注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

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
收起阅读 »

居然还能这么画骑车线路?:手绘骑行路线 和 起始点途径点规划 导出GPX数据

web
写在前面 众所周知啊骑行🚲是一项非常健康、锻炼身体素质、拓宽视野的一项运动,在如今的2024年啊,越来越多的小孩年轻人等等各类人群都加入了骑行这项运动,哈哈本人也不例外😲,像今年的在中国举办的环广西更是加深了国内的骑行氛围,那导播的运镜水平相比去年越来越有观赏...
继续阅读 »

写在前面


众所周知啊骑行🚲是一项非常健康、锻炼身体素质、拓宽视野的一项运动,在如今的2024年啊,越来越多的小孩年轻人等等各类人群都加入了骑行这项运动,哈哈本人也不例外😲,像今年的在中国举办的环广西更是加深了国内的骑行氛围,那导播的运镜水平相比去年越来越有观赏性。


image.png


在骑行过程中,其中一些想记录自己骑行数据的骑友会选择一些子骑行软件啊,比如像行者、Strva、捷安特骑行等等这些子,功能都非常丰富,他们都会有路线规划这个功能,大部分规划的方案我知道的大概分为 起始点规划起始+途径点规划GPX文件导入这三个主要功能前二者都是靠输入明确地点来确定路线,对于没有明确骑行目的地、选择困难症的一些朋友想必是一大考验,于是我就在想可不可以在地图上画一个大概的线路来生成地图?答案是可以的!


技术分析


灵感来自高德app中运动的大栏中有一个跑步线路规划这一功能,其中的绘制路线就是我们想要的功能,非常方便在地图上画一个大概的线路,然后自动帮你匹配道路上,但是高德似乎没有道路匹配得API?


但是!他有线路纠偏这个功能,这个API大概的功能就是把你历史行进过的线路纠偏到线路上,我们可以将画好得线路模拟出一段行驶轨迹,模拟好方向角、时间和速度,就可以了,这就是我们下面要做得手绘线路这个功能,规划线路那肯定不能只有这一种这么单一啦,再加上一个支持添加途径点得线路规划功能岂不美哉?


效果截图和源码地址


UI截图


image.png


导出效果截图


image.png



仓库地址 : github.com/zuowenwu/Li…



手绘线路+线路纠偏 代码实现


首先是要明确画线的操作,分三步:按下、画线和抬起的操作:


          
this.map.on("touchstart", (e) => {});// 准备画线
this.map.on("touchend", (e) => {});// 结束画线
this.map.on("touchmove");// 画线中

最重要的代码是画线的操作,此时我们设置为地图不可拖动,然后记录手指在地图上的位置即可:


//路径
this.path = []
// 监听滑动配合节流(这里节流是为了减少采样过快避免造成不必要的开销)
this.map.on("touchmove",_.throttle((e) => {
// 点
const position = [e.lnglat.lng, e.lnglat.lat];

// 数组长度为0则第一个点为起点marker
if (!this.path.length) {
this.path.push(position);
new this.AMap.Marker({ map: this.map, position: position });
return;
}
//满足两点创建线
if (this.path.length == 1) {
this.path.push(position);
this.line = new this.AMap.Polyline({
map: this.map,
path: this.path,
strokeColor: "#FF33FF",
strokeWeight: 6,
strokeOpacity: 0.5,
});
return;
}
//添加path
if (this.path.length > 1) {
this.path.push(position);
this.line.setPath(this.path);
}
}, 30)
);

线连接好了,可以导出了!。。吗?那肯定不是,手指在屏幕上画线肯定会和道路有很大的偏差的,我们可以使用高德的线路纠偏功能,因为该功能需要方向角、速度和时间,我们可以把刚刚模拟的线路path设置一下:


let arr = this.path.map((item, index) => {
// 默认角度
let angle = 0;
// 初始时间戳
let tm = 1478031031;
// 和下一个点的角度
if (this.path[index + 1]) {
// 计算与正北方向的夹角
const north = turf.bearing(turf.point([item[0], item[1]]), turf.point([item[0], item[1] + 1]));
// 使用正北方向的点
angle = north < 0 ? (360 + north) : north;
}
return {
x: item[0], //经度
y: item[1],//维度
sp: 10,//速度
ag: Number(angle).toFixed(0),//与正北的角度
tm: !index ? tm : 1 + index,//时间
};
});

这里的数据格式就是这样的:
要注意一下,第一个tm是初始的时间戳,后面都是在[index-1]+距离上次的时间,角度则是与正北方向的夹角而不是和上一个点的夹角,这里我差点弄混淆了


image.png


然后使用线路纠偏:


graspRoad.driving(arr, (error, result) => {
if (!error) {
var path2 = [];
var newPath = result.data.points;
for (var i = 0; i < newPath.length; i += 1) {
path2.push([newPath[i].x, newPath[i].y]);
}
var newLine = new this.AMap.Polyline({
path: path2,
strokeWeight: 8,
strokeOpacity: 0.8,
strokeColor: "#00f",
showDir: true,
});
this.map.add(newLine);
}
});

绿色是手动画的线,蓝色是纠偏到道路上的线,可以看的出来效果还是很不错的


image.png


OK!接下来是导出手机或者码表使用的GPX格式文件的代码,这里使用插件geojson-to-gpx,直接npm i geojson-to-gpx即可,然后导入使用,代码如下:



import GeoJsonToGpx from "@dwayneparton/geojson-to-gpx";

// 转为GeoJSON
const geoJSON = turf.lineString(this.path);
const options = {
metadata: {
name: "导出为GPX",
author: {
name: "XiaoZuoOvO",
},
},
};
//转为geoJSON
const gpxLine = GeoJsonToGpx(geoJSON, options);
const gpxString = new XMLSerializer().serializeToString(gpxLine);

const link = document.createElement("a");
link.download = "高德地图路线绘制.gpx";
const blob = new Blob([gpxString], { type: "text/xml" });
link.href = window.URL.createObjectURL(blob);
link.click();
ElMessage.success("导出PGX成功");

好的,以上就是手绘线路的大概功能!接下来是我们的线路规划功能。


起终点和定义途径点的线路规划 代码实现


虽然说这个功能大多骑行软件都有,但是我们要做就做好用的,支持添加途径点,我们这里使用高德的线路规划2.0,这个API支持添加途径点,再配合上elementplus的el-autocomplete配合搜索,搜索地点使用搜索POI2.0来搜索地点,以下是代码实现,完整代码在github


//html
<el-autocomplete
:prefix-icon="Location"
v-model.trim="start"
:trigger-on-focus="false"
clearable
size="large"
placement="top-start"
:fetch-suggestions="querySearch"
@select="handleSelectStart"
placeholder="起点" />

//js

//搜索地点函数
const querySearch = async (queryString, cb) => {
if (!queryString) return;
const res = await inputtips(queryString);//inputtips是封装好的

if (res.status == "1") {
const arr = res.tips.map((item) => {
return {
value: item.name,
name: item.name,
district: item.district,
address: item.address,
location: item.location,
};
});
cb(arr);
return;
}
};

//自行车路径规划函数
const plan = async () => {
path = [];
const res = await driving({
origin: startPositoin.value,//起点
destination: endPosition.value,//终点
cartype: 1, //电动车/自行车
waypoints: means.value.map((item) => item.location).join(";"),//途径点
});

if (res.status == "1") {

res.route.paths[0].steps.map((item) => {
const linestring = item.polyline;
path = path.concat(
linestring.split(";").map((item) => {
const arr = item.split(",");
return [Number(arr[0]), Number(arr[1])];
})
);
});
}
};


//......................完整代码见github..............................


搜索和规划效果截图:


image.png


以上就是手绘线路和途径点起点终点两个功能,接下来我们干个题外事,我们优化一下高德的 setCenter 和 setFitView,高德的动画太过于线性,我们这里模仿一下cesium和mapbox的效果,使用丝滑贝塞尔曲线来插值过度,配合高德Loca镜头动画


动画效果优化


首先是写一个setCenter,使用的时候传入即可,效果图和代码:


92ba48a695ee5084ec483bd307c2150e.webp


export function panTo(center, map, loca) {
const curZoom = map.getZoom();
const curPitch = map.getPitch();
const curRotation = map.getRotation();
const curCenter = [map.getCenter().lng, map.getCenter().lat];

const targZoom = 17;
const targPitch = 45;
const targRotation = 0;
const targCenter = center;

const route = [
{
pitch: {
value: targPitch,
duration: 2000,
control: [
[0, curPitch],
[1, targPitch],
],
timing: [0.420, 0.145, 0.000, 1],
},
zoom: {
value: targZoom,
duration: 2500,
control: [
[0, curZoom],
[1, targZoom],
],
timing: [0.315, 0.245, 0.405, 1.000],
},
rotation: {
value: targRotation,
duration: 2000,
control: [
[0, curRotation],
[1, targRotation],
],
timing: [1.000, 0.085, 0.460, 1],
},
center: {
value: targCenter,
duration: 1500,
control: [curCenter, targCenter],
timing: [0.0, 0.52, 0.315, 1.0],
},
},
];

// 如果用户有操作则停止动画
map.on("mousewheel", () => {
loca.animate.stop();
});
loca.viewControl.addAnimates(route, () => {});
loca.animate.start();
}

接下来是setFitView:


65c8f54f255a968bfeb25f4620b42139.webp


export function setFitView(center, zoom, map, loca) {
const curZoom = map.getZoom();
const curPitch = map.getPitch();
const curRotation = map.getRotation();
const curCenter = [map.getCenter().lng, map.getCenter().lat];

const targZoom = zoom;
const targPitch = 0;
const targRotation = 0;
const targCenter = center;

const route = [
{
pitch: {
value: targPitch,
duration: 1000,
control: [
[0, curPitch],
[1, targPitch],
],
timing: [0.23, 1.0, 0.32, 1.0],
},
zoom: {
value: targZoom,
duration: 2500,
control: [
[0, curZoom],
[1, targZoom],
],
timing: [0.13, 0.31, 0.105, 1],
},
rotation: {
value: targRotation,
duration: 1000,
control: [
[0, curRotation],
[1, targRotation],
],
timing: [0.13, 0.31, 0.105, 1],
},
center: {
value: targCenter,
duration: 1000,
control: [curCenter, targCenter],
timing: [0.13, 0.31, 0.105, 1],
},
},
];

// 如果用户有操作则停止动画
map.on("mousewheel", () => {
loca.animate.stop();
});

loca.viewControl.addAnimates(route, () => {});

loca.animate.start();
}

export function getFitCenter(points) {
let features = turf.featureCollection(points.map((point) => turf.point(point)));
let center = turf.center(features);
return [center.geometry.coordinates[0], center.geometry.coordinates[1]];
}

export function setFitCenter(points, map) {
const center = getFitCenter(points);
}


//使用
setFitView(getFitCenter(path), getFitZoom(map, path), map, loca);



结束


先贴上仓库地址:
github.com/zuowenwu/Li…


最后送几张自己拍的照片吧哈哈哈


1730049672350.png


1730049711089.png


作者:小左OvO
来源:juejin.cn/post/7430616540804153394
收起阅读 »

用js手撸了一个zip saver

web
背景介绍最近公司有个需求,要在浏览器端生成一大堆的 word 文件并保存到本地。生成 word 文件直接用了docx这个库,嗖的一下很快就搞定了。但是交付给需求方的时候他们却说生成的文件乱糟糟的放在下载目录里面他们看着烦,而且还要手动整理每一批文件,问我能不能...
继续阅读 »

背景介绍

最近公司有个需求,要在浏览器端生成一大堆的 word 文件并保存到本地。

生成 word 文件直接用了docx这个库,嗖的一下很快就搞定了。但是交付给需求方的时候他们却说生成的文件乱糟糟的放在下载目录里面他们看着烦,而且还要手动整理每一批文件,问我能不能搞成一个压缩包。我一听这个要求,心想:不就是调的包的事吗,二话不说马上就答应了。

然而,,,,,

搜了很久,也没有找到直接马上就可以用的 js 库来将一大堆文件直接变成一个压缩包。又搜了一下 zip 的文件格式内容,发现好像不是很复杂。那就自己来搞一个包吧。

太长不看?直接用 zip-saver

一、zip 文件格式简介

zip 文件大致可以分成三个个部分:

  1. 文件部分
    • 文件部分包含了所有的文件内容,每个文件都有一个文件头,文件头包含了文件的元信息,比如文件名、文件大小、文件的压缩方式等等。
  2. 中央目录部分
    • 中央目录部分包含了所有文件的元信息,比如文件名、文件大小、文件的压缩方式等等。
  3. 目录结束标识 - 目录结束标识标识了中央目录部分的结束。包含了中央目录的开始位置、中央目录的大小等信息。 image.png 图片来自:en.wikipedia.org/wiki/ZIP_(f…

对于每一个文件,他在 zip 中包含三部分

  1. 本地文件头( Local File Header)-- 图片来自:goodapple.top/archives/70…

image.png

  1. 文件内容
  2. 数据描述符( Data descriptor)-- 图片来自:goodapple.top/archives/70…

image.png

数据描述符是可选的,当本地文件头中没有指明 CRC-32 校验码和压缩前后的长度时,才需要数据描述符

中央目录区的数据构成是这样的 -- 图片来自:goodapple.top/archives/70…

image.png

目录结束标识的数据构成是这样的 -- 图片来自:goodapple.top/archives/70…

image.png

二、代码实现

有了上面的信息之后,不难想到生成一个 zip 文件的步骤:

  1. 生成文件部分
    1. 构造固定的文件信息头
    2. 追加文件内容
    3. 计算文件的 CRC32 校验码
    4. 生成数据描述符
  2. 生成中央目录部分
    1. 构造固定的中央文件信息头
    2. 计算文件的偏移量
  3. 生成目录结束标识
    1. 构造固定的目录结束标识
    2. 计算中央目录的大小和偏移

1. 生成本地文件头(local file header)

根据local file header的结构,我们很容易得知:一个local file header 的大小是 30 + n + m 个字节

其中n是文件名的长度,m是扩展字段的长度,在这里我们不考虑扩展字段,那么最终大小就是30 + n

js中我可以直接用Uint8Array来存储一个字节,又因为 zip 是采用小端序,为了方便操作, 那么local file header变量就可以这样定义:

const length = 30 + filenameLength
const localFileHeaderBytes = new Uint8Array(length)
// 使用DataView可以更方便的操作小端序数据
const localFileHeaderDataView = new DataView(localFileHeaderBytes.buffer)

定义完 local file header 变量后我们就可以往里面塞一些东西了

// local file header 的起始固定值为 0x04034b50
// setUint第一个参数为偏移量,第二个参数是值,第三个参数为true表示以小端序存储
localFileHeaderDataView.setUint32(0, 0x04034b50, true)
// 设置最低要求的版本号为 0x14
localFileHeaderDataView.setUint16(4, 0x0014, true)
// 设置通用标志位为 0x0808
// 0x0808 使用UTF-8编码且文件头中不包含CRC32和文件大小信息
localFileHeaderDataView.setUint16(6, 0x0808, true)

// 设置压缩方式为 0x0000 表示不压缩
localFileHeaderDataView.setUint16(8, 0x0000, true)
// 设置最后修改时间, 这里假设最后修改时间为当前时间
const lastModified = new Date().getTime()
// last modified time
localFileHeader.setUint16(
10,
(date.getUTCHours() << 11) |
(date.
getUTCMinutes() << 5) |
(date.
getUTCSeconds() / 2)
)

// last modified date
localFileHeader.setUint16(
12,
date.getUTCDate() |
((date.
getUTCMonth() + 1) << 5) |
((date.
getUTCFullYear() - 1980) << 9)
)

// 设置文件名的长度,这里假设文件名已经转换成了字节数组nameBytes
localFileHeaderDataView.setUint16(26, nameBytes.length, true)

// 设置文件名
localFileHeaderBytes.set(nameBytes, 30)

到此,一个local file header就生成好了

2. 文件内容追加

文件内容追加这一步很简单,这里我们不考虑压缩文件,直接将文件转为Uint8Array 并计算文件的 CRC32 校验码,然后追加到local file header后面即可

    const crc = new CRC32()
// 获取file数据备用
const fileBytes = await file.arrayBuffer()
crc.append(fileBytes)

3. 数据描述符(Data descriptor)生成

数据描述符用来表示文件压缩与的结束,根据他的编码格式,他包含的信息只有四个:固定的标识符、CRC-32校验码,压缩前的大小,压缩后的大小,这里我们暂且不考虑数据的压缩, 要生成他也很简单:

    const dataDescriptor = new Uint8Array(16)
const dataDescriptorDataView = new DataView(dataDescriptor.buffer)
// 0x08074b50 是数据描述符的固定标识字段
dataDescriptorDataView.setUint32(0, 0x08074b50, true)
// CRC-32校验码
dataDescriptorDataView.setUint32(4, crc.value, true)
// 压缩前的大小
dataDescriptorDataView.setUint32(8, fileBytes.length, true)
// 压缩后的大小
dataDescriptorDataView.setUint32(12, fileBytes.length, true)

至此,一个文件在zip中所有的信息就已经都可以生成了,接下来就需要生成中央目录信息了

4. 中央目录区生成

根据上面的图,我们知道, 中央目录区也是由一个一个的文件头组成,每一个文件头对对应着一个真实文件的信息,每个文件信息大小是46 + n + m + k,其中n是文件名称的大小,m是扩展字段的大小,k是文件注释的大小。 在这里,我们可以暂时不必管扩展字段,先计算一下中央目录区的总大小:

    // 假设有一个文件列表为flieList
const wholeLength = flieList.reduce((acc, file) => {
// 文件名长度
const nameBufferLength = textEncoder.encode(file.name).length
// 假设文件有注释字段comment
const commentBufferLength = textEncoder.encode(file.comment).length
// 累加起来
return acc + 46 + nameBufferLength + commentBufferLength
}, 0)

然后,创建一个变量存储中央目录区的数据

const centraHeader = new Uint8Array(wholeLength)
const centraHeaderDataView = new DataView(dataDescriptor.buffer)

接下来就可以通过循环,将所有文件的信息都写入中央目录区

    // 假设有这样一个数据结构存储了文件的信息
type FileZipInfo = {
localFileHeader: Uint8Array
fileBytes: Uint8Array
dataDescriptor: Uint8Array
filename: string
fileComment: string
}

// offset表示中央目录信息中,当前文件相对于中央目录起始位置的偏移
// entryOffset 表示一个文件的信息(本地文件头+文件数据+数据描述符)相对于整个zip文件起始位置的偏移
let entryOffset = 0

for (
let i = 0, offset = 0;
i < fileZipInfoList.length;
i++
) {
const fileZipInfo = fileZipInfoList[i]
// 设置固定标识符号
centraHeaderDataView.setUint32(offset, 0x02014b50, true)
// 设置压缩版本号
centraHeaderDataView.setUint16(offset + 4, 0x0014true)
// 因为中央目录信息中的文件数据一大部份都是本地文件头数据的冗余,所以可以直接复制过来使用
centraHeader.set(fileZipInfo.localFileHeader.slice(4, 30), offset + 6)

const textEncoder = new TextEncoder()
// 注释长度
const commentBuffer = textEncoder.encode(fileZipInfo.fileComment)
centraHeaderDataView.setUint16(offset + 32, commentBuffer.length, true)

// 对应的本地文件头在整个zip文件中的偏移
centraHeaderDataView.setUint32(offset + 42, entryOffset, true)

// 文件名
const filenameBuffer = textEncoder.encode(fileZipInfo.filename)
centraHeaderDataView.setUint16(filenameBuffer, offset + 46)

// 扩展字段暂时不管,下一个直接设置文件注释
bufferDataView.set(commentBuffer, offset + 46 + filenameBuffer.length)

// 更新offset的值
// 下一个中央目录中的文件的offset的值为此次生成的文件信息大小 + 当前的offset
// 也就是
offset = offset + 46 + commentBuffer.length + filenameBuffer.length

// entryOffset 的值累加为当前文件信息在整个zip文件中的大小 + 当前的 entryOffset
entryOffset += fileZipInfo.localFileHeader.length + fileZipInfo.fileBytes.length + fileZipInfo.dataDescriptor.length


}

最后,再生成 目录结束标识

    // 目录结束标识的大小为22 + 注释信息(注释信息先忽略)
const eocdBytes = new Uint8Array(22)
const eocdDataView = new DataView(eocd.buffer)

// 固定标识值
eocdDataView.setUint32(eocdOffset, 0x06054b50, true)

// 和分卷有关的数据都可以忽略,他主要是为了处理一个zip文件跨磁盘存储的问题,现在基本没有这种场景
// 当前分卷号
eocdDataView.setUint16(4, 0, true)
// 中央目录开始分卷号
eocdDataView.setUint16(6, 0, true)
// 当前分卷的总文件数量
eocdDataView.setUint16(8, fileZipInfoList.length, true)
// 总文件数量
eocdDataView.setUint16(10, fileZipInfoList.length, true)
// 中央目录的总大小
eocdDataView.setUint32(12, wholeLength, true)
// 中央目录在整个zip文件中的目录偏移
eocdDataView.setUint32(16, entryOffset, true)
// 最后是注释的信息,先忽略

5. 拼接完整数据

完成了上面所有的步骤之后,我们只需要把数据都拼接起来就可以了


// 所有文件数据都存储在 fileZipInfoList中

// 组合文件数据
const fileBytesList = fileZipInfoList.map(fileZipInfo => {
return new Uint8Array([
...fileZipInfo.localFileHeader,
...fileZipInfo.fileBytes,
...fileZipInfo.dataDescriptor
])
})

const zipBlob = new Blob([
...fileBytesList,
centraHeader,
eocdBytes
],{
type: 'application/zip'
})

ok,搞定!

6. 完整的实现

github.com/EatherToo/z…

三、总结

经过上面的步骤,我们就可以生成一个zip文件了,当然,这里只是一个简单的实现,zip文件格式还有很多细节,比如压缩算法、加密压缩等等,这里都没有涉及到,后面有时间再来完善吧。

参考资料:


作者:EatherToo
来源:juejin.cn/post/7430660826900185097

收起阅读 »

决定了,做一个纯前端的pptx预览库

web
大家好,我是前端林叔。 今年我github的vue-office文档预览库star已经达到了3600+,不过这个库没什么技术含量,只不过是站在前人的肩膀上简单封装了下。目前该库包含了word(docx)、excel(xls、xlsx)和pdf的预览,唯独缺少p...
继续阅读 »

大家好,我是前端林叔。


今年我github的vue-office文档预览库star已经达到了3600+,不过这个库没什么技术含量,只不过是站在前人的肩膀上简单封装了下。目前该库包含了word(docx)、excel(xls、xlsx)和pdf的预览,唯独缺少ppt文档的预览,很多朋友都提过,能不能做一个ppt的预览库,我一直也在纠结。


为什么迟迟不做ppt的预览库


说到底,还是收益的问题,我做这件事的收益到底是什么?


一般来说做一个开源库我们会有以下几个收益:



  • 证明自己的技术实力,在找工作时增加自己的竞争力(回答面试官经常问的那个问题,怎么证明你的技术深度?)

  • 锻炼自己的技术能力,做一个好的开源项目需要一定的技术功底,在实战中提升自己是最快的方式

  • 反哺开源社区,用爱发电,提升社区知名度

  • 做得好了还可以考虑商业化赚钱


我迟迟没有做这件事就是没有想好我到底要什么,而且今年一直在忙着写掘金小册,也确实没有时间,另外就是在做vue-office库的时候,真切的感觉到,用爱发电是不长久的,如果没有利益驱动,是很难坚持下去的,试问,在如今行情这么不好的情况下,怎么平衡工作和自己的业余爱好,每个周末都去免费解决用户的问题,谁能长久地坚持下去呢?


为什么又决定做了


最近正好小册已经完结了(估计最近就会上线),自己也闲下来了,突然感觉失去了方向,不知道做啥了,整个人都变得迷茫,而且能预期到明年裁员的大刀就要砍到自己头上了,也要为后面的面试做下准备了,毕竟年龄大了,没有拿得出手的技术作品,想必后面也是很难的,把近期想做的事情排了个优先级,觉得这个事情还是比较重要的,于是决定开干!


但对于选择开源还是闭源纠结了很久,开源的话比较容易积累star,但主要还是精神支持,对长期利益来看是好的;不过开源后代码很容易被人拷贝改做他用,将自己辛辛苦苦几个月的成果免费拿走,还是不太甘心(这里忏悔下自己的格局)。我最终决定还是闭源,打赏一定金额(比如50以上)可以索取源码,源码不得用于开源,仅做学习和自己项目使用,后期可以考虑开发企业版,通过license授权。


这么做肯定会被人骂的,不过没办法,免费的事情实在坚持不下去了。当然了,只是不免费开放源码,使用都是免费的,会把最终的库发布到npm。


可行性


对于pptx格式的文件,实际上可以看做一个压缩文件,我们把任意一个pptx文件的后缀改为zip,然后解压,就可以看到pptx文件的内容,大部分都是xml文件,我们可以通过分析这个xml中的内容来获取ppt文档的信息,文档符合Microsoft Open XML(简称OOXML)规范。而对于.ppt格式的文件则无法获取其具体格式,所以本库只支持.pptx格式的文件。


ppt-zip.png


说起来容易,不过由于xml的格式比较晦涩难懂,分析过程还是非常痛苦的,下面是ppt中单个幻灯片的xml,可以体会下其中的复杂度。


<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">

<p:cSld>
<p:bg>
<p:bgPr>
<a:solidFill>
<a:schemeClr val="accent2">
<a:alpha val="34902"/>
</a:schemeClr>
</a:solidFill>
<a:effectLst/>
</p:bgPr>
</p:bg>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
<p:pic>
<p:nvPicPr>
<p:cNvPr id="2" name="图片 1">
<a:extLst>
<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">
<a16:creationId xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main"
id="{92992223-295D-7122-C034-29375CD12672}"/>

</a:ext>
</a:extLst>
</p:cNvPr>
<p:cNvPicPr>
<a:picLocks noChangeAspect="1"/>
</p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
<p:blipFill>
<a:blip r:embed="rId2"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</p:blipFill>
<p:spPr>
<a:xfrm>
<a:off x="1270000" y="635000"/>
<a:ext cx="1485900" cy="787400"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
</p:pic>
<p:pic>
<p:nvPicPr>
<p:cNvPr id="5" name="图片 4">
<a:extLst>
<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">
<a16:creationId xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main"
id="{D071BB10-9D98-FEF0-768B-8E826152F476}"/>

</a:ext>
</a:extLst>
</p:cNvPr>
<p:cNvPicPr>
<a:picLocks noChangeAspect="1"/>
</p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
<p:blipFill rotWithShape="1">
<a:blip r:embed="rId3"/>
<a:srcRect r="46000"/>
<a:stretch/>
</p:blipFill>
<p:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="685800" cy="1270000"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
</p:pic>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="3" name="矩形 2">
<a:extLst>
<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">
<a16:creationId xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main"
id="{FC6BFD96-7710-5D5C-0E6D-5647BB89F8D0}"/>

</a:ext>
</a:extLst>
</p:cNvPr>
<p:cNvSpPr/>
<p:nvPr/>
</p:nvSpPr>
<p:spPr>
<a:xfrm>
<a:off x="7002462" y="2264229"/>
<a:ext cx="3110366" cy="522514"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
<p:style>
<a:lnRef idx="2">
<a:schemeClr val="accent1">
<a:shade val="15000"/>
</a:schemeClr>
</a:lnRef>
<a:fillRef idx="1">
<a:schemeClr val="accent1"/>
</a:fillRef>
<a:effectRef idx="0">
<a:schemeClr val="accent1"/>
</a:effectRef>
<a:fontRef idx="minor">
<a:schemeClr val="lt1"/>
</a:fontRef>
</p:style>
<p:txBody>
<a:bodyPr rtlCol="0" anchor="ctr"/>
<a:lstStyle/>
<a:p>
<a:pPr algn="ctr"/>
<a:endParaRPr kumimoji="1" lang="zh-CN" altLang="en-US">
<a:ln>
<a:solidFill>
<a:srgbClr val="FF0000"/>
</a:solidFill>
</a:ln>
</a:endParaRPr>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
<p:extLst>
<p:ext uri="{BB962C8B-B14F-4D97-AF65-F5344CB8AC3E}">
<p14:creationId xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" val="760063892"/>
</p:ext>
</p:extLst>
</p:cSld>
<p:clrMapOvr>
<a:masterClrMapping/>
</p:clrMapOvr>
</p:sld>

怎么做好这个库


就像我在我的掘金小册中说的那样,做前端开发,首先要做的就是设计,必须先编写设计文档,然后再开发,现在我也是这么做的。


第一步:分析pptx中每个xml的含义


ppt-design1.png


第二步:整体架构设计


我把这个库分成了三层(我在小册中提到的分层思维)


pptx-jiagou.png



  • PPTX Reader层:负责读取pptx中的内容,将其转为便于理解的格式,也就是自己定义的PPTX的对象

  • PPTX Render层:负责进行pptx单个幻灯片的渲染,入参为上一步得到的PPTX对象,不同的渲染方式实现不同的渲染对象,比如我们可以开发一个HtmlRender,将其渲染成为html格式,或者开发一个Canvas Render将其渲染成为Canvas,而不是写死,这样扩展性也更好一些(小册中提到的前端扩展方法)

  • PPTX Preview层:负责整个pptx文件的预览,比如是采用左右翻页展示还是一下把pptx的幻灯片都展示出来,都由这个层来决定。


其中文件读取是非常复杂的,面对这种复杂的大型项目,必须考虑采用面向对象的方式来组织代码(也是小册中提到的),我将 PPTX Reader层细化为如下几个类。



  • PPTX: pptx类,存储pptx文档的信息,比如缩略图,尺寸大小等信息

  • Theme: 主题类,存储pptx的主题信息

  • Slide: 单个幻灯片类,存储幻灯片信息

  • PicNode:图片类,用它表示幻灯片中的一个图片

  • ShapeNode:形状类,用它表示幻灯片中的一个一个形状

  • Node:不同节点的基类

  • ...


pptx-class.png


第三步:搭建代码仓库


这次决定还是采用monorepo方式组织代码,其中技术栈包括 turbo + ts + jest单测 + rollup打包 + eslint 等。


目前进展


目前正在开发 PPTX Reader 层的相关代码,争取元旦前完成PPTX中基础功能的预览,有什么心得和进展随时给大家同步。


感兴趣的同学可以关注我或者仓库,小册近期也要上线了,到时候大家多关注支持。


pptx-preview


作者:前端林叔
来源:juejin.cn/post/7418389059287908404
收起阅读 »

为什么使用fetch时,有两个await?

web
为什么使用fetch时,有两个await? 提问 // first await let response = await fetch("/some-url") // second await let myObject = await response.json...
继续阅读 »

为什么使用fetch时,有两个await?


提问


// first await
let response = await fetch("/some-url")
// second await
let myObject = await response.json()

你以前在使用fetch时,见过这两个await对吗?


有没有思考过,这是为什么?


思考


我们在浏览器中使用异步编程来,处理需要时间才能完成的任务(也就是异步任务),这样我们就不会阻塞用户界面。


等待 fetch 是有道理的。因为我们最好不要阻止 UI!


但是,我们到底为什么需要呢 await response.json()


解析 JSON 应该不会花费很长时间。 事实上,我们经常调用 JSON.parse("{"key": "value"}") ,这是一个同步调用。 那么,为什么 response.json() 返回 promise 而不是我们真正想要的呢?


这是怎么回事?


摘自 MDN 关于 Fetch API 的文章


https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#concepts_and_usage


fetch() 方法接受一个强制性参数,即你想要获取的资源的路径。 它返回一个 Promise,该 Promise 解析为该请求的 Response — 只要服务器使用 Headers 响应 — 即使服务器响应是 HTTP 错误状态。



因此,fetch 会在必须完全接收 body 之前解析响应。


查看前面的代码:


let response = await fetch("/some-url")

// At this point,
// 1. the client has received the headers
// 2. the body is (probably) still making its way over.

let myObject = await response.json()

// At this point,
// 1. the client has received the body
// 2. the client has tried to parse the body
// as json with the goal of making a JavaScript object

很明显,在整个正文到达之前访问 headers 是多么有用。


根据状态代码或其中一个标头,我们可能会决定根本不读取正文。


而 body 在 headers 之后到达实际上是我们已经习惯的。这就是浏览器中一切的工作方式。


HTML 通过网络缓慢发送,图像、字体等也是如此。


我想我只是被方法的名称弄糊涂了: response.json() .


有一个简单的节点服务器来演示这一点。相关代码都在这里


github.com/tom-on-the-…


YouTube视频演示在这


http://www.youtube.com/watch?v=Ki6…


原文地址 tomontheinternet.com/why-two-awa…


作者:龙阡
来源:juejin.cn/post/7432269413405769762
收起阅读 »

性能对比:为什么 Set.has() 比 Array.includes() 更快?

web
在 JavaScript 开发中,检查某个元素是否存在于集合中是一个常见的操作。对于这个任务,我们通常会使用两种方法:Set.has() 和 Array.includes()。尽管它们都能实现查找功能,但在性能上存在显著差异。今天我们就来探讨一下,为什么 Se...
继续阅读 »

JavaScript 开发中,检查某个元素是否存在于集合中是一个常见的操作。对于这个任务,我们通常会使用两种方法:Set.has()Array.includes()。尽管它们都能实现查找功能,但在性能上存在显著差异。今天我们就来探讨一下,为什么 Set.has() 通常比 Array.includes() 更快,特别是在查找大量元素时。




  1. 数据结构的差异:Set vs Array


    首先,要理解性能差异,我们需要了解 SetArrayJavaScript 中的底层实现原理。它们使用了不同的数据结构,这对查找操作的效率有着直接影响。




    1. Set:哈希表的魔力

      Set 是一种集合数据结构,旨在存储唯一的值。JavaScript 中的 Set 通常使用 哈希表 来实现。在哈希表中,每个元素都有一个唯一的哈希值,这个哈希值用于快速定位和访问该元素。这意味着,当我们使用 Set.has() 来检查某个元素时,JS 引擎能够直接计算该元素的哈希值,从而迅速确定元素是否存在。查找操作的时间复杂度是 O(1) ,即无论集合中有多少个元素,查找的时间几乎是恒定的。



    2. Array:顺序遍历

      Set 不同,Array 是一种有序的列表结构,元素按插入顺序排列。在数组中查找元素时,Array.includes() 方法必须遍历数组的每一个元素,直到找到目标元素或确认元素不存在。这样,查找操作的时间复杂度是 O(n) ,其中 n 是数组中元素的个数。也就是说,随着数组中元素数量的增加,查找所需的时间将线性增长。





  2. 性能差异:什么时候该用哪个?


    在实际开发中,我们通常会选择根据数据的特性来选择 Set.has()Array.includes()。但是,理解它们的性能差异有助于我们做出更加明智的决策。




    1. 小型数据集

      对于较小的集合,性能差异可能不那么明显。在这种情况下,无论是 Set.has() 还是 Array.includes(),都能以接近常数时间完成操作,因为数据集本身就很小。因此,在小数据集的情况下,开发者更关心的是易用性和代码的简洁性,而不是性能。


      例如,以下是对小型数据集的查找操作:


      // 小型数据集
      const smallSet = new Set([1, 2, 3, 4, 5]);
      console.log(smallSet.has(3));  // true

      const smallArray = [1, 2, 3, 4, 5];
      console.log(smallArray.includes(3));  // true

      在这个示例中,Set.has()Array.includes() 都能快速找到元素 3,两者的性能差异几乎不明显。


      image-20241105095232819


      Set.has(Code 1)和 Array.includes(Code 2)代码性能分析。数据来源:CodePerf



    2. 大型数据集

      当数据集变得更大时,Set.has() 的优势变得尤为明显。如果我们使用 Array.includes() 在一个包含上百万个元素的数组中查找一个目标元素,时间复杂度将变为 O(n) ,查找时间会随着数组的大小而增长。


      Set.has() 在面对大数据集时,性能依然保持在 O(1) ,因为它利用了哈希表的高效查找特性。下面是两个在大数据集下性能对比的例子:


      // 大型数据集
      const largeArray = Array.from({ length: 1000000 }, (_, i) => i);
      const largeSet = new Set(largeArray);

      const valueToFind = 999999;

      console.time("Set.has");
      console.log(largeSet.has(valueToFind));  // true
      console.timeEnd("Set.has");

      console.time("Array.includes");
      console.log(largeArray.includes(valueToFind));  // true
      console.timeEnd("Array.includes");

      在这个例子中,当数据集非常大时,Set.has() 显示了明显的性能优势,而 Array.includes() 的执行时间会随着数组的大小而显著增加。


      BIG


      Set.has(Code 1)和 Array.includes(Code 2)代码性能分析。数据来源:CodePerf



    3. 重复元素的影响

      Set 本身就是一个集合,只允许存储唯一的元素,因此它天然会去除重复的元素。如果你在一个包含大量重复元素的数组中查找某个值,使用 Set 可以提高性能。因为在将数组转换为 Set 后,我们不必担心查找操作的冗余计算。


      // 数组中有重复元素
      const arrayWithDuplicates = [1, 2, 3, 1, 2, 3];
      const uniqueSet = new Set(arrayWithDuplicates);

      // 使用 Set 查找
      console.log(uniqueSet.has(2));  // true





  3. 何时选择 Array.includes()


    尽管 Set.has() 在查找时的性能更优,但这并不意味着 Array.includes() 就没有用武之地。对于小型数据集、对顺序有要求或需要保留重复元素的场景,Array.includes() 仍然是一个非常合适的选择。例如,数组保持元素的插入顺序,或者你需要查找重复元素时,数组仍然是首选。



  4. 总结



    1. Set.has() 性能较好,特别是在处理大型数据集时,其查找时间接近 O(1)

    2. Array.includes() 在小型数据集或元素顺序敏感时可以正常工作,但随着数据量的增加,其时间复杂度为 O(n)

    3. 在需要频繁查找元素且数据量较大的情况下,建议使用 Set

    4. 对于较小数据集或有顺序要求的操作,Array.includes() 仍然是一个合适的选择。

    5. 因为构造 Set 的过程本身就是遍历的过程,所以如果只用来查询一次的话,可以使用 Array.includes()。但如果需要频繁查询,则建议使用 Set,尤其是在处理较大的数据集时,性能优势更加明显。


    通过理解这两种方法的性能差异,我们可以在编写 JavaScript 程序时更加高效地处理数据查找操作,选择合适的数据结构来提升应用的性能。



作者:一点一木
来源:juejin.cn/post/7433458585147342882
收起阅读 »

前端啊,拿Lottie炫个动画吧

web
点赞 + 关注 + 收藏 = 学会了 本文简介 有时候在网页上看到一些很炫酷的小动画,比如loading特效,还能控制这个动画的状态,真的觉得很神奇。 大部分做后端的不想碰前端,做前端的不想碰动画特效。 其实啊,很多时候不需要自己写炫酷的特效,会调用第三方库已...
继续阅读 »

点赞 + 关注 + 收藏 = 学会了


本文简介


有时候在网页上看到一些很炫酷的小动画,比如loading特效,还能控制这个动画的状态,真的觉得很神奇。


大部分做后端的不想碰前端,做前端的不想碰动画特效。


其实啊,很多时候不需要自己写炫酷的特效,会调用第三方库已经挺厉害的了。比如今天要介绍的 Lottie。


01.gif


Lottie 是什么?



🔗Lottie官网 airbnb.io/lottie/



Lottie 是一个适用于 Android、iOS、Web 和 Windows 的库,它可以解析使用 Bodymovin 导出为 JSON 的 Adobe After Effects 动画,并在移动设备和 Web 上本地渲染它们!


After Effects 是什么?Bodymovin 又是什么?


别怕,这些我也不会。作为前端,我会拿别人做好的东西来用😁


简单来说,Lottie 是 Airbnb 开发的动画库,特别适合前端开发人员。它可以轻松实现复杂的动画效果,不需要手写大量代码,只需引入现成的 JSON 文件即可。


今天不讲iOS,不讲Android,只讲如何在前端使用 Lottie。


安装 Lottie Web


要在前端项目中使用 Lottie,要么用 CDN 的方式引入,要么通过 NPM 下载。


CDN


在这个网址可以找到 Lottie 的各个版本的JS文件: cdnjs.com/libraries/b…


02.png


我使用的是 5.12.2 这个版本


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#lottie {
width: 200px;
height: 200px;
}
</style>
</head>
<body>
<div id="lottie"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
<script>
var animation = lottie.loadAnimation({
container: document.getElementById('lottie'), // 渲染动画的容器
renderer: 'svg', // 渲染方式
loop: true, // 是否循环
autoplay: true, // 是否自动播放
path: './Animation_1.json' // 动画 JSON 文件的路径
});
</script>
</body>
</html>

Animation_1.json 是我下载的一个动画文件,这个文件我放在同级目录里。这个动画文件在哪可以下载我接下来会介绍。这里先了解一下 CDN 的方式怎么引入 Lottie 即可。


NPM


用下面这个命令将 Lottie 下载到你的项目里。


npm install lottie-web

动画资源下载


前面介绍到,动画是用 AE 做好,然后用 Bodymovin 插件将动画转换成一个 JSON 文件,前端就可以使用 lottie-web 将这个 JSON 文件的内容转换成图像渲染到浏览器页面上。


如果想要现成的动画资源可以在这些地方找找



我这里也给大家准备了一个动画文件,大家可以拿它来练手。



实现第一个 Lottie 动画


我通过 React 脚手架创建了一个 React 项目来举例说明如何使用 Lottie,在 Vue 里的用法也是一样的。


03.gif


import React, { useEffect, useRef } from 'react';
import lottie from 'lottie-web';
import animationData from './assets/animations/Animation.json';

function App() {
const containerRef = useRef(null);

useEffect(() => {
const anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
animationData: animationData
});
}, []);

return <div ref={containerRef} style={{width: "300px", height: "300px"}}></div>;
}

export default App;


在 HTML 文件中,创建一个容器,用于放置 Lottie 动画。在这个例子中我创建了一个宽和高都是 300pxdiv 元素。


然后引入 lottie-web 以及放在前端项目里的 Animation.json 动画文件。


最后调用 lottie.loadAnimation() 来启动动画。它将一个对象作为唯一参数。



  • container:动画容器,这个例子通过 React 提供的语法获取到 DOM 元素。

  • renderer:渲染方式,可选 svgcanvashtml

  • loop:是否循环播放。

  • autoplay:是否自动播放。

  • animationData:本地的动画数据的对象。


这里需要注意,animationData 接收的动画对象是存放在前端项目的 JSON 文件,如果你的动画文件是存在别的服务器,需要通过一个 URL 引入的话就不能用 animationData 来接收了,而是要改成 path


const anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'https://lottie.host/68bd36a3-b21d-4909-9b61-9be6b0947943/gInO8owFG1.json'
});

Lottie 常用功能


播放、暂停、停止


控制动画的播放、暂停、停止是很常用的功能。



  • 播放:使用 play() 方法。顾名思义就是让动画动起来。

  • 暂停:使用 pause()方法。暂停可以让动画在当前帧停下来。可以这么理解,你在看视频一个10秒的短视频,播放到第7秒的时候你按了“暂停”,画面就停在第7秒的地方了。

  • 停止:使用 stop() 方法。停止和暂停都是让动画停下来,而停止会让动画返回第1帧画面的地方停下来。


04.gif


import lottie from 'lottie-web';
import React, { useEffect, useRef } from 'react';
import animationData from './assets/animations/Animation.json';

function App() {
const containerRef = useRef(null);

let anim = null

useEffect(() => {

anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
animationData: animationData,
});
}, []);

// 播放动画
function play() {
anim.play()
}

// 暂停动画
function pause() {
anim.pause()
}

// 停止动画
function stop() {
anim.stop()
}

return <>
<div ref={containerRef} style={{width: "300px", height: "300px"}}></div>
<button onClick={play}>播放</button>
<button onClick={pause}>暂停</button>
<button onClick={stop}>停止</button>
</>;
}

export default App;

代码放这,建议自己运行起来体验一下。


设置动画播放速度


使用 setSpeed() 方法可以设置动画的播放速度,传入一个数字即可。默认的播放速度是1。


05.gif


// 省略部分代码

// 2倍速度播放
anim.setSpeed(2)

这个参数支持正数(包括非整数)、0、负数。



  • 大于1的正数:比默认速度快

  • 大于0小于1:比默认速度慢

  • 0:画面停止在第一帧不动了

  • 小于0大于-1:动画倒放,而且速度比默认值慢

  • -1:动画倒放,速度和默认值一样

  • 小于-1:动画倒放,速度比默认值快


设置动画播放方向


这里说的播放方向指的是「正着放」还是「倒着放」。前面用 setSpeed() 方法可以做到这点。但还有一个叫 setDirection() 的方法也能做到。


setDirection() 接收一个数字参数,这个参数大于等于0时是正着播放,负数时是倒着播放。通常情况下,想倒着播放会传入 -1。


06.gif


// 省略部分代码

anim.setDirection(-1)

看,面是吐出来的。


设置动画进度


通过 goToAndStop() 方法可以控制动画跳转到指定帧或时间并停止。


goToAndStop(value, isFrame) 接收2个参数。



  • value:数值,表示要跳转到的帧数或时间点。

  • isFrame:布尔值,默认为 false。如果设置为 true,则 value 参数表示帧数;如果设置为 false,则 value 参数表示时间(以毫秒为单位)。


07.png


function goToAndStop() {
anim.goToAndStop(1000, false)
}

return <>
<div ref={containerRef} style={{width: "300px", height: "300px"}}></div>
<button onClick={goToAndStop}>跳转到1秒</button>
</>
;

如果 goToAndStop 第二个参数为 true 则表示要跳转到指定帧数,这个值不能超过动画的总帧数。


销毁动画实例


有些场景在某个时刻需要将动画元素删除掉,比如在数据加载时需要显示 loading,数据加载成功或者失败后需要隐藏 loading,此时可以用 destroy 将 Lottie 动画实例销毁掉。


// 省略部分代码

anim.destroy()

动画监听事件


动画有很多个状态,比如动画数据加载完成/失败、动画播放结束、循环下一次播放、进入新的一帧。Lottie 为我们提供了几个常用的监听方法。


而要监听这些事件,需要在 lottie 实例上用 addEventListener 方法绑定各个事件。


动画数据加载情况


监听动画数据(JSON文件)加载成功或者失败,可以用这两个方法。



  • data_ready:数据加载成功后执行。

  • data_failed:数据加载失败后执行。


需要注意,这两个方法只适用 path 的方式加载数据时触发。animationData 加载的是本地数据,并不会触发这两个方法。


// 省略部分代码

let anim = null;

useEffect(() => {
anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'https://lottie.host/68bd36a3-b21d-4909-9b61-9be6b0947943/gInO8owFG1.json'
});

anim.addEventListener('data_ready', () => {
console.log('数据加载完成');
});

anim.addEventListener('data_failed', () => {
console.log('数据加载失败');
})
}, []);

初始配置完成后


在数据加载前,还可以通过 config_ready 监听初始化配置的完成情况。


要让 config_ready 生效,同样需要通过 path 的方式加载数据。


config_ready 的执行顺序排在 data_ready 之前。


// 省略部分代码

let anim = null;

useEffect(() => {
anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'https://lottie.host/68bd36a3-b21d-4909-9b61-9be6b0947943/gInO8owFG1.json'
});

anim.addEventListener('data_ready', () => {
console.log('数据加载完成');
});

anim.addEventListener('config_ready', () => {
console.log('初始化成功');
});
}, []);

动画播放结束


当动画播放结束时,会触发 complete 事件。


如果 looptrue 的话时不会触发 complete 的,因为一直循环的话动画是没有结束的那天。


// 省略部分代码

let anim = null;

useEffect(() => {

anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: false,
autoplay: true,
animationData: animationData,
});

anim.addEventListener('complete', () => {
console.log('动画播完了');
});
}, []);

动画循环播放结束


looptrue 时,每循环播放完一次就会触发 loopComplete 事件。


// 省略部分代码

let anim = null;

useEffect(() => {

anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
animationData: animationData,
});

anim.addEventListener('loopComplete', () => {
console.log('循环结束,准备进入下一次循环');
});
}, []);

当你通过 pause() 暂停了动画,过一阵用 play() 继续播放,也会等这次动画完整播放完才会触发 loopComplete


进入新的一帧


一个动画由很多个画面组成,每个画面都属于1帧。动画每进入一帧时都会触发 enterFrame 事件。


// 省略部分代码

let anim = null;

useEffect(() => {

anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
animationData: animationData,
// path: 'https://lottie.host/68bd36a3-b21d-4909-9b61-9be6b0947943/gInO8owFG1.json'
});

anim.addEventListener('enterFrame', () => {
console.log('进入新帧');
});
}, []);

自己手写一个动画JSON?


手写 Lottie 的 JSON 动画文件相对复杂,因为需要对 Lottie 的 JSON 结构有较深入的理解。Lottie 的 JSON 文件基于 Bodymovin 插件输出的格式,主要包含静态资源、图层、形状以及帧动画信息。


由于相对复杂,所以不建议真的自己手写,这会显得你很傻。


Lottie JSON 文件由多个部分组成,主要包括:



  1. assets:动画中使用的资源(图片等)。

  2. layers:动画中的每一层(类似于 Photoshop 图层)。

  3. shapes:定义图形、路径等基本元素及其动画。

  4. animations:定义每一帧的动画数据,包括位置、缩放、透明度等。


太复杂的元素我确实手写不出来,只能写一个简单的圆形从左向右移动演示一下。


08.gif


{
"v": "5.6.10", // Lottie 版本
"fr": 30, // 帧率 (Frames per second)
"ip": 0, // 动画开始帧 (In Point)
"op": 60, // 动画结束帧 (Out Point)
"w": 500, // 画布宽度
"h": 500, // 画布高度
"nm": "circle animation",// 动画名称
"ddd": 0, // 是否是 3D 动画
"assets": [], // 静态资源(如图片等)
"layers": [ // 动画的图层
{
"ddd": 0, // 图层是否是 3D
"ind": 1, // 图层索引
"ty": 4, // 图层类型,4 代表形状图层
"nm": "circle", // 图层名称
"sr": 1, // 图层的播放速度
"ks": { // 图层的关键帧属性(动画数据)
"o": { // 不透明度动画
"a": 0, // 不透明度动画为 0,表示不设置动画
"k": 100 // 不透明度固定为 100%
},
"r": { // 旋转动画
"a": 0, // 不设置动画
"k": 0 // 旋转角度为 0
},
"p": { // 位置动画 (Position)
"a": 1, // a 为 1 表示位置有动画
"k": [
{
"i": { "x": 0.667, "y": 1 }, // 起始位置插值
"o": { "x": 0.333, "y": 0 }, // 终止位置插值
"n": "0p667_1_0p333_0", // 插值模式名称
"t": 0, // 起始帧
"s": [50, 250, 0], // 起始位置 (x: 50, y: 250)
"e": [450, 250, 0], // 结束位置 (x: 450, y: 250)
"to": [66.66667, 0, 0], // 起始插值控制点
"ti": [-66.66667, 0, 0] // 终止插值控制点
},
{ "t": 60 } // 在 60 帧时结束动画
]
},
"a": { // 锚点动画(用于旋转或缩放中心)
"a": 0,
"k": [0, 0, 0] // 锚点固定在 (0, 0)
},
"s": { // 缩放动画 (Scale)
"a": 0,
"k": [100, 100, 100] // 保持 100% 缩放
}
},
"ao": 0, // 自动定向
"shapes": [ // 图形数组,定义图层中的形状
{
"ty": "el", // 图形类型 'el' 代表 ellipse(椭圆/圆形)
"p": { // 椭圆的中心点
"a": 0,
"k": [0, 0]
},
"s": { // 椭圆的大小
"a": 0,
"k": [100, 100] // 圆的宽和高为 100px
},
"nm": "ellipse"
},
{
"ty": "st", // 图形类型 'st' 代表 stroke(描边)
"c": { // 描边颜色
"a": 0,
"k": [1, 0, 0, 1] // 红色 [R: 1, G: 0, B: 0, Alpha: 1]
},
"o": { // 描边不透明度
"a": 0,
"k": 100
},
"w": { // 描边宽度
"a": 0,
"k": 10
},
"lc": 1, // 线帽样式
"lj": 1, // 线接样式
"ml": 4 // 折线限制
}
],
"ip": 0, // 图层开始帧
"op": 60, // 图层结束帧
"st": 0, // 图层起始时间
"bm": 0 // 混合模式
}
]
}


  • v: 表示 Lottie 动画的版本。

  • fr: 帧率,表示每秒多少帧。在这个示例中,每秒播放 30 帧。

  • ipop: 分别代表动画的起始帧和结束帧。本例中,动画从第 0 帧开始,到第 60 帧结束。

  • layers: 图层数组。每个图层包含 ks (关键帧属性),用于控制位置、缩放、旋转等动画参数。



    • ty: 4: 图层类型为形状图层。

    • p: 定义了位置动画,从帧 0 开始,圆形从 (50, 250) 移动到 (450, 250) 的位置,表示从画布左侧移动到右侧。



  • shapes: 定义了图形的属性。



    • el: 表示一个椭圆形,即我们定义的圆形。

    • st: 表示圆形的描边,颜色为红色,宽度为 10px。






以上就是本文的全部内容,如果本文对你有帮助,欢迎转发给你的朋友。


IMG_4387 2.GIF


点赞 + 关注 + 收藏 = 学会了


作者:德育处主任
来源:juejin.cn/post/7430690608711647232
收起阅读 »

什么年代了?还不懂为什么一定要在团队项目开发中去使用 TypeScript ?

web
为什么要去使用 TypeScript ? 一直以来 TypeScript 的存在都备受争议,很多人认为他加重了前端开发的负担,特别是在它的严格类型系统和 JavaScript 的灵活性之间的矛盾上引发了不少讨论。 支持者认为 TypeScript 提供了强类型...
继续阅读 »

image.png


为什么要去使用 TypeScript ?


一直以来 TypeScript 的存在都备受争议,很多人认为他加重了前端开发的负担,特别是在它的严格类型系统和 JavaScript 的灵活性之间的矛盾上引发了不少讨论。


支持者认为 TypeScript 提供了强类型检查、丰富的 IDE 支持和更好的代码重构能力,从而提高了大型项目的代码质量和可维护性。


然而,也有很多开发者认为 TypeScript 加重了开发负担,带来了不必要的复杂性,尤其是在小型项目或快速开发场景中,它的严格类型系统可能显得过于繁琐,限制了 JavaScript 本身的动态和自由特性


但是随着项目规模的增大和团队协作的复杂性增加,TypeScript 的优势也更加明显。因为你不可能指望团队中所有人的知识层次和开发习惯都达到同一水准!你也不可能保证团队中的其他人都能够完全正确的使用你封装的组件、函数!



在大型项目中我们往往会封装到很多工具函数、组件等等,我们不可能在使用到组件时跑去看这个组件的实现逻辑,而 TypeScript 的类型提示正好弥补了这一点。通过明确的类型注解,TypeScript 可以在代码中直接提示每个组件的输入输出、参数类型和预期结果,让开发者只需在 IDE 中悬停或查看提示信息,就能了解组件的用途和使用方式,而不需要翻阅具体实现逻辑。


这时你可能会说,使用 JSDoc 也能够实现类似的效果。的确,JSDoc 可以通过注释的形式对函数、参数、返回值等信息进行详细描述,甚至可以生成文档。


然而,JSDoc 依赖于开发者的自觉维护,且其检查和提示能力远不如 TypeScript 强大和全面。TypeScript 的类型系统是在编译阶段强制执行的,这意味着所有类型定义都是真正的 “硬性约束”,能在代码运行前捕获错误,而不仅仅是提示。


在实际开发中,JSDoc 的确能让我们知道参数类型,但它只是一种 “约定” ,而不是真正的约束。这意味着,如果同事在使用工具函数时不小心写错了类型,比如传了字符串而不是数字,JSDoc 只能通过注释告诉你正确的使用方法,却无法在你出错时立即给出警告。


然而在 TypeScript 中,类型系统会在代码编写阶段实时检查。比如,你定义的函数要求传入数字类型的参数,如果有人传入了字符串,IDE 立刻会报错提醒你,防止错误进一步传播。


所以,TypeScript 的价值就在于它提供了一层代码保护,让代码有了“硬约束”,团队在开发过程中更加节省心智负担,显著提升开发体验和生产力,少出错、更高效。



接下来我们来使用 TypeScript 写一个基础的防抖函数作为示例。通过类型定义和参数注解,我们不仅能让防抖函数更加通用且类型安全,还能充分利用 TypeScript 的类型检查优势,从而提高代码的可读性和可维护性。


这样的实现方式将有效地降低潜在的运行时错误,特别是在大型项目中,可以使团队成员之间的协作能够更加顺畅,并且避免一些低级问题。


 

image.png




功能点讲解


防抖函数的主要功能是:在指定的延迟时间内,如果函数多次调用,只有最后一次调用会生效。这一功能尤其适合优化用户输入等高频事件。


防抖函数的核心功能



  1. 函数执行的延迟控制:函数调用后不立即执行,而是等待一段时间。如果在等待期间再次调用函数,之前的等待会被取消,重新计时。

  2. 立即执行选项:有时我们希望函数在第一次调用时立即执行,然后在延迟时间内避免再次调用。

  3. 取消功能:我们还希望在某些情况下手动取消延迟执行的函数,比如当页面卸载或需要重新初始化时。




第一步:编写函数框架


在开始封装防抖函数之前,我们首先应该想到的就是要写一个函数,假设这个函数名叫 debounce。我们先创建它的基本框架:


function debounce() {
// 函数的逻辑将在这里编写
}

这一步非常简单,先定义一个空函数,这个函数就是我们的防抖函数。在后续步骤中,我们会逐步向这个函数中添加功能。


第二步:添加基本的参数


防抖函数的第一个功能是控制某个函数的执行,因此,我们需要传递一个需要防抖的函数。其次,防抖功能依赖于一个延迟时间,这意味着我们还需要添加一个用于设置延迟的参数。


让我们扩展一下 debounce 函数,为它添加两个基本的参数:



  1. func:需要防抖的目标函数。

  2. duration:防抖的延迟时间,单位是毫秒。


function debounce(func: Function, duration: number) {
// 函数的逻辑将在这里编写
}


  • func 是需要防抖的函数。每当防抖函数被调用时,我们实际上是在控制这个 func 函数的执行。

  • duration 是延迟时间。这个参数控制了在多长时间后执行目标函数


第三步:为防抖功能引入定时器逻辑


防抖的核心逻辑就是通过定时器setTimeout),让函数执行延后。那么我们需要用一个变量来保存这个定时器,以便在函数多次调用时可以取消之前的定时器。


function debounce(func: Function, duration: number) {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
}


  • let timer: ReturnType<typeof setTimeout> | null = null:我们使用一个变量 timer 来存储定时器的返回值。

  • clearTimeout(timer):每次调用防抖函数时,都会清除之前的定时器,这样就保证了函数不会被立即执行,直到等待时间结束。

  • setTimeout:在指定的延迟时间后执行传入的目标函数 func,并传递原始参数。


为什么写成了 ReturnType<typeof setTimeout> | null 这样的类型 ?


JavaScript 中,setTimeout 是一个内置函数,用来设置一个延迟执行的任务。它的基本语法如下:


let id = setTimeout(() => {
console.log("Hello, world!");
}, 1000);

setTimeout 返回一个定时器 ID(在浏览器中是一个数字),这个 ID 用来唯一标识这个定时器。如果你想取消定时器,你可以使用 clearTimeout(id),其中 id 就是这个返回的定时器 ID。



ReturnType<T> 是 TypeScript 提供的一个工具类型,它的作用是帮助我们获取某个函数类型的返回值类型。我们通过泛型 T 来传入一个函数类型,然后 ReturnType<T> 就会返回这个函数的返回值类型。在这里我们可以用它来获取 setTimeout 函数的返回类型。



为什么需要使用 ReturnType<typeof setTimeout>


 

由于不同的 JavaScript 运行环境中,setTimeout 的返回值类型是不同的:



  • 浏览器中,setTimeout 返回的是一个数字 ID

  • Node.js 中,setTimeout 返回的是一个对象(Timeout 对象)。


为了兼容不同的环境,我们需要用 ReturnType<typeof setTimeout> 来动态获取 setTimeout 返回的类型,而不是手动指定类型(比如 numberTimeout)。


let timer: ReturnType<typeof setTimeout>;

这里 ReturnType<typeof setTimeout> 表示我们根据 setTimeout 的返回值类型自动推导出变量 timer 的类型,不管是数字(浏览器)还是对象(Node.js),TypeScript 会自动处理。



为什么需要设置联合类型 | null



在我们的防抖函数实现中,定时器 timer 并不是一开始就设置好的。我们需要在每次调用防抖函数时动态设置定时器,所以初始状态下,timer 的值应该是 null



使用 | null 表示联合类型,它允许 timer 变量既可以是 setTimeout 返回的值,也可以是 null,表示目前还没有设置定时器。


let timer: ReturnType<typeof setTimeout> | null = null;


  • ReturnType<typeof setTimeout>:表示 timer 可以是 setTimeout 返回的定时器 ID。

  • | null:表示在初始状态下,timer 没有定时器,它的值为 null


第四步:返回一个新函数


在防抖函数 debounce 中,我们希望当它被调用时,返回一个新的函数。这是防抖函数的核心机制,因为每次调用返回的新函数,实际上是在控制目标函数 func 的执行。



具体的想法是这样的:我们并不直接执行传入的目标函数 func,而是返回一个新函数,这个新函数在被调用时会受到防抖的控制。


因此,我们要修改 debounce 函数,使它返回一个新的函数,真正控制 func 的执行时机。


function debounce(func: Function, duration: number) {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量

return function () {
// 防抖逻辑将在这里编写
};
}


  • 返回新函数:当 debounce 被调用时,它返回一个新函数。这个新函数是每次调用时执行防抖逻辑的入口。

  • 为什么返回新函数? :因为我们需要在每次事件触发时(例如用户输入时)执行防抖操作,而不是直接执行传入的目标函数 func


第五步:清除之前的定时器


为了实现防抖功能,每次调用返回的新函数时,我们需要先清除之前的定时器。如果之前有一个定时器在等待执行目标函数,我们应该将其取消,然后重新设置一个新的定时器。



这个步骤的关键就是使用 clearTimeout(timer)


function debounce(func: Function, duration: number) {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量

return function () {
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}

// 下面将设置新的定时器
};
}


  • if (timer) :我们检查 timer 是否有值。如果它有值,说明之前的定时器还在等待执行,我们需要将其清除。

  • clearTimeout(timer) :这就是清除之前的定时器,防止之前的调用被执行。这个操作非常关键,因为它确保了只有最后一次调用(在延迟时间后)才会真正触发目标函数。


第六步:设置新的定时器


现在我们需要在每次调用返回的新函数时,重新设置一个新的定时器,让它在指定的延迟时间 duration 之后执行目标函数 func



这时候就要使用 setTimeout 来设置定时器,并在延迟时间后执行目标函数。


function debounce(func: Function, duration: number) {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量

return function () {
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}

timer = setTimeout(() => {
func(); // 延迟后调用目标函数
}, duration);
};
}


  • setTimeout:我们使用 setTimeout 来设置一个新的定时器,定时器将在 duration 毫秒后执行传入的目标函数 func

  • func() :这是目标函数的实际执行点。定时器到达延迟时间时,它会执行目标函数 func

  • timer = setTimeout(...) :我们将定时器的 ID 存储在 timer 变量中,以便后续可以使用 clearTimeout(timer) 来清除定时器。


第七步:支持参数传递


接下来是让这个防抖函数能够接受参数,并将这些参数传递给目标函数 func



为了实现这个功能,我们需要用到 ...args 来捕获所有传入的参数,并在执行目标函数时将这些参数传递过去。


function debounce(func: Function, duration: number) {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量

return function (...args: any[]) { // 接收传入的参数
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}

timer = setTimeout(() => {
func(...args); // 延迟后调用目标函数,并传递参数
}, duration);
};
}


  • ...args: any[] :这表示新函数可以接收任意数量的参数,并将这些参数存储在 args 数组中。

  • func(...args) :当定时器到达延迟时间后,调用目标函数 func,并将 args 中的所有参数传递给它。这确保了目标函数能接收到我们传入的所有参数。


到这里,我们一个基本的防抖函数的实现。这个防抖函数实现了以下基本功能:



  1. 函数执行的延迟控制:每次调用时,都重新设置定时器,确保函数不会立即执行,而是在延迟结束后才执行。

  2. 多参数支持:通过 ...args,防抖函数能够接收多个参数,并将它们传递给目标函数。

  3. 清除之前的定时器:在每次调用时,如果定时器已经存在,先清除之前的定时器,确保只有最后一次调用才会生效。


但是,这样就完了吗?



在当前的实现中,debounce 函数的定义是 debounce(func: Function, duration: number),其中 func: Function 用来表示目标函数。这种定义虽然可以工作,但它存在明显的缺陷和不足之处,尤其是在 TypeScript 强调类型安全的情况下。



缺陷 1:缺乏参数类型检查


Function 是一种非常宽泛的类型,它允许目标函数接收任何类型、任意数量的参数。因此定义目标函数 funcFunction 类型意味着 TypeScript 无法对目标函数的参数类型进行任何检查。


const debounced = debounce((a: number, b: number) => {
console.log(a + b);
}, 200);

debounced("hello", "world"); // 这里不会报错,参数类型不匹配,但仍会被调用

在这个例子中,我们定义了一个目标函数,期望它接受两个数字类型的参数,但在实际调用时却传入了两个字符串。



这种情况下 TypeScript 不会提示任何错误,因为 Function 类型没有对参数类型进行限制。这种类型检查的缺失可能导致运行时错误或者逻辑上的错误。


缺陷 2:返回值类型不安全


同样,定义 funcFunction 类型时,TypeScript 无法推断目标函数的返回值类型。这意味着防抖函数不能保证目标函数的返回值是符合预期的类型,可能导致返回值在其他地方被错误使用。


const debounced = debounce(() => {
return "result";
}, 200);

const result = debounced(); // TypeScript 不知道返回值类型,认为是 undefined

image.png



在这个例子中,虽然目标函数明确返回了一个字符串 "result",但 debounced 函数的返回值类型未被推断出来,因此 TypeScript 会认为它的返回值是 voidundefined,即使目标函数实际上返回了 string


缺陷 3:缺乏目标函数的签名限制


由于 Function 类型允许任何形式的函数,因此 TypeScript 也无法检查目标函数的参数个数和类型是否匹配。这种情况下,如果防抖函数返回的新函数接收了错误数量或类型的参数,可能导致函数行为异常或意外的运行时错误。


const debounced = debounce((a: number) => {
console.log(a);
}, 200);

debounced(1, 2, 3); // TypeScript 不会报错,但多余的参数不会被使用

虽然目标函数只期望接收一个参数,但在调用时传入了多个参数。TypeScript 不会进行任何警告或报错,因为 Function 类型允许这种宽泛的调用,这可能会导致开发者误以为这些参数被使用。


总结 func: Function 的缺陷



  • 缺乏参数类型检查任何数量、任意类型的参数都可以传递给目标函数,导致潜在的参数类型错误。

  • 返回值类型不安全目标函数的返回值类型无法被推断,导致 TypeScript 无法确保返回值的类型正确。

  • 函数签名不受限制没有对目标函数的参数个数和类型进行检查,容易导致逻辑错误或参数使用不当。


这些缺陷使得代码在类型安全性和健壮性上存在不足,可能导致运行时错误或者隐藏的逻辑漏洞。


下一步的改进


为了解决这些缺陷,我们可以通过泛型的方式为目标函数添加类型限制,确保目标函数的参数和返回值类型都能被准确地推断和检查。这会是我们接下来要进行的优化。


第八步:使用泛型优化


为了克服 func: Function 带来的缺陷,我们可以通过 泛型 来优化防抖函数的类型定义,确保目标函数的参数和返回值都能在编译时进行类型检查。使用泛型不仅可以解决参数类型和返回值类型的检查问题,还可以提升代码的灵活性和安全性。


如何使用泛型进行优化?


我们将通过引入两个泛型参数来改进防抖函数的类型定义:



  1. A:表示目标函数的参数类型,可以是任意类型和数量的参数,确保防抖函数在接收参数时能进行类型检查。

  2. R:表示目标函数的返回值类型,确保防抖函数返回的值与目标函数一致。


function debounce<A extends any[], R>(
func: (...args: A) => R, // 使用泛型 A 表示参数,R 表示返回值类型
duration: number // 延迟时间,以毫秒为单位
): (...args: A) => R { // 返回新函数,参数类型与目标函数相同,返回值类型为 R
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
let lastResult: R; // 存储目标函数的返回值

return function (...args: A): R { // 返回的新函数,参数类型由 A 推断
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}

timer = setTimeout(() => {
lastResult = func(...args); // 延迟后调用目标函数,并存储返回值
}, duration);

return lastResult; // 返回上一次执行的结果,如果尚未执行则返回 undefined
};
}


  1. A extends any[]A 表示目标函数的参数类型,A 是一个数组类型,能够适应目标函数接收多个参数的场景。通过泛型,防抖函数能够根据目标函数的签名推断出参数类型并进行检查。

  2. RR 表示目标函数的返回值类型,防抖函数能够确保返回值类型与目标函数一致。如果目标函数返回值类型为 string,防抖函数也会返回 string,这样可以防止返回值类型不匹配。

  3. lastResult:用来存储目标函数的最后一次返回值。每次调用目标函数时会更新 lastResult,并在调用时返回上一次执行的结果,确保防抖函数返回正确的返回值。


泛型优化后的优点:



  1. 类型安全的参数传递

    通过泛型 A,防抖函数可以根据目标函数的签名进行类型检查,确保传入的参数与目标函数一致,避免参数类型错误。


    const debounced1 = debounce((a: number, b: string) => {
    console.log(a, b);
    }, 300);

    debounced1(42, "hello"); // 正确,参数类型匹配
    debounced1("42", 42); // 错误,类型不匹配

    image.png


  2. 返回值类型安全

    泛型 R 确保了防抖函数的返回值与目标函数的返回值类型一致,防止不匹配的类型被返回。


    const debounced = debounce(() => {
    return "result";
    }, 200);

    const result = debounced(); // 返回值为 string
    console.log(result); // 输出 "result"

    image.png


  3. 支持多参数传递

    泛型 A 表示参数类型数组,这意味着目标函数可以接收多个参数,防抖函数会将这些参数正确传递给目标函数。而如果防抖函数返回的新函数接收了错误数量或类型的参数,会直接报错提示。


    const debounced = debounce((name: string, age: number) => {
    return `${name} is ${age} years old.`;
    }, 300);

    const result = debounced("Alice", 30);
    console.log(result); // 输出 "Alice is 30 years old."

    image.png



第九步:添加 cancel 方法并处理返回值类型


在前面的步骤中,我们已经实现了一个可以延迟执行的防抖函数,并且支持参数传递和返回目标函数的结果。



但是,由于防抖函数的执行是异步延迟的,因此在初次调用时,防抖函数可能无法立即返回结果。因此函数的返回值我们需要使用 undefined 来表示目标函数的返回结果可能出现还没生成的情况。



除此之外,我们还要为防抖函数添加一个 cancel 方法,用于手动取消防抖的延迟执行。



为什么需要 cancel 方法?



在一些场景下,可能需要手动取消防抖操作,例如:



  • 用户取消了操作,不希望目标函数再执行。

  • 某个事件或操作已经不再需要处理,因此需要取消延迟中的函数调用。


为了解决这些需求,cancel 方法可以帮助我们在定时器还未触发时,清除定时器并停止目标函数的执行。


// 定义带有 cancel 方法的防抖函数类型
type DebouncedFunction<A extends any[], R> = {
(...args: A): R | undefined; // 防抖函数本身,返回值可能为 R 或 undefined
cancel: () => void; // `cancel` 方法,用于手动清除防抖
};

// 实现防抖函数
function debounce<A extends any[], R>(
func: (...args: A) => R, // 泛型 A 表示参数类型,R 表示返回值类型
duration: number // 延迟时间
): DebouncedFunction<A, R> { // 返回带有 cancel 方法的防抖函数
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
let lastResult: R | undefined; // 用于存储目标函数的返回值

// 防抖逻辑的核心函数
const debouncedFn = function (...args: A): R | undefined {
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}

// 设置新的定时器
timer = setTimeout(() => {
lastResult = func(...args); // 延迟后执行目标函数,并存储返回值
}, duration);

// 返回上一次的结果或 undefined
return lastResult;
};

// 添加 `cancel` 方法,用于手动取消防抖
debouncedFn.cancel = function () {
if (timer) {
clearTimeout(timer); // 清除定时器
timer = null; // 重置定时器
}
};

return debouncedFn; // 返回带有 `cancel` 方法的防抖函数
}


  1. 返回值类型 R | undefined



    • R:代表目标函数的返回值类型,例如 numberstring

    • undefined:在防抖函数的首次调用或目标函数尚未执行时,返回 undefined,表示结果尚未生成。

    • lastResult 用于存储目标函数上一次执行的结果,防抖函数在每次调用时会返回该结果,或者在尚未执行时返回 undefined



  2. cancel 方法



    • cancel 方法的作用是清除当前的定时器,防止目标函数在延迟时间结束后被执行。

    • 通过调用 clearTimeout(timer),我们可以停止挂起的防抖操作,并将 timer 重置为 null,表示当前没有挂起的定时器。




让我们来看一个具体的使用示例,展示如何使用防抖函数,并在需要时手动取消操作。


// 定义一个简单的目标函数
const debouncedLog = debounce((message: string) => {
console.log(message);
return message;
}, 300);

// 第一次调用防抖函数,目标函数将在 300 毫秒后执行
debouncedLog("Hello"); // 如果不取消,300ms 后会输出 "Hello"

// 手动取消防抖,目标函数不会执行
debouncedLog.cancel();

在这个示例中:



  1. 调用 debouncedLog("Hello") :会启动一个 300 毫秒的延迟执行,目标函数计划在 300 毫秒后执行,并输出 "Hello"

  2. 调用 debouncedLog.cancel() :会清除定时器,目标函数不会执行,避免了不必要的操作。


第十步:将防抖函数作为工具函数单独放在一个 ts 文件中并添加 JSDoc 注释


在编写好防抖函数之后,下一步是将其作为一个工具函数放入单独的 .ts 文件中,以便在项目中重复使用。同时,我们可以为函数添加详细的 JSDoc 注释,方便使用者了解函数的作用、参数、返回值及用法。


1. 将防抖函数放入单独的文件


首先,我们可以创建一个名为 debounce.ts 的文件,并将防抖函数的代码放在其中。


// debounce.ts

export type DebouncedFunction<A extends any[], R> = {
(...args: A): R | undefined; // 防抖函数本身,返回值可能为 R 或 undefined
cancel: () => void; // `cancel` 方法,用于手动清除防抖
};

/**
* 创建一个防抖函数,确保在最后一次调用后,目标函数只会在指定的延迟时间后执行。
* 防抖函数可以防止某个函数被频繁调用,例如用户输入事件、滚动事件或窗口调整大小等场景。
*
* @template A - 函数接受的参数类型。
* @template R - 函数的返回值类型。
* @param {(...args: A) => R} func - 需要防抖的目标函数。该函数将在延迟时间后执行。
* @param {number} duration - 延迟时间(以毫秒为单位)。在这个时间内,如果再次调用函数,将重新计时。
* @returns {DebouncedFunction<A, R>} 一个防抖后的函数,该函数包括一个 `cancel` 方法用于清除防抖。
*
* @example
* const debouncedLog = debounce((message: string) => {
* console.log(message);
* return message;
* }, 300);
*
* debouncedLog("Hello"); // 300ms 后输出 "Hello"
* debouncedLog.cancel(); // 取消防抖,函数不会执行
*/

export function debounce<A extends any[], R>(
func: (...args: A) => R,
duration: number
): DebouncedFunction<A, R> {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
let lastResult: R | undefined; // 存储目标函数的返回值

const debouncedFn = function (...args: A): R | undefined {
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}

timer = setTimeout(() => {
lastResult = func(...args); // 延迟后执行目标函数,并存储返回值
}, duration);

return lastResult; // 返回上次执行的结果,如果尚未执行则返回 undefined
};

debouncedFn.cancel = function () {
if (timer) {
clearTimeout(timer); // 清除定时器,防止目标函数被执行
timer = null; // 重置定时器
}
};

return debouncedFn;
}

2. 详细的 JSDoc 注释说明


通过添加 JSDoc 注释,能够为函数使用者提供清晰的文档信息,说明防抖函数的功能、参数类型、返回值类型,以及如何使用它。



JSDoc 注释的结构说明



  1. @template A, R:说明泛型 A 是函数接受的参数类型,R 是目标函数的返回值类型。

  2. @param:解释函数的输入参数,说明 func 是目标函数,duration 是防抖的延迟时间。

  3. @returns:说明返回值是一个带有 cancel 方法的防抖函数,函数返回值类型是 R | undefined

  4. @example:为函数提供示例,展示防抖函数的典型用法,包括取消防抖操作。


使用 JSDoc 生成文档


通过在 .ts 文件中添加 JSDoc 注释,可以借助 TypeScript 编辑器或 IDE(如 VSCode/Webstorm)自动生成代码提示和函数文档说明,提升开发体验。



例如,当开发者在使用 debounce 函数时,可以自动看到函数的说明和参数类型提示:



image.png


回顾:泛型防抖函数的最终效果


通过前面各个步骤的优化,我们已经构建了一个类型安全的防抖函数,结合泛型实现了以下关键功能:



  1. 类型安全的参数传递

    通过泛型 A,防抖函数能够根据目标函数的签名进行参数类型检查,确保传入的参数与目标函数的类型一致。如果传入的参数类型不匹配,TypeScript 将在编译时报错,避免运行时的潜在错误。


    const debounced1 = debounce((a: number, b: string) => {
    console.log(a, b);
    }, 300);

    debounced1(42, "hello"); // 正确,参数类型匹配
    debounced1("42", 42); // 错误,类型不匹配

    在上面的例子中,TypeScript 会检查参数类型,确保传入的参数符合预期的类型。错误的参数类型会被及时捕捉。


  2. 返回值类型安全

    泛型 R 确保防抖函数的返回值与目标函数的返回值类型保持一致。TypeScript 可以根据目标函数的返回值类型推断防抖函数的返回值,防止不匹配的类型被返回。


    const debounced = debounce(() => {
    return "result";
    }, 200);

    const result = debounced(); // 返回值为 string
    console.log(result); // 输出 "result"

    image.png



    在这个例子中,debounce 返回的防抖函数的返回值类型为 string 或者 undefind ,因为在防抖函数的实现中,目标函数是延迟执行的,因此在初次调用或在延迟期间debounced 函数返回的结果可能尚未生成,与目标函数的返回值类型预期一致。


  3. 支持多参数传递

    泛型 A 表示目标函数的参数类型数组,这意味着防抖函数可以正确传递多个参数,并确保类型安全。如果传入了错误数量或类型的参数,TypeScript 会提示开发者进行修正。


    const debounced = debounce((name: string, age: number) => {
    return `${name} is ${age} years old.`;
    }, 300);

    const result = debounced("Alice", 30);
    console.log(result); // 输出 "Alice is 30 years old."

    在这个例子中,防抖函数正确地将多个参数传递给目标函数,并输出目标函数的正确返回值。传入的参数数量或类型不正确时,TypeScript 会发出报错提示。





总结


至此,我们完整实现并优化了一个类型安全的防抖函数,并通过泛型确保参数和返回值的类型安全。此外,我们还详细讲解了如何为防抖函数添加 cancel 方法,并处理延迟执行的返回值 R | undefined。最后,我们将防抖函数封装在一个单独的 TypeScript 文件中,并为其添加了 JSDoc 注释,使其成为一个可复用的工具函数。



通过这种方式,防抖函数不仅功能强大,还能在编译时提供类型检查,减少运行时的潜在错误。TypeScript 的类型系统帮助我们提升了代码的安全性和健壮性。



最后,我们给出完整的的代码如下:


// debounce.ts

export type DebouncedFunction<A extends any[], R> = {
(...args: A): R | undefined; // 防抖函数本身,返回值可能为 R 或 undefined
cancel: () => void; // `cancel` 方法,用于手动清除防抖
};

/**
* 创建一个防抖函数,确保在最后一次调用后,目标函数只会在指定的延迟时间后执行。
* 防抖函数可以防止某个函数被频繁调用,例如用户输入事件、滚动事件或窗口调整大小等场景。
*
* @template A - 函数接受的参数类型。
* @template R - 函数的返回值类型。
* @param {(...args: A) => R} func - 需要防抖的目标函数。该函数将在延迟时间后执行。
* @param {number} duration - 延迟时间(以毫秒为单位)。在这个时间内,如果再次调用函数,将重新计时。
* @returns {DebouncedFunction<A, R>} 一个防抖后的函数,该函数包括一个 `cancel` 方法用于清除防抖。
*
* @example
* const debouncedLog = debounce((message: string) => {
* console.log(message);
* return message;
* }, 300);
*
* debouncedLog("Hello"); // 300ms 后输出 "Hello"
* debouncedLog.cancel(); // 取消防抖,函数不会执行
*/

export function debounce<A extends any[], R>(
func: (...args: A) => R,
duration: number
): DebouncedFunction<A, R> {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
let lastResult: R | undefined; // 存储目标函数的返回值

const debouncedFn = function (...args: A): R | undefined {
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}

timer = setTimeout(() => {
lastResult = func(...args); // 延迟后执行目标函数,并存储返回值
}, duration);

return lastResult; // 返回上次执行的结果,如果尚未执行则返回 undefined
};

debouncedFn.cancel = function () {
if (timer) {
clearTimeout(timer); // 清除定时器,防止目标函数被执行
timer = null; // 重置定时器
}
};

return debouncedFn;
}

作者:ImAllen
来源:juejin.cn/post/7431889821168812073
收起阅读 »

为什么一个文件的代码不能超过300行?

web
先说观点:在进行前端开发时,单个文件的代码行数推荐最大不超过300行,而超过1000行的都可以认为是垃圾代码,需要进行重构。 为什么是300 当然,这不是一个完全精准的数字,你一个页面301行也并不是什么犯天条的大罪,只是一般情况下,300行以下的代码可读性会...
继续阅读 »

先说观点:在进行前端开发时,单个文件的代码行数推荐最大不超过300行,而超过1000行的都可以认为是垃圾代码,需要进行重构。


为什么是300


当然,这不是一个完全精准的数字,你一个页面301行也并不是什么犯天条的大罪,只是一般情况下,300行以下的代码可读性会更好。


起初,这只是林叔根据自己多年的工作经验拍脑袋拍出来的一个数字,据我观察,常规的页面开发,或者说几乎所有的前端页面开发,在进行合理的组件化拆分后,页面基本上都能保持在300行以下,当然,一个文件20行也并没有什么不妥,这里只是说上限。


但是拍脑袋得出的结论是不能让人信服的,于是林叔突发奇想想做个实验,看看这些开源大佬的源码文件都是多少行,于是我开发了一个小脚本。给定一个第三方的源文件所在目录,读取该目录下所有文件的行数信息,然后统计该库下文件的最长行数、最短行数、平均行数、小于500行/300行/200行/100行的文件占比。


脚本实现如下,感兴趣的可以看一下,不感兴趣的可以跳过看统计结果。统计排除了css样式文件以及测试相关文件。


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

let fileList = []; //存放文件路径
let fileLengthMap = {}; //存放每个文件的行数信息
let result = { //存放统计数据
min: 0,
max: 0,
avg: 0,
lt500: 0,
lt300: 0,
lt200: 0,
lt100: 0
}
//收集所有路径
function collectFiles(sourcePath){
const isFile = function (filePath){
const stats = fs.statSync(filePath);
return stats.isFile()
}
const shouldIgnore = function (filePath){
return filePath.includes("__tests__")
|| filePath.includes("node_modules")
|| filePath.includes("output")
|| filePath.includes("scss")
|| filePath.includes("style")
}
const getFilesOfDir = function (filePath){
return fs.readdirSync(filePath)
.map(file => path.join(filePath, file));
}

//利用while实现树的遍历
let paths = [sourcePath]
while (paths.length){
let fileOrDirPath = paths.shift();
if(shouldIgnore(fileOrDirPath)){
continue;
}
if(isFile(fileOrDirPath)){
fileList.push(fileOrDirPath);
}else{
paths.push(...getFilesOfDir(fileOrDirPath));
}
}

}

//获取每个文件的行数
function readFilesLength(){
fileList.forEach((filePath) => {
const data = fs.readFileSync(filePath, 'utf8');
const lines = data.split('\n').length;
fileLengthMap[filePath] = lines;
})
}

function statisticalMin(){
let min = Infinity;
Object.keys(fileLengthMap).forEach((key) => {
if (min > fileLengthMap[key]) {
min = fileLengthMap[key];
}
})
result.min = min;
}
function statisticalMax() {
let max = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (max < fileLengthMap[key]) {
max = fileLengthMap[key];
}
})
result.max = max;
}
function statisticalAvg() {
let sum = 0;
Object.keys(fileLengthMap).forEach((key) => {
sum += fileLengthMap[key];
})
result.avg = Math.round(sum / Object.keys(fileLengthMap).length);
}
function statisticalLt500() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 500) {
count++;
}
})
result.lt500 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
function statisticalLt300() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 300) {
count++;
}
})
result.lt300 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
function statisticalLt200() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 200) {
count++;
}
})
result.lt200 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
function statisticalLt100() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 100) {
count++;
}
})
result.lt100 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
//统计
function statistics(){
statisticalMin();
statisticalMax();
statisticalAvg();
statisticalLt500();
statisticalLt300();
statisticalLt200();
statisticalLt100();
}

//打印
function print(){
console.log(fileList)
console.log(fileLengthMap)
console.log('最长行数:', result.max);
console.log('最短行数:', result.min);
console.log('平均行数:', result.avg);
console.log('小于500行的文件占比:', result.lt500);
console.log('小于300行的文件占比:', result.lt300);
console.log('小于200行的文件占比:', result.lt200);
console.log('小于100行的文件占比:', result.lt100);
}

function main(path){
collectFiles(path);
readFilesLength();
statistics();
print();
}

main(path.resolve(__dirname,'./vue-main/src'))

利用该脚本我对Vue、React、ElementPlus和Ant Design这四个前端最常用的库进行了统计,结果如下:


小于100行占比小于200行占比小于300行占比小于500行占比平均行数最大行数备注
vue60.8%84.5%92.6%98.0%1121000仅1个模板文件编译的为1000行
react78.0%92.0%94.0%98.0%961341仅1个JSX文件编译的为1341行
element-plus73.6%90.9%95.8%98.875950
ant-design86.9%96.7%98.7%99.5%47722

可以看出95%左右的文件行数都不超过300行,98%的都低于500行,而每个库中超过千行以上的文件最多也只有一个,而且还都是最复杂的模板文件编译相关的代码,我们平时写的业务代码复杂度远远小于这些优秀的库,那我们有什么理由写出那么冗长的代码呢?


从这个数据来看,林叔的判断是正确的,代码行数推荐300行以下,最好不超过500行,禁止超过1000行


为什么不要超过300


现在,请你告诉我,你见过最难维护的代码文件是什么样的?它们有什么特点?


没错,那就是,通常来说,难维护的代码会有3个显著特点:耦合严重、可读性差、代码过长,而代码过长是难以维护的最重要的原因,就算耦合严重、可读性差,只要代码行数不多,我们总还能试着去理解它,但一旦再伴随着代码过长,就超过我们大脑(就像计算机的CPU和内存)的处理上限了,直接死机了。


这是由于我们的生理结构决定的,大脑天然就喜欢简单的事物,讨厌复杂的事物,不信咱们做个小测试,试着读一遍然后记住下面的几个字母:



F H U T L P



怎么样,记住了吗?是不是非常简单,那我们再来看下下面的,还是读一遍然后记住:



J O Q S D R P M B C V X



这次记住了吗?这才12个字母而已,而上千行的代码中,包含各种各样的调用关系、数据结构等,为了搞懂一个功能可能还要跳转好几个函数,这么复杂的信息,是不是对大脑的要求有点过高了。


代码行数过大通常是难以维护的最大原因。


怎么不超过300


现在前端组件化编程这么流行,这么方便,我实在找不出还要写出超大文件的理由,我可以"武断"地说,凡是写出大文件的同学,都缺乏结构化思维和分治思维


面向结构编程,而不是面向细节编程


以比较简单的官网开发为例,喜欢面向细节编程的同学,可能得实现是这样的:


<div>
<div class="header">
<img src="logo.png"/>
<h1>网站名称h1>

div>
<div class="main-content">
<div class="banner">
<ul>
<li><img src="banner1.png">li>

ul>
div>
<div class="about-us">

div>

div>
div>

其中省略了N行代码,通常他们写出的页面都非常的长,光Dom可能都有大几百行,再加上JS逻辑以及CSS样式,轻松超过1000行。


现在假如领导让修改"关于我们"的相关代码,我们来看看是怎么做的:首先从上往下阅读代码,在几千行代码中找到"关于我们"部分的DOM,然后再从几千行代码中找到相关的JS逻辑,这个过程中伴随着鼠标的反复上下滚动,眼睛像扫描仪一样一行行扫描,生怕错过了某行代码,这样的代码维护起来无疑是让人痛苦的。


面向结构开发的同学实现大概是这样的:


<div>
<Header/>
<main>
<Banner/>
<AboutUs/>
<Services/>
<ContactUs/>
main>
<Footer/>
div>

我们首先看到的是页面的结构、骨架,如果领导还是让我们修改"关于我们"的代码,你会怎么做,是不是毫不犹豫地就进入AboutUs组件的实现,无关的信息根本不会干扰到你,而且AboutUs的逻辑都集中在组件内部,也符合高内聚的编程原则。


特别是关于表单的开发,面向细节编程的情况特别严重,也造成表单文件特别容易变成超大文件,比如下面这个图,在一个表单中有十几个表单项,其中有一个选择商品分类的下拉选择框。


form.png


面向细节编程的同学喜欢直接把每个表单项的具体实现,杂糅在表单组件中,大概如下这样:






这还只是一个非常简单的表单项,你看看,就增加了这么多细节,如果是比较复杂点的表单项,其代码就更多了,这么多实现细节混合在这里,你能轻易地搞明白每个表单项的实现吗?你能说清楚这个表单组件的主线任务吗?


面向结构编程的同学会把它抽取为表单项组件,这样表单组件中只需要关心表单初始化、校验规则配置、保存逻辑等应该表单组件处理的内容,而不再呈现各种细节,实现了关注点的分离。






分而治之,大事化小


在进行复杂功能开发时,应该首先通过结构化思考,将大功能拆分为N个小功能,具体每个小功能怎么实现,先不用关心,在结构搭建完成后,再逐个问题击破。


仍然以前面提到的官网为例,首先把架子搭出来,每个子组件先不要实现,只要用一个简单的占位符占个位就行。


<div>
<Header/>
<main>
<Banner/>
<AboutUs/>
<Services/>
<ContactUs/>
main>
<Footer/>
div>

每个子组件刚开始先用个Div占位,具体实现先不管。





架子搭好后,再去细化每个子组件的实现,如果子组件很复杂,利用同样的方式将其拆分,然后逐个实现。相比上来就实现一个超大的功能,这样的实现更加简单可执行,也方便我们看到自己的任务进度。


可以看到,我们实现组件拆分的目的,并不是为了组件的复用(复用也是组件化拆分的一个主要目的),而是为了更好地呈现功能的结构,实现关注点的分离,增强可读性和可维护性,同时通过这种拆分,将复杂的大任务变成可执行的小任务,更容易完成且能看到进度。


总结


前端单个文件代码建议不超过300行,最大上限为500行,严禁超过100行。


应该面向结构编程,而不是面向细节编程,要能看到一个组件的主线任务,而不被其中的实现细节干扰,实现关注点分离。


将大任务拆分为可执行的小任务,先进行占位,后逐个实现。


作者:前端林叔
来源:juejin.cn/post/7431575865152618511
收起阅读 »

太强了!这个js库有200多个日期时间函数

web
笔者在多年的职业生涯中,用过很多 js 日期时间操作库,如今唯爱这一个,它就是 date-fns。 这是一个拥有 200多个 日期时间函数的集合,堪称日期时间中的 Lodash。 支持按需导出,最大可能地降低打包体积,也支持函数式,链式调用风格。 当前维护非常...
继续阅读 »


date-fns首页


笔者在多年的职业生涯中,用过很多 js 日期时间操作库,如今唯爱这一个,它就是 date-fns


这是一个拥有 200多个 日期时间函数的集合,堪称日期时间中的 Lodash


支持按需导出,最大可能地降低打包体积,也支持函数式,链式调用风格。


当前维护非常积极,star 数 35k,提交了 2000多次,近 400个 代码贡献者,被超过 400万 个项目所依赖使用。


2024年9月份发布了 v4 大版本,支持不同时区的时间操作和互转等,特点如下图。


功能说明


话不多说,看几个示例:


格式化


import { format, formatDistance, formatRelative, subDays } from 'date-fns';

format(new Date(), "'Today is a' eeee");
//=> "Today is a Saturday"

formatDistance(subDays(new Date(), 3), new Date(), { addSuffix: true });
//=> "3 days ago"

formatRelative(subDays(new Date(), 3), new Date());
//=> "last Friday at 7:26 p.m."

国际化


import { formatRelative, subDays } from 'date-fns';
import { es, ru } from 'date-fns/locale';

formatRelative(subDays(new Date(), 3), new Date());
//=> "last Friday at 7:26 p.m."

formatRelative(subDays(new Date(), 3), new Date(), { locale: es });
//=> "el viernes pasado a las 19:26"

formatRelative(subDays(new Date(), 3), new Date(), { locale: ru });
//=> "в прошлую пятницу в 19:26"

组合与函数式


import { addYears, formatWithOptions } from 'date-fns/fp';
import { eo } from 'date-fns/locale';

const addFiveYears = addYears(5);

const dateToString = formatWithOptions({ locale: eo }, 'D MMMM YYYY');

const dates = [
new Date(2017, 0, 1),
new Date(2017, 1, 11),
new Date(2017, 6, 2)
];

const toUpper = (arg) => String(arg).toUpperCase();

const formattedDates = dates.map(addFiveYears).map(dateToString).map(toUpper);
//=> ['1 JANUARO 2022', '11 FEBRUARO 2022', '2 JULIO 2022']

可以看到,这操作,非常地 Lodash


官网文档


不过呢,官方文档,笔者每次查看都感觉有点不方便。


所以,特意根据官方文档制作了一份中文文档,点击查看 date-fns 中文文档


date-fns中文文档


不过,笔者目前还没翻译完毕,还在持续进行中,感兴趣的朋友可以先提前关注一下,也欢迎与我微信交流探讨。


那么关于 date-fns 的安利,基本就结束了,本身就是一个函数库而已,没有太多可以细说的地方。


由于笔者比较八卦,我们来看一看 date-fns 周边的数据和信息。


日期时间的操作,是一个非常基础且重要的领域。


赞助者


果不其然地,这个项目的赞助者非常之多,不乏很多出名的产品和公司。


赞助费用预算


截至目前,共收到了近 23 万美元的赞助。


作者Sasha


项目的发起者是 Sasha,是一个独立开发者,自2017年起,就一直全职在做开源项目,目前和一家人生活在新加坡。


笔者发现,国外的独立开发者,不依托于公司而具备赚钱和生存能力的人有不少。


这确实是一个非常不错的生活方式,可以自由地选择自己觉得舒适的生活。


笔者目前也是独立开发者,2024年,是笔者做自由职业的地 4 第四年,做全职独立开发的第1年,目前超过 10 个产品有或多或少的收益。


如果对独立开发感兴趣,欢迎与我交流探讨。


作者:前端之虎陈随易
来源:juejin.cn/post/7432588086418948131
收起阅读 »

增强 vw/rem 移动端适配,适配宽屏、桌面端、三折屏

web
vw 和 rem 是两个神奇的 CSS 长度单位,认识它们之前,我一度认为招聘广告上的“像素级还原”是一种超能力,我想具备这种能力的人,一定专业过硬、有一双高分辨率的深邃大眼睛。 时间一晃,入坑两年,我敏捷地移动有点僵硬不算过硬的小手,将一些固定的 px 尺寸...
继续阅读 »

一个在桌面端、移动端、平板上展示良好的网页


vw 和 rem 是两个神奇的 CSS 长度单位,认识它们之前,我一度认为招聘广告上的“像素级还原”是一种超能力,我想具备这种能力的人,一定专业过硬、有一双高分辨率的深邃大眼睛。


时间一晃,入坑两年,我敏捷地移动有点僵硬不算过硬的小手,将一些固定的 px 尺寸复制到代码,等待编译阶段的 vw/rem 转换,刷新浏览器的功夫,完美还原的界面映入眼前,我推了推眼镜,会心一笑。多亏了 vw 和 rem。



TLDR:极简配置 postcss-mobile-forever 增强 vw 的宽屏可访问性,限制视图最大宽度。



用 vw 和 rem 适配移动端视图的结果是一致的,都会得到一个随屏幕宽度变化的等比例伸缩视图。一般使用 postcss-px-to-viewport 做 vw 适配,使用 postcss-px2rem 配合 amfe-flexible 做 rem 适配。由于 rem 适配的原理是模仿 vw,所以后面关于适配的增强,一律使用 vw 适配做对比。


vw 适配有一些优点(同样 rem):



  • 加速开发效率;

  • 像素级还原设计稿;

  • 也许更容易被自动生成。


但是 vw 适配也不完美,它引出了下面的问题:



  1. 开发过程机械化,所有元素是固定的宽高,不考虑响应式设计,例如不使用 flexgrid 布局;

  2. 助长不规范化,不关注细节,只关注页面还原度和开发速度,例如在非按钮元素的 <div> 上添加点击事件;

  3. 桌面端难以访问,包容性降低。


前两个问题,也许要抛弃 vw、回归响应式布局才能解决,在日常开发时,我们要约束自己以开发桌面端的标准来开发移动端页面,改善这两个问题。



马克·吐温在掌握通过密西西比河的方法之后,发现这条河已经失去了它的美丽——总会丢掉一些东西,但是在艺术中比较不受重视的东西同时也被创造出来了。让我们不要再注意丢掉了什么,而是注意获得了什么。 ——《禅与摩托车的维修艺术》



后面,我们将关注第三点,介绍如何在保持现状(vw 适配)的情况下,尽可能提高不同屏幕的包容性,至少让我们在三折屏的时代能得到从前 1 倍的体验,而不是 1/3。


移动端桌面端
一个展示正常的移动端页面撑满屏幕而无法阅览的展示在桌面端的移动端页面

上面是一个页面分别在手机和电脑上展示的截图,可以看到左图移动端的右上角没有隐藏分享按钮,所以用户是允许(也应该允许)被分享到桌面端访问的,可惜,当用户准备享受大屏震撼的时候,真的被震撼了:他不知道这个页面的技术细节是神奇的 vw,也不知道他只能用鼠标小心地拖动浏览器窗口边缘,直到窗口窄得和手机一样,最崩溃的是,当他得意地按下了浏览器的缩小按钮,页面像冰冷的机器纹丝不动,浇灭了他的最后一点自信。


限制最大宽度


由于 vw 是视口单位,因此当屏幕变宽,vw 元素也会变大,无限变宽,无限变大。


现在假设在一张宽度 600 像素的设计图上,有一个宽度 60px 的元素,最终通过工具,它会被转为 10vw。这个 10vw 元素是任意伸缩的,但是现在我希望,当屏幕宽度扩大到 700px 后,停止元素的放大。



出现了一堆枯燥的数字,不用担心,后面还有一波,请保持耐心。



首先计算 10vw 在宽 700 像素的屏幕上,应该是多少像素:60 * 700 / 600 = 70。通过最大宽度(700px)和标准宽度(600px)的比例,乘以元素在标准宽度时的尺寸(60px),得到了元素的最大尺寸 70px


接着结合 CSS 函数:min(10vw, 70px),这样元素的宽度将被限制在 70px 以内,小于这个宽度时会以 10vw 等比伸缩。


除了上面的作为正数的尺寸,可能还会有用于方位的负数,负数的情况则使用 CSS 函数 max(),下面的代码块是一个具体实现:


/**
* 限制大小的 vw 转换
* @param {number} n
* @param {number} idealWidth 标准/设计稿/理想宽度,设计稿的宽度
* @param {number} maxWidth 表示伸缩视图的最大宽度
*/

function maxVw(n, idealWidth = 600, maxWidth = 700) {
if (n === 0) return n;

const vwN = Math.round(n * 100 / idealWidth);
const maxN = Math.round(n * maxWidth / idealWidth);
const cssF = n > 0 ? "min" : "max";
return `${cssF}(${vwN}vw, ${maxN}px)`;
}

矫正视图外的元素位置


上一节提供的方法,包容了限制最大宽度尺寸的大部分情况,但是如果不忘像素级还原的❤️初心,就会找到一些漏洞。


下面是一个图例,移动端页面提供了 Top 按钮用于帮助用户返回顶部,按照上一节的方法,Top 按钮会出现在中央移动端视图之外、右边的空白区域中,而不是矫正回中央移动端视图的右下角。


简笔图分成了左右两部分,左边指向右边,左边的部分包含一个视图和视图之外的 Top 按钮,右边的部分包含了一个视图和视图内的 Top 按钮
假设 Top 按钮的样式是这样的:


.top {
position: fixed;
right: 30px;
bottom: 30px;
/* ... */
}

按照标准宽度 600、最大宽度 700,上面的 30px 都被转换成了 min(5vw, 35px)bottom 没错,但 right 需要矫正。


对照上面右图矫正过的状态,right 的值 = 右半边的空白长度 + Top 按钮到居中视图右边框的长度 = 桌面端视图的一半 - 视图中线到 Top 按钮的右边框长度。


沿着第二个等号后面的思路,fixed 定位时桌面端视图一半的尺寸即为 50%,中线到 Top 按钮右边框的长度,分两种情况:



  • 在屏幕宽度大于最大宽度 700 时,为 700 / 2 - 30 * 700 / 600,即为 315px(其中 700 / 2 是中线到移动端右边框长度,30 * 700 / 600 是屏宽 600 时的 30px 在屏宽 700 时的尺寸);

  • 在屏幕宽度小于最大宽度 700 时,为 (600 / 2 - 30) / 600,即为 45%


结合 calc()min() 和上面得到的 50%315px45%,参考第二个等式,可以得到 right 的新值为 calc(50% - min(315px, 45%))。当尺寸大于移动端视图的一半时,会出现负数的情况,这时使用 max() 替换 min()



上面的计算方法是一种符合预期的稳定的方法,另一种方法是强制设置移动端视图的根元素成为包含块,设置之后,right: min(5vw, 35px) 将不再基于浏览器边框,而是基于移动端视图的边框。



postcss-mobile-forever


上面介绍了增强 vw 以包容移动端视图在宽屏展示的两个方面,除了介绍的这些,还有一点点边角情况,例如:



  • 逻辑属性的判断和转换;

  • 矫正 fixed 定位时和包含块宽度有关的 vw% 尺寸;

  • 矫正 fixed 定位时leftrightvw% 尺寸;

  • 为移动端视图添加居中样式;

  • 各种情况的判断和转换方法选择。


postcss-mobile-forever 是一个 PostCSS 插件,利用 mobile-forever 这些工作可以在编译阶段完成,上面举了那么多例子,汇总成一份 mobile-forever 配置就是:


{
"viewportWidth": 700,
"appSelector": "#app",
"maxDisplayWidth": 600
}

上面是 mobile-forever 用户使用最多的模式,max-vw-mode,此外还提供:



  • mq-mode,media-query 媒体查询模式,生成可访问性更高的样式,同样限制最大宽度,但是避免了 vw 带来的无法通过桌面端浏览器缩放按钮缩放页面的问题,也提供了更高的浏览器兼容性;

  • vw-mode,朴素地将固定尺寸转为 vw 伸缩页面,不限制最大宽度。


postcss-mobile-forever 相比 postcss-px-to-viewport 提供了更多的模式,包容了宽屏展示,相比 postcss-px2rem,无需加载 JavaScript,不为项目引入复杂度,即使用户禁用了 js,也能正常展示页面。



scale-view 提供运行时的转换方法。



优秀的模版


postcss-mobile-forever 的推广离不开开源模版的支持、尝试与反馈,下面是这些优秀的模版,它们为开发者提供了更多元的选项,为用户提供了更包容的产品:



  • vue3-vant-mobile,一个基于 Vue 3 生态系统的移动 web 应用模板,帮助你快速完成业务开发。【查看在线演示

  • vue3-vant4-mobile,基于Vue3.4、Vite5、Vant4、Pinia、Typescript、UnoCSS等主流技术开发,集成 Dark Mode(暗黑)模式和系统主题色,且持久化保存,集成 Mock 数据,包括登录/注册/找回/keep-alive/Axios/useEcharts/IconSvg 等其他扩展。你可以在此之上直接开发你的业务代码!【查看在线演示

  • fantastic-mobile,一款自成一派的移动端 H5 框架,支持多款 UI 组件库,基于 Vue3。【查看在线演示




增强后的 vw/rem 看起来已经完成了适配宽屏的任务,不过回想最初的另外两个问题,机械化的开发过程与不规范化的开发细节,没有解决。作为一名专业的前端开发工程师,请考虑使用响应式设计开发你的下一个项目,为三折屏带来 3 倍的用户体验吧。


作者:wsWmsw
来源:juejin.cn/post/7431558902171484211
收起阅读 »

前端进阶必须会的Zod !

web
大家好,我是白露。 今天我想和大家分享一个我最近在使用的TypeScript库 —— Zod。简单来说,Zod是一个用于数据验证的库,它可以让你的TypeScript代码更加安全和可靠。 最近几个月我一直在使用Zod,发现它不仅解决了我长期以来的一些痛点,还大...
继续阅读 »

大家好,我是白露。


今天我想和大家分享一个我最近在使用的TypeScript库 —— Zod。简单来说,Zod是一个用于数据验证的库,它可以让你的TypeScript代码更加安全和可靠。


最近几个月我一直在使用Zod,发现它不仅解决了我长期以来的一些痛点,还大大提高了我的开发效率。我相信,这个库也能帮助到许多和我有同样困扰的TypeScript开发者们。


1. 为什么需要Zod?


作为一个热爱TypeScript的程序员,我一直在寻找能够增强类型安全性的方法。


最近几年,我主要使用TypeScript进行开发。原因很简单:TypeScript提供了优秀的静态类型检查,特别是对于大型项目来说,它的类型系统可以帮助我们避免许多潜在的运行时错误。


然而,尽管TypeScript的类型系统非常强大,但它仍然存在一些局限性。特别是在处理运行时数据时,TypeScript的静态类型检查无法完全保证数据的正确性。这就是我开始寻找额外的数据验证解决方案的原因。


在这个过程中,我尝试了多种数据验证库,如Joi、Yup等。但它们要么缺乏与TypeScript的良好集成,要么使用起来过于复杂。直到我发现了Zod,它完美地解决了我的需求。


2. Zod是什么?


Zod是一个TypeScript优先的模式声明和验证库。它允许你创建复杂的类型安全验证模式,并在运行时执行这些验证。Zod的设计理念是"以TypeScript类型为先",这意味着你定义的每个Zod模式不仅可以在运行时进行验证,还可以被TypeScript编译器用来推断类型。


使用Zod的主要优势包括:



  1. 类型安全: Zod提供了从运行时验证到静态类型推断的端到端类型安全。

  2. 零依赖: Zod没有任何依赖项,这意味着它不会给你的项目增加额外的包袱。

  3. 灵活性: Zod支持复杂的嵌套对象和数组模式,可以处理几乎任何数据结构。

  4. 可扩展性: 你可以轻松地创建自定义验证器和转换器。

  5. 性能: Zod经过优化,可以处理大型和复杂的数据结构,而不会影响性能。


3. 如何使用Zod?


让我们通过一些实际的例子来看看如何使用Zod。


3.1 基本类型验证


import { z } from 'zod';

// 定义一个简单的字符串模式
const stringSchema = z.string();

// 验证
console.log(stringSchema.parse("hello")); // 输出: "hello"
console.log(stringSchema.parse(123)); // 抛出 ZodError

3.2 对象验证


const userSchema = z.object({
  name: z.string(),
  age: z.number().min(0).max(120),
  email: z.string().email(),
});

type User = z.infer<typeof userSchema>; // 自动推断类型

const user = {
  name: "Alice",
  age: 30,
  email: "alice@example.com",
};

console.log(userSchema.parse(user)); // 验证通过

3.3 数组验证


const numberArraySchema = z.array(z.number());

console.log(numberArraySchema.parse([123])); // 验证通过
console.log(numberArraySchema.parse([1"2"3])); // 抛出 ZodError

4. Zod的高级用法


Zod不仅可以处理基本的类型验证,还可以处理更复杂的场景。


4.1 条件验证


const personSchema = z.object({
  name: z.string(),
  age: z.number(),
  drivingLicense: z.union([z.string(), z.null()]).nullable(),
}).refine(data => {
  if (data.age < 18 && data.drivingLicense !== null) {
    return false;
  }
  return true;
}, {
  message: "未成年人不能持有驾-照",
});

4.2 递归模式


const categorySchema: z.ZodType<Category> = z.lazy(() => z.object({
  name: z.string(),
  subcategories: z.array(categorySchema).optional(),
}));

type Category = z.infer<typeof categorySchema>;

4.3 自定义验证器


const passwordSchema = z.string().refine(password => {
  // 至少8个字符,包含大小写字母和数字
  const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/;
  return regex.test(password);
}, {
  message"密码必须至少8个字符,包含大小写字母和数字",
});

5. Zod与前端框架的集成


Zod可以很好地与各种前端框架集成。


这里我们以React为例,看看如何在React应用中使用Zod进行表单验证。


import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const schema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
  password: z.string().min(8),
});

type FormData = z.infer<typeof schema>;

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolverzodResolver(schema),
  });

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("username")} placeholder="Username" />
      {errors.username && <span>{errors.username.message}</span>}
      
      <input {...register("email")} placeholder="Email" />
      {errors.email && <span>{errors.email.message}</span>}
      
      <input {...register("password")} type="password" placeholder="Password" />
      {errors.password && <span>{errors.password.message}</span>}
      
      <button type="submit">Sign Up</button>

    </form>


  );
}

6. Zod与数据库的结合


Zod不仅可以用于前端验证,还可以与后端数据库模式定义完美结合。以下是一个使用Prisma和Zod的例子:


import { z } from 'zod';
import { Prisma } from '@prisma/client';

const userSchema = z.object({
  id: z.number().optional(),
  name: z.string().min(3),
  email: z.string().email(),
  age: z.number().min(18),
  createdAt: z.date().optional(),
  updatedAt: z.date().optional(),
});

type User = z.infer<typeof userSchema>;

// 使用Zod模式来定义Prisma模型
const userModel: Prisma.UserCreateInput = userSchema.omit({ id: true, createdAt: true, updatedAt: true }).parse({
  name: "John Doe",
  email: "john@example.com",
  age: 30,
});

// 现在可以安全地将这个对象传递给Prisma的create方法
// prisma.user.create({ data: userModel });

7. Zod的性能优化


虽然Zod非常强大,但在处理大型数据结构时,可能会遇到性能问题。以下是一些优化建议:



  1. 延迟验证: 对于大型对象,考虑使用z.lazy()来延迟验证。

  2. 部分验证: 使用z.pick()z.omit()来只验证需要的字段。

  3. 缓存模式: 如果你频繁使用相同的模式,考虑缓存它们。

  4. 异步验证: 对于复杂的验证逻辑,考虑使用异步验证器。


8. Zod vs 其他验证库


Zod并不是市场上唯一的验证库。让我们简单比较一下Zod与其他流行的验证库:



  1. Joi: Joi是一个功能强大的验证库,但它不是TypeScript优先的,这意味着你需要额外的工作来获得类型推断。

  2. Yup: Yup与Zod非常相似,但Zod的API设计更加直观,而且性能通常更好。

  3. Ajv: Ajv是一个高性能的JSON Schema验证器,但它的API相对复杂,学习曲线较陡。

  4. class-validator: 这是一个基于装饰器的验证库,非常适合与TypeORM等ORM一起使用,但它需要使用实验性的装饰器特性。


相比之下,Zod提供了一个平衡的解决方案:它是TypeScript优先的,性能优秀,API直观,并且不需要任何实验性特性。


总而言之,通过使用Zod,你可以:



  1. 减少运行时错误

  2. 提高代码的可读性和可维护性

  3. 自动生成TypeScript类型

  4. 简化前后端之间的数据验证逻辑


开始使用Zod吧,让你的TypeScript代码更安全、更强大!


写了这么多,大家不点赞或者star一下,说不过去了吧?


延伸阅读



作者:青玉白露
来源:juejin.cn/post/7426923218952847412
收起阅读 »

threejs做特效:实现物体的发光效果-EffectComposer详解!

web
简介与效果概览 各位大佬给个赞,感谢呢! threejs的开发中,实现物体发光效果是一个常见需求,比如实现楼体的等待照明 要想实现这样的效果,我们只需要了解一个效果合成器概念:EffectComposer。 效果合成器能够合成各种花里胡哨的效果,好比是一个做...
继续阅读 »

简介与效果概览


各位大佬给个赞,感谢呢!


threejs的开发中,实现物体发光效果是一个常见需求,比如实现楼体的等待照明



要想实现这样的效果,我们只需要了解一个效果合成器概念:EffectComposer。


效果合成器能够合成各种花里胡哨的效果,好比是一个做特效的AE,本教程,我们将使用它来实现一个简单的发光效果。


如图,这是我们将导入的一个模型


.


我们要给他赋予灵魂,实现下面的发光效果


顺带的,我们要实现物体的自动旋转、一个简单的性能监视器、一个发光参数调节的面板


技术方案


原生html框架搭建


借助threejs实现一个物体发光效果非常简单,首先我们使用html搭建一个简单的开发框架


参考官方起步文档:three.js中文网


<!DOCTYPE html>
<html lang="en">

<head>
<title>three.js物体发光效果</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0" />
<link type="text/css" rel="stylesheet" href="./main.css" />
<style>
#info>* {
max-width: 650px;
margin-left: auto;
margin-right: auto;
}
</style>
</head>

<body>
<div id="container"></div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.163.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.163.0/examples/jsm/"
}
}
</script>

<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";

</script>
</body>

</html>


上述代码中,我们采用type="importmap"的方式引入了threejs开发 的一些核心依赖,"three"是开发的最基本依赖;在Three.js中,"addons" 通常指的是一些附加功能或扩展模块,它们提供了额外的功能,可以用于增强或扩展Three.js的基本功能。


type="module"中,我们引入了threejs的一些基础依赖,OrbitControls轨道控制器和GLTFLoader模型加载器。


实现模型的加载


我们将下载好的模型放在文件根目录


http://www.yanhuangxueyuan.com/threejs/examples/models/gltf/PrimaryIonDrive.glb

基于threejs的基础知识,我们先实现模型的加载与渲染


<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";

init()
function init() {
const container = document.getElementById("container");

// WebGL渲染器
// antialias是否执行抗锯齿。默认为false.
renderer = new THREE.WebGLRenderer({ antialias: true });
// 设置设备像素比。通常用于避免HiDPI设备上绘图模糊
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置色调映射 这个属性用于在普通计算机显示器或者移动设备屏幕等低动态范围介质上,模拟、逼近高动态范围(HDR)效果。
renderer.toneMapping = THREE.ReinhardToneMapping;
container.appendChild(renderer.domElement);

// 创建新的场景对象。
const scene = new THREE.Scene();
// 创建透视相机
camera = new THREE.PerspectiveCamera(
40,
window.innerWidth / window.innerHeight,
1,
100
);
camera.position.set(-5, 2.5, -3.5);
scene.add(camera);
// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.maxPolarAngle = Math.PI * 0.5;
controls.minDistance = 3;
controls.maxDistance = 8;
// 添加了一个环境光
scene.add(new THREE.AmbientLight(0xcccccc));
// 创建了一个点光源
const pointLight = new THREE.PointLight(0xffffff, 100);
camera.add(pointLight);

// 模型加载
new GLTFLoader().load("./PrimaryIonDrive.glb", function (gltf) {
const model = gltf.scene;
scene.add(model);
const clip = gltf.animations[0];
renderer.render(scene, camera);
});
}
</script>

现在,我们的页面中就有了下面的场景



接下来,我们实现模型的发光效果添加。


模型发光效果添加


实现模型的发光效果,实际是EffectComposer效果合成器实现的。


官方定义:用于在three.js中实现后期处理效果。该类管理了产生最终视觉效果的后期处理过程链。 后期处理过程根据它们添加/插入的顺序来执行,最后一个过程会被自动渲染到屏幕上。


简单来说,EffectComposer效果合成器只是一个工具,它可以将多种效果集成,进行渲染。我们来看一个伪代码:


import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";

// 创建效果合成器
composer = new EffectComposer(renderer);
composer.addPass(发光效果);
composer.addPass(光晕效果);
composer.addPass(玻璃磨砂效果

// 渲染
composer.render();

它的实现过程大致如上述代码。要实现发光效果,我们需要先熟悉三个Pass。


import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";


  • RenderPass: 渲染通道是用于传递渲染结果的对象。RenderPass是EffectComposer中的一个通道,用于将场景渲染到纹理上。(固定代码,相当于混合效果的开始)

  • UnrealBloomPass: 这是一个用于实现逼真的辉光效果的通道。它模拟了逼真的辉光,使得场景中的亮部分在渲染后产生耀眼的辉光效果。(不同效果有不同的pass)

  • OutputPass: OutputPass是EffectComposer中的一个通道,用于将最终渲染结果输出到屏幕上。(固定代码,相当于混合效果的结束)


现在,我们完整的实现发光效果


  <script type="module">
import * as THREE from "three";

import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

let camera;
let composer, renderer;

const params = {
threshold: 0,
strength: 1,
radius: 0,
exposure: 1,
};

init();

function init() {
const container = document.getElementById("container");

// WebGL渲染器
// antialias是否执行抗锯齿。默认为false.
renderer = new THREE.WebGLRenderer({ antialias: true });
// 设置设备像素比。通常用于避免HiDPI设备上绘图模糊
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置色调映射 这个属性用于在普通计算机显示器或者移动设备屏幕等低动态范围介质上,模拟、逼近高动态范围(HDR)效果。
renderer.toneMapping = THREE.ReinhardToneMapping;
container.appendChild(renderer.domElement);

// 创建新的场景对象。
const scene = new THREE.Scene();
// 创建透视相机
camera = new THREE.PerspectiveCamera(
40,
window.innerWidth / window.innerHeight,
1,
100
);
camera.position.set(-5, 2.5, -3.5);
scene.add(camera);
// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.maxPolarAngle = Math.PI * 0.5;
controls.minDistance = 3;
controls.maxDistance = 8;
// 添加了一个环境光
scene.add(new THREE.AmbientLight(0xcccccc));
// 创建了一个点光源
const pointLight = new THREE.PointLight(0xffffff, 100);
camera.add(pointLight);

// 创建了一个RenderPass对象,用于将场景渲染到纹理上。
const renderScene = new RenderPass(scene, camera);

// 创建了一个UnrealBloomPass对象,用于实现辉光效果。≈
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5,
0.4,
0.85
);
// 设置发光参数,阈值、强度和半径。
bloomPass.threshold = params.threshold;
bloomPass.strength = params.strength;
bloomPass.radius = params.radius;

// 创建了一个OutputPass对象,用于将最终渲染结果输出到屏幕上。
const outputPass = new OutputPass();

// 创建了一个EffectComposer对象,并将RenderPass、UnrealBloomPass和OutputPass添加到渲染通道中。
composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
composer.addPass(outputPass);

// 模型加载
new GLTFLoader().load("./PrimaryIonDrive.glb", function (gltf) {
const model = gltf.scene;
scene.add(model);
const clip = gltf.animations[0];
animate();
});
}

function animate() {
requestAnimationFrame(animate);
// 通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}
</script>

现在,我们就实现发光的基本效果了!



实现物体的自动旋转动画


现在,我们实现一下物体自身的旋转动画



AnimationMixer是three中的动画合成器,使用AnimationMixer可以解析到模型中的动画数据


// 模型加载
new GLTFLoader().load("./PrimaryIonDrive.glb", function (gltf) {
const model = gltf.scene;
scene.add(model);
//创建了THREE.AnimationMixer 对象,用于管理模型的动画。
mixer = new THREE.AnimationMixer(model);
//从加载的glTF模型文件中获取动画数据。
//这里假设模型文件包含动画数据,通过 gltf.animations[0] 获取第一个动画片段。
const clip = gltf.animations[0];
// 使用 mixer.clipAction(clip) 创建了一个动画操作(AnimationAction),并立即播放该动画
mixer.clipAction(clip.optimize()).play();

animate();
});

实现动画更新


let clock;
clock = new THREE.Clock();

function animate() {
requestAnimationFrame(animate);
//使用了 clock 对象的 getDelta() 方法来获取上一次调用后经过的时间,即时间间隔(delta)。
const delta = clock.getDelta();
//根据上一次更新以来经过的时间间隔来更新动画。
//这个方法会自动调整动画的播放速度,使得动画看起来更加平滑,不受帧率的影响
mixer.update(delta);

// 通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}

完整代码


<script type="module">
import * as THREE from "three";

import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

let camera, stats;
let composer, renderer, mixer, clock;

const params = {
threshold: 0,
strength: 1,
radius: 0,
exposure: 1,
};

init();

function init() {
const container = document.getElementById("container");

clock = new THREE.Clock();

// WebGL渲染器
// antialias是否执行抗锯齿。默认为false.
renderer = new THREE.WebGLRenderer({ antialias: true });
// .....

// 模型加载
new GLTFLoader().load("./PrimaryIonDrive.glb", function (gltf) {
const model = gltf.scene;

scene.add(model);

mixer = new THREE.AnimationMixer(model);
const clip = gltf.animations[0];
mixer.clipAction(clip.optimize()).play();

animate();
});
}

function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixer.update(delta);
// 通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}
</script>

优化屏幕缩放逻辑


init{
// ....

window.addEventListener("resize", onWindowResize);
}
function onWindowResize() {
const width = window.innerWidth;
const height = window.innerHeight;

camera.aspect = width / height;
camera.updateProjectionMatrix();

renderer.setSize(width, height);
composer.setSize(width, height);
}

添加参数调节面板


在Three.js中,GUI是一个用于创建用户界面(UI)控件的库。具体来说,GUI库允许你在Three.js应用程序中创建交互式的图形用户界面元素,例如滑块、复选框、按钮等,这些元素可以用于控制场景中的对象、相机、光源等参数。


我们借助这个工具实现如下发光效果调试面板


import { GUI } from "three/addons/libs/lil-gui.module.min.js";


init{
// ....

// 创建一个GUI实例
const gui = new GUI();

// 创建一个名为"bloom"的文件夹,用于容纳调整泛光效果的参数
const bloomFolder = gui.addFolder("bloom");

// 在"bloom"文件夹中添加一个滑块控件,用于调整泛光效果的阈值参数
bloomFolder
.add(params, "threshold", 0.0, 1.0)
.onChange(function (value) {
bloomPass.threshold = Number(value);
});

// 在"bloom"文件夹中添加另一个滑块控件,用于调整泛光效果的强度参数
bloomFolder
.add(params, "strength", 0.0, 3.0)
.onChange(function (value) {
bloomPass.strength = Number(value);
});

// 在根容器中添加一个滑块控件,用于调整泛光效果的半径参数
gui
.add(params, "radius", 0.0, 1.0)
.step(0.01)
.onChange(function (value) {
bloomPass.radius = Number(value);
});

// 创建一个名为"tone mapping"的文件夹,用于容纳调整色调映射效果的参数
const toneMappingFolder = gui.addFolder("tone mapping");

// 在"tone mapping"文件夹中添加一个滑块控件,用于调整曝光度参数
toneMappingFolder
.add(params, "exposure", 0.1, 2)
.onChange(function (value) {
renderer.toneMappingExposure = Math.pow(value, 4.0);
});

window.addEventListener("resize", onWindowResize);
}

添加性能监视器



import Stats from "three/addons/libs/stats.module.js";

init{
stats = new Stats();
container.appendChild(stats.dom);
// ...
}

function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixer.update(delta);

stats.update();
// 通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}

在Three.js中,Stats是一个性能监视器,用于跟踪帧速率(FPS)、内存使用量和渲染时间等信息。


完整demo代码


html


<!DOCTYPE html>
<html lang="en">

<head>
<title>three.js物体发光效果</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0" />
<link type="text/css" rel="stylesheet" href="./main.css" />
<style>
#info>* {
max-width: 650px;
margin-left: auto;
margin-right: auto;
}
</style>
</head>

<body>
<div id="container"></div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.163.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.163.0/examples/jsm/"
}
}
</script>

<script type="module">
import * as THREE from "three";

import Stats from "three/addons/libs/stats.module.js";
import { GUI } from "three/addons/libs/lil-gui.module.min.js";

import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

let camera, stats;
let composer, renderer, mixer, clock;

const params = {
threshold: 0,
strength: 1,
radius: 0,
exposure: 1,
};

init();

function init() {
const container = document.getElementById("container");

stats = new Stats();
container.appendChild(stats.dom);

clock = new THREE.Clock();

// WebGL渲染器
// antialias是否执行抗锯齿。默认为false.
renderer = new THREE.WebGLRenderer({ antialias: true });
// 设置设备像素比。通常用于避免HiDPI设备上绘图模糊
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置色调映射 这个属性用于在普通计算机显示器或者移动设备屏幕等低动态范围介质上,模拟、逼近高动态范围(HDR)效果。
renderer.toneMapping = THREE.ReinhardToneMapping;
container.appendChild(renderer.domElement);

// 创建新的场景对象。
const scene = new THREE.Scene();
// 创建透视相机
camera = new THREE.PerspectiveCamera(
40,
window.innerWidth / window.innerHeight,
1,
100
);
camera.position.set(-5, 2.5, -3.5);
scene.add(camera);
// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.maxPolarAngle = Math.PI * 0.5;
controls.minDistance = 3;
controls.maxDistance = 8;
// 添加了一个环境光
scene.add(new THREE.AmbientLight(0xcccccc));
// 创建了一个点光源
const pointLight = new THREE.PointLight(0xffffff, 100);
camera.add(pointLight);

// 创建了一个RenderPass对象,用于将场景渲染到纹理上。
const renderScene = new RenderPass(scene, camera);

// 创建了一个UnrealBloomPass对象,用于实现辉光效果。≈
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5,
0.4,
0.85
);
// 设置发光参数,阈值、强度和半径。
bloomPass.threshold = params.threshold;
bloomPass.strength = params.strength;
bloomPass.radius = params.radius;

// 创建了一个OutputPass对象,用于将最终渲染结果输出到屏幕上。
const outputPass = new OutputPass();

// 创建了一个EffectComposer对象,并将RenderPass、UnrealBloomPass和OutputPass添加到渲染通道中。
composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
composer.addPass(outputPass);

// 模型加载
new GLTFLoader().load("./PrimaryIonDrive.glb", function (gltf) {
const model = gltf.scene;

scene.add(model);

mixer = new THREE.AnimationMixer(model);
const clip = gltf.animations[0];
mixer.clipAction(clip.optimize()).play();

animate();
});

const gui = new GUI();

const bloomFolder = gui.addFolder("bloom");

bloomFolder
.add(params, "threshold", 0.0, 1.0)
.onChange(function (value) {
bloomPass.threshold = Number(value);
});

bloomFolder
.add(params, "strength", 0.0, 3.0)
.onChange(function (value) {
bloomPass.strength = Number(value);
});

gui
.add(params, "radius", 0.0, 1.0)
.step(0.01)
.onChange(function (value) {
bloomPass.radius = Number(value);
});

const toneMappingFolder = gui.addFolder("tone mapping");

toneMappingFolder
.add(params, "exposure", 0.1, 2)
.onChange(function (value) {
renderer.toneMappingExposure = Math.pow(value, 4.0);
});

window.addEventListener("resize", onWindowResize);
}

function onWindowResize() {
const width = window.innerWidth;
const height = window.innerHeight;

camera.aspect = width / height;
camera.updateProjectionMatrix();

renderer.setSize(width, height);
composer.setSize(width, height);
}

function animate() {
requestAnimationFrame(animate);


const delta = clock.getDelta();

mixer.update(delta);

stats.update();
// 通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}
</script>
</body>

</html>


main.css


body {
margin: 0;
background-color: #000;
color: #fff;
font-family: Monospace;
font-size: 13px;
line-height: 24px;
overscroll-behavior: none;
}

a {
color: #ff0;
text-decoration: none;
}

a:hover {
text-decoration: underline;
}

button {
cursor: pointer;
text-transform: uppercase;
}

#info {
position: absolute;
top: 0px;
width: 100%;
padding: 10px;
box-sizing: border-box;
text-align: center;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
z-index: 1; /* TODO Solve this in HTML */
}

a, button, input, select {
pointer-events: auto;
}

.lil-gui {
z-index: 2 !important; /* TODO Solve this in HTML */
}

@media all and ( max-width: 640px ) {
.lil-gui.root {
right: auto;
top: auto;
max-height: 50%;
max-width: 80%;
bottom: 0;
left: 0;
}
}

#overlay {
position: absolute;
font-size: 16px;
z-index: 2;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background: rgba(0,0,0,0.7);
}

#overlay button {
background: transparent;
border: 0;
border: 1px solid rgb(255, 255, 255);
border-radius: 4px;
color: #ffffff;
padding: 12px 18px;
text-transform: uppercase;
cursor: pointer;
}

#notSupported {
width: 50%;
margin: auto;
background-color: #f00;
margin-top: 20px;
padding: 10px;
}

总结


通过本教程,我想现在你对效果合成器一定有了更深入的了解,现在,我们在看看官网的定义:


用于在three.js中实现后期处理效果。该类管理了产生最终视觉效果的后期处理过程链。 后期处理过程根据它们添加/插入的顺序来执行,最后一个过程会被自动渲染到屏幕上


结合代码,我想现在理解其它非常容易


<script type="module">
import * as THREE from "three";

import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

function init() {
// 1【渲染开始】创建了一个RenderPass对象,用于将场景渲染到纹理上。
const renderScene = new RenderPass(scene, camera);

// 2【需要合成的中间特效】创建了一个UnrealBloomPass对象,用于实现辉光效果。≈
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5,
0.4,
0.85
);
// 【特效设置】设置发光参数,阈值、强度和半径。
bloomPass.threshold = params.threshold;
bloomPass.strength = params.strength;
bloomPass.radius = params.radius;

// 3【效果输出】创建了一个OutputPass对象,用于将最终渲染结果输出到屏幕上。
const outputPass = new OutputPass();

// 4【特效合并】创建了一个EffectComposer对象,并将RenderPass、UnrealBloomPass和OutputPass添加到渲染通道中。
composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
composer.addPass(outputPass);
}

function animate() {
requestAnimationFrame(animate);
// 5【渲染特效】通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}
</script>

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

uni-app的这个“地雷”坑,我踩了

web
距离上次的 uni-app-x 文章已有一月有余,在此期间笔者又“拥抱”了 uni-app,使用 uni-app 开发微信小程序。 与使用 uni-app-x 相比个人感觉 uni-app 在开发体验上更流畅,更舒服一些,这可能得益于 uni-app 相对成熟...
继续阅读 »

距离上次的 uni-app-x 文章已有一月有余,在此期间笔者又“拥抱”了 uni-app,使用 uni-app 开发微信小程序。


与使用 uni-app-x 相比个人感觉 uni-app 在开发体验上更流畅,更舒服一些,这可能得益于 uni-app 相对成熟,且与标准的前端开发相差不大。至少在 IDE 的选择上比较自由,比如可以选择 VSCode 或者 WebStorm,笔者习惯了 Jetbrains 家的 IDE,自然选择了 WebStorm。


虽说 uni-app 相对成熟,但是笔者还是踩到了“地雷式”的巨坑,下面且听我娓娓道来。



附:配套代码



什么样的坑


先描述下是什么样的坑。简单来说,我有一个动态的 style 样式,伪代码如下:


<view v-for="(c, ci) in 10" :key="ci" :style="{ height: `${50}px` }">
{{ c }}
</view>

理论上编译到小程序应该如下:


<view style="height: 50px">1</view>

但是,实际上编译后却是:


<view style="height: [object Object]">1</view>

最后导致样式没有生效。


着手排查


先网上搜索一番,基本上千篇一律的都是 uni-app 编程成微信小程序时 style 不支持对象的形式,需要在对象外包一层数组,需要做如下修改:


<view :style="[{ height: `${50}px` }]"></view>

但是,这种方式对我无效。


然后开始了漫长的排查之旅,对比之前的项目是使用的对象形式对动态 style 进行的赋值也没有遇到这样问题,最后各种尝试至深夜一点多也没有解决,浪费我大好的“青春”。


没有解决问题实在是不甘心啊,于是第二天上午继续排查,观察 git 提交记录,没有发现什么异常的代码,然后开始拉新分支一个一个的 commit 回滚代码,然后再把回滚的代码手敲一遍再一点点的编译调试对比,这真的是浪费时间与精力的一件事,最终也是功夫不负有心人,终于锁定了一个 commit 提交,在这个 commit 后出现了上述问题。



为什么要回滚代码?因为在之前的代码中都是以对象形式为动态 style 赋值的。



现在可以着重的“攻击”这个 commit 上的代码了,仿佛沉溺在水中马上就要浮出水面可以呼一口气。这个 commit 上的代码不是很多,其中就包含上述的伪代码。最后,经过仔细的审查这个 commit 上的代码也没有发现什么异常的代码逻辑,好像突然没有了力气又慢慢沉入了水底。


反正是经过了各种尝试,其中历程真是一把鼻涕一把泪,不提也罢。


也不知是脑子不好使还是最后的倔强,突发奇想的修改了上述伪代码中 v-for 语句中的 c 变量命名:


<view v-for="(a, ci) in 10" :key="ci" :style="{ height: `${50}px` }">
{{ a }}
</view>

妈耶,奇迹发生了,动态 style 编译后正常了,样式生效了。随后又测试了一些其他的命名,如:A,b,B,C,d,D,i,I,这些都编译后正常,唯独命名为小写的 c 后,编译后不正常还是 [object Object] 的形式。


如果,现在,你迫不及待的去新建个 uni-app 项目来验证笔者所言是否属实,那么不好意思,你大概率不会踩到这个坑。


但是,如果你在动态 style 中再多加一个 css 属性,代码如下:


<view
v-for="(c, ci) in 5"
:key="ci"
:style="{
height: `${50}px`,
marginTop: `${10}px`,
}"
>
{{ c }}
</view>

那么你会发现第一个 height 属性生效了,然而新加的 marginTop 属性却是 [object Object]


如果你再多加几个属性,你会发现它们都生效了,唯独第二个属性是失效的。


如果你在这个问题 view 代码前面使用过 v-for 且使用过动态 style 且动态 style 中有字符串模板,那么你会发现问题 view 变正常了。


总结


本文记录了笔者排查 uni-app 动态 style 失效的心路历程,虽然问题得到了解决,但是没有深入研究产生此问题的本质原因,总结起来就是菜,还得多练。


深夜对着星空感叹,这种坑也能被我踩到,真是时也命也。


作者:guodongAndroid
来源:juejin.cn/post/7416554802254364708
收起阅读 »

写了一个字典hook,瞬间让组员开发效率提高20%!!!

web
1、引言 在项目的开发中,发现有很多地方(如:选择器、数据回显等)都需要用到字典数据,而且一个页面通常需要请求多个字典接口,如果每次打开同一个页面都需要重新去请求相同的数据,不但浪费网络资源、也给开发人员造成一定的工作负担。最近在用 taro + react ...
继续阅读 »

1、引言


在项目的开发中,发现有很多地方(如:选择器、数据回显等)都需要用到字典数据,而且一个页面通常需要请求多个字典接口,如果每次打开同一个页面都需要重新去请求相同的数据,不但浪费网络资源、也给开发人员造成一定的工作负担。最近在用 taro + react 开发一个小程序,所以就写一个字典 hook 方便大家开发。


2、实现过程


首先,字典接口返回的数据类型如下图所示:
image.png


其次,在没有实现字典 hook 之前,是这样使用 选择器 组件的:


  const [unitOptions, setUnitOptions] = useState([])

useEffect(() => {
dictAppGetOptionsList(['DEV_TYPE']).then((res: any) => {
let _data = res.rows.map(item => {
return {
label: item.fullName,
value: item.id
}
})
setUnitOptions(_data)
})
}, [])

const popup = (
<PickSelect
defaultValue=""
open={unitOpen}
options={unitOptions}
onCancel={() =>
setUnitOpen(false)}
onClose={() => setUnitOpen(false)}
/>

)

每次都需要在页面组件中请求到字典数据提供给 PickSelect 组件的 options 属性,如果有多个 PickSelect 组件,那就需要请求多次接口,非常麻烦!!!!!


既然字典接口返回的数据格式是一样的,那能不能写一个 hook 接收不同属性,返回不同字典数据呢,而且还能 缓存 请求过的字典数据?


当然是可以的!!!


预想一下如何使用这个字典 hook?


const { list } = useDictionary('DEV_TYPE')

const { label } = useDictionary('DEV_TYPE', 1)

const { label } = useDictionary('DEV_TYPE', 1, '、')

从上面代码中可以看到,第一个参数接收字典名称,第二个参数接收字典对应的值,第三个参数接收分隔符,而且后两个参数是可选的,因此根据上面的用法来写我们的字典 hook 代码。


interface dictOptionsProps {
label: string | number;
value: string | number | boolean | object;
disabled?: boolean;
}

interface DictResponse {
value: string;
list: dictOptionsProps[];
getDictValue: (value: string) => string
}

let timer = null;
const types: string[] = [];
const dict: Record<string, dictOptionsProps[]> = {}; // 字典缓存

// 因为接收不同参数,很适合用函数重载
function useDictionary(type: string): DictResponse;
function useDictionary(
type: string | dictOptionsProps[],
value: number | string | Array<number | string>,
separator?: string
): DictResponse;
function useDictionary(
type: string | dictOptionsProps[],
value?: number | string | Array<string | number>,
separator = ","
): DictResponse {
const [options, setOptions] = useState<dictOptionsProps[]>([]); // 字典数组
const [dictValue, setDictValue] = useState(""); // 字典对应值

const init = () => {
if (!dict[type] || !dict[type].length) {
dict[type] = [];

types.push(type);

// 当多次调用hook时,获取所有参数,合成数组,再去请求,这样可以避免多次调用接口。
timer && clearTimeout(timer);
timer = setTimeout(() => {
dictAppGetOptionsList(types.slice()).then((res) => {
for (const key in dictRes.data) {
const dictList = dictRes.data[key].map((v) => ({
label: v.description,
value: v.subtype,
}));
dict[type] = dictList
setOptions(dictList) // 注意这里会有bug,后面有说明的
}
});
}, 10);
} else {
typeof type === "string" ? setOptions(dict[type]) : setOptions(type);
}
};

// 获取字典对应值的中文名称
const getLabel = useCallback(
(value) => {
if (value === undefined || value === null || !options.length) return "";
const values = Array.isArray(value) ? value : [value];

const items = values.map((v) => {
if (typeof v === "number") v = v.toString();
return options.find((item) => item.value === v) || { label: value };
});
return items.map((v) => v.label).join(separator);
},
[options]
)

useEffect(() => init(), [])
useEffect(() => setDictValue(getLabel(value)), [options, value])

return { value: dictValue, list: options, getDictValue: getLabel };
}

初步的字典hook已经开发完成,在 Input 组件中添加 dict 属性,去试试看效果如何。


export interface IProps extends taroInputProps {
value?: any;
dict?: string; // 字典名称
}

const CnInput = ({
dict,
value,
...props
}: IProps
) => {
const { value: _value } = dict ? useDictionary(dict, value) : { value };

return <Input value={_value} {...props} />
}

添加完成,然后去调用 Input 组件


<CnInput
readonly
dict="DEV_ACCES_TYPE"
value={formData?.accesType}
/>

<CnInput
readonly
dict="DEV_SOURCE"
value={formData?.devSource}
/>


没想到,翻车了


会发现,在一个页面组件中,多次调用 Input 组件,只有最后一个 Input 组件才会回显数据


image.png


这个bug是怎么出现的呢?原来是 setTimeout 搞的鬼,在 useDictionary hook 中,当多次调用 useDictionary hook 的时候,为了能拿到全部的 type 值,请求一次接口拿到所有字典的数据,就把字典接口放在 setTimeout 里,弄成异步的逻辑。但是每次调用都会清除上一次的 setTimeout,只保存了最后一次调用 useDictionary 的 setTimeout ,所以就会出现上面的bug了。


既然知道问题所在,那就知道怎么去解决了。


解决方案: 因为只有调用 setOptions 才会引起页面刷新,为了不让 setTimeout 清除掉 setOptions,就把 setOptions 添加到一个更新队列中,等字典接口数据回来再去执行更新队列就可以了。



let timer = null;
const queue = []; // 更新队列
const types: string[] = [];
const dict: Record<string, dictOptionsProps[]> = {};

function useDictionary2(type: string): DictResponse;
function useDictionary2(
type: string | dictOptionsProps[],
value: number | string | Array<number | string>,
separator?: string
): DictResponse;
function useDictionary2(
type: string | dictOptionsProps[],
value?: number | string | Array<string | number>,
separator = ","
): DictResponse {
const [options, setOptions] = useState<dictOptionsProps[]>([]);
const [dictValue, setDictValue] = useState("");

const getLabel = useCallback(
(value) => {
if (value === undefined || value === null || !options.length) return "";
const values = Array.isArray(value) ? value : [value];

const items = values.map((v) => {
if (typeof v === "number") v = v.toString();
return options.find((item) => item.value === v) || { label: value };
});
return items.map((v) => v.label).join(separator);
},
[options]
);

const init = () => {
if (typeof type === "string") {
if (!dict[type] || !dict[type].length) {
dict[type] = [];

const item = {
key: type,
exeFunc: () => {
if (typeof type === "string") {
setOptions(dict[type]);
} else {
setOptions(type);
}
},
};
queue.push(item); // 把 setOptions 推到 更新队列(queue)中

types.push(type);

timer && clearTimeout(timer);
timer = setTimeout(async () => {
const params = types.slice();

types.length = 0;

try {
let dictRes = await dictAppGetOptionsList(params);
for (const key in dictRes.data) {
dict[key] = dictRes.data[key].map((v) => ({
label: v.description,
value: v.subtype,
}));
}

queue.forEach((item) => item.exeFunc()); // 接口回来了再执行更新队列
queue.length = 0; // 清空更新队列
} catch (error) {
queue.length = 0;
}
}, 10);
} else {
typeof type === "string" ? setOptions(dict[type]) : setOptions(type);
}
}
};

useEffect(() => init(), []);

useEffect(() => setDictValue(getLabel(value)), [options, value]);

return { value: dictValue, list: options, getDictValue: getLabel };
}

export default useDictionary;

修复完成,再去试试看~


image.png


不错不错,已经修复,嘿嘿~


这样就可以愉快的使用 字典 hook 啦,去改造一下 PickSelect 组件


export interface IProps extends PickerProps {
open: boolean;
dict?: string;
options?: dictOptionsProps[];
onClose: () => void;
}
const Base = ({
dict,
open = false,
options = [],
onClose = () => { },
...props
}: Partial<IProps>
) => {
// 如果不传 dict ,就拿 options
const { list: _options } = dict ? useDictionary(dict) : { list: options };

return <Picker.Column>
{_options.map((item) => {
return (
<Picker.Option
value={item.value}
key={item.value as string | number}
>

{item.label}
</Picker.Option>
);
})}
</Picker.Column>


在页面组件调用 PickSelect 组件


image.png


效果:


image.png


这样就只需要传入 dict 值,就可以轻轻松松获取到字典数据啦。不用再手动去调用字典接口啦,省下来的时间又可以愉快的摸鱼咯,哈哈哈


最近也在写 vue3 的项目,用 vue3 也实现一个吧。


// 定时器
let timer = 0
const timeout = 10
// 字典类型缓存
const types: string[] = []
// 响应式的字典对象
const dict: Record<string, Ref<CnPage.OptionProps[]>> = {}

// 请求字典选项
function useDictionary(type: string): Ref<CnPage.OptionProps[]>
// 解析字典选项,可以传入已有字典解析
function useDictionary(
type: string | CnPage.OptionProps[],
value: number | string | Array<number | string>,
separator?: string
): ComputedRef<string>
function useDictionary(
type: string | CnPage.OptionProps[],
value?: number | string | Array<number | string>,
separator = ','
): Ref<CnPage.OptionProps[]> | ComputedRef<string> {
// 请求接口,获取字典
if (typeof type === 'string') {
if (!dict[type]) {
dict[type] = ref<CnPage.OptionProps[]>([])

if (type === 'UNIT_LIST') {
// 单位列表调单独接口获取
getUnitListDict()
} else if (type === 'UNIT_TYPE') {
// 单位类型调单独接口获取
getUnitTypeDict()
} else {
types.push(type)
}
}

// 等一下,人齐了才发车
timer && clearTimeout(timer)
timer = setTimeout(() => {
if (types.length === 0) return
const newTypes = types.slice()
types.length = 0
getDictionary(newTypes).then((res) => {
for (const key in res.data) {
dict[key].value = res.data[key].map((v) => ({
label: v.description,
value: v.subtype
}))
}
})
}, timeout)
}

const options = typeof type === 'string' ? dict[type] : ref(type)
const label = computed(() => {
if (value === undefined || value === null) return ''
const values = Array.isArray(value) ? value : [value]
const items = values.map(
(value) => {
if (typeof value === 'number') value = value.toString()
return options.value.find((v) => v.value === value) || { label: value }
}
)
return items.map((v) => v.label).join(separator)
})

return value === undefined ? options : label
}

export default useDictionary

感觉 vue3 更简单啊!


到此结束!如果有错误,欢迎大佬指正~


作者:kazaf又开始送了
来源:juejin.cn/post/7377559533785022527
收起阅读 »

前端大佬都在用的useForm究竟有多强?

web
大家好,今天我要和大家分享一个超级实用的功能 - alovajs 的 useForm。老实说,当我第一次接触到这个功能时,我简直惊呆了!以前处理表单提交总是让我头疼不已,写了一堆重复的代码还容易出错。但现在有了 useForm,一切都变得如此简单和优雅。让我来...
继续阅读 »

大家好,今天我要和大家分享一个超级实用的功能 - alovajs 的 useForm。老实说,当我第一次接触到这个功能时,我简直惊呆了!以前处理表单提交总是让我头疼不已,写了一堆重复的代码还容易出错。但现在有了 useForm,一切都变得如此简单和优雅。让我来告诉你它是如何改变我的开发体验的!


alovajs 简介


首先,让我介绍一下 alovajs。它不仅仅是一个普通的请求工具,而是一个能大大简化我们 API 集成流程的新一代利器。与 react-query 和 swr 这些库不同,alovajs 提供了针对各种请求场景的完整解决方案。


它有 15+ 个"请求策略",每个策略都包含状态化数据、特定事件和 actions。 这意味着我们只需要很少的代码就能实现特定场景下的请求。我记得第一次使用时,我惊讶地发现原来复杂的请求逻辑可以如此简洁!


如果你想了解更多关于 alovajs 的信息,强烈推荐你去官网看看: alova.js.org。相信我,你会发现一个全新的世界!


useForm 的神奇用法


现在,让我们一起深入了解 useForm 的具体用法。每次我使用这些功能时,都会感叹它的设计有多么巧妙。


基本用法


useForm 的基本用法非常简单,看看这段代码:


const {
loading: submiting,
form,
send: submit,
onSuccess,
onError,
onComplete
} = useForm(
formData => {
return formSubmit(formData);
},
{
initialForm: {
name: '',
cls: '1'
}
}
);

只需要这么几行代码,我们就能获得表单状态、数据、提交函数等所有需要的东西。 第一次看到这个时,我简直不敢相信自己的眼睛!


自动重置表单


还记得以前每次提交表单后都要手动重置吗?那种繁琐的感觉简直让人抓狂。但是 useForm 为我们提供了一个优雅的解决方案:


useForm(submitData, {
resetAfterSubmiting: true
});

设置这个参数为 true,表单就会在提交后自动重置。 当我发现这个功能时,我感觉自己省了好几年的寿命!


表单草稿


你有没有遇到过这种情况:正在填写一个长表单,突然被打断,等回来时发现数据全没了?那种沮丧的感觉我再清楚不过了。但是 useForm 的表单草稿功能彻底解决了这个问题:


useForm(submitData, {
store: true
});

开启这个功能后,即使刷新页面也能恢复表单数据。 我第一次使用这个功能时,简直感动得想哭!


多页面表单


对于那些需要分步骤填写的复杂表单,useForm 也有令人惊叹的解决方案:


// 组件A
const { form, send } = useForm(submitData, {
initialForm: { /*...*/ },
id: 'testForm'
});

// 组件B、C
const { form, send } = useForm(submitData, {
id: 'testForm'
});

通过设置相同的 id,我们可以在不同组件间共享表单数据。 这个功能让我在处理复杂表单时不再手忙脚乱,简直是多页面表单的福音!


条件筛选


useForm 还可以用于数据筛选,这个功能让我在开发搜索功能时如虎添翼:


const { send: searchData } = useForm(queryCity, {
initialForm: { cityName: '' },
immediate: true
});

设置 immediate 为 true,就能在初始化时就开始查询数据。 这对于需要立即显示结果的场景非常有用,大大提升了用户体验。


看完这些用法,你是不是也和我一样,被 useForm 的强大所折服?它不仅简化了我们的代码,还为我们考虑了各种常见的表单场景。使用 useForm,我感觉自己可以更专注于业务逻辑,而不是被繁琐的表单处理所困扰。


那么,你有没有在项目中遇到过类似的表单处理问题?useForm 是否解决了你的痛点?我真的很好奇你的想法和经验!如果你觉得这篇文章对你有帮助,别忘了点个赞哦!让我们一起探讨,一起进步!


作者:坡道口
来源:juejin.cn/post/7425193631583305780
收起阅读 »

老板:不是吧,这坨屎都给你优化好了,给你涨500工资!!

web
前言 最近负责了项目的一个大迭代,然后目前基本的功能都是实现了,也上了生产。但是呢,大佬们可以先看下面这张图,cpu占用率100%,真的卡了不得了哈哈哈,用户根本没有一点使用体验。还有就是首屏加载,我靠说实话,真的贼夸张,首屏加载要十来秒,打开控制台一看,一个...
继续阅读 »

前言


最近负责了项目的一个大迭代,然后目前基本的功能都是实现了,也上了生产。但是呢,大佬们可以先看下面这张图,cpu占用率100%,真的卡了不得了哈哈哈,用户根本没有一点使用体验。还有就是首屏加载,我靠说实话,真的贼夸张,首屏加载要十来秒,打开控制台一看,一个js资源加载就要七八秒。本来呢,我在这个迭代中我应该是负责开发需求的那个底层苦力码农,而这种性能优化这种活应该是组长架构师来干的,我这种小菜鸡应该是拿个小本本偷偷记笔记的,但是组长离职跳槽了,哥们摇身一变变成了项目负责人哈哈哈了。所以就有了这篇文章,和大家分享记录一下,毕业几个月的菜鸡的性能优化思路和手段,也希望大佬们给指点一下。


image.png



先和大家说一下。这个页面主要有两个问题 卡顿首屏加载,本来这篇文章是打算把我优化这两个问题的思路和方法都一起分享给大家的,但是我码完卡顿的思路和方法后发现写的有点多。所以这篇文章就只介绍我优化卡顿的思路和方法,首屏加载我会另外发一篇文章。



卡顿


这个页面卡顿呢,主要是由于这个表格的原因,很多人应该会想着表格为什么会卡顿啊,但是我这个表格是真的牛逼,大家可以看我这篇文章 “不是吧,刚毕业几个月的前端,就写这么复杂的表格??”,顺便给我装一下杯,这篇文章上了前端热榜第一(还是断层霸榜哦)(手动狗头)。
微信图片编辑_20241025221330.jpg


言归正传,为了一些盆友们不想看那篇文章,我给大家总结一下(最好看一下嘿嘿嘿),这个表格整体就是由三个表格合成为一个表格的,所以这个页面相当于有三个表格。因为它是一个整体的,所以我就需要去监听这个三个表格滚动事件去保证它表现为一个表格,其实就是保证他们滚动同步,以及信息栏浮层正确的计算位置,有点啰嗦了哈哈哈。


改之前3.gif
其实可以看到,很明显的卡顿。而且,这还是最普通的时候,为什么说普通呢,因为这个项目是金融方面的,所以这些数据都是需要实时更新的,我录制的这个动图是没有进行数据更新的。然后这个表格是一共是有四百来条数据,四百来条实时更新,这也就是为什么cpu占用率百分百的主要原因。再加之为了实现三个表格表现为一个表格,不得不给每一个表格都监听滚动事件,去改变剩下两个表格滚动条,然后改变滚动条也会触发滚动事件,也就是说滚动一下,触发了三个函数,啥意思呢,就比如说我本来只用执行1行代码,现在会执行3行代码(如果看不明白,去上面那边文章的demo跑一下就知道了)。所以,我们就可以知道主要的卡顿原因了。


卡顿原因


看到这盆友们应该知道为什么卡顿了,如果还不知道,那罚你再重新看一遍咯。其实真可以去看一下那篇文章,那篇文章很好的阐述了这个表格为什么会这么复杂。



卡顿原因:



  1. 大量数据需要实时更新

  2. 三个表格滚动事件让工作代码量变成了三倍



优化效果


不行,得先学资本家给大家画个饼,不然搞得我好像在诈骗一样,可以看下面这两张动态图,我只能说吃了二十盒德芙也没有这么丝滑。虽然滚轮滚动速度是有差别,可能会造成误差,但是这两区别也太大,丝滑了不止一点点,肉眼都可以看的出来。



优化前
改之前3.gif




优化后
改之后.gif



在看数据实时更新的前后对比动图,优化前的动图可以看到,cpu占有率基本都是100%,偶尔会跳去99%。但是看优化后的图,虽然也会有飙到100的cpu占有率,但是只是某一个瞬间。这肯定就高下立判了,吾与城北徐公孰美,肯定是吾美啊!



优化前
改之前订阅.gif




优化后
改之后订阅.gif



优化思路与方法


如何呢,少侠?是不是还不错!


前面已经说过了两个原因导致卡顿,我们只要解决这两个原因自然就会好起来了,也不是解决,只能说是优化它,因为在网络,数据大量更新,以及用户频繁操作等等其他原因,还是会特别卡。


如何优化三个表格的滚动事件


对于这三个表格,核心是一次滚动事件会触发三次滚动函数,而且三个事件函数其实都是大差不差的,都是去改变其余两个表格的上下滚动高度或者左右滚动宽度,换句话说,这个滚动事件的主要目的其实就是获取当前这个表格滚动了多少。那我们偷换一下概念,原本的是滚动事件去改变其他两个表格的滚动高度,不如把他变成滚动了多少去改变其他两个表格的滚动高度。懵了吧,少年哈哈哈哈!看下修改后的代码你就能细评这句话了,代码是vue3写法,而且并不全,大家知道我在干嘛就行。


修改前的js代码


const leftO = document.querySelector("#left")
const middleO = document.querySelector("#middle")
const rightO = document.querySelector("#right")

leftO.addEventListener("scroll", (e) => {
const top = e.target.scrollTop
const left = e.target.scrollLeft
middleO.scrollTop = e.target.scrollTop
rightO.scrollTop = e.target.scrollTop
rightO.scrollLeft = left
},true)
middleO.addEventListener("scroll", (e) => {
const top = e.target.scrollTop
leftO.scrollTop = e.target.scrollTop
rightO.scrollTop = e.target.scrollTop
},true)
rightO.addEventListener("scroll", (e) => {
const left = e.target.scrollLeft
const top = e.target.scrollTop
leftO.scrollTop = e.target.scrollTop
middleO.scrollTop = e.target.scrollTop
leftO.scrollLeft = left
},true)

修改后的js代码


const leftO = document.querySelector("#left")
const middleO = document.querySelector("#middle")
const rightO = document.querySelector("#right")

const top = ref(0)
const left = ref(0)
// 这个是判断哪个表格进行滚动了
const flag = ref("")
leftO.addEventListener("scroll", (e) => {
// 记录top和left
top.value = e.target.scrollTop
left.value = e.target.scrollLeft
flag.value = 'left'
}, true)
middleO.addEventListener("scroll", (e) => {
// 记录top
top.value = e.target.scrollTop
flag.value = 'middle'
}, true)
rightO.addEventListener("scroll", (e) => {
// 记录top和left
top.value = e.target.scrollTop
left.value = e.target.scrollLeft
flag.value = 'right'
}, true)
// 监听top去进行滚动
watch(() => top.value, (newV) => {
// 当前滚动就不进行设置滚动条了
flag.value!=="left" && (leftO.scrollTop = newV)
flag.value!=="middle" && (middleO.scrollTop = newV)
flag.value!=="right" && (rightO.scrollTop = newV)
})
// 监听left去进行滚动
watch(() => left.value, (newV) => {
// 当前滚动就不进行设置滚动条了
flag.value!=="left" && (leftO.scrollleft = newV)
flag.value!=="right" && (rightO.scrollleft= newV)
})

看完了吧,我简单的总结下我都干了啥,其实就是将三个滚动事件所造成的影响全部作用于变量,再通过watch监听变量是否变化再去作用于表格,而不是直接作用于表格。换句来说,从之前的监听三个滚动事件去滚动表格变成监听一个滚动高度变量去滚动表格,自然代码工作量从原来的三倍变回了原来的一倍。其实和发布订阅是有异曲同工之妙,三个发布者通知一个订阅者。如此简单的一个事,为啥我要啰里吧嗦逼逼这么多,其实就是想让大家体会待入一下那种恍然大悟妙不可言的高潮感,而不是坐享其成的麻痹感


如何优化大量数据实时更新


前面说过这是一个金融项目的页面,所以他是需要实时更新的。但是这个表格大概有四百来条数据,一条数据有二十一列,也就是可能会有八千多个数据需要更新。这肯定导致页面很卡,甚至是页面崩溃。那咋办呢,俗话说的好啊,只要思想不滑坡,办法总比困难多


image.png


我们不妨想一想,四百来条数据都要实时更新吗?对,这并不需要!我们只要实现了类似于图片懒加载的效果,啥意思呢?就是比如当前我们屏幕只能看到二十条数据,我们只要实时更新的当前这二十条就行了,在滚动的时候屏幕又显示了另外二十条,我们在实时更新这二十条数据。不就洒洒水的优化了好几倍的性能吗。



我先和大家先说一下,我这边实现这个实时更新是通过websocket去实现的,前端将需要实时更新的数据id代码,发送给服务端,服务端就会一直推送相关的更新数据。然后我接下来就用subscribe代表去给通知服务端需要更新哪些数据id,unsubscribe代表去通知服务的不用继续更新数据,来给大家讲一下整体一个思路。



首先,我们需要去维护好一个数组,什么数组呢。就是在可视窗口的所有数据的id数组,有了这个数组我们就可以写出下面的一个逻辑,只要是在可视窗口的数据id数组发生了变化,就把之前的数据推送取消,在重新开启当前这二十条的数据推送


// idArr为当前在可视窗口数据id数组
function updateSubscribe(idArr){
// 取消之前二十条的数据推送
unsubscribe()
// 开启当前这二十条的数据推送
subscribe(idArr)
}

所以,现在问题就变成如何维护好这个数组了!这个是在用户滚动的时候会发生变化,所以我们还是要监听滚动事件,虽然我们之前已经做了上面的表格滚动优化操作,我这边还是给大家用滚动事件去演示demo。言归正传,我们要获取到这个数组,就要知道有哪些数据的dom是在可视窗口中的!这里我的方法还是比较笨的,我感觉应该是有更好的方法去获取的。大家可以复制下面这个demo跑一下,打开控制台看一下打印的数组。


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
* {
padding: 0;
margin: 0;
}

.box {
width: 400px;
height: 600px;
margin: 0 auto;
margin-top: 150px;
border: 1px solid red;
overflow-y: scroll;
overflow-x: hidden;
}

.item {
width: 400px;
height: 100px;
/* background-color: beige; */
border: 1px solid rgb(42, 165, 42);
text-align: center;
}
</style>
</head>

<body>
<div class="box" id="box">
<div class="item" id="1">
1
</div>
<div class="item" id="2">
2
</div>
<div class="item" id="3">
3
</div>
<div class="item" id="4">
4
</div>
<div class="item" id="5">
5
</div>
<div class="item" id="6">
6
</div>
<div class="item" id="7">
7
</div>
<div class="item" id="8">
8
</div>
<div class="item" id="9">
9
</div>
<div class="item" id="10">
10
</div>
<div class="item" id="11">
11
</div>
<div class="item" id="12">
12
</div>
<div class="item" id="13">
13
</div>
<div class="item" id="14">
14
</div>
<div class="item" id="15">
15
</div>
<div class="item" id="16">
16
</div>
<div class="item" id="17">
17
</div>
</div>
</body>
<script>
const oBOX = document.querySelector("#box")
oBOX.addEventListener('scroll', () => {
console.log(findIDArr())
})

const findIDArr = () => {
const domList = document.querySelectorAll(".item")
// 过滤在视口的dom
const visibleDom = Array.prototype.filter.call(domList, dom => isVisible(dom))
const idArr = Array.prototype.map.call(visibleDom, (dom) => dom.id)
return idArr
}
// 是否在可视区域内
const isVisible = element => {
const bounding = element.getBoundingClientRect()
// 判断元素是否在可见视口中
const isVisible =
bounding.top >= 0 && bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight)
return isVisible
}
</script>

</html>

这段代码其实还是很好理解的,我就给大家提两个地方比较难搞的地方。


id的获取方式

我们这里是先在每个div手动的绑定了id,然后在通过是拿到dom的实例对象,进而去获取到它的id。而在我们实际的开发工作中,基本都是使用组件的,然后是数据驱动视图的。就比如el-table,给他绑定好一个数据列表,就可以渲染出一个列表。也就是说,这一行的dom和这一行绑定的数据是两个东西,我们所获取的dom不一定就能拿到id,所以怎么获取到每一行的id也是一个问题,反正核心就是将dom和数据id联系起来,这就需要大家具体问题具体分析解决了。


如何判断是否在可视区域

image.png
判断是否在可视区域主要是通过getBoundingClientRect函数,这个函数是可以获取一个元素的六个属性,分别是上面(下面)的这几个属性,然后就可以根据这些字段去判断是否在可视区域。




  • width: 元素的宽度

  • height: 元素的高度

  • x: 元素左上角相对于视口的横坐标

  • y: 元素左上角相对于视口的纵坐标

  • top: 元素顶部距离窗口的距离

  • left: 元素左侧距离窗口左侧的距离

  • bottom: 元素底部距离窗口顶部的距离 (等于 y + height)

  • right: 元素右侧距离窗口右侧的距离(等于 x + width)



进一步优化

除了上面这些,我还做一个优化,啥优化呢?就是在vue中因为是响应式驱动,只要数据一发生变化就会触发视图更新,但是如果变化的太频繁,也会特别卡,所以我就添加了一个节流,让他一秒更新一次,但是这个优化其实是有一丢丢问题的。为什么呢,比如以一秒为一个时间跨度,他本来是在0.5秒更新的,但是我现在把他变成了在1秒更新,在某种意义上他就并不实时了。但是做了这个操作,性能肯定是比之前好得多,这就涉及到一个平衡了,毕竟鱼和熊掌不可兼得嘛。因为保密协议巴拉巴拉的,我就给大家写了个伪代码。


// 表格绑定的值
const tableData = ref([])
// 表格原始值
const tableRow = toRaw(tableData.value)
// 定时器变量
let timer
// 更新函数
const updateData = (resData) => {
// resData是websocket服务端推送的一个数据更新的数组,我们假设resData这个数据结构是下面这样
// [{
// id: "",
// data: {}
// },
// {
// id: "",
// data: {}
// }]
resData.forEach(item => {
// 更新的id
const Id = item.id
// 先去找tableRow原始值中找到对应的数据
const dataItem = tableRow.findIndex(row => row.id == Id)
// 更新tableRow原始值数据
dataItem[data] = item.data
})
if(!timer){
timer = setTimeout(()=>{
// 这个时候才去更新tableData再去更新视图
tableData.value = [...tableRow]
timer = null
},1000)
}
}

我大概的讲一下这段代码在干嘛。假设我这个表格绑定的值是tableData,我用vue3的toRaw方法,将这个拷贝一份形成一个没有响应式的值为tableRow。这里提一嘴,toRaw这个方法并不是深拷贝,他只是丧失了响应式了,改变tableRow的值,tableData也会发生变化但是不会更新视图updateData大家可以看成封装好的更新方法。传入的参数为服务端推送的数据,它是一个全是对象的数组。这段代码的核心就是服务端推送的数据先去更新tableRow的值,再利用节流实现一秒更新一次tableData的值


toRaW

这里再给大家分享一个知识,大家可以看到我去更新的tableData的值的时候是新创建了一个数组,然后用...扩展运算符去浅拷贝。这是因为如果直接用toRaw后的对象去赋值给响应式的的对象,这个对象也会丧失响应式。但是如果只是某一个属性单独赋值是不会丧失响应式的


单独属性赋值


import { reactive, toRaw } from 'vue';
const state = reactive({ count: 0 });
const rawState = toRaw(state);
// 将原始对象的属性值赋给响应式对象的属性
state.count = rawState.count;
const increment = () => {
state.count++;
};
increment();
console.log(state.count); // 响应式更新,输出1

整个对象赋值


import { reactive, toRaw } from 'vue';
const state = reactive({ count: 0 });
const rawState = toRaw(state);
// 错误地用原始对象替换响应式对象
state = rawState;
// 这会导致错误,因为不能重新赋值响应式对象本身,并且响应式关联被破坏

并不是深拷贝


import { reactive, toRaw } from 'vue';
const nestedObj = reactive({
a: 1,
b: {
c: 2
}
});
const rawObj = toRaw(nestedObj);
// 修改原始对象的属性
rawObj.a = 10;
console.log(nestedObj.a); // 输出10,说明不是深拷贝,因为修改原始对象影响了响应式对象

总结


其实整体来看,并没有做一些高大上的操作,但是性能确实好了很多很多。去年面试的时候被问到性能优化总是会很慌张,因为我一直觉得的性能优化特别牛逼,我也确实没有做过什么性能优化的操作,只能背一些八股文,什么防抖节流,图片懒加载,虚拟列表......然后我想表达啥呢,因为我觉得肯定很多人面试的时候很怕被问到性能优化,特别是现在在准备秋招春招啥的,因为我也刚毕业三四个月,我包有体会的。所以我想告诉大家的意思的,性能优化并没有这么高端,只要是能让你的项目变好的,都是性能优化。实在不行,你就好好看哥们的写的东西,你就说这个表格是你写,反正面试不就是糊弄面试官的吗,自信!


作者:落课
来源:juejin.cn/post/7430026536215281698
收起阅读 »

将B站作为曲库,实现一个畅听全网歌曲的音乐客户端

web
仓库地址 github.com/bb-music/fl… 前言 在很久之前做了一个能够免费听周杰伦歌曲的网页,经历了各种歌曲源失效的问题之后,换了一种实现思路,将B站作为曲库,开发一个应用,这样只要B站不倒,就可以一直白嫖歌曲了。 实现思路 B 站上有很多的...
继续阅读 »

仓库地址


github.com/bb-music/fl…


前言


在很久之前做了一个能够免费听周杰伦歌曲的网页,经历了各种歌曲源失效的问题之后,换了一种实现思路,将B站作为曲库,开发一个应用,这样只要B站不倒,就可以一直白嫖歌曲了。


实现思路



  1. B 站上有很多的音乐视频,相当于一种超级全的音乐聚合曲库(索尼直接将 B 站当做网盘,传了 15w 个视频)

  2. 对这些视频进行收集制作成歌单

  3. 无需登录即可完整播放,无广告

  4. 使用 SocialSisterYi 整理的 B 站接口文档,直接就可以获取和搜索 B 站视频数据


功能



  • 播放器

    • 基础功能(播放,暂停,上一首,下一首)

    • 播放列表

    • 单曲循环,列表循环,随机播放

    • 进度拖动

    • 计时播放



  • 搜索

    • 名称关键字搜索



  • 歌单

  • 歌单同步

  • 歌单广场(由用户贡献分享自己的歌单)


技术栈



  1. Flutter


缺陷



  1. 没有 IOS 版本(上架太贵了)

  2. 没有歌词

  3. 云同步配置麻烦一些,(隐私与便利不可兼得)


UI


1.png


2.png


3.png


4.png


警告


此项目仅供个人学习使用,请勿用于商业用途,否则后果自负。


鸣谢致敬



  1. SocialSisterYi 感谢这个库的作者和相关贡献者

  2. 感谢广大 B 站网友们提供的视频资源


作者:阿炸克斯
来源:juejin.cn/post/7414129923633905675
收起阅读 »

THREE.JS——让你的logo切割出高级感

web
灵感图 每次都根据灵感图写代码,我都快成灵感大王了,本文较长,跨度较大,效果较好,请耐心看完,本文阶段代码有tag可以分部查看 前言 这是B站的一段视频,用3D渲染的方式表达各个大厂的logo如何制作出来的,其中提取出一小段,用于本文的灵感,就是这个图的切割...
继续阅读 »

灵感图


2024-02-03 18.05.27.gif

每次都根据灵感图写代码,我都快成灵感大王了,本文较长,跨度较大,效果较好,请耐心看完,本文阶段代码有tag可以分部查看


前言


这是B站的一段视频,用3D渲染的方式表达各个大厂的logo如何制作出来的,其中提取出一小段,用于本文的灵感,就是这个图的切割效果,下文不包含激光的圆圈和工作平台,只有切割的光线、切割效果和分离动画,灵感图中切割的部分是超过logo的,如果有UI设计师,可以让设计师给提供分段的svg,我孤军奋战没有那么些资源,文中的点位都是从logo的svg文件获取的,场景创建就不赘述了,以前的文章也讲过很多次,那么我们开始吧


准备工作



  • threejs

  • ts

  • vite


找一个这个小鸟的svg文件。


将svg文件的点位获取出来并将svg加入到场景中


渲染svg


// 加载模型
const loadModel = async () => {

svgLoader.load('./svg/logo.svg', (data) => {

const material = new THREE.MeshBasicMaterial({
color: '#000',
});

for (const path of data.paths) {
const shapes = SVGLoader.createShapes(path);
for (const shape of shapes) {
const geometry = new THREE.ShapeGeometry(shape);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh)
}

}

renderer.setAnimationLoop(render)
})
}
loadModel()

渲染结果


image.png

svg加载出来后的shape就是组成当前logo的所有关键点位信息,接下来要做的是将这个logo以正确的角度放置在场景,再将这些关键点位生成激光运动路径,比如一个圆弧,是一个贝塞尔曲线,有两个定点,几个手柄,通过不同的角度组成曲线,而我们要做的是一条布满点位的曲线作为运动路径


image.png

获取曲线点位


这里用到的api是# CubicBezierCurve贝塞尔曲线的基类Curve对象提供的方法getPoints



.getPoints ( divisions : Integer ) : Array


divisions -- 要将曲线划分为的分段数。默认是 5.



为了更方便的查看我们创建的点位,我们将生成的点位信息创建一个cube


// 加载模型
const loadModel = async () => {
...
for (const curve of shape.curves) {
/*
* .getPoints ( divisions : Integer ) : Array
* divisions -- 要将曲线划分为的分段数。默认是 5.
*/

const points = curve.getPoints(100);
console.log(points);
for (const v2 of points) {
const geometry = new THREE.BoxGeometry(10, 10, 10);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(v2.x, v2.y, 0)
scene.add(cube);
}

}
...
}

}

renderer.setAnimationLoop(render)
})
}
loadModel()

从图中可以看出,现在cube已经绕着logo围成一圈了,但是有一个现象,就是路径长的地方cube比较稀疏,而路径比较短的曲线cube比较密集,上面代码创建的关键点位信息都是以100的数量创建,所以会导致这种情况,曲线的疏密程度决定将来激光的行走速度,为了保证不管多长的路径,他们的行走速度是一样的,那么我们需要动态计算一下到底该以多少个点位来生成这条路径


image.png


...
const length = curve.getLength ();
const points = curve.getPoints(Math.floor(length/10));
...

在遍历curve的时候,通过getLength获取曲线的长度,根据长度的不同,决定分段的点位数量,这样就保证了点位之间的距离是一样的,将来激光行走的速度也是可以控制成一样的,速度一样,距离越短,越先完成,当然你想让所有激光都同时完成,那是不需要让分割的点位分布均匀的。


提取点位信息


由于之前我们获取到了所有的点位信息,那么是不要加载原有的svg生成的logo,所以我们现在要将获取到的分割点,改为vector3,并缩小一下logo,这样方便以后操作


// 新建一个二维数组用于收集组成logo的点位信息
// 用于计算box3的点位合集
let divisionPoints: THREE.Vector2[] = []
// 用于计算box3的点位合集
let divisionPoints: THREE.Vector3[] = []
// 将遍历贝塞尔曲线的地方再改造一下
let list: THREE.Vector3[] = []
/*
* .getPoints ( divisions : Integer ) : Array
* divisions -- 要将曲线划分为的分段数。默认是 5.
*/

const length = curve.getLength();

const points = curve.getPoints(Math.floor(length / 20));
for (const v2 of points) {
// logo 太大了,缩小一下,这里不建议用scale缩svg,直接缩向量
v2.divideScalar(20)
const v3 = new THREE.Vector3(v2.x, 0, v2.y)
list.push(v3)
divisionPoints.push(v2)
}

paths.push(list)

制作底板并将logo和底板统一放在视图中心


在此之前需要先定义几个变量,用于之后的使用


const logoSize = new THREE.Vector2()
const logoCenter = new THREE.Vector2()
// 底板厚度
const floorHeight = 3
let floor: THREE.Mesh | null
// 底板比logo的扩张尺寸
let floorOffset = 8


根据点位信息收集logo 的信息


根据之前收集的点位信息创建出底板和logo



const handlePaths = () => {
const box2 = new THREE.Box2();
box2.setFromPoints(divisionPoints)
box2.getSize(logoSize)
box2.getCenter(logoCenter)
createFloor()
}

创建地板和logo



const createFloor = () => {
const floorSize = logoSize.clone().addScalar(floorOffset)
const geometry = new THREE.BoxGeometry(floorSize.width, floorHeight, floorSize.height);
const material = new THREE.MeshLambertMaterial({ color: 0x6ac3f7 });
floor = new THREE.Mesh(geometry, material);
scene.add(floor);

createLine()

}

const createLine = () => {
const material = new THREE.LineBasicMaterial({
color: 0x0000ff
});

const points: THREE.Vector3[] = [];
divisionPoints.forEach(point => {
points.push(new THREE.Vector3(point.x, floorHeight, point.y))
})

const geometry = new THREE.BufferGeometry().setFromPoints(points);

const line = new THREE.Line(geometry, material);
const linePos = logoSize.clone().divideScalar(-2)
line.position.set(linePos.x, 0, linePos.y)
scene.add(line);
}


我们之前加载的svg已经没有用了,只是为了提供点位信息,所以需要再根据整理后的点位信息创建一个logo的Line对象


效果图


image.png


绘制激光


创建4(可自定)条激光,起点从底板上方30的位置,结束于logo,然后结束的点位随着logo的点位进行改变,从而实现激光运动的效果,提前先确定一下激光起点,


判断起点


由于激光数量可以自定,那么我们需要自定义一个激光的数量,当前用的数量是10,而要配置不同数量的激光,位置就需要有一定的规则,下面代码是创建了一个圆弧,以激光数量为基础,在圆弧上获取相应的点位,这样不管多少个激光,都可以从这个圆弧上取起点位置,圆弧的半径是以logo为基础向内缩进的,而结束点,目前定在底板的下面。


// 激光组
const buiGr0up = new THREE.Gr0up()
// 激光起点相对于logo缩进的位置
const buiDivide = 3
// 决定激光起点距离场景中心的距离
const buiOffsetH = 30
// 决定有几条激光
const buiCount = 10

const createBui = () => {
// 创建一个圆弧,将来如果有很多激光,那么起点就从圆弧的点位上取
var R = Math.min(...logoSize.toArray()) / buiDivide; //圆弧半径
var N = buiCount * 10; // 根据激光的条数生成圆弧上的点位数量
// 批量生成圆弧上的顶点数据
const vertices: number[] = []
for (var i = 0; i < N; i++) {
var angle = 2 * Math.PI / N * i;
var x = R * Math.sin(angle);
var y = R * Math.cos(angle);
vertices.push(x, buiOffsetH, y)
}

// 创建圆弧的辅助线
initArc(vertices)

for (let i = 0; i < buiCount; i++) {

const startPoint = new THREE.Vector3().fromArray(vertices, i * buiCount * 3)
const endPoint = new THREE.Vector3()


endPoint.copy(startPoint.clone().setY(-floorHeight))
// 创建cube辅助块
const color = new THREE.Color(Math.random() * 0xffffff)
initCube(startPoint, color)
initCube(endPoint, color)

}
}

效果图


image.png


每两个相同的颜色就是当前激光一条激光的两段


line2


下面该创建激光biu~,原理上是一条可控制宽度的线,虽然threejs中的线条材质提供的linewidth来控制线宽,但是属性下面有说明,无论怎么设置,线宽始终是1,所以我们要用另一种表现形式:Line2



.linewidth : Float


控制线宽。默认值为 1

由于OpenGL Core Profile与 大多数平台上WebGL渲染器的限制,无论如何设置该值,线宽始终为1。



import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";

...

const createLine2 = (linePoints: number[]) => {
const geometry = new LineGeometry();
geometry.setPositions(linePoints);
const matLine = new LineMaterial({
linewidth: 0.002, // 可以调整线宽
dashed: true,
opacity: 0.5,
color: 0x4cb2f8,
vertexColors: false, // 是否使用顶点颜色
});

let biu = new Line2(geometry, matLine);
biuGr0up.add(biu);
}


调用initBiu~


createLine2([...startPoint.toArray(),...endPoint.toArray()])

效果图


image.png


准备工作大致就到此结束了,接下来要实现的效果是激光运动激光发光logo切割


激光效果


首先先把激光的数量改为4,再将之前收集到的logo坐标点位分成四份,每根激光负责切割其中一份,切割的过程就是将激光的endpoint进行改变。


激光运动


计算激光结束点位置


在创建好激光后调用biuAnimate方法,这个方法更新了激光的结束点,遍历之前从svg上获取的点位信息,将这些点位以激光的数量等分,再将这些点位信息作为Line2的顶点信息,通过setInterval的形式更新到激光的Line2


const biuAnimate = () => {
console.log('paths', paths, divisionPoints);
// biuCount
// todo 这里要改成points这样的 每次切割完 收缩一下激光,再伸展出来
const allPoints = [...divisionPoints]
const len = Math.ceil(allPoints.length / biuCount)
for (let i = 0; i < biuCount; i++) {
const s = (i - 1) * len
const points = allPoints.splice(0, len);
const biu = biuGr0up.children[i] as Line2;
const biuStartPoint = biu.userData.startPoint
let j = 0;

const interval = setInterval(() => {
if (j < points.length) {
const point = points[j]
const attrPosition = [...biuStartPoint.toArray(), ...new THREE.Vector3(point.x, floorHeight/2, point.y).add(getlogoPos()).toArray()]
uploadBiuLine(biu, attrPosition)

j++
} else {
clearInterval(interval)
}
}, 100)

}
}

// 更新激光信息
const uploadBiuLine = (line2: Line2, attrPosition) => {
const geometry = new LineGeometry();
line2.geometry.setPositions(attrPosition);
}


效果图


2024-02-05 16.21.15.gif

根据激光经过的路径绘制logo


首先隐藏掉原有的logo,以每一条激光为维度,创建一个THREE.Line,这样我们就有了4条曲线,在每次激光经过的点作为这条曲线的节点,去更新BufferGeometry


创建激光的部分代码


 for (let i = 0; i < biuCount; i++) {
...
// 创建线段
const line = createLine()
scene.add(line)
const interval = setInterval(() => {
if (j < points.length) {
const point = points[j]
const endArray = new THREE.Vector3(point.x, floorHeight / 2, point.y).add(getlogoPos()).toArray()
const attrPosition = [...biuStartPoint.toArray(), ...endArray]
...
// 获取原有的点位信息
const logoLinePointArray = [...(line.geometry.attributes['position']?.array||[])];

logoLinePointArray.push(...endArray)
// 更新线段
line.geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(logoLinePointArray), 3))

j++
} else {
clearInterval(interval)
}
}, 100)

}

2024-02-19 09.51.53.gif


image.png

从图中可以看到,每根曲线之间的衔接做的并不是很到位,所以稍微改造一下代码,将上一根线的最后一个点位给到当前的线,


const points = allPoints.splice(0, len);
// allPoints是截取到上一轮点位的其余点位,所以第一个就是当前激光相邻的第一个点
if(i<biuCount-1) {
points.push(allPoints[0])
} else {
//最后一条曲线需要加的点是第一条线的第一个点
points.push(divisionPoints[0])
}

image.png

logo分离


激光切割完毕后,logo和底板将分离,之前想用的是threeBSP进行布尔运算进行裁切,但是对于复杂的logo使用布尔运算去裁切太消耗资源了,简单的几何形状可以。


创建裁切的多余部分


创建裁切的过程其实就是新增和删除的过程,新增一个logo和多余部分,再将原有的底板删除掉


这里多余的部分使用shape的孔洞,底板尺寸生成的形状作为主体,logo作为孔洞,结合起来后,将得到的shape进行挤压


创建logo和多余部分的几何体


在外部创建logo和多余部分的shape


// 用于创建logo挤压模型的形状Shape
const logoShape = new THREE.Shape()
// 用于创建多余部分的挤压模型形状
const moreShape = new THREE.Shape()

loadModel方法新增代码,用于收集logoShape的点位信息



// 加载模型
const loadModel = async () => {
...
for (let i = 0; i < points.length - 1; i++) {
const v2 = points[i]
if (v2.x !== 0 && v2.x && v2.y !== 0 && v2.y) {
// logo 太大了,缩小一下,这里不建议用scale缩svg,直接缩向量,后面依赖向量的元素都需要重新绘制
v2.divideScalar(20)
const v3 = new THREE.Vector3(v2.x, 0, v2.y)
list.push(v3)
divisionPoints.push(v2)
if (i === 0) {
logoShape.moveTo(v2.x, v2.y)
} else {
logoShape.lineTo(v2.x, v2.y)
}
}
}
...
}

createFloor方法创建moreMesh多余部分的挤压几何体


const createFloor = () => {
const floorSize = logoSize.clone().addScalar(floorOffset)
const geometry = new THREE.BoxGeometry(floorSize.width, floorHeight, floorSize.height);

floor = new THREE.Mesh(geometry, logoMaterial);
// scene.add(floor);

moreShape.moveTo(floorSize.x / 2, floorSize.y / 2);
moreShape.lineTo(-floorSize.x / 2, floorSize.y / 2);
moreShape.lineTo(-floorSize.x / 2, -floorSize.y / 2);
moreShape.lineTo(floorSize.x / 2, -floorSize.y / 2);


const path = new THREE.Path()

const logoPos = new THREE.Vector3(logoCenter.x, floorHeight / 2, logoCenter.y).negate()

// logo实例
logoMesh = createLogoMesh(logoShape)
logoMesh.position.copy(logoPos.clone().setY(floorHeight))
logoMesh.material = new THREE.MeshLambertMaterial({ color: 0xff0000, side: THREE.DoubleSide });
scene.add(logoMesh);

// 孔洞path
divisionPoints.forEach((point, i) => {
point.add(logoCenter.clone().negate())
if (i === 0) {
path.moveTo(point.x, point.y);
} else {
path.lineTo(point.x, point.y);
}
})
// 多余部分添加孔洞
moreShape.holes.push(path)
// 多余部分实例
moreMesh = createLogoMesh(moreShape)
// moreMesh.visible = false
scene.add(moreMesh)

}

经过以上的改造,画面总共分为三个主要部分,激光、多余部分、logo。


2024-02-19 15.58.25.gif


大概效果就是这样的,再加上动画,让激光有收起和展开,再加上切割完以后,多余部分的动画,那这篇教程基本上就完事儿了,下面优化的部分就不一一展示了,可以看最终的效果动图,也可以从gitee上将代码下载下来自行运行


推特logo


2024-02-19 18.20.28.gif


抖音 logo


2024-02-19 18.20.52.gif


github logo


2024-02-19 18.19.59.gif



动图比较大,可以保存在本地查看



项目地址


作者:孙_华鹏
来源:juejin.cn/post/7337169269951283235
收起阅读 »

BOE(京东方)首度全面解读ADS Pro液晶显示技术众多“真像” 倡导以创新推动产业高价值增长

10月28日,BOE(京东方)“真像 只有一个”ADS Pro技术品鉴会在上海举行。BOE(京东方)通过打造“光影显真”、“万像归真”、“竞速见真”三大场景互动区,以及生动鲜活的实例和现场体验,揭示了众多“真像”,解读了行业误区以及消费者认知偏差,不仅全面展示...
继续阅读 »

10月28日,BOE(京东方)“真像 只有一个”ADS Pro技术品鉴会在上海举行。BOE(京东方)通过打造“光影显真”、“万像归真”、“竞速见真”三大场景互动区,以及生动鲜活的实例和现场体验,揭示了众多“真像”,解读了行业误区以及消费者认知偏差,不仅全面展示了ADS Pro技术在高环境光对比度、全视角无色偏、高刷新率和动态画面优化等方面的卓越性能表现,以及液晶显示技术蓬勃的生命力,更是极大推动了全球显示产业的良性健康发展。活动现场,BOE(京东方)高级副总裁、首席技术官刘志强,京东集团副总裁、京东零售家电家居生活事业群总裁李帅等出席并发表致辞,并在行业嘉宾、媒体伙伴的见证下,共同发起“产业高价值增长倡议”,标志着中国显示产业开启从价格竞争走向价值竞争的高质发展新时代。

BOE(京东方)高级副总裁、首席技术官刘志强表示,人类对真相的探究永无止境,而显示技术的“真像”也需要还原最真实的色彩和场景。作为中国大陆液晶显示产业的先行者和领导者,BOE(京东方)在市场规模、出货量、技术、应用等方面遥遥领先,如今,有屏的地方就有京东方,如何选好屏,也成为当下消费者最关注的议题之一。作为三大技术品牌之一,BOE(京东方)自主研发的ADS Pro是应用最广的主流液晶显示技术,搭载ADS Pro技术的产品目前全球出货量最高。BOE(京东方)通过不断技术创新,依托ADS Pro技术的超高环境光对比度、超广视角、超高刷新率等优势,不断迭代升级并推出包括BD Cell、UB Cell、以及ADS Pro+Mini LED背光等创新显示解决方案,引领显示行业技术发展潮流,带领中国屏幕走向全球。

京东集团副总裁、京东零售家电家居生活事业群总裁李帅表示,作为BOE(京东方)自主研发的高端显示技术,ADS Pro在高对比度、更广色域、超广全视角、超低反射率等方面的技术特性,极大程度满足了用户对于高端电视的消费需求,今年618期间,ADS Pro电视在京东的成交额同比增长超过3倍。京东与BOE(京东方)共同打造了全域内容营销矩阵,通过整合京东站内外内容,用好内容积攒产品口碑。未来,“双京”将持续强强联手,让更多人了解ADS Pro技术、选购ADS Pro技术赋能的高端电视,让更好的视听体验走进千家万户。

在品鉴会现场,BOE(京东方)带领行业探寻了一系列ADS Pro的技术真相:

真相一:在环境光对比度方面,通常传统液晶显示技术所呈现的对比度多数用暗室对比度进行测试,脱离用户真实使用场景的数值是毫无意义的在真实应用场景中,室内常规照明情况下的环境光对比度对用户更有意义,也是决定用户真实体验的关键指标,BOE(京东方)创新升级环境光对比度(ACR)这一更加适配当前屏幕使用场景的测试指标,更准确地表征人眼真实感知的对比度。作为中国唯一拥有自主知识产权的高端液晶显示技术,BOE(京东方)ADS Pro技术对比度测试标准从用户体验出发,在近似真实的使用场景下进行数据测试,ACR数值高达1400:1,与其他同类技术相比具有显著优势。同时,通过像素结构优化、新材料开发、表面处理等多种减反技术,大幅降低了显示屏幕光线反射率,达到远超常规显示技术的超高环境光对比度,实现更高的光线适应性和更佳的画质表现。在BOE(京东方)ACR技术的加持下,能够让消费者在观看屏幕时无需受到环境光干扰。

真相二:在广视角方面,传统测量标准下,几乎所有产品都能达到所谓的“广视角”,但实际观看效果有很大区别,“色偏视角”才能更客观反馈广视角显示效果。大屏观看时,产品色偏问题显得尤为突出。色偏是指屏幕在侧视角观看时,亮度/色彩的变化与正视角观看时的差异,色偏视角能真实呈现色彩的“本真”。在所有显示技术中,ADS Pro在广视角观看时显示效果最能够达到真实还原,实现接近180°的超广视角,且全视角无色偏、无褪色,让消费者实现家庭观影处处都是“C位”,这也成为ADS Pro技术的另一大重要优势。

真相三:高端LCD显示技术依然是大屏电视产品的主流,并实现了媲美OLED的显示效果。基于BOE(京东方)ADS Pro技术进一步升级的高端LCD解决方案UB Cell,所呈现的完美画质可以媲美OLED,甚至超越它的显示效果,这是LCD显示技术领域发展的重要里程碑。BOE(京东方)UB Cell技术在感知画质、信赖性、能耗等方面相较于其他技术更具优势。由于采用了多种减反技术,UB Cell显示屏的表面反射率大幅降低,使其环境光对比度远高于市场旗舰机型中应用其他技术的产品,从而极大提升屏幕的亮态亮度和暗态亮度的比值,让画面显示的暗态部分更暗、亮态部分更亮,画质更加细腻逼真。同时,BOE(京东方)通过开发新型光学相位补偿技术,实现了超宽视角,使得UB Cell技术的大视角色彩表现能力进一步提升。此外,借助ADS Pro技术的特有像素结构,配合宽频液晶材料体系和驱动算法,可以全灰阶满足G-sync 标准,完全无屏闪,更护眼的同时也让显示画面更稳定更流畅。

真相四:ADS Pro广视角和高刷的优势,结合Mini LEDHDR极致暗态画质的优异表现,让二者做到最完美的优势互补这样的组合才是画质提升的最佳拍档!BOE(京东方)采用高光效LED+高对比度ADS Pro面板+画质增强算法方案,相比其他显示技术有更出众的表现,不仅实现更宽的亮度范围,还进一部拓展了高亮度灰阶,扩充暗场景灰阶层次感。此外,随着刷新率的不断提升,通过ADS Pro+Mini LED实现分区动态差黑,可以极大提升高速运动画面清晰度,显著减少卡顿、延迟等现象,树立电竞显示的性能画质新标杆。目前,ADS Pro+Mini LED解决方案已成为全品类产品的应用趋势。

五:为目前全球领先的主流液晶显示技术,BOE(京东方)ADS Pro是唯一可以覆盖从手机、平板电脑、笔记本、显示器到电视所有产品类型的技术。ADS Pro技术在大屏上的优势更加明显,并且通过专业级高端画质、极致的视觉享受及一系列健康护眼技术,为各行业客户打开新的增长空间。目前ADS Pro技术在显示器、笔记本、平板电脑、大屏高端电视等领域市场份额逐年攀升,已成为全球各大一线终端品牌高端机型的首选。群智咨询总经理李亚琴表示,五年后,LCD面板出货面积较当前水平将达到两位数增幅。在用户需求和技术升级的双重驱动下,ADS/IPS技术在IT市场将位居绝对主流地位并逐年提升份额;尤其在电竞领域,用户对高分辨率、高刷新率、快速响应时间、高色域、更大尺寸屏幕等显示性能提升有很高的期待。

当前,显示无处不在的时代已经到来,显示技术的持续迭代升级,背后的“真像”是中国科技力量在全球发挥着越来越重要的价值。作为全球半导体显示行业龙头企业,BOE(京东方)携手行业伙伴共同发起倡议,呼吁行业各界合作伙伴打破内卷,以技术升维不断提升用户体验,从聚焦价格的“零和博弈”走向聚焦价值的“融合共生”,开辟新技术、新赛道、新模式,共同发展高科技、高效益、高质量的新质生产力!未来,以BOE(京东方)为代表的中国科技企业也将持续创新,为消费带来更真实、更智能、更时尚、更节能的显示技术和产品,引领中国屏幕走向全球,为产业高质升维发展注入源源不断的新动能。

收起阅读 »

微信的消息订阅,就是小程序有通知,可以直接发到你的微信上

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
收起阅读 »

WebGL实现soul星球效果

web
WebGL实现soul星球效果 最近在研究webGL,觉得soul app的星球挺有意思的,于是就实现了一下,中间涉及的细节和知识点挺多的,写篇博客分享一下 soul原版 WebGL实现的 主要技术要点 1.使用黄金分割数螺旋分配使小球在球表面均匀分布 ...
继续阅读 »

WebGL实现soul星球效果


最近在研究webGL,觉得soul app的星球挺有意思的,于是就实现了一下,中间涉及的细节和知识点挺多的,写篇博客分享一下


soul原版


描述

WebGL实现的


描述


主要技术要点


1.使用黄金分割数螺旋分配使小球在球表面均匀分布

使用不同的goldenRatio可以得到非常多分布效果,采用黄金分割数在视觉上最匀称、舒服


const goldenRatio = (1 + Math.sqrt(5)) / 2 
const y = 1 - (i / (numPoints - 1)) * 2
const radiusAtY = Math.sqrt(1 - y * y)
const theta = (2 * Math.PI * i) / goldenRatio
const x = Math.cos(theta) * radiusAtY
const z = Math.sin(theta) * radiusAtY

2.自由转动

因为要解决万向锁的问题,所以不能使用rotateXrotateYrotateZ来旋转,应当使用四元数THREE.Quaternion


3.背面小球变暗

这里通过内部放置了一个半透明的黑色小球来实现


// 创建半透明球体
const sphereGeometry = new THREE.SphereGeometry(4.85, 16, 16)

为了使小球从正面转动的背面的过程中可以平滑的变暗,这里还需要把半透明小球的边沿处理成高斯模糊,具体实现就是使用GLSL的插值函数smoothstep


fragmentShader: `
uniform vec3 color;
uniform float opacity;
varying vec3 vNormal;
void main() {
float alpha = opacity * smoothstep(0.5, 1.0, vNormal.z);
gl_FragColor = vec4(color, alpha);
}

但是需要注意的是需要关闭小球的深度测试,否则会遮挡小球



side: THREE.FrontSide,
depthWrite: false,


4.使用THREE.Sprite创建小球标签

5.标签位置计算

for (let i = 0; i < numPoints; i++) {
const y = 1 - (i / (numPoints - 1)) * 2
const radiusAtY = Math.sqrt(1 - y * y)

const theta = (2 * Math.PI * i) / goldenRatio

const x = Math.cos(theta) * radiusAtY
const z = Math.sin(theta) * radiusAtY
const smallBallMaterial = new THREE.MeshBasicMaterial({
color: getRandomBrightColor(),
depthWrite: true,
depthTest: true,
side: THREE.FrontSide,
})
const smallBall = new THREE.Mesh(smallBallGeometry, smallBallMaterial)
smallBall.position.set(x * radius, y * radius, z * radius)


6.超出长度的标签采用贴图采样位移来实现跑马灯效果

7.滚动阻尼,鼠标转动球体之后速度能衰减到转动旋转的速率

8.自动旋转需要保持上一次滚动的方向

9.使用射线拾取来实现点击交互

完整代码


<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>3D 半透明球体与可交互小球</title>
<style>
body {
margin: 0;
background-color: black;
touch-action: none;
}
canvas {
display: block;
}
</style>
</head>
<body>
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js'

// 创建场景
const scene = new THREE.Scene()

// 创建相机
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
)
camera.position.set(0, 0, 14)
camera.lookAt(0, 0, 0)

// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setClearColor(0x000000, 0)
document.body.appendChild(renderer.domElement)

// 创建半透明球体
const sphereGeometry = new THREE.SphereGeometry(4.85, 16, 16)
const sphereMaterial = new THREE.ShaderMaterial({
uniforms: {
color: { value: new THREE.Color(0x000000) },
opacity: { value: 0.8 },
},
vertexShader: `
varying vec3 vNormal;
void main() {
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
}
`
,
fragmentShader: `
uniform vec3 color;
uniform float opacity;
varying vec3 vNormal;
void main() {
float alpha = opacity * smoothstep(0.5, 1.0, vNormal.z);
gl_FragColor = vec4(color, alpha);
}
`
,
transparent: true,
side: THREE.FrontSide,
depthWrite: false,
})

const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial)
scene.add(sphere)

// 创建小球体和标签数组
const smallBallGeometry = new THREE.SphereGeometry(0.15, 16, 16)
const smallBalls = []
const labelSprites = []

const radius = 5
const numPoints = 88
const goldenRatio = (1 + Math.sqrt(5)) / 2
const maxWidth = 160
const textSpeed = 0.002

// 创建射线投射器
const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()

function createTextTexture(text, parameters = {}) {
const {
fontSize = 24,
fontFace = 'PingFang SC, Microsoft YaHei, Noto Sans, Arial, sans-serif',
textColor = 'white',
backgroundColor = 'rgba(0,0,0,0)',
maxWidth = 160,
} = parameters

const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
context.font = `${fontSize}px ${fontFace}`

const textMetrics = context.measureText(text)
const textWidth = Math.ceil(textMetrics.width)
const textHeight = fontSize * 1.2

const needMarquee = textWidth > maxWidth

let canvasWidth = maxWidth
if (needMarquee) {
canvasWidth = textWidth + 60
}

canvas.width = canvasWidth
canvas.height = textHeight
context.font = `${fontSize}px ${fontFace}`
context.clearRect(0, 0, canvas.width, canvas.height)

context.fillStyle = backgroundColor
context.fillRect(0, 0, canvas.width, canvas.height)

context.fillStyle = textColor
context.textAlign = needMarquee ? 'left' : 'center'
context.textBaseline = 'middle'

if (needMarquee) {
context.fillText(text, 0, canvas.height / 2)
} else {
context.fillText(text, maxWidth / 2, canvas.height / 2)
}

const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true

if (needMarquee) {
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.ClampToEdgeWrapping
texture.repeat.x = maxWidth / canvas.width
} else {
texture.wrapS = THREE.ClampToEdgeWrapping
texture.wrapT = THREE.ClampToEdgeWrapping
}

texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter
texture.generateMipmaps = false
return { texture, needMarquee, HWRate: textHeight / maxWidth }
}

for (let i = 0; i < numPoints; i++) {
const y = 1 - (i / (numPoints - 1)) * 2
const radiusAtY = Math.sqrt(1 - y * y)

const theta = (2 * Math.PI * i) / goldenRatio

const x = Math.cos(theta) * radiusAtY
const z = Math.sin(theta) * radiusAtY
const smallBallMaterial = new THREE.MeshBasicMaterial({
color: getRandomBrightColor(),
depthWrite: true,
depthTest: true,
side: THREE.FrontSide,
})
const smallBall = new THREE.Mesh(smallBallGeometry, smallBallMaterial)
smallBall.position.set(x * radius, y * radius, z * radius)
sphere.add(smallBall)
smallBalls.push(smallBall)

const labelText = getRandomNickname()
const { texture, needMarquee, HWRate } = createTextTexture(labelText, {
fontSize: 28,
fontFace: 'PingFang SC, Microsoft YaHei, Noto Sans, Arial, sans-serif',
textColor: '#bbbbbb',
maxWidth: maxWidth,
})

const spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: true,
depthTest: true,
blending: THREE.NormalBlending,
})

const sprite = new THREE.Sprite(spriteMaterial)
sprite.scale.set(1, HWRate, 1)
labelSprites.push({ sprite, smallBall, texture, needMarquee, labelText })
scene.add(sprite)
}

// 添加灯光
const light = new THREE.AmbientLight(0xffffff, 0.5)
scene.add(light)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1)
directionalLight.position.set(5, 5, 5)
scene.add(directionalLight)

// 定义自动旋转速度和轴
const autoRotationSpeed = 0.0005
let autoRotationAxis = new THREE.Vector3(0, 1, 0).normalize()
let currentAngularVelocity = autoRotationAxis.clone().multiplyScalar(autoRotationSpeed)

let isDragging = false
let previousMousePosition = { x: 0, y: 0 }
let lastDragDelta = { x: 0, y: 0 }

const decayRate = 0.92
const increaseRate = 1.02

// 鼠标事件处理
const onMouseDown = (event) => {
isDragging = true
previousMousePosition = {
x: event.clientX,
y: event.clientY,
}
}

const onMouseMove = (event) => {
if (isDragging) {
const deltaX = event.clientX - previousMousePosition.x
const deltaY = event.clientY - previousMousePosition.y

lastDragDelta = { x: deltaX, y: deltaY }

const rotationFactor = 0.005

const angleY = deltaX * rotationFactor
const angleX = deltaY * rotationFactor

const quaternionY = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
angleY
)
const quaternionX = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(1, 0, 0),
angleX
)

const deltaQuat = new THREE.Quaternion().multiplyQuaternions(quaternionY, quaternionX)

sphere.quaternion.multiplyQuaternions(deltaQuat, sphere.quaternion)

const dragRotationAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
const dragRotationSpeed = Math.sqrt(deltaX * deltaX + deltaY * deltaY) * rotationFactor

if (dragRotationAxis.length() > 0) {
currentAngularVelocity.copy(dragRotationAxis).multiplyScalar(dragRotationSpeed)
}

previousMousePosition = {
x: event.clientX,
y: event.clientY,
}
}
}

const onMouseUp = () => {
if (isDragging) {
isDragging = false

const deltaX = lastDragDelta.x
const deltaY = lastDragDelta.y

if (deltaX !== 0 || deltaY !== 0) {
const newAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
if (newAxis.length() > 0) {
autoRotationAxis.copy(newAxis)
}

const dragSpeed = currentAngularVelocity.length()
if (dragSpeed > autoRotationSpeed) {
// 维持当前旋转速度
} else {
currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
}
}
}
}

// 触摸事件处理
const onTouchStart = (event) => {
isDragging = true
const touch = event.touches[0]
previousMousePosition = {
x: touch.clientX,
y: touch.clientY,
}
}

const onTouchMove = (event) => {
event.preventDefault()
if (isDragging) {
const touch = event.touches[0]
const deltaX = touch.clientX - previousMousePosition.x
const deltaY = touch.clientY - previousMousePosition.y

lastDragDelta = { x: deltaX, y: deltaY }

const rotationFactor = 0.002

const angleY = deltaX * rotationFactor
const angleX = deltaY * rotationFactor

const quaternionY = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
angleY
)
const quaternionX = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(1, 0, 0),
angleX
)

const deltaQuat = new THREE.Quaternion().multiplyQuaternions(quaternionY, quaternionX)

sphere.quaternion.multiplyQuaternions(deltaQuat, sphere.quaternion)

const dragRotationAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
const dragRotationSpeed = Math.sqrt(deltaX * deltaX + deltaY * deltaY) * rotationFactor

if (dragRotationAxis.length() > 0) {
currentAngularVelocity.copy(dragRotationAxis).multiplyScalar(dragRotationSpeed)
}

previousMousePosition = {
x: touch.clientX,
y: touch.clientY,
}
}
}

const onTouchEnd = (event) => {
if (isDragging) {
isDragging = false

const deltaX = lastDragDelta.x
const deltaY = lastDragDelta.y

if (deltaX !== 0 || deltaY !== 0) {
const newAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
if (newAxis.length() > 0) {
autoRotationAxis.copy(newAxis)
}

const dragSpeed = currentAngularVelocity.length()
if (dragSpeed > autoRotationSpeed) {
// 维持当前旋转速度
} else {
currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
}
}
}

// 检查点击事件
if (event.changedTouches.length > 0) {
const touch = event.changedTouches[0]
mouse.x = (touch.clientX / window.innerWidth) * 2 - 1
mouse.y = -(touch.clientY / window.innerHeight) * 2 + 1
checkIntersection()
}
}

// 事件监听
window.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('touchstart', onTouchStart)
window.addEventListener('touchmove', onTouchMove)
window.addEventListener('touchend', onTouchEnd)
document.addEventListener('gesturestart', function (e) {
e.preventDefault()
})

// 添加点击事件监听
window.addEventListener('click', onMouseClick)

// 处理窗口大小调整
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})

function onMouseClick(event) {
event.preventDefault()
mouse.x = (event.clientX / window.innerWidth) * 2 - 1
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
console.log(event.clientX, mouse.x, mouse.y)

checkIntersection()
}

function checkIntersection() {
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObjects(smallBalls)

if (intersects.length > 0) {
const intersectedBall = intersects[0].object
const index = smallBalls.indexOf(intersectedBall)
if (index !== -1) {
const labelInfo = labelSprites[index]
showLabelInfo(labelInfo)
}
}
}

function showLabelInfo(labelInfo) {
alert(`点击的小球标签:${labelInfo.labelText}`)
}

// 动画循环
function animate() {
requestAnimationFrame(animate)

if (!isDragging) {
const deltaQuat = new THREE.Quaternion().setFromEuler(
new THREE.Euler(
currentAngularVelocity.x,
currentAngularVelocity.y,
currentAngularVelocity.z,
'XYZ'
)
)
sphere.quaternion.multiplyQuaternions(deltaQuat, sphere.quaternion)

const currentSpeed = currentAngularVelocity.length()

if (currentSpeed > autoRotationSpeed) {
currentAngularVelocity.multiplyScalar(decayRate)

if (currentAngularVelocity.length() < autoRotationSpeed) {
currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
}
} else if (currentSpeed < autoRotationSpeed) {
currentAngularVelocity.multiplyScalar(increaseRate)

if (currentAngularVelocity.length() > autoRotationSpeed) {
currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
}
} else {
currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
}
}

// 更新标签的位置和跑马灯效果
labelSprites.forEach(({ sprite, smallBall, texture, needMarquee }) => {
smallBall.updateMatrixWorld()
const smallBallWorldPos = new THREE.Vector3()
smallBall.getWorldPosition(smallBallWorldPos)

const upOffset = new THREE.Vector3(0, 0.3, 0)

sprite.position.copy(smallBallWorldPos).add(upOffset)

if (needMarquee) {
texture.offset.x += textSpeed

if (texture.offset.x > 1) {
texture.offset.x = 0
}
}
})

renderer.render(scene, camera)
}

animate()

function getRandomBrightColor() {
const hue = Math.floor(Math.random() * 360)
const saturation = Math.floor(Math.random() * 40 + 10)
const lightness = Math.floor(Math.random() * 40 + 40)

const rgb = hslToRgb(hue, saturation, lightness)

return (rgb.r << 16) | (rgb.g << 8) | rgb.b
}

function hslToRgb(h, s, l) {
s /= 100
l /= 100

const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = l - c / 2

let r, g, b
if (h >= 0 && h < 60) {
r = c
g = x
b = 0
} else if (h >= 60 && h < 120) {
r = x
g = c
b = 0
} else if (h >= 120 && h < 180) {
r = 0
g = c
b = x
} else if (h >= 180 && h < 240) {
r = 0
g = x
b = c
} else if (h >= 240 && h < 300) {
r = x
g = 0
b = c
} else {
r = c
g = 0
b = x
}

return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
}
}

function getRandomNickname() {
const adjectives = [
'Cool',
'Crazy',
'Mysterious',
'Happy',
'Silly',
'Brave',
'Smart',
'Swift',
'Fierce',
'Gentle',
]
const nouns = [
'Tiger',
'Lion',
'Dragon',
'Wizard',
'Ninja',
'Pirate',
'Hero',
'Ghost',
'Phantom',
'Knight',
]

const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)]
const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]

const nickname = `${randomAdjective} ${randomNoun}`

if (nickname.length < 2) {
return getRandomNickname()
} else if (nickname.length > 22) {
return nickname.slice(0, 22)
}

return nickname
}
</script>
</body>
</html>


作者:前端小趴蔡
来源:juejin.cn/post/7425249244850913280
收起阅读 »

当前端遇到了自动驾驶

web
这是一篇用ThreeJS开发自动驾驶点云标注系统的实战记录,也是《THREEJS无师自通》的第一篇。通常情况,一个系列文章开篇应该是Quick Start或者Guide之类的简单口水文,而我选择以此开篇,最主要的原因还是因为这次经历足够有趣。 公众号|沐洒(...
继续阅读 »

这是一篇用ThreeJS开发自动驾驶点云标注系统的实战记录,也是《THREEJS无师自通》的第一篇。通常情况,一个系列文章开篇应该是Quick Start或者Guide之类的简单口水文,而我选择以此开篇,最主要的原因还是因为这次经历足够有趣。



公众号|沐洒(ID:musama2018)


前端开发,大家熟啊,有很多亲(bi)切(shi)的称谓,诸如“切图仔”,“Bug路由器”。自动驾驶,大家更熟了吧,最近几年但凡新能源汽车,谁要是不说自己搭配点自动驾驶(或辅助驾驶)功能,都不好意思拿出来卖。那么,当前端和自动驾驶碰到了一起,会发生什么有意思的事呢?


有点云标注相关背景的可以跳过背景普及,直接看方案。


背景   


去年9月,我们业务因为某些原因(商业机密)开始接触自动驾驶领域的数据处理,经过仔细一系列调研和盘算,我们最终决定从零开始,独立自研一套自动驾驶点云数据标注系统。你可能要说了,自动驾驶我知道啊,但是“点云”是个啥?呐,就是这玩意儿:




点云的学术定义比较复杂,大家可以自行搜索学习,这里我简单贴一个引述:
点云是指目标表面特性的海量点集合。
根据激光测量原理得到的点云,包括三维坐标(XYZ)和激光反射强度(Intensity)。 
根据摄影测量原理得到的点云,包括三维坐标(XYZ)和颜色信息(RGB)。
结合激光测量和摄影测量原理得到点云,包括三维坐标(XYZ)、激光反射强度(Intensity)和颜色信息(RGB)。 
在获取物体表面每个采样点的空间坐标后,得到的是一个点的集合,称之为“点云”(Point Cloud)。



看不懂?没事,不重要,你只需要知道,我们周围的世界,都是点构成的,而点云只不过是用一些仪器(比如激光雷达),对真实世界进行了采样(且只对部分属性进行采样)。


好了,假设你已经知道“点云”是啥了,但你心里肯定还有十万个为什么:



你不是说自动驾驶么?前端呢?这仨有啥关联么?这东西自研成本很高么?



别急,容我慢慢解释,先快速普及一下啥叫“数据标注”:



人工智能数据标注是对文本、视频、图像等元数据进行标注的过程,标记好的数据将用于训练机器学习的模型。常见的数据标注类型有文本标注、语义分割和图像视频标注。



这些经标注的训练数据集可用于训练自动驾驶、聊天机器人、翻译系统、智能客服和搜索引擎等人工智能应用场景之中


假设你懒得看,或者看不懂,我再给你翻译翻译,什么叫数据标注:


一个婴儿来到这个世界,你在它面前放两张卡片,一张红色,一张绿色,你问它,这是什么颜色,它必然是不知道的(我们假设它能听懂并理解你的话)。只有当你一遍又一遍的,不断的告诉它,这是红色,这是绿色,它才会记住。等下次你带它过马路时,它就能准确地识别出红绿灯,并在你面前大声喊出来 “红色(的灯)!”没错,你应该猜到了,那两张卡片本身没有标签(元数据),是你给它们“打上了标”(分别标注了红色和绿色),然后把这个“结构化的数据”,“喂养”给你的宝宝,久而久之,这个宝宝就学会了分辨世间万物,成为一个“智人”。


image.png


(图片来源于网络)


你的“喂养”,就是人工;宝宝的成长,就是智能。人工智能(AI,Artificial Intelligence),就是数据喂养的成果,没有数据标注,就没有人工智能。


从这个意义上聊,你和我,都是别人(父母,老师,朋友…)用成千上万的标注数据喂养出来的AI。


image.png


扯远了,收!我们说回自动驾驶。


大家都知道现在自动驾驶很火啊,那自动驾驶的“智能”是怎么训练的呢?当然是算法工程师用模型训练出来的啦,而自动驾驶模型需要喂养的数据,就是点云。仪器扫描回来的点云数据里,仅仅只是包含了所有点的基本信息(位置,颜色,激光强度等),模型怎么知道这个点是人身上采的,还是出租车上采的呢?!


image.png


(图片来源于网络)


于是这些点就需要被加工(标注),被我们用一系列手段(包括人工和机器)给点赋予更多的信息,区分出每一个点的含义(语义分割)。在自动驾驶领域的点云标注里,我们需要通过2D+3D工具,把物体识别出来。本文重点讲3D的部分。可以先看下3D框的效果:


图片


(图中黄色高亮的点,就是被3D框圈中的点云)


挑战   


以往我们较为常见的数据标注,主要集中在文本,图片,视频等类型,例如文本翻译,音频转写,图片分类等等,涉及的工具基本上都是传统web开发知识可以搞定的,而点云标注则完全不同,点云需要作为3D数据渲染到立体空间内,这就需要使用到3D渲染引擎。我们使用的是ThreeJS,这是一个基于WebGL封装的3D引擎。


写了10年的web前端代码,能有机会把玩一下3D技术,还真是挺令人兴奋的。于是我们吭哧吭哧把基本的3D拉框功能做出来了,效果是这样的:


图片


(3D拉框 - 人工调整边缘:2倍速录制)


动图是我加了2倍速的效果,真实情况是,要标出图上这辆小汽车,我需要先拉出一个大概的2D矩形区域,然后在三视图上不断的人工调整边缘细节,确保把应该纳入的点都框进去(未框入的点呈白色,框体垂直方向未框入则呈现蓝色,框入的呈现黄色)


看起来好像也还行?


no,no,no!你知道一份完整的点云标注任务需要标多少个框么?也不吓唬大家,保守点,一般情况一份连续帧平均20帧左右,每帧里要标注的框体保守点,取100个吧,而这一份连续帧标注,必须同一个标注员完成,那么20帧至少有2000个框体需要标注


按照上面实现的这种人工调节边缘的方式来拉框,一个框需要22秒(GIF共11秒,2倍速),熟练工可能能在10秒内调整完成。那么2000个框体,单纯只是拉框这一件小事,不包括其他工序(打标等),就需要耗费20000秒,约等于5.5小时



这是什么概念?通常情况标注员都是坐班制,平均一天有效工作时长不超过6小时,也就是说,一个标注员,在工位上一动不动,大气都不敢喘一下的工作一天,就只能把一条点云数据标完,哦不对,仅仅只是拉完框!没错,只是拉框而已。


这种低效的重复性工作,哪个组织受得了?怎么办呢?


方法比较容易想,不就是引入自动化能力么,实现自动边缘检测,嗯,想想倒是挺简单的,问题是怎么实现呢?


以下进入干货区,友情提示:货很干,注意补水。




方案


点云分类


基本思路就是进行边缘探测:



找出三个坐标轴(XYZ)方向上的框体边缘点,计算出边缘点之间的距离,调整框体的长宽高,进而将框体贴合到边缘点。



边缘的定义:



某方向上的同值坐标点数大于某个设定值(可配置,默认3,三者为众)



找出边缘点的核心算法:



遍历框体内的点,分别将XYZ方向的坐标值存入数组,加权,排序,取第一个满足边缘定义的点,作为该方向极限值。



进行边缘判定之前,我们得先找出存在于框体内的点,这就涉及到第一个核心问题:点云和3D框的相对位置判断


为了更好的管理与框体“强相关”的点云,我们先对点云进行一个基本分类:


image.png


从俯视图看,把3D图降维成2D图,立方体则看作矩形,如下图:


image.png


则点与框的相对位置可以降维等效为:


第一类(点在立方体内)



点在矩形内,且点的Z值在立方体[Zmin, Zmax]范围内



第二类(点在立方体垂直方向)



点在矩形内,且Z值在立方体[Zmin, Zmax]范围外



第三类(点在立方体周围)



点在延展矩形(向外延展N个距离)内,且不属于第二类。



我们先按这个思路实现一版代码:


// 判断点是否位于框体XY平面区域内
function isPointInXYPlane(gap: IGap, distance = 0) {
const { gapL, gapR, gapB, gapU } = gap;
// 在框体XY平面区域内
return gapL > - distance && gapR < distance && gapU < distance && gapB > - distance;
}
// 在框体垂直方向上下边界内
function isPointInVerticalBoundry(up: number, bottom: number, z: number) {
return z >= bottom && z <= up;
}

// 位于框体XY平面向外延伸NEAR_DISTANCE距离的区域内
if (isPointInXYPlane(posInfo.gap, NEAR_DISTANCE)) {
const isInVerticalBoundry = isPointInVerticalBoundry(posInfo.up, posInfo.bottom, posInfo.z);
// 位于框体XY平面区域内
if (isPointInXYPlane(posInfo.gap)) {
// 在框体内
if (isInVerticalBoundry) {
isInside = true;
} else {
// 在框体外的垂直方向上
isVertical = true;
}
}
// 在框体上下边界内
if (isInVerticalBoundry) {
isNearBy = true;
}
}

通过以上逻辑,我们就拿到了与框体“相关”的点云(正确与否先按下不表,后面会说),我们先存起来,后面做极值寻找(即边缘检测)时候使用。


第一版效果


图片


看起来好像还行,基本实现了贴合,但是……我们旋转一下看看:


image.png


好家伙,旋转后框体边界没更新!所以点云高亮也没变化。


这个问题其实也好理解,我们在处理边界的时候,只采用position和scale计算,并没有使用rotation属性,所以当框体的旋转分量发生变化,我们计算边界时没有及时调整,程序就会认为框体此时仍然留在原地未动呢。


我们来优化一下。我先尝试用三角函数来计算旋转后的新坐标点,类似这样


image.png


折腾了很久的三角函数,有点变化了,但是效果却成了这样:


image.png


已经接近真相了,只需要把待判定点放到三角函数判定公式里,就可以知道该点是否在旋转后的框体内了,不过到这里我突然意识到问题被我搞复杂了,是不是可以有更简单的方法来判定矩形内部点呢?


我们回到最初的问题:判断一个点,与一个立方体的相对位置


对这个原始问题进行逻辑拆解,可以拆为3个子问题:



  1. 如何判断一个点位于立方体内部?

  2. 如何判断一个点位于立方体的垂直方向(排除体内点)?

  3. 如何判断一个点位于立方体的周围(排除垂直方向点)?


关于问题1,第一反应还是立体几何,而且我笃定这是个非常成熟的几何问题,没必要自己硬憋。于是我就上网搜索:How to determine a point is inside or outside a cube? 结果如下:


image.png


image.png


上面是stackoverflow上大神给的两种数学方法,一看就知道能解,奈何我看图是看懂了,公式没有完全吸收透,于是最终没有采纳(尽量不干不求甚解的事,写成代码就要求自己得是真的懂)


于是我进一步思考:


几种数学方法确实都很虎,但我是不是把问题搞复杂了?能不能没事踩踩别人的肩膀呢?


看看ThreeJS 是否有相应的API……果然有:


图片


这不正好就是我想要的效果么?踏破铁鞋无觅处,得来全不费功夫啊!


图片


直接拿来用,搞定!


但问题来了,人家是怎么做到的呢?带着这个疑问,我开始翻相关源码。


首先看到containsPoint,其实就和我们用的方法是一样的:


// https://github.com/mrdoob/three.js/blob/4503ef10b81a00f5c6c64fe9a856881ee31fe6a3/src/math/Box3.js#L243
containsPoint( point ) {
return point.x < this.min.x || point.x > this.max.x ||
point.y < this.min.y || point.y > this.max.y ||
point.z < this.min.z || point.z > this.max.z ? false : true;
}

而核心问题还是得想办法计算出box.min和box.max,那ThreeJS是怎么计算的呢?继续看:


// https://github.com/mrdoob/three.js/blob/4503ef10b81a00f5c6c64fe9a856881ee31fe6a3/src/core/BufferGeometry.js#L290
computeBoundingBox() {
// ..... 省略部分代码 ....
const position = this.attributes.position;
if ( position !== undefined ) {
this.boundingBox.setFromBufferAttribute(position);
}
// ..... 省略部分代码 ....
}

看起来boundingBox的属性来自于attributes.position,这个position就是box在世界坐标里的具体位置,是我们在创建box时候设定的。再继续深挖下setFromBufferAttribute:


// https://github.com/mrdoob/three.js/blob/4503ef10b81a00f5c6c64fe9a856881ee31fe6a3/src/math/Box3.js#L56
setFromBufferAttribute( attribute ) {
// ..... 省略部分代码 ....
for ( let i = 0, l = attribute.count; i < l; i ++ ) {
const x = attribute.getX( i );
const y = attribute.getY( i );
const z = attribute.getZ( i );
if ( x < minX ) minX = x;
if ( y < minY ) minY = y;
if ( z < minZ ) minZ = z;
if ( x > maxX ) maxX = x;
if ( y > maxY ) maxY = y;
if ( z > maxZ ) maxZ = z;
}
this.min.set( minX, minY, minZ );
this.max.set( maxX, maxY, maxZ );
return this;
}

平平无奇啊这代码,几乎和我们自己写的边界判定代码一模一样啊,也没引入rotation变量,那到底怎么是在哪处理的旋转分量呢?


关键点在这里:


image.png


我尝试给你解释下:



在调用containsPoint之前,我们使用box的转换矩阵,对point使用了一次矩阵逆变换,从而把point的坐标系转换到了box的坐标系,而这个转换矩阵,是一个Matrix4(四维矩阵),而point是一个Vector3(三维向量)。
使用四维矩阵对三维向量进行转换的时候,会逐一提取出矩阵的position(位置),scale(缩放)和rotation(旋转)分量,分别对三维向量做矩阵乘法。
也就是这么一个操作,使得该point在经过矩阵变换之后,其position已经是一个附加了rotation分量的新的坐标值了,然后就可以直接拿来和box的8个顶点的position做简单的边界比对了。



这里涉及大量的数学知识和ThreeJS底层知识,就不展开讲了,后面找机会单独写一篇关于转换矩阵的。


我们接着看点与框体相对位置判断的第二个问题:如何判断一个点位于立方体的垂直方向(排除体内点)?


首先,我们置换下概念:


垂直方向上的点 = Z轴方向上的点 = 从俯视图看,在XY平面上投射的点 - 框内点


image.png


那么,如何判断一个点在一个矩形内,这个问题就进一步转化为:


image.png
(AB X AE ) * (CD X CE) >= 0 && (DA X DE ) * (BC X BE) >= 0


图片


这里涉及到的数学知识是向量点乘和叉乘的几何意义,也不展开了,感兴趣的朋友可以自行搜索学习下。


还剩最后一个问题:如何判断一个点位于立方体的周围(排除垂直方向点)?


这个问题我们先放一放,周围点判断主要用来扩展框体的,并不影响本次的边界探测结果,以后再找机会展开讲,这里先跳过了。


到此为止,我们就至少拿到了两类点(框内点,和框体垂直方向的点),接下来就可以开始探测边缘了。


边缘探测   


边缘探测的核心逻辑其实也不复杂,就是:



遍历框体内的点,分别将X,Y,Z方向的坐标值存入数组,加权,排序,取第一个满足边缘定义的点,作为该方向极限值。



这里我们可以拆分位两个Step。


Step 1:点位排序


基本思路如下:



选择一个方向,遍历点云,取到该方向上点云的坐标值,放入一个map中,key为坐标值,value为出现次数。同时对该坐标进行排序,并返回有序数组。**



那么问题来了,点云的坐标值多半精确到小数点七八位,如果直接以原值作为key,那么这个map很难命中重复坐标,那map的意义就不大了,难以聚合坐标。


于是这里对原坐标取2个精度后作为key来聚合点云,效果如下:


image.png


可以明显看到已经有聚合了。这是源码实现:


image.png


Step 2:夹逼探测


拿到了点云坐标的聚合map,和排序数组,那么现在要检测边缘就很简单了,基本思路就是:



从排序数组的两头开始检查,只要该点的聚合度大于DENSE_COUNT(根据需要设置,默认为3),我们就认为这个点是一个相对可信的边缘点。



从这个算法描述来看,这不就是个夹逼算法么,可以一次遍历就拿到两个极值。


image.png


到这里,某方向的两个极值(low 和 high)就拿到手了,那么剩下的工作无非就是分别计算XYZ三个方向的极值就好了。


我们来看下效果,真的是“啪”一下,就贴上去了:


640.gif


上面的案例录制的比较早,有点模糊,再来看个高清带色彩的版本:


图片


这个体验是不是很丝滑?就这效率,拉框速度提升了10倍有吧?(22秒 -> 2秒


读到这里,不知道大家还记不记得前面,我们刻意跳过了一个环节的介绍,就是“框体周围点位”这一部分,这里简单补充两句吧。


在实际的场景里,有很多物体是靠得很近的,还有很多物体的点云并没有那么整齐,会有一些离散点在物体周围。那么这些点就可能会影响到你的边缘极限值的判断。


因此我在这里引入了两个常量:


附近点判定距离 NEAR_DISTANCE(框体紧凑的场景,NEAR_DISTANCE就小一点,否则就大一点)!


image.png


密集点数 DENSE_COUNT(点云稀少的场景,就可以把DENSE_COUNT设置小一点,而点云密集厚重的场景,DENSE_COUNT就适当增加。)


图片


通过在不同的场景下,调整这两个常量的取值,就可以使得边缘探测更加的准确。




遗留问题   


其实在3D的世界里,多一个维度之后,很多问题都会变得更加的麻烦起来。上面的方案,在处理大部分场景的时候都能work,但实际上依然有一些小众场景下存在问题,比如:



平时多半都是物体都是围绕Z轴旋转,但如果有上下坡路,物体围绕XY轴旋转,那垂直方向就需要进行矫正。



再比如:



用户移动了镜头方位,在屏幕上拉2D框的时候,就需要对2D框采集到的坐标进行3D投射,拿到真实的世界坐标,才能创建合适的立方体。



当然,这些问题在后面的版本都已经完善修复了,之所以放在遗留问题,是想说明,仅仅依照正文部分的方法去实现的话,还会有这些个遗留的问题需要单独处理。


如果大家感兴趣的话可以留言告诉我,我再决定要不要接着写。


作者:沐洒
来源:juejin.cn/post/7422338076528181258
收起阅读 »

2024:写 TypeScript 必须改掉的 10 个坏习惯

web
大家好,我是CodeQi! 一位热衷于技术分享的码仔。 在过去的几年里,TypeScript 已经逐渐成为了前端开发的首选语言,尤其是那些追求更高代码质量和类型安全的开发者。不过,正如所有编程语言一样,随着时间的推移和技术的进步,我们的编程习惯也应该与时俱进。...
继续阅读 »

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


在过去的几年里,TypeScript 已经逐渐成为了前端开发的首选语言,尤其是那些追求更高代码质量和类型安全的开发者。不过,正如所有编程语言一样,随着时间的推移和技术的进步,我们的编程习惯也应该与时俱进。


👋 你有没有想过,自己在写 TypeScript 时是否养成了一些“坏习惯”?


随着 TypeScript 生态系统的进一步成熟,有些你以前觉得合理的做法,现在可能不太合理。接下来,我将分享10 个常见的 TypeScript 坏习惯,并告诉你如何改进它们,确保你的代码更健壮、性能更高、并且更加易于维护。


1. 不使用 strict 模式


当开发者为了减少“麻烦”而禁用 TypeScript 的 strict 模式时,往往是在给自己埋雷。💣


为什么不好?


strict 模式通过强制进行更严格的类型检查,帮助我们避免潜在的错误。如果你关掉它,TypeScript 就变得更像是 JavaScript,失去了静态类型带来的种种好处。短期内你可能会觉得更自由,但未来的重构和维护将变得更加棘手。


怎么改进?


在 tsconfig.json 中启用 strict 模式,这样你的代码在未来的迭代中会更加稳健:


{
  "compilerOptions": {
    "strict": true
  }
}

2. 依赖 any 类型


any 可能是 TypeScript 中最具“争议”的类型之一,因为它违背了我们使用 TypeScript 的初衷:类型安全


为什么不好?


any 让 TypeScript 失去意义。它让代码回归到“JavaScript 模式”,绕过了类型检查,最终可能导致各种运行时错误。


怎么改进?


使用 unknown 替代 any,并在实际使用前对类型进行检查。unknown 更安全,因为它不会自动允许任何操作:


let dataunknown;

if (typeof data === "string") {
  console.log(data.toUpperCase());
}

3. 过度使用类型断言


你是否经常用 as 关键字来“消除”编译错误?🙈 这种做法短期内看似有效,但可能会隐藏更多问题。


为什么不好?


类型断言会绕过 TypeScript 的安全机制,告诉编译器“别管了,我知道自己在做什么”。问题是,当你其实并不完全确定时,它会导致难以追踪的运行时错误。


怎么改进?


减少类型断言,使用类型保护函数代替:


function isString(value: unknown): value is string {
  return typeof value === 'string';
}

if (isString(data)) {
  console.log(data.toUpperCase());
}

4. 忽视联合类型和交叉类型


联合类型 (|) 和交叉类型 (&) 是 TypeScript 中极其强大的工具,但它们经常被忽视。🚫


为什么不好?


没有联合和交叉类型,代码容易变得冗长而难以维护。你可能会写大量的冗余代码,而这些类型可以帮你更简洁地表达逻辑。


怎么改进?


使用联合类型来处理不同情况,交叉类型来组合多个类型:


type Admin = { isAdmintrueprivilegesstring[] };
type User = { isAdminfalseemailstring };

type Person = Admin | User;

function logUser(person: Person) {
  if (person.isAdmin) {
    console.log(person.privileges);
  } else {
    console.log(person.email);
  }
}

5. 使用非特定的返回类型


不为函数指定精确的返回类型,可能会让使用者摸不着头脑。🤔


为什么不好?


模糊的返回类型增加了代码的不确定性,调试难度也会增加。你失去了静态类型的优势,最终使代码变得不可靠。


怎么改进?


始终为函数指定明确的返回类型,哪怕它是一个联合类型:


function fetchData(): Promise<{ idnumbernamestring }> {
  return fetch("/data").then(response => response.json());
}

6. 忽视 null 和 undefined


一些开发者在处理 null 和 undefined 时掉以轻心,结果导致一堆潜在的运行时错误。


为什么不好?


JavaScript 允许变量为 null 或 undefined,TypeScript 也有相应的工具帮助处理这些值。如果忽视它们,代码可能会在运行时崩溃。


怎么改进?


使用可选链 (?.) 和空值合并操作符 (??) 处理 null 和 undefined


const name = user?.profile?.name ?? "Guest";

7. 过度使用 Enums


在 TypeScript 中,Enums 有时会被滥用。尽管它们有其应用场景,但并不总是必要。


为什么不好?


Enums 会增加复杂性,尤其是在简单常量足够的情况下。


怎么改进?


考虑用 const 或字面量类型来替代枚举:


type Role = "Admin" | "User" | "Guest";

let userRoleRole = "Admin";

8. 不使用 readonly


如果不使用 readonly 来防止对象或数组的意外修改,代码中的副作用将难以控制。


为什么不好?


可变性会导致对象在不经意间被修改,造成难以调试的问题。


怎么改进?


尽可能使用 readonly 来确保不变性:


const datareadonly number[] = [123];

9. 忽视自定义类型保护


依赖隐式类型检查而非明确的类型保护,可能导致你错过一些重要的类型问题。


为什么不好?


没有自定义类型保护,你可能会在运行时错过一些类型错误,最终导致不可预期的行为。


怎么改进?


编写明确的类型保护函数:


function isUser(user: any): user is User {
  return typeof user.email === "string";
}

10. 没有充分利用 unknown 类型


许多开发者默认使用 any 来处理未知类型,其实 unknown 是一个更好的选择。


为什么不好?


any 禁用了类型检查,而这正是使用 TypeScript 的初衷。unknown 则要求你在使用前对类型进行明确的验证。


怎么改进?


用 unknown 代替 any,并在使用前进行类型缩小:


let inputunknown;

if (typeof input === "string") {
  console.log(input.toUpperCase());
}

总结


2024 年,是时候告别这些坏习惯了!通过启用 strict 模式、避免使用 any、掌握联合和交叉类型等高级特性,你的 TypeScript 代码将变得更强大、更灵活、更具维护性。希望这些建议能够帮助你在 TypeScript 之路上走得更远,写出更加优雅的代码!✨


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

你小子,一个bug排查一整天,你在🐟吧!

web
楔子   在每日的例行会议上,空气中弥漫着紧张的气息。一位实习组员语速略急地说道:“昨天我主要的工作是排查一个线上bug,目前还没有得到解决,今天我得继续排查。”。   组长眉头微皱,冷冷地盯了他一眼:“你小子,一个bug排查一整天,怕是在摸鱼吧!到底是什么问...
继续阅读 »

楔子


  在每日的例行会议上,空气中弥漫着紧张的气息。一位实习组员语速略急地说道:“昨天我主要的工作是排查一个线上bug,目前还没有得到解决,今天我得继续排查。”。


  组长眉头微皱,冷冷地盯了他一眼:“你小子,一个bug排查一整天,怕是在摸鱼吧!到底是什么问题?说来听听,我稍后看看。”。


  组员无奈地摊了摊手,耸了耸肩,长叹一口气:“前两天,订单表格新增定制信息匹配失败情况的展示。自己没有经过仔细的测试,就直接发布上线了,导致现在整个订单列表渲染缓慢。这个bug超出了我的能力范围,我排查了一天也排查不出来,摸鱼是404的。”。


  组长深吸一口气,眼神中露出几分聪慧:“那不就是你编写的组件有问题吗?你最好没有摸鱼!不然你就等着吃鱼吧!”。


  组员按捺不住心中的窃喜:“我如果不说一天,又怎么能请动你这尊大神呢?”。


你小子.jpg


排查


  果不其然,控制台果真报错了。组长看了眼报错信息,摇了摇头,面色凝重:“你小子,居然都不看控制台的报错信息?这bug怎么排查的?”。组员下意识地捏紧了拳头,声音也不自觉地低了几分,结结巴巴道:“我、我真的不知道控制台还有这操作!学废了。”。


image.png

  组长怀着忐忑不安的心情打开vsCode, 只见一大串代码赫然映入眼帘:


<template>
<div class="design-wrapper">
<designProducts v-if="showBtn" btnText="设计" class="mr10" :data="data" @success="success" />
<el-tooltip v-if="showStatus" trigger="click" placement="right" :disabled="disabled">
<baseTable1 class="hide-tabs" :data="tableData" :option="option">
<template #content="{ row }">
<defaultImg v-if="imageType(row)" :src="image(row)" :size="100" />
<span v-else>{{ text(row) }}</span>
</template>
<template #mapping="{ row }">
<i :class="icon(row)"></i>
</template>
<template #importLabelCode="{ row }">
<span v-if="!row.hide">{{ row.importLabelCode }}</span>
</template>
<template #matchStatus="{ row }">
{{ matchSuccess(row) ? '已匹配' : '未匹配' }}
</template>
<template #design="{ row }">
<defaultImg
v-if="!row.hide && imageType(row)"
:disabled="row.disabled"
:src="row.importContent"
:size="100"
@error="error(row)"
>

<template #defaultImg>
<div class="flex-middle">{{ row.importContent || '无' }}</div>
</template>
</defaultImg>
<div v-else-if="!row.hide && !imageType(row)">{{ row.importContent || '无' }}</div>
</template>
</baseTable1>
<color-text-btn slot="reference" @click="toDesign">{{ status }}</color-text-btn>
</el-tooltip>
<span v-else></span>
</div>
</template>

  当扫到el-tooltip (文字提示), 组长拍案而起,额头上暴起的青筋在不断颤抖。急切的声音,仿佛要撕裂虚空:“你小子,短短几十行代码,至少犯了2个致命错误!”。


问题分析


1. 从代码源头分析el-tooltip(控制台报错的原因)



  • el-tooltip组件主要是针对文字提示,而el-popover才是针对组件进行展示。这两者是截然不同的,不然element也不会分出两套组件,去分别处理这两种情况。

  • 我们的项目之所以能正常使用vueXrouter,是因为我们在main.js中引入并挂载了


    QQ截图20241009142821.png

    同理,分析el-tooltip组件的代码实现,它只挂载了data属性。因此,当强行在el-tooltip组件中使用自定义组件:如果组件内部使用的是非国际语言(i18n)的纯文本,控制台不会报错;如果在该组件中使用了诸如vueX、路由跳转等在内的变量或者方法时,控制台就会疯狂报错,因为这些并没有在初始化时注入到el-tooltip组件中。


    image.png


2. 如何在el-tooltip中使用i18n?


  假定有一个非常执拗的人,他看到el-tooltip组件描述的功能是文字提示。他就不想在el-popover中使用$t, 而想在el-tooltip组件中使用i18n。那么可以做到吗?答案是肯定的。


  我们可以直接通过继承法则:封装一个base-tooltip组件,继承el-tooltip组件。并根据继承规则:先执行el-tooltip组件的生命周期钩子方法,再执行base-tooltip组件里面的生命周期钩子方法。通过这种方式,我们成功挂载了i18n。此时,在base-tooltip组件中使用$t,就不会报错了。


<script>
import { Tooltip } from 'element-ui'
import i18n from '@/i18n'
import Vue from 'vue'

export default {
extends: Tooltip,

beforeCreate() {
this.popperVM = new Vue({
data: { node: '' },
i18n,
render(h) {
return this.node;
}
}).$mount()
}
}

</script>

3. el-tooltip的局限性(订单列表渲染缓慢的原因)


  前文提及,我们可以继承el-tooltip组件。那么,我们如果通过按需引入的方式,将所需要的资源全部挂载到vue中。这样,就算在base-tooltip组件中使用vueX$route变量,也不会在控制台上报错。的确如此,但是我们需要注意到el-tooltipel-popover的局限性: 悬浮框内容是直接渲染的,不是等你打开悬浮框才渲染。


  这也就意味着,如果我们在表格的每一行都应用了el-tooltipel-popover组件,而且在el-tooltipel-popover的生命周期钩子函数中请求了异步数据。就会导致页面初始化渲染数据的同时,会请求N个接口(其中,N为当前表格的数据条数)。一次性请求大于N + 1个接口,你就说页面会不会卡顿就完事了!


  但是,el-popover这个组件不一样。在它的组件内部,提供了一个show方法,这个方法在trigger触发后才执行。于是,我们可以在show方法中,去请求我们需要的异步数据。 同时注意一个优化点:在悬浮框打开之后,才渲染Popover内嵌的html文本,避免页面加载时就渲染数据。


  由于el-popover的内容是在弹窗打开后才异步加载的,弹窗可能会在内容完全加载之前就开始计算和渲染位置,导致弹出的位置不对。但是我们遇到事情不要慌,el-popover组件的混入中提供了一个方法updatePopper,用于矫正popover的偏移量,以期获取正确的popover布局。


image.png

解决方法


  将上述所有思路结合在一起,我们就能够封装一个公共组件,兼容工作中的大多数场景。


<template>
<el-popover
ref="popover"
@show="onShow"
v-bind="$attrs"
v-on="$listeners"
>

<template v-if="isOpened">
<slot></slot>
</template>
<template slot="reference">
<slot name="reference"></slot>
</template>
</el-popover>

</template>

<script>
import agentMixin from '@/mixins/component/agentMixin'

export default {
// 方便我们直接调用popover组件中的方法
mixins: [agentMixin({ ref: 'popover', methods: ['updatePopper', 'doClose'] })],

props: {
// 方便在打开悬浮框之前,做一些前置操作,比如数据请求等
beforeOpen: Function
},

data() {
return {
isOpened: false
}
},

methods: {
async onShow() {
if(!this.beforeOpen) {
return this.isOpened = true
}
const res = await this.beforeOpen()
if(!res) return this.isOpened = false
this.isOpened = true
await this.$nextTick()
this.updatePopper()
}
}
}
</script>


/* eslint-disable */

import { isArray, isPlainObject } from 'lodash'

export default function ({ ref, methods } = {}) {
if (isArray(methods)) {
methods = methods.map(name => [name, name])
// 如果传入是对象,可以设置别名,防止方法名重复
} else if (isPlainObject(methods)) {
methods = Object.entries(methods)
}

return {
methods: {
...methods.reduce((prev, [name, alias]) => {
prev[alias] = function (...args) {
return this.$refs[ref][name](...args)
}
return prev
}, {})
}
}
}

<template>
<div class="design-wrapper">
<designProducts v-if="showBtn" btnText="设计" class="mr10" :data="data" @success="success" />
<basePopover v-if="showStatus" trigger="click" placement="right" :beforeOpen="beforeOpen" :disabled="disabled">
<baseTable1 class="hide-tabs" :data="tableData" :option="option">
<template #content="{ row }">
<defaultImg v-if="imageType(row)" :src="image(row)" :size="100" />
<span v-else>{{ text(row) }}</span>
</template>
<template #mapping="{ row }">
<i :class="icon(row)"></i>
</template>
<template #importLabelCode="{ row }">
<span v-if="!row.hide">{{ row.importLabelCode }}</span>
</template>
<template #matchStatus="{ row }">
{{ matchSuccess(row) ? '已匹配' : '未匹配' }}
</template>
<template #design="{ row }">
<defaultImg
v-if="!row.hide && imageType(row)"
:disabled="row.disabled"
:src="row.importContent"
:size="100"
@error="error(row)"
>

<template #defaultImg>
<div class="flex-middle">{{ row.importContent || '无' }}</div>
</template>
</defaultImg>
<div v-else-if="!row.hide && !imageType(row)">{{ row.importContent || '无' }}</div>
</template>
</baseTable1>
<color-text-btn slot="reference" @click="toDesign">{{ status }}</color-text-btn>
</basePopover>
<span v-else></span>
</div>

</template>

<script>
methods: {
async beforeOpen() {
const res = await awaitResolveDetailLoading(
microApi.getMatchInfo({
id: this.data.id
})
)
if (!res) return false
this.tableData = res
return true
}
}
</script>


反思


  在组长的悉心指导下,组员逐渐揭开了问题的真相。回想起自己在面对bug时的轻率和慌乱,他不禁感到一阵羞愧。组长平静而富有耐心的声音再次在耳边响起:“排查问题并非一朝一夕之功。急于上线而忽视测试,只会让问题愈加复杂。”这一番话如同醍醐灌顶,瞬间点醒了他,意识到自己的错误不仅在于代码的疏漏,更在于对整个工作流程的轻视。


  “编写代码不是一场竞赛,速度永远无法替代质量。”组长边调试代码,边语重心长地说道。组长的语气虽然平淡,却蕴含着深邃的力量。组员心中的敬佩之意油然而生,细细回味着这番话,顿时明白了面对复杂bug时,耐心与细致才是解决问题的最强利器。组长的话语简洁而富有哲理,令他意识到,曾经追求的“快速上线”与开发中的严谨要求完全背道而驰。


  不久之后,组员陷入了沉思,轻声开口:“起初,我还真觉得自己运气不好,偏偏遇上如此棘手的bug。但现在看来,这更像是一场深刻的教训。若能在上线前认真测试,这个问题本是可以避免的。”他的声音中透出几分懊悔,眼中闪烁着反思的光芒。


  组长微微一笑,点头示意:“每一个bug都是一次学习的契机,能意识到问题的根源,已是进步。”他稍作停顿,眼神愈加坚定:“编程的速度固然重要,但若未经过深思熟虑的测试与分析,那无疑只是纸上谈兵。写代码不仅需要实现功能,更需要经得起时间的考验。”这番话语透着无可辩驳的真理,给予了组员莫大的启迪。


  组员感慨道:“今天的排查让我真正领悟到耐心与细致的重要性。排查bug就像走出迷宫,急躁只会迷失方向,而冷静思考才能找到出路。”他不禁回忆起自己曾经的粗心大意,心中暗自发誓,今后在每一次提交前都要更加谨慎,绝不再犯同样的错误。


  “你小子,倒也不算愚钝。”组长调侃道,嘴角勾起一丝轻松的笑意,“但记住,遇到问题时要先冷静分析错误信息,找出原因再行动。不要盲目修改,开发不仅仅是写代码,更需要学会深思熟虑。”他轻轻拍了拍组员的肩膀,那一拍似乎传达着无限的关心与期望。


  这一拍虽轻,却如雷霆般震动着组员的心灵。他明白,这不仅是组长对他的鼓励,更是一份期待与责任的传递。心中顿时涌起一股暖流,他暗自立誓:今后的每一次开发,必将怀揣严谨的态度,赋予每一行代码以深刻的责任感,而不再仅仅是为了完成任务。


  在回家的路上,组员默默在心中念道:“这次bug排查,不仅修复了代码,更矫正了我对待开发工作的态度。感谢组长,给予我如此宝贵的经验和鼓励。”他深知,从这次经历中所学到的,绝不仅是技术层面的知识,更需要以一种成熟与稳重的心态,来面对未来的每一个挑战。


  怀着这样的领悟,组员的内心充满了期待。他坚信,这必将成为他在开发道路上迈向更高境界的起点。


作者:沐浴在曙光下的贰货道士
来源:juejin.cn/post/7423378897381130277
收起阅读 »

浅谈“过度封装”

web
干了很多很多所谓的“敏捷”开发的项目之后,对于封装组件有了新的看法,在这里和大家分享一下 为什么要封装组件 封装组件可以复用共通的代码,增加可读性,可以统一UI样式,可以十分方便的管理代码结构。这是所有同学都知道的封装代码的好处,特别是当公司遇到需要“敏捷”开...
继续阅读 »

干了很多很多所谓的“敏捷”开发的项目之后,对于封装组件有了新的看法,在这里和大家分享一下


为什么要封装组件


封装组件可以复用共通的代码,增加可读性,可以统一UI样式,可以十分方便的管理代码结构。这是所有同学都知道的封装代码的好处,特别是当公司遇到需要“敏捷”开发一个项目,封装组件可以帮助我们提高效率(为了绩效)


往往我们就会选择开源的成熟的好用的组件库(element-ui、ant design等)这些组件库帮助我们开发的同时,也帮助我们更加高效的完成任务。


但是每个人对使用组件库的理解都不一样,很多可以使用组件库中的组件的地方自己反而会手动实现,虽然看上去像是实现了效果,但是严重的破坏了代码结构,极大的增加了后续的维护工作量,对于这些封装往往都是“过度封装”


浅谈“过度封装”


“过度封装”在不同的项目组同学中都有不一样的理解,但是很难有一个标准,我封装的这个组件到底算不算“过度封装”呢?



  1. 对与项目中已有的组件做二次封装的封装可以算是“过度封装”

  2. 手动实现一个组件库中存在的类似的组件在项目中使用可以算是“过度封装”


以上是我对一个组件是否是“过度封装”的理解,也可以判断一个方法是不是“过度封装”


对与项目中已有的组件做二次封装的封装可以算是“过度封装”


当我作为后续开发接手一个快要离职的同事的代码时,往往会在components文件夹里看到很多针对element-ui(antd、等其他的组件库)的table组件做的二次封装


image.png
这类的封装往往伴随着一些不够灵活的问题。当一些特殊的页面需要不一样的table设置时,往往需要修改组件的带啊才能支持使用,当这个table支持了很多不同页面的个性化需求之后,大量的props没有一个文档说明后续开发人员要阅读这个封装的组件的源码并熟悉之后快速使用。后续维护产生了大量的工作量,十分不友好。


手动实现一个组件库中存在的类似的组件在项目中使用可以算是“过度封装”


有时候设计稿中出现一个组件和组件库中的很像,但是存在差别的时候,我们需要思考一下,组件库中的组件是否完全支持我们的功能(可以多看看文档上的props,或者打开在线编辑器,调试一下看看),而不是看着有一点点的差异就手动实现一个,比如:tag标签组件、image图像组件等等在项目中基本不去使用,往往直接使用原生标签就手动开发了。


不仅仅是组件当中存在这类问题,封装方法的时候也存在这里问题,明明项目导入了lodash、momentjs、dayjs等库,反而在utils中手动实现formatDate、formatTime、节流防抖、深拷贝等方法,实在令人费解。


关于样式封装


关于组件的样式是最难封装的地方,往往开发到最后每一个文件里面就会出现一大堆的修改样式的代码


image.png


就算是在统一的样式文件中同意修改还是不免出现超长的修改颜色的各种代码


image.png


对于element-ui实在硬伤,摊牌了、我不会了🤷🏻‍♀️


所以我推荐使用naiveUI开发,对于样式的处理绝对的一流,加之vue3使用hooks配合组件,开发体验也很不错😎,(arco Design、antd 现在处理统一的样式风格也是很棒了)


总结


简单聊了一下“过度封装”,希望这种代码不会出现在大家的代码当中,不要去封装 my-button、my-table 这种组件,世界会更加美好。(^▽^)


作者:我的username
来源:juejin.cn/post/7426643406305296419
收起阅读 »

还在用轮询、websocket查询大屏数据?sse用起来

web
常见的大屏数据请求方式 1、http请求轮询:使用定时器每隔多少时间去请求一次数据。优点:简单,传参方便。缺点:数据更新不实时,浪费服务器资源(一直请求,但是数据并不更新) 2、websocket:使用websocket实现和服务器长连接,服务器向客户端推送大...
继续阅读 »

常见的大屏数据请求方式


1、http请求轮询:使用定时器每隔多少时间去请求一次数据。优点:简单,传参方便。缺点:数据更新不实时,浪费服务器资源(一直请求,但是数据并不更新)

2、websocket:使用websocket实现和服务器长连接,服务器向客户端推送大屏数据。优点:长连接,客户端不用主动去请求数据,节约服务器资源(不会一直去请求数据,也不会一直去查数据库),数据更新及时,浏览器兼容较好(web、h5、小程序一般都支持)。缺点:有点大材小用,一般大屏数据只需要查询数据不需要向服务端发送消息,还要处理心跳、重连等问题。

image.png


3、sse:基于http协议,将一次性返回数据包改为流式返回数据。优点:sse使用http协议,兼容较好、sse轻量,使用简单、sse默认支持断线重连、支持自定义响应事件。缺点:浏览器原生的EventSource不支持设置请求头,需要使用第三方包去实现(event-source-polyfill)、需要后端设置接口的响应头Content-Type: text/event-stream

image.png


sse和websocket的区别



  1. websocket支持双向通信,服务端和客户端可以相互通信。sse只支持服务端向客户端发送数据。

  2. websocket是一种新的协议。sse则是基于http协议的。

  3. sse默认支持断线重连机制。websocket需要自己实现断线重连。

  4. websocket整体较重,较为复杂。sse较轻,简单易用。


Websocket和SSE分别适用于什么业务场景?


根据sse的特点(轻量、简单、单向通信)更适用于大屏的数据查询,业务应用上查询全局的一些数据,比如消息通知未读消息等。


根据websocket的特点(双向通信)更适用于聊天功能的开发


前端代码实现


sse的前端的代码非常简单


 const initSse = () => {
const source = new EventSource(`/api/wisdom/terminal/stats/change/notify/test`);

// 这里的stats_change要和后端返回的数据结构里的event要一致
source.addEventListener('stats_change', function (event: any) {
const types = JSON.parse(event.data).types;
});
// 如果event返回的是message 数据监听也可以这样监听
// source.onmessage =function (event) {
// var data = event.data;
// };

// 下面这两个监听也可以写成addEventListener的形式
source.onopen = function () {
console.log('SSE 连接已打开');
};

// 处理连接错误
source.onerror = function (error: any) {
console.error('SSE 连接错误:', error);
};
setSseSource(source);
};

// 关闭连接
sseSource.close();

这种原生的sse连接是不能设置请求头的,但是在业务上接口肯定是要鉴权需要传递token的,那么怎么办呢? 我们可以使用event-source-polyfill这个库


 const source = new EventSourcePolyfill(`/api/wisdom/terminal/stats/change/notify/${companyId}`, {
headers: {
Authorization: sessionStorage.get(StorageKey.TOKEN) || storage.get(StorageKey.TOKEN),
COMPANYID: storage.get(StorageKey.COMPANYID),
COMPANYTYPE: 1,
CT: 13
}
});

//其它的事件监听和原生的是一样

后端代码实现


后端最关键的是设置将响应头的Content-Type设置为text/event-streamCache-Control设置为no-cacheConnection设置为keep-alive。每次发消息需要在消息体结尾用"/n/n"进行分割,一个消息体有多个字段每个字段的结尾用"/n"分割。


var http = require("http");

http.createServer(function (req, res) {
var fileName = "." + req.url;

if (fileName === "./stream") {
res.writeHead(200, {
"Content-Type":"text/event-stream",
"Cache-Control":"no-cache",
"Connection":"keep-alive",
"Access-Control-Allow-Origin": '*',
});
res.write("retry: 10000\n");
res.write("event: connecttime\n");
res.write("data: " + (new Date()) + "\n\n");
res.write("data: " + (new Date()) + "\n\n");

interval = setInterval(function () {
res.write("data: " + (new Date()) + "\n\n");
}, 1000);

req.connection.addListener("close", function () {
clearInterval(interval);
}, false);
}
}).listen(8844, "127.0.0.1");

其它开发中遇到的问题


我在开发调试中用的是umi,期间遇到个问题就是sse连接上了但是在控制台一直没有返回消息,后端那边又是正常发出了的,灵异的是在后端把服务干掉的一瞬间可以看到控制台一下接到好多消息。我便怀疑是umi的代理有问题,然后我就去翻umi的文档,看到了下面的东西:


image.png


一顿操作之后正常


image.png


作者:Mozambique_Here
来源:juejin.cn/post/7424908830902042658
收起阅读 »

为什么JQuery会被淘汰?Vue框架就一定会比JQuery好吗?

web
前言 曾经面试时碰到过一个问题:为什么现有的Vue框架开发可以淘汰之前的JQuery? 我回答:Vue框架无需自己操作DOM,可以避免自己频繁的操作DOM 面试官接着反问我:Vue框架无需自己操作DOM,有什么优势吗,不用操作DOM就一定是好的吗? 我懵了,在...
继续阅读 »

前言


曾经面试时碰到过一个问题:为什么现有的Vue框架开发可以淘汰之前的JQuery?


我回答:Vue框架无需自己操作DOM,可以避免自己频繁的操作DOM


面试官接着反问我:Vue框架无需自己操作DOM,有什么优势吗,不用操作DOM就一定是好的吗?


我懵了,在我的认知里Vue框架无需自己操作DOM性能是一定优于自己来操作DOM元素的,其实并不是的.....


声明式框架与命令式框架


首先我们得了解声明式框架和命令式框架的区别


命令式框架关注过程


JQuery就是典型的命令式框架


例如我们来看如下一段代码


$( "button.continue" ).html( "Next Step..." ).on('click', () => { alert('next') })

这段代码的含义就是先获取一个类名为continue的button元素,它的内容为 Next Step...,并为它绑定一个点击事件。可以看到自然语言描述与代码是一一对应的,这更符合我们做事的逻辑


声明式框架更关注结果


现有的Vue,React都是典型的声明式框架


接着来看一段Vue的代码


<button class="continue" @click="() => alert('next')">Next Step...</button>

这是一段类HTML模板,它更像是直接提供一个结果。至于怎么实现这个结果,就交给Vue内部来实现,开发者不用关心


性能比较


首先告诉大家结论:声明式代码性能不优于命令式代码性能


即:声明式代码性能 <= 命令式代码性能


为什么会这样呢?


还是拿上面的代码举例


假设我们要将button的内容改为 pre Step,那么命令式的实现就是:


button.textContent = "pre Step"

很简单,就是直接修改


声明式的实现就是:


<!--之前 -->
<button class="continue" @click="() => alert('next')">Next Step...</button>
<!--现在 -->
<button class="continue" @click="() => alert('next')">pre Step</button>

对于声明式框架来说,它需要找到更改前后的差异并只更新变化的地方。但是最终更新的代码仍然是


button.textContent = "pre Step"

假设直接修改的性能消耗为 A, 找出差异的性能消耗为 B,
那么就有:



  • 命令式代码的更新性能消耗 = A

  • 声明式代码的更新性能消耗 = A + B


可以看到声明式代码永远要比命令式代码要多出找差异的性能消耗


那既然声明式代码的性能无法超越命令式代码的性能,为什么我们还要选择声明式代码呢?这就要考虑到代码可维护性的问题了。当项目庞大之后,手动完成dom的创建,更新与删除明显需要更多的时间和精力。而声明式代码框架虽然牺牲了一点性能,但是大大提高了项目的可维护性降低了开发人员的心智负担


那么,有没有办法能同时兼顾性能和可维护性呢?
有!那就是使用虚拟dom


虚拟Dom


首先声明一个点,命令式代码只是理论上会比声明式代码性能高。因为在实际开发过程中,尤其是项目庞大之后,开发人员很难写出绝对优化的命令式代码。
而Vue框架内部使用虚拟Dom + 内部封装Dom元素操作的方式,能让我们不用付出太多精力的同时,还能保证程序的性能下限,甚至逼近命令式代码的性能


在讨论虚拟Dom的性能之前,我们首先要说明一个点:JavaScript层面的计算所需时间要远低于Dom层面的计算所需时间 看过浏览器渲染与解析机制的同学应该很明白为什么会这样。


我们在使用原生JavaScript编写页面时,很喜欢使用innerHTML,这个方法非常特殊,下面我们来比较一下使用虚拟Dom和使用innerHTML的性能差异


创建页面时


我们在使用innerHTML创建页面时,通常是这样的:


const data = "hello"
const htmlString = `<div>${data}</div>`
domcument.querySelect('.target').innerHTML = htmlString

这个过程需要先通过JavaScript层的字符串运算,然后是Dom层的innerHTML的Dom运算 (将字符串赋值给Dom元素的innerHTML属性时会将字符串解析为Dom树)


而使用虚拟Dom的方式通常是编译用户编写的类html模板得到虚拟Dom(JavaScript对象),然后遍历虚拟Dom树创建真实Dom对象


两者比较:


innerHTML虚拟Dom
JavaScript层面运算计算拼接HTML字符串创建JavaScript对象(虚拟Dom)
Dom层面运算新建所有Dom元素新建所有Dom元素

可以看到两者在创建页面阶段的性能差异不大。尽管在JavaScript层面,创建虚拟Dom对象貌似更耗时间,但是总体来说,Dom层面的运算是一致的,两者属于同一数量级,宏观来看可认为没有差异


更新页面时


使用innerHTML更新页面,通常是这样:


//更新
const newData = "hello world"
const newHtmlString = `<div>${newData}</div>`
domcument.querySelect('.target').innerHTML = newHtmlString

这个过程同样是先通过JavaScript层的字符串运算,然后是Dom层的innerHTML的Dom运算。但是它在Dom层的运算是销毁所有旧的DOM元素,再全量创建新的DOM元素


而使用虚拟Dom的方式通常是重新创建新的虚拟Dom(JavaScript对象),然后比较新旧虚拟Dom,找到需要更改的地方并更新Dom元素


两者比较:


innerHTML虚拟Dom
JavaScript层面运算计算拼接HTML字符串创建JavaScript对象(虚拟Dom)+ Diff算法
Dom层面运算销毁所有旧的Dom元素,新建所有新的DOM元素必要的DOM更新

可以看到虚拟DOM在JavaScript层面虽然多出一个Diff算法的性能消耗,但这毕竟是JavaScript层面的运算,不会产生数量级的差异。而在DOM层,虚拟DOM可以只更新差异部分,对比innerHTML的全量卸载与全量更新性能消耗要小得多。所以模板越大,元素越多,虚拟DOM在更新页面的性能上就越有优势


总结


现在我们可以回答这位面试官的问题了:JQuery属于命令式框架,Vue属于声明式框架。在理论上,声明式代码性能是不优于命令式代码性能的,甚至差于命令式代码的性能。但是声明式框架无需用户手动操作DOM,用户只需关注数据的变化。声明式框架在牺牲了一点性能的情况下,大大降低了开发难度,提高了项目的可维护性,且声明式框架通常使用虚拟DOM的方式,使其在更新页面时的性能大大提升。综合来说,声明式框架仍旧是更好的选择


作者:yep
来源:juejin.cn/post/7425121392738615350
收起阅读 »

老板想集成地图又不想花钱,于是让我...

web
前言 在数字化时代,地图服务已成为各类应用的标配,无论是导航、位置分享还是商业分析,地图都扮演着不可或缺的角色。然而,高质量的地图服务往往伴随着不菲的授权费用。公司原先使用的是国内某知名地图服务,但随着业务的扩展和成本的考量,老板决定寻找一种成本更低的解决方案...
继续阅读 »

前言


在数字化时代,地图服务已成为各类应用的标配,无论是导航、位置分享还是商业分析,地图都扮演着不可或缺的角色。然而,高质量的地图服务往往伴随着不菲的授权费用。公司原先使用的是国内某知名地图服务,但随着业务的扩展和成本的考量,老板决定寻找一种成本更低的解决方案。于是,我们的目光转向了免费的地图服务——天地图。


天地图简介


天地图(lbs.tianditu.gov.cn/server/guid…
是中国领先的在线地图服务之一,提供全面的地理信息服务。它的API支持地理编码、逆地理编码、周边搜索等多种功能,且完全免费。这正是我们需要的。


具体实现代码


为了将天地图集成到我们的系统中,我们需要进行一系列的开发工作。以下是实现过程中的关键代码段。


1. 逆地理编码


逆地理编码是将经纬度转换为可读的地址。在天地图中,这一功能可以通过以下代码实现:


public static MapLocation reverseGeocode(String longitude, String latitude) {
Request request = new Request();
LocateInfo locateInfo = GCJ02_WGS84Utils.gcj02_To_Wgs84(Double.valueOf(latitude), Double.valueOf(longitude));
longitude = String.valueOf(locateInfo.getLongitude());
latitude = String.valueOf(locateInfo.getLatitude());
String postStr = String.format(REVERSE_GEOCODE_POST_STR, longitude, latitude);
String encodedPostStr = null;
try {
encodedPostStr = URLEncoder.encode(postStr, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String url = REVERSE_GEOCODE_URL + "?tk=" + TK + "&type=" + GEOCODE + "&postStr=" + encodedPostStr;
request.setUrl(url);
Response<String> response = HttpClientHelper.getWithoutUserAgent(request, null);
if (response.getSuccess()) {
String body = response.getBody();
JSONObject jsonObject = JSON.parseObject(body);
String status = jsonObject.getString("status");
if (!"0".equals(status)) {
return null;
}
JSONObject resultObject = jsonObject.getJSONObject("result");
MapLocation mapLocation = new MapLocation();
String formattedAddress = resultObject.getString("formatted_address");
mapLocation.setAddress(formattedAddress);
String locationStr = resultObject.getString("location");
JSONObject location = JSON.parseObject(locationStr);
String lon = location.getString("lon");
String lat = location.getString("lat");
locateInfo = GCJ02_WGS84Utils.wgs84_To_Gcj02(Double.valueOf(lat), Double.valueOf(lon));
lon = String.valueOf(locateInfo.getLongitude());
lat = String.valueOf(locateInfo.getLatitude());
mapLocation.setLongitude(lon);
mapLocation.setLatitude(lat);
JSONObject addressComponent = resultObject.getJSONObject("addressComponent");
String address = addressComponent.getString("address");
mapLocation.setName(address);
mapLocation.setCity(addressComponent.getString("city"));
return mapLocation;
}
return null;
}

2. 周边搜索


周边搜索允许我们根据一个地点的经纬度搜索附近的其他地点。实现代码如下:


public static List<MapLocation> nearbySearch(String query, String longitude, String latitude, String radius) {
LocateInfo locateInfo = GCJ02_WGS84Utils.gcj02_To_Wgs84(Double.valueOf(latitude), Double.valueOf(longitude));
longitude = String.valueOf(locateInfo.getLongitude());
latitude = String.valueOf(locateInfo.getLatitude());
Request request = new Request();
String longLat = longitude + "," + latitude;
String postStr = String.format(NEARBY_SEARCH_POST_STR, query, Integer.valueOf(radius), longLat);
String encodedPostStr = null;
try {
encodedPostStr = URLEncoder.encode(postStr, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String url = SEARCH_URL + "?tk=" + TK + "&type=" + QUERY + "&postStr=" + encodedPostStr;
request.setUrl(url);
Response<String> response = HttpClientHelper.getWithoutUserAgent(request, null);
List<MapLocation> list = new ArrayList<>();
if (response.getSuccess()) {
String body = response.getBody();
JSONObject jsonObject = JSON.parseObject(body);
JSONObject statusObject = jsonObject.getJSONObject("status");
String infoCode = statusObject.getString("infocode");
if (!"1000".equals(infoCode)) {
return new ArrayList<>();
}
String resultType = jsonObject.getString("resultType");
String count = jsonObject.getString("count");
if (!"1".equals(resultType) || "0".equals(count)) {
return new ArrayList<>();
}
JSONArray poisArray = jsonObject.getJSONArray("pois");
for (int i = 0; i < poisArray.size(); i++) {
JSONObject poiObject = poisArray.getJSONObject(i);
MapLocation mapLocation = new MapLocation();
mapLocation.setName(poiObject.getString("name"));
mapLocation.setAddress(poiObject.getString("address"));
String lonlat = poiObject.getString("lonlat");
String[] lonlatArr = lonlat.split(",");
locateInfo = GCJ02_WGS84Utils.wgs84_To_Gcj02(Double.valueOf(lonlatArr[1]), Double.valueOf(lonlatArr[0]));
String lon = String.valueOf(locateInfo.getLongitude());
String lat = String.valueOf(locateInfo.getLatitude());
mapLocation.setLongitude(lon);
mapLocation.setLatitude(lat);
list.add(mapLocation);
}
}
return list;
}

3. 文本搜索


文本搜索功能允许用户根据关键词搜索地点。实现代码如下:


public static List<MapLocation> searchByText(String query, String mapBound) {
Request request = new Request();
String postStr = String.format(SEARCH_BY_TEXT_POST_STR, query, mapBound);
String encodedPostStr = null;
try {
encodedPostStr = URLEncoder.encode(postStr, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String url = SEARCH_URL + "?tk=" + TK + "&type=" + QUERY + "&postStr=" + encodedPostStr;
request.setUrl(url);
Response<String> response = HttpClientHelper.getWithoutUserAgent(request, null);
List<MapLocation> list = new ArrayList<>();
if (response.getSuccess()) {
String body = response.getBody();
JSONObject jsonObject = JSON.parseObject(body);
JSONObject statusObject = jsonObject.getJSONObject("status");
String infoCode = statusObject.getString("infocode");
if (!"1000".equals(infoCode)) {
return new ArrayList<>();
}
String resultType = jsonObject.getString("resultType");
String count = jsonObject.getString("count");

if (!"1".equals(resultType) || "0".equals(count)) {
return new ArrayList<>();
}
JSONArray poisArray = jsonObject.getJSONArray("pois");
for (int i = 0; i < poisArray.size(); i++) {
JSONObject poiObject = poisArray.getJSONObject(i);
MapLocation mapLocation = new MapLocation();
mapLocation.setName(poiObject.getString("name"));
mapLocation.setAddress(poiObject.getString("address"));
String lonlat = poiObject.getString("lonlat");
String[] lonlatArr = lonlat.split(",");
LocateInfo locateInfo = GCJ02_WGS84Utils.wgs84_To_Gcj02(Double.valueOf(lonlatArr[1]), Double.valueOf(lonlatArr[0]));
String lon = String.valueOf(locateInfo.getLongitude());
String lat = String.valueOf(locateInfo.getLatitude());
mapLocation.setLongitude(lon);
mapLocation.setLatitude(lat);
list.add(mapLocation);
}
}
return list;
}

4. 坐标系转换


由于天地图使用的是WGS84坐标系,而国内常用的是GCJ-02坐标系,因此我们需要进行坐标转换。以下是坐标转换的工具类:



/**
* WGS-84:是国际标准,GPS坐标(Google Earth使用、或者GPS模块)
* GCJ-02:中国坐标偏移标准,Google Map、高德、腾讯使用
* BD-09:百度坐标偏移标准,Baidu Map使用(经由GCJ-02加密而来)
* <p>
* 这些坐标系是对真实坐标系统进行人为的加偏处理,按照特殊的算法,将真实的坐标加密成虚假的坐标,
* 而这个加偏并不是线性的加偏,所以各地的偏移情况都会有所不同,具体的内部实现是没有对外开放的,
* 但是坐标之间的转换算法是对外开放,在网上可以查到的,此算法的误差在0.1-0.4之间。
*/

public class GCJ02_WGS84Utils {

public static double pi = 3.1415926535897932384626;//圆周率
public static double a = 6378245.0;//克拉索夫斯基椭球参数长半轴a
public static double ee = 0.00669342162296594323;//克拉索夫斯基椭球参数第一偏心率平方

/**
* 从GPS转高德
* isOutOfChina 方法用于判断经纬度是否在中国范围内,如果不在中国范围内,则直接返回原始的WGS-84坐标。
* transformLat 和 transformLon 是辅助函数,用于进行经纬度的转换计算。
* 最终,wgs84ToGcj02 方法返回转换后的GCJ-02坐标系下的经纬度。
*/

public static LocateInfo wgs84_To_Gcj02(double lat, double lon) {
LocateInfo info = new LocateInfo();
if (isOutOfChina(lat, lon)) {
info.setChina(false);
info.setLatitude(lat);
info.setLongitude(lon);
} else {
double dLat = transformLat(lon - 105.0, lat - 35.0);
double dLon = transformLon(lon - 105.0, lat - 35.0);
double radLat = lat / 180.0 * pi;
double magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
double sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
double mgLat = lat + dLat;
double mgLon = lon + dLon;
info.setChina(true);
info.setLatitude(mgLat);
info.setLongitude(mgLon);
}
return info;
}

//从高德转到GPS
public static LocateInfo gcj02_To_Wgs84(double lat, double lon) {
LocateInfo info = new LocateInfo();
LocateInfo gps = transform(lat, lon);
double lontitude = lon * 2 - gps.getLongitude();
double latitude = lat * 2 - gps.getLatitude();
info.setChina(gps.isChina());
info.setLatitude(latitude);
info.setLongitude(lontitude);
return info;
}

// 判断坐标是否在国外
private static boolean isOutOfChina(double lat, double lon) {
if (lon < 72.004 || lon > 137.8347)
return true;
if (lat < 0.8293 || lat > 55.8271)
return true;
return false;
}

//转换
private static LocateInfo transform(double lat, double lon) {
LocateInfo info = new LocateInfo();
if (isOutOfChina(lat, lon)) {
info.setChina(false);
info.setLatitude(lat);
info.setLongitude(lon);
return info;
}
double dLat = transformLat(lon - 105.0, lat - 35.0);
double dLon = transformLon(lon - 105.0, lat - 35.0);
double radLat = lat / 180.0 * pi;
double magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
double sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
double mgLat = lat + dLat;
double mgLon = lon + dLon;
info.setChina(true);
info.setLatitude(mgLat);
info.setLongitude(mgLon);

return info;
}

//转换纬度所需
private static double transformLat(double x, double y) {
double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y
+ 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * pi) + 20.0 * Math.sin(2.0 * x * pi)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * pi) + 40.0 * Math.sin(y / 3.0 * pi)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * pi) + 320 * Math.sin(y * pi / 30.0)) * 2.0 / 3.0;
return ret;
}

//转换经度所需
private static double transformLon(double x, double y) {
double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1
* Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * pi) + 20.0 * Math.sin(2.0 * x * pi)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * pi) + 40.0 * Math.sin(x / 3.0 * pi)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * pi) + 300.0 * Math.sin(x / 30.0 * pi)) * 2.0 / 3.0;
return ret;
}
}

结论


通过上述代码,我们成功地将天地图集成到了我们的系统中,不仅满足了功能需求,还大幅降低了成本。这一过程中,我们深入理解了地图服务的工作原理,也提升了团队的技术能力。


注意事项



  • 确保在使用天地图API时遵守其服务条款,尤其是在商业用途中。

  • 由于网络或其他原因,天地图API可能存在访问延迟或不稳定的情况,建议在生产环境中做好异常处理和备用方案。

  • 坐标系转换是一个复杂的过程,确保使用可靠的算法和工具进行转换,以保证定位的准确性。


通过这次集成,我们不仅为公司节省了成本,还提升了系统的稳定性和用户体验。在未来的开发中,我们将继续探索更多高效、低成本的技术解决方案。


作者:JustinNeil
来源:juejin.cn/post/7419524888041472009
收起阅读 »

我开发的一些开发者小工具

web
在 2020 年,我辞职在家,每天都有大把时间。于是,我开始开发一些与开发相关的小工具,目的是解决开发中遇到的问题,或者帮助更深入地理解某些技术概念。 每天写写小工具,时间就这样一天天过去,回想起来,这段经历其实挺有意思的。 刚开始时,这些工具的 UI 确实比...
继续阅读 »

在 2020 年,我辞职在家,每天都有大把时间。于是,我开始开发一些与开发相关的小工具,目的是解决开发中遇到的问题,或者帮助更深入地理解某些技术概念。


每天写写小工具,时间就这样一天天过去,回想起来,这段经历其实挺有意思的。


刚开始时,这些工具的 UI 确实比较简陋。不过随着时间推移,我也在不断改进它们的外观。虽然现在看来可能还是不够精美,但已经有了很大进步。


说实话,这些工具的用户引导和文档都很少,更像是我自己的一个小天地。通过 Google Analytics 的数据,我发现有些工具的使用者可能只有我自己,比如微图床。但正因为我自己在用,即使最近添加新工具的频率减少了,我也一直在维护它们。


令我感到欣慰的是,我把其中一些工具提交到了阮一峰老师的博客,很多小工具都得到了他的推荐。这对我来说是一种莫大的鼓励。


一些与深入原理相关的工具


这些工具旨在帮助开发者更深入地理解一些基础概念和底层原理。


IEEE754 浮点数转换


这个工具可以帮助你理解 IEEE 754 标准中双精度浮点数的内部表示。它能将十进制数转换为对应的二进制表示,并清晰地展示符号位、指数位和尾数位。这对于理解计算机如何处理浮点数非常有帮助。


根据 IEEE754 标准,Infinity 的浮点数转换为:指数位全为 1,尾数位全为 0。


以下是 Infinity 的浮点数转换:


Infinity 的浮点数转换


根据 IEEE754 标准,0 的浮点数转换为:符号位为 0,指数位全为 0,尾数位全为 0。


以下是 0 的浮点数转换:


0 的浮点数转换


UTF-8 编码转换


UTF-8 是一种可变长度的字符编码,这个工具可以帮助你理解 Unicode 字符是如何被编码成 UTF-8 的。你可以输入任何 Unicode 字符,工具会显示其 UTF-8 编码的二进制表示,让你直观地看到编码过程。


UTF-8 编码转换示例


base64 编码转换


Base64 是一种常用的编码方式,特别是在处理二进制数据时。这个工具不仅可以帮助你理解 Base64 编码的原理,还提供了便捷的编码和解码功能。它对于处理需要在文本环境中传输二进制数据的场景特别有用。


base64 编码转换示例


文件类型检测


这个工具可以帮助你理解如何通过文件的魔数(magic number)来判断文件类型。你可以上传一个文件,工具会读取文件的二进制数据,并根据魔数判断文件类型。这在处理未知文件或验证文件类型时非常有用。


比如,JPEG 是因为它的 Magic Number 为 FF D8 FF DB


文件类型检测示例


图片相关


图片处理是 Web 开发中的一个重要方面,以下是一些与图片处理相关的工具。


微图


这是一个快速的图片压缩工具,可以帮助你减小图片文件的大小,而不会显著降低图片质量。


它支持多种图片格式,并且没有文件大小或数量的限制。这个工具对于优化网站加载速度特别有帮助。


最主要的是它借助于前端实现,无需服务器成本,所以你不需要担心隐私问题。它的实现方式与 squoosh 类似,都是借助于 WebAssembly 实现。


微图示例


微图床


这是一个个人图床工具,允许你将 GitHub 仓库用作个人图床。它提供了简单的上传和管理功能,让你可以方便地在文章或网页中引用图片。对于经常需要在线分享图片的开发者来说,这是一个非常实用的工具。


微图床示例


图片分享


这个工具可以帮助你快速生成带有文字的图片,适合用于社交媒体分享或创建简单的海报。它简化了图文组合的过程,让你无需使用复杂的图像编辑软件就能创建吸引人的图片。


图片分享示例


图片占位符


这是一个图片占位符生成工具,可以快速创建自定义尺寸和颜色的占位图片,非常适合在开发过程中使用。它可以帮助你在实际图片还未准备好时,保持页面布局的完整性。


图片占位符示例


编码与加密


在 Web 开发中,我们经常需要处理各种编码和加密。以下是一些相关的工具:


URL 编码


这个工具可以帮助你进行 URL 编码和解码,对于处理包含特殊字符的 URL 非常有用。它可以确保你的 URL 在各种环境中都能正确传输和解析。


HTML 实体编码


HTML 实体编码工具可以帮助你将特殊字符转换为 HTML 实体,确保它们在 HTML 中正确显示。这对于防止 XSS 攻击和确保 HTML 文档的正确渲染都很重要。


哈希生成器


这个工具可以生成多种常用的哈希值,包括 MD5、SHA1、SHA256 等。它在数据完整性验证、密码存储等场景中非常有用。


颜色工具


颜色是 Web 设计中的重要元素,以下是一些与颜色相关的工具:


颜色转换


这个工具可以在 RGB、HSL、CMYK 等不同颜色模型之间进行转换。它可以帮助设计师和开发者在不同的颜色表示方法之间自如切换。


颜色转换示例


调色板生成器


这个工具可以帮助你生成颜色的色调和阴影,非常适合创建一致的颜色主题。它可以让你快速构建和谐的配色方案,提高设计效率。


调色板生成器示例


对比度计算器


这个工具可以计算两种颜色之间的对比度,帮助你确保文本在背景上的可读性。它对于创建符合可访问性标准的设计非常重要。


对比度计算器示例


结语


虽然有些工具可能只有我自己在用,但正是这种持续的学习和创造过程让我感到充实和快乐。


我会继续维护和改进这些工具,也欢迎大家使用并提供反馈。


作者:程序员山月
来源:juejin.cn/post/7426151241470476298
收起阅读 »

尤雨溪成立VoidZero,Rust要一统JavaScript工具链?

web
尤雨溪在Vite Conf 2024上宣布成立公司Void Zero,目前已经完成$460万种子轮融资,由Accel领头,并且有Amplify以及在dev tools领域有丰富经验的创始人参与。 主要目标是搭建下一代JavaScript工具链,实现一套工具覆盖...
继续阅读 »

尤雨溪在Vite Conf 2024上宣布成立公司Void Zero,目前已经完成$460万种子轮融资,由Accel领头,并且有Amplify以及在dev tools领域有丰富经验的创始人参与。 主要目标是搭建下一代JavaScript工具链,实现一套工具覆盖从源码到最终产物的中间过程,例如semantic analysis、transformer、linter、formatter、minifier、boundler等。


image.png


好的工具链不外乎好用, 本文将结合尤雨溪在Vite Conf 2024上分享的内容来介绍什么是下一代JavaScript工具链, 以及好用体现在哪些方面, 最后再上手试验下相关工具看是否真的


Vite工具现状


相信现在大多数前端开发人员的构建工具首选一定是Vite,Vite确实易上手并且快,涵盖了Vue、React、Preact等主流前端框架,也支持TypeScript、Nuxt等。Vite仅需简单的几个指令即可运行起项目:


// 运行create指令,选择前端框架和语言,例如Vue、TypeScript
npm create vite@latest

// Done. Now run:

cd vite-project
npm install
npm run dev

Vite为什么快?


有对比才能体现快,几年前构建前端项目还是使用webpack、Rollup、Parcel等工具,当项目代码指数级增长,这些工具的性能瓶颈愈发明显,动不动需要几分钟才能启动dev server,即使是模块热更新(HMR),文件修改后也需要几秒钟才能反馈到浏览器,这严重影响开发者工作幸福指数


浏览器的快速发展造就了Vite,Vite的优势在两个方面:首先是支持了Native ES Modules,其次是build过程接入了编译型语言(如go、rust)开发的工具。这些优势体现在服务器启动和热更新两个阶段:



  • 服务器启动: Vite将应用中的模块区分为依赖、源码两种,改进了开发服务器启动时间。



    • 依赖:开发时不会变动的纯JavaScript,或者是较大的依赖(上百个模块的组件库),这些代码的处理代价比较高,Vite会使用esbuild预构建这些依赖,由于esbuild使用Go编写,因此比以JavaScript编写的打包器预构建快10-100倍。

    • 源码:对于Vue、JSX等频繁变动的代码文件,Vite以原生ESM方式提供源码,让浏览器接管了打包程序的部分工作,Vite只需要在浏览器请求源码时进行转换并安需提供,也就是需安需导入、安需加载。


    image.png


  • 热更新(HMR)


    在Vite中,HMR是在原生ESM上执行的。当编辑一个文件时,Vite只需要精确地使已编辑Module与其最近的HMR边界之间的链失活,使得无论应用大小如何,HMR能保持快速更新。


    Vite同时利用HTTP头来加速整个页面的重新加载:源码模块请求根据304 Not Modified协商缓存,而预构建的依赖模块请求则通过Cache-Control:max-age=31536000,immutable进行强缓存,因此一旦被缓存将不需要再次请求。



Vite也有缺陷


image.png


Vite当前架构底层依赖于esbuildRollupSWC,三者的作用如下:



  • esbuild: Vite使用esbuild执行依赖项预构建,转化TypeScript、JSX,并且作为生成环境构建的默认minifier。

  • Rollup: Rollup直接基于ES6模块格式,因此能够实现除屑优化(Tree Shaking),然后基于插件生态来支持打包过程的扩展。Vite基于Rollup的插件模板实现插件生态,构建生产环境的bundling chunk和静态资源。

  • SWC: SWC使用Rust语言实现,号称super-fastJavaScript编译器,能够将TypeScript、JSX编译为浏览器支持的JavaScript,编译速度比babel快20倍。Vite主要使用SWC来打包React代码以及实现React代码的HMR。


Vite充分利用esbuild、Rollup、SWC各自的优势来组合成一套打包工具链,虽然对使用者来说是无感的,但对于Vite内部,三套打包框架组合在一起本身就显得比较臃肿。


接下来我们就分析下这一套组合拳会有哪些缺陷:



  • 两套bundling



    虽然esbuild构建非常快,但它的tree shaking以及代码切分不像rollup的配置化那样灵活,插件系统设计的也不尽如人意,因此Vite仅在DEV环境使用esbuild预构建依赖项。rollup正好拟补了ebuild的缺点,比较好的chunck control,以及易配置的tree shaking,因此适合在生成环境打包代码。




  • 生产环境构建速度慢



    由于Rollup基于JavaScript实现,虽然比Webpack快很多,但相比于native工具链,速度就相形见绌了。




  • 比较大的二进制包



    SWC的二进制包有多大?在Mac系统下,达到37MB,比Vite和其依赖项文件总和大了2倍。




  • SWC虽然快,但缺少bundler能力



    SWC有比较完善的transform和minifier,但没有提供可用的bundler,这也就说明了SWC不能直接作为打包工具。




  • 不一致的bundler行为



    DEV环境使用esbuild预构建依赖项,而PROD环境使用rollup构建包,在包含ESM、CJS多模块形式场景下,esbuild和rullup的构建行为存在差异,导致一些仅在线上出现的问题。




  • 低效率的构建管道



    由于混合了JavaScript、Go、Rust三种程序,同一代码片段可能会在不同进程重复执行AST、transform、serialize,并将结果在不同进程间传递。另一方面,在进程间传递大量的代码块本身也会有比较大的开销,特别是传递source map此类文件时开销更大。





总结这些问题,就三点:碎片化、不兼容、低效率。 为了解决这些种种问题,统一的打包工具迫在眉睫,这也是尤雨溪提出Rolldown的主要原因。


image.png


基于Rust的下一代工具链


image.png


VoidZero提出的下一代工具链是什么?下图为 VoieZero规划蓝图,不管是Vue、React、Nuxt还是其他前端框架,一个Vite统统给你搞定,测试框架仅需Vitest即可。Vite底层依赖Rolldown打包器,而打包过程完全交由工具链Oxc负责。实际干活的RolldownOxc都基于Rust实现,因此够快。


image.png


OxcRolldown离正式使用还有一段距离,预计是2025年初投入使用,但这也不妨碍我们先了解下这两个工具让人惊掉下巴的牛,毕竟值460万美金。


Oxc


Oxc作为统一的语言工具链,将提供包含代码检查Linter、代码格式化Formatter、代码打包的组合式NPM包或者Rust包。代码打包过程分为TransformerMinifierResolverParserSemantic Analysis


Oxc官网地址: oxc.rs/ , 目前已经发布了oxlint v0.9.9oxc-transform alpha 版本



  • oxlint v0.9.9检查速度比eslint快50-100倍。oxlint已经在Shopify投入使用,之前使用eslint检查代码需要75分钟/CI,而使用oxlint仅需要10秒钟,你就说快吧!

  • oxc-transform alpha转换速度是SWC的3到5倍, 并且内存占用减少20%, 更小的包体积(2MB vs SWC的37MB). 已实现的主要3个功能:

    • 转换TypeScript至ESNext;

    • 转换React JSX至ESNext,并支持React Refresh;

    • TypeScript DTS 声明;




Oxc目前已完成Parser Linter Resolver,正在快马加鞭地完善Transformer


image.png


Rolldown


Rolldown是基于Rust实现的JavaScript快速打包器,与Rollup API兼容。作为打包器应包含的功能有:



  • Bundling:



    • 阶段1:使用Rolldown替换esbuild,执行依赖项预生成,主要包括多entry的代码切分、cjs/esm混合打包、基础插件支持;

    • 阶段2:Rolldown能够支持生成环境构建,包括命令行和配置文件支持、Treeshaking、Source map、Rollup插件兼容等;



  • Transform: 使用oxc代替esbuild的Transform,尽可能使用同一套AST。核心功能模块Typescript/JSX转换、代码缩减(minification)以及语法降级;

  • 与Vite深度集成: 替换esbuild和rollup,Vite内部核心插件使用Rust实现,提升构建效率。


image.png


Rolldown在性能方面表现如何?官方给出了两个测试。


测试一:打包数量为19k的模块文件,其中10k为React JSX组件,9k为图标js文件。不同打包框架的耗时如下:



  • rolldown: 0.63s

  • rolldown-vite: 1.49s

  • esbuild: 1.23s

  • fram: 2.08s

  • rsbuild: 3.14s



rolldown打包速度比esbuild快了近2倍。



测试二:打包Vue Core代码,基于TypeScript的多包仓库,包含11个包/62个dist bundles,耗时如下:


Vue版本构建框架构建耗时
Vue3.2Rollup + rullup-plugin-typescript2+ terser tsx114s
Vue3.5(main branch)Rollup + rollup-plugin-esbuild + swc minify tsc8.5s
Vue3.5(rolldown branch)Rolldown(tranform+minify) + oxc-transform1.11s


基于rolldown的Vue3.5源代码比Rollup构建快了近8倍。



单从测试数据来看,基于Rust开发的rolldown,打包速度确实带来惊人的提升。以下为下一代Vite的架构概览,预计2025年初发布。


image.png


总结


VoidZero宣称的下一代JavaScript工具链,价值460万美金,其商业价值可见一斑,对于研发个体来说没有明显的感受,但对于大型企业来说,VoidZero能实打实的为企业节省每年几百万的CI构建成本。


VoidZero将清一色的使用Rust来搭建底层构建逻辑,如果能够成型,也证明了Rust在前端构建领域的地位。这也让我们反思,借助于Rust独特的性能和安全性优势,它还能够为前端带来哪些价值?例如WASM支持,基于Tauri、Electon.rs框架的桌面应用,支持Flutter和Dart语言的移动端应用。


究竟VoidZero会为前端领域带来怎样的变革,Vite能不能一统JavaScript工具链,让我们拭目以待吧。



我是前端下饭菜,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!



作者:前端下饭菜
来源:juejin.cn/post/7422404598360948748
收起阅读 »

2024年全面的多端统一开发解决方案推荐!

web
前言 最近在DotNetGuide技术社区交流群看到有不少小伙伴问:有没有一套代码能够同时在多个平台运行的框架推荐?今天大姚给大家分享8个多端统一开发框架其中语言包括C#、C++、Vue、React、Dart、Kotlin等等(一套代码,可以运行到多个平台从而...
继续阅读 »


前言


最近在DotNetGuide技术社区交流群看到有不少小伙伴问:有没有一套代码能够同时在多个平台运行的框架推荐?今天大姚给大家分享8个多端统一开发框架其中语言包括C#、C++、Vue、React、Dart、Kotlin等等(一套代码,可以运行到多个平台从而大幅减轻开发者的开发与维护负担),同学们可以按需选择对应框架(排名不分先后,适合自己的才是最好的)。


使用情况投票统计


微信使用情况投票统计: mp.weixin.qq.com/s/9DNgjTIUX…


image.png


uni-app


uni-app 是一个使用 Vue.js 开发所有前端应用的框架,开发者编写一套代码,可发布到iOS、Android、Web(响应式)、以及各种小程序(微信/支付宝/百度/头条/飞书/QQ/快手/钉钉/淘宝)、快应用等多个平台。




功能框架图


从下面uni-app功能框架图可看出,uni-app在跨平台的过程中,不牺牲平台特色,可优雅的调用平台专有能力,真正做到海纳百川、各取所长。



为什么要选择uni-app?


uni-app在开发者数量、案例、跨端抹平度、扩展灵活性、性能体验、周边生态、学习成本、开发成本等8大关键指标上拥有更强的优势。



Taro


Taro是一个开放式跨端跨框架解决方案,支持使用 React/Vue/Nerv 等框架来开发微信/京东/百度/支付宝/字节跳动/ QQ 小程序/H5/React Native 等应用。




多端转换支持



Flutter


Flutter是由Google开发的一款开源、跨平台的UI(用户界面)框架,一份代码兼顾 Android、iOS、Web、Windows、macOS 和 Linux 六个平台,编译为原生机器代码,助力提升应用的流畅度并实现优美的动画效果。




主要特性



React Native


React Native由Facebook开发,允许开发者使用JavaScript和React来构建原生体验的移动应用,支持iOS和Android平台。


React Native不仅适用于 Android 和 iOS - 还有社区支持的项目将其应用于其他平台,例如:





Avalonia


Avalonia是一个强大的框架,使开发人员能够使用.NET创建跨平台应用程序。它使用自己的渲染引擎绘制UI控件,确保在Windows、macOS、Linux、Android、iOS和WebAssembly等不同平台上具有一致的外观和行为。这意味着开发人员可以共享他们的UI代码,并在不同的目标平台上保持统一的外观和感觉。



Avalonia 已经成熟且可用于生产,并被 Schneider Electric、Unity、JetBrains 和 GitHub 等公司使用。





.NET MAUI


.NET 多平台应用 UI (.NET MAUI) 是一个跨平台框架,用于使用 C# 和 XAML 创建本机移动和桌面应用。使用 .NET MAUI,您可以从单个共享代码库开发可在 Android、iOS、iPadOS、macOS 和 Windows 上运行的应用程序。





Uno


Uno平台是一个开源平台,用于快速构建单一代码库原生移动、Web、桌面和嵌入式应用程序。它允许 C# 和 WinUI XAML 和/或 C# 代码在所有目标平台上运行,同时允许您控制每个像素。它支持开箱即用的 Fluent、Material 和 Cupertino 设计系统。Uno 平台实现了越来越多的 WinRT 和 WinUI API,例如 Microsoft.UI.Xaml,使 WinUI 应用程序能够以本机性能在所有平台上运行。






Eto.Forms


Eto.Forms是一个.NET开源、跨平台的桌面和移动应用的统一框架,该框架允许开发者使用单一的UI代码库构建在多个平台上运行的应用程序,并利用各自平台的原生工具包,从而使应用程序在所有平台上看起来和工作都像原生应用一样。



支持的平台:支持Windows Forms、WPF、MonoMac和GTK#等桌面平台,以及正在开发中的iOS(使用Xamarin.iOS)和Android(使用Xamarin.Android)移动平台支持(尽管目前尚不完整)。





作者:追逐时光者
来源:juejin.cn/post/7426554951349747762
收起阅读 »

在我硬盘安监控了?纯 JS 监听本地文件的一举一动

web
💰 点进来就是赚到知识点!本文带你用 JS 代码监控本地文件,点赞、收藏、评论更能促进消化吸收! 🚀 想解锁更多 Web 文件系统的技能吗?快来订阅专栏「Web 玩转文件操作」! 📣 我是 Jax,在畅游 Web 技术海洋的又一年,我仍然是坚定不移的 Java...
继续阅读 »

💰 点进来就是赚到知识点!本文带你用 JS 代码监控本地文件点赞收藏评论更能促进消化吸收!


🚀 想解锁更多 Web 文件系统的技能吗?快来订阅专栏「Web 玩转文件操作」!


📣 我是 Jax,在畅游 Web 技术海洋的又一年,我仍然是坚定不移的 JavaScript 迷弟,Web 技术带给我太多乐趣。如果你也和我一样,欢迎关注私聊



开门见 demo


先来玩玩这个 demo —— 在 Chrome 中监控本地文件夹


20241005-220737.gif


在上面的 demo 中,点击按钮选择一个本地文件夹后,无论是在该文件夹中新增、修改还是删除内容,网页端都能够感知到每一步操作的细节,包括操作时间、操作对象是文件还是文件夹、具体执行了什么操作等等。



如果你的感觉是:”哟?有点儿意思!“ 那么这篇文章就是专门为你而写的,读下去吧。



本专栏的前几篇文章中,我们已经知道,Web 应用能对本地文件进行各种花式操作,例如选择文件/文件夹、增/删/改/查文件等等。网页好像能伸出长长的手臂,穿过浏览器触摸到了用户的本地文件。但你可能还不知道,网页也能长出千里眼、顺风耳,本地文件有什么风吹草动,都能被网页端监控到。如此灵通的耳目,它的名字就是 File System Observer API(文件系统观察者)。


API 简介


现在想象我们要开发一个 Web 端相册应用,展示用户本地文件夹中的图片。我们希望这个相册能实时响应用户的操作,例如增加/删掉几张图片后,无需用户手动在 Web 端刷新,就能自动更新到最新状态。


如果请你来实现自动刷新,阁下又该如何应对?


经典思路可能会是以短时间间隔轮询文件夹状态,读取并缓存每个文件的 lastModified 时间戳,如果前后两次轮询的时间戳发生了变化,再把前后差异更新到 Web 视图中。这种实现方式能达到效果,但还是有一些缺点,比如不能真正做到即时响应,且会有很大的性能问题等。


其实咱们都知道,最优雅高效的做法是仅在文件被操作时触发更新。原生操作系统如 WIndows 和 MacOS 都有这样的文件监听机制,但显然目前 Web 端还无法享受其便利性。除了在用户端,Node.js 应用也面临这样的问题。开发者苦此久矣。


直到 2023 年 6 月,来自谷歌的贡献者们开始推进一项 W3C 提案 —— File System Observer(为方便叙述,下文将简称其为 FSO),旨在从浏览器层面向 Web 应用提供跨平台的文件监听支持。如果这项提案能够顺利进入 ECMAScript 标准,那么 Web 文件系统的又一块重要功能版图将得以补全,Web 生态将会变得更友好、更强大。


解锁尝鲜:加入 Origin Trial


FSO 还是一套崭新的 API,有多新呢?MDN 和 CanIUse 中还没有建立关于它的词条。但这并不意味着我们完全无法用于生产环境 —— 正如你在本文开头的 demo 中体验到的,我已经用到线上功能中了。只要做一点配置工作,你和你的用户就能成为全球第一批享受到 FSO 的人 😎。


Chrome 已经对 FSO 开启了试用,版本范围是 129 到 134,你可以为你的 Web App 域名注册一个试用 token,你可以跟着我一步一步操作:


首先我们访问 developer.chrome.com/origintrial… 并登录账号。


20241006-163407.jpeg


点击界面下方的「REGISTER」按钮,进入表单页:


20241006-163919.jpeg


按照上图的标注填写信息。每一个域名都需要单独注册一次。例如我本地开发调试时用的是localhost:3000,而线上域名是 rejax.fun,那么就需要给这两个域名分别走一遍 REGISTER 流程。


填写信息后提交表单,你会得到一串字符串 token:


20241006-164840.jpeg


将 token 复制出来,写到 Web App 的 html 文件中,像这样:


<meta http-equiv="origin-trial" content="把 token 粘贴到这里" />

或者用 JavaScript 动态插入:


const meta = document.createElement('meta')
meta.httpEquiv = 'origin-trial'
meta.content = token
document.head.appendChild(meta)

最后,在 Chrome 中打开你注册的域名所在的页面,在 Console 中输入 FileSystemObserver 并回车:


20241006-165520.jpeg


如果打印出了「native code」而不是「undefined」,那么恭喜,你已经成功解锁了 FSO 试用!


监听一个文件


有了试用资格,我们来监听一个文件,边调试代码边研究 FSO 的设计和实现。


实例化


上一小节的最后,我们用来测试是否解锁成功的 FileSystemObserver 就是 FSO 的构造函数,它接收一个回调函数作为参数。我们可以像这样实例化一个观察者:


function callback (params) {
console.log(params)
}
const observer = new FileSystemObserver(callback)

callback 函数会在被监听的文件发生变动时被执行,所以我们可以把响应变动的业务处理逻辑放在其中。


绑定目标文件


实例 observer 有一个 observe 方法,它接收两个参数。第二个参数暂且按下不表,我们先专心看第一个参数。


这个参数是一个 FileSystemHandle 格式的对象,代表着本地文件在 JavaScript 运行时中的入口。我们可以通过 showOpenFilePicker 来选择一个文件(假如我们选择了文件 a.js),并获取到对应的 FileSystemHandle


const [fileHandle] = await window.showOpenFilePicker()
observer.observe(fileHandle)


如果你想看 FileSystemHandleshowOpenFilePicker 的详解,可以移步至本专栏的上一篇文章谁也别拦我们,网页里直接增删改查本地文件!



调用 observe 方法后,这个文件就算是进入了我们的监控区域 📸 了,直到我们主动解除监听或者网页被关闭/刷新。


监听文件操作


当我们编辑文件 a.js 的内容时,给 observe() 传入的回调函数被调用,并且会接收到两个参数,第一个是本次的变动记录 records,第二个是实例 observer 本身。我们打印 records 可以看到如下结构:


20241006-201248.jpeg


records 是一个数组,其元素是 FileSystemChangeRecord 类型的对象,我们重点关注以下几个属性:



  • changedHandle:可以理解为这就是我们绑定的文件。

  • type:变动类型,可取值及对应含义如下:


    type 值含义
    appeared新建文件,或者移入被监听的根目录
    disappeared文件被删除,或者移出被监听的根目录
    modified文件内容被编辑
    moved文件被移动
    unknown未知类型
    errored出现报错



一般情况下,如果我们监听的是单个文件而不是一个目录,那么无论是把文件移走、重命名、删除, record 中的 type 值都会是 disappeared。


监听一个文件夹


监听文件夹的方式和监听文件类似,我们先用 showDirectoryPicker 选择一个文件夹(以文件夹 foo 为例),再把 DirectoryHandle 传入 observe 方法。



为方便描述,我们假设文件夹 foo 的结构如下:


/foo


├── 文件夹 dir1


├── 文件夹 **dir2**


└── 文件 a.js



const dirHandle = await window.showDirectoryPicker()
observer.observe(dirHandle)

与文件有所不同的是,文件夹会有子文件夹和子文件,这是一个树形结构。如果我们只想监听 foo 下面的一级子内容,那么使用像上方代码块那样的调用方式就可以了。但如果我们想密切掌控每一子级的变动,就需要额外的配置参数,也就是前文提到的第二个参数:


observer.observe(dirHandle, {
recursive: true
})

此时你可以在 foo 文件夹里面任意增、删、改子文件或文件夹,一切操作都能在回调函数里以 record 的形式被捕获到。子文件和子文件夹所支持的操作类型,record 值也具有相同结构,因此接下来我们从监听子文件的视角来观察 FSO。


监听子文件


创建和移入、删除和移出 a.js 的情况,record.type 的值分布如下:


文件移入 foo在 foo 中创建文件文件从 foo 中移出删除文件
appearedappeareddisappeareddisappeared

其中移出和删除的表现,与监听单文件的情况是相同的。


我们来试试把 a.js 移到与它同级的文件夹 dir1 中,看看会得到怎样的 record


20241006-211746.jpeg


有几个点值得我们注意:



  • type 的值是 moved,说明只要 a.js 还在 foo 内,不管处于第几层,都不会触发 type: appeared/disappeared

  • relativePathMovedFrom 是一个单元素数组,它代表移动前 a.js 的文件路径

  • relativePathComponents 有两个数组元素,代表被移动文件的新路径是 dir1/a.js


但重命名子文件和监听单文件时不同。例如我们将 a.js 更名为 b.js,会监听到如下 record


20241006-210550.jpeg


我们本以为 type 的值是 renamed,但其实是 moved,确实有点反直觉。从 record 上来看,与真正的移动操作相比,重命名的不同之处在于:



  • changedHandle 指向了重命名后的新文件 b.js

  • relativePathMovedFromrelativePathComponents 分别包含的是旧名和新名


FSO 在状态设计上并没有直接定义一个重命名状态,但我们可以自己来区分。重命名的响应数据有这样的特征:



  • relativePathMovedFromrelativePathComponents 这两个数组的 length 一定相等

  • 除了最后一个元素,两个数组的其他元素一定是一一对应相等的


因此我们可以这样判断重命名操作:


const { oldList: relativePathMovedFrom, newList: relativePathComponents } = recors[0]
let operation = '是常规的移动操作'
// 重命名前后,文件的目录路径没变,只是文件名变了
if (oldList.length === newList.length) {
const len = newList.length
for (let i = 0; i < len; i++) {
// 相同序号的新旧路径是否一样
const isEqual = newList[i] === oldList[i]
if (i < len - 1) {
if (!isEqual) break
} else if (!isEqual) {
operation = '是重命名操作,不是移动操作'
}
}
}

至此,我们已经摸清了如何监听子文件上的不同操作,除了监听单文件部分已经覆盖到的内容,增量知识点仅有移动和重命名这两块。


监听子文件夹


对子文件夹的操作,也不外乎新建、删除、移动、重命名,和子文件在逻辑上基本一致,我们可以直接复用子文件的监听逻辑,再加上用 record.changedHandle.kind === ‘directory’ 来判断是否是文件夹即可。


解除监听


当我们想主动解除对文件或文件夹的监听时,只需要调用对应 observerdisconnect 即可:


observer.disconnect()

结语


恭喜你读完了本文,你真棒!


这一次,我们勇敢地品尝了一只新鲜生猛的螃蟹,对 File System Observer API 进行了较为深入的理解和实践。如果你之前一直苦于 JS 无法监听文件,无法带给用户完备的功能和极致的体验,那么从现在开始,你可以开始着手准备升级你的 Web App 了!


这套船新版本的 API 有力地补齐了 Web 文件系统 API 的短板,增强了 Web App 的实现能力,提升了开发者和用户的体验。它还在不断修改完善中,非常需要我们开发者积极参与到标准的制定中来,让 Web 技术栈变得更高效、更易用!


作者:JaxNext
来源:juejin.cn/post/7422275840069615652
收起阅读 »

如何实现一个稳如老狗的 websocket?

web
前言 彦祖们,前端开发中对于 setTimeout setInterval 一定用得烂熟于心了吧? 但你知道你的定时器并没那么靠谱吗? 本文涉及技术栈(非必要) vue2 场景复现 今天笔者在开发业务的时候就遇到了这样一个场景 前后端有一个 ws 通道,我...
继续阅读 »

前言


彦祖们,前端开发中对于 setTimeout setInterval 一定用得烂熟于心了吧?


但你知道你的定时器并没那么靠谱吗?


本文涉及技术栈(非必要)



  • vue2


场景复现


今天笔者在开发业务的时候就遇到了这样一个场景


前后端有一个 ws 通道,我们暂且命名为 channel


前后端约定如下:



  1. 前端每隔 5000ms 发送一个 ping 消息

  2. 后端收到 ping 后回复一个 pong 消息

  3. 后端如果 15000ms 未收到 ping,则视为 channel 失活,直接 kill

  4. kill 后前端会主动发起重连


文章还没写两分钟,一只暴躁的测试老哥说道:"你们的 ws 也太不稳定了,几十秒就断开一次?废物?"


骂骂咧咧的甩过来一张截图


image.png


笔者心想:"为什么我的界面稳如老狗?浏览器问题,绝对是浏览器问题..."


起身查看,遂发现毫无问题,和笔者一模一样的 chrome 版本...


静心而坐,对着浏览器屏幕茶颜悦色(哦,察言观色)...


10 分钟过去了,半小时过去了...还是稳如老狗,根本不断


image.png


问题分析


那么问题到底出在哪里呢?


笔者坐在测试妹纸身边仔细观察了她的操作后!


发现她不断得切屏,此时已初步心虚,不禁问道 GPT



当浏览器标签页变为非活动状态时,setIntervalsetTimeout 的执行频率通常会被降级。大多数现代浏览器将其执行频率限制在 1 秒(1000 毫秒)或更高,以减少 CPU 和电池的消耗。



问题原因大致是这样了


问题复现


此时笔者在本地写了个 demo


let prev = performance.now()

setInterval(()=>{
const offset = performance.now() - prev
prev = performance.now()
console.log('__SY__🎄 ~ setInterval ~ offset:', offset)

},1000)

理想的情况,我们这个 offset 应该是一直维持在 1000ms 左右


那么后续我们就要看页面激活 | 失活时候的情况了


页面激活时


我们先看下页面激活时的打印数据


image.png


没什么问题,符合我们的期望值


页面失活时


接下来我们,切换到其他浏览器标签,保持几分钟,几分钟后我们看下打印数据


image.png


明显发现有些数据不符合我们的期望值


甚至有些夸张到长达 41003ms,将近 40 倍,不靠谱!


寻找方案


用 setTimeout 模拟 setInterval


其实网上最多的方案就是说用 setTimeout 模拟 setInterval


但是很可惜,笔者亲自模拟下来,也是同样的结果,我们看截图


image.png


而且发现更加不靠谱了...错误的概率明显更高了...


其实可想而知,setIntervalsetTimeout 在事件循环中都属于 Task


事件循环的优先级是一样的,同样都属于主线程任务(标记起来,后面考重点)


Web Worker


其实网上还有类似于 requestAnimationFrame 的方案


但是测试下来更离谱,就不浪费彦祖们的时间了


进入正题吧


其实上文说了,主线程任务的优先级会被降低,那么我们思考一下子线程任务呢?


子线程任务在前端领域,我们不就能想到 Web Worker 吗?


当然除了 Web Worker,还有 SharedWorker Service Worker


非本文重点,不做赘述


什么是 Web Worker


首先我们来认识下什么是 Web Worker



Web Worker 是一种运行在浏览器后台的独立 JavaScript 线程,允许我们在不阻塞主线程(即不影响页面 UI 和用户交互的情况下)执行一些耗时的任务,比如数据处理、文件操作、复杂计算等。



不阻塞主线程这恰恰是我们的所需要的!


使用 Web Worker


其实 Web Worker常规使用vue 中还是有一定的区别的


常规使用


常规使用其实非常简单,我们还是以上文中的 demo 为例


改造一下



  • index.html


const worker = new Worker('./worker.js');

我们还需要一个 worker.js文件



  • worker.js


let prev = performance.now()

setInterval(() => {
const offset = performance.now() - prev
prev = performance.now()
console.log('__SY__🎄 ~ setInterval ~ offset:', offset)
}, 1000)

切换 tab 几分钟后让我们来看看打印结果


image.png


非常完美,几乎都保持在 1000ms 左右


在 vue 中使用 Web Worker


在 vue 中使用就和常规使用有所不同了


这也是笔者今天踩坑比较多的地方


网上很多文中配置了 webpackworker-loader,然后改造 vue.config.js


但是笔者多次尝试,还是各种报错(如果有大佬踩过坑,请在评论区留言)


最后笔者翻到了之前的笔记,其实早在多年之前就记录了在 vue 中使用 Web Worker 的文章


使用方式非常简单


我们只需要把 worker.js 放置于 public 目录即可!


看下我们此时的代码



  • App.vue


// 此处注意要访问 根路径 /
const myWorker = new Worker('/worker.js')


  • public

    • worker.js




let prev = performance.now()

setInterval(() => {
const offset = performance.now() - prev
prev = performance.now()
console.log('__SY__🎄 ~ setInterval ~ offset:', offset)
}, 1000)

测试一下


image.png


非常完美!


解决业务问题


彦祖们此时可能要问道,你只是证明了 Web Worker 不会阻塞主进程


和你的业务有什么关系吗?


其实这还得依赖于Web Worker的通信机制


我们继续改造



  • App.vue


const myWorker = new Worker('/worker.js')

myWorker.postMessage('createPingInterval') //向 worker 发送开启定时器的指令
// 接收 Web Worker 的执行指令,执行对应业务
myWorker.onmessage = function (event) {
console.log('__SY__🎄 ~ event:', event)
}


  • worker.js


// 接收到主进程 `开启定时器的指令` 处理定时器逻辑
self.onmessage = function(event) {
const interval = setInterval(() => {
self.postMessage('executor') // 定时向主进程发送定时器执行指令
}, 1000)
}

封装一个 setWorkerInterval


其实有了以上的代码模型,我们就能封装一个不受主进程阻塞的定时器了


我们暂且命名它为 setWorkerInterval


函数设计


首先设计一下我们的函数


为了减少开发者心智负担


我们需要把函数设计成和 setInterval 一样的用法


我们在使用 setInterval 的时候,日常最常用的参数就是 callbackdelay


它的返回值是一个 intervalID


由此可见我们的函数签名如下


function setWorkerInterval(callback,delay){
const intervalID = xxx(callback,delay) // 定时执行
return intervalID
}

动手实现


有了上面的函数设计,我们就开始来实现


目前我们遇到一个问题,那就是上文中的 xxx 具体是个啥?


这其实就是 Web Worker 中的 setInterval


我们只需要把 Web Worker 中的 setInterval的功能暴露给主线程不就完事了吗?


来看 代码



  • setWorkerInterval.js


export default function(callback, delay) {
//创建一个 worker
const worker = new Worker('/worker.js')
worker.postMessage('') // 开启定时器
// 接收 Web Worker 的消息
worker.onmessage = function(event) {
// 收到 worker 的 setInterval 触发,触发对应业务逻辑
}
}


  • worker.js



// 处理定时器逻辑
self.onmessage = function(event) {
const interval = setInterval(() => {
self.postMessage({}) // 定时通知主线程,即上文中的 xxx
}, 1000)
}

这样我们就初步完成了以上 xxx 的逻辑


但随之而来又有两个问题


1.如何触发对应业务逻辑?


2.如何清除定时器?


触发对应业务逻辑

其实第一个问题非常容易解决,我们不是传递了一个 callback 吗?


这不就是我们的业务逻辑吗


改造一下



  • setWorkerInteraval.js


export default function(callback, delay) {
const worker = new Worker('/worker.js')

worker.postMessage('') // 开启定时器
// 接收 Web Worker 的消息
worker.onmessage = function(event) {
callback() // 定时执行业务 callback
}
}

清除定时器

这个问题还是踩了坑的,刚开始以为 intervalID 的来源不就在 worker.js吗?


那我们只需要把它通知给主线程即可,后来发现不可行,主线程的 clearInteravl 对于 workerintervalID 并不生效...


那我们换个思路,在主线程发送一个 clear 指令不就行了吗? 说干就干,思路有了,直接看代码



  • worker.js


// 处理定时器逻辑
self.onmessage = function(event) {

const intervalID = setInterval(() => {
self.postMessage({}) // 定时通知主线程,即上文中的 xxx
}, 1000)

/// 收到clear消息后, 清除定时器
if (event.data === 'clear') {
clearInterval(intervalID)
}

}


  • setWorkerInteraval


export default function(callback, delay) {
const worker = new Worker('/worker.js');
// 因为 onmessage 是异步的, 所以我们要抛出一个 promise
return new Promise((resolve) => {
worker.postMessage('') // 开启定时器
// 接收 Web Worker 的消息
worker.onmessage = function(event) {
callback() // 执行业务逻辑
}
})
const clear = () => {worker.postMessage('clear')}
return clear // 返回一个函数, 用于关闭定时器
}

让我们看下使用方式


let prev = performance.now()
const clear = setWorkerInteraval(function(){
const offset = performance.now() - prev
console.log('__SY__🎄 ~ setWorkerInteraval ~ offset:', offset)
prev = performance.now()
},1000)

setTimeout(clear,5000) // 5000ms 后清除

以上代码看似没问题,但是使用下来并不生效,也就是定时器并未被清除


问题出在哪里呢?


其实我们在发送 clear 指令的时候,也会进入 self.onmessage 函数


那么此时又会新建一个 interval,而我们清空的只是当前 interval 而已


那么我们必须想个方法,使得 interval 在当前实例是唯一的


其实非常简单,借助于 JS 万物皆对象 的思想,我们的 self 不也是一个对象吗?


那我们在它上面挂载一个 interval 有何不可呢?说干就干



  • worker.js


// 处理定时器逻辑
self.onmessage = function (event) {
// 返回一个非零值 所以我们可以大胆使用 ||=
self.intervalID ||= setInterval(() => {
self.postMessage({}) // 定时通知主线程,即上文中的 xxx
}, 1000)

/// 收到clear消息后, 清除定时器
if (event.data === 'clear') {
clearInterval(self.intervalID)
}
}

测试后,非常完美,至此,一个靠谱的定时器我们就完成了!


当然我们还可以把上文中的 1000ms 改成 delay 传参,直接看完成代码吧


完整代码



  • worker.js (vue项目 需要放在 public 中)


// 处理定时器逻辑
self.onmessage = function (event) {
/// 收到clear消息后, 清除定时器
if (event.data === 'clear') {
clearInterval(self.intervalID)
} else {
const delay = event.data
self.intervalID ||= setInterval(() => {
self.postMessage({}) // 定时通知主线程,即上文中的 xxx
}, delay)
}
}


  • setWorkerInteraval


export default function(callback, delay) {
const worker = new Worker('/worker.js');

worker.postMessage(delay) // 传递 delay 延时参数
// 接收 Web Worker 的消息
worker.onmessage = function(event) {
callback() // 执行业务逻辑
}
const clear = () => {worker.postMessage('clear')}
return clear
}


写在最后


技术服务于业务,但最怕局限于业务


希望彦祖们在开发业务中,能获取更多更深层次的思考和能力!共勉✨


感谢彦祖们的阅读


个人能力有限


如有不对,欢迎指正🌟 如有帮助,建议小心心大拇指三连🌟


彩蛋


宁波团队还有一个资深前端hc, 带你实现海鲜自由。 欢迎彦祖们私信😚


作者:前端手术刀
来源:juejin.cn/post/7418391732163182607
收起阅读 »

css3+vue做一个带流光交互效果的功能入口卡片布局

web
前言 该案例主要用到了css的新特性 @property来实现交互流光效果 @property是CSS Houdini API的一部分,通过它,开发者可以在样式表中直接注册自定义属性,而无需运行任何JavaScript代码。 Demo在线预览 @prope...
继续阅读 »

前言


该案例主要用到了css的新特性 @property来实现交互流光效果

@property是CSS Houdini API的一部分,通过它,开发者可以在样式表中直接注册自定义属性,而无需运行任何JavaScript代码。


Demo在线预览



@propert语法说明


@property --自定义属性名 {  
syntax: '语法结构';
initial-value: '初始值';
inherits: '是否允许该属性被继承';
}

自定义属性名:需要以--开头,这是CSS自定义属性的标准命名方式。

syntax:描述该属性所允许的语法结构,是必需的。它定义了自定义属性可以接受的值的类型,如颜色、长度、百分比等。

initial-value:用于指定自定义属性的默认值。它必须能够按照syntax描述符的定义正确解析。在syntax为通用语法定义时,initial-value是可选的,否则它是必需的。

inherits:用于指定该自定义属性是否可以被其他元素所继承,通过布尔值truefalse赋值。


Html代码部分


  <div class="flex-x-box">
<!-- 内容 start -->
<div class="card" v-for="(i,index) in list" :key="index">
<img :src="i.url"/>
<div class="info-box">
<h1>{{ i.label +'管理' }}</h1>
<p>{{ i.desc }}</p>
</div>
<div class="details-box">
<span>
<h2>{{ i.total }}</h2>
<p>{{i.label +'总数'}}</p>
</span>
<span>
<h2>{{ i.add }}</h2>
<p>今日新增</p>
</span>
</div>
</div>
<!-- 内容 end -->
</div>

数据结构部分


list:[
{
label:'报表',
desc:'深度数据挖掘,多维度报表生成,实时监控业务动态,为企业决策提供有力数据支撑',
url:'图标地址',
total:'108',
add:'12'
},{
label:'产品',
desc:'全生命周期管理,从研发到销售无缝衔接,优化产品组合,提升市场竞争力',
url:'图标地址',
total:'267',
add:'25'
},{
label:'文档',
desc:'高效知识存储,智能检索体系,确保信息准确传递,助力团队协作与知识共享',
url:'图标地址',
total:'37',
add:'2'
}
]

css样式控制部分


.flex-x-box{
display:flex;
}

/* 卡片宽度 */
$card-height:280px;
$card-width:220px;


div,span,p{
box-size:border-box;
}
.card {
position: relative;
width: $card-width;
height: $card-height;
border-radius: 12px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: rgba(88,199,250,0%);
cursor: pointer;
box-size:border-box;
background: linear-gradient(210deg,#203656,#426889);
margin:0px 15px;

img{
width:90%;
margin-top:-80px;
transition:200ms linear;
}
.info-box{
width:100%;
}
h1{
font-size:14px;
color:#f3fbff;
}
p{
font-size:12px;
color:#a1c4de;
padding: 0px 28px;
line-height: 1.6;
}
.details-box{
position: absolute;
display:flex;
align-items:center;
width: 100%;
height:120px;
box-sizing: border-box;
bottom: 0px;
padding-top: 45px;
z-index: 10;
background: linear-gradient(0deg, #152d4a 70%, transparent);
border-radius: 0px 0px 12px 12px;
transition:200ms linear;
opacity:0;
span{
width:0px;
flex-grow:1;
}
h2{
margin:0px;
font-size:14px;
line-height:1.8;
color:#fff;
}
p{
margin:0px;
}
}
}

.card:hover{
&::before{
background-image: linear-gradient(var(--rotate) , #5ddcff, #3c67e3 43%, #ffddc5);
animation: spin 3s linear infinite;
}
&::after{
background-image: linear-gradient(var(--rotate), #5ddcff, #3c67e3 43%, #ff8055);
animation: spin 3s linear infinite;
}
.details-box{
opacity:1;
}
img{
width:96%;
margin-top:-60px;
}
}

.card::before {
content: "";
width:$card-width +2px;
height:$card-height +2px;
border-radius: 13px;
background-image: linear-gradient(var(--rotate) , #435b7c, #5f8cad 50%, #729bba);
position: absolute;
z-index: -1;
top: -1px;
left: -1px;
}
.card::after {
position: absolute;
content: "";
top: calc($card-height / 6);
left: 0;
right: 0;
z-index: -1;
height: 100%;
width: 100%;
margin: 0 auto;
transform: scale(0.9);
filter: blur(20px);
opacity: 1;
transition: opacity .5s;
}

/* CSS 自定义属性 */
@property --rotate {
syntax: "<angle>";
initial-value: 90deg;
inherits: false;
}

@keyframes spin {
0% {
--rotate: 0deg;
}
100% {
--rotate: 360deg;
}
}

作者:Easy_Y
来源:juejin.cn/post/7423708823428055081
收起阅读 »

入职N天的我,终于接到了我的第一个需求

web
我是9月份中下旬入职的,入职了之后,每天的工作就是熟悉代码和框架,就这样过了几天之后,迎来了我在新公司的第一个节日:国庆节。每年国庆节都是农民最开心的日子,因为要秋收了,秋收就意味着赚钱了,但是今年农作物的行情就跟程序员的行情一样,超级不好,各种粮食的价格都很...
继续阅读 »

我是9月份中下旬入职的,入职了之后,每天的工作就是熟悉代码和框架,就这样过了几天之后,迎来了我在新公司的第一个节日:国庆节。

每年国庆节都是农民最开心的日子,因为要秋收了,秋收就意味着赚钱了,但是今年农作物的行情就跟程序员的行情一样,超级不好,各种粮食的价格都很便宜,辛辛苦苦一年只能算个收支平衡(来自一个农二代的吐槽)。

节后第一天,由于领导请假了,所以我还是在自己看代码(是的,还在看)。等到了第二天,领导来上班了,我迫不及待的问领导有没有什么需求给我,领导说快有了,然后我就只能默默地等待,好在中午快吃饭的时候,领导跟我说有个功能需要修改一下,让我来做(激动地心,颤抖的手,终于有需求可做了)

项目排序功能

需求:根据汉字拼音进行排序。

有以下几种实现方案

方式一:通过String.prototype.localCompare()

const arr = ['我十分', '喜欢', '写', '代码'];

function sortArr(data) {
    return data.sort((a, b) => a.localeCompare(b, 'zh', { sensitivity: 'base' }))
}
sortArr(arr)

如果大家有对localeCompare不熟悉的可以继续往下看,如果有比较熟悉的,那么可以直接跳过。

localeCompare

返回一个数字,用于表示引用字符串(调用者referenceString)在比较字符串(第一个参数compareString)的前面、后面、或者相等

参数
  • compareString: 要与referenceString进行比较的字符串,所有值都会被强制转换为字符串。如果不传或者传入undefined会出现你不想要的情况
  • locals:表示语言类型,比如zh就是中文
  • options: 一堆属性
  • localeMather:区域匹配算法
  • usage:比较是用于排序还是用于搜索匹配的字符串,支持sort和search,默认sort
  • sensitivity:字符串哪些差异导致非0
    • base:字母不同的字符串比较不相等,a≠b,a=A
    • accent:字母不同 || 重音符号和其他变音符号的不同字符串比较不相等, a ≠ b、a ≠ á、a = A。
    • case:字母不同 或者 大小写的字母表示不同 a ≠ b,a ≠ A
    • variant:字符串的字母、重音和其他变音符号,或者不同大小写比较不相等,a ≠ b、a ≠ á、a ≠ A

    注意:当useage的字为sort时,sensitivity的字默认为variantignore

  • Punctuation:是否忽略标点符号,默认为falsen
  • umeric:是否使用数字对照,使得“1”<“2”<“10”,默认是false
  • caseFirst:是否个根据大小写排序,可能的值是upper、lower、false(默认设置)
  • collation:区域的变体
    • pinyin:汉语
    • compat:阿拉伯语

locals中的配置会和options内的配置会发生重叠,如果有重叠,options的优先级更高

注意:某些浏览器中可能不支持locals或者options,那么此时会忽略这些属性

返回值

返回一个数字

  • 如果引用字符串在比较字符串前面,返回负数
  • 如果引用字符串在比较字符串后面,返回正数
  • 如果两者相同,那么返回0

warning:不要依赖具体的返回值去做一些事情,因为在不同的浏览器、或者相同浏览器的不同版本中,返回的具体数值可能是不一样的,W3C只要求返回值是正数或者负数,而不规定具体的值。

方式2:通过pinyin库

  1. 需要安装pinyin库,在命令行中执行
npm install pinyin
  1. 实现排序逻辑

const { pinyin } = require('pinyin')

const arr = [ '我十分', '喜欢', '写', '代码' ]

const sortedWords = arr.sort((a, b) => {
const pinyinA = pinyin(a, { style: pinyin.STYLE_NORMAL }).flat().join('')
const pinyinB = pinyin(b, { style: pinyin.STYLE_NORMAL }).flat().join('')
return pinyinA.localeCompare(pinyinB)
})

console.log(sortedWords)

方式3:自己实现一套映射

因为我们的文案不是确定的,且可以随意修改,所以这种方案不提倡,但是如果只有几个固定的文案,这样可以自己实现一套映射

const pinyinMap = {
我十分: 'woshifen',
喜欢: 'xihuan',
写: 'xie',
代码: 'daima',
}
const arr = [ '我十分', '喜欢', '写', '代码' ]

const sortedWords = arr.sort((a, b) => pinyinMap[a].localeCompare(pinyinMap[b]))
console.log(sortedWords)

上面三种方式,可以看的出来,第一种还是存在一定的误差,但是我还是选择了第一种实现方式,有以下几个原因

  1. 不需要额外的引入库
  2. 我们的文案是随时可以修改的
  3. 我们对于排序的要求没有那么强烈,只要排一个大致的顺序即可

以上就是我对根据汉字拼音排序实现方案的理解,欢迎大家补充,希望大家一起进步!


作者:落魄的开发
来源:juejin.cn/post/7423573726400299027
收起阅读 »

几种神秘鲜为人知但却有趣的前端技术

web
测定网速 navigator.connection.downlink 是一个用于表示当前连接下行速度的属性,它是 JavaScript 的 Network Information API 的一部分。此属性返回一个数值,单位为 Mbps(Megabits per...
继续阅读 »

测定网速


navigator.connection.downlink 是一个用于表示当前连接下行速度的属性,它是 JavaScript 的 Network Information API 的一部分。此属性返回一个数值,单位为 Mbps(Megabits per second),表示网络的下行带宽。


例如,你可以通过以下方式使用它:


if (navigator.connection) {
const downlink = navigator.connection.downlink;
console.log(`当前下行速度为: ${downlink} Mbps`);
} else {
console.log("当前浏览器不支持Network Information API");
}

需要注意的是,Network Information API 并不是所有浏览器都支持,因此在使用时最好进行兼容性检查。


在智能手机上启用振动


window.navigator.vibrate 是一个用于触发设备震动的 Web API,通常用于移动设备上。这个方法允许开发者控制设备的震动模式,它接受一个数字或一个数组作为参数。



  • 如果传入一个数字,这个数字表示震动的时长(以毫秒为单位)。

  • 如果传入一个数组,可以定义震动和静止的模式,例如 [200, 100, 200] 表示震动200毫秒,静止100毫秒,再震动200毫秒。


以下是一个简单的示例:


// 使设备震动 500 毫秒
if (navigator.vibrate) {
navigator.vibrate(500);
} else {
console.log("当前浏览器不支持震动 API");
}

// 使用数组来创建震动模式
if (navigator.vibrate) {
navigator.vibrate([200, 100, 200, 100, 200]);
}

请注意,并不是所有的设备和浏览器都支持震动 API,因此在使用时最好确认设备的兼容性。


禁止插入文字


你可能不希望用户在输入字段中粘贴从其他地方复制的文本(请仔细考虑清楚是否真的要这样做)。通过跟踪事件paste并调用其方法preventDefault()就很容易完成了。


<input type="text"></input>
<script>
  const input = document.querySelector('input');

  input.addEventListener("paste"function(e){
    e.preventDefault()
  })

</script>

好了,现在你无法复制粘贴,必须得手动编写和输入所有的内容了


快速隐藏dom


要隐藏DOM元素,不是非得用到JavaScript。原生的HTML属性完全可以实现hidden。效果类似于添加样式display: none;。元素就从页面上消失了。


<p hidden>我在页面看不到了</p>

注意,这个技巧不适用于伪元素


快速使用定位


你知道CSS的inset属性吗?这是我们所熟悉的topleftrightbottom的缩写版本。通过类比短语法margin或属性padding,只要一行代码就可以设置元素的所有偏移量。


/* 普通写法 */ 
div {
  position: absolute;
  top0;
  left0;
  bottom0;
  right0;
}

/* inset写法 */ 
div {
  position: absolute;
  inset: 0;
}

使用简短的语法可以大大减小CSS文件的体积,这样代码看起来更干净。但是,可别忘了inset是一个布尔属性,它考虑了内容排版方向。换句话说,如果站点使用的是具有rtl方向(从右到左)的语言,那么left要变成right,反之亦然。


你不知道的Console的用法


通常我们用的最多的console.log(xxx),其实在 JavaScript 中,console 对象提供了一些很有用的方法用于调试和查看信息。以下是一些可能不太常见的 console 用法:



  1. console.table() : 可以用来以表格的格式输出数组或对象,非常适合查看数据结构。


    const data = [
    { name: 'Alice', age: 25 },
    { name: 'Bob', age: 30 }
    ];
    console.table(data);


  2. console.group() 和 console.groupEnd() : 可以将相关日志信息分组,方便查看和组织输出。


    console.group('Gr0up Label');
    console.log('这是一条 log');
    console.log('这是一条 log 2');
    console.groupEnd();


  3. console.time() 和 console.timeEnd() : 用于测量代码块的执行时间。


    console.time('myTimer');
    // 执行一些操作
    console.timeEnd('myTimer'); // 输出所用的时间


  4. console.error() 和 console.warn() : 用于输出错误和警告信息,通常会以不同的颜色高亮显示。


    console.error('这是一个错误信息');
    console.warn('这是一个警告信息');


  5. console.assert() : 用于在条件为 false 时输出错误信息。


    const condition = false;
    console.assert(condition, '条件为 false,输出这条信息');


  6. console.clear() : 清空控制台的输出。


    console.clear();


  7. console.dir() : 用于打印对象的可枚举属性,方便查看对象的详细结构。


    const obj = { name: 'Alice', age: 25 };
    console.dir(obj);



禁止下拉刷新


下拉刷新是当前流行的移动开发模式。如果你不喜欢这样做,只需将overscroll-behavior-y属性的值设为contains即可达到此效果。


body {
 overscroll-behavior-y: contain;
}

这个属性对于组织模态窗口内的滚动也非常有用——它可以防止主页在到达边框时拦截滚动


使整个网页的 <body> 内容可编辑


document.body.contentEditable='true'; 是一段 JavaScript 代码,用于使整个网页的 <body> 内容可编辑。这意味着用户可以直接在浏览器中点击并编辑文本,就像在文本编辑器中一样。


以下是一些相关的要点:



  1. 启用编辑模式:将 contentEditable 属性设置为 'true',浏览器会允许用户更改页面的内容。


    document.body.contentEditable = 'true';


  2. 禁用编辑模式:如果希望用户无法编辑页面,您可以将该属性设置为 'false'


    document.body.contentEditable = 'false';


  3. 注意事项



    • 这种做法在很多场景中很方便,比如在展示一些信息并希望用户能快速修改的时候。例如,创建自定义的富文本编辑器。

    • 但是,使用 contentEditable 也可能会带来一些不便,比如用户修改了页面的结构,甚至可能影响脚本的运行。因此在使用时要谨慎,并确保有合适的方法来处理用户的输入。

    • 启用 contentEditable 后,如果网页中有表单元素,用户的输入可能与表单的默认行为产生冲突。



  4. 样式和功能:在启用编辑模式后,你可能还想添加一些 CSS 来改变光标样式,或者结合 JavaScript 进一步增强编辑体验,比如自动保存用户的修改。


示例代码:


// 启用编辑功能
document.body.contentEditable = 'true';

// 禁用编辑功能
// document.body.contentEditable = 'false';

这种功能可以非常方便地用于快速原型设计或需要快速内容编辑的应用,但在生产环境中要慎重使用。


带有Id属性的元素,会创建全局变量


在一张HTML页面中,所有设置了ID属性的元素会在JavaScript的执行环境中创建对应的全局变量,这意味着document.getElementById像人的智齿一样显得多余了。但实际项目中最好还是老老实实该怎么写就怎么写,毕竟常规代码出乱子的机会要小得多。


<div id="test"></div>
<script>
console.log(test)
</script>

网站平滑滚动


<html> 元素中添加 scroll-behavior: smooth,以实现整个页面的平滑滚动。


html{    
scroll-behavior: smooth;
}

:empty 表示空元素


此选择器定位空的 <p> 元素并隐藏它们


p:empty{   
display: none;
}

作者:鱼樱前端
来源:juejin.cn/post/7423314983884292134
收起阅读 »

threejs渲染高级感可视化风力发电车模型

web
本文使用threejs开发一款风力发电机物联可视化系统,包含着色器效果、动画、补间动画和开发过程中使用模型材质遇到的问题,内含大量gif效果图, 视频讲解及源码见文末 技术栈 three.js 0.165.0 vite 4.3.2 nodej...
继续阅读 »

本文使用threejs开发一款风力发电机物联可视化系统,包含着色器效果、动画、补间动画和开发过程中使用模型材质遇到的问题,内含大量gif效果图,



视频讲解及源码见文末



技术栈



  • three.js 0.165.0

  • vite 4.3.2

  • nodejs v18.19.0


效果图


一镜到底动画


一镜到底 (1).gif


切割动画


切割动画.gif


线稿动画


线稿动画.gif


外壳透明度动画


外壳透明度动画.gif


展开齿轮动画


展开齿轮动画.gif


发光线条动画


发光线条.gif


代码及功能介绍


着色器


文中用到一个着色器,就是给模型增加光感的动态光影


创建顶点着色器 vertexShader:


varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

创建片元着色器 vertexShader:


varying vec2 vUv;
uniform vec2 u_center; // 添加这一行

void main() {
// 泡泡颜色
vec3 bubbleColor = vec3(0.9, 0.9, 0.9); // 乳白色
// 泡泡中心位置
vec2 center = u_center;
// 计算当前像素到泡泡中心的距离
float distanceToCenter = distance(vUv, center);
// 计算透明度,可以根据实际需要调整
float alpha = smoothstep(0.1, 0.0, distanceToCenter);

gl_FragColor = vec4(bubbleColor, alpha);

创建着色器材质 bubbleMaterial


export const bubbleMaterial = new THREE.ShaderMaterial({
vertexShader: vertexShader,
fragmentShader: fragmentShader,
transparent: true, // 开启透明
depthTest: true, // 开启深度测试
depthWrite: false, // 不写入深度缓冲
uniforms: {
u_center: { value: new THREE.Vector2(0.3, 0.3) } // 添加这一行
},
});


从代码中可以看到 uniform声明了一个变量u_center,目的是为了在render方法中动态修改中心位置,从而实现动态光效的效果,


具体引用 render 方法中


 // 更新中心位置(例如,每一帧都改变)  
let t = performance.now() * 0.001;
bubbleMaterial.uniforms.u_center.value.x = Math.sin(t) * 0.5 + 0.5; // x 位置基于时间变化
bubbleMaterial.uniforms.u_center.value.y = Math.cos(t) * 0.5 + 0.5; // y 位置基于时间变化

官网案例 # Uniform,详细介绍了uniform的使用方法,支持通过变量对着色器材质中的属性进行改变


光影着色器.gif


从模型上可能看不出什么,下面的图是在一个圆球上加的这个效果


光影着色器-球体.gif


着色器中有几个参数可以自定义也可以自己修改, float alpha = smoothstep(0.6, 0.0, distanceToCenter);中的smoothstep 是一个常用的函数,用于在两个值之间进行平滑插值。具体来说,smoothstep(edge0, edge1, x) 函数会计算 x 在 edge0 和 edge1 之间的平滑过渡值。当 x 小于 edge0 时,返回值为 0;当 x 大于 edge1 时,返回值为 1;而当 x 在 edge0 和 edge1 之间时,它返回一个在 0 和 1 之间的平滑过渡值。


切割动画


切割动画使用的是数学库平面THREE.Plane和属性 constant,通过修改constant值即可实现动画,从normal法向量起至constant的距离为可展示内容。



从原点到平面的有符号距离。 默认值为 0.



constant取模型的box3包围盒的min值,至max值做补间动画,以下是代码示意


const wind = windGltf.scene
const boxInfo = wind.userData.box3Info;

const max = boxInfo.worldPosition.z + boxInfo.max.z
const min = boxInfo.worldPosition.z + boxInfo.min.z

let tween = new TWEEN.Tween({ d: min - 0.2 })
.to({ d: max + 0.1 }, 1000 * 2)
.start()
.onUpdate(({ d }) => {
clippingPlane.constant = d
})

详看切割效果图


切割动画.gif


图中添加了切割线的辅助线,可以通过右侧的操作面板显示或隐藏。


模型材质需要注意的问题


由于齿轮在风车的内容部,并且风车模型开启了transparent=true,那么计算透明度深度就会出现问题,首先要设置 depthWrite = true,开启深度缓存区,renderOrder = -1



这个值将使得scene graph(场景图)中默认的的渲染顺序被覆盖, 即使不透明对象和透明对象保持独立顺序。 渲染顺序是由低到高来排序的,默认值为0



threejs的透明材质渲染和不透明材质渲染的时候,会互相影响,而调整renderOrder顺序则可以让透明对象和不透明对象相对独立的渲染。


depthWrite对比


depthwrite对比.jpeg


renderOrder 对比


renderOrder 对比.jpeg


自定义动画贝塞尔曲线


众所周知,贝塞尔曲线通常用于调整关键帧动画,创建平滑的、曲线的运动路径。本文中使用的tweenjs就内置了众多的运动曲线easing(easingFunction?: EasingFunction): this;类型,虽然有很多内置,但是毕竟需求是无限的,接下来介绍的方法就是可以自己设置动画的贝塞尔曲线,来控制动画的执行曲线。


具体使用


// 使用示例
const controlPoints = [ { x: 0 }, { x: 0.5 }, { x: 2 }, { x: 1 }];
const cubicBezier = new CubicBezier(controlPoints[0], controlPoints[1], controlPoints[2], controlPoints[3]);

let tween = new TWEEN.Tween(edgeLineGr0up.scale)
.to(windGltf.scene.scale.clone().set(1, 1, 1), 1000 * 2)
.easing((t) => {
return cubicBezier.get(t).x
})
.start()
.onComplete(() => {
lineOpacityAction(0.3)
res({ tween })
})

在tween的easing的回调中添加一个方法,方法中调用了cubicBezier,下面就介绍一下这个方法


源码


[p0] – 起点  
[p1] – 第一个控制点
[p2] – 第二个控制点
[p3] – 终点

export class CubicBezier {
private p0: { x: number; };
private p1: { x: number; };
private p2: { x: number; };
private p3: { x: number; };

constructor(p0: { x: number; }, p1: { x: number; }, p2: { x: number; }, p3: { x: number; }) {
this.p0 = p0;
this.p1 = p1;
this.p2 = p2;
this.p3 = p3;
}

get(t: number): { x: number; } {
const p0 = this.p0;
const p1 = this.p1;
const p2 = this.p2;
const p3 = this.p3;

const mt = 1 - t;
const mt2 = mt * mt;
const mt3 = mt2 * mt;
const t2 = t * t;
const t3 = t2 * t;

const x = mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x;

return { x };
}
}


CubicBezier支持get方法,通过四个关键点位信息,绘制三次贝塞尔曲线,参数t在0到1之间变化,当t从0变化到1时,曲线上的点从p0平滑地过渡到p3


mt = 1 - t;:这是t的补数(1减去t)。
mt2 = mt * mt; 和 mt3 = mt2 * mt;:计算mt的平方和立方。
t2 = t * t; 和 t3 = t2 * t;:计算t的平方和立方。


这是通过取四个点的x坐标的加权和来完成的,其中权重是基于t的幂的。具体来说,p0的权重是(1-t)^3p1的权重是3 * (1-t)^2 * tp2的权重是3 * (1-t) * t^2,而p3的权重是t^3


{ x: 0 },{ x: 0.5 },{ x: 2 },{ x: 1 } 这组数据形成的曲线效果是由start参数到end的两倍参数再到end参数


具体效果如下


贝塞尔曲线.gif


齿轮


齿轮动画


模型中自带动画


齿轮动画数据.jpeg


源码中有一整套的动画播放类方法,HandleAnimation,其中功能包含播放训话动画,切换动画,播放一次动画,绘制骨骼,镜头跟随等功能。


具体使用方法:


   // 齿轮动画
/**
*
* @param model 动画模型
* @param animations 动画合集
*/

motorAnimation = new HandleAnimation(motorGltf.scene, motorGltf.animations)
// 播放动画 take 001 是默认动画名称
motorAnimation.play('Take 001')

在render中调用


motorAnimation && motorAnimation.upDate()

齿轮展开(补间动画)


补间动画在齿轮展开时调用,使用的tweenjs,这里讲一下定位运动后的模型位置,使用# 变换控制器(TransformControls),代码中有封装好的完整的使用方法,在TransformControls.ts中,包含同时存在轨道控制器时与变换控制器对场景操作冲突时的处理。


使用方法:


/**
* @param mesh 受控模型
* @param draggingChangedCallback 操控回调
*/

TransformControls(mesh, ()=>{
console.log(mesh.position)
})

齿轮展开定位.jpeg


齿轮发光


发光效果方法封装在utls/index.ts中的unreal方法,使用的是threejs提供的虚幻发光通道RenderPass,UnrealBloomPass,以及合成器EffectComposer,方法接受参数如下



// params 默认参数
const createParams = {
threshold: 0,
strength: 0.972, // 强度
radius: 0.21,// 半径
exposure: 1.55 // 扩散
};

/**
*
* @param scene 渲染场景
* @param camera 镜头
* @param renderer 渲染器
* @param width 需要发光位置的宽度
* @param height 发光位置的高度
* @param params 发光参数
* @returns
*/


调用方法如下:



const { finalComposer: F,
bloomComposer: B,
renderScene: R, bloomPass: BP } = unreal(scene, camera, renderer, width, height, params)
finalComposer = F
bloomComposer = B
renderScene = R
bloomPass = BP
bloomPass.threshold = 0


除了调用方法还有一些需要调整的地方,比如发光时模型什么材质,又或者不发光时又是什么材质,这里需要单独定义,并在render渲染函数中调用


 if (guiParams.isLight) {
if (bloomComposer) {
scene.traverse(darkenNonBloomed.bind(this));
bloomComposer.render();
}
if (finalComposer) {
scene.traverse(restoreMaterial.bind(this));
finalComposer.render();
}
}

scene.traverse的回调中,检验模型是否为发光体,再进行材质的更换,这里用的标识是 object.userData.isLighttrue时,判定该物体为发光物体。其他物体则不发光


回调方法


function darkenNonBloomed(obj: THREE.Mesh) {
if (bloomLayer) {
if (!obj.userData.isLight && bloomLayer.test(obj.layers) === false) {
materials[obj.uuid] = obj.material;
obj.material = darkMaterial;
}
}

}

function restoreMaterial(obj: THREE.Mesh) {
if (materials[obj.uuid]) {
obj.material = materials[obj.uuid];
// 用于删除没必要的渲染
delete materials[obj.uuid];
}
}


再场景的右上角我们新增了几个参数,用来调整线条的发光效果,下面通过动图看一下,图片有点大,请耐心等待加载


调试发光效果.gif


好啦,本篇文章到此,如看源码有不明白的地方,可私信~


最近正在筹备工具库,以上可视化常用的方法都将涵盖在里面


历史文章


three.js——商场楼宇室内导航系统 内附源码


three.js——可视化高级涡轮效果+警报效果 内附源码


高德地图巡航功能 内附源码


three.js——3d塔防游戏 内附源码


three.js+物理引擎——跨越障碍的汽车 可操作 可演示


百度地图——如何计算地球任意两点之间距离 内附源码


threejs——可视化地球可操作可定位


three.js 专栏


源码及讲解



源码 http://www.aspiringcode.com/content?id=…


体验地址:display.aspiringcode.com:8888/html/171422…


作者:孙_华鹏
来源:juejin.cn/post/7379906492038889512
收起阅读 »