注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

为什么可以通过process.env.NODE_ENV来区分环境

web
0.背景 通常我们在开发中需要区分当前代码的运行环境是dev、test、prod环境,以便我们进行相对应的项目配置,比如是否开启sourceMap,api地址切换等。而我们区分环境一般都是通过process.env.NODE_ENV,那么为什么process....
继续阅读 »

0.背景


通常我们在开发中需要区分当前代码的运行环境是dev、test、prod环境,以便我们进行相对应的项目配置,比如是否开启sourceMap,api地址切换等。而我们区分环境一般都是通过process.env.NODE_ENV,那么为什么process.env.NODE_ENV可以区分环境呢?是我们给他配置的,还是他可以自动识别呢?


1.什么是process.env.NODE_ENV


process.env属性返回一个包含用户环境信息的对象。


在node环境中,当我们打印process.env时,发现它并没有NODE_ENV这一个属性。实际上,process.env.NODE_ENV是在package.json的scripts命令中注入的,也就是NODE_ENV并不是node自带的,而是由用户定义的,至于为什么叫NODE_ENV,应该是约定成俗的吧。


2.通过package.json来设置node环境中的环境变量


如下为在package.json文件的script命令中设置一个变量NODE_ENV


{
"scripts": {
"dev": "NODE_ENV=development webpack --config webpack.dev.config.js"
}
}

执行对应的webpack.config.js文件


// webpack.config.js
console.log("【process.env】", process.env.AAA);

但是在index.jsx中也就是浏览器环境下的文件中打印process.env就会报错,如下:
image.png
可以看到NODE_ENV被赋值为development,当执行npm run dev时,我们就可以在 webpack.dev.config.js脚本中以及它所引入的脚本中访问到process.env.NODE_ENV,而无法在其它脚本中访问。原因就是前文提到的peocess.env是Node环境的属性,浏览器环境中index.js文件不能够获取到。


3.使用webpack.DefinePlugin插件在业务代码中注入环境变量


这个时候我们就存在一个解决方法,通过webpack中的DefinePlugin来设置一个全局变量,这样所有的打包的js文件都可以访问到这个全局变量了。


const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"development"'
})
]
}



使用DefinePlugin注意点
webpack.definePlugins本质上是打包过程中的字符串替换,比如我们刚才定义的__WEBPACK__ENV:JSON.stringify('packages')
在打包过程中,如果我们代码中使用到了__WEPBACK__ENVwebpack会将它的值替换成为对应definePlugins中定义的值,本质上就是匹配字符串替换,并不是传统意义上的环境变量process注入。

如下图所示:
image.png
由上图可知:仔细对比这两段代码第一个问题的答案其实已经很明了了,针对definePlugin这个插件我们使用它定义key:value全局变量时,他会将value进行会直接替换文本。所以我们通常使用JSON.stringify('pacakges')或者"'packages'"



作者:会飞的特洛伊
来源:juejin.cn/post/7345760019319390248
收起阅读 »

Unocss 写 border太费劲?试试这样

web
在css中, border 是高频使用的一个属性,但它的写法有非常非常多。按属性分类,border 属性可以分为以下几类:border-width:设置边框的宽度。border-style:设置边框的样式。border-color:设置边框的颜色。按方向分类,...
继续阅读 »

在css中, border 是高频使用的一个属性,但它的写法有非常非常多。

按属性分类,border 属性可以分为以下几类:

  • border-width:设置边框的宽度。
  • border-style:设置边框的样式。
  • border-color:设置边框的颜色。

按方向分类,border 属性可以分为以下几类:

  • border-top:设置上边框的宽度、样式和颜色。
  • border-right:设置右边框的宽度、样式和颜色。
  • border-bottom:设置下边框的宽度、样式和颜色。
  • border-left:设置左边框的宽度、样式和颜色。

一般情况下我们会直接使用 border 属性,它是一个简写属性,可以同时设置边框的宽度、样式和颜色。

div {
border: 1px solid red;
}

如果我们要单独设置某个方向边框的某个属性,可以使用以下属性:

  • border-top-width:设置上边框的宽度。
  • border-top-style:设置上边框的样式。
  • border-top-color:设置上边框的颜色。
div {
border-top-width: 1px;
border-top-style: solid;
border-top-color: red;
}

我们也可以单独设置某个方向的边框宽度、样式和颜色,可以使用以下属性:

  • border-top:设置上边框的宽度、样式和颜色。
  • border-right:设置右边框的宽度、样式和颜色。
  • border-bottom:设置下边框的宽度、样式和颜色。
  • border-left:设置左边框的宽度、样式和颜色。
div {
border-top: 1px solid red;
}

以上的写法,最常用的还是简写方式,如:

  • 简写属性:border: 1px solid red;
  • 单个方向的属性:border-top: 1px solid red;

在 unocss 中,我们怎么写边框呢?

可以使用 border 的预设,比如:


<div class="b">div>


<div class="b-2px">div>


<div class="b b-solid">div>


<div class="b b-red">div>


<div class="b b-dashed b-red">div>

为什么只设置 boder-width: 1px; 也能看到边框效果呢?这是因为浏览器为每个元素都设置了一个默认的边框样式,只是 boder-width 的默认值是 0px,所以最少只需要设置 border-width 就能看到边框效果

image-1.png

当然 unocss 预设中边框的写法也可以单独定义每个方向的宽度、样式和颜色,比如


<div class="b-l">div>


<div class="b b-l-dashed">div>


<div class="b b-l-red">div>


<div class="b-l-2px b-l-red b-l-dashed">div>

由上可知 unocss 的 border 预设其实就是将 border-width 、 border-style 和 border-color 分别定义,然后又可以各自组合上 left、right、top 和 bottom,这样就可以控制每一个方向的边框

这样写当然没什么问题,也非常的灵活,但仔细想想是不是过于麻烦了呢,为什么会觉得麻烦呢?原因就是这样写没有利用到 border 的简写方式,比如 左边 2px red dashed 的边框 我们其实是可以简写成这样的:

div {
border-left: 2px dashed red;
}

甚至我们写行内样式也比 b-l-2px b-l-red b-l-dashed 这种写法更简洁易懂

<div style="border-left: 2px dashed red;">div>

那么,有没有办法不写 css 也能做到这么简洁呢,并且还不能损失它的灵活性

当然有,答案就是自定义rules

// unocss配置文件, uno.config.js|ts

import { defineConfig, presetUno } from 'unocss'
const DIRECTION_MAPPIINGS = { t: 'top', r: 'right', b: 'bottom', l: 'left' }

export default defineConfig({
presets: [
presetUno,
],
rules: [
[
/^b(t|r|b|l|d)-(.*)/,
([, d, c]) => {
const direction = DIRECTION_MAPPIINGS[d] || ''
const p = direction ? `border-${direction}` : 'border'
const attrs = c.split('_')
if (
// 属性中不包含 border-style 则默认 solid
!attrs.some((item) =>
/^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/.test(item),
)
) {
attrs.push('solid')
}
// 属性中不包含 border-width 则默认 1px
if (!attrs.some((item) => /^\d/.test(item))) {
attrs.push('1px')
}
return {
[p]: attrs.join(' '),
}
},
],
],
})

怎么用呢?

  1. 完整的写法

<div class="bd-2px_dashed_red">div>

<div class="bl-2px_dashed_red">div>

<div class="br-2px_dashed_red">div>
  1. 缺省的写法

border-width 、 border-style 和 border-color 都可以缺省(但最少写一个),border-style 默认 solid,border-width 默认 1px,border-color 默认继承父容器的 color


<div class="bd-2px">div>


<div class="bd-red">div>


<div class="bd-dashed">div>


<div class="bl-2px">div>


<div class="bl-red">div>


<div class="bl-dashed">div>


<div class="bl-2px">div>


<div class="bl-red">div>


<div class="bl-dashed">div>

可以看出这种写法是不是更简洁、更容易理解呢!

为什么 border-width 、 border-style 和 border-color 最少得写一个,全部缺省不是更好吗?

答: unocss 的默认写法就是可以全缺省的,没必要多此一举了,如 b b-r b-l b-t b-b

为什么用 bd 表示 border 而不用 b?

主要是为了跟 unocss 的默认写法区分开来,其次 bd 也勉强符合 border 语义的简写。

以上就是本篇文章分享的所有内容了,希望对大家有帮助。


关注我,大脸怪将持续分享更多实用知识和技巧


作者:大脸怪
来源:juejin.cn/post/7348473946582646784
收起阅读 »

hover后元素边框变粗,样式被挤压?一招帮你解决,快收藏备用!

web
背景简介 大家好,我是石小石!最近开发中遇到这样一个需求: hover卡片后,边框由原来的1px变成2px,且颜色由灰色变为蓝色。 hover改变样式,这太easy了! .work-order-card { padding: 8px 16px 1...
继续阅读 »

背景简介


大家好,我是石小石!最近开发中遇到这样一个需求:



hover卡片后,边框由原来的1px变成2px,且颜色由灰色变为蓝色。




hover改变样式,这太easy了!


.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #e1e5eb;
width: 296px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}

但实际做完后,我们会发现一个问题,样式不够丝滑:



hover后元素的内边距发生变化,中间区域尺寸被挤压,从而导致过渡动画很生硬!




这个问题在前端开发中应该比较常见,我就简单分享一下自己的解决方案吧。


如何解决


要想解决这个问题,本质就是让hover前后,中间核心区域的位置不随边框、边距的变化而变化


场景一:边框从无到有


最简单的场景,就是一开始没有边框,后来有边框。



这种最容易处理,我们只需要给盒子设置和hover后同样粗细的边框,颜色设置透明即可。


.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 2px solid transparent;
width: 296px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}

场景二:边框粗细发生变化


比较麻烦的场景,如文章一开始说的场景,hover后,边框从1px变成2px。这种情况,hover盒子的padding一定会变化(注意大盒子尺寸是固定的),必然会导致内部元素被挤压,位置改变。



动态padding


当然,聪明的你可能计算hover后的padding


.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
&:hover {
padding: 7px 15px 15px 15px;
border: 2px solid #64A6F7;
}
}

不加过渡动画时,看着挺不错



但加上transition过渡效果,那就原形毕露!


.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
transition: all 0.2s ease;
&:hover {
padding: 7px 15px 15px 15px;
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}


不设置padding,居中核心内容


如果盒子的尺寸都能确定,最好的方式,还是使用flex布局,让中间的核心区域(下图红色部分)永远居中!这样,无论边框怎么变,中间的位置永远不变,自然就解决了元素被挤压的问题!



<div class="work-order-card">
<div class="center-box">
<!-- 子元素 -->
</div>

</div>

.work-order-card {
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
height: 214px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
.center-box{
width: 264px;
}
}



注意:这种实现方式,要求最外层的盒子宽高是固定的,内部盒子宽度也需要固定。



总结


针对hover某个元素,其边框变粗导致内部元素被挤压的问题,这篇文章提供了三个解决方案:



  • 边框从无到有,改变原始边框透明度即可

  • 边框hover尺寸变化:



    1. 如果不要求过渡效果,hover后可以计算padding

    2. 如果需要过渡效果,使用felx布局居中核心区域即可




如果大家有更好的方案,可以评论区分享一下。


作者:石小石Orz
来源:juejin.cn/post/7431999862919921675
收起阅读 »

autoUno:最直觉的UnoCSS预设方案

web
起因可能你跟我一样头一次听说原子化CSS时,觉得写预设 class 听起来是一件极蠢的事,感觉这是在开倒车,因为我们都经历过 Bootstrap(其实不属于原子化) 的时代。于是在这个概念刚刚在国内爆火的时候,我对其是嗤之以鼻的,当时我想象中的原子化:只有带鱼...
继续阅读 »

起因

可能你跟我一样头一次听说原子化CSS时,觉得写预设 class 听起来是一件极蠢的事,感觉这是在开倒车,因为我们都经历过 Bootstrap(其实不属于原子化) 的时代。

于是在这个概念刚刚在国内爆火的时候,我对其是嗤之以鼻的,当时我想象中的原子化:

image.png

只有带鱼屏才装得下。

而实际上的原子化:

image.png

在实际使用中,我们往往不会将所有的样式都使用原子化实现(当然也可以这么干)。

举一个例子,在你开发时,你按照自己习惯,做了一个近乎完美的布局,你的 class 已经写的非常棒,页面看起来赏心悦目,而此时,产品告诉你要在某个按钮的下面加一句提示,为了不破坏你的完美代码,又或者是样式无需太多的 css,你可能会选择直接写行内样式。此时原子化的魅力就体现了出来,只需要简单的寥寥几字,就把准确的 css 表达出来了,而无需再抽出一个无意义的 class。

为什么是 UnoCSS

在 tailwindCSS、windiCSS 之后,一位长发飘飘的帅小伙,发布了一款国产原子化工具 UnoCSS。虽然大家可能很熟悉它,我还是想啰嗦几句。

UnoCSS 的优势

CSS原子化在前端的长河中,可谓是一个婴儿:

“原子化 CSS”(Atomic CSS)的概念最早可以追溯到 2014 年,由 Nicolas Gallagher 在他的博客文章 “About HTML semantics and front-end architecture” 中提出。他在文章中提到了一种新的 CSS 方法论,即使用“单一功能类”(Single-purpose Classes)来替代传统的基于组件或块的样式管理方式。这种方法的核心思想是,将每一个 CSS 类设计为仅包含一种样式规则或一组简单的样式,以便更好地复用和组合样式,从而减少冗余代码。这一思想成为后来原子化 CSS 的基础。同年,第一个原子化框架 ACSS(Atomic CSS)发布了,由 Yahoo 团队开发。

ACSS 的推出激发了 Utility-First CSS 框架的兴起,最终在 Tailwind CSS 等项目中得到广泛应用。

Tailwind 和 Windi CSS 虽然也支持自定义,但它们的定制性主要体现在配置文件的扩展上,如自定义颜色、间距、字体、断点等,且在设计上仍然偏向于固定的原子类名体系。这两者可以通过配置文件生成新的实用类名,这种方式显然使他们有了不可避免的局限性。

而 UnoCSS 则有着高度定制化的特性,主要体现在它的灵活性插件化设计,使其可以自由定义和扩展类名、行为,甚至能模拟其他 CSS 框架。相比之下,Tailwind CSS 和 Windi CSS 在设计上更偏向于固定的、基于配置的实用类体系,而 UnoCSS 则提供了更多自由度。

这样的设计也使得 UnoCSS 有着天然的性能优势,UnoCSS 支持基于正则表达式的动态类名解析,允许开发者定义自定义的样式规则。例如,可以通过简单的正则规则为特定样式创建动态的类,而不需要预先定义所有的类名。这使得 UnoCSS 的 CSS 小而精,据官网介绍,它无需解析,无需AST,无需扫描。它比Windi CSS或Tailwind CSS JIT快5倍!

原子化的通病

从原子化的概念本身出发,我们不难发现,这种做法有一种通病,就是我除了要知道基本的 CSS 之外,还需要知道原子化类库的预定义值,也就是说,我们需要提前知道写哪些 class 是有效的,哪些是无法识别的。

在现代化编辑器中,我们可以使用编辑器扩展来识别这些类名。

比如在 VSCode 中的 UnoCSS 扩展

image.png

image.png

它可以在 HTML 中提示开发者这个类名下将解析出的 css

image.png

也可以进行自动补全。

是的这很方便,但是我们依旧要大概知道这些 预设 class 的写法,对其不熟悉的的用户,可能还要翻阅文档来书写。

全自动的 UnoCSS

我就在想,为什么没有一个原子化库,可以支持智能识别呢,比如我想实现一个行高

按照上图中的预设,我需要依次打出 l、i、n、e、-,才匹配到了第一个和行高有关的属性,如果情况再搞笑一点,我根本不知道 line 怎么写怎么办?

我相信很多同学可能会有共情,因为我们在写传统 CSS 时,一般是打出我们自己熟悉的几个字母,依靠编辑器的自动补全(emmet)来做的,像这样:

image.png

嗯,看起来很舒服,只需要打出少量的字母,就可以识别到了。

先看一下传统的字面量 Uno 预设

传统预设

image.png

我们可以自定义一些个人比较熟悉的简写。

或者写一些正则,来支持更复杂的数值插入等

image.png

好吧,看到这我都上不来气儿了,这我要写到什么时候去?

确实,一个一个的去自定义规则,花费了非常多的精力和时间,那我们看一下社区有没有提供相对通用的规则呢, UnoCSS社区预设

image.png

好吧,可能有,但是太多了,且大多是一些个性化的实现。

autoUno 预设方案

于是我准备手动做一个类似 emmet 补全的预设,希望它可以做到识别任意写法,比如:

  • line-height1px
  • lh24px
  • lh1
  • lh1rem
  • lineh1
  • lihei1
  • ...等等你习惯的写法

正则拦截几乎所有写法

字母+数字

/^[a-zA-Z]+(\d+)$/

字母+数字+单位

/^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/

字母+颜色

/^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/

字母+冒号+字母

/^[a-zA-Z]+:+[a-zA-Z]$/

也就是说,我们的 rules 会长这样:

    rules: [
    [
       /^[a-zA-Z]+(\d+)$/,
      ([a, d]) => {
         const [property, unit] = findBestMatch(a, customproperty)
         if (!property) return
         return { [property]: `${d || ''}${unit || ''}` }
      }
    ],
    [
       /^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/,
      ([a, d, u]) => {
         const [property] = findBestMatch(a, customproperty)
         if (!property) return
         return { [property]: `${d || ''}${u}` }
      }
    ],
    [
       /^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/,
      ([a, c]) => {
         const [property] = findBestMatch(a, customproperty)
         if (!property) return
         return { [property]: c }
      }
    ],
    [
       /^[a-zA-Z]+:+[a-zA-Z]$/,
      ([a]) => {
         const [property] = findBestMatch(a, customproperty)
         if (!property) return
         const propertyName = property.split(':')[0]
         const propertyValue = property.split(':')[1]
         return { [propertyName]: propertyValue }
      }
    ],
  ]

接下来,只要实现 findBestMatch 方法就好了。

正如刚刚提到的,我们需要模拟一个 emmet 的提示,规则大概是这样的

  1. 匹配顺序一致
  2. 至少命中 2 字符
  3. 可以自定义单位

那么我们可以先列举一下可能用到的 CSS 属性(全部大概有350个左右)

const propertyCommon = [
 "display: flex",
 "display: block",
 "display: inline",
 "display: inline-block",
 "display: grid",
 "display: none",
 // "...":"..." 还有更多
]

比如我希望 输入 d:f 就自动帮我匹配到 display: flex 。

那么逻辑应该是这样的:

获取到第一个字符 d,让它分别去这些字符串中比较,比如 display: flex 将被分解成 dis...

首先匹配到第一个字符 d 发现一致,那么 display: flex 的可能性就 + 1,整个遍历下来,顺序一致,且命中字符数最多的,就是我们要找的,很显然 输入 d:f 命中最多的应该是 display: flex ,分别是 d:f ,此时函数返回就正确了。

findBestMatch 方法实现

除了刚刚列举的常用固定写法,还有一些带单位的属性,我选择用 $ 符号分割,以便于在函数中提取

const propertyWithUnit = [
 "animation-delay$ms",
 "animation-duration$ms",
 "border-bottom-width$px",
 "border-left-width$px",
 "border-right-width$px",
 "border-top-width$px",
 "border-width$px",
 "bottom$px",
 "box-shadow$px",
 "clip$px",
 // ... 更多
]

我们在预设属性中,使用 $ 符号隔断了一个默认单位,一会将在函数中提取它。

export function findBestMatch(input: string, customproperty: string[] = []) {
 // 将输入字符串转换为字符数组
 const inputChars = input.split('')

 let bestMatch: any = null
 let maxMatches = 0

 // 遍历所有目标字符串
 for (let keywordOrigin of customproperty.concat(propertyWithUnit.concat(propertyCommon))) {
   const keyword = keywordOrigin.split('$')[0]
   // 用来记录目标字符串的字符序列是否匹配
   let matchCount = 0
   let inputIndex = 0
   // 遍历目标字符串
   for (let i = 0; i < keyword.length; i++) {
     // 如果第一个字符就不匹配,直接跳过
     if (i === 0 && keyword[i] !== inputChars[0]) {
       break
    }
     if (inputIndex < inputChars.length && keyword[i] === inputChars[inputIndex]
       && (input.includes(":") && keyword.includes(":") || (!input.includes(":")))) {
       matchCount++
       inputIndex++
    }
  }
   // 如果找到的匹配字符数大于等于 2,且比当前最大匹配数多
   if (matchCount >= 2 && matchCount > maxMatches) {
     maxMatches = matchCount
     bestMatch = keywordOrigin
  }
}
 let unit: any = ''
 // 用正则匹配单位,最后一个数字的后面的字符
 const unitMatch = input.match(/(\d+)([a-zA-Z%]+)/)
 unit = unitMatch && unitMatch[2]
 if (!unit && bestMatch && bestMatch.split('$')[1]) {
   unit = bestMatch.split('$')[1]
}
 return [bestMatch && bestMatch.split('$')[0], unit]
}

此函数使用了一种加分机制,去寻找最匹配的字符,当用户传入一个 class 时,将从第一个字符开始匹配,第一个不匹配直接跳过(遵循emmet规则,也有利于性能),接着,在是否加分的的 if 中,需要判断是否包含 : ,这是为了区分是否是带冒号的常用属性(区别于带单位的属性)。

在循环中,将找出最匹配的预设属性值,最后,判断用户输入的字符串是否带单位,如果带单位就使用用户单位,如果没有,就使用默认单位(预设属性中 $ 符号后面的字符)。

然后返回一个数组,它将是 [property,unit]

其实在上面的正则中,我将带单位和不带单位的匹配分开了,在写这篇文章时,findBestMatch 函数我还没想好怎么改😅,于是就先将就着讲给各位看,核心思想是一样的。

如此一来,我们无需自定义过多的固定 rules,只需要补充一些CSS属性就可以了,接下来你的UnoCSS 规则将长这样:

export default defineConfig({
presets: [
autoUno([
'border-radius$px',
"display:flex",
"...."
])],
})

只需列举你将用到的标准css属性即可,含有数值的,以$符号分隔默认单位,其实你也无须过多设置,因为我的 autoUno 预设中已经涵盖了大部分常用属性,只有你发现 autoUno 无法识别你的简写时,才需要手动传入。

接下来,隆重介绍

autoUno

image.png

autoUno 是 UnoCSS 的一个预设方案,它支持你以最直觉的方式设置 class 。

你认为对,它就对,再也不受任何预设的影响,再也不用记下任何别人定义的习惯。

此项目已在 github 开源:github.com/Auto-Plugin…

此项目在 NPM 可供下载:http://www.npmjs.com/package/aut…

官方网站(可在线尝试):auto-plugin.github.io/index/autou…

安装

pnpm i autouno

使用

import { defineConfig } from 'unocss'
import autoUno from 'autouno'

export default defineConfig({
presets: [
autoUno([
"box-shadow:none",
])],
})

作者:德莱厄斯
来源:juejin.cn/post/7435653910252191754

收起阅读 »

小程序webview我爱死你了 小程序webview和H5通讯

web
webview 我 * 众所周知,将已上线的H5页面转换为小程序,最快的方法是通过WebView进行套壳。然而,在这个过程中,我们需要将H5页面的登录和支付功能迁移到小程序版本。这意味着H5页面需通过特定的方式与小程序进行通信,以实现如支付等关键功能。 因此需...
继续阅读 »

webview 我 *


众所周知,将已上线的H5页面转换为小程序,最快的方法是通过WebView进行套壳。然而,在这个过程中,我们需要将H5页面的登录和支付功能迁移到小程序版本。这意味着H5页面需通过特定的方式与小程序进行通信,以实现如支付等关键功能。


因此需要了解H5与WebView之间的通讯方式,以确保数据的顺利传递和功能的无缝对接。


找了很久发现H5与WebView的通讯方式主要有两种



  1. 小程序通过改变H5地址栏携带参数

  2. WebSocket实时通讯


而webview自带的bindmessage、bindload、binderror,触发条件只有小程序后退、组件销毁、分享、复制链接,给我卡的死死的,只好选择了第一种方式,WebSocket虽然可以实现实时通讯,但会增加额外的开销,不符合我的需求。


这里的URL域名必须添加到 小程序后台中-管理-业务域名内,否则会报无法打开 xxx 页面,个人小程序是没有这个选项的,需要申请成企业小程序


QQ20241123-124446.png


小程序向H5通讯

小程序端


<view class="content">
<web-view :src="url"></web-view>
</view>

H5端


// 判断当前页面的 URL 是否包含 'userInfo',用于识别是否来自小程序端
if (window.location.href.includes('userInfo')) {
// 匹配 URL 中的 userInfo 参数
const userInfoRegex = /userInfo=([^]*)/;

// 解码
const decodedUrl = decodeURIComponent(window.location.href);

// 使用正则表达式从解码后的 URL 中提取参数值
const userInfoMatch = decodedUrl.match(userInfoRegex);
let auth_token = userInfoMatch[1];
localStorage.setItem('loc_token', auth_token);
}


H5向小程序通讯

小程序端


onMounted(() => {
const paymentData = getCurrentPages().pop().options.paymentData // 获取当前页面参数
submitInfo(paymentData);
});


H5端


 wx.miniProgram.navigateTo({
url: `/pagesMember/pay/pay?paymentData=${payInfo.value}`,
})


通讯限制也就算了,导航栏不能自定义,还不让去掉,这让自带导航栏显得极其突兀!我 * !!!


QQ20241123-121254.png


微信图片_20241123122103.jpg


navigationStyle: custom对 web-view 组件无效
一句话干碎我的摸鱼梦,领导要把那块做成透明的,没办法只好把常用页面重构,
but小程序不支持elementPlus啊,太爽了家人们。


作者:loooseFish
来源:juejin.cn/post/7440122922025058342
收起阅读 »

分不清Boolean和boolean,我被同事diss了!

web
背景 这几天写代码,遇到一个不确定的知识点:我在vue的props中如何给一个属性定义小写的bolean,代码就会报错 但是大写的Bolean就没问题 由于我在其他地方我看大小写都可以,有点疑惑,于是想去请教一下同事。然而,没想到同事上来就diss我: ...
继续阅读 »

背景


这几天写代码,遇到一个不确定的知识点:我在vue的props中如何给一个属性定义小写的bolean,代码就会报错



但是大写的Bolean就没问题



由于我在其他地方我看大小写都可以,有点疑惑,于是想去请教一下同事。然而,没想到同事上来就diss我:



这么基础的知识你都不清楚?这两个根本就不是一个东西!



我有点不开心,想反驳一下:


这两个不都是描述类型的东西吗?我给你看其他地方的代码,这两个都是可以混用的!



同事有点不耐烦,说道:大姐,boolean是TS中的类型声明,Boolean是JavaScript 的构造函数,根本不是一个东西吧!


行吧,我也刚入门不久,确实不了解这个东西,只能强忍委屈,对同事说了声谢谢,我知道了!


然后,我好好的学习了一下Boolean和boolean的知识,终于搞明白他们的区别了。


Boolean和boolean


本质区别


同事说的很对,他们两个的本质区别就是一个是JavaScript语法,一个是TypeScript语法,这意味着非TypeScript项目是不存在boolean这个东西的。


Boolean 是 JavaScript 的构造函数


Boolean 是 JavaScript 中的内置构造函数,用于布尔值的类型转换或创建布尔对象。


typeof Boolean; // "function"

boolean 是 TypeScript 的基本类型



  • 如果使用了 TypeScript,boolean 是 TypeScript 中的基本类型,用于静态类型检查。

  • 在 JavaScript 的运行时上下文中,boolean 并不存在,仅作为 TypeScript 的静态检查标识。


typeof boolean; // ReferenceError: boolean is not defined

TS中作为类型的Boolean和boolean


在TypeScript中,Boolean和boolean都可以用于表示布尔类型


export interface ActionProps {
checkStatus: Boolean
}
export interface RefundProps {
visible: boolean
}

但是,他们存在一些区别


boolean



  • boolean 是 TypeScript 的基本类型,用于定义布尔值。

  • 它只能表示 truefalse

  • 编译后 boolean 不会存在于 JavaScript 中,因为它仅用于静态类型检查。


//typescript
let isActive: boolean; // 只能是 true 或 false
isActive = true; // 正确
isActive = false; // 正确
isActive = new Boolean(true); // 错误,不能赋值为 Boolean 对象

Boolean



  • Boolean 是 JavaScript 的内置构造函数,用于将值显式转换为布尔值或创建布尔对象(Boolean 对象)。

  • 它是一个引用类型,返回的是一个布尔对象,而不是基本的布尔值。

  • 在 TypeScript 中, Boolean 表示构造函数类型,而不是基本的布尔值类型


//typescript
let isActive: Boolean; // 类型是 Boolean 对象
isActive = new Boolean(false); // 正确,赋值为 Boolean 对象
isActive = true; // 正确,基本布尔值也可以兼容

关键区别


特性booleanBoolean
定义TypeScript 的基本类型JavaScript 的构造函数
值类型只能是 truefalse是一个布尔对象
推荐使用场景用于定义基本布尔值类型很少用,除非需要显式构造布尔对象
运行时行为不存在,只在编译时有效在运行时是 JavaScript 的构造函数
性能高效,直接操作布尔值对象包装,性能较差

为什么尽量避免使用 Boolean


类型行为不一致Boolean 是对象类型,而不是基本值类型。这会在逻辑运算中导致混淆:


const flag: Boolean = new Boolean(false);
if (flag) {
console.log("This will run!"); // 因为对象始终为 truthy
}

性能开销更大Boolean 会创建对象,而 boolean 是直接操作基本类型。


vue中的Boolean与boolean


Vue 的运行时框架无法识别 boolean 类型,它依赖的是 JavaScript 的内置构造函数(如 BooleanStringNumber 等)来检查和处理 props 类型。


因此,props的Type只能是BooleanStringNumber


但是如果vue中开启了ts语法,就可以使用boolean 表示类型了


<script lang="ts" setup>

interface IProps {
photoImages?: string[],
isEdit?: boolean
}

const props = withDefaults(defineProps<IProps>(), {
photoImages: () => [],
isEdit: true
})

</script>

作者:快乐就是哈哈哈
来源:juejin.cn/post/7439576043223203892
收起阅读 »

TypeScript很麻烦💔,不想使用!

web
本文已经授权【稀土掘金技术社区】官方公众号独家原创发布。 前言 最近,我们部门在开发一个组件库时,我注意到一些团队成员对使用TypeScript表示出了抵触情绪,他们常常抱怨说:“TypeScript太麻烦了,我们不想用!”起初,我对此感到困惑:TypeScr...
继续阅读 »

本文已经授权【稀土掘金技术社区】官方公众号独家原创发布。


前言


最近,我们部门在开发一个组件库时,我注意到一些团队成员对使用TypeScript表示出了抵触情绪,他们常常抱怨说:“TypeScript太麻烦了,我们不想用!”起初,我对此感到困惑:TypeScript真的有那么麻烦吗?然而,当我抽时间审查队伍的代码时,我终于发现了问题所在。在这篇文章中,我想和大家分享我的一些发现和解决方案。


一、类型复用不足


在代码审查过程中,我发现了大量的重复类型定义,这显著降低了代码的复用性。


进一步交流后,我了解到许多团队成员并不清楚如何在TypeScript中复用类型。TypeScript允许我们使用typeinterface来定义类型。


当我询问他们typeinterface之间的区别时,大多数人都表示不清楚,这也就难怪他们不知道如何有效地复用类型了。


type定义的类型可以通过交叉类型(&)来进行复用,而interface定义的类型则可以通过继承(extends)来实现复用。值得注意的是,typeinterface定义的类型也可以互相复用。下面是一些简单的示例:


复用type定义的类型:


type Point = {
x: number;
y: number;
};

type Coordinate = Point & {
z: number;
};

复用interface定义的类型:


interface Point {
x: number;
y: number;
};

interface Coordinate extends Point {
z: number;
}

interface复用type定义的类型:


type Point = {
x: number;
y: number;
};

interface Coordinate extends Point {
z: number;
}

type复用interface定义的类型:


interface Point {
x: number;
y: number;
};

type Coordinate = Point & {
z: number;
};

二、复用时只会新增属性的定义


我还注意到,在类型复用时,团队成员往往只是简单地为已有类型新增属性,而忽略了更高效的复用方式。


例如,有一个已有的类型Props需要复用,但不需要其中的属性c。在这种情况下,团队成员会重新定义Props1,仅包含Props中的属性ab,同时添加新属性e


interface Props {
a: string;
b: string;
c: string;
}

interface Props1 {
a: string;
b: string;
e: string;
}

实际上,我们可以利用TypeScript提供的工具类型Omit来更高效地实现这种复用。


interface Props {
a: string;
b: string;
c: string;
}

interface Props1 extends Omit<Props, 'c'> {
e: string;
}

类似地,工具类型Pick也可以用于实现此类复用。


interface Props {
a: string;
b: string;
c: string;
}

interface Props1 extends Pick<Props, 'a' | 'b'> {
e: string;
}

OmitPick分别用于排除和选择类型中的属性,具体使用哪一个取决于具体需求。


三、未统一使用组件库的基础类型


在开发组件库时,我们经常面临相似功能组件属性命名不一致的问题,例如,用于表示组件是否显示的属性,可能会被命名为showopenvisible。这不仅影响了组件库的易用性,也降低了其可维护性。


为了解决这一问题,定义一套统一的基础类型至关重要。这套基础类型为组件库的开发提供了坚实的基础,确保了所有组件在命名上的一致性。


以表单控件为例,我们可以定义如下基础类型:


import { CSSProperties } from 'react';

type Size = 'small' | 'middle' | 'large';

type BaseProps<T> = {
/**
* 自定义样式类名
*/

className?: string;
/**
* 自定义样式对象
*/

style?: CSSProperties;
/**
* 控制组件是否显示
*/

visible?: boolean;
/**
* 定义组件的大小,可选值为 small(小)、middle(中)或 large(大)
*/

size?: Size;
/**
* 是否禁用组件
*/

disabled?: boolean;
/**
* 组件是否为只读状态
*/

readOnly?: boolean;
/**
* 组件的默认值
*/

defaultValue?: T;
/**
* 组件的当前值
*/

value?: T;
/**
* 当组件值变化时的回调函数
*/

onChange: (value: T) => void;
}

基于这些基础类型,定义具体组件的属性类型变得简单而直接:


interface WInputProps extends BaseProps<string> {
/**
* 输入内容的最大长度
*/

maxLength?: number;
/**
* 是否显示输入内容的计数
*/

showCount?: boolean;
}

通过使用type关键字定义基础类型,我们可以避免类型被意外修改,进而增强代码的稳定性和可维护性。


四、处理含有不同类型元素的数组


在审查自定义Hook时,我发现团队成员倾向于返回对象,即使Hook只返回两个值。


虽然这样做并非错误,但它违背了自定义Hook的一个常见规范:当Hook返回两个值时,应使用数组返回。


团队成员解释说,他们不知道如何定义含有不同类型元素的数组,通常会选择使用any[],但这会带来类型安全问题,因此他们选择返回对象。


实际上,元组是处理这种情况的理想选择。通过元组,我们可以在一个数组中包含不同类型的元素,同时保持每个元素类型的明确性。


function useMyHook(): [string, number] {
return ['示例文本', 42];
}

function MyComponent() {
const [text, number] = useMyHook();
console.log(text); // 输出字符串
console.log(number); // 输出数字
return null;
}

在这个例子中,useMyHook函数返回一个明确类型的元组,包含一个string和一个number。在MyComponent组件中使用这个Hook时,我们可以通过解构赋值来获取这两个不同类型的值,同时保持类型安全。


五、处理参数数量和类型不固定的函数


审查团队成员封装的函数时,我发现当函数的参数数量不固定、类型不同或返回值类型不同时,他们倾向于使用any定义参数和返回值。


他们解释说,他们只知道如何定义参数数量固定、类型相同的函数,对于复杂情况则不知所措,而且不愿意将函数拆分为多个函数。


这正是函数重载发挥作用的场景。通过函数重载,我们可以在同一函数名下定义多个函数实现,根据不同的参数类型、数量或返回类型进行区分。


function greet(name: string): string;
function greet(age: number): string;
function greet(value: any): string {
if (typeof value === "string") {
return `Hello, ${value}`;
} else if (typeof value === "number") {
return `You are ${value} years old`;
}
}

在这个例子中,我们为greet函数提供了两种调用方式,使得函数使用更加灵活,同时保持类型安全。


对于箭头函数,虽然它们不直接支持函数重载,但我们可以通过定义函数签名的方式来实现类似的效果。


type GreetFunction = {
(name: string): string;
(age: number): string;
};

const greet: GreetFunction = (value: any): string => {
if (typeof value === "string") {
return `Hello, ${value}`;
} else if (typeof value === "number") {
return `You are ${value} years old.`;
}
return '';
};

这种方法利用了类型系统来提供编译时的类型检查,模拟了函数重载的效果。


六、组件属性定义:使用type还是interface


在审查代码时,我发现团队成员在定义组件属性时既使用type也使用interface


询问原因时,他们表示两者都可以用于定义组件属性,没有明显区别。


由于同名接口会自动合并,而同名类型别名会冲突,我推荐使用interface定义组件属性。这样,使用者可以通过declare module语句自由扩展组件属性,增强了代码的灵活性和可扩展性。


interface UserInfo {
name: string;
}
interface UserInfo {
age: number;
}

const userInfo: UserInfo = { name: "张三", age: 23 };

结语


TypeScript的使用并不困难,关键在于理解和应用其提供的强大功能。如果你在使用TypeScript过程中遇到任何问题,不清楚应该使用哪种语法或技巧来解决,欢迎在评论区留言。我们一起探讨,共同解决TypeScript中遇到的挑战。


作者:前端大骆
来源:juejin.cn/post/7344282440725577765
收起阅读 »

只写后台管理的前端要怎么提升自己

web
本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。 写优雅的代码 一道面试题 大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单...
继续阅读 »

本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。


写优雅的代码


一道面试题


大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。


原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是kb,而我要展示成 KBMB 等形式。大概写一个工具函数(具体怎么写的忘记了,不过功能比这个复杂点):


function formatSizeUnits(kb) {
let units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;

while (kb >= 1024 && unitIndex < units.length - 1) {
kb /= 1024;
unitIndex++;
}

return `${kb.toFixed(2)} ${units[unitIndex]}`;
}

而在此之前,是一个后端同学写的代码(说起来,后台管理系统前端人力不够后端帮忙写好像是常规操作,后端总觉得写前端简单,而我只能说你们那代码写的,一言难尽……),如下:


function formatSizeUnits(kb) {
var result = '';

if (kb < 1024) {
result = kb + ' KB';
} else if (kb < 1024 * 1024) {
result = (kb / 1024).toFixed(2) + ' MB';
} else if (kb < 1024 * 1024 * 1024) {
result = (kb / 1024 / 1024).toFixed(2) + ' GB';
} else {
result = (kb / 1024 / 1024 / 1024).toFixed(2) + ' TB';
}

return result;
}

虽然功能都一样,但是我写在面试的时候写了上面的代码得到了面试官的赞扬,也因此非常顺利的通过了面试。而如果我写成了下面的样子,估计不会有这种效果。


如何提升代码质量


想提升代码质量,最理想的是靠 code review ,但实际上这玩意在大多数公司根本就不存在。其次就只能多看看大佬们写的代码,但是一来后台管理本来就没太大难度很少有大佬,二来很多时候一个后台管理系统就一两个人负责。


还好现在有了各种 AI 工具,可以非常方便的优化自己的代码了。


还是上面的问题,看看 GPT 给的答案


// 提问:写一个工具函数,对文件大小的单位进行转换,加注释
// 回答:下面是一个JavaScript工具函数,它接受文件大小、初始单位、目标单位和小数位数,然后返回目标单位下的文件大小。

/**
* 将文件大小从一个单位转换为另一个单位。
*
* @param {number} size 文件大小。
* @param {string} fromUnit 初始单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {string} toUnit 目标单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {number} [decimalPoint=2] 结果保留的小数位数,默认为2。
* @return {string} 转换后的文件大小,带单位。
*/

function convertFileSize(size, fromUnit, toUnit, decimalPoint = 2) {
// 定义单位与字节之间的转换关系
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 获取初始单位和目标单位的索引
const fromIndex = units.indexOf(fromUnit);
const toIndex = units.indexOf(toUnit);

// 如果单位不在列表中,抛出错误
if (fromIndex === -1 || toIndex === -1) {
throw new Error('Invalid units');
}

// 计算初始单位与目标单位之间的转换系数
const exponent = toIndex - fromIndex;
// 计算结果大小
const resultSize = size / Math.pow(1024, exponent);

// 返回格式化后的结果
return parseFloat(resultSize.toFixed(decimalPoint)) + ' ' + toUnit;
}

// 示例使用
console.log(convertFileSize(1, 'GB', 'MB')); // 输出: 1024.00 MB
console.log(convertFileSize(1, 'MB', 'KB')); // 输出: 1024.00 KB
console.log(convertFileSize(1, 'KB', 'B')); // 输出: 1024.00 B
console.log(convertFileSize(1, 'MB', 'GB', 5)); // 输出: 0.00098 GB

还能说什么,我只能说 NB!(经评论区提示,此段代码可能有问题,建议大家在使用AI工具时,重点是借鉴思路,直接使用代码需要谨慎~)


我现在每次写大段逻辑之后都习惯拿给 AI 看看,有什么更好的实现方式,或者用什么设计模式。AI 是非常低成本且高效提升代码质量的工具。


学会封装


一个功能用到了好多次,为什么不封装成组件?一个组件用到了好几个项目,为什么不单独写个npm包?差不多的项目创建了好几个,为什么不封装成脚手架?


你说,没时间,没必要,复制粘贴反而更快。


那你就完全没理解,这么做不一定是为了让工作更快完成,而是可以让你在年年终述职时更有话说(你就算写了一百个表单表格没有写一个脚手架更值得炫耀),如果不会写可以问问 AI。


而当你真正开始封装组件,开始写工具库了,你会发现你需要思考的确实比之前多了。


关注业务


对于前端业务重要吗?


相比于后端来说,前端一般不会太关注业务。就算出了问题大部分也是后端的问题。


但是就我找工作的经验,业务非常重要!


如果你做的工作很有技术含量,比如你在做低代码,你可以面试时讲一个小时的技术难点。但是你只是一个破写后台管理,你什么都没有的说。这个时候,了解业务就成为了你的亮点。


一场面试


还是拿真实的面试场景举例,当时前同事推我字节,也是我面试过N次的梦中情厂了,刚好那个组做的业务和我之前呆的组做的一模一样。



  • 同事:“做的东西和咱们之前都是一样的,你随便走个过场就能过,我在前端组长面前都夸过你了!”

  • 我:“好嘞!”


等到面试的时候:



  • 前端ld:“你知道xxx吗?(业务名词)”

  • 我:“我……”

  • 前端ld:“那xxxx呢?(业务名词)”

  • 我:“不……”

  • 前端ld:“那xxxxx呢??(业务名词)”

  • 我:“造……”


然后我就挂了………………


如何了解业务



  1. 每次接需求的时候,都要了解需求背景,并主动去理解


    我们写一个表格简简单单,把数据展示出来就好,但是表格中的数据是什么意思呢?比如我之前写一个 kafka 管理平台,里面有表格表单,涉及什么 cluster controller topic broker partition…… 我真的完全不了解,很后悔我几年时间也没有耐下心来去了解。


  2. 每次做完一个需求,都需要了解结果


    有些时候,后台管理的团队可能根本没有PM,那你也要和业务方了解,这个功能做了之后,多少人使用,效率提高了吗?数据是怎样的?


  3. 理解需求,并主动去优化


    产品要展示一千条数据,你要考虑要不要分页,不分页会不会卡,要不要上虚拟表格?


    产品要做一个可拖拽表单,你要考虑是否需要拖动,是否需要配置。


    其实很多时候,产品的思维可能会被局限在竞品的实现方式,而前端可以给TA更多选项。在和产品沟通的时候,你不仅是沟通页面的实现,也更能理解业务。



关注源码


说到源码, Vue,React 这些基本是每次必问,也有太多大佬们的总结我就不多说了。


除此之外,我想大家每次面试应该都会被问,你写了什么亮点的项目,困难的项目,你会说什么?哦,都是表单表格,都用 Element UI 实现的,没什么难度。那估计你面试也就到这里了。


那说什么,后台管理就这些啊?!


如果你用了 Element UI 你可以说写了什么自定义 Element UI 表单组件,Element UI 表单源码是什么原理,怎么通信的?用了 Axios 可以说 Axios 的原理,它的拦截器怎么实现的?用了编辑器 diff 那你知道 diff 算法是什么吗?用了 ECharts 那你知道他底层是用什么绘制的吗?用了 husky ?husky的原理是什么?用了Vite,它为什么快?是怎么打包的?写过插件吗?


可能有人觉得我离谱,事实上上面每一个问题都是我在面试中真实被问到的。当然,如果全部去了解,肯定不现实,每个人的精力有限,不可能每个用到的每个工具每个库都去了解原理,不过只要有一两个你去了解过,就可以在面试中引导面试官来问,要学会表现出你擅长的点


至于怎么读源码?以前我不知道,现在嘛…… 为什么不问问神奇的 ChatGPT 呢?



讲一下 Axios 源码中,拦截器是怎么实现的?


Axios 的拦截器实现基于 Axios 的核心原理,即 Axios 实例是一个包含请求和响应拦截器堆栈的对象。当发出请求或接收响应时,Axios 会遍历这些拦截器,并按照添加的顺序执行请求拦截器,以及按照相反的顺序执行响应拦截器。


在 Axios 的源码中,拦截器是通过一个 AxiosInterceptorManager 实例来管理的,它维护了一个拦截器数组。每个拦截器都是一个包含 fulfilledrejected 函数的对象。这两个函数分别对应于拦截器成功处理和拦截器处理出错的情况。


以下是 Axios 拦截器管理器的一个简化版本,展示了其核心实现思路:


class InterceptorManager {
constructor() {
this.handlers = []; // 存储拦截器的数组
}

use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1; // 返回拦截器的ID
}

eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 移除拦截器
}
}

forEach(fn) {
this.handlers.forEach((h) => {
if (h !== null) {
fn(h);
}
});
}
}

在发送请求或接收响应时,Axios 会创建一个 promise 链,并通过 forEach 方法将拦截器中的 fulfilledrejected 函数添加到这个链中。这样,每个拦截器都可以对请求或响应进行处理,然后将结果传递到链的下一个拦截器,或者在出错时结束链的执行。


axios.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});

axios.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});

在 Axios 的完整实现中,这个拦截器机制被集成到了 Axios 的请求发送和响应处理流程中。通过这种方式,Axios 可以在发送请求之前和接收响应之后,但在用户定义的 .then.catch 执行之前,插入自定义的逻辑。


请注意,这里提供的代码只是为了说明 Axios 拦截器的实现原理,并不是 Axios 源码的完整复制。如果你对 Axios 的拦截器实现细节感兴趣,建议查看 Axios 的官方 GitHub 仓库中的源码。



前端基建


当我们工作时间久了面试难免会遇到这些问题,前端工程化,前端监控,工作流,部署,性能等等。其实我们在工作中绝大部分时间都在写代码,对于这些不是所有人都有机会接触到,不过这些和所做的业务无关,是我们提升自己很好的一个思路。


技术选型


技术栈选 Vue 还是 React?Vue 选 Vue2 还是 Vue3?组件库选 ElementUI 还是 Ant Design?微前端有没有使用过?打包工具用 Vite 还是 Webpack?有那么多表单怎么实现的,有没有什么表单配置化方案,比如Formily?


对于我这种菜鸡,我这种只写简单的表单表格的人,这些都……无所谓……


image.png

不过为了应对面试我们还是需要了解下未选择技术栈的缺点,和已选择技术栈的优点(有点本末倒置…但是常规操作啦)


Vue 你可以说简单高效轻量级,面试必会问你为什么,你就开始说 Vue 的响应式系统,依赖收集等。


React 你可以说 JSX、Hooks 很灵活,那你必然要考虑 JSX 怎么编译, Hooks 实现方式等。


总体而言,对于技术选型,依赖于我们对所有可选项的理解,做选择可能很容易,给出合理的理由还是需要花费一些精力的。


开发规范


这个方面,在面试的时候我被问到的不多,我们可以在创建项目的时候,配置下 ESlintstylelintprettiercommitlint 等。


前端监控


干了这么多年前端,前端监控我是……一点没做过。


image.png

前端监控,简单来说就是我们在前端程序中记录一些信息并上报,一般是错误信息,来方便我们及时发现问题并解决问题。除此之外也会有性能监控,用户行为的监控(埋点)等。之前也听过有些团队分享前端监控,为了出现问题明确责任(方便甩锅)。


对于实现方案,无论使用第三方库还是自己实现,重要的都是理解实现原理。


对于错误监控,可以了解一下 Sentry,原理简单来说就是通过 window.onerrorwindow.addEventListener('unhandledrejection', ...) 去分别捕获同步和异步错误,然后通过错误信息和 sourceMap 来定位到源码。


对于性能监控,我们可以通过 window.performancePerformanceObserver 等 API 收集页面性能相关的指标,除此之外,还需要关注接口的响应时间。


最后,收集到信息之后,还要考虑数据上报的方案,比如使用 navigator.sendBeacon 还是 Fetch、AJAX?是批量上报,实时上报,还是延迟上报?上报的数据格式等等。


CI/CD


持续集成(Continuous Integration, CI)和 持续部署(Continuous Deployment, CD),主要包括版本控制,代码合并,构建,单测,部署等一系列前端工作流。


场景的工作流有 Jenkins、 Gitlab CI 等。我们可以配置在合并代码时自动打包部署,在提交代码时自动构建并发布包等。


这块我了解不多,但感觉这些工具层面的东西,不太会涉及到原理,基本上就是使用的问题。还是需要自己亲自动手试一下,才能知道细节。比如在 Gitlab CI 中, Pipeline 、 Stage 和 Job 分别是什么,怎么配置,如何在不同环境配置不同工作流等。


了解技术动态


这个可能还是比较依赖信息收集能力,虽然我个人觉得很烦,但好像很多领导级别的面试很愿意问。


比如近几年很火的低代码,很多面试官都会问,你用过就问你细节,你没用过也会问你有什么设计思路。


还有最近的两年爆火的 AI,又或者 Vue React的最新功能,WebAssembly,还有一些新的打包工具 Vite Bun 什么的,还有鸿蒙开发……


虽然不可能学完每一项新技术,但是可以多去了解下。


总结


写了这么多,可能有人会问,如果能回到过去,你会怎么做。


啊,我只能说,说是一回事,做又是另一回事,事实上我并不希望回到过去去卷一遍,菜点没关系,快乐就好,一切都是最好的安排。


image.png

作者:我不吃饼干
来源:juejin.cn/post/7360528073631318027
收起阅读 »

搭建一个快速开发油猴脚本的前端工程

web
一、需求起因最近遇到一个问题:公司自用的 bug 管理工具太老了,网页风格还是上世纪的文字页面。虽然看习惯了还好,但是某些功能确实很不方便。比如,联系人都是邮箱或者英文名,没有中文名称,在流转 bug 时还得复制粘贴英文名去企业微信里搜索对应的人名。第二是人员...
继续阅读 »

一、需求起因

最近遇到一个问题:公司自用的 bug 管理工具太老了,网页风格还是上世纪的文字页面。虽然看习惯了还好,但是某些功能确实很不方便。比如,联系人都是邮箱或者英文名,没有中文名称,在流转 bug 时还得复制粘贴英文名去企业微信里搜索对应的人名。第二是人员比较多,在一堆邮箱里很难找到对应的人......

总之,诸如此类的问题让我有了对该网页进行改造的想法。

但是这种网页都是公司创业时期拿的开源产品私有化部署,网页源码能不能找到都不好说。再者,公司也不会允许此类的“小聪明”,这并不是我的主职工作,所以修改源码是非常不现实的。

那目前的思路,就是在原网页基础上进行脚本注入,修改网页内容和样式。方案无非就是浏览器插件或者脚本注入两种。

脚本的话就是利用油猴插件的能力,写一个油猴脚本,在网页加载完成后注入我们书写的脚本,达到修改原网页的效果。

插件也是类似的原理,但是写插件要麻烦得多。

出于效率考虑,我选择了脚本的方案。这里其实也是想巩固下 js 的 DOM API,框架写多了,很多原生的 API 反而忘得一干二净。

二、关于油猴脚本

先看一份 demo

// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是一段油猴脚本
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==

(function () {
"use strict";
const script = document.createElement("script");
document.body.appendChild(script);
})();

油猴脚本由注释及 js 代码组成。注释需要包裹在

// ==UserScript==

// ==/UserScript==

两个闭合标签内。同时只能书写类似 @name 规定好的注释头,用于标明脚本的一些元信息。其中比较重要的是 @match 和 @run-at

@match 规定了该脚本所运行的域名,例如,只有当我打开了百度的网页时我才运行脚本,这个 @match 可以书写多个。@run-at 则规定了脚本的运行时机,一般是网页加载开始,网页加载结束。@run-at 只声明一次。

@run-at 有以下可选值:

image.png

图片看得不清晰也没关系,这种都是用到再查。

更多注释配置请参考:油猴脚本

而代码部分是一个立即执行函数,所有的内容都需要写在这个立即执行函数内,否则无法生效。

三、问题显现

刚开始,我并没有工程化开发的想法,我想的是就是一个脚本,直接一梭子写到底即可,反正就是那样,就是个普通的 js 文件,一切都是那么原始,朴实无华。

但是当代码来到两千多行后(我是真的很爱加东西),绷不住了,每次写代码都需要在文件上下之间反复横跳,有时候有些变量定义了都不记得,写代码还得滚动半天才能到最底下。

加东西也变得越来越臃肿,越来越丑陋。

忍无可忍,我决定对这个脚本进行工程化改造。但是工程化之前有几个问题需要解决,或者说需要调研清楚。

四、关键点分析

1.构建工具

首先肯定是打包成 iife 的产物,很多工具都支持。既然工程化了,一般大家的选择就是 webpack 或者 vite。这里因为涉及到开发模式,需要及时产出打包产物,且能够搭建 dev 服务器,方便访问本地打包后的资源,因此需要选择具备 dev 服务器的开发构建工具。

我选择 vite。当然,webpack 也是不错的选择。

如果你对实时预览要求不高,能够接受复制粘贴到油猴再刷新页面预览,也可以选择纯粹的打包器,例如 rollup

2.css 预编译器

传统的添加样式的方式,一般就是生成一个 style 标签,然后修改其 innerHTML

export const addStyle = (css: string) => {
const style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
document.getElementsByTagName('head')[0].appendChild(style);
}

addStyle(`
body {
width: 100%;
height: 100%;
}
`
);

这样就能实现往网页里添加自定义的样式。但是我现在不满足于书写传统的 css,我既然都工程化了,肯定要把 less 或者 scss 用上。

我的目的,就是可以新建一个例如 style.less 的文件开心地书写 less,打包时候编译一下这个 less 文件,并将其样式注入到目标 HTML 中。

但在传统模块化工程里,构建工具对 less 的支持,是直接在 HTML 中生成一个 style 标签,引入编译后的 less 产物(css)。

也就是说,我需要手动实现 less 到 css 到 js 这个过程。

转变的步骤就是用 less 本身的编译能力,将其产物转变为一个 js 模块。

具体实现放到后面再聊。

3.实现类似热更新的效果

我们启动一个传统的 vite 工程时,我们更新了某个 js 文件或者相关文件后,工程会监听我们的文件被修改了,从而触发热更新,服务也会自动刷新,从而达到实时预览的效果。

这是因为工程会在本地启动一个开发服务器,最终产物也会实时构建,那网页每次去获取这个服务器上的资源,就会获取到最新的代码。根据这点,我们同样需要启动一个本地服务器,而这在 vite 中直接一个 vite 命令即可。

在油猴脚本中,我们新建一个 script 标签,将其 src 指向我们本地服务器的构建产物的地址,即可实现实时的脚本更新,而不用复制产物代码再粘贴到油猴。

代码如下:

// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是描述
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==

(function () {
"use strict";
const script = document.createElement("script");
script.src = "http://localhost:6419/dist/script.iife.js";
document.body.appendChild(script);
})();

这里的 localhost:6419/dist/script.iife.js 都取决于你 vite.config.js 中的配置。

具体后面再聊。

五、开始搭建工程

1.使用 yarn create vite 或者 pnpm create vite 初始化一个 vite 模板工程

image.png

image.png

image.png

其他的你自己看着选就可以。

2.修改 vite.config.js

/**
* @type {import('vite').UserConfig}
*/

module.exports = {
server: {
host: 'localhost',
port: 6419,
},
build: {
minify: false,
outDir: 'dist',
lib: {
entry: 'src/main.ts',
name: 'script',
fileName: 'script',
formats: ['iife'],
},
},
resolve: {
alias: {
'@': '/src',
'@utils': '/src/utils',
'@enum': '/src/enum',
'@const': '/src/const',
'@style': '/src/style',
}
}
}

这里使用 cjs 是因为我们会实现一些脚本,脚本里可能会用到这里的某些配置,所以使用 cjs 导出也有利于外部的使用。

3.创建一个 tampermonkey.config 文件,将油猴注释放在这里

// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是描述
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==

当然,你要觉得这样多余、没必要,也可以看自己喜好,只要最终产物里有这个注释即可。但是拆出来有利于我们维护,后续也会新增脚本,有利于工程化的整体性和可维护性。

4.使用 nodemon 监听文件修改

因为我们自己对 less 有特殊处理,加上未来可能会对需要监听的文件进行精细化管理,所以这里引入 nodemon,如果你自己对工程化有自己的理解,也可以按照自己的理解配置。

执行 pnpm i nodemon -D

根目录新增 nodemon.json

{
"ext": "ts,less",
"watch": ["src"],
"exec": "pnpm dev:build && vite"
}

这里的 pnpm dev:build 还另有玄机,后面再展开。

到这里,我们的工程雏形已经具备了。但是还有一个最关键的点没有解决——那就是 less 的转换。

六、less 的转换以及几个脚本

首先,less 代码需要编译为 css,但是我们需要的是 css 的字符串,这样才能通过 innerHTML 之类的方法注入到网页中。

使用 less.render 方法可以对 less 代码进行编译,其是一个 Promise,我们可以在 then 中接收编译后的产物。

我们可以直接在根目录新建一个 script 文件夹,在 script 文件夹下新建一个 gen-style-string.js 的脚本:

const less = require('less');
const fs = require('fs');
const path = require('path');

const styleContent = fs.readFileSync(path.resolve(__dirname, '../src/style.less'), 'utf-8');

less.render(styleContent).then(output => {
if(output.css) {
const code = `export default \`\n${output.css}\``;

const relativePath = '../style/index.ts';
const filePath = path.resolve(__dirname, relativePath)

if(fs.existsSync(filePath)) {
fs.rm(filePath, () => {
fs.writeFileSync(path.resolve(__dirname, relativePath), code)
})
} else {
fs.writeFileSync(path.resolve(__dirname, relativePath), code)
}
}
})

我们将编译后的 css 代码结合 js 代码导出为一个模块,供外部使用。也就是说,这部分编译必须在打包之前执行,这样才能得到正常的 js 模块,否则就会报错。

这段脚本执行完后会在 style/index.ts 中生成类似代码:

export default `
body {
width: 100%;
height: 100%;
}
`

这样 less 代码就能够被外部引入并使用了。

这里多说一句,因为 style/index.ts 的内容是根据 less 编译来的,而我们的 nodemon 会监听 src 目录,因此这个 less 编译后的 js 产物,不能放在 src 下,因为假设将它放在 src 目录下,它在写入的过程中也会触发 nodemon,会导致 nodemon 进入死循环。

除此之外,我们之前还将油猴注释拎出来单独放在一个文件里:tampermonkey.config

在最终产物中,我们需要将其合并进去,思路同上:

const fs = require('fs');
const path = require('path');
const prettier = require('prettier');

const codeFilePath = '../dist/script.iife.js';
const configFilePath = '../tampermonkey.config';
const codeContent = fs.readFileSync(path.resolve(__dirname, codeFilePath), 'utf-8');
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, configFilePath), 'utf-8');

if (codeContent) {
const code = `${tampermonkeyConfig}\n${codeContent}`;
prettier.format(code, { parser: 'babel' }).then((formatted) => {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted)
})
}

最后,因为我们的 tampermonkey.config 以及 vite.config.js 可能会更改配置,所以每次我们在开发模式时生成的临时油猴脚本,也需要变,我们不可能每次都去修改,而是应该跟随上面两个配置文件进行生成,我们再新建一个脚本:

const fs = require('fs');
const path = require('path');
const prettier = require('prettier');
const viteConfig = require('../vite.config');

const codeFilePath = '../tampermonkey.js';
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, '../tampermonkey.config'), 'utf-8');
const hostPort = `${viteConfig.server.host}:${viteConfig.server.port}`;
const codeContent = `
(function () {
'use strict'

const script = document.createElement('script');

script.src = 'http://${hostPort}/dist/${viteConfig.build.lib.name}.iife.js';

document.body.appendChild(script);
})()
`
;

const code = `${tampermonkeyConfig}\n${codeContent}`;

prettier.format(code, { parser: 'babel' }).then((formatted) => {
if(fs.existsSync(path.resolve(__dirname, codeFilePath))) {
fs.rm(path.resolve(__dirname, codeFilePath), () => {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
});
}
else {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
}
})

稍微用 prettier 美化一下。

七、完善 package.json 中的 script

我们其实只有开发模式,新建一个命令:

"dev": "node script/gen-tampermonkey.js && nodemon"

优先生成 tampermonkey.js,这时候会启动服务器,记得先将 tampermonkey.js 中的内容拷贝到油猴,才能方便热更新,不然又需要复制粘贴。

对于 build 命令:

"dev:build": "node script/gen-style-string.js && tsc && vite build && node script/gen-script-header-comment.js"

需要先将 less 编译为可用的 js 字符串模块,然后才能执行 buildbuild 完还需要拼接油猴注释,这样最终产物才具备可用的能力。

开发完成后,就将打包产物替换掉之前粘贴进油猴的内容。

八、额外的补充

vite 命令会直接启动本地开发服务器,而我们的 script 命令中,使用 && 时,下一个命令会等待上一个命令执行完成后再执行,所以 vite 需要放在最后执行,这是串行逻辑。当然,借助一些库我们可以实现并行 script 命令。但是我们这里需要的是串行,只是不完美的是,每次文件变更,都需要重新执行 pnpm dev:build && vite,这样会重复新启一个服务器,但是不重启的话,始终使用最初的那个服务,最新编译的资源无法被油猴感知,资源没有得到更新。

所以,聪明的你有办法解决吗?


作者:北岛贰
来源:juejin.cn/post/7437887483259584522
收起阅读 »

作为一个前端你连requestAnimationFrame的用法、优势和应用场景都搞不清楚?

web
前言 如果你是一名前端开发,那么你多少有了解过requestAnimationFrame吧?如果没有也接着往下看,会有详细用法说明。 其实很多人会局限于把requestAnimationFrame应用于一些纯动画相关的需求上,但其实在前端很多业务场景下requ...
继续阅读 »

前言


如果你是一名前端开发,那么你多少有了解过requestAnimationFrame吧?如果没有也接着往下看,会有详细用法说明。


其实很多人会局限于把requestAnimationFrame应用于一些纯动画相关的需求上,但其实在前端很多业务场景下requestAnimationFrame都能用于性能优化,下面将细说一下requestAnimationFrame的具体用法和几种应用场景


requestAnimationFrame作用与用法


requestAnimationFrame简述


MDN官方说法是这样的
image.png


基本示例


<script lang="ts" setup>
function init() {
console.log('您好,我是requestAnimationFrame');
}
requestAnimationFrame(init)
</script>

效果如下
image.png


但是例子上面是最基本的调用方式,并且只简单执行了一次,而对于动画是要一直执行的。


下面直接上图看看官方的文档对这个的说明,上面说具体用法应该要递归调用,而不是单次调用。


image.png


递归调用示例


<script lang="ts" setup>
function init() {
console.log('您好,递归调用requestAnimationFrame');
requestAnimationFrame(init)
}
requestAnimationFrame(init)
</script>

执行动图效果如下


requestAnimationFrame会一直递归调用执行,并且调用的频率通常是与当前显示器的刷新率相匹配(这也是这个API核心优势),例如屏幕75hz1秒执行75次。


而且如果使用的是定时器实现此功能是无法适应各种屏幕帧率的。


动画.gif


回调函数


requestAnimationFrame执行后的回调函数有且只会返回一个参数,并且返回的参数是一个毫秒数,这个参数所表示是的上一帧渲染的结束时间,直接看看下面代码示例与打印效果。


<script lang="ts" setup>
function init(val) {
console.log('您好,requestAnimationFrame回调:', val);
requestAnimationFrame(init);
}
requestAnimationFrame(init);
</script>


image.png


注意: 如果我们同时调用了很多个requestAnimationFrame,那么他们会收到相同的时间戳,因为与屏幕的帧率相同所以并不会不一样。


终止执行


终止此API的执行,官方提供的方法是window.cancelAnimationFrame(),语法如下


ancelAnimationFrame(requestID)   

直接看示例更便于理解,用法非常类似定时器的clearTimeout(),直接把 requestAnimationFrame 返回值传给 cancelAnimationFrame() 即可终止执行。


<template>
<div>
<button @click="stop">停止</button>
</div>
</template>
<script lang="ts" setup>
let myReq;
function init(val) {
console.log('您好,requestAnimationFrame回调:', val);
myReq = requestAnimationFrame(init);
}
requestAnimationFrame(init);

function stop() {
cancelAnimationFrame(myReq);
}
</script>


动画.gif


requestAnimationFrame优势


1、动画更丝滑,不会出现卡顿


对比传统的setTimeoutsetInterval动画会更流畅丝滑。


主要 原因 是由于运行的浏览器会监听显示器返回的VSync信号确保同步,收到信号后再开始新的渲染周期,因此做到了与浏览器绘制频率绝对一致。所以帧率会相当平稳,例如显示屏60hz,那么会固定1000/60ms刷新一次。


但如果使用的是setTimeoutsetInterval来实现同样的动画效果,它们会受到事件队列宏任务、微任务影响会导致执行的优先级顺序有所差异,自然做不到与绘制同频。


所以使用setTimeoutsetInterval不但无法自动匹配显示屏帧率,也无法做到完全固定的时间去刷新。


2、性能更好,切后台会暂停


当我们把使用了requestAnimationFrame的页面切换到后台运行时,requestAnimationFrame会暂停执行从而提高性能,切换回来后会马上提着执行。


效果如下动图,隐藏后停止运行,切换回来接着运行。


动画.gif


应用场景:常规动画


用一个很简单的示例:用requestAnimationFrame使一张图片动态也丝滑旋转,直接看示例代码和效果。


思路:首先在页面初始化时执行window.requestAnimationFrame(animate)使动画动起来,实现动画一直丝滑转运。在关闭页面时用window.cancelAnimationFrame(rafId)去终止执行。


<template>
<div class="container">
<div :style="imgStyle" class="earth"></div>
</div>
</template>

<script setup>
import { ref, onMounted, reactive, onUnmounted } from 'vue';

const imgStyle = reactive({
transform: 'rotate(0deg)',
});

let rafId = null;

// 请求动画帧方法
function animate(time) {
const angle = (time % 10000) / 5; // 控制转的速度
imgStyle.transform = `rotate(${angle}deg)`;

rafId = window.requestAnimationFrame(animate);
}

// 开始动画
onMounted(() => {
rafId = window.requestAnimationFrame(animate);
});

// 卸载时生命周末停止动画
onUnmounted(() => {
if (rafId) {
window.cancelAnimationFrame(rafId);
}
});
</script>

<style scoped>
body {
box-sizing: border-box;
background-color: #ccc;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}

.container {
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

.earth {
height: 100px;
width: 100px;
background-size: cover;
border-radius: 50%;
background-image: url('@/assets/images/about_advantage_3.png'); /* 替换为实际的路径 */
}
</style>


看看动图效果
动画2.gif


应用场景:滚动加载


在滚动事件中用requestAnimationFrame去加载渲染数据使混动效果更加丝滑。主要好久有几个



  • 提高性能: 添加requestAnimationFrame之后会在下一帧渲染之前执行,而不是每次在滚动事件触发的时候就立即执行。这可以减少大量不必要的计算,提高性能。

  • 用户体验更好:确保在绘制下一帧时再执行,使帧率与显示屏相同,视觉上会更丝滑。


代码示例和效果如下。


<template>
<div class="container" ref="scrollRef">
<div v-for="(item, index) in items" :key="index" class="item">
{{ item }}
</div>
<div v-if="loading" class="loading">数据加载中...</div>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';

const loading = ref(false);
let rafId: number | null = null;
// 数据列表
const items = ref<string[]>(Array.from({ length: 50 }, (_, i) => `Test ${i + 1}`));

// 滚动容器
const scrollRef = ref<HTMLElement | null>(null);

// 模拟一个异步加载数据效果
const moreData = () => {
return new Promise<void>((resolve) => {
setTimeout(() => {
const newItems = Array.from({ length: 50 }, (_, i) => `Test ${items.value.length + i + 1}`);
items.value.push(...newItems);
resolve();
}, 1000);
});
};

// 检查是否需要加载更多数据
const checkScrollPosition = () => {
if (loading.value) return;

const container = scrollRef.value;
if (!container) return;

const scrollTop = container.scrollTop;
const clientHeight = container.clientHeight;
const scrollHeight = container.scrollHeight;

if (scrollHeight - scrollTop - clientHeight <= 100) {
startLoading();
}
};

// 加载数据
const startLoading = async () => {
loading.value = true;
await moreData();
loading.value = false;
};

// 监听滚动事件
const handleScroll = () => {
console.log('滚动事件触发啦');
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(checkScrollPosition);
};

// 添加滚动事件监听器
onMounted(() => {
if (scrollRef.value) {
scrollRef.value.addEventListener('scroll', handleScroll);
}
});

// 移除相关事件
onUnmounted(() => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
if (scrollRef.value) {
scrollRef.value.removeEventListener('scroll', handleScroll);
}
});
</script>

<style scoped>
.container {
padding: 20px;
max-width: 800px;
overflow-y: auto;
margin: 0 auto;
height: 600px;
}

.item {
border-bottom: 1px solid #ccc;
padding: 10px;
}

.loading {
padding: 10px;
color: #999;
text-align: center;
}
</style>

看看下面动图效果
动画3.gif


小结


通过代码示例配合动图讲解后,再通过两个简单的事例可能大家会发现,只要在页面需要运动的地方其实都可以用到 requestAnimationFrame 使效果变的更加丝滑。


除了上面两个小示例其它非常多地方都可以用到requestAnimationFrame去优化性能,比较常见的例如游戏开发、各种动画效果和动态变化的布局等等。


文章就写到这啦,如果文章写的哪里不对或者有什么建议欢迎指出。


作者:天天鸭
来源:juejin.cn/post/7431004279819288613
收起阅读 »

前端:为什么 try catch 能捕捉 await 后 Promise 的错误?

web
一次代码CR引发的困惑 “你这块的代码,没有做异常捕获呀,要是抛出了异常,可能会影响后续的代码流程”。这是一段出自组内代码CR群的聊天记录。代码类似如下: const asyncErrorThrow = () => { return new Prom...
继续阅读 »

一次代码CR引发的困惑


“你这块的代码,没有做异常捕获呀,要是抛出了异常,可能会影响后续的代码流程”。这是一段出自组内代码CR群的聊天记录。代码类似如下:
const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
// 假设这里抛出了错误
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
}
testFun();

testFun 函数中,抛出错误后,await 函数中后续流程不会执行。


仔细回想一下,在我的前端日常开发中,对于错误捕获,还基本停留在使用 Promise时用 catch 捕获一下 Promise 中抛出的错误或者 reject,或者最基本的,在使用 JSON.parseJSON.stringfy等容易出错的方法中,使用 try..catch... 方法捕获一下可能出现的错误。


后来,这个同学将代码改成了:


const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
try {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
} catch (error) {
console.log("若错误发生 async 函数中的后续流程"); // 会执行
}
}
testFun();

而这次不同的是,这段修改后的代码中使用了 try...catch...来捕获 async...await... 函数中的错误,这着实让我有些困惑,让我来写的话,我可能会在 await 函数的后面增加一个 catch:await asyncErrorThrow().catch(error => {})。因为我之前已经对 try..catch 只能捕获发生在当前执行上下文的错误(或者简单理解成同步代码的错误)有了一定的认知,但是 async...await... 其实还是异步的代码,只不过用的是同步的写法,为啥用在这里就可以捕获到错误了呢?在查阅了相当多的资料之后,才清楚了其中的一些原理。


Promise 中的错误


我们都知道,一个 Promise 必然处于以下几种状态之一:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。

  • 已兑现(fulfilled):意味着操作成功完成。

  • 已拒绝(rejected):意味着操作失败。


当一个 Promise 被 reject 时,该 Promise 会变为 rejected 状态,控制权将移交至最近的 rejection 处理程序。最常见的 rejection 处理程序就是 catch handler或者 then 函数的第二个回调函数。而如果在 Promise 中抛出了一个错误。这个 Promise 会直接变成 rejected 状态,控制权移交至最近的 error 处理程序。


const function myExecutorFunc = () => {
// 同步代码
throw new Error();
};
new Promise(myExecutorFunc);

Promise 的构造函数需要传入的 Executor 函数参数,实际上是一段同步代码。在我们 new 一个新的 Promise 时,这个 Executor 就会立即被塞入到当前的执行上下文栈中进行执行。但是,在 Executor 中 throw 出的错误,并不会被外层的 try...catch 捕获到。


const myExecutorFunc = () => {
// 同步代码
throw new Error();
};
try {
new Promise(myExecutorFunc);
} catch (error) {
console.log('不会执行: ', error);
}
console.log('会执行的'); // 打印

其原因是因为,在 Executor 函数执行的过程中,实际上有一个隐藏的机制,当同步抛出错误时,相当于执行了 reject 回调,让该 Promise 进入 rejected 状态。而错误不会影响到外层的代码执行。


const myExecutorFunc = () => {
throw new Error();
// 等同于
reject(new Error());
};
new Promise(myExecutorFunc);
console.log('会执行的'); // 打印

同理 then 回调函数也是这样的,抛出的错误同样会变成 reject。


在一个普通脚本执行中,我们知道抛出一个错误,如果没有被捕获掉,会影响到后续代码的执行,而在 Promise 中,这个错误不会影响到外部代码的执行。对于 Promise 没有被捕获的错误,我们可以通过特定的事件处理函数来观察到。


new Promise(function() {
throw new Error("");
}); // 没有用来处理 error 的 catch
// Web 标准实现
window.addEventListener('unhandledrejection', function(event) {
console.log(event);
// 可以在这里采取其他措施,如日志记录或应用程序关闭
});
// Node 下的实现
process.on('unhandledRejection', (event) => {
console.log(event);
// 可以在这里采取其他措施,如日志记录或应用程序关闭
});


Promise 是这样实现的,我们可以想一想为什么要这样实现。我看到一个比较好的回答是这个:


传送门。我也比较赞成他的说法,我觉得,Promise 的诞生是为了解决异步函数过多而形成的回调地狱,使用了微任务的底层机制来实现异步链式调用。理论上是可以将同步的错误向上冒泡抛出然后用 try...catch... 接住的,异步的一些错误用 catch handler 统一处理,但是这样做的话会使得 Promise 的错误捕获使用起来不够直观,如果同步的错误也进行 reject 的话,实际上我们处理错误的方式就可以统一成 Promise catch handler 了,这样其实更直观也更容易让开发者理解和编写代码。


async await 的问题


那么回到我们最开始的问题,在这个里面,为什么 try catch 能够捕获到错误?
const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
try {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
} catch (error) {
console.log("若错误发生 async 函数中的后续流程"); // 会执行
}
}
testFun();

我思考了很久,最后还是从黄玄大佬的知乎回答中窥见的一部分原理。



这...难道就是浏览器底层帮我们处理的事儿吗,不然也没法解释了。唯一能够解释的事就是,async await 原本就是为了让开发者使用同步的写法编写异步代码,目的是消除过多的 Promise 调用链,我们在使用 async await 时,最好就是不使用 .catch 来捕获错误了,而直接能使用同步的 try...catch... 语法来捕获错误。即使 .catch 也能做同样的事情。只是说,代码编写风格统一性的问题让我们原本能之间用同步语法捕获的错误,就不需要使用 .catch 链式调用了,否则代码风格看起来会有点“异类”。


这就是为什么 async MDN 中会有这样一句解释:





参考文档:


《使用Promise进行错误治理》- zh.javascript.info/promise-err…


《为什么try catch能捕捉 await 后 promise 错误? 和执行栈有关系吗?》http://www.zhihu.com/question/52…


作者:21Pilots
来源:juejin.cn/post/7436370478521991183
收起阅读 »

告别 "if-else",改用 "return"!

web
大家好,我是CodeQi!  一位热衷于技术分享的码仔。 在日常的开发中,很多人习惯于使用 if-else 语句来处理各种条件。但你有没有想过,层层嵌套的条件判断,可能会让代码变得难以维护且难以阅读?今天,我想分享一个让代码更清晰易读的技巧,那就是——retu...
继续阅读 »

大家好,我是CodeQi!  一位热衷于技术分享的码仔。


在日常的开发中,很多人习惯于使用 if-else 语句来处理各种条件。但你有没有想过,层层嵌套的条件判断,可能会让代码变得难以维护且难以阅读?今天,我想分享一个让代码更清晰易读的技巧,那就是——return。✨


if-else 真的有必要吗?


初学编程时,我们都习惯通过 if-else 语句来处理分支逻辑。比如判断一个用户是否活跃,是否有折扣,代码通常会写成这样:


function getDiscountMessage(user) {
  if (user.isActive) {
    if (user.hasDiscount) {
      return `折扣已应用于 ${user.name}!`;
    } else {
      return `${user.name} 不符合折扣条件。`;
    }
  } else {
    return `用户 ${user.name} 已被停用。`;
  }
}

你看,这段代码嵌套了多个 if-else 语句。如果我们继续在这种风格的代码上添加更多条件判断,会变得更加难以阅读和维护。过多的嵌套让人一眼难以理清逻辑。更严重的是,随着代码量增多,容易导致出错。


return:清晰与高效的代码编写方式


所谓的提前return,就是在遇到异常情况或不符合条件时,立即返回并结束函数。通过提前处理错误情况或边界情况,我们可以把代码的“理想情况”留到最后处理。这种写法可以让代码更清晰,逻辑更加直接。🎯


示例:用return优化代码


来看一看如何用return来重写上面的代码:


function getDiscountMessage(user) {
  if (!user.isActive) {
    return `用户 ${user.name} 已被停用。`;
  }

  if (!user.hasDiscount) {
    return `${user.name} 不符合折扣条件。`;
  }

  // 理想情况:用户活跃且符合折扣条件
  return `折扣已应用于 ${user.name}!`;
}

🌟 优势



  1. 每个条件只处理一次:每个 if 语句都提前处理好错误情况,让后面的代码不必考虑这些条件。

  2. 代码结构更扁平:没有嵌套的 if-else 块,更加一目了然。

  3. 更易维护:当我们想增加或修改判断逻辑时,只需在前面添加或修改条件判断,不会影响到“理想情况”的代码部分。


return vs if-else:一个真实场景


假设我们有一个需要检查多个条件的函数,validateOrder,要确保订单状态有效、用户有权限、库存足够等情况:


function validateOrder(order) {
  if (!order.isValid) {
    return `订单无效。`;
  }

  if (!order.userHasPermission) {
    return `用户无权限。`;
  }

  if (!order.hasStock) {
    return `库存不足。`;
  }

  // 理想情况:订单有效,用户有权限,库存足够
  return `订单已成功验证!`;
}

通过这种方式,我们将所有不符合条件的情况都提前处理掉,将主逻辑留到最后一行。这不仅让代码更易读,而且可以提高代码的运行效率,因为无须进入嵌套的条件分支。🎉


何时使用return


虽然提前return是优化代码的好方式,但并不是所有情况下都适用。以下是一些适用场景:



  • 多条件判断:需要检查多个条件时,尤其是多个边界条件。

  • 简单条件过滤:对于不符合条件的情况可以快速返回,避免执行复杂逻辑。

  • 确保主要逻辑代码始终位于底部:这样可以减少逻辑处理的复杂性。


结语


当我们写代码时,保持代码简洁明了是一项重要的原则。通过采用提前return,我们可以减少嵌套层次,避免过度依赖 if-else,让代码更直观、易维护。如果你还没有使用return,不妨从现在开始尝试一下!😎


下次写代码时,记得问自己一句:“这个 if-else 可以用return替换吗?


让我们一起追求清晰、优雅的代码!Happy Coding! 💻


作者:CodeQi技术小栈
来源:juejin.cn/post/7431120645981831194
收起阅读 »

一种纯前端的H5灰度方案

web
什么是灰度发布 在互联网领域,灰度发布是产品质量保障的重要一环,它可以让某次更新的产品,以一种平滑,逐步扩大的方式呈现给用户,在此过程中,产品和技术团队可以对功能进行验证,收集用户反馈,不断优化,从而减少线上问题的影响范围,完善产品功能。 在前端领域,APP和...
继续阅读 »

什么是灰度发布


在互联网领域,灰度发布是产品质量保障的重要一环,它可以让某次更新的产品,以一种平滑,逐步扩大的方式呈现给用户,在此过程中,产品和技术团队可以对功能进行验证,收集用户反馈,不断优化,从而减少线上问题的影响范围,完善产品功能。


在前端领域,APP和小程序天生就具有灰度的能力,一般基于发布平台来控制。但 H5 却缺少这种天生能力,而且 H5 一旦发布就会影响所有用户,更加需要一套灰度系统,来保证产品的稳定性。


灰度发布的本质


既然要让部分用户先使用新功能,就需要做好两件事情,这也是灰度的本质:



  1. 版本控制 同一个项目需要在线上同时发布至少两套页面,一套针对全量用户,一套针对灰度用户

  2. 分流控制 需要有一套规则,把用户按某种特征划分为不同的群体,可以是用户ID,门店、城市,也可以是年龄,亦或是随机。命中的用户访问灰度页面,未命中的访问全量页面。


Pasted image 20240803093018.png


那么想要实现灰度发布有哪些方案呢?


可选的灰度方案


Nginx+lua+redis


通过使用 Nginx 的反向代理特性,我们可以根据请求的特定属性(例如ip、请求头、cookie)等有选择性的将请求路由到全量或灰度版本。


同时在 Nginx 中嵌入 Lua 脚本,负责根据预定义的灰度发布策略处理请求,Lua 脚本可以从 Redis 中获取灰度配置。从而确定哪些用户可以访问新版本,那些用户应该可以访问旧版本。


Redis 用于存储灰度发布的配置数据。


通过这种方式可以实现基于 Ngnix 的灰度发布,但这种方式并不适合我们,为什么呢?


因为我们的C端H5页面连同HTML文件都是直接投放在 CDN 上,这就意味着我们没有中转服务层,无法使用第一套 Nginx 的方案,而且使用 Nginx 也会响应降低页面加载速度,虽然可能很轻微,但却是对所有用户都会有影响。


采用 Nginx 进行中转:


Pasted image 20240803102717.png


不采用 Nginx 中转:


Pasted image 20240803102758.png


如上两张图,可以很明显的看到,如果采用 Nginx 来作为中转并进行分流控制,将导致我们的 CDN 优势失效,所有的流量都可能回到上海的机房,再流转到上海的 CDN,这显然不是我们想看到的。


这也是我们放弃 Nginx+lua+redis 方案的原因。


基于 SSR 做灰度


如果我们的前端页面是通过服务端来进行渲染,可以把灰度控制继承在服务端渲染中,基于不同的用户放回不同的HTML,这样也就可以做到灰度发布。


Pasted image 20240803093946.png


不过这需要有一套完善的 SSR系统,对于访问量大的产品,维持系统稳定性的难度远大于实现 SSR 本身的技术难度。由于我们是前后端分离,并且没有基于 Node 高可用的运维团队和经验,所以这个方案也就放弃了。


APP拦截灰度


基于APP的方案,是在用户点击H5资源位,创建webview时,拉取灰度配置,如果当前页面有灰度,则拉取灰度配置,判断是否命中灰度,如果命中,替换H5链接即可。


Pasted image 20240803100556.png


看过我其他文章的朋友,应该有了解到我们针对H5秒开有一套配置下发到APP,那么灰度配置,也可以集成到原有配置中,一并下发给APP,这套方案相对而言也比较简单,但是却有如下问题。



  1. 只能支持APP,APP外和小程序内打开的场景无法支持

  2. 依赖APP,公司其他业务线的APP,如果要使用也需要开发,工作量较大。


所以最后该方案也被排除。


纯前端方案


方案概览


基于如上的一些原因,于是我们采用了一套纯前端的方案,来解决灰度发布问题,虽然这套方案也有一点缺点。前面我们提到灰度发布的本质,其实包含两个方面,一是版本控制,二是分流控制。


版本控制比较好做,我们把全量的HTML代码发布到 index.html 文件,把灰度的HTML代码发布到 gray.html 文件,这样就做到了版本控制。


分流控制,可以被拆分为两部分,一部分只管获取配置、判定是否命中灰度并入在本地,另一部只管读取结果并执行跳转,这样整个系统就解耦了。


方案大体思路是:



  1. 在用户首次方式时,静默激活灰度计算逻辑,通过接口或其他条件判断用户是否命中灰度,把结果存储在 localStorage 中。

  2. 有别于全量版本时使用 index.html,灰度时构建并修改html名称为 gray.html,并发布

  3. 当要灰度发布时,下载 index.html ,注入灰度判断代码到 head 中,注入 GRAY_SWITCH 开关并开启

  4. 当用户再次访问时,执行灰度判断代码,如果命中,重定向到 gray.html 页面

  5. 对获取页面点击的地方,进行封装或拦截,确保灰度用户分享出去的链接,是全量链接


流程图:


Pasted image 20240803111309.png


时序图如下:


Pasted image 20240803113658.png


灰度版本控制


对于版本控制,我们通过提供了一个 webpack 插件集成到构建流程中,在构建时生成不同文件名的 html 文件。


通过构建命令参数,来区分各种发布情况


npm run build your_project_name -- --gray=open  
# --gray 的值
# --gray=close 不打开灰度,默认值
# --gray=open 打开灰度
# --gray=full 灰度全量
# --gray=unpublish 撤销灰度

可以分为如下情况:


正式发布


构建时生成:



  • index.html 全量页面

  • index_backup.html 全量备份页面(用来做回归)


灰度发布


构建时生成:



  • gray.html 灰度页面

  • gray_backup.html 灰度备份页(用来在全量后替换 index_backup.html)


同时下载 index.html ,注入灰度重定向控制JS。


重定向控制代码如下:


// 标记是否打开灰度  
window.__GRAY_SWITCH__ = 1 
let graySwitchName = 'gray_switch_';
// 获取去除html后的pathname
const pathname = window.location.pathname.split('/').slice(0, -1).join('/'); 
graySwitchName = graySwitchName + pathname
const graySwitch = localStorage.getItem(graySwitchName)
if (graySwitch === '1') {
const grayUrl = window.location.href.replace('index.html''_gray.html')
if(window.history.replaceState){
// 安卓 app 使用 location.replace 无效
window.history.replaceState(nulldocument.title, grayUrl);
}else{
window.location.replace(grayUrl);
}
}

修改输出的 HTML 文件名,是通过编写 webpack 的自定义插件来完成。


原理是通过 compiler.hooks.afterEmit.tapAsync 钩子函数,再 “输出” 阶段,对文件名进行修改。


撤销灰度


从云端下载 index_backup.html 重命名为 index.html 放在打包目录,之后再由发布系统上传。


全量发布


从云端下载 gray.html 和 gray_backup.html,重命名为 index.html 和 index_backup.html,发布后就会替换原有的全量HTML。


灰度分流控制


分流的重点是如何判断哪些用户能命中灰度。每个项目划分人员的策略都可能不同,比如C端页面更倾向于按useID随机划分。而B端拣货、配送等业务线,更需要按门店来进行划分,这样可以做到同门店员工体验一致,便于管理。所以这块这块必须要足够的灵活性。


我们这里采取了两种方式:

第一种是基于接口来做分流控制:把用户信息传给服务端,接口通过配置的灰度规则,计算是否命中,并返回前端。前端只管把结果存入本地。

第二种是把计算逻辑都放在前端,比较适合C端项目,因为C端项目大部分场景都是随机划分灰度用户。


灰度分流计算的JS代码是在用户每次打开后,静默运行,所以需要引入到业务代码中。


引入的代码如下:


import grayManager from '@cherry/grayManager'  
import { getMemberId } from '../utils/index'

// 伪代码,说明GrayOptions 的类型
interface GrayOptions {
    // 灰度比例控制 支持固定值和数组阶梯灰度,配置grayScale 后,grayComputeFn无效
    grayScalenumber | [number]
    // 自定义灰度方法,在内可以请求接口等
    grayCompute() => (() => Promise<boolean>) | boolean
    // 获取维护标识,比如以 shopId 为灰度标识,该函数就返回当前用户的 shopId
    getGaryData() => ()=> Promise<string>,
    // 配置灰度白名单,白名单内的用户都会命中灰度
    whiteDatastring[]
}

// 初始化灰度计算逻辑
grayManagerInit({
    grayScale10,
    whiteData: ['123''456']
})

前端计算分流


随机百分比

多数项目,我们一般使用的策略是随机,比如设置10%的用户命中灰度。


我们可以通过生成随机数来判断是否命中灰度,具体步骤如下:



  1. 在 grayManager.init() 时,随机生成一个 uuid,存在用户本地,不做清除,下次 init 时,先从本地取 uuid,存储 key 命名为 __GRAY_UUID__

  2. 当使用预置灰度计算能力时,取 __GRAY_UUID__ 每位转化为 asci 码并相加,除以100 求余数

  3. 用余数+1 和灰度比例(grayScale)对比,当余数 +1 <= grayScale 时命中灰度


这样可以得到一个近似 10% 比例的灰度用户数。


基于门店和城市分流

如果想基于门店或城市分流,我们只需要配置两个参数, 一是如何获取门店和城市ID

另一个是需要灰度的门店和城市ID


import grayManager from '@cherry/grayManager'  
import { getShopId } from '../utils/index'

grayManagerInit({
    getGaryData() => {
        return await getCityId()
    },
    whiteData: ['123''456']
})

可以通过 grayScale 配置数组来实现,起始时间为打灰度包构建的时间,我们会把构建时间注入到 HTML 中。


其他注意项


开头讲过,这套方案有一点缺点。可能大家也会发现,灰度时用户需要先进入打 HTML,执行 head 中注入的重定向控制JS,对命中灰度的用户再次跳转到 gray.html


这样其实带来了两个问题:一是对灰度用户来说经过了两个HTML,白屏的时间会更长。二是灰度用户访问的URL变化了,如果此时用户把页面分享出去,被分享用户将直接打开灰度页面。


对于第一条,全量用户是不会被影响,只有灰度用户才会白屏更久,我们目前测试白屏的时长还能接受。


对于第二条,我们最初是系统通过 Object.defineProperty 来拦截 对 window.location.pathname 的获取,返回 index.html。但window.location.pathname 是一个只读属性不可拦截。


最后只能提供统一的方法,来获取 pathname


结语


以上就是我们的灰度核心方案,整个方案会比较简单,几乎不依赖外部部门。无论是对于H5还是pcWeb,亦或是不同的容器,都无依赖,各个业务线都可以平滑使用。


作者:思考的Joey
来源:juejin.cn/post/7438840414239326227
收起阅读 »

用Three.js搞个炫酷风场图

web
风场图,指根据风速风向数据进行渲染,以表征空气流动方向、流动速度的一种动态流场图。接下来让我们学一下怎么实现炫酷的2D和3D风场图吧!一、 获取风场数据打开NCEP(美国气象环境预报中心)查看Climate Models(气候模型)的部分点击Climate F...
继续阅读 »

风场图,指根据风速风向数据进行渲染,以表征空气流动方向、流动速度的一种动态流场图。接下来让我们学一下怎么实现炫酷的2D和3D风场图吧!

vvvv.gif

一、 获取风场数据

  1. 打开NCEP(美国气象环境预报中心)
  2. 查看Climate Models(气候模型)的部分
  3. 点击Climate Forecast System 3D Pressure Products(气候预报系统3D大气压产品)的grib fiter选择数据下载

image.png 4. 界面会有不同日期的数据提供下载,我们选择默认最新的那个日期就好

  1. 一堆看不懂的参数,没关系,我们只需要在Levels图层这里勾选max wind这个就好(因为我们要画风场图),不推荐Levels勾选all,数据太大,下载慢,并且看不懂,用不到。
  2. 点击Start download就可以下载了

image.png

二、处理风场数据

grib这个数据格式打不开,看不懂,需要转换成json,有位大牛A写了个java的grib处理工具(grib2json),然而我用maven打包失败了,然后发现有另一位大牛B封装了大牛A的jar包成node脚本,正好给前端开发者使用。

  1. 安装@weacast/grib2json
pnpm add -D @weacast/grib2json
  1. 执行脚本,将grib转换成json

使用说明

Usage: grib2json (or node bin.js) [options] 
-V, --version 输出版本号
-d, --data 输出GRIB记录数据
-c, --compact 压缩json
-fc, --filter.category 选择类目值
-fs, --filter.surface 选择表面类型
-fp, --filter.parameter 选择参数值
-fv, --filter.value 选择表面值
-n, --names 打印数字代码的名称
-o, --output 输出文件名
-p, --precision 使用小数点后几位数的精度(默认值:-1)
-v, --verbose 启用stdout日志记录
-bs, --bufferSize stdout或stderr上允许的最大数据量(以字节为单位)
-h, --help 使用帮助
pnpm exec  grib2json -c --names --data --fp 2 --fs 103 --fv 10.0 -o output.json D:/code/wind/pgbf2024103000.01.2024103000.grb2

注意:

  • --fs 103表面类型103(地面以上指定高度)
  • --fv 10.0 距离GRIB2文件10.0米的表面值
  • --fp 2 将参数2(U-component_of_wind)的记录输出到stdout
  • 需要转换的grib文件放在最后,文件路径要用完整的路径名称
  1. 数据格式说明
{
"header":{
//数据更新时间
"refTime":"2024-10-30T00:00:00.000Z",

"parameterCategory":2,//类目号,2表示风力
"parameterCategoryName":"Momentum",
"parameterNumber":2,//2表示u,3表示v
"parameterNumberName":"U-component_of_wind",
"numberPoints":65160,//点数量
"nx":360,//横向栅格数量
"ny":181, //纵向栅格数量
"lo1":0.0,//开始经度
"la1":-90.0,//开始纬度
"lo2":359.0,//结束经度
"la2":90.0,//结束纬度
"dx":1.0,//横向步长
"dy":1.0//纵向补偿
},
"data":[//方向数据,u数据,要搭配另一个v的数据使用
-7.8,
-7.9,
]
}

U表示横向风速,V表示纵向风速,UV的正负值表示风向

  1. output.json有2.25MB大,数据里面除了uv方向的数据,还包含了其他的数据,我们只需要有用的一个header和uv数据即可,可以用node处理一下,得到一个header信息数据info.json和风向数据wind.json
const fs = require('fs');
const output = require('./output.json');
let uData = [];
let vData = [];
let header = {};
for (let i = 0; i < output.length; i++) {
if (output[i].header.parameterNumber === 2) {//u的数据集
uData = output[i].data;
header = output[i].header;
} else if (output[i].header.parameterNumber === 3) {//v的数据集
vData = output[i].data;
}
}

const len = uData.length;
const list = [];
const info = {
minU: Number.MAX_SAFE_INTEGER,
maxU: Number.MIN_SAFE_INTEGER,
minV: Number.MAX_SAFE_INTEGER,
maxV: Number.MIN_SAFE_INTEGER,
...header
};
for (let i = 0; i < len; i++) {
//uv数据组合
list.push([uData[i], vData[i]]);
//计算最大最小边界值
info.minU = Math.min(uData[i], info.minU);
info.maxU = Math.max(uData[i], info.maxU);
info.minV = Math.min(vData[i], info.minV);
info.maxV = Math.max(vData[i], info.maxV);
}

fs.writeFileSync('./wind.json', JSON.stringify(list));
fs.writeFileSync('./info.json', JSON.stringify(info));

三、绘制2D风场图

重头戏来了!瞪大你的眼睛(0 v 0),看好了!

1. 创建风场网格

nx和ny对应横向纵向网格数量,然后uv数据按照nx行,ny列组装添加到二维数组里面就是网格了。

 this.grid = [];
let index = 0;
for (let j = 0; j < header.ny; j++) {
const row = [];
for (let i = 0; i < header.nx; i++) {
const item = this.data[index++];
row.push(item);
}
this.grid.push(row);
}

2. 获取点xy对应的风向uv

根据风场网格获取该xy先在应的风向uv,点xy可能不是整数,那么这时候需要使用双线性插值(根据临近的周围四个点计算出插值)算出对应的风向uv。

  • 根据xy获取风向uv
 getUV(x, y) {
let x0 = Math.floor(x),
y0 = Math.floor(y);
//正好落在网格里
if (x0 === x && y0 === y) return this.getGrid(x, y);

let x1 = x0 + 1;
let y1 = y0 + 1;

//临近四周的点
let g00 = this.getGrid(x0, y0),
g10 = this.getGrid(x1, y0),
g01 = this.getGrid(x0, y1),
g11 = this.getGrid(x1, y1);
return this.bilinearInterpolation(x - x0, y - y0, g00, g10, g01, g11);
}
  • 不落在整数网格里面的采用双线性插值计算出风向uv
  /**双线性插值
* g00, g10, g01, g11对应临近可映射的四个点
* x为当前点与最近点x坐标差
* y为当前点与最近点y坐标差
* ***/

bilinearInterpolation(x, y, g00, g10, g01, g11) {
let rx = 1 - x;
let ry = 1 - y;
let a = rx * ry,
b = x * ry,
c = rx * y,
d = x * y;
let u = g00[0] * a + g10[0] * b + g01[0] * c + g11[0] * d;
let v = g00[1] * a + g10[1] * b + g01[1] * c + g11[1] * d;
return [u, v];
}

  • 获取网格数值,需规整超出的边界值
getGrid(x, y) {
const h = this.header;
if (x < 0) {
x = 0;
} else if (x > h.nx - 1) {
x = h.nx - 1;
}

if (y < 0) {
y = 0;
} else if (y > h.ny - 1) {
y = h.ny - 1;
}

return this.grid[y][x];
}

3. 创建随机点

 createRandParticle() {
//必须在风场网格范围内才能获取到风向uv
const x = Math.random() * this.header.nx;
const y = Math.random() * this.header.ny;

const uv = this.getUV(x, y);

return {
//起点位置
x,
y,
//终点位置=当前位置加上风向偏移
tx: x + this.speed * uv[0],
ty: y + this.speed * uv[1],
//生命周期,将生命周期归零的时候重新设置起点坐标
age: Math.floor(Math.random() * this.maxAge)
};
}
//重新设置随机点
setParticleRand(p) {
const newp = this.createRandParticle();
for (let k in p) {
p[k] = newp[k];
}
}
  • 生成随机点
 
this.particles = [];
for (let i = 0; i < this.particlesCount; i++) {
this.particles.push(this.createRandParticle());
}

4. 绘制风场图

canvas绘制风场即用线段的起点和终点跟随着风向不断运动形成风场图。

  • 设置canvas
//缓存canvas context之前的合成操作类型
const pre = ctx.globalCompositeOperation;
//'destination-in'仅保留现有画布内容和新形状重叠的部分。其他的都是透明的。
ctx.globalCompositeOperation = 'destination-in';
//之前绘制的保留重叠部分
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
//还原合成操作类型
ctx.globalCompositeOperation = pre;


//设置线的全局透明度
ctx.globalAlpha = 0.8;

注意cxt.fillRect本来清空之前的画布内容,但采用了globalCompositeOperation='destination-in'globalAlpha=0.5的透明度作为重叠标准,重叠部分以0.5的透明度重新绘制并保留下来,通过这种方式,可以形成很多连续点的感觉,如果设置为1的透明度则会全部保留,并且不停叠加,等价于没有清空画布的状态。

  • 遍历随机点更新位置
      this.particles.forEach((p) => {
if (p.age <= 0) {
//生命周期耗尽重新设置随机点值
this.setParticleRand(p);
} else {
if (!this.inBound(p.x, p.y)) {
//画出范围外重新设置随机点值
this.setParticleRand(p);
} else {
//根据下一个点的风向,计算出下一个点的位置
const uv = this.getUV(p.tx, p.ty);
const nextx = p.tx + this.speed * uv[0];
const nexty = p.ty + this.speed * uv[1];
//将起点换成之前的终点
p.x = p.tx;
p.y = p.ty;
//终点设置成计算出的下一个点
p.tx = nextx;
p.ty = nexty;
//生命周期递减
p.age--;
}
}
//起始点和终点转换成显示的画布大小
const start = this.getCanvasPos(p.x, p.y);
const end = this.getCanvasPos(p.tx, p.ty);
//渐变跟随线段的方向
const gradient = ctx.createLinearGradient(start[0], start[1], end[0], end[1]);
for (let k in this.color) {
gradient.addColorStop(+k, this.color[k]);
}
//绘制线段
ctx.beginPath();
ctx.strokeStyle = gradient;
ctx.moveTo(start[0], start[1]);
ctx.lineTo(end[0], end[1]);
ctx.stroke();
});

5. 使用封装类绘制

async function main() {
//风场信息数据
const header = await getData('./info.json');
//风场uv方向数据
const data = await getData('./wind.json');

const canvas = document.getElementById('canvas');
canvas.width = 1200;
canvas.height = 600;
const cw = new Windy({
header,
data,
canvas,
//运动速度
speed: 0.1,
//随机点数量
particlesCount: 1000,
//生命周期
maxAge: 120,
//1秒更新次数
frame: 10,
//线渐变
color: {
0: 'rgba(255,255,0,0)',
1: '#ffff00'
},
//线宽度
lineWidth: 3
});
}

20241102_204252.gif

效果非常好,线段顺着风向在运动!

  • 上面的线段因为一段段渐变呈现出一个个小蝌蚪的样子,然而利用叠加保留的效果,可以自动将线段绘制渐变色。只需要改变一下绘制顺序就行
  
//线段绘制开始
ctx.beginPath();
//设置纯颜色
ctx.strokeStyle = this.color;
//遍历随机点更新位置
this.particles.forEach((p) => {
//同上面更新随机点的位置
//...

//起始点和终点转换成显示的画布大小
const start = this.getCanvasPos(p.x, p.y);
const end = this.getCanvasPos(p.tx, p.ty);
//通过moveTo和lineTo绘制多个线段
ctx.moveTo(start[0], start[1]);
ctx.lineTo(end[0], end[1]);
});
//最终统一绘制线段
ctx.stroke();

20241102_213723.gif

这样看上去流动线段连续性更强,不那么零散了!

6. 利用图片信息存储数据的优化

wind.json风场uv方向数据有739KB接近1MB,这着实有点大,要是网络稍微有点卡都会很影响首屏加载时间!从webgl-wind中我看到了用Canvas的ImageData中颜色来存储与解析数值,这操作太优秀了!

实现逻辑:用nx*ny与风场网格同样大小的canvas,获取到ImageData,将像素颜色四个数值中red红色和green绿色分别赋值成uv转换后的颜色值,注意透明度一定要置为不透明,然后put回canvas里面绘制,再利用canvas.toDataURL导出图片。

async function createCanvas() {
const data = await getData('./wind.json');
const info = await getData('info.json');
const canvas = document.getElementById('theCanvas');
canvas.width = info.nx;
canvas.height = info.ny;

const minU = Math.abs(info.minU);
const minV = Math.abs(info.minV);
// uv风方向范围
const uSize = info.maxU - info.minU;
const vSize = info.maxV - info.minV;
const ctx = canvas.getContext('2d');
//获取imageData像素数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
data.forEach((item, i) => {
//值转换成正数
const u = item[0] + minU;
const v = item[1] + minV;
//转换成颜色值
const r = (u / uSize) * 255;
const g = (v / vSize) * 255;
imageData.data[i * 4] = r;
imageData.data[i * 4 + 1] = g;
//透明度默认255即不透明
imageData.data[i * 4 + 3] = 255;
});
//用imageData像素颜色值绘制图片
ctx.putImageData(imageData, 0, 0);
}

wind.png

这样一张360px*181px的图片存储了65,160个点,但仅仅只需要86.6KB,压缩成原来数据的十分之一了。

  • 如果改用风场方向图片,那么对应需要添加加载和解析数据的流程

加载风场方向数据图片


loadImageData() {
return new Promise((resolve) => {
const image = new Image();
image.src = this.imageUrl;
image.onload = () => {
const c = document.createElement('canvas');
c.width = image.naturalWidth;
c.height = image.naturalHeight;
const ctx = c.getContext('2d');
//绘制图片
ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight);
//获取ImageData像素数据
const imageData = ctx.getImageData(0, 0, image.naturalWidth, image.naturalHeight);

resolve(imageData.data);
};
});
}

解析图片数据成uv,并组装成风场网格Grid

data = await this.loadImageData();

const minU = Math.abs(header.minU);
const minV = Math.abs(header.minV);
//uv风方向范围
const uSize = header.maxU - header.minU;
const vSize = header.maxV - header.minV;

let index = 0;
for (let j = 0; j < header.ny; j++) {
const row = [];
for (let i = 0; i < header.nx; i++) {
//将颜色数据转化成风向uv数据
const u = (data[index] / 255) * uSize - minU;
const v = (data[index + 1] / 255) * vSize - minV;
row.push([u, v]);
index = index + 4;
}
this.grid.push(row);
}

后面的绘制风场逻辑跟上面一样,只不过多了个加载图片解析的过程。

20241102_214148.gif

加上一张世界地图底图可以更清晰得看到风流动的方向!

四、绘制3D风场图

1.利用Canvas风场贴图绘制3D风场图

  • 常规的顶点着色器
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);

}
  • 片元着色器,要将世界底图与风场图合并成一张图
varying vec2 vUv;
uniform sampler2D windTex;
uniform sampler2D worldTex;
void main() {
vec4 color = texture2D(windTex, vUv);
float a = color.a;
if(a < 0.01) {
a = 0.;
}

vec4 w = texture2D(worldTex, vUv);
//根据透明度合并世界贴图和风场贴图
vec4 c = w * (1. - a) + color * a;

gl_FragColor = c;
}
  • 创建风场贴图
async createWindCanvas() {
const header = await getData('./info.json');
const canvas = document.createElement('canvas');
//要足够大,否则会贴图模糊
canvas.width = 4000;
canvas.height = 2000;
this.cw = new Windy({
header,
// data,
canvas,
//运动速度
speed: 0.1,
//随机点数量
particlesCount: 1000,
//生命周期
maxAge: 120,
//1秒更新次数
frame: 10,
//线渐变
// color: {
// 0: 'rgba(255,255,0,0)',
// 1: '#ffff00'
// },
color: '#ffff00',
//线宽度
lineWidth: 3,
imageUrl: 'wind.png'
//autoAnimate: true
});
const texture = new THREE.CanvasTexture(canvas);
//因为是动态canvas,所以要置为需要更新
texture.needsUpdate = true;
return texture;
}
  • 添加球体
async createChart(that) {
this.windTex = await this.createWindCanvas();

const worldTex = new THREE.TextureLoader().load('../assets/world.jpg');
{
const material = new THREE.ShaderMaterial({
uniforms: {
worldTex: { value: worldTex },

windTex: { value: this.windTex }
},
vertexShader: document.getElementById('vertexShader').innerHTML,
fragmentShader: document.getElementById('fragmentShader').innerHTML,
side: THREE.DoubleSide,
transparent: true
});

const geometry = new THREE.SphereGeometry(2, 32, 16);

const sphere = new THREE.Mesh(geometry, material);
this.scene.add(sphere);
}
}
  • 让canvas动起来
animateAction() {
if (this.windTex) {
if (this.cw) {
this.cw.render();
}
this.windTex.needsUpdate = true;
}
}

20241102_230324.gif

地球展开收起动画

  • 将顶点着色器替换成下面的,根据uv计算出压平后球体表面点的位置,然后用mix来让原来球体表面的点过渡变化

注意球体半圆周长,对应球体压平后矩形的宽度,球体贴图正好是2:1,长度对应宽度的两倍。

uniform float time;
uniform float radius;
varying vec2 vUv;
float PI = acos(-1.0);
void main() {
vUv = uv;
//半圆周长
float w = radius * PI;
//随着时间压平或收起球体点位置
vec3 newPosition = mix(position, vec3(0.0, (uv.y - 0.5) * w, -(uv.x - 0.5) * 2.0 * w), sin(time * PI * 0.5));
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
  • 展开或收起球体动画
openMap() {
const tw = new TWEEN.Tween({ time: 0.0 })
.to({ time: 1.0 }, 2000)
.onUpdate((obj) => {
if (this.mat) {
this.mat.uniforms.time.value = obj.time;
}
})
.start();
TWEEN.add(tw);
}
closeMap() {
const tw = new TWEEN.Tween({ time: 1.0 })
.to({ time: 0.0 }, 2000)
.onUpdate((obj) => {
if (this.mat) {
this.mat.uniforms.time.value = obj.time;
}
})
.start();
TWEEN.add(tw);
}

20241102_232847.gif

除了用贴图来实现,还能用three.js的BufferGeometry+LineSegments实现动态线段,进而实现3D风场图。

2.使用LineSegments绘制风场图

  • 顶点着色器
uniform vec2 uResolution;//nx与ny网格大小
uniform vec2 uSize;//显示的宽高
varying vec2 vUv;
void main() {
vUv = vec2(position.z);
// 转换为经纬度坐标
vec2 p = vec2(position.x, -position.y) - vec2(180., 90.);

gl_Position = projectionMatrix * modelViewMatrix * vec4((p / uResolution) * uSize + vec2(0., uSize.y), 0.0, 1.);
}

注意:地球的经纬度是从下往上变大的,而平面的坐标是从上往下变大的的,因此随机点的y坐标取反才是正确位置,因为取反的问题,位置会偏移,对应也要将整体位置加上偏移量归位。

  • 片元着色器
varying vec2 vUv;
uniform vec3 startColor;
uniform vec3 endColor;
void main() {
//渐变色
gl_FragColor = vec4(mix(startColor, endColor, vUv.y), 1.0);
}
  • 绘制线段LineSegments 将随机点的开始结束两个点位置分别赋值到线段position里面,并添加索引。
//点索引
const points = new Float32Array(num * 6);
let i = 0;

pointCallback: (p) => {
// 线段开始位置
points[i] = p.x;
points[i + 1] = p.y;
points[i + 2] = 0;//开始点z坐标标识是0
// 线段结束位置
points[i + 3] = p.tx;
points[i + 4] = p.ty;
points[i + 5] = 1;//结束点z坐标标识是1

//递增索引
i += 6;
}

添加LineSegments,一定要用LineSegments,因为LineSegments是绘制的线段是gl.LINES模式,就是每两个点一组,形成一个新线段,就是A,B,C,D四个点,就会变成AB一条线段,BC一条线段,就可以绘制多条线段了。

 const material = new THREE.ShaderMaterial({
uniforms: {
//nx和ny网格大小
uResolution: { value: new THREE.Vector2(this.cw.header.nx, this.cw.header.ny) },
//显示宽高大小
uSize: { value: new THREE.Vector2(20, 10) },
//渐变开始颜色
startColor: { value: new THREE.Color('#ffff00') },
//渐变结束颜色
endColor: { value: new THREE.Color('#ff0000') }
},
vertexShader: document.getElementById('vertexShader1').innerHTML,
fragmentShader: document.getElementById('fragmentShader').innerHTML,
side: THREE.DoubleSide,
transparent: true
});

const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(points, 3));
this.geometry = geometry;
this.mat = material;
//添加多个线段
const lines = new THREE.LineSegments(geometry, material);
this.scene.add(lines);

渲染的时候移动点的位置并给position属性赋值更新

if (this.frameCount % this.frame === 0 && this.cw && this.geometry) {
let i = 0;
const g = this.geometry;
this.cw.movePoints((p) => {
g.attributes.position.array[i] = p.x;
g.attributes.position.array[i + 1] = p.y;
g.attributes.position.array[i + 3] = p.tx;
g.attributes.position.array[i + 4] = p.ty;
i += 6;
});
//属性值改变一定要置true,通知更新
g.attributes.position.needsUpdate = true;
}

20241103_155901.gif

上面效果的风场图与canvas 2D风场图清空再绘制一样的效果,没有走destination-in叠加保留的过程,点的数量可能看起来偏少,因此为了保证风流向的连续性,最好增加随机点个数。

  • 将平面的LineSegments变成球体 修改一下定点着色器,经纬度坐标转换成三维坐标
float PI = 3.1415926;
float rad = 3.1415926 / 180.;
uniform vec2 uResolution;
uniform vec2 uSize;
//半径
uniform float radius;
//旋转翻过来
uniform mat4 rotateX;

varying vec2 vUv;
//经纬度坐标转为三维坐标
vec3 lnglat2pos(vec2 p) {
float lng = p.x * rad;
float lat = p.y * rad;
float x = cos(lat) * cos(lng);
float y = cos(lat) * sin(lng);
float z = sin(lat);
return vec3(x, z, y);
}
void main() {
vUv = vec2(position.z);
//转换成经纬度
vec2 p = vec2(position.x, -position.y) - vec2(180., 90.);
//经纬度转三维坐标
vec3 newPosition = radius * lnglat2pos(p);
gl_Position = projectionMatrix * modelViewMatrix *rotateX* vec4(newPosition, 1.);

}

注意

  1. three.js高度y轴坐标,那么对应三维坐标里面的z轴坐标,而three.js深度z轴坐标,那么对应三维坐标里面的y轴坐标,就是yz轴要对调一下,才是正确的点的位置,即vec3(x, z, y)
  2. position转经纬度,同上面一样需要将y取反才是正确的位置。 3.地球贴图贴在球体x方向开始位置有PI的偏移,需要将贴图设置一下偏移值才能对上经纬度坐标。
const worldTex = new THREE.TextureLoader().load('../assets/world.jpg');
worldTex.offset.x = 0.5;
worldTex.wrapS = THREE.RepeatWrapping;

4.因为y取反了,但在球体不能用位置偏移量解决归位问题,就会导致整个风流向路径反过来了,所以需要添加一个矩阵翻转量,让风流向路径回归正确的样子,

  const matrix = new THREE.Matrix4();
matrix.makeRotationX(Math.PI);

20241103_170442.gif

终于解决风场位置对齐的问题了!这点小细节调了好久!唉~

五、Github地址

https://github.com/xiaolidan00/my-earth

参考


作者:敲敲敲敲暴你脑袋
来源:juejin.cn/post/7433055938418933787
收起阅读 »

前端js中如何保护密钥?

web
在前端js编程中,如果涉及到加密通信、加密算法,经常会用到密钥。 但密钥,很容易暴露。 暴露原因:js代码透明,在浏览器中可以查看源码,从中找到密钥。 例如,下面的代码中,变量key是密钥: 如何保护源码中的密钥呢? 很多时候,人们认为需要对密钥字符串进行加...
继续阅读 »

在前端js编程中,如果涉及到加密通信、加密算法,经常会用到密钥


但密钥,很容易暴露。 暴露原因:js代码透明,在浏览器中可以查看源码,从中找到密钥。


例如,下面的代码中,变量key是密钥:



如何保护源码中的密钥呢?


很多时候,人们认为需要对密钥字符串进行加密。其实更重要的是对存储密钥的变量进行加密


加密了密钥变量,使变量难以找到,才更能保护密钥本身。


顺着这个思路,下面给出一个不错的密钥的保护方法:


还是以上面的代码为例,


首先,用到jsfuck:


https://www.jshaman.com/tools/jsfuck.html

将代码中的密钥定义整体,用jsfuck加密:


var key = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";


加密后得到一串奇怪的字符,这是将变量“key ”以及密钥字符“0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ”隐藏了起来



注意:加密时需要选中“在父作用域中运行”,选中之后,key 变量的定义虽然不存在,但变量key是可用的!(这点很神奇)。也就是虽然代码中没有定义这个变量,但这个变量存在,且可用。而且它存储的就是密钥!



用加密后的代码替换掉原来的代码,变成如下形式:



运行效果:



即时他人拿走代码去调试,也会显示变量key未定义,如下图所示:



但,这时候还不足够安全,还能更安全。


将整体JS代码,再用JS加密工具:JShaman,进行混淆加密:


https://www.jshaman.com


然后得到更安全、更难调试分析的JS代码,这时密钥就变的更安全了:



注:用ajax等异步传递密钥时,也可以使用这个办法,也能很好的隐藏密钥。


用jsfuck+jshaman保护JS中的密钥,你学会了吗?


作者:w2sfot
来源:juejin.cn/post/7431087851389747236
收起阅读 »

Fuse.js一个轻量高效的模糊搜索库

web
最近逛github的时候发现了一个非常好用的轻量工具库,Fuse.js,支持模糊搜索。感觉还是非常好用的,所以有了此篇博客,这篇文章主要是介绍Fuse的使用,同样,我对这个开源项目的实现也非常感兴趣。后续会出一篇Fuse源码解析的文章来分析其实现原理。 Fus...
继续阅读 »

最近逛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的文章到此就结束了,你没看错就这么一点介绍就基本能支撑我们在项目中的应用,谢谢阅读,如果哪里有不对的地方请评论博主,会及时进行改正。


欢迎访问我的博客地址


作者:VapausQi
来源:juejin.cn/post/7393172686115569705
收起阅读 »

错怪react的半年-聊聊keepalive

web
背景 在半年前的某一天,一个运行一年的项目被人提了建议,希望有一个tabs页面,因为他需要多个页面找东西。 我:滚,自己开浏览器标签页(此处吹牛逼) 项目经理:我审核的时候也需要看别人提交的数据是否正确,我也需要看,很多人提了建议 我:作为一个合格的外包,肯定...
继续阅读 »

背景


在半年前的某一天,一个运行一年的项目被人提了建议,希望有一个tabs页面,因为他需要多个页面找东西。


我:滚,自己开浏览器标签页(此处吹牛逼)


项目经理:我审核的时候也需要看别人提交的数据是否正确,我也需要看,很多人提了建议


我:作为一个合格的外包,肯定以项目经理体验为主(狗头保命)


1 React的Keepalive


简单实现React KeepAlive不依赖第三方库(附源码)React KeepAlive 实现,不依赖第三方库,支持 - 掘金
神说要有光《React通关秘籍》


....还有一些没有收藏的keepalive实现


代码不想多赘述,我找了很多资料基本思路讲一下


基本是通过react-router来实现



  • 弄一个map来存储{path:<OutLet|useOutlet>}

  • 然后根据当前路由path来决定哪些组件需要渲染,不渲染的hidden

  • 然后在最外层布局套一层Tabs布局用react的context来传递


出现的问题是:


072932bf075694f67855441b3d7c883.jpg


就是当依赖项是一个公共数据的时候,useEffect会触发,如图片中的searchParams同名的key、存在state内存中的同名key之类的



详情页打开多个,地址栏清一色pkId的情况



使用的是ant design pro v6,也是看到有加配置就可以用Keepalive,大家可以掘金找找,我测试过也是有一样的问题,但是和目前能找到功能差不多了,免去自己封装


看很多react Keepalive的实现目前是还没找到什么方案,也皮厚的找过神光,但是要是知道我是被人忽悠了,绝对不会去打扰大佬



tip:先自己敲,再问,不要让自己陷于蠢逼的尴尬



发现问题后,我去问日常开发Vue的童鞋们,因为我两年没写vue了,我说:你们是怎么实现tabs布局的,Vue的Keepalive是怎么实现只有显示哪个页面,别的组件存储而不运行watch的,然后说了一下我在react开发tabs的时候尴尬。


Vueer:vue不会有这个问题,自带的Keepalive封装的很完美,react没有这个功能吗?


就这样我信了半年,但是这个bug我说,我只能到这种程度了,要是说新项目还可以再useEffect再封装一层自定义的hooks,增加一点心智负担,让别人用封装的来,再useEffect内做判断是否是显示的path之类的。


这边年来反正这个功能做了一个开关,项目经理自己用,别人要是没问也不告诉别人有开发这东西,嘿嘿嘿


之所以又捞起来是别的几个项目也有人问,然后问我能不能迁移给别的项目,那他妈不得王炸,别的项目有的还是umi3没升级的。


2 vue3的Keepalive


先说结论吧:vue的Keepalive也能解决这个问题,纯属胡扯


实在想不到什么好的方案,闲余时间,就用vue写了一个demo,想看看vue是怎么实现的,因为react除了就是hidden,或者超出overflow 然后切换是平移像轮播图一样,实在想不出什么方案能保存原本的数据了。


image.png


<template>
home
<ul>
<li v-for="item in router.getRoutes().filter(r=>r.path!=='/')" @click="liHandler(item)">
{{ item.path }}
</li>

</ul>
</template>
<script lang="ts" setup>
import {useRouter} from "vue-router";

const router = useRouter()

const liHandler = (route) => {
router.push({name: route.name, query: {path: route.path}})
}
</script>


简单来个demo的目录结构和代码,代码是会有问题的代码,不用细看...


两年没写vue还是遇到几个坑,先记录一下


2.1 vue3的routerview不能写在keepAlive内


// home.vue
<template>
<!-- <RouterView v-slot="{Component}">-->
<!-- <KeepAlive>-->
<!-- <component :is="Component"/>-->
<!-- </KeepAlive>-->
<!-- </RouterView>
-->

<KeepAlive>
<RouterView></RouterView>
</KeepAlive>

</template>

image.png


注释掉的是正确的,这里不提一嘴,vue的console.log的体验感很ok,下面列的就是正确的,我还去百度了为啥Keepalive无效,直到截这张图写这文的时候才看到,人家都谢了,哈哈哈哈


2.2 router.push地址不变


主要原因是路由创建的是
createMemoryHistory 这玩意不知道是啥 没用过,我是复制vueRoute官网demo的,一开始没注意,改成createWebHistory就好


import {createRouter, createWebHistory} from "vue-router";

const routes = [
{path: "/", component: () => import("./components/Home.vue")},
{path: "/aaa", component: () => import("./components/Aaa.vue"), name: 'aaa'},
{path: "/bbb", component: () => import("./components/Bbb.vue"), name: 'bbb'},
{path: "/ccc", component: () => import("./components/Ccc.vue"), name: 'ccc'},
{path: "/ddd", component: () => import("./components/Ddd.vue"), name: 'ddd'},
]
const router = createRouter({
history: createWebHistory(),
routes,
})

export default router

2.3 router.push不拼接?传参


router.push(path, query: {path}})

一开始是这么写的,用path+query,这个问题纯粹弱智了,太久没写有点菜,改成name+query就行


2.4 vue Keepalive测试


我先点了去aaa,然后返回首页点ccc,这里可以看到,aaa页面的watch触发了!触发了!触发了!


image.png


然后去看了下源码,简约版如下


const KeepAliveImpl = {
name: `KeepAlive`,

// 私有属性 标记 该组件是一个KeepAlive组件
__isKeepAlive: true,
props: {
// 用于匹配需要缓存的组件
include: [String, RegExp, Array],
// 用于匹配不需要缓存的组件
exclude: [String, RegExp, Array],
// 用于设置缓存上线
max: [String, Number]
},
setup(props, { slots }) {
// 省略部分代码...

// 返回一个函数
return () => {
if (!slots.default) {
return null
}

// 省略部分代码...

// 获取子节点
const children = slots.default()
// 获取第一个子节点
const rawVNode = children[0]
// 返回原始Vnode
return rawVNode
}
}
}

这不就是存下来了children,但是有个有意思的是


前面说过,react是通过缓存住组件,然后用hidden来控制展示哪个隐藏哪个,vue这边的dom渲染出来不是。


image.png


官网也说了动态组件是会卸载的


image.png


3 分析一波


image.png


直接用光哥的代码跑起来,这么一比可以看出来dom上的差异


然后把代码的显示改成判断语句渲染,测试一下


image.png


测试图:


image.png


image.png


然后去首页,再回到/bbb,发现数字又变回0了


先来看下React的渲染,通过卡颂大佬的文章,找到了react在render阶段


500行左右


image.png
这是判断是mountd还是update的一个标志,然而我们已经卸载了,这里肯定是null,那么就会去创建组建,仅仅是创建。


而vue因为自身有keepalive,在render阶段,是有对标志为keepalive的做patch的逻辑


image.png


image.png
所以keepalive的组件不会再走created和mounted,而是直接进行diff进行parch


总结



  • 公共参数无论是vue还是react都会被监听到

  • react想要实现像vue一样的效果只能等react官方适配


image.png
也是有提过啦


作者:不喝酒不会写代码
来源:juejin.cn/post/7436955628263784475
收起阅读 »

谈谈HTML5a标签的ping属性用法

web
前言 今天谈谈a标签ping属性的用法,这个用法可以用来做埋点,及用户上报,关于埋点,我之前有文章写过,利用空白gif图片,实现数据上报,ping的这种方式可以发送post请求给后端,当然也可以通过这个做DDOS攻击,今天详细介绍一下。 Ping的用法 Pin...
继续阅读 »

前言


今天谈谈a标签ping属性的用法,这个用法可以用来做埋点,及用户上报,关于埋点,我之前有文章写过,利用空白gif图片,实现数据上报,ping的这种方式可以发送post请求给后端,当然也可以通过这个做DDOS攻击,今天详细介绍一下。


Ping的用法


Ping的用法相对比较简单,我们通过举例的方式,为大家介绍:


href="https://www.haorooms.com/" ping="https://www.haorooms.com /nav">点击haorooms博客

当你点击‘点击haorooms博客’的时候,会异步发送一个POST请求到Ping后面指定的地址,Request Body的内容为PING。或许你会问,那


ping="https://www.haorooms.com /nav">点击haorooms博客

这段代码行不行?答案是否定的,和HTML中的a标签一样,HTML5中href这个属性必须存在与a中,不然Ping也是不会运行的。


应用一,埋点上报


我们可以看到 ping 请求的 content-type 是 text/ping,包含了用户的 User-Agent,是否跨域,目标来源地址等信息,非常方便数据收集的时候进行追踪。可以利用这个进行埋点上报,点击上报等。
Ping可以进行广告追踪,它可以统计用户都点击了哪些链接以及次数,并使用POST请求把这些信息发送到广告商的服务器上。那么POST的这些信息都包含了什么呢,简单来说HTTP Header的内容都会有,我们来看一个截获的完整信息


HOST: haorooms.com

CONTENT-LENGTH: 4

ORIGIN: http://mail.163.com

PING-FROM: http:/
/****.com/js6/read/readhtml.jsp?mid=458:xtbBygBMgFO+dvBcvQAAsM&font=15&color=064977

USER-AGENT: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.125 Safari/537.36

PING-TO: http://www.baidu.com/

CONTENT-TYPE: text/ping

ACCEPT: */*

REFERER: http:/
/****.com/js6/read/readhtml.jsp?mid=458:xtbBygBMgFO+dvBcvQAAsM&font=15&color=064977

ACCEPT-ENCODING: gzip, deflate

ACCEPT-LANGUAGE: zh-CN,zh;q=0.8

COOKIE: sessionid=rnbymrrkbkipn7byvdc2hsem5o0vrr13

CACHE-CONTROL: max-age=0

CONNECTION: keep-alive

PING-FROM、USER-AGENT、REFERER这三个关键信息,直接泄漏了用户的隐私(但几个月前,百度已宣布不支持REFERER)。而这也为我们最爱的XSSSHELL又提供了一个小插件。对于图片探针如果没了新鲜感,那么请试试Ping探针吧,简单的一句


href="" ping=>

就搞定!


ping 属性的优势



1、无需 JavaScript 代码参与,网页功能异常也能上报;


2、不受浏览器刷新、跳转过关闭影响,也不会阻塞页面后续行为,这一点和 navigator.sendBeacon()
类似,可以保证数据上报的准确性; 支持跨域;


href="https://www.haorooms.com/"  ping="https://www.baidu.com/ad.php">点击我

3、可上报大量数据,因为是 POST 请求;


4、语义明确,使用方便,灵活自主。



ping 属性的劣势



1、只能支持点击行为的上报,如果是进入视区,或弹框显示的上报,需要额外触发下元素的 click() 行为;


2、只能支持 a 元素,在其他元素上设置 ping 属性没有作用,这就限制了其使用范围,因为很多开发喜欢 div 一把梭。


3、只能是 POST 请求,目前主流的数据统计还是日志中的 GET 请求,不能复用现有的基建。


4、出生不好,身为 HTML 属性,天然受某些开发者无视与不屑。


5、适合在移动端项目使用,PC端需要酌情使用(不需要考虑上报总量的情况下),因为目前 IE 和 Firefox
浏览器都不支持(或没有默认开启支持)。



应用二,DDOS攻击


根据Ping发送POST请求这个特性,我们可以使用循环使之不停的向一个地址追加POST请求,造成DOS攻击。


var arr = ['https://www.haorooms1.com', 'https://www.haorooms2.com', 'https://www.haorooms3.com'];
function haoroomsDOS( ){
var indexarr = Math.floor((Math.random( )*arr.length));
document.writeln("");
}

if(arr.length>0){
var htimename = setlnterval("haoroomsDOS()", 1000);
}

防御方法


web服务器可以通过WAF(如:ShareWAF,http://www.sharewaf.com/)等拦截含有“Ping… HTTP headers的请求。


作者:haorooms
来源:juejin.cn/post/7438964981453094966
收起阅读 »

HTML 还有啥可学的?这份年终总结帮你梳理

web
💰 点进来就是赚到知识点!本文带你解读 2024年 HTML 的发展现状,点赞、收藏、评论更能促进消化吸收! 前言 作为前端三驾马车之一的 HTML,其关注度可能不如 CSS 和 JavaScript 那样高。但这绝不是因为它不重要,正相反,作为 Web 生...
继续阅读 »

💰 点进来就是赚到知识点!本文带你解读 2024年 HTML 的发展现状点赞收藏评论更能促进消化吸收!



前言


作为前端三驾马车之一的 HTML,其关注度可能不如 CSS 和 JavaScript 那样高。但这绝不是因为它不重要,正相反,作为 Web 生态的基石,HTML 是最早被设计出来构成 Web 页面的基本标准,它简明、稳定,所以非常让开发者省心,绝不是 CSS 和 JavaScript 那种闹人的孩子。


meme.jpeg


一般来说,30 岁的人就不怎么长高了,那么这项 30 岁的 Web 技术,是否也已经悄悄停止了生长呢?我的答案是:并没有。这不,最近《2024 HTML 年度调查结果报告》新鲜出炉,从从业人群、特性、工具等维度统计了来自全球 5000+ 的问卷结果,汇总出了 2024 年 HTML 的完整面貌。


如果你也不甘落后,想与业界保持同步的技术认知和水平,但又没时间仔细研究完整个维度繁复、类目庞杂的调查报告,那接下来,我会带你直击几个核心类目的 Top 5,让你轻松了解全球开发者最爱用、最关注、最期待的特性和 API。


最常用功能 Top5


001.jpeg


上图中列出的功能,是用过人数最多的前 5 个元素。



  • Landmark 元素:<main><nav><aside><header><footer><section> 这些 HTML5 语义化标签。还记得十年前我入行前端时,「什么是 HTML 语义化」是必考的面试题。

  • tabindex:控制元素的聚焦交互,是提升用户操作效率的小妙招。

  • 懒加载:控制图片、视频或 iframe 的加载时机,可以有效节省带宽、提升首屏加载速度。

  • srcset:设置多媒体元素的源路径,它的广泛使用代表着 Web 页面内容的多样性。

  • <details> 和 <summary>:原生折叠/展开控件。我得承认我还没直接使用过,看来技术栈要更新了。


最想了解的特性 Top5


002.jpeg


如上图所示,这 5 个特性是开发者们在填完问卷后最想要第一时间去学习的。



  • 自定义 Select 元素:可自定义内容和样式的下拉菜单,目前包括  <selectlist> 和 <selectmenu>


    003.gif


  • focusgroup:让用户能用键盘的方向键来选中聚焦元素,提升操作体验和效率。

  • Popover API:原生的弹层组件


    004.gif


  • EditContext API:控制元素可编辑性

  • 自定义高亮:用 CSS 控制文本选中后的样式


    005.jpeg



Web 组件库 Top5


006.jpeg
当被问到用哪些库/框架来搭建 UI 界面时,上图中这 5 种库名列前茅;而大家熟知的 Vue、React 则分别排在了第 12 和第 13。是不是很意外?其实这可能和问题的语境有关系。大型 Web 应用为了方便协作和维护一般用主流框架,但也有些中小工程用一些简洁框架反而更高效。


用 Web 技术开发原生应用时最常用的特性 Top5


007.jpeg


这一领域相对小众,样本数量下降了一个量级。但也为我们提供了不一样的视角,看到一些新鲜的 API:



  • Web Share API:用于控制分享逻辑。

  • File System Access API:用于处理设备本地的文件,增删改查样样行,能力超强,我有一个专栏就是写这个 API 的。

  • Launch API:控制 PWA 的启动逻辑。

  • FIle Handling API:用于在 PWA 中注册文件类型。

  • WIndow Controls Overlay API:PWA 控制自定义内容的显示。


网站生成框架 Top5


008.jpeg


这类框架一般用于静态官网、博客等站点的生成。



  • Next.js:基于 React,无论是国内外都是应用最广的主流框架。

  • Astro:它有一套自己的组件体系,像 Vue 但又有独到之处,很适合搭建博客。

  • Nuxt:基于 Vue,对标 Next.js。我在用,一套代码搞定前后端逻辑,非常爽。

  • SvelteKit:顾名思义是 Svelte 的配套生态。

  • Eleventy:还没用到过,从官网介绍看,是主打小巧简洁。很想玩一玩。


信息来源 Top5


009.jpeg


这一类目统计了开发者们日常获取泛 HTML 知识和信息的渠道,从数据可以看到大家主要用的都是上图这几种。


呼声最高的补完计划 Top5


010.jpeg


有这么一些组件,是咱们日常开发非常常用,但 HTML 却迟迟没有提供原生支持的:



  • 数据表格:指的是自带排序、过滤等常用功能的 table。

  • 标签页组件

  • Switch/Toggle 开关

  • 骨架屏、Loading 组件

  • 右键菜单


结语


恭喜你读完本文,你真棒!


这一次我们选取了 7 个核心维度来解读 《2024 HTML 年度调查结果报告》。如果其中有你陌生的技术点,那正好可以查缺补漏。


最后,咱们玩个互动小游戏:


把你的输入法切到中文,再按 HTML 这四个键,把你最离谱的联想词打在评论区,看看谁最逆天!


我用的是小鹤双拼,所以打出了「混天绫」,笑死,每天都用 HTML,原来我是哪吒。



📣 我是 Jax,在畅游 Web 技术海洋的又一年,我仍然是坚定不移的 JavaScript 迷弟,Web 技术带给我太多乐趣。如果你也和我一样,欢迎关注私聊



作者:JaxNext
来源:juejin.cn/post/7439353204054228992
收起阅读 »

离谱,split方法的设计缺陷居然导致了生产bug!

web
需求简介 大家好,我是石小石!前几天实现了这样一个需求: 根据后端images字段返回的图片字符,提取图片key查找图片链接并渲染。 由于后端返回的是用逗号分隔的字符,所以获取图片的key使用split方法非常方便。 if(data.images != ...
继续阅读 »

需求简介


大家好,我是石小石!前几天实现了这样一个需求:



根据后端images字段返回的图片字符,提取图片key查找图片链接并渲染。




由于后端返回的是用逗号分隔的字符,所以获取图片的key使用split方法非常方便。


if(data.images != null || data.images != undefined){

// 将字符通过split方法分割成数组
const picKeyList = data.images.split(",")

picKeyList.forEach(key => {
// 通过图片key查询图片链接
// ...
})

}

乍一看,代码并没有问题,qa同学在测试环境也验证了没有问题!于是,当晚,我们就推送生产了。


生产事故


几天后的一个晚上,我已经睡觉了,突然接到领导的紧急电话,说我开发的页面加载图片后白屏了!来不及穿衣服,我赶紧去排查bug。



通过断点排查,发现当后端返回的data.images是空字符“""”时,用split分割空字符,得到的picKeyList结果是 “[""]” ,这导致picKeyList遍历时,内部的 key是空,程序执行错误



然后我用控制台验证了一下split分割空字符,我人傻了。



后来,我也成功的为这次生产事故背锅。我也无可争辩,是我没完全搞懂split方法的作用机制。


ps:宝宝心里苦,为什么后端不直接返回图片的key数组!!为什么!!


split方法


吃一堑,长一智,我决定在复习一下split方法的使用,并梳理它的踩坑点及可能得解决方案。


语法


split() 用于将字符串按照指定分隔符分割成数组


string.split(separator, limit)


  • separator(可选):指定分隔符,可以是字符串或正则表达式。如果省略,则返回整个字符串作为数组。

  • limit(可选):整数,限制返回的数组的最大长度。如果超过限制,多余的部分将被忽略。


基本用法


使用字符串作为分隔符


const text = "苹果,华为,小米";
const result = text.split(",");
console.log(result);
// 输出: ['苹果', '华为', '小米']

使用正则表达式作为分隔符


const text = "苹果,华为,小米";
const result = text.split(/[,; ]+/); // 匹配逗号、分号或空格
console.log(result);
// 输出: ['苹果', '华为', '小米']

使用限制参数


const text = "苹果,华为,小米";
const result = text.split(",", 2);
console.log(result);
// 输出: ['苹果', '华为'] (限制数组长度为 2)

没有找到分隔符


const text = "hello";
const result = text.split(",");
console.log(result);
// 输出: ['hello'] (原字符串直接返回)

split方法常见踩坑点


空字符串的分割


const result = "".split(",");
console.log(result);
// 输出: [''] (非空数组,包含一个空字符串)

原因:

空字符串没有内容,split() 默认返回一个数组,包含原始字符串。


解决方案:


const result = "".split(",").filter(Boolean);
console.log(result);
// 输出: [] (使用 filter 移除空字符串)

多余分隔符


const text = ",,苹果,,华为,,";
const result = text.split(",");
console.log(result);
// 输出: ['', '', '苹果', '', '华为', '', '']

原因:

连续的分隔符会在数组中插入空字符串。


解决方案:


const text = ",,苹果,,华为,,";
const result = text.split(",").filter(Boolean);
console.log(result);
// 输出: ['苹果','华为']

filter(Boolean) 是一个非常常用的技巧,用于过滤掉数组中的假值


分割 Unicode 字符


const text = "👍😊👨‍👩‍👦";
const result = text.split('');
console.log(result);
// 输出: ['👍', '😊', '👨', '‍', '👩', '‍', '👦']

原因:

split("") 按字节分割,无法正确识别组合型字符。


解决方案:


const text = "👍😊👨‍👩‍👦";
const result = Array.from(text);
console.log(result);
// 输出: ['👍', '😊', '👨‍👩‍👦'] (完整分割)

总结


这篇文章通过本人的生产事故,向介绍了split方法使用可能存在的一些容易忽略的bug,希望大家能有所收获。一定要注意split分割空字符会得到一个包含空字符数组的问题


作者:石小石Orz
来源:juejin.cn/post/7439189795614916658
收起阅读 »

brain.js提升我们前端智能化水平

web
有时候真的不得不感叹,AI实在是太智能,太强大了。从自动驾驶,家具,AI无处不在。现在我们前端开发领域,AI也成了一种新的趋势,让不少同行压力山大啊。本文我们将探讨AI在前端开发中的应用,以及如何用浏览器端的神经网络库(brain.js)来提升我们前端的智能化...
继续阅读 »

有时候真的不得不感叹,AI实在是太智能,太强大了。从自动驾驶,家具,AI无处不在。现在我们前端开发领域,AI也成了一种新的趋势,让不少同行压力山大啊。本文我们将探讨AI在前端开发中的应用,以及如何用浏览器端的神经网络库(brain.js)来提升我们前端的智能化水平。


brain.js


开局即重点,我们先来介绍一下bran.js。


brain.js是由Brain团队开发的JavaScript库,专门用于实现神经网络。其源代码可以在Github上,任何人都可以进行查看,提问和贡献代码。



  • 起源: brain.js 最初是为了让前端开发者能够更容易地接触到机器学习技术而创建的。它的设计目标是提供一个简单易用的接口,同时保持足够的灵活性来满足不同需求。


功能:



  • 实例化神经网络:


<script src="./brain.js"></script>
const net = new brain.recurrent.LSTM();


  • 训练模型:可以提供灵活的训练方式,支持多种参数


const data = [
{ input: [0, 0], output: [0] },
{ input: [0, 1], output: [1] },
{ input: [1, 0], output: [1] },
{ input: [1, 1], output: [0] } ];
network.train(data, {
iterations: 2000, // 训练迭代次数
log: true, // 是否打印训练日志
logPeriod: 100 // 日志打印间隔
});


  • 进行模型推理


const output = network.run([1, 0]); //输出应该接近1
console.log(output);

训练结束后,用run方法进行推理


实战


话不多说,直接开始实战,这次我们进行一个任务分类,看看是前端还是后端。



  • 首先,我们先导包


可以直接利用npm下载,终端输入npm install brain.js安装,后面代码是这样


const brain = require('brain.js');

require 函数require 是 Node.js 中用于导入模块的函数。它会在 node_modules 目录中查找指定的模块,并将其导出的对象或函数加载到当前作用域中。


或者你可以像我一样,到Github仓库下好brain.js文件


<script src="./brain.js"></script>

<script> 标签<script> 标签用于在 HTML 文件中引入外部 JavaScript 文件。src 属性指定了 JavaScript 文件的路径。


完成第一步后,我们要用jason数组给大模型“喂”一些数据,用于后面的推理


 const data = [
{ "input": "自定义表单验证 ", "output": "frontend" }, // 前端任务
{ "input": "实现 WebSocket 进行实时通信", "output": "backend" }, // 后端任务
{ "input": "视差滚动效果 ", "output": "frontend" }, // 前端任务
{ "input": "安全存储用户密码", "output": "backend" }, // 后端任务
{ "input": "创建主题切换器(深色/浅色模式) ", "output": "frontend" }, // 前端任务
{ "input": "高流量负载均衡", "output": "backend" }, // 后端任务
{ "input": "为残疾用户提供的无障碍功能": "frontend" }, // 前端任务
{ "input": "可扩展架构以应对增长的用户基础 ", "output": "backend" } // 后端任务 ];


  • 再然后,初始化我们的神经网络:


    const network = new brain.recurrent.LSTM();

这里的LSTM是brain.js中提供的一种类,用于创建长短期记忆网络Long Short-Term Memory


经过这个操作,我们就拥有一个可训练的和可使用的LSTM模型。



  • 开始训练


// 训练模型 network.train(data, {
iterations: 2000, // 训练迭代次数
log: true, // 是否打印训练日志
logPeriod: 100 // 日志打印间隔
});

注意:训练需要花费一段时间。



  • 执行程序


   const output = network.run("自定义表单验证");// 前端任务
console.log(output);

此时,我们的神经网络就会开始推理这是个什么任务,在进行一段时间的训练后,就会出现结果


image.png
此时,正确输出了,这是个前端frontend任务。
类似的,我们改成


 const output = network.run("高流量负载均衡"); // 后端任务
console.log(output);

经过一段时间的训练后,得到


image.png
也得到了正确结果,这是个后端backend任务。


总结


brain.js凭借其简洁的API设计和强大的功能,为前端开发者提供了一个易于上手的工具,降低了进入AI领域的门槛,促进了前端开发与AI技术的深度融合。本文我们用bran.js进行了一个简单的数据投喂,实现了我们的任务。相信在未来会有更具有创新性的应用案例出现,推动行业发展。


作者:黑马王子13
来源:juejin.cn/post/7438655509899444251
收起阅读 »

用了组合式 (Composition) API 后代码变得更乱了,怎么办?

web
前言 组合式 (Composition) API 的一大特点是“非常灵活”,但也因为非常灵活,每个开发都有自己的想法。加上项目的持续迭代导致我们的代码变得愈发混乱,最终到达无法维护的地步。本文是我这几年使用组合式API的一些经验总结,希望通过本文让你也能够写出...
继续阅读 »

前言


组合式 (Composition) API 的一大特点是“非常灵活”,但也因为非常灵活,每个开发都有自己的想法。加上项目的持续迭代导致我们的代码变得愈发混乱,最终到达无法维护的地步。本文是我这几年使用组合式API的一些经验总结,希望通过本文让你也能够写出易维护优雅组合式API代码。


加入欧阳的高质量vue源码交流群、欧阳平时写文章参考的多本vue源码电子书


选项式API


vue2的选项式API因为每个选项都有固定的书写位置(比如数据就放在data里面,方法就放在methods里面),所以我们只需要将代码放到对应的选项中就行了。


优点是因为已经固定了每个代码的书写位置,所有人写出来的代码风格都差不多。


缺点是当单个组件的逻辑复杂到一定程度时,代码就会显得特别笨重,非常不灵活。
option-api


随意的写组合式API


vue3推出了组合式 (Composition) API,他的主要特点就是非常灵活。解决了选项式API不够灵活的问题。但是灵活也是一把双刃剑,因为每个开发的编码水平不同。所以就出现了有的人使用组合式 (Composition) API写出来的代码非常漂亮和易维护,有的人写的代码确实很混乱和难易维护。


比如一个组件开始的时候还是规规矩矩的写,所有的ref响应式变量放在一块,所有的方法放在一块,所有的computed计算属性放在一块。


但是随着项目的不断迭代 ,或者干脆是换了一个人来维护。这时的代码可能就不是最开始那样清晰了,比如新加的代码不管是refcomputed还是方法都放到一起去了。如下图:
chao


只有count1count2时,代码看着还挺整齐的。但是随着count3的代码加入后看着就比较凌乱了,后续如果再加count4的代码就会更加乱了。


有序的写组合式API


为了解决上面的问题,所以我们约定了一个代码规范。同一种API的代码全部写在一个地方,比如所有的props放在一块、所有的emits放在一块、所有的computed放在一块。并且这些模块的代码都按照约定的顺序去写,如下图:
sort


随着vue组件的代码增加,上面的方案又有新的问题了。


还是前面的那个例子比如有5个countref变量,对应的computedmethods也有5个。此时我们的vue组件代码量就很多了,比如此时我想看看computed1increment1的逻辑是怎么样的。


因为computed1increment1函数分别在文件的computedmethods的代码块处,computed1increment1之间隔了几十行代码,看完computed1的代码再跳转去看increment1的代码就很痛苦。如下图:
long


这时有小伙伴会说,抽成hooks呗。这里有5个count,那么就抽5个hooks文件。像这样的代码。如下图:
hooks-file


一般来说抽取出来的hooks都是用来多个组件进行逻辑共享,但是我们这里抽取出来的useCount文件明显只有这个vue组件会用他。达不到逻辑共享的目的,所以单独将这些逻辑抽取成名为useCounthooks文件又有点不合适。


最终解决方案


我们不如将前面的方案进行融合一下,抽取出多个useCount函数放在当前vue组件内,而不是抽成单个hooks文件。并且在多个useCount函数中我们还是按照前面约定的规范,按照顺序去写ref变量、computed、函数的代码。


最终得出的最佳实践如下图:
perfect


上面这种写法有几个优势:



  • 我们将每个count的逻辑都抽取成单独的useCount函数,并且这些函数都在当前vue文件中,没有将其抽取成hooks文件。如果哪天useCount1中的逻辑需要给其他组件使用,我们只需要新建一个useCount文件,然后直接将useCount1函数的代码移到新建的文件中就可以了。

  • 如果我们想查看doubleCount1increment1中的逻辑,只需要找到useCount1函数,关于count1相关的逻辑都在这个函数里面,无需像之前那样翻山越岭跨越几十行代码才能从doubleCount1的代码跳转到increment1的代码。


总结


本文介绍了使用Composition API的最佳实践,规则如下:



  • 首先约定了一个代码规范,Composition API按照约定的顺序进行书写(书写顺序可以按照公司代码规范适当调整)。并且同一种组合式API的代码全部写在一个地方,比如所有的props放在一块、所有的emits放在一块、所有的computed放在一块。

  • 如果逻辑能够多个组件复用就抽取成单独的hooks文件。

  • 如果逻辑不能给多个组件复用,就将逻辑抽取成useXXX函数,将useXXX函数的代码还是放到当前组件中。


    第一个好处是如果某天useXXX函数中的逻辑需要给其他组件复用,我们只需要将useXXX函数的代码移到新建的hooks文件中即可。


    第二个好处是我们想查看某个业务逻辑的代码,只需要在对应的useXXX函数中去找即可。无需在整个vue文件中翻山越岭从computed模块的代码跳转到function函数的代码。



最后推荐一下欧阳自己写的开源电子书vue3编译原理揭秘,看完这本书可以让你对vue编译的认知有质的提升,并且这本书初、中级前端能看懂。完全免费,只求一个star。


作者:前端欧阳
来源:juejin.cn/post/7398046513811095592
收起阅读 »

HTML到PDF转换,11K Star 的pdfmake.js轻松应对

web
在Web开发中,将HTML页面转换为PDF文件是一项常见的需求。无论是生成报告、发票、还是其他任何需要打印或以PDF格式分发的文档,开发者都需要一个既简单又可靠的解决方案。幸运的是,pdfmake.js库以其轻量级、高性能和易用性,成为了许多开发者的首选。本文...
继续阅读 »

在Web开发中,将HTML页面转换为PDF文件是一项常见的需求。无论是生成报告、发票、还是其他任何需要打印或以PDF格式分发的文档,开发者都需要一个既简单又可靠的解决方案。幸运的是,pdfmake.js库以其轻量级、高性能和易用性,成为了许多开发者的首选。本文将介绍如何使用这个拥有11K Star的GitHub项目来实现HTML到PDF的转换。


什么是pdfmake.js


pdfmake.js是一个基于JavaScript的库,用于在客户端和服务器端生成PDF文档。它允许开发者使用HTML和CSS来设计PDF文档的布局和样式,使得创建复杂的PDF文档变得异常简单。


为什么选择pdfmake.js



  • pdfmake.js的文件大小仅为11KB(压缩后),这使得它成为Web应用中一个非常轻量级的解决方案

  • 拥有超过11K Star的GitHub项目,pdfmake.js得到了广泛的社区支持和认可,稳定性和可靠性值得信任

  • 功能丰富,它支持表格、列表、图片、样式、页眉页脚等多种元素,几乎可以满足所有PDF文档的需求。

  • pdfmake.js可以轻松集成到任何现有的Web应用中,无论是使用Node.js、Angular、React还是Vue.js。


快速开始


安装


通过npm安装pdfmake.js非常简单:


npm install pdfmake

或者,如果你使用yarn:


yarn add pdfmake

创建PDF文档


创建一个PDF文档只需要几个简单的步骤:



  1. 引入pdfmake.js


import  pdfMake from 'pdfmake/build/pdfmake';

//引入中文字体,避免转换的PDF中文乱码
pdfMake.fonts = {
AlibabaPuHuiTi: {
normal: 'https://xx/AlibabaPuHuiTi-3-55-Regular.ttf',
bold: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf',
italics: 'https://xxx/AlibabaPuHuiTi-3-55-Regular.ttf',
bolditalics: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf'
}
};


  1. 定义文档内容


const dd = {
content: [
'Hello, 我是程序员凌览',
{ text: 'This is a simple PDF document.', fontSize: 12 },
{ text: 'It is generated using pdfmake.js.', bold: true }
],
//设置默认字体
defaultStyle: {
font: 'AlibabaPuHuiTi'
},
};


  1. 创建PDF


const pdf = pdfMake.createPdf(dd);
pdf.getBlob((buffer) => {
const file = new File([blob], filename, { type: blob.type })
//上传服务器
});

//或直接下载
pdf.download('文件名.pdf')

生成的pdf效果:



想动手体验,请访问pdfmake.org/playground.…



html-to-pdfmake 强强联合


当PDF文档内容非固定,content字段内的结构要随时可变,不能再像下方代码块一样写死,html-to-pdfmake即为解决这类问题而产生的。


const dd = {
content: [
'Hello, 我是程序员凌览',
{ text: 'This is a simple PDF document.', fontSize: 12 },
{ text: 'It is generated using pdfmake.js.', bold: true }
],
//设置默认字体
defaultStyle: {
font: 'AlibabaPuHuiTi'
},
};

安装


通过npm安装:


npm install html-to-pdfmake

或者,如果你使用yarn:


yarn add html-to-pdfmake

HTML字符串转pdfmake格式



  1. 引入html-to-pdfmake


import  pdfMake from 'pdfmake/build/pdfmake';
import htmlToPdfmake from 'html-to-pdfmake';

//引入中文字体,避免转换的PDF中文乱码
pdfMake.fonts = {
AlibabaPuHuiTi: {
normal: 'https://xx/AlibabaPuHuiTi-3-55-Regular.ttf',
bold: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf',
italics: 'https://xxx/AlibabaPuHuiTi-3-55-Regular.ttf',
bolditalics: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf'
}
};

//它会返回pdfmake需要的数据结构
const html = htmlToPdfmake(`
<div>
<h1>程序员凌览</h1>
<p>
This is a sentence with a <strong>bold word</strong>, <em>one in italic</em>,
and <u>one with underline</u>. And finally <a href="https://www.somewhere.com">a link</a>.
</p>
</div>
`
);


  1. 使用html-to-pdfmake转换的数据结构


const dd = {
content:html.content,
//设置默认字体
defaultStyle: {
font: 'AlibabaPuHuiTi'
},
};
const pdf = pdfMake.createPdf(dd);
pdf.download()

生成的pdf效果:



添加图片要额外设置:


const ret = htmlToPdfmake(`<img src="https://picsum.photos/seed/picsum/200">`, {
imagesByReference:true
});
// 'ret' contains:
// {
// "content":[
// [
// {
// "nodeName":"IMG",
// "image":"img_ref_0",
// "style":["html-img"]
// }
// ]
// ],
// "images":{
// "img_ref_0":"https://picsum.photos/seed/picsum/200"
// }
// }

const dd = {
content:ret.content,
images:ret.images
}
pdfMake.createPdf(dd).download();

最后


通过上述步骤,我们可以看到pdfmake.js及其配套工具html-to-pdfmake为Web开发者提供了一个强大而灵活的工具,以满足各种PDF文档生成的需求。无论是静态内容还是动态生成的内容,这个组合都能提供简洁而高效的解决方案。



程序员凌览的技术网站linglan01.cn/;关注公粽号【程序员凌览】回复"1",获取编程电子书



作者:程序员凌览
来源:juejin.cn/post/7376894518330359843
收起阅读 »

可视化大屏开发,知道这三个适配方案,效率翻倍!

web
哈喽,大家好 我是 xy👨🏻‍💻。今天和大家来聊一聊大屏可视化适配过程中的痛点以及怎么去解决这些痛点!!! 前言 开发过大屏可视化应用的前端工程师们通常会有这样的共识: 在可视化开发过程中,最具有挑战性的并非各种图表的配置与效果展示,而是如何确保大屏在不同尺...
继续阅读 »

哈喽,大家好 我是 xy👨🏻‍💻。今天和大家来聊一聊大屏可视化适配过程中的痛点以及怎么去解决这些痛点!!!



前言


开发过大屏可视化应用的前端工程师们通常会有这样的共识:


在可视化开发过程中,最具有挑战性的并非各种图表的配置与效果展示,而是如何确保大屏不同尺寸的屏幕上都能实现良好的适配。


原始解决方案


起初比较流行的三大解决方式:


rem 方案


  • 动态设置 HTML 根字体大小和 body 字体大小,配合百分比或者 vw/vh 实现容器宽高字体大小位移的动态调整


vw/vh 方案


  • 像素值(px)按比例换算为视口宽度(vw)和视口高度(vh),能够实时计算图表尺寸、字体大小等


scale 方案


  • 根据宽高比例进行动态缩放,代码简洁,几行代码即可解决,但是遇到一些地图或者 Canvas 中的点击事件,可能会存在错位问题,需要做针对性的调整适配


以上三种方式,都能够实现大屏的基本适配!


但是在开发过程中需要对每个字体容器去做相应的计算调整,相对来说较为繁琐,而且在团队协作过程中也容易出现问题。


那么有没有一种方式,只需要简单的一些配置,就能完全搞定大屏在不同尺寸的屏幕上都能实现良好的适配


以下给大家推荐三个方案,只需要简单的几行代码配置,可以完全解决大屏开发中的适配问题,让你效率翻倍!!!


autofit.js


autofit.js 基于比例缩放原理,通过动态调整容器的宽度和高度来实现全屏填充,避免元素的挤压或拉伸。


autofit.js 提供了一种简单而有效的方法来实现网页的自适应设计,尤其适合需要在不同分辨率屏幕尺寸下保持布局一致性的应用场景。


image.png


安装:

npm i autofit.js

配置:

import autofit from 'autofit.js';
onMounted(() => {
autofit.init({
el: '#page',
dw: 375,
dh: 667
})
})
* - 传入对象,对象中的属性如下:
* - el(可选):渲染的元素,默认是 "body"
* - dw(可选):设计稿的宽度,默认是 1920
* - dh(可选):设计稿的高度,默认是 1080
* - resize(可选):是否监听resize事件,默认是 true
* - ignore(可选):忽略缩放的元素(该元素将反向缩放),参数见readme.md
* - transition(可选):过渡时间,默认是 0
* - delay(可选):延迟,默认是 0

源码地址


Github 地址:https://github.com/995231030/autofit.js



v-scale-screen


大屏自适应容器组件,可用于大屏项目开发,实现屏幕自适应,可根据宽度自适应高度自适应,和宽高等比例自适应全屏自适应(会存在拉伸问题),如果是 React 开发者,可以使用 r-scale-screen


image.png


安装:

npm install v-scale-screen
# or
yarn add v-scale-screen

配置:

<template>
<v-scale-screen width="1920" height="1080">
<div>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
</div>
</v-scale-screen>
</template>

<script>
import { defineComponent } from 'vue'
import VScaleScreen from 'v-scale-screen'

export default defineComponent({
name: 'Test',
components: {
VScaleScreen
}
})
</script>

源码地址


github 地址:https://github.com/Alfred-Skyblue/v-scale-screen



FitScreen


一种基于缩放的大屏自适应解决方案的基本方法,一切都是基于设计草图的像素尺寸,通过缩放进行适配,一切都变得如此简单。


支持 vue2vue3 以及 react,可以适用于任何框架,只要一点点代码。


image.png


安装:

npm install @fit-screen/vue
# or
yarn add @fit-screen/vue
# or
pnpm install @fit-screen/vue

配置:

<script setup>
import FitScreen from '@fit-screen/vue'
</script>

<template>
<FitScreen :width="1920" :height="1080" mode="fit">
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo">
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo">
</a>
</div>
<HelloWorld msg="Vite + Vue" />
</FitScreen>
</template>

源码地址


github 地址:https://github.com/jp-liu/fit-screen



最后,如果大家有更好的适配方案,欢迎在评论区留言一起学习交流!👏


最后



如果觉得本文对你有帮助,希望能够给我点赞支持一下哦 💪 也可以关注wx公众号:前端开发爱好者 回复加群,一起学习前端技能 公众号内包含很多实战精选资源教程,欢迎关注



作者:前端开发爱好者
来源:juejin.cn/post/7386514632725872674
收起阅读 »

我是如何实现网页颜色自适应的

web
前言 不知大家有没有留意过,当前大部分 App 或网页中,很少存在允许用户完全自定义要展示信息的颜色的功能。 例如在钉钉的自定义表情中,只允许用户从一组预设的配色中随机切换: 再比如笔记应用 Notion 虽然允许用户改变文本颜色,但也只允许在一组预设色值中...
继续阅读 »

前言


不知大家有没有留意过,当前大部分 App 或网页中,很少存在允许用户完全自定义要展示信息的颜色的功能。


例如在钉钉的自定义表情中,只允许用户从一组预设的配色中随机切换:


image.png转存失败,建议直接上传图片文件


再比如笔记应用 Notion 虽然允许用户改变文本颜色,但也只允许在一组预设色值中选取:


image.png转存失败,建议直接上传图片文件


原因无它,配色,不是一件容易事。


对于大众用户而言,没什么颜色理论知识,很可能挑出来的颜色在应用中很难看、看不清,这会极大的影响用户的使用体验(即使是用户自己造成的)。


因此大部分产品选择的做法是提供一组预先检验过的、不会对用户阅读造成困扰的颜色,放在应用中供用户挑选。


今天我来斗胆挑战一下这个业界难题。


在这篇文章中将会探讨两个具体问题:



  1. 如何让文本颜色自适应背景色

  2. 如何允许用户完全自定义主题色,同时保证可阅读性


文本颜色自适应背景色


在下面这张图中,文本的颜色默认都是黑色的,背景色设置了多个明暗不同的颜色。可以看到对于暗色的背景色,此时文本可阅读性特别差(不太明显,想看清楚会很累)。


image.png转存失败,建议直接上传图片文件


如果能够自动根据背景色的明暗,决定使用白色还是黑色的文本,那便是实现了文本颜色的自适应了。


首先介绍下借助第三方库实现的方案。


第三方库实现


color 是 JavaScript 生态中在颜色处理方面最流行的库,它有诸多功能:颜色空间转换、颜色通道分解、获取对比度、颜色混合……


在文本颜色自适应这个场景中,最为方便的两个 API 是 isDark()isLight() ,它们分别用来表示一个颜色是否为深色、是否为浅色。


实际应用:


import Color from 'color'

const BgColors = ['#f87171', '#fef08a', '#042f2e', ...]
export default function Page() {
return (
<main>
{BgColors.map((bg) => (
<div
style={{
background: bg,
// 根据背景是否为深色决定文本用白色还是黑色
color: Color(bg).isDark() ? 'white' : 'black'
}}
>

恍恍惚惚
</div>
))}
</main>

)
}

实际效果:


image.png转存失败,建议直接上传图片文件


很 Nice ~


下面再来看下使用 CSS 的解决方案。


mix-blend-mode: difference


mix-blend-mode: difference 用于指定一个元素的颜色与背景色进行「差值」混合,可以使用如下公式表达:


# || 表示取绝对值
# 最终元素显示的颜色 = |元素原有的颜色 - 背景色|
result_color = | element_color - background_color |

例如:



  • 文本颜色为白色 rgb(255 255 255) 背景色为蓝色 rgb(0 0 255) ,最终文本颜色为黄色 rgb(255 255 0)

  • 文本颜色为黑色 rgb(0 0 0) ,此时无论背景色是什么颜色,最终文本的颜色一定和背景色完全相同,因为 | 0 - x | = x


下面来看个实际的 demo,这里我们让文本颜色为 rbg(255, 255, 255),背景色动态调整:


屏幕录制2024-08-31 14.22.02-3.gif


可以看到这个方案会有如下问题:



  • 当背景色为灰色时,文本的颜色和背景色很相似,对比度低,可阅读性差

  • 当背景色为彩色时,混合出来的颜色也是彩色,而且颜色比较脏,不太美观


CSS 相对颜色


以 CSS 颜色函数 rgb() 举例,相对颜色的语法是通过 from 关键词扩展了该函数的能力:


color: rgb(from red r g b);

由于 red 的 RGB 是 (255, 0, 0) ,因此后面的 r g b 值分别为 255 0 0 。对于 r g b 还可以调用 calc() 或其他 CSS 函数进一步处理。


除了 rgb()rgba() hsl() hwb() lch() 等等 CSS 颜色函数都是支持相对颜色语法的。


CSS 相对颜色语法带来的能力有调节亮度、调节饱和度、获取反转色、获取补充色……其中反转色就可以用于文本颜色自适应的场景中。


在上面 Demo 上,使用如下规则使文本的颜色为背景色的反转色:


color: rgb(from var(--bg) calc(255 - r) calc(255 - g) calc(255 - b));

实际效果:


image.png转存失败,建议直接上传图片文件


可以看到虽然能一定程度上提升可阅读性,但是有些反转色奇奇怪怪的,和背景色搭配起来实在不美观,并不是特别推荐使用。


color-constract


color-constract() 可以说是最适合文本颜色自适应场景的 CSS 函数了,用法简单,效果好!


color: color-constract(var(--bg) vs color1, color2, ...);

它的作用即从 vs 右边的一堆色值中,挑选一个和 vs 左边的色值对比度最大的返回。


color-constract(#ccc vs #000, #fff) ,由于 #ccc 是个浅灰色,色值和 #000 对比度更大,因此这个函数会返回 #000


很美好。


但是!这个函数现在几乎不能用!


image.png转存失败,建议直接上传图片文件


目前只有 Safari 中能使用,而且必须开启相关的实验性功能 Flag 。


完全允许用户自定义主题色


让文本的颜色根据背景色自适应,本质是在背景色(background)未知、前景色(foreground)有限的情况下,选择一个合适的前景色,使页面的可读性得到保障。


在上面的 Demo 中,背景色有淡紫色、淡黄色、深绿色、深棕色……前景色即文本的颜色只会有白色、黑色两种情况,依据背景色的明暗决定使用白色还是黑色。


相反的,我们讨论下前景色未知、背景色有限的情况。


看下这个实际应用场景:


image.png转存失败,建议直接上传图片文件


在这款应用中,支持明、暗两种主题,明亮主题为左边的白色(#FFFFFF),暗夜主题为右边的深蓝色(#020617),这是两个背景色;标签主题色(即前景色)支持用户自己设定,图中以红色(#FF0000)为例。


通过截图可以看到,红色在这两种背景色上展示效果都还不错,主要是因为他们的对比度足够。



  • 红色 vs 白色,对比度:3.998

  • 红色 vs 深蓝色,对比度:5.045


要使页面的可读性得到保障,对比度至少要 > 3。


如果允许用户完全自定义前景色,就不可避免的出现用户选择的颜色和背景色的对比度 < 3,这时页面阅读起来会很费力,影响用户体验。


下面给出两种解决方案。


及时给出提示


允许用户完全自定义,意味着用户可以从色盘上选取任意颜色。当用户选取的颜色和背景色对比度 < 3 时,界面上可以给出适当提示,让用户自己决定用一个「难看的颜色」还是遵从应用的建议,选择一个对比度合理,在当前应用中可以和背景色合理搭配的颜色。


实际效果:



相关代码并没有太多难点,主要还是借助 color 库,通过 color1.contrast(color2) 获取两个颜色之间的对比度实现,这里就不放代码了。


自动计算对比度安全的颜色


另一种解决方案,就有点「强制」的意思了:在用户从色盘选色时,实时计算色值和背景色的对比度,如果 < 3 了,就使用 color.lightness() API 逐步的调整颜色的明暗,确保最后界面上使用的是安全对比度的色值。


核心代码:


function calcLightColor(originColor: string) {
const white = Color('#fff')
let c = Color(originColor)
// 对比度 < 3,循环迭代使颜色越来越暗
while (c.contrast(white) < 3) {
// lightness() 可以读取/赋值颜色的 HSL 中的亮度值
// 如果是计算暗夜模式下的安全前景色,这里应该是 + 1,即让颜色越来越亮
c = c.lightness(c.lightness() - 1)
}
return c.hex()
}

function calcDarkColor(originColor: string() {
// ...
}

实际效果:


录屏2024-08-28 15.54.31.mov转存失败,建议直接上传图片文件


可以看到当用户选择了偏白的颜色时,明亮主题中实际使用的是灰色作为前景色,当用户选了择偏黑的颜色时,也有同样的自适应处理。


总结


在 2024 年的今天,CSS 看似已经足够强大,但是在颜色自适应类似的需求中还是略显不足,还好有 color 这个方便的 JavaScript 库帮助我们实现类似的需求。


无论背景色未知,还是前景色未知,只要设计界面时通过各种手段能保证前景色和背景色的对比度 > 3,那就可以保证界面的可阅读性。当然了,这里的安全对比度阈值是可以调整的,设为 3.5、3.75 都是可以的,但也非常不建议低于 3。


作者:人间观察员
来源:juejin.cn/post/7407983735661936674
收起阅读 »

如何实现一个微信PC端富文本输入框?

web
微信PC端输入框支持图片、文件、文字、Emoji四种消息类型,本篇文章就这四种类型的消息输入进行实现。我们选用HTML5新增标签属性contenteditable来实现。 contenteditable属性 contenteditable是一个全局属性,表示...
继续阅读 »

微信PC端输入框支持图片、文件、文字、Emoji四种消息类型,本篇文章就这四种类型的消息输入进行实现。我们选用HTML5新增标签属性contenteditable来实现。


image-20231126224805823.png


contenteditable属性


contenteditable是一个全局属性,表示当前元素可编辑。


该属性拥有一下三个属性值:



  • true或空字符串: 表示元素可编辑

  • false: 表示元素不可编辑

  • plaintext-only: 表示只有原始文本可编辑,禁用富文本编辑


给div元素添加contenteditable, 将元素变为可编辑的状态


<div contenteditable></div>

image-20231126233101180.png


文字输入


当鼠标聚焦在输入框内时,会有outline展示,所以要去掉该样式


<!-- index.html -->
<div contenteditable class="editor"></div>

.editor:focus {
outline:none;
}

image-20231126233743470.png


此时文本就可以正常输入了


图片输入


图片输入分为两部分



  • 截图粘贴

  • 图片文件复制粘贴


有的浏览器是直接支持截图图片的粘贴的,不过图片文件的复制粘贴就不是我们想要的效果(会把图片粘贴成一个文件的图标俺不是展示图片)。


我们这里要自己做一个图片的粘贴展示,以兼容所有的浏览器。


这里我们使用浏览器提供的粘贴事件paste来实现


paste事件函数中存在e.clipboardData.files 属性,该属性是一个数组,当该数组大于0时,就证明用户粘贴的是文件;通过判断 filetype 属性是否为image/... 来确定粘贴的内容是否为图片。


const editor = document.querySelector(".editor");

/**
* 处理粘贴图片
* @param {File} imageFile 图片文件
*/

function handleImage(imageFile) {
// 创建文件读取器
const reader = new FileReader();
// 读取完成
reader.onload = (e) => {
// 创建img标签
const img = new Image();
img.src = e.target.result;
editor.appendChild(img);
};

reader.readAsDataURL(imageFile);
}

// 添加paste事件
editor.addEventListener("paste", (e) => {
const files = e.clipboardData.files;
const filesLen = files.length;
console.log("files", files);
// 粘贴内用是否存在文件
if (filesLen > 0) {
//禁止默认事件
e.preventDefault();
for (let i = 0; i < filesLen; i++) {
const file = files[i];
if (file.type.indexOf("image") === 0) {
// 粘贴的文件为图片
handleImage(file);
continue;
}
// 粘贴内容是普通文件
}
}
});

image-20231127005155571.png


现在就可以粘贴图片了,无论是截图的图片还是复制的文件图片都可以粘贴到文本框内了。


现在存在的问题就是,图片尺寸太大,会按原尺寸展示图片。微信输入框展示的尺寸为最大宽高150x150


.image {
max-width: 150px;
max-height: 150px;
}

现在图片的粘贴展示部分就是解释正常的了


image-20231127005730501.png



细心的同学注意到了,粘贴图片后光标不会向后移动,这是因为我们禁用了粘贴的默认事件,这里需要我们手动处理光标;因为我们使用了appendChild 将图片插入到了输入框的最后面,就会出现无论光标在哪里,粘贴的图片都是出现在输入框的最后面,我们希望的是在光标所在的地方粘贴内容,接下来我们处理一下光标。



光标处理


处理光标主要以来两个WebAPI , SelectionRange



  • Selection:Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。通过window.getSelection() 获取 Selection 对象。 具体可查阅MDN-Selection

  • Range:Range 接口表示一个包含节点与文本节点的一部分的文档片段。可以使用 Document.createRange 方法创建 Range。也可以用 Selection对象的 getRangeAt()方法或者 Document对象的caretRangeFromPoint() 方法获取 Range 对象。具体可查阅MDN-Range


我们使用window.getSelection获取当前位置,通过 getRangeAt()获取当前选中的范围,将选中的内容删除,再把我们自己的内容插入到当前的 Range 中,就实现了文件的输入。在这时插入的文件是选中状态的,所以要将选择范围折叠到结束位置,即在粘贴文本的后面显示光标。


/**
* 将指定节点插入到光标位置
* @param {DOM} fileDom dom节点
*/

function insertNode(dom) {
// 获取光标
const selection = window.getSelection();
// 获取选中的内容
const range = selection.getRangeAt(0);
// 删除选中的内容
range.deleteContents();
// 将节点插入范围最前面添加节点
range.insertNode(dom);
// 将光标移到选中范围的最后面
selection.collapseToEnd();
}

/**
* 处理粘贴图片
* @param {File} imageFile 图片文件
*/
function handleImage(imageFile) {
// 创建文件读取器
const reader = new FileReader();
// 读取完成
reader.onload = (e) => {
// 创建img标签
const img = new Image();
img.src = e.target.result;
- editor.appendChild(img);
+ insertNode(img);
};
reader.readAsDataURL(imageFile);
}


到这里粘贴到光标位置就正常了



演示1.gif


文件输入


文件输入分为两部分粘贴文件和选择文件



  • 粘贴文件


image-20231126234519901.png



  • 选择文件


image-20231128231736630.png


粘贴文件


微信是以卡片的方式展示的文件,所以我们先要准备一个类似的dom结构。


/**
* 返回文件卡片的DOM
* @param {File} file 文件
* @returns 返回dom结构
*/

function createFileDom(file) {
const size = file.size / 1024;
const templte = `
<div class="file-container">
<img src="./assets/PDF.png" class="image" />
<div class="title">
<span>${file.name || "未命名文件"}</span>
<span>${size.toFixed(1)}K</span>
</div>
</div>`
;
const dom = document.createElement("span");
dom.style = "display:flex;";
dom.innerHTML = templte;
return dom;
}

.file-container {
height: 75px;
width: 405px;
box-sizing: border-box;
border: 1px solid rgb(208, 208, 208);
padding: 13px;
display: flex;
justify-content: start;
gap: 13px;
background-color: #ffffff;
}
.file-container .image {
height: 100%;
object-fit: scale-down;
}
.file-container .title {
display: flex;
flex-direction: column;
gap: 2px;
}

按照微信样式卡片准备DOM的生成函数,如上。


将生成后的DOM加入到光标位置


/**
* 处理粘贴文件
* @param {File} file 文件
*/

function handleFile(file) {
insertNode(createFileDom(file));
}

在页面上就可以看到效果啦


image-20231129220051724.png



可以看到以上的卡片还是存在问题的,



  • 光标位置不在卡片的最后面

  • Backspace 不会将整个卡片都删除掉



卡片的删除


因为我们的卡片是DOM,其父元素也就是输入框,开启了contenteditable ,因此它的该属性也继承了父元素的值,所以本身也是可以编辑的。分析到这里,就会涌现出一个方案——将卡片最外层 divcontenteditable 属性值设为 false


th.jpg


但是,设置了 contenteditable="false" 后就变成了不可编辑元素,光标在他周围就不显示了🥹🥹🥹


再接着考虑,思绪一下又回到了图片身上,我们能不能做一个一样的图片呢?答案是肯定的。


说到做图片我们又会有两种方案:



  • svg

  • canvas


这里就考虑使用SVG制作卡片。


SVG在这里就不多介绍了,直接开始


<svg width="409" height="77" nsxml="http://www.w3.org/2000/svg">
<!-- 矩形边框 -->
<rect
x="2"
y="0"
width="405"
height="75"
fill="none"
stroke="rgb(208,208,208)"
/>

<!-- 右侧文件图标 -->
<polygon
points="15 13,36 13,50 30,50 60,15 60"
fill="rgb(156,193,233)"
stroke="rgb(0,105,255)"
stroke-width="2"
/>

<!-- 图标折叠三角部分 -->
<polygon points="36 13,36 30,50 30" fill="rgb(0,105,255)" />
<!-- 文件类型 -->
<text
x="32"
y="50"
font-size="10"
text-anchor="middle"
fill="rgb(0,105,255)"
>

xmind
</text>
<!-- 文件名称 -->
<text x="63" y="30" font-size="16">JavaScript.xmind</text>
<!-- 文件大小 -->
<text x="63" y="52" font-size="14">206.6K</text>
</svg>

然后就得到了一个卡片


image-20231209191730273.png


现在我们只要将该卡片使用JS封装,在上传文件的时候调用生成图片就好了。


封装如下:


/**
* 返回文件卡片的DOM
* @param {File} file 文件
* @returns 返回dom结构
*/

function createFileDom(file) {
const size = file.size / 1024;
let extension = "未知文件";
if (file.name.indexOf(".") >= 0) {
const fileName = file.name.split(".");
extension = fileName.pop();
}
const templte = `
<rect
x="2"
y="0"
width="405"
height="75"
fill="none"
stroke="rgb(208,208,208)"
/>
<polygon
points="15 13,36 13,50 30,50 60,15 60"
fill="rgb(156,193,233)"
stroke="rgb(0,105,255)"
stroke-width="2"
/>
<polygon points="36 13,36 30,50 30" fill="rgb(0,105,255)" />
<text
x="32"
y="50"
font-size="10"
text-anchor="middle"
fill="rgb(0,105,255)"
>
${extension}
</text>
<text x="63" y="30" font-size="16">${file.name || "未命名文件"}</text>
<text x="63" y="52" font-size="14">${size.toFixed(1)}K</text>
`
;
const dom = document.createElementNS("http://www.w3.org/2000/svg", "svg");
dom.setAttribute("width", "409");
dom.setAttribute("height", "77");
dom.innerHTML = templte;
const svg = new XMLSerializer().serializeToString(dom);
const blob = new Blob([svg], {
type: "image/svg+xml;charset=utf-8",
});
const imageUrl = URL.createObjectURL(blob);
const img = new Image();
img.src = imageUrl;
return img;
}

到这里我们的文件粘贴就完成了,来展示


演示.gif


上传文件


上传文件又包含两部分:图片和普通文件


我们需要在这里做一个判断,如果是图片需要将其内容渲染出来,文件用卡片显示。


<div class="controls">
<img src="./assets/emoji.png" alt="表情" title="表情" />
<img
src="./assets/file.png"
alt="发送文件"
title="发送文件"
class="file"
/>

</div>

const fileDom = document.querySelector(".file");

fileDom.addEventListener("click", () => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("multiple", "true");
input.addEventListener("change", () => {
const files = input.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
fileTypeCheck(file);
}
});
input.click();
});

以上代码,在输入框上方添加了一个文件的icon , 给这个icon 添加点击事件,选择文件并展示。


演示6.gif


到这里文件上传就做好了,但是还是存在问题的。


输入框的光标,在鼠标点击页面其他地方的时候会从输入框移出,如果在这时选择文件不会添加到输入框中。


所以,我们在点击文件上传的时候需要叫光标聚焦到输入框。


const fileDom = document.querySelector(".file");

fileDom.addEventListener("click", () => {
+ // 光标聚焦到输入框
+ editor.focus();
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("multiple", "true");
input.addEventListener("change", () => {
const files = input.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
fileTypeCheck(file);
}
});
input.click();
});

到这里文件上传就完美了。


总结


以上的源码已经上传到了Github, 想要源码的小伙伴自己去拉。代码读取文件那部分可以使用Promise 进行优化。最后,欢迎大佬批评指正。


作者:虫洞空间
来源:juejin.cn/post/7312848658718375971
收起阅读 »

没有后端,前端也能下载文件

web
📋 功能介绍 纯前端,不涉及后端或接口 点击下载按钮,下载一个html文件(任何文件都可以实现),打开下载的文件展示一个的html网页 📽️ 演示Treasure-Navigation 💡思路 编写对应的字符串信息 把字符串信息转成url,赋值给a标签...
继续阅读 »

📋 功能介绍



  • 纯前端,不涉及后端或接口

  • 点击下载按钮,下载一个html文件(任何文件都可以实现),打开下载的文件展示一个的html网页


📽️ 演示Treasure-Navigation


下载.gif


💡思路



  1. 编写对应的字符串信息

  2. 把字符串信息转成url,赋值给a标签

  3. a标签设置download属性后,可以下载url中的内容信息


代码实现



  • 文本信息可以随意写,最终下载的内容就是下方文本


// 定义一个包含 HTML 内容的字符串
const htmlStr = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>导航集合</title>
</head>
...... // 此处省略业务代码,有兴趣可以去我的项目中查看
<\/html>
`
;

1. URL.createObjectURL下载


  // 创建一个 Blob 对象,将 HTML 字符串封装成一个可下载的文件
const htmlStrBolo = new Blob([htmlStr], { type: 'text/html' });
// blob转成url,使用URL.createObjectURL和fileReader都可以
// 创建一个指向 Blob 对象的 URL
const htmlStrUrl = URL.createObjectURL(htmlStrBolo);
// 创建一个链接元素
const aLink = document.createElement('a');
// 设置链接的 href 属性为刚刚生成的 URL
aLink.href = htmlStrUrl;
// 设置下载文件的名称
aLink.download = '下载文件名称.html';
// 触发链接的点击事件,开始下载
aLink.click();
// 释放之前创建的 URL 对象,释放内存
URL.revokeObjectURL(htmlStrUrl);

上面是最推荐一种方式。使用URL.createObjectURL,然后立即手动释放内存URL.revokeObjectURL(htmlStrUrl)性能高效。


2. FileReader.readAsDataURL下载


// 创建一个 Blob 对象,将 HTML 字符串封装成一个可下载的文件
const htmlStrBolo = new Blob([htmlStr], { type: 'text/html' });
// 创建FileReader,可以转换文件格式
const reader = new FileReader()
// 传入被读取的blob对象,并且转换成url形式
reader.readAsDataURL(htmlStrBolo)
reader.onload = (e) => {
// 创建一个链接元素
const aLink = document.createElement('a');
// 设置链接的 href 属性为刚刚生成的 URL
aLink.href = reader.result;
// 设置下载文件的名称
aLink.download = '下载文件名称.html';
// 生成的base64编码
aLink.click();
};

🌟理解URL.createObjectURL和FileReader.readAsDataURL


在web中处理文件和数据是常见的需求。URL.createObjectURL() 和 FileReader.readAsDataURL() 是两个用于处理文件对象的常用方法。他们都是将 File 或 Blob 对象转换成URL的形式。让我们来深入了解它们的用途和区别。


📄FileReader.readAsDataURL



  1. 功能概述
    URL.createObjectURL(myBlob) 可以将 File 或 Blob 对象转换为临时 URL。只要没有销毁,该临时 URL 可以在任何网页中使用,网页将显示对应的 File 或 Blob 信息。

  2. 生命周期
    该 URL 的生命周期与其创建时所在窗口的 document 绑定在一起。一旦关闭原窗口,该临时 URL 将失效。

  3. 内存管理
    可以使用 URL.revokeObjectURL(myUrl) 提前销毁该 URL,以释放内存。



  • 代码示例


// 创建blob信息
const htmlStrBlob = new Blob(["Hello World"], { type: 'text/plain' });
// 创建一个指向 Blob 对象的 URL
const htmlStrUrl = URL.createObjectURL(htmlStrBlob);
console.log(htmlStrUrl);
//在执行 revokeObjectURL 之前,htmlStrUrl可以复制到浏览器的地址栏中
URL.revokeObjectURL(htmlStrUrl);

📄FileReader.readAsDataURL



  1. 功能概述
    FileReader.readAsDataURL() 方法用于将 File 或 Blob 对象读取为一个 Base64 编码的字符串。该字符串可以在任意网页中永久使用,网页将显示对应的 File 或 Blob 信息。

  2. 生命周期
    生成的 Base64 字符串的生命周期是永恒的。



  • 代码示例


// 创建一个 Blob 信息
const htmlStrBolo = new Blob(["Hello World"], { type: 'text/plain' });
// 创建FileReader,可以转换文件格式
const reader = new FileReader()
// 传入被读取的blob对象,并且转换成url形式
reader.readAsDataURL(htmlStrBolo)
reader.onload = () => {
// 这个reader.result可以复制到浏览器的地址栏中,永远可以查看对应的信息
console.log(reader.result)
}

⚖️ 区别总结


1.生成的 URL 类型


  • URL.createObjectURL(): 生成一个临时的对象 URL。

  • FileReader.readAsDataURL(): 生成一个 Base64 编码的数据 URL,相对临时的URL会大很多


2.性能


  • URL.createObjectURL(): 性能更好,因为不需要将文件内容加载到内存中,可以使用完后立即销毁。

  • FileReader.readAsDataURL(): 可能会占用更多内存,特别是在处理大文件时。


作者:前端金熊
来源:juejin.cn/post/7425656340982480936
收起阅读 »

页面跳转如何优雅携带大数据Array或Object

web
前言 在小程序或者app开发中,最常用到的就是页面跳转,上文中详细介绍了页面跳转4种方法的区别和使用,可以点击查看👉分析小程序页面导航与事件通讯。 而页面跳转就经常会携带数据到下一个页面,常见的做法是通过 URL 参数将数据拼接在 navigateTo 的 ...
继续阅读 »

前言



  • 在小程序或者app开发中,最常用到的就是页面跳转,上文中详细介绍了页面跳转4种方法的区别和使用,可以点击查看👉分析小程序页面导航与事件通讯

  • 而页面跳转就经常会携带数据到下一个页面,常见的做法是通过 URL 参数将数据拼接在 navigateTo 的 URL 后面。然而,这种方式在处理较大数据(如数组或对象)时会显得笨拙且有限。

  • 下面将讨论通过 URL 传递参数的局限性,以及使用 EventChannel 进行数据传递的好处,并提供代码示例进行解析。


1学习.png


一、使用 URL 参数传递数据的局限性


在小程序中,我们通常使用 navigateTo 方法来跳转到另一个页面,并通过 URL 传递参数。例如:


// 使用 URL 参数进行页面跳转
wx.navigateTo({
url: '/pages/target/target?name=John&age=30'
});

1.1 问题



  1. 数据大小限制:URL 的长度限制通常在 2000 字符左右。如果需要传递一个较大的数据结构(例如一个包含大量信息的对象或数组),URL 会迅速达到限制,导致无法完整传递数据。

  2. 编码和解析:当数据包含特殊字符(如空格、&、=等)时,必须进行编码处理。这增加了编码和解析的复杂性,不够直观。

  3. 可读性差:长的 URL 会导致可读性降低,特别是当需要传递多个参数时,容易让人困惑。


二、使用 EventChannel 的优势


相比之下,使用 EventChannel 可以更优雅地在页面之间传递数据,尤其是复杂的数据结构。以下是使用 EventChannel 的几个主要优点:



  1. 支持复杂数据类型:可以直接传递对象和数组,无需担心 URL 长度限制或特殊字符的编码问题。

  2. 简化代码:代码更简洁,逻辑更清晰,特别是在需要传递多个参数时。

  3. 即时通信:在新页面创建后,可以立即接收数据,使得页面间的交互更加流畅。


三、示例代码解析


3.1 在源页面中


在源页面中,我们可以使用 EventChannel 传递数组和对象:


// 源页面
wx.navigateTo({
url: '/pages/target/target',
events: {
// 监听目标页面发送的消息
someEvent(data) {
console.log(data); // 可以在这里处理目标页面返回的数据
}
},
success: (res) => {
// 创建要传递的复杂数据
const arrayData = [1, 2, 3, 4, 5];
const objectData = { key: 'value', info: { nestedKey: 'nestedValue' } };

// 通过 EventChannel 向目标页面传递数据
res.eventChannel.emit('someEvent', {
array: arrayData,
object: objectData,
});
}
});

3.2 在目标页面中


在目标页面中,我们接收并使用传递的数据:


// 目标页面
const eventChannel = this.getOpenerEventChannel();
eventChannel.on('someEvent', (data) => {
// 接收数组和对象数据
console.log(data.array); // [1, 2, 3, 4, 5]
console.log(data.object); // { key: 'value', info: { nestedKey: 'nestedValue' } }

// 进行相应的数据处理
// 例如,渲染数据到页面上
this.setData({
receivedArray: data.array,
receivedObject: data.object,
});
});

四、总结


通过使用 EventChannel 进行页面间的数据传递,我们可以避免使用 URL 传递参数时面临的各种局限性,特别是在处理复杂数据时。EventChannel 使得数据传递变得更加灵活、简洁和高效,提升了小程序的用户体验。


在实际开发中,传递较少数据时,可以在url后面拼接参数进行传递使用。当需要携带大数据时可以考虑使用 EventChannel 进行复杂数据的传递,确保应用的交互更加顺畅。


dacd2633d04544dabaac13d20e4eb0ef~tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN54ix6K-06K-d6YOt5b6357qy_q75.webp


作者:不爱说话郭德纲
来源:juejin.cn/post/7433271555830431784
收起阅读 »

绑定大量的的v-model,导致页面卡顿的解决方案

web
绑定大量的的v-model,导致页面卡顿的解决方案 设计图如下: 页面布局看着很简单使用element组件,那就完蛋了,因为是大量的数据双向绑定,所以使用组件,延迟非常高,高到什么程度,请求100条数据到渲染到页面上,要10-12s,特别是下拉选择的时候,延...
继续阅读 »

绑定大量的的v-model,导致页面卡顿的解决方案


设计图如下:

image.png
页面布局看着很简单使用element组件,那就完蛋了,因为是大量的数据双向绑定,所以使用组件,延迟非常高,高到什么程度,请求100条数据到渲染到页面上,要10-12s,特别是下拉选择的时候,延迟都在2-3s,人麻了老铁!!!
20180825133312_Wy8Qc.jpeg
卡顿的原因很长一段时间都是在绑定v-model,为什么绑定v-model会很卡呢,请求到的每一条数据有14个数据需要绑定v-model,每次一请求就是100个打底,那就是1400个数据需要绑定v-model;而且组件本身也有延迟,所以这个方案不能采用,那怎么做呢?
我尝试采用原生去写,写着写着,哎解决了!!!惊呆了
20165257101366892.jpg
做完后100条数据页面渲染不超过2s,毕竟还是需要绑定v-model,能在2s内,我还是能接受的吧;选择和输入延迟基本没有
R-C.gif
下面就来展示一下我的代码,写的不好看着玩儿就好了:
8c9ddd7ac11871fe4dde268f99b430a6.gif
请求到的数据:


image.png


image.png


image.png


image.png


image.png
methods这两个事件做的什么事儿呢,就是手动将数据绑定到数据上去也就是row上如图:


image.png
当然还有很多解决方案


作者:无敌暴龙神
来源:juejin.cn/post/7392248233222881316
收起阅读 »

微前端原理与iframe 项目实践

web
一、背景 在讲微前端之前,首先了解下前端开发模式的发展历程,在最早的时候,前端的开发是耦合在服务端,主要的工作其实就是提供一个界面模板,交互也不多,实际的数据还是在服务端渲染的时候提供的。 大概在2010年,界面越来越复杂,调试、部署、都要依赖于后端,如果还是...
继续阅读 »

一、背景


在讲微前端之前,首先了解下前端开发模式的发展历程,在最早的时候,前端的开发是耦合在服务端,主要的工作其实就是提供一个界面模板,交互也不多,实际的数据还是在服务端渲染的时候提供的。


大概在2010年,界面越来越复杂,调试、部署、都要依赖于后端,如果还是以模板的形式开发效率太低了。于是就提出了前后端分离的模式开发,这个时期也是单页应用开始火起来的时期。


到了2014左右,后端开始了微服务模式的开发,这也为微前端提供的思路。随着前端承担的东西越来越多,不断的迭代后,原本简单的单页应用,已经变成了一个巨石应用,不管是代码量还是页面量都非常庞大,一个单页应用由多个团队一起来维护。同时巨石应用还受到了非常大的约束,比如新技术的更新、打包速度等等问题。


因此,在2019年左右,借鉴微服务的思想,提出了微前端的开发模式,也就是将一个大型的单页应用,以业务域为粒度,拆分为多个子应用,最后通过微前端的技术,整合成一个完整的单页应用,同时,每个子应用也能够享有独立应用开发一致的体验。


微前端时间.png

二、微前端简介


微前端概念


微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。Micro Frontends


微前端(Micro Frontends)是一种前端架构模式,借鉴了微服务的架构理念,将一个庞大的前端应用拆分为多个独立灵活的小型应用,每个前端应用都可以独立开发独立运行独立部署,再将这些小型应用联合为一个完整的应用。


微前端的目标是使前端开发更加容易、可维护和可扩展,并且能够实现团队之间的协作。


微前端的特点



  • 技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈

  • 独立开发/部署 子应用仓库独立,单独部署,互不依赖

  • 增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性

  • 独立运行 子应用之间运行时互不依赖,有独立的状态管理

  • 提升效率 微应用可以很好拆分项目,提升协作效率

  • 可维护性 微前端可以更容易地进行维护和测试,因为它们具有清晰的界限和独立的代码库


劣势



  • 增加了系统复杂度 需要对系统进行拆分,将单体应用拆分成多个独立的微前端应用。这种拆分可能导致系统整体变得更加复杂,因为需要处理跨应用之间的通信和集成问题

  • 需要依赖额外的工具和技术 例如模块加载器、应用容器等,这些工具和技术需要额外的学习和维护成本,也可能会导致一些性能问题

  • 安全性问题 由于微前端应用是独立的,它们之间可能存在安全隐患。例如,如果某个微前端应用存在漏洞,攻击者可能会利用这个漏洞来攻击整个系统

  • 兼容性问题 由于微前端应用是独立的,它们之间可能存在兼容性问题。例如,某个微前端应用可能使用了一些不兼容的依赖库,这可能会导致整个系统出现问题

  • 开发团队需要有一定的技术水平 实现微前端需要开发团队有一定的技术水平,包括对模块化、代码复用、应用集成等方面有深入的了解。如果团队缺乏这方面的技能,可能会导致微前端实现出现问题


三、微前端的技术实现


3.1 微前端的基础架构


微前端架构基本需要实现三个部分:



  • 主应用接入子应用,包括子应用的注册、路由的处理、应用的加载和路由的切换。

  • 主应用加载子应用,这部分之所以重要,是因为接入的方式决定了是否可以更高效的解耦。

  • 子应用的容器,这是子应用加载之后面临的问题,包含了JS沙箱、样式隔离和消息机制。


微前端架构.png

3.2 微前端的主要技术问题


1) 构建时组合 VS 运行时组合


主框架与子应用集成的方式


微前端架构模式下,子应用打包的方式,基本分为两种:


组合方式说明优点缺点
构建时子应用与主应用一起打包发布构建的时候可以做打包优化,如依赖共享等主子应用构建方案、工具耦合,必须一起发布,不够灵活
运行时子应用自己构建打包,主应用运行时动态加载子应用资源主子应用完全解耦,完全技术栈无关有运行时损耗,多出一些运行时的复杂度

要实现真正的技术栈无关跟独立部署两个核心目标,大部分场景下需要使用运行时加载子应用这种方案。


2)JS Entry VS HTML Entry


子应用提供什么形式的资源作为渲染入口?


JS Entry 的方式通常是子应用将资源打成一个 entry script。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。


HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。


App Entry优点缺点
HTML Entry1.子应用开发、发布完全独立 2.子应用具备与独立应用开发时一致的开发体验1.多一次请求,子应用资源解析消耗转移到运行时 2.主子应用不处于同一个构建环境,无法利用bundle的一些构建期的优化能力,如公共依赖抽取等
JS Entry主子应用使用同一个bundle,可以方便做构建时优化1.子应用的发布需要主应用重新打包 2.主应用需为每个子应用预留一个容器节点,且该节点id需与子应用的容器id保持一致 3.子应用各类资源需要一起打成一个bundle,资源加载效率变低

3)样式隔离


由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。


“甲之蜜糖,乙之砒霜“,每个方案都有着不同的优势与劣势。



  • BEM (Block Element Module)规范命名约束

  • CSS Modules 构建时生成各自的作用域

  • CSS in JS 使用 JS 语言写 CSS

  • Shadow DOM 沙箱隔离

  • experimentalStyleIsolation 给所有的样式选择器前面都加了当前挂载容器

  • Dynamic Stylesheet 动态样式表

  • postcss 增加命名空间


方案说明优点缺点
BEM不同项目用不同的前缀/命名规则避开冲突简单依赖约定,这也是耦合的一种,容易出现纰漏
CSS Modules通过编译生成不冲突的选择器名可靠易用,避免人工约束只能在构建期使用,依赖预处理器与打包工具
CSS in JSCSS和JS编码在一起最终生成不冲突的选择器基本彻底避免冲突运行时开销,缺失完整的CSS能力

4)JS隔离


一个子应用从加载到运行,再到卸载,有可能会对全局产生一些污染。这些污染包括但不限于:添加 / 删除 / 修改全局变量、绑定全局事件、修改原生方法或对象等。而所有这些子应用造成的影响都可能引起潜在的全局冲突。为此,需要在加载和卸载一个应用的同时,尽可能消除这种影响。目前,主要有两种隔离方式,一种是快照沙箱、另外一种是代理沙箱。



  • 快照沙箱的核心思想就是在应用挂载(mount方法)的时候记录快照,在应用卸载(unmount)的时候依据快照恢复环境。


    实现的思路是直接用 window diff,把当前的环境和原来的环境做一个比较,跑两次循环(创建快照和恢复快照),然后把两个环境做一次比较,最后去全量的恢复回原来的环境。


  • 代理沙箱的核心思想是让子应用里面的环境和外面的环境完全隔离。每个应用对应一个环境,比如应用A对应环境A,应用B对应环境B,同时两者之间的环境和全局环境也互不干扰。


    实现思路是主要利用 ES6 的 Proxy 能力。通过劫持window,可以劫持到子应用对全局环境的一些修改,当子应用往window上挂载、删除、修改的时候,把操作记录下来,当恢复全局环境时,反向执行之前的操作。



四、微前端方案


实现方式基本思想优点不足代表方案
路由分发1.前端框架公共路由方案,映射到不同应用 2.服务器反向代理路由到不同应用 路由分发.png1. 维护、开发成本低;2.适应一些关联方不多、业务场景不发展的情况;不足:1.独立应用的硬聚合,有比较明显的割裂感--
前端容器化iframe 可以创建一个全新的独立的宿主环境,这意味着前端应用之间可以相互独立运行,仅需要做好应用之间的管理、通信即可 前端容器化.png1. 比较简单,无需过多改造,开发成本低; 2.完美隔离,JS、CSS 都是独立的运行环境; 3. 不限制使用,页面上可以放多个 iframe 来组合业务1. 无法保持路由状态,刷新后 iframe url 状态丢失(这点也不是完全不能解决,可以将路由作为参数拼接在链接后,刷新时去参数进行页面跳转); 2. 全局上下文完全隔离,应用之间通信困难(比如iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果); 3. iframe 中的弹窗无法突破其本身,无法覆盖全局; 4. 加载慢,每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程Iframe
前端微服务化前端微服务化,是微服务架构在前端的实施,每个前端应用都是完全独立(技术栈、开发、部署、构建独立)、自主运行的,最后通过模块化的方式组合出完成的应用前端微服务化.png1. 应用间通信简单,全局注入; 2. html entry 的方式引入子应用,相比 js entry 极大的降低了应用改造的成本; 3. 完备的js、css 沙箱方案,确保微应用之间的样式/全局变量/事件互相不干扰; 4. 具备静态资源预加载能力,加速微应用打开速度1. 适配成本比较高,webpack工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作; 2. css 沙箱采用严格隔离会有各种问题,js 沙箱在某些场景下执行性能下降严重; 3. 基于路由匹配,无法同时激活多个子应用,也不支持子应用保活; 4. 无法支持 vite 等 ESM 脚本运行Single-SPAqiankun
应用组件化Web Components 是一套不同的技术,允许开发者创建可重用的定制元素(它们的功能封装在代码之外)并且在 Web 应用中使用它们,其中html imports 被废弃,可以直接加载js即可应用组件化.png1. 使用 类WebComponent 加载子应用相比 single-spa 这种注册监听方案更加优雅a; 2. 组件式的 api 更加符合使用习惯,支持子应用保活; 3. 降低子应用改造的成本,提供静态资源预加载能力; 4. 基于CustomElement和样式隔离、js隔离来实现微应用的加载,所以子应用无需改动就可以接入1. 类 webcomponent css 沙箱依然无法绝对的隔离; 2. 支持 vite 运行,但必须使用 plugin 改造子应用,且 js 代码没办法做沙箱隔离; 3. 对于不支持 webcompnent 的浏览器没有做降级处理,兼容性不够micro-app
微件化微前端下的微件化是指,每个业务团队编写自己的业务代码,并将编译好的代码部署到指定的服务器上,运行时只需要加载指定的代码即可微件化.png1. webpack 联邦编译可以保证所有子应用依赖解耦; 2. 应用间去中心化的调用、共享模块; 3. 模块远程 ts 支持1. 需要相关规范约束各Widget; 2. 没有有效的 css 沙箱和 js 沙箱,需要靠用户自觉; 3. 子应用保活、多应用激活无法实现; 4. 主、子应用的路由可能发生冲突EMP

五、iframe 项目实践


5.1 iframe的概念以及作用


iframe(内联框架)是HTML标签,也是一个内联元素,作用是文档中的文档,或者浮动的框架(FRAME),iframe 元素会创建包含另外一个HTML文档的内联框架(即行内框架) 。


简言之,iframe可以在当前页面嵌入其他页面


5.2 优缺点


优点:



  1. 内容隔离:可以在同一页面中加载并显示来自不同源的内容,而不会影响主页面的功能

  2. 异步加载:可以异步加载iframe中的内容,不会阻塞主页面的加载

  3. 独立滚动:iframe内的内容可以独立滚动,不影响主页面的滚动

  4. 可以实现复杂的布局和组件,如广告、小工具、第三方插件等


缺点:



  1. 性能问题:每个iframe都会创建一个新的窗口环境,会消耗更多的内存和CPU资源

  2. SEO问题:搜索引擎可能无法正确索引iframe中的内容

  3. 跨域问题:当iframe嵌入网页与主页面不同源,会受到浏览器的安全限制,使用postMessage API需要避免发送敏感信息,或者接收来自不可信源的消息

  4. 历史记录问题:iframe的导航可能不会更新浏览器的历史记录,可能会影响用户的导航体验


5.3 主要属性


属性描述
srcURL规定在 iframe 中显示的文档的 URL
classclassname规定 iframe 的类名
idid规定 iframe 的特定id
stylestyle_definition规定 iframe 的行内样式
titletext规定 iframe 的额外信息(可在工具提示中显示)
widthpixels/percentage规定 iframe 的宽度
heightpixels/percentage规定 iframe 的高度

5.4 父子页面通信


5.4.1 单向通信(父传子)


URL传参: 可以在iframe的src属性中使用URL参数,将父页面的数据传递到iframe嵌入的页面。


<iframe src="http://new-iframe-url?param1=value1&param2=value2"></iframe>

5.4.2 双向通信


父页面和子页面(即iframe内的页面)的通信机制,分为两种


(1)同源的父子页面通信:


如果父页面和子页面同源,可以直接通过JavaScript访问对方的DOM。这是因为同源策略允许同源的文档之间进行完全的交互。


父页面可以通过iframe元素的contentWindow属性获取子页面的window对象,然后直接调用其函数或访问其变量。同样,子页面也可以通过window.parent获取父页面的window对象。


父页面访问子页面:


// 获取iframe元素
const iframe = document.getElementById('myIframe');
// 获取子页面的window对象
const childWindow = iframe.contentWindow;
// 调用子页面的函数
childWindow.myFunction();
// 访问子页面的变量
console.log(childWindow.myVariable);
// 修改子页面的DOM
childWindow.document.getElementById('myElement').innerText = 'hhhh';

子页面访问父页面:


// 获取父页面的window对象
var parentWindow = window.parent;
// 调用父页面的函数
parentWindow.myFunction();
// 访问父页面的变量
console.log(parentWindow.myVariable);
// 修改父页面的DOM
parentWindow.document.getElementById('myElement').innerText = 'hhhh';

(2)不同源的父子页面通信:


如果父页面和子页面不同源,则不能直接通过JavaScript访问对方的DOM。但可以通过HTML5的postMessage API进行跨源通信。


具体来说,一个页面可以通过postMessage方法向另一个页面发送消息,然后另一个页面通过监听message事件来接收这个消息。


父页面发送消息到子页面:


var iframe = document.getElementById('myIframe');
iframe.contentWindow.postMessage('Hello', 'https://example.com');

子页面接收来自父页面的消息:


window.addEventListener('message', function(event) {
if (event.origin !== 'https://parent.com') return;
console.log('received message:', event.data);
});

5.5 项目中遇到的问题


问题描述


背景:页面初始化时,子应用iframe要从主应用获取cookie,实现免登


问题实现步骤:清除主应用的cookie,刷新页面,点击加载子应用,此时没获取到cookie,接口报鉴权错误


问题原因


1)异步获取 cookie:cookie 是通过 postMessage 从父页面异步获取的,在发送 HTTP 请求时,cookie可能尚未获取或存储在 sessionStorage 中


2)立即发送请求:在页面组件的 useEffect 钩子中,当组件挂载时立即发送请求,而不等待 cookie 的获取和存储,导致请求发出时缺少 cookie,造成请求失败


cookie交互流程


cookie交互.png

修复方案


为了确保 token 在 HTTP 请求发送之前已经成功获取并存储,需要实现以下步骤:


1)等待 token 被存储:在 httpMethod 中添加一个辅助函数,用于轮询 sessionStorage,直到 token 被存储


2)在请求之前检查并等待 token:在每个 HTTP 请求方法中,在请求实际发送之前,先调用辅助函数等待 token 被存储


具体实现


修改 api.js文件


1)创建一个waitForToken函数用于等待cookie中的 token


每隔 100ms 检查一次 sessionStorage 中是否存在 Access-Token,并返回一个 Promise。若存在 token ,调用 resolve(token) 方法将 Promise 标记为成功,并返回 token,否则等待 200 毫秒,再次检查token是否存在


const waitForToken = (timeout = 20000) => {
return new Promise((resolve) => {
const startTime = Date.now();
const checkToken = () => {
const token = window.sessionStorage.getItem("Access-Token");
if (token) {
resolve(token); //找到 token,通过 resolve 通知任务成功完成
} else if (Date.now() - startTime >= timeout) {
resolve(null); // 超时后返回 null
} else {
setTimeout(checkToken, 200); //如果没找到,每200ms检查一次
}
};
checkToken();
});
};

2)修改httpMethod


在每个请求方法里调用 waitForToken, 确保在发送请求之前获取到 token,并在获取到 token 后将其从 sessionStorage 中取出并添加到请求头中


const httpMethod = {
get: async (url, params) => {
const token = await waitForToken(); //获取token
return axios
.get(api + url, {
params: params,
headers: Object.assign(
headers("json"),
{ "Access-Token": JSON.parse(token) },
options.headers || {}
),
})
.then(dataError, errorHandle);
},
postJson: async (url, data, options = {}) => {
const token = await waitForToken(); //获取token
return axios
.post(api + url, data, {
...options,
headers: Object.assign(
headers("json"),
{ "Access-Token": JSON.parse(token) },
options.headers || {}
),
})
.then(dataError, errorHandle);
},
}

六、总结


微前端能否做到利大于弊,具体取决于实际情况和条件。对于小型、高度协作的团队和相对简单的产品来说,微前端的优势相比代价来说就很不明显了;而对于大型、功能众多的产品和许多较独立的团队来说,微前端的好处就会更突出。


工程就是权衡的艺术,而微前端提供了另一个可以做权衡的维度。


学习资料:



作者:正是江南好时节
来源:juejin.cn/post/7435928578585264138
收起阅读 »

nextTick用过吗?讲一讲实现思路吧

web
源码实现思路(面试高分回答) 📖 面试官问我 Vue 的 nextTick 原理是怎么实现的,我这样回答: 在调用 this.$nextTick(cb) 之前: 存在一个 callbacks 数组,用于存放所有的 cb 回调函数。 存在一个 flushCal...
继续阅读 »

源码实现思路(面试高分回答) 📖


面试官问我 Vue 的 nextTick 原理是怎么实现的,我这样回答:


在调用 this.$nextTick(cb) 之前:



  1. 存在一个 callbacks 数组,用于存放所有的 cb 回调函数。

  2. 存在一个 flushCallbacks 函数,用于执行 callbacks 数组中的所有回调函数。

  3. 存在一个 timerFunc 函数,用于将 flushCallbacks 函数添加到任务队列中。


当调用 this.nextTick(cb) 时:



  1. nextTick 会将 cb 回调函数添加到 callbacks 数组中。

  2. 判断在当前事件循环中是否是第一次调用 nextTick

    • 如果是第一次调用,将执行 timerFunc 函数,添加 flushCallbacks 到任务队列。

    • 如果不是第一次调用,直接下一步。



  3. 如果没有传递 cb 回调函数,则返回一个 Promise 实例。





根据上述描述,对应的`流程图`如下:



graph TD
A["this.$nextTick(callback)"] --> B[将回调函数 callback 放入到数组 callbacks 中]
B --> C[判断是否是第一次调用 nextTick]
C -->|是| D[执行 timerFunc, 将 flushCallbacks 添加到任务队列]
C -->|否| F[如果没有 cb, 则retrun Promise]
D --> F
F --> 结束

如果上面的描述没有很理解。没关系,花几分钟跟着我下面来,看完下面的源码逐行讲解,你一定能够清晰地向别人讲出你的思路!


nextTick思路详解 🏃‍♂‍➡


1. 核心代码 🌟


下面用十几行代码,就已经可以基本实现nextTick的功能(默认浏览器支持Promise)


// 存储所有的cb回调函数
const callbacks = [];
/*类似于节流的标记位,标记是否处于节流状态。防止重复推送任务*/
let pending = false;

/*遍历执行数组 callbacks 中的所有存储的cb回调函数*/
function flushCallbacks() {
// 重置标记,允许下一个 nextTick 调用
pending = false;
/*执行所有cb回调函数*/
for (let i = 0; i < callbacks.length; i++) {
callbacks[i]();
}
// 清空回调数组,为下一次调用做准备
callbacks.length = 0;
}

function nextTick(cb) {
// 将回调函数cb添加到 callbacks 数组中
callbacks.push(() => {
cb();
});

// 第一次使用 nextTick 时,pending 为 false,下面的代码才会执行
if (!pending) {
// 改变标记位的值,如果有flushCallbacks被推送到任务队列中去则不需要重复推送
pending = true;
// 使用 Promise 机制,将 flushCallbacks 推送到任务队列
Promise.resolve().then(flushCallbacks);
}
}

测试一下:


let message = '初始消息';

nextTick(() => {
message = '更新后的消息';
console.log('回调:', message); // 输出2: 更新后的消息
});

console.log('测试开始:', message); // 输出1: 初始消息

如果你想要应付面试官,能手写这部分核心原理就已经差不多啦。

如果你想彻底掌握它,请继续跟着我来!!!🕵🏻‍♂


卷四你们.jpg


2. nextTick() 返回promise 🌟


我们在开发中,会使用await this.$nextTick();让其下面的代码全部变成异步代码。
比如写成这样:


await this.$nextTick();
......
......

// 或者
this.$nextTick().then(()=>{
......
})

核心就是nextTick()如果没有参数,则返回一个promise


const callbacks = [];
let pending = false;

function flushCallbacks() {
pending = false;
for (let i = 0; i < callbacks.length; i++) {
callbacks[i]();
}
callbacks.length = 0;
}

function nextTick(cb) {
// 用于存储 Promise 的resolve函数
let _resolve;
callbacks.push(() => {
/* ------------------ 新增start ------------------ */
// 如果有cb回调函数,将cb存储到callbacks
if (cb) {
cb();
} else if (_resolve) {
// 如果参数cb不存在,则保存promise的的成功回调resolve
_resolve();
}
/* ------------------ 新增end ------------------ */
});
if (!pending) {
pending = true;
Promise.resolve().then(flushCallbacks);
}

/* ------------------ 新增start ------------------ */
if (!cb) {
return new Promise((resolve, reject) => {
// 保存resolve到callbacks数组中
_resolve = resolve;
});
}
/* ------------------ 新增end ------------------ */
}

测试一下:


async function testNextTick() {
let message = "初始消息";

nextTick(() => {
message = "更新后的消息";
});
console.log("传入回调:", message); // 输出1: 初始消息

// 不传入回调的情况
await nextTick(); // nextTick 返回 Promise
console.log("未传入回调后:", message); // 输出2: 更新后的消息
}

// 运行测试
testNextTick();

3. 判断浏览器环境 🔧


为了防止浏览器不支持 PromiseVue 选择了多种 API 来实现兼容 nextTick

    Promise --> MutationObserver --> setImmediate --> setTimeout



  1. Promise (微任务):

    如果当前环境支持 PromiseVue 会使用 Promise.resolve().then(flushCallbacks)

  2. MutationObserver (微任务):

    如果不支持 Promise,支持 MutationObserverVue 会创建一个 MutationObserver 实例,通过监听文本节点的变化来触发执行回调函数。

  3. setImmediate (宏任务):

    如果前两者都不支持,支持 setImmediate。则:setImmediate(flushCallbacks)

    注意setImmediate 在绝大多数浏览器中不被支持,但在 Node.js 中是可用的。

  4. setTimeout (宏任务):

    如果前面所有的都不支持,那你的浏览器一定支持 setTimeout!!!
    终极方案:setTimeout(flushCallbacks, 0)


// 存储所有的回调函数
const callbacks = [];
/* 类似于节流的标记位,标记是否处于节流状态。防止重复推送任务 */
let pending = false;

/* 遍历执行数组 callbacks 中的所有存储的 cb 回调函数 */
function flushCallbacks() {
// 重置标记,允许下一个 nextTick 调用
pending = false;
/* 执行所有 cb 回调函数 */
for (let i = 0; i < callbacks.length; i++) {
callbacks[i](); // 依次调用存储的回调函数
}
// 清空回调数组,为下一次调用做准备
callbacks.length = 0;
}

// 判断最终支持的 API:Promise / MutationObserver / setImmediate / setTimeout
let timerFunc;

if (typeof Promise !== "undefined") {
// 创建一个已resolve的 Promise 实例
var p = Promise.resolve();
// 定义 timerFunc 为使用 Promise 的方式调度 flushCallbacks
timerFunc = () => {
// 使用 p.then 方法将 flushCallbacks 推送到微任务队列
p.then(flushCallbacks);
};
} else if (
typeof MutationObserver !== "undefined" &&
MutationObserver.toString() === "[object MutationObserverConstructor]"
) {
/* 新建一个 textNode 的 DOM 对象,用 MutationObserver 绑定该 DOM 并指定回调函数。
在 DOM 变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),
即 textNode.data = String(counter) 时便会加入该回调 */

var counter = 1; // 用于切换文本节点的值
var observer = new MutationObserver(flushCallbacks); // 创建 MutationObserver 实例
var textNode = document.createTextNode(String(counter)); // 创建文本节点
observer.observe(textNode, {
characterData: true, // 监听文本节点的变化
});
// 定义 timerFunc 为使用 MutationObserver 的方式调度 flushCallbacks
timerFunc = () => {
counter = (counter + 1) % 2; // 切换 counter 的值(0 或 1)
textNode.data = String(counter); // 更新文本节点以触发观察者
};
} else if (typeof setImmediate !== "undefined") {
/* 使用 setImmediate 将回调推入任务队列尾部 */
timerFunc = () => {
setImmediate(flushCallbacks); // 将 flushCallbacks 推送到宏任务队列
};
} else {
/* 使用 setTimeout 将回调推入任务队列尾部 */
timerFunc = () => {
setTimeout(flushCallbacks, 0); // 将 flushCallbacks 推送到宏任务队列
};
}

function nextTick(cb) {
// 用于存储 Promise 的解析函数
let _resolve;
// 将回调函数 cb 添加到 callbacks 数组中
callbacks.push(() => {
// 如果有 cb 回调函数,将 cb 存储到 callbacks
if (cb) {
cb();
} else if (_resolve) {
// 如果参数 cb 不存在,则保存 Promise 的成功回调 resolve
_resolve();
}
});

// 第一次使用 nextTick 时,pending 为 false,下面的代码才会执行
if (!pending) {
// 改变标记位的值,如果有 nextTickHandler 被推送到任务队列中去则不需要重复推送
pending = true;
// 调用 timerFunc,将 flushCallbacks 推送到合适的任务队列
timerFunc(flushCallbacks);
}

// 如果没有 cb 且环境支持 Promise,则返回一个 Promise
if (!cb && typeof Promise !== "undefined") {
return new Promise((resolve) => {
// 保存 resolve 到 callbacks 数组中
_resolve = resolve;
});
}
}



你真的太牛了,居然几乎全部看完了!


来杯咖啡.jpg


Vue纯源码


    上面的代码实现,对于 nextTick 功能已经非常完整了,接下来我将给你展示出 Vue 中实现 nextTick 的完整源码。无非是加了一些判断变量是否存在的判断。看完上面的讲解,我相信聪明的你一定能理解 Vue 实现 nextTick 的源码了吧!💡


// 存储所有的 cb 回调函数
const callbacks = [];
/* 类似于节流的标记位,标记是否处于节流状态。防止重复推送任务 */
let pending = false;

/* 遍历执行数组 callbacks 中的所有存储的 cb 回调函数 */
function flushCallbacks() {
pending = false; // 重置标记,允许下一个 nextTick 调用
const copies = callbacks.slice(0); // 复制当前的 callbacks 数组
callbacks.length = 0; // 清空 callbacks 数组
for (let i = 0; i < copies.length; i++) {
copies[i](); // 执行每一个存储的回调函数
}
}
// 判断是否为原生实现的函数
function isNative(Ctor) {
// 如Promise.toString() 为 'function Promise() { [native code] }'
return typeof Ctor === "function" && /native code/.test(Ctor.toString());
}

// 判断最终支持的 API:Promise / MutationObserver / setImmediate / setTimeout
let timerFunc;

if (typeof Promise !== "undefined" && isNative(Promise)) {
const p = Promise.resolve(); // 创建一个已解决的 Promise 实例
timerFunc = () => {
p.then(flushCallbacks); // 使用 p.then 将 flushCallbacks 推送到微任务队列

// 在某些有问题的 UIWebView 中,Promise.then 并不会完全失效,
// 但可能会陷入一种奇怪的状态:回调函数被添加到微任务队列中,
// 但队列并没有被执行,直到浏览器需要处理其他工作,比如定时器。
// 因此,我们可以通过添加一个空的定时器来“强制”执行微任务队列。
if (isIOS) setTimeout(() => {}); // 解决iOS 的bug,推迟 空函数 的执行(如果不理解,建议忽略)
};
} else if (
typeof MutationObserver !== "undefined" &&
(isNative(MutationObserver) ||
MutationObserver.toString() === "[object MutationObserverConstructor]")
) {
let counter = 1; // 用于切换文本节点的值
const observer = new MutationObserver(flushCallbacks); // 创建 MutationObserver 实例
const textNode = document.createTextNode(String(counter)); // 创建文本节点
observer.observe(textNode, {
characterData: true, // 监听文本节点的变化
});
// 定义 timerFunc 为使用 MutationObserver 的方式调度 flushCallbacks
timerFunc = () => {
counter = (counter + 1) % 2; // 切换 counter 的值(0 或 1)
textNode.data = String(counter); // 更新文本节点以触发观察者
};
} else if (typeof setImmediate !== "undefined" && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks); // 使用 setImmediate 推送到任务队列
};
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0); // 使用 setTimeout 推送到宏任务队列
};
}

function nextTick(cb, ctx) {
let _resolve; // 用于存储 Promise 的解析函数
// 将回调函数 cb 添加到 callbacks 数组中
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx); // 执行传入的回调函数
} catch (e) {
handleError(e, ctx, "nextTick"); // 错误处理
}
} else if (_resolve) {
_resolve(ctx); // 解析 Promise
}
});
// 第一次使用 nextTick 时,pending 为 false,下面的代码才会执行
if (!pending) {
pending = true; // 改变标记位的值
timerFunc(); // 调用 timerFunc,调度 flushCallbacks
}
// 如果没有 cb 且环境支持 Promise,则返回一个 Promise
if (!cb && typeof Promise !== "undefined") {
return new Promise((resolve) => {
_resolve = resolve; // 存储解析函数
});
}
}

总结


      通过这样分成三步、循序渐进的方式,我们深入探讨了 nextTick 的原理和实现机制。希望这篇文章能够对你有所帮助,让你在前端开发的道路上更加得心应手!🚀


实在学不下去了.jpg


作者:前端金熊
来源:juejin.cn/post/7433439452662333466
收起阅读 »

用Vant组件库来做移动端UI界面能有多方便?🚀🚀🚀

web
前言 最近写一个移动端的项目,用到了vant做UI界面设计,不得不再次感叹开源的力量,这个组件库封装的实在是太优雅了,只要你愿意看官方文档,就不会担心看不懂,也不会担心不会用,接下来就带大家去浅尝一下这个组件库。官网文档:Vant 4 - 轻量、可定制的移动端...
继续阅读 »

前言


最近写一个移动端的项目,用到了vant做UI界面设计,不得不再次感叹开源的力量,这个组件库封装的实在是太优雅了,只要你愿意看官方文档,就不会担心看不懂,也不会担心不会用,接下来就带大家去浅尝一下这个组件库。官网文档:Vant 4 - 轻量、可定制的移动端组件库 (vant-ui.github.io)


Vant


Vant 是一个轻量、可定制的移动端组件库,于 2017 年开源。


可以看到,移动端,用来做移动端的ui界面。


目前 Vant 官方提供了 Vue 2 版本Vue 3 版本微信小程序版本,并由社区团队维护 React 版本支付宝小程序版本


特性



  • 🚀 性能极佳,组件平均体积小于 1KB(min+gzip)

  • 🚀 80+ 个高质量组件,覆盖移动端主流场景

  • 🚀 零外部依赖,不依赖三方 npm 包

  • 💪 使用 TypeScript 编写,提供完整的类型定义

  • 💪 单元测试覆盖率超过 90%,提供稳定性保障

  • 📖 提供丰富的中英文文档和组件示例

  • 📖 提供 Sketch 和 Axure 设计资源

  • 🍭 支持 Vue 2、Vue 3 和微信小程序

  • 🍭 支持 Nuxt 2、Nuxt 3,提供 Nuxt 的 Vant Module

  • 🍭 支持主题定制,内置 700+ 个主题变量

  • 🍭 支持按需引入和 Tree Shaking

  • 🍭 支持无障碍访问(持续改进中)

  • 🍭 支持深色模式

  • 🍭 支持服务器端渲染

  • 🌍 支持国际化,内置 30+ 种语言包


没错,这是官网复制来的介绍。


那么从官方文档可以看到目前这是Vant4.x,适用于vue3,如果你用的是vue2,那么就用Vant2。我们切换到快速上手,就能看到如何快速入门使用


Vant使用


1.安装


在现有项目中使用 Vant 时,可以通过 npm 进行安装:


# Vue 3 项目,安装最新版 Vant
npm i vant

# Vue 2 项目,安装 Vant 2
npm i vant@latest-v2

看得出来,非常优雅,最新版直接安装vant,如果不是最新的才加了些-2...


当然,你也可以通过 yarnpnpm 或 bun 进行安装:什么方式都有,大家可以自己参照官方文档。


# 通过 yarn 安装
yarn add vant

# 通过 pnpm 安装
pnpm add vant

# 通过 Bun 安装
bun add vant

那么引入之后如何使用呢?我们常常需要做一个登陆注册页面,看看有了vant组件库之后,我们还需要去切页面吗?


image.png
此处我们需要放一个表单,接下来我们查阅官方文档,看看vant的表单如何使用。


image.png


2.引入


写的很清楚,表单会用到三个组件,我们只需要从vant库中引入这三个组件,然后都use掉即可


import { createApp } from 'vue';
import { Form, Field, CellGr0up } from 'vant';

const app = createApp();
app.use(Form);
app.use(Field);
app.use(CellGr0up);


引入之后,就可以在我们需要用到表单的页面使用了。直接将他写好的表单复制到我们的代码中


<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="username"
name="用户名"
label="用户名"
placeholder="用户名"
:rules="[{ required: true, message: '请填写用户名' }]"
/>

<van-field
v-model="password"
type="password"
name="密码"
label="密码"
placeholder="密码"
:rules="[{ required: true, message: '请填写密码' }]"
/>

</van-cell-group>

<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
提交
</van-button>
</div>

</van-form>


可以看到,它使用了v-model双向绑定,因此我们就需要自己去定义一个username、passwor...看需要用到什么,然后官方文档就直接了当告诉我们了这些变量


import { ref } from 'vue';

export default {
setup() {
const username = ref('');
const password = ref('');
const onSubmit = (values) => {
console.log('submit', values);
};

return {
username,
password,
onSubmit,
};
},
};



  • 注意他这里提交表单事件,直接接收了一个参数values,事实上这个values可以直接通过values.name,我们input框上的name属性代表的input框拿到的数据,在这里其实就是values.用户名,所以我们可以直接改成英文,如:values.username。


image.png


在官方文档上也能看见他的样式,甚至可以做校验


除此之外,再介绍一个,例如微信中点击某个东西成功之后,就会弹出一个弹出框,打勾,或者失败,如果这么一个UI界面要我们自己去写可能多少也有点麻烦,但是vant都给我们封装好了


image.png
引入


通过以下方式来全局注册组件,更多注册方式请参考组件注册


import { createApp } from 'vue';
import { Toast } from 'vant';

const app = createApp();
app.use(Toast);

显然就是引入一个封装好了的Toast组件,然后use掉,vant官方都给我们想好了,一般这种弹出提示框都是我们js中做判断,如果成功就弹出,因此一般都出现在函数里面,官方就直接给我们打造了函数调用的方式


import { showToast } from 'vant';

showToast('提示内容');


全局引入依赖之后,在js中直接引入,然后直接调用就能出现效果,帮助咱们切图仔省去了不少麻烦事儿。


image.png
除了成功、失败,他甚至可以自定义写图标,也是极度舒适了。


看看我的登录页面是如何使用的:


<template>
<div class="login">
<h1>登录</h1>
<div class="login-wrapper">
<div class="avatar">
<img src="https://q6.itc.cn/q_70/images03/20240601/80b789341c9b45cb8a76238650d288a5.png" alt="">
</div>

<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field v-model="username" name="username" label="用户名" placeholder="用户名"
:rules="[{ required: true, message: '请填写用户名' }]" />

<van-field v-model="password" type="password" name="password" label="密码" placeholder="密码"
:rules="[{ required: true, message: '请填写密码' }]" />

</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
登录
</van-button>
</div>
</van-form>


</div>
</div>
</template>


<script setup>
import { ref } from 'vue'
import axios from '@/api/index.js'
const password = ref('')
const username = ref('')
// 登录
const onSubmit = (values) => {
// console.log(values); // 用户输入的账号和密码对象 name作为key
// 发送接口请求给后端 校验数据
axios.post('/user/login', {
username: values.username,
password: values.password
}).then((res) => {
console.log(res);
})
}
</script>


<style lang="less" scoped>
.login {
width: 100vw;
height: 100vh;
background-color: #fff;
padding: 0 0.3rem;
box-sizing: border-box;
overflow: hidden;

h1 {
height: 0.6933rem;
text-align: center;
font-size: 0.48rem;
margin-top: 1.12rem;
}

.login-wrapper {
width: 7.44rem;
height: 10.77rem;
border: 1px solid rgba(187, 187, 187, 1);
margin: 0 auto;
margin-top: 1.7rem;
border-radius: 0.3rem;
box-shadow: 0 0 0.533rem rgba(170, 170, 170, 1);

.avatar {
width: 2.4rem;
height: 2.4rem;
margin: 1rem auto 0.77rem auto;
border-radius: 50%;
overflow: hidden;

img {
width: 100%;
}
}
}
}

:deep(.van-cell__title.van-field__label) {
width: 45px;
}
</style>


非常方便,跟着上述步骤,官方文档写的很清楚,直接贴上去就实现了,然后自己再加一些less样式。


那么其他的就不做过多介绍了,大家在做项目需要用到一些东西的时候,直接查阅官方文档,直接拿去用即可。Vant 4 - 轻量、可定制的移动端组件库 (vant-ui.github.io)


在这里我写的都是rem单位,目的是做移动端的适配,根据宽度调整fontsize的大小,其实也可以通过vant封装好的移动端适配组件来做,这里不做过多介绍了。


小结


Vant 是一个轻量、可靠的移动端组件库,可用于快速构建风格统一的移动端 UI 界面。它提供了一系列高质量的组件,涵盖了导航、表单、按钮、列表、轮播图等常用功能。


我们只需要安装依赖后引入依赖还有在全局中引入他的整份css代码,最后按需取组件,就能轻松完成ui界面设计。


作者:zykk
来源:juejin.cn/post/7389577588174503936
收起阅读 »

彻底理解import和require区别和用法

web
前言 在真实工作中,估计import和require大家经常见到,如果做前端业务代码,那么import更是随处可见了。但我们都是直接去使用,但是这两种方式的区别是什么呢?应用场景有什么区别呢? 大部分能说出来import是ES6规范,而require是Comm...
继续阅读 »

前言


在真实工作中,估计importrequire大家经常见到,如果做前端业务代码,那么import更是随处可见了。但我们都是直接去使用,但是这两种方式的区别是什么呢?应用场景有什么区别呢?


大部分能说出来importES6规范,而requireCommonJS规范,然后面试官深入问你两者编译规则有啥不一样?然后就不知道了


本文一次性对importrequire模块基本概念编译规则基本用法差异生态支持性能对比5个方面一次理清总结好,下次遇到这种问题直接举一反三。


一、模块基本概念


require: 是CommonJS模块规范,主要应用于Node.js环境。


import:是ES6模块规范,主要应用于现代浏览器和现代js开发(适用于例如各种前端框架)。


二、编译规则


require


require 执行时会把导入的模块进行缓存,下次再调用会返回同一个实例。


CommonJS模块规范中,require默认是同步的。当我们在某个模块中使用require调用时,会等待调用完成才接着往下执行,如下例子所示。


模块A代码


console.log('我是模块A的1...');
const moduleB = require('./myModuleB');
console.log('我是模块A的2');

模块B代码


console.log('我是模块B...');

打印顺序,会按顺序同步执行


// 我是模块A的1...
// 我是模块B...
// 我是模块A的2...


注意require并非绝对是同步执行,例如在Webpack中能使用 require.ensure 来进行异步加载模块。



import


ES6模块规范中,import默认是静态编译的,也就是在编译过程就已经确认了导入的模块是啥,因此默认是同步的。import有引用提升置顶效果,也就是放在何处都会默认在最前面。


但是...., 通过import()动态引入是异步的哦,并且是在执行中加载的。
import()在真实业务中是很常见的,例如路由组件的懒加载component: () => import('@/components/dutest.vue')和动态组件const MyTest = await import('@/components/MyTest.vue');等等,import() 执行返回的是一个 Promise,所以经常会配合async/await一起用。


三、基本用法差异


require


一般不直接用于前端框架,是用于 Node.js 环境和一些前端构建工具(例如:Webpack)中


1. 导入模块(第三方库)

Node.js中经常要导入各种模块,用require可以导入模块是最常见的。例如导入一个os模块


const os = require('os');

// 使用
os.platform()

2. 导入本地写好的模块

假设我本地项目有一个名为 utils.js 的本地文件,文件里面导出一个add函数


module.exports = {
add: (a, b) => a + b,
};

在其它文件中导入并使用上面的模块


const { add } = require('../test/utils');

// 使用
add(2, 3);

import


一般都是应用于现在浏览器和各种主流前端框架(例如:Vue\react


1. 静态引入(项目中最常用)

这种情况一般适用于确定的模块关系,是在编译时解析


<script setup>
import { ref } from 'vue';
import test from '@/components/test.vue';
</script>

2. 动态引入

其实就是使用import()函数去返回一个 Promise,在Promise回调函数里面处理加载相关,例如路由的懒加载。


{
path: '/',
name: 'test',
component: () => import('@/components/dutest.vue')
},

或者动态引入一些文件(或者本地的JSON文件)


<script setup>
const MyTest = await import('@/components/MyTest.vue');
</script>

四、生态支持


require


Node.js14 之前是默认模块系统。目前的浏览器基本是不原生支持 CommonJS,都是需要通过构建工具(如 Webpack )转换才行。并且虽然目前市面上CommonJS依然广泛使用,但基本都是比较老的库,感觉被逐渐过渡了。


import


importES6规范,并且Node.jsNode.js12开始支持ES6Node.js14 之后是默认选项。目前现代浏览器和主流的框架(Vue、React)都支持原生ES6,大多数现代库也是,因此import是未来主流。


五、性能对比


ES6 支持 Tree Shaking摇树优化,因此可以更好地去除一些没用的代码,能很好减小打包体积。
所以import有更好的性能。


import()能动态导入模块性能更好,而require不支持动态导入。


小结


对比下来发现,import不但有更好性能,而且还是Node.js14之后的默认,会是主流趋势。


至此我感觉足够能举一反三了,如有哪里写的不对或者有更好建议欢迎大佬指点一二啊。


作者:天天鸭
来源:juejin.cn/post/7425135423145394213
收起阅读 »

深入解析 effet.js:人脸识别、添加、打卡与睡眠检测的项目结构揭秘

web
深入解析 effet.js:人脸识别、添加、打卡与睡眠检测的项目结构揭秘 近年来,面部识别和 AR 特效技术的普及让我们在日常应用中越来越多地接触到有趣的互动体验。而基于 facemesh.js 的二次开发框架——effet.js,则为开发者提供了一种简单而强...
继续阅读 »

深入解析 effet.js:人脸识别、添加、打卡与睡眠检测的项目结构揭秘


近年来,面部识别和 AR 特效技术的普及让我们在日常应用中越来越多地接触到有趣的互动体验。而基于 facemesh.js 的二次开发框架——effet.js,则为开发者提供了一种简单而强大的方式来实现面部特效和识别功能。今天,我们将通过 effet.js 的项目结构,深入探讨其运行原理,看看它是如何利用前沿技术来实现流畅的人脸检测与交互的。


1. effet.js 的整体架构


effet.js 的项目结构采用模块化设计,通过明确的目录划分,将各项功能进行清晰地组织和封装。我们可以将项目大致分为以下几个部分:



  • components:主要包括内部初始化数据、公共枚举以及管理当前 DOM 实例的逻辑。

  • core:核心模块,包含动作处理、数据库交互、DOM 操作等多项功能,是整个框架的关键部分。

  • styles:存放框架的样式文件,用于定义人脸特效的视觉表现。

  • util:各种通用工具函数,包括摄像头操作、条件检测、绘制等常用的辅助功能。

  • index.js:整个项目的入口文件,负责初始化和启动框架。


接下来,我们将详细介绍这些模块的作用及其在 effet.js 运行过程中的角色。


2. components 组件模块


components 目录主要用于存放框架的公共组件和初始化数据。



  • AppState.ts:管理内部的初始化数据,包括摄像头、DOM 元素等基本信息。

  • FaceManager.ts:用于管理当前 DOM 单例,这个类的作用是确保在处理消息替换时,始终对同一 DOM 元素进行操作,避免出现不必要的内存占用和资源浪费。


这种设计让 effet.js 在处理多次人脸检测和特效应用时能够高效、稳定地管理 DOM 元素。


3. core 核心模块


core 目录是 effet.js 的核心逻辑所在,涵盖了以下几个重要部分:



  • action:动作处理模块,包含人脸添加、登录检测、睡眠检测和打卡等功能。例如,addFace/index.js 负责处理用户人脸添加的逻辑,而 checkLogin/index.js 则用于人脸登录的检测。

  • before:动作前的预处理模块。每个动作在执行前,可能需要一些额外的检查或准备工作。例如,checkLogin/index.js 用于处理登录前的检查逻辑。

  • db:数据库模块,db.js 负责使用 IndexedDB 来存储和缓存用户数据、模型信息等。这种设计让 effet.js 可以离线运行,提升了用户体验。

  • defaultAssign:默认配置和参数分配模块,assign.js 用于为框架中的各个组件提供初始参数和默认值,确保框架在各种环境下均能正常运行。

  • dom:DOM 操作模块,包含创建和管理人脸相关 DOM 元素的逻辑,例如 createFaceElements.js 用于动态创建人脸特效所需的 DOM 元素。

  • log:用于屏蔽插件相关的内部警告信息,log.js 确保了控制台的整洁,方便开发人员进行调试。


核心模块的分层设计使得每个功能都具有独立性和可维护性,这对于复杂交互和特效的实现尤为重要。


代码示例:人脸添加动作

以下是处理人脸添加动作的代码示例:


import { addFace } from './core/action/faceAction';

// 执行人脸添加逻辑
addFace().then(() => {
console.log('人脸添加成功');
}).catch(error => {
console.error('人脸添加失败:', error);
});

上述代码展示了如何调用核心模块中的人脸添加逻辑来实现用户的人脸注册功能。


4. styles 样式模块


styles 目录包含所有与视觉表现相关的文件,用于定义人脸特效的外观。



  • template:存放不同功能的样式模板,如 addFacecheckLogin 等模块中的 index.css 定义了对应特效的样式。

  • faceColor.js:用于处理与人脸特效颜色相关的逻辑,让开发者可以根据需求自定义特效的颜色效果。

  • root.css:全局样式文件,定义了一些基础样式和布局。


样式模块确保了特效在浏览器中展示时具有一致性和视觉吸引力。通过将样式与逻辑分离,开发者可以更方便地对特效的外观进行调整。


图片示例:人脸添加样式

人脸添加样式示例.png


5. util 通用工具模块


util 目录包含了多个工具函数,用于简化常见的任务,例如摄像头操作、距离计算和人脸网格处理。



  • cameraAccessUtils.jscameraUtils.js:用于处理摄像头的访问和操作,如获取摄像头权限、切换摄像头等。

  • faceMesh.js:负责处理人脸网格的相关逻辑,例如通过 facemesh 模型获取人脸特征点。

  • distanceUtils.jsdrawingUtils.js:用于进行距离计算和绘图操作,这些工具函数在渲染特效时被频繁使用。


这些工具函数的存在,让开发者可以专注于高层次的功能开发,而不必担心底层的细节实现。


代码示例:摄像头访问工具

import { requestCameraAccess } from './util/cameraAccessUtils';

// 请求摄像头权限
requestCameraAccess().then(stream => {
console.log('摄像头权限已获取');
// 使用摄像头流进行进一步处理
}).catch(error => {
console.error('无法获取摄像头权限:', error);
});

6. 使用 IndexedDB 的缓存机制


effet.js 通过 core/db/db.js 使用 IndexedDB 来缓存模型和其他依赖资源,从而减少每次加载的时间。这种设计可以显著提升用户体验,尤其是对于网络环境不稳定的用户。


IndexedDB 是浏览器内置的一个低级 API,它允许我们在用户设备上存储大量结构化数据。在 effet.js 中,我们利用 IndexedDB 缓存 facemesh 模型和用户的面部数据,确保在用户首次加载模型后,后续访问时能够直接从缓存中读取数据,而无需重新下载模型。这不仅提升了加载速度,还降低了对网络的依赖。


代码示例:使用 IndexedDB 缓存模型

// 缓存 facemesh 模型到 IndexedDB
async function cacheModel(modelData) {
const db = await openIndexedDB('effetModelCache', 1);
const transaction = db.transaction(['models'], 'readwrite');
const store = transaction.objectStore('models');
store.put(modelData, 'facemeshModel');
}

// 从 IndexedDB 加载模型
async function loadModelFromCache() {
const db = await openIndexedDB('effetModelCache', 1);
const transaction = db.transaction(['models'], 'readonly');
const store = transaction.objectStore('models');
return store.get('facemeshModel');
}

function openIndexedDB(dbName, version) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

通过这样的缓存机制,用户在第一次访问时可能需要加载较长时间,但后续的访问速度会大大提升,从而提供更流畅的用户体验。


7. 核心模块中的预处理逻辑


core/before 目录下,包含了许多预处理逻辑,这些逻辑在每个动作执行之前运行,以确保后续的动作能够顺利进行。



  • checkLogin/index.js:负责在执行人脸登录之前检查用户的身份信息,例如判断用户是否已经注册或存在于数据库中。

  • faceBefore.js:是所有预处理逻辑的入口,通过调用不同的预处理函数,确保每个动作的执行环境和条件都已满足。


这种预处理机制有效地提高了系统的稳定性和安全性。例如,在进行人脸登录时,如果用户的数据尚未注册或缓存不完整,系统将通过预处理逻辑提前捕获这些问题并进行相应的处理。


代码示例:登录前的预处理

import { checkLogin } from './core/before/faceBefore';

// 执行登录前的检查逻辑
checkLogin().then(() => {
console.log('预处理完成,准备执行登录动作');
}).catch(error => {
console.error('登录预处理失败:', error);
});

8. DOM 操作和特效渲染


effet.js 中的 DOM 操作模块负责创建和管理人脸特效相关的 DOM 元素。通过对 DOM 的操作,我们可以将各种特效应用到用户的人脸上,并根据用户面部的变化来实时调整特效的位置和形状。



  • createFaceElements.js:用于动态创建用于渲染特效的 DOM 元素。这些元素包括虚拟的眼镜、面具等,通过将它们附加到特定的人脸特征点,可以实现各种视觉特效。

  • defaultElement.js:提供了一些默认的 DOM 元素配置,如特效的初始位置、大小和样式等。


代码示例:创建人脸特效元素

import { createFaceElements } from './core/dom/createFaceElements';

// 创建用于渲染特效的 DOM 元素
createFaceElements().then(elements => {
console.log('特效元素创建成功', elements);
}).catch(error => {
console.error('特效元素创建失败:', error);
});

通过将特效元素与人脸特征点绑定,我们可以实现一些有趣的交互。例如,当用户张嘴或眨眼时,系统可以检测到这些动作并触发相应的视觉反馈,从而提升用户体验的互动性。


9. 样式与用户体验


样式模块不仅仅是为了让特效看起来美观,更是为了确保其与用户的操作无缝对接。styles/template 目录下的样式模板被精心设计,以适应不同类型的设备和显示环境。


例如,addFace/index.csscheckLogin/index.css 分别定义了人脸添加和登录检测的样式,通过这些样式文件,开发者可以轻松地实现具有一致性且专业的用户界面。


图片示例:多种特效风格

多种特效风格示例.png


这种模块化的样式管理方式让我们可以快速调整不同特效的外观,同时确保代码的可读性和可维护性。在不断的版本迭代中,样式模块可以独立于逻辑模块进行修改,极大地提高了项目的可扩展性。


10. effet.js 的初始化流程


index.js 作为项目的主要入口,负责初始化 effet.js 的所有模块。在初始化过程中,系统会调用核心模块、样式模块和工具函数,以确保整个框架能够无缝启动。



  • initializeEffet:这是一个主函数,负责加载配置、初始化摄像头、检测用户设备是否满足要求,并调用各个核心模块来启动面部检测和特效渲染。

  • FaceManager:用于管理初始化后的 DOM 元素,确保在不同的特效之间切换时,DOM 操作能够始终保持一致。


代码示例:框架初始化

import { initializeEffet } from './core/index';

// 初始化 effet.js 框架
initializeEffet().then(() => {
console.log('effet.js 初始化完成');
}).catch(error => {
console.error('初始化失败:', error);
});

通过这样的初始化流程,我们可以确保框架的各个部分能够正确协同工作,从而为用户提供稳定且高质量的体验。


11. 开发与优化的挑战


effet.js 在开发过程中遇到了许多挑战,例如:



  • 模型加载时间长:由于 facemesh 模型文件较大,我们使用 IndexedDB 来缓存模型,减少用户每次访问的加载时间。

  • 资源丢包与网络不稳定:为了解决网络环境不稳定的问题,我们采用了离线缓存策略,并通过优化加载顺序和减少请求次数来提升加载速度。

  • 性能优化:为了保证在中低端设备上的流畅运行,我们对人脸检测和特效渲染进行了大量优化,包括减少计算开销、利用 WebGL 加速等。


这些挑战促使我们不断改进框架的架构和实现方式,以确保 effet.js 在不同的设备和网络环境下都能表现出色。


结语


effet.js 是一个旨在降低面部识别和特效开发门槛的框架,通过模块化的项目结构封装复杂的底层逻辑,让开发者可以专注于创造有趣的互动体验。通过组件、核心模块、样式、工具函数以及缓存机制的有机结合,effet.js 为开发者提供了强大的基础设施来构建各种人脸交互应用。


通过这篇文章,我们展示了 effet.js 的整体架构、人脸检测与特征点定位、特效渲染、交互逻辑实现以及优化挑战,并结合代码示例和图片示例来帮助你更好地理解其运行原理。希望你能够从中获得启发,创造更多有趣的应用!


如果你有任何问题或者想要进一步了解它的应用,欢迎在评论区留言讨论!


这篇博客旨在详细介绍 effet.js 的运行原理和模块化设计,帮助开发者深入了解其工作机制。在未来的开发中,我们期待更多的人参与到这个项目中来,共同探索面部识别和特效技术的更多可能性。


更多资源



作者:用户611466961220
来源:juejin.cn/post/7433805918494916618
收起阅读 »

仿拼多多领红包、金额数字滚动如何实现?

web
拼多多现金大转盘领取红包后,小数部分有一个数字移动的效果,这个效果怎么做呢? 本文我会告诉你数字移动的原理,并用 React 实现一个 Demo,效果如下: 拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。 滚...
继续阅读 »

拼多多现金大转盘领取红包后,小数部分有一个数字移动的效果,这个效果怎么做呢?


pdd.gif


本文我会告诉你数字移动的原理,并用 React 实现一个 Demo,效果如下:


scroll-number.gif


拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。


滚动原理


不难想到,数字移动,实质是一串数字列表,在容器内部向上移动。下图就展示了数字从 0 变为 2,又从 2 变为 4 的过程:


up.png


但是,金额数字比较特殊,它是可以进位的。举例来说,39.5 变为 39.9 时,小数部分由 5 到 9 需要向上移动;39.9 变为 40.2 时,小数部分由 9 变到 2 时也需要向上移动。


为了做到这个效果,我们需要每次滚动结束之后,重新设置一整串数字。


同样是从 0 变为 2,又从 2 变为 4。下图不同的是,数字变为 2 时,它下方的数字变为了 3、4、5、6、7、8、9、0、1;数字变为 4 时,它下方的数字变为了 5、6、7、8、9、0、1、2、3。


loop.png


关键布局


了解原理后,我们开始写元素布局。关键布局有 2 个元素:



  • 选择框,它可以确认下一个将要变成的数字,我们用它来模拟领取红包之后、金额变化的情况。

  • 数字盒子,它包括三部分,带 overflow: hidden 的外层盒子,包裹数字并向上滚动的内层盒子,以及一个个数字。



点击查看代码
const App = function () {
const [options] = useState([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
const [nums, setNums] = useState([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

return (
<main>
<div className="select-box">
<span>改变数字:</span>
<select>
{
options.map(v => (
<option key={v}>{v}</option>
))
}
</select>
</div>
<div className="num-box">
<div>
{
nums.map(v => (
<div className="num" key={v}>{v}</div>
))
}
</div>
</div>
</main>

)
};


关键逻辑


数字移动的关键逻辑有 3 个:



  • 重置数字数组

  • 计算移动距离

  • 开启关闭动画


重置数字数组


你之前已经知道,如果数组首位是 2,它下面的数字就是 3、4、5、6、7、8、9、0、1。要获取完整的数组,我们可以分为两个步骤:



  • 首先,数组首位背后的数字依次加 1,这样数字就变为了 3、4、5、6、7、8、9、10、11;

  • 然后,所有大于 9 的数字都减去 10,这样数字就变为了 3、4、5、6、7、8、9、0、1。



点击查看代码
const getNewNums = (next) => {
const newNums = []
for (let i = next; i < next + 10; i++) {
const item = i > 9 ? (i - 10) : i
newNums.push(item)
}
return newNums
}


计算移动距离


你可以用 current 表示当前的数字,next 表示需要变成的数字。计算移动距离时,需要分两种情况考虑:



  • next 大于 current 时,只需要移动 next - current 个数字即可;

  • next 小于 current 时,需要先移动 10 - next 个数字,再移动 current 个数字即可。



点击查看代码
const calculateDistance = (current, next) => {
const height = 40
let diff = next - current
if (next < current) {
diff = 10 - current + next
}
return -(diff * height)
}


开启关闭动画


不难想到,我们数字移动的动画是使用 translateY 和 transition 实现。当数字移动时,我们把 translateY 设置为 calculateDistance 的结果;当移动结束、重置数组时,我们需要把 translateY 设置为 0。


整个过程中,如果我们一直开启动画,效果会是数字先向上移动,再向下移动,这并不符合预期。


back.gif


因此,我们需要在数字开始移动时开启动画,数字结束移动后、重置数组前关闭动画。



点击查看代码
const App = function () {
// ... 省略
const numBoxRef = useRef()

const onChange = (e) => {
// 开启动画
numBoxRef.current.style.transition = `all 1s`
// ... 省略
}

const onTransitionEnd = () => {
// 关闭动画
numBoxRef.current.style.transition = ''
// ... 省略
}

return (
<main>{/* ... 省略 */}</main>
)
};


完整代码



总结


本文介绍了类似拼多多的金额数字滚动如何实现,其中有三个关键点,分别是重置数字数组、计算移动距离和开启关闭动画。


拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。


作者:小霖家的混江龙
来源:juejin.cn/post/7430861008753688576
收起阅读 »

calc-size: 一个解决浏览器11年的 bug的属性终于上线了

web
我们在平常写动画的时候,经常会遇到元素的高度 height:auto或者 100% 时,无法触发过渡动画效果。 这是浏览器一直存在的问题,这个问题最早可以追溯到距今2013年,可以说是由来已久的问题了。 问题复现 我们先来复现一遍这个问题。 当我们将盒子的...
继续阅读 »


我们在平常写动画的时候,经常会遇到元素的高度 height:auto或者 100% 时,无法触发过渡动画效果。


这是浏览器一直存在的问题,这个问题最早可以追溯到距今2013年,可以说是由来已久的问题了。



问题复现


我们先来复现一遍这个问题。



当我们将盒子的高度从 0 变成 auto 或者 100% 的时候, 盒子是没有过度动画的。显示得很生硬。


不过我们可以使用其他的方式去使得这个过渡动画生效,方法有很多,这里就不过多追溯了。


calc-size 插值计算属性


calc-size 属性是一个最最新提出的属性, 和 calc 类似,都是可以计算的。现阶段还在一个草案阶段,但是浏览器已经有支持的计划了,预计在 chrome 129 版本就正式发布了。


如果要在浏览器中体验这个属性,可以在 chrome://flags/ 中开启 Experimental Web Platform features 进行体验



基础语法


<calc-size()> = calc-size( <calc-size-basis>, <calc-sum>? )


  • calc-size-basis: 可以是 pxautomax-contentpercent 等等






  • calc-sum:表示只可以进行 css 单位进行 相加、相减 操作



使用示例


通过使用 calc-size 属性计算高度的插值过程,这样就可以实现高度从 0 到 300px 的高度过渡变化。



interpolate-size 全局属性


interpolate-size 可以让我们在根元素上设置插值计算的规则,这样针对整个页面都会生效。


interpolate-size 有两个值,一个是 allow-keywords 所有关键字,一个是仅限数字 numeric-only


numeric-only


设置了这个属性之后,如果插值的属性值不是数字的话,就不会产生过渡的效果



只有设置了数字,过渡才会生效。



allow-keywords


设置了这个属性,只要是合法的属性值,都会插值计算,从而都会产生过渡效果。



小结


相信再过上一两年, calc-size支持计划,我们就可以在浏览器中使用 cacl-size 插值计算属性了。到时候就不需要再用 hack 的方法处理过渡效果。



如果这篇文章对你有帮助,欢迎点赞、关注➕、转发 ✔ !


作者:前端蛋卷
来源:juejin.cn/post/7395385447294271526
收起阅读 »

qs.js库的使用

web
用于url参数转化:parse和stringify的js库 import qs from 'qs' qs.parse('a=b&c=d'); => 转化为{ a: 'b', c: 'd' } qs.stringify({a: 'b', c:...
继续阅读 »

用于url参数转化:parse和stringify的js库


import qs from 'qs'

qs.parse('a=b&c=d'); => 转化为{ a: 'b', c: 'd' }
qs.stringify({a: 'b', c: 'd'}) => 转化为‘a=b&c=d’

qs.parse


qs.parse 方法可以把一段格式化的字符串转换为对象格式


let url = 'http://item.taobao.com/item.htm?a=1&b=2&c=&d=xxx&e';
let data = qs.parse(url.split('?')[1]);

// data的结果是
{
a: 1,
b: 2,
c: '',
d: xxx,
e: ''
}

qs.stringify


基本用法


qs.stringify 则和 qs.parse 相反,是把一个参数对象格式化为一个字符串。


let params = { c: 'b', a: 'd' };
qs.stringify(params)

// 结果是
'c=b&a=d'

排序


甚至可以对格式化后的参数进行排序


qs.stringify(params, (a,b) => a.localeCompare(b))

// 结果是
'a=b&c=d'

let params = { c: 'z', a: 'd' };
qs.stringify(params, {
sort: (a, b) => a.localeCompare(b)
});

// 结果是
'a=d&c=z'

指定数组编码格式


let params = [1, 2, 3];

// indices(默认)
qs.stringify({a: params}, {
arrayFormat: 'indices'
})
// 结果是
'a[0]=1&a[1]=2&a[2]=3'

// brackets
qs.stringify({a: params}, {
arrayFormat: 'brackets'
})
// 结果是
'a[]=1&a[]=2&a[]=3'

// repeat
qs.stringify({a: params}, {
arrayFormat: 'repeat'
})
// 结果是
'a=1&a=2&a=3'

处理json格式的参数


在默认情况下,json格式的参数会用 [ ] 方式编码


let json = { a: { b: { c: 'd', e: 'f' } } };

qs.stringify(json);
//结果 'a[b][c]=d&a[b][e]=f'

但是某些服务端框架,并不能很好的处理这种格式,所以需要转为下面的格式


qs.stringify(json, {allowDots: true});  
//结果 ‘a.b.c=d&a.b.e=f’

作者:砺能
来源:juejin.cn/post/7431999633071030283
收起阅读 »

CSS 技巧:如何让 div 完美填充 td 高度

web
引言一天哈比比突然冒出一个毫无理头的一个问题:本文就该问题进行展开...一、需求说明大致需求如下, 当然这里做了些简化有如下初始代码:一个自适应的表格每个单元格的宽度固定 200px每个单元格高度则是自适应每个单元格内是一个 div&nbs...
继续阅读 »

引言

一天哈比比突然冒出一个毫无理头的一个问题:

image

本文就该问题进行展开...

一、需求说明

大致需求如下, 当然这里做了些简化

有如下初始代码:

  • 一个自适应的表格
  • 每个单元格的宽度固定 200px
  • 每个单元格高度则是自适应
  • 每个单元格内是一个 div 标签, div 标签内包裹了一段文本, 文本内容不定

下面是初始代码(为了方便演示和美观, 代码中还加了些背景色、边距、圆角, 这些都是可以忽略):

<table>
<tr>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
tr>
table>
<style>
table {
background: #f5f5f5;
}

td {
background: #ffccc7;
}

table, tr, td {
padding: 12px;
border-radius: 4px;
}

td > div {
padding: 12px;
border-radius: 4px;
background: #f4ffb8;
}
style>

上面代码的整体效果如下:

image

上面是哈比比目前的现状, 然后需求就是希望, 黄色部分也就是 div 标签能够高度撑满单元格(td), 也就是如下图所示:

image

二、关键问题

这里我第一反应就是, 既然 td 高度是对的(自适应)的那么 div 高度直接设置 100% 不就好了吗? 事实是这样的吗? 我们可以试下:


...


实际效果肯定是没有用的, 要不然也就不会有这篇文章了 🐶🐶🐶

image

主要问题: 在 CSS 中如果父元素没有一个明确的高度, 子元素设置 100% 是无法生效的, 至于为啥就不能生效呢, 因为如果可以, 那么必然会进入死循环这里可以参考张鑫旭大大的文章《从 height:100% 不支持聊聊 CSS 中的 "死循环"》

三、方案一(定位)

通过定位来实现, 也是哈比比最初采用的一个方案:

  • td 设置相对定位即: position: relative;
  • td 下的子元素通过相对定位(position: absolute;)撑满
....

整体效果如下:

image

上面代码其实我并没有给所有 td 中的 div 设置 position: absolute; 目的是为了留一个内容最多的块, 来将 tr td 撑开, 如果不这么做就会出现下面这种情况:

image

所以, 严格来说该方案是不行的, 但是可能哈比比情况比较特殊, 他只有空值和有内容两种情况, 所以他完全可以通过判断内容是否为空来设置 position: absolute; 即可

四、方案二(递归设置 height 100%)

第二个方案就是给 tabletrtd 设置一个明确的高度即 100%, 这样的话 td 中的子元素 div 再设置高度 100% 就可以生效了


效果如下:

image

上面第一个单元格高度其实还是有点问题, 目前也没找到相关研究可以结束这个现象, 要想达到我们要的效果解决办法有两个:

  1. 移除代码中所有 padding, 有关代码和效果图如下:

image

  1. 修改 td 中 div 的 box-sizing 属性为 border-box, 有关代码和效果图如下:

image

五、方案三(利用 td 自增加特效, 推荐)

方案三是比较推荐的做法, 其利用了 td 自增加的一个特效, 那么何谓自增加呢? 假设我们给 td 设置可一个高度 1px 但是呢它实际高度实际上是会根据 tr 的高度进行自适应(自动增长), 那么在这种情况下我们给 td 下子元素 div 设置高度 100% 则会奏效, 因为这时的 td 高度是明确的

<table>
<tr>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
tr>
table>
<style>
table {
background: #f5f5f5;
}

td {
height: 1px; /* 关键代码 */
background: #ffccc7;
}

table, tr, td {
padding: 12px;
border-radius: 4px;
}

td > div {
height: 100%; /* 关键代码 */
padding: 12px;
border-radius: 4px;
background: #f4ffb8;
}
style>

效果如下:

image

六、补充: td 下 div 内容顶对齐

几天后, 哈比比又来找我了 🐶🐶🐶

image

这次需求就比较简单了, 就是 td 中默认情况下子元素(p)都是居中呈现的, 现想要的就是能否居上(置顶)展示

这里初始代码和上面是一样的:

<table>
<tr>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
tr>
table>
<style>
table {
background: #f5f5f5;
}

td {
background: #ffccc7;
}

table, tr, td {
padding: 12px;
border-radius: 4px;
}

td > div {
padding: 12px;
border-radius: 4px;
background: #f4ffb8;
}
style>

默认效果就是 div 都居中展示:

image

这里我第一反应是用 vertical-align 但是该属性在很多人印象中只针对 行内元素(或文本)才能生效, 但这里是 div 是 块元素 所以哈比比自然就忽略了该 vertical-align 属性

但实际上如果查阅文档会发现 vertical-align 实际用途有两个:

  1. 用来指定行内元素(inline)的垂直对齐方式
  2. 表格单元格(table-cell)元素的垂直对齐方式

所以这个问题就简单了, 一行 CSS 就解决了:


完美实现(最终效果):

image

七、参考


作者:墨渊君
来源:juejin.cn/post/7436027021057884172

收起阅读 »

如何在高德地图上制作立体POI图层

web
本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究! 前言 在基于GIS的数据可视化层面,我们能够展示的基本数据无非就是点线面体,其中,离散的点数据出现的情况相对较为普遍,通常POI(Point of Interest)...
继续阅读 »

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!



前言


在基于GIS的数据可视化层面,我们能够展示的基本数据无非就是点线面体,其中,离散的点数据出现的情况相对较为普遍,通常POI(Point of Interest)的展示方式和丰富程度对于用户体验和地图的实用性有着重要的影响。在这篇技术分享文章中,我们将由浅入深地探讨如何在高德地图上创建大量立体 POI。相信通过本文的介绍,开发者能够受到启发,并且掌握这一个不错的技巧,为地图点数据的展示和应用带来新的视觉和功能体验。


需求分析


首先收集一波需求:在地图上展示大量的POI,能够配置用第三方工具制作的模型,作为POI的主体,能够实现基本的鼠标交互操作,比如鼠标悬浮状态下具有区别于其他POI的特殊的形态或者动画,每个POI能够根据自身属性出现特异的外观,再厉害一点的能不能实现固定POI在屏幕上的大小,即POI的尺寸不会随着地图缩放的远近而出现变化。


根据以上琐碎的内容我们可以整理为以下功能描述,下文我们将一步步实现这些需求:



  • 支持灵活配置POI模型,POI样式可调整

  • 能够支持大数据量(10000+)的POI展示

  • 支持鼠标交互,能够对外派发事件

  • 支持动画效果

  • 支持开启一种模式,不会随地图远近缩放而改变POI的可见尺寸


poi3dLayer.gif


实现步骤


从基础功能到进阶功能逐步完善这个POI图层,篇幅有限每个功能仅陈述开发原理以及核心代码,完整的代码工程可以到这里查看下载


加载模型到场景中



  1. 首先讨论一个POI的情况要如何加载,以本文为例我们的POI是一个带波纹效果的倒椎体模型,根据后续的动画情况,我们把它拆成两个模型来实现。


    image.png


  2. 把主体和托盘模型分别加载到场景中,并给它们替换为自己创建的材质,代码实现如下


    // 加载单个模型
    loadOneModel(sourceUrl) {

    const loader = new GLTFLoader()
    return new Promise(resolve => {
    loader.load(sourceUrl, (gltf) => {
    // 获取模型
    const mesh = gltf.scene.children[0]
    // 放大模型以便观察
    const size = 100
    mesh.scale.set(size, size, size)
    // 放到场景中
    this.scene.add(mesh)
    resolve(mesh)
    }
    })

    }
    // 创建主体
    async createMainMesh() {
    // 加载主体模型
    const model = await this.loadOneModel('../static/gltf/taper2.glb')
    // 缓存模型
    this._models.main = model

    // 给模型换一种材质
    const material = new THREE.MeshStandardMaterial({
    color: 0x1171ee, //自身颜色
    transparent: true,
    opacity: 1, //透明度
    metalness: 0.0, //金属性
    roughness: 0.5, //粗糙度
    emissive: new THREE.Color('#1171ee'), //发光颜色
    emissiveIntensity: 0.2,
    // blending: THREE.AdditiveBlending
    })
    model.material = material
    }
    // 创建托盘
    async createTrayMesh() {
    // 加载底部托盘
    const model = await this.loadOneModel('../static/gltf/taper1-p.glb')
    // 缓存模型
    this._models.tray = model

    const loader = new THREE.TextureLoader()

    const texture = await loader.loadAsync('../static/image/texture/texture_wave_circle4.png')
    const { width, height } = texture.image
    this._frameX = width / height
    // xy方向纹理重复方式必须为平铺
    texture.wrapS = texture.wrapT = THREE.RepeatWrapping
    // 设置xy方向重复次数,x轴有frameX帧,仅取一帧
    texture.repeat.set(1 / this._frameX, 1)

    const material = new THREE.MeshStandardMaterial({
    color: 0x1171ee,
    map: texture,
    transparent: true,
    opacity: 0.8,
    metalness: 0.0,
    roughness: 0.6,
    depthTest: true,
    depthWrite: false
    })
    model.material = material
    }


  3. 这样一来单个模型实现动画的效果很简单,对于旋转的主体,我们只需要在逐帧函数中更新主体的z轴旋转角度;而波纹的效果使用时序图的方式实现,原理类似于css sprite不断变化纹理图片的x轴位移。感兴趣看一看之前的文章有详细阐述过


    update() {
    const {main, tray} = this._models
    // 更新托盘纹理
    const texture = tray?.material?.map
    if (texture) {
    this._offset += 0.6
    texture.offset.x = Math.floor(this._offset) / this._frameX
    }
    // 更新主体角度
    if(main){
    this._currentAngle += 0.005;
    main.rotateZ((this._currentAngle / 180) * Math.PI);
    }
    }


  4. 对动画的速度参数进行一些调试,并增加适当的灯光,我们就可以得到以下结果(工程目录/pages/poi3dLayer0.html)


    1.gif



解决大量模型的性能问题


上文的方案用来处理数据量较小的场景基本上是没有问题的,然而现实中往往有大量散点数据的情况需要处理,这时候需要THREE.InstancedMesh 出手了,InstanceMesh用于高效地渲染大量相同几何形状但具有不同位置、旋转或其他属性的物体实例,使用它可以显著提高渲染性能,尤其是在需要渲染大量相似物体的场中,比如一片森林中的树木、一群相似的物体等。



  1. 首先获取数据,我们以数量为20个的POI数据为例,使用高德API提供的customCoords.lngLatsToCoords方法现将数据的地理坐标转换为空间坐标


    // 处理转换图层基础数据的地理坐标为空间坐标
    initData(geoJSON) {
    const { features } = geoJSON
    this._data = JSON.parse(JSON.stringify(features))

    const coordsArr = this.customCoords.lngLatsToCoords(features.map(v => v.lngLat))
    this._data.forEach((item, index) => {
    item.coords = coordsArr[index]
    })
    }


  2. 我们对刚才的代码进行改造,模型加载之后不直接放置到场景scene而是存起来,加载完所有模型后为其逐个创建InstancedMesh。



    // 加载主体模型
    await this.loadMainMesh()
    // 加载底座模型
    await this.loadTrayMesh()
    // 实例化模型
    this.createInstancedMeshes()

    async loadMainMesh() {
    // 加载主体模型
    const model = await this.loadOneModel('../static/gltf/taper2.glb')
    // 缓存模型
    this._models.main = model
    //...
    }
    async loadTrayMesh() {
    // 加载底部托盘
    const model = await this.loadOneModel('../static/gltf/taper1-p.glb')
    // 缓存模型
    this._models.tray = model
    //...
    }

    createInstancedMeshes() {
    const { _models, _data, _materials, scene } = this
    const keys = Object.keys(_models)

    for (let i = 0; i < keys.length; i++) {
    // 创建实例化模型
    let key = keys[i]
    const mesh = new THREE.InstancedMesh(_models[key].geometry, _materials[key], _data.length)
    mesh.attrs = { modelId: key }
    this._instanceMap[key] = mesh

    // 实例化
    this.updateInstancedMesh(mesh)
    scene.add(mesh)
    }
    }


  3. 对每个InstancedMesh进行实例化,需要注意的一点是对instanceMesh进行变换操作时必须设置 instanceMatrix.needsUpdate=true,否则无效


    // 用于做定位和移动的介质
    _dummy = new THREE.Object3D()

    updateInstancedMesh(instancedMesh) {
    const { _data } = this

    for (let i = 0; i < _data.length; i++) {
    // 获得转换后的坐标
    const [x, y] = this._data[i].coords

    // 每个实例的尺寸
    const newSize = this._size
    this._dummy.scale.set(newSize, newSize, newSize)
    // 更新每个实例的位置
    this._dummy.position.set(x, y, i)
    this._dummy.updateMatrix()

    // 更新实例 变换矩阵
    instancedMesh.setMatrixAt(i, this._dummy.matrix)
    // 设置实例 颜色
    instancedMesh.setColorAt(i, new THREE.Color(0xfbdd4f))
    }
    // 强制更新实例
    instancedMesh.instanceMatrix.needsUpdate = true
    }


  4. 实现动画效果,托盘的波纹动画不需要调整代码,因为所有实例都是用的同一个Material,主体模块需要instancedMesh.setMatrixAt 更新每一个数据。


    _currentAngle = 0
    // 逐帧更新图层
    update() {
    const { main, tray } = this._instanceMap
    // 更新托盘纹理
    const texture = tray?.material?.map
    if (texture) {
    this._offset += 0.6
    texture.offset.x = Math.floor(this._offset) / this._frameX
    }

    // 更新主体旋转角度
    this._data.forEach((item, index) => {
    const [x, y] = item.coords
    this.updateMatrixAt(main, {
    size: this._size,
    position: [x, y, 0],
    rotation: [0, 0, this._currentAngle]
    }, index)
    })
    // 更新主体旋转角度
    this._currentAngle = (this._currentAngle + 0.05) % this._maxAngle

    // 强制更新instancedMesh实例,必须!
    if (main?.instanceMatrix) {
    main.instanceMatrix.needsUpdate = true
    }
    }

    /**
    * @description 更新指定网格体的单个示例的变化矩阵
    * @param {instancedMesh} Mesh 网格体
    * @param {Object} transform 变化设置,比如{size:1, position:[0,0,0], rotation:[0,0,0]}
    * @param {Number} index 网格体实例索引值
    */

    updateMatrixAt(mesh, transform, index) {
    if (!mesh) {
    return
    }
    const { size, position, rotation } = transform
    const { _dummy } = this
    // 更新尺寸
    _dummy.scale.set(size, size, size)
    // 更新dummy的位置和旋转角度
    _dummy.position.set(position[0], position[1], position[2])
    _dummy.rotation.x = rotation[0]
    _dummy.rotation.y = rotation[1]
    _dummy.rotation.z = rotation[2]
    _dummy.updateMatrix()
    mesh.setMatrixAt(index, _dummy.matrix)
    }


  5. 最终效果如下,POI数量再翻10倍也能够保持较为流畅的体验


    2.gif



实现数据特异性


从上一步骤updateInstancedMesh方法中,我们不难发现在对每个POI进行实例化的时候都会调用一次变化装置矩阵和设置颜色,因此我们可以通过对每个POI设定不同的尺寸、朝向等空间状态来实现数据的特异性。



  1. 改进实例化方法,根据每个数据的scale和index索引值设置专有的尺寸和颜色


    updateInstancedMesh(instancedMesh) {
    const { _data } = this

    for (let i = 0; i < _data.length; i++) {
    // 获得转换后的坐标
    const [x, y] = this._data[i].coords

    // 每个实例的尺寸
    const newSize = this._size * this._data[i].scale
    this._dummy.scale.set(newSize, newSize, newSize)
    // 更新每个实例的位置
    this._dummy.position.set(x, y, i)
    this._dummy.updateMatrix()

    // 更新实例 变换矩阵
    instancedMesh.setMatrixAt(i, this._dummy.matrix)
    console.log(this._dummy.matrix)
    // 设置实例 颜色
    instancedMesh.setColorAt(i, new THREE.Color(this.getColor(i)))
    }
    // // 强制更新实例
    instancedMesh.instanceMatrix.needsUpdate = true
    }

    // 获取实例颜色
    getColor(index, data){
    return index % 2 == 0 ? 0xfbdd4f : 0xff0000
    }



  2. 在逐帧函数中调整setMatrixAt,对于每个动画中的POI,更新变化矩阵时也要带上scale


    // 逐帧更新图层
    update() {
    // ...
    // 更新主体旋转角度
    this._data.forEach((item, index) => {
    const [x, y] = item.coords
    this.updateMatrixAt(main, {
    size: item.scale * this._size,
    //...
    }, index)
    })


  3. 最终效果如下(工程目录/pages/poi3dLayer1.html),对于使用instancedMesh实现的POI图层,POI的特异性也仅能做到这个程度;我们当然也可以实现主体模型上的特异性,在渲染图层前做一次枚举,为每一类主体模型创建一个instanceMesh即可,只不过instanceMesh的数量与数据量之间需要取得一个平衡,否则如果每个POI都是特定模型,使用instanceMesh就失去意义了。


    3.gif



实现鼠标交互


我们实现这样一种交互效果,所有POI主体静止不动,当鼠标悬浮在POI上,则POI开始转动画,且在POI上方出现广告牌显示它的名称属性。这里涉及到three.js中的射线碰撞检测和对外派发事件。主要的业务逻辑如下图:

image 1.png



  1. 对容器进行鼠标事件监听,每次mousemove时发射rayCast射线监控场景中物体碰撞并派发碰撞结果给onPick方法


    _pickEvent = 'mousemove'
    // ....
    if (this._pickEvent) {
    this.container.addEventListener(this._pickEvent, this.handleOnRay)
    }
    }
    // ....
    // onRay方法 防抖动
    this.handleOnRay = _.debounce(this.onRay, 100, true)
    /**
    * 在光标位置创建一个射线,捕获物体
    * @param event
    * @return {*}
    */

    onRay (event) {
    const { scene, camera } = this

    if (!scene) {
    return
    }

    const pickPosition = this.setPickPosition(event)

    this._raycaster.setFromCamera(pickPosition, camera)

    const intersects = this._raycaster.intersectObjects(scene.children, true)

    if (typeof this.onPicked === 'function' && this._interactAble) {
    this.onPicked.apply(this, [{ targets: intersects, event }])
    }
    return intersects
    }



  2. 在onPicked中处理碰撞结果,如果碰撞结果至少有1个,则将第一个结果作为当前鼠标拾取到的对象,为其赋值为拾取状态;如果碰撞结果为0个,则取消上一次拾取到的对象的拾取状态。


    _lastPickIndex = {index: null}

    /**
    * 处理拾取事件
    * @private
    * @param targets
    * @param event
    */

    onPicked({ targets, event }) {

    let attrs = null
    if (targets.length > 0) {
    const cMesh = targets[0].object
    if (cMesh?.isInstancedMesh) {
    const intersection = this._raycaster.intersectObject(cMesh, false)
    // 获取目标序号
    const { instanceId } = intersection[0]
    // 设置选中状态
    this.setLastPick(instanceId)
    attrs = this._data[instanceId]
    this.container.style.cursor = 'pointer'
    }
    } else {
    if (this._lastPickIndex.index !== null) {
    this.container.style.cursor = 'default'
    }
    this.removeLastPick()
    }
    // ...
    }
    /**
    * 设置最后一次拾取的目标
    * @param {Number} instanceId 目标序号
    * @private
    */

    setLastPick(index) {
    this._lastPickIndex.index = index
    }

    /**
    * 移除选中的模型状态
    */

    removeLastPick() {
    const { index } = this._lastPickIndex
    if (index !== null) {
    // 恢复实例化模型初始状态
    const mainMesh = this._instanceMap['main']

    const [x, y] = this._data[index].coords
    this.updateMatrixAt(mainMesh, {
    size: this._size,
    position: [x, y, 0],
    rotation: [0, 0, 0]
    }, index)
    }

    this._lastPickIndex.index = null
    }


  3. 修改逐帧函数,仅对当前拾取对象进行动画处理


    // 逐帧更新图层
    update() {

    const { main, tray, } = this._instanceMap
    const { _lastPickIndex, _size } = this
    // ...

    // 鼠标悬浮对象
    if (_lastPickIndex.index !== null) {
    const [x, y] = this._data[_lastPickIndex.index].coords
    this.updateMatrixAt(main, {
    size: _size * 1.2, // 选中的对象放大1.2倍
    position: [x, y, 0], // 保持原位置
    rotation: [0, 0, this._currentAngle] //调整旋转角度
    }, _lastPickIndex.index)
    }

    // 更新旋转角度值
    this._currentAngle = (this._currentAngle + 0.05) % this._maxAngle

    // 强制更新instancedMesh实例,必须!
    if (main?.instanceMatrix) {
    main.instanceMatrix.needsUpdate = true
    }
    }


  4. 不管有没有拾取到,都将事件派发出去,让上层逻辑处理“广告牌”的显示情况,将广告牌移到当前拾取对象上方并设置显示内容为拾取对象的name


    onPicked({ targets, event }) {
    //...
    // 派发pick事件
    this.handleEvent('pick', {
    screenX: event?.pixel?.x,
    screenY: event?.pixel?.y,
    attrs
    })
    }

    // 上层逻辑监听图层的pick事件
    layer.on('pick', (event) => {
    const { screenX, screenY, attrs } = event
    updateMarker(attrs)
    })

    let marker = new AMap.Marker({
    content: '<div class="tip"></div>',
    offset: [0, 0],
    anchor: 'bottom-center',
    map
    })

    // 更新广告牌
    function updateMarker(attrs) {
    if (attrs) {
    const { lngLat, id, modelId, name } = attrs
    marker.setPosition([...lngLat, 200])
    marker.setContent(`<div class="tip">${name || id}</div>`)
    marker.show()
    } else {
    marker.hide()
    }
    }


  5. 最终实现效果如下(工程目录/pages/poi3dLayer2.html)
    4.gif


实现PDI效果


PDI即像素密度无关模式,本意是使图形元素、界面布局和内容在各种不同像素密度的屏幕上都能保持相对一致的显示效果和视觉体验 ,在此我们借助这个概念作为配置参数,来实现POI不会随着地图远近缩放而更改尺寸的效果。
PDI_vs.gif


在这里我们会用到高德API提供的非常重要的方法Map.getResolution(),它用于获取指定位置的地图分辨率(单位:米/像素),即当前缩放尺度下,1个像素长度可以代表多少米长度,在每次地图缩放时POI示例必须根据这个系数进行缩放,才能保证在视觉上是没有变化尺寸的。


接下来进行代码实现,对上文的代码再次进行改造:



  1. 监听地图缩放事件


    initMouseEvent() {
    this.map.on("zoomchange", this.handelViewChange);
    }

    /**
    * 初始化尺寸字典
    * @private
    */

    handelViewChange() {
    if (this._conf.PDI) {
    this.refreshTransformData();
    this.updatePOIMesh();
    }
    }


  2. 重新计算当前每个模型的目标尺寸系数,实际情况下每个模型的尺寸可能是不同的,这里为了演示方便都设为1了;完了再执行updatePOIMesh重新设置每个POI的尺寸即可。


    _sizeMap = {}
    /**
    * @description 重新计算每个模型的目标尺寸系数
    * @private
    */

    refreshTransformData() {
    this._resolution = this.getResolution();
    this._sizeMap["main"] = this._resolution * 1;
    this._sizeMap["tray"] = this._resolution * 1;
    }
    /**
    * @description 更新所有POI实例尺寸
    */

    updatePOIMesh() {
    const { _sizeMap } = this;

    // 更新模型尺寸
    const mainMesh = this._instanceMap["main"];
    const trayMesh = this._instanceMap["tray"];

    // 重置纹理偏移
    if (this?._mtMap?.tray?.map) {
    this._mtMap.tray.map.offset.x = 0;
    }

    for (let i = 0; i < this._data.length; i++) {
    // 获取空间坐标
    const [x, y] = this._data[i].coords;
    // 变换主体
    this.updateMatrixAt(
    mainMesh,
    {
    size: _sizeMap.main ,
    position: [x, y, 0],
    rotation: [0, 0, 0],
    },
    i
    );
    // 变换托盘
    this.updateMatrixAt(
    trayMesh,
    {
    size: _sizeMap.tray ,
    position: [x, y, 0],
    rotation: [0, 0, 0],
    },
    i
    );
    }
    // 强制更新instancedMesh实例
    if (mainMesh?.instanceMatrix) {
    mainMesh.instanceMatrix.needsUpdate = true;
    }
    if (trayMesh?.instanceMatrix) {
    trayMesh.instanceMatrix.needsUpdate = true;
    }
    }


  3. 再逐帧函数中,由于当前选中对象的变化矩阵也随着动画在不断调整,因此也需要把PDI系数带进去计算(工程目录/pages/poi3dLayer3.html)


    // 逐帧更新图层
    update() {
    //...
    // 鼠标悬浮对象
    if (_lastPickIndex.index !== null) {
    const [x, y] = this._data[_lastPickIndex.index].coords;
    const newSize = this._conf.PDI ? this._sizeMap.main: this._size
    //...
    }
    //...
    }



代码封装


最后为了让我们的代码具有复用性,我们将它封装为POI3dLayer类,将模型、颜色、尺寸、PDI、是否可交互、是否可动画等作为配置参数,具体操作可以看POI3dLayer.js这个类的写法。


//创建一个立体POI图层
async function initLayer() {
const map = getMap()
const features = await getData()
const layer = new POI3dLayer({
map,
data: { features },
size: 20,
PDI: false
})

layer.on('pick', (event) => {
const { screenX, screenY, attrs } = event
updateMarker(attrs)
})
}

// POI类的构造函数

/**
* 创建一个实例
* @param {Object} config
* @param {GeoJSON|Array} config.data 图层数据
* @param {ColorStyle} [config.colorStyle] 顔色配置
* @param {LabelConfig} [config.label] 用于显示POI顶部文本
* @param {ModelConfig[]} [config.models] POI 模型的相关配置数组,前2个成员modelId必须为main和tray
* @param {Number} [config.maxMainAltitude=1.0] 动画状态下,相对于初始位置的向上最大值, 必须大于minMainAltitude
* @param {Number} [config.minMainAltitude=0] 动画状态下,相对于初始位置的向下最小距离, 可以为负数
* @param {Number} [config.mainAltitudeSpeed=1.0] 动画状态下,垂直移动速度系数
* @param {Number} [config.rotateSpeed=1.0] 动画状态下,旋转速度
* @param {Number} [config.traySpeed=1.0] 动画状态下,圆环波动速度
* @param {Array} [config.scale=1.0] POI尺寸系数, 会被models[i].size覆盖
* @param {Boolean} [config.PDI=false] 像素密度无关(Pixel Density Independent)模式,开启后POI尺寸不会随着缩放而变化
* @param {Number} [config.intensity=1.0] 图层的光照强度系数
* @param {Boolean} [config.interact=true] 是否可交互
*/

class POI3dLayer extends Layer {
constructor (config) {
super(conf)
//...
}
}

这样一来我们配置模型和颜色就很便捷了,试试其他业务场景效果貌似也还可以,今天就到这里吧。


poi3dLayer2.gif


相关链接


演示工程代码gitbub地址


高德JS API 2.0 Map文档


作者:Gyrate
来源:juejin.cn/post/7402068646166462502
收起阅读 »

vue实现移动端扫一扫功能(带样式)

web
前言:最近在做一个vue2的项目,其中有个需求是,通过扫一扫功能,扫二维码进入获取到对应的code,根据code值获取接口数据。在移动端开发中,扫一扫功能是一个非常实用的特性。它可以帮助用户快速获取信息、进行支付、添加好友等操作。而 Vue ...
继续阅读 »

前言:

最近在做一个vue2的项目,其中有个需求是,通过扫一扫功能,扫二维码进入获取到对应的code,根据code值获取接口数据。

在移动端开发中,扫一扫功能是一个非常实用的特性。它可以帮助用户快速获取信息、进行支付、添加好友等操作。而 Vue 作为一种流行的前端框架,为我们实现移动端扫一扫功能提供了强大的支持。

本文将详细介绍如何使用 Vue 实现移动端扫一扫功能,并为其添加个性化的样式。

一、需要实现的效果图

image.png

二、背景

我这边的需求是,需要在移动端使用扫一扫功能进行物品的盘点。由于有的地方环境比较暗,所以要兼具“可开关手机手电筒”的功能,即上图中的“轻触点亮”。

本文主要介绍:

  • 运用 vue-qrcode-reader 插件实现扫一扫功能;
  • 实现打开手电筒功能;
  • 按照上图中的设计稿实现样式,并且中间蓝色短线是上下扫描的视觉效果。

三、下载并安装插件

  1. 可参考vue-qrcode-reader官网
  2. 在项目install这个插件:
npm install --save vue-qecode-reader

或者

cnpm install --save vue-qrcode-reader
  1. 然后就可以在代码中引入了:
import { QrcodeStream } from 'vue-qrcode-reader';

components: {
QrcodeStream
},
  1. html中的结构可以这样写:

image.png

附上代码可直接复制:

<template>
<div class="saoma">
<qrcode-stream
:torch="torchActive"
@decode="onDecode"
@init="onInit"
style="height: 100vh; width:100vw">

<div>
<div class="qr-scanner">
<div class="box">
<div class="line">div>
<div class="angle">div>
<div @click="openTorch" class="openTorch">
<img src="@/assets/imgs/icon_torch.png" />
<div>轻触点亮div>
div>
div>
div>
div>
qrcode-stream>
div>
template>

API介绍可参考vue-qrcode-reader API介绍

  1. js中主要包含两个通用的事件和一个“轻触点亮”的事件:

image.png

image.png

注:

我这边的这个扫码页面,会根据情况分别跳转到两个页面,所以做了区分。

实现打开手电筒的功能时,要先自定义一个变量torchActive,将初始值设置为false,同时要注意在onDecode方法中,要重置为false

image.png

下面将js的全部代码附上:


  1. CSS可参考下面的代码,其中中间那条蓝色的短线是动态上线扫描的效果:

注:

  • 颜色可自定义(我这边的主色是蓝色,可根据自己项目调整);
  • 我的项目用的css语法是less,也可根据自己项目修改。

这就是实现这个页面功能的全部代码了~

四、总结

读者可以通过本文介绍,根据自己的需求进行定制和扩展。无论是为了提高用户体验还是满足特定的业务需求,这个功能都能为你的移动端应用增添不少价值。

以上,希望对大家有帮助!


作者:小蹦跶儿
来源:juejin.cn/post/7436275126742712372
收起阅读 »

10 个超赞的开发者工具,助你轻松提升效率

web
嗨,如果你像我一样,总是热衷于寻找新的方法让开发工作更轻松,那么你一定不能错过这篇文章!我精心挑选了 10 个 超级酷炫 的工具,可以让你效率倍增。无论是 API 管理、数据库操作还是调试最新项目,这里总有一款适合你。 而且,我还分享了一些你可能从未听过的全新...
继续阅读 »

嗨,如果你像我一样,总是热衷于寻找新的方法让开发工作更轻松,那么你一定不能错过这篇文章!我精心挑选了 10 个 超级酷炫 的工具,可以让你效率倍增。无论是 API 管理、数据库操作还是调试最新项目,这里总有一款适合你。 而且,我还分享了一些你可能从未听过的全新工具。 让我们马上开始吧!


1. Hoppscotch — API 测试变得更简单 🐦



如果你曾经需要测试 API(谁没做过呢?),那么 Hoppscotch 就是你的新伙伴。它就像 Postman,但速度更快且开源。你可以用它测试 REST、GraphQL 甚至 WebSockets。它轻量级、易于使用,不会像一些臃肿的替代方案那样拖慢你的速度。


它为何如此酷炫: 它速度极快,非常适合测试 API,无需额外的功能。如果你追求速度,这就是你的不二之选。



🌍 网站: hoppscotch.io



2. Zed — 专业级代码协作 👩‍💻👨‍💻


image.png
让协作变得简单!Zed 是一款超级炫酷的代码编辑器,专为实时协作而设计。 如果你喜欢结对编程,或者仅仅需要与你的编码伙伴远程合作,这款工具会让你感觉就像并肩作战一样。 此外,它还拥有无干扰界面,让你专注于代码。


你为何会爱上它: 想象一下,你和你的团队就像坐在同一个房间里一样进行编码,即使你们相隔千里。 非常适合远程团队!



🌍 网站: zed.dev



3. Mintlify — 自动化文档,省时省力 📚



让我们面对现实:编写文档可不是什么让人兴奋的事情。 这就是 Mintlify 的用武之地。 它使用人工智能自动生成代码库文档,这意味着你可以专注于有趣的事情——编码! 此外,它会随着代码的更改而更新,因此你无需担心文档过时。


它为何是救星: 无需再手动编写文档! 该工具可以节省你的时间和精力,同时让你的项目文档井井有条。



🌍 网站: mintlify.com



4. Infisical — 安全保管秘密 🔐



管理敏感的环境变量可能很棘手,尤其是在不同团队之间。 Infisical 使这变得轻而易举,它可以安全地存储和管理秘密,例如 API 密钥和密码。 它开源且以安全性为中心构建,这意味着你的所有秘密都将安全且加密。


它为何如此方便: 安全,安全,安全。 Infisical 负责所有秘密管理,让你专注于构建酷炫的东西。



🌍 网站: infisical.com



5. Caddy — 带有自动 HTTPS 的 Web 服务器 🌐


如果你曾经不得不处理 Web 服务器配置,你就会知道这可能是一场噩梦。 Caddy 是一款现代 Web 服务器,它负责处理设置 HTTPS 自动化等繁琐工作。 它简单、快速且安全——相信我,使用这款工具设置 SSL 证书非常容易。


它为何如此赞: 无需再与服务器配置或安全设置作斗争。 Caddy 仅需点击几下即可为你处理所有事宜。



🌍 网站: caddyserver.com



6. TablePlus — 专业级数据库管理 🗄️



处理数据库? TablePlus 是一款时尚、超级易于使用的数据库管理工具,支持所有主要数据库,例如 MySQL、PostgreSQL 等。 它拥有简洁的界面,管理数据库查询从未如此简单。 此外,它速度很快,因此你可以快速完成任务,无需等待。


它为何如此酷炫: 支持多种数据库类型,并拥有出色的 UI,TablePlus 让数据库管理变得轻而易举。



🌍 网站: tableplus.com



7. JSON Crack — 以全新视角可视化 JSON 数据 🧩



JSON 很快就会变得混乱不堪。 这就是 JSON Crack 的用武之地。 它允许你将 JSON 数据可视化为交互式图表,使其更易于理解、调试,甚至与团队分享。 告别在嵌套数据中无限滚动。


它为何如此酷炫: 就像 JSON 数据的 X 光透视! 你只需一瞥就能看到复杂的数据结构。



🌍 网站: jsoncrack.com



8. Signoz — DevOps 的开源监控工具 💻


如果你处理的是后端应用程序或从事 DevOps 工作,那么 Signoz 是必不可少的工具。 它提供应用程序的全面可观察性,包括日志、指标和分布式跟踪——所有这些都在一个地方。 此外,它是开源的,如果你喜欢自行托管,这非常棒。


它为何如此重要: 就像监控和调试应用程序的瑞士军刀。 你可以在问题变得严重之前捕捉到错误和性能问题。



🌍 网站: signoz.io



9. Warp — 更智能的终端 🖥️



终端多年来几乎没有变化,但 Warp 正在改变这一现状。 它是一款现代终端,具有富文本、命令共享和协作功能。 你甚至可以实时查看你的团队在输入什么内容。 此外,它快速直观——非常适合所有终端高级用户。


你为何会爱上它: 如果你常在终端工作,Warp 会让你的生活更轻松。 协作功能也是一个不错的加分项!



🌍 网站: warp.dev



10. Gleek.io — 文本绘图工具 ✏️➡️📊


需要快速绘制图表,但又不想使用拖放工具? Gleek.io 允许你仅通过输入文本创建图表。 它非常适合那些习惯写作而不是绘画的开发者,并且支持 UML、流程图和实体关系图等。


它为何如此赞: 就像魔法一样。 输入几行文本,然后——你就会得到一张图表。 它超级快,非常适合规划下一个大项目。



🌍 网站: gleek.io



总结


以上就是我推荐的 10 个工具,我相信它们会让你的开发者生活 无比 轻松。 无论你是独自工作还是与团队合作,这些工具旨在节省你的时间、提高你的效率,而且说实话,它们能让你编码更加愉快。 赶快试试吧,告诉我你最喜欢哪些工具!


作者:前端宝哥
来源:juejin.cn/post/7434471758819901452
收起阅读 »

仿今日头条,H5 循环播放的通知栏如何实现?

web
我们在各大 App 活动页面,经常会看到循环播放的通知栏。比如春节期间,我就在今日头条 App 中看到了如下通知:「春节期间,部分商品受物流影响延迟发货,请耐心等待,祝你新春快乐!」。 那么,这种循环播放的通知栏如何实现呢?本文我会先介绍它的布局、再介绍它的...
继续阅读 »

我们在各大 App 活动页面,经常会看到循环播放的通知栏。比如春节期间,我就在今日头条 App 中看到了如下通知:「春节期间,部分商品受物流影响延迟发货,请耐心等待,祝你新春快乐!」。


toutiao.gif


那么,这种循环播放的通知栏如何实现呢?本文我会先介绍它的布局、再介绍它的逻辑,并给出完整的代码。最终我实现的效果如下:


loop-notice.gif


拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。


布局代码


我们先看布局,如下图所示,循环播放的布局不是普通的左中右布局。可以看到,当文字向左移动时,左边的通知 Icon 和右边的留白会把文字挡住一部分。


block-out.png


为了实现这样的效果,我们给容器 box 设置一个相对定位,并把 box 中的 HTML 代码分为三部分:



  • 第一部分是 content,它包裹着需要循环播放的文字;

  • 第二部分是 left,它是左边的通知 Icon,我们给它设置绝对定位和 left: 0;

  • 第三部分是 right,它是右边的留白,我们给它设置绝对定位和 right: 0;


<div class="box">
<div class="content">
<!-- ... 省略 -->
</div>
<div class="left">🔔</div>
<div class="right"></div>
</div>

.box {
position: relative;
overflow: hidden;
/* ... 省略 */
}
.left {
position: absolute;
left: 0;
/* ... 省略 */
}
.right {
position: absolute;
right: 0;
/* ... 省略 */
}

现在我们来看包裹文字的 content。content 内部包裹了三段一模一样的文字 notice,每段 notice 之间还有一个 space 元素作为间距。


<!-- ... 省略 -->
<div id="content">
<div class="notice">春节期间,部分商品...</div>
<div class="space"></div>
<div class="notice">春节期间,部分商品...</div>
<div class="space"></div>
<div class="notice">春节期间,部分商品...</div>
</div>
<!-- ... 省略 -->

为什么要放置三段一模一样的文字呢?这和循环播放的逻辑有关。


逻辑代码


我们并没有实现真正的循环播放,而是欺骗了用户的视觉。如下图所示:



  • 播放通知时,content 从 0 开始向左移动。

  • 向左移动 2 * noticeWidth + spaceWidth 时,继续向左移动便会露馅。因为第 3 段文字后不会有第 4 段文字。


    如果我们把 content 向左移动的距离强行从 2 * noticeWidth + spaceWidth 改为 noticeWidth,不难看出,用户在 box 可视区域内看到的情况基本一致的。


    然后 content 继续向左移动,向左移动的距离大于等于 2 * noticeWidth + spaceWidth 时,就把距离重新设为 noticeWidth。循环往复,就能欺骗用户视觉,让用户认为 content 能无休无止向左移动。



no-overflow-with-comment.png


欺骗视觉的代码如下:



  • 我们通过修改 translateX,让 content 不断地向左移动,每次向左移动 1.5px;

  • translateX >= noticeWidth * 2 + spaceWidth 时,我们又会把 translateX 强制设为 noticeWidth

  • 为了保证移动动画更丝滑,我们并没有采用 setInterval,而是使用 requestAnimationFrame。


const content = document.getElementById("content");
const notice = document.getElementsByClassName("notice");
const space = document.getElementsByClassName("space");
const noticeWidth = notice[0].offsetWidth;
const spaceWidth = space[0].offsetWidth;

let translateX = 0;
function move() {
translateX += 1.5;
if (translateX >= noticeWidth * 2 + spaceWidth) {
translateX = noticeWidth;
}
content.style.transform = `translateX(${-translateX}px)`;
requestAnimationFrame(move);
}

move();

完整代码


完整代码如下,你可以在 codepen 或者码上掘金上查看。



总结


本文我介绍了如何用 H5 实现循环播放的通知栏:



  • 布局方面,我们需要用绝对定位的通知 Icon、留白挡住循环文字的左侧和右侧;此外,循环播放的文字我们额外复制 2 份。

  • 逻辑方面,通知栏向左移动 2 * noticeWidth + spaceWidth 后,我们需要强制把通知栏向左移动的距离从 2 * noticeWidth + spaceWidth 变为 noticeWidth,以此来欺骗用户视觉。


拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。


作者:小霖家的混江龙
来源:juejin.cn/post/7372765277460496394
收起阅读 »

JavaScript 中的 ‘return’ 是什么意思?

web
Medium 原文 最近朋友问了我一个问题:“JavaScript 中的 return 是什么意思?” function contains(px, py, x, y) { const d = dist(px, py, x, y); if (d >...
继续阅读 »

Medium 原文



最近朋友问了我一个问题:“JavaScript 中的 return 是什么意思?”


function contains(px, py, x, y) {
const d = dist(px, py, x, y);
if (d > 20) return true; // 这行是什么意思?
else return false; // 那这一行呢?
}

一开始我觉得这个问题很简单,但它背后其实蕴藏了一些重要且有趣的概念!



因为我朋友是艺术背景,所以本篇文章的结论是一些很基础的东西,大家感兴趣可以继续看下去。



两种函数


我先解释了有 return 和没有 return 的函数的区别。函数是一组指令,如果你需要这组指令的执行结果,就需要一个 return 语句,否则不需要。


例如,要获得两个数的和,你应该声明一个带有 return 语句的 add 函数:


function add(x, y) {
return x + y; // 带有 return 语句
}

然后你可以这样使用 add 函数:


const a = 1;
const b = 2;
const c = add(a, b); // 3
const d = add(b, c); // 5

如果你只是想在控制台打印一条消息,则不需要在函数中使用 return 语句:


function great(name) {
console.log(`Hello ${name}!`);
}

你可以这样使用 great 函数:


great('Rachel');

我原以为我已经解答了朋友的问题,但她又提出了一个新问题:“为什么我们需要这个求和函数?我们可以在任何地方写 a + b,那为什么还要用 return 语句?”


const a = 1;
const b = 2;
const c = a + b; // 3
const d = b + c; // 5

此时,我意识到她的真正问题是:“我们为什么需要函数?”


为什么需要函数?


为什么要使用函数?尽管有经验的程序员有无数的理由,这里我只关注一些与我朋友问题相关的原因


可重用的代码


她的确有道理。我们可以轻松地在任何地方写 a + b。然而,这仅仅因为加法是一个简单的操作。如果你想执行一个更复杂的计算呢?


const a = 1;
const b = 2;

// 这是否易于在每个地方写?
const c = 0.6 + 0.2 * Math.cos(a * 6.0 + Math.cos(d * 8.0 + b));

如果你需要多个语句来获得结果呢?


const a = 1;
const b = 2;

// t 是一个临时变量
const t = 0.6 + 0.2 * Math.cos(a * 6.0 + Math.cos(d * 8.0 + b));
const c = t ** 2;

在这两种情况下,重复编写这些代码会很麻烦。对于这种可重用的代码,你可以将其封装在一个函数中,这样每次需要它时就不必重新实现了!


function theta(a, b) {
return 0.6 + 0.2 * Math.cos(a * 6.0 + Math.cos(d * 8.0 + b));
}

const a = 1;
const b = 2;
const c = theta(a, b);
const d = theta(b, c);

易于维护


在讨论可重用性时,你无法忽视可维护性。唯一不变的是世界总是在变化,这对于代码也一样!你的代码越容易修改,它就越具可维护性。


如果你想在计算结果时将 0.6 改为 0.8,没有函数的情况下,你必须在每个执行计算的地方进行更改。但如果有一个函数,你只需更改一个地方:函数内部!


function theta(a, b) {
// 将 0.6 更改为 0.8,你就完成了!
return 0.8 + 0.2 * Math.cos(a * 6.0 + Math.cos(d * 8.0 + b));
}

毫无疑问,函数增强了代码的可维护性。就在我以为我解答了她的问题时,她又提出了另一个问题:“我理解了函数的必要性,但为什么我们需要写 return?”


为什么需要 return


真有意思!我之前没有考虑过这个问题!她随后提出了一些关于 return 的替代方案,这些想法非常有创意!


为什么不直接返回最后一条语句?


第一个建议的方案是“为什么不直接返回最后一条语句?”


function add(a, b) {
a + b
}

const sum = add(1, 2); // undefined

我们知道,在 JavaScript、Java、C 或许多其他语言中,这样是不允许的。这些语言的规范要求显式的 return 语句。然而,在某些语言中,例如 Rust,这是允许的:


fn add(a: i32, b: i32) -> i32 {
a + b
}

let sum = add(1, 2); // 3

然而值得注意的是,JavaScript 中的另一种函数类型不需要 return 语句!那就是带有单个表达式的箭头函数


const add = (x, y) => x + y;
const sum = add(1, 2); // 3

如果我们将结果赋值给局部变量呢?


然后她提出了另一个有创意的解决方案:“如果我们将结果赋值给一个局部变量呢?”


function add(x, y) {
let sum = x + y;
}

add(1, 2);
sum; // Uncaught ReferenceError: sum is not defined

她很快注意到我们无法访问 sum 变量。这是因为使用 let 关键字声明的变量只在其定义的作用域内可见——在这个例子中是函数作用域。


可以将函数视为黑盒子。你将参数放入盒子中,期待获得一个输出(返回值)。只有返回值对外部世界(父作用域)是可见的(或可访问的)。


将结果赋值给全局变量呢?


如果我们在函数作用域之外访问这个值呢?将其赋值给一个全局变量怎么样?


let sum;

function add(x, y) {
sum = x + y;
}

add(1, 2);
sum; // 3

啊,修改全局变量!副作用!非纯函数!这些想法在我脑海中浮现。但我如何在一分钟内解释为什么这是一个糟糕的选择呢?


避免这种方法的一个关键原因是,别人很难知道具体的全局变量是在哪个函数中被修改的。他们需要去查找结果在哪儿,而不是直接从函数中获取!


总结


简而言之,我们需要 return,因为我们需要函数,而在 JavaScript 中的标准函数中没有可行的替代方案。


函数的存在是为了使代码具有可重用性和可维护性。由于 JavaScript 的规范、函数作用域的限制以及修改全局变量带来的风险,我们在 JavaScript 的标准函数中必须使用 return 语句。


这次讨论非常有趣!我从未想过看似简单的问题背后竟然蕴含着如此多的有趣的思考。与不同视角的人交流总能带来新的见解!


作者:小小酥梨
来源:juejin.cn/post/7434460436307591177
收起阅读 »

关于前端压缩字体的方法

web
我在编写一个撰写日常的网站,需要用到自定义字体,在网上找到一ttf的字体,发现体积很大,需要进行压缩 如何压缩 目前我们的字体是.ttf字体,其实我们需要把字体转换成.woff字体 WOFF本质上是包含了基于SFNT的字体(如TrueType、OpenTy...
继续阅读 »

我在编写一个撰写日常的网站,需要用到自定义字体,在网上找到一ttf的字体,发现体积很大,需要进行压缩



如何压缩


目前我们的字体是.ttf字体,其实我们需要把字体转换成.woff字体



WOFF本质上是包含了基于SFNT的字体(如TrueTypeOpenType或其他开放字体格式),且这些字体均经过WOFF的编码工具压缩,以便嵌入网页中。[3]WOFF 1.0使用zlib压缩,[3]文件大小一般比TTF小40%。[11]而WOFF 2.0使用Brotli压缩,文件大小比上一版小30%



CloudConvert在线字体转换


image.png


可以看下实际效果


image.png


20M 转换为 9M 大小,效果还是很明显


image.png


transfonter


这个网站transfonter.org/只接受转换15M以下的字体


image.png


工具压缩


先下载这个工具字体压缩工具下载,这个工具是从Google的代码编译而来,是用Cygwin编译的,Windows下可以使用


解压出来后大概包含以下几个文件


image.png


下载后打开,其中包括woff2_compress.exewoff2_decompress.exe,使用方法很简单使用命令行:


woff2_compress myfont.ttf
woff2_decompress myfont.woff2

实测效果还不错


image.png


作者:vipbic
来源:juejin.cn/post/7436015589527273522
收起阅读 »

项目开发时越来越卡?多半是桶文件用多了!

web
前言无论是开发性能优化还是生产性能优化如果你想找资料那真是一抓一大把,而且方案万变不离其宗并已趋于成熟,但是有一个点很多人没有关注到,在铺天盖地的性能优化文章中几乎很少出现它的影子,它就是桶文件(barrel files),今天我们就来聊一聊。虽然大家都没怎么...
继续阅读 »

前言

无论是开发性能优化还是生产性能优化如果你想找资料那真是一抓一大把,而且方案万变不离其宗并已趋于成熟,但是有一个点很多人没有关注到,在铺天盖地的性能优化文章中几乎很少出现它的影子,它就是桶文件(barrel files),今天我们就来聊一聊。

虽然大家都没怎么提及过,但是你肯定都或多或少地在项目中使用过,而且还对你的项目产生不小的影响!

那么什么是桶文件?

桶文件 barrel files

桶文件是一种将多个模块的导出汇总到一个模块中的方式。具体来说,桶文件本身是一个模块文件,它重新导出其他模块的选定导出。

原始文件结构

// demo/foo.ts
export class Foo {}

// demo/bar.ts
export class Bar {}

// demo/baz.ts
export class Baz {}

不使用桶文件时的导入方式:

import { Foo } from '../demo/foo';
import { Bar } from '../demo/bar';
import { Baz } from '../demo/baz';

使用桶文件导出(通常是 index.ts)后:

// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';

使用桶文件时的导入方式:

import { Foo, Bar, Baz } from '../demo';

是不是很熟悉,应该有很多人经常这么写吧,尤其是封装工具时 utils/index

还有这种形式的桶文件:

// components/index.ts
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Select } from './Select';
export {foo} from './foo';
export {bar} from './bar';
export {baz} from './baz';

这都是大家平常很常用到的形式,那么用桶文件到底怎么了?

桶文件的优缺点

先来说结论:

优点:

  • 集中管理,简化代码
  • 统一命名,利于多人合作

缺点:

  1. 增加编译、打包时间
  2. 增加包体积
  3. 不必要的性能和内存消耗
  4. 降低代码可读性

嗯,有没有激起你的好奇心?我们一个一个来解释。

增加编译、打包时间

桶文件对打包工具的影响

我们都知道 tree-shaking ,他可以在打包时分析出哪些模块和代码没有用到,从而在打包时将这些没有用到的部分移除,从而减少包体积。

以 rollup 为例,tree-shaking 的实现原理(其他大同小异)是:

1.静态分析

  • Tree-shaking 基于 ES Module 的静态模块结构进行分析
  • 通过分析 import/export 语句,构建模块依赖图
  • 标记哪些代码被使用,哪些未被使用
  • 使用 /#PURE/ 和 /@NO_SIDE_EFFECTS/ 注释来标记未使用代码
  1. 死代码消除
  • 移除未使用的导出
  • 移除未使用的纯函数
  • 保留有副作用的代码

tree-shaking 实现流程

  1. 模块分析阶段
// 源代码
import { a, b } from './module'
console.log(a)

// 分析:b 未被使用
  1. 构建追踪
// 构建依赖图
module -> a -> used
module -> b -> unused
  1. 代码生成
// 最终只保留使用的代码
import { a } from './module'
console.log(a)

更多细节可以看我的另一篇文章关于tree-shaking,这不是这篇文章的重点 。

接着说回来,为什么桶文件会增加编译、打包时间?

如果你使用支持 tree-shaking 的打包工具,那么在打包时打包工具需要分析每个模块是否被使用,而桶文件作为入口整合了模块并重新导出,所以会增加分析的复杂度,你重导出的模块越多,它分析的时间就越长。

那有聪明的小伙伴就会问,既然 tree-shaking 分析、标记、删除无用代码会降低打包效率,那我关闭 tree-shaking 功能怎么样?

我只能说,不怎么样,有些情况你关闭 tree-shaking 后,打包时间反而更长。为啥?

关闭 Tree Shaking 意味着 Rollup 会直接将所有模块完整打包,即使某些模块中的代码未被使用。结果是:

  • 打包体积增大:更多的代码需要进行语法转换、压缩等步骤。
  • I/O 操作增加:较大的输出文件需要更多时间写入磁盘。
  • 模块合并工作量增加:Rollup 在关闭 Tree Shaking 时仍会尝试将模块合并到一个文件中(尤其是 output.format 为 iife 或 esm 时)。

所以,虽然 Tree Shaking 的静态分析阶段可能较慢,但其最终生成的 bundle 通常更小、更优化,反而会减少后续步骤(如 压缩 和 代码生成)的负担。

又跑题了,我其实想说的是,问题不在于是否开启 tree-shaking,而在于你使用了桶文件,导致打包工具苦不堪言。

这个很好理解,你就想下面的桶文件重导出了100个模块,相当于这个文件里包含了100个模块的代码,解析器肯定一视同仁每一行代码都得照顾到,但其实你就用了其中一个方法 import { Foo } from '../demo';,想想都累...

// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';
...

下面这两种形式,比上面的稍微强点

// components/index.ts
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Select } from './Select';

假设 ./Button 文件导出多个具名导出和一个默认导出,那么这段代码意味着只使用其中的默认导出,而 export * 则是照单全收。

export {foo} from './foo';
export {bar} from './bar';
export {baz} from './baz';

同理,假设 ./foo 中有100个具名导出,这行代码就只使用了其中的 foo

即使这比export * 强,但是当重导出的模块较多较复杂时对打包工具依然负担不小。

好难啊。。。,那到底要怎么样打包工具才舒服?

最佳建议

  1. 包或者模块支持通过具体路径引入即所谓的“按需导入” 如:
import Button from 'antd/es/button';
import Divider from 'antd/es/divider';

不知道有没有人用过 babel-plugin-import,它的工作原理大概就是

import { Button, Divider } from 'antd';

帮你转换为

import Button from 'antd/es/button';
import Divider from 'antd/es/divider';
  1. 减少或避免使用桶文件,将模块按功能细粒度分组,且要控制单个文件的导出数量

例如:

import {formatTime} from 'utils/timeUtils';
import {formatNumber} from 'utils/numberUtils';
import {formatMoney} from 'utils/moneyUtils';
...

而不是使用桶文件统一导出

import { formatTime, formatNumber, formatMoney } from 'utils/index';

其实这和生产环境的代码拆分一个意思,你把一个项目的所有代码都放在一个文件里搞个几M,浏览器下载和解析肯定是慢的

另外,不止打包阶段,本地开发也是一样的,无论是 vite 还是 webpack ,桶文件都会影响解析编译速度,你的桶文件搞得很多很复杂页面初始加载时间就会很长。

这一点 vite 的官方文档中也有说明。

image.png

增加包体积

有的小伙伴可能想,桶文件只影响开发和打包时的体验?那没事,我不差那点时间。

肯定没那么简单,桶文件也会影响打包后产物的体积,这就切实影响到用户侧的体验了。

如果你在打包时没有刻意关注 treeshaking 的效果,或者压根就没开启,那么你无形之中就打包了很多无用代码进最终产物里去了,这就是桶文件带来的坑。

如果你有计划的想要优化打包体积,那么桶文件会额外给你带来很多心智负担,你要一边看着打包产物一边调试打包工具的各种配置,以确保打包结果符合你的预期。

// components/utils/index.ts (桶文件)
export * from './chart'; // 依赖 echarts
export * from './format'; // 纯工具函数
export * from './i18n'; // 依赖 i18next
export * from './storage'; // 浏览器 API

// 使用桶文件
import { formatDate } from 'components/utils';
// 可能导致加载所有依赖

上面的代码,即使开启了 tree-shaking ,打包工具也无能为力。

好在较新版本的 Rollup 已针对export * from 进行了优化,只要最终代码路径中没有实际使用的导出项,它仍会尝试移除这些未使用的代码。但在以下场景下仍可能有问题:

  • 模块间有副作用:如果重新导出的模块执行了副作用代码(如修改全局变量),Rollup 会保留这些模块。
  • 与 CommonJS 混用:如果被导入模块是 CommonJS 格式,Tree Shaking 可能会受到限制。

想了解完整的影响 treeshaking 的场景点这里传送 Rollup 的 Tree Shaking

不仅 vite,rollup官网也说明了使用桶文件导入的弊端。

image.png

总之就是,使用桶文件如果不开 treeshaking,那么打包产物体积大,开了treeshaking也没办法做到完美(目前),你还得多花很多心思去分析优化,就没必要嘛。

不必要的性能和内存消耗

// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';
...

这点就很好理解了,即使你只 import {foo} from 'demo/index'使用了一个模块,其他模块也是被初始化了的,这些初始化是没有任何意义的,但是却可能拖累你的初始加载速度、增加内存占用

同理,他也会影响你的IDE的性能,例如代码检查、补全等,或者测试框架 jest 等。

降低代码可读性

这一点见仁见智,我个人觉得桶文件增加了追踪实现的复杂性,当然大部分情况我们使用IDE是可以直接跳转到对应文件或者搜索的,不然用桶文件真的很抓狂。

// 使用桶文件
import { something } from '@/utils';
// 难以知道 something 的具体来源

// 直接导入更清晰
import { something } from '@/utils/atool';

总结

看到这里快去你的项目里检查一下,你可能做一个很小的改动就能让旁边小伙伴刮目相看:你做了what?这个项目怎么突然快了这么多?

桶文件实际上产生的影响并不小,只有少量桶文件在您的代码中通常是没问题的,但当每个文件夹都有一个时,问题就大了。

如果的项目是一个广泛使用桶文件的项目,现在可以应用一项免费的优化,使许多任务的速度提高 60-80%,让你的IDE和构建工具减减负:

删除所有桶文件!


作者:CoderLiu
来源:juejin.cn/post/7435492245912551436

收起阅读 »

微信的消息订阅,就是小程序有通知,可以直接发到你的微信上

web
给客户做了一个信息发布的小程序,今天客户提要求说希望用户发布信息了以后,他能收到信息,然后即时给用户审核,并且要免费,我就想到了微信的订阅消息。之前做过一次,但是忘了,这次记录一下,还是有一些坑的。 一 先申请消息模版 先去微信公众平台,申请消息模版 在un...
继续阅读 »

给客户做了一个信息发布的小程序,今天客户提要求说希望用户发布信息了以后,他能收到信息,然后即时给用户审核,并且要免费,我就想到了微信的订阅消息。之前做过一次,但是忘了,这次记录一下,还是有一些坑的。


一 先申请消息模版


先去微信公众平台,申请消息模版



在uni-app 里面下载这个插件uni-subscribemsg


我的原则就是有插件用插件,别自己造轮子。而且这个插件文档很好


根据文档定义一个message.js 云函数


这个其实文档里面都有现成的代码,但我还是贴一下自己的吧。


'use strict';

const uidObj = require('uni-id');
const {
Controller
} = require('uni-cloud-router');
// 引入uni-subscribemsg公共模块
const UniSubscribemsg = require('uni-subscribemsg');
// 初始化实例
let uniSubscribemsg = new UniSubscribemsg({
dcloudAppid: "填你的应用id",
provider: "weixin-mp",
});

module.exports = class messagesController extends Controller {

// 发送消息
async send() {

let response = { code: 1, msg: '发送消息失败', datas: {} };
const {
openid,
data,
} = this.ctx.data;
// 发送订阅消息
let resdata = await uniSubscribemsg.sendSubscribeMessage({
touser: openid,// 就是用户的微信id,决定发给他
template_id: "填你刚刚申请的消息模版id",
page: "pages/tabbar/home", // 小程序页面地址
miniprogram_state: "developer", // 跳转小程序类型:developer为开发版;trial为体验版;formal为正式版;默认为正式版
lang: "zh_CN",
data: {
thing1: {
value: "信息审核通知"// 消息标题
},
thing2: {
value: '你有新的内容需要审核' // 消息内容
},
number3: {
value: 1 // 未读数量
},
thing4: {
value: '管理员' // 发送人
},
time7: {
value: data.time // 发送时间
}
}
});
response.code = 0;
response.msg = '发送消息成功';
response.datas = resdata;

return response;
}
}

四 让用户主动订阅消息


微信为了防止打扰用户,需要用户订阅消息,并且每次订阅只能发送一次,不过我取巧,在用户操作按钮上偷偷加订阅方法,让用户一直订阅,我就可以一直发


// 订阅
dingYue() {
uni.requestSubscribeMessage({
tmplIds: ["消息模版id"], // 改成你的小程序订阅消息模板id
success: (res) => {
if (res['消息模版id'] == 'accept') {

}

}
});
},

五 讲一下坑


我安装了那个uni-app 的消息插件,但是一直报错找不到那个模块。原来是unicloud 云函数要主动关联公共模块,什么意思呢,直接上图。



又是一个人的前行


如果你喜欢我的文章,可以关注我的公众号,九月有风,上面更新更及时


作者:图颜有信
来源:juejin.cn/post/7430353222685048859
收起阅读 »

anime,超强JS动画库和它的盈利模式

大家好,我是农村程序员,独立开发者,前端之虎陈随易。 前面分享了开源编辑器 wangEditor 维护九年终停更,隐藏大佬接大旗的故事。 本文呢,分享开源项目进行商业化盈利的故事。 这个项目叫做 anime,是一个 JavaScript 动画库,目前有 5...
继续阅读 »

大家好,我是农村程序员,独立开发者,前端之虎陈随易。


个人网站



前面分享了开源编辑器 wangEditor 维护九年终停更,隐藏大佬接大旗的故事。


本文呢,分享开源项目进行商业化盈利的故事。


这个项目叫做 anime,是一个 JavaScript 动画库,目前有 50k star


我们先来看看它的效果。


anime效果1


anime效果2


anime效果3


anime效果4


怎么样?是不是大呼过瘾。


而这,只是 anime 的冰山一角,更多案例,可以访问下方官网查看。


官网:https://animejs.com


github:https://github.com/juliangarnier/anime


anime仓库


anime 的第一次提交时间是 2016年6月27日,到如今 8年 来,一共提交了 752次,平均每年提交 100次,核心代码 1300行 左右。


从数据上来看,并不亮眼,但是从功能上来说,确是极其优秀。


目前,anime v4 版本已经可以使用了。


v4 版本的功能特点如下:



  • 新的 ES 模块优先 API。

  • 主要性能提升和减少内存占用。

  • 内置类型定义!

  • 用于检查和加速动画工作流程的 GUI 界面。

  • 带有标签的改进时间轴、更多时间位置语法选项、对子项的循环/方向支持等等!。

  • 用于创建附加动画的新附加合成模式。

  • 新的可配置内置功能:‘linear(x,x,x)’、‘in(x)’、‘out(x)’、‘inOut(x)’、‘outIn(x)’。

  • 更好的 SVG 工具,包括改进的形状变形、线条绘制和运动路径实用程序。

  • 支持 CSS 变量动画。

  • 能够从特定值进行动画处理。

  • 可链接的实用程序函数可简化动画工作流程中的常见任务。

  • 新的 Timer 实用程序类,可用作 setInterval 和 setTimeout 的替代方案。

  • 超过 300 个测试,使开发过程更轻松且无错误。

  • 全新的文档,具有新设计和更深入的解释。

  • 新的演示和示例。


可以看到,新版进行了大量的优化和升级。


但是呢,目前只提供给赞助的用户使用。


赞助


最低档赞助是 10美元/月,目标是 120个 赞助,目前已经积累了 117个 赞助。


也就是说,每个月都会有至少 1170美元 的赞助收入,折合人民币 8400元/月


不知道作者所在地区的生活水平怎么样,这个赞助收入,对于生存问题,基本能够胜任了。


我们很多时候都在抱怨开源赚不到钱,那么开源盈利的方案也是有很多的,比如:



  1. 旧版免费,新版付费使用。

  2. 源码免费,文档或咨询付费。

  3. 开源免费,定制服务付费。


希望我们的开源环境更加友好,让更多人可以解决他们的问题,也要让开源作者获得应有的回报。


作者:前端之虎陈随易
来源:juejin.cn/post/7435959580506914816
收起阅读 »

明明 3 行代码即可轻松实现,Promise 为何非要加塞新方法?

web
给前端以福利,给编程以复利。大家好,我是大家的林语冰。 00. 观前须知 地球人都知道,JS 中的异步编程是 单线程 的,和其他多线程语言的三观一龙一猪。因此,虽然其他语言的异步模式彼此互通有无,但对 JS 并不友好,比如 Actor 模型等。 这并不是说 J...
继续阅读 »

给前端以福利,给编程以复利。大家好,我是大家的林语冰。


00. 观前须知


地球人都知道,JS 中的异步编程是 单线程 的,和其他多线程语言的三观一龙一猪。因此,虽然其他语言的异步模式彼此互通有无,但对 JS 并不友好,比如 Actor 模型等。


这并不是说 JS 被异步社区孤立了,只是因为 JS 天生和多线程八字不合。你知道的,要求 JS 使用多线程,就像要求香菜恐惧症患者吃香菜一样离谱。本质上而言,这是刻在 JS 单线程 DNA 里的先天基因,直接决定了 JS 的“异步性状”。有趣的是,如今 JS 也变异出若干多线程的使用场景,只是比较非主流。


ES6 之后,JS 的异步编程主要基于 Promise 设计,比如人气爆棚的 fetch API 等。因此,最新的 ES2024 功能里,又双叒叕往 Promise 加塞了新型静态方法 Promise.withResolvers(),也就见怪不怪了。


00-promise.png


问题在于,我发现这个新方法居然只要 3 行代码就能实现!奥卡姆剃刀原则告诉我们, 若无必要,勿增实体。那么这个鸡肋的新方法是否违背了奥卡姆剃刀原则呢?我决定先质疑、再质疑。


当然,作为应试教育的漏网之鱼,我很擅长批判性思考,不会被第一印象 PUA。经过三天三夜的刻意练习,机智如我发现新方法果然深藏不露。所以,本期我们就一起来深度学习 Promise 新方法的技术细节。


01. 静态工厂方法


Promise.withResolvers() 源自 tc39/proposal-promise-with-resolvers 提案,是 Promise 类新增的一个 静态工厂方法


静态的意思是,该方法通过 Promise 类调用,而不是通过实例对象调用。工厂的意思是,我们可以使用该方法生成一个 Promise 实例,而无须求助于传统的构造函数 + new 实例化。


01-factory.png


可以看到,这类似于 Promise.resolve() 等语法糖。区别在于,传统构造函数实例化的对象状态可能不太直观,而这里的 promise 显然处于待定状态,此外还“买一送二”,额外附赠一对用于改变 promise 状态的“变态函数” —— resolve()reject()


ES2024 之后,该方法可以作为一道简单的异步笔试题 —— 请你在一杯泡面的时间里,实现一下 Promise.withResolvers()


如果你是我的粉丝,根本不慌,因为新方法的基本原理并不复杂,参考我下面的实现,简单给面试官表演一下就欧了。


02-mock.png


可以看到,这个静态工厂方法的实现难点在于,如何巧妙地将变态函数暴露到外部作用域,其实核心逻辑压缩后有且仅有 3 行代码。


这就引发了本文开头的质疑:新方法是否多此一举?难道负责 JS 标准化的 tc39 委员会也有绩效考核,还是确实存在某些不为人知的极端情况?


02. 技术细节


通过对新方法进行苏格拉底式的“灵魂拷问”和三天三夜的深度学习,我可以很有把握地说,没人比我更懂它。


首先,与传统的构造函数实例化不同,新方法支持无参构造,我们不需要在调用时传递任何参数。


03-new.png


可以看到,构造函数实例化要求传递一个执行器回调,偷懒不传则直接报错,无法顺利实例化。


其次,变态函数的设计更加自由。


04-local.png


可以看到,传统的构造函数中,变态函数能且仅能作为局部变量使用,无法在构造函数外部调用。而新方法同时返回实例及其变态函数,这意味着实例和变态函数处于同一级别的作用域。


那么,这个设计上的小细节有何黑科技呢?


假设我们想要一个 Promise 实例,但尚未知晓异步任务的所有细节,我们期望先将变态函数抽离出来,再根据业务逻辑灵活调用,请问阁下如何应对?


ES2024 之前,我们可以通过 作用域提升 来“曲线救国”,举个栗子:


05-cache.png


可以看到,这种方案的优势在于,诉诸作用域提升,我们不必把所有猫猫放在一个薛定谔的容器里,在构造函数中封装一大坨“代码屎山”;其次,变态函数不被限制在构造函数内部,随时随地任你调用。


该方案的缺陷则在于,某些社区规范鼓励“const 优先”的代码风格,即 const 声明优先,再按需修改为 let 声明。


这里的变态函数被迫使用 let 声明,这意味着存在被愣头青意外重写的隐患,但为了缓存赋值,我们一开始就不能使用 const 声明。从防御式编程的角度,这可能不太鲁棒。


因此,Promise.withResolvers() 应运而生,该静态工厂方法允许我们:



  • 无参构造

  • const 优先

  • 自由变态


03. 设计动机


在某些需要封装 Promise 风格的场景中,新方法还能减少回调函数的嵌套,我把这种代码风格上的优化称为“去回调化”。


举个栗子,我们可以把 Node 中回调风格的 API 转换为 Promise 风格,以 fs 模块为例:


06-hell.png


可以看到,由于使用了传统的构造函数实例化,在封装 readFile() 的时候,我们被迫将其嵌套在构造函数内部。


现在,我们可以使用新方法来“去回调化”。


07-fs.png


可以看到,传统构造函数嵌套的一层回调函数就无了,整体实现更加扁平,减肥成功!


粉丝请注意,很多 Node API 现在也内置了 Promise 版本,现实开发中不需要我们手动封装,开箱即用就欧了。但是这种封装技巧是通用的。


举个栗子,瞄一眼 MDN 电子书搬运过来的一个更复杂的用例,将 Node 可读流转换为异步可迭代对象。


08-stream.png


可以看到,井然有序的代码中透露着一丝无法形容的优雅。我脑补了一下如何使用传统构造函数来实现上述功能,现在还没缓过来......


04. 高潮总结


从历史来看,Promise.withResolvers() 并非首创,bluebird 的 Promise.defer() 或 jQuery 的 $.defer() 等库就提供了同款功能,ES2024 只是换了个名字“新瓶装旧酒”,将其标准化为内置功能。


但是,Promise.withResolvers() 的标准化势在必行,比如 Vite 源码中就自己手动封装了同款功能。


09-vite.png


无独有偶,Axios、Vue、TS、React 等也都在源码内部“反复造轮子”,像这种回头率超高的代码片段我们称之为 boilerplate code(样板代码)。


重复乃编程之大忌,既然大家都要写,不如大家都别写,让 JS 自己写,牺牲小我,成全大家。编程里的 DRY 原则就是让我们不要重复,因为很多 bug 就是重复导致的,而且不好统一管理和维护,《ES6 标准入门教程》科普的 魔术字符串 就是其中一种反模式。


兼容性方面,我也做过临床测试了,主流浏览器广泛支持。


10-can.png


总之,Promise.withResolvers() 通过将样板代码标准化,达到了消除重复的目的,原生实现除了性能更好,是一个性价比较高的静态工厂方法。


参考文献



粉丝互动


本期话题是:你觉得新方法好评几颗星,为什么?你可以在本文下方自由言论,文明科普。


欢迎持续关注“前端俱乐部”,给前端以福利,给编程以复利。


坚持阅读的小伙伴可以给自己点赞!谢谢大家的点赞,掰掰~


26-cat.gif


作者:前端俱乐部
来源:juejin.cn/post/7391745629876469760
收起阅读 »

商品 sku 在库存影响下的选中与禁用

web
分享一下,最近使用 React 封装的一个 Skus 组件,主要用于处理商品的sku在受到库存的影响下,sku项的选中和禁用问题; 需求分析 需要展示商品各规格下的sku信息,以及根据该sku的库存是否为空,判断是否禁用该sku的选择。 以下讲解将按照我的 ...
继续阅读 »

分享一下,最近使用 React 封装的一个 Skus 组件,主要用于处理商品的sku在受到库存的影响下,sku项的选中和禁用问题;


需求分析


需要展示商品各规格下的sku信息,以及根据该sku的库存是否为空,判断是否禁用该sku的选择。


sku-2.gif

以下讲解将按照我的 Skus组件 来,我这里放上我组件库中的线上 demo 和码上掘金的一个 demo 供大家体验;由于码上掘金导入不了组件库,我就上传了一份开发组件前的一份类似的代码,功能和代码思路是差不多的,大家也可以自己尝试写一下,可能你的思路会更优;


线上 Demo 地址


码上掘金



传入的sku数据结构


需要传入的商品的sku数据类型大致如下:


type SkusProps = { 
/** 传入的skus数据列表 */
data: SkusItem[]
// ... 其他的props
}

type SkusItem = {
/** 库存 */
stock?: number;
/** 该sku下的所有参数 */
params: SkusItemParam[];
};

type SkusItemParam = {
name: string;
value: string;
}

转化成需要的数据类型:


type SkuStateItem = {
value: string;
/** 与该sku搭配时,该禁用的sku组合 */
disabledSkus: string[][];
}[];

生成数据


定义 sku 分类


首先假装请求接口,造一些假数据出来,我这里自定义了最多 6^6 = 46656 种 sku。


sku-66.gif

下面的是自定义的一些数据:


const skuData: Record<string, string[]> = {
'颜色': ['红','绿','蓝','黑','白','黄'],
'大小': ['S','M','L','XL','XXL','MAX'],
'款式': ['圆领','V领','条纹','渐变','轻薄','休闲'],
'面料': ['纯棉','涤纶','丝绸','蚕丝','麻','鹅绒'],
'群体': ['男','女','中性','童装','老年','青少年'],
'价位': ['<30','<50','<100','<300','<800','<1500'],
}
const skuNames = Object.keys(skuData)

页面初始化



  • checkValArr: 需要展示的sku分类是哪些;

  • skusList: 接口获取的skus数据;

  • noStockSkus: 库存为零对应的skus(方便查看)。


export default () => {
// 这个是选中项对应的sku类型分别是哪几个。
const [checkValArr, setCheckValArr] = useState<number[]>([4, 5, 2, 3, 0, 0]);
// 接口请求到的skus数据
const [skusList, setSkusList] = useState<SkusItem[]>([]);
// 库存为零对应的sku数组
const [noStockSkus, setNoStockSkus] = useState<string[][]>([])

useEffect(() => {
const checkValTrueArr = checkValArr.filter(Boolean)
const _noStockSkus: string[][] = [[]]
const list = getSkusData(checkValTrueArr, _noStockSkus)
setSkusList(list)
setNoStockSkus([..._noStockSkus])
}, [checkValArr])

// ....

return <>...</>
}

根据上方的初始化sku数据,生成一一对应的sku,并随机生成对应sku的库存。


getSkusData 函数讲解


先看总数(total)为当前需要的各sku分类的乘积;比如这里就是上面传入的 checkValArr 数组 [4,5,2,3]120种sku选择。对应的就是 skuData 中的 [颜色前四项,大小前五项,款式前两项,面料前三项] 即下图的展示。


image.png

遍历 120 次,每次生成一个sku,并随机生成库存数量,40%的概率库存为0;然后遍历 skuNames 然后找到当前对应的sku分类即 [颜色,大小,款式,面料] 4项;


接下来就是较为关键的如何根据 sku的分类顺序 生成对应的 120个相应的sku。


请看下面代码中注释为 LHH-1 的地方,该 value 的获取是通过 indexArr 数组取出来的。可以看到上面 indexArr 数组的初始值为 [0,0,0,0] 4个零的索引,分别对应 4 个sku的分类;



  • 第一次遍历:


indexArr: [0,0,0,0] -> skuName.forEach -> 红,S,圆领,纯棉


看LHH-2标记处: 索引+1 -> indexArr: [0,0,0,1];



  • 第二次遍历:


indexArr: [0,0,0,1] -> skuName.forEach -> 红,S,圆领,涤纶


看LHH-2标记处: 索引+1 -> indexArr: [0,0,0,2];



  • 第三次遍历:


indexArr: [0,0,0,2] -> skuName.forEach -> 红,S,圆领,丝绸


看LHH-2标记处: 由于已经到达该分类下的最后一个,所以前一个索引加一,后一个重新置为0 -> indexArr: [0,0,1,0];



  • 第四次遍历:


indexArr: [0,0,1,0] -> skuName.forEach -> 红,S,V领,纯棉


看LHH-2标记处: 索引+1 -> indexArr: [0,0,1,1];



  • 接下来的一百多次遍历跟上面的遍历同理


image.png
function getSkusData(skuCategorys: number[], noStockSkus?: string[][]) {
// 最终生成的skus数据;
const skusList: SkusItem[] = []
// 对应 skuState 中各 sku ,主要用于下面遍历时,对 product 中 skus 的索引操作
const indexArr = Array.from({length: skuCategorys.length}, () => 0);
// 需要遍历的总次数
const total = skuCategorys.reduce((pre, cur) => pre * (cur || 1), 1)
for(let i = 1; i <= total; i++) {
const sku: SkusItem = {
// 库存:60%的几率为0-50,40%几率为0
stock: Math.floor(Math.random() * 10) >= 4 ? Math.floor(Math.random() * 50) : 0,
params: [],
}
// 生成每个 sku 对应的 params
let skuI = 0;
skuNames.forEach((name, j) => {
if(skuCategorys[j]) {
// 注意:LHH-1
const value = skuData[name][indexArr[skuI]]
sku.params.push({
name,
value,
})
skuI++;
}
})
skusList.push(sku)

// 注意: LHH-2
indexArr[indexArr.length - 1]++;
for(let j = indexArr.length - 1; j >= 0; j--) {
if(indexArr[j] >= skuCategorys[j] && j !== 0) {
indexArr[j - 1]++
indexArr[j] = 0
}
}

if(noStockSkus) {
if(!sku.stock) {
noStockSkus.at(-1)?.push(sku.params.map(p => p.value).join(' / '))
}
if(indexArr[0] === noStockSkus.length && noStockSkus.length < skuCategorys[0]) {
noStockSkus.push([])
}
}
}
return skusList
}

Skus 组件的核心部分的实现


初始化数据


需要将上面生成的数据转化为以下结构:


type SkuStateItem = {
value: string;
/** 与该sku搭配时,该禁用的sku组合 */
disabledSkus: string[][];
}[];

export default function Skus() {
// 转化成遍历判断用的数据类型
const [skuState, setSkuState] = useState<Record<string, SkuStateItem>>({});
// 当前选中的sku值
const [checkSkus, setCheckSkus] = useState<Record<string, string>>({});

// ...
}

将初始sku数据生成目标结构


根据 data (即上面的假数据)生成该数据结构。


第一次遍历是对skus第一项进行的,会生成如下结构:


const _skuState = {
'颜色': [{value: '红', disabledSkus: []}],
'大小': [{value: 'S', disabledSkus: []}],
'款式': [{value: '圆领', disabledSkus: []}],
'面料': [{value: '纯棉', disabledSkus: []}],
}

第二次遍历则会完整遍历剩下的skus数据,并往该对象中填充完整。


export default function Skus() {
// ...
useEffect(() => {
if(!data?.length) return
// 第一次对skus第一项的遍历
const _checkSkus: Record<string, string> = {}
const _skuState = data[0].params.reduce((pre, cur) => {
pre[cur.name] = [{value: cur.value, disabledSkus: []}]
_checkSkus[cur.name] = ''
return pre
}, {} as Record<string, SkuStateItem>)
setCheckSkus(_checkSkus)

// 第二次遍历
data.slice(1).forEach(item => {
const skuParams = item.params
skuParams.forEach((p, i) => {
// 当前 params 不在 _skuState 中
if(!_skuState[p.name]?.find(params => params.value === p.value)) {
_skuState[p.name].push({value: p.value, disabledSkus: []})
}
})
})

// ...接下面
}, [data])
}

第三次遍历主要用于为每个 sku的可点击项 生成一个对应的禁用sku数组 disabledSkus ,只要当前选择的sku项,满足该数组中的任一项,该sku选项就会被禁用。之所以保存这样的一个二维数组,是为了方便后面点击时的条件判断(有点空间换时间的概念)。


遍历 data 当库存小于等于0时,将当前的sku的所有参数传入 disabledSkus 中。


例:第一项 sku(红,S,圆领,纯棉)库存假设为0,则该选项会被添加到 disabledSkus 数组中,那么该sku选择时,勾选前三个后,第四个 纯棉 的勾选会被禁用。


image.png
export default function Skus() {
// ...
useEffect(() => {
// ... 接上面
// 第三次遍历
data.forEach(sku => {
// 遍历获取库存需要禁用的sku
const stock = sku.stock!
// stockLimitValue 是一个传参 代表库存的限制值,默认为0
// isStockGreaterThan 是一个传参,用来判断限制值是大于还是小于,默认为false
if(
typeof stock === 'number' &&
isStockGreaterThan ? stock >= stockLimitValue : stock <= stockLimitValue
) {
const curSkuArr = sku.params.map(p => p.value)
for(const name in _skuState) {
const curSkuItem = _skuState[name].find(v => curSkuArr.includes(v.value))
curSkuItem?.disabledSkus?.push(
sku.params.reduce((pre, p) => {
if(p.name !== name) {
pre.push(p.value)
}
return pre
}, [] as string[])
)
}
}
})

setSkuState(_skuState)
}, [data])
}

遍历渲染 skus 列表


根据上面的 skuState,生成用于渲染的列表,渲染列表的类型如下:


type RenderSkuItem = {
name: string;
values: RenderSkuItemValue[];
}
type RenderSkuItemValue = {
/** sku的值 */
value: string;
/** 选中状态 */
isChecked: boolean
/** 禁用状态 */
disabled: boolean;
}

export default function Skus() {
// ...
/** 用于渲染的列表 */
const list: RenderSkuItem[] = []
for(const name in skuState) {
list.push({
name,
values: skuState[name].map(sku => {
const isChecked = sku.value === checkSkus[name]
const disabled = isChecked ? false : isSkuDisable(name, sku)
return { value: sku.value, disabled, isChecked }
})
})
}
// ...
}

html css 大家都会,以下就简单展示了。最外层遍历sku的分类,第二次遍历遍历每个sku分类下的名称,第二次遍历的 item(类型为:RenderSkuItemValue),里面会有sku的值,选中状态和禁用状态的属性。


export default function Skus() {
// ...
return list?.map((p) => (
<div key={p.name}>
{/* 例:颜色、大小、款式、面料 */}
<div>{p.name}</div>
<div>
{p.values.map((sku) => (
<div
key={p.name + sku.value}
onClick={() =>
selectSkus(p.name, sku)}
>
{/* classBem 是用来判断当前状态,增加类名的一个方法而已 */}
<span className={classBem(`sku`, {active: sku.isChecked, disabled: sku.disabled})}>
{/* 例:红、绿、蓝、黑 */}
{sku.value}
</span>
</div>
))}
</div>
</div>

))
}

selectSkus 点击选择 sku


通过 checkSkus 设置 sku 对应分类下的 sku 选中项,同时触发 onChange 给父组件传递一些信息出去。


const selectSkus = (skuName: string, {value, disabled, isChecked}: RenderSkuItemValue) => {
const _checkSkus = {...checkSkus}
_checkSkus[skuName] = isChecked ? '' : value;
const curSkuItem = getCurSkuItem(_checkSkus)
// 该方法主要是 sku 组件点击后触发的回调,用于给父组件获取到一些信息。
onChange?.(_checkSkus, {
skuName,
value,
disabled,
isChecked: disabled ? false : !isChecked,
dataItem: curSkuItem,
stock: curSkuItem?.stock
})
if(!disabled) {
setCheckSkus(_checkSkus)
}
}

getCurSkuItem 获取当前选中的是哪个sku



  • isInOrder.current 是用来判断当前的 skus 数据是否是整齐排列的,这里当成 true 就好,判断该值的过程就不放到本文了,感兴趣可以看 源码


由于sku是按顺序排列的,所以只需按顺序遍历上面生成的 skuState,找出当前sku选中项对应的索引位置,然后通过 就可以直接得出对应的索引位置。这样的好处是能减少很多次遍历。


如果直接遍历原来那份填充所有 sku 的 data 数据,则需要很多次的遍历,当sku是 6^6 时, 则每次变换选中的sku时最多需要 46656 * 6 (data总长度 * 里面 sku 的 params) 次。


const getCurSkuItem = (_checkSkus: Record<string, string>) => {
const length = Object.keys(skuState).length
if(!length || Object.values(_checkSkus).filter(Boolean).length < length) return void 0
if(isInOrder.current) {
let skuI = 0;
// 由于sku是按顺序排列的,所以索引可以通过计算得出
Object.keys(_checkSkus).forEach((name, i) => {
const index = skuState[name].findIndex(v => v.value === _checkSkus[name])
const othTotal = Object.values(skuState).slice(i + 1).reduce((pre, cur) => (pre *= cur.length), 1)
skuI += index * othTotal;
})
return data?.[skuI]
}
// 这样需要遍历太多次
return data.find(s => (
s.params.every(p => _checkSkus[p.name] === getSkuParamValue(p))
))
}

isSkuDisable 判断该 sku 是否是禁用的


该方法是在上面 遍历渲染 skus 列表 时使用的。



  1. 开始还未有选中值时,需要校验 disabledSkus 的数组长度,是否等于该sku参数可以组合的sku总数,如果相等则表示禁用。

  2. 判断当前选中的 sku 还能组成多少种组合。例:当前选中 红,S ,而 isSkuDisable 方法当前判断的 sku 为 款式 中的 圆领,则还有三种组合 红\S\圆领\纯棉红\S\圆领\涤纶红\S\圆领\丝绸

  3. 如果当前判断的 sku 的 disabledSkus 数组中存在这三项,则表示该 sku 选项会被禁用,无法点击。


const isCheckValue = !!Object.keys(checkSkus).length

const isSkuDisable = (skuName: string, sku: SkuStateItem[number]) => {
if(!sku.disabledSkus.length) return false
// 1.当一开始没有选中值时,判断某个sku是否为禁用
if(!isCheckValue) {
let checkTotal = 1;
for(const name in skuState) {
if(name !== skuName) {
checkTotal *= skuState[name].length
}
}
return sku.disabledSkus.length === checkTotal
}

// 排除当前的传入的 sku 那一行
const newCheckSkus: Record<string, string> = {...checkSkus}
delete newCheckSkus[skuName]

// 2.当前选中的 sku 一共能有多少种组合
let total = 1;
for(const name in newCheckSkus) {
if(!newCheckSkus[name]) {
total *= skuState[name].length
}
}

// 3.选中的 sku 在禁用数组中有多少组
let num = 0;
for(const strArr of sku.disabledSkus) {
if(Object.values(newCheckSkus).every(str => !str ? true : strArr.includes(str))) {
num++;
}
}

return num === total
}

至此整个商品sku从生成假数据到sku的选中和禁用的处理的核心代码就完毕了。还有更多的细节问题可以直接查看 源码 会更清晰。


作者:滑动变滚动的蜗牛
来源:juejin.cn/post/7313979106890842139
收起阅读 »

如果你没有必须要离职的原因,我建议你在忍忍

web
自述 本人成都,由于一些原因我在八月离职了,因为我终于脱离了那个压抑的环境,我没有自己想象中的那么开心,我离职的那天,甚至后面很长的一段时间;离职后的我回了一趟家,刚好在最热的那几天,在家躺了几天,然后又出去逛了逛,玩了差不多一个月吧!我觉得心情逐渐恢复了;然...
继续阅读 »

自述


本人成都,由于一些原因我在八月离职了,因为我终于脱离了那个压抑的环境,我没有自己想象中的那么开心,我离职的那天,甚至后面很长的一段时间;离职后的我回了一趟家,刚好在最热的那几天,在家躺了几天,然后又出去逛了逛,玩了差不多一个月吧!我觉得心情逐渐恢复了;然后开始慢慢的投递简历。


前期


刚投递简历那会,基本上每天都是耍耍哒哒的投递;有面试就去面试,没有面试就在家刷抖音也不看看面试题,可能我找工作的状态还在几年前或者还没从上家公司的状态中走出来,也有可能我目前有一点存款不是特别焦虑,所以也没认真的找。


就这样刷刷哒哒的又过了半月,然后有许多朋友跟我说他们被裁员了,问他们的打算是怎么样的:有的人休息了两三天就开始了找工作当中,而有的人就玩几个月再说。


休息两三天就开始找工作的人基本上都是有家庭有小孩的,反之基本上都是单身。


在跟他们聊天的过程中发现,有些人半年没找到工作了,也有一些人一年都没有找到工作了。可能是年级大了、也可能是工资不想要的太低吧!但是工作机会确实比原来少很多。


在听了大家的话以后,我觉得我差不多也该认真找工作了,我开始逐渐投递简历。


疯狂投递简历


我在9月的下旬开始了简历的修改以及各大招聘App的下载,拉钩、智联、boos以及一下小程序的招聘软件(记不住名字了,因为没啥效果);在我疯狂的投递了几天以后我迎来了第一家面试,是一个线上面试;刚一来就给了我迎头一棒,或许我只忙着修改简历和投递简历去了,没有去背面试题吧(网上说现在都问场景题,所以没准备);


具体的问题我记不全了,但是我记得这么一个问题,面试官问:“深克隆有哪些方法”,我回答的是递归,然后他说还有吗?我直接呆住说不知道了。然后我们就结束了面试,最后他跟我说了这么一句话:“现在的市场行情跟原来没法比,现在的中级基本上要原来的高级的水平,现在的初级也就是原来的中级的水平,所以问的问题会比原来难很多,你可以在学习一下,看你的简历是很不错的;至少简历是这样的。”


当这个面试结束以后我想了想发现是这样的,不知是我还没有接受或者说还没有进入一个面试的状态,还是因为我不想上班的原因,导致我连一些基本的八股文都不清楚,所以我决定开始学习。


给准备离职或者已经离职的朋友们一个忠告:“做任何事情都需提前准备,至少在找工作上是这样的。”


学习


我去看了招聘网站的技术要求(想了解下企业需要的一些技术),不看不知道一看吓一跳,真的奇葩层出不穷,大概给大家概述一下:



  • 开发三班倒:分为早中晚班

  • 要你会vue+react+php+java等技术(工资8-12)

  • 要你会基本的绘画(UI)以及会后端的一些工作,目前这些都需要你一个人完成

  • 要你会vue+react+fluter;了解electron以及3d等

  • 还有就是你的项目跟我们的项目不一致的。


我看到这些稀奇古怪的玩意有点失望,最终我选择了fabricjs进行学习,最开始的时候就是在canvas上画了几个矩形,感觉挺不错的;然后我就想这不是马上快要国庆了吗?我就想用fabric做一个制作头像的这么一个工具插件,在经过两天的开发成功将其制作了出来,并且发布到了网站上(插件tools),发布第一天就有使用的小伙伴给我提一些宝贵的建议了,然后又开始了调整,现在功能也越来越多;


fabricjs在国内的资料很少,基本上就那么几篇文章,没有办法的我就跑去扒拉他们的源码看,然后拷贝需要的代码在修修改改(毕竟比较菜只能这样....);然后在学习fabric的时候也会去学习一些基本知识,比如:js内置方法、手写防抖节流、eventloop、闭包(一些原理逻辑)、深拷贝、内存回收机制等等。


在学习的过程中很难受,感觉每天都是煎熬;每次都想在床上躺着,但是想想还是放弃了,毕竟没有谁会喜欢一个懒惰的人...


在战面试(HR像是刷KPI)


在有所准备的情况下再去面试时就得心应手了,基本上没有太多的胆怯,基本上问啥都知道一些,然后就在面试的时候随机应变即可,10月我基本上接到的面试邀请大概有10多家,然后有几家感觉工资低了就没去面试,去面试了的应该有7/8家的样子,最终只要一家录取。


说说其中一家吧(很像刷KPI的一家):这是一家做ai相关的公司,公司很大,看资料显示时一家中外合资的企业,进去以后先开始了一轮笔试题(3/4页纸),我大概做了50分钟的样子;我基本上8层都答对了(因为他的笔试题很多我都知道嘛,然后有一些还写了几个解决方案的),笔试完了以后,叫我去机试;机试写接口;而且还是在规定的一个网站写(就给我一个网站,然后说写一个接口返回正确结果就行;那个网站我都不会用);我在哪儿磨磨蹭蹭了10多分钟以后,根据node写了一个接口给了hr;然后HR说你这个在我们网站上不能运行。我站起来就走了...


其实我走的原因还有一个,就是他们另一个HR对带我进来的这个HR说:你都没有协调好研发是否有时间,就到处招面试...


是否离职


如果你在你现在这公司还能呆下去的情况下,我建议你还是先呆呆看吧!目前这个市场行情很差,你看到我有10来个面试,但是你知道嘛?我沟通了多少:



  • boos沟通了差不多800-900家公司,邀请我投递简历的只有100家左右。邀请我面试的只有8/9家。

  • 智联招聘我投递了400-600家,邀请我面试的只有1家。

  • 拉钩这个不说了基本上没有招聘的公司(反反复复就那几家);投递了一个月后有一家叫我去面试的,面试了差不多50来分钟;交谈的很开心,他说周一周二给我回复,结果没有回复,我发消息问;也没有回复;看招聘信息发现(邀约面试800+)


我离职情非得已,愿诸君与我不同;如若您已离职,愿您早日找到属于自己的路,不一定是打工的路;若你在职,请在坚持坚持;在坚持的同时去做一些对未来有用的事情,比如:副业、耍个男女朋友、拓展一下圈子等等。


后续的规划


在经历了这次离职以后,我觉得我的人生应该进行好好的规划了;不能为原有的事物所影响,不能为过去所迷茫;未来还很长,望诸君互勉;


未来的计划大致分为几个方向:



  • 拓展自己的圈子(早日脱单)

  • 学习开发鸿蒙(我已经在做了,目前开发的app在审核),发布几款工具类app(也算是为国内唯一的系统贡献一些微弱的力量吧!)

  • 持续更新我在utools上的绘图插件

  • 学习投资理财(最近一月炒股:目前赚了4000多了)

  • 持续更新公众号(前端雾恋)、掘金等网站技术文章


结尾


我们的生活终将回归正轨,所有的昨天也将是历史,不必遗憾昨天,吸取教训继续前进。再见了...


作者:雾恋
来源:juejin.cn/post/7435289649273569334
收起阅读 »