Element组件Tree实现高亮定位

业务需求

在再编辑回显时,需要高亮上次选中的值。当服务组(业务数据只有两层)小于十个时需要全部展开,且需要自动定位至用户可视区域(避免用户需要自己手动滑动寻找)。

实现

tree 配置

1
2
3
4
5
6
7
8
9
10
11
12
<el-tree
class="filter-tree"
node-key="id"
:data="serviceList"
:props="defaultProps"
:default-expand-all="isExpandAll"
:filter-node-method="filterNode"
@current-change="currentChange"
highlight-current
ref="tree"
>
</el-tree>
  • data便是数据源
  • filter-node-method实现关键词搜索(需求需要,实现高亮可以不配)
  • node-key="id"表示使用每个节点的”id”对应的值来表示每个节点(唯一的,使用 setCurrentKey 方法必须设置)
  • highlight-current"表示高亮选中的节点
  • props设置为默认格式
  • default-expand-all表示默认展开所有节点(需求需要动态判断服务组数(计算属性),实现高亮直接给 true 就行)
  • ref="tree"指代这颗树名为 tree

实现高亮

1
2
3
4
5
6
7
8
9
// 高亮选中节点
this.$nextTick(() => {
const eventTree: any = this.$refs.tree;
// 需要高亮节点的id
eventTree.setCurrentKey(this.processServiceData.eventId);

// 实现自动定位(必须要在高亮设置后在滚动,无效)
this.scrollToCurrentNode();
});

注意:需要数据渲染后且 DOM 更新后才能设置成功,使用$nextTick

实现定位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 修改scroll位置
private async scrollToCurrentNode() {
// 下一个DOM修改时机
await this.$nextTick()
//父组件
const elDialog: any = this.$parent
const elTreeOffsetElBody = 80
const tree: any = this.$refs.tree
const currentNode = tree.$el.querySelector('.el-tree-node.is-current')
const dialogBody = elDialog.$el.querySelector('.el-dialog__body')

// 防止当前没有选中服务报错
if (currentNode) {
const currentOffsetTop = currentNode.offsetTop + elTreeOffsetElBody
dialogBody.scrollTo({
top: currentOffsetTop,
})
}
}

注意:由于个人根据需求的组件设计风格,tree 的组件并不是直接扔在 dialog 中,而是再对其封装成只需 v-model 绑定组件。所以大家在取父子组件操作 DOM 时注意一下即可

XSS 的攻防

随着互联网的高速发展,web 应用在互联网中占据的位置也越来越重要,web 安全问题也自然成为了企业最关注的焦点之一。目前,针对 web 的攻击手段层出不穷(比如 SQL 注入、CSRF、XSS 等),虽然浏览器本身也在不断引入 CORS、CSP、Same-Site Cookies 等技术来增强安全性,但是仍存在很多潜在的威胁,需要技术人员不断进行“查漏补缺”。

在上述的威胁中,XSS(Crossing-Site Scripting,跨站脚本攻击)是困扰 Web 安全多年的一种常见攻击方式。我们本次主要针对 XSS 的原理和防御方案进行介绍

介绍

XSS 是 Crossing-Site Scripting(跨站脚本)的英文首字母缩写,因为和 CSS(Cascading Style Sheets 层叠样式表)重名了,所以将 C 改成 X 以区分。

XSS 是一种代码注入攻击,攻击者可以往 Web 页面里插入恶意代码,当其他用户浏览该网页的时候,嵌入 web 里面的代码会被执行,从而达到攻击者特殊目的,比如:

  • 破坏网站,导致页面不可用

  • 攻击服务端,导致服务拒绝

  • 窃取用户 cookie,发送恶意请求

  • 安装键盘记录器,窃取用户数据

  • 跳转钓鱼页面,窃取账号密码

  • ….

XSS 触发的条件包括:

  1. 攻击者可以提交恶意数据

  2. 数据没有被处理,直接展示到页面上(标签、标签属性、标签事件)

  3. 其他用户可以访问该页面

image-20200823161141603

由来

介绍完 XSS 的基本概念后,我们来聊一聊 XSS 的历史。XSS 的由来已久,最早可以追溯到上个世纪 90 年代。

1995 年,网景公司为其 Netscape Navigator(网景导航者)浏览器引入 JavaScript,这改变了之前 Web 内容是静态 HTML 的状况。JS 使得网页的交互越来越频繁,促使了 web 的蓬勃发展,但是也带来了新的问题:JS 可以执行服务器发送的代码片段,存在恶意代码注入的巨大安全风险,网页里的恶意代码可以向另外一个服务发送数据。使得攻击者可以利用这个方法窃取敏感信息。

2000 年 2 月,CERT(卡内基梅隆大学计算机紧急回应小组协调中心) 公布了一份 XSS 最早的报告,记载了网页因为疏忽包含了恶意的 html 标签和脚本的案例,以及这些恶意代码会对用户造成什么影响。

因为 RD 在开发时不可能对所有的用户输入都进行检测和处理,所以 XSS 是广泛长期存在的。根据OWASP Top 10的统计,XSS 每次都能抢到前排小板凳,2010 年位列第三、2013 年位列第三、2017 年位列第七。

image-20200823161309934

案例

由于 XSS 漏洞很容易被开发忽略,互联网上已经爆发了多起恶劣攻击事件:

2005 年,19 岁的精神小伙萨米为了增加个人主页的关注量、认识更多的辣妹,在 Myspace 网站上发布了萨米蠕虫病毒。它在每个被感染的用户页面显示一行字串“but most of all, samy is my hero”,并将自己复制在该用户页面。在短短 20 个小时内,超过一百万用户被感染和传播,MySpace 被迫将网站彻底关闭来清理病毒。源码

image-20200823161344946

2006 年,Paypal 遭到 XSS 攻击,攻击者将 PayPal 的访问者重定向到仿制的新页面,在新页面警告用户需要重新设置账号密码,窃取用户信用卡、登陆信息等隐私数据。

2011 年,新浪微博出现了一次比较大的 XSS 攻击事件。大量用户自动发送诸如:“郭美美事件的一些未注意到的细节”,“建党大业中穿帮的地方”,“让女人心动的 100 句诗歌”,“这是传说中的神仙眷侣啊”,“惊爆!范冰冰艳照真流出了”等等微博和私信,并自动关注一位名为 hellosamy 的用户。

2014 年,百度贴吧爆出 XSS 蠕虫漏洞,页面 onmouseovers 时会下载 XSS 脚本,如果是吧务不小心被蠕虫感染了,会在「贴吧意见反馈」里,每 8 秒批量发 100 条「百度 SB」的帖子;任何用户被感染后也会发一个标题比较羞耻的帖子。源码

分类

介绍完 XSS 的黑历史后,你是否对 XSS 稍有兴趣(认识更多的辣妹)?我们接下来会揭开 XSS 的面纱。

目前 XSS 的攻击形式主要分成三类:反射型、存储型、Dom Based 型

类型 典型攻击步骤 常见场景 区别
反射型 攻击者构造出包含恶意代码的特殊的 URL 用户登陆后,访问带有恶意代码的 URL 服务端取出 URL 上的恶意代码,拼接在 HTML 中返回浏览器用户浏览器收到响应后解析执行混入其中的恶意代码窃取敏感信息/冒充用户行为,完成 XSS 攻击 通过 URL 传递参数的功能,如网站搜索、跳转等 非持久型 xss 攻击,依赖于服务器对恶意请求的反射,仅对当次的页面访问产生影响恶意代码存在 URL 上经过后端,不经过数据库
存储型 攻击者将恶意代码提交到目标网站的数据库中用户登陆后,访问相关页面 URL 服务端从数据库中取出恶意代码,拼接在 HTML 中返回浏览器用户浏览器收到响应后解析执行混入其中的恶意代码窃取敏感信息/冒充用户行为,完成 XSS 攻击 带有用户保存数据的网站功能,比如论坛发帖、商品评价、用户私信等等。 持久型 xss,攻击者的数据会存储在服务端,攻击行为将伴随着攻击数据一直存在。恶意代码存在数据库经过后端,经过数据库
DOM 型 前端 JavaScript 取出 URL 中的恶意代码并执行窃取敏感信息/冒充用户行为,完成 XSS 攻击 页面 JS 获取数据后不做甄别,直接操作 DOM。一般见于从 URL、cookie、LocalStorage 中取内容的场景 取出和执行恶意代码由浏览器端完成,属于前端 JavaScript 自身的安全漏洞

如何防御

针对反射和存储型 XSS

存储型和反射型 XSS 都是在后端取出恶意代码后,插入到响应 HTML 里的,预防这种漏洞主要是关注后端的处理。

后端设置白名单,净化数据

后端对于保存/输出的数据要进行过滤和转义,过滤的内容:比如 location、onclick、onerror、onload、onmouseover 、 script 、href 、 eval、setTimeout、setInterval 等,常见框架:bluemondayjsoup

1
2
3
4
**String** unsafe =
"<p><a href='http://example.com/' onclick='stealCookies()'>Link</a></p>";
**String** safe = **Jsoup**.clean(unsafe, **Whitelist**.basic());
// now: <p><a href="http://example.com/" >Link</a></p>

转义规则可见【DOM 型 XSS-数据充分转义】

避免拼接 HTML,采用纯前端渲染

浏览器先加载一个静态 HTML,后续通过 Ajax 加载业务数据,调用 DOM API 更新到页面上。纯前端渲染还需注意避免 DOM 型 XSS 漏洞

针对 DOM 型 XSS

DOM 型 XSS 攻击,实际上就是网站前端 JavaScript 代码本身不够严谨,把不可信的数据当作代码执行了。

谨慎对待展示数据

谨慎使用.innerHTML、.outerHTML、document.write() ,不要把不可信的数据作为 HTML 插到页面上。

DOM 中的内联事件监听器,如 location、onclick、onerror、onload、onmouseover 等, 标签的 href 属性,JavaScript 的 eval()、setTimeout()、setInterval() 等,都能把字符串作为代码运行,很容易产生安全隐患,谨慎处理传递给这些 API 的字符串。

数据充分转义,过滤恶意代码

需要根据具体场景使用不同的转义规则,前端插件 xss.jsDOMPurify

放置位置 例子 采取的编码 编码格式
HTML 标签之间
不可信数据
HTML Entity 编码 & –> &< –> <> –> >” –> "‘ –> '/ –> /
HTML 标签的属性 <input type=”text”value=” 不可信数据 ” /> HTML Attribute 编码 &#xHH
JavaScript JavaScript 编码 \xHH
CSS <div style=” width: 不可信数据 ” > … CSS 编码 \HH
URL 参数中 <a href=”/page?p= 不可信数据 ” >… URL 编码 %HH

编码规则:除了阿拉伯数字和字母,对其他所有的字符进行编码,只要该字符的 ASCII 码小于 256。编码后输出的格式为以上编码格式 (以&#x、\x 、\、%开头,HH 则是指该字符对应的十六进制数字)

使用插值表达式

采用 vue/react/angular 等技术栈时,使用插值表达式,避免使用 v-html。因为 template 转成 render function 的过程中,会把插值表达式作为 Text 文本内容进行渲染。在前端 render 阶段避免 innerHTML**、**outerHTML 的 XSS 隐患。

比如:

最终生成的代码如下

"with(this){return _c('div',{staticClass:"a"},[_c('span',[_v(_s(item))])])}"

_c 是 createElement 简写,即 render 函数,_v 是 createTextVNode 的简写,创建文本节点,_s 是 toString 简写

其他措施

禁止 JavaScript 读取某些敏感 Cookie,攻击者完成 XSS 注入后也无法窃取此 Cookie

image-20200823161809436

设置 CSP(Content Security Policy)

CSP 的实质就是设置浏览器白名单,告诉浏览器哪些外部资源可以加载和执行,自动禁止外部注入恶意脚本。

CSP 可以通过两种方式来开启 :

    1. 设置 html 的 meta 标签的方式
1
2
3
4
<meta
http-equiv="Content-Security-Policy"
content="script-src 'self' *.example.com ; style-src 'self' ;"
/>
    1. 设置 HTTP Header 中的 Content-Security-Policy

Content-Security-Policy: script-src 'self' *.example.com ; style-src 'self' ;

上述代码描述的 CSP 规则是 js 脚本只能来自当前域名和 example.com 二级域名下,css 只能来自当前域名

CSP 可以限制加载资源的类型

script-src 外部脚本
style-src 样式表
img-src 图像
media-src 媒体文件(音频和视频)
font-src 字体文件
object-src 插件(比如 Flash)
child-src 框架
frame-ancestors 嵌入的外部资源(比如、
connect-src HTTP 连接(通过 XHR、WebSockets、EventSource 等)
worker-src worker 脚本
manifest-src manifest 文件
default-src 用来设置上面各个选项的默认值

同时也可设置资源的限制规则

主机名 example.orghttps://example.com:443
路径名 example.org/resources/js/
通配符 .example.org://_.example.com:_(表示任意协议、任意子域名、任意端口)
协议名 https:、data:
‘self’ 当前域名,需要加引号
‘none’ 禁止加载任何外部资源,需要加引号
‘unsafe-inline’ 允许执行页面内嵌的

代码Lint与格式化规范配置手册

代码 Lint 与格式化规范配置手册

如何在项目中使用

npm 安装依赖

Lint 规则参考 https://alloyteam.github.io/eslint-config-alloy/

1
npm i -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-alloy eslint-config-prettier eslint-plugin-prettier eslint-plugin-vue husky lint-staged prettier

package.json 中增加

1
2
3
4
5
6
7
8
9
10
11
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,vue,ts}": [
"eslint --fix",
"git add"
]
}

添加.eslintrc.js 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = {
root: true,
env: {
node: true,
},
extends: ["alloy", "alloy/typescript", "plugin:vue/essential", "prettier"],
plugins: ["prettier"],
parserOptions: {
parser: "@typescript-eslint/parser",
ecmaVersion: 2017,
sourceType: "module",
},
rules: {
eqeqeq: "off",
"prettier/prettier": "error",
},
};

增加 .editorconfig 文件

1
2
3
4
5
6
7
8
9
[*.{js,jsx,ts,tsx,vue}]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 2
trim_trailing_whitespace = true

增加 .prettierrc.js 文件

1
2
3
4
5
6
module.exports = {
trailingComma: "es5",
tabWidth: 2,
semi: false,
singleQuote: true,
};

IDE 设置

IDEA / Web Storm

安装相关插件

  1. Prettier

  2. File Watcher

如何自动 Lint/格式化文件 参考:

https://prettier.io/docs/en/webstorm.html#running-prettier-on-save-using-file-watcher

检查项目是否启动 ESLint Prettier

img

img

手动格式化

修改默认格式化快捷键为 Prettier

关闭分号警告

搜索 unterminated statement 然后去掉 ✔️ 如下图

img

VS Code

安装相关插件

  1. Prettier

  2. ESLint

  3. EditorConfig

如何自动 Lint/格式化文件

在 VSCode 中,默认 ESLint 并不能识别 .vue、.ts 或 .tsx 文件,需要在「文件 => 首选项 => 设置里做如下配置:

1
2
3
4
5
6
7
8
9
{
"eslint.validate": [
"javascript",
"javascriptreact",
"vue",
"typescript",
"typescriptreact"
]
}

如果需要针对 .vue、.ts 和 .tsx 文件开启 ESLint 的 autoFix,则需要配置成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"eslint.autoFixOnSave": true,
"eslint.validate": [
"javascript",
"javascriptreact",
{
"language": "vue",
"autoFix": true
},
{
"language": "typescript",
"autoFix": true
},
{
"language": "typescriptreact",
"autoFix": true
}
]
}

Vue 文件格式化与 ESLint 冲突的问题

修改下 vetur 的配置 保证 格式化使用 prettier

img

以上设置成功后应该达到的效果

  1. 编辑代码时能标出与 Lint 规则冲突的代码

  2. 文件保存时自动 Lint 并格式化代码

  3. git commit 时自动检查暂存区代码是否符合 Lint/Prettier 不符合代码无法 commit

对于旧代码不符合 Lint 规则的情况

以上设置后 Lint 规则只会扫描加入到 git 暂存区的文件,即当前修改的文件.如果需要修改旧代码则需要解决旧代码的 Lint 问题。

Nexus搭建npm 私服

Nexus 搭建 npm 私服

服务端部署 Nexus

  • 下载解压

    官方下载链接](help.sonatype.com/repomanager…),执行:

    1
    2
    3
    4
    5
    6
    #下载
    $ wget https://download.sonatype.com/nexus/3/latest-unix.tar.gz
    #移动到 /opt 目录
    $ sudo mv latest-unix.tar.gz /opt/nexus3.tar.gz
    #解压
    $ sudo tar -xzvf nexus3.tar.gz

    注意运行 Nexus 需要 Java 8 运行时环境(JRE),请自行安装。

  • 创建运行用户

    单独创建一个 nexus 用户用来运行

    1
    2
    3
    4
    5
    # 创建用户、指定用户目录、授权
    $ sudo useradd -d /home/nexus -m nexus
    $ sudo chown -R nexus:nexus /home/nexus
    $ sudo chown -R nexus:nexus /opt/nexus-3.15.2-01
    $ sudo chown -R nexus:nexus /opt/sonatype-work/

    修改运行用户配置项:修改 /opt/nexus-3.15.2-01/bin 目录下的配置文件 nexus.rc 为 run_as_user="nexus"

  • 运行

    修改端口指 8073 并开放 iptables 防火墙,对/opt/sonatype-work/nexus3/etc/nexus.properties 文件进行修改:

    1
    2
    3
    4
    # Jetty section
    application-port=8073
    application-host=0.0.0.0 # nexus-args=${jetty.etc}/jetty.xml,${jetty.etc}/jetty-http.xml,${jetty.etc}/jetty-requestlog.xml
    # nexus-context-path=/ # Nexus section # nexus-edition=nexus-pro-edition # nexus-features=\ # nexus-pro-feature

    启动服务,以下为 nexus 服务命令:

    1
    2
    3
    4
    5
    6
    7
    8
    # 启动 nexus 服务
    $ sudo service nexus start
    # 重启 nexus 服务
    $ sudo service nexus restart
    # 停止 nexus 服务
    $ sudo service nexus stop
    # 查看 nexus 服务状态
    $ sudo service nexus status

    查看日志检查服务状态:

    1
    $ tail -f /opt/sonatype-work/nexus3/log/nexus.log

    至此,nexus 服务已搭建完毕!可使用默认账号 admin/admin123 登录 ip:post 后对 npm 仓库进行管理

![image-20200806002327294](Nexus 搭建 npm 私服.assets/image-20200806002327294.png)

  • 仓库管理

    创建仓库,npm 仓库有三种,这三种我们都需要创建

![image-20200806002450572](Nexus 搭建 npm 私服.assets/image-20200806002450572.png)

  1. npm(proxy) - 代理 npm 仓库

    将公共 npm 服务器的资源代理缓存,减少重复下载,加快开发人员和 CI 服务器的下载速度。

    创建时需填写 Name(npm-external)和 Remote Storage(公有库地址,填写官方或淘宝镜像,https://registry.npmjs.org/)。

    该仓库地址为:http://ip:post/repository/npm-external/

  2. npm(hosted) - 私有 npm 仓库

    用于 上传自己的 npm 包 以及第三方 npm 包。

    创建时只需填写 Name(npm-internal)。

    该仓库地址为:http://ip:post/repository/npm-internal/

    请注意:发布包时请将 registry 设为该地址。

  3. npm(group) - npm 仓库组

    用于将多个内部或外部 npm 仓库统一为一个 npm 仓库。可以新建一个 npm 仓库组将 上面两个刚刚创建的两个 npm 仓库都添加进去。这样可以通过这个 npm 仓库组,既可以访问 公有 npm 仓库 又可以访问自己的 私有 npm 仓库。

    创建时需填写 Name(npm-all),然后选择需要添加到组里的 其他 npm 仓库(npm-externalnpm-internal)。

    该仓库地址为:http://ip:post/repository/npm-all/

    请注意:安装包以及卸载包时请将 registry 设为该地址。

  • 用户管理

    将包发布到 nexus npm 仓库需要设置一下 Nexus Repository Manager 的权限。否则无法登陆到我们的私服。在 Security->Realms 栏目里,将npm Bearer Token Realm 选入 Active。

![image-20200806002525787](Nexus 搭建 npm 私服.assets/image-20200806002525787.png)

之后我们需要在 Security->Users 栏目里添加用户(需要admin权限否则无法发包),只有这样添加的用户才可以发布包。经测试,在客户端使用 npm adduser 创建的用户没有发布权限。

​ npm adduser 添加的用户信息需要时刚刚创建的 user 信息, 非私服可以使用npm login使用 npm 账号进行发布。

客户端使用

使用 nrm 管理 registry

1
2
3
4
5
6
7
8
9
10
11
12
13
$npm install -g nrm

$ nrm ls
* npm ---- https://registry.npmjs.org/
cnpm --- http://r.cnpmjs.org/
taobao - https://registry.npm.taobao.org/
nj ----- https://registry.nodejitsu.com/
rednpm - http://registry.mirror.cqupt.edu.cn/
npmMirror https://skimdb.npmjs.com/registry/
edunpm - http://registry.enpmjs.org/

$ nrm add ynpm http://XXXXXX:8888 # 添加私服的npm镜像地址
$ nrm use ynpm # 使用私服的镜像地址

安装包

1
2
3
4
5
6
7
8
9
10
npm install lodash # sinopia发现本地没有 lodash包,就会从 官方镜像下载
npm --loglevel info install lodash # 设置loglevel 可查看下载包时的详细请求信息

[storage]$ ls
#下载过之后,私服的storage目录下回缓存安装包
[storage]$ ls
lodash

rm -rf node-modules # 删除目录
npm insatll lodash # 第二次安装就会从缓存下载了,速度很快

制作包

包的制作其实很简单,只需要npm init初始化一个项目,配置package.json文件自己所需的信息即可,主要注意下main字段所指定的入口文件,也是导出文件。

1
2
// index.js
module.exports = function () {};

发布包与撤销发布包

在项目根目录下运行$ npm publish发布新包。

运行$ npm unpublish 包名 --force撤销发布包。

1
$ npm publish

查看发布的包,已成功发布:

![image-20200806002957482](Nexus 搭建 npm 私服.assets/image-20200806002957482.png)

作用域 scope 管理发布包

经常有看到@xxx/yyy类型的开源 npm 包,原因是包名称难免会有重名,如果已经有人在 npm 上注册该包名,再次 npm publish 同名包时会告知发布失败,这时可以通过 scope 作用域来解决

  • 定义作用域包

    修改 package.json 中包名称:

    1
    2
    3
    4
    {
    "name": "@username/project-name"
    }
    复制代码

    需要注意的是,如果是发布到官方 registry,scope 一定要是自己注册的用户名,而如果是发布到自己的 npm 私服,scope 可以不是用户名

  • 发布作用域包

    作用域模块默认发布是私有的

    发布到官方 registry 时,直接npm publish会报错,原因是只有付费用户才能发布私有 scope 包,免费用户只能发布公用包,因此需要添加 access=public 参数;

    发布到自己的 npm 私服时,不加access=public参数也可以发布

    1
    npm publish --access=public
  • 使用作用域包

    1
    npm install @username/project-name

TypeScript 进阶——类型编程

TypeScript 进阶——类型编程

TypeScript 在某个层面上可以称作 Type + JavaScript,那么抛开 JavaScript,Type 是完备的编程语言吗?我们是否可以对类型进行编程呢?本文介绍一些 ts 特性和工具,期望一窥类型的乐趣。

PS:建议将代码放到 IDE 或* TS playground里查看类型提示。\

Generics (泛型)

主流的编程语言通常都支持泛型以提供更加出色的抽象能力 (手动艾特 Go),TypeScript 也不免俗。

1
2
3
const wrapArr = <T>(v: T): T[] => [v];
const outputA = wrapArr("aa"); // string[]
const outputB = wrapArr(1); // number[]

完备的编程语言函数是必不可少的。本质上,泛型可以理解成类型层面的函数,当我们指定具体的输入类型时,得到的结果是经过处理后的输出类型。

1
2
3
4
const identity = (x) => x; // value level
type Identity<T> = T; // Type level
const pair = (x, y) => [x, y];
type Pair<T, U> = [T, U];

泛型也起到约束和推导,举个例子

1
const map = <T, U>(arr: T[], cb: (v: T, i: number) => U): U[] => {/* map 实现 */}

那么函数已经有了,我们还需要一些工具函数帮助编程。

Utility Types

TypeScript 内置了很多工具类型,帮助开发者做类型变换(编程)。以下举几个例子,更多可看官方文档

Partial, Required

将 T 的所有属性变成可选/必选。

1
2
3
4
5
6
7
8
9
10
11
interface Foo {
name: string;
age: number;
}
type Bar = Partial<Foo>;
// Bar => {
// name?: string
// age?: number
// }
type LikeFoo = Required<Bar>;
// LikeFoo is same as Foo

Pick<T, U>, Omit<T, U>

从一个类型中获得子集。在组件 Props 透传时,常用于生成子组件的 Props 类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface ParentProps {
color: string;
size: number;
label: string;
name: string;
options: string[];
placeholder?: string;
}
type Child1Key = "color" | "size" | "label";
type Child1Props = Pick<ParentProps, Child1Key>;
// {
// color: string
// size: number
// label: string
// }
type Child2Props = Omit<ParentProps, Child1Key>;
// {
// name: string
// options: string[]
// placeholder?: string
// }

Exclude<T, U>, Extract<T, U>

在 T 中排除/抽取匹配 U 的类型

1
2
3
type T0 = Exclude<"a" | "b" | "c", "a" | "b">; // "c"
type T1 = Exclude<string | number | (() => void), Function>; // string | number
type T2 = Extract<"a" | "b" | "c", "c">; // "c"

Parameters, ReturnType

获得函数的参数类型和返回类型。可用于获得未暴露出来的类型。

1
2
3
4
5
6
// util.ts
export const sum = (...p: number[]) => p.reduce((r, v) => r + v, 0)
// main.ts
import { sum } from 'util'
Parameters<typeof sum> // => number[]
ReturnType<typeof sum> // => number

与 lodash 的工具函数一样,这些工具类型可以通过 ts 本身的特性实现,下文着重介绍以上几个工具类型的实现。

类型操作符

先介绍几个常用的操作符

  1. in,与 JavaScript 的 in 类似,用于遍历

  2. keyof,获取一个类型的所有键值。最终得到一个联合类型,类似 Object.keys

1
2
3
type Pick<T, U extends keyof T> = { [K in U]: T[K] };
type Partial<T> = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] };

Conditional Type (条件类型)

条件判断是编程语言基础的功能之一。TS 可以用条件类型来实现。条件类型一般形式是 T extends U ? X : Y ,和 JavaScript 的三元表达式一致,其中条件部分 T extends U 表示 T 是 U 的子集,即 T 类型的所有取值都包含在 U 类型中。

首先可以利用条件类型实现几个工具类型

1
2
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;

再举个实际的案例

1
2
3
4
function process(text: string | null): string | null {
return text && text.replace(/f/g, "p");
}
process("hello world").toUpperCase(); // Type Error!

由于欠缺输入类型和输出类型之间的关联关系,导致即便输入是字符串时 TypeScript 仍然不能推断出输出是字符串,最终编译报错。

1
2
3
4
5
function process<T extends string | null>(text: T): T extends string ? string : null {
return text && text.replace(/f/g, 'p');
}
process('foo').toUpperCase(); *// Okay.*
process(null).toUpperCase(); // Type Error!

infer

infer 是条件类型的补充,表示待推断的类型。举个例子

1
2
type Parameters<T> = T extends (...args: infer U) => any ? U : never;
type ReturnType<T> = T extends (...args: any) => infer U ? U : never;

在上面的条件类型中,infer U 表示待推断的函数参数类型。

infer 可以用于很多 unpack 的场景中

1
2
3
4
5
6
7
type Unpacked<T> = T extends (infer U)[]
? U
: T extends Set<infer U>
? U
: T extends Promise<infer U>
? U
: T;

图灵完备

有了类型层面的函数(泛型)、条件语句(条件类型)、递归等功能之后, 我们不禁有一个疑问:TypeScript 能够描述所有的数据类型吗?Github 有类似的讨论:TypeScript 是图灵完备的

TypeScript 包含了一套完整的类型层面编程能力,就像我们可以用 JavaScript、C++、Go 等编程语言解决各种实际问题一样,TypeScript 可以解决各种类型问题,因为本质上它们的内核都和图灵机等价。

拾遗

类型编程与实际业务编程一样充满乐趣。类型体操就像瑜伽/JOJO 立一样,容易沉迷,可谓一直凹类型一直爽。

双手供上一道类型题(leetcode 的笔试题),所谓实践出真知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 实现一个 Connect 类型,将下面 Module 转换成 Result
interface Action<T> {
payload?: T;
type: string;
}
interface Module {
count: number;
message: string;
asyncMethod<T, U>(input: Promise<T>): Promise<Action<U>>;
syncMethod<T, U>(action: Action<T>): Action<U>;
}
type Result = {
asyncMethod<T, U>(input: T): Action<U>;
syncMethod<T, U>(action: T): Action<U>;
};

项目git使用规范

项目 git 使用规范

分支规范

功能分支

feature-(姓名简称)-(jira 号/任务 ID)

feature-jzh-XXXXXXXID

上线分支

release-(年月日)

release-20200803

冲突分支

conflict-(姓名简称)-(年月日)

conflict-jzh-20200803

conflict-jzh-20200803-1

测试/灰度修复分支

bugfix-(jira 号/任务 ID)

线上修复分支

hotfix-(年月日)

hotfix-20200803

Commit Message 规范

basic
1
2
3
4
5
6
7
feat: 新特性
fix: 修改问题
refactor: 代码重构
docs: 文档修改
style: 代码格式修改, 注意不是 css 修改
test: 测试用例修改
chore: 其他修改, 比如构建流程, 依赖管理.

在 commit 的时候需要增加上面中的类型:

1
git commit -m "fix: 解决xxxxxxx问题"

Merge Request 规范

标题格式为:

basic
1
2
[jiraID/任务ID][姓名]feat:描述
注意是英文字符的中括号 []

Code Review

由项目 Leader/同事 进行 review 代码,review 完成之后 进行评论 LGFM 后合并分支

刚clone下来项目,submodule更新不成功

刚 clone 下来项目,submodule 更新不成功

问题

开发过程中,我们可能会引入一个公共库来提供工程来使用,而公共代码库的版本管理是个麻烦的事情。我并遇到在 clone 一个项目到本地,在启动该项目前需要将该工程的子模块仓库下载下来。执行命令:

1
git submodule update --init --recursive

但遇到下面这个报错

1
2
3
4
5
6
7
8
fatal: No url found for submodule path '子模块名称' in .gitmodules
npm ERR! code ELIFECYCLE
npm ERR! errno 128
npm ERR! 项目名 install-submodule: `git submodule update --init --recursive`
npm ERR! Exit status 128
npm ERR!
npm ERR! Failed at the 项目名 install-submodule script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

解决方法

1
2
3
4
# 逆初始化模块,其中{MOD_NAME}为模块目录,执行后可发现模块目录被清空
git submodule deinit 子模块名
# 删除.gitmodules中记录的模块信息(--cached选项清除.git/modules中的缓存)
git rm --cached 子模块名

补充

为当前工程添加 submodule,命令如下:

1
git submodule add 仓库地址 路径

其中,仓库地址是指子模块仓库地址,路径指将子模块放置在当前工程下的路径。

Webpack学习-深入浅出

Webpack学习-深入浅出

简介

webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具

  • Webpack是提升前端生产力的利器

    个人觉得 Webpack 应该是现代化前端开发的基石,也是目前前端生产力的代名词。

  • Webpack 与模块化开发

    随着前端应用的日益复杂化,我们的项目已经逐渐膨胀到了不得不花大量时间去管理的程度。而模块化就是一种最主流的项目组织方式,它通过把复杂的代码按照功能划分为不同的模块单独维护,从而提高开发效率、降低维护成本。

Webpack核心特点

使用 Webpack 实现模块化打包

目前,前端领域有一些工具能够很好的满足以上这 3 个需求,其中最为主流的就是 Webpack、Parcel 和 Rollup,我们以 Webpack 为例:

  • Webpack 作为一个模块打包工具,本身就可以解决模块化代码打包的问题,将零散的 JavaScript 代码打包到一个 JS 文件中。

  • 对于有环境兼容问题的代码,Webpack 可以在打包过程中通过 Loader 机制对其实现编译转换,然后再进行打包。

  • 对于不同类型的前端模块类型,Webpack 支持在 JavaScript 中以模块化的方式载入任意类型的资源文件,例如,我们可以通过 Webpack 实现在 JavaScript 中加载 CSS 文件,被加载的 CSS 文件将会通过 style 标签的方式工作。

Webpack 快速上手

安装 Webpack 的核心模块以及它的 CLI 模块,具体操作如下:

1
2
npm init --yes
npm i webpack webpack-cli --save-dev

webpack 是 Webpack 的核心模块,webpack-cli 是 Webpack 的 CLI 程序,用来在命令行中调用 Webpack。

安装完成之后,webpack-cli 所提供的 CLI 程序就会出现在 node_modules/.bin 目录当中,我们可以通过 npx 快速找到 CLI 并运行它,具体操作如下:

1
2
3
npx webpack --version

npx 是 npm 5.2 以后新增的一个命令,可以用来更方便的执行远程模块或者项目 node_modules 中的 CLI 程序。

运行 webpack 命令来打包 JS 模块代码,具体操作如下:

1
npx webpack

这个命令在执行的过程中,Webpack 会自动从 src/index.js 文件开始打包,然后根据代码中的模块导入操作,自动将所有用到的模块代码打包到一起。

对于 Webpack 最基本的使用,总结下来就是:先安装 webpack 相关的 npm 包,然后使用 webpack-cli 所提供的命令行工具进行打包。

配置 Webpack 的打包过程

详细的文档你可以在 Webpack 的官网中找到:https://webpack.js.org/configuration/#options

在这里,我想跟你分享我在编写 Webpack 配置文件时用过的一个小技巧,因为 Webpack 的配置项比较多,而且很多选项都支持不同类型的配置方式。即便没有使用 TypeScript 这种类型友好的语言,也可以通过类型注释的方式去标注变量的类型。

默认 VSCode 并不知道 Webpack 配置对象的类型,我们通过 import 的方式导入 Webpack 模块中的 Configuration 类型,然后根据类型注释的方式将变量标注为这个类型,这样我们在编写这个对象的内部结构时就可以有正确的智能提示了,具体代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
import { Configuration } from 'webpack' // 一定记得运行 Webpack 前先注释掉这里。
/**
* @type {Configuration}
*/
const config = {
entry: './src/index.js',
output: {
filename: 'bundle.js'
}

module.exports = config

需要注意的是:我们添加的 import 语句只是为了导入 Webpack 配置对象的类型,这样做的目的是为了标注 config 对象的类型,从而实现智能提示。在配置完成后一定要记得注释掉这段辅助代码,因为在 Node.js 环境中默认还不支持 import 语句,如果执行这段代码会出现错误。

所以我一般的做法是直接在类型注释中使用 import 动态导入类型,具体代码如下:

1
2
3
4
5
6
7
8
/** @type {import('webpack').Configuration} */
const config = {
entry: './src/index.js',
output: {
filename: 'bundle.js'
}
}
module.exports = config

这种方式同样也可以实现载入类型,而且相比于在代码中通过 import 语句导入类型更为方便,也更为合理。

不过需要注意一点,这种导入类型的方式并不是 ES Modules 中的 Dynamic Imports,而是 TypeScript 中提供特性。虽然我们这里只是一个 JavaScript 文件,但是在 VSCode 中的类型系统都是基于 TypeScript 的,所以可以直接按照这种方式使用,详细信息你可以参考这种 import-types 的文档。

其次,这种 @type 类型注释的方式是基于 JSDoc 实现的。JSDoc 中类型注释的用法还有很多,详细可以参考官方文档中对 @type 标签的介绍。https://jsdoc.app/index.html

#####Webpack 工作模式

https://webpack.js.org/configuration/mode/

Webpack 4 新增了一个工作模式的用法,这种用法大大简化了 Webpack 配置的复杂程度。你可以把它理解为针对不同环境的几组预设配置:

  • production 模式下,启动内置优化插件,自动优化打包结果,打包速度偏慢;
  • development 模式下,自动优化打包速度,添加一些调试过程中的辅助插件;
  • none 模式下,运行最原始的打包,不做任何额外处理。

针对工作模式的选项,如果你没有配置一个明确的值,打包过程中命令行终端会打印一个对应的配置警告。在这种情况下 Webpack 将默认使用 production 模式去工作。

修改 Webpack 工作模式的方式有两种:

  • 通过 CLI –mode 参数传入;
  • 通过配置文件设置 mode 属性。

打包结果运行原理
最后,我们来一起学习 Webpack 打包后生成的 bundle.js 文件,深入了解 Webpack 是如何把这些模块合并到一起,而且还能正常工作的。

为了更好的理解打包后的代码,我们先将 Webpack 工作模式设置为 none,这样 Webpack 就会按照最原始的状态进行打包,所得到的结果更容易理解和阅读。

按照 none 模式打包完成后,我们打开最终生成的 bundle.js 文件,如下图所示:

image-20200706002840563

我们可以先把代码全部折叠起来,以便于了解整体的结构,如下图所示:

VSCode 中折叠代码的快捷键是 Ctrl + K,Ctrl + 0(macOS:Command + K,Command + 0)

image-20200706002900825

整体生成的代码其实就是一个立即执行函数,这个函数是 Webpack 工作入口(webpackBootstrap),它接收一个 modules 参数,调用时传入了一个数组。

展开这个数组,里面的元素均是参数列表相同的函数。这里的函数对应的就是我们源代码中的模块,也就是说每个模块最终被包裹到了这样一个函数中,从而实现模块私有作用域,如下图所示:

image-20200706003053538

我们再来展开 Webpack 工作入口函数,如下图所示:

image-20200706003115366

这个函数内部并不复杂,而且注释也很清晰,最开始定义了一个 installedModules 对象用于存放或者缓存加载过的模块。紧接着定义了一个 require 函数,顾名思义,这个函数是用来加载模块的。再往后就是在 require 函数上挂载了一些其他的数据和工具函数,这些暂时不用关心。

这个函数执行到最后调用了 require 函数,传入的模块 id 为 0,开始加载模块。模块 id 实际上就是模块数组的元素下标,也就是说这里开始加载源代码中所谓的入口模块。

为了更好的理解 bundle.js 的执行过程,可以把它运行到浏览器中,然后通过 Chrome 的 Devtools 单步调试一下。

#####Webpack 运行机制

Webpack 核心工作过程中的关键环节

  • Webpack CLI 启动打包流程;
  • 载入 Webpack 核心模块,创建 Compiler 对象;
    • webpack核心模块会根据配置选项去构建compiler对象,可分多路打包和单路打包。
    • 多路打包相当与再次调用webpack函数,通过MultiCompiler类获得compiler对象;单路打包会解析配置选项覆盖默认选项,接着通过Compiler类获得compiler对象,将解析好的options配置对象挂载到compiler上。
    • 紧接着注册已配置的插件,调用环境变量钩子和注册内置的插件
    • 最后返回compiler对象
  • 使用 Compiler 对象开始编译整个项目;
    • 执行compiler对象的run方法,会先触发beforeRun和run钩子,再执行compile方法(也就是Compiler类的compile方法),开始真正的编译。
    • 触发beforeCompile和compile钩子,创建compilation上下文对象,再触发make钩子,开始make阶段。
  • 从入口文件开始,解析模块依赖,形成依赖关系树;
  • 递归依赖树,将每个模块交给对应的 Loader 处理;
  • 合并 Loader 处理完的结果,将打包结果输出到 dist 目录。

对于 make 阶段后续的基本流程

  • SingleEntryPlugin 中调用了 Compilation 对象的 addEntry 方法,开始解析入口;
  • addEntry 方法中又调用了 _addModuleChain 方法,将入口模块添加到模块依赖列表中;
  • 紧接着通过 Compilation 对象的 buildModule 方法进行模块构建;
  • buildModule 方法中执行具体的 Loader,处理特殊资源加载;
  • build 完成过后,通过 acorn 库生成模块代码的 AST 语法树;
  • 根据语法树分析这个模块是否还有依赖的模块,如果有则继续循环 build 每个依赖;
  • 所有依赖解析完成,build 阶段结束;
  • 最后合并生成需要输出的 bundle.js 写入 dist 目录。

webpack高阶

#####DevServer提高开发效率

我们在开发过程中需要时时的看我们的编写结果,我们比较原始的方式就是以 watch 模式工作修改代码 → Webpack 自动打包 → 手动刷新浏览器(http-serve) → 预览运行结果,或者使用BrowserSync工具替换 serve 工具,启动 HTTP 服务,这里还需要同时监听 dist 目录下文件的变化,具体命令如下:

1
2
3
4
5
6
# 可以先通过 npm 全局安装 browser-sync 模块,然后再使用这个模块
$ npm install browser-sync --global
$ browser-sync dist --watch

# 或者也可以使用 npx 直接使用远端模块
$ npx browser-sync dist --watch

它的原理就是 Webpack 监视源代码变化,自动打包源代码到 dist 中,而 dist 中文件的变化又被 BrowserSync 监听了,从而实现自动编译并且自动刷新浏览器的功能,整个过程由两个工具分别监视不同的内容。

这种 watch 模式 + BrowserSync 虽然也实现了我们的需求,但是这种方法有很多弊端:

  • 操作烦琐,我们需要同时使用两个工具,那么需要了解的内容就会更多,学习成本大大提高;

  • 效率低下,因为整个过程中, Webpack 会将文件写入磁盘,BrowserSync 再进行读取。过程中涉及大量磁盘读写操作,必然会导致效率低下。
    所以这只能算是“曲线救国”,并不完美,我们仍然需要继续改善。

所以我们可以使用webpack-dev-server,它 Webpack 官方推出的一款开发工具,根据它的名字我们就应该知道,它提供了一个开发服务器,并且将自动编译和自动刷新浏览器等一系列对开发友好的功能全部集成在了一起。

1
2
3
4
# 安装 webpack-dev-server
$ npm install webpack-dev-server --save-dev
# 运行 webpack-dev-server
$ npx webpack-dev-server

image-20200708160621592webpack-dev-server 为了提高工作速率,它并没有将打包结果写入到磁盘中,而是暂时存放在内存中,内部的 HTTP Server 也是从内存中读取这些文件的。这样一来,就会减少很多不必要的磁盘读写操作,大大提高了整体的构建效率。

Webpack 配置对象中可以有一个叫作 devServer 的属性,专门用来为 webpack-dev-server 提供配置,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ./webpack.config.js
const path = require('path')

module.exports = {
// ...
devServer: {
contentBase: 'pulic', // 指定额外的静态资源路径
compress: true, // 开启压缩
port: 9000, // 指定端口,默认8080
// ...
// 详细配置文档:https://webpack.js.org/configuration/dev-server/
}
}

Proxy 代理解决开发跨域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ./webpack.config.js
module.exports = {
// ...
devServer: {
proxy: {
'/api': {
target: 'https://api.github.com', // 目标地址
pathRewrite: {
'^/api': '' // 替换掉代理地址中的 /api
},
changeOrigin: true // 确保请求 GitHub 的主机名就是:api.github.com
}
}
}
}
Source Map配置

原理:

Source Map(源代码地图)就是解决此类问题最好的办法,从它的名字就能够看出它的作用:映射转换后的代码与源代码之间的关系。一段转换后的代码,通过转换过程中生成的 Source Map 文件就可以逆向解析得到对应的源代码。image-20200708183431060

.map是一个 JSON 格式的文件,为了更容易阅读,我提前对该文件进行了格式化。这个 JSON 里面记录的就是转换后和转换前代码之间的映射关系,主要存在以下几个属性:

  • version 是指定所使用的 Source Map 标准版本;
  • sources 中记录的是转换前的源文件名称,因为有可能出现多个文件打包转换为一个文件的情况,所以这里是一个数组;
  • names 是源代码中使用的一些成员名称,我们都知道一般压缩代码时会将我们开发阶段编写的有意义的变量名替换为一些简短的字符,这个属性中记录的就是原始的名称;
  • mappings 属性,这个属性最为关键,它是一个叫作 base64-VLQ 编码的字符串,里面记录的信息就是转换后代码中的字符与转换前代码中的字符之间的映射关系,具体如下图所示:

image-20200708183626164

一般我们会在转换后的代码中通过添加一行注释的方式来去引入 Source Map 文件。不过这个特性只是用于开发调试的,所以最新版本的 jQuery 已经去除了引入 Source Map 的注释,我们需要手动添加回来,这里我们在最后一行添加 //# sourceMappingURL=jquery-3.4.1.min.map,具体效果如下:image-20200708183757516

配置:

1
2
3
4
// ./webpack.config.js
module.exports = {
devtool: 'source-map' // source map 设置
}

Webpack 中的 devtool 配置,除了可以使用 source-map 这个值,它还支持很多其他的选项,具体的我们可以参考文档中的不同模式的对比表。image-20200708184217136

根据上图,总结得出:

  • eval(不生产.map文件,在打包后js模块中):Webpack 会将每个模块转换后的代码都放到 eval 函数中执行,并且通过 sourceURL 声明对应的文件路径。它只能定位源代码的文件路径,无法知道具体的行列信息
  • inline-source-map:它跟普通的 source-map 效果相同,只不过这种模式下 Source Map 文件不是以物理文件存在,而是以 data URLs 的方式出现在代码中。我们前面遇到的 eval-source-map 也是这种 inline 的方式。
  • hidden-source-map :在这个模式下,我们在开发工具中看不到 Source Map 的效果,但是它也确实生成了 Source Map 文件,这就跟 jQuery 一样,虽然生成了 Source Map 文件,但是代码中并没有引用对应的 Source Map 文件,开发者可以自己选择使用。
  • nosources-source-map :在这个模式下,我们能看到错误出现的位置(包含行列位置),但是点进去却看不到源代码。这是为了保护源代码在生产环境中不暴露。
  • 其他可得出cheap: 只能定位到行,定位不到列;module:让源代码不转化(不经过Loader) ES6 写法,保持原本写法

我个人平时开发会选:cheap-module-eval-source-map,而发布前打包则选择 none不代码映射。

热替换(HMR)机制

配置代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ./webpack.config.js
const webpack = require('webpack')

module.exports = {
// ...
devServer: {
// 开启 HMR 特性,如果资源不支持 HMR 会 fallback 到 live reloading
hot: true
// 只使用 HMR,不会 fallback 到 live reloading,这样热替换逻辑中的错误信息就可以直接看到(不页面刷新)
// hotOnly: true
},
plugins: [
// ...
// HMR 特性所需要的插件
new webpack.HotModuleReplacementPlugin()
]
}

当我们还是需要自己手动通过代码来处理,可以使用HotModuleReplacementPlugin 为我们的 JavaScript 提供了一套用于处理 HMR 的 API,我们需要在我们自己的代码中,使用这套 API 将更新后的模块替换到正在运行的页面中。

1
2
3
4
5
6
7
// HMR -----------------------------------
if (module.hot) { // 确保有 HMR API 对象
module.hot.accept('./sourceUrl', () => {
// ...
console.log('更新了')
})
}
高级特性优化项目

######Tree Shaking

Tree-shaking 并不是指 Webpack 中的某一个配置选项,而是一组功能搭配使用过后实现的效果,这组功能在生产模式下都会自动启用,所以使用生产模式打包就会有 Tree-shaking 的效果。

功能:移除未引用代码(dead-code)

配置:

1
2
3
4
5
6
7
8
9
10
11
12
// ./webpack.config.js
module.exports = {
// ... 其他配置项
optimization: {
// 模块只导出被使用的成员(未开启压缩还可以在模块中看到)
usedExports: true,
// 压缩输出结果
minimize: true,
// 尽可能合并每一个模块到一个函数中,这个特性又被称为 Scope Hoisting,也就是作用域提升,它是 Webpack 3.0 中添加的一个特性。
concatenateModules: true,
}
}

注意:结合 babel-loader 的问题

Tree-shaking 实现的前提是 ES Modules,也就是说:最终交给 Webpack 打包的代码,必须是使用 ES Modules 的方式来组织的模块化。

我们为了更好的兼容性,会选择使用 babel-loader 去转换我们源代码中的一些 ECMAScript 的新特性。而 Babel 在转换 JS 代码时,很有可能处理掉我们代码中的 ES Modules 部分,把它们转换成 CommonJS 的方式。

但在最新版本(8.x)的 babel-loader 中,已经自动帮我们关闭了对 ES Modules 转换的插件,所以我们只需要注意我们的 babel-loader的版本即可

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
// ./webpack.config.js
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env']
// 强制使用 Babel 的 ES Modules 插件把代码中的 ES Modules 转换为 CommonJS
// ['@babel/preset-env', { modules: 'commonjs' }]
]
}
}
}
]
},
optimization: {
usedExports: true
}
}

######sideEffects

允许我们通过配置标识我们的代码是否有副作用,从而提供更大的压缩空间。模块的副作用指的就是模块执行的时候除了导出成员,是否还做了其他的事情。

开启 Tree-shaking 特性(只设置 useExports),这里没有用到的导出成员被移除,打包效果如下:image-20200708232156819

但是由于这些成员所属的模块中有副作用代码,所以就导致最终 Tree-shaking 过后,这些模块并不会被完全移除。所以说,Tree-shaking 只能移除没有用到的代码成员,而想要完整移除没有用到的模块,那就需要开启 sideEffects 特性了。

配置:

1
2
3
4
5
6
7
8
9
10
11
12
// ./webpack.config.js
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js'
},
optimization: {
// 在 production 模式下同样会自动开启
sideEffects: true
}
}

Webpack 在打包某个模块之前,会先检查这个模块所属的 package.json 中的 sideEffects 标识,以此来判断这个模块是否有副作用,如果没有副作用的话,这些没用到的模块就不再被打包。换句话说,即便这些没有用到的模块中存在一些副作用代码,我们也可以通过 package.json 中的 sideEffects 去强制声明没有副作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// package.json
{
"name": "09-side-effects",
"version": "0.1.0",
"author": "zce <w@zce.me> (https://zce.me)",
"license": "MIT",
"scripts": {
"build": "webpack"
},
"devDependencies": {
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
// 项目中的所有代码都没有副作用,webpack就会将所有未导出模块副作用代码删除
"sideEffects": false
// 可以指定保留有副作用的模块路径(可以使用通配符)
//"sideEffects": [
// "./src/extend.js",
// "*.css"
// ]
}

总结sideEffects配置:

  • webpack.config.js 中的 sideEffects 用来开启这个功能;
  • package.json 中的 sideEffects 用来标识我们的代码没有副作用。

不管是 Tree-shaking 还是 sideEffects,我个人认为,它们都是为了弥补 JavaScript 早期在模块系统设计上的不足。随着 Webpack 这类技术的发展,JavaScript 的模块化确实越来越好用,也越来越合理。

除此之外,我还想说一点,在开发过程中应该意识到:尽可能不要写影响全局的副作用代码。

######Code Splitting(分块打包)

原因:All in One 的弊端,如果我们的应用非常复杂,模块非常多,那么这种 All in One 的方式就会导致打包的结果过大,甚至超过 4~5M。

在绝大多数的情况下,应用刚开始工作时,并不是所有的模块都是必需的。如果这些模块全部被打包到一起,即便应用只需要一两个模块工作,也必须先把 bundle.js 整体加载进来,而且前端应用一般都是运行在浏览器端,这也就意味着应用的响应速度会受到影响,也会浪费大量的流量和带宽。

更为合理的方案是把打包的结果按照一定的规则分离到多个 bundle 中,然后根据应用的运行需要按需加载。这样就可以降低启动成本,提高响应速度。

可能有同学会疑问,webpack不就是要把多个模块打包一起吗?现在又要拆开?原因很简单:Web 应用中的资源受环境所限,太大不行,太碎更不行。因为我们开发过程中划分模块的颗粒度一般都会非常的细,很多时候一个模块只是提供了一个小工具函数,并不能形成一个完整的功能单元。

而且,目前主流的 HTTP 1.1 本身就存在一些缺陷,例如:

  • 同一个域名下的并行请求是有限制的;
  • 每次请求本身都会有一定的延迟
  • 每次请求除了传输内容,还有额外的请求头,大量请求的情况下,这些请求头加在一起也会浪费流量和带宽。

实现方案:

  • 根据业务不同配置多个打包入口,输出多个打包结果;
  • 结合 ES Modules 的动态导入(Dynamic Imports)特性,按需加载模块。

多入口打包配置

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
// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: {
index: './src/index.js',
main: './src/main.js'
},
output: {
filename: '[name].bundle.js' // [name] 是入口名称
},
// ... 其他配置
plugins: [
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html',
chunks: ['index'] // 指定使用 index.bundle.js
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/main.html',
filename: 'main.html',
chunks: ['main'] // 指定使用 main.bundle.js
})
],
optimization: {
splitChunks: {
// 自动提取所有公共模块到单独 bundle
chunks: 'all'
}
}
}

动态导入

为了动态导入模块,可以将 import 关键字作为函数调用。当以这种方式使用时,import 函数返回一个 Promise 对象。这就是 ES Modules 标准中的 Dynamic Imports。

image-20200709121257273

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ./src/App.vue
export default {
components: {
Home,
},
data() {
return {
isShow: false,
}
},
methods: {
onToggle() {
this.isShow = !this.isShow
if (this.isShow) {
// 魔法注释,可以给打包后的js文件取名
import(/* webpackChunkName: 'indexVue' */'./components/index.vue').then(({default: data}) => console.log(data))
} else {
import(/* webpackChunkName: 'mainVue' */'./components/main.vue').then(({default: data}) => console.log(data))
}
}
},
}
</script>
优化Webpack的构建速度和打包结果

我们先为不同的工作环境创建不同的 Webpack 配置。创建不同环境配置的方式主要有两种:

  • 在配置文件中添加相应的判断条件,根据环境不同导出不同配置。
1
2
3
4
5
// 终端命令
// 项目打包
webpack --env=production
// 项目启动
webpack-dev-server --env=development
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// webpack.config.js
module.exports = (env, argv) => {
const config = {
// ... 不同模式下的公共配置
}

if (env === 'development') {
// 为 config 添加开发模式下的特殊配置
config.mode = 'development'
config.devtool = 'cheap-eval-module-source-map'
} else if (env === 'production') {
// 为 config 添加生产模式下的特殊配置
config.mode = 'production'
config.devtool = 'none'
}

return config
}
  • 为不同环境单独添加一个配置文件,一个环境对应一个配置文件。

一般在这种方式下,项目中最少会有三个 webpack 的配置文件。其中两个用来分别适配开发环境和生产环境,另外一个则是公共配置。因为开发环境和生产环境的配置并不是完全不同的,所以需要一个公共文件来抽象两者相同的配置。具体配置文件结构如下:

1
2
3
├── webpack.common.js ···························· 公共配置
├── webpack.dev.js ······························· 开发模式配置
└── webpack.prod.js ······························ 生产模式配置

合并对象,覆盖公共的配置对象我们可以使用Object.assign,或者使用 Lodash 提供的 merge 函数来实现,不过社区中提供了更为专业的模块 webpack-merge,它专门用来满足我们这里合并 Webpack 配置的需求。

我们可以先通过 npm 安装一下 webpack-merge 模块。具体命令如下:

1
2
$ npm i webpack-merge --save-dev 
$ yarn add webpack-merge --dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ./webpack.common.js
module.exports = {
// ... 公共配置
}
// ./webpack.prod.js
const merge = require('webpack-merge')
const common = require('./webpack.common')
module.exports = merge(common, {
// 生产模式配置
})
// ./webpack.dev.jss
const merge = require('webpack-merge')
const common = require('./webpack.common')
module.exports = merge(common, {
// 开发模式配置
})

分别配置完成过后,我们再次回到命令行终端,然后尝试运行 webpack 打包。不过因为这里已经没有默认的配置文件了,所以我们需要通过 –config 参数来指定我们所使用的配置文件路径。例如:

1
$ webpack --config webpack.prod.js

生产模式下的优化插件

  • Define Plugin:为代码中注入全局成员
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ./webpack.config.js
const webpack = require('webpack')
module.exports = {
// ... 其他配置
plugins: [
new webpack.DefinePlugin({
// 值要求的是一个代码片段(如果只是字符串的,调用就会变成变量报错)
API_BASE_URL: JSON.stringify('https://api.example.com')
})
]
}

// ./src/main.js
console.log(API_BASE_URL) // https://api.example.com
  • Mini CSS Extract Plugin:可以将 CSS 代码从打包结果中提取出来的插件
  • Optimize CSS Assets Webpack Plugin: 压缩CSS文件

CSS 体积不是很大的话,提取到单个文件中,效果可能适得其反,因为单独的文件就需要单独请求一次。个人认为是如果 CSS 超过200KB才需要考虑是否提取出来,作为单独的文件。

安装:

1
2
$ npm i mini-css-extract-plugin --save-dev
$ npm i optimize-css-assets-webpack-plugin --save-dev

使用:

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
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
module: {
rules: [
{
test: /\.css$/,
use: [
// 'style-loader', // 将样式通过 style 标签注入
MiniCssExtractPlugin.loader,//样式就会存放在独立的文件中,直接通过 link 标签引入页面, CSS样式不会压缩,只会压缩js代码
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin(),
new OptimizeCssAssetsWebpackPlugin() // 压缩css代码
]
}

优化压缩:

如果我们配置到 plugins 属性中,那么这个插件在任何情况下都会工作。而配置到 minimizer 中,就只会在 minimize 特性开启时才工作

需额外安装内置的 JS 压缩插件叫作 terser-webpack-plugin,因为我们设置了 minimizer,Webpack 认为我们需要使用自定义压缩器插件,那内部的 JS 压缩器就会被覆盖掉

1
$ npm i terser-webpack-plugin --save-dev

使用:

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
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')
module.exports = {
...
optimization: {
minimize: true, // 压缩功能需要开启,生产可不需要
minimizer: [
new TerserWebpackPlugin(),
new OptimizeCssAssetsWebpackPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}

其他打包工具

Rollup

Rollup 是一款 ES Modules 打包器。它也可以将项目中散落的细小模块打包为整块代码,从而使得这些划分的模块可以更好地运行在浏览器环境或者 Node.js 环境。它的初衷只是希望能够提供一个高效的 ES Modules 打包器,充分利用 ES Modules 的各项特性,构建出结构扁平,性能出众的类库。

安装

1
npm i rollup --save-dev

准备结构

1
2
3
4
5
6
.
├── src
│ ├── index.js
│ ├── logger.js
│ └── messages.js
└── package.json

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ./src/messages.js
export default {
hi: 'Hey Guys, I am zce~'
}
// ./src/logger.js
export const log = msg => {
console.log('---------- INFO ----------')
console.log(msg)
console.log('--------------------------')
}
export const error = msg => {
console.error('---------- ERROR ----------')
console.error(msg)
console.error('---------------------------')
}
// ./src/index.js
import { log } from './logger'
import messages from './messages'
log(messages.hi)

打包

1
npx rollup ./src/index.js --file ./dist/bundle.js

Rollup 默认会自动开启 Tree-shaking 优化输出结果,Tree-shaking 的概念最早也就是 Rollup 这个工具提出的。

我们也可以配置文件, 而不再命令行写

1
2
3
4
5
6
7
 .
├── src
│ ├── index.js
│ ├── logger.js
│ └── messages.js
├── package.json
+└── rollup.config.js

文件配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ./rollup.config.js
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es' // 输出格式
}
}

or

// ./rollup.config.js
// 所有 Rollup 支持的格式
const formats = ['es', 'amd', 'cjs', 'iife', 'umd', 'system']
export default formats.map(format => ({
input: 'src/index.js',
output: {
file: `dist/bundle.${format}.js`,
format
}
}))

执行命令

1
2
$ npx rollup --config # 使用默认配置文件
$ npx rollup --config rollup.prod.js # 指定配置文件路径

使用插件

Rollup 自身的功能就只是 ES Modules 模块的合并,如果有更高级的要求,例如加载其他类型的资源文件或者支持导入 CommonJS 模块,又或是编译 ES 新特性,这些额外的需求 Rollup 同样支持使用插件去扩展实现。

Webpack 中划分了 Loader、Plugin 和 Minimizer 三种扩展方式,而插件是 Rollup 的唯一的扩展方式。

这里我们先来尝试使用一个可以让我们在代码中导入 JSON 文件的插件:@rollup/plugin-json,通过这个过程来了解如何在 Rollup 中使用插件。

首先我们需要将 @rollup/plugin-json 作为项目的开发依赖安装进来。具体安装命令:

1
$ npm i @rollup/plugin-json --save-dev

安装完成过后,我们打开配置文件。由于 rollup 的配置文件中可以直接使用 ES Modules,所以我们这里使用 import 导入这个插件模块。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// ./rollup.config.js
import json from '@rollup/plugin-json'
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es'
},
plugins: [
json()
]
}

插件使用方法基本一致,再提两个功能插件:

1
2
3
4
5
6
7
// Rollup 默认只能够按照文件路径的方式加载本地的模块文件,该包直接通过模块名称直接导入
// 加载 NPM 模块
$ npm i @rollup/plugin-node-resolve --save-dev

// 目前大量的 NPM 模块还是使用 CommonJS 方式导出成员,所以为了兼容这些模块
// 加载 CommonJS 模块
$ npm i @rollup/plugin-commonjs --save-dev

Code Splitting

Rollup 的最新版本中已经开始支持代码拆分了。我们同样可以使用符合 ES Modules 标准的动态导入方式实现模块的按需加载。例如:

1
2
3
4
5
// ./src/index.js
// 动态导入的模块会自动分包
import('./logger').then(({ log }) => {
log('code splitting~')
})

在 Rollup 在分包过后会输出多个 JS 文件,需要我们在配置中指定输出的目录,而不是一个具体的文件名,具体配置如下:

1
2
3
4
5
6
7
8
9
// ./rollup.config.js
export default {
input: 'src/index.js',
output: {
// file: 'dist/bundle.js', // code splitting 输出的是多个文件
dir: 'dist',
format: 'es'
}
}

优缺点

Rollup 确实有它的优势:

  • 输出结果更加扁平,执行效率更高;
  • 自动移除未引用代码;
  • 打包结果依然完全可读。

但是它的缺点也同样明显:

  • 加载非 ESM 的第三方模块比较复杂;
  • 因为模块最终都被打包到全局中,所以无法实现 HMR;
  • 浏览器环境中,代码拆分功能必须使用 Require.js 这样的 AMD 库

总结一下:Webpack 大而全,Rollup 小而美。

个人感觉:应用开发使用 Webpack,类库或者框架开发使用 Rollup。

不过这并不是绝对的标准,只是经验法则。因为 Rollup 也可用于构建绝大多数应用程序,而 Webpack 同样也可以构建类库或者框架。

另外随着近几年 Webpack 的发展,Rollup 中的很多优势几乎已经抹平了,所以这种对比慢慢地也就没有太大意义了。

Parcel

Parcel 是一款完全零配置的前端打包器,它提供了 “傻瓜式” 的使用体验,我们只需了解它提供的几个简单的命令,就可以直接使用它去构建我们的前端应用程序了。

准备目录

1
2
3
4
5
6
.
├── src
│ ├── index.html
│ ├── logger.js
│ └── main.js
└── package.json

安装

1
$ npm install parcel-bundler --save-dev

打包

1
2
3
4
$ npx parcel src/index.html

// 生产模式打包
$ npx parcel build src/index.html

这里补充一点,相同体量的项目打包,Parcel 的构建速度会比 Webpack 快很多。因为 Parcel 内部使用的是多进程同时工作,充分发挥了多核 CPU 的性能。Webpack 中也可以使用一个叫作 happypack 的插件实现这一点。

虽然 Parcel 跟 Webpack 一样都支持以任意类型文件作为打包入口,不过 Parcel 官方还是建议我们使用 HTML 文件作为入口。官方的理由是 HTML 是应用在浏览器端运行时的入口。

模块热替换

1
2
3
4
5
6
7
8
9
// ./src/main.js
import { log } from './logger'
log('hello parcel')
// HMR API
if (module.hot) {
module.hot.accept(() => {
console.log('HMR~')
})
}

不过这里的 accept 方法与 Webpack 提供的 HMR 有点不太一样,Webpack 中的 accept 方法支持接收两个参数,用来处理指定的模块更新后的逻辑。

而这里 Parcel 提供的 accept 只需要接收一个回调参数,作用就是当前模块更新或者所依赖的模块更新过后自动执行传入的回调函数,这相比于之前 Webpack 中的用法要简单很多。

自动安装依赖

在文件保存过后,Parcel 会自动去安装刚刚导入的模块包,极大程度地避免手动操作。

动态导入(code splitting)

Parcel 同样支持直接使用动态导入,内部也会自动处理代码拆分

核心特点

  • 真正做到了完全零配置,对项目没有任何的侵入;
  • 自动安装依赖,开发过程更专注;
  • 构建速度更快,因为内部使用了多进程同时工作,能够充分发挥多核 CPU 的效率。

但是目前看来,如果你去观察开发者的实际使用情况,绝大多数项目的打包还是会选择 Webpack。个人认为原因有两点:

  • Webpack 生态更好,扩展更丰富,出现问题容易解决;
  • 随着这两年的发展,Webpack 越来越好用,开发者也越来越熟悉。

bind、call和apply原理

bind、call和apply原理

基本使用

bind

1
2
3
func.bind(thisArg[, arg1[, arg2[, ...]]])
thisArg 当绑定函数被调用时,该参数会作为原函数运行时的this指向。当使用new 操作符调用绑定函数时,该参数无效。
arg1, arg2, ... 当绑定函数被调用时,这些参数将置于实参之前传递给被绑定的方法。

call

1
2
3
func.call(thisArg, arg1, arg2, ...)
thisArg::在fun函数运行时指定的this值。需要注意的是,指定的this值并不一定是该函数执行时真正的this值,如果这个函数处于非严格模式下,则指定为nullundefinedthis值会自动指向全局对象(浏览器中就是window对象),同时值为原始值(数字,字符串,布尔值)的this会指向该原始值的自动包装对象。
arg1, arg2, ... 指定的参数列表。

apply

1
2
3
func.apply(thisArg, [argsArray])
thisArg: 在 fun 函数运行时指定的 this 值。需要注意的是,指定的 this 值并不一定是该函数执行时真正的 this 值,如果这个函数处于非严格模式下,则指定为 nullundefined 时会自动指向全局对象(浏览器中就是window对象),同时值为原始值(数字,字符串,布尔值)的 this 会指向该原始值的自动包装对象。
argsArray: 一个数组或者类数组对象,其中的数组元素将作为单独的参数传给 fun 函数。如果该参数的值为nullundefined,则表示不需要传入任何参数。从ECMAScript 5 开始可以使用类数组对象。

原理实现

bind

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ES5
Function.prototype.myBind = function() {
var _this = this;
var context = [].shift.call(arguments) || window// 保存需要绑定的this上下文
var args = [].slice.call(arguments); //剩下参数转为数组,预设参数

return function() {
//预设参数与传入参数拼接
return _this.apply(context, [].concat.call(args, [].slice.call(arguments)));
}
};

// ES6
Function.prototype.myBind = function(ctx, ...args) {
const _this = this
const context = ctx || window // 保存需要绑定的this上下文
return function() {
return _this.apply(context, [].concat.call(args, Array.from(arguments))
}
};

call

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
// ES5
Function.prototype.myCall = function() {
var context = [].shift.call(arguments);// 保存需要绑定的this上下文
var args = [].slice.call(arguments); //剩下参数转为数组
context.fn = this // 保存外部的函数fn
//var result = context.fn(...args) // 隐式绑定 调用的外部的fn
//代替...扩展符
var argsVarStr = []
for(var i = 0; i < args.length; i++){
argsVarStr.push("args[" + i +"]")
}
var result = eval("context.fn(" + argsVarStr.toString() +")")
delete context.fn // 删除新增属性fn
return result
}

var a = 1

function fn() {
console.log(this.a); // 2
}
var obj = {
a: 2
}
// 调用自己的call2方法
fn.myCall(obj)

// ES6
Function.prototype.myCall = function(ctx, ...args) {
const context = ctx || window
context.fn = this
const result = context.fn(...args)
delete context.fn
return result
};

apply

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
/**
* ES5
* apply函数传入的是this指向和参数数组
*/
Function.prototype.myApply = function(ctx, arr) {
var context = ctx || window;
context.fn = this;
if (arr instanceof Array) {
return new Error('第二个参数需要是数组')
}
var args = [];
for (var i=0,len=arr.length;i<len;i++) {
args.push("arr[" + i + "]");
}
var result = eval("context.fn(" + args.toString() + ")");

//将this指向销毁
delete context.fn;
return result;
}

// ES6
Function.prototype.myApply = function(ctx, arr) {
const context = ctx || window
const context.fn = this
if(Object.prototype.toString.call(arr) !== '[object Array]'){
return new Error('第二个参数需要是数组')
}

const result = context.fn(...arr)
delete context.fn
return result
}

常用的JS开发技巧(II)

目录

  • 判断当前环境是否是手机端
  • 断当前环境是否是微信环境
  • 检测浏览器是否放大
  • 获取普通地址url参数
  • 获取hash模式地址url参数
  • 时间戳转换为目标格式
  • 时间戳距离现在多久以前
  • 生成任意位数随机数(数字)
  • 随机生成一个自定义长度,不重复的字母加数字组合,可用来做id标识
  • js数组去重(复杂数据有ID的情况下)
  • 浅拷贝
  • 深拷贝
  • Promise方式封装的Ajax函数
  • js浮点数计算加减乘除精度损失解决方法
  • 防抖 (debounce)
  • 节流(throttle)
  • 文件大小换算成单位
  • 将一个字符串复制到剪贴板
  • 平滑滚动到页面顶部

如果以上工具函数还未能解你的需求,可以移步查阅JavaScript工具函数大全JavaScript 工具函数大全(新)

函数实现

1.判断当前环境是否是手机端

1
2
3
4
5
6
7
8
9
10
11
/**
* 判断当前环境是否是手机端
* @return {Boolean} 返回结果
*/
export const isMobile=() =>{
if(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
return true
} else {
return false
}
}

2.断当前环境是否是微信环境

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 断当前环境是否是微信环境
* @return {Boolean} 返回结果
*/
export const isWeixin =() =>{
const ua = navigator.userAgent.toLowerCase();
if(ua.match(/MicroMessenger/i)==="micromessenger") {
return true;
} else {
return false;
}
}

3.检测浏览器是否放大

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 检测浏览器是否放大
* @param {Boolean } rsize 是否返回具体放大数值,默认否
* @return {Boolean | Number} 返回结果
*/
export const detectZoom=rsize =>{
let ratio = 0
const screen = window.screen
const ua = navigator.userAgent.toLowerCase()

if (window.devicePixelRatio) {
ratio = window.devicePixelRatio
} else if (~ua.indexOf('msie')) {
if (screen.deviceXDPI && screen.logicalXDPI) ratio = screen.deviceXDPI / screen.logicalXDPI
} else if (window.outerWidth&& window.innerWidth) {
ratio = window.outerWidth / window.innerWidth
}

if (ratio) ratio = Math.round(ratio * 100)

return rsize ? ratio : ratio === 100
}

4.获取普通地址url参数

1
2
3
4
5
6
7
8
9
10
11
/**
* 获取普通地址url参数
* 例如:http://localhost:8080/?token=rTyJ7bcRb7KU4DMcWo4216&roleId=512213631174180864
* @param {String} name
* @return {Boolean | String} 返回获取值
*/
export const getUrlParam = name =>{
const reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
const r = window.location.search.substr(1).match(reg);
if (r != null) return decodeURI(r[2]); return false;
}

5.获取hash模式地址url参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 获取hash模式地址url参数
* 例如:http://localhost:8080/#/?token=rTyJ7bcRb7KU4DMcWo4216&roleId=512213631174180864
* @param {String} name
* @return {Boolean | String} 返回获取值
*/
export const getUrlHashParam =name =>{
const w = window.location.hash.indexOf("?");
const query = window.location.hash.substring(w + 1);
const vars = query.split("&");
for (let i = 0; i < vars.length; i++) {
const pair = vars[i].split("=");
if (pair[0] == name) {
return pair[1];
}
}

return false;
}

6.时间戳转换为目标格式

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
/**
* 时间戳转换
* @param {Number} date 时间戳
* @param {String} fmt 时间显示格式,例如 yyyy-MM-dd HH:mm:ss
* @return {String} fmt 返回转换后的时间 ,formatDate(value, "yyyy-MM-dd hh: mm : ss")
*/
export const formatDate = (date, fmt) => {
date = new Date(date);
if (isNaN(date.getDate())) return date;
if (/(y+)/.test(fmt)) {
fmt = fmt.replace(
RegExp.$1,
(date.getFullYear() + "").substr(4 - RegExp.$1.length)
);
}
let o = {
"M+": date.getMonth() + 1,
"d+": date.getDate(),
"h+": date.getHours(),
"m+": date.getMinutes(),
"s+": date.getSeconds()
};
for (let k in o) {
if (new RegExp(`(${k})`).test(fmt)) {
let str = o[k] + "";
fmt = fmt.replace(
RegExp.$1,
RegExp.$1.length === 1 ? str : ("00" + str).substr(str.length)
);
}
}
return fmt;
};

7.时间戳距离现在多久以前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 时间戳转换成什么之前
* @param {Number} times 时间戳
* @return {String} 返回结果,timeAgoLabel(1606273724459) 输出:刚刚
*/
export const timeAgoLabel = times => {
let nowTimes = new Date().getTime()
let diffSecond = (nowTimes - times) / 1000
let agoLabel = ''
if (diffSecond < 60) {
agoLabel = '刚刚'
} else if (diffSecond < 60 * 60) {
agoLabel = Math.floor(diffSecond / 60) + '分钟前'
} else if (diffSecond < 60 * 60 * 24) {
agoLabel = Math.floor(diffSecond / 3600) + '小时前'
} else if (diffSecond < 60 * 60 * 24 * 30) {
agoLabel = Math.floor(diffSecond / (3600 * 24)) + '天前'
} else if (diffSecond < 3600 * 24 * 30 * 12) {
agoLabel = Math.floor(diffSecond / (3600 * 24 * 30)) + '月前'
} else {
agoLabel = Math.floor(diffSecond / (3600 * 24 * 30 * 12)) + '年前'
}
return agoLabel
}

8.生成任意位数随机数(数字)

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 生成任意位数随机数(数字)
* @param {Number} n 可选长度位数
* @return {Number} 返回随机值
*/
export const randomNumber =n =>{
let rnd = '';
for (let i = 0; i < n; i++) {
rnd += Math.floor(Math.random() * 10);
}
return rnd;
}

9.随机生成一个自定义长度,不重复的字母加数字组合,可用来做id标识

1
2
3
4
5
6
7
8
9
/**
* 随机生成一个自定义长度,不重复的字母加数字组合,可用来做id标识
* @param {Number} randomLength 可选长度位数,默认10
* @return {String} 返回随机值
*/
export const randomId =(randomLength = 10) =>{
return Number(Math.random().toString().substr(3,randomLength) + Date.now()).toString(36)
},
复制代码

10.js数组去重(复杂数据有ID的情况下)

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
 /**
* js数组去重(复杂数据有ID的情况下)
* 方式一(hash)
* @param {Array} repeatArray 含重复数据的数组
* @return {Array} 返回去重后的数据
*/
export const noRepeatArrayHash= repeatArray =>{
const hash = {};
const temp = [];
for (let i = 0; i < repeatArray.length; i++) {
if (!hash[repeatArray[i].id]) {
hash[repeatArray[i].id] = true;
temp.push(repeatArray[i]);
}
}

return temp;
}

/**
* js数组去重(复杂数据有ID的情况下)
* 方式二(hash + reduce)
* @param {Array} repeatArray 含重复数据的数组
* @return {Array} 返回去重后的数据
*/
export const noRepeatArrayReduce= repeatArray =>{
const hash = {};
return repeatArray.reduce(function(accumulator, currentValue){
if(!hash[currentValue.id]){
hash[currentValue.id]=true;
accumulator.push(currentValue)
}

return accumulator

}, []);
}

11.浅拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 浅拷贝
* @param {Array | Object} objOrArr 要拷贝的对象或数组
* @return {Array | Object} 返回拷贝结果
*/
export const shallowCopy = objOrArr =>{
const type = objOrArr instanceof Array ? 'array' : 'object'
let newObjOrArr = objOrArr instanceof Array ? [] : {}
if(type === 'array'){
newObjOrArr=[].concat(objOrArr)
}else{
for(let key in objOrArr){
if(objOrArr.hasOwnProperty(key)){
newObjOrArr[key]= objOrArr[key]
}
}
}

return newObjOrArr
}

12.深拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 深拷贝
* @param {Array | Object} objOrArr 要拷贝的对象或数组
* @return {Array | Object} 返回拷贝结果
*/
export const deepCopy= objOrArr => {
const type = objOrArr instanceof Array ? 'array' : 'object'
let newObjOrArr = objOrArr instanceof Array ? [] : {}
if (type === 'array') {
newObjOrArr = JSON.parse(JSON.stringify(objOrArr))
} else {
for (let key in objOrArr) {
if (objOrArr.hasOwnProperty(key)) {
newObjOrArr[key] = typeof objOrArr[key] === 'object' ? deepCopy(objOrArr[key]) : objOrArr[key]
}
}
}

return newObjOrArr
}

13.Promise方式封装的Ajax函数

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
/**
* promise方式封装的ajax函数
* @param {String} method 请求方式
* @param {String} url 请求地址
* @param {Object} params 请求参数
*/
export const ajax=(method,url, params) =>{
//兼容IE
const request= window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP")
return new Promise(function(resolve,reject){
request.onreadystatechange=function(){
if(request.readyState===4){
if(request.status===200){
resolve(JSON.parse(request.response));
}else{
reject(request.status);
}
}
};
if(method.toUpperCase() === "GET"){
const arr = [];
for(let key in params){
arr.push(key + '=' + params[key]);
}
const getData=arr.join("&");
request.open("GET",url +"?"+getData,true);
request.send(null);
}else if(method.toUpperCase() === "POST"){
request.open("POST",url,true);
request.responseType="json";
request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded;charset=utf-8');
request.send(params);

}

})

}

14.js浮点数计算加减乘除精度损失解决方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
* js浮点数计算加减乘除精度损失解决方法
* @param {Number} a 数值a
* @param {Number} b 数值b
* @param {String} computeType 加减乘除类型 add加 subtract减 multiply乘 divide除
* @return {Number} 返回计算结果,floatNumber(0.11, 0.03, 'add')
*/
export const floatNumber = (a, b, computeType) =>{
const isInteger= obj =>{
return Math.floor(obj) === obj
}
const toInteger= floatNum =>{
const ret = {times: 1, num: 0}
if (isInteger(floatNum)) {
ret.num = floatNum
return ret
}
const strfi = floatNum + ''
const dotPos = strfi.indexOf('.')
const len = strfi.substr(dotPos+1).length
const times = Math.pow(10, len)
const intNum = parseInt(floatNum * times + 0.5, 10)
ret.times = times
ret.num = intNum
return ret
}
const operation=(a, b, computeType) =>{
const o1 = toInteger(a)
const o2 = toInteger(b)
const n1 = o1.num
const n2 = o2.num
const t1 = o1.times
const t2 = o2.times
const max = t1 > t2 ? t1 : t2
let result = null
switch (computeType) {
case 'add':
if (t1 === t2) { // 两个小数位数相同
result = n1 + n2
} else if (t1 > t2) { // o1 小数位 大于 o2
result = n1 + n2 * (t1 / t2)
} else { // o1 小数位 小于 o2
result = n1 * (t2 / t1) + n2
}
return result / max
case 'subtract':
if (t1 === t2) {
result = n1 - n2
} else if (t1 > t2) {
result = n1 - n2 * (t1 / t2)
} else {
result = n1 * (t2 / t1) - n2
}
return result / max
case 'multiply':
result = (n1 * n2) / (t1 * t2)
return result
case 'divide':
result = (n1 / n2) * (t2 / t1)
return result
}

}

return operation(a, b, computeType)
}

15.防抖 (debounce)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 防抖 (debounce)将多次高频操作优化为只在最后一次执行
* @param {Function} fn 需要防抖函数
* @param {Number} wait 需要延迟的毫秒数
* @param {Boolean} immediate 可选参,设为true,debounce会在wait时间间隔的开始时立即调用这个函数
* @return {Function}
*/
export const debounce= (fn, wait, immediate) =>{
let timer = null

return function() {
let args = arguments
let context = this

if (immediate && !timer) {
fn.apply(context, args)
}

if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(context, args)
}, wait)
}
}

16.节流(throttle)

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
/**
* 节流(throttle)将高频操作优化成低频操作,每隔 100~500 ms执行一次即可
* @param {Function} fn 需要防抖函数
* @param {Number} wait 需要延迟的毫秒数
* @param {Boolean} immediate 可选参立即执行,设为true,debounce会在wait时间间隔的开始时立即调用这个函数
* @return {Function}
*/
export const throttle =(fn, wait, immediate) =>{
let timer = null
let callNow = immediate

return function() {
let context = this,
args = arguments

if (callNow) {
fn.apply(context, args)
callNow = false
}

if (!timer) {
timer = setTimeout(() => {
fn.apply(context, args)
timer = null
}, wait)
}
}
}

17.文件大小换算成单位

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
/** 
* 文件大小换算成单位
* @param {Number} bytes 大小
* @param {String} units 可选单位,默认metric
* @param {Number} precision 可选位数,数值精度保留几位小数点,默认1
* @return {String} 返回带单位值,byteSize(1580),输出1.6 kB
*/
export const byteSize = (bytes, units='metric', precision=1) => {
let value='',
unit=''
const base = units === 'metric' || units === 'metric_octet' ? 1000 : 1024
const table = [
{ expFrom: 0, expTo: 1, metric: 'B', iec: 'B', metric_octet: 'o', iec_octet: 'o' },
{ expFrom: 1, expTo: 2, metric: 'kB', iec: 'KiB', metric_octet: 'ko', iec_octet: 'Kio' },
{ expFrom: 2, expTo: 3, metric: 'MB', iec: 'MiB', metric_octet: 'Mo', iec_octet: 'Mio' },
{ expFrom: 3, expTo: 4, metric: 'GB', iec: 'GiB', metric_octet: 'Go', iec_octet: 'Gio' },
{ expFrom: 4, expTo: 5, metric: 'TB', iec: 'TiB', metric_octet: 'To', iec_octet: 'Tio' },
{ expFrom: 5, expTo: 6, metric: 'PB', iec: 'PiB', metric_octet: 'Po', iec_octet: 'Pio' },
{ expFrom: 6, expTo: 7, metric: 'EB', iec: 'EiB', metric_octet: 'Eo', iec_octet: 'Eio' },
{ expFrom: 7, expTo: 8, metric: 'ZB', iec: 'ZiB', metric_octet: 'Zo', iec_octet: 'Zio' },
{ expFrom: 8, expTo: 9, metric: 'YB', iec: 'YiB', metric_octet: 'Yo', iec_octet: 'Yio' }
]

for (let i = 0; i < table.length; i++) {
const lower = Math.pow(base, table[i].expFrom)
const upper = Math.pow(base, table[i].expTo)
if (bytes >= lower && bytes < upper) {
const retUnit = table[i][units]
if (i === 0) {
value = String(bytes)
unit = retUnit
break;
} else {
value = (bytes / lower).toFixed(precision)
unit = retUnit
break;
}
}
}
return `${value} ${unit}`.trim()
}

18.将一个字符串复制到剪贴板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 将一个字符串复制到剪贴板
* @param {String} str 复制的内容
* @return {String} 直接粘贴, copyToClipboard('将一个字符串复制到剪贴板')
*/
export const copyToClipboard = str => {
const el = document.createElement('textarea');
el.value = str;
el.setAttribute('readonly', '');
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
const selected =document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false;
el.select();
document.execCommand('copy');
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
}
}

19.平滑滚动到页面顶部

1
2
3
4
5
6
7
8
9
10
/**
* 平滑滚动到页面顶部
*/
export const scrollToTop = () => {
const c = document.documentElement.scrollTop || document.body.scrollTop;
if (c > 0) {
window.requestAnimationFrame(scrollToTop);
window.scrollTo(0, c - c / 8);
}
}