注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

与大厂面试官的高端博弈、顶级拉扯

前言 最近是跳槽季,发现有小伙伴在一些非技术的软性问题上答的不是很好。 众所周知,程序员情商偏低,而这些软性问题,恰恰都具有一定欺骗性和吹牛皮成分在里边,对于演技不好的直男癌,简直就是天生克星。 其实不用太担心,软性问题往往就那几个,稍加训练和准备,你就可以成...
继续阅读 »

前言


最近是跳槽季,发现有小伙伴在一些非技术的软性问题上答的不是很好。


众所周知,程序员情商偏低,而这些软性问题,恰恰都具有一定欺骗性和吹牛皮成分在里边,对于演技不好的直男癌,简直就是天生克星。


其实不用太担心,软性问题往往就那几个,稍加训练和准备,你就可以成为一位高端名猿。


题目


第 1 题:说一下你自己的缺点


演技考验:4星


这题处处暗藏杀鸡,很多小伙伴会推心置腹,诚实的将自己所有缺点说出来,比如:“我脾气不好”、“我学习东西慢”、“我贪财好.色”。


这种缺点,人人都有,但大可不必说出来。


很多小伙伴说,我就要做real man,你爱喜欢不喜欢。外面的那些人都是虚伪装哔的妖艳剑货,我就要做真实的自己。


其实,这根本不是真实的自己,这是情商低。不懂得逢场作戏,不懂得在特定的场合说正确的话。


所以,如何说一个缺点,但又不完全是缺点的缺点,还能让人觉得这是优点的缺点


你应该这么演:


唉声叹气,微笑、头部倾斜45度看向地面,说:我偶尔会因为专研技术问题,而搞到深夜,把自己弄得很累。今后我会多注意,把控好技术学习和工作状态的平衡。


这里几个细节注意以下



  • “头部倾斜45度看向地面”从行为学上让人感觉你没有架子,谦虚好相处。

  • 回答中多次用了“搞”、“弄”、“累”等动词,让人觉得你不做作,接地气。

  • 我说我的缺点是因为太爱学习,就好像是说,我的缺点是因为我太有钱,这根本不是缺点,而是优点,只是换了一种说法,直接上天日龙


这就是传说中的反套路学,程序猿和面试官之间的高端博弈,顶级拉扯


如果你有更好的答案或想法,欢迎在题目对应的github下留言:第一名的小蝌蚪


第 2 题:你对加班什么看法?


演技考验:3星


这题真的是炒鸡容易被问到。首先,没人有会喜欢加班,但是,但凡出现“敏捷开发”、“谈加班看法”的,大概率都是经常加班的公司。


我觉得只要不是业界那几个著名黑工厂,正常的加班,都是可以接受的


在大厂工作了8年,总结了一下导致我加班的几个原因:



  • 不爱思考

  • 能力不行

  • 贪玩好.色


  • 第五点不能说,懂的都懂


这8年里,我对加班的心态,从早期的抵触,到中期的忍耐,到现在的主动,我发现,你越不想加班,加班就越来找你,还不如主动进攻,主动学习技术,主动思考工作中优化点。


将重复性工作,通过开发一些工程化、自动化工具去代替,慢慢的你会发现,工作会事半功倍,不再那么被动,真的比被按住头逼你加班要好受很多。


撸迅曾说过:“工作就像强J,与其反抗,不如闭上眼睛好好享受”,很悲哀,但也是中年屌丝生存之道


所以,这道题有一个很不错的答案:
如果是工作需要我会义不容辞加班。但同时,我也会思考工作中的优化点,将重复性工作,通过开发一些工程化、自动化工具去代替,提高工作效率,减少不必要的加班


如果你有更好的答案或想法,欢迎在这题目对应的github下留言:第一名的小蝌蚪


第 3 题:你为什么从上家离职


演技考验:2星


有几个注意点,在说离职原因的时候,有几个大忌:



  • 不能说你跟上家leader闹掰

  • 不能说自己是被开除的

  • 不能说上家公司黄了,所以我跳槽了


反正千万记住,不要说任何上家公司和leader的坏话。


正确的表演应该是,不管上家对你怎么样,都要一副感恩戴德的态度去展示给面试官。是因为某种不可抗拒因素所以才导致你离职


有一个常规回答:上家公司平台趋于稳定,想到一家更大的平台去开阔视野,更好的展现自己的实力,让自己创造更大的价值


见过几个比较搞笑的回答:“我老婆要生了,想去一家能准点下班的公司”、“我失恋了,想去一个地方重新开始”,嘻嘻,但老铁还是别这么回答就好


如果你有更好的答案或想法,欢迎在这题目对应的github下留言:第一名的小蝌蚪


第 4 题:面对hr和面试官刁难,如何应对


演技考验:5星


这个情况是我真实遇到的,我职业生涯第二家公司(某知名大厂),就被当时的hr刁难了


她当时很高傲,还很藐视我,说过几句话让我印象深刻



  • 你背景很一般啊,上家公司是个小公司,我们一般只要有大厂经历的候选人

  • 我们都招985的研究生,你的综合素质没有我想象的好

  • 你简历里面写的那几个项目经历,都太一般了,没有任何知名度,你凭什么觉得我们会要你呢?


当时面完以后,我其实很生气,觉得那个hr人品有问题,但从头到尾都忍住了。


最后诡异的是。。。我还拿到了offer,拿到了期望的薪资和职级。。。。。


很久以后问过她为什么要这么面试候选人


她的回答,大意是:“hr一般这么刁难你,都是想压你的气势,让你受挫,让你觉得配不上我们,增加你接收offer的成功率,还能增加将来谈判薪资的主动权。”


这绝对是一种对人性心理的操控了。。。。


第二点,尤为重要“测试你的情商,那些随便刺激一下,就暴怒、就疯狂反驳、情绪失控的候选人,是绝对不能要的。能全程忍下来,且不卑不亢,保持自信的人,证明了你的抗压能力,确保你今后在工作中能处理好各种工作关系和极端情况”


她的回答令我醍醐灌顶,可能是代码敲多了,从来没有想过,原来人性也可以和代码一样,被度量,被证明


从那以后,对大厂的hr专业度,还是挺认可的


当然,这种刁难也可能是pua的早期萌芽,细思极恐。。。


所以针对候选人在面试过程中被刁难情况,应该如何表现和回答呢?


大家可以先自行思考一下


我把答案放在了github中,稍后公布,如果你有更好的想法,也可以给我留言:第一名的小蝌蚪


总结


以上4题,揭露了些面试中的套路和反套路,展示了逢场作戏和演技的技巧,很多人可能会觉得这样很虚伪


这里又要引用撸迅的一句名言:“当混浊变成一种常态,清白也会是一种罪行”


虚伪的人,有时候也是一种自我保护。


也许这并不虚伪,而是一种生存法则,因为不这么做,就会被这么做的人弄si。


希望大家针对上面提出的问题,和对应的答案,触发一些思考,总结自身,完善自己。不仅仅是面试,在工作也是要这样。


由于篇幅限制,下期会公布另外几道软性问题的答案:



  • 1.职场上,你的技术方案和同事不合,如何处理?

  • 2.如果你的方案和领导不合,如何处理?

  • 3.你未来五年的规划是什么

  • 4.你如何看待ppt文化

  • 5.你的入职,能给我们带来什么价值



链接:https://juejin.cn/post/6990533805786431524

收起阅读 »

vuepress的使用

快速上手 前提条件 VuePress 需要 Node.js (opens new window)>= 8.6 1.安装vuepress yarn add -D vuepress # npm install -D vuepress 2.创建你的第一篇文...
继续阅读 »

快速上手



前提条件


VuePress 需要 Node.js (opens new window)>= 8.6



1.安装vuepress


yarn add -D vuepress # npm install -D vuepress

2.创建你的第一篇文档


mkdir docs && echo '# Hello VuePress' > docs/README.md

3.在 package.json 中添加 scripts


{
"scripts": {
  "docs:dev": "vuepress dev docs",
  "docs:build": "vuepress build docs"
}
}

4.在本地启动服务器


yarn docs:dev # npm run docs:dev

VuePress 会在 http://localhost:8080启动一个热重载的开发服务器。


目录结构



  • docs/.vuepress: 用于存放全局的配置、组件、静态资源等。

  • docs/.vuepress/components: 该目录中的 Vue 组件将会被自动注册为全局组件。

  • docs/.vuepress/theme: 用于存放本地主题。

  • docs/.vuepress/styles: 用于存放样式相关的文件。

  • docs/.vuepress/styles/index.styl: 将会被自动应用的全局样式文件,会生成在最终的 CSS 文件结尾,具有比默认样式更高的优先级。

  • docs/.vuepress/styles/palette.styl: 用于重写默认颜色常量,或者设置新的 stylus 颜色常量。

  • docs/.vuepress/public: 静态资源目录。

  • docs/.vuepress/templates: 存储 HTML 模板文件。

  • docs/.vuepress/templates/dev.html: 用于开发环境的 HTML 模板文件。

  • docs/.vuepress/templates/ssr.html: 构建时基于 Vue SSR 的 HTML 模板文件。

  • docs/.vuepress/config.js: 配置文件的入口文件,也可以是 YMLtoml

  • docs/.vuepress/enhanceApp.js: 客户端应用的增强。


默认的页面路由























文件的相对路径页面路由地址
/README.md/
/guide/README.md/guide/
/guide/config.md/guide/config.html

主题配置


首页


默认的主题提供了一个首页(Homepage)的布局 (用于 这个网站的主页)。想要使用它,需要在你的根级 README.md 中指定 home: true。以下是一个如何使用的例子:


---
home: true
heroImage: /hero.png
heroText: Hero 标题
tagline: Hero 副标题
actionText: 快速上手 →
actionLink: /zh/guide/
features:
- title: 简洁至上
details: 以 Markdown 为中心的项目结构,以最少的配置帮助你专注于写作。
- title: Vue驱动
details: 享受 Vue + webpack 的开发体验,在 Markdown 中使用 Vue 组件,同时可以使用 Vue 来开发自定义主题。
- title: 高性能
details: VuePress 为每个页面预渲染生成静态的 HTML,同时在页面被加载的时候,将作为 SPA 运行。
footer: MIT Licensed | Copyright © 2018-present Evan You
---

更多配置项


导航栏


导航栏可能包含你的页面标题、搜索框导航栏链接多语言切换仓库链接,它们均取决于你的配置。


导航栏 Logo


// .vuepress/config.js
module.exports = {
themeConfig: {
logo: '/assets/img/logo.png',
}
}

导航栏链接


// .vuepress/config.js
module.exports = {
themeConfig: {
nav: [
{ text: 'Home', link: '/' },
{ text: 'Guide', link: '/guide/' },
{ text: 'External', link: 'https://google.com' },
{ text: 'test', link: 'test', target:'_self', rel:'' }
]
}
}

设置分组


// .vuepress/config.js
module.exports = {
themeConfig: {
nav: [
{
text: 'Languages',
items: [
{ text: 'Chinese', link: '/language/chinese/' },
{ text: 'Japanese', link: '/language/japanese/' }
]
}
]
}
}

侧边栏


想要使 侧边栏(Sidebar)生效,需要配置 themeConfig.sidebar,基本的配置,需要一个包含了多个链接的数组:


// .vuepress/config.js
module.exports = {
themeConfig: {
sidebar: [
'/',
'/page-a',
['/page-b', 'Explicit link text']
]
}
}

省略 .md 拓展名,同时以 / 结尾的路径将会被视为 */README.md,这个链接的文字将会被自动获取到


嵌套的标题链接


默认情况下,侧边栏会自动地显示由当前页面的标题(headers)组成的链接,并按照页面本身的结构进行嵌套,你可以通过 themeConfig.sidebarDepth 来修改它的行为。默认的深度是 1,它将提取到 h2 的标题,设置成 0 将会禁用标题(headers)链接,同时,最大的深度为 2,它将同时提取 h2h3 标题。


显示所有页面的标题链接


// .vuepress/config.js
module.exports = {
themeConfig: {
displayAllHeaders: true // 默认值:false
}
}

活动的标题链接


默认情况下,当用户通过滚动查看页面的不同部分时,嵌套的标题链接和 URL 中的 Hash 值会实时更新,这个行为可以通过以下的配置来禁用:


// .vuepress/config.js
module.exports = {
themeConfig: {
activeHeaderLinks: false, // 默认值:true
}
}

侧边栏分组


// .vuepress/config.js
module.exports = {
themeConfig: {
sidebar: [
{
title: 'Group 1', // 必要的
path: '/foo/', // 可选的, 标题的跳转链接,应为绝对路径且必须存在
collapsable: false, // 可选的, 默认值是 true,
sidebarDepth: 1, // 可选的, 默认值是 1
children: [
'/'
]
},
{
title: 'Group 2',
children: [ /* ... */ ],
initialOpenGroupIndex: -1 // 可选的, 默认值是 0
}
]
}
}

侧边栏的每个子组默认是可折叠的,你可以设置 collapsable: false 来让一个组永远都是展开状态。


多个侧边栏


.
├─ README.md
├─ contact.md
├─ about.md
├─ foo/
│ ├─ README.md
│ ├─ one.md
│ └─ two.md
└─ bar/
├─ README.md
├─ three.md
└─ four.md

注意


确保 fallback 侧边栏被最后定义。VuePress 会按顺序遍历侧边栏配置来寻找匹配的配置


搜索框


内置搜索


你可以通过设置 themeConfig.search: false 来禁用默认的搜索框,或是通过 themeConfig.searchMaxSuggestions 来调整默认搜索框显示的搜索结果数量


// .vuepress/config.js
module.exports = {
themeConfig: {
search: false,
searchMaxSuggestions: 10
}
}

最后更新时间


你可以通过 themeConfig.lastUpdated 选项来获取每个文件最后一次 git 提交的 UNIX 时间戳(ms),同时它将以合适的日期格式显示在每一页的底部:


// .vuepress/config.js
module.exports = {
themeConfig: {
lastUpdated: 'Last Updated', // string | boolean
}
}

上 / 下一篇链接


上一篇和下一篇文章的链接将会自动地根据当前页面的侧边栏的顺序来获取。


你可以通过 themeConfig.nextLinksthemeConfig.prevLinks 来全局禁用它们:


// .vuepress/config.js
module.exports = {
themeConfig: {
// 默认值是 true 。设置为 false 来禁用所有页面的 下一篇 链接
nextLinks: false,
// 默认值是 true 。设置为 false 来禁用所有页面的 上一篇 链接
prevLinks: false
}
}

Git 仓库和编辑链接


当你提供了 themeConfig.repo 选项,将会自动在每个页面的导航栏生成生成一个 GitHub 链接,以及在页面的底部生成一个 "Edit this page" 链接。


// .vuepress/config.js
module.exports = {
themeConfig: {
// 假定是 GitHub. 同时也可以是一个完整的 GitLab URL
repo: 'vuejs/vuepress',
// 自定义仓库链接文字。默认从 `themeConfig.repo` 中自动推断为
// "GitHub"/"GitLab"/"Bitbucket" 其中之一,或是 "Source"。
repoLabel: '查看源码',
// 以下为可选的编辑链接选项
// 假如你的文档仓库和项目本身不在一个仓库:
docsRepo: 'vuejs/vuepress',
// 假如文档不是放在仓库的根目录下:
docsDir: 'docs',
// 假如文档放在一个特定的分支下:
docsBranch: 'master',
// 默认是 false, 设置为 true 来启用
editLinks: true,
// 默认为 "Edit this page"
editLinkText: '帮助我们改善此页面!'
}
}

静态资源


静态资源都存放在public文件下


图片的使用


<img :src="$withBase('/frontend/prototype-chains.jpg')" alt="prototype-chains">


logo


// .vuepress/config.js
module.exports = {
themeConfig: {
logo: '/logo.png',
}
}

首页logo


// README.md
---
heroImage: /app.png
---

Markdown扩展


Emoji


你可以在这个列表 找到所有可用的 Emoji。


自定义容器




::: warning
这是一个警告
:::
::: danger
这是一个危险警告
:::
::: details
这是一个详情块,在 IE / Edge 中不生效
:::

你也可以自定义块中的标题:


::: danger STOP
危险区域,禁止通行
:::
::: details 点击查看代码
这是代码
:::

代码块中的语法高亮


VuePress 使用了 Prism 来为 markdown 中的代码块实现语法高亮。Prism 支持大量的编程语言,你需要做的只是在代码块的开始倒勾中附加一个有效的语言别名:


    ```
export default {
name: 'MyComponent',
// ...
}
```

在 Prism 的网站上查看 合法的语言列表


代码块中的行高亮


    ``` js {4}
export default {
data () {
return {
msg: 'Highlighted!'
}
}
}
```

除了单行以外,你也可指定多行,行数区间,或是两者都指定。



  • 行数区间: 例如 {5-8}, {3-10}, {10-17}

  • 多个单行: 例如 {4,7,9}

  • 行数区间与多个单行: 例如 {4,7-13,16,23-27,40}


行号


// config.js
module.exports = {
markdown: {
lineNumbers: true
}
}

导入代码段


<<< @/docs/.vuepress/code/test.js

使用vue组件


所有在 .vuepress/components 中找到的 *.vue 文件将会自动地被注册为全局的异步组件,如:


.
└─ .vuepress
└─ components
├─ demo-1.vue
├─ OtherComponent.vue
└─ Foo
└─ Bar.vue

你可以直接使用这些组件在任意的 Markdown 文件中(组件名是通过文件名取到的):


<demo-1/>
<OtherComponent/>
<Foo-Bar/>

插件


@vuepress/plugin-back-to-top


安装


yarn add -D @vuepress/plugin-back-to-top
# OR npm install -D @vuepress/plugin-back-to-top

使用


module.exports = {
plugins: ['@vuepress/back-to-top']
}

构建与部署


Github Pages 是面向用户、组织和项目开放的公共静态页面搭建托管服务,站点可以被免费托管在 Github 上,你可以选择使用 Github Pages 默 认提供的域名 github.io 或者自定义域名来发布站点。不仅免除了租服务器的麻烦,而且部署起来非常轻松。简而言之,在GitHub Pages上发布博客是非常好的选择。


创建两个仓库


1、amjanney.github.io,站点仓库,用来存放打包后的文件。


2、docs,用来放vuepress写的文档。


github.io会默认读取根目录下的index.html作为首页。所以我们要做的就是把打包后的vuepress文档上传到创建的名为<username>.github.io的仓库下。


image.png


仓库地址


github.com/amjanney/am…


github.com/amjanney/do…


发布


在docs下面有个deploy.sh文件,代码如下:


#!/usr/bin/env sh

# 确保脚本抛出遇到的错误
set -e

# 生成静态文件
npm run docs:build

# 进入生成的文件夹
cd docs/.vuepress/dist

# 如果是发布到自定义域名
# echo 'www.example.com' > CNAME

git init
git add -A
git commit -m 'deploy'

# 如果发布到 https://amjanney.github.io
git push -f https://github.com/amjanney/amjanney.github.io.git master

在package.json文件中配置命令


"deploy": "bash deploy.sh"

运行


npm run deploy

会执行deploy.sh中的命令,先打包vuepress文件,在docs/.vuepress/dist下面打包后的文件,cd到这个文件下面,通过git在上传到amjanney.github.io.git仓库,这时候访问amjanney.github.io/,文档就已经生效了。


每次更改了文件,就需要执行npm run deploy命令,更新文档。


当然这个过程可以集成一下,每次push代码到master的时候,自动出发npm run deploy过程。


更多部署方式移步链接


链接:https://juejin.cn/post/6990718623484477447

收起阅读 »

如何做前端单元测试

单元测试 什么是单元测试 单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证 需要访问数据库的测试不是单元测试 需要访问网络的测试不是单元测试 需要访问文件系统的测试不是单元测试 --- 修改代码的艺术 为什么要做单元测...
继续阅读 »

单元测试


什么是单元测试



单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证



需要访问数据库的测试不是单元测试

需要访问网络的测试不是单元测试

需要访问文件系统的测试不是单元测试

--- 修改代码的艺术

为什么要做单元测试



  1. 执行单元测试,就是为了证明这段代码的行为和我们期望的一致

  2. 进行充分的单元测试,是提高软件质量,降低开发成本的必由之路

  3. 在开发人员做出修改后进行可重复的单元测试可以避免产生那些令人不快的负作用


怎么去设计单元测试



  1. 理解这个单元原本要做什么(倒推出一个概要的规格说明(阅读那些程序代码和注释))

  2. 画出流程图

  3. 组织对这个概要规格说明的走读(Review),以确保对这个单元的说明没有基本的错误

  4. 设计单元测试

    在实践工作中,进行了完整计划的单元测试和编写实际的代码所花费的精力大致上是相同的





两个常用的单元测试方法论



  • TDD(Test-driven development):测试驱动开发

  • BDD(Behavior-driven development):行为驱动开发


前端与单元测试


如何对前端代码做单元测试


通常是针对函数、模块、对象进行测试


至少需要三类工具来进行单元测试:



  • *测试管理工具

  • *测试框架:就是运行测试的工具。通过它,可以为 JavaScript 应用添加测试,从而保证代码的质量

  • *断言库

  • 测试浏览器

  • 测试覆盖率统计工具


测试框架选择


Jasmine:Behavior-Drive development(BDD)风格的测试框架,在业内较为流行,功能很全面,自带 asssert、mock 功能


Qunit:该框架诞生之初是为了 jquery 的单元测试,后来独立出来不再依赖于 jquery 本身,但是其身上还是脱离不开 jquery 的影子


Mocha:node 社区大神 tj 的作品,可以在 node 和 browser 端使用,具有很强的灵活性,可以选择自己喜欢的断言库,选择测试结果的 report


Jest:来自于 facebook 出品的通用测试框架,Jest 是一个令人愉快的 JavaScript 测试框架,专注于简洁明快。他适用但不局限于使用以下技术的项目:Babel, TypeScript, Node, React, Angular, Vue


如何编写测试用例(Jest + Enzyme)


通常测试文件名与要测试的文件名相同,后缀为.test.js,所有测试文件默认放在__test__文件夹中


describe块之中,提供测试用例的四个函数:before()、after()、beforeEach()和 afterEach()。它们会在指定时间执行(如果不需要可以不写)


测试文件中应包括一个或多个describe, 每个 describe 中可以有一个或多个it,每个describe中可以有一个或多个expect.



describe 称为"测试套件"(test suite),it 块称为"测试用例"(test case)。



expect就是判断源码的实际执行结果与预期结果是否一致,如果不一致就抛出一个错误.


所有的测试都应该是确定的。 任何时候测试未改变的组件都应该产生相同的结果。 你需要确保你的快照测试与平台和其他不相干数据无关。


基础模板


describe('加法函数测试', () => {
before(() => {
// 在本区块的所有测试用例之前执行
});
after(() => {
// 在本区块的所有测试用例之后执行
});
beforeEach(() => {
// 在本区块的每个测试用例之前执行
});
afterEach(() => {
// 在本区块的每个测试用例之后执行
});

it('1加1应该等于2', () => {
expect(add(1, 1)).toBe(2);
});
it('2加2应该等于4', () => {
expect(add(2, 2)).toBe(42);
});
});

常用的测试


组件中的方法测试


it('changeCardType', () => {
let component = shallow(<Card />);
expect(component.instance().cardType).toBe('initCard');
component.instance().changeCardType('testCard');
expect(component.instance().cardType).toBe('testCard');
});

模拟事件测试


通过 Enzyme 可以在这个返回的 dom 对象上调用类似 jquery 的 api 进行一些查找操作,还可以调用 setProps 和 setState 来设置 props 和 state,也可以用 simulate 来模拟事件,触发事件后,去判断 props 上特定函数是否被调用,传参是否正确;组件状态是否发生预料之中的修改;某个 dom 节点是否存在是否符合期望


it('can save value and cancel', () => {
const value = 'edit';
const { wrapper, props } = setup({
editable: true,
});
wrapper.find('input').simulate('change', { target: { value } });
wrapper.setProps({ status: 'save' });
expect(props.onChange).toBeCalledWith(value);
});

使用 snapshot 进行 UI 测试


it('App -- snapshot', () => {
const renderedValue = renderer.create(<App />).toJSON();
expect(renderedValue).toMatchSnapshot();
});

真实用例分析(组件)


写一个单元测试你需要这样做



  1. 看代码,熟悉待测试模块的功能和作用

  2. 设计测试用例必须覆盖到组件的各种情况

  3. 对错误情况的测试


通常测试文件名与要测试的文件名相同,后缀为.test.js,所有测试文件默认放在test文件夹中,一般测试文件包含下列内容:



  • 全局设置:一些前置配置,mock 的全局或第三方方法、进行一些重复的组件初始化工作,,当多个测试用例有相同的初始化组件行为时,可以在这里进行挂载和销毁

  • UI 测试:为组件打快照,第一次运行测试命令会在目录下生成一个组件的 DOM 节点快照,在之后的测试命令中会与快照文件进行 diff 对照,避免在后面对组件进行了非期望的 UI 更改

  • 关键行为:验证组件的基本行为(如:Checkbox 组件的勾选行为)

  • 事件:测试各种事件的触发

  • 属性:测试传入不同属性值是否得到与期望一致的结果


accordion 组件


// accordion.test.tsx
import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals';
import Enzyme, { mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import toJSON from 'enzyme-to-json';
import JestMock from 'jest-mock';
import React from 'react';
import { Accordion } from '..';

Enzyme.configure({ adapter: new Adapter() }); // 需要根据项目的react版本来配置适配

describe('Accordion', () => {
// 测试套件,通过 describe 块来将测试分组
let onChange: JestMock.Mock<any, any>; // Jest 提供的mock 函数,擦除函数的实际实现、捕获对函数的调用
let wrapper: Enzyme.ReactWrapper;

beforeEach(() => {
// 在运行测试前做的一些准备工作
onChange = jest.fn();
wrapper = mount(
<Accordion onChange={onChange}>
<Accordion.Item name='one' header='one'>
two
</Accordion.Item>
<Accordion.Item name='two' header='two' disabled={true}>
two
</Accordion.Item>
<Accordion.Item name='three' header='three' showIcon={false}>
three
</Accordion.Item>
<Accordion.Item name='four' header='four' active={true} icons={['custom']}>
four
</Accordion.Item>
</Accordion>
);
});

afterEach(() => {
// 在运行测试后进行的一些整理工作
wrapper.unmount();
});

// UI快照测试,确保你的UI不会因意外改变
test('Test snapshot', () => {
// 测试用例,需要提供详细的测试用例描述
expect(toJSON(wrapper)).toMatchSnapshot();
});

// 事件测试
test('should trigger onChange', () => {
wrapper.find('.qtc-accordion-item-header').first().simulate('click');

expect(onChange.mock.calls.length).toBe(1);
expect(onChange.mock.calls[0][0]).toBe('one');
});

// 关键逻辑测试
//点击头部触发展开收起
test('should expand and collapse', () => {
wrapper.find('.qtc-accordion-item-header').at(2).simulate('click');

expect(wrapper.find('.qtc-accordion-item').at(2).hasClass('active')).toBeTruthy();
});
// 配置disabled时不可展开
test('should not trigger onChange when disabled', () => {
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

expect(onChange.mock.calls.length).toBe(0);
});

// 对所有的属性配置进行测试
// 是否展示头部左侧图标
test('hide icon', () => {
expect(wrapper.find('.qtc-accordion-item-header').at(2).children().length).toBe(2);
});
// 自定义图标
test('custom icon', () => {
const customIcon = wrapper.find('.qtc-accordion-item-header').at(3).children().first();

expect(customIcon.getDOMNode().innerHTML).toBe('custom');
});
// 是否可展开多项
test('single expand', () => {
onChange = jest.fn();
wrapper = mount(
<Accordion multiple={false} onChange={onChange}>
<Accordion.Item name='1'>1</Accordion.Item>
<Accordion.Item name='2'>2</Accordion.Item>
</Accordion>
);

wrapper.find('.qtc-accordion-item-header').at(0).simulate('click');
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

expect(wrapper.find(Accordion).state().activeNames).toEqual(new Set(['2']));
});
test('mutiple expand', () => {
onChange = jest.fn();
wrapper = mount(
<Accordion multiple={true} onChange={onChange}>
<Accordion.Item name='1'>1</Accordion.Item>
<Accordion.Item name='2'>2</Accordion.Item>
</Accordion>
);

wrapper.find('.qtc-accordion-item-header').at(0).simulate('click');
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

expect(wrapper.find(Accordion).state().activeNames).toEqual(new Set(['1', '2']));
});
});

难点记录


对一些异步和延时的处理


使用单个参数调用 done,而不是将测试放在一个空参数的函数,Jest 会等 done 回调函数执行结束后,结束测试


test('the data is peanut butter', done => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}

fetchData(callback);
});

模拟 setTimeout


// 提取utils方法,封装一个sleep
export const sleep = async (timeout = 0) => {
await act(async () => {
await new Promise((resolve) => globalTimeout(resolve, timeout));
});
};

// 测试用例中调用
it('测试用例', async () => {
doSomething();
await sleep(1000);
doSomething();
})

mock 组件内系统函数的返回结果


对于组件内调用了 document 上的方法,可以通过 mock 指定方法的返回值,来保证一致性


const getBoundingClientRectMock = jest.spyOn(
HTMLHeadingElement.prototype,
'getBoundingClientRect',
);

beforeAll(() => {
getBoundingClientRectMock.mockReturnValue({
width: 100,
height: 100,
top: 1000,
} as DOMRect);
});

afterAll(() => {
getBoundingClientRectMock.mockRestore();
});

直接调用组件方法


通过 wrapper.instance()获取组件实例,再调用组件内方法,如:wrapper.instance().handleScroll()
测试系统方法的调用


const scrollToSpy = jest.spyOn(window, 'scrollTo');

const calls = scrollToSpy.mock.calls.length;

expect(scrollToSpy.mock.calls.length).toBeGreaterThan(calls);

使用属性匹配器代替时间


当快照有时间时,通过属性匹配器可以在快照写入或者测试前只检查这些匹配器是否通过,而不是具体的值


it('will check the matchers and pass', () => {
const user = {
createdAt: new Date(),
id: Math.floor(Math.random() * 20),
name: 'LeBron James',
};

expect(user).toMatchSnapshot({
createdAt: expect.any(Date),
id: expect.any(Number),
});
});

附录


JEST 语法


匹配器


expect:返回一个'期望‘的对象


toBe:使用 object.is 去判断相等


toEqual:递归检测对象或数组的每个字段


not:测试相反的匹配


真值


toBeNull:只匹配 null


toBeUndefined:只匹配 undefined


toBeDefined:与 toBeUndefined 相反


toBeTruthy:匹配任何 if 语句为真


toBeFalsy:匹配任务 if 语句为假


数字


toBeGreaterThan:大于


toBeGreaterThanOrEqual:大于等于


toBeLessThan:小于


toBeLessThanOrEqual:小于等于


toBeCloseTo:比较浮点数相等


字符串


toMatch:匹配字符串


Array


toContain:检测一个数组或可迭代对象是否包含某个特定项


例外


toThrow:测试某函数在调用时是否抛出了错误


自定义匹配器


// The mock function was called at least once
expect(mockFunc).toHaveBeenCalled();

// The mock function was called at least once with the specified args
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);

// The last call to the mock function was called with the specified args
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);

// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();

测试异步代码


回调


默认情况下,一旦到达运行上下文底部 Jest 测试立即结束,使用单个参数调用 done,而不是将测试放在一个空参数的函数,Jest 会等 done 回调函数执行结束后,结束测试。


test('the data is peanut butter', (done) => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}

fetchData(callback);
});

Promises


为你的测试返回一个 Promise,Jest 会等待 Promise 的 resove 状态,如果 Promist 被拒绝,则测试将自动失败


test('the data is peanut butter', () => {
return fetchData().then((data) => {
expect(data).toBe('peanut butter');
});
});

如果期望 Promise 被 Reject,则需要使用 .catch 方法。 请确保添加 expect.assertions 来验证一定数量的断言被调用。 否则,一个 fulfilled 状态的 Promise 不会让测试用例失败


test('the fetch fails with an error', () => {
expect.assertions(1);
return fetchData().catch(e => expect(e).toMatch('error'));
});

.resolves/.rejects


test('the data is peanut butter', () => {
return expect(fetchData()).resolves.toBe('peanut butter');
});
test('the fetch fails with an error', () => {
return expect(fetchData()).rejects.toMatch('error');
});

Async/Await


写异步测试用例时,可以再传递给 test 的函数前面加上 async。


安装和移除


为多次测试重复设置:beforeEach、afterEach 来为多次测试重复设置的工作


一次性设置:beforeAll、afterAll 在文件的开头做一次设置


作用域:可以通过 describe 块将测试分组,before 和 after 的块在 describe 块内部时,则只适用于该 describe 块内的测试


模拟函数


Mock 函数允许你测试代码之间的连接——实现方式包括:擦除函数的实际实现、捕获对函数的调用(以及在这些调用中传递的参数)、在使用 new 实例化时捕获构造函数的实例、允许测试时配置返回值。


两种方法可以模拟函数:1.在测试代码中创建一个 mock 函数,2.编写一个手动 mock 来覆盖模块依赖


mock 函数


const mockCallback = jest.fn((x) => 42 + x);
forEach([0, 1], mockCallback);

// 此 mock 函数被调用了两次
expect(mockCallback.mock.calls.length).toBe(2);

// 第一次调用函数时的第一个参数是 0
expect(mockCallback.mock.calls[0][0]).toBe(0);

// 第二次调用函数时的第一个参数是 1
expect(mockCallback.mock.calls[1][0]).toBe(1);

// 第一次函数调用的返回值是 42
expect(mockCallback.mock.results[0].value).toBe(42);

.mock 属性


所有的 mokc 函数都有这个特殊的.mock 属性,它保存了关于此函数如何被调用、调用时的返回值的信息。.mock 属性还追踪每次调用时的 this 的值,所以我们同样可以检查 this


// 这个函数被实例化两次
expect(someMockFunction.mock.instances.length).toBe(2);

// 这个函数被第一次实例化返回的对象中,有一个 name 属性,且被设置为了 'test’
expect(someMockFunction.mock.instances[0].name).toEqual('test');

Mock 的返回值


const myMock = jest.fn();
console.log(myMock());
// > undefined

myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true

模拟模块


可以 用 jest.mock(...)函数自动模拟 axios 模块,一旦模拟模块,我们可为.get 提供一个 mockResolveValue,它会返回假数据用于测试


// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
const users = [{ name: 'Bob' }];
const resp = { data: users };
axios.get.mockResolvedValue(resp);

// or you could use the following depending on your use case:
// axios.get.mockImplementation(() => Promise.resolve(resp))

return Users.all().then((data) => expect(data).toEqual(users));
});

Mock 实现


用 mock 函数替换指定返回值:jest.fn(cb => cb(null, true))


用 mockImplementation 根据别的模块定义默认的 mock 函数实现:jest.mock('../foo'); const foo = require('../foo');foo.mockImplementation(() => 42);


当你需要模拟某个函数调用返回不同结果时,请使用 mockImplementationOnce 方法


.mockReturnThis()函数来支持链式调用


Mock 名称


可以为你的 Mock 函数命名,该名字会替代 jest.fn() 在单元测试的错误输出中出现。 用这个方法你就可以在单元测试输出日志中快速找到你定义的 Mock 函数


const myMockFn = jest
.fn()
.mockReturnValue('default')
.mockImplementation((scalar) => 42 + scalar)
.mockName('add42');

快照测试


当要确保你的 UI 不会又意外的改变时,快照测试是非常有用的工具;典型的做法是在渲染了 UI 组件之后,保存一个快照文件, 检测他是否与保存在单元测试旁的快照文件相匹配。 若两个快照不匹配,测试将失败:有可能做了意外的更改,或者 UI 组件已经更新到了新版本。


快照文件应该和项目代码一起提交并做代码评审


更新快照


jest --updateSnapshot/jest -u,这将为所有失败的快照测试重新生成快照文件。 如果我们无意间产生了 Bug 导致快照测试失败,应该先修复这些 Bug,再生成快照文件;只重新生成一部分的快照文件,你可以使用--testNamePattern 来正则匹配想要生成的快照名字


属性匹配器


项目中常常会有不定值字段生成(例如 IDs 和 Dates),针对这些情况,Jest 允许为任何属性提供匹配器(非对称匹配器)。 在快照写入或者测试前只检查这些匹配器是否通过,而不是具体的值


it('will check the matchers and pass', () => {
const user = {
createdAt: new Date(),
id: Math.floor(Math.random() * 20),
name: 'LeBron James',
};

expect(user).toMatchSnapshot({
createdAt: expect.any(Date),
id: expect.any(Number),
});
});

覆盖率


Jest 还提供了生成测试覆盖率报告的命令,只需要添加上 --coverage 这个参数即可生成,再加上--colors 可根据覆盖率生成不同颜色的报告(<50%红色,50%~80%黄色, ≥80%绿色)



  • % Stmts 是语句覆盖率(statement coverage):是否每个语句都执行了

  • % Branch 分支覆盖率(branch coverage):是否每个分支代码块都执行了(if, ||, ? : )

  • % Funcs 函数覆盖率(function coverage):是否每个函数都调用了

  • % Lines 行覆盖率(line coverage):是否每一行都执行了


Enzyme


nzyme 来自 airbnb 公司,是一个用于 React 的 JavaScript 测试工具,方便你判断、操纵和历遍 React Components 输出。Enzyme 的 API 通过模仿 jQuery 的 API ,使得 DOM 操作和历遍很灵活、直观。Enzyme 兼容所有的主要测试运行器和判断库。


安装与配置



  • npm install --save-dev enzyme

  • 安装 Enzyme Adapter 来对应 React 的版本 npm install --save-dev enzyme-adapter-react-16


渲染方式


shallow 浅渲染


返回组件的浅渲染,对官方 shallow rendering 进行封装。浅渲染 作用就是:它仅仅会渲染至虚拟 dom,不会返回真实的 dom 节点,这个对测试性能有极大的提升。shallow 只渲染当前组件,只能能对当前组件做断言


render 静态渲染


将 React 组件渲染成静态的 HTML 字符串,然后使用 Cheerio 这个库解析这段字符串,并返回一个 Cheerio 的实例对象,可以用来分析组件的 html 结构,对于 snapshot 使用 render 比较合适


mount 完全渲染


将组件渲染加载成一个真实的 DOM 节点,用来测试 DOM API 的交互和组件的生命周期,用到了 jsdom 来模拟浏览器环境


常用 API


.simulate(event, mock):用来模拟事件触发,event 为事件名称,mock 为一个 event object


.instance():返回测试组件的实例


.find(selector):根据选择器查找节点,selector 可以是 CSS 中的选择器,也可以是组件的构造函数,以及组件的 display name 等


.get(index):返回指定位置的子组件的 DOM 节点


.at(index):返回指定位置的子组件


.first():返回第一个子组件


.last():返回最后一个子组件


.type():返回当前组件的类型


.contains(nodeOrNodes):当前对象是否包含参数重点 node,参数类型为 react 对象或对象数组


.text():返回当前组件的文本内容


.html():返回当前组件的 HTML 代码形式


.props():返回根组件的所有属性


.prop(key):返回根组件的指定属性


.state([key]):返回根组件的状态


.setState(nextState):设置根组件的状态


.setProps(nextProps):设置根组件的属性



链接:https://juejin.cn/post/6990655486659919902

收起阅读 »

老掉牙之前端组件化

组件化已经无处不在。可能每个人一张嘴都是组件化模块化。 这个时候我们能否认真回想一下,自己的组件,真的是组件化了吗? 怎样的组件化才算比较好的组件化? 根据客观事实(主要是主观臆想),浅谈一下前端的组件化。 1、组件化的使用背景 业务的迭代和堆积 1、单个文件...
继续阅读 »

组件化已经无处不在。可能每个人一张嘴都是组件化模块化。

这个时候我们能否认真回想一下,自己的组件,真的是组件化了吗?

怎样的组件化才算比较好的组件化?

根据客观事实(主要是主观臆想),浅谈一下前端的组件化。


1、组件化的使用背景


业务的迭代和堆积


1、单个文件有成千上万行代码,可读性非常差,维护也不方便

2、有大量重复的代码,相同或者类似的功能实现了很多遍

3、新功能的开发成本巨大

4、不敢重构,牵一发而动全身


场景的多样化


1、不同的项目,类似的场景

2、相同的项目,越来越多的场景


背景和场景都有了。

如何判断你的代码质量如何?

一个比较直观(其实也是我的主观)的判断就是:2年后我是否还能轻易维护或者复用你的代码。

如果举步维艰,那我们应该好好想想,什么才是组件化?


2、组件化的定义和特性


(蹩脚的)定义


组件化 就是将UI、样式以及其实现的比较完整的功能作为独立的整体,

无关业务,

无论将这个整体放在哪里去使用,

它都具有一样的功能和UI,

从而达到复用的效果,

这种体系化的思想就是组件化。


特性——高内聚,低耦合


一个组件中包含了完整视图结构,样式表以及交互逻辑。

它是封闭的结构,对外提供属性的输入用来满足用户的多样化需求,

对内自己管理内部状态来满足自己的交互需求,

一言蔽之就是:高内聚,低耦合。


组件化的目的


减少重复造轮子(虽然造轮子是避免不了的事)、反复修轮胎(疲于奔命迭代维护组件)的频率,

增加代码复用性和灵活性,提高系统设计,从而提高开发效率。

说完组件化的基本定义和特性,接下来就说说组件化的分类吧。


3、组件的分类


分类的形式可能有多种和多角度,我这里按自己的日常(react技术栈)使用分一下。


函数组件和类组件


1、函数组件的写法要比类组件简洁

2、类组件比函数组件功能更加强大

类组件可以维护自身的状态变量,还有不同的生命周期方法,

可以让开发者能够在组件的不同阶段(挂载、更新、卸载),

对组件做更多的控制。

ps:自从hooks出来以后, 函数组件也能实现生命周期等更多骚操作了。


自己的使用原则:

如果功能相对简单简洁,就是用函数组件;

功能丰富多样,相对复杂就使用类组件。

界限不是特别清晰,但是大的基本原则是这个,仅供参考。


展示型组件和容器型组件


1、展示型组件像个父亲(父爱如山,一动不动,他真的不动!)

不用理会数据是怎么来到哪里去,

它只管兵来将挡,水来土掩,

你给我什么数据,就就拿它来渲染成相应的UI即可。


2、容器型组件则像个老师。

他需要知道如何获取学生(子组件)所需数据,

以及这些数据的处理逻辑,

并把数据和使用方法(处理逻辑)通过props提供给学生(子组件)使用。

ps:容器型组件一般是有状态组件,因为它们需要管理页面所需数据。


无状态组件和有状态组件


1、无状态组件内部不维护自身的state(因为它根本就没使用),

只根据外部组件传入的props返回待渲染的元素(传说中的饭来张口,衣来伸手?)。


2、有状态组件维护自身状态的变化,

并且根据外部组件传入的props和自身的state,

共同决定最终渲染的元素(自给自足,别人也来者不拒)


高阶组件


任性,就是不说,自己百度谷歌一下...

说完分类,说说使用了组件化以后有啥好处。


4、组件化的价值


业务价值


1、组件与具体场景或业务解耦,提升开发效率与降低风险,促进业务安全、快速迭代

2、提高了组件的复用和可移植,减少开发人力

3、方便测试模拟接口数据

4、便于堆积木般快速组合不同的场景和业务


技术价值


1、组件与框架解耦,去中心化的开发,这背后其实是一种前端微服务化的思想;

2、页面资源可以动态按需加载,提升性能;

3、组件可持续,可自由组合,提升开发效率;


O了,说得组件化这么好,那我们设计组件前,应该思考什么问题呢?


5、开发组件前的灵魂拷问?


组件应该如何划分,划分的粒度标准是什么?


组件划分的依据通常是业务逻辑和功能,

一段相对完整且完备的功能逻辑就是划分的一个界限。
当然,你还要考虑各组件之间的关系是否明确以及组件的可复用度等等。


这个组件还能再减吗,它还能减少不必要的代码和依赖吗?


越简单的组件,往往具备越容易复用的特性。
你看各大知名UI库组件库,
他们设计出来的轮子是不是几乎都差不多?
都是按钮,弹窗,提示框等等?
而那些看起来功能超级丰富的组件,往往使用的场景反而很少?


此组件是不是渣男?是不是到处去破坏别的组件,入侵其他组件却挥一挥衣袖,留下了一堆云彩?


如果一个组件的封装性不够好,或者实现自身的越界操作,

就可能对自身之外造成了侵入,这种情况应该尽量避免。

确保组件的生命周期能够对其影响进行有效的管理(如destroy后不留痕迹)。

举个栗子: 如果你的组件触发了鼠标滚轮事件,一滚轮就打断点,或者不断的加数据到内存中。

直到组件被销毁了,还没有去掉这个事件的处理,那就可能导致内存泄漏等等情况了,渣男...


是否便于拔插,就是来去自如的意思,复用方便,删除随便?


1、组件设计需要考虑需要适用的不同场景,

在组件设计时进行必要的兼容。

2、各组件之前以组合的关系互相配合,

也是对功能需求的模块化抽象,

当需求变化时可以将实现以模块粒度进行调整。

3、设计组件时要想想如何快速接入,快速删除,但是又不影响别的组件和业务。


6、组件化的设计原则和标准


不说了,打字打累了,直接上图 (有图有真相)


PS:这个标准和原则来源于网络(图是我的图),我看到的时候表示默默认同,记下来了,再截图出来。如果侵权,请告诉我删除。


组件化,你今天想好了吗?


链接:https://juejin.cn/post/6926726194054365192

收起阅读 »

防抖和节流知多少

防抖 在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新再等n秒在执行回调。 例子 //模拟一段ajax请求 function ajax(content) { console.log('ajax request ' + content) } l...
继续阅读 »

防抖


在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新再等n秒在执行回调。


例子


//模拟一段ajax请求
function ajax(content) {
console.log('ajax request ' + content)
}

let inputa = document.getElementById('unDebounce')

inputa.addEventListener('keyup', function (e) {
ajax(e.target.value)
})

看一下运行结果:
1111.gif


可以看到,我们只要按下键盘,就会触发这次ajax请求。不仅从资源上来说是很浪费的行为,而且实际应用中,用户也是输出完整的字符后,才会请求。下面我们优化一下:


//模拟一段ajax请求

function ajax(content) {
console.log('ajax request' + content)
}
function debounce(fn, delay) {
let timer;
return function () {
let context = this;
const args = [...arguments];
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(context, args);
}, delay);
};
}
let dedebounceajax = debounce(ajax,1000);
var inputs = document.getElementById('inputs')
inputs.addEventListener("keyup",(e)=>{
dedebounceajax(e.target.value)
})

看一下运行结果:


2222.gif


可以看到,我们加入了防抖以后,当你在频繁的输入时,并不会发送请求,只有当你在指定间隔内没有输入时,才会执行函数。如果停止输入但是在指定间隔内又输入,会重新触发计时。


节流


规定在一个单位时间内,只能触发一次函数。
假设你点击一个按钮规定了5秒生效,不管你在5秒内点击了按钮多少次,5秒只会生效一次。


例子


// 时间戳箭头函数版本 节流函数

function throttle(func, wait) {
let timer = 0;
return (...rest) => {
let now = Date.now();
let that = this;
if (now > timer + delay) {
fn.apply(that, rest);
timer = now;
}
};
}

// 定时器版本 节流函数

function throttle(func, wait) {
let timeout;
return function() {
let context = this;
let args = arguments;
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
func.apply(context, args)
}, wait)
}

}
}

let throttleAjax = throttle(ajax, 1000)
let inputc = document.getElementById('throttle')
inputc.addEventListener('keyup', function(e) {
throttleAjax(e.target.value)
})

复制代码

看一下运行结果:


3333.gif


可以看到,我们在不断输入时,ajax会按照我们设定的时间,每1s执行一次。


总结



  • 函数防抖和函数节流都是防止某一时间频繁触发,但是这两兄弟之间的原理却不一样。

  • 函数防抖是某一段时间内只执行一次,而函数节流是间隔时间执行。


应用场景


防抖应用场景



  • input框搜索,用户在不断输入值时,用防抖来节约请求资源。

  • window触发resize的时候,不断的调整浏览器窗口大小会不断的触发这个事件,用防抖来让其只触发一次。


节流应用场景



  • 鼠标不断点击触发,mousedown(单位时间内只触发一次)

  • 监听滚动事件,比如是否滑到底部自动加载更多,用throttle来判断


链接:https://juejin.cn/post/6989861117187063822

收起阅读 »

每个前端都需要知道这些面向未来的CSS技术

写在前面 前端技术日新月异,我们需要不断学习来更新自己的前端知识并运用到自己的项目中。这次笔者整理一些未来普及或者现在同学们可能已经用到的CSS特性,包括SVG图标、滚动特性、CSS自定义属性、CSS现代伪类 、JS in CSS、Web Layout、混合模...
继续阅读 »

写在前面


前端技术日新月异,我们需要不断学习来更新自己的前端知识并运用到自己的项目中。这次笔者整理一些未来普及或者现在同学们可能已经用到的CSS特性,包括SVG图标、滚动特性、CSS自定义属性、CSS现代伪类 、JS in CSS、Web Layout、混合模式和滤镜、CSS计数器等等。


滚动特性


能用CSS实现的就不用麻烦JavaScript文章提及到滚动捕捉的特性,更多有关于容器滚动方面的CSS新特性其实还有有很多个,比如:



  • 自定义滚动条的外观

  • scroll-behavior指容容器滚动行为,让滚动效果更丝滑

  • overscroll-behavior优化滚动边界,特别是可以帮助我们滚动的穿透


自定义滚动条的外观


默认的window外观和mac外观


windows
image.png
mac


image.png


在CSS中,我们可以使用-webkit-scrollbar来自定义滚动条的外观。该属性提供了七个伪元素:



  • ::-webkit-scrollbar:整个滚动条

  • ::-webkit-scrollbar-button:滚动条上的按钮(下下箭头)

  • ::-webkit-scrollbar-thumb:滚动条上的滚动滑块

  • ::-webkit-scrollbar-track:滚动条轨道

  • ::-webkit-scrollbar-track-piece:滚动条没有滑块的轨道部分

  • ::-webkit-scrollbar-corner:当同时有垂直和水平滚动条时交汇的部分

  • ::-webkit-resizer:某些元素的交汇部分的部分样式(类似textarea的可拖动按钮)


html {
--maxWidth:1284px;
scrollbar-color: linear-gradient(to bottom,#ff8a00,#da1b60);
scrollbar-width: 30px;
background: #100e17;
color: #fff;
overflow-x: hidden
}

html::-webkit-scrollbar {
width: 30px;
height: 30px
}

html::-webkit-scrollbar-thumb {
background: -webkit-gradient(linear,left top,left bottom,from(#ff8a00),to(#da1b60));
background: linear-gradient(to bottom,#ff8a00,#da1b60);
border-radius: 30px;
-webkit-box-shadow: inset 2px 2px 2px rgba(255,255,255,.25),inset -2px -2px 2px rgba(0,0,0,.25);
box-shadow: inset 2px 2px 2px rgba(255,255,255,.25),inset -2px -2px 2px rgba(0,0,0,.25)
}

html::-webkit-scrollbar-track {
background: linear-gradient(to right,#201c29,#201c29 1px,#100e17 1px,#100e17)
}

通过这几个伪元素,可以实现你自己喜欢的滚动条外观效果,比如下面这个示例:


image.png


完整演示


css自定义属性


你大概已经听说过CSS自定义属性,也被称为 CSS 变量,估计熟悉SCSS、LESS就会很快上手,概念大同小异,都是让我们的CSS变得可维护,目前Edge最新版都已经支持这个特性了,这说明现在 CSS 自定义属性已经能用在实际项目中了,相信不久以后开发者们将大大依赖这个特性。但还请在使用之前请先检查一下本文附录中 Postcss 对于 CSS 自定义属性的支持情况,以便做好兼容。


什么是自定义属性呢?简单来说就是一种开发者可以自主命名和使用的 CSS 属性。浏览器在处理像 color 、position 这样的属性时,需要接收特定的属性值,而自定义属性,在开发者赋予它属性值之前,它是没有意义的。所以要怎么给 CSS 自定义属性赋值呢?这倒和习惯无异


.foo {
color: red;
--theme-color: gray;
}

自定义元素的定义由 -- 开头,这样浏览器能够区分自定义属性和原生属性,从而将它俩分开处理。假如只是定义了一个自定义元素和它的属性值,浏览器是不会做出反应的。如上面的代码, .foo 的字体颜色由 color 决定,但 --theme-color.foo 没有作用。


你可以用 CSS 自定义元素存储任意有效的 CSS 属性值


.foo {
--theme-color: blue;
--spacer-width: 8px;
--favorite-number: 3;
--greeting: "Hey, what's up?";
--reusable-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.85);
}

使用


假如自定义属性只能用于设值,那也太没用了点。至少,浏览器得能获取到它们的属性值。


使用 var() 方法就能实现:


.button {
background-color: var(--theme-color);
}

下面这段代码中,我们将 .buttonbackground-color 属性值赋值为 --theme-color 的值。这例子看起来自定义属性也没什么了不起的嘛,但这是一个硬编码的情况。你有没有意识到,--theme-color 的属性值是可以用在任意选择器和属性上的呢?这可就厉害了。


.button {
background-color: var(--theme-color);
}

.title {
color: var(--theme-color);
}

.image-grid > .image {
border-color: var(--theme-color);
}

缺省值


如果开发者并没有定义过 --theme-color 这个变量呢?var() 可以接收第二个参数作为缺省值:


.button {
background-color: var(--theme-color, gray);
}


注意:如果你想把另一个自定义属性作为缺省值,语法应该是 background-color: var(--theme-color, var(--fallback-color))



传参数时总是传入一个缺省值是一个好习惯,特别是在构建 web components 的时候。为了让你的页面在不支持自定义属性的浏览器上正常显示,别忘了加上兼容代码:


.button {
background-color: gray;
background-color: var(--theme-color, gray);
}

CSS现代伪类


这些最新的伪类特性,我们也需要知道。
image.png


使用 :is() 减少重复


你可以使用 :is() 伪类来删除选择器列表中的重复项。


/* BEFORE */
.embed .save-button:hover,
.attachment .save-button:hover {
opacity: 1;
}

/* AFTER */
:is(.embed, .attachment) .save-button:hover {
opacity: 1;
}

此功能主要在未处理的标准CSS代码中有用。如果使用Sass或类似的CSS预处理程序,则可能更喜欢嵌套。


注意:浏览器还支持非标准的 :-webkit-any() 和 :-moz-any() 伪类,它们与 :is() 相似,但限制更多。WebKit在2015年弃用了 :-webkit-any() ,Mozilla已将Firefox的用户代理样式表更新为使用 :is() 而不是 :-moz-any()


使用 :where() 来保持低特殊性


:where() 伪类与 :is() 具有相同的语法和功能。它们之间的唯一区别是 :where() 不会增加整体选择器的特殊性(即某条CSS规则特殊性越高,它的样式越优先被采用)。



:where() 伪类及其任何参数都不对选择器的特殊性有所帮助,它的特殊性始终为零



此功能对于应易于覆盖的样式很有用。例如,基本样式表 sanitize.css 包含以下样式规则,如果缺少 <svg fill> 属性,该规则将设置默认的填充颜色:


svg:not([fill]) {
fill: currentColor;
}

由于其较高的特殊性(B = 1,C = 1),网站无法使用单个类选择器(B = 1)覆盖此声明,并且被迫添加 !important 或人为地提高选择器的特殊性(例如 .share- icon.share-icon)。


.share-icon {
fill: blue; /* 由于特殊性较低,因此不适用 */
}

CSS库和基础样式表可以通过用 :where() 包装它们的属性选择器来避免这个问题,以保持整个选择器的低特殊性(C=1)。


/* sanitize.css */
svg:where(:not([fill])) {
fill: currentColor;
}

/* author stylesheet */
.share-icon {
fill: blue; /* 由于特殊性较高,适用 */
}

其它新伪类特性有情趣同学可以按照导图查阅一下相关文档资料。


完整演示


JS in CSS


前面提到过,使用CSS自定义属性的时候,可以通过JavaScript来操作自定义属性的值。其实还可以更强大一点,如果你对CSS Houdini熟悉的话,可以借助其特性,直接在CSS的代码中来操作CSS自定义属性


:root {
--property: document.write('hello world!');
}

window.onload = () => {
const doc = window.getComputedStyle(document.documentElement);
const cssProp = doc.getPropertyValue('--property');
new Function((cssProp))();
}

完整演示


Web layout


对于Web布局而言,前端就一直在探讨这方面的最优方式。早期的table布局,接着的floatposition相关的布局,多列布局,Flexbox布局和Grid布局等。Flexbox和Grid的出现,Web布局的灵活性越来越高。


如图不依赖媒体查询实现自动计算


屏幕录制2021-07-27 下午3.17.46.gif


CSS Grid中提供了很多强大的特性,比如:



  • fr单位,可以很好的帮助我们来计算容器可用空间

  • repeat()函数,允许我们给网格多个列指定相同的值。它也接受两个值:重复的次娄和重复的值

  • minmax()函数,能够让我们用最简单的CSS控制网格轨道的大小,其包括一个最小值和一个最大值

  • auto-fillauto-fit,配合repeat()函数使用,可以用来替代重复次数,可以根据每列的宽度灵活的改变网格的列数

  • max-contentmin-content,可以根据单元格的内容来确定列的宽度

  • grid-suto-flow,可以更好的让CSS Grid布局时能自动排列


结合这些功能点,布局会变得更轻松。比如我们要实现一个响应式的布局,很多时候都会依赖于媒体查询(@media)来处理,事实上,有了CSS Grid Layout之后,这一切变得更为简单,不需要依赖任何媒体查询就可以很好的实现响应式的布局。特别是当今这个时代,要面对的终端设备只会增加不会减少,那么希望布局更容易的适配这些终端的布局,那么CSS Grid Layout将会起到很大的作用。


完整示例


Grid和flex都是面向未来的最佳布局方案。我们不应该探讨谁优谁劣,而是应该取长补短结合使用。


混合模式和滤镜


能用CSS实现的就不用麻烦JavaScript — Part2一文提到混合模式。CSS混合模式和滤镜主要是用来处理图片的。熟悉PS之类软件的同学很容易理解里面的属性。


屏幕录制2021-07-19 上午11.12.39.gif


完整代码演示


CSS计数器


CSS计数器其实涉及到三个属性:counter-incrementcounter-resetcounter()。一般情况都是配合CSS的伪元素::before::aftercontent一起使用。可以用来计数


屏幕录制2021-07-27 下午3.15.06.gif


完整演示


SVG图标


对于SVG而言,它是一套独立而又成熟的体系,也有自己的相关规范(Scalable Vecgtor Graphics 2),即 SVG2。虽然该规范已经存在很久了,但很多有关于SVG相关的特性在不同的浏览器中得到的支持度也是有所不一致的。特别是SVG中的渐变和滤镜相关的特性。不过,随着技术的革新,在Web的应用当中SVG的使用越来越多,特别是SVG 图标相关的方面的运用。




  • 最早通过<img>标签来引用图标(每个图标一个文件)




  • 为了节省请求,提出了Sprites的概念,即将多个图标合并在一起,使用一个图片文件,借助background相关的属性来实现图标




  • 图片毕竟是位图,面对多种设备终端,或者说更易于控制图标颜色和大小,开始在使用Icon Font来制作Web图标




  • 当然,字体图标是解决了不少问题,但每次针对不同的图标的使用,需要自定义字体,也要加载相应的字体文件,相应的也带了一定的问题,比如说跨域问题,字体加载问题




  • 随着SVG的支持力度越来越强,大家开始在思考SVG,使用SVG来制作图标。该技术能解决我们前面碰到的大部分问题,特别是在而对众多终端设备的时候,它的优势越发明显




  • SVG和img有点类似,我们也可以借助<symbol>标签和<use>标签,将所有的SVG图标拼接在一起,有点类似于Sprites的技术,只不过在此称为SVG Sprites




<!-- HTML -->
<svg width="0" height="0" display="none" xmlns="http://www.w3.org/2000/svg">
<symbol id="half-circle" viewBox="0 0 106 57">...</symbol>
<!-- .... -->
<symbol id="icon-burger" viewBox="0 0 24 24">...</symbol>
</svg>

SVG Sprites和img Sprites有所不同,SVG Sprites就是一些代码(类似于HTML一样),估计没有接触过的同学会问,SVG Sprites对应的代码怎么来获取呢?其实很简单,可以借助一些设计软件来完成,比如Sketch。当然也可以使用一些构建工具,比如说svg-sprite。有了这个之后,在该使用的地方,使用<use>标签,指定<symbol>中相应的id值即可,比如:


<svg class="icon-nav-articles" width="26px" height="26px">
<use xlink:href="#icon-nav-articles"></use>
</svg>

使用SVG的图标还有一优势,我们可以在CSS中直接通过代码来控制图标的颜色:


.site-header .main-nav .main-sections>li>a>svg {
// ...
fill: none;
stroke-width: 2;
stroke: #c2c2c2;
}
.site-header .main-nav:hover>ul>li:nth-child(1) svg {
stroke: #ff8a00;
}

image.png


完整演示


写在最后


以上列举都是CSS一些优秀的特性。还有很多,有时间再收集更多分享给大家。这些新特性在不同的浏览器中差异性是有所不同的。但这并不是阻碍我们去学习和探索的原因所在。我们应该及时去了解并运用到,才可以做到对项目精益求精。


链接:https://juejin.cn/post/6989513390636924936

收起阅读 »

React 中的一些 Router 必备知识点

前言 每次开发新页面的时候,都免不了要去设计一个新的 URL,也就是我们的路由。其实路由在设计的时候不仅仅是一个由几个简单词汇和斜杠分隔符组成的链接,偶尔也可以去考虑有没有更“优雅”的设计方式和技巧。而在这背后,路由和组件之间的协作关系是怎样的呢?于是我以 R...
继续阅读 »

前言


每次开发新页面的时候,都免不了要去设计一个新的 URL,也就是我们的路由。其实路由在设计的时候不仅仅是一个由几个简单词汇和斜杠分隔符组成的链接,偶尔也可以去考虑有没有更“优雅”的设计方式和技巧。而在这背后,路由和组件之间的协作关系是怎样的呢?于是我以 React 中的 Router 使用方法为例,整理了一些知识点小记和大家分享~


React-Router


基本用法


通常我们使用 React-Router 来实现 React 单页应用的路由控制,它通过管理 URL,实现组件的切换,进而呈现页面的切换效果。


其最基本用法如下:


import { Router, Route } from 'react-router';
render((
<Router>
<Route path="/" component={App}/>
</Router>
), document.getElementById('app'));

亦或是嵌套路由:


在 React-Router V4 版本之前可以直接嵌套,方法如下:


<Router>
<Route path="/" render={() => <div>外层</div>}>
<Route path="/in" render={() => <div>内层</div>} />
</Route>
</Router>

上面代码中,理论上,用户访问 /in 时,会先加载 <div>外层</div>,然后在它的内部再加载 <div>内层</div>


然而实际运行上述代码却发现它只渲染出了根目录中的内容。后续对比 React-Router 版本发现,是因为在 V4 版本中变更了其渲染逻辑,原因据说是为了践行 React 的组件化理念,不能让 Route 标签看起来只是一个标签(奇怪的知识又增加了)。


现在较新的版本中,可以使用 Render 方法实现嵌套。


<Route
path="/"
render={() => (
<div>
<Route
path="/"
render={() => <div>外层</div>}
/>
<Route
path="/in"
render={() => <div>内层</div>}
/>
<Route
path="/others"
render={() => <div>其他</div>}
/>
</div>
)}
/>

此时访问 /in 时,会将“外层”和“内层”一起展示出来,类似地,访问 /others 时,会将“外层”和“其他”一起展示出来。


图片


路由传参小 Tips


在实际开发中,往往在页面切换时需要传递一些参数,有些参数适合放在 Redux 中作为全局数据,或者通过上下文传递,比如业务的一些共享数据,但有些参数则适合放在 URL 中传递,比如页面类型或详情页中单据的唯一标识 id。在处理 URL 时,除了问号带参数的方式,React-Router 能帮我们做什么呢?在这其中,Route 组件的 path 属性便可用于指定路由的匹配规则。


场景 1



描述:就想让普普通通的 URL 带个平平无奇的参数



那么,接下来我们可以这样干:


Case A:路由参数


path="/book/:id"

我们可以用冒号 + 参数名字的方式,将想要传递的参数添加到 URL 上,此时,当参数名字(本 Case 中是 id)对应的值改变时,将被认为是不同 URL。


Case B:查询参数


path="/book"

如果想要在页面跳转的时候问号带参数,那么 path 可以直接设计成既定的样子,参数由跳转方拼接。
在跳转时,有两种形式带上参数。其一是在 Link 组件的 to 参数中通过配置字符串并用问号带参数,其二是 to 参数可以接受一个对象,其中可以在 search 字段中配置想要传递的参数。


<Link to="/book?id=111" />
// 或者
<Link to={{
pathname: '/book',
search: '?id=111',
}}/>

此时,假设当前页面 URL中的 id 由111 修改为 222 时,该路由对应的组件(在上述例子中就是 React-Route 配置时 path="/book" 对应的页面/组件 )会更新,即执行 componentDidUpdate 方法,但不会被卸载,也就是说,不会执行 componentDidMount 方法。


Case C:查询参数隐身式带法


path="/book"

path 依旧设计成既定的样子,而在跳转时,可以通过 Link 中的 state 将参数传递给对应路由的页面。


<Link to={{
pathname: '/book',
state: { id: 111 }
}}/>

但一定要注意的是,尽管这种方式下查询参数不会明文传递了,但此时页面刷新会导致参数丢失(存储在 state 中的通病),So,灰常不推荐~~(其实不想明文可以进行加密处理,但一般情况下敏感信息是不建议放在 URL 中传递的~)


场景 2



描述:编辑/详情页,想要共用一个页面,URL 由不同的参数区分,此时我们希望,参数必须为 edit、detail、add 中的 1 个,不然需要跳转到 404 Not Found 页面。



path='/book/:pageType(edit|detail|add)'

如果不加括号中的内容 (edit|detail|add),当传入错误的参数(比如用户误操作、随便拼接 URL 的情况),则页面不会被 404 拦截,而是继续走下去开始渲染页面或调用接口,但此时很有可能导致接口传参错误或页面出错。


场景 3



描述:新增页和编辑页辣么像,我的新增页也想和编辑/详情共用一个页面。但是新增页不需要 id,编辑/详情页需要 id,使用同一个页面怎么办?



path='/book/:pageType(edit|detail|add)/:id?'

别急,可以用 ? 来解决,它意味着 id 不是一个必要参数,可传可不传。


场景 4



描述:我的 id 只能是数字,不想要字符串怎么办?



path='/book/:id(\\\d+)'

此时 id 不是数字时,会跳转 404,被认为 URL 对应的页面找不到啦。


底层依赖


有了这么多场景,那 Router 是怎样实现的呢?其实它底层是依赖了 path-to-regexp 方法。


var pathToRegexp = require('path-to-regexp')
// pathToRegexp(path, keys, options)
// 示例
var keys = []
var re = pathToRegexp('/foo/:bar', keys)
// re = /^\/foo\/([^\/]+?)\/?$/i
// keys = [{ name: 'bar', prefix: '/', delimiter: '/', optional: false, repeat: false, pattern: '[^\\/]+?' }]


delimiter:重复参数的定界符,默认是 '/',可配置



一些其他常用的路由正则通配符:




  • ? 可选参数




  • * 匹配 0 次或多次




  • + 匹配 1 次或多次




如果忘记写参数名字,而只写了路由规则,比如下述代码中 /:foo 后面的参数:


var re = pathToRegexp('/:foo/(.*)', keys)
// 匹配除“\n”之外的任何字符
// keys = [{ name: 'foo', ... }, { name: 0, ...}]
re.exec('/test/route')
//=> ['/test/route', 'test', 'route']

它也会被正确解析,只不过在方法处理的内部,未命名的参数名会被替换成数组下标。


取路由参数


path 带的参数,可以通过 this.props.match 获取


例如:


// url 为 /book/:pageType(edit|detail|add)
const { match } = this.props;
const { pageType } = match.params;

由于有 #,# 之后的所有内容都会被认为是 hash 的一部分,window.location.search 是取不到问号带的参数的。


比如:aaa.bbb.com/book-center…


那么在 React-Router 中,问号带的参数,可以通过 this.props.location (官方墙推 👍)获取。个人理解是因为 React-Router 帮我们做了处理,通过路由和 hash 值(window.location.hash)做了解析的封装。


例如:


// url 为 /book?pageType=edit
const { location } = this.props;
const searchParams = location.search; // ?pageType=edit

实际打印 props 参数发现,this.props.history.location 也可以取到问号参数,但不建议使用,因为 React 的生命周期(componentWillReceiveProps、componentDidUpdate)可能使它变得不可靠。(原因可参考:blog.csdn.net/zrq1210/art…


在早期的 React-Router 2.0 版本是可以用 location.query.pageType 来获取参数的,但是 V4.0 去掉了(有人认为查询参数不是 URL 的一部分,有人认为现在有很多第三方库,交给开发者自己去解析会更好,有个对此讨论的 Issue,有兴趣的可以自行获取 😊 github.com/ReactTraini…


针对上一节中场景 1 的 Case C,查询参数隐身式带法时(从 state 里带过去的),在 this.props.location.state 里可以取到(不推荐不推荐不推荐,刷新会没~)


Switch


<div>
<Route
path="/router/:type"
render={() => <div>影像</div>}
/>
<Route
path="/router/book"
render={() => <div>图书</div>}
/>
</div>

如果 <Route /> 是平铺的(用 div 包裹是因为 Router 下只能有一个元素),输入 /router/book 则影像和图书都会被渲染出来,如果想要只精确渲染其中一个,则需要 Switch


<Switch>
  <Route
    path="/router/:type"
    render={() => <div>影像</div>}
  />
  <Route
    path="/router/book"
    render={() => <div>图书</div>}
  />
</Switch>

Switch 的意思便是精准的根据不同的 path 渲染不同 Route 下的组件。
但是,加了 Switch 之后路由匹配规则是从上到下执行,一旦发现匹配,就不再匹配其余的规则了。因此在使用的时候一定要“百般小心”。


上面代码中,用户访问 /router/book 时,不会触发第二个路由规则(不会 展示“图书”),因为它会匹配 /router/:type 这个规则。因此,带参数的路径一般要写在路由规则的底部。


路由的基本原理


路由做的事情:管控 URL 变化,改变浏览器中的地址。


Router 做的事情:URL 改变时,触发渲染,渲染对应的组件。


URL 有两种,一种不带 #,一种带 #,分别对应 Browse 模式和 Hash 模式。


一般单页应用中,改变 URL,但是不重新加载页面的方式有两类:


Case 1(会触发路由监听事件):点击 前进、后退,或者调用的 history.back( )、history.forward( )


Case 2(不会触发路由监听事件):组件中调用 history.push( ) 和 history.replace( )


于是参考 「源码解析 」这一次彻底弄懂 React-Router 路由原理 一文,针对上述两种 Case,以及这两种 Case 分别对应的两种模式,作出如下总结。


图片



图片来源:「源码解析 」这一次彻底弄懂 React-Router 路由原理



Browser 模式


Case 1:


URL 改变,触发路由的监听事件 popstate,then,监听事件的回调函数 handlePopState 在回调中触发 history 的 setState 方法,产生新的 location 对象。state 改变,通知 Router 组件更新 location 并通过 context 上下文传递,匹配出符合的 Route 组件,最后由 <Route /> 组件取出对应内容,传递给渲染页面,渲染更新。


/* 简化版的 handlePopState (监听事件的回调) */
const handlePopState = (event)=>{
     /* 获取当前location对象 */
    const location = getDOMLocation(event.state)
    const action = 'POP'
     /* transitionManager 处理路由转换 */
    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
        if (ok) {
          setState({ action, location })
        } else {
          revertPop(location)
        }
    })
}

Case 2:
以 history.push 为例,首先依据你要跳转的 path 创建一个新的 location 对象,然后通过 window.history.pushState (H5 提供的 API )方法改变浏览器当前路由(即当前的 url),最后通过 setState 方法通知 Router,触发组件更新。


const push = (path, state) => {
  const action = 'PUSH'
   /* 创建location对象 */
  const location = createLocation(path, state, createKey(), history.location)
   /* 确定是否能进行路由转换 */
   transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
... // 此处省略部分代码
   const href = createHref(location)
   const { key, state } = location
   if (canUseHistory) {
     /* 改变 url */
     globalHistory.pushState({ key, state }, null, href)
     if (forceRefresh) {
       window.location.href = href
     } else {
      /* 改变 react-router location对象, 创建更新环境 */
       setState({ action, location })
     }
   } else {
     window.location.href = href
   }
 })
}

Hash 模式


Case 1:


增加监听,当 URL 的 Hash 发生变化时,触发 hashChange 注册的回调,回调中去进行相类似的操作,进而展示不同的内容。


window.addEventListener('hashchange',function(e){
/* 监听改变 */
})

Case 2:
history.push 底层调用 window.location.hash 来改变路由。history.replace 底层是调用 window.location.replace 改变路由。然后 setState 通知改变。


从一些参考资料中显示,出于兼容性的考虑(H5 的方法 IE10 以下不兼容),路由系统内部将 Hash 模式作为创建 History 对象的默认方法。(此处若有疑议,欢迎指正~)


Dva/Router


在实际项目中发现,Link,Route 都是从 dva/router 中引进来的,那么,Dva 在这之中做了什么呢?


答案:貌似没有做特殊处理,Dva 在 React-Router 上做了上层封装,会默认输出 React-Router 接口。


我们对 Router 做过的一些处理


Case 1:


项目代码的 src 目录下,不管有多少文件夹,路由一般会放在同一个 router.js 文件中维护,但这样会导致页面太多时,文件内容会越来越长,不便于查找和修改。


因此我们可以做一些小改造,在 src 下的每个文件夹中,创建自己的路由配置文件,以便管理各自的路由。但这种情况下 React-Router 是不能识别的,于是我们写了一个 Plugin 放在 Webpack 中,目的是将各个文件夹下的路由汇总,并生成 router-config.js 文件。之后,将该文件中的内容解析成组件需要的相关内容。插件实现方式可了解本团队另一篇文章: 手把手带你入门 Webpack Plugin


Case 2:


路由的 Hash 模式虽然兼容性好,但是也存在一些问题:



  1. 对于 SEO、前端埋点不太友好,不容易区分路径

  2. 原有页面有锚点时,使用 Hash 模式会出现冲突


因此公司内部做了一次 Hash 路由转 Browser 路由的改造。


如原有链接为:aaa.bbb.com/book-center…


改造方案为:


通过新增以下配置代码去掉 #


import createHistory from 'history/createBrowserHistroy';
const app = dva({
history: createHistory({
basename: '/book-center',
}),
onError,
});

同时,为了避免用户访问旧页面出现 404 的情况,前端需要在 Redirect 中配置重定向以及在 Nginx 中配置旧的 Hash 页面转发。


Case 3:


在实际项目中,其实我们也会去考虑用户未授权时路由跳转、页面 404 时路由跳转等不同情况,以下 Case 和代码仅供读者参考~


<Switch>
{
getRoutes(match.path, routerData).map(item =>
(
// 用户未授权处理,AuthorizedRoute 为项目中自己实现的处理组件
<AuthorizedRoute
{...item}
redirectPath="/exception/403"
/>
)
)
}
// 默认跳转页面
<Redirect from="/" exact to="/list" />
// 页面 404 处理
<Route render={props => <NotFound {...props} />} />
</Switch>

参考链接


「源码解析 」这一次彻底弄懂react-router路由原理


react-router v4 路由规则解析


二级动态路由的解决方案


链接:https://juejin.cn/post/6989764387275800607

收起阅读 »

老生常谈的JavaScript闭包

老生常谈的闭包 很多观点参考于《你不知道的JavaScript》、《JavaScript忍者秘籍》,私信我,可发电子书呀。进入正文: 也许你并不知道闭包是什么,但是你的代码中到处都有闭包的影子!也许你觉得闭包平时用不到,但是每次面试你都得去准备这个方面内容!也...
继续阅读 »

老生常谈的闭包


很多观点参考于《你不知道的JavaScript》、《JavaScript忍者秘籍》,私信我,可发电子书呀。进入正文:


也许你并不知道闭包是什么,但是你的代码中到处都有闭包的影子!也许你觉得闭包平时用不到,但是每次面试你都得去准备这个方面内容!也许你不觉得这个功能有什么用,但是很多框架的功能都是基于闭包去实现的!


下面我们将目光聚焦到以下几个问题,来理解一下闭包:



  • 词法作用域

  • 闭包的形成

  • 闭包的概念

  • 闭包的常见形式

  • 闭包的作用


闭包与词法作用域


在《你不知道的JavaScript》书有一句原话:闭包是基于词法作用域书写代码所产生的自然结果。所以在知道闭包之前,得先理解什么是词法作用域。以前的文章有过介绍: 理解JavaScript的词法作用域(可以稍微的翻看一下)。如果不想看,也没关系。 接下来我们分析一段代码,去理解什么是词法作用域,以及闭包的形成。


var a = 100

function foo() {
var a = 10
function test() {
var b = 9
console.log(a + b)
}
return test
}

var func = foo()
func()

作用域分析


image


上图我们清晰的反应了作用域嵌套。



  • 其中全局变量func就是test函数的引用。

  • test定义虽然定义在foo包裹的作用域内,但运行在全局作用域内。

  • test里面执行a + b的时候,a变量的值等于10而不是等于100。说明变量a的查找跟test在哪里执行没有关系。

  • 这就是词法作用域,在书写阶段作用域嵌套就已经确定好了,跟函数在哪里运行没有关系。


闭包的形成


对上诉代码进行作用域分析之后我们不难得出一个结论:test函数不管在哪里执行,他永远都属于foo作用域下得一个标识符,所以test永远对foo作用域持有访问的权限


正常情况下,foo函数执行完毕后,js的垃圾回收机制就会对foo函数作用域进行销毁。但是由于test函数对foo的作用域持有引用,所以只要程序还在运行中,你就永远不会知道test会在哪里被调用。 每当test要执行的时候,都会去访问foo作用域下的a变量。所以垃圾回收机制在foo执行完毕之后,不会对foo作用域进行销毁。这就形成了闭包


闭包的常见形式


以上我们分析了,闭包是怎么形成的。也分析了一段典型的闭包代码。前言中我们有说过一句话也许你并不知道闭包是什么,但是你的代码中到处都有闭包的影子。接下来我们分析一下,闭包的常见形式。


以下代码就算你不了解闭包,你也写过。类似的:


computed: {
add() {
return function(num) {
return num + 1
}
}
},


vue中可接受参数的计算属性



function init() {
$(.name).click(function handleClickBtn() {
alert('click btn')
})
}


初始化函数中使用jq绑定事件



$.ajax({url:"/api/getName",success:function(result){
console.log(result)
}});


ajax请求数据



window.addEventListener('click', function() {

})


原生注册事件



可以发现当把函数当做值传递的时候,就会形成闭包。《你不知道的JavaScript》给出了总结: 如果将函数(访问它们各自的词法作用域)当作第一
级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、
Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使
用了回调函数,实际上就是在使用闭包!


闭包的作用


闭包的一大用处是回调函数。还有一个作用是封装私变量。在《JavaScript忍者秘籍》有专门章节对其进行详细讲解。 下面我们看看闭包如何封装私有变量


私有变量封装


场景:有一个函数foo, 统计其被调用的次数


var num = 0

function foo() {
// 次数加一
num = num + 1
return num
}

foo()
foo()
foo()

console.log(num)


全局变量num来统计foo调用次数,最大的坏处在于,你不知道程序运行到什么时候,num的值被篡改。如果能够将num变量私有化,外部不能随意更改就好了。



function baz() {
var num = 0

return function() {
num++
return num
}
}

var foo = baz()
foo()
foo()
let fooNum = foo()

console.log(fooNum)


通过闭包,num被私有化在baz作用域下,程序运行过程中,不能随意更改baz下的num值。



小结



  • 闭包是基于词法作用域书写代码产生的自然结果

  • 闭包经常出现在我们的代码里面,常见的是回调函数

  • 闭包作用很多,回调函数,私有化变量等等


作者:limbo
链接:https://juejin.cn/post/6989148728649072653

收起阅读 »

带你了解SSO登录过程

什么是单点登录? 单点登录(Single Sign On),简称为SSO,是比较流行的企业业务整合的解决方案之一。 SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。 上图为sso的登录方式,对比传统登录方式,sso只做...
继续阅读 »

什么是单点登录?



单点登录(Single Sign On),简称为SSO,是比较流行的企业业务整合的解决方案之一。 SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。




上图为sso的登录方式,对比传统登录方式,sso只做一次身份验证,而传统需要做多次登录。下图为传统登录方式。



登录类型



  1. 无登录状态。需要用户登录。

  2. 已登录app1,再次登录app1。(token有效)无需用户登录

  3. 已登录app1,登录app2。(有登录状态)无需用户登录


登录原理图


1. 无登录状态登录图,入下图:



2. 再次登录app1



3. 登录app2, 由于app1等中,中心服务sso已经生成了登录状态TGC,app2就不需要扫码登录。



链接:https://juejin.cn/post/6988752065501593636

收起阅读 »

JS 解决超出精度数字问题

一、js 最大安全数字是 Math.pow(2,53) - 1,超出这个数字相加会出现精度丢失问题,可通过将数字转换为字符串操作的思路处理,如下: // js 最大安全数字: Math.pow(2, 53)-1 let a = '12345644456545...
继续阅读 »

一、js 最大安全数字是 Math.pow(2,53) - 1,超出这个数字相加会出现精度丢失问题,可通过将数字转换为字符串操作的思路处理,如下:


// js 最大安全数字: Math.pow(2, 53)-1

let a = '123456444565456.889'
let b = '121231456.32'
// a + b = '123456565796913.209'

function addTwo(a, b) {
//1.比较两个数长度 然后短的一方前面补0
if (a.length > b.length) {
let arr = Array(a.length - b.length).fill(0);
b = arr.join('') + b
} else if (a.length < b.length) {
let arr = Array(b.length - a.length).fill(0);
a = arr.join('') + a
}

//2.反转两个数 (这里是因为人习惯从左往右加 而数字相加是从右到左 因此反转一下比较好理解)
a = a.split('').reverse();
b = b.split('').reverse();

//3.循环两个数组 并进行相加 如果和大于10 则 sign = 1,当前位置的值为(和)
let sign = 0;//标记 是否进位
let newVal = [];//用于存储最后的结果
for (let j = 0; j < a.length; j++) {
let val = a[j] / 1 + b[j] / 1 + sign; //除1是保证都为数字 这里也可以用Number()
if (val >= 10) {
sign = 1;
newVal.unshift(val % 10) //这里用unshift而不是push是因为可以省了使用reverse
} else {
sign = 0;
newVal.unshift(val)
}
}

// 最后一次相加需要向前补充一位数字 ‘1’
return sign && newVal.unshift(sign) && newVal.join('') || newVal.join('')
}


// 参考其他朋友的精简写法
function addTwo(a,b) {
let temp = 0
let res = ''
a = a.split('')
b = b.split('')
while(a.length || b.length || temp) {
temp += Number(a.pop() || 0) + Number(b.pop() || 0)
res = (temp) + res
temp = temp > 9
}
return res.replace(/^0+/g, '')
}

二、当涉及到带有小数部分相加时,对上面方法进行一次封装,完整实现如下:


let a = '123456444565456.889'
let b = '121231456.32'
// a + b = '123456565796913.209'

function addTwo(a = '0',b = '0', isHasDecimal=false) {
//1.比较两个数长度 然后短的一方前面补0
if (a.length > b.length) {
let arr = Array(a.length - b.length).fill(0);
b = isHasDecimal && (b + arr.join('')) || arr.join('') + b
} else if (a.length < b.length) {
let arr = Array(b.length - a.length).fill(0);
a = isHasDecimal && (a + arr.join('')) || arr.join('') + a
}

//2.反转两个数 (这里是因为人习惯从左往右加 而数字相加是从右到左 因此反转一下比较好理解)
a = a.split('').reverse();
b = b.split('').reverse();


//3.循环两个数组 并进行相加 如果和大于10 则 sign = 1,当前位置的值为(和)
let sign = 0;//标记 是否进位
let newVal = [];//用于存储最后的结果
for (let j = 0; j < a.length; j++) {
let val = a[j] / 1 + b[j] / 1 + sign; //除1是保证都为数字 这里也可以用Number()
if (val >= 10) {
sign = 1;
newVal.unshift(val % 10) //这里用unshift而不是push是因为可以省了使用reverse
} else {
sign = 0;
newVal.unshift(val)
}
}

// 最后一次相加需要向前补充一位数字 ‘1’
return sign && newVal.unshift(sign) && newVal.join('') || newVal.join('')
}

function add(a,b) {
let num1 = String(a).split('.')
let num2 = String(b).split('.')
let intSum = addTwo(num1[0], num2[0])
let res = intSum

if (num1.length>1 || num2.length > 1) {
let decimalSum = addTwo(num1[1], num2[1], true)

if (decimalSum.length > (num1[1]||'0').length && decimalSum.length > (num2[1]||'0').length) {
intSum = addTwo(intSum, decimalSum[0])
decimalSum = decimalSum.slice(1)
res = `${intSum}.${decimalSum}`
} else {
res = `${intSum}.${decimalSum}`
}
}
return res
}
console.log(add(a, b)) // 123456565796913.209
// console.log(add('325', '988')) // 1313

链接:https://juejin.cn/post/6988693462618996749

收起阅读 »

文件下载,搞懂这9种场景就够了(下)

六、附件形式下载在服务端下载的场景中,附件形式下载是一种比较常见的场景。在该场景下,我们通过设置 Content-Disposition 响应头来指示响应的内容以何种形式展示,是以内联(inline)的形式,还是以附件(attachment...
继续阅读 »

六、附件形式下载

在服务端下载的场景中,附件形式下载是一种比较常见的场景。在该场景下,我们通过设置 Content-Disposition 响应头来指示响应的内容以何种形式展示,是以内联(inline)的形式,还是以附件(attachment)的形式下载并保存到本地。

Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="mouth.png"

而在 HTTP 表单的场景下, Content-Disposition 也可以作为 multipart body 中的消息头:

Content-Disposition: form-data
Content-Disposition: form-data; name="fieldName"
Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"

第 1 个参数总是固定不变的 form-data;附加的参数不区分大小写,并且拥有参数值,参数名与参数值用等号(=)连接,参数值用双引号括起来。参数之间用分号(;)分隔。

了解完 Content-Disposition 的作用之后,我们来看一下如何实现以附件形式下载的功能。Koa 是一个简单易用的 Web 框架,它的特点是优雅、简洁、轻量、自由度高。所以我们选择它来搭建文件服务,并使用 @koa/router 中间件来处理路由:

// attachment/file-server.js
const fs = require("fs");
const path = require("path");
const Koa = require("koa");
const Router = require("@koa/router");

const app = new Koa();
const router = new Router();
const PORT = 3000;
const STATIC_PATH = path.join(__dirname, "./static/");

// http://localhost:3000/file?filename=mouth.png
router.get("/file", async (ctx, next) => {
const { filename } = ctx.query;
const filePath = STATIC_PATH + filename;
const fStats = fs.statSync(filePath);
ctx.set({
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename=${filename}`,
"Content-Length": fStats.size,
});
ctx.body = fs.createReadStream(filePath);
});

// 注册中间件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
// ENOENT(无此文件或目录):通常是由文件操作引起的,这表明在给定的路径上无法找到任何文件或目录
ctx.status = error.code === "ENOENT" ? 404 : 500;
ctx.body = error.code === "ENOENT" ? "文件不存在" : "服务器开小差";
}
});
app.use(router.routes()).use(router.allowedMethods());

app.listen(PORT, () => {
console.log(`应用已经启动:http://localhost:${PORT}/`);
});

以上的代码被保存在 attachment 目录下的 file-server.js 文件中,该目录下还有一个 static 子目录用于存放静态资源。目前 static 目录下包含以下 3 个 png 文件。

├── file-server.js
└── static
├── body.png
├── eyes.png
└── mouth.png

当你运行 node file-server.js 命令成功启动文件服务器之后,就可以通过正确的 URL 地址来下载 static 目录下的文件。比如在浏览器中打开 http://localhost:3000/file?filename=mouth.png 这个地址,你就会开始下载 mouth.png 文件。而如果指定的文件不存在的话,就会返回文件不存在。

Koa 内核很简洁,扩展功能都是通过中间件来实现。比如常用的路由、CORS、静态资源处理等功能都是通过中间件实现。因此要想掌握 Koa 这个框架,核心是掌握它的中间件机制。若你想深入了解 Koa 的话,可以阅读 如何更好地理解中间件和洋葱模型 这篇文章。

在编写 HTML 网页时,对于一些简单图片,通常会选择将图片内容直接内嵌在网页中,从而减少不必要的网络请求,但是图片数据是二进制数据,该怎么嵌入呢?绝大多数现代浏览器都支持一种名为 Data URLs 的特性,允许使用 Base64 对图片或其他文件的二进制数据进行编码,将其作为文本字符串嵌入网页中。所以文件也可以通过 Base64 的格式进行传输,接下来我们将介绍如何下载 Base64 格式的图片。

附件形式下载示例:attachment

github.com/semlinker/f…

七、base64 格式下载

Base64 是一种基于 64 个可打印字符来表示二进制数据的表示方法。由于 2⁶ = 64 ,所以每 6 个比特为一个单元,对应某个可打印字符。3 个字节有 24 个比特,对应于 4 个 base64 单元,即 3 个字节可由 4 个可打印字符来表示。相应的转换过程如下图所示:

Base64 常用在处理文本数据的场合,表示、传输、存储一些二进制数据,包括 MIME 的电子邮件及 XML 的一些复杂数据。 在 MIME 格式的电子邮件中,base64 可以用来将二进制的字节序列数据编码成 ASCII 字符序列构成的文本。使用时,在传输编码方式中指定 base64。使用的字符包括大小写拉丁字母各 26 个、数字 10 个、加号 + 和斜杠 /,共 64 个字符,等号 = 用来作为后缀用途。

Base64 的相关内容就先介绍到这,如果你想进一步了解 Base64 的话,可以阅读 一文读懂base64编码 这篇文章。下面我们来看一下具体实现代码:

7.1 前端代码

html

在以下 HTML 代码中,我们通过 select 元素来让用户选择要下载的图片。当用户切换不同的图片时,img#imgPreview 元素中显示的图片会随之发生变化。

<h3>base64 下载示例</h3>
<img id="imgPreview" src="./static/body.png" />
<select id="picSelect">
<option value="body">body.png</option>
<option value="eyes">eyes.png</option>
<option value="mouth">mouth.png</option>
</select>
<button onclick="download()">下载</button>

js

const picSelectEle = document.querySelector("#picSelect");
const imgPreviewEle = document.querySelector("#imgPreview");

picSelectEle.addEventListener("change", (event) => {
imgPreviewEle.src = "./static/" + picSelectEle.value + ".png";
});

const request = axios.create({
baseURL: "http://localhost:3000",
timeout: 60000,
});

async function download() {
const response = await request.get("/file", {
params: {
filename: picSelectEle.value + ".png",
},
});
if (response && response.data && response.data.code === 1) {
const fileData = response.data.data;
const { name, type, content } = fileData;
const imgBlob = base64ToBlob(content, type);
saveAs(imgBlob, name);
}
}

在用户选择好需要下载的图片并点击下载按钮时,就会调用以上代码中的 download 函数。在该函数内部,我们利用 axios 实例的 get 方法发起 HTTP 请求来获取指定的图片。因为返回的是 base64 格式的图片,所以在调用 FileSaver 提供的 saveAs 方法前,我们需要将 base64 字符串转换成 blob 对象,该转换是通过以下的 base64ToBlob 函数来完成,该函数的具体实现如下所示:

function base64ToBlob(base64, mimeType) {
let bytes = window.atob(base64);
let ab = new ArrayBuffer(bytes.length);
let ia = new Uint8Array(ab);
for (let i = 0; i < bytes.length; i++) {
ia[i] = bytes.charCodeAt(i);
}
return new Blob([ab], { type: mimeType });
}

7.2 服务端代码

// base64/file-server.js
const fs = require("fs");
const path = require("path");
const mime = require("mime");
const Koa = require("koa");
const cors = require("@koa/cors");
const Router = require("@koa/router");

const app = new Koa();
const router = new Router();
const PORT = 3000;
const STATIC_PATH = path.join(__dirname, "./static/");

router.get("/file", async (ctx, next) => {
const { filename } = ctx.query;
const filePath = STATIC_PATH + filename;
const fileBuffer = fs.readFileSync(filePath);
ctx.body = {
code: 1,
data: {
name: filename,
type: mime.getType(filename),
content: fileBuffer.toString("base64"),
},
};
});

// 注册中间件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
ctx.body = {
code: 0,
msg: "服务器开小差",
};
}
});
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());

app.listen(PORT, () => {
console.log(`应用已经启动:http://localhost:${PORT}/`);
});

在以上代码中,对图片进行 Base64 编码的操作是定义在 /file 路由对应的路由处理器中。当该服务器接收到客户端发起的文件下载请求,比如 GET /file?filename=body.png HTTP/1.1 时,就会从 ctx.query 对象上获取 filename 参数。该参数表示文件的名称,在获取到文件的名称之后,我们就可以拼接出文件的绝对路径,然后通过 Node.js 平台提供的 fs.readFileSync 方法读取文件的内容,该方法会返回一个 Buffer 对象。在成功读取文件的内容之后,我们会继续调用 Buffer 对象的 toString 方法对文件内容进行 Base64 编码,最终所下载的图片将以 Base64 格式返回到客户端。

base64 格式下载示例:base64

github.com/semlinker/f…

八、chunked 下载

分块传输编码主要应用于如下场景,即要传输大量的数据,但是在请求在没有被处理完之前响应的长度是无法获得的。例如,当需要用从数据库中查询获得的数据生成一个大的 HTML 表格的时候,或者需要传输大量的图片的时候。

要使用分块传输编码,则需要在响应头配置 Transfer-Encoding 字段,并设置它的值为 chunked 或 gzip, chunked

Transfer-Encoding: chunked
Transfer-Encoding: gzip, chunked

响应头 Transfer-Encoding 字段的值为 chunked,表示数据以一系列分块的形式进行发送。需要注意的是 Transfer-Encoding 和 Content-Length 这两个字段是互斥的,也就是说响应报文中这两个字段不能同时出现。下面我们来看一下分块传输的编码规则:

  • 每个分块包含分块长度和数据块两个部分;
  • 分块长度使用 16 进制数字表示,以 \r\n 结尾;
  • 数据块紧跟在分块长度后面,也使用 \r\n 结尾,但数据不包含 \r\n
  • 终止块是一个常规的分块,表示块的结束。不同之处在于其长度为 0,即 0\r\n\r\n

了解完分块传输的编码规则,我们来看如何利用分块传输编码实现文件下载。

8.1 前端代码

html5

<h3>chunked 下载示例</h3>
<button onclick="download()">下载</button>

js

const chunkedUrl = "http://localhost:3000/file?filename=file.txt";

function download() {
return fetch(chunkedUrl)
.then(processChunkedResponse)
.then(onChunkedResponseComplete)
.catch(onChunkedResponseError);
}

function processChunkedResponse(response) {
let text = "";
let reader = response.body.getReader();
let decoder = new TextDecoder();

return readChunk();

function readChunk() {
return reader.read().then(appendChunks);
}

function appendChunks(result) {
let chunk = decoder.decode(result.value || new Uint8Array(), {
stream: !result.done,
});
console.log("已接收到的数据:", chunk);
console.log("本次已成功接收", chunk.length, "bytes");
text += chunk;
console.log("目前为止共接收", text.length, "bytes\n");
if (result.done) {
return text;
} else {
return readChunk();
}
}
}

function onChunkedResponseComplete(result) {
let blob = new Blob([result], {
type: "text/plain;charset=utf-8",
});
saveAs(blob, "hello.txt");
}

function onChunkedResponseError(err) {
console.error(err);
}

当用户点击 下载 按钮时,就会调用以上代码中的 download 函数。在该函数内部,我们会使用 Fetch API 来执行下载操作。因为服务端的数据是以一系列分块的形式进行发送,所以在浏览器端我们是通过流的形式进行接收。即通过 response.body 获取可读的 ReadableStream,然后用 ReadableStream.getReader() 创建一个读取器,最后调用 reader.read 方法来读取已返回的分块数据。

因为 file.txt 文件的内容是普通文本,且 result.value 的值是 Uint8Array 类型的数据,所以在处理返回的分块数据时,我们使用了 TextDecoder 文本解码器。一个解码器只支持一种特定文本编码,例如 utf-8iso-8859-2koi8cp1261gbk 等等。

如果收到的分块非 终止块result.done 的值是 false,则会继续调用 readChunk 方法来读取分块数据。而当接收到 终止块 之后,表示分块数据已传输完成。此时,result.done 属性就会返回 true。从而会自动调用 onChunkedResponseComplete 函数,在该函数内部,我们以解码后的文本作为参数来创建 Blob 对象。之后,继续使用 FileSaver 库提供的 saveAs 方法实现文件下载。

这里我们用 Wireshark 网络包分析工具,抓了个数据包。具体如下图所示:

从图中我们可以清楚地看到在 HTTP chunked response 下面包含了 Data chunk(数据块) 和 End of chunked encoding(终止块)。接下来,我们来看一下服务端的代码。

8.2 服务端代码

const fs = require("fs");
const path = require("path");
const Koa = require("koa");
const cors = require("@koa/cors");
const Router = require("@koa/router");

const app = new Koa();
const router = new Router();
const PORT = 3000;

router.get("/file", async (ctx, next) => {
const { filename } = ctx.query;
const filePath = path.join(__dirname, filename);
ctx.set({
"Content-Type": "text/plain;charset=utf-8",
});
ctx.body = fs.createReadStream(filePath);
});

// 注册中间件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
// ENOENT(无此文件或目录):通常是由文件操作引起的,这表明在给定的路径上无法找到任何文件或目录
ctx.status = error.code === "ENOENT" ? 404 : 500;
ctx.body = error.code === "ENOENT" ? "文件不存在" : "服务器开小差";
}
});
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());

app.listen(PORT, () => {
console.log(`应用已经启动:http://localhost:${PORT}/`);
});

在 /file 路由处理器中,我们先通过 ctx.query 获得 filename 文件名,接着拼接出该文件的绝对路径,然后通过 Node.js 平台提供的 fs.createReadStream 方法创建可读流。最后把已创建的可读流赋值给 ctx.body 属性,从而向客户端返回图片数据。

现在我们已经知道可以利用分块传输编码(Transfer-Encoding)实现数据的分块传输,那么有没有办法获取指定范围内的文件数据呢?对于这个问题,我们可以利用 HTTP 协议的范围请求。接下来,我们将介绍如何利用 HTTP 范围请求来下载指定范围的数据。

chunked 下载示例:chunked

github.com/semlinker/f…

九、范围下载

HTTP 协议范围请求允许服务器只发送 HTTP 消息的一部分到客户端。范围请求在传送大的媒体文件,或者与文件下载的断点续传功能搭配使用时非常有用。如果在响应中存在 Accept-Ranges 首部(并且它的值不为 “none”),那么表示该服务器支持范围请求。

在一个 Range 首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码。假如所请求的范围不合法,那么服务器会返回 416 Range Not Satisfiable 状态码,表示客户端错误。服务器允许忽略 Range 首部,从而返回整个文件,状态码用 200 。

Range 语法:

Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
  • unit:范围请求所采用的单位,通常是字节(bytes)。
  • <range-start>:一个整数,表示在特定单位下,范围的起始值。
  • <range-end>:一个整数,表示在特定单位下,范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束。

了解完 Range 语法之后,我们来看一下实际的使用示例:

# 单一范围
$ curl http://i.imgur.com/z4d4kWk.jpg -i -H "Range: bytes=0-1023"
# 多重范围
$ curl http://www.example.com -i -H "Range: bytes=0-50, 100-150"

9.1 前端代码

html

<h3>范围下载示例</h3>
<button onclick="download()">下载</button>

js

async function download() {
try {
let rangeContent = await getBinaryContent(
"http://localhost:3000/file.txt",
0, 100, "text"
);
const blob = new Blob([rangeContent], {
type: "text/plain;charset=utf-8",
});
saveAs(blob, "hello.txt");
} catch (error) {
console.error(error);
}
}

function getBinaryContent(url, start, end, responseType = "arraybuffer") {
return new Promise((resolve, reject) => {
try {
let xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.setRequestHeader("range", `bytes=${start}-${end}`);
xhr.responseType = responseType;
xhr.onload = function () {
resolve(xhr.response);
};
xhr.send();
} catch (err) {
reject(new Error(err));
}
});
}

当用户点击 下载 按钮时,就会调用 download 函数。在该函数内部会通过调用 getBinaryContent 函数来发起范围请求。对应的 HTTP 请求报文如下所示:

GET /file.txt HTTP/1.1
Host: localhost:3000
Connection: keep-alive
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36
Accept: */*
Accept-Encoding: identity
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,id;q=0.7
Range: bytes=0-100

而当服务器接收到该范围请求之后,会返回对应的 HTTP 响应报文:

HTTP/1.1 206 Partial Content
Vary: Origin
Access-Control-Allow-Origin: null
Accept-Ranges: bytes
Last-Modified: Fri, 09 Jul 2021 00:17:00 GMT
Cache-Control: max-age=0
Content-Type: text/plain; charset=utf-8
Date: Sat, 10 Jul 2021 02:19:39 GMT
Connection: keep-alive
Content-Range: bytes 0-100/2590
Content-Length: 101

从以上的 HTTP 响应报文中,我们见到了前面介绍的 206 状态码和 Accept-Ranges 首部。此外,通过 Content-Range 首部,我们就知道了文件的总大小。在成功获取到范围请求的响应体之后,我们就可以使用返回的内容作为参数,调用 Blob 构造函数创建对应的 Blob 对象,进而使用 FileSaver 库提供的 saveAs 方法来下载文件了。

9.2 服务端代码

const Koa = require("koa");
const cors = require("@koa/cors");
const serve = require("koa-static");
const range = require("koa-range");

const PORT = 3000;
const app = new Koa();

// 注册中间件
app.use(cors());
app.use(range);
app.use(serve("."));

app.listen(PORT, () => {
console.log(`应用已经启动:http://localhost:${PORT}/`);
});

服务端的代码相对比较简单,范围请求是通过 koa-range 中间件来实现的。由于篇幅有限,阿宝哥就不展开介绍了。感兴趣的小伙伴,可以自行阅读该中间件的源码。其实范围请求还可以应用在大文件下载的场景,如果文件服务器支持范围请求的话,客户端在下载大文件的时候,就可以考虑使用大文件分块下载的方案。

范围下载示例:range

github.com/semlinker/f…

十、大文件分块下载

相信有些小伙伴已经了解大文件上传的解决方案,在上传大文件时,为了提高上传的效率,我们一般会使用 Blob.slice 方法对大文件按照指定的大小进行切割,然后在开启多线程进行分块上传,等所有分块都成功上传后,再通知服务端进行分块合并。

那么对大文件下载来说,我们能否采用类似的思想呢?其实在服务端支持 Range 请求首部的条件下,我们也是可以实现大文件分块下载的功能,具体处理方案如下图所示:

因为在 JavaScript 中如何实现大文件并发下载? 这篇文章中,阿宝哥已经详细介绍了大文件并发下载的方案,所以这里就不展开介绍了。我们只回顾一下大文件并发下载的完整流程:

其实在大文件分块下载的场景中,我们使用了 async-pool 这个库来实现并发控制。该库提供了 ES7 和 ES6 两种不同版本的实现,代码很简洁优雅。如果你想了解 async-pool 是如何实现并发控制的,可以阅读 JavaScript 中如何实现并发控制? 这篇文章。

大文件分块下载示例:big-file

github.com/semlinker/f…

十一、总结

本文阿宝哥详细介绍了文件下载的 9 种场景,希望阅读完本文后,你对 9 种场景背后使用的技术有一定的了解。其实在传输文件的过程中,为了提高传输效率,我们可以使用 gzipdeflate 或 br 等压缩算法对文件进行压缩。由于篇幅有限,阿宝哥就不展开介绍了,如果你感兴趣的话,可以阅读 HTTP 传输大文件的几种方案 这篇文章。



链接:https://juejin.cn/post/6989413354628448264

收起阅读 »

文件下载,搞懂这9种场景就够了(上)

既然掘友有要求,连标题也帮阿宝哥想好了,那我们就来整一篇文章,总结一下文件下载的场景。 一般在我们工作中,主要会涉及到 9 种文件下载的场景,每一种场景背后都使用不同的技术,其中也有很多细节需要我们额外注意。今天阿宝哥就来带大家总结一下这 9 种场景,让大家能...
继续阅读 »





既然掘友有要求,连标题也帮阿宝哥想好了,那我们就来整一篇文章,总结一下文件下载的场景。


一般在我们工作中,主要会涉及到 9 种文件下载的场景,每一种场景背后都使用不同的技术,其中也有很多细节需要我们额外注意。今天阿宝哥就来带大家总结一下这 9 种场景,让大家能够轻松地应对各种下载场景。阅读本文后,你将会了解以下的内容:



在浏览器端处理文件的时候,我们经常会用到 Blob 。比如图片本地预览、图片压缩、大文件分块上传及文件下载。在浏览器端文件下载的场景中,比如我们今天要讲到的 a 标签下载showSaveFilePicker API 下载Zip 下载 等场景中,都会使用到 Blob ,所以我们有必要在学习具体应用前,先掌握它的相关知识,这样可以帮助我们更好地了解示例代码。


一、基础知识


1.1 了解 Blob


Blob(Binary Large Object)表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。在 JavaScript 中 Blob 类型的对象表示一个不可变、原始数据的类文件对象。 它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 用于数据操作。


Blob 对象由一个可选的字符串 type(通常是 MIME 类型)和 blobParts 组成:


在 JavaScript 中你可以通过 Blob 的构造函数来创建 Blob 对象,Blob 构造函数的语法如下:


const aBlob = new Blob(blobParts, options);

相关的参数说明如下:



  • blobParts:它是一个由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等对象构成的数组。DOMStrings 会被编码为 UTF-8。

  • options:一个可选的对象,包含以下两个属性:

    • type —— 默认值为 "",它代表了将会被放入到 blob 中的数组内容的 MIME 类型。

    • endings —— 默认值为 "transparent",用于指定包含行结束符 \n 的字符串如何被写入。 它是以下两个值中的一个: "native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变。




1.2 了解 Blob URL


Blob URL/Object URL 是一种伪协议,允许 Blob 和 File 对象用作图像、下载二进制数据链接等的 URL 源。在浏览器中,我们使用 URL.createObjectURL 方法来创建 Blob URL,该方法接收一个 Blob 对象,并为其创建一个唯一的 URL,其形式为 blob:<origin>/<uuid>,对应的示例如下:


blob:http://localhost:3000/53acc2b6-f47b-450f-a390-bf0665e04e59

浏览器内部为每个通过 URL.createObjectURL 生成的 URL 存储了一个 URL → Blob 映射。因此,此类 URL 较短,但可以访问 Blob。生成的 URL 仅在当前文档打开的状态下才有效。它允许引用 <img><a> 中的 Blob,但如果你访问的 Blob URL 不再存在,则会从浏览器中收到 404 错误。


上述的 Blob URL 看似很不错,但实际上它也有副作用。 虽然存储了 URL → Blob 的映射,但 Blob 本身仍驻留在内存中,浏览器无法释放它。映射在文档卸载时自动清除,因此 Blob 对象随后被释放。但是,如果应用程序寿命很长,那么 Blob 在短时间内将无法被浏览器释放。因此,如果你创建一个 Blob URL,即使不再需要该 Blob,它也会存在内存中。


针对这个问题,你可以调用 URL.revokeObjectURL(url) 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。


现在你已经了解了 Blob 和 Blob URL,如果你还意犹未尽,想深入理解 Blob 的话,可以阅读 你不知道的 Blob 这篇文章。下面我们开始介绍客户端文件下载的场景。


随着 Web 技术的不断发展,浏览器的功能也越来越强大。这些年出现了很多在线 Web 设计工具,比如在线 PS、在线海报设计器或在线自定义表单设计器等。这些 Web 设计器允许用户在完成设计之后,把生成的文件保存到本地,其中有一部分设计器就是利用浏览器提供的 Web API 来实现客户端文件下载。下面阿宝哥先来介绍客户端下载中,最常见的 a 标签下载 方案。


二、a 标签下载


html


<h3>a 标签下载示例</h3>
<div>
<img src="../images/body.png" />
<img src="../images/eyes.png" />
<img src="../images/mouth.png" />
</div>
<img id="mergedPic" src="http://via.placeholder.com/256" />
<button onclick="merge()">图片合成</button>
<button onclick="download()">图片下载</button>

在以上代码中,我们通过 img 标签引用了以下 3 张素材:



当用户点击 图片合成 按钮时,会将合成的图片显示在 img#mergedPic 容器中。在图片成功合成之后,用户可以通过点击 图片下载 按钮把已合成的图片下载到本地。对应的操作流程如下图所示:



由上图可知,整体的操作流程相对简单。接下来,我们来看一下 图片合成图片下载 的实现逻辑。


js


图片合成的功能,阿宝哥是直接使用 Github 上 merge-images 这个第三方库来实现。利用该库提供的 mergeImages(images, [options]) 方法,我们可以轻松地实现图片合成的功能。调用该方法后,会返回一个 Promise 对象,当异步操作完成后,合成的图片会以 Data URLs 的格式返回。


const mergePicEle = document.querySelector("#mergedPic");
const images = ["/body.png", "/eyes.png", "/mouth.png"].map(
(path) => "../images" + path
);
let imgDataUrl = null;

async function merge() {
imgDataUrl = await mergeImages(images);
mergePicEle.src = imgDataUrl;
}

而图片下载的功能是借助 dataUrlToBlobsaveFile 这两个函数来实现。它们分别用于实现 Data URLs => Blob 的转换和文件的保存,具体的代码如下所示:


function dataUrlToBlob(base64, mimeType) {
let bytes = window.atob(base64.split(",")[1]);
let ab = new ArrayBuffer(bytes.length);
let ia = new Uint8Array(ab);
for (let i = 0; i < bytes.length; i++) {
ia[i] = bytes.charCodeAt(i);
}
return new Blob([ab], { type: mimeType });
}

// 保存文件
function saveFile(blob, filename) {
const a = document.createElement("a");
a.download = filename;
a.href = URL.createObjectURL(blob);
a.click();
URL.revokeObjectURL(a.href)
}

因为本文的主题是介绍文件下载,所以我们来重点分析 saveFile 函数。在该函数内部,我们使用了 HTMLAnchorElement.download 属性,该属性值表示下载文件的名称。如果该名称不是操作系统的有效文件名,浏览器将会对其进行调整。此外,该属性的作用是表明链接的资源将被下载,而不是显示在浏览器中。


需要注意的是,download 属性存在兼容性问题,比如 IE 11 及以下的版本不支持该属性,具体如下图所示:



(图片来源:caniuse.com/download)


当设置好 a 元素的 download 属性之后,我们会调用 URL.createObjectURL 方法来创建 Object URL,并把返回的 URL 赋值给 a 元素的 href 属性。接着通过调用 a 元素的 click 方法来触发文件的下载操作,最后还会调用一次 URL.revokeObjectURL 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。


关于 a 标签下载 的内容就介绍到这,下面我们来介绍如何使用新的 Web API —— showSaveFilePicker 实现文件下载。



a 标签下载示例:a-tag


github.com/semlinker/f…



三、showSaveFilePicker API 下载


showSaveFilePicker API 是 Window 接口中定义的方法,调用该方法后会显示允许用户选择保存路径的文件选择器。该方法的签名如下所示:



let FileSystemFileHandle = Window.showSaveFilePicker(options);


showSaveFilePicker 方法支持一个对象类型的可选参数,可包含以下属性:



  • excludeAcceptAllOption:布尔类型,默认值为 false。默认情况下,选择器应包含一个不应用任何文件类型过滤器的选项(由下面的 types 选项启用)。将此选项设置为 true 意味着 types 选项不可用。

  • types:数组类型,表示允许保存的文件类型列表。数组中的每一项是包含以下属性的配置对象:

    • description(可选):用于描述允许保存文件类型类别。

    • accept:是一个对象,该对象的 keyMIME 类型,值是文件扩展名列表。




调用 showSaveFilePicker 方法之后,会返回一个 FileSystemFileHandle 对象。有了该对象,你就可以调用该对象上的方法来操作文件。比如调用该对象上的 createWritable 方法之后,就会返回 FileSystemWritableFileStream 对象,就可以把数据写入到文件中。具体的使用方式如下所示:


async function saveFile(blob, filename) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [
{
description: "PNG file",
accept: {
"image/png": [".png"],
},
},
{
description: "Jpeg file",
accept: {
"image/jpeg": [".jpeg"],
},
},
],
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return handle;
} catch (err) {
console.error(err.name, err.message);
}
}

function download() {
if (!imgDataUrl) {
alert("请先合成图片");
return;
}
const imgBlob = dataUrlToBlob(imgDataUrl, "image/png");
saveFile(imgBlob, "face.png");
}

当你使用以上更新后的 saveFile 函数,来保存已合成的图片时,会显示以下保存文件选择器:



由上图可知,相比 a 标签下载 的方式,showSaveFilePicker API 允许你选择文件的下载目录、选择文件的保存格式和更改存储的文件名称。看到这里是不是觉得 showSaveFilePicker API 功能挺强大的,不过可惜的是该 API 目前的兼容性还不是很好,具体如下图所示:



(图片来源:caniuse.com/?search=sho…


其实 showSaveFilePickerFile System Access API 中定义的方法,除了 showSaveFilePicker 之外,还有 showOpenFilePickershowDirectoryPicker 等方法。如果你想在实际项目中使用这些 API 的话,可以考虑使用 GoogleChromeLabs 开源的 browser-fs-access 这个库,该库可以让你在支持平台上更方便地使用 File System Access API,对于不支持的平台会自动降级使用 <input type="file"><a download> 的方式。


可能大家对 browser-fs-access 这个库会比较陌生,但是如果换成是 FileSaver.js 这个库的话,应该就比较熟悉了。接下来,我们来介绍如何利用 FileSaver.js 这个库实现客户端文件下载。



showSaveFilePicker API 下载示例:save-file-picker


github.com/semlinker/f…



四、FileSaver 下载


FileSaver.js 是在客户端保存文件的解决方案,非常适合在客户端上生成文件的 Web 应用程序。它是 HTML5 版本的 saveAs() FileSaver 实现,支持大多数主流的浏览器,其兼容性如下图所示:



(图片来源:github.com/eligrey/Fil…


在引入 FileSaver.js 这个库之后,我们就可以使用它提供的 saveAs 方法来保存文件。该方法对应的签名如下所示:



FileSaver saveAs(
Blob/File/Url,
optional DOMString filename,
optional Object { autoBom }
)


saveAs 方法支持 3 个参数,第 1 个参数表示它支持 Blob/File/Url 三种类型,第 2 个参数表示文件名(可选),而第 3 个参数表示配置对象(可选)。如果你需要 FlieSaver.js 自动提供 Unicode 文本编码提示(参考:字节顺序标记),则需要设置 { autoBom: true}


了解完 saveAs 方法之后,我们来举 3 个具体的使用示例:


1. 保存文本


let blob = new Blob(["大家好,我是阿宝哥!"], { type: "text/plain;charset=utf-8" });
saveAs(blob, "hello.txt");

2. 保存线上资源


saveAs("https://httpbin.org/image", "image.jpg");

如果下载的 URL 地址与当前站点是同域的,则将使用 a[download] 方式下载。否则,会先使用 同步的 HEAD 请求 来判断是否支持 CORS 机制,若支持的话,将进行数据下载并使用 Blob URL 实现文件下载。如果不支持 CORS 机制的话,将会尝试使用 a[download] 方式下载。


标准的 W3C File API Blob 接口并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 Blob.js 来解决兼容性问题。



(图片来源:caniuse.com/?search=blo…


3. 保存 canvas 画布内容


let canvas = document.getElementById("my-canvas");
canvas.toBlob(function(blob) {
saveAs(blob, "abao.png");
});

需要注意的是 canvas.toBlob() 方法并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 canvas-toBlob.js 来解决兼容性问题。



(图片来源:caniuse.com/?search=toB…


介绍完 saveAs 方法的使用示例之后,我们来更新前面示例中的 download 方法:


function download() {
if (!imgDataUrl) {
alert("请先合成图片");
return;
}
const imgBlob = dataUrlToBlob(imgDataUrl, "image/png");
saveAs(imgBlob, "face.png");
}

很明显,使用 saveAs 方法之后,下载已合成的图片就很简单了。如果你对 FileSaver.js 的工作原理感兴趣的话,可以阅读 聊一聊 15.5K 的 FileSaver,是如何工作的? 这篇文章。前面介绍的场景都是直接下载单个文件,其实我们也可以在客户端同时下载多个文件,然后把已下载的文件压缩成 Zip 包并下载到本地。



FileSaver 下载示例:file-saver


github.com/semlinker/f…



五、Zip 下载


文件上传,搞懂这8种场景就够了 这篇文章中,阿宝哥介绍了如何利用 JSZip 这个库提供的 API,把待上传目录下的所有文件压缩成 ZIP 文件,然后再把生成的 ZIP 文件上传到服务器。同样,利用 JSZip 这个库,我们可以实现在客户端同时下载多个文件,然后把已下载的文件压缩成 Zip 包,并下载到本地的功能。对应的操作流程如下图所示:



在以上 Gif 图中,阿宝哥演示了把 3 张素材图,打包成 Zip 文件并下载到本地的过程。接下来,我们来介绍如何使用 JSZip 这个库实现以上的功能。


html


<h3>Zip 下载示例</h3>
<div>
<img src="../images/body.png" />
<img src="../images/eyes.png" />
<img src="../images/mouth.png" />
</div>
<button onclick="download()">打包下载</button>

js


const images = ["body.png", "eyes.png", "mouth.png"];
const imageUrls = images.map((name) => "../images/" + name);

async function download() {
let zip = new JSZip();
Promise.all(imageUrls.map(getFileContent)).then((contents) => {
contents.forEach((content, i) => {
zip.file(images[i], content);
});
zip.generateAsync({ type: "blob" }).then(function (blob) {
saveAs(blob, "material.zip");
});
});
}

// 从指定的url上下载文件内容
function getFileContent(fileUrl) {
return new JSZip.external.Promise(function (resolve, reject) {
// 调用jszip-utils库提供的getBinaryContent方法获取文件内容
JSZipUtils.getBinaryContent(fileUrl, function (err, data) {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}

在以上代码中,当用户点击 打包下载 按钮时,就会调用 download 函数。在该函数内部,会先调用 JSZip 构造函数创建 JSZip 对象,然后使用 Promise.all 函数来确保所有的文件都下载完成后,再调用 file(name, data [,options]) 方法,把已下载的文件添加到前面创建的 JSZip 对象中。最后通过 zip.generateAsync 函数来生成 Zip 文件并使用 FileSaver.js 提供的 saveAs 方法保存 Zip 文件。



Zip 下载示例:Zip


github.com/semlinker/f…





收起阅读 »

我给鸿星尔克写了一个720°看鞋展厅

最近因为鸿星尔克给河南捐了5000万物资,真的是看哭了很多的网友,普通一家公司捐款5000万可能不会有这样的共情,但是看了鸿星尔克的背景之后,发现真的是令人心酸。鸿星尔克2020年的营收是28亿,但是利润却是亏损2个亿,甚至连微博的官方账号都舍不得开会员,在这...
继续阅读 »


最近因为鸿星尔克给河南捐了5000万物资,真的是看哭了很多的网友,普通一家公司捐款5000万可能不会有这样的共情,但是看了鸿星尔克的背景之后,发现真的是令人心酸。鸿星尔克2020年的营收是28亿,但是利润却是亏损2个亿,甚至连微博的官方账号都舍不得开会员,在这种情况下,还豪气地捐赠5000万,真的是破防了。



网友还称鸿星尔克,特别像是老一辈人省吃俭用一分一毛攒起来的存款,小心翼翼存在铁盒里。一听说祖国需要,立马拿出铁盒子,哗~全导给你。让上最贵的鞋,拿出了双 249 的。


然后我去鸿星尔克的官网看了看他家的鞋子。



好家伙,等了55秒,终于把网站打开了。。。(看来真的是年久失修了,太令人心酸了。作为一个前端看到这一幕真的疯了...)


恰逢周末,我就去了离我最近的鸿星尔克看了看。买了一双 136 的鞋子(是真的便宜,最关键的还是舒服)。




买回家后心里想着,像毒APP上面那些阿迪、耐克的都有线上 360° 查看,就想着能不能给鸿星尔克也做一个呢,算作为一个技术人员为它出的一份绵薄之力。


行动


有了这个想法后,我就立马开始行动了。然后我大致总结了以下几个步骤:


1.建模


2.使用 Thee.js 创建场景


3.导入模型


4.加入 Three.js 控制器


由于之前学习了一些 Three.js 的相关知识,因此对于有了模型后的展示还是比较有底的,因此其中最麻烦的就是建模了,因为我们需要把一个3维的东西,放到电脑中。对于2维的物体,想要放到电脑上,我们都知道,非常简单,就是使用相机拍摄一下就好了,但是想要在电脑中查看3维的物体却不一样,它多了一个维度,增加的量确实成倍的增长,于是开始查阅各种资料来看如何能够建立一个物体的模型。



查了一堆资料,想要建立一个鞋子模型,总结起来共有两种模式。


1.摄影绘图法(photogrammetry):通过拍摄照片,通过纯算法转化成3d模型,在图形学中也称为单目重建 。


2.雷达扫描(Lidar scan):是通过激光雷达扫描,何同学的最新一期视频中也提到了这种方式扫描出点云。


放上一个我总结的大纲,大部分都是国外的网站/工具。



一开始搜索结果中,绝大多数人都在提 123D Catch,并且也看了很多视频,说它建立模型快速且逼真,但是再进一步的探索中,发现它貌似在2017年的时候业务就进行了合并进行了整合。整合后的 ReMake 需要付费,处于成本考虑我就没有继续了。(毕竟只是demo尝试)



后面发现一款叫做 Polycam 的软件,成品效果非常好。



但是当我选择使用的时候,发现它需要激光雷达扫描仪(LiDAR),必须要 iphone 12 pro 以上的机型才能使用。



最终我选择了 Reality Capture 来创建模型,他是可以通过多张图片来合成一个模型的方式,看了一些b站的视频,感觉它的呈像效果也不错,不过它只支持 windows,且运行内存需要8g,这个时候我搬出了我7年前的windows电脑... 发现没想到它还能服役,也是惊喜。


建模


下面就开始正式的内容,主角就是我这次买的鞋子(开头放的那双)



然后我们开始拍摄,首先我环绕着鞋子随意拍摄了一组照片,但是发现这个模型真的差强人意...



后面我也采用了白幕的形式,加了一层背景,后面发现还是不行,应用更多是识别到了后面的背景数字。



最后... 还是在楠溪的帮助下,将背景图P成了白色。



皇天不负有心人,最终的效果还不错,基本上的点云模型已经出来了。(这感觉还不错,有种电影里的黑科技的感觉)



下面是模型的样子,已经是我花了一天的时间训练出的最好的模型了(但是还是有一些轻微的粗糙)



为了尽可能的让模型看起来完美,中间一共花了一天的时间来测试模型,因为拍摄的角度以及非常影响模型的生成,我一共拍了大约1G的图片,大约500张图片(由于前期不懂如何调整模型,因此尝试了大量的方法。)




有了模型后,我们就可以将它展示在互联网上啦,这里采用了 Three.js(由于这里考虑到很多人不是这块领域相关的人员,因此会讲的比较基础,大佬们请见谅。)


构建应用


主要由三部分组成(构建场景、模型加载、添加控制器)


1.构建3d场景


首先我们先加载 Three.js


<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.129.0/build/three.module.js';
</script>

然后创建一个WebGL渲染器


const container = document.createElement( 'div' );
document.body.appendChild( container );

let renderer = new THREE.WebGLRenderer( { antialias: true } );
container.appendChild( renderer.domElement );

再将添加一个场景和照相机


let scene = new THREE.Scene();

相机语法PerspectiveCamera(fov, aspect, near, far)



// 设置一个透视摄像机
camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.25, 1000 );
// 设置相机的位置
camera.position.set( 0, 1.5, -30.0 );

将场景和相机添加到 WebGL渲染器中。


renderer.render( scene, camera );

2.模型加载


由于我们的导出的模型是 OBJ 格式的,体积非常大,我先后给它压成了 gltf、glb 的格式,Three.js 已经帮我们写好了GLTF 的loader,我们直接使用即可。


// 加载模型
const gltfloader = new GLTFLoader();
const draco = new DRACOLoader();
draco.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
gltfloader.setDRACOLoader(draco);
gltfloader.setPath('assets/obj4/');
gltfloader.load('er4-1.glb', function (gltf) {
gltf.scene.scale.set(0.2, 0.2, 0.2); //设置缩放
gltf.scene.rotation.set(-Math.PI / 2, 0, 0) // 设置角度
const Orbit = new THREE.Object3D();
Orbit.add(gltf.scene);
Orbit.rotation.set(0, Math.PI / 2, 0);

scene.add(Orbit);
render();
});

但是通过以上代码打开我们的页面会是一片漆黑,这个是因为我们的还没有添加光照。于是我们继续来添加一束光,来照亮我们的鞋子。


// 设置灯光
const directionalLight = new THREE.AmbientLight(0xffffff, 4);
scene.add(directionalLight);
directionalLight.position.set(2, 5, 5);


现在能够清晰地看到我们的鞋子了,仿佛黑暗中看到了光明,但是这时候无法通过鼠标或者手势控制的,需要用到我们 Three.js 的控制器来帮助我们控制我们的模型角度。


3.添加控制器


const controls = new OrbitControls( camera, renderer.domElement );
controls.addEventListener('change', render );
controls.minDistance = 2; // 限制缩放
controls.maxDistance = 10;
controls.target.set( 0, 0, 0 ); // 旋转中心点
controls.update()

这个时候我们就能从各个角度看我们的鞋子啦。



大功告成!


在线体验地址: resume.mdedit.online/erke/


开源地址(包含了工具、运行步骤以及实际demo):github.com/hua1995116/…


后续规划


由于时间有限(花了一整天周末的时间),还是没有得到一个非常完美的模型,后续也会继续探索这块的实现,再后续将探索是否能实现一条自动化的方式,从拍摄到模型的展示,以及其实我们有了模型后,离AR试鞋也不远了,如果你有兴趣或者有更好的想法建议,欢迎和我交流。


最后非常感谢楠溪,放下了原本计划的一些事情来帮助一起拍摄加后期处理,以及陪我处理了一整天的模型。(条件有限的拍摄真的太艰难了。)


还有祝鸿星尔克能够成为长久的企业,保持创新,做出更多更好的运动服装,让此刻的全民青睐的状态保持下去。


附录


得出的几个拍摄技巧,也是官方提供的。


1.不要限制图像数量,RealityCapture可以处理任意张图片。


2.使用高分辨率的图片。


3.场景表面中的每个点应该在至少两个高质量的图像中清晰可见。


4.拍照时以圆形方式围绕物体移动。


5.移动的角度不要超过30度以上。


6.从拍摄整个对象的照片,移动它然后关注细节,保证大小都差不多。


7.完整的环绕。(不要绕半圈就结束了)


链接:https://juejin.cn/post/6989439618877751303

收起阅读 »

「干货」面试官问我如何快速搜索10万个矩形?——我说RBush

前言 亲爱的coder们,我又来了,一个喜欢图形的程序员👩‍💻,前几篇文章一直都在教大家怎么画地图、画折线图、画烟花🎆,难道图形就是这样嘛,当然不是,一个很简单的问题, 如果我在canvas中画了10万个点,鼠标在画布上移动,靠近哪一个点,哪一个点高亮。有同学...
继续阅读 »

前言


亲爱的coder们,我又来了,一个喜欢图形的程序员👩‍💻,前几篇文章一直都在教大家怎么画地图、画折线图、画烟花🎆,难道图形就是这样嘛,当然不是,一个很简单的问题, 如果我在canvas中画了10万个点,鼠标在画布上移动,靠近哪一个点,哪一个点高亮。有同学就说遇事不决 用for循环遍历哇,我也知道可以用循环解决哇,循环解决几百个点可以,如果是几万甚至几百万个点你还循环,你想让用户等死?这时就引入今天的主角他来了就是Rbush


RBUSH


我们先看下定义,这个rbush到底能帮我们解决了什么问题?



RBush是一个high-performanceJavaScript库,用于点和矩形的二维空间索引。它基于优化的R-tree数据结构,支持大容量插入。空间索引是一种用于点和矩形的特殊数据结构,允许您非常高效地执行“此边界框中的所有项目”之类的查询(例如,比在所有项目上循环快数百倍)。它最常用于地图和数据可视化。



看定义他是基于优化的R-tree数据结构,那么R-tree又是什么呢?



R-trees是用于空间访问方法的树数据结构,即用于索引多维信息,例如地理坐标矩形多边形。R-tree 在现实世界中的一个常见用途可能是存储空间对象,例如餐厅位置或构成典型地图的多边形:街道、建筑物、湖泊轮廓、海岸线等,然后快速找到查询的答案例如“查找我当前位置 2 公里范围内的所有博物馆”、“检索我所在位置 2 公里范围内的所有路段”(以在导航系统中显示它们)或“查找最近的加油站”(尽管不将道路进入帐户)。



R-tree的关键思想是将附近的对象分组,并在树的下一个更高级别中用它们的最小边界矩形表示它们;R-tree 中的“R”代表矩形。由于所有对象都位于此边界矩形内,因此不与边界矩形相交的查询也不能与任何包含的对象相交。在叶级,每个矩形描述一个对象;在更高级别,聚合包括越来越多的对象。这也可以看作是对数据集的越来越粗略的近似。说着有点抽象,还是看一张图:


R-tree


我来详细解释下这张图:



  1. 首先我们假设所有数据都是二维空间下的点,我们从图中这个R8区域说起,也就是那个shape of data object。别把那一块不规则图形看成一个数据,我们把它看作是多个数据围成的一个区域。为了实现R树结构,我们用一个最小边界矩形恰好框住这个不规则区域,这样,我们就构造出了一个区域:R8。R8的特点很明显,就是正正好好框住所有在此区域中的数据。其他实线包围住的区域,如R9,R10,R12等都是同样的道理。这样一来,我们一共得到了12个最最基本的最小矩形。这些矩形都将被存储在子结点中。

  2. 下一步操作就是进行高一层次的处理。我们发现R8,R9,R10三个矩形距离最为靠近,因此就可以用一个更大的矩形R3恰好框住这3个矩形。

  3. 同样道理,R15,R16被R6恰好框住,R11,R12被R4恰好框住,等等。所有最基本的最小边界矩形被框入更大的矩形中之后,再次迭代,用更大的框去框住这些矩形。


算法


插入


为了插入一个对象,树从根节点递归遍历。在每一步,检查当前目录节点中的所有矩形,并使用启发式方法选择候选者,例如选择需要最少放大的矩形。搜索然后下降到这个页面,直到到达叶节点。如果叶节点已满,则必须在插入之前对其进行拆分。同样,由于穷举搜索成本太高,因此采用启发式方法将节点一分为二。将新创建的节点添加到上一层,这一层可以再次溢出,并且这些溢出可以向上传播到根节点;当这个节点也溢出时,会创建一个新的根节点并且树的高度增加。


搜索


范围搜索中,输入是一个搜索矩形(查询框)。搜索从树的根节点开始。每个内部节点包含一组矩形和指向相应子节点的指针,每个叶节点包含空间对象的矩形(指向某个空间对象的指针可以在那里)。对于节点中的每个矩形,必须确定它是否与搜索矩形重叠。如果是,则还必须搜索相应的子节点。以递归方式进行搜索,直到遍历所有重叠节点。当到达叶节点时,将针对搜索矩形测试包含的边界框(矩形),如果它们位于搜索矩形内,则将它们的对象(如果有)放入结果集中。


读着就复杂,但是社区里肯定有大佬替我们封装好了,就不用自己再去手写了,写了写估计不一定对哈哈哈。


RBUSH 用法


用法


// as a ES module
import RBush from 'rbush';

// as a CommonJS module
const RBush = require('rbush');

创建一个树🌲


const tree = new RBush(16);

后面的16 是一个可选项,RBush 的一个可选参数定义了树节点中的最大条目数。 9(默认使用)是大多数应用程序的合理选择。 更高的值意味着更快的插入和更慢的搜索,反之亦然


插入数据📚


const item = {
   minX: 20,
   minY: 40,
   maxX: 30,
   maxY: 50,
   foo: 'bar'
};
tree.insert(item);

删除数据📚


tree.remove(item);

默认情况下,RBush按引用移除对象。但是,您可以传递一个自定义的equals函数,以便按删除值进行比较,当您只有需要删除的对象的副本时(例如,从服务器加载),这很有用:


tree.remove(itemCopy, (a, b) => {
   return a.id === b.id;
});

删除所有数据


tree.clear();

搜索🔍


const result = tree.search({
   minX: 40,
   minY: 20,
   maxX: 80,
   maxY: 70
});

api 介绍完毕下面👇开始进入实战环节一个简单的小案例——canvas中画布搜索🔍的。


用图片填充画布


填充画布的的过程中,这里和大家介绍一个canvas点的api ——createPattern



CanvasRenderingContext2D .createPattern()是 Canvas 2D API 使用指定的图像 (CanvasImageSource)创建模式的方法。 它通过repetition参数在指定的方向上重复元图像。此方法返回一个CanvasPattern对象。



第一个参数是填充画布的数据源可以是下面这:



第二个参数指定如何重复图像。允许的值有:



如果为空字符串 ('') 或 null (但不是 undefined),repetition将被当作"repeat"。


代码如下:


 class search { 
constructor() {
this.canvas = document.getElementById('map')
this.ctx = this.canvas.getContext('2d')
this.tree = new RBush()
this.fillCanvas()
}

fillCanvas() {
const img = new Image()
img.src ='https://ztifly.oss-cn-hangzhou.aliyuncs.com/%E6%B2%B9%E7%94%BB.jpeg'
img.onload = () => {
const pattern = this.ctx.createPattern(img, '')
this.ctx.fillStyle = pattern
this.ctx.fillRect(0, 0, 960, 600)
}
}
}

这边有个小提醒的就是图片加载成功的回调里面去给画布创建模式,然后就是this 指向问题, 最后就是填充画布。


如图:


image-20210722220842530


数据的生成


数据生成主要在画布的宽度 和长度的范围内随机生成10万个矩形。插入到rbush数据的格式就是有minX、maxX、minY、maxY。这个实现的思路也是非常的简单哇, minX用画布的长度Math.random minY 就是画布的高度Math.random. 然后最大再此基础上随机*20 就OK了,一个矩形就形成了。这个实现的原理就是左上和右下两个点可以形成一个矩形。代码如下:


randomRect() {
 const rect = {}
 rect.minX = parseInt(Math.random() * 960)
 rect.maxX = rect.minX + parseInt(Math.random() * 20)
 rect.minY = parseInt(Math.random() * 600)
 rect.maxY = rect.minY + parseInt(Math.random() * 20)
 rect.name = 'rect' + this.id
 this.id += 1
 return rect
}

然后循环加入10万条数据:


loadItems(n = 100000) {
let items = []
for (let i = 0; i < n; i++) {
  items.push(this.randomRect())
}
this.tree.load(items)
}

画布填充


这里我创建一个和当前画布一抹一样的canvas,但是里面画了n个矩形,将这个画布 当做图片填充到原先的画布中。


memCanva() {
 this.memCanv = document.createElement('canvas')
 this.memCanv.height = 600
 this.memCanv.width = 960
 this.memCtx = this.memCanv.getContext('2d')
 this.memCtx.strokeStyle = 'rgba(255,255,255,0.7)'
}

loadItems(n = 10000) {
 let items = []
 for (let i = 0; i < n; i++) {
   const item = this.randomRect()
   items.push(item)
   this.memCtx.rect(
     item.minX,
     item.minY,
     item.maxX - item.minX,
     item.maxY - item.minY
  )
}
 this.memCtx.stroke()
 this.tree.load(items)
}

然后在加载数据的时候,在当前画布画了10000个矩形。这时候新建的画布有东西了,然后我们用一个drawImage api ,


这个api做了这样的一个事,就是将画布用特定资源填充,然后你可以改变位置,后面有参数可以修改,这里我就不多介绍了, 传送门


this.ctx.drawImage(this.memCanv, 0, 0)

我们看下效果:
画布填充效果


添加交互


添加交互, 就是对画布添加mouseMove 事件, 然后呢我们以鼠标的位置,形成一个搜索的数据,然后我在统计花费的时间,然后你就会发现,这个Rbush 是真的快。代码如下:


 this.canvas.addEventListener('mousemove', this.handler.bind(this))
// mouseMove 事件
handler(e) {
   this.clearRect()
   const x = e.offsetX
   const y = e.offsetY
   this.bbox.minX = x - 20
   this.bbox.maxX = x + 20
   this.bbox.minY = y - 20
   this.bbox.maxY = y + 20
   const start = performance.now()
   const res = this.tree.search(this.bbox)
   this.ctx.fillStyle = this.pattern
   this.ctx.strokeStyle = 'rgba(255,255,255,0.7)'
   res.forEach((item) => {
     this.drawRect(item)
  })
   this.ctx.fill()
   this.res.innerHTML =
     'Search Time (ms): ' + (performance.now() - start).toFixed(3)
}

这里给大家讲解一下,现在我们画布是黑白的, 然后以鼠标搜索到数据后,然后我们画出对应的矩形,这时候呢,可以将矩形的填充模式改成 pattern 模式,这样便于我们看的更加明显。fillStyle可以填充3种类型:


ctx.fillStyle = color;
ctx.fillStyle = gradient;
ctx.fillStyle = pattern;

分别代表的是:


填充的模式


OK讲解完毕, 直接gif 看在1万个矩形的搜索中Rbush的表现怎么样。
rbush 演示
这是1万个矩形我换成10万个矩形我们在看看效果:


10万个点


我们发现增加到10万个矩形,速度还是非常快的,也就是1点几毫秒,增加到100万个矩形,canvas 已经有点画不出来了,整个页面已经卡顿了,这边涉及到canvas的性能问题,当图形的数量过多,或者数量过大的时候,fps会大幅度下降的。可以采用批量绘制的方法,还有一种优化手段是分层渲染


我引用一下官方的Rbush的性能图,供大家参考。


image.png


总结


最后总结下:rbush 是一种空间索引搜索🔍算法,当你涉及到空间几何搜索的时候,尤其在地图场景下,因为Rbush 实现的原理是比较搜索物体的boundingBox 和已知的boundingBox 求交集, 如果不相交,那么在树的遍历过程中就已经过滤掉了。


链接:https://juejin.cn/post/6989027459601547294

收起阅读 »

我们是如何封装项目里的共用弹框的

前言 随着产品的迭代,项目里的弹框越来越多,业务模块共用的弹框也比较多。在刚开始的阶段,有可能不是共用的业务弹框,我们只放到了当前的业务模块里。随着迭代升级,有些模块会成为通用弹框。简而言之,一个弹框会在多个页面中使用。举例说下我们的场景。 项目当中有这样一个...
继续阅读 »

前言


随着产品的迭代,项目里的弹框越来越多,业务模块共用的弹框也比较多。在刚开始的阶段,有可能不是共用的业务弹框,我们只放到了当前的业务模块里。随着迭代升级,有些模块会成为通用弹框。简而言之,一个弹框会在多个页面中使用。举例说下我们的场景。


项目当中有这样一个预览的弹框,已经存放在我们的业务组件当中。内容如下


import React from 'react';
import {Modal} from 'antd';

const Preview = (props) => {
const {visible, ...otherProps} = props;
return(
<Modal
visible={visible}
{...otherProps}
... // 其它Props
>
<div>预览组件的内容</div>
</Modal>
)
}

这样的一个组件我们在多个业务模块当中使用,下面我们通过不同的方式来处理这种情况。


各模块引入组件


组件是共用的,我们可以在各业务模块去使用。


在模块A中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

const A = () => {
const [previewState, setPreviewState] = useState({
visible: false,
... // 其它props,包括弹框的props和预览需要的参数等
});

// 显示弹框
const showPreview = () => {
setPreviewState({
...previewState,
visible: true,
})
}

// 关闭弹框
const hidePreview = () => {
setPreviewState({
...previewState,
visible: false,
})
}

return (<div>
<Button onClick={showPreview}>预览</Button>
<Preview {...previewState} onCancel={hidePreview} />
</div>)
}

export default A;

在模块B中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

const B = () => {
const [previewState, setPreviewState] = useState({
visible: false,
... // 其它props,包括弹框的props和预览需要的参数等
});

// 显示弹框
const showPreview = () => {
setPreviewState({
...previewState,
visible: true,
})
}

// 关闭弹框
const hidePreview = () => {
setPreviewState({
...previewState,
visible: false,
})
}

return (<div>
B模块的业务逻辑
<Button onClick={showPreview}>预览</Button>
<Preview {...previewState} onCancel={hidePreview} />
</div>)
}

export default B;

我们发现打开弹框和关闭弹框等这些代码基本都是一样的。如果我们的系统中有三四十个地方需要引入预览组件,那维护起来简直会要了老命,每次有调整,需要改动的地方太多了。


放到Redux中,全局管理。


通过上面我们可以看到显示很关闭的业务逻辑是重复的,我们把它放到redux中统一去管理。先改造下Preview组件


import React from 'react';
import {Modal} from 'antd';

@connect(({ preview }) => ({
...preview,
}))
const Preview = (props) => {
const {visible} = props;

const handleCancel = () => {
porps.dispatch({
type: 'preview/close'
})
}

return(
<Modal
visible={visible}
onCancel={handleCancel}
... // 其它Props
>
<div>预览组件的内容</div>
</Modal>
)
}

在redux中添加state管理我们的状态和处理一些参数


const initState = {
visible: false,
};

export default {
namespace: 'preview',
state: initState,
reducers: {
open(state, { payload }) {
return {
...state,
visible: true,
};
},
close(state) {
return {
...state,
visible: false,
};
},
},

};


全局引入


我们想要在模块中通过dispatch去打开我们弹框,需要在加载这些模块之前就导入我们组件。我们在Layout中导入组件


import Preview from './components/preview';
const B = () => {

return (<div>
<Header>顶部导航</Header>
<React.Fragment>
// 存放我们全局弹框的地方
<Preview />
</React.Fragment>
</div>)
}

export default B;

在模块A中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

@connect()
const A = (porps) => {
// 显示弹框
const showPreview = () => {
porps.dispatch({
type: 'preview/show'
payload: { ... 预览需要的参数}
})
}
return (<div>
<Button onClick={showPreview}>预览</Button>
</div>)
}

export default A;

在模块B中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

@connect()
const B = () => {
// 显示弹框
const showPreview = () => {
this.porps.dispatch({
type: 'preview/show'
payload: { ... 预览需要的参数}
})
}
return (<div>
<Button onClick={showPreview}>预览</Button>
</div>)
}

export default B;

放到redux中去管理状态,先把弹框组件注入到我们全局当中,我们在业务调用的时候只需通过dispatch就可以操作我们的弹框。


基于插件注入到业务当中


把状态放到redux当中,我们每次都要实现redux那一套流程和在layout组件中注入我们的弹框。我们能不能不关心这些事情,直接在业务当中使用呢。


创建一个弹框的工具类


class ModalViewUtils {

// 构造函数接收一个组件
constructor(Component) {
this.div = document.createElement('div');
this.modalRef = React.createRef();
this.Component = Component;
}

onCancel = () => {
this.close();
}

show = ({
title,
...otherProps
}: any) => {
const CurrComponent = this.Component;
document.body.appendChild(this.div);
ReactDOM.render(<GlobalRender>
<Modal
onCancel={this.onCancel}
visible
footer={null}
fullScreen
title={title || '预览'}
destroyOnClose
getContainer={false}
>
<CurrComponent {...otherProps} />
</ZetModal>
</GlobalRender>, this.div)

}

close = () => {
const unmountResult = ReactDOM.unmountComponentAtNode(this.div);
if (unmountResult && this.div.parentNode) {
this.div.parentNode.removeChild(this.div);
}
}

}

export default ModalViewUtils;

更改Preview组件


import React, { FC, useState } from 'react';
import * as ReactDOM from 'react-dom';
import ModalViewUtils from '../../utils/modalView';

export interface IModalViewProps extends IViewProps {
title?: string;
onCancel?: () => void;
}

// view 组件的具体逻辑
const ModalView: FC<IModalViewProps> = props => {
const { title, onCancel, ...otherProps } = props;
return <View isModal {...otherProps} />
}

// 实例化工具类,传入对用的组件
export default new ModalViewUtils(ModalView);


在模块A中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

const A = (porps) => {
// 显示弹框
const showPreview = (params) => {
Preview.show()
}
return (<div>
<Button onClick={showPreview}>预览</Button>
</div>)
}

export default A;

在模块B中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

const B = () => {
// 显示弹框
const showPreview = () => {
Preview.show(params)
}
return (<div>
<Button onClick={showPreview}>预览</Button>
</div>)
}

export default B;


基于这种方式,我们只用关心弹框内容的实现,调用的时候直接引入组件,调用show方法, 不会依赖redux,也不用再调用的地方实例组件,并控制显示隐藏等。


基于Umi插件,不需引入模块组件


我们可以借助umi的插件,把全局弹框统一注入到插件当中, 直接使用。


import React, {useState} from 'react';
import {ModalView} from 'umi';
import {Button} from 'antd';

const A = () => {
// 显示弹框
const showPreview = () => {
ModalView.Preview.show(params)
}
return (<div>
<Button onClick={showPreview}>预览</Button>
</div>)
}

export default A

结束语


对全局弹框做的统一处理,大家有问题,评论一起交流。


链接:https://juejin.cn/post/6989158134530965512

收起阅读 »

一个优秀前端的工具素养

👆 这句话,想然大家道理都懂 ~ 但最近在暑期实习的日子里,我特意留心观察了一下身边的实习生同学使用工具的习惯。我发现自己在大学认为高效率的工作模式,他们无论在意识层面还是在使用层面上对工具的掌握都有些蹩脚。特别是有部分同学 Mac 也没有怎么接触过,算是效率...
继续阅读 »

👆 这句话,想然大家道理都懂 ~


但最近在暑期实习的日子里,我特意留心观察了一下身边的实习生同学使用工具的习惯。我发现自己在大学认为高效率的工作模式,他们无论在意识层面还是在使用层面上对工具的掌握都有些蹩脚。特别是有部分同学 Mac 也没有怎么接触过,算是效率领域的门外汉了。所以本着做个负责的好师兄的态度,我将自己对工具使用的经验,分享给大家。也算是抛砖引玉,和大家一起聊聊有哪些 NB 又和好玩的工具。



需要注意的是:我这里主要以 Mac Apple 生态作为基调,但我相信工具和效率提升的思想是不变的,Win 下也有具体的工具可以替代,所以 Win 的同学也可以认真找一找,评论回复一下 Win 下的替代方案吧 🙏🏻



当然,👇 的工具,我没有办法在这种汇总类的文章里面讲透彻,所以都「点到为止」,给了相关扩展阅读的文章,所以感兴趣的话大家再外链出去研究一番,或者自行 Google 社区的优质资源 ~


所以准备好了么?Here we go ~


image.png


🛠 前端工作中的那些工具


在开始聊前端的主流工具之前,先强调一下作为的 Coder,熟练,及其熟练,飞一般熟练快捷键的重要性!


成为快捷键爱好者


使用工具软件的时候,我都是下意识地要求自己记住至少 Top 20 操作的「快捷键」。虽然不至于要求一定要成为 vim 编辑者这种级别的「纯金键盘侠」,但至少对 VSCode 主流快捷键要形成「肌肉记忆」。这就问大家一个问题,如果能够回答上,说明大家对 VSCode 快捷键掌握还是不错的 ~ 来:


问:VSCode 中 RenameSymbol 的快捷键是什么?(P.S. 若 Rename Symbol 都不知道是什么作用的话,去打板子吧 😄)


image.png


如果回答不上,那请加油了,相信我,快捷键每次操作都可以节省你至少 1s 的时间,想一想,有多划算吧 ~
当然在这里给大家推荐一个查询 Mac 下面应用对「快捷键」注册的工具 —— CheatSheet,长按 Command 键可以激活当前使用 App 的快捷键菜单。like this 👇


image.png


捷键没有速成之法,还是在不断重复练习,所以 KEEP ON DOING THAT


成为 VSCode Professional


工具,也有时髦之说,自从 Typescript 开始泛滥之后,VSCode 确乎成为了主流的前端开发工具。但我发很多同学对 VSCode 的使用上还是处于一种入门水准,没有真正发挥好这个工具的强大之处 ~ 所以也在和大家一起聊一聊。我不打算写一篇 Bible 级别的 VSCode 指南,只是通过几个小 Case 告诉大家 VSCode 有很多有趣的点可以使用以极大程度上提升效率,尤其是 VSCode Extensions(插件)。



  1. 你知道 VSCode 是可以云同步配置的功能,且可以一键同步其它设备么?

  2. 你知道 VSCode 有一个可以自动给 Typescript 的 import 排序并自动清除无效依赖的插件么?

  3. 你知道 VSCode 可以使用快捷键自动折叠代码层数么?

  4. 你知道如何快速返回到上一个编辑或者浏览过的文件吗?


如果都知道,那还不错 👍,如果听都没听说过,那我给大家几个建议:



  • 把 VSCode 的快捷键列表看一遍,摘出来自己觉得可以将来提升自己编码效率的,反复执行,直到形成肌肉记忆。

  • 把 VSCode 安装量和受欢迎度 Top200 的插件,浏览一遍,看看简介,安装自己感兴趣的插件。 👈 来一场探索宝藏的游戏吧,少读一些推荐文章,多动手自己捣鼓,找到好工具!




  • 最后把 VSCode 上一个绝美的皮肤和字体,按照我的审美,这就是我要的「滑板鞋」 ~ btw,主题是 OneDarkPro 字体是:FiraCode





扩展阅读:



用好 Terminal


作为一个工程师,不要求你成为 Shell 大师,但 Terminal 里面的常用命令以及日常美化优化还是必须要做的。这里给大家推荐 iTerm + OhMyZsh 的模式,打造一个稳定好用的 Terminal。



  • 下载 iTerm 安装(你用 VSCode 的也行,但我还是推荐独立终端 App,因为 VSCode 这货有时候会假死,然后把 iTerm 一下整没了,所以还是术业有专攻才行 🙈),有了这货,分屏幕上 👇 就是常规操作了。




  • 下载 OhMyZsh 安装,更新最新的 Git 目录,把主流插件都 down 下来,装好后秒变彩色,再安装对应的主题,不要太开心。




  • 按照个人兴趣「调教」OhMyZsh,强烈建议在 ~/.zshrc 启动这些插件:谁用谁知道 ~ 😄 随便说一个功能都好用到不行,这里就不啰嗦了,有其它好用插件的同学,欢迎盖楼分享一下。




plugins=(git osx wd autojump zsh-autosuggestions copyfile history last-working-dir)


比如:Git 这个插件就可以将复杂的 git 命令 git checkout -b 'xxx' 简化为:gcb 'xxx'


比如:OSX 插件可以帮我们快速打开 Finder 等等操作。


...




扩展阅读:




  • Shell 编程入门:手撸脚本,提升效率 ✍🏻




  • OhMyZsh 插件集:看那些花里胡哨的 shell 插件们,来,拉出来都晒一晒 🌞




  • Vim 快捷键 CheatSheet:在手撸服务器时代,Vim 是神器,现在看来依旧值得传火 🧎‍♂️ 大神收下我的膝盖




用好 Chrome DebugTool


作为一个前端我就不赘述这个的重要性了。强烈建议大家把官方文档撸一遍,你会有很多新知的。


developer.chrome.com/docs/devtoo…


👆 这个可以写一本书,但是我还是建议大家用好这个工具,这是我们前端最重要的调试器了,我经常在面试同学的时候会问关于他们如何使用调试器解决问题的。其实看大家调试代码的过程就知道这个同学的编程水准,真的,大家可以有意识的看看自己是怎么去调试和排查问题的,效率高么?有没有提升空间。



  • 比如:如何排查一个项目的渲染卡顿点?

  • 比如:如何排查内存泄露?

  • 比如:如何全局搜索查找重复的代码?


用好 ChromeExtensions


浏览器插件,我就不多说了。我在此罗列一下我日常使用的 Chrome 插件,也欢迎各路神仙补充自己的浏览器插件和那些骚操作。重点说一下 For 开发者的:





  • JSONFormatter:对于日常直接请求的 JSON 数据格式化




  • XSwitch:我前 TL 手撸的浏览器网络请求代理工具,帮他打个广告 😛




  • ReactDeveloerTools 👈 这个就不多解释了,强烈建议大家打开 HighlightRerender 功能,看看自己的代码写得多烂,多多批判一下自己 🙈




对于 Chrome Extension 这种「神文」比较多,像油猴、AdBlock、视频下载啥的之类的工具我就不在这里提了,懂的人都懂,不懂的人自己 Google。我这里再推荐几篇文章,大家按需阅读吧:



  • Chrome 前端插件推荐:B 乎上这个 问题和 回答 比较中肯

  • Chrome 通用插件推荐:B 乎继续 推荐,看看高赞回答下的「集体智慧」吧 😁


🔍 搜索!搜索!!搜索!!!


呼,终于聊完了开发用的工具,那接下来我们来聊一下搜索。按照我的理解,我一直把数字化时代个人信息管理的效率分成三个基础段位:



  • 入门级:很少整理自己的磁盘和桌面,典型特征就是桌面什么奇葩的命名文件都堆在一起

  • 新手级:开始有意识整理了,文件分级分层,重视文件命名,建立标签等机制

  • 熟练级:开始有意识建立数据库索引,在 OS 层面做文件索引,有数据意识

  • 大师级:开始关注数据,将个人数据,集体数据融入日常,甚至开始使用非结构化的数据来辅助自己做事情


扪心自问,你在哪一个 Level 呢?


Spotlight


第一第二级,我就不了了,这里我重点和大家分享一下达到第三级的索引和搜索工具。要知道在 Mac 下 Spotlight 一直都是一个全局搜索工具,用好 Spotlight,就可以无缝解锁系统级别的搜索,主要的 Apps、文件、日历 ... 都可以搜索。



Alfred



但系统自带的,往往都不是最强的是吧?所以在 Spotlight 系统级的 metadata (Mac 会自建文件索引库并开放 API 给上层应用调用)的基础上,诞生了一个很强的工具 Alfred。我一直是 Alfred 的资深粉丝 + 用户,每天使用 Alfred 的功能(搜索)高达 70 次。👇 图为证:



Alfred 是一个「真正意义上的效率工具」,其主要的功能:



  • 文档检索

  • 快捷 URL 拼接

  • 剪切板历史快速访问 & 搜索

  • BookMark 搜索

  • 自定义工作流(下一个章节重点聊一聊这个)

  • ...(功能无敌)


强烈建议不知道 Alfred 是啥的同学,读一下 👇 这篇文章,这篇文章是我在入职阿里第一年内网写的一篇介绍 Alfred 的文章,如果有收获,还请给我点个赞了 👍


此处为语雀内容卡片,点击链接查看:http://www.yuque.com/surfacew/fe…


🚌 自动化的魅力


「自动化」一定是一种程序工作者应该深深植入自己「脑海里」的思考模式。但凡遇到重复的流程,我们是不是都应该尝试着问自己,这么费时间的流程,频次有多少,是否值得我们使用工具去自动化?


如今,靠做自动化上市的公司也有了,所以这里重点想和大家一起聊一聊个人如何把身边的自动化做到极致。这里重点讲三个工具:Alfred Workflow、Apple 捷径、IFFTT。


AlfredWorkflow


主打 Mac 上的自动化流程。通过 👇 这种可视化编程的思路,创建一种动作流。比如我想实现通过 Cmd + Alt + B 搜索 Chrome 书签 🔖。社区的小伙伴们就已经帮我们实现了一套工作流。我们可以直接在 Alfred 的社区 Packtal 等论坛去下载已经实现的 Workflow 去实现这些日常生活中的小自动化流程。



再比如上面的:




  • ChromeHistory:搜索 Chrome 历史记录(在 Alfred 搜索中)




  • GithubRepos:浏览搜索自己的 GithubRepo




  • Colors:快速转换前端颜色(前端同学一定知道为什么这个常用)🙈






  • ... 等等等等


我们也可以定义自己的工作流来自动化一些流程,我用自身的一个 Case 来说,我会定义很多快捷键来绑定我自己的日常操作。比如:




  • Cmd + Alt + D:打开钉钉




  • Alfred 输入 weather:查询天气




  • Alfred 输入 calendar:打开百度日历(不为别的,看放假日历 😄)




  • codereview:进入集团 CR 的工作台




  • ...





浑然一体,非常好玩,可以大量定制自己的工作流程。我之前写过一篇文章有关联到 Workflow 的部分,感兴趣的可以 一读


AppleShortcuts


主打手机上的自动化流程。(iPhone)


它提供了近乎 0 代码的流程编排,让我们可以访问 App 以及一些操作系统的 API,从而实现类似 👆 Alfred 的功能编排,是不是也很强。比如我们想要实现一个从剪切板里面读取内容并打开网页的功能,只需要下面增加两个简单的编程动作(真 0 代码)就可以实现自定义流程的实现。



Apple 捷径提供的 API 示意:




可以看到的是,Apple 这些大厂一直在思考真正意义上的让编码平易近人,让普通的小白用户也可以低成本地定义自己的工作流程。Shortcuts 的玩法有很多,在这里就不细细展开了,给大家足够多探索的可能性。


IFFTT


🔗:ifttt.com/home


三方中立的自动化流程提供商。这个工具跨平台多端支持,我用的相对偏少,但可以解决我部分跨平台的流程问题,这块大家自行探索吧 ~


聪明的人,一定会用「自动化」的思维解决问题,所以用好自动化的工具的重要性我相信大家应该明白了。


💻 突破次元壁の工具


最后,再和大家聊一聊非软件的「工具」吧。我还是觉得大家作为 Coder,还是要在自己的装备上多花点盘缠,就像 Kevin 老师用了戴森吹风机就比普通发型师厉害一样。



  • 自己的 主力机,一定是要性能杠杠的,经济允许的情况下,前端我还是力挺 Mac(高配) 和 Apple 生态 ~

  • 给自己 一块 4K 屏(最好放公司),看着心情都会变好,如果财力雄厚,搞一块 Apple 的 PRO DISPLAY XDR,就给跪了。




  • 使用 iPad & ApplePencil 尝试着数字笔记的艺术,涂涂画画,发现灵感,整理思维。





  • 自动升降桌 & 人体工程学椅:对身体,脊椎好一点 🙂 就坐屁股变大,变胖,是不争的事实 😿




  • HHKB 键盘 ⌨️,最近用了一段时间,适应布局之后,觉得打字都变快了 ... 可能是金钱的力量让代码翘起来都更顺心了呢 🎶(开个玩笑)




  • ...




🎓 结语


当然,👆 的工具只是大千世界中,集体智慧凝练的工具的冰山一角。


这些工具提升效率创造的增益往往初步看很小,但是大家一定要知道,这种增益会随着时间积累而放大,做一个简单的计算,一天你因为工具里面的 100 次效率优化,每一次即便是优化 5s,一年下来,节省的时间(Alfred 可以直接计算时间):



是不是令人震惊地高达 50 个小时,活生生的 2 天 啊!!!受限于篇幅,如果大家觉得对这篇文章对自己有帮助的话,欢迎点赞收藏转载(怎么变成了 B 站三连了)哈哈,如果后续有时间的话,我再和大家一起分享一下我是如何做信息管理和知识管理的,希望能够给大家带来一些真正帮助。


链接:https://juejin.cn/post/6989033473457520653

收起阅读 »

你能不能在网页里实现裸眼3D

前言 最近产品经理在掘金社区的出镜率很高,看来大家都很喜闻乐见工程师与产品经理的相爱相杀。 这次他让我调研一下在网页里实现裸眼3D 这是故意为难我把? 搞什么调研影响我摸鱼 现在的我想拿枪打他 拿弓箭射他 点火烧他 诶,如果我在3D场景中刻意加上一些框框...
继续阅读 »

前言


最近产品经理在掘金社区的出镜率很高,看来大家都很喜闻乐见工程师与产品经理的相爱相杀。


这次他让我调研一下在网页里实现裸眼3D


这是故意为难我把?


搞什么调研影响我摸鱼


现在的我想拿枪打他


619c-hawmaua2753951.gif


拿弓箭射他


26a78036e0304df84daf3c634f264c0d.gif


点火烧他


c685-hawmaua2754245.gif


诶,如果我在3D场景中刻意加上一些框框,会不会看上去更立体呢?


方案一:造个框框,再打破它


现在我们用一个非常简单的立方体来试试看


2021-07-23 13_50_55.gif


2021-07-23 13_53_07.gif


立体感是稍微提升一点,但就这?那怕是交不了差的...


不过,大家发挥一下想象力,框框可以不全是直的,这个B站防遮挡弹幕是不是也让你产生了些裸眼3D的效果呢?


image.png


方案二:人脸识别


不行,谁都不能耽误我摸鱼。


此时我又想起另一个方案,是不是可以通过摄像头实时检测人脸在摄像头画面中的位置来模拟裸眼3D呢。我找到了tracking.js,这是一款在浏览器中可以实时进行人脸检测的库。


github.com/eduardolund…


var tracker = new tracking.ObjectTracker('face');
tracker.setInitialScale(4);
tracker.setStepSize(2);
tracker.setEdgesDensity(0.1);

tracking.track('#video', tracker, { camera: true });

tracker.on('track', function(event) {
context.clearRect(0, 0, canvas.width, canvas.height);

event.data.forEach(function(rect) {
context.strokeStyle = '#a64ceb';
context.strokeRect(rect.x, rect.y, rect.width, rect.height);
context.font = '11px Helvetica';
context.fillStyle = "#fff";
context.fillText('x: ' + rect.x + 'px', rect.x + rect.width + 5, rect.y + 11);
context.fillText('y: ' + rect.y + 'px', rect.x + rect.width + 5, rect.y + 22);
});
});

2021-07-23 14_45_40.gif


我们可以看到,画面中呈现了人脸在摄像头视角画布中的坐标,有了这个坐标数据,我们就可以做很多事情了。


接着把它接到threejs中,我们仍然拿这个立方体来试试看


2021-07-23 15_11_29.gif


实际体验还有点意思,但录屏的感受不太明显,请自行下载demo源码试试看吧


方案三:陀螺仪


W3C标准APIDeviceOrientation,用于检测移动设备的旋转方向和加速度。通过这个API,我们可以获取到三个基础属性:



  • alpha(设备平放时,水平旋转的角度)


image.png



  • beta(设备平放时,绕横向X轴旋转的角度)


image.png



  • gamma(设备平放时,绕纵向Y轴旋转的角度)


image.png


这个API的使用非常简单,通过给window添加一个监听


function capture_orientation (event) { 
var alpha = event.alpha;
var beta = event.beta;
var gamma = event.gamma;
console.log('Orientation - Alpha: '+alpha+', Beta: '+beta+', Gamma: '+gamma);
}

window.addEventListener('deviceorientation', capture_orientation, false);

现在我们把这个加入到咱们的立方体演示中,在加入的过程中,这里需要注意的是,在IOS设备上,这个API需要主动申请用户权限。


window.DeviceOrientationEvent.requestPermission()
.then(state => {
switch (state) {
case "granted":
//在这里建立监听
window.addEventListener('deviceorientation', capture_orientation, false);
break;
case "denied":
alert("你拒绝了使用陀螺仪");
break;
case "prompt":
alert("其他行为");
break;
}
});

返回的是一个promise,所以你也可以这么写


var permissionState = await window.DeviceOrientationEvent.requestPermission();
if(permissionState=="granted")window.addEventListener('deviceorientation', capture_orientation, false);

还有几点需要注意的事,requestPermission必须由用户主动发起,也就是必须在用户的行为事件里触发,比如“click”,还有就是这个API的调用,必须在HTTPS协议访问的网页里使用。


2021-07-25 10_46_16.gif


结语


至此,我能想到在网页里实现裸眼3D的几种方法都在此文中,你还能想到别的方法吗?请在评论区一起讨论吧。



链接:https://juejin.cn/post/6988949401842483230

收起阅读 »

感谢 compose 函数,让我的代码屎山?逐渐美丽了起来~

有言在先 本瓜知道前不久写的《JS 如何函数式编程》系列各位可能并不感冒,因为一切理论的东西如果脱离实战的话,那就将毫无意义。 于是乎,本瓜着手于实际工作开发,尝试应用函数式编程的一些思想。 最终惊人的发现:这个实现过程并不难,但是效果却不小! 实现思路:借...
继续阅读 »

有言在先


本瓜知道前不久写的《JS 如何函数式编程》系列各位可能并不感冒,因为一切理论的东西如果脱离实战的话,那就将毫无意义。


I6cDpC.th.png


于是乎,本瓜着手于实际工作开发,尝试应用函数式编程的一些思想。


最终惊人的发现:这个实现过程并不难,但是效果却不小!


实现思路:借助 compose 函数对连续的异步过程进行组装,不同的组合方式实现不同的业务流程。


这样不仅提高了代码的可读性,还提高了代码的扩展性。我想:这也许就是高内聚、低耦合吧~


撰此篇记之,并与各位分享。


场景说明


在和产品第一次沟通了需求后,我理解需要实现一个应用 新建流程,具体是这样的:


第 1 步:调用 sso 接口,拿到返回结果 res_token;


第 2 步:调用 create 接口,拿到返回结果 res_id;


第 3 步:处理字符串,拼接 Url;


第 4 步:建立 websocket 链接;


第 5 步:拿到 websocket 后端推送关键字,渲染页面;



  • 注:接口、参数有做一定简化


上面除了第 3 步、第 5 步,剩下的都是要调接口的,并且前后步骤都有传参的需要,可以理解为一个连续且有序的异步调用过程。


为了快速响应产品需求,于是本瓜迅速写出了以下代码:


/**
* 新建流程
* @param {*} appId
* @param {*} tag
*/

export const handleGetIframeSrc = function(appId, tag) {
let h5Id
// 第 1 步: 调用 sso 接口,获取token
getsingleSignOnToken({ formSource: tag }).then(data => {
return new Promise((resolve, reject) => {
resolve(data.result)
})
}).then(token => {
const para = { appId: appId }
return new Promise((resolve, reject) => {
// 第 2 步: 调用 create 接口,新建应用
appH5create(para).then(res => {
// 第 3 步: 处理字符串,拼接 Url
this.handleInsIframeUrl(res, token, appId)
this.setH5Id(res.result.h5Id)
h5Id = res.result.h5Id
resolve(h5Id)
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
})
}).then(h5Id => {
// 第 4 步:建立 websocket 链接;
return new Promise((resolve, reject) => {
webSocketInit(resolve, reject, h5Id)
})
}).then(doclose => {
// 第 5 步:拿到 websocket 后端推送关键字,渲染页面;
if (doclose) { this.setShowEditLink({ appId: appId, h5Id: h5Id, state: true }) }
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
}

const handleInsIframeUrl = function(res, token, appId) {
// url 拼接
const secretId = this.$store.state.userinfo.enterpriseList[0].secretId
let editUrl = res.result.editUrl
const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
editUrl = res.result.editUrl.replace(infoId, `from=a2p&${infoId}`)
const headList = JSON.parse(JSON.stringify(this.headList))
headList.forEach(i => {
if (i.appId === appId) { i.srcUrl = `${editUrl}&token=${token}&secretId=${secretId}` }
})
this.setHeadList(headList)
}

这段代码是非常自然地根据产品所提需求,然后自己理解所编写。


其实还可以,是吧?🐶


需求更新


但你不得不承认,程序员和产品之间有一条无法逾越的沟通鸿沟


它大部分是由所站角度不同而产生,只能说:李姐李姐!


所以,基于前一个场景,需求发生了点 更新 ~


I6UGrz.th.png


除了上节所提的 【新建流程】 ,还要加一个 【编辑流程】 ╮(╯▽╰)╭


编辑流程简单来说就是:砍掉新建流程的第 2 步调接口,再稍微调整传参即可。


于是本瓜直接 copy 一下再作简单删改,不到 1 分钟,编辑流程的代码就诞生了~


/**
* 编辑流程
*/

const handleToIframeEdit = function() { // 编辑 iframe
const { editUrl, appId, h5Id } = this.ruleForm
// 第 1 步: 调用 sso 接口,获取token
getsingleSignOnToken({ formSource: 'ins' }).then(data => {
return new Promise((resolve, reject) => {
resolve(data.result)
})
}).then(token => {
// 第 2 步:处理字符串,拼接 Url
return new Promise((resolve, reject) => {
const secretId = this.$store.state.userinfo.enterpriseList[0].secretId
const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
const URL = editUrl.replace(infoId, `from=a2p&${infoId}`)
const headList = JSON.parse(JSON.stringify(this.headList))
headList.forEach(i => {
if (i.appId === appId) { i.srcUrl = `${URL}&token=${token}&secretId=${secretId}` }
})
this.setHeadList(headList)
this.setShowEditLink({ appId: appId, h5Id: h5Id, state: false })
this.setShowNavIframe({ appId: appId, state: true })
this.setNavLabel(this.headList.find(i => i.appId === appId).name)
resolve(h5Id)
})
}).then(h5Id => {
// 第 3 步:建立 websocket 链接;
return new Promise((resolve, reject) => {
webSocketInit(resolve, reject, h5Id)
})
}).then(doclose => {
// 第 4 步:拿到 websocket 后端推送关键字,渲染页面;
if (doclose) { this.setShowEditLink({ appId: appId, h5Id: h5Id, state: true }) }
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
}

需求再更新


老实讲,不怪产品,咱做需求的过程也是逐步理解需求的过程。理解有变化,再正常不过!(#^.^#) 李姐李姐......


I6UIKu.th.png


上面已有两个流程:新建流程、编辑流程


这次,要再加一个 重新创建流程 ~


重新创建流程可简单理解为:在新建流程之前调一个 delDraft 删除草稿接口;


至此,我们产生了三个流程:



  1. 新建流程;

  2. 编辑流程;

  3. 重新创建流程;


本瓜这里作个简单的脑图示意逻辑:


I6Xi9Q.png


我的直觉告诉我:不能再 copy 一份新建流程作修改了,因为这样就太拉了。。。没错,它没有耦合,但是它也没有内聚,这不是我想要的。于是,我开始封装了......


实现上述脑图的代码:


/**
* 判断是否存在草稿记录?
*/
judgeIfDraftExist(item) {
const para = { appId: item.appId }
return appH5ifDraftExist(para).then(res => {
const { editUrl, h5Id, version } = res.result
if (h5Id === -1) { // 不存在草稿
this.handleGetIframeSrc(item)
} else { // 存在草稿
this.handleExitDraft(item, h5Id, version, editUrl)
}
}).catch(err => {
console.log(err)
})
},
/**
* 选择继续编辑?
*/
handleExitDraft(item, h5Id, version, editUrl) {
this.$confirm('有未完成的信息收集链接,是否继续编辑?', '提示', {
confirmButtonText: '继续编辑',
cancelButtonText: '重新创建',
type: 'warning'
}).then(() => {
const editUrlH5Id = h5Id
this.handleGetIframeSrc(item, editUrl, editUrlH5Id)
}).catch(() => {
this.handleGetIframeSrc(item)
appH5delete({ h5Id: h5Id, version: version })
})
},
/**
* 新建流程、编辑流程、重新创建流程;
*/
handleGetIframeSrc(item, editUrl, editUrlH5Id) {
let ws_h5Id
getsingleSignOnToken({ formSource: item.tag }).then(data => {
// 调用 sso 接口,拿到返回结果 res_token;
return new Promise((resolve, reject) => {
resolve(data.result)
})
}).then(token => {
const para = { appId: item.appId }
return new Promise((resolve, reject) => {
if (!editUrl) { // 新建流程、重新创建流程
// 调用 create 接口,拿到返回结果 res_id;
appH5create(para).then(res => {
// 处理字符串,拼接 Url;
this.handleInsIframeUrl(res.result.editUrl, token, item.appId)
this.setH5Id(res.result.h5Id)
ws_h5Id = res.result.h5Id
this.setShowNavIframe({ appId: item.appId, state: true })
this.setNavLabel(item.name)
resolve(true)
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
} else { // 编辑流程
this.handleInsIframeUrl(editUrl, token, item.appId)
this.setH5Id(editUrlH5Id)
ws_h5Id = editUrlH5Id
this.setShowNavIframe({ appId: item.appId, state: true })
this.setNavLabel(item.name)
resolve(true)
}
})
}).then(() => {
// 建立 websocket 链接;
return new Promise((resolve, reject) => {
webSocketInit(resolve, reject, ws_h5Id)
})
}).then(doclose => {
// 拿到 websocket 后端推送关键字,渲染页面;
if (doclose) { this.setShowEditLink({ appId: item.appId, h5Id: ws_h5Id, state: true }) }
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
},

handleInsIframeUrl(editUrl, token, appId) {
// url 拼接
const secretId = this.$store.state.userinfo.enterpriseList[0].secretId
const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
const url = editUrl.replace(infoId, `from=a2p&${infoId}`)
const headList = JSON.parse(JSON.stringify(this.headList))
headList.forEach(i => {
if (i.appId === appId) { i.srcUrl = `${url}&token=${token}&secretId=${secretId}` }
})
this.setHeadList(headList)
}

如此,我们便将 新建流程、编辑流程、重新创建流程 全部整合到了上述代码;


需求再再更新


上面的封装看起来似乎还不错,但是这时我害怕了!想到:如果这个时候,还要加流程或者改流程呢??? 我是打算继续用 if...else 叠加在那个主函数里面吗?还是打算直接 copy 一份再作删改?


我都能遇见它会充斥着各种判断,变量赋值、引用飞来飞去,最终成为一坨💩,没错,代码屎山的💩


我摸了摸左胸的左心房,它告诉我:“饶了接盘侠吧~”


于是乎,本瓜尝试引进了之前吹那么 nb 的函数式编程!它的能力就是让代码更可读,这是我所需要的!来吧!!展示!!


I6cPMf.png


compose 函数


我们在 《XDM,JS如何函数式编程?看这就够了!(三)》 这篇讲过函数组合 compose!没错,我们这次就要用到这个家伙!


还记得那句话吗?



组合 ———— 声明式数据流 ———— 是支撑函数式编程最重要的工具之一!



最基础的 compose 函数是这样的:


function compose(...fns) {
return function composed(result){
// 拷贝一份保存函数的数组
var list = fns.slice();
while (list.length > 0) {
// 将最后一个函数从列表尾部拿出
// 并执行它
result = list.pop()( result );
}
return result;
};
}

// ES6 箭头函数形式写法
var compose =
(...fns) =>
result => {
var list = fns.slice();
while (list.length > 0) {
// 将最后一个函数从列表尾部拿出
// 并执行它
result = list.pop()( result );
}
return result;
};

它能将一个函数调用的输出路由跳转到另一个函数的调用上,然后一直进行下去。


I6c6uy.png


我们不需关注黑盒子里面做了什么,只需关注:这个东西(函数)是什么!它需要我输入什么!它的输出又是什么!


composePromise


但上面提到的 compose 函数是组合同步操作,而在本篇的实战中,我们需要组合是异步函数!


于是它被改造成这样:


/**
* @param {...any} args
* @returns
*/

export const composePromise = function(...args) {
const init = args.pop()
return function(...arg) {
return args.reverse().reduce(function(sequence, func) {
return sequence.then(function(result) {
// eslint-disable-next-line no-useless-call
return func.call(null, result)
})
}, Promise.resolve(init.apply(null, arg)))
}
}

原理:Promise 可以指定一个 sequence,来规定一个执行 then 的过程,then 函数会等到执行完成后,再执行下一个 then 的处理。启动sequence 可以使用 Promise.resolve() 这个函数。构建 sequence 可以使用 reduce 。


我们再写一个小测试在控制台跑一下!


let compose = function(...args) {
const init = args.pop()
return function(...arg) {
return args.reverse().reduce(function(sequence, func) {
return sequence.then(function(result) {
return func.call(null, result)
})
}, Promise.resolve(init.apply(null, arg)))
}
}

let a = async() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('xhr1')
resolve('xhr1')
}, 5000)
})
}

let b = async() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('xhr2')
resolve('xhr2')
}, 3000)
})
}
let steps = [a, b] // 从右向左执行
let composeFn = compose(...steps)

composeFn().then(res => { console.log(666) })

// xhr2
// xhr1
// 666

它会先执行 b ,3 秒后输出 "xhr2",再执行 a,5 秒后输出 "xhr1",最后输出 666


你也可以在控制台带参 debugger 试试,很有意思:


composeFn(1, 2).then(res => { console.log(66) })

逐渐美丽起来


测试通过!借助上面 composePromise 函数,我们更加有信心用函数式编程 composePromise 重构 我们的代码了。



  • 实际上,这个过程一点不费力~


实现如下:


/**
* 判断是否存在草稿记录?
*/
handleJudgeIfDraftExist(item) {
return appH5ifDraftExist({ appId: item.appId }).then(res => {
const { editUrl, h5Id, version } = res.result
h5Id === -1 ? this.compose_newAppIframe(item) : this.hasDraftConfirm(item, h5Id, editUrl, version)
}).catch(err => {
console.log(err)
})
},
/**
* 选择继续编辑?
*/
hasDraftConfirm(item, h5Id, editUrl, version) {
this.$confirm('有未完成的信息收集链接,是否继续编辑?', '提示', {
confirmButtonText: '继续编辑',
cancelButtonText: '重新创建',
type: 'warning'
}).then(() => {
this.compose_editAppIframe(item, h5Id, editUrl)
}).catch(() => {
this.compose_reNewAppIframe(item, h5Id, version)
})
},

敲黑板啦!画重点啦!


/**
* 新建应用流程
* 入参: item
* 输出:item
*/
compose_newAppIframe(...args) {
const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_appH5create, this.step_getsingleSignOnToken]
const handleCompose = composePromise(...steps)
handleCompose(...args)
},
/**
* 编辑应用流程
* 入参: item, draftH5Id, editUrl
* 输出:item
*/
compose_editAppIframe(...args) {
const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_getsingleSignOnToken]
const handleCompose = composePromise(...steps)
handleCompose(...args)
},
/**
* 重新创建流程
* 入参: item,draftH5Id,version
* 输出:item
*/
compose_reNewAppIframe(...args) {
const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_appH5create, this.step_getsingleSignOnToken, this.step_delDraftH5Id]
const handleCompose = composePromise(...steps)
handleCompose(...args)
},

我们通过 composePromise 执行不同的 steps,来依次执行(从右至左)里面的功能函数;你可以任意组合、增删或修改 steps 的子项,也可以任意组合出新的流程来应付产品。并且,它们都被封装在 compose_xxx 里面,相互独立,不会干扰外界其它流程。同时,传参也是非常清晰的,输入是什么!输出又是什么!一目了然!


对照脑图再看此段代码,不正是对我们需求实现的最好诠释吗?


对于一个阅读陌生代码的人来说,你得先告诉他逻辑是怎样的,然后再告诉他每个步骤的内部具体实现。这样才是合理的!


I6Xi9Q.png


功能函数(具体步骤内部实现):


/**
* 调用 sso 接口,拿到返回结果 res_token;
*/
step_getsingleSignOnToken(...args) {
const [item] = args.flat(Infinity)
return new Promise((resolve, reject) => {
getsingleSignOnToken({ formSource: item.tag }).then(data => {
resolve([...args, data.result]) // data.result 即 token
})
})
},
/**
* 调用 create 接口,拿到返回结果 res_id;
*/
step_appH5create(...args) {
const [item, token] = args.flat(Infinity)
return new Promise((resolve, reject) => {
appH5create({ appId: item.appId }).then(data => {
resolve([item, data.result.h5Id, data.result.editUrl, token])
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
})
},
/**
* 调 delDraft 删除接口;
*/
step_delDraftH5Id(...args) {
const [item, h5Id, version] = args.flat(Infinity)
return new Promise((resolve, reject) => {
appH5delete({ h5Id: h5Id, version: version }).then(data => {
resolve(...args)
})
})
},
/**
* 处理字符串,拼接 Url;
*/
step_splitUrl(...args) {
const [item, h5Id, editUrl, token] = args.flat(Infinity)
const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
const url = editUrl.replace(infoId, `from=a2p&${infoId}`)
const headList = JSON.parse(JSON.stringify(this.headList))
headList.forEach(i => {
if (i.appId === item.appId) { i.srcUrl = `${url}&token=${token}` }
})
this.setHeadList(headList)
this.setH5Id(h5Id)
this.setShowNavIframe({ appId: item.appId, state: true })
this.setNavLabel(item.name)
return [...args]
},
/**
* 建立 websocket 链接;
*/
step_createWs(...args) {
return new Promise((resolve, reject) => {
webSocketInit(resolve, reject, ...args)
})
},
/**
* 拿到 websocket 后端推送关键字,渲染页面;
*/
step_getDoclose(...args) {
const [item, h5Id, editUrl, token, doclose] = args.flat(Infinity)
if (doclose) { this.setShowEditLink({ appId: item.appId, h5Id: h5Id, state: true }) }
return new Promise((resolve, reject) => {
resolve(true)
})
},

功能函数的输入、输出也是清晰可见的。


至此,我们可以认为:借助 compose 函数,借助函数式编程,咱把业务需求流程进行了封装,明确了输入输出,让我们的代码更加可读了!可扩展性也更高了!这不就是高内聚、低耦合?!


I6UWZD.th.png


阶段总结


你问我什么是 JS 函数式编程实战?我只能说本篇完全就是出自工作中的实战!!!


这样导致本篇代码量可能有点多,但是这就是实打实的需求变化,代码迭代、改造的过程。(建议通篇把握、理解)


当然,这不是终点,代码重构这个过程应该是每时每刻都在进行着。


对于函数式编程,简单应用 compose 函数,这也只是一个起点!


已经讲过,偏函数、函数柯里化、函数组合、数组操作、时间状态、函数式编程库等等概念......我们将再接再厉得使用它们,把代码屎山进行分类、打包、清理!让它不断美丽起来!💩 => 👩‍🦰


以上,便是本次分享~ 都看到这里,不如点个赞吧👍👍👍


链接:https://juejin.cn/post/6989020415444123662

收起阅读 »

最优解前端面试题答法

1. JS事件冒泡和事件代理(委托) 1. 事件冒泡 会从当前触发的事件目标一级一级往上传递,依次触发,直到document为止。 <body> <div id="parentId"> 查看消息信息 <div id="chi...
继续阅读 »

1. JS事件冒泡和事件代理(委托)


1. 事件冒泡


会从当前触发的事件目标一级一级往上传递,依次触发,直到document为止。


<body>    <div id="parentId"> 查看消息信息 <div id="childId1"> 删除消息信息 </div>    </div></body><script>    let parent = document.getElementById('parentId');    let childId1 = document.getElementById('childId1');    parent.addEventListener('click', function () {        alert('查看消息信息');    }, false);    childId1.addEventListener('click', function () {        alert('删除消息信息');    }, false);     // 如出发消息列表里的删除按钮, 先执行了删除操作, 在向上冒泡执行‘ 查看消息信息’。        // 打印:删除消息信息 查看消息信息</script>

原生js取消事件冒泡


   try{
e.stopPropagation();//非IE浏览器
}
catch(e){
window.event.cancelBubble = true;//IE浏览器
}

vue.js取消事件冒泡


<div @click.stop="doSomething($event)">vue取消事件冒泡</div>

2. 事件代理(委托)


a. 为什么要用事件委托:


比如ul下有100个li,用for循环遍历所有的li,然后给它们添加事件,需要不断的与dom节点进行交互,访问dom的次数越多,引起浏览器重绘与重排的次数也就越多,就会延长整个页面的交互就绪时间,这就是为什么性能优化的主要思想之一就是减少DOM操作的原因;


如果要用事件委托,就会将所有的操作放到js程序里面,与dom的操作就只需要交互一次,这样就能大大的减少与dom的交互次数,提高性能;


b. 事件委托的原理


事件委托:利用事件冒泡的特性,将本应该注册在子元素上的处理事件注册在父元素上,这样点击子元素时发现其本身没有相应事件就到父元素上寻找作出相应。这样做的优势有:


1、减少DOM操作,提高性能。


2、随时可以添加子元素,添加的子元素会自动有相应的处理事件。


<div id="box">
<input type="button" id="add" value="添加" />
<input type="button" id="remove" value="删除" />
<input type="button" id="move" value="移动" />
<input type="button" id="select" value="选择" />
</div>
方式一:需要4次dom操作
window.onload = function () {
var Add = document.getElementById("add");
var Remove = document.getElementById("remove");
var Move = document.getElementById("move");
var Select = document.getElementById("select");
Add.onclick = function () { alert('添加'); }; Remove.onclick = function () { alert('删除'); }; Move.onclick = function () { alert('移动'); }; Select.onclick = function () { alert('选择'); } }
方式二:委托它们父级代为执行事件
window.onload = function(){
var oBox = document.getElementById("box");
oBox.onclick = function (ev) {
var ev = ev || window.event;
var target = ev.target || ev.srcElement;
if(target.nodeName.toLocaleLowerCase() == 'input'){
switch(target.id){
case 'add' :
alert('添加');
break;
case 'remove' :
alert('删除');
break;
case 'move' :
alert('移动');
break;
case 'select' :
alert('选择');
break;
}
}
}

}
用事件委托就可以只用一次dom操作就能完成所有的效果,比上面的性能肯定是要好一些的

3. 事件捕获


会从document开始触发,一级一级往下传递,依次触发,直到真正事件目标为止。


    <div> <button>            <p>点击捕获</p>        </button></div>    <script>        var oP = document.querySelector('p');        var oB = document.querySelector('button');        var oD = document.querySelector('div');        var oBody = document.querySelector('body');        oP.addEventListener('click', function () {            console.log('p标签被点击')        }, true);        oB.addEventListener('click', function () {            console.log("button被点击")        }, true);        oD.addEventListener('click', function () {            console.log('div被点击')        }, true);        oBody.addEventListener('click', function () {            console.log('body被点击')        }, true);    </script>    点击<p>点击捕获</p>,打印的顺序是:body=>div=>button=>p</body>

流程:先捕获,然后处理,然后再冒泡出去。


2. 原型链



1. 原型对象诞生原因和本质


为了解决无法共享公共属性的问题,所以要设计一个对象专门用来存储对象共享的属性,那么我们叫它「原型对象


原理:构造函数加一个属性叫做prototype,用来指向原型对象,我们把所有实例对象共享的属性和方法都放在这个构造函数的prototype属性指向的原型对象中,不需要共享的属性和方法放在构造函数中。实现构造函数生成的所有实例对象都能够共享属性。


构造函数:私有属性
原型对象:共有属性

2.  彼此之间的关系


构造函数中一属性prototype:指向原型对象,而原型对象一constructor属性,又指回了构造函数。

每个构造函数生成的实例对象都有一个proto属性,这个属性指向原型对象。


那原型对象的_proto_属性指向谁?-> null


3. 原型链是什么?


顾名思义,肯定是一条链,既然每个对象都有一个_proto_属性指向原型对象,那么原型对象也有_proto_指向原型对象的原型对象,直到指向上图中的null,这才到达原型链的顶端。


4. 原型链和继承使用场景


原型链主要用于继承,实现代码复用。因为js算不上是面向对象的语言,继承是基于原型实现而不是基于类实现的,


a. 判断函数的原型是否在对象的原型链上


对象 instanceof 函数(不推荐使用)


b. 创建一个新对象,该新对象的隐式原型指向指定的对象


Object.create(对象)


var obj = Object.create(Object.prototype);


obj.__proto__ === Object.prototype


c. new的实现


d. es6的class A extends B 


因为es6-没有类和继承的概念。js实现继承本质是把js中的对象构造函数在自己的脑中抽象成一个类,然后使用构造函数的protptype属性封装出一个类(另一个构造函数),使之完美继承前一构造函数的所有属性和方法。因为构造函数能new出一个具体的对象实例,这就在js中实现了现代化的面向对象和继承。


3. 闭包和垃圾回收机制


闭包的概念


  function f1(){    var n=999;    function f2(){      alert(n);    }    return f2;  }  var result=f1();  result(); // 999

在上面的代码中,函数f2就被包括在函数f1内部,这时f1内部的所有局部变量,对f2都是可见的。但是反过来就不行,f2内部的局部变量,对f1就是不可见的。这就是Javascript语言特有的"链式作用域"结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。


既然f2可以读取f1中的局部变量,那么只要把f2作为返回值,我们不就可以在f1外部读取它的内部变量了吗!


作用:一是前面提到的可以读取函数内部的变量,二是让这些变量的值始终保持在内存中,主要用来封装私有变量, 提供一些暴露的接口


垃圾回收


**垃圾回收机制:JavaScript 引擎中有一个后台进程称为垃圾回收器,它监视所有对象,并删除那些不可访问的对象

**


**注意点:**对于内存的管理,Javascript与C语言等底层语言JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理,


实现的原理:由于 f2 中引用了 相对于自己的全局变量 n ,所以 f2 会一直存在内存中,又因为 n 是 f1 中的局部变量,也就是说 f2 依赖 f1,所以说 f1 也会一直存在内存中,并不像普通函数那样,调用后变量便被垃圾回收了。


所以说,在setTimeout中的函数引用了外层 for循环的变量 i,导致 i 一直存在内存中,不被回收,所以等到JS队列执行 函数时,i 已经是 10了,所以最终打印 10个10。


五、使用闭包的注意点


1)由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。


for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
打印:5个6,原因js事件执行机制
办法一:
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})(i);
}
打印:依次输出1到5
原因:因为实际参数跟定时器内部的i有强依赖。通过闭包,将i的变量驻留在内存中,当输出j时,
引用的是外部函数的变量值i,i的值是根据循环来的,执行setTimeout时已经确定了里面的的输出了。办法二:
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
打印:依次输出1到5因为for循环头部的let不仅将i绑定到for循环中,事实上它将其重新绑定到循环体的每一次迭代中,
确保上一次迭代结束的值重新被赋值。
setTimeout里面的function()属于一个新的域,
通过var定义的变量是无法传入到这个函数执行域中的,
通过使用let来声明块变量能作用于这个块,所以function就能使用i这个变量了;
这个匿名函数的参数作用域和for参数的作用域不一样,是利用了这一点来完成的。
这个匿名函数的作用域有点类似类的属性,是可以被内层方法使用的。

4. js事件执行机制


事件循环的过程如下:



  1. JS引擎(唯一主线程)按顺序解析代码,遇到函数声明,直接跳过,遇到函数调用,入栈;

  2. 如果是同步函数调用,直接执行得到结果,同步函数弹出栈,继续下一个函数调用;

  3. 如果是异步函数调用,分发给Web API(多个辅助线程),异步函数弹出栈,继续下一个函数调用;

  4. Web API中,异步函数在相应辅助线程中处理完成后,即异步函数达到触发条件了(比如setTimeout设置的10s后),如果异步函数是宏任务,则入宏任务消息队列,如果是微任务,则入微任务消息队列;

  5. Event Loop不停地检查主线程的调用栈与回调队列,当调用栈空时,就把微任务消息队列中的第一个任务推入栈中执行,执行完成后,再取第二个微任务,直到微任务消息队列为空;然后

    去宏任务消息队列中取第一个宏任务推入栈中执行,当该宏任务执行完成后,在下一个宏任务执行前,再依次取出微任务消息队列中的所有微任务入栈执行。

  6. 上述过程不断循环,每当微任务队列清空,可作为本轮事件循环的结束。




链接:https://juejin.cn/post/6987429092542922783

收起阅读 »

项目中实用的前端性能优化

一、CDN 1. CDN的概念 CDN(Content Delivery Network,内容分发网络)是指一种通过互联网互相连接的电脑网络系统,利用最靠近每位用户的服务器,更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户,来提供高性能、可扩展性...
继续阅读 »

一、CDN


1. CDN的概念


CDN(Content Delivery Network,内容分发网络)是指一种通过互联网互相连接的电脑网络系统,利用最靠近每位用户的服务器,更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户,来提供高性能、可扩展性及低成本的网络内容传递给用户。


典型的CDN系统由下面三个部分组成:



  • 分发服务系统: 最基本的工作单元就是Cache设备,cache(边缘cache)负责直接响应最终用户的访问请求,把缓存在本地的内容快速地提供给用户。同时cache还负责与源站点进行内容同步,把更新的内容以及本地没有的内容从源站点获取并保存在本地。Cache设备的数量、规模、总服务能力是衡量一个CDN系统服务能力的最基本的指标。

  • 负载均衡系统: 主要功能是负责对所有发起服务请求的用户进行访问调度,确定提供给用户的最终实际访问地址。两级调度体系分为全局负载均衡(GSLB)和本地负载均衡(SLB)。全局负载均衡主要根据用户就近性原则,通过对每个服务节点进行“最优”判断,确定向用户提供服务的cache的物理位置。本地负载均衡主要负责节点内部的设备负载均衡

  • **运营管理系统:**运营管理系统分为运营管理和网络管理子系统,负责处理业务层面的与外界系统交互所必须的收集、整理、交付工作,包含客户管理、产品管理、计费管理、统计分析等功能。


2. CDN的作用


CDN一般会用来托管Web资源(包括文本、图片和脚本等),可供下载的资源(媒体文件、软件、文档等),应用程序(门户网站等)。使用CDN来加速这些资源的访问。


(1)在性能方面,引入CDN的作用在于:



  • 用户收到的内容来自最近的数据中心,延迟更低,内容加载更快

  • 部分资源请求分配给了CDN,减少了服务器的负载


(2)在安全方面,CDN有助于防御DDoS、MITM等网络攻击:



  • 针对DDoS:通过监控分析异常流量,限制其请求频率

  • 针对MITM:从源服务器到 CDN 节点到 ISP(Internet Service Provider),全链路 HTTPS 通信


除此之外,CDN作为一种基础的云服务,同样具有资源托管、按需扩展(能够应对流量高峰)等方面的优势。


3. CDN的原理


CDN和DNS有着密不可分的联系,先来看一下DNS的解析域名过程,在浏览器输入 http://www.test.com 的解析过程如下:


(1) 检查浏览器缓存


(2)检查操作系统缓存,常见的如hosts文件


(3)检查路由器缓存


(4)如果前几步都没没找到,会向ISP(网络服务提供商)的LDNS服务器查询


(5)如果LDNS服务器没找到,会向根域名服务器(Root Server)请求解析,分为以下几步:



  • 根服务器返回顶级域名(TLD)服务器如.com.cn.org等的地址,该例子中会返回.com的地址

  • 接着向顶级域名服务器发送请求,然后会返回次级域名(SLD)服务器的地址,本例子会返回.test的地址

  • 接着向次级域名服务器发送请求,然后会返回通过域名查询到的目标IP,本例子会返回http://www.test.com的地址

  • Local DNS Server会缓存结果,并返回给用户,缓存在系统中


CDN的工作原理:


(1)用户未使用CDN缓存资源的过程:



  1. 浏览器通过DNS对域名进行解析(就是上面的DNS解析过程),依次得到此域名对应的IP地址

  2. 浏览器根据得到的IP地址,向域名的服务主机发送数据请求

  3. 服务器向浏览器返回响应数据


(2)用户使用CDN缓存资源的过程:



  1. 对于点击的数据的URL,经过本地DNS系统的解析,发现该URL对应的是一个CDN专用的DNS服务器,DNS系统就会将域名解析权交给CNAME指向的CDN专用的DNS服务器。

  2. CND专用DNS服务器将CND的全局负载均衡设备IP地址返回给用户

  3. 用户向CDN的全局负载均衡设备发起数据请求

  4. CDN的全局负载均衡设备根据用户的IP地址,以及用户请求的内容URL,选择一台用户所属区域的区域负载均衡设备,告诉用户向这台设备发起请求

  5. 区域负载均衡设备选择一台合适的缓存服务器来提供服务,将该缓存服务器的IP地址返回给全局负载均衡设备

  6. 全局负载均衡设备把服务器的IP地址返回给用户

  7. 用户向该缓存服务器发起请求,缓存服务器响应用户的请求,将用户所需内容发送至用户终端。


如果缓存服务器没有用户想要的内容,那么缓存服务器就会向它的上一级缓存服务器请求内容,以此类推,直到获取到需要的资源。最后如果还是没有,就会回到自己的服务器去获取资源。


image


CNAME(意为:别名):在域名解析中,实际上解析出来的指定域名对应的IP地址,或者该域名的一个CNAME,然后再根据这个CNAME来查找对应的IP地址。


4. CDN的使用场景



  • **使用第三方的CDN服务:**如果想要开源一些项目,可以使用第三方的CDN服务

  • **使用CDN进行静态资源的缓存:**将自己网站的静态资源放在CDN上,比如js、css、图片等。可以将整个项目放在CDN上,完成一键部署。

  • **直播传送:**直播本质上是使用流媒体进行传送,CDN也是支持流媒体传送的,所以直播完全可以使用CDN来提高访问速度。CDN在处理流媒体的时候与处理普通静态文件有所不同,普通文件如果在边缘节点没有找到的话,就会去上一层接着寻找,但是流媒体本身数据量就非常大,如果使用回源的方式,必然会带来性能问题,所以流媒体一般采用的都是主动推送的方式来进行。


二、懒加载


1. 懒加载的概念


懒加载也叫做延迟加载、按需加载,指的是在长网页中延迟加载图片数据,是一种较好的网页性能优化的方式。在比较长的网页或应用中,如果图片很多,所有的图片都被加载出来,而用户只能看到可视窗口的那一部分图片数据,这样就浪费了性能。


如果使用图片的懒加载就可以解决以上问题。在滚动屏幕之前,可视化区域之外的图片不会进行加载,在滚动屏幕时才加载。这样使得网页的加载速度更快,减少了服务器的负载。懒加载适用于图片较多,页面列表较长(长列表)的场景中。


2. 懒加载的特点



  • 减少无用资源的加载:使用懒加载明显减少了服务器的压力和流量,同时也减小了浏览器的负担。

  • 提升用户体验: 如果同时加载较多图片,可能需要等待的时间较长,这样影响了用户体验,而使用懒加载就能大大的提高用户体验。

  • 防止加载过多图片而影响其他资源文件的加载 :会影响网站应用的正常使用。


3. 懒加载的实现原理


图片的加载是由src引起的,当对src赋值时,浏览器就会请求图片资源。根据这个原理,我们使用HTML5 的data-xxx属性来储存图片的路径,在需要加载图片的时候,将data-xxx中图片的路径赋值给src,这样就实现了图片的按需加载,即懒加载。


注意:data-xxx 中的xxx可以自定义,这里我们使用data-src来定义。


懒加载的实现重点在于确定用户需要加载哪张图片,在浏览器中,可视区域内的资源就是用户需要的资源。所以当图片出现在可视区域时,获取图片的真实地址并赋值给图片即可。


使用原生JavaScript实现懒加载:


知识点:


(1)window.innerHeight 是浏览器可视区的高度


(2)document.body.scrollTop || document.documentElement.scrollTop 是浏览器滚动的过的距离


(3)imgs.offsetTop 是元素顶部距离文档顶部的高度(包括滚动条的距离)


(4)图片加载条件:img.offsetTop < window.innerHeight + document.body.scrollTop;


图示:


image


代码实现:


<div>
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
</div>
<script>
var imgs = document.querySelectorAll('img');
function lozyLoad(){
var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
var winHeight= window.innerHeight;
for(var i=0;i < imgs.length;i++){
if(imgs[i].offsetTop < scrollTop + winHeight ){
imgs[i].src = imgs[i].getAttribute('data-src');
}
}
}
window.onscroll = lozyLoad;
</script>

4. 懒加载与预加载的区别


这两种方式都是提高网页性能的方式,两者主要区别是一个是提前加载,一个是迟缓甚至不加载。懒加载对服务器前端有一定的缓解压力作用,预加载则会增加服务器前端压力。



  • 懒加载也叫延迟加载,指的是在长网页中延迟加载图片的时机,当用户需要访问时,再去加载,这样可以提高网站的首屏加载速度,提升用户的体验,并且可以减少服务器的压力。它适用于图片很多,页面很长的电商网站的场景。懒加载的实现原理是,将页面上的图片的 src 属性设置为空字符串,将图片的真实路径保存在一个自定义属性中,当页面滚动的时候,进行判断,如果图片进入页面可视区域内,则从自定义属性中取出真实路径赋值给图片的 src 属性,以此来实现图片的延迟加载。

  • 预加载指的是将所需的资源提前请求加载到本地,这样后面在需要用到时就直接从缓存取资源。 通过预加载能够减少用户的等待时间,提高用户的体验。我了解的预加载的最常用的方式是使用 js 中的 image 对象,通过为 image 对象来设置 scr 属性,来实现图片的预加载。


三、回流与重绘


1. 回流与重绘的概念及触发条件


(1)回流


当渲染树中部分或者全部元素的尺寸、结构或者属性发生变化时,浏览器会重新渲染部分或者全部文档的过程就称为回流


下面这些操作会导致回流:



  • 页面的首次渲染

  • 浏览器的窗口大小发生变化

  • 元素的内容发生变化

  • 元素的尺寸或者位置发生变化

  • 元素的字体大小发生变化

  • 激活CSS伪类

  • 查询某些属性或者调用某些方法

  • 添加或者删除可见的DOM元素


在触发回流(重排)的时候,由于浏览器渲染页面是基于流式布局的,所以当触发回流时,会导致周围的DOM元素重新排列,它的影响范围有两种:



  • 全局范围:从根节点开始,对整个渲染树进行重新布局

  • 局部范围:对渲染树的某部分或者一个渲染对象进行重新布局


(2)重绘


当页面中某些元素的样式发生变化,但是不会影响其在文档流中的位置时,浏览器就会对元素进行重新绘制,这个过程就是重绘


下面这些操作会导致回流:



  • color、background 相关属性:background-color、background-image 等

  • outline 相关属性:outline-color、outline-width 、text-decoration

  • border-radius、visibility、box-shadow


注意: 当触发回流时,一定会触发重绘,但是重绘不一定会引发回流。


2. 如何避免回流与重绘?


减少回流与重绘的措施:



  • 操作DOM时,尽量在低层级的DOM节点进行操作

  • 不要使用table布局, 一个小的改动可能会使整个table进行重新布局

  • 使用CSS的表达式

  • 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。

  • 使用absolute或者fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素

  • 避免频繁操作DOM,可以创建一个文档片段documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中

  • 将元素先设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。

  • 将DOM的多个读操作(或者写操作)放在一起,而不是读写操作穿插着写。这得益于浏览器的渲染队列机制


浏览器针对页面的回流与重绘,进行了自身的优化——渲染队列


浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘。


上面,将多个读操作(或者写操作)放在一起,就会等所有的读操作进入队列之后执行,这样,原本应该是触发多次回流,变成了只触发一次回流。


3. 如何优化动画?


对于如何优化动画,我们知道,一般情况下,动画需要频繁的操作DOM,就就会导致页面的性能问题,我们可以将动画的position属性设置为absolute或者fixed,将动画脱离文档流,这样他的回流就不会影响到页面了。


4. documentFragment 是什么?用它跟直接操作 DOM 的区别是什么?


MDN中对documentFragment的解释:



DocumentFragment,文档片段接口,一个没有父对象的最小文档对象。它被作为一个轻量版的 Document使用,就像标准的document一样,存储由节点(nodes)组成的文档结构。与document相比,最大的区别是DocumentFragment不是真实 DOM 树的一部分,它的变化不会触发 DOM 树的重新渲染,且不会导致性能等问题。



当我们把一个 DocumentFragment 节点插入文档树时,插入的不是 DocumentFragment 自身,而是它的所有子孙节点。在频繁的DOM操作时,我们就可以将DOM元素插入DocumentFragment,之后一次性的将所有的子孙节点插入文档中。和直接操作DOM相比,将DocumentFragment 节点插入DOM树时,不会触发页面的重绘,这样就大大提高了页面的性能。


四、节流与防抖


1. 对节流与防抖的理解



  • 函数防抖是指在事件被触发 n 秒后再执行回调,如果在这 n 秒内事件又被触发,则重新计时。这可以使用在一些点击请求的事件上,避免因为用户的多次点击向后端发送多次请求。

  • 函数节流是指规定一个单位时间,在这个单位时间内,只能有一次触发事件的回调函数执行,如果在同一个单位时间内某事件被触发多次,只有一次能生效。节流可以使用在 scroll 函数的事件监听上,通过事件节流来降低事件调用的频率。


防抖函数的应用场景:



  • 按钮提交场景:防⽌多次提交按钮,只执⾏最后提交的⼀次

  • 服务端验证场景:表单验证需要服务端配合,只执⾏⼀段连续的输⼊事件的最后⼀次,还有搜索联想词功能类似⽣存环境请⽤lodash.debounce


节流函数的****适⽤场景:



  • 拖拽场景:固定时间内只执⾏⼀次,防⽌超⾼频次触发位置变动

  • 缩放场景:监控浏览器resize

  • 动画场景:避免短时间内多次触发动画引起性能问题


2. 实现节流函数和防抖函数


函数防抖的实现:


function debounce(fn, wait) {
var timer = null;

return function() {
var context = this,
args = [...arguments];

// 如果此时存在定时器的话,则取消之前的定时器重新记时
if (timer) {
clearTimeout(timer);
timer = null;
}

// 设置定时器,使事件间隔指定事件后执行
timer = setTimeout(() => {
fn.apply(context, args);
}, wait);
};
}

函数节流的实现:


// 时间戳版
function throttle(fn, delay) {
var preTime = Date.now();

return function() {
var context = this,
args = [...arguments],
nowTime = Date.now();

// 如果两次时间间隔超过了指定时间,则执行函数。
if (nowTime - preTime >= delay) {
preTime = Date.now();
return fn.apply(context, args);
}
};
}

// 定时器版
function throttle (fun, wait){
let timeout = null
return function(){
let context = this
let args = [...arguments]
if(!timeout){
timeout = setTimeout(() => {
fun.apply(context, args)
timeout = null
}, wait)
}
}
}

五、图片优化


1. 如何对项目中的图片进行优化?



  1. 不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。

  2. 对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片。

  3. 小图使用 base64 格式

  4. 将多个图标文件整合到一张图片中(雪碧图)

  5. 选择正确的图片格式:





    • 对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好

    • 小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替

    • 照片使用 JPEG




2. 常见的图片格式及使用场景


(1)BMP,是无损的、既支持索引色也支持直接色的点阵图。这种图片格式几乎没有对数据进行压缩,所以BMP格式的图片通常是较大的文件。


(2)GIF是无损的、采用索引色的点阵图。采用LZW压缩算法进行编码。文件小,是GIF格式的优点,同时,GIF格式还具有支持动画以及透明的优点。但是GIF格式仅支持8bit的索引色,所以GIF格式适用于对色彩要求不高同时需要文件体积较小的场景。


(3)JPEG是有损的、采用直接色的点阵图。JPEG的图片的优点是采用了直接色,得益于更丰富的色彩,JPEG非常适合用来存储照片,与GIF相比,JPEG不适合用来存储企业Logo、线框类的图。因为有损压缩会导致图片模糊,而直接色的选用,又会导致图片文件较GIF更大。


(4)PNG-8是无损的、使用索引色的点阵图。PNG是一种比较新的图片格式,PNG-8是非常好的GIF格式替代者,在可能的情况下,应该尽可能的使用PNG-8而不是GIF,因为在相同的图片效果下,PNG-8具有更小的文件体积。除此之外,PNG-8还支持透明度的调节,而GIF并不支持。除非需要动画的支持,否则没有理由使用GIF而不是PNG-8。


(5)PNG-24是无损的、使用直接色的点阵图。PNG-24的优点在于它压缩了图片的数据,使得同样效果的图片,PNG-24格式的文件大小要比BMP小得多。当然,PNG24的图片还是要比JPEG、GIF、PNG-8大得多。


(6)SVG是无损的矢量图。SVG是矢量图意味着SVG图片由直线和曲线以及绘制它们的方法组成。当放大SVG图片时,看到的还是线和曲线,而不会出现像素点。这意味着SVG图片在放大时,不会失真,所以它非常适合用来绘制Logo、Icon等。


(7)WebP是谷歌开发的一种新图片格式,WebP是同时支持有损和无损压缩的、使用直接色的点阵图。从名字就可以看出来它是为Web而生的,什么叫为Web而生呢?就是说相同质量的图片,WebP具有更小的文件体积。现在网站上充满了大量的图片,如果能够降低每一个图片的文件大小,那么将大大减少浏览器和服务器之间的数据传输量,进而降低访问延迟,提升访问体验。目前只有Chrome浏览器和Opera浏览器支持WebP格式,兼容性不太好。



  • 在无损压缩的情况下,相同质量的WebP图片,文件大小要比PNG小26%;

  • 在有损压缩的情况下,具有相同图片精度的WebP图片,文件大小要比JPEG小25%~34%;

  • WebP图片格式支持图片透明度,一个无损压缩的WebP图片,如果要支持透明度只需要22%的格外文件大小。


六、Webpack优化


1. 如何提⾼webpack的打包速度**?**


(1)优化 Loader


对于 Loader 来说,影响打包效率首当其冲必属 Babel 了。因为 Babel 会将代码转为字符串生成 AST,然后对 AST 继续进行转变最后再生成新的代码,项目越大,转换代码越多,效率就越低。当然了,这是可以优化的。


首先我们优化 Loader 的文件搜索范围


module.exports = {
module: {
rules: [
{
// js 文件才使用 babel
test: /\.js$/,
loader: 'babel-loader',
// 只在 src 文件夹下查找
include: [resolve('src')],
// 不会去查找的路径
exclude: /node_modules/
}
]
}
}

对于 Babel 来说,希望只作用在 JS 代码上的,然后 node_modules 中使用的代码都是编译过的,所以完全没有必要再去处理一遍。


当然这样做还不够,还可以将 Babel 编译过的文件缓存起来,下次只需要编译更改过的代码文件即可,这样可以大幅度加快打包时间


loader: 'babel-loader?cacheDirectory=true'

(2)HappyPack


受限于 Node 是单线程运行的,所以 Webpack 在打包的过程中也是单线程的,特别是在执行 Loader 的时候,长时间编译的任务很多,这样就会导致等待的情况。


HappyPack 可以将 Loader 的同步执行转换为并行的,这样就能充分利用系统资源来加快打包效率了


module: {
loaders: [
{
test: /\.js$/,
include: [resolve('src')],
exclude: /node_modules/,
// id 后面的内容对应下面
loader: 'happypack/loader?id=happybabel'
}
]
},
plugins: [
new HappyPack({
id: 'happybabel',
loaders: ['babel-loader?cacheDirectory'],
// 开启 4 个线程
threads: 4
})
]

(3)DllPlugin


DllPlugin 可以将特定的类库提前打包然后引入。这种方式可以极大的减少打包类库的次数,只有当类库更新版本才有需要重新打包,并且也实现了将公共代码抽离成单独文件的优化方案。DllPlugin的使用方法如下:


// 单独配置在一个文件中
// webpack.dll.conf.js
const path = require('path')
const webpack = require('webpack')
module.exports = {
entry: {
// 想统一打包的类库
vendor: ['react']
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].dll.js',
library: '[name]-[hash]'
},
plugins: [
new webpack.DllPlugin({
// name 必须和 output.library 一致
name: '[name]-[hash]',
// 该属性需要与 DllReferencePlugin 中一致
context: __dirname,
path: path.join(__dirname, 'dist', '[name]-manifest.json')
})
]
}

然后需要执行这个配置文件生成依赖文件,接下来需要使用 DllReferencePlugin 将依赖文件引入项目中


// webpack.conf.js
module.exports = {
// ...省略其他配置
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
// manifest 就是之前打包出来的 json 文件
manifest: require('./dist/vendor-manifest.json'),
})
]
}

(4)代码压缩


在 Webpack3 中,一般使用 UglifyJS 来压缩代码,但是这个是单线程运行的,为了加快效率,可以使用 webpack-parallel-uglify-plugin 来并行运行 UglifyJS,从而提高效率。


在 Webpack4 中,不需要以上这些操作了,只需要将 mode 设置为 production 就可以默认开启以上功能。代码压缩也是我们必做的性能优化方案,当然我们不止可以压缩 JS 代码,还可以压缩 HTML、CSS 代码,并且在压缩 JS 代码的过程中,我们还可以通过配置实现比如删除 console.log 这类代码的功能。


(5)其他


可以通过一些小的优化点来加快打包速度



  • resolve.extensions:用来表明文件后缀列表,默认查找顺序是 ['.js', '.json'],如果你的导入文件没有添加后缀就会按照这个顺序查找文件。我们应该尽可能减少后缀列表长度,然后将出现频率高的后缀排在前面

  • resolve.alias:可以通过别名的方式来映射一个路径,能让 Webpack 更快找到路径

  • module.noParse:如果你确定一个文件下没有其他依赖,就可以使用该属性让 Webpack 不扫描该文件,这种方式对于大型的类库很有帮助


2. 如何减少 Webpack 打包体积


(1)按需加载


在开发 SPA 项目的时候,项目中都会存在很多路由页面。如果将这些页面全部打包进一个 JS 文件的话,虽然将多个请求合并了,但是同样也加载了很多并不需要的代码,耗费了更长的时间。那么为了首页能更快地呈现给用户,希望首页能加载的文件体积越小越好,这时候就可以使用按需加载,将每个路由页面单独打包为一个文件。当然不仅仅路由可以按需加载,对于 loadash 这种大型类库同样可以使用这个功能。


按需加载的代码实现这里就不详细展开了,因为鉴于用的框架不同,实现起来都是不一样的。当然了,虽然他们的用法可能不同,但是底层的机制都是一样的。都是当使用的时候再去下载对应文件,返回一个 Promise,当 Promise 成功以后去执行回调。


(2)Scope Hoisting


Scope Hoisting 会分析出模块之间的依赖关系,尽可能的把打包出来的模块合并到一个函数中去。


比如希望打包两个文件:


// test.js
export const a = 1
// index.js
import { a } from './test.js'

对于这种情况,打包出来的代码会类似这样:


[
/* 0 */
function (module, exports, require) {
//...
},
/* 1 */
function (module, exports, require) {
//...
}
]

但是如果使用 Scope Hoisting ,代码就会尽可能的合并到一个函数中去,也就变成了这样的类似代码:


[
/* 0 */
function (module, exports, require) {
//...
}
]

这样的打包方式生成的代码明显比之前的少多了。如果在 Webpack4 中你希望开启这个功能,只需要启用 optimization.concatenateModules 就可以了:


module.exports = {
optimization: {
concatenateModules: true
}
}

(3)Tree Shaking


Tree Shaking 可以实现删除项目中未被引用的代码,比如:


// test.js
export const a = 1
export const b = 2
// index.js
import { a } from './test.js'

对于以上情况,test 文件中的变量 b 如果没有在项目中使用到的话,就不会被打包到文件中。


如果使用 Webpack 4 的话,开启生产环境就会自动启动这个优化功能。


3. 如何⽤webpack来优化前端性能?


⽤webpack优化前端性能是指优化webpack的输出结果,让打包的最终结果在浏览器运⾏快速⾼效。



  • 压缩代码:删除多余的代码、注释、简化代码的写法等等⽅式。可以利⽤webpack的 UglifyJsPlugin 和 ParallelUglifyPlugin 来压缩JS⽂件, 利⽤ cssnano (css-loader?minimize)来压缩css

  • 利⽤CDN加速: 在构建过程中,将引⽤的静态资源路径修改为CDN上对应的路径。可以利⽤webpack对于 output 参数和各loader的 publicPath 参数来修改资源路径

  • Tree Shaking: 将代码中永远不会⾛到的⽚段删除掉。可以通过在启动webpack时追加参数 --optimize-minimize 来实现

  • Code Splitting: 将代码按路由维度或者组件分块(chunk),这样做到按需加载,同时可以充分利⽤浏览器缓存

  • 提取公共第三⽅库: SplitChunksPlugin插件来进⾏公共模块抽取,利⽤浏览器缓存可以⻓期缓存这些⽆需频繁变动的公共代码


4. 如何提⾼webpack的构建速度?



  1. 多⼊⼝情况下,使⽤ CommonsChunkPlugin 来提取公共代码

  2. 通过 externals 配置来提取常⽤库

  3. 利⽤ DllPlugin 和 DllReferencePlugin 预编译资源模块 通过 DllPlugin 来对那些我们引⽤但是绝对不会修改的npm包来进⾏预编译,再通过 DllReferencePlugin 将预编译的模块加载进来。

  4. 使⽤ Happypack 实现多线程加速编译

  5. 使⽤ webpack-uglify-parallel 来提升 uglifyPlugin 的压缩速度。 原理上 webpack-uglify-parallel 采⽤了多核并⾏压缩来提升压缩速度

  6. 使⽤ Tree-shaking 和 Scope Hoisting 来剔除多余代码

链接:https://juejin.cn/post/6987268877096845320

收起阅读 »

『前端BUG』—— 本地代理导致会话cookie中的数据丢失

vue
问题在本地用代理请求服务端接口,解决跨域问题后,发生了一件极其诡异的事情,明明登录成功了,但是请求每个接口都返回未登录的报错信息。原因该套系统是采用会话cookie进行登录用户的身份认证,故查看每个请求的Request Headers中的cookie的值,发现...
继续阅读 »

问题

在本地用代理请求服务端接口,解决跨域问题后,发生了一件极其诡异的事情,明明登录成功了,但是请求每个接口都返回未登录的报错信息。

原因

该套系统是采用会话cookie进行登录用户的身份认证,故查看每个请求的Request Headers中的cookie的值,发现原本如下图中的红框区域的SESSION不见了。

image.png

而明明登录接口的Response Headers中是存在set-cookie。

image.png

set-cookie会是把其值中的SESSION存储到浏览器的cookie中,存储成功后,每次请求服务端时,都会去浏览器中的cookie中读取SESSION,然后通过Request Headers中的cookie传递到服务端,完成身份认证。

另外set-cookie的值是服务端设置的,我们来认真观察一下set-cookie的值

SESSION=NjE1MTNmZWI1N2ExNDYyZGE4MWE0YmZjNjgwMmFmZGY=; Path=/api/operation/; HttpOnly; SameSite=Lax
复制代码

里面除SESSION,还有PathHttpOnlySameSite,而Path就是导致SESSION无法存储到客户端中的元凶,其中Path的值/api/operation/表示该cookie只有在用请求路径的前缀为/api/operation/才能使用。

回到代理配置中一看,

proxy: getProxy({
'/dev': {
target: 'https://xxx.xxx.com',
pathRewrite: { '^/dev': '/api' },
secure: false,
changeOrigin: true
}
}),
复制代码

代理后,请求服务端的地址为xxx.xxx.com/dev/operati… ,其路径为 dev/operation/xxx,自然与/api/operation/不匹配,导致该cookie无法使用,自然无法将SESSION保存到浏览器的cookie中。

解决

找到原因了,问题很好解决,只要更改一下代理配置。

proxy: getProxy({
'/api': {
target: 'https://xxx.xxx.com',
pathRewrite: { '^/api': '/api' },
secure: false,
changeOrigin: true
}
}),
复制代码

此外不要忘记更改 axios 的配置中的baseURL,将其改为/api/


链接:https://juejin.cn/post/6987408221669425189

收起阅读 »

「自我检验」输入URL发生了啥?希望你顺便懂这15个知识点

输入URL发生了啥? 1、浏览器的地址栏输入URL并按下回车。 2、浏览器查找当前URL是否存在缓存,并比较缓存是否过期。 3、DNS解析URL对应的IP。 4、根据IP建立TCP连接(三次握手)。 5、HTTP发起请求。 6、服务器处理请求,浏览器接收HT...
继续阅读 »

输入URL发生了啥?



  • 1、浏览器的地址栏输入URL并按下回车。

  • 2、浏览器查找当前URL是否存在缓存,并比较缓存是否过期。

  • 3、DNS解析URL对应的IP。

  • 4、根据IP建立TCP连接(三次握手)。

  • 5、HTTP发起请求。

  • 6、服务器处理请求,浏览器接收HTTP响应。

  • 7、渲染页面,构建DOM树。

  • 8、关闭TCP连接(四次挥手)。


永恒钻石


1. 浏览器应该具备什么功能?



  • 1、网络:浏览器通过网络模块来下载各式各样的资源,例如HTML文本,JavaScript代码,CSS样式表,图片,音视频文件等。网络部分尤为重要,因为它耗时长,而且需要安全访问互联网上的资源

  • 2、资源管理:从网络下载,或者本地获取到的资源需要有高效的机制来管理他们。例如如何避免重复下载,资源如何缓存等等

  • 3、网页浏览:这是浏览器的核心也是最基本的功能,最重要的功能。这个功能决定了如何将资源转变为可视化的结果

  • 4、多页面管理

  • 5、插件与管理

  • 6、账户和同步

  • 7、安全机制

  • 8、开发者工具


浏览器的主要功能总结起来就是一句话:将用户输入的url转变成可视化的图像


2. 浏览器的内核


在浏览器中有一个最重要的模块,它主要的作用是把一切请求回来的资源变成可视化的图像,这个模块就是浏览器内核,通常他也被称为渲染引擎


下面是浏览器内核的总结:



  • 1、IE:Trident

  • 2、Safari:WebKit。WebKit本身主要是由两个小引擎构成的,一个正是渲染引擎“WebCore”,另一个则是javascript解释引擎“JSCore”,它们均是从KDE的渲染引擎KHTML及javascript解释引擎KJS衍生而来。

  • 3、Chrome:Blink。在13年发布的Chrome 28.0.1469.0版本开始,Chrome放弃Chromium引擎转而使用最新的Blink引擎(基于WebKit2——苹果公司于2010年推出的新的WebKit引擎),Blink对比上一代的引擎精简了代码、改善了DOM框架,也提升了安全性。

  • 4、Opera:2013年2月宣布放弃Presto,采用Chromium引擎,又转为Blink引擎

  • 5、Firefox:Gecko


3. 进程和线程



  • 1、进程:程序的一次执行,它占有一片独有的内存空间,是操作系统执行的基本单元

  • 2、线程:是进程内的一个独立执行单元,是CPU调度的最小单元,程序运行基本单元

  • 3、一个进程中至少有一个运行的线程:主线程。它在进程启动后自动创建

  • 4、一个进程可以同时运行多个线程,我们常说程序是多线程运行的,比如你使用听歌软件,这个软件就是一个进程,而你在这个软件里听歌收藏歌点赞评论,这就是一个进程里的多个线程操作

  • 5、一个进程中的数据可以供其中的多个线程直接共享,但是进程与进程之间的数据时不能共享

  • 6、JS引擎是单线程运行


4. 浏览器渲染引擎的主要模块



  • 1、HTML解析器:解释HTML文档的解析器,主要作用是将HTML文本解释为DOM树

  • 2、CSS解析器:它的作用是为DOM中的各个元素对象计算出样式信息,为布局提供基础设施

  • 3、JavaScript引擎:JavaScript引擎能够解释JavaScript代码,并通过DOM接口和CSS接口来修改网页内容 和样式信息,从而改变渲染的结果

  • 4、布局(layout):在DOM创建之后,WebKit需要将其中的元素对象同样式信息结合起来,计算他们的大小位置等布局信息,形成一个能表达着所有信息的内部表示模型

  • 5、绘图模块(paint):使用图形库将布局计算后的各个网页的节点绘制成图像结果


5. 大致的渲染过程


第1题的第7点,渲染页面,构建DOM树,接下来说说大致的渲染过程



  • 1、浏览器会从上到下解析文档

  • 2、遇见HTML标记,调用HTML解析器解析为对应的token(一个token就是一个标签文本的序列化)并构建DOM树(就是一块内存,保存着tokens,建立他们之间的关系)

  • 3、遇见style/link标记调用相应解析器处理CSS标记,并构建出CSS样式树

  • 4、遇见script标记,调用JavaScript引擎处理script标记,绑定事件,修改DOM树/CSS树等

  • 5、将DOM树与CSS合并成一个渲染树

  • 6、根据渲染树来渲染,以计算每个节点的几何信息(这一过程需要依赖GPU)

  • 7、最终将各个节点绘制在屏幕上


02_浏览器渲染过程的副本.png


至尊星耀


6. CSS阻塞情况以及优化



  • 1、style标签中的样式:由HTML解析器进行解析,不会阻塞浏览器渲染(可能会产生“闪屏现象”),不会阻塞DOM解析

  • 2、link引入的CSS样式:由CSS解析器进行解析,阻塞浏览器渲染,会阻塞后面的js语句执行,不阻塞DOM的解析

  • 3、优化:使用CDN节点进行外部资源加速,对CSS进行压缩,优化CSS代码(不要使用太多层选择器)


注意:看下图,HTMLCSS是并行解析的,所以CSS不会阻塞HTML解析,但是,会阻塞整体页面的渲染(因为最后要渲染必须CSS和HTML一起解析完并合成一处)
02_浏览器渲染过程的副本.png


7. JS阻塞问题



  • 1、js会阻塞后续DOM的解析,原因是:浏览器不知道后续脚本的内容,如果先去解析了下面的DOM,而随后的js删除了后面所有的DOM,那么浏览器就做了无用功,浏览器无法预估脚本里面具体做了什么操作,例如像document.write这种操作,索性全部停住,等脚本执行完了,浏览器再继续向下解析DOM

  • 2、js会阻塞页面渲染,原因是:js中也可以给DOM设置样式,浏览器等该脚本执行完毕,渲染出一个最终结果,避免做无用功。

  • 3、js会阻塞后续js的执行,原因是维护依赖关系,例如:必须先引入jQuery再引入bootstrap


8. 资源加载阻塞


无论css阻塞,还是js阻塞,都不会阻塞浏览器加载外部资源(图片、视频、样式、脚本等)


原因:浏览器始终处于一种:“先把请求发出去”的工作模式,只要是涉及到网络请求的内容,无论是:图片、样式、脚本,都会先发送请求去获取资源,至于资源到本地之后什么时候用,由浏览器自己协调。这种做法效率很高。


9. 为什么CSS解析顺序从右到左


如果是从左到右的话:



  • 1、第一次从爷节点 -> 子节点 -> 孙节点1

  • 2、第一次从爷节点 -> 子节点 -> 孙节点2

  • 3、第一次从爷节点 -> 子节点 -> 孙节点3


如果三次都匹配不到的话,那至少也得走三次:爷节点 -> 子节点 -> 孙节点,这就做了很多无用功啊。


截屏2021-07-18 下午9.33.13.png


如果是从右到左的话:



  • 1、第一次从孙节点1,找不到,停止

  • 2、第一次从孙节点2,找不到,停止

  • 3、第一次从孙节点3,找不到,停止


这样的话,尽早发现找不到,尽早停止,可以少了很多无用功。


截屏2021-07-18 下午9.37.16.png


最强王者


10. 什么是重绘回流



  • 1、重绘:重绘是一个元素外观的改变所触发的浏览器行为,例如改变outline、背景色等属性。浏览器会根据元素的新属性重新绘制,使元素呈现新的外观。重绘不会带来重新布局,所以并不一定伴随重排。

  • 2、回流:渲染对象在创建完成并添加到渲染树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排,或回流

  • 3、"重绘"不一定需要"重排",比如改变某个网页元素的颜色,就只会触发"重绘",不会触发"重排",因为布局没有改变。

  • 4、"重排"大多数情况下会导致"重绘",比如改变一个网页元素的位置,就会同时触发"重排"和"重绘",因为布局改变了。


11. 触发重绘的属性


* color * background * outline-color * border-style * background-image * outline * border-radius * background-position * outline-style * visibility * background-repeat * outline-width * text-decoration * background-size * box-shadow


12. 触发回流的属性


* width * top * text-align * height * bottom * overflow-y * padding * left * font-weight * margin * right * overflow * display * position * font-family * border-width * float * line-height * border * clear * vertival-align * min-height * white-space


13. 常见触发重绘回流的行为



  • 1、当你增加、删除、修改 DOM 结点时,会导致 Reflow , Repaint。

  • 2、当你移动 DOM 的位置

  • 3、当你修改 CSS 样式的时候。

  • 4、当你Resize窗口的时候(移动端没有这个问题,因为移动端的缩放没有影响布局视口)

  • 5、当你修改网页的默认字体时。

  • 6、获取DOM的height或者width时,例如clientWidth、clientHeight、clientTop、clientLeft、offsetWidth、offsetHeight、offsetTop、offsetLeft、scrollWidth、scrollHeight、scrollTop、scrollLeft、scrollIntoView()、scrollIntoViewIfNeeded()、getComputedStyle()、getBoundingClientRect()、scrollTo()


14. 针对重绘回流的优化方案



  • 1、元素位置移动变换时尽量使用CSS3的transform来代替top,left等操作

  • 2、不要使用table布局

  • 3、将多次改变样式属性的操作合并成一次操作

  • 4、利用文档素碎片(documentFragment),vue使用了该方式提升性能

  • 5、动画实现过程中,启用GPU硬件加速:transform:tranlateZ(0)

  • 6、为动画元素新建图层,提高动画元素的z-index

  • 7、编写动画时,尽量使用requestAnimationFrame


15. 浏览器缓存分类


image.png



  1. 强缓存

    1. 不会向服务器发送请求,直接从本地缓存中获取数据

    2. 请求资源的的状态码为: 200 ok(from memory cache)

    3. 优先级:cache-control > expires



  2. 协商缓存

    1. 向服务器发送请求,服务器会根据请求头的资源判断是否命中协商缓存

    2. 如果命中,则返回304状态码通知浏览器从缓存中读取资源

    3. 优先级:Last-Modified与ETag是可以一起使用的,服务器会优先验证ETag,一致的情况下,才会继续比对Last-Modified,最后才决定是否返回304
链接:https://juejin.cn/post/6986416221323264030

收起阅读 »

今天聊:大厂如何用一道编程题考察候选人水平

进入正题 面试环节对面试官的一些挑战 面试官和候选人的知识结构可能有差异 => 可能会错过优秀的人 遇到「面霸」,频繁面试刷题,但是实际能力一般 => 招到不合适的人 要在短短半个小时到一个小时内判断一个人,其实很难 相对靠谱的做法 笔试:"...
继续阅读 »

进入正题


面试环节对面试官的一些挑战



  • 面试官和候选人的知识结构可能有差异 => 可能会错过优秀的人

  • 遇到「面霸」,频繁面试刷题,但是实际能力一般 => 招到不合适的人

  • 要在短短半个小时到一个小时内判断一个人,其实很难


相对靠谱的做法



  • 笔试:"Talk is cheap, show me the code"


笔试常见的问题



  • 考通用算法,Google 能直接搜到,失去考察意义

  • 题目难度设计有问题。要么满分,要么零分,可能错过还不错的同学

  • 和实际工作内容脱节


我认为好的笔试题



  • 上手门槛低,所有人多多少少都能写一点,不至于开天窗

  • 考点多,通过一道题可以基本摸清候选人的代码综合素养

  • 给高端的人有足够的发挥空间。同样的结果,不同的实现方式可以看出候选人的技术深度


我常用的一道笔试题


很普通的一道题


// 假设本地机器无法做加减乘除运算,需要通过远程请求让服务端来实现。
// 以加法为例,现有远程API的模拟实现

const addRemote = async (a, b) => new Promise(resolve => {
setTimeout(() => resolve(a + b), 1000)
});

// 请实现本地的add方法,调用addRemote,能最优的实现输入数字的加法。
async function add(...inputs) {
// 你的实现
}

// 请用示例验证运行结果:
add(1, 2)
.then(result => {
console.log(result); // 3
});


add(3, 5, 2)
.then(result => {
console.log(result); // 10
})

答案一
最基本的答案,如果写不出来,那大概率是通过不了了


async function add(...args) {
let res = 0;
if (args.length <= 2) return res;

for (const item of args) {
res = await addRemote(res, item);
}
return res;
}

递归版本


async function add(...args) {
let res = 0;
if (args.length === 0) return res;
if (args.length === 1) return args[0];

const a = args.pop();
const b = args.pop();
args.push(await addRemote(a, b));
return add(...args);
}

常见的问题:



  • 没有判断入参个数

  • 仍然用了本地加法


答案二
有候选人的答案如下:


// Promise链式调用版本
async function add(...args) {
return args.reduce((promiseChain, item) => {
return promiseChain.then(res => {
return addRemote(res, item);
});
}, Promise.resolve(0));

}

从这个实现可以看出:



  • 对 Array.prototype.reduce 的掌握

  • 对于 Promise 链式调用的理解

  • 考察候选人对 async function 本质的理解


这个版本至少能到 70 分


答案三
之前的答案结果都是对的,但是我们把耗时打出来,可以看到耗时和参数个数成线性关系,因为所有计算都是串行的,显然不是最优的解



更好一点的答案:


function add(...args) {
if (args.length <= 1) return Promise.resolve(args[0])
const promiseList = []
for (let i = 0; i * 2 < args.length - 1; i++) {
const promise = addRemote(args[i * 2], args[i * 2 + 1])
promiseList.push(promise)
}

if (args.length % 2) {
const promise = Promise.resolve(args[args.length - 1])
promiseList.push(promise)
}

return Promise.all(promiseList).then(results => add(...results));
}


可以看到很明显的优化。


答案四
还能再优化吗?
有些同学会想到加本地缓存


const cache = {};

function addFn(a, b) {
const key1 = `${a}${b}`;
const key2 = `${b}${a}`;
const cacheVal = cache[key1] || cache[key2];

if (cacheVal) return Promise.resolve(cacheVal);

return addRemote(a, b, res => {
cache[key1] = res;
cache[key2] = res;
return res;
});
}

加了缓存以后,我们再第二次执行相同参数加法时,可以不用请求远端,直接变成毫秒级返回



还能再优化吗?交给大家去思考


其他考察点


有些时候会让候选人将代码提交到 Github 仓库,以工作中一个实际的模块标准来开发,可以考察:



  • git 操作,commit 规范

  • 工程化素养

  • 是否有单元测试

  • 覆盖率是否达标

  • 依赖的模块版本如何设置

  • 如何配置 ci/cd

  • 文档、注释

  • ...


更加开放的一种笔试形式



  • 给一道题目,让候选人建一个 Github 仓库来完成

  • 题目有一定难度,但是可以 Google,也可以用三方模块,和我们平时做项目差不多

  • 通常面向级别较高的候选人


实际题目


// 有一个 10G 文件,每一行是一个时间戳,
// 现在要在一台 2C4G 的机器上对它进行排序,输出排序以后的文件

// 案例输入
// 1570593273487
// 1570593273486
// 1570593273488
// …

// 输出
// 1570593273486
// 1570593273487
// 1570593273488
// …



先看一个答案,看看哪里有问题


async function sort(inputFile, outputFile) {
const input = fs.createReadStream(inputFile);
const rl = readline.createInterface({ input });
const arr = [];
for await (const line of rl) {
const item = Number(line);
arr.push(item);
}
arr.sort((a, b) => a - b);

fs.writeFileSync(outputFile, arr.join('\n'));
}

10GB 的文件无法一次性放进内存里处理,内存只有 4GB


再看一个神奇的答案,只有一行代码,而且从结果来说是正确的。但不是我们笔试想要的答案。


const cp = require('child_process');

function sort(inputFile, outputFile) {
cp.exec(`sort -n ${inputFile} > ${outputFile}`);
}

解题思路



  • 既然没办法一次性在内存中排序,那我们能否将 10GB 的文件拆分成若干个小文件

  • 小文件先分别排序,然后再合并成一个大的文件


再将问题拆解



  • 拆分大文件到小文件

  • 小文件在内存里排序

  • 合并所有小文件成一个整体排序过的大文件


本题最难的点在于如何合并所有小文件。代码如何实现?



  • 这里需要用到一种数据结构:堆

  • 堆:就是用数组实现的一个二叉树

  • 堆分为:最大堆和最小堆,下面是一个最小堆(父节点小于它的子节点)


image.png


堆有一些特性:



  • 对于一个父节点来说

    • 左节点位置:父节点位置 * 2 + 1

    • 右节点位置:父节点位置 * 2 + 2



  • 很容易查找最大值 / 最小值


我们尝试把下面数组构造成一个最小堆


image.png



  • 从最后一个非叶子节点开始往前处理

  • 10 比 5 大,所以交换它们的位置


image.png



  • 然后是节点 2,符合要求不需要处理

  • 最后到顶点 3,它比左子节点大,所以要交换


image.png


完整的实现参考:github.com/gxcsoccer/e…
image.png







链接:https://juejin.cn/post/6987529814324281380

收起阅读 »

基础篇 - 从构建层面看 import 和 require 的区别

前言 一切的一切,都是因为群里的一个问题 虽然说最近在做 webpack 相关的事情,但是也没有对着干问题做过相关的研究,网上很多文章包括 vue 都介绍了建议使用 import ,但是没有说为什么要使用 import,对于开发者来说,调用的方式是没有区别的...
继续阅读 »

前言


一切的一切,都是因为群里的一个问题


image.png


虽然说最近在做 webpack 相关的事情,但是也没有对着干问题做过相关的研究,网上很多文章包括 vue 都介绍了建议使用 import ,但是没有说为什么要使用 import,对于开发者来说,调用的方式是没有区别的,那么为什么 import 的包就要比 require 的包小呢


这里暂时就不说什么调用方式了,什么动态加载(require)、静态编译(import)的,这个网上都有,这篇文章就是分析一下为什么要用 import,而不用 require


正文


首先本地先基于 webpack 搭建一个环境只是为了测试,不需要搭建太复杂的内容


基础文件内容


// webpack.config.js
module.exports = {
mode: 'development',
entry: './src/index.js'
}

index.js 内添加两种调用方式


function test() {
const { b } = import('./importtest')
console.log(b())
}
test()

// or

function test() {
const { b } = require('./requiretest')
console.log(b())
}
test()

importtest.js 中也是简单输出一下


// importtest.js
export default {
b: function () {
return {
name: 'zhangsan'
}
}
}

requiretest.js 也是如此


// requiretest.js
module.exports = {
b: function() {
return {
name: 'lisi'
}
}
}

上述的方式分别执行 webpack 后,输出的内容分别如下


import 输出


image.png


在打包时一共输出了两个文件:main.jssrc_importtest_js.jsmain.js 里面输出的内容如下


image.png


main.js 里面就是 index.js 里面的内容,importtest 里面的内容,是通过一个索引的方式引用过来的,引用的地址就是 src_importtest_js.js


require 输出


image.png


require 打包时,直接输出了一个文件,就只有一个 main.jsmain.js 里面输出的内容如下


image.png


main.js 里面的内容是 index.jsrequiretest.js 里面的所有内容


综上所述,我们从数据角度来看 import 的包是要大于 require 的,但通过打包文件来看,由业务代码导致的文件大小其实 import 是要小于 require 的
复制代码

多引用情况下导致的打包变化


这个时候我们大概知道了 importrequire 打包的区别,接下来我们可以模拟一下一开始那位同学的问题,直接修改一下 webpack.config.js 的入口即可


module.exports = {
mode: 'development',
entry: {
index: './src/index.js',
index1: './src/index1.js'
}
}
复制代码

这里直接保证 index.jsindex1.js 的内容一样即可,还是先测试一下 import 的打包


image.png


这里的内容和单入口时打包的 import 基本一致,里面出了本身的内容外,都是引用的 src_importtest_js 的地址,那么在看看 require 的包


image.png


这里内容和单入口打包的 require 基本一致,都是把 requiretest 的内容复制到了对应的文件内


虽然我们现在看的感觉多入口打包,还是 import 的文件要比 require 的文件大,但是核心问题在于测试案例的业务代码量比较少,所以看起来感觉 import 要比 require 大,当我们的业务代码量达到实际标准的时候,区别就看出来了


总结


import: 打包的内容是给到一个路径,通过该路径来访问对应的内容


require: 把当前访问资源的内容,打包到当前的文件内


到这里就可以解释为什么 vue 官方和网上的文章说推荐 import 而不推荐 require,因为每一个使用 require 的文件会把当前 require 的内容打包到当前文件内,所以导致了文件的过大,使用 import,抛出来的是一个索引,所以不会导致重复内容的打包,就不会出现包大的情况


当然这也不是绝对的,就好像上述案例那种少量的业务代码,使用 import 的代码量其实要比 require 大,所以不建议大家直接去确定某一种方式是最好的,某一种方式就是不行的,依场景选择方法


尾声


这篇文章就是一个简单的平时技术方面基础研究的简介,不是特别高深的东西,还希望对大家有所帮助,如果有覆盖面不够,或者场景不全面的情况,还希望大家提出,我在继续补充


这种类型的文章不是我擅长的方向,还是喜欢研究一些新的东西,欢迎大家指教:


链接:https://juejin.cn/post/6987219589612601357

收起阅读 »

小程序页面返回传值四种解决方案总结

使用场景 小程序从A页面跳转到B页面,在B页面选择一个值后返回到A页面,在A页面使用在B页面选中的值。例如:在购买订单页面跳转到地址列表,选择完地址以后回退到订单页面,订单页面的配送地址需要同步更新。 解决方案 常见的比容要容易解决的方案是使用小程序的全局存储...
继续阅读 »

使用场景


小程序从A页面跳转到B页面,在B页面选择一个值后返回到A页面,在A页面使用在B页面选中的值。例如:在购买订单页面跳转到地址列表,选择完地址以后回退到订单页面,订单页面的配送地址需要同步更新。


解决方案


常见的比容要容易解决的方案是使用小程序的全局存储globalData、本地缓存storage、获取小程序的页面栈,调用上一个Page的setData方法、以及利用wx.navigateTo的events属性监听被打开页面发送到当前页面的数据。下面给大家简单对比下四种方法的优缺点:


1、使用globalData实现


//page A
const app = getApp() //获取App.js实例
onShow() {//生命周期函数--监听页面显示
if (app.globalData.backData) {
this.setData({ //将B页面更新完的值渲染到页面上
backData: app.globalData.backData
},()=>{
delete app.globalData.backData //删除数据 避免onShow重复渲染
})
}
}
//page B
const app = getApp() //获取App.js实例
changeBackData(){
app.globalData.backData = '我被修改了'
wx.navigateBack()
}

2、使用本地缓存Storage实现


//page A
onShow: function () {
let backData = wx.getStorageSync('backData')
if(backData){
this.setData({
backData
},()=>{
wx.removeStorageSync('backData')
})
}
},
//page B
changeBackData(){
wx.setStorageSync('backData', '我被修改了')
wx.navigateBack()
},

3、使用小程序的Page页面栈实现


使小程序的页面栈,比其他两种方式会更方便一点而且渲染的会更快一些,不需要等回退到A页面上再把数据渲染出来,在B页面上的直接就会更新A页面上的值,回退到A页面的时候,值已经被更新了。globalData和Storage实现的原理都是在B页面上修改完值以后,回退到A页面,触发onShow生命周期函数,来更新页面渲染。


//page B
changeBackData(){
const pages = getCurrentPages();
const beforePage = pages[pages.length - 2]
beforePage.setData({ //会直接更新A页面的数据,A页面不需要其他操作
backData: "我被修改了"
})

4、使用wx.navigateTo API的events实现


wx.navigateTo的events的实现原理是利用设计模式的发布订阅模式实现的,有兴趣的同学可以自己动手实现一个简单的,也可以实现相同的效果。


//page A
goPageB() {
wx.navigateTo({
url: 'B',
events: {
getBackData: res => { //在events里面添加监听事件
this.setData({
backData: res.backData
})
},
},
})
},
//page B
changeBackData(){
const eventChannel = this.getOpenerEventChannel()
eventChannel.emit('getBackData', {
backData: '我被修改了'
});
wx.navigateBack()
}

总结


1和2两种方法在页面渲染效果上比后面两种稍微慢一点,3和4两种方法在B页面回退到A页面之前已经触发了更新,而1和2两种方法是等返回到A页面之后,在A页面才触发更新。并且1和2两种方式,要考虑到A页面更新完以后要删除globalData和Storage的数据,避免onShow方法里面重复触发setData更新页面,所以个人更推荐大家使用后面的3和4两种方式。


链接:https://juejin.cn/post/6986556857703727117

收起阅读 »

腾讯面试官:兄弟,你说你会Webpack,那说说他的原理?

原理图解 1、首先肯定是要先解析入口文件entry,将其转为AST(抽象语法书),使用@babel/parser 2、然后使用@babel/traverse去找出入口文件所有依赖模块 3、然后使用@babel/core+@babel/preset-env将入...
继续阅读 »

image.png


原理图解



  • 1、首先肯定是要先解析入口文件entry,将其转为AST(抽象语法书),使用@babel/parser

  • 2、然后使用@babel/traverse去找出入口文件所有依赖模块

  • 3、然后使用@babel/core+@babel/preset-env将入口文件的AST转为Code

  • 4、将2中找到的入口文件的依赖模块,进行遍历递归,重复执行1,2,3

  • 5。重写require函数,并与4中生成的递归关系图一起,输出到bundle


截屏2021-07-21 上午7.39.26.png


代码实现


webpack具体实现原理是很复杂的,这里只是简单实现一下,让大家粗略了解一下,webpack是怎么运作的。在代码实现过程中,大家可以自己console.log一下,看看ast,dependcies,code这些具体长什么样,我这里就不展示了,自己去看会比较有成就感,嘿嘿!!


image.png


目录


截屏2021-07-21 上午7.47.33.png


config.js


这个文件中模拟webpack的配置


const path = require('path')
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'main.js'
}
}

入口文件


src/index.js是入口文件


// src/index
import { age } from './aa.js'
import { name } from './hh.js'

console.log(`${name}今年${age}岁了`)

// src/aa.js
export const age = 18

// src/hh.js
console.log('我来了')
export const name = '林三心'


1. 定义Compiler类


// index.js
class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {}
// 重写 require函数,输出bundle
generate() {}
}

2. 解析入口文件,获取 AST


我们这里使用@babel/parser,这是babel7的工具,来帮助我们分析内部的语法,包括 es6,返回一个 AST 抽象语法树


const fs = require('fs')
const parser = require('@babel/parser')
const options = require('./webpack.config')

const Parser = {
getAst: path => {
// 读取入口文件
const content = fs.readFileSync(path, 'utf-8')
// 将文件内容转为AST抽象语法树
return parser.parse(content, {
sourceType: 'module'
})
}
}

class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {
const ast = Parser.getAst(this.entry)
}
// 重写 require函数,输出bundle
generate() {}
}

new Compiler(options).run()

3. 找出所有依赖模块


Babel 提供了@babel/traverse(遍历)方法维护这 AST 树的整体状态,我们这里使用它来帮我们找出依赖模块


const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default

const Parser = {
getAst: path => {
// 读取入口文件
const content = fs.readFileSync(path, 'utf-8')
// 将文件内容转为AST抽象语法树
return parser.parse(content, {
sourceType: 'module'
})
},
getDependecies: (ast, filename) => {
const dependecies = {}
// 遍历所有的 import 模块,存入dependecies
traverse(ast, {
// 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
ImportDeclaration({ node }) {
const dirname = path.dirname(filename)
// 保存依赖模块路径,之后生成依赖关系图需要用到
const filepath = './' + path.join(dirname, node.source.value)
dependecies[node.source.value] = filepath
}
})
return dependecies
}
}

class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {
const { getAst, getDependecies } = Parser
const ast = getAst(this.entry)
const dependecies = getDependecies(ast, this.entry)
}
// 重写 require函数,输出bundle
generate() {}
}

new Compiler(options).run()

4. AST 转换为 code


AST 语法树转换为浏览器可执行代码,我们这里使用@babel/core 和 @babel/preset-env


const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')

const Parser = {
getAst: path => {
// 读取入口文件
const content = fs.readFileSync(path, 'utf-8')
// 将文件内容转为AST抽象语法树
return parser.parse(content, {
sourceType: 'module'
})
},
getDependecies: (ast, filename) => {
const dependecies = {}
// 遍历所有的 import 模块,存入dependecies
traverse(ast, {
// 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
ImportDeclaration({ node }) {
const dirname = path.dirname(filename)
// 保存依赖模块路径,之后生成依赖关系图需要用到
const filepath = './' + path.join(dirname, node.source.value)
dependecies[node.source.value] = filepath
}
})
return dependecies
},
getCode: ast => {
// AST转换为code
const { code } = transformFromAst(ast, null, {
presets: ['@babel/preset-env']
})
return code
}
}

class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {
const { getAst, getDependecies, getCode } = Parser
const ast = getAst(this.entry)
const dependecies = getDependecies(ast, this.entry)
const code = getCode(ast)
}
// 重写 require函数,输出bundle
generate() {}
}

new Compiler(options).run()

5. 递归解析所有依赖项,生成依赖关系图


const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')

const Parser = {
getAst: path => {
// 读取入口文件
const content = fs.readFileSync(path, 'utf-8')
// 将文件内容转为AST抽象语法树
return parser.parse(content, {
sourceType: 'module'
})
},
getDependecies: (ast, filename) => {
const dependecies = {}
// 遍历所有的 import 模块,存入dependecies
traverse(ast, {
// 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
ImportDeclaration({ node }) {
const dirname = path.dirname(filename)
// 保存依赖模块路径,之后生成依赖关系图需要用到
const filepath = './' + path.join(dirname, node.source.value)
dependecies[node.source.value] = filepath
}
})
return dependecies
},
getCode: ast => {
// AST转换为code
const { code } = transformFromAst(ast, null, {
presets: ['@babel/preset-env']
})
return code
}
}

class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {
// 解析入口文件
const info = this.build(this.entry)
this.modules.push(info)
this.modules.forEach(({ dependecies }) => {
// 判断有依赖对象,递归解析所有依赖项
if (dependecies) {
for (const dependency in dependecies) {
this.modules.push(this.build(dependecies[dependency]))
}
}
})
// 生成依赖关系图
const dependencyGraph = this.modules.reduce(
(graph, item) => ({
...graph,
// 使用文件路径作为每个模块的唯一标识符,保存对应模块的依赖对象和文件内容
[item.filename]: {
dependecies: item.dependecies,
code: item.code
}
}),
{}
)
}
build(filename) {
const { getAst, getDependecies, getCode } = Parser
const ast = getAst(filename)
const dependecies = getDependecies(ast, filename)
const code = getCode(ast)
return {
// 文件路径,可以作为每个模块的唯一标识符
filename,
// 依赖对象,保存着依赖模块路径
dependecies,
// 文件内容
code
}
}
// 重写 require函数,输出bundle
generate() {}
}

new Compiler(options).run()

6. 重写 require 函数,输出 bundle


const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')

const Parser = {
getAst: path => {
// 读取入口文件
const content = fs.readFileSync(path, 'utf-8')
// 将文件内容转为AST抽象语法树
return parser.parse(content, {
sourceType: 'module'
})
},
getDependecies: (ast, filename) => {
const dependecies = {}
// 遍历所有的 import 模块,存入dependecies
traverse(ast, {
// 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
ImportDeclaration({ node }) {
const dirname = path.dirname(filename)
// 保存依赖模块路径,之后生成依赖关系图需要用到
const filepath = './' + path.join(dirname, node.source.value)
dependecies[node.source.value] = filepath
}
})
return dependecies
},
getCode: ast => {
// AST转换为code
const { code } = transformFromAst(ast, null, {
presets: ['@babel/preset-env']
})
return code
}
}

class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {
// 解析入口文件
const info = this.build(this.entry)
this.modules.push(info)
this.modules.forEach(({ dependecies }) => {
// 判断有依赖对象,递归解析所有依赖项
if (dependecies) {
for (const dependency in dependecies) {
this.modules.push(this.build(dependecies[dependency]))
}
}
})
// 生成依赖关系图
const dependencyGraph = this.modules.reduce(
(graph, item) => ({
...graph,
// 使用文件路径作为每个模块的唯一标识符,保存对应模块的依赖对象和文件内容
[item.filename]: {
dependecies: item.dependecies,
code: item.code
}
}),
{}
)
this.generate(dependencyGraph)
}
build(filename) {
const { getAst, getDependecies, getCode } = Parser
const ast = getAst(filename)
const dependecies = getDependecies(ast, filename)
const code = getCode(ast)
return {
// 文件路径,可以作为每个模块的唯一标识符
filename,
// 依赖对象,保存着依赖模块路径
dependecies,
// 文件内容
code
}
}
// 重写 require函数 (浏览器不能识别commonjs语法),输出bundle
generate(code) {
// 输出文件路径
const filePath = path.join(this.output.path, this.output.filename)
// 懵逼了吗? 没事,下一节我们捋一捋
const bundle = `(function(graph){
function require(module){
function localRequire(relativePath){
return require(graph[module].dependecies[relativePath])
}
var exports = {};
(function(require,exports,code){
eval(code)
})(localRequire,exports,graph[module].code);
return exports;
}
require('${this.entry}')
})(${JSON.stringify(code)})`

// 把文件内容写入到文件系统
fs.writeFileSync(filePath, bundle, 'utf-8')
}
}

new Compiler(options).run()

7. 看看main里的代码


实现了上面的代码,也就实现了把打包后的代码写到main.js文件里,咱们来看看那main.js文件里的代码吧:


(function(graph){
function require(module){
function localRequire(relativePath){
return require(graph[module].dependecies[relativePath])
}
var exports = {};
(function(require,exports,code){
eval(code)
})(localRequire,exports,graph[module].code);
return exports;
}
require('./src/index.js')
})({
"./src/index.js": {
"dependecies": {
"./aa.js": "./src\\aa.js",
"./hh.js": "./src\\hh.js"
},
"code": "\"use strict\";\n\nvar _aa = require(\"./aa.js\");\n\nvar _hh = require(\"./hh.js\");\n\nconsole.log(\"\".concat(_hh.name, \"\\u4ECA\\u5E74\").concat(_aa.age, \"\\u5C81\\u4E86\"));"
},
"./src\\aa.js": {
"dependecies": {},
"code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.age = void 0;\nvar age = 18;\nexports.age = age;"
},
"./src\\hh.js": {
"dependecies": {},
"code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.name = void 0;\nconsole.log('我来了');\nvar name = '林三心';\nexports.name = name;"
}
})

大家可以执行一下main.js的代码,输出结果是:


我来了
林三心今年18岁了

image.png


结语


webpack具体实现原理是很复杂的,这里只是简单实现一下,让大家粗略了解一下,webpack是怎么运作的。在代码实现过程中,大家可以自己console.log一下,看看ast,dependcies,code这些具体长什么样,我这里就不展示了,自己去看会比较有成就感,嘿嘿!!



链接:https://juejin.cn/post/6987180860852142093

收起阅读 »

Electron上手指南

前置 配置代理,解决网络问题: npm set electron_mirror https://npm.taobao.org/mirrors/electron/ # electron 二进制包镜像 npm set ELECTRON_MIRROR https:/...
继续阅读 »

前置


配置代理,解决网络问题:


npm set electron_mirror https://npm.taobao.org/mirrors/electron/ # electron 二进制包镜像
npm set ELECTRON_MIRROR https://cdn.npm.taobao.org/dist/electron/ # electron 二进制包镜像

安装:


npm install electron --save-dev

使用


和开发 Web 应用非常类似。


index.html


<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
  <meta http-equiv="X-Content-Security-Policy" content="default-src 'self'; script-src 'self'">
  <title>Hello World!</title>
</head>
<body>
  <h1>Hello World!</h1>
  We are using Node.js <span id="node-version"></span>,
  Chromium <span id="chrome-version"></span>,
  and Electron <span id="electron-version"></span>.
</body>
</html>

main.js


const { app, BrowserWindow } = require('electron')

function createWindow() {
const win = new BrowserWindow({
  width: 800,
  height: 600
})

win.loadFile('index.html')
}

app.whenReady().then(() => {
createWindow()
})

甚至可以直接加载一个现成的线上应用:


const { app, BrowserWindow } = require('electron')

function createWindow() {
const win = new BrowserWindow({
  width: 800,
  height: 600
})

win.loadURL('https://www.baidu.com/')
}

app.whenReady().then(() => {
createWindow()
})

package.json


{
"name": "electron-demo",
"version": "1.0.0",
"description": "",
"main": "main.js",
"scripts": {
  "start": "electron ."
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
  "electron": "^13.1.7"
}
}

执行:


npm start

打包构建


npm install --save-dev @electron-forge/cli
npx electron-forge import

npm run make

流程模型


Electron 与 Chrome 类似采用多进程架构。作为 Electron 应用开发者,可以控制着两种类型的进程:主进程和渲染器。


主进程


每个 Electron 应用都有一个单一的主进程,作为应用程序的入口点。 主进程在 Node.js 环境中运行,这意味着它具有 require 模块和使用所有 Node.js API 的能力。


窗口管理


主进程的主要目的是使用 BrowserWindow 模块创建和管理应用程序窗口。
BrowserWindow 类的每个实例创建一个应用程序窗口,且在单独的渲染器进程中加载一个网页。 可从主进程用 window 的 webContent 对象与网页内容进行交互。


const { BrowserWindow } = require('electron')

const win = new BrowserWindow({ width: 800, height: 1500 })
win.loadURL('https://github.com')

const contents = win.webContents
console.log(contents)
复制代码

应用程序生命周期


主进程还能通过 Electron 的app 模块来控制应用程序的生命周期。 该模块提供了一整套的事件和方法,可以添加自定义的应用程序行为 ( 例如:以编程方式退出您的应用程序、修改程序坞或显示关于面板 ) 。


// 当 macOS 无窗口打开时退出应用
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})

渲染器进程


每个 Electron 应用都会为每个打开的 BrowserWindow ( 与每个网页嵌入 ) 生成一个单独的渲染器进程。


预加载脚本


预加载(preload)脚本包含了那些执行于渲染器进程中,且先于网页内容开始加载的代码 。


预加载脚本可以在 BrowserWindow 构造方法中的 webPreferences 选项里被附加到主进程。


const { BrowserWindow } = require('electron')
//...
const win = new BrowserWindow({
preload: 'path/to/preload.js'
})
//...

由于预加载脚本与渲染器共享同一个全局 Window 接口,并且可以访问 Node.js API,因此它通过在 window 全局中暴露任意的网络内容来增强渲染器。



链接:https://juejin.cn/post/6987310547133792286

收起阅读 »

你真的了解package.json吗?来看看吧,这可能是最全的package解析

1. 概述 从我们接触前端开始,每个项目的根目录下一般都会有一个package.json文件,这个文件定义了当前项目所需要的各种模块,以及项目的配置信息(比如名称、版本、许可证等)。 当运行npm install命令的时候,会根据package.json文件中...
继续阅读 »

1. 概述


从我们接触前端开始,每个项目的根目录下一般都会有一个package.json文件,这个文件定义了当前项目所需要的各种模块,以及项目的配置信息(比如名称、版本、许可证等)。


当运行npm install命令的时候,会根据package.json文件中的配置自动下载所需的模块,也就是配置项目所需的运行和开发环境。


比如下面这个文件,只存在简单的项目名称和版本号。


{
"name" : "yindong",
"version" : "1.0.0",
}

package.json文件是一个JSON对象,这从他的后缀名.json就可以看出来,该对象的每一个成员就是当前项目的一项设置。比如name就是项目名称,version是版本号。


当然很多人其实并不关心package.json的配置,他们应用的更多的是dependencies或devDependencies配置。


下面是一个更完整的package.json文件,详细解释一下每个字段的真实含义。


{
"name": "yindong",
"version":"0.0.1",
"description": "antd-theme",
"keywords":["node.js","antd", "theme"],
"homepage": "https://zhiqianduan.com",
"bugs":{"url":"http://path/to/bug","email":"yindong@xxxx.com"},
"license": "ISC",
"author": "yindong",
"contributors":[{"name":"yindong","email":"yindong@xxxx.com"}],
"files": "",
"main": "./dist/default.js",
"bin": "",
"man": "",
"directories": "",
"repository": {
"type": "git",
"url": "https://path/to/url"
},
"scripts": {
"start": "webpack serve --config webpack.config.dev.js --progress"
},
"config": { "port" : "8080" },
"dependencies": {},
"devDependencies": {
"@babel/core": "^7.14.3",
"@babel/preset-env": "^7.14.4",
"@babel/preset-react": "^7.13.13",
"babel-loader": "^8.2.2",
"babel-plugin-import": "^1.13.3",
"glob": "^7.1.7",
"less": "^3.9.0",
"less-loader": "^9.0.0",
"style-loader": "^2.0.0",
"webpack": "^5.38.1",
"webpack-cli": "^4.7.0",
"webpack-dev-server": "^3.11.2"
},
"peerDependencies": {
"tea": "2.x"
},
"bundledDependencies": [
"renderized", "super-streams"
],
"engines": {"node": "0.10.x"},
"os" : [ "win32", "darwin", "linux" ],
"cpu" : [ "x64", "ia32" ],
"private": false,
"publishConfig": {}
}


2. name字段


package.json文件中最重要的就是nameversion字段,这两项是必填的。名称和版本一起构成一个标识符,该标识符被认为是完全唯一的。对包的更改应该与对版本的更改一起进行。


name必须小于等于214个字符,不能以._开头,不能有大写字母,因为名称最终成为URL的一部分因此不能包含任何非URL安全字符。
npm官方建议我们不要使用与核心节点模块相同的名称。不要在名称中加jsnode。如果需要可以使用engines来指定运行环境。


该名称会作为参数传递给require,因此它应该是简短的,但也需要具有合理的描述性。


3. version字段


version一般的格式是x.x.x, 并且需要遵循该规则。


package.json文件中最重要的就是nameversion字段,这两项是必填的。名称和版本一起构成一个标识符,该标识符被认为是完全唯一的。每次发布时version不能与已存在的一致。


4. description字段


description是一个字符串,用于编写描述信息。有助于人们在npm库中搜索的时候发现你的模块。


5. keywords字段


keywords是一个字符串组成的数组,有助于人们在npm库中搜索的时候发现你的模块。


6. homepage字段


homepage项目的主页地址。


7. bugs字段


bugs用于项目问题的反馈issue地址或者一个邮箱。


"bugs": { 
"url" : "https://github.com/owner/project/issues",
"email" : "project@hostname.com"
}

8. license字段


license是当前项目的协议,让用户知道他们有何权限来使用你的模块,以及使用该模块有哪些限制。


"license" : "BSD-3-Clause"

9. author字段 contributors字段


author是具体一个人,contributors表示一群人,他们都表示当前项目的共享者。同时每个人都是一个对象。具有name字段和可选的urlemail字段。


"author": {
"name" : "yindong",
"email" : "yindong@xx.com",
"url" : "https://zhiqianduan.com/"
}

也可以写成一个字符串


"author": "yindong yindong@xx.com (https://zhiqianduan.com/)"

10. files字段


files属性的值是一个数组,内容是模块下文件名或者文件夹名,如果是文件夹名,则文件夹下所有的文件也会被包含进来(除非文件被另一些配置排除了)


可以在模块根目录下创建一个.npmignore文件,写在这个文件里边的文件即便被写在files属性里边也会被排除在外,这个文件的写法与.gitignore类似。


11. main字段


main字段指定了加载的入口文件,require导入的时候就会加载这个文件。这个字段的默认值是模块根目录下面的index.js


12. bin字段


bin项用来指定每个内部命令对应的可执行文件的位置。如果你编写的是一个node工具的时候一定会用到bin字段。


当我们编写一个cli工具的时候,需要指定工具的运行命令,比如常用的webpack模块,他的运行命令就是webpack


"bin": {
"webpack": "bin/index.js",
}

当我们执行webpack命令的时候就会执行bin/index.js文件中的代码。


在模块以依赖的方式被安装,如果存在bin选项。在node_modules/.bin/生成对应的文件,
Npm会寻找这个文件,在node_modules/.bin/目录下建立符号链接。由于node_modules/.bin/目录会在运行时加入系统的PATH变量,因此在运行npm时,就可以不带路径,直接通过命令来调用这些脚本。


所有node_modules/.bin/目录下的命令,都可以用npm run [命令]的格式运行。在命令行下,键入npm run,然后按tab键,就会显示所有可以使用的命令。


13. man字段


man用来指定当前模块的man文档的位置。


"man" :[ "./doc/calc.1" ]

14. directories字段


directories制定一些方法来描述模块的结构, 用于告诉用户每个目录在什么位置。


15. repository字段


指定一个代码存放地址,对想要为你的项目贡献代码的人有帮助


"repository" : {
"type" : "git",
"url" : "https://github.com/npm/npm.git"
}

16. scripts字段


scripts指定了运行脚本命令的npm命令行缩写,比如start指定了运行npm run start时,所要执行的命令。


"scripts": {
"start": "node ./start.js"
}

使用scripts字段可以快速的执行shell命令,可以理解为alias


scripts可以直接使用node_modules中安装的模块,这区别于直接运行需要使用npx命令。


"scripts": {
"build": "webpack"
}

// npm run build
// npx webpack

17. config字段


config字段用于添加命令行的环境变量。


{
"name" : "yindong",
"config" : { "port" : "8080" },
"scripts" : { "start" : "node server.js" }
}

然后,在server.js脚本就可以引用config字段的值。


console.log(process.env.npm_package_config_port); // 8080

用户可以通过npm config set来修改这个值。


npm config set yindong:port 8000

18. dependencies字段, devDependencies字段


dependencies字段指定了项目运行所依赖的模块,devDependencies指定项目开发所需要的模块。


它们的值都是一个对象。该对象的各个成员,分别由模块名和对应的版本要求组成,表示依赖的模块及其版本范围。


当安装依赖的时候使用--save参数表示将该模块写入dependencies属性,--save-dev表示将该模块写入devDependencies属性。


"devDependencies": {
"webpack": "^5.38.1",
}

对象的每一项通过一个键值对表示,前面是模块名称,后面是对应模块的版本号。版本号遵循“大版本.次要版本.小版本”的格式规定。



版本说明



固定版本: 比如5.38.1,安装时只安装指定版本。
波浪号: 比如~5.38.1, 表示安装5.38.x的最新版本(不低于5.38.1),但是不安装5.39.x,也就是说安装时不改变大版本号和次要版本号。
插入号: 比如ˆ5.38.1, ,表示安装5.x.x的最新版本(不低于5.38.1),但是不安装6.x.x,也就是说安装时不改变大版本号。需要注意的是,如果大版本号为0,则插入号的行为与波浪号相同,这是因为此时处于开发阶段,即使是次要版本号变动,也可能带来程序的不兼容。
latest: 安装最新版本。




19. peerDependencies字段


当我们开发一个模块的时候,如果当前模块与所依赖的模块同时依赖一个第三方模块,并且依赖的是两个不兼容的版本时就会出现问题。


比如,你的项目依赖A模块和B模块的1.0版,而A模块本身又依赖B模块的2.0版。


大多数情况下,这不构成问题,B模块的两个版本可以并存,同时运行。但是,有一种情况,会出现问题,就是这种依赖关系将暴露给用户。


最典型的场景就是插件,比如A模块是B模块的插件。用户安装的B模块是1.0版本,但是A插件只能和2.0版本的B模块一起使用。这时,用户要是将1.0版本的B的实例传给A,就会出现问题。因此,需要一种机制,在模板安装的时候提醒用户,如果A和B一起安装,那么B必须是2.0模块。


peerDependencies字段,就是用来供插件指定其所需要的主工具的版本。可以通过peerDependencies字段来限制,使用myless模块必须依赖less模块的3.9.x版本.


{
"name": "myless",
"peerDependencies": {
"less": "3.9.x"
}
}

注意,从npm 3.0版开始,peerDependencies不再会默认安装了。就是初始化的时候不会默认带出。


20. bundledDependencies字段


bundledDependencies指定发布的时候会被一起打包的模块.


21. optionalDependencies字段


如果一个依赖模块可以被使用, 同时你也希望在该模块找不到或无法获取时npm继续运行,你可以把这个模块依赖放到optionalDependencies配置中。这个配置的写法和dependencies的写法一样,不同的是这里边写的模块安装失败不会导致npm install失败。


22. engines字段


engines字段指明了该模块运行的平台,比如Node或者npm的某个版本或者浏览器。


{ "engines" : { "node" : ">=0.10.3 <0.12", "npm" : "~1.0.20" } }

23. os字段


可以指定你的模块只能在哪个操作系统上运行


"os" : [ "darwin", "linux", "win32" ]

24. cpu字段


限制模块只能在某种架构的cpu下运行


"cpu" : [ "x64", "ia32" ]

25. private字段


如果这个属性被设置为truenpm将拒绝发布它,这是为了防止一个私有模块被无意间发布出去。


"private": true

26. publishConfig字段


这个配置是会在模块发布时生效,用于设置发布用到的一些值的集合。如果你不想模块被默认标记为最新的,或者默认发布到公共仓库,可以在这里配置tag或仓库地址。


通常publishConfig会配合private来使用,如果你只想让模块被发布到一个特定的npm仓库,如一个内部的仓库。


"private": true,
"publishConfig": {
"tag": "1.0.0",
"registry": "https://registry.npmjs.org/",
"access": "public"
}

27. preferGlobal字段


preferGlobal的值是布尔值,表示当用户不将该模块安装为全局模块时(即不用–global参数),要不要显示警告,表示该模块的本意就是安装为全局模块。


"preferGlobal": false

28. browser字段


browser指定该模板供浏览器使用的版本。Browserify这样的浏览器打包工具,通过它就知道该打包那个文件。


"browser": {
"tipso": "./node_modules/tipso/src/tipso.js"
},


链接:https://juejin.cn/post/6987179395714646024


收起阅读 »

教你使用whistle工具捉小程序包

介绍 我们说起捉包工具,可能大家比较熟悉的Fiddler工具,它是通过断点修改请求响应的方式,平时使用测试捉包也是很方便的,今天主角介绍另一个捉包工具whistle,这个工具比较轻,无需安装客户端只需通过终端node即可跑起捉取数据 whistle用的是类似...
继续阅读 »

介绍



  • 我们说起捉包工具,可能大家比较熟悉的Fiddler工具,它是通过断点修改请求响应的方式,平时使用测试捉包也是很方便的,今天主角介绍另一个捉包工具whistle,这个工具比较轻,无需安装客户端只需通过终端node即可跑起捉取数据

  • whistle用的是类似配置系统hosts的方式,一切操作都可以通过配置实现,支持域名、路径、正则表达式、通配符、通配路径等多种匹配方式,且可以通过Node模块扩展功能,更多内容介绍请查看官方文档


安装



  1. 安装node 文档地址


$ node -v  // 查看node版本号
v12.0.0 //(建议12版本以上,不然手机捉包会有点问题)


  1. 安装whistle


npm install -g whistle
或者直接指定镜像安装:
npm install whistle -g --registry=https://registry.npm.taobao.org


  1. 使用whistle

    • 启动whistle: (以下指令,window系统不需要$符号)


    $ w2 start


    • 重启whsitle:


    $ w2 restart


    • 停止whistle:3


    $ w2 stop


    • 调试模式启动whistle(主要用于查看whistle的异常及插件开发):


    $ w2 run

    w2 start启动完即可查看本地ip,把ip拷贝到浏览器即可


image.png
在浏览器显示效果
image.png
4. 配置代理 更多配置请查看官方文档

抓取 Https 请求需要配置



  • 电脑上安装根证书(现在安装证书也没那么麻烦,下载完直接点安装一步下一步就行)


   下载根证书:Whistle 监控界面 -> HTTPS -> Download RootCA

   下载完根证书后点击rootCA.crt文件,弹出根证书安装对话框。

   Windows 安装方法:

image.png



  • 移动端需要在设置中配置当前Wi-Fi的代理,以 harmonyOS 为例:


image.png



  • 手机上安装根证书


   iOS:

   Safari 地址栏输入 rootca.pro,按提示安装证书。  

   iOS 10.3 之后需要手动信任自定义根证书,设置路径:设置 --> 通用 --> 关于本机 --> 证书信任设置


   Android:

   用浏览器扫描 whistle 监控界面 HTTPS 的二维码下载安装,或者浏览器地址栏 rootca.pro 按提示安装。

   ca 证书安装完后可以在 Android 手机的“设置” -》“安全和隐私” -》“受信任的凭证” 里查看手机上有没有安装成功。

   部分浏览器不会自动识别 ca 证书,可以通过 Android Chrome 来完成安装。



  • 电脑选择配置勾选捉取https:


image.png



  • 最后捉取得效果图:


image.png


总结



  • 其实使用并不难,按上面安装步骤来即可,这个捉包方法适用于捉取小程序体验版或测试版,不支持小程序正式版本,如果打开小程序正式版本,整个小程序请求接口都会异常无法请求;如果你的体验版小程序无法捉取,请尝试打开调试工具;(本文仅限学习,方便测试使用,还有更多好玩的东西,请移步到官方文档

链接:https://juejin.cn/post/6986888917622456351

收起阅读 »

面试了十几个高级前端,竟然连(扁平数据结构转Tree)都写不出来

前言 招聘季节一般都在金三银四,或者金九银十。最近在这五六月份,陆陆续续面试了十几个高级前端。有一套考察算法的小题目。后台返回一个扁平的数据结构,转成树。 我们看下题目:打平的数据内容如下: let arr = [ {id: 1, name: '部门1...
继续阅读 »

前言


招聘季节一般都在金三银四,或者金九银十。最近在这五六月份,陆陆续续面试了十几个高级前端。有一套考察算法的小题目。后台返回一个扁平的数据结构,转成树。


我们看下题目:打平的数据内容如下:


let arr = [
{id: 1, name: '部门1', pid: 0},
{id: 2, name: '部门2', pid: 1},
{id: 3, name: '部门3', pid: 1},
{id: 4, name: '部门4', pid: 3},
{id: 5, name: '部门5', pid: 4},
]

输出结果


[
{
"id": 1,
"name": "部门1",
"pid": 0,
"children": [
{
"id": 2,
"name": "部门2",
"pid": 1,
"children": []
},
{
"id": 3,
"name": "部门3",
"pid": 1,
"children": [
// 结果 ,,,
]
}
]
}
]

我们的要求很简单,可以先不用考虑性能问题。实现功能即可,回头分析了面试的情况,结果使我大吃一惊。


10%的人没思路,没碰到过这种结构


60%的人说用过递归,有思路,给他个笔记本,但就是写不出来


20%的人在引导下,磕磕绊绊能写出来


剩下10%的人能写出来,但性能不是最佳


感觉不是在招聘季节遇到一个合适的人真的很难。


接下来,我们用几种方法来实现这个小算法


什么是好算法,什么是坏算法


判断一个算法的好坏,一般从执行时间占用空间来看,执行时间越短,占用的内存空间越小,那么它就是好的算法。对应的,我们常常用时间复杂度代表执行时间,空间复杂度代表占用的内存空间。


时间复杂度



时间复杂度的计算并不是计算程序具体运行的时间,而是算法执行语句的次数。



随着n的不断增大,时间复杂度不断增大,算法花费时间越多。 常见的时间复杂度有



  • 常数阶O(1)

  • 对数阶O(log2 n)

  • 线性阶O(n)

  • 线性对数阶O(n log2 n)

  • 平方阶O(n^2)

  • 立方阶O(n^3)

  • k次方阶O(n^K)

  • 指数阶O(2^n)


计算方法



  1. 选取相对增长最高的项

  2. 最高项系数是都化为1

  3. 若是常数的话用O(1)表示


举个例子:如f(n)=3*n^4+3n+300 则 O(n)=n^4


通常我们计算时间复杂度都是计算最坏情况。计算时间复杂度的要注意的几个点



  • 如果算法的执行时间不随n增加增长,假如算法中有上千条语句,执行时间也不过是一个较大的常数。此类算法的时间复杂度是O(1)。 举例如下:代码执行100次,是一个常数,复杂度也是O(1)


    let x = 1;
while (x <100) {
x++;
}


  • 多个循环语句时候,算法的时间复杂度是由嵌套层数最多的循环语句中最内层语句的方法决定的。举例如下:在下面for循环当中,外层循环每执行一次内层循环要执行n次,执行次数是根据n所决定的,时间复杂度是O(n^2)


  for (i = 0; i < n; i++){
for (j = 0; j < n; j++) {
// ...code
}
}


  • 循环不仅与n有关,还与执行循环判断条件有关。举例如下:在代码中,如果arr[i]不等于1的话,时间复杂度是O(n)。如果arr[i]等于1的话,循环不执行,时间复杂度是O(0)


    for(var i = 0; i<n && arr[i] !=1; i++) {
// ...code
}

空间复杂度



空间复杂度是对一个算法在运行过程中临时占用存储空间的大小。



计算方法:



  1. 忽略常数,用O(1)表示

  2. 递归算法的空间复杂度=(递归深度n)*(每次递归所要的辅助空间)


计算空间复杂度的简单几点



  • 仅仅只复制单个变量,空间复杂度为O(1)。举例如下:空间复杂度为O(n) = O(1)。


   let a = 1;
let b = 2;
let c = 3;
console.log('输出a,b,c', a, b, c);


  • 递归实现,调用fun函数,每次都创建1个变量k。调用n次,空间复杂度O(n*1) = O(n)。


    function fun(n) {
let k = 10;
if (n == k) {
return n;
} else {
return fun(++n)
}
}

不考虑性能实现,递归遍历查找


主要思路是提供一个递getChildren的方法,该方法递归去查找子集。
就这样,不用考虑性能,无脑去查,大多数人只知道递归,就是写不出来。。。


/**
* 递归查找,获取children
*/
const getChildren = (data, result, pid) => {
for (const item of data) {
if (item.pid === pid) {
const newItem = {...item, children: []};
result.push(newItem);
getChildren(data, newItem.children, item.id);
}
}
}

/**
* 转换方法
*/
const arrayToTree = (data, pid) => {
const result = [];
getChildren(data, result, pid)
return result;
}

从上面的代码我们分析,该实现的时间复杂度为O(2^n)


不用递归,也能搞定


主要思路是先把数据转成Map去存储,之后遍历的同时借助对象的引用,直接从Map找对应的数据做存储


function arrayToTree(items) {
const result = []; // 存放结果集
const itemMap = {}; //

// 先转成map存储
for (const item of items) {
itemMap[item.id] = {...item, children: []}
}

for (const item of items) {
const id = item.id;
const pid = item.pid;
const treeItem = itemMap[id];
if (pid === 0) {
result.push(treeItem);
} else {
if (!itemMap[pid]) {
itemMap[pid] = {
children: [],
}
}
itemMap[pid].children.push(treeItem)
}

}
return result;
}

从上面的代码我们分析,有两次循环,该实现的时间复杂度为O(2n),需要一个Map把数据存储起来,空间复杂度O(n)


最优性能


主要思路也是先把数据转成Map去存储,之后遍历的同时借助对象的引用,直接从Map找对应的数据做存储。不同点在遍历的时候即做Map存储,有找对应关系。性能会更好。


function arrayToTree(items) {
const result = []; // 存放结果集
const itemMap = {}; //
for (const item of items) {
const id = item.id;
const pid = item.pid;

if (!itemMap[id]) {
itemMap[id] = {
children: [],
}
}

itemMap[id] = {
...item,
children: itemMap[id]['children']
}

const treeItem = itemMap[id];

if (pid === 0) {
result.push(treeItem);
} else {
if (!itemMap[pid]) {
itemMap[pid] = {
children: [],
}
}
itemMap[pid].children.push(treeItem)
}

}
return result;
}

从上面的代码我们分析,一次循环就搞定了,该实现的时间复杂度为O(n),需要一个Map把数据存储起来,空间复杂度O(n)


链接:https://juejin.cn/post/6983904373508145189

收起阅读 »

前端是不是又要回去操作真实dom年代?

写在开头 近期我有写两篇文章,一篇是:petite-vue源码解析和掘金编辑器的源码解析,发现里面用到了Svelte这个框架 加上最近React17,vite大家也在逐步的用在生产环境中,我于是有了今天的思考 看前端的技术演进 原生Javascript ...
继续阅读 »

写在开头



  • 近期我有写两篇文章,一篇是:petite-vue源码解析和掘金编辑器的源码解析,发现里面用到了Svelte这个框架

  • 加上最近React17,vite大家也在逐步的用在生产环境中,我于是有了今天的思考


看前端的技术演进



  • 原生Javascript - Jquery为代表的时代,例如,引入Jquery只要


<script src="cdn/jquery.min,js"></script>


  • 接着便又有了gulp webpack等构建工具出现,React和Vue也在这个时候开始火了起来,随即而来的是一大堆工程化的辅助工具,例如babel,还有提供整套服务的create-react-app等脚手架

  • 这也带来了问题,当然这个是npm的问题,每次启动项目前,都要安装大量的依赖,即便出现了yarn pnpm`等优化的依赖管理工具,但是这个问题根源不应该使用工具解决,而是问题本质是依赖本地化,代码和依赖需要工具帮助才能运行在浏览器中



总结就是:现有的开发模式,让项目太重,例如我要使用某个脚手架,我只想写一个helloworld演示下,结果它让我装500mb的依赖,不同的脚手架产物,配置不同,产物也不同



理想的开发模式




  • 1.不需要辅助的工具配置,我不需要webpack这类帮我打包的工具,模块化浏览器本身就支持,而且是一个规范。例如vite号称不打包,用的是浏览器本身支持的esm模块化,但是它没有解决依赖的问题,因为依赖问题本身是依赖的问题,而不是工具的问题




  • 2.不需要安装依赖,一切都可以import from remote,我觉得webpack5Module Federation设计,就考虑到了这一点,下面是官方的解释:




    • 多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。




    • 这通常被称作微前端,但并不仅限于此。







但是这可能并不是最佳实践,目前是有import from http,例如



import lodash from 'https://unpackage/lodash/es'


  • 这里又会有人问,那你不都是要发请求吗,都是要每次启动的时候去远程拉取,还不如在本地呢。import from http我想只是解决了一个点的问题,就是不用手动安装依赖到本地磁盘

  • 前段时间我写过,在浏览器中本地运行Node.js




这个技术叫WebContainers技术,感兴趣的可以去翻翻我公众号之前的文章




  • 等等,别急。这些仅仅开了个头,新的技术往往要探索才能实现价值最大化,我想此处应该可以彻底颠覆现有的开发模式,而且应该就在3-5年内。


将几个新的前端技术理念融合?



  • vite的不打包理念:直接使用浏览器支持的esm模块化

  • WebContainers技术:让浏览器直接运行node.js

  • import from remote,从一个个远程地址直接引入可以使用的依赖

  • 现在很火的webIDE:类似remix编辑器,直接全部可以在云端搞定

  • 浏览器的优化,天然有缓存支持


会发生什么变化?



  • 我们所有的一切开始,都直接启动一个浏览器即可

  • 浏览器中的webIDE,可以直接引入远程依赖,浏览器可以运行Node.js,使用的都是esm模块化,不需要打包工具,项目启动的时间和热更新时间都非常短,构建也是直接可以在浏览器中构建



这些看似解决了我们之前提出的大部分问题,回到今天的主题





回到主题



  • 前端会不会回到操作原生dom的时代?

  • 我觉得,有这个趋势,例如petite-vue,还有Svelte



因为之前写过petite-vue源码解析了,我们今天就讲讲Svelte



Svelte



Svelte 是一种全新的构建用户界面的方法。传统框架如 React 和 Vue 在浏览器中需要做大量的工作,而 Svelte 将这些工作放到构建应用程序的编译阶段来处理。




  • 与使用虚拟(virtual)DOM 差异对比不同。Svelte 编写的代码在应用程序的状态更改时就能像做外科手术一样更新 DOM






  • 上面是官方的介绍,我们看看知乎这篇文章https://zhuanlan.zhihu.com/p/97825481,感觉他写得很好,这里照搬一些过来吧直接




  • React和Vue都是基于runtime的框架。所谓基于runtime的框架就是框架本身的代码也会被打包到最终的bundle.js并被发送到用户浏览器。




  • 当用户在你的页面进行各种操作改变组件的状态时,框架的runtime会根据新的组件状态(state)计算(diff)出哪些DOM节点需要被更新





可是,这些被打包进去的框架,实在太大了。



(今天还在跟同事说,前年写的登录站点,纯原生手工打造,性能无敌)



  • 100kb对于一个弱网环境来说,很要命,我们看看svelte减少了多少体积:



科普



  • 虚拟dom并没有加快用户操作浏览器响应的速度,只是说,方便用于数据驱动视图,更便于管理而已,并且在一定程度上,更慢。真正最快的永远是:


currentDom.innerHtml = '前端巅峰';


所以Svelte并不是说多好,而是它的这种理念,可能未来会越来越成为主流



React17的改变



  • 大家应该都知道,现有的浏览器都是无法直接解译JSX的,所以大多数React用户都需要使用Babel或者TypeScript之类的编译器来将JSX转换为浏览器能够理解的JavaScript语言。许多预配置的工具箱(如:Create React App 或者Next.js)内部也有JSX的转换。

  • React 17.0,尽管React团队想对JSX的转换进行改进,但React团队不想打破现有的配置。这就是为什么React团队与Babel合作,为想要升级的开发者提供了一个全新的JSX转换的重写版本。

  • 通过全新的转换,你可以单独使用JSX而无需引入React.



我猜想,或许React团队有意将jsx语法推动到成为es标准语法中去,剥离开来希望会大大提升。



重点



  • 说了这么多,大家可能没理解到重点,那就是:大家都在想着减轻自身的负重,把丢下来的东西标准化,交给浏览器处理,这也是在为未来的只需要打开一个浏览器,就可以完成所有的事情做铺垫

  • 而我,相信这一天应该不远了,据我所知已经有不少顶尖的团队在研发这种产品



链接:https://juejin.cn/post/6986613468975595556

收起阅读 »

面试官:你知道git xx 和git xx的区别吗?看完这篇Git指南后直呼:内行!

Git
前言 作为一名工程师,既然想要加入一个团队,并肩作战地协同开发项目,就必不可少要学会Git基本操作。面试过程中,面试官不止是考察1-3年的工程师,高级岗位也同样会考察团队协作的能力。相信小伙伴们经常会在面试中被问到以下问题吧,可以帮助你测试一下你的Git基础牢...
继续阅读 »

前言


作为一名工程师,既然想要加入一个团队,并肩作战地协同开发项目,就必不可少要学会Git基本操作。面试过程中,面试官不止是考察1-3年的工程师,高级岗位也同样会考察团队协作的能力。相信小伙伴们经常会在面试中被问到以下问题吧,可以帮助你测试一下你的Git基础牢不牢固。



  • 代码开发到一半,需要紧急切换分支修复线上问题,该怎么办?

  • 代码合并有几种模式?分别有什么优缺点?

  • git fetchgit pull有什么区别,有合并操作吗?

  • git mergegit rebase有什么区别,它们的应用场景有哪些?

  • git resetgit revert有什么区别,该如何选择,回滚后的<commit-id>还能找到吗?


如果你心中已有答案,那么可以选择跳过这篇文章啦,愉快地继续摸鱼~


如果你对这些概念还有些模糊,或者没有实际操作过,那么就需要好好阅读本篇文章啦!


接下来马上进入正文啦,本文分四个部分介绍,大家可以自由选择阅读。



  • Git的区域示例图,帮助大家理解Git的结构。

  • Git的基本使用场景,介绍一些常用git命令。

  • Git的进阶使用场景,介绍一些高频出现的面试题目以及应用场景。

  • 最后介绍Git的下载地址、基本配置和工具推荐。


Git的区域


画了一个简单的示意图,供大家参考


yuque_diagram.jpg



  • 远程仓库(Remote):在远程用于存放代码的服务器,远程仓库的内容能够被分布其他地方的本地仓库修改。

  • 本地仓库(Repository):在自己电脑上的仓库,平时我们用git commit 提交到暂存区,就会存入本地仓库。

  • 暂存区(Index):执行 git add 后,工作区的文件就会被移入暂存区,表示哪些文件准备被提交,当完成某个功能后需要提交代码,可以通过 git add 先提交到暂存区。

  • 工作区(Workspace):工作区,开发过程中,平时打代码的地方,看到是当前最新的修改内容。


Git的基本使用场景


以下命令远程主机名默认为origin,如果有其他远程主机,将origin替换为其他即可。


git fetch


# 获取远程仓库特定分支的更新
git fetch origin <分支名>

# 获取远程仓库所有分支的更新
git fetch --all

git pull


# 从远程仓库拉取代码,并合并到本地,相当于 git fetch && git merge 
git pull origin <远程分支名>:<本地分支名>

# 拉取后,使用rebase的模式进行合并
git pull --rebase origin <远程分支名>:<本地分支名>

注意



  • 直接git pull 不加任何选项,等价于git fetch + git merge FETCH_HEAD,执行效果就是会拉取所有分支信息回来,但是只合并当前分支的更改。其他分支的变更没有执行合并。

  • 使用git pull --rebase 可以减少冲突的提交点,比如我本地已经提交,但是远程其他同事也有新的代码提交记录,此时拉取远端其他同事的代码,如果是merge的形式,就会有一个merge的commit记录。如果用rebase,就不会产生该合并记录,是将我们的提交点挪到其他同事的提交点之后。


git branch


# 基于当前分支,新建一个本地分支,但不切换
git branch <branch-name>

# 查看本地分支
git branch

# 查看远程分支
git branch -r

# 查看本地和远程分支
git branch -a

# 删除本地分支
git branch -D <branch-name>

# 基于旧分支创建一个新分支
git branch <new-branch-name> <old-branch-name>

# 基于某提交点创建一个新分支
git branch <new-branch-name> <commit-id>

# 重新命名分支
git branch -m <old-branch-name> <new-branch-name>

git checkout


# 切换到某个分支上
git checkout <branch-name>

# 基于当前分支,创建一个分支并切换到新分支上
git checkout -b <branch-name>

git add


# 添把当前工作区修改的文件添加到暂存区,多个文件可以用空格隔开
git add xxx

# 添加当前工作区修改的所有文件到暂存区
git add .

git commit


# 提交暂存区中的所有文件,并写下提交的概要信息
git commit -m "message"

# 相等于 git add . && git commit -m
git commit -am

# 对最近一次的提交的信息进行修改,此操作会修改commit的hash值
git commit --amend

git push


# 推送提交到远程仓库
git push

# 强行推送到远程仓库
git push -f

git tag


# 查看所有已打上的标签
git tag

# 新增一个标签打在当前提交点上,并写上标签信息
git tag -a <version> -m 'message'

# 为指定提交点打上标签
git tag -a <version> <commit-id>

# 删除指定标签
git tag -d <version>

Git的进阶使用场景



HEAD表示最新提交 ;HEAD^表示上一次; HEAD~n表示第n次(从0开始,表示最近一次)



正常协作



  • git pull 拉取远程仓库的最新代码

  • 工作区修改代码,完成功能开发

  • git add . 添加修改的文件到暂存区

  • git commit -m 'message' 提交到本地仓库

  • git push将本地仓库的修改推送到远程仓库


代码合并


git merge


自动创建一个新的合并提交点merge-commit,且包含两个分支记录。如果合并的时候遇到冲突,仅需要修改解决冲突后,重新commit。



  • 场景:如dev要合并进主分支master,保留详细的合并信息

  • 优点:展示真实的commit情况

  • 缺点:分支杂乱


git checkout master
git merge dev

rf1o2b6eduboqwkigg3w.gif


git merge 的几种模式



  • git merge --ff (默认--ff,fast-farward)

    • 结果:被merge的分支和当前分支在图形上并为一条线,被merge的提交点commit合并到当前分支,没有新的提交点merge

    • 缺点:代码合并不冲突时,默认快速合并,主分支按时间顺序混入其他分支的零碎commit点。而且删除分支,会丢失分支信息。



  • git merge --no-ff(不快速合并、推荐)

    • 结果:被merge的分支和当前分支不在一条线上,被merge的提交点commit还在原来的分支上,并在当前分支产生一个新提交点merge

    • 优点:代码合并产生冲突就会走这个模式,利于回滚整个大版本(主分支自己的commit点)



  • git merge --squash(把多次分支commit历史压缩为一次)

    • 结果:把多次分支commit历史压缩为一次




image.png


git rebase



  • 不产生merge commit,变换起始点位置,“整理”成一条直线,且能使用命令合并多次commit。

  • 如在develop上git rebase master 就会拉取到master上的最新代码合并进来,也就是将分支的起始时间指向master上最新的commit上。自动保留的最新近的修改,不会遇到合并冲突。而且可交互操作(执行合并删除commit),可通过交互式变基来合并分支之前的commit历史git rebase -i HEAD~3

  • 场景:主要发生在个人分支上,如 git rebase master整理自己的dev变成一条线。频繁进行了git commit提交,可用交互操作drop删除一些提交,squash提交融合前一个提交中。

  • 优点:简洁的提交历史

  • 缺点:发生错误难定位,解决冲突比较繁琐,要一个一个解决。


git checkout dev
git rebase master

dwyukhq8yj2xliq4i50e.gifmsofpv7k6rcmpaaefscm.gif


git merge和git rebase的区别



  • merge会保留两个分支的commit信息,而且是交叉着的,即使是ff模式,两个分支的commit信息会混合在一起(按真实提交时间排序),多用于自己dev合并进master。

  • rebase意思是变基,改变分支的起始位置,在dev上git rebase master,将dev的多次commit一起拉到要master最新提交的后面(时间最新),变成一条线,多用于整理自己的dev提交历史,然后把master最新代码合进来。

  • 使用rebase还是merge更多的是管理风格的问题,有个较好实践:

    • 就是dev在merge进主分支(如master)之前,最好将自己的dev分支给rebase到最新的主分支(如master)上,然后用pull request创建普通merge请求。

    • 用rebase整理成重写commit历史,所有修改拉到master的最新修改前面,保证dev运行在当前最新的主branch的代码。避免了git历史提交里无意义的交织。



  • 假设场景:从 dev 拉出分支 feature-a。

    • 那么当 dev 要合并 feature-a 的内容时,使用 git merge feature-a

    • 反过来当 feature-a 要更新 dev 的内容时,使用 git rebase dev



  • git merge和git rebase 两者对比图

    • git merge图示 image.png

    • git rebase图示 image.png




取消合并


# 取消merge合并
git merge --abort
# 取消rebase合并
git rebase --abort

代码回退


代码回退的几种方式



  • git checkout

  • git reset

    • --hard:硬重置,影响【工作区、暂存区、本地仓库】

    • --mixed:默认,影响【暂存区、本地仓库】,被重置的修改内容还留在工作区

    • --soft:软重置,影响 【本地仓库】,被重置的修改内容还留在工作区和暂存区



  • git revert


# 撤回工作区该文件的修改,多个文件用空格隔开
git checkout -- <file-name>
# 撤回工作区所有改动
git checkout .

# 撤回已经commit到暂存区的文件
git reset <file-name>
# 撤回已经commit到暂存区的所有文件
git reset .
# 丢弃已commit的其他版本,hard参数表示同时重置工作区的修改
git reset --hard <commit-id>
# 回到上一个commit的版本,hard参数表示同时重置工作区的修改
git reset --hard HEAD^

# 撤销0ffaacc这次提交
git revert 0ffaacc
# 撤销最近一次提交
git revert HEAD
# 撤销最近2次提交,注意:数字从0开始
git revert HEAD~1

# 回退后要执行强制推送远程分支
git push -f

git reset和git revert的区别



  • reset是根据来移动HEAD指针,在该次提交点后面的提交记录会丢失。


hlh0kowt3hov1xhcku38.gif



  • revert会产生新的提交,来抵消选中的该次提交的修改内容,可以理解为“反做”,不会丢失中间的提交记录。


3kkd2ahn41zixs12xgpf.gif



  • 使用建议

    • 公共分支回退使用git revert,避免丢掉其他同事的提交。

    • 自己分支回退可使用git reset,也可以使用git revert,按需使用。




挑拣代码


git cherry-pick



  • “挑拣”提交,单独抽取某个分支的一个提交点,将这个提交点的所有修改内容,搬运到你的当前分支。

  • 如果我们只想将其他分支的某个提交点合并进来,不想用git merge将所有提交点合并进来,就需要使用这个git cherry-pick


git cherry-pick <commit-id>

2dkjx4yeaal10xyvj29v.gif


暂存代码


git stash



  • 当我们想要切换去其他分支修复bug,此时当前的功能代码还没修改完整,不想commit,就需要暂存当前修改的文件,然后切换到hotfix分支修复bug,修复完成再切换回来,将暂存的修改提取出来,继续功能开发。

  • 还有另一种场景就是,同事在远程分支上推送了代码,此时拉下来有冲突,可以将我们自己的修改stash暂存起来,然后先拉最新的提交代码,再pop出来,这样可以避免一个冲突的提交点。


# 将本地改动的暂存起来
git stash
# 将未跟踪的文件暂存(另一种方式是先将新增的文件添加到暂存区,使其被git跟踪,就可以直接git stash)
git stash -u
# 添加本次暂存的备注,方便查找。
git stash save "message"
# 应用暂存的更改
git stash apply
# 删除暂存
git stash drop
# 应用暂存的更改,然后删除该暂存,等价于git stash apply + git stash drop
git stash pop
# 删除所有缓存
git stash clear

打印日志



  1. git log


可以显示所有提交过的版本信息,如果感觉太繁琐,可以加上参数  --pretty=oneline,只会显示版本号和提交时的备注信息。



  1. git reflog


git reflog 可以查看所有分支的所有操作记录(包括已经被删除的 commit 记录和 reset 的操作),例如执行 git reset --hard HEAD~1,退回到上一个版本,用git log是看不出来被删除的,用git reflog则可以看到被删除的,我们就可以买后悔药,恢复到被删除的那个版本。


Git的下载、配置、工具推荐



  • Git下载地址


  • 两种拉取代码的方式

    • https:每次都要手动输入用户名和密码

    • ssh :自动使用本地私钥+远程的公钥验证是否为一对秘钥



  • 配置ssh

    • ssh-keygen -t rsa -C "邮箱地址"

    • cd ~/.ssh切换到home下面的ssh目录、cat id_rsa.pub命令查看公钥的内容,然后复制

    • github的settings -> SSH and GPG keys-> 复制刚才的内容贴入 -> Add SSH key

    • 全局配置一下Git用户名和邮箱

      • git config --global user.name "xxx"

      • git config --global user.email "xxx@xx.com"

      • image.png





  • Git 相关工具推荐

    • 图形化工具 SourceTree :可视化执行git命令,解放双手

    • VSCode插件 GitLens:可以在每行代码查看对应git的提交信息,而且提供每个提交点的差异对比




结尾


阅读到这里,是不是感觉对Git相关概念更加清晰了呢,那么恭喜你,再也不怕因为误操作,丢失同事辛辛苦苦写的代码了,而且将在日常工作的协同中游刃有余。



  • 💖建议收藏文章,工作中有需要的时候翻出来看一看~

  • 📃创作不易,如果我的文章对你有帮助,辛苦大佬们点个赞👍🏻,支持我一下~

  • 📌如果有错漏,欢迎大佬们指正~

  • 👏欢迎转载分享,请注明出处,谢谢~

链接:https://juejin.cn/post/6986868722136776718

收起阅读 »

为了让她10分钟入门canvas,我熬夜写了3个小项目和这篇文章

1. canvas实现时钟转动 实现以下效果,分为几步: 1、找到canvas的中心,画出表心,以及表框 2、获取当前时间,并根据时间画出时针,分针,秒针,还有刻度 3、使用定时器,每过一秒获取新的时间,并重新绘图,达到时钟转动的效果 1.1 表心,表框...
继续阅读 »

image.png


1. canvas实现时钟转动


实现以下效果,分为几步:



  • 1、找到canvas的中心,画出表心,以及表框

  • 2、获取当前时间,并根据时间画出时针,分针,秒针,还有刻度

  • 3、使用定时器,每过一秒获取新的时间,并重新绘图,达到时钟转动的效果


截屏2021-07-19 下午8.52.15.png


1.1 表心,表框


画表心,表框有两个知识点:



  • 1、找到canvas的中心位置

  • 2、绘制圆形


//html

<canvas id="canvas" width="600" height="600"></canvas>

// js

// 设置中心点,此时300,300变成了坐标的0,0
ctx.translate(300, 300)
// 画圆线使用arc(中心点X,中心点Y,半径,起始角度,结束角度)
ctx.arc(0, 0, 100, 0, 2 * Math.PI)
ctx.arc(0, 0, 5, 0, 2 * Math.PI)
// 执行画线段的操作stroke
ctx.stroke()

让我们来看看效果,发现了,好像不对啊,我们是想画两个独立的圆线,怎么画出来的两个圆连到一起了


截屏2021-07-19 下午9.10.07.png
原因是:上面代码画连个圆时,是连着画的,所以画完大圆后,线还没斩断,就接着画小圆,那肯定会大圆小圆连一起,解决办法就是:beginPath,closePath


ctx.translate(300, 300) // 设置中心点,此时300,300变成了坐标的0,0

// 画大圆
+ ctx.beginPath()
// 画圆线使用arc(中心点X,中心点Y,半径,起始角度,结束角度)
ctx.arc(0, 0, 100, 0, 2 * Math.PI)
ctx.stroke() // 执行画线段的操作
+ ctx.closePath()

// 画小圆
+ ctx.beginPath()
ctx.arc(0, 0, 5, 0, 2 * Math.PI)
ctx.stroke()
+ ctx.closePath()

1.2 时针,分针,秒针


画这三个指针,有两个知识点:



  • 1、根据当前时,分,秒计算角度

  • 2、在计算好的角度上去画出时针,分针,秒针


如何根据算好的角度去画线呢,比如算出当前是3点,那么时针就应该以12点为起始点,顺时针旋转2 * Math.PI / 12 * 3 = 90°,分针和秒针也是同样的道理,只不过跟时针不同的是比例问题而已,因为时在表上有12份,而分针和秒针都是60份


截屏2021-07-19 下午10.07.19.png


这时候又有一个新问题,还是以上面的例子为例,我算出了90°,那我们怎么画出时针呢?我们可以使用moveTo和lineTo去画线段。至于90°,我们只需要将x轴顺时针旋转90°,然后再画出这条线段,那就得到了指定角度的指针了。但是上面说了,是要以12点为起始点,我们的默认x轴确是水平的,所以我们时分秒针算出角度后,每次都要减去90°。可能这有点绕,我们通过下面的图演示一下,还是以上面3点的例子:


截屏2021-07-19 下午10.30.23.png


截屏2021-07-19 下午10.31.02.png
这样就得出了3点指针的画线角度了。


又又又有新问题了,比如现在我画完了时针,然后我想画分针,x轴已经在我画时针的时候偏转了,这时候肯定要让x轴恢复到原来的模样,我们才能继续画分针,否则画出来的分针是不准的。这时候save和restore就派上用场了,save是把ctx当前的状态打包压入栈中,restore是取出栈顶的状态并赋值给ctxsave可多次,但是restore取状态的次数必须等于save次数


截屏2021-07-19 下午10.42.06.png


懂得了上面所说,剩下画刻度了,起始刻度的道理跟时分秒针道理一样,只不过刻度是死的,不需要计算,只需要规则画出60个小刻度,和12个大刻度就行


const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')

ctx.translate(300, 300) // 设置中心点,此时300,300变成了坐标的0,0
// 把状态保存起来
+ ctx.save()

// 画大圆
ctx.beginPath()
// 画圆线使用arc(中心点X,中心点Y,半径,起始角度,结束角度)
ctx.arc(0, 0, 100, 0, 2 * Math.PI)
ctx.stroke() // 执行画线段的操作
ctx.closePath()

// 画小圆
ctx.beginPath()
ctx.arc(0, 0, 5, 0, 2 * Math.PI)
ctx.stroke()
ctx.closePath()

----- 新加代码 ------

// 获取当前 时,分,秒
let time = new Date()
let hour = time.getHours() % 12
let min = time.getMinutes()
let sec = time.getSeconds()

// 时针
ctx.rotate(2 * Math.PI / 12 * hour + 2 * Math.PI / 12 * (min / 60) - Math.PI / 2)
ctx.beginPath()
// moveTo设置画线起点
ctx.moveTo(-10, 0)
// lineTo设置画线经过点
ctx.lineTo(40, 0)
// 设置线宽
ctx.lineWidth = 10
ctx.stroke()
ctx.closePath()
// 恢复成上一次save的状态
ctx.restore()
// 恢复完再保存一次
ctx.save()

// 分针
ctx.rotate(2 * Math.PI / 60 * min + 2 * Math.PI / 60 * (sec / 60) - Math.PI / 2)
ctx.beginPath()
ctx.moveTo(-10, 0)
ctx.lineTo(60, 0)
ctx.lineWidth = 5
ctx.strokeStyle = 'blue'
ctx.stroke()
ctx.closePath()
ctx.restore()
ctx.save()

//秒针
ctx.rotate(2 * Math.PI / 60 * sec - - Math.PI / 2)
ctx.beginPath()
ctx.moveTo(-10, 0)
ctx.lineTo(80, 0)
ctx.strokeStyle = 'red'
ctx.stroke()
ctx.closePath()
ctx.restore()
ctx.save()

// 绘制刻度,也是跟绘制时分秒针一样,只不过刻度是死的
ctx.lineWidth = 1
for (let i = 0; i < 60; i++) {
ctx.rotate(2 * Math.PI / 60)
ctx.beginPath()
ctx.moveTo(90, 0)
ctx.lineTo(100, 0)
// ctx.strokeStyle = 'red'
ctx.stroke()
ctx.closePath()
}
ctx.restore()
ctx.save()
ctx.lineWidth = 5
for (let i = 0; i < 12; i++) {
ctx.rotate(2 * Math.PI / 12)
ctx.beginPath()
ctx.moveTo(85, 0)
ctx.lineTo(100, 0)
ctx.stroke()
ctx.closePath()
}

ctx.restore()

截屏2021-07-19 下午10.53.53.png


最后一步就是更新视图,使时钟转动起来,第一想到的肯定是定时器setInterval,但是注意一个问题:每次更新视图的时候都要把上一次的画布清除,再开始画新的视图,不然就会出现千手观音的景象


截屏2021-07-19 下午10.57.05.png


附上最终代码:


const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')

setInterval(() => {
ctx.save()
ctx.clearRect(0, 0, 600, 600)
ctx.translate(300, 300) // 设置中心点,此时300,300变成了坐标的0,0
ctx.save()

// 画大圆
ctx.beginPath()
// 画圆线使用arc(中心点X,中心点Y,半径,起始角度,结束角度)
ctx.arc(0, 0, 100, 0, 2 * Math.PI)
ctx.stroke() // 执行画线段的操作
ctx.closePath()

// 画小圆
ctx.beginPath()
ctx.arc(0, 0, 5, 0, 2 * Math.PI)
ctx.stroke()
ctx.closePath()

// 获取当前 时,分,秒
let time = new Date()
let hour = time.getHours() % 12
let min = time.getMinutes()
let sec = time.getSeconds()

// 时针
ctx.rotate(2 * Math.PI / 12 * hour + 2 * Math.PI / 12 * (min / 60) - Math.PI / 2)
ctx.beginPath()
// moveTo设置画线起点
ctx.moveTo(-10, 0)
// lineTo设置画线经过点
ctx.lineTo(40, 0)
// 设置线宽
ctx.lineWidth = 10
ctx.stroke()
ctx.closePath()
ctx.restore()
ctx.save()

// 分针
ctx.rotate(2 * Math.PI / 60 * min + 2 * Math.PI / 60 * (sec / 60) - Math.PI / 2)
ctx.beginPath()
ctx.moveTo(-10, 0)
ctx.lineTo(60, 0)
ctx.lineWidth = 5
ctx.strokeStyle = 'blue'
ctx.stroke()
ctx.closePath()
ctx.restore()
ctx.save()

//秒针
ctx.rotate(2 * Math.PI / 60 * sec - Math.PI / 2)
ctx.beginPath()
ctx.moveTo(-10, 0)
ctx.lineTo(80, 0)
ctx.strokeStyle = 'red'
ctx.stroke()
ctx.closePath()
ctx.restore()
ctx.save()

// 绘制刻度,也是跟绘制时分秒针一样,只不过刻度是死的
ctx.lineWidth = 1
for (let i = 0; i < 60; i++) {
ctx.rotate(2 * Math.PI / 60)
ctx.beginPath()
ctx.moveTo(90, 0)
ctx.lineTo(100, 0)
// ctx.strokeStyle = 'red'
ctx.stroke()
ctx.closePath()
}
ctx.restore()
ctx.save()
ctx.lineWidth = 5
for (let i = 0; i < 12; i++) {
ctx.rotate(2 * Math.PI / 12)
ctx.beginPath()
ctx.moveTo(85, 0)
ctx.lineTo(100, 0)
// ctx.strokeStyle = 'red'
ctx.stroke()
ctx.closePath()
}

ctx.restore()
ctx.restore()
}, 1000)

效果 very good啊:


clock的副本.gif


2. canvas实现刮刮卡


小时候很多人都买过充值卡把,懂的都懂啊哈,用指甲刮开这层灰皮,就能看底下的答案了。
截屏2021-07-19 下午11.02.09.png


思路是这样的:



  • 1、底下答案是一个div,顶部灰皮是一个canvascanvas一开始盖住div

  • 2、鼠标事件,点击时并移动时,鼠标经过的路径都画圆形开路,并且设置globalCompositeOperationdestination-out,使鼠标经过的路径都变成透明,一透明,自然就显示出下方的答案信息。


关于fill这个方法,其实是对标stroke的,fill是把图形填充,stroke只是画出边框线


// html
<canvas id="canvas" width="400" height="100"></canvas>
<div class="text">恭喜您获得100w</div>
<style>
* {
margin: 0;
padding: 0;
}
.text {
position: absolute;
left: 130px;
top: 35px;
z-index: -1;
}
</style>


// js
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')

// 填充的颜色
ctx.fillStyle = 'darkgray'
// 填充矩形 fillRect(起始X,起始Y,终点X,终点Y)
ctx.fillRect(0, 0, 400, 100)
ctx.fillStyle = '#fff'
// 绘制填充文字
ctx.fillText('刮刮卡', 180, 50)

let isDraw = false
canvas.onmousedown = function () {
isDraw = true
}
canvas.onmousemove = function (e) {
if (!isDraw) return
// 计算鼠标在canvas里的位置
const x = e.pageX - canvas.offsetLeft
const y = e.pageY - canvas.offsetTop
// 设置globalCompositeOperation
ctx.globalCompositeOperation = 'destination-out'
// 画圆
ctx.arc(x, y, 10, 0, 2 * Math.PI)
// 填充圆形
ctx.fill()
}
canvas.onmouseup = function () {
isDraw = false
}

效果如下:


guaguaka.gif


3. canvas实现画板和保存


框架:使用vue + elementUI


其实很简单,难点有以下几点:



  • 1、鼠标拖拽画正方形和圆形

  • 2、画完一个保存画布,下次再画的时候叠加

  • 3、保存图片


第一点,只需要计算出鼠标点击的点坐标,以及鼠标的当前坐标,就可以计算了,矩形长宽计算:x - beginX, y - beginY,圆形则要利用勾股定理:Math.sqrt((x - beginX) * (x - beginX) + (y - beginY) * (y - beginY))


第二点,则要利用canvas的getImageDataputImageData方法


第三点,思路是将canvas生成图片链接,并赋值给具有下载功能的a标签,并主动点击a标签进行图片下载


看看效果吧:


截屏2021-07-19 下午11.16.24.png


截屏2021-07-19 下午11.17.41.png


具体代码我就不过多讲解了,说难也不难,只要前面两个项目理解了,这个项目很容易就懂了:


<template>
<div>
<div style="margin-bottom: 10px; display: flex; align-items: center">
<el-button @click="changeType('huabi')" type="primary">画笔</el-button>
<el-button @click="changeType('rect')" type="success">正方形</el-button>
<el-button
@click="changeType('arc')"
type="warning"
style="margin-right: 10px"
>圆形</el-button
>
<div>颜色:</div>
<el-color-picker v-model="color"></el-color-picker>
<el-button @click="clear">清空</el-button>
<el-button @click="saveImg">保存</el-button>
</div>
<canvas
id="canvas"
width="800"
height="400"
@mousedown="canvasDown"
@mousemove="canvasMove"
@mouseout="canvasUp"
@mouseup="canvasUp"
>
</canvas>
</div>
</template>

<script>
export default {
data() {
return {
type: "huabi",
isDraw: false,
canvasDom: null,
ctx: null,
beginX: 0,
beginY: 0,
color: "#000",
imageData: null,
};
},
mounted() {
this.canvasDom = document.getElementById("canvas");
this.ctx = this.canvasDom.getContext("2d");
},
methods: {
changeType(type) {
this.type = type;
},
canvasDown(e) {
this.isDraw = true;
const canvas = this.canvasDom;
this.beginX = e.pageX - canvas.offsetLeft;
this.beginY = e.pageY - canvas.offsetTop;
},
canvasMove(e) {
if (!this.isDraw) return;
const canvas = this.canvasDom;
const ctx = this.ctx;
const x = e.pageX - canvas.offsetLeft;
const y = e.pageY - canvas.offsetTop;
this[`${this.type}Fn`](ctx, x, y);
},
canvasUp() {
this.imageData = this.ctx.getImageData(0, 0, 800, 400);
this.isDraw = false;
},
huabiFn(ctx, x, y) {
ctx.beginPath();
ctx.arc(x, y, 5, 0, 2 * Math.PI);
ctx.fillStyle = this.color;
ctx.fill();
ctx.closePath();
},
rectFn(ctx, x, y) {
const beginX = this.beginX;
const beginY = this.beginY;
ctx.clearRect(0, 0, 800, 400);
this.imageData && ctx.putImageData(this.imageData, 0, 0, 0, 0, 800, 400);
ctx.beginPath();
ctx.strokeStyle = this.color;
ctx.rect(beginX, beginY, x - beginX, y - beginY);
ctx.stroke();
ctx.closePath();
},
arcFn(ctx, x, y) {
const beginX = this.beginX;
const beginY = this.beginY;
this.isDraw && ctx.clearRect(0, 0, 800, 400);
this.imageData && ctx.putImageData(this.imageData, 0, 0, 0, 0, 800, 400);
ctx.beginPath();
ctx.strokeStyle = this.color;
ctx.arc(
beginX,
beginY,
Math.round(
Math.sqrt((x - beginX) * (x - beginX) + (y - beginY) * (y - beginY))
),
0,
2 * Math.PI
);
ctx.stroke();
ctx.closePath();
},
saveImg() {
const url = this.canvasDom.toDataURL();
const a = document.createElement("a");
a.download = "sunshine";
a.href = url;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
clear() {
this.imageData = null
this.ctx.clearRect(0, 0, 800, 400)
}
},
};
</script>

<style lang="scss" scoped>
#canvas {
border: 1px solid black;
}
</style>

结语


链接:https://juejin.cn/post/6986785259966857247

收起阅读 »

CSS 奇思妙想 | 巧妙的实现带圆角的三角形

之前在这篇文章中 -- 《老生常谈之 CSS 实现三角形》,介绍了 6 种使用 CSS 实现三角形的方式。 但是其中漏掉了一个非常重要的场景,如何使用纯 CSS 实现带圆角的三角形呢?,像是这样: 本文将介绍几种实现带圆角的三角形的实现方式。 法一. 全兼容...
继续阅读 »

之前在这篇文章中 -- 《老生常谈之 CSS 实现三角形》,介绍了 6 种使用 CSS 实现三角形的方式。


但是其中漏掉了一个非常重要的场景,如何使用纯 CSS 实现带圆角的三角形呢?,像是这样:


A triangle with rounded


本文将介绍几种实现带圆角的三角形的实现方式。


法一. 全兼容的 SVG 大法


想要生成一个带圆角的三角形,代码量最少、最好的方式是使用 SVG 生成。


使用 SVG 的 多边形标签 <polygon> 生成一个三边形,使用 SVG 的 stroke-linejoin="round" 生成连接处的圆角。


代码量非常少,核心代码如下:


<svg  width="250" height="250" viewBox="-50 -50 300 300">
<polygon class="triangle" stroke-linejoin="round" points="100,0 0,200 200,200"/>
</svg>

.triangle {
fill: #0f0;
stroke: #0f0;
stroke-width: 10;
}

实际图形如下:


A triangle with rounded


这里,其实是借助了 SVG 多边形的 stroke-linejoin: round 属性生成的圆角,stroke-linejoin 是什么?它用来控制两条描边线段之间,有三个可选值:



  • miter 是默认值,表示用方形画笔在连接处形成尖角

  • round 表示用圆角连接,实现平滑效果

  • bevel 连接处会形成一个斜接



我们实际是通过一个带边框,且边框连接类型为 stroke-linejoin: round 的多边形生成圆角三角形的


如果,我们把底色和边框色区分开,实际是这样的:


.triangle {
fill: #0f0;
stroke: #000;
stroke-width: 10;
}


通过 stroke-width 控制圆角大小


那么如何控制圆角大小呢?也非常简单,通过控制 stroke-width 的大小,可以改变圆角的大小。


当然,要保持三角形大小一致,在增大/缩小 stroke-width 的同时,需要缩小/增大图形的 width/height



完整的 DEMO 你可以戳这里:CodePen Demo -- 使用 SVG 实现带圆角的三角形


法二. 图形拼接


不过,上文提到了,使用纯 CSS 实现带圆角的三角形,但是上述第一个方法其实是借助了 SVG。那么仅仅使用 CSS,有没有办法呢?


当然,发散思维,CSS 有意思的地方正在于此处,用一个图形,能够有非常多种巧妙的解决方案!


我们看看,一个圆角三角形,它其实可以被拆分成几个部分:



所以,其实我们只需要能够画出一个这样的带圆角的菱形,通过 3 个进行旋转叠加,就能得到圆角三角形:



绘制带圆角的菱形


那么,接下来我们的目标就变成了绘制一个带圆角的菱形,方法有很多,本文给出其中一种方式:



  1. 首先将一个正方形变成一个菱形,利用 transform 有一个固定的公式:



<div></div>

div {
width: 10em;
height: 10em;
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
}



  1. 将其中一个角变成圆角:


div {
width: 10em;
height: 10em;
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
+ border-top-right-radius: 30%;
}


至此,我们就顺利的得到一个带圆角的菱形了!


拼接 3 个带圆角的菱形


接下来就很简单了,我们只需要利用元素的另外两个伪元素,再生成 2 个带圆角的菱形,将一共 3 个图形旋转位移拼接起来即可!


完整的代码如下:


<div></div>

div{
position: relative;
background-color: orange;
}
div:before,
div:after {
content: '';
position: absolute;
background-color: inherit;
}
div,
div:before,
div:after {
width: 10em;
height: 10em;
border-top-right-radius: 30%;
}
div {
transform: rotate(-60deg) skewX(-30deg) scale(1,.866);
}
div:before {
transform: rotate(-135deg) skewX(-45deg) scale(1.414, .707) translate(0,-50%);
}
div:after {
transform: rotate(135deg) skewY(-45deg) scale(.707, 1.414) translate(50%);
}

就可以得到一个圆角三角形了!效果如下:


image


完整的代码你可以戳这里:CodePen Demo -- A triangle with rounded


法三. 图形拼接实现渐变色圆角三角形


完了吗?没有!


上述方案,虽然不算太复杂,但是有一点还不算太完美的。就是无法支持渐变色的圆角三角形。像是这样:



如果需要实现渐变色圆角三角形,还是有点复杂的。但真就还有人鼓捣出来了,下述方法参考至 -- How to make 3-corner-rounded triangle in CSS


同样也是利用了多块进行拼接,但是这次我们的基础图形,会非常的复杂。


首先,我们需要实现这样一个容器外框,和上述的方法比较类似,可以理解为是一个圆角菱形(画出 border 方便理解):



<div></div>

div {
width: 200px;
height: 200px;
transform: rotate(30deg) skewY(30deg) scaleX(0.866);
border: 1px solid #000;
border-radius: 20%;
}

接着,我们同样使用两个伪元素,实现两个稍显怪异的图形进行拼接,算是对 transform 的各种用法的合集:


div::before,
div::after {
content: "";
position: absolute;
width: 200px;
height: 200px;
}
div::before {
border-radius: 20% 20% 20% 55%;
transform: scaleX(1.155) skewY(-30deg) rotate(-30deg) translateY(-42.3%) skewX(30deg) scaleY(0.866) translateX(-24%);
background: red;
}
div::after {
border-radius: 20% 20% 55% 20%;
background: blue;
transform: scaleX(1.155) skewY(-30deg) rotate(-30deg) translateY(-42.3%) skewX(-30deg) scaleY(0.866) translateX(24%);
}

为了方便理解,制作了一个简单的变换动画:



本质就是实现了这样一个图形:


image


最后,给父元素添加一个 overflow: hidden 并且去掉父元素的 border 即可得到一个圆角三角形:



由于这两个元素重叠空间的特殊结构,此时,给两个伪元素添加同一个渐变色,会完美的叠加在一起:


div::before,
div::after, {
background: linear-gradient(#0f0, #03a9f4);
}

最终得到一个渐变圆角三角形:



上述各个图形的完整代码,你可以戳这里:CodePen Demo -- A triangle with rounded and gradient background


最后


本文介绍了几种在 CSS 中实现带圆角三角形的方式,虽然部分有些繁琐,但是也体现了 CSS ”有趣且折磨人“ 的一面,具体应用的时候,还是要思考一下,对是否使用上述方式进行取舍,有的时候,切图也许是更好的方案。


链接:https://juejin.cn/post/6984599136842547213

收起阅读 »

微前端模块共享你真的懂了吗

前言:我们运用微前端架构解决了应用体积庞大的问题,通过实践微前端的理念,将前端应用拆分为多个微应用(可独立部署、松散耦合的应用)。同时微应用的存在,使得我们无需在构建一个庞大的应用,而是按需构建,极大了加快了构建效率。但只是解决了应用层面的问题,在中后台应用场...
继续阅读 »

前言:我们运用微前端架构解决了应用体积庞大的问题,通过实践微前端的理念,将前端应用拆分为多个微应用(可独立部署、松散耦合的应用)。同时微应用的存在,使得我们无需在构建一个庞大的应用,而是按需构建,极大了加快了构建效率。但只是解决了应用层面的问题,在中后台应用场景中,不同微应用和基座之间可能存在通用的模块依赖,那么如果应用间可以实现模块共享,那么可以大大优化单应体积大小



image.png


1.Npm 依赖



最简单的方式,就是把需要共享的模块抽出,可能是一个工具库,有可能是一个组件库,然后讲其打包成为npm包,然后在每个子应用中都安装该模块依赖,以此达到多个项目复用的效果



也就代表每个应用都有相同的npm包,本质上没有真正意义上的实现模块共享和复用,只是代码层次共享和复用了,应用打包构建时,还是会将依赖包一起打包


image.png


劣势有以下👇 几点:



  • 每个微应用都会打包该模块,导致依赖的包冗余,没有真正意义上的共享复用

  • npm包进行更新发布了,微应用还需要重新构建,调试麻烦且低效 (除非用npm link


2.Git Submodule (子模块)



阿乐童鞋: 那如果我们没有搭建npm内网,又不想把模块开源出去,而且依赖npm,只要涉及变更需要重新发布,有没有其他方式可以解决以上问题呀?



image.png


2.1 对比 npm


你可以试试 Git Submodule ,它提供了一种类似于npm package的依赖管理机制,两者差别如下图所示👇


image.png


2.2 如何使用


通过在应用项目中,通过git submodule add <submodule_url>远程拉取子模块项目,这时会发现应用项目中多了两个文件.gitmodules子模块目录


image.png


这个子模块就是我们共享的模块,它是一个完整的Git仓库,换句话说:我们在应用项目目录中无论使用git add/commit都对其不影响,即子模块拥有自身独立的版本控制


总结: submodule本质上是通过git submodule add把项目依赖的模块加起来,最终构成一个完整的项目。而且add进来的模块,项目中并不实际包含,而只是一个包含索引信息,也就是上文提到的 .gitmodule来存储子模块的联系方式, 以此实现同步关联子模块。当下载到本地运行的时候才会再拉取文件


部分命令行:




  • git submodule add <子模块repository> <path> : 添加子模块




  • git submodule update --recursive --remote : 拉取所有子模块的更新




2.3 Monorepo



阿乐童鞋: 🌲 树酱,我记得有个叫Monorepo又是什么玩意,跟 Git Submodule 有啥区别?



image.png


Monorepo 全称叫monolithic respoitory,即单体式仓库,核心是允许我们将多个项目放到同一个仓库里面进行管理。主张不拆分repo,而是在单仓库里统一管理各个模块的构建流程、版本号等等


这样可以避免大量的冗余node_module冗余,因为每个项目都会安装vue、vue-router等包,再或者本地开发需要的webpack、babel、mock等都会造成储存空间的浪费


那么Monorepo是怎么管理的呢? 开源社区中诸如babel、vue的项目都是基于Monorepo去维护的(Lerna工具)


我们以Babel为例,在github中可以看到其每个模块都在指定的packages目录下, 也就意味着将所有的相关package都放入一个repository来管理,这不是显得项目很臃肿?


image.png
也就这个问题,啊乐同学和啊康同学展开了辩论~


image.png



最终是选用Monorepo单体式仓库还是Multirepo多仓库管理, 具体还是要看你业务场景来定,Monorepo集中管理带来的便利性,比如方便版本、依赖等管理、方便调试,但也带来了不少不便之处 👇




  • 统一构建工具所带来更高的要求

  • 仓库体积过大,维护成本也高


🌲 酱 不小心扯多了,还有就是Monorepo 跟 Git Submodule 的区别




  • 前者:monorepo在单repo里存放所有子模块源码




  • 后者:submodules只在主repo里存放所有子模块“索引”




目前内部还未使用Monorepo进行落地实际,目前基于微前端架构中后台应用存在依赖重叠过多的情况,后期会通过实践来深入分享


3. Webpack external



我们知道webpack中有externals的配置,主要是用来配置:webpack输出的bundle中排除依赖,换句话说通过在external定义的依赖,最终输出的bundle不存在该依赖,主要适用于不需要经常打包更新的第三方依赖,以此来实现模块共享。



下面是一个vue.config.js 的配置文件,通过配置exteral移除不经常更新打包的第三方依赖👇
carbon (26).png


你可以通过在packjson中script定义的命令后添加--report查看打包📦后的分析图,如果是webpack就是用使用插件webpack-bundle-analyzer



阿乐童鞋: 🌲 树酱,那移除了这些依赖之后,如何保证应用正常使用?



浏览器环境:我们使用cdn的方式在入口文件引入,当然你也可以预先打包好,比如把vue全家桶打包成vue-family.min.js文件,最终达成多应用共享模块的效果


<script src="<%= VUE_APP_UTILS_URL %>static/js/vue-family.min.js"></script>


总结:避免公共模块包(package) 一起打到bundle 中,而是在运行时再去从外部获取这些扩展依赖


通过这种形式在微前端基座应用加载公共模块,并将微应用引用同样模块的external移除掉,就可以实现模块共享了
但是存在微应用技术栈多样化不统一的情况,可能有的使用vue3,有的使用react开发,但externals 并无法支持多版本共存的情况,针对这种情况该方式就不太适用


4. Webpack DLL


官方介绍:"DLL" 一词代表微软最初引入的动态链接库, 换句话说我的理解,可以把它当做缓存,通过预先编译好的第三方外部依赖bundle,来节省应用在打包时混入的时间



Webpack DLL 跟 上一节提到的external本质是解决同样的问题:就是避免将第三方外部依赖打入到应用的bundle中(业务代码),然后在运行时再去加载这部分依赖,以此来实现模块复用,也提升了编译构建速度



webpack dll模式下需要配置两份webpack配置,下面是主要两个核心插件


image.png


4.1 DllPlugin


DllPlugin:在一个独立的webpack进行配置webpack.dll.config.js,目的是为了创建一个把所有的第三方库依赖打包到一起的bundle的dll文件里面,同时还会生成一个manifest.json的文件,用于:让使用该第三方依赖集合的应用配置的DllReferencePlugin能映射到相关的依赖上去 具体配置看下图👇


carbon.png


image.png


4.2 DllReferencePlugin


DllReferencePlugin:插件核心是把上一节提到的通过webpack.dll.config.js中打包生成的dll文件,引用到需要实际项目中使用,引用机制就是通过DllReferencePlugin插件来读取vendor-manifest.json文件,看看是否有该第三方库,最后通过add-asset-html-webpack-plugin插件在入口html自动插入上一节生成的vendor.dll.js 文件, 具体配置看下图👇
carbon (1).png


5. 联邦模块 Module Federation


模块联邦是 Webpack5 推出的一个新的重要功能,可以真正意义上实现让跨应用间做到模块共享,解决了从前用 NPM 公共包方式共享的不便利,同时也可以作为微前端的落地方案,完美秒杀了上两节介绍webpack特征


用过qiankun的小伙伴应该知道,qiankun微前端架构控制的粒度是在应用层面,而Module Federation控制的粒度是在模块层面。相比之下,后者粒度更小,可以有更多的选择


与qiankun等微前端架构不同的另一点是,我们一般都是需要一个中心基座去控制微应用的生命周期,而Module Federation则是去中心化的,没有中心基座的概念,每一个模块或者应用都是可以导入或导出,我们可以称为:host和remote,应用或模块即可以是host也可以是remote,亦或者两者共同体


image.png


看看下面这个例子👇


carbon (3).png


核心在于 ModuleFederationPlugin中的几个属性



  • remote : 示作为 Host 时,去消费哪些 Remote;

  • exposes :表示作为 Remote 时,export 哪些属性提供给 Host 消费

  • shared: 可以让远程加载的模块对应依赖改为使用本地项目的 vue,换句话说优先用 Host 的依赖,如果 Host 没有,最后再使用自己的


后期也会围绕 Module Federation 去做落地分享


链接:https://juejin.cn/post/6984682096291741704

收起阅读 »

全自动jQuery与渣男的故事

我是个恋旧的人,Github头像还是上古时期端游仙剑奇侠传的截图。 对于前端,如果能jQuery一把梭,我是很开心的。 React、Vue的普及让大家习惯了虚拟DOM的存在。但是虚拟DOM一定是最优解么? 举个例子,要进行如下DOM移动操作: // 变化前 ...
继续阅读 »

我是个恋旧的人,Github头像还是上古时期端游仙剑奇侠传的截图。



对于前端,如果能jQuery一把梭,我是很开心的。


ReactVue的普及让大家习惯了虚拟DOM的存在。但是虚拟DOM一定是最优解么?


举个例子,要进行如下DOM移动操作:


// 变化前
abcd
// 变化后
dabc

jQuery时调用insertBefored挪到a前面就行。而React基于虚拟DOMDiff会依次对abc执行appendChild,将他们依次挪到最后。


1次DOM操作 vs 3次DOM操作,显然前者更高效。


那么有没有框架能砍掉虚拟DOM,直接对DOM节点执行操作,实现全自动jQuery


有的,这就是最近出的petite-vue


阅读完本文,你会从原理层面了解该框架,如果你还有精力,可以在此基础上深入框架源码。


全自动jQuery的实现


可以将原理概括为一句话:



建立状态更新DOM的方法之间的联系



比如,对于如下DOM


<p v-show="showName">我是卡颂</p>

期望showName状态的变化能影响p的显隐(通过改变diaplay)。


实际是建立showName的变化调用如下方法的联系:


() => {
el.style.display = get() ? initialDisplay : 'none'
}

其中el代表pget()获取showName当前值。


再比如,对于如下DOM


<p v-text="name"></p>

name改变后ptextContent会变为对应值。


实际是建立name的变化调用如下方法的联系:


() => {
el.textContent = toDisplayString(get())
}

所以,整个框架的工作原理呼之欲出:初始化时遍历所有DOM,根据各种v-xx属性建立DOM操作DOM的方法之间的联系。


当改变状态后,会自动调用与其有关的操作DOM的方法,简直就是全自动jQuery



所以,框架的核心在于:如何建立联系?


一个渣男的故事


这部分源码都收敛在@vue/reactivity库中。我并不想带你精读源码,因为这样很没意思,看了还容易忘。


接下来我会通过一个故事为你展示其工作原理,当你了解原理后如果感兴趣可以自己去看源码。



我们的目标是描述:状态变化更新DOM的方法之间的联系。说得再宽泛点,是建立状态副作用之间的联系。


即:状态变化 -> 执行副作用


对于一段关系,可以从当事双方的角度描述,比如:


男生指着女生说:这是我女朋友。


接着女生指着男生说:这是我男朋友。


你作为旁观者,通过双方的描述就知道他们处于一段恋爱关系。


推广到状态副作用,则是:


副作用指着状态说:我依赖这个状态,他变了我就会执行。


状态指着副作用说:我订阅了这个副作用,当我变了后我会通知他。



可以看到,发布订阅其实是对一段关系站在双方视角的阐述



举个例子,如下DOM结构:


<div v-scope="{num: 0}">
<button @click="num++">add 1</button>
<p v-show="num%2">
<span v-text="num"></span>
</p>
</div>

经过petite-vue遍历后的关系图:



框架的交互流程为:




  1. 触发点击事件,状态num变化




  2. 通知其订阅的副作用effect1effect2),执行对应DOM操作




如果从情侣关系角度解读,就是:


num指着effect1说:这是我女朋友。


effect1指着num说:这是我男朋友。


num指着effect2说:这是我女朋友。


effect2指着num说:这是我男朋友。



总结


今天我们学习了一个框架petite-vue,他的底层实现由多段混乱的男女关系组成,上层是一个个直接操作DOM的方法。


不知道看完后你有没有兴趣深入了解下这种关系呢?


感兴趣的话可以看看Vue MasteryVue 3 Reactivity课程。



链接:https://juejin.cn/post/6984710323945078820

收起阅读 »

拖拽竟然还能这样玩!

在大多数低代码平台中的设计器都支持组件拖拽的功能,这样大大地提高了用户的设计体验。而拖拽另一个比较常见的场景就是文件上传,通过拖拽的方式,可以让用户方便地上传文件。其实利用拖拽功能,我们还可以 跨越浏览器的边界,实现数据共享。 那么如何 跨越浏览器的边界,实现...
继续阅读 »

在大多数低代码平台中的设计器都支持组件拖拽的功能,这样大大地提高了用户的设计体验。而拖拽另一个比较常见的场景就是文件上传,通过拖拽的方式,可以让用户方便地上传文件。其实利用拖拽功能,我们还可以 跨越浏览器的边界,实现数据共享


那么如何 跨越浏览器的边界,实现数据共享 呢?本文阿宝哥将介绍谷歌的一个开源项目 —— transmat,利用该项目可以实现上述功能。不仅如此,该项目还可以帮助我们实现一些比较好玩的功能,比如针对不同的可释放目标,做出不同的响应。


下面我们先通过 4 张 Gif 动图来感受一下,使用 transmat 开发的 神奇、好玩 的拖拽功能。


图 1(把可拖拽的元素,拖拽至富文本编辑器)



图 2(把可拖拽的元素,拖拽至 Chrome 浏览器,也支持其他浏览器)



图 3(把可拖拽的元素,拖拽至自定义的释放目标)



图 4(把可拖拽的元素,拖拽至 Chrome 开发者工具)




以上示例使用的浏览器版本:Chrome 91.0.4472.114(正式版本) (x86_64)



以上 4 张图中的 可拖拽元素都是同一个元素,当它被放置到不同的可释放目标时,产生了不同的效果。同时,我们也跨越了浏览器的边界,实现了数据的共享。看完以上 4 张动图,你是不是觉得挺神奇的。其实除了拖拽之外,该示例也支持复制、粘贴操作。不过,在详细介绍如何使用 transmat 实现上述功能之前,我们先来简单介绍一下 transmat 这个库。


一、Transmat 简介


Transmat 是一个围绕 DataTransfer API 的小型库 ,它使用 drag-dropcopy-paste 交互简化了在 Web 应用程序中传输和接收数据的过程。 DataTransfer API 能够将多种不同类型的数据传输到用户设备上的其他应用程序,该 API 所支持的数据类型,常见的有这几种:text/plaintext/htmlapplication/json 等。



(图片来源:google.github.io/transmat/)


了解完 transmat 是什么之后,我们来看一下它的应用场景:



  • 想以便捷的方式与外部应用程序集成。

  • 希望为用户提供与其他应用程序共享数据的能力,即使是那些你不知道的应用程序。

  • 希望外部应用程序能够与你的 Web 应用程序深度集成。

  • 想让你的应用程序更好地适应用户现有的工作流程。


现在你已经对 transmat 有了一定的了解,下面我们来分析如何使用 transmat 实现以上 4 张 Gif 动图对应的功能。


二、Transmat 实战


2.1 transmat-source


html


在以下代码中,我们为 div#source 元素添加了 draggable 属性,该属性用于标识元素是否允许被拖动,它的取值为 truefalse


<script src="https://unpkg.com/transmat/lib/index.umd.js"></script>
<div id="source" draggable="true" tabindex="0">大家好,我是阿宝哥</div>

css


#source {
background: #eef;
border: solid 1px rgba(0, 0, 255, 0.2);
border-radius: 8px;
cursor: move;
display: inline-block;
margin: 1em;
padding: 4em 5em;
}

js


const { Transmat, addListeners, TransmatObserver } = transmat;

const source = document.getElementById("source");

addListeners(source, "transmit", (event) => {
const transmat = new Transmat(event);
transmat.setData({
"text/plain": "大家好,我是阿宝哥!",
"text/html": `
<h1>大家好,我是阿宝哥</h1>
<p>聚焦全栈,专注分享 TS、Vue 3、前端架构等技术干货。
<a href="https://juejin.cn/user/764915822103079">访问我的主页</a>!
</p>
<img src="https://sf3-ttcdn-tos.pstatp.com/img/user-avatar/
075d8e781ba84bf64035ac251988fb93~300x300.image" border="1" />
`,
"text/uri-list": "https://juejin.cn/user/764915822103079",
"application/json": {
name: "阿宝哥",
wechat: "semlinker",
},
});
});

在以上代码中,我们利用 transmat 这个库提供的 addListeners 函数为 div#source 元素,添加了 transmit 的事件监听。在对应的事件处理器中,我们先创建了 Transmat 对象,然后调用该对象上的 setData 方法设置不同 MIME 类型的数据。


下面我们来简单回顾一下,示例中所使用的 MIME 类型:



  • text/plain:表示文本文件的默认值,一个文本文件应当是人类可读的,并且不包含二进制数据。

  • text/html:表示 HTML 文件类型,一些富文本编辑器会优先从 dataTransfer 对象上获取 text/html 类型的数据,如果不存在的话,再获取 text/plain 类型的数据。

  • text/uri-list:表示 URI 链接类型,大多数浏览器都会优先读取该类型的数据,如果发现是合法的 URI 链接,则会直接打开该链接。如果不是的合法 URI 链接,对于 Chrome 浏览器来说,它会读取 text/plain 类型的数据并以该数据作为关键词进行内容检索。

  • application/json:表示 JSON 类型,该类型对前端开发者来说,应该都比较熟悉了。


介绍完 transmat-source 之后,我们来看一下图 3 自定义目标(transmat-target)的实现代码。


2.2 transmat-target


html


<script src="https://unpkg.com/transmat/lib/index.umd.js"></script>
<div id="target" tabindex="0">放这里哟!</div>

css


body {
text-align: center;
font: 1.2em Helvetia, Arial, sans-serif;
}
#target {
border: dashed 1px rgba(0, 0, 0, 0.5);
border-radius: 8px;
margin: 1em;
padding: 4em;
}
.drag-active {
background: rgba(255, 255, 0, 0.1);
}
.drag-over {
background: rgba(255, 255, 0, 0.5);
}

js


const { Transmat, addListeners, TransmatObserver } = transmat;

const target = document.getElementById("target");

addListeners(target, "receive", (event) => {
const transmat = new Transmat(event);
// 判断是否含有"application/json"类型的数据
// 及事件类型是否为drop或paste事件
if (transmat.hasType("application/json")
&& transmat.accept()
) {
const jsonString = transmat.getData("application/json");
const data = JSON.parse(jsonString);
target.textContent = jsonString;
}
});

在以上代码中,我们利用 transmat 这个库提供的 addListeners 函数为 div#target 元素,添加了 receive 的事件监听。顾名思义,该 receive 事件表示接收消息。在对应的事件处理器中,我们通过 transmat 对象的 hasType 方法过滤了 application/json 的消息,然后通过 JSON.parse 方法进行反序列化获得对应的数据,同时把对应 jsonString 的内容显示在 div#target 元素内。


在图 3 中,当我们把可拖拽的元素,拖拽至自定义的释放目标时,会产生高亮效果,具体如下图所示:



这个效果是利用 transmat 这个库提供的 TransmatObserver 类来实现,该类可以帮助我们响应用户的拖拽行为,具体的使用方式如下所示:


const obs = new TransmatObserver((entries) => {
for (const entry of entries) {
const transmat = new Transmat(entry.event);
if (transmat.hasType("application/json")) {
entry.target.classList.toggle("drag-active", entry.isActive);
entry.target.classList.toggle("drag-over", entry.isTarget);
}
}
});
obs.observe(target);

第一次看到 TransmatObserver 之后,阿宝哥立马想到了 MutationObserver API,因为它们都是观察者且拥有类似的 API。利用 MutationObserver API 我们可以监视 DOM 的变化。DOM 的任何变化,比如节点的增加、减少、属性的变动、文本内容的变动,通过这个 API 我们都可以得到通知。如果你对该 API 感兴趣的话,可以阅读 是谁动了我的 DOM? 这篇文章。


现在我们已经知道 transmat 这个库如何使用,接下来阿宝哥将带大家一起来分析这个库背后的工作原理。



Transmat 使用示例:Transmat Demo


gist.github.com/semlinker/c…



三、Transmat 源码分析


transmat 源码分析环节,因为在前面实战部分,我们使用到了 addListenersTransmatTransmatObserver 这三个 “函数” 来实现核心的功能,所以接下来的源码分析,我们将围绕它们展开。这里我们先来分析 addListeners 函数。


3.1 addListeners 函数


addListeners 函数用于设置监听器,调用该函数后会返回一个用于移除事件监听的函数。在分析函数时,阿宝哥习惯先分析函数的签名:


// src/transmat.ts
function addListeners<T extends Node>(
target: T,
type: TransferEventType,
listener: (event: DataTransferEvent, target: T) => void,
options = {dragDrop: true, copyPaste: true}
): () => void

通过观察以上的函数签名,我们可以很直观的了解该函数的输入和输出。该函数支持以下 4 个参数:



  • target:表示监听的目标,它的类型是 Node 类型。

  • type:表示监听的类型,该参数的类型 TransferEventType 是一个联合类型 —— 'transmit' | 'receive'

  • listener:表示事件监听器,它支持的事件类型为 DataTransferEvent,该类型也是一个联合类型 —— DragEvent | ClipboardEvent,即支持拖拽事件和剪贴板事件。

  • options:表示配置对象,用于设置是否允许拖拽和复制、粘贴操作。


addListeners 函数体中,主要包含以下 3 个步骤:



  • 步骤 ①:根据 isTransmitEventoptions.copyPaste 的值,注册剪贴板相关的事件。

  • 步骤 ②:根据 isTransmitEventoptions.dragDrop 的值,注册拖拽相关的事件。

  • 步骤 ③:返回函数对象,用于移除已注册的事件监听。


// src/transmat.ts
export function addListeners<T extends Node>(
target: T,
type: TransferEventType, // 'transmit' | 'receive'
listener: (event: DataTransferEvent, target: T) => void,
options = {dragDrop: true, copyPaste: true}
): () => void {
const isTransmitEvent = type === 'transmit';
let unlistenCopyPaste: undefined | (() => void);
let unlistenDragDrop: undefined | (() => void);

if (options.copyPaste) {
// ① 可拖拽源监听cut和copy事件,可释放目标监听paste事件
const events = isTransmitEvent ? ['cut', 'copy'] : ['paste'];
const parentElement = target.parentElement!;
unlistenCopyPaste = addEventListeners(parentElement, events, event => {
if (!target.contains(document.activeElement)) {
return;
}
listener(event as DataTransferEvent, target);

if (event.type === 'copy' || event.type === 'cut') {
event.preventDefault();
}
});
}

if (options.dragDrop) {
// ② 可拖拽源监听dragstart事件,可释放目标监听dragover和drop事件
const events = isTransmitEvent ? ['dragstart'] : ['dragover', 'drop'];
unlistenDragDrop = addEventListeners(target, events, event => {
listener(event as DataTransferEvent, target);
});
}

// ③ 返回函数对象,用于移除已注册的事件监听
return () => {
unlistenCopyPaste && unlistenCopyPaste();
unlistenDragDrop && unlistenDragDrop();
};
}

以上代码的事件监听最终是通过调用 addEventListeners 函数来实现,在该函数内部会循环调用 addEventListener 方法来添加事件监听。以前面 Transmat 的使用示例为例,在对应的事件处理回调函数内部,我们会以 event 事件对象为参数,调用 Transmat 构造函数创建 Transmat 实例。那么该实例有什么作用呢?要搞清楚它的作用,我们就需要来了解 Transmat 类。


3.2 Transmat 类


Transmat 类被定义在 src/transmat.ts 文件中,该类的构造函数含有一个类型为 DataTransferEvent 的参数 event


// src/transmat.ts
export class Transmat {
public readonly event: DataTransferEvent;
public readonly dataTransfer: DataTransfer;

// type DataTransferEvent = DragEvent | ClipboardEvent;
constructor(event: DataTransferEvent) {
this.event = event;
this.dataTransfer = getDataTransfer(event);
}
}

Transmat 构造函数内部还会通过 getDataTransfer 函数来获取 DataTransfer 对象并赋值给内部的 dataTransfer 属性。DataTransfer 对象用于保存拖动并放下(drag and drop)过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。


下面我们来看一下 getDataTransfer 函数的具体实现:


// src/data_transfer.ts
export function getDataTransfer(event: DataTransferEvent): DataTransfer {
const dataTransfer =
(event as ClipboardEvent).clipboardData ??
(event as DragEvent).dataTransfer;
if (!dataTransfer) {
throw new Error('No DataTransfer available at this event.');
}
return dataTransfer;
}

在以上代码中,使用了空值合并运算符 ??。该运算符的特点是:当左侧操作数为 null 或 undefined 时,其返回右侧的操作数,否则返回左侧的操作数。即先判断是否为剪贴板事件,如果是的话就会从 clipboardData 属性获取 DataTransfer 对象。否则,就会从 dataTransfer 属性获取。


对于可拖拽源,在创建完 Transmat 对象之后,我们就可以调用该对象上的 setData 方法保存一项或多项数据。比如,在以下代码中,我们设置了不同类型的多项数据:


transmat.setData({
"text/plain": "大家好,我是阿宝哥!",
"text/html": `
<h1>大家好,我是阿宝哥</h1>
...
`,
"text/uri-list": "https://juejin.cn/user/764915822103079",
"application/json": {
name: "阿宝哥",
wechat: "semlinker",
},
});

了解完 setData 方法的用法之后,我们来看一下它的具体实现:


// src/transmat.ts
setData(
typeOrEntries: string | {[type: string]: unknown},
data?: unknown
): void {
if (typeof typeOrEntries === 'string') {
this.setData({[typeOrEntries]: data});
} else {
// 处理多种类型的数据
for (const [type, data] of Object.entries(typeOrEntries)) {
const stringData =
typeof data === 'object' ? JSON.stringify(data) : `${data}`;
this.dataTransfer.setData(normalizeType(type), stringData);
}
}
}

由以上代码可知,在 setData 方法内部最终会调用 dataTransfer.setData 方法来保存数据。dataTransfer 对象的 setData 方法支持两个字符串类型的参数:formatdata。它们分别表示要保存的数据格式和实际的数据。如果给定数据格式不存在,则将对应的数据保存到末尾。如果给定数据格式已存在,则将使用新的数据替换旧的数据


下图是 dataTransfer.setData 方法的兼容性说明,由图可知主流的现代浏览器都支持该方法。



(图片来源:caniuse.com/mdn-api_dat…


Transmat 类除了拥有 setData 方法之外,它也含有一个 getData 方法,用于获取已保存的数据。getData 方法支持一个字符串类型的参数 type,用于表示数据的类型。在获取数据前,会调用 hasType 方法判断是否含有该类型的数据。如果有包含的话,就会通过 dataTransfer 对象的 getData 方法来获取该类型对应的数据。


// src/transmat.ts
getData(type: string): string | undefined {
return this.hasType(type)
? this.dataTransfer.getData(normalizeType(type))
: undefined;
}

此外,在调用 getData 方法前,还会调用 normalizeType 函数,对传入的 type 类型参数进行标准化操作。具体的如下所示:


// src/data_transfer.ts
export function normalizeType(input: string) {
const result = input.toLowerCase();
switch (result) {
case 'text':
return 'text/plain';
case 'url':
return 'text/uri-list';
default:
return result;
}
}

同样,我们也来看一下 dataTransfer.getData 方法的兼容性:



(图片来源:caniuse.com/mdn-api_dat…


好的,Transmat 类中的 setDatagetData 这两个核心方法就先介绍到这里。接下来我们来介绍另一个类 —— TransmatObserver 。


3.3 TransmatObserver 类


TransmatObserver 类的作用是可以帮助我们响应用户的拖拽行为,可用于在拖拽过程中高亮放置区域。比如,在前面的示例中,我们通过以下方式来实现放置区域的高亮效果:


const obs = new TransmatObserver((entries) => {
for (const entry of entries) {
const transmat = new Transmat(entry.event);
if (transmat.hasType("application/json")) {
entry.target.classList.toggle("drag-active", entry.isActive);
entry.target.classList.toggle("drag-over", entry.isTarget);
}
}
});
obs.observe(target);

同样,我们先来分析一下 TransmatObserver 类的构造函数:


// src/transmat_observer.ts
export class TransmatObserver {
private readonly targets = new Set<Element>(); // 观察的目标集合
private prevRecords: ReadonlyArray<TransmatObserverEntry> = []; // 保存前一次的记录
private removeEventListeners = () => {};

constructor(private readonly callback: TransmatObserverCallback) {}
}

由以上代码可知,TransmatObserver 类的构造函数支持一个类型为 TransmatObserverCallback 的参数 callback,该参数对应的类型定义如下:


// src/transmat_observer.ts
export type TransmatObserverCallback = (
entries: ReadonlyArray<TransmatObserverEntry>,
observer: TransmatObserver
) => void;

TransmatObserverCallback 函数类型接收两个参数:entriesobserver。其中 entries 参数的类型是一个


只读数组(ReadonlyArray),数组中每一项的类型是 TransmatObserverEntry,对应的类型定义如下:


// src/transmat_observer.ts
export interface TransmatObserverEntry {
target: Element;
/** type DataTransferEvent = DragEvent | ClipboardEvent */
event: DataTransferEvent;
/** Whether a transfer operation is active in this window. */
isActive: boolean;
/** Whether the element is the active target (dragover). */
isTarget: boolean;
}

在前面 transmat-target 的示例中,当创建完 TransmatObserver 实例之后,就会调用该实例的 observe 方法并传入待观察的对象。observe 方法的实现并不复杂,具体如下所示:


// src/transmat_observer.ts
observe(target: Element) {
/** private readonly targets = new Set<Element>(); */
this.targets.add(target);
if (this.targets.size === 1) {
this.addEventListeners();
}
}

observe 方法内部,会把需观察的元素保存到 targets Set 集合中。当 targets 集合的大小等于 1 时,就会调用当前实例的 addEventListeners 方法来添加事件监听:


// src/transmat_observer.ts
private addEventListeners() {
const listener = this.onTransferEvent as EventListener;
this.removeEventListeners = addEventListeners(
document,
['dragover', 'dragend', 'dragleave', 'drop'],
listener,
true
);
}

在私有的 addEventListeners 方法内部,会利用我们前面介绍的 addEventListeners 函数来为 document 元素批量添加与拖拽相关的事件监听。而对应的事件说明如下所示:



  • dragover:当元素或选中的文本被拖到一个可释放目标上时触发;

  • dragend:当拖拽操作结束时触发(比如松开鼠标按键);

  • dragleave:当拖拽元素或选中的文本离开一个可释放目标时触发;

  • drop:当元素或选中的文本在可释放目标上被释放时触发。


其实与拖拽相关的事件并不仅仅只有以上四种,如果你对完整的事件感兴趣的话,可以阅读 MDN 上 HTML 拖放 API 这篇文章。下面我们来重点分析 onTransferEvent 事件监听器:


private onTransferEvent = (event: DataTransferEvent) => {
const records: TransmatObserverEntry[] = [];
for (const target of this.targets) {
// 当光标离开浏览器时,对应的事件将会被派发到body或html节点
const isLeavingDrag =
event.type === 'dragleave' &&
(event.target === document.body ||
event.target === document.body.parentElement);

// 页面上是否有拖拽行为发生
// 当拖拽操作结束时触发dragend事件
// 当元素或选中的文本在可释放目标上被释放时触发drop事件
const isActive = event.type !== 'drop'
&& event.type !== 'dragend' && !isLeavingDrag;

// 判断可拖拽的元素是否被拖到target元素上
const isTargetNode = target.contains(event.target as Node);
const isTarget = isActive && isTargetNode
&& event.type === 'dragover';

records.push({
target,
event,
isActive,
isTarget,
});
}

// 仅当记录发生变化的时候,才会调用回调函数
if (!entryStatesEqual(records, this.prevRecords)) {
this.prevRecords = records as ReadonlyArray<TransmatObserverEntry>;
this.callback(records, this);
}
}

在以上代码中,使用了 node.contains(otherNode) 方法来判断可拖拽的元素是否被拖到 target 元素上。当 otherNodenode 的后代节点或者 node 节点本身时,返回 true,否则返回 false。此外,为了避免频繁地触发回调函数,在调用回调函数前会先调用 entryStatesEqual 函数来检测记录是否发生变化。entryStatesEqual 函数的实现比较简单,具体如下所示:


// src/transmat_observer.ts
function entryStatesEqual(
a: ReadonlyArray<TransmatObserverEntry>,
b: ReadonlyArray<TransmatObserverEntry>
): boolean {
if (a.length !== b.length) {
return false;
}
// 如果有一项不匹配,则立即返回false。
return a.every((av, index) => {
const bv = b[index];
return av.isActive === bv.isActive && av.isTarget === bv.isTarget;
});
}

MutationObserver 一样,TransmatObserver 也提供了用于获取最近已触发记录的 takeRecords 方法和用于 “断开” 连接的 disconnect 方法:


// 返回最近已触发记录
takeRecords() {
return this.prevRecords;
}

// 移除所有目标及事件监听器
disconnect() {
this.targets.clear();
this.removeEventListeners();
}

到这里 Transmat 源码分析的相关内容已经介绍完了,如果你对该项目感兴趣的话,可以自行阅读该项目的完整源码。该项目是使用 TypeScript 开发,已入门 TypeScript 的小伙伴可以利用该项目巩固一下所学的 TS 知识及 OOP 面向对象的设计思想。



链接:https://juejin.cn/post/6984587700951056414

收起阅读 »

JS循环大总结, for, forEach,for in,for of, map区别

map(数组方法): 特性: map不改变原数组但是会 返回新数组 可以使用break中断循环,可以使用return返回到外层函数 实例: let newarr=arr.map(i=>{ return i+=1; console.log(i); })...
继续阅读 »

map(数组方法):


特性:



  1. map不改变原数组但是会 返回新数组

  2. 可以使用break中断循环,可以使用return返回到外层函数


实例:


let newarr=arr.map(i=>{
return i+=1;
console.log(i);
})
console.log(arr)//1,3,4---不会改变原数组
console.log(newarr)//[2,4,5]---返回新数组

forEach(数组方法):


特性:



  1. 便利的时候更加简洁,效率和for循环相同,不用关心集合下标的问题,减少了出错的概率。

  2. 没有返回值

  3. 不能使用break中断循环,不能使用return返回到外层函数


实例:


let newarr=arr.forEach(i=>{
i+=1;
console.log(i);//2,4,5
})
console.log(arr)//[1,3,4]
console.log(newarr)//undefined

注意:



  1. forEach() 对于空数组是不会执行回调函数的。

  2. for可以用continue跳过循环中的一个迭代,forEach用continue会报错。

  3. forEach() 需要用 return 跳过循环中的一个迭代,跳过之后会执行下一个迭代。


for in(大部分用于对象):


用于循环遍历数组或对象属性


特性:


可以遍历数组的键名,遍历对象简洁方便
###实例:


   let person={name:"小白",age:28,city:"北京"}
let text=""
for (let i in person){
text+=person[i]
}
输出结果为:小白28北京
//其次在尝试一些数组
let arry=[1,2,3,4,5]
for (let i in arry){
console.log(arry[i])
}
//能输出出来,证明也是可以的

for of(不能遍历对象):


特性:



  1. (可遍历map,object,array,set string等)用来遍历数据,比如组中的值

  2. 避免了for in的所有缺点,可以使用break,continue和return,不仅支持数组的遍历,还可以遍历类似数组的对象。


   let arr=["nick","freddy","mike","james"];
for (let item of arr){
console.log(item)
}
//暑促结果为nice freddy mike james
//遍历对象
let person={name:"老王",age:23,city:"唐山"}
for (let item of person){
console.log(item)
}
//我们发现它是不可以的
//但是它和forEach有个解决方法,结尾介绍

总结:



  • forEach 遍历列表值,不能使用 break 语句或使用 return 语句

  • for in 遍历对象键值(key),或者数组下标,不推荐循环一个数组

  • for of 遍历列表值,允许遍历 Arrays(数组), Strings(字符串), Maps(映射), Sets(集合)等可迭代的数据结构等.在 ES6 中引入的 for of 循环,以替代 for in 和 forEach() ,并支持新的迭代协议。

  • for in循环出的是key,for of循环出的是value;

  • for of是ES6新引入的特性。修复了ES5的for in的不足;

  • for of不能循环普通的对象,需要通过和Object.keys()搭配使用。


链接:https://juejin.cn/post/6983313955233988644

收起阅读 »

Vue3+Vite+TS+Eslint(Airbnb规则)搭建生产项目,踩坑详记(三):引入Element-plus,解决字体文件404问题

vue
今天我们来看引入大杯Element,其实引入很简单,跟着文档操作就完事了。所以这篇文章重点是看如何修改主题以及在修改主题中我遇到的问题。 废话少说,开整! 引入Element-plus npm install element-plus --save // m...
继续阅读 »

今天我们来看引入大杯Element,其实引入很简单,跟着文档操作就完事了。所以这篇文章重点是看如何修改主题以及在修改主题中我遇到的问题。


image.png


废话少说,开整!


引入Element-plus


npm install element-plus --save

// main.ts
import { createApp } from 'vue';
import ElementPlus from 'element-plus'; // ++
import 'element-plus/lib/theme-chalk/index.css'; // ++
import App from './App.vue';

createApp(App).use(ElementPlus).mount('#app'); // edit

此时在项目中引入Element组件测试发现,已经可以正常使用了。


image.png


修改Element主题


在Element文档中有如何修改主题的教程,我们项目中主要的需求就是修改主题色,因此本文也以修改主题色为例子。


创建文件


首先我新增了两个文件,color.sass 和 element-theme.sass(这里假设你的项目已经引入了sass)。之所以创建两个文件,是因为 color.sass 除了给element主题提供颜色配置,还会引入为全局变量,方便在组件中使用。


image.png


配置主题


// color.sass
$--color-primary: red

// element-theme.sass
@improt "./color.sass" // 引入主题色文件

$--font-path: '~element-plus/lib/theme-chalk/fonts'
@import "~element-plus/packages/theme-chalk/src/index"

// main.ts
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/lib/theme-chalk/index.css'; // --
import './styles/element-theme.sass'; // ++
import App from './App.vue';

createApp(App).use(ElementPlus).mount('#app');

如上按照element官网给的例子引入以后,在vite项目中会报错。


image.png
这是因为 ~ 这种路径写法是vue-cli中的约定,它使我们可以引用node_modules包内的资源,详见文档:URL转换规则
image.png


所以我们在这里需要把路径 ~element-plus 改成 node_modules/element-plus。也就是文件变成了这个样子:


@import "./color.sass"

$--font-path: 'node_modules/element-plus/lib/theme-chalk/fonts'
@import "node_modules/element-plus/packages/theme-chalk/src/index"

文章写到这里的时候遇到了一个尴尬的问题,在我们的生产项目搭建框架时,路径改成由 node_modules 引入后,主题色修改没有问题,可以生效。但是fonts文件加载请求报404,如下图1。但是文章用的实验项目,同样的方式修改后,一切正常,如图2。


image.png


image.png


经过反复测试后发现,是因为生产项目配置了多入口,启动项目时对应了不同的入口文件,导致引入fonts文件报错。具体原因有待研究,希望了解的兄弟不吝赐教


关于多入口文件配置以及解决element字体引入的问题,后边会有一篇文章单独介绍,这里就先不剧透了。


配置全局变量


前面单独创建了一个color.sass是为了将文件里的颜色变量引入到全局,方便在组件中使用。
为了简化使用,我们可以在文件中为常用颜色额外定义简短变量,但是要注意,不能修改element需要的变量!


$--color-primary: #ff0000

$primary: #ff0000

引入全局变量需要在vite.config.ts文件中配置css预处理器,并将引入的变量文件传给预处理器。配置方式如下


// vite.config.ts
...
export default defineConfig({
...
css: {
preprocessorOptions: {
sass: {
// \n 处理文件中多个引入报换行错误的问题
additionalData: "@import './src/styles/color.sass'\n",
},
},
},
});

引入后我们在组件内进行测试


// HelloWorld.vue
<style scoped lang="sass">
a
color: $primary
</style>

可以看到页面上已经生效了
image.png


因为通过这种方式插入全局变量,会为所有的.sass文件都插入对应的文件引入,所以在前面我们定义的 element-theme.sass 文件中就可以不写 color.sass 文件的引入了。


// element-theme.sass

// @import "./color.sass" // edit

$--font-path: 'node_modules/element-plus/lib/theme-chalk/fonts'
@import "node_modules/element-plus/packages/theme-chalk/src/index"

修改默认语言


可能是为了立足中国,走向世界。使用组件时会发现大杯Element的默认语言变成了英文,我们需要自己引入并修改默认语言为中文。


// main.ts
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import './styles/element-theme.sass';
import locale from 'element-plus/lib/locale/lang/zh-cn'; // ++
import App from './App.vue';

createApp(App).use(ElementPlus, { locale }).mount('#app'); // edit

修改完成后,再去看看组件,是不是已经变成了中文。


引入大杯Element并修改主题的工作已经完成了,项目中我们就可以使用自定义主题色的Element组件。并且抛出了主题色全局变量,方便我们在组件中使用。


下次我们将一次性引入Vuex和Vue Router,这两项工作完成后,就已经完成了项目框架的雏形,可以开始开发了。不过后续我们仍然会有一些优化以及Vue3开发过程中相较于Vue2有较大变化的方法总结,整理不易,希望大家多多支持。


链接:https://juejin.cn/post/6984004322736472095

收起阅读 »

Vue3+Vite+TS+Eslint(Airbnb规则)搭建生产项目,踩坑详记(二):配置husky和lint-staged

vue
上回我们说到配置ESLint以及添加vue-recommended、airbnb-base、prettier规则,一切都很顺利。唯有一点需要注意的就是 .eslintrc 文件extends配置项中,plugin:prettier/recommended一定要...
继续阅读 »

上回我们说到配置ESLint以及添加vue-recommended、airbnb-base、prettier规则,一切都很顺利。唯有一点需要注意的就是 .eslintrc 文件extends配置项中,plugin:prettier/recommended一定要在airbnb-base之后添加,上篇文章没有看到的童鞋们可以回去看看原因。


上篇文章最后我们提到,在开发阶段进行ESLint校验,效果是一件靠自觉的事。因此我们需要在代码提交前再次执行ESLint,加强校验力度以保证Git上得到的都是优美的代码。


我们本次需要用到的工具有两个:huskylint-staged


husky


它的主要作用就是关联git的钩子函数,在执行相关git hooks时进行自定义操作,比如在提交前执行eslint校验,提交时校验commit message等等。


Install


husky官网推荐使用自动初始化命令,因为我们就按照官网推荐的方式进行安装,以npm为例


// && 连接符在vscode中会报错,建议在windows的powershell执行
npx husky-init && npm install

执行完成后,项目根目录会多出来 .husky 文件夹。


image.png


内部的_文件夹我们在此无需关心,pre-commit文件便是在git提交前会执行的操作,如图。我们可以在当前目录创建钩子文件来完成我们想要的操作。


image.png


需要注意的是,新版husky的配置方式做出了破坏性的改变,如果在使用过程中发现配置完以后没有生效,可以注意查看一下安装版本


升级方式可以查看官方文档:typicode.github.io/husky/#/?id…


配置


我们想要在提交前执行eslint校验代码,因此修改husky的pre-commit文件即可。我们在文件中添加如下代码


#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

eslint . --ext .js,.ts,.vue --fix #++ 校验所有的.js .ts .vue文件,并修复可自动修复的问题
git add . #++ 用于将自动修复后改变的文件添加到暂存区
exit 1 #++ 终止命令,用来测试钩子

此时提交代码执行commit是可以看到已经进入了pre-commit文件执行命令。但是会报错


image.png


这是因为此处执行shell命令,需要我们全局安装eslint。执行 npm install -g eslint。
安装完成后再次执行git commit,可以看到已经可以正常运行了


image.png


错误处理




  • 截图中第一个报错是书写错误,直接改掉就好。




  • 第二个错误,是因为我们的ESLint中没有配置TS的解析器,导致ESLint不能正常识别并校验TS代码。解决它,我们安装 @typescript-eslint/parser,并修改ESLint配置即可。




npm install @typescript-eslint/parser --save-dev

// .eslintrc.js
...
parserOptions: {
ecmaVersion: 12,
parser: '@typescript-eslint/parser', // ++
},
...


  • 第三个错误,它说的是我们引入的vite和@vitejs/plugin-vue两个包在 package.json 中应该是dependencies而不是devDependencies依赖。这个错误是因为airbnb-base规则设置了不允许引入开发依赖包,但是很明显我们不应该修改这两个框架生成的依赖结构。那我们看一下airbnb关于这条规则的定义


image.png
可以看到,airbnb对这条规格做了列外处理,那就很好办了,我们只需要在它的基础上,添加上上面报错的两个包。


在eslint中添加如下规则:


// .eslintrc.js
...
rules: {
...
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: [
... // 保持airbnb-base中的规则不变
'**vite**', // ++
'**@vitejs**', // ++
],
optionalDependencies: false,
},
],
}
...

修改完上述错误后,我们去掉 .husky/pre-commit 文件中 exit 1 这行代码,再次执行提交操作,可以看到,已经可以提交成功了。


image.png


思考


通过配置husky,我们已经实现了在提交前对代码进行检查。但是eslint配置的是 eslint . --ext .js,.ts,.vue --fix,检查所有的js、ts、vue文件,随着项目代码越来越多,每次提交前校验所有代码显然是不现实的。所以需要一个办法每次只检查新增或修改的文件。


这就需要开头提到的第二个工具来祝我们一臂之力了。


lint-staged


lint-staged的作用就是对暂存区的文件执行lint,可以让我们每次提交时只校验自己修改的文件。


npm install lint-staged --save-dev

配置lint-staged


安装完成后,在package.json文件中添加lint-staged的配置


// package.json
...
"scripts": {
...
"lint-staged": "lint-staged"
},
"lint-staged": {
// 校验暂存区的ts、js、vue文件
"*.{ts,js,vue}": [
"eslint --fix",
"git add ."
]
}

添加scripts里的lint-staged命令,是因为不建议全局安装lint-staged,以防在其他同学电脑上没有全局安装导致运行报错。


修改husky


添加lint-staged配置后,husky就不在需要直接调用eslint了。修改pre-commit文件如下:


#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

# eslint . --ext .js,.ts,.vue --fix
# git add .
# exit 1
npm run lint-staged

lint-staged配置后,我们不再需要配置husky时全局安装的eslint,因为lint-staged可以检测项目里局部安装的脚本。同时,不建议全局安装脚本,原因同上。


测试


到此,提交阶段对代码执行lint需要的配置我们已经完成了。再次提交代码测试,可以看到commit后执行的命令已经变成了lint-staged。


image.png


下一篇踩坑记我们将引入Element-plus,详细介绍其中遇到的问题,并修改element组件主题。


链接:https://juejin.cn/post/6982876819292684318
收起阅读 »

Vue3+Vite+TS+Eslint(Airbnb规则)搭建生产项目,踩坑详记(一)

vue
前段时间领导告知公司将开启一个全新的项目。 从零开始,如果不尝试一下最近火热的 Vue3 + Vite 岂不是白白浪费了这么好的吃螃蟹的机会。 说干就干,然后就开始读各种文档,从 0 开始,一步一步搭完这个项目到可以正常开发,这对于我一个第一次搭生产项目的菜鸡...
继续阅读 »

前段时间领导告知公司将开启一个全新的项目。


从零开始,如果不尝试一下最近火热的 Vue3 + Vite 岂不是白白浪费了这么好的吃螃蟹的机会。


说干就干,然后就开始读各种文档,从 0 开始,一步一步搭完这个项目到可以正常开发,这对于我一个第一次搭生产项目的菜鸡来说,着实艰难。


到今天,项目已经进入联调阶段,并且已经在环境上部署成功可以正常访问。这个实验也算是有了阶段性的成功吧,因此来写文章记录此次Vue3项目搭建历险记。


下载.jfif


第一篇文章主要是项目初始化和ESLint导入,废话不多说,开整。


初始化项目


image.png
按照自己需要的框架选择就可以了,我这里用的Vue3+TS。
初始化完成后的目录结构如下:


image.png


启动项目


执行 npm run dev,大概率会直接报错,因为项目默认启动在3000端口,可能会被拒绝。


image.png


解决这个问题,我们需要在根目录下的 vite.config.ts 文件中修改开发服务器的配置,手动指定端口号。


image.png


修改完成后重新启动项目,就可以访问了。


image.png


添加ESLint支持


安装ESLint



  • eslint只有开发阶段需要,因此添加到开发阶段的依赖中即可


npm install eslint --save-dev


  • 在VS Code中安装eslint插件,以在开发中自动进行eslint校验


配置ESLint


创建 .eslintrc.js 文件


添加基础配置


module.exports = {
root: true,
env: {
browser: true, // browser global variables
es2021: true, // adds all ECMAScript 2021 globals and automatically sets the ecmaVersion parser option to 12.
},
parserOptions: {
ecmaVersion: 12,
},
}

引入规则


为了规范团队成员代码格式,以及保持统一的代码风格,项目采用当前业界最火的 Airbnb规范 ,并引入代码风格管理工具 Prettier


eslint-plugin-vue


ESLint官方提供的Vue插件,可以检查 .vue文件中的语法错误


npm install eslint-plugin-vue

// .eslintrc.js
...
extends: [
'plugin:vue/vue3-recommended' // ++
]
...

eslint-config-airbnb-base


Airbnb基础规则的eslint插件


// npm version > 5
npx install-peerdeps --dev eslint-config-airbnb-base

// .eslintrc.js
...
extends: [
'plugin:vue/vue3-recommended',
'airbnb-base', // ++
],
...

这个时候就应该可以看到一些项目原有代码的eslint报错信息了,如果没有的话,可以尝试重启编辑器的eslint服务。


eslint-plugin-prettier


本次项目不单独引入prettier,而是使用eslint插件将prettier作为eslint规则执行。


npm install --save-dev eslint-plugin-prettier
npm install --save-dev --save-exact prettier

// .eslintrc.js
...
plugins: ['prettier'], // ++
rules: {
'prettier/prettier': 'error', // ++
},
...

配置到此时,大概率会遇到 eslint 规则和 prettier 规则冲突的情况,比如下图。eslint告诉我们要使用单引号,但是改为单引号以后,prettier有告诉我们要使用双引号。


image.png


image.png


这时候就需要另一个eslint的插件 eslint-config-prettier,这个插件的作用是禁用所有与格式相关的eslint规则,也就是说把所有格式相关的校验都交给 prettier 处理。


npm install --save-dev eslint-config-prettier

// .eslintrc.js
...
plugins: ['prettier'],
extends: [
'plugin:vue/vue3-recommended',
'airbnb-base',
'plugin:prettier/recommended', // ++
],
rules: {
'prettier/prettier': 'error',
},
...

plugin:prettier/recommended 的配置需要注意的是,一定要放在最后。因为extends中后引入的规则会覆盖前面的规则。


我们还可以在根目录新建 .prettierrc.js 文件自定义 prettier 规则,保存规则后,重启编辑器的eslint服务以更新编辑器读取的配置文件。


// .prettierrc.js
module.exports = {
singleQuote: true, // 使用单引号
}

到此,我们的ESLint基本配置结束了,后续需要时可以对规则进行调整。


这篇文章到这里就结束了,但是只在开发阶段约束代码风格是一件靠自觉性的是,因为我们还需要增强ESLint的约束度。下一篇文章,我们一起研究如果在提交代码前进行ESLint二次校验,保证提交到Git的代码都是符合规定的~


链接:https://juejin.cn/post/6982529246480564238

收起阅读 »

有趣的JS存储

今天给大家分享一下关于JS存储的问题。 建议阅读时间:5-10分钟。 序章 首先看一道经典的关于JS存储的题目,来一场紧张又刺激的脑内吃鸡大战吧: var a = {n:1};a.x = a = {n:2};console.log(a.x);console....
继续阅读 »

今天给大家分享一下关于JS存储的问题。


建议阅读时间:5-10分钟。




序章


首先看一道经典的关于JS存储的题目,来一场紧张又刺激的脑内吃鸡大战吧:


var a = {n:1};
a.x = a = {n:2};
console.log(a.x);
console.log(a);·


问输出?
想必大家心中都有答案了 ...
结果很显然是有趣的,


image.png


到这里有部分现场观众朋友就问了,这特喵咋undefined?不是赋值了吗?别急先别骂人,往下看:




探索时刻


我们先将代码这样修改:


a.x = a = {n:2};   ---- >  a = a.x = {n:2};

image.png


结果显然是一致的,不论是先给 a 赋值还是先给 a.x 赋值结果都是一致的,
查了一些资料后,得知这等式中 . 的优先级别是最高的,


因此这题的思路:


JS会把变量存到栈中,而对象则会存在堆中。


image.png



  1. 第一行代码:变量 a 的指针指向堆栈;

  2. 第二行代码:a.x = a = {n:2}; 堆1中的变量对像X指向堆2 { n:2 }, 接着给a赋值 a={n:2} ,a的指针被改变指向堆2,然后堆1没有被指针指向,被GC回收,因此输出的 a.x 是underfinde 而 a 的值是 {n:2};


理解上述代码只需要稍微理解一下js变量储存:


大家都知道,JavaScript中的变量类型分为两种,一种是基本数据类型,另外一种就是引用类型


两种数据类型的存储方式在JS中也有所不同。


另外,内存分为栈区(stack)和堆区(heap),然后在JS中开发人员并不能直接操作堆区,堆区数据由JS引擎操作完成,那这二者在存储数据上到底有什么区别呢?




揭晓时刻


一幅图告诉你:


image.png


 JS中变量的定义在内存中包括三个部分:



  • 变量标示  (比如上图中的Str,变量标示存储在内存的栈区)

  • 变量值   (比如上面中的Str的值souvenir或者是obj1对象的指向堆区地址,这个值也是存储在栈区)

  • 对象   (比如上图中的对象1或者对象2,对象存储在堆区)


也就是说,对于基本数据类型来说,只使用了内存的栈区。
我们再做一个有趣的改动:


var a = {n:1};
var b=a;
a.x = a = {n:2};
console.log(a.x);
console.log(a);
console.log(b);
console.log(b.x);

可以看到我们并没有对 b 进行操作但是 b.x 等于{n:2},这是一个被操作过的值,就如上述可知 b的指针指向堆1,所以堆没有被回收,而被显示出来了 ~


从这么一个简单例子,你是否对JS存储机制有了新的认识呢 ~


链接:https://juejin.cn/post/6983978244596826142

收起阅读 »

petite-vue源码分析:无虚拟DOM的极简版Vue

vue
最近发现Vue增加了一个petite-vue的仓库,大概看了一下,这是一个无虚拟DOM的mini版Vue,前身貌似是vue-lite(瞎猜的~),主要用于在服务端渲染的HTML页面中上"sprinkling"(点缀)一些Vue式的交互。颇有意思,于是看了下源码...
继续阅读 »

最近发现Vue增加了一个petite-vue的仓库,大概看了一下,这是一个无虚拟DOM的mini版Vue,前身貌似是vue-lite(瞎猜的~),主要用于在服务端渲染的HTML页面中上"sprinkling"(点缀)一些Vue式的交互。颇有意思,于是看了下源码(v0.2.3),整理了本文。



起步


开发调试环境


整个项目的开发环境非常简单


git clone git@github.com:vuejs/petite-vue.git

yarn

# 使用vite启动
npm run dev

# 访问http://localhost:3000/

(不得不说,用vite来搭开发环境还是挺爽的~


新建一个测试文件exmaples/demo.html,写点代码


<script type="module">
import { createApp, reactive } from '../src'

createApp({
msg: "hello"
}).mount("#app")
</script>

<div id="app">
<h1>{{msg}}</h1>
</div>

然后访问http://localhost:3000/demo.html即可


目录结构


从readme可以看见项目与标准vue的一些差异



  • Only ~5.8kb,体积很小

  • Vue-compatible template syntax,与Vue兼容的模板语法

  • DOM-based, mutates in place,基于DOM驱动,就地转换

  • Driven by @vue/reactivity,使用@vue/reactivity驱动


目录结构也比较简单,使用ts编写,外部依赖基本上只有@vue/reactivity



核心实现


createContext


从上面的demo代码可以看出,整个项目从createApp开始。


export const createApp = (initialData?: any) => {
// root context
const ctx = createContext()
if (initialData) {
ctx.scope = reactive(initialData) // 将初始化数据代理成响应式
}
// app的一些接口
return {
directive(name: string, def?: Directive) {},
mount(el?: string | Element | null) {},
unmount() {}
}
}

关于Vue3中的reactive,可以参考之前整理的:Vue3中的数据侦测reactive,这里就不再展开了。


createApp中主要是通过createContext创建根context,这个上下文现在基本不陌生了,来看看createContext


export const createContext = (parent?: Context): Context => {
const ctx: Context = {
...parent,
scope: parent ? parent.scope : reactive({}),
dirs: parent ? parent.dirs : {}, // 支持的指令
effects: [],
blocks: [],
cleanups: [],
// 提供注册effect回调的接口,主要使用调度器来控制什么时候调用
effect: (fn) => {
if (inOnce) {
queueJob(fn)
return fn as any
}
// @vue/reactivity中的effect方法
const e: ReactiveEffect = rawEffect(fn, {
scheduler: () => queueJob(e)
})
ctx.effects.push(e)
return e
}
}
return ctx
}

稍微看一下queueJob就可以发现,还是Vue中熟悉的nextTick实现,



  • 通过一个全局变量queue队列保存回调

  • 在下一个微任务处理阶段,依次执行queue中的每一个回调,然后清空queue


mount


基本使用


createApp().mount("#app")

mount方法最主要的作用就是处理el参数,找到应用挂载的根DOM节点,然后执行初始化流程


mount(el?: string | Element | null) {
let roots: Element[]
// ...根据el参数初始化roots
// 根据el创建Block实例
rootBlocks = roots.map((el) => new Block(el, ctx, true))
return this
}

Block是一个抽象的概念,用于统一DOM节点渲染、插入、移除和销毁等操作。


下图是依赖这个Block的地方,可以看见主要在初始化、iffor这三个地方使用



看一下Block的实现


// src/block.ts
export class Block {
template: Element | DocumentFragment
ctx: Context
key?: any
parentCtx?: Context

isFragment: boolean
start?: Text
end?: Text

get el() {
return this.start || (this.template as Element)
}

constructor(template: Element, parentCtx: Context, isRoot = false) {
// 初始化this.template
// 初始化this.ctx

// 构建应用
walk(this.template, this.ctx)
}
// 主要在新增或移除时使用,可以先不用关心实现
insert(parent: Element, anchor: Node | null = null) {}
remove() {}
teardown() {}
}

这个walk方法,主要的作用是递归节点和子节点,如果之前了解过递归diff,这里应该比较熟悉。但petite-vue中并没有虚拟DOM,因此在walk中会直接操作更新DOM。


export const walk = (node: Node, ctx: Context): ChildNode | null | void => {
const type = node.nodeType
if (type === 1) {
// 元素节点
const el = node as Element
// ...处理 如v-if、v-for
// ...检测属性执行对应的指令处理 applyDirective,如v-scoped、ref等

// 先处理子节点,在处理节点自身的属性
walkChildren(el, ctx)

// 处理节点属性相关的自定,包括内置指令和自定义指令
} else if (type === 3) {
// 文本节点
const data = (node as Text).data
if (data.includes('{{')) {
// 正则匹配需要替换的文本,然后 applyDirective(text)
applyDirective(node, text, segments.join('+'), ctx)
}
} else if (type === 11) {
walkChildren(node as DocumentFragment, ctx)
}
}

const walkChildren = (node: Element | DocumentFragment, ctx: Context) => {
let child = node.firstChild
while (child) {
child = walk(child, ctx) || child.nextSibling
}
}

可以看见会根据node.nodeType区分处理处理



  • 对于元素节点,先处理了节点上的一些指令,然后通过walkChildren处理子节点。

    • v-if,会根据表达式决定是否需要创建Block然后执行插入或移除

    • v-for,循环构建Block,然后执行插入



  • 对于文本节点,替换{{}}表达式,然后替换文本内容


v-if


来看看if的实现,通过branches保存所有的分支判断,activeBranchIndex通过闭包保存当前位于的分支索引值。


在初始化或更新时,如果某个分支表达式结算结果正确且与上一次的activeBranchIndex不一致,就会创建新的Block,然后走Block构造函数里面的walk。


export const _if = (el: Element, exp: string, ctx: Context) => {
const parent = el.parentElement!
const anchor = new Comment('v-if')
parent.insertBefore(anchor, el)

// 存放条件判断的各种分支
const branches: Branch[] = [{ exp,el }]

// 定位if...else if ... else 等分支,放在branches数组中

let block: Block | undefined
let activeBranchIndex: number = -1 // 通过闭包保存当前位于的分支索引值

const removeActiveBlock = () => {
if (block) {
parent.insertBefore(anchor, block.el)
block.remove()
block = undefined
}
}

// 收集依赖
ctx.effect(() => {
for (let i = 0; i < branches.length; i++) {
const { exp, el } = branches[i]
if (!exp || evaluate(ctx.scope, exp)) {
// 当判断分支切换时,会生成新的block
if (i !== activeBranchIndex) {
removeActiveBlock()
block = new Block(el, ctx)
block.insert(parent, anchor)
parent.removeChild(anchor)
activeBranchIndex = i
}
return
}
}
// no matched branch.
activeBranchIndex = -1
removeActiveBlock()
})

return nextNode
}

v-for


for指令的主要作用是循环创建多个节点,这里还根据key实现了类似于diff算法来复用Block的功能


export const _for = (el: Element, exp: string, ctx: Context) => {
// ...一些工具方法如createChildContexts、mountBlock

ctx.effect(() => {
const source = evaluate(ctx.scope, sourceExp)
const prevKeyToIndexMap = keyToIndexMap
// 根据循环项创建多个子节点的context
;[childCtxs, keyToIndexMap] = createChildContexts(source)
if (!mounted) {
// 首次渲染,创建新的Block然后insert
blocks = childCtxs.map((s) => mountBlock(s, anchor))
mounted = true
} else {
// 更新时
const nextBlocks: Block[] = []
// 移除不存在的block
for (let i = 0; i < blocks.length; i++) {
if (!keyToIndexMap.has(blocks[i].key)) {
blocks[i].remove()
}
}
// 根据key进行处理
let i = childCtxs.length
while (i--) {
const childCtx = childCtxs[i]
const oldIndex = prevKeyToIndexMap.get(childCtx.key)
const next = childCtxs[i + 1]
const nextBlockOldIndex = next && prevKeyToIndexMap.get(next.key)
const nextBlock =
nextBlockOldIndex == null ? undefined : blocks[nextBlockOldIndex]
// 不存在旧的block,直接创建
if (oldIndex == null) {
// new
nextBlocks[i] = mountBlock(
childCtx,
nextBlock ? nextBlock.el : anchor
)
} else {
// 存在旧的block,复用,检测是否需要移动位置
const block = (nextBlocks[i] = blocks[oldIndex])
Object.assign(block.ctx.scope, childCtx.scope)
if (oldIndex !== i) {
if (blocks[oldIndex + 1] !== nextBlock) {
block.insert(parent, nextBlock ? nextBlock.el : anchor)
}
}
}
}
blocks = nextBlocks
}
})

return nextNode
}

处理指令


所有的指令都是通过applyDirectiveprocessDirective来处理的,后者是基于前者的二次封装,主要处理一些内置的指令快捷方式builtInDirectives


export const builtInDirectives: Record<string, Directive<any>> = {
bind,
on,
show,
text,
html,
model,
effect
}

每种指令都是基于ctx和el等来实现快速实现某些逻辑,具体实现可以参考对应源码。


当调用app.directive注册自定义指令时,


directive(name: string, def?: Directive) {
if (def) {
ctx.dirs[name] = def
return this
} else {
return ctx.dirs[name]
}
},

实际上是向contenx的dirs添加一个属性,当调用applyDirective时,就可以得到对应的处理函数


const applyDirective = (el: Node,dir: Directive<any>,exp: string,ctx: Context,arg?: string,modifiers?: Record<string, true>) => {
const get = (e = exp) => evaluate(ctx.scope, e, el)
// 执行指令方法
const cleanup = dir({
el,
get,
effect: ctx.effect,
ctx,
exp,
arg,
modifiers
})
// 收集那些需要在卸载时清除的副作用
if (cleanup) {
ctx.cleanups.push(cleanup)
}
}

因此,可以利用上面传入的这些参数来构建自定义指令


app.directive("auto-focus", ({el})=>{
el.focus()
})

小结


整个代码看起来,确实非常精简



  • 没有虚拟DOM,就无需通过template构建render函数,直接递归遍历DOM节点,通过正则处理各种指令就行了

  • 借助@vue/reactivity,整个响应式系统实现的十分自然,除了在解析指令的使用通过ctx.effect()收集依赖,基本无需再关心数据变化的逻辑


文章开头提到,petite-vue的主要作用是:在服务端渲染的HTML页面中上"sprinkling"(点缀)一些Vue式的交互。


就我目前接触到的大部分服务端渲染HTML的项目,如果要实现一些DOM交互,一般使用



  • jQuery操作DOM,yyds

  • 当然Vue也是可以通过script + template的方式编写的,但为了一个div的交互接入Vue,又有点杀鸡焉用牛刀的感觉

  • 其他如React框架等同上


petite-vue使用了与Vue基本一致的模板语法和响应式功能,开发体验上应该很不错。且其无需考虑虚拟DOM跨平台的功能,在源码中直接使用浏览器相关API操作DOM,减少了框架runtime运行时的成本,性能方面应该也不错。


总结一下,感觉petite-vue结合了Vue标准版本的开发体验,以非常小的代码体积、良好的开发体验和还不错的运行性能,也许可以用来替代jQuery,用更现代的方式来操作DOM。


该项目是6月30号提交的第一个版本,目前相关的功能和接口应该不是特别稳定,可能会有调整。但就exmples目录中的示例而言,应该能满足一些简单的需求场景了,也许可以尝试在一些比较小型的历史项目中使用。


链接:https://juejin.cn/post/6983688046843527181

收起阅读 »

【学不动了就回家喂猪】尤大大新活 petite-vue 尝鲜

vue
前言 打开尤大大的GitHub,发现多了个叫 petite-vue 的东西,好家伙,Vue3 和 Vite 还没学完呢,又开始整新东西了?本着学不死就往死里学的态度,咱还是来瞅瞅这到底是个啥东西吧,谁让他是咱的祖师爷呢! 简介 从名字来看可以知道 peti...
继续阅读 »


前言


image.png


打开尤大大的GitHub,发现多了个叫 petite-vue 的东西,好家伙,Vue3 和 Vite 还没学完呢,又开始整新东西了?本着学不死就往死里学的态度,咱还是来瞅瞅这到底是个啥东西吧,谁让他是咱的祖师爷呢!


简介


image.png


从名字来看可以知道 petite-vue 是一个 mini 版的vue,大小只有5.8kb,可以说是非常小了。据尤大大介绍,petite-vue 是 Vue 的可替代发行版,针对渐进式增强进行了优化。它提供了与标准 Vue 相同的模板语法和响应式模型:



  • 大小只有5.8kb

  • Vue 兼容模版语法

  • 基于DOM,就地转换

  • 响应式驱动


上活


下面对 petite-vue 的使用做一些介绍。


简单使用


<body>
<script src="https://unpkg.com/petite-vue" defer init></script>
<div v-scope="{ count: 0 }">
<button @click="count--">-</button>
<span>{{ count }}</span>
<button @click="count++">+</button>
</div>
</body>

通过 script 标签引入同时添加 init ,接着就可以使用 v-scope 绑定数据,这样一个简单的计数器就实现了。



了解过 Alpine.js 这个框架的同学看到这里可能有点眼熟了,两者语法之间是很像的。



<!--  Alpine.js  -->
<div x-data="{ open: false }">
<button @click="open = true">Open Dropdown</button>
<ul x-show="open" @click.away="open = false">
Dropdown Body
</ul>
</div>

除了用 init 的方式之外,也可以用下面的方式:


<body>
<div v-scope="{ count: 0 }">
<button @click="count--">-</button>
<span>{{ count }}</span>
<button @click="count++">+</button>
</div>
<!-- 放在body底部 -->
<script src="https://unpkg.com/petite-vue"></script>
<script>
PetiteVue.createApp().mount()
</script>
</body>

或使用 ES module 的方式:


<body>
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
createApp().mount()
</script>

<div v-scope="{ count: 0 }">
<button @click="count--">-</button>
<span>{{ count }}</span>
<button @click="count++">+</button>
</div>
</body>

根作用域


createApp 函数可以接受一个对象,类似于我们平时使用 data 和 methods 一样,这时 v-scope 不需要绑定值。


<body>
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
createApp({
count: 0,
increment() {
this.count++
},
decrement() {
this.count--
}
}).mount()
</script>

<div v-scope>
<button @click="decrement">-</button>
<span>{{ count }}</span>
<button @click="increment">+</button>
</div>
</body>

指定挂载元素


<body>
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
createApp({
count: 0
}).mount('#app')
</script>

<div id="app">
{{ count }}
</div>
</body>

生命周期


可以监听每个元素的生命周期事件。


<body>
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
createApp({
onMounted1(el) {
console.log(el) // <span>1</span>
},
onMounted2(el) {
console.log(el) // <span>2</span>
}
}).mount('#app')
</script>

<div id="app">
<span @mounted="onMounted1($el)">1</span>
<span @mounted="onMounted2($el)">2</span>
</div>
</body>

组件


在 petite-vue 里,组件可以使用函数的方式创建,通过template可以实现复用。


<body>
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'

function Counter(props) {
return {
$template: '#counter-template',
count: props.initialCount,
increment() {
this.count++
},
decrement() {
this.count++
}
}
}

createApp({
Counter
}).mount()
</script>

<template id="counter-template">
<button @click="decrement">-</button>
<span>{{ count }}</span>
<button @click="increment">+</button>
</template>

<!-- 复用 -->
<div v-scope="Counter({ initialCount: 1 })"></div>
<div v-scope="Counter({ initialCount: 2 })"></div>
</body>

全局状态管理


借助 reactive 响应式 API 可以很轻松的创建全局状态管理


<body>
<script type="module">
import { createApp, reactive } from 'https://unpkg.com/petite-vue?module'

const store = reactive({
count: 0,
increment() {
this.count++
}
})
// 将count加1
store.increment()
createApp({
store
}).mount()
</script>

<div v-scope>
<!-- 输出1 -->
<span>{{ store.count }}</span>
</div>
<div v-scope>
<button @click="store.increment">+</button>
</div>
</body>

自定义指令


这里来简单实现一个输入框自动聚焦的指令。


<body>
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'

const autoFocus = (ctx) => {
ctx.el.focus()
}

createApp().directive('auto-focus', autoFocus).mount()
</script>

<div v-scope>
<input v-auto-focus />
</div>
</body>

内置指令



  • v-model

  • v-if / v-else / v-else-if

  • v-for

  • v-show

  • v-html

  • v-text

  • v-pre

  • v-once

  • v-cloak



注意:v-for 不需要key,另外 v-for 不支持 深度解构



<body>
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'

createApp({
userList: [
{ name: '张三', age: { a: 23, b: 24 } },
{ name: '李四', age: { a: 23, b: 24 } },
{ name: '王五', age: { a: 23, b: 24 } }
]
}).mount()
</script>

<div v-scope>
<!-- 支持 -->
<li v-for="{ age } in userList">
{{ age.a }}
</li>
<!-- 不支持 -->
<li v-for="{ age: { a } } in userList">
{{ a }}
</li>
</div>
</body>

不支持


为了更轻量小巧,petite-vue 不支持以下特性:



  • ref()、computed

  • render函数,因为petite-vue 没有虚拟DOM

  • 不支持Map、Set等响应类型

  • Transition, KeepAlive, Teleport, Suspense

  • v-on="object"

  • v-is &

  • v-bind:style auto-prefixing


总结


以上就是对 petite-vue 的一些简单介绍和使用,抛砖引玉,更多新的探索就由你们去发现了。


总的来说,prtite-vue 保留了 Vue 的一些基础特性,这使得 Vue 开发者可以无成本使用,在以往,当我们在开发一些小而简单的页面想要引用 Vue 但又常常因为包体积带来的考虑而放弃,现在,petite-vue 的出现或许可以拯救这种情况了,毕竟它真的很小,大小只有 5.8kb,大约只是 Alpine.js 的一半。


链接:https://juejin.cn/post/6983328034443132935
收起阅读 »

10张脑图带你快速入门Vue3 | 附高清原图

vue
前言 这个月重新开始学习Vue3 目前已经完结第一部分:基础部分 我将所有内容吸收整理成10张脑图,一来快速入门Vue3,二来方便以后查看 脑图 应用实例和组件实例 模板语法 配置选项 计算属性和监听器 绑定class和style 条件渲染 列表渲...
继续阅读 »

前言


这个月重新开始学习Vue3


目前已经完结第一部分:基础部分


我将所有内容吸收整理成10张脑图,一来快速入门Vue3,二来方便以后查看


脑图


应用实例和组件实例


1应用实例和组件实例.png


模板语法


2模板语法.png


配置选项


3配置选项.png


计算属性和监听器


4计算属性和监听器.png


绑定class和style


5绑定class和style.png


条件渲染


6条件渲染.png


列表渲染


7列表渲染v-for.png


事件处理


8事件处理.png


v-model及其修饰符


9v-model及其修饰符.png


组件的基本使用


10组件的基本使用.png


温馨小贴士



  1. 由于图片较多,为了避免一张张保存的麻烦


我已将上述原图已上传githubgithub.com/jCodeLife/m…



  1. 如果需要更改图片,为了方便你按照自己的习惯进行修改


我已将原始文件xmind上传github
github.com/jCodeLife/m…



链接:https://juejin.cn/post/6983867993805553671

收起阅读 »

面试官问我CORS跨域,我直接一套操作斩杀!

前言 我们都知道由于同源策略的存在,导致我们在跨域请求数据的时候非常的麻烦。首先阻挡我们的所谓同源到底是什么呢?,所谓同源就是浏览器的一个安全机制,不同源的客户端脚本没有在明确授权的情况下,不能读写对方资源。由于存在同源策略的限制,而又有需要跨域的业务,所以就...
继续阅读 »

前言


我们都知道由于同源策略的存在,导致我们在跨域请求数据的时候非常的麻烦。首先阻挡我们的所谓同源到底是什么呢?,所谓同源就是浏览器的一个安全机制,不同源的客户端脚本没有在明确授权的情况下,不能读写对方资源。由于存在同源策略的限制,而又有需要跨域的业务,所以就有了CORS的出现。


我们都知道,jsonp也可以跨域,那为什么还要使用CORS



  • jsonp只可以使用 GET 方式提交

  • 不好调试,在调用失败的时候不会返回任何状态码

  • 安全性,万一假如提供jsonp的服务存在页面注入漏洞,即它返回的javascript的内容被人控制的。那么结果是什么?所有调用这个jsonp的网站都会存在漏洞。于是无法把危险控制在一个域名下…所以在使用jsonp的时候必须要保证使用的jsonp服务必须是安全可信的。


开始CORS


CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing),他允许浏览器向跨源服务器发送XMLHttpRequest请求,从而克服啦 AJAX 只能同源使用的限制


CORS需要浏览器和服务器同时支持,整个 CORS通信过程,都是浏览器自动完成不需要用户参与,对于开发者来说,CORS的代码和正常的 ajax 没有什么差别,浏览器一旦发现跨域请求,就会添加一些附加的头信息,


CORS这么好吗,难道就没有缺点嘛?


答案肯定是NO,目前所有最新浏览器都支持该功能,但是万恶的IE不能低于10


简单请求和非简单请求


浏览器将CORS请求分成两类:简单请求和非简单请求


简单请求


凡是同时满足以下两种情况的就是简单请求,反之则非简单请求,浏览器对这两种请求的处理不一样



  • 请求方法是以下方三种方法之一

    • HEAD

    • GET

    • POST



  • HTTP的头信息不超出以下几种字段

    • Accept

    • Accept-Language

    • Content-Language

    • Last-Event-ID

    • Content-Type:只限于三个值 application/x-www-form-urlencodedmultipart/form-datatext/plain




对于简单请求来说,浏览器之间发送CORS请求,具体来说就是在头信息中,增加一个origin字段,来看一下例子


GET /cors? HTTP/1.1
Host: localhost:2333
Connection: keep-alive
Origin: http://localhost:2332
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Accept: */*
Referer: http://localhost:2332/CORS.html
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
If-None-Match: W/"1-NWoZK3kTsExUV00Ywo1G5jlUKKs"

上面的头信息中,Origin字段用来说名本次请求来自哪个源,服务器根据这个值,决定是否同意这次请求。


如果Origin指定的源不在允许范围之内,服务器就会返回一个正常的HTTP回应,然后浏览器发现头信息中没有包含Access-Control-Allow-Origin 字段,就知道出错啦,然后抛出错误,反之则会出现这个字段(实例如下)


Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8



  • Access-Control-Allow-Origin 这个字段是必须的,表示接受那些域名的请求(*为所有)




  • Access-Control-Allow-Credentials 该字段可选, 表示是否可以发送cookie




  • Access-Control-Expose-Headers 该字段可选,XHMHttpRequest对象的方法只能够拿到六种字段: Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma ,如果想拿到其他的需要使用该字段指定。




如果你想要连带Cookie一起发送,是需要服务端和客户端配合的


// 服务端
Access-Control-Allow-Credentials: true
// 客户端
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
// 但是如果省略withCredentials属性的设置,有的浏览器还是会发送cookie的
xhr.withCredentials = false;

非简单请求


非简单请求则是不满足上边的两种情况之一,比如请求的方式为 PUT,或者请求头包含其他的字段


非简单请求的CORS请求是会在正式通信之前进行一次预检请求


浏览器先询问服务器,当前网页所在的域名是否可以请求您的服务器,以及可以使用那些HTTP动词和头信息,只有得到正确的答复,才会进行正式的请求


// 前端代码
var url = 'http://localhost:2333/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

由于上面的代码使用的是 PUT 方法,并且发送了一个自定义头信息.所以是一个非简单请求,当浏览器发现这是一个非简单请求的时候,会自动发出预检请求,看看服务器可不可以接收这种请求,下面是"预检"HTTP 头信息


OPTIONS /cors HTTP/1.1
Origin: localhost:2333
Access-Control-Request-Method: PUT // 表示使用的什么HTTP请求方法
Access-Control-Request-Headers: X-Custom-Header // 表示浏览器发送的自定义字段
Host: localhost:2332
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
User-Agent: Mozilla/5.0...

"预检"使用的请求方法是 OPTIONS , 表示这个请求使用来询问的,


预检请求后的回应,服务器收到"预检"请求以后,检查了OriginAccess-Control-Request-MethodAccess-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应。


预检的响应头:


HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://localhost:2332 // 表示http://localhost:2332可以访问数据
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

如果浏览器否定了"预检"请求,会返回一个正常的HTTP回应,但是没有任何CORS的头相关信息,这是浏览器就认定,服务器不允许此次访问,从而抛出错误


预检之后的请求


当预检请求通过之后发出正经的HTTP请求,还有一个就是一旦通过了预检请求就会,请求的时候就会跟简单请求,会有一个Origin头信息字段。


通过预检之后的,浏览器发出发请求


PUT /cors HTTP/1.1
Origin: http://api.bob.com // 通过预检之后的请求,会自动带上Origin字段
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

感谢


谢谢你读完本篇文章,希望对你能有所帮助,如有问题欢迎各位指正。




链接:https://juejin.cn/post/6983852288091619342

收起阅读 »

「百毒不侵」面试官最喜欢问的13种Vue修饰符

1.lazy lazy修饰符作用是,改变输入框的值时value不会改变,当光标离开输入框时,v-model绑定的值value才会改变 <input type="text" v-model.lazy="value"> <div>{{val...
继续阅读 »

image.png


1.lazy


lazy修饰符作用是,改变输入框的值时value不会改变,当光标离开输入框时,v-model绑定的值value才会改变


<input type="text" v-model.lazy="value">
<div>{{value}}</div>

data() {
return {
value: '222'
}
}

lazy1.gif


2.trim


trim修饰符的作用类似于JavaScript中的trim()方法,作用是把v-model绑定的值的首尾空格给过滤掉。


<input type="text" v-model.trim="value">
<div>{{value}}</div>

data() {
return {
value: '222'
}
}

number.gif


3.number


number修饰符的作用是将值转成数字,但是先输入字符串和先输入数字,是两种情况


<input type="text" v-model.number="value">
<div>{{value}}</div>

data() {
return {
value: '222'
}
}


先输入数字的话,只取前面数字部分



trim.gif



先输入字母的话,number修饰符无效



number2.gif


4.stop


stop修饰符的作用是阻止冒泡


<div @click="clickEvent(2)" style="width:300px;height:100px;background:red">
<button @click.stop="clickEvent(1)">点击</button>
</div>

methods: {
clickEvent(num) {
不加 stop 点击按钮输出 1 2
加了 stop 点击按钮输出 1
console.log(num)
}
}

5.capture


事件默认是由里往外冒泡capture修饰符的作用是反过来,由外网内捕获


<div @click.capture="clickEvent(2)" style="width:300px;height:100px;background:red">
<button @click="clickEvent(1)">点击</button>
</div>

methods: {
clickEvent(num) {
不加 capture 点击按钮输出 1 2
加了 capture 点击按钮输出 2 1
console.log(num)
}
}

6.self


self修饰符作用是,只有点击事件绑定的本身才会触发事件


<div @click.self="clickEvent(2)" style="width:300px;height:100px;background:red">
<button @click="clickEvent(1)">点击</button>
</div>

methods: {
clickEvent(num) {
不加 self 点击按钮输出 1 2
加了 self 点击按钮输出 1 点击div才会输出 2
console.log(num)
}
}

7.once


once修饰符的作用是,事件只执行一次


<div @click.once="clickEvent(2)" style="width:300px;height:100px;background:red">
<button @click="clickEvent(1)">点击</button>
</div>

methods: {
clickEvent(num) {
不加 once 多次点击按钮输出 1
加了 once 多次点击按钮只会输出一次 1
console.log(num)
}
}

8.prevent


prevent修饰符的作用是阻止默认事件(例如a标签的跳转)


<a href="#" @click.prevent="clickEvent(1)">点我</a>

methods: {
clickEvent(num) {
不加 prevent 点击a标签 先跳转然后输出 1
加了 prevent 点击a标签 不会跳转只会输出 1
console.log(num)
}
}

9.native


native修饰符是加在自定义组件的事件上,保证事件能执行


执行不了
<My-component @click="shout(3)"></My-component>

可以执行
<My-component @click.native="shout(3)"></My-component>

10.left,right,middle


这三个修饰符是鼠标的左中右按键触发的事件


<button @click.middle="clickEvent(1)"  @click.left="clickEvent(2)"  @click.right="clickEvent(3)">点我</button>

methods: {
点击中键输出1
点击左键输出2
点击右键输出3
clickEvent(num) {
console.log(num)
}
}

11.passive


当我们在监听元素滚动事件的时候,会一直触发onscroll事件,在pc端是没啥问题的,但是在移动端,会让我们的网页变卡,因此我们使用这个修饰符的时候,相当于给onscroll事件整了一个.lazy修饰符


<div @scroll.passive="onScroll">...</div>

12.camel


不加camel viewBox会被识别成viewbox
<svg :viewBox="viewBox"></svg>

加了canmel viewBox才会被识别成viewBox
<svg :viewBox.camel="viewBox"></svg>

12.sync


父组件传值进子组件,子组件想要改变这个值时,可以这么做


父组件里
<children :foo="bar" @update:foo="val => bar = val"></children>

子组件里
this.$emit('update:foo', newValue)

sync修饰符的作用就是,可以简写:


父组件里
<children :foo.sync="bar"></children>

子组件里
this.$emit('update:foo', newValue)

13.keyCode


当我们这么写事件的时候,无论按什么按钮都会触发事件


<input type="text" @keyup="shout(4)">

那么想要限制成某个按键触发怎么办?这时候keyCode修饰符就派上用场了


<input type="text" @keyup.keyCode="shout(4)">

Vue提供的keyCode:


//普通键
.enter
.tab
.delete //(捕获“删除”和“退格”键)
.space
.esc
.up
.down
.left
.right
//系统修饰键
.ctrl
.alt
.meta
.shift

例如(具体的键码请看键码对应表


按 ctrl 才会触发
<input type="text" @keyup.ctrl="shout(4)">

也可以鼠标事件+按键
<input type="text" @mousedown.ctrl.="shout(4)">

可以多按键触发 例如 ctrl + 67
<input type="text" @keyup.ctrl.67="shout(4)">

链接:https://juejin.cn/post/6981628129089421326

收起阅读 »

比浏览器 F12 更好用的免费调试抓包工具 Fiddler 介绍

身为一名前端搬砖工,长久以来有两个问题困扰着我,一个是做后台项目接口返回的数据都为空,不方便做更进一步的对数据的查改及测试;另一个是做移动端的项目,比如 uniapp,每次遇到接口问题都只能 console 在 HBuilder 进行调试,苦不堪言,后来发现我...
继续阅读 »

身为一名前端搬砖工,长久以来有两个问题困扰着我,一个是做后台项目接口返回的数据都为空,不方便做更进一步的对数据的查改及测试;另一个是做移动端的项目,比如 uniapp,每次遇到接口问题都只能 console 在 HBuilder 进行调试,苦不堪言,后来发现我司 TE 同学用 Fiddler 进行抓包测试,一问这软件还是免费的,遂进行了一番学习了解,发现可以直接解决刚刚提到的这两个问题,所以在这里做个分享。


简介



  • Fiddler 是位于客户端和服务器端的 HTTP 代理

  • 目前最常用的 HTTP 抓包工具之一

  • 功能非常强大,是 web 调试的利器

    • 监控浏览器所有的 HTTP/HTTPS 流量

    • 查看、分析请求内容细节

    • 伪造客户端请求和服务器响应

    • 解密 HTTPS 的 web 会话

    • 全局、局部断点功能

    • 第三方插件



  • 使用场景

    • 接口的测试与调试

    • 线上环境调试

    • web 性能分析




下载


直接去官网下载 Fiddler Classic 即可:


image.png


原理


学习一件新事物,最好是知其然亦知其所以然,这样遇到问题心里有底,才不容易慌,下面就介绍下 Fiddler 抓包的原理。


Fiddler 是位于客户端和服务器端之间的 HTTP 代理。一旦启动 Fiddler,其会自动将代理服务器设置成本机,默认端口为 8888,并设置成系统代理(Act as system proxy on startup)。可以在 Fiddler 通过 'Tools -> Options -> Connections' 查看, 图示如下:


image.png

在 Fiddler 运行的情况下,以 Chrome 浏览器为例,可以在其 '设置 -> 高级 -> 系统 -> 打开您计算机的代理设置 -> 连接 -> 局域网(LAN)设置' 里看到,'代理服务器' 下的 '为 LAN 使用代理服务器' 选项被勾选了(如果没有运行 Fiddler,默认情况下是不会被勾选的),如下图:


image (1).png

点开 '高级',会发现 '要使用的代理服务器地址' 就是本机 ip,端口为 8888。如下图:


image (2).png

也就是说浏览器的 HTTP 请求/响应都被代理到了系统的 8888 端口,被 Fiddler 拦截了。


界面介绍


下面开始对整个 Fiddler 的界面进行一个庖丁解牛


工具栏


image.png
主要介绍上图中几个标了号的我认为比较常用的功能:



  1. Replay:重放选中的那条请求,同时按下 shift + R 键,可以输入重复发送请求的次数(这些请求是串行发送的)。可以用来做重放攻击的测试。

  2. 删除会话(sessions)

  3. 继续打了断点的请求:打断点后请求会被拦截在 Fiddler,点击这个 Go 继续发送。打断点的方式是点击界面底部的空格,具体位置如下图所示:


image (1).png



  1. 这个类似瞄准器的工具时用于选择抓取请求的应用:按住不放将鼠标拖放到目标应用即可

  2. 可用于查找某条请求,比如你知道请求参数里的某个字段,可以直接输入进行查找

  3. 编码解码工具,可以进行多种编码的转换,是个人觉得挺好用的一个工具,能够编码的格式包括但不限于 base64、md5 和 URLEncode 等

  4. 可以查看一些诸如本机 ip(包括 IPv4,IPv6) 等信息,就用不着去 cmd 里 输入ipconfig 查看了,如下图:


image (2).png


会话列表(Session List)


位于软件界面的左半部的就是会话列表了,抓取到的每条 http 请求都列在这,每一条被称为一个 session,如下图所示:

image (3).png


每条会话默认包含的信息



  • 请求的状态码(result)

  • 协议(protocol)

  • 主机名(host)

  • URL

  • 请求大小(body,以字节为单位)

  • 缓存信息(caching)

  • 响应类型(content-type)

  • 发出请求的 Windows 进程及进程 ID(process)


自定义列


除了以上这些,我们还可以添加自定义列,比如想添加一列请求方法信息:



  1. 点击菜单栏 -> Rules -> Customize Rules 调出 Fiddler ScriptEditor 窗口

  2. 按下 ctrl + f 输入 static function Main() 进行查找

  3. 然后在找到的函数 Main 里添加:


FiddlerObject.UI.lvSessions.AddBoundColumn("Method",60,getHTTPMethod );
static function getHTTPMethod(oS: Session){
if (null != oS.oRequest) return oS.oRequest.headers.HTTPMethod;
else return String.Empty;
}

图示如下:


image (4).png
4. 按下 ctrl + s 保存。然后就可以在会话列表里看到多出了名为 Method 的一列,内容为请求方法。


排序和移动



  1. 点击每一列的列表头,可以反向排序

  2. 按住列表头不放进行拖动,可以改变列表位置


QuickExec 与状态栏


位于软件界面底部的那条黑色的是 QuickExec,可用于快速执行输入的一些命令,具体命令可输入 help 跳转到官方的帮助页面查看。图示如下:


image (5).png

在 QuickExec 下面的就是状态栏,



  1. Capturing:代表目前 Fiddler 的代理功能是开启的,也就是是否进行请求响应的拦截,如果想关闭代理,只需要点击一下 Capturing 图标即可

  2. All Processes:选择抓取的进程,可以只选浏览器进程或是非浏览器进程等

  3. 断点:按一次是请求前断点,也就是请求从浏览器发出到 Fiddler 这停住;再按一次是响应后的断点,也就是响应从服务器发出,到Fiddler 这停住;再按一次就是不打断点

  4. 当前选中的会话 / 总会话数

  5. 附加信息


辅助标签 + 工具


位于软件界面右边的这一大块面板,即为辅助标签 + 工具,如下图所示,它拥有 10 个小标签,我们先从 Statistics 讲起,btw,这单词的发音是 [stəˈtɪstɪks],第 3 个字母 a 发 'ə' 的音,而不是 'æ'~


image (6).png


Statistics(统计)


这个 tab 里都是些 http 请求的性能数据分析,如 DNS Lookup(DNS 解析时间)、 TCP/IP Connect(TCP/IP 连接时间)等。


Inspectors(检查器)


image.png

以多种不同的方式查看请求的请求报文和响应报文,比如可以只看头部信息(Headers)、或者是查看请求的原始信息(Raw),再比如请求的参数是 x-www-form-urlencoded 的话,就能在 WebForms 里查看...


AutoResponder(自动响应器)


image (1).png

这是一个我认为比较有用的功能了,它可以篡改从服务器返回的数据,达到欺骗浏览器的目的。


实战案例


我在做一个后台项目的时候,因为前台还没弄好,数据库都是没有数据的,在获取列表时,请求得到的都是如下图所示的空数组:


image.png

那么在页面上显示的也就是“暂无数据”,这就影响了之后一些删改数据的接口的对接。


image (2).png

此时,我们就可以通过 AutoResponder ,按照接口文档的返回实例,对返回的数据进行编辑,具体步骤如下:



  1. 勾选上 Enable rules(激活自动响应器) 和 Unmatched requests passthrough(放行所有不匹配的请求)


image (3).png

2. 在左侧会话列表里选中要修改响应的那条请求,按住鼠标直接拖动到 AutoResponder 的面板里,如下图红框所示:


image (4).png

3. 选中上图红框里的请求单机鼠标右键,选择 Edit Response...


image (5).png

4. 进入编辑面板选择 Raw 标签就可以直接进行编辑了,这里我按照接口文档的返回示例,给 items 数组添加了数据,如下图所示:


image (6).png

这样,浏览器接收到数据,页面就如下图所示有了内容,方便进行之后的操作


image (7).png


Composer(设计者)


说完了对响应的篡改,现在介绍的 composer 就是用于对请求的篡改。这个单词的翻译是作曲家,按照我们的想法去修改一个请求,宛如作曲家谱一首乐曲一般。


image.png

用法与 AutoResponder 类似,也是可以从会话列表里直接拖拽一个请求到上图红框中,然后对请求的内容进行修改即可。应用场景之一就是可以绕过一些前端用 js 写的限制与验证,直接发送请求,通过返回的数据可以判断后端是否有做相关限制,测试系统的健壮性。


Filters(过滤器)


在默认情况下,Filters 会抓取一切能够抓取到的请求,统统列在左侧的会话列表里,如果我们是有目的对某些接口进行测试,就会觉得请求列表很杂乱,这时可以点开 Filters 标签,勾选 Use Filters,启动过滤工具,如下图:


image.png

接着就可以根据我们需要对左侧列表里展示的所抓取的接口进行过滤,比如根据 Hosts 进行过滤,只显示 Hosts 为 api.juejin.cn 的请求,就可以如下图在 Hosts 那选择 'Show only the following Hosts',然后点击右上角 Actions 里的 'Run Filterset now' 执行过滤。


image.png

过滤的筛选条件还有很多,比如据请求头字段里 URL 是否包含某个单词等,都很简单,一看便知,这里不再一一细说。


HTTPS 抓包


默认情况下,Fiddler 没办法显示 HTTPS 的请求,需要进行证书的安装:



  1. 点击 'Tools -> Options...' ,勾选上 'Decrypt HTTPS traffic' (解密HTTPS流量)


image.png



  1. 点击 Actions 按钮,点击 'Reset All Certicicates' (重置所有证书),之后遇到弹出的窗口,就一直点击 '确定' 或 'yes' 就行了。


image (1).png



  1. 查看证书是否安装成功:点击 'Open Windows Certificate Manager' 打开 Windows 证书管理器窗口


image (2).png

点击 '操作' 选择 '查找证书',在 '包含' 输入框输入 fiddler 进行查找


image (3).png

查找结果类似下图即安装证书成功


image (4).png

现在会话列表就能成功显示 https 协议的请求了。


断点应用


全局断点


通过 'Rules -> Automatic Breakpoints' 可以给请求打断点,也就是中断请求,断点分为两种:



  1. Before Requests(请求前断点):请求发送给服务器之前进行中断

  2. After Responses(响应后断点):响应返回给客户端之前进行中断


image.png

打上断点之后,选中想要修改传输参数的那一条请求,按 R 进行重发,这条请求就会按要求在请求前或响应后被拦截,我们就可以根据需要进行修改,然后点击工具栏的 'Go',或者点击如下图所示的绿色按钮 'Run to Completion',继续完成请求。


image (1).png

这样打断点是全局断点,即所有请求都会被拦截,下面介绍局部断点。


局部断点


如果只想对某一条请求打断点,则可以在 QuickExec 输入相应的命令执行。



  • 请求前断点



  1. 在 QuickExec 输入 bpu query_adverts 。注意:query_adverts 为请求的 url 的一部分,这样就只有 url 中包含 query_adverts 的请求会被打上断点。


image (2).png



  1. 按下 Enter 键,可以看到红框中显示 query_adverts 已经被 breakpoint 了,而且是 RequestURI


image (3).png



  1. 选中 url 中带 query_adverts 的这条请求,按 R 再次发送,在发给服务器前就会被中断(原谅我又拿掘金的请求做例子~)


image (4).png



  1. 取消断点:在 QuickExec 输入 bpu 按下 Enter 即可



  • 响应后断点


与请求前断点步骤基本一致,区别在于输入的命令是 bpafter get_today_status
按下 Enter 后在 'Composer' 标签下点击 'Execute' 执行,再次发送该请求则服务器的响应在发送给浏览器之前被截断,注意下红色的图标,跟之前的请求前断点的区别在于一个是向上的箭头,一个是向下的箭头。


image (5).png

取消拦截则是输入 bpafter 后回车,可以看到状态栏显示 'ResponseURI breakpoint cleared'


image (6).png


弱网测试


Fiddler 还可以用于弱网测试,'Rules -> Performance -> 勾选 Simulate Modem Speeds' 即可


image (7).png

再次刷新网页会感觉回到了拨号上网的年代,可以测试网站在网速很低的情况下的表现。


修改网速


网速还可以修改,点击 'FiddlerScript' 标签,在下图绿框中搜索 simulateM,按几下回车找到 if (m_SimulateModem) 这段代码,可以修改上下传输的速度:


image (8).png


安卓手机抓包


最后一部分主要内容是关于手机抓包的,我用的是小米手机 9,MIUI 12.5.1 稳定版,安卓版本为 11。



  1. 首先保证安装了 Fiddler 的电脑和手机连的是同一个 wifi

  2. 在 Fiddler 中,点击 'Tools -> Options...' ,在弹出的 Options 窗口选择 Connections 标签,勾选 'Allow remote computers to connect'


image (9).png



  1. 手机打开 '设置 -> WLAN -> 连接的那个 WLAN 的设置' 进入如下图所示的页面


image (10).png



  1. '代理' 选择 '手动','主机名' 填写电脑的主机名,端口则是 Fiddler 默认监听的 8888,然后点击左上角的 '打钩图标' 进行保存

  2. 下载证书:打开手机浏览器,输入 'http://192.168.1.1:8888' (注意:192.168.1.1 要替换成你电脑的 ip 地址),会出现如下页面


image (11).png

点击红框中链接进行证书的下载



  1. 安装证书:打开 '设置 -> 密码与安全 -> 系统安全 -> 加密与凭据 -> 安装证书(从存储设备安装证书)-> 证书 ' 找到刚刚下载的证书进行安装


image (12).png



  1. 安装完成可以在 '加密与凭据 -> 信任的凭据' 下查看


image (13).png



  1. 现在 Fiddler 就可以抓到手机里 app 发送的请求了

  2. 最后注意:测试完毕需要关闭手机的 WLAN 代理,否则手机就上不了网了~


One More Thing


几个常用快捷键



  • 双击某一条请求:打开该请求的 Inspectors 面板

  • ctrl + X:清除请求列表

  • R:选中某一条请求,按 R 键可重新发送该请求

  • shift+delete:删除除了选中那一条之外的请求



链接:https://juejin.cn/post/6983282278277316615

收起阅读 »

小程序自动化测试入门到实践

背景 随着小程序项目越来越复杂,业务场景越来多,花费在回归测试上的时间会越来越多,前端自动化测试就非常有必要提上日程。 今天要带来的是: 小程序自动化测试入门教程。 环境 系统 :macOS 微信开发者工具版本: 1.05.2106300 什么是小程序自动化 ...
继续阅读 »

背景


随着小程序项目越来越复杂,业务场景越来多,花费在回归测试上的时间会越来越多,前端自动化测试就非常有必要提上日程。


今天要带来的是: 小程序自动化测试入门教程


环境


系统 :macOS

微信开发者工具版本: 1.05.2106300


什么是小程序自动化


微信官方文档:小程序自动化


使用小程序自动化 SDK miniprogram-automator,可以在帮助我们在小程序中完成一些事情,比如:控制小程序跳转到指定页面,获取小程序页面数据,获取小程序页面元素状态等。


配合 jest 就可以实现小程序端自动化测试了。
话不多说,我们开始吧


准备




  1. 项目根目录 mini-auto-test-demo 里面准备两个目录 miniprogram 放小程序代码,和 test-e2e 放测试用例代码




 |— mini-auto-test-demo/  // 根目录
|— miniprogram/ // 小程序代码
|— pages/
|— index/ // 测试文件
|— test-e2e/ // 测试用例代码
|— index.spec.js // 启动文件
|— package.json

index 文件夹下准备用于测试的页面

<!--index.wxml-->
<view class="userinfo">
<view class="userinfo-avatar" bindtap="bindViewTap">
<open-data type="userAvatarUrl"></open-data>
</view>
<open-data type="userNickName"></open-data>
</view>

/**index.wxss**/
.userinfo {
margin-top: 50px;
display: flex;
flex-direction: column;
align-items: center;
color: #aaa;
}
.userinfo-avatar {
overflow: hidden;
width: 128rpx;
height: 128rpx;
margin: 20rpx;
border-radius: 50%;
}

// index.js
// 获取应用实例
const app = getApp()
Page({
data: {
userInfo: {},
},
// 事件处理函数
bindViewTap() {
wx.navigateTo({
url: '../logs/logs'
})
}
})


  1. 微信开发者工具->设置-> 安全设置 -> 打卡服务端口


image.png



  1. 安装npm包


如果根目录没有 package.json 文件,先执行


npm init

如果根目录已经有 package.json 文件 ,执行以下命令:


npm install miniprogram-automator jest --save-dev
npm i jest -g

安装需要的依赖



  1. 在根目录下新建index.spec.js 文件
const automator = require('miniprogram-automator')

automator.launch({
cliPath: '/Applications/wechatwebdevtools.app/Contents/MacOS/cli', // 工具 cli 位置
projectPath: '/Users/SONG/Documents/github/mini-auto-test-demo/miniprogram', // 项目文件地址
}).then(async miniProgram => {
const page = await miniProgram.reLaunch('/pages/index/index')
await page.waitFor(500)
const element = await page.$('.userinfo-avatar')
console.log(await element.attribute('class'))
await element.tap()
await miniProgram.close()
})

这里要注意修改为自己的cli位置和项目文件地址:



  1. cliPath:


可以在应用程序中找到微信开发者工具,点击右键点击"显示包内容"


image.png


找到cli后,快捷键 :command+option+c 复制路径, 就拿到了


image.png



  1. projectPath:


注意!!项目路径填写的是小程序文件夹miniprogram而不是mini-auto-test-demo


启动


写好路径后,在mac终端进入mini-auto-test-demo根目录或 vscode 终端根目录执行命令:


node index.spec.js

image.png


你会发现微信开发者工具被自动打开,并执行了点击事件进入了log页面,终端输出了class的值。
到此你已经感受到了自动化,接下来你要问了,自动化测试呢?别急,接着往下看。


自动化测试


在一开始准备的test-e2e 文件夹下新建integration.test.js文件,


引入'miniprogram-automator, 连接自动化操作端口,把刚刚index.spec.js中的测试代码,放到 jest it 里,jest相关内容我们这里就不赘述了,大家可以自行学习(其实我也才入门 ̄□ ̄||)。

const automator = require('miniprogram-automator');

describe('index', () => {
let miniProgram;
let page;
const wsEndpoint = 'ws://127.0.0.1:9420';
beforeAll(async() => {
miniProgram = await automator.connect({
wsEndpoint: wsEndpoint
});
}, 30000);

it('test index', async() => {
page = await miniProgram.reLaunch('/pages/index/index')
await page.waitFor(500)
const element = await page.$('.userinfo-avatar')
console.log(await element.attribute('class'))
await element.tap()
});
});

package.json scripts 添加命令


"e2e": "jest ./test-e2e integration.test.js --runInBand"

测试代码写好了,接下来如何运行呢?这里我们提另外一个方法。


cli 命令行调用


官方文档:命令行调用

你一定会问,刚刚我们不是学习了启动运行,这么还要学另外一种方法 o(╥﹏╥)o
大家都知道,一般团队里都是多人合作的,大家的项目路径都不一样,难道每次还要改projectPath吗?太麻烦了,使用cli就不需要考虑在哪里启动,项目地址在哪里,话不多说,干!


打开终端进入放微信开发者工具cli文件夹(路径仅供参考):


cd /Applications/wechatwebdevtools.app/Contents/MacOS 

执行命令(如果你的微信开发者工具开着项目,先关掉)


./cli --auto  /Users/SONG/Documents/github/mini-auto-test-demo/miniprogram  --auto-port 9420

微信开发者工具通过命令行启动


image.png


启动后在项目根目录下执行,可以看到测试通过


npm run e2e

image.png


到此,我们已经可以写测试用例了。这只是入门系列,后续会持续更文,感谢大家的耐心阅读,如果你有任何问题都可以留言给我,摸摸哒



链接:https://juejin.cn/post/6983294039852318728
收起阅读 »

面试官:能不能手写几道链表的基本操作

反转链表 示例: 输入: 1->2->3->4->5->NULL 输出: 5->4->3->2->1->NULL 循环解决方案 这道题是链表中的经典题目,充分体现链表这种数据结构 操作思路简单 ,...
继续阅读 »

反转链表


示例:


输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL


  • 循环解决方案


这道题是链表中的经典题目,充分体现链表这种数据结构 操作思路简单 , 但是 实现上 并没有那么简单的特点。


那在实现上应该注意一些什么问题呢?


保存后续节点。作为新手来说,很容易将当前节点的 next 指针直接指向前一个节点,但其实当前节点下一个节点 的指针也就丢失了。因此,需要在遍历的过程当中,先将下一个节点保存,然后再操作 next指向。


链表结构声定义如下:


function ListNode(val) {
this.val = val;
this.next = null;
}

实现如下:

/**
* @param {ListNode} head
* @return {ListNode}
*/
let reverseList = (head) => {
if (!head)
return null;
let pre = null,
cur = head;
while (cur) {
// 关键: 保存下一个节点的值
let next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
};


  • 递归解决方案


let reverseList = (head) =>{
let reverse = (pre, cur) => {
if(!cur) return pre;
// 保存 next 节点
let next = cur.next;
cur.next = pre;
return reverse(cur, next);
}
return reverse(null, head);
}

2.区间反转


反转从位置 m 到 n 的链表。请使用一趟扫描完成反转。


说明: 1 ≤ m ≤ n ≤ 链表长度。


示例:


输入: 1->2->3->4->5->NULL, m = 2, n = 4
输出: 1->4->3->2->5->NULL

思路
这一题相比上一个整个链表反转的题,其实是换汤不换药。我们依然有两种类型的解法:循环解法递归解法


image.png
关于前节点和后节点的定义,大家在图上应该能看的比较清楚了,后面会经常用到。


反转操作上一题已经拆解过,这里不再赘述。值得注意的是反转后的工作,那么对于整个区间反转后的工作,其实就是一个移花接木的过程,首先将前节点的 next 指向区间终点,然后将区间起点的 next 指向后节点。因此这一题中有四个需要重视的节点: 前节点 、 后节点 、 区间起点 和 区间终点 。



  • 循环解法
/**
* @param {ListNode} head
* @param {number} m
* @param {number} n
递归解法
对于递归解法,唯一的不同就在于对于区间的处理,采用递归程序进行处理,大家也可以趁着复习一下
递归反转的实现。
* @return {ListNode}
*/
var reverseBetween = function(head, m, n) {
let count = n - m;
let p = dummyHead = new ListNode();
let pre, cur, start, tail;
p.next = head;
for(let i = 0; i < m - 1; i ++) {
p = p.next;
}
// 保存前节点
front = p;
// 同时保存区间首节点
pre = tail = p.next;
cur = pre.next;
// 区间反转
for(let i = 0; i < count; i++) {
let next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// 前节点的 next 指向区间末尾
front.next = pre;
// 区间首节点的 next 指向后节点(循环完后的cur就是区间后面第一个节点,即后节点)
tail.next = cur;
return dummyHead.next;
};


  • 递归解法
var reverseBetween = function(head, m, n) {
// 递归反转函数
let reverse = (pre, cur) => {
if(!cur) return pre;
// 保存 next 节点
let next = cur.next;
cur.next = pre;
return reverse(cur, next);
}
let p = dummyHead = new ListNode();
dummyHead.next = head;
let start, end; //区间首尾节点
let front, tail; //前节点和后节点
for(let i = 0; i < m - 1; i++) {
p = p.next;
}
front = p; //保存前节点
start = front.next;
for(let i = m - 1; i < n; i++) {
p = p.next;
}
end = p;
tail = end.next; //保存后节点
end.next = null;
// 开始穿针引线啦,前节点指向区间首,区间首指向后节点
front.next = reverse(null, start);
start.next = tail;
return dummyHead.next;
}

3.两个一组翻转链表


给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。


你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。


示例


给定 1->2->3->4, 你应该返回 2->1->4->3

思路


如图所示,我们首先建立一个虚拟头节点(dummyHead),辅助我们分析。


image.png


首先让 p 处在 dummyHead 的位置,记录下 p.next 和 p.next.next 的节点,也就是 node1 和
node2。


随后让 node1.next = node2.next, 效果:


image.png


然后让 node2.next = node1, 效果:


image.png
最后,dummyHead.next = node2,本次翻转完成。同时 p 指针指向node1, 效果如下:


image.png
依此循环,如果 p.next 或者 p.next.next 为空,也就是 找不到新的一组节点 了,循环结束。



  • 循环解决
var swapPairs = function(head) {
if(head == null || head.next == null)
return head;
let dummyHead = p = new ListNode();
let node1, node2;
dummyHead.next = head;
while((node1 = p.next) && (node2 = p.next.next)) {
node1.next = node2.next;
node2.next = node1;
p.next = node2;
p = node1;
}
return dummyHead.next;
};


  • 递归方式


var swapPairs = function(head) {
if(head == null || head.next == null)
return head;
let node1 = head, node2 = head.next;
node1.next = swapPairs(node2.next);
node2.next = node1;
return node2;
};

4.K个一组翻转


给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。


k 是一个正整数,它的值小于或等于链表的长度。


如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。


示例


给定这个链表:1->2->3->4->5
当 k = 2 时,应当返回: 2->1->4->3->5
当 k = 3 时,应当返回: 3->2->1->4->5

说明 :


你的算法只能使用常数的额外空间。


你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。


思路
思路类似No.3中的两个一组翻转。唯一的不同在于两个一组的情况下每一组只需要反转两个节点,而在K 个一组的情况下对应的操作是将 K 个元素 的链表进行反转。



  • 递归解法
/**
* @param {ListNode} head
* @param {number} k
* @return {ListNode}
*/
var reverseKGroup = function(head, k) {
let pre = null, cur = head;
let p = head;
// 下面的循环用来检查后面的元素是否能组成一组
for(let i = 0; i < k; i++) {
if(p == null) return head;
p = p.next;
}
for(let i = 0; i < k; i++){
let next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// pre为本组最后一个节点,cur为下一组的起点
head.next = reverseKGroup(cur, k);
return pre;
};


  • 循环解法
var reverseKGroup = function(head, k) {
let count = 0;
// 看是否能构成一组,同时统计链表元素个数
for(let p = head; p != null; p = p.next) {
if(p == null && i < k) return head;
count++;
}
let loopCount = Math.floor(count / k);
let p = dummyHead = new ListNode();
dummyHead.next = head;
// 分成了 loopCount 组,对每一个组进行反转
for(let i = 0; i < loopCount; i++) {
let pre = null, cur = p.next;
for(let j = 0; j < k; j++) {
let next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// 当前 pre 为该组的尾结点,cur 为下一组首节点
let start = p.next;// start 是该组首节点
// 开始穿针引线!思路和2个一组的情况一模一样
p.next = pre;
start.next = cur;
p = start;
}
return dummyHead.next;
}


链接:https://juejin.cn/post/6983580875842093092

收起阅读 »