mac 环境变量与 shell

背景 最近在 mac 上安装了 nginx 用来跑本地 web 服务。 但是启动的时候需要切换到 nginx 的安装目录下,然后执行命令才能启动。比如: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 进入 nginx 目录 cd /usr/local/nginx # 启动 nginx 服务 sudo sbin/nginx # 退出 nginx sudo sbin/nginx -s quit # 强制停止 nginx sudo sbin/nginx -s stop # 重启 nginx sudo sbin/nginx -s reload 可以看到每次操作 nginx 都需要切到对应目录才能执行命令,挺不方便的。 有没有更便捷的方法呢?当然是有的,那就是使用环境变量。 环境变量 环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数,如临时文件夹位置、系统文件夹位置以及某些应用软件文件的路径等等。环境变量相当于给系统或用户应用程序设置的一些参数,具体起什么作用这当然和具体的环境变量相关。 比如 Path,是告诉系统,当要求系统运行一个程序而没有告诉它程序所在的完整路径时,系统除了在当前目录下面寻找此程序外,还应到哪些目录下去寻找。 环境变量的配置文件 设置的环境变量需要生效,就必须要存起来,存到配置文件里。 在 Mac 中环境变量的配置文件有好几种类型。 系统级别 系统级别配置文件,系统启动就会加载,修改需要 Root 权限。 /etc/paths :任何用户登陆时都会读取该文件,全局建议修改这个文件 。 /etc/profile:为系统的每个用户设置环境信息和启动程序,其配置对所有登录的用户都有效,一般不建议修改该文件。 用户级别 ~/.bash_profile:为当前用户设置专属的环境信息和启动程序,当用户登录时该文件执行一次。默认情况下,它用于设置环境变量,并执行当前用户的 .bashrc 文件,一般用户级环境变量会放到这个文件。 ~/.bash_login 和 ~/.profile:这三个文件也是依次执行的,如果 bash_profile 文件存在,则后面的两个文件就会被忽略不读了,如 bash_profile 文件不存在,才会以此类推读取后面的文件。 shell 打开时加载 /etc/bashrc 或 /etc/zshrc:系统级配置,为每个运行 bash/zhs shell 的用户执行该文件,当 bash/zhs shell 打开时,该文件被执行,其配置对所有使用 bash 的用户打开的每个 bash 都有效。 ~/.bashrc 或 ~/.zshrc:用户级配置,作用同上。它是 bash/zsh shell 打开的时候载入的,对当前用户打开的每个 bash 都有效。 如果系统默认 shell 使用的是 zsh 而不是 sh、bash,那么 zsh 是不加载 .bash_profile 文件的,而是加载 .zshrc。source .zshrc 在 zsh 环境下读取配置文件。 shell 上面多个地方提到了 shell,环境变量也是需要运行在 shell 上,那 shell 到底是什么呢? shell 的概念 Shell 这个单词的原意是“外壳”,跟 kernel(内核)相对应,比喻内核外面的一层,即用户跟内核交互的对话界面。 具体来说,Shell 这个词有多种含义。 首先,Shell 是一个程序,提供一个与用户对话的环境。这个环境只有一个命令提示符,让用户从键盘输入命令,所以又称为命令行环境(command line interface,简写为 CLI)。Shell 接收到用户输入的命令,将命令送入操作系统执行,并将结果返回给用户。本书中,除非特别指明,Shell 指的就是命令行环境。 其次,Shell 是一个命令解释器,解释用户输入的命令。它支持变量、条件判断、循环操作等语法,所以用户可以用 Shell 命令写出各种小程序,又称为脚本(script)。这些脚本都通过 Shell 的解释执行,而不通过编译。 最后,Shell 是一个工具箱,提供了各种小工具,供用户方便地使用操作系统的功能。 以上这个 shell 概念来自阮一峰的 《Bash 脚本教程》 shell 的种类 不同的程序员写出的程序风格不同,有的喜欢这么写,有的喜欢那样写,所以就形成了不同的规范,就有了多种类型的 shell 了,就好像每个人喜欢穿不同的衣服一样。 历史上,主要的 Shell 有下面这些。 Bourne Shell(sh) Bourne Again shell(bash) C Shell(csh) TENEX C Shell(tcsh) Korn shell(ksh) Z Shell(zsh) Friendly Interactive Shell(fish) 随着时间的推移,每个 mac 系统会内置一些不同的 shell,那么如何查看你的 mac 安装了哪些 shell 呢?其实它的答案藏在了 /etc/shells 这个文件里: 1 2 3 4 5 6 7 8 9 10 cat /etc/shells # 我的 mac 电脑内置的 shell /bin/bash /bin/csh /bin/dash /bin/ksh /bin/sh /bin/tcsh /bin/zsh 如何查看默认使用的是哪个 shell 呢? 1 2 3 4 5 6 7 # 当前系统默认的 shell echo $SHELL > /bin/zsh # 当前进程正在使用的 shell echo $0 > zsh 如果你想更改系统默认的 shell,你可以这么做: 1 2 # 以下将把 bash 设置为 默认 shell chsh -s /bin/bash 配置环境变量 通过上面的介绍,你知道了你系统默认的 shell 是什么了。 我的是 zsh,那我就可以把我的环境变量保存在 ~/.zshrc 配置文件里。 1 2 3 4 5 # 同过 vim 打开 ~/.zshrc 文件 vim ~/.zshrc # 配置 nginx 的环境变量 $PATH export PATH=$PATH:/usr/local/nginx/sbin 通过 vim 编辑保存该文件后,想让环境变量立即生效还需要执行: 1 2 # 该命令的作用是读取并且执行该文件脚本 source ~/.zshrc 或者,可以把你的环境变量存在 ~/.bash_profile 里,然后在 ~/.zshrc 最后一行加上 source ~/.bash_profile,这样对于默认 shell 是 zsh 的用户来说,就可以让 ~/.bash_profile 里的配置生效了。 环境变量配置好后,就可以测试是否生效了。 1 2 # 如果没有报错,那就说明 nginx 的环境变量已经生效了 sudo nginx

2024/6/14
articleCard.readMore

如何反编译微信小程序

什么是反编译 微信小程序可以运行在手机微信或者 pc 版微信环境下,它是以一个应用包的形式存在的,这个应用包的后缀通常是 .wxapkg。如果想知道某个微信小程序的功能模块是如何实现的,就需要把这个包给解开,这个过程就叫反编译。 获取小程序包 要反编译微信小程序,那首先就需要获取微信小程序的应用包。 可以在 pc 版本微信上获取包,那么就需要在 pc 版微信上先运行某个小程序;运行完成后,就会在电脑某个目录下生成这个小程序的包,我们需要找到这个目录,那么就找到了这个包了。 windows 和 mac 环境不同,包的位置也会不一样。 windows 系统 打开微信设置-文件管理-就能看到微信的目录了,继续往下找到 /applet 这个目录,就能看到小程序的包了。示例:D:\软件\WeChat Files\WeChat Files\Applet\小程序 AppID__APP__.wxapkg mac 系统 包通常是被放在:/System/Volumes/Data/Users/你的用户名/Library/Containers/com.tencent.xinWeChat/Data/.wxapplet/packages/小程序 AppID/随机数/APP.wxapkg 知道了目录后,就可以拿到包了。 在 mac 下进入某个目录可以这样做: 访达-前往-前往文件夹-然后把目录路径贴到输入框,选择某个路径就可以进去了; 或者在终端用命令:open 目录路径; 如果 mac 系统的包不在刚刚说的目录下,还有一种方式可以找到。那就是通过微信小程序 AppID 来搜索目录。那就需要先知道某个微信小程序的 AppID 是什么。 可以通过如下这张图的操作路径来获取 AppID: 拿到了 AppID 后,就可以通过终端里输入命令来进行搜索了: 1 find / -name 某个AppID 当搜索到这种路径的时候就说明找到了,就可以停止搜索了。 反编译 拿到了小程序应用包,接下来就需要通过代码把这个包给解开,然后还原成可以跑在微信开发者工具上的微信小程序项目代码了。 反编译使用的是 github 上的开源项目:wxappUnpacker 把这个项目 clone 到本地后,安装好依赖,就可以使用如下命令进行反编译了: 1 node wuWxapkg.js /System/Volumes/Data/Users/你的用户名/Library/Containers/com.tencent.xinWeChat/Data/.wxapplet/packages/小程序AppID/随机数/__APP__.wxapkg 由于这个库的程序比较旧了,而小程序的项目结构或者语法再时时更新,所以在解包的时候有可能会报错,这个不用管。 解包完成后,会在 APP.wxapkg 的同级目录生成一个 APP 的文件夹,这个文件夹就是微信开发者工具的可识别的项目目录。把它复制到外面,好让微信开发者工具方便导入,导入项目过程中的 AppID 使用测试号即可。 在微信开发者加载代码的过程中,正常不会很顺利,我自己反编译了几个小程序也都会有问题。比如如下图: 问题一:require(…)() is not a function 比如如下文件 miniprogram_npm/@vant/weapp/wxs/utils.wxs 中的 require(…)() 改成 require(…) 即可。 1 2 3 4 5 6 7 8 9 10 11 - var bem = require('p_./miniprogram_npm/@vant/weapp/wxs/bem.wxs')(); + var bem = require('p_./miniprogram_npm/@vant/weapp/wxs/bem.wxs'); - var memoize = require('p_./miniprogram_npm/@vant/weapp/wxs/memoize.wxs')(); + var memoize = require('p_./miniprogram_npm/@vant/weapp/wxs/memoize.wxs'); - var addUnit = require('p_./miniprogram_npm/@vant/weapp/wxs/add-unit.wxs')(); + var addUnit = require('p_./miniprogram_npm/@vant/weapp/wxs/add-unit.wxs'); module.exports = ({ bem: memoize(bem), memoize: memoize, addUnit: addUnit, }); 问题二:_typeof2 is not a function 这个错误文件是 @babel/runtime/helpers/typeof.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 function _typeof2(o) { "@babel/helpers - typeof"; return (_typeof2 = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; })(o); } function _typeof(o) { return ( "function" == typeof Symbol && "symbol" === _typeof2(Symbol.iterator) ? (module.exports = _typeof = function (o) { return _typeof2(o); }) : (module.exports = _typeof = function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : _typeof2(o); }), _typeof(o) ); } module.exports = _typeof; 其他一些别的错误,需要看报错信息对应改一下,这里就不再多介绍了。

2024/6/12
articleCard.readMore

mac 终端命令大全

“终端” App 可让高级用户和开发者通过命令行界面 (CLI) 与 Mac 操作系统进行通信。你可以输入命令和脚本(称为 shell 脚本)在 Mac 上执行任务。 Mac 终端键盘快键键 目录操作 命令功能描述举例 mkdir创建一个目录mkdir dirname rmdir删除一个目录rmdir dirname mvdir移动或重命名一个目录mvdir dir1 dir2 cd改变当前目录cd dirname pwd显示当前目录的路径名pwd ls显示当前目录的内容ls -la dircmp比较两个目录的内容dircmp dir1 dir2 文件操作 命令名功能描述使用举例 cat显示或连接文件cat filename pg分页格式化显示文件内容pg filename more分屏显示文件内容more filename cp复制文件或目录cp file1 file2 rm删除文件或目录rm filename mv改变文件名或所在目录mv file1 file2 ln联接文件ln -s file1 file2 find使用匹配表达式查找文件find . -name “*.c” -print file显示文件类型file filename open使用默认的程序打开文件open filename 选择操作 命令功能描述举例 head显示文件的最初几行head -20 filename tail显示文件的最后几行tail -15 filename cut显示文件每行中的某些域cut -f1,7 -d: /etc/passwd colrm从标准输入中删除若干列colrm 8 20 file2 paste横向连接文件paste file1 file2 diff比较并显示两个文件的差异diff file1 file2 sed非交互方式流编辑器sed “s/red/green/g” filename grep在文件中按模式查找grep “a” filename awk在文件中查找并处理模式awk ‘{print $1 $1}’ filename sort排序或归并文件sort -d -f -u file1 uniq去掉文件中的重复行uniq file1 file2 commcomm file1 file2 wc统计文件的字符数、词数和行数wc filename nl给文件加上行号nl file1 >file2 安全操作 命令名功能描述使用举例 passwd修改用户密码passwd chmod改变文件或目录的权限chmod ug+x filename umask定义创建文件的权限掩码umask 027 chown改变文件或目录的属主chown newowner filename chgrp改变文件或目录的所属组chgrp staff filename xlock给终端上锁xlock -remote 编程操作 命令名功能描述使用举例 make维护可执行程序的最新版本make touch更新文件的访问和修改时间touch -m 05202400 filename dbx命令行界面调试工具dbx a.out xde图形用户界面调试工具xde a.out 进程操作 命令名功能描述使用举例 ps显示进程当前状态ps u kill终止进程kill -9 30142 nice改变待执行命令的优先级nice cc -c *.c renice改变已运行进程的优先级renice +20 32768 时间操作 命令名功能描述使用举例 date显示系统的当前日期和时间date cal显示日历cal 8 1996 time统计程序的执行时间time a.out 网络与通信操作 命令名功能描述使用举例 telnet远程登录telnet hpc.sp.net.edu.cn rlogin远程登录rlogin hostname -l username rsh在远程主机执行指定命令rsh f01n03 date ftp在本地主机与远程主机之间传输文件ftp ftp.sp.net.edu.cn rcp在本地主机与远程主机 之间复制文rcp file1 host1:file2 ping给一个网络主机发送 回应请求ping hpc.sp.net.edu.cn mail阅读和发送电子邮件mail write给另一用户发送报文write username pts/1 mesg允许或拒绝接收报文mesg n Korn Shell 命令 命令名功能描述使用举例 history列出最近执行过的 几条命令及编号history r重复执行最近执行过的 某条命令r -2 alias给某个命令定义别名alias del=rm -i unalias取消对某个别名的定义unalias del 其他命令 命令名功能描述使用举例 uname显示操作系统的有关信息uname -a clear清除屏幕或窗口内容clear env显示当前所有设置过的环境变量env who列出当前登录的所有用户who whoami显示当前正进行操作的用户名whoami tty显示终端或伪终端的名称tty stty显示或重置控制键定义stty du查询磁盘使用情况du -k subdir df显示文件系统的总空间和可用空间df /tmp w显示当前系统活动的总信息w

2024/6/6
articleCard.readMore

深入学习 TypeScript 类型体操

模式匹配做提取 数组类型 提取数组第一个元素的类型 1 2 3 type GetFirst<Arr extends unknown[]> = Arr extends [infer First, ...unknown[]] ? First : never; 提取数组最后一个元素的类型 1 2 3 type GetLast<Arr extends unknown[]> = Arr extends [...unknown[], infer Last] ? Last : never; 取去掉最后一个元素的数组 1 2 3 4 5 type PopArr<Arr extends unknown[]> = Arr extends [] ? [] : Arr extends [...infer Rest, unknown] ? Rest : never; 字符串类型 字符串是否以某个特定字符开头 1 2 3 4 type StartWidth< Str extends string, Prefix extends String > = Str extends `${Prefix}${string}` ? true : false; 将字符串中某个特定的部分替换成别的字符串 1 2 3 4 5 6 7 type ReplaceStr< Str extends string, From extends string, To extends string > = Str extends `${infer Prefix}${From}${infer Suffix}` ? `${Prefix}${To}${Suffix}` : Str; 字符串去除右空格 1 2 3 4 5 6 type TrimStrRight<Str extends string> = Str extends `${infer Rest}${ | " " | "\n" | "\t"}` ? TrimStrRight<Rest> : Str; 函数类型 提取函数参数的类型 1 2 3 4 5 type GetParameters<Func extends Function> = Func extends ( ...args: infer Args ) => unknown ? Args : never; 提取函数返回值的类型 1 2 3 4 5 type GetReturnType<Func extends Function> = Func extends ( ...args: any[] ) => infer ReturnType ? ReturnType : never; 构造器 提取构造器参数的类型 1 2 3 4 type GetInstanceParameters<ConstructorType extends new (...args: any) => any> = ConstructorType extends new (...args: infer ParametersType) => any ? ParametersType : never; 提取构造器返回值类型 1 2 3 4 type GetInstanceReturnType<ConstructorType extends new (...args: any) => any> = ConstructorType extends new (...args: any) => infer InstanceType ? InstanceType : never; 索引类型 提取 props 中 ref 值的类型 1 2 3 4 5 type GetRefProps<Props> = "ref" extends keyof Props ? Props extends { ref?: infer Value | undefined } ? Value : never : never; 重新构造做变换 TypeScript 的 type、infer、类型参数声明的变量都不能修改,想对类型做各种变换产生新的类型就需要重新构造。 数组类型的构造 给数组/元组添加新类型 1 type Push<Arr extends unknown[], Ele> = [...Arr, Ele]; 元组重组 1 2 3 4 5 6 7 8 9 10 11 12 13 14 type tuple1 = [1, 2]; type tuple2 = ["guang", "dong"]; // 重组成如下的元组 type tuple = [[1, "guang"], [2, "dong"]]; // 代码实现 type Zip<One extends unknown[], Other extends unknown[]> = One extends [ infer OneFirst, ...infer OneRest ] ? Other extends [infer OtherFirst, ...infer OtherRest] ? [[OneFirst, OtherFirst], ...Zip<OneRest, OtherRest>] : [] : []; 字符串类型的构造 将字符串首字母大写 1 2 3 4 type CapitalizeStr<Str extends string> = Str extends `${infer First}${infer Rest}` ? `${Uppercase<First>}${Rest}` : Str; 将字符串下划线转驼峰 ```tstype CamelCase<Str extends string> = Str extends${infer Left}_${infer Right}${infer Rest}?${Left}${Uppercase}${CamelCase}`Str; 1 2 3 4 5 6 7 8 **删除字符串子串** ```ts type DropSubStr<Str extends string, SubStr extends string> = Str extends `${infer Prefix}${SubStr}${infer Suffix}` ? DropSubStr<`${Prefix}${Suffix}`, SubStr> : Str; 函数类型的构造 给函数添加一个参数 1 2 3 4 5 type AppendArgument<Func extends Function, Arg> = Func extends ( ...args: infer Args ) => infer ReturnType ? (...args: [...Args, Arg]) => ReturnType : never; 索引类型的构造 索引类型是聚合多个元素的类型。比如 class 和对象都是索引类型。索引类型的元素的类型只能是 string、number 或者 Symbol 等类型。 索引类型的每个元素的类型可以添加修饰符:readonly(只读)、?(可选)。 映射类型语法 1 2 3 type Mapping<Obj extends object> = { [Key in keyof Obj]: Obj[Key]; }; 用 as 做重映射改变索引类型的 Key 转成大写 1 2 3 type UppercaseKey<Obj extends object> = { [Key in keyof Obj as Uppercase<Key & string>]: Obj[Key]; }; 因为这里索引的类型可能是 string、number 或 symbol 类型,但是这里转成大写只能是限定为 string。 TS 内置高级类型 Record 1 type Record<K extends string | number | symbol, T> = { [P in K]: T }; UppercaseKey 重写版:用 Record 来约束索引类型而不是 object 1 2 3 type UppercaseKey<Obj extends Record<string, any>> = { [Key in keyof Obj as Uppercase<Key & string>]: Obj[Key]; }; 给索引类型添加只读的高级类型 1 2 3 type ToReadonly<T> = { readonly [Key in keyof T]: T[Key]; }; 给索引类型添加可选的高级类型 1 2 3 type ToPartial<T> = { [Key in keyof T]?: T[Key]; }; 给索引类型去掉只读修饰符 1 2 3 type ToMutable<T> = { -readonly [Key in keyof T]: T[Key]; }; 给索引类型去掉可选修饰符 1 2 3 type ToRequired<T> = { [Key in keyof T]-?: T[Key]; }; 返回特定值的类型的索引类型 1 2 3 type FilterByValueType<Obj extends Record<string, any>, ValueType> = { [Key in keyof Obj as Obj[Key] extends ValueType ? Key : never]: Obj[Key]; }; 递归复用做循环 Promise 的递归复用 提取 Promise 值的类型 1 2 3 4 5 6 7 type DeepPromiseValueType<P extends Promise<unknown>> = P extends Promise< infer ValueType > ? ValueType extends Promise<unknown> ? DeepPromiseValueType<ValueType> : ValueType : never; 提取 Promise 值的类型简化版 1 2 3 type DeepPromiseValueType<T> = T extends Promise<infer ValueType> ? DeepPromiseValueType<ValueType> : never; 数组类型的递归 反转元组 1 2 3 4 5 6 type ReversrArr<Arr extends unknown[]> = Arr extends [ infer First, ...infer Rest ] ? [...ReversrArr<Rest>, First] : Arr; 查找元素 1 2 3 4 5 6 7 8 9 10 type Includes<Arr extends unknown[], FindItem> = Arr extends [ infer First, ...infer Rest ] ? IsEqual<First, FindItem> extends true ? true : Includes<Rest, FindItem> : false; type IsEqual<A, B> = (A extends B ? true : false) & (B extends A ? true : false); 删除元素 1 2 3 4 5 6 7 8 9 10 11 type RemoveItem< Arr extends unknown[], Item, Result extends unknown[] = [] > = Arr extends [infer First, ...infer Rest] ? IsEqual<First, Item> extends true ? RemoveItem<Rest, Item, Result> : RemoveItem<Rest, Item, [...Result, First]> : Result; type IsEqual<A, B> = (A extends B ? true : false) & (B extends A ? true : false); 构造指定类型的数组 1 2 3 4 5 type BuildArray< Length extends number, Ele = unknown, Arr extends unknown[] = [] > = Arr["length"] extends Length ? Arr : BuildArray<Length, Ele, [...Arr, Ele]>; 字符串类型的递归 替换子串 1 2 3 4 5 6 7 type ReplaceAll< Str extends string, From extends string, To extends string > = Str extends `${infer Left}${From}${infer Right}` ? `${Left}${To}${ReplaceAll<Right, From, To>}` : Str; 提取字符做联合类型 1 2 3 4 type StringToUnion<Str extends string> = Str extends `{infer First}${infer Rest}` ? First | StringToUnion<Rest> : never; 反转字符串 1 2 3 type ReverseStr<Str extends string> = Str extends `${infer First}${infer Rest}` ? `${ReverseStr<Rest>}${First}` : Str; 对象类型的递归 深度递归 1 2 3 4 5 6 7 8 9 type DeepToReadonly<T extends Record<string, any>> = T extends any ? { readonly [Key in keyof T]: T[Key] extends Object ? T[Key] extends Function ? T[Key] : DeepToReadonly<T[Key]> : T[Key]; } : never; 数组长度做计算 数组长度实现加减乘除 加法 1 2 3 4 5 6 7 8 9 10 type BuildArray< Length extends number, Ele = unknown, Arr extends unknown[] = [] > = Arr["length"] extends Length ? Arr : BuildArray<Length, Ele, [...Arr, Ele]>; type Add<Num1 extends number, Num2 extends number> = [ ...BuildArray<Num1>, ...BuildArray<Num2> ]["length"]; 减法 1 2 3 4 5 6 type Subtract< Num1 extends number, Num2 extends number > = BuildArray<Num1> extends [...BuildArray<Num2>, ...infer Rest] ? Rest["length"] : never; 乘法 1 2 3 4 5 6 7 type Multiple< Num1 extends number, Num2 extends number, ResultArr extends unknown[] = [] > = Num2 extends 0 ? ResultArr["length"] : Multiple<Num1, Subtract<Num2, 1>, [...ResultArr, ...BuildArray<Num1>]>; 除法 1 2 3 4 5 6 7 type Divide< Num1 extends number, Num2 extends number, ResultArr extends unknown[] = [] > = Num1 extends 0 ? ResultArr["length"] : Divide<Subtract<Num1, Num2>, Num2, [...ResultArr, unknown]>; 数组长度实现计数 计算字符串长度 1 2 3 4 5 6 type StrLen< Str extends string, CountArr extends unknown[] = [] > = Str extends `${string}${infer Rest}` ? StrLen<Rest, [...CountArr, unknown]> : CountArr["length"]; 比较 2 个数值谁更大 1 2 3 4 5 6 7 8 9 10 11 type GreaterThan< Num1 extends number, Num2 extends number, CountArr extends unknown[] = [] > = Num1 extends Num2 ? false : CountArr["length"] extends Num2 ? true : CountArr["length"] extends Num1 ? false : GreaterThan<Num1, Num2, [...CountArr, unknown]>; 实现斐波那契数列 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 type FibonacciLoop< PrevArr extends unknown[], CurrentArr extends unknown[], IndexArr extends unknown[] = [], Num extends number = 1 > = IndexArr["length"] extends Num ? CurrentArr["length"] : FibonacciLoop< CurrentArr, [...PrevArr, ...CurrentArr], [...IndexArr, unknown], Num >; type Fibonacci<Num extends number> = FibonacciLoop<[1], [], [], Num>; 聚合分散可简化 分布式条件类型 当类型参数为联合类型,并且在条件类型左边直接引用该类型参数的时候,TypeScript 会把每一个元素单独传入来做类型运算,最后再合并成联合类型,这种语法叫做分布式条件类型。 1 2 3 4 type Union = "a" | "b" | "c"; type UppercaseA<Item extends string> = Item extends "a" ? Uppercase<Item> : Item; 这和联合类型遇到字符串时的处理一样: 1 2 3 type Union = "a" | "b"; type str = `${Union}~`; // type str = 'a~' | 'b~'; 数组转联合类型 1 2 3 type Arr = ["a", "b", "c"]; type UnionArr = Arr[number]; // type UnionArr = 'a' | 'b' | 'c'; 判断是否是联合类型 1 type isUnion<A, B = A> = A extends A ? ([B] extends [A] ? false : true) : never; 当 A 是联合类型时: A extends A 这种写法是为了触发分布式条件类型,让每个类型单独传入处理的,没别的意义。 A extends A 和 [A] extends [A] 是不同的处理,前者是单个类型和整个类型做判断,后者两边都是整个联合类型,因为只有 extends 左边直接是类型参数才会触发分布式条件类型。 BEM 1 2 3 4 5 type BEM< Block extends string, Element extends string[], Modifiers extends string[] > = `${Block}__${Element[number]}--${Modifiers[number]}`; 全组合 1 2 3 4 5 6 7 8 9 // A 和 B 的全组合 type Combination<A extends string, B extends string> = A | B | `${A}${B}` | `${B}${A}` // 全组合 type AllCombinations<A extends string, B extends string = A> = A extens A ? Combination<A, AllCombinations<Exclude<B, A>>> : never; 特殊特性要记清 IsAny any 类型与任何类型的交叉都是 any,也就是 1 & any 结果是 any。 1 type IsAny<T> = "a" extends 1 & T ? true : false; IsEqual 1 2 3 4 5 6 7 8 9 10 // 以下这种写法不能判断 isAny,isEqual<'a', any> 会返回 true type IsEqual<A, B> = (A extends B ? true : false) & (B extends A ? true : false); // 以下这个可以判断 IsEqual2<'a', any> 会返回 false type IsEqual2<A, B> = (<T>() => T extends A ? 1 : 2) extends < T >() => T extends B ? 1 : 2 ? true : false; IsUnion 1 type IsUnion<A, B> = A extends A ? ([B] extends [A] ? false : true) : never; IsNever never 在条件类型中也比较特殊,如果条件类型左边是类型参数,并且传入的是 never,那么直接返回 never。 1 2 3 4 5 6 type TestNever<T> = T extends number ? 1 : 2; // 如下会返回 never type result = TestNever<never>; // 正确的 IsNever type IsNever<T> = [T] extends [never] ? true : false; 除此之外,any 在条件类型中也比较特殊,如果类型参数为 any,会直接返回 trueType 和 falseType 的合并。 1 2 3 type TestAny<T> = T extends number ? 1 : 2; // 如下会返回 1 | 2 type result = TestAny<any>; IsTuple 元组类型的 length 是数字字面量,而数组的 length 是 number。 1 2 3 4 5 6 7 8 9 type IsTuple<T> = T extends [...infer Eles] ? NotEqual<Ele["length"], number> : false; type NotEqual<A, B> = (<T>() => T extends A ? 1 : 2) extends < T >() => T extends B ? 1 : 2 ? false : true; UnionToIntersection 联合类型转交叉类型。 1 2 3 4 5 type UnionToIntersecion<U> = (U extends U ? (x: U) => unknown : never) extends ( x: infer R ) => unknown ? R : never; GetOptional 提取索引类型中的可选索引。 1 2 3 4 5 6 type GetOptional<Obj extends Record<string, any>> = { [Key in keyof Obj as {} extends Pick<Obj, Key> ? Key : never]: Obj[Key]; }; // Pick 是 TS 内置高级类型 type Pick<T, K extends keyof T> = { [P in K]: T[P] }; GetRequired 提取索引类型中的非可选索引构造成新的索引类型。 1 2 3 type GetRequired<Obj extends Record<string, any>> = { [Key in keyof Obj as {} extends Pick<Obj, Key> ? never : Key]: Obj[Key]; }; RemoveIndexSignature 过滤掉索引类型中的可索引签名,构造成一个新的索引类型。 索引签名的特性:索引签名不能构造成字符串字面量类型,因为它没有名字,而其他索引可以 1 2 3 type RemoveIndexSignature<Obj extends Record<string, any>> = { [Key in keyof Obj as Key extends `${infer Str}` ? Str : never]: Obj[Key]; }; ClassPublicProps 过滤 class 的 public 属性。 根据特性:keyof 只能拿到 class 的 public 索引,private 和 protected 的索引会被忽略。 1 2 3 type ClassPublicProps<Obj extends Record<string, any>> = { [Key in keyof Obj]: Obj[Key]; }; as const TypeScript 默认推导出来的类型并不是字面量类型。 1 2 3 4 5 6 7 8 9 const obj = { a: 1, b: 2, }; type objType = typeof obj; // type objType = { // a: number; // b: number // } 如果想要推到出字面量,就需要用 as const: 1 2 3 4 5 6 7 const arr = [1, 2, 3]; type arrType = typeof arr; // type arrType = number[]; const arr2 = [1, 2, 3] as const; type arrType2 = typeof arr2; // type arrType2 = readonly [1, 2, 3]; 反转 3 个元素的元组类型,需要加上 readonly 才能匹配成功。 1 2 3 type ReverseArr<Arr> = Arr extends readonly [infer A, infer B, infer C] ? [C, B, A] : never; 练一练 实现 ParseQueryString 将 ‘a=1&b=2&c=3’ 转成 {a: 1, b: 2, c: 3} 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 type ParseQueryString<Str extends string> = Str extends `${infer Param}&${infer Rest}` ? MergeParams<ParseParams<Param>, ParseQueryString<Rest>> : ParseParam<Str>; type ParseParams<Param extends string> = Param extends `${infer Key}=${infer Value}` ? { [K in Key]: Value; } : Record<string, any>; type MergeParams< OneParam extends Record<string, any>, OtherParam extends Record<string, any> > = { [Key in keyof OneParam | keyof OtherParam]: Key extends keyof OneParam ? Key extends keyof OtherParam ? MergeValue<OneParam[Key], OtherParam[Key]> : OneParam[Key] : Key extends keyof OtherParam ? OtherParam[Key] : never; }; type MergeValue<One, Other> = One extends Other ? One : Other extends unknown[] ? [One, ...Other] : [One, Other]; TS 内置的高级类型 Parameters 提取函数类型的参数类型 1 2 3 4 5 type Parameters<T extends (...args: any) => any> = T extends ( ...args: infer P ) => any ? P : never; ReturnType 提取函数类型的返回值类型 1 2 3 4 5 type ReturnType<T extends (...args: any) => any> = T extends ( ...args: any ) => infer R ? R : never; ConstructorParameters 提取构造函数的参数类型 1 2 3 4 5 type ConstructorParameters<T extends new (...ars: any) => any> = T extends new ( ...args: infer P ) => any ? P : never; InstanceType 提取构造器返回值类型 1 2 3 4 5 type InstanceType<T extends new (...ars: any) => any> = T extends new ( ...ars: any ) => infer R ? R : any; ThisParameterType 提取函数参数中 this 的类型 1 2 3 type ThisParameterType<T> = T extends (this: infer U, ...args: any[]) => any ? U : unknown; OmitThisParameter 去除函数参数中的 this 类型,并且返回一个新的类型 1 2 3 4 5 6 type OmitThisParameter<T> = unknown extends ThisParameterType<T> ? T : T extends (...args: infer A) => infer R ? (...args: A) => infer R : T; Partial 把索引类型的所有索引变成可选类型 1 2 3 type Partial<T> = { [P in keyof T]?: T[P]; }; Required 把索引类型里可选索引改成必选索引 1 2 3 type Required<T> = { [P in keyof T]-?: T[P]; }; Readonly 索引类型的索引添加只读 1 2 3 type Readonly<T> = { readonly [P in keyof T]: T[P]; }; Pick 过滤出指定的索引类型 1 2 3 type Pick<T, K extends keyof T> = { [P in K]: T[P]; }; Record 创建索引类型 keyof any 会返回 string | number | symbol 1 2 3 type Record<K extends keyof any, T> = { [P in K]: T; }; 如果 Record 里的第一个参数是 string | number | symbol,那么创建的就是索引签名索引类型: 1 2 3 4 type RecordRes = Record<string, number>; // RecordRes = { // [x: string]: number; // } Exclude 去掉联合类型中的某些类型,即取差集 联合类型当作为类型参数出现在条件类型左边时,会被分散成单个类型传入,这叫做分布式条件类型。 1 type Exclude<T, U> = T extends U ? never : T; Extract 提取联合类型中的某些类型,即取交集 1 type Extract<T, U> = T extends U ? T : never; Omit 去掉某部分索引类型的索引构成新索引类型 1 type Omit<T, K in keyof any> = Pick<T, EXclude<keyof T, K>>; Awaited 提取 Promise 的返回值类型 1 2 3 4 5 6 7 8 type Awaited<T> = T extends null | undefined ? T : T extends object & { then(onfulfilled: infer F): any} ? F extends ((value, infer V, ...ars: any) => any) ? Awaited<V> : never : T; NonNullable 判断是否是空类型,即不是 null 或 undefined 1 type NonNullable<T> = T extends null | unfefined ? never : T; Uppercase、Lowercase、Capitalize、Uncapitalize 这几个类型分别是实现大写、小写、首字母大写、去掉首字母大写的。 他们的实现是直接用 js 实现的。 综合实战 KebabCaseToCamelCase ‘aa-bb-cc’ 这种是 KebabCase,而 ‘aaBbCc’ 这种是 CamelCase 1 2 3 4 type KebabCaseToCamelCase<Str extends string> = Str extends `${infer Item}-${infer Rest}` ? `${Item}${KebabCaseToCamelCase<Capitalize<Rest>>}` : Str; CamelCaseToKebabCase 1 2 3 4 5 6 type CamelCaseToKebabCase<Str extends string> = Str extends `${infer First}${infer Rest}` ? First extends Lowercase<First> ? `${First}${CamelCaseToKebabCase<Rest>}` : `-${Lowercase<First>}${CamelCaseToKebabCase<Rest>}` : Str; Chunk 对数组做分组,比如 1、2、3、4、5 的数组,每两个为 1 组,那就可以分为 1、2 和 3、4 以及 5 这三个 Chunk。 1 2 3 4 5 6 7 8 9 10 type Chunk< Arr extends unknown[], ItemLen extends number, CurItem extends unknown[] = [], Res extends unknown[] = [] > = Arr extends [infer First, ...infer Rest] ? CurItem["length"] extends ItemLen ? Chunk<Rest, ItemLen, [First], [...Res, CurItem]> : Chunk<Rest, ItemLen, [...CurItem, First], Res> : [...Res, CurItem]; TupleToNestedObject 根据数组类型,比如 [‘a’, ‘b’, ‘c’] 的元组类型,再加上值的类型 ‘xxx’,构造出这样的索引类型: 1 2 3 4 5 6 7 { a: { b: { c: "xxx"; } } } 1 2 3 4 5 6 7 8 9 10 11 12 type TupleToNestedObject<Tuple extends unknown[], ValueType> = Tuple extends [ infer First, ...infer Rest ] ? { [Key in First as Key extends keyof any ? Key : never]: Rest extends unknown[] ? TupleToNestedObject<Rest, ValueType> : ValueType; } : ValueType; PartialObjectPropByKeys 把一个索引类型的某些 Key 转为 可选的,其余的 Key 不变。 1 2 3 4 5 6 7 8 9 10 11 type PartialObjectPropByKeys< Obj extends Record<string, any>, Key extends keyof any > = Partial<Pick<Obj, Extract<keyof Obj, Key>>> & Omit<Obj, Key>; type PartialObjectPropByKeys2< Obj extends Record<string, any>, KeyType extends keyof any > = { [Key in keyof Obj as Key extends KeyType ? Key? : Key]: Obj[Key]; }; 函数重载的三种写法 第一种 1 2 3 4 5 function add(a: number, b: number): number; function add(a: string, b: string): string; function add(a: any, b: any) { return a + b; } 第二种 1 2 3 4 5 interface Func { (a: number, b: number): number; (a: string, b: string): string; } const add: Func = (a: any, b: any) => a + b; 第三种 1 2 type Func = ((a: number, b: number) => number) & ((a: string, b: string): string) const add: Func = (a: any, b: any) => a + b; UnionToTuple 将联合类型转成元组。 1 2 3 4 5 6 7 8 9 10 11 12 type UnionToTuple<T> = UnionToIntersection< T extends any ? () => T : never > extends () => infer ReturnType ? [...UnionToTuple<Exclude<T, ReturnType>>, ReturnType] : []; // 联合转交叉 type UnionToIntersection<U> = ( U extends U ? (x: U) => unknown : never ) extends (x: infer R) => unknown ? R : never; join 实现一个类似的效果,将: 1 2 const res = join("-")("guang", "and", "dong"); // 转成 res = 'guang-and-dong' join 代码实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 declare function join<Delimiter extends string>( delimiter: Delimiter ): <Items extends string[]>(...parts: Items) => JoinType<Items, Delimiter>; type JoinType< Items extends any[], Delimiter extends string, Result extends string = "" > = Items extends [infer First, ...infer Rest] ? JoinType<Rest, Delimiter, `${Result}${First & string}-`> : RemoveLastDelimiter<Result>; type RemoveLastDelimiter<Str extends string> = Str extends `${infer Rest}-` ? Rest : Str; AllKeyPath 拿到一个索引类型的所有 key 的路径。 1 2 3 4 5 6 7 type AllKeyPath<Obj extends Record<string, any>> = { [Key in keyof Obj]: Key extends string ? Obj[Key] extends Record<string, any> ? Key | `${Key}.${AllKeyPath<Obj[Key]>}` : Key : never; }[keyof Obj]; Defaultize 实现这样一个高级类型,对 A、B 两个索引类型做合并,如果是只有 A 中有的不变,如果是 A、B 都有的就变为可选,只有 B 中有的也变为可选。 1 2 3 type Defaultize<A, B> = Pick<A, Exclude<keyof A, keyof B>> & Partial<Pick<A, Extract<keyof A, keyof B>>> & Partial<Pick<B, Exclude<keyof B, keyof A>>>; infer extends 枚举值转联合类型 以下会把枚举的数值类型转成字符串类型。 1 2 3 4 5 6 7 enum Code { a = 111, b = 222, c = "abc", } type res = `${Code}`; // res = '111' | '222' | 'abc' StrToNum 使用 infer extends 后就就可以正常使用了。 1 2 3 4 5 6 7 8 enum Code { a = 111, b = 222, c = "abc", } type StrToNum<Str> = Str extends `${infer Num extends number}` ? Num : Str; type res = StrToNum<`${Code}`>; // res = 'abc' | 111 | 222

2024/2/18
articleCard.readMore

微前端的几种架构介绍

什么是微前端 而提到微前端就离不开微服务,大家对微服务都比较熟悉了,微服务允许后端体系结构通过松散耦合的代码库进行扩展,每个代码库负责自己的业务逻辑,并公开一个 API,每个 API 均可独立部署,并且各自由不同的团队拥有和维护。 前端架构经历了从单体,到前后端分离,再到微服务,最终发展到现在的微前端的过程如下图所示: 微前端的思路是把微服务的架构引入到前端,其核心都是要能够以业务为单元构建端到端的垂直架构,使得单个的团队能够独立自主的进行相关的开发,同时又具备相当的灵活性,按需求来组成交付应用。 “微前端”一词最早于 2016 年底在 ThoughtWorks 技术雷达中提出的。它将微服务的概念扩展到了前端世界。微前端的核心思路其实是远程应用程序,包含组件/模块/包的运行时加载。 如上图,对于用户而言,访问的是一个微前端的容器(container),容器加载运行在远程服务上的应用,把这些远程应用作为组件/模块/包在本地浏览器中加载。 组件是底层 UI 库的构建单元; 模块是相应运行时的构建单元; 包是依赖性解析器的构建单元; 微前端是所提出的应用程序的构建块。 上面说了很多,总结一下就是:微前端(Micro-Frontends)是一种类似于微服务的架构,他将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署、独立运行。微前端不是单纯的前端框架或者工具,而是一套架构体系。 为什么需要微前端 在前面我们看到的微前端之前的架构,所有的前端还是一个单体,前端团队会依赖所有的服务或者后台的 API,前端开发会成为整个系统的瓶颈。使用微前端,就是要让前端业务从水平分层变为垂直应用的一部分,进入业务团队,剥离耦合。 那么微前端有什么好处,为什么要采用微前端架构呢? 各个团队独立开发,相互不影响,独立开发、独立部署,微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新; 增量升级,在面对各种复杂场景时,通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略。因为是运行时加载,可以在没有重建的情况下添加,删除或替换前端的各个部分; 不受技术影响,每个团队都应该能够选择和升级其技术栈,而无需与其他团队进行协调。也就是说 A 应用可以用 React,而 B 应用使用 Vue,大家可以通过同一个基座应用来加载; 独立运行时,每个微应用之间状态隔离,运行时状态不共享。隔离团队代码,即使所有团队都使用相同的框架,也不要共享运行时。构建自包含的独立应用程序。不要依赖共享状态或全局变量; 建立团队命名空间,对于 CSS,事件,本地存储和 Cookies,可以避免冲突并阐明所有权。 因此,微前端和微服务的本质都是关于去耦合。而只有当应用程序达到一定规模时,这才开始变得更有意义。 如何实现微前端架构 微前端不是一个库,是一种前端架构的设计思路,要实现微前端,本质上就是在运行时远程加载应用。如下列了一些实现方案,但不仅仅只是这样: 纯 nginx 路由转发; 使用 iframe 创建容器; 组合式应用路由分发; 使用 Web Components 技术构建; Module Federation; 纯 nginx 路由转发 即通过路由将不同的业务分发到不同的、独立前端应用上。其通常可以通过 HTTP 服务器的反向代理来实现。这种方案,不涉及前端的改造,完全是依靠运维层面的配置。 当在浏览器里访问 www.nrp.com/app1 的时候,其实访问的是 app1 这个应用;当访问 www.nrp.com/app2 的时候,其实访问的是 app2 这个应用。要实现这个功能,就可以用 nginx 的反向代理来实现路由分发: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 http { server { listen 80; server_name www.nrp.com; location / { root /www/wwwroot/www.nrp.com/; index index.html index.htm; } location /app1 { proxy_pass http://www.app1.com/; } location /app2 { proxy_pass http://www.app2.com/; } } } 通过 http 服务分发不同的路由到不同的独立应用上,虽然实现上很简单,但是缺点也相当明显: 每一次切换应用的时候都会重新请求资源,没办法做到局部更新当前页面,就相当于是刷新了浏览器,完全丢失了单页应用的体验; 需要配置一个通用可扩展的路由规则,否则当引入新的应用的时候,还需要修改 nginx 的路由配置; 使用 iframe 创建容器 HTML 内联框架元素 <iframe> 表示嵌套的正在浏览的上下文,能有效地将另一个 HTML 页面嵌入到当前页面中。 1 <iframe src="http://www.baidu.com/"></iframe> iframe 可以创建一个全新的独立的宿主环境,这意味着我们的前端应用之间可以相互独立运行。通过给 iframe 的 src 指定不同的地址,来实现加载不同的子应用。 1 2 3 4 5 6 7 <template v-for="app in appList"> <iframe v-show="currAppCode == app.code" :key="app.code" :src="app.src" ></iframe> </template> 使用 iframe 来加载不同的微应用,接入非常简单,且由于 iframe 天然的沙箱环境使得 js 和样式隔离都非常完美。但他存在以下一些问题: 页面加载问题:iframe 和主页面共享连接池,而浏览器对相同域的连接有限制,所以会影响页面的并行加载,阻塞 onload 事件。相比于 SPA 加载会更慢; 布局问题:iframe 必须给一个指定的高度,否则会塌陷。另外有些时候会出现多个滚动条,用户体验不佳; 弹窗及遮罩层问题:弹窗只能在 iframe 区域进行展示,没办法在浏览器视口里显示,导致的问题是,弹窗遮罩无法覆盖浏览器视口且弹窗位置可能没有垂直水平居中; 浏览器记录和前进后退问题:iframe 和主页面共用浏览器记录,导致前进后退的时候不能切换不同的应用;刷新页面无法保存当前状态,比如访问一个微应用的列表页,当从列表页点击进入详情,此时刷新浏览器,会加载列表页 组合式应用路由分发 这个方案和第一种方案很像,都是需要通过 nginx 来将路由分发到不同的应用上,可以认为是它的升级版,区别是这种方式的系统在运行时将由主应用来进行路由管理,子应用的加载、启动、卸载以及通信都需要依靠主应用来完成。 这种方式的代表开源框架就是 qiankun。 作为微前端的基座应用,是整个应用的入口,负责承载当前微应用的展示和对其他路由微应用的转发,对于当前微应用的展示,一般是由以下几步构成: 作为一个 SPA 的基座应用,本身是一套纯前端项目,要想展示微应用的页面除了采用 iframe 之外,要能先拉取到微应用的页面内容,这就需要远程拉取机制。 远程拉取实施的前提是需要知道拉取的地址是什么?所以就需要先在主应用里集成一套微应用的管理机制,比如说当浏览器地址匹配 /app1 的时候就去加载 app1 应用的内容,而 app1 的地址其实是动态配置到本地的映射,最终将指向一个可以独立访问的域名。 有了拉取地址之后,通常会采用 fetch API 来首先获取到微应用的 HTML 内容。然后通过解析将微应用的 JavaScript 和 CSS 进行抽离,采用 eval 方法来运行 JavaScript,并将 CSS 和 HTML 内容 append 到基座应用中留给微应用的展示区域,当微应用切换走时,可以在主应用里同步卸载这些内容,这就构成的当前应用的展示流程。 对于路由分发而言,以采用 vue-router 开发的基座 SPA 应用来举例,主要是下面这个流程: 当浏览器的路径变化后,vue-router 会监听 hashchange 或者 popstate 事件,从而获取到路由切换的时机。 最先接收到这个变化的是基座的 router,通过查询注册信息可以获取到转发到那个微应用,经过一些逻辑处理后,采用修改 hash 方法或者 pushState 方法来路由信息推送给微应用的路由,微应用可以是手动监听 hashchange 或者 popstate 事件接收,或者采用 React-router,vue-router 接管路由,后面的逻辑就由微应用自己控制。 使用 Web Components 技术构建 Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的 Web 应用中使用它们。 它主要有三项技术组件: Custom elements(自定义元素):一组 JavaScript API,允许您定义 custom elements 及其行为,然后可以在您的用户界面中按照需要使用它们。 Shadow DOM(影子 DOM):一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。 HTML templates(HTML 模板): <template> 和 <slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。 这里有一个在线的 Web Components 示例 随后,在各自的 HTML 文件里,创建相应的组件元素,编写相应的组件逻辑。一个典型的 Web Components 应用架构如下图所示: 可以看到这边方式与我们上面使用 iframe 的方式很相似,组件拥有自己独立的 Scripts 和 Styles,以及对应的用于单独部署组件的域名。然而它并没有想象中的那么美好,要直接使用纯 Web Components 来构建前端应用的难度有: 重写现有的前端应用。是的,现在我们需要完成使用 Web Components 来完成整个系统的功能。 上下游生态系统不完善。缺乏相应的一些第三方控件支持,这也是为什么 jQuery 相当流行的原因。 系统架构复杂。当应用被拆分为一个又一个的组件时,组件间的通讯就成了一个特别大的麻烦。 Web Components 中的 ShadowDOM 更像是新一代的前端 DOM 容器。而遗憾的是并不是所有的浏览器,都可以完全支持 Web Components。 MicroApp 借鉴了 Web Component 的思想,通过 CustomElement 结合自定义的 ShadowDom,将微前端封装成一个类 Web Component 组件,从而实现微前端的组件化渲染。 Module Federation Module Federation 是 webpack 5 中的一个新特性,能轻易实现在两个使用 webpack 构建的项目之间共享代码,通俗点讲,Module Federation 提供了能在当前应用加载其他应用的能力。这就为实现微前端提供了另一种可能。 EMP 就是基于这个特性研发出来的微前端解决方案。 对于 Module Federation,它有几个概念: local module(本地模块):就是普通模块,对于某个项目而言,每次构建就都是产生本地模块的过程; remote module(远程模块):远程模块不属于当前构建,并在运行时从所谓的容器加载;加载远程模块被认为是异步操作,通常可以通过调用 import() 实现; container(容器):每个构建都充当一个容器,也可将其他构建作为容器。通过这种方式,每个构建都能够通过从对应容器中加载模块来访问其他容器暴露出来的模块。 shared module(共享模块):共享模块是指既可重写的又可作为向嵌套容器提供重写的模块。它们通常指向每个构建中的相同模块,例如相同的库。 所以,当前模块想要加载其他模块,就要有一个引入动作,同样,如果想让其他模块使用,就需要有一个导出动作。通过以下 2 个 webpack 插件配置参数可以实现模块的导入和导出。 expose:导出应用,被其他应用导入; remote:引入其他应用; 这与基座模式完全不同,像 iframe 和 qiankun 都是需要一个基座(中心容器)去加载其他子应用。而 Module Federation 任意一个模块都可以引用其他应用,同时也可以导出被其他应用使用,这就没有了容器中心的概念。 想要使用 Module Federation 功能需要引入 webpack 5 中内置的插件 ModuleFederationPlugin,通过配置该插件来完成。比如 base 应用需要引入 expose 应用里导出的 HelloWorld 模块,可以这样配置: expose 的 vue.config.js: 1 2 3 4 5 6 7 8 9 10 11 12 new webpack.container.ModuleFederationPlugin({ name: "app_expose", filename: "remoteEntry.js" exposes: { "./HelloWorld.vue": "./src/components/HelloWorld.vue", }, shared: { vue: { singleton: true, }, }, }), base 应用的 vue.config.js: 1 2 3 4 5 6 7 8 9 10 11 12 new webpack.container.ModuleFederationPlugin({ name: "app_base", filename: "remoteEntry.js", remotes: { app_expose: "app_expose@http://localhost:8082/remoteEntry.js", }, shared: { vue: { singleton: true, }, }, }), 使用 expose 应用的 HelloWorld 模块: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <template> <div class="home"> <img alt="Vue logo" src="../assets/logo.png" /> <HelloWorld msg="Welcome to Your Vue.js" /> </div> </template> <script lang="js"> import { defineComponent } from "vue"; import HelloWorld from "app_expose/HelloWorld.vue"; export default defineComponent({ name: 'HomeView', components: { HelloWorld }, }) </script> webpack 5 与之前版本的模块管理对比图: 微前端的问题和缺点 讲了这么多的优点和实现,那么微前端是不是解决前端开发问题的银弹呢?当然不是。所有的架构都是取舍和权衡,这个世界上并不存在银弹,微前端架构和微服务一样也存在他的弊端,单体架构未必就差。 微前端的构建通常比较复杂,从工具,打包,到部署,微前端都是更为复杂的存在,天下没有免费的午餐,对于小型项目,它的成本太高; 每个团队可以使用不同的框架,这个听上去很美,但是实际操作起来,除了要支持历史遗留的应用,它的意义不大。同时也为带来体验上的问题。可以远程加载不同的框架代码是一回事,把它们都用好是另一回事; 性能上来看,如果优化得不好微前端的性能可能会存在问题,至少微前端框架是额外的一层加载。如果不同的微前端使用了不同的框架,那么每一个框架都需要额外的加载; 微前端架构还在不断发展之中,本文提到的 nginx / iframe/ qiankun/Web Components / Module Federation 只是诸多解决方案中的一小部分,前端的发展变化和生态系统实在是丰富,相信未来会有更多以及更好用的微前端架构的出现。

2024/2/2
articleCard.readMore

git clone 的时候如何通过 SSH 链接来下载远程项目

当我们需要从 github 下载项目的时候,它提供了 2 种下载链接: 1 2 https://github.com/ant-design/ant-design.git git@github.com:ant-design/ant-design.git 上面 2 种下载链接分别使用了 HTTPS 和 SSH 协议。那么这两种协议有什么区别呢? 访问控制:SSH 协议使用公钥和私钥来进行身份验证,因此你需要在远程主机上安装你的公钥。只有拥有私钥的用户才能进行身份验证和访问。相反,HTTPS 协议使用用户名和密码进行身份验证。git clone 或 git fetch 的时候,如果使用 HTTPS 链接则不需要进行身份验证,而 SSH 链接需要。 传输速度:SSH 协议在传输数据时进行压缩,因此它可以在传输大量数据时比 HTTPS 更快。此外,SSH 协议通常可以在传输数据时使用更少的带宽,这对于连接速度较慢的网络非常有用。 防火墙限制: 某些公司或组织可能会在防火墙中阻止对 SSH 端口的访问,这可能会使 SSH 协议无法使用。另一方面,HTTPS 协议通常可以通过防火墙,因为它使用标准的 Web 端口 80 和 443。 想要通过 SSH 链接来下载项目,需要在客户端和服务器之间配置 SSH key,那么如何做呢?下面以 github 为例来说明具体步骤。 1. 在本地生成 SSH key 1 ssh-keygen -t ed25519 -C "user@example.com" 这里我把 user@example.com 替换成了我的 github.com 的邮箱。执行完后可能会有如下提示,不用管直接按回车即可。 1 2 Generating public/private ed25519 key pair. Enter file in which to save the key (/home/johndoe/.ssh/id_ed25519): 1 2 Enter passphrase (empty for no passphrase): Enter same passphrase again: 最后你会发现会打印这么一串信息: 1 2 3 4 5 6 7 8 9 10 11 12 13 SHA256:ToTEp33dDV8Sokslnx568DC5ABPQTmvlBjGx+r/W0k8 user@example.com The key's randomart image is: +--[ED25519 256]--+ |o.+o .. | |. +o....+ | | +.= .o. | |o.+ o . . . | |o+ . . .So . | |*. oo+ o . | |o*. o =E= o | |ooo.o =..o o | |.o+o++ .. | +----[SHA256]-----+ 然后你就会发现在 .ssh 目录下多了 2 个文件: 1 2 $ ls ~/.ssh id_ed25519 id_ed25519.pub 2. 为你的 github 账户添加一个公共的 key 上面已经在本地生成好了 SSH key,现在就要把这个 key 的内容贴到 github 账户里。 执行如下命令后,会在终端打印 key 的内容,可以选中它们然后复制。 1 cat ~/.ssh/id_ed25519.pub 然后按照如下顺序把 key 的内容贴到 github 的账户里。 Log in to your GitHub account. Navigate to “Settings”. Click on “SSH and GPG keys” in the left menu. Click on the “New SSH key” button. 3. 测试是否可用 接下来就可以找个 github 仓库,复制 SSH 的地址进行 clone 了: 1 git clone git@github.com:ant-design/ant-design.git --depth=1 4. 为 SSH 代理指定 SSH key 如果您不想每次 git 使用 SSH 键时输入密码,则可以将键添加到 SSH 代理管理的键列表中。 1 eval "$(ssh-agent -s)" 然后: 1 ssh-add ~/.ssh/id_ed25519 但是每次电脑重启后,如上设置就会失效,需要重新设置。

2023/10/11
articleCard.readMore

从 ESLint 开始,说透我如何在团队项目中基于 Vue 做代码校验

最近遇到了一个老项目,比较有意思的是这个项目集前后端的代码于一起,而后端也会去修改前端代码,所以就出现了后端用 IntelliJ IDEA 来开发前端项目,而前端用 VSCode 来开发前端项目的情况。 于是乎,出现了代码规范的问题,所以就有了这篇文章,整理了一下前端代码校验以及在 Vue 项目中的实践。 阅读完这篇文章,你可以收获: 能够自己亲手写出一套 ESLint 配置; 会知道业界都有哪些著名的 JS 代码规范,熟读它们可以让你写出更规范的代码; vue-cli 在初始化一个包含代码校验的项目时都做了什么; Prettier 是什么?为什么要使用它?如何与 ESLint 配合使用? EditorConfig 又是什么?如何使用? 如何在 VSCode 中通过插件来协助代码校验工作; 如何保证 push 到远程仓库的代码是符合规范的; 下面开始阅读吧,如果你对 ESLint 比较熟悉,可以直接跳过这个部分。 ESLint 是什么 ESLint 是一个集代码审查和修复的工具,它的核心功能是通过配置一个个规则来限制代码的合法性和风格。 配置解析器和解析参数 ESLint 的解析器,早期的时候用的是 Esprima,后面基于 Esprima v1.2.2 版本开发了一个新的解析器 Espree,并且把它当做默认解析器。 除了使用 ESLint 自带的解析器外,还可以指定其他解析器: @babel/eslint-parser:使 Babel 和 ESLint 兼容,对一些 Babel 语法提供支持; @typescript-eslint/parser:TSLint 被弃用后,TypeScript 提供了此解析器用于将其与 ESTree 兼容,使 ESLint 对 TypeScript 进行支持; 为项目指定某个选择器的原则是什么? 如果你的项目用到了比较新的 ES 语法,比如 ES2021 的 Promise.any(),那就可以指定 @babel/eslint-parser 为解析器; 如果项目是基于 TS 开发的,那就使用 @typescript-eslint/parser; 如果你对 ES 最新标准还不熟悉,可以看看这篇文章:送你一份精心总结的 3 万字 ES6 实用指南(下) 除了指定解析器 parser 外,还可以额外配置解析器参数 parserOption: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { // ESLint 默认解析器,也可以指定成别的 parser: "espree", parserOption: { // 指定要使用的 ECMAScript 版本,默认值 5 ecmaVersion: 5, // 设置为 script (默认) 或 module(如果你的代码是 ECMAScript 模块) sourceType: "script", // 这是个对象,表示你想使用的额外的语言特性,所有选项默认都是 false ecmafeatures: { // 是否允许在全局作用域下使用 return 语句 globalReturn: false, // 是否启用全局 strict 模式(严格模式) impliedStrict: false, // 是否启用JSX jsx: false, // 是否启用对实验性的objectRest/spreadProperties的支持 experimentalObjectRestSpread: false } } } 指定环境 env 指定不同的环境可以给对应环境下提供预设的全局变量。比如说在 browser 环境下,可以使用 window 全局变量;在 node 环境下,可以使用 process 全局变量等; ESLint 中可配置的环境比较多,这里有份完整的环境列表,下面列出几个比较常见的: browser:浏览器全局变量; node:Node.js 全局变量和作用域; es6:es6 中除了模块之外的其他特性,同时将自动设置 parserOptions.ecmaVersion 参数为 6;以此类推 ES2017 是 7,而 ES2021 是 12; es2017:parserOptions.ecmaVersion 为 8; es2020:parserOptions.ecmaVersion 为 11; es2021:parserOptions.ecmaVersion 为 12; 配置方式如下: 1 2 3 4 5 6 7 8 9 10 { env: { browser: true, node: true, es6: true, commonjs: true, mocha: true, jquery: true, } } 可以指定多个环境并不意味着配置的环境越多越好,实际配置的时候还是得依据当前项目的环境来选择。 配置全局变量 globals ESLint 的一些核心规则依赖于对代码在运行时可用的全局变量的了解。 由于这些在不同环境之间可能会有很大差异,并且在运行时会进行修改,因此 ESLint 不会假设你的执行环境中存在哪些全局变量。 如果你想使用这些全局变量,那就可以通过 globals 来指定。比如在 react .eslintrc.js 里就把 spyOnDev、 spyOnProd 等变量挂在了 global 下作为全局变量: 1 2 3 4 5 6 { globals: { spyOnDev: true, spyOnProd: true, } } 对于它的值需要特别说明下: false、readable、readonly 这 3 个是等价的,表示变量只可读不可写; true、writeable、writable 这 3 个是等价的,表示变量可读可写; 配置扩展 extends 实际项目中配置规则的时候,不可能团队一条一条的去商议配置,太费精力了。通常的做法是使用业内大家普通使用的、遵循的编码规范;然后通过 extends 去引入这些规范。extends 配置的时候接受字符串或者数组: 1 2 3 4 5 6 7 8 9 { extends: [ 'eslint:recommended', 'plugin:vue/essential', 'eslint-config-standard', // 可以缩写成 'standard' '@vue/prettier', './node_modules/coding-standard/.eslintrc-es6' ] } 从上面的配置,可以知道 extends 支持的配置类型可以是以下几种 eslint 开头的:是 ESLint 官方的扩展; plugin 开头的:是插件类型扩展,比如 plugin:vue/essential; eslint-config 开头的:来自 npm 包,使用时可以省略前缀 eslint-config-,比如上面的可以直接写成 standard; @开头的:扩展和 eslint-config 一样,只是在 npm 包上面加了一层作用域 scope; 一个执行配置文件的相对路径或绝对路径; 那有哪些常用的、比较著名扩展可以被 extends 引入呢 eslint:recommended:ESLint 内置的推荐规则,即 ESLint Rules 列表中打了钩的那些规则; eslint:all:ESLint 内置的所有规则; eslint-config-standard:standard 的 JS 规范; eslint-config-prettier:关闭和 ESLint 中以及其他扩展中有冲突的规则; eslint-config-airbnb-base:airbab 的 JS 规范; eslint-config-alloy:腾讯 AlloyTeam 前端团队出品,可以很好的针对你项目的技术栈进行配置选择,比如可以选 React、Vue(现已支持 Vue 3.0)、TypeScript 等; 使用插件 plugins ESLint 提供插件是干嘛用的 ESLint 虽然可以定义很多的 rules,以及通过 extends 来引入更多的规则,但是说到底只是检查 JS 语法。如果需要检查 Vue 中的 template 或者 React 中的 jsx,就束手无策了。 所以引入插件的目的就是为了增强 ESLint 的检查能力和范围。 如何配置插件 ESLint 相关的插件的命名形式有 2 种:不带命名空间的和带命名空间的,比如: eslint-plugin- 开头的可以省略这部分前缀; @/ 开头的; 1 2 3 4 5 6 7 { plugins: [ 'jquery', // 是指 eslint-plugin-jquery '@jquery/jquery', // 是指 @jquery/eslint-plugin-jquery '@foobar', // 是指 @foobar/eslint-plugin ] } 当需要基于插件进行 extends 和 rules 的配置的时候,需要加上插件的引用,比如: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 { plugins: [ 'jquery', // eslint-plugin-jquery '@foo/foo', // @foo/eslint-plugin-foo '@bar, // @bar/eslint-plugin ], extends: [ 'plugin:jquery/recommended', 'plugin:@foo/foo/recommended', 'plugin:@bar/recommended' ], rules: { 'jquery/a-rule': 'error', '@foo/foo/some-rule': 'error', '@bar/another-rule': 'error' }, } 以上配置来自 ESLint plugins 配置规则 rules ESLint 提供了大量内置的规则,这里是它的规则列表 ESLint Rules,除此之外你还可以通过插件来添加更多的规则。 规则的校验说明,有 3 个报错等级 off 或 0:关闭对该规则的校验; warn 或 1:启用规则,不满足时抛出警告,且不会退出编译进程; error 或 2:启用规则,不满足时抛出错误,且会退出编译进程; 通常规则只需要配置开启还是关闭即可;但是也有些规则可以传入属性,比如: 1 2 3 4 5 6 7 8 9 10 { rules: { 'quotes': ['error', 'single'], // 如果不是单引号,则报错 'one-var': ['error', { 'var': 'always', // 每个函数作用域中,只允许 1 个 var 声明 'let': 'never', // 每个块作用域中,允许多个 let 声明 'const': 'never', // 每个块作用域中,允许多个 const 声明 }] } } 如何知道某个扩展有哪些规则可以配置,以及每个规则具体限制? 这里直接给出业内著名且使用比较多的规则列表的快速链接: ESLint rules,这整个列表对应 eslint:all,而打钩 ✔️ 的是 eslint:recommenmed; Prettier rules standard rules airbnb rules AlloyTeam vue rules 规则的优先级 如果 extends 配置的是一个数组,那么最终会将所有规则项进行合并,出现冲突的时候,后面的会覆盖前面的; 通过 rules 单独配置的规则优先级比 extends 高; 其他配置 配置当前目录为 root ESLint 检测配置文件步骤: 在要检测的文件同一目录里寻找 .eslintrc.* 和 package.json; 紧接着在父级目录里寻找,一直到文件系统的根目录; 如果在前两步发现有 root:true 的配置,停止在父级目录中寻找 .eslintrc; 如果以上步骤都没有找到,则回退到用户主目录 ~/.eslintrc 中自定义的默认配置; 通常我们都习惯把 ESLint 配置文件放到项目根目录,因此可以为了避免 ESLint 校验的时候往父级目录查找配置文件,所以需要在配置文件中加上 root: true。 1 2 3 { root: true, } 添加共享数据 ESLint 支持在配置文件添加共享设置,你可以添加 settings 对象到配置文件,它将提供给每一个将被执行的规则。如果你想添加的自定义规则而且使它们可以访问到相同的信息,这将会很有用,并且很容易配置: 1 2 3 4 5 { settings: { sharedData: 'Hello' }, } 参考:ESLint 配置文件.eslintrc 参数说明 针对个别文件设置新的检查规则 比如 webpack 的中包含了某些运行时的 JS 文件,而这些文件是只跑在浏览器端的,所以需要针对这部分文件进行差异化配置: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 overrides: [ { files: ['lib/**/*.runtime.js', 'hot/*.js'], env: { es6: false, browser: true, }, globals: { Promise: false, }, parserOptions: { ecmaVersion: 5, }, }, ] 以上配置来自 webpack .eslintrc.js 如何校验 上面细说了 ESLint 的各种配置项,以及针对 Vue 项目如何进行差异配置的说明。 现在我们知道了如何配置,但是你知道这些配置都是配置到哪里的吗? 配置方式 ESLint 支持 3 种配置方式: 命令行:不推荐,不做介绍; 单文件内注释:不推荐,不做介绍; 配置文件:配置文件的类型可以是好几种,比如:.js、.yml、json 等。推荐使用 .eslintrc.js; 下面通过命令来生成一个配置文件: 1 2 3 4 5 # 安装 eslint npm i eslint -D # 初始化一个配置文件 npx eslint --init 最后会在当前目录生成一个 .eslintrc.js 文件。这里就不把代码贴出来了,没参考意义。 上面我们知道了可以将配置统一写到一个配置文件里,但是你知道该如何去触发这个配置文件的校验规则嘛? 校验单个文件 1 2 3 4 5 // 校验 a.js 和 b.js npx eslint a.js b.js // 校验 src 和 scripts 目录 npx eslint src scripts 校验别的类型的文件 通常 ESLint 只能校验 JS 文件。比如需要校验 .vue 文件,光配置 vue 插件和 vue-eslint-parser 解析器是不够的,还需要让 ESLint 在查找文件的时候找到 .vue 文件。 可以通过 –ext 来指定具体需要校验的文件: 1 npx eslint --ext .js,.jsx,.vue src 自动修复部分校验错误的代码 rules 列表项中标识了一个扳手 🔧 图案的规则就标识该规则是可以通过 ESLint 工具自动修复代码的。 如何自动修复呢?通过 –fix 即可。比如对于 ESLint Rules 里的这个 semi 规则,它就是带扳手图案的。 对于如下的 a.js 代码: 1 const num = 12 当在配置文件配置了 ‘semi’: [2, ‘always’] 后,运行命令: 1 npx eslint --fix a.js 校验直接就通过了,且会自动修复代码,在代码末尾自动加上分号。 把校验命令加到 package.json 检验命令比较长,也难记,习惯上会把这些命名直接写到 package.json 里: 1 2 3 4 5 6 { "scripts": { "lint": "npx eslint --ext .js,.jsx,.vue src", "lint:fix": "npx eslint --fix --ext .js,.jsx,.vue src" } } 过滤一些不需要校验的文件 对于一些公共的 JS、测试脚本或者是特定目录下的文件习惯上是不需要校验的,因此可以在项目根目录通过创建一个 .eslintignore 文件来配置,告诉 ESLint 校验的时候忽略它们: 1 2 public/ src/main.js 除了 .eslintignore 中指定的文件或目录,ESLint 总是忽略 /node_modules/ 和 /bower_components/ 中的文件;因此对于一些目前解决不了的规则报错,但是如果又急于打包上线,在不影响运行的情况下,我们就可以利用 .eslintignore 文件将其暂时忽略。 在 Vue 项目中的实践 上面把 ESLint 的几乎所有的配置参数和校验方式都详细的介绍了一遍,但是如果想在项目中落地,仅仅靠上面的知识还是不够的。下面将细说如何在 Vue 中落地代码校验。 关于如何在 Vue 中落地代码校验,一般是有 2 种情况: 通过 vue-cli 初始化项目的时候已经选择了对应的校验配置 对于一个空的 Vue 项目,想接入代码校验 其实这 2 种情况最终的校验的核心配置都是一样的,只是刚开始的时候安装的包有所区别。下面通过分析 vue-cli 配置的代码校验,来看看它到底做了哪些事情,通过它安装的包以及包的作用,我们就会知道如何在空项目中配置代码校验了。 通过 vue-cli 初始化的项目 如果你的项目最初是通过 vue-cli 新建的,那么在新建的时候会让你选 是否支持 eslint; 是否开启保存校验; 是否开启提交前校验; 如果都开启了话,会安装如下几个包: eslint:前面 2 大章节介绍的就是这玩意,ESLint 出品,是代码校验的基础包,且提供了很多内置的 Rules,比如 eslint:recommended 经常被作为项目的 JS 检查规范被引入; babel-eslint:一个对 Babel 解析器的包装,使其能够与 ESLint 兼容; lint-staged:请看后面 pre-commit 部分; @vue/cli-plugin-eslint eslint-plugin-vue 下面重点介绍 @vue/cli-plugin-eslint 和 eslint-plugin-vue,说下这 2 个包是干嘛的。 @vue/cli-plugin-eslint 这个包它主要干了 2 件事情: 第一件事 往 package.json 里注册了一个命令: 1 2 3 4 5 { "scripts": { "lint": "vue-cli-service lint" } } 执行这个命令之后,它会去检查和修复部分可以修复的问题。默认查找的文件是 src 和 tests 目录下所有的 .js,.jsx,.vue 文件,以及项目根目录下所有的 js 文件(比如,也会检查 .eslintrc.js)。 当然你也可以自定义的传入参数和校验文件: 1 vue-cli-service lint [options] [...files] 支持的参数如下: –no-fix: 不会修复 errors 和 warnings; –max-errors [limit]:指定导致出现 npm ERR 错误的最大 errors 数量; 第二件事 增加了代码保存触发校验的功能 lintOnSave,这个功能默认是开启的。如果想要关闭这个功能,可以在 vue.config.js 里配置,习惯上只开启 development 环境下的代码保存校验功能: 1 2 3 module.exports = { lintOnSave: process.env.NODE_ENV === 'development', } lintOnSave 参数说明: true 或者 warning:开启保存校验,会将 errors 级别的错误在终端中以 WARNING 的形式显示。默认的,WARNING 将不会导致编译失败; false:不开启保存校验; error:开启保存校验,会将 errors 级别的错误在终端中以 ERROR 的形式出现,会导致编译失败,同时浏览器页面变黑,显示 Failed to compile。 eslint-plugin-vue eslint-plugin-vue 是对 .vue 文件进行代码校验的插件。 针对这个插件,它提供了这几个扩展 plugin:vue/base:基础 plugin:vue/essential:预防错误的(用于 Vue 2.x) plugin:vue/recommended:推荐的,最小化任意选择和认知开销(用于 Vue 2.x); plugin:vue/strongly-recommended:强烈推荐,提高可读性(用于 Vue 2.x); plugin:vue/vue3-essential:(用于 Vue 3.x) plugin:vue/vue3-strongly-recommended:(用于 Vue 3.x) plugin:vue/vue3-recommended:(用于 Vue 3.x) 各扩展规则列表:vue rules 看到这么一堆的扩展,是不是都不知道选哪个了 代码规范的东西,原则还是得由各自的团队去磨合商议出一套适合大家的规则。不过,如果你用的是 Vue2,我这里可以推荐 2 套 extends 配置: 1 2 3 4 5 6 7 { // Vue 官方示例上的配置 extends: ['eslint:recommended', 'plugin:vue/recommended'], // 或者使用 AlloyTeam 团队那套 extends: ['alloy', 'alloy/vue'] } 配置和插件对应的解析器 如果是 Vue 2.x 项目,配置了 eslint-plugin-vue 插件和 extends 后,template 校验还是会失效,因为不管是 ESLint 默认的解析器 Espree 还是 babel-eslint 都只能解析 JS,无法解析 template 的内容。 而 vue-eslint-parser 只能解析 template 的内容,但是不会解析 JS,因此还需要对解析器做如下配置: 1 2 3 4 5 6 7 8 9 10 11 12 13 { parser: 'vue-eslint-parser', parserOptions: { parser: 'babel-eslint', ecmaVersion: 12, sourceType: 'module' }, extends: [ 'eslint:recommended', 'plugin:vue/recommended' ], plugins: ['vue'] } 参考:eslint-plugin-vue faq 让 Prettier 管控代码风格 针对 Prettier 不得不提出以下疑问? Prettier 是什么? 为什么有了 ESLint,还需要引入 Prettier 呢?它两之间有什么区别? 如何配置 Prettier? Prettier 如何和 ESLint 结合使用? Prettier 是什么 用它自己的话来说:我是一个自以为是的代码格式化工具,而且我支持的文件类型很多,比如: JavaScript(包括实验中的特性) JSX Vue TypeScript CSS、Less、SCSS HTML JSON Markdown 以及还有一些其他类型的文件。 Prettier 对比 ESLint 我们知道 ESLint 负责了对代码的校验功能,并且主要提供了 2 类规则: 检查格式化的规则 检查代码质量的规则 说到底 ESLint 就是通过一条条的规则去限制代码的规范,但是这些规则毕竟是有限的,而且更重要的是这些规则的重点并不在代码风格上,所以单凭 ESLint 并不能完全的统一代码风格。 这个时候就需要引入 Prettier 了,因为它干的事就是只管代码格式化,不管代码质量。 Prettier:在代码风格这一块,我一直拿捏的死死的。 如何配置 Prettier 初始化操作: 1 2 3 4 5 6 7 8 # 安装包 npm i prettier -D # 新建 .prettierrc.js echo module.exports = {} > .prettierrc.js # 新建 .prettierignore echo > .prettierignore Prettier 支持可以配置参数不多,总共才 21 个,这里是所有参数的说明 prettier options 所有参数都有默认值,也就是说即使你没有配置 .prettierrc.js,当你用 Prettier 去格式化代码的时候全部都会走默认配置。针对个别参数,你不想用默认设置的话,就可以在 .prettierrc.js 配置具体想要的值。 如下,把项目中会用到的参数进行一个说明: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 module.exports = { printWidth: 80, //(默认值)单行代码超出 80 个字符自动换行 tabWidth: 2, //(默认值)一个 tab 键缩进相当于 2 个空格 useTabs: true, // 行缩进使用 tab 键代替空格 semi: false, //(默认值)语句的末尾加上分号 singleQuote: true, // 使用单引号 quoteProps: 'as-needed', //(默认值)仅仅当必须的时候才会加上双引号 jsxSingleQuote: true, // 在 JSX 中使用单引号 trailingComma: 'all', // 不用在多行的逗号分隔的句法结构的最后一行的末尾加上逗号 bracketSpacing: true, //(默认值)在括号和对象的文字之间加上一个空格 jsxBracketSameLine: true, // 把 > 符号放在多行的 JSX 元素的最后一行 arrowParens: 'avoid', // 当箭头函数中只有一个参数的时候可以忽略括弧 htmlWhitespaceSensitivity: 'ignore', // vue template 中的结束标签结尾尖括号掉到了下一行 vueIndentScriptAndStyle: false, //(默认值)对于 .vue 文件,不缩进 <script> 和 <style> 里的内容 embeddedLanguageFormatting: 'auto', //(默认值)允许自动格式化内嵌的代码块 } 扩展阅读:关于 Trailing commas 你或许想了解更多。 然后可以通过命令来格式化代码: 1 2 3 4 5 # 将格式化当前目录及子目录下所有文件 npx prettier --write . # 检查某个文件是否已经格式化 npx prettier --check src/main.js 如果有些文件不想被 Prettier 格式化,可以将其写入到 .prettierignore 里: 1 2 3 4 build/ package.json public/ test/*.* Prettier 和 ESLint 一起干活更配哦 上面介绍了 Prettier 的具体配置,这里主要介绍和 ESLint 结合使用的配置和注意事项。 和 ESLint 配合使用需要用到 eslint-plugin-prettier 这个插件: 1 npm i eslint-plugin-prettier -D 配置: 1 2 3 4 5 6 { plugins: ['prettier'], rules: { 'prettier/prettier': 'error' } } 这个插件的工作原理是先调用 Prettier 对你的代码进行格式化,然后会把格式化前后不一致的地方进行标记,通过配置 ‘prettier/prettier’: ‘error’ 此条规则会将标记地方进行 error 级别的报错提示,然后可以通过 ESLint 的 –fix 自动修复功能将其修复。 冲突了怎么办 通过前面的介绍,我们知道 ESLint 也是会对代码风格做一些限制的,而 Prettier 主要就是规范代码风格,所以在把它们结合一起使用的时候是存会在一些问题的。对于个别规则,会使得双方在校验后出现代码格式不一致的问题。 那么当 Prettier 和 ESLint 出现冲突之后,该怎么办呢? 用 Prettier 的话来说很简单,只要使用 eslint-config-prettier 就可以了。解决冲突的思路就是通过将这个包提供的扩展放到 extends 最后面引入,依据 rules 生效的优先级,所以它会覆盖前面起冲突的规则,比如: 1 2 3 4 5 6 { extends: [ 'eslint:recommended', 'prettier', // 必须放最后 ], } 除了能覆盖和 ESLint 中起冲突的规则之外,eslint-config-prettier 还能覆盖来自以下插件的规则(只列了部分): eslint-plugin-standard eslint-plugin-vue 那 eslint-config-prettier 到底提供了哪些覆盖规则呢?直接看这个列表:eslint-config-prettier rules 如果想覆盖某些插件的规则,需要引入对应插件的扩展,比如: 1 2 3 4 5 6 7 8 { extends: [ 'standard', 'plugin:vue/recommended', 'prettier/standard', // 覆盖 eslint-config-stanard 'prettier/vue', // 覆盖 eslint-plugin-vue ], } 提示:在 eslint-config-prettier 8.0.0 版本后,extends 不再需要为单独的插件引入对应扩展来覆盖冲突了,统一引入 ‘prettier’ 即可。 如果同时使用了 eslint-plugin-prettier 和 eslint-config-prettier 可以这么配置: 1 2 3 { extends: ['plugin:prettier/recommended'], } 它其实和下面这些配置是等价的: 1 2 3 4 5 6 7 8 9 { extends: ['prettier'], // eslint-config-prettier 提供的,用于覆盖起冲突的规则 plugins: ['prettier'], // 注册 eslint-plugin-prettier 插件 rules: { 'prettier/prettier': 'error', 'arrow-body-style': 'off', 'prefer-arrow-callback': 'off' } } 所以如果是在 Vue 2 项目中配置 ESLint 和 Prettier 会这么配置: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 { parser: 'vue-eslint-parser', parserOptions: { parser: 'babel-eslint', ecmaVersion: 12, sourceType: 'module' }, extends: [ 'eslint:recommended', 'plugin:vue/recommended', 'plugin:prettier/recommended', // 在前面 Vue 配置的基础上加上这行 ], plugins: ['vue'] } 其实如果你的项目是用 vue-cli 初始化的,且选择了 eslint + prettier 方案的话,生成的项目中,.eslintrc.js 配置文件中 extends 的配置是这样的: 1 2 3 4 5 6 7 { extends: [ 'plugin:vue/essential', 'eslint:recommended', '@vue/prettier' ] } 它的最后一项扩展是 @vue/prettier,这个对应的是 @vue/eslint-config-prettier 这个包,让我们看看这个包下面的 index.js 内容: 1 2 3 4 5 6 7 8 9 10 { plugins: ['prettier'], extends: [ require.resolve('eslint-config-prettier'), require.resolve('eslint-config-prettier/vue') ], rules: { 'prettier/prettier': 'warn' } } 这个和我们上面配置的内容是相差无几的,而引入 eslint-config-prettier/vue 是因为这个 @vue/eslint-config-prettier 包依赖的 eslint-config-prettier 版本是 ^6.0.0 版本的,所以在处理冲突的时候需要特别指定和对应类型插件匹配的扩展。 让 EditorConfig 助力多编辑器开发吧 EditorConfig 是个啥玩意? 它可以对多种类型的单文件进行简单的格式化,它提供的配置参数很少: 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 # 告诉 EditorConfig 插件,这是根文件,不用继续往上查找 root = true # 匹配全部文件 [*] # 设置字符集 charset = utf-8 # 缩进风格,可选 space、tab indent_style = tab # 缩进的空格数,当 indent_style = tab 将使用 tab_width # 否则使用 indent_size indent_size = 2 tab_width = 2 # 结尾换行符,可选 lf、cr、crlf end_of_line = lf # 在文件结尾插入新行 insert_final_newline = true # 删除一行中的前后空格 trim_trailing_whitespace = true # 匹配md结尾的文件 [*.md] insert_final_newline = false trim_trailing_whitespace = false 虽然它提供的格式化的配置参数很少,就 3 个,缩进风格、是否在文件末尾插入新行和是否删除一行中前后空格。但是它还是非常有必要存在的,理由有 3 个: 能够在不同的编辑器和 IDE 中保持一致的代码风格; 配合插件打开文件即自动格式化,非常方便 支持格式化的文件类型很多; 如果需要让以上的配置生效,还得在 VSCode 里安装 EditorConfig for VS Code 这个插件配合使用。 重点来了 可以看到 EditorConfig 和 Prettier 会存在一些重复的配置,比如都提供了对缩进的配置参数,所以在实际使用的时候需要避免它们,或者把他们的参数设置为一致。 在 VSCode 中支持 ESLint 前面做的配置,都需要执行命令才能进行检查和修复代码,还是挺不方便的,如果我希望编辑完或者保存的时候去检查代码该如何做呢?可以直接在 IDE 里安装 ESLint 插件,因为我使用的是 VSCode,所以这里只介绍在 VSCode 中的配置。 在使用前,需要把 ESLint 扩展安装到 VSCode 里,这里我就不细说安装步骤了。 安装完成后,需要在设置里写入配置: 在 VSCode 左下角找到一个齿轮 ⚙ 图标,点击后选择设置选项,这个时候打开了设置面板; 然后在 VSCode 右上角找到打开设置(json)的图标,点击后,会打开 settings.json 文件; 然后把以下配置贴进去即可; 1 2 3 4 5 6 7 8 9 { "eslint.alwaysShowStatus": true, // 总是在 VSCode 显示 ESLint 的状态 "eslint.quiet": true, // 忽略 warning 的错误 "editor.codeActionsOnSave": { // 保存时使用 ESLint 修复可修复错误 "source.fixAll": true, "source.fixAll.eslint": true } } 配置说明,在 ESLint 2.0.4 版本开始: 不需要通过 eslint.validate 来指定校验的文件类型了,已经自动支持了 .vue 文件; editor.codeActionsOnSave 开启保存自动修复功能; 当这样配置之后呢,每次编辑代码 ESLint 都会实时校验代码,且当保存的时候会自动 fix,是不是很方便呢。不过对于有些无法自动 fix 的代码就需要你手动去修改了,如果不想修改的话就可以配置 rules 把该条规则给关闭掉。 其实在团队开发的时候,最好把针对 VSCode 的配置,写一个文件跟随着项目,一起提交到远程仓库,这样的话就保证了项目成员都是用的这套配置。比如可以在项目根目录新建 .vscode/settings.json,然后写入上面的那串配置内容。 在提交前做校验 pre-commit 以上只是通过 ESLint 自动修复能够修复的错误以及通过 Prettier 进行代码的格式化,但是在实际开发的时候难免会遇到无法 fix 的错误,可能开发人员也忘记修改,如果这个时候把代码提交到远程仓库,那就把糟糕的代码给提交上去了。 那么如何杜绝把糟糕的代码提交上去呢?可以通过配置 git hooks 的 pre-commit 钩子来实现这个目的。主要是利用了 husky 和 lint-staged 这 2 个包。husky 就是用来配置 git hooks 的,而 lint-staged 则是对拿到的 staged 文件进行处理,比如执行 npx eslint –fix 进行代码校验。 具体操作步骤如下: 1、执行以下命令: 1 npx mrm lint-staged 会自动安装 lint-staged 和 husky 并且在 package.json 里写入 lint-staged。 注意:mrm 是一个自动化工具,它将根据 package.json 依赖项中的代码质量工具来安装和配置 husky 和 lint-staged,因此请确保在此之前安装并配置所有代码质量工具,如 Prettier 和 ESlint。 如果上面顺利会在 package.json 里写入 lint-staged,可以自行修改让它支持 .vue 文件的校验: 1 2 3 4 5 { "lint-staged": { "*.{js,vue}": "eslint --cache --fix" } } 2、启动 git hooks 1 npx husky install 经过上面的命令后,v6 版本的 husky 会在项目根目录新建一个 .husky 目录。如果是 v4 版本的则会写入到 package.json 里。 3、创建 pre-commit 钩子 1 npx husky add .husky/pre-commit "npx lint-staged" 到这里后,git commit 前自动执行代码校验和修复的功能就算完成了。然后你可以试试修改文件,然后提交试试。 总结 这篇文章比较长,前前后后讲了很多代码校验的东西,现在我们来梳理下。 首先用 ESLint 来做代码校验,它自带的 ruels 能提供 2 种类型的校验,分别是代码错误校验和代码格式校验,而 ESLint 本身的核心工作其实就是校验和修复错误的代码,而对格式化的规则提供的不多。 所以如果想要对代码格式化进行一个更加精细的配置则需要借助 Prettier,因为它是只负责风格的管控,所以用它再适合不过了。但是如果把 ESLint 和 Prettier 结合起来一起使用的话,就可能会出现规则的冲突了,毕竟它们两者都会对风格进行处理,所以这个时候就可以通过 eslint-config-prettier 这个扩展来把冲突的规则进行关闭,这个扩展不仅可以关闭和 ESLint 内置规则的冲突,还可以关闭实际项目中引用到的扩展规则的冲突,比如和 Vue、React、TypeScript、Flow 的冲突。 在把 ESLint 和 Prettier 结合的时候,我们希望让 ESLint 来检查代码错误,而 Prettier 校验代码风格,那么这个时候其实是有 2 个任务的,需要用 2 条命令来处理的。但是有了 eslint-plugin-prettier 这个插件后就可以很方便的把它们结合起来,当需要校验代码错误的时候 ESLint 自动会给你校验,当然前提是 VSCode 里必须按照 ESLint 插件,而当需要校验代码风格的时候 ESLint 就会调用 Prettier 的能力进行代码风格的检查。 文章的后面分别又细说了 EditorConfig 和提交代码前校验的处理,这里就不多讲了。 看到这里希望你对代码校验和规范有一个新的认识,不过我最希望的是你能够自己动手为你的项目配置一套校验规则,如果不能成功,一定是我的文章写的有问题,欢迎评论区留言指出不足之处,我是大海我来了,下篇文章见。

2021/6/16
articleCard.readMore

死磕 36 个 JS 手写题(搞懂后,提升真的大)

为什么要写这类文章 作为一个程序员,代码能力毋庸置疑是非常非常重要的,就像现在为什么大厂面试基本都问什么 API 怎么实现可见其重要性。我想说的是居然手写这么重要,那我们就必须掌握它,所以文章标题用了死磕,一点也不过分,也希望不被认为是标题党。 作为一个普通前端,我是真的写不出 Promise A+ 规范,但是没关系,我们可以站在巨人的肩膀上,要相信我们现在要走的路,前人都走过,所以可以找找现在社区已经存在的那些优秀的文章,比如工业聚大佬写的 100 行代码实现 Promises/A+ 规范,找到这些文章后不是收藏夹吃灰,得找个时间踏踏实实的学,一行一行的磨,直到搞懂为止。我现在就是这么干的。 能收获什么 这篇文章总体上分为 2 类手写题,前半部分可以归纳为是常见需求,后半部分则是对现有技术的实现; 对常用的需求进行手写实现,比如数据类型判断函数、深拷贝等可以直接用于往后的项目中,提高了项目开发效率; 对现有关键字和 API 的实现,可能需要用到别的知识或 API,比如在写 forEach 的时候用到了无符号位右移的操作,平时都不怎么能够接触到这玩意,现在遇到了就可以顺手把它掌握了。所以手写这些实现能够潜移默化的扩展并巩固自己的 JS 基础; 通过写各种测试用例,你会知道各种 API 的边界情况,比如 Promise.all, 你得考虑到传入参数的各种情况,从而加深了对它们的理解及使用; 阅读的时候需要做什么 阅读的时候,你需要把每行代码都看懂,知道它在干什么,为什么要这么写,能写得更好嘛?比如在写图片懒加载的时候,一般我们都是根据当前元素的位置和视口进行判断是否要加载这张图片,普通程序员写到这就差不多完成了。而大佬程序员则是会多考虑一些细节的东西,比如性能如何更优?代码如何更精简?比如 yeyan1996 写的图片懒加载就多考虑了 2 点:比如图片全部加载完成的时候得把事件监听给移除;比如加载完一张图片的时候,得把当前 img 从 imgList 里移除,起到优化内存的作用。 除了读通代码之外,还可以打开 Chrome 的 Script snippet 去写测试用例跑跑代码,做到更好的理解以及使用。 在看了几篇以及写了很多测试用例的前提下,尝试自己手写实现,看看自己到底掌握了多少。条条大路通罗马,你还能有别的方式实现嘛?或者你能写得比别人更好嘛? 好了,还楞着干啥,开始干活。 数据类型判断 typeof 可以正确识别:Undefined、Boolean、Number、String、Symbol、Function 等类型的数据,但是对于其他的都会认为是 object,比如 Null、Date 等,所以通过 typeof 来判断数据类型会不准确。但是可以使用 Object.prototype.toString 实现。 1 2 3 4 5 6 function typeOf(obj) { return Object.prototype.toString.call(obj).slice(8, -1).toLowerCase() } typeOf([]) // 'array' typeOf({}) // 'object' typeOf(new Date) // 'date' 继承 原型链继承 1 2 3 4 5 6 7 8 9 10 11 12 13 function Animal() { this.colors = ['black', 'white'] } Animal.prototype.getColor = function() { return this.colors } function Dog() {} Dog.prototype = new Animal() let dog1 = new Dog() dog1.colors.push('brown') let dog2 = new Dog() console.log(dog2.colors) // ['black', 'white', 'brown'] 原型链继承存在的问题: 问题1:原型中包含的引用类型属性将被所有实例共享; 问题2:子类在实例化的时候不能给父类构造函数传参; 借用构造函数实现继承 1 2 3 4 5 6 7 8 9 10 function Animal(name) { this.name = name this.getName = function() { return this.name } } function Dog(name) { Animal.call(this, name) } Dog.prototype = new Animal() 借用构造函数实现继承解决了原型链继承的 2 个问题:引用类型共享问题以及传参问题。但是由于方法必须定义在构造函数中,所以会导致每次创建子类实例都会创建一遍方法。 组合继承 组合继承结合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function Animal(name) { this.name = name this.colors = ['black', 'white'] } Animal.prototype.getName = function() { return this.name } function Dog(name, age) { Animal.call(this, name) this.age = age } Dog.prototype = new Animal() Dog.prototype.constructor = Dog let dog1 = new Dog('奶昔', 2) dog1.colors.push('brown') let dog2 = new Dog('哈赤', 1) console.log(dog2) // { name: "哈赤", colors: ["black", "white"], age: 1 } 寄生式组合继承 组合继承已经相对完善了,但还是存在问题,它的问题就是调用了 2 次父类构造函数,第一次是在 new Animal(),第二次是在 Animal.call() 这里。 所以解决方案就是不直接调用父类构造函数给子类原型赋值,而是通过创建空函数 F 获取父类原型的副本。 寄生式组合继承写法上和组合继承基本类似,区别是如下这里: 1 2 3 4 5 6 7 8 - Dog.prototype = new Animal() - Dog.prototype.constructor = Dog + function F() {} + F.prototype = Animal.prototype + let f = new F() + f.constructor = Dog + Dog.prototype = f 稍微封装下上面添加的代码后: 1 2 3 4 5 6 7 8 9 10 11 function object(o) { function F() {} F.prototype = o return new F() } function inheritPrototype(child, parent) { let prototype = object(parent.prototype) prototype.constructor = child child.prototype = prototype } inheritPrototype(Dog, Animal) 如果你嫌弃上面的代码太多了,还可以基于组合继承的代码改成最简单的寄生式组合继承: 1 2 3 4 5 - Dog.prototype = new Animal() - Dog.prototype.constructor = Dog + Dog.prototype = Object.create(Animal.prototype) + Dog.prototype.constructor = Dog class 实现继承 1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Animal { constructor(name) { this.name = name } getName() { return this.name } } class Dog extends Animal { constructor(name, age) { super(name) this.age = age } } 数组去重 ES5 实现: 1 2 3 4 5 6 function unique(arr) { var res = arr.filter(function(item, index, array) { return array.indexOf(item) === index }) return res } ES6 实现: 1 var unique = arr => [...new Set(arr)] 数组扁平化 数组扁平化就是将 [1, [2, [3]]] 这种多层的数组拍平成一层 [1, 2, 3]。使用 Array.prototype.flat 可以直接将多层数组拍平成一层: 1 [1, [2, [3]]].flat(2) // [1, 2, 3] 现在就是要实现 flat 这种效果。 ES5 实现:递归。 1 2 3 4 5 6 7 8 9 10 11 function flatten(arr) { var result = []; for (var i = 0, len = arr.length; i < len; i++) { if (Array.isArray(arr[i])) { result = result.concat(flatten(arr[i])) } else { result.push(arr[i]) } } return result; } ES6 实现: 1 2 3 4 5 6 function flatten(arr) { while (arr.some(item => Array.isArray(item))) { arr = [].concat(...arr); } return arr; } 深浅拷贝 浅拷贝:只考虑对象类型。 1 2 3 4 5 6 7 8 9 10 11 function shallowCopy(obj) { if (typeof obj !== 'object') return let newObj = obj instanceof Array ? [] : {} for (let key in obj) { if (obj.hasOwnProperty(key)) { newObj[key] = obj[key] } } return newObj } 简单版深拷贝:只考虑普通对象属性,不考虑内置对象和函数。 1 2 3 4 5 6 7 8 9 10 function deepClone(obj) { if (typeof obj !== 'object') return; var newObj = obj instanceof Array ? [] : {}; for (var key in obj) { if (obj.hasOwnProperty(key)) { newObj[key] = typeof obj[key] === 'object' ? deepClone(obj[key]) : obj[key]; } } return newObj; } 复杂版深克隆:基于简单版的基础上,还考虑了内置对象比如 Date、RegExp 等对象和函数以及解决了循环引用的问题。 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 isObject = (target) => (typeof target === "object" || typeof target === "function") && target !== null; function deepClone(target, map = new WeakMap()) { if (map.get(target)) { return target; } // 获取当前值的构造函数:获取它的类型 let constructor = target.constructor; // 检测当前对象target是否与正则、日期格式对象匹配 if (/^(RegExp|Date)$/i.test(constructor.name)) { // 创建一个新的特殊对象(正则类/日期类)的实例 return new constructor(target); } if (isObject(target)) { map.set(target, true); // 为循环引用的对象做标记 const cloneTarget = Array.isArray(target) ? [] : {}; for (let prop in target) { if (target.hasOwnProperty(prop)) { cloneTarget[prop] = deepClone(target[prop], map); } } return cloneTarget; } else { return target; } } 事件总线(发布订阅模式) 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 class EventEmitter { constructor() { this.cache = {} } on(name, fn) { if (this.cache[name]) { this.cache[name].push(fn) } else { this.cache[name] = [fn] } } off(name, fn) { let tasks = this.cache[name] if (tasks) { const index = tasks.findIndex(f => f === fn || f.callback === fn) if (index >= 0) { tasks.splice(index, 1) } } } emit(name, once = false, ...args) { if (this.cache[name]) { // 创建副本,如果回调函数内继续注册相同事件,会造成死循环 let tasks = this.cache[name].slice() for (let fn of tasks) { fn(...args) } if (once) { delete this.cache[name] } } } } // 测试 let eventBus = new EventEmitter() let fn1 = function(name, age) { console.log(`${name} ${age}`) } let fn2 = function(name, age) { console.log(`hello, ${name} ${age}`) } eventBus.on('aaa', fn1) eventBus.on('aaa', fn2) eventBus.emit('aaa', false, '布兰', 12) // '布兰 12' // 'hello, 布兰 12' 解析 URL 参数为对象 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 function parseParam(url) { const paramsStr = /.+\?(.+)$/.exec(url)[1]; // 将 ? 后面的字符串取出来 const paramsArr = paramsStr.split('&'); // 将字符串以 & 分割后存到数组中 let paramsObj = {}; // 将 params 存到对象中 paramsArr.forEach(param => { if (/=/.test(param)) { // 处理有 value 的参数 let [key, val] = param.split('='); // 分割 key 和 value val = decodeURIComponent(val); // 解码 val = /^\d+$/.test(val) ? parseFloat(val) : val; // 判断是否转为数字 if (paramsObj.hasOwnProperty(key)) { // 如果对象有 key,则添加一个值 paramsObj[key] = [].concat(paramsObj[key], val); } else { // 如果对象没有这个 key,创建 key 并设置值 paramsObj[key] = val; } } else { // 处理没有 value 的参数 paramsObj[param] = true; } }) return paramsObj; } 字符串模板 1 2 3 4 5 6 7 8 9 function render(template, data) { const reg = /\{\{(\w+)\}\}/; // 模板字符串正则 if (reg.test(template)) { // 判断模板里是否有模板字符串 const name = reg.exec(template)[1]; // 查找当前模板里第一个模板字符串的字段 template = template.replace(reg, data[name]); // 将第一个模板字符串渲染 return render(template, data); // 递归的渲染并返回渲染后的结构 } return template; // 如果模板没有模板字符串直接返回 } 测试: 1 2 3 4 5 6 let template = '我是{{name}},年龄{{age}},性别{{sex}}'; let person = { name: '布兰', age: 12 } render(template, person); // 我是布兰,年龄12,性别undefined 图片懒加载 与普通的图片懒加载不同,如下这个多做了 2 个精心处理: 图片全部加载完成后移除事件监听; 加载完的图片,从 imgList 移除; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 let imgList = [...document.querySelectorAll('img')] let length = imgList.length const imgLazyLoad = (function() { let count = 0 return function() { let deleteIndexList = [] imgList.forEach((img, index) => { let rect = img.getBoundingClientRect() if (rect.top < window.innerHeight) { img.src = img.dataset.src deleteIndexList.push(index) count++ if (count === length) { document.removeEventListener('scroll', imgLazyLoad) } } }) imgList = imgList.filter((img, index) => !deleteIndexList.includes(index)) } })() // 这里最好加上防抖处理 document.addEventListener('scroll', imgLazyLoad) 参考:图片懒加载 函数防抖 触发高频事件 N 秒后只会执行一次,如果 N 秒内事件再次触发,则会重新计时。 简单版:函数内部支持使用 this 和 event 对象; 1 2 3 4 5 6 7 8 9 10 11 function debounce(func, wait) { var timeout; return function () { var context = this; var args = arguments; clearTimeout(timeout) timeout = setTimeout(function(){ func.apply(context, args) }, wait); } } 使用: 1 2 3 4 5 6 var node = document.getElementById('layout') function getUserAction(e) { console.log(this, e) // 分别打印:node 这个节点 和 MouseEvent node.innerHTML = count++; }; node.onmousemove = debounce(getUserAction, 1000) 最终版:除了支持 this 和 event 外,还支持以下功能: 支持立即执行; 函数可能有返回值; 支持取消功能; 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 function debounce(func, wait, immediate) { var timeout, result; var debounced = function () { var context = this; var args = arguments; if (timeout) clearTimeout(timeout); if (immediate) { // 如果已经执行过,不再执行 var callNow = !timeout; timeout = setTimeout(function(){ timeout = null; }, wait) if (callNow) result = func.apply(context, args) } else { timeout = setTimeout(function(){ func.apply(context, args) }, wait); } return result; }; debounced.cancel = function() { clearTimeout(timeout); timeout = null; }; return debounced; } 使用: 1 2 3 4 5 6 var setUseAction = debounce(getUserAction, 10000, true); // 使用防抖 node.onmousemove = setUseAction // 取消防抖 setUseAction.cancel() 参考:JavaScript专题之跟着underscore学防抖 函数节流 触发高频事件,且 N 秒内只执行一次。 简单版:使用时间戳来实现,立即执行一次,然后每 N 秒执行一次。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 function throttle(func, wait) { var context, args; var previous = 0; return function() { var now = +new Date(); context = this; args = arguments; if (now - previous > wait) { func.apply(context, args); previous = now; } } } 最终版:支持取消节流;另外通过传入第三个参数,options.leading 来表示是否可以立即执行一次,opitons.trailing 表示结束调用的时候是否还要执行一次,默认都是 true。 注意设置的时候不能同时将 leading 或 trailing 设置为 false。 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 function throttle(func, wait, options) { var timeout, context, args, result; var previous = 0; if (!options) options = {}; var later = function() { previous = options.leading === false ? 0 : new Date().getTime(); timeout = null; func.apply(context, args); if (!timeout) context = args = null; }; var throttled = function() { var now = new Date().getTime(); if (!previous && options.leading === false) previous = now; var remaining = wait - (now - previous); context = this; args = arguments; if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } previous = now; func.apply(context, args); if (!timeout) context = args = null; } else if (!timeout && options.trailing !== false) { timeout = setTimeout(later, remaining); } }; throttled.cancel = function() { clearTimeout(timeout); previous = 0; timeout = null; } return throttled; } 节流的使用就不拿代码举例了,参考防抖的写就行。 参考:JavaScript专题之跟着 underscore 学节流 函数柯里化 什么叫函数柯里化?其实就是将使用多个参数的函数转换成一系列使用一个参数的函数的技术。还不懂?来举个例子。 1 2 3 4 5 6 function add(a, b, c) { return a + b + c } add(1, 2, 3) let addCurry = curry(add) addCurry(1)(2)(3) 现在就是要实现 curry 这个函数,使函数从一次调用传入多个参数变成多次调用每次传一个参数。 1 2 3 4 5 6 7 function curry(fn) { let judge = (...args) => { if (args.length == fn.length) return fn(...args) return (...arg) => judge(...args, ...arg) } return judge } 偏函数 什么是偏函数?偏函数就是将一个 n 参的函数转换成固定 x 参的函数,剩余参数(n - x)将在下次调用全部传入。举个例子: 1 2 3 4 5 function add(a, b, c) { return a + b + c } let partialAdd = partial(add, 1) partialAdd(2, 3) 发现没有,其实偏函数和函数柯里化有点像,所以根据函数柯里化的实现,能够能很快写出偏函数的实现: 1 2 3 4 5 function partial(fn, ...args) { return (...arg) => { return fn(...args, ...arg) } } 如上这个功能比较简单,现在我们希望偏函数能和柯里化一样能实现占位功能,比如: 1 2 3 4 5 function clg(a, b, c) { console.log(a, b, c) } let partialClg = partial(clg, '_', 2) partialClg(1, 3) // 依次打印:1, 2, 3 _ 占的位其实就是 1 的位置。相当于:partial(clg, 1, 2),然后 partialClg(3)。明白了原理,我们就来写实现: 1 2 3 4 5 6 function partial(fn, ...args) { return (...arg) => { args[index] = return fn(...args, ...arg) } } JSONP JSONP 核心原理:script 标签不受同源策略约束,所以可以用来进行跨域请求,优点是兼容性好,但是只能用于 GET 请求; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const jsonp = ({ url, params, callbackName }) => { const generateUrl = () => { let dataSrc = '' for (let key in params) { if (params.hasOwnProperty(key)) { dataSrc += `${key}=${params[key]}&` } } dataSrc += `callback=${callbackName}` return `${url}?${dataSrc}` } return new Promise((resolve, reject) => { const scriptEle = document.createElement('script') scriptEle.src = generateUrl() document.body.appendChild(scriptEle) window[callbackName] = data => { resolve(data) document.removeChild(scriptEle) } }) } AJAX 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const getJSON = function(url) { return new Promise((resolve, reject) => { const xhr = XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject('Mscrosoft.XMLHttp'); xhr.open('GET', url, false); xhr.setRequestHeader('Accept', 'application/json'); xhr.onreadystatechange = function() { if (xhr.readyState !== 4) return; if (xhr.status === 200 || xhr.status === 304) { resolve(xhr.responseText); } else { reject(new Error(xhr.responseText)); } } xhr.send(); }) } 实现数组原型方法 forEach 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Array.prototype.forEach2 = function(callback, thisArg) { if (this == null) { throw new TypeError('this is null or not defined') } if (typeof callback !== "function") { throw new TypeError(callback + ' is not a function') } const O = Object(this) // this 就是当前的数组 const len = O.length >>> 0 // 后面有解释 let k = 0 while (k < len) { if (k in O) { callback.call(thisArg, O[k], k, O); } k++; } } 参考:forEach#polyfill O.length >>> 0 是什么操作?就是无符号右移 0 位,那有什么意义嘛?就是为了保证转换后的值为正整数。其实底层做了 2 层转换,第一是非 number 转成 number 类型,第二是将 number 转成 Uint32 类型。感兴趣可以阅读 something >>> 0是什么意思?。 map 基于 forEach 的实现能够很容易写出 map 的实现: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 - Array.prototype.forEach2 = function(callback, thisArg) { + Array.prototype.map2 = function(callback, thisArg) { if (this == null) { throw new TypeError('this is null or not defined') } if (typeof callback !== "function") { throw new TypeError(callback + ' is not a function') } const O = Object(this) const len = O.length >>> 0 - let k = 0 + let k = 0, res = [] while (k < len) { if (k in O) { - callback.call(thisArg, O[k], k, O); + res[k] = callback.call(thisArg, O[k], k, O); } k++; } + return res } filter 同样,基于 forEach 的实现能够很容易写出 filter 的实现: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 - Array.prototype.forEach2 = function(callback, thisArg) { + Array.prototype.filter2 = function(callback, thisArg) { if (this == null) { throw new TypeError('this is null or not defined') } if (typeof callback !== "function") { throw new TypeError(callback + ' is not a function') } const O = Object(this) const len = O.length >>> 0 - let k = 0 + let k = 0, res = [] while (k < len) { if (k in O) { - callback.call(thisArg, O[k], k, O); + if (callback.call(thisArg, O[k], k, O)) { + res.push(O[k]) + } } k++; } + return res } some 同样,基于 forEach 的实现能够很容易写出 some 的实现: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 - Array.prototype.forEach2 = function(callback, thisArg) { + Array.prototype.some2 = function(callback, thisArg) { if (this == null) { throw new TypeError('this is null or not defined') } if (typeof callback !== "function") { throw new TypeError(callback + ' is not a function') } const O = Object(this) const len = O.length >>> 0 let k = 0 while (k < len) { if (k in O) { - callback.call(thisArg, O[k], k, O); + if (callback.call(thisArg, O[k], k, O)) { + return true + } } k++; } + return false } reduce 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 Array.prototype.reduce2 = function(callback, initialValue) { if (this == null) { throw new TypeError('this is null or not defined') } if (typeof callback !== "function") { throw new TypeError(callback + ' is not a function') } const O = Object(this) const len = O.length >>> 0 let k = 0, acc if (arguments.length > 1) { acc = initialValue } else { // 没传入初始值的时候,取数组中第一个非 empty 的值为初始值 while (k < len && !(k in O)) { k++ } if (k > len) { throw new TypeError( 'Reduce of empty array with no initial value' ); } acc = O[k++] } while (k < len) { if (k in O) { acc = callback(acc, O[k], k, O) } k++ } return acc } 实现函数原型方法 call 使用一个指定的 this 值和一个或多个参数来调用一个函数。 实现要点: this 可能传入 null; 传入不固定个数的参数; 函数可能有返回值; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Function.prototype.call2 = function (context) { var context = context || window; context.fn = this; var args = []; for(var i = 1, len = arguments.length; i < len; i++) { args.push('arguments[' + i + ']'); } var result = eval('context.fn(' + args +')'); delete context.fn return result; } apply apply 和 call 一样,唯一的区别就是 call 是传入不固定个数的参数,而 apply 是传入一个数组。 实现要点: this 可能传入 null; 传入一个数组; 函数可能有返回值; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 Function.prototype.apply2 = function (context, arr) { var context = context || window; context.fn = this; var result; if (!arr) { result = context.fn(); } else { var args = []; for (var i = 0, len = arr.length; i < len; i++) { args.push('arr[' + i + ']'); } result = eval('context.fn(' + args + ')') } delete context.fn return result; } bind bind 方法会创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。 实现要点: bind() 除了 this 外,还可传入多个参数; bing 创建的新函数可能传入多个参数; 新函数可能被当做构造函数调用; 函数可能有返回值; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Function.prototype.bind2 = function (context) { var self = this; var args = Array.prototype.slice.call(arguments, 1); var fNOP = function () {}; var fBound = function () { var bindArgs = Array.prototype.slice.call(arguments); return self.apply(this instanceof fNOP ? this : context, args.concat(bindArgs)); } fNOP.prototype = this.prototype; fBound.prototype = new fNOP(); return fBound; } 实现 new 关键字 new 运算符用来创建用户自定义的对象类型的实例或者具有构造函数的内置对象的实例。 实现要点: new 会产生一个新对象; 新对象需要能够访问到构造函数的属性,所以需要重新指定它的原型; 构造函数可能会显示返回; 1 2 3 4 5 6 7 8 9 function objectFactory() { var obj = new Object() Constructor = [].shift.call(arguments); obj.__proto__ = Constructor.prototype; var ret = Constructor.apply(obj, arguments); // ret || obj 这里这么写考虑了构造函数显示返回 null 的情况 return typeof ret === 'object' ? ret || obj : obj; }; 使用: 1 2 3 4 5 6 function person(name, age) { this.name = name this.age = age } let p = objectFactory(person, '布兰', 12) console.log(p) // { name: '布兰', age: 12 } 实现 instanceof 关键字 instanceof 就是判断构造函数的 prototype 属性是否出现在实例的原型链上。 1 2 3 4 5 6 7 8 9 10 function instanceOf(left, right) { let proto = left.__proto__ while (true) { if (proto === null) return false if (proto === right.prototype) { return true } proto = proto.__proto__ } } 上面的 left.proto 这种写法可以换成 Object.getPrototypeOf(left)。 实现 Object.create Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 Object.create2 = function(proto, propertyObject = undefined) { if (typeof proto !== 'object' && typeof proto !== 'function') { throw new TypeError('Object prototype may only be an Object or null.') if (propertyObject == null) { new TypeError('Cannot convert undefined or null to object') } function F() {} F.prototype = proto const obj = new F() if (propertyObject != undefined) { Object.defineProperties(obj, propertyObject) } if (proto === null) { // 创建一个没有原型对象的对象,Object.create(null) obj.__proto__ = null } return obj } 实现 Object.assign 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Object.assign2 = function(target, ...source) { if (target == null) { throw new TypeError('Cannot convert undefined or null to object') } let ret = Object(target) source.forEach(function(obj) { if (obj != null) { for (let key in obj) { if (obj.hasOwnProperty(key)) { ret[key] = obj[key] } } } }) return ret } 实现 JSON.stringify JSON.stringify([, replacer [, space]) 方法是将一个 JavaScript 值(对象或者数组)转换为一个 JSON 字符串。此处模拟实现,不考虑可选的第二个参数 replacer 和第三个参数 space,如果对这两个参数的作用还不了解,建议阅读 MDN 文档。 基本数据类型: undefined 转换之后仍是 undefined(类型也是 undefined) boolean 值转换之后是字符串 “false”/“true” number 类型(除了 NaN 和 Infinity)转换之后是字符串类型的数值 symbol 转换之后是 undefined null 转换之后是字符串 “null” string 转换之后仍是string NaN 和 Infinity 转换之后是字符串 “null” 函数类型:转换之后是 undefined 如果是对象类型(非函数) 如果是一个数组:如果属性值中出现了 undefined、任意的函数以及 symbol,转换成字符串 “null” ; 如果是 RegExp 对象:返回 {} (类型是 string); 如果是 Date 对象,返回 Date 的 toJSON 字符串值; 如果是普通对象; 如果有 toJSON() 方法,那么序列化 toJSON() 的返回值。 如果属性值中出现了 undefined、任意的函数以及 symbol 值,忽略。 所有以 symbol 为属性键的属性都会被完全忽略掉。 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。 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 function jsonStringify(data) { let dataType = typeof data; if (dataType !== 'object') { let result = data; //data 可能是 string/number/null/undefined/boolean if (Number.isNaN(data) || data === Infinity) { //NaN 和 Infinity 序列化返回 "null" result = "null"; } else if (dataType === 'function' || dataType === 'undefined' || dataType === 'symbol') { //function 、undefined 、symbol 序列化返回 undefined return undefined; } else if (dataType === 'string') { result = '"' + data + '"'; } //boolean 返回 String() return String(result); } else if (dataType === 'object') { if (data === null) { return "null" } else if (data.toJSON && typeof data.toJSON === 'function') { return jsonStringify(data.toJSON()); } else if (data instanceof Array) { let result = []; //如果是数组 //toJSON 方法可以存在于原型链中 data.forEach((item, index) => { if (typeof item === 'undefined' || typeof item === 'function' || typeof item === 'symbol') { result[index] = "null"; } else { result[index] = jsonStringify(item); } }); result = "[" + result + "]"; return result.replace(/'/g, '"'); } else { //普通对象 /** * 循环引用抛错(暂未检测,循环引用时,堆栈溢出) * symbol key 忽略 * undefined、函数、symbol 为属性值,被忽略 */ let result = []; Object.keys(data).forEach((item, index) => { if (typeof item !== 'symbol') { //key 如果是symbol对象,忽略 if (data[item] !== undefined && typeof data[item] !== 'function' && typeof data[item] !== 'symbol') { //键值如果是 undefined、函数、symbol 为属性值,忽略 result.push('"' + item + '"' + ":" + jsonStringify(data[item])); } } }); return ("{" + result + "}").replace(/'/g, '"'); } } } 参考:实现 JSON.stringify 实现 JSON.parse 介绍 2 种方法实现: eval 实现; new Function 实现; eval 实现 第一种方式最简单,也最直观,就是直接调用 eval,代码如下: 1 2 var json = '{"a":"1", "b":2}'; var obj = eval("(" + json + ")"); // obj 就是 json 反序列化之后得到的对象 但是直接调用 eval 会存在安全问题,如果数据中可能不是 json 数据,而是可执行的 JavaScript 代码,那很可能会造成 XSS 攻击。因此,在调用 eval 之前,需要对数据进行校验。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 var rx_one = /^[\],:{}\s]*$/; var rx_two = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g; var rx_three = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g; var rx_four = /(?:^|:|,)(?:\s*\[)+/g; if ( rx_one.test( json.replace(rx_two, "@") .replace(rx_three, "]") .replace(rx_four, "") ) ) { var obj = eval("(" +json + ")"); } 参考:JSON.parse 三种实现方式 new Function 实现 Function 与 eval 有相同的字符串参数特性。 1 2 var json = '{"name":"小姐姐", "age":20}'; var obj = (new Function('return ' + json))(); 实现 Promise 实现 Promise 需要完全读懂 Promise A+ 规范,不过从总体的实现上看,有如下几个点需要考虑到: then 需要支持链式调用,所以得返回一个新的 Promise; 处理异步问题,所以得先用 onResolvedCallbacks 和 onRejectedCallbacks 分别把成功和失败的回调存起来; 为了让链式调用正常进行下去,需要判断 onFulfilled 和 onRejected 的类型; onFulfilled 和 onRejected 需要被异步调用,这里用 setTimeout 模拟异步; 处理 Promise 的 resolve; 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 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 const PENDING = 'pending'; const FULFILLED = 'fulfilled'; const REJECTED = 'rejected'; class Promise { constructor(executor) { this.status = PENDING; this.value = undefined; this.reason = undefined; this.onResolvedCallbacks = []; this.onRejectedCallbacks = []; let resolve = (value) = > { if (this.status === PENDING) { this.status = FULFILLED; this.value = value; this.onResolvedCallbacks.forEach((fn) = > fn()); } }; let reject = (reason) = > { if (this.status === PENDING) { this.status = REJECTED; this.reason = reason; this.onRejectedCallbacks.forEach((fn) = > fn()); } }; try { executor(resolve, reject); } catch (error) { reject(error); } } then(onFulfilled, onRejected) { // 解决 onFufilled,onRejected 没有传值的问题 onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) = > v; // 因为错误的值要让后面访问到,所以这里也要抛出错误,不然会在之后 then 的 resolve 中捕获 onRejected = typeof onRejected === "function" ? onRejected : (err) = > { throw err; }; // 每次调用 then 都返回一个新的 promise let promise2 = new Promise((resolve, reject) = > { if (this.status === FULFILLED) { //Promise/A+ 2.2.4 --- setTimeout setTimeout(() = > { try { let x = onFulfilled(this.value); // x可能是一个proimise resolvePromise(promise2, x, resolve, reject); } catch (e) { reject(e); } }, 0); } if (this.status === REJECTED) { //Promise/A+ 2.2.3 setTimeout(() = > { try { let x = onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (e) { reject(e); } }, 0); } if (this.status === PENDING) { this.onResolvedCallbacks.push(() = > { setTimeout(() = > { try { let x = onFulfilled(this.value); resolvePromise(promise2, x, resolve, reject); } catch (e) { reject(e); } }, 0); }); this.onRejectedCallbacks.push(() = > { setTimeout(() = > { try { let x = onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (e) { reject(e); } }, 0); }); } }); return promise2; } } const resolvePromise = (promise2, x, resolve, reject) = > { // 自己等待自己完成是错误的实现,用一个类型错误,结束掉 promise Promise/A+ 2.3.1 if (promise2 === x) { return reject( new TypeError("Chaining cycle detected for promise #<Promise>")); } // Promise/A+ 2.3.3.3.3 只能调用一次 let called; // 后续的条件要严格判断 保证代码能和别的库一起使用 if ((typeof x === "object" && x != null) || typeof x === "function") { try { // 为了判断 resolve 过的就不用再 reject 了(比如 reject 和 resolve 同时调用的时候) Promise/A+ 2.3.3.1 let then = x.then; if (typeof then === "function") { // 不要写成 x.then,直接 then.call 就可以了 因为 x.then 会再次取值,Object.defineProperty Promise/A+ 2.3.3.3 then.call( x, (y) = > { // 根据 promise 的状态决定是成功还是失败 if (called) return; called = true; // 递归解析的过程(因为可能 promise 中还有 promise) Promise/A+ 2.3.3.3.1 resolvePromise(promise2, y, resolve, reject); }, (r) = > { // 只要失败就失败 Promise/A+ 2.3.3.3.2 if (called) return; called = true; reject(r); }); } else { // 如果 x.then 是个普通值就直接返回 resolve 作为结果 Promise/A+ 2.3.3.4 resolve(x); } } catch (e) { // Promise/A+ 2.3.3.2 if (called) return; called = true; reject(e); } } else { // 如果 x 是个普通值就直接返回 resolve 作为结果 Promise/A+ 2.3.4 resolve(x); } }; Promise 写完之后可以通过 promises-aplus-tests 这个包对我们写的代码进行测试,看是否符合 A+ 规范。不过测试前还得加一段代码: 1 2 3 4 5 6 7 8 9 10 11 12 // promise.js // 这里是上面写的 Promise 全部代码 Promise.defer = Promise.deferred = function () { let dfd = {} dfd.promise = new Promise((resolve,reject)=>{ dfd.resolve = resolve; dfd.reject = reject; }); return dfd; } module.exports = Promise; 全局安装: 1 npm i promises-aplus-tests -g 终端下执行验证命令: 1 promises-aplus-tests promise.js 上面写的代码可以顺利通过全部 872 个测试用例。 参考: BAT前端经典面试问题:史上最最最详细的手写Promise教程 100 行代码实现 Promises/A+ 规范 Promise.resolve Promsie.resolve(value) 可以将任何值转成值为 value 状态是 fulfilled 的 Promise,但如果传入的值本身是 Promise 则会原样返回它。 1 2 3 4 5 6 7 Promise.resolve = function(value) { // 如果是 Promsie,则直接输出它 if(value instanceof Promise){ return value } return new Promise(resolve => resolve(value)) } 参考:深入理解 Promise Promise.reject 和 Promise.resolve() 类似,Promise.reject() 会实例化一个 rejected 状态的 Promise。但与 Promise.resolve() 不同的是,如果给 Promise.reject() 传递一个 Promise 对象,则这个对象会成为新 Promise 的值。 1 2 3 Promise.reject = function(reason) { return new Promise((resolve, reject) => reject(reason)) } Promise.all Promise.all 的规则是这样的: 传入的所有 Promsie 都是 fulfilled,则返回由他们的值组成的,状态为 fulfilled 的新 Promise; 只要有一个 Promise 是 rejected,则返回 rejected 状态的新 Promsie,且它的值是第一个 rejected 的 Promise 的值; 只要有一个 Promise 是 pending,则返回一个 pending 状态的新 Promise; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Promise.all = function(promiseArr) { let index = 0, result = [] return new Promise((resolve, reject) => { promiseArr.forEach((p, i) => { Promise.resolve(p).then(val => { index++ result[i] = val if (index === promiseArr.length) { resolve(result) } }, err => { reject(err) }) }) }) } Promise.race Promise.race 会返回一个由所有可迭代实例中第一个 fulfilled 或 rejected 的实例包装后的新实例。 1 2 3 4 5 6 7 8 9 10 11 Promise.race = function(promiseArr) { return new Promise((resolve, reject) => { promiseArr.forEach(p => { Promise.resolve(p).then(val => { resolve(val) }, err => { rejecte(err) }) }) }) } Promise.allSettled Promise.allSettled 的规则是这样: 所有 Promise 的状态都变化了,那么新返回一个状态是 fulfilled 的 Promise,且它的值是一个数组,数组的每项由所有 Promise 的值和状态组成的对象; 如果有一个是 pending 的 Promise,则返回一个状态是 pending 的新实例; 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 Promise.allSettled = function(promiseArr) { let result = [] return new Promise((resolve, reject) => { promiseArr.forEach((p, i) => { Promise.resolve(p).then(val => { result.push({ status: 'fulfilled', value: val }) if (result.length === promiseArr.length) { resolve(result) } }, err => { result.push({ status: 'rejected', reason: err }) if (result.length === promiseArr.length) { resolve(result) } }) }) }) } Promise.any Promise.any 的规则是这样: 空数组或者所有 Promise 都是 rejected,则返回状态是 rejected 的新 Promsie,且值为 AggregateError 的错误; 只要有一个是 fulfilled 状态的,则返回第一个是 fulfilled 的新实例; 其他情况都会返回一个 pending 的新实例; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Promise.any = function(promiseArr) { let index = 0 return new Promise((resolve, reject) => { if (promiseArr.length === 0) return promiseArr.forEach((p, i) => { Promise.resolve(p).then(val => { resolve(val) }, err => { index++ if (index === promiseArr.length) { reject(new AggregateError('All promises were rejected')) } }) }) }) } 后话 能看到这里的对代码都是真爱了,毕竟代码这玩意看起来是真的很枯燥,但是如果看懂了后,就会像打游戏赢了一样开心,而且这玩意会上瘾,当你通关了越多的关卡后,你的能力就会拔高一个层次。用标题的话来说就是:搞懂后,提升真的大。加油吧💪,干饭人 噢不,代码人。

2021/4/2
articleCard.readMore

1.5 万字 CSS 基础拾遗(核心知识、常用需求)

本篇文章围绕了 CSS 的核心知识点和项目中常见的需求来展开。虽然行文偏长,但偏基础,适合初级中级前端阅读,阅读的时候请适当跳过已经掌握的部分。 这篇文章断断续续写了比较久,也参考了许多优秀的文章,但或许文章里还是存在不好或不对的地方,请多多指教,可以评论里直接提出来哈。 小 tip:后续内容更精彩哦。 核心概念和知识点 语法 CSS 的核心功能是将 CSS 属性设定为特定的值。一个属性与值的键值对被称为声明(declaration)。 1 color: red; 而如果将一个或者多个声明用 {} 包裹起来后,那就组成了一个声明块(declaration block)。 1 2 3 4 { color: red; text-align: center; } 声明块如果需要作用到对应的 HTML 元素,那还需要加上选择器。选择器和声明块组成了CSS 规则集(CSS ruleset),常简称为 CSS 规则。 1 2 3 4 span { color: red; text-align: center; } 规则集中最后一条声明可以省略分号,但是并不建议这么做,因为容易出错。 CSS 中的注释: 1 2 3 4 5 6 /* 单行注释 */ /* 多行 注释 */ 在 CSS 文件中,除了注释、CSS 规则集以及 @规则 外,定义的一些别的东西都将被浏览器忽略。 @规则 CSS 规则是样式表的主体,通常样式表会包括大量的规则列表。但有时候也需要在样式表中包括其他的一些信息,比如字符集,导入其它的外部样式表,字体等,这些需要专门的语句表示。 而 @规则 就是这样的语句。CSS 里包含了以下 @规则: @namespace 告诉 CSS 引擎必须考虑 XML 命名空间。 @media, 如果满足媒体查询的条件则条件规则组里的规则生效。 @page, 描述打印文档时布局的变化. @font-face, 描述将下载的外部的字体。 @keyframes, 描述 CSS 动画的关键帧。 @document, 如果文档样式表满足给定条件则条件规则组里的规则生效。 (推延至 CSS Level 4 规范) 除了以上这几个之外,下面还将对几个比较生涩的 @规则 进行介绍。 @charset @charset 用于定义样式表使用的字符集。它必须是样式表中的第一个元素。如果有多个 @charset 被声明,只有第一个会被使用,而且不能在 HTML 元素或 HTML 页面的 <style> 元素内使用。 注意:值必须是双引号包裹,且和 1 @charset "UTF-8"; 平时写样式文件都没写 @charset 规则,那这个 CSS 文件到底是用的什么字符编码的呢? 某个样式表文件到底用的是什么字符编码,浏览器有一套识别顺序(优先级由高到低): 文件开头的 Byte order mark 字符值,不过一般编辑器并不能看到文件头里的 BOM 值; HTTP 响应头里的 content-type 字段包含的 charset 所指定的值,比如: 1 Content-Type: text/css; charset=utf-8 CSS 文件头里定义的 @charset 规则里指定的字符编码; <link> 标签里的 charset 属性,该条已在 HTML5 中废除; 默认是 UTF-8。 @import @import 用于告诉 CSS 引擎引入一个外部样式表。 link 和 @import 都能导入一个样式文件,它们有什么区别嘛? link 是 HTML 标签,除了能导入 CSS 外,还能导入别的资源,比如图片、脚本和字体等;而 @import 是 CSS 的语法,只能用来导入 CSS; link 导入的样式会在页面加载时同时加载,@import 导入的样式需等页面加载完成后再加载; link 没有兼容性问题,@import 不兼容 ie5 以下; link 可以通过 JS 操作 DOM 动态引入样式表改变样式,而@import 不可以。 @supports @supports 用于查询特定的 CSS 是否生效,可以结合 not、and 和 or 操作符进行后续的操作。 1 2 3 4 5 6 /* 如果支持自定义属性,则把 body 颜色设置为变量 varName 指定的颜色 */ @supports (--foo: green) { body { color: var(--varName); } } 层叠性 层叠样式表,这里的层叠怎么理解呢?其实它是 CSS 中的核心特性之一,用于合并来自多个源的属性值的算法。比如说针对某个 HTML 标签,有许多的 CSS 声明都能作用到的时候,那最后谁应该起作用呢?层叠性说的大概就是这个。 针对不同源的样式,将按照如下的顺序进行层叠,越往下优先级越高: 用户代理样式表中的声明(例如,浏览器的默认样式,在没有设置其他样式时使用)。 用户样式表中的常规声明(由用户设置的自定义样式。由于 Chrome 在很早的时候就放弃了用户样式表的功能,所以这里将不再考虑它的排序。)。 作者样式表中的常规声明(这些是我们 Web 开发人员设置的样式)。 作者样式表中的 !important 声明。 用户样式表中的 !important 声明 S。 理解层叠性的时候需要结合 CSS 选择器的优先级以及继承性来理解。比如针对同一个选择器,定义在后面的声明会覆盖前面的;作者定义的样式会比默认继承的样式优先级更高。 选择器 CSS 选择器无疑是其核心之一,对于基础选择器以及一些常用伪类必须掌握。下面列出了常用的选择器。 想要获取更多选择器的用法可以看 MDN CSS Selectors。 基础选择器 标签选择器:h1 类选择器:.checked ID 选择器:#picker 通配选择器:* 属性选择器 [attr]:指定属性的元素; [attr=val]:属性等于指定值的元素; [attr*=val]:属性包含指定值的元素; [attr^=val] :属性以指定值开头的元素; [attr$=val]:属性以指定值结尾的元素; [attr~=val]:属性包含指定值(完整单词)的元素(不推荐使用); [attr|=val]:属性以指定值(完整单词)开头的元素(不推荐使用); 组合选择器 相邻兄弟选择器:A + B 普通兄弟选择器:A ~ B 子选择器:A > B 后代选择器:A B 伪类 条件伪类 :lang():基于元素语言来匹配页面元素; :dir():匹配特定文字书写方向的元素; :has():匹配包含指定元素的元素; :is():匹配指定选择器列表里的元素; :not():用来匹配不符合一组选择器的元素; 行为伪类 :active:鼠标激活的元素; :hover: 鼠标悬浮的元素; ::selection:鼠标选中的元素; 状态伪类 :target:当前锚点的元素; :link:未访问的链接元素; :visited:已访问的链接元素; :focus:输入聚焦的表单元素; :required:输入必填的表单元素; :valid:输入合法的表单元素; :invalid:输入非法的表单元素; :in-range:输入范围以内的表单元素; :out-of-range:输入范围以外的表单元素; :checked:选项选中的表单元素; :optional:选项可选的表单元素; :enabled:事件启用的表单元素; :disabled:事件禁用的表单元素; :read-only:只读的表单元素; :read-write:可读可写的表单元素; :blank:输入为空的表单元素; :current():浏览中的元素; :past():已浏览的元素; :future():未浏览的元素; 结构伪类 :root:文档的根元素; :empty:无子元素的元素; :first-letter:元素的首字母; :first-line:元素的首行; :nth-child(n):元素中指定顺序索引的元素; :nth-last-child(n):元素中指定逆序索引的元素;; :first-child :元素中为首的元素; :last-child :元素中为尾的元素; :only-child:父元素仅有该元素的元素; :nth-of-type(n) :标签中指定顺序索引的标签; :nth-last-of-type(n):标签中指定逆序索引的标签; :first-of-type :标签中为首的标签; :last-of-type:标签中为尾标签; :only-of-type:父元素仅有该标签的标签; 伪元素 ::before:在元素前插入内容; ::after:在元素后插入内容; 优先级 优先级就是分配给指定的 CSS 声明的一个权重,它由匹配的选择器中的每一种选择器类型的数值决定。为了记忆,可以把权重分成如下几个等级,数值越大的权重越高: 10000:!important; 01000:内联样式; 00100:ID 选择器; 00010:类选择器、伪类选择器、属性选择器; 00001:元素选择器、伪元素选择器; 00000:通配选择器、后代选择器、兄弟选择器; 可以看到内联样式(通过元素中 style 属性定义的样式)的优先级大于任何选择器;而给属性值加上 !important 又可以把优先级提至最高,就是因为它的优先级最高,所以需要谨慎使用它,以下有些使用注意事项: 一定要优先考虑使用样式规则的优先级来解决问题而不是 !important; 只有在需要覆盖全站或外部 CSS 的特定页面中使用 !important; 永远不要在你的插件中使用 !important; 永远不要在全站范围的 CSS 代码中使用 !important; 继承性 在 CSS 中有一个很重要的特性就是子元素会继承父元素对应属性计算后的值。比如页面根元素 html 的文本颜色默认是黑色的,页面中的所有其他元素都将继承这个颜色,当申明了如下样式后,H1 文本将变成橙色。 1 2 3 4 5 6 body { color: orange; } h1 { color: inherit; } 设想一下,如果 CSS 中不存在继承性,那么我们就需要为不同文本的标签都设置一下 color,这样一来的后果就是 CSS 的文件大小就会无限增大。 CSS 属性很多,但并不是所有的属性默认都是能继承父元素对应属性的,那哪些属性存在默认继承的行为呢?一定是那些不会影响到页面布局的属性,可以分为如下几类: 字体相关:font-family、font-style、font-size、font-weight 等; 文本相关:text-align、text-indent、text-decoration、text-shadow、letter-spacing、word-spacing、white-space、line-height、color 等; 列表相关:list-style、list-style-image、list-style-type、list-style-position 等; 其他属性:visibility、cursor 等; 对于其他默认不继承的属性也可以通过以下几个属性值来控制继承行为: inherit:继承父元素对应属性的计算值; initial:应用该属性的默认值,比如 color 的默认值是 #000; unset:如果属性是默认可以继承的,则取 inherit 的效果,否则同 initial; revert:效果等同于 unset,兼容性差。 文档流 在 CSS 的世界中,会把内容按照从左到右、从上到下的顺序进行排列显示。正常情况下会把页面分割成一行一行的显示,而每行又可能由多列组成,所以从视觉上看起来就是从上到下从左到右,而这就是 CSS 中的流式布局,又叫文档流。文档流就像水一样,能够自适应所在的容器,一般它有如下几个特性: 块级元素默认会占满整行,所以多个块级盒子之间是从上到下排列的; 内联元素默认会在一行里一列一列的排布,当一行放不下的时候,会自动切换到下一行继续按照列排布; 如何脱离文档流呢? 脱流文档流指节点脱流正常文档流后,在正常文档流中的其他节点将忽略该节点并填补其原先空间。文档一旦脱流,计算其父节点高度时不会将其高度纳入,脱流节点不占据空间。有两种方式可以让元素脱离文档流:浮动和定位。 使用浮动(float)会将元素脱离文档流,移动到容器左/右侧边界或者是另一个浮动元素旁边,该浮动元素之前占用的空间将被别的元素填补,另外浮动之后所占用的区域不会和别的元素之间发生重叠; 使用绝对定位(position: absolute;)或者固定定位(position: fixed;)也会使得元素脱离文档流,且空出来的位置将自动被后续节点填补。 盒模型 在 CSS 中任何元素都可以看成是一个盒子,而一个盒子是由 4 部分组成的:内容(content)、内边距(padding)、边框(border)和外边距(margin)。 盒模型有 2 种:标准盒模型和 IE 盒模型,分别是由 W3C 和 IExplore 制定的标准。 如果给某个元素设置如下样式: 1 2 3 4 5 6 7 .box { width: 200px; height: 200px; padding: 10px; border: 1px solid #eee; margin: 10px; } 标准盒模型认为:盒子的实际尺寸 = 内容(设置的宽/高) + 内边距 + 边框 所以 .box 元素内容的宽度就为 200px,而实际的宽度则是 width + padding-left + padding-right + border-left-width + border-right-width = 200 + 10 + 10 + 1 + 1 = 222。 IE 盒模型认为:盒子的实际尺寸 = 设置的宽/高 = 内容 + 内边距 + 边框 .box 元素所占用的实际宽度为 200px,而内容的真实宽度则是 width - padding-left - padding-right - border-left-width - border-right-width = 200 - 10 - 10 - 1 - 1 = 178。 现在高版本的浏览器基本上默认都是使用标准盒模型,而像 IE6 这种老古董才是默认使用 IE 盒模型的。 在 CSS3 中新增了一个属性 box-sizing,允许开发者来指定盒子使用什么标准,它有 2 个值: content-box:标准盒模型; border-box:IE 盒模型; 视觉格式化模型 视觉格式化模型(Visual formatting model)是用来处理和在视觉媒体上显示文档时使用的计算规则。CSS 中一切皆盒子,而视觉格式化模型简单来理解就是规定这些盒子应该怎么样放置到页面中去,这个模型在计算的时候会依赖到很多的因素,比如:盒子尺寸、盒子类型、定位方案(是浮动还是定位)、兄弟元素或者子元素以及一些别的因素。 从上图中可以看到视觉格式化模型涉及到的内容很多,有兴趣深入研究的可以结合上图看这个 W3C 的文档 Visual formatting model。所以这里就简单介绍下盒子类型。 盒子类型由 display 决定,同时给一个元素设置 display 后,将会决定这个盒子的 2 个显示类型(display type): outer display type(对外显示):决定了该元素本身是如何布局的,即参与何种格式化上下文; inner display type(对内显示):其实就相当于把该元素当成了容器,规定了其内部子元素是如何布局的,参与何种格式化上下文; outer display type 对外显示方面,盒子类型可以分成 2 类:block-level box(块级盒子) 和 inline-level box(行内级盒子)。 依据上图可以列出都有哪些块级和行内级盒子: 块级盒子:display 为 block、list-item、table、flex、grid、flow-root 等; 行内级盒子:display 为 inline、inline-block、inline-table 等; 所有块级盒子都会参与 BFC,呈现垂直排列;而所有行内级盒子都参会 IFC,呈现水平排列。 除此之外,block、inline 和 inline-block 还有什么更具体的区别嘛? block 占满一行,默认继承父元素的宽度;多个块元素将从上到下进行排列; 设置 width/height 将会生效; 设置 padding 和 margin 将会生效; inline 不会占满一行,宽度随着内容而变化;多个 inline 元素将按照从左到右的顺序在一行里排列显示,如果一行显示不下,则自动换行; 设置 width/height 将不会生效; 设置竖直方向上的 padding 和 margin 将不会生效; inline-block 是行内块元素,不单独占满一行,可以看成是能够在一行里进行左右排列的块元素; 设置 width/height 将会生效; 设置 padding 和 margin 将会生效; inner display type 对内方面,其实就是把元素当成了容器,里面包裹着文本或者其他子元素。container box 的类型依据 display 的值不同,分为 4 种: block container:建立 BFC 或者 IFC; flex container:建立 FFC; grid container:建立 GFC; ruby container:接触不多,不做介绍。 值得一提的是如果把 img 这种替换元素(replaced element)申明为 block 是不会产生 container box 的,因为替换元素比如 img 设计的初衷就仅仅是通过 src 把内容替换成图片,完全没考虑过会把它当成容器。 参考: CSS 原理 - 你所不知道的 display 格式化上下文 格式化上下文 格式化上下文(Formatting Context)是 CSS2.1 规范中的一个概念,大概说的是页面中的一块渲染区域,规定了渲染区域内部的子元素是如何排版以及相互作用的。 不同类型的盒子有不同格式化上下文,大概有这 4 类: BFC (Block Formatting Context) 块级格式化上下文; IFC (Inline Formatting Context) 行内格式化上下文; FFC (Flex Formatting Context) 弹性格式化上下文; GFC (Grid Formatting Context) 格栅格式化上下文; 其中 BFC 和 IFC 在 CSS 中扮演着非常重要的角色,因为它们直接影响了网页布局,所以需要深入理解其原理。 BFC 块格式化上下文,它是一个独立的渲染区域,只有块级盒子参与,它规定了内部的块级盒子如何布局,并且与这个区域外部毫不相干。 BFC 渲染规则 内部的盒子会在垂直方向,一个接一个地放置; 盒子垂直方向的距离由 margin 决定,属于同一个 BFC 的两个相邻盒子的 margin 会发生重叠; 每个元素的 margin 的左边,与包含块 border 的左边相接触(对于从左往右的格式化,否则相反),即使存在浮动也是如此; BFC 的区域不会与 float 盒子重叠; BFC 就是页面上的一个隔离的独立容器,容器里面的子元素不会影响到外面的元素。反之也如此。 计算 BFC 的高度时,浮动元素也参与计算。 如何创建 BFC? 根元素:html 非溢出的可见元素:overflow 不为 visible 设置浮动:float 属性不为 none 设置定位:position 为 absolute 或 fixed 定义成块级的非块级元素:display: inline-block/table-cell/table-caption/flex/inline-flex/grid/inline-grid BFC 应用场景 1、 自适应两栏布局 应用原理:BFC 的区域不会和浮动区域重叠,所以就可以把侧边栏固定宽度且左浮动,而对右侧内容触发 BFC,使得它的宽度自适应该行剩余宽度。 1 2 3 4 <div class="layout"> <div class="aside">aside</div> <div class="main">main</div> </div> 1 2 3 4 5 6 7 8 .aside { float: left; width: 100px; } .main { <!-- 触发 BFC --> overflow: auto;BFCoverflow } 2、清除内部浮动 浮动造成的问题就是父元素高度坍塌,所以清除浮动需要解决的问题就是让父元素的高度恢复正常。而用 BFC 清除浮动的原理就是:计算 BFC 的高度时,浮动元素也参与计算。只要触发父元素的 BFC 即可。 1 2 3 .parent { overflow: hidden; } 3、 防止垂直 margin 合并 BFC 渲染原理之一:同一个 BFC 下的垂直 margin 会发生合并。所以如果让 2 个元素不在同一个 BFC 中即可阻止垂直 margin 合并。那如何让 2 个相邻的兄弟元素不在同一个 BFC 中呢?可以给其中一个元素外面包裹一层,然后触发其包裹层的 BFC,这样一来 2 个元素就不会在同一个 BFC 中了。 1 2 3 4 5 6 <div class="layout"> <div class="a">a</div> <div class="contain-b"> <div class="b">b</div> </div> </div> 1 2 3 4 5 6 7 8 .demo3 .a, .demo3 .b { border: 1px solid #999; margin: 10px; } .contain-b { overflow: hidden; } 针对以上 3 个 示例 ,可以结合这个 BFC 应用示例 配合观看更佳。 参考:CSS 原理 - Formatting Context IFC IFC 的形成条件非常简单,块级元素中仅包含内联级别元素,需要注意的是当 IFC 中有块级元素插入时,会产生两个匿名块将父元素分割开来,产生两个 IFC。 IFC 渲染规则 子元素在水平方向上一个接一个排列,在垂直方向上将以容器顶部开始向下排列; 节点无法声明宽高,其中 margin 和 padding 在水平方向有效在垂直方向无效; 节点在垂直方向上以不同形式对齐; 能把在一行上的框都完全包含进去的一个矩形区域,被称为该行的线盒(line box)。线盒的宽度是由包含块(containing box)和与其中的浮动来决定; IFC 中的 line box 一般左右边贴紧其包含块,但 float 元素会优先排列。 IFC 中的 line box 高度由 line-height 计算规则来确定,同个 IFC 下的多个 line box 高度可能会不同; 当内联级盒子的总宽度少于包含它们的 line box 时,其水平渲染规则由 text-align 属性值来决定; 当一个内联盒子超过父元素的宽度时,它会被分割成多盒子,这些盒子分布在多个 line box 中。如果子元素未设置强制换行的情况下,inline box 将不可被分割,将会溢出父元素。 针对如上的 IFC 渲染规则,你是不是可以分析下下面这段代码的 IFC 环境是怎么样的呢? 1 <p>It can get <strong>very complicated</storng> once you start looking into it.</p> 对应上面这样一串 HTML 分析如下: p 标签是一个 block container,对内将产生一个 IFC; 由于一行没办法显示完全,所以产生了 2 个线盒(line box);线盒的宽度就继承了 p 的宽度;高度是由里面的内联盒子的 line-height 决定; It can get:匿名的内联盒子; very complicated:strong 标签产生的内联盒子; once you start:匿名的内联盒子; looking into it.:匿名的内联盒子。 参考:Inline formatting contexts IFC 应用场景 水平居中:当一个块要在环境中水平居中时,设置其为 inline-block 则会在外层产生 IFC,通过 text-align 则可以使其水平居中。 垂直居中:创建一个 IFC,用其中一个元素撑开父元素的高度,然后设置其 vertical-align: middle,其他行内元素则可以在此父元素下垂直居中。 偷个懒,demo 和图我就不做了。 层叠上下文 在电脑显示屏幕上的显示的页面其实是一个三维的空间,水平方向是 X 轴,竖直方向是 Y 轴,而屏幕到眼睛的方向可以看成是 Z 轴。众 HTML 元素依据自己定义的属性的优先级在 Z 轴上按照一定的顺序排开,而这其实就是层叠上下文所要描述的东西。 我们对层叠上下文的第一印象可能要来源于 z-index,认为它的值越大,距离屏幕观察者就越近,那么层叠等级就越高,事实确实是这样的,但层叠上下文的内容远非仅仅如此: z-index 能够在层叠上下文中对元素的堆叠顺序其作用是必须配合定位才可以; 除了 z-index 之外,一个元素在 Z 轴上的显示顺序还受层叠等级和层叠顺序影响; 在看层叠等级和层叠顺序之前,我们先来看下如何产生一个层叠上下文,特定的 HTML 元素或者 CSS 属性产生层叠上下文,MDN 中给出了这么一个列表,符合以下任一条件的元素都会产生层叠上下文: html 文档根元素 声明 position: absolute/relative 且 z-index 值不为 auto 的元素; 声明 position: fixed/sticky 的元素; flex 容器的子元素,且 z-index 值不为 auto; grid 容器的子元素,且 z-index 值不为 auto; opacity 属性值小于 1 的元素; mix-blend-mode 属性值不为 normal 的元素; 以下任意属性值不为 none 的元素: transform filter perspective clip-path mask / mask-image / mask-border isolation 属性值为 isolate 的元素; -webkit-overflow-scrolling 属性值为 touch 的元素; will-change 值设定了任一属性而该属性在 non-initial 值时会创建层叠上下文的元素; contain 属性值为 layout、paint 或包含它们其中之一的合成值(比如 contain: strict、contain: content)的元素。 层叠等级 层叠等级指节点在三维空间 Z 轴上的上下顺序。它分两种情况: 在同一个层叠上下文中,它描述定义的是该层叠上下文中的层叠上下文元素在 Z 轴上的上下顺序; 在其他普通元素中,它描述定义的是这些普通元素在 Z 轴上的上下顺序; 普通节点的层叠等级优先由其所在的层叠上下文决定,层叠等级的比较只有在当前层叠上下文中才有意义,脱离当前层叠上下文的比较就变得无意义了。 层叠顺序 在同一个层叠上下文中如果有多个元素,那么他们之间的层叠顺序是怎么样的呢? 以下这个列表越往下层叠优先级越高,视觉上的效果就是越容易被用户看到(不会被其他元素覆盖): 层叠上下文的 border 和 background z-index < 0 的子节点 标准流内块级非定位的子节点 浮动非定位的子节点 标准流内行内非定位的子节点 z-index: auto/0 的子节点 z-index > 0 的子节点 如何比较两个元素的层叠等级? 在同一个层叠上下文中,比较两个元素就是按照上图的介绍的层叠顺序进行比较。 如果不在同一个层叠上下文中的时候,那就需要比较两个元素分别所处的层叠上下文的等级。 如果两个元素都在同一个层叠上下文,且层叠顺序相同,则在 HTML 中定义越后面的层叠等级越高。 参考:彻底搞懂 CSS 层叠上下文、层叠等级、层叠顺序、z-index 值和单位 CSS 的声明是由属性和值组成的,而值的类型有许多种: 数值:长度值 ,用于指定例如元素 width、border-width、font-size 等属性的值; 百分比:可以用于指定尺寸或长度,例如取决于父容器的 width、height 或默认的 font-size; 颜色:用于指定 background-color、color 等; 坐标位置:以屏幕的左上角为坐标原点定位元素的位置,比如常见的 background-position、top、right、bottom 和 left 等属性; 函数:用于指定资源路径或背景图片的渐变,比如 url()、linear-gradient() 等; 而还有些值是需要带单位的,比如 width: 100px,这里的 px 就是表示长度的单位,长度单位除了 px 外,比较常用的还有 em、rem、vw/vh 等。那他们有什么区别呢?又应该在什么时候使用它们呢? px 屏幕分辨率是指在屏幕的横纵方向上的像素点数量,比如分辨率 1920×1080 意味着水平方向含有 1920 个像素数,垂直方向含有 1080 个像素数。 而 px 表示的是 CSS 中的像素,在 CSS 中它是绝对的长度单位,也是最基础的单位,其他长度单位会自动被浏览器换算成 px。但是对于设备而言,它其实又是相对的长度单位,比如宽高都为 2px,在正常的屏幕下,其实就是 4 个像素点,而在设备像素比(devicePixelRatio) 为 2 的 Retina 屏幕下,它就有 16 个像素点。所以屏幕尺寸一致的情况下,屏幕分辨率越高,显示效果就越细腻。 讲到这里,还有一些相关的概念需要理清下: 设备像素(Device pixels) 设备屏幕的物理像素,表示的是屏幕的横纵有多少像素点;和屏幕分辨率是差不多的意思。 设备像素比(DPR) 设备像素比表示 1 个 CSS 像素等于几个物理像素。 计算公式:DPR = 物理像素数 / 逻辑像素数; 在浏览器中可以通过 window.devicePixelRatio 来获取当前屏幕的 DPR。 像素密度(DPI/PPI) 像素密度也叫显示密度或者屏幕密度,缩写为 DPI(Dots Per Inch) 或者 PPI(Pixel Per Inch)。从技术角度说,PPI 只存在于计算机显示领域,而 DPI 只出现于打印或印刷领域。 计算公式:像素密度 = 屏幕对角线的像素尺寸 / 物理尺寸 比如,对于分辨率为 750 * 1334 的 iPhone 6 来说,它的像素密度为: 1 Math.sqrt(750 * 750 + 1334 * 1334) / 4.7 = 326ppi 设备独立像素(DIP) DIP 是特别针对 Android 设备而衍生出来的,原因是安卓屏幕的尺寸繁多,因此为了显示能尽量和设备无关,而提出的这个概念。它是基于屏幕密度而计算的,认为当屏幕密度是 160 的时候,px = DIP。 计算公式:dip = px * 160 / dpi em em 是 CSS 中的相对长度单位中的一个。居然是相对的,那它到底是相对的谁呢?它有 2 层意思: 在 font-size 中使用是相对于父元素的 font-size 大小,比如父元素 font-size: 16px,当给子元素指定 font-size: 2em 的时候,经过计算后它的字体大小会是 32px; 在其他属性中使用是相对于自身的字体大小,如 width/height/padding/margin 等; 我们都知道每个浏览器都会给 HTML 根元素 html 设置一个默认的 font-size,而这个值通常是 16px。这也就是为什么 1em = 16px 的原因所在了。 em 在计算的时候是会层层计算的,比如: 1 2 3 <div> <p></p> </div> 1 2 3 4 5 6 div { font-size: 2em; } p { font-size: 2em; } 对于如上一个结构的 HTML,由于根元素 html 的字体大小是 16px,所以 p 标签最终计算出来后的字体大小会是 16 _ 2 _ 2 = 64px rem rem(root em) 和 em 一样,也是一个相对长度单位,不过 rem 相对的是 HTML 的根元素 html。 rem 由于是基于 html 的 font-size 来计算,所以通常用于自适应网站或者 H5 中。 比如在做 H5 的时候,前端通常会让 UI 给 750px 宽的设计图,而在开发的时候可以基于 iPhone X 的尺寸 375px * 812px 来写页面,这样一来的话,就可以用下面的 JS 依据当前页面的视口宽度自动计算出根元素 html 的基准 font-size 是多少。 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 ;(function (doc, win) { var docEl = doc.documentElement, resizeEvt = 'orientationchange' in window ? 'orientationchange' : 'resize', psdWidth = 750, // 设计图宽度 recalc = function () { var clientWidth = docEl.clientWidth if (!clientWidth) return if (clientWidth >= 640) { docEl.style.fontSize = 200 * (640 / psdWidth) + 'px' } else { docEl.style.fontSize = 200 * (clientWidth / psdWidth) + 'px' } } if (!doc.addEventListener) return // 绑定事件的时候最好配合防抖函数 win.addEventListener(resizeEvt, debounce(recalc, 1000), false) doc.addEventListener('DOMContentLoaded', recalc, false) function debounce(func, wait) { var timeout return function () { var context = this var args = arguments clearTimeout(timeout) timeout = setTimeout(function () { func.apply(context, args) }, wait) } } })(document, window) 比如当视口是 375px 的时候,经过计算 html 的 font-size 会是 100px,这样有什么好处呢?好处就是方便写样式,比如从设计图量出来的 header 高度是 50px 的,那我们写样式的时候就可以直接写: 1 2 3 header { height: 0.5rem; } 每个从设计图量出来的尺寸只要除于 100 即可得到当前元素的 rem 值,都不用经过计算,非常方便。偷偷告诉你,如果你把上面那串计算 html 标签 font-size 的 JS 代码中的 200 替换成 2,那在计算 rem 的时候就不需要除于 100 了,从设计图量出多大 px,就直接写多少个 rem。 vw/vh vw 和 vh 分别是相对于屏幕视口宽度和高度而言的长度单位: 1vw = 视口宽度均分成 100 份中 1 份的长度; 1vh = 视口高度均分成 100 份中 1 份的长度; 在 JS 中 100vw = window.innerWidth,100vh = window.innerHeight。 vw/vh 的出现使得多了一种写自适应布局的方案,开发者不再局限于 rem 了。 相对视口的单位,除了 vw/vh 外,还有 vmin 和 vmax: vmin:取 vw 和 vh 中值较小的; vmax:取 vw 和 vh 中值较大的; 颜色体系 CSS 中用于表示颜色的值种类繁多,足够构成一个体系,所以这里就专门拿出一个小节来讲解它。 根据 CSS 颜色草案 中提到的颜色值类型,大概可以把它们分为这几类: 颜色关键字 transparent 关键字 currentColor 关键字 RGB 颜色 HSL 颜色 颜色关键字 颜色关键字(color keywords)是不区分大小写的标识符,它表示一个具体的颜色,比如 white(白),黑(black)等; 可接受的关键字列表在 CSS 的演变过程中发生了改变: CSS 标准 1 只接受 16 个基本颜色,称为 VGA 颜色,因为它们来源于 VGA 显卡所显示的颜色集合而被称为 VGA colors (视频图形阵列色彩)。 CSS 标准 2 增加了 orange 关键字。 从一开始,浏览器接受其它的颜色,由于一些早期浏览器是 X11 应用程序,这些颜色大多数是 X11 命名的颜色列表,虽然有一点不同。SVG 1.0 是首个正式定义这些关键字的标准;CSS 色彩标准 3 也正式定义了这些关键字。它们经常被称作扩展的颜色关键字, X11 颜色或 SVG 颜色 。 CSS 颜色标准 4 添加可 rebeccapurple 关键字来纪念 web 先锋 Eric Meyer。 如下这张图是 16 个基础色,又叫 VGA 颜色。截止到目前为止 CSS 颜色关键字总共有 146 个,这里可以查看 完整的色彩关键字列表。 需要注意的是如果声明的时候的颜色关键字是错误的,浏览器会忽略它。 transparent 关键字 transparent 关键字表示一个完全透明的颜色,即该颜色看上去将是背景色。从技术上说,它是带有 alpha 通道为最小值的黑色,是 rgba(0,0,0,0) 的简写。 透明关键字有什么应用场景呢? 实现三角形 下面这个图是用 4 条边框填充的正方形,看懂了它你大概就知道该如何用 CSS 写三角形了。 1 2 3 4 5 6 7 8 div { border-top-color: #ffc107; border-right-color: #00bcd4; border-bottom-color: #e26b6b; border-left-color: #cc7cda; border-width: 50px; border-style: solid; } 用 transparent 实现三角形的原理: 首先宽高必须是 0px,通过边框的粗细来填充内容; 那条边需要就要加上颜色,而不需要的边则用 transparent; 想要什么样姿势的三角形,完全由上下左右 4 条边的中有颜色的边和透明的边的位置决定; 等腰三角形:设置一条边有颜色,然后紧挨着的 2 边是透明,且宽度是有颜色边的一半;直角三角形:设置一条边有颜色,然后紧挨着的任何一边透明即可。 看下示例: 增大点击区域 常常在移动端的时候点击的按钮的区域特别小,但是由于现实效果又不太好把它做大,所以常用的一个手段就是通过透明的边框来增大按钮的点击区域: 1 2 3 .btn { border: 5px solid transparent; } currentColor 关键字 currentColor 会取当前元素继承父级元素的文本颜色值或声明的文本颜色值,即 computed 后的 color 值。 比如,对于如下 CSS,该元素的边框颜色会是 red: 1 2 3 4 .btn { color: red; border: 1px solid currentColor; } RGB[A] 颜色 RGB[A] 颜色是由 R(red)-G(green)-B(blue)-A(alpha) 组成的色彩空间。 在 CSS 中,它有两种表示形式: 十六进制符号; 函数符; 十六进制符号 RGB 中的每种颜色的值范围是 00~ff,值越大表示颜色越深。所以一个颜色正常是 6 个十六进制字符加上 # 组成,比如红色就是 #ff0000。 如果 RGB 颜色需要加上不透明度,那就需要加上 alpha 通道的值,它的范围也是 00~ff,比如一个带不透明度为 67% 的红色可以这样写 #ff0000aa。 使用十六进制符号表示颜色的时候,都是用 2 个十六进制表示一个颜色,如果这 2 个字符相同,还可以缩减成只写 1 个,比如,红色 #f00;带 67% 不透明度的红色 #f00a。 函数符 当 RGB 用函数表示的时候,每个值的范围是 0255 或者 0%100%,所以红色是 rgb(255, 0, 0), 或者 rgb(100%, 0, 0)。 如果需要使用函数来表示带不透明度的颜色值,值的范围是 01 及其之间的小数或者 0%100%,比如带 67% 不透明度的红色是 rgba(255, 0, 0, 0.67) 或者 rgba(100%, 0%, 0%, 67%) 需要注意的是 RGB 这 3 个颜色值需要保持一致的写法,要嘛用数字要嘛用百分比,而不透明度的值的可以不用和 RGB 保持一致写法。比如 rgb(100%, 0, 0) 这个写法是无效的;而 rgb(100%, 0%, 0%, 0.67) 是有效的。 在第 4 代 CSS 颜色标准中,新增了一种新的函数写法,即可以把 RGB 中值的分隔逗号改成空格,而把 RGB 和 alpha 中的逗号改成 /,比如带 67% 不透明度的红色可以这样写 rgba(255 0 0 / 0.67)。另外还把 rgba 的写法合并到 rgb 函数中了,即 rgb 可以直接写带不透明度的颜色。 HSL[A] 颜色 HSL[A] 颜色是由色相(hue)-饱和度(saturation)-亮度(lightness)-不透明度组成的颜色体系。 色相(H)是色彩的基本属性,值范围是 0360 或者 0deg360deg, 0 (或 360) 为红色, 120 为绿色, 240 为蓝色; 饱和度(S)是指色彩的纯度,越高色彩越纯,低则逐渐变灰,取 0~100% 的数值;0% 为灰色, 100% 全色; 亮度(L),取 0~100%,0% 为暗,100% 为白; 不透明度(A),取 0100%,或者 01 及之间的小数; 写法上可以参考 RGB 的写法,只是参数的值不一样。 给一个按钮设置不透明度为 67% 的红色的 color 的写法,以下全部写法效果一致: 1 2 3 4 5 6 7 8 button { color: #ff0000aa; color: #f00a; color: rgba(255, 0, 0, 0.67); color: rgb(100% 0% 0% / 67%); color: hsla(0, 100%, 50%, 67%); color: hsl(0deg 100% 50% / 67%); } 小提示:在 Chrome DevTools 中可以按住 shift + 鼠标左键可以切换颜色的表示方式。 媒体查询 媒体查询是指针对不同的设备、特定的设备特征或者参数进行定制化的修改网站的样式。 你可以通过给 <link> 加上 media 属性来指定该样式文件只能对什么设备生效,不指定的话默认是 all,即对所有设备都生效: 1 2 <link rel="stylesheet" src="styles.css" media="screen" /> <link rel="stylesheet" src="styles.css" media="print" /> 都支持哪些设备类型? all:适用于所有设备; print:适用于在打印预览模式下在屏幕上查看的分页材料和文档; screen:主要用于屏幕; speech:主要用于语音合成器。 需要注意的是:通过 media 指定的 资源尽管不匹配它的设备类型,但是浏览器依然会加载它。 除了通过 <link> 让指定设备生效外,还可以通过 @media 让 CSS 规则在特定的条件下才能生效。响应式页面就是使用了 @media 才让一个页面能够同时适配 PC、Pad 和手机端。 1 2 @media (min-width: 1000px) { } 媒体查询支持逻辑操作符: and:查询条件都满足的时候才生效; not:查询条件取反; only:整个查询匹配的时候才生效,常用语兼容旧浏览器,使用时候必须指定媒体类型; 逗号或者 or:查询条件满足一项即可匹配; 媒体查询还支持众多的媒体特性,使得它可以写出很复杂的查询条件: 1 2 3 /* 用户设备的最小高度为680px或为纵向模式的屏幕设备 */ @media (min-height: 680px), screen and (orientation: portrait) { } 常见需求 自定义属性 之前我们通常是在预处理器里才可以使用变量,而现在 CSS 里也支持了变量的用法。通过自定义属性就可以在想要使用的地方引用它。 自定义属性也和普通属性一样具有级联性,申明在 :root 下的时候,在全文档范围内可用,而如果是在某个元素下申明自定义属性,则只能在它及它的子元素下才可以使用。 自定义属性必须通过 --x 的格式申明,比如:–theme-color: red; 使用自定义属性的时候,需要用 var 函数。比如: 1 2 3 4 5 6 7 <!-- 定义自定义属性 -- > :root { --theme-color: red; } <!-- 使用变量 -- > h1 { color: var(--theme-color); } 上图这个是使用 CSS 自定义属性配合 JS 实现的动态调整元素的 box-shadow,具体可以看这个 codepen demo。 1px 边框解决方案 Retina 显示屏比普通的屏幕有着更高的分辨率,所以在移动端的 1px 边框就会看起来比较粗,为了美观通常需要把这个线条细化处理。这里有篇文章列举了 7 种方案可以参考一下:7 种方法解决移动端 Retina 屏幕 1px 边框问题 而这里附上最后一种通过伪类和 transform 实现的相对完美的解决方案: 只设置单条底部边框: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 .scale-1px-bottom { position: relative; border: none; } .scale-1px-bottom::after { content: ''; position: absolute; left: 0; bottom: 0; background: #000; width: 100%; height: 1px; -webkit-transform: scaleY(0.5); transform: scaleY(0.5); -webkit-transform-origin: 0 0; transform-origin: 0 0; } 同时设置 4 条边框: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 .scale-1px { position: relative; margin-bottom: 20px; border: none; } .scale-1px::after { content: ''; position: absolute; top: 0; left: 0; border: 1px solid #000; -webkit-box-sizing: border-box; box-sizing: border-box; width: 200%; height: 200%; -webkit-transform: scale(0.5); transform: scale(0.5); -webkit-transform-origin: left top; transform-origin: left top; } 清除浮动 什么是浮动:浮动元素会脱离文档流并向左/向右浮动,直到碰到父元素或者另一个浮动元素。 为什么要清楚浮动,它造成了什么问题? 因为浮动元素会脱离正常的文档流,并不会占据文档流的位置,所以如果一个父元素下面都是浮动元素,那么这个父元素就无法被浮动元素所撑开,这样一来父元素就丢失了高度,这就是所谓的浮动造成的父元素高度坍塌问题。 父元素高度一旦坍塌将对后面的元素布局造成影响,为了解决这个问题,所以需要清除浮动,让父元素恢复高度,那该如何做呢? 这里介绍两种方法:通过 BFC 来清除、通过 clear 来清除。 BFC 清除浮动 前面介绍 BFC 的时候提到过,计算 BFC 高度的时候浮动子元素的高度也将计算在内,利用这条规则就可以清楚浮动。 假设一个父元素 parent 内部只有 2 个子元素 child,且它们都是左浮动的,这个时候 parent 如果没有设置高度的话,因为浮动造成了高度坍塌,所以 parent 的高度会是 0,此时只要给 parent 创造一个 BFC,那它的高度就能恢复了。 而产生 BFC 的方式很多,我们可以给父元素设置 overflow: auto 来简单的实现 BFC 清除浮动,但是为了兼容 IE 最好用 overflow: hidden。 1 2 3 .parent { overflow: hidden; } 通过 overflow: hidden 来清除浮动并不完美,当元素有阴影或存在下拉菜单的时候会被截断,所以该方法使用比较局限。 通过 clear 清除浮动 我先把结论贴出来: 1 2 3 4 5 6 7 8 .clearfix { zoom: 1; } .clearfix::after { content: ''; display: block; clear: both; } 这种写法的核心原理就是通过 ::after 伪元素为在父元素的最后一个子元素后面生成一个内容为空的块级元素,然后通过 clear 将这个伪元素移动到所有它之前的浮动元素的后面,画个图来理解一下。 可以结合这个 codepen demo 一起理解上图的 clear 清楚浮动原理。 上面这个 demo 或者图里为了展示需要所以给伪元素的内容设置为了 ::after,实际使用的时候需要设置为空字符串,让它的高度为 0,从而父元素的高度都是由实际的子元素撑开。 该方式基本上是现在人人都在用的清除浮动的方案,非常通用。 参考:CSS 中的浮动和清除浮动,梳理一下 消除浏览器默认样式 针对同一个类型的 HTML 标签,不同的浏览器往往有不同的表现,所以在网站制作的时候,开发者通常都是需要将这些浏览器的默认样式清除,让网页在不同的浏览器上能够保持一致。 针对清除浏览器默认样式这件事,在很早之前 CSS 大师 Eric A. Meyer 就干过。它就是写一堆通用的样式用来重置浏览器默认样式,这些样式通常会放到一个命名为 reset.css 文件中。比如大师的 reset.css 是这么写的: 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 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; vertical-align: baseline; } /* HTML5 display-role reset for older browsers */ article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { display: block; } body { line-height: 1; } ol, ul { list-style: none; } blockquote, q { quotes: none; } blockquote:before, blockquote:after, q:before, q:after { content: ''; content: none; } table { border-collapse: collapse; border-spacing: 0; } 他的这份 reset.css 据说是被使用最广泛的重设样式的方案了。 除了 reset.css 外,后来又出现了 Normalize.css 。关于 Normalize.css, 其作者 necolas 专门写了一篇文章介绍了它,并谈到了它和 reset.css 的区别。这个是他写那篇文章的翻译版:让我们谈一谈 Normalize.css。 文章介绍到:Normalize.css 只是一个很小的 CSS 文件,但它在默认的 HTML 元素样式上提供了跨浏览器的高度一致性。相比于传统的 CSS reset,Normalize.css 是一种现代的、为 HTML5 准备的优质替代方案,现在已经有很多知名的框架和网站在使用它了。 Normalize.css 的具体样式可以看这里 Normalize.css 区别于 reset.css,Normalize.css 有如下特点: reset.css 几乎为所有标签都设置了默认样式,而 Normalize.css 则是有选择性的保护了部分有价值的默认值; 修复了很多浏览器的 bug,而这是 reset.css 没做到的; 不会让你的调试工具变的杂乱,相反 reset.css 由于设置了很多默认值,所以在浏览器调试工具中往往会看到一大堆的继承样式,显得很杂乱; Normalize.css 是模块化的,所以可以选择性的去掉永远不会用到的部分,比如表单的一般化; Normalize.css 有详细的说明文档; 长文本处理 默认:字符太长溢出了容器 字符超出部分换行 字符超出位置使用连字符 单行文本超出省略 多行文本超出省略 查看以上这些方案的示例: codepen demo 有意思的是刚好前两天看到 chokcoco 针对文本溢出也写了一篇文章,主要突出的是对整块的文本溢出处理。啥叫整块文本?比如,下面这种技术标签就是属于整块文本: 另外他还对 iOS/Safari 做了兼容处理,感兴趣的可以去阅读下:CSS 整块文本溢出省略特性探究。 水平垂直居中 让元素在父元素中呈现出水平垂直居中的形态,无非就 3 种情况: 单行的文本、inline 或者 inline-block 元素; 固定宽高的块级盒子; 不固定宽高的块级盒子; 以下列到的所有水平垂直居中方案这里写了个 codepen demo,配合示例阅读效果更佳。 单行的文本、inline 或 inline-block 元素 水平居中 此类元素需要水平居中,则父级元素必须是块级元素(block level),且父级元素上需要这样设置样式: 1 2 3 .parent { text-align: center; } 垂直居中 方法一:通过设置上下内间距一致达到垂直居中的效果: 1 2 3 4 .single-line { padding-top: 10px; padding-bottom: 10px; } 方法二:通过设置 height 和 line-height 一致达到垂直居中: 1 2 3 4 .single-line { height: 100px; line-height: 100px; } 固定宽高的块级盒子 方法一:absolute + 负 margin 方法二:absolute + margin auto 方法三:absolute + calc 不固定宽高的块级盒子 这里列了 6 种方法,参考了颜海镜 写的文章 ,其中的两种 line-height 和 writing-mode 方案看后让我惊呼:还有这种操作?学到了学到了。 方法一:absolute + transform 方法二:line-height + vertical-align 方法三:writing-mode 方法四:table-cell 方法五:flex 方法六:grid 常用布局 两栏布局(边栏定宽主栏自适应) 针对以下这些方案写了几个示例: codepen demo 方法一:float + overflow(BFC 原理) 方法二:float + margin 方法三:flex 方法四:grid 三栏布局(两侧栏定宽主栏自适应) 针对以下这些方案写了几个示例: codepen demo 方法一:圣杯布局 方法二:双飞翼布局 方法三:float + overflow(BFC 原理) 方法四:flex 方法五:grid 多列等高布局 结合示例阅读更佳:codepen demo 方法一:padding + 负 margin 方法二:设置父级背景图片 三行布局(头尾定高主栏自适应) 列了 4 种方法,都是基于如下的 HTML 和 CSS 的,结合示例阅读效果更佳:codepen demo 1 2 3 4 5 6 7 <div class="layout"> <header></header> <main> <div class="inner"></div> </main> <footer></footer> </div> 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 html, body, .layout { height: 100%; } body { margin: 0; } header, footer { height: 50px; } main { overflow-y: auto; } 方法一:calc 方法二:absolute 方法三:flex 方法四:grid 结了个尾 这是我断断续续写了 2 周完成的文章,算是自己对 CSS 的一个总结,虽然写得很长,但不足以覆盖所有 CSS 的知识,比如动画和一些 CSS3 的新特性就完全没涉及,因为这要写下来估计得有大几万字(其实就是懒 😝 )。 码字作图不易,如果喜欢或者对你有丝毫帮助的话,帮忙点个 👍 哈,点赞就是我的动力。同时也希望自己能坚持认真的写下去,因为在总结提升自己的同时如果也能帮助更多的前端 er,那将会让我感觉很开心。

2021/3/22
articleCard.readMore

为你的网站加上 WebP 格式的图片吧

之前写了一篇文章:jpg、gif、png 和 svg 用于 web 上,我们该如何选择最合适的图像格式,介绍了这几种图片格式的特点,以及如何为网站选择合适的图片,然后评论区有位大佬让我补充下 WebP 格式,于是乎它来了。 什么是 WebP 格式 WebP 是一种现代图像格式,可为 Web 上的图像提供出色的无损和有损压缩。 使用 WebP,网站管理员和 Web 开发人员可以创建更小,更丰富的图像,从而使 Web 更快。 与 PNG 相比,WebP 无损图像的尺寸要小 26%。 在同等的 SSIM 质量指数下,WebP 有损图像比同类 JPEG 图像小 25-34%。 无损 WebP 支持透明性(也称为 Alpha 通道),而仅增加了 22% 的字节数。 对于可以接受有损 RGB 压缩的情况,有损 WebP 还支持透明性,与 PNG 相比,文件大小通常小 3 倍。 上面这 3 段话来源于 https://developers.google.com/speed/webp 为什么你需要这个格式 因为 WebP 图像比 JPEG 和 PNG 图像小-通常文件大小减少 25-35%。这样可以减小页面大小并提高性能。举 2 个例子: YouTube 发现,切换到 WebP 缩略图可将页面加载速度提高 10%。 当他们切换到使用 WebP 时,Facebook 节省了 25-35% 的 JPEG 文件大小,节省了 80% 的 PNG 文件大小。 WebP 是 JPEG,PNG 和 GIF 图像的理想替代品。 另外,WebP 提供无损压缩和有损压缩。 在无损压缩中,不会丢失任何数据。 有损压缩会减小文件大小,但会以降低图像质量为代价。 如何将图片转成 WebP 格式 通常,开发者会用如下两种方式来将图片转成 WebP 格式: cwebp 命令行工具 Imagemin WebP 插件(npm 包) 如果你的项目比较简单或者你仅需要将图片转化一次,那么 cwebp 命令行工具是一个很好的选择;而如果你使用构建工具比如 Webpack 或 Gulp 等去构建你的项目的时候,那么将图片转 WebP 使用 Imagemin WebP 插件就是你最好的选择了。 当你需要把图片转成 WebP 格式的时候,你可以设置很多的参数,但是你最需要关心的就仅仅只是压缩质量,你可以指定一个压缩的质量等级,它的范围是从 0 ~ 100,0 表示质量最差,100 是最好。那么该把它设置成多少才最合适呢?这就需要你好好的花费一翻功夫去实践到底哪个质量等级是既兼顾了呈现质量又不会使得文件太大呢? 使用 cwebp 转换图片 使用这个命令前需要先安装它的工具包 webp,按照如下几个步骤操作即可: 可以去这下载 webp 工具包 ,这个网站提供了很多版本的包,选择一个和电脑匹配的包,比如我是 Mac 系统,我下载的是 libwebp-1.1.0-rc2-mac-10.15 版本的包,下载完成后解压,然后把解压后的文件夹放到你想要存放的目录,我是把他放到了 /Applications/Utilities/ 下; 设置环境变量,使得命令可以在终端下使用。而我是直接修改当前用户下的 .bash_profile 文件,该文件目录:/Users/ccp/.bash_profile: 1 export PATH=$PATH:/Applications/Utilities/libwebp-1.1.0-rc2-mac-10.15/bin 修改完成后,需要让命令立即生效的话,需要在终端下运行:source .bash_profile 然后就可以在终端下输入 cwebp,如果它能提示你如何使用该命令,说明这个包安装好了,可以正常使用 cwebp 命令了。 阅读 libwebp 包的下的 READMD,可以发现,该包除了有 cwebp 命令外,还提供了很多额外的命令,具体用法可以自行查阅相关文档。 除了以上这种操作稍微麻烦的安装方式外,还可以使用 OS X 的包管理工具进行安装(你怎么不早说 😭): Homebrew WebP package Macports WebP package 安装成功后,就可以愉快的使用 cwebp 命令了。来看看以下操作: 使用 cwebp 的默认压缩设置转换单张图片(默认是有损压缩,且默认的压缩的质量参数是 75): 1 cwebp images/flower.jpg -o images/flower.webp 使用 50 质量等级去转换单张图片: 1 cwebp -q 50 images/flower.jpg -o images/flower.webp 转换指定目录下的所有文件: 1 for file in images/*; do cwebp "$file" -o "${file%.*}.webp"; done 使用 Imagemin 转换图片 Imagemin Webp 插件可以在 Node 环境中独立使用,也可以结合 Webpack 等构建工具使用。通常只需要 10 行左右代码即可配置完成。 Node 环境下配置,以下代码会把 images 目录下的图片转成 WebP 图片后存到 compressed_images 目录下: 1 2 3 4 5 6 7 8 9 const imagemin = require('imagemin') const imageminWebp = require('imagemin-webp') imagemin(['images/*'], { destination: 'compressed_images', plugins: [imageminWebp({ quality: 50 })], }).then(() => { console.log('Done!') }) 在构建工具 Webpack 下使用,这里还配合了 copy-webpack-plugin 插件实现图片的复制。以下代码来源于 webp-webpack: 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 // Copyright 2018 Google LLC. // SPDX-License-Identifier: Apache-2.0 const ImageminWebP = require('imagemin-webp') const ImageminPlugin = require('imagemin-webpack-plugin').default const CopyWebpackPlugin = require('copy-webpack-plugin') const path = require('path') module.exports = { entry: './index.js', output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), }, plugins: [ new CopyWebpackPlugin([ { from: './images/**/**', to: './images/[name].webp', }, ]), new ImageminPlugin({ // imagemin-webp docs: https://github.com/imagemin/imagemin-webp plugins: [ImageminWebP({ quality: 50 })], }), ], } 你也可以尝试使用 imagemin-webp-webpack-plugin 这个插件,因为我看它最近 4 个月内有过更新,下载量也快 10K 了,应该可以满足需求。 对比 WebP 和 其他图片格式 下面我用 PS 做了一张图片: 然后用它导出了 4 张图片,它们的信息如下: jpg_60.jpg:质量为 60 的 JPG 格式图片,48KB; jpg_100.jpg:质量为 100 的 JPG 格式图片,142KB; png_8.png:8 位的 PNG 格式图片,60KB; png_24.png:24 位的 PNG 格式图片,200KB; 然后我用如下命令,批量将他们转成了 WebP 格式的图片: 1 for file in Downloads/img/*; do cwebp "$file" -o "${file%.*}.webp"; done 然后可以看到 Downloads/img/ 文件夹下所有文件的信息如下: 发现这 4 张图片被转成 WebP 格式的图片后文件大小居然都差不多大,大概都是 22KB,比原来真的小了太多了,尤其是 PNG 24 和 JPG 质量 100 的图片体积缩减更加明显。而最差的 JPG 质量 60 的图片再被转成 WebP 格式后,体积竟然还缩减了 54% 左右。 接下来我们再把这 4 张图片通过在线工具 TinyPNG 压缩一下,以下是文件压缩后的信息(左右两列绿色数字分别表示:文件压缩前大小和压缩后大小): 发现 TinyPNG 对 PNG 24 和 JPG 质量 100 的图片压缩效果比较明显。**另外将这压缩过的 4 张图片和 WebP 格式的图片(22KB)相比,发现 WebP 图片的文件大小还是明显小很多,小了超过 50%**,所以这就是为什么建议在 Web 应用上使用 WebP 图片的原因,真的优化太大了。 让 WebP 图片在 Mac 下正常预览 从这往上翻的第二张图,可以发现 WebP 格式的图片在 Mac 下是无法正常预览的,所以需要给 Mac 加上这种能力。另外需要说一句 WebP 图片在 Chrome 下可以很好的支持了,直接将图片拖到浏览器中即可显示。 说到预览,看一下 WebP 在各浏览器下的兼容程度: 由上图可以看出,基本上现代浏览器已经能很好的支持 WebP 图片了,所以在生产环境上使用它是一点问题都没有。 Mac 需要正常预览 WebP 图片,可以在这里 qlImageSize 下载了 2 个插件来支持它: qlImageSize:QuickLook 插件,用来预览 WebP 图片,且能在标题栏里显示尺寸、文件大小等信息,另外还可以让 WebP 图片在访达中以缩略图的形式显示出来。 mdImageSize:Spotlight 插件,用来在显示简介的时候能够支持 WebP 图片的信息; 下载完这 2 个插件包后,解压,然后把插件复制到对应的目录下保存: qlImageSize 复制到 /Library/QuickLook 目录下; mdImageSize 复制到 /Library/Spotlight 目录下; 这个时候 WebP 图片应该就能快速预览了,还不行的话就重启下访达进程(按住 option,同时鼠标右键点击访达,选择重新开启)。 兼容不同浏览器 如果你的网站对于不兼容 WebP 格式的图片的浏览器(比如 IE11)也有需求的话,那这里有一套方案可以让图片不会因为浏览器兼容性而显示出错: 1 2 3 4 5 <picture> <source type="image/webp" srcset="flower.webp" /> <source type="image/jpeg" srcset="flower.jpg" /> <img src="flower.jpg" alt="" /> </picture> 对于上面这段代码,浏览器会首先检测是否支持 <source> 标签列表里的资源,如果兼容的话,默认会加载 flower.webp 图片,如果都不支持 <source> 列表里的资源的话,则会去加载 <img> 里指定的图片。 接下来该干什么 之前我做的项目里的图片基本都是 JPG\PNG 格式的,我找了一张我们网站首页的 Banner 图,就是那种 1920px 宽度的图片,它在被压缩工具压缩后文件大小还有 275 KB ,而当我用 cwebp 工具用 -q 75 的质量参数去转换成 WebP 图片后,你们知道它的文件大小变成多少了吗?它变成 50KB 😱 真的的不试不知道,一试吓一跳,原来 WebP 格式的图片能够带来这么大的优化效果,最关键的是 2 张图片用肉眼看起来显示效果差不多。 真的很棒 👍,WebP 凭借一己之力将网站的性能优化提高了一个档次。所以接下来 KPI 就有了:可以很愉快的把项目里所有的图片都转成 WebP 图片了。这么干完之后,你就可以和领导提涨薪了,领导领导我把我们网站优化了一下,性能提升了 10% ~ 20% 左右。不过接下来发生什么,可不要来找我哈。 参考文章 https://web.dev/serve-images-webp/ https://developers.google.com/speed/webp https://github.com/Nyx0uf/qlImageSize

2021/1/23
articleCard.readMore

让人爱不释手的 JS 扩展操作符 13 用

我相信你一定或多或少的接触或使用过 JS 中的扩展操作符(Spread Operator),在基本形式中,扩展操作符看起来像三个点,比如如下这样: 1 [...arr] 而实际上,它也就是这么用的,但是如果事情有这么简单,就不用我在这里写了。扩展操作符给我最大的印象就是,这玩意还挺方便的,然而最近写代码的时候经常性的遇到需要使用扩展操作符的场景,所以我干脆在网上找了些资料,把平时常见的应用场景给罗列了下,发现这个操作符是真的强大,有多强大?来看看下面这些用法吧。 1. 字符串转数组 字符串转数组最普遍的做法是这样: 1 2 3 let str = 'hello' let arr = str.split('') console.log(arr) // ['h', 'e', 'l', 'l', 'o'] 而使用了扩展操作符后可以这样: 1 2 3 let str = 'hello' let arr = [...str] console.log(arr) // ['h', 'e', 'l', 'l', 'o'] 2. 将类数组转换为数组 在 JS 中有一种数据结构叫做 NodeList,它和数组很相似,也被叫做“类数组”,类数组是什么?在 MDN 中是这么定义它的: 类数组:拥有一个 length 属性和若干索引属性的任意对象。 类数组有哪些呢?以下这些可以看成是类数组: NodeList:document.querySelectorAll() 返回的对象; HTMLCollection:document.getElementsByTagName() 返回的对象; Arguments:函数里的参数对象; 类数组没有数组的一些方法比如 push、map 等,所以经常需要将它们转成数组,而通常我们是这么转化的: 1 2 3 4 5 6 7 8 9 10 11 12 13 let nodeList = document.querySelectorAll('div') console.log(nodeList instanceof NodeList) // true let arr = Array.apply(null, nodeList) console.log(arr instanceof Array) // true // 或者 let arr2 = [].slice.call(nodeList) console.log(arr2 instanceof Array) // true // 又或者 let arr3 = Array.from(nodeList) console.log(arr3 instanceof Array) // true 而有了扩展操作符可以这么做: 1 2 3 let nodeList = document.querySelectorAll('div') let arr = [...nodeList] console.log(arr instanceof Array) // true 3. 向数组中添加项 往数组中添加几项通常这样操作: 1 2 3 4 5 6 7 8 9 10 11 12 13 let arr = [5] // 从头部添加 arr.unshift(1, 2) console.log(arr) // [1, 2, 5] // 从尾部添加 arr.push(6, 7) console.log(arr) // [1,2, 5, 6, 7] // 从任意位置添加 arr.splice(2, 0, 3, 4) console.log(arr) // [1,2, 3, 4, 5, 6, 7] 使用扩展操作符后: 1 2 3 let arr = [3, 4] arr = [1, 2, ...arr, 5, 6] console.log(arr) // [1, 2, 3, 4, 5, 6] 4. 拷贝数组和对象 通常拷贝一个数组,可以这么做: 1 2 3 4 5 6 7 8 9 let arr = [1, 3, 5, 7] let arr2 = arr.concat() // 或者 let arr3 = arr.slice() arr[0] = 2 console.log(arr) // [2, 3, 5, 7] console.log(arr2) // [1, 3, 5, 7] console.log(arr3) // [1, 3, 5, 7] 但是有了扩展操作符,拷贝数组就能写得很简单: 1 2 3 4 let arr = [1, 3, 5, 7] let arr2 = [...arr] arr[0] = 2 console.log(arr2) // [1, 3, 5, 7] 同样的,扩展操作符还能拷贝对象。 拷贝对象的通常做法: 1 2 3 4 5 let person = { name: '布兰', age: 12} let p2 = Object.assign({}, person) person.age = 20 console.log(person) // { name: '布兰', age: 20 } console.log(p2) // { name: '布兰', age: 12 } 有了扩展操作符,拷贝一个对象就相当方便了: 1 2 3 4 5 6 7 8 let person = { name: '布兰', age: 12 } let p2 = {...person} person.age = 20 console.log(person) // { name: '布兰', age: 20 } console.log(p2) // { name: '布兰', age: 12 } // 甚至还可以这么写 let {...p3} = person 注意:扩展操作符只能深拷贝结构为一层的对象,如果对象是两层的结构,那么使用扩展操作符拷贝会是浅拷贝。 5. 合并数组或对象 数组合并通常是这么做的: 1 2 3 4 let arr1 = [1, 3, 5] let arr2 = [2, 4, 6] let arr3 = arr1.concat( arr2 ) console.log(arr3) // [1, 3, 5, 2, 4, 6] 使用扩展操作符后,可以这么写: 1 2 3 4 let arr1 = [1, 3, 5] let arr2 = [2, 4, 6] let arr3 = [...arr1, ...arr2] console.log(arr3) // [1, 3, 5, 2, 4, 6] 对了,它除了能合并数组外还能合并对象呢。合并对象,通常的做法是: 1 2 3 4 let p1 = { name: '布兰' } let p2 = { age: 12 } let p3 = Object.assign({}, p1, p2) console.log(p3) // { name: '布兰', age: 12} 用扩展操作符合并对象: 1 2 3 4 let p1 = { name: '布兰' } let p2 = { age: 12 } let p3 = { ...p1, ...p2 } console.log(p3) // { name: '布兰', age: 12} 6. 解构对象 经常我们给对象设置参数的时候会这么做: 1 2 3 4 5 6 7 8 let person = { name: '布兰', age: 12, sex: 'male' } let name = person.name let age = person.age let sex = person.sex 而有了扩展操作符,我们就可以这么写,不过其实如下这种写法并不是扩展操作符的写法🤣,而是剩余操作符的写法,虽然写出来后看起来差不多,但就在操作对象这一点上,基本上可以认为它和扩展操作符是相反的操作,扩展操作符是用来展开对象的属性到多个变量上,而剩余操作符是用来把多个参数凝聚到一个变量上。 1 2 3 4 5 6 7 8 let person = { name: '布兰', age: 12, sex: 'male' } let { name, ...reset } = person console.log(name) // '布兰' console.log(reset) // { age: 12, sex: 'male' } 7. 给对象添加属性 给对象加属性通常这样加: 1 2 3 let person = { name: '布兰' } person.age = 12 console.log(person) // { name: '布兰', age: 12 } 使用扩展操作符给对象添加属性: 1 2 3 let person = { name: '布兰' } person = {...person, age: 12} console.log(person) // { name: '布兰', age: 12 } 关于使用扩展操作符给对象添加属性,这里有 2 个小技巧: 给新对象设置默认值: 1 2 3 // 默认 person 对象的 age 属性值 为 12 let person = {age: 12, ...{ name: '布兰' } } console.log(person) // { age: 12, name: '布兰' } 重写对象属性 1 2 3 4 5 let person = { name: '布兰', age: 12 } // person 对象的 age 属性被重写为 20 person = {...person, age: 20 } console.log(person) // { name: '布兰', age: 20 } 8. 设置对象 Getter 设置对象 Getter 通常做法是这样: 1 2 3 4 5 6 7 let person = { name: '布兰' } Object.defineProperty(person, 'age', { get() { return 12 }, enumerable: true, configurable: true }) console.log(person.age) // 12 而有了扩展操作符后可以这么写: 1 2 3 4 5 6 let person = { name: '布兰' } person = { ...person, get age() { return 12 } } console.log(person.age) // 12 9. 将数组作为函数参数展开 如果我们有一个形参是多个参数的函数,但是当调用的时候发现入参却是一个数组,常规做法是这样: 1 2 3 let arr = [1, 3, 5] function fn(a, b, c) { } fn.apply(null, arr) 使用扩展操作符后,就简单多了: 1 2 3 let arr = [1, 3, 5] function fn(a, b, c) { } fn(...arr) 10. 无限参数的函数 如果有这么一个累加函数,他会把所有传递进来的参数都加起来,普通做法是把参数都整合到数组里,然后这样做: 1 2 3 4 5 function doSum(arr) { return arr.reduce((acc, cur) => acc + cur) } console.log( doSum([1, 3]) ) // 4 console.log( doSum([1, 3, 5]) ) // 9 如果参数不是数组,而是需要一个个传递,相当于函数必须支持无限参数,那可能会这么做: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 function doSum() { let sum = 0 for(let i = 0, l = arguments.length; i < l; i++){ sum += arguments[i] } return sum // 或者 // let args = [].slice.call(arguments) // return args.reduce((acc, cur) => acc + cur) } console.log( doSum(1, 3) ) // 4 console.log( doSum(1, 3, 5) ) // 9 console.log( doSum(1, 3, 5, 7) ) // 16 而有了扩展操作符,就简单多了: 1 2 3 4 5 6 function doSum(...arr) { return arr.reduce((acc, cur) => acc + cur) } console.log( doSum(1, 3) ) // 4 console.log( doSum(1, 3, 5) ) // 9 console.log( doSum(1, 3, 5, 7) ) // 16 11. 扩展函数的剩余参数 有的时候一个函数需要传递很多的参数,比如小程序页面(WePY)的 onLoad 生命周期函数里就可能有很多别的页面传递过来的参数,然后还需要在函数里进行一些数据初始化工作,这样一来就会显得很臃肿不美观,比如: 1 2 3 function init(a, b, x, y) { // 进行一系列初始化数据工作 } 而使用了扩展操作符后,我们就可以按照业务把参数进行解构,把本该在一个函数里进行初始化的工作拆分成多个,可以这么做: 1 2 3 4 5 6 function other( x, y ) {} function init(a, b, ...restConfig) { // 使用 a 和 b 参数进行操作 // 其余参数传给原始函数 return other( ...restConfig ) } 12. 结合 Math 函数使用 比如当需要对一个数组求最大值的时候,通常会这么做: 1 2 3 4 5 6 7 8 9 10 11 let arr = [3, 1, 8, 5, 4] function max(arr) { return [].concat(arr).sort((a, b) => b - a) } console.log(max(arr)[0]) // 8 // 或者 arr.reduce((acc, cur) => Math.max(acc, cur)) // 8 // 又或者 Math.max.apply(null, arr) // 8 但是使用扩展操作符后,能够把给数组求最大值写得更加简洁: 1 2 3 let arr = [3, 1, 8, 5, 4] let max = Math.max(...arr) console.log(max) // 8 13. 在 new 表达式中使用 假设有一个数组格式的日期,想要通过 Date 构造函数创建一个日期实例的话,可能会这么做: 1 2 3 4 5 6 7 8 9 10 let arr = [2021, 1, 1] let date = new Date([].toString.call(arr)) console.log(date) // 'Mon Feb 01 2021 00:00:00 GMT+0800 (中国标准时间)' // 或者 let date2 = new (Function.prototype.bind.apply( Date, [null].concat(arr) )) console.log(date2) // 'Mon Feb 01 2021 00:00:00 GMT+0800 (中国标准时间)' 而有了扩展操作符就简单多了: 1 2 3 let arr = [2021, 1, 1] let date = new Date(...arr) console.log(date) // 'Mon Feb 01 2021 00:00:00 GMT+0800 (中国标准时间)' 总结 这个操作符真可谓使用简单无脑,但是功能效率上不得不说很强大,所以我们要做的就是只要记住在什么时候使用它就好了,于是乎为了让大家能更好的记住这 13 种使用场景,我特意做了一个图,方便大家记忆,是不是很贴?是的话请不要吝啬你的爱心,给个小星星👍吧,感谢感谢。以上这些只列了 13 种写法,我觉得作为一个这么强大的操作符,肯定有更多使用的场景,欢迎把你们知道的写到评论区吧。 参考文章 「建议收藏」送你一份精心总结的3万字ES6实用指南(下) https://github.com/tc39/proposal-object-rest-spread/blob/master/Spread.md https://github.com/tc39/proposal-object-rest-spread/blob/master/Rest.md https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Spread_syntax

2021/1/13
articleCard.readMore

6 分钟了解 HTTP 发展史

HTTP/0.9 HTTP/0.9 是于 1991 年提出的,主要用于学术交流,需求很简单——用来在网络之间传递 HTML 超文本的内容,所以被称为超文本传输协议。整体来看,它的实现也很简单,采用了基于请求响应的模式,从客户端发出请求,服务器返回数据。 完整请求流程 因为 HTTP 都是基于 TCP 协议的,所以客户端先要根据 IP 地址、端口和服务器建立 TCP 连接,而建立连接的过程就是 TCP 协议三次握手的过程。 建立好连接之后,会发送一个 GET 请求行的信息,如 GET /index.html 用来获取 index.html。 服务器接收请求信息之后,读取对应的 HTML 文件,并将数据以 ASCII 字符流返回给客户端。 HTML 文档传输完成后,断开连接。 特点 第一个是只有一个请求行,并没有 HTTP 请求头和请求体,因为只需要一个请求行就可以完整表达客户端的需求了。 第二个是服务器也没有返回头信息,这是因为服务器端并不需要告诉客户端太多信息,只需要返回数据就可以了。 第三个是返回的文件内容是以 ASCII 字符流来传输的,因为都是 HTML 格式的文件,所以使用 ASCII 字节码来传输是最合适的。 HTTP/1.0 HTTP/0.9 存在许多的问题,比如如下的这些: 只支持 HTML 类型文件,无法传输 JS、CSS、字体、图片和视频等类型的文件; 文件传输格式局限于 ASCII,无法输出其他类型编码的文件; 只有请求行,传输给服务器的信息太少; 只响应请求数据,不能传输额外的数据给浏览器。 所以它已经不能满足当时的需求了,于是乎 HTTP/1.0 来了,它带来了这些: 新增了请求头和请求体,能传输更多的信息给服务器,比如如下请求头字段:Accept 文件类型,Accept-Encoding 压缩格式,Accept-Charset 字符编码格式,Accept-Language 国际化语音: 1 2 3 4 Accept: text/html Accept-Encoding: gzip, deflate, br Accept-Charset: ISO-8859-1,utf-8 Accept-Language: zh-CN,zh 请求头新增 User-Agent 字段,用于服务器统计客户端信息: 1 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 新增了响应头,能够告诉浏览器更多的信息,比如 Content-Encoding 表示服务器返回文件的压缩类型,Content-Type 告诉浏览器服务器返回的是什么类型的文件以及使用了什么编码格式: 1 2 Content-Encoding: gzip Content-Type: text/html; charset=utf-8 新增响应行状态码,用于告知浏览器当前请求的状态,比如 200 表示请求成功: 1 HTTP/1.1 200 OK 新增缓存机制,用来缓存已经下载过的资源,减轻了服务端压力。 在构建请求流程上来看,HTTP/1.0 区别于 HTTP/0.9 最大的区别就是在请求和响应的时候新增了不少字段用于在浏览器和服务器之间通信。 HTTP/1.1 HTTP/1.0 虽说已经能够传输不同类型的文件了,但是它还是有缺点的,比如每发出一次 HTTP 请求都需要经历如下阶段: 建立 TCP 连接; HTTP 请求; HTTP 响应; 断开 TCP 连接。 HTTP/1.0 发送多个同域名请求: 可以发现每次请求都需要重新建立 TCP 连接和断开连接的操作,这无疑增加了网络开销,同时也延迟了页面显示。 HTTP/1.1 在请求头中增加了 Connection 字段:用于提供 TCP 的持久连接**: 1 Connection: keep-alive 它默认是开启持久连接的,即对于同一个域名,浏览器默认支持 6 个 TCP 持久连接。 当启用持久连接后,多个同域名下的请求发送会是如下情况: HTTP/1.1 中新增 Host 字段,用于支持虚拟主机 1 Host: bubuzou.com 虚拟主机:一台物理机器上绑定多个虚拟主机,每个虚拟主机有单独的域名,这些域名都公用一个 IP 地址。 HTTP/1.1 通过引入 Chunk transfer 机制来支持动态内容:服务器会将数据分割成若干个任意大小的数据块,每个数据块发送时会附上上个数据块的长度,最后使用一个零长度的块作为发送数据完成的标志。 HTTP/1.1 还引入了客户端 Cookie 机制和安全机制 HTTP/2 我们知道 HTTP/1.1 为网络效率做了大量的优化,最核心的有如下三种方式: 增加了持久连接; 浏览器为每个域名最多同时维护 6 个 TCP 持久连接; 使用 CDN 的实现域名分片机制。 HTTP/1.1 中依然存在的问题 虽然 HTTP/1.1 采取了很多优化资源加载速度的策略,也取得了一定的效果,但是 HTTP/1.1 对带宽的利用率却并不理想,这也是 HTTP/1.1 的一个核心问题。 带宽是指每秒最大能发送或者接收的字节数。我们把每秒能发送的最大字节数称为上行带宽,每秒能够接收的最大字节数称为下行带宽。 之所以说 HTTP/1.1 对带宽的利用率不理想,是因为 HTTP/1.1 很难将带宽用满。比如我们常说的 100M 带宽,实际的下载速度能达到 12.5M/S,而采用 HTTP/1.1 时,也许在加载页面资源时最大只能使用到 2.5M/S,很难将 12.5M 全部用满。 之所以会出现这个问题,主要是 3 个问题导致的: 第一个原因,TCP 的慢启动 一旦一个 TCP 连接建立之后,就进入了发送数据状态,刚开始 TCP 协议会采用一个非常慢的速度去发送数据,然后慢慢加快发送数据的速度,直到发送数据的速度达到一个理想状态,我们把这个过程称为慢启动。这个过程可以想象是一辆车的启动过程,开始的时候慢,当速度起来后加速就更快了。 而之所以说慢启动会带来性能问题,是因为页面中常用的一些关键资源文件本来就不大,如 HTML 文件、CSS 文件和 JavaScript 文件,通常这些文件在 TCP 连接建立好之后就要发起请求的,但这个过程是慢启动,所以耗费的时间比正常的时间要多很多,这样就推迟了宝贵的首次渲染页面的时长了。 第二个原因,同时开启了多条 TCP 连接,那么这些连接会竞争固定的带宽 你可以想象一下,系统同时建立了多条 TCP 连接,当带宽充足时,每条连接发送或者接收速度会慢慢向上增加;而一旦带宽不足时,这些 TCP 连接又会减慢发送或者接收的速度。 这样就会出现一个问题,因为有的 TCP 连接下载的是一些关键资源,如 CSS 文件、JavaScript 文件等,而有的 TCP 连接下载的是图片、视频等普通的资源文件,但是多条 TCP 连接之间又不能协商让哪些关键资源优先下载,这样就有可能影响那些关键资源的下载速度了。 第三个原因,HTTP/1.1 队头阻塞的问题 我们知道在 HTTP/1.1 中使用持久连接时,虽然能公用一个 TCP 管道,但是在一个管道中同一时刻只能处理一个请求,在当前的请求没有结束之前,其他的请求只能处于阻塞状态。这意味着我们不能随意在一个管道中发送请求和接收内容。 这是一个很严重的问题,因为阻塞请求的因素有很多,并且都是一些不确定性的因素,假如有的请求被阻塞了 5 秒,那么后续排队的请求都要延迟等待 5 秒,在这个等待的过程中,带宽、CPU 都被白白浪费了。 HTTP/2 的多路复用 为了解决 HTTP/1.1 中存在的问题,在 HTTP/2 中采用最具颠覆性的方案:多路复用机制。 HTTP/2 多路复用是什么 HTTP/2 的多路复用机制用简单的话来说就是浏览器针对同一域名的资源,只建立一个 TCP 连接通道,所有的针对这个域名的请求全部都在这个通道中完成; 除此之外,数据的传输不再使用文本格式,而是会将它们分割为更小的流和帧,并对他们采用二进制格式的编码。在一个 TCP 连接通道中,支持任意数量的双向数据流,这些数据流是并行、乱序的且它们之间互不干扰。而数据流中传输的数据是二进制帧,它是 HTTP/2 中数据传输的最小单位,一个流中的帧是按照顺序传输的,且是并行的,所以无需按顺序等待。 解决了什么问题 因为只使用一个 TCP 连接,所以减少了由于 TCP 慢启动而消耗的时间,另外也由于只有单条 TCP 连接,所以不存在不同的 TCP 争夺网络带宽的问题。 客户端发送的请求经过二进制分帧层后,不再是一个个完整的 HTTP 请求报文,而是一堆乱序的帧(即不同流的帧是乱的,但是同一条流的帧数顺序传输的),所以就不会按顺序传输,也就不存在等待,从而解决了 HTTP 对头阻塞问题。 是如何实现的 首先,浏览器准备好请求数据,包括了请求行、请求头等信息,如果是 POST 方法,那么还要有请求体。 这些数据经过二进制分帧层处理之后,会被转换为一个个带有请求 ID 编号的帧,通过协议栈将这些帧发送给服务器。请求头的信息存在 header 帧中,而请求体数据存在 data 帧中。 服务器接收到所有帧之后,会将所有相同 ID 的帧合并为一条完整的请求信息。 然后服务器处理该条请求,并将处理的响应行、响应头和响应体分别发送至二进制分帧层。 同样,二进制分帧层会将这些响应数据转换为一个个带有请求 ID 编号的帧,经过协议栈发送给浏览器。 浏览器接收到响应帧之后,会根据 ID 编号将帧的数据提交给对应的请求。 HTTP/2 其他特性 1. 可以设置请求的优先级 在浏览器中,某些数据是非常重要的,比如关键 CSS 或者 JS,这些重要的数据如果比较晚才推送到浏览器,那么对用户来说肯定是一个不好的体验。 所以 HTTP/2 中可以支持设置请求的优先级,这样服务器收到高优先级的请求后,会优先处理。 2. 服务器推送 在 HTTP/2 中服务器解析到一个 HTML 页面后,服务器知道浏览器需要这个页面上引用到的资源,比如 CSS 和 JS,那么服务器就会主动的把这些资源一并推送给浏览器,减少客户端的等待时间。 3. 头部压缩 HTTP/2 使用 HPACK 压缩算法对请求头和响应头进行压缩,虽然单个请求压缩之后效果不是很明显,但是如果一个页面有 100 个请求,那每个请求压缩 20% 之后,那提速效果就很明显了。 而 HPACK 的压缩原理其实就是 2 点: 它要求客户端和服务器两者都维护和更新先前看到的报头字段的索引列表(即,建立共享的压缩上下文),然后将该列表用作有效编码先前传输的值的参考。在实际传输的时候用索引代替每一侧的静态或动态表中已经存在的字段,从而减小每个请求的大小。 它允许通过静态霍夫曼码对发送的标头字段进行编码,从而减小了它们各自的传输大小。 HTTP/3 HTTP/2 依然是基于 TCP 的,所以还存在以下一些问题。 TCP 的队头阻塞 HTTP/2 中多个请求是跑在一个 TCP 连接中的,如果某个数据流中出现了丢包的情况,就会阻塞该 TCP 连接中的所有请求。这个和 HTTP/1.1 中的不同,在 HTTP/1.1 中,由于浏览器为每个域名建立了 6 个 TCP 连接,如果其中一个 TCP 连接发生了队头阻塞,那么其他的 5 个连接依然可以继续传输数据。 TCP 建立连接的延时 在传输数据之前,需要进行 TCP 的 3 次握手,需要花费 1.5 个 RTT;如果是 HTTPS,那还需要进行 TLS 连接,又需要 1 ~ 2 个 RTT。 网络延迟又叫 RTT(Round Trip Time),是从浏览器发送一个数据包到服务器,再从服务器返回数据包到浏览器的整个往返时间。 总之,在传输数据之前需要花掉 3 ~ 4 个 RTT。如果客户端和服务器距离近的话,那 1 个 RTT 大概是 10ms,但如果远的话,可能是 100ms,所以传输数据之前需要花掉 300ms 左右,这个时候就能感觉到慢了。 TCP 协议僵化 我们知道 TCP 协议存在队头阻塞和建立连接延迟的问题,但是又没办法改进 TCP 协议,理由有如下 2 个: 中间设备僵化。中间设备比如路由器、交换机、防火墙和 NAT 等,这些设备依赖的软件使用了大量的 TCP 特性,一旦功能被设置后就很少进行更新了。如果在客户端进行升级 TCP 协议,那么当新协议的数据包经过这些设备的时候,可能会不理解包的内容,造成数据丢失。 操作系统也是导致 TCP 协议僵化的另外一个原因。 QUIC 协议 HTTP/3 是基于 UDP 实现的,实现了类似于 TCP 的多路数据流、传输可靠性等功能,我们把这套功能称为 QUIC 协议。 实现了类似 TCP 的流量控制、传输可靠性的功能。虽然 UDP 不提供可靠性的传输,但 QUIC 在 UDP 的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些 TCP 中存在的特性。 集成了 TLS 加密功能。目前 QUIC 使用的是 TLS1.3,相较于早期版本 TLS1.3 有更多的优点,其中最重要的一点是减少了握手所花费的 RTT 个数。 实现了 HTTP/2 中的多路复用功能。和 TCP 不同,QUIC 实现了在同一物理连接上可以有多个独立的逻辑数据流(如下图)。实现了数据流的单独传输,就解决了 TCP 中队头阻塞的问题。 实现了快速握手功能。由于 QUIC 是基于 UDP 的,所以 QUIC 可以实现使用 0-RTT 或者 1-RTT 来建立连接,这意味着 QUIC 可以用最快的速度来发送和接收数据,这样可以大大提升首次打开页面的速度。 HTTP/3 的挑战 第一,从目前的情况来看,服务器和浏览器端都没有对 HTTP/3 提供比较完整的支持。Chrome 虽然在数年前就开始支持 Google 版本的 QUIC,但是这个版本的 QUIC 和官方的 QUIC 存在着非常大的差异。 第二,部署 HTTP/3 也存在着非常大的问题。因为系统内核对 UDP 的优化远远没有达到 TCP 的优化程度,这也是阻碍 QUIC 的一个重要原因。 第三,中间设备僵化的问题。这些设备对 UDP 的优化程度远远低于 TCP,据统计使用 QUIC 协议时,大约有 3%~ 7% 的丢包率。

2021/1/5
articleCard.readMore

Resourse Hints 知多少

在上篇文章 探究网页资源究竟是如何阻塞浏览器加载的 中介绍到 JS 会阻塞 DOM 的加载,样式会阻塞页面的渲染,外链样式里的自定义字体还会对文字造成闪动给用户带来不好的体验,诸如此类问题还有挺多,那到底该如何解决它们呢? 今天我们就来学习通过在 link 标签里加上特定的属性,比如 preload、prefetch 等来解决此类问题,那么你对这些属性又了解多少呢?把它们用在了你们的项目优化中了嘛? preload preload 提升了资源加载的优先级,使得它提前开始加载(预加载),在需要用的时候能够更快的使用上。另外 onload 事件必须等页面所有资源都加载完成才触发,而当给某个资源加上 preload 后,该资源将不会阻塞 onload。 preload 怎么用 当某个页面加载了 2 个脚本 jquery.min.js 和 main.js: 1 2 <script src="https://cdn.bootcss.com/jquery/2.1.4/jquery.min.js"></script> <script src="./main.js"></script> 此时该页面的资源加载 Waterfall 长这样: 当在 <head> 里通过 <link> 标签给 main.js 配置 preload 预加载后: 1 <link rel="preload" as="script" href="./main.js"> 此时的 main.js 加载顺序出现在了 jquery.min.js 的前面,这就是 preload 提升资源加载优先级的效果。 当一直刷新浏览器的时候,偶然出现 Waterfall 并不能准确的显示资源加载的顺序,所以这个时候就需要比较每个资源被加入到下载队列的时间,比如如下的 main.js 由于用了 preload 预加载,所以 queue time 比较早。 通过 <link rel="preload"> 只是预加载了资源,但是资源加载完成后并不会执行,所以需要在想要执行的地方通过 <script> 来引入它: 1 <script src="./main.js"></script> 但是也有一个例外,因为 CSS 的加载也是通过 <link> 标签引入的,所以我们可以巧妙的利用这点,当 onload 事件触发的时候修改 rel 属性的值,使得它由原来的预加载样式变成引入样式: 1 <link rel="preload" as="style" onload="this.rel='stylesheet'" href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css"/> 如果通过 preload 加载了资源,但是又没有使用它,则浏览器会报一个警告: preload 除了能够预加载脚本之外,还可以通过 as 指定别的资源类型,比如: style 样式表; font:字体文件; image:图片文件; audio:音频文件; video:视频文件; document:文档。 preload 应用案例 preload 主要用于提升当前页面某些阻塞资源的下载优先级,使得页面能够尽快渲染显示出来。 案例一:预加载定义在 CSS 中资源的下载,比如自定义字体 当页面中使用了自定义字体的时候,就必须在 CSS 中引入该字体,而由于字体必须要等到浏览器下载完且解析该 CSS 文件的时候才开始下载,所以对应页面上该字体处可能会出现闪动的现象,为了避免这种现象的出现,就可以使用 preload 来提前加载字体,type 可以用来指定具体的字体类型,加载字体必须指定 crossorigin 属性,否则会导致字体被加载两次。 1 <link rel="preload" as="font" crossorigin type="font/woff2" href="myfont.woff2"> 以上这种写法和指定 crossorigin="anonymous" 是等同的效果。 案例二:预加载 CSS 文件 在首屏加载优化中一直存在一种技术,叫做抽取关键 CSS,意思就是把页面中在视口中出现的样式抽出一个独立的 CSS 文件出来 critical.css,然后剩余的样式在放到另外一个文件上 non-critical.css: 由于 CSS 会阻塞页面的渲染,当同时去加载这 2 部分样式的时候,只要 non-critical.css 还没加载完成,那么页面就显示不了,而实际上只需要显示出视口下的界面即可,所以期待的结果是:当加载完成 critical.css 的时候马上显示出视口下的界面,不让 non-critical.css 阻塞渲染,则需要给 non-critical.css 加上预加载: 1 2 3 <link rel="preload" as="style" href="https://bubuzou.com/non-critical.css"> <link rel="stylesheet" href="https://bubuzou.com/critical.css"> <link rel="stylesheet" href="https://bubuzou.com/non-critical.css"> 案例三:创建动态的预加载资源 当需要预先加载的时候调用 downloadScript,而希望执行的时候则调用 runScript 函数。 1 2 3 4 5 6 7 8 9 10 11 12 function downloadScript(src) { var el = document.createElement("link") el.as = "script" el.rel = "preload" el.href = src document.body.appendChild(el) } function runScript(src) { var el = document.createElement("script") el.src = src } 案例四:结合媒体查询预加载响应式图片 preload 甚至还可以结合媒体查询加载对应尺寸下的资源,对于以下代码当可视区域尺寸小于 600px 的时候会提前加载这张图片。 1 <link rel="preload" as="image" href="someimage.jpg" media="(max-width: 600px)"> 案例五:结合 Webpack 预加载 JS 模块 Webpack 从 4.6.0 版本开始支持在魔术注释中配置预加载模块: 1 import(_/* webpackPreload: true */_ "CriticalChunk") 如果是版本比较老的,则可以使用 preload-webpack-plugin 进行处理。 prefetch preload 用于提前加载用于当前页面的资源,而 prefetch 则是用于加载未来(比如下一个页面)会用到的资源,并且告诉浏览器在空闲的时候去下载,它会将下载资源的优先级降到最低。 比如在首页配置如下代码: 1 <link rel="prefetch" as="script" href="https://cdn.bootcss.com/jquery/2.1.4/jquery.min.js"> 我们会在页面中看到该脚本的下载优先级已经被降低为 Lowest: 当资源被下载完成后,会被存到浏览器缓存中,当从首页跳转到页面 A 的时候,假如页面 A 中引入了该脚本,那么浏览器会直接从 prefetch cache 中读取该资源,从而实现资源加载优化。 preconnect 当浏览器向服务器请求一个资源的时候,需要建立连接,而建立一个安全的连接需要经历以下 3 个步骤: 查询域名并将其解析成 IP 地址(DNS Lookup); 建立和服务器的连接(Initial connection); 加密连接以确保安全(SSL); 以上 3 个步骤浏览器都需要和服务器进行通信,而这一来一往的请求和响应势必会耗费不少时间。 而就基于这点上,可以使用 preconnect 或者 dns-prefetch 进行优化,而它两又是什么呢?怎么使用呢? preconnect 是什么,怎么用 当我们的站点需要对别的域下的资源进行请求的时候,就需要和那个域建立连接,然后才能开始下载资源,如果我都已经知道了是和哪个域进行通信,那不就可以先建立连接,然后等需要进行资源请求的时候就可以直接进行下载了。 假设当前站点是 https://a.com,这个站点的主页需要请求 https://b.com/b.js 这个资源。对比正常请求和配置了 preconnect 时候的请求,它们在请求时间轴上看到的表现是不一样的: 通过如下配置可以提前建立和 https://b.com 这个域的连接: 1 <link rel="preconnect" href="https://b.com"> 通过 preconnect 提早建立和第三方源的连接,可以将资源的加载时间缩短 100ms ~ 500ms,这个时间虽然看起来微不足道,但是它是实实在在的优化了页面的性能,提升了用户的体验。 通过 preconnect 和别的域建立连接后,应该尽快的使用它,因为浏览器会关闭所有在 10 秒内未使用的连接。不必要的预连接会延迟其他重要资源,因此要限制 preconnect 连接域的数量。 preconnect 应用场景 场景一: 当知道资源是来源于哪个源下,但是对于加载哪个资源不是很明确的时候,比如对于如下这些资源: 它们要嘛是动态的,要嘛是根据不同环境携带不同参数,所以它们很适合用 preconnect 进行加载。 场景二: 如果页面上有流媒体,但是没那么快播放,又希望当按下播放按钮的时候可以越快开始越好,此时就可以使用 preconnect 预建立连接,节省一段时间。 如果用 preconnect 预建立连接的资源是一个字体文件,那么也是需要加上 crossorigin 属性。 dns-prefetch 通常我们记住一个网站都是通过它的域名,但是对于服务器来说,它是通过 IP 来记住它们的。浏览器使用 DNS 来将站点转成 IP 地址,这个是建立连接的第一步,而这一步骤通常需要花费的时间大概是 20ms ~ 120ms。因此,可以通过 dns-prefetch 来节省这一步骤的时间。 居然能通过 preconnect 来减少整个建立连接的时间,那为什么还需要 dns-prefetch 来减少建立连接中第一步 DNS 查找解析的时间呢? 假如页面引入了许多第三方域下的资源,而如果它们都通过 preconnect 来预建立连接,其实这样的优化效果反而不好,甚至可能变差,所以这个时候就有另外一个方案,那就是对于最关键的连接使用 preconnect,而其他的则可以用 dns-prefetch。 可以按照如下方式配置 dns-prefetch: 1 <link rel="dns-prefetch" href="https://cdn.bootcss.com"> 另外由于 preconnect 的浏览器兼容稍微比 dns-prefetch 低,看下图: 因此 dns-prefetch 可以作为不支持预连接的浏览器的后备选择,同时配置它们两即可: 1 2 <link rel="preconnect" href="https://cdn.bootcss.com"> <link rel="dns-prefetch" href="https://cdn.bootcss.com"> 参考文章 前端性能优化之关键路径渲染优化 https://web.dev/link-prefetch/ https://www.w3.org/TR/resource-hints/ 译文Preload,Prefetch 和它们在 Chrome 之中的优先级 preload-prefetch-preconnect https://web.dev/extract-critical-css/

2020/12/28
articleCard.readMore

探究网页资源究竟是如何阻塞浏览器加载的

一个页面允许加载的外部资源有很多,常见的有脚本、样式、字体、图片和视频等,对于这些外部资源究竟是如何影响整个页面的加载和渲染的呢?今天我们来一探究竟。 阅读完这篇文章你将解开如下谜团: 如何用 Chrome 定制网络加载速度? 图片/视频/字体会阻塞页面加载嘛? CSS 是如何阻塞页面加载的? JS 又是如何阻塞页面加载的? JS 一定会阻塞 DOM 加载嘛? defer 和 async 是什么?又有何特点? 动态脚本会造成阻塞嘛? 阻塞是怎么和 DOMContentLoaded 与 onload 扯上关系的? 测试前环境准备 测试之前我们需要对浏览器下载资源的速度进行控制,将它重新设置为 50kb/s,操作方式: 打开 Chrome 开发者工具; 在 Network 面板下找到 Disable cache 右侧的下拉列表,然后选择 Add 添加自定义节流配置; 添加一个下载速度为 50kb/s 的配置; 最后在第二步骤中的下拉列表选择刚刚配置的选项即可; 注意:如果当前选择的自定义选项被修改了,则需要切换到别的选项再切回来才可生效。 为什么是这个速度?因为如下的一些资源,比如图片、样式或者脚本体积都是 50kb 的好几倍,方便测试。 图片会造成阻塞嘛 直接写个示例来看下结果: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <script> document.addEventListener('DOMContentLoaded', () => { console.log('DOMContentLoaded') }) window.onload = function() { console.log('onload') } </script> </head> <body> <h1>我是 h1 标签</h1> <img src="https://xxx.oss-cn-shenzhen.aliyuncs.com/images/flow.png" /> <h2>我是 h2 标签</h2> </body> </html> 上面这张图片的大小大概是 200kb,当把网络下载速度限制成 50kb/s,打开该页面,可以看到如下结果:当 h1 和 h2 标签渲染出来且打印了 DOMContentLoaded 的时候,此时图片还在加载中,这就说明了图片并不会阻塞 DOM 的加载,更加不会阻塞页面渲染;当图片加载完成的时候,会打印 onload,说明图片延迟了 onload 事件的触发。 视频、字体和图片其实是一样的,也不会阻塞 DOM 的加载和渲染。 CSS 加载阻塞 同样的,我们还是直接用代码来测试 CSS 加载对页面阻塞的情况,因为下面代码加载的 bootstrap.css 是 192kb 的,所以理论上下载它应该需要花费 3 到 4 秒左右。 1 2 3 4 5 6 7 8 9 10 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet" /> </head> <body> <h1>我是 h1 标签</h1> </body> </html> 测试过程如下: 在 Elements 面板下,选中 h1 这个标签,然后按 delete 键将它从 DOM 中删掉,从而模拟首次加载; 刷新浏览器,马上 Elements 面板下就加载出 h1 标签,继续加载 3 到 4 秒后(此时正在加载 bootstrap.css),页面出现 我是 h1 标签 字样,此时页面已经渲染完成。 从而得出结论: bootstrap.css 还没加载完成,而 DOM 中就已经出现 h1 标签,说明 CSS 不会阻塞 DOM 的解析; 页面直到 bootstrap.css 加载完成才出现 h1 里的文案,说明 CSS 会阻塞 DOM 的渲染。 为什么是这个结论呢?试想一下页面渲染的流程就知道了。浏览器首先解析 HTML 生成 DOM 树,解析 CSS 生成 CSSOM 树,然后 DOM 树和 CSSOM 树进行合成生成渲染树,通过渲染树进行布局并且计算每个节点信息,绘制页面。 可以说解析 DOM 和 解析 CSS 其实是并列进行的,既然是并列进行的,那 CSS 和 DOM 就不会互相影响了,这和结论一相符;另外渲染页面一定是在得到 CSSOM 树之后进行的,这和结论二相符。 CSS 一定会阻塞 DOM 的渲染嘛?答案是否定的,当把外链样式放到 <body> 最尾部去加载: 1 2 3 4 <body> <h1>我是 h1 标签</h1> <link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet" /> </body> 此时刷新浏览器,页面上会马上显示出 我是 h1 标签 字样,当 3 到 4 秒过后样式加载完成的时会造成二次渲染,页面重新渲染出该字样,这就**说明 CSS 阻塞 DOM 的渲染只阻塞定义在 CSS 后面的 DOM**。二次渲染会对用户造成不好的体验且加重了浏览器的负担,所以这也就是为什么需要把外链样式提前到 <head> 里加载的原因。 CSS 会阻塞后面 JS 的执行嘛 CSS 阻塞了后面 DOM 的渲染,那它会阻塞 JS 的执行嘛? 1 2 3 4 5 6 7 8 9 10 11 12 13 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet" /> </head> <body> <h1>我是 h1 标签</h1> <script> console.log('888') </script> </body> </html> 刷新浏览器的时候可以看到,浏览器 Console 面板下没有打印内容,而当样式加载完成的时候打印了 888,这就说明 CSS 会阻塞定义在其之后 JS 的执行。 为什么会这样呢?试想一下,如果 JS 里执行的操作需要获取当前 h1 标签的样式,而由于样式没加载完成,所以就无法得到想要的结果,从而证明了 CSS 需要阻塞定义在其之后 JS 的执行。 JS 加载阻塞 CSS 会阻塞 DOM 的渲染和阻塞定义在其之后的 JS 的执行,那 JS 加载会对渲染过程造成什么影响呢? 1 2 3 4 5 6 7 8 9 10 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <script src="https://cdn.bootcss.com/jquery/2.1.4/jquery.min.js"></script> </head> <body> <h1>我是 h1 标签</h1> </body> </html> 首先删除页面中已经存在的 h1 标签(如果存在的话),仔细观察 Elements 面板,当刷新浏览器的时候,一直未加载出 h1 标签(期间页面一直白屏),直到 JS 加载完成后,DOM 中才出现,这足以说明了 JS 会阻塞定义在其之后的 DOM 的加载,所以应该将外部 JS 放到 <body> 的最尾部去加载,减少页面加载白屏时间。 defer 和 async JS 一定会阻塞定义在其之后的 DOM 的加载嘛?来测试一下: 1 2 3 4 5 6 7 8 9 10 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <script async src="https://cdn.bootcss.com/jquery/2.1.4/jquery.min.js"></script> </head> <body> <h1>我是 h1 标签</h1> </body> </html> 上面这段代码的测试结果是当页面中显示出 h1 标签的时候,脚本还没有加载完成,这就说明了 async 脚本不会阻塞 DOM 的加载;同理我们可以用同样的方式测试 defer,也会得到这个结论。 现在我们知道了通过 defer 或者 async 方式加载 JS 的时候,它是不会阻塞 DOM 加载的。那么你知道 defer 和 async 是什么嘛?它们两者有什么区别呢? 回答这些疑问之前,我们先来看下当浏览器解析 HTML 遇到 script 标签的时候会发生什么? 暂停解析 DOM; 执行 script 里的脚本,如果该 script 是外链,则会先下载它,下载完成后立刻执行; 执行完成后继续解析剩余 DOM。 上面这是解析时遇到一个正常的外链的情况,正常外链的下载和执行都会阻塞页面解析;而如果外链是通过 defer 或者 async 加载的时候又会是如何呢? defer 特点 对于 defer 的 script,浏览器会继续解析 html,且同时并行下载脚本,等 DOM 构建完成后,才会开始执行脚本,所以它不会造成阻塞; defer 脚本下载完成后,执行时间一定是 DOMContentLoaded 事件触发之前执行; 多个 defer 的脚本执行顺序严格按照定义顺序进行,而不是先下载好的先执行; async 特点 对于 async 的 script,浏览器会继续解析 html,且同时并行下载脚本,一旦脚本下载完成会立刻执行;和 defer 一样,它在下载的时候也不会造成阻塞,但是如果它下载完成后 DOM 还没解析完成,则执行脚本的时候是会阻塞解析的; async 脚本的执行 和 DOMContentLoaded 的触发顺序无法明确谁先谁后,因为脚本可能在 DOM 构建完成时还没下载完,也可能早就下载好了; 多个 async,按照谁先下载完成谁先执行的原则进行,所以当它们之间有顺序依赖的时候特别容易出错。 defer 和 async 都只能用于外部脚本,如果 script 没有 src 属性,则会忽略它们。 动态脚本会造成阻塞嘛 对于如下这段代码,当刷新浏览器的时候会发现页面上马上显示出 我是 h1 标签,而过几秒后才加载完动态插入的脚本,所以可以得出结论:动态插入的脚本不会阻塞页面解析。 1 2 3 4 5 6 7 8 9 10 <!-- 省略了部分内容 --> <script> function loadScript(src) { let script = document.createElement('script') script.src = src document.body.append(script) } loadScript('https://cdn.bootcss.com/jquery/2.1.4/jquery.min.js') </script> <h1>我是 h1 标签</h1> 动态插入的脚本在加载完成后会立即执行,这和 async 一致,所以如果需要保证多个插入的动态脚本的执行顺序,则可以设置 script.async = false,此时动态脚本的执行顺序将按照插入顺序执行和 defer 一样。 DOMContentLoaded 和 onload 在浏览器中加载资源涉及到 2 个事件,分别是 DOMContentLoaded 和 onload,那么它们之间有什么区别呢? onload:当页面所有资源(包括 CSS、JS、图片、字体、视频等)都加载完成才触发,而且它是绑定到 window 对象上; DOMContentLoaded:当 HTML 已经完成解析,并且构建出了 DOM,但此时外部资源比如样式和脚本可能还没加载完成,并且该事件需要绑定到 document 对象上; 细心的你一定看到了上面的可能二字,为什么当 DOMContentLoaded 触发的时候样式和脚本是可能还没加载完成呢? DOMContentLoaded 遇到脚本 当浏览器处理一个 HTML 文档,并在文档中遇到 <script> 标签时,就会在继续构建 DOM 之前运行它。这是一种防范措施,因为脚本可能想要修改 DOM,甚至对其执行 document.write 操作,所以 DOMContentLoaded 必须等待脚本执行结束后才触发。以下这段代码验证了这个结论:当脚本加载完成的时候,Console 面板下才会打印出 DOMContentLoaded。 1 2 3 4 5 6 7 <script> document.addEventListener('DOMContentLoaded', () => { console.log('DOMContentLoaded') }) </script> <h1>我是 h1 标签</h1> <script src="https://cdn.bootcss.com/jquery/2.1.4/jquery.min.js"></script> 那么一定是脚本执行完成后才会触发 DOMContentLoaded 嘛?答案也是否定的,有两个例外,对于 async 脚本和动态脚本是不会阻塞 DOMContentLoaded 触发的。 DOMContentLoaded 遇到样式 前面我们已经介绍到 CSS 是不会阻塞 DOM 的解析的,所以理论上 DOMContentLoaded 应该不会等到外部样式的加载完成后才触发,这么分析是对的,让我们用下面代码进行测试一翻就知道了: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <script> document.addEventListener('DOMContentLoaded', () => { console.log('DOMContentLoaded') }) </script> <link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet"/> </head> <body> <h1>我是 h1 标签</h1> </body> </html> 测试结果:当样式还没加载完成的时候,就已经打印出 DOMContentLoaded,这和我们分析的结果是一致的。但是一定是这样嘛?显然不一定,这里有个小坑,(基于上面代码)在样式后面再加上 <script> 标签的时候,会发现只有等样式加载完成了才会打印出 DOMContentLoaded,为什么会这样呢?正是因为 <script> 会阻塞 DOMContentLoaded 的触发,所以当外部样式后面有脚本(async 脚本和动态脚本除外)的时候,外部样式就会阻塞 DOMContentLoaded 的触发。 1 2 3 4 <!-- 只显示了部分内容 --> <link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet"/> <script></script> </head> 参考文章 DOMContentLoaded https://html.spec.whatwg.org/multipage/scripting.html

2020/12/26
articleCard.readMore

很多人知道 Web Storage,但是你清楚 Cookie 嘛

可以在浏览器的 Application 面板下看到浏览器的本地存储包含了:Cookie、sessionStorage、localStorage 和 IndexedDB。 Cookie Cookie 是什么 Cookie 又叫 HTTP Cookie 或者叫浏览器 Cookie。Cookie 的作用是维护服务端和客户端的会话状态,简而言之就是告诉服务器当前客户端用户的一些信息,比如是否登录啥的。 Cookie 是如何工作的 Cookie 通常是由服务端生成,然后通过响应头的 Set-Cookie 发送给客户端浏览器: 1 2 3 HTTP/1.0 200 OK Content-type: text/html Set-Cookie: my_cookie=bulandent 浏览器会将 Cookie 保存在本地,并且会在下次请求头部的 Cookie 中附上这个值: GET /home.html HTTP/1.1Host: www.example.orgCookie: my_cookie=bulandent Cookie 分类 按照 Cookie 的生命周期可以将它分为两类: 会话 Cookie:没有指定过期时间 (Expires)或有效期(Max-Age)的 Cookie,当浏览器关闭后会被自动删除,但是现在很多浏览器都实现了会话恢复功能,即使浏览器关闭,会话 Cookie 也会被保留下来;这种类型的 Cookie 会保存在浏览器的内存中; 持久性 Cookie:通过指定过期时间 (Expires)或有效期(Max-Age)的一种 Cookie,存储于客户端硬盘中。设定的日期和时间是指和客户端系统时间进行比较的。 Cookie 限制 Cookie 会绑定特定的域名(Domain),除此之外,它还有如下一些限制: 通常,只要遵守以下大致的限制,就不会在任何浏览器中碰到问题: 不超过 300 个 Cookie; 每个 Cookie 不超过 4KB; 每个域名下不超过 20 个 Cookie。 每个域能设置的 Cookie 总数也是受限的,但不同浏览器的限制不同。例如: 最新版 IE 和 Edge 限制每个域不超过 50 个 Cookie; 最新版 Firefox 限制每个域不超过 150 个 Cookie; 最新版 Opera 限制每个域不超过 180 个 Cookie; Safari 和 Chrome 对每个域的 Cookie 数没有硬性限制。 如果 Cookie 总数超过了单域名的上限,浏览器就会删除之前设置的 Cookie,而删除的逻辑不同浏览器也不大相同。 Cookie 构成 Cookie 构成除了以上提到的 Name、Value、Domain、Expires/Max-Age 外,还有几个比较重要的需要说下: Path:请求 URL 中包含这个路径才会把 Cookie 发送到服务器; Secure:只有 HTTPS 请求才会发送标记为 Secure 的 Cookie; HttpOnly:将限制在客户端通过 document.cookie 读取设置为 HttpOnly 的 Cookie; SameSite:控制 Cookie 在跨站请求的时候是否会被发送,有 3 个值: None 允许跨站请求发送; Lax:允许跨站 GET 请求发送; Strict:不允许跨站请求发送; 除了服务器能够设置 Cookie 外,客户端也可以通过 document.cookie 设置。 Cookie 缺陷 Cookie 会被附加在每个 HTTP 请求中,所以无形中增加了流量; 由于在 HTTP 请求中的 Cookie 是明文传递的,所以安全性成问题,除非用超文本传输安全协定; Cookie 的大小限制在 4KB 左右,对于复杂的存储需求来说是不够用的。 Cookie 安全 黑客常常会利用 Cookie 进行攻击,比如 XSS 和 CSRF 等;所以为了网站安全,通常需要针对 Cookie 做一些安全措施: 对特殊的 Cookie 设置 HttpOnly,防止被客户端脚本读取,比如维护登录状态的 Cookie 就可以这么做; 用于敏感信息(例如指示身份验证)的 Cookie 的生存期应较短,并且 SameSite 属性设置为Strict 或 Lax。 Web Storage Web Storage 存在的目的就是为了解决每次向服务器请求的时候都需要携带 Cookie 信息的问题。Web Storage 包含了 2 个对象:sessionStorage 和 localStorage。通过这 2 个对象实现了: 提供在 Cookie 之外的存储会话数据的途径; 提供跨会话持久化存储大量数据的机制。 Web Storage 的限制 和其他客户端数据存储方案一样,Web Storage 也有限制。 存储大小:不同浏览器给 sessionStorage 和 localStorage 设置了不同的空间限制,但大多数会限制为每个源 5MB; 存储类型:只能存储字符串,所以如果数据是对象结构的,需要通过 JSON.stringify 先转成字符串; 存储限制于同一个源(origin),这也是同源策略的限制之一。即 http://a.com 和 https://a.com 存储的 ``Web Storage` 数据是不相同的。 Web Storage 提供了一套详细的 API 使得我们可以很好的进行数据存储: 属性 Storage.length:返回一个整数,表示存储在 Storage 对象中的数据项数量。 方法 Storage.key(n):该方法接受一个数值 n 作为参数,并返回存储中的第 n 个键名; Storage.getItem():该方法接受一个键名作为参数,返回键名对应的值; Storage.setItem():该方法接受一个键名和值作为参数,将会把键值对添加到存储中,如果键名存在,则更新其对应的值; Storage.removeItem():该方法接受一个键名作为参数,并把该键名从存储中删除; Storage.clear():调用该方法会清空存储中的所有键名。 sessionStorage 和 localStorage 都是 Storage 的实例,所以自然而然的它们都拥有上面的属性和方法。 sessionStorage sessionStorage 对象只会存储会话数据,这意味着当浏览器 tab 页被关闭的时候,对应的 sessionStorage 数据将被清除。除此之外,它还有如下表现: 不受页面刷新(包括强制刷新)影响,并且可以在浏览器崩溃并重启后恢复; 在当前页面通过新标签页或窗口打开一个新页面的时候,新页面会复制父级页面的 sessionStorage 数据; 使用同一个 URL 打开多个标签页,它们各自的 sessionStorage 数据不同; localStorage 区别于 sessionStorage,localStorage 的存储不受会话限制而且能够长期存储于客户端浏览器中,直到手动删除或者清除浏览器缓存。 IndexedDB 虽然 Web Storage 在存储较少量的数据很有用,但对于存储更大量的结构化数据来说力不从心,这个时候就需要用到 IndexedDB,它类似于 MySQL,但是和传统数据库最大的区别在于,它是适用对象存储而不是表格保存数据。IndexedDB 也受到源的限制。 和 Web Storage 的区别 存储大小:Web Storage 限制每个源大约 5MB。IndexedDB 的存储空间有 2 个限制:全局限制即为浏览器的最大存储空间一般是可用磁盘空间的 50%;组限制为全局限制的 20%,且它至少有 10MB,最大为 2GB 存储空间; 存储类型:Web Storage 只能存储字符串,IndexedDB 可以存储字符串、Blob 和 ArrayBuffer; Web Storage 的存储操作是同步进行的;IndexedDB 由于数据量大,所以多数操作都是异步执行的; 参考文章 HTTP Cookies Browser_storage_limits_and_eviction_criteria

2020/12/24
articleCard.readMore

浏览器专题之缓存篇

浏览器缓存一直是个老生常谈的话题,也是面试官常常用来鉴别面试者的利器,作为前端来讲这块知识是属于必须掌握的,再者利用好缓存也是做性能优化的有效方法。本文将从缓存原因、缓存读写顺序,缓存位置以及缓存策略这几个角度介绍浏览器缓存,并且最后给出实践的应用举例。 为什么要缓存 很多同学知道缓存的位置和字段,知道怎么用,但是你有没有想过为什么我们的页面需要浏览器缓存呢? 缓存可以减少用户等待时间,提升用户体验,直接从内存或磁盘中取缓存数据肯定是比从服务器请求更快的; 减少网络带宽消耗:对于网站运营者和用户,带宽都代表着成本,过多的带宽消耗,都需要支付额外的费用。试想一下如果可以使用缓存,只会产生极小的网络流量,这将有效的降低运营成本。 降低服务器压力:给网络资源设定有效期之后,用户可以重复使用本地的缓存,减少对源服务器的请求,降低服务器的压力。 缓存读写顺序 当浏览器对一个资源(比如一个外链的 a.js)进行请求的时候会发生什么?请从缓存的角度大概说下: 调用 Service Worker 的 fetch 事件获取资源; 查看 memory cache; 查看 disk cache;这里又细分: 如果有强制缓存且未失效,则使用强制缓存,不请求服务器。这时的状态码全部是 200; 如果有强制缓存但已失效,使用协商缓存,比较后确定 304 还是 200; 发送网络请求,等待网络响应; 把响应内容存入 disk cache (如果请求头信息配置可以存的话); 把响应内容的引用存入 memory cache (无视请求头信息的配置,除了 no-store 之外); 把响应内容存入 Service Worker 的 Cache Storage (如果 Service Worker 的脚本调用了 cache.put()); 上面这一系列过程其实是浏览器查找缓存和把资源存入缓存的执行流程。这其中出现了很多专业词汇,让人看了一脸懵逼,下面将从缓存位置和缓存策略两个角度简要介绍浏览器的缓存。 缓存位置 从浏览器开发者工具的 Network 面板下某个请求的 Size 中可以看到当前请求资源的大小以及来源,从这些来源我们就知道该资源到底是从 memory cache 中读取的呢,还是从 disk cache 中读取的,亦或者是服务器返回的。而这些就是缓存位置: Service Worker 是一个注册在指定源和路径下的事件驱动 worker;特点是: 运行在 worker 上下文,因此它不能访问 DOM; 独立于主线程之外,不会造成阻塞; 设计完全异步,所以同步 API(如 XHR 和 localStorage )不能在 Service Worker 中使用; 最后处于安全考虑,必须在 HTTPS 环境下才可以使用; 说了这么多特点,那它和缓存有啥关系?其实它有一个功能就是离线缓存:Service Worker Cache;区别于浏览器内部的 memory cache 和 disk cache,它允许我们自己去操控缓存,具体操作过程可以参看 Using_Service_Workers;通过 Service Worker 设置的缓存会出现在浏览器开发者工具 Application 面板下的 Cache Storage 中。 memory cache 是浏览器内存中的缓存,相比于 disk cache 它的特点是读取速度快,但容量小,且时效性短,一旦浏览器 tab 页关闭,memory cache 就将被清空。memory cache 会自动缓存所有资源嘛?答案肯定是否定的,当 HTTP 头设置了 Cache-Control: no-store 的时候或者浏览器设置了 Disabled cache 就无法把资源存入内存了,其实也无法存入硬盘。当从 memory cache 中查找缓存的时候,不仅仅会去匹配资源的 URL,还会看其 Content-type 是否相同。 disk cache 也叫 HTTP cache 是存在硬盘中的缓存,根据 HTTP 头部的各类字段进行判定资源的缓存规则,比如是否可以缓存,什么时候过期,过期之后需要重新发起请求吗?相比于 memory cache 的 disk cache 拥有存储空间时间长等优点,网站中的绝大多数资源都是存在 disk cache 中的。 浏览器如何判断一个资源是存入内存还是硬盘呢?关于这个问题,网上说法不一,不过比较靠谱的观点是:对于大文件大概率会存入硬盘中;当前系统内存使用率高的话,文件优先存入硬盘。 缓存按照缓存位置划分,其实还有一个 HTTP/2 的内容 push cache,由于目前国内对 HTTP/2 应用还不广泛,且网上对 push cache 的知识还不齐全,所以本篇不打算介绍这块,感兴趣的可以阅读这篇文章:HTTP/2 push is tougher than I thought 缓存策略 根据 HTTP header 的字段又可以将缓存分成强缓存和协商缓存。强缓存可以直接从缓存中读取资源返回给浏览器而不需要向服务器发送请求,而协商缓存是当强缓存失效后(过了过期时间),浏览器需要携带缓存标识向服务器发送请求,服务器根据缓存标识决定是否使用缓存的过程。强缓存的字段有:Expires 和 Cache-Control。协商缓存的字段有:Last-Modified 和 ETag。 Expires Expires 是 HTTP/1.0 的字段,表示缓存过期时间,它是一个 GMT 格式的时间字符串。Expires 需要在服务端配置(具体配置也根据服务器而定),浏览器会根据该过期日期与客户端时间对比,如果过期时间还没到,则会去缓存中读取该资源,如果已经到期了,则浏览器判断为该资源已经不新鲜要重新从服务端获取。由于 Expires 是一个绝对的时间,所以会局限于客户端时间的准确性,从而可能会出现浏览器判断缓存失效的问题。如下是一个 Expires 示例,是一个日期/时间: 1 Expires: Wed, 21 Oct 2020 07:28:00 GMT Cache-Control 它是 HTTP/1.1 的字段,其中的包含的值很多: max-age 最大缓存时间,值的单位是秒,在该时间内,浏览器不需要向浏览器请求。这个设置解决了 Expires 中由于客户端系统时间不准确而导致缓存失效的问题; must-revalidate:如果超过了 max-age 的时间,浏览器必须向服务器发送请求,验证资源是否还有效; public 响应可以被任何对象(客户端、代理服务器等)缓存; private 响应只能被客户端缓存; no-cache 跳过强缓存,直接进入协商缓存阶段; no-store 不缓存任何内容,设置了这个后资源也不会被缓存到内存和硬盘; Cache-Control 的值是可以混合使用的,比如: 1 Cache-Control: private, max-age=0, no-cache 当混合使用的时候它们的优先级如下图所示: 当 Expires 和 Cache-Control 都被设置的时候,浏览器会优先考虑后者。当强缓存失效的时候,则会进入到协商缓存阶段。具体细节是这样:浏览器从本地查找强缓存,发现失效了,然后会拿着缓存标识请求服务器,服务器拿着这个缓存标识和对应的字段进行校验资源是否被修改,如果没有被修改则此时响应状态会是 304,且不会返回请求资源,而是直接从浏览器缓存中读取。 而浏览器缓存标识可以是:Last-Modified 和 ETag: Last-Modified 资源的最后修改时间;第一次请求的时候,响应头会返回该字段告知浏览器资源的最后一次修改时间;浏览器会将值和资源存在缓存中;再次请求该资源的时候,如果强缓存过期,则浏览器会设置请求头的 If-Modifined-Since 字段值为存储在缓存中的上次响应头 Last-Modified 的值,并且发送请求;服务器拿着 If-Modifined-Since 的值和 Last-Modified 进行对比。如果相等,表示资源未修改,响应 304;如果不相等,表示资源被修改,响应 200,且返回请求资源。如果资源更新的速度是小于 1 秒的,那么该字段将失效,因为 Last-Modified 时间是精确到秒的。所以有了 ETag。 ETag 根据资源内容生成的唯一标识,资源是否被修改的判断过程和上面的一致,只是对应的字段替换了。Last-Modified 替换成 ETag,If-Modifined-Since 替换成 If-None-Match。 当 Last-Modified 和 ETag 都被设置的时候,浏览器会优先考虑后者。 浏览器的行为 浏览器地址栏输入 URL 后回车: 查找 disk cache 中是否有匹配。如有则使用;如没有则发送网络请求。 普通刷新 (⌘ + R):因为 TAB 页并没有关闭,因此 memory cache 是可用的,会被优先使用(如果匹配的话),其次才是 disk cache。 强制刷新 (⇧ + ⌘ + R):浏览器不使用缓存,因此发送的请求头部均带有 Cache-control: no-cache(为了兼容,还带了 Pragma: no-cache)。服务器直接返回 200 和最新内容。 当在开发者工具 Network 面板下设置了 Disabled cache 禁用缓存后,浏览器将不会从 memory cache 或者 disk cache 中读取缓存,而是直接发起网络请求。 缓存应用 静态资源 比如页面引入了一个 JQuery,对于页面来说这个脚本就是一个工具库,基本上是不会发生变化的,对于这种资源可以将它的缓存时间设置得长一点,比如如下这个地址的脚本: 1 <script src="https://cdn.bootcss.com/jquery/2.1.4/jquery.min.js"></script> 你会看到它的响应头里设置了,max-age=2592000 直接缓存 30 天: 1 cache-control: public, max-age=2592000 频繁变化的资源 对于频繁变化的资源,比如某个页面经常需要调整,那么这个页面就需要在每次请求的时候都进行验证,可以在响应头这样设置: 1 cache-control: no-cache 不进行缓存 当然并不是所有请求都能被缓存,无法被浏览器缓存的请求如下: HTTP 信息头中包含 Cache-Control: no-cache ,pragma: no-cache(HTTP1.0),或 Cache-Control: max-age=0 等告诉浏览器不用缓存的请求; 需要根据 Cookie、认证信息等决定输入内容的动态请求是不能被缓存的; 经过 HTTPS 安全加密的请求; POST 请求无法被缓存; HTTP 响应头中不包含 Last-Modified/Etag,也不包含 Cache-Control/Expires 的请求无法被缓存; 参考文章 深入理解浏览器的缓存机制 一文读懂前端缓存 Service_Worker_API

2020/12/20
articleCard.readMore

从输入 URL 到页面显示发生了什么

读了李兵老师的 浏览器的工作原理与实践,让我对浏览器的工作原理有了更加深刻的理解,尤其是从用户输入 URL 到页面显示这一过程发生的事情,以往看的文章都是点到为止,而他却说得面面俱到非常详细,遂我把内容总结了一下分享给大家,值得你花个 5 分钟阅读一下。 用户输入阶段 合成 URL:用户输入 URL,浏览器会根据用户输入的信息判断是搜索还是网址,如果是搜索内容,就将搜索内容 + 默认搜索引擎合成新的 URL;如果用户输入的内容符合 URL 规则,浏览器就会根据 URL 协议,在这段内容上加上协议合成合法的 URL; 加载:用户输入完内容,按下回车键,浏览器导航栏显示 loading 状态,但是页面还是呈现前一个页面,这是因为新页面的响应数据还没有获得; 发起URL请求阶段 构建请求:浏览器进程首先会构建请求行信息,然后通过进程间通信(IPC)将 URL 请求发送给网络进程; 查找缓存:网络进程获取到 URL,先去本地缓存中查找是否有缓存资源,如果有则拦截请求,直接将缓存资源返回给浏览器进程;否则,进入网络请求阶段; DNS 解析:网络进程请求首先会从 DNS 数据缓存服务中查找是否缓存过当前域名信息,有则直接返回;否则,会进行 DNS 解析返回域名对应的 IP 和端口号,如果没有指定端口号,http 默认 80 端口,https 默认 443。如果是 https 请求,还需要建立 TLS 连接; 等待 TCP 队列:Chrome 有个机制,同一个域名同时最多只能建立 6 个 TCP 连接,如果在同一个域名下同时有 10 个请求发生,那么其中 4 个请求会进入排队等待状态,直至进行中的请求完成。如果当前请求数量少于6个,会直接建立 TCP 连接; 建立 TCP 连接:TCP 三次握手与服务器建立连接,然后进行数据的传输,最后; 发送 HTTP 请求:浏览器首先会向服务器发送请求行,它包含了请求方法、请求 URI 和 HTTP 协议的版本;另外还会发送请求头,告诉服务器一些浏览器的相关信息,比如浏览器内核,请求域名、Cookie 等;如果需要传递参数,则还需要发送请求体; 服务器处理请求:服务器首先返回响应行,包括协议版本和状态码,比如状态码 200 表示继续处理该请求;(如果是 301,则表示重定向,将会在响应头的 Locaiton 字段中加上重定向的地址信息,接下来浏览器获取这个地址,将会重新导航。)服务器也会向浏览器发送响应头,包含了一些信息,比如服务器生成返回数据的时间、返回的数据类型(JSON、HTML、流媒体等类型),以及服务器要在客户端保存的 Cookie 等;继续发送响应体的数据; 断开 TCP 连接:数据传输完成,正常情况下 TCP 将四次挥手断开连接。但是如果浏览器或者服务器在HTTP头部加上 Connection: keep-alive,TCP 就会一直保持连接。保持 TCP 连接可以省下下次需要建立连接的时间,提示资源加载速度; 准备渲染进程阶段 网络进程将获取到的数据包进行解析,根据响应头中的 Content-type 来判断响应数据的类型,如果是字节流类型,就将该请求交给下载管理器,该导航流程结束,不再进行;如果是 text/html 类型,就通知浏览器进程获取到的是 HTML,应该准备渲染进程了; 正常情况下每个浏览器的 tab 会对应一个渲染进程,但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程,否则就会创建一个新的渲染进程; 提交文档阶段 渲染进程准备好后,浏览器会发出 “提交文档” 的消息给渲染进程,渲染进程收到消息后,会和网络进程建立传输数据的 “管道”,文档数据传输完成后,渲染进程会返回“确认提交”的消息给浏览器进程; 浏览器收到 “确认提交” 的消息后,会更新浏览器的页面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 web 页面,此时的 web 页面是空白页; 页面渲染阶段 文档一旦提交,渲染进程将开始页面解析和子资源加载;渲染阶段比较复杂,所以将分为多个子阶段,按照渲染的时间顺序可以分为:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成; 构建 DOM 树:HTML 经过解析后输出一个以 document 为顶层节点的树状结构的 DOM; 样式计算:这里有 3 个步骤: 将 3 个来源(<link> 标签引入的外部样式、<style> 标签里定义的样式、以及元素的 style 属性上的样式)的 CSS 转化成浏览器能够理解的结构 styleSheets; 转换样式表中的属性值,使其标准化;比如 font-weight: bold; 会转成 font-weight: 700;、color: blue; 会转成 color: rgb(0, 0, 255); 等; 依据 CSS 的继承和层叠规则计算出 DOM 树中每个节点的具体样式; 布局阶段:DOM 树中依然存在许多不可见的元素(比如 head),这些元素对于布局是丝毫没用的,所以又会生成一棵只包含可见元素的布局树;然后再根据布局树的每个节点计算出其具体位置和尺寸大小; 分层:页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-index 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树;关于层叠上下文的知识,具体可以参考这里彻底搞懂CSS层叠上下文、层叠等级、层叠顺序、z-index; 绘制:为每个图层生成绘制列表,并将其提交到合成线程; 光栅化:通常一个页面很大,而视口很局限,所以合成线程会按照视口附近的图块来优先生成位图,并在光栅化线程池中将图块转换成位图; 合成:一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令 DrawQuad,然后将该命令提交给浏览器进程;之后浏览器将开始生成显示页面。

2020/12/16
articleCard.readMore

浏览器专题之事件机制

事件流 在早期 IE 和 Netscape 团队在开发第四代浏览器的时候,遇到一个问题:当点击一个按钮的时候,是应该先处理父级的事件呢?还是应该先处理按钮的事件呢?IE 和 Netscape 给出了 2 种完全相反的答案,IE 提出事件冒泡的概念,而 Netscape 则支持事件捕获。 事件冒泡 事件冒泡认为事件应该由最具体的元素开始触发,然后层层往父级传播: 事件捕获 而事件捕获则相反,认为最外层的元素应该最先收到事件,然后层层往下级传递: DOM 事件流 为了在浏览器中兼容这 2 种事件流,在 DOM2 Events 规范中将事件流分为 3 个阶段:事件捕获阶段、到底目标阶段、事件冒泡阶段。 可以通过指定 addEventListener 的第三个参数为 true 来设置事件是在捕获阶段调用事件处理程序,默认是 false 指在冒泡阶段调用事件处理程序。 所有现代浏览器都支持 DOM 事件流,只有 IE8 及更早版本不支持。 事件处理程序 HTML 事件处理程序 就是将事件处理程序直接绑定到 HTML 的属性中: 1 2 3 4 5 6 7 8 // 方式一 <div onclick="console.log('hello world')"></div> 方式二 <div onclick="print(event)"></div> <script> function print(e) { } </script> HTML 事件处理程序修改事件相对麻烦,可能需要同时修改 HTML 和 JS,所以大家都不爱使用这种方式绑定事件。 DOM0 事件处理程序 将一个函数赋值给 DOM 元素的一个事件处理程序属性,比如 onclick: 1 2 3 4 5 6 7 let btn = document.getElementById('div') // 添加事件 btn.onclick = function() { } // 移除事件 btn.onclick = null DOM2 事件处理程序 通过 addEventListener 可以添加 DOM2 级别的事件处理程序,它接收 3 个参数:事件名、事件处理程序和 useCapture (它是一个可选参数,是个布尔值,默认为 false 表示在冒泡阶段调用事件处理程序) 1 2 3 4 let btn = document.getElementById('div') btn.addEventListener('click', () => { }, false) 和 DOM0 事件处理程序的区别: addEventListener 可以改变事件流,即可以在捕获阶段触发事件,而 DOM0 是不行的; addEventListener 可以为同一个元素多次添加同一类型的事件处理程序,先添加的事件处理程序会先触发,而 DOM0 如果给同一个元素绑定多个相同类型的事件处理程序的话,则后面添加的会覆盖前面定义的; 它有几个注意事项: 如果不需要在捕获阶段进行拦截操作,则 useCapture 即第三个参可以不传; 通过 addEventListener 添加的事件处理程序只能通过 removeEventListener 移除,而且绑定的事件处理程序必须是同一个。 1 2 3 4 let btn = document.getElementById('div') let handler = function() { } btn.addEventListener("click", handler) btn.removeEventListener("click", handler) IE 事件处理函数 由于 addEventListener 无法兼容 IE8 及更早版本,所以此时就可以使用 attachEvent 添加事件处理程序和用 detachEvent 移除事件处理程序。 1 2 let btn = document.getElementById('div') btn.attachEvent("onclick", function() { }) 它有这么几个注意事项: 注册的事件名和 DOM0 一样,需要带上 on,比如 onclick; 在通过 attachEvent 添加的事件处理程序内部 this 会指向 window,而 DOM0 和 DOM2 的 this 会指向元素本身; 和 addEventListener 一样, attachEvent 也可以针对同一元素多次添加同一个事件类型的处理程序,但是触发顺序是后定义的先触发; 通过 detachEvent 移除事件处理程序的时候,处理函数必须是和注册的同一个,这点和 addEventListener 保持一致; attachEvent 和 detachEvent 是 IE 专属的 API,所以如果有兼容性要求,我们可以写出跨浏览器的事件处理程序: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 var EventUtil = { addHandler: function(element, type, handler) { if (element.addEventListener) { element.addEventListener(type, handler, false) } else if (element.attachEvent) { element.attachEvent("on" + type, handler) } else { element["on" + type] = handler; } }, removeHandler: function(element, type, handler) { if (element.removeEventListener) { element.removeEventListener(type, handler, false) } else if (element.detachEvent) { element.detachEvent("on" + type, handler) } else { element["on" + type] = null } } } 事件对象 通过不同的事件处理程序添加的事件,event 对象的属性略有不同,我们不需要记住他们的差异,只需要在平时写代码的时候养成一个写兼容代码的习惯即可,如下是一个兼容各种 event 对象的事件处理程序: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 let handler = function(event) { // 事件对象 let event = event || window.event // 目标元素 let target = event.target || event.srcElement // 阻止默认事件触发 if (event.preventDefault) { event.preventDefault() } else { event.returnValue = false } // 阻止事件冒泡 if (event.stopPropagation) { event.stopPropagation() } else { event.cancelBubble = true } } 事件类型 DOM3 Events 定义了如下事件类型: 用户界面事件(UIEvent):涉及与 BOM 交互的通用浏览器事件,比如 onload、resize、scroll、input、select 等; 焦点事件(FocusEvent):在元素获得和失去焦点时触发,比如 focus、blur; 鼠标事件(MouseEvent):使用鼠标在页面上执行某些操作时触发,比如 click、mousedown、mouseover 等; 滚轮事件(WheelEvent):使用鼠标滚轮(或类似设备)时触发,比如 mousewheel; 输入事件(InputEvent):向文档中输入文本时触发,比如 textInput; 键盘事件(KeyboardEvent):使用键盘在页面上执行某些操作时触发,比如 keydown、keypress; 合成事件(CompositionEvent):在使用某种 IME(Input Method Editor,输入法编辑器)输入字符时触发,比如 compositionstart。 事件委托 事件委托是指将多个元素上绑定的事件通过利用事件冒泡的原理从而转移到他们共同的父级上去绑定,从而在一定程度上起到优化的作用,有的人也喜欢叫它事件代理。比如在 Vue 中经常会将事件绑定到每个列表项中: 1 2 3 4 5 6 7 8 9 10 11 <ul> <li v-for="item in list" :key="item" @click="handleClick(item)">{{item}}</li> </ul> ``` 其实更好的做法是利用事件委托,将事件绑定到 `ul` 上: ```html <ul @click="handleClick"> <li v-for="item in list" :key="item" :data-item="item">{{item}}</li> </ul> 1 2 3 4 5 6 handleClick(event) { let target = event.target if (target === 'li') { let data = target.dataset.item } }

2020/12/11
articleCard.readMore

浏览器专题之安全篇

同源策略(Same Origin Policy) 如果两个 URL 的协议、域名和端口都相同,我们就称这两个 URL 同源。 比如,这个 http://store.company.com/dir/page.html 和下面这些 URL 相比源的结果如下: 1 2 3 4 5 http://store.company.com/dir2/other.html // 同源,只有路径不同 http://store.company.com/dir/inner/another.html // 同源,只有路径不同 https://store.company.com/secure.html // 失败,协议不同 http://news.company.com/dir/other.html // 失败,域名不同 http://store.company.com:81/dir/etc.html // 失败,端口不同 ( http:// 默认端口是80) 同源策略的限制 限制了来自不同源的 JavaScript 脚本对当前 DOM 对象读和写的操作; 限制了不同源的站点读取当前站点的 Cookie、IndexDB、LocalStorage 等数据; 限制了通过 XMLHttpRequest 等方式将站点的数据发送给不同源的站点。 由于浏览器同源策略的限制使得 Web 项目难以开发和使用,所以为了既保证安全性又能够灵活开发 Web 应用,从而出现了一些新技术 页面中可以引用第三方资源,不过这也暴露了很多诸如 XSS 的安全问题,因此又在这种开放的基础之上引入了内容安全策略 CSP 来限制其自由程度; 使用 XMLHttpRequest 和 Fetch 都是无法直接进行跨域请求的,因此浏览器又在这种严格策略的基础之上引入了跨域资源共享策略 CORS,让其可以安全地进行跨域操作; 两个不同源的 DOM是不能相互操纵的,因此浏览器中又实现了跨文档消息机制,让其可以比较安全地通信,可以通过 window.postMessage 的 JavaScript 接口来和不同源的 DOM 进行通信。 内容安全策略(CSP) 内容安全策略(Content Security Policy)简称 CSP,通过它可以明确的告诉客户端浏览器当前页面的哪些外部资源可以被加载执行,而哪些又是不可以的。 2 种方式启用 CSP 通过 HTTP 头配置 Content-Security-Policy,以下配置说明该页面只允许当前源和 https://apis.google.com 这 2 个源的脚本加载和执行: 1 Content-Security-Policy: script-src 'self' https://apis.google.com 通过页面 <meta> 标签配置: 1 <meta http-equiv="Content-Security-Policy" content="script-src 'self' https://apis.google.com"> CSP 的限制 CSP 提供了丰富的限制,除了能限制脚本的加载和执行,对其他资源也有限制,比如: font-src:字体来源; img-src:图片来源; style-src:样式来源; 以上只是列举了一些常见的外部资源的限制,想要查看更多资源限制可以看这里。 默认情况下,这些指令的适用范围很广。如果您不为某条指令(例如,font-src)设置具体的策略,则默认情况下,该指令在运行时假定您指定 * 作为有效来源(例如,您可以从任意位置加载字体,没有任何限制。 另外你可以通过 default-src 设置资源限制的默认行为,但它只适用于 -src 结尾的所有指令,比如设置了如下的 CSP 规则,则只允许从 https://cdn.example.net 加载脚本、字体、图片、样式等资源: 1 Content-Security-Policy: default-src https://cdn.example.net CSP 配置事项 如果要配置多个同一类型的资源限制,需要将它们进行合并: 1 Content-Security-Policy: script-src https://host1.com https://host2.com 不同的资源类型之间需要用分号分隔: 1 Content-Security-Policy: script-src https://host1.com; img-src https://host2.com 可以通过以下值来灵活配置来源列表: 协议:https:、data:; 主机名:example.com、example.com:443; 路径名:example.com/js; 通配符:*://*.example.com:*。 还可以给来源列表指定关键字,包含如下 4 个关键字,使用关键字需要加上单引号: 'none':不执行任何匹配; 'self':与当前来源(而不是其子域)匹配; 'unsafe-inline':允许使用内联 JavaScript 和 CSS; 'unsafe-eval':允许使用类似 eval 的 text-to-JavaScript 机制。 CSP 应用举例 让我们假设一下,您在运行一个银行网站,并希望确保只能加载您自己写入的资源。 在此情形下,首先设置一个阻止所有内容的默认政策 (default-src 'none'),然后在此基础上逐步构建。 假设此银行网站在 https://cdn.mybank.net 上加载所有来自 CDN 的图像、样式和脚本,并通过 XHR 连接到 https://api.mybank.com/ 以抽取各种数据。可使用帧,但仅用于网站的本地页面(无第三方来源)。 网站上没有 Flash,也没有字体和 Extra。 我们能够发送的最严格的 CSP 标头为: 1 Content-Security-Policy: default-src 'none'; script-src https://cdn.mybank.net; style-src https://cdn.mybank.net; img-src https://cdn.mybank.net; connect-src https://api.mybank.com; child-src 'self' 安全沙箱(Sandbox) 我们知道早期的浏览器是单进程架构的,这样当某个标签页挂了之后,将影响到整个浏览器。所以出现了多进程架构,它通过给每个标签页分配一个渲染进程解决了这个问题。 而渲染进程的工作是进行 HTML、CSS 的解析,JavaScript 的执行等,而这部分内容是直接暴露给用户的,所以也是最容易被黑客利用攻击的地方,如果黑客攻击了这里就有可能获取到渲染进程的权限,进而威胁到操作系统。所以需要一道墙用来把不可信任的代码运行在一定的环境中,限制不可信代码访问隔离区之外的资源,而这道墙就是浏览器的安全沙箱。 多进程的浏览器架构将主要分为两块:浏览器内核和渲染内核。而安全沙箱能限制了渲染进程对操作系统资源的访问和修改,同时渲染进程内部也没有读写操作系统的能力,而这些都是在浏览器内核中一一实现了,包括持久存储、网络访问和用户交互等一系列直接与操作系统交互的功能。浏览器内核和渲染内核各自职责分明,当他们需要进行数据传输的时候会通过 IPC 进行。 安全沙箱的存在是为了保护客户端操作系统免受黑客攻击,但是阻止不了 XSS 和 CSRF。 跨站脚本攻击(XSS) 跨站脚本攻击(Cross Site Scripting)本来缩写是 CSS,但是为了和层叠样式表(Cascading Style Sheet)的简写区分开来,所以在安全领域被称为 XSS。它是指黑客往 HTML 文件中或者 DOM 中注入恶意脚本,从而在用户浏览页面时利用注入的恶意脚本对用户实施攻击的一种手段。 可以通过 3 种方式注入恶意脚本 存储型 XSS 攻击 首先黑客利用站点漏洞将一段恶意 JavaScript 代码提交到网站的数据库中,比如在表单输入框中输入这样一段内容: 1 <script src="http://tod.cn/ReFgeasE"></script> 然后用户向网站请求包含了恶意 JavaScript 脚本的页面; 当用户浏览该页面的时候,恶意脚本可以通过 document.cookie 获取到页面 Cookie 信息,然后通过 XMLHttpRequest 将这些信息发送给恶意服务器,恶意服务器拿到用户的 Cookie 信息之后,就可以在其他电脑上模拟用户的登录,然后进行操作。 反射型 XSS 攻击 恶意 JavaScript 脚本属于用户发送给网站请求中的一部分,随后网站又把恶意 JavaScript 脚本返回给用户。当恶意 JavaScript 脚本在用户页面中被执行时,黑客就可以利用该脚本做一些恶意操作。 基于 DOM 的 XSS 攻击 通常是由于是前端代码不够严谨,把不可信的内容插入到了页面。在使用 .innerHTML、.outerHTML、.appendChild、document.write()等 API 时要特别小心,不要把不可信的数据作为 HTML 插到页面上,尽量使用 .innerText、.textContent、.setAttribute() 等代替。比如对于如下代码:当输入 " onclick=alert('xss') 且点击生成的链接的时候,就会提示 xss: 1 2 3 4 5 6 7 8 9 <div id="link"></div> <input type="text" id="text" value="" /> <input type="button" value="按钮" id="button" onclick="test()" /> <script> function test() { let text = document.getElementById('text').value document.getElementById('link').innerHTML = `<a href="${text}">链接</a>` } </script> 阻止 XSS 攻击的措施 服务器对输入脚本进行过滤或转码,比如:<script> 转成 &lt;script&gt; 后脚本就无法执行了; 使用 HttpOnly 属性,服务器通过响应头来将某些重要的 Cookie 设置为 HttpOnly 值,限制了客户端浏览器可以通过 document.cookie 获取这些重要的 Cookie 信息; 充分利用 CSP,可以通过 <meta> 来配置 CSP,这也是前端用于防止 XSS 的最合适手段。 1 <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src https://*; child-src 'none';"> 跨站请求伪造(CSRF) 跨站请求伪造(Cross-site request forgery)简称是 CSRF:是指黑客引诱用户打开黑客的网站,在黑客的网站中,利用用户的登录状态发起的跨站请求。 CSRF 是怎么攻击的 一个典型的 CSRF 攻击过程应该是这样子的: 用户登录 A 网站,并且保留了登录信息(Cookie); 被黑客诱导访问了 B 网站,从 A 跳转到 B; B 网站对 A 网站发出请求(这个就是下面将介绍的几种伪造请求的方式),浏览器请求头中会默认携带 A 网站的 Cookie; A 网站服务器收到请求后,经过验证发现用户是登录了的,所以会处理请求。 下面将举一个例子来模拟几种伪造请求的方式。假设: 1 https://platforma.com/withdraw?account=账户名&money=转账金额` 这是某个资金平台 A 的转账接口,黑客知道这个接口后就可以通过以下方式进行攻击: 1. 自动发起 GET 请求 黑客在他自己网站的页面上加载了一张图片,而链接地址是指向那个转账接口。所以需要做的就是,只要某个用户在资金平台 A 上刚登录过,且此时被诱导点击了黑客的页面,一进入这个页面就会自动发起 GET 请求去加载图片,实而是去请求去执行转账接口。 1 <img src="https://platforma.com/withdraw?account=hacker名&money=1000"> 2. 自动发起 POST 请求 这类其实就是表单的自动提交。以下是黑客网站上的代码,一旦跳转到黑客指定的页面就会自动提交表单: 1 2 3 4 5 <form action="https://platforma.com/withdraw" method=POST> <input type="hidden" name="account" value="hacker" /> <input type="hidden" name="money" value="1000" /> </form> <script> document.forms[0].submit()</script> 3. 点击链接来触发请求 这种伪造请求的方式和第一种很像,不过是将请求的接口放到了 <a> 链接上: 1 2 3 4 <img src="美女图片的链接" /> <a href="https://platforma.com/withdraw?account=hacker名&money=1000"> 点击查看更多美女图片 <a/> 如何预防 CSRF 攻击 1. 给 Cookie 设置合适的 SameSite 当从 A 网站登录后,会从响应头中返回服务器设置的 Cookie 信息,而如果 Cookie 携带了 SameSite=strict 则表示完全禁用第三方站点请求头携带 Cookie,比如当从 B 网站请求 A 网站接口的时候,浏览器的请求头将不会携带该 Cookie。SameSite 还有另外 2 个属性值: Lax 是默认值,允许第三方站点的 GET 请求携带; None 任何情况下都会携带; 以下是一个响应头的 Set-Cookie 示例: 1 Set-Cookie: flavor=choco; SameSite=strict 2. 同源检测 在服务端,通过请求头中携带的 Origin 或者 Referer 属性值进行判断请求是否来源同一站点,同时服务器应该优先检测 Origin。为了安全考虑,相比于 Referer,Origin 只包含了域名而不带路径。 3. CSRF Token 大概过程是可以分成 2 步骤: 在浏览器向服务器发起请求时,服务器生成一个 CSRF Token。CSRF Token 其实就是服务器生成的随机字符串,然后将该字符串植入到返回的页面中,通常是放到表单的隐藏输入框中,这样能够很好的保护 CSRF Token 不被泄漏; 1 2 3 4 5 6 <form action="https://platforma.com/withdraw" method="POST"> <input type="hidden" name="csrf-token" value="nc98P987b"> <input type="text" name="account"> <input type="text" name="money"> <input type="submit"> </form> 当浏览器再次发送请求的时候(比如转账),就需要携带这个 CSRF Token 值一并提交; 服务器验证 CSRF Token 是否一致;从第三方网站发出的请求是无法获取用户页面中的 CSRF Token 值的。 点击劫持(ClickJacking) 点击劫持(Clickjacking)是一种通过视觉欺骗的手段来达到攻击目的手段。往往是攻击者将目标网站通过 iframe 嵌入到自己的网页中,通过 opacity 等手段设置 iframe 为透明的,使得肉眼不可见,这样一来当用户在攻击者的网站中操作的时候,比如点击某个按钮(这个按钮的顶层其实是 iframe),从而实现目标网站被点击劫持。 防护手段即不希望自己网站的页面被嵌入到别人的网站中。 frame busting 如果 A 页面通过 iframe 被嵌入到 B 页面,那么在 A 页面内部window 对象将指向 iframe,而 top 将指向最顶层的网页这里是 B。所以可以依据这个原理来判断自己的页面是被 iframe 引入而嵌入到别人页面,如果是的话,则通过如下的判断会使得 B 页面将直接替换 A 的内容而显示,从而让用户发觉自己被骗。 1 2 3 if (top.location != window.location) { top.location = window.location } X-Frame-Options 通过给页面响应头里设置 X-Frame-Options 为某个属性值,就能达到控制该页面是否可以通过 iframe 的方式被嵌入到别人的网站中。 它有 3 个属性值: deny 表示该页面不允许嵌入到任何页面,包括同一域名页面也不允许; sameorigin 表示只允许嵌入到同一域名的页面; allow-from uri 表示可以嵌入到指定来源的页面中。 点击劫持中的本质就是通过视觉来欺骗用户,顺着这个思路,还有一个攻击方法也和这个类似,那就是图片覆盖攻击大概的原理就是通过样式把图片覆盖在攻击者所希望的任意位置,比如盖在一个网站的 logo 上,当用户点击图片的时候就会被链接到攻击者的站点。 1 2 3 <a src="https://hacker.com"> <img src="图片链接" style="position: absolute; left: 100; top: 100; z-index: 100;"/> </a> 对于这种攻击方式,预防的手段就是需要用户在提交的 HTML 中检查,<img> 标签是否有可能导致浮出。 参考文章 浏览器的工作原理与实践 Same-origin_policy CSP 白帽子讲Web安全

2020/12/4
articleCard.readMore

「建议收藏」送你一份精心总结的3万字ES6实用指南(全)

写本篇文章目的是为了夯实基础,基于阮一峰老师的著作 ECMAScript 6 入门 以及 tc39-finished-proposals 这两个知识线路总结提炼出来的重点和要点,涉及到从 ES2015 到 ES2021 的几乎所有知识,基本上都是按照一个知识点配上一段代码的形式来展示,所以篇幅较长,也正是因为篇幅过长,所以就没把 Stage 2 和 Stage 3 阶段的提案写到这里,后续 ES2021 更新了我再同步更新。 有 5 个提案已经列入 Expected Publication Year in 2021 所以本篇中暂且把他们归为 ES2021。 ES6 前言 发展史 能写好 JS 固然是重要的,但是作为一个前端,我们也要了解自己所使用语言的发展历程,这里强烈推荐看 《JavaScript 20 年》,本书详细记载和解读了自 1995 年语言诞生到 2015 年 ES6 规范制定为止,共计 20 年的 JavaScript 语言演化历程。 版本说明 2011 年,发布了 ECMAScript 5.1 版,而 2015 年 6 月发布了 ES6 的第一个版本又叫 ES2015。ES6 其实是一个泛指,指代 5.1 版本以后的下一代标准。TC39 规定将于每年的 6 月发布一次正式版本,版本号以当年的年份为准,比如当前已经发布了 ES2015、ES2016、ES2017、ES2018、ES2019、ES2020 等版本。 提案发布流程 任何人都可以向 TC39 提案,要求修改语言标准。一种新的语法从提案到变成正式标准,需要经历五个阶段。每个阶段的变动都需要由 TC39 委员会批准。 Stage 0 - Strawperson(展示阶段) Stage 1 - Proposal(征求意见阶段) Stage 2 - Draft(草案阶段) Stage 3 - Candidate(候选人阶段) Stage 4 - Finished(定案阶段) 一个提案只要能进入 Stage 2,就差不多肯定会包括在以后的正式标准里面。ECMAScript 当前的所有提案,可以在这里查看 ecma262。关于提案流程可以在这里 TC39_Process 看到更加详细的信息。 ES2015 声明 const:声明一个常量,let:声明一个变量;const/let 声明的常量/变量都只能作用于代码块(块级作用域或函数作用域)里; 1 2 3 4 if (true) { let name = '布兰' } console.log(name) // undefined const/let 不存在变量提升,所以在代码块里必须先声明然后才可以使用,这叫暂时性死区; 1 2 3 4 5 6 let name = 'bubuzou' if (true) { name = '布兰' let name } console.log(name) const/let 不允许在同一个作用域内,重复声明; 1 2 3 function setName(name) { let name = '' // SyntaxError } const 声明时必须初始化,且后期不能被修改,但如果初始化的是一个对象,那么不能修改的是该对象的内存地址; 1 2 3 4 5 6 const person = { name: '布兰', } person.name = 'bubuzou' console.log(person.name) // 'bubuzou' person = '' // TypeError const/let 在全局作用域中声明的常量/变量不会挂到顶层对象(浏览器中是 window )的属性中; 1 2 3 4 var name = '布兰' let age = 12 console.log(window.name) // '布兰' console.log(window.age) // undefined 解构赋值 解构类型: 字符串解构 1 2 let [a, b, c = 'c'] = '12' console.log(a, b, c) // '1' '2' 'c' 数值解构 1 2 let { toFixed: tf } = 10 console.log(tf.call(Math.PI, 2)) // 3.14 布尔值解构 1 2 let { toString: ts } = true console.log(ts.call(false)) // 'false' 数组解构:等号右侧的数据具有 Iterator 接口可以进行数组形式的解构赋值; 1 2 3 4 5 6 7 // 解构不成功的变量值为 undefined let [a, b, c] = [1, 2] console.log(a, b, c) // 1, 2, undefined // 可以设置默认值 let [x, y, z = 3] = [1, 2, null] console.log(x, y, z) // 1, 2, null 什么样的数据具有 Iterator 接口呢?如果一个对象能够通过 [Symbol.iterator] 访问,且能够返回一个符合迭代器协议的对象,那么该对象就是可迭代的。目前内置的可迭代对象有:String、Array、TypeArray、Map、Set、arguments 和 NodeList 等。 对象解构:与数组按照索引位置进行解构不同,对象解构是按照属性名进行解构赋值,如果在当前对象属性匹配不成功则会去对象的原型属性上查找: 1 2 // 默认写法 let { name: name, age: age } = { name: '布兰', age: 12 } 1 2 // 简写 let { name, age } = { name: '布兰', age: 12 } 1 2 3 // 改名且设置默认值 let { name: name1, age: age1 = 12 } = { name: '布兰' } console.log(name1, age1) // '布兰' 12 函数参数解构:其实就是运用上面的对象解构和数组解构规则; 1 2 3 4 5 6 7 8 function move({ x = 0, y = 0 } = {}) { console.log([x, y]) return [x, y] } move({ x: 3, y: 8 }) // [3, 8] move({ x: 3 }) // [3, 0] move({}) // [0, 0] move() // [0, 0] 解构要点: 只要等号两边的模式相同(同是对象或同是数组),则左边的变量会被赋予对应的值; 解构不成功的变量值为 undefined; 默认值生效的前提是当等号右边对应的值全等于 undefined 的时候; 只要等号右边的值不是对象或者数组,则会进行自动装箱将其转成对象; null 和 undefined 都无法转成对象,所以无法解构。 解构应用: 交换变量的值; 1 2 3 4 let x = 1, y = 2 ;[x, y] = [y, x] console.log(x, y) // 2 1 通过函数返回对象属性 1 2 3 4 5 6 7 function getParams() { return { name: '布兰', age: 12, } } let { name, age } = getParams() 通过定义函数参数来声明变量 1 2 3 4 5 6 7 8 9 10 11 12 13 let person = { name: '布兰', age: 12, } init(person) // 普通用法 function init(person) { let { name, age } = person } // 更简洁用法 function init({ name, age }) {} 指定函数参数默认值 1 2 3 4 5 function initPerson({ name = '布兰', age = 12 } = {}) { console.log(name, age) } initPerson() // '布兰' 12 initPerson({ age: 20 }) // '布兰' 20 提取 JSON 数据 1 2 3 4 5 6 7 let responseData = { code: 1000, data: {}, message: 'success', } let { code, data = {} } = responseData 遍历 Map 结构 1 2 3 4 5 6 7 let map = new Map() map.set('beijing', '北京') map.set('xiamen', '厦门') for (let [key, value] of map) { console.log(key, value) } 输入模块的指定方法和属性 1 const { readFile, writeFile } = require('fs') 字符串扩展 可以使用 Unicode 编码来表示一个字符: 1 2 3 4 5 6 // 以下写法都可以用来表示字符 z '\z' // 转义 '\172' // 十进制表示法 '\x7A' // 十六进制表示法 '\u007A' // Unicode 普通表示法 '\u{7A}' // Unicode 大括号表示法 www.52unicode.com 这个网站可以查询到常见符号的 Unicode 编码。 可以使用 for...of 正确遍历字符串: 1 2 3 4 5 6 7 let str = '😀🤣😜😍🤗🤔' for (const emoji of str) { console.log(emoji) // 😀🤣😜😍🤗🤔 } for (let i = 0, l = str.length; i < l; i++) { console.log(str[i]) // 不能正确输出表情 } 模板字符串使用两个反引号标识(``),可以用来定义多行字符串,或者使用它在字符串中插入变量: 1 2 3 4 let name = 'hero' let tips = `Hello ${name}, welcome to my world.` alert(tips) 标签模板:在函数名后面接一个模板字符串相当于给函数传入了参数进行调用: 1 2 3 4 5 6 7 let name = '布兰', age = 12 let tips = parse`Hello ${name}, are you ${age} years old this year?` function parse(stringArr, ...variable) {} // 相当于传递如下参数进行调用 parse 函数 parse(['Hello ', ', are you ', ' years old this year?'], name, age) String.fromCodePoint() 用于从 Unicode 码点返回对应字符,可以支持 0xFFFF 的码点: 1 2 String.fromCharCode(0x1f600) // "" String.fromCodePoint(0x1f600) // "😀" String.raw() 返回把字符串所有变量替换且对斜杠进行转义的结果: 1 String.raw`Hi\n${2 + 3}!` // "Hi\n5!" codePointAt() 返回字符的十进制码点,对于 Unicode 大于 0xFFFF 的字符,会被认为是 2 个字符,十进制码点转成十六进制可以使用 toString(16): 1 2 3 4 5 6 let emoji = '🤣' emoji.length // 2 emoji.charCodeAt(0).toString(16) // 'd83d' emoji.charCodeAt(1).toString(16) // 'de00' String.fromCodePoint(0xd83d, 0xde00) === '🤣' // true normalize() 方法会按照指定的一种 Unicode 正规形式将当前字符串正规化。(如果该值不是字符串,则首先将其转换为一个字符串): 1 2 3 4 5 6 7 8 let str1 = '\u00F1' let str2 = '\u006E\u0303' str1 // ñ str2 // ñ str1 === str2 // false str1.length === str2.length // false str1.normalize() === str2.normalize() // true 字符串是否包含子串: includes():返回布尔值,表示是否找到了参数字符串。 startsWith():返回布尔值,表示参数字符串是否在原字符串的头部。 endsWith():返回布尔值,表示参数字符串是否在原字符串的尾部。 1 2 3 4 5 let s = 'Hello world!' s.includes('o') // true s.startsWith('Hello') // true s.endsWith('!') // true 这三个方法都支持第二个参数,表示开始搜索的位置: 1 2 3 4 5 let s = 'Hello world!' s.includes('Hello', 6) // false s.startsWith('world', 6) // true s.endsWith('Hello', 5) // true 上面代码表示,使用第二个参数 n 时,endsWith 的行为与其他两个方法有所不同。它针对前 n 个字符,而其他两个方法针对从第 n 个位置直到字符串结束。 repeat(n) 将当前字符串重复 n 次后,返回一个新字符串: 1 2 3 4 5 6 7 8 'x'.repeat(2) // 'xx' 'x'.repeat(1.9) // 'x' 'x'.repeat(NaN) // '' 'x'.repeat(undefined) // '' 'x'.repeat('2a') // '' 'x'.repeat(-0.6) // '',解释:0 ~ 1 之间的小数相当于 0 'x'.repeat(-2) // RangeError 'x'.repeat(Infinity) // RangeError 数值扩展 二进制(0b)和八进制(0o)表示法: 1 2 3 4 5 let num = 100 let b = num.toString(2) // 二进制的100:1100100 let o = num.toString(8) // 八进制的100:144 0b1100100 === 100 // true 0o144 === 100 // true Number.isFinite() 判断一个数是否是有限的数,入参如果不是数值一律返回 false: 1 2 3 4 5 Number.isFinite(-2.9) // true Number.isFinite(NaN) // false Number.isFinite('') // false Number.isFinite(false) // true Number.isFinite(Infinity) // false Number.isNaN() 判断一个数值是否为 NaN,如果入参不是 NaN 那结果都是 false: 1 2 3 Number.isNaN(NaN) // true Number.isFinite('a' / 0) // true Number.isFinite('NaN') // false 数值转化:Number.parseInt() 和 Number.parseFloat(),非严格转化,从左到右解析字符串,遇到非数字就停止解析,并且把解析的数字返回: 1 2 3 4 parseInt('12a') // 12 parseInt('a12') // NaN parseInt('') // NaN parseInt('0xA') // 10,0x开头的将会被当成十六进制数 parseInt() 默认是用十进制去解析字符串的,其实他是支持传入第二个参数的,表示要以多少进制的 基数去解析第一个参数: 1 2 parseInt('1010', 2) // 10 parseInt('ff', 16) // 255 参考:parseInt Number.isInteger() 判断一个数值是否为整数,入参为非数值则一定返回 false: 1 2 3 4 5 6 7 Number.isInteger(25) // true Number.isInteger(25.0) // true Number.isInteger() // false Number.isInteger(null) // false Number.isInteger('15') // false Number.isInteger(true) // false Number.isInteger(3.0000000000000002) // true 如果对数据精度的要求较高,不建议使用 Number.isInteger() 判断一个数值是否为整数。 Number.EPSILON 表示一个可接受的最小误差范围,通常用于浮点数运算: 1 2 0.1 + 0.2 === 0.3 // false Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON // true Number.isSafeInteger() 用来判断一个数是否在最大安全整数(Number.MAX_SAFE_INTEGER)和最小安全整数(Number.MIN_SAFE_INTEGER)之间: 1 2 3 4 5 6 Number.MAX_SAFE_INTEGER === 2 ** 53 - 1 // true Number.MAX_SAFE_INTEGER === 9007199254740991 // true Number.MIN_SAFE_INTEGER === -Number.MAX_SAFE_INTEGER // true Number.isSafeInteger(2) // true Number.isSafeInteger('2') // false Number.isSafeInteger(Infinity) // false Math.trunc():返回数值整数部分 Math.sign():返回数值类型(正数 1、负数 -1、零 0) Math.cbrt():返回数值立方根 Math.clz32():返回数值的 32 位无符号整数形式 Math.imul():返回两个数值相乘 Math.fround():返回数值的 32 位单精度浮点数形式 Math.hypot():返回所有数值平方和的平方根 Math.expm1():返回 e^n - 1 Math.log1p():返回 1 + n 的自然对数(Math.log(1 + n)) Math.log10():返回以 10 为底的 n 的对数 Math.log2():返回以 2 为底的 n 的对数 Math.sinh():返回 n 的双曲正弦 Math.cosh():返回 n 的双曲余弦 Math.tanh():返回 n 的双曲正切 Math.asinh():返回 n 的反双曲正弦 Math.acosh():返回 n 的反双曲余弦 Math.atanh():返回 n 的反双曲正切 数组扩展 数组扩展运算符(…)将数组展开成用逗号分隔的参数序列,只能展开一层数组: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 应用一:函数传参 Math.max(...[1, 2, 3]) // 3 // 应用二:数组合并 let merge = [...[1, 2], ...[3, 4], 5, 6] // 1, 2, 3, 4, 5, 6 // 应用三:浅克隆 let a = [1, 2, 3] let clone = [...a] a === clone // false // 应用四:数组解构 const [x, ...y] = [1, 2, 3] x // 1 y // [2, 3] Array.from() 可以将类数组对象( NodeList,arguments)和可迭代对象转成数组: 1 2 3 4 5 6 7 8 9 10 11 12 // 应用一:字符串转数组 Array.from('foo') // ['f', 'o', 'o'] // 应用二:数组合并去重 let merge = [...[1, 2], ...[2, 3]] Array.from(new Set(merge)) // ['1', '2', '3'] // 应用三:arguments 转数组 function f() { return Array.from(arguments) } f(1, 2, 3) // [1, 2, 3] 如果 Array.from() 带第二个参数 mapFn,将对生成的新数组执行一次 map 操作: 1 2 Array.from([1, 2, 3], (x) => x * x) // [1, 4, 9] Array.from({ length: 3 }, (v, i) => ++i) // [1, 2, 3] Array.of() 将一组参数转成数组: 1 2 3 4 5 6 7 Array.of(1, 2, 3) // [1, 2, 3] // 类似于 function arrayOf(...params) { return [].slice.call(params) } arrayOf(1, 2, 3) // [1, 2, 3] Array.copyWithin() 在当前数组内部,将制定位置的成员复制到其他位置(会覆盖原来位置的成员),最后返回一个新数组。接收 3 个参数,参数为负数表示右边开始计算: target(必选):替换位置的索引; start(可选):从该位置开始读取数据,默认为 0; end(可选):从该位置结束读取数据(不包括该位置的数据),默认为原数组长度; 1 2 3 4 5 ;[1, 2, 3, 4, 5] .copyWithin(-1) // [1, 2, 3, 4, 1] [(1, 2, 3, 4, 5)].copyWithin(1) // [1, 1, 2, 3, 4] [(1, 2, 3, 4, 5)].copyWithin(0, 3, 4) // [4, 2, 3, 4, 5] [(1, 2, 3, 4, 5)].copyWithin(0, -3, -1) // [3, 4, 3, 4, 5] 查找第一个出现的子成员:find() 和 findIndex(): 1 2 3 4 5 6 7 8 // 找出第一个偶数 ;[1, 6, 9] .find((val, index, arr) => val % 2 === 0) // 6 [ // 找出第一个偶数的索引位置 (1, 6, 9) ].findIndex((val, index, arr) => val % 2 === 0) // 1 fill() 使用给定的值来填充数组,有 3 个参数: value:填充值; start(可选),开始索引,默认为 0; end(可选):结束索引,默认为数组长度,不包括该索引位置的值; 1 2 3 4 5 // 初始化空数组 Array(3) .fill(1) // [1, 1, 1] [(1, 2, 3, 4)].fill('a', 2, 4) // [1, 2, 'a', 'a'] 通过 keys()(键名)、entries()(键值)和 values()(键值对) 获取数组迭代器对象,可以被 for...of 迭代, 1 2 3 4 5 6 7 8 9 10 let arr = ['a', 'b', 'c'] for (let x of arr.keys()) { console.log(x) // 1, 2, 3 } for (let v of arr.values()) { console.log(v) // 'a' 'b' 'c' } for (let e of arr.entries()) { console.log(e) // [0, 'a'] [0, 'b'] [0, 'c'] } 数组空位,是指数组没有值,比如:[,,],而像这种 [undefined] 是不包含空位的。由于 ES6 之前的一些 API 对空位的处理规则很不一致,所以实际操作的时候应该尽量避免空位的出现,而为了改变这个现状,ES6 的 API 会默认将空位处理成 undefined: 1 2 [...[1, , 3].values()] // [1, undefined, 3] [1, , 3].findIndex(x => x === undefined) // 1 对象扩展 对象属性简写: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 let name = '布兰' let person = { name, getName() { return this.name }, } // 等同于 let person1 = { name: '布兰', getName: function () { return this.name }, } 属性名表达式:在用对象字面量定义对象的时候,允许通过属性名表达式来定义对象属性: 1 2 3 4 5 6 7 let name = 'name', let person = { [name]: '布兰', ['get'+ name](){ return this.name } } 方法的 name 属性,存在好几种情况,这里仅列出常见的几种: 情况一:普通对象方法的 name 属性直接返回方法名,函数声明亦是如此,函数表达式返回变量名: 1 2 3 4 let person = { hi() {}, } person.hi.name // 'hi' 情况二:构造函数的 name 为 anonymous: 1 new Function().name // 'anonymous' 情况三:绑定函数的 name 将会在函数名前加上 bound: 1 2 function foo() {} foo.bind({}).name // 'bound foo' 情况四:如果对象的方法使用了取值函数(getter)和存值函数(setter),则 name 属性不是在该方法上面,而是该方法的属性的描述对象的 get 和 set 属性上面: 1 2 3 4 5 6 7 8 let o = { get foo() {}, set foo(x) {}, } o.foo.name // TypeError: Cannot read property 'name' of undefined let descriptor = Object.getOwnPropertyDescriptor(o, 'foo') descriptor.get.name // "get foo" descriptor.set.name // "set foo" 参考:function_name 属性的可枚举性 对象的每个属性都有一个描述对象(Descriptor),用来控制该属性的行为。可以通过 Object.getOwnPropertyDescriptor() 来获取对象某个属性的描述: 1 2 3 4 5 6 7 8 let person = { name: '布兰', age: 12 } Object.getOwnPropertyDescriptor(person, 'name') // { // configurable: true, // enumerable: true, // value: "布兰", // writable: true, // } 这里的 enumerable 就是对象某个属性的可枚举属性,如果某个属性的 enumerable 值为 false 则表示该属性不能被枚举,所以该属性会被如下 4 种操作忽略: for...in :只遍历对象自身的和继承的可枚举的属性; Object.keys():返回对象自身的所有可枚举的属性的键名; JSON.stringify():只串行化对象自身的可枚举的属性; Object.assign(): 只拷贝对象自身的可枚举的属性。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 let person = { name: '布兰' } Object.defineProperty(person, 'age', { configurable: true, enumerable: false, value: 12, writable: true, }) person // { name: '布兰', age: 12 } // 以下操作都将忽略 person 对象的 age 属性 for (let x in person) { console.log(x) // 'name' } Object.keys(person) // ['name'] JSON.stringify(person) // '{"name": "布兰"}' Object.assign({}, person) // { name: '布兰' } Reflect.ownKeys(obj): 返回一个数组,包含对象自身的(不含继承的)所有键名,不管键名是 Symbol 或字符串,也不管是否可枚举: 1 2 // 基于上面的代码 Reflect.ownKeys(person) // ['name', 'age'] super 关键字,指向对象的原型对象,只能用于对象的方法中,其他地方将报错: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 let person = { name: '布兰', getName() { return super.name }, } Object.setPrototypeOf(person, { name: 'hello' }) person.getName() // 'hello' // 以下几种 super 的使用将报错 const obj1 = { foo: super.foo, } const obj2 = { foo: () => super.foo, } const obj3 = { foo: function () { return super.foo }, } Object.is() 用来判断两个值是否相等,表现基本和 === 一样,除了以下两种情况: 1 2 3 4 ;+0 === -0 //true NaN === NaN // false Object.is(+0, -0) // false Object.is(NaN, NaN) // true Object.assign() 用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target),如果有同名属性,则后面的会直接替换前面的: 1 2 3 4 5 let target = { a: 1 } let source1 = { a: 2, b: 3, d: { e: 1, f: 2 } } let source2 = { a: 3, c: 4, d: { g: 3 } } Object.assign(target, source1, source2) target // { a: 3, b: 3, c: 4, d: {g: 3} } Object.assign() 实行的是浅拷贝,如果源对象某个属性是对象,那么拷贝的是这个对象的引用: 1 2 3 4 5 let target = { a: { b: 1 } } let source = { a: { b: 2 } } Object.assign(target, source) target.a.b = 3 source.a.b // 3 __proto__ 属性是用来读取和设置当前对象的原型,而由于其下划线更多的是表面其是一个内部属性,所以建议不在正式场合使用它,而是用下面的 Object.setPrototypeOf()(写操作)、Object.getPrototypeOf()(读操作)、Object.create()(生成操作)代替。 Object.setPrototypeOf() 用于设置对象原型,Object.getPrototypeOf() 用于读取对象原型: 1 2 3 let person = { name: '布兰' } Object.setPrototypeOf(person, { name: '动物' }) Object.getPrototypeOf(person) // {name: '动物'} 正则扩展 RegExp 构造函数,允许首参为正则表达式,第二个参数为修饰符,如果有第二个参数,则修饰符以第二个为准: 1 2 let reg = new RegExp(/xYz\d+/gi, i) reg.flags // 'i' 正则方法调用变更:字符串对象的 match()、replace()、search()、split() 内部调用转为调用 RegExp 实例对应的 RegExp.prototype[Symbol.方法]; u 修饰符:含义为 Unicode 模式,用来正确处理大于 \uFFFF 的 Unicode 字符。也就是说,如果待匹配的字符串中可能包含有大于 \uFFFF 的字符,就必须加上 u 修饰符,才能正确处理。 1 2 3 4 5 6 7 8 9 10 11 // 加上 u 修饰符才能让 . 字符正确识别大于 \uFFFF 的字符 /^.$/.test('🤣') // false /^.$/u.test('🤣') // true // 大括号 Unicode 字符表示法必须加上 u 修饰符 /\u{61}/.test('a') // false /\u{61}/u.test('a') // true // 有 u 修饰符,量词才能正确匹配大于 \uFFFF 的字符 /🤣{2}/.test('🤣🤣') // false /🤣{2}/u.test('🤣🤣') // true RegExp.prototype.unicode 属性表示正则是否设置了 u 修饰符: 1 2 /🤣/.unicode // false /🤣/u.unicode // true y 修饰符,与 g 修饰符类似也是全局匹配;不同的是 g 是剩余字符中匹配即可,而 y 则是必须在剩余的第一个字符开始匹配才行,所以 y 修饰符也叫黏连修饰符: 1 2 3 4 5 6 7 8 9 let s = 'aaa_aa_a' let r1 = /a+/g let r2 = /a+/y r1.exec(s) // ["aaa"] r2.exec(s) // ["aaa"] r1.exec(s) // ["aa"] r2.exec(s) // null RegExp.prototype.sticky 属性表示是否设置了 y 修饰符: 1 ;/abc/y.sticky // true RegExp.prototype.flags 属性会返回当前正则的所有修饰符: 1 ;/abc🤣/iuy.flags // 'iuy' 函数扩展 函数参数默认值。参数不能有同名的,函数体内不能用 let 和 const 声明同参数名的变量: 1 function printInfo(name = '布兰', age = 12) {} 使用参数默认值的时候,参数不能有同名的: 1 2 function f(x, x, y) {} // 不报错 function f(x, x, y = 1) {} // 报错 函数体内不能用 let 和 const 声明同参数名的变量: 1 2 3 4 // 报错 function f(x, y) { let x = 0 } 函数的 length 属性会返回没有指定默认值的参数个数,且如果设置默认值的参数不是尾参数,则 length 不再计入后面的参数: 1 2 3 4 5 6 7 8 9 ;(function f(x, y) {} .length( // 2 function f(x, y = 1) {} ) .length( // 1 function f(x = 1, y) {} ).length) // 0 剩余(rest) 参数(…变量名)的形式,用于获取函数的剩余参数,注意 rest 参数必须放在最后一个位置,可以很好的代替 arguments 对象: 1 2 3 4 5 6 7 function f(x, ...y) { console.log(x) // 1 for (let val of y) { coonsole.log(val) // 2 3 } } f(1, 2, 3) 严格模式:只要函数参数使用了默认值、解构赋值或者扩展运算符,那么函数体内就不能显示的设定为严格模式,因为严格模式的作用范围包含了函数参数,而函数执行的顺序是先执行参数,然后再执行函数体,执行到函数体里的 use strict 的时候,那么此时因为函数参数已经执行完成了,那函数参数还要不要受到严格模式的限制呢?这就出现矛盾了。规避限制的办法有两个:设置全局的严格模式或者在函数体外在包一个立即执行函数并且声明严格模式: 1 2 3 4 5 6 7 8 9 // 解法一 'use strict' function f(x, y = 2) {} // 解法二 let f = (function () { 'use strict' return function (x, y = 2) {} })() 箭头函数语法比函数表达式更简洁,并且没有自己的 this、arguments,不能用作构造函数和用作生成器。 几种箭头函数写法: 1 2 3 4 5 6 7 let f1 = () => {} // 没有参数 let f2 = (x) => {} // 1个参数 let f3 = (x) => {} // 1个参数可以省略圆括号 let f4 = (x, y) => {} // 2个参数以上必须加上圆括号 let f5 = (x = 1, y = 2) => {} // 支持参数默认值 let f6 = (x, ...y) => {} // 支持 rest 参数 let f7 = ({ x = 1, y = 2 } = {}) // 支持参数解构 箭头函数没有自己的 this: 1 2 3 4 5 6 7 function Person() { this.age = 0 setInterval(() => { this.age++ }, 1000) } var p = new Person() // 1 秒后 Person {age: 1} 通过 call/apply 调用箭头函数的时候将不会绑定第一个参数的作用域: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 let adder = { base: 1, add: function (a) { let f = (v) => v + this.base return f(a) }, addThruCall: function (a) { let f = (v) => v + this.base let b = { base: 2, } return f.call(b, a) }, } adder.add(1) // 输出 2 adder.addThruCall(1) // 仍然输出 2 箭头函数没有自己的 arguments 对象,不过可以使用 rest 参数代替: 1 2 3 4 5 6 7 8 9 10 let log = () => { console.log(arguments) // ReferenceError } log(2, 3) // 剩余参数代替写法 let restLog = (...arr) => { console.log(arr) // [2, 3] } restLog(2, 3) 箭头函数不能用作构造器,和 new 一起用会抛出错误: 1 2 3 let Foo = () => {} let foo = new Foo() // TypeError: Foo is not a constructor 箭头函数返回对象字面量,需要用圆括号包起来: 1 let func2 = () => ({ foo: 1 }) 参考:Arrow_functions 尾调用和尾递归 首先得知道什么是尾调用:函数的最后一步调用另外一个函数: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 是尾调用 function f(x) { return g(x) } // 以下都不是尾调用 function f(x) { let y = g(x) return y } function f(x) { let y = g(x) return g(x) + 1 } function f(x) { g(x) // 因为最后一步是 return: undefined } 尾调用有啥用?我们知道函数的相互调用是会生成“调用帧”的,而“调用帧”里存了各种信息比如函数的内部变量和调用函数的位置等,所有的“调用帧”组成了一个“调用栈”。如果在函数的最后一步操作调用了另外一个函数,因为外层函数里调用位置、内部变量等信息都不会再用到了,所有就无需保留外层函数的“调用帧”了,只要直接用内层函数的“调用帧”取代外层函数的“调用帧”即可: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function f() { let m = 1 let n = 2 return g(m + n) } f() // 等同于 function f() { return g(3) } f() // 等同于 g(3) 这样一来就很明显的减少了调用栈中的帧数,内存占用就少了,所以这就是尾调用的优化作用。尾递归也是如此,递归如果次数多那就需要保留非常多的“调用帧”,所以经常会出现栈溢出错误,而使用了尾递归优化后就不会发生栈溢出的错误了: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 常规递归的斐波那契函数 function Fibonacci(n) { if (n <= 1) { return 1 } return Fibonacci(n - 1) + Fibonacci(n - 2) } Fibonacci(100) // 超时 // 尾递归优化后的斐波那契函数 function Fibonacci2(n, ac1 = 1, ac2 = 1) { if (n <= 1) { return ac2 } return Fibonacci2(n - 1, ac2, ac1 + ac2) } s Fibonacci2(100) // 573147844013817200000 Symbol Symbol 是一个新的原始类型,用来表示一个独一无二的值,可以通过 Symbol() 函数来创建一个 Symbol 类型的值,为了加以区分,可以传入一个字符串作为其描述: 1 2 3 let s1 = Symbol('foo') let s2 = Symbol('foo') s1 === s2 // false Symbol 类型无法通过数学运算符进行隐式类型转换,但是可以通过 String() 显示转成字符串或者通过 Boolean() 显示转成布尔值: 1 2 3 4 let s = Symbol('foo') String(s) // "Symbol('foo')" s.toString() // "Symbol('foo')" Boolean(s) // true 引入 Symbol 最大的初衷其实就是为了让它作为对象的属性名而使用,这样就可以有效避免属性名的冲突了: 1 2 3 4 5 6 7 let foo = Symbol('foo') let obj = { [foo]: 'foo1', foo: 'foo2', } obj[foo] // 'foo1' obj.foo // 'foo2' Symbol 属性的不可枚举性,不会被 for...in、for...of、Object.keys()、Object.getOwnPropertyNames()、JSON.stringify() 等枚举: 1 2 3 4 5 6 7 8 9 10 let person = { name: '布兰', [Symbol('age')]: 12, } for (let x in person) { console.log(x) // 'name' } Object.keys(person) // ['name'] Object.getOwnPropertyNames(person) // ['name'] JSON.stringify(person) // '{"name":"布兰"}' 但是可以通过 Object.getOwnPropertySymbols() 获取到对象的所有 Symbol 属性名,返回一个数组: 1 2 // 基于上面的代码 Object.getOwnPropertySymbols(person) // [Symbol(age)] 静态方法: Symbol.for() 按照描述去全局查找 Symbol,找不到则在全局登记一个: 1 2 3 let s1 = Symbol.for('foo') let s2 = Symbol.for('foo') s1 === s2 // true Symbol.for() 的这个全局登记特性,可以用在不同的 iframe 或 service worker 中取到同一个值。 Symbol.keyFor() 根据已经在全局登记的 Symbol 查找其描述: 1 2 let s = Symbol.for('foo') Symbol.keyFor(s) // 'foo' Symbol 的内置值: Symbol.hasInstance:指向一个内部方法,当其他对象使用 instanceof 运算符判断是否为此对象的实例时会调用此方法; Symbol.isConcatSpreadable:指向一个布尔,定义对象用于 Array.prototype.concat() 时是否可展开; Symbol.species:指向一个构造函数,当实例对象使用自身构造函数时会调用指定的构造函数; Symbol.match:指向一个函数,当实例对象被 String.prototype.match() 调用时会重新定义 match()的行为; Symbol.replace:指向一个函数,当实例对象被 String.prototype.replace() 调用时会重新定义 replace() 的行为; Symbol.search:指向一个函数,当实例对象被 String.prototype.search() 调用时会重新定义 search() 的行为;s Symbol.split:指向一个函数,当实例对象被 String.prototype.split() 调用时会重新定义 split() 的行为; Symbol.iterator:指向一个默认遍历器方法,当实例对象执行 for...of 时会调用指定的默认遍历器; Symbol.toPrimitive:指向一个函数,当实例对象被转为原始类型的值时会返回此对象对应的原始类型值; Symbol.toStringTag:指向一个函数,当实例对象被 Object.prototype.toString() 调用时其返回值会出现在 toString() 返回的字符串之中表示对象的类型; Symbol.unscopables:指向一个对象,指定使用 with 时哪些属性会被 with 环境排除; Set Set 是一种新的数据结构,类似数组,但是它没有键只有值,且值都是唯一的。可以通过构造函数生成一个新实例,接收一个数组或者可迭代数据结构作为参数: 1 2 new Set([1, 2, 3]) // Set {1, 2, 3} new Set('abc') // Set {'a', 'b', 'c'} Set 判断两个值是不是相等用的是 sameValueZero 算法,类似于 ===,唯一的区别是,在 Set 里 NaN 之间被认为是相等的: 1 2 3 4 5 6 let set = new Set() let a = NaN let b = NaN set.add(a) set.add(b) set.size // 1 相同对象的不同实例也被 Set 认为是不相等的: 1 2 3 4 5 6 let set = new Set() let a = { a: 1 } let b = { a: 1 } set.add(a) set.add(b) set.size // 2 Set 是有顺序的,将按照插入的顺序进行迭代,可以使用 for...of 迭代: 1 2 3 4 5 6 let set = new Set([1, 3]) set.add(5) set.add(7) for (let x of set) { console.log(x) } Set 实例属性和方法: Set.prototype.constructor:构造函数,默认就是 Set 函数; Set.prototype.size:返回 Set 实例的成员总数; Set.prototype.add(value):添加某个值,返回 Set 结构本身; Set.prototype.delete(value):删除某个值,返回一个布尔值,表示删除是否成功; Set.prototype.has(value):返回一个布尔值,表示该值是否为 Set 的成员; Set.prototype.clear():清除所有成员,没有返回值; Set.prototype.keys():返回键名的遍历器; Set.prototype.values():返回键值的遍历器; Set.prototype.entries():返回键值对的遍历器; Set.prototype.forEach():使用回调函数遍历每个成员; 1 2 3 4 5 6 7 8 let set = new Set([1, 3]) set.add(5) // Set {1, 3, 5} set.size // 3 set.delete(1) // true,1 已被删除 set.has(1) // false set.keys() // SetIterator {3, 5} set.clear() set.size // 0 Set 应用场景: 数组去重: 1 2 ;[...new Set([1, 3, 6, 3, 1])] // [1, 3, 6] Array.from(new Set([1, 3, 6, 3, 1])) // [1, 3, 6] 字符串去重: 1 ;[...new Set('abcbacd')].join('') // 'abcd' 求两个集合的交集/并集/差集: 1 2 3 4 5 6 7 8 9 10 11 let a = new Set([1, 2, 3]) let b = new Set([4, 3, 2]) // 并集 let union = new Set([...a, ...b]) // Set {1, 2, 3, 4} // 交集 let intersect = new Set([...a].filter((x) => b.has(x))) // set {2, 3} // (a 相对于 b 的)差集 let difference = new Set([...a].filter((x) => !b.has(x))) // Set {1} 遍历修改集合成员的值: 1 2 3 4 5 6 7 let set = new Set([1, 2, 3]) // 方法一 let set1 = new Set([...set].map((val) => val * 2)) // Set {2, 3, 6} // 方法二 let set2 = new Set(Array.from(set, (val) => val * 2)) // Set {2, 4, 6} WeakSet WeakSet 对象允许将弱保持对象存储在一个集合中: 1 2 3 4 5 let ws = new WeakSet() let foo = {} ws.add(foo) // WeakSet {{}} ws.has(foo) // true ws.delete(foo) // WeakSet {} 和 Set 的区别: WeakSet 只能是对象的集合,而不能是任何类型的任意值; WeakSet 持弱引用:集合中对象的引用为弱引用。如果没有其他的对 WeakSet 中对象的引用,那么这些对象会被当成垃圾回收掉。这也意味着 WeakSet 中没有存储当前对象的列表。正因为这样,WeakSet 是不可枚举的,也就没有 size 属性,没有 clear 和遍历的方法。 实例方法: WeakSet.prototype.add(value):添加一个新元素 value; WeakSet.prototype.delete(value):从该 WeakSet 对象中删除 value 这个元素; WeakSet.prototype.has(value):返回一个布尔值, 表示给定的值 value 是否存在于这个 WeakSet 中; Map Map 是一种类似于 Object 的这种键值对的数据结构,区别是对象的键只能是字符串或者 Symbol,而 Map 的键可以是任何类型(原始类型、对象或者函数),可以通过 Map 构造函数创建一个实例,入参是具有 Iterator 接口且每个成员都是一个双元素数组 [key, value] 的数据结构: 1 2 3 4 5 6 7 8 let map1 = new Map() map1.set({}, 'foo') let arr = [ ['name', '布兰'], ['age', 12], ] let map2 = new Map(arr) Map 中的键和 Set 里的值一样也必须是唯一的,遵循 sameValueZero 算法,对于同一个键后面插入的会覆盖前面的, 1 2 3 4 5 let map = new Map() let foo = { foo: 'foo' } map.set(foo, 'foo1') map.set(foo, 'foo2') map.get(foo) // 'foo2' 对于键名同为 NaN 以及相同对象而不同实例的处理同 Set 的值一样: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 let a = NaN let b = NaN let map = new Map() map.set(a, 'a') map.set(b, 'b') map.size // 1 map.get(a) // 'b' let c = { c: 'c' } let d = { c: 'c' } map.set(c, 'c') map.set(d, 'd') map.size // 3 map.get(c) // 'c' 实例属性和方法: Map.prototype.size:返回 Map 对象的键值对数量; Map.prototype.set(key, value):设置 Map 对象中键的值。返回该 Map 对象; Map.prototype.get(key): 返回键对应的值,如果不存在,则返回 undefined; Map.prototype.has(key):返回一个布尔值,表示 Map 实例是否包含键对应的值; Map.prototype.delete(key): 如果 Map 对象中存在该元素,则移除它并返回 true; Map.prototype.clear(): 移除 Map 对象的所有键/值对; Map.prototype.keys():返回一个新的 Iterator 对象, 它按插入顺序包含了 Map 对象中每个元素的键; Map.prototype.values():返回一个新的 Iterator 对象,它按插入顺序包含了 Map 对象中每个元素的值; Map.prototype.entries():返回一个新的 Iterator 对象,它按插入顺序包含了 Map 对象中每个元素的 [key, value] 数组; Map.prototype.forEach(callbackFn[, thisArg]):按插入顺序遍历 Map; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 let map = new Map() map.set({ a: 1 }, 'a') map.set({ a: 2 }, 'b') for (let [key, value] of map) { console.log(key, value) } // {a: 1} 'a' // {a: 2} 'b' for (let key of map.keys()) { console.log(key) } // {a: 1} // {a: 2} WeakMap 类似于 Map 的结构,但是键必须是对象的弱引用,注意弱引用的是键名而不是键值,因而 WeakMap 是不能被迭代的; 1 2 3 4 5 let wm = new WeakMap() let foo = { name: 'foo' } wm.set(foo, 'a') // Weak wm.get(foo) // 'a' wm.has(foo) // true 虽然 wm 的键对 foo 对象有引用,但是丝毫不会阻止 foo 对象被 GC 回收。当引用对象 foo 被垃圾回收之后,wm 的 foo 键值对也会自动移除,所以不用手动删除引用。 实例方法: WeakMap.prototype.delete(key):移除 key 的关联对象; WeakMap.prototype.get(key):返回 key 关联对象, 或者 undefined(没有 key 关联对象时); WeakMap.prototype.has(key):根据是否有 key 关联对象返回一个 Boolean 值; WeakMap.prototype.set(key, value):在 WeakMap 中设置一组 key 关联对象,返回这个 WeakMap 对象; Proxy Proxy 用来定义基本操作的的自定义行为,可以理解为当对目标对象 target 进行某个操作之前会先进行拦截(执行 handler 里定义的方法),必须要对 Proxy 实例进行操作才能触发拦截,对目标对象操作是不会拦截的,可以通过如下方式定义一个代理实例 1 2 3 4 5 6 7 8 9 10 11 let proxy = new Proxy(target, handler) let instance = new Proxy( { name: '布兰' }, { get(target, propKey, receiver) { return `hello, ${target.name}` }, } ) instance.name // 'hello, 布兰' 如果 handle 没有设置任何拦截,那么对实例的操作就会转发到目标对象身上: 1 2 3 4 let target = {} let proxy = new Proxy(target, {}) proxy.name = '布兰' target.name // '布兰' 目标对象被 Proxy 代理的时候,内部的 this 会指向代理的实例: 1 2 3 4 5 6 7 8 9 const target = { m: function () { console.log(this === proxy) }, } const handler = {} const proxy = new Proxy(target, handler) target.m() // false proxy.m() // true 静态方法: Proxy.revocable() 用以定义一个可撤销的 Proxy: 1 2 3 4 5 6 7 8 let target = {} let handler = {} let { proxy, revoke } = Proxy.revocable(target, handler) proxy.foo = 123 proxy.foo // 123 revoke() proxy.foo // TypeError handle 对象的方法: get(target, propKey, receiver):拦截对象属性的读取,比如 proxy.foo 和 proxy[‘foo’]。 set(target, propKey, value, receiver):拦截对象属性的设置,比如 proxy.foo = v或proxy['foo'] = v,返回一个布尔值。 has(target, propKey):拦截 propKey in proxy 的操作,返回一个布尔值。 deleteProperty(target, propKey):拦截 delete proxy[propKey] 的操作,返回一个布尔值。 ownKeys(target):拦截 Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in 循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而 Object.keys() 的返回结果仅包括目标对象自身的可遍历属性。 getOwnPropertyDescriptor(target, propKey):拦截Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。 defineProperty(target, propKey, propDesc):拦截 Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。 preventExtensions(target):拦截 Object.preventExtensions(proxy),返回一个布尔值。 getPrototypeOf(target):拦截 Object.getPrototypeOf(proxy),返回一个对象。 isExtensible(target):拦截 Object.isExtensible(proxy),返回一个布尔值。 setPrototypeOf(target, proto):拦截 Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。 apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如proxy(...args)、proxy.call(object, ...args)、proxy.apply(...)。 construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如 new proxy(...args)。 Reflect Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与 proxy handlers 的方法相同。Reflect 不是一个函数对象,因此它是不可构造的。 设计的目的: 将 Object 属于语言内部的方法放到 Reflect 上; 修改某些 Object 方法的返回结果,让其变得更合理; 让 Object 操作变成函数行为; Proxy handles 与 Reflect 方法一一对应,前者用于定义自定义行为,而后者用于恢复默认行为; 静态方法: Reflect.apply(target, thisArgument, argumentsList) 对一个函数进行调用操作,同时可以传入一个数组作为调用参数。和 Function.prototype.apply() 功能类似; Reflect.construct(target, argumentsList[, newTarget]) 对构造函数进行 new 操作,相当于执行 new target(...args); Reflect.defineProperty(target, propertyKey, attributes) 和 Object.defineProperty() 类似。如果设置成功就会返回 true; Reflect.deleteProperty(target, propertyKey) 作为函数的 delete 操作符,相当于执行 delete target[name]; Reflect.get(target, propertyKey[, receiver]) 获取对象身上某个属性的值,类似于 target[name]; Reflect.getOwnPropertyDescriptor(target, propertyKey) 类似于 Object.getOwnPropertyDescriptor()。如果对象中存在该属性,则返回对应的属性描述符, 否则返回 undefined; Reflect.getPrototypeOf(target) 类似于 Object.getPrototypeOf(); Reflect.has(target, propertyKey) 判断一个对象是否存在某个属性,和 in 运算符 的功能完全相同; Reflect.isExtensible(target) 类似于 Object.isExtensible(); Reflect.ownKeys(target) 返回一个包含所有自身属性(不包含继承属性)的数组。(类似于 Object.keys(), 但不会受 enumerable 影响); Reflect.preventExtensions(target) 类似于 Object.preventExtensions()。返回一个 Boolean; Reflect.set(target, propertyKey, value[, receiver]) 将值分配给属性的函数。返回一个 Boolean,如果更新成功,则返回 true; Reflect.setPrototypeOf(target, prototype) 设置对象原型的函数. 返回一个 Boolean, 如果更新成功,则返回 true; Class 可以用 class 关键字来定义一个类,类是对一类具有共同特征的事物的抽象,就比如可以把小狗定义为一个类,小狗有名字会叫也会跳;类是特殊的函数,就像函数定义的时候有函数声明和函数表达式一样,类的定义也有类声明和类表达式,不过类声明不同于函数声明,它是无法提升的;类也有 name 属性 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 类声明 class Dog { constructor(name) { this.name = name } bark() {} jump() {} } Dog.name // 'Dog' // 类表达式:可以命名(类的 name 属性取类名),也可以不命名(类的 name 属性取变量名) let Animal2 = class { // xxx } Animal2.name // 'Animal2' JS 中的类建立在原型的基础上(通过函数来模拟类,其实类就是构造函数的语法糖),和 ES5 中构造函数类似,但是也有区别,比如类的内部方法是不可被迭代的: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class Dog { constructor() {} bark() {} jump() {} } Object.keys(Dog.prototype) // [] // 类似于 function Dog2() {} Dog2.prototype = { constructor() {}, bark() {}, jump() {}, } Object.keys(Dog2.prototype) // ['constructor', 'bark', 'jump'] 基于原型给类添加新方法: 1 2 3 Object.assign(Dog.prototype, { wag() {}, // 摇尾巴 }) 类声明和类表达式的主体都执行在严格模式下。比如,构造函数,静态方法,原型方法,getter和 setter 都在严格模式下执行。 类内部的 this 默认指向类实例,所以如果直接调用原型方法或者静态方法会导致 this 指向运行时的环境,而类内部是严格模式,所以此时的 this 会是 undefined: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Dog { constructor(name) { this.name = name } bark() { console.log(`${this.name} is bark.`) } static jump() { console.log(`${this.name} is jump.`) } } let dog = new Dog('大黄') let { bark } = dog let { jump } = Dog bark() // TypeError: Cannot read property 'name' of undefined jump() // TypeError: Cannot read property 'name' of undefined 方法和关键字: constructor 方法是类的默认方法,通过 new 关键字生成实例的时候,会自动调用;一个类必须有constructor 方法,如果没有显示定义,则会自动添加一个空的;constructor 默认会返回实例对象: 1 2 3 4 5 6 class Point {} // 等同于 class Point { constructor() {} } 通过 get 和 set 关键字拦截某个属性的读写操作: 1 2 3 4 5 6 7 8 class Dog { get age() { return 1 } set age(val) { this.age = val } } 用 static 关键字给类定义静态方法,静态方法不会存在类的原型上,所以不能通过类实例调用,只能通过类名来调用,静态方法和原型方法可以同名: 1 2 3 4 5 6 7 8 9 10 11 12 13 class Dog { bark() {} jump() { console.log('原型方法') } static jump() { console.log('静态方法') } } Object.getOwnPropertyNames(Dog.prototype) // ['constructor', 'bark', 'jump'] Dog.jump() // '静态方法' let dog = new Dog() dog.jump() // '原型方法' 公有字段和私有字段: 静态公有字段和静态方法一样只能通过类名调用;私有属性和私有方法只能在类的内部调用,外部调用将报错: 1 2 3 4 5 6 7 8 9 10 11 12 class Dog { age = 12 // 公有字段 static sex = 'male' // 静态公有字段 #secret = '我是人类的好朋友' // 私有字段 #getSecret() { // 私有方法 return this.#secret } } Dog.sex // 'male' let dog = new Dog() dog.#getSecret() // SyntaxError 公共和私有字段声明是 JavaScript 标准委员会 TC39 提出的实验性功能(第 3 阶段)。浏览器中的支持是有限的,但是可以通过 Babel 等系统构建后使用此功能。 new.target 属性允许你检测函数、构造方法或者类是否是通过 new 运算符被调用的。在通过 new 运算符被初始化的函数或构造方法中,new.target 返回一个指向构造方法或函数的引用。在普通的函数调用中,new.target 的值是 undefined,子类继承父类的时候会返回子类: 1 2 3 4 5 6 7 8 9 10 11 12 class Dog { constructor() { console.log(new.target.name) } } function fn() { if (!new.target) return 'new target is undefined' console.log('fn is called by new') } let dog = new Dog() // 'Dog' fn() // 'new target is undefined' new fn() // 'fn is called by new' 类的继承: 类可以通过 extends 关键字实现继承,如果子类显示的定义了 constructor 则必须在内部调用 super() 方法,内部的 this 指向当前子类: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Animal { constructor(name) { this.name = name } run() { console.log(`${this.name} is running.`) } } class Dog extends Animal { constructor(name) { super(name) // 必须调用 this.name = name } bark() { console.log(`${this.name} is barking.`) } } let dog = new Dog('大黄') dog.run() // '大黄 is running.' 通过 super() 调用父类的构造函数或者通过 super 调用父类的原型方法;另外也可以在子类的静态方法里通过 super 调用父类的静态方法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 基于上面的代码改造 class Dog extends Animal{ constructor(name){ super(name) // 调用父类构造函数 this.name = name } bark() { super.run() // 调用父类原型方法 console.log(`${this.name} is barking.`) } } let dog = new Dog() dog.bark()s // '大黄 is running.' // '大黄 is barking.' 子类的 __proto__ 属性,表示构造函数的继承,总是指向父类;子类 prototype 属性的 __proto__ 属性,表示方法的继承,总是指向父类的 prototype 属性: 1 2 3 4 5 class Animal {} class Dog extends Animal {} Dog.__proto__ === Animal // true Dog.prototype.__proto__ === Animal.prototype // true 子类原型的原型指向父类的原型: 1 2 3 4 // 基于上面的代码 let animal = new Animal() let dog = new Dog() dog.__proto__.__proto__ === animal.__proto__ // true 使用 extends 还可以实现继承原生的构造函数,如下这些构造函数都可以被继承: String() Number() Boolean() Array() Object() Function() Date() RegExp() Error() 1 2 3 4 5 6 7 8 9 10 11 12 13 class MyString extends String { constructor(name) { super(name) this.name = name } welcome() { return `hello ${this.name}` } } let ms = new MyString('布兰') ms.welcome() // 'hello 布兰' ms.length // 2 ms.indexOf('兰') // 1 Module 浏览器传统加载模块方式: 1 2 3 4 5 6 7 8 // 同步加载 <script src="test.js"></script> // defer异步加载:顺序执行,文档解析完成后执行; <script src="test.js" defer></script> // async异步加载:乱序加载,下载完就执行。 <script src="test.js" async></script> 浏览器现在可以按照模块(加上 type="module")来加载脚本,默认将按照 defer 的方式异步加载;ES6 的模块加载依赖于 import 和 export 这 2 个命令;模块内部自动采用严格模式: 1 2 // 模块加载 <script type="module" src="test.js"></script> export 用于输出模块的对外接口,一个模块内只能允许一个 export default 存在,以下是几种输出模块接口的写法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // person.js // 写法一:单独导出 export const name = '布兰' export const age = 12 // 写法二:按需导出 const name = '布兰', age = 12 export { name, age } // 写法三:重命名后导出 const name = '布兰', age = 12 export { name as name1, age as age1 } // 写法四:默认导出 const name = '布兰' export default name import 用于输入其他模块的接口: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 按需导入 import { name, age } './person.js' // 导入后重命名 import { name1 as name, age1 as age } from './person.js' // 默认导入 import person from './person.js' // 整体导入 import * as person from './person.js' // 混合导入 import _, { each } from 'lodash' import 导入的细节: 导入的变量名必须与导出模块的名字一致,可以使用 as 进行重命名; 导入的变量都是只读的,不能改写; import 命令具有提升效果,会提升到整个模块的头部,首先执行; import 是编译时导入,所以不能将其写到代码块(比如 if 判断块里)或者函数内部; import 会执行所加载的模块的代码,如果重复导入同一个模块则只会执行一次模块; import 和 export 的复合写法:export 和 import 语句可以结合在一起写成一行,相当于是在当前模块直接转发外部模块的接口,复合写法也支持用 as 重命名。以下例子中需要在 hub.js 模块中转发 person.js 的接口: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // person.js const name = '布兰', age = 12 export { name, age } // 按需转发接口(中转模块:hub.js) export { name, age } from './person.js' // 相当于 import { name, age } from './person.js' export { name, age } // person.js const name = '布兰', age = 12 export default { name, age } // 转发默认接口(中转模块:hub.js) export { default } from './person.js' // 相当于 import person from './person.js' export default person ES6 模块和 CommonJS 模块的差异: CommonJS 模块输出的是一个值的拷贝(一旦输出一个值,模块内部的变化就影响不到这个值),ES6 模块输出的是值的引用(是动态引用且不会缓存值,模块里的变量绑定其所在的模块,等到脚本真正执行时,再根据这个只读引用到被加载的那个模块里去取值); CommonJS 模块是运行时加载,ES6 模块是编译时输出接口; CommonJS 模块的 require() 是同步加载模块,ES6 模块的 import 命令是异步加载,有一个独立的模块依赖的解析阶段; Iterator 和 for…of Iterator 迭代器协议,为各种数据结构提供了一种统一按照某种顺序进行访问的机制。通常部署在一个可迭代数据结构内部或其原型上。一个对象要能够成为迭代器,它必须有一个 next() 方法,每次执行 next() 方法会返回一个对象,这个对象包含了一个 done 属性(是个布尔值,true 表示可以继续下次迭代)和一个 value 属性(每次迭代的值): 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 生成一个迭代器 let makeIterator = (arr) => { let index = 0 return { next() { return index < arr.length ? { value: arr[index++], done: false, } : { done: true } }, } } iterable 可迭代数据结构:内部或者原型上必须有一个 Symbol.iterator 属性(如果是异步的则是 Symbol.asyncIterator),这个属性是一个函数,执行后会生成一个迭代器: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 let obj = { [Symbol.iterator]() { return { index: 0, next() { if (this.index < 3) { return { value: this.index++, done: false } } else { return { done: true } } }, } }, } for (let x of obj) { console.log(x) // 0 1 2 } 内置的一些可迭代数据结构有:String、Array、TypedArray、Map 和 Set、arguments、NodeList: 1 2 3 4 5 let si = 'hi'[Symbol.iterator]() si // StringIterator si.next() // {value: 'h', done: false} si.next() // {value: 'i', done: false} si.next() // {value: undefined, done: true} for...of:用于遍历可迭代数据结构: 遍历字符串:for...in 获取索引,for...of 获取值; 遍历数组:for...in 获取索引,for...of 获取值; 遍历对象:for...in 获取键,for...of 需自行部署 [Symbol.iterator] 接口; 遍历 Set:for...of 获取值, for (const v of set); 遍历 Map:for...of 获取键值对,for (const [k, v] of map); 遍历类数组:包含 length 的对象、arguments 对象、NodeList对象(无 Iterator 接口的类数组可用 Array.from() 转换); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 迭代字符串 for (let x of 'abc') { console.log(x) // 'a' 'b' 'c' } // 迭代数组 for (let x of ['a', 'b', 'c']) { console.log(x) // 'a' 'b' 'c' } // 遍历 Set let set = new Set(['a', 'b', 'c']) for (let x of set) { console.log(x) // 'a' 'b' 'c' } // 遍历 Map let map = new Map([ ['name', '布兰'], ['age', 12], ]) for (let [key, value] of map) { console.log(key + ': ' + value) // 'name: 布兰' 'age: 12' } for...of 和 for...in 对比 共同点:能够通过 break、continue 和 return 跳出循环; 不同点: for...in 的特点:只能遍历键,会遍历原型上属性,遍历无顺序,适合于对象的遍历; for...of 的特点:能够遍历值(某些数据结构能遍历键和值,比如 Map),不会遍历原型上的键值,遍历顺序为数据的添加顺序,适用于遍历可迭代数据结构; Promise Promise 这块知识可以直接看我之前写的一篇文章:深入理解 Promise 非常完整。 Generator function* 会定义一个生成器函数,调用生成器函数不会立即执行,而是会返回一个 Generator 对象,这个对象是符合可迭代协议和迭代器协议的,换句话说这个 Generator 是可以被迭代的。 生成器函数内部通过 yield 来控制暂停,而 next() 将把它恢复执行,它的运行逻辑是如下这样的: 遇到 yield 表达式,就暂停执行后面的操作,并将紧跟在 yield 后面的那个表达式的值作为返回的对象的 value 属性值; 下一次调用 next 方法时,再继续往下执行,直到遇到下一个 yield 表达式; 如果没有再遇到新的 yield 表达式,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值; 如果该函数没有 return 语句,则返回的对象的 value 属性值为 undefined; 1 2 3 4 5 6 7 8 9 function* gen() { yield 'hello' yield 'world' return 'end' } let g = gen() g.next() // {value: 'hello', done: false} g.next() // {value: 'world', done: false} g.next() // {value: 'end', done: true} 在生成器函数内部可以使用 yield* 表达式委托给另一个 Generator 或可迭代对象,比如数组、字符串等;yield* 表达式本身的值是当迭代器关闭时返回的值(即 done 为 true 时): 1 2 3 4 5 6 7 8 9 10 11 12 function* inner() { yield* [1, 2] return 'foo' } function* gen() { let result = yield* inner() console.log(result) } let g = gen() g.next() g.next() g.next() 实例方法: Generator.prototype.next():返回一个包含属性 done 和 value 的对象。该方法也可以通过接受一个参数用以向生成器传值。如果传入了参数,那么这个参数会传给上一条执行的 yield 语句左边的变量: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function* f() { let a = yield 12 console.log(a) let b = yield a console.log(b) } let g = f() console.log(g.next('a')) console.log(g.next('b')) console.log(g.next('c')) // {value: 12, done: false} // 'b' // {value: 'b', done: false} // 'c' // {value: undefined, done: true} Generator.prototype.throw():用来向生成器抛出异常,如果内部捕获了则会恢复生成器的执行(即执行下一条 yield 表达式),并且返回带有 done 及 value 两个属性的对象: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function* gen() { try { yield 'a' } catch(e) { console.log(e) } yiele 'b' yield 'c' } let g = gen() g.next() g.throw('error a') g.next() // {value: "a", done: false} // 'error a' // {value: "b", done: false} // {value: "c", done: false} 如果内部没有捕获异常,将中断内部代码的继续执行(类似 throw 抛出的异常,如果没有捕获,则后面的代码将不会执行),此时异常会抛到外部,可以被外部的 try...catch 块捕获,此时如果再执行一次 next(),会返回一个值为 done 属性为 true 的对象: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function* gen() { yield 'a' yield 'b' yield 'c' } let g = gen() g.next() try { g.throw('error a') } catch (e) { console.log(e) } g.next() // {value: "a", done: false} // 'error a' // {value: undefined, done: true} Generator.prototype.return():返回给定的值并结束生成器: 1 2 3 4 5 6 7 8 9 function* gen() { yield 1 yield 2 yield 3 } let g = gen() g.next() // { value: 1, done: false } g.return('foo') // { value: "foo", done: true } g.next() // { value: undefined, done: true } 应用: 将异步操作同步化,比如同时有多个请求,多个请求之间是有顺序的,只能等前面的请求完成了才请求后面的: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function* main() { let res1 = yield request('a') console.log(res1) let res2 = yield request('b') console.log(res2) let res3 = yield request('c') console.log(res3) } function request(url) { setTimeout(function () { // 模拟异步请求 it.next(url) }, 300) } let it = main() it.next() // 'a' 'b' 'c' 给对象部署 Iterator 接口: 1 2 3 4 5 6 7 8 9 10 11 12 13 function* iterEntries(obj) { let keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { let key = keys[i] yield [key, obj[key]] } } let obj = { foo: 3, bar: 7 } for (let [key, value] of iterEntries(myObj)) { console.log(key, value) } // 'foo' 3 // 'bar' 7 ES2016 Array.prototype.includes 判断一个数组是否包含某个元素,之前一般是这么做的: 1 2 3 4 5 6 if (arr.indexOf(el) >= 0) { } // 或者 if (~arr.indexOf(el)) { } 而现在你可以这么做了: 1 2 if (arr.includes(el)) { } indexOf 会返回找到元素在数组中的索引位置,判断的逻辑是是否严格相等,所以他在遇到 NaN 的时候不能正确返回索引,但是 includes 解决了这个问题: 1 2 3 ;[1, NaN, 3] .indexOf(NaN) // -1 [(1, NaN, 3)].includes(NaN) // true 求幂运算符(**) x ** y 是求 x 的 y 次幂,和 Math.pow(x, y) 功能一致: 1 2 3 // x ** y let squared = 2 ** 2 // 2 * 2 = 4 let cubed = 2 ** 3 // 2 * 2 * 2 = 8 x **= y 表示求 x 的 y 次幂,并且把结果赋值给 x: 1 2 3 // x **= y let x = 2 x **= 3 // x 最后等于 8 ES2017 Object.values() 返回一个由对象自身所有可遍历属性的属性值组成的数组: 1 2 3 4 5 6 7 8 9 const person = { name: '布兰' } Object.defineProperty(person, 'age', { value: 12, enumrable: false, // age 属性将不可遍历 }) console.log(Object.values(person)) // ['布兰'] // 类似 str.split('') 效果 console.log(Object.values('abc')) // ['a', 'b', 'c'] Object.entries() 返回一个由对象自身所有可遍历属性的键值对组成的数组: 1 2 const person = { name: '布兰', age: 12 } console.log(Object.entries(person)) // [["name", "布兰"], ["age", 12]] 利用这个方法可以很好的将对象转成正在的 Map 结构: 1 2 3 const person = { name: '布兰', age: 12 } const map = new Map(Object.entries(person)) console.log(map) // Map { name: '布兰', age: 12 } Object.getOwnPropertyDescriptors() Object.getOwnPropertyDescriptor() 会返回指定对象某个自身属性的的描述对象,而 Object.getOwnPropertyDescriptors() 则是返回指定对象自身所有属性的描述对象: 1 2 3 4 5 6 7 8 9 10 const person = { name: '布兰', age: 12 } console.log(Object.getOwnPropertyDescriptor(person, 'name')) // { configurable: true, enumerable: true, value: "布兰", writable: true } console.log(Object.getOwnPropertyDescriptors(person)) //{ // name: { configurable: true, enumerable: true, value: "布兰", writable: true }, // age: {configurable: false, enumerable: false, value: 12, writable: false} //} 配合 Object.create() 可以实现浅克隆: 1 2 3 4 5 const shallowClone = (obj) => Object.create( Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj) ) String.prototype.padStart() str.padStart(length [, padStr]) 会返回一个新字符串,该字符串将从 str 字符串的左侧开始填充某个字符串 padStr(非必填,如果不是字符串则会转成字符串, 传入 undefined 和不传这个参数效果一致)直到达到指定位数 length 为止: 1 2 3 4 5 'abc'.padStart(5, 2) // '22abc' 'abc'.padStart(5, undefined) // ' abc' 'abc'.padStart(5, {}) // '[oabc' 'abc'.padStart(5) // ' abc' 'abcde'.padStart(2, 'f') // 'abcde' String.prototype.padEnd() 规则和 padStart 类似,但是是从字符串右侧开始填充: 1 'abc'.padEnd(5, 2) // 'abc22' 函数参数尾逗号 允许函数在定义和调用的时候时候最后一个参数后加上逗号: 1 2 3 function init(param1, param2) {} init('a', 'b') Async 函数 使用 async 可以声明一个 async 函数,结合 await 可以用一种很简介的方法写成基于 Promise 的异步行为,而不需要刻意的链式调用。await 表达式会暂停整个 async 函数的执行进程并出让其控制权,只有当其等待的基于 Promise 的异步操作被兑现或被拒绝之后才会恢复进程。async 函数有如下几种定义形式: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 函数声明 async function foo() {} // 函数表达式 let foo = async function() {} // 箭头函数 let foo = async () => {} // 对象方法 lef obj = { async foo() {} } // 类方法 class Dog { async bark() {} } async 函数一定会返回一个 Promise 对象,所以它可以使用 then 添加处理函数。如果一个 async 函数的返回值看起来不是Promise,那么它将会被隐式地包装在一个 Promise 中: 1 2 3 4 5 6 async function foo() { return 'a' } foo().then((res) => { console.log(res) // 'a' }) 内部如果发生错误,或者显示抛出错误,那么 async 函数会返回一个 rejected 状态的 Promsie: 1 2 3 4 5 6 async function foo() { throw new Error('error') } foo().catch((err) => { console.log(err) // Error: error }) 返回的 Promise 对象必须等到内部所有 await 命令 Promise 对象执行完才会发生状态改变,除非遇到 return 语句或抛出错误;任何一个 await 命令返回的 Promise 对象变 为rejected 状态,整个 Async 函数都会中断后续执行: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 async function fn() { let a = await Promise.resolve('success') console.log('a_' + a) let b = await Promise.reject('fail') console.log('b_' + b) // 不会执行 } fn().then( (res) => { console.log(res) // 不会执行 }, (err) => { console.log(err) } ) // 'a_success' // 'fail' 所以为了保证 async 里的异步操作都能完成,我们需要将他们放到 try...catch() 块里或者在 await 返回的 Promise 后跟一个 catch 处理函数: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 async function fn() { try { let a = await Promise.reject('a fail') console.log('a_' + a) // 不会执行 } catch (e) { console.log(e) // 'a fail' } let b = await Promise.reject('b fail').catch((e) => { console.log(e) // 'b fail' }) console.log('b_' + b) // 'bundefined' } fn().then( (res) => { console.log(res) // undefined }, (err) => { console.log(err) // 不会执行 } ) 如果 async 函数里的多个异步操作之间没有依赖关系,建议将他们写到一起减少执行时间: 1 2 3 4 5 6 7 8 // 写法一 let [foo, bar] = await Promise.all([getFoo(), getBar()]) // 写法二 let fooPromise = getFoo() let barPromise = getBar() let foo = await fooPromise let bar = await barPromise await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。 共享内存和 Atomics 对象 SharedArrayBuffer Atomics ES2018 Promise.prototype.finally() Promise.prototype.finally() 用于给 Promise 对象添加 onFinally 函数,这个函数主要是做一些清理的工作,只有状态变化的时候才会执行该 onFinally 函数。 1 2 3 4 function onFinally() { console.log(888) // 并不会执行 } new Promise((resolve, reject) => {}).finally(onFinally) finally() 会生成一个 Promise 新实例,finally 一般会原样后传父 Promise,无论父级实例是什么状态: 1 2 3 4 5 6 7 8 9 let p1 = new Promise(() => {}) let p2 = p1.finally(() => {}) setTimeout(console.log, 0, p2) // Promise {<pending>} let p3 = new Promise((resolve, reject) => { resolve(3) }) let p4 = p3.finally(() => {}) setTimeout(console.log, 0, p3) // Promise {<fulfilled>: 3} 上面说的是一般,但是也有特殊情况,比如 finally 里返回了一个非 fulfilled 的 Promise 或者抛出了异常的时候,则会返回对应状态的新实例: 1 2 3 4 5 6 7 8 9 10 11 12 13 let p1 = new Promise((resolve, reject) => { resolve(3) }) let p2 = p1.finally(() => new Promise(() => {})) setTimeout(console.log, 0, p2) // Promise {<pending>} let p3 = p1.finally(() => Promise.reject(6)) setTimeout(console.log, 0, p3) // Promise {<rejected>: 6} let p4 = p1.finally(() => { throw new Error('error') }) setTimeout(console.log, 0, p4) // Promise {<rejected>: Error: error} 参考:深入理解 Promise 异步迭代器 想要了解异步迭代器最好的方式就是和同步迭代器进行对比。我们知道可迭代数据的内部都是有一个 Symbol.iterator 属性,它是一个函数,执行后会返回一个迭代器对象,这个迭代器对象有一个 next() 方法可以对数据进行迭代,next() 执行后会返回一个对象,包含了当前迭代值 value 和 标识是否完成迭代的 done 属性: 1 2 3 4 let iterator = [1, 2][Symbol.iterator]() iterator.next() // { value: 1, done: false } iterator.next() // { value: 2, done: false } iterator.next() // { value: undefinde, done: true } 上面这里的 next() 执行的是同步操作,所以这个是同步迭代器,但是如果 next() 里需要执行异步操作,那就需要异步迭代了,可异步迭代数据的内部有一个 Symbol.asyncIterator 属性,基于此我们来实现一个异步迭代器: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Emitter { constructor(iterable) { this.data = iterable } [Symbol.asyncIterator]() { let length = this.data.length, index = 0 return { next: () => { const done = index >= length const value = !done ? this.data[index++] : undefined return new Promise((resolve, reject) => { resolve({ value, done }) }) }, } } } 异步迭代器的 next() 会进行异步的操作,通常是返回一个 Promise,所以需要对应的处理函数去处理结果: 1 2 3 4 5 6 7 8 9 10 11 let emitter = new Emitter([1, 2, 3]) let asyncIterator = emitter[Symbol.asyncIterator]() asyncIterator.next().then((res) => { console.log(res) // { value: 1, done: false } }) asyncIterator.next().then((res) => { console.log(res) // { value: 2, done: false } }) asyncIterator.next().then((res) => { console.log(res) // { value: 3, done: false } }) 另外也可以使用 for await...of 来迭代异步可迭代数据: 1 2 3 4 5 6 7 8 let asyncIterable = new Emitter([1, 2, 3]) async function asyncCount() { for await (const x of asyncIterable) { console.log(x) } } asyncCount() // 1 2 3 另外还可以通过异步生成器来创建异步迭代器: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Emitter { constructor(iterable) { this.data = iterable } async *[Symbol.asyncIterator]() { let length = this.data.length, index = 0 while (index < length) { yield this.data[index++] } } } async function asyncCount() { let emitter = new Emitter([1, 2, 3]) const asyncIterable = emitter[Symbol.asyncIterator]() for await (const x of asyncIterable) { console.log(x) } } asyncCount() // 1 2 3 参考: Iteration_protocols for-await…of s 修饰符(dotAll 模式) 正则表达式新增了一个 s 修饰符,使得 . 可以匹配任意单个字符: 1 2 3 ;/foo.bar/.test('foo\nbar') / // false foo.bar / s.test('foo\nbar') // true 上面这又被称为 dotAll 模式,表示点(dot)代表一切字符。所以,正则表达式还引入了一个 dotAll 属性,返回一个布尔值,表示该正则表达式是否处在 dotAll 模式: 1 ;/foo.bar/s.dotAll // true 具名组匹配 正则表达式可以使用捕获组来匹配字符串,但是想要获取某个组的结果只能通过对应的索引来获取: 1 2 3 4 5 6 let re = /(\d{4})-(\d{2})-(\d{2})/ let result = re.exec('2015-01-02') // result[0] === '2015-01-02' // result[1] === '2015' // result[2] === '01' // result[3] === '02' 而现在我们可以通过给捕获组 (?<name>...) 加上名字 name ,通过名字来获取对应组的结果: 1 2 3 4 5 let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/ let result = re.exec('2015-01-02') // result.groups.year === '2015' // result.groups.month === '01' // result.groups.day === '02' 配合解构赋值可以写出非常精简的代码: 1 2 3 4 let { groups: { year, month, day }, } = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/.exec('2015-01-02') console.log(year, month, day) // 2015 01 02 具名组也可以通过传递给 String.prototype.replace 的替换值中进行引用。如果该值为字符串,则可以使用 $<name> 获取到对应组的结果: 1 2 3 let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/ let result = '2015-01-02'.replace(re, '$<day>/$<month>/$<year>') // result === '02/01/2015' 参考:proposal-regexp-named-groups 后行断言 后行断言: (?<=y)x,x 只有在 y 后面才能匹配: 1 ;/(?<=\$)\d+/.exec('I have $100.') // ['100'] 后行否定断言: (?<!y)x,x 只有不在 y 后面才能匹配: 1 ;/(?<!\$)\d+/.exec('I have $100.') // ['00'] Unicode 属性转义 允许正则表达式匹配符合 Unicode 某种属性的所有字符,\p{...} 是匹配包含,\P{...} 是匹配不包含的字符,且必须搭配 /u 修饰符才会生效: 1 2 /\p{Emoji}+/u.exec('😁😭笑死我了🤣😂不行了') // ['😁😭'] /\P{Emoji}+/u.exec('😁😭笑死我了🤣😂不行了') // ['笑死我了'] 这里可以查询到更多的 Unicode 的属性 Full_Properties 对象扩展运算符 对象的扩展运算符可以用到解构赋值上,且只能应用到最后一个变量上: 1 2 let { x, ...y } = { x: 1, a: 2, b: 3 } console.log(y) // {a: 2, b: 3} 对象扩展运算符不能解构原型上的属性: 1 2 3 4 let obj = { x: 1 } obj.__proto__ = { y: 2 } let { ...a } = obj console.log(a.y) // undefined 应用一:可以实现浅拷贝,但是不会拷贝原始属性: 1 2 3 4 5 6 7 8 9 10 11 12 let person = Object.create({ name: '布兰' }) person.age = 12 // 浅拷贝写法一 let { ...pClone1 } = person console.log(pClone1) // { age: 12 } console.log(pClone1.name) // undefined // 浅拷贝写法二 let pClone2 = { ...person } console.log(pClone2) // { age: 12 } console.log(pClone2.name) // undefined 应用二:合并两个对象: 1 2 3 4 let ab = { ...a, ...b } // 等同于 let ab = Object.assign({}, a, b) 应用三:重写对象属性 1 let aWithOverrides = { ...a, x: 1, y: 2 } 应用四:给新对象设置默认值 1 let aWithDefaults = { x: 1, y: 2, ...a } 应用五:利用扩展运算符的解构赋值可以扩展函数参数: 1 2 3 4 5 6 function baseFunction({ a, b }) {} function wrapperFunction({ x, y, ...restConfig }) { // 使用 x 和 y 参数进行操作 // 其余参数传给原始函数 return baseFunction(restConfig) } 参考: Object Spread Initializer Object Rest Destructuring 放松对标签模板里字符串转义的限制 ECMAScript 6 入门 ES2019 允许省略 catch 里的参数 异常被捕获的时候如果不需要做操作,甚至可以省略 catch(err) 里的参数和圆括号: 1 2 try { } catch {} JSON.stringify()变动 UTF-8 标准规定,0xD800 到 0xDFFF 之间的码点,不能单独使用,必须配对使用。 所以 JSON.stringify() 对单个码点进行操作,如果码点符合 UTF-8 标准,则会返回对应的字符,否则会返回对应的码点: 1 2 JSON.stringify('\u{1f600}') // ""😀"" JSON.stringify('\u{D834}') // ""\ud834"" Symbol.prototype.description Symbol 实例新增了一个描述属性 description: 1 2 let symbol = Symbol('foo') symbol.description // 'foo' Function.prototype.toString() 函数的 toString() 会原样输出函数定义时候的样子,不会省略注释和空格。 Object.fromEntries() Object.fromEntries() 方法是 Object.entries() 的逆操作,用于将一个键值对数组转为对象: 1 2 3 let person = { name: '布兰', age: 12 } let keyValueArr = Object.entries(person) // [['name', '布兰'], ['age', 12]] let obj = Object.fromEntries(arr) // { name: '布兰', age: 12 } 常用可迭代数据结构之间的装换: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 let person = { name: '布兰', age: 12 } // 对象 -> 键值对数组 let keyValueArr = Object.entries(person) // [['name', '布兰'], ['age', 12]] // 键值对数组 -> Map let map = new Map(keyValueArr) // Map {"name": "布兰", "age": 12} // Map -> 键值对数组 let arr = Array.from(map) // [['name', '布兰'], ['age', 12]] // 键值对数组 -> 对象 let obj = Array.from(arr).reduce( (acc, [key, val]) => Object.assign(acc, { [key]: val }), {} ) // { name: '布兰', age: 12 } 参考:Object.fromEntries 字符串可直接输入行分隔符和段分隔符 JavaScript 规定有 5 个字符,不能在字符串里面直接使用,只能使用转义形式。 U+005C:反斜杠(reverse solidus) U+000D:回车(carriage return) U+2028:行分隔符(line separator) U+2029:段分隔符(paragraph separator) U+000A:换行符(line feed) 但是由于 JSON 允许字符串里可以使用 U+2028 和 U+2029,所以使得 JSON.parse() 去解析字符串的时候可能会报错,所以 ES2019 允许模板字符串里可以直接这两个字符: 1 2 3 JSON.parse('"\u2028"') // "" JSON.parse('"\u2029"') // "" JSON.parse('"\u005C"') // SyntaxError String.prototype.trimStart 消除字符串头部空格,返回一个新字符串;浏览器还额外增加了它的别名函数 trimLeft(): 1 2 3 4 let str = ' hello world ' let newStr = str.trimStart() console.log(newStr, newStr === str) // 'hello world ' false String.prototype.trimEnd 消除字符串尾部空格,返回一个新字符串;浏览器还额外增加了它的别名函数 trimRight(): 1 2 3 4 let str = ' hello world ' let newStr = str.trimEnd() console.log(newStr, newStr === str) // ' hello world' false Array.prototype.flat() arr.flat(depth) 按照 depth (不传值的话默认是 1)深度拍平一个数组,并且将结果以新数组形式返回: 1 2 3 4 5 6 7 8 // depth 默认是 1 const arr1 = [1, 2, [3, 4]] console.log(arr1.flat()) // [1, 2, 3, 4] // 使用 Infinity,可展开任意深度的嵌套数组;自动跳过空数组; const arr2 = [1, , [2, [3, [4]]]] console.log(arr2.flat(Infinity)) // [1, 2, 3, 4] 用 reduce 实现拍平一层数组: 1 2 3 4 5 6 7 8 const arr = [1, 2, [3, 4]] // 方法一 let newStr = arr.reduce((acc, cur) => acc.concat(cur), []) // 方法二 const flattened = (arr) => [].concat(...arr) flattened(arr) 参考:flat Array.prototype.flatMap() flatMap(callback) 使用映射函数 callback 映射每个元素,callback 每次的返回值组成一个数组,并且将这个数组执行类似 arr.flat(1) 的操作进行拍平一层后最后返回结果: 1 2 3 4 const arr1 = [1, 2, 3, 4] arr1.flatMap((x) => [x * 2]) // 将 [[2], [4], [6], [8]] 数组拍平一层得到最终结果:[2, 4, 6, 8] 参考:flatMap ES2020 String.prototype.matchAll() String.prototype.matchAll() 方法,可以一次性取出所有匹配。不过,它返回的是一个 RegExpStringIterator 迭代器同是也是一个可迭代的数据结构,所以可以通过 for...of 进行迭代: 1 2 3 4 5 6 7 8 let str = 'test1test2' let regexp = /t(e)(st(\d?))/g let iterable = str.matchAll(regexp) for (const x of iterable) { console.log(x) } // ['test1', 'e', 'st1', '1', index: 0, input: 'test1test1', groups: undefined] // ['test2', 'e', 'st2', '2', index: 5, input: 'test1test2', groups: undefined] 注意当使用 matchAll(regexp) 的时候,正则表达式必须加上 /g 修饰符。 也可以将这个可迭代数据转成数组形式: 1 2 3 4 5 // 方法一 ;[...str.matchAll(regexp)] // 方法二 Array.from(str.matchAll(regexp)) 动态 import() 标准用法的 import 导入的模块是静态的,会使所有被导入的模块,在加载时就被编译(无法做到按需编译,降低首页加载速度)。有些场景中,你可能希望根据条件导入模块或者按需导入模块,这时你可以使用动态导入代替静态导入。 比如按需加载一个模块可以这样: 1 2 3 if (xxx) { import('./module.js') } import() 是异步导入的,结果会返回一个 Promise: 1 2 3 import('/module.js').then((module) => { // Do something with the module. }) 动态 import() 的应用场景挺多的,比如 Vue 中的路由懒加载就是使用的动态导入组件。另外由于动态性不便于静态分析工具和 tree-shaking 工作,所以不能滥用。 BigInt BigInt 是一种内置对象,它提供了一种方法来表示大于 $2^{53}$ - 1 的整数。这原本是 Javascript 中可以用 Number 表示的最大数字。BigInt 可以表示任意大的整数。 为了区分 Number,定义一个 BigInt 需要在整数后面加上一个 n,或者用函数直接定义: 1 2 const num1 = 10n const num2 = BigInt(20) Number 和 BigInt 之间能进行比较,但他们之间是宽松相等;且由于他们表示的是不同类型的数字,所以不能直接进行四则运算: 1 2 3 4 5 10n == 10 // true 10n === 10 // false 10n > 8 // true 10 + Number(10n) // 20 10 + 10n // TypeError Promise.allSettled Promise.allSettled(iterable) 当所有的实例都已经 settled,即状态变化过了,那么将返回一个新实例,该新实例的内部值是由所有实例的值和状态组合成的数组,数组的每项是由每个实例的状态和内部值组成的对象。 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 function init() { return 3 } let p1 = Promise.allSettled([ new Promise((resolve, reject) => { resolve(9) }).then((res) => {}), new Promise((resolve, reject) => { reject(6) }), init(), ]) let p2 = p1.then( (res) => { console.log(res) }, (err) => { console.log(err) } ) // [ // {status: "fulfilled", value: undefined}, // {status: "rejected", reason: 6}, // {status: "fulfilled", value: 3} // ] 只要所有实例中包含一个 pending 状态的实例,那么 Promise.allSettled() 的结果为返回一个这样 Promise {<pending>} 的实例。 globalThis 在以前,从不同的 JavaScript 环境中获取全局对象需要不同的语句。在 Web 中,可以通过 window、self 或者 frames 取到全局对象,但是在 Web Workers 中,只有 self 可以。在 Node.js 中,它们都无法获取,必须使用 global。 而现在只需要使用 globalThis 即可获取到顶层对象,而不用担心环境问题。 1 2 // 在浏览器中 globalThis === window // true import.meta import.meta 是一个给 JavaScript 模块暴露特定上下文的元数据属性的对象。它包含了这个模块的信息,比如说这个模块的 URL,import.meta 必须在一个模块里使用: 1 2 3 4 5 6 // 没有声明 type="module",就使用 import.meta 会报错 ;<script type="module" src="./js/module.js"></script> // 在module.js里 console.log(import.meta) // {url: "http://localhost/3ag/js/module.js"} 如果需要在配置了 Webpack 的项目,比如 Vue 里使用 import.meta 需要加一个包且配置一下参数,否则项目编译阶段会报错。 包配置详情参考:@open-wc/webpack-import-meta-loader 比如我用的是 4.x 版本的 vue-cli,那我需要在 vue.config.js 里配置: 1 2 3 4 5 6 7 8 9 10 module.exports = { chainWebpack: (config) => { config.module .rule('js') .test(/\.js$/) .use('@open-wc/webpack-import-meta-loader') .loader('@open-wc/webpack-import-meta-loader') .end() }, } 可选链操作符(?.) 通常我们获取一个深层对象的属性会需要写很多判断或者使用逻辑与 && 操作符,因为对象的某个属性如果为 null 或者 undefined 就有可能报错: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 let obj = { first: { second: '布兰', }, } // 写法一 let name1 = '' if (obj) { if (obj.first) { name1 = obj.first.second } } // 写法二 let name2 = obj && obj.first && obj.first.second ?. 操作符允许读取位于连接对象链深处的属性的值,而不必明确验证链中的每个引用是否有效。如果某个属性为 null 或者 undefined 则结果直接为 undefined。有了可选链操作符就可以使得表达式更加简明了,对于上面例子用可选链操作符可以这么写: 1 let name3 = obj?.first?.second 空值合并操作符(??) 对于逻辑或 || 运算符,当对运算符左侧的操作数进行装换为 Boolean 值的时候,如果为 true,则取左边的操作数为结果,否则取右边的操作数为结果: 1 2 let name = '' || '布兰' console.log(name) // '布兰' 我们都知道 ''、0、null、undefined、false、NaN 等转成 Boolean 值的时候都是 false,所以都会取右边的操作数。这个时候如果要给变量设置默认值,如果遇到本身值就可能是 '' 或 0 的情况那就会出错了,会被错误的设置为默认值了。 而 ?? 操作符就是为了解决这个问题而出现的,x ?? y 只有左侧的操作数为 null 或 undefined 的时候才取右侧操作数,否则取左侧操作数: 1 2 let num = 0 ?? 1 console.log(num) // 0 ES2021 如下这几个提案已经确定了会在 2021 年发布,所以把他们归到 ES2021 中。 String.prototype.replaceAll 之前需要替换一个字符串里的全部匹配字符可以这样做: 1 2 3 4 5 6 7 const queryString = 'q=query+string+parameters' // 方法一 const withSpaces1 = queryString.replace(/\+/g, ' ') // 方法二 const withSpaces2 = queryString.split('+').join(' ') 而现在只需要这么做: 1 const withSpace3 = queryString.replaceAll('+', ' ') replaceAll 的第一个参数可以是字符串也可以是正则表达式,当是正则表达式的时候,必须加上全局修饰符 /g,否则报错。 参考:string-replaceall Promise.any() Promsie.any() 和 Promise.all() 一样接受一个可迭代的对象,然后依据不同的入参会返回不同的新实例: 传一个空的可迭代对象或者可迭代对象所有 Promise 都是 rejected 状态的,则会抛出一个 AggregateError 类型的错误,同时返回一个 rejected 状态的新实例: 1 2 3 4 let p1 = Promise.any([]) let p2.catch(err => {}) setTimeout(console.log, 0, p1) // Promise {<rejected>: AggregateError: All promises were rejected} 只要可迭代对象里包含任何一个 fulfilled 状态的 Promise,则会返回第一个 fulfilled 的实例,并且以它的值作为新实例的值: 1 2 3 4 5 6 7 8 let p = Promise.any([ 1, Promise.reject(2), new Promise((resolve, reject) => {}), Promise.resolve(3), ]) setTimeout(console.log, 0, p) // Promise {<fulfilled>: 1} 其他情况下,都会返回一个 pending 状态的实例: 1 2 3 4 5 6 7 let p = Promise.any([ Promise.reject(2), Promise.reject(3), new Promise((resolve, reject) => {}), ]) setTimeout(console.log, 0, p) // Promise {<pending>: undefined} WeakRef 我们知道一个普通的引用(默认是强引用)会将与之对应的对象保存在内存中。只有当该对象没有任何的强引用时,JavaScript 引擎 GC 才会销毁该对象并且回收该对象所占的内存空间。 WeakRef 对象允许你保留对另一个对象的弱引用,而不会阻止被弱引用的对象被 GC 回收。WeakRef 的实例方法 deref() 可以返回当前实例的 WeakRef 对象所绑定的 target 对象,如果该 target 对象已被 GC 回收则返回 undefined: 1 2 3 4 let person = { name: '布兰', age: 12 } let wr = new WeakRef(person) console.log(wr.deref()) // { name: '布兰', age: 12 } 正确使用 WeakRef 对象需要仔细的考虑,最好尽量避免使用。这里面有诸多原因,比如:GC 在一个 JavaScript 引擎中的行为有可能在另一个 JavaScript 引擎中的行为大相径庭,或者甚至在同一类引擎,不同版本中 GC 的行为都有可能有较大的差距。GC 目前还是 JavaScript 引擎实现者不断改进和改进解决方案的一个难题。 参考: WeakRef 内存管理 逻辑赋值符 逻辑赋值符包含 3 个: x &&= y:逻辑与赋值符,相当于 x && (x = y) x ||= y:逻辑或赋值符,相当于 x || (x = y) x ??= y:逻辑空赋值符,相当于 x ?? (x = y) 看如下示例,加深理解: 1 2 3 4 5 6 7 8 9 let x = 0 x &&= 1 // x: 0 x ||= 1 // x: 1 x ??= 2 // x: 1 let y = 1 y &&= 0 // y: 0 y ||= null // y: null y ??= 2 // y: 2 数值分隔符(_) 对于下面一串数字,你一眼看上去不确定它到底是多少吧? 1 const num = 1000000000 那现在呢?是不是可以很清楚的看出来它是 10 亿: 1 const num = 1_000_000_000 数值分隔符(_)的作用就是为了让数值的可读性更强。除了能用于十进制,还可以用于二级制,十六进制甚至是 BigInt 类型: 1 2 3 let binarary = 0b1010_0001_1000_0101 let hex = 0xa0_b0_c0 let budget = 1_000_000_000_000n 使用时必须注意 _ 的两边必须要有类型的数值,否则会报错,以下这些都是无效的写法: 1 2 3 4 let num = 10_ let binarary = 0b1011_ let hex = 0x_0A0B let budget = 1_n 参考文章 ECMAScript6 入门 1.5 万字概括 ES6 全部特性(已更新 ES2020) 近一万字的 ES6 语法知识点补充 深入理解 Promise for-await…of Iteration_protocols Object.fromEntries WeakRef 内存管理 jshistory-cn sameValueZero

2020/11/17
articleCard.readMore