这些天,我们前端组一起处理的网站换肤功能
前言
大家好,我是沐浴在曙光下的贰货道士,好久不见,别来无恙!本文主要根据UI
设计需求,讲述一套基于scss
封装方法的网页响应式布局,以及不同于传统引入element UI
主题配色文件的换肤思路。大家仔细看完文章,相信一定会有所收获。倘若本文提供的响应式布局思路对您有所帮助,烦请大家一键三连哦。同时如果您有其他响应式布局解决方案或网站换肤思路,欢迎您不吝赐教,在评论区留言分享。感谢大家!
大家好,我是沐浴在曙光下的贰货道士,好久不见,别来无恙!本文主要根据UI
设计需求,讲述一套基于scss
封装方法的网页响应式布局,以及不同于传统引入element UI
主题配色文件的换肤思路。大家仔细看完文章,相信一定会有所收获。倘若本文提供的响应式布局思路对您有所帮助,烦请大家一键三连哦。同时如果您有其他响应式布局解决方案或网站换肤思路,欢迎您不吝赐教,在评论区留言分享。感谢大家!
需求分析
- 早期我们前端项目组开发了一个国外业务网站。这周为了迎合其他国家的喜好,需要在国外业务项目的基础上,新建多个项目,对之前的主题配色和部分布局进行修改,并需要适配不同分辨率下的屏幕。
UI
提供了包括主题配色和页面布局修改在内的一系列项目稿件,这些稿件基于1920px
分辨率的屏幕进行处理。前端需要根据UI提供的主题色,修改项目中的颜色变量。接口暂时使用国外业务的那一套接口,后期需要对接这些项目的接口,而我们目前的主要任务就是处理这些项目的静态页面改版。 - 主题色修改:
- 首先,我们前端团队需要根据
UI
提供的主题色,更新项目中的颜色变量,确保页面上的所有元素都符合新的配色方案。 页面布局
提供的修改稿件,如果有不在主题色内的颜色,需要和UI
确认是否需要更换为其他颜色相近的主题配色或者双方都新增主题配色- 检查项目中包括
CSS
和HTML
在内的所有带#
颜色值的信息。与UI
确认后,将其更换为其他颜色接近的主题配色,或者双方共同新增主题配色,以确保配色方案的一致性和协调性。
- 响应式布局:
- 前端需要根据
UI
提供的稿件和意见,适配项目在不同屏幕下的样式。对于页面上的不同元素,在小于等于1920px
的屏幕上进行缩放时,需要保持横纵比,并根据页面大小进行等比例缩放,包括容器宽高、间距等在内的页面布局是否合适都需要与UI
确认;在高于1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 - 然而,字体元素在页面缩放时,需要保持一定的风格。比如:
16px
的文字最小不能低于14px
,18px
、20px
以及24px
的文字最小不能低于16px
,32px
的文字最小不能低于18px
,36px
的文字最小不能低于20px
,44px
的文字最小不能低于28px
,48px
的文字最小不能低于32px
。 - 在移动设备上,需要保持和800px网页相同的布局。
- 早期我们前端项目组开发了一个国外业务网站。这周为了迎合其他国家的喜好,需要在国外业务项目的基础上,新建多个项目,对之前的主题配色和部分布局进行修改,并需要适配不同分辨率下的屏幕。
UI
提供了包括主题配色和页面布局修改在内的一系列项目稿件,这些稿件基于1920px
分辨率的屏幕进行处理。前端需要根据UI提供的主题色,修改项目中的颜色变量。接口暂时使用国外业务的那一套接口,后期需要对接这些项目的接口,而我们目前的主要任务就是处理这些项目的静态页面改版。 - 主题色修改:
- 首先,我们前端团队需要根据
UI
提供的主题色,更新项目中的颜色变量,确保页面上的所有元素都符合新的配色方案。 页面布局
提供的修改稿件,如果有不在主题色内的颜色,需要和UI
确认是否需要更换为其他颜色相近的主题配色或者双方都新增主题配色- 检查项目中包括
CSS
和HTML
在内的所有带#
颜色值的信息。与UI
确认后,将其更换为其他颜色接近的主题配色,或者双方共同新增主题配色,以确保配色方案的一致性和协调性。
- 首先,我们前端团队需要根据
- 响应式布局:
- 前端需要根据
UI
提供的稿件和意见,适配项目在不同屏幕下的样式。对于页面上的不同元素,在小于等于1920px
的屏幕上进行缩放时,需要保持横纵比,并根据页面大小进行等比例缩放,包括容器宽高、间距等在内的页面布局是否合适都需要与UI
确认;在高于1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 - 然而,字体元素在页面缩放时,需要保持一定的风格。比如:
16px
的文字最小不能低于14px
,18px
、20px
以及24px
的文字最小不能低于16px
,32px
的文字最小不能低于18px
,36px
的文字最小不能低于20px
,44px
的文字最小不能低于28px
,48px
的文字最小不能低于32px
。 - 在移动设备上,需要保持和800px网页相同的布局。
- 前端需要根据
项目现状
- 主题色: 早期在与UI团队合作时,我们为国外业务系统确定了一套配色方案,并将其定义在项目的颜色变量中。然而,后续设计稿中出现了一些不在这套配色方案中的色值。 由于种种原因,我们在开发时没有与UI确认这些颜色是否需要更换,也没有将新增的颜色定义到颜色变量中,而是直接在代码中使用了这些颜色值。这导致在此次换肤过程中,仅通过修改颜色变量无法实现统一换肤的效果。我们需要逐一检查代码中硬编码的颜色值,并将其替换为新的颜色变量,以确保换肤的统一性和一致性。
- 布局: 以前我们使用
flex
、百分比、最小最大宽度/高度以及element UI
的栅格布局做了一些简单的适配,但这些方法不够灵活。为了更好地适应不同分辨率的屏幕,我们需要采用更为灵活和动态的布局方案,以确保在各种设备上的显示效果都能达到预期。
- 主题色: 早期在与UI团队合作时,我们为国外业务系统确定了一套配色方案,并将其定义在项目的颜色变量中。然而,后续设计稿中出现了一些不在这套配色方案中的色值。 由于种种原因,我们在开发时没有与UI确认这些颜色是否需要更换,也没有将新增的颜色定义到颜色变量中,而是直接在代码中使用了这些颜色值。这导致在此次换肤过程中,仅通过修改颜色变量无法实现统一换肤的效果。我们需要逐一检查代码中硬编码的颜色值,并将其替换为新的颜色变量,以确保换肤的统一性和一致性。
- 布局: 以前我们使用
flex
、百分比、最小最大宽度/高度以及element UI
的栅格布局做了一些简单的适配,但这些方法不够灵活。为了更好地适应不同分辨率的屏幕,我们需要采用更为灵活和动态的布局方案,以确保在各种设备上的显示效果都能达到预期。
思路分析
主题色
传统的解决方案
- 以前在官网上,我们可以直接编辑并修改一套主题色。点击下载后,会生成一个
css
文件。
- 将下载后的
css
文件引入到我们项目中,可以看到编译后的css
文件
- 最后在项目中的入口文件,引入我们下载的
css
文件(这种方式会增加app.css
的体积)。
`main.js`
import '@/styles/theme/index.css'
- 后续处理的优化
`将编译后的element样式,从main.js指向到index.html中,减小了main.css体积`
`main.js中的css文件,最终还是会link到index.html中。那为什么还要把它拆开呢?`
`这涉及到css的拆分:浏览器会并行请求加载多个css文件,比单独请求并加载一个css文件要快`
`这样处理的目的是:将main.js中的css文件,抽出一部分放到index.html中`
<link rel="stylesheet" href="<%= BASE_URL %>theme/index.css">
- webpack小知识:
loader
webpack
只识别js
文件:当遇到其他非js
文件时,因为不识别js
文件,所以需要使用loader
插件(或引入第三方插件,或自己编写一个loader
方法),将其他文件转换为webpack
能够识别的js
文件。- 因此,
loader
的作用相当于对传入的非js
文件做处理,将它转换为 webpack
可识别的js
字符串。
在字体商用不侵权的前提下,
严格遵循设计稿的字体样式
`如果用户电脑不存在设计稿上提供的字体样式,则会展示用户电脑的默认字体样式。`
`为此,我们需要下载并引入字体,将字体集成到网站中,确保用户电脑呈现效果与我们开发一致`
`(1) 引入: 在public文件夹下新建fonts文件夹,在fonts文件夹下引入我们下载好的字体样式`
`(2) 在index.html中, 为document增加字体`
`(3) 引入并挂载字体后,我们就可以使用下载的字体了,也可以在body上全局挂载字体`
`类似element字体的引入和挂载`
`FontFace: https://developer.mozilla.org/zh-CN/docs/Web/API/CSS_Font_Loading_API`
const font1 = new FontFace(
'iconfont',
'url(/iconfont/iconfont.woff2?t=1688345853791),
url(/iconfont/iconfont.woff?t=1688345853791),
url(/iconfont/iconfont.ttf?t=1688345853791)')
const font2 = new FontFace(
'element-icons',
'url(/theme/fonts/element-icons.woff),
url(/theme/fonts/element-icons.ttf)')
font1.load().then(function() {
document.fonts.add(font1)
})
font2.load().then(function() {
document.fonts.add(font2)
})
- 以前在官网上,我们可以直接编辑并修改一套主题色。点击下载后,会生成一个
css
文件。 - 将下载后的
css
文件引入到我们项目中,可以看到编译后的css
文件 - 最后在项目中的入口文件,引入我们下载的
css
文件(这种方式会增加app.css
的体积)。
`main.js`
import '@/styles/theme/index.css'
- 后续处理的优化
`将编译后的element样式,从main.js指向到index.html中,减小了main.css体积`
`main.js中的css文件,最终还是会link到index.html中。那为什么还要把它拆开呢?`
`这涉及到css的拆分:浏览器会并行请求加载多个css文件,比单独请求并加载一个css文件要快`
`这样处理的目的是:将main.js中的css文件,抽出一部分放到index.html中`
<link rel="stylesheet" href="<%= BASE_URL %>theme/index.css">
loader
webpack
只识别js
文件:当遇到其他非js
文件时,因为不识别js
文件,所以需要使用loader
插件(或引入第三方插件,或自己编写一个loader
方法),将其他文件转换为webpack
能够识别的js
文件。loader
的作用相当于对传入的非js
文件做处理,将它转换为 webpack
可识别的js
字符串。在字体商用不侵权的前提下,
严格遵循设计稿的字体样式`如果用户电脑不存在设计稿上提供的字体样式,则会展示用户电脑的默认字体样式。`
`为此,我们需要下载并引入字体,将字体集成到网站中,确保用户电脑呈现效果与我们开发一致`
`(1) 引入: 在public文件夹下新建fonts文件夹,在fonts文件夹下引入我们下载好的字体样式`
`(2) 在index.html中, 为document增加字体`
`(3) 引入并挂载字体后,我们就可以使用下载的字体了,也可以在body上全局挂载字体`
`类似element字体的引入和挂载`
`FontFace: https://developer.mozilla.org/zh-CN/docs/Web/API/CSS_Font_Loading_API`
const font1 = new FontFace(
'iconfont',
'url(/iconfont/iconfont.woff2?t=1688345853791),
url(/iconfont/iconfont.woff?t=1688345853791),
url(/iconfont/iconfont.ttf?t=1688345853791)')
const font2 = new FontFace(
'element-icons',
'url(/theme/fonts/element-icons.woff),
url(/theme/fonts/element-icons.ttf)')
font1.load().then(function() {
document.fonts.add(font1)
})
font2.load().then(function() {
document.fonts.add(font2)
})
现在的解决方案
由于element UI
官方已不再维护传统的主题配色下载,我们项目采取官方提供的第二种方式:
- 原理: 我们项目使用
scss
编写css
,element UI
的theme-chalk
又恰好使用scss
进行编写。在官方定义的scss
变量中,使用了!default
语法,用于提供默认值。这也就意味着,我们不用考虑css
的加载顺序,直接新建scss
文件,覆盖定义在theme-chalk
文件且在我们系统中常用的scss
变量,达到在css
编译阶段自定义主题scss
变量的效果。

- 引入变量: 新建
element-variable.scss
文件,在这个文件中引入theme-chalk
定义的主题scss
变量,同时需要改变icon
字体路径变量(使用传统方法不需要改变路径变量,是因为我们直接引入了编译后的css
文件,里面已经帮我们做过处理了;而使用现在的解决方案,如果不改变字体路径变量,项目会提示找不到icon
字体路径,所以这个配置必填)。此时,将这个文件引入到我们的入口文件,那么系统中已经存在theme-chalk
定义好的scss
变量了

- 修改变量: 新建
element.scss
文件,在里面覆盖我们需要修改的主题变量,最后在vue.config.js
中sass
配置下的additionalData
里全局引入到项目中的每个vue
文件中(因为是挂载到每个vue
文件中,所以这个配置下的scss文件不宜过多),方便在vue
文件中直接使用变量。


优势
1. 定制化和灵活性
- 更改主题色和变量: 轻松改变
Element UI
的主题色、字体、间距等变量,而无需过多地覆盖现有的element CSS
样式。 - 精细控制: 原先的配置方式只能配置主题色,无法控制更细粒度的配置,比如边框颜色之类。
2. 避免样式冲突
- 避免样式覆盖的冲突: 通过直接修改
SCSS
变量来定制样式,可以避免在使用编译后的 CSS 文件时可能出现的样式覆盖冲突问题。这样可以保证样式的独立性和一致性。
3. 便于维护
- 集中管理: 所有的样式修改都集中在一个地方(变量文件),这使得维护样式变得更加方便和清晰。只需要修改文件中定义的变量,就可以影响整个项目中的样式,无需逐一查找以及修改每个组件的样式。
缺陷
- 在
sass loader
的additionalData
中配置了过多的全局css
变量,添加到每个vue
文件中 - 相比之前的处理方式,在
main.js
中引入element
自定义的主题scss
变量,首页加载的css
文件更多,
sass loader
的additionalData
中配置了过多的全局css
变量,添加到每个vue
文件中main.js
中引入element
自定义的主题scss
变量,首页加载的css
文件更多,响应式布局
思路分析
UI
提供的稿件是1920px
,前端需要对UI
提供的稿件进行一比一还原;- 网页在小屏缩放时,需要保持元素的横纵比。针对这个问题,我们可以用百分比作为布局单位。 以设计稿宽度
1920px
为基准,建立px
和vw
之间的关系。如果把1920px
视为100vw
,那么1vw = 19.2px
。 如果设计稿上某个元素的宽度为192px
, 那么将它换算得到的结果将会是192px / 19.2px * 1vw = 10vw
。因此我们在布局时,需要严格遵循UI
提供的设计稿件,并借助下文封装的方法,将设计稿元素的像素作为第一个形参,传递到下文封装的方法中; 实现思路:为等比例缩放网页元素,先去掉传入的像素单位。最后使用前文提到的换算公式,不论宽高,都将其转换为
vw单位,等比缩放
。 - 字体页面元素在放大时,需要限制字体元素展现的最大阈值。 那么我们封装的方法,第二个形参需要控制字体元素的最大阈值;
实现思路:借助
scss中的
max方法实现。
- 字体页面元素在缩小时,需要限制字体元素展现的最小阈值。 那么我们封装的方法,第三个形参需要控制字体元素的最小阈值;
实现思路:借助
scss中的
min方法实现。
- 在高于
1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 针对这个问题,我们只需要保证方法中的max
形参和1920px
下的像素值一致,即保证方法中的第一个形参和第二个形参相同。 - 在移动设备上,需要使用
800px
的网页布局。针对这个问题,我们可以使用meta
标签进行适配:
- 不同屏幕下的元素显示势必不会那么完美。我们可以通过媒体查询,在不同分辨率的屏幕下,按照
UI
给定的反馈意见,对网页进行适配,这样就可以解决问题。但是在项目中大量使用媒体查询语法,会导致整个项目看上去很乱。为此,我们可以基于scss
语法,对媒体查询语法进行二次封装。 - 如何测试我们编写的
scss
代码? 移步sass在线调试

UI
提供的稿件是1920px
,前端需要对UI
提供的稿件进行一比一还原;- 网页在小屏缩放时,需要保持元素的横纵比。针对这个问题,我们可以用百分比作为布局单位。 以设计稿宽度
1920px
为基准,建立px
和vw
之间的关系。如果把1920px
视为100vw
,那么1vw = 19.2px
。 如果设计稿上某个元素的宽度为192px
, 那么将它换算得到的结果将会是192px / 19.2px * 1vw = 10vw
。因此我们在布局时,需要严格遵循UI
提供的设计稿件,并借助下文封装的方法,将设计稿元素的像素作为第一个形参,传递到下文封装的方法中;实现思路:为等比例缩放网页元素,先去掉传入的像素单位。最后使用前文提到的换算公式,不论宽高,都将其转换为
vw单位,等比缩放
。 - 字体页面元素在放大时,需要限制字体元素展现的最大阈值。 那么我们封装的方法,第二个形参需要控制字体元素的最大阈值;
实现思路:借助
scss中的
max方法实现。
- 字体页面元素在缩小时,需要限制字体元素展现的最小阈值。 那么我们封装的方法,第三个形参需要控制字体元素的最小阈值;
实现思路:借助
scss中的
min方法实现。
- 在高于
1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 针对这个问题,我们只需要保证方法中的max
形参和1920px
下的像素值一致,即保证方法中的第一个形参和第二个形参相同。 - 在移动设备上,需要使用
800px
的网页布局。针对这个问题,我们可以使用meta
标签进行适配: - 不同屏幕下的元素显示势必不会那么完美。我们可以通过媒体查询,在不同分辨率的屏幕下,按照
UI
给定的反馈意见,对网页进行适配,这样就可以解决问题。但是在项目中大量使用媒体查询语法,会导致整个项目看上去很乱。为此,我们可以基于scss
语法,对媒体查询语法进行二次封装。 - 如何测试我们编写的
scss
代码? 移步sass在线调试
自适应scss
方法封装
// 自定义scss函数, 作用是去掉传入变量的单位
// 之所以要去掉单位,是为了将传入的px转换为vw单位,自适应布局`
@function stripUnits($value) {
// 对带有单位的变量进行特殊处理,返回去掉单位后的结果`
// 对于scss来说, 90px和90都是number`
// 在scss中,unitless是一个术语,指的是没有单位的数值,not unitless就是变量带单位`
@if type-of($value) == 'number' and not unitless($value) {
// 90px / 1 得到的结果是90px, 90px / 1px得到的结果是90
// 这也是这里为什么要用($value * 0 + 1),而不是直接写1的原因`
@return $value / ($value * 0 + 1);
}
@return $value;
}
/*
自定义scss函数,提供三个参数:
第一个参数是设计稿提供的元素大小,传入会自动转换为vw单位,达到自适应的效果
第二个参数是用来约束这个元素的大小最大不能超过第一个参数和第二个参数的最大值, 必须带单位
第三个参数是用来约束这个元素的大小最小不能小于第一个参数和第三个参数的最小值,必须带单位
如果不传入第二个和第三个参数,则表示元素完全随屏幕响应式缩放
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为960px时,标题字号缩放为24px,起不到突出的作用。
于是我们可以给它设置一个最小阈值,比如最小不能小于32px;
2. 同理,当屏幕分辨率为3840px时,标题字号放大为96px,我们不希望字号这么大。
于是可以给它设置一个最大阈值,比如最大不能超过60px。
*/
@function auto($raw, $max:null, $min:null) {
$raw: stripUnits($raw);
$str: #{$raw / $proportion}vw;
@if $max {
$str: min(#{$str}, #{$max});
}
@if $min {
$str: max(#{$str}, #{$min});
}
@return $str;
}
/*
自定义scss函数,auto方法的二次封装, 提供两个参数
第一个参数用于设置1920px下的元素大小
第二个参数用于设置这个元素的最小值
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为3840px时,标题字号放大为96px,我们希望它保持48px大小,
于是我们可以给它设置一个最大阈值48px。同时,我们可以传入一个最小阈值,让它最小不能小于这个参数。
*/
@function autoMax($raw, $min:null) {
@return auto($raw, $raw, $min)
}
// 和上面相反
@function autoMin($raw, $max:null) {
@return auto($raw, $max, $raw)
}
//1vw = 1920 / 100 ;
$proportion: 19.2;
// 根据UI需求,对不同字体大小进行封装
$wb-font-size-mini: 16px; // $text-mini-1
$wb-font-size-extra-small: 18px; // $text-small-1
$wb-font-size-small: 20px; //$text-sm-md-1
$wb-font-size-base: 24px; //$text-medium-1
$wb-font-size-lesser-medium: 32px;
$wb-font-size-medium: 36px; //$text-large-1
$wb-font-size-extra-medium: 44px;
$wb-font-size-large: 48px; //$text-title-1
// 根据UI需求,在屏幕分辨率缩小时,字体响应式变化,并设定最小阈值
// 并在1920px以上的屏幕,保持和1920px一样的字体大小
$wb-auto-font-size-mini: autoMax($wb-font-size-mini, 14px);
$wb-auto-font-size-extra-small: autoMax($wb-font-size-extra-small, 16px);
$wb-auto-font-size-small: autoMax($wb-font-size-small, 16px);
$wb-auto-font-size-base: autoMax($wb-font-size-base, 16px);
$wb-auto-font-size-lesser-medium: autoMax($wb-font-size-lesser-medium, 18px);
$wb-auto-font-size-medium: autoMax($wb-font-size-medium, 20px);
$wb-auto-font-size-extra-medium: autoMax($wb-font-size-extra-medium, 28px);
$wb-auto-font-size-large: autoMax($wb-font-size-large, 32px);
// 严格按照UI稿件提供的元素大小、间距编写代码,以下是示例代码
.title {
padding: 0 autoMax(180px);
font-size: $wb-auto-font-size-large;
font-weight: 600;
text-align: center;
}

// 自定义scss函数, 作用是去掉传入变量的单位
// 之所以要去掉单位,是为了将传入的px转换为vw单位,自适应布局`
@function stripUnits($value) {
// 对带有单位的变量进行特殊处理,返回去掉单位后的结果`
// 对于scss来说, 90px和90都是number`
// 在scss中,unitless是一个术语,指的是没有单位的数值,not unitless就是变量带单位`
@if type-of($value) == 'number' and not unitless($value) {
// 90px / 1 得到的结果是90px, 90px / 1px得到的结果是90
// 这也是这里为什么要用($value * 0 + 1),而不是直接写1的原因`
@return $value / ($value * 0 + 1);
}
@return $value;
}
/*
自定义scss函数,提供三个参数:
第一个参数是设计稿提供的元素大小,传入会自动转换为vw单位,达到自适应的效果
第二个参数是用来约束这个元素的大小最大不能超过第一个参数和第二个参数的最大值, 必须带单位
第三个参数是用来约束这个元素的大小最小不能小于第一个参数和第三个参数的最小值,必须带单位
如果不传入第二个和第三个参数,则表示元素完全随屏幕响应式缩放
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为960px时,标题字号缩放为24px,起不到突出的作用。
于是我们可以给它设置一个最小阈值,比如最小不能小于32px;
2. 同理,当屏幕分辨率为3840px时,标题字号放大为96px,我们不希望字号这么大。
于是可以给它设置一个最大阈值,比如最大不能超过60px。
*/
@function auto($raw, $max:null, $min:null) {
$raw: stripUnits($raw);
$str: #{$raw / $proportion}vw;
@if $max {
$str: min(#{$str}, #{$max});
}
@if $min {
$str: max(#{$str}, #{$min});
}
@return $str;
}
/*
自定义scss函数,auto方法的二次封装, 提供两个参数
第一个参数用于设置1920px下的元素大小
第二个参数用于设置这个元素的最小值
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为3840px时,标题字号放大为96px,我们希望它保持48px大小,
于是我们可以给它设置一个最大阈值48px。同时,我们可以传入一个最小阈值,让它最小不能小于这个参数。
*/
@function autoMax($raw, $min:null) {
@return auto($raw, $raw, $min)
}
// 和上面相反
@function autoMin($raw, $max:null) {
@return auto($raw, $max, $raw)
}
//1vw = 1920 / 100 ;
$proportion: 19.2;
// 根据UI需求,对不同字体大小进行封装
$wb-font-size-mini: 16px; // $text-mini-1
$wb-font-size-extra-small: 18px; // $text-small-1
$wb-font-size-small: 20px; //$text-sm-md-1
$wb-font-size-base: 24px; //$text-medium-1
$wb-font-size-lesser-medium: 32px;
$wb-font-size-medium: 36px; //$text-large-1
$wb-font-size-extra-medium: 44px;
$wb-font-size-large: 48px; //$text-title-1
// 根据UI需求,在屏幕分辨率缩小时,字体响应式变化,并设定最小阈值
// 并在1920px以上的屏幕,保持和1920px一样的字体大小
$wb-auto-font-size-mini: autoMax($wb-font-size-mini, 14px);
$wb-auto-font-size-extra-small: autoMax($wb-font-size-extra-small, 16px);
$wb-auto-font-size-small: autoMax($wb-font-size-small, 16px);
$wb-auto-font-size-base: autoMax($wb-font-size-base, 16px);
$wb-auto-font-size-lesser-medium: autoMax($wb-font-size-lesser-medium, 18px);
$wb-auto-font-size-medium: autoMax($wb-font-size-medium, 20px);
$wb-auto-font-size-extra-medium: autoMax($wb-font-size-extra-medium, 28px);
$wb-auto-font-size-large: autoMax($wb-font-size-large, 32px);
// 严格按照UI稿件提供的元素大小、间距编写代码,以下是示例代码
.title {
padding: 0 autoMax(180px);
font-size: $wb-auto-font-size-large;
font-weight: 600;
text-align: center;
}
媒体查询语法封装及使用规范
// 导入scss的list和map模块,用于处理相关操作。
@use 'sass:list';
@use "sass:map";
/*
媒体查询映射表,定义各种设备类型的媒体查询范围
key为定义的媒体类型,value为对应的分辨率范围
*/
$media-list: (
mobile-begin: (0, null),
mobile: (0, 800),
mobile-end:(null, 800),
tablet-begin: (801, null),
tablet: (801, 1023),
tablet-end:(null, 1023),
mini-desktop-begin: (1024, null),
mini-desktop: (1024, 1279),
mini-desktop-end: (null, 1279),
small-desktop-begin: (1280, null),
small-desktop: (1280, 1439),
small-desktop-end: (null, 1439),
medium-desktop-begin: (1440, 1919),
medium-desktop: (1440, 1919),
medium-desktop-end: (null, 1919),
large-desktop-begin: (1920, null),
large-desktop: (1920, 2559),
large-desktop-end: (null, 2559),
super-desktop-begin: (2560, null),
super-desktop: (2560, null),
super-desktop-end: (2560, null)
);
/*
创建响应式媒体查询的函数,传参是媒体查询映射表中的媒体类型
从$media-list中获取对应的最小和最大宽度,并返回相应的媒体查询字符串。
*/
@function createResponsive($media) {
$size-list: map.get($media-list, $media);
$min-size: list.nth($size-list, 1);
$max-size: list.nth($size-list, 2);
@if ($min-size and $max-size) {
@return "screen and (min-width:#{$min-size}px) and (max-width: #{$max-size}px)";
} @else if ($max-size) {
@return "screen and (max-width: #{$max-size}px)";
} @else {
@return "screen and (min-width:#{$min-size}px)";
}
}
/*
这个混入接受一个或多个媒体类型参数,调用createResponsive函数生成媒体查询
@content是Scss中的一个占位符,用于在混入中定义块级内容。
它允许你在调用混入时,将实际的样式代码插入到混入定义的样式规则中。
*/
@mixin responsive-to($media...) {
@each $item in $media {
$media-content: createResponsive($item);
@media #{$media-content} {
@content;
}
}
}
// 以下是针对各种媒体类型定义的混入:
@mixin mobile() {
@include responsive-to(mobile) {
@content;
}
}
@mixin tablet() {
@include responsive-to(tablet) {
@content;
}
}
@mixin mini-desktop() {
@include responsive-to(mini-desktop) {
@content;
}
}
@mixin small-desktop() {
@include responsive-to(small-desktop) {
@content;
}
}
@mixin medium-desktop() {
@include responsive-to(medium-desktop) {
@content;
}
}
@mixin large-desktop() {
@include responsive-to(large-desktop) {
@content;
}
}
@mixin super-desktop() {
@include responsive-to(super-desktop) {
@content;
}
}
@mixin mobile-begin() {
@include responsive-to(mobile-begin) {
@content;
}
}
@mixin tablet-begin() {
@include responsive-to(tablet-begin) {
@content;
}
}
@mixin mini-desktop-begin() {
@include responsive-to(mini-desktop-begin) {
@content;
}
}
@mixin small-desktop-begin() {
@include responsive-to(small-desktop-begin) {
@content;
}
}
@mixin medium-desktop-begin() {
@include responsive-to(medium-desktop-begin) {
@content;
}
}
@mixin large-desktop-begin() {
@include responsive-to(large-desktop-begin) {
@content;
}
}
@mixin super-desktop-begin() {
@include responsive-to(super-desktop-begin) {
@content;
}
}
@mixin mobile-end() {
@include responsive-to(mobile-end) {
@content;
}
}
@mixin tablet-end() {
@include responsive-to(tablet-end) {
@content;
}
}
@mixin mini-desktop-end() {
@include responsive-to(mini-desktop-end) {
@content;
}
}
@mixin small-desktop-end() {
@include responsive-to(small-desktop-end) {
@content;
}
}
@mixin medium-desktop-end() {
@include responsive-to(medium-desktop-end) {
@content;
}
}
@mixin large-desktop-end() {
@include responsive-to(large-desktop-end) {
@content;
}
}
@mixin super-desktop-end() {
@include responsive-to(super-desktop-begin) {
@content;
}
}

// 导入scss的list和map模块,用于处理相关操作。
@use 'sass:list';
@use "sass:map";
/*
媒体查询映射表,定义各种设备类型的媒体查询范围
key为定义的媒体类型,value为对应的分辨率范围
*/
$media-list: (
mobile-begin: (0, null),
mobile: (0, 800),
mobile-end:(null, 800),
tablet-begin: (801, null),
tablet: (801, 1023),
tablet-end:(null, 1023),
mini-desktop-begin: (1024, null),
mini-desktop: (1024, 1279),
mini-desktop-end: (null, 1279),
small-desktop-begin: (1280, null),
small-desktop: (1280, 1439),
small-desktop-end: (null, 1439),
medium-desktop-begin: (1440, 1919),
medium-desktop: (1440, 1919),
medium-desktop-end: (null, 1919),
large-desktop-begin: (1920, null),
large-desktop: (1920, 2559),
large-desktop-end: (null, 2559),
super-desktop-begin: (2560, null),
super-desktop: (2560, null),
super-desktop-end: (2560, null)
);
/*
创建响应式媒体查询的函数,传参是媒体查询映射表中的媒体类型
从$media-list中获取对应的最小和最大宽度,并返回相应的媒体查询字符串。
*/
@function createResponsive($media) {
$size-list: map.get($media-list, $media);
$min-size: list.nth($size-list, 1);
$max-size: list.nth($size-list, 2);
@if ($min-size and $max-size) {
@return "screen and (min-width:#{$min-size}px) and (max-width: #{$max-size}px)";
} @else if ($max-size) {
@return "screen and (max-width: #{$max-size}px)";
} @else {
@return "screen and (min-width:#{$min-size}px)";
}
}
/*
这个混入接受一个或多个媒体类型参数,调用createResponsive函数生成媒体查询
@content是Scss中的一个占位符,用于在混入中定义块级内容。
它允许你在调用混入时,将实际的样式代码插入到混入定义的样式规则中。
*/
@mixin responsive-to($media...) {
@each $item in $media {
$media-content: createResponsive($item);
@media #{$media-content} {
@content;
}
}
}
// 以下是针对各种媒体类型定义的混入:
@mixin mobile() {
@include responsive-to(mobile) {
@content;
}
}
@mixin tablet() {
@include responsive-to(tablet) {
@content;
}
}
@mixin mini-desktop() {
@include responsive-to(mini-desktop) {
@content;
}
}
@mixin small-desktop() {
@include responsive-to(small-desktop) {
@content;
}
}
@mixin medium-desktop() {
@include responsive-to(medium-desktop) {
@content;
}
}
@mixin large-desktop() {
@include responsive-to(large-desktop) {
@content;
}
}
@mixin super-desktop() {
@include responsive-to(super-desktop) {
@content;
}
}
@mixin mobile-begin() {
@include responsive-to(mobile-begin) {
@content;
}
}
@mixin tablet-begin() {
@include responsive-to(tablet-begin) {
@content;
}
}
@mixin mini-desktop-begin() {
@include responsive-to(mini-desktop-begin) {
@content;
}
}
@mixin small-desktop-begin() {
@include responsive-to(small-desktop-begin) {
@content;
}
}
@mixin medium-desktop-begin() {
@include responsive-to(medium-desktop-begin) {
@content;
}
}
@mixin large-desktop-begin() {
@include responsive-to(large-desktop-begin) {
@content;
}
}
@mixin super-desktop-begin() {
@include responsive-to(super-desktop-begin) {
@content;
}
}
@mixin mobile-end() {
@include responsive-to(mobile-end) {
@content;
}
}
@mixin tablet-end() {
@include responsive-to(tablet-end) {
@content;
}
}
@mixin mini-desktop-end() {
@include responsive-to(mini-desktop-end) {
@content;
}
}
@mixin small-desktop-end() {
@include responsive-to(small-desktop-end) {
@content;
}
}
@mixin medium-desktop-end() {
@include responsive-to(medium-desktop-end) {
@content;
}
}
@mixin large-desktop-end() {
@include responsive-to(large-desktop-end) {
@content;
}
}
@mixin super-desktop-end() {
@include responsive-to(super-desktop-begin) {
@content;
}
}
需求解决思路:
- 根据提供的设计稿,使用
autoMax
系列方法,对页面做初步的响应式布局适配 - 针对不同屏幕下部分元素布局需要调整的问题,使用封装的媒体查询方法进行处理
书写规范:
为避免项目中的scss
文件过多,搞得整个项目看上去很臃肿,现提供一套书写规范:
- 在每个路由下的主
index.vue
文件中,引入同级文件夹scss
下的media.scss
文件
// 小屏状态下,覆盖前面定义的css样式
media.css
文件
写法:以vue
文件最外层的类进行包裹,使用deep
穿透,以屏幕分辨率大小作为排序依据,从大到小书写媒体查询样式
.about-wrapper::v-deep {
@include small-desktop {
.a {
.b {
}
}
}
@include mini-desktop {
.a {
.b {
}
}
}
@include tablet-end {
.a {
.b {
}
}
}
}
结语
感谢掘友们耐心看到文末,希望你们不是一路跳转至评论区,我们江湖再见!
来源:juejin.cn/post/7388753413309775887
淘宝、京东复制好友链接弹出商品详情是如何实现的
前言: 最近接到了一个需求很有意思,类似于我们经常在逛购物平台中,选择一个物品分享给好友,然后好友复制这段文本打开相对应的平台以后,就可以弹出链接上的物品。实现过程也比较有意思,特来分享一下实现思路🎁。
一. 效果预览
当我在别的界面复制了内容以后,回到主应用,要求可以检测到当前剪切板是什么内容。
二. 监听页面跳转动作
- 要完成这个需求,整体思路并不复杂。首先我们要解决的就是如何检测到用户从别的应用切回到我们自己的应用。
- 这个听起来很复杂,但其实浏览器已经提供了相对应的
api
来帮我们检测用户这个操作----document.visibilitychange
。
- 那么我们就可以写下如下代码
document.addEventListener("visibilitychange", () => {
console.log("用户切换了");
});
相对应的效果如下图所示,你可能会好奇,我明明只切换了一次,但是为什么控制台却执行了两次打印?
这也不难理解,首先你要理解这个change
这个动作,你从 tab1 切换到 Tab2 的时候,触发了当前 Tab1 从可见 变为=> 不可见 。
而当你从 tab2 切回 tab1 的时候,触发了当前 Tab1 从不可见变为了可见。完整动作引起了状态两次变化,所以才有了两次打印。 - 而我们的场景只是希望 app 从不可见转变为可见的时候才触发。那么我们就需要用到��外一个变量来配合使用-------
document.visibilityState
。
这个值是一个document
对象上的一个只读属性,它有三个string
类型的值visible
、hidden
、prerender
。从它的使用说明中不难看出,我们要使用的值是visible
。
tips:hidden 可以用来配合做一些流量控制优化,当用户切换网页到后台的时候,我们可以停止一些不必要的轮询任务,待用户切回后再开启。
- 那么我们现在的代码应该是这样的:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
console.log("页面变得可见了!");
}
});
可以看到,现在控制台正确的只执行了一次。
三. 完成读取剪切板内容
- 要完成读取剪切板内容需要用到浏览器提供的另外一个
api
-----navigator.clipboard
。这里穿插一个英语记忆的小技巧,我们要把这个单词分成两部分记忆:clip 和 board。clip 本身就有修剪的意思,board 常作为木板相近的含义和别的单词组合,如:黑板 blackboard、棋盘 chessboard。所以这两个单词组合起来的含义就是剪切板。
- 这里需要注意一句话,这个功能只能用在安全上下文中。这个概念很抽象,如果想深入了解的话,还需自行查阅资料。这里指简单说明这句话的限制:要想使用这个
api
你只能在localhost、127.0.0.1
这样的本地回环地址或者使用https
协议的网站中使用。
- 要快速检测当前浏览器或者网站是否是安全上下文 ,可以使用
Window:isSecureContext
属性来判断。 - 你可以动手访问一个 http 的网站,然后在控制台打印一下该属性,你大概率会看到一个
false
,则说明该环境不是一个安全上下文,所以 clipboard 在这个环境下大概率不会生效。因为本文章代码都为本地开发(localhost),所以自然为安全上下文。
- 经过上面的知识,那么我们就可以写出下面的兼容性代码。
- 前置步骤都已经完成,接下来就是具体读取剪切板内容了。关于读取操作,
clipboard
提供了两个api
-----read
和readText
。这里由于我们的需求很明确,我读取的链接本身就是一个字符串类型的数据,所以我们就直接选用readText
方法即可。稍后在第四章节我会介绍read
方法。 clipboard
所有操作都是异步会返回一个Promise
类型的数据的,所以这里我们的代码应该是这样的:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.readText().then((text) => {
console.log("text", text);
});
}, 1000);
} else {
console.log("不支持 clipboard");
}
}
});
注意⚠️:这里你会看到我使用了
setTimeout
来解决演示的问题,如果你正在跟着练习但是不明白原因,请查看下面链接:
关于 DOM exception: document is not focused 请查阅stackoverflow 文档未聚焦的解决方案
相应的效果如下图所示,可以看到我们已经可以正确读取刚刚剪切板的内容了。
- 此时,当拿到用户剪切板的内容以后,我们就可以根据某些特点来判断弹窗了。这里我随便使用了一个弹出组件来展示效果:
- 什么?到这里你还是没看懂和网购平台链接之间有什么关系?ok,让我们仔细看一下我分别从两家平台随手复制的两个链接,看出区别了吗?开头的字符串可以很明显看出是各家的名字。
- 那么我只需判断用户剪切板上的字符串是否符合站内的某项规则不就行了吗?让我来举个更具体的栗子,下面链接是我掘金的个人首页,假如用户此时复制了这段文本,然后跳转回我们自己的应用后,刚刚的代码就可以加一个逻辑判断,检测用户剪切板上的链接是否是以
juejin.cn
开头的,如果是则跳转首页;如果不是,那么什么事情也不做。
对应的代码如下:
- 那么相对应的效果如下,这也就是为什么复制某宝的链接到某东后没任何反应的原因。某东并不是没读取,而是读取后发现不是自家的就不处理罢了。
:
四*. 思维拓展:粘贴图片自动转链接的实现
- 用过相关写作平台的小伙伴大概对在编辑器中直接接粘贴图片的功能不陌生。如掘金的编辑器,当我复制一个图片以后,直接在编辑器中粘贴即可。掘金会自动将图片转换为一个链接,这样会极大的提高创作者的写作体验。
- 那么现在让我们继续发散思维来思考这个需求如何实现,这里我们先随便创建一个富文本框。
- 既然是粘贴图片,那么最起码我得知道用户什么时候进行粘贴操作吧?这还不简单,直接监听
paste
事件即可。
document.addEventListener("paste",()=>{
console.log("用户粘贴了")
})
实现的效果如下:
- 把之前的
clipboard.readText
替换为clipboard.read
以后你的代码应该是下面这样的:
document.addEventListener("paste", () => {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {
console.log("result", result);
});
}, 1000);
} else {
console.log("不支持 clipboard");
}
});
让我们复制一张图片到富文本区域执行粘贴操作后,控制台会打印以下信息:
clipboardItem
是一个数组,里面有很多子clipboardItem
,是数组的原因是因为你可以一下子复制多张图片,不过在这里我们只考虑一张图片的场景。- 这里我们取第一项,然后调用
clipboardItem.getType
方法,这个方法需要传递一个文件类型的参数,这里我们传入粘贴内容对应的类型即可,这里传入image/png
。
在控制台这里可以看到一下输出,就表示我们已经正确拿到图片的blob
的格式数据了。
- 此时我们就只需要把相对应的图片数据传递给后端或者
CDN
服务器,让它们返回一个与之对应的链接即可。在掘金的编辑器中,对应的请求就是get-image-url
这个请求。
- 然后调用
textarea.value + link
把链接补充到文章最后位置即可。
五. 源码
<script lang="ts" setup>
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {});
}, 1000);
} else {
console.log("不支持 clipboard");
}
}
});
document.addEventListener("paste", () => {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {
result[0].getType("image/png").then((blob) => {
console.log("blob", blob);
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onload = (e) => {
const img = document.createElement("img");
img.src = e.target?.result;
const wrapper = document.getElementById("han");
wrapper.appendChild(img);
};
});
});
}, 1000);
} else {
console.log("不支持 clipboard");
}
});
</script>
<template>
<div id="han" class="w-full h-full bg-blue">
<textarea class="w-300px h-300px"></textarea>
</div>
</template>
六. 思考 writeText 的用法
有了上面的经验,我相信你已经可以自己理解 clipboard
剩下的两个方法 write
和 writeText
了。你可以思考下面的问题:
为什么在掘金复制文章内容后,剪切板会自动加版权信息呢?。
如果你实现了,不妨在评论区写下你的思路~🌹
来源:juejin.cn/post/7385776238789181449
Dart中令人惊艳的8个用法(深入探索)
Dart是谷歌开发的现代化编程语言,凭借其简洁的语法和强大的功能,在开发者当中赢得了极高的声誉,尤其是在Flutter框架中发挥了巨大的作用。本文将介绍Dart中的8个令人惊艳的用法,这些用法不仅技术深度足够,充满启发性,而且能够让您的Dart编程效率飞速提升。
1. 泛型类型别名的高级应用
类型别名可以让你用简单的名称定义更复杂的类型,尤其是在处理大量嵌套的泛型时特别有用。
typedef ComplexList<T> = List<Map<T, T>>;
void main() {
// 适用于需要设置特定键值对类型的列表
ComplexList<String> complexList = [
{'key1': 'value1'},
{'key2': 'value2'},
];
// 复杂集合的操作
complexList.add({'key3': 'value3'});
print(complexList);
}
泛型类型别名可以更好地组织代码,增强代码的可读性。
2. Stream的高级处理技巧
利用Stream提供的各种操作符和转换器,能够更好地处理事件流和异步数据。
Stream<int> timedCounter(Duration interval, int maxCount) async* {
int count = 0;
while (count < maxCount) {
await Future.delayed(interval);
yield ++count;
}
}
void main() async {
// 监听Stream,执行特定逻辑
await for (final count in timedCounter(Duration(seconds: 1), 5)) {
print(count);
}
}
通过async*
和yield
,你可以构建出能够发射数据序列的Stream,为异步编程提供强大支持。
3. Isolate的轻量级并行计算
Isolate可以在不同的执行线程中运是执行并发操作的强大工具。
import 'dart:isolate';
Future<void> computeOnIsolate() async {
final receivePort = ReceivePort();
Isolate.spawn(_heavyComputation, receivePort.sendPort);
final message = await receivePort.first as String;
print(message);
}
void _heavyComputation(SendPort sendPort) {
// 很重的计算
// 假设这是一个令CPU满负荷的操作
sendPort.send('计算完成');
}
void main() {
computeOnIsolate();
}
通过Isolate,你可以在Flutter应用中执行耗时操作而不影响应用的响应性。
4. 使用枚举的高级技巧
枚举类型不仅仅可以代表一组命名常量,通过扩展方法,可以大幅提升它们的功能。
enum ConnectionState {
none,
waiting,
active,
done,
}
extension ConnectionStateX on ConnectionState {
bool get isTerminal => this == ConnectionState.done;
}
void main() {
final state = ConnectionState.active;
print('Is the connection terminal? ${state.isTerminal}');
}
枚举类型的扩展性提供了类似面向对象的模式,从而可以在保证类型安全的前提下,增加额外的功能。
5. 使用高级const构造函数
const构造函数允许在编译时创建不可变实例,有利于性能优化。
class ImmutableWidget {
final int id;
final String name;
const ImmutableWidget({this.id, this.name});
@override
String toString() => 'ImmutableWidget(id: $id, name: $name)';
}
void main() {
const widget1 = ImmutableWidget(id: 1, name: 'Widget 1');
const widget2 = ImmutableWidget(id: 1, name: 'Widget 1');
// 标识符相同,它们是同一个实例
print(identical(widget1, widget2)); // 输出: true
}
使用const构造函数创建的实例,由于它们是不可变的,可以被Dart VM在多个地方重用。
6. 元数据注解与反射
虽然dart:mirrors
库在Flutter中不可用,但理解元数据的使用可以为你提供设计灵感。
import 'dart:mirrors'; // 注意在非Web平台上不可用
class Route {
final String path;
const Route(this.path);
}
@Route('/login')
class LoginPage {}
void main() {
final mirror = reflectClass(LoginPage);
for (final instanceMirror in mirror.metadata) {
final annotation = instanceMirror.reflectee;
if (annotation is Route) {
print('LoginPage的路由是: ${annotation.path}');
}
}
}
通过注解,你可以给代码添加可读的元数据,并通过反射在运行时获取它们,为动态功能提供支持,虽然在Flutter中可能会借助其他方式如代码生成来实现。
7. 匿名mixin
创建匿名mixin能够在不暴露mixin到全局作用域的情况下复用代码。
class Bird {
void fly() {
print('飞翔');
}
}
class Swimmer {
void swim() {
print('游泳');
}
}
class Duck extends Bird with Swimmer {}
void main() {
final duck = Duck();
duck.fly();
duck.swim();
}
利用匿名mixin可以在不同的类中混入相同的功能而不需要创建明显的类层次结构,实现了代码的复用。
8. 高级异步编程技巧
在异步编程中,Dart提供了Future、Stream、async和await等强大的工具。
Future<String> fetchUserData() {
// 假设这是一个网络请求
return Future.delayed(Duration(seconds: 2), () => '用户数据');
}
Future<void> logInUser(String userId) async {
print('尝试登录用户...');
try {
final data = await fetchUserData();
print('登录成功: $data');
} catch (e) {
print('登录失败: $e');
}
}
void main() {
logInUser('123');
}
通过使用async
和await
,可以编写出看起来像同步代码的异步操作,使得异步代码更加简洁和易于理解。
来源:juejin.cn/post/7321526403434315811
用了这么多年的字体,你知道它是怎么解析的吗?
大家好呀。
因为之前有过字体解析的相关代码开发,一直想把这个过程记录下来,总觉得这个并不是很难,自认为了解得不算全面,就一拖再拖了。今天仅做简单的抛砖引玉,通过本篇文章,可以知道Opentype.js是如何解析字体的。在遇到对应问题的时候,可以有其它的思路去解决。比如:.ttc的解析。又或者好奇我们开发软件过程中字体是如何解析的。
Opentype.js 使用
看官方readme也可以,这里直接将github代码下载,使用自动化测试目录里的字体文件。
需要注意的是load方法已经被废弃。
function load() {
console.error('DEPRECATED! migrate to: opentype.parse(buffer, opt) See: https://github.com/opentypejs/opentype.js/issues/675');
}
将package.json
设置为type: module
,然后就可以直接使用import
了。
import { parse } from './src/opentype.mjs';
import fs from 'fs';
// test/fonts/AbrilFatface-Regular.otf
const buffer = fs.promises.readFile('./test/fonts/AbrilFatface-Regular.otf');
// if not running in async context:
buffer.then(data => {
const font = parse(data);
console.log(font.tables);
})
这样就能得到解析的结果了。
Opentype源码阅读
parseBuffer:解析的入口
通过简单的调用入口,我们可以反查源码。传入文件的ArrayBuffer并返回Font结构的对象,在不清楚会有什么结构的时候,可以通过Font查看,当然了,直接console.log查看更方便。
// Public API ///////////////////////////////////////////////////////////
/**
* Parse the OpenType file data (as an ArrayBuffer) and return a Font object.
* Throws an error if the font could not be parsed.
* @param {ArrayBuffer}
* @param {Object} opt - options for parsing
* @return {opentype.Font}
*/
function parseBuffer(buffer, opt={}) {
// ...
// should be an empty font that we'll fill with our own data.
const font = new Font({empty: true});
}
export {
// ...
parseBuffer as parse,
// ...
};
字体类型判断
接着往下阅读。
根据signature
的值,去确认字体类型。粗略看来,这里仅支持了TrueType
(.ttf)、CFF
(.otf)、WOFF
、WOFF2
。
const signature = parse.getTag(data, 0);
if (signature === String.fromCharCode(0, 1, 0, 0) || signature === 'true' || signature === 'typ1') {
} else if (signature === 'OTTO') {
} else if (signature === 'wOFF') {
} else if (signature === 'wOF2') {
} else {
throw new Error('Unsupported OpenType signature ' + signature);
}
还需要注意的是,signature
的值是的获取(后续基本都是这样婶儿获取的信息)。从指定偏移位置开始,读取4个字节的数据,并将每个字节转换为字符,最终返回一个4字符的字符串标签。
// Retrieve a 4-character tag from the DataView.
// Tags are used to identify tables.
function getTag(dataView, offset) {
let tag = '';
for (let i = offset; i < offset + 4; i += 1) {
tag += String.fromCharCode(dataView.getInt8(i));
}
return tag;
}
表入口信息获取
再看TrueType
和CFF
字体的处理,除了对font.outlinesFormat
属性的设置之外。剩余的处理方式都是:获取表的个数numTables
,再获取表的入口偏移信息。
numTables = parse.getUShort(data, 4);
tableEntries = parseOpenTypeTableEntries(data, numTables);
// Table Directory Entries //////////////////////////////////////////////
/**
* Parses OpenType table entries.
* @param {DataView}
* @param {Number}
* @return {Object[]}
*/
function parseOpenTypeTableEntries(data, numTables) {
const tableEntries = [];
let p = 12;
for (let i = 0; i < numTables; i += 1) {
const tag = parse.getTag(data, p);
const checksum = parse.getULong(data, p + 4);
const offset = parse.getULong(data, p + 8);
const length = parse.getULong(data, p + 12);
tableEntries.push({tag: tag, checksum: checksum, offset: offset, length: length, compression: false});
p += 16;
}
return tableEntries;
}
function getUShort(dataView, offset) {
return dataView.getUint16(offset, false);
}
// Retrieve an unsigned 32-bit long from the DataView.
// The value is stored in big endian.
function getULong(dataView, offset) {
return dataView.getUint32(offset, false);
}
留意到tableEntries
获取的offset是从12开始的,而获取numTables
是从4开始的,也仅仅是getUnit16
,也就是说4-12中间还会有别的信息。
表信息标准描述
这时候只能通过查看微软排版文档描述,Microsoft Typography documentation: Organization of an OpenType Font。
按照8bit计算,这些信息之后,刚好是在12个字节开始。
后续的描述就是parseOpenTypeTableEntries
的结构信息了。
表入口数据
以选择的AbrilFatface-Regular.otf 为例。我们可以打断点看看,这两步骤得到的结果,这里Opentype提供了网址,就直接在上面断点了。
这里有11个表,在入口分别有对应的名称、偏移量、长度、校验和。
表数据解析
有了表入口信息,就可以通过tableEntries
获取表的数据了。接下来的代码就是通过对应的tag(name)
去选择对应的解析方式。有些表的信息需要依赖于别的表,则先暂时存起来。比如: name表需要依赖language表。
case 'ltag':
table = uncompressTable(data, tableEntry);
ltagTable = ltag.parse(table.data, table.offset);
break;
// ...
case 'name':
nameTableEntry = tableEntry;
break;
// ...
const nameTable = uncompressTable(data, nameTableEntry);
font.tables.name = _name.parse(nameTable.data, nameTable.offset, ltagTable);
font.names = font.tables.name;
这里就简单看下ltag
表的解析,table = uncompressTable(data, tableEntry);
判断是否有压缩,比如WOFF压缩字体,这里没有entry数据就还是原来的。
ltag表的解析
function parseLtagTable(data, start) {
const p = new parse.Parser(data, start);
const tableVersion = p.parseULong();
check.argument(tableVersion === 1, 'Unsupported ltag table version.');
// The 'ltag' specification does not define any flags; skip the field.
p.skip('uLong', 1);
const numTags = p.parseULong();
const tags = [];
for (let i = 0; i < numTags; i++) {
let tag = '';
const offset = start + p.parseUShort();
const length = p.parseUShort();
for (let j = offset; j < offset + length; ++j) {
tag += String.fromCharCode(data.getInt8(j));
}
tags.push(tag);
}
return tags;
}
创了p
这个Parser
实例,包含各种长度parseShort
、parseULong
等。自动移动offset,避免每次手动传入位置。获取了table的version信息,而后就是循环的获取表内容了。找了好些个字体,都没有ltag表🤦🏻♀️
解析小结
这里我们可以初步的了解到整个字体的解析过程,就是按照约定的顺序,有个线头般一点儿一点儿的找到所需,只储存了数据。
如需获取最终字形信息,可能需要经过多个表联合查询,比如loca获取字形数据的偏移量,glyf获取字形数据,又或者camp获取字符代码对应的字形索引。
TTC字体集合的解析
回到前面提出的,ttc字体集合,应该怎么解析呢?参照文档对字体集合的处理 Font Collections,相信大家已经有办法解析了。
注意:这里截图给出的是1.0的结构,更多的查看文档。
最后
这次的分享就到这里了,对一些有按需解析,自定义解析的场景下,希望对大家有帮助。
来源:juejin.cn/post/7400072326199640100
前端身份验证终极指南:Session、JWT、SSO 和 OAuth 2.0
Hello,大家好,我是 Sunday
在前端项目开发中,验证用户身份主要有 4 种方式:Session、JWT、SSO 和 OAuth 2.0
。
那么这四种方式各有什么优缺点呢?今天,咱们就来对比下!
01:基于 Session 的经典身份验证方案
什么是基于Session的身份验证?
基于 Session 的身份验证是一种在前端和后端系统中常用的用户认证方法。
它主要依赖于服务器端创建和管理用户会话。
Session 运行的基本原理
Session 的运行流程分为 6 步:
- 用户登录:用户在登录页面输入凭据(如用户名和密码)。这些凭据通过前端发送到后端服务器进行验证。
- 创建会话:后端服务器验证凭据后,创建一个会话(session)。这个会话通常包括一个唯一的会话 ID,该 ID 被存储在服务器端的会话存储中。
- 返回会话 ID:服务器将会话 ID 返回给前端,通常是通过设置一个 cookie。这个 cookie 被发送到用户的浏览器,并在后续的请求中自动发送回服务器。
- 保存会话 ID:浏览器保存这个 cookie,并在用户每次向服务器发起请求时都会自动包含这个 cookie。这样,服务器就能识别出该用户的会话,从而实现身份验证。
- 会话验证:服务器根据会话 ID 查找和验证该用户的会话信息,并确定用户的身份。服务器可以使用会话信息来确定用户的权限和访问控制。
- 会话过期与管理:服务器可以设置会话过期时间,定期清除过期的会话。用户注销或会话超时后,服务器会删除或使会话失效。
通过以上流程,我们可以发现:基于 Session 的身份验证,前端是不需要主动参与的。核心是 浏览器 和 服务器 进行处理
优缺点
优点:
- 简单易用:对开发者而言,管理会话和验证用户身份相对简单。
- 兼容性好:大多数浏览器支持 cookie,能够自动发送和接收 cookie。
缺点:
- 扩展性差:在分布式系统中,多个服务器可能需要共享会话存储,这可能会增加复杂性。
- 必须配合 HTTPS:如果 cookie 被窃取,可能会导致会话劫持。因此需要使用 HTTPS 来保护传输过程中的安全性,并实施其他安全措施(如设置 cookie 的
HttpOnly
和Secure
属性)。
示例代码
接下来,我们通过 Express 实现一个基本的 Session 验证示例
const express = require('express');
const session = require('express-session');
const app = express();
// 配置和使用 express-session 中间件
app.use(session({
secret: 'your-secret-key', // 用于签名 Session ID cookie 的密钥,确保会话的安全
resave: false, // 是否每次请求都重新保存 Session,即使 Session 没有被修改
saveUninitialized: true, // 是否保存未初始化的 Session
cookie: {
secure: true, // 是否只通过 HTTPS 发送 cookie,设置为 true 需要 HTTPS 支持
maxAge: 24 * 60 * 60 * 1000 // 设置 cookie 的有效期,这里设置为 24 小时
}
}));
// 登录路由处理
app.post('/login', (req, res) => {
// 进行用户身份验证(这里假设用户已经通过验证)
// 用户 ID 应该从数据库或其他存储中获取
const user = { id: 123 }; // 示例用户 ID
req.session.userId = user.id; // 将用户 ID 存储到 Session 中
res.send('登录成功');
});
app.get('/dashboard', (req, res) => {
if (req.session.userId) {
// 如果 Session 中存在用户 ID,说明用户已登录
res.send('返回内容...');
} else {
// 如果 Session 中没有用户 ID,说明用户未登录
res.send('请登录...'); // 提示用户登录
}
});
app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});
02:基于 JWT(JSON Web Token) 的身份验证方案
什么是基于 JWT 的身份验证?
这应该是我们目前 最常用 的身份验证方式。
服务端返回 Token
表示用户身份令牌。在请求中,把 token
添加到请求头中,以验证用户信息。
因为 HTTP
请求本身是无状态的,所以这种方式也被成为是 无状态身份验证方案
JWT 运行的基本原理
- 用户登录:用户在登录页面输入凭据(如用户名和密码),这些凭据通过前端发送到后端服务器进行验证。
- 生成 JWT:后端服务器验证用户凭据后,生成一个 JWT。这个 JWT 通常包含用户的基本信息(如用户 ID)和一些元数据(如过期时间)。
- 返回 JWT:服务器将生成的 JWT 发送回前端,通常通过响应的 JSON 数据返回。
- 存储 JWT:前端将 JWT 存储在客户端(Token),通常是 localStorage 。极少数的情况下会保存在 cookie 中(但是需要注意安全风险,如:跨站脚本攻击(XSS)和跨站请求伪造(CSRF))
- 使用 JWT 进行请求:在用户进行 API 调用时,前端将 JWT(Token) 附加到请求的 Authorization 头部(格式为
Bearer
)发送到服务器。 - 验证 JWT:服务器接收到请求后,提取 JWT(Token) 并验证其有效性。验证过程包括检查签名、过期时间等。如果 JWT 合法,服务器会处理请求并返回相应的资源或数据。
- 响应请求:服务器处理请求并返回结果,前端根据需要展示或处理这些结果。
优缺点
优点
- 无状态:JWT 是自包含的,不需要在服务器端存储会话信息,简化了扩展性和负载均衡。
- 跨域支持:JWT 可以在跨域请求中使用(例如,API 与前端分离的场景)。
缺点
- 安全性:JWT 的安全性取决于密钥的保护和有效期的管理。JWT 一旦被盗用,可能会带来安全风险。
示例代码
接下来,我们通过 Express 实现一个基本的 JWT 验证示例
const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
const secretKey = 'your-secret-key'; // JWT 的密钥,用于签名和验证
// 登录路由,生成 JWT
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 用户身份验证(假设验证通过)
const user = { id: 1, username: 'user' }; // 示例用户信息
const token = jwt.sign(user, secretKey, { expiresIn: '24h' }); // 生成 JWT
res.json({ token }); // 返回 JWT
});
// 受保护的路由
app.get('/dashboard', (req, res) => {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).send('没有提供令牌');
}
jwt.verify(token, secretKey, (err, decoded) => {
if (err) {
return res.status(401).send('无效的令牌');
}
res.send('返回仪表板内容');
});
});
app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});
03:基于 SSO 的身份验证方案
什么是基于 SSO(Single Sign-On,单点登录) 的身份验证?
SSO 身份验证多用在 “成套” 的应用程序中,通过 登录中心 的方式,可以实现 一次登录,在多个应用中均可以获取身份
SSO 运行的基本原理
- 用户访问应用:用户访问一个需要登录的应用(称为服务提供者或 SP)。
- 重定向到身份提供者:由于用户尚未登录,应用会将用户重定向到 SSO 身份提供者(Identity Provider,简称 IdP)(一般称为 登录中心)。登录中心 是负责处理用户登录和身份验证的系统。
- 用户登录:用户在 登录中心 输入凭据进行登录。如果用户已经在 IdP 处登录过(例如,已登录到公司内部的 SSO 系统),则可能直接跳过登录步骤。
- 生成 SSO 令牌:SSO 身份提供者验证用户身份后,生成一个 SSO 令牌(如 OAuth 令牌或 SAML 断言),并将用户重定向回原应用,同时附带令牌。
- 令牌验证:原应用(服务提供者)接收到令牌后,会将其发送到 SSO 身份提供者进行验证。SSO 身份提供者返回用户的身份信息。
- 用户访问应用:一旦身份验证成功,原应用会根据用户的身份信息提供访问权限。用户现在可以访问应用中的受保护资源,而无需再次登录。
- 访问其他应用:如果用户访问其他应用,这些应用会重定向用户到相同的 登录中心 进行身份验证。由于用户已经登录,登录中心 会自动验证并将用户重定向回目标应用,从而实现无缝登录。
优缺点
优点
- 简化用户体验:用户只需登录一次,即可访问多个应用或系统,减少了重复登录的麻烦。
- 集中管理:管理员可以集中管理用户的身份和访问权限,提高了管理效率和安全性。
- 提高安全性:减少了密码泄露的风险,因为用户只需记住一个密码,并且可以使用更强的认证机制(如多因素认证)。
缺点
- 单点故障:如果 登录中心 出现问题,可能会影响所有依赖该 SSO 服务的应用。
- 复杂性:SSO 解决方案的部署和维护可能较为复杂,需要确保安全配置和互操作性。
常见的 SSO 实现技术
- SAML(Security Assertion Markup Language):
- 一个 XML-based 标准,用于在身份提供者和服务提供者之间传递认证和授权数据。
- 常用于企业环境中的 SSO 实现。
- OAuth 2.0 和 OpenID Connect:
- OAuth 2.0 是一种授权框架,用于授权第三方访问用户资源。
- OpenID Connect 是建立在 OAuth 2.0 之上的身份层,提供用户身份认证功能。
- 常用于 Web 和移动应用中的 SSO 实现。
- CAS(Central Authentication Service):
- 一个用于 Web 应用的开源 SSO 解决方案,允许用户通过一次登录访问多个 Web 应用。
04:基于 OAuth 2.0 的身份验证方案
什么是基于 OAuth 2.0 的身份验证?
基于 OAuth 2.0 的身份验证是一种用于授权第三方应用访问用户资源的标准协议。常见的有:微信登录、QQ 登录、APP 扫码登录等
OAuth 2.0 主要用于授权,而不是身份验证,但通常与身份验证结合使用来实现用户登录功能。
OAuth 2.0 运行的基本原理
OAuth 2.0 比较复杂,在了解它的原理之前,我们需要先明确一些基本概念。
OAuth 2.0 的基本概念
- 资源拥有者(Resource Owner):通常是用户,拥有需要保护的资源(如个人信息、文件等)。
- 资源服务器(Resource Server):提供资源的服务器,需要保护这些资源免受未经授权的访问。
- 客户端(Client):需要访问资源的应用程序或服务。客户端需要获得资源拥有者的授权才能访问资源。
- 授权服务器(Authorization Server):责认证资源拥有者并授权客户端访问资源。它颁发访问令牌(Access Token)给客户端,允许客户端访问资源服务器上的受保护资源。
运行原理
- 用户授权:用户使用客户端应用进行操作时,客户端会请求授权访问用户的资源。用户会被重定向到授权服务器进行授权。
- 获取授权码(Authorization Code):如果用户同意授权,授权服务器会生成一个授权码,并将其发送回客户端(通过重定向 URL)。
- 获取访问令牌(Access Token):客户端使用授权码向授权服务器请求访问令牌。授权服务器验证授权码,并返回访问令牌。
- 访问资源:客户端使用访问令牌向资源服务器请求访问受保护的资源。资源服务器验证访问令牌,并返回请求的资源。
常见的授权流程
- 授权码流程(Authorization Code Flow):最常用的授权流程,适用于需要与用户交互的客户端(如 Web 应用)。用户在授权服务器上登录并授权,客户端获取授权码后再交换访问令牌。
- 隐式流程(Implicit Flow):适用于公共客户端(如单页应用)。用户直接获得访问令牌,适用于不需要安全存储的情况,但不推荐用于高度安全的应用。
- 资源所有者密码凭据流程(Resource Owner Password Credentials Flow):适用于信任客户端的情况。用户直接将用户名和密码提供给客户端,客户端直接获得访问令牌。这种流程不推荐用于公开的客户端。
- 客户端凭据流程(Client Credentials Flow):适用于机器对机器的情况。客户端直接向授权服务器请求访问令牌,用于访问与客户端本身相关的资源。
优缺点
优点
- 灵活性:OAuth 2.0 支持多种授权流程,适应不同类型的客户端和应用场景。
- 安全性:通过分离授权和认证,增强了系统的安全性。使用令牌而不是用户名密码来访问资源。
缺点
- 复杂性:OAuth 2.0 的实现和配置可能较复杂,需要正确管理访问令牌和刷新令牌。
- 安全风险:如果令牌泄露,可能会导致安全风险。因此需要采取适当的安全措施(如使用 HTTPS 和适当的令牌管理策略)。
示例代码
接下来,我们通过 Express 实现一个基本的 OAuth 2.0 验证示例
const express = require('express');
const axios = require('axios');
const app = express();
// OAuth 2.0 配置
const clientId = 'your-client-id';
const clientSecret = 'your-client-secret';
const redirectUri = 'http://localhost:3000/callback';
const authorizationServerUrl = 'https://authorization-server.com';
const resourceServerUrl = 'https://resource-server.com';
// 登录路由,重定向到授权服务器
app.get('/login', (req, res) => {
const authUrl = `${authorizationServerUrl}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=read`;
res.redirect(authUrl);
});
// 授权回调路由,处理授权码
app.get('/callback', async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).send('Authorization code is missing');
}
try {
// 请求访问令牌
const response = await axios.post(`${authorizationServerUrl}/token`, {
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret
});
const { access_token } = response.data;
// 使用访问令牌访问资源
const resourceResponse = await axios.get(`${resourceServerUrl}/user-info`, {
headers: { Authorization: `Bearer ${access_token}` }
});
res.json(resourceResponse.data);
} catch (error) {
res.status(500).send('Error during token exchange or resource access');
}
});
app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});
总结一下
目前这四种验证方案均有对应的 优缺点、应用场景:
- Session:非常适合简单的服务器呈现的应用程序
- JWT:适用于现代无状态架构和移动应用
- SSO:非常适合具有多种相关服务的企业环境
- OAuth 2.0:第三方集成和 API 访问的首选
来源:juejin.cn/post/7399986979736322063
💥图片碎片化展示-Javascript
写在开头
哈喽吖!各位好!😁
今天刚好是周四呢,疯狂星期四快整起来。🍔🍟🍗
最近,小编从玩了两年多的游戏中退游了😔,本来以为会一直就这么玩下去,和队友们相处很融洽,收获了很多开心快乐的时光😭。可惜,游戏的一波更新......准备要开始收割韭菜了,只能无奈选择弃坑了。

小编属于贫民玩家,靠着硬肝与白嫖也将游戏号整得还不错,这两天把号给卖了💰。玩了两年多,竟然还能赚一点小钱,很开心😛。只是...多少有点舍不得的一起组队的队友们,唉。😔
记录一下,希望未来还有重逢一日吧,也希望各位一切安好!😆
好,回到正题,本文将分享一个图片碎片化展示的效果,具体效果如下,请诸君按需食用。

原理
这种特效早在几年前就已经出现,属于老演员了😪,它最早是经常在轮播图(banner)上应用的,那会追求各种花里胡哨的特效,而现在感觉有点返璞归真了,简洁实用就行。
今天咱们来看看它的具体实现原理是如何的,且看图:

一图胜千言,不知道聪明的你是否看明白了?😉
大概原理是:通过容器/图片大小生成一定数量的小块,然后每个小块背景也使用相同图片,再使用 background-size
与 background-position
属性调整背景图片的大小与位置,使小块又合成一整张大图片,这操作和使用"精灵图"的操作是一样的,最后,我们再给每个小块增加动画效果,就大功告成。
简单朴实😁,你可以根据这个原理自个尝试一下,应该能整出来吧。👻
具体实现
布局与样式:
<!DOCTYPE html>
<html>
<head>
<style>
body{
width: 100%;
height: 100vh;
padding: 0;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
.box {
width: var(--width);
height: var(--height);
display: flex;
/* 小块自动换行排列 */
flex-wrap: wrap;
justify-content: center;
}
.small-box {
background-image: url('https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/99b070fcb1de471d9af4f4d5d3f71909~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1120&h=1680&s=2088096&e=png&b=d098d0');
box-sizing: border-box;
background-repeat: no-repeat;
}
</style>
</head>
<body>
<div id="box" class="box"></div>
</body>
</html>
生成无数小块填充:
<script>
document.addEventListener('DOMContentLoaded', () => {
const box = document.getElementById('box');
const { width, height } = box.getBoundingClientRect();
// 定义多少个小块,由多少行和列决定
const row = 14;
const col = 10;
// 计算小块的宽高
const smallBoxWidth = width / col;
const smallBoxHeight = height / row;
/** @name 创建小块 **/
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
const smallBox = document.createElement('div');
smallBox.classList.add('small-box');
smallBox.style.width = smallBoxWidth + 'px';
smallBox.style.height = smallBoxHeight + 'px';
smallBox.style.border = '1px solid red';
// 插入小块
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
上面,生成多少个小块是由人为规定行(row
)与列(col
)来决定。可能有的场景想用小块固定的宽高来决定个数,这也是可以的,只是需要注意处理一下"边界"的情况。😶

调整小块背景图片的大小与位置:
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
smallBox.style.border = '1px solid red';
// 设置背景偏移量,让小块的背景显示对应图片的位置,和以前那种精灵图一样
const offsetX = j * smallBoxWidth * -1;
const offsetY = i * smallBoxHeight * -1;
smallBox.style.backgroundPosition = `${offsetX}px ${offsetY}px`;
smallBox.style.backgroundSize = `${width}px ${height}px`;
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
女神拼接成功,到这里就已经完成一大步了,是不是没什么难度!😋

小块样式整好后,接下来,我们需要来给小块增加动画,让它们动起来,并且是有规律的动起来。
先来整个简单的透明度动画,且看:
<!DOCTYPE html>
<html>
<head>
<style>
/* ... */
.small-box {
/* ... */
opacity: 0;
animation: smallBoxAnimate 2000ms linear forwards;
}
@keyframes smallBoxAnimate {
0% {
opacity: 0;
}
40% {
opacity: 0;
}
70% {
opacity: 1;
}
100% {
opacity: 1;
}
}
</style>
</head>
<body>
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
// smallBox.style.border = '1px solid red';
// 给每个小块增加不同的延时,让动画不同时间执行
const delay = i * 100; // 延迟时间为毫秒(ms),注意不要太小了
smallBox.style.animationDelay = `${delay}ms`;
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
</body>
</html>
嘿嘿😃,稍微有点意思了吧?


Em...等等,你发现没有?怎么有一些小白条?这可不是小编添加的,小块的边框(border
)已经是注释了的。😓
一开始小编以为是常见的"图片底部白边"问题,直接设置一下 display: block
或者 vertical-align : middle
就能解决,结果还不是,折腾了很久都没有搞掉这个小白条。😤
最后,竟然通过设置 will-change 属性能解决这个问题❗我所知道的 will-change
应该是应用在性能优化上,解决动画流畅度问题上的,想不到这里竟然也能用。
❗不对不对,当初以为是
will-change
能直接完美解决白边的问题,但是感觉还是不对,但又确实能解决。。。(部分电脑屏幕)
但其实,应该是
smallBoxWidth
与smallBoxHeight
变量不是整数的问题,只要小块的宽度与高度保持一个整数,自然就没有这些白边了❗这是比较靠谱的事实,对于当前的高清屏幕来说。
但是,也是很奇怪,在小编另一台电脑(旧电脑)上即使是保持了整数,也会在横向存在一些小白边,太难受了。。。没办法彻底搞定这个问题。
猜测应该是和屏幕分辨率有关,毕竟那才是根源所在。
2024年07月01日
看来得去深度学习一下💪 will-change
属性的原理过程才行,这里也推荐倔友写得一篇文章:传送门。
解决相邻背景图片白条/白边间隙问题:
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
smallBox.style.willChange = 'transform';
// 在动画执行后,需要重置will-change
const timer = setTimeout(() => {
smallBox.style.willChange = 'initial';
clearTimeout(timer);
}, 2000);
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
一定要注意 will-change
不可能被滥用,注意重置回来❗
这下女神在动画执行后,也清晰可见了,这是全部小块拼接组成的图片。

在上述代码中,咱们看到,通过 animation-delay
去延迟动画的执行,就能制造一个从上到下的渐变效果。
那么,咱们再改改延迟时间,如:
// const delay = i * 100;
// 改成 ⤵
const delay = j * 100;
效果:

这...好像有那么点意思吧。。。

但是,这渐变...好像还达不到我们开头 gif
的碎片化效果吧?
那么,碎片化安排上:
.small-box {
/* ... */
--rotateX: rotateX(0);
--rotateY: rotateY(0);
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
@keyframes smallBoxAnimate {
0% {
opacity: 0;
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
40% {
opacity: 0;
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
70% {
opacity: 1;
transform: rotateX(0) rotateY(0) scale(0.8);
}
100% {
opacity: 1;
transform: rotateX(0) rotateY(0) scale(1);
}
}
其实就是增加小块的样式动画而已,再加点旋转,再加点缩放,都整上,整上。😆
效果:

是不是稍微高级一点?有那味了?😁
看到上面旋转所用的"样式变量"没有?
--rotateX: rotateX(0);
--rotateY: rotateY(0);
不可能无缘无故突然使用,必然是有深意啦。😁
现在效果还不够炫,咱们将样式变量利用起来,让"相邻两个小块旋转相反":
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
// 相邻两个小块旋转相反
const contrary = (i + j) % 2 === 0;
smallBox.style.setProperty('--rotateX', `rotateX(${contrary ? -180 : 0}deg)`);
smallBox.style.setProperty('--rotateY', `rotateY(${contrary ? 0 : -180}deg)`);
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
效果:

这下对味了。😃
总的来说,我们可以通过"延迟"执行动画与改变"旋转"行为,让小块们呈现不同的动画效果,或者你只要有足够多的设想,你可以给小块添加不同的动画效果,相信也能制造出不错的整体效果。
更多效果
下面列举一些通过"延迟"执行动画产生的效果,可以瞧瞧哈。
随机:
const getRandom = (min, max) => Math.floor(Math.random() * (max - min + 1) + min);
const delay = getRandom(0, col + row) * 100;

从左上角到右下角:
const delay = (i + j) * 100;

其他的从"右上角到左下角"或者"左下角到右上角"等等的,只要反向调整一下变量就行了,就靠你自己悟啦,Come On!👻
从中心向四周扩散:
const delay = ((Math.abs(col / 2 - j) + Math.abs(row / 2 - i))) * 100;

从四周向中心聚齐:
const delay = (col / 2 - Math.abs(col / 2 - j) + (col / 2 - Math.abs(row / 2 - i))) * 100;

那么,到这里就差不多了❗
但还有最后一个问题,那就是图片的大量使用与加载时长的情况可能会导致效果展示不佳,这里你最好进行一些防范措施,如:
- 图片链接设置缓存,让浏览器缓存到内存或硬盘中。
- 通过
JS
手动将图片缓存到内存,主要就是创建Image
对象。 - 将图片转成
base64
使用。 - 直接将图片放到代码本地使用。
- ...
以上等等吧,反正最好就是要等图片完整加载后再进行效果展示。
至此,本篇文章就写完啦,撒花撒花。
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。
来源:juejin.cn/post/7379856289487831074
Canvas星空类特效
思路
- 绘制单个星星
- 在画布批量随机绘制星星
- 添加星星移动动画
- 页面resize处理
Vanilla JavaScript实现
- 初始化一个工程
pnpm create vite@latest
# 输入工程名后,类型选择Vanilla
cd <工程目录> pnpm install pnpm dev # 运行本地服务
body {
background-color: black;
overflow: hidden;
}
"use strict";
import './style.css';
document.querySelector('#app').innerHTML = `
`;
// 后续代码全部在main.js下添加即可
- 绘制单个星星
const hue = 220; // 颜色hue值可以根据自己喜好调整
// 离屏canvas不需要写入到静态html里,所以用createElement
const offscreenCanvas = document.createElement("canvas");
const offscreenCtx = offscreenCanvas.getContext("2d");
offscreenCanvas.width = 100;
offscreenCanvas.height = 100;
const half = offscreenCanvas.width / 2;
const middle = half;
// 设定径向渐变的范围,从画布中心到画布边缘
const gradient = offscreenCtx.createRadialGradient(
middle,
middle,
0,
middle,
middle,
half
);
// 添加多级颜色过渡,可以根据自己喜好调整
gradient.addColorStop(0.01, "#fff");
gradient.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`);
gradient.addColorStop(0.5, `hsl(${hue}, 64%, 6%)`);
gradient.addColorStop(1, "transparent");
// 基于渐变填充色,在画布中心为原点绘制一个圆形
offscreenCtx.fillStyle = gradient;
offscreenCtx.beginPath();
offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
offscreenCtx.fill();
参考链接:
- 在画布批量绘制星星
其实要绘制星星,我们只需要在画布上基于离屏画布来在指定位置将离屏画布渲染成图片即可,但是批量绘制以及后续的动画需要我们能记录每颗星星的位置、状态和行驶轨迹,所以可以考虑创建一个星星的类。
// 声明存放星星数据的数组,以及最大星星数量
const stars = [];
const maxStars = 1000;
// 用于提供随机值,不用每次都Math.random()
const random = (min, max) => {
if (!max) {
max = min;
min = 0;
}
if (min > max) {
[min, max] = [max, min];
}
return Math.floor(Math.random() * (max - min + 1)) + min;
};
// 用于计算当前以画布为中心的环绕半径
const maxOrbit = (_w, _h) => {
const max = Math.max(_w, _h);
const diameter = Math.round(Math.sqrt(max * max + max * max));
return diameter / 2;
};
class Star {
constructor(_ctx, _w, _h) {
this.ctx = _ctx;
// 最大轨道半径
this.maxOrbitRadius = maxOrbit(_w, _h);
// 轨道半径
this.orbitRadius = random(this.maxOrbitRadius);
// 星星大小(半径)
this.radius = random(60, this.orbitRadius) / 12;
// 环绕轨道中心,即画布中心点
this.orbitX = _w / 2;
this.orbitY = _h / 2;
// 随机时间,用于动画
this.elapsedTime = random(0, maxStars);
// 移动速度
this.speed = random(this.orbitRadius) / 500000;
// 透明度
this.alpha = random(2, 10) / 10;
}
// 星星的绘制方法
draw() {
// 计算星星坐标[x, y],使用sin和cos函数使星星围绕轨道中心做圆周运动
const x = Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX;
const y = Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY;
// 基于随机数调整星星的透明度
const spark = Math.random();
if (spark < 0.5 && this.alpha > 0) {
this.alpha -= 0.05;
} else if (spark > 0.5 && this.alpha < 1) {
this.alpha += 0.05;
}
// 调整全局绘制透明度,使后续绘制都基于这个透明度绘制,也就是绘制当前星星
// 因为动画里会遍历每一个星星进行绘制,所以透明度会来回改变
this.ctx.globalAlpha = this.alpha;
// 在星星所在的位置基于离屏canvas绘制一张星星的图片
this.ctx.drawImage(offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
// 时间基于星星的移动速度递增,为下一帧绘制做准备
this.elapsedTime += this.speed;
}
}
获取当前画布,批量添加星星
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let w = canvas.width = window.innerWidth;
let h = canvas.height = window.innerHeight;
for (let i = 0; i < maxStars; i++) {
stars.push(new Star(ctx, w, h));
}
- 添加星星的移动动画
function animation() {
// 绘制一个矩形作为背景覆盖整个画布,'source-over'是用绘制的新图案覆盖原有图像
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.8;
ctx.fillStyle = `hsla(${hue} , 64%, 6%, 1)`;
ctx.fillRect(0, 0, w, h);
// 绘制星星,'lighter'可以使动画过程中重叠的星星有叠加效果
ctx.globalCompositeOperation = 'lighter';
stars.forEach(star => {
star.draw();
});
window.requestAnimationFrame(animation);
}
// 调用动画
animation();
这样星星就动起来了。
- 页面resize处理
其实只需要在resize事件触发时重新设定画布的大小即可
window.addEventListener('resize', () => {
w = canvas.width = window.innerWidth;
h = canvas.height = window.innerHeight;
});
但是有一个问题,就是星星的运行轨迹并没有按比例变化,所以需要添加两处变化
// 在Star类里添加一个update方法
class Star {
constructor(_ctx, _w, _h) {//...//}
//添加部分
update(_w, _h) {
// 计算当前的最大轨道半径和类之前保存的最大轨道半径的比例
const ratio = maxOrbit(_w, _h) / this.maxOrbitRadius;
// 因为每帧动画都会调用这个方法,但比例没变化时不需要按比例改变移动轨道,所以加个判断
if (ratio !== 1) {
// 重新计算最大轨道半径
this.maxOrbitRadius = maxOrbit(_w, _h);
// 按比例缩放轨道半径和星星的半径
this.orbitRadius = this.orbitRadius * ratio;
this.radius = this.radius * ratio;
// 重新设置轨道中心点
this.orbitX = _w / 2;
this.orbitY = _h / 2;
}
}
draw() {//...//}
}
// 在animation函数里调用update
function animation() {
// ...
stars.forEach(star => {
star.update(w, h);
star.draw();
});
// ...
}
React实现
react实现主要需要注意resize事件的处理,怎样避免重绘时对星星数据初始化,当前思路是使用多个useEffect
import React, { useEffect, useRef, useState } from 'react';
const HUE = 217;
const MAX_STARS = 1000;
const random = (min: number, max?: number) => {
if (!max) {
max = min;
min = 0;
}
if (min > max) {
[min, max] = [max, min];
}
return Math.floor(Math.random() * (max - min + 1)) + min;
};
const maxOrbit = (_w: number, _h: number) => {
const max = Math.max(_w, _h);
const diameter = Math.round(Math.sqrt(max * max + max * max));
return diameter / 2;
};
// 离屏canvas只需要执行一次,但是直接在函数外部使用document.createElement会出问题
const getOffscreenCanvas = () => {
const offscreenCanvas = document.createElement('canvas');
const offscreenCtx = offscreenCanvas.getContext('2d')!;
offscreenCanvas.width = 100;
offscreenCanvas.height = 100;
const half = offscreenCanvas.width / 2;
const middle = half;
const gradient = offscreenCtx.createRadialGradient(middle, middle, 0, middle, middle, half);
gradient.addColorStop(0.01, '#fff');
gradient.addColorStop(0.1, `hsl(${HUE}, 61%, 33%)`);
gradient.addColorStop(0.5, `hsl(${HUE}, 64%, 6%)`);
gradient.addColorStop(1, 'transparent');
offscreenCtx.fillStyle = gradient;
offscreenCtx.beginPath();
offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
offscreenCtx.fill();
return offscreenCanvas;
};
class OffscreenCanvas {
static instance: HTMLCanvasElement = getOffscreenCanvas();
}
class Star {
orbitRadius!: number;
maxOrbitRadius!: number;
radius!: number;
orbitX!: number;
orbitY!: number;
elapsedTime!: number;
speed!: number;
alpha!: number;
ratio = 1;
offscreenCanvas = OffscreenCanvas.instance;
constructor(
private ctx: CanvasRenderingContext2D,
private canvasSize: { w: number, h: number; },
) {
this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
this.orbitRadius = random(this.maxOrbitRadius);
this.radius = random(60, this.orbitRadius) / 12;
this.orbitX = this.canvasSize.w / 2;
this.orbitY = this.canvasSize.h / 2;
this.elapsedTime = random(0, MAX_STARS);
this.speed = random(this.orbitRadius) / 500000;
this.alpha = random(2, 10) / 10;
}
update(size: { w: number, h: number; }) {
this.canvasSize = size;
this.ratio = maxOrbit(this.canvasSize.w, this.canvasSize.h) / this.maxOrbitRadius;
if (this.ratio !== 1) {
this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
this.orbitRadius = this.orbitRadius * this.ratio;
this.radius = this.radius * this.ratio;
this.orbitX = this.canvasSize.w / 2;
this.orbitY = this.canvasSize.h / 2;
}
}
draw() {
const x = (Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX);
const y = (Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY);
const spark = Math.random();
if (spark < 0.5 && this.alpha > 0) {
this.alpha -= 0.05;
} else if (spark > 0.5 && this.alpha < 1) {
this.alpha += 0.05;
}
this.ctx.globalAlpha = this.alpha;
this.ctx.drawImage(this.offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
this.elapsedTime += this.speed;
}
}
const StarField = () => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const animationRef = useRefnull>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
const [initiated, setInitiated] = useState(false);
const [stars, setStars] = useState<Star[]>([]);
// 这里会在画布准备好之后初始化星星,理论上只会执行一次
useEffect(() => {
if (canvasRef.current && canvasSize.w !== 0 && canvasSize.h !== 0 && !initiated) {
const ctx = canvasRef.current!.getContext('2d')!;
const _stars = Array.from({ length: MAX_STARS }, () => new Star(ctx, canvasSize));
setStars(_stars);
setInitiated(true);
}
}, [canvasSize.w, canvasSize.h]);
// 这里用于处理resize事件,并重新设置画布的宽高
useEffect(() => {
if (canvasRef.current) {
const resizeHandler = () => {
const { clientWidth, clientHeight } = canvasRef.current!.parentElement!;
setCanvasSize({ w: clientWidth, h: clientHeight });
};
resizeHandler();
addEventListener('resize', resizeHandler);
return () => {
removeEventListener('resize', resizeHandler);
};
}
}, []);
// 这里用于渲染动画,每次画布有变化时都会触发,星星初始化完成时也会触发一次
useEffect(() => {
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d')!;
canvasRef.current!.width = canvasSize.w;
canvasRef.current!.height = canvasSize.h;
const animation = () => {
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.8;
ctx.fillStyle = `hsla(${HUE} , 64%, 6%, 1)`;
ctx.fillRect(0, 0, canvasSize.w, canvasSize.h);
ctx.globalCompositeOperation = 'lighter';
stars.forEach((star) => {
if (star) {
star.update(canvasSize);
star.draw();
}
});
animationRef.current = requestAnimationFrame(animation);
};
animation();
return () => {
cancelAnimationFrame(animationRef.current!);
};
}
}, [canvasSize.w, canvasSize.h, stars]);
return (
<canvas ref={canvasRef}>canvas>
);
};
export default StarField;
来源:juejin.cn/post/7399983901811474484
1个demo带你入门canvas
canvas画布是前端一个比较重要的能力,在MDN上看到有关canvas的API时,是否会感到枯燥?今天老弟就给各位带来了1个还说得过去的demo,话不多说,咱们一起来尝尝咸淡。
一、小球跟随鼠标移动
先来欣赏一下这段视频:
从上图我们发现,小球跟着我们的鼠标移动,并且鼠标点到的位置就是小球的中心点。想要实现这样的功能,我们可以将它抽象为下面图里的样子:
是的,一个是画布(canvas)类,一个是小球(Ball)类。
canvas主要负责尺寸、执行绘画、事件监听。
Ball主要负责圆心坐标、半径、以及更新自己的位置。
接下来就是代码部分,我们先来完成canvas类的实现。
1.1、初始化canvas类属性
从上面的视频以及拆解的图里,我们会发现这个画布至少拥有以下几个属性:
- width。画布的宽度
- height。画布的高度
- element。画布的标签元素
- context。画布的渲染上下文
- events。这个画布上的事件集合
- setWidthAndHeight()。设置画布的大小
- draw()。用于执行绘画的函数
- addEventListener()。用于给canvas添加相应的监听函数
因此我们可以得出下面这段代码:
class Canvas {
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
}
}
1.2、设置canvas的大小
class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
}
在上面的代码里,我们手动实现了一个setWidthAndHeight
这样的函数,并且当执行new操作的时候,自动这个函数,从而达到设置几何元素大小的作用。
1.3、绘画小球
我们的这个绘画功能应该只负责绘画,也就是说这个小球的位置坐标等信息应该通过传参的形式传入到我们的draw函数里
。
class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
// 绘画小球
draw(ball){
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
}
1.4、给canvas添加事件监听
根据我们的需求,小球随着鼠标移动,说明我们应该是在canvas标签上监听mouseover事件。我们再来思考一下,其实这个事件监听函数也应该保持纯粹。纯粹的意思就是,你不要在这个方法里去写业务逻辑。这个函数只负责添加相应的事件监听。
接下来我们实现一下这个addEventListener函数。
class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
// 画物体
draw(ball){
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
// 添加监听器(eventName:要监听的事件,eventCallback:事件对应的处理函数)
addEventListener(eventName, eventCallback){
let finalEventName = eventName.toLocaleLowerCase();
if (!this.events.filter(item => item === finalEventName)?.length > 0){
this.events.push(finalEventName);
}
this.element['on' + finalEventName] = (target) => {
eventCallback && eventCallback(target);
}
}
}
好啦,Canvas类的实现到这里先告一段落,我们来看看小球(Ball)类的实现。
1.5、Ball类的初始化
Ball这个类比较简单,4个属性+一个方法。
属性分别是:
- centerX。小球的圆心横坐标X
- centerY。小球的圆心纵坐标Y
- background。小球的背景色
- radius。小球的半径
方法是updateLocation
函数。这个函数同样也是一个纯函数,只负责更新圆心坐标,更新的值也是由参数传递。
class Ball {
constructor(props){
this.centerX = props.centerX;
this.centerY = props.centerY;
this.radius = props.radius;
this.background = props.background || 'orange';
}
// 更新小球的地理位置
updateLocation(x, y){
this.centerX = x;
this.centerY = y;
}
}
1.6、添加推动器
说的直白点,就是我们现在只有2个class类,但是无法实现想要的效果,现在来想想什么时机去触发draw方法。
根据上面的视频,我们知道需要在canvas标签上添加鼠标over事件,然后在over事件里来实时获取小球的位置信息,最后再触发draw方法。
当鼠标离开canvas画布后,需要将画布上的内容清除,不留痕迹。
这样一来,我们不仅要实现类似桥梁(bridge)的功能,还需要在canvas类上实现“画布清空”的功能。
class Canvas {
// ...其他代码不动
// 清空画布的功能
clearCanvas(){
this.canvasContext.clearRect(0, 0, this.width, this.height);
}
}
// 画布对象
let canvas = new Canvas({
element: document.querySelector('canvas'),
width: 300,
height: 300
});
// 小球对象
let ball = new Ball({
centerX: 0,
centerY: 0,
radius: 30,
background: 'orange'
});
// 给canvas标签添加mouseover事件监听
canvas.addEventListener(
'mousemove',
(target) => {
canvasMouseOverEvent(target, canvas.element, ball);
}
)
canvas.addEventListener(
'mouseleave',
(target) => {
canvasMouseLeave(target, canvas);
}
)
// 鼠标滑动事件
function canvasMouseOverEvent(target, canvasElement, ball){
let pointX = target.offsetX;
let pointY = target.offsetY;
ball.updateLocation(pointX, pointY);
canvas.draw(ball);
}
// 鼠标离开事件
function canvasMouseLeave(target, canvasInstance){
canvasInstance.clearCanvas();
}
这样一来,我们便实现了大致的效果,如下:
我们似乎实现了当初的需求,只是为啥目前的表现跟“刮刮乐”差不多?因为下一次绘画的时候,没有将上次的绘画清空,所以才导致了现在的这个样子。
想要解决这个bug,只需在canvas类的draw方法里,加个清空功能就可以了。
class Canvas {
// 画物体
draw(ball){
this.clearCanvas();
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
}
如此一来,这个bug就算解决了。
二、最后
好啦,本期内容到这里就告一段落了,希望这个demo能够帮助你了解一下canvas的相关API,如果能够帮到你,属实荣幸,那么我们下期再见啦,拜拜~~
来源:juejin.cn/post/7388056383642206262
全面横评 6 大前端视频播放器 - Vue3 项目应该怎么选?
前言
最近,我在负责公司的音视频开发,使用的技术栈是 Vue3,技术方案采用的是基于第三方库二次封装播放器组件,来实现公司的业务定制需求。
市面上有很多第三方视频库,在最开始进行技术选型的时候,我也花了很多时间。
现在初步把我们公司的音视频组件开发完成了,我对视频第三方库的技术选型进行一个总结分享,希望能帮助到正在进行音视频开发的你。
这次技术选型一共会对比 6 个第三方库:xgplayer、video.js、ckplayer x3、aliplayer、tcplayer、flv.js。
在对比每个第三库时,我都会给出文档地址、示例、Git 地址,便于大家自行对比。
我会给出收集到的每个第三方库的优缺点分析。对于部分重要的第三方库,将进行详细的优缺点分析;而对于涉及闭源等不可接受情况的库,优缺点分析将较为简略,不再深入对比。
因为我们技术团队的英文水平还达不到无障碍阅读英文文档的地步,所以第三库的文档是否支持中文,也是我们考虑的一个因素。
我们技术团队详细对比这些第三方库之后,最后选择的是 xgplayer。
好了,接下来开始分析以上提到的 6 个音视频第三方库~
1. xgplayer(推荐)
这个库也是我们现在所采用的库,整体使用下来感觉很不错,文档很详细,支持自定义插件,社区活跃~
文档地址: h5player.bytedance.com/
示例地址: h5player.bytedance.com/examples/
Git 地址: github.com/bytedance/x…
优点
- 基本满足现有功能,自带截图 等功能
- 中文文档,有清晰详细的功能释义
- 可通过在线配置和生成对应功能代码参考,预览配置后的视频效果,开发体验好
- 项目积极维护
- 近期从 v2 版本升级到了 v3版本,优化了很多功能,开发体验更好
- 支持自定义插件,对开发业务定制需求很有用
缺点
- 直播需要浏览器支持Media Source Extensions
- PC Web端支持直接播放mp4视频,播放HLS、FLV、MPEG-DASH需要浏览器支持Media Source Extensions
- iOS Web端支持直接播放mp4和HLS,不支持播放FLV、MPEG-DASH(iOS webkitwebview 均不支持MediaSource,因此无法支持flv文件转封装播放)
- Android Web端支持直接播放mp4和HLS,播放FLV、MPEG-DASH需要浏览器支持Media Source Extensions
- 进度条拖动:拖动时,视频会一直播放当前拖动视频帧的声音,导致挺起来声音一卡一卡的,而且拖动一停止就立马开始播放视频
- 自动播放限制:对于大多数移动 webview 和浏览器,默认情况下会阻止有声自动播放。可以设置静音起播,达到自动播放的目的,不能保证一定能够自动播放,不同的app和浏览器配置不一样,表现不一样
- 打点功能没有提供图片的配置,可能需要二次开发或者用预览功能
- hls和flv不能同时添加,但是可以自己通过逻辑判断,去允许 hls 和 flv 同时播放
- Android 在网页端打开后截图很模糊
2. video.js(候选)
文档地址: videojs.com/
示例地址: videojs.com/advanced/?v…
Git 地址: github.com/videojs/vid…
优点
- 功能全面:提供暂停、播放等功能,基本满足项目所有功能需求
- 社区情况:社区活跃,项目持续维护
- 插件和主题丰富:可以根据需求进行定制和扩展
- 跨平台和浏览器兼容性:支持跨平台播放,适用于各种设备和操作系统
- 进度条拖动时,视频暂停,且能预览当前拖动所处位置,在放开拖动时,才开始播放视频,体验比较好
缺点
- 英文文档:上手学习播放器难度大,且后期维护成本高(搭建 demo 时,发现英文文档对开发有影响)
- 学习曲线:提供广泛功能,可能需要一定时间来理解其概念、API 等
- 不支持 flv 格式,但是可以通过安装 videojs-flvjs-es6 插件,同时安装 flv.js 库,来提供 flv 格式支持(但是 videojs-flvjs-es6 库的 star 太少,可能会出现其他问题)
- 没有自带截图功能,需要自己开发
3. ckplayer x3(候选)
文档地址: http://www.ckplayer.com/
示例地址: http://www.ckplayer.com/demo.html
Git 地址: gitee.com/niandeng/ck…
优点
- 功能丰富,且提供良好的示例
- 中文文档,文档相对比较丰富和专业
- 格式支持度较高,通过插件还可以播放 ts、mpd 等视频
缺点
- 社区支持不够丰富,如果以后有扩展功能需求,不便开发
- git 仓库 issue 响应慢,后续出问题,可能不便解决
- 文档的左侧菜单的交互不太友好,功能模块分级不够清晰,导致查找 API 不方便
- 没有直接提供视频列表(通道切换)的功能或插件
- 进度条拖动:拖动时,视频一直在播放,且没有显示当前所处拖动位置的预览画面,用户不知道当前拖动所处的具体位置,体验不佳
4. aliplayer(候选)
文档地址: player.alicdn.com/aliplayer/i…
示例地址: player.alicdn.com/aliplayer/p…
Git 地址: github.com/aliyunvideo…
优点
- 基本满足现有功能需求,自带截图、视频列表等功能
- 提供部分功能演示和在线配置
- 中文文档
- 支持4K视频播放,并且具备高分辨率和高比特率视频的优化能力
- 刷新和切换页面的 loading 时间比 xgplayer 短
- 播放器内部集成 flv 和 hls 格式,可以直接播放
缺点
- Web播放器H5模式在移动端不支持播放FLV视频,但可播 HLS(m3u8)
- Web播放器提供的音量调节方法在iOS系统和部分Android系统会失效
- 自动播放限制:由于浏览器自身的限制,在Web播放器SDK中无法通过设置autoplay属性或者调用play()方法实现自动播放。只有视频静音才可以实现自动播放或者通过用户行为手动触发播放
- 截图功能限制:fiv 视频在Safari浏览器下不支持截图功能。即使启用截图按钮也不会出现
- 回放时,必须点击播放按钮那个图标才能播放,体验不佳。且鼠标悬停时,会显示视频控制栏,但点击控制栏,视频无对应功能响应,体验不佳
- 回放播放效果不统一:同样的设置,刷新页面时视频不会自动播放,切换页面再回来,视频会自动播放
- 有些高级功能需要商业授权:Web播放器SDK从2.14.0版本开始支持播放H.265编码协议的视频流,如需使用此功能,您需要先填写表单申请License授权
- 文档:文档目录混乱且杂糅其他播放器不需要的文档
- 进度条拖动:拖动时,视频会一直播放当前拖动视频帧的声音,导致听起来声音一卡一卡的,而且拖动一停止就立马开始播放视频
5. tcplayer(不推荐)
文档地址: cloud.tencent.com/document/pr…
示例地址: tcplayer.vcube.tencent.com/
Git 地址: 闭源,无 git 仓库
优点
- 断点续播:播放失败时自动重试,支持直播的自动重连功能
缺点
- 文档不丰富,示例功能不多
- 闭源项目,出现问题不好解决
- 内置的功能和插件相对阿里云和 CK 较少
- web 端截图功能不支持
6. flv.js(不推荐)
文档地址: github.com/bilibili/fl…
示例地址: bilibili.github.io/flv.js/demo…
Git 地址: github.com/bilibili/fl…
优点
- 由于浏览器对原生Video标签采用了硬件加速,性能很好,支持高清
缺点
- 文档:缺乏详细功能说明文档,只有 md 英文文档,文档阅读不方便
- 项目很久未更新,原作已离开哔站,虽已开源,但后期应该不会有啥版本升级和优化
- 播放 flv 格式需要依赖 Media Source Extensions,但目前所有 iOS 和 Android4.4.4 以下的浏览器都不支持
结语
以上是我对音视频第三方库进行技术选型对比的一个总结。如果有更好的见解或者其他补充,欢迎在评论区留言或者私聊我进行沟通。
来源:juejin.cn/post/7359083412386807818
受够了useState的逻辑分散?来,试试用reducer聚合逻辑
useState的缺点
经常写react 的同学都知道,useState
是 React 中的一个 Hook,可以在函数组件中管理组件的状态,并在状态更新时重新渲染组件。
这东西虽然简单好用,但有一个致命缺点:当组件有非常多的状态更新逻辑时,事件处理会非常分散,维护起来很头疼!
比如,一个简单的记事本功能
我需要通过三个不同的事件处理程序来实现任务的添加、删除和修改:
const [tasks, setTasks] = useState([
{ id: 1, title: '去贝加尔湖旅行', completed: false },
{ id: 2, title: '去烟台海边度假', completed: false },
{ id: 3, title: '再去一次厦门看海', completed: false },
]);
// 添加
const addTask = (taskTitle) => {
const newId = tasks.length + 1;
const newTask = { id: newId, title: taskTitle, completed: false };
setTasks([...tasks, newTask]);
};
// 删除
const deleteTask = (taskId) => {
setTasks(tasks.filter(task => task.id !== taskId));
};
// 编辑
const toggleTaskCompletion = (taskId) => {
setTasks(tasks.map(task =>
task.id === taskId ? { ...task, completed: !task.completed } : task
));
};
上面的代码中,每个事件处理程序都通过 setTasks
来更新状态。随着这个组件功能的,其状态逻辑也会越来越多。为了降低这种复杂度,并让所有逻辑都可以存放在一个易于理解的地方,我们可以将这些状态逻辑移到组件之外的一个称为 reducer 的函数中。
使用 reducer 整合状态逻辑
在学习reducer之前,我们先看看使用reducer整合后的代码
import React, { useReducer, useState } from 'react';
// 定义 reducer 函数
const taskReducer = (state, action) => {
switch (action.type) {
case 'ADD_TASK':
const newId = state.length + 1;
return [...state, { id: newId, title: action.payload, completed: false }];
case 'DELETE_TASK':
return state.filter(task => task.id !== action.payload);
case 'TOGGLE_TASK':
return state.map(task =>
task.id === action.payload ? { ...task, completed: !task.completed } : task
);
default:
return state;
}
};
function TaskList() {
const [tasks, dispatch] = useReducer(taskReducer, [
{ id: 1, title: '去贝加尔湖旅行', completed: false },
{ id: 2, title: '去烟台海边度假', completed: false },
{ id: 3, title: '再去一次厦门看海', completed: false },
]);
const [newTaskTitle, setNewTaskTitle] = useState('');
const handleAddTask = () => {
if (newTaskTitle.trim()) {
dispatch({ type: 'ADD_TASK', payload: newTaskTitle });
setNewTaskTitle('');
}
};
return (
<div>
<h2>快乐就是哈哈哈的记事本</h2>
<div>
<input
type="text"
placeholder="添加新任务"
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
/>
<button onClick={handleAddTask} style={{ marginLeft: '10px' }}>添加</button>
</div>
<div>
{tasks.map(task => (
<div key={task.id} style={{ marginTop: '10px' }}>
<input
type="checkbox"
checked={task.completed}
onChange={() => dispatch({ type: 'TOGGLE_TASK', payload: task.id })}
/>
{task.title}
<button onClick={() => { /* Add edit functionality here */ }} style={{ marginLeft: '10px' }}>编辑</button>
<button onClick={() => dispatch({ type: 'DELETE_TASK', payload: task.id })} style={{ marginLeft: '10px' }}>删除</button>
</div>
))}
</div>
</div>
);
}
export default TaskList;
能够看出,所有的逻辑被整合到taskReducer这个函数中了,我们的逻辑聚合度很高,非常好维护!
useReducer的基本语法
const [state, dispatch] = useReducer(reducer, initialArg, init?)
在组件的顶层作用域调用 useReducer 以创建一个用于管理状态的 reducer。
import { useReducer } from 'react';
function reducer(state, action) {
// ...
}
function MyComponent() {
const [state, dispatch] = useReducer(reducer, { age: 42 });
// ...
参数
reducer
:用于更新 state 的纯函数。参数为 state 和 action,返回值是更新后的 state。state 与 action 可以是任意合法值。initialArg
:用于初始化 state 的任意值。初始值的计算逻辑取决于接下来的init
参数。- 可选参数
init
:用于计算初始值的函数。如果存在,使用init(initialArg)
的执行结果作为初始值,否则使用initialArg
。
返回值
useReducer
返回一个由两个值组成的数组:
- 当前的 state。初次渲染时,它是
init(initialArg)
或initialArg
(如果没有init
函数)。 - dispatch函数。用于更新 state 并触发组件的重新渲染。
dispatch 函数
dispatch 函数可以用来更新 state的值 并触发组件的重新渲染,它的用法其实和vue的store,react的状态管理库非常相似!
dispacth可以有很多,通过dispacth可以发送数据给reducer函数,函数内部,我们通过action可以拿到所有dispatch发送的数据,然后进行逻辑判断,更改state的值。
通常来说 action 是一个对象,其中 type 属性标识类型,其它属性携带额外信息。
代码解读
熟悉了它的语法后,我们的整合逻辑就非常好理解了。我们简化下逻辑:
import React, { useReducer, useState } from 'react';
// 定义 reducer 函数
const taskReducer = (state, action) => {
switch (action.type) {
// 根据不同逻辑,返回一个新的state的值
default:
return state;
}
};
function TaskList() {
const [tasks, dispatch] = useReducer(taskReducer, [
{ id: 1, title: '去贝加尔湖旅行', completed: false },
{ id: 2, title: '去烟台海边度假', completed: false },
{ id: 3, title: '再去一次厦门看海', completed: false },
]);
// 通过dispatch发送数据给taskReducer
return (
<div>
<h2>快乐就是哈哈哈的记事本</h2>
<div key={task.id} style={{ marginTop: '10px' }}>
<input
type="checkbox"
checked={task.completed}
onChange={() => dispatch({ type: 'TOGGLE_TASK', payload: task.id })}
/>
<button onClick={() => dispatch({ type: 'DELETE_TASK', payload: task.id })} style={{ marginLeft: '10px' }}>删除</button>
</div>
</div>
);
}
export default TaskList;
useReducer的性能优化
我们先看看下面的代码
function createInitialState(username) {
// ...
// 生成初始值的一些逻辑
}
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, createInitialState(username));
// ...
createInitialState方法用于生成初始值,但是在每一次渲染的时候都会被调用,如果创建了比较大的数组或计算是比较浪费性能的!
我们可以通过给 useReducer 的第三个参数传入 初始化函数 来解决这个问题:
function createInitialState(username) {
// ...
}
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, username, createInitialState);
// ...
需要注意的是你传入的参数是 createInitialState 这个 函数自身,而不是执行 createInitialState() 后的返回值
如果createInitialState可以直接计算出初始值,不需要默认的username,上面的代码可以进一步优化
function createInitialState() {
// ...
}
function TodoList() {
const [state, dispatch] = useReducer(reducer, null, createInitialState);
// ...
来源:juejin.cn/post/7399496845277151242
才4W条数据页面就崩溃了
写过地图需求的同学应该都遇到过地图加载大量点(4W多个点)导致页面十分卡顿的问题吧。
最近项目上线验收,现场直接卡崩溃了,其实在公司还好,因为公司的电脑配置还算可以,没有出现过崩溃的现象(但是也很卡,本来也想偷下懒)。崩溃了怎么办啊(我感觉我更崩溃呢,天天加班赶需求哪有时间做优化的啊)。
原因:用户想要加载所有的点还不做聚合,而且每个点都要做动态扩散效果,还要实时刷新地图数据。
哎,先说我的解决方案吧。
- 取消点的动态扩散效果
- 图层层级显示图标点才会更新
- 只显示可视范围内的点
- 用户操作过程中不更新图层
第一点,我必须拿着我的数据让产品经理去给客户说有动效内存占用在600M到1200M跳动,动效是1s一个循环。那么每次执行动效就会让内存飚到1200M,然后接下来浏览器会回收之前渲染的内存,内存又降至600M。我就说这个实在没法优化只能去掉(我没有试加载动态图片会不会好点,但想来也差不多)。
第二点,我先说明图层的加载方案,当图层层级小于14时就加载全部点否则加载聚合点(what fuck
)。客户就是这么牛,一般不应该是反着来吗。。那必须滴,这么多点客户也看不出点的位置更新,看到了也不知道是那个更新了。所以,出于性能考虑给出的方案是:当图层层级小于14的时候就不更新点。那么多点一起更新
第三点,前端是首次加载的时候把所有的数据都缓存起来的,由服务端推送更新消息,前端收到消息就维护缓存的数据并做相应的更新逻辑(在线离线/GIS等),会维护一个更新队列,如果数据太大的时候就分次更新。好的,说了那么多废话是不是想水文啊
首先,每次更新(用户缩放和拖拽地图与推送)之前需要先拿到当前地图的四个角经纬度,然后调用Turf.js
库提供的# pointsWithinPolygon
方法:
const searchWithin = Turf.multiPolygon([[东],[南],[西],[北]]);
const ptsWithin = Turf.pointsWithinPolygon(Turf.points([...points]), searchWithin);
拿到当前可视范围内的点,再将可视范围内的点渲染到地图上。
第四点,当我开开心心把代码提交上去后,过了一会,我的前端同事给我说感觉页面还是很卡啊(0.0
)。我表示不信,然后实际操作了一下,虽然上面的减少点的操作和减少点的数量让浏览器内存占用降了下来页面也确实不卡了,但是当我去拖动地图的时候发现问题了,怎么感觉拖着拖着的地图有规律的卡。怎么回事呢,再梳理下我明白了,之前的地图刷新时间是10s由于客户觉得刷新太慢了,索性就改成了3s,这一改一个不吱声,3s那不是很大概率当用户正在操作地图的时候地图重新渲染了所以感觉卡。知道问题就好办,判断用户当前是否在操作地图,movestart
事件时表示用户开始操作地图,moveend
事件表示用户结束操作。那就等用户操作地图结束后再更新地图,上手感受了一下一点也不卡了,搞定。
创作不易求,如果你看到这里还请您点赞收藏
来源:juejin.cn/post/7361973121790656562
web3入门:编写第一个智能合约
1. 引言
Web3 是下一代互联网,它通过区块链技术实现了去中心化。智能合约是 Web3 的核心组件之一,它们是部署在区块链上的自动化程序,可以执行预定义的操作。本学习笔记旨在介绍智能合约的基本概念、开发与部署步骤,并分享一些常见的问题及解决方案。
2. 基础知识
什么是智能合约?
智能合约是一种在区块链上自动执行的程序,具有以下特点:
- 自动化执行:无需人工干预,合约条件一旦满足,程序自动执行。
- 不可篡改:部署到区块链上的合约内容无法被篡改。
- 透明性:所有交易和代码都是公开的,任何人都可以查看。
关键工具
- Solidity:用于编写智能合约的编程语言。
- Remix IDE:在线智能合约开发环境。
- MetaMask:浏览器插件,用于管理以太坊账户并与区块链交互。
- Ganache:本地区块链模拟器,用于测试和开发。
3. 智能合约开发
编写第一个智能合约
以下是一个简单的 Solidity 智能合约例子:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract HelloWorld {
string public greeting;
constructor() {
greeting = "Hello, World!";
}
function setGreeting(string memory _greeting) public {
greeting = _greeting;
}
function getGreeting() public view returns (string memory) {
return greeting;
}
}
编译合约
使用 Remix IDE 编写和编译上述合约:
- 打开 Remix IDE。
- 新建文件并命名为
HelloWorld.sol
。 - 将上述代码粘贴到文件中。
- 选择适当的编译器版本(如
0.8.0
),点击编译按钮。
4. 部署环境设置
安装 Node.js 和 npm
sudo apt update
sudo apt install nodejs npm
安装 Truffle 和 Ganache
npm install -g truffle
npm install -g ganache-cli
创建 Truffle 项目
mkdir MySmartContract
cd MySmartContract
truffle init
配置 Truffle
修改 truffle-config.js
文件以使用本地 Ganache 区块链:
module.exports = {
networks: {
development: {
host: "127.0.0.1",
port: 7545,
network_id: "*"
}
},
compilers: {
solc: {
version: "0.8.0"
}
}
};
复制智能合约 HelloWorld.sol
在项目contracts
文件夹中创建一个新的智能合约文件 HelloWorld.sol
,将上面智能合约HelloWorld.sol内容复制过来
5. 部署智能合约
启动本地区块链Ganache
ganache-cli
编写迁移脚本
在项目migrations
文件夹中创建一个新的迁移脚本 2_deploy_contracts.js
:
const HelloWorld = artifacts.require("HelloWorld");
module.exports = function(deployer) {
deployer.deploy(HelloWorld);
};
部署合约
在项目根目录下运行以下命令:
truffle migrate
6. 互动和验证
连接到已部署的合约
使用 Truffle 控制台与合约互动:
truffle console
在控制台中执行以下命令:
const hello = await HelloWorld.deployed();
let greeting = await hello.getGreeting();
console.log(greeting); // 输出 "Hello, World!"
await hello.setGreeting("Hello, Blockchain!");
greeting = await hello.getGreeting();
console.log(greeting); // 输出 "Hello, Blockchain!"
7. 常见问题及解决方法
合约部署失败
- 检查编译器版本:确保 Truffle 配置中的编译器版本与合约代码中使用的版本匹配。
- 网络设置:确保 Ganache 正在运行且 Truffle 配置中的网络设置正确。
交易被拒绝
- 账户余额不足:确保用于部署合约的账户有足够的以太币。
- Gas 限制不足:增加 Gas 限制。
8. 总结
部署 Web3 智能合约需要掌握 Solidity 编程、开发环境设置以及与区块链的交互。通过本学习笔记,您可以了解从编写智能合约到在本地区块链上部署和测试的全过程。随着 Web3 技术的不断发展,掌握这些技能将对未来的区块链应用开发大有裨益。
项目链接地址:github.com/ctq123/web3…
来源:juejin.cn/post/7399569706183671842
前端 Element Plus 简单完美换肤方案
前言:本次新项目中,要求加一个换肤功能,要求可以换任何颜色。通过自己的摸索,总结出一套最合适且比较简单的换肤方案,我分享出来供大家参考,如有更好的方案,大家可以在评论区交流一下。
先看效果:
直接上干货,不废话
原理就是修改主题变量,在根html标签上添加内联样式变量,如图
因为style权重高,会覆盖element plus的颜色变量,这时我们只要选择自己喜欢的颜色替换就行。
我们开发时可以直接使用--el-color-primary颜色主题变量,更改主题的时候,自己的自定义组件也会随着更改,例如:
li:hover {
border-color: var(--el-color-primary);
}
上代码
我去掉了无关代码,方便大家理解
<template>
<div>主题颜色</div>
<el-color-picker
v-model="color"
@change="colorChange"
:predefine="predefine"
/>
</template>
<script setup lang="ts">
import { ref } from "vue";
import colorTool from "@/utils/theme"; //引入方法
// 换肤主题
const color = ref<string>("#409eff");
const colorChange = (value: string) => {
if (value) {
color.value = value;
}
把颜色存到本地,持久化,解决刷新页面主题丢失问题
localStorage.setItem("COLOR", JSON.stringify(color.value));
设置html标签style样式变量
document.documentElement.style.setProperty("--el-color-primary", color.value);
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-light-${i}`,
colorTool.lighten(color.value, i / 10)
);
}
//透明
document.documentElement.style.setProperty(
`--el-color-primary-light-10`,
color.value + 15
);
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-dark-${i}`,
colorTool.darken(color.value, i / 10)
);
}
};
if (localStorage.getItem("COLOR")) {
colorChange(JSON.parse(localStorage.getItem("COLOR") as string));
}
默认颜色板
const predefine = ref<string[]>([
"#409eff",
"#009688",
"#536dfe",
"#ff5c93",
"#c62f2f",
"#fd726d",
]);
</script>
<style scoped lang="scss">
</style>
@/utils/theme文件代码
export default {
//hex颜色转rgb颜色
HexToRgb(str: string) {
str = str.replace("#", "");
var hxs: any= str.match(/../g);
for (var i = 0; i < 3; i++) {
hxs[i] = parseInt(hxs[i], 16)
}
return hxs;
},
//rgb颜色转hex颜色
RgbToHex(a:number, b:number, c:number) {
var hexs = [a.toString(16), b.toString(16), c.toString(16)];
for (var i = 0; i < 3; i++) {
if (hexs[i].length == 1) hexs[i] = "0" + hexs[i];
}
return "#" + hexs.join("");
},
//加深
darken(color: string, level: number) {
var rgbc = this.HexToRgb(color);
for (var i = 0; i < 3; i++) rgbc[i] = Math.floor(rgbc[i] * (1 - level));
return this.RgbToHex(rgbc[0], rgbc[1], rgbc[2]);
},
//变淡
lighten(color:string, level:number) {
var rgbc = this.HexToRgb(color);
for (var i = 0; i < 3; i++)
rgbc[i] = Math.floor((255 - rgbc[i]) * level + rgbc[i]);
return this.RgbToHex(rgbc[0], rgbc[1], rgbc[2]);
},
};
这是现成完整的代码,大家可以直接拿来用。
希望本篇文章能帮到你
来源:juejin.cn/post/7399592120146313243
谷歌浏览器禁用第三方Cookie:独裁者的新工具?
2024年,Chrome将要正式禁用第三方Cookie了,这个变化对Web开发者来说是非常重要的,因为它将改变开发者如何设计网站以及如何搜集和使用用户数据。这是怎么一回事,到底有什么具体影响?
什么是Cookie?
随着互联网技术的发展,我们的生活变得越来越数字化,网上购物、社交、阅读新闻成为日常。而在这个数字化的世界中,Cookie扮演了一个不可或缺的角色。
Cookie是一种由浏览器保存在用户电脑上的小块数据,用来帮助网站记住用户的信息和设置。网站可以在前端直接操作Cookie,也可以根据服务器返回的指令设置Cookie,当浏览器请求同一服务器时相应的Cookie会被回传。Cookie让网站能够记住用户的登录状态、购物车中的商品、以及个性化设置等,极大地提升了用户体验。
第三方Cookie是咋回事?
然而,Cookie并非只有一种。其中,第三方Cookie与网站直接设置的第一方Cookie不同,它们通常由第三方广告商或网站分析服务设置。网站一般通过在前端页面引入第三方的Javascript程序文件来实现这种能力。
用于网站分析时,Cookie可以收集和存储有关用户访问网站的数据,如用户的浏览历史、停留时间、点击行为等。这些数据对于网站运营者来说非常有价值,可以帮助他们了解用户的行为和兴趣,优化网站的设计和功能,提升用户的体验。
用于广告时,Cookie可以用来追踪用户在不同网站上的行为,以便提供个性化广告和内容。这种广泛的追踪能力让广告商能够了解用户的喜好和习惯,从而推送更加精准的广告。
为什么要禁用第三方Cookie?
但第三方Cookie也引发了隐私方面的担忧。许多用户和隐私倡导者认为,广告商利用Cookie追踪用户的行为侵犯了个人隐私。
很多同学应该有这样的体验:你在某个网站搜索了某个东西,然后访问其它网站或服务时,网页会向你展示之前搜索过的类似东西。基于某些原因,有时候我们并不想这样被跟踪。
这种担忧导致了对第三方Cookie的禁用呼声。同时隐私担忧不仅仅来自于用户和隐私倡导者,也来自于法规如欧盟的通用数据保护条例(GDPR)和加州消费者隐私法案(CCPA)这样的法律要求。
为了应对这一问题,苹果公司在其Safari浏览器中率先禁用了第三方Cookie,微软和Mozilla也采取了类似措施。
谷歌的行动计划是什么样的?
但是,作为浏览器市场占有率第一的谷歌,却迟迟没有明显的动作,遭到了不少人的非议和批评。
为了解决这些问题,谷歌提出了“隐私沙盒”(Privacy Sandbox)计划,旨在开发一系列新的技术,既能保护用户隐私,又能支持广告商进行有效的广告投放。
想象一下,如果有一个中立的场所,既能让你安心地存放你的个人物品,又能让有需要的人在不侵犯你隐私的情况下了解你的需求,这就是隐私沙盒的理念。
例如,谷歌提出的FLoC(Federated Learning of Cohorts)技术,就是将用户分群,而非单独追踪个人行为,从而在保护个人隐私的同时提供群体级的广告定位。
作为市场份额最大的浏览器,谷歌计划在2024年1月开始逐步禁用第三方Cookie,在2024年第三季度完成第三方Cookie的全面禁用。
禁用导致的问题有哪些?
禁用第三方 Cookie 对网站主、广告商和用户都会产生一系列影响。针对不同群体的影响及解决方案如下:
网站主
第三方 Cookie 的禁用可能会让网站主失去对用户行为的深入分析能力,因为他们将不能再依靠第三方 Cookie 来追踪用户在多个网站上的活动。这可能会影响到网站的个性化服务和广告定位的准确性,进而影响网站收入。
解决方案:网站主可以更多地依赖第一方 Cookie,即直接由网站域设置和读取的 Cookie。这些 Cookie 可以帮助网站主跟踪用户在自己网站上的行为,而不越过隐私边界。此外,网站主可以通过提供更多的价值服务来鼓励用户主动分享信息。
对于拥有多个域名的网站主来说,可以使用同一个根域名来设置Cookie,这样在根域名下的所有子域名都可以访问这些Cookie,这种方法仍然在用户隐私保护的框架内。如果网站主拥有多个相关联的服务,可以实施更安全的单点登录解决方案,比如使用会话令牌和OAuth等协议。
广告商
广告商将难以像过去那样追踪用户在整个互联网的行为,从而无法进行精准的广告定向,这可能导致广告效果下降和收入减少。
解决方案:谷歌提出的隐私沙盒计划中,Event Conversion Measurement API 是一种技术解决方案,它允许广告商在不侵犯用户隐私的情况下测量广告转化率。此外,广告商还可以利用机器学习等技术,通过分析大量的匿名化和聚合数据来预测用户兴趣。不过广告商也有机会使用一些更隐蔽的技术追踪手段来保持原有的业务模式,具体一些技术下文会提到。
用户
用户的隐私得到更好的保护,但可能会失去一些基于个性化广告的便利性,例如推荐系统的准确性可能会下降。
解决方案:用户可以获得更多的隐私控制权,例如通过浏览器设置来决定哪些数据可以被网站使用。同时,随着技术的发展,用户可能会遇到更多基于隐私保护的个性化体验,例如使用本地算法来进行内容推荐,而不需要将个人数据传输到服务器。
谷歌真的做好了吗?
尽管谷歌提出了Privacy Sandbox这样的计划,希望在不依赖个人用户信息的情况下,仍然能够支持广告生态系统,但实际上这个计划也遭到了一些批评。批评者认为,这些新提出的技术可能仍然允许用户被追踪,只不过追踪的方式更加隐蔽了。
例如,FLoC的提出本意是为了代替传统的个人定向广告,它通过对用户进行群组化来推送广告,这样不会直接暴露个人的行为数据。但问题在于,群组化的数据仍然可能被用来间接识别和追踪个人,特别是当某个群组里的用户数量不多时。这就导致了一种新形式的隐私问题,即“群组隐私”的泄露。
还有广告服务商仍可能通过一些技术手段突破隐私限制,比如通过网站转发、Canvas指纹技术、网络信标、用户代理字符串、本地存储和ETag跟踪等。
此外,一些隐私倡导者还担心,谷歌作为一个广告公司,其提出的隐私解决方案可能偏向于维护其在在线广告市场中的主导地位。他们认为,谷歌有动机设计一套系统,搜集用户在搜索、YouTube和其他谷歌服务上的行为数据,使得自家的广告网络相比其他竞争对手拥有优势。而这可能会影响到广告市场的公平竞争,甚至可能对开放网络生态系统构成威胁。
总的来说,谷歌在隐私问题上的努力是一个积极的开始,但隐私保护的路还很长。而对于技术人员而言,理解这些变化和挑战,以及如何在保护用户隐私的同时提供优质服务,将是未来发展的一个重要课题。
关注萤火架构,提升技术不迷路!
来源:juejin.cn/post/7313414896783769609
关于菜狗做了一个chrome小插件

前言
很多时候,老是质疑反问自己,在空闲时间都在干嘛呢?是沉迷于看动漫、美女视频,打游戏?还是在追求更有意义的事呢?自我回答,没错我是属于前者了(dog),然后前后左右思考,还是决定找一些事做,不能一直这么荒废了,起码做一些是一些,积少成多!但是能做什么呢?这又引起我这不聪明的大脑深深的思考,是从自己从事的职位来,还是从自己的兴趣来呢?然后在这段迷茫的时间一直在寻找中,突然有一天看到别人的文章或视频感触深刻,最后还是敲定做项目!然后就着手开始准备做起来,就这么愉快决定行动起来了,奥利给!
于是利用上班摸鱼时间和下班空闲的时间开始行动起来~
起源
在空闲的摸鱼时间里,我经常喜欢看别人写的文章,读完一篇又一篇,大多数时候都会感叹并羡慕。然鹅,特别是对于某些事情,我会有特别深的感触。因为有时候的情绪只是在特定时刻被触发的,所以我想记录下当时的触发感受和情绪。然后我就去寻找相关的插件,结果找到了一个相当不错的插件。试用了一番后,发现效果还不错,于是我决定将关注点留在这里,尝试着制作这类插件,看看自己做的的效果如何。有时候我在想,为什么要费力自己创建一个插件呢?毕竟市面上已经有现成的插件了,为什么不用呢?然而,答案很明了:一是想要找点事情做,二是想要提升一下自己的技能水平。于是,这样的动力激励着我行动起来。
需求
产品需求其实很明确,因为从我的角度来看已经有现成的产品可供参考,然后只需根据个人需求进行定制。因此,产品的主要目的是为了方便我们的生活。
因此,一个产品的设计应当能够满足不同用户的需求,以提供更好的使用体验。这里着重考虑我自己个人使用,因为每个人的习惯和偏好都不尽相同。
收集的功能需求:
- 对内容进行划线
- 记录想法/感想
- 统计列表数据
- 预览原文(主要是针对文章)
- 拷贝(划线、原文、Markdown)内容
- 下载(划线TXT、原文TXT、原文Markdown)内容
目前第一版只涵盖了这些功能需求(感谢ChatGPT,它帮助解决了我作为菜狗的许多问题),后续将根据需要情况进行调整和完善~
下面是完成的功能截图:
突然发现我开发这个插件还有一个小用处哈哈哈,针对类似我这种不会写文章格式的小白来说,有时候真的不知道该如何开始写。但有一个现成的文章作为参考,真是太棒了!它不仅能够给我提供灵感,还能够帮助我了解文章的结构和写作风格,让我更加自信地面对写作挑战!
拷贝的文章markdown格式:
最后打算发版到chrome,目前正在审核中~
总结
可能我是一个老老实实上班族的一个菜狗,但是勇于尝试也挺好的,毕竟在尝试中,我能够不断学习、成长,迎接新的挑战,拓展自己的能力和视野。
历经三个月的努力,终于完成了第一版。其实本来应该在一个多月内完成的,但中途我可能有些懒惰哈哈。虽然我觉得自己做的东西还不尽如人意,但从某种角度来说,至少我迈出了那一步。哪怕是简单的东西,也是通过自己制造出来的,多多少少都有一些成就感。而且,这个过程也丰富了我的技术栈。因此,我在开始思考如何完成一个要好的项目,是否可以继续打造一款完整的产品,让人们使用,如果有人使用我的产品,我也会感到非常满足!
最后思考
1、有时候,当你不知道该做什么时,一定不要让自己闲着。我深以为然,这句话给了我很大启发,我记得是在阅读一篇文章时被深深触动的。
2、有时候,想得再多也不如行动来得有效。即使方向错误,但这也是我从中获得的宝贵经验。总结经验才能不断进步。
3、时间是可以挤出来的,再忙再累也会有时间的。看个人是否愿意付诸行动,但也要适当放松,保持身心健康。
第一次写文章还是有点乱七八糟的,但这也是成长的一部分。还得继续努力,不断学习,能够更好地传达我的想法和观点!
来源:juejin.cn/post/7341642966790144035
耗时两周,我终于自己搭了一个流媒体服务器
RTSP流媒体播放器
前言:因公司业务需求,研究了下在web端播放rtsp流视频的方案,过程很曲折,但也算是颇有收获。
播放要求
- web网页播放或者手机小程序播放
- 延迟小于500ms
- 支持多路播放
- 免费
舍弃的方案
- 【hls】延时非常高,有时候能达到几十秒,实时性场景直接pass
- 【转rtmp】需要借助flash插件
- 【转图片帧】需要后端借助工具将rtsp视频流每一帧转成图片,再通过websocket协议实时传输到前端,前端用canvas绘制,这种方法对后端转流要求较高,每张图片如果太大会掉帧,延时也不稳定
思路尝试
1、 flvjs + ffmpeg + websocket + node
这套方案的核心为 BiLiBiLi 开源的
flvjs
,原理是在后端利用 转流工具 FFmpeg 将rtsp流
转成flv流
,然后通过websocket
传输flv流
,在利用flvjs
解析成可以在浏览器播放的视频。
flv不支持ios 请自行取舍
2、WebRTC
Webrtc是前端的技术,后端使用有点困难,原理是将
rtsp流
转成webrtc流
,直接在video中播放(需要浏览器支持webrtc)
如何将rtsp转成webrtc 基于两个工具实现
参考链接1 :webrtcstreamer.js 前端实现
参考链接2 : mediamtx转流
3、jsmpeg.js + ffmpeg + websocket + node
这种方案是我测试过免费方案中效果最好的,原理是在后端利用 转流工具 FFmpeg 将
rtsp流
转成图片流
,然后通过websocket
传输图片
,在利用jsmpeg.js
绘制到canvas上显示
优点:
- 可以通时兼容多路视频,且对浏览器内存占用不会太高
- 延迟还可以 测试在300-1000ms左右
缺点:
- 多路无法使用主码流 会把浏览器卡死
- 清晰度不够,画面大概在720p左右
前后端代码放jsbin了 地址 :jsbin.com/hazacak/edi…
注意
使用时请下载ffmpeg 并把ffmpeg添加值环境变量
4 、终极方案:ZLMediaKit +h265webjs
该方案应该是此类问题的终极解决方案(个人认为,有好的方案请共享)
原理:
可以看到ZlMediaKit支持把各种流进行转换输出,我们可以使用输出的流进行播放
为了便捷 推荐你使用ZLM文档提供的Docker镜像,同时ZLM提供各种的restful AP供你使用,可以转流,推流等等,具体查看文档中的 restful API部分内容
其中需要注意的是 API中的secret 在镜像文件 /opt/mdeia/conf 文件夹下 请手动复制出来 每次构建镜像 改值会变化
另外 推荐一个ZLM 的管理界面 github.com/1002victor/…
只需要把代码全部复制到 www目录下即可放心食用
前端部分:
因为前端部分相关的视频库都存在部分协议不支持,没办法完全进行测试
故选择了ZLM官方推荐的 h265webjs这个播放库,经过测试,便捷容易,可安全食用
地址:
来源:juejin.cn/post/7399564369229496358
如何让 localStorage 存储变为响应式
背景
项目上有个更改时区的全局组件,同时还有一个可以更改时区的局部组件,想让更改时区的时候能联动起来,实时响应起来。
其实每次设置完时区的数据之后是存在了前端的 localStorage 里边,时间组件里边也是从 localStorage 拿去默认值来回显。如果当前页面不刷新,那么时间组件就不能更新到最新的 localStorage 数据。
怎么才能让 localStorage 存储的数也变成响应式呢?
实现
- 应该写个公共的方法,不仅仅时区数据能用,万一后边其他数据也能用。
- 项目是 React 项目,那就写个 hook
- 怎么才能让 localStorage 数据变成响应式呢?监听?
失败的案例 1
首先想到的是按照下边这种方式做,
useEffect(()=>{
console.log(11111, localStorage.getItem('timezone'))
},[localStorage.getItem('timezone')])
得到的测试结果肯定是失败的,但是为啥失败?我们也应该知道一下。查了资料说,使用 localStorage.getItem('timezone')
作为依赖项会导致每次渲染都重新计算依赖项,这不是正确的做法。
具体看一下官方文档:useEffect(setup, dependencies?)
在此说一下第二个参数 dependencies:
可选 dependencies
:setup
代码中引用的所有响应式值的列表。响应式值包括 props、state 以及所有直接在组件内部声明的变量和函数。如果你的代码检查工具 配置了 React,那么它将验证是否每个响应式值都被正确地指定为一个依赖项。依赖项列表的元素数量必须是固定的,并且必须像 [dep1, dep2, dep3]
这样内联编写。React 将使用 Object.is
来比较每个依赖项和它先前的值。如果省略此参数,则在每次重新渲染组件之后,将重新运行 Effect 函数。
- 如果你的一些依赖项是组件内部定义的对象或函数,则存在这样的风险,即它们将 导致 Effect 过多地重新运行。要解决这个问题,请删除不必要的 对象 和 函数 依赖项。你还可以 抽离状态更新 和 非响应式的逻辑 到 Effect 之外。
如果你的 Effect 依赖于在渲染期间创建的对象或函数,则它可能会频繁运行。例如,此 Effect 在每次渲染后重新连接,因为 createOptions
函数 在每次渲染时都不同:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
function createOptions() { // 🚩 此函数在每次重新渲染都从头开始创建
return {
serverUrl: serverUrl,
roomId: roomId
};
}
useEffect(() => {
const options = createOptions(); // 它在 Effect 中被使用
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // 🚩 因此,此依赖项在每次重新渲染都是不同的
// ...
}
失败的案例 2
一开始能想到的是监听,那就用 window 上监听事件。
在 React 应用中监听 localStorage
的变化,可以使用 window
对象的 storage
事件。这个事件在同一域名的不同文档之间共享,当某个文档修改 localStorage
时,其他文档会收到通知。
写代码...
// useRefreshLocalStorage.js
import { useState, useEffect } from 'react';
const useRefreshLocalStorage = (key) => {
const [storageValue, setStorageValue] = useState(
localStorage.getItem(key)
);
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === key) {
setStorageValue(event.newValue)
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [key]);
return [storageValue];
};
export default useRefreshLocalStorage;
使用方式:
// useTimezone.js
import { useState, useEffect } from "react";
import { getTimezone, timezoneKey } from "@/utils/utils";
import useRefreshLocalStorage from "./useRefreshLocalStorage";
function useTimezone() {
const [TimeZone, setTimeZone] = useState(() => getTimezone());
const [storageValue] = useRefreshLocalStorage(timezoneKey);
useEffect(() => {
setTimeZone(() => getTimezone());
}, [storageValue]);
return [TimeZone];
}
export default useTimezone;
经过测试,失败了,没有效果!!!那到底怎么回事呢?哪里出现问题了?查阅��料经过思考,可能出现的问题的原因有:只能监听同源的两个页面之间的 storage 变更,没法监听同一个页面的变更。
成功的案例
import { useState, useEffect } from "react";
// 自定义 Hook,用于监听 localStorage 中指定键的变化
function useRefreshLocalStorage(localStorage_key) {
// 检查 localStorage_key 是否有效
if (!localStorage_key || typeof localStorage_key !== "string") {
return [null];
}
// 创建一个状态变量来保存 localStorage 中的值
const [storageValue, setStorageValue] = useState(
localStorage.getItem(localStorage_key)
);
useEffect(() => {
// 保存原始的 localStorage.setItem 方法
const originalSetItem = localStorage.setItem;
// 重写 localStorage.setItem 方法,添加事件触发逻辑
localStorage.setItem = function(key, newValue) {
// 创建一个自定义事件,用于通知 localStorage 的变化
const setItemEvent = new CustomEvent("setItemEvent", {
detail: { key, newValue },
});
// 触发自定义事件
window.dispatchEvent(setItemEvent);
// 调用原始的 localStorage.setItem 方法
originalSetItem.apply(this, [key, newValue]);
};
// 事件处理函数,用于处理自定义事件
const handleSetItemEvent = (event) => {
const customEvent = event;
// 检查事件的键是否是我们关心的 localStorage_key
if (event.detail.key === localStorage_key) {
// 更新状态变量 storageValue
const updatedValue = customEvent.detail.newValue;
setStorageValue(updatedValue);
}
};
// 添加自定义事件的监听器
window.addEventListener("setItemEvent", handleSetItemEvent);
// 清除事件监听器和还原原始方法
return () => {
// 移除自定义事件监听器
window.removeEventListener("setItemEvent", handleSetItemEvent);
// 还原原始的 localStorage.setItem 方法
localStorage.setItem = originalSetItem;
};
// 依赖数组,只在 localStorage_key 变化时重新运行 useEffect
}, [localStorage_key]);
// 返回当前的 storageValue
// 为啥没有返回 setStorageValue ?
// 因为想让用户直接操作自己真实的 “setValue” 方法,这里只做一个只读。
return [storageValue];
}
export default useRefreshLocalStorage;
具体的实现步骤如上,每一步也加上了注释。
接下来就是测试了,
useTimezone 针对 timezone 数据统一封装,
// useTimezone.js
import { useState, useEffect } from "react";
import { getTimezone, timezoneKey } from "@/utils/utils";
import useRefreshLocalStorage from "./useRefreshLocalStorage";
function useTimezone() {
const [TimeZone, setTimeZone] = useState(() => getTimezone());
const [storageValue] = useRefreshLocalStorage(timezoneKey);
useEffect(() => {
setTimeZone(() => getTimezone());
}, [storageValue]);
return [TimeZone];
}
export default useTimezone;
具体的业务页面组件中使用,
// 页面中
// ...
import useTimezone from "@/hooks/useTimezone";
export default (props) => {
// ...
const [TimeZone] = useTimezone();
useEffect(()=>{
console.log(11111, TimeZone)
},[TimeZone)
}
测试结果必须是成功的啊!!!
小结
其实想要做到该效果,用全局 store 状态管理也能做到,条条大路通罗马嘛!不过本次需求由于历史原因一直使用的是 localStorage ,索性就想着 如何让 localStorage 存储变为响应式 ?
不知道大家还有什么更好的方法吗?
来源:juejin.cn/post/7399461786348044325
接口一异常你的前端页面就直接崩溃了?
前言
在 JavaScript 开发中,细节处理不当往往会导致意想不到的运行时错误,甚至让应用崩溃。可能你昨天上完线还没问题,第二天突然一大堆人艾特你,你就说你慌不慌。
来吧,咱们来捋一下怎么做才能让你的代码更健壮,即使后端数据出问题了咱前端也能稳得一批。
解构失败报错
不做任何处理直接将后端接口数据进行解构
const handleData = (data)=> {
const { user } = data;
const { id, name } = user;
}
handleData({})
VM244:3 Uncaught TypeError: Cannot destructure property 'id' of 'user' as it is undefined.
解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象(装箱)。由于 undefined 、null 无法转为对象,所以对它们进行解构赋值时就会报错。
所以当 data 为 undefined 、null 时候,上述代码就会报错。
第二种情况,虽然给了默认值,但是依然会报错
const handleData = (data)=> {
const { user = {} } = data;
const { id, name } = user;
}
handleData({user: null})
ES6 内部使用严格相等运算符(===)判断一个变量是否有值。所以,如果一个对象的属性值不严格等于 undefined ,默认值是不会生效的。
所以当 props.data
为 null
,那么 const { name, age } = null
就会报错!
good:
const handleData = (data)=> {
const { user } = data;
const { id, name } = user || {};
}
handleData({user: null})
数组方法调用报错
从接口拿回来的数据直接用当成数组来用
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item.name)
}
handleData({userList: null})
handleData({userList: 123})
VM394:3 Uncaught TypeError: userList.map is not a function
那么问题来了,如果 userList 不符合预期,不是数组时必然就报错了,所以最好判断一下
good:
const handleData = (data)=> {
const { userList } = data;
if(Array.isArray(userList)){
const newList = userList.map((item)=> item.name)
}
}
handleData({userList: 123})
遍历对象数组报错
遍历对象数组时也要注意 null
或 undefined
的情况
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item?.name)
}
handleData({userList: [ null, undefined ]})
VM547:3 Uncaught TypeError: Cannot read properties of null (reading 'name')
一旦数组中某项值是 undefined 或 null,那么 item.name 必定报错,可能又白屏了。
good:
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item?.name)
}
handleData({userList: [null]})
但是如果是这种情况就不good了
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> `用户id是${item?.id},用户名字是${item?.name},用户年龄是${item?.age}岁了`);
}
handleData({userList: [null]})
? 可选链操作符,虽然好用,但也不能滥用。item?.name 会被编译成 item === null || item === void 0 ? void 0 : item.name
,滥用会导致编译后的代码size增大。
good:
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> {
const { id, name, age } = item || {};
return `用户id是${id},用户名字是${name},用户年龄是${age}岁了`
});
}
handleData({userList: [null]})
当可选链操作符较多的情况时无论是性能还是可读性都明显上面这种方式更好。
复习一下装箱
大家可以思考一下,以下代码会不会报错
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item.name)
}
handleData({userList: ['', 123]})
是不会报错的,因为在 JavaScript 中,当你在一些基本类型上直接访问属性时这些类型会被自动临时转换成它们对应的对象类型。这种转换称为“装箱”(boxing)。例如:
('').name
空字符串被临时转换成一个字符串对象。由于没有名为 name 的属性,所以它返回 undefined,但不会报错。
let str = "hello";
console.log(str.length); // 5
在这里,str.length 实际上是在字符串对象上调用的,而不是直接在基本类型字符串上。JavaScript 引擎在幕后将字符串 "hello" 装箱为 String 对象,因此可以访问 length 属性。
(123).name
数字 123 被临时转换成一个数字对象。由于没有名为 name 的属性,所以它返回 undefined,但不会报错。
let num = 123;
console.log(num.toFixed(2)); // "123.00"
num.toFixed(2) 调用了数字对象的 toFixed 方法。JavaScript 将数字 123 装箱为 Number 对象。
(null).name
null 是一个特殊的基本类型,当尝试访问其属性时会报错,因为 null 不能被装箱为对象。
try {
const name = (null).name; // TypeError: Cannot read property 'name' of null
} catch (error) {
console.error(error);
}
(undefined).name
undefined 也不能被装箱为对象。
try {
const name = (undefined).name; // TypeError: Cannot read property 'name' of undefined
} catch (error) {
console.error(error);
}
JavaScript 中的基本类型包括:
string
number
boolean
symbol
bigint
null
undefined
对应的对象类型是:
String
Number
Boolean
Symbol
BigInt
装箱的工作原理:
当你访问基本类型的属性或方法时,JavaScript 会自动将基本类型装箱为其对应的对象类型。这个临时的对象允许你访问属性和方法,但它是短暂的,一旦属性或方法访问完成,这个对象就会被销毁。
需要注意的是,null 和 undefined 没有对应的对象类型,不能被装箱。所以访问它们的属性或方法会直接报错!所以时刻警惕 null
和 undefined
这俩坑。
使用对象方法时报错
同理,只要变量能被转成对象,就可以使用对象的方法,但是 undefined 和 null 无法转换成对象。对其使用对象方法时就会报错。
const handleData = (data)=> {
const { user } = data;
const newList = Object.entries(user);
}
handleData({user: null});
VM601:3 Uncaught TypeError: Cannot convert undefined or null to object
下面这两种优化方式都可
good:
const handleData = (data)=> {
const { user } = data;
const newList = Object.entries(user || {})
}
handleData({user: null})
good:
/**
* 判断给定值的类型或获取给定值的类型名称。
*
* @param {*} val - 要判断类型的值。
* @param {string} [type] - 可选,指定的类型名称,用于检查 val 是否属于该类型。
* @returns {string|boolean} - 如果提供了 type 参数,返回一个布尔值表示 val 是* 否属于该类型;如果没有提供 type 参数,返回 val 的类型名称(小写)。
*
* @example
* // 获取类型名称
* console.log(judgeDataType(123)); // 输出 'number'
* console.log(judgeDataType([])); // 输出 'array'
*
* @example
* // 判断是否为指定类型
* console.log(judgeDataType(123, 'number')); // 输出 true
* console.log(judgeDataType([], 'array')); // 输出 true
*/
function judgeDataType(val, type) {
const dataType = Object.prototype.toString.call(val).slice(8, -1).toLowerCase();
return type ? dataType === type : dataType;
}
const handleData = (data)=> {
const { user } = data;
// 判断是否为对象
if(judgeDataType({}, "object")){
const newList = Object.entries(user || {})
}
}
handleData({user: null})
async/await 报错未捕获
这个也是比较容易犯且低级的错误
import React, { useState } from 'react';
const List = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
const res = await fetchListData();
setLoading(false);
}
}
如果 fetchListData() 执行报错,页面就会一直在加载中,所以一定要捕获一下。
good:
const List = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
try {
const res = await queryData();
setLoading(false);
} catch (error) {
setLoading(false);
}
}
}
当然如果觉得这种方式不优雅,用 await-to-js
库或者其他方式都可以,记得捕获就行。
JSON.parse报错
如果传入的不是一个有效的可被解析的 JSON 字符串就会报错啦。
const handleData = (data)=> {
const { userStr } = data;
const user = JSON.parse(userStr);
}
handleData({userStr: 'fdfsfsdd'})
16:06:57.521 VM857:1 Uncaught SyntaxError: Unexpected token 'd', "fdfsfsdd" is not valid JSON
这里没必要去判断一个字符串是否为有效的 JSON 字符串,只要利用 trycatch 来捕获错误即可。
good:
const handleData = (data)=> {
const { userStr } = data;
try {
const user = JSON.parse(userStr);
} catch (error) {
console.error('不是一个有效的JSON字符串')
}
}
handleData({userStr: 'fdfsfsdd'})
动态导入模块失败报错
动态导入某些模块时,也要注意可能会报错
const loadModule = async () => {
const module = await import('./dynamicModule.js');
module.doSomething();
}
如果导入的模块存在语法错误、网络或者跨域问题、文件不存在、循环依赖、甚至文件非常大导致内存不足、模块内的运行时错误等都有可能阻塞后续代码执行。
good:
const loadModule = async () => {
try {
const module = await import('./dynamicModule.js');
module.doSomething();
} catch (error) {
console.error('Failed to load module:', error);
}
}
API 兼容性问题报错
fetch('/api/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));
低版本 Node 不支持 fetch
,需要更高兼容性的场景使用 axios
等更好。
其他包括小程序开发,web开发等也同理,如果使用了某些不支持的 es 新特性或者较新版本的平台才支持的api也会导致直接报错,使用时做好判断或直接用兼容性更好的写法。
框架在编译时已经帮我们解决了大部分的兼容性问题,但是有些场景还需额外注意。
内存溢出崩溃
滥用内存缓存可能会导致内存溢出
const cache = {};
function addToCache(key, value) {
cache[key] = value;
// 没有清理机制,缓存会无限增长
}
避免闭包持有大对象的引用
function createClosure() {
const largeData = new Array(1000000).fill('x');
return function() {
console.log(largeData.length);
};
}
const closure = createClosure();
// largeData 现在被闭包引用会一直存活在内存中,即使不再直接使用
closure = null; // 手动解除引用
记得清除定时器和事件监听器
// React
useEffect(() => {
const timeoutId = setTimeout(() => {
// 一些操作
}, 1000);
return () => clearTimeout(timeoutId);
}, []);
function setupHandler() {
const largeData = new Array(1000000).fill('x');
const handler = function() {
console.log(largeData.length);
};
document.getElementById('myButton').addEventListener('click', handler);
return function cleanup() {
document.getElementById('myButton').removeEventListener('click', handler);
};
}
const cleanup = setupHandler();
// 在适当的时候调用
// cleanup();
还有深度递归,JSON.parse()
解析超大数据等都可能会对内存造成压力。
总结
以上列举了js在运行时可能会发生错误而导致的应用崩溃的一些边界情况,这些都是在开发时不那么容易察觉,eslint等静态检查工具也无能为力的场景,当然如果用typescript的话还是可以帮助我们避免大部分坑的,如果不用 ts 的话就不可避免的需要考虑这些情况才能写出健壮的代码。
边界场景的容错一定要做,原则上不信任任何外部输入数据的存在性和类型,历史经验告诉我们,不做容错出错只是早晚的事。
帮别人review代码的时候也可以参考以上清单,如果大家还有补充欢迎讨论,最后祝各位大佬没有bug。
来源:juejin.cn/post/7388022210856222732
为了检测360浏览器,我可是煞费苦心……
众所周知,360浏览器一向是特立独行的存在,为了防止用户识别它,隐藏了自己的用户代理(User-Agent)。只有在自己域名下访问,用户代理(User-Agent)才暴露出自己的特征。显然,这样对于开发者想要识别它,造成了不少麻烦。
非360官方网站访问
360官方网站访问
为了识别出360,只能通过Javascript检测360的特殊属性,找和其他浏览器的区别。最常见的方式就是找navigator.mimeTypes
或者navigator.plugins
有哪些不一样的值。为此,我在各个版本的360浏览器(360安全浏览器、360极速浏览器)也找到了各种可以利用的特征。
比如,无论 360安全浏览器 还是 360极速浏览器 的navigator.mimeTypes
都可能存在
application/360softmgrplugin
, application/mozilla-npqihooquicklogin
, application/npjlgplayer3-chrome-jlp
,application/vnd.chromium.remoting-viewer
这几种类型,我们可以通过判断这几个值是否存在识别 360浏览器。不仅如此,在早期的360浏览器中,明明是chrome内核,还保留着IE时代有了showModalDialog
方法,这些都可以用来做识别的依据。
import getMime from '../method/getMime.js';
import _globalThis from '../runtime/globalThis.js';
export default {
name: '360',
match(ua) {
let isMatch = false;
if (_globalThis?.chrome) {
let chrome_version = ua.replace(/^.*Chrome\/([\d]+).*$/, '$1');
if (getMime("type", "application/360softmgrplugin") || getMime("type", "application/mozilla-npqihooquicklogin") || getMime("type", "application/npjlgplayer3-chrome-jlp")) {
isMatch = true;
} else if (chrome_version > 36 && _globalThis?.showModalDialog) {
isMatch = true;
} else if (chrome_version > 45) {
isMatch = getMime("type", "application/vnd.chromium.remoting-viewer");
if (!isMatch && chrome_version >= 69) {
isMatch = getMime("type", "application/asx");
}
}
}
return ua.includes('QihooBrowser')
||ua.includes('QHBrowser')
||ua.includes(' 360 ')
||isMatch;
},
version(ua) {
return ua.match(/QihooBrowser(HD)?\/([\d.]+)/)?.[1]
||ua.match(/Browser \(v([\d.]+)/)?.[1]
||'';
}
};
然而,360并不是只有1种浏览器,还包含了 360安全浏览器, 360极速浏览器,360 AI浏览器等。我们又怎么区分呢,这时候还要寻找它们之间的差别。360AI浏览器较为简单,用户代理(User-Agent)中直接暴露相关信息。
我发现有一个值是360安全浏览器独有的,那就是application/gameplugin
,应该是浏览器内置的游戏插件。可是在后续的版本中也消失了,我又发现navigator.userAgentData.brands
里的值也有细微区别。于是就可以如下处理:
import getMime from '../method/getMime.js';
import _Chrome from './Chrome.js';
import _360 from './360.js';
import _globalThis from '../runtime/globalThis.js';
export default {
name:'360SE',
match(ua,isAsync=false){
let isMatch = false;
if(_360.match(ua)){
if(getMime("type", "application/gameplugin")){
isMatch = true;
}else if(_globalThis?.navigator?.userAgentData?.brands.filter(item=>item.brand=='Not.A/Brand').length){
isMatch = true;
}
}
return ua.includes('360SE')||isMatch;
},
version(ua){
let hash = {
'122':'15.3',
'114':'15.0',
'108':'14.0',
'86':'13.0',
'78':'12.0',
'69':'11.0',
'63':'10.0',
'55':'9.1',
'45':'8.1',
'42':'8.0',
'31':'7.0',
'21':'6.3'
};
let chrome_version = parseInt(_Chrome.version(ua));
return hash[chrome_version]||'';
}
};
而 360极速浏览器的识别依据就相对较多了!各种身份验证的插件都能在里面找到。
import getMime from '../method/getMime.js';
import _Chrome from './Chrome.js';
import _360 from './360.js';
import _globalThis from '../runtime/globalThis.js';
export default {
name:'360EE',
match(ua){
let isMatch = false;
if(getMime('type','application/cenroll.cenroll.version.1')||getMime('type','application/hwepass2001.installepass2001')){
isMatch = true;
}else if(_360.match(ua)){
if(_globalThis?.navigator?.userAgentData?.brands.find(item=>item.brand=='Not A(Brand'||item.brand=='Not?A_Brand')){
isMatch = true;
}
}
return ua.includes('360EE')||isMatch;
},
version(ua){
let hash = {
'122':'22.3', // 360极速X
'119':'22.0', // 360极速X
'108':'14.0', // 360极速
'95':'21.0', // 360极速X
'86':'13.0',
'78':'12.0',
'69':'11.0',
'63':'9.5',
'55':'9.0',
'50':'8.7',
'30':'7.5'
};
let chrome_version = parseInt(_Chrome.version(ua));
return ua.match(/Browser \(v([\d.]+)/)?.[1]
||hash[chrome_version]
||'';
}
};
可惜的是在Mac系统中的情况复杂点,这些插件的方法都不存在,这下又失去了判断的依据了。还有,在一次无意打开网络连接一次的时候,发现了360浏览器在请求一个奇怪的地址,居然返回了浏览器版本信息。
import _globalThis from '../runtime/globalThis.js';
const GetDeviceInfo = () => {
return new Promise((resolve) => {
const randomCv = `cv_${new Date().getTime() % 100000}${Math.floor(Math.random()) * 100}`
const params = { key: 'GetDeviceInfo', data: {}, callback: randomCv }
const Data = JSON.stringify(params)
if(_globalThis?.webkit?.messageHandlers){
_globalThis.webkit.messageHandlers['excuteCmd'].postMessage(Data)
_globalThis[randomCv] = function (response) {
delete _globalThis[randomCv];
resolve(JSON.parse(response||'{}'));
}
}else{
return resolve({});
}
})
};
export default {
name: '360EE',
match(ua) {
return GetDeviceInfo().then(function(response){
return response?.pid=='360csexm'||false;
});
},
version(ua) {
return GetDeviceInfo().then(function(response){
return response?.module_version||'';
});
}
};
原本觉得一切应该就这么顺利了,然后当我从Windows10迁移到windows11的时候,发现原来的浏览器中的插件特征识别已经失效了,我找不到360安全浏览器的识别特征。
于是我疯了……我开始一个个属性对比差异,就是找不到有什么特征是可以区分开的。就在我一筹莫展的时候,我无意间发现,我自己的网站在360安全浏览器中,莫默其妙多了一个奇怪的节点,看样子是一个AI组件。我敢确定,这个节点并不是我写的,于是我断言是360做了什么特殊处理。
经过分析,我发现这是360安全浏览器内置的一个“扩展程序 - 360智脑” ,是默认安装的而且无法卸载。我脑子一下子亮起来了,心想着我可以根据这个节点判断啊,只要检测它是否加载就能判断出来。于是,我写了以下代码:
// 根据检测文档中是否被插入“360智脑”组件,判断是否为360安全浏览器
if(!document?.querySelector('#ai-assist-root')){
return new Promise(function(resolve){
let hander = setTimeout(function(){
resolve(false);
},1500);
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(function($item){
if($item.id=='ai-assist-root'){
hander&&clearTimeout(hander);
resolve(true);
}
});
}
});
});
observer.observe(document,{childList: true, subtree: true});
});
}
但确实也有不足之处,我只能判断出什么时候加载了节点,但是无法对非360浏览器不加载进行判断。只能通过定时器去做超时处理,这就意味着非360浏览器判断需要一定耗时,这样太不友好了。
就在这时候,我发现这个“扩展程序”是需要加载资源的,而这个资源在浏览器本地。也就意味着,我可以直接直接对资源进行判断,这样并不需要等插件加载超时判断,非360安全浏览器可以较快地判断出“非他”的条件。
// 根据判断扩展程序CSS是否加载成功判断是否为360安全浏览器
return new Promise(function(resolve){
fetch('chrome-extension://fjbbmgamncjadhlpmffehlmmkdnkiadk/css/content.css').then(function(){
resolve(true);
}).catch(function(){
resolve(false);
});
});
于是我终于可以判断出这烦人的“小妖精”了!而这也是浏览器嗅探的其中一项工作,为此我还做了诸如:操作系统、屏幕、处理器架构、GPU、CPU、IP地址、时区、语言、网络等浏览器信息的判断和识别。
浏览器在线检测: passer-by.com/browser/
开源项目仓库地址:github.com/mumuy/brows…
如果你对此感兴趣或者有什么内容要补充,欢迎关注此项目~
来源:juejin.cn/post/7390588322768748580
因为不知道Object.keys,被嫌弃了
关联精彩文章:# 改进tabs组件切换效果,丝滑的动画获得一致好评~
背景
今天,同事看到了我一段遍历读取对象key的代码后,居然嘲笑我技术菜(虽然我确实菜)
const person = {
name: '员工1',
age: 30,
profession: 'Engineer'
// ....
};
// 使用 for 循环读取对象的键
const keys = Object.keys(person);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = person[key];
console.log(key + ': ' + value);
}
我说,用for循环读取对象的key不对吗,他二话不说直接给我改代码
const person = {
name: '员工1',
age: 30,
profession: 'Engineer'
};
Object.keys(person).forEach(key => {
console.log(key + ': ' + person[key]);
});
然后很装的走了......钢铁直男啊!我很生气,我这个人比较较劲,我一定要把Object.keys吃透!
Object.keys的基础用法
语法
Object.keys
是 JavaScript 中的一个方法,用于获取对象自身的可枚举属性名称,并以数组形式返回。
Object.keys(obj)
- 参数:要返回其枚举自身属性的对象
- 返回值:一个表示给定对象的所有可枚举属性的字符串数组
不同入参的返回值
- 处理对象,返回可枚举的属性数组
let person = {name:"张三",age:25,address:"深圳",getName:function(){}}
Object.keys(person)
// ["name", "age", "address","getName"]
- 处理数组,返回索引值数组
let arr = [1,2,3,4,5,6]
Object.keys(arr)
// ["0", "1", "2", "3", "4", "5"]
- 处理字符串,返回索引值数组
let str = "saasd字符串"
Object.keys(str)
// ["0", "1", "2", "3", "4", "5", "6", "7"]
Object.keys的常用技巧
Object.keys在处理对象属性、遍历对象和动态生成内容时非常有用。
遍历对象属性
当你需要遍历一个对象的属性时,Object.keys
可以将对象的所有属性名以数组形式返回,然后你可以使用 forEach
或 for...of
来遍历这些属性名。
示例:
const person = {
name: '员工1',
age: 30,
profession: 'Engineer'
};
Object.keys(person).forEach(key => {
console.log(key + ': ' + person[key]);
});
输出:
name: 员工1
age: 30
profession: Engineer
获取对象属性的数量
可以使用 Object.keys
获取对象的属性名数组,然后通过数组的 length
属性来确定对象中属性的数量。
示例:
const person = {
name: '员工2',
age: 30,
profession: 'Engineer'
};
const numberOfProperties = Object.keys(person).length;
console.log(numberOfProperties); // 输出: 3
过滤对象属性
可以使用 Object.keys
来获取对象的属性名数组,然后使用数组的 filter
方法来筛选属性名,从而创建一个新的对象。
示例:
const person = {
name: '员工3',
age: 30,
profession: '钢铁直男'
};
const filteredKeys = Object.keys(person).filter(key => key !== 'age');
const filteredPerson = {};
filteredKeys.forEach(key => {
filteredPerson[key] = person[key];
});
console.log(filteredPerson); // 输出: { name: '员工3', profession: '钢铁直男' }
检查对象是否为空
可以通过检查 Object.keys
返回的数组长度来确定对象是否为空。
示例:
const emptyObject = {};
const nonEmptyObject = { name: '讨厌的人' };
function isEmpty(obj) {
return Object.keys(obj).length === 0;
}
console.log(isEmpty(emptyObject)); // 输出: true
console.log(isEmpty(nonEmptyObject)); // 输出: false
深拷贝对象
虽然 Object.keys
本身并不能进行深拷贝,但它可以与其他方法结合使用来创建对象的深拷贝,特别是当对象的属性是另一层对象时。
示例:
const person = {
name: '快乐就是哈哈哈',
age: 18,
profession: 'coder',
address: {
city: 'Wonderland',
postalCode: '12345'
}
};
function deepCopy(obj) {
const copy = {};
Object.keys(obj).forEach(key => {
if (typeof obj[key] === 'object' && obj[key] !== null) {
copy[key] = deepCopy(obj[key]);
} else {
copy[key] = obj[key];
}
});
return copy;
}
const copiedPerson = deepCopy(person);
console.log(copiedPerson);
是小姐姐,不是小哥哥~
来源:juejin.cn/post/7392115478549069861
JavaScript实现访问本地文件夹
这个功能放在之前是不可能实现的,因为考虑到用户的隐私,但是最近有一个新的api可以做到这一点。下面来进行一个简单的功能实现。
如何选择文件夹
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<button>打开文件夹</button>
<script>
var btn = document.querySelector('button');
btn.onclick=function() {
showDirectoryPicker()
}
</script>
</body>
</html>
我们调用showDirectoryPicker这个函数就可以实现一个选择文件夹的功能。
showDirectoryPicker()
options
可选
选项对象,包含以下属性:
id
通过指定 ID,浏览器能够记住不同 ID 所对应的目录。当使用相同的 ID 打开另一个目录选择器时,选择器会打开相同的目录。mode
字符串,默认为"read"
,可对目录进行只读访问。设为"readwrite"
可对目录进行读写访问。startIn
一个FileSystemHandle
对象或者代表某个众所周知的目录的字符串(如:"desktop"
、"documents"
、"downloads"
、"music"
、"pictures"
、"videos"
)。用于指定选择器的起始目录。
返回值
一个 Promise
对象,会兑现一个 FileSystemDirectoryHandle
(en-US) 对象。
异常
AbortError
当用户直接关闭了目录选择器或选择的目录是敏感目录时将会抛出 AbortError。
如何得到文件夹中的文件/子文件夹
首先对于上面所写的东西,我们进行try catch的优化
try {
// 获得文件夹的句柄
const handle = await showDirectoryPicker();
}
catch {
//用户拒绝查看文件
alert('访问失败')
}
之后我们来看一下这个headler打印出来是什么
句柄的简单解释
对于“句柄”,在下一直停留在一知半解的认识层面,近日在下学习Windows编程,决定趁此机会将句柄彻底搞清楚。查阅了一些网络上的资料,发现网络上的讲解大概可以分为两类:一种是以比喻、类比的方式说明,这种方法虽然形象易懂,但并没有从原理上、本质上加以揭示,让人仍然想问“为什么?”、“怎么实现?”。另一种是给出源代码,无可厚非,这当然是最本质的说明了,但这样一来,又显得不够直观,初学者理解起来有一定的难度。鉴于此,在下尽微末之能,结合自己的愚见,在两者之间折中,用图解的方式来将原理呈现出来,做到一目了然。
这里需要说明:
1.这里将句柄所能标识的所有东西(如窗口、文件、画笔等)统称为“对象”。
2.图中一个小横框表示一定大小的内存区域,并不代表一个字节,如标有0X00000AC6的横框表示4个字节。
3.图解的目的是为了直观易懂,所以不一定与源码完全对应,会有一定的简化。
让我们先看图,再解释。
其中,图1是程序运行到某时刻时的内存快照,图2是程序往后运行到另一时刻时的内存快照。红色部分标出了两次的变化。
简单解释:
Windows是一个以虚拟内存为基础的操作系统,很多时候,进程的代码和数据并不全部装入内存,进程的某一段装入内存后,还可能被换出到外存,当再次需要时,再装入内存。两次装入的地址绝大多数情况下是不一样的。也就是说,同一对象在内存中的地址会变化。(对于虚拟内存不是很了解的读者,可以参考有关操作系统方面的书籍)那么,程序怎么才能准确地访问到对象呢?为了解决这个问题,Windows引入了句柄。
系统为每个进程在内存中分配一定的区域,用来存放各个句柄,即一个个32位无符号整型值(32位操作系统中)。每个32位无符号整型值相当于一个指针,指向内存中的另一个区域(我们不妨称之为区域A)。而区域A中存放的正是对象在内存中的地址。当对象在内存中的位置发生变化时,区域A的值被更新,变为当前时刻对象在内存中的地址,而在这个过程中,区域A的位置以及对应句柄的值是不发生变化的。这种机制,用一种形象的说法可以表述为:有一个固定的地址(句柄),指向一个固定的位置(区域A),而区域A中的值可以动态地变化,它时刻记录着当前时刻对象在内存中的地址。这样,无论对象的位置在内存中如何变化,只要我们掌握了句柄的值,就可以找到区域A,进而找到该对象。而句柄的值在程序本次运行期间是绝对不变的,我们(即系统)当然可以掌握它。这就是以不变应万变,按图索骥,顺藤摸瓜。
**所以,我们可以这样理解Windows **句柄:
数值上,是一个32位无符号整型值(32位系统下);逻辑上,相当于指针的指针;形象理解上,是Windows中各个对象的一个唯一的、固定不变的ID;作用上,Windows使用句柄来标识诸如窗口、位图、画笔等对象,并通过句柄找到这些对象。
下面,关于句柄,再交代一些关键性细节:
1.所谓“唯一”、“不变”是指在程序的一次运行中。如果本次运行完,关闭程序,再次启动程序运行,那么这次运行中,同一对象的句柄的值和上次运行时比较,一般是不一样的。
其实这理解起来也很自然,所谓“一把归一把,这把是这把,那把是那把,两者不相干”(“把”是形象的说法,就像打牌一样,这里指程序的一次运行)。
2.句柄是对象生成时系统指定的,属性是只读的,程序员不能修改句柄。
3.不同的系统中,句柄的大小(字节数)是不同的,可以使用sizeof()来计算句柄的大小。
4.通过句柄,程序员只能调用系统提供的服务(即API调用),不能像使用指针那样,做其它的事。
再回归正题。
处理句柄函数
async function processHandler(handle) {
if (handle.kind==='file'){
return handle
}
handle.children=[]
const iter = await handle.entries();//获得文件夹中的所有内容
//iter:异步迭代器
for await (const info of iter){
var subHandle = await processHandler(info[1]);
handle.children.push(subHandle)
}
return handle
}
如何得到文件内容
const root = await processHandler(handle);
// 获得文件内容
const file = await root.children[1].getFile();
const reader = new FileReader();
reader.onload=e=>{
// 读取结果
console.log(e.target.result)
}
reader.readAsText(file,'utf-8')
这里用到的就是一个很简单的文件读了。
下面是完整的代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<button>打开文件夹</button>
<script>
var btn = document.querySelector('button');
btn.onclick=async function() {
try {
// 获得文件夹的句柄
const handle = await showDirectoryPicker();
const root = await processHandler(handle);
// 获得文件内容
const file = await root.children[1].getFile();
const reader = new FileReader();
reader.onload=e=>{
// 读取结果
console.log(e.target.result)
}
reader.readAsText(file,'utf-8')
}
catch {
//用户拒绝查看文件
alert('访问失败')
}
}
async function processHandler(handle) {
if (handle.kind==='file'){
return handle
}
handle.children=[]
const iter = await handle.entries();//获得文件夹中的所有内容
//iter:异步迭代器
for await (const info of iter){
var subHandle = await processHandler(info[1]);
handle.children.push(subHandle)
}
return handle
}
</script>
</body>
</html>
来源:juejin.cn/post/7268011328940769315
三大微前端框架,谁是你的理想型?
1. 分享目标:
2. 什么是微前端?
故事开始于三年前…
小明为公司重构了一版新的管理后台,采用了市面上最流行的SPA渲染模式,具体技术栈使用的是 react + react-router。

项目第一版很快就顺利上线了,但在后续的迭代中,遇到一个棘手的问题:产品经理希望快速复用之前项目的某些页面。这让小明犯了难,因为老项目是用“上古神器” jQuery 写的,完全重构成 react,成本非常高。这时后端老哥丢过来一句:“你们前端用 iframe 嵌进来就可以了吧? ” 小明心里很清楚 iframe 有许多小毛病,但在当时,也确实没有比它更好的选择了。
上线后,随着时间的推移,用户产生了困惑:
- 为什么这个页面的弹框不居中了?
- 为什么这个页面的跳转记录无法保存? ...
小明心里其实非常清楚,这一切都是 iframe 带来的弊端。
时间来到三年后的今天,小明听说微前端能够解决 iframe 的各种疑难杂症,于是展开了调研。
市面上对微前端的定义让人眼花缭乱,比如微前端是:

这里给出我对微前端最接地气的定义:

故事开始于三年前…
小明为公司重构了一版新的管理后台,采用了市面上最流行的SPA渲染模式,具体技术栈使用的是 react + react-router。
项目第一版很快就顺利上线了,但在后续的迭代中,遇到一个棘手的问题:产品经理希望快速复用之前项目的某些页面。这让小明犯了难,因为老项目是用“上古神器” jQuery 写的,完全重构成 react,成本非常高。这时后端老哥丢过来一句:“你们前端用 iframe 嵌进来就可以了吧? ” 小明心里很清楚 iframe 有许多小毛病,但在当时,也确实没有比它更好的选择了。
上线后,随着时间的推移,用户产生了困惑:
- 为什么这个页面的弹框不居中了?
- 为什么这个页面的跳转记录无法保存? ...
小明心里其实非常清楚,这一切都是 iframe 带来的弊端。
时间来到三年后的今天,小明听说微前端能够解决 iframe 的各种疑难杂症,于是展开了调研。
市面上对微前端的定义让人眼花缭乱,比如微前端是:
这里给出我对微前端最接地气的定义:
“类似于iframe的效果,但没有它带来的各种问题”——小明。
3. 主流技术方向分类
首先,“微前端”作为近几年国内前端界最火的技术之一,目前存在多个技术流派。我按照它们对 iframe 看法的不同,将主流微前端方案分为了三大派系:革新派、改良派、中间派。

首先,“微前端”作为近几年国内前端界最火的技术之一,目前存在多个技术流派。我按照它们对 iframe 看法的不同,将主流微前端方案分为了三大派系:革新派、改良派、中间派。
3.1. 革新派 qiankun
以 qiankun 为主的革新派认为: iframe 问题很多,应避免使用它。 完全可以利用现有的前端技术自建一套应用隔离渲染方案。
以 qiankun 为主的革新派认为: iframe 问题很多,应避免使用它。 完全可以利用现有的前端技术自建一套应用隔离渲染方案。
3.1.1. 原理:
3.1.1.1. 基于 single-spa
将路由切换与子应用加载、卸载等生命周期结合起来是微前端的一项核心能力。这一步 qiankun 是基于 single-spa 实现的,不同的是它支持以 html 作为加载子应用的入口,不必像 single-spa 那样需要手动梳理资源链接,内部插件 import-html-entry 会自动分析 html 以获取 js 和 css。

将路由切换与子应用加载、卸载等生命周期结合起来是微前端的一项核心能力。这一步 qiankun 是基于 single-spa 实现的,不同的是它支持以 html 作为加载子应用的入口,不必像 single-spa 那样需要手动梳理资源链接,内部插件 import-html-entry 会自动分析 html 以获取 js 和 css。
3.1.1.2. 样式隔离
为了确保子应用之间样式互不影响,qiankun 内置了三种样式隔离模式:
- 默认模式。
原理是加载下一个子应用时,将上一个子应用的 、
等样式相关标签通通删除与替换,来实现样式隔离。缺点是仅支持单例模式(同一时间只能渲染单个子应用),且没法做到主子应用及多个子应用之间的样式隔离。
- 严格模式。
为了确保子应用之间样式互不影响,qiankun 内置了三种样式隔离模式:
- 默认模式。
原理是加载下一个子应用时,将上一个子应用的 、
等样式相关标签通通删除与替换,来实现样式隔离。缺点是仅支持单例模式(同一时间只能渲染单个子应用),且没法做到主子应用及多个子应用之间的样式隔离。
- 严格模式。
可通过 strictStyleIsolation:true
开启。原理是利用 webComponent 的 shadowDOM 实现。但它的问题在于隔离效果太好了,在目前的前端生态中有点水土不服,这里举两个例子。
- 可能会影响 React 事件。比如这个issue 当 Shadow Dom 遇上 React 事件 ,大致原因是在 React 中事件是“合成事件”,在React 17 版本之前,所有用户事件都需要冒泡到 document 上,由 React 做统一分发与处理,如果冒泡的过程中碰到 shadowRoot 节点,就会将事件拦截在 shadowRoot 范围内,此时
event.target
强制指向 shadowRoot,导致在 react 中事件无响应。React 17 之后事件监听位置由 document 改为了挂载 App 组件的 root 节点,就不存在此问题了。
- 弹框样式丢失。 原因是主流UI框架比如 antd 为了避免上层元素的样式影响,通常会把弹框相关的 DOM 通过
document.body.appendChild
插入到顶层 body 的下边。此时子应用中 antd 的样式规则,由于开启了 shadowDom ,只对其下层的元素产生影响,自然就对全局 body 下的弹框不起作用了,造成了样式丢失的问题。
解决方案:调整 antd 入参,让其在当前位置渲染。
- 实验模式。
可通过 experimentalStyleIsolation:true
开启。 原理类似于 vue 的 scope-css,给子应用的所有样式规则增加一个特殊的属性选择器,限定其影响范围,达到样式隔离的目的。但由于需要在运行时替换子应用中所有的样式规则,所以目前性能较差,处于实验阶段。
3.1.1.3. JS 沙箱
确保子应用之间的“全局变量”不会产生冲突。
- 快照沙箱( snapshotSandbox )
- 激活子应用时,对着当前
window
对象照一张相(所有属性 copy 到一个新对象windowSnapshot
中保存起来)。 - 离开子应用时,再对着
window
照一张相,对比离开时的window
与激活时的 window (也就是windowSnapshot
)之间的差异。- 记录变更。Diff 出在这期间更改了哪些属性,记录在
modifyPropsMap
对象中。 - 恢复环境。依靠
windowSnapshot
恢复之前的window
环境。
- 记录变更。Diff 出在这期间更改了哪些属性,记录在
- 下次激活子应用时,从
modifyPropsMap
对象中恢复上一次的变更。
- 单例的代理沙箱 ( LegacySanbox )
与快照沙箱思路很相似,但它不用通过 Diff 前后 window 的方式去记录变更,而是通过 ES6的 Proxy 代理 window 属性的 set 操作来记录变更。由于不用反复遍历 window,所以性能要比快照沙箱好。
- 支持多例的代理沙箱( ProxySandbox )
以上两种沙箱机制,都只支持单例模式(同一页面只支持渲染单个子应用)。
原因是:它们都直接操作的是全局唯一的 window。此时机智的你肯定想到了,假如为每个子应用都分配一个独立的“虚拟window”,当子应用操作 window 时,其实是在各自的“虚拟 window”上操作,不就可以实现多实例共存了?事实上,qiankun 确实也是这样做的。
既然是“代理”沙箱,那“代理”在这的作用是什么呢?
主要是为了实现对全局对象属性 get、set 的两级查找,优先使用fakeWindow,特殊情况(set命中白名单或者get到原生属性)才会改变全局真实window。
如此,qiankun 就对子应用中全局变量的 get 、 set 都实现了管控与隔离。
3.1.2. 优势:
3.1.2.1. 具有先发优势
2019年开源,是国内最早流行起来的微前端框架,在蚂蚁内外都有丰富的应用,后期维护性是可预测的。
3.1.2.2. 开箱即用
虽然是基于国外的 single-spa 二次封装,但提供了更加开箱即用的 API,比如支持直接以 HTML 地址作为加载子应用的入口。
3.1.2.3. 对 umi 用户更加友好
有现成的插件 @umijs/plugin-qiankun 帮助降低子应用接入成本。
3.1.3. 劣势:
3.1.3.1. vite 支持性差
由上可知,代理沙箱实现的关键是需要将子应用的 window “替换”为 fakeWindow,在这一步 qiankun 是通过函数 window 同名参数 + with 作用域绑定的方式,更改子应用 window 指向为 fakeWindow,最终使用 eval(...) 解析运行子应用的代码。
const jsCode = `
(function(window, self, globalThis){
with(this){
// your code
window.a = 1;
b = 2
...
}
}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);
`
eval(jsCode)
问题就出在这个 eval 上, vite 的构建产物如果不做特殊降级,默认打包出的就是 ESModule 语法的代码,使用 eval 解析运行会报下图这个错误。
报错的大意是, import 语法的代码必须放在 中执行。
官方目前推荐的解决方法是关闭沙箱... 但其实还有另一种比较取巧的方案:vite 生态里有一款专门兼容此问题的vite-plugin-qiankun 插件,它的原理是: eval 虽然没办法执行静态 import 语法,但它可以执行动态 import(...) 语法。
所以这款插件的解决方案就是替换子应用代码中的静态 import 为动态 import(),以绕过上述限制。
3.1.3.2. 子应用接入成本较高,详细步骤参考子应用接入文档
umi 用户可忽略这点,尤其是 @umi/max 用户,相比 webpack 接入成本要低很多。
3.1.3.3. JS 沙箱存在性能问题,且并不完善。
大致原因是 with + proxy 带来的性能损耗,详见 JS沙箱的困境 。当然 qiankun 官方也在针对性的进行优化,进展在这篇《改了 3 个字符,10倍的沙箱性能提升?!!》文章中可见一斑 。
3.2. 改良派 wujie
3.2.1. 原理:
wujie 是腾讯出品的一款微前端框架。作为改良派的代表,它认为: iframe 虽然问题很多,但仅把它作为一个 js 沙箱去用,表现还是很稳定的,毕竟是浏览器原生实现的,比自己实现 js 沙箱靠谱多了。至于 iframe 的弊端,可以针对性的去优化:
- DOM 渲染无法突破 iframe 边界?(弹框不居中问题)
那 DOM
就不放 iframe
里渲染了,而是单独提取到一个 webComponent
里渲染,顺便用 shadowDOM
解决样式隔离的问题。
简单说,无界的方案就是:JS 放 iframe 里运行,DOM 放 webComponent 渲染。
那么问题来了: 用 JS 操作 DOM 时,两者如何联系起来呢?毕竟 JS 默认操作的总是全局的 DOM。无界在此处用了一种比较 hack 的方式:代理子应用中所有的 DOM 操作,比如将 document
下的 getElementById、querySelector、querySelectorAll、head、body
等查询类 api 全部代理到 webComponent
。
下图是子应用真实运行时的例子:
至于多实例模式,就更容易理解了。给每个子应用都分配一套 iframe
+ webComponent
的组合,就可以实现相互之间的隔离了!
- 刷新页面会导致子应用路由状态丢失?
通过重写 iframe
实例的history.pushState
和 history.replaceState
,将子应用的 path
记录到主应用地址栏的 query
参数上,当刷新浏览器初始化 iframe
时,从地址栏读到子应用的 path
并使用 iframe
的 history.replaceState
进行同步。
简单理解就是:将子应用路径记录在地址栏参数中。
3.2.2. 优势:
3.2.2.1. 相比 qiankun 接入成本更低。
- 父应用:
- 与 iframe 的接入方式很类似,只需引入一个 React 组件渲染子应用即可。
- 与 iframe 的接入方式很类似,只需引入一个 React 组件渲染子应用即可。
- 子应用理论上不需要做任何改造
3.2.2.2. vite 兼容性好
直接将完整的 ESM 标签块 插入 iframe 中,避免了 qiankun 使用 eval 执行 ESM 代码导致的报错问题。
3.2.2.3. iframe 沙箱隔离性好
3.2.3. 劣势:
3.2.3.1. 坑比较多
- 明坑: 用于 JS 沙箱的 iframe 的 src 必须指向一个同域地址导致的问题。
具体问题描述见下图:
具体问题描述见下图:
此 [issue]() 至今无法在框架层面得到解决,属于 iframe 的原生限制。
手动的解决方案:
- 主应用提供一个路径比如说 https://host/empty ,这个路径不需要返回任何内容,子应用设置 attr 为 {src:'https://host/empty'},这样 iframe 的 src 就是 https://host/empty。
- 在主应用 template 的 head 插入
这样的代码可以避免主应用代码污染。
- 暗坑: 复杂的 iframe 到 webComponent 的代理机制,导致市面上大部分富文本编辑器都无法在无界中完好运行。所以有富文本的项目,尽量别用无界,除非你对富文本库的源码了如指掌。issues 在这里。
3.2.3.2. 长期维护性一般。
3.2.3.3. 内存开销较大
用于 js 沙箱的 iframe 是隐藏在主应用的 body 下面的,相当于是常驻内存,这可能会带来额外的内存开销。
3.3. 中间派 micro-app
3.3.1. 原理:
京东的大前端团队出品。
样式隔离方案与 qiankun 的实验方案类似,也是在运行时给子应用中所有的样式规则增加一个特殊标识来限定 css 作用范围。
子应用路由同步方案与 wujie 类似,也是通过劫持路由跳转方法,同步记录到 url 的 query 中,刷新时读取并恢复。
组件化的使用方式与 wujie 方案类似,这也是 micro-app 主打的宣传点。
最有意思的是它的沙箱方案,居然内置了两种沙箱:
- 类 qiankun 的 with 代理沙箱,据说相比 qiankun 性能高点,但目前微前端框架界并没有一个权威的基准性能测试依据,所以并无有效依据支撑。
- 类 wujie 的 iframe 沙箱,用于兼容 vite 场景。
开发者可以根据自身的实际情况自由选择。
整体感觉 micro-app 是一种偏“现实主义”的框架,它的特点就是取各家所长,最终成为了功能最丰富的微前端框架。
3.3.2. 优势:
3.3.2.1. 支持的功能最丰富。
3.3.2.2. 接入成本低。
3.3.2.3. 文档完善。
micro-zoe.github.io/micro-app/d…
3.3.3. 劣势:
3.3.3.1. 功能丰富导致配置项与 api 太多。
3.3.3.2. 静态资源补全问题。
静态资源补全是基于父应用的,而非子应用这需要开发者自己手动解决。
4. 选型建议
统计时间2023.12.3 | npm周下载量 | star数 | issue数 | 最近更新时间 | 接入成本 | 沙箱支持vite |
---|---|---|---|---|---|---|
qiankun | 22k | 15k | 362/1551 | 12天前 | 高 | ❌ |
wujie | 1.3k | 3.4k | 280/271 | 24天前 | 低 | ✅ |
micro-app | 1.1k | 4.9k | 57/748 | 1个月前 | 中 | ✅ |
- 刚性建议。
- vite 项目且对 js 沙箱有刚需,选 wujie 或者 micro-app。
- 项目存在复杂的交互场景,比如有用到富文本编辑器库,选 wujie 前请做好充分的测试。
- 如果你的团队对主、子应用的开发完全受控,即使有隔离性问题也可以通过治理来解决,那么可以试试更轻量的 single-SPA 方案。
- 如果特别重视稳定性,那无疑是 iframe 最佳... 因为 iframe 存在的问题都是摆在明面的,市面上现有的微前端框架多多少少都有一些隐性问题。
- 综合推荐。
主要从接入成本、功能稳定性、长期维护性三方面来衡量:
- 接入成本: wujie > microApp > qiankun (由低到高)
- 功能稳定性:qiankun > microApp > wujie
- 长期维护性:qiankun > microApp > wujie
看你的团队最看重哪一点,针对性去选择就好了,没有十全十美微前端框架,只有适合自己的。
最后
以上内容,确实会有我强烈的个人理解与观点,这也是我写文章一贯的风格。我并不喜欢那种客观且枯燥无味的文章,读完之后感觉像流水账,给不了读者任何的指导。我认为文章就是要有观点输出,技术文章也不例外,如果非常看重准确无误的表达,可以直接去看说明文档or源码,那应该是最权威的知识。如有错误或者误解,可以评论区或者私信指出,我积极改正。
来源:juejin.cn/post/7309477710523269174
从组件库中学习颜色主题配置
前言
对于一般前端来说,在颜色选择配置上可能没有设计师那么专业,特别在某些项目中的一些场景颜色配置上可能都是用相近的颜色或者透明度来匹配,没有一个专门的颜色对比输出。
所以本文想给大家讲一下主题色的配置应用,其中antd组件库
给我们提供了十二种自然主题色板,在美感和视觉上感觉非常的舒适自然,可以参考来使用。
我们以antd组件库
的色彩体系中的火山主题为例来讲解下面的内容。(本文会涉及到 sass
的语法)
其中提供的主题色每一种都有从浅至深有 10
个颜色,一般以第 6
种为主题的主色,其中的一些场景也给出了我们对应的颜色级别。

以图为例,告诉我们常用的场景对应的颜色深浅级别
- selected 选中:颜色值为
1
- hover 悬浮:颜色值为
5
- click 点击:颜色值为
7
主题色场景
以上述场景为例我们来实践一下,先列出10种颜色,然后写入在 css
变量中
$color-valcano: (
'valcano-1': #fff2e8,
'valcano-2': #ffd8bf,
'valcano-3': #ffbb96,
'valcano-4': #ff9c6e,
'valcano-5': #ff7a45,
'valcano-6': #fa541c,
'valcano-7': #d4380d,
'valcano-8': #ad2102,
'valcano-9': #871400,
'valcano-10': #610b00
);
:root{
@each $attribute, $value in $color-valcano {
#{'--color-#{$attribute}'}: $value
}
}
对于以上的场景,我们只需要应用对应的 css
变量即可
最终的变量如下
:root {
--color-valcano-1: #fff2e8;
--color-valcano-2: #ffd8bf;
--color-valcano-3: #ffbb96;
--color-valcano-4: #ff9c6e;
--color-valcano-5: #ff7a45;
--color-valcano-6: #fa541c;
--color-valcano-7: #d4380d;
--color-valcano-8: #ad2102;
--color-valcano-9: #871400;
--color-valcano-10: #610b00;
}
示例如下
通过变量后缀带数字这种非常难记住对应的场景值,不利于开发,我们可以再优化一下,把对应的场景细化出来,存储对应的颜色级别。
$scence-color-level: (
'primary': 6,
'selected': 1,
'hover': 5,
'border': 5,
'click': 7
);
:root{
@each $attribute, $value in $scence-color-level {
#{'--color-#{$attribute}'}: map-get($color-valcano, #{'valcano-#{$value}'})
}
}
我们来看看转换之后的变量
:root {
--color-primary: #fa541c;
--color-selected: #fff2e8;
--color-hover: #ff7a45;
--color-border: #ff7a45;
--color-click: #d4380d;
}
这样遇到对应的变量我们就可以不用关心颜色的深浅级别,只需要找对应场景,例如 selected
场景只需要使用变量 var(--color-selected)
就可以了。
当我们想切换其他主题的时候,难道要全部重写一遍,手动变更吗?
我们再来优化一下,将主题的变量变成动态的
$theme: 'valcano';
$theme-color: (
'valcano': $color-valcano,
'lime': $color-lime,
'cyan': $color-cyan,
'purple': $color-purple
);
:root{
@each $attribute, $value in $scence-color-level {
#{'--color-#{$attribute}'}: map-get(map-get($theme-color,$theme), #{'#{$theme}-#{$value}'})
}
}
以代码为例 引入了四种主题valcano
lime
cyan
purple
,若要切换主题,只需要更改变量$theme
即可
可以在代码片段中的 style
中手动更改$theme
变量值,然后运行查看效果
element组件库主题切换
了解完原理并实践之后,我们来看看 element组件库 的切换主题的原理是怎样的?
这是element的主题变量
$colors: () !default;
$colors: map.deep-merge(
(
'white': #ffffff,
'black': #000000,
'primary': (
'base': #409eff,
),
'success': (
'base': #67c23a,
),
'warning': (
'base': #e6a23c,
),
'danger': (
'base': #f56c6c,
),
'error': (
'base': #f56c6c,
),
'info': (
'base': #909399,
),
),
$colors
);
官网提供的覆盖方法
// styles/element/index.scss /* 只需要重写你需要的即可 */
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: ( 'primary': ( 'base': green, ), ),
);
// 如果只是按需导入,则可以忽略以下内容。
// 如果你想导入所有样式:
// @use "element-plus/theme-chalk/src/index.scss" as *;
官网定义的主题变量是通过 map.deep-merge
来实现主题映射合并。
map.deep-merge
的作用是:用于深度合并两个或多个映射(maps)。它可以在不丢失嵌套映射的情况下合并映射,这对于处理复杂的配置数据结构非常有用。
所以其实就是通过新的配置去合并覆盖它,有点类似 Object.assign()
这种变量对象覆盖的感觉。
其中element组件库也是通过sass函数自动生成需要用到的 css 变量来重构整一个样式系统。
为什么最后都转变成css变量?
- 兼容性:因为CSS 变量是一个非常有用的功能,几乎所有浏览器都支持。
- 动态性:每个组件都是有对应的css变量,想要改变颜色,只需要动态地改变组件内的个别变量即可。
- 多样性:也可以通过js来控制css变量
来源:juejin.cn/post/7398340132161994793
内网开发提效指南
❝
工欲善其事必先利其器,使用过内网开发的小伙伴都知道,CV大法在内网基本就废了,查资料也是非常的不便。对于一名程序员来说,如果把搜索引擎和CV键给他ban了,遇到问题后那基本是寸步难行。今天给大家介绍几种帮助内网开发提效的方法,希望能够帮助到大家。
一、文档站点内网部署
可以把项目中所用技术和框架的文档部署到公司内网中。
以elementPlus为例:
1、访问gh-pages分支https://github.com/element-plus/element-plus/tree/gh-pages
,下载文档站源码。
2、将文档站部署到内网服务器(以nginx为例)。
server {
listen 9800;
server_name localhost;
location / {
root html/element-plus-gh-pages;
index index.html index.htm;
try_files $uri $uri/ /element-plus-gh-pages/index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
部署后的访问速度也是非常快的
使用这种方式,随着部署的站点增多,后续框架、文档更新的时候,维护起来相对是比较麻烦的。且只能查看文档,遇到问题需要求助度娘还是不太方便。
下面介绍两种物理外挂,可以直接访问外网。
二、USB跨屏穿越器数据线
个人感觉此方案的体验是最好的,缺点是需要两台电脑,并且需要花钱买一根线,价格在80-200之间。
购买
某宝、某鱼都有销售,我是在某宝85块买的。
使用
连接两台电脑的USB端口即可,会自动安装驱动,那根线实际上就相当于是一个文件中转器,可以实现剪切板、文件的互传。使用体验就跟一台电脑连接了两台显示器一样。如下图所示:
三、手机投屏
本文重点介绍此方案,因为可以白嫖且不需要第二台电脑。一部安卓手机+数据线即可,缺点是文件传输不太方便。它就是一个开源投屏项目scrcpy
。可以看到,此项目在github上拥有高达102k的star数量。
✨亮点
- 亮度 (原生,仅显示设备屏幕)
- 表演 (30~60fps)
- 质量 (1920×1080或以上)
- 低延迟 (70~100ms)
- 启动时间短 (显示第一张图像约1秒)
- 非侵入性 (设备上没有安装任何东西)
- 不需要 ROOT
- 有线无线都可连接
- 可以随便调整界面和码率
- 画面随意裁剪,自带录屏(手游直播利器)
- 支持多设备同时投屏
- 利用电脑的键盘和鼠标可以控制手机
- 把 APK 文件拖拽到电脑窗口即可安装应用到手机,把普通文件拖拽到窗口即可复制到手机
- 手机电脑共享剪贴板
- 自动检测USB连接的设备
- 可直接添加设备的局域网IP,达到无线控制的效果
- 将自动保存连接过的IP地址,下次输入时,自动提醒
- 支持设备别名
- 支持中英两种语言
- Tray menu
- 等等等...
安装
根据不同系统直接去release页面下载对应版本即可:github.com/Genymobile/…
使用
下载解压完,进入软件目录,点击下图按钮打开命令行界面,输入启动命令即可。
命令行输入scrcpy,按回车, 猿神,起洞!
启动之后,即可使用鼠标操作手机,非常的丝滑
1、手机复制文本到电脑
2、电脑复制文本到手机
可以看到,使用投屏的方式,也可以实现CV大法。并且可以使用手机端的外网搜索资料、解决问题等。以下是该项目的快捷键,熟练使用,即可达到人机合一的地步。
快捷键
操作 | 快捷键 | 快捷键 (macOS) | ||
---|---|---|---|---|
切换全屏模式 | Ctrl +f | Cmd +f | ||
将窗口调整为 1:1 | Ctrl +g | Cmd +g | ||
调整窗口大小以删除黑色边框 | Ctrl +x | 双击黑色背景 | Cmd +x | 双击黑色背景 |
设备HOME 键 | Ctrl +h | 鼠标中键 | Ctrl +h | 鼠标中键 |
设备BACK 键 | Ctrl +b | 鼠标右键 | Cmd +b | 鼠标右键 |
设备任务管理 键 | Ctrl +s | Cmd +s | ||
设备菜单 键 | Ctrl +m | Ctrl +m | ||
设备音量+ 键 | Ctrl +↑ | Cmd +↑ | ||
设备音量- 键 | Ctrl +↓ | Cmd +↓ | ||
设备电源 键 | Ctrl +p | Cmd +p | ||
点亮手机屏幕 | 鼠标右键 | 鼠标右键 | ||
关闭设备屏幕(保持镜像) | Ctrl +o | Cmd +o | ||
展开通知面板 | Ctrl +n | Cmd +n | ||
折叠通知面板 | Ctrl +Shift +n | Cmd +Shift +n | ||
将设备剪贴板中的内容复制到计算机 | Ctrl +c | Cmd +c | ||
将计算机剪贴板中的内容粘贴到设备 | Ctrl +v | Cmd +v | ||
将计算机剪贴板中的内容复制到设备 | Ctrl +Shift +v | Cmd +Shift +v | ||
安装APK | 将APK 文件拖入投屏 | 将APK 文件拖入投屏 | ||
传输文件到设备 | 将文件拖入投屏 | 将文件拖入投屏 | ||
启用/禁用FPS计数器(stdout) | Ctrl +i | Cmd +i |
使用小技巧
经过笔者几天的使用,总结出几个小技巧。
1、电脑键盘控制手机进行中文输入,必须使用正确的输入法组合。
手机端:讯飞输入法(搜狗输入法不支持)
电脑端:ENG(使用英文键盘)
2、手机熄屏状态下投屏。 在scrcpy命令后加上熄屏参数即可:scrcpy --turn-screen-off
这样就可以在手机熄屏的状态下,仍可以被电脑操作,达到节省电量和减轻发热的目的。
诸如此类的命令参数还有很多,执行scrcpy --help
就可查看详细的帮助文档。
衍生项目
因为开源的特性,scrcpy也衍生了一些相关项目,列举其中一些:
- QtScrcpy 使用qt重新实现的桌面端,并加强了对游戏的支持。
- scrcpy-gui 为scrcpy的命令行提供了gui界面。
- guiscrcpy 另一个scrcpy的gui界面。
- scrcpy-docker docker版本的scrcpy。
- scrcpy-go go语言版本的scrcpy,增强对游戏的支持。
总结
第二第三种方法虽然建立了内网开发电脑和外网设备的联系,但是是不会被公司的安全系统检测到一机双网的,因为其本质就类似于设计模式中的发布订阅模式,用数据线充当了中间人,两台设备之间方便传输数据了而已,不会涉及到联网。
内网开发的痛点,无非就是复制粘贴、文件传输不便,只要打通这个链路,就能解决此问题。以上三个方法,笔者在实际工作中都用到了,确实极大的提高了工作效率。如果你也在饱受内网开发的折磨,不妨试试这几个方法。
来源:juejin.cn/post/7362464700879716403
UI: 为啥你这个页面边框1px看起来这么粗?
背景
最近在开发H5,ui稿给的border:1px solid,因为ui稿上的宽度是750px,我们运行的页面宽度是375px,所以我们需要把所以尺寸/2。所以我们可能会想写border:0.5px solid。但是实际上,我们看页面渲染,仍然是渲染1px而不是0.5
示例代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
}
.flex {
display: flex;
}
.item {
margin-right: 10px;
padding: 10px;
font-size: 13px;
line-height: 1;
background-color: rgba(242, 243, 245,1);
}
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
border: 0.5px solid ;
}
</style>
</head>
<body>
<div class="flex">
<!-- <div class="item active">
active
</div> -->
<div class="item">
item1
</div>
<div class="item">
item2
</div>
<div class="item">
item3
</div>
</div>
</body>
</html>
在没active的情况下
他们的内容都是占13px
在有active的情况下
active占了14px这个是没问题的,因为它font-size是14px嘛,但是我们是设置了border的宽度是0.5px,但展现的却是1px。
再来看看item
它内容占了16px,它受到相邻元素影响是14px+2px的上下边框
为啥border是1px呢
在 CSS 中,边框可以设置为 0.5px,但在某些情况下,尤其是低分辨率的屏幕上,浏览器可能会将其渲染为 1px 或根本不显示。这是因为某些浏览器和显示设备不支持小于 1px 的边框宽度或不能准确渲染出这样的细小边框。
浏览器渲染机制
- 不同浏览器对于小数像素的处理方式不同。一些浏览器可能会将
0.5px
边框四舍五入为1px
,以确保在所有设备上的一致性。
设备像素比
- 在高 DPI(如 Retina 显示器)设备上,
0.5px
边框可能看起来更清晰,因为这些设备可以渲染更细的边框。 - 在低 DPI 设备上,
0.5px
边框可能会被放大或者根本不会被显示。
解决办法
方法一:使用伪类和定位
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
position: relative;
}
.active::after {
content: "";
pointer-events: none;
display: block;
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
border: 1px #ff892e solid;
box-sizing: border-box;
width: 100%;
height: 100%;
}
另外的item的内容高度也是14px了符合要求
方法二:使用阴影,使用F12看的时候感觉还是有些问题
.active2 {
margin-left: 10px;
color: rgba(250, 100, 0, 1);
font-size: 14px;
position: relative;
box-shadow: 0 0 0 0.5px #ff892e;
}
方法三:使用svg,但这种自己设置了宽度。
<div class="active">
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="100" fill="none" stroke="#ff892e" stroke-width="0.5"></rect>
</svg>
active
</div>
方案四:使用svg加定位,也比较麻烦,而且有其他的问题
<div class="active">
<svg viewBox="0 0 100 100" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="100" fill="none" stroke="#ff892e" stroke-width="0.5"></rect>
</svg>
<div class="content">active</div>
</div>
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
position: relative;
display: inline-block;
}
.active svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
box-sizing: border-box;
}
.active .content {
position: relative;
z-index: 1;
}
方法五:使用一个父元素 比较麻烦
<div class="border-container">
<div class="active">active</div>
</div>
.border-container {
display: inline-block;
padding: 0.5px;
background-color: #ff892e;
}
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
background-color: white;
}
最后
在公司里,我们使用的都是方案一,这样active和item它们的内容高度都是14px了。然后我们再给他们的父盒子加上 align-items: center。这样active的高度是14px,其他都是13px了。但是active的高度会比其他item的盒子高1px,具体看个人需求是否添加吧。
来源:juejin.cn/post/7393656776539963407
基于英雄联盟人物的加载动画,奇怪的需求又增加了!
1、背景
前两天老板找到我说有一个需求,要求使用英雄联盟的人物动画制作一个加载中的组件,类似于下面这样:
我定眼一看:这个可以实现,但是需要UI妹子给切图。
老板:UI? 咱们啥时候招的UI !
我:老板,那不中呀,不切图弄不成呀。
老板:下个月绩效给你A。
我:那中,管管管。
2、调研
发动我聪明的秃头,实现这个需求有以下几种方案:
- 切动画帧,没有UI不中❎。
- 去lol客户端看看能不能搞到什么美术素材,3D模型啥的,可能行❓
- 问下 gpt4o,有没有哪个老表收集的有lol英雄的美术素材,如果有那就更得劲了✅。
经过我一番搜索,发现了这个网站:model-viewer,收集了很多英雄联盟的人物模型,模型里面还有各种动画,还给下载。老表,这个需求稳了50%了!
接下来有几种选择:
- 将模型动画转成动画帧,搞成雪碧图,较为麻烦,且动画不支持切换。
- 直接加载模型,将模型放在进度条上,较为简单,支持切换不同动画,而且可以自由过渡。就是模型文件有点大,初始化加载可能耗时较长。但是后续缓存一下就好了。
聪明的我肯定先选第二个方案呀,你糊弄我啊,我糊弄你。
3、实现
web中加载模型可以使用谷歌基于threejs
封装的 model-viewer
, 使用现代的 web component 技术。简单易用。
先初始化一个vue工程
npm create vue@latest
然后将里面的初始化的组件和app.vue里面的内容都删除。
安装model-viewer
依赖:
npm i three // 前置依赖
npm i @google/model-viewer
修改vite.config.js
,将model-viewer
视为自定义元素,不进行编译
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
// 添加以下内容
compilerOptions: {
isCustomElement: (tag) => ['model-viewer'].includes(tag)
}
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
assetsInclude: ['./src/assets/heros/*.glb']
})
新建 src/components/LolProgress.vue
<template>
<div class="progress-container">
<model-viewer
:src="hero.src"
disable-zoom
shadow-intensity="1"
:camera-orbit="hero.cameraOrbit"
class="model-viewer"
:style="heroPosition"
:animation-name="animationName"
:camera-target="hero.cameraTarget"
autoplay
ref="modelViewer"
></model-viewer>
<div
class="progress-bar"
:style="{ height: strokeWidth + 'px', borderRadius: strokeWidth / 2 + 'px' }"
>
<div class="progress-percent" :style="currentPercentStyle"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch, type PropType } from 'vue'
/** 类型 */
interface Hero {
src: string
cameraOrbit: string
progressAnimation: string
finishAnimation: string
finishAnimationIn: string
cameraTarget: string
finishDelay: number
}
type HeroName = 'yasuo' | 'yi'
type Heros = {
[key in HeroName]: Hero
}
const props = defineProps({
hero: {
type: String as PropType<HeroName>,
default: 'yasuo'
},
percentage: {
type: Number,
default: 100
},
strokeWidth: {
type: Number,
default: 10
},
heroSize: {
type: Number,
default: 150
}
})
const modelViewer = ref(null)
const heros: Heros = {
yasuo: {
src: '/src/components/yasuo.glb',
cameraOrbit: '-90deg 90deg',
progressAnimation: 'Run2',
finishAnimationIn: 'yasuo_skin02_dance_in',
finishAnimation: 'yasuo_skin02_dance_loop',
cameraTarget: 'auto auto 0m',
finishDelay: 2000
},
yi: {
src: '/src/components/yi.glb',
cameraOrbit: '-90deg 90deg',
progressAnimation: 'Run',
finishAnimationIn: 'Dance',
finishAnimation: 'Dance',
cameraTarget: 'auto auto 0m',
finishDelay: 500
}
}
const heroPosition = computed(() => {
const percentage = props.percentage > 100 ? 100 : props.percentage
return {
left: `calc(${percentage + '%'} - ${props.heroSize / 2}px)`,
bottom: -props.heroSize / 10 + 'px',
height: props.heroSize + 'px',
width: props.heroSize + 'px'
}
})
const currentPercentStyle = computed(() => {
const percentage = props.percentage > 100 ? 100 : props.percentage
return { borderRadius: `calc(${props.strokeWidth / 2}px - 1px)`, width: percentage + '%' }
})
const hero = computed(() => {
return heros[props.hero]
})
const animationName = ref('')
watch(
() => props.percentage,
(percentage) => {
if (percentage < 100) {
animationName.value = hero.value.progressAnimation
} else if (percentage === 100) {
animationName.value = hero.value.finishAnimationIn
setTimeout(() => {
animationName.value = hero.value.finishAnimation
}, hero.value.finishDelay)
}
}
)
onMounted(() => {
setTimeout(() => {
console.log(modelViewer.value.availableAnimations)
}, 2000)
})
</script>
<style scoped>
.progress-container {
position: relative;
width: 100%;
}
.model-viewer {
position: relative;
background: transparent;
}
.progress-bar {
border: 1px solid #fff;
background-color: #666;
width: 100%;
}
.progress-percent {
background-color: aqua;
height: 100%;
transition: width 100ms ease;
}
</style>
组件非常简单,核心逻辑如下:
- 根据传入的英雄名称加载模型
- 指定每个英雄的加载中的动画,
- 加载100%,切换完成动作进入动画和完成动画即可。
- 额外的细节处理。
最后修改
app.vue
:
<script setup lang="ts">
import { ref } from 'vue'
import LolProgress from './components/LolProgress.vue'
const percentage = ref(0)
setInterval(() => {
percentage.value = percentage.value + 1
}, 100)
</script>
<template>
<main>
<LolProgress
:style="{ width: '200px' }"
:percentage="percentage"
:heroSize="200"
hero="yasuo"
/>
</main>
</template>
<style scoped></style>
这不就完成了吗,先拿给老板看看。
老板:换个女枪的看看。
我:好嘞。
老板:弄类不赖啊小伙,换个俄洛伊的看看。
4、总结
通过本次需求,了解到了 model-viewer
组件。
老板招个UI妹子吧。
在线体验:github-pages
来源:juejin.cn/post/7377217883305279526
做了这么久前端还不会手写瀑布流?(H5 & 小程序)
前言
做了7年前端我一直不知道瀑布流是什么(怪设计师不争气啊,哈哈哈),我一直以为就是个普通列表,几行css解决的那种。
当然瀑布流确实有css解决方案,但是这个方案对于分页列表来说完全不能用,第二页内容一出来位置都变了。
我看了一下掘金的一些文章,好长啊,觉得还是自己想一下怎么写吧。就自己实现了一遍。希望思路给大家一点帮助。
分析瀑布流

以小红书的瀑布流为例,相同宽度不同高度的卡片堆叠在一起形成瀑布流。
这里有两个难点:
- 卡片高度如何确定?
- 堆叠布局如何实现?
卡片的高度 = padding + imageHeight + textHeight....
不固定的内容包括:图片高度、标题行数
也就是说当我们解决了图片和标题的高度问题,那么瀑布流的第一个问题就解决了。(感觉已经写好代码了一样)
堆叠问题——因为css没有这样的布局方式,所以肯定得用js实现。最简单的解决方案就是对每一个盒子进行绝对定位。
这个问题就转换成计算现有盒子的定位问题。
从问题到代码
第一个问题——图片高度
无论是企业业务场景还是个人开发,通过后端返回图片的width、height都是合理且轻松的。
前端去获取图片信息,无疑让最重要的用户体验变得糟糕。前端获取图片信息并不困难,但是完全没有必要。
所以我直接考虑后端返回图片信息的情况。
const realImageHeight = imageWidth / imageHeight * cardContentWidth;
图片高度轻松解决,无平台差异
第二个问题——文字高度
从小红书可以看出,标题有些两行有些一行,也有些三行。
如果你固定一行,这个问题完全可以跳过。
- 方案一:我们可以用字数和宽度来计算可能得行数
优势:速度快,多平台复用
劣势:不准确(标题包括英文中文) - 方案二:我们可以先渲染出来再获取行数
优势:准确
劣势:相对而言慢,不同平台方法不同
准确是最重要的!选择方案二
其实方案二也有两种方案,一种是用canvas模拟,这样可以最大限度摆脱平台(h5、小程序)的限制,
然而我试验后,canvas还没找到准确的计算的方法(待后续更新)
第二种就是用div渲染一遍,获取行数或者高度。
创建一个带有指定样式的 div 元素
function createDiv(style: string): HTMLDivElement {
const div = document.createElement('div');
div.style.cssText = style;
document.body.appendChild(div);
return div;
}
计算文本数组在指定字体大小和容器宽度下的行数
/**
* 计算文本数组在指定字体大小和容器宽度下的行数
* @param texts - 要渲染的文本数组
* @param fontSize - 字体大小(以像素为单位)
* @param lineHeight - 字体高度(以像素为单位)
* @param containerWidth - 容器宽度(以像素为单位)
* @param maxLine - 最大行数(以像素为单位)
* @returns 每个文本实际渲染时的行数数组
*/
export function calculateTextLines(
texts: string[],
fontSize: number,
lineHeight: number,
containerWidth: number,
maxLine?: number
): number[] {
// 创建一个带有指定样式的 div 元素
const div = createDiv(`font-size: ${fontSize}px; line-height: ${lineHeight}px; width: ${containerWidth}px; white-space: pre-wrap;`);
const results: number[] = [];
texts.forEach((text) => {
div.textContent = text;
// 获取 div 的高度,并根据字体大小计算行数
const divHeight = div.offsetHeight;
const lines = Math.ceil(divHeight / lineHeight);
maxLine && lines > maxLine ? results.push(maxLine) : results.push(lines);
});
// 清理 div
removeElement(div);
return results;
}
这个问题小程序如何解决放在文末
第三个问题——每个卡片的定位问题
解决了上面的问题,就解决了盒子高度的问题,这个问题完全就是相同宽度不同高度盒子的堆放问题了
问题的完整描述是这样的:
写一个ts函数实现将一堆小盒子,按一定规则顺序推入大盒子里
函数输入:小盒子高度列表
小盒子:不同小盒子高度不一致,宽度为stackWidth,彼此间隔gap
大盒子:高度无限制,宽度为width
堆放规则:优先放置高度低的位置,高度相同时优先放在左侧
返回结果:不同盒子的高度和位置信息
如果你有了这么清晰的描述,接下去的工作你只需要交给gpt来写你的函数
// 返回的盒子信息
export interface Box {
x: number;
y: number;
height: number;
}
// 盒子堆叠的方法类
export class BoxPacker {
// 返回的小盒子信息列表
private boxes: Box[] = [];
// 大盒子宽度
private width: number;
// 小盒子宽度
private stackWidth: number;
// 小盒子间隔
private gap: number;
constructor(width: number, stackWidth: number, gap: number) {
this.width = width;
this.stackWidth = stackWidth;
this.gap = gap;
this.boxes = [];
}
// 添加单个盒子
public addBox(height: number): Box[] {
return this.addBoxes([height]);
}
// 添加多个盒子(一般用这个方法)
public addBoxes(heights: number[], isReset?: boolean): Box[] {
isReset && (this.boxes = [])
console.log('this.boxes—————— ', JSON.stringify(this.boxes) )
for (const height of heights) {
const position = this.findBestPosition();
const newBox: Box = { x: position.x, y: position.y, height };
this.boxes.push(newBox);
}
return this.boxes;
}
// 查找定位函数
private findBestPosition(): { x: number; y: number } {
let bestX = 0;
let bestY = Number.MAX_VALUE;
for (let x = 0; x <= this.width - this.stackWidth; x += this.stackWidth + this.gap) {
const currentY = this.getMaxHeightInColumn(x, this.stackWidth);
if (currentY < bestY || (currentY === bestY && x < bestX)) {
bestX = x;
bestY = currentY;
}
}
return { x: bestX, y: bestY };
}
private getMaxHeightInColumn(startX: number, width: number): number {
return this.boxes
.filter(box => box.x >= startX && box.x < startX + width)
.reduce((maxHeight, box) => Math.max(maxHeight, box.y + box.height + this.gap), 0);
}
}
这样我们就实现了根据高度获取定位的功能了
来实现一波
核心的代码就是获取每个盒子的定位、宽高信息
// 实例
const boxPacker = useMemo(() => {
return new BoxPacker(width, stackWidth, gap)
}, []);
const getCurrentPosition = (currentData: DataItem[], reset?: boolean) => {
// 获取标题文本行数列表
const textLines = calculateTextLines(currentData.map(item => item.title),card.fontSize,card.lineHeight, cardContentWidth)
// 获取图片高度列表
const imageHeight = currentData.map(item => (item.imageHeight / item.imageWidth * cardContentWidth))
// 获取小盒子高度列表
const cardHeights = imageHeight.map((h, index) => (
h + textLines[index] * card.lineHeight + card.padding * 2 + (card?.otherHeight || 0)
)
);
// 获取盒子定位信息
const boxes = boxPacker.addBoxes(
cardHeights,
reset
)
// 返回盒子列表信息
return boxes.map((box, index) => ({
...box,
title: currentData[index]?.title,
url: currentData[index]?.url,
imageHeight: imageHeight[index],
}))
}
set获取到的盒子信息
const [boxPositions, setBoxPositions] = useState<(Box & Pick<DataItem, 'url' | 'title' | 'imageHeight'>)[]>([]);
useEffect(() => {
// 首次和刷新
if (page === 1) {
setBoxPositions(getCurrentPosition(data, true))
} else {
// 加载更多
setBoxPositions(getCurrentPosition(data.slice((page - 1) * pageSize, page * pageSize)))
}
}, [])
效果如下

小程序获取文本高度
从上面的分析可以看出来只有文本高度实现是不同的,如果canvas方案实验成功,说不定还能做到大一统。
目前没成功大家就先看看我的目前方案:先实际渲染文字然后读取信息,然后获取实际高度
import React, {useEffect, useMemo, useState} from 'react'
import { View } from '@tarojs/components'
import Taro from "@tarojs/taro";
import './index.less'
import {BoxPacker} from "./flow";
const data = [
'vwyi这是一个标题,这是一个标题,这是一个标题,这是一个标题',
'这是一个标题',
'这是一个标题,这是一个标题,这是一个标题,这是一个标题',
'这是一个标题',
'这是一个标题,这是一个标题,这是一个标题,一个标题',
'这是一个标题,这是一个标题,这是一个标题,这题',
'这是一个标题,这是一个标题,这是一',
'这是一个标题,这是一个标题,这是一',
];
function Index() {
const boxPacker = useMemo(() => new BoxPacker(320, 100, 5), []);
const [boxPositions, setBoxPositions] = useState<any[]>([])
function getTextHeights() {
return new Promise((resolve, reject) => {
Taro.createSelectorQuery()
.selectAll('#textContainer .text-item')
.boundingClientRect()
.exec(res => {
if (res && res[0]) {
const heights = res[0].map(item => item.height);
resolve(heights);
} else {
reject('No buttons found');
}
});
});
}
useEffect(() => {
getTextHeights().then(h => {
setBoxPositions(boxPacker.addBoxes(h))
})
}, [])
return (
<View className="flow-container">
<View id="textContainer">
{
data.map((item, index) => (<View key={index} className="text-item">{item}</View>))
}
</View>
<View className="text-box-container">
{boxPositions.map((position, index) => (
<View
key={index}
className="text-box"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
height: `${position.height}px`,
width: '100px', // 假设盒子的宽度固定为100px
}}
>
{`${data[index]}`}
</View>
))}
</View>
</View>
)
}
export default Index
来源:juejin.cn/post/7397278180644372521
面试官:假如有几十个请求,如何去控制并发?
面试官:看你简历上做过图片或文件批量下载,那么假如我一次性下载几十个,如何去控制并发请求的?
让我想想,额~, 选中ID,循环请求?,八嘎!肯定不是那么沙雕的做法,这样做服务器直接崩溃啦!突然灵光一现,请求池!!!
我:利用Promise模拟任务队列,从而实现请求池效果。
面试官:大佬!
废话不多说,正文开始:
众所周知,浏览器发起的请求最大并发数量一般都是6~8
个,这是因为浏览器会限制同一域名下的并发请求数量,以避免对服务器造成过大的压力。
首先让我们来模拟大量请求的场景
const ids = new Array(100).fill('')
console.time()
for (let i = 0; i < ids.length; i++) {
console.log(i)
}
console.timeEnd()
一次性并发上百个请求,要是配置低一点,又或者带宽不够的服务器,直接宕机都有可能,所以我们前端这边是需要控制的并发数量去为服务器排忧解难。
什么是队列?
先进先出就是队列,push
一个的同时就会有一个被shift
。我们看下面的动图可能就会更加的理解:
我们接下来的操作就是要模拟上图的队列行为。
定义请求池主函数函数
export const handQueue = (
reqs // 请求数量
) => {}
接受一个参数reqs
,它是一个数组,包含需要发送的请求。函数的主要目的是对这些请求进行队列管理,确保并发请求的数量不会超过设定的上限。
定义dequeue函数
const dequeue = () => {
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}
}
这个函数用于从请求池中取出请求并发送。它在一个循环中运行,直到当前并发请求数current
达到最大并发数concurrency
或请求池queue
为空。对于每个出队的请求,它首先增加current
的值,然后调用请求函数requestPromiseFactory
来发送请求。当请求完成(无论成功还是失败)后,它会减少current
的值并再次调用dequeue
,以便处理下一个请求。
定义返回请求入队函数
return (requestPromiseFactory) => {
queue.push(requestPromiseFactory) // 入队
dequeue()
}
函数返回一个函数,这个函数接受一个参数requestPromiseFactory
,表示一个返回Promise的请求工厂函数。这个返回的函数将请求工厂函数加入请求池queue
,并调用dequeue
来尝试发送新的请求,当然也可以自定义axios,利用Promise.all
统一处理返回后的结果。
实验
const enqueue = requestQueue(6) // 设置最大并发数
for (let i = 0; i < reqs.length; i++) { // 请求
enqueue(() => axios.get('/api/test' + i))
}
我们可以看到如上图所示,请求数确实被控制了,只有有请求响应成功的同时才会有新的请求进来,极大的降低里服务器的压力,后端的同学都只能喊6。
整合代码
import axios from 'axios'
export const handQueue = (
reqs // 请求总数
) => {
reqs = reqs || []
const requestQueue = (concurrency) => {
concurrency = concurrency || 6 // 最大并发数
const queue = [] // 请求池
let current = 0
const dequeue = () => {
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}
}
return (requestPromiseFactory) => {
queue.push(requestPromiseFactory) // 入队
dequeue()
}
}
const enqueue = requestQueue(6)
for (let i = 0; i < reqs.length; i++) {
enqueue(() => axios.get('/api/test' + i))
}
}
实战文章
之前写过一篇关于web-worker
大文件切片的案例文章,就是利用了此特性感兴趣的小伙伴可以看看web-worker的基本用法并进行大文件切片上传(附带简易node后端)
来源:juejin.cn/post/7356534347509645375
文件上传你会吧?那帮我做个文件下载功能
大家好,又是我,大聪明,立志做个早起吃草的马儿。话说上回解决完部署的问题(部署完了,样式不生效差点让我这个前端仔背锅),我又感觉回到了眼神清澈的大聪明状态,直到今天产品跟我说:“听说你是文件上传高手?做过大文件上传?切片?断点续传?”,听完我一脸戒备和紧张,“难道我面试吹的牛皮被他发现了,现在要捅破了?”我正在犹豫要不要跟他摊牌说,我面试掺了水的时候,他又来了一句,“那帮我做个 文件下载 的功能吧”,我突然放松下来了,原来是要加需求呀,害我白担心一场。作为CVT工程师,这点事根本难不倒我。
好了下面开始我的CV大法。
首先找到后端协调,他让我返回一个file_id
,该file_id是我在文件上传到服务器存储的时候,后端返回给我的,通过此file_id,来找到对应的文件,很好很简单。
接着,看后端提供的文件下载接口,咱就是说,经历的少,不知道对不对,后端直接就是返回文件的字节流(bytes),除此之外没有任何信息,没有文件名,没有文件类型,去问了一下说就是这样的,咱也不敢多问
天无绝人之路,还好我在前端获取文件的时候能找到总的文件列表,通过遍历出来也能拿到文件的信息
下面是判断文件类型方法
getFileTypeMime (key) {
let mimeType = ''
switch (key) {
case 'png':
mimeType = 'image/png'
break
case 'jpeg':
mimeType = 'image/jpeg'
break
case 'pdf':
mimeType = 'application/pdf'
break
case 'xlsx':
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
break
case 'docx':
mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
break
default:
mimeType = 'text/plain'
break
}
return mimeType
},
下面是文件下载的方法 (错误的)
downLoad (value, key) {
const type = key.split('.')[1] // 文件类型
const name = key.split('.')[0] // 文件名
axiosBase({
url: `/ass/download?alert_id=${this.alert_id}&file_id=${value}`,
method: 'get',
}).then(res => {
const blob = new Blob([res], { type: this.getFileTypeMime(type) }) // 文件类型
const link = document.createElement('a')
const href = window.URL.createObjectURL(blob) // 创建下载的链接
link.href = href
link.download = name // 下载后文件名
document.body.appendChild(link)
link.click() // 点击下载
document.body.removeChild(link) // 下载完成移除元素
window.URL.revokeObjectURL(href) // 释放掉blob对象
}).catch((err) => { console.log(err) })
}
为什么是错误的呢?点了效果也确实是实现了 文件的下载,但是打开,然后格式错误了??
又是百思不得其解的问题,直接打开度娘,搜索又找到了这篇(神文)解决
downLoad (value, key) {
const type = key.split('.')[1] // 文件类型
const name = key.split('.')[0] // 文件名
axiosBase({
url: `/download?alert_id=${this.alert_id}&file_id=${value}`,
method: 'get',
responseType: 'blob'
}).then(res => {
const blob = new Blob([res], { type: this.getFileTypeMime(type) }) // 文件类型
const link = document.createElement('a')
const href = window.URL.createObjectURL(blob) // 创建下载的链接
link.href = href
link.download = name // 下载后文件名
document.body.appendChild(link)
link.click() // 点击下载
document.body.removeChild(link) // 下载完成移除元素
window.URL.revokeObjectURL(href) // 释放掉blob对象
}).catch((err) => { console.log(err) })
}
又是有惊无险的一天
来源:juejin.cn/post/7389913027654434857
部署完了,样式不生效差点让我这个前端仔背锅
大家好,作为今年刚毕业的大聪明(小🐂🐎),知道今年不好就业,费心费力的面试终于进了个小公司,然后我的奇妙之旅开始了。
叮!闹钟响了!牙都不刷了,直接起床出门去上班,身为🐂🐎就要有早起吃草干活的觉悟,所以我是个很合格的食草动物。只不过,下面的信息,让我一天当动物的好心情都没了。
部署?因为该公司人数比较少,也没有自动化部署操作,只能让前端build一下dist包然后丢给后端,让后端部署,然后该项目也比较急,我刚来,也不好意思@后端,恰巧跟我对接的后端也是刚来不熟悉,所以我就跟负责人继续扯皮拖延时间了。(真是不好意思,假的)↓↓↓
终于经过不断努力(后端),部署好了,但是,我的样式乱了,为什么呢?看了一下网络和源代码,样式文件都请求到了,问了后端,一致说是我前端的问题,我懵了,然后快马加鞭查找问题
解决
ok,那我先看打包的dist文件有样式有问题吗,使用npm安装个 http-server 运行打包后的index.html,运行打开运行后的地址,欧克样式没乱,文件引用也没问题,那到底是什么鬼影响的呢?
在我百思不得其解的时候,我打开了神器--csdn,搜索打包dist部署后样式文件不生效,就遇到了这个神文t.csdnimg.cn/WbQyq
欧克,按照博主说的Nginx没有配置这两个东西而导致的,我有点不确定是不是,有点犹犹豫豫的找到后端说帮我在Nginx配置文件的http加下这两个配置,然后样式就好了,完美!后端由于疏忽,没加上就把问题推给我,结果被我扳回一城。
include mime.types;
default_type application/octet-stream;
- include mime.types;
- 这个指令告诉Nginx去包含一个文件,这个文件通常包含了定义了MIME类型(Multipurpose Internet Mail Extensions)的配置。MIME类型指定了文件的内容类型,例如文本文件、图像、视频等。通过包含
mime.types
文件,Nginx可以识别不同类型的文件并正确地处理它们。 - 示例:假设
mime.types
文件中定义了.html
文件为text/html
类型,Nginx在处理请求时会根据这个定义设置正确的HTTP响应头。
- 这个指令告诉Nginx去包含一个文件,这个文件通常包含了定义了MIME类型(Multipurpose Internet Mail Extensions)的配置。MIME类型指定了文件的内容类型,例如文本文件、图像、视频等。通过包含
- default_type application/octet-stream;
- 这个指令设置了默认的MIME类型。如果Nginx无法根据文件扩展名或其他方式确定响应的MIME类型时,就会使用这个默认类型。
application/octet-stream
是一个通用的MIME类型,表示未知的二进制数据。当服务器无法识别文件类型时,会默认使用这个类型。例如,如果请求的文件没有合适的MIME类型或没有被mime.types
文件中列出,Nginx就会返回application/octet-stream
类型。- 这种设置对于确保未知文件类型的安全传输很有用,因为浏览器通常会下载这些文件而不是尝试在浏览器中打开它们。
总之,添加 include mime.types;
和 default_type application/octet-stream;
配置后,Nginx能够正确地识别和处理CSS文件的MIME类型,从而确保浏览器能够正确加载和应用CSS样式。
所以,前端仔不能只当前端仔,还是要好好学点服务端的知识,不然锅给你,你还认为这是你该背的锅。
以上是开玩笑的描述,只是为了吸引增加阅读量
来源:juejin.cn/post/7388696625689051170
前端更新部署后通知用户刷新
前言
周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。
现在大部分的前端系统都是SPA,用户在使用中对系统更新无感知,切换菜单等并不能获取最新资源,如果前端是覆盖性部署,切换菜单请求旧资源,这个旧资源已经被覆盖(hash打包的文件),还会出现一直无响应的情况。
那么,当前端部署更新后,提示一直停留在系统中的用户刷新系统很有必要。
解决方案
- 在public文件夹下加入manifest.json文件,记录版本信息
- 前端打包的时候向manifest.json写入当前时间戳信息
- 在入口JS引入检查更新的逻辑,有更新则提示更新
- 路由守卫router.beforeResolve(Vue-Router为例),检查更新,对比manifest.json文件的响应头Etag判断是否有更新
- 通过Worker轮询,检查更新,对比manifest.json文件的响应头Etag判断是否有更新。当然你如果不在乎这点点开销,可不使用Worker另开一个线程
Public下的加入manifest.json文件
{
"timestamp":1706518420707,
"msg":"更新内容如下:\n--1.添加系统更新提示机制"
}
这里如果是不向用户提示更新内容,可不填,前段开发者也无需维护manifest.json的msg内容,这里主要考虑到如果用户在填长表单的时候,填了一大半,你这时候给用户弹个更新提示,用户无法判断是否影响当前表单填写提交,如果将更新信息展示出来,用户感知更新内容,可判断是否需要立即刷新,还是提交完表单再刷新。
webpack向manifest.json写入当前时间戳信息
// 版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
// 读取文件内容
readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件时出错:', err)
return
}
// 将文件内容转换JSON
const dataObj = JSON.parse(data)
dataObj.timestamp = new Date().getTime()
// 将修改后的内容写回文件
writeFile(filePath, JSON.stringify(dataObj), 'utf8', err => {
if (err) {
console.error('写入文件时出错:', err)
return
}
})
})
如果你无需维护更新内容的话,可直接写入timestamp
// 生成版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
writeFileSync(filePath, `${JSON.stringify({ timestamp: new Date().getTime() })}`)
检查更新的逻辑
入口文件main.js处引入
我这里检查更新的文件是放在utils/checkUpdate
// 检查版本更新
import '@/utils/checkUpdate'
checkUpdate文件内容如下
import router from '@/router'
import { Modal } from 'ant-design-vue'
if (process.env.NODE_ENV === 'production') {
let lastEtag = ''
let hasUpdate = false
let worker = null
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'head'
})
// 获取最新的etag
let etag = response.headers.get('etag')
hasUpdate = lastEtag && etag !== lastEtag
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}
async function confirmReload(msg = '', lastEtag) {
worker &&
worker.postMessage({
type: 'pause'
})
try {
Modal.confirm({
title: '温馨提示',
content: '系统后台有更新,请点击“立即刷新”刷新页面\n' + msg,
okText: '立即刷新',
cancelText: '5分钟后提示我',
onOk() {
worker.postMessage({
type: 'destroy'
})
location.reload()
},
onCancel() {
worker &&
worker.postMessage({
type: 'recheck',
lastEtag: lastEtag
})
}
})
} catch (e) {}
}
// 路由拦截
router.beforeEach(async (to, from, next) => {
next()
try {
await checkUpdate()
if (hasUpdate) {
worker.postMessage({
type: 'destroy'
})
location.reload()
}
} catch (e) {}
})
// 利用worker轮询
worker = new Worker(
/* webpackChunkName: "checkUpdate.worker" */ new URL('../worker/checkUpdate.worker.js', import.meta.url)
)
worker.postMessage({
type: 'check'
})
worker.onmessage = ({ data }) => {
if (data.type === 'hasUpdate') {
hasUpdate = true
confirmReload(data.msg, data.lastEtag)
}
}
}
这里因为缺换路由本来就要刷新页面,用户可无需感知系统更新信息,直接通过请求头的Etag即可,这里的Fetch方法就用head获取相应头就好了。
checkUpdate.worker.js文件如下
let lastEtag
let hasUpdate = false
let intervalId = ''
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'get'
})
// 获取最新的etag和data
let etag = response.headers.get('etag')
let data = await response.json()
hasUpdate = lastEtag !== undefined && etag !== lastEtag
if (hasUpdate) {
postMessage({
type: 'hasUpdate',
msg: data.msg,
lastEtag: lastEtag,
etag: etag
})
}
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}
// 监听主线程发送过来的数据
addEventListener('message', ({ data }) => {
if (data.type === 'check') {
// 每5分钟执行一次
// 立即执行一次,获取最新的etag,避免在setInterval等待中系统更新,第一次获取的etag是新的,但是lastEtag还是undefined,不满足条件,错失刷新时机
checkUpdate()
intervalId = setInterval(checkUpdate,5 * 60 * 1000)
}
if (data.type === 'recheck') {
// 每5分钟执行一次
hasUpdate = false
lastEtag = data.lastEtag
intervalId = setInterval(checkUpdate, 5 * 60 * 1000)
}
if (data.type === 'pause') {
clearInterval(intervalId)
}
if (data.type === 'destroy') {
clearInterval(intervalId)
close()
}
})
如果不使用worker直接讲轮询逻辑放在checkUpdate即可
Worker引入
从 webpack 5 开始,你可以使用 Web Workers 代替 worker-loader
。
new Worker(new URL('./worker.js', import.meta.url));
以下版本的就只能用worker-loader
咯
也可以逻辑写成字符串,然后通过ToURL给new Worker,如下:
function createWorker(f) {
const blob = new Blob(['(' + f.toString() +')()'], {type: "application/javascript"});
const blobUrl = window.URL.createObjectURL(blob);
const worker = new Worker(blobUrl);
return worker;
}
createWorker(function () {
self.addEventListener('message', function (event) {
// 消费信息
self.postMessage('send message')
}, false);
})
worker数据通信
// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
var uInt8Array = e.data;
postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};
但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。
如果要直接转移数据的控制权,就要使用下面的写法。
// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);
// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);
Web Worker 使用教程 - 阮一峰的网络日志 (ruanyifeng.com)
然而,并不是所有的对象都可以被转移。只有那些被设计为可转移的对象(用[ Transferable ] IDL 扩展属性修饰),比如ArrayBuffer、MessagePort,ImageBitmap,OffscreenCanvas,才能通过这种方式来传递。转移操作是不可逆的,一旦对象被转移,原始上下文中的引用将不再有效。转移对象可以显著减少复制数据所需的时间和内存。
---------------------------------更新-------------------------------------
如果只考虑是否更新,无需告知内容,无需创建manifest.json,直接通过index.html的Etag判断即可,因为打包工具会自动在filename添加hash,如果有内容修改,入口文件的js引入资源文件hash会改变,通过Etag判断即可。
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/?v=${Date.now()}`, {
method: 'head',
cache: 'no-cache'
})
// 获取最新的etag
let etag = response.headers.get('etag')
hasUpdate = lastEtag && etag !== lastEtag
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}
来源:juejin.cn/post/7329280514628534313
🎲选择困难症的福音-基于threejs+cannonjs的扔骰子小游戏
在一个美好的周末,闲来无事,约上朋友一起在家打麻将,奈何尘封已久的麻将包里翻来翻去也没找到骰子的踪影,于是想在网上找一个骰子模拟器来代替,找了半天都没有发现一款合适好用的软件,于是心血来潮,打算自己做一个🎲模拟器。
1.制定需求设计以及技术方案
1.骰子模型
- 用户可以选择多种骰子模型,如六面 15面 20面等不同风格的供选择
- 并且可以选择要投掷骰子的数量 暂定1-10
2.分数计算规则
- 每次投掷完后会自动计算总点数并显示在屏幕上
- 可以保存历史摇骰子记录 最多支持历史100条记录
- 可以自定义用户参与摇骰子,投掷完成后会显示:luke摇出了xx点。。并且保存在历史记录中
- 支持多人参与摇骰子比赛:比如先加入4名玩家,开始后会依次提示轮到哪位玩家来开始投掷,并且可以选择每局大家需要投掷的次数,最后会统计总点数以及排名。
3.动画特效
- 每次投掷时对骰子随机一个角度以及投掷方向,如果同时投掷多个骰子则随机每一个骰子的角度,给骰子施加一个向赌盘中心的力,让骰子随机落在赌盘中部,在赌盘周围增加一道空气墙来阻止骰子移动到牌桌外。
- 模拟不同骰子的落地音效
4.技术实现
- 利用threejs来实现webgl相关渲染。如骰子模型、场景渲染、相机、棋盘等。
- 利用cannosjs来模拟物理引擎。如骰子碰撞检测、抛出坠落动画、重力加速等物理效果。
2.效果展示
3.开始实现
准备3d骰子模型和棋盘素材
首先是找到合适的gltf模型,这里我们在sketchfab上找到了一款质感很真实的骰子模型。下载下来的模型可以通过gltf-viewer来查看模型效果。
棋盘的话其实是一张图片平铺起来的,这里我用了一张木地板图片。
引入资源以及初始化webgl场景
import * as CANNON from "cannon-es";
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
//创建物理世界对象
const world = new CANNON.World();
// 设置物理世界重力加速度
world.gravity.set(0, -100, 0); //重力加速度: 单位:m/s²
创建物理模型、地面以及网格模型地面
这里简单叙述一下物理世界和webgl世界的联系以及如何在webgl场景里模拟出真实的物理效果。
- 首先在three创建的webgl场景是无法直接创建并感知到物理世界的,threejs只负责实时渲染物理的状态并展示在画布上。而cannos恰好相反,它不负责渲染,只负责创建一个物理世界以及具备物理引擎的物体,并根据物体状态实时计算物体的位置、角度等。并把这些信息实时同步给webgl场景中的模型,把模型渲染到页面上实现物理世界的可视化。
- 所以我们创建骰子、地面等模型都需要创建两份。一份在webgl中创建,一份在物理世界中创建,并且保持同样的尺寸。
//创建骰子网格模型(gltf模型)
const loader = new GLTFLoader();
const gltf = await loader.loadAsync('./assets/model/dice_model.glb');
const meshModel = gltf.scene;//获取箱子网格模型
meshModel.position.set(50,30,50);
meshModel.scale.set(5,5,5);
scene.add(meshModel);
//包围盒计算
const box3 = new THREE.Box3();
box3.expandByObject(meshModel);//计算模型包围盒
const size = new THREE.Vector3();
box3.getSize(size);//包围盒计算箱子的尺寸
// 创建骰子物理模型
const sphereMaterial = new CANNON.Material()//碰撞体材质
// 物理正方体
const bodyModel = new CANNON.Body({
mass: 0.3, // 碰撞体质量0.3kg
position: new CANNON.Vec3(50,30,50), // 位置
shape: new CANNON.Box(new CANNON.Vec3(size.x/2, size.y/2, size.z/2)),
material: sphereMaterial
});
// 物理地面
const groundMaterial = new CANNON.Material()
const groundBody = new CANNON.Body({
mass: 0, // 质量为0,始终保持静止,不会受到力碰撞或加速度影响
shape: new CANNON.Plane(),
material: groundMaterial,
});
// 改变平面默认的方向,法线默认沿着z轴,旋转到平面向上朝着y方向
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);//旋转规律类似threejs 平面
world.addBody(groundBody)
//设置物理世界参数
const contactMaterial = new CANNON.ContactMaterial(groundMaterial, sphereMaterial, {
restitution: 0.5, //反弹恢复系数
})
// 把关联的材质添加到物理世界中
world.addContactMaterial(contactMaterial)
// 网格地面
const planeGeometry = new THREE.PlaneGeometry(1000, 1000);
const texture = new THREE.TextureLoader().load('./assets/textures/hardwood2_diffuse.jpg');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(10, 10);
const planeMaterial = new THREE.MeshLambertMaterial({
// color:0x777777,
map: texture,
});
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial);
planeMesh.rotateX(-Math.PI / 2);
scene.add(planeMesh);
创建好模型后,我们在创建光源,相机等,这里就不再赘述。接下来我们开始设计物理世界的骰子抛出后坠落效果,并将物理世界和webgl渲染同步。
//点击屏幕后,设置物理骰子的角度和速度,物理会向上抛出并随着重力下落,触碰到地面后则会发生碰撞反弹
renderer.domElement.addEventListener('click', function (event) {
start_throw = true;
clearPoints();
const randomEuler = Math.random()*3;
bodyModel.quaternion.setFromEuler(Math.PI / randomEuler, Math.PI / randomEuler, Math.PI / randomEuler);
bodyModel.position.set(0,50,0);//点击按钮,body回到下落的初始位置
// 为物体设置初始速度(用以产生抛物线效果)
bodyModel.velocity.set(option.x,option.y,option.z); // x, y, z 方向上的速度
// 选中模型的第一个模型,开始下落
world.addBody(bodyModel);
})
function render() {
world.step(1/60);//更新物理计算
meshModel.position.copy(bodyModel.position); //渲染循环中,同步物理球body与网格球mesh的位置
meshModel.quaternion.copy(bodyModel.quaternion); //同步姿态角度
locateView(); //相机跟随物体移动
requestAnimationFrame(render);
renderer.render(scene, camera);
if (isBodyStopped(bodyModel)&&start_throw) {
showPoints(); //停止运动后,显示点数
start_throw = false;
}
}
render();//根据帧数渲染
接下来,我们再添加骰子点数计算相关逻辑。
//获取朝上面的点数
function getUpperFace(mesh) {
// 定义每个面的局部法线向量(手动定义)
const localNormals = [
new THREE.Vector3(0, 1, 0), // 面1
new THREE.Vector3(0, 0, -1), // 面2
new THREE.Vector3(-1, 0, 0), // 面3
new THREE.Vector3(1, 0, 0), // 面4
new THREE.Vector3(0, 0, 1), // 面5
new THREE.Vector3(0, -1, 0), // 面6
];
let maxDot = -Infinity;
let faceValue = 0;
for (let i = 0; i < localNormals.length; i++) {
// 将局部法线向量转化为世界空间
const worldNormal = localNormals[i].clone().applyQuaternion(mesh.quaternion);
// 与全局上方向的点积
const dot = worldNormal.dot(new THREE.Vector3(0, 1, 0));
// 检查点积,找到最大值
if (dot > maxDot) {
maxDot = dot;
faceValue = i + 1; // 面的点数即为索引加1
}
}
return faceValue;
}
//判断物体是否停止运动
function isBodyStopped(body, linearVelocityThreshold = 0.1, angularVelocityThreshold = 0.1) {
// 获取物体的线性速度和角速度
const linearVelocityMagnitude = body.velocity.length();
const angularVelocityMagnitude = body.angularVelocity.length();
// 判断速度是否低于设定的阈值
return linearVelocityMagnitude < linearVelocityThreshold && angularVelocityMagnitude < angularVelocityThreshold;
}
//展示点数
function showPoints() {
let res = getUpperFace(meshModel);
let point = document.getElementById("points");
point.innerHTML = `点数:${res}`
}
//清空点数
function clearPoints() {
let point = document.getElementById("points");
point.innerHTML = ``
}
实现这些逻辑后,我们已经可以模拟出骰子抛出坠落并触碰地面后反弹,在停止运动后计算点数的效果。已经实现基础功能,但是我们发现如果随机速度过大的时候会移动很远才停下,于是我们增加一个空气墙来限制骰子在固定范围内。并且增加一个碰撞检测来触发撞地声音的效果。
//添加空气墙
const wallShape = new CANNON.Box(new CANNON.Vec3(100, 100, 0.1));
const wall1 = new CANNON.Body({ mass: 0 });
wall1.addShape(wallShape);
wall1.position.set(0, 100, -100); // Back wall
world.addBody(wall1);
const wall2 = new CANNON.Body({ mass: 0 });
wall2.addShape(wallShape);
wall2.position.set(0, 100, 100); // Front wall
world.addBody(wall2);
const wall3 = new CANNON.Body({ mass: 0 });
wall3.addShape(wallShape);
wall3.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall3.position.set(100, 100, 0); // Right wall
world.addBody(wall3);
const wall4 = new CANNON.Body({ mass: 0 });
wall4.addShape(wallShape);
wall4.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall4.position.set(-100, 100, 0); // Left wall
world.addBody(wall4);
//监听物体碰撞回调
const audio = new Audio('./assets/audio/peng.mp3');
bodyModel.addEventListener('collide', (event) => {
const contact = event.contact;
//获得沿法线的冲击速度
const ImpactV = contact.getImpactVelocityAlongNormal();
// 碰撞越狠,声音越大
if(ImpactV/35>1) {
audio.volume = 1;
} else {
audio.volume = ImpactV/35>0?ImpactV/35:0;
}
audio.currentTime = 0;
audio.play();
})
这样我们就初步完成了抛掷一个骰子并获取点数的功能,看似简单的一个场景实际上设计起来并不容易,要考虑很多因素。后续我会继续增加多个骰子同时抛掷的场景,以及比赛模式。源码也会贡献出来供大家一起学习参考,如果有更好的idea也可以在评论区留言或私信,大家一起在webgl中感受物理世界的魅力!
附完整代码:
import * as CANNON from "cannon-es";
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 实例化一个gui对象
// const gui = new GUI();
// //改变交互界面style属性
// gui.domElement.style.right = '0px';
// gui.domElement.style.width = '300px';
const option = {
z: -24,
x: -36,
y: -17,
z1: 1,
x1: 1,
y1: 1,
}
// //gui控制参数
// const folder_position = gui.addFolder('速度方向');
// folder_position.add(option, 'z', -100, 100);
// folder_position.add(option, 'x', -100, 100);
// folder_position.add(option, 'y', -100, 100);
// const folder_rotation = gui.addFolder('角度');
// folder_rotation.add(option, 'z1', -10, 10).step(0.1);
// folder_rotation.add(option, 'x1', -10, 10).step(0.1);
// folder_rotation.add(option, 'y1', -10, 10).step(0.1);
// CANNON.World创建物理世界对象
const world = new CANNON.World();
// 设置物理世界重力加速度
// world.gravity.set(0, -1000, 0); //重力加速度: 单位:m/s²
world.gravity.set(0, -100, 0);
//网格球体(gltf模型)
const loader = new GLTFLoader();
const gltf = await loader.loadAsync('./assets/model/dice_model.glb');
const meshModel = gltf.scene;//获取箱子网格模型
meshModel.position.set(50,30,50);
meshModel.scale.set(5,5,5);
scene.add(meshModel);
//包围盒计算
const box3 = new THREE.Box3();
box3.expandByObject(meshModel);//计算模型包围盒
const size = new THREE.Vector3();
box3.getSize(size);//包围盒计算箱子的尺寸
// 物理球体
const sphereMaterial = new CANNON.Material()//碰撞体材质
// 物理箱子
const bodyModel = new CANNON.Body({
mass: 0.3, // 碰撞体质量0.3kg
position: new CANNON.Vec3(50,30,50), // 位置
shape: new CANNON.Box(new CANNON.Vec3(size.x/2, size.y/2, size.z/2)),
material: sphereMaterial
});
// 骨骼辅助显示
const skeletonHelper = new THREE.SkeletonHelper(meshModel);
scene.add(skeletonHelper);
// world.addBody(bodyModel);
//添加空气墙
// Create air walls
const wallShape = new CANNON.Box(new CANNON.Vec3(100, 100, 0.1));
const wall1 = new CANNON.Body({ mass: 0 });
wall1.addShape(wallShape);
wall1.position.set(0, 100, -100); // Back wall
world.addBody(wall1);
const wall2 = new CANNON.Body({ mass: 0 });
wall2.addShape(wallShape);
wall2.position.set(0, 100, 100); // Front wall
world.addBody(wall2);
const wall3 = new CANNON.Body({ mass: 0 });
wall3.addShape(wallShape);
wall3.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall3.position.set(100, 100, 0); // Right wall
world.addBody(wall3);
const wall4 = new CANNON.Body({ mass: 0 });
wall4.addShape(wallShape);
wall4.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall4.position.set(-100, 100, 0); // Left wall
world.addBody(wall4);
camera.position.set(42,85,21)
camera.lookAt(0,10,0);
// 物理地面
const groundMaterial = new CANNON.Material()
const groundBody = new CANNON.Body({
mass: 0, // 质量为0,始终保持静止,不会受到力碰撞或加速度影响
shape: new CANNON.Plane(),
material: groundMaterial,
});
// 改变平面默认的方向,法线默认沿着z轴,旋转到平面向上朝着y方向
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);//旋转规律类似threejs 平面
world.addBody(groundBody)
//设置物理世界参数
const contactMaterial = new CANNON.ContactMaterial(groundMaterial, sphereMaterial, {
restitution: 0.5, //反弹恢复系数
})
// 把关联的材质添加到物理世界中
world.addContactMaterial(contactMaterial)
//光源设置
const directionalLight = new THREE.DirectionalLight(0xffffff, 2);
directionalLight.position.set(20, 100, 10);
scene.add(directionalLight);
// 网格地面
const planeGeometry = new THREE.PlaneGeometry(1000, 1000);
const texture = new THREE.TextureLoader().load('./assets/textures/hardwood2_diffuse.jpg');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(10, 10);
const planeMaterial = new THREE.MeshLambertMaterial({
// color:0x777777,
map: texture,
});
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial);
planeMesh.rotateX(-Math.PI / 2);
scene.add(planeMesh);
// 添加一个辅助网格地面
// const gridHelper = new THREE.GridHelper(50, 50, 0x004444, 0x004444);
// scene.add(gridHelper);
var controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 允许阻尼效果
controls.dampingFactor = 0.25; // 阻尼系数
let start_throw = false;
renderer.domElement.addEventListener('click', function (event) {
start_throw = true;
clearPoints();
const randomEuler = Math.random()*3;
bodyModel.quaternion.setFromEuler(Math.PI / randomEuler, Math.PI / randomEuler, Math.PI / randomEuler);
bodyModel.position.set(0,50,0);//点击按钮,body回到下落的初始位置
// 为物体设置初始速度(用以产生抛物线效果)
bodyModel.velocity.set(option.x,option.y,option.z); // x, y, z 方向上的速度
// 选中模型的第一个模型,开始下落
world.addBody(bodyModel);
})
const audio = new Audio('./assets/audio/peng.mp3');
bodyModel.addEventListener('collide', (event) => {
const contact = event.contact;
//获得沿法线的冲击速度
const ImpactV = contact.getImpactVelocityAlongNormal();
// 碰撞越狠,声音越大
if(ImpactV/35>1) {
audio.volume = 1;
} else {
audio.volume = ImpactV/35>0?ImpactV/35:0;
}
audio.currentTime = 0;
audio.play();
})
//判断物体是否停止运动
function isBodyStopped(body, linearVelocityThreshold = 0.1, angularVelocityThreshold = 0.1) {
// 获取物体的线性速度和角速度
const linearVelocityMagnitude = body.velocity.length();
const angularVelocityMagnitude = body.angularVelocity.length();
// 判断速度是否低于设定的阈值
return linearVelocityMagnitude < linearVelocityThreshold && angularVelocityMagnitude < angularVelocityThreshold;
}
function showPoints() {
let res = getUpperFace(meshModel);
let point = document.getElementById("points");
point.innerHTML = `点数:${res}`
}
//相机跟随物体移动
function locateView() {
camera.position.x = meshModel.position.x;
camera.position.y = meshModel.position.y + 30;
camera.position.z = meshModel.position.z + 20;
camera.lookAt(meshModel.position)
}
function clearPoints() {
let point = document.getElementById("points");
point.innerHTML = ``
}
//获取朝上面的点数
function getUpperFace(mesh) {
// 定义每个面的局部法线向量(手动定义)
const localNormals = [
new THREE.Vector3(0, 1, 0), // 面1
new THREE.Vector3(0, 0, -1), // 面2
new THREE.Vector3(-1, 0, 0), // 面3
new THREE.Vector3(1, 0, 0), // 面4
new THREE.Vector3(0, 0, 1), // 面5
new THREE.Vector3(0, -1, 0), // 面6
];
let maxDot = -Infinity;
let faceValue = 0;
for (let i = 0; i < localNormals.length; i++) {
// 将局部法线向量转化为世界空间
const worldNormal = localNormals[i].clone().applyQuaternion(mesh.quaternion);
// 与全局上方向的点积
const dot = worldNormal.dot(new THREE.Vector3(0, 1, 0));
// 检查点积,找到最大值
if (dot > maxDot) {
maxDot = dot;
faceValue = i + 1; // 面的点数即为索引加1
}
}
return faceValue;
}
function render() {
world.step(1/60);//更新物理计算
meshModel.position.copy(bodyModel.position); //渲染循环中,同步物理球body与网格球mesh的位置
meshModel.quaternion.copy(bodyModel.quaternion); //同步姿态角度
locateView(); //相机跟随物体移动
requestAnimationFrame(render);
renderer.render(scene, camera);
if (isBodyStopped(bodyModel)&&start_throw) {
showPoints(); //停止运动后,显示点数
start_throw = false;
}
}
render();
来源:juejin.cn/post/7394993393125064704
谈谈国内前端的三大怪啖
因为工作的原因,我和一些外国前端开发有些交流。他们对于国内环境不了解,有时候会问出一些有趣的问题,大概是这些问题的启发,让我反复在思考一些更为深入的问题。
今天聊三个事情:
- 小程序
- 微前端
- 模块加载
小程序
每个行业都有一把银座,当坐上那把银座时,做什么便都是对的。
“我们为什么需要小程序?”
第一次被问到这个问题,是因为一个法国的同事。他被派去做一个移动端业务,刚好那个业务是采用小程序在做。于是一个法国小哥就在被痛苦的中文文档和黑盒逻辑中来回折磨着 🤦。
于是,当我们在有一次交流中,他问出了我这个问题:我们为什么需要小程序?
说实话,我试图解释了 19 年国内的现状,以及微信小程序推出时候所带来的便利和体验等等。总之,在我看来并不够深刻的见解。
即便到现在为止,每次当我使用小程序的时候,依旧会复现这个问题。在 ChatGPT 11 月份出来的时候,我也问了它这个很有国内特色的问题:
看起来它回答的还算不错,至少我想如果它来糊弄那些老外,应该会比我做的更好些。
但如果扪心自问,单从技术上来讲。以上这些事情,一定是有其他方案能解决的。
所以从某种程度上来看,这更像是一场截胡的商业案例:
应用市场
全世界的互联网人都知道应用市场是非常有价值的事情,可以说操作系统最值钱的部分就在于他们构建了自己的应用市场。
只要应用在这里注册发行,雁过拔毛,这家公司就是互联网世界里的统治阶级,规则的制定者。
反之则需要受制于人,APP 做的再大,也只是应用市场里的一个应用,做的好与坏还得让应用商店的评判。
另外,不得不承认的是,一个庞大如苹果谷歌这样的公司,他们的应用商店对于普通国内开发者来说,确实是有门槛的。
在国内海量的 APP 需求来临之前,能否提供一个更低成本的解决方案,来消化这些公司的投资?
毕竟不是所有的小企业都需要 APP,其实他们大部分需求 Web 就可以解决,但是 Web 没牌面啊,做 Web 还得砸搜索的钱才有流量。(某度搜索又做成那样...)
那做操作系统?太不容易,那么多人都溺死在水里了,这水太深。
那有没有一种办法可以既能构建生态,又有 APP 的心智,还能给入驻企业提供流量?
于是,在 19 年夏天,滨海大厦下的软件展业基地里,每天都在轮番播放着,做 XX小程序,拥抱下一个风口...
全新体验心智
小程序用起来挺方便的。
你有没有想过,这些美妙感觉的具体都来自哪些?以及这些真的是 Web 技术本身无法提供的吗?
- 靠谱感,每个小程序都有约束和规范,于是你可以将他们整整齐齐的陈放在你的列表里,仿佛你已经拥有了这一个个精心雕琢的作品,相对于一条条记不住的网页地址和鱼龙混杂的网页内容来说,这让你觉得小程序更加的有分量和靠谱。
- 安全感,沉浸式的头部,没有一闪而过的加载条,这一切无打扰的设计,都让你觉得这是一个在你本地的 APP,而不是随时可能丢失网页。你不会因为网速白屏而感到焦虑,尽管网络差的时候,你的 KFC 依旧下不了单 😂
- 沉浸感,我不知道是否打开网页时顶部黑黑的状态栏是故意留下的,还是不小心的... 这些限制都让用户非常强烈的意识到这是一个网页而不是 APP,而小程序中虽然上面也存在一个空间的空白,但是却可以被更加沉浸的主题色和氛围图替代。网页这个需求做不了?我不信。
H5 | 小程序 |
---|---|
![]() | ![]() |
- 顺滑感,得益于 Native 的容器实现,小程序在所有的视图切换时,都可以表现出于原生应用一样的顺滑感。其实这个问题才是在很多 Hybrid 应用中,主要想借助客户端来处理和解决的问题。类似容器预开、容器切换等技术是可以解决相关问题的,只是还没有一个标准。
我这里没有提性能,说实话我不认为性能在小程序中是有优势的(Native 调用除外,如地图等,不是一个讨论范畴)。作为普通用户,我们感受到的只是离线加载下带来的顺滑而已。
而上述提到的许多优势,这对于一个高品质的 Web 应用来说是可以做得到的,但注意这里是高品质的 Web 应用。而这种“高品质”在小程序这里,只是入驻门槛而已。
心智,这个词,听起来很黑话,但却很恰当。当小程序通过长期这样的筛选,所沉淀出来一批这样品质的应用时。就会让每个用户即便在还没打开一个新的小程序之前,也有不错体验的心理预期。这就是心智,一种感觉跟 Web 不一样,跟 APP 有点像的心智。
打造心智,这件事情好像就是国内互联网企业最擅长做的事情,总是能从一些细微的差别中开辟一条独立的领域,然后不断强化灌输本来就不大的差异,等流量起来再去捞钱变现。
我总是感觉现在的互利网充斥着如何赚钱的想法,好像永远赚不够。“赚钱”这个事情,在这些公司眼里就是圈人圈地抢资源,看看谁先占得先机,别人有的我也得有,这好像是最重要的事情。
很少有企业在思考如何创造些没有的市场,创造些真正对技术发展有贡献,对社会发展有推动作用的事情。所以在国内互联网圈也充斥着一种奇怪的价值观,有技术的不一定比赚过钱的受待见。
管你是 PHP 还是 GO,管你是在做游戏还是直播带货,只要赚到钱就是高人。并且端的是理所应当、理直气壮,有些老板甚至把拍摄满屋子的程序员为自己打工作为一种乐趣。什么情怀、什么优雅、什么愿景,人生就俩字:搞钱。
不是故意高雅,赚钱这件事情本身不寒碜,只是在已经赚到盆满钵满、一家独大的时候还在只是想着赚更多的钱,好像赚钱的目的就是为了赚钱一样,这就有点不合适。企业到一定程度是要有社会责任的,龙头企业每一个决定和举措,都有会影响接下来的几年里这个行业的价值观走向。
当然也不是完全没有好格局的企业,我非常珍惜每一个值得尊重的中国企业,来自一个蔚来车主。
小程序在商业上固然是成功的,但吃的红利可以说还是来自 网页 到 应用 的心智变革。将本来流向 APP 的红利,截在了小程序生态里。
但对于技术生态的发展却是带来了无数条新的岔路,小程序的玩法就决定了它必须生长在某个巨型应用里面,不论是用户数据获取、还是 API 的调用,其实都是取决于应用容器的标准规范。
不同公司和应用之间则必然会产生差异,并且这种差异是墒增式的差异,只会随着时间的推移越变越大。如果每个企业都只关注到自己业务的增长,无外部约束的话,企业必然会根据自己的业务发展和政策需要,选择成本较低的调整 API,甚至会故意制造一些壁垒来增加这种差异。
小程序,应该是 浏览器 与 操作系统 的融合,这本应该是推动这两项技术操刀解决的事情。
微前端
qiankun、wujie、single-spa 是近两年火遍前端的技术方案,同样一个问题:我们为什么需要微前端?
我不确定是否每个在使用这项技术的前端都想清楚了这个问题,但至少在我面试过的候选人中,我很少遇到对自己项目中已经在使用的微前端,有很深的思考和理解的人。
先说下我的看法:
- 微前端,重在解决项目管理而不在用户体验。
- 微前端,解决不了该优化和需要规范的问题。
- 微前端,在挽救没想清楚 MPA 的 SPA 项目。
没有万能银弹
银色子弹(英文:Silver Bullet),或者称“银弹”“银质子弹”,指由纯银质或镀银的子弹。在欧洲民间传说及19世纪以来哥特小说风潮的影响下,银色子弹往往被描绘成具有驱魔功效的武器,是针对狼人、吸血鬼等超自然怪物的特效武器。后来也被比喻为具有极端有效性的解决方法,作为杀手锏、最强杀招、王牌等的代称。
所有技术的发展都是建立在前一项技术的基础之上,但技术依赖的选择过程中一定需要保留第一性原理的意识。
当 React、Vue 兴起,当 打包技术(Webpack) 兴起,当 网页应用(SPA) 兴起,这些杰出的技术突破都在不同场景和领域中给行业提供了新的思路、新的方案。
不知从何时开始,前端除了 div 竟说不出其他的标签(还有说 View 的),项目中再也不会考虑给一个通用的 class 解决通用样式问题。
不知从何时开始,有没有权限需要等到 API 请求过后才知道,没有权限的话再把页面跳转过去申请。
不知从何时开始,大家的页面都放在了一个项目里,两个这样的巨石应用融合竟然变成了一件困难的事。
上面这些不合理的现状,都是在不同的场景下,不思考适不适合,单一信奉 “一招吃遍天” 下演化出的问题。
B 端应用,是否应该使用 SPA? 这其实是一个需要思考的问题。
微前端从某种程度上来讲,是认准 SPA 了必须是互联网下一代应用标准的产物,好像有了 SPA 以后,MPA 就变得一文不值。甭管页面是移动端的还是PC的;甭管页面是面对 C 端的还是 B 端的;甭管一个系统是有 20 个页面还是 200 个页面,一律行这套逻辑。
SPA 不是万能银弹,React 不是万能银弹,Tailwind 不是万能银弹。在新技术出现的时候,保持热情也要保持克制。
ps. 我也十分痛恨 React 带来的这种垄断式的生态,一个 React 组件将 HTML 和 Style 都吃进去,让你即使在做一个移动端的纯展示页面时,也需要背上这些称重的负担。
质疑 “墨守成规”,打开视野,深度把玩,理性消费。
分而治之
分治法,一个很基本的工程思维。
在我看来在一个正常商业迭代项目中的主要维护者,最好不要超过 3 个人,注意是主要维护者(Maintainer) 。
你应该让每个项目都有清晰的责任人,而不是某行代码,某个模块。责任人的理解是有归属感,有边界感的那种,不是口头意义上的责任人。(一些公司喜欢搞这种虚头巴脑的事情,什么连坐…)
我想大部分想引入微前端的需求都是类似 如何更好的划分项目边界,同时保留更好的团队协同。
比如 导航菜单 应该是独立收口独立管理的,并且当其更新时,应该同时应用于所有页面中。类似的还有 环境变量、时区、主题、监控及埋点。微前端将这些归纳在主应用中。
而具体的页面内容,则由对应的业务进行开发子应用,最后想办法将路由关系注册进主应用即可。
当然这样纯前端的应用切换,还会出现不同应用之间的全局变量差异、样式污染等问题,需要提供完善的沙箱容器、隔离环境、应用之间通信等一系列问题,这里不展开。
当微前端做到这一部分的时候,我不禁在想,这好像是在用 JavaScript 实现一个浏览器的运行容器。这种本应该浏览器本身该做的事情,难道 JS 可以做的更好?
只是做到更好的项目拆分,组织协同的话,引入后端服务,由后端管控路由表和页面规则,将页面直接做成 MPA,这个方案或许并不比引入微前端成本高多少。
体验差异
从 SPA 再回 MPA,说了半天不又回去了么。
所以不防想想:在 B端 业务中使用 SPA 的优势在哪里?
流畅的用户体验:
这个话题其实涵盖范围很广,对于 SPA 能带来的 “流畅体验”,对于大多数情况下是指:导航菜单不变,内容变化 发生变化,页面切换中间不出现白屏。
但要做到这个点,其实对于 MPA 其实并没有那么困难,你只需要保证你的 FCP 在 500ms 以内就行。
以上的页面切换全部都是 MPA 的多页面切换,我们只是简单做了导航菜单的 拆分 和 SWR,并没有什么特殊的 preload、prefetch 处理,就得到了这样的效果。
因为浏览器本身在页面切换时会在 unload 之前先 hold 当前页面的视图不变,发起一下一个 document 的请求,当页面的视图渲染做到足够快和界面结构稳定就可以得到这样的效果。
这项浏览器的优化手段我找了很久,想找一篇关于它的博文介绍,但确实没找到相关内容,所以 500ms 也是我的一个大致测算,如果你知道相关的内容,可以在评论区补充,不胜感激。
所以从这个角度来看,浏览器本身就在尽最大的努力做这些优化,并且他们的优化会更底层、更有效的。
离线访问 (PWA)
SPA 确实会有更好的 PWA 组织能力,一个完整的 SPA 应用甚至可以只针对编译层做改动就可以支持 PWA 能力。
但如果看微前端下的 SPA 应用,需要支持 PWA 那就同样需要分析各子应用之间的元数据,定制 Service Worker。这种组织关系和定制 SW,对于元数据对于数据是来自前端还是后端,并不在意。
也就是说微前端模式下的 PWA,同样的投入成本,把页面都管理在后端服务中的 MPA 应用也是可以做到相同效果的。
项目协同、代码复用
有人说 SPA 项目下,项目中的组件、代码片段是可以相互之间复用的,在 MPA 下就相对麻烦。
这其实涉及到项目划分的领域,还是要看具体的需求也业务复杂度来定。如果说整个系统就是二三十个页面,这做成 SPA 使用前端接管路由高效简单,无可厚非。
但如果你本身在面对的是一个服务于复杂业务的 B 端系统,比如类似 阿里云、教务系统、ERP 系统或者一些大型内部系统,这种往往需要多团队合作开发。这种时候就需要跟完善的项目划分、组织协同和系统整合的方案。
这个时候 SPA 所体现出的优势在这样的诉求下就会相对较弱,在同等投入的情况下 MPA 的方案反而会有更少的执行成本。
也不是所有项目一开始就会想的那么清楚,或许一开始的时候就是个简单的 SPA 项目,但是随着项目的不断迭代,才变成了一个个复杂的巨石应用,现在如果再拆出来也会有许多迁移成本。引入微前端,则可以...
这大概是许多微前端项目启动的背景介绍,我想说的是:对于屎山,我从来不信奉“四两拨千斤”。
如果没有想好当下的核心问题,就引入新的“银弹”解决问题,只会是屎山雕花。
项目协同,抽象和复用这些本身不是微前端该解决的问题,这是综合因素影响下的历史背景问题。也是需要一个个资深工程师来把控和解决的核心问题,就是需要面对不同的场景给出不同的治理方案。
这个道理跟防沙治沙一样,哪有那么多一蹴而就、立竿见影的好事。
模块加载
模块加载这件事情,从玉伯大佬的成名作 sea.js 开始就是一个非常值得探讨的问题。在当时 jQuery 的时代里,这是一个绝对超前的项目,我也在实际业务中体会过在无编译的环境下 sea.js 的便捷。
实际上,不论是微前端、低代码、会场搭建等热门话题离不开这项技术基础。
import * from *
我们每天都在用,但最终的产物往往是一个自运行的 JS Bundle,这来自于 Webpack、Vite 等编译技术的发展。让我们可以更好的组织项目结构,以构建更复杂的前端应用。
模块的概念用久了,就会自然而然的在遇到浏览器环境中,遇到动态模块加载的需求时,想到这种类似模块加载的能力。
比如在遇到会场千奇百怪的个性化营销需求时,能否将模块的 Props 开放出来,给到非技术人员,以更加灵活的方式让他们去做自由组合。
比如在低代码平台中,让开发者自定义扩展组件,动态的将开发好的组件注册进低代码平台中,以支持更加个性的需求。
在万物皆组件的思想影响下,把一整个完整页面都看做一个组件也不是不可以。于是在一些团队中,甚至提倡所有页面都可以搭建、搭建不了的就做一个大的页面组件。这样及可以减少维护运行时的成本,又可以统一管控和优化,岂不美哉。
当这样大一统的“天才方案”逐渐发展成为标准后,也一定会出现一些特殊场景无法使用,但没关系,这些天才设计者肯定会提供一种更加天才的扩展方案出来。比如插件,比如扩展,比如 IF ELSE。再后来,就会有性能优化了,而优化的 追赶对象 就是用原来那种简单直出的方案。
有没有发现,这好像是在轮回式的做着自己出的数学题,一道接一道,仿佛将 1 + 1的结论重新演化了一遍。
题外话,我曾经自己实现过一套通过 JSON Schema 描述 React 结构的 “库” ,用于一个低代码核心层的渲染。在我的实现过程中,我越发觉得我在做一件把 JSX 翻译成 JS 的事情,但 JSX 或者 HTML 本身不就是一种 DSL 么。为什么一定要把它翻译成 JSON 来传输呢?或者说这样的封装其本身有意义么?这不就是在做 PHP、.Net 直接返回带有数据的 HTML Ajax 一样的事情么。
传统的浏览器运行环境下要实现一个模块加载器,无非是在全局挂载一个注册器,通过 Script 插入一段新的 JS,该 JS 通过特殊的头尾协议,将运行时的代码声明成一个函数,注册进事先挂载好的注册器。
但实际的实现场景往往要比这复杂的多,也有一些问题是这种非原生方式无法攻克的问题。比如全局注册器的命名冲突;同模块不同版本的加载冲突;并发加载下的时序问题;多次模块加载的缓存问题 等等等等等...
到最后发现,这些事情好像又是在用 JS 做浏览器该做的事情。然而浏览器果然就做了,,Vite 就主要采用这种模式实现了它 1 年之内让各大知名项目切换到 Vite 的壮举。
“但我们用不了,有兼容性问题。”
哇哦,当我看着大家随意写出的 display: grid
样式定义,不禁再次感叹人们对未知的恐惧。
import.meta
的兼容性是另外一个版本,是需要 iOS12 以上,详情参考:caniuse.com/?search=imp…
试想一下,现在的低代码、会场搭建等等各类场景的模块加载部分,如果都直接采用 ESM 的形式处理,这对于整个前端生态和开发体验来说会有多么大的提升。
模块加载,时至今日,本来就已经不再需要 loader。 正如 seajs 中写的:前端模块化开发那点历史
历史不是过去,历史正在上演。随着 W3C 等规范、以及浏览器的飞速发展,前端的模块化开发会逐步成为基础设施。一切终究都会成为历史,未来会更好。
结语
文章的结尾,我想感叹另外一件事,国人为什么一定要有自己的操作系统?为什么一定需要参与到一些规范的制定中?
因为我们的智慧需要有开花的土壤,国内这千千万开发者的抱负需要有地方释放。
如果没有自己掌握核心技术,就是只能在问题出现的时候用另类的方式来解决。最后在一番折腾后,发现更底层的技术只要稍稍一改就可以实现的更好。这就像三体中提到的 “智子” 一样,不断在影响着我们前进的动力和方向。
不论是小程序、微前端还是模块加载。试想一下,如果我们有自己的互联网底蕴,能决定或者影响操作系统和浏览器的底层能力。这些 “怪啖” 要么不会出现,要么就是人类的科技创新。
希望未来技术人不用再追逐 Write Once, Run Everywhere 的事情...
来源:juejin.cn/post/7267091810366488632
为什么删除node_modules文件夹那么慢
Windows系统 为什么删除node_modules文件夹那么慢?
在Windows系统中删除node_modules
文件夹可能会比较慢的原因有以下几点:
- 文件数量过多:
node_modules
文件夹通常包含大量的文件和文件夹,如果其中文件数量过多,系统需要逐一扫描并删除每个文件,这会导致删除过程变得缓慢。 - 文件路径过长:在Windows系统中,文件路径的长度有限制,如果
node_modules
文件夹中存在过长的文件路径,系统在删除这些文件时可能会变得缓慢。 - 文件占用:
node_modules
文件夹中可能包含一些被其他程序占用的文件,这会导致系统无法立即删除这些文件,从而延长删除时间。 - 磁盘速度:如果
node_modules
文件夹位于机械硬盘上而非固态硬盘,机械硬盘的读写速度相对较慢,也会影响删除操作的速度。 - 杀软扫描:有些杀毒软件在删除文件时会对文件进行扫描,以确保文件不包含恶意代码。这个额外的扫描过程也会增加删除文件的时间。
为什么在苹果系统上删除node_modules文件夹就很快?
- 文件系统差异:Windows采用的是NTFS文件系统,而macOS使用的是APFS文件系统,APFS 在快速复制、文件元数据管理、空间分配等方面具有优势,支持快速文件复制、快速目录大小计算、快速空间释放等功能,而 NTFS 和 exFAT 在某些方面可能不如 APFS 那么快速和高效。
- 文件路径处理:Windows对文件路径长度有限制,而macOS对文件路径长度的限制相对较宽松。如果
node_modules
文件夹中存在过长的文件路径,Windows系统在处理这些文件时可能会变得缓慢。 - 文件锁定:Windows系统在处理被其他程序占用的文件时,可能会出现文件锁定的情况,导致删除操作变得缓慢。而macOS系统在这方面可能更加灵活。
- 文件系统碎片:Windows系统在长时间使用后可能会产生文件系统碎片,这会影响文件的读写和删除速度。而macOS对文件系统碎片的处理可能更加高效。
Windows中删除慢解决方案
为了加快在Windows系统中删除文件夹的速度,可以尝试使用命令行删除、关闭占用文件的程序、使用专门的删除工具等方法,以提高删除效率。
- 在删除前关闭占用文件的程序:确保
node_modules
文件夹中的文件没有被其他程序占用,可以提前关闭相关程序再进行删除操作。 - 使用固态硬盘:如果可能的话,将
node_modules
文件夹放在固态硬盘上,可以显著提高文件的读写速度。 - 使用命令行删除:在命令行中使用
rd /s /q node_modules
命令可以快速删除node_modules
文件夹,避免Windows资源管理器中的删除操作。 - 使用专门的删除工具:例如 npm 全局安装 rimraf,以后直接使用删除命令即可。
npm install rimraf -g
~
rimraf node_modules/
来源:juejin.cn/post/7350107540325875721
Fuse.js一个轻量高效的模糊搜索库
最近逛github的时候发现了一个非常好用的轻量工具库,Fuse.js,支持模糊搜索。感觉还是非常好用的,所以有了此篇博客,这篇文章主要是介绍Fuse的使用,同样,我对这个开源项目的实现也非常感兴趣。后续会出一篇Fuse源码解析的文章来分析其实现原理。
Fuse.js是什么?
强大、轻量级的模糊搜索库,没有任何依赖关系。
什么是模糊搜索?
一般来说,模糊搜索(更正式的名称是近似字符串匹配)是查找与给定模式近似相等(而不是完全相等)的字符串的技术。
通常我们项目中的的模糊搜索大多数情况下有几种方案可用:
- 前端工程通过正则表达式或者字符串匹配来实现
- 调用后端接口去匹配搜索
- 使用搜索引擎如:ElasticSearch或Algolia等
但是这些方案都有各自的缺陷,比如正则表达式和字符串匹配的效率较低,且无法处理复杂的搜索需求,而调用后端接口和搜索引擎虽然效率高,但是需要额外的服务器资源,且需要维护一套搜索引擎。
所以,Fuse.js的出现就是为了解决这些问题,它是一个轻量级的模糊搜索库,没有依赖关系,支持复杂的搜索需求,且效率高,当然Fuse并不适用于所有场景。
Fuse.js的使用场景
它可能不适用于所有情况,但根据您的搜索要求,它可能是最理想的。例如:
- 当您想要对小型到中等大型数据集进行客户端模糊搜索时
- 当您无法证明设置专用后端只是为了处理搜索时
- ElasticSearch 或 Algolia 虽然都是很棒的服务,但对于您的特定用例来说可能有些过度
Fuse.js的使用
安装
Fuse支持多种安装方式
NPM
npm install fuse.js
Yarn
yarn add fuse.js
CDN 引入
<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0">script>
引入
ES6 模块语法
import Fuse from 'fuse.js'
CommonJS 语法
const Fuse = require('fuse.js')
Tips: 使用npm或者yarn引入,支持两种模块语法引入,如果是使用cdn引入,那么Fuse将被注册为全局变量。直接使用即可
使用
以下是官网一个最简单的例子,只要简单的构造new Fuse对象,就能模糊搜索匹配到你想要的结果
// 1. List of items to search in
const books = [
{
title: "Old Man's War",
author: {
firstName: 'John',
lastName: 'Scalzi'
}
},
{
title: 'The Lock Artist',
author: {
firstName: 'Steve',
lastName: 'Hamilton'
}
}
]
// 2. Set up the Fuse instance
const fuse = new Fuse(books, {
keys: ['title', 'author.firstName']
})
// 3. Now search!
fuse.search('jon')
// Output:
// [
// {
// item: {
// title: "Old Man's War",
// author: {
// firstName: 'John',
// lastName: 'Scalzi'
// }
// },
// refIndex: 0
// }
// ]
从上述代码中可以看到我们要通过Fuse 对books的这个数组进行模糊搜索,构建的Fuse对象中,模糊搜索的key定义为['title', 'author.firstName'],支持对title及author.firstName这两个字段进行搜索。然后执行fuse的search API就能过滤出我们的期望结果。整体代码还是非常简单的。
高级配置
Demo示例只是提供了一个基础版本的模糊搜索。如果用户想获得更灵活的搜索能力,比如搜索结果排序、权重控制、搜索结果高亮等,那么就需要对Fuse进行一些高级配置。
Fuse的所有配置都是通过new Fuse时传入的参数来配置的,下面列举一些常用的配置项:
const options = {
keys: ['title', 'author'], // 指定搜索key值,可多选
isCaseSensitive: false, //是否区分大小写 默认为false
includeScore: false, //结果集中是否展示匹配项的分数字段, 分数越大代表匹配程度越低,区间值为0-1,注意:当此项为true时,会返回完整的结果集,只不过每一项中携带了score分数字段
includeMatches: false, //匹配项是否应包含在结果中。当时true,结果的每条记录都包含匹配项的索引。这个通常我们用来对搜索内容做高亮处理
threshold: 0.6, // 阈值控制匹配的敏感度,默认值为0.6,如果要完全匹配这里要设置为0
shouldSort: true, // 是否对结果进行排序
location: 0, // 匹配的位置,0 表示开头匹配
distance: 100, // 搜索的最大距离
minMatchCharLength: 2, // 最小匹配字符长度
};
出了上述常用的一些配置项之外,Fuse还支持更高阶模糊搜索,如权重搜索,嵌套搜索,运算符拓展搜索,具体高阶用法可以参考官方文档。
Fuse的主要实现原理是通过改写Bitap 算法(近似字符串匹配)算法的内部实现来支撑其模糊搜索的算法依据,后续会出一篇文章看一下作者源码的算法实现。
总结
Fuse的文章到此就结束了,你没看错就这么一点介绍就基本能支撑我们在项目中的应用,谢谢阅读,如果哪里有不对的地方请评论博主,会及时进行改正。
来源:juejin.cn/post/7393172686115569705
微信小程序 折叠屏适配
最近维护了将近的一年的微信小程序(某知名企业),突然提出要兼容折叠屏,这款小程序主要功能一些图表汇总展示,也就是专门给一些领导用的,也不知道为啥领导们为啥突然喜欢用折叠屏手机了,一句话需求,苦的还是咱们程序员,但没办法,谁让甲方是爸爸呢,硬着头皮改吧,好在最后解决了,因为是甲方内部使用的小程序,这里不便贴图,但有官方案例图片,以供参考
启用大屏模式
从小程序基础库版本 2.21.3 开始,在 Windows、Mac、车机、安卓 WMPF 等大屏设备上运行的小程序可以支持大屏模式。可参考小程序大屏适配指南。方法是:在 app.json 中添加 "resizable": true
看到这里我心里窃喜,就加个配置完事了?这也太简单了,但后面证明我想简单了,
主要有两大问题:
- 1 尺寸不同的情况下内容展示效果兼容问题
- 2 预览版和体验版 大屏模式冷启动会生效,但热启动 和 菜单中点击重新进入小程、授权操作,会失效变成窄屏
解决尺寸问题
因为css的长度单位大部分用的 rpx,窄屏和宽屏展示差异出入较大,别说客户不认,自己这关就过不了,简直都不忍直视,整个乱成一片,尤其登录页,用了定位,更是乱上加乱。
随后参考了官方的文档 小程序大屏适配指南和自适应布局,方案对于微信小程序原生开发是可行的,但这个项目用的 uni-app开发的,虽然uni-app 也有对应的响应式布局组件,再加上我是个比较爱偷懒的人(甲方给的工期事件也有限制),不可能花大量时间把所有也页面重新写一遍布局,这是不现实的。
于是又转战到uni-app官网寻找解决方案 uni-app宽屏适配指南
内容缩放拉伸的处理 这一段中提出了两个策略
- 1.局部拉伸:页面内容划分为固定区域和长宽动态适配区域,固定区域使用固定的px单位约定宽高,长宽适配区域则使用flex自动适配。当屏幕大小变化时,固定区域不变,而长宽适配区域跟着变化
- 2.等比缩放:根据页面屏幕宽度缩放。rpx其实属于这种类型。在宽屏上,rpx变大,窄屏上rpx变小。
随后看到这句话特别符合我的需求,哈哈 省事 省事 省事
策略2省事,设计师按750px屏宽出图,程序员直接按rpx写代码即可。但策略2的实际效果不如策略1好。程序员使用策略1,分析下界面,设定好局部拉伸区域,这样可以有更好的用户体验
具体实现
1.配置 pages.json 的 globeStyle
{
"globalStyle": {
"rpxCalcMaxDeviceWidth": 1200, // rpx 计算所支持的最大设备宽度,单位 px,默认值为 960
"rpxCalcBaseDeviceWidth": 375, // rpx 计算使用的基准设备宽度,设备实际宽度超出 rpx 计算所支持的最大设备宽度时将按基准宽度计算,单位 px,默认值为 375
"rpxCalcIncludeWidth": 750 // rpx 计算特殊处理的值,始终按实际的设备宽度计算,单位 rpx,默认值为 750
},
}
2.单位兼容
还有一点官方也提出来了很重要,那就很多时候 会把宽度750rpx 当成100% 使用,这在宽屏的设备上就会有问题, uniapp给了两种解决方案
- 750rpx 改为100%
- 另一种是配置rpxCalcIncludeWidth,设置某个特定数值不受rpxCalcMaxDeviceWidth约束
想要用局部拉伸:页面内容划分为固定区域和长宽动态适配区域”的策略,单位必须用px
添加脚本
项目根目录新增文件 postcss.config.js 内容如下。则在编译时,编译器会自动转换rpx单位为px。
// postcss.config.js
const path = require('path')
module.exports = {
parser: 'postcss-comment',
plugins: {
'postcss-import': {
resolve(id, basedir, importOptions) {
if (id.startsWith('~@/')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(3))
} else if (id.startsWith('@/')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(2))
} else if (id.startsWith('/') && !id.startsWith('//')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(1))
}
return id
}
},
'autoprefixer': {
overrideBrowserslist: ["Android >= 4", "ios >= 8"],
remove: process.env.UNI_PLATFORM !== 'h5'
},
// 借助postcss-px-to-viewport插件,实现rpx转px,文档:https://github.com/evrone/postcss-px-to-viewport/blob/master/README_CN.md
// 以下配置,可以将rpx转换为1/2的px,如20rpx=10px,如果要调整比例,可以调整 viewportWidth 来实现
'postcss-px-to-viewport': {
unitToConvert: 'rpx',
viewportWidth: 200,
unitPrecision: 5,
propList: ['*'],
viewportUnit: 'px',
fontViewportUnit: 'px',
selectorBlackList: [],
minPixelValue: 1,
mediaQuery: false,
replace: true,
exclude: undefined,
include: undefined,
landscape: false
},
'@dcloudio/vue-cli-plugin-uni/packages/postcss': {}
}
}
大屏模式失效问题
下面重头戏来了,这期间经历 蜿蜒曲折 ,到头来发现都是无用功,我自己都有被wx蠢到发笑,唉,
样式问题解决后 开始着重钻研 大屏失效的问题,但看了官方的多端适配示例demo,人家的就是好的,那就应该有解决办法,于是转战github地址 下项目,谁知这项目暗藏机关,各种报错,让你跑不起来。。。。,让我一度怀疑腾讯也这么拉跨
还好issues 区一位大神有解决办法 感兴趣的老铁可以去瞅瞅
另外 微信小程序开发工具需要取消这两项,最后当项目跑起来后我还挺开心,模拟器上没有问题,但用真机预览的时候我啥眼了,还是窄屏,偶尔可以大屏,后面发现 冷启动是大屏,热启动和点击右上角菜单中的重新进入小程序按钮都会自己变成窄屏幕
![]() | ![]() |
这是官方的项目啊,为啥人家的可以,我本地跑起来却不可以,让我一度怀疑这里有内幕,经过几轮测试还是不行,于是乎,我开始了各种询问查资料,社区、私聊、评论、github issues,最后甚至 统计出来了 多端适配示例demo 开发者的邮箱 挨个发了邮件,但都结果无一例外,全部石沉大海
![]() | ![]() |
![]() | ![]() |
![]() | ![]() |
结果就是,没有办法了,想看看是不是只有预览和体验版有问题,后面发布到正式版后,再看居然没问题了,就是这么神奇,也是无语!!!! 原来做了这么多无用功。。。。
来源:juejin.cn/post/7273764921456492581
前端项目公共组件封装思想(Vue)
1. 通用组件(表单搜索+表格展示+分页器)
在项目当中我们总会遇到这样的页面:页面顶部是一个表单筛选项,下面是一个表格展示数据。表格下方是一个分页器,这样的页面在我们的后台管理系统中经常所遇到,有时候可能不止一个页面,好几个页面的结构都是这种。如图:
本人记得,在react中的高级组件库中有这么一个组件,就实现了这么一个效果。就拿这个页面来说我们实现一下组件封装的思想:1.首先把每个页面的公共部分抽出来,比如标题等,用props或者插槽的形式传入到组件中进行展示 2. 可以里面数据的双向绑定实现跟新的效果 3. 设置自定义函数传递给父组件要做上面事情
1.将公共的部分抽离出来
TableContainer组件
<template>
<div class="container">
<slot name="navbar"></slot>
<div class="box-detail">
<div class="detail-box">
<div class="box-left">
<div class="left-bottom">
<div class="title-bottom">{{ title }}</div>
<div class="note">
<div class="note-detail">
<slot name="table"></slot>
</div>
</div>
</div>
</div>
</div>
</div>
<el-backtop style="width: 3.75rem; height: 3.75rem" :bottom="10" :right="5">
<div
style="
{
width: 5.75rem;
flex-shrink: 0;
border-radius: 2.38rem;
background: #fff;
box-shadow: 0 0.19rem 1rem 0 #2b4aff14;
}
"
>
<i class="el-icon-arrow-up" style="color: #6e6f74"></i>
</div>
</el-backtop>
</div>
</template>
这里的话利用了具名插槽插入了navbar、table组件,title通过props的属性传入到子组件当中。进行展示,
父组件
<TableContainer title="资源审核">
<template v-slot:navbar>
<my-affix :offset="0">
<Navbar/>
</my-affix>
</template>
<template v-slot:table>
<SourceAuditTable/>
</template>
</TableContainer>
当然这是一个非常非常简单的组件封装案例
接下来我们看一个高级一点的组件封装
父组件
<template>
<div>
<hr>
<HelloWorld :page.sync="page" :limit.sync="limit" />
</div>
</template>
<script>
import HelloWorld from './components/HelloWorld.vue';
export default {
data() {
return {
page: 1,
limit: 5
}
},
components: {
HelloWorld
},
}
</script>
父组件传递给子组件各种必要的属性:total(总共多少条数据)、page(当前多少页)、limit(每页多少条数据)、pageSizes(选择每页大小数组)
子组件
<template>
<el-pagination :current-page.sync="currentPage" :page-size.sync="pageSize" :total="20" />
</template>
<script>
export default {
name: 'HelloWorld',
props: {
page: {
default: 1
},
limit: {
default: 5
},
},
computed: {
currentPage: {
get() {
return this.page
},
set(val) {
//currentPage 这里对currentPage做出来改变就会走这里
//这边更新数据走这里
console.log('currentPage', this.currentPage)
this.$emit('update:page', val)
}
},
pageSize: {
get() {
return this.limit
},
set(val) {
this.$emit('update:limit', val)
}
}
},
methods: {
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
这里的page.sync、limit.sync目的就是为了实现数据的双向绑定,computed中监听page和limit的变化,子组件接收的数据通过computed生成的currentPage通过sync绑定到了 el-pagination中, 点击分页器的时候会改变currentPage 此时会调用set函数设置新的值,通过代码 this.$emit(update:page,value) 更新父组件中的值,实现双向的数据绑定
本文是作者在闲暇的时间随便记录一下, 若有错误请指正,多多包涵。感谢支持!
来源:juejin.cn/post/7312353213347708940
小程序和h5有什么差别
差别
微信小程序和H5应用在实现原理上的差异主要体现在架构、渲染方式、数据通信、运行环境和API接口等方面。以下是详细的对比:
1. 架构和运行环境
微信小程序:
架构:微信小程序主要分为逻辑层(JavaScript)和视图层(WXML、WXSS)。逻辑层运行在小程序的JSCore中,而视图层运行在WebView(它是基于浏览器内核重构的内置解析器,它并不是一个完整的浏览器,官方文档中重点强调了脚本内无法使用浏览器中常用的
window
对象和document
对象,就是没有DOM
和BOM
的相关的API
,这一条就干掉了JQ
和一些依赖于BOM
和DOM
的NPM包)中,两者通过平台提供的桥接机制进行通信。运行环境:逻辑层在微信提供的JS引擎中运行,视图层在微信内置的WebView中渲染。
H5 应用:
架构:H5应用是一个整体。HTML、CSS和JavaScript共同构成了一个Web页面。
运行环境:H5应用在浏览器中运行,所有代码都在浏览器的环境中解析和执行。
2. 渲染方式
微信小程序:
微信小程序采用双线程模型,将逻辑层和视图层分离,分别运行在不同的线程中(两者通过平台提供的桥接机制进行通信):
逻辑层:运行在小程序的JSCore环境中,负责处理业务逻辑、数据计算和API调用。
视图层:运行在WebView中,负责渲染用户界面和处理用户交互。( 性能提升:由于小程序的渲染过程并不依赖于JS,因此即使JS线程发生阻塞,页面的渲染也不会受到影响。这种机制有利于提高渲染效率,减少卡顿,提升用户体验。)
通信桥接机制
逻辑层和视图层之间不能直接访问和操作对方的数据和界面,因此需要通过微信小程序框架提供的桥接机制来进行通信。这种通信机制通常包括以下几个方面:
1. 数据绑定和响应式更新(逻辑层--->视图层)
逻辑层通过数据绑定的方式将数据传递给视图层,视图层根据数据变化自动更新界面。数据绑定的过程如下:
设置数据:逻辑层通过
Page
或Component
实例的setData
方法,将数据传递给视图层。更新视图:视图层接收到数据变化的消息后,根据新的数据重新渲染界面。
2. 事件处理(视图层--->逻辑层)
视图层中的用户交互(如点击、输入等)会触发事件,这些事件通过桥接机制传递给逻辑层进行处理。事件处理的过程如下:
事件绑定:在视图层(WXML)中定义事件处理函数。
事件触发:用户在界面上进行交互时,触发相应的事件。
事件传递:视图层将事件信息通过桥接机制传递给逻辑层。
事件处理:逻辑层的事件处理函数接收到事件信息,执行相应的业务逻辑。
3. 消息传递
逻辑层和视图层之间的通信实际是通过消息传递的方式实现的。微信小程序框架负责在两个层之间传递消息,包括:
逻辑层到视图层的消息:如数据更新、视图更新等。
视图层到逻辑层的消息:如用户交互事件、视图状态变化等
通信桥接机制具体实现
依赖于微信小程序框架内部的设计和优化,开发者无需直接接触底层的通信细节。以下是桥接机制的一些关键点:
消息队列:逻辑层和视图层之间维护一个消息队列,用于存储待传递的消息。
消息格式:消息以JSON格式进行编码,包含消息类型、数据内容等信息。
消息处理:逻辑层和视图层各自维护一个消息处理器,负责接收、解析和处理消息。
异步通信:消息传递通常是异步进行的,以确保界面和逻辑的流畅性和响应性
H5 应用:
H5应用的逻辑层和视图层通常是在同一线程(主线程)中运行,直接通过JavaScript代码操作DOM来更新界面。主要的通信方式包括:
直接DOM操作:通过JavaScript直接操作DOM元素,更新界面。
事件监听和处理:通过JavaScript监听DOM事件(如点击、输入等)并处理。
数据绑定:使用现代前端框架(如Vue.js、React.js)的数据绑定和响应式机制,实现视图的自动更新。
3. 数据通信
微信小程序:
通信机制:逻辑层和视图层之间的通信通过小程序框架提供的机制来实现,通常是通过事件和数据绑定。
后台通信:可以通过小程序提供的API与服务器通信,例如wx.request等。
H5 应用:
通信机制:页面内的通信可以通过DOM事件、JavaScript函数调用等方式实现。
后台通信:可以使用标准的AJAX请求、Fetch API、WebSocket等方式与服务器通信。
4. 运行机制
微信小程序
启动
如果用户已经打开过某小程序,在一定时间内再次打开该小程序,此时无需重新启动,只需将后台态的小程序切换到前台,整个过程就是所谓的
热启动
如果用户首次打开或小程序被微信主动销毁后再次打开的情况,此时小程序需要重新加载启动,就是
冷启动
销毁
当小程序进入后台一定时间,或系统资源占用过高,或者是你手动销毁,才算真正的销毁
h5:解析HTML CSS形成DOM树和CSSOM树,两者结合形成renderTree,js运行,当然中间存在一系列的阻塞问题,还有同源策略等等
5. 系统权限方面(特定功能)
微信小程序依托于微信平台,能够利用微信提供的特有功能和API,实现许多H5应用无法直接实现或不易实现的功能,如微信支付、微信登录、硬件接口(如摄像头、麦克风、蓝牙、NFC等)、微信特有功能等。
6.更新机制
h5更新后访问地址即可
微信小程序需要审核
开发者在发布新版本之后,无法立刻影响到所有现网用户,要在发布之后 24 小时之内才下发新版本信息到用户
小程序每次
冷启动
时,都会检查有无更新版本,如果发现有新版本,会异步下载新版本代码包,并同时用客户端本地包进行启动,所以新版本的小程序需要等下一次冷启动
才会应用上,当然微信也有wx.getUpdateManager
可以做检查更新
7. 开发工具和调试
微信小程序:
开发工具:微信提供了专门的开发者工具,集成了调试、预览、上传等功能,方便开发者进行开发和测试。
调试:可以使用微信开发者工具进行实时调试,并提供丰富的日志和调试信息。
H5 应用:
开发工具:可以使用任何Web开发工具和IDE(如VS Code、WebStorm等),以及浏览器的开发者工具进行调试。
调试:依赖浏览器的开发者工具(如Chrome DevTools),可以进行断点调试、查看网络请求、分析性能等。
总结来说,微信小程序和H5应用在实现原理上的差异主要是由于它们的架构设计、运行环境和生态系统的不同。小程序依托于微信平台,提供了许多平台专属的优化和功能,而H5应用则更加开放和灵活,依赖于浏览器的标准和特性。
小程序为什么使用双层架构
微信小程序采用双线程架构的原因主要是为了优化性能和用户体验。双线程架构将逻辑层和视图层分离,使得业务逻辑处理和视图渲染在不同的线程中进行,从而提高了小程序的运行效率和响应速度。以下是采用双线程架构的具体原因和优势:
提高性能:
将逻辑处理和页面渲染分离到不同的线程中,可以避免互相干扰,提高整体性能。例如,在复杂的业务逻辑计算过程中,视图层仍然可以保持流畅的界面更新和响应。
逻辑层和视图层通过消息机制进行异步通信,可以避免阻塞和卡顿。这样即使逻辑层的操作较为耗时,也不会影响界面的即时响应。
安全性: 视图层无法直接操作逻辑层的数据和代码,这样可以避免一些潜在的安全风险和漏洞。
XSS
由于逻辑层和视图层分离,视图层不能直接执行逻辑层的JavaScript代码。这种隔离使得即使视图层(WXML)中存在注入的恶意代码,也不能直接影响逻辑层的数据和操作。
逻辑层和视图层之间的通信通过统一的API进行,传递的数据会经过平台的安全检查和过滤,进一步减少了XSS攻击的风险。
CSRF
小程序通过平台的统一API进行请求,这些请求包含了平台自动添加的安全令牌(如
session_key
等),确保请求的合法性。由于逻辑层和视图层的分离,用户在视图层进行操作时,逻辑层的业务逻辑和数据处理经过平台的校验,减少了CSRF攻击的风险。
DOM篡改:视图层的DOM结构由WXML和WXSS定义,不能直接通过逻辑层的JavaScript代码进行操作,这种隔离减少了DOM篡改的可能性。
安全权限管理:小程序的API权限由平台统一管理和控制,开发者需要申请和用户授权后才能使用特定的API。
用户体验: 微信小程序在启动时可以并行加载逻辑层和视图层资源,减少初始加载时间,提升启动速度。同时,微信平台会对小程序进行预加载和缓存优化,进一步提升加载性能。
rpx
微信的自适应单位,可以根据屏幕宽度进行自适应。
在微信小程序中,1 rpx 表示屏幕宽度的 1/750,因此 rpx
和 px
的换算关系是动态的,基于设备的实际屏幕宽度。
作者:let_code
来源:juejin.cn/post/7389168680747614245
展开收起的箭头动画应该怎么做?
背景
我们在日常开发中,一定经常遇到折叠面板的开发,为了美观,我们经常会添加展开收起按钮,并且带有箭头旋转动画。
比如下面的几种情况
- 文字点击变化,且有箭头旋转动画
- 只有箭头动画
这几种情况的核心其实就是:点击箭头开始旋转,再点击箭头恢复初始位置。
如何实现
思路分析
要实现展开和收起箭头的旋转动画,我们可以使用 CSS 和 JavaScript。我们在点击按钮时,通过添加和移除 CSS 类,实现箭头的旋转动画。并且添加transition属性实现过渡效果。
代码实现
我们以第一种动画效果为例,先写基础代码
<template>
<div @click="open = !open">
<span>{{ open ? '收起' : '展开' }}</span>
<span>▼</span>
</div>
</template>
<script>
const open = ref(false)
</script>
现在我们点击按钮,只有文字会变化,箭头不会旋转
我们给按钮加一个动态类
<template>
<div @click="open = !open">
<span>{{ open ? '收起' : '展开' }}</span>
<span :class="{ rotate: open }">▼</span>
</div>
</template>
<script>
const open = ref(false)
</script>
<style scoped>
.rotate {
transform: rotate(180deg);
transition: transform 0.3s linear;
}
</style>
可以看到,展开的时候有动画,但是收起的时候是没有过渡效果的。
我们只需要加一个transition属性即可
<template>
<div @click="open = !open">
<span>{{ open ? '收起' : '展开' }}</span>
<span :class="{ rotate: open }" class="arrow">▼</span>
</div>
</template>
<script>
const open = ref(false)
</script>
<style scoped>
.arrow {
transition: transform 0.3s linear;
}
.rotate {
transform: rotate(180deg);
transition: transform 0.3s linear;
}
</style>
现在样式就ok了
html版本
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arrow Rotation</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<button id="toggleButton">
<span id="arrow" class="arrow">▼</span>
</button>
</div>
<script src="script.js"></script>
</body>
</html>
css
/* styles.css */
.container {
text-align: center;
margin-top: 50px;
}
#toggleButton {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
outline: none;
}
.arrow {
display: inline-block;
transition: transform 0.3s ease;
}
.arrow.rotate {
transform: rotate(180deg);
}
js
// script.js
document.getElementById('toggleButton').addEventListener('click', function() {
const arrow = document.getElementById('arrow');
arrow.classList.toggle('rotate');
});
这种方式可以实现箭头在点击时的旋转动画效果。在实际项目中使用,我们也可以根据具体需求调整样式和逻辑。
来源:juejin.cn/post/7385132403025149989
如果iconfont停止服务了,我们怎么办
前言
个人一直都比较喜欢阿里大佬提供的一些组件服务什么的,这里就只说图标管理吧,最开始的时候我是使用的icomoon.io 后面发现http://www.iconfont.cn/ 在以前开发是时候我们为了图片的请求少点,提升网站的性能会把这些小图标做到一张图上面专业叫法是雪碧图,后来随着前端发展有了字体图标,不啰嗦了吧,回到正题,由于上次icofont官网挂了之后,导致我的项目无法进行正常迭代,我就决定要自己弄个简单的图标管理。
需求
一般使用需要的是把设计做好的图标或者其他地方下载的svg图标上传到服务器然后可以查看,并且打包成一个js文件加载到项目中。
准备
都说天下文章一大抄,我也是去抄iconfont,我把iconfont的使用demo打开研究了一下。
我这里只说Symbol,通过分析看到就是我们需要在项目中引入./iconfont.js
iconfont把我们上传的图标进行了删减优化,最外层就一个svg标签里面使用了一个symbol标签来包裹之前的图标内容,每一个symbol的id都对应之前的svg图标名称。
iconfont也没有开源,具体怎么做的咱也不知道,只能知道目前这些信息了,然后就按照自己理解开始开发实现了
前端开发
通过上面我们可以知道,现在我们前端需要的是把设计稿的svg图变成symbol里面的内容,打开svg图 可以简单测试下,直接把内容复制然后粘贴带了iconfont.js中,然后运行demo图标正常显示就说明是ok的,不然就是这样做是不行的。
通过图片我们可以看到,现在需要的就是把svg里面的path改成用symblo来包裹,在开发中svg图我们是通过文档流上传上来的,这个时候我们就需要对文档流进行操作,先把文档流变成字符串,然后js操作字符串拼接达到目的。
- 使用到FileReader和readAsText获取到字符串
const fileParse = (file) => {
return new Promise((resolve) => {
const fileReader = new FileReader();
fileReader.readAsText(file, "UTF-8");
fileReader.onload = (e) => {
resolve({ content: e.target?.result, name: file.name })
}
})
}
- 字符操作拼接我使用的是cheerio
const handleUploadSvg = ($, result) => {
let index = result.indexOf('
通过上面的操作我们可以得到一个新的svg源码,图标显示还是flow
然后我把这个字符串传送给后端就行了
后端开发
后端要做的就是把我上传上来的svg字符串拼接到一起,然后以iconfont.js一样通过一个get接口返回给我就可以了, 通过浏览器可以访问到就说明ok了
其他两种需要用到些第三方库当时也有顺便研究看到了,如果有需要我再写一篇。
文章中会有些不怎么清晰或者有问题的地方还望各位大佬见谅,刚刚开始决定写一些技术相关文章,还很生疏
来源:juejin.cn/post/7340197367515578378
谁说forEach不支持异步代码,只是你拿不到异步结果而已
在前面探讨 forEach 中异步请求后端接口时,很多人都知道 forEach 中 async/await 实际是无效的,很多文章也说:forEach 不支持异步,forEach 只能同步运行代码,forEach 会忽略 await 直接进行下一次循环...
当时我的理解也是这样的,后面一细想好像不对,直接上我前面一篇文章用到的示例代码:
async function getData() {
const list = await $getListData()
// 遍历请求
list.forEach(async (item) => {
const res = await $getExtraInfo({
id: item.id
})
item.extraInfo = res.extraInfo
})
// 打印下最终处理过的额外数据
console.log(list)
}
上面 $getListData、$getExtraInfo 都是 promise 异步方法,按照上面说的 forEach 会直接忽略掉 await,那么循环体内部拿到的 res 就应该是 undefined,后面的 res.extraInfo 应该报错才对,但是实际上代码并没有报错,说明 await 是有效的,内部的异步代码也是可以正常运行的,所以 forEach 肯定是支持异步代码的。
手写版 forEach
先从自己实现的简版 forEach 看起:
Array.prototype.customForEach = function (callback) {
for (let i = 0; i < this.length; i++) {
callback(this[i], i, this)
}
}
里面会为数组的每个元素执行一下回调函数,实际拿几组数组测试和正宗的 forEach 方法效果也一样。可能很多人还是会有疑问你自己实现这到底靠不靠谱,不瞒你说我也有这样的疑问。
MDN 上关于 forEach 的说明
先去 MDN 上搜一下 forEach,里面的大部分内容只是使用层面的文档,不过里面有提到:“forEach() 期望的是一个同步函数,它不会等待 Promise 兑现。在使用 Promise(或异步函数)作为 forEach 回调时,请确保你意识到这一点可能带来的影响”。
ECMAScript 中 forEach 规范
继续去往 javascript 底层探究,我们都知道执行 js 代码是需要依靠 js 引擎,去将我们写的代码解释翻译成计算机能理解的机器码才能执行的,所有 js 引擎都需要参照 ECMAScript 规范来具体实现,所以这里我们先去看下 ECMAScript 上关于 forEach 的标准规范:
添加图片注释,不超过 140 字(可选)
谷歌 V8 的 forEach 实现
常见的 js 引擎有:谷歌的 V8、火狐 FireFox 的 SpiderMonkey、苹果 Safari 的 JavaScriptCore、微软 Edge 的 ChakraCore...后台都很硬,这里我们就选其中最厉害的谷歌浏览器和 nodejs 依赖的 V8 引擎,V8 中对于 forEach 实现的主要源码:
transitioning macro FastArrayForEach(implicit context: Context)(
o: JSReceiver, len: Number, callbackfn: Callable, thisArg: JSAny): JSAny
labels Bailout(Smi) {
let k: Smi = 0;
const smiLen = Cast<Smi>(len) otherwise goto Bailout(k);
const fastO = Cast<FastJSArray>(o) otherwise goto Bailout(k);
let fastOW = NewFastJSArrayWitness(fastO);
// Build a fast loop over the smi array.
for (; k < smiLen; k++) {
fastOW.Recheck() otherwise goto Bailout(k);
// Ensure that we haven't walked beyond a possibly updated length.
if (k >= fastOW.Get().length) goto Bailout(k);
const value: JSAny = fastOW.LoadElementNoHole(k)
otherwise continue;
Call(context, callbackfn, thisArg, value, k, fastOW.Get());
}
return Undefined;
}
源码是 .tq 文件,这是 V8 团队开发的一个叫 Torque 的语言,语法类似 TypeScript,所以对于前端程序员上面的代码大概也能看懂,想要了解详细的 Torque 语法,可以直接去 V8 的官网上查看。
从上面的源码可以看到 forEach 实际还是依赖的 for 循环,没有返回值所以最后 return 的一个 Undefined。看完源码是不是发现咱上面的手写版也大差不差,只不过 V8 里实现了更多细节的处理。
结论:forEach 支持异步代码
最后的结论就是:forEach 其实是支持异步的,循环时并不是会直接忽略掉 await,但是因为 forEach 没有返回值,所以我们在外部没有办法拿到每次回调执行过后的异步 promise,也就没有办法在后续的代码中去处理或者获取异步结果了,改造一下最初的示例代码:
async function getData() {
const list = await $getListData()
// 遍历请求
list.forEach(async (item) => {
const res = await $getExtraInfo({
id: item.id
})
item.extraInfo = res.extraInfo
})
// 打印下最终处理过的额外数据
console.log(list)
setTimeout(() => {
console.log(list)
}, 1000 * 10)
}
你会发现 10 秒后定时器中是可以按照预期打印出我们想要的结果的,所以异步代码是生效了的,只不过在同步代码中我们没有办法获取到循环体内部的异步状态。
如果还是不能理解,我们对比下 map 方法,map 和 forEach 很类似,但是 map 是有返回值的,每次遍历结束之后我们是可以直接 return 一个值,后续我们就可以接收到这个返回值。这也是为什么很多文章中改写 forEach 异步操作时,使用 map 然后借助 Promise.all 来等待所有异步操作完成后,再进行下面的逻辑来实现同步的效果。
参考文档
- MDN forEach 文档:developer.mozilla.org/zh-CN/docs/…
- ECMAScript 中 forEach 规范:tc39.es/ecma262/#se…
- 谷歌 V8 中 forEach 源码:chromium.googlesource.com/v8/v8.git/+…
- 谷歌 V8 中 map 源码:chromium.googlesource.com/v8/v8.git/+…
- 谷歌 V8 官网:v8.dev
- 谷歌 V8 源码:github.com/v8/v8
来源:juejin.cn/post/7389912354749087755
js如何实现当文本内容过长时,中间显示省略号...,两端正常展示
前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。
产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号...表示,并且鼠标悬浮后,展示全部内容。
关于鼠标悬浮展示全部内容的代码就不放在这里了,本文主要写关于实现中间省略号...的代码。
实现思路
- 获取标题盒子的真实宽度, 我这里用的是clientWidth;
- 获取文本内容所占的实际宽度;
- 根据文字的大小计算出每个文字所占的宽度;
- 判断文本内容的实际宽度是否超出了标题盒子的宽度;
- 通过文字所占的宽度累加之和与标题盒子的宽度做对比,计算出要截取位置的索引;
- 同理,文本尾部的内容需要翻转一下,然后计算索引,截取完之后再翻转回来;
代码
html代码
<div class="title" id="test">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</div>
css代码: 设置文本不换行,同时设置overflow:hidden
让文本溢出盒子隐藏
.title {
width: 640px;
height: 40px;
line-height: 40px;
font-size: 14px;
color: #00b388;
border: 1px solid #ddd;
overflow: hidden;
/* text-overflow: ellipsis; */
white-space: nowrap;
/* box-sizing: border-box; */
padding: 0 10px;
}
javascript代码:
获取标题盒子的宽度时要注意,如果在css样式代码中设置了padding, 就需要获取标题盒子的左右padding值。 通过getComputedStyle
属性获取到所有的css样式属性对应的值, 由于获取的padding值都是带具体像素单位的,比如: px
,可以用parseInt特殊处理一下。
获取盒子的宽度的代码,我当时开发时是用canvas计算的,但计算的效果不太理想,后来逛社区,发现了嘉琪coder
大佬分享的文章,我这里就直接把代码搬过来用吧, 想了解的掘友可以直接滑到文章末尾查看。
判断文本内容是否超出标题盒子
// 标题盒子dom
const dom = document.getElementById('test');
// 获取dom元素的padding值
function getPadding(el) {
const domCss = window.getComputedStyle(el, null);
const pl = Number.parseInt(domCss.paddingLeft, 10) || 0;
const pr = Number.parseInt(domCss.paddingRight, 10) || 0;
console.log('padding-left:', pl, 'padding-right:', pr);
return {
left: pl,
right: pr
}
}
// 检测dom元素的宽度,
function checkLength(dom) {
// 创建一个 Range 对象
const range = document.createRange();
// 设置选中文本的起始和结束位置
range.setStart(dom, 0),
range.setEnd(dom, dom.childNodes.length);
// 获取元素在文档中的位置和大小信息,这里直接获取的元素的宽度
let rangeWidth = range.getBoundingClientRect().width;
// 获取的宽度一般都会有多位小数点,判断如果小于0.001的就直接舍掉
const offsetWidth = rangeWidth - Math.floor(rangeWidth);
if (offsetWidth < 0.001) {
rangeWidth = Math.floor(rangeWidth);
}
// 获取元素padding值
const { left, right } = getPadding(dom);
const paddingWidth = left + right;
// status:文本内容是否超出标题盒子;
// width: 标题盒子真实能够容纳文本内容的宽度
return {
status: paddingWidth + rangeWidth > dom.clientWidth,
width: dom.clientWidth - paddingWidth
};
}
通过charCodeAt返回指定位置的字符的Unicode
编码, 返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。
截取和计算文本长度
// 计算文本长度,当长度之和大于等于dom元素的宽度后,返回当前文字所在的索引,截取时会用到。
function calcTextLength(text, width) {
let realLength = 0;
let index = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2 * 14; // 14是字体大小
}
// 判断长度,为true时终止循环,记录索引并返回
if (realLength >= width) {
index = i;
break;
}
}
return index;
}
// 设置文本内容
function setTextContent(text) {
const { status, width } = checkLength(dom);
let str = '';
if (status) {
// 翻转文本
let reverseStr = text.split('').reverse().join('');
// 计算左右两边文本要截取的字符索引
const leftTextIndex = calcTextLength(text, width);
const rightTextIndex = calcTextLength(reverseStr, width);
// 将右侧字符先截取,后翻转
reverseStr = reverseStr.substring(0, rightTextIndex);
reverseStr = reverseStr.split('').reverse().join('');
// 字符拼接
str = `${text.substring(0, leftTextIndex)}...${reverseStr}`;
} else {
str = text;
}
dom.innerHTML = str;
}
最终实现的效果如下:
上面就是此功能的所有代码了,如果想要在本地试验的话,可以在本地新建一个html文件,复制上面代码就可以了。
下面记录下从社区内学到的相关知识:
- js判断文字被溢出隐藏的几种方法;
- JS获取字符串长度的几种常用方法,汉字算两个字节;
1、 js判断文字被溢出隐藏的几种方法
1. Element-plus这个UI框架中的表格组件实现的方案。
通过document.createRange
和document.getBoundingClientRect()
这两个方法实现的。也就是我上面代码中实现的checkLength
方法。
2. 创建一个隐藏的div模拟实际宽度
通过创建一个不会在页面显示出来的dom元素,然后把文本内容设置进去,真实的文本长度与标题盒子比较宽度,判断是否被溢出隐藏了。
function getDomDivWidth(dom) {
const elementWidth = dom.clientWidth;
const tempElement = document.createElement('div');
const style = window.getComputedStyle(dom, null)
const { left, right } = getPadding(dom); // 这里我写的有点重复了,可以优化
tempElement.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
white-space: nowrap;
padding-left:${style.paddingLeft};
padding-right:${style.paddingRight};
font-size: ${style.fontSize};
font-family: ${style.fontFamily};
font-weight: ${style.fontWeight};
letter-spacing: ${style.letterSpacing};
`;
tempElement.textContent = dom.textContent;
document.body.appendChild(tempElement);
const obj = {
status: tempElement.clientWidth + right + left > elementWidth,
width: elementWidth - left - right
}
document.body.removeChild(tempElement);
return obj;
}
3. 创建一个block元素来包裹inline元素
这种方法是在UI框架acro design vue
中实现的。外层套一个块级(block)元素,内部是一个行内(inline)元素。给外层元素设置溢出隐藏的样式属性,不对内层元素做处理,这样内层元素的宽度是不变的。因此,通过获取内层元素的宽度和外层元素的宽度作比较,就可以判断出文本是否被溢出隐藏了。
// html代码
<div class="title" id="test">
<span class="content">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</span>
</div>
// 创建一个block元素来包裹inline元素
const content = document.querySelector('.content');
function getBlockDomWidth(dom) {
const { left, right } = getPadding(dom);
console.log(dom.clientWidth, content.clientWidth)
const obj = {
status: dom.clientWidth < content.clientWidth + left + right,
width: dom.clientWidth - left - right
}
return obj;
}
4. 使用canvas中的measureText方法和TextMetrics对象来获取元素的宽度
通过Canvas 2D渲染上下文(context)可以调用measureText方法,此方法会返回TextMetrics对象,该对象的width
属性值就是字符占据的宽度,由此也能获取到文本的真实宽度,此方法有弊端,比如说兼容性,精确度等等。
// 获取文本长度
function getTextWidth(text, font = 14) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")
context.font = font
const metrics = context.measureText(text);
return metrics.width
}
5. 使用css实现
这种方式来自评论区的掘友@S_mosar
提供的思路。
先来看下效果:
代码如下:
css部分
.con {
font-size: 14px;
color: #666;
width: 600px;
margin: 50px auto;
border-radius: 8px;
padding: 15px;
overflow: hidden;
resize: horizontal;
box-shadow: 20px 20px 60px #bebebe, -20px -20px 60px #ffffff;
}
.wrap {
position: relative;
line-height: 2;
height: 2em;
padding: 0 10px;
overflow: hidden;
background: #fff;
margin: 5px 0;
}
.wrap:nth-child(odd) {
background: #f5f5f5;
}
.title {
display: block;
position: relative;
background: inherit;
text-align: justify;
height: 2em;
overflow: hidden;
top: -4em;
}
.txt {
display: block;
max-height: 4em;
}
.title::before{
content: attr(title);
width: 50%;
float: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
}
html部分
<ul class="con">
<li class="wrap">
<span class="txt">CSS 实现优惠券的技巧 - 2021-03-26</span>
<span class="title" title="CSS 实现优惠券的技巧 - 2021-03-26">CSS 实现优惠券的技巧 - 2021-03-26</span>
</li>
<li class="wrap">
<span class="txt">CSS 测试标题,这是一个稍微有点长的标题,超出一行以后才会有title提示,标题是 实现优惠券的技巧 - 2021-03-26</span>
<span class="title" title="CSS 测试标题,这是一个稍微有点长的标题,超出一行以后才会有title提示,标题是 实现优惠券的技巧 - 2021-03-26">CSS
测试标题,这是一个稍微有点长的标题,超出一行以后才会有title提示,标题是 实现优惠券的技巧 - 2021-03-26</span>
</li>
<li class="wrap">
<span class="txt">CSS 拖拽?</span>
<span class="title" title="CSS 拖拽?">CSS 拖拽?</span>
</li>
<li class="wrap">
<span class="txt">CSS 文本超出自动显示title</span>
<span class="title" title="CSS 文本超出自动显示title">CSS 文本超出自动显示title</span>
</li>
</ul>
思路解析:
- 文字内容的父级标签li设置
line-height: 2;
、overflow: hidden;
、height: 2em;
,因此 li 标签的高度是当前元素字体大小的2倍,行高也是当前字体大小的2倍,同时内容若溢出则隐藏。 - li 标签内部有两个 span 标签,二者的作用分别是:类名为
.txt
的标签用来展示不需要省略号时的文本,类名为.title
用来展示需要省略号时的文本,具体是如何实现的请看第五步。 - 给
.title
设置伪类before
,将伪类宽度设置为50%,搭配浮动float: right;
,使得伪类文本内容靠右,这样设置后,.title
和伪类就会各占父级宽度的一半了。 .title
标签设置text-align: justify;
,用来将文本内容和伪类的内容两端对齐。- 给伪类
before
设置文字对齐方式direction: rtl;
,将伪类内的文本从右向左流动,即right to left
,再设置溢出省略的css样式就可以了。 .title
标签设置了top: -4em
,.txt
标签设置max-height: 4em;
这样保证.title
永远都在.txt
上面,当内容足够长,.txt
文本内容会换行,导致高度从默认2em变为4em,而.title
位置是-4em
,此时正好将.txt
覆盖掉,此时显示的就是.title
标签的内容了。
知识点:text-align: justify;
- 文本的两端(左边和右边)都会与容器的边缘对齐。
- 为了实现这种对齐,浏览器会在单词之间添加额外的空间。这通常意味着某些单词之间的间距会比其他单词之间的间距稍大一些。
- 如果最后一行只有一个单词或少数几个单词,那么这些单词通常不会展开以填充整行,而是保持左对齐。
需要注意的是,
text-align: justify;
主要用于多行文本。对于单行文本,这个值的效果与text-align: left;
相同,因为单行文本无法两端对齐。
2、JS获取字符串长度的几种常用方法
1. 通过charCodeAt判断字符编码
通过charCodeAt获取指定位置字符的Unicode
编码,返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。
function calcTextLength(text) {
let realLength = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2;
}
}
return realLength;
}
2. 采取将双字节字符替换成"aa"的做法,取长度
function getTextWidth(text) {
return text.replace(/[^\x00-\xff]/g,"aa").length;
};
参考文章
4. canvas绘制字体偏上不居中问题、文字垂直居中后偏上问题、measureText方法和TextMetrics对象
来源:juejin.cn/post/7329967013923962895
只会Vue的我,一入职就让用React,用了这个工具库,我依然高效
由于公司最近项目周期紧张,还有一个项目因为人手不够排不开,时间非常紧张,所以决定招一个人来。这不,经过一段时间紧张的招聘,终于招到了一个前端妹子。妹子也坦白过,自己干了3年,都是使用的Vue开发,自己挺高效的。但如果入职想用React的话,会稍微费点劲儿。我说,没事,来就是了,我们都可以教你的。
但入职后发现,这个妹子人家一点也不拖拉,干活很高效。单独分给她的项目,她比我们几个干的还快,每天下班准时就走了,任务按时完成。终于到了分享会了,组长让妹子准备准备,分享一下高效开发的秘诀。
1 初始化React项目
没想到妹子做事还挺认真,分享并没有准备个PPT什么的,而是直接拿着电脑,要给我们手动演示她的高效秘诀。而且是从初始化React项目开发的,这让我们很欣慰。
首先是初始化React项目的命令,这个相信大家都很熟悉了:
第一步:启动终端
第二步:npm install -g create-react-app
第三步:create-react-app js-tool-big-box-website
(注意:js-tool-big-box-website是我们要创建的那个项目名称)
第四步:cd js-tool-big-box-website
(注意:将目录切换到js-tool-big-box-website项目下)
第五步:npm start
然后启动成功后,可以看到这样的界面:
2 开始分享秘诀
妹子说,自己不管使用Vue,还是React,高效开发的秘诀就是 js-tool-big-box 这个前端JS库
首先需要安装一下: npm install js-tool-big-box
2.1 注册 - 邮箱和手机号验证
注册的时候,需要验证邮箱或者手机号,妹子问我们,大家平时怎么验证?我们说:不是有公共的正则验证呢,就是验证一下手机号和邮箱的格式呗,你应该在utils里加了公共方法了吧?或者是加到了表单验证里?
妹子摇摇头,说,用了js-tool-big-box工具库后,会省事很多,可以这样:
import logo from './logo.svg';
import './App.css';
import { matchBox } from 'js-tool-big-box';
function App() {
const email1 = '232322@qq.com';
const email2 = '232322qq.ff';
const emailResult1 = matchBox.email(email1);
const emailResult2 = matchBox.email(email2);
console.log('emailResult1验证结果:', emailResult1); // true
console.log('emailResult2验证结果:', emailResult2); // false
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
js-tool-big-box,使React开发更加高效
</header>
</div>
);
}
export default App;
2.2 验证密码强度值
验证密码强度值的时候呢,妹子问我们,大家平时怎么验证?我们说:不就是写个公共方法,判断必须大于几位,里面是否包含数字,字母,大写字母,特殊符号这样子吗?
妹子摇摇头,说,不是,我们可以这样来验证:
const pwd1 = '12345';
const pwd1Strength = matchBox.checkPasswordStrength(pwd1);
console.log('12345的密码强度值为:', pwd1Strength); // 0
const pwd2 = '123456';
const pwd2Strength = matchBox.checkPasswordStrength(pwd2);
console.log('123456的密码强度值为:', pwd2Strength); // 1
const pwd3 = '123456qwe';
const pwd3Strength = matchBox.checkPasswordStrength(pwd3);
console.log('123456qwe的密码强度值为:', pwd3Strength); // 2
const pwd4 = '123456qweABC';
const pwd4Strength = matchBox.checkPasswordStrength(pwd4);
console.log('123456qweABC的密码强度值为:', pwd4Strength); // 3
const pwd5 = '123@456qwe=ABC';
const pwd5Strength = matchBox.checkPasswordStrength(pwd5);
console.log('123@456qwe=ABC的密码强度值为:', pwd5Strength); // 4
2.3 登录后存localStorage
登录后,需要将一些用户名存到localStorage里,妹子问,我们平时怎么存?我们说:就是直接拿到服务端数据后,存呗。妹子问:你们加过期时间不?我们说:有时候需要加。写个公共方法,传入key值,传入value值,传个过期时间,大家不都是这样?
妹子摇摇头,说,不是,我们可以这样来存:
import { storeBox } from 'js-tool-big-box';
storeBox.setLocalstorage('today', '星期一', 1000*6);
2.4 需要判断是否手机端浏览器
我们市场需要判断浏览器是否是手机端H5浏览器的时候,大家都怎么做?我们说:就是用一些内核判断一下呗,写好方法,然后在展示之处判断一下,展示哪些组件?不是这样子吗?
妹子又问:我这个需求,老板比较重视微信内置的浏览器,这样大家写的方法是不是就比较多了?我们说,那再写方法,针对微信内置浏览器的内核做一下判断呗。
妹子摇摇头,说,那样得写多少方法啊,可以用这个方法,很全面的:
如果你单纯的只是想判断一下是否是手机端浏览器,可以这样:
import { browserBox } from 'js-tool-big-box';
const checkBrowser = browserBox.isMobileBrowser();
console.log('当前是手机端浏览器吗?', checkBrowser);
如果你需要更详细的,根据内核做一些判断,可以这样:
const info = browserBox.getBrowserInfo();
console.log('=-=-=', info);
这个getBrowserInfo方法,可以获取更详细的ua,浏览器名字,以及浏览器版本号
2.5 日期转换
妹子问,大家日常日期转换怎么做?如果服务端给的是一个时间戳的话?我们说:不就是引入一个js库,然后就开始使用呗?
妹子问:这次产品的要求是,年月日中间不是横岗,也不是冒号,竟然要求我显示这个符号 “~” ,也不是咋想的?然后我们问:你是不是获取了年月日,然后把年月日中间拼接上了这个符号呢?
妹子摇摇头,说,你可以这样:
import { timeBox } from 'js-tool-big-box';
const dateTime2 = timeBox.getFullDateTime(1719220131000, 'YYYY-MM-DD', '~');
console.log(dateTime2); // 2024~06~24
2.6 获取数据的详细类型
妹子问,大家日常获取数据的类型怎么获取?我们说,typeof呀,instanceof呀,或者是
Object.prototype.toString.call 一下呗,
妹子摇摇头,说,你可以这样:
import { dataBox } from 'js-tool-big-box';
const numValue = 42;
console.log('42的具体数据类型:', dataBox.getDataType(numValue)); // [object Number]
const strValue = 'hello';
console.log('hello的具体数据类型:', dataBox.getDataType(strValue)); // [object String]
const booleanValue = true;
console.log('true的具体数据类型:', dataBox.getDataType(booleanValue)); // [object Boolean]
const undefinedValue = undefined;
console.log('undefined的具体数据类型:', dataBox.getDataType(undefinedValue)); // [object Undefined]
const nullValue = null;
console.log('null的具体数据类型:', dataBox.getDataType(nullValue)); // [object Null]
const objValue = {};
console.log('{}的具体数据类型:', dataBox.getDataType(objValue)); // [object Object]
const arrayValue = [];
console.log('[]的具体数据类型:', dataBox.getDataType(arrayValue)); // [object Array]
const functionValue = function(){};
console.log('function的具体数据类型:', dataBox.getDataType(functionValue)); // [object Function]
const dateValue = new Date();
console.log('date的具体数据类型:', dataBox.getDataType(dateValue)); // [object Date]
const regExpValue = /regex/;
console.log('regex的具体数据类型:', dataBox.getDataType(regExpValue)); // [object RegExp]
2.8 更多
估计妹子也是摇头摇的有点累了,后来演示的就快起来了,我后来也没听得太仔细,大概有,
比如我们做懒加载的时候,判断某个元素是否在可视范围内;
比如判断浏览器向上滚动还是向下滚动,距离底部和顶部的距离;
比如某个页面,需要根据列表下载一个excel文件啦;
比如生成一个UUID啦;
比如后面还有将小写数字转为大写中文啦,等等等等
3 最后
分享完了第二天,妹子就没来,我们还准备请教她具体js-tool-big-box的使用心得呢。据说是第一天分享的时候,摇头摇得把脖子扭到了,希望妹子能早日康复,早点来上班。
最后告诉你个消息:
js-tool-big-box的npm地址是(js-tool-big-box 的npm地址)
js-tool-big-box的git仓库地址(js-tool-big-box的代码仓库地址)
来源:juejin.cn/post/7383650248265465867
绑定大量的的v-model,导致页面卡顿的解决方案
绑定大量的的v-model,导致页面卡顿的解决方案
设计图如下:
页面布局看着很简单使用element组件,那就完蛋了,因为是大量的数据双向绑定,所以使用组件,延迟非常高,高到什么程度,请求100条数据到渲染到页面上,要10-12s,特别是下拉选择的时候,延迟都在2-3s,人麻了老铁!!!
卡顿的原因很长一段时间都是在绑定v-model,为什么绑定v-model会很卡呢,请求到的每一条数据有14个数据需要绑定v-model,每次一请求就是100个打底,那就是1400个数据需要绑定v-model;而且组件本身也有延迟,所以这个方案不能采用,那怎么做呢?
我尝试采用原生去写,写着写着,哎解决了!!!惊呆了
做完后100条数据页面渲染不超过2s,毕竟还是需要绑定v-model,能在2s内,我还是能接受的吧;选择和输入延迟基本没有
下面就来展示一下我的代码,写的不好看着玩儿就好了:
请求到的数据:
methods这两个事件做的什么事儿呢,就是手动将数据绑定到数据上去也就是row上如图:
当然还有很多解决方案
来源:juejin.cn/post/7392248233222881316
无框架,跨框架!时隔两年,哈啰Quark Design迎来重大特性升级!
引言
历经1年多迭代,Quarkd 2.0 版本正式发布,这是自 Quarkd 开源以来第二个重大版本。本次升级主要实现了组件外部可以穿透影子Dom,修改组件内部元素的任何样式。
- (迁移后)最新官网:quark-ecosystem.github.io/quarkd-docs
- Github 地址:github.com/hellof2e/qu…
Quark Design 介绍
Quark(夸克) Design 是由哈啰平台 UED 和增长&电商前端团队联合打造的一套面向移动端的跨框架 UI 组件库。与业界第三方组件库不一样,Quark Design 底层基于 Web Components 实现,它能做到一套代码,同时运行在各类前端框架/无框架中。
前端各类框架技术发展多年,很多公司存量前端项目中必定存在各类技术栈。为了解决各类不同技术栈下UI交互统一,我们开发了这套UI组件库。
之前技术瓶颈
熟悉 quarkd 的开发者都知道其底层基因是 Web Components,从而实现了跨技术栈使用。但Web Components 中的 shadow dom 特性决定了其“孤岛”的特性,组件内部是个独立于外部的小世界,外部无法修改组件内部样式,若要修改内部样式,我们在 quarkd 1.x 版本中采用了 CSS 变量的方式来支援这种做法。
但这种做法依旧局限性非常大,你只能修改预设css变量的指定样式,比如你要修改 Dialog 内容中的字体大小/颜色:

// 使用组件
<quark-dialog class=“dialog” content="生命远不止连轴转和忙到极限,人类的体验远比这辽阔、丰富得多。"></quark-dialog>
// 内部css源码
:host .quark-dialog-content {
font-size: var(--dialog-content-font-size, 14px);
color: var(--dialog-content-color, "#5A6066");
// ... 其它样式
}
这时候,你需要在组件外部书写:
.dialog {
--dialog-content-font-size: 36px;
--dialog-content-color: red;
}
这种做法会带来一些问题,比如当源码中没有指定的css变量,就意味着你无法通过css变量从外面渗透进入组件内部去修改,比如 dialog conent 内的 font-style
。
升级后
得益于 ::part
CSS 伪元素的特性, 我们将 Quarkd 主要 dom 节点进行改造,升级后,你可以通过如下方式来自定义任何组件样式。
custom-element::part(foo) {
/* 样式作用于 `foo` 部分 */
}
::part
可以用来表示在阴影树中任何匹配 part
属性的元素。
该特性已兼容主流浏览器,详情见:mozilla.org # ::part()
用法示例:
// 使用组件
<quark-dialog class=“dialog” content="生命远不止连轴转和忙到极限,人类的体验远比这辽阔、丰富得多。"></quark-dialog>
.dialog::part(body) {
font-size: 24px;
color: #666;
}
.dialog::part(footer) {
font-size: 14px;
color: #333;
}
其它DEMO地址:stackblitz.com/edit/quarkd…
关于升级
Quarkd 2.x 向下兼容所有 1.x 功能及特性,之前的css变量也被保留,所以使用者可以从1.x直接升级到2.x!
One more thing
假如你也想利用 quarkd 底层能力构建属于自己的跨技术栈组件,欢迎使用:
github.com/hellof2e/qu…
最后
感谢在Quarkd迭代期间作出贡献的朋友们,感谢所有使用quarkd的开发者!
来源:juejin.cn/post/7391753478123864091
zero-privacy——uniapp小程序隐私协议弹窗组件
一. 引言
为规范开发者的用户个人信息处理行为,保障用户的合法权益,自2023年9月15日起,对于涉及处理用户个人信息的小程序开发者,微信要求,仅当开发者主动向平台同步用户已阅读并同意了小程序的隐私保护指引等信息处理规则后,方可调用微信提供的隐私接口。
公告地址:关于小程序隐私保护指引设置的公告
developers.weixin.qq.com/miniprogram…
接下来我们将打造一个保姆级的隐私协议弹窗组件
二. 开发调试基础
划重点,看文档,别说为什么没有效果,没有弹窗
1. 更新用户隐私保护指引
小程序管理员或开发者可以根据具体小程序涉及到的隐私相关接口来更新微信小程序后台的用户隐私保护指引,更新并审核通过后就可以进行相关的开发调试工作。仅有在指引中声明所处理的用户信息,才可以调用平台提供的对应接口或组件。若未声明,对应接口或组件将直接禁用。
- ���知道怎么填写隐私协议,看看文档:用户隐私保护指引设置developers.weixin.qq.com/miniprogram…
- 哪些api需要用户点击同意隐私协议才可以使用的看这里:小程序用户隐私保护指引内容介绍developers.weixin.qq.com/miniprogram…
审核时间有人说十几分钟,我自己的给大家参考一下。
审核通过!审核通过!审核通过后才可以开发调试。
2.配置调试字段 "__usePrivacyCheck__": true
- 在 2023 年 9 月 15 号之前,在 app.json 中配置
"__usePrivacyCheck__": true
后,会启用隐私相关功能,如果不配置或者配置为 false 则不会启用。 - 在 2023 年 9 月 15 号之后,不论 app.json 中是否有配置 usePrivacyCheck,隐私相关功能都会启用。
- 所以在基于uni-app开发时,我们在 2023 年 9 月 15 号之前进行相关开发调试则需要在manifest.json文件mp-weixin中添加
"__usePrivacyCheck__": true
- manifest.json文件源码视图
"mp-weixin" : {
"__usePrivacyCheck__": true
},
3. 配置微信开发工具基础库
将调试基础库改为3.0.0以上。具体路径为:
微信开发者工具->详情->本地设置->调试基础库
以上配置完成后,即可看看效果,我在小程序后台设置了剪切板的隐私接口,果然,已经提示没有隐私授权不能使用了。
三. zero-privacy组件介绍
组件下载地址:ext.dcloud.net.cn/plugin?name…
组件的功能和特点
- 支持 居中弹出,底部弹出
- 不依赖第三方弹窗组件,内置轻量动画效果
- 支持自定义触发条件
- 支持自定义主题色
- 组件中最重要的4个api(只需用到前3个):
- wx.getPrivacySetting 查询隐私授权情况 官方链接
- wx.onNeedPrivacyAuthorization 监听隐私接口需要用户授权事件。 官方链接
- wx.openPrivacyContract 跳转至隐私协议页面 官方链接
- wx.requirePrivacyAuthorize 模拟隐私接口调用,并触发隐私弹窗逻辑 官方链接
四. zero-privacy组件使用方法
在uniapp插件市场直接下载导入 uni_modules
后使用即可
- 最直接看到弹窗效果的测试方法
<template>
<view class="container">
<zero-privacy :onNeed='false'></zero-privacy>
</view>
</template>
注意以上是测试方案,不建议实际开发中按上面的方法使用,推荐以下两种方法
- 在小程序首页等tabbar页面直接处理隐私弹窗逻辑
<template>
<view class="container">
<zero-privacy :onNeed='false' :hideTabBar='true'></zero-privacy>
</view>
</template>
- 在页面点击某些需要用到隐私协议后处理隐私弹窗逻辑
<template>
<view class="container">
<view class="btn" @click="handleCopy">
复制
</view>
<zero-privacy></zero-privacy>
</view>
</template>
- 自定义内容使用
<template>
<view class="container">
<zero-privacy title="测试自定义标题" predesc="协议前内容" privacy-contract-name-custom="<自定义名称及括号>" subdesc="协议后内容协议后内容协议后内容. 主动换行"></zero-privacy>
</view>
</template>
五. zero-privacy组件参数说明
参数 | 类型 | 默认值 | 描述 |
---|---|---|---|
position | String | center | 可选 bottom ,从底部弹出 |
color | String | #0396FF | 主颜色: 协议名和同意按钮的背景色 |
bgcolor | String | #ffffff | 弹窗背景色 |
onNeed | Boolean | true | 使用到隐私相关api时触发弹窗,设置为false时初始化弹窗将判断是否需要隐私授权,需要则直接弹出 |
hideTabBar | Boolean | false | 是否需要隐藏tabbar,在首页等tabbar页面使用改弹窗时建议改为true |
title | String | #ffffff | 用户隐私保护提示 |
predesc | String | 使用前请仔细阅读 Ï | 协议名称前的内容 |
subdesc | String | 当您点击同意后,即表示您已理解并同意该条款内容,该条款将对您产生法律约束力。如您拒绝,将无法使用该服务。 | 协议名称后的内容 |
privacyContractNameCustom | String | '' | 自定义协议名称,不传则由小程序自动获取 |
predesc
和 subdesc
的自定义内容,需要主动换行时在内容中添加实体字符
即可
六. zero-privacy组件运行效果
来源:juejin.cn/post/7273803674790150183
领导让前端实习生在网页上添加一个长时间不操作锁定电脑的功能
前情提要
大约一个月前,公司的医疗管理系统终于完工上线。后面一个周一,领导叫大家开会,说后面没有项目进来了,用不了这么多开发人员,原地宣布裁员。再后一周后,花 2000 招了个实习生,工作内容为系统维护。
工作内容
领导:由于我们工作内容很简单,事情轻松,基本就在页面上加加按钮就行,所以工资相对较少一些,是否接受?
实习生小李:能开实习证明吗?
领导:能的。
实习生小李:好的,谢谢老板。
领导:什么时候能入职?
实习生小李:现在。
工作来源
医疗系统是一个比较数据敏感的系统,现在医院那边需要添加一个十分钟时间没有在系统进行操作,则锁定电脑的功能,使用者再次使用时,必须输入密码。客户那边在系统对接群里发出需求时,并没有人回复(PS:人都裁完了),然后老板回复到:好的。
工作安排
领导:小李,我们有个医疗系统,需要添加锁屏功能,你处理一下,两天时间应该没问题吧?
实习生小李:(思索片刻)好的,有代码吗
(4小时之后)
领导:有的,我找下
(第二天10点)
实习生小李:王总,代码找到了没有
(第二天12点)
领导:没代码改不了吗?
实习生小李:(瑟瑟发抖)我试试
(第二天14点)
实习生小李:王总,是那种长时间不操作就锁定系统的功能吗
领导:是的
实习生小李:多久不操作才锁
领导:十分钟,锁了需要输入密码才能使用
实习生小李:但是我们医疗系统没有密码功能
领导:客户电脑有密码啊
实习生小李:是锁电脑系统吗
领导:对
实习生小李:(若有所思)我试试
实现过程
实习生小李:魔镜魔镜,我们有个医疗系统,需要做一个十分钟不操作电脑,就锁定用户电脑系统的功能,在没有源代码的情况下如何实现?
魔镜:好的,在没有源代码的情况下为医疗系统添加十分钟不操作电脑就锁定用户电脑系统的功能,可以使用 sys-shim 实现。
第一步,创建一个目录例如 medical-system
,目录里有以下两个文件:
package.json
文件用来配置 sys-shimpreload.js
用来向医疗系统添加功能
第二步
在 package.json 中编写内容如下
{
"browserArguments": "--disable-web-security --allow-running-insecure-content ",
"form": {
"right": "1300",
"bottom": "800"
},
"page": "https://www.baidu.com/"
}
- browserArguments 用来指定浏览器参数,这里配置为允许跨域以方便注入代码
- form 用来控制窗口如何显示,这里表示窗口大小
- page 表示医疗系统的页面
在 preload.js 中编写内容如下
new Promise(async function () {
window.main = await new window.Sys({ log: true })
// 设置倒计时时间,为了测试方便,这里改为 30 秒
const TIMEOUT = 0.5 * 60 * 1000;
// 声明一个变量来存储 setTimeout 的引用
let timeoutId = null;
// 定义一个函数来重置倒计时并在2分钟后打印日志
function startInactivityCheck() {
// 清除之前的倒计时(如果有的话)
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
// 设置一个新的倒计时
timeoutId = setTimeout(function() {
// 锁定系统
window.main.native.sys.lock()
}, TIMEOUT);
}
// 为 body 元素添加点击事件监听器
document.body.addEventListener('click', function() {
console.log("检测到点击事件,重新开始计时。");
// 重置倒计时
startInactivityCheck();
});
// 初始化倒计时
startInactivityCheck();
})
sys.lock()
方法用于锁定操作系统。
第三步,生成应用程序
npx sys-shim pack --input medical-system
运行该命令后,会在当前目录生成一个名为 medical-system.exe 的可执行文件。它封装了医疗系统这个 web 程序,并在里面添加了锁屏功能。
pack
指定表示打包应用--input
参数表示要打包的目录
--input 参数也可以是线上的网页,比如:
npx sys-shim pack --input https://www.baidu.com/
即可获取一个可以调用操作系统 api 的 web 应用。
交付反馈
用户:以前我们还需要进入浏览器输入网址才能进入系统,现在直接在桌面上就能进入,并且还有安全锁屏功能,非常好!
领导:小李干得不错,但没有在规定的时间内完成,但由于客户反馈不错,就不扣你的考核分了。
实习生小李:(不得其解)谢谢老板。
后记
不知不觉,又到了周五,这是公司技术分享会的时候。当前公司技术人员只有实习生小李,由小李负责技术分享。
宽旷的会议室里,秘书、领导、小李三人面面相觑,小李强忍住尴尬,开始了自己的第一次技术分享:
实习生小李:感谢领导给我的工作机会,在这份工作里,我发现了 sys-shim 这个工具,它可以方便的在已有的 web 页面中添加系统 api,获取调用操作系统层面功能的能力,比如关机、锁屏。
领导:(好奇)那他可以读取电脑上的文件吗?
实习生小李:可以的,它可以直接读取电脑上的文件,例如电脑里面的文档、照片、视频等。
突然领导脸色一黑,看了一眼秘书,并关闭了正在访问的医疗系统,然后在技术分享考核表上写下潦潦草草的几个字:考核分-5
。
续集:托领导大福!前端实习生用 vue 随手写了个系统修复工具,日赚 300
提示
大家可以直接运行这个命令生成 app 体验:
npx sys-shim pack --input https://www.baidu.com/
生成后的 app 可以右键解压,看到内部结构。如果遇到问题,可以在这里提交,方便追溯,我会及时解答的。
参考
来源:juejin.cn/post/7373831659470880806
领导被我的花式console.log吸引了!直接写入公司公共库!
文章的效果,大家可以直接只用云vscode实验一下:juejin.cn/post/738875…
背景简介
这几天代码评审,领导无意中看到了我本地代码的控制台,被我花里胡哨的console打印
内容吸引了!
老板看见后,说我这东西有意思,花里胡哨的,他喜欢!
但是随即又问我,这么花里胡哨的东西,上生产会影响性能吧?我自信的说:不会,代码内有判断的,只有开发环境会打印
!
老板很满意,于是让我给其他前端同事分享一下,讲解下实现思路!最终,这个方法还被写入公司的公用utils库里,供大家使用!
console简介
console 是一个用于调试和记录信息的内置对象, 提供了多种方法,可以帮助开发者输出各种信息,进行调试和分析。
console.log()
用于输出一般信息,大家应该在熟悉不过了。
console.info() :
输出信息,与 console.log 类似,但在某些浏览器中可能有不同的样式。
console.warn() :
输出警告信息,通常会以黄色背景或带有警告图标的样式显示。
console.error() :
输出错误信息,通常会以红色背景或带有错误图标的样式显示。
console.table() :
以表格形式输出数据,适用于数组和对象。
例如:
const users = [
{ name: '石小石', age: 18 },
{ name: '刘亦菲', age: 18 }
];
console.table(users);
通过上述介绍,我们可以看出,原生的文本信息、警告信息、错误信息、数组信息打印出来的效果都很普通,辨识度不高!现在我们通过console.log来实现一些花里花哨的样式!
技术方案
console.log()
console.log() 可以接受任何类型的参数,包括字符串、数字、布尔值、对象、数组、函数等。最厉害的是,它支持占位符!
常用的占位符:
- %s - 字符串
- %d or %i - 整数
- %f - 浮点数
- %o - 对象
- %c - CSS 样式
格式化字符串
console.log() 支持类似于 C 语言 printf 函数的格式化字符串。我们可以使用占位符来插入变量值。
const name = 'Alice';
const age = 30;
console.log('Name: %s, Age: %d', name, age); // Name: Alice, Age: 30
添加样式
可以使用 %c 占位符添加 CSS 样式,使输出内容更加美观。
console.log('%c This is a styled message', 'color: red; font-size: 20px;');
自定义样式的实现,其实主要是靠%c 占位符添加 CSS 样式实现的!
实现美化的信息打印
基础信息打印
我们创建一个prettyLog方法,用于逻辑编写
// 美化打印实现方法
const prettyLog = () => {
const isEmpty = (value: any) => {
return value == null || value === undefined || value === '';
};
const prettyPrint = (title: string, text: string, color: string) => {
console.log(
`%c ${title} %c ${text} %c`,
`background:${color};border:1px solid ${color}; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`,
`border:1px solid ${color}; padding: 1px; border-radius: 0 2px 2px 0; color: ${color};`,
'background:transparent'
);
};
// 基础信息打印
const info = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Info' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#909399');
};
return {
info
};
};
上述代码定义了一个 prettyLog 函数,用于美化打印信息到控制台。通过自定义样式,输出信息以更易读和美观的格式呈现。
我们使用一下看看效果
// 创建打印对象
const log = prettyLog();
// 不带标题
log.info('这是基础信息!');
//带标题
log.info('注意看', '这是个男人叫小帅!');
info 方法用于输出信息级别的日志。它接受两个参数:textOrTitle 和 content。如果只提供一个参数,则视为内容并设置默认标题为 Info;如果提供两个参数,则第一个参数为标题,第二个参数为内容。最后调用 prettyPrint 方法进行输出。
错误信息打印
const prettyLog = () => {
const isEmpty = (value: any) => {
return value == null || value === undefined || value === '';
};
const prettyPrint = (title: string, text: string, color: string) => {
// ...
};
const info = (textOrTitle: string, content = '') => {
// ...
};
const error = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Error' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#F56C6C');
};
// retu;
return {
info,
error,
};
};
// 创建打印对象
const log = prettyLog();
log.error('奥德彪', '出来的时候穷 生活总是让我穷 所以现在还是穷。');
log.error('前方的路看似很危险,实际一点也不安全。');
成功信息与警告信息打印
// 美化打印实现方法
const prettyLog = () => {
const isEmpty = (value: any) => {
return value == null || value === undefined || value === '';
};
const prettyPrint = (title: string, text: string, color: string) => {
console.log(
`%c ${title} %c ${text} %c`,
`background:${color};border:1px solid ${color}; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`,
`border:1px solid ${color}; padding: 1px; border-radius: 0 2px 2px 0; color: ${color};`,
'background:transparent'
);
};
const info = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Info' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#909399');
};
const error = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Error' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#F56C6C');
};
const warning = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Warning' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#E6A23C');
};
const success = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Success ' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#67C23A');
};
// retu;
return {
info,
error,
warning,
success
};
};
// 创建打印对象
const log = prettyLog();
log.warning('奥德彪', '我并非无路可走 我还有死路一条! ');
log.success('奥德彪', '钱没了可以再赚,良心没了便可以赚的更多。 ');
实现图片打印
// 美化打印实现方法
const prettyLog = () => {
// ....
const picture = (url: string, scale = 1) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
if (ctx) {
c.width = img.width;
c.height = img.height;
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0);
const dataUri = c.toDataURL('image/png');
console.log(
`%c sup?`,
`font-size: 1px;
padding: ${Math.floor((img.height * scale) / 2)}px ${Math.floor((img.width * scale) / 2)}px;
background-image: url(${dataUri});
background-repeat: no-repeat;
background-size: ${img.width * scale}px ${img.height * scale}px;
color: transparent;
`
);
}
};
img.src = url;
};
return {
info,
error,
warning,
success,
picture
};
}
// 创建打印对象
const log = prettyLog();
log.picture('https://nimg.ws.126.net/?url=http%3A%2F%2Fdingyue.ws.126.net%2F2024%2F0514%2Fd0ea93ebj00sdgx56001xd200u000gtg00hz00a2.jpg&thumbnail=660x2147483647&quality=80&type=jpg');
上述代码参考了其他文章:Just a moment...
url可以传支持 base64,如果是url链接,图片链接则必须开启了跨域访问才能打印
实现美化的数组打印
打印对象或者数组,其实用原生的console.table比较好
const data = [
{ id: 1, name: 'Alice', age: 25 },
{ id: 2, name: 'Bob', age: 30 },
{ id: 3, name: 'Charlie', age: 35 }
];
console.table(data);
当然,我们也可以伪实现
const table = () => {
const data = [
{ id: 1, name: 'Alice', age: 25 },
{ id: 2, name: 'Bob', age: 30 },
{ id: 3, name: 'Charlie', age: 35 }
];
console.log(
'%c id%c name%c age',
'color: white; background-color: black; padding: 2px 10px;',
'color: white; background-color: black; padding: 2px 10px;',
'color: white; background-color: black; padding: 2px 10px;'
);
data.forEach((row: any) => {
console.log(
`%c ${row.id} %c ${row.name} %c ${row.age} `,
'color: black; background-color: lightgray; padding: 2px 10px;',
'color: black; background-color: lightgray; padding: 2px 10px;',
'color: black; background-color: lightgray; padding: 2px 10px;'
);
});
};
但是,我们无法控制表格的宽度,因此,这个方法不太好用,不如原生。
仅在开发环境使用
// 美化打印实现方法
const prettyLog = () => {
//判断是否生产环境
const isProduction = import.meta.env.MODE === 'production';
const isEmpty = (value: any) => {
return value == null || value === undefined || value === '';
};
const prettyPrint = (title: string, text: string, color: string) => {
if (isProduction) return;
// ...
};
// ...
const picture = (url: string, scale = 1) => {
if (isProduction) return;
// ...
};
// retu;
return {
info,
error,
warning,
success,
picture,
table
};
};
我们可以通过import.meta.env.MODE 判断当前环境是否为生产环境,在生产环境,我们可以禁用信息打印!
完整代码
// 美化打印实现方法
const prettyLog = () => {
const isProduction = import.meta.env.MODE === 'production';
const isEmpty = (value: any) => {
return value == null || value === undefined || value === '';
};
const prettyPrint = (title: string, text: string, color: string) => {
if (isProduction) return;
console.log(
`%c ${title} %c ${text} %c`,
`background:${color};border:1px solid ${color}; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`,
`border:1px solid ${color}; padding: 1px; border-radius: 0 2px 2px 0; color: ${color};`,
'background:transparent'
);
};
const info = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Info' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#909399');
};
const error = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Error' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#F56C6C');
};
const warning = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Warning' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#E6A23C');
};
const success = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Success ' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#67C23A');
};
const table = () => {
const data = [
{ id: 1, name: 'Alice', age: 25 },
{ id: 2, name: 'Bob', age: 30 },
{ id: 3, name: 'Charlie', age: 35 }
];
console.log(
'%c id%c name%c age',
'color: white; background-color: black; padding: 2px 10px;',
'color: white; background-color: black; padding: 2px 10px;',
'color: white; background-color: black; padding: 2px 10px;'
);
data.forEach((row: any) => {
console.log(
`%c ${row.id} %c ${row.name} %c ${row.age} `,
'color: black; background-color: lightgray; padding: 2px 10px;',
'color: black; background-color: lightgray; padding: 2px 10px;',
'color: black; background-color: lightgray; padding: 2px 10px;'
);
});
};
const picture = (url: string, scale = 1) => {
if (isProduction) return;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
if (ctx) {
c.width = img.width;
c.height = img.height;
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0);
const dataUri = c.toDataURL('image/png');
console.log(
`%c sup?`,
`font-size: 1px;
padding: ${Math.floor((img.height * scale) / 2)}px ${Math.floor((img.width * scale) / 2)}px;
background-image: url(${dataUri});
background-repeat: no-repeat;
background-size: ${img.width * scale}px ${img.height * scale}px;
color: transparent;
`
);
}
};
img.src = url;
};
// retu;
return {
info,
error,
warning,
success,
picture,
table
};
};
// 创建打印对象
const log = prettyLog();
来源:juejin.cn/post/7371716384847364147
fabric.js实战
一、业务需求
- 给定一个图片作为参考
- 可配置画笔颜色、粗细
- 可通过手势生成实际轨迹
- 可通过手势来圈选轨迹
- 可撤销、删除轨迹
- 可操作轨迹
- 可缩放、拖动
- 背景网格,且背景网格可缩放和拖动
- 参考图片可缩放、偏移
- 手势处理系统,单指绘制、双指缩放、三指拖动
- 需要一个禁止绘制区域,方便用户在平板上有手掌的支撑区域
二、技术选型
因为涉及到轨迹
、操作轨迹
两点,
svg无法满足大量且复杂的轨迹,canvas没法操作轨迹,所以从 fabricjs/konva 中选型,
因 fabricjs 使用人数更多,所以采取了其作为技术选型。
三、fabric 原理
- 通过其内置的几何对象来创建图形
- 维护一个对象树
- 将对象树通过 canvas-api 绘制在实际的 canvas 上
因此,fabric 能做非常多的优化手段
- 已渲染的节点可通过
子canvas
做缓存 - 对比新旧对象树能做差值更新
- 虚拟画布,类似于虚拟滚动,只渲染可视化的区域
四、模块拆分
- header:与业务相关
- toolBar:工具栏
- sidebar:与业务相关
- canvas:绘制画板
五、架构设计
六、问题收集
6.1 性能问题
- 圈选是实时的,即判断一个多边形是否相交于或包含于一条复杂轨迹,因为使用了射线法,当遇到大量轨迹的时候,可能会卡顿。目前做了多重优化手段,比如函数节流、先稀疏复杂轨迹的点、然后判断图形的占位区域是否相交、然后判断图形的线段之间是否相交、然后判断是否包含关系。
- 轨迹的实时生成,在一长串touchmove事件中,使用一个初始化的polyline,后续更改其点集,这样只需要实例化一个对象,性能高。
- touchmove回调里执行复杂的逻辑,这会阻塞touchmove的触发频率,我们将touchmove里的回调通过settimeout放到异步队列中,这样就剥离了touchmove
事件层
和 回调函数处理层
,这样touchmove的触发频率就不会被影响
6.2 禁止绘制区域
该需求无法实现,因为当我们手掌放在平板上时,会触发系统级别的误触识别算法,阻止所有的触摸事件,所以我们没办法在页面上实现该功能。
touch事件
一个屏幕上可以有多个touch触摸点,这些触摸点绑定的target是可以多个的
touchEvent对象有如下重要属性
- targetTouches:只在当前target(比如某div)上触发的touch触摸点
- touches:在屏幕上触发的所有touch触目点
特别注意点:
- 没有类似鼠标的mouseout事件,所以你一开始是点在div上的,然后移出div的范围后,依旧触发touchmove事件
平板调试手段-chrome浏览器
- 平板安装chrome移动版
- 电脑安装chrome + chrome插件:inspect devices
- 平板开启开发者选项,然后允许usb调试
- usb链接平板和chrome
- 平板和电脑都打开chrome
- 电脑启动插件,然后就能控制平板的chrome,并且对该chrome访问的网页进行调试
- 很好用哇
来源:juejin.cn/post/7278931998650744869