注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

希尔排序,我真的领悟了

web
之前文章我们讲到过 冒泡排序、选择排序、插入排序 都是原地的,并且时间复杂度都为O(n^2) 的排序算法。那么今天我们来讲一下希尔排序,它的时间复杂度为O(n*logn)。那这个算法是怎么做到的呢?我们这回一次看个透。 首先再回顾一下 冒泡、选择、插入这3个排...
继续阅读 »

之前文章我们讲到过 冒泡排序选择排序插入排序 都是原地的,并且时间复杂度都为O(n^2) 的排序算法。那么今天我们来讲一下希尔排序,它的时间复杂度为O(n*logn)。那这个算法是怎么做到的呢?我们这回一次看个透。


首先再回顾一下 冒泡选择插入这3个排序。这三个排序都有一个共同的特点,就是每次比较都会得到当前的最大值或者最小值。有人会说这是屁话,但你细品,为什么很多人都会去刻意背10大排序算法,本质就是因为自己的思想被困住了(什么每轮比较得出最大值的就是冒泡,得出最小值的就是选择等等),假设你从没有接触过排序算法,我还真不相信你不会排序,最差的情况就是做不出原地呗,时间复杂度最差也是N^2,就像下面这样:


let data = [30, 20, 55, 10, 90];

for (let index = 0; index < data.length; index++){
for (let y = 0; y < data.length - index; y++){
if(data[y] > data[y+1]){
[ data[y], data[y+1] ] = [ data[y+1], data[y] ];
}
}
}


data的长度是5,就循环5次,每轮比较中都要得出当前轮次的最大值。那么在每一轮中,如何得出最大值呢?那就再来一次遍历。


上述思想我们会发现,它在时间复杂度上是突破不了 O(n^2) 的限制的。原因在于你是两两比较(一次只能在两个数中得到最大值,一次只能给两个数排序)


如何突破限制呢?那就一次比较多个,就像下面这样:


let data = [30, 20, 55, 10, 90];

// 1、我们对data数组进行拆分,拆分规则:步长为2的数据放到一个集合里。
// 2、根据上面的拆分规则,我们可以将data数组拆成2个子数组。分别是:[30, 55, 90]、[20, 10]
// 3、分别对这2个子数组进行排序,排序后的子数组分别是:[30, 55, 90]、[10, 20]
// 4、将上面的子数组合并为一个新的数组data1:[30, 10, 55, 20, 90]
// 5、修改拆分规则,对修改后的data1数组进行拆分,步长为1。
// 6、因为步长为1,所以相当于对data1数组进行整体排序。

那么如何用代码表示呢?请继续阅读。


第一步、确定步长


这里我们以不断均分,直到均分结果大于0为原则来确定步长:


let data = [30, 20, 55, 10, 90];

// 维护步长集合
let gapArr = [];

let temp = Math.floor(data / 2);

while(temp > 0){
gap.push(temp);
temp = Math.floor(temp / 2);
}


第二步、得到间隔相等的元素


这一步其实本质上就是将间隔相等的元素放在一起进行比较


这意味着我们不用分割数组,只要保证原地对间隔相同的元素进行排序即可。


let data = [30, 20, 55, 10, 90];

let gapArr = [2, 1];

for (let gapIndex = 0; gapIndex < gapArr.length; gapIndex++){
// 当前步长
let curGap = gapArr[gapIndex];
// 从当前索引项为步长的地方开始进行比较
for(let index = curGap; index < data.length; index++){
let curValue = data[index]; // 当前的值
let prevIndex = index - curGap; // 间隔为gap的前一项索引
while(prevIndex >= 0 && curValue < data[prevIndex]){
// 这里面的while就代表着gap相等的数据项之间的比较...
prevIndex = prevIndex - curGap;
}
}
}

第二层的for循环、最里面的while循环需要我们好好理解一下。


就以上面的数据为例,我们先不考虑数据交换的问题,只考虑上面的写法是如何把gap相等的元素联系到一块的,现在我们来推演一下:




从上图我们看到,第一次while循环因为不满足条件,导致没有被触发。紧接着index++,我们来推演一下这种状态下的数据:



继续index++,此时我们来推演一下这种状态下的数据:




经过我们一轮间隔(gap)的分析,我们发现这种 for + while 的方法能够满足我们对间隔相等的元素进行排序。因为我们通过这种方式可以获取到间隔相等的元素


此时,我们终于可以进入到了最后一步,那就是对间隔相等的元素进行排序


第三步、对间隔相等的元素进行排序


在上一步的基础上,我们来完成相应元素的排序。


// 其它代码都不变......
for(let index = curGap; index < data.length; index++){
let curValue = data[index]; // 当前的值
let prevIndex = index - curGap; // 间隔为gap的前一项索引
while(prevIndex >= 0 && curValue < data[prevIndex]){
// 新增代码 ++++++
data[prevIndex + curGap] = data[prevIndex];
prevIndex = prevIndex - curGap;
}
// 新增代码 ++++++
data[prevIndex + curGap] = curValue;
}

现在我们来对排序的过程进行一下数据推演:


注意,这里我们只演示 curGap === 2 && index === data.length - 1 && data === [30, 20, 55, 10, 9] 的情况。


读到这里大家可能会发现我们突然换了数据源,因为原先的数据源的最后一项正好是最大值,不方便看到数据比较的全貌,所以在这里我们将最后一项改为了最小值。




开启while循环如下:




紧接上图,第二次进入while循环如下:




第二次循环结束后,此时的prevIndex < 0,因为未能进入到第三次的while循环:




至此,我们完成了本轮的数据推演。


在本轮数据推演中,我们会发现它跟之前的两两相比,区别在于它一次可能会比较很多个元素,更具体的说就是,它的一次for循环里,可以比较多个元素对,并将这些元素对进行排序。


第四步、源码展示


function hillSort(arr){
let newData = Array.from(arr);
// 增量序列集合
let incrementSequenceArr = [];
// 数组总长度
let allLength = newData.length;
// 获取增量序列
while(incrementSequenceArr[incrementSequenceArr.length - 1] != 1){
let increTemp = Math.floor(allLength / 2);
incrementSequenceArr.push(increTemp);
allLength = increTemp;
}
for (let gapIndex = 0; gapIndex < incrementSequenceArr.length; gapIndex++){
// 遍历间隔
let gap = incrementSequenceArr[gapIndex]; // 获取当前gap
for (let currentIndex = gap; currentIndex < newData.length; currentIndex++){
let preIndex = currentIndex - gap; // 前一个gap对应的索引
let curValue = newData[currentIndex];
while(preIndex >= 0 && curValue < newData[preIndex]){
newData[preIndex + gap] = newData[preIndex];
preIndex = preIndex - gap;
}
newData[preIndex + gap] = curValue;
}
}
return newData;
}

最后


又到分别的时刻啦,在上述过程中如果有讲的不透彻的地方,欢迎小伙伴里评论留言,希望我说的对你有启发,我们下期再见啦~~

作者:小九九的爸爸
来源:juejin.cn/post/7258180488359018557

收起阅读 »

随着鼠标移入,图片的切换跟着修改背景颜色(Vue3写法)

web
先看看效果图吧 下面来看实现思路 又是摸鱼的下午,无聊来实现了一下这个效果,记录一下,说不定以后有这需求,记一下放到官网上也是OK的, 我这里提供一种实现方法,当然你们想用放大加模糊也是可以的,想怎么来就怎么来 1.背景颜色不是固定的,是随着图片的切换动态...
继续阅读 »

先看看效果图吧


image.png


image.png


下面来看实现思路


又是摸鱼的下午,无聊来实现了一下这个效果,记录一下,说不定以后有这需求,记一下放到官网上也是OK的,
我这里提供一种实现方法,当然你们想用放大加模糊也是可以的,想怎么来就怎么来


1.背景颜色不是固定的,是随着图片的切换动态改变


原理:

1.当鼠标移入到某一张图片时,拿到这张图片

2.我们就可以把这张图片画到canvas里,就可以获取到每一个像素点

3.我们的背景是需要渐变的,我们是需要三种颜色的渐变,当然也可以有很多种,看你们的心情

4.我们就要计算出前三种的主要颜色,但是每个像素点的颜色非常非常多,好多颜色也非常相近,我们通过肉眼肯定看不出来的,这个时候就要用到计算机了

5.需要一种近似算法(颜色聚合算法)了,就是把好多相近的颜色聚合成一种颜色,当然我们就要用到第三方库(colorthief)了


准备好html


<template>
<div class="box">
<div class="item" v-for="item in 8" :key="item" :class="item === hoverIndex ? 'over' : ''">
<img crossorigin="anonymous" @mouseenter="onMousenter($event.target, item)" @mouseleave="onMousleave"
:src="`https://picsum.photos/438/300?id=${item}`" alt=""
:style="{ opacity: hoverIndex === -1 ? 1 : item === hoverIndex ? 1 : 0.2 }">
// 设置透明度
</div>
</div>
</template>


scss


.box {
height: 100vh;
display: flex;
justify-content: space-evenly;
align-items: center;
flex-wrap: wrap;
background-color: rgb(var(--c1), var(--c2), var(--c3));
}

.item {
border: 1px solid #fff;
margin-top: 50px;
transition: 0.8s;
padding: 5px;
box-shadow: 0 0 10px #00000058;
background-color: #fff;
}

img {
transition: .8s;
}

npm安装colorthief库


npm i colorthief

导入到文件中


import ColorThief from "colorthief";

因为这是一个构造函数,所以需要创建出一个实例对象


const colorThief = new ColorThief()
const hoverIndex = ref<number>(-1) //设置变换样式的响应式变量

重点函数:鼠标移入事件onMousenter


getPalette(img,num) img是dom元素,是第三库需要将其画入到canvas中,所以需要在img标签中添加一个允许跨域的属性 crossorigin="anonymous",不然会报错

num是需要提取几种颜色,同样也会返回多少个数组

返回的是一个promise,需要await


const onMousenter = async (img: EventTarget | null, i: number) => {
hoverIndex.value = i //将响应式变量改成自身,样式就生效了
const colors = await colorThief.getPalette(img, 3)
console.log(colors); //获取到三个数组,将其数组改造成rgb格式
const [c1, c2, c3] = colors.map((c: string[]) => `rgb(${c[0]},${c[1]},${c[2]})`)//将三个颜色解构出来
html.style.setProperty('--c1', c1) //给html设置变量,下面有步骤
html.style.setProperty('--c2', c2)
html.style.setProperty('--c3', c3)
}

鼠标移出事件


将响应式变量初始化,将背景颜色改为白色


const onMousleave = () => {
hoverIndex.value = -1
html.style.setProperty('--c1', '#fff')
html.style.setProperty('--c2', '#fff')
html.style.setProperty('--c3', '#fff')
}

获取html根元素


const html = document.documentElement

在主文件index.html给html设置渐变变量


<style>
html{
background-image: linear-gradient(to bottom, var(--c1), var(--c2),var(--c3));
}
</style>

image.png
需要注意的是colorthief使用的时候需要给img设置跨域,不然会报错,还有就是给html设置渐变变量


🔥🔥🔥好的,到这里基本上就已经实现了,看着代码也不多,也没啥技术含量,全靠三方库干事,主要是记录生活,方便未来cv


作者:井川不擦
来源:juejin.cn/post/7257733186158903356
收起阅读 »

几何算法:判断两条线段是否相交

web
‍ ‍大家好,我是前端西瓜哥。 如何判断两条线段(注意不是直线)是否有交点? 传统几何算法的局限 上过一点学的西瓜哥我,只用高中学过的知识,还是可以解这个问题的。 一条线段两个点,可以列出一个两点式(x - x1) / (x2 - x1) = (y - y1)...
继续阅读 »


‍大家好,我是前端西瓜哥。


如何判断两条线段(注意不是直线)是否有交点?


传统几何算法的局限


上过一点学的西瓜哥我,只用高中学过的知识,还是可以解这个问题的。


一条线段两个点,可以列出一个两点式(x - x1) / (x2 - x1) = (y - y1) / (y2 - y1)),两条线段是两个两点式,这样就是 二元一次方程组 了 ,就能求出两条直线的交点。


然后判断这个点是否在其中一条线段上。如果在,说明两线段相交,否则不相交。


看起来不错,但这里要考虑直线垂直或水平于坐标轴的特殊情况,还有两条直线平行导致没有唯一解的情况,除数不能为 0 的情况。


特殊情况实在是太多了,能用是能用,但不好用。


那么,有其他的更好的解法吗?


有的,叉乘。


叉乘是什么?


叉乘(cross product)是线性代数的一个概念,也叫外积、叉积、向量积,是在三维空间中两个向量的二元运算的结果,该结果为一个向量。


但那是严格意义上的。实际也可以用在二维空间的二维向量中,不过此时它们的叉乘结果变成了标量。


假设向量 A 为 (x1, y1),向量 B 为 (x2, y2),则叉乘 AxB 的结果为 x1 * y2 - x2 * y1


(注意叉乘不满足交换律)


在几何意义上,这个叉乘结果的绝对值对应两个向量组成的平行四边形的面积。


此外可通过符号判断向量 A 变成向量 B 的旋转方向。


如果叉乘为正数,说明 A 变成 B 需要逆时针旋转(旋转角度小于 180 度);


如果为负数,说明 A 到 B 需要顺时针旋转;


如果为 0,说明两个向量平行(或重合)


叉乘解法的原理


回到题目本身。


假设线段 1 的端点为 A 和 B,线段 2 的端点为 C 和 D。


图片


我们可以换另一个角度去解,即判断线段 1 的两个端点是否在线段 2 的两边,然后再反过来比线段 2 的两点是否线段 1 的两边。


这里我们可以利用上面 叉乘的正负代表旋转方向的特性


以上图为例, AB 向量到 AD 向量位置需要逆时针旋转,AB 向量到 AC 向量则需要顺时针,代表 C 和 D 在 AB 的两侧,对应就是两个叉乘相乘为负数。


function crossProduct(p1: Point, p2: Point, p3: Point)number {
  const x1 = p2[0] - p1[0];
  const y1 = p2[1] - p1[1];
  const x2 = p3[0] - p1[0];
  const y2 = p3[1] - p1[1];
  return x1 * y2 - x2 * y1;
}

const [a, b] = seg1;
const [c, d] = seg2;

// d1 的符号表示 AB 旋转到 AC 的旋转方向
const d1 = crossProduct(a, b, c);


只是判断了 C 和 D 在 AB 线段的两侧还不行,因为可能还有下面这种情况。


图片


所以我们还要再判断一下,A 和 B 是否在 CD 线的的两侧。计算过程同上,这里不赘述。


一般实现


type Point = [numbernumber];

function crossProduct(p1: Point, p2: Point, p3: Point): number {
  const x1 = p2[0] - p1[0];
  const y1 = p2[1] - p1[1];
  const x2 = p3[0] - p1[0];
  const y2 = p3[1] - p1[1];
  return x1 * y2 - x2 * y1;
}

function isSegmentIntersect(
  seg1: [Point, Point],
  seg2: [Point, Point],
): boolean {
  const [a, b] = seg1;
  const [c, d] = seg2;

  const d1 = crossProduct(a, b, c);
  const d2 = crossProduct(a, b, d);
  const d3 = crossProduct(c, d, a);
  const d4 = crossProduct(c, d, b);

  return d1 * d2 < 0 && d3 * d4 < 0;
}

// 测试
const seg1: [PointPoint] = [
  [00],
  [11],
];
const seg2: [PointPoint] = [
  [01],
  [10],
];

console.log(isSegmentIntersect(seg1, seg2)); // true


注意,这个算法认为线段的端点刚好在另一条线段上的情况,不属于相交。


考虑点在线段上或重合


如果你需要考虑线段的端点刚好在另一条线段上的情况,需要额外在叉乘为 0 的情况下,再判断一下线段 1 的端点是否在另一个线段的 x  和 y 范围内。


对应的算法实现:


type Point = [numbernumber];

function crossProduct(p1: Point, p2: Point, p3: Point): number {
  const x1 = p2[0] - p1[0];
  const y1 = p2[1] - p1[1];
  const x2 = p3[0] - p1[0];
  const y2 = p3[1] - p1[1];
  return x1 * y2 - x2 * y1;
}

function onSegment(p: Point, seg: [Point, Point]): boolean {
  const [a, b] = seg;
  const [x, y] = p;
  return (
    x >= Math.min(a[0], b[0]) &&
    x <= Math.max(a[0], b[0]) &&
    y >= Math.min(a[1], b[1]) &&
    y <= Math.max(a[1], b[1])
  );
}

function isSegmentIntersect(
  seg1: [Point, Point],
  seg2: [Point, Point],
): boolean {
  const [a, b] = seg1;
  const [c, d] = seg2;

  const d1 = crossProduct(a, b, c);
  const d2 = crossProduct(a, b, d);
  const d3 = crossProduct(c, d, a);
  const d4 = crossProduct(c, d, b);

  if (d1 * d2 < 0 && d3 * d4 < 0) {
    return true;
  }
 
  // d1 为 0 表示 C 点在 AB 所在的直线上
  // 接着会用 onSegment 再判断这个 C 是不是在 AB 的 x 和 y 的范围内
  if (d1 === 0 && onSegment(c, seg1)) return true;
  if (d2 === 0 && onSegment(d, seg1)) return true;
  if (d3 === 0 && onSegment(a, seg2)) return true;
  if (d4 === 0 && onSegment(b, seg2)) return true;

  return false;
}

// 测试
const seg1: [PointPoint] = [
  [00],
  [11],
];
const seg2: [PointPoint] = [
  [01],
  [10],
];
const seg3: [PointPoint] = [
  [00],
  [22],
];
const seg4: [PointPoint] = [
  [11],
  [10],
];
// 普通相交情况
console.log(isSegmentIntersect(seg1, seg2)); //  true
// 线段 1 的一个端点刚好在线段 2 上
console.log(isSegmentIntersect(seg3, seg4)); // true


结尾


总结一下,判断两条线段是否相交,可以判断两条线段的两端点是否分别在各自的两侧,对应地需要用到二维向量叉乘结果的正负值代表向量旋转方向的特性。


我是前端西瓜哥,关注我,学习更多几何算法。



作者:前端西瓜哥
来源:juejin.cn/post/7257547252540751909

收起阅读 »

我给项目加了性能守卫插件,同事叫我晚上别睡的太死

web
点击在线阅读,体验更好链接现代JavaScript高级小册链接深入浅出Dart链接现代TypeScript高级小册链接 引言 给组内的项目都在CICD流程上更新上了性能守卫插件,效果也还不错,同事还疯狂夸奖我 接下里进入我们的此次的主题吧 由于我组主要是负...
继续阅读 »

点击在线阅读,体验更好链接
现代JavaScript高级小册链接
深入浅出Dart链接
现代TypeScript高级小册链接

引言


给组内的项目都在CICD流程上更新上了性能守卫插件,效果也还不错,同事还疯狂夸奖我


WX20230708-170807@2x.png


接下里进入我们的此次的主题吧



由于我组主要是负责的是H5移动端项目,老板比较关注性能方面的指标,比如首页打开速度,所以一般会关注FP,FCP等等指标,所以一般项目写完以后都会用lighthouse查看,或者接入性能监控系统采集指标.



WX20230708-141706@2x.png


但是会出现两个问题,如果采用第一种方式,使用lighthouse查看性能指标,这个得依赖开发自身的积极性,他要是开发完就Merge上线,你也不知道具体指标怎么样。如果采用第二种方式,那么同样是发布到线上才能查看。最好的方式就是能强制要求开发在还没发布的时候使用lighthouse查看一下,那么在什么阶段做这个策略呢。聪明的同学可能想到,能不能在CICD构建阶段加上策略。其实是可以的,谷歌也想到了这个场景,提供性能守卫这个lighthouse ci插件


性能守卫



性能守卫是一种系统或工具,用于监控和管理应用程序或系统的性能。它旨在确保应用程序在各种负载和使用情况下能够提供稳定和良好的性能。



Lighthouse是一个开源的自动化工具,提供了四种使用方式:




  • Chrome DevTools




  • Chrome插件




  • Node CLI




  • Node模块




image.png


其架构实现图是这样的,有兴趣的同学可以深入了解一下


这里我们我们借助Lighthouse Node模块继承到CICD流程中,这样我们就能在构建阶段知道我们的页面具体性能,如果指标不合格,那么就不给合并MR


剖析lighthouse-ci实现


lighthouse-ci实现机制很简单,核心实现步骤如上图,差异就是lighthouse-ci实现了自己的server端,保持导出的性能指标数据,由于公司一般对这类数据敏感,所以我们一般只需要导出对应的数据指标JSON,上传到我们自己的平台就行了。


image.png


接下里,我们就来看看lighthouse-ci实现步骤:





    1. 启动浏览器实例:CLI通过Puppeteer启动一个Chrome实例。


    const browser = await puppeteer.launch();




    1. 创建新的浏览器标签页:接着,CLI创建一个新的标签页(或称为"页面")。


    const page = await browser.newPage();




    1. 导航到目标URL:CLI命令浏览器加载指定的URL。


    await page.goto('https://example.com');




    1. 收集数据:在加载页面的同时,CLI使用各种Chrome提供的API收集数据,包括网络请求数据、JavaScript执行时间、页面渲染时间等。





    1. 运行审计:数据收集完成后,CLI将这些数据传递给Lighthouse核心,该核心运行一系列预定义的审计。





    1. 生成和返回报告:最后,审计结果被用来生成一个JSON或HTML格式的报告。


    const report = await lighthouse(url, opts, config).then(results => {
    return results.report;
    });




    1. 关闭浏览器实例:报告生成后,CLI关闭Chrome实例。


    await browser.close();



// 伪代码
const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const {URL} = require('url');

async function run() {
// 使用 puppeteer 连接到 Chrome 浏览器
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});

// 新建一个页面
const page = await browser.newPage();

// 在这里,你可以执行任何Puppeteer代码,例如:
// await page.goto('https://example.com');
// await page.click('button');

const url = 'https://example.com';

// 使用 Lighthouse 进行审查
const {lhr} = await lighthouse(url, {
port: new URL(browser.wsEndpoint()).port,
output: 'json',
logLevel: 'info',
});

console.log(`Lighthouse score: ${lhr.categories.performance.score * 100}`);

await browser.close();
}

run();

导出的HTML文件


image.png


导出的JSON数据


image.png


实现一个性能守卫插件


在实现一个性能守卫插件,我们需要考虑以下因数:





    1. 易用性和灵活性:插件应该易于配置和使用,以便它可以适应各种不同的CI/CD环境和应用场景。它也应该能够适应各种不同的性能指标和阈值。





    1. 稳定性和可靠性:插件需要可靠和稳定,因为它将影响整个构建流程。任何失败或错误都可能导致构建失败,所以需要有强大的错误处理和恢复能力。





    1. 性能:插件本身的性能也很重要,因为它将直接影响构建的速度和效率。它应该尽可能地快速和高效。





    1. 可维护性和扩展性:插件应该设计得易于维护和扩展,以便随着应用和需求的变化进行适当的修改和更新。





    1. 报告和通知:插件应该能够提供清晰和有用的报告,以便开发人员可以快速理解和处理任何性能问题。它也应该有一个通知系统,当性能指标低于预定阈值时,能够通知相关人员。





    1. 集成:插件应该能够轻松集成到现有的CI/CD流程中,同时还应该支持各种流行的CI/CD工具和平台。





    1. 安全性:如果插件需要访问或处理敏感数据,如用户凭证,那么必须考虑安全性。应使用最佳的安全实践来保护数据,如使用环境变量来存储敏感数据。




image.png


// 伪代码
//perfci插件
const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const { port } = new URL(browser.wsEndpoint());

async function runAudit(url) {
const browser = await puppeteer.launch();
const { lhr } = await lighthouse(url, {
port,
output: 'json',
logLevel: 'info',
});
await browser.close();

// 在这里定义你的性能预期
const performanceScore = lhr.categories.performance.score;
if (performanceScore < 0.9) { // 如果性能得分低于0.9,脚本将抛出错误
throw new Error(`Performance score of ${performanceScore} is below the threshold of 0.9`);
}
}

runAudit('https://example.com').catch(console.error);


使用


name: CI
on: [push]
jobs:
lighthouseci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
- run: npm install && npm install -g @lhci/cli@0.11.x
- run: npm run build
- run: perfci autorun


性能审计


const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');
const nodemailer = require('nodemailer');

// 配置邮件发送器
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: 'your-email@gmail.com',
pass: 'your-password',
},
});

// 定义一个函数用于执行Lighthouse审计并处理结果
async function runAudit(url) {
// 通过Puppeteer启动Chrome
const browser = await puppeteer.launch({ headless: true });
const { port } = new URL(browser.wsEndpoint());

// 使用Lighthouse进行性能审计
const { lhr } = await lighthouse(url, { port });

// 检查性能得分是否低于阈值
if (lhr.categories.performance.score < 0.9) {
// 如果性能低于阈值,发送警告邮件
let mailOptions = {
from: 'your-email@gmail.com',
to: 'admin@example.com',
subject: '网站性能低于阈值',
text: `Lighthouse得分:${lhr.categories.performance.score}`,
};

transporter.sendMail(mailOptions, function(error, info){
if (error) {
console.log(error);
} else {
console.log('Email sent: ' + info.response);
}
});
}

await browser.close();
}

// 使用函数
runAudit('https://example.com');


接下来,我们分步骤大概介绍下几个核心实现


数据告警


// 伪代码
const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');
const nodemailer = require('nodemailer');

// 配置邮件发送器
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: 'your-email@gmail.com',
pass: 'your-password',
},
});

// 定义一个函数用于执行Lighthouse审计并处理结果
async function runAudit(url) {
// 通过Puppeteer启动Chrome
const browser = await puppeteer.launch({ headless: true });
const { port } = new URL(browser.wsEndpoint());

// 使用Lighthouse进行性能审计
const { lhr } = await lighthouse(url, { port });

// 检查性能得分是否低于阈值
if (lhr.categories.performance.score < 0.9) {
// 如果性能低于阈值,发送警告邮件
let mailOptions = {
from: 'your-email@gmail.com',
to: 'admin@example.com',
subject: '网站性能低于阈值',
text: `Lighthouse得分:${lhr.categories.performance.score}`,
};

transporter.sendMail(mailOptions, function(error, info){
if (error) {
console.log(error);
} else {
console.log('Email sent: ' + info.response);
}
});
}

await browser.close();
}

// 使用函数
runAudit('https://example.com');

处理设备、网络等不稳定情况


// 伪代码

// 网络抖动
const { lhr } = await lighthouse(url, {
port,
emulatedFormFactor: 'desktop',
throttling: {
rttMs: 150,
throughputKbps: 1638.4,
cpuSlowdownMultiplier: 4,
requestLatencyMs: 0,
downloadThroughputKbps: 0,
uploadThroughputKbps: 0,
},
});


// 设备
const { lhr } = await lighthouse(url, {
port,
emulatedFormFactor: 'desktop', // 这里可以设定为 'mobile' 或 'desktop'
});


用户登录态问题



也可以让后端同学专门提供一条内网访问的登录态接口环境,仅用于测试环境



const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const fs = require('fs');
const axios = require('axios');
const { promisify } = require('util');
const { port } = new URL(browser.wsEndpoint());

// promisify fs.writeFile for easier use
const writeFile = promisify(fs.writeFile);

async function runAudit(url, options = { port }) {
// 使用Puppeteer启动Chrome
const browser = await puppeteer.launch();
const page = await browser.newPage();

// 访问登录页面
await page.goto('https://example.com/login');

// 输入用户名和密码
await page.type('#username', 'example_username');
await page.type('#password', 'example_password');

// 提交登录表单
await Promise.all([
page.waitForNavigation(), // 等待页面跳转
page.click('#login-button'), // 点击登录按钮
]);

// 运行Lighthouse
const { lhr } = await lighthouse(url, options);

// 保存审计结果到JSON文件
const resultJson = JSON.stringify(lhr);
await writeFile('lighthouse.json', resultJson);

// 上传JSON文件到服务器
const formData = new FormData();
formData.append('file', fs.createReadStream('lighthouse.json'));

// 上传文件到你的服务器
const res = await axios.post('https://your-server.com/upload', formData, {
headers: formData.getHeaders()
});

console.log('File uploaded successfully');

await browser.close();
}

// 运行函数
runAudit('https://example.com');

总结


性能插件插件还有很多需要考虑的情况,所以,不懂还是来私信问我吧,我同事要请

作者:linwu
来源:juejin.cn/post/7253331974051823675
我吃饭去了,不写了。

收起阅读 »

村镇级别geojson获取方法

web
前言 公司需要开发某个村镇的网格地图,个大搜索引擎找了地图板块都只能到村镇级的,在高德地图上搜索出来只是一个标记并没有详细的网格分布,并使用BigMap等工具尝试也只能到村镇不能到具体下面的网格。下文将介绍一种思路用于获取村镇的geojson。 准备工作 ...
继续阅读 »

前言


公司需要开发某个村镇的网格地图,个大搜索引擎找了地图板块都只能到村镇级的,在高德地图上搜索出来只是一个标记并没有详细的网格分布,并使用BigMap等工具尝试也只能到村镇不能到具体下面的网格。下文将介绍一种思路用于获取村镇的geojson。
1.png


准备工作



  • 需要转换村镇的png/svg图

  • Vector Magin用于将png转换为svg工具

  • Svg2geojson工具,git地址:Svg2geojson

  • geojson添加属性工具:geojson.io(需要T子)

  • geojson压缩工具:mapshaper(需要T子)


整体思路


2.jpeg


PNG转SVG


导入png图片


3.png


配置Vector Magic参数


我这里是一直点击下一步直到出现转换界面,这里也可基于自己的图片配置参数。出现下面界面表示已经转换完成,这里选择Edit Result能够对转换完成的svg进行编辑
image.png


Vector Magic操作



  • Pan(A)移动画布

  • Zap(D)删除某块区域

  • Fill(F)对某块区域进行填充颜色

  • Pencil(X)使用画笔进行绘制

  • Color(C)吸取颜色


操作完成点击Update完成svg更新


保存为svg


image.png



如果有svg图片本步骤可以省略,另外如果是UI出的svg图片注意边与边不能重合,不能到时候只能识别为一块区域



SVG转换为GeoJson


安装工具


npm install svg2geojson


获取村镇经纬度边界


使用BigMap选择对应的村镇,获取边缘四个点的经纬度并记录
uTools_1689736099805.png


编辑svg加入边界经纬度


<MetaInfo xmlns="http://www.prognoz.ru">
<Geo>
<GeoItem
X="0" Y="0"
Latitude="最右边的lat" Longitude="最上边的lng"
/>
<GeoItem
X="1445" Y="1047"
Latitude="最左边的lat" Longitude="最下边的lng"
/>
</Geo>
</MetaInfo>

最终的svg文件如下
image.png


转换svg


svg2geojson canggou.svg


使用geojson.io添加对应的属性


image.png
右边粘贴转换出来的geojson,点击对应的区域即可添加属性


注意事项⚠️



  1. 转换出来的geojson可能复制到geojson.io不能使用,可以先放到mapshaper里面然后导出geojson再使用geojson.io使用。

  2. 部分区域粘连问题(本来是多个区域,编辑时却是同一个区域),需要使用Vector Magin重新编辑下生成出来的svg,注意边界。


最终效果


PS:具体使用geojson需要自己百度下,下面是最终呈现的效果,地图有点丑请忽略还未来得及优化
image.png

收起阅读 »

我终于成功登上了JS 框架榜单,并且仅落后于 React 4 名!

web
前言 如期而至,我独立开发的 JavaScript 框架 Strve.js 迎来了一个大版本5.6.2。此次版本距离上次大版本发布已经接近半年之多,为什么这么长时间没有发布新的大版本呢?主要是研究 Strve.js 如何支持单文件组件,使代码智能提示、代码格式...
继续阅读 »

前言


如期而至,我独立开发的 JavaScript 框架 Strve.js 迎来了一个大版本5.6.2。此次版本距离上次大版本发布已经接近半年之多,为什么这么长时间没有发布新的大版本呢?主要是研究 Strve.js 如何支持单文件组件,使代码智能提示、代码格式化方面更加友好。之前也发布了 Strve SFC,但是由于其语法规则的繁琐以及是在运行时编译的种种原因,我果断放弃了这个方案的继续研究。而这次的版本5.6.2成功解决了代码智能提示、代码格式化方面友好的问题,另外还增加了很多锦上添花的特性,这些都归功于我们这次版本成功支持JSX语法。熟悉React的朋友知道,JSX语法非常灵活。 而 Strve.js 一大特性也就是灵活操作代码块,这里的代码块我们可以理解成函数,而JSX语法在一定场景下也恰恰满足了我们这种需求。


那么,我们如何在 Strve 项目中使用JSX语法呢?我们在Strve项目构建工具 CreateStrveApp 预置了模版,你可以选择 strve-jsx 或者 strve-jsx-apps 模版即可。我们使用 CreateStrveApp 搭建完 Strve 项目会发现,同时安装了babelPluginStrvebabelPluginJsxToStrve,这是因为我们需要使用 babelPluginJsxToStrve 将 JSX 转换为标签模版,之后再使用babelPluginStrve 将标签模版转换为 Virtual DOM,进而实现差异化更新视图。


尝试


我既然发布出了一个大版本,并且个人还算比较满意。那么下一步我如何推广它呢?毕竟毛遂自荐有时候还是非常有意义的。所以,我打算通过js-framework-benchmark 这个项目评估下性能。


js-framework-benchmark 是什么?我们这里就简单介绍下 js-framework-benchmark,它是一个用于比较 JavaScript 框架性能的项目。它旨在通过执行一系列基准测试来评估不同框架在各种场景下的性能表现。这些基准测试包括渲染大量数据、更新数据、处理复杂的 UI 组件等。通过运行这些基准测试,可以比较不同框架在各种方面的性能优劣,并帮助开发人员选择最适合其需求的框架。js-framework-benchmark 项目提供了一个包含多个流行 JavaScript 框架的基准测试套件。这些框架包括 Angular、React、Vue.js、Ember.js 等。每个框架都会在相同的测试场景下运行,然后记录下执行时间和内存使用情况等性能指标。通过比较这些指标,可以得出不同框架的性能差异。这个项目的目标是帮助开发人员了解不同 JavaScript 框架的性能特点,以便在选择框架时能够做出更加明智的决策。同时,它也可以促进框架开发者之间的竞争,推动框架的不断改进和优化。


那么,我们就抱着试试的心态去运行下这个项目。


测试


我们进入js-framework-benchmark Github主页,然后 clone 下这个项目。


git clone https://github.com/krausest/js-framework-benchmark.git

然后,我们 clone 到本地之后,打开 README.md 文件找到如何评估框架。大体浏览之后,我们得出的结论是:通过使用自己的框架完成js-framework-benchmark规定的练习项目。


01.png


那么,我们就照着其他框架已经开发完成的示例进行开发吧!在开发之前,我们必须要了解js-framework-benchmark 中有两种模式。一种是keyed,另一种是non-keyed。在 js-framework-benchmark 中,"keyed" 模式是指通过给数据项分配一个唯一标识符作为 "key" 属性,从而实现数据项与 DOM 节点之间的一对一关系。当数据发生变化时,与之相关联的 DOM 节点也会相应更新。而 "non-keyed" 模式是指当数据项发生变化时,可能会修改之前与其他数据项关联的 DOM 节点。因为 Strve 暂时没有类似唯一标识符这种特性,所以我们选择non-keyed模式。


我们打开项目下/frameworks/non-keyed文件夹,找一个案例框架看一下它们开发的项目,我们选择 Vue 吧!
我们根据它开发的样例迁移到自己的框架中去。为了测试新版本,我们将使用JSX语法进行开发。


import { createApp, setData } from "strve-js";
import { buildData } from "./data.js";

let selected = undefined;
let rows = [];

function setRows(update = rows.slice()) {
setData(
() => {
rows = update;
},
{
name: TbodyComponent,
}
);
}

function add() {
const data = rows.concat(buildData(1000));
setData(
() => {
rows = data;
},
{
name: TbodyComponent,
}
);
}

function remove(id) {
rows.splice(
rows.findIndex((d) => d.id === id),
1
);
setRows();
}

function select(id) {
setData(
() => {
selected = +id;
},
{
name: TbodyComponent,
}
);
}

function run() {
setRows(buildData());
selected = undefined;
}

function update() {
for (let i = 0; i < rows.length; i += 10) {
rows[i].label += " !!!";
}
setRows();
}

function runLots() {
setRows(buildData(10000));
selected = undefined;
}

function clear() {
setRows([]);
selected = undefined;
}

function swapRows() {
if (rows.length > 998) {
const d1 = rows[1];
const d998 = rows[998];
rows[1] = d998;
rows[998] = d1;
setRows();
}
}

function TbodyComponent() {
return (
<tbody $key>
{rows.map((item) => (
<tr
class={item.id === selected ? "danger" : ""}
data-label={item.label}
$key
>

<td class="col-md-1" $key>
{item.id}
</td>
<td class="col-md-4">
<a onClick={() => select(item.id)} $key>
{item.label}
</a>
</td>
<td class="col-md-1">
<a onClick={() => remove(item.id)} $key>
<span
class="glyphicon glyphicon-remove"
aria-hidden="true"
>
</span>
</a>
</td>
<td class="col-md-6"></td>
</tr>
))}
</tbody>

);
}

function MainBody() {
return (
<>
<div class="jumbotron">
<div class="row">
<div class="col-md-6">
<h1>Strve-non-keyed</h1>
</div>
<div class="col-md-6">
<div class="row">
<div class="col-sm-6 smallpad">
<button
type="button"
class="btn btn-primary btn-block"
id="run"
onClick={run}
>

Create 1,000 rows
</button>
</div>
<div class="col-sm-6 smallpad">
<button
type="button"
class="btn btn-primary btn-block"
id="runlots"
onClick={runLots}
>

Create 10,000 rows
</button>
</div>
<div class="col-sm-6 smallpad">
<button
type="button"
class="btn btn-primary btn-block"
id="add"
onClick={add}
>

Append 1,000 rows
</button>
</div>
<div class="col-sm-6 smallpad">
<button
type="button"
class="btn btn-primary btn-block"
id="update"
onClick={update}
>

Update every 10th row
</button>
</div>
<div class="col-sm-6 smallpad">
<button
type="button"
class="btn btn-primary btn-block"
id="clear"
onClick={clear}
>

Clear
</button>
</div>
<div class="col-sm-6 smallpad">
<button
type="button"
class="btn btn-primary btn-block"
id="swaprows"
onClick={swapRows}
>

Swap Rows
</button>
</div>
</div>
</div>
</div>
</div>
<table class="table table-hover table-striped test-data">
<component $name={TbodyComponent.name}>{TbodyComponent()}</component>
</table>
<span
class="preloadicon glyphicon glyphicon-remove"
aria-hidden="true"
>
</span>
</>

);
}

createApp(() => MainBody()).mount("#main");


其实,我们虽然使用了JSX语法,但是你会发现有很多特性并不与JSX语法真正相同,比如我们可以直接使用 class 去表示样式类名属性,而不能使用 className 表示。


评估案例项目开发完成了,我们下一步就要测试一下项目是否符合评估标准。


npm run bench non-keyed/strve

02.gif


测试标准包括:




  • create rows:创建行,页面加载后创建 1000 行的持续时间(无预热)




  • replace all rows:替换所有行,替换表中所有 1000 行所需的时间(5 次预热循环)。该指标最大的价值就是了解当页面上的大部分内容发生变化时库的执行方式。




  • partial update:部分更新,对于具有 10000 行的表,每 10 行更新一次文本(进行 5 次预热循环)。该指标是动画性能和深层嵌套数据结构开销等方面的最佳指标。




  • select row:选择行,在单击行时高亮显示该行所需的时间(进行 5 次预热循环)。




  • swap rows:交换行,在包含 1000 行的表中交换 2 行的时间(进行 5 次预热迭代)。




  • remove row:删除行,在包含 1,000 行的表格上移除一行所需的时间(有 5 次预热迭代),该指标可能变化最少,因为它比库的任何开销更多地测试浏览器布局变化(因为所有行向上移动)。




  • create many rows:创建多行,创建 10000 行所需的时间(没有预热),该指标更容易受到内存开销的影响,并且对于效率较低的库来说,扩展性会更差。




  • append rows to large table:追加行到大型表格,在包含 10000 行的表格上添加 1000 行所需的时间(没有预热)。




  • clear rows:清空行,清空包含 10000 行的表格所需的时间(没有预热),该指标说明了库清理代码的成本,内存使用对这个指标的影响很大,因为浏览器需要更多的 GC。




最终,Strve 顶住了压力,通过了测试。


03.gif


看到了successful run之后,觉得特别开心!那种成就感是任何事物都难以代替的。


跑分


我们既然通过了测试,那么下一步我们将与前端两大框架Vue、React进行比较跑分,我们先在我自己本地环境上跑一下,看一下效果。


性能测试基准分为三类:



  • 持续时间

  • 启动指标

  • 内存分配


持续时间


04.png


启动指标


05.png


内存分配


06.png


总体而言,我感觉还不错,毕竟跟两个大哥在比较。到这里我还是觉得不够,跟其他框架比比呢!


提交


只要框架通过了测试,并且按照提交PR的规定提交,是可以被选录到 js-framework-benchmark 中去的。


好,那我们就去试试!


07.png


又一个比较有成就感的事!提交的PR被作者合并了!


成绩单


我迫不及待的去榜单上看下我的排名,会不会垫底啊!


因为浏览器版本发布的时差问题,暂时 Official results ( 官方结果 ) 还没有发布最新结果,我们可以先来 Snapshot of the results ( 快照结果 ) 中查看。


我们打开下方网址就可以看到JS框架的最新榜单了。


https://krausest.github.io/js-framework-benchmark/current.html

我们在持续时间这个类别下从后往前找,目前63个框架我居然排名 50 名,并且大名鼎鼎的 React 排名45名。


08.png


我们先不激动,我们再看下启动指标类别。Strve 平均分数是1.04,我看了看好几个框架分数是1.04。Strve 可以排到前8名。


09.png


我们再稳一下,继续看内存分配这个类别。Strve 平均分数是1.40,Strve 可以排到前12名。


10.png


意义


js-framework-benchmark 的测试结果是相对准确的,因为它是针对同样的测试样本和基准测试情境进行比较,可以提供框架之间的相对性能比较。然而,需要注意的是,这个测试结果也只是反映了测试条件下的性能表现。框架实际的性能可能还会受到很多方面的影响。
此外,js-framework-benchmark 测试结果也不应该成为选择框架的唯一指标。在选择框架时,还需要考虑框架的生态、开发效率、易用性等多方面因素,而不仅仅是性能表现。


虽然,Strve 跟 React 比较是有点招黑,但是不妨这样想,榜样的力量是巨大的!只有站在巨人的肩膀上才能望得更远!


Strve 要走的路还有很长,入选JS框架榜单使我更加明确了方向。我觉得做自己喜欢做得事情,这样才会有意义!


加油


Strve 要继续维护下去,我也会不断学习,继续精进。



Strve 源码仓库:github.com/maomincodin…




Strve 中文文档:maomincoding.gitee.io/strve-doc-z…



谢谢大家的阅读!如果大家觉得Strve不错,麻烦帮我点下Star吧!


作者:前端历劫之路
来源:juejin.cn/post/7256250499280158776
收起阅读 »

web端实现远程桌面控制

web
阐述 应着标题所说,web端实现远程桌面控制,方案有好几种,达到的效果就是类似于向日葵一样可以远程桌面,但是操作方可以不用安装客户端,只需要一个web浏览器即可实现,桌面端需写一个程序用来socket连接和执行Windows指令。 实现方案 使用webSock...
继续阅读 »

阐述


应着标题所说,web端实现远程桌面控制,方案有好几种,达到的效果就是类似于向日葵一样可以远程桌面,但是操作方可以不用安装客户端,只需要一个web浏览器即可实现,桌面端需写一个程序用来socket连接和执行Windows指令。


实现方案


使用webSocket实现web端和桌面端的实时TCP通讯和连接,连接后桌面端获取自己的桌面流以照片流的形式截图发送blob格式给web端,web端再接收后将此格式解析再赋值在img标签上,不停的接收覆盖,类似于快照的形式来呈现画面,再通过api获取web端的鼠标事件和键盘事件通过webSocket发送给客户端让他执行Windows事件,以此来达到远程控制桌面控制效果。


Demo实现


因为为了方便同事观看,所以得起一个框架服务,我习惯用vue3了,但大部分都是js代码,可参考改写。


html只需一行搞定。


<div>
<img ref="imageRef" class="remote" src="" alt="">
</div>

接下来就是socket连接,用socket库和直接new webSocket都可以连接,我习惯用库了,因为多一个失败了可以自动连接的功能,少写一点代码🤣,库也是轻量级才几k大小。顺便把socket心跳也加上,这个和对端协商好就行了,一般都是ping/pong加个type值,发送时记得处理一下使用json格式发送,如果连接后60秒后没有互相发送消息客户端就会认为你是失联断开连接了,所以他就会强行踢掉你连接状态,所以心跳机制还是必不可少的。


import ReconnectingWebSocket from 'reconnecting-websocket'
const remoteControl = '192.168.1.175'
const scoketURL = `ws://${remoteControl}:10086/Echo`
const imageRef = ref()

onMounted(() => {
createdWebsocket()
})

const createdWebsocket = () => {
socket = new ReconnectingWebSocket(scoketURL)
socket.onopen = function () {
console.log('连接已建立')
resetHeart()
}
socket.onmessage = function (event) {
// console.log(event.data)
const objectDta = JSON.parse(event.data)
console.log(objectDta)
if (objectDta.type === 100) {
resetHeart()
}
}
socket.onclose = function () {
console.log('断开连接')
}
}

let heartTime = null // 心跳定时器实例
let socketHeart = 0 // 心跳次数
let HeartTimeOut = 20000 // 心跳超时时间
let socketError = 0 // 错误次数
// socket 重置心跳
const resetHeart = () => {
socketHeart = 0
socketError = 0
clearInterval(heartTime)
sendSocketHeart()
}
const sendSocketHeart = () => {
heartTime = setInterval(() => {
if (socketHeart <= 3) {
console.log('心跳发送:', socketHeart)
socket.send(
JSON.stringify({
type: 100,
key: 'ping'
})
)
socketHeart = socketHeart + 1
} else {
reconnect()
}
}, HeartTimeOut)
}
// socket重连
const reconnect = () => {
socket.close()
if (socketError <= 3) {
clearInterval(heartTime)
socketError = socketError + 1
console.log('socket重连', socketError)
} else {
console.log('重试次数已用完的逻辑', socketError)
clearInterval(heartTime)
}
}

成功稳定连接后那么恭喜你完成第一步了,接下来就是获取对端发来的照片流了,使用socket.onmessageapi用来接收对端消息,需要转一下json,因为发送的数据照片流很快,控制台直接刷屏了,所以简单处理一下。收到照片流把blob格式处理一下再使用window.URL.createObjectURL(blob)赋值给img即可。


socket.onmessage = function (event) {
// console.log(event.data)
if (event.data instanceof Blob) { // 处理桌面流
const data = event.data
const blob = new Blob([data], { type: "image/jpg" })
imageRef.value.src = window.URL.createObjectURL(blob)
} else {
const objectDta = JSON.parse(event.data)
console.log(objectDta)
if (objectDta.type === 100) {
resetHeart()
}
}
}

此时页面可以呈现画面了,并且是可以看得到对面操作的,但让人挠头的是,分辨率和尺寸不对,有上下和左右的滚动条显示,并不是百分百的,解决这个问题倒是不难,但如需考虑获取自身的鼠标坐标发送给对端,这个坐标必须准确无误,简单来说就是分辨率自适应,因为web端使用的电脑屏幕大小是不一样的,切桌面端发送给你的桌面流比如是全屏分辨率的,以此得做适配,这个放后面解决,先来处理鼠标和键盘事件,纪录下来并发送对应的事件给桌面端。记得去除浏览器的拖动和鼠标右键事件,以免效果紊乱。


const watchControl = () => { // 监听事件
window.ondragstart = function (e) { // 移除拖动事件
e.preventDefault()
}
window.ondragend = function (e) { // 移除拖动事件
e.preventDefault()
}
window.onkeydown = function (e) { // 键盘按下
console.log('键盘按下', e)
socket.send(JSON.stringify({ type: 0, key: e.keyCode }))
}
window.onkeyup = function (e) { // 键盘抬起
console.log('键盘抬起', e)
socket.send(JSON.stringify({ type: 1, key: e.keyCode }))
}
window.onmousedown = function (e) { // 鼠标单击按下
console.log('单击按下', e)
console.log(newPageX, 'newPageX')
console.log(newPageY, 'newPageY')
socket.send(JSON.stringify({ type: 5, x: pageX, y: pageY }))
}
window.onmouseup = function (e) { // 鼠标单击抬起
console.log('单击抬起', e)
socket.send(JSON.stringify({ type: 6, x: pageX, y: pageY }))
}
window.oncontextmenu = function (e) { // 鼠标右击
console.log('右击', e)
e.preventDefault()
socket.send(JSON.stringify({ type: 4, x: pageX, y: pageY }))
}
window.ondblclick = function (e) { // 鼠标双击
console.log('双击', e)
}
window.onmousewheel = function (e) { // 鼠标滚动
console.log('滚动', e)
const moving = e.deltaY / e.wheelDeltaY
socket.send(JSON.stringify({ type: 7, x: e.x, y: e.y, deltaY: e.deltaY, deltaFactor: moving }))
}
window.onmousemove = function (e) { // 鼠标移动
if (!timer) {
timer = setTimeout(function () {
console.log("鼠标移动:X轴位置" + e.pageX + ";Y轴位置:" + e.pageY)
socket.send(JSON.stringify({ type: 2, x: pageX, y: pageY }))
timer = null
}, 60)
}
}
}

现在就可以实现远程控制了,发送的事件类型根据桌面端服务需要什么参数协商好就成,接下来就是处理分辨率适配问题了,解决办法大致就是赋值img图片后拿到他的参数分辨率,然后获取自身浏览器的宽高,除以他的分辨率再乘上自身获取的鼠标坐标就OK了,获取img图片事件需要延迟一下,因为是后面socket连接后才赋值的图片,否则宽高就一直是0,加在watchControl事件里面,发送时坐标也要重新计算。


const watchControl = () => {
console.dir(imageRef.value)
imgWidth.value = imageRef.value.naturalWidth === 0 ? 1920 : imageRef.value.naturalWidth// 图片宽度
imgHeight.value = imageRef.value.naturalHeight === 0 ? 1080 : imageRef.value.naturalHeight // 图片高度
clientHeight = document.body.offsetHeight

......

window.onmousedown = function (e) { // 鼠标单击按下
console.log('单击按下', e)
const newPageX = parseInt(e.pageX * (imgWidth.value / clientWidth)) // 计算分辨率
const newPageY = parseInt(e.pageY * (imgHeight.value / clientHeight))
console.log(newPageX, 'newPageX')
console.log(newPageY, 'newPageY')
socket.send(JSON.stringify({ type: 5, x: newPageX, y: newPageY }))
}
}

现在就几乎大功告成了,坐标稳定发送,获取的也是正确计算出来的,下面再做一些socket加密优化,还有事件优化,集成到项目里面离开时还是要清除所有事件和socket连接,直接上完整全部代码。


<template>
<div>
<img ref="imageRef" class="remote" src="" alt="" />
</div>
</template>

<script setup>
import ReconnectingWebSocket from 'reconnecting-websocket'
import { Base64 } from 'js-base64'

onMounted(() => {
createdWebsocket()
})

const route = useRoute()
let socket = null
const secretKey = 'keyXXXXXXX'
const remoteControl = '192.168.1.xxx'
const scoketURL = `ws://${remoteControl}:10086/Echo?key=${Base64.encode(secretKey)}`
const imageRef = ref()
let timer = null
const clientWidth = document.documentElement.offsetWidth
let clientHeight = null
const widthCss = (window.innerWidth) + 'px'
const heightCss = (window.innerHeight) + 'px'
const imgWidth = ref() // 图片宽度
const imgHeight = ref() // 图片高度

const createdWebsocket = () => {
socket = new ReconnectingWebSocket(scoketURL)
socket.onopen = function () {
console.log('连接已建立')
resetHeart()
setTimeout(() => {
watchControl()
}, 500)
}
socket.onmessage = function (event) {
if (event.data instanceof Blob) { // 处理桌面流
const data = event.data
const blob = new Blob([data], { type: 'image/jpg' })
imageRef.value.src = window.URL.createObjectURL(blob)
} else {
const objectDta = JSON.parse(event.data)
console.log(objectDta)
if (objectDta.type === 100) {
resetHeart()
}
}
}
socket.onclose = function () {
console.log('断开连接')
}
}

const handleMousemove = (e) => { // 鼠标移动
if (!timer) {
timer = setTimeout(function () {
const newPageX = parseInt(e.pageX * (imgWidth.value / clientWidth)) // 计算分辨率
const newPageY = parseInt(e.pageY * (imgHeight.value / clientHeight))
// console.log(newPageX, 'newPageX')
// console.log(newPageY, 'newPageY')
// console.log('鼠标移动:X轴位置' + e.pageX + ';Y轴位置:' + e.pageY)
socket.send(JSON.stringify({ type: 2, x: newPageX, y: newPageY }))
timer = null
}, 60)
}
}
const handleKeydown = (e) => { // 键盘按下
console.log('键盘按下', e)
socket.send(JSON.stringify({ type: 0, key: e.keyCode }))
}
const handleMousedown = (e) => { // 鼠标单击按下
console.log('单击按下', e)
const newPageX = parseInt(e.pageX * (imgWidth.value / clientWidth)) // 计算分辨率
const newPageY = parseInt(e.pageY * (imgHeight.value / clientHeight))
console.log(newPageX, 'newPageX')
console.log(newPageY, 'newPageY')
socket.send(JSON.stringify({ type: 5, x: newPageX, y: newPageY }))
}
const handleKeyup = (e) => { // 键盘抬起
console.log('键盘抬起', e)
socket.send(JSON.stringify({ type: 1, key: e.keyCode }))
}

const handleMouseup = (e) => { // 鼠标单击抬起
console.log('单击抬起', e)
const newPageX = parseInt(e.pageX * (imgWidth.value / clientWidth)) // 计算分辨率
const newPageY = parseInt(e.pageY * (imgHeight.value / clientHeight))
console.log(newPageX, 'newPageX')
console.log(newPageY, 'newPageY')
socket.send(JSON.stringify({ type: 6, x: newPageX, y: newPageY }))
}

const handleContextmenu = (e) => { // 鼠标右击
console.log('右击', e)
e.preventDefault()
const newPageX = parseInt(e.pageX * (imgWidth.value / clientWidth)) // 计算分辨率
const newPageY = parseInt(e.pageY * (imgHeight.value / clientHeight))
console.log(newPageX, 'newPageX')
console.log(newPageY, 'newPageY')
socket.send(JSON.stringify({ type: 4, x: newPageX, y: newPageY }))
}

const handleDblclick = (e) => { // 鼠标双击
console.log('双击', e)
}

const handleMousewheel = (e) => { // 鼠标滚动
console.log('滚动', e)
const moving = e.deltaY / e.wheelDeltaY
socket.send(JSON.stringify({ type: 7, x: e.x, y: e.y, deltaY: e.deltaY, deltaFactor: moving }))
}

const watchControl = () => { // 监听事件
console.dir(imageRef.value)
imgWidth.value = imageRef.value.naturalWidth === 0 ? 1920 : imageRef.value.naturalWidth// 图片宽度
imgHeight.value = imageRef.value.naturalHeight === 0 ? 1080 : imageRef.value.naturalHeight // 图片高度
clientHeight = document.body.offsetHeight

window.ondragstart = function (e) { // 移除拖动事件
e.preventDefault()
}
window.ondragend = function (e) { // 移除拖动事件
e.preventDefault()
}
window.addEventListener('mousemove', handleMousemove)
window.addEventListener('keydown', handleKeydown)
window.addEventListener('mousedown', handleMousedown)
window.addEventListener('keyup', handleKeyup)
window.addEventListener('mouseup', handleMouseup)
window.addEventListener('contextmenu', handleContextmenu)
window.addEventListener('dblclick', handleDblclick)
window.addEventListener('mousewheel', handleMousewheel)
}

let heartTime = null // 心跳定时器实例
let socketHeart = 0 // 心跳次数
const HeartTimeOut = 20000 // 心跳超时时间
let socketError = 0 // 错误次数
// socket 重置心跳
const resetHeart = () => {
socketHeart = 0
socketError = 0
clearInterval(heartTime)
sendSocketHeart()
}
const sendSocketHeart = () => {
heartTime = setInterval(() => {
if (socketHeart <= 3) {
console.log('心跳发送:', socketHeart)
socket.send(
JSON.stringify({
type: 100,
key: 'ping'
})
)
socketHeart = socketHeart + 1
} else {
reconnect()
}
}, HeartTimeOut)
}
// socket重连
const reconnect = () => {
socket.close()
if (socketError <= 3) {
clearInterval(heartTime)
socketError = socketError + 1
console.log('socket重连', socketError)
} else {
console.log('重试次数已用完的逻辑', socketError)
clearInterval(heartTime)
}
}

onBeforeUnmount(() => {
socket.close()
console.log('组件销毁')
window.removeEventListener('mousemove', handleMousemove)
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('mousedown', handleMousedown)
window.removeEventListener('keyup', handleKeyup)
window.removeEventListener('mouseup', handleMouseup)
window.removeEventListener('contextmenu', handleContextmenu)
window.removeEventListener('dblclick', handleDblclick)
window.removeEventListener('mousewheel', handleMousewheel)
})
</script>

<style scoped>
.remote {
width: v-bind(widthCss);
height: v-bind(heightCss);
}
</style>

现在就算是彻底大功告成了,加密密钥或者方式还是和对端协商,流畅度和清晰度也不错的,简单办公还是没问题的,和不开会员的向日葵效果差不多,后面的优化方式大致围绕着图片压缩来做应该能达到更加流畅的效果,如果项目是https的话socket服务也要升级成wss协议,大致就这样,若有不正确的地

作者:小胡不糊涂
来源:juejin.cn/post/7256970964533297211
方还请指正一番😁😁。

收起阅读 »

前端基建原来可以做这么多事情

web
前端基建是指在前端开发过程中,为提高开发效率、代码质量和团队协作而构建的一些基础设施和工具。下面是前端基建可以做的一些事情: 脚手架工具:开发和维护一个通用的脚手架工具,可以帮助团队快速初始化项目结构、配置构建工具、集成常用的开发依赖等。 组件库:开发...
继续阅读 »

guide-cover-2.2d36b370.jpg


前端基建是指在前端开发过程中,为提高开发效率、代码质量和团队协作而构建的一些基础设施和工具。下面是前端基建可以做的一些事情:




  1. 脚手架工具:开发和维护一个通用的脚手架工具,可以帮助团队快速初始化项目结构、配置构建工具、集成常用的开发依赖等。




  2. 组件库:开发和维护一个内部的组件库,包含常用的UI组件、业务组件等,提供给团队成员复用,减少重复开发的工作量。




  3. 构建工具和打包工具:搭建和维护一套完善的构建和打包工具链,包括使用Webpack、Parcel等工具进行代码的压缩、合并、打包等工具,优化前端资源加载和性能。




  4. 自动化测试工具:引入自动化测试工具,如Jest、Mocha等,编写和维护测试用例,进行单元测试、集成测试、UI测试等,提高代码质量和可靠性。




  5. 文档工具:使用工具如JSDoc、Swagger等,生成项目的API文档、接口文档等,方便团队成员查阅和维护。




  6. Git工作流:制定和规范团队的Git工作流程,使用版本控制工具管理代码,方便团队协作和代码回退。




  7. 性能监控和优化:引入性能监控工具,如Lighthouse、Web Vitals等,对项目进行性能分析,优化网页加载速度、响应时间等。




  8. 工程化规范:制定并推广团队的代码规范、目录结构规范等,提高代码的可读性、可维护性和可扩展性。




  9. 持续集成和部署:搭建持续集成和部署系统,如Jenkins、Travis CI等,实现代码的自动构建、测试和部署,提高开发效率和代码质量。




  10. 项目文档和知识库:建立一个内部的项目文档和知识库,记录项目的技术细节、开发经验、常见问题等,方便团队成员查阅和学习。




通过建立和维护前端基建,可以提高团队的协作效率,减少重复劳动,提高代码质量和项目的可维护性。


当涉及到前端基建时,还有一些其他的事情可以考虑:




  1. 代码质量工具:引入代码质量工具,如ESLint、Prettier等,对代码进行静态分析和格式化,提高代码的一致性和可读性。




  2. 国际化支持:为项目添加国际化支持,可以通过引入国际化库,如i18next、vue-i18n等,实现多语言的切换和管理。




  3. 错误监控和日志收集:引入错误监控工具,如Sentry、Bugsnag等,实时监控前端错误,并收集错误日志,方便进行问题排查和修复。




  4. 前端性能优化工具:使用工具如WebPageTest、Chrome DevTools等,对项目进行性能分析和优化,提高页面加载速度、响应时间等。




  5. 缓存管理:考虑合理利用浏览器缓存和服务端缓存,减少网络请求,提升用户访问速度和体验。




  6. 移动端适配:针对移动端设备,采用响应式设计或使用CSS媒体查询等技术,实现移动端适配,保证页面在不同尺寸的设备上有良好的显示效果。




  7. 安全防护:对项目进行安全审计,使用安全防护工具,如CSP(Content Security Policy)、XSS过滤等,保护网站免受常见的安全攻击。




  8. 性能优化指标监控:监控和分析关键的性能指标,如页面加载时间、首次渲染时间、交互响应时间等,以便及时发现和解决性能问题。




  9. 前端日志分析:使用日志分析工具,如ELK(Elasticsearch、Logstash、Kibana)等,对前端日志进行收集和分析,了解用户行为和页面异常情况。




  10. 跨平台开发:考虑使用跨平台开发框架,如React Native、Flutter等,实现一套代码在多个平台上复用,提高开发效率。




以上是一些可以考虑的前端基建事项,根据项目需求和团队情况,可以选择适合的工具和技术进行实施。同时,持续关注前端领域的最新技术和工具,不断优化和改进前端基建,以提高开发效率和项目质量。


当涉及到前端基建时,还有一些其他的事情可以考虑:




  1. 编辑器配置和插件:为团队提供统一的编辑器配置文件,包括代码格式化、语法高亮、代码自动补全等,并推荐常用的编辑器插件,提高开发效率。




  2. 文档生成工具:使用工具如Docusaurus、VuePress等,为项目生成漂亮的文档网站,方便团队成员查阅和维护项目文档。




  3. Mock数据和接口管理:搭建一个Mock服务器,用于模拟后端接口数据,方便前端开发和测试,同时可以考虑使用接口管理工具,如Swagger等,方便接口的定义和调试。




  4. 前端监控和统计:引入前端监控工具,如Google Analytics、百度统计等,收集用户访问数据和行为信息,用于分析和优化用户体验。




  5. 移动端调试工具:使用工具如Eruda、VConsole等,帮助在移动端设备上进行调试和错误排查,提高开发效率。




  6. 自动化部署:配置自动化部署流程,将项目的代码自动部署到服务器或云平台,减少人工操作,提高发布效率和稳定性。




  7. 前端团队协作工具:使用团队协作工具,如GitLab、Bitbucket等,提供代码托管、项目管理、任务分配和团队沟通等功能,增强团队协作效率。




  8. 前端培训和知识分享:组织定期的前端培训和技术分享会,让团队成员相互学习和交流,推动技术的共享和提升。




  9. 客户端性能优化:针对移动端应用,可以使用工具如React Native Performance、Weex等,进行客户端性能优化,提高应用的响应速度和流畅度。




  10. 技术选型和评估:定期评估和研究前端技术的发展趋势,选择适用的技术栈和框架,以保持项目的竞争力和可持续发展。




以上是一些可以考虑的前端基建事项,根据项目需求和团队情况,可以选择适合的工具和技术进行实施。同时,持续关注前端领域的最新技术和工具,不断优化和改进前端基建,以提高开发效率和项目质量。


当涉及到前端基建时,还有一些其他的事情可以考虑:




  1. 统一的状态管理:引入状态管理工具,如Redux、Vuex等,帮助团队管理前端应用的状态,提高代码的可维护性和可扩展性。




  2. 前端日志记录:引入前端日志记录工具,如log4javascript、logrocket等,记录前端应用的运行日志,方便排查和解决问题。




  3. 前端代码扫描:使用静态代码扫描工具,如SonarQube、CodeClimate等,对前端代码进行扫描和分析,发现潜在的问题和漏洞。




  4. 前端数据可视化:使用数据可视化工具,如ECharts、Chart.js等,将数据以图表或图形的形式展示,增强数据的可理解性和可视化效果。




  5. 前端容灾和故障处理:制定容灾方案和故障处理流程,对前端应用进行监控和预警,及时处理和恢复故障,提高系统的可靠性和稳定性。




  6. 前端安全加固:对前端应用进行安全加固,如防止XSS攻击、CSRF攻击、数据加密等,保护用户数据的安全性和隐私。




  7. 前端版本管理:建立前端代码的版本管理机制,使用工具如Git、SVN等,管理和追踪代码的变更,方便团队成员之间的协作和版本控制。




  8. 前端数据缓存:考虑使用Local Storage、Session Storage等技术,对一些频繁使用的数据进行缓存,提高应用的性能和用户体验。




  9. 前端代码分割:使用代码分割技术,如Webpack的动态导入(Dynamic Import),将代码按需加载,减少初始加载的资源大小,提高页面加载速度。




  10. 前端性能监测工具:使用性能监测工具,如WebPageTest、GTmetrix等,监测前端应用的性能指标,如页面加载时间、资源加载时间等,进行性能优化。




以上是一些可以考虑的前端基建事项,根据项目需求和团队情况,可以选择适合的工具和技术进行实施。同时,持续关注前端领域的最新技术和工具,不断优化和改进前端基建,以提高

作者:服部
来源:juejin.cn/post/7256879435339628604
开发效率和项目质量。

收起阅读 »

无虚拟 DOM 版 Vue 进行到哪一步了?

web
前言 就在一年前的 Vue Conf 2022,尤雨溪向大家分享了一个非常令人期待的新模式:无虚拟 DOM 模式! 我看了回放之后非常兴奋,感觉这是个非常牛逼的新 feature,于是赶紧写了篇文章: 《无虚拟 DOM 版 Vue 即将到来》 鉴于可能会有...
继续阅读 »

前言


就在一年前的 Vue Conf 2022,尤雨溪向大家分享了一个非常令人期待的新模式:无虚拟 DOM 模式!


我看了回放之后非常兴奋,感觉这是个非常牛逼的新 feature,于是赶紧写了篇文章:



《无虚拟 DOM 版 Vue 即将到来》



鉴于可能会有部分人还不知道或者还没听过什么是 Vue 无虚拟 DOM 模式,我们先来简单的介绍一下:Vue 无虚拟 DOM 编译模式在官方那边叫 Vue Vapor Mode,直译过来就是:Vue 蒸汽模式。


为什么叫蒸汽模式呢?个人瞎猜的哈:第一次工业革命开创了以机器代替手工劳动的时代,并且是以蒸汽机作为动力机被广泛使用为标志的。这跟 Vue1 有点像,Vue 赶上了前端界的第一次工业革命(以声明式代替命令式的时代),此时的 Vue 还没有虚拟 DOM,也就是 Vue 的蒸汽时代。


不过万万没想到的是历史居然是个轮回,当年火的不行的虚拟 DOM 如今早已日薄西山、跌落了神坛,现在无虚拟 DOM 居然又开始重返王座。当然重返王座这个说法也不是很准确,只能说开始演变成为了一种新的流行趋势吧!这无疑让尤大想起了那个蒸汽时代的 Vue1,于是就起名为 Vapor


当然也有这么一种可能:自从为自己的框架起名为 Vue 之后,尤大就特别钟意以 V 开头的单词,不信你看:



  • Vue

  • Vite

  • Vetur

  • Volar

  • Vapor


不过以上那些都是自己瞎猜的,人家到底是不是那么想的还有待商榷。可以等下次他再开直播时发弹幕问他,看他会怎么回答。


但是吧,都过了一年多了,这个令我特别期待的新特性一点信都没有,上网搜到的内容也都是捕风捉影,这甚至让我开始怀疑 Vapor Mode 是不是要难产了?不过好在一年后的今天,Vue Conf 2023 如期而至,在那里我终于看到了自己所期待的与 Vapor Mode 有关的一系列信息。


正文



他说第三第四季度会主要开发 Vapor Mode,我听了以后直呼好家伙!合着这一年的功夫一点关于 Vapor Mode 相关的功能都没开发,鸽了一年多啊!




[译]:这是一个全新的编译策略,还是相同的模板语法一点没变,但编译后的代码性能更高。利用 Template 标签克隆元素 + 更精准的绑定,并且没有虚拟 DOM




他说 Vapor 是一个比较大的工程,所以会分阶段开发,他目前处在第一阶段。第一阶段是运行时,毕竟以前的组件编译出来的是虚拟 DOM,而 Vapor 编译出的则是真实 DOM,这需要运行时的改变。他们基本已经实现了这一点,现在正在做一些性能测试,测试效果很不错,性能有大幅度的提升。



下一阶段则是编译器,也就是说他们现在虽然能写出一些 Vapor Mode 的代码来测试性能,但写的基本上都是编译后的代码,人肉编译无疑了。



第三阶段是集成,第四阶段是兼容特殊组件,接下来进行每个阶段的详细介绍。


第一阶段



他们先实现了 v-ifv-for 等核心指令的 runtime,看来以前的 v-ifv-for 代码不能复用啊,还得重新实现。然后他们用 Benchmark 做了一些基准测试,效果非常理想,更合理的内存利用率,性能有着明显的提升。还有与服务端渲染兼容的一些东西,他们还是比较重视 SSR 的。


第二阶段



他们希望能生成一种中间语言,因为现在用 JSX 的人越来越多了,我知道肯定有人会说我身边一个用 JSX 的都没有啊(指的是 Vue JSX,不是 React JSX)咱们暂且先不讨论这种身边统计法的准确性,咱就说 Vue 的那些知名组件库,大家可以去看看他们有多少都用了 JSX 来进行开发的。只能说是 JSX 目前相对于 SFC 而言用的比较少而已,但它的用户量其实已经很庞大了:



我知道肯定还会有人说:这个统计数据不准,别的包依赖了这个包,下载别的包的时候也会顺带着下载这个包。其实这个真的没必要杠,哪怕说把这个数据减少到一半那都是每周 50 万的下载量呢!就像是国内 185 的比例很小吧?但你能说国内 185 的人很少么?哪怕比例小,但由于总数大,一相乘也照样是个非常庞大的数字。


Vue 以前是通过 render 函数来进行组件的渲染的,而如今 Vapor Mode 已经没有 render 函数了,所以不能再手写 render 了,来看一个 Vue 官网的例子:



由于 Vapor Mode 不支持 render 函数,如果想要拥有同样的灵活性那就只有 JSX,所以他们希望 SFCJSX 能编译成同一种中间语言,然后再编译为真实 DOM


第三阶段



尤大希望 Vapor Mode 是个渐进式的功能而不是破坏性功能,所以他们要做的是让 Vapor Mode 的代码可以无缝嵌入到你现有的项目中而不必重构。不仅可以选择在组件级别嵌入,甚至还可以选择在项目的性能瓶颈部分嵌入 Vapor Mode。如果你开发的是一个新项目的话,你也可以让整个项目都是 Vapor Mode,这样的话就可以完全删除掉虚拟 DOM 运行时,打包出来的尺寸体积会更小。


最牛逼的是还可以反向操作,还可以在无虚拟 DOM 组件里运行虚拟 DOM 组件。比方说你开发了款无虚拟 DOM 应用,但你需要组件库,组件库是虚拟 DOM 写的,没关系,照样可以完美运行!


第四阶段



这一阶段要让 Vapor 支持一些特殊组件,包括:



  • <transition>

  • <keep-alive>

  • <teleport>

  • <suspense>


等这一阶段忙完,整个 Vapor Mode 就可以正式推出了。


源码解析


本想着带大家看看源码,但非常不幸的是目前没在 GitHubVue 仓库里发现任何有关 Vapor Mode 的分支,可能是还没传呢吧。关注我,我会实时紧跟 Vue Vapor 的动态,并会试图带着大家理解源码。其实我是希望他能早点把源码给放出来的,因为一个新功能或者一个新项目就是在最初始的阶段最好理解,源码也不会过于的复杂,后续随着功能的疯狂迭代慢慢的就不那么易于理解了。而且跟着最初的源码也可以很好的分析出他的后续思路,想要实现该功能后面要怎么做,等放出下一阶段源码时就能很好的延续这种思路,这对于我们学习高手思路而言非常有帮助。


而且我怀疑会有些狗面试官现在就开始拿跟这玩意有关的东西做面试题了,你别看这项功能还没正式推出,但有些狗官就是喜欢问这些,希望能把你问倒以此来压你价。


我们经常调侃说学不动了,尤雨溪还纳闷这功能不影响正常使用啊?有啥学习成本呢?如果他真的了解国情的话就会知道学不动的压根就

作者:手撕红黑树
来源:juejin.cn/post/7256983702810181688
不是写法,而是源码!

收起阅读 »

Vite 开发环境为何这么快?

web
本文只是笔者作为一个初学者,在学习中与看了诸多业界的优秀实践文章之后的思考和沉淀,如果你在看的过程中觉得有些不妥的地方,可以随时和我联系,一起探讨学习。 提到 Vite,第一个想到的字就是 快,到底快在哪里呢?为什么可以这么快? 本文从以下几个地方来讲 快...
继续阅读 »

本文只是笔者作为一个初学者,在学习中与看了诸多业界的优秀实践文章之后的思考和沉淀,如果你在看的过程中觉得有些不妥的地方,可以随时和我联系,一起探讨学习。



提到 Vite,第一个想到的字就是 ,到底快在哪里呢?为什么可以这么快?
本文从以下几个地方来讲



  • 快速的冷启动: No Bundle + esbuild 预构建

  • 模块热更新:利用浏览器缓存策略

  • 按需加载:利用浏览器 ESM 支持


Vite 本质上是一个本地资源服务器,还有一套构建指令组成。



  • 本地资源服务器,基于 ESM 提供很多内建功能,HMR 速度很快

  • 使用 Rollup 打包你的代码,预配件了优化的过配置,输出高度优化的静态资源


快递的冷启动


No-bundle


在冷启动开发者服务器时,基于 Webpack 这类 bundle based 打包工具,启动时必须要通过 依赖收集、模块解析、生成 chunk、生成模块依赖关系图,最后构建整个应用输出产物,才能提供服务。


这意味着不管代码实际是否用到,都是需要被扫描和解析。


image.png


而 Vite 的思路是,利用浏览器原生支持 ESM 的原理,让浏览器来负责打包程序的工作。而 Vite 只需要在浏览器请求源码时进行转换并按需提供源码即可。


这种方式就像我们编写 ES5 代码一样,不需要经过构建工具打包成产物再给浏览器解析,浏览器自己就能够解析。


image.png
与现有的打包构建工具 Webpack 等不同,Vite 的开发服务器启动过程仅包括加载配置和中间件,然后立即启动服务器,整个服务启动流程就此结束。


Vite 利用了现代浏览器支持的 ESM 特性,在开发阶段实现了 no-bundle 模式,不生成所有可能用到的产物,而是在遇到 import 语句时发起资源文件请求。


当 Vite 服务器接收到请求时,才对资源进行实时编译并将其转换为 ESM,然后返回给浏览器,从而实现按需加载项目资源。而现有的打包构建工具在启动服务器时需要进行项目代码扫描、依赖收集、模块解析、生成 chunk 等操作,最后才启动服务器并输出生成的打包产物。


正是因为 Vite 采用了 no-bundle 的开发模式,使用 Vite 的项目不会随着项目迭代变得庞大和复杂而导致启动速度变慢,始终能实现毫秒级的启动。


esbuild 预构建


当然这里的毫秒级是有前提的,需要是非首次构建,并且没有安装新的依赖,项目代码中也没有引入新的依赖。


这是因为 Vite 的 Dev 环境会进行预构建优化。
在第一次运行项目之后,直接启动服务,大大提高冷启动速度,只要没有依赖发生变化就会直接出发热更新,速度也能够达到毫秒级。


这里进行预构建主要是因为 Vite 是基于浏览器原生**支持 **ESM 的能力实现的,但要求用户的代码模块必须是ESM模块,因此必须将 commonJSUMD 规范的文件提前处理,转化成 ESM 模块并缓存入 node_modules/.vite


在转换 commonJS 依赖时,Vite 会进行智能导入分析,即使模块导出时动态分配的,具名导出也能正常工作。


// 符合预期
import React, { useState } from 'react'

另一方面是为了性能优化


为了提高后续页面加载的性能,Vite 将那些具有许多内部模块的 ESM 依赖转为单个模块。


比如我们常用的 lodash 工具库,里面有很多包通过单独的文件相互导入,而 lodash-es这种 ESM 包会有几百个子模块,当代码中出现 import { debounce } from 'lodash-es'发出几百个 HTTP 请求,这些请求会造成网络堵塞,影响页面的加载。


通过将 lodash-es 预构建成一个单独模块,只需要一个 HTTP 请求。


那么如果是首次构建呢?Vite 还能这么快吗?


在首次运行项目时,Vite 会对代码进行扫描,对使用到的依赖进行预构建,但是如果使用 rollup、webpack 进行构建同样会拖累项目构建速度,而 Vite 选择了 esbuild 进行构建。



btw,预构建只会在开发环境生效,并使用 esbuild 进行 esm 转换,在生产环境仍然会使用 rollup 进行打包。



生产环境使用 rollup 主要是为了更好的兼容性和 tree-shaking 以及代码压缩优化等,以减小代码包体积


为什么选择 esbuild?


esbuild 的构建速度非常快,比 Webpack 快非常多,esbuild 是用 Go 编写的,语言层面的压制,运行性能更好


image.png


核心原因就是 esbuild 足够快,可以在 esbuild 官网看到这个对比图,基本上是 上百倍的差距。


前端的打包工具大多数是基于 JavaScript 实现的,由于语言特性 JavaScript 边运行边解释,而 esbuild 使用 Go 语言开发,直接编译成机器语言,启动时直接运行即可。


更多关于 Go 和 JavaScript 的语言特性差异,可以检索一下。


不久前,字节开源了 Rspack 构建工具,它是基于 Rust 编写的,同样构建速度很快



  • Rust 编译生成的 Native Code 通常比 JavaScript 性能更为高效,也意味着 rspack 在打包和构建中会有更高的性能。

  • 同时 Rust 支持多线程,意味着可以充分利用多核 CPU 的性能进行编译。而 Webpack 受限于 JavaScript 对多线程支持较弱,导致很难进行并行计算。


不过,Rspack 的插件系统还不完善,同时由于插件支持 JS 和 rust 编写,如果采用 JS 编写估计会损失部分性能,而使用 rust 开发,对于开发者可能需要一定的上手成本


image.png


同时发现 Vite 4 已经开始增加对 SWC 的支持,这是一个基于 Rust 的打包器,可以替代 Babel,以获取更高的编译性能。


**Rust 会是 JavaScript 基建的未来吗?**推荐阅读:zhuanlan.zhihu.com/p/433300816


模块热更新


主要是通过 WebSocket 创建浏览器和服务器的通信监听文件的改变,当文件被修改时,服务端发送消息通知客户端修改相应的代码,客户端对应不同的文件进行不同的操作的更新。


WebpackVite 在热更新上有什么不同呢?


Webpack: 重新编译,请求变更后模块的代码,客户端重新加载


Vite 通过监听文件系统的变更,只对发生变更的模块重新加载,只需要让相关模块的 boundary 失效即可,这样 HMR 更新速度不会因为应用体积增加而变慢,但 Webpack 需要经历一次打包构建流程,所以 HMR Vite 表现会好于 Webpack


核心流程


Vite 热更新流程可以分为以下:



  1. 创建一个 websocket 服务端和client文件,启动服务

  2. 监听文件变更

  3. 当代码变更后,服务端进行判断并推送到客户端

  4. 客户端根据推送的信息执行不同操作的更新


image.png


创建 WebSocket 服务


在 dev server 启动之前,Vite 会创建websocket服务,利用chokidar创建一个监听对象 watcher 用于对文件修改进行监听等等,具体核心代码在 node/server/index 下


image.png


createWebSocketServer 就是创建 websocket 服务,并封装内置的 close、on、send 等方法,用于服务端推送信息和关闭服务



源码地址:packages/vite/src/node/server/ws.ts



image.png


执行热更新


当接受到文件变更时,会执行 change 回调


watcher.on('change', async (file) => {
file = normalizePath(file)
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)

await onHMRUpdate(file, false)
})

当文件发生更改时,这个回调函数会被触发。file 参数表示发生更改的文件路径。


首先会通过 normalizePath 将文件路径标准化,确保文件路径在不同操作系统和环境中保持一致。


然后会触发 moduleGraph 实例上的 onFailChange 方法,用来清空被修改文件对应的 ModuleNode 对象的 transformResult 属性,**使之前的模块已有的转换缓存失效。**这块在下一部分会讲到。



  • ModuleNode 是 Vite 最小模块单元

  • moduleGraph 是整个应用的模块依赖关系图



源码地址:packages/vite/src/node/server/moduleGraph.ts



onFileChange(file: string): void {
const mods = this.getModulesByFile(file)
if (mods) {
const seen = new Set<ModuleNode>()
mods.forEach((mod) => {
this.invalidateModule(mod, seen)
})
}
}

invalidateModule(
mod: ModuleNode,
seen: Set<ModuleNode> = new Set(),
timestamp: number = Date.now(),
isHmr: boolean = false,
hmrBoundaries: ModuleNode[] = [],
): void {
...
// 删除平行编译结果
mod.transformResult = null
mod.ssrTransformResult = null
mod.ssrModule = null
mod.ssrError = null
...
mod.importers.forEach((importer) => {
if (!importer.acceptedHmrDeps.has(mod)) {
this.invalidateModule(importer, seen, timestamp, isHmr)
}
})
}

可能会有疑惑,Vite 在开发阶段不是不会打包整个项目吗?怎么生成模块依赖关系图


确实是这样,Vite 不会打包整个项目,但是仍然需要构建模块依赖关系图,当浏览器请求一个模块时



  • Vite 首先会将请求的模块转换成原生 ES 模块

  • 分析模块依赖关系,也就是 import 语句的解析

  • 将模块及依赖关系添加到 moduleGraph

  • 返回编译后的模块给浏览器


因此 Vite 的 Dev 阶段时动态构建和更新模块依赖关系图的,无需打包整个项目,这也实现了真正的按需加载。


handleHMRUpdate


在 chokidar change 的回调中,还执行了 onHMRUpdate 方法,这个方法会调用执行 handleHMRUpdate 方法


handleHMRUpdate 中主要会分析文件更改,确定哪些模块需要更新,然后将更新发送给浏览器。


浏览器端的 HMR 运行时会接收到更新,并在不刷新页面的情况下替换已更新的模块。



源码地址:packages/vite/src/node/server/hmr.ts



export async function handleHMRUpdate(
file: string,
server: ViteDevServer,
configOnly: boolean,
): Promise<void> {
const { ws, config, moduleGraph } = server
// 获取相对路径
const shortFile = getShortName(file, config.root)
const fileName = path.basename(file)
// 是否配置文件修改
const isConfig = file === config.configFile
// 是否自定义插件
const isConfigDependency = config.configFileDependencies.some(
(name) => file === name,
)
// 环境变量文件
const isEnv =
config.inlineConfig.envFile !== false &amp;&amp;
(fileName === '.env' || fileName.startsWith('.env.'))
if (isConfig || isConfigDependency || isEnv) {
// auto restart server
...
try {
await server.restart()
} catch (e) {
config.logger.error(colors.red(e))
}
return
}
...
// 如果是 Vite 客户端代码发生更改,强刷
if (file.startsWith(normalizedClientDir)) {
// ws full-reload
return
}
// 获取到文件对应的 ModuleNode
const mods = moduleGraph.getModulesByFile(file)
...
// 调用所有定义了 handleHotUpdate hook 的插件
for (const hook of config.getSortedPluginHooks('handleHotUpdate')) {
const filteredModules = await hook(hmrContext)
...
}
// 如果是 html 文件变更,重新加载页面
if (!hmrContext.modules.length) {
// html file cannot be hot updated
if (file.endsWith('.html')) {
// full-reload
}
return
}

updateModules(shortFile, hmrContext.modules, timestamp, server)
}


  • 配置文件更新、.env更新、自定义插件更新都会重新启动服务 reload server

  • Vite 客户端代码更新、index.html 更新,重新加载页面

  • 调用所有 plugin 定义的 handleHotUpdate 钩子函数

  • 过滤和缩小受影响的模块列表,使 HMR 更准确。

  • 返回一个空数组,并通过向客户端发送自定义事件来执行完整的自定义 HMR 处理

  • 插件处理更新 hmrContext 上的 modules

  • 如果是其他情况更新,调用 updateModules 函数


流程图如下


image.png


updateModules 中主要是对模块进行处理,生成 updates 更新列表,ws.send 发送 updates 给客户端


ws 客户端响应


客户端在收到服务端发送的 ws.send 信息后,会进行相应的响应


当接收到服务端推送的消息,通过不同的消息类型做相应的处理,比如 updateconnectfull-reload 等,使用最频繁的是 update(动态加载热更新模块)和 full-reload (刷新整个页面)事件。



源码地址:packages/vite/src/client/client.ts



image.png


在 update 的流程里,会使用 Promise.all 来异步加载模块,如果是 js-update,及 js 模块的更新,会使用 fetchUpdate 来加载


if (update.type === 'js-update') {
return queueUpdate(fetchUpdate(update))
}

fetchUpdate 会通过动态 import 语法进行模块引入


浏览器缓存优化


Vite 还利用 HTTP 加速整个页面的重新加载。
对预构建的依赖请求使用 HTTP 头 max-age=31536000, immutable 进行强缓存,以提高开发期间页面重新加载的性能。一旦被缓存,这些请求将永远不会再次访问开发服务器。


这部分的实现在 transformMiddleware 函数中,通过中间件的方式注入到 Koa dev server 中。



源码地址:packages/vite/src/node/server/middlewares/transform.ts



若需要对依赖代码模块做改动可手动操作使缓存失效:


vite --force

或者手动删除 node_modules/.vite 中的缓存文件。


总结


Vite 采用 No Bundleesbuild 预构建,速度远快于 Webpack,实现快速的冷启动,在 dev 模式基于 ES module,实现按需加载,动态 import,动态构建 Module Graph。


在 HMR 上,Vite 利用 HTTP 头 cacheControl 设置 max-age 应用强缓存,加速整个页面的加载。


当然 Vite 还有很多的不足,比如对 splitChunks 的支持、构建生态 loader、plugins 等都弱于 Webpack。不过 Vite 仍然是一个非常好的构建工具选择。在不少应用中,会使用 Vite 来进行开发环境的构建,采用 Webpack5 或者其他 bundle base 的工具构建生产环境。


参考文章


zhuanlan.zhihu.com/p/467

325485

收起阅读 »

nest.js 添加 swagger 响应数据文档

web
基本使用 通常情况下,在 nest.js 的 swagger 页面文档中的响应数据文档默认如下 此时要为这个控制器添加响应数据文档的话,只需要先声明 数据的类型,然后通过@ApiResponse 装饰器添加到该控制器上即可,举例说明 todo.entity....
继续阅读 »

基本使用


通常情况下,在 nest.js 的 swagger 页面文档中的响应数据文档默认如下



此时要为这个控制器添加响应数据文档的话,只需要先声明 数据的类型,然后通过@ApiResponse 装饰器添加到该控制器上即可,举例说明


todo.entity.ts


@Entity('todo')
export class TodoEntity {
@Column()
@ApiProperty({ description: 'todo' })
value: string

@ApiProperty({ description: 'todo' })
@Column({ default: false })
status: boolean
}

todo.controller.ts


  @Get()
@ApiOperation({ summary: '获取Todo详情' })
@ApiResponse({ type: [TodoEntity] })
async list(): Promise<TodoEntity[]> {
return this.todoService.list();
}


@Get(':id')
@ApiOperation({ summary: '获取Todo详情' })
@ApiResponse({ type: TodoEntity })
async info(@IdParam() id: number): Promise<TodoEntity> {
return this.todoService.detail(id);
}

此时对应的文档数据如下显示


image-20230718012234692


如果你想要自定义返回的数据,而不是用 entity 对象的话,可以按照如下定义


todo.model.ts


export class Todo {
@ApiProperty({ description: 'todo' })
value: string

@ApiProperty({ description: 'todo' })
status: boolean
}

然后将 @ApiResponse({ type: TodoEntity }) 中的 TodoEntity 替换 Todo 即可。


自定义返回数据


然而通常情况下,都会对返回数据进行一层包装,如


{
"data": [
{
"name": "string"
}
],
"code": 200,
"message": "success"
}

其中 data 数据就是原始数据。要实现这种数据结构字段,首先定义一个自定义类用于包装,如


export class ResOp<T = any> {
@ApiProperty({ type: 'object' })
data?: T

@ApiProperty({ type: 'number', default: 200 })
code: number

@ApiProperty({ type: 'string', default: 'success' })
message: string

constructor(code: number, data: T, message = 'success') {
this.code = code
this.data = data
this.message = message
}
}

接着在定义一个拦截器,将 data 数据用 ResOp 包装,如下拦截器代码如下


transform.interceptor.ts


export class TransformInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector) {}

intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Observable<any> {
return next.handle().pipe(
map(data => {
const response = context.switchToHttp().getResponse<FastifyReply>()
response.header('Content-Type', 'application/json; charset=utf-8')
return new ResOp(HttpStatus.OK, data ?? null)
}),
)
}
}

此时返回的数据都会转换为 { "data": { }, "code": 200, "message": "success" } 的形式,这部分不为就本文重点,就不赘述了。


回到 Swagger 文档中,只需要 @ApiResponse({ type: TodoEntity }) 改写成 @ApiResponse({ type: ResOp<TodoEntity> }),就可以实现下图需求。


image-20230718012618710


自定义 Api 装饰器


然后对于庞大的业务而言,使用 @ApiResponse({ type: ResOp<TodoEntity> })的写法,肯定不如@ApiResponse({ type: TodoEntity })来的高效,有没有什么办法能够用后者的方式,却能达到前者的效果,答案是肯定有的。


这里需要先自定义一个装饰器,命名为 ApiResult,完整代码如下


import { Type, applyDecorators, HttpStatus } from '@nestjs/common'
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger'

import { ResOp } from '@/common/model/response.model'

const baseTypeNames = ['String', 'Number', 'Boolean']

/**
* @description: 生成返回结果装饰器
*/

export const ApiResult = <TModel extends Type<any>>({
type,
isPage,
status,
}: {
type?: TModel | TModel[]
isPage?: boolean
status?: HttpStatus
}
) =>
{
let prop = null

if (Array.isArray(type)) {
if (isPage) {
prop = {
type: 'object',
properties: {
items: {
type: 'array',
items: { $ref: getSchemaPath(type[0]) },
},
meta: {
type: 'object',
properties: {
itemCount: { type: 'number', default: 0 },
totalItems: { type: 'number', default: 0 },
itemsPerPage: { type: 'number', default: 0 },
totalPages: { type: 'number', default: 0 },
currentPage: { type: 'number', default: 0 },
},
},
},
}
} else {
prop = {
type: 'array',
items: { $ref: getSchemaPath(type[0]) },
}
}
} else if (type) {
if (type && baseTypeNames.includes(type.name)) {
prop = { type: type.name.toLocaleLowerCase() }
} else {
prop = { $ref: getSchemaPath(type) }
}
} else {
prop = { type: 'null', default: null }
}

const model = Array.isArray(type) ? type[0] : type

return applyDecorators(
ApiExtraModels(model),
ApiResponse({
status,
schema: {
allOf: [
{ $ref: getSchemaPath(ResOp) },
{
properties: {
data: prop,
},
},
],
},
}),
)
}

其核心代码就是在 ApiResponse 上进行扩展,这一部分代码在官方文档: advanced-generic-apiresponse 中提供相关示例,这里我简单说明下


{ $ref: getSchemaPath(ResOp) } 表示原始数据,要被“塞”到那个类下,而第二个参数 properties: { data: prop } 则表示 ResOpdata 属性要如何替换,替换的部分则由 prop 变量决定,只需要根据实际需求构造相应的字段结构。


由于有些 类 没有被任何控制器直接引用, SwaggerModule 目前还无法生成相应的模型定义,所以需要 @ApiExtraModels(model) 将其额外导入。


此时只需要将 @ApiResponse({ type: TodoEntity }) 改写为 @ApiResult({ type: TodoEntity }),就可达到最终目的。


不过我还对其进行扩展,使其能够返回分页数据格式,具体根据实际数据而定,演示效果如下图:


image-20230718023729609


导入第三方接口管理工具


通过上述的操作后,此时记下项目的 swagger-ui 地址,例如 http://127.0.0.1:5001/api-docs, 此时再后面添加-json,即 http://127.0.0.1:5001/api-docs-json 所得到的数据便可导入到第三方的接口管理工具,就能够很好的第三方的接口协同,接口测试等功能。


image-20230718022612215


image-20230718022446188

收起阅读 »

echarts+dataV实现中国在线选择省市区地图

web
echarts+dataV实现中国在线选择省市区地图 利用 dataV 的地图 GEO JSON数据配合 echarts 和 element-china-area-data实现在线选择 省市区 地图 效果预览 可以通过自行选择省市区在线获取地图数据,配合 e...
继续阅读 »

echarts+dataV实现中国在线选择省市区地图


利用 dataV 的地图 GEO JSON数据配合 echarts 和 element-china-area-data实现在线选择 省市区 地图


效果预览




可以通过自行选择省市区在线获取地图数据,配合 echarts 渲染出来。


实现思路


先通过 regionData 中的数据配合组件库的 级联选择器 进行 省市区 的展示和选择,同时拿到选中 省市区 的 value 值去请求 dataV 中的 GEO JSON 数据


regionData 中的 省市区 数据结构为


elementChinaAreaData.regionData = [{
label: "北京市",
value: "11",
children: [{…}]
}, {
label: 'xxx',
value: 'xxx',
children: [{...}]
}]



  1. dataV 地图数据请求地址为 https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json 其中https://geo.datav.aliyun.com/areas_v3/bound/请求地址前缀不变, 100000_full是全国地图数据的后缀,每个 省市区 后缀不同

  2. regionData 中的 value 值逻辑是


省级为 2                 广东省 value  44
市级为 4 广州市 value 4401
区/县级为 6 天河区 value 440106
直辖市为 2 北京市 value 11
直辖市-市辖区级为 4 北京市-市辖区 value 1101

但是 dataV 后缀长度都是 6 位,好在不足 6 位的只需要用 0 补齐就可以和 dataV 请求后缀联动起来



  1. 直辖市 和 直辖市-市辖区是指同个地址,只是 regionData 多套了一层,所以应该请求同一个 value 后缀的地址,这里以直辖市的为准,下列是需要转换的直辖市,重庆市同时含有 区和县,但是 dataV 中没有做区分,regionData 又有做区分,统一成重庆市总的地图数据


const specialMapCode = {
'1101': '11', // 北京市-市辖区
'1201': '12', // 天津市-市辖区
'3101': '31', // 上海市-市辖区
'5001': '50', // 重庆市-市辖区
'5002': '50' // 重庆市-县
}


  1. dataV 请求地址到 区/县 级后不需要加 _full 后缀如 广东省-广州市-天河区 的请求地址为 https://geo.datav.aliyun.com/areas_v3/bound/440106.json


接合这几点我们可以缕清 regionDatadataV 数据接口地址的关系了,如果 value 长度不足 6 位,如 省/市 则需要用 0 补齐到 6位,且需要加 _full,而 县/区 则不用补 0 和 _full,直辖市-市辖区 则需要进行特殊处理,也只有四个直辖市,处理难度不大,下列代码是级联选择器选择后的处理逻辑,很好理解


const padMapCode = value => {
let currValue = specialMapCode[value] || value
return currValue.length < 6 ? currValue += `${'0'.repeat(6 - currValue.length)}_full` : currValue
}

其它页面展示代码请参考源码


源码


echart-China-map

作者:半生瓜i
来源:juejin.cn/post/7256610327131258940

收起阅读 »

Blitz:可以实时聊天的协同白板

web
书接上文 之前在 跨平台渲染引擎之路:类 Figma 的无限画布 中提到了想落地无限画布场景的渲染 SDK,最近业余时间基本都在折腾类似的事情,不过在形式和顺序上有些调整,首先先看下目前还比较粗糙的项目预览。 预览 项目地址:Blitz 体验地址:Blitz...
继续阅读 »

书接上文


之前在 跨平台渲染引擎之路:类 Figma 的无限画布 中提到了想落地无限画布场景的渲染 SDK,最近业余时间基本都在折腾类似的事情,不过在形式和顺序上有些调整,首先先看下目前还比较粗糙的项目预览。


预览


preview.gif


项目地址:Blitz


体验地址:Blitz - A collaborative whiteboard with chat functionality


目前前端项目直接运行后,协同编辑与音视频部分连接的是我个人的服务器,配置比较低,可能会出现不稳定的情况,后续会再做一些服务自动重启之类的保障措施,也会在 server 模块提供本地运行的机制,感兴趣的可以先 Star 关注一下~


项目目标


产品向



  1. 类似 Canva/Figma 的白板应用

  2. 支持多人实时协作,包括编辑、评论等

  3. 支持音视频聊天、实时通信、投屏等


技术向



  1. 覆盖前端客户端与后端服务器完整链路

  2. 矢量绘制、文字排版与渲染、特效、音视频等实现原理

  3. 集成一些流行或前沿的技术尝鲜,比如 AIGC、抠图、超分等

  4. 调研子技术点目前常见的技术选型,研究第三方库与自主实现/优化的方案


落地思路


最开始提到的落地思路上的调整主要是在以下几个方面上:


第一是在项目形式上,原计划只想搞几个按钮来进行操作,结果发现这太过简陋了,要做更复杂的功能和性能测试就很不方便,同时自己后续想进一步落地像文字、路径等引擎之上的元素级别的能力,因此考虑直接搭建一个类似 Canva 或 Figma 的白板应用,并逐渐迭代到可真正供用户使用的状态。


第二是在引擎开发的节奏上,上次调研后发现,在前端 Pixi.js 的性能其实已经算是 Top 级别的了,像 WASM 方向的 CanvasKit 也只能是不相上下(当然 CanvasKit 的定位其实是不太一样的),如果按照 Pixi 的设计重新用 C++ 或 Rust 实现一遍,那亲测性能是有 30% 左右的提升的,所以考虑先直接用 Pixi.js 作为第一步的引擎,后续逐步替换成 C++ 版本。


第三是会在画布之上从编辑器的角度将整个生态搭建起来,比如更多的元素、特效,以及实时协作、通信等,并且集成一些自己感兴趣的或者看到的有意思的第三方能力,跟市面上已有的产品形成差异化,比如在白板协作的同时能够进行视频聊天、视角跟随等。


因此,在落地顺序上会先搭建一个可以满足最小主流程的编辑器,并在其上逐渐补充能力和优化架构,这过程中会使用到许多优秀的第三方项目来先快速满足需求,最后再将第三方的内容逐渐替代成原生能力(如果第三方存在问题或自己的确有这个能力的话~)。


这项目不仅涉及渲染等多媒体技术,也是一个自己用来学习从前端到后端完整技术栈的项目,欢迎大家一起交流讨论,有想要的功能或者新奇的想法更可以提出来,一起共建或者我来尝试集成到项目中看看~。


编辑器


介绍一下目前编辑器已支持的能力所涉及的技术选型,以及落地过程相关知识点的整理。


无限画布


目前我是直接使用 Pixi 作为渲染引擎,因此只需要将视图中的 传递给 Pixi 的 Application 即可。不过现实情况下我们不可能创建一个无限大的 canvas,因此一般需要自定义一个 Viewport 的概念,用于模拟出无限缩放倍数以及无边界移动的效果。


社区中已经有一个 pixi-viewport 的项目支持类似效果了,但是实际使用过程中,发现其所使用的 Pixi 是之前的版本,与最新版本结合使用时会报错,另外我预先考虑考虑是将用户操作事件进行统一的拦截、分发和处理,该项目会把视口上的事件都接管过去,与我的设计思路相悖,不过 Viewport 的复杂度也不高,因此这部分目前是直接自主实现的。


代码文件:Viewport.ts


画笔


线条的绘制最开始使用的是 Paper.js ,效果和性能方式都很优异,而且也能完全满足后面形状、路径元素的实现,在交互上 Paper 也完整支持了基于锚点调整路径等操作。不过在引入 Pixi 后就需要考虑二者如何交互,目前为了先跑通最小流程先使用的 Pixi 的 Graphics ,后面在完成二者的兼容设计后大概率还是会换回来的。


代码文件:Brush.ts


交互


编辑器用户界面框架是基于 Vue3 落地的,在 UI 组件和风格上采用的是 Element-Plus 为主,这部分前端同学应该属于驾轻就熟的,目前只实现了简单的包围盒展示以及移动元素的操作。


用户认证


用户认证目前直接使用的是第三方的 Authing 身份云 ,不仅能够支持用户名、邮箱、手机等注册方式,微信、Github等第三方身份绑定也是齐全的,并且提供了配套的 UI 组件,拆箱即用,在眼下阶段可以节省很大的成本,帮助聚焦在其他核心能力的开发上。


协同编辑


协同算法目前主流的就是 OT 和 CRDT ,二者都有许多的论文和应用实践,我目前的方案是直接使用 Y.js 项目,目前还只是简单接入,后续这个模块的设计将参考 liveblocks 进行。


CRDT 在多人编辑场景的适用性方面,首先最终一致性保障上肯定是没问题的,同时 FigmaRoom.shPingcode WIKI 等成熟项目也都正在使用,Y.js 的作者在 Are CRDTs suitable for shared editing? 文章中也做了很多说明。从个人的使用体验来说,在工程应用方面,Y.js 相比自己基于 OT 的原理进行实现而言成本大大降低了,只需要进行数据层的 Binding 即可,至于内存方面的问题,其实远没有想象中那么严重,也有许多优化手段,综合评估来看,对于个人或小团队,以及新项目来说,Y.js 是一个相对更好的选择。


协同编辑是很大的一个课题,@doodlewind 的 探秘前端 CRDT 实时协作库 Yjs 工程实现 , @pubuzhixing 的 多人协同编辑技术的演进 都是很好的学习文章,后面自己有更多心得收获的话再进行分享。


代码文件:WhiteBoard.ts


音视频会议


音视频会议的技术实现方案是多样的,你可以选择直接基于 WebRTC 甚至更底下的协议层完全自主实现,也可以采用成熟的流媒体服务器和配套客户端 SDK 进行接入,前者更适合系统学习,但是我觉得后者会更平滑一些,不仅能够先快速满足项目需求,也能够从成熟的解决方案中学习成功经验,避免自己重复走弯路。


媒体服务器的架构目前主要有 Mesh、MCU、SFU 三种。纯 Mesh 方案无法适应多人视频通话,也无法实现服务端的各种视频处理需求,SFU 相比于 MCU,服务器的压力更小(纯转发,无转码合流),灵活性更好,综合看比较适合目前我的诉求。


在 SFU 的架构方向下,被 Miro 收购的 MediaSoup 是综合社区活跃性、团队稳定性、配套完善度等方面较好的选择。在编辑器侧 MediaSoup 提供了 mediasoup-client ,基于该 SDK 可以实现房间连接、音视频流获取与传输、消息通信等能力。


代码文件:VideoChat.ts


服务器


HTTPS 证书


MediaSoup 的服务端访问需要通过 HTTPS 协议,另外处于安全考虑,也建议前端与后端通信统一走 HTTPS ,证书的申请我是用的 Certbot ,按官方教程走就行,非常简单,如果遇到静态页面 HTTPS 访问异常的话,可以参考 该文章 调整下 Nginx 配置看看。


协同编辑


协同编辑我采用的是 Hocuspocus 的解决方案,其除了提供客户端的 Provider 外,也提供了对应服务端的 SDK,按照官网教程直接使用即可,也比较简单。


不过因为项目使用的是 HTTS 协议,因此 WebScoket 也需要使用 WSS 协议才行,Hocuspocus 没有提供这部分的封装,需要自己通过 Express 中转一层,这部分参考 express-ws 的 https 实现即可。


代码文件:server 目录下的 whiteboard.ts


音视频会议


MediaSoup 官方提供了一套基本可以拆箱即用的 demo ,目前我是直接将其 server 模块的代码改了改直接部署在了后端上。主要的修改点就是在端口、证书等配置上,另外项目编译的时候可能会有一些 TS 的 Lint 错误,视情况修改或跳过即可。可以直接参考这两篇文章:mediasoup部署mediasoup错误解决


代码文件:后面熟悉了该模块代码后再整理到项目里面,目前与官方无太大差异


CI/CD


这部分会等到项目的架构相对稳定,功能相对完整后再落地,特别是 CI 属于项目质量保障的重要一环,为了项目达成用户可用的目标是一定要做的。


下一步


从上述内容看其实目前我们也还不算完成了最小流程的编辑体验,比如作图记录的保存就没有做,而且已实现的能力都比较简陋,问题较多,因此下一个项目计划节点中会做的是:



  • 保存/读取作图记录

  • 元素缩放/旋转/删除

  • 导出作图结果

  • 音视频聊天的一些能力补充

  • 支持图片/文字元素

  • 快捷键


同时会将项目的代码框架再做一些完善,修复一些问题,届时会再结合过程中的技术点或浅或深地做一些分享。


最后


目前项目还处于最最开始的起步阶段,架构设计、代码规范等都存在暴力实现的情况,不过我基本会保持每天抽时间更新的状态(通过 Github记录也能看出来),计划今年内能够实现定好的项目目标。


在过程中会阶段性分享自己做的技术选型还有一些技术原理、优化细节等,目前我的路径大体是从第三方到自主实现,因此分享上也会是从浅到深,从大框架到具体技术这么一个节奏。之前我对前端/后端开发其实接触很少,所以许多知识都需要现学现用,比如这次的 Vue、Nginx 等等,欢迎看到的朋友有任何问题或者建议可以一起交流,甚至一起共建项目~


最后对 Blitz 感兴趣的可以点个 S

作者:格子林ll
来源:juejin.cn/post/7256393626681540645
tar ,万分感谢~

收起阅读 »

手撸一个 useLocalStorage

web
前言 最近在用 vue3 + typeScript + vite 重构之前的代码,想着既然都重写了那何不大刀阔斧的改革,把复杂的逻辑全部抽象成独立的 hook,不过官方称之为“组合式函数”(Composables),好家伙写着写着就陷入 “hook 陷阱” 了...
继续阅读 »

前言


最近在用 vue3 + typeScript + vite 重构之前的代码,想着既然都重写了那何不大刀阔斧的改革,把复杂的逻辑全部抽象成独立的 hook,不过官方称之为“组合式函数”(Composables),好家伙写着写着就陷入 “hook 陷阱” 了,啥都想用 hook 实现(自己强迫自己的那种🙃),下笔之前会先去vueuse上看看有没有现成可用的,没有就自己撸一个。


但回过头来发现有些地方确实刻意为之了,导致用起来不是那么爽,比如写了一个 usePxToRem hook,作用是把 px 转换为 rem,用法如下


import { usePxToRem } from './usePxToRem'

const { rem } = usePxToRem('120px')

初看确实没问题,但如果此时有两个px需要转换怎么办,下面这样写肯定不行的,会提示变量rem已经被定义了,不能重复定义。


import { usePxToRem } from './usePxToRem'

const { rem } = usePxToRem('120px')
const { rem } = usePxToRem('140px')

像这样变通下也是勉强能解决的。


import { usePxToRem } from './usePxToRem'

const { rem: rem1 } = usePxToRem('120px')
const { rem: rem2 } = usePxToRem('140px')
console.log(rem1, rem2)

但是总感觉有点麻烦不够优雅,重新思考下这个需求,好像不需要响应式,是不是更适合用函数 convertPxToRem 解决,所以说写着写着就掉进了 hook 陷阱了😂。


正文


扯远了回到正题,开发中经常需要操作 localStorage,直接用原生也没啥问题,如果再简单封装一下就更好了,用起来方便多了。


export function getLocalStorage(key: string, defaultValue?: any) {
const value = window.localStorage.getItem(key)

if (!value) {
if (defaultValue) {
window.localStorage.setItem(key, JSON.stringify(defaultValue))
return defaultValue
} else {
return ''
}
}

try {
const jsonValue = JSON.parse(value)
return jsonValue
} catch (error) {
return value
}
}

export function setLocalStorage(key: string, value: any) {
window.localStorage.setItem(key, JSON.stringify(value))
}

export function removeLocalStorage(key: string) {
window.localStorage.removeItem(key)
}

假设有个需求在页面上实时显示 localStorage 里的值,那么必须单独设置一个变量接收 localStorage 的值,然后一边修改变量一边设置 localStorage,这样写起来就有点繁琐了。


<template>
<div>
{{ user }}
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { getLocalStorage, setLocalStorage } from './localStorage';

const user = ref('')
user.value = getLocalStorage('user', '张三')
user.value = '李四'
setLocalStorage('user', user.value)
</script>

我想要的效果是一步搞定,像下面这样,是不是很优雅。


import { useLocalStorage } from './useLocalStorage'

const user = useLocalStorage('user', '张三')
user.value = '李四'

第一想法是从 vueues 上找现成的,毕竟这个需求太通用了,useLocalStorage 确实很好用,然后就在想能不能学习 vueuse 自己实现一个简单的 useLocalStorage,正好锻炼下。


第一步搭框架实现基本功能。


import { ref, watch } from "vue";

export function useLocalStorage(key: string, defaultValue: any) {
const data = ref<any>()

// 读取 storage
try {
data.value = JSON.parse(window.localStorage.getItem(key) || '')
} catch (error) {
data.value = window.localStorage.getItem(key)
} finally {
if (!data.value) {
data.value = defaultValue
}
}

// 上面只是读取 storage,并没有把更新后的值写入到 storage 中
// 接下来监听 data,每次更新都更新 storage 中值
watch(() => data.value, () => {
if (data.value === null) {
// 置为null表明要清空该值了
window.localStorage.removeItem(key)
} else {
if (typeof data.value === 'object') {
window.localStorage.setItem(key, JSON.stringify(data.value))
} else {
window.localStorage.setItem(key, data.value)
}
}
}, {
immediate: true
})

return data
}

虽然基本功能实现了,但有个问题,比如定义了一个 number 类型的 count 变量,正常情况下只能赋值数字,但这里赋值为字符串也是允许的,因为 data 设置 any 类型了,接下来想办法把类型固定住,比如一开始赋值为 number,后续更新只能是 number 类型,避免误操作。此时就不能使用 any 类型了,需要用范型来约束返回值了,至于范型是啥,请移步这里


我们约定好默认值 defaultValue 的类型就是接下来要操作的类型,稍作调整如下,这样返回值 datadefaultValue 的类型就一致了。


import { ref, watch } from "vue"
import type { Ref } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T) {
const data = ref() as Ref<T>

// 读取 storage
try {
data.value = JSON.parse(window.localStorage.getItem(key) || '')
} catch (error) {
data.value = window.localStorage.getItem(key) as T
} finally {
if (!data.value) {
data.value = defaultValue
}
}

// 上面只是读取 storage,并没有把更新后的值写入到 storage 中
// 接下来监听 data,每次更新都更新 storage 中值
watch(() => data.value, () => {
if (data.value === null) {
// 置为null表明要清空该值了
window.localStorage.removeItem(key)
} else {
if (typeof data.value === 'object') {
window.localStorage.setItem(key, JSON.stringify(data.value))
} else {
window.localStorage.setItem(key, data.value as string)
}
}
}, {
immediate: true
})

return data
}

继续举例子看看,会发现IDE报错了,提示不能将类型“string”分配给类型“number”,至此改造第一步算是完成了。


const count = useLocalStorage('count', 1);
count.value = 2
count.value = '3'

image.png


来试试删除 count,IDE又报错了,提示不能将类型“null”分配给类型“number”,确实有道理。


image.png


那来点暴力的,在定义 data 的时候给一个 null 类型,就像这样 const data = ref() as Ref<T | null>,那么 count.value = null 就不会报错了,也能清空了。不过当我们这样写的时候问题又来了,count.value += 1,IDE会提示 “count.value”可能为 “null” ,确实在定义的时候给了一个 null 类型,那该怎么办呢?


可以用 get set 实现,在 get 的时候返回当前类型,在 set 的时候可以设置 null,然后 count.value 在设置的时候可以为 null 或者 number,在读取的时候只是 number 了。


type RemovableRef<T> = {
get value(): T
set value(value: T | null)
}

const data = ref() as RemovableRef<T>

至此一个简单的 useLocalStorage 算是实现了,顺便聊聊自己在开发 hook 时一些心得体验。



  1. 不要把所有功能写到一个 hook 中,这样没有任何意义,一定要一个功能一个 hook,功能越单一越好

  2. 有时候 hook 在初始化的时候需要传递一些参数,如果这些参数是给 hook 中某个函数使用的,那么最好是在调用该函数的时候传参,这样可以多次调用传不同的
    作者:胡先生
    来源:juejin.cn/post/7256620538092290107
    参数。

收起阅读 »

揭秘 html2Canvas:打印高清 PDF 的原理解析

web
1. 前言 最近我需要将网页的DOM输出为PDF文件,我使用的技术是html2Canvas和jsPDF。具体流程是,首先使用html2Canvas将DOM转化为图片,然后将图片添加到jsPDF中进行输出。 const pdf = new jsPDF({    ...
继续阅读 »

1. 前言


最近我需要将网页的DOM输出为PDF文件,我使用的技术是html2Canvas和jsPDF。具体流程是,首先使用html2Canvas将DOM转化为图片,然后将图片添加到jsPDF中进行输出。


const pdf = new jsPDF({     
unit: 'pt',    
format: 'a4',    
orientation: 'p',
});
const canvas = await html2canvas(element,
{
onrendered: function (canvas) {    
document.body.appendChild(canvas);  
}
}
);
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
pdf.addImage(canvasData, 10, 10);
pdf.save('jecyu.pdf');

遇到了图片导出模糊的问题,解决思路是:



  1. 先html2canvas 转成高清图片,然后再传一个 scale 配置:


scale: window\.devicePixelRatio \* 3// 增加清晰度


  1. 为了确保图片打印时不会变形,需要按照 PDF 文件的宽高比例进行缩放,使其与 A4 纸张的宽度一致。因为 A4 纸张采用纵向打印方式,所以以宽度为基准进行缩放。


// 获取canavs转化后的宽度 
const canvasWidth = canvas.width;
// 获取canvas转化后的高度
const canvasHeight = canvas.height;
// 高度转化为PDF的高度 const height = (width / canvasWidth) \* canvasHeight;
// 1 比 1 进行缩放
pdf.addImage(data, 'JPEG', 0, 0, width, height);
pdf.save('jecyu.pdf');

要想了解为什么这样设置打印出来的图片变得更加清晰,需要先了解一些有关图像的概念。


2. 一些概念


2.1 英寸


F2FDB01D-EAF3-4056-BFB0-A2615285F55C.png

英寸是用来描述屏幕物理大小的单位,以对角线长度为度量标准。常见的例子有电脑显示器的17英寸或22英寸,手机显示器的4.8英寸或5.7英寸等。厘米和英寸的换算是1英寸等于2.54厘米。


2.2 像素


像素是图像显示的基本单元,无法再分割。它是由单一颜色的小方格组成的。每个点阵图都由若干像素组成,这些小方格的颜色和位置决定了图像的样子。


image.png

图片、电子屏幕和打印机打印的纸张都是由许多特定颜色和位置的小方块拼接而成的。一个像素通常被视为图像的最小完整样本,但它的定义和上下文有关。例如,我们可以在可见像素(打印出来的页面图像)、屏幕上的像素或数字相机的感光元素中使用像素。根据上下文,可以使用更精确的同义词,如像素、采样点、点或斑点。


2.3 PPI 与 DPI


PPI (Pixel Per Inch):每英寸包括的像素数,用来描述屏幕的像素密度。


DPI(Dot Per Inch):即每英寸包括的点数。   


在这里,点是一个抽象的单位,可以是屏幕像素点、图片像素点,也可以是打印的墨点。在描述图片和屏幕时,通常会使用DPI,这等同于PPI。DPI最常用于描述打印机,表示每英寸打印的点数。一张图片在屏幕上显示时,像素点是规则排列的,每个像素点都有特定的位置和颜色。当使用打印机打印时,打印机可能不会规则地打印这些点,而是使用打印点来呈现图像,这些打印点之间会有一定的空隙,这就是DPI所描述的:打印点的密度。


30E718E3-8D78-4759-8D3A-A2E428936DF7.png


在这张图片中,我们可以清晰地看到打印机是如何使用墨点打印图像的。打印机的DPI越高,打印出的图像就越精细,但同时也会消耗更多的墨点和时间。


2.4 设备像素


设备像素(物理像素)dp:device pixels,显示屏就是由一个个物理像素点组成,屏幕从工厂出来那天物理像素点就固定不变了,也就是我们经常看到的手机分辨率所描述的数字。


DF7FDA29-CBFC-41DD-8AD3-E4BB480C322F.png
一个像素并不一定是小正方形区块,也没有标准的宽高,只是用于丰富色彩的一个“点”而已。


2.5 屏幕分辨率


屏幕分辨率是指一个屏幕由多少像素组成,常说的分辨率指的就是物理像素。手机屏幕的横向和纵向像素点数以 px 为单位。


CB3C41C3-C15B-40E5-9724-60E7639A1B65.png

iPhone XS Max 和 iPhone SE 的屏幕分辨率分别为 2688x1242 和 1136x640。分辨率越高,屏幕上显示的像素就越多,单个像素的尺寸也就越小,因此显示效果更加精细。


2.6 图片分辨率


在我们所说的图像分辨率中,指的是图像中包含的像素数量。例如,一张图像的分辨率为 800 x 400,这意味着图像在垂直和水平方向上的像素点数分别为 800 和 400。图像分辨率越高,图像越清晰,但它也会受到显示屏尺寸和分辨率的影响。


如果将 800 x 400 的图像放大到 1600 x 800 的尺寸,它会比原始图像模糊。通过图像分辨率和显示尺寸,可以计算出 dpi,这是图像显示质量的指标。但它还会受到显示屏影响,例如最高显示屏 dpi 为 100,即使图像 dpi 为 200,最高也只能显示 100 的质量。


可以通过 dpi 和显示尺寸,计算出图片原来的像素数


719C1F90-0990-499B-AD74-0ED41A8825FD.png

这张照片的尺寸为 4x4 英寸,分辨率为 300 dpi,即每英寸有 300 个像素。因此它的实际像素数量是宽 1200 像素,高 1200 像素。如果有一张同样尺寸(4x4 英寸)但分辨率为 72 dpi 的照片,那么它的像素数量就是宽 288 像素,高 288 像素。当你放大这两张照片时,由于像素数量的差异,可能会导致细节的清晰度不同。


怎么计算 dpi 呢?dpi = 像素数量 / 尺寸


举个例子说明:


假设您有一张宽为1200像素,高为800像素的图片,您想将其打印成4x6英寸的尺寸。为此,您可以使用以下公式计算分辨率:宽度分辨率 = 1200像素/4英寸 = 300 dpi;高度分辨率 = 800像素/6英寸 = 133.33 dpi。因此,这张图片的分辨率为300 dpi(宽度)和133.33 dpi(高度)。需要注意的是,计算得出的分辨率仅为参考值,实际的显示效果还会受到显示设备的限制。


同一尺寸的图片,同一个设备下,图片分辨率越高,图片越清晰。  


A790AD33-7440-458B-8588-F32827C533BD.png


2.7 设备独立像素


前面我们说到显示尺寸,可以使用 CSS 像素来描述图片在显示屏上的大小,而 CSS 像素就是设备独立像素。设备独立像素(逻辑像素)dip:device-independent pixels,独立于设备的像素。也叫密度无关像素。


为什么会有设备独立像素呢?


智能手机的发展非常迅速。几年前,我们使用的手机分辨率非常低,例如左侧的白色手机,它的分辨率只有320x480。但是,随着科技的进步,低分辨率手机已经无法满足我们的需求了。现在,我们有更高分辨率的屏幕,例如右侧的黑色手机,它的分辨率是640x960,是白色手机的两倍。因此,如果在这两个手机上展示同一张照片,黑色手机上的每个像素点都对应白色手机上的两个像素点。


image.png

理论上,一个图片像素对应1个设备物理像素,图片才能得到完美清晰的展示。因为黑色手机的分辨率更高,每英寸显示的像素数量增多,缩放因素较大,所以图片被缩小以适应更高的像素密度。而在白色手机的分辨率较低,每英寸显示的像素数量较少,缩放因素较小,所以图片看起来相对较大。


为了解决分辨率越高的手机,页面元素越来越小的问题,确保在白色手机和黑色手机看起来大小一致,就出现了设备独立像素。它可以认为是计算机坐标系统中得到一个点,这个点代表可以由程序使用的虚拟像素。


例如,一个列表的宽度 300 个独立像素,那么在白色手机会用 300个物理像素去渲染它,而黑色手机使用 600个物理像素去渲染它,它们大小是一致的,只是清晰度不同。


那么操作系统是怎么知道 300 个独立像素,应该用多少个物理像素去渲染它呢?这就涉及到设备像素比。


2.8 设备像素比


设备像素比是指物理像素和设备独立像素之间的比例关系,可以用devicePixelRatio来表示。具体而言,它可以按以下公式计算得出。


设备像素比:物理像素 / 设备独立像素 // 在某一方向上,x 方向或者 y 方向

在JavaScript中,可以使用window.devicePixelRatio获取设备的DPR。设备像素比有两个主要目的:



  • 1.保持视觉一致性,以确保相同大小的元素在不同分辨率的屏幕上具有一致的视觉大小,避免在不同设备上显示过大或过小的问题。

  • 2.支持高分辨率屏幕,以提供更清晰、更真实的图像和文字细节。


开发人员可以使用逻辑像素来布局和设计网页或应用程序,而不必考虑设备的物理像素。系统会根据设备像素比自动进行缩放和适配,以确保内容的一致性和最佳显示效果。


3. 分析原理


3.1 html2canvas 整体流程


在使用html2canvas时,有两种可选的模式:一种是使用foreignObject,另一种是使用纯canvas绘制。


使用第一种模式时,需要经过以下步骤:首先将需要截图的DOM元素进行克隆,并在过程中附上getComputedStyle的style属性,然后将其放入SVG的foreignObject中,最后将SVG序列化成img的src(SVG直接内联)。


img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(new XMLSerializer().serializeToString(svg)); 4.ctx.drawImage(img, ....)

第二种模式是使用纯Canvas进行截图的步骤。具体步骤如下:



  1. 复制要截图的DOM,并将其附加样式。

  2. 将复制的DOM转换为类似于VirtualDOM的对象。

  3. 递归该对象,根据其父子关系和层叠关系计算出一个renderQueue。

  4. 每个renderQueue项目都是一个虚拟DOM对象,根据之前获取的样式信息,调用ctx的各种方法。


6C07042D-923E-4FFA-9C79-FF924D3E8512.png


3.2 分析画布属性 width、height、scale


通常情况下,每个位图像素应该对应一个物理像素,才能呈现完美清晰的图片。但是在 retina 屏幕下,由于位图像素点不足,图片就会变得模糊。


为了确保在不同分辨率的屏幕下输出的图片清晰度与屏幕上显示一致,该程序会取视图的 dpr 作为默认的 scale 值,以及取 dom 的宽高作为画布的默认宽高。这样,在 dpr 为 2 的屏幕上,对于 800 * 600 的容器画布,通过 scale * 2 后得到 1600 * 1200 这样的大图。通过缩放比打印出来,它的清晰度是跟显示屏幕一致的。


0A21E97D-FE73-47D9-9CE8-058696CCE58C.png


假设在 dpr 为 1 的屏幕,假如这里 scale 传入值为 2,那么宽、高和画布上下文都乘以 2倍。


A3086809-FA99-42A4-8C9E-8BA6C21395E4.png


为什么要这样做呢?因为在 canvas 中,默认情况下,一个单位恰好是一个像素,而缩放变换会改变这种默认行为。比如,缩放因子为 0.5 时,单位大小就变成了 0.5 像素,因此形状会以正常尺寸的一半进行绘制;而缩放因子为 2.0 时,单位大小会增加,使一个单位变成两个像素,形状会以正常尺寸的两倍进行绘制。


如下例子,通过放大倍数绘制,输出一张含有更多像素的大图


// 创建 Canvas 元素 
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 200;

// 获取绘图上下文
const ctx = canvas.getContext('2d');
// 绘制矩形
ctx.fillStyle = 'red';
ctx.fillRect(50, 50, 100, 100);
document.body.appendChild(canvas)

//== 放大2倍画布 ==//
const canvas2 = document.createElement('canvas'); //
// 改变 Canvas 的 width 和 height
canvas2.width = 400;
canvas2.height = 400;
const ctx2 = canvas2.getContext('2d');
// 绘制矩形
ctx2.scale(2, 2);
// 将坐标系放大2倍,必须放置在绘制矩形前才生效
ctx2.fillStyle = 'blue';
ctx2.fillRect(50, 50, 100, 100);
document.body.appendChild(canvas2)

3.3 为什么 使用 dpr * 倍数进行 scale


在使用html2Canvas时,默认会根据设备像素比例(dpr)来输出与屏幕上显示的图片清晰度相同的图像。但是,如果需要打印更高分辨率的图像,则需要将dpr乘以相应的倍数。例如,如果我们想要将一张800像素宽,600像素高,72dpi分辨率的屏幕图片打印在一张8x6英寸,300dpi分辨率的纸上,我们需要确保图片像素与打印所需像素相同,以保证清晰度。


步骤 1: 将纸的尺寸转换为像素


可以使用打印分辨率来确定转换后的像素尺寸。


假设打印分辨率为 300 dpi,纸的尺寸为 8x6 英寸,那么:


纸的宽度像素 = 8 英寸 * 300 dpi = 2400 像素


纸的高度像素 = 6 英寸 * 300 dpi = 1800 像素


步骤 2: 计算图片在纸上的实际尺寸


将图片的尺寸与纸的尺寸进行比例缩放,以确定在纸上的实际打印尺寸


图片在纸上的宽度 = (图片宽度 / 屏幕像素每英寸) * 打印分辨率


图片在纸上的高度 = (图片高度 / 屏幕像素每英寸) * 打印分辨率


图片在纸上的宽度 = (800 / 72) * 300 = 3333.33 像素(约为 3334 像素)


图片在纸上的高度 = (600 / 72) * 300 = 2500 像素


步骤 3: 调整图片大小和打印分辨率


根据计算出的实际尺寸,可以将图片的大小调整为适合打印的尺寸,并设置适当的打印分辨率。


图片在纸上的宽度为 3334 像素,高度为 2500 像素。


也就是说,在保持分辨率为 72 dpi 的情况下,需要把原来 800*600 的图片,调整像素为 3334 * 2500。如果是位图直接放大,就会变糊。如果是矢量图,就不会有问题。这也是 html2Canvas 最终通过放大 scale 来提高打印清晰度的原因。


在图片调整像素为 *3334 * 2500,虽然屏幕宽高变大了,但通过打印尺寸的换算,最终还是 6 8 英寸,分辨率 为 300dpi。


在本案例中,我们需要打印出一个可以正常查看的 pdf,对于 A4尺寸,我们可以用 pt 作为单位,其尺寸为 595pt * 841pt。 实际尺寸为  595/72 = 8.26英寸,841/72 =  11.68英寸。为了打印高清图片,需要确保每英寸有300个像素,也就是8.26 * 300 = 2478像素,11.68 * 300 = 3504 像素,也就是说 canvas 转出的图片必须要这么大,最终打印的像素才这么清晰。


而在绘制 DOM 中,由于调试时不需要这么大,我们可以缩放比例,比如缩小至3倍,这样图片大小就为826像素 * 1168像素。如果高度超过1168像素,则需要考虑分页打印。


下面是 pt 转其他单位的计算公式


function convertPointsToUnit(points, unit) {   
// Unit table from <https://github.com/MrRio/jsPDF/blob/ddbfc0f0250ca908f8061a72fa057116b7613e78/jspdf.js#L791>  
var multiplier;  
switch(unit) {    
case 'pt'
multiplier = 1;         
break;    
case 'mm'
multiplier = 72 / 25.4
break;    
case 'cm'
multiplier = 72 / 2.54
break;    
case 'in'
multiplier = 72;        
break;    
case 'px'
multiplier = 96 / 72;   
break;    
case 'pc'
multiplier = 12;        
break;    
case 'em'
multiplier = 12;        
break;    
case 'ex'
multiplier = 6;
break;
default:      
throw ('Invalid unit: ' + unit);  
}  
return points \* multiplier; }

4. 扩展


4.1 为什么使用大图片 Icon 打印出来还模糊


在理论上,一个位图像素应该对应一个物理像素,这样图片才能完美清晰地展示。在普通屏幕上,这没有问题,但在Retina屏幕上,由于位图像素点不足,图片会变得模糊。


EE3424CB-9DEB-4F55-B4A7-89736725C0E1.jpg


所以,对于图片高清问题,比较好的方案是两倍图片(@2x)


如:200x300(css pixel)img标签,就需要提供 400x600 的图片


如此一来,位图像素点个数就是原来的 4 倍,在 retina 屏幕下,位图像素个数就可以跟物理像素个数


形成 1:1 的比例,图片自然就清晰了(这也解释了为啥视觉稿的画布需要 x2


这里还有另一个问题,如果普通屏幕下,也用了两倍图片 ,会怎么样呢?


很明显,在普通屏幕下(dpr1),200X300(css pixel)img 标签,所对应的物理像素个数就是 200x300 个。而两倍图的位图像素。则是200x300*4,所以就出现一个物理像素点对应4个位图像素点,所以它的取色也只能通过一定的算法(显示结果就是一张只有原像素总数四分之一)


我们称这个过程叫做(downsampling),肉眼看上去虽然图片不会模糊,但是会觉得图片缺失一些锐利度。


11465C2B-AEBB-472D-9CFD-8922ADB72E5F.jpg


通常在做移动端开发时,对于没那么精致的app,统一使用 @2x 就好了。


10C2665B-6F90-4317-8AE9-A380F9F0ABA3.png


上面 100x100的图片,分别放在 100x100、50x50、25x25的 img 容器中,在 retina 屏幕下的显示效果


条码图,通过放大镜其实可以看出边界像素点取值的不同:




  • 图片1,就近取色,色值介于红白之间,偏淡,图片看上去可能会模糊(可以理解为图片拉伸)。




  • 图片2,没有就近取色,色值要么红,要么是白,看上去很清晰。




  • 图片3,就近取色,色值位于红白之间,偏重,图片看上去有色差,缺失锐利度(可以理解为图片挤压)。




要想大的位图 icon 缩小时保证显示质量,那就需要这样设置:


img {     
image-rendering:-moz-crisp-edges;    
image-rendering:-o-crisp-edges;    
image-rendering:-webkit-optimize-contrast;    
image-rendering: crisp-edges;    
-ms-interpolation-mode: nearest-neighbor;    
-webkit-font-smooting:  antialiased;
}

5. 总结


本文介绍了如何通过使用 html2Canvas 来打印高清图片,并解释了一些与图片有关的术语,包括英寸、像素、PPI 与 DPI、设备像素、分辨率等,同时逐步分析了 html2Canvas 打印高清图片的原理。



demo: github.com/jecyu/pdf-d…



参考资料


收起阅读 »

uni-app下App转微信小程序的操作经验

web
背景 就是老板觉得 app 比较难以开展,需要开发小程序版本方便用户引入; 个人觉得,我们的产品更偏向B端产品,需要公司整体入住,而不是散兵游勇的加入,没必要进行这样的引流,奈何我不是老板,那就干。 目前已经有二十几个页面及即时通信模块,已经可以稳定运行;...
继续阅读 »

背景



  1. 就是老板觉得 app 比较难以开展,需要开发小程序版本方便用户引入;

    1. 个人觉得,我们的产品更偏向B端产品,需要公司整体入住,而不是散兵游勇的加入,没必要进行这样的引流,奈何我不是老板,那就干。

    2. 目前已经有二十几个页面及即时通信模块,已经可以稳定运行;



  2. 后续新开发的功能要兼容到App和微信小程序;

  3. 同时还要按照新的ui进行修改页面样式。


关于APP代码转小程序的方案研究



  1. App的开发方案uni-app,本来就是留了兼容的方案的,但是目前有很多的业务,需要逐步测试优化;

  2. 原始开发过程一般以h5为基础,然后兼容app的各种版本;

  3. 开发过程,代码管理的考虑是需要切出一个新的打包小程序分支,这样对于基础的更新仍然在app端首先兼容开发,后续合并到具体的端开发分支上,然后做兼容问题处理,具体的分支如下:

    1. ft/base分支,仍旧以原本的App开发分支为准;

    2. ft/app分支,用做App的开发兼容测试;

      1. ft/app_android_qa,app的安卓端测试分支‘

      2. ...



    3. ft/mp分支,用做微信小程序开发兼容测试;




按着官方指导文档进行修改,对可预知的问题进行修改



  1. App正常,小程序、H5异常的可能性

    1. 代码中使用了App端特有的plus、Native.js、subNVue、原生插件等功能,如下的地点坐标获取功能;



  2. 微信小程序开发注意

    1. 这里一个很重要的问题,需要对原始的项目进行分包,不然是绝对不能提交发布的;




地点坐标获取功能


本次开发中的地理位置选择功能,在App下使用了原生的高德地图服务,在小程序下边就需要改成腾讯地图的位置选择服务uni.chooseLocation


高德地图、腾讯地图以及谷歌中国区地图使用的是GCJ-02坐标系,还好这两个使用的坐标系是一致的,否则就需要进行坐标的转换;


关联的bug报错:getLocation:fail the api need to be declared in the requiredPrivateInfos field in app.json/ext.json


// manifest.json,如下两个平台不需要同时配置
{
// App应用,使用高德地图
"sdkConfigs": {
"geolocation": {
"amap": {
"__platform__": ["ios", "android"],
"appkey_ios": "",
"appkey_android": ""
}
},
"maps": {
"amap": {
"appkey_ios": "",
"appkey_android": ""
}
},
},

// 在小程序下使用地图选择,使用腾讯地图
"mp-weixin": {
"permission": {
"scope.userLocation": {
"desc": "你的位置信息将用于小程序位置接口的效果展示"
}
},
// 这里的配置是有效的
"requiredPrivateInfos": ["getLocation", "chooseLocation"],
}
}


契约锁



  1. 契约锁,app下使用的是webview直接打开签订的合同;

  2. 但是在小程序,需要引用契约锁的小程序插件页面;


// App下打开webview进行操作
await navTo('/pages/common/webview');
export const navTo = (url, query) => {
if (query) {
url += `?query=${encodeURIComponent(JSON.stringify(query))}`;
}
return new Promise((resolve, reject) => {
uni.navigateTo({
url,
success: (res) => {
resolve(res);
},
fail: (e) => {
reject(e);
},
});
});
};

// 微信小程序下的处理方式
// 如下打开插件页面
const res = await wx.navigateTo({
url: `plugin://qyssdk-plugin/${pageType}?ticket=${ticket}&hasCb=true&env=${baseUrl.qys_mp_env}`,
success(res) {},
fail(e) {},
});

微信小程序分包



  1. 原始的App版本是在pages下边进行平铺的,没任何分包;

  2. 小程序每个分包不能大于2M,主包也不能大于2M,分包总体积不超过 20M;

  3. 在小程序下,分包:

    1. 主包,包括基础的一些配置,资源文件等,还要包括几个tab页面;

    2. 分包,按照业务模块进行划分:

      1. "root": "pages/authenticate"

      2. "root": "pages/team",

      3. "root": "pages/salary",

      4. "root": "pages/employ",

      5. ...





  4. 分包之后需要相应的修改页面跳转的地址,当前版本主要在pages.json里边进行划分,所以需要修改的跳转地址并不是很多;


压缩资源文件大小



  1. 对static目录进行整理;

    1. 压缩图片文件;

    2. 对于不着急展示的图片采用远端加载的方式;



  2. 删除不需要的资源,如一些不兼容微信端的组件、不再用的组件等;


视频模块nvue页面的重写



  1. 原本的组件不支持小程序,后续只能重新写这块;

  2. 删除原本的App视频模块nvue页面;


即时通信模块的业务修改



  1. 这块的核心是推送即时消息,在小程序下很容易收不到,最后的方案是做一个新的页面,去提示下载打开App操作;

  2. 删除原本的App即时通信所引入的各种资源文件;


整体ui的修改



  1. 修改基础的样式定义变量;

    1. 修改uni.scss文件,修改为新的ui风格;



  2. 对硬页面的ui逐步修改;


小程序的按需注入


小程序配置:lazyCodeLoading,在 mp-weixin 下边配置;


直接运行代码,对着bug进行逐步修改


在开发工具中运行,查看控制台以及小程序评分、优化体验等的提示进行。


Error: 暂不支持动态组件[undefined],Errors compiling template: :style 不支持 height: ${scrollHeight}px 语法


其实就是 style 的一种写法的问题,语法问题:


:style="{height: `${scrollHeight}px`}">


:style="`height: ${scrollHeight}px`" => :style="{height: `${scrollHeight}px`}"


http://test.XXX.com 不在以下 request 合法域名列表中


配置request合法域名的问题,参考文档:developers.weixin.qq.com/miniprogram…,添加后正常。


Unhandled promise rejection


当 Promise 的状态变为 rejection 时,我们没有正确处理,让其一直冒泡(propagation),直至被进程捕获。这个 Promise 就被称为 unhandled promise rejection。


Error: Compile failed at pages/message/components/Chat.vue






只能删除后使用v-if进行判断展示;


无效的 page.json ["titleNView"]


也就是这里的头信息不能支持这个配置,直接删除。


代码质量的问题 / 代码优化


common/vendor 过大的问题



  1. uni-app 微信小程序 vendor.js 过大的处理方式和分包优化

    1. 使用运行时代码压缩;

      1. HBuilder 直接开启压缩,但是这样会编译过程变慢;

      2. cli 创建的项目可以在 package.json 中添加参数–minimize





  2. vendor.js 过大的处理方式

    1. 开启压缩;

    2. 分包,对一些非主包引用的资源引用位置进行修改;




总结



  1. 方向很重要,预先的系统选型要多考虑以后的需要,不要太相信老板的话,可能开始说不要,后边就要了;

  2. uni-app框架下,兼容多端的修改还是容易处理的,一般只会发生几类问题,有时候看起来很严重,其实并不严重;


以上只是个人见解,请指教

作者:qiuwww
来源:juejin.cn/post/7255879340223184956

收起阅读 »

熟读代码简洁之道,为什么我还是选择屎山

web
前言 前几天我写了一篇Vue2屎山代码汇总,收到了很多人的关注;这说明代码简洁这仍然是一个程序员的基本素养,大家也都对屎山代码非常关注;但是关注归关注,执行起来却非常困难;我明明知道这段代码的最佳实践,但是我就是不那样写,因为我有很多难言之隐; 没有严格的卡口...
继续阅读 »

前言


前几天我写了一篇Vue2屎山代码汇总,收到了很多人的关注;这说明代码简洁这仍然是一个程序员的基本素养,大家也都对屎山代码非常关注;但是关注归关注,执行起来却非常困难;我明明知道这段代码的最佳实践,但是我就是不那样写,因为我有很多难言之隐;


没有严格的卡口


没有约束就没有行动,比方说eslint,eslint只能减少很少一部分屎山,而且如果不在打包机器上配置eslint的话那么eslint都可以被绕过;对我个人而言,实现一个需求,当然是写屎山代码要来的快一些,我写屎山代码能够6点准时下班,要是写最佳实践可能就要7点甚至8点下班了,没有人愿意为了代码整洁度而晚一点下班的。


没有CodeReview,CodeReview如果不通过会被打回重新修改,直到代码符合规范才能提交到git。CodeReview是一个很好地解决团队屎山代码的工具,只可惜它只是一个理想。因为实际情况是根本不可能有时间去做CodeReview,连基本需求都做不完,如果去跟老板申请一部分时间来做CodeReview,老板很有可能会对你进行灵魂三连问:你做为什么要做CodeReivew?CodeReview的价值是什么?有没有量化的指标?对于屎山代码的优化,对于开发体验、开发效率、维护成本方面,这些指标都非常难以衡量,它们对于业务没有直接的价值,只能间接地提高业务的开发效率,提高业务的稳定性,所以老板只注重结果,只需要你去实现这个需求,至于说代码怎么样他并不关心;


没有代码规约


大厂一般都有代码规约,比如:2021最新阿里代码规范(前端篇)百度代码规范


但是在小公司,一般都没有代码规范,也就是说代码都无章可循;这种环境助长了屎山代码的增加,到头来屎山堆得非常高了,之后再想去通过重构来优化这些屎山代码,这就非常费力了;所以要想优化屎山代码光靠个人自觉,光靠多读点书那是没有用的,也执行不下去,必须在团队内形成一个规范约定,制定规约宜早不宜迟


没有思考的时间


另外一个造成屎山代码的原因就是没时间;产品经理让我半天完成一个需求,老大说某个需求很紧急,要我两天内上线;在这种极限压缩时间的需求里面,确实没有时间去思考代码怎么写,能cv尽量cv;但是一旦养成习惯,即使后面有时间也不会去动脑思考了;我个人的建议是不要总是cv,还是要留一些时间去思考代码怎么写,至少在接到需求到写代码之前哪怕留个5分钟去思考,也胜过一看到需求差不多就直接cv;


框架约束太少


越是自由度高的框架越是容易写出屎山代码,因为很多东西不约束的话,代码就会不按照既定规则去写了;比如下面这个例子:
stackblitz.com/edit/vue-4a…


这个例子中父组件调用子组件,子组件又调用父组件,完全畅通无阻,完全可以不遵守单向数据流,这样的话为了省掉一部分父子组件通信的逻辑,就直接调用父组件或者子组件,当时为了完成需求我这么做了,事后我就后悔了,极易引起bug,比如说下一次这个需求要改到这一部分逻辑,我忘记了当初这个方法还被父组件调用,直接修改了它,于是就引发线上事故;最后自己绩效不好看,但是全是因为自己当初将父子组件之间耦合太深了;


自己需要明白一件事情那就是框架自由度越高,越需要注意每个api调用的方式,不能随便滥用;框架自由不自由这个我无法改变,我只能改变自己的习惯,那就是用每一个api之前思考一下这会给未来的维护带来什么困难;


没有代码质量管理平台


没有代码质量管理平台,你说我写的屎山,我还不承认,你说我代码写的不好,逻辑不清晰,我反问你有没有数据支撑


但是当代码质量成为上线前的一个关键指标时,每个人都不敢懈怠;常见的代码质量管理平台有SonarQubeDeepScan,这些工具能够继承到CI中,成为部署的一个关键环节,为代码质量保驾护航;代码的质量成为了一个量化指标,这样的话每个人的代码质量都清晰可见


最后


其实看到屎山代码,每一个人都应该感到庆幸,这说明有很多事情要做了,有很多基建可以开展起来;推动团队制定代码规约、开发eslint插件检查代码、为框架提供API约束或者部署一个代码质量管理平台,这一顿操作起

作者:蚂小蚁
来源:juejin.cn/post/7255686239756533818
来绩效想差都差不了;

收起阅读 »

如何给你的个人博客添加点赞功能

web
最近在重构博客,想要添加一些新功能。平时有看 Josh W. Comeau 的个人网站,他的每篇文章右侧都会有一个心形按钮,用户通过点击次数来表达对文章的喜爱程度。让我们来尝试实现这个有趣的点赞功能吧! 绘制点赞图标 点赞按钮的核心是 SVG 主要由两部分组...
继续阅读 »

最近在重构博客,想要添加一些新功能。平时有看 Josh W. Comeau 的个人网站,他的每篇文章右侧都会有一个心形按钮,用户通过点击次数来表达对文章的喜爱程度。让我们来尝试实现这个有趣的点赞功能吧!


image.png


绘制点赞图标


点赞按钮的核心是 SVG 主要由两部分组成:



  • 两个爱心形状 ❤️ 的 path ,一个为前景,一个为背景

  • 一个遮罩 mask ,引用 rect 作为遮罩区域


首先使用 defs 标签定义一个 id 为 heart 的爱心形状元素,在后续任何地方都可以使用 use 标签来复用这个 “组件”。


其次使用 mask 标签定义了一个 id 为 mask 的遮罩元素,通过 rect 标签设置了一个透明的矩形作为遮罩区域。


最后使用一个 use 标签引用了之前定义的 heart 图形元素作为默认的初始颜色,使用另一个 use 标签,同样引用 heart 图形元素,并使用 mask 属性引用了之前定义的遮罩元素,用于实现填充颜色的遮罩效果。


点赞动画


接下来实现随着点赞数量递增时爱心逐渐被填充的效果,我们可以借助 CSS 中 transfrom 的 translateY 属性来完成。设置最多点击次数(这里我设置为 5 次)通过 translateY 来移动遮罩的位置完成填充,也就是说,读者需要点击 5 次才能看到完整的红色爱心形状 ❤️ 的点赞按钮。


除此之外我们还可以为点赞按钮添加更有趣交互效果:



  1. 每次点击时右侧会出现『 +1 』字样

  2. 用户在点击第 3 次的时候,填充爱心形状 ❤️ 点赞按钮的同时,还会向四周随机扩散 mini 爱心 💗


这里可以用 framer-motion 来帮助我们实现动画效果。


animate([
...sparklesReset,
['button', { scale: 0.9 }, { duration: 0.1 }],
...sparklesAnimation,
['.counter-one', { y: -12, opacity: 0 }, { duration: 0.2 }],
['button', { scale: 1 }, { duration: 0.1, at: '<' }],
['.counter-one', { y: 0, opacity: 1 }, { duration: 0.2, at: '<' }],
['.counter-one', { y: -12, opacity: 0 }, { duration: 0.6 }],
...sparklesFadeOut,
])

这样就完成啦,使劲儿戳下面的代码片段试试效果:



数据持久化


想要让不同用户看到一致的点赞数据,我们需要借助数据库来保存每一个用户的点赞次数和该文章的总获赞次数。每当用户点击一次按钮,就会发送一次 POST 请求,将用户的 IP 地址和当前点赞的文章 ID (这里我使用的文章标题,可以替换为任意文章唯一标识) 存入数据库,同时返回当前的用户合计点赞次数和该文章的总获赞次数


export async function POST(req: NextRequest, { params }: ParamsProps) {
const res = await req.json()
const slug = params.slug
const count = Number(res.count)
const ip = getIP(req)
const sessionId = slug + '___' + ip

try {
const [post, user] = await Promise.all([
db.insert(pages)
.values({ slug, likes: count })
.onConflictDoUpdate({
target: pages.slug,
set: { likes: sql`pages.likes + ${count}` },
})
.returning({ likes: pages.likes }),
db.insert(users)
.values({ id: sessionId, likes: count })
.onConflictDoUpdate({
target: users.id,
set: { likes: sql`users.likes + ${count}` },
})
.returning({ likes: users.likes })
])
return NextResponse.json({
post_likes: post[0].likes || 0,
user_likes: user[0]?.likes || 0
});
} catch (error) {
return NextResponse.json({ error }, { status: 400 })
}
}

同理,当用户再次进入该页面时,发起 GET 请求,获取当前点赞状态并及时渲染到页面。


回顾总结


点赞功能在互联网应用中十分广泛,自己手动尝试实现这个功能还是挺有趣的。本文从三方面详细介绍了这一实现过程:



  • 绘制点赞图标:SVG 的各种属性应用

  • 点赞动画:framer-motion 动画库的使用

  • 数据持久化:数据库查询


如果这篇文章对你有帮助,记得点赞!


本文首发于我的个人网站 leonf

ong.me

收起阅读 »

我教你怎么在Vue3实现列表无限滚动,hook都给你写好了

web
先看成果 无限滚动列表 无限滚动列表(Infinite Scroll)是一种在网页或应用程序中加载和显示大量数据的技术。它通过在用户滚动到页面底部时动态加载更多内容,实现无缝的滚动体验,避免一次性加载所有数据而导致性能问题。供更流畅的用户体验。但需要注意在实...
继续阅读 »

先看成果


动画.gif

无限滚动列表


无限滚动列表(Infinite Scroll)是一种在网页或应用程序中加载和显示大量数据的技术。它通过在用户滚动到页面底部时动态加载更多内容,实现无缝的滚动体验,避免一次性加载所有数据而导致性能问题。供更流畅的用户体验。但需要注意在实现时,要考虑合适的加载阈值、数据加载的顺序和流畅度,以及处理加载错误或无更多数据的情况,下面我们用IntersectionObserver来实现无线滚动,并且在vue3+ts中封装成一个可用的hook


IntersectionObserver是什么



IntersectionObserver(交叉观察器)是一个Web API,用于有效地跟踪网页中元素在视口中的可见性。它提供了一种异步观察目标元素与祖先元素或视口之间交叉区域变化的方式。
IntersectionObserver的主要目的是确定一个元素何时进入或离开视口,或者与另一个元素相交。它在各种场景下非常有用,例如延迟加载图片或其他资源,实现无限滚动等。



这里用一个demo来做演示


动画.gif

demo代码如下,其实就是用IntersectionObserver来对某个元素做一个监听,通过siIntersecting属性来判断监听元素的显示和隐藏。


 const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('元素出现');
} else{
console.log('元素隐藏');
}
});
});
observer.observe(bottom);


无限滚动实现


下面我们开始动手


1.数据模拟


模拟获取数据,比如分页的数据,这里是模拟的表格滚动的数据,每次只加载十条,类似于平时的翻页效果,这里写的比较简单,
在这里给它加了一个最大限度30条,超过30条就不再继续增加了


<template>
<div ref="container" class="container">
<div v-for="item in list" class="box">{{ item.id }}</div>
</div>

</template>
<script setup lang="ts">

const list: any[] = reactive([]);
let idx = 0;

function getList() {
return new Promise((res) => {
if(idx<30){
for (let i = idx; i < idx + 10; i++) {
list.push({ id: i });
}
idx += 10
}
res(1);
});
</script>

2.hook实现


import { createVNode, render, Ref } from 'vue';
/**
接受一个列表函数、列表容器、底部样式
*/

export function useScroll() {
// 用ts定义传入的三个参数类型
async function init(fn:()=>Promise<any[] | unknown>,container:Ref) {
const res = await fn();
}
return { init }
}


执行init就相当于加载了第一次列表 后续通过滚动继续加载列表


import { useScroll } from "../hooks/useScroll.ts";
onMounted(() => {
const {init} = useScroll()
//三个参数分别是 加载分页的函数 放置数据的容器 结尾的提示dom
init(getList,container,bottom)
});

3.监听元素


export function useScroll() {
// 用ts定义传入的三个参数类型
async function init(fn:()=>Promise<any[] | unknown>,container:Ref,bottom?:HTMLDivElement) {
const res = await fn();
// 使用IntersectionObserver来监听bottom的出现
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
fn();
console.log('元素出现');
} else{
console.log('元素隐藏');

}
});
});
observer.observe(bottom);
}
return { init }
}

4.hook初始化


获取需要做无限滚动的容器 这里我们用ref的方式来直接获取到dom节点 大家也可以尝试下用getCurrentInstance这个api来获取到


整个实例,其实就是类似于vue2中的this.$refs.container来获取到dom节点容器


根据生命周期我们知道dom节点是在mounted中再挂载的,所以想要拿到dom节点,要在onMounted里面获取到,毕竟没挂载肯定是拿不到的嘛



const container = ref<HTMLElement | null>(null);
onMounted(() => {
const vnode = createVNode('div', { id: 'bottom',style:"color:#000" }, '到底了~');
render(vnode, container.value!);
const bottom = document.getElementById('bottom') as HTMLDivElement;
// 用到的是createVNode来生成虚拟节点 然后挂载到容器container中
const {init} = useScroll()
//三个参数分别是 加载分页的函数 放置数据的容器 结尾的提示dom
init(getList,container,bottom)
});

这部分代码是生成放到末尾的dom节点 封装的init方法可以自定义传入末尾的提示dom,也可以不传,封装的方法中有默认的dom


优化功能


1.自定义默认底部提示dom


async function init(fn:()=>Promise<any[] | unknown>,container:Ref,bottom?:HTMLDivElement) {
const res = await fn();
// 如果没有传入自定义的底部dom 那么就生成一个默认底部节点
if(!bottom){
const vnode = createVNode('div', { id: 'bottom',style:"color:#000" }, '已经到底啦~');
render(vnode, container.value!);
bottom = document.getElementById('bottom') as HTMLDivElement;
}
// 使用IntersectionObserver来监听bottom的出现
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
fn();
console.log('元素出现');
} else{
console.log('元素隐藏');

}
});
});
observer.observe(bottom);
}

完整代码


import { createVNode, render, Ref } from 'vue';
/**
接受一个列表函数、列表容器、底部样式
*/

export function useScroll() {
async function init(fn:()=>Promise<any[] | unknown>,container:Ref,bottom?:HTMLDivElement) {
const res = await fn();
// 生成一个默认底部节点
if(!bottom){
const vnode = createVNode('div', { id: 'bottom' }, '已经到底啦~');
render(vnode, container.value!);
bottom = document.getElementById('bottom') as HTMLDivElement;
}
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
fn();
}
});
});
observer.observe(bottom);
}
return { init }
}


<template>
<div ref="container" class="container">
<div v-for="item in list" class="box">{{ item.id }}</div>
</div>

</template>
<script setup lang="ts">
import { onMounted, createVNode, render, ref, reactive } from 'vue';
import { useScroll } from "../hooks/useScroll.ts";
const list: any[] = reactive([]);
let idx = 0;
function getList() {
return new Promise((res,rej) => {
if(idx<=30){
for (let i = idx; i < idx + 10; i++) {
list.push({ id: i });
}
idx += 10
res(1);
}
rej(0)
});
}

const container = ref<HTMLElement | null>(null);
onMounted(() => {
const vnode = createVNode('div', { id: 'bottom' }, '到底了~');
render(vnode, container.value!);
const bottom = document.getElementById('bottom') as HTMLDivElement;
const {init} = useScroll()
init(getList,container,bottom)
});

</script>
<style scoped>
.container {
border: 1px solid black;
width: 200px;
height: 100px;
overflow: overlay
}

.box {
height: 30px;
width: 100px;
background: red;
margin-bottom: 10px
}
</style>

作者:一只大加号
来源:juejin.cn/post/7255149657769066551
>
收起阅读 »

作为开发人员,如何一秒洞悉文件结构?

web
曾经在处理复杂的文件结构时感到束手无策吗?别担心,说一个真正的解决方案——JavaScript中的tree-node包。它能以一种惊人的方式展示文件和文件夹的层次结构,让你瞬间掌握复杂的项目布局。 背景 在一个新项目中,你可能会面对各种文件,包括HTML、CS...
继续阅读 »

b60632618f4042c9a5aed99a0d176157.jpeg


曾经在处理复杂的文件结构时感到束手无策吗?别担心,说一个真正的解决方案——JavaScript中的tree-node包。它能以一种惊人的方式展示文件和文件夹的层次结构,让你瞬间掌握复杂的项目布局。


背景


在一个新项目中,你可能会面对各种文件,包括HTML、CSS、JavaScript、配置文件等等。起初,你可能不清楚这些文件的具体作用和位置,感到无从下手。而随着项目的发展,文件数量可能会急剧增加,你可能会渐渐迷失在文件的迷宫中,忘记了某个文件的用途或者它们之间的关联。


正是在这样的背景下,tree-node包闪亮登场!它为你呈现出一个惊人的树状结构,展示了项目中各个文件和文件夹之间的层次关系。通过运行简单的命令,你就能立即获得一个清晰而易于理解的文件结构图。无论是文件的嵌套层级、文件之间的依赖关系,还是文件夹的组织结构,一目了然。


一键安装,瞬间拥有超能文件管理能力!


无需复杂的步骤或繁琐的设置,只需在命令提示符或终端中输入一行命令,即可全局安装tree-node包:


npm install -g tree-node-cli

震撼视觉展示


tree-node包不仅仅是文件管理工具,它能以惊人的树状结构展示方式,为你带来震撼的视觉体验。使用treee命令,它能够在屏幕上呈现令人惊叹的文件和文件夹布局。无论是开发项目还是设计项目,你都能一目了然地了解整个文件结构。


示例: 假设你的项目文件结构如下:


- src
- js
- app.js
- css
- styles.css
- theme.css
- index.html
- public
- images
- logo.png
- banner.png
- index.html
- README.md

通过执行以下命令:


treee -L 3 -I "node_modules|.idea|.git" -a --dirs-first

你将获得一个惊艳的展示结果:


.
├───src
│ ├───js
│ │ └───app.js
│ ├───css
│ │ ├───styles.css
│ │ └───theme.css
│ └───index.html
├───public
│ ├───images
│ │ ├───logo.png
│ │ └───banner.jpg
│ └───index.html
└───README.md

这个直观的展示方式帮助你迅速理解整个文件结构,无需手动遍历文件夹层级。你可以清楚地看到哪些文件和文件夹属于哪个层级,方便你快速导航和查找所需资源,你也可以在上面注释文件的作用。


自定义控制


tree-node包提供了强大的自定义功能,让你对文件结构拥有绝对掌控。只需重新执行treee命令,tree-node-cli会自动展示最新的文件结构。再通过设置参数,你可以控制显示的层级深度、忽略特定文件夹,并决定是否显示隐藏文件。


配置参数:


-V, --version             输出版本号
-a, --all-files 打印所有文件,包括隐藏文件
--dirs-first 目录在前,文件在后
-d, --dirs-only 仅列出目录
-I, --exclude [patterns] 排除与模式匹配的文件。用 | 隔开,用双引号包裹。 例如 “node_modules|.git”
-L, --max-depth <n> 目录树的最大显示深度
-r, --reverse 按反向字母顺序对输出进行排序
-F, --trailing-slash 为目录添加'/'
-h, --help 输出用法信息

例如,使用以下命令可以显示三级深度的文件结构,并排除node_modules、.idea、objects和.git文件夹,同时显示所有文件,包括以点开头的隐藏文件:(这几个配置是最常见的,我基本是直接复制粘贴拿来就用


treee -L 3 -I "node_modules|.idea|objects|.git" -a --dirs-first


  • -L 3:指定路径的级别为3级。

  • -I "node_modules|.idea|objects|.git":忽略文件夹(正则表达式匹配。.git会匹配到.gitignore)。

  • -a:显示所有文件(默认前缀有"."的不会显示,例如".bin")。

  • --dirs-first:目录在前,文件在后(默认是字母排序)。


tree-node-cli的自定义控制没有繁琐的配置和操作,只需几个简单的参数设置执行命令,你就能根据自己的需求,定制化你的文件展示方式。


灵活应对文件变动


tree-node-cli不仅可以帮助你展示当前的文件结构,还可以灵活应对文件的变动。当你新增或删除了JS文件时,只需重新执行treee命令,tree-node-cli会自动更新并展示最新的文件结构。


示例:
假设在项目中新增了一个名为utils.js的JavaScript文件。只需在终端中切换到项目文件夹路径,并执行以下命令:


treee -L 3 -I "node_modules|.idea|objects|.git" -a --dirs-first

tree-node-cli将重新扫描文件结构,并在展示中包含新添加的utils.js文件:


.
├───src
│ ├───js
│ │ ├───utils.js
│ │ └───app.js
│ ├───css
│ │ ├───styles.css
│ │ └───theme.css
│ └───index.html
├───public
│ ├───images
│ │ ├───logo.png
│ │ └───banner.jpg
│ └───index.html
└───README.md

同样,如果你删除了一个文件,tree-node-cli也会自动更新并将其从展示中移除。


总结


不管你是开发者、设计师还是任何需要处理复杂文件结构的人,tree-node包都将成为你的得力助手。它简化了文件管理手动操作过程,提供了震撼的视觉展示,让你能够轻松地理解和掌握项目的文件结构。你还有更好的文件管理方法吗,欢迎在评论区分享你对文件管理的更好方法,让我们共同探讨文件管理的最佳实践。


作者:Sailing
来源:juejin.cn/post/7255189463747280951
收起阅读 »

CSS实现0.5px的边框的两种方式

web
方式一 <style> .border { width: 200px; height: 200px; position: relative; } .border::before { content: ""; position: abs...
继续阅读 »

方式一


<style>
.border {
width: 200px;
height: 200px;
position: relative;
}
.border::before {
content: "";
position: absolute;
left:0;
top: 0;
width: 200%;
height: 200%;
border: 1px solid blue;
transform-origin: 0 0;
transform: scale(0.5);
}
</style>

<div class="border"></div>

方式二


<style>
.border {
width: 200px;
height: 200px;
position: relative;
}
.border::before {
position: absolute;
box-sizing: border-box;
content: " ";
pointer-events: none;
top: -50%;
right: -50%;
bottom: -50%;
left: -50%;
border: 1px solid blue;
transform: scale(0.5);
}
</style>

<div class="border"></div>
作者:很晚很晚了
来源:juejin.cn/post/7255147749360156730

收起阅读 »

基于 Tauri, 我写了一个 Markdown 桌面 App

web
本文视频地址 前言 大家好,我是小马。 去年,我开发了一款微信排版编辑器 MDX Editor。它可以自定义组件、样式,生成二维码,代码 Diff 高亮,并支持导出 Markdown 和 PDF 等功能。然而,作为一个微信排版编辑器,它的受众面比较有限,并不适...
继续阅读 »

本文视频地址


前言


大家好,我是小马。


去年,我开发了一款微信排版编辑器 MDX Editor。它可以自定义组件、样式,生成二维码,代码 Diff 高亮,并支持导出 Markdown 和 PDF 等功能。然而,作为一个微信排版编辑器,它的受众面比较有限,并不适用于每个人。因此,我基于该编辑器开发了 MDX Editor 桌面版,它支持 Mac、Windows 和 Linux,并且非常轻量,整个应用的大小只有 7M。现在,MDX Editor 桌面版已经成为我的创作工具。如果你对它感兴趣,可以在文末获取。


演示


技术选型


开发 MDX Editor 桌面 App,我使用了如下核心技术栈:




  • React (Next.js)




  • Tauri —— 构建跨平台桌面应用的开发框架




  • Tailwind CSS —— 原子类样式框架,支持深色皮肤




  • Ant Design v5 —— 使用"Tree"组件管理文档树




功能与实现


1. MDX 自定义组件


MDX 结合了 Markdown 和 JSX 的优点,它让你可以在 Markdown 文档中直接使用 React 组件,构建复杂的交互式文档。如果你熟悉 React,你可以在 "Config" 标签页中自定义你的组件;如果你不是一个程序员,你也可以基于现有模板进行创作。例如,模板中的 "Gallery" 组件实际上就是一个 "flex" 布局。


代码



function Gallery({children}) {

return <div className="flex gallery">

{children}

</div>


}


文档写作


预览效果


2. 深色皮肤


对于笔记软件来说,深色皮肤已经成为一个不可或缺的部分。MDX Editor 使用 Tailwind CSS 实现了深色皮肤。



3. 多主题


编辑器内置了 10+个文档主题和代码主题,你可以点击右上方的设置按钮进行切换。



4. 本地文件管理


桌面 App 还支持管理本地文件。你可以选择一个目录,或者将你的文档工作目录拖入编辑器,便能够实时地在编辑器中管理文档。



当我在开发这个功能之前,我曾担心自己不熟悉 Rust,无法完成这个功能。但是,熟悉了 Tauri 文档之后,我发现其实很简单。Tauri 提供了文件操作的 API,使得我们不需要编写 Rust 代码,只需要调用 Tauri API 就能完成文件管理。


import { readTextFile, BaseDirectory } from '@tauri-apps/api/fs';

// 读取路径为 `$APPCONFIG/app.conf` 的文本文件

const contents = await readTextFile('app.conf', { dir: BaseDirectory.AppConfig });


文档目录树采用了 Ant Design 的 Tree 组件实现,通过自定义样式使其与整体皮肤风格保持一致,这大大减少了编码工作量。


5. 文档格式化


在文档写作的过程中,格式往往会打断你的创作思路。虽然 Markdown 已经完全舍弃了格式操作,但有时你仍然需要注意中英文之间的空格、段落之间的空行等细节。MDX Editor 使用了 prettier 来格式化文档,只需按下 command+s 就能自动格式化文档。



最后


如果你对这个编辑器感兴趣,可以在 Github 下载桌面版体验。如果你对实现过程感兴趣,也可以直接查看源码。如果您有任何好的建议,可以在上面提出 Issues,或者关注微信公众号 "JS

作者:狂奔滴小马
来源:juejin.cn/post/7255189463746986039
酷" 并留言反馈。

收起阅读 »

用Echarts打造自己的天气预报!

web
前言 最近刚刚学习了Echarts的使用,于是想做一个小案例来巩固一下。项目效果如下图所示: 话不多说,开始进入实战。 创建项目 这里我们使用vue-cli来创建脚手架: vue create app 这里的app是你要创建的项目的名称,进入界面我们选择安装...
继续阅读 »

前言


最近刚刚学习了Echarts的使用,于是想做一个小案例来巩固一下。项目效果如下图所示:


0.png


话不多说,开始进入实战。


创建项目


这里我们使用vue-cli来创建脚手架:
vue create app


这里的app是你要创建的项目的名称,进入界面我们选择安装VueRouter,然后就可以开始进行开发啦。


页面自适应实现


我们这个项目实现了一个页面自适应的处理,实现方式很简单,我利用了一个第三方的库,可以将项目中的px动态的转化为rem,首先我们要安装一个第三方的库
npm i lib-flexible
安装完成后,我们需要在 main.js中引入
import 'lib-flexible/flexible'
还要在项目中添加一个配置文件postcss.config.js,文件内容如下:


module.exports = {
plugins: {
autoprefixer: {},
"postcss-pxtorem": {
"rootValue": 37.5,
"propList": ["*"]
}
}
}

上述代码是一个 PostCSS 的配置示例,用于自动添加 CSS 属性的前缀和将像素单位转换为 rem 单位。


其中



  • autoprefixer 是一个 PostCSS 插件,用于根据配置的浏览器兼容性自动添加 CSS 属性的前缀,以确保在不同浏览器中的兼容性。

  • postcss-pxtorem 是另一个 PostCSS 插件,用于将像素单位转换为 rem 单位,以实现页面在不同设备上的自适应效果。在上述配置中,rootValue 设置为 37.5,这意味着 1rem 会被转换为 37.5px。propList 设置为 ["*"] 表示所有属性都要进行转换。


这样,我们在项目中任何一个地方写px,都会动态的转化成为rem,由于rem是一个中相对于根元素字体大小的CSS单位,可以根据根元素的字体大小进行动态的调整,达到我们一个也买你自适应的目的。


实时时间效果实现


在项目的左上角有一个实时显示的时间,我们是如何做到的呢?首先我们在数据源中定义一个loalTime字段,用来装我们的时间,然后可以通过 new Date() 函数返回当前的时间对象,但这个对象我们是无法直接使用的,需要通过toLocaleTimeString() 函数处理,将 Date 对象转换为本地时间的格式化字符串。


methods{
getLocalTime() {
return new Date().toLocaleTimeString();
},
}

仅仅是这样的话,我们获取的时间是不会动的,怎么让他动起来呢,答案是使用定时器:


created() {
setInterval(() => {
this.localTime = this.getLocalTime();
}, 1000);
},

我们使用了一个setInterval定时器函数,让他每秒钟触发一次,然后将返回的时间赋值给我们的数据源中的localTime,同时将他放在created这个生命周期中,确保一开始就能运行,这样,我们就得到了一个可以随当前时间变化的时间。


省市选择组件实现


这个功能自己实现较为麻烦,我们选择使用第三方的组件库,这里我们选择的是Vant,这是一个轻量级,可靠的移动端组件库,我们首先需要安装他


npm i vant@latest-v2 -S


由于我们使用Vue2进行开发,所以需要指定其版本,然后就是导入所以有组件:


import Vant from 'vant'; 
import 'vant/lib/index.css';
Vue.use(Vant);

由于我们只是在本地开发,所以我们选择导入所有组件,在正式开发中可以选择按需引入来达到性能优化的目的。


准备工作完毕,导入我们需要的组件:


<van-popup v-model="show" position="bottom" :style="{ height: '30%' }">
<van-area
title="标题"
:area-list="areaList"
visible-item-count="4"
@cancel="show = false"
columns-num="2"
@confirm="selectCity"
/>

</van-popup>

这里我们通过show的值来控制的组件的显示与否,点击确认按钮后,会执行selectVCity方法,该方法会将我们选择的省市返回,格式为一个包含地区编码和地区名称的一个对象数组。


天气信息的获取


我们获取天气的信息主要依靠高德地图提供的api来实现,高德地图为我们提供了很多丰富的地图功能,包括了实时天气和天气预报功能,首先我们要注册一下,成为开发者,并获取自己的密钥和key。


最后在index.html中引入:


<script type="text/javascript">
window._AMapSecurityConfig = {
securityJsCode: '你的密钥',
}
</script>
<script type="text/javascript" src="https://webapi.amap.com/maps?v=2.0&key=你的key"></script>

就可以进行开发了。我们首先需要在项目开始加载的时候显示我们当地的信息,所以需要获取我们的当前所处环境的IP地址,所以高德也为我们提供了方法:


initMap() {
let that = this;
AMap.plugin("AMap.CitySearch", function () {
var citySearch = new AMap.CitySearch();
citySearch.getLocalCity(function (status, result) {
if (status === "complete" && result.info === "OK") {
// 查询成功,result即为当前所在城市信息
// console.log(result.city);
that.getWeatherData(result.city);
}
});
});
},

通过AMap.CitySearch插件我们可以很容易的获取到我们当前的IP地址,然后将我们获取到的IP地址传入到getWeatherData() 方法中去获取天气信息,需要注意的是,因为要求项目一启动就获取信息,所以这个方法也是需要放在created这个生命周期中的。然后就是获取天气信息的方法:


getWeatherData(cityName) {
let that = this;
AMap.plugin("AMap.Weather", function () {
//创建天气查询实例
var weather = new AMap.Weather();

//执行实时天气信息查询
weather.getLive(cityName, function (err, data) {
console.log(err, data);
that.mapData = data;
});

//执行实时天气信息查询
weather.getForecast(cityName, function (err, data) {
that.futureMapData = data.forecasts;
console.log(that.futureMapData);

// 每天的温度
that.seriesData = [];
that.seriesNightData = [];
data.forecasts.forEach((item) => {
that.seriesData.push(item.dayTemp);
that.seriesNightData.push(item.nightTemp);
});

that.$nextTick(() => {
that.initEchart();
});
});
});
},

通过这个方法,我们只需要传入城市名就可以很轻松的获取到我们需要的天气信息,并同步到我们的数据源中,然后将其渲染到页面中去。


数据可视化的实现


面对一堆枯燥的数据,我们很难提起兴趣,这时候,数据可视化的重要性就体现出来了,数据可视化是指使用图表、图形、地图、仪表盘等可视化工具将大量的数据转化为具有可读性和易于理解的图像形式的过程。通过数据可视化,可以直观地呈现数据之间的关系、趋势、模式和异常,从而帮助人们更好地理解和分析数据。


而Echarts就是这样一个基于 JavaScript 的开源可视化图表库,里面有非常多的图表类型可供我们使用,这里我们使用比较简单的折线统计图来展示数据。


首先也是安装依赖


npm i echarts


然后就是在项目中引入


import * as echarts from "echarts";


然后就可以进行开发啦,现在页面中准备好一个容器,方便承载我们的图表


<div class="echart-container" ref="echartContainer"></div>


然后就是根据我们获取到的数据进行绘制:


initEchart() {
// 基于准备好的dom,初始化echarts实例
let myChart = echarts.init(this.$refs.echartContainer);

// 绘制图表
let option = {
title: {
text: "ECharts 入门示例",
},
tooltip: {},
xAxis: {
data: ["今天", "明天", "后天", "三天后"],
axisTick: {
show: false,
},
axisLine: {
lineStyle: {
color: "#fff",
},
},
},
yAxis: {
min: "-10",
max: "50",
interval: 10,
axisLine: {
show: true,
lineStyle: {
color: "#fff",
},
},
splitLine: {
show: true,
lineStyle: {
type: "dashed",
color: ["red", "green", "yellow"],
},
},
},
series: [
{
name: "白天温度",
type: "line",
data: this.seriesData,
},
{
name: "夜间温度",
type: "line",
data: this.seriesNightData,
lineStyle: {
color: "red",
},
},
],
};
myChart.setOption(option);
},

一个图表中有非常多的属性可以控制它的不同形态,具体的不过多阐述,可以查看Echarts的参考文档,然后我们就得到一个非常美观的折线统计图。同时不能忘记和省市区选择器进行联动,当我们切换省市的时候,手动触发一次绘制,并且将我们选择的城市传入,这样,我们就得到了一个可以实时获取全国各地天气的小demo。


以上就是主要功能的具体实现方法:代码地址


作者:严辰
来源:juejin.cn/post/7255161684526940220
>欢迎大家和我交流!

收起阅读 »

通过调试技术,我理清了 b 站视频播放很快的原理

web
b 站视频播放的是很快的,基本是点哪就播放到哪。 而且如果你上次看到某个位置,下次会从那个位置继续播放。 那么问题来了:如果一个很大的视频,下载下来需要很久,怎么做到点哪个位置快速播放那个位置的视频呢? 前面写过一篇 range 请求的文章,也就是不下载资源的...
继续阅读 »

b 站视频播放的是很快的,基本是点哪就播放到哪。


而且如果你上次看到某个位置,下次会从那个位置继续播放。


那么问题来了:如果一个很大的视频,下载下来需要很久,怎么做到点哪个位置快速播放那个位置的视频呢?


前面写过一篇 range 请求的文章,也就是不下载资源的全部内容,只下载 range 对应的范围的部分。


那视频的快速播放,是不是也是基于 range 来实现的呢?


我们先复习下 range 请求:



请求的时候带上 range:



服务端会返回 206 状态码,还有 Content-Range 的 header 代表当前下载的是整个资源的哪一部分:



这里的 Content-Length 是当前内容的长度,而 Content-Range 里是资源总长度和当前资源的范围。


更多关于 Range 的介绍可以看这篇文章:基于 HTTP Range 实现文件分片并发下载!


那 b 站视频是不是用 Range 来实现的快速播放呢?


我们先在知乎的视频试一下:


随便打开一个视频页面,比如这个:



然后打开 devtools,刷新页面,拖动下进度条,可以看到确实有 206 的状态码:



我们可以在搜索框输入 status-code:206 把它过滤出来:



这是一种叫过滤器的技巧:



可以根据 method、domain、mime-type 等过滤。




  • has-response-header:过滤响应包含某个 header 的请求




  • method:根据 GET、POST 等请求方式过滤请求




  • domain: 根据域名过滤




  • status-code:过滤响应码是 xxx 的请求,比如 404、500 等




  • larger-than:过滤大小超过多少的请求,比如 100k,1M




  • mime-type:过滤某种 mime 类型的请求,比如 png、mp4、json、html 等




  • resource-type:根据请求分类来过滤,比如 document 文档请求,stylesheet 样式请求、fetch 请求,xhr 请求,preflight 预检请求




  • cookie-name:过滤带有某个名字的 cookie 的请求




当然,这些不需要记,输入一个 - 就会提示所有的过滤器:



但是这个减号之后要去掉,它是非的意思:



和右边的 invert 选项功能一样。


然后点开状态码为 206 的请求看一下:




确实,这是标准的 range 请求。


我点击进度条到后面的位置,可以看到发出了新的 range 请求:



那这些 range 请求有什么关系呢?


我们需要分析下 Content-Range,但是一个个点开看不直观。


这时候可以自定义显示的列:


右键单击列名,可以勾选展示的 header,不过这里面没有我们想要的 header,需要自定义:



点击 Manage Header Columns



添加自定义的 header,输入 Content-Range:



这时候就可以直观的看出这些 range 请求的范围之间的关系:



点击 Content-Range 这一列,升序排列。


我们刷新下页面,从头来试一下:


随着视频的播放,你会看到一个个 range 请求发出:



这些 range 请求是能连起来的,也就是说边播边下载后面的部分。


视频进度条这里的灰条也在更新:



当你直接点击后面的进度条:



观察下 range,是不是新下载的片段和前面不连续了?


也就是说会根据进度来计算出 range,再去请求。


那这个 range 是完全随意的么?


并不是。


我们当前点击的是 15:22 的位置:



我刷新下页面,点击 15:31 的位置:



如果是任意的 range,下载的部分应该和之前的不同吧。


但是你观察下两次的 range,都是 2097152-3145727


也就是说,视频分成多少段是提前就确定的,你点击进度条的时候,会计算出在哪个 range,然后下载对应 range 的视频片段来播放。


那有了这些视频片段,怎么播放呢?


浏览器有一个 SourceBuffer 的 api,我们在 MDN 看一下:



大概是这样用的:



也就是说,可以一部分一部分的下载视频片段,然后 append 上去。


拖动进度条的时候,可以把之前的部分删掉,再 append 新的:



我们验证下,搜索下代码里是否有 SourceBuffer:


按住 command + f 可以搜索请求内容:



可以看到搜索出 3 个结果。


在其中搜索下 SourceBuffer:



可以看到很多用到 SourceBuffer 的方法,基本可以确认就是基于 SourceBuffer 实现的。


也就是说,知乎视频是通过 range 来请求部分视频片段,通过 SourceBuffer 来动态播放这个片段,来实现的快速播放的目的。具体的分段是提前确定好的,会根据进度条来计算出下载哪个 range 的视频。


那服务端是不是也要分段存储这些视频呢?


确实,有这样一种叫做 m3u8 的视频格式,它的存储就是一个个片段 ts 文件来存储的,这样就可以一部分一部分下载。



不过知乎没用这种格式,还是 mp4 存储的,这种就需要根据 range 来读取部分文件内容来返回了:



再来看看 b 站,它也是用的 range 请求的方式来下载视频片段:



大概 600k 一个片段:


下载 600k 在现在的网速下需要多久?这样播放能不快么?


相比之下,知乎大概是 1M 一个片段:



网速不快的时候,体验肯定是不如 b 站的。


而且 b 站用的是一种叫做 m4s 的视频格式:



它和 m3u8 类似,也是分段存储的,这样提前分成不同的小文件,然后 range 请求不同的片段文件,速度自然会很快。


然后再 command + f 搜索下代码,同样是用的 SourceBuffer:



这样,我们就知道了为什么 b 站视频播放的那么快了:


m4s 分段存储视频,通过 range 请求动态下载某个视频片段,然后通过 SourceBuffer 来动态播放这个片段。


总结


我们分析了 b 站、知乎视频播放速度很快的原因。


结论是通过 range 动态请求视频的某个片段,然后通过 SourceBuffer 来动态播放这个片段。


这个 range 是提前确定好的,会根据进度条来计算下载哪个 range 的视频。


播放的时候,会边播边下载后面的 range,而调整进度的时候,也会从对应的 range 开始下载。


服务端存储这些视频片段的方式,b 站使用的 m4s,当然也可以用 m3u8,或者像知乎那样,动态读取 mp4 文件的部分内容返回。


除了结论之外,调试过程也是很重要的:


我们通过 status-code 的过滤器来过滤除了 206 状态码的请求。



通过自定义列在列表中直接显示了 Content-Range:



通过 command + f 搜索了响应的内容:



这篇文章就是对这些调试技巧的综合运用。


以后再看 b 站和知乎视频的时候,你会不会想起它是基于 range 来实现的分段下载和播放呢?



更多调试技术可以看我的调试小册《前端调试通关秘籍》


作者:zxg_神说要有光
来源:juejin.cn/post/7255110638154072120

收起阅读 »

环信的那些”已读“功能实现及问题解决

写在前面你在调用环信的消息回执时,是否有以下的烦恼1、发送了消息已读回执,为什么消息列表页的未读数没有发生变化?2、发送了消息已读回执,为什么消息漫游拉取不到已读状态?如果你有这些烦恼,那就继续往下看一些歧义在这之前,我们需要先来统一确定两件事情第一:消息列表...
继续阅读 »

写在前面
你在调用环信的消息回执时,是否有以下的烦恼
1、发送了消息已读回执,为什么消息列表页的未读数没有发生变化?
2、发送了消息已读回执,为什么消息漫游拉取不到已读状态?
如果你有这些烦恼,那就继续往下看

一些歧义
在这之前,我们需要先来统一确定两件事情
第一:消息列表页
第二:聊天页面
接下来以环信vuedemo为例,看一下这两者


如图所示,红色圈起来的部分为消息列表页也叫会话列表页面,可通过会话列表的api拉取。

绿色圈起来的部分为聊天页面,可通过消息漫游的api拉取

注:聊天页面的数据获取不是必须调用消息漫游api,也可以存在本地从本地进行获取,这个可根据自己项目的需求以及业务逻辑来做调整,本文以消息漫游中的数据为例
插播:会话是什么,当和一个用户或者在一个群中发消息后,就会自动把对方加到会话列表中,可以通过调用会话列表去查询。需要注意,1、此api调用有延迟,建议只有初次登录时通过此api获取到初始会话列表的数据,后续都在本地进行维护。2、登陆ID不要为大小写混用的ID,拉取会话列表大小写ID混用会出现拉取会话列表为空

解决问题一:
在明确了会话列表页和聊天页面各代指的部分之后,我们先来解决第一个问题:发送了消息已读回执,为什么会话列表的未读数没有变化
原因:对于环信来讲,消息是消息,会话是会话,这是两个概念,消息已读和会话已读并没有做联动,也就是消息已读只是对于这条消息而言并不会对会话列表的未读数产生影响,他们是两个独立的个体。会话列表的未读数是针对整个会话而言
那么如何清除会话列表的未读数呢?——需要发送会话已读回执也就是channel ack,这里还需要注意一点,sdk是只负责数据传输的,改变不了页面层的渲染逻辑。所以在发送完channel ack后页面上渲染的未读数不会无缘无故就清0了,是需要重新调用api渲染的!!!!!

channelAck() {
let option = {
chatType: "", // 会话类型,设置为单聊。
type: "channel", // 消息类型。固定参数固定值,不要动它
to: "", // 接收消息对象(用户 ID)。
};
let msg = WebIM.message.create(option);
WebIM.conn
.send(msg)
.then((res) => {
console.log("%c>>>>>>>>会话已读回执发送成功", "color:#6ad1c7", res);
})
.catch((e) => {
console.log("%c>>>>>>>>>会话已读回执发送失败", "color:#ef8784", e);
});
},


会话已读回执发送成功之后,接收方会收到onChannelMessage回调监听

conn.addEventHandler("customEvent", {
onChannelMessage: (message) => {},
});



消息已读回执是需要发送readack,是针对于某一条消息而言。这里也需要注意一点,sdk是只负责数据传输的,改变不了页面层的渲染逻辑,所以已读未读在页面上的渲染也是需要自己处理一下

readAck() {
let option = {
type: "read", // 消息是否已读。固定参数固定值,不要动它
chatType: "singleChat", // 会话类型,这里为单聊。
to: "", // 消息接收方(用户 ID)。
id: "", // 需要发送已读回执的消息 ID。
};
let msg = WebIM.message.create(option);
WebIM.conn
.send(msg)
.then((res) => {
console.log("%c>>>>>>>>消息已读回执发送成功", "color:#6ad1c7", res);
})
.catch((e) => {
console.log("%c>>>>>>>>>消息已读回执发送失败", "color:#ef8784", e);
});
},



消息已读回执发送成功之后,接收方会收到onReadMessage回调监听

conn.addEventHandler("customEvent", {
onReadMessage: (message) => {},
});




插播:会话列表未读数计算规则,简单理解,如果这个会话是单个用户在一直输出的话,这个未读数会一直累加,但是只要对方回了这条消息,那么未读数就会从这条消息之后开始再计算

 解决问题二:
再来看一下第二个问题:为什么消息漫游中拉取不到消息的已读状态
原因:环信服务器是不记录消息状态的,也就是不会记录这条消息是否已读了,所以不会返回消息已读或者未读
那么如何来实现
1、自己本地进行记录消息状态
2、可以使用环信sdk提供的reaction功能来间接是实现已读未读

reaction实现已读未读简单示例

addReaction() {
WebIM.conn
.addReaction(
{
messageId: "",//消息ID
reaction: "read" //reaction
}
)
.then((res) => {
console.log("%c>>>>>>>>reaction添加成功", "color:#6ad1c7", res);
})
.catch((e) => {
console.log("%c>>>>>>>>>reaction添加失败", "color:#ef8784", e);
});
},






总结Q&A
Q:发送了消息已读回执,为什么消息列表页的未读数没有发生变化?
A:会话和消息是两个概念,会话已读是会话已读,消息已读是消息已读,消息已读无法改变会话列表的数据
Q:发送了消息已读回执,为什么消息漫游拉取不到已读状态?
A:环信的服务器不记录消息状态,需要自己本地存储或者使用reaction功能间接实现

收起阅读 »

小程序自定义导航栏

web
小程序布局 谈到导航栏与自定义导航栏,就需要解释一下微信小程序的布局了。在小程序开发中使用wx.getSystemInfoAsync() 方法可以获取到系统信息。 部分获取到的信息如上图(截取自微信小程序开发者文档),对我们理解布局有用的信息是以上...
继续阅读 »

小程序布局




  • 谈到导航栏与自定义导航栏,就需要解释一下微信小程序的布局了。在小程序开发中使用wx.getSystemInfoAsync() 方法可以获取到系统信息。


    image.png


    image.png




  • 部分获取到的信息如上图(截取自微信小程序开发者文档),对我们理解布局有用的信息是以上跟宽度高度相关的属性,如当前设备的屏幕高宽,可用高宽,以及saveArea





  • 上图展示我们从systemInfo获取到的数据的实际表现,以苹果X的刘海屏为例(所有安卓刘海屏原理类似):最外层的红色框即屏幕大小,蓝色框即安全区域字面意思也就是开发者所能操纵的页面区域,上面的黄色框即手机的状态栏,绿色区域即我们要自定义的navigationBar




  • 可见,导航栏紧贴safeArea的上部,如果使用原生导航栏,导航栏下方即是真正意义的可操控范围。




  • 实际上我们自定义的导航栏也是在这个safeArea内与胶囊对齐最为和谐。很关键的原因就是微信将右上角的胶囊按钮作为了内置组件,只有黑白两种颜色,即我们无法改变它的大小位置透明度等等,所以为了配合胶囊按钮,一般自定义的导航栏位置也与上图位置一致。




自定义navigationBar怎么做?


去掉原生导航栏。



  1. 将需要自定义navigationBar页面的page.json的navigationBarTitleText去掉。

  2. 加上 "navigationStyle":"custom" ,这样原生的导航栏就已经消失,甚至后退键也不会出现需要自定义。

  3. 另外,早在2016年微信已经开始适配沉浸式状态栏,目前几乎所有的机型里微信都是沉浸式状态栏,也就是说去掉原生导航栏的同时,整个屏幕已经成为可编程区域


计算navigationBarHeight。



  • 原生的胶囊按钮当然存在,那么下一步就需要你去定位出自定义的导航栏高度以及位置。

  • 对于不同的机型,对于不同的系统,状态栏以及胶囊按钮的位置都不确定,所以需要用到一定的计算,从而面对任何机型都可以从容判定。




  1. 使用wx.getSystemInfoSync() 获取到statusBarHeight,这样就确定了导航栏最基本的距离屏幕上方的距离。




  2. 使用wx.getMenuButtonBoundingClientRect() 获取到小程序的胶囊信息(注意这个api存在各种问题,在不同端表现不一致,后面会叙述这个api调用失败的处理情况),如下图,以下坐标信息以屏幕左上角为原点。





  3. 以下图为例,上面的红色框是statusBar,高度已知;下面的红色框是正文内容,夹在中间的就是求解之一navigationBarHeight;而黄色的是原生胶囊按钮也是在垂直居中位置,高度为胶囊按钮基于左上角的坐标信息已知,不难得出,navigationBarHeight = 蓝色框高度 × 2 + 胶囊按钮.height。(蓝色框高度 = 胶囊按钮.top - statusBarHeight






  1. 最后的计算公式为:navigationBarHeight = (胶囊按钮.top - statusBarHeight) × 2 + 胶囊按钮.height。navigationBar 距屏幕上方的距离即为navigationBarHeight

  2. 这种计算方法在各种机型以及安卓ios都适用。

  3. 针对"wx.getMenuButtonBoundingClientRect() "获取错误或者获取数据为0的极少数情况,只能够去模拟,对于android,一般navigationBarHeight为48px,而对于ios一般为40px,所有机型的胶囊按钮高度是32px。



代码实现



  • 获取本机信息,写在组件的attached生命周期中。


// components/Navigation/index.js
Component({
/**
* 组件的属性列表
*/

properties: {

},

/**
* 组件的初始数据
*/

data: {
navigationBarHeight: 40,
statusBarHeight:20,
},

/**
* 组件的方法列表
*/

methods: {

},
lifetimes: {
attached: function () {
const { statusBarHeight, platform } = wx.getSystemInfoSync();
const { top, height = 32 } = wx.getMenuButtonBoundingClientRect();// 胶囊按钮高度 一般是32 如果获取不到就使用32
// 判断胶囊按钮信息是否成功获取
if (top && top !== 0 && height && height !== 0) {
//获取成功进行计算
const navigationBarHeight = (top - statusBarHeight) * 2 + height;
console.log(navigationBarHeight)
// 导航栏高度
this.setData({
navigationBarHeight,
statusBarHeight
})
} else {
//获取失败使用默认的高度
this.setData({
navigationBarHeight: platform === "android" ? 48 : 40,
statusBarHeight
})
}
}
}
})



  • 组件模板编写


<view class="custom-nav" style="height: {{navigationBarHeight}}px;margin-top:{{statusBarHeight}}px;">
<view>
<image style="width: 40rpx;height:40rpx;" src="/images/location.svg" mode="" />
</view>
</view>


 .navigationBar.wxml 样式如下:


.custom-nav{
background-color:palegoldenrod;
display: flex;
align-items: center;
}
.custom-nav__title{
margin:auto
}

外部页面引用该组件如下,


.json文件,引入组件


{
"usingComponents": {
"my-navigation":"/components/Navigation"
},
"navigationStyle": "custom"
}

注意添加属性:"navigationStyle":"custom"  代表我们要自定义组件


.wxml代码如下:


<view>
<my-navigation></my-navigation>
<view class="page-container" style="background-color: rebeccapurple;">这里是页面内容</view>
</view>


最终效果
image.png


如果想要编写更加通用的组件,可以根据需求定义传入的参数和样式


参考链接


http://www.cnblogs.com/chenwo

作者:let_code
来源:juejin.cn/post/7254812719349858361
long/…

收起阅读 »

Progress 圆形进度条 实现

web
效果图 实现过程分析 简要说明 本文主要以 TypeScript + React 为例进行讲解, 但相关知识和这个关系不大. 不会也不影响阅读 dome 中使用到了 sass, 但用法相对简单, 不影响理解 HTML DOM 元素说明 <div c...
继续阅读 »

效果图



实现过程分析


简要说明



  • 本文主要以 TypeScript + React 为例进行讲解, 但相关知识和这个关系不大. 不会也不影响阅读

  • dome 中使用到了 sass, 但用法相对简单, 不影响理解


HTML DOM 元素说明


<div className="g-progress-wrap">
<div className="g-progress"></div>
<div className="g-circle">
<span className="g-circle-before"><i/></span>
<span className="g-circle-after"><i/></span>
</div>
<div className="g-text">
20%
</div>
</div>


  • g-progress-wrap 包裹 progress, 所有的内容都在这里面

  • g-progress 主要的区域

  • 为了保证圆环有圆角效果 g-circle 内的有 2 个小圆, 放置到圆环的开始和结尾

  • g-text 放置文字区域



上面已经介绍了 html, 因为主要的处理都在css, 所以接下来只说 css



第一步, 实现一个圆


.g-progress {
width: 100px;
height: 100px;
border-radius: 50%;
background: conic-gradient(#1677ff 0, #1677ff 108deg, #eeeeee 108deg, #eeeeee 360deg);
}

image.png




  • border-radius: 50%; 实现圆形




  • 使用 background 实现背景颜色



    • conic-gradient 创建了一个由渐变组成的图像,渐变的颜色变换围绕一个中心点旋转

    • 当角度为 0 - 108deg 时, 颜色为: #1677ff; 当角度为 108deg - 360deg 时, 颜色为: #eeeeee;




第二步, 实现圆环效果


.g-progress {
/* 新增代码 */
/* mask: radial-gradient(transparent, transparent 44px, #000 44.5px, #000 100%); */
-webkit-mask: radial-gradient(transparent, transparent 44px, #000 44.5px, #000 100%);
}

image.png




  • 通过使用 mask属性, 隐藏 中间区域的显示




  • radial-gradient 创建一个图像,该图像由从原点辐射的两种或多种颜色之间的渐进过渡组成



    • 当为 0 - 44px 时, 颜色为: transparent; 当为 44px - 100% 时, 颜色为: #000;

    • 设置为 transparent 时, transparent 的区域的颜色会被隐藏




  • 为什么不使用元素覆盖, 使用中间区域的隐藏



    • 如果用元素覆盖实现的话, 如果需要显示父级的背景色时, 没办法实现




第三步, 实现圆环的圆角效果


.g-circle {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
transform: rotate(-90deg);
&>span {
position: absolute;
top: 47px;
left: 50px;
width: 50%;
transform-origin: left;
&>i {
width: 3px;
height: 3px;
float: right;
border-radius: 50%;
background: #1677ff;
z-index: 1;
}
}
& .g-circle-after {
transform: rotate(0deg);
}
}

image.png


第四步, 文字效果处理


.g-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 16px;
color: #666666;
}

image.png


第五步, 进度变化时, 通过js更新


通过行内样式更新 rotate 的方式即可更新进度


参考文档


developer.mozilla.org/zh-CN/docs/…


developer.mozilla.org/zh-CN/docs/…


http://www.cnblogs.com/coco1s

作者:洲_
来源:juejin.cn/post/7254450297467781176
/p/15…

收起阅读 »

记录一次小程序开发中的各种奇葩bug

web
前段时间,跟好哥们儿商量了一下,帮他公司设计并开发一款宣传用的小程序。因为是宣传用的,所以对于后台数据几乎没什么需求,只需要用到一些接口,引导用户联系公司,且公司性质是古建筑装修,没有自己的服务器。所以我直接给他做的是个静态的小程序。 微信小程序的开发需要注意...
继续阅读 »

前段时间,跟好哥们儿商量了一下,帮他公司设计并开发一款宣传用的小程序。因为是宣传用的,所以对于后台数据几乎没什么需求,只需要用到一些接口,引导用户联系公司,且公司性质是古建筑装修,没有自己的服务器。所以我直接给他做的是个静态的小程序。


微信小程序的开发需要注意几个点:


1、主包不大于2M,分包不超过20M。
图片、视频等文件很容易占据大量空间,因此,作为没有服务器的静态页面,这些图片、视频资源,放在什么地方,然后再拿到网络链接地址,是非常关键的节省空间的方案。


2、微信小程序开发者工具,众所周知经常发神经,
莫名其妙弹出一些报错,也会有一些不兼容情况,其中的一些组件也是经常出现问题,比如媒体组件莫名其妙报“渲染层网络错误”的err。


在这次的miniProgram中,有一些功能的实现中,触发了各种奇怪bug。比如
自定义tabbar,
为了让tabbar能被自定义定制,我几乎把整个关于tabbar的开发文档读了个通透;而在定制之后又发现,
pc端模拟机上正常显示、真机预览正常显示,唯独真机调试中,tabbar不显示。
也不是不显示,我的小米8手机不显示,我两位朋友的iphone,一个显示一个不显示(过程中所有的配置是完全相同的)。


接下来就详细介绍一下我在开发中遇到的几个让我把头皮薅到锃亮的问题。


1、自定义tabbar组件


微信小程序app.json中可以直接配置tabbar。但默认的tabbar组件
不足以完全应付各类不尽相同的场景。


譬如,默认的tabbar上使用的icon
实际是png等格式的图片
而非iconfont,其大小也完全由图片本身大小决定,
无法通过css自定制。


为了解决不同业务需求,小程序也非常人性化的
允许tabbar自定义。
其方法如下:


1、在app.json的tabbar配置中,加上custom:true

2、原本的tabbar配置项必须写完整。

在custom:true之后,tabbar的所有样式皆由自定义组件控制(颜色等),但路径等需要填写正确,否则会报错路径找不到。如配置项中必须的属性不写完整,会导致报错,告诉你缺少必须的配置项属性,也不会解析出来。


    "custom": true,                                                  //自定义tabbar开启
"color": "#c7c7c7", //常态下文字颜色
"selectedColor": "#056f60", //被选中时文字颜色
"list": [
{
"iconPath": "images/tabBarIcon/index.png", //常态下icon图片的路径
"selectedIconPath": "images/tabBarIcon/index-action.png", //被选中时icon图片的路径
"text": "首页展览", //icon图片下的文字
"pagePath": "pages/index/index" //该tabbar对应的路由路径
},
{
"iconPath": "images/tabBarIcon/cases.png",
"selectedIconPath": "images/tabBarIcon/cases-action.png",
"text": "精选案例",
"pagePath": "pages/cases/cases"
},
{
"iconPath": "images/tabBarIcon/about.png",
"selectedIconPath": "images/tabBarIcon/about-action.png",
"text": "关于我们",
"pagePath": "pages/about/about"
},
{
"iconPath": "images/tabBarIcon/contact.png",
"selectedIconPath": "images/tabBarIcon/contact-action.png",
"text": "联系我们",
"pagePath": "pages/contact/contact"
}
]
},

3、创建一个自定义组件文件夹custom-tab-bar。

级别为component组件级别。里面包含一个微信小程序包必须的wxml、wxss、js、json文件。


在这里我使用了vant weapp组件库做的tabbar组件。组件上的icon用的是字节跳动的fontPark字体图标库。


<!-- components/tabBar/tabBar.wxml -->
<!-- active用于控制被选定的item -->
<van-tabbar class="tabbar"
active="{{ active }}"
inactive-color="#b5b5b5"
active-color="#056f60"
bind:change="onChange"
>
<van-tabbar-item class="tabbarItem"
wx:for="{{list}}" wx:key="id">
<view class="main">
<image class="selectedIcon"
src="{{item.selectedIconPath}}"
wx:if="{{item.id === active}}"
mode=""
/>
<image src="{{item.iconPath}}" wx:else mode="" class="icon"/>
<text class="txt">{{item.text}}</text>
</view>
</van-tabbar-item>
</van-tabbar>

/* components/tabBar/tabBar.wxss */
.main{
display: flex;
flex-direction: column;
justify-content:center;
align-items: center;

}
.tabbarItem{
background-color: #e9e9e9;
}
.selectedIcon, .icon{
width: 40rpx;
height: 40rpx;
margin-bottom: 10rpx;
}

Component({
data:{
active:0, //用来找到被选中的tabbar-Item
list:[
{
id:0,
iconPath: "/images/tabBarIcon/index.png", //iconPath这些地址换成自己的地
selectedIconPath:"/images/tabBarIcon/index-action.png", // 址,如果需要用icon图表,在
text:"首页展览", // vant中有说明如何在vant组件
pagePath:"pages/index/index" // 中集成vant以外的字体图标。
}, // 就是因为感觉太麻烦了,所以我
{ // 没有用icon图表,还是使用png
id:1,
iconPath: "/images/tabBarIcon/cases.png",
selectedIconPath:"/images/tabBarIcon/cases-action.png",
text:"精选案例",
pagePath:"pages/cases/cases"
},
{
id:2,
iconPath: "/images/tabBarIcon/about.png",
selectedIconPath:"/images/tabBarIcon/about-action.png",
text:"关于我们",
pagePath:"pages/about/about"
},
{
id:3,
iconPath: "/images/tabBarIcon/contact.png",
selectedIconPath:"/images/tabBarIcon/contact-action.png",
text:"联系我们",
pagePath:"pages/contact/contact"
}
]
},
computed:{

},
methods:{
//点击了tabbar的item后,拿到event.detail的值,根据值再进行路由跳转。
//需要注意的是,navigateTo、redirectTo的跳转方式不能跳到 tabbar 页面,
//reLaunch总是会关闭掉之前打开过的所有页,导致页面回退会直接退出小程序
//所以在此使用switchTab,跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面
onChange(event){

if(event.detail===0){
wx.switchTab({
url: '/pages/index/index',
})
}else if(event.detail===1){
wx.switchTab({
url: '/pages/cases/cases',
})
}else if(event.detail===2){
wx.switchTab({
url: '/pages/about/about',
})
}else if(event.detail===3){
wx.switchTab({
url: '/pages/contact/contact',
})
}
}

},
})

到这里完成了页面跳转功能。但会发现,当我们点击其他页面的tab时,并
没有让tabbar的图表发生变化,
始终在首页被选定。
这是因为data中的active并没有发生变化,依然是active:0


那么要解决这个问题,方案是在每个tabbar路由页面的js文件中,修改active的值。比如,当点击首页时,active=0,点击第二个页面cases时,active=1......以此类推。


//pages/index/index.js
Page({
onShow() {
if (typeof this.getTabBar === 'function' &&
this.getTabBar()) {
this.getTabBar().setData({
active: 0
})
}
}
})


//pages/cases/cases.js
Page({
onShow() {
//在自定义tabbar组件的情况下,即app.json中的tabbar配置项中,custom为true时,会提供一个api接口,
//this.getTabBar(),用于获取到tabbar组件,
//可以通过this.getTabBar().setData({})修改tabbar组件内的数据。
if (typeof this.getTabBar === 'function' &&
this.getTabBar()) {
this.getTabBar().setData({
active: 0
})
}
}
})

//......其他页面以此类推

直到这一步,整个自定义的tabbar组件算是完成。


出现过的BUG




  1. 因为tabbar在app.json文件中"tabbar"配置项配置过了,所以不用再在app.json中的usingComponent配置项进行引用。也无需在tabbar的路由页面的json文件中进行页面配置。




  2. 我曾在onChange(event){}方法中,添加了一行代码:this.setData({active: event.detail });
    57a5d6c616684605f393b89f49d07bf.png




这段代码在没有注释掉的时候,会导致组件在页面切换时发生跳动,处于一种混乱的状态。其原因大致是因为这行代码与page页onshow()时期的getTabBar().setData()有同样的active赋值效果,所以冲突,造成组件闪烁。



  1. 在整个项目完成后,我在使用真机调试时意外发现,模拟机上的tabbar正常显示并使用,但手机上却消失不见。


PC端:


7cb36075fc28472251777b33c085d0f.png


安卓mi8:


安卓.png


我找了很多帖子,没有发现能解决我问题的方案。然后我就问了前辈。前辈的手机是苹果系统,无论是预览、调试,都可以正常显示并使用tabbar,告知我可能是我手机问题,或许是我的手机有什么权限没开。


我又找到一位用苹果手机的同事。如果这位同事的手机也能正常使用,我就要再找一个安卓机的伙伴再测试一次,看看是否机型对代码有影响。


结果奇怪的是,我的这位朋友在进行真机调试时,也没有正常显示tabbar组件。


那么结果就不是安卓和苹果的系统问题。肯定与代码或者某种权限有关。


于是我花了两三个小时去一点点修改,一遍遍重复调试,直到终于找到问题关键所在:


1688803520185.png


这是微信小程序开发者工具中的详情界面,在本地设置中,有一个
启用条件编译
选项。把这个选项开启,tabbar就显示了;关掉这个选项,tabbar就消失了。


于是我开始搜索启用条件编译是什么意思:


2066f1267092e1b9c8c3570069f4cca.png


这是最后找到的结果。但是我并不明白为什么勾选这个会对tabbar有影响。都没有勾选的情况下,前辈的苹果手机就有显示,另一位同事的苹果手机又没有显示,而安卓机的我也一样没有显示。


如果有哪位大佬明白其中的原理,请一定要留言告诉我!!!


2、地图系统


地图系统应该是非常常见的功能,如果在公司的宣传类小程序中加入地图系统,会非常便于用户获取地址信息。


地图系统使用很简单,可以说没太大难度。只要给个map容器,然后给上必须的键值对:经(longitude)纬(latitude)度,如果需要,再给个scale,限制地图缩放的级别,其他的都可以在腾讯地图api的文档中查找需要用的属性。


如果小程序中地图没显示,就要去腾讯地图开放平台里面看看。因为这些地图系统的api都是需要密钥才能使用,所以
注册
api开放平台的账户是第一步,然后在上面的开发文档中选择微信小程序SDK中可以查阅文档。在右上角登录旁边有个控制台,里面创建一个实例,把自己的小程序appID填进去,这个时候小程序中的map应该就是可以正常显示并使用了。


如果需要在小程序的地图中加入标记点,就在map中加入markers,js中传入Obj obj格式的参数,就可以了,在腾讯地图的文档内也有。


地图系统并不难,只需要按照api规则来即可。


<map
longitude="不便展示"
latitude="不便展示"
scale="16"
markers="{{markers}}"
enable-zoom="{{false}}"
enable-scroll="{{false}}"
enable-satellite
style="width: 100%;"
/>

//以下键值对中的value,不加引号为数字类型数据,加引号为字符串类型数据。
Page({
data: {
markers: [{
id: 1, //标记点 id
longitude: 不便展示,
latitude: 不便展示,
iconPath: '/images/local.png',
height: 20,
width: 20,
title: '不便展示',
}],
},

openMap() {
//wx.openLocation()是地图功能的api,在调用该方法时,会跳转到地图
wx.openLocation({
longitude: 不便展示,
latitude: 不便展示,
scale: 18,
name: '不便展示', // 终点名称
});
}
})


3、奇奇怪怪的位置用swiper


一般而言swiper都会用在首页,用以承载轮播图。


不得不说,微信小程序自带的swiper组件虽然简单,但是好用,放上去之后加点属性和数据就可以直接用,比起bug频出的swiper插件还是舒服些。


但是swiper组件就不能用在其他地方吗?


当然可以咯,只要愿意,你就是把许多个业务员的名片用一个swiper组件去收纳,用户不嫌麻烦去一个一个翻的话,你就做呗!


这里,我在精选案例中用了两个swipwe,用来承载相册。


image.png


如图所示,这是两个swiper正在进行滚动动画。


当时在做这个时候,觉得那么多照片正好可以分成两类,一类是成品,一类是原料,让用户可以分类查看。但是我又不想让用户在看到两个相册时,觉得成品和材料就只有一张照片。一想,用swiper正好可以解决这个问题:


让用户看到轮播滚动的图片,每张图片存在时间不长,用户就会想点击放大的图片来延长查看时间,正好落入圈套,进入相册,看到所有图片。


首先是准备了两个view容器,然后在容器中放进swiper,对swiper进行for循环。这整个过程不难,循规蹈矩。但是有个难点,直到项目做完我也没能找到方案:


现在是两个view容器装了两套swiper,如果有更多的swiper,需要更多的view容器,假定数据一次性发过来,怎么样可以循环view的同时,将swiper里面的item也循环?


大概的样子就是:


<view wx:for="{{list1}}">
<swiper>
<swiper-item wx:for="{{item.list}}" wx:for-item="items">
<image src="{{items.src}}" />
<swiper-item>
</swiper>
</view>

数据结构大概是:


    data:{
list1:[
{list:[{title:"111",src:""},{title:"222",src:""},{title:"333",src:""},]},
{list:[{title:"444",src:""},{title:"555",src:""},{title:"666",src:""},]},
{list:[{title:"777",src:""},{title:"888",src:""},{title:"999",src:""},]},
{list:[{title:"aaa",src:""},{title:"bbb",src:""},{title:"ccc",src:""},]},
]
}

上面的代码在循环中肯定出现问题,但是我目前没有找到对应的方法解决。


4、总是有报错渲染层网络层出错


24ec761eb4c272a88fd96edda27bfac.png


这个问题我相信写小程序的应该都遇到过。目前我没找到什么有效解决方案。在社区看到说清除网络缓存。但是在下一次编译时又会出现。如果每次都要清除缓存,好像并不算是个解决问题的方案。


好在这个错误并不影响整体功能,我就

作者:NuLL
来源:juejin.cn/post/7254066710369763388
没有去做任何处理了。

收起阅读 »

搭建适用于公司内部的脚手架

web
前言 公司项目多了,且后续会增加更多项目,为了避免每次创建项目都是重复的copy,这里可以自己写一个适合公司的脚手架,就跟 vue-cli, create-react-app 类似。 简单描述下原理:首先你需要准备一个模板,这个模板可以存储在公司的git上,然...
继续阅读 »

前言


公司项目多了,且后续会增加更多项目,为了避免每次创建项目都是重复的copy,这里可以自己写一个适合公司的脚手架,就跟 vue-clicreate-react-app 类似。


简单描述下原理:首先你需要准备一个模板,这个模板可以存储在公司的git上,然后根据用户选择决定采用哪个分支。比如我们就有 h5模板web模板 两个分支。


然后这些模板会有一些我们自定义的特殊字符,让用户可以根据输入的内容替换。比如我在模板那边里有定义了 $$PROJECT_NAME$$ 这个特殊字符,通过命令行交互让用户输入创建的项目名: test-project ,最后我就通过node去遍历模板里的文件,找到这个字符,将 $$PROJECT_NAME$$ 替换成 test-project 即可。根据公司需求自己事先定义好一些特殊变量即可,主要用到的就是下面几个库。


package.json 里的 bin 字段


用于执行 可执行文件 ,当使用 npm 或 yarn 命令安装时,如果发现包里有该字段,那么会在 node_modules 目录下的 .bin 目录中复制 bin 字段链接的可执行文件,我们在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。




bin 文件里的 #! 含义


#! 符号的名称叫 Shebang,用于指定脚本的解释程序。


/usr/bin/env node 表示 系统可以在 PATH 目录中查找 node 程序


如果报错,说明没有在 PATH 中找到 node




npm link


npm link (组件库里用来在本地调试用的)是将整个目录链接到全局node_modules 中,如果有 bin 那么则会生成全局的可执行命令


npm link xxx (本地测试项目里使用), xxx 为 那个库的 package.jsonname。 是让你在本地测试项目中可以使用 xxx




  1. 库在开发迭代,不适合发布到线上进行调试。




  2. 可以帮助我们模拟包安装后的状态,它会在系统中做一个快捷方式映射,让本地的包就好像 install 过一样,可以直接使用。




  3. npm unlink 解除链接






commander —— 命令行指令配置


实现脚手架命令的配置, commander 中文文档


// 引入 program
const { program } = require('commander')

// 设置 program 可以输入的选项
// 每个选项可以定义一个短选项名称(-后面接单个字符)和一个长选项名称(--后面接一个或多个单词),使用逗号、空格或|分隔。
// 长选项名称可以作为 .opts() 的对象key
program.option('-p, --port <count>') // 必选参数使用 <> 表示,可选参数使用 [] 表示

// 解析后的选项可以通过Command对象上的.opts()方法获取,同时会被传递给命令处理函数。
const options = program.opts()

program.command('create <name>').action((fileName) => {
console.log({ fileName, options })
})

program.parse(process.argv)



chalk —— 命令行美化工具


可以美化我们在命令行中输出内容的样式,例如实现多种颜色,花里胡哨的命令行提示等。chalk 文档


安装 chalk 时一定要注意安装 4.x 版本(小包使用的是 4.0.0),否则会因为版本过高,爆出错误。


const chalk = require('chalk')
console.log(`hello ${chalk.blue('world')}`)
console.log(chalk.blue.bgRed.bold('Hello world!'))



inquirer —— 命令行交互工具


支持 input, number, confirm, list, rawlist, expand, checkbox, password,editor 等多种交互方式。 inquirer 文档


const inquirer = require('inquirer')

inquirer
.prompt([
/* 输入问题 */
{
name: 'question1',
type: 'checkbox',
message: '爸爸的爸爸叫什么?',
choices: [
{
name: '爸爸',
checked: true
},
{
name: '爷爷'
}
]
},
{
name: 'question2',
type: 'list',
message: `确定要创建${fileName}的文件夹吗`,
choices: [
{
name: '确定',
checked: true
},
{
name: '否'
}
]
}
])
.then((answers) => {
// Use user feedback for... whatever!!
console.log({ answers })
})
.catch((error) => {
if (error.isTtyError) {
// Prompt couldn't be rendered in the current environment
} else {
// Something else went wrong
}
})



ora —— 命令行 loading 效果


现在的最新版本为 es6 模块,需要用以前的版本,例如: V5.4.1 才是 cjs 模块 : ora 文档


const ora = require('ora')

const spinner = ora('Loading unicorns').start()

setTimeout(() => {
spinner.color = 'yellow'
spinner.text = 'Loading rainbows'
}, 1000)

spinner.succeed()



fs-extra —— 更友好的文件操作


是系统 fs 模块的扩展,提供了更多便利的 API,并继承了 fs 模块的 API。比 fs 使用起来更加友好。 fs-extra 文档




download-git-repo —— 命令行下载工具


从 git 中拉取仓库,提供了 download 方法,该方法接收 4 个参数。 download-git-repo 文档


/**
* download-git-repo 源码
* Download `repo` to `dest` and callback `fn(err)`.
*
* @param {String} repo 仓库地址
* @param {String} dest 仓库下载后存放路径
* @param {Object} opts 配置参数
* @param {Function} fn 回调函数
*/


function download(repo, dest, opts, fn) {}


【注】 download-git-repo 不支持 Promise


作者:pnm学编程
来源:juejin.cn/post/7254176076082249785

收起阅读 »

今天这个 Antd 咱们是非换不可吗?

web
最近在思考一个可有可无的问题: “我们是不是要换一个组件库?” 为什么会有这个问题? 简单同步一下背景,我效力于 Lazada 商家前端团队。从接手系统以来(近 2 年) 就一直使用着 Alibaba Fusion 这套组件库。据我所知淘系都是在使用这套组件...
继续阅读 »

最近在思考一个可有可无的问题:


“我们是不是要换一个组件库?”


为什么会有这个问题?



简单同步一下背景,我效力于 Lazada 商家前端团队。从接手系统以来(近 2 年) 就一直使用着 Alibaba Fusion 这套组件库。据我所知淘系都是在使用这套组件库进行业务开发,已经有 7 ~ 10 年了吧。我们团队花了 2 年时间从 @alife/next(内部版本已经不更新) 升级到了 @alifd/next,并在此之上建立了一套前端组件库体系。将 Lazada Seller Center 改了模样,在 Fusion 的基础上建立了一套支持整个 Lazada B 端业务的设计规范和业务组件库,覆盖页面 500+。



image.pngimage.png

在这样一个可以说牵一发动全身的背景下,为何还敢有这种想法?


不美



美的反义词,不应该是丑,而是庸俗



不能说 Fusion 丑,但绝对算不上美,这点应该没有争议吧。


虽然也可以在大量的主题样式定制的情况下也可以做到下面这样看上去还行的效果:


image.png


image.png


但说实话,这不能算出众。导致不出众的原因,可以从 Ant Design 上面寻找,Ant Design 的许多细节实现细到令人发指,比如:




  • 弹出窗的追踪动效


    iShot_2023-07-10_11.57.53.gif




  • 按钮的点击动效


    iShot_2023-07-10_11.59.46.gif




  • Tooltip 的箭头追踪


    iShot_2023-07-10_12.02.04.gif




  • NumberPicker 控制按钮放大


    iShot_2023-07-10_12.11.19.gif




这些细节决定了在它上层构建出的应用品质,同样是在一个基础上进行主题和样式的调整。有 Antd 这样品质的基础,就会让在此之上构建的应用品质不会很低,自然也能够带来更好的用户体验及产品品质。


迭代


拿 Antd 的源码和 Fusion 还是有蛮大的差距的,这些差距不只是技术水平的差距,可能在 10 年前他们的代码质量是差不多的,但贵在 Antd 是一个健康的迭代状态。


Antd 已经到了 5.x,Fusion 还是 1.x。这版本后背后意味着 Fusion 从 1.x 发布后就没有大的迭代和改动。即使是 DatePicker、Overlay 这类的组件重构也是提供一个 v2 的 Props 作为差别。


这背后其实反应出的是维护者对于这个库的 Vision (愿景),或许随着 Fusion 这边不断的组织变动,早就已经失去了属于它的那份 Vision。


所以当 Antd 已经在使用 cssinjs、:where、padding-block 这种超前到我都不能接受的东西时,Fusion 里面还充斥着各种 HOC 和 Class。


可以说,Fusion 已经是一个处于缺乏活力,得过且过的维护状态。如果我们不想让这种封闭结构所带来的长期腐蚀所影响,就需要趁早谋求改变。


性能、稳定


得益于上述许多“耗散结构”的好处,Antd 的性能也比 Fusion 要好上许多。许多能够使用 Hooks、CSS 解决的问题,都不会采用组件 JS 来处理,比如 responsive、space 等。


稳定性,既体现在代码的测试质量,又体现在 UI 交互的表现稳定性。比如,Dialog、Tooltip 随着内容高度的变化而动态居中的问题( Fusion overlay v2 有通过 CSS 来控制居中,已经修复)。在很长一段时间内,我们的开发者和用户都承受着元素闪动带来的不好体验。


还有诸如 Icon 不对齐、Label 不对齐,换行 Margin 不居中等等,使用者稍微不注意打开方式,就会可能出现非预期的表现,这些都需要使用者花费额外的精力去在上层处理修复。有些不讲究的开发者就直接把这些丢了用户,又不是不能用。


“又不是不能用” , 而我们不想要这样


投入


Antd 的投入有目共睹,一个 86K star,超过 25K 次提交的库,与 Fusion 的 4.4K star、4K commits。这种投入的比例完全不在一个量级,这还没有计算围绕 Antd 周边丰富的文档、套件等投入。


都是站在巨人的肩膀上,都是借力,没有理由不去选择一个活跃的、周全的、前沿的、生态丰富的巨人。


为什么这变成了问题?


那既然我都把 Antd 吹成这样了,为什么这还需要思考,这还是个问题?无脑换不就行了?


现有生态


或许社区的 Antd 生态非常强劲。但在内部,我们所有的生态都是围绕 Fusion 在建立。包括:



  • 设计规范

  • 业务组件(50+ 常用)

  • 模板 20+

  • 发布体系

  • 业务 External

  • ... 等等许多


切换 Antd,意味着需要对所有现有生态进行升级改造,这将会是一个粗略估计 500+ 小时巨大的投入。


这将意味着我们会拦一个巨大的活到身上,做好了大家用,做不好所有人喷。


影子很重


我们都会发现一个问题,所有 Antd 来做的业务都一眼能被认出来这是 Antd。


因为它太火了,做互联网的应该没有人没见过 Antd 做的页面吧。


辩证的来看,Ant Design 它就叫 “Design”,引入 Antd 还不要它的样式,那你到底想要什么?


“想要它的好看好用,还想让他看上去跟别人不一样”


别急眼,这看上去很荒谬,但这确实是在使用 Antd 时的一个很大诉求。


我认为 Antd 应该考虑像 Daisyui 这样提供多套的主题预设。


不是说这个能力 Antd 现在没有,相反 Antd 5 提供了一整套完整的 Design Token。


但插件体系或者说开放能力,真的需要在官方自己进来做上几个,才会发现会有这么多问题 😭


这就跟 Vite 如果不自己做几个插件,只是提供了插件系统,那它的插件系统大概率是满足不了真正的使用者的。


反正虽然 Antd 5.0 提供了海量的 Design Token,但我在精细化调整样式主题时,还是发现了许多不能调整的地方(就是没有提供这样的 TOKEN 出来)。


因为 cssinjs 的方案,说实话我也不知道应该用什么样的方式进行样式改写才算是最佳实践。


CSS 方案


可以说,近一两年,随着 Vue 3、Vite、Tailwind CSS 等项目的大火🔥,又重新引起了我们对样式的思考。


Unstyled 这个词反复的被 Radix UIHeadless UI 等为首的项目提及,衍生出来的:Shadcn UIArk UI 等热门项目都让人有种醍醐灌顶的感觉。


大概是从 React、Vue 出现开始,UI 的事情就被绑定在了组件库里面,和 JS 逻辑都做好了放一起交给使用者。


但在此之前,样式和 JS 库其实分的很开的。如果你不满意当前的 UI,你大可以换一套 UI 样式库。同样是一个 <button class="btn"></button>,换上不同的 CSS,他们的样式就可以完全不一样。


但前端发展到了今天,如果我想要对我们的样式进行大范围升级,从 Element 换到 Ant Design 很可能涉及到的是技术栈的全部更替。


所以面对 cssinjs,我不敢说这是一个未来的方向,我花了很长时间去了解和体会 cssinjs,也确实它在一些场景中表现出了一些优势:



  • 按需加载,我不用再使用 babel-plugin-import 这类插件

  • 样式不在冲突,完美prefix+ :where hash样式 Scope 运行时计算,必不冲突。微前端友好!

  • ES Module,Bundless 技术不断发展,如果有一天你需要使用 ES Module,你会发现 Antd 5.x 这个组件库不需要任何适配也可以运行的很好,因为它是纯 JS

  • SSR,纯 JS 运行,也可以做 CSS 提取,InlineStyle 也变得没有那么困难


但说实话,这些方案,在原子化 CSS 中也不是无解,甚至还能做的更好。


但 Ant Design 底层其实也是采用 Unstyled 方式沉淀出了一系列的 rc-* 组件,或许有一天这又会有所变化呢,谁知道呢。


总之,我非常不喜欢使用 Props 来控制 Style这件事情。


也非常不喜欢想要用一个 Button,在移动端和 PC 端需要从不同的组件库中导入。


所以,有答案了吗?


说实话,这个问题,我思考了很久。每次思考,仿佛抓到了什么,又仿佛没有抓到什么,其实写这篇文章也是把一些思考过程罗列下来,或许能想的更清楚。



最初科举考试是选拔官僚用的,其中一个作用是:筛选出那些能够忍受每天重复做自己不喜欢事情的人



或许畏惧变化、畏惧折腾,或许就应该用 Fusion ,因为可以确定的是 Antd 5 绝对不是最后一个大版本。


选择 Antd,也意味着选择迭代更快的底层依赖,意味着拥抱了更活跃的变化,意味着要持续折腾。


如果没有准备好这种心态,那即使换了 Antd,大概率也可能会锁定某个版本,或者直接拷贝一份,这种最粗暴的方式使用。然后进入下一个循环。


今天这个 Antd 咱们是非换不可吗?


我想我已经有了我的决定,你呢?


(ps. 为什么大家对暗黑模式这么不重视...)


(ps. 如果 Fusion 相关同学看到,别自责,这不怪

作者:YeeWang
来源:juejin.cn/post/7254559214588543034
你...)

收起阅读 »

为什么React一年不发新版了?

web
大家好,我卡颂。 遥想前几年,不管是React还是Vue,都在快速迭代版本,以至于很多同学抱怨学不动了。 而现在,React已经一年没更新稳定release了。 甚至有人认为,这就是前端已死最直接的证据: 那么,React最近一年为什么不发版了呢?是因为前...
继续阅读 »

大家好,我卡颂。


遥想前几年,不管是React还是Vue,都在快速迭代版本,以至于很多同学抱怨学不动了


而现在,React已经一年没更新稳定release了。


上一次发版还是22年6月


甚至有人认为,这就是前端已死最直接的证据:



那么,React最近一年为什么不发版了呢?是因为前端框架领域已经没有新活儿可整了么?React v19是不是遥遥无期了?


欢迎围观朋友圈、加入人类高质量前端交流群,带飞


最近一年React活跃吗?


不想看长文章的同学,这里一句话总结本文观点:



React之所以一年没发版,并不是因为无活可整,而是在完成框架从UI库到元框架的转型



首先,我们来看看,最近这一年React的更新活跃度是否降低?


从代码push量来看,最近一年甚至比release产出较多的前几年更活跃:



既然更活跃,那React这段时间到底在做什么呢?从代码增删行数可以一窥端倪,其中:




  • 绿色柱状代表代码增加行数




  • 红色柱状代表代码减少行数




  • 红色折线代表代码行数总体趋势





代码量变化来看,React历史上大体分为四个时期:




  • 13年开源,到17年之前的功能迭代期




  • 持续到18年的重构期(重构React Fiber架构)




  • 18~22年基于Fiber架构的新功能迭代期




  • 22年至今的重构期




功能迭代期重构期的区别在于:




  • 前者主要是在稳定的架构上迭代新特性




  • 后者一般重构底层架构的同时,重构老特性




剧烈的代码量波动通常发生在重构期。比如,在最近的重构期内,PR #25774删除了3w行代码。




这个PR主要改变React对于同一个子包,同时拥有.new.old两个文件的开发模式



最近一年React都在干啥?


明确了React最近一年处于重构期。那么,究竟是重构什么呢?


答案是 —— 将RSCReact Server Component,服务端组件)接入当前React体系内。


有同学会问:RSC只是个类似SSR的特性,为什么要实现他还涉及重构?


这是因为RSC不仅是一个特性,更是React未来主要的发展方向,其意义不亚于Hooks。所以,围绕RSC的迭代涉及大量代码的重构。比如:




  • SSR相关代码需要修改




  • SSR代码修改导致Suspense组件代码修改




  • Suspense的修改又牵扯到useEffect回调触发时机的变化




可以说是牵一发而动全身了。


RSC为什么重要


为什么RSCReact这么重要?要回答这个问题,得从开源项目的发展聊起。


开源项目要想获得成功,一定需要满足目标用户(开发者)的需求。


早期,React作为前端框架,满足了UI开发的需求。在此期间,React团队的迭代方向主要是:




  • 摸索更清晰的开发范式(发布了Error BoundraySuspenseHooks




  • 修补代码(发布新的Context实现)




  • 优化开发体验(发布CRA




  • 底层优化(重构Fiber架构)




可以发现,这些迭代内容中大部分(除了底层优化)都是直接面向普通开发者的,所以React文档(文档也是面向开发者的)中都有体现,开发者通过文档能直观的感受到React不断迭代。


随着前端领域的发展,逐渐涌现出各种业务开发的最佳实践,比如:




  • 状态管理的最佳实践




  • 路由的最佳实践




  • SSR的最佳实践




一些框架开始整合这些最佳实践(比如Next.jsRemix,或者国内的Umijs...)


到了这一时期,开发者更多是通过使用这些框架间接使用React


感受到这一变化后,React团队的发展方向逐渐变化 —— 从面向开发者的前端框架变为面向上层框架的元框架。


发展方向变化最明显的表现是 —— 文档中新出的特性普通开发者很少会用到,比如:




  • useTransition




  • useId




  • useMutableSource




这些特性都是作为元框架,给上层框架(或库)使用的。


上述特性虽然普通开发者很少用到,但至少文档中提及了。但随着React不断向元框架方向发展,即使出了新特性,文档中已经不再提及了。比如:




  • useOptimistic




  • useFormStatus




上述两个Hook想必大部分同学都没听过。他们是React源码中切实存在的Hook。但由于是元框架理念下的产物,所以React文档并未提及。相反,Next.js文档中可以看到使用介绍。


总结


React之所以已经一年没有发布稳定release,是因为发展方向已经从面向开发者转型为面向上层框架


在此期间的更新都是面向上层框架,所以开发者很难感知到React的变化。


但这并不能说明React停止迭代了,也不能据此认为前端发展的停滞。


如果一定要定量观察React最近一年的发展,距离React v19里程碑,已经大体过半了:


收起阅读 »

5分钟,带你迅速上手“Markdown”语法

web
本篇将重点讲解:Markdown的 “语法规范” 与 “上手指南”。 一、Markdown简介 Markdown是一种文本标记语言,它容易上手、易于学习,排版清晰明了、直观清晰。常用于撰写 “技术文档” 、 “技术博客” 、 “开发文档” 等等。 总之,如...
继续阅读 »

本篇将重点讲解:Markdown的 “语法规范”“上手指南”





一、Markdown简介


Markdown是一种文本标记语言,它容易上手、易于学习,排版清晰明了、直观清晰。常用于撰写 “技术文档”“技术博客”“开发文档” 等等。
总之,如果你是一名开发者,并且你有写博客的欲望与想法时,使用Markdown是你不二的选择。




二、Markdown语法


接下来,我们来看一下Markdown“标准语法”


我们看下大纲,其中包括:



1、标题


  • 标准语法:使用1~6“#”符 + “空格” + “你的标题”。


# 一级标题
## 二级标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题


  • 效果图解:




注:#和「标题」之间有一个空格,这是最标准的语法格式。
有些编辑器做了兼容,有的并没有。所以最好要加上空格。



2、列表


  • 标准语法:使用-符,在文本前加入-符即可。


- 文本1
- 文本2
- 文本3

如果你希望有序,在文本前加上1. 2. 3. 4. ...


1. 文本1
2. 文本2
3. 文本3


注:-1. 2. 等和文本之间要保留一个字符的空格。




  • 效果图解:



3、超链接



  • 标准语法:[链接名](链接url)




  • 效果图解:





4、图片


  • 标准语法:


![图片名](链接url)


  • 效果图解:



5、引用



  • 标准语法:> 文本




  • 效果图解:





6、斜体、加粗



  • 标准语法
    斜体*文本*
    加粗**文本**
    斜体&加粗***文本***




  • 效果图解:





7、代码块



  • 标准语法:
    ``` 你的代码 ```(前面3个点,后面3个点)




  • 效果图解:







8、表格


  • 标准语法:


dog | bird | cat
----|------|----
foo | foo | foo
bar | bar | bar
baz | baz | baz


  • 效果图解:





9、特殊标记


  • 标准语法:``


`特殊样式`


  • 效果图解:





10、分割线



  • 标准语法:--- 最少3个




  • 效果图解:





11、常用html标记

注意:html标记只适合辅助使用,不一定所有编辑器都能生效。



  • 标准语法:


换行符:<br/> (或者使用Markdown标准语法:空格+空格+回车,但我感觉不是很直观)
上:<sup>文本</sup>
下:<sub>文本</sub>



  • 效果图解:





三、Markdown优点



  • 纯文本,所以兼容性极强,可以用所有文本编辑器打开。

  • 让作者更专注于写作而不是排版。(大家都是技术人员嘛..)

  • 格式转化方便,markdown文本可以很轻松转成htmlpdf等等。(图个方便嘛)

  • 语法简单

  • 可读性强,配合表格、引用、代码块等等,让读者瞬间“懂
    作者:齐舞647
    来源:juejin.cn/post/7254107670012510245
    你”。

收起阅读 »

🤣泰裤辣!这是什么操作,自动埋点,还能传参?

web
前言 在上篇文章讲了如何通过手写babel插件自动给函数埋点之后,就有同学问我,自动插入埋点的函数怎么给它传参呢?这篇文章就来解决这个问题我讲了通过babel来实现自动化埋点,也讲过读取注释给特定函数插入埋点代码,感兴趣的同学可以来这里 给所有函数都添加埋...
继续阅读 »


前言


在上篇文章讲了如何通过手写babel插件自动给函数埋点之后,就有同学问我,自动插入埋点的函数怎么给它传参呢?这篇文章就来解决这个问题
我讲了通过babel来实现自动化埋点,也讲过读取注释给特定函数插入埋点代码,感兴趣的同学可以来这里





效果是这样的
源代码:


//##箭头函数
//_tracker
const test1 = () => {};

const test1_2 = () => {};

转译之后:


import _tracker from "tracker";
//##箭头函数
//_tracker
const test1 = () => {
_tracker();
};

const test1_2 = () => {};

代码中有两个函数,其中一个//_tracker的注释,另一个没有。转译之后只给有注释的函数添加埋点函数。
要达到这个效果就需要读取函数上面的注释,如果注释中有//_tracker,我们就给函数添加埋点。这样做避免了僵硬的给每个函数都添加埋点的情况,让埋点更加灵活。




那想要给插入的埋点函数传入参数应该怎么做呢?
传入参数可以有两个思路,



  • 一个是将参数也放在注释里面,在babel插入代码的时候读取下注释里的内容就好了;

  • 另一个是将参数以局部变量的形式放在当前作用域中,在babel插入代码时读取下当前作用域的变量就好;


下面我们来实现这两个思路,大家挑个自己喜欢的方法就好


参数放在注释中


整理下源代码


import "./index.css";

//##箭头函数
//_tracker,_trackerParam={name:'gongfu', age:18}
const test1 = () => {};

//_tracker
const test1_2 = () => {};


代码中,有两个函数,每个函数上都有_tracker的注释,其中一个注释携带了埋点函数的参数,待会我们就要将这个参数放到埋点函数里



关于如何读取函数上方的注释,大家可以这篇文章:(),我就不赘述了




准备入口文件


index.js


const { transformFileSync } = require("@babel/core");
const path = require("path");
const tracker = require("./babel-plugin-tracker-comment.js");

const pathFile = path.resolve(__dirname, "./sourceCode.js");

//transform ast and generate code
const { code } = transformFileSync(pathFile, {
plugins: [[tracker, { trackerPath: "tracker", commentsTrack: "_tracker",commentParam: "_trackerParam" }]],
});

console.log(code);



和上篇文章的入口文件类似,使用了transformFileSyncAPI转译源代码,并将转译之后的代码打印出来。过程中,将手写的插件作为参数传入plugins: [[tracker, { trackerPath: "tracker", commentsTrack: "_tracker"}]]。除此之外,还有插件的参数



  • trackerPath表示埋点函数的路径,插件在插入埋点函数之前会检查是否已经引入了该函数,如果没有引入就需要额外引入。

  • commentsTrack标识埋点,如果函数前的注释有这个,就说明函数需要埋点。判断的标识是动态传入的,这样比较灵活

  • commentParam标识埋点函数的参数,如果注释中有这个字符串,那后面跟着的就是参数了。就像上面源代码所写的那样。这个标识不是固定的,是可以配置化的,所以放在插件参数的位置上传进去


编写插件


插件的功能有:



  • 查看埋点函数是否已经引入

  • 查看函数的注释是否含有_tracker

  • 将埋点函数插入函数中

  • 读取注释中的参数


前三个功能在上篇文章(根据注释添加埋点)中已经实现了,下面实现第四个功能


const paramCommentPath = hasTrackerComments(leadingComments, options.commentsTrack);
if (paramCommentPath) {
const param = getParamsFromComment(paramCommentPath, options);
insertTracker(path, param, state);
}

//函数实现
const getParamsFromComment = (commentNode, options) => {
const commentStr = commentNode.node.value;
if (commentStr.indexOf(options.commentParam) === -1) {
return null;
}

try {
return commentStr.slice(commentStr.indexOf("{"), commentStr.indexOf("}") + 1);
} catch {
return null;
}
};

const insertTracker = (path, param, state) => {
const bodyPath = path.get("body");
if (bodyPath.isBlockStatement()) {
let ast = template.statement(`${state.importTackerId}(${param});`)();
if (param === null) {
ast = template.statement(`${state.importTackerId}();`)();
}
bodyPath.node.body.unshift(ast);
} else {
const ast = template.statement(`{
${state.importTackerId}(${param});
return BODY;
}`
)({ BODY: bodyPath.node });
bodyPath.replaceWith(ast);
}
};


上述代码的逻辑是检查代码是否含有注释_tracker,如果有的话,再检查这一行注释中是否含有参数,最后再将埋点插入函数。
在检查是否含有参数的过程中,用到了插件参数commentParam。表示如果注释中含有该字符串,那后面的内容就是参数了。获取参数的方法也是简单的字符串切割。如果没有获取到参数,就一律返回null



获取参数的复杂程度,取决于。先前是否就有一个规范,并且编写代码时严格按照规范执行。
像我这里的规范是埋点参数commentParam和埋点标识符_tracker必须放在一行,并且参数需要是对象的形式。即然是对象的形式,那这一行注释中就不允许就其他的大括号符号"{}"
遵从了这个规范,获取参数的过程就变的很简单了。当然你也可以有自己的规范



在执行插入逻辑的函数中,会校验参数param是否为null,如果是null,生成ast的时候,就不传入param了。



当然你也可以一股脑地传入param,不会影响结果,顶多是生成的埋点函数会收到一个null的参数,像这样_tracker(null)



第四个功能也实现了,来看下完整代码


完整代码


const { declare } = require("@babel/helper-plugin-utils");
const { addDefault } = require("@babel/helper-module-imports");
const { template } = require("@babel/core");
//judge if there are trackComments in leadingComments
const hasTrackerComments = (leadingComments, comments) => {
if (!leadingComments) {
return false;
}
if (Array.isArray(leadingComments)) {
const res = leadingComments.filter((item) => {
return item.node.value.includes(comments);
});
return res[0] || null;
}
return null;
};

const getParamsFromComment = (commentNode, options) => {
const commentStr = commentNode.node.value;
if (commentStr.indexOf(options.commentParam) === -1) {
return null;
}

try {
return commentStr.slice(commentStr.indexOf("{"), commentStr.indexOf("}") + 1);
} catch {
return null;
}
};

const insertTracker = (path, param, state) => {
const bodyPath = path.get("body");
if (bodyPath.isBlockStatement()) {
let ast = template.statement(`${state.importTackerId}(${param});`)();
if (param === null) {
ast = template.statement(`${state.importTackerId}();`)();
}
bodyPath.node.body.unshift(ast);
} else {
const ast = template.statement(`{
${state.importTackerId}(${param});
return BODY;
}`
)({ BODY: bodyPath.node });
bodyPath.replaceWith(ast);
}
};


const checkImport = (programPath, trackPath) => {
let importTrackerId = "";
programPath.traverse({
ImportDeclaration(path) {
const sourceValue = path.get("source").node.value;
if (sourceValue === trackPath) {
const specifiers = path.get("specifiers.0");
importTrackerId = specifiers.get("local").toString();
path.stop();
}
},
});

if (!importTrackerId) {
importTrackerId = addDefault(programPath, trackPath, {
nameHint: programPath.scope.generateUid("tracker"),
}).name;
}

return importTrackerId;
};

module.exports = declare((api, options) => {

return {
visitor: {
"ArrowFunctionExpression|FunctionDeclaration|FunctionExpression|ClassMethod": {
enter(path, state) {
let nodeComments = path;
if (path.isExpression()) {
nodeComments = path.parentPath.parentPath;
}
// 获取leadingComments
const leadingComments = nodeComments.get("leadingComments");
const paramCommentPath = hasTrackerComments(leadingComments,options.commentsTrack);

// 如果有注释,就插入函数
if (paramCommentPath) {
//add Import
const programPath = path.hub.file.path;
const importId = checkImport(programPath, options.trackerPath);
state.importTackerId = importId;

const param = getParamsFromComment(paramCommentPath, options);
insertTracker(path, param, state);
}
},
},
},
};
});



运行代码


现在可以用入口文件来使用这个插件代码了


node index.js

执行结果
image.png



运行结果符合预期



可以看到我们设置的埋点参数确实被放到函数里面了,而且注释里面写了什么,函数的参数就会放什么,那么既然如此,可以传递变量吗?我们来试试看


import "./index.css";

//##箭头函数
//_tracker,_trackerParam={name, age:18}
const test1 = () => {
const name = "gongfu2";
};

const test1_2 = () => {};

在需要插入的代码中,声明了一个变量,然后注释的参数刚好用到了这个变量。
运行代码看看效果
image.png
可以看到,插入的参数确实用了变量,但是引用变量却在变量声明之前,这肯定不行🙅。得改改。
需要将埋点函数插入到函数体的后面,并且是returnStatement的前面,这样就不会有问题了


const insertTrackerBeforeReturn = (path, param, state) => {
//blockStatement
const bodyPath = path.get("body");
let ast = template.statement(`${state.importTackerId}(${param});`)();
if (param === null) {
ast = template.statement(`${state.importTackerId}();`)();
}
if (bodyPath.isBlockStatement()) {
//get returnStatement, by body of blockStatement
const returnPath = bodyPath.get("body").slice(-1)[0];
if (returnPath && returnPath.isReturnStatement()) {
returnPath.insertBefore(ast);
} else {
bodyPath.node.body.push(ast);
}
} else {
ast = template.statement(`{ ${state.importTackerId}(${param}); return BODY; }`)({ BODY: bodyPath.node });
bodyPath.replaceWith(ast);
}
};

这里将insertTracker改成了insertTrackerBeforeReturn
其中关键的逻辑是判断是否是一个函数体,



  • 如果是一个函数体,就判断有没有return语句,

    • 如果有return,就放在return前面

    • 如果没有return,就放在整个函数体的后面



  • 如果不是一个函数体,就直接生成一个函数体,然后将埋点函数放在return的前面


再来运行插件:
image.png



很棒,这就是我们要的效果😃




完整代码


const { declare } = require("@babel/helper-plugin-utils");
const { addDefault } = require("@babel/helper-module-imports");
const { template } = require("@babel/core");
//judge if there are trackComments in leadingComments
const hasTrackerComments = (leadingComments, comments) => {
if (!leadingComments) {
return false;
}
if (Array.isArray(leadingComments)) {
const res = leadingComments.filter((item) => {
return item.node.value.includes(comments);
});
return res[0] || null;
}
return null;
};

const getParamsFromComment = (commentNode, options) => {
const commentStr = commentNode.node.value;
if (commentStr.indexOf(options.commentParam) === -1) {
return null;
}

try {
return commentStr.slice(commentStr.indexOf("{"), commentStr.indexOf("}") + 1);
} catch {
return null;
}
};

const insertTrackerBeforeReturn = (path, param, state) => {
//blockStatement
const bodyPath = path.get("body");
let ast = template.statement(`${state.importTackerId}(${param});`)();
if (param === null) {
ast = template.statement(`${state.importTackerId}();`)();
}
if (bodyPath.isBlockStatement()) {
//get returnStatement, by body of blockStatement
const returnPath = bodyPath.get("body").slice(-1)[0];
if (returnPath && returnPath.isReturnStatement()) {
returnPath.insertBefore(ast);
} else {
bodyPath.node.body.push(ast);
}
} else {
ast = template.statement(`{ ${state.importTackerId}(${param}); return BODY; }`)({BODY: bodyPath.node });
bodyPath.replaceWith(ast);
}
};


const checkImport = (programPath, trackPath) => {
let importTrackerId = "";
programPath.traverse({
ImportDeclaration(path) {
const sourceValue = path.get("source").node.value;
if (sourceValue === trackPath) {
const specifiers = path.get("specifiers.0");
importTrackerId = specifiers.get("local").toString();
path.stop();
}
},
});

if (!importTrackerId) {
importTrackerId = addDefault(programPath, trackPath, {
nameHint: programPath.scope.generateUid("tracker"),
}).name;
}

return importTrackerId;
};

module.exports = declare((api, options) => {

return {
visitor: {
"ArrowFunctionExpression|FunctionDeclaration|FunctionExpression|ClassMethod": {
enter(path, state) {
let nodeComments = path;
if (path.isExpression()) {
nodeComments = path.parentPath.parentPath;
}
// 获取leadingComments
const leadingComments = nodeComments.get("leadingComments");
const paramCommentPath = hasTrackerComments(leadingComments,options.commentsTrack);

// 如果有注释,就插入函数
if (paramCommentPath) {
//add Import
const programPath = path.hub.file.path;
const importId = checkImport(programPath, options.trackerPath);
state.importTackerId = importId;

const param = getParamsFromComment(paramCommentPath, options);
insertTrackerBeforeReturn(path, param, state);
}
},
},
},
};
});


参数放在局部作用域中


这个功能的关键就是读取当前作用域中的变量。


在写代码之前,来定一个前提:当前作用域的变量名也和注释中参数标识符一致,也是_trackerParam


准备源代码


import "./index.css";

//##箭头函数
//_tracker,_trackerParam={name, age:18}
const test1 = () => {
const name = "gongfu2";
};

const test1_2 = () => {};

//函数表达式
//_tracker
const test2 = function () {
const age = 1;
_trackerParam = {
name: "gongfu3",
age,
};
};

const test2_1 = function () {
const age = 2;
_trackerParam = {
name: "gongfu4",
age,
};
};

代码中,准备了函数test2test2_1。其中都有_trackerParam作为局部变量,但test2_1没有注释//_tracker


编写插件


if (paramCommentPath) {
//add Import
const programPath = path.hub.file.path;
const importId = checkImport(programPath, options.trackerPath);
state.importTackerId = importId;

//check if have tackerParam
const hasTrackParam = path.scope.hasBinding(options.commentParam);
if (hasTrackParam) {
insertTrackerBeforeReturn(path, options.commentParam, state);
return;
}

const param = getParamsFromComment(paramCommentPath, options);
insertTrackerBeforeReturn(path, param, state);
}

这个函数的逻辑是先判断当前作用域中是否有变量_trackerParam,有的话,就获取该声明变量的初始值。然后将该变量名作为insertTrackerBeforeReturn的参数传入其中。
我们运行下代码看看
image.png



运行结果符合预期,很好




完整代码


const { declare } = require("@babel/helper-plugin-utils");
const { addDefault } = require("@babel/helper-module-imports");
const { template } = require("@babel/core");
//judge if there are trackComments in leadingComments
const hasTrackerComments = (leadingComments, comments) => {
if (!leadingComments) {
return false;
}
if (Array.isArray(leadingComments)) {
const res = leadingComments.filter((item) => {
return item.node.value.includes(comments);
});
return res[0] || null;
}
return null;
};

const getParamsFromComment = (commentNode, options) => {
const commentStr = commentNode.node.value;
if (commentStr.indexOf(options.commentParam) === -1) {
return null;
}

try {
return commentStr.slice(commentStr.indexOf("{"), commentStr.indexOf("}") + 1);
} catch {
return null;
}
};

const insertTrackerBeforeReturn = (path, param, state) => {
//blockStatement
const bodyPath = path.get("body");
let ast = template.statement(`${state.importTackerId}(${param});`)();
if (param === null) {
ast = template.statement(`${state.importTackerId}();`)();
}
if (bodyPath.isBlockStatement()) {
//get returnStatement, by body of blockStatement
const returnPath = bodyPath.get("body").slice(-1)[0];
if (returnPath && returnPath.isReturnStatement()) {
returnPath.insertBefore(ast);
} else {
bodyPath.node.body.push(ast);
}
} else {
ast = template.statement(`{${state.importTackerId}(${param}); return BODY;}`)({BODY: bodyPath.node });
bodyPath.replaceWith(ast);
}
};


const checkImport = (programPath, trackPath) => {
let importTrackerId = "";
programPath.traverse({
ImportDeclaration(path) {
const sourceValue = path.get("source").node.value;
if (sourceValue === trackPath) {
const specifiers = path.get("specifiers.0");
importTrackerId = specifiers.get("local").toString();
path.stop();
}
},
});

if (!importTrackerId) {
importTrackerId = addDefault(programPath, trackPath, {
nameHint: programPath.scope.generateUid("tracker"),
}).name;
}

return importTrackerId;
};

module.exports = declare((api, options) => {

return {
visitor: {
"ArrowFunctionExpression|FunctionDeclaration|FunctionExpression|ClassMethod": {
enter(path, state) {
let nodeComments = path;
if (path.isExpression()) {
nodeComments = path.parentPath.parentPath;
}
// 获取leadingComments
const leadingComments = nodeComments.get("leadingComments");
const paramCommentPath = hasTrackerComments(leadingComments,options.commentsTrack);

// 如果有注释,就插入函数
if (paramCommentPath) {
//add Import
const programPath = path.hub.file.path;
const importId = checkImport(programPath, options.trackerPath);
state.importTackerId = importId;

//check if have tackerParam
const hasTrackParam = path.scope.hasBinding(options.commentParam);
if (hasTrackParam) {
insertTrackerBeforeReturn(path, options.commentParam, state);
return;
}

const param = getParamsFromComment(paramCommentPath, options);
insertTrackerBeforeReturn(path, param, state);
}
},
},
},
};
});


总结:


这篇文章讲了如何才埋点的函数添加参数,参数可以写在注释里,也可以写在布局作用域中。支持动态传递,非常灵活。感兴趣的金友们可以拷一份代码下来跑一跑,相信你们会很有成就感的。


下篇文章来讲讲如何在create-reate-app中使用我们手写的babel插件。



相关文章:



  1. 通过工具babel,给函数都添加埋点

  2. 通过工具babel,根据注释添加埋点


作者:慢功夫
来源:juejin.cn/post/7254032949229764669

收起阅读 »

作为一名前端给自己做一个算命转盘不过分吧

web
算命转盘 前言 给自己做一个算命转盘,有事没事算算命,看看运势挺好的(虽然我也看不懂)。 这个算命转盘我是实现在了自己的个人博客中的这里是地址,感兴趣可以点进去看看。 实现过程 开发技术:react + ts 该转盘主要是嵌套了三层 圆形滚动组件 来实现的,...
继续阅读 »

算命转盘


zodiac.gif

前言


给自己做一个算命转盘,有事没事算算命,看看运势挺好的(虽然我也看不懂)。


这个算命转盘我是实现在了自己的个人博客中的这里是地址,感兴趣可以点进去看看。


实现过程


开发技术:react + ts


该转盘主要是嵌套了三层 圆形滚动组件 来实现的,再通过 ref 绑定组件,调用其中的 scrollTo 方法即可使组件发生指定的滚动,再传入随机数,即可实现随机旋转效果,通过嵌套三层该组件实现三层的随机旋转,模拟“算命”效果。


// 这是精简后的代码
export default () => {
const onScrollCircle = () => {
const index = Math.floor(Math.random() * zodiacList.length)
scrollCircleRef.current?.scrollTo({index, duration: 1000})
}
return (
<>
<ScrollCircle ref={scrollCircleRef}></ScrollCircle>
<button onClick={() => onScrollCircle}>点击旋转</button>
</>

)
}

三层大致结构如下:具体代码可以看码上掘金



  • 转盘的第一层


export default () => {
return (
<ScrollCircle>
{list.map((item, index) => (
<ScrollCircle.Item
key={index}
index={index}
>

<CircleItem />
</ScrollCircle.Item>
))}
</ScrollCircle>

)
}


  • 转盘的第二层


const CircleItem = () => {
return (
<ScrollCircle>
{list.map((item, index) => (
<ScrollCircle.Item
key={index}
index={index}
>

<CircleItemChild />
</ScrollCircle.Item>
))}
</ScrollCircle>

)
}


  • 转盘的第三层


const CircleItemChild = () => {
return (
<ScrollCircle>
{list.map((item, index) => (
<ScrollCircle.Item
key={index}
index={index}
>

<div>
内容
</div>
</ScrollCircle.Item>
))}
</ScrollCircle>

)
}

圆形滚动组件


现在的 圆形滚动组件 支持展示到上下左右中各个方向上,要是大家使用过程中有什么意见可以提一下,我尽力实现,当然能提 pr 最好了(∪^ェ^∪)。


组件源码地址


线上Demo演示地址


image.png

主要是在旧版的基础上不断完善而来的,旧版圆形滚动组件的 往期文章


props等使用文档


ScrollCircle


属性名描述类型默认值
listLength传入卡片的数组长度number(必选)
width滚动列表的宽度string"100%"
height滚动列表的高度string"100%"
centerPoint圆心的位置"center" , "auto" , "left" , "right" , "bottom" , "top""auto (宽度大于高度时在底部,否则在右侧)"
circleSize圆的大小"inside" , "outside""outside (圆溢出包裹它的盒子)"

其他的属性...(篇幅问题就不全放上来了,可以直接去线上Demo演示地址查看)


centerPoint


主要通过该属性,将圆心控制到上下左右中间位置。


属性名描述
auto自动适应,当圆形区域宽度大于高度时,圆心会自动在底部,否则在右边
center建议搭配 circleSize='inside' 一起使用(让整个圆形在盒子内部)
left让圆心在左边
top让圆心在顶部
right让圆心在右边
bottom让圆心在底部

作者:滑动变滚动的蜗牛
来源:juejin.cn/post/7254014646779428922
收起阅读 »

vue3 表单封装遇到的一个有意思的问题

web
前言 最近在用 vue3 封装 element 的表单时遇到的一个小问题,这里就简单记录一下过程。话不多说直接上代码!!! 正文 部分核心代码 import { ref, defineComponent, renderSlot, type PropType, ...
继续阅读 »

前言


最近在用 vue3 封装 element 的表单时遇到的一个小问题,这里就简单记录一下过程。话不多说直接上代码!!!


正文


部分核心代码


import { ref, defineComponent, renderSlot, type PropType, type SetupContext } from 'vue';
import { ElForm, ElFormItem, ElRow, ElCol } from 'element-plus';
import type { RowProps, FormItemProps, LabelPosition } from './types';
import formItemRender from './CusomFormItem';
import { pick } from 'lodash-es';

const props = {
formRef: {
type: String,
default: 'customFormRef',
},
modelValue: {
type: Object as PropType<Record<string, unknown>>,
default: () => ({}),
},
rowProps: {
type: Object as PropType<RowProps>,
default: () => ({
gutter: 24,
}),
},
formData: {
type: Array as PropType<FormItemProps[]>,
default: () => [],
},
labelPosition: {
type: String as PropType<LabelPosition>,
default: 'right',
},
labelWidth: {
type: String,
default: '150px',
},
};

const elFormItemPropsKeys = [
'prop',
'label',
'labelWidth',
'required',
'rules',
// 'error',
// 'showMessage',
// 'inlineMessage',
// 'size',
// 'for',
// 'validateStatus',
];

export default defineComponent({
name: 'CustomForm',
props,
emits: ['update:modelValue'],
setup(props, { slots, emit, expose }: SetupContext) {
const customFormRef = ref();

const mValue = ref({ ...props.modelValue });

watch(
mValue,
(newVal) => {
emit('update:modelValue', newVal);
},
{
immediate: true,
deep: true,
},
);

// 表单校验
const validate = async () => {
if (!customFormRef.value) return;
return await customFormRef.value.validate();
};

// 表单重置
const resetFields = () => {
if (!customFormRef.value) return;
customFormRef.value.resetFields();
};

// 暴漏方法
expose({ validate, resetFields });

// col 渲染
const colRender = () => {
return props.formData.map((i: FormItemProps) => {
const formItemProps = { labelWidth: props.labelWidth, ...pick(i, elFormItemPropsKeys) };
return (
<ElCol {...i.colProps}>
<ElFormItem {...formItemProps}>
{i.formItemType === 'slot'
? renderSlot(slots, i.prop, { text: mValue.value[i.prop], props: { ...i } })
: formItemRender(i, mValue.value)}
</ElFormItem>
</ElCol>

);
});
};

return () => (
<ElForm ref={customFormRef} model={mValue} labelPosition={props.labelPosition}>
<ElRow {...props.rowProps}>
{colRender()}
<ElCol>
<ElFormItem labelWidth={props.labelWidth}>{renderSlot(slots, 'action')}</ElFormItem>
</ElCol>
</ElRow>
</ElForm>

);
},
});

<script setup lang="ts">
import CustomerForm from '/@/components/CustomForm';
const data = ref([
{
formItemType: 'input',
prop: 'name',
label: 'Activity name',
placeholder: 'Activity name',
rules: [
{
required: true,
message: 'Please input Activity name',
trigger: 'blur',
},
{ min: 3, max: 5, message: 'Length should be 3 to 5', trigger: 'blur' },
],
},
{
formItemType: 'select',
prop: 'region',
label: 'Activity zone',
placeholder: 'Activity zone',
options: [
{
label: 'Zone one',
value: 'shanghai',
},
{
label: 'Zone two',
value: 'beijing',
},
],
},
{
formItemType: 'inputNumber',
prop: 'count',
label: 'Activity count',
placeholder: 'Activity count',
},
{
formItemType: 'date',
prop: 'date',
label: 'Activity date',
type: 'datetime',
placeholder: 'Activity date',
},
{
formItemType: 'radio',
prop: 'resource',
label: 'Resources',
options: [
{ label: 'Sponsorship', value: '1' },
{ label: 'Venue', value: '2' },
],
},
{
formItemType: 'checkbox',
prop: 'type',
label: 'Activity type',
options: [
{ label: 'Online activities', value: '1', disabled: true },
{ label: 'Promotion activities', value: '2' },
{ label: 'Offline activities', value: '3' },
{ label: 'Promotion activities', value: '4' },
{ label: 'Simple brand exposure', value: '5' },
],
},
{
formItemType: 'input',
prop: 'desc',
type: 'textarea',
label: 'Activity form',
placeholder: 'Activity form',
},
{
formItemType: 'slot',
prop: 'test',
label: 'slot',
},
]);
const model = reactive({
name: '',
region: '',
count: 0,
date: '',
resource: '',
type: [],
desc: '',
test: '1111',
});
const formRef = ref();
const submitForm = () => {
const valid = formRef.value.validate();
if (valid) {
console.log(model);
} else {
return false;
}
};

const resetForm = () => {
formRef.value.resetFields();
};
</script>

<template>
<div class="wrap">
<CustomerForm
ref="formRef"
:v-model="model"
:formData="data"
>

<template #test="scope">
{{ scope.text }}
</template>
<template #action>
<el-button type="primary" @click="submitForm()">Create</el-button>
<el-button @click="resetForm()">Reset</el-button>
</template>
</CustomerForm>
</div>
</template>


<style scoped>
.wrap {
margin: 30px auto;
width: 600px;
height: auto;
}
</style>



问题现象


代码其实非常简单,运行起来也很正常很流畅😀😀😀,但是当我填写完表单后点击提交按钮,打印model的值时,发现值全没给上。


微信截图_20230709120015.png


原因分析


这里经过两年半的尝试,终于发现在定义model时,将const model = reactive({xxx}) 改为 const model = ref({xxx}) 后就正常了。思考了一下 ref 定义的对象,源码上最后通过 toReactive 还是被转化为 reactive,ref 用法上需要 .value, 数据上这两者应该没有什么不同。然后我就去把 reactive、ref 又看了看也没发现问题。在emit('update:modelValue', newVal) 处打印也是正常的。


watch( mValue,
(newVal) => {
console.log('newVal>>>', newVal)
emit('update:modelValue', newVal);
},
{ immediate: true, deep: true, }
);

最后有意思的是,我把 const model 改成 let model tmd居然也正常了,这就让我百思不得其解了😕😕😕


解决


其实上面 debugger 后,就确定了方向 肯定是emit('update:modelValue', newVal)这里出问题了,回到使用组件,把v-model 拆解一下,此时还看不出来问题。


1688879457596.jpg


换成:modelValue="model" @update:model-value="update(e)"问题立马出现了,ts已经提示了 model是常量!


微信截图_20230709131250.png


这样问题就非常明了了,这就解释了 let 可以 const 不行,但你好歹报个错啊 😤😤😤 坑死人不偿命,可见即使在 template 里面这样写@update:model-value="model = $event" ts 也无能为力!
回过头再来看看 ref 为啥可行呢?当改成ref时,


  const update = (e) => {
model.value = e;
};

update是要.value 的,修改常量对象里面属性是正常的。再想想 ref 的变量在 template 中 vue 已经帮我们解过包了,v-model 语法糖拿着属性直接赋值并不会产生问题。而常量 reactive 则不能修改,也可以在在里面再包裹一层对象,但这样就有点冗余了。


总结


总结起来就是,const 定义的 reactive 对象,v-model 去更新整个对象的时候失败,常量不能更改,也没有给出任何报错或提示!


唉!今年太难了。前端路漫漫其修远兮,还需

作者:Pluto5280
来源:juejin.cn/post/7253453908039123005
更加卷地而行!😵😵😵

收起阅读 »

极致舒适的Vue弹窗使用方案

web
一个Hook让你体验极致舒适的Dialog使用方式! Dialog地狱 为啥是地狱? 因为凡是有Dialog出现的页面,其代码绝对优雅不起来!因为一旦你在也个组件中引入Dialog,就最少需要额外维护一个visible变量。如果只是额外维护一个变量这也不是不...
继续阅读 »

一个Hook让你体验极致舒适的Dialog使用方式!


image.png


Dialog地狱


为啥是地狱?


因为凡是有Dialog出现的页面,其代码绝对优雅不起来!因为一旦你在也个组件中引入Dialog,就最少需要额外维护一个visible变量。如果只是额外维护一个变量这也不是不能接受,可是当同样的Dialog组件,即需要在父组件控制它的展示与隐藏,又需要在子组件中控制。


为了演示我们先实现一个MyDialog组件,代码来自ElementPlus的Dialog示例


<script setup lang="ts">
import { computed } from 'vue';
import { ElDialog } from 'element-plus';

const props = defineProps<{
visible: boolean;
title?: string;
}>();

const emits = defineEmits<{
(event: 'update:visible', visible: boolean): void;
(event: 'close'): void;
}>();

const dialogVisible = computed<boolean>({
get() {
return props.visible;
},
set(visible) {
emits('update:visible', visible);
if (!visible) {
emits('close');
}
},
});
</script>

<template>
<ElDialog v-model="dialogVisible" :title="title" width="30%">
<span>This is a message</span>
<template #footer>
<span>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="dialogVisible = false"> Confirm </el-button>
</span>
</template>
</ElDialog>
</template>

演示场景


就像下面这样:


Kapture 2023-07-07 at 22.44.55.gif


示例代码如下:


<script setup lang="ts">
import { ref } from 'vue';
import { ElButton } from 'element-plus';

import Comp from './components/Comp.vue';
import MyDialog from './components/MyDialog.vue';

const dialogVisible = ref<boolean>(false);
const dialogTitle = ref<string>('');

const handleOpenDialog = () => {
dialogVisible.value = true;
dialogTitle.value = '父组件弹窗';
};

const handleComp1Dialog = () => {
dialogVisible.value = true;
dialogTitle.value = '子组件1弹窗';
};

const handleComp2Dialog = () => {
dialogVisible.value = true;
dialogTitle.value = '子组件2弹窗';
};
</script>

<template>
<div>
<ElButton @click="handleOpenDialog"> 打开弹窗 </ElButton>
<Comp text="子组件1" @submit="handleComp1Dialog"></Comp>
<Comp text="子组件2" @submit="handleComp2Dialog"></Comp>
<MyDialog v-model:visible="dialogVisible" :title="dialogTitle"></MyDialog>
</div>
</template>

这里的MyDialog会被父组件和两个Comp组件都会触发,如果父组件并不关心子组件的onSubmit事件,那么这里的submit在父组件里唯一的作用就是处理Dialog的展示!!!🧐这样真的好吗?不好!


来分析一下,到底哪里不好!


MyDialog本来是submit动作的后续动作,所以理论上应该将MyDialog写在Comp组件中。但是这里为了管理方便,将MyDialog挂在父组件上,子组件通过事件来控制MyDialog


再者,这里的handleComp1DialoghandleComp2Dialog函数除了处理MyDialog外,对于父组件完全没有意义却写在父组件里。


如果这里的Dialog多的情况下,简直就是Dialog地狱啊!🤯


理想的父组件代码应该是这样:


<script setup lang="ts">
import { ElButton } from 'element-plus';

import Comp from './components/Comp.vue';
import MyDialog from './components/MyDialog.vue';

const handleOpenDialog = () => {
// 处理 MyDialog
};
</script>

<template>
<div>
<ElButton @click="handleOpenDialog"> 打开弹窗 </ElButton>
<Comp text="子组件1"></Comp>
<Comp text="子组件2"></Comp>
</div>
</template>

在函数中处理弹窗的相关逻辑才更合理。


解决之道


🤔朕观之,是书之文或不雅,致使人之心有所厌,何得无妙方可解决?


依史记之辞曰:“天下苦Dialog久矣,苦楚深深,望有解脱之道。”于是,诸位贤哲纷纷举起讨伐Dialog之旌旗,终“命令式Dialog”逐渐突破困境之境地。


image.png


没错现在网上对于Dialog的困境,给出的解决方案基本上就“命令式Dialog”看起来比较优雅!这里给出几个网上现有的命令式Dialog实现。


命令式一


codeimg-facebook-shared-image (5).png


吐槽一下~,这种是能在函数中处理弹窗逻辑,但是缺点是MyDialog组件与showMyDialog是两个文件,增加了维护的成本。


命令式二


基于第一种实现的问题,不就是想让MyDialog.vue.js文件合体吗?于是诸位贤者想到了JSX。于是进一步的实现是这样:


codeimg-facebook-shared-image (7).png


嗯,这下完美了!🌝


doutub_img.png


完美?还是要吐槽一下~



  • 如果我的系统中有很多弹窗,难道要给每个弹窗都写成这样吗?

  • 这种兼容JSX的方式,需要引入支持JSX的依赖!

  • 如果工程中不想即用template又用JSX呢?

  • 如果已经存在使用template的弹窗了,难道推翻重写吗?

  • ...


思考


首先承认一点命令式的封装的确可以解决问题,但是现在的封装都存一定的槽点。


如果有一种方式,即保持原来对话框的编写方式不变,又不需要关心JSXtemplate的问题,还保存了命令式封装的特点。这样是不是就完美了?


那真的可以同时做到这些吗?


doutub_img (2).png


如果存在一个这样的Hook可以将状态驱动的Dialog,转换为命令式的Dialog吗,那不就行了?


它来了:useCommandComponent


image.png


父组件这样写:


<script setup lang="ts">
import { ElButton } from 'element-plus';

import { useCommandComponent } from '../../hooks/useCommandComponent';

import Comp from './components/Comp.vue';
import MyDialog from './components/MyDialog.vue';

const myDialog = useCommandComponent(MyDialog);
</script>

<template>
<div>
<ElButton @click="myDialog({ title: '父组件弹窗' })"> 打开弹窗 </ElButton>
<Comp text="子组件1"></Comp>
<Comp text="子组件2"></Comp>
</div>
</template>

Comp组件这样写:


<script setup lang="ts">
import { ElButton } from 'element-plus';

import { useCommandComponent } from '../../../hooks/useCommandComponent';

import MyDialog from './MyDialog.vue';

const myDialog = useCommandComponent(MyDialog);

const props = defineProps<{
text: string;
}>();
</script>

<template>
<div>
<span>{{ props.text }}</span>
<ElButton @click="myDialog({ title: props.text })">提交(需确认)</ElButton>
</div>
</template>

对于MyDialog无需任何改变,保持原来的样子就可以了!


useCommandComponent真的做到了,即保持原来组件的编写方式,又可以实现命令式调用


使用效果:


Kapture 2023-07-07 at 23.44.25.gif


是不是感受到了莫名的舒适?🤨


不过别急😊,要想体验这种极致的舒适,你的Dialog还需要遵循两个约定!


两个约定


如果想要极致舒适的使用useCommandComponent,那么弹窗组件的编写就需要遵循一些约定(其实这些约定应该是弹窗组件的最佳实践)。


约定如下:



  • 弹窗组件的props需要有一个名为visible的属性,用于驱动弹窗的打开和关闭。

  • 弹窗组件需要emit一个close事件,用于弹窗关闭时处理命令式弹窗。


如果你的弹窗组件满足上面两个约定,那么就可以通过useCommandComponent极致舒适的使用了!!



这两项约定虽然不是强制的,但是这确实是最佳实践!不信你去翻所有的UI框看看他们的实现。我一直认为学习和生产中多学习优秀框架的实现思路很重要!



如果不遵循约定


这时候有的同学可能会说:哎嘿,我就不遵循这两项约定呢?我的弹窗就是要标新立异的不用visible属性来控制打开和关闭,我起名为dialogVisible呢?我的弹窗就是没有close事件呢?我的事件是具有业务意义的submitcancel呢?...


doutub_img.png


得得得,如果真的没有遵循上面的两个约定,依然可以舒适的使用useCommandComponent,只不过在我看来没那么极致舒适!虽然不是极致舒适,但也要比其他方案舒适的多!


如果你的弹窗真的没有遵循“两个约定”,那么你可以试试这样做:


<script setup lang="ts">
// ...
const myDialog = useCommandComponent(MyDialog);

const handleDialog = () => {
myDialog({
title: '父组件弹窗',
dialogVisible: true,
onSubmit: () => myDialog.close(),
onCancel: () => myDialog.close(),
});
};
</script>

<template>
<div>
<ElButton @click="handleDialog"> 打开弹窗 </ElButton>
<!--...-->
</div>
</template>

如上,只需要在调用myDialog函数时在props中将驱动弹窗的状态设置为true,在需要关闭弹窗的事件中调用myDialog.close()即可!


这样是不是看着虽然没有上面的极致舒适,但是也还是挺舒适的?


源码与实现


实现思路


对于useCommandComponent的实现思路,依然是命令式封装。相比于上面的那两个实现方式,useCommandComponent是将组件作为参数传入,这样保持组件的编写习惯不变。并且useCommandComponent遵循单一职责原则,只做好组件的挂载和卸载工作,提供足够的兼容性



其实useCommandComponent有点像React中的高阶组件的概念



源码


源码不长,也很好理解!在实现useCommandComponent的时候参考了ElementPlus的MessageBox


源码如下:


import { AppContext, Component, ComponentPublicInstance, createVNode, getCurrentInstance, render, VNode } from 'vue';

export interface Options {
visible?: boolean;
onClose?: () => void;
appendTo?: HTMLElement | string;
[key: string]: unknown;
}

export interface CommandComponent {
(options: Options): VNode;
close: () => void;
}

const getAppendToElement = (props: Options): HTMLElement => {
let appendTo: HTMLElement | null = document.body;
if (props.appendTo) {
if (typeof props.appendTo === 'string') {
appendTo = document.querySelector<HTMLElement>(props.appendTo);
}
if (props.appendTo instanceof HTMLElement) {
appendTo = props.appendTo;
}
if (!(appendTo instanceof HTMLElement)) {
appendTo = document.body;
}
}
return appendTo;
};

const initInstance = <T extends Component>(
Component: T,
props: Options,
container: HTMLElement,
appContext: AppContext | null = null
) =>
{
const vNode = createVNode(Component, props);
vNode.appContext = appContext;
render(vNode, container);

getAppendToElement(props).appendChild(container);
return vNode;
};

export const useCommandComponent = <T extends Component>(Component: T): CommandComponent => {
const appContext = getCurrentInstance()?.appContext;

const container = document.createElement('div');

const close = () => {
render(null, container);
container.parentNode?.removeChild(container);
};

const CommandComponent = (options: Options): VNode => {
if (!Reflect.has(options, 'visible')) {
options.visible = true;
}
if (typeof options.onClose !== 'function') {
options.onClose = close;
} else {
const originOnClose = options.onClose;
options.onClose = () => {
originOnClose();
close();
};
}
const vNode = initInstance<T>(Component, options, container, appContext);
const vm = vNode.component?.proxy as ComponentPublicInstance<Options>;
for (const prop in options) {
if (Reflect.has(options, prop) && !Reflect.has(vm.$props, prop)) {
vm[prop as keyof ComponentPublicInstance] = options[prop];
}
}
return vNode;
};

CommandComponent.close = close;

return CommandComponent;
};

export default useCommandComponent;

除了命令式的封装外,我加入了const appContext = getCurrentInstance()?.appContext;。这样做的目的是,传入的组件在这里其实已经独立于应用的Vue上下文了。为了让组件依然保持和调用方相同的Vue上下文,我这里加入了获取上下文的操作!


基于这个情况,在使用useCommandComponent时需要保证它在setup中被调用,而不是在某个点击事件的处理函数中哦~


最后


如果你觉得useCommandComponent对你在开发中有所帮助,麻烦多点赞评论收藏😊


如果useCommandComponent对你实现某些业务有所启发,麻烦多点赞评论收藏😊


如果...,麻烦多点赞评论收藏😊


如果大家有其他弹窗方案,欢迎留言交流哦!


1632388279060.gif

收起阅读 »

前端业务代码,怎么写测试用例?

web
为什么前端写测试用例困难重重 关于不同测试的种类,网上有很多资料,比如单元、集成、冒烟测试,又比如 TDD BDD 等等,写测试的好处也不用多说,但是就前端来说,写测试用例,特别是针对业务代码测试用例写还是不太常见的事情。我总结的原因有如下几点: 搭建测试环...
继续阅读 »

为什么前端写测试用例困难重重


关于不同测试的种类,网上有很多资料,比如单元、集成、冒烟测试,又比如 TDD BDD 等等,写测试的好处也不用多说,但是就前端来说,写测试用例,特别是针对业务代码测试用例写还是不太常见的事情。我总结的原因有如下几点:



  • 搭建测试环境比较麻烦,什么 jest config、mock 这个、mock 那个,有那个时间写完 mock,都能写完业务代码了

  • 网上能找到的测试教程资料都是简单的 demo,与真实业务场景不匹配,看了这些 demo,还是不知道怎么写测试

  • 网上很难找到合适的模版项目,像 antd 这种都是针对公共 UI 组件的测试用例,对我们写业务逻辑的测试用例没有太大的参考价值

  • 业务需求改动频繁,导致维护测试用例的成本高


我最近在做一个 React Native 项目,想践行 TDD 开发,所以我花了几天时间,梳理了市面上常见的前端测试工具,看了 N 个前端测试实践的文章,最终选择了大道至简,只用下面两个库:



  • jest,不多说,最流行的类 react 项目的测试框架

  • react-test-renderer,用于测试组件 UI,搭配 jest 的快照功能一起使用,让测试 UI 变得不再繁琐


业务代码的测试用例之心法


不要这样写业务代码的测试用例


不要面向实现写测试用例,比如针对某个组件,把每个 props 都写一个测试用例,而 props 很有可能因为业务改动或重构等原因改动,导致我们也要改动相应的测试用例代码,尽管测试用例本身没有错误。


页面跳转、没有任何交互的静态页面、兼容性、


业务代码要怎么写测试


为了平衡开发时间和写测试用例的时间,我认为对于业务代码来说,测试用例不需要面面俱到,什么逻辑都写个测试用例。我们只需要关注用户交互相关的逻辑,具体来说,我会重点关注以下方面:



  • pure 组件的 UI 是否有对应的测试用例

  • 面向功能测试,比如用户输入、点击按钮、加载数据时的 UI、数据为空时的 UI

  • 针对工具函数的各种输入输出测试


写测试用例所需的成本由低到高依次是:

reducer → pure component → business component → DOM testing → e2e

其中 pure component 指的是只有 props 的,只负责渲染的 dummy component。Business compoent 指的是包含 store dispatcher、api fetch、副作用等业务逻辑的业务组件。


程度越靠后,测试的成本越高,所以我们可以花多些精力在测试组件和 reducer 上,少花时间在 DOM 测试和 e2e 测试上。而对于 reducer、pure component、business component 来说,它们的测试用例是相辅相成的,因为 business component 里就包括了 reducer 的使用和 pure component 的渲染,

所以测 business compoent,就等于侧面测到了 reducer 和 pure component。这个测试方法在 Redux 官网也有提到:

完全避免直接测试任何 Redux 代码,将其视为实现细节cn.redux.js.org/usage/writi…


案例:如何测试 pure component


Dumb Component 只用来接收 props 并进行展示,所以它更易于测试,我们只需要 mock 父组件传来的 props 即可,然后搭配 Jest 的 snapshot 快照来判断测试用例是否通过。


比如我们要测试 Tag Component,这个组件的功能很简单,就是展示标签 UI:


Pasted image 20230708154654.png


我们可以用快照测试来记录下这个组件的 UI,如果以后 UI 有改动,这条测试用例就会报错。比如我们现在多了一个业务逻辑,需要每个标签都自动带上 [],好比之前标签展示的是 text,根据业务逻辑,现在标签展示的是 [text]


我们修改 Tag 组件,添加相应的业务逻辑:


Pasted image 20230708155008.png


这时候跑测试用例,可以发现用例报错,而且我们可以报错结果知道组件的 UI 进行了哪些改动,如果这个改动是符合我们期待的,那么直接更新 snapshot 即可:


Pasted image 20230708155111.png


同时,提交代码的时候,这条测试用例对应的 snapshot 也会跟着一起 commit,在 Code Review 阶段我们可以根据 snapshot 来直观的看到组件 UI 进行了哪些改动,美滋滋啊。


如何对 Reducer 进行测试


用 Redux 作为状态管理工具时,一种比较好的编程范式是,让 Store 提供数据,组件只负责渲染数据。组件 UI 可能会因为业务变动而频繁的更改,而 Redux 中的数据逻辑不会经常更改,所以在没有任何像上面那种组件 UI 的快照测试时,可以优先测试 Redux,后期补上组件的快照测试。


工作流:



  1. 先写测试用例,开一个 snapshot

  2. 开启 jest --watch,编写 action 和 reducer 相关代码

  3. 当 snapshot 是我们期待的值,就保存这个 snapshot

  4. 完成测试用例
    作者:Kz
    来源:juejin.cn/post/7253102401452032055
    的编写

收起阅读 »

关于浏览器缓存策略这件事儿

web
前言 我们打开百度这个网站并刷新多次时时,注意到百度的logo是没有每次都加载一遍的。我们知道图片是img标签中的src属性加载出来的,这也需要浏览器去请求图片资源的,那么为什么刷新多次浏览器只请求了一次图片资源呢?这就涉及到了浏览器的缓存策略了,这张图片被浏...
继续阅读 »

前言


我们打开百度这个网站并刷新多次时时,注意到百度的logo是没有每次都加载一遍的。我们知道图片是img标签中的src属性加载出来的,这也需要浏览器去请求图片资源的,那么为什么刷新多次浏览器只请求了一次图片资源呢?这就涉及到了浏览器的缓存策略了,这张图片被浏览器缓存下来了!


正文


一、为什么要有浏览器的缓存策略?



  • 提升用户体验,减少页面重复的http请求


二、为什么通过浏览器url地址栏访问的html页面不缓存?



  • 强制刷新页面浏览器url地址栏访问资源 时,浏览器默认会在请求头中设置Cache-control: no-cache,如设置该属性浏览器就会忽略响应头中的 Cache-control


如何优化网络资源请求的时间呢?有以下三种方式。


三、CDN网络分发



CDN:CDN会通过负载均衡技术,将用户的请求定向到最合适缓存服务器上去获取内容。



比如说,北京的用户,我们让他访问北京的节点,深圳的用户,我们让他访问深圳的节点。通过就近访问,加速用户对网站的访问,进而解决Internet网络拥堵状况,提高用户访问网络的响应速度。


四、强缓存



强缓存是浏览器的缓存策略,后端设置响应头中的属性值就能设置文件资源在浏览器的缓存时间过了缓存的有效期再次访问时,文件资源需再次加载



强缓存有两种方式来控制资源被浏览器缓存的时长:



  1. 后端设置响应头中的 Cache-control: max-age=3600 来控制缓存时长(为一个小时)

  2. 后端设置响应头中的 Expires:xxx 来控制缓存的截止日期(截止日期为xxx)


我们直接上代码让你更好理解,我们需要实现一个页面,页面上需展现一个标题一张图片


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>Earth</h1>
<img src="assets/image/earth.jpeg" alt="">
</body>
</html>

const http = require('http');
const path = require('path');
const fs = require('fs');
const mime = require('mime'); //接收一个文件后缀,返回一个文件类型

const server = http.createServer((req, res) => {
const filePath = path.resolve(__dirname, `www/${req.url}`) //resolve合并地址
if (fs.existsSync(filePath)) { //判断路径是否有效
const stats = fs.statSync(filePath) //获取文件信息
const isDir = stats.isDirectory() //是否是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
//读取文件
if (!isDir || fs.existsSync(filePath)) {

//判断前端请求的路径的后缀名是图片还是文本
const { ext } = path.parse(filePath) //.html .jpeg

const time = new Date(Date.now() + 3600000).toUTCString() //定义时间 作为缓存时间的有效期

let status = 200

res.writeHead(status, {
'Content-Type': `${mime.getType(ext)};charset=utf-8`,
'Cache-control': 'max-age=3600', //缓存时长为一小时
// 'expires': time //截止日期 缓存一小时后过期
})

if (status === 200) {
const fileStream = fs.createReadStream(filePath) //将文件读成流类型
fileStream.pipe(res) //将文件流导入响应体
}else{
res.end();
}

}
}
})

server.listen(3000, () => {
console.log('listening on port 3000');
})

第一次运行:
image.png


刷新页面后,可以看到图片资源没有重新加载:
image.png


三、协商缓存


我们想象这样的场景:当我们偷偷把图片偷偷换成另一张图片,图片名依然和之前那张一样,会是什么结果呢?

操作后,刷新页面发现图片还是之前那张图片,并没有换成新的!那这就出事儿了,后端图片换了,用户看到的还是老图片,有一种方案是改变图片资源的名字,直接请求最新图片资源,但这并不是最优方案,终极方案是需要协商缓存的帮忙。



协商缓存也是浏览器的缓存策略,它也有两种方式辅助强缓存,来判断文件资源是否被修改



1. 后端设置响应头中的 last-modified: xxxx



  • 辅助强缓存,让URL地址栏请求的资源也能被缓存

  • 辅助强缓存,借助请求头中的if-modified-since来判断资源文件是否被修改,如果被修改则返回新的资源,否则返回304状态码,让前端读取本地缓存


代码如下:


const http = require('http');
const path = require('path');
const fs = require('fs');
const mime = require('mime'); //接收一个文件后缀,返回一个文件类型

const server = http.createServer((req, res) => {
const filePath = path.resolve(__dirname, `www/${req.url}`) //resolve合并地址
if (fs.existsSync(filePath)) { //判断路径是否有效
const stats = fs.statSync(filePath) //获取文件信息
const isDir = stats.isDirectory() //是否是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
//读取文件
if (!isDir || fs.existsSync(filePath)) {

//判断前端请求的路径的后缀名是图片还是文本
const { ext } = path.parse(filePath) //.html .jpeg

const time = new Date(Date.now() + 3600000).toUTCString() //定义时间 作为缓存时间的有效期

const timeStamp = req.headers['if-modified-since'] //请求头的if-modified-since字段
let status = 200

//判断文件是否修改过
if (timeStamp && Number(timeStamp) === stats.mtimeMs) { //timeStamp为字符串 转换为number类型判断
status = 304
}

res.writeHead(status, {
'Content-Type': `${mime.getType(ext)};charset=utf-8`,
'Cache-control': 'max-age=3600', //缓存时长为一小时 //max-age=0或no-cache不需要缓存
// 'expires': time //截止日期 缓存一小时后过期
'last-modified': stats.mtimeMs //文件最后一次修改时间
})

if (status === 200) {
const fileStream = fs.createReadStream(filePath) //将文件读成流类型
fileStream.pipe(res) //将文件流导入响应体
}else{
res.end();
}

}
}
})

server.listen(3000, () => {
console.log('listening on port 3000');
})

我们只要看last-modified这个字段的值有无变化即可:
image.png


2. Etag:文件的标签



  • 请求头中会被携带If--Match

  • Etag保证了每一个资源是唯一的,资源变化都会导致Etag变化。服务器根据If--Match值来判断是否命中缓存。 当服务器返回304的响应时,由于ETag重新生成过,response header中还会把这个ETag返回,即使这个ETag跟之前的没有变化。


image.png

代码如下:


const http = require('http');
const path = require('path');
const fs = require('fs');
const mime = require('mime'); //接收一个文件后缀,返回一个文件类型
const md5 = require('crypto-js/md5');

const server = http.createServer((req, res) => {
const filePath = path.resolve(__dirname, `www/${req.url}`) //resolve合并地址
if (fs.existsSync(filePath)) { //判断路径是否有效
const stats = fs.statSync(filePath) //获取文件信息
const isDir = stats.isDirectory() //是否是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
//读取文件
if (!isDir || fs.existsSync(filePath)) {

//判断前端请求的路径的后缀名是图片还是文本
const { ext } = path.parse(filePath) //.html .jpeg
const content = fs.readFileSync(filePath);
let status = 200

//判断文件是否被修改过
if (req.headers['if-none-match'] == md5(content)) {
status=304
}

res.writeHead(status, {
'Content-Type': `${mime.getType(ext)};charset=utf-8`,
'Cache-control': 'max-age=3600', //缓存时长为一小时 //max-age=0或no-cache不需要缓存
'Etag': md5(content) //文件资源的md5值
})

if (status === 200) {
const fileStream = fs.createReadStream(filePath) //将文件读成流类型
fileStream.pipe(res) //将文件流导入响应体
} else {
res.end();
}

}
}
})

server.listen(3000, () => {
console.log('listening on port 3000');
})


最后附上一张图便于更好理解浏览器的缓存策略:


image.png

收起阅读 »

js十大手撕代码

web
前言 js中有很多API贼好用,省下了很多工夫,你知道它的原理吗?这篇文章对它们做一个总结。 正文 一、手撕instanceof instanceof的原理:通过判断对象的原型是否等于构造函数的原型来进行类型判断 代码实现: const myInstanc...
继续阅读 »

前言


js中有很多API贼好用,省下了很多工夫,你知道它的原理吗?这篇文章对它们做一个总结。


正文


一、手撕instanceof



  • instanceof的原理:通过判断对象的原型是否等于构造函数的原型来进行类型判断

  • 代码实现:


const myInstanceOf=(Left,Right)=>{
if(!Left){
return false
}
while(Left){
if(Left.__proto__===Right.prototype){
return true
}else{
Left=Left.__proto__
}
}
return false
}

//验证
console.log(myInstanceOf({},Array)); //false

二、手撕call,apply,bind


call,apply,bind是通过this的显示绑定修改函数的this指向


1. call


call的用法:a.call(b) -> 将a的this指向b

我们需要借助隐式绑定规则来实现call,具体实现步骤如下:

往要绑定的那个对象(b)上挂一个属性,值为需要被调用的那个函数名(a),在外层去调用函数。


function foo(x,y){
console.log(this.a,x+y);
}

const obj={
a:1
}

Function.prototype.myCall=function(context,...args){
if(typeof this !== 'function') return new TypeError('is not a function')
const fn=Symbol('fn') //使用Symbol尽可能降低myCall对其他的影响
context[fn]=this //this指向foo
const res=context[fn](...args) //解构,调用fn
delete context[fn] //不要忘了删除obj上的工具函数fn
return res //将结果返回
}

//验证
foo.myCall(obj,1,2) //1,3

2. apply


apply和call的本质区别就是接受的参数形式不同,call接收零散的参数,而apply以数组的方式接收参数,实现思路完全一样,代码如下:


function foo(x,y){
console.log(this.a,x+y);
}

const obj={
a:1
}

Function.prototype.myApply=function(context,args){
if(typeof this !== 'function') return new TypeError('is not a function')
const fn=Symbol('fn') //尽可能降低myCall对其他的影响
context[fn]=this
context[fn](...args)
delete context[fn]
}

//验证
foo.myApply(obj,[1,2]) //1,3

3. bind


bind和call,apply的区别是会返回一个新的函数,接收零散的参数

需要注意的是,官方bind的操作是这样的:



  • 当new了bind返回的函数时,相当于new了foo,且new的参数需作为实参传给foo

  • foo的this.a访问不到obj中的a


function foo(x,y,z){
this.name='zt'
console.log(this.a,x+y+z);
}

const obj={
a:1
}


Function.prototype.myBind=function(context,...args){

if(typeof this !== 'function') return new TypeError('is not a function')

context=context||window

let _this=this

return function F(...arg){
//判断返回出去的F有没有被new,有就要把foo给到new出来的对象
if(this instanceof F){
return new _this(...args,...arg) //new一个foo
}
_this.apply(context,args.concat(arg)) //this是F的,_this是foo的 把foo的this指向obj用apply
}
}

//验证
const bar=foo.myBind(obj,1,2)
console.log(new bar(3)); //undefined 6 foo { name: 'zt' }


三、手撕深拷贝


这篇文章中详细记录了实现过程
【js手写】浅拷贝与深拷贝


四、手撕Promise


思路:



  • 我们知道,promise是有三种状态的,分别是pending(异步操作正在进行), fulfilled(异步操作成功完成), rejected(异步操作失败)。我们可以定义一个变量保存promise的状态。

  • resolve和reject的实现:把状态变更,并把resolve或reject中的值保存起来留给.then使用

  • 要保证实例对象能访问.then,必须将.then挂在构造函数的原型上

  • .then接收两个函数作为参数,我们必须对所传参数进行判断是否为函数,当状态为fulfilled时,onFulfilled函数触发,并将前面resolve中的值传给onFulfilled函数;状态为rejected时同理。

  • 当在promise里放一个异步函数(例:setTimeout)包裹resolve或reject函数时,它会被挂起,那么当执行到.then时,promise的状态仍然是pending,故不能触发.then中的回调函数。我们可以定义两个数组分别存放.then中的两个回调函数,将其分别在resolve和reject函数中调用,这样保证了在resolve和reject函数触发时,.then中的回调函数即能触发。


代码如下:


const PENDING = 'pending'
const FULFILLED = 'fullfilled'
const REJECTED = 'rejected'

function myPromise(fn) {
this.state = PENDING
this.value = null
const that = this
that.resolvedCallbacks = []
that.rejectedCallbacks = []

function resolve(val) {
if (that.state == PENDING) {
that.state = FULFILLED
that.value = val
that.resolvedCallbacks.map((cb)=>{
cb(that.value)
})
}
}
function reject(val) {
if (that.state == PENDING) {
that.state = REJECTED
that.value = val
that.rejectedCallbacks.map((cb)=>{
cb(that.value)
})
}
}

try {
fn(resolve, reject)
} catch (error) {
reject(error)
}

}

myPromise.prototype.then = function (onFullfilled, onRejected) {
const that = this
onFullfilled = typeof onFullfilled === 'function' ? onFullfilled : v => v
onRejected= typeof onRejected === 'function' ? onRejected : r => { throw r }

if(that.state===PENDING){
that.resolvedCallbacks.push(onFullfilled)
that.resolvedCallbacks.push(onRejected)
}
if (that.state === FULFILLED) {
onFullfilled(that.value)
}
if (that.state === REJECTED) {
onRejected(that.value)
}
}

//验证 ok ok
let p = new myPromise((resolve, reject) => {
// reject('fail')
resolve('ok')
})

p.then((res) => {
console.log(res,'ok');
}, (err) => {
console.log(err,'fail');
})

五、手撕防抖,节流


这篇文章中详细记录了实现过程
面试官:什么是防抖和节流?如何实现?应用场景?


六、手撕数组API


1. forEach()


思路:



  • forEach()用于数组的遍历,参数接收一个回调函数,回调函数中接收三个参数,分别代表每一项的值、下标、数组本身。

  • 要保证数组能访问到我们自己手写的API,必须将其挂到数组的原型上


代码实现:


const arr = [
{ name: 'zt', age: 18 },
{ name: 'aa', age: 19 },
{ name: 'bb', age: 18 },
{ name: 'cc', age: 21 },
]

//代码实现
Array.prototype.my_forEach = function (callback) {
for (let i = 0; i < this.length; i++) {
callback(this[i], i, this)
}
}

//验证
arr.my_forEach((item, index, arr) => { //111 111
if (item.age === 18) {
item.age = 17
return
}
console.log('111');
})


2. map()


思路:



  • map()也用于数组的遍历,与forEach不同的是,它会返回一个新数组,这个新数组是map接收的回调函数返回值

    代码实现:


const arr = [
{ name: 'zt', age: 18 },
{ name: 'aa', age: 19 },
{ name: 'bb', age: 18 },
{ name: 'cc', age: 21 },
]

Array.prototype.my_map=function(callback){
const res=[]
for(let i=0;i<this.length;i++){
res.push(callback(this[i],i,this))
}
return res
}

//验证
let newarr=arr.my_map((item,index,arr)=>{
if(item.age>18){
return item
}
})
console.log(newarr);
//[
// undefined,
// { name: 'aa', age: 19 },
// undefined,
// { name: 'cc', age: 21 }
//]

3. filter()


思路:



  • filter()用于筛选过滤满足条件的元素,并返回一个新数组


代码实现:


const arr = [
{ name: 'zt', age: 18 },
{ name: 'aa', age: 19 },
{ name: 'bb', age: 18 },
{ name: 'cc', age: 21 },
]

Array.prototype.my_filter = function (callback) {
const res = []
for (let i = 0; i < this.length; i++) {
callback(this[i], i, this) && res.push(this[i])
}
return res
}

//验证
let newarr = arr.my_filter((item, index, arr) => {
return item.age > 18
})
console.log(newarr); [ { name: 'aa', age: 19 }, { name: 'cc', age: 21 } ]

4. reduce()


思路:



  • reduce()用于将数组中所有元素按指定的规则进行归并计算,返回一个最终值

  • reduce()接收两个参数:回调函数、初始值(可选)。

  • 回调函数中接收四个参数:初始值 或 存储上一次回调函数的返回值、每一项的值、下标、数组本身。

  • 若不提供初始值,则从第二项开始,并将第一个值作为第一次执行的返回值


代码实现:


const arr = [
{ name: 'zt', age: 18 },
{ name: 'aa', age: 19 },
{ name: 'bb', age: 18 },
{ name: 'cc', age: 21 },
]

Array.prototype.my_reduce = function (callback,...arg) {
let pre,start=0
if(arg.length){
pre=arg[0]
}
else{
pre=this[0]
start=1
}
for (let i = start; i < this.length; i++) {
pre=callback(pre,this[i], i, this)
}
return pre
}

//验证
const sum = arr.my_reduce((pre, current, index, arr) => {
return pre+=current.age
},0)
console.log(sum); //76


5. fill()


思路:



  • fill()用于填充一个数组的所有元素,它会影响原数组 ,返回值为修改后原数组

  • fill()接收三个参数:填充的值、起始位置(默认为0)、结束位置(默认为this.length-1)。

  • 填充遵循左闭右开的原则

  • 不提供起始位置和结束位置时,默认填充整个数组


代码实现:


Array.prototype.my_fill = function (value,start,end) {
if(!start&&start!==0){
start=0
}
end=end||this.length
for(let i=start;i<end;i++){
this[i]=value
}
return this
}

//验证
const arr=new Array(7).my_fill('hh',null,3) //往数组的某个位置开始填充到哪个位置,左闭右开
console.log(arr); //[ 'hh', 'hh', 'hh', <4 empty items> ]


6. includes()


思路:



  • includes()用于判断数组中是否包含某个元素,返回值为 true 或 false

  • includes()提供第二个参数,支持从指定位置开始查找


代码实现:


const arr = ['a', 'b', 'c', 'd', 'e']

Array.prototype.my_includes = function (item,start) {
if(start<0){start+=this.length}
for (let i = start; i < this.length; i++) {
if(this[i]===item){
return true
}
}
return false
}

//验证
const flag = arr.my_includes('c',3) //查找的元素,从哪个下标开始查找
console.log(flag); //false


7. join()


思路:



  • join()用于将数组中的所有元素指定符号连接成一个字符串


代码实现:


const arr = ['a', 'b', 'c']

Array.prototype.my_join = function (s = ',') {
let str = ''
for (let i = 0; i < this.length; i++) {
str += `${this[i]}${s}`
}
return str.slice(0, str.length - 1)
}

//验证
const str = arr.my_join(' ')
console.log(str); //a b c

8. find()


思路:



  • find()用于返回数组中第一个满足条件元素,找不到返回undefined

  • find()的参数为一个回调函数


代码实现:


const arr = [
{ name: 'zt', age: 18 },
{ name: 'aa', age: 19 },
{ name: 'bb', age: 18 },
{ name: 'cc', age: 21 },
]

Array.prototype.my_find = function (callback) {
for (let i = 0; i < this.length; i++) {
if(callback(this[i], i, this)){
return this[i]
}

}
return undefined
}

//验证
let j = arr.my_find((item, index, arr) => {
return item.age > 19
})
console.log(j); //{ name: 'cc', age: 21 }

9. findIndex()


思路:



  • findIndex()用于返回数组中第一个满足条件索引,找不到返回-1

  • findIndex()的参数为一个回调函数


代码实现:


const arr = [
{ name: 'zt', age: 18 },
{ name: 'aa', age: 19 },
{ name: 'bb', age: 18 },
{ name: 'cc', age: 21 },
]

Array.prototype.my_findIndex = function (callback) {
for (let i = 0; i < this.length; i++) {
if(callback(this[i], i, this)){
return i
}
}
return -1
}


let j = arr.my_findIndex((item, index, arr) => {
return item.age > 19
})
console.log(j); //3

10. some()


思路:



  • some()用来检测数组中的元素是否满足指定条件。

  • 有一个元素符合条件,则返回true,且后面的元素会再检测。


代码实现:


const arr = [
{ name: 'zt', age: 18 },
{ name: 'aa', age: 19 },
{ name: 'bb', age: 18 },
{ name: 'cc', age: 21 },
]

Array.prototype.my_some = function (callback) {
for (let i = 0; i < this.length; i++) {
if(callback(this[i], i, this)){
return true
}
}
return false
}

//验证
const flag = arr.some((item, index, arr) => {
return item.age > 20
})
console.log(flag); //true

11. every()


思路:



  • every() 用来检测所有元素是否都符合指定条件。

  • 有一个不满足条件,则返回false,后面的元素都会再执行。


代码实现:


const arr = [
{ name: 'zt', age: 18 },
{ name: 'aa', age: 19 },
{ name: 'bb', age: 18 },
{ name: 'cc', age: 21 },
]

Array.prototype.my_every = function (callback) {
for (let i = 0; i < this.length; i++) {
if(!callback(this[i], i, this)){
return false
}
}
return true
}

//验证
const flag = arr.my_every((item, index, arr) => {
return item.age > 16
})
console.log(flag); //true


七、数组去重


1. 双层for循环 + splice()


let arr = [1, 1, '1', '1', 2, 2, 2, 3, 2]
function unique(arr) {
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
if (arr[i] === arr[j]) {
arr.splice(j, 1)
j-- //删除后j向前走了一位,下标需要减一,避免少遍历一位
}
}
}
return arr
}

console.log(unique(arr)) //[ 1, '1', 2, 3 ]

2. 排序后做前后比较


let arr = [1, 1, '1', '1', 2, 2, 2, 3, 2]

function unique(arr) {
let res = []
let seen //记录上一次比较的值
let newarr=[...arr] //解构出来,开辟一个新数组
newarr.sort((a,b)=>a-b) //sort会影响原数组 n*logn
for (let i = 0; i < newarr.length; i++) {
if (newarr[i]!==seen) {
res.push(newarr[i])
}
seen=newarr[i]
}
return res
}

console.log(unique(arr)) //[ 1, '1', 2, 3 ]

3. 借助include


let arr = [1, 1, '1', '1', 2, 2, 2, 3, 2]

function unique(arr) {
let res = []
for (let i = 0; i < arr.length; i++) {
if(!res.includes(arr[i])){
res.push(arr[i])
}
}
return res
}

console.log(unique(arr)) //[ 1, '1', 2, 3 ]

4. 借助set


let arr = [1, 1, '1', '1', 2, 2, 2, 3, 2]
const res1 = Array.from(new Set(arr));
console.log(res1); //[ 1, '1', 2, 3 ]

八、数组扁平化


1. 递归


let arr1 = [1, 2, [3, 4, [5],6]]

function flatter(arr) {
let len = arr.length
let result = []
for (let i = 0; i < len; i++) { //遍历数组每一项
if (Array.isArray(arr[i])) { //判断子项是否为数组并拼接起来
result=result.concat(flatter(arr[i]))//是则使用递归继续扁平化
}
else {
result.push(arr[i]) //不是则存入result
}
}
return result
}

console.log(flatter(arr1)) //[ 1, 2, 3, 4, 5, 6 ]

2. 借助reduce (本质也是递归)


let arr1 = [1, 2, [3, 4, [5],6]]

const flatter = arr => {
return arr.reduce((pre, cur) => {
return pre.concat(Array.isArray(cur) ? flatten(cur) : cur);
}, [])
}
console.log(flatter(arr1)) //[ 1, 2, 3, 4, 5, 6 ]

3. 借助正则


let arr1 = [1, 2, [3, 4, [5],6]]

const res = JSON.parse('[' + JSON.stringify(arr1).replace(/\[|\]/g, '') + ']');
console.log(res) //[ 1, 2, 3, 4, 5, 6 ]

九、函数柯里化


思路:



  • 函数柯里化是只传递给函数一部分参数调用它,让它返回一个函数去处理剩下的参数

  • 传入的参数大于等于原始函数fn的参数个数,则直接执行该函数,小于则继续对当前函数进行柯里化,返回一个接受所有参数(当前参数和剩余参数) 的函数


代码实现:


const my_curry = (fn, ...args) => 
args.length >= fn.length
? fn(...args)
: (...args1) => curry(fn, ...args, ...args1);

function adder(x, y, z) {
return x + y + z;
}
const add = my_curry(adder);
console.log(add(1, 2, 3)); //6
console.log(add(1)(2)(3)); //6
console.log(add(1, 2)(3)); //6
console.log(add(1)(2, 3)); //6

十、new方法


思路:



  • new方法主要分为四步:

    (1) 创建一个新对象

    (2) 将构造函数中的this指向该对象

    (3) 执行构造函数中的代码(为这个新对象添加属性

    (4) 返回新对象


function _new(obj, ...rest){
// 基于obj的原型创建一个新的对象
const newObj = Object.create(obj.prototype);

// 添加属性到新创建的newObj上, 并获取obj函数执行的结果.
const result = obj.apply(newObj, rest);

// 如果执行结果有返回值并且是一个对象, 返回执行的结果, 否则, 返回新创建的对象
return typeof result === 'object' ? result : newObj;
}



总结不易,

作者:zt_ever
来源:juejin.cn/post/7253260410664419389
动动手指给个赞吧!💗

收起阅读 »

在线代码编辑器介绍与选型

web
引言 作为数据生产和管理的平台,数据平台的一大核心功能是在线数据开发,工欲善其事必先利其器,所以平台具备一个功能较为丰富、用户体验友好的在线代码编辑器,就成为了前提条件。 经历最近一两年的代码编辑器方案调研、选型和开发,我们对内部平台使用的代码编辑器进行了统一...
继续阅读 »

引言


作为数据生产和管理的平台,数据平台的一大核心功能是在线数据开发,工欲善其事必先利其器,所以平台具备一个功能较为丰富、用户体验友好的在线代码编辑器,就成为了前提条件。


经历最近一两年的代码编辑器方案调研、选型和开发,我们对内部平台使用的代码编辑器进行了统一和升级,并根据用户需求和业务场景进行了插件化定制,其底层是使用了 Monaco Editor 来进行二次开发。


本文主要是结合自己的理解,对代码编辑器相关知识进行整理,跟大家分享。


1. 在线代码编辑器是什么?


1.1 介绍


在线代码编辑器是一种基于 Web 技术开发的代码文本编辑器,可以在 Web 浏览器中直接使用。它通常包括用户界面模块、文本处理模块、插件扩展模块等模块;用户可以通过 Web 编辑器创建、编辑各种类型的文本文件,例如 HTML、CSS、JavaScript、Markdown 等。


1.2 分类


我们先来看看编辑器的分类:


类型描述典型产品优势劣势
远古编辑器textarea 或contentEditable+execCommand早期轻型编辑器(《100行代码带你实现一个编辑器》系列)门槛低,短时间内快速研发无法定制
contentEditable+文档模型借助contentEditable,各种拦截用户操作draftjs (react)、quilljs (vue)、prosemirror(util)站在浏览器的肩膀上,可以实现绝大多数的业内需求无法突破浏览器本身的限制(排版)
独立开发脱离浏览器自带编辑能力,独立做光标和排版引擎Google Docs、WPS等所有内容都把握在自己手上,排版随意个性化技术难度较高,研发成本较大

第一类编辑器,其劣势明显:由于重度依赖浏览器 execCommand 接口,而该接口支持的能力非常有限,故大多数功能无法订制,比如 fontSize 只能设置 1 - 7。另外兼容性也是一大问题,例如 Safari 并没有支持 heading 的设置。参考 MDN。而且该类编辑器基本都会直接将 HTML 作为数据模型(Model)来使用,这样会引发另外一个问题:相同的UI,可能对应了不同的DOM结构。举个例子,对于“加粗字体”这个用户输入,在 chrome 上,是添加了<blod>标签,ie11上则是添加了<strong>标签。


第二类编辑器与上一类编辑器最大的不同是定义了自己的 Model 层,所有视图(View)都与 Model 一一对应,并且一切 View 的变化都将由 Model 层的变化引发。为了做到这一点,需要拦截一切用户操作,准确识别用户意图,再对 Model 层进行正确的修改。坑点主要来自于对用户操作的拦截以及浏览器实现层面上的一些疑难杂症。故该类编辑器实现中的 hack 代码会非常多,理解起来比较困难。


第三类编辑器,采用隐藏textarea方案,它只负责接收输入事件,其他视图输出全靠自己,相对来说,更容易解耦。因为基本脱离了浏览器原生的光标,这块可以实现出更强大的功能。排版引擎可以自己搞,只要码力够强,想搞一个从从上往下从右往左的富文本编辑器也没问题,也带来了各种各样的可能,比如可以通过将 View 层用 canvas 实现,以规避很多兼容性问题。


2. 一款优秀的在线代码编辑器需要有哪些功能?


下面我们来看一下一个可用于生产环境的在线代码编辑器需要有哪些能力和模块:



2.1 核心模块


模块名模块描述
文本编辑用于处理用户输入的文本内容,管理文本状态,还包括实现文本的插入、删除、替换、撤销、重做等操作
语言实现语言高亮、代码分析、代码补全、代码提示&校验等能力
主题主要用于实现主题的管理、注册、切换、等功能
渲染主要完成编辑器的整体设计与生命周期管理
命令 & 快捷键管理注册和编辑的各种命令,比如查找文件、撤销、复制&粘贴等,同时也支持将命令以快捷键的形式暴露给用户
通信 & 数据流管理编辑器各模块之前的通信,以及数据存储、流转过程

2.2 扩展模块


模块名模块描述
文本能力扩展在现有处理文本的基础上进行功能扩展,比如修改获取文本方式。
语言扩展包括自定义新语言,扩展现有语言的关键字,完善代码解析、提示&校验等能力。
主题扩展包括自定义新主题,扩展现有主题的能力
命令扩展增加新命令,或者改写&扩展现有命令

3. 开源市场上有哪些代码编辑器?


目前开源市场使用较多的代码编辑器主要有 3 个,分别是 Monaco Editor(第三类)、Ace(第三类)和 Code Mirror(第二类)。本文也将带大家去了解他们的整体架构,做一些对比分析。


3.1 Monaco Editor


基本介绍:


类别描述
介绍是一个功能相对比较完整的代码编辑器,实现使用了 MVP 架构,采用了模块化和组件化的思想,其中编辑器核心代码部分是与 vscode 共用的,从源码目录中能看到有很多 browser 与 common 的目录区分。
仓库地址github.com/microsoft/v…
入口文件/editor/editor.main.ts
开始使用editor.create()方法来自 /editor/standalone/browser/standaloneEditor.ts

目录结构:


├── base        			# 通用工具/协议和UI库
│ ├── browser # 基础UI组件,DOM操作,事件
│ ├── common # diff计算、处理,markdown解析器,worker协议,各种工具函数
├── editor # 代码编辑器核心
| ├── browser # 在浏览器环境下的实现,包括了用于处理 DOM 事件、测量文本尺寸和位置、渲染文本等功能的代码。
| ├── common # 浏览器和 Node.js 环境下共用的代码,其中包括了文本模型、文本编辑操作、语法分析等功能的实现
| ├── contrib # 扩展模块,包含很多额外功能 查找&替换,代码片段,多光标编辑等等
| └── standalone # 实现了一个完整的编辑器界面,也是我们通常使用的完整编辑器
├── language # 前端需要的几种语言类型,与basic-languages不同的是,这里的实现语言功能更完整,包含关键字提示与语法校验等
├── basic-languages # 基础语言声明,里面只包含了关键字的罗列,主要用于关键字的高亮,不包含提示和语法校验

特点:



  • 多线程处理,主要分为 主线程 和 语言服务线程(使用了 Web Worker 技术 来模拟多线程,主要通过 postMessage 来进行消息传递)

    • 主线程:主要负责处理用户与编辑器的交互操作,以及渲染编辑器的 UI 界面,还负责管理编辑器的生命周期和资源,例如创建和销毁编辑器实例、加载和卸载语言服务、加载和卸载扩展等。

    • 语言服务线程:负责提供代码分析、语法检查等功能,以及处理与特定语言相关的操作。




DOM 结构:


<div class="monaco-editor" role="presentation">
<div class="overflow-guard" role="presentation">
<div class="monaco-scrollable-element editor-scrollable" role="presentation">
<!--实现行高亮-->
<div class="monaco-editor-background" role="presentation"></div>
<!--实现关键字背景高亮-->
<div class="view-overlays" role="presentation">
<div>...</div>
</div>
<!--每一行内容-->
<div class="view-lines" role="presentation">
<div>...</div>
</div>
<!--光标-->
<div class="monaco-cursor-layer" role="presentation"></div>
<!--文本输入框-->
<textarea class="monaco-editor-textarea"></textarea>
<!--横向滚动条-->
<div class="scrollbar horizontal"></div>
<!--纵向滚动条-->
<div class="scrollbar vertical"></div>
</div>
</div>
</div>


3.2 Code Mirror


基本介绍:


类别描述
介绍CodeMirror 6 是一款浏览器端代码编辑器,基于 TypeScript,该版本进行了完全的重写,核心思想是模块化和函数式,支持超过 14 种语言的语法高亮,亮点是高性能、可扩展性高以及支持移动端。
仓库地址github.com/codemirror
入口文件由于高度模块化,没有一个集成的入口文件,这里放上核心库@codemirror/view的入口文件:src/index.ts

开始使用


import { EditorState } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view';
import { defaultKeymap } from '@codemirror/commands';
let startState = EditorState.create({
doc: 'console.log("hello, javascript!")',
extensions: [keymap.of(defaultKeymap)],
});
let view = new EditorView({
state: startState,
parent: document.body,
});

目录结构:


高度模块化(分为多个仓库),这里放上比较核心的库的分布和内部结构


核心模块:提供了编辑器视图(@codemirror/view)、编辑器状态(@codemirror/state)、基础命令(@codemirror/commands)等基础功能。


语言模块:提供了不同编程语言的语法高亮、自动补全、缩进等功能,例如@codemirror/lang-javascript@codemirror/lang-sql@codemirror/lang-python 等。


主题模块:提供了不同风格的编辑器主题,例如 @codemirror/theme-one-dark


扩展模块:提供了一些额外的编辑器功能,例如行号(@codemirror/gutter)、折叠(@codemirror/fold)、括号匹配(@codemirror/matchbrackets)等。


内部结构,以@codemirror/view为例:


├── src                         # 源文件夹
│ ├── editorview.ts # 编辑器视图层
│ ├── decoration.ts # 视图装饰
│ ├── cursor.ts # 光标的渲染
│ ├── domchange.ts # DOM 改变相关的逻辑
│ ├── domobserver.ts # 监听 DOM 的逻辑
│ ├── draw-selection.ts # 绘制选区
│ ├── placeholder.ts # placeholder的渲染
│ ├── ...
├── test # 测试用例
| ├── webtest-domchange.ts # 测试监听到 DOM 变化后的一系列处理。
| ├── ...

特点:


指导 CodeMirror 架构设计的核心观点是函数式代码(纯函数),它会创建一个没有副作用的新值,和命令式代码交互更方便。而浏览器 DOM 很明显也是命令式思维,和 CodeMirror 集成的大部分系统类似。


CodeMirror 6 的 state 表现层是严格函数式的 - 即 document 和 state 数据结构都是不可变的,而能操作它们的都是纯函数,view 包将它们封装在一个命令式接口中。


所以即使 editor 已经转到了新的 state,而旧的 state 依然原封不动的存在,保存旧状态和新状态在面对处理 state 改变的情况下极为有利,这也意味着直接改变一个 state 值,或者添加额外 state 属性的命令式扩展都是不建议的,后果也不太可控。


CodeMirror 处理状态更新的方式受 Redux 启发,除了极少数情况(如组合和拖拽处理),视图的状态完全是由 EditorState 里的 state 属性决定的。


通过创建一个描述改变document、selection 或其他 state 属性的 transaction,以这种函数调用方式来更新 state。这个 transaction 之后可以通过 dispatched 分发,告诉 view 更新 state,更新新 state 对应的 DOM 展示。


let transaction = view.state.update({ changes: { from: 0, insert: "0" }})
console.log(transaction.state.doc.toString()) // "0123"
// 此刻视图依然显示的旧状态
view.dispatch(transaction)
// 现在显示新状态了

典型的用户交互数据流如下图:



view 监听事件变化。当 DOM 事件发生时(或者快捷键触发的命令,或者由扩展注册的事件处理器),CodeMirror会把这些事件转换为新的状态 transcation,然后分发。此时生成一个新的 state,当接收到新 state 后就会去更新 DOM。


DOM 结构:


<div class="cm-editor [theme scope classes]">
<div class="cm-scroller">
<div class="cm-content" contenteditable="true">
<div class="cm-line">Content goes here</div>
<div class="cm-line">...</div>
</div>
</div>
</div>


cm-editor 为一个 editor view 实例(在 merge-view,也就是代码对比情况下,给做了一个合并,其实还是两个 editor view 合在一起)


cm-scroller 为编辑器主展示区,并且展示了滚动条


cm-tooltip-autocomplete 为展示一些独立的层,比如代码提示,代码补全等


cm-gutter 是行号


cm-content 是编辑器的内容区


cm-layer 是跟 content 平级的,主要负责自定义指针和选区的展示


view-port 为CodeMirror 的一个优化,只解析和渲染了这个可视区域内的 DOM


cm-line 是每一行的内容,里面就是真实的 DOM 了


line-decorator 是提供给插件使用,用来装饰每一行的


在这个架构下,每个 editor 比较独立,可以渲染多个



3.3 Ace


基本介绍:


类别描述
介绍基于 Web 技术的代码编辑器,可以在浏览器中运行,高性能,体积小,功能全是它的主要优点。支持了超过120种语言的语法高亮,超过20个不同风格的主题,与 Sublime,Vim 和 TextMate 等本地编辑器的功能和性能相匹配。
仓库地址github.com/ajaxorg/Ace
入口文件/src/Ace.js
开始使用Ace.edit()

目录结构:


Ace 的目录结构相对简单,按功能分成了一个个不同的 js 文件,我这里列举其中一部分,部分较为复杂的功能除了提供了入口 js 文件以外,还在对应同级建立了文件夹里面实现各种逻辑,这里列举了 layer (渲染层) 为例子。


src/
├── layer #渲染分层实现
├── cursor.js #鼠标滑入层
├── decorators.js #装饰层,例如波浪线
├── lines.js #行渲染层
├── text.js #文本内容层
├── ...
├── ... #其他功能,例如 keybord
├── Ace.js #入口文件
├── ...
├── autocomplete.js #定义了编辑器补全相关内容
├── clipboard.js #定义了pc移动端兼容的剪切板
├── config.js
├── document.js
├── edit_session.js #定义了 Session 对象
├── editor.js #定义了 editor 对象
├── editor_keybinding.js #键盘事件绑定
├── editor_mouse_handler.js
├── virtual_renderer.js #定义了渲染对象 Renderer,引用了 layer 中定义的个种类
├── ...
├── mode.js
├── search.js
├── selection.js
├── split.js
└── theme.js

特点:



  • 事件驱动

    • Ace 中提供了丰富的事件系统,以供使用者直接使用或者自定义,并且通过对事件的触发和响应来进行内部数据通信实现代码检查,数据更新等等



  • 多线程

    • Ace 编辑器将解析代码的任务交给 Web Worker 处理,以提高代码解析的速度并避免阻塞用户界面。在 Web Worke r中,Ace 使用 Acorn库来解析 JavaScript 代码,并将解析结果发送回主线程进行处理




DOM 结构:


<div class="ace-editor">

<textarea
class="ace_text-input"
wrap="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
>

</textarea>
<!-- 行号区域 -->
<div class="ace_gutter" aria-hidden="true">
<div
class="ace_layer ace_gutter-layer"
>

<div class="ace_gutter-cell" >1 <span></span></div>
</div>
</div>
<!-- 内容区域 -->
<div class="ace_scroller" >
<div class="ace_content">
<div class="ace_layer ace_print-margin-layer">
<div class="ace_print-margin" style="left: 580px; visibility: visible;"></div>
</div>
<div class="ace_layer ace_marker-layer">
<div class="ace_active-line"></div>
</div>
<div class="ace_layer ace_text-layer" >
<div class="ace_line" >
<span class="ace_keyword">select</span>
<span class="ace_keyword">from</span>
<span class="ace_string">'xxx'</span>
</div>
<div class="ace_line"></div>
</div>
<div class="ace_layer ace_marker-layer"></div>
<div class="ace_layer ace_cursor-layer ace_hidden-cursors">
<!-- 光标 -->
<div class="ace_cursor"></div>
</div>
</div>
</div>
<!-- 纵向滚动条 -->
<div class="ace_scrollbar ace_scrollbar-v">
<div class="ace_scrollbar-inner" >&nbsp;</div>
</div>
<!-- 横行滚动条 -->
<div class="ace_scrollbar ace_scrollbar-h">
<div class="ace_scrollbar-inner">&nbsp;</div>
</div>

</div>

4. 整体对比


4.1 功能完整度


类别Monaco EditorCode MirrorAce
代码主题内置 3 种,可扩展基于扩展来支持,现有官方 1 种内置 20+,可扩展
语言内置 70+, 可扩展基于扩展来支持,现有官方 16 种内置 110+,可扩展
代码提示/自动补全只支持 4 种语言,官方提供了自动补全的基础插件,可自行实现基于扩展来支持,官方提供了自动补全的基础插件只支持 4 种语言,官方提供了自动补全的基础插件,可自行实现
代码折叠
快捷键
多光标编辑
代码检查只支持 4 种语言,官方提供了自动补全的基础插件,可自行实现基于扩展来支持,官方提供了代码检查的基础插件只支持 4 种语言,官方提供了自动补全的基础插件,可自行实现
代码对比❌,需自己扩展
MiniMap❌,需自己扩展❌,需自己扩展
多文本管理❌,需自己扩展
多视图❌,需自己扩展
协同编辑可引入额外插件支持 github.com/convergence…架构支持
移动端支持

4.2 性能体验


类别Monaco EditorCode MirrorAce
核心包大小800KB 左右核心包 115 KB 左右(未压缩)200KB 左右(不同版本有轻微出入)
编辑器渲染 (无代码)400ms 左右仅核心包情况下,120ms 左右185 ms 左右(实际使用包)

5. 结论与展望


一年前我们因为Monaco Editor丰富的生态、迅猛的迭代速度、开箱即用的特性和 VSCode 同款编辑器背书等原因选择了基于它来进行二次开发和插件化定制(后续文章会对这些定制开发做分享)。但由于编辑器的使用场景日渐多样化,个性化,以及移动端的占比日渐增加,我们对 Monaco Editor 的底层支持也越来越感觉到不足和乏力。对于这些点,我们的计划是先使用CodeMirror 6来支持移动端的代码编辑,然后逐步实

作者:pdai0001525
来源:juejin.cn/post/7252589598152851517
现代码编辑器的自研。

收起阅读 »

剑走偏锋,无头浏览器是什么神奇的家伙

web
浏览器是再熟悉不过的东西了,几乎每个人用过,比如 Chrome、FireFox、Safari,尤其是我们程序员,可谓开发最强辅助,摸鱼最好的伴侣。 浏览器能干的事儿,无头浏览器都能干,而且很多时候比标准浏览器还要更好用,而且能实现一些很好玩儿的功能,我们能借...
继续阅读 »

浏览器是再熟悉不过的东西了,几乎每个人用过,比如 Chrome、FireFox、Safari,尤其是我们程序员,可谓开发最强辅助,摸鱼最好的伴侣。



浏览器能干的事儿,无头浏览器都能干,而且很多时候比标准浏览器还要更好用,而且能实现一些很好玩儿的功能,我们能借助无头浏览器比肩标准浏览器强大的功能,而且又能灵活的用程序控制的特性,做出一些很有意思的产品功能来,稍后我们细说。


什么是浏览器


关于浏览器还有一个很好玩儿的梗,对于一些对计算机、对互联网不太了解的同学,你跟他说浏览器,他/她就默认是百度了,因为好多小白的浏览器都设置了百度为默认页面。所以很多小白将浏览器和搜索引擎(99%是百度)划等号了。



浏览器里我百分之99的时间都是用 Chrome,不过有一说一,这玩意是真耗内存,我基本上是十几、二十几个的 tab 开着,再加上几个 IDEA 进程,16G 的内存根本就不够耗的。


以 Chrome 浏览器为例,Chrome 由以下几部分组成:



  1. 渲染引擎(Rendering Engine):Chromium使用的渲染引擎主要有两个选项:WebKit和Blink。WebKit是最初由苹果开发的渲染引擎,后来被Google采用并继续开发。Blink则是Google从WebKit分支出来并进行独立开发的渲染引擎,目前Chromium主要使用Blink作为其默认的渲染引擎。

  2. JavaScript引擎(JavaScript Engine):Chromium使用V8引擎作为其JavaScript引擎。V8是由Google开发的高性能JavaScript引擎,它负责解析和执行网页中的JavaScript代码。

  3. 网络栈(Network Stack):Chromium的网络栈负责处理网络通信。它支持各种网络协议,包括HTTP、HTTPS、WebSocket等,并提供了网络请求、响应处理和数据传输等功能。

  4. 布局引擎(Layout Engine):Chromium使用布局引擎来计算网页中元素的位置和大小,并确定它们在屏幕上的布局。布局引擎将CSS样式应用于DOM元素,并计算它们的几何属性。

  5. 绘制引擎(Painting Engine):绘制引擎负责将网页内容绘制到屏幕上,生成最终的图像。它使用图形库和硬件加速技术来高效地进行绘制操作。

  6. 用户界面(User Interface):Chromium提供了用户界面的支持,包括地址栏、标签页、书签管理、设置等功能。它还提供了扩展和插件系统,允许用户根据自己的需求进行个性化定制。

  7. 其他组件:除了上述主要组件外,Chromium还包括其他一些辅助组件,如存储系统、安全模块、媒体处理、数据库支持等,以提供更全面的浏览器功能。


Chrome 浏览器光源码就有十几个G,2000多万行代码,可见,要实现一个功能完善的浏览器是一项浩大的工程。


什么是无头浏览器


无头浏览器(Headless Browser)是一种浏览器程序,没有图形用户界面(GUI),但能够执行与普通浏览器相似的功能。无头浏览器能够加载和解析网页,执行JavaScript代码,处理网页事件,并提供对DOM(文档对象模型)的访问和操作能力。


与传统浏览器相比,无头浏览器的主要区别在于其没有可见的窗口或用户界面。这使得它在后台运行时,不会显示实际的浏览器窗口,从而节省了系统资源,并且可以更高效地执行自动化任务。


常见的无头浏览器包括Headless Chrome(Chrome的无头模式)、PhantomJS、Puppeteer(基于Chrome的无头浏览器库)等。它们提供了编程接口,使开发者能够通过代码自动化控制和操作浏览器行为。


无头浏览器其实就是看不见的浏览器,所有的操作都要通过代码调用 API 来控制,所以浏览器能干的事儿,无头浏览器都能干,而且很多事儿做起来比标准的浏览器更简单。


我举几个常用的功能来说明一下无头浏览器的主要使用场景



  1. 自动化测试: 无头浏览器可以模拟用户行为,执行自动化测试任务,例如对网页进行加载、表单填写、点击按钮、检查页面元素等。

  2. 数据抓取: 无头浏览器可用于爬取网页数据,自动访问网站并提取所需的信息,用于数据分析、搜索引擎优化等。

  3. 屏幕截图: 无头浏览器可以加载网页并生成网页的截图,用于生成快照、生成预览图像等。

  4. 服务器端渲染: 无头浏览器可以用于服务器端渲染(Server-side Rendering),将动态生成的页面渲染为静态HTML,提供更好的性能和搜索引擎优化效果。

  5. 生成 PDF 文件:使用浏览器自带的生成 PDF 功能,将目标页面转换成 PDF 。


使用无头浏览器做一些好玩的功能


开篇就说了使用无头浏览器可以实现一些好玩儿的功能,这些功能别看不大,但是使用场景还是很多的,有些开发者就是抓住这些小功能,开发出好用的产品,运气好的话还能赚到钱,尤其是在国外市场。(在国内做收费的产品确实不容易赚到钱)


下面我们就来介绍两个好玩儿而且有用的功能。


前面的自动化测试、服务端渲染就不说了。


自动化测试太专业了,一般用户用不到,只有开发者或者测试工程师用。


服务端渲染使用无头浏览器确实没必要,因为有太多成熟的方案了,连 React 都有服务端渲染的能力(RSC)。


网页截图功能


我们可能见过一些网站提供下载文字卡片或者图文卡片的功能。比如读到一段想要分享的内容,选中之后将文本端所在的区域生成一张图片。



其实就是通过调用浏览器自身的 API page.screenshot,可以对整个页面或者选定的区域生成图片。


通过这个方法,我们可以做一个浏览器插件,用户选定某个区域后,直接生成对应的图片。这类功能在手机APP上很常见,在浏览器上一搬的网站都不提供。


说到这儿好像和无头浏览器都没什么关系吧,这都是标准浏览器中做的事儿,用户已经打开了页面,在浏览器上操作自己看到的内容,顺理成章。


但是如果这个操作是批量的呢,或者是在后台静默完成的情况呢?


那就需要无头浏览器来出手了,无头浏览器虽然没有操作界面,但是也具备绘制引擎的完整功能,仍然可以生成图像,利用这个功能,就可以批量的、静默生成图像了,并且可以截取完整的网页或者部分区域。


Puppeteer 是无头浏览器中的佼佼者,提供了简单好用的 API ,不过是 nodejs 版的。


如果是用 Java 开发的话,有一个替代品,叫做 Jvppeteer,提供了和 Puppeteer 几乎一模一样的 API。


下面这段代码就展示了如何用 Jvppeteer 来实现网页的截图。


下面这个方法是对整个网页进行截图,只需要给定网页 url 和 最终的图片路径就可以了。


public static boolean screenShotWx(String url, String path) throws IOException, ExecutionException, InterruptedException {
BrowserFetcher.downloadIfNotExist(null);
ArrayList arrayList = new ArrayList<>();
// MacOS 要这样写,指定Chrome的位置
String executablePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
LaunchOptions options = new LaunchOptionsBuilder().withExecutablePath(executablePath).withArgs(arrayList).withHeadless(true).withIgnoreHTTPSErrors(true).build();
// Windows 和 Linux 这样就可以,不用指定 Chrome 的安装位置
//LaunchOptions options = new LaunchOptionsBuilder().withArgs(arrayList).withHeadless(true).withIgnoreHTTPSErrors(true).build();
arrayList.add("--no-sandbox");
arrayList.add("--disable-setuid-sandbox");
arrayList.add("--ignore-certificate-errors");
arrayList.add("--disable-gpu");
arrayList.add("--disable-web-security");
arrayList.add("--disable-infobars");
arrayList.add("--disable-extensions");
arrayList.add("--disable-bundled-ppapi-flash");
arrayList.add("--allow-running-insecure-content");
arrayList.add("--mute-audio");
Browser browser = Puppeteer.launch(options);
Page page = browser.newPage();
page.setJavaScriptEnabled(true);
page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36 Edg/83.0.478.37");
page.setCacheEnabled(true);
page.onConsole((msg) -> {
log.info("==> {}", msg.text());
});


PageNavigateOptions pageNavigateOptions = new PageNavigateOptions();
pageNavigateOptions.setTimeout(1000000);
//dom加载完毕就算导航完成
pageNavigateOptions.setWaitUntil(Collections.singletonList("domcontentloaded"));
page.goTo(url, pageNavigateOptions, true);

autoScroll(page);
ElementHandle body = page.$("body");
double width = body.boundingBox().getWidth();
double height = body.boundingBox().getHeight();
Viewport viewport = new Viewport();

viewport.setWidth((int) width); // 设置视口宽度
viewport.setHeight((int) height + 100); // 设置视口高度
page.setViewport(viewport);
ScreenshotOptions screenshotOptions = new ScreenshotOptions();
screenshotOptions.setType("jpeg");
screenshotOptions.setFullPage(Boolean.FALSE);
//screenshotOptions.setClip(clip);
screenshotOptions.setPath(path);
screenshotOptions.setQuality(100);
// 或者转换为 base64
//String base64Str = page.screenshot(screenshotOptions);
//System.out.println(base64Str);

browser.close();
return true;
}

一个自动滚屏的方法。


虽然可以监听页面上的事件通知,比如 domcontentloaded,文档加载完成的通知,但是很多时候并不能监听到网页上的所有元素都加载完成了。对于那些滚动加载的页面,可以用这种方式模拟完全加载,加载完成之后再进行操作就可以了。


使用自动滚屏的操作,可以模拟我们人为的在界面上下拉滚动条的操作,随着滚动条的下拉,页面上的元素会自然的加载,不管是同步的还有延迟异步的,比如图片、图表等。


private static void autoScroll(Page page) {
if (page != null) {
try {
page.evaluate("() => {\n" +
" return new Promise((resolve, reject) => {\n" +
" //滚动的总高度\n" +
" let totalHeight = 0;\n" +
" //每次向下滚动的高度 500 px\n" +
" let distance = 500;\n" +
" let k = 0;\n" +
" let timeout = 1000;\n" +
" let url = window.location.href;\n" +
" let timer = setInterval(() => {\n" +
" //滚动条向下滚动 distance\n" +
" window.scrollBy(0, distance);\n" +
" totalHeight += distance;\n" +
" k++;\n" +
" console.log(`当前第${k}次滚动,页面高度: ${totalHeight}`);\n" +
" //页面的高度 包含滚动高度\n" +
" let scrollHeight = document.body.scrollHeight;\n" +
" //当滚动的总高度 大于 页面高度 说明滚到底了。也就是说到滚动条滚到底时,以上还会继续累加,直到超过页面高度\n" +
" if (totalHeight >= scrollHeight || k >= 200) {\n" +
" clearInterval(timer);\n" +
" resolve();\n" +
" window.scrollTo(0, 0);\n" +
" }\n" +
" }, timeout);\n" +
" })\n" +
" }");
} catch (Exception e) {

}
}
}

调用截图方法截图,这里是对一篇公众号文章进行整个网页的截图。


public static void main(String[] args) throws Exception {
screenShotWx("https://mp.weixin.qq.com/s/MzCyWqcH1TCytpnHI8dVjA", "/Users/fengzheng/Desktop/PICTURE/wx.jpeg");
}

或者也可以截取页面中的部分区域,比如某篇文章的正文部分,下面这个方法是截图一个博客文章的正文部分。


public static boolean screenShotJueJin(String url, String path) throws IOException, ExecutionException, InterruptedException {
BrowserFetcher.downloadIfNotExist(null);
ArrayList arrayList = new ArrayList<>();
String executablePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
LaunchOptions options = new LaunchOptionsBuilder().withExecutablePath(executablePath).withArgs(arrayList).withHeadless(true).withIgnoreHTTPSErrors(true).build();

//LaunchOptions options = new LaunchOptionsBuilder().withArgs(arrayList).withHeadless(true).withIgnoreHTTPSErrors(true).build();
arrayList.add("--no-sandbox");
arrayList.add("--disable-setuid-sandbox");
Browser browser = Puppeteer.launch(options);
Page page = browser.newPage();

PageNavigateOptions pageNavigateOptions = new PageNavigateOptions();
pageNavigateOptions.setTimeout(1000000);
//dom加载完毕就算导航完成
pageNavigateOptions.setWaitUntil(Collections.singletonList("domcontentloaded"));
page.goTo(url, pageNavigateOptions, true);

WaitForSelectorOptions waitForSelectorOptions = new WaitForSelectorOptions();
waitForSelectorOptions.setTimeout(1000 * 15);
waitForSelectorOptions.setVisible(Boolean.TRUE);
// 指定截图的区域
ElementHandle elementHandle = page.waitForSelector("article.article", waitForSelectorOptions);
Clip clip = elementHandle.boundingBox();
Viewport viewport = new Viewport();
ElementHandle body = page.$("body");
double width = body.boundingBox().getWidth();
viewport.setWidth((int) width); // 设置视口宽度
viewport.setHeight((int) clip.getHeight() + 100); // 设置视口高度
page.setViewport(viewport);
ScreenshotOptions screenshotOptions = new ScreenshotOptions();
screenshotOptions.setType("jpeg");
screenshotOptions.setFullPage(Boolean.FALSE);
screenshotOptions.setClip(clip);
screenshotOptions.setPath(path);
screenshotOptions.setQuality(100);
// 或者生成图片的 base64编码
String base64Str = page.screenshot(screenshotOptions);
System.out.println(base64Str);
return true;
}


调用方式:


public static void main(String[] args) throws Exception {
screenShotJueJin("https://juejin.cn/post/7239715628172902437", "/Users/fengzheng/Desktop/PICTURE/juejin.jpeg");
}

最后的效果是这样的,可以达到很清晰的效果。



网页生成 PDF 功能


这个功能可太有用了,可以把一些网页转成离线版的文档。有人说直接保存网页不就行了,除了程序员,大部分人还是更能直接读 PDF ,而不会用离线存储的网页。


我们可以在浏览器上使用浏览器的「打印」功能,用来将网页转换成 PDF 格式。



但这是直接在页面上操作,如果是批量操作呢,比如想把一个专栏的所有文章都生成 PDF呢,就可以用无头浏览器来做了。


有的同学说,用其他的库也可以呀,Java 里面有很多生成 PDF 的开源库,可以把 HTML 转成 PDF,比如Apache PDFBox、IText 等,但是这些库应对一般的场景还行,对于那种页面上有延迟加载的图表啊、图片啊、脚本之类的就束手无策了。


而无头浏览器就可以,你可以监听页面加载完成的事件,可以模拟操作,主动触发页面加载,甚至还可以在页面中添加自定义的样式、脚本等,让生成的 PDF 更加完整、美观。


下面这个方法演示了如何将一个网页转成 PDF 。


public static boolean pdf(String url, String savePath) throws Exception {
Browser browser = null;
Page page = null;
try {
//自动下载,第一次下载后不会再下载
BrowserFetcher.downloadIfNotExist(null);
ArrayList arrayList = new ArrayList<>();
// MacOS
String executablePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
LaunchOptions options = new LaunchOptionsBuilder().withExecutablePath(executablePath).withArgs(arrayList).withHeadless(true).withIgnoreHTTPSErrors(true).build();
// windows 或 linux
//LaunchOptions options = new LaunchOptionsBuilder().withArgs(arrayList).withHeadless(true).withIgnoreHTTPSErrors(true).build();

arrayList.add("--no-sandbox");
arrayList.add("--disable-setuid-sandbox");
arrayList.add("--ignore-certificate-errors");
arrayList.add("--disable-gpu");
arrayList.add("--disable-web-security");
arrayList.add("--disable-infobars");
arrayList.add("--disable-extensions");
arrayList.add("--disable-bundled-ppapi-flash");
arrayList.add("--allow-running-insecure-content");
arrayList.add("--mute-audio");

browser = Puppeteer.launch(options);
page = browser.newPage();

page.onConsole((msg) -> {
log.info("==> {}", msg.text());
});

page.setViewport(viewport);
page.setJavaScriptEnabled(true);
page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36 Edg/83.0.478.37");
page.setCacheEnabled(true);

//设置参数防止检测
page.evaluateOnNewDocument("() =>{ Object.defineProperties(navigator,{ webdriver:{ get: () => undefined } }) }");
page.evaluateOnNewDocument("() =>{ window.navigator.chrome = { runtime: {}, }; }");
page.evaluateOnNewDocument("() =>{ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); }");
page.evaluateOnNewDocument("() =>{ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5,6], }); }");

PageNavigateOptions pageNavigateOptions = new PageNavigateOptions();
pageNavigateOptions.setTimeout(1000000);
//dom加载完毕就算导航完成
pageNavigateOptions.setWaitUntil(Collections.singletonList("domcontentloaded"));

page.goTo(url, pageNavigateOptions, true);
// 添加自定义演示
StyleTagOptions styleTagOptions1 = new StyleTagOptions();
styleTagOptions1.setContent("html {-webkit-print-color-adjust: exact} .table > table > tr:nth-child(1),.table > table > tr:nth-child(2) {background: #4074b0;} #tableB td:nth-child(2) {width:60%;}");
page.addStyleTag(styleTagOptions1);

//滚屏
autoScroll(page);
Thread.sleep(1000);

PDFOptions pdfOptions = new PDFOptions();
// pdfOptions.setHeight("5200");
pdfOptions.setPath(savePath);
page.pdf(pdfOptions);

} catch (Exception e) {
log.error("生成pdf异常:{}", e.getMessage());
e.printStackTrace();
} finally {
if (page != null) {
page.close();
}
if (browser != null) {
browser.close();
}
}
return true;
}

调用生成 PDF 的方法,将一个微信公众号文章转成 PDF。


    public static void main(String[] args) throws Exception {
String pdfPath = "/Users/fengzheng/Desktop/PDF";
String filePath = pdfPath + "/hello.pdf";
JvppeteerUtils.pdf("https://mp.weixin.qq.com/s/MzCyWqcH1TCytpnHI8dVjA", filePath);
}

最终的效果,很清晰,样式都在,基本和页面一模一样。


作者:古时的风筝
来源:juejin.cn/post/7243780412547121208

收起阅读 »

面试官您好,这是我写的TodoList

web
前段时间看到掘金上有人二面被面试官要求写一个TodoList,今天趁着上班没啥事情,我也来写一个小Demo玩玩。 功能 一个TodoList大致就是长成这个样子,有一个输入框,可以通过输入任务名称进行新增,每个任务可以进行勾选,切换已完成和未完成状态,还可以...
继续阅读 »

前段时间看到掘金上有人二面被面试官要求写一个TodoList,今天趁着上班没啥事情,我也来写一个小Demo玩玩。


image.png


功能


一个TodoList大致就是长成这个样子,有一个输入框,可以通过输入任务名称进行新增,每个任务可以进行勾选,切换已完成和未完成状态,还可以删除。


组件设计


组件拆分


接下来,我们可以从功能层次上来拆分组件


image.png



  1. 最外层容器组件,只做一个统一的汇总(红色)

  2. 新增组件,管理任务的输入(绿色)

  3. 列表组件,管理任务的展示(紫色),同时我们也可以将每一个item拆分成为单独的组件(粉色)


数据流


组件拆分完毕之后,我们来管理一下数据流向,我们的数据应该存放在哪里?


我们的数据可以放在新增组件里面吗?不可以,我们的数据是要传递到列表组件进行展示的,他们两个是兄弟组件,管理起来非常不方便。同理,数据也不能放在列表组件里面。所以我们把数据放在我们的顶级组件里面去管理。


我们在最外层容器组件中把数据定义好,并写好删除,新增的逻辑,然后将数据交给列表组件进行展示,列表组件只管数据的展示,不管具体的实现逻辑,我只要把列表id抛出来,调用你传递的删除函数就可以了


现在,我们引出组件设计时的一些原则



  1. 从功能层次上拆分一些组件

  2. 尽量让组件原子化,一个组件只做一个功能就可以了,可以让组件吸收复杂度。每个组件都实现一部分功能,那么整个大复杂度的项目自然就被吸收了

  3. 区分容器组件和UI组件。容器组件来管理数据,具体的业务逻辑;UI组件就只管显示视图


image.png


数据结构的设计


一个合理的数据结构应该满足以下几点:



  1. 用数据描述所有的内容

  2. 数据要结构化,易于操作遍历和查找

  3. 数据要易于扩展,方便增加功能


[
{
id:"1",
title:'标题一',
completed:false
},
{
id:"2",
title:'标题二',
completed:false
}
]

coding


codesandbox.io/s/todolist-…


反思


看了下Antd表单组件的设计,它将一个Form拆分出了Form和Form.item


image.png


image.png


为什么要这么拆分呢?


上文说到,我们在设计一个组件的时候,需要从功能上拆分层次,尽量让组件原子化,只干一件事情。还可以让容器组件(只管理数据)和渲染组件(只管理视图)进行分离


通过Form表单的Api,我们可以发现,Form组件可以控制宏观上的布局,整个表单的样式和数据收集。Form.item控制每个字段的校验等。


个人拙见,如有

作者:晨出
来源:juejin.cn/post/7252678036692451388
不妥,还请指教!!!

收起阅读 »

给你十万条数据,给我顺滑的渲染出来!

web
前言 这是一道面试题,这个问题出来的一刹那,很容易想到的就是for循环100000次吧,但是这方案着实让浏览器崩溃啊!还有什么解决方案呢? 正文 1. for 循环100000次 虽说for循环有点low,但是,当面试官问,为什么会让浏览器崩溃的时候,你知道咋...
继续阅读 »

前言


这是一道面试题,这个问题出来的一刹那,很容易想到的就是for循环100000次吧,但是这方案着实让浏览器崩溃啊!还有什么解决方案呢?


正文


1. for 循环100000次


虽说for循环有点low,但是,当面试官问,为什么会让浏览器崩溃的时候,你知道咋解释吗?

来个例子吧,我们需要在一个容器(ul)中存放100000项数据(li):



我们的思路是打印js运行时间页面渲染时间,第一个console.log的触发时间是在页面进行渲染之前,此时得到的间隔时间为JS运行所需要的时间;第二个console.log是在 setTimeout 中的,它的触发时间是在渲染完成,在下一次Event Loop中执行的。



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

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<ul id="ul"></ul>

<script>
let now = Date.now(); //Date.now()得到时间戳

const total = 100000
const ul = document.getElementById('ul')

for (let i = 0; i < total; i++) {
let li = document.createElement('li')
li.innerHTML = ~~(Math.random() * total)
ul.appendChild(li)
}
console.log('js运行时间',Date.now()-now);

setTimeout(()=>{
console.log('总时间',Date.now()-now);
},0)
console.log();
</script>
</body>

</html>

运行可以看到这个数据:


image.png

这渲染开销也太大了吧!而且它是十万条数据一起加载出来,没加载完成我们看到的会是一直白屏;在我们向下滑动过程中,页面也会有卡顿白屏现象,这就需要新的方案了。继续看!


2. 定时器


我们可以使用定时器实现分页渲染,我们继续拿上面那份代码进行优化:


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

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<ul id="ul"></ul>

<script>
let now = Date.now(); //Date.now()得到时间戳

const total = 100000 //总共100000条数据
const once = 20 //每次插入20条
const page = total / once //总页数
let index = 1
const ul = document.getElementById('ul')

function loop(curTotal, curIndex) {
if (curTotal <= 0) { 判断总数居条数是否小于等于0
return false
}
let pageCount = Math.min(curTotal, once) //以便除不尽有余数
setTimeout(() => {
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li')
li.innerHTML = curIndex + i + ':' + ~~(Math.random() * total)
ul.appendChild(li)
}
loop(curTotal - pageCount, curIndex + pageCount)
}, 0)
}
loop(total, index)
</script>
</body>

</html>

运行后可以看到这十万条数据并不是一次性全部加载出来,浏览器右方的下拉条有顺滑的效果哦,如下图:


进度条.gif

但是当我们快速滚动时,页面还是会有白屏现象,如下图所示,这是为什么呢?


st.gif
可以说有两点原因:



  • 一是setTimeout的执行时间是不确定的,它属于宏任务,需要等同步代码以及微任务执行完后执行。

  • 二是屏幕刷新频率受分辨率和屏幕尺寸影响,而setTimeout只能设置一个固定的时间间隔,这个时间不一定和屏幕刷新时间相同。


3. requestAnimationFrame


我们这次采用requestAnimationFrame的方法,它是一个用于在下一次浏览器重绘之前调用指定函数的方法,它是 HTML5 提供的 API。



我们插入一个小知识点, requestAnimationFrame 和 setTimeout 的区别:

· requestAnimationFrame的调用频率通常为每秒60次。这意味着我们可以在每次重绘之前更新动画的状态,并确保动画流畅运行,而不会对浏览器的性能造成影响。

· setIntervalsetTimeout它可以让我们在指定的时间间隔内重复执行一个操作,不考虑浏览器的重绘,而是按照指定的时间间隔执行回调函数,可能会被延迟执行,从而影响动画的流畅度。



还有一个问题,我们多次创建li挂到ul上,这样会导致回流,所以我们用虚拟文档片段的方式去优化它,因为它不会触发DOM树的重新渲染!


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

![rf.gif](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3eab42b37f53408b981411ee54088d5a~tplv-k3u1fbpfcp-watermark.image?)
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

![st.gif](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3e922cc57a044f5e9e48e58bda5f6756~tplv-k3u1fbpfcp-watermark.image?)
<body>
<ul id="ul"></ul>

<script>
let now = Date.now(); //Date.now()得到时间戳

const total = 10000
const once = 20
const page = total / once
let index = 1
const ul = document.getElementById('ul')

function loop(curTotal, curIndex) {
if (curTotal <= 0) {
return false
}
let pageCount = Math.min(curTotal, once) //以便除不尽有余数
requestAnimationFrame(()=>{
let fragment = document.createDocumentFragment() //虚拟文档
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li')
li.innerHTML = curIndex + i + ':' + ~~(Math.random() * total)
fragment.appendChild(li)
}
ul.appendChild(fragment)
loop(curTotal - pageCount, curIndex + pageCount)
})
}
loop(total, index)
</script>
</body>

</html>

可以看到它白屏时间没有那么长了:
rqf.gif

还有没有更好的方案呢?当然有!往下看!


4. 虚拟列表


我们可以通过这张图来表示虚拟列表红框代表你的手机黑条代表一条条数据


image.png

思路:我们只要知道手机屏幕最多能放下几条数据,当下拉滑动时,通过双指针的方式截取相应的数据就可以了。

🚩 PS:为了防止滑动过快导致的白屏现象,我们可以使用预加载的方式多加载一些数据出来。



代码如下:


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

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<title>虚拟列表</title>
<style>
.v-scroll {
height: 600px;
width: 400px;
border: 3px solid #000;
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
}

.infinite-list {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}

.scroll-list {
left: 0;
right: 0;
top: 0;
position: absolute;
text-align: center;
}

.scroll-item {
padding: 10px;
color: #555;
box-sizing: border-box;
border-bottom: 1px solid #999;
}
</style>
</head>

<body>
<div id="app">
<div ref="list" class="v-scroll" @scroll="scrollEvent($event)">
<div class="infinite-list" :style="{ height: listHeight + 'px' }"></div>

<div class="scroll-list" :style="{ transform: getTransform }">
<div ref="items" class="scroll-item" v-for="item in visibleData" :key="item.id"
:style="{ height: itemHeight + 'px',lineHeight: itemHeight + 'px' }">
{{ item.msg }}</div>
</div>
</div>
</div>

<script>
var throttle = (func, delay) => { //节流
var prev = Date.now();
return function () {
var context = this;
var args = arguments;
var now = Date.now();
if (now - prev >= delay) {
func.apply(context, args);
prev = Date.now();
}
}
}
let listData = []
for (let i = 1; i <= 10000; i++) {
listData.push({
id: i,
msg: i + ':' + Math.floor(Math.random() * 10000)
})
}

const { createApp } = Vue
createApp({
data() {
return {
listData: listData,
itemHeight: 60,
//可视区域高度
screenHeight: 600,
//偏移量
startOffset: 0,
//起始索引
start: 0,
//结束索引
end: null,
};
},
computed: {
//列表总高度
listHeight() {
return this.listData.length * this.itemHeight;
},
//可显示的列表项数
visibleCount() {
return Math.ceil(this.screenHeight / this.itemHeight)
},
//偏移量对应的style
getTransform() {
return `translate3d(0,${this.startOffset}px,0)`;
},
//获取真实显示列表数据
visibleData() {
return this.listData.slice(this.start, Math.min(this.end, this.listData.length));
}
},
mounted() {
this.start = 0;
this.end = this.start + this.visibleCount;
},
methods: {
scrollEvent() {
//当前滚动位置
let scrollTop = this.$refs.list.scrollTop;
//此时的开始索引
this.start = Math.floor(scrollTop / this.itemHeight);
//此时的结束索引
this.end = this.start + this.visibleCount;
//此时的偏移量
this.startOffset = scrollTop - (scrollTop % this.itemHeight);
}
}
}).mount('#app')
</script>
</body>

</html>

可以看到白屏现象解决了!


zz.gif

结语


解决十万条数据渲染的方案基本都在这儿了,还有更好

作者:zt_ever
来源:juejin.cn/post/7252684645979111461
的方案等待大佬输出!

收起阅读 »

记一次修改一行代码导致的线上BUG

web
背景介绍 先描述一下需求,要在一个老项目里根据type类型,给一个试题题干组件新增一个class样式,type是在url地址栏上面携带的。简单,一行代码搞定,五分钟部署,十分钟留给测试,然后跟车上线,打卡下班! 《凉凉》送给自己 看标题就知道结果了,第二天下午...
继续阅读 »

1920_1200_20100319011154682575.jpg


背景介绍


先描述一下需求,要在一个老项目里根据type类型,给一个试题题干组件新增一个class样式type是在url地址栏上面携带的。简单,一行代码搞定,五分钟部署,十分钟留给测试,然后跟车上线,打卡下班!


《凉凉》送给自己


看标题就知道结果了,第二天下午现网问题来了,一线反馈某个页面题干不展示了,值班同事排查一圈,找到我说我昨天加的代码报错了!


006Cmetyly1ff16b3zxvxj308408caa8.jpg


惊了,就加了一行业务代码,其他都是样式,测试也通过了,这也能有问题?绩效C打底稳了(为方便写文章,实际判断用变量代替):


<div :class="{'addClass': $route.query.type === 'xx'}">
...
</div>

temp.png
问题其实很简单,$route为undefined了,导致query获取有问题,这让我点怀疑自己,难道这写错了?管不了太多,只能先兼容上线了。


$route && $route.query && $route.query.type

其实是可以用?.简写的,但是这个项目实在不“感动”了,保险写法,解决问题优先。提申请,拉评审,走流程,上线,问题解决,松口气,C是保住了。


问题分析


解决完问题,还要写线上问题分析报告,那只能来扒一扒代码来看看了。首先,这个项目使用的是多页应用,每个页面都是一个新的SPA,我改的页面先叫组件A吧,组件A在页面A里被使用,没问题;组件A同样被页面B使用,报错了。那接下来简单了,看代码:


// 2022-09-26 新增
import App from '@/components/pages/页面A'
import router from '@/config/router.js'
// initApp 为封装的 new Vue
import { initApp, Vue } from '../base-import'
initApp(App, router)

// 2020-10-18 新增
import App from '@/components/pages/页面b'
new Vue({
el: '#app',
components: { App },
template: '<App/>'
})

两个页面的index.js文件,两种写法,一个引用了router,一个没有引用,被这个神仙代码整懵了。然后再看了一下其他页面,也都是两种写法掺着写的,心态崩了。这分析报告只能含着泪写了...


最后总结



  1. 问题不是关键,关键的是代码规范;

  2. 修改新项目之前,最好看一下代码逻辑,有熟悉的同事最好,可以沟通了解一下业务(可以避免部分问题);

  3. 当想优化之前代码的时候,要全面评估,统一优化,上面的写法我也找同事了解了,因为之前写法不满足当时的需求,他就封装了新方法,但是老的没有修改,所以就留了坑;


作者:追风筝的呆子
来源:juejin.cn/post/7252198762625089596
收起阅读 »

什么!一个项目给了8个字体包???

web
🙋 遇到的问题 在一个新项目中,设计统一了项目中所有的字体,并提供了字体包。在项目中需要按需引入这些字体包。 首先,字体包的使用分为了以下几种情况: 无特殊要求的语言使用字体A,阿拉伯语言使用字体B; 加粗、中等、常规、偏细四种样式,AB两种字体分别对应使用...
继续阅读 »

🙋 遇到的问题


在一个新项目中,设计统一了项目中所有的字体,并提供了字体包。在项目中需要按需引入这些字体包。


首先,字体包的使用分为了以下几种情况:



  1. 无特殊要求的语言使用字体A,阿拉伯语言使用字体B;

  2. 加粗、中等、常规、偏细四种样式,AB两种字体分别对应使用 BoldMediumRegularThin 四种字体包;


所以,我现在桌面上摆着 8 个字体包:



  • A-Bold.tff

  • A-Medium.tff

  • A-Regular.tff

  • A-Thin.tff

  • B-Bold.tff

  • B-Medium.tff

  • B-Regular.tff

  • B-Thin.tff


image.png
不同语言要使用不同的字体包,不同粗细也要使用不同的字体包!


还有一个前提是,设计给的设计图都是以字体A为准,所以在 Figma 中复制出来的 CSS 代码中字体名称都是A。


刚接到这个需求时还是比较懵的,一时想不出来怎么样才能以最少的逻辑判断最少的文件下载最少的代码改动去实现在不同情况下自动的去选择对应的字体包。


因为要涉及到语言的判断,最先想到的还是通过 JS,然后去添加相应的类名。但这样也只能判断语言使用A或B,粗细还是解决不了。


image.png


看来还是要用 CSS 解决。


首先我将所有的8个字体先定义好:


@font-face {
font-family: A-Bold;
src: url('./fonts/A-Bold.ttf');
}

/* ... */

@font-face {
font-family: B-Thin;
src: url('./fonts/B-Thin.ttf');
}

image.png


🤲🏼 如何根据粗细程度自动选择对应字体包


有同学可能会问,为什么不直接使用 font-weight 来控制粗细而是用不同的字体包呢?


我们来看下面这个例子,我们使用同一个字体, font-weight 分别设置为900、500、100,结果我们看到的字体粗细是一样的。


对的,很多字体不支持 font-weight 所以我们需要用不同粗细的字体包。


image.png


所以,我们可以通过 @font-face 中的 font-weight 属性来设置字体的宽度:


@font-face {
font-family: A;
src: url('./fonts/A-Bold.ttf');
font-weight: 600;
}
@font-face {
font-family: A;
src: url('./fonts/A-Medium.ttf');
font-weight: 500;
}
@font-face {
font-family: A;
src: url('./fonts/A-Regular.ttf');
font-weight: 400;
}
@font-face {
font-family: A;
src: url('./fonts/A-Thin.ttf');
font-weight: 300;
}

注意,这里我们把字体名字都设为相同的,如下图所示,这样我们就成功的解决了第一个问题:不同粗细也要使用不同的字体包;


image.png


并且,如果我们只是定义而未真正使用时,不会去下载未使用的字体包,再加上字体包的缓存策略,就可以最大程度节省带宽:


image.png


🔤 如何根据不同语言自动选择字体包?


通过张鑫旭的博客找到了解决办法,使用 unicode-range 设置字符 unicode 范围,从而自定义字体包。


unicode-range 是一个 CSS 属性,用于指定字体文件所支持的 Unicode 字符范围,以便在显示文本时选择适合的字体。


它的语法如下:


@font-face {
font-family: "Font Name";
src: url("font.woff2") format("woff2");
unicode-range: U+0020-007E, U+4E00-9FFF;
}

在上述例子中,unicode-range 属性指定了字体文件支持的字符范围。使用逗号分隔不同的范围,并使用 U+XXXX-XXXX 的形式表示 Unicode 字符代码的范围。


通过设置 unicode-range 属性,可以优化字体加载和页面渲染性能,只加载所需的字符范围,减少不必要的网络请求和资源占用。


通过查表得知阿拉伯语的 unicode 的范围为:U+06??, U+0750-077F, U+08A0-08FF, U+FB50-FDFF, U+FE70-FEFF, U+10A60-10A7F, U+10A80-10A9F 这么几个区间。所以我们设置字体如下,因为设计以 A 字体为准,所以在 Figma 中给出的样式代码字体名均为 A,所以我们把 B 字体的字体名也设置为 A:


image.png


当使用字体的字符中命中 unicode-rang 的范围时,自动下载相应的字体包。


@font-face {
font-family: A;
src: url('./fonts/A-Bold.ttf');
font-weight: 600;
}

@font-face {
font-family: A;
src: url('./fonts/A-Medium.ttf');
font-weight: 500;
}

@font-face {
font-family: A;
src: url('./fonts/A-Regular.ttf');
font-weight: 400;
}

@font-face {
font-family: A;
src: url('./fonts/A-Thin.ttf');
font-weight: 300;
}

:root {
--ARABIC_UNICODE_RANGE: U+06??, U+0750-077F, U+08A0-08FF, U+FB50-FDFF, U+FE70-FEFF, U+10A60-10A7F, U+10A80-10A9F;
}
@font-face {
font-family: A;
src: url('./fonts/B-Bold.ttf');
font-weight: 600;
unicode-range: var(--ARABIC_UNICODE_RANGE);
}
@font-face {
font-family: A;
src: url('./fonts/B-Medium.ttf');
font-weight: 500;
unicode-range: var(--ARABIC_UNICODE_RANGE);
}
@font-face {
font-family: A;
src: url('./fonts/B-Regular.ttf');
font-weight: 400;
unicode-range: var(--ARABIC_UNICODE_RANGE);
}
@font-face {
font-family: A;
src: url('./fonts/B-Thin.ttf');
font-weight: 300;
unicode-range: var(--ARABIC_UNICODE_RANGE);
}
p {
font-family: A;
}

总结


遇到的问题:



  1. 两种字体,B 字体为阿拉伯语使用,A 字体其他语言使用。根据语言自动选择。

  2. 根据字宽自动选择相应的字体包。

  3. 可以直接使用 Figma 中生成的样式而不必每次手动改动。

  4. 尽可能节省带宽。


我们通过 font-weight 解决了问题2,并通过 unicode-range 解决了问题1。


并且实现了按需下载相应字体包,不使用时不下载。


Figma 中的代码可以直接复制粘贴,无需任何修改即可根据语言和自宽自动使用相应字体包。




参考资料:http://www.zhangxinxu.com/wordpr

作者:Mengke
来源:juejin.cn/post/7251884086536781880
ess/2…

收起阅读 »

用 node 实战一下 CSRF

web
前言 之前面试经常被问到 CSRF, 跨站请求伪造 大概流程比较简单, 大概就是用户登录了A页面,存下来登录凭证(cookie), 攻击者有诱导受害者打开了B页面, B页面中正好像A发送了一个跨域请求,并把cookie进行了携带, 欺骗浏览器以为是用户的行为...
继续阅读 »

前言


之前面试经常被问到 CSRF, 跨站请求伪造



大概流程比较简单, 大概就是用户登录了A页面,存下来登录凭证(cookie), 攻击者有诱导受害者打开了B页面, B页面中正好像A发送了一个跨域请求,并把cookie进行了携带, 欺骗浏览器以为是用户的行为,进而达到执行危险行为的目的,完成攻击



上面就是面试时,我们通常的回答, 但是到底是不是真是这样呢? 难道这么容易伪造吗?于是我就打算试一下能不能实现


接下来,我们就通过node起两个服务 A服务(端口3000)和B服务(端口4000), 然后通过两个页面 A页面、和B页面模拟一下CSRF。


我们先约定一下 B页面是正常的页面, 起一个 4000 的服务, 然后 A页面为伪造者的网站, 服务为3000


先看B页面的代码, B页面有一个登录,和一个获取数据的按钮, 模拟正常网站,需要登录后才可以获取数据


<body>
<div>
正常 页面 B
<button onclick="login()">登录</button>
<button onclick="getList()">拿数据</button>
<ul class="box"></ul>
<div class="tip"></div>
</div>
</body>
<script>
async function login() {
const response = await fetch("http://localhost:4000/login", {
method: "POST",
});
const res = await response.json();
console.log(res, "writeCookie");
if (res.data === "success") {
document.querySelector(".tip").innerHTML = "登录成功, 可以拿数据";
}
}

async function getList() {
const response = await fetch("http://localhost:4000/list", {
method: "GET",
});

if (response.status === 500) {
document.querySelector(".tip").innerHTML = "cookie失效,请先登录!";
document.querySelector(".box").innerHTML = "";
} else {
document.querySelector(".tip").innerHTML = "";
const data = await response.json();
let html = "";
data.map((el) => {
html += `<div>${el.id} - ${el.name}</div>`;
});
document.querySelector(".box").innerHTML = html;
}
}
</script>

在看B页面的服务端代码如下:


const express = require("express");
const app = express();

app.use(express.json()); // json
app.use(express.urlencoded({ extends: true })); // x-www-form-urlencoded

app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
// 允许客户端跨域传递的请求头
res.header("Access-Control-Allow-Headers", "Content-Type");
next();
});

app.use(express.static("public"));

app.get("/list", (req, res) => {
const cookie = req.headers.cookie;
if (cookie !== "user=allow") {
res.sendStatus("500");
} else {
res.json([
{ id: 1, name: "zhangsan" },
{ id: 2, name: "lisi" },
]);
}
});

app.post("/login", (req, res) => {
res.cookie("user", "allow", {
expires: new Date(Date.now() + 86400 * 1000),
});
res.send({ data: "success" });
});

app.post("/delete", (req, res) => {
const cookie = req.headers.cookie;
if (req.headers.referer !== req.headers.host) {
console.log("should ban!");
}
if (cookie !== "user=allow") {
res.sendStatus("500");
} else {
res.json({
data: "delete success",
});
}
});

app.listen(4000, () => {
console.log("sever 4000");
});

B 服务有三个接口, 登录、获取列表、删除。 再触发登录接口的时候,会像浏览器写入cookie, 再删除或者获取列表的时候,都先检测有没有将指定的cookie传回,如果有就认为有权限


然后我们打开 http://localhost:4000/B.html 先看看B页面功能是否都正常


image.png


我们看到此时 B 页面功能和接口都是正常的, cookie 也正常进行了设置,每次获取数据的时候,都是会携带cookie到服务端校验的


那么接下来我们就通过A页面,起一个3000端口的服务,来模拟一下跨域情况下,能否完成获取 B服务器数据,调用 B 服务器删除接口的功能


A页面代码


  <body>
<div>
伪造者页面 A
<form action="http://localhost:4000/delete" method="POST">
<input type="hidden" name="account" value="xiaoming" />
</form>
<script>
// 这行可以放到控制台执行,便于观察效果
// document.forms[0].submit();
</script>
</div>
<ul class="box"></ul>
<div class="tip"></div>
</body>

A页面服务端代码


  <body>
<div>
伪造者页面 A
<form action="http://localhost:4000/delete" method="POST">
<input type="hidden" name="account" value="xiaoming" />
</form>
<script>
// 这行可以放到控制台输入
// document.forms[0].submit();
</script>
<script src="http://localhost:4000/list"></script>
</div>

</body>

于是在我们 访问 http://localhost:3000/A.html 页面的时候发现, 发现list列表确实,请求到了, 控制台输入 document.forms[0].submit() 时发现,确实删除也发送成功了, 是不是说明csrf就成功了呢, 但是其实还不是, 关键的一点是, 我们在B页面设置cookie的时候, domain设置的是 localhost 那么其实在A页面, 发送请求的时候cookie是共享的状态, 真实情况下,肯定不会是这样, 那么为了模拟真实情况, 我们把 http://localhost:3000/A.html 改为 http://127.0.0.1:3000/A.html, 这时发现,以及无法访问了, 那么这是怎么回事呢, 说好的,cookie 会在获取过登录凭证下, 再次访问时可以携带呢。


image.png


于是,想了半天也没有想明白, 难道是浏览器限制严格进行了限制, 限制规避了这个问题? 难道我们背的面试题是错误的?


有知道的

作者:重阳微噪
来源:juejin.cn/post/7250374485567340603
小伙伴,欢迎下方讨论

收起阅读 »

前端流程图插件对比选型

web
前言 前端领域有多种流程库可供选择,包括但不限于vue-flow、butterfly、JointJS、AntV G6、jsPlumb和Flowchart.js。这些库都提供了用于创建流程图、图形编辑和交互的功能。然而,它们在特性、易用性和生态系统方面存在一些差...
继续阅读 »

Snipaste_2023-07-04_15-49-12.png


前言


前端领域有多种流程库可供选择,包括但不限于vue-flow、butterfly、JointJS、AntV G6、jsPlumb和Flowchart.js。这些库都提供了用于创建流程图、图形编辑和交互的功能。然而,它们在特性、易用性和生态系统方面存在一些差异。


流程图插件汇总


序号名称地址
1vue-flowgithub.com/bcakmakoglu…
2butterflygithub.com/alibaba/but…
3JointJShttp://www.jointjs.com/
4AntV G6antv-2018.alipay.com/zh-cn/g6/3.…
5jsPlumbgithub.com/jsplumb/jsp…
6Flowchart.jsgithub.com/adrai/flowc…

流程图插件分析


vue-flow


简介


vue-flowReactFlow 的 Vue 版本,目前只支持 在Vue3中使用,对Vue2不兼容,目前国内使用较少。包含四个功能组件 core、background、controls、minimap,可按需使用。


使用


Vue FlowVue下流程绘制库。安装:
npm i --save @vue-flow/core 安装核心组件
npm i --save @vue-flow/background 安装背景组件
npm i --save @vue-flow/controls 安装控件(放大,缩小等)组件
npm i --save @vue-flow/minimap 安装缩略图组件

引入组件:
import { Panel, PanelPosition, VueFlow, isNode, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'

引入样式:
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';

优缺点分析


优点:



  1. 轻松上手:内置缩放和平移功能、元素拖动、选择等等。

  2. 可定制:使用自定义节点、边缘和连接线并扩展Vue Flow的功能。

  3. 快速:链路被动更改,仅重新渲染适当的元素。

  4. 工具和组合:带有图形助手和状态可组合函数,用于高级用途。

  5. 附加组件:背景(内置模式、高度、宽度或颜色),小地图(右下角)、控件(左下角)。


缺点:



  1. 仓库迭代版本较少,2022年进入首次迭代。

  2. 国内使用人数少,没有相关技术博客介绍,通过官网学习。


butterfly


简介


Butterfly是由阿里云-数字产业产研部孵化出来的的图编辑器引擎,具有使用自由、定制性高的优势,已支持上百张画布。号称 “杭州余杭区最自由的图编辑器引擎”。


使用



  • 安装


//
npm install butterfly-dag --save


  • 在 Vue3 中使用


<script lang="ts" setup>
import {TreeCanvas, Canvas} from 'butterfly-dag';
const root = document.getElementById('chart')
const canvas = new Canvas({
root: root,
disLinkable: true, // 可删除连线
linkable: true, // 可连线
draggable: true, // 可拖动
zoomable: true, // 可放大
moveable: true, // 可平移
theme: {
edge: {
shapeType: "AdvancedBezier",
arrow: true,
arrowPosition: 0.5, //箭头位置(0 ~ 1)
arrowOffset: 0.0, //箭头偏移
},
},
});
canvas.draw(mockData, () => {
//mockData为从mock中获取的数据
canvas.setGridMode(true, {
isAdsorb: false, // 是否自动吸附,默认关闭
theme: {
shapeType: "circle", // 展示的类型,支持line & circle
gap: 20, // 网格间隙
background: "rgba(0, 0, 0, 0.65)", // 网格背景颜色
circleRadiu: 1.5, // 圆点半径
circleColor: "rgba(255, 255, 255, 0.8)", // 圆点颜色
},
});
});
</script>

<template>
<div class="litegraph-canvas" id="chart"></div>
</template>

优缺点分析


优点:



  1. 轻松上手:基于dom的设计模型大大方便了用户的入门门槛,提供自定义节点,锚点的模式大大降低了用户的定制性。

  2. 多技术栈支持:支持 jquery 基于 dom 的设计,也包含 butterfly-react、butterfly-vue 两种设计。

  3. 核心概念少而精:提供 画布(Canvas)、节点(Node)、线(Edge)等核心概念。

  4. 优秀的组件库支持:对于当前使用组件库来说,可以大量复用现有的组件。


缺点:



  1. butterfly 对 Vue的支持不是特别友好,这跟阿里的前端技术主栈为React有关,butterfly-vue库只支持 Vue2版本。在Vue3上使用需要对 butterfly-drag 进行封装。


JointJS


简介


创建静态图表或完全交互式图表工具,例如工作流编辑器、流程管理工具、IVR 系统、API 集成器、演示应用程序等等。


属于闭源收费项目,暂不考虑。


AntV G6


简介


AntV 是蚂蚁金服全新一代数据可视化解决方案,致力于提供一套简单方便、专业可靠、无限可能的数据可视化最佳实践。G6 是一个图可视化引擎。它提供了图的绘制、布局、分析、交互、动画等图可视化的基础能力。G6可以实现很多d3才能实现的可视化图表。


使用



  • 安装


npm install --save @antv/g6	//安装


  • 在所需要的文件中引入


<template>
/* 图的画布容器 */
<div id="mountNode"></div>
</template>

<script lang="ts" setup>
import G6 from '@antv/g6';
// 定义数据源
const data = {
// 点集
nodes: [
{
id: 'node1',
x: 100,
y: 200,
},
{
id: 'node2',
x: 300,
y: 200,
},
],
// 边集
edges: [
// 表示一条从 node1 节点连接到 node2 节点的边
{
source: 'node1',
target: 'node2',
},
],
};

// 创建 G6 图实例
const graph = new G6.Graph({
container: 'mountNode', // 指定图画布的容器 id
// 画布宽高
width: 800,
height: 500,
});
// 读取数据
graph.data(data);
// 渲染图
graph.render();
</script>



优缺点分析


优点:



  1. 强大的可定制性:G6 提供丰富的图形表示和交互组件,可以通过自定义配置和样式来实现各种复杂的图表需求。

  2. 全面的图表类型支持:G6 支持多种常见图表类型,如关系图、流程图、树图等,可满足不同领域的数据可视化需求。

  3. 高性能:G6 在底层图渲染和交互方面做了优化,能够处理大规模数据的展示,并提供流畅的交互体验。


缺点:



  1. 上手难度较高:G6 的学习曲线相对较陡峭,需要对图形语法和相关概念有一定的理解和掌握。

  2. 文档相对不完善:相比其他成熟的图表库,G6 目前的文档相对较简单,部分功能和使用方法的描述可能不够详尽,需要进行更深入的了解与实践。


jsPlumb


简介


一个用于创建交互式、可拖拽的连接线和流程图的 JavaScript 库。它在 Web 应用开发中广泛应用于构建流程图编辑器、拓扑图、组织结构图等可视化操作界面。


使用


<template>
<div ref="container">
<div ref="sourceElement">Source</div>
<div ref="targetElement">Target</div>
</div>

</template>

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

const container = ref<HTMLElement | null>(null);
const sourceElement = ref<HTMLElement | null>(null);
const targetElement = ref<HTMLElement | null>(null);

onMounted(() => {
// 创建 jsPlumb 实例
const jsPlumbInstance = jsPlumb.getInstance();

// 初始化 jsPlumb 实例设置
if (container.value) {
jsPlumbInstance.setContainer(container.value);
}

// 创建连接线
if (sourceElement.value && targetElement.value) {
jsPlumbInstance.connect({
source: sourceElement.value,
target: targetElement.value,
});
}
});
</script>

优缺点分析


优点:



  1. 简单易用:jsPlumb 提供了直观的 API 和丰富的文档,比较容易上手和使用。

  2. 可拓展性:允许开发人员根据自己的需求进行定制和扩展,使其适应不同的应用场景。

  3. 强大的连接功能:jsPlumb 允许创建各种连接类型,包括直线、曲线和箭头等,满足了复杂交互需求的连接效果。
    缺点:

  4. 文档更新不及时:有时候,jsPlumb 的官方文档并没有及时更新其最新版本的特性和用法。

  5. 性能考虑:在处理大量节点、连接线或复杂布局时,jsPlumb 的性能可能受到影响,需要进行优化。


Flowchart.js


简介


Flowchart.js 是一款开源的JavaScript流程图库,可以使用最短的语法来实现在页面上展示一个流程图,目前大部分都是用在各大主流 markdown 编辑器中,如掘金、csdn、语雀等等。


使用


flowchat
start=>start: 开始
end=>end: 结束
input=>inputoutput: 我的输入
output=>inputoutput: 我的输出
operation=>operation: 我的操作
condition=>condition: 确认
start->input->operation->output->condition
condition(yes)->end
condition(no)->operation

优缺点


优点:



  1. 使用方便快捷,使用几行代码就可以生成一个简单的流程图。

  2. 可移植:在多平台上只需要写相同的代码就可以实现同样的效果。


缺点:



  1. 可定制化限制:对于拥有丰富需求的情况下,flowchartjs只能完成相对简单的需求,没有高级的定制化功能。

  2. 需要花费一定时间来学习他的语法和规则,但是flowchartjs的社区也相对不太活跃。


对比分析




  1. 功能和灵活性:



    • Butterfly、G6 和 JointJS 是功能较为丰富和灵活的库。它们提供了多种节点类型、连接线样式、布局算法等,并支持拖拽、缩放、动画等交互特性。

    • Vue-Flow 来源于 ReactFlow 基于 D3和vueuse等库,提供了 Vue 组件化的方式来创建流程图,并集成了一些常见功能。

    • jsPlumb 专注于提供强大的连接线功能,具有丰富的自定义选项和功能。

    • Flowchart.js 则相对基础,提供了构建简单流程图的基本功能。




  2. 技术栈和生态系统:



    • Vue-Flow 是基于 Vue.js 的流程图库,与 Vue.js 生态系统无缝集成。

    • Butterfly 是一个基于 TypeScript 的框架,适用于现代 Web 开发。

    • JointJS、AntV G6 和 jsPlumb 可以与多种前端框架(如Vue、React、Angular等)结合使用。

    • AntV G6 是 AntV 团队开发的库,其背后有强大的社区和文档支持。




  3. 文档和学习曲线:



    • Butterfly、G6 和 AntV G6 都有完善的文档和示例,提供了丰富的使用指南和教程。

    • JointJS 和 jsPlumb 也有较好的文档和示例资源,但相对于前三者较少。

    • Flowchart.js 的文档相对较少。




  4. 兼容性:



    • Butterfly、JointJS 和 G6 库在现代浏览器中表现良好,并提供了兼容低版本浏览器

    • 作者:WayneX
      来源:juejin.cn/post/7251835247595110457
      l>

收起阅读 »

为什么选择 Next.js 框架?

web
前言 Next.js 框架作为一种强大而受欢迎的工具,为开发人员提供了许多优势和便利。本文将探讨 Next.js 框架的优点,并解释为什么选择 Next.js 是一个明智的决策。 文档:nextjs.org/docs 强大的服务端渲染和静态生成能力: Ne...
继续阅读 »

前言


Next.js 框架作为一种强大而受欢迎的工具,为开发人员提供了许多优势和便利。本文将探讨 Next.js 框架的优点,并解释为什么选择 Next.js 是一个明智的决策。



文档:nextjs.org/docs



强大的服务端渲染和静态生成能力:


Next.js 框架提供了先进的服务端渲染(SSR)和静态生成(SSG)能力,使得我们能够在服务器上生成动态内容并将其直接发送给客户端,从而大大减少首次加载的等待时间。这样可以提高网站的性能、搜索引擎优化(SEO)以及用户体验。


简化的数据获取:


Next.js 提供了简单易用的数据获取方法,例如 getServerSidePropsgetStaticProps,使得从后端获取数据并将其注入到组件中变得非常容易。这种无缝的数据获取流程,可以让开发人员专注于业务逻辑而不用过多关注数据获取的细节。


优化的路由系统:


Next.js 内置了灵活而强大的路由功能,使得页面之间的导航变得简单直观。通过自动化的路由管理,我们可以轻松地构建复杂的应用程序,并实现更好的用户导航体验。


支持现代前端技术栈:


Next.js 是建立在 React 生态系统之上的,因此可以充分利用 React 的强大功能和丰富的社区资源。同时,Next.js 也支持最新的 JavaScript(ES6+)特性,如箭头函数、模块化导入导出、解构赋值等,让开发人员可以使用最新的前端技术来构建现代化的应用。


简化的部署和扩展:


Next.js 提供了轻松部署和扩展应用程序的工具和解决方案。借助 Vercel、Netlify 等平台,我们可以快速将应用程序部署到生产环境,并享受高性能、弹性扩展的好处。Next.js 还支持构建静态站点,可以轻松地将应用部署到 CDN 上,提供更快的加载速度和更好的全球可访问性。


大型社区支持:


Next.js 拥有庞大的开发者社区,其中有许多优秀的开源项目和库。这意味着你可以从社区中获取到大量的学习资源、文档和支持。无论是在 Stack Overflow 上寻求帮助,还是参与讨论,你都能够从其他开发人员的经验中获益。


什么环境下需要选择nextjs框架?


需要服务端渲染或静态生成:


如果你的应用程序需要在服务器端生成动态内容,并将其直接发送给客户端,以提高性能和搜索引擎优化,那么 Next.js 是一个很好的选择。它提供了强大的服务端渲染和静态生成能力,使得构建高性能的应用变得更加简单。


需要快速开发和部署:


Next.js 提供了简化的开发流程和快速部署的解决方案。它具有自动化的路由管理、数据获取和构建工具,可以提高开发效率。借助 Vercel、Netlify 等平台,你可以轻松地将 Next.js 应用部署到生产环境,享受高性能和弹性扩展的好处。


基于 React 的应用程序:


如果你已经熟悉 React,并且正在构建一个基于 React 的应用程序,那么选择 Next.js 是自然而然的。Next.js 是建立在 React 生态系统之上的,提供了与 React 紧密集成的功能和工具。


需要良好的 SEO 和页面性能:


如果你的应用程序对搜索引擎优化和良好的页面性能有较高的要求,Next.js 可以帮助你实现这些目标。通过服务端渲染和静态生成,Next.js 可以在初始加载时提供完整的 HTML 内容,有利于搜索引擎索引和页面的快速呈现。


需要构建现代化的单页应用(SPA):


尽管 Next.js 可以支持传统的多页面应用(MPA),但它也非常适合构建现代化的单页应用(SPA)。你可以使用 Next.js 的路由系统、数据获取和状态管理功能,构建出功能丰富且响应快速的 SPA。


与nextjs相似的框架?


Nuxt.js:


Nuxt.js 是一个基于 Vue.js 的应用框架,提供了类似于 Next.js 的服务端渲染和静态生成功能。它通过使用 Vue.js 的生态系统,使得构建高性能、可扩展的 Vue.js 应用变得更加简单。


Gatsby:


Gatsby 是一个基于 React 的静态网站生成器,具有类似于 Next.js 的静态生成功能。它使用 GraphQL 来获取数据,并通过预先生成静态页面来提供快速的加载速度和良好的SEO。


Angular Universal:


Angular Universal 是 Angular 框架的一部分,提供了服务端渲染的能力。它可以生成动态的 HTML 内容,从而加快首次加载速度,并提供更好的 SEO 和用户体验。


Sapper:


Sapper 是一个基于 Svelte 的应用框架,支持服务端渲染和静态生成。它提供了简单易用的工具和流畅的开发体验,帮助开发者构建高性能的 Sv

作者:嚣张农民
来源:juejin.cn/post/7251875626906599485
elte 应用程序。

收起阅读 »

为什么你非常不适应 TypeScript

web
前言 在群里看到一些问题和言论:为什么你们这么喜欢“类型体操”?为什么我根本学不下去 TypeScript?我最讨厌那些做类型体操的了;为什么我学了没过多久马上又忘了? 有感于这些问题,我想从最简单的一个角度来切入介绍一下 TypeScript,并向大家介绍并...
继续阅读 »

前言


在群里看到一些问题和言论:为什么你们这么喜欢“类型体操”?为什么我根本学不下去 TypeScript?我最讨厌那些做类型体操的了;为什么我学了没过多久马上又忘了?


有感于这些问题,我想从最简单的一个角度来切入介绍一下 TypeScript,并向大家介绍并不是只要是个类型运算就是体操。并在文中介绍一种基本思想作为你使用类型系统的基本指引。


引子


我将从一个相对简单的 API 的设计过程中阐述关于类型的故事。在这里我们可以假设我们现在是一个工具的开发者,然后我们需要设计一个 API 用于从对象中拿取指定的一些 key 作为一个新的对象返回给外面使用。


垃圾 TypeScript


一个人说:我才不用什么破类型,我写代码就是要没有类型,我就是要随心所欲的写。然后写下了这段代码。


declare function pick(target: any, ...keys: any): any

他的用户默默的写下了这段代码:


pick(undefined, 'a', 1).b

写完运行,发现问题大条了,控制台一堆报错,接口数据也提交不上去了,怎么办呢?


刚学 TypeScript


一个人说:稍微检查一下传入类型就好了,别让人给我乱传参数就行。


declare function pick(target: Record<string, unknown>, ...keys: string[]): unknown

很好,上面的问题便不复存在了,API 也是基本可用的了。但是!当对象复杂的时候,以及字段并不是短单词长度的时候就会发现了一个没解决的问题。


pick({ abcdefghijkl: '123' }, 'abcdefghikjl')

从肉眼角度上,我们很难发现这前后的不一致,所以我们为什么要让调用方的用户自己去 check 自己的字段有没有写对呢?


不就 TypeScript


一个人说:这还不简单,用个泛型加 keyof 不就行了。


declare function pick<
T extends Record<string, unknown>
>(target: T, ...keys: keyof T[]): unknown

我们又进一步解决的上面的问题,但是!还是有着相似的问题,虽然我们不用检查 keys 是不是传入的是一个正确的值了,但是我们实际上对返回的值也存在一个类似的问题。


pick({ abcdefghijkl: '123' }, 'abcdefghijkl').abcdefghikjl



  • 一点小小的拓展


    在这里我们看起来似乎是一个很简单的功能,但实际上蕴含着一个比较重要的信息。


    为什么我们之前的方式都拿不到用户传入进来的类型信息呢?是有原因的,当我们设计的 API 的时候,前面的角度是从,如何校验类型方向进行的思考。


    而这里是尝试去通过约定好的一种规则,通过 TypeScript 的隐式类型推断获得到传入的类型,再通过约定的规则转化出一种新的类型约束来对用户的输入进行限制。




算算 TypeScript


一个人说:好办,算出来一个新的类型就好了。


declare function pick<
T extends Record<string, unknown>,
Keys extends keyof T
>(target: T, ...keys: Keys[]): {
[K in Keys]: T[K]
}

到这里已经是对类型的作用有了基础的了解了,能写出来符合开发者所能接受的类型相对友好的代码了。我们可以再来思考一些更特殊的情况:


// 输入了重复的 key
pick({ a: '' }, 'a', 'a')

完美 TypeScript


到这里,我们便是初步开始了类型“体操”。但是在本篇里,我们不去分析它。


export type L2T<L, LAlias = L, LAlias2 = L> = [L] extends [never]
? []
: L extends infer LItem
? [LItem?, ...L2T<Exclude<LAlias2, LItem>, LAlias>]
: never

declare function pick<
T extends Record<string, unknown>,
Keys extends L2T<keyof T>
>(target: T, ...keys: Keys): Pick<T, Keys[number] & keyof T>

const x0 = pick({ a: '1', b: '2' }, 'a')
console.log(x0.a)
// @ts-expect-error
console.log(x0.b)

const x1 = pick({ a: '1', b: '2' }, 'a', 'a')
// ^^^^^^^^
// TS2345: Argument of type '["a", "a"]' is not assignable to parameter of type '["a"?, "b"?] | ["b"?, "a"?]'.
//   Type '["a", "a"]' is not assignable to type '["a"?, "b"?]'.
//     Type at position 1 in source is not compatible with type at position 1 in target.
//       Type '"a"' is not assignable to type '"b"'.

一个相对来说比较完美的 pick 函数便完成了。


总结


我们再来回到我们的标题吧,从我对大多数人的观察来说,很多的人开始来使用 TypeScript 有几种原因:



  • 看到大佬们都在玩,所以自己也想来“玩”,然后为了过类型校验而去写

  • 看到一些成熟的项目在使用 TypeScript ,想参与贡献,参与过程中为了让类型通过而想办法去解决类型报错

  • 公司整体技术栈采用的是 TypeScript ,要用 TypeScript 进行业务编写,从而为了过类型检查和 review 而去解决类型问题


诸如此类的问题还有很多,我将这种都划分为「为了解决类型检查的问题」而进行的类型编程,这也是大多数人为什么非常不适应 TypeScript,甚至不喜欢他的一个原因。这其实对学习 TypeScript 并不是一个很好的思路,在这里我觉得我们需要站在设计者的角度去对类型系统进行思考。我觉得有以下几个角度:



  • 类型检查到位

  • 类型提示友好

  • 类型检查严格

  • 扩展性十足


我们如果站在这几个角度对我们的 API 进行设计,我们可以发现,开发者能够很轻松的将他们需要的代码编写出来,而尽量不用去翻阅文档,查找 example。


希望通过我的这篇分享,大家能对 TypeScript 多一些理解,并参与到生态中来,守护我们的 JavaScript。




2023/06/27 更新



理性探讨,在评论区说什么屎不是屎的,嘴巴臭可以不说话的。


没谁逼着你一定要写最后一种层次的代码,能力不足可以学啊,不喜欢可以不学啊,能达到倒数第二个就已经很棒啊。


最后一种只是给大家看看 TypeScript 的一种可能,而不是说你应该这么做的。


作者:一介4188
来源:juejin.cn/post/7248599585751515173

收起阅读 »

次世代前端视图框架都在卷啥?

web
上图是 State of JavaScript 2022 前端框架满意度排名。前三名分别是 Solid、Svelte、Qwik。我们可以称他们为次世代前端框架的三大代表,前辈是 React/Angular/Vue。 目前 React/Augular/Vue 还...
继续阅读 »

state of JavaScript 2022 满意度排名


上图是 State of JavaScript 2022 前端框架满意度排名。前三名分别是 SolidSvelteQwik。我们可以称他们为次世代前端框架的三大代表,前辈是 React/Angular/Vue
目前 React/Augular/Vue 还占据的主流的市场地位, 现在我们还不知道下一个五年、十年谁会成为主流,有可能前辈会被后浪拍死在沙滩上, 也有可能你大爷还是你大爷。


就像编程语言一样,尽管每年都有新的语言诞生,但是撼动主流编程语言的地位谈何容易。在企业级项目中,我们的态度会趋于保守,选型会偏向稳定、可靠、生态完善的技术,因此留给新技术的生存空间并不多。除非是革命性的技术,或者有大厂支撑,否则这些技术或框架只会停留小众圈子内。



比如有一点革命性、又有大厂支撑的 Flutter。





那么从更高的角度看,这些次时代的前端视图框架在卷哪些方向呢?有哪些是革命性的呢?


先说一下本文的结论:



  • 整体上视图编程范式已经固化

  • 局部上体验上内卷






视图编程范式固化


从 JQuery 退出历史舞台,再到 React 等占据主流市场。视图的编程范式基本已经稳定下来,不管你在学习什么视图框架,我们接触的概念模型是趋同的,无非是实现的手段、开发体验上各有特色:



  • 数据驱动视图。数据是现代前端框架的核心,视图是数据的映射, View=f(State) 这个公式基本成立。

  • 声明式视图。相较于上一代的 jQuery,现代前端框架使用声明式描述视图的结构,即描述结果而不是描述过程。

  • 组件化视图。组件是现代前端框架的第一公民。组件涉及的概念无非是 props、slots、events、ref、Context…






局部体验内卷


回顾一下 4 年前写的 浅谈 React 性能优化的方向,现在看来依旧不过时,各大框架无非也是围绕着这些「方向」来改善。


当然,在「框架内卷」、「既要又要还要」时代,新的框架要脱颖而出并不容易,它既要服务好开发者(开发体验),又要服务好客户(用户体验) , 性能不再是我们选择框架的首要因素。




以下是笔者总结的,次世代视图框架的内卷方向:



  • 用户体验

    • 性能优化

      • 精细化渲染:这是次世代框架内卷的主要战场,它们的首要目的基本是实现低成本的精细化渲染

        • 预编译方案:代表有 Svelte、Solid

        • 响应式数据:代表有 Svelte、Solid、Vue、Signal(不是框架)

        • 动静分离





    • 并发(Concurrent):React 在这个方向独枳一树。

    • 去 JavaScript:为了获得更好的首屏体验,各大框架开始「抛弃」JavaScript,都在比拼谁能更快到达用户的眼前,并且是完整可交互的形态。



  • 开发体验

    • Typescript 友好:不支持 Typescript 基本就是 ca

    • 开发工具链/构建体验: Vite、Turbopack… 开发的工具链直接决定了开发体验

    • 开发者工具:框架少不了开发者工具,从 Vue Devtools 再到 Nuxt Devtools,酷炫的开发者工具未来可能都是标配

    • 元框架: 毛坯房不再流行,从前到后、大而全的元框架称为新欢,内卷时代我们只应该关注业务本身。代表有 Nextjs、Nuxtjs










精细化渲染






预编译方案


React、Vue 这些以 Virtual DOM 为主的渲染方式,通常只能做到组件级别的精细化渲染。而次世代的 Svelte、Solidjs 不约而同地抛弃了 Virtual DOM,采用静态编译的手段,将「声明式」的视图定义,转译为「命令式」的 DOM 操作


Svelte


<script>
let count = 0

function handleClick() {
count += 1
}
</script>

<button on:click="{handleClick}">Clicked {count} {count === 1 ? 'time' : 'times'}</button>

编译结果:


// ....
function create_fragment(ctx) {
let button
let t0
let t1
let t2
let t3_value = /*count*/ (ctx[0] === 1 ? 'time' : 'times') + ''
let t3
let mounted
let dispose

return {
c() {
button = element('button')
t0 = text('Clicked ')
t1 = text(/*count*/ ctx[0])
t2 = space()
t3 = text(t3_value)
},
m(target, anchor) {
insert(target, button, anchor)
append(button, t0)
append(button, t1)
append(button, t2)
append(button, t3)

if (!mounted) {
dispose = listen(button, 'click', /*handleClick*/ ctx[1])
mounted = true
}
},
p(ctx, [dirty]) {
if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0])
if (
dirty & /*count*/ 1 &&
t3_value !== (t3_value = /*count*/ (ctx[0] === 1 ? 'time' : 'times') + '')
)
set_data(t3, t3_value)
},
i: noop,
o: noop,
d(detaching) {
if (detaching) {
detach(button)
}

mounted = false
dispose()
},
}
}

function instance($$self, $$props, $$invalidate) {
let count = 0

function handleClick() {
$$invalidate(0, (count += 1))
}

return [count, handleClick]
}

class App extends SvelteComponent {
constructor(options) {
super()
init(this, options, instance, create_fragment, safe_not_equal, {})
}
}

export default App

我们看到,简洁的模板最终被转移成了底层 DOM 操作的命令序列。


我写文章比较喜欢比喻,这种场景让我想到,编程语言对内存的操作,DOM 就是浏览器里面的「内存」:



  • Virtual DOM 就是那些那些带 GC 的语言,使用运行时的方案来屏蔽 DOM 的操作细节,这个抽象是有代价的

  • 预编译方案则更像 Rust,没有引入运行时 GC, 使用了一套严格的所有权和对象生命周期管理机制,让编译器帮你转换出安全的内存操作代码。

  • 手动操作 DOM, 就像 C、C++ 这类底层语言,需要开发者手动管理内存


使用 Svelte/SolidJS 这些方案,可以做到修改某个数据,精细定位并修改 DOM 节点,犹如我们当年手动操作 DOM 这么精细。而 Virtual DOM 方案,只能到组件这一层级,除非你的组件粒度非常细。








响应式数据


和精细化渲染脱不开身的还有响应式数据


React 一直被诟病的一点是当某个组件的状态发生变化时,它会以该组件为根,重新渲染整个组件子树,如果要避免不必要的子组件的重渲染,需要开发者手动进行优化(比如 shouldComponentUpdatePureComponentmemouseMemo/useCallback)  。同时你可能会需要使用不可变的数据结构来使得你的组件更容易被优化。


在 Vue 应用中,组件的依赖是在渲染过程中自动追踪的,所以系统能精确知晓哪个组件确实需要被重渲染。


近期比较火热的 signal (信号,Angular、Preact、Qwik、Solid 等框架都引入了该概念),如果读者是 Vue 或者 MobX 之类的用户, Signal 并不是新的概念。


按 Vue 官方文档的话说:从根本上说,信号是与 Vue 中的 ref 相同的响应性基础类型。它是一个在访问时跟踪依赖、在变更时触发副作用的值容器。


不管怎样,响应式数据不过是观察者模式的一种实现。相比 React 主导的通过不可变数据的比对来标记重新渲染的范围,响应式数据可以实现更细粒度的绑定;而且响应式的另一项优势是它的可传递性(有些地方称为 Props 下钻(Props Drilling))。






动静分离


Vue 3 就是动静结合的典型代表。在我看来 Vue 深谙中庸之道,在它身上我们很难找出短板。


Vue 的模板是需要静态编译的,这使得它可以像 Svelte 等框架一样,有较大的优化空间;同时保留了 Virtual DOM 和运行时 Reactivity,让它兼顾了灵活和普适性。


基于静态的模板,Vue 3 做了很多优化,笔者将它总结为动静分离吧。比如静态提升、更新类型标记、树结构打平,无非都是将模板中的静态部分和动态部分作一些分离,避免一些无意义的更新操作。


更长远的看,受 SolidJS 的启发, Vue 未来可能也会退出 Vapor 模式,不依赖 Virtual DOM 来实现更加精细的渲染。








再谈编译时和运行时


编译时和运行时没有优劣之分, 也不能说纯编译的方案就必定是未来的趋势。


这几年除了新的编译时的方案冒出来,宣传自己是未来;也有从编译时的焦油坑里爬出来, 转到运行时方案的,这里面的典型代表就是 Taro。


Taro 2.0 之前采用的是静态编译的方案,即将 ’React‘ 组件转译为小程序原生的代码:


Untitled


但是这个转译工作量非常庞大,JSX 的写法千变万化,非常灵活。Taro 只能采用 穷举 的方式对 JSX 可能的写法进行了一 一适配,这一部分工作量很大,实际上 Taro 有大量的 Commit 都是为了更完善的支持 JSX 的各种写法。这也是 Taro 官方放弃这种架构的原因。


也就是说 Taro 也只能覆盖我们常见的 JSX 用法,而且我们必须严格遵循 Taro 规范才能正常通过。


有非常多的局限:



  • 静态的 JSX

  • 不支持高阶组件

  • 不支持动态组件

  • 不支持操作 JSX 的结果

  • 不支持 render function

  • 不能重新导出组件

  • 需要遵循 on*、render* 约束

  • 不支持 Context、Fragment、props 展开、forwardRef

  • ….


有太多太多的约束,这已经不是带着镣铐跳舞了,是被五花大绑了。




使用编译的方案不可避免的和实际运行的代码有较大的 Gap,源码和实际运行的代码存在较大的差别会导致什么?



  • 比较差的 Debug 体验。

  • 比较黑盒。


我们在歌颂编译式的方案,能给我们带来多大的性能提升、带来多么简洁的语法的同时。另一方面,一旦我们进行调试/优化,我们不得不跨越这层 Gap,去了解它转换的逻辑和底层实现。


这是一件挺矛盾的事情,当我们「精通」这些框架的时候,估计我们已经是一个人肉编译器了。


Taro 2.x 配合小程序, 这对卧龙凤雏, 可以将整个开发体验拉到地平线以下。




回到这些『次世代』框架。React/Vue/Angular 这些框架先入为主, 在它们的教育下,我们对前端视图开发的概念和编程范式的认知已经固化。


Untitled


比如在笔者看来 Svelte 是违法直觉的。因为 JavaScript 本身并不支持这种语义。Svelte 要支持这种语义需要一个编译器,而作为一个 JavaScript 开发者,我也需要进行心智上的转换。


而 SolidJS 则好很多,目之所及都是我们熟知的东西。尽管编译后可能是一个完全不一样的东西。



💡 Vue 曾经也过一个名为**响应性语法糖的实验性功能来探索这个方向,但最后由于这个原因**,废弃了。这是一次明智的决定



当然,年轻的次世代的前端开发者可能不这么认为,他们毕竟没有经过旧世代框架的先入为主和洗礼,他们更能接受新的开发范式,然后扛起这些旗帜,让它们成为未来主流。


总结。纯编译的方能可以带来更简洁的语法、更多性能优化的空间,甚至也可以隐藏一些跨平台/兼容性的细节。另一方面,源码和实际编译结果之间的 Gap,可能会逼迫开发者成为人肉编译器,尤其在复杂的场景,对开发者的心智负担可能是翻倍的。


对于框架开发者来说,纯编译的方案实现复杂度会更高,这也意味着,会有较高贡献门槛,间接也会影响生态。








去 JavaScript


除了精细化渲染,Web 应用的首屏体验也是框架内卷的重要方向,这个主要的发展脉络,笔者在 现代前端框架的渲染模式 一文已经详细介绍,推荐大家读一下:


Untitled


这个方向的强有力的代表主要有 Astro(Island Architecture 岛屿架构)、Next.js(React Server Component)、Qwik(Resumable 去 Hydration)。


这些框架基本都是秉承 SSR 优先,在首屏的场景,JavaScript 是「有害」的,为了尽量更少地向浏览器传递 JavaScript,他们绞尽脑汁 :



  • Astro:’静态 HTML‘优先,如果想要 SPA 一样实现复杂的交互,可以申请开启一个岛屿,这个岛屿支持在客户端进行水合和渲染。你可以把岛屿想象成一个 iframe 一样的玩意。

  • React Server Component: 划分服务端组件和客户端组件,服务端组件仅在服务端运行,客户端只会看到它的渲染结果,JavaScript 执行代码自然也仅存于服务端。

  • Qwik:我要直接革了水合(Hydration)的命,我不需要水合,需要交互的时候,我惰性从服务端拉取事件处理器不就可以了…


不得不说,「去 JavaScript」的各种脑洞要有意思多了。






总结


本文主要讲了次世代前端框架的内卷方向,目前来看还处于量变的阶段,并没有脱离现在主流框架的心智模型,因此我们上手起来基本不会有障碍。


作为普通开发者,我们可以站在更高的角度去审视这些框架的发展,避免随波逐流和无意义的内卷。






扩展阅读



作者:荒山
来源:juejin.cn/post/7251763342954512440
收起阅读 »