注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

60分前端之canvas

web
前言 最近在等前端对接,没事干就折腾一下前端。写了这么多年服务端,一直都是写api接口,还没好好学过前端呢。前端框架那么多,什么Vue、React,还分移动端、PC端,还有什么响应式、自适应,各种花里胡哨的东西,也没那么多精力去折腾,直接上手小程序吧。 小程序...
继续阅读 »

前言


最近在等前端对接,没事干就折腾一下前端。写了这么多年服务端,一直都是写api接口,还没好好学过前端呢。前端框架那么多,什么Vue、React,还分移动端、PC端,还有什么响应式、自适应,各种花里胡哨的东西,也没那么多精力去折腾,直接上手小程序吧。


小程序开发虽然简单,但基本的CSS样式还是得学一下吧:

1. MDN官方CSS教程
2. 谷歌出的CSS教程
3. 谷歌HTML教程


在国内,微信小程序开发,应该是每个前端程序员必会的技术了吧。


话不多说,直接开干。


写了这么多年的hello world了,学一门“新”的技术还是很简单的。而且咱要求也不高,毕竟不是专门写前端的,所以对于前端的技术,只要做到60分就够了。


所以,下面很多东西,我们都是能忽略就忽略,主打一个囫囵吞枣、不求甚解,以解决问题为主。


微信小程序类目的坑


微信小程序文档


学习任何技术,官方文档都是最好的入门方式之一。


查看微信小程序官方文档,照着文档一步一步往下走,有个两年开发经验的人,基本都能搞定,没什么难度。


但是问题来了,在配置小程序时,需要设置小程序类目。这里提前说一下,因为是练手的项目,所以我写的这个小程序是个大杂烩,既有数独小游戏,又有背单词。由于微信小程序类目可以选择多个,所以我先选择了小游戏......


问题来了!


微信小程序类目,选择了游戏之后,就不能选择其他的了!


而且还改不了!!


改不了就算了,更坑的是,小游戏的代码结构和小程序还不一样,没办法通用。选择小游戏,就必须用小游戏的代码结构,没办法和小程序混用。


练手的几个小游戏,我都是用小程序实现的,没办法在小游戏模式下运行。


网上搜了一下,发现很多人都遇到过这样的问题,也都向官方反映过了,但官方都没有给回复。顺带一提,好像社区里很多问题,官方基本都不回复的。


没办法,只能重新注册一个号了。或者注销之前的号,然后重新注册也行。嫌麻烦,干脆重新注册一个号吧。


数独81宫格实现过程

数独游戏估计很多人都只听过,但没实际玩过,这里就不详细介绍数独游戏了,我们只需要知道一点,数独游戏需要有一个81宫格。


可以看到,数独的81宫格最外层边框加粗,里面每三列的右边框、每三行的下边框也加粗,相当于是9个9宫格合并在一起。

作为前端的初学者,想要实现一个样式,第一反应肯定是用CSS来实现。

CSS选择器

先来把基本的框给实现了。

.sudoku {
box-sizing: border-box;
width: 700rpx;
min-height: 730rpx;
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(9, 1fr);
gap: 0;
border: 2px solid #000000;
}
.cell {
background-color: #FFFFFF;
border: 1px solid #000000;
border-right-width: 0;
border-bottom-width: 0;
}

实现效果是这样的:


不知道大家有没有注意到这段样式min-height: 730rpx;,设置最小高度。


一开始设置height: 700rpx;,宽高都是700rpx,在iPhone12/13 Pro Max机型下没问题,可是在iPhone12/13 Pro机型下,最下面一行会少一截,高度不够了!


前端的同学都知道CSS的一个概念,叫盒模型。这里高度不够是因为每个小格子的边框也有个宽度,实际总高度应该是每个格子的高度加上格子边框的宽度之和。宽度也是一样的。


这里有一个问题,就算宽高要加上每个小格子边框的宽度,最终的宽高也应该一样的才对啊?!为什么高度反而比宽度还大了呢?!


不理解!算了,毕竟是写服务端的,前端只要做到60分就够了。解决问题就行。高度不够的话,给它加高不就行了。/doge


加多少呢?试呗。😁


好了,在iPhone12/13 Pro机型下问题是解决了,但其他机型下呢?


这个好办,既然知道高度可能不够,继续加高的话,怕影响其他机型下的效果,那干脆给一个最低高度,超过这个高度就让它自己加高,简单粗暴。


它已经是个成熟的高度了,已经学会了自己长高。


还有一种更直接的办法,就是不设置高度,让它自己撑开。但是这样有一个不好的点,高度在撑开的过程中,会很明显看到页面抖动。设置一个最低高度的话,抖动就不明显了。


只要我看不到它抖动,它就不存在。


基本的形状已经有了,下面开始对内部指定的边框进行加粗。先来对每三列的右边框加粗,看过上面的CSS教程的话,稍微学点CSS知识的人都知道,CSS有个伪类选择器,我们用选择器nth-child来实现:

.cell:nth-child(3n+1) {
border-left-width: 2px;
}

:nth-child(n) 选择器匹配父元素中的第 n 个子元素,元素类型没有限制。


n 可以是一个数字,一个关键字,或者一个公式。



这里,我们用的是公式的方式。



使用公式(an+ b)a代表一个循环的大小,n是一个计数器(从0开始),以及b是偏移量。ab都必须是整数,an 必须写在 b 的前面,不能写成 b+an 的形式。


第一列左边框看起来太粗了,虽然样式上看起来边框的宽度是2px,但视觉上却像是4px也不知道是什么原因?


不管它什么原因了,毕竟我们不是专业的前端,就像上面说的那样,我们只需要做到60分就够了。


既然视觉上看起来像是4px,那就让它在视觉上看起来像2px

.cell:nth-child(9n+1) {
border-left-width: 1px;
}


现在看起来顺眼了很多。


宽度明明设置的是1px,为什么视觉上看起来像2px,不知道什么原因?老规矩,不管!😁


每三列的左边框加粗,这个已经实现了,下面开始实现每三行的下边框加粗


可是问题来了,使用:nth-child选择器貌似不能选中连续的子元素,网上搜了一堆,都没有解决方案,后来问了一下ChatGPT,给的回复是这样的:

/* 设置第3行和第6行的下边框宽度为2px */
.cell:nth-child(3n+1):nth-child(n+7),
.cell:nth-child(3n+2):nth-child(n+8),
.cell:nth-child(3n+3):nth-child(n+9) {
border-bottom-width: 2px;
}

还亲切地给了注释。可惜根本用不了。


除了第一行前6个的下边框没加粗外,其他元素的下边框全加粗了!


题外话:ChatGPT对CSS的问题,一本正经地胡说八道,给的答案基本都用不了。


折腾了好久,都没折腾出怎么用CSS选择器实现每三行下边框加粗这个效果,算了,换种方式吧。


对象模型


数独游戏不但要有这个宫格,相应的宫格里还得要有数字的。最终的效果应该像这样:


既然没办法自动计算出每三列和每三行的元素,那我们就用笨办法,手动给它们全标记出来。

定义对象模型:

interface SudokuItem {
numerical: number, // 数值
tower: boolean, // 左边框是否需要加粗
floor: boolean // 下边框是否需要加粗
}

初始化对象模型:

Page({
data: {
sudoku: [
{numerical: 0, tower: false, floor: false},
{numerical: 0, tower: false, floor: false},
{numerical: 0, tower: true, floor: false},
...
]
}
})

实际初始化的时候,通过JS代码初始化sudoku,并计算哪个元素的tower为true,哪个floor为true。

页面渲染的时候:


class="cell-item {{item.tower ? 'tower' : ''}} {{item.floor ? 'floor' : ''}}"
bindtap="focusTheCell"
data-index="{{idx}}"
>
{{item.numerical == 0 ? undefined : item.numerical}}

.sudoku .floor {
border-top-width: 2px;
}

.sudoku .tower {
border-right-width: 2px;
}

好了,基本的样式是实现了,虽然过程很简单粗暴,但咱毕竟是写服务端的,对前端的要求也不高,能做到60分就够了。


继续折腾,上面是用CSS样式实现的81宫格,还有其他办法吗?


必须有啊,前端的Canvas技术也很火啊,这个必须得折腾一下啊。


而且貌似还有很多前端不会Canvas的哦,等我们学会了,就去找前端嘚瑟一下。


Canvas


MDN官方教程


先看Canvas官方教程,嗯......,东西挺多的,好像有点复杂,几十个API,看得人眼花。没关系,毕竟我们是带着问题来学习的。这里,我们只需要几个API就行了。


什么问题?使用Canvas画出一个数独的81宫格。


我在学习一个新技术的时候,都是先上手再深入,不懂的地方暂时先跳过去,前面说过的,主打一个囫囵吞枣、不求甚解。先把问题解决了再说。


我们先暂时抛开小程序,只专注于Canvas。


直接开干。


首页,创建一个Canvas画布。

const canvas = document.getElementById("sudoku");
const ctx = canvas.getContext("2d");

好了,一个画布就创建好了。是不是很简单。而且是固定套路,可以不理解,记住就行了。下次用的时候,直接复制粘贴,再简单改改就行了。


id:和HTML标签的id属性一样。


widthheight:画布的宽高,可以通过CSS属性调整。这里的宽高类似于图片的原始宽高,CSS调整后的宽高就和修改页面图片的宽高一样,实际是对图片进行缩放。


canvas.getContext("2d"):接受一个参数,即上下文的类型。可以传入2dwebglwebgl2bitmaprenderer,用于创建不同类型的上下文。


这里我们传入2d,创建一个二维渲染上下文。


画布创建好了,下面该画画了。


想想看,如果让你在现实中用笔画一个81宫格,你应该怎么做?


首先,得有张白纸,然后提笔,之后开始画。


在Canvas中也一样,白纸我们已经有了,上面创建的画布就是白纸。


下面,该考虑怎么画了。初中数学老师告诉我们,两点确定一条直线,从A点到B点,画一条直线。


Canvas通过路径和填充的方式来绘制图画的,填充我们暂时不管,路径可以简单理解为线段,直线、曲线这些。


每条线段就是一条路径。



生成路径的第一步叫做 beginPath()。本质上,路径是由很多子路径构成,这些子路径都是在一个列表中,所有的子路径(线、弧形、等等)构成图形。而每次这个方法调用之后,列表清空重置,然后我们就可以重新绘制新的图形。本质上,路径是由很多子路径构成,这些子路径都是在一个列表中,所有的子路径(线、弧形、等等)构成图形。而每次这个方法调用之后,列表清空重置,然后我们就可以重新绘制新的图形。



不理解?记住就行了,都是固定套路。先记住怎么用,等bug写多了,自然就理解了。别管代码是不是写的垃圾,先敲出来再说,别只光在脑子里想。


ctx.beginPath():清空子路径列表开始一个新路径。可以简单理解成“提笔”。


ctx.moveTo(x, y):将一个新的子路径的起始点移动到 (x,y) 坐标。可以理解成上面的“从A点......”


ctx.lineTo(x, y):使用直线连接子路径的终点到(x,y)坐标。可以理解成上面的“到B点,画一条直线”


上面的(x, y)为坐标,页面坐标这个东西,相信很多人应该都已经知道了。


这里的坐标为画布中的坐标,坐标原点(0, 0)为画布的左上角。

const canvas = document.getElementById("sudoku");
// 创建二维上下文
const ctx = canvas.getContext("2d");
// 从A点
ctx.moveTo(100, 100);
// 到B点
ctx.lineTo(200, 100);

刷新一下页面,什么都没有!


别急,这就和我们现实中画画一样,先要在脑中构思,构思完了,才开始落笔绘画。


同样的道理,上面的那些相当于是在构思,构思完了后,我们得要落笔绘制。


ctx.stroke():根据当前的画线样式,绘制当前或已经存在的路径。


所以,完整的代码应该是这样的:

const canvas = document.getElementById("sudoku");
// 创建二维上下文
const ctx = canvas.getContext("2d");
ctx.beginPath();
// 从A点
ctx.moveTo(100, 100);
// 到B点
ctx.lineTo(200, 100);
// 画线
ctx.stroke();


具体可以看一下MDN的示例,使用canvas来绘制图形


好了,一条直线就绘制出来了。


可以了,能画出一条直线,我们可以开始画81宫格了。81宫格就是10条横线、10条竖线组成的。只要计算好每个点的坐标,然后画线就行了。


计算点的坐标,这是数学的范畴,就不过多赘述了,而且点的坐标计算也不难。

const canvas = document.getElementById("sudoku");
const ctx = canvas.getContext("2d");
// 单元格的宽高
const cellSize = 50;

for (let i = 0; i < 10; i++) {
// 画横线,留5px的间距
ctx.beginPath();
ctx.moveTo(5, i * cellSize + 5);
ctx.lineTo(455, i * cellSize + 5);
ctx.stroke();

// 画竖线
ctx.beginPath();
ctx.moveTo(i * cellSize + 5, 5);
ctx.lineTo(i * cellSize + 5, 455);
ctx.stroke();
}


81宫格是画出来了,可还没法用,因为数独中的81宫格,要求最外围边框加粗,里面每三列左边框加粗、每三行下边框加粗。


ctx.lineWidth:设置线段的宽度


还记得上面我们通过CSS选择器给边框加粗时遇到的痛苦吗,现在就简单多了,只需要简单的数学计算就可以了。

// 设置画笔宽度
if (i % 3 == 0) {
ctx.lineWidth = 3;
} else {
ctx.lineWidth = 1;
}

设置完后我们,看一下效果


下面是完整的代码:






<span class="hljs-string">Canvas</span> <span class="hljs-string">练习</span>





之后只要根据小程序的规则,将代码移植到小程序里就行了。至于怎么往数独里填充数字,这个之后再说吧,我们一点一点的来。这里我们只介绍这么画直线。


学一门新技术,一定要多练。数独的81宫格画完了,还可以画点其他的啊。比如画一个象棋的棋盘,或者画一个围棋的棋盘。


多练手,遇到的问题多了,自然就会了。


最后


虽然是一个练手的项目,但是既然已经做出来了,干脆就上线算了。

作者:W_懒虫
链接:https://juejin.cn/post/7260820017758093349
来源:稀土掘金
收起阅读 »

背包算法——双条件背包

web
通过8.6日的小红书笔试第二题,我彻底搞懂了01背包与完全背包。 这篇文章不是手把手的基础教学,简单从我自己的对背包问题掌握的基础上分享一下新的心得,随缘帮助陌生人。 题目: 小红很喜欢前往小红书分享她的日常生活。已知她生活中有n个事件,分享第i个事件需要她花...
继续阅读 »

通过8.6日的小红书笔试第二题,我彻底搞懂了01背包与完全背包。


这篇文章不是手把手的基础教学,简单从我自己的对背包问题掌握的基础上分享一下新的心得,随缘帮助陌生人。


题目:


小红很喜欢前往小红书分享她的日常生活。已知她生活中有n个事件,分享第i个事件需要她花费ti的时间和hi的精力来编辑文章,并能获得ai的快乐值。
小红想知道,在总花费时间不超过T且总花费精力不超过H的前提下,小红最多可以获得多少快乐值?

第一行输入一个正整数n,代表事件的数量。


第二行输入两个正整数T和H,代表时间限制和精力限制。


接下来的n行,每行输入三个正整数ti,hi,ai,代表分享第i个事件需要花费ti的时间、hi的精力,收获ai的快乐值。


输入示例:


3
5 4
1 2 2
2 1 3
4 1 5

输出示例:


7

考场上的做法——暴力递归(超时,通过36%)


下面的一段没啥好说的,输入数据的处理:


const n = parseInt(read_line());
const tArr = new Array(n).fill();
const hArr = new Array(n).fill();
const aArr = new Array(n).fill();
let line2 = read_line();
const T = parseInt(line2.split(" ")[0]);
const H = parseInt(line2.split(" ")[1]);
// 构造t\h\a数组
for(let i = 0; i < n; i ++) {
 let line = read_line().split(" ");
 line = line.map((str) => {
   return parseInt((str));
})
 tArr[i] = line[0];
 hArr[i] = line[1];
 aArr[i] = line[2];
}

最终处理的目标是如下的数据结构:


// 输入:
// 3
// 5 4
// 1 2 2
// 2 1 3
// 4 1 5
let n = 3;
const T = 5;
const H = 4;
const tArr = [1, 2, 4];
const hArr = [2, 1, 1];
const aArr = [2, 3, 5];

递归算法部分,没啥好说的,下面是笔试时的源代码:


let maxA = 0; // 最大快乐值
// 考察第i件事情,并且考察前的t, h, a值分别为t, h, a
function backTrace(i, t, h, a) {
 if(a > maxA) {
   maxA = a;
}
 if(i === n) return;
 backTrace(i + 1, t, h, a); // 不做第i件事
 if(t + tArr[i] <= T && h + hArr[i] <= H) { // 做第i件事
   backTrace(i + 1, t + tArr[i], h + hArr[i], a + aArr[i]);
}
}
backTrace(0, 0, 0, 0);
console.log(maxA);

dp动态规划


数据读取同上,dp算法部分:


let dp = new Array(T + 1).fill(); // 定义dp[i][j]表示i的精力与j的体力能获得的最大快乐值
dp = dp.map(() => {
 return new Array(H + 1).fill(0);
})

for(let k = 0; k < n; k ++) { // k表示下面计算dp[i][j]时事件的集合是前k件事情
 for(let i = 1; i <= T; i ++) { // i, j 遍历各种时间和精力组合,计算dp[i][j],注意必须是倒序遍历i\j!!!
   for(let j = 1; j <= H; j ++) {
     if(i >= tArr[k] && j >= hArr[k]) {
       dp[i][j] = Math.max(dp[i - tArr[k]][j - hArr[k]] + aArr[k], dp[i][j]);
    }
  }
}
}

// 打印dp数组
for(let i = 0; i <= T; i ++) {
 let str = '';
 for(let j = 0; j <= H; j ++) {
   str += dp[i][j];
   str += " ";
}
 console.log(str);
}

console.log(dp[T][H]);

感悟:



  1. 这里的k,也就是事件,等价于背包问题中的物品;对于背包问题来说,只对物品的体积(重量)这一个指标进行限制,这里的精力与体力就相当于背包问题中的体积,相当于两个限制。

  2. 之所以不管01背包还是完全背包,都可以把dp数组初始化为一维而不是二维,就是因为省略了第一维的物品数量;这里我们dp数组初始化为二维,但是最外层进行k次迭代,也就相当于省略了事件数量。

  3. 基于上面的理解,为什么i和j都要倒序遍历就显而易见了,因为本题显然对标01背包问题,也就是说每件事情最多分享一次,所以说如果不省略第一维度k的递推公式应该是:dp[k][i][j] = max(dp[k - 1][i][j], dp[k - 1][i - tArr[k]][j - hArr[k]]) + aArr[k]。说白了就是**dp[k] = f(dp[k - 1])** ,所以因为我们省略了第一维k,完全等价于背包问题省略第一维,所以需要倒序计算,可以类比01背包问题的一维dp数组,倒序的目的就是保证计算dp[j]时所使用的dp数据的正确性。

  4. plus,如果修改题目,没件事情可以重复分享,那么就正序计算dp[i][j],按照上面的测例算出来结果是8,完全没问题。


如果以前没接触过背包问题,这篇文章确实p用没有,但是如果正对背包问题处于似懂非懂的状态,或许看下

作者:荣达
来源:juejin.cn/post/7264792741951062068
对你有所帮助,加油。

收起阅读 »

因为美工小妹妹天还没亮就下班,我做了一版可以把svg文件转成web组态组件的svg编辑器

web
自我介绍 本猫生活在东北的一个四线小城市,目前在一家小单位任职前端工程师的职位。早八晚五,生活充实,好不快活! 噩耗 Ctrl+c,Ctrl+v。Ctrl+c,Ctrl+v。新的一天开始了,本猫正在努力的工作着。看着旁边的美工小妹妹,我的口水止不住的往下流,别...
继续阅读 »

自我介绍


本猫生活在东北的一个四线小城市,目前在一家小单位任职前端工程师的职位。早八晚五,生活充实,好不快活!


噩耗


Ctrl+c,Ctrl+vCtrl+c,Ctrl+v。新的一天开始了,本猫正在努力的工作着。看着旁边的美工小妹妹,我的口水止不住的往下流,别误会,是因为公司没有几个人,所以我们每天中午都在一起吃饭,而且我本来身为一个吃货,想到午饭就会流口水,很合理吧。


正当我沉迷在幻想之际,手机的一声震动把我拽回到现实。


“来我办公室一趟!”

竟然是老板给我发的消息!!!

难不成是我每天辛苦的工作被他发现了?要给我涨工资?

我一溜烟的从工位冲了出去,直奔老板的办公室。

这一路上我想了很多:想我这么多年的辛劳付出终于达到了回报、想我迎娶白富美的现场何其壮观、想我出任ceo之后回村又是多么的风光......

终于在三秒之后,我来到了老板的办公室,见到了我最敬爱的老板。


“猫啊,听说你最近表现不错”

还没等我说话,老板又接着说到:

“咱们公司最近新接了一个项目要交给你完成,你有没有信心啊?”

我拍了拍胸脯:

“必须的!老板您说让我做什么我就做什么”

然后老板对我展开了需求攻势:

“巴拉巴拉,如此如此,这般这般,巴巴拉拉加班拉拉巴巴”

“什么?加班!”

其实老板说了那么多我是左耳听,右耳冒,但是加班这两个重重的砸在了我心上。我突然感觉一股热流冲到了头顶,双腿也止不住的颤抖,随即我双手用力的支撑在了老板的办公桌上,才勉强地站稳脚跟。

老板似乎也看出了我的不适,继续说道:

“小猫啊!你年龄这么小,正是应该努力奋斗的时候,我像你这么大的时候每天只睡十分钟,才有了今天的成就。巴拉巴拉。。。。”

听着老板的经历,我不由得留下了感动的泪水。原来老板是如此的器重我,耐心的劝导我只为了让我成长,想到我刚才还想向老板提涨工资的事,我的脸唰的一下就红了。我赶紧向老板鞠了一躬,猫着腰往办公室外面跑。

“谢谢老板关心,我一定完美完成任务!”

“公司就你一个前端,一定得好好做啊!”

随着老板的声音越来越小,我知道我已经离开了老板的办公室,我挺起胸膛,这一刻我仿佛重生了一般,浑身充满了干劲。迈着自信的步伐,我回到了我的工位。


打开电脑,老板已经把项目需求文档发给了我,打开1个Gdoc文件,首页赫然印着几个大字:项目工期一个月


我眼前一黑,晕了过去


曙光


“醒醒!醒醒!你怎么睡着了呢?”

我闻到一股淡淡的清香味,原来是我旁边的美工小妹妹在叫我。

“老板发你的新需求的项目文档你看了吧?老板让我们一起做这个项目”

“看到了看到了”

我惊喜着大叫一声,丝毫不顾忌周围同事的目光。我把头发往后捋了捋,望向我旁边的美工小妹妹:

“放心吧,包在我身上!”

“好的吧,那我先回去了,需要我做什么跟我说吖!”

我比了一个ok的手势,目送美工小妹妹转身回到了她的工位上。


需求分析


于是乎,我赶紧打开了文档,细心的分析了这个项目的需求。

原来这个项目是希望完成一个web端的拓扑图,要求使用svg技术实现,布局大概是长这样:


布局.png


功能呢,也不复杂,可以从左侧的节点区把节点拖到画布,在画布上选中绘制的节点可以缩放旋转,在右侧属性区可以快捷的更改节点的一些属性


美工小妹妹就负责配合我去设计svg的图形


我冷笑一声,这还不简单?真是小看了我这个代码吸血鬼。我打开搜索引擎,用出了我的绝招



“无敌暴龙cv大法”



不出半天,上面的布局就被我给做好了。


根据项目需求还需要去实现拖动的功能,凭借我多年的工作经验,我很快便找到了如下可以进行参考的文章:


一个低代码(可视化拖拽)教学项目


详细的拖拽需求以及缩放旋转的操作这篇文章里都有讲,我这里就不重复赘述了,经过了几天没日没夜的开发,主要说下我遇到的问题吧!


上面文章包含的项目实际上是采用定位来实现的,本身也是支持集成svg文件的,我们先看一下svg文件如何集成:


svg节点定义:


<template>
   <div class="svg-star-container">
       <svg xmlns="http://www.w3.org/2000/svg" version="1.1">
           <circle cx="100" cy="50" r="40" stroke="black" stroke-width="2" :fill="fill" />
       </svg>
   </div>
</template>

<script>
export default {
   props: {
       fill: {
           type: String,
           require: true,
           default: '#ff0000',
      },
  },
}
</script>

右侧属性面板定义:


<template>
   <div>
       <el-form>
           <el-form-item label="填充色">
               <el-color-picker v-model="curComponent.fill" />
           </el-form-item>
       </el-form>
   </div>
</template>

上面这是我新集成的一个svg图形,里面只有一个圆形,我这里希望可以在右侧改变圆形的填充(fill)颜色,集成之后的效果就是这样的:


集成效果.png


其实本质上是可以满足我们的需求的,因为我们也就是想改改svgfill或者是stroke这些基本属性的,但是我们的项目大概有300多个组件,这样一来我需要写600多vue文件。上面的示例我只是演示了如何更改svgfill属性,没做缩放适配效果是这样的:


未适配.png


所以我还需要手动调整每个文件去适配缩放,这个工作量堪比吨级,我的脑中顿时思绪万千:


想起来这几日美工小妹妹准时下班向我发我来的甜蜜问候


美工下班.png


想起来昨天向公司申请换键盘的尴尬经历


申请键盘.png


望着手机屏幕反射出连续高强度加班憔悴的我,看着我新换回来CV按键清晰可见的键盘,看着美工小妹妹键盘上那纤细的手指,我不由得感叹道:这么好看的手,不多做几个图可惜了。于是乎我的脑海里萌生了一个大胆的想法:



我要写个逻辑自动把svg文件转成组件,每个svg文件写个配置文件就能在右侧属性区动态的设置svgfillstroke等属性!!!



理想很丰满,现实很骨感


开始我是打算用viteraw以字符串的方式加载svg文件,再用v-html指令将字符串渲染成html


<script setup lang="ts">
import testSvgString from '../assets/vue.svg?raw'
</script>

<template>
 <div>
   <div v-html="testSvgString"></div>
   <div v-html="testSvgString"></div>
 </div>
</template>



效果是这样的:


raw效果.png


这个方案确实行得通,不过如果真正绘制图像的话,会导致dom节点变的很多很多。


dom节点.png


而且现在还没有考虑怎么适应svg节点的实际大小,怎么动态的改变节点fill等基本属性。。。


越想头越大,迷迷糊糊中我仿佛身处在一片森林中,映入我眼帘的是一个正在从我面前河里飘出来的白发老人


“少年哟,你掉的是这个<symbol>标签呢?还是这个<use>标签呢?”


我猛然惊醒


皇天不负有心人


对啊,之前我们用过的一个项目里面的icon图标就是一个个文件,当时是用的这个插件vite-plugin-svg-icons


它可以把svg文件加载成symbol标签,然后在需要的地方用use标签引用就可以了


说干就干,于是我赶紧熟练的打开了搜索引擎,开始了我的求知之路


终于


这个基于 vue3.2+ts 实现的 svg 可视化 web 组态编辑器项目诞生了!


项目介绍


svg文件即组件,引入并编写好配置文件后之后无需进行额外配置,编辑器会自适应解析加载组件。
同时支持自定义svg组件和传统的vue组件


开源地址


github


gitee


在线预览


绘画


选中左侧的组件库,按住鼠标左键即可把组件拖动到画布中


绘画.gif


操作


选中绘制好的节点后会出现锚点,可以直接进行移动、缩放、旋转等功能,右侧属性面板可以设置配置好的节点的属性,鼠标右键可以进行一些快捷操作


操作.gif


连线


鼠标移动到组件上时会出现连线锚点,左键点击锚点创建线段,继续左键点击画布会连续创建线段,右键停止创建线段,鼠标放在线段上会出现线段端点提示,拖动即可重新设置连线,选中线段后还可以在右侧的动画面板设置线段的动画效果


连线.gif


支持集成到已有项目


脚手架项目


# 创建项目(已有项目跳过此步骤)
npm init vite@latest

# 进入项目目录
cd projectname

# 安装插件
pnpm i webtopo-svg-edit

# 安装pinia
pnpm i pinia

# 修改main.ts 注册pinia
import { createPinia } from 'pinia';
const app = createApp(App);
app.use(createPinia());
app.mount('#app')

#在需要的页面引入插件
import { WebtopoSvgEdit,WebtopoSvgPreview } from 'webtopo-svg-edit';
import 'webtopo-svg-edit/dist/style.css'

umd方式集成


<!DOCTYPE html>
<html>
 <head>
   <title>webtopo-svg-edit Example</title>
   <link href="https://unpkg.com/webtopo-svg-edit@0.0.8/dist/style.css" rel="stylesheet" />
   <script src="https://unpkg.com/vue@3.2.6/dist/vue.global.prod.js"></script>
   <script src="https://unpkg.com/vue-demi@0.13.11/lib/index.iife.js"></script>
   <script src="https://unpkg.com/pinia@2.0.33/dist/pinia.iife.prod.js"></script>
   <script src="https://unpkg.com/webtopo-svg-edit@0.0.8/dist/webtopo-svg-edit.umd.js"></script>
 </head>
 <body>
   <div id="app"></div>
   <script>
     const pinia = Pinia.createPinia()
     const app = Vue.createApp(WebtopoYLM.WebtopoSvgEdit)
     app.use(pinia)
     app.mount('#app')
   
</script>
 </body>
</html>


es module方式集成


<!DOCTYPE html>
<html>
 <head>
   <title>webtopo-svg-edit Example</title>
   <link href="https://unpkg.com/webtopo-svg-edit@0.0.8/dist/style.css" rel="stylesheet" />
 </head>
 <body>
   <div id="app"></div>
 </body>
</html>
<script type="importmap">
{
  "imports": {
    "vue": "https://unpkg.com/vue@3.2.47/dist/vue.esm-browser.prod.js",
    "@vue/devtools-api": "https://cdn.jsdelivr.net/npm/@vue/devtools-api/lib/esm/index.min.js",
    "vue-demi": "https://unpkg.com/vue-demi@0.13.11/lib/index.mjs",
    "pinia": "https://unpkg.com/pinia@2.0.29/dist/pinia.esm-browser.js",
    "WebtopoYLM": "https://unpkg.com/webtopo-svg-edit@0.0.8/dist/webtopo-svg-edit.es.js"
  }
}
</script>
<script type="module">
 import { createApp } from 'vue'
 import { createPinia } from 'pinia'
 import { WebtopoSvgEdit } from 'WebtopoYLM'
 const app = createApp(WebtopoSvgEdit)
 app.use(createPinia())
 app.mount('#app')
</script>


后记


“报告老板,以前一个月的工作,现在7天就能做完!”


“小伙子你很有前途,等公司赚钱了一定不会忘了你的!”


“老板这是哪里话,牛马的命也是命,当牛做马是我的荣幸!”


“行了,没什么事就去忙吧,7天之后等你们的好消息!”


凌晨三点,我看着美工小妹妹忙碌的身影,不由得嘴角上扬


嘿嘿!天

作者:咬轮猫
来源:juejin.cn/post/7260126013111812153
还没亮,谁也别想走!

收起阅读 »

基于css3写出的流水加载效果

web
准备部分 这里写入基本的html样式,这里还设置了水球的css样式,用于css样式中的计算--i:1是一种自定义的CSS变量,可能用于控制样式中的计数 <body> <div class="box"> <d...
继续阅读 »

准备部分



这里写入基本的html样式,这里还设置了水球的css样式,用于css样式中的计算--i:1是一种自定义的CSS变量,可能用于控制样式中的计数



<body>
<div class="box">
<div class="loader">
<!-- 这里设置了水球的css样式变量,用于css样式中的计算 -->
<span style="--i:1"></span>
<span style="--i:2"></span>
<span style="--i:3"></span>
<span style="--i:4"></span>
<span style="--i:5"></span>
<span style="--i:6"></span>
<span style="--i:7"></span>
<span style="--i:8"></span>
</div>
</div>
</body>

设置基本的css背景及其样式


image.png


* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

.box {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #0c1b21;
}
/* 设置流水区域 */
.loader {
position: relative;
width: 250px;
height: 250px;
background: #666;
animation: animate 12s linear infinite;
}

下面进行详细的css设计



这里通过伪元素设计了第一个小球的效果,通过定位定位到左上角,,设置了大小为40,并且设置了颜色和圆角色设置,同时添加了阴影效果,形成了如下的圆球水滴效果,通过渐变函数linear-gradient是一个渐变函数,用于创建线性渐变背景,设置了颜色和倾斜的角度



image.png


    .loader span {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
/* 定位 伪元素,设置流水的珠子 */
.loader span::before {
content: '';
position: absolute;
top: 0;
width: 40px;
height: 40px;
/* 珠子颜色设置 */
background: linear-gradient(45deg, #c7eeff, #03a9f4);
border-radius: 50%;
/* 设置阴影 */
box-shadow: 0 0 30px #00bcd4;
}

接下来根据html中定义的css变量,来设置不同方向的数据


image.png


.loader span{
/* 设置不同方向的小球 */
transform: rotate(calc(45deg*var(--i)));
}

将小球向内收缩一部分


image.png


.loader span::before {
left: calc(50% - 20px);
}

动画设置


在这里设置了旋转动画,并且在整个区域添加入了动画


image.png


/* 这里设置了旋转动画 */
@keyframes animate {
0% {
transform: rotate(0deg);
}

100% {
transform: rotate(360deg);
}
}


接下来补一下html


这里设置了4个span元素,用来形成四个小球,在容器内沿着小球的方向进行转动,形成一个如下的的效果,并且通过在html中给span元素加入css变量来控制每个小球的延迟效果


ezgif.com-video-to-gif.gif


去掉背景颜色后


ezgif.com-video-to-gif.gif


<body>
<div class="box">
<div class="loader">
<!-- 这里设置了水球的css样式变量,用于css样式中的计算 --i:1是一种自定义的CSS变量,可能用于控制样式中的计数 -->
<span style="--i:1"></span>
<span style="--i:2"></span>
<span style="--i:3"></span>
<span style="--i:4"></span>
<span style="--i:5"></span>
<span style="--i:6"></span>
<span style="--i:7"></span>
<span style="--i:8"></span>
<!-- 这里设置了光晕效果,使得出现5个转动的小球 -->
<span class="rotate" style="--j:0"></span>
<span class="rotate" style="--j:1"></span>
<span class="rotate" style="--j:2"></span>
<span class="rotate" style="--j:3"></span>
<span class="rotate" style="--j:4"></span>
</div>
<div class="word">加载中</div>
</div>

</body>


.rotate {
animation: animate 4s ease-in-out infinite;
/* 设置延迟 */
animation-delay: calc(-0.2s*var(--j));
}
.loader {
filter: url(#fluid);
/* 去掉临时背景颜色为透明 */
background: transparent;
}

接下来补全所需要的html



这里不全了所需要的html,加入了svg部分,使用过svg,对图形进行高斯模糊处理,然后对图形的颜色进行变化,通过颜色矩阵来实现颜色变化,这里使用的颜色矩阵将每个像素的红、绿、蓝三个通道的颜色值分别乘以1,不变化;将透明度乘以20,增加透明度;最后将透明度减去10,进一步增加透明度。这段代码可能被使用在创建视觉效果中,比如给图像添加模糊效果并调整其透明度,从而实现一种"流体"或"柔和"的视觉效果
并且定义了一个filter的滤镜效果
“in"属性的值为"SourceGraphic”,表示将滤镜应用在源图形上,“stdDeviation"属性的值为"10”,表示高斯模糊的参数,即模糊程度



<body>
<div class="box">
<!-- 这里使用了svg,svg是可缩放矢量图形的标签,通过创建和操作svg,使得图形通过缩放而不失去真的在各种尺寸和分辨率下呈现 -->
<!-- 这段代码的作用是先对图形进行高斯模糊处理,然后对图形的颜色进行变换。具体的颜色变换可以通过颜色矩阵来实现,
这里使用的颜色矩阵将每个像素的红、绿、蓝三个通道的颜色值分别乘以1,不变化;将透明度乘以20,增加透明度;
最后将透明度减去10,进一步增加透明度。这段代码可能被使用在创建视觉效果中,比如给图像添加模糊效果并调整其透明度,
从而实现一种"流体"或"柔和"的视觉效果 -->

<svg>
<!-- 这是定义一个滤镜效果的元素,其中"id"属性的值为"fluid",用于给滤镜效果命名。 -->
<filter id="fluid">
<!-- 这是一个高斯模糊滤镜效果,用于对图形进行模糊处理。“in"属性的值为"SourceGraphic”,表示将滤镜应用在源图形上,
“stdDeviation"属性的值为"10”,表示高斯模糊的参数,即模糊程度。 -->

<feGaussianBlur in="SourceGraphic" stdDeviation="10"></feGaussianBlur>
<!-- 这是一个颜色矩阵滤镜效果,用于对图形的颜色进行变换。
"values"属性的值为一个包含20个数字的字符串,表示颜色矩阵的变换矩阵。 -->

<feColorMatrix values="
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 20 -10 "
>

</feColorMatrix>
</filter>
</svg>
<div class="loader">
<!-- 这里设置了水球的css样式变量,用于css样式中的计算 --i:1是一种自定义的CSS变量,可能用于控制样式中的计数 -->
<span style="--i:1"></span>
<span style="--i:2"></span>
<span style="--i:3"></span>
<span style="--i:4"></span>
<span style="--i:5"></span>
<span style="--i:6"></span>
<span style="--i:7"></span>
<span style="--i:8"></span>
<!-- 这里设置了光晕效果,使得出现5个转动的小球 -->
<span class="rotate" style="--j:0"></span>
<span class="rotate" style="--j:1"></span>
<span class="rotate" style="--j:2"></span>
<span class="rotate" style="--j:3"></span>
<span class="rotate" style="--j:4"></span>
</div>
<div class="word">加载中</div>
</div>
</body>

再继续设置svg的样式,这里将被svg偏移的容器位置归于中心位置,并且设置了文字效果及其设置了文字的缩放动画


    svg {
width: 0;
height: 0;
}

.word {
position: absolute;
color: #fff;
font-size: 1.2em;
animation: words 3s linear infinite;
}
/* 这里设置了文字的缩放动画 */
@keyframes words {
0% {
transform: scale(1.2);
}

25% {
transform: scale(1);
}

50% {
transform: scale(0.8);
}

75% {
transform: scale(1);
}

100% {
transform: scale(1.2);
}
}

效果展示


ezgif.com-video-to-gif (1).gif


总结



实现这个效果关键在最后的那个svg的使用,通过添加svg给了一个高斯模糊的效果,从而才会使得明显的小球变成了这种连在一起的流动液体样式的小球,要学习一下svg效果,在他也是属于html5里面的一部分内容,仅供自己学习记录和新的展示



作者:如意呀
来源:juejin.cn/post/7263064267560976442
收起阅读 »

记录实现音频可视化

web
实现音频可视化 这里主要使用了web audio api和canvas来实现的(当然也可以使用svg,因为canvas会有失帧的情况,svg的写法如果有大佬会的话,可以滴滴我一下) 背景 最近听音乐的时候,看到各种动效,突然好奇这些音频数据是如何获取并展示出来...
继续阅读 »

实现音频可视化


这里主要使用了web audio api和canvas来实现的(当然也可以使用svg,因为canvas会有失帧的情况,svg的写法如果有大佬会的话,可以滴滴我一下)


背景


最近听音乐的时候,看到各种动效,突然好奇这些音频数据是如何获取并展示出来的,于是花了几天功夫去研究相关的内容,这里只是给大家一些代码实例,具体要看懂、看明白,还是建议大家大家结合相关api来阅读这篇文章。

参考资料地址:Web Audio API - Web API 接口参考 | MDN (mozilla.org)


实现思路


首先画肯定是用canvas去画,关于音频的相关数据(如频率、波形)如何去获取,需要去获取相关audio的DOM 或通过请求处理去拿到相关的音频数据,然后通过Web Audio API 提供相关的方法来实现。(当然还要考虑要音频请求跨域的问题,留在最后。)


一个简单而典型的 web audio 流程如下(取自MDN):


1.创建音频上下文<br>
2.在音频上下文里创建源 — 例如 <audio>, 振荡器,流<br>
3.创建效果节点,例如混响、双二阶滤波器、平移、压缩<br>
4.为音频选择一个目的地,例如你的系统扬声器<br>
5.连接源到效果器,对目的地进行效果输出<br>

image.png


上代码


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body {
background-color: #000;
}
div {
width: 100%;
border-top: 1px solid #fff;
padding-top: 50px;
display: flex;
justify-content: center;
}
</style>
</head>
<body>
<canvas></canvas>
<div>
<audio src="./1.mp3" controls></audio>
</div>
<script>
// 获取音频组件
const audioEle = document.querySelector("audio");
// 获取canvas元素
const cvs = document.querySelector("canvas");
// 创建canvas上下文
const ctx = cvs.getContext("2d");

// 初始化canvas的尺寸
function initCvs() {
cvs.width = window.innerWidth;
cvs.height = window.innerHeight / 2;
}
initCvs();

// 音频的步骤 音频源 ==》分析器 ===》输出设备

//是否初始化
let isInit = false;
let dataArray, analyser;
audioEle.onplay = function () {
if (isInit) return;
// 初始化
const audCtx = new AudioContext(); //创建一个音频上下文
const source = audCtx.createMediaElementSource(audioEle); //创建音频源节点
analyser = audCtx.createAnalyser(); //拿到分析器节点
analyser.fftSize = 512; // 时域图变换的窗口大小(越大越细腻)默认2048
// 创建一个数组,接受分析器分析回来的数据
dataArray = new Uint8Array(analyser.frequencyBinCount); // 数组每一项都是一个整数 接收数组的长度,因为快速傅里叶变换是对称的,所以除以2
source.connect(analyser); // 将音频源节点链接到输出设备,否则会没声音哦。
analyser.connect(audCtx.destination); // 把分析器节点了解到输出设备
isInit = true;
};

// 绘制,把分析出来的波形绘制到canvas上
function draw() {
requestAnimationFrame(draw);
// 清空画布
const { width, height } = cvs;
ctx.clearRect(0, 0, width, height);
// 先判断音频组件有没有初始化
if (!isInit) return;
// 让分析器节点分析出数据到数组中
analyser.getByteFrequencyData(dataArray);
const len = dataArray.length / 2.5;
const barWidth = width / len / 2; // 每一个的宽度
ctx.fillStyle = "#78C5F7";
for (let i = 0; i < len; i++) {
const data = dataArray[i]; // <256
const barHeight = (data / 255) * height; // 每一个的高度
const x1 = i * barWidth + width / 2;
const x2 = width / 2 - (i + 1) * barWidth;
const y = height - barHeight;
ctx.fillRect(x1, y, barWidth - 2, barHeight);
ctx.fillRect(x2, y, barWidth - 2, barHeight);
}
}
draw();
</script>
</body>
</html>

我这里只用了简单的柱状图,还有什么其他的奇思妙想,至于怎么把数据画出来,就凭大家的想法了。


关于请求音频跨域问题解决方案


1.因为我这里是简单的html,音频文件也在同一个文件夹但是如果直接用本地路径打开本地文件是会报跨域的问题,所以我这里是使用Open with Live Server就可以了


image.png


2.给获取的audio DOM添加一条属性即可
audio.crossOrigin ='anonymous'

或者直接在 aduio标签中 加入 crossorigin="anonymous"


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

我写了一个自动化脚本涨粉,从0阅读到接近100粉丝

web
点击在线阅读,体验更好链接现代JavaScript高级小册链接深入浅出Dart链接现代TypeScript高级小册链接linwu的算法笔记📒链接 引言 在CSDN写了大概140篇文章,一直都是0阅读量,仿佛石沉大海,在掘金能能频频上热搜的文章,在CSDN一点反...
继续阅读 »

点击在线阅读,体验更好链接
现代JavaScript高级小册链接
深入浅出Dart链接
现代TypeScript高级小册链接
linwu的算法笔记📒链接

引言


在CSDN写了大概140篇文章,一直都是0阅读量,仿佛石沉大海,在掘金能能频频上热搜的文章,在CSDN一点反馈都没有,所以跟文章质量关系不大,主要是曝光量,后面调研一下,发现情况如下


image.png


好家伙,基本都是人机评论,后面问了相关博主,原来都是互相刷评论来涨阅读量,enen...,原来CSDN是这样的,真无语,竟然是刷评论,那么就不要怪我用脚本了。


puppeteer入门



先来学习一波puppeteer知识点,其实也不难



puppeteer 简介


Puppeteer 是 Chrome 开发团队在 2017 年发布的一个 Node.js 包, 用来模拟 Chrome 浏览器的运行。



Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。 Chromium 和 Chrome区别



在学puppeteer之前我们先来了解下 headless chrome


什么是 Headless Chrome



  • 在无界面的环境中运行 Chrome

  • 通过命令行或者程序语言操作 Chrome

  • 无需人的干预,运行更稳定

  • 在启动 Chrome 时添加参数 --headless,便可以 headless 模式启动 Chrome


alias chrome="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"  # Mac OS X 命令别名

chrome --headless --disable-gpu --dump-dom https://www.baidu.com # 获取页面 DOM

chrome --headless --disable-gpu --screenshot https://www.baidu.com # 截图


查看更多chrome启动参数 英文
中文


puppeteer 能做什么


官方称:“Most things that you can do manually in the browser can be done using Puppeteer”,那么具体可以做些什么呢?



  • 网页截图或者生成 PDF

  • 爬取 SPA 或 SSR 网站

  • UI 自动化测试,模拟表单提交,键盘输入,点击等行为

  • 捕获网站的时间线,帮助诊断性能问题

  • ......


puppeteer 结构


image



  • Puppeteer 使用 DevTools 协议 与浏览器进行通信。

  • Browser 实例可以拥有浏览器上下文。

  • BrowserContext 实例定义了一个浏览会话并可拥有多个页面。

  • Page 至少有一个框架:主框架。 可能还有其他框架由 iframe 或 框架标签 创建。

  • frame 至少有一个执行上下文 - 默认的执行上下文 - 框架的 JavaScript 被执行。 一个框架可能有额外的与 扩展 关联的执行上下文。


puppeteer 运行环境


查看 Puppeteer 的官方 API 你会发现满屏的 async, await 之类,这些都是 ES7 的规范,所以你需要: Nodejs 的版本不能低于 v7.6.0


npm install puppeteer 

# or "yarn add puppeteer"


Note: 当你安装 Puppeteer 时,它会自动下载Chromium,由于Chromium比较大,经常会安装失败~ 可是使用以下解决方案



  • 把npm源设置成国内的源 cnpm taobao 等

  • 安装时添加--ignore-scripts命令跳过Chromium的下载 npm install puppeteer --ignore-scripts

  • 安装 puppeteer-core 这个包不会去下载Chromium


puppeteer 基本用法


先打开官方的入门demo


const puppeteer = require('puppeteer');

(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({path: 'example.png'});

await browser.close();
})();

上面这段代码就实现了网页截图,先大概解读一下上面几行代码:



  1. 先通过 puppeteer.launch() 创建一个浏览器实例 Browser 对象

  2. 然后通过 Browser 对象创建页面 Page 对象

  3. 然后 page.goto() 跳转到指定的页面

  4. 调用 page.screenshot() 对页面进行截图

  5. 关闭浏览器


是不是觉得好简单?


puppeteer.launch(options)


options 参数详解


参数名称参数类型参数说明
ignoreHTTPSErrorsboolean在请求的过程中是否忽略 Https 报错信息,默认为 false
headlessboolean是否以”无头”的模式运行 chrome, 也就是不显示 UI, 默认为 true
executablePathstring可执行文件的路劲,Puppeteer 默认是使用它自带的 chrome webdriver, 如果你想指定一个自己的 webdriver 路径,可以通过这个参数设置
slowMonumber使 Puppeteer 操作减速,单位是毫秒。如果你想看看 Puppeteer 的整个工作过程,这个参数将非常有用。
argsArray(String)传递给 chrome 实例的其他参数,比如你可以使用”–ash-host-window-bounds=1024x768” 来设置浏览器窗口大小。
handleSIGINTboolean是否允许通过进程信号控制 chrome 进程,也就是说是否可以使用 CTRL+C 关闭并退出浏览器.
timeoutnumber等待 Chrome 实例启动的最长时间。默认为30000(30秒)。如果传入 0 的话则不限制时间
dumpioboolean是否将浏览器进程stdout和stderr导入到process.stdout和process.stderr中。默认为false。
userDataDirstring设置用户数据目录,默认linux 是在 ~/.config 目录,window 默认在 C:\Users{USER}\AppData\Local\Google\Chrome\User Data, 其中 {USER} 代表当前登录的用户名
envObject指定对Chromium可见的环境变量。默认为process.env。
devtoolsboolean是否为每个选项卡自动打开DevTools面板, 这个选项只有当 headless 设置为 false 的时候有效

puppeteer如何使用



下面介绍 10 个关于使用 Puppeteer 的用例,并在介绍用例的时候会穿插的讲解一些 API,告诉大家如何使用 Puppeteer:



01 获取元素及操作


如何获取元素?



  • page.$('#uniqueId'):获取某个选择器对应的第一个元素

  • page.$$('div'):获取某个选择器对应的所有元素

  • page.$x('//img'):获取某个 xPath 对应的所有元素

  • page.waitForXPath('//img'):等待某个 xPath 对应的元素出现

  • page.waitForSelector('#uniqueId'):等待某个选择器对应的元素出现



Page.$(selector) 获取单个元素,底层是调用的是 document.querySelector() , 所以选择器的 selector 格式遵循 css 选择器规范




Page.$$(selector) 获取一组元素,底层调用的是 document.querySelectorAll(). 返回 Promise(Array(ElemetHandle)) 元素数组.




const puppeteer = require('puppeteer');

async function run (){
const browser = await puppeteer.launch({headless:false,defaultViewport:{width:1366,height:768}});
const page = await browser.newPage();
await page.goto('https://www.baidu.com');
const input_area = await page.$("#kw");
await input_area.type("Hello Wrold");

const search_btn = await page.$('#su');
await search_btn.click();

}

run();

02 获取元素属性


Puppeteer 获取元素属性跟我们平时写前段的js的逻辑有点不一样,按照通常的逻辑,应该是现获取元素,然后在获取元素的属性。但是上面我们知道 获取元素的 API 最终返回的都是 ElemetHandle 对象,而你去查看 ElemetHandle 的 API 你会发现,它并没有获取元素属性的 API.


事实上 Puppeteer 专门提供了一套获取属性的 API, Page.$eval() 和 Page.$$eval()


Page.$$eval(selector, pageFunction[, …args]), 获取单个元素的属性,这里的选择器 selector 跟上面 Page.$(selector) 是一样的。


const value = await page.$eval('input[name=search]', input => input.value);
const href = await page.$eval('#a", ele => ele.href);
const content = await page.$eval('
.content', ele => ele.outerHTML);

const puppeteer = require('puppeteer');

async function run (){
const browser = await puppeteer.launch({headless:false,defaultViewport:{width:1366,height:768}});
const page = await browser.newPage();
await page.goto('https://www.baidu.com');
const input_area = await page.$("#kw");
await input_area.type("Hello Wrold");

const search_btn = await page.$('#su');
await search_btn.click();

await page.waitFor('div#content_left > div.result-op.c-container.xpath-log',{visible:true});

let resultText = await page.$eval('div#content_left > div.result-op.c-container.xpath-log',ele=> ele.innerText)
console.log("result Text= ",resultText);



}

run();

03 处理多个元素




const puppeteer = require('puppeteer');

async function run() {
const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 1280,
height: 800,
},
slowMo: 200,
});
const page = await browser.newPage();
await page.goto('https://www.baidu.com');
const input_area = await page.$('#kw');
await input_area.type('Hello Wrold');
await page.keyboard.press('Enter');
const listSelector = 'div#content_left > div.result-op.c-container.xpath-log';
// await page.waitForSelector(listSelector);
await page.waitFor(3 * 1000);

const list = await page.$$eval(listSelector, (eles) =>
eles.map((ele) => ele.innerText)
);
console.log('List ==', list);
}

run();


04 切换frame


一个 Frame 包含了一个执行上下文(Execution Context),我们不能跨 Frame 执行函数,一个页面中可以有多个 Frame,主要是通过 iframe 标签嵌入的生成的。其中在页面上的大部分函数其实是 page.mainFrame().xx 的一个简写,Frame 是树状结构,我们可以通过page.frames()获取到页面所有的 Frame,如果想在其它 Frame 中执行函数必须获取到对应的 Frame 才能进行相应的处理



const puppeteer = require('puppeteer')

async function anjuke(){
const browser = await puppeteer.launch({headless:false});
const page = await browser.newPage();
await page.goto('https://login.anjuke.com/login/form');

// 切换iframe

await page.frames().map(frame => {console.log(frame.url())})
const targetFrameUrl = 'https://login.anjuke.com/login/iframeform'
const frame = await page.frames().find(frame => frame.url().includes(targetFrameUrl));

const phone= await frame.waitForSelector('#phoneIpt')
await phone.type("13122022388")
}

anjuke();

05 拖拽验证码操作


const puppeteer = require('puppeteer')

async function aliyun(){
const browser = await puppeteer.launch({headless:false,ignoreDefaultArgs:['--enable-automation']});
const page = await browser.newPage();
await page.goto('https://account.aliyun.com/register/register.htm',{waitUntil:"networkidle2"});

const frame = await page.frames().find(frame=>{
console.log(frame.url())
return frame.url().includes('https://passport.aliyun.com/member/reg/fast/fast_reg.htm')

})

const span = await frame.waitForSelector('#nc_1_n1z');
const spaninfo = await span.boundingBox();
console.log('spaninfo',spaninfo)

await page.mouse.move(spaninfo.x,spaninfo.y);
await page.mouse.down();

const div = await frame.waitForSelector('div#nc_1__scale_text > span.nc-lang-cnt');
const divinfo = await div.boundingBox();

console.log('divinfo',divinfo)
for(var i=0;i<divinfo.width;i++){
await page.mouse.move(spaninfo.x+i,spaninfo.y);
}
await page.mouse.up();
}

aliyun();


06 模拟不同设备


const puppeteer = require('puppeteer');
const devices = require('puppeteer/DeviceDescriptors');
const iPhone = devices['iPhone 6'];

puppeteer.launch().then(async browser => {
const page = await browser.newPage();
await page.emulate(iPhone);
await page.goto('https://www.baidu.com');
// 其他操作...
await browser.close();
});

07 请求拦截



const puppeteer = require('puppeteer');
async function run () {
const browser = await puppeteer.launch({
headless:false,
defaultViewport:{
width:1280,
height:800
}
})
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', interceptedRequest => {
const blockTypes = new Set(['image', 'media', 'font']);
const type = interceptedRequest.resourceType();
const shouldBlock = blockTypes.has(type);
if (shouldBlock) {
interceptedRequest.abort();
} else {
interceptedRequest.continue();
}

});
await page.goto('https://t.zhongan.com/group');
}

run();

08 性能分析



const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.tracing.start({path: 'trace.json'});
await page.goto('https://t.zhongan.com/group');
await page.tracing.stop();
browser.close();
})();

09 生成pdf



const URL = 'http://es6.ruanyifeng.com';
const puppeteer = require('puppeteer');
const fs = require('fs');

fs.mkdirSync('es6-pdf');

(async () => {
let browser = await puppeteer.launch();
let page = await browser.newPage();
await page.goto(URL);
await page.waitFor(5000); // 等待五秒,确保页面加载完毕

// 获取左侧导航的所有链接地址及名字
let aTags = await page.evaluate(() => {
let eleArr = [...document.querySelectorAll('#sidebar ol li a')];
return eleArr.map((a) =>{
return {
href: a.href.trim(),
name: a.text
}
});
});

// 先将本页保存成pdf,并关闭本页
console.log('正在保存:0.' + aTags[0].name);
await page.pdf({path: `./es6-pdf/0.${aTags[0].name}.pdf`});

// 遍历节点数组,逐个打开并保存 (此处不再打印第一页)
for (let i = 1, len = aTags.length; i < len; i++) {
let a = aTags[i];
console.log('正在保存:' + i + '.' + a.name);
page = await browser.newPage();
await page.goto(a.href);
await page.waitFor(5000);
await page.pdf({path: `./es6-pdf/${i + '.' + a.name}.pdf`});
}
browser.close();
})


10 自动化发布微博


const puppeteer = require('puppeteer');
const {username,password} = require('./config')

async function run(){
const browser = await puppeteer.launch({
headless:false,
defaultViewport:{width:1200,height:700},
ignoreDefaultArgs:['--enable-automation'],
slowMo:200,
args:['--window-size=1200,700']})
const page = await browser.newPage();

await page.goto('http://wufazhuce.com/',{waitUntil:'networkidle2'});
const OneText = await page.$eval('div.fp-one-cita > a',ele=>ele.innerText);
console.log('OneText:',OneText);


await page.goto('https://weibo.com/',{waitUntil:'networkidle2'});
await page.waitFor(2*1000);
await page.reload();

const loginUserInput = await page.waitForSelector('input#loginname');
await loginUserInput.click();
await loginUserInput.type(username);

const loginUserPasswdInput = await page.waitForSelector('input[type="password"]');
await loginUserPasswdInput.click();
await loginUserPasswdInput.type(password);

const loginBtn = await page.waitForSelector('a[action-type="btn_submit"]')
await loginBtn.click();

const textarea = await page.waitForSelector('textarea[class="W_input"]')
await textarea.click();
await textarea.type(OneText);

const sendBtn = await page.waitForSelector('a[node-type="submit"]');
await sendBtn.click();
}

run();

CSDN的脚本


image.png



这里注意CSDN有反扒机制,规则自己琢磨就行,我贴了伪代码,核心代码就不开放,毕竟自己玩玩就行了



const puppeteer = require('puppeteer');

async function autoCommentCSDN(username, password, targetBlogger, commentContent) {
const browser = await puppeteer.launch({ headless: false }); // 打开有头浏览器
const page = await browser.newPage();

// 登录CSDN
await page.goto('https://passport.csdn.net/login');
await page.waitForTimeout(1000); // 等待页面加载

// 切换到最后一个Tab (账号登录)
// 点击“密码登录”
const passwordLoginButton = await page.waitForXPath('//span[contains(text(), "密码登录")]');
await passwordLoginButton.click();


// 输入用户名和密码并登录
const inputFields = await page.$$('.base-input-text');

await inputFields[0].type( username);
await inputFields[1].type( password);
await page.click('.base-button');

await page.waitForNavigation();

// // 跳转到博主的首页
await page.goto(`https://blog.csdn.net/${targetBlogger}?type=blog`);

// // 点击第一篇文章的标题,进入文章页面
await page.waitForSelector('.list-box-cont', { visible: true });
await page.click('.list-box-cont');
// // 获取文章ID
console.log('page.url()',page.url())

// await page.waitForTimeout(1000); // 等待页面加载


await page.goto('https://blog.csdn.net/weixin_52898349/article/details/132115618')


await page.waitForTimeout(1000); // 等待页面加载

console.log('开始点击评论按钮...')

console.log('page.url()',page.url())

// 获取当前页面的DOM内容

const bodyHTML = await page.evaluate(() => {
return document.body.innerHTML;
});

console.log(bodyHTML);

// await page.waitForSelector('.comment-side-tit');

// const commentInput = await page.$('.comment-side-tit');

// await commentInput.click();

// 等待评论按钮出现

// 点击评论按钮

// await page.waitForSelector('.comment-content');
// const commentInput = await page.$('.comment-content textarea');
// await commentInput.type(commentContent);
// const submitButton = await page.$('.btn-comment-input');
// await submitButton.click();

// console.log('评论成功!');
// await browser.close();

}

// 请替换以下参数为您的CSDN账号信息、目标博主和评论内容
const username = 'weixin_52898349';
const password = 'xxx!';
const targetBlogger = 'weixin_52898349'; // 目标博主的CSDN用户名
const commentContent = '各位大佬们帮忙三连一下,非常感谢!!!!!!!!!!!'; // 评论内容

autoCommentCSDN(username, password, targetBlogger, commentContent);




作者:linwu
来源:juejin.cn/post/7263871284010106938
收起阅读 »

清朝项目太臭怎么办?TS重构它!

web
多图预警,流量党慎入 图片:均图片在上,描述在下 全文:6474字 阅读需要约28分钟 项目背景 最近公司要求给一个老项目加功能,具体就是把原来免费的服务改成付费的,然后再加一点其他的功能,我之前看到过那个项目的线上,这么一说,顿感不妙。截个图简单感受一下:...
继续阅读 »

多图预警,流量党慎入


图片:均图片在上,描述在下


全文:6474字 阅读需要约28分钟



项目背景


最近公司要求给一个老项目加功能,具体就是把原来免费的服务改成付费的,然后再加一点其他的功能,我之前看到过那个项目的线上,这么一说,顿感不妙。截个图简单感受一下:


image.png


农业数字云页面


image-20230804132617537


告警平台页


image.png
天气监测页面(navBar直接没了,我天😅)


image-20230804132711975


气象大数据页,好家伙直接空壳页面,也是没有navBar了,好像进入了一个全新的大屏项目。


报错什么的咱就不说了,光log信息就看的眼花缭乱,点几下给你log十几行出来,你说log就log吧,把名字取好也行,不过各位,看图😅。


好好好,虽然线上看起来没那么漂亮,可能人家代码写的工整呢?


那咱接下来看看代码,我草!等会jym,咱先不看代码,光这目录结构就把俺老猪吓一跳:


image-20230804133247244


image-20230804134709932


image-20230804133320761


image-20230804133541655


牛蛙牛蛙,今天算开眼了,根目录下命名个entries,好家伙下面还有components、views、App.vue(这是把另一个项目整个搬过来了是吧😅),根目录下同时拥有:utils、tools、service,好家伙,直接跟俺摆迷魂阵!更离谱的是,view下面还有view,view下的文件夹还有components,组件和页面完全分不清啊!敢问掘友们见过这种史诗级屎目录吗?


被目录结构震惊之后,先平复一下心情,看一下代码,代码写的还是挺工整的


做好准备,图来了:


先拿个vue组件看看:


image-20230804134138764


(那个分页函数是我后来写的)嗯~中规中矩,我还可以接受,毕竟命名什么的还算规范,看个十几分钟就没问题了,继续看个组件


image-20230804134418622


啊~行,配合模板部分还是能看懂的,继续来:


image-20230804134856519


啊?我可以接受两个函数长得差不多,但是功能应该也差不多吧?你这addControl2里面的功能和addControl完全不一样啊!!一个是绑定控制地图时的监听回调,一个是添加地图控件,我承认这两种确实都能称之为 Control...但是


vue组件写的还都可以接受


image-20230804135816345


接口封装也行,命名和注释都有


image-20230804140404214


这哥们自己用正则写了个时间格式化函数,我只能说很强,但是没必要。


好吧,看到这,虽然每一项都还算过得去,但是如果让我维护这样的项目,实在是太累了(我截图不是个例各位,每个文件中都有类似的)。改了一周后,我申请了重构,没想到当即被领导拍板,重构吧,交给你了。


敲定技术栈


因为旧的项目变量和全局变量、路由传参、mixin混用,导致页面内的变量来源难以追踪,很多时候知道是这个值引起的问题,但是就是找不到这个值哪来(可能被路由传了好多级才过来)。由此,我准备用 ts + pinia 做类型和状态管理。


旧项目(webpack)每次冷启动或打包都要花费 20s 左右的时间,热更新在 1 - 2s之间还行。由于这个网站一直在使用阿里云云效在线部署,所以我也没特意看打包后的大小,但是估计要在几十上下,其实这些问题都不大,主要优化一些静态文件和无用的代码就好了。但是我还是因此选择了使用 vite


因为旧项目使用了vue2、mixin和vuex和vue-router导致 变量污染问题严重 。为了解决这个问题,除了上面提到的 ts + pinia ,还选用了vue3,因为vue3天然不支持mixin,使用组合式API很容易可以更清晰的实现mixin可以做到的所有功能。


在新项目中,使用vue-router时,只作为备用方案,因为在重构时进行了新一轮的需求整理,直接将告警平台移除,后面两个页面的核心功能合并到农业数字云,在此场景下,新版的农业数字云只需要一个路由就可以承载所有的功能了。并且,使用了pinia-plugin-persistedstate插件对pinia做了全局的持久化,这里要说明一点,很多人觉得在本地存储太多数据不太好,尤其是像我这样一股脑的全部持久化,其实,在localStorage允许的情况下,把一些常用数据甚至是状态信息存储在本地,是效率最高也是性能最好的方式。


最终技术栈为:vue3 + pinia + vue-router + ts


开干


概述


因为是旧的项目,所以接口基本都全,整理出来的新需求,后端大哥跟我一起做,速度很快,我们第一周就已经完成了整个项目的99%。


由于重构系统并没有出新的设计图,导致我页面端两眼一抹黑,只能靠模仿旧的大致样式来做(因为和旧版逻辑架构完全不一样了,所以大部分还是重做的),最终做出了一个更清爽的前端页面:


image-20230804143240923


image-20230804143358520


登录页也不是一个单独的页面了,几乎所有的功能都改成了轻量级的弹窗。


这个项目完整的展开后是这种样子:


image-20230804143517623


对比旧的


image-20230804143615322


重构和优化


页面变化


可以看到明显变化的地方有:



  1. 新增了行政区域级地块

  2. 不再对播种、未播种和样板田分类,而是使用tag徽标

  3. 不再对每个地块单独展示服务项,而是选中地块时使用同一个区域展示

  4. 移除了信息重复的图表,移除了冗余的图例、地图控件、农事记录

  5. 灾害信息亦作为服务项、不再单独列出

  6. 新增了时间轴替代了原本的轮播图


代码变化


不明显的变化在于:


移除所有生产环境的log

image-20230804144029382


控制台干净到一尘不染,生产环境的log全部被移除了,且使用ts编写,运行阶段基本不会出错,使用了autofit.js,一行轻松适配设计稿下任何分辨率(就是强无敌)


移除所有第三方大型UI组件库

image-20230804144258724


未使用任何大型UI组件库(我管你能不能树摇),这也使得该项目体积被压缩到了极限,打包后仅有 1M+


清晰代码结构

image-20230804144439678


代码结构清晰,现在内容比较少,但不难看出结构还是比较规范的,基本沿用了vite创建的默认结构,且静态文件基本没有,这也是保证打包体积的重要因素。


使用pinia持久化存储

image-20230804144806244


因为全篇使用了 pinia 持久化存储,开发时不用关心数据什么时候get什么时候set了,随时使用store,即可得到、设置数据,极大的提高了开发幸福感。


接口分类封装

image-20230804145117239


接口分类封装,调用时可以清楚的知道在调用哪个厂商的接口,并且有ts加持,绝不会少一个、错一个参数。


简化拦截器

image-20230804145317150


极简拦截器,拦截器本质上就是一个错误截获器,只需要保证后续流程不崩溃就可以了,所以这里只做了最简单的拦截器,非常好用。


简化路由、使用组件

image-20230804145538621


不出所料的,这个项目没有用到路由切换,所有的内部功能都以轻量化弹窗的形式展示,唯一需要跳转的是支付页面,但那是另一个项目,我们的解决方案是直接带着用户token跳转,简单粗暴。


基于mqtt理念的发布订阅范式

image-20230804145855326


我选择完全相信vue3的watch,以这种方式编码乍一看会很难阅读,但是实际上是仿照了mqtt的消息订阅机制,把watch当成一个subscribe,把store.xxx当成一个topic,你会发现这种写法再好理解不过了,并且这种做法很爽,你如果需要订阅一个数据的变化而做出一些操作的话,就写watch就好了。


清除冗余代码

image-20230804150605361


没有冗余代码,没有!函数、变量命名清晰规范,主打的就是一个清晰,你看我这段代码需要注释吗?


编码规范

image-20230804150758323


规范、规范、还是他妈的规范,这就是规范的节流器,都跟着写!


使用ts定义类型

image-20230804150944425


明确的类型,定义类型可能多花五分钟,但是会在编码时节省一小时。


开发轻量级必要组件

image-20230804151157258


自定义selecter、toast等组件,不需要的一点不写,主打的就是一个轻量。


打包


整个开发流程下来,打包体积达到了惊人的1.94M(少林功夫好耶,太棒辽啊🥳),线上运行表现基本令人满意(少林功夫,够劲!顶呱呱呀🥳)


image.png


完事


虽然在第一周改完99%后,又加了大大小小的新需求和新改动,不过在规范的开发面前都迎刃而解,编码成了一种享受,对于这种轻量级项目,就要用轻量级的方法去实现,搞得笨重的像一头装甲熊(没说沃利贝尔),没有任何意义,关于这次重构,除了技术上的学习,还有理念上的进步,往往一个前端项目,只需要保证页面不卡顿、不报错、不崩溃就行了,不要剪了芝麻丢了西瓜、头重脚轻、舍本逐末、南辕北辙。我是德莱厄斯,共勉。


作者:德莱厄斯
来源:juejin.cn/post/7263315523537928250
收起阅读 »

分享我使用两年的极简网页记事本

web
若无单独说明,按照文章代码块中命令的顺序,一条一条执行,即可实现目标。 适用系统:Debian 系发行版,包括 Ubuntu 和 Armbian,其他发行版按流程稍改命令一般也可。 走通预计时间:10 分钟(Docker) 可以访问这个实...
继续阅读 »

若无单独说明,按照文章代码块中命令的顺序,一条一条执行,即可实现目标。 适用系统:Debian 系发行版,包括 Ubuntu 和 Armbian,其他发行版按流程稍改命令一般也可。




走通预计时间:10 分钟(Docker)





可以访问这个实例: https://forward.vfly.app/index.php ,试试怎么样,公开使用的网页记事本。


minimalist-web-notepad



image.png

image.png



这个网页记事本是我 2 年前玩机子初期的一大驱动力。当时主要从手机上浏览信息,刚转变到在电脑上处理信息,需要一种简便的渠道在两者之间传递文本、网址。




网盘太重,微信需要验证,tg 很好,但在找到这个记事本后,都是乐色,这就是最好的全平台传递文本的工具。



极简网页 记事本,是一个使用浏览器访问的轻量好用的记事本,专注于文本记录。


Github:pereorga/minimalist-web-notepad: Minimalist Web Notepad (github.com)




使用方法





  1. 访问网页: https://forward.vfly.app/index.php



  2. 它会随机分配 5 个字符组成的地址,如 https://forward.vfly.app/5b79m ,如果想指定地址,只需要访问时手动修改,如 https://forward.vfly.app/this_is_a_path 。下面以 5b79m 为例。



  3. 在上面编辑文本



  4. 等待一会(几秒,取决于延迟),服务端就会存储网页内容到名为 5b79m 的文件里。



  5. 关闭网页,如果关闭太快,会来不及保存,丢失编辑。



  6. 在其他平台再访问同样的网址,就能剪切内容了 ٩۹(๑•̀ω•́ ๑)۶



只要不关闭过快和在两个网页同时编辑,它都能很好地工作。因为极简,项目作者不会考虑增加多余功能。




webnote-in-phone_compressed.webp

webnote-in-phone_compressed.webp


在远控其他电脑时,用这个先传递命令,在目标电脑上使用,非常方便,而且适应性强。多个手机之间也一样。或者用于临时传送敏感数据,避免受到平台审查。


使用 Docker 安装网页 记事本


GitHub: pereorga/minimalist-web-notepad at docker (github.com)


全复制并执行,一键创建工作目录并开放端口


myserve="webnote"
sudo ufw allow 8088/tcp comment $myserve && sudo ufw reload
cd ~/myserve/
wget https://github.com/pereorga/minimalist-web-notepad/archive/refs/heads/docker.zip
unzip docker.zip && mv minimalist-web-notepad-docker webnote
cd webnote

根据注释自定义,然后执行,一键创建 docker-compose.yml 文件


cat > docker-compose.yml << EOF
---

version: "2.4"
services:
  minimalist-web-notepad:
    build: .
    container_name: webnote
    restart: always
    ports:
     - "8088:80"
    volumes:
     - ./_tmp:/var/www/html/_tmp
EOF

前面的 5b79m 就存储在 _tmp 中。


构建并启动容器(完成后就可以访问网页了,通过 http://ip_addr_or_domain:8088 访问。将 ip_addr_or_domain 替换为服务器的 IP 或域名)


docker compose up -d

Docker 版很久没更新了,有技术的可以参考博文中原生安装流程创建镜像。


迁移


数据都在 /var/www/webnote/_tmp 中,也就是 ~/myserve/webnote/_tmp,在新机子上重新部署一遍,复制这个目录到新机子上即可。




受限于篇幅,如果对原生安装(Apache + PHP)网页 记事本 感兴趣,请到我的博客浏览,链接在下面。



原文链接: https://blog.vfly2.com/2023/08/a-minimalist-web-notepad-used-for-two-years/ 版权声明:本博客所有文章除特別声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 承飞之咎 (blog.vfly2.com)


作者:AhFei
来源:mdnice.com/writing/611872e312654a22aa2472d71a6b3844
收起阅读 »

当我遇见了强制横屏签字的需求...

web
序言 人的一生就是进行尝试,尝试的越多,生活就越美好。——爱默生 在前一阶段的工作中,突然接到了这个需求:手写签批的页面在移动端竖屏时强制页面横屏展示进行签字,一开始我觉着只要将页面使用 CSS3 的 transform 进行 rotate 一下就可以了...
继续阅读 »

序言



人的一生就是进行尝试,尝试的越多,生活就越美好。——爱默生



在前一阶段的工作中,突然接到了这个需求:手写签批的页面在移动端竖屏时强制页面横屏展示进行签字,一开始我觉着只要将页面使用 CSS3 的 transform 进行 rotate 一下就可以了,但是当我尝试后发现并不是像我想象的那样简单。


vue2实现手写签批


在介绍横屏签字之前,我想先说明一下我实现签批使用的插件以及插件所调用的方法,这样在之后说到横屏签字的时候,大佬们不会感觉唐突。


vue-signature-pad


项目使用 vue-signature-pad 插件进行签名功能实现,强调一下如果使用vue2进行开发,安装的 vue-signature-pad 的版本我自测 2.0.5 是可以的


安装


npm i vue-signature-pad@2.0.5

引入


// main.js
import Vue from 'vue'
import App from './App.vue'

import VueSignaturePad from 'vue-signature-pad'

Vue.use(VueSignaturePad)

Vue.config.productionTip = false

new Vue({
render: (h) => h(App),
}).$mount('#app')

使用 vue-signature-pad 完成签批功能


这里我为了方便直接写了一个demo放到App.vue中,没有封装成组件


// app.vue
<template>
<div id="app">
<div style="background: #fff">
<vue-signature-pad
id="signature"
width="95%"
height="400px"
ref="signaturePad"
:options="options"
/>

</div>

<button @click="save">保存</button>
<button @click="resume">重置</button>
</div>

</template>

<script>
export default {
name: 'App',
data() {
return {
options: {
penColor: '#000',
},
}
},
methods: {
save() {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()
console.log(isEmpty)
console.log(data)
},

//清除重置
resume() {
this.$refs.signaturePad.clearSignature()
},
},
}
</script>


<style lang="scss">
html,
body {
padding: 0;
margin: 0;
}
#app {
width: 100vw;
height: 100vh;
background: #ececec;
}
</style>



代码比较通俗易懂,就是调用组件封装好的方法,保存后能够解构出data为base64编码的图片
Kapture 2023-07-28 at 10.27.49.gif
之后需要将base64编码格式转换成File文件格式的图片最后进行接口请求,那么转换方法如下展示👇🏻


<template>
<div id="app">
<div style="background: #fff">
<vue-signature-pad
id="signature"
width="95%"
height="300px"
ref="signaturePad"
:options="options"
/>

</div>

<div v-for="(item, index) in imgList" :key="index">
<img :src="item.src" alt="" width="100" />
</div>

<button @click="save" class="btn">保存</button>
<button @click="resume" class="btn">重置</button>
</div>

</template>

<script>
export default {
name: 'App',
data() {
return {
options: {
penColor: '#000',
},
imgList: [],
}
},
methods: {
save() {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()
this.imgList.push({
src: data,
})
let res = this.dataURLtoFile(data, 'demo')
console.log(res)
},

// 清除重置
resume() {
this.$refs.signaturePad.clearSignature()
},

// 将base64转换为文件
dataURLtoFile(dataurl, filename) {
var arr = dataurl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n)

while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}

return new File([u8arr], filename, { type: mime })
},
},
}
</script>


<style lang="scss">
html,
body {
padding: 0;
margin: 0;
}
#app {
width: 100vw;
height: 100vh;
background: #ececec;
}

.btn {
width: 35%;
color: #fff;
background: #5daaf3;
border: none;
height: 40px;
border-radius: 20px;
margin-top: 20px;
margin-left: 40px;
}
</style>


调用后,打印出转换成文件的图片如图


image.png
之后根据需求调用接口将文件图片作为入参即可。


阶段总结


经过上面的操作,我们就实现了前端的签批的完整流程,还是比较容易理解的。




新的需求


在实现这个功能不久之后,客户那边提出了新的需求:手机竖屏时将签字功能进行横屏展示。


错误思路


刚开始接到这个需求的时候,通过我所掌握的技术首先就是想到用CSS3的transform:rotate方法进行页面90deg的旋转,将签字组件也进行旋转之后进行签名;由于我对canvas不是很了解,所以我把包裹在签字组件外的div标签进行了旋转后签字发现落笔点位置错乱。


    <div style="background: #fff; transform: rotate(-90deg)">
<vue-signature-pad
id="signature"
width="95%"
height="300px"
ref="signaturePad"
:options="options"
/>

</div>


改变思路


既然不能旋转外层的div,那我想到一种欺骗方式:不旋转div,样式修改成与横屏样式相似,然后将生成的图片进行一个旋转,这样就ok了!那么我们的目标就明确了,找到能够旋转bas64编码的方法然后返回一个旋转后的base64图片在转换成file文件传递给后端问题就解决了。

经过一个苦苦寻找,终于找到了方法并实现了这个功能,话不多说,先撸为敬(样式大佬们自己改下,我这里展示下转换后的图片)。


<template>
<div id="app">
<div style="background: #fff">
<vue-signature-pad
id="signature"
width="95%"
height="300px"
ref="signaturePad"
:options="options"
/>

</div>

<div v-for="(item, index) in imgList" :key="index">
<img :src="item.src" alt="" width="100" />
</div>

<div class="buttons">
<button @click="save" class="btn">保存</button>
<button @click="resume" class="btn">重置</button>
</div>
</div>

</template>

<script>
export default {
name: 'App',
data() {
return {
options: {
penColor: '#000',
},
imgList: [],
fileList: [],
}
},
methods: {
save() {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()

this.rotateBase64Img(data, 90, (res) => {
console.log(res) // 旋转后的base64图片src
this.fileList.push({
file: this.dataURLtoFile(res, 'sign'),
name: 'sign',
})
this.imgList.push({
src: res,
})
})
},

// 清除重置
resume() {
this.$refs.signaturePad.clearSignature()
},

// 将base64转换为文件
dataURLtoFile(dataurl, filename) {
var arr = dataurl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n)

while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}

return new File([u8arr], filename, { type: mime })
},

// 通过canvas旋转图片
rotateBase64Img(src, edg, callback) {
var canvas = document.createElement('canvas')
var ctx = canvas.getContext('2d')

var imgW //图片宽度
var imgH //图片高度
var size //canvas初始大小

if (edg % 90 != 0) {
console.error('旋转角度必须是90的倍数!')
throw '旋转角度必须是90的倍数!'
}
edg < 0 && (edg = (edg % 360) + 360)
const quadrant = (edg / 90) % 4 //旋转象限
const cutCoor = { sx: 0, sy: 0, ex: 0, ey: 0 } //裁剪坐标

var image = new Image()

image.crossOrigin = 'anonymous'
image.src = src

image.onload = function () {
imgW = image.width
imgH = image.height
size = imgW > imgH ? imgW : imgH

canvas.width = size * 2
canvas.height = size * 2
switch (quadrant) {
case 0:
cutCoor.sx = size
cutCoor.sy = size
cutCoor.ex = size + imgW
cutCoor.ey = size + imgH
break
case 1:
cutCoor.sx = size - imgH
cutCoor.sy = size
cutCoor.ex = size
cutCoor.ey = size + imgW
break
case 2:
cutCoor.sx = size - imgW
cutCoor.sy = size - imgH
cutCoor.ex = size
cutCoor.ey = size
break
case 3:
cutCoor.sx = size
cutCoor.sy = size - imgW
cutCoor.ex = size + imgH
cutCoor.ey = size + imgW
break
}

ctx.translate(size, size)
ctx.rotate((edg * Math.PI) / 180)
ctx.drawImage(image, 0, 0)

var imgData = ctx.getImageData(
cutCoor.sx,
cutCoor.sy,
cutCoor.ex,
cutCoor.ey
)

if (quadrant % 2 == 0) {
canvas.width = imgW
canvas.height = imgH
} else {
canvas.width = imgH
canvas.height = imgW
}
ctx.putImageData(imgData, 0, 0)
callback(canvas.toDataURL())
}
},
},
}
</script>


<style lang="scss">
html,
body {
padding: 0;
margin: 0;
}
#app {
width: 100vw;
height: 100vh;
background: #ececec;
}

.btn {
width: 35%;
color: #fff;
background: #5daaf3;
border: none;
height: 40px;
border-radius: 20px;
margin-top: 20px;
margin-left: 40px;
}
</style>


那么经过翻转后当我们横屏移动设备时,保存出的图片会进行90度旋转,传递给后端的图片就是正常的了✅(代码可直接食用)


处理细节


后来我发现签字的笔锋太细了,打印出来的效果很差,于是通过查阅,只要设置 options 中的 minWidth和maxWidth 大一些即可
到此所有需求就已经都解决了。


总结


其实平时开发中没有对canvas用到很多,导致对这块的知识很薄弱,我在查阅的时候找到过用原生实现此功能,不过因为时间不够充裕,为了完成需求耍了一个小聪明,后续应该对canvas更多的了解一下,在深入了解上面的旋转方法具体是如何实现的,希望这篇文章能够对遇到这种需求并且时间紧迫的你有所帮助!如果有更好的方式,也希望大佬们分享,

作者:爱泡澡的小萝卜
来源:juejin.cn/post/7260697932173590565
交流经验变得更强!!

收起阅读 »

前端简洁表单模型

web
大家好,我是前端菜鸡木子 今天想和大家浅谈下前端表单的简洁模型。说起表单大家一定都不陌生,因为各自团队内部一定充斥着各种或简单或复杂的表单场景。为了解决表单开发问题,市面上也有着许多优秀的表单解决方案,例如:Formily、Ant Design、FormRen...
继续阅读 »

gabriel-ramos-azbe3hSHNHU-unsplash.jpg


大家好,我是前端菜鸡木子


今天想和大家浅谈下前端表单的简洁模型。说起表单大家一定都不陌生,因为各自团队内部一定充斥着各种或简单或复杂的表单场景。为了解决表单开发问题,市面上也有着许多优秀的表单解决方案,例如:FormilyAnt DesignFormRender 等。这些框架的底层都维护着一套基础的「表单模型」,虽然框架不同,但是「表单模型」的设计却是基本一致,只是上层应用层的设计会随着业务的需求进行调整。今天的主题也会围绕着「表单模型」进行展开


前言


本文是偏基础层面的介绍,不会涉及到太多框架的源码解析。另外,我会以最近如日中天的 Formily 为例进行讲解,大家如果对 Formily 不太了解,可以先去了解和使用。


表单模型的基础概念


我们知道一个表单包含了 N 多个字段,每个字段都需要用户输入或者联动带出,当用户输入完成之后我们可以通过 Form.Values 的形式直接获取到表单内部 N 多个字段的值,那么这是如何实现的呢?


我们通过一张图来简单阐述下:


yuque_diagram.png


其中:



  • Form:是通过 JS 维护的一个表单模型实例,FormilycreateForm 返回的就是这个实例,它负责维护表单的所有数据和每个字段 Field 的实例

  • Field: 是通过 JS 维护的每一个字段的实例,它负责维护当前字段的所有数据和状态

  • Component: 是每个字段对应的展示层组件,可以是 Input 或者 Select,也可以是其它的自定义组件


从图中不难看出,每个 Field 都对应着一个展示层的 Component,当用户在 Component 层输入时,会触发 props.onChange 事件,然后在事件内部将用户输入的值传入到 Field 里。同时当 Field 值变化时 (比如初始化时的默认值,或者通过 field.setValue 修改字段的值 ),又会将 Field.value 通过 props.value 的形式传入到 Component 内部,以此来达到 ComponetField 的数据联动。


我们可以看下在 Formily 内部是如何实现的(已对源码进行一些优化和注释):


const renderComponent = () => {
// 获取 Field 的 value
const value = !isVoidField(field) ? field.value : undefined;

// 设置 onChange 事件
const onChange = !isVoidField(field)
? (...args: any[]) => {
field.onInput(...args)
field.componentProps?.onChange?.(...args)
}
: field.componentProps?.onChange

// 生成 Field 对应的 Component
return React.createElement(
getComponent(field.componentType),
{
value,
onChange,
},
content
)
}

这里面的 onChange 事件里触发了 field.onInput 的事件,在 field.onInput 内会做两件事情:



  • onChange 携带的 value 赋值给 field.value

  • onChange 携带的 value 赋值给 form.values


这里需要额外说明的是,一个 Form 会通过「路径」系统聚合多个 Field,每个 Field.value 也是通过路径系统被聚合到 Form.values 下。


我们通过一个简单的 demo 来介绍下路径的概念:


const formValues = {
key1: {
key2: 'value',
}
};

我们通过 key1.key2 可以找到一个具体的值,这个 key1.key2 就是一个路径。在 Formily 内维护了一个高级的路径模块,感兴趣的可以去看下 form-path


表单模型的响应式


聊完表单模型的基础概念后,我们知道



  • Component 组件通过 props.onChange 将用户的数据回传到 FieldForm 实例内

  • Field 实例内的 value 会通过 props.value 形式传递到 Component 组件内


那么问题来了,Field 实例内部的 value 改变后,Component 组件是如何做到细粒度的重新渲染呢?


不卖关子,直接公布答案:



  • formily: 通过 formily/reactive 进行响应式跟踪,能知道具体是哪个组件依赖了 Field.value, 并做到精准刷新

  • Antd:通过 rc-field-form/useForm 这个 hook 来实现,本质上是通过 const [, forceUpdate] = React.useState({}); 来实现的


虽然这两种方法都能实现响应式,但是 Ant 的方式比较暴力,当其中一个 Field.value 发生改变时,整个表单组件都需要 render 。而 Formily 能通过 formily/reacitve 追踪到具体改变的 Field 对应的 Componet 组件,只让这个组件进行 render



formily/reactive 实现比较复杂,这边不会深入探讨具体实现方式,感兴趣的小伙伴可以看下这篇文章 从零开始撸一个「响应式」框架 (本质上是通过 Proxy 来拦截 getset,从而实现依赖追踪)



接下来,我们就看下如何借助 formily/reactive 来实现响应式


第一步:我们需要在 Field 初始化时将 value 变成响应式:


import { define, observable } from '@formily/reactive'

class Field {
constructor(props) {
// 初始化 value 值
this.value = props.value;

// 将 this.value 变成响应式
define(this, {
value: observable.computed
})
}
}

第二步:对 Field 对应的 Componet 进行下 "包装":


import { observer } from '@formily/reactive-react'

const ReactiveComponentInernal = () => {
// renderComponent 源码在 「基础概念」章节里
return renderComponent();
}

export const FieldComponent = observer(ReactiveComponentInernal);


observer 内部也和 rc-field-form/useForm 类似,通过 const [, forceUpdate] = React.useState({}); 来实现依赖改变时,子组件级别的动态 render



到此为止,表单模型的响应式也基本完成了


表单模型的规范


有了以上的表单模型,我们就可以构建一个简单的表单框架。但是真实的业务场景却不可能这么简单,迎面而来的第一个问题就是「联动」,举个例子:


QQ20230729-134607-HD.gif


需求:当城市名称改变后,城市编码字段需要联动带出对应的值。我们可以快速想到两种方案:



  • 方案1:在 城市名称 字段的 onChange 事件里通过 form.values.cityCode = hz 的形式去动态修改 城市编码 字段。

  • 方案2:在 城市编码 字段里显示的配置对 城市名称 字段的依赖,同时需要配置依赖改变时的处理逻辑,例如:


const formSchema = {
cityName: {
'x-component': 'Select',
},
cityCode: {
'x-component': 'Input',
'x-reactions': {
dependencies: ['cityName'],
fulfill: {
state: {
value: '{{ $deps[0]?.value }}',
},
},
},
},
};

无论方案 1 还是方案 2 都能实现需求,但是两个方案各有缺点


方案 1 有两个问题:



  • 问题一:打破了【表单模型的基础概念】,cityName 对应的组件的 onChange 事件里「直接」对 cityNamecityCode 字段进行了修改。

  • 问题二:我们不能「直观」的看到 cityCodecityName 字段产生了依赖,只有在看具体代码时才能知道


方案 2 也会有两个问题:



  • 问题一:schema 本身的可读性不强,且使用 formily schema 时,配置内容比较多

  • 问题二:使用 schema 配置 x-component-props 时不能使用 ts 特性


当表单逐渐复杂起来的时候,方案 1 的弊端会逐步显现出来,字段间会产生诸多的 「幽灵」依赖和控制,导致后续迭代的时候根本无从下手。所以在我自己的团队内部,我们规定出了几条「表单模型」的使用规范:




  • 规范 1: 每个 Field 对应的 Component 只对自己的字段负责,不允许通过 Form api 直接修改其他字段

  • 规范 2: 在 formSchema 里需要维护表单的所有字段配置和依赖,字段间不允许出现「幽灵」依赖

  • 规范 3: 尽量不要使用 form.setValuesform.queryField('xxx').setValue 等动态修改字段值的 Form api(特殊场景除外)

  • 规范 4: 表单涉及到的所有字段都尽量存储到表单模型中,不要使用外部变量来保存



这些规范其实是个普适性的范式,无论你在使用 Formily 也好,还是 Ant Design 也好,都需要去遵守。规范 2 里我用了 Formilyschema 来说明,但如果你使用的是 Ant Design,可以把 formSchema 理解为 <Form.Item reaction={{ xxx }}></Form.Item>



其实 formily 的 schema 最终会通过 RecursionField 组件递归渲染成具体的 FormItem 形式



表单模型的应用层


有了上述的「表单模型」概念和规范之后,我们就可以来构建表单模型的应用层了


yuque_diagram(1).png



  • Form Scheam: 整个表单的配置中心,负责表单各个字段的配置和联动 、校验等,它只负责定义不负责实现。它可以是个 Json Schema,也可以是 Ant Design<FormItem>

  • Form Component: 表单内每个字段的 UI 层组件,可以再分为:基础组件业务组件,每个组件都只负责和自己对应的 Field 字段交互

  • 业务逻辑:将复杂业务抽象出来的业务逻辑层,纯 JS 层。当然这一层是虚拟的概念,它可以存在于 Form Componet 里,也可以放在入口的 Index Component 内。如果业务复杂, 也可以放到 hooks 里或者单独的 JS 模块内部


有了应用层架构后,在写具体表单页面时,我们需要在脑海中清晰的勾勒出每层(Schema Component Logic)的设计。当页面足够简单时,也许会没有 Logic 层,Component 层也可以直接使用自带的基础表单组件,但是在设计层面我们不能混淆


表单模型的实践 - Formily


从去年开始,我们团队便引入 formily 作为中后台表单解决方案。在不断的实践过程中,我们逐步形成了一套自己的开发范式。主要有以下几个方面


Formily 的取舍


我们借助了 formily 的以下几个能力:



  • formily/reactive: 通过 reacitve 响应式框架来构建业务侧的数据模型

  • formily/schema: 通过 json-schema 配置来描述整个表单字段的属性,当然其背后还携带着 formily 关于 schema 的解析、渲染能力

  • formily/antd: 一些高级组件


同时,我们也在尽量避免使用 formily 的一些灵活 API:



  • Form 相关 API:比如 useFormform.setValues 等,我们不希望在任何组件内部都能「方便」的窜改整个表单的所有字段值,如果当前字段对 XX 字段有依赖或者影响,你应该在 schema 里显示的声明出来,而不是偷偷摸摸的修改。

  • Query 相关 API: 比如 form.query('field'),原因同上


当然,这不代表我们绝不会使用这些 API ,比如在表单初始化时需要回填信息的场景,我们就会用到 form.setValues 。我想说明的是不能滥用!!!


静态化的 schema


我们认为 schema 和普通的 JSX 相差不大,只不过前者是通过 JSON 标准语言来表述而已,举个例子:


// chema 形式
const formSchema = {
name: {
type: 'string',
'x-decorate': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: '请输入名称'
}
}
}

// jsx 形式
const Form = () => {
return (
<Form>
<FormItem name"name">
<Input placeholder="请输入名称" />
</FormItem>
</Form>

)
}

schema 最终也会被 formily/react 解析成 jsx 格式。那为什么我们推荐使用 schema 呢?



  • 原因一:schema 可以做到足够的静态化,避免我们做一些灵活的动态操作 (在 jsx 里我们几乎能做通过 form 实例动态的做任何事情)

  • 原因二: schema 更容易被解析和生成,为之后的智能化生成做铺垫(不一定是低代码)


表单模型的挑战


在真实业务开发过程中,我们对表单模型的使用会出现一些问题,以两个常见的问题为例:



  • 问题 1:我们是通过表单的 UI 结构来设计 schema 还是通过表单数据结构来设计?

  • 问题 2:有时候为了简单,我们会设计出一个巨大的 Component,这个 Componet 对应的 Field 嵌套了很多层字段


下面这个案例就可能触发上述的两个问题:


demo2.gif


其中,每个分类都对应着一组商品,所以最终表单的数据格式应该是这样的:


{
categoryList: [
{
categoryName: '分类一'
productList: [{ productName: '商品一', others: 'xxx' }],
},
{
categoryName: '分类二'
productList: [{ productName: '商品二', others: 'xxx' }],
}
],
}

我们提供两种思路来设计这个表单


方案一


我们发现简单的通过 ArrayTable 是实现不出这种交互的,所以我们直接设计出一个大而全的 Component,那么我们的实现方式应该是这样的:


// 设计一个大而全组件,过滤组件内部实现
const BigComponent = (props) => {
return (
<Row>
<CategoryArrayTable />
<ProductArrayTable />
</Row>

)
};

// schema 设计
const formSchema = {
categoryList: {
type: 'array',
'x-component': BigComponent,
}
}

在这种方案里,BigComponent 组件需要 onChange 整个表单的值(多层嵌套的对象数组),这会出现一个问题:formSchema 里看不到表单的所有字段配置,如果字段间需要有联动,那么只能在 BigComponent 组件内部去实现(违反了规范2)。


方案二


我们认为 schema 是面对表单数据结构设计的,Component 是面对 UI 设计的,两者的设计思路是分开的(但是在大多数场景下两者的设计结果是一致的)
那么我们的实现方式应该是这样的:


// 基于 formily/antd/ArrayTable + formily/react RecursionField 来实现
const CategoryArrayTable = (props) => {
return (
<Row>
<ArrayTableWithoutProductList />
<ArrayTableWithProductList />
</Row>

)
};

// schema 设计
const formSchema = {
categoryList: {
type: 'array',
'x-component': CategoryArrayTable,
items: {
categoryName: {
type: 'string',
'x-component': 'Select',
},
productList: {
type: 'array,
'
x-component': 'ArrayTable',
items: {
productName: {
type: '
string',
'
x-component': 'Select',
},
others: {},
}
}
},
}
}

在这种方案的 schema 里能够直接反映出表单的所有字段配置,一目了然,而且真实的代码实现会比方案一简洁很多


但是呢,这个方案有个难点,需要开发者对 formily 的渲染机制,主要是 RecursionFieldArrayTable 的源码有一定程度的了解。


当然,还有很多其他的方案可以实现这个需求,这边只是拿出两个方案来对比下设计思路上的差异,虽然最终的方案取舍是根据团队内部协商 + 规范而定的,但是在我自己的团队里,我们一直保持着一种设计准则:



schema 是面对表单结构的,Component 是面对 UI 的



后续


在实践过程中,我们发现了一些待优化点:


1、我们发现对于复杂的表单页面,schema 的配置会非常冗长,如果 schema 足够静态化的话,我们是否可以简化对 schema 的编写,同时能提高 schema 的可读性呢?低代码平台是个方案,但是太重,是否可以考虑弄个 vsocde 插件类接管 schema ?


2、如果表单配置、表单子组件、业务逻辑都由 schemaComponentLogic Fucntion 来负责了,我们是否可以取消表单页面的入口组件 index.tsx 呢?


当然随着对表单的不断深入研究,还有很多其他问题可以优化和解决

作者:木与子
来源:juejin.cn/post/7261262567304921146
,这边就不一一列举了

收起阅读 »

三言两语说透柯里化和反柯里化

web
JavaScript中的柯里化(Currying)和反柯里化(Uncurrying)是两种很有用的技术,可以帮助我们写出更加优雅、泛用的函数。本文将首先介绍柯里化的概念、实现原理和应用场景,然后介绍反柯里化的概 念、实现原理和应用场景,通过大量的代码示例帮助读...
继续阅读 »

JavaScript中的柯里化(Currying)和反柯里化(Uncurrying)是两种很有用的技术,可以帮助我们写出更加优雅、泛用的函数。本文将首先介绍柯里化的概念、实现原理和应用场景,然后介绍反柯里化的概 念、实现原理和应用场景,通过大量的代码示例帮助读者深入理解这两种技术的用途。


JavaScript中的柯里化


概念


柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由数学家Haskell Curry命名。


简单来说,柯里化可以将使用多个参数的函数转换成一系列使用一个参数的函数。例如:


function add(a, b) {
  return a + b; 
}

// 柯里化后
function curriedAdd(a) {
  return function(b) {
    return a + b;
  }
}

实现原理


实现柯里化的关键是通过闭包保存函数参数。以下是柯里化函数的一般模式:


function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function(...args2) {
        return curried.apply(this, args.concat(args2));
      }
    }
  }
}

curry函数接受一个fn函数为参数,返回一个curried函数。curried函数检查接收的参数个数args.length是否满足fn函数需要的参数个数fn.length。如果满足,则直接调用fn函数;如果不满足,则继续返回curried函数等待接收剩余参数。


这样通过闭包保存每次收到的参数,直到参数的总数达到fn需要的参数个数,然后将保存的参数全部 apply fn执行。


利用这个模式可以轻松将普通函数柯里化:


// 普通函数
function add(a, b) {
  return a + b;


// 柯里化后
let curriedAdd = curry(add); 
curriedAdd(1)(2); // 3

应用场景



  1. 参数复用


柯里化可以让我们轻松复用参数。例如:


function discounts(price, discount) {
  return price * discount;
}

// 柯里化后
const tenPercentDiscount = discounts(0.1); 
tenPercentDiscount(500); // 50
tenPercentDiscount(200); // 20


  1. 提前返回函数副本


有时我们需要提前返回函数的副本给其他模块使用,这时可以用柯里化。


// 模块A
function ajax(type, url, data) {
  // 发送ajax请求
}

// 柯里化后
export const getJSON = curry(ajax)('GET');

// 模块B
import { getJSON } from './moduleA'

getJSON('/users', {name'John'});


  1. 延迟执行


柯里化函数在调用时并不会立即执行,而是返回一个函数等待完整的参数后再执行。这让我们可以更加灵活地控制函数的执行时机。


let log = curry(console.log);

log('Hello'); // 不会立即执行

setTimeout(() => {
  log('Hello'); // 2秒后执行
}, 2000);

JavaScript中的反柯里化


概念


反柯里化(Uncurrying)与柯里化相反,它将一个接受单一参数的函数转换成接受多个参数的函数。


// 柯里化函数  
function curriedAdd(a) {
  return function(b) {
    return a + b;
  }
}

// 反柯里化后
function uncurriedAdd(a, b) {
  return a + b; 
}

实现原理


反柯里化的关键是通过递归不停调用函数并传入参数,Until参数的数量达到函数需要的参数个数。


function uncurry(fn) {
  return function(...args) {
    let context = this;
    return args.reduce((acc, cur) => {
      return acc.call(context, cur); 
    }, fn);
  }
}

uncurry 接收一个函数 fn,返回一个函数。这个函数利用reduce不停调用 fn 并传入参数,Untilargs所有参数都传给 fn


利用这个模式可以轻松实现反柯里化:


const curriedAdd = a => b => a + b;

const uncurriedAdd = uncurry(curriedAdd);
uncurriedAdd(1, 2); // 3

应用场景



  1. 统一接口规范


有时我们会从其他模块接收到一个柯里化的函数,但我们的接口需要一个普通的多参数函数。这时可以通过反柯里化来实现统一。


// 模块A导出
export const curriedGetUser = id => callback => {
  // 调用callback(user)
};

// 模块B中
import { curriedGetUser } from './moduleA';

// 反柯里化以符合接口
const getUser = uncurry(curriedGetUser); 

getUser(123user => {
  // use user
});


  1. 提高参数灵活性


反柯里化可以让我们以任意顺序 passes 入参数,增加了函数的灵活性。


const uncurriedLog = uncurry(console.log);

uncurriedLog('a''b'); 
uncurriedLog('b''a'); // 参数顺序灵活


  1. 支持默认参数


柯里化函数不容易实现默认参数,而反柯里化后可以方便地设置默认参数。


function uncurriedRequest(url, method='GET', payload) {
  // 请求逻辑
}

大厂面试题解析


实现add(1)(2)(3)输出6的函数


这是一道典型的柯里化面试题。解析:


function curry(fn) {
  return function curried(a) {
    return function(b) {
      return fn(a, b);
    }
  }
}

function add(a, b) {
  return a + b;
}

const curriedAdd = curry(add);

curriedAdd(1)(2)(3); // 6

利用柯里化技术,我们可以将普通的 add 函数转化为 curriedAdd,它每次只接收一个参数,并返回函数等待下一个参数,从而实现了 add(1)(2)(3) 的效果。


实现单参数compose函数


compose函数可以将多个函数合并成一个函数,这也是一道常见的柯里化面试题。解析:


function compose(fn1) {
  return function(fn2) { 
    return function(x) {
      return fn1(fn2(x));
    };
  };
}

function double(x) {
  return x * 2;
}

function square(x) {
  return x * x;
}

const func = compose(double)(square);

func(5); // 50

利用柯里化,我们创建了一个单参数的 compose 函数,它每次返回一个函数等待下一个函数参数。这样最终实现了 compose(double)(square) 的效果。


反柯里化Function.bind


Function.bind 函数实现了部分参数绑定,这本质上是一个反柯里化的过程。解析:


Function.prototype.uncurriedBind = function(context) {
  const fn = this;
  return function(...args) {
    return fn.call(context, ...args);
  } 
}

function greet(greeting, name) {
  console.log(greeting, name);
}

const greetHello = greet.uncurriedBind('Hello');
greetHello('John'); // Hello John

uncurriedBind 通过递归调用并传参实现了反柯里化,使 bind 参数从两步变成一步传入,这也是 Function.bind 的工作原理。


总结


柯里化和反柯里化都是非常有用的编程技巧,让我们可以写出更加灵活通用的函数。理解这两种技术的实现原理可以帮助我们更好地运用它们。在编码中,我们可以根据需要决定是将普通函数柯里化,还是将柯里化函数反柯里化。合理运用这两种技术可以大大

作者:一码平川哟
来源:juejin.cn/post/7262349502920605753
提高我们的编程效率。

收起阅读 »

如何为你的 js 项目添加 ts 支持?

web
前一段时间为公司内的一个 JS 公共库,增加了一些 TypeScript 类型支持。在这里简答记录一下。 安装 TypeScript 依赖 首先安装 TypeScript 依赖,我们要通过 tsc 指令创建声明文件: pnpm ins...
继续阅读 »

前一段时间为公司内的一个 JS 公共库,增加了一些 TypeScript 类型支持。在这里简答记录一下。





安装 TypeScript 依赖


首先安装 TypeScript 依赖,我们要通过 tsc 指令创建声明文件:


pnpm install -D typescript

创建配置文件


接下来创建 TypeScript 配置文件:


npx tsc --init

这一步会在项目的根目录下创建一个 tsconfig.json 文件。我们在原来配置的基础上开放一些配置:


{
  "compilerOptions": {
     "target": "es2016",
     "module": "commonjs",
     "esModuleInterop": true,
     "forceConsistentCasingInFileNames": true,
     "strict": true,
     "noImplicitAny": false,
     "skipLibCheck": true,
+    "allowJs": true,
+    "checkJs": true,
+    "declaration": true,
+    "emitDeclarationOnly": true,
+    "rootDir": "./",
+    "outDir": "./types",
   }
+  "include": [
+    "security/**/*"
+  ]
}

字段说明


对上述字段,我们挑几个重要的说明一下。





  • allowJscheckJs 增加 JS 文件支持



  • declarationemitDeclarationOnly 我们只需要 tsc 帮我们生成类型声明文件即可



  • rootDiroutDir 指定了类型声明文件生成到 types/ 目录



  • include 我们只为 security/ 目录下的代码生成类型声明文件


想详细了解每个配置字段的含义,可以参考 TypeScript 官方说明:https://aka.ms/tsconfig


生成类型文件


项目根目录下创建 index.d.ts 文件


export let security: typeof import("./types/security");

接下里修改 package.json, 增加当前 npm 包的类型声明支持和构建脚本 typecheck


{
    "scripts": {
        // ...
        "typecheck""tsc",
    },
    types: "index.d.ts"   
}

接下来执行脚本:


npm run typecheck

最后就能看到在 types/ 目录下为 security/ 生成的类型声明文件了。


作者:zhangbao
来源:mdnice.com/writing/55c153377fd3436581576b7017c25f3a
收起阅读 »

如何在页面关闭时发送 API 请求

web
前言 在一些需求背景下,我们需要在页面销毁(关闭/刷新)时将数据同步给后台,比如 记录视频播放进度、页面浏览时长埋点等。 在 window 全局对象上,提供了 beforeunload 事件,会在浏览器窗口关闭或刷新时触发。 要实现这个需求,普遍的做法是在 w...
继续阅读 »

前言


在一些需求背景下,我们需要在页面销毁(关闭/刷新)时将数据同步给后台,比如 记录视频播放进度、页面浏览时长埋点等


window 全局对象上,提供了 beforeunload 事件,会在浏览器窗口关闭或刷新时触发。


要实现这个需求,普遍的做法是在 window.onbeforeunload 监听事件回调中发起 api 请求。


const onBeforeunload = async () => {
// 发起请求
}
window.addEventListener('beforeunload', onBeforeunload);


注意:在移动设备下,一些浏览器并不支持 beforeunload 事件,最可靠的方式是在 visibilitychange 事件中处理。



document.addEventListener('visibilitychange', function logData() {
if (document.visibilityState === 'hidden') {
...
}
});

发起请求的方式有如下几种:



  1. ajax(XMLHttpRequest)

  2. sendBeacon(Navigator.sendBeacon)

  3. fetch(Fetch keepalive)


下面,我们分析对比以上几种方式的优劣及适用性。


一、ajax


早期前后端进行数据交互多数都采用 XMLHttpRequest 方式创建一个 HTTP 请求,默认采用 异步 方式发起请求:


const ajax = (config) => {
const options = Object.assign({
url: '',
method: 'GET',
headers: {},
success: function () { },
error: function () { },
data: null,
timeout: 0,
async: true, // 是否异步发送请求,默认 true 是异步,同步需设置 false。
}, config);
const method = options.method.toUpperCase();

// 1、创建 xhr 对象
const xhr = new XMLHttpRequest();
xhr.timeout = options.timeout; // 设置请求超时时间

// 2、建立连接
xhr.open(method, options.url, options.async); // 第三参数决定是以 异步/同步 方式发起 HTTP 请求

// 设置请求头
Object.keys(options.headers).forEach(key => {
xhr.setRequestHeader(key, options.headers[key]);
});

// 3. 发送数据
xhr.send(['POST', 'PUT'].indexOf(method) > -1 ? JSON.stringify(options.data) : null);

// 4. 接收数据
xhr.onreadystatechange = function () { // 处理响应
if (xhr.readyState === 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
options.success(xhr.responseText);
} else {
options.error(xhr.status, xhr.statusText);
}
}
};

// 超时处理
xhr.ontimeout = function () { options.error(0, 'timeout') };
// 错误处理
xhr.onerror = function () { options.error(0, 'error') };
// xhr.abort(); // 取消请求
}

对于 ajax 发起异步请求,若在发送过程中 刷新或关闭 浏览器,请求会被自动终止,如下图:


image.png



如果想在控制台查看刷新前页面接口调用情况,可勾选 Preserve log 选项,Network 会保留上个页面的请求记录。



可见,异步方式的 ajax 请求被浏览器自动 cancel 取消,无法将数据正常推送到后台。


一种处理方式是改为 同步 ajax 请求方式,在调用 open 建立连接时,第三参数 async 传递 false 表示以 同步方式 发送请求:


xhr.open(method, options.url, false);

但目前,谷歌浏览器已经不允许在页面关闭期间发起 同步 XHR 请求,建议使用 sendBeacon 或者 fetch keep-alive。我们接着往下看。


二、sendBeacon


navigator.sendBeacon()  方法可用于通过 HTTP POST 将少量数据 异步 传输到 Web 服务器。



官方链接:developer.mozilla.org/zh-CN/docs/…



它的语法如下:


navigator.sendBeacon(url);
navigator.sendBeacon(url, data);


  • url: 指定将要被发送到的网络地址;

  • data: 可选,是将要发送的 ArrayBufferArrayBufferViewBlobDOMStringFormData 或 URLSearchParams 类型的数据。

  • return: 返回值。当用户代理成功把数据加入传输队列时,sendBeacon()  方法将会返回 true,否则返回 false


navigator.sendBeacon 使用示例如下:


// 通过 Blob 方式传递 JSON 数据
const blob = new Blob(
[JSON.stringify({ ... })],
{ type: 'application/json; charset=UTF-8' }
);
// 发送请求
navigator.sendBeacon(url, blob);

sendBeacon 发送请求有以下几个特点:



  1. 通过 HTTP POST 请求方式 异步 发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能;

  2. 支持跨域,但不支持自定义 headers 请求头,这意味着:如果用户信息 Access-Token 是作为请求头信息传递,需要后台接口支持 url querystring 参数传递解析。

  3. 考虑其兼容性。


三、fetch keep-alive


当使用 fetch() 请求时,如果把 RequestInit.keeplive 设置为 true,即便页面被终止请求也会保持连接。


fetch(url, {
method: 'POST',
body: JSON.stringify({ ... }),
headers: {
'Content-Type': 'application/json', // 指定 type
},
keepalive: true,
});

推荐使用 Fetch API 实现「离开网页时,将数据保存到我们的服务器上」。


但它也有一些限制需要注意:



  1. 传输数据大小限制:无法发送兆字节的数数据,我们可以并行执行多个 keepalive 请求,但它们的 body 长度之和不得超过 64KB

  2. 无法处理服务器响应:在网页文档卸载后,尽管设置 keepalive 的 fetch 请求可以成功,但后续的响应处理无法工作。所以在大多数情况下,例如发送统计信息,这不是问题,因为服务器只接收数据,并通常向此类请求发送空的响应。


思考


在框架的生命周期,如 React useEffect 可以实现页面关闭时发送 HTTP 请求记录数据吗?


答案是:不可以


尽管,我们所理解的 useEffect 中的销毁函数会在页面销毁时触发,但有一个前提条件是:程序保活正常运行,即 ReactDOM.render 创建的 FiberRoot 正常运转


试想,浏览器页面进行刷新或关闭,React 所启动的应用会直接中断停止,程序中页面定义的 useEffect 将不会被执行。


参考


Navigator sendBeacon页面关闭也能发送请求方法.

fetch keep

laive.

收起阅读 »

微前端是怎样炼成的,从思想到实现

web
1 道 “微前端”的概念最早由 Thoughtworks 在2016年提出。 微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。 ——...
继续阅读 »

1 道


“微前端”的概念最早由 Thoughtworks 在2016年提出。



微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。
—— 黄峰达《前端架构——从入门到微前端》



1.1 独立


独立开发、独立部署、独立运行,是微前端应用组织的关键词。独立带来了很多有价值的特性:



  • 不同微应用可以使用不同的技术栈,从而兼容老应用,微应用也可以独立选型、渐进升级;

  • 微应用有单独的 git 仓库,方便管理;

  • 微应用隔离,单独上线,回归测试无需测试整个系统;

  • 拆分应用,加速加载;


为了实现可靠且灵活的独立,微前端必须面对几个核心问题:



  • 微应用间如何调度、解析、加载?

  • 如何避免运行时互相污染?

  • 微应用间如何进行通信?



1.2 大道至简——微前端的理论基础


微前端能成的理论基础是,底层API的唯一性。


首先无论各家前端框架多么天花乱坠,最后都离不开一个操作 ——「通过 js 在一个DOM容器中插入或更新节点树」。所以你在各家的demo也都看得到这样的 api 描述:


ReactDOM.render(<App />, document.getElementById('root'));	// react
createApp(...).mount('#app'); // vue

所以只要提供容器,就能让任何前端框架正常渲染。再上一层,任何 JS API,都离不开在全局对象 window 上的调用,包括 DOM 操作、事件绑定、页面路由、前端存储等等。所以只要封住 window,就可以隔离微应用的运行时。


1.3 主流微前端方案套娃


微前端是一个概念,历史上各种实现方案层出不穷,到今天阿里 qiankun 的方案成为国内主流。


qiankun 底层基于 single-spa,而业务系统也倾向于再在外面包一层,三层方案各自专注解决不同的问题:




  • single-spa 的官方定位是「一个顶层路由,当路由处于活动状态时,它将下载并执行该路由的相关代码」。放到微前端概念中,它专注解决微应用基于路由的调度。

  • qiankun 是一个「微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统」。在 single-spa 的基础上:所谓「简单」,是降低了接入门槛,增强了资源接入方式,支持 HTML Entry;所谓「无痛」,是尽量降低了微前端带来的副作用,即提供了样式和JS的隔离,并通过资源缓存加速微应用切换性能。

  • 到业务系统这一层,着重解决业务生产环境中的问题。最常见的像提供一个MIS管理后台,灵活配置,动态下发微应用信息,实现动态应用插拔。


2 single-spa


single-spa 做的事很聚焦,核心流程是:1、注册路由对应资源 —> 2、监听路由 —> 3、加载对应资源 —> 4、执行资源提供的状态回调。


2.1 api 概览


为了实现这套流程,single-spa 首先提供了「1、注册路由对应资源」的接口:


singleSpa.registerApplication({ name, appLoader, activeWhen });

然后启动「2、监听路由 —> 3、加载对应资源」机制:


singleSpa.start();

对资源则有「提供状态回调」的改造要求:


// 资源代码
export function bootstrap(props) {}
export function mount(props) {}
export function unmount(props) {}
export function unload(props) {} // 可选

2.2 整体实现原理


很显然,这里面有一套应用的状态机制,以及对应的状态流转流程,在 single-spa 内部是这样的:




  • app 池收集注册进来的微应用信息,包括应用资源、对应路由。app 池中的所有微应用,都会维护一个自身的状态机。

  • 刷新器是整个 single-spa 的发动机,负责流转整个状态流程。一旦刷新器被触发(首次启动或路由更新),就开始调度:

    • 拿着最新路由去池子里分拣 app

    • 根据分拣结果,执行 app 资源暴露的生命周期方法




2.3 app 池的实现


app 池的实现都在 src/applications/apps.js 模块中,首先是一个全局池:


const apps = [];

然后直接实现并导出 registerApplication 方法作为向 app 池添加成员的入口:


export function registerApplication( appNameOrConfig, appOrLoadApp, activeWhen, customProps) {
const registration = sanitizeArguments( appNameOrConfig, appOrLoadApp, activeWhen, customProps);
apps.push(
assign(
{ status: NOT_LOADED },
registration
)
);
if (isInBrowser) {
reroute();
}
}

registerApplication 做了几件事:



  1. 构造 app 对象,整理入参,这个和 single-spa 入参兼容有关系。最终 app 对象将包含app 信息、状态、资源、激活条件等信息。

  2. 加入 app 池。这里可以看到初始状态是 NOT_LOADED

  3. 触发了一次 reroute。


2.4 reroute 触发


前面 registerApplication 调了一次 reroute 方法,这就是执行一次刷新。reroute 会在下列场景执行:



  • registerApplication:微应用注册

  • start:框架启动

  • 路由事件(popstate、hashchange)触发


window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
function urlReroute() {
reroute([], arguments);
}

2.5 reroute 分拣执行


reroute 先判断 app 是否应该激活,逻辑很简单,就是把当前路由带到 app.activeWhen 里计算返回(app.activeWhen(window.location)),这里我们只看当前应该处于什么状态。而且按我们通常用法,只有少数 app 会激活。



接下来看微前端应用的激活过程,是先 load 下载应用资源,再 mount 挂载启动应用。



这样结合「app 是否应该激活」X「app 当前状态」,可以得到「应该对 app 做什么操作」。



  1. 「激活」X「not loaded」:应该去加载微应用资源

  2. 「激活」X「not mounted」:应该去挂载启动微应用

  3. 「激活」X「mounted」:什么都不用动

  4. 「不激活」X「not loaded」:什么都不用动

  5. 「不激活」X「not mounted」:应该去卸掉微应用资源

  6. 「不激活」X「mounted」:应该卸载微应用


这里只有1、2、5、6需要操作,也对应了上图中的四个箭头。于是 app 被进一步分拣为四个组:



代码如下:


switch (app.status) {
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}

拿到四个组后,需要转为具体操作,于是 reroute 中有这种 map:const unloadPromises = appsToUnload.map(toUnloadPromise);,把 app 转换为操作的 Promise。


需要注意的是,load 后app处于中间状态,并未完成激活,还差一步,反之亦然。所以只到中间态的两个组 appsToUnmount、appsToLoad,还需要继续往前走一步。


const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));

至于这些Promise是干嘛的也很容易猜到,无非是执行资源暴露的生命周期回调 + 修改应用状态。toXXXPromise 方法都定义在 src/lifecycles 下,可以找到对应生命周期。


至此 reroute 从分拣到执行生命周期的过程完成,完整图如下:



2.6 小结



  • single-spa 主要实现了微前端微应用调度部分,包含一个 app 池及路由变化时刷新回调 app 生命周期函数的机制

  • app 池维护了 app 的信息、资源和状态,暴露添加方法给 registerApplication api

  • 刷新的过程:确定app是否active —> 结合状态判断要做的操作 —> 调用生命周期回调,改状态


3 qiankun


在 single-app 微应用调度的基础上,qiankun 要带来的是「更简单、无痛的」生产应用。这些特性包括:



  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。

  • 🛡 样式隔离,确保微应用之间样式互相不干扰。

  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。

  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。


我们看它的实现思路。


3.1 qiankun 加载应用的过程


qiankun 的特性和它的应用加载方式密不可分,我们先从一个叫 loadApp 的方法入手,看看子应用加载的全过程。


1、入口解析


qiankun 从入参中拿到子应用的 name 和 entry,过一个import-html-entry库,这个库也是 qiankun 自己的,有俩主要用途:从 html 解析静态资源(HTML Entry 的基础),并使其在特定上下文下运行(JS 隔离的基础)。


解析后可以得到子应用对应的可执行 JS(execScripts 方法)、静态资源(assetPublicPath)、html 模版(template)。


2、创建应用容器


随后 qiankun 需要构造一个给子应用的容器(createElement),这个容器是一个子应用独有的 div,标记了从子应用信息挖出来的 id、name、version、config 等信息。容器的形态取决于几个因素:



  • 子应用是否有 html 模版,有的话需要装进去才能让子应用找到渲染 DOM

  • 子应用是否需要样式隔离,有的话可能要加一层 shadow DOM


然后要确保在子应用挂载前,这个容器被渲染并挂到页面上。


3、沙箱构造


接着 qiankun 会构造一个沙箱(createSandboxContainer),然后依赖 execScripts 方法把 window 代理到沙箱上,并在恰当的时候开关拦截。


4、构造传给下游 single-app 的生命周期


这里先从子应用脚本中解析出生命周期(bootstrap, mount, unmount, update),然后补充一些逻辑:



  • mount 时,补充容器获取和绑定、容器挂载、沙箱开启

  • unmount 时,补充沙箱关闭、容器卸载


3.2 HTML Entry 接入


html 解析能力来自import-html-entry库。


它加载完 html 资源,就按 string 继续解析(processTpl),主要方法是通过正则匹配出里面的字符串,比如异步 script:


// 异步 script
if (matchedScriptSrc) {
var asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
scripts.push(asyncScript ? {
async: true,
src: matchedScriptSrc
} : matchedScriptSrc);
return genScriptReplaceSymbol(matchedScriptSrc, asyncScript);
}

然后把这些资源打包返回。


3.3 样式隔离


样式隔离是避免子应用之间、子应用-父应用之间出现 class 名的相互污染。


处理样式隔离一般只有两个方法:一是为所有 class name 增加唯一的 scope 标记;二是利用 shadow dom 的天然隔离。


自己加 scope


参考 qiankun 文档:常见问题 - qiankun,可以通过干预编译、利用 antd 等框架的能力来做。


scope 的qiankun实现


如果懒得自己 scope,可以通过 qiankun 配置直接生成 scope:


sandbox: { experimentalStyleIsolation: true }

这个参数会给所有的 class name 外层增加一个子应用独有的标识:


div[data-qiankun-react16] .app-main {
font-size: 14px;
}

通过遍历所有 style 节点,增加前缀:


const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
// css.process
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
processor.process(stylesheetElement, prefix);

shadow dom 的乾坤实现


qiankun 通过配置也可以实现 shadow dom 隔离:


sandbox: { strictStyleIsolation: true }

其实是在容器和内容间增加了一层 shadow:


// createElement
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;

当然后面获取容器的时候也会兼容这点:


// getAppWrapperGetter
if (strictStyleIsolation && supportShadowDOM) {
return element!.shadowRoot!;
}
return element!;

3.4 JS 沙箱


子应用加载过程中,qiankun 构造 JS 沙箱:


// loadApp
if (sandbox) {
sandboxContainer = createSandboxContainer( appName, /* 其他参数 */ );
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}

沙箱创建后,会去包裹子应用脚本的执行上下文。沙箱实例被传到 import-html-entry包里,最终用在对 script 标签的包装执行上:


const code = `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
eval(code);

这样子应用的所有「模块和全局变量」声明,就都挂到了代理上。脚本导出的生命周期,也都执行在代理上。


JS 沙箱的目的是,任何一个微应用,在活跃期间能正常使用和修改 window,但卸载后能把「初始」window 还回去供其他微应用正常使用,且当微应用再次活跃时,能找回之前修改过的 window。这就必然需要一个和 app 一一对应的代理对象来管理和记录「app 对 window 的修改」,并提供重置和恢复 window 的能力。



qiankun 为我们准备了三套沙箱方案:



  • ProxySandbox:代理沙箱,在支持 Proxy 时使用

  • LegacySandbox:继承沙箱,在支持 Proxy 且用户 useLooseSandbox 时使用

  • SnapshotSandbox:快照沙箱,在不支持 Proxy 时使用


ProxySandbox


当我们有 Proxy 时,这件事很好办。我们可以让 window 处于「只读模式」,所有对 window 的修改,都将属性挂到代理对象上,使用时先找代理对象,再找真 window。



class ProxySandbox {
proxyWindow
isRunning = false
active() {
this.isRunning = true
}
inactive() {
this.isRunning = false
}
constructor() {
const fakeWindow = Object.create(null)
this.proxyWindow = new Proxy(fakeWindow, {
set: (target, prop, value, receiver) => {
if(this.isRunning) target[prop] = value
},
get: (target, prop, receiver) => {
return prop in target ?target[prop]:window[prop];
}
})
}
}

ProxySandbox 的好处是实现简单,在设计上非常严谨,完全不会影响原生 window,所以卸载时也不需要做任何处理。


LegacySandbox


Proxy 的另一种用法是,放 app 去修改 window,只做被修改属性的「键-初始值」、「键-修改值」记录,在卸载后把初始值挨个重置,在再次挂载后把修改值挨个恢复。



LegacySandbox 保证了真实 window 的属性和当前 app 用到的 window 属性完全一致。如果你需要全局监控当前应用的真实环境,这点就很重要。


SnapshotSandbox


如果环境不支持 Proxy,就没法在「活跃时」做监听,只能尝试在挂载卸载的时候想办法。



  • 对 window 来说,我们只要在挂载时备份一份「快照」存起来,卸载时再把快照覆盖回去。

  • 反过来对 app 环境来说,我们需要在卸载时 diff 出一份「被修改过」的快照,挂载时把快照覆盖回去。



拦截其他副作用


三种沙箱都实现了对 window 属性增删改查的拦截和记录,但子应用还可能对 window 做其他有副作用的操作,比如:定时器、事件监听、DOM节点API操作。


这就是沙箱实例暴露 mount、unmount 方法的原因。当子应用 mount 时,实现对其他副作用的拦截和记录,unmount 时再清除掉。这些副作用包括:


patchInterval									劫持定时器
patchWindowListener 劫持window事件监听
patchHistoryListener 劫持history事件监听(umi专用)
patchDocumentCreateElement 劫持DOM节点创建
patchHTMLDynamicAppendPrototypeFunctions 劫持DOM节点添加方法

副作用的拦截方法都采用同样的接口实现:


function patchXXX(global) {
// 给 mount 调用,在 global 上拦截方法
return function free() {
// 给 unmount 调用,清除副作用
}
}

实现思路都是维护一个「池」,把 mount 后注册的定时器、事件、DOM等记录下来,在 unmount 时清除。比如定时器:


function patchInterval(global) {
// 给 mount 调用,在 global 上拦截方法
let intervals: number[] = [];
global.clearInterval = (intervalId: number) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval.call(window, intervalId as any);
};
global.setInterval = (handler: CallableFunction, timeout?: number) => {
const intervalId = rawWindowInterval(handler, timeout);
intervals = [...intervals, intervalId];
return intervalId;
};
return function free() {
// 给 unmount 调用,清除副作用
intervals.forEach((id) => global.clearInterval(id));
}
}

3.4 预加载


qiankun 可以通过配置或手动调用发起 prefetch:


start({ prefetch: true });
// or
prefetchApps([...]);

发起预加载的时机无非两种:立即预加载(prefetchImmediately)、首个应用挂载后预加载其他应用(prefetchAfterFirstMounted)。但这只是时机差别,预加载的逻辑是一致的。


qiankun 说了,在浏览器空闲时预加载,那肯定要用 requestIdleCallback:


requestIdleCallback(async () => {
// 第一次空闲时解析入口资源
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
// 后面空闲时下载资源
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});

3.5 小结



  • 乾坤的特性离不开在子应用加载上下的功夫(loadApp),这个过程包含入口解析、容器创建、沙箱构造、生命周期补充,最后调用 single-app

  • HTML Entry 特性由 import-html-entry 库实现,通过对 html 字符串进行正则匹配,得到资源信息。

  • 样式隔离主要有 class name scope 和 shadow DOM 两种方式,qiankun 都做了支持。前者靠遍历 stylesheet 更改 class name,后者靠容器构建时增加 shadow DOM 层。

  • JS 沙箱是用一个代理对象拦截对 window 的操作。qiankun 提供了Snapshot、Proxy、Legacy三种沙箱,区别在于对属性增删改的拦截方式,效果是一样的。一些直接调用全局 api 的副作用(定时器、DOM操作、事件等)则需要额外拦截和恢复,通常靠维护一个「属于当前子应用的副作用池」。

  • 预加载用的是 requestIdleCallback。


4 业务系统


4.1 配置的数据模型


为了实现动态部署,业务平台要回答一个问题:每次启动时,这个微前端要注册哪些微应用?也就是「平台 - 系统 - 子应用 - 资源」之间关系的维护和下发。


好在这个关系并不复杂:



这是一套最简单的微前端管理模型,在此之上,可根据自己需求选择性加上用户、角色、权限、模版、菜单、导航等。


4.2 用户请求流程


当用户来访问业务平台上的系统时,基本会经过以下流程:




  1. 用户通过域名,经DNS解析,访问到平台的前端服务器。平台作为基建,会承载多个业务系统,每个业务系统又有各自的域名,这里会要求每个接入的业务域名都在DNS配置解析到平台统一的前端服务器IP。

  2. 匹配接入配置,锁定一个系统。一方面系统配置会作为“准入”的nginx配置挂在前端服务器上。另一方面,根据请求携带的域名等信息,可以匹配到具体请求来自哪个系统。

  3. 根据系统获取系统配置。这些配置包含整个系统ID关联的子应用、资源、权限、导航等等配置,通过接口可以一次性返回给客户端,也可以先返回系统ID,客户端再按需请求。客户端的微前端框架现在知道要注册哪些应用了。

  4. 客户端加载静态资源。在配置中会关联应用框架和子应用用到的所有静态资源的CDN地址,按微前端的逻辑,这些资源会在微应用 load 的时候异步加载。


Z 总结



  • 微前端将单体应用拆分为若干个微应用,它们独立开发、独立部署、独立运行。其理论基础是不同框架下相同的底层API。目前主流是在 single-spa、qiankun 的技术方案基础上,做业务系统的封装。

  • single-spa 根据路由变化调度微应用的资源加载和运行,并定义了一套微应用生命周期。实现上依赖内部的一套“应用池”+“刷新器”+路由监听。

  • qiankun 在 single-spa 基础上增加了 js 和 css 隔离、html entry、预加载等开箱即用的工程友好特性。其基础是自己实现了import-html-entry库来控制资源加载和运行时,实现一层容器以隔离样式,并借助 Proxy 等api 实现沙箱来劫持 window 操作。

  • 在业务系统实际应用中,还要在 qiankun 基础上构建数据模型和服务,实现「平台-系统-微应用-资源」各级配置的下发来启动和动态注册微
    作者:几木_Henry
    来源:juejin.cn/post/7262158134322806844
    前端。

收起阅读 »

一文揭秘饿了么跨端技术的演进、实践与落地

web
导读:本文会先带领大家一起简单回顾下跨端技术背景与演进历程与在这一波儿接着一波儿的跨端浪潮中的饿了么跨端现状,以及在这个背景下,相较于业界基于 React/Vue 研发习惯出发的各种跨端方案,饿了么为什么会选择走另外一条路,这个过程中我们的一些思考、遇到及解决...
继续阅读 »

导读:本文会先带领大家一起简单回顾下跨端技术背景与演进历程与在这一波儿接着一波儿的跨端浪潮中的饿了么跨端现状,以及在这个背景下,相较于业界基于 React/Vue 研发习惯出发的各种跨端方案,饿了么为什么会选择走另外一条路,这个过程中我们的一些思考、遇到及解决的问题和取得的一些成果,希望能给大家带来一些跨端方面的新思路。



跨端技术背景与演进历程


跨端,究竟跨的是哪些端?


自 90 年的万维网出现,而后的三十多年,我们依次经历了 PC 时代、移动时代,以及现在的万物互联(的 IoT )时代,繁荣的背后,是越来越多的设备、越来越多的系统以及各种各样的解决方案。


总的来说,按照跨端的场景来划分,主要包含以下 4 类:




  • 跨设备平台,如 PC(电脑)/ Mobile(手机)/ OTT(机顶盒)/ IoT(物联网设备)。不同的设备平台往往意味着不同的硬件能力、传感器、屏幕尺寸与交互方式

  • 跨操作系统,如 Android/iOS/HarmonyOS。不同的操作系统为应用开发通常提供了不同的编程语言、应用框架和 API

  • 跨移动应用,如 微信/支付宝/手淘/抖音/快手等。由于移动平台 CS 架构 及 App 间天然的壁垒,不同 App 间相互隔离,并各自在其封闭体系内遵循一套自有标准进行各类资源的索引、定位及渲染。而同一业务投放至不同 App 端时,就需要分别适配这些不同的规则。

  • 跨渲染容器,如 Webview/React Native/Flutter。前面三类场景催生了针对不同设备平台、不同操作系统、不同 App 间解决方案,因而移动领域的各种 Native 化渲染、自绘渲染与魔改 Webview 的方案也由此被设计出来,在尝试解决跨端问题的同时,也一定程度上提高了跨端的迁移门槛和方案选择难度。


而在当下,移动领域依然是绝对的主角,我们来看一下移动端的跨端技术都经历了哪些阶段。


移动跨端技术演进


随着移动互联网的蓬勃发展,端形态变的多样,除了传统的 Native、H5 之外,以动态化与小程序为代表的新兴模式百花齐放大行其道,世面上的框架/容器/工具也层出不穷,整个业态朝着碎片化方向发展。


对开发者来说,碎片化的直接影响,是带来了包括但不限于,刚才提到的设备平台、操作系统、渲染容器、语法标准等方面的各种不确定性,增加了大量的学习、开发与维护成本。


于是,应运而生的各类跨端技术,核心在于从不确定性中找寻确定性,以保障研发体验与产物一致性为前提,为各端适配到最优解,用最少成本达到最好效果,真正做到 "一次编写,到处运行"。


移动跨端大致经历了如下几个阶段:





  • H5 Wap 阶段:Web 天然跨平台,响应式布局是当时的一个主要手段,但由于早期网络环境原因,页面加载速度无法满足业务预期,加之设备传感器标准缺失、内存占用大、GPU 利用率低等问题,在移动设备量爆发伊始,难堪大任的论调一下子被推上风口浪尖,并在 12 年达到顶峰。




  • Hybrid 阶段:典型代表是 Cordova/ionic。功能上看,Hybrid 解决了历史两大痛点:



    • 1)性能,依靠容器能力,各类离线化、预装包、Prefetch 方案大幅减少加载耗时,配合编码优化在 3/4G 时代使 H5 的体验上了一个台阶;

    • 2)功能,通过 JSBridge 方式规避了与 Native 原生割裂带来的底层能力缺失。




  • 框架+原生阶段:典型代表是 ReactNative/Weex。基于 JSC 或类似的引擎,在语法层与 React/Vue 结合,渲染层使用原生组件绘制,尝试在研发效率与性能体验间寻找更佳的平衡点,各类领域解决方案(受限 DSL + 魔改 web 标准 + Native 渲染能力)开始涌现,拉开了大前端融合渲染方案的序幕。




  • 自绘渲染阶段:典型代表是 Flutter/Qt。这里的 “自绘” 更强调不使用系统原生控件或 Webview 的渲染管线,而是依赖 Skia、Cairo 等跨平台图形库,自底向上自建渲染引擎、研发框架及基础配套的方式,其跨 Android/iOS 的特性迅速点燃了客户端研发领域。




  • 小程序阶段:典型代表是 微信/支付宝小程序。小程序是被创造出来的,其本质是各 APP 厂商出于商业考量构造了相对封闭的生态,在标准与能力上无论与 Web 还是厂商之间均存在差异,能力上是自定义 DSL & API + Hybrid + 同层渲染 + 商业管控的综合体。市面跨端方案策略均是锚定一种研发规约进行各形态编译时与运行时的差异抹平与适配。




回顾了以上跨端技术背景与演进历程,在这股浪潮里面,饿了么的跨端投放情况是什么样的?投了那些端?遇到了哪些问题?又是如何解决的?


饿了么跨端投放诉求、现状与策略



众所周知,饿了么是围绕 O2O 为用户提供线上到线下服务的公司,通过对时、空、人、货 的有机结合,来链接商家与消费者,相比于传统电商,时空人货本身具有区域属性,这意味着我们做的不是一个大卖场生意,更多的是需要围绕区域特性提供精细化的服务,这里面有一系列时空、体验、规模、成本的约束需要考虑与应对


而在这一系列约束背后,其实有一个各方共通的经营诉求:



  • 对于商家来说:为了有更好的经营需要有更多曝光,与客户有更多的触达,以便带来成交

  • 对于平台来说:为了能够让更多消费者享受我们的服务,除了深耕自己的超级APP(饿了么APP)外,还需要在人流量大的地方加大曝光、声量与服务能力来扩大我们的规模


这都导向一个目的:哪里流量多,我们就需要在哪里提供与消费者的连接能力


那么问题来了,流量在哪里?现在的互联网,更多都是在做用户的时间与精力生意,背后拆解下来,其实有几个关键因素可以衡量:用户密度、用户活跃度、市场占有率与用户时间分配,细化来看,其中任意几个条件满足,都可以作为我们流量阵地的候选集。


饿了么经过多年耕耘,对外部关键渠道做了大量布局,业务阵地众多,从效果上看,渠道业务无论是用户流量规模还是订单规模均对大盘贡献度较高,且随着业务的持续精进与外部合作环境的持续改善,增量渠道也在不断的涌现中。



在这么多的业务阵地中,投放在各个端的应用的形态基于:



  • 渠道的运行环境

  • 渠道的流量特性

  • 渠道的业务定位

  • 渠道的管控规则


等的差异和限制,目前形成了 以小程序为主,H5为辅 的承接方式,而这些差异带来了大量的不确定性,主要体现在:



  • 渠道环境的高度不确定性:对接了这么多渠道,每个端的运行环境存在巨大差异,拿小程序能力举例,即使是个别 APP 的小程序方案借鉴了微信的思路,由于其内部商业能力、产品设计思路、能力成熟度与完整度、研发配套(语法、框架、工具等)的不一致也会使研发体感有明显的不同,这对技术同学来说,带来的是渠道环境的高度不确定性;

  • 业务诉求的高度不确定性:同时,我们所投放的 APP 都可划分到某些细分领域,用户特性与用户在该平台上的诉求不一,渠道定位也不一致,随着每个业务域的功能演进越来越多,多个渠道功能是否对齐、要不要对齐、有没有对齐、什么时候对齐成了一个非常现实和麻烦的事情,同时业务域之间可能还存在功能上的关联,这进一步提高了其复杂度,在没有一个好的机制与能力保障下,业务、产品、研发对每个渠道的同步策略、能力范围的感知会有较大偏差,甚至于一个需求的迭代,每个端什么时候能同步都变成了一个无法预期的事情,这对于业、产、研来说,带来的是业务诉求上的高度不确定性。


而我们要做的,就是在这两种不确定性中,找到技术能带来的确定性的事情。如何系统性的解决这些问题,则成为我们在保障渠道业务灵活性的基础上持续提升研发效率和体验的关键。


在差异应对上,业务研发最理想的方式是对底层的变化与不一致无感,安心应对业务诉求,基于这个点出发,我们的主要策略是:围绕 “研发体验一致性提升与复杂应用协作机制改进”来保障业务高效迭代。这需要一套强有力的、贴合业务特性的基础设施来支撑。首先想到的便是如何通过“推动框架统一”和“实现一码多端”,来为业务研发降本增效,然而理想很丰满,现实很骨感:



框架的升级通常情况下,大概率会带来业务重构,综合评估之后,作为外部渠道流量大头的小程序业务,则成为了我们优先要保障的业务,也基于此,为了尽可能降低对业务的影响和接入成本,我们明确了以小程序为第一视角来实现多端。


基于小程序跨端的行业现状和思考


在明确了方向之后,那么问题来了:业界有没有适合我们的开源的框架或解决方案呢?


业界有哪些面向于小程序的研发框架?



市面上,从小程序视角出发,具备类似能力的优秀多端框架有很多,有代表性的如 Taro、uni-app、Rax 等,大多以 React 或者 Vue 作为 DSL


那么这些框架能否解决我们所面临的问题?答案是:并不能。


为什么饿了么选择以小程序 DSL 为基础实现跨端?



综合 饿了么 的渠道业务背景需要考虑以下几点:



  • 改造成本:以支付宝、微信、淘宝为代表的饿了么小程序运营多年,大部分存量业务是以支付宝或微信小程序 DSL 来编写,需关注已有业务逻辑(或组件库)的改造成本,而采纳业界框架基本上会直接引发业务的大量重构,这个改造成本是难以接受的。

  • 性能体验:渠道业务是饿了么非常重要的流量阵地,重视程度与APP无差,在体验和性能上有极致的要求,所以我们期望在推动跨端的同时,尽可能减少运行时引入带来的性能损耗。

  • 业务协同:由于每个渠道都基本相当于一个小型的饿了么APP,复杂度高,涉及到多业务域的协同,包括主线步调一致性考量、多业务线/应用类型集成、全链路功能无缝衔接等,在此之外还需给各业务线最大限度的自控与闭环能力,背后需要的是大型小程序业务的一体化研发支撑。


在做了较多的横向对比与权衡之后,上面的这些框架对于我们而言采纳成本过高,所以我们选择了另外一条相对艰辛但更为契合饿了么多端演进方向的路:以小程序原生 DSL 为基础建设跨端解决方案,最大限度保障各端产物代码贴合小程序原生语法,以此尽可能降低因同构带来的体验损耗和业务多端接入成本。


基于小程序 DSL 的跨端解决方案


确定以小程序 DSL 作为方向建设跨端解决方案之后,首先要解决的就是如果将已有的小程序快速适配到多端。这就需要对各个端的差异做细致的分析并给出解决方案。



如何解决小程序多端编译?


为了能够兼顾性能和研发体验,我们选择了 编译时(重)+运行时(轻) 的解决方案。


静态编译解决了那些问题?



静态编译转换主要用于处理 JSWXS/SJSWXML/AXMLWXSS/ACSSJSON 等源码中约束强且不能动态修改的部分,如:



  • 模块引用:JS/WXS/SJS/WXML/AXML/WXSS/ACSS/JSON 等源码中的模块引用替换和后缀名修改;

  • 模版属性映射或语法兼容: AXML/WXML 中如 a:if → wx:if、 onTap → bind:tap{{`${name}Props`}} →  {{name + 'Props'}} 等;

  • 配置映射:如页面 { "titleBarColor": "#000000" } → { "navigationBarBackgroundColor: "#000000", "navigationBarTextStyle": "white" }


等,原理是通过将源码文件转换为 AST(抽象语法树),并通过操作 AST 的方式来实现将源码转换为目标平台的代码。


但静态编译只能解决一部分的差异,还有一些差异需要通过运行时来抹平。


运行时补偿解决了那些问题?



运行时补偿主要用于处理静态编译无法处理或者处理成本较高的一些运行时动态内容,如:



  • JSAPI:实际业务使用上,不管是 JSAPI 的名字还是 JSAPI 的入参都会存在动态赋值的情况,导致了在 JSAPI 的真实调用上,很难通过 AST 去解析出实际传参;

  • 自定义组件 - Props 属性:比如,支付宝属性使用 props 声明,而微信属性使用 properties 声明,配置方式不同且使用时分别使用 this.props.x 及 this.properties.x 的方式获取,同时可能存在动态取值的情况;

  • 自定义组件 - 生命周期:支付宝小程序中的 didUpdate 生命周期,在触发了 propsdata 更新后都会进入 didUpdate 这个生命周期,且能够在 didUpdate 中访问到prevProps / prevData,而在微信小程序中静态转义出这个生命周期就意味着你需要去动态分析出didUpdate里面要用到的所有属性,然后去动态生成出这些属性的监听函数。这显然可靠程度是极其低的;


等等,类似的场景有很多,这里不再一一列举。


通过静态编译 + 运行时补偿的方式,我们便可以让现有的微信或支付宝小程序快速的迁移到其他小程序平台。


如何解决小程序转 Web?


伴随外卖小程序上线多年之后,各个大的渠道(支付宝、手淘、微信等)已切流为小程序承载,但是还有很多细分渠道或非小程序环境渠道,比如:各个银行金融渠道,客户端的极小包等,还需要依赖 H5 的形态快速投放,但当前饿了么的业务越来越复杂,相关渠道的投入资源有限,历史包袱重、迭代成本大等原因,产品功能和服务能力远远落后于小程序和饿了么App。而业务急需一个可以将小程序的功能快速复制到 h5 端的技术方案,以较低的研发和维护成本,满足业务多渠道能力对齐上线的诉求。


基于这个背景,我们自然而然的可以想到,即然小程序可以转其他小程序,那么是否也可以直接将小程序直接转换为 Web,从而最大程度上提升人效和功能对齐效率。


具体是怎么实现的?主要手段还是通过编译时 + 运行时的有机结合:


Web 转端编译原理



编译部分和小程序转小程序的主要区别和难点是:需要将 JSWXS/SJSWXML/AXML 等文件统一转换并合并为 JS 文件并将 WXML/AXML 文件转换为 JSX 语法,将样式文件统一转换为 CSS 文件,并将小程序的页面和组件都转换为 React 组件。


运行时原理



转 Web 的运行时相较于转换为其他小程序会重很多,为了兼顾性能和体验,运行时的核心在于提供与小程序对等的高效运行环境,这里面包含四个主要模块:



  • 框架:提供了小程序在 Web 中的基础运行时功能,比如:Page 、Component 、App 等全局函数,并且提供完整的生命周期实现,事件的注册、分发等

  • 组件:提供小程序公共组件的支持,比如 viewbuttonscroll-view 等小程序原生提供的组件

  • API:提供了类似小程序中 wx 或者 my 的 一系列 api 的实现

  • 路由:提供了页面路由支持和 getCurrentPages 等方法支持


基于这四个模块,配合编译时的自动注入和代码转换,以及路由映射等,我们就可以把一个小程序转换为一个 Web 的 SPA(单页) 或者 MPA(多页) 应用,也成功的解决了业务的研发效率问题,目前 饿了么的新 M 站就是基于这套方案在运行。


如何解决多端多形态问题?



解决了各端的编译转换问题,是不是就万事大吉,业务接下来只要按部就班的基于这套能力实现一码多端就可以了?


然而并不是,随着饿了么的业务场景和范围快速拓展,诞生了一些新的诉求,比如:



  • 支付宝的独立小程序作为分包接入微信小程序

  • 淘宝 / 支付宝的小程序插件作为分包接入某个现有的微信小程序

  • 支付宝的独立小程序作为插件接入淘宝小程序插件

  • 支付宝小程序插件作为分包接入微信或抖音小程序


等等,大家如果仔细观察这些诉求,就会发现一个共同的点:就是小程序的形态不一样。


虽然我们已经具备了多端的能力,但是形态的差异没有解决掉,而之前相关业务的做法是,尽可能将通用功能沉淀到组件库,并按照多端的方式分端输出产物,然而由于相同业务在不同小程序端形态差异性的问题,业务上难以自行规避,而重构的成本又比较高,所以有一部分业务选择了直接按照不同的端不同的形态(如微信、支付宝、淘宝、抖音)各自维护一套代码,但这样做不仅功能同步迭代周期被拉长,而且 BUG 较多,维护困难,研发过程也是异常痛苦。


小程序形态差异有哪些?


形态差异是指 小程序、小程序分包、小程序插件 三种不同形态的运行方式差异以及转换为其他形态之后产生的差异,具体如下:




  • getApp 差异



    • 小程序: 可通过 getApp() 来获取全局 App 实例及实例上挂载的属性或方法

    • 小程序插件: 无法调用 getApp()

    • 小程序分包: 可通过 getApp() 来获取全局 App 实例及实例上挂载的属性或方法;但当通过小程序转换为分包后,分包自身原本调用的 getApp 将失效,并被替换为宿主小程序的 getApp




  • App 应用生命周期 差异



    • 小程序: 应用会执行 onLaunch、onShow、onHide 等生命周期

    • 小程序插件: 无应用生命周期

    • 小程序分包: 无应用生命周期




  • 全局样式(如:app.wxss 或 app.acss)差异



    • 小程序: 可通过全局样式来声明全局样式

    • 小程序插件: 无全局样式

    • 小程序分包: 无全局样式




  • NPM 使用限制



    • 小程序: 各个小程序平台支持和限制情况不一

    • 小程序插件: 各个小程序平台支持和限制情况不一

    • 小程序分包: 各个小程序平台支持和限制情况不一




  • 接口调用限制





  • 路由差异



    • 小程序: 转换到其他形态后自身路由会发生变化

    • 小程序插件: 转换到其他形态后自身路由会发生变化,跳转插件页面需要包含 plugin:// 或 dynamic-plugin:// 等前缀,小程序或分包则不需要

    • 小程序分包: 转换到其他形态后自身路由会发生变化




  • getCurrentPages 差异



    • 小程序: 无限制

    • 小程序插件: 无法通过 getCurrentPages 获取到小程序的页面堆栈

    • 小程序分包: 无限制




  • 页面或组件样式差异



    • 小程序: 无限制

    • 小程序插件: 基本选择器只支持 ID 与 class 选择器,不支持标签、属性、通配符选择器

    • 小程序分包: 无限制




等等,相关形态差异可结合各个小程序平台查看,这里仅罗列常见的部分。


如何解决这些差异?


这里举几个例子:



通过在编译过程中,自动向产物中注入对 App 和 getApp 的运行时模拟实现,这样就可以解决分包和插件下方法缺失或者是冲突引起的报错问题。



方法也是类似,可以在编译的过程中检测全局样式是否存在,如果存在,则将对应的全局样式引用自动注入到每一个页面和组件中来解决全局样式失效的问题。



而针对各个小程序平台的 NPM 使用规则不同的问题,可以通过依赖解析、动态分组、组件提取打包、引用替换等方式,将 NPM 抽取到特定的地方,并将对应的组件和页面中的引用进行替换,来解决 NPM 的支持问题,这样业务就可以基本无脑使用各类 NPM 而不用关心平台差异。


以此类推,将业务难以自行适配的差异,逐一解决之后,剩余的一些功能差异,则由业务基于条件编译的方式来自行适配,这样便可以大大的降低业务形态转换成本,同时也形成了我们面向多端场景下的形态转换方案。


那么到这里,多端转换的问题才算是基本解决了。


如何治理 “复杂小程序”?


如果说上面讲的内容都是聚焦在如何通过编译的方式来解决多端同构以及形态问题的话,那么接下来要解决的就是针对“复杂小程序”的应用架构与研发协作的问题了。



首先介绍下我们所定义的 “复杂小程序”,即具备跨业务领域的、长周期的、多团队协同的、呈现主链路+多分支业务模式的应用,其之所以“复杂”,主要体现在应用形态多样、诉求多样、关联业务面广等特性上


对于饿了么来说,每个渠道阵地均相当于一个小型饿了么APP,除了在研发上提供便利外,还需一套可靠的应用架构来保证其有序演进。


同时,由于渠道之间定位不同,各域的业务、产品及研发对各渠道重视程度与投入比重均有差异,间接导致渠道间相同业务能力的参差不齐,且不同渠道功能缺失的情况持续出现。


我们以饿了么微信小程序为例:



面临的问题有哪些?



  • 工程复杂导致研发效率低:大量的团队在一个单体小程序应用上研发,带来的直接问题就是小程序巨大化带来的研发体验差和编译效率低,且业务相互依赖,单一模块构建失败会引发整个项目的失败,比如饿了么微信小程序单次编译的时间超过了半个小时,且体积逼近 20m 上限

  • 研发流程不规范导致稳定性差:同时由于不同的业务团队迭代周期不一致,而每次发版都需要所有业务的代码一起发,哪怕是某个业务分包或者插件没有更新,但是对应的底层依赖库发生了变更,也极有可能引入线上 BUG,导致测试回归的成本居高不下,发版质量难以保障


解决方案:线下线上结合的集成研发模式


针对上面两个“复杂小程序”所面临的核心问题,我们针对性的通过 「线下集成研发」和「线上研发协作」来解决。


线下集成研发


重点考虑的是提供什么样的集成研发能力,允许以业务单元维度将多个独立的构建(宿主、小程序、插件、分包等)组成一个可用的小程序,消除业务之间强依赖关系,从而达成业务可独立开发、调试和部署的目的,方面统一业务协作流程、降低多端同构成本,关键策略:



  • 提供统一的集成研发方式和流程

  • 提供标准、可复用的集成产物规范

  • 为复杂小程序提供解耦工具和集成方法

  • 标准化小程序宿主、小程序插件、小程序分包、小程序模块之间的通信及能力注入方式



将小程序宿主和各个业务模块(分包、小程序、插件)通过形态转换、拉包、编译、构建、合并等一系列处理后,合并为一个完整小程序,且根据不同的场景可以支持:



  • 主子分包研发模式:基于不同业务对小程序中的分包进行拆分,以达到各个业务相互解耦,独立迭代的目的;

  • SDK 研发模式:将通用的页面或组件封装置某个 NPM 包中作为提供特定功能的 SDK 交由业务使用;

  • 小程序插件研发模式:集成研发也可以用支持标准的小程序插件研发。


这样我们就可以解决线下研发的问题。


线上研发协作


前面介绍的“线下集成研发”为业务单元提供了无阻塞的开发与调试能力,但对于饿了么业务整体演进来说,重视的是每个版本功能的可用与可控,这里面除了将集成的范围扩展到所有业务域的之外,还需要标准化的流程约束:



具体方式上,在机制层面提供了业务类型定义的能力,开发者可将工程做对应标记(主包、分包、插件、独立小程序),在流程层面定义了开发、集成与发布三个阶段,这和 APP 的研发流程有些类似:



  • 开发:各业务应用自行研发并结合平台部署测试,开发测试通过,等待窗口期开启进入集成测试;

  • 集成:管理员设置集成窗口期,在窗口期,允许业务多次集成研发,确认最终要进集成的稳定版本,期间主包管理员可多次部署体验版用于集成测试。窗口期结束后,不允许随意变更;

  • 发布:集成测试通过,各业务进行代码 CR 并进入发布阶段,等候主包提审通过发布上线,最终由管理员完成本次迭代发布,发布完成后,符合标准的主分包产物会被保存下来,后续的迭代中,如果某个分包未发生变更,则会直接复用产物,极大的降低了业务的发布风险,并提升了整体的构建效率。


再进一步,多端业务的最佳实践


通过线下集成+线上协作的双重能力加持,结合已有的多端编译能力,在成功的支撑了饿了么多端渠道业务的稳定高效研发的同时,我们也在思考,面向于未来的多端研发模式应该是个什么样子?


下图是我们期望同时也是饿了么目前多端应用架构正在演进中的样子:



从图上可以看出,我们将应用架构划分为三层(从下往上看):




  • 基础服务与研发规范:最底部的是基础服务与研发规范,由 多端研发框架、多端研发平台和多端研发规范,来提供统一的研发支撑,保障业务研发的基础能力、体验和效率,并负责将相关的业务统一打包、封装、集成,并部署和投放到不同的渠道;




  • 宿主应用框架:第二层是宿主应用框架(Framework),也可以认为是多端统一解决方案,承接了面向于业务研发并适配了多端差异的基础 API(如 登录、定位、请求、路由、实验、风控、埋点、容器等)、基础组件和最佳实践,通过分渠道的配置化运行、标准化的接入手段和中心化的能力管理,来保障整体框架的轻量化、标准化与持续迭代和升级;




  • 渠道应用主体:最上层是各个业务的应用实体,有一个壳工程 + N个业务工程组成,壳工程承接各个渠道定制化的一些能力,而并将下层应用框架的能力暴露给上层的各个业务,各个业务只需要关心两件事即可:



    • 多端形态:以什么样的形态接入到对应的渠道(即壳工程中)?

    • 业务功能:不同的渠道需要展示那些功能?




基于这种分层协作模式,可以最大程度上消除业务对多端差异的感知,可以将重心放在如何更好的为用户提供服务上。


以上内容为饿了么基于小程序 DSL 的跨端实践和解决方案,下面我们来看一下具体取得的成果。


跨端成果


饿了么各渠道业务效果展示



业务一码多端研发提效数据



  • 研发提效:采用一码多端和集成研发模式的业务平均提效 70%,同构的端越多提效越多

  • 多端占比:饿了么内部 85%+ 的多端业务在基于这套方案实现多渠道业务研发和投放

  • 业务覆盖:涵盖了饿了么全域的各个业务板块


能力沉淀 — 饿了么自研 MorJS 多端研发框架


MorJS 开源



我们将饿了么在跨端多渠道上的多年沉淀和解决方案,融合为 MorJS 多端研发框架,并通过 Github 开源的方式向社区开放。


GitHub 仓库地址:github.com/eleme/morjs


下图为 MorJS 的完整架构图:



MorJS 框架目前支持 :



  • 2 种 DSL:微信小程序 DSL 或 支付宝小程序 DSL

  • 4 种编译形态:小程序、小程序插件、小程序分包、小程序多端组件

  • 9 个目标平台:微信、支付宝、百度、字节、快手、钉钉、手淘、QQ、Web


并支撑了饿了么 C 端大多数业务在各个渠道上的研发和投放。


MorJS 为饿了么解决了大量业务在多端研发上的差异问题,让小程序开发的重心回到产品业务本身,减少使用者对多端差异兼容的投入。通过 MorJS 的开源,我们期望能把其中的实现细节、架构设计和技术思考呈现给大家,为更多有类似多端同构需求的企业和开发者服务。同时,我们也希望能够借此吸引到更多志趣相投的小伙伴参与共建,一起加速小程序一码多端能力的发展。欢迎广大小程序开发者们与我们交流。


MorJS 特性介绍



为了能够帮助社区的用户可以快速上手,我们在易用性、标准化和灵活性方面做了大量的准备:



  • ⭐️ 易用性

    • 💎 DSL 支持:可使用微信小程序 DSL 或 支付宝小程序 DSL 编写小程序,无额外使用成本;

    • 🌴 多端支持:支持将一套小程序转换为各类小程序平台及 Web 应用, 节省双倍人力;

    • 🚀 快速接入:仅需引入两个包,增加一个配置文件,即可简单快速接入到现有小程序项目;



  • 🌟 标准化

    • 📦 开箱即用:内置了脚手架、构建、分析、多端编译等完整研发能力,仅需一个依赖即可上手开发;

    • 🌈 表现一致:通过编译时+运行时抹平多端差异性,让不同平台的小程序获得一致的用户体验;

    • 🖇 形态转换:支持同一个项目的不同的形态,允许小程序、分包、插件不同形态之间的相互转换;



  • ✨ 灵活性

    • 🎉 方便扩展:MorJS 将完备的生命周期和内部功能插件化,使用插件(集)以满足功能和垂直域的分层需求;

    • 📚 类型支持:除小程序标准文件类型外,还支持 ts、less/scss、jsonc/json5 等多种文件类型;

    • 🧰 按需适配:可根据需求选择性接入适配能力,小项目仅需编译功能,中等项目可结合编译和页面注入能力,大型项目推荐使用复杂小程序集成能力;




同时也提供了丰富的文档:mor.eleme.io/ 共大家查阅。


部分使用案例及社区服务


以下为部分基于 MorJS 的案例:



用户原声



MorJS 上线的这几个月里面,我们收到了一些社区用户的正向反馈,也收到了一些诉求和问题,其中用户最担心的问题是:MorJS 是不是 KPI 项目,是否会长期维护?


这里借用一下我在 Github 项目的讨论区(Discussions)的回复:



如果大家对 MorJS 感兴趣,期望有更多了解或者在使用 MorJS 中有遇到任何问题,欢迎加入 MorJS 社区服务钉钉群(群号:29445021084)反馈、交流和学习,也可以🔗 点击链接加入钉钉群


展望未来



未来,在现有的 MorJS 的能力基础上,我们会进一步完善已有的多端能力,提升多端转换可用度,完善对各类社区组件库的兼容,并持续扩展编译目标平台的支持(如 鸿蒙、快应用等),在持续为饿了么自身业务和社区用户提供高质量服务的同时,期望有朝一日 MorJS 可以成为业界小程序多端

作者:lyfeyaj
来源:juejin.cn/post/7262558218169319484
研发的基础设施之一。

收起阅读 »

三言两语说透koa的洋葱模型

web
Koa是一个非常轻量化的Node.js web应用框架,其洋葱圈模型是它独特的设计理念和核心实现机制之一。本文将详细介绍Koa的洋葱圈模型背后的设计思想,以及它是如何实现的。 洋葱圈模型设计思想 Koa的洋葱圈模型主要是受函数式编程中的compose思想启发而...
继续阅读 »

Koa是一个非常轻量化的Node.js web应用框架,其洋葱圈模型是它独特的设计理念和核心实现机制之一。本文将详细介绍Koa的洋葱圈模型背后的设计思想,以及它是如何实现的。


洋葱圈模型设计思想


Koa的洋葱圈模型主要是受函数式编程中的compose思想启发而来的。Compose函数可以将需要顺序执行的多个函数复合起来,后一个函数将前一个函数的执行结果作为参数。这种函数嵌套是一种函数式编程模式。


Koa借鉴了这个思想,其中的中间件(middleware)就相当于compose中的函数。请求到来时会经过一个中间件栈,每个中间件会顺序执行,并把执行结果传给下一个中间件。这就像洋葱一样,一层层剥开。


这样的洋葱圈模型设计有以下几点好处:



  • 更好地封装和复用代码逻辑,每个中间件只需要关注自己的功能;

  • 更清晰的程序逻辑,通过中间件的嵌套可以表明代码的执行顺序;

  • 更好的错误处理,每个中间件可以选择捕获错误或将错误传递给外层;

  • 更高的扩展性,可以很容易地在中间件栈中添加或删除中间件。


洋葱圈模型实现机制


Koa的洋葱圈模型主要是通过Generator函数和Koa Context对象来实现的。


Generator函数


Generator是ES6中新增的一种异步编程解决方案。简单来说,Generator函数可以像正常函数那样被调用,但其执行体可以暂停在某个位置,待到外部重新唤起它的时候再继续往后执行。这使其非常适合表示异步操作。


// koa中使用generator函数表示中间件执行链
function *logger(next){
  console.log('outer');
  yield next;
  console.log('inner');
}

function *main(){
  yield logger();
}

var gen = main();
gen.next(); // outer
gen.next(); // inner

Koa使用Generator函数来表示洋葱圈模型中的中间件执行链。外层不断调用next重新执行Generator函数体,Generator函数再按顺序yield内层中间件异步操作。这样就可以很优雅地表示中间件的异步串行执行过程。


Koa Context对象


Koa Context封装了请求上下文,作为所有中间件共享的对象,它保证了中间件之间可以通过Context对象传递信息。具体而言,Context对象在所有中间件间共享以下功能:



  • ctx.request:请求对象

  • ctx.response:响应对象

  • ctx.state:推荐的命名空间,用于中间件间共享数据

  • ctx.throw:手动触发错误

  • ctx.app:应用实例引用


// Context对象示例
ctx = {
  request: {...}, 
  response: {...},
  state: {},
  throwfunction(){...},
  app: {...}
}

// 中间件通过ctx对象传递信息
async function middleware1(ctx){
  ctx.response.body = 'hello';
}

async function middleware2(ctx){
  let body = ctx.response.body
  //...
}

每次请求上下文创建后,这个Context实例会在所有中间件间传递,中间件可以通过它写入响应,传递数据等。


中间件执行流程


当请求到达Koa应用时,会创建一个Context实例,然后按顺序执行中间件栈:



  1. 最内层中间件首先执行,可以操作Context进行一些初始化工作;

  2. 用yield将执行权转交给下一个中间件;

  3. 下一个中间件执行,并再次yield交还执行权;

  4. 当最后一个中间件执行完毕后,倒序执行中间件的剩余逻辑;

  5. 每个中间件都可以读取之前中间件写入Context的状态;

  6. 最外层获得Context并响应请求。


// 示意中间件执行流程
app.use(async function(ctx, next){
  // 最内层执行
  ctx.message = 'hello';

  await next();
  
  // 最内层剩余逻辑  
});

app.use(async function(ctx, next){
  // 第二层执行
  
  await next();

  // 第二层剩余逻辑
  console.log(ctx.message); 
});

// 最外层获得ctx并响应

这就是洋葱圈模型核心流程,通过Generator函数和Context对象实现了优雅的异步中间件机制。


完整解析


Koa中间件是一个Generator函数,可以通过yield关键字来调用下一个中间件。例如:


const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
  console.log('中间件1开始');
  
  await next();
  
  console.log('中间件1结束');
});

app.use(async (ctx, next) => {
  console.log('中间件2');

  await next();

  console.log('中间件2结束');  
});

app.use(async ctx => {
  console.log('中间件3')
});

app.listen(3000);

在代码中,可以看到Koa注册中间件是通过app.use实现的。所有中间件的回调函数中,await next()前面的逻辑是按照中间件注册的顺序从上往下执行的,而await next()后面的逻辑是按照中间件注册的顺序从下往上执行的。


执行流程如下:



  1. 收到请求,进入第一个中间件

  2. 第一个中间件打印日志,调用next进入第二个中间件

  3. 第二个中间件打印日志,调用next进入第三个中间件

  4. 第三个中间件打印日志,并结束请求

  5. control返回第二个中间件,打印结束日志

  6. control返回第一个中间件,打印结束日志

  7. 请求结束


这样每个中间件都可以控制请求前和请求后,形成洋葱圈模型。


中间件的实现原理


Koa通过compose函数来组合中间件,实现洋葱圈模型。compose接收一个中间件数组作为参数,执行数组中的中间件,返回一个可以执行所有中间件的函数。


compose函数的实现源码如下:


function compose (middleware{

  return function (context, next{
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i{
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

这里利用了函数递归的机制。dispatch函数接收当前中间件的索引i,如果i大于中间件数组长度,则执行next函数。如果i小于中间件数组长度,则取出对应索引的中间件函数执行。


中间件的执行过程


中间件的执行过程


执行中间件函数的时候,递归调用dispatch,同时将索引+1,表示执行下一个中间件。


这样通过递归不断调用dispatch函数,就可以依次执行每个中间件,实现洋葱圈模型。


所以Koa的洋葱圈模型实现得非常简洁优雅,这也是Koa作为新一代Node框架,相比Express更优秀的设计。


洋葱圈模型的优势


提高中间件的复用性


洋葱模型让每个中间件都可以控制请求前和请求后,这样中间件可以根据需要完成各种额外的功能,不会相互干扰,提高了中间件的复用性。


使代码结构更清晰


洋葱模型层层嵌套,执行流程一目了然,代码阅读性好,结构清晰。不会像其他模型那样回调多层嵌套,代码难以维护。


异步编程更简单


洋葱模型通过async/await,使异步代码可以以同步的方式编写,没有回调函数,代码逻辑更清晰。


错误处理更友好


每个中间件都可以捕获自己的错误,并且不会影响其他中间件的执行,这样对错误处理更加友好。


方便Debug


通过洋葱模型可以清楚看到每个中间件的进入和离开,方便Debug。


便于扩展


可以随意在洋葱圈的任意层增加或删除中间件,结构灵活,便于扩展。


总结


总体来说,洋葱模型使中间件更容易编写、维护和扩展,这也是Koa等新框架选择它的主要原因。它的嵌套结构和异步编程支持,使Koa的中间件机制更优雅和高效。


作者:一码平川哟
来源:juejin.cn/post/7262158134323560508
收起阅读 »

2023.28 forEach 、for ... in 、for ... of有什么区别?

web
大家好,我是wo不是黄蓉,今年学习目标从源码共读开始,希望能跟着若川大佬学习源码的思路学到更多的东西。 forEach 、for ... in 、for ... of有什么区别 forEach 数组提供的方法,只能遍历数组 遍历数组:for...in key返...
继续阅读 »

大家好,我是wo不是黄蓉,今年学习目标从源码共读开始,希望能跟着若川大佬学习源码的思路学到更多的东西。


forEach 、for ... in 、for ... of有什么区别


forEach 数组提供的方法,只能遍历数组


遍历数组:for...in key返回数组下标;for...of key返回值;


1690806838416.png
遍历对象:for...in key返回对象的键;for...of 遍历对象报错,提示没有实现person对象不可迭代;


1690806968808.png


iterable什么是可迭代对象?


简单来说就是可以使用for...of遍历的对象,也就是实现了[Symbol.iterator]


迭代和循环有什么区别?


遍历强调把整个数据依次全部取出来,是访问数据结构的所有元素;


迭代虽然也是一次取出数据,但是并不保证取多少,需要调用next方法才能获取数据,不保证把所有的数据取完,是遍历的一种形式。


有哪些对象是可迭代对象呢?


原生的可迭代对象 set map nodelist arguments 数组 string


迭代器是针对某个对象的,有些对象是自己继承了Symbol.Iterator,也可以实现自己的迭代器,必须要实现一个next方法,返回内容



{value:any,done:boolean}

实现对象的迭代器


如果要实现迭代器,需要实现[Symbol.Iterator]是一个函数,这个函数返回一个迭代器


// let arr = ['a', 'b', 'c']
let person = {
name: 'a',
age: 18,
myIterator: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
}
}
}
}

let myIterator = person.myIterator()
console.log(person.myIterator())//{ next: [Function: next] }
console.log(myIterator.next())//{ value: 'a', done: false }
console.log(myIterator.next())//{ value: 18, done: false }
console.log(myIterator.next())//{ value: [Function: myIterator], done: false }
console.log(myIterator.next())//{ value: undefined, done: true }
{ value: undefined, done: true }

按道理实现了迭代器该对象就会变为可迭代对象了,可以使用for..of遍历


但是执行后发现还是会提示Person不是可迭代的,是因为for..of只能遍历实现了[Symbol.iterator]接口的的对象,因此我们写的方法名要使用[Symbol.iterator]


1690873941456.png


修改后:


let person = {
name: 'a',
age: 18,
[Symbol.iterator]: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
}
}
}
}

//for..in
for (let key in person) {
console.log(key, person[key])
}

//for...of
for (let key of person) {
console.log(key)
}

//打印结果
name a
age 18
a
18

什么时候会用迭代器?


应用场景:可以参考阮一峰老师列举的例子


js语法:for ... of 展开运算符 yield 解构赋值


创建对象时:new map new set new weakmap new weakset


一些方法的调用:promise.all promise.race array.from


for in 和for of 迭代器、生成器(generator)


迭代器中断:


迭代器中定义return方法在迭代器提前关闭时执行,必须返回一个对象


break return throw 在迭代器的return 方法中可以捕获到



let person = {
name: 'a',
age: 18,
[Symbol.iterator]: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
},
return: () => {
console.log('结束迭代')
return { done: true }
}
}
}
}

//for...of
for (let key of person) {
console.log(key)
if (key === 'a') break
}

//打印结果
a
结束迭代



作者:wo不是黄蓉
来源:juejin.cn/post/7262212980346404922
>结束:下节讲生成器

收起阅读 »

认识Base64,看这篇足够了

web
Base64的简介 Base64是常见的用于传输8Bit字节码的编码方式之一,基于64个可打印字符来标识二进制数据点方法。 使用Base64的编码不可读,需要解码。 Base64实现方式 Base64编码要求把3个8位字节(3*8=24)转化为4个6位...
继续阅读 »

Base64的简介


Base64是常见的用于传输8Bit字节码的编码方式之一,基于64个可打印字符来标识二进制数据点方法。


使用Base64的编码不可读,需要解码。


Base64实现方式


Base64编码要求把3个8位字节(3*8=24)转化为4个6位的字节(4*6=24),之后在6位的前面补两个0,形成8位一个字节的形式。如果剩下的字符不足3个字节,则用0填充,输出字符使用=,因此编码后输出的文本末尾可能会出现1个或2个=


Base64就是包括小写字母a-z,大写字母A-Z,数字0-9,符号+/组成的64个字符的字符集,另外包括填充字符=。任何符号都可以转换成这个字符集中的字符,这个转化过程就叫做Base64编码。


为了保证输出的编码位可读字符,Base64指定了一个编码表,以便进行统一转换,编码表的大小为2^6=64,即Base64名称的由来。




























































































































































































索引 对应字符 索引 对应字符 索引 对应字符 索引 对应字符
0 A 17 R 34 i 51 z
1 B 18 S 35 j 52 0
2 C 19 T 36 k 53 1
3 D 20 U 37 l 54 2
4 E 21 V 38 m 55 3
5 F 22 W 39 n 56 4
6 G 23 X 40 o 57 5
7 H 24 Y 41 p 58 6
8 I 25 Z 42 q 59 7
9 J 26 a 43 r 60 8
10 K 27 b 44 s 61 9
11 L 28 c 45 t 62 +
12 M 29 d 46 u 63 /
13 N 30 e 47 v
14 O 31 f 48 w
15 P 32 g 49 x
16 Q 33 h 50 y


十进制转二进制


十进制数转换为二进制数时,由于整数和小数的转换方法不同,所以先将十进制数的整数部分和小数部分分别转换后,再加以合并。


十进制整数转换为二进制整数采用 "除 2 取余,逆序排列" 法。


具体做法是:用 2 整除十进制整数,可以得到一个商和余数;再用 2 去除商,又会得到一个商和余数,如此进行,直到商为小于 1 时为止,然后把先得到的余数作为二进制数的低位有效位,后得到的余数作为二进制数的高位有效位,依次排列起来。


示例:求十进制34对应的二进制数





Base64编码示例


示例1:对字符串 Son 进行 Base64编码

























































ASCII字符 S(大写) o n
十进制 83 111 110
二进制 01010011 01101111 01101110
每6个bit为一组 010100 110110 111101 101110
高位补0 00010100 00110110 00111101 00101110
对应的Base64索引 20 54 61 46
对应的Base64字符 U 2 9 u


Son通过Base64编码转换成了U29u,3个ASCII字符刚好转换成对应的Base64字符。


示例2:对字符串 S 进行 Base64编码

























































ASCII字符 S(大写)
十进制 83
二进制 01010011
每6个bit为一组 010100 110000 000000 000000
高位补0 00010100 00110000 00000000 00000000
对应的Base64索引 20 48
对应的Base64字符 U w = =


字符串S对应1个字节,一共8位,按6位为一组分为4组,每组不足6位的补0,然后在每组的高位补0,找到Base64编码进行转换。


Base64的优缺点


优点:





  • 将二进制数据(如图片)转为可打印字符,在HTTP协议下传输



  • 对数据进行简单加密,肉眼安全



  • 如果是在html或css中处理图片,可以减少http请求


缺点:





  • 内容编码后体积变大,至少1/3



  • 编码和解码需要额外工作量


Base64编码和解码





  • btoaatob方法:js中有两个函数被分别用来处理编码和解码Base64字符串



    • btoa():该函数基于二进制数据字符串创建一个Base64编码的字符串



    • atob():该函数用于解码使用Base64编码的字符串




btoa()示例:


let btoaStr = window.btoa(123456)
console.log(btoaStr) // MTIzNDU2

atob()示例:


let atobStr = window.atob(btoaStr)
console.log(atobStr) // 123456

这两个函数容易混淆,可以这样记下,比如btoa是以b字母开头,编码也是以b字母开头。即btoa编码,升序的atob解码。



Data URI Scheme


Data URI scheme的目的是将一些小的数据,直接嵌入到网页中,从而不用再从外部文件载入,减少请求资源的连接数,缺点是不会被浏览器缓存起来。


Data URI scheme支持的Base64编码的类型比如:





  • data:image/png;base64,base64编码的png图片数据



  • data:image/gif;base64,base64编码的gif图片数据



  • data:text/javascript;base64,base64编码的javascript代码


Base64的应用


使用Base64编码资源文件


比如:在html文档中渲染一张Base64的图片


<img :src="data:image/png;base64,IVBORsssssfjsfsfs==" alt="">img>


注意:如果图片较大,在Base64编码后字符串会非常大,影响页面加载进度,这种情况不适合Base64编码。



在HTTP中传递较长的标识信息


比如使用Base64将一个较长的标识符(128bit的UUID)编码为一个字符串,用作表单和httpGET请求中的参数。


标准的Base64编码后不适合直接放在请求的URL中传输,因为URL编码器会把Base64中的/+变成乱码,即%xxx的形式。我们可以将base64编码改进一下用于URL中,比如Base64中的/+转换成_-等字符,避免URL编码器的转换。


canvas生成图片


canvas提供了toDataURL()toBlob()方法。


HTMLCanvasElement.toDataURL() 方法返回一个包含图片展示的data URI。可以使用 type 参数其类型,默认为PNG格式。图片的分辨率为 96dpi。


canvas.toDataURL(type, encoderOptions);

使用示例:设置jpeg图片的质量,图片将以Base64存储


let quality = canvas.toDataURL("image/jpeg"1.0);
// data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...9oADAMBAAIRAxEAPwD/AD/6AP/Z"

HTMLCanvasElement.toBlob() 方法创造Blob对象,用以展示 canvas 上的图片;这个图片文件可以被缓存或保存到本地(由用户代理自行决定)。


HTMLCanvasElement.toBlob(callback, type?, quality?)

使用示例:


canvas.toBlob(function(blob){...}, "image/jpeg"0.95); // JPEG at 95% quality

读取文件


FileReader.readAsDataURL()方法会读取指定的BlobFile对象。读取操作完成的时候,readyState会变成已完成DONE,并触发 loadEnd事件,同时result属性将包含一个data:URL 格式的字符串(Base64 编码)以表示所读取文件的内容。


读取文件示例:


代码演示可以点击这里查看哦


<input type="file" onchange="previewFile()" />
<br />
<br />
<img src="" alt="导入图片进行预览" />
<script>
  function previewFile({
    var preview = document.querySelector("img");
    var file = document.querySelector("input[type=file]").files[0];
    var reader = new FileReader();

    reader.addEventListener("load",
      function ({
        preview.src = reader.result;
        console.log(reader.result); // Base64编码的图片格式
      },
      false,
    );

    if (file) {
      reader.readAsDataURL(file);
    }
  }
script>

简单"加密"某些数据


Base64常用作一个简单的“加密”来保护某些数据,比如个人密码我们如果不使用其他加密算法的话,可以使用Base64来简单处理。


Base64是加密算法吗


Base64不是加密算法,只是一种编码方式,可以用Base64来简单的“加密”来保护某些数据。


结语


❤️ 🧡 💛大家喜欢我写的文章的话,欢迎大家点点关注、点赞、收藏和转载!!


作者:前端开心果
来源:mdnice.com/writing/3c8306915f484882a5b720bfdedc6e02
收起阅读 »

为什么你不应该使用div作为可点击元素

web
按钮是为任何网络应用程序提供交互性的最常见方式。但我们经常倾向于使用其他HTML元素,如divspan等作为clickable元素。但通过这样做,我们错过了许多内置浏览器的功能。 我们缺少什么? 无障碍问题(空格键或回车键无法触发按钮点击) 元素将无法通过按...
继续阅读 »

按钮是为任何网络应用程序提供交互性的最常见方式。但我们经常倾向于使用其他HTML元素,如divspan等作为clickable元素。

但通过这样做,我们错过了许多内置浏览器的功能。


我们缺少什么?



  1. 无障碍问题(空格键或回车键无法触发按钮点击)

  2. 元素将无法通过按Tab键来聚焦


1062174618-64c4718ba0919.webp


权宜之计


我们需要在每次创建可点击的 div 按钮时,以编程方式添加所有这些功能


image.png


更好的解决方案


始终优先使用 button 作为可点击元素,以获取浏览器的所有内置功能,如果你没有使用它,始终将上述列出的可访问性功能添加到你的div中。


虽然,直接使用按钮并不直观。我们必须添加并修改一些默认的CSS和浏览器自带的行为。


使用按钮的注意事项


1. 它自带默认样式


我们可以通过将每个属性值设置为 unset 来取消设置现有的CSS。


我们可以添加 all:unset 一次性移除所有默认样式。


在HTML中,我们有三种类型的按钮。 submit, reset and button.
默认的按钮类型是 submit.


无论何时使用按钮,如果它不在表单内,请始终添加 type='button' ,因为 submit 和 reset 与表格有关。


2.请不要在按钮标签内部放置 divs


我们仍然需要添加 cursor:pointer 以便将光标更改为手形。


image.png


作者:王大冶
来源:juejin.cn/post/7261985825089110076
收起阅读 »

从9G到0.3G,腾讯会议对他们的git库做了什么?

web
导读 过去三年在线会议需求井喷,腾讯会议用户量骤增到3亿。快速迭代的背后,腾讯会议团队发现:业务保留了长达5年的历史数据,大量未进行 lfs 转换,新 clone 仓库本地空间占17.7G+。本地磁盘面临严重告急,强烈影响团队 clone 效率。当务之急是将仓...
继续阅读 »

导读


过去三年在线会议需求井喷,腾讯会议用户量骤增到3亿。快速迭代的背后,腾讯会议团队发现:业务保留了长达5年的历史数据,大量未进行 lfs 转换,新 clone 仓库本地空间占17.7G+。本地磁盘面临严重告急,强烈影响团队 clone 效率。当务之急是将仓库进行瘦身。本栏目特邀腾讯会议的智子研发团队成员李双君,回顾腾讯会议客户端的瘦身历程和经验,欢迎阅读。


目录


1 瘦身成效


2 瘦身前事项


3 瘦身整体方案


4 瘦身具体命令执行


5 新代码库验证


6 解决其它设备本地老分支 push 问题


7 其他平台适配


8 最后的验证


9 瘦身完毕后的知会


10 兜底回滚方案


11 踩坑记录及应对


12 写在最后


*作者所在的腾讯会议智子研发团队是腾讯会议的终端团队,负责腾讯会议 Win、Mac、Linux、Android、iOS、小程序、Web 等全栈开发,致力于打造一流的端产品体验。


01、瘦身成效


腾讯会议瘦身完毕后整体收益:




  • Git 仓库大小,9G 到350M。




  • 新 clone 仓库占用空间,从17.7G 到12.2G。




  • 平常拉代码速度(北京地区测试):macbook m1 pro:提升45%;devcloud win:提升56%。




  • 包构建流水线全量拉代码耗时,从16分钟减少到5分钟以内。





02、瘦身前事项


2.1 环境准备


使用有线网,看看能否通过其他办法给机器的上传和下载速度提速?不建议在家中开代理来瘦身,因为家里网速一般都没有公司快;如果在家操作,提前配置好远程桌面,远程公司电脑来瘦身。


使用性能较好的机器,硬盘空间至少要有 xxxG 剩余 (可以提前演练,看看究竟要多大磁盘空间?会议最起码得要求有600G 空余)。会议本次瘦身使用的设备是 MAC Book M1 Pro(16寸)笔记本电脑。


2.2 周知


工作开发群或者邮件等通知瘦身时间和注意事项:


瘦身时间: 选一个大家基本上都不会提交代码的时间,比如十一国庆或者春节;会议选的是春节期间。


注意事项: (开发重点关注)


瘦身期间会锁库,必须提前推送代码到远端,否则需要手动同步; 锁库期间无法进行 MR,且已创建 MR 会失效; 因删除历史记录,会导致本地仓库与远端冲突,请恢复后重新 clone 代码; 需要查询或处理更老的代码,需要去备份仓库查看。

2.3 代码库锁定


禁止代码库写操作,一般公司的代码管理平台可以提供这个功能,Git 项目的 owner 有权限。


2.4 第三方 Git 平台禁用


如果 Git 项目被第三方 Git 平台使用了,要保证瘦身前仓库的同步任务禁用。


比如,会议使用了 Ugit(UGit 是腾讯内部的一款自研 Git 客户端,主要是为腾讯内部研发环境特点而定制),就要如下禁用项目同步:



03、瘦身整体方案


原仓库继续保留作为备份仓库,另外新建仓库,新仓库沿用原仓库的项目名称、版本库路径和 id,并同步原项目数据。


之所以这么做,是为了保证其他平台无缝对接新的 Git 仓库,不用再更换 Git 地址,另外有些通过 api 调用的系统和工具也不受到影响。


瘦身内容:




  • 历史记录删除,只保留最近半年的历史记录。




  • 将历史大文件以及未加入 lfs 的大文件进行 lfs 处理。




04、瘦身具体命令执行


4.1 clone 项目,拉取所有文件版本到本地


git clone example.com/test.git


为了后面的比对验证,可以拷贝一份 test 文件夹放到和 test 同级目录下面的新建的 copyForCompare 文件夹中。


ulimit -n 9999999 # 解决可能出现的报错too many open files的问题
ulimit -n # 查看改成9999999了没

遍历拉取所有分支的 lfs 最新文件,并追踪远端分支到本地


以下这段 shell 脚本可以直接拷贝到终端运行,也可以创建一个.sh 文件放到根目录执行


cur_index=1
j=1
git branch -r | grep -v '->' |
while read remote
do
echo ”deal $cur_index th branch“
cur_index=$[cur_index+1]
git branch --track "${remote#origin/}" "$remote"
echo "begin to lfs fetch branch $remote"
git lfs fetch origin $remote
if [ $? -eq 0 ]; then
echo "fetch branch $remote success"
else
echo "fetch branch $remote failed"
lfs_fetch_fail_array[$j]=$remote
j=$[j+1]
fi
done
if [ ${#lfs_fetch_fail_array[*]} -gt 0 ]; then
echo "git lfs fetch error branches are: ${lfs_fetch_fail_array[*]}"
else
echo "fetch all branches success. done."
fi

获取所有分支的文件和 lfs 文件版本


git fetch --all
git lfs fetch --all

4.2 使用 git filter-branch 截断历史记录


这次瘦身只保留最近半年的历史记录,2022.6.1之前的提交记录都删除,所以截断的 commit 节点按如下所述来找:


提前用 sourceTree(或者别的 Git 界面工具)找出来需要截断的那个 commit,以主干 master 为例,找到 master 分支上提交的并且只有一个父的提交节点(如果提交节点有多个父,那么所有父节点都要处理),该节点必须是所有分支的父节点,否则需要考虑其他分支特殊处理的情况,该情况后面的【特殊分支处理】会有说明。



可以看到选中的截断 commit id 是 ff75cc5cdbf0423a24b4f5438e52683210813ba0



  • 根据上面的 commit id,带入下面的命令,找出其父


git cat-file -p ff75cc5cdbf0423a24b4f5438e52683210813ba0



可以看到只有一个父,其父是7ffe6782272879056ca9618f1d85a5f9716f8e90 ,所以该提交 id 就是要置为空的。如果有多个父都需要处理。



  • 执行命令


注意:对于普通提交节点,下面命令的 parent 值是"-p parentId";对于合并提交节点,下面命令的 parent 值是"-p parentId1 -p parentId2 -p parentId3 ..."


git filter-branch --force --parent-filter '
read parent
if [ "$parent" = "-p 7ffe6782272879056ca9618f1d85a5f9716f8e90" ]
then
echo
else
echo "$parent"
fi' --tag-name-filter cat -- --all


  • 重点验证:上述命令执行完毕后,一定要用如下命令检查是否修改成功


注意:因为执行完了命令已经修改了历史记录,此时 Git log 命令执行会慢点,大概5分钟可以出结果,另外可以用这个在线时间戳转换工具来转换时间戳。


工具链接:http://www.beijing-time.org/shijianchuo…


如果执行成功会把之前的文件版本取最新的 add 到这个截断的提交节点里面,如下图:


git log --all-match --author="xxxx" --grep="auto update .code.yml by robot" --name-status --before="1654043400" --after="1654012800" --all


4.3 使用 git-filter-repo 清理截断日期前的所有历史记录,并将截断节点的提交信息修改


注意此步骤要谨慎处理,因为这步会真正地删除提交记录。


提前安装好 git-filter-repo,执行下面的 python 代码。


import os
try:
import git_filter_repo as fr
except ImportError:
raise SystemExit("Error: Couldn't find git_filter_repo.py. Did you forget to make a symlink to git-filter-repo named git_filter_repo.py or did you forget to put the latter in your PYTHONPATH?")

k_work_dir = "/Volumes/SolidCompany/S_Shoushen/test"
# 2022.6.1 00:00:00
k_clean_history_deadline = b"1654012800"
# 2022.6.1 07:05:07
k_clean_deadline_commit_date = b"1654038307"
k_clean_deadline_commit_author_name = b"xxxxx"
k_new_root_commit_message = "仓库瘦身历史记录裁剪,截断提交记录后新根结点新增历史文件;如果想查看更多历史记录,请去备份仓库:https://example.com/test_backup.git"

def commitCallBackFun(commit, metadata):
[time_stamp, timezone] = commit.committer_date.split()
if time_stamp == k_clean_deadline_commit_date and commit.author_name == k_clean_deadline_commit_author_name:
commit.message = k_new_root_commit_message.encode("utf-8")
if time_stamp >= k_clean_history_deadline:
return
commit.file_changes = []

def main():
os.chdir(k_work_dir)
print("git work dir is", os.getcwd())
args = fr.FilteringOptions.parse_args(['--force', '--debug'])
filter = fr.RepoFilter(args, commit_callback = commitCallBackFun)
filter.run()

if __name__ == '__main__':
main()

验证下截断提交结点的提交信息更改成功了没?


git log --all-match --author="xxx" --grep="仓库瘦身历史记录裁剪" --name-status --before="1654043400" --after="1654012800"

如下就对了:



以上执行完后做个简单验证:


用 BeyondCompare 工具跟刚开始备份的 copyForCompare 目录下的 test 仓库对比,看看有没有增删改文件,期望应该没有任何变化才对。



  • 特殊分支处理


说明:以上历史记录裁剪并删除历史提交记录执行完后,对于基于截断提交节点前的提交节点创建出来的分支或者其子分支会出现文件被删除或者整个分支被删除的情况。



所以要提前弄清楚有没有在截断节点之前早就创建出来一直在用的分支, 如果有就得特殊处理上面的2和3步骤了:


第2步中截断历史记录的时候,要类似分析 master 分支那样分析其它需要保留的特殊分支,找出各自的截断节点的父提交 id;然后执行的 shell 脚本里面条件判断改成判断所有的父提交 id;类似这样:


git filter-branch --force --parent-filter '
read parent
if [ "$parent" = "-p 85f5ee6314f4f46cc47eb02c6af93bd3020a1053 -p cd207e9b3372f68a6d1ffe06fcf189d952e3bf9f" ] || [ "$parent" = "-p 7ffe6782272879056ca9618f1d85a5f9716f8e90" ]
then
echo
else
echo "$parent"
fi' --tag-name-filter cat -- --all

第3步中删除截断节点前提交记录的 python 脚本里面,按照分支名字和自己分支的截断日期来做比对逻辑进行删除提交记录的操作。类似如下:


#!/usr/bin/env python3
import os
try:
import git_filter_repo as fr
except ImportError:
raise SystemExit("Error: Couldn't find git_filter_repo.py. Did you forget to make a symlink to git-filter-repo named git_filter_repo.py or did you forget to put the latter in your PYTHONPATH?")

k_work_dir = "/Users/jevon/Disk/work/appShoushen/shoushen/test"

# 2022.6.1 07:05:07
k_master_cut_date = b"1654038307"

# 2022.3.25 19:32:00
k_private_new_saas_sdk_master_cut_date = b"1648207920"

k_new_root_commit_message = "仓库瘦身历史记录裁剪,截断提交记录后新根结点新增历史文件;如果想查看更多历史记录,请去备份仓库:https://example.com/test_backup.git"

def commitCallBackFun(commit, metadata):
[time_stamp, timezone] = commit.committer_date.split()
# 每个特殊分支的截断提交点的提交信息修改
if (time_stamp == k_master_cut_date and commit.author_name == b"xxx_author1") or \
(time_stamp == k_private_new_saas_sdk_master_cut_date and commit.author_name == b"xxx_author2"):
commit.message = k_new_root_commit_message.encode("utf-8")

# 每个特殊分支的截断提交点前的提交记录,需要根据各自截止日期来做比对删除日期前的历史记录
strBranch = commit.branch.decode("utf-8")
if strBranch.endswith("refs/heads/master"):
if time_stamp < k_master_cut_date:
commit.file_changes = []
elif strBranch.endswith("refs/heads/private/feature/3.12.1/new-saas-sdk-master"):
if time_stamp < k_private_new_saas_sdk_master_cut_date:
commit.file_changes = []
def main():
os.chdir(k_work_dir)
print("git work dir is", os.getcwd())
args = fr.FilteringOptions.parse_args(['--force', '--debug'])
filter = fr.RepoFilter(args, commit_callback = commitCallBackFun)
filter.run()

if __name__ == '__main__':
main()

以上[特殊分支处理]没有实验过,但是个解决思路,具体实践结果待补充,也欢迎实验过的同学交流。


4.4 进行 lfs 转换


rm -Rf .git/refs/original
rm -Rf .git/logs
git branch | wc -l # 看一下本地分支总数
# 拷贝原来的仓库到新目录下面
git clone file:///Users/jevon/Disk/work/appShoushen/shoushen/test /Users/jevon/Disk/work/appShoushen/shoushen/test_new
cd test_new
git branch -r | grep -v '->' |
while read remote
do
git branch --track "${remote#origin/}" "$remote"
done
git fetch --all git branch | wc -l # 看一下本地分支总数,和拷贝之前是否一样
# 分析仓库中占用空间较大的文件类型(演练的时候可以提前分析出来,节省瘦身时间)
git lfs migrate info --top=100 --everything

命令结果如下,是按照文件所有的历史版本累加统计的,只有未加入 lfs 的才会统计。



git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | sed -n 's/^blob //p' | sort --numeric-sort --key=2 | cut -c 1-12,41- | $(command -v gnumfmt || echo numfmt) --field=2 --to=iec-i --suffix=B --padding=7 --round=nearest | grep MiB

该命令执行结果如下,是把所有大于 1Mb 的文件版本都列出来了,不进行累加,从小到大排序,已经加入 lfs 的不会统计。



# lfs转换
# --include=之后填入根据实际分析的大文件列表
git lfs migrate import --everything --include="*.jar,tool/ATG/index.js,xxx"
# 上面lfs转换执行完后,看一下根目录的.gitattribute文件里面是不是加入了新的lfs文件了

4.5 新建新仓库,推送所有历史记录修改


新创建目标仓库 test_backup.git ,然后运行下面代码:


git remote remove origin
git remote add origin https://example.com/test_backup.git
git remote set-url origin https://example.com/test_backup.git
git remote -v # 确保设置成功新仓库地址

此时可以用下面的命令看看还有没有大文件了(可选)。


git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | sed -n 's/^blob //p' | sort --numeric-sort --key=2 | cut -c 1-12,41- | $(command -v gnumfmt || echo numfmt) --field=2 --to=iec-i --suffix=B --padding=7 --round=nearest | grep MiB

用以下命令看看还有没有未转换的大的 lfs 文件了(可选)。


git lfs migrate info --top=100 --everything
# 推送历史记录修改到目标新仓库:
git push origin --no-verify --all
git push origin --no-verify --tags

4.6 回到原来的 test 目录,推送 lfs 文件


cd ../test
git config lfs. https://example.com/test_backup.git /info/lfs.access basic
git lfs env # 看一下改成了basic了吗
# 设置成远端目标仓库test_backup.git
git remote remove origin
git remote add origin https://example.com/test_backup.git
git remote set-url origin https://example.com/test_backup.git
git remote -v # 确保设置成功新仓库地址


将 upload_objects.sh 拷贝到 test 目录,然后执行 sh ./upload_objects.sh


upload_objects.sh 内容如下:


#!/bin/bash

set -e

count=$(find .git/lfs/objects -type f | wc -l)
echo "-- total objects count is $count"

index=0
concurrency=25

find .git/lfs/objects -type f | awk -F '/' '{print $NF}' | while read -r obj; do
echo "-- $(date) -- uploading ($index/$count) $obj"
git lfs push origin --object-id "$obj" &
index=$((index+1))
if [[ $index%$concurrency -eq 0 ]]; then
echo -e "\r\n-- $(date) -- waiting --------------------------\r\n"
wait
fi
done

注意脚本里面的并发值不能设置太高,不然会报错./upload_objects.sh: line 12: echo: write error: Interrupted system call,测试发现设置25是比较合适的。



确保像上图这样,最后一个也上传成功。


05、新代码库验证


git clone https://example.com/test_backup.git

使用 git lfs pull 先拉取主干分支所有的历史文件进行测试,保留瘦身的本地仓库;  后续如果发现有其他分支的 lfs 文件没有上传成功,再单独上传即可。


上传命令:


git lfs push --object-id origin "$objId"

对比新旧代码库主干最新代码是否一致,可使用 beyond compare 工具进行验证。四端编译不同代表性的分支运行验证。


06、解决其它设备本地老分支 push 问题


在公司的代码管理平台上设置瘦身后的 test_backup 仓库单文件大小上限为1.5M。


一般公司自己的代码管理平台都会提供设置单个 git 文件上传大小上限的功能,得管理员才有权限设置;腾讯的代码管理平台是像下图这样设置的:



解释:之后的步骤将会把新老仓库互换,新旧仓库互换后,其它机器本地的老仓库分支还是有 push 上去的风险,这样就会把瘦身前的历史记录又推送到瘦身后的 Git 仓库,造成瘦身白费。


07、其他平台适配


7.1 代码管理平台


找代码管理平台协助完成下面的操作:(需要提前预约沟通好)


会议用的代码管理平台是工蜂:


项目名称、版本库路径互换:test_backup 重命名为 test,test 重命名为 test_backup。 将两个项目项目 id 进行调换:新项目沿用旧项目的项目 id,以此保证通过 api 调用的系统和工具不受到影响。 项目数据同步:同步项目成员和权限相关的数据、保护分支规则组到新仓库。

自己工蜂适配(可以提前进行)。对照老工蜂的所有配置,在新工蜂上手动同步修改。


7.2 第三方 Git 工具


如果使用了第三方 Git 工具平台做过瘦身仓库与其他项目仓库的同步,需要处理下(会议使用了 UGit 第三方工具):


通知 UGit 相关负责人把旧的工作区移除一下,重新 clone test 仓库。 把 Ugit 里面 test 仓库的同步任务恢复(如有需要)。

7.3 出包流水线构建平台


因为执行完瘦身后,Git 的 commit id 都变了,历史记录也变了,而 coding 的构建机如果不清理缓存删掉老仓库的话,会导致构建机本地仓库历史与远端冲突,执行 Git 命令会异常,所以 coding 必须要清理掉老仓库,重新 clone。


08、最后的验证


代码管理平台以及出包构建平台都处理完成后,进行最后的验证。


本地验证:


本地是否能正常 clone 代码。 本地对比新旧仓库主干最新代码是否一致。 本地随机抽取分支对比新旧仓库文件个数以及最新代码是否一致。 本地编译验证,程序启动主流程验证。

出包构建平台验证:


主干分支、发布分支、个人需求分支、个人分支等的构建验证。

代码管理平台验证:


代码库基础、高级配置是否正确 保护分支规则配置是否正确,是否有效 项目成员是否和原仓库一致 MR 是否可正常发起、合并,能否正常调起检测流水线

代码库写权限恢复:


保证瘦身后的 Git 仓库恢复写权限;备份仓库禁用写权限。

09、瘦身完毕后的知会


知会参考模板:


xxx 仓库瘦身完成了!接下来需要开发重点关注:本地旧仓库已经失效,必须删掉重新 clone 代码【最最重要】未提前push到远端的本地新增代码需要手动同步旧的未完成的MR已经失效,需要关闭后重新发起需要查询或处理更老的代码,需要去备份仓库查看(xxxx/xxxx.git)开发过程中有任何疑问,欢迎请随时联系 xxx

10、兜底回滚方案


因为使用了备份仓库,所以不会修改原始仓库,但只有代码管理平台(工蜂)在第七步的时候修改了原始仓库,对于这个工蜂的协助修改,需要提前确认好工蜂那边做好了回滚的方案。


11、踩坑记录及应对


11.1 上传 lfs 的时候报错 User is null or anonymous user



LFS: Git:User is null or anonymous user.


解决:git config lfs.example.com/test_backup… basic


输入 git lfs env 看一下输出结果改成了 basic 了吗?



11.2 git push 的时候报错



把远程链接改成 https 的:


git remote set-url origin https://example.com/test_backup.git
git remote -v

如果~/.gitconfig 中有如下的内容要先注释掉。


url.git@example.com:.insteadof=http://example.com/ url.git@example.com:.insteadof=https://example.com/

最后再 push 即可。


如果上述还不行,那么在命令行中执行:


git config --global https.postbuffer 1572864000git config --global https.lowSpeedLimit 0git config --global https.lowSpeedTime 999999

如仍然无法解决,可能是用户的客户端默认有设默认值限制 git 传输包的大小,可执行指令:


git config --global core.packedGitLimit 2048m
git config --global core.packedGitWindowSize 2048m

11.3 window 如何在 git batch 里面运行 git-filter-repo?


安装 python:打开 cmd 窗口,运行 python -m pip install git-filter-repo,安装 git-filter-repo;


用 everything 查找 git-filter-repo.exe;


cmd 窗口,运行 git --exec-path,得到路径类似:C:\Program Files\Git\mingw64\libexec\git-core;


把上面找到的 git-filter-repo.exe 拷贝到 git-core 文件夹里面;


此时在 git batch 窗口中,输入命令 git filter-repo(注意输入的git后面没有-),会提示 No arguments specified.证明 ok 了。


11.4 如果想让 git-filter-repo 作为一个 python 库来使用,实现更复杂的功能该怎么办?


比如,不想这么用了 git-filter-repo --force --commit-callback "xxxx python code...",因为这么用只能写回调的 python 代码,太弱了。


解决:python3 -m pip install --user git-filter-repo,不行就 python3 -m pip install git-filter-repo,安装这个 git-filter-repo包,然后就可以在 python 代码中作为库使用:import git_filter_repo as fr。


11.5 瘦身后发现 coding 的 win 构建机器在 clone 代码时出问题,怎么办?


卡在 git lfs pull:



卡在 git checkout --force xxxxx 提交 id:



卡在 checking out files:



调查发现,是 lfs 进程卡住,不知道什么样的场景触发的,官方有个类似 issue,以上问题均是因为 git 或者 git lfs 版本过低导致的,升级到高版本即可解决。


据当时出错 case 总结得出结论,以下 git 和 git lfs 的版本号可以保证稳定运行不出问题,如果版本号低于以下所示,最好升级。



11.6 执行 git lfs fetch 的时候报错 too many open files 的问题


解决办法:ulimit -n 9999999


12、写在最后


仓库瘦身是个细致耗时的工作,需要谨慎认真地完成。最后腾讯会议客户端仓库的大小也从 9G 瘦身到 350M ,实现的效果还是不错的。


本次我们分享了仓库瘦身的全历程,把执行命令也公示给各位读者。希望可以帮助到为类似困境而头疼的开发者们。这篇文章对您有帮助的话,欢迎转发分享。


-End-


原创作者|李双君


技术责编|陈从贵、郭浩伟



作者:腾讯云开发者
来源:juejin.cn/post/7261814990843265061
收起阅读 »

浏览器渲染15M文本导致崩溃怎么办

web
最近,我刚刚完成了一个阅读器的txt文件阅读功能,但在处理大文件时,遇到了文本内容过多导致浏览器崩溃的问题。 一般情况下,没有任何样式渲染时不会出现什么问题,15MB的文件大约会有3秒的空白时间。 <div id="content"></di...
继续阅读 »

最近,我刚刚完成了一个阅读器的txt文件阅读功能,但在处理大文件时,遇到了文本内容过多导致浏览器崩溃的问题。


一般情况下,没有任何样式渲染时不会出现什么问题,15MB的文件大约会有3秒的空白时间。


<div id="content"></div>

fetch('./dp.txt').then(resp => resp.text()).then(text => {
document.getElementById('content').innerText = text
})

尽管目前还没有严重的问题,但随着文件继续增大,肯定会超过浏览器内存限制而导致崩溃。


在开发阅读器的过程中,我添加了下面的样式,结果导致浏览器直接崩溃:


* {
margin: 0;
padding: 0;
}

html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}

body {
column-fill: auto;
column-width: 375px;
overflow-x: auto;
}

预期结果应该是像下面这样分段显示:



然而,实际出现了下面的问题:


unnamed.png


因此,文件内容太多会导致浏览器崩溃。即使进行普通的渲染,我们也要考虑这个问题。


如何解决


解决这个问题的方法有点经验的前端开发工程师应该都知道可以使用虚拟滚动,重点是怎么对文本分段分段,最容易的可能就是按照一定数量字符划分,但是这个导致文本衔接不整齐出现文本跳动。如图,橙色和蓝色表示两端文本的衔接,虚拟滚动必然会提前移除橙色那块内容,那么就会导致蓝色文本位置发生改变。



要解决这个问题,我们需要想办法用某个元素替代原来的位置。当前页橙色部分删除并计算位置,问题会变得复杂并且误差比较大,因此这一部分直接保留,把这部分前面的内容移除,然后用相同长度的元素占据,接下来重点就是怎么获取到橙色部分与前面内容的分界点。


获取分界点可以使用document.createRange()document.createRange()是 JavaScript 中用于创建Range对象的方法。Range对象表示一个包含节点与文本节点之间一定范围的文档片段。这个范围可以横跨单个节点、部分节点或者多个节点。


// 创建 Range 对象
const range = document.createRange();

range.setStart(textNode, 0); // 第一个参数可以是文本节点,第二个参数表示偏移量
range.setEnd(textNode, 1);
const rect = range.getBoundingClientRect(); // 获取第一个字符的位置信息

利用Range对象的特性,我们可以从橙色部分的最后一个字符开始计算,直到找到分界点的位置。


阅读器如果仅仅只是从左往右阅读,按照上面的方法已经可以实现,但是阅读器可能会出现页面直接跳转,跳转之后的文本起点你并不知道,并且页面总页码你也无法知道。因此从一开始就要知道每一页的分界点,也就是需要做预渲染。以下是一个简单的示例:


let text = '...'
const step = 300
let end = Math.min(step, value.length) // 获取结束点

while (text.length > 0) {
node.innerText = value.substring(0, end) // 取部分插入节点
const range = document.createRange()
range.selectNodeContents(node)
const rect = range.getBoundingClientRect() // 获取当前内容的位置信息

if (rect.height > boxHeight) {
// 判断当前内容高度是否会超出显示区域的高度
// 如果超出,从 end 最后一个字符开始计算,直到不超出范围
while (bottom > boxHeight) {
// node.childNodes[0] 表示文本节点
range.setStart(node.childNodes[0], end - 1)
range.setEnd(node.childNodes[0], end)
bottom = range.getBoundingClientRect().bottom
end--
}
} else {
// 如果没有超出,end 继续增加
// ...
}
}

上面只是简单的实现原理,可以达到精确区分每一页的字符,但是计算量有点太大,15MB文本大约500多万字,循环次数估计也在几十万到上百万。在本人的电脑上测试大约需要20秒,每个人设备的性能不同,所需时间也会有所不同。很明显,这种实现方式并不太理想。


后来我对这个方案进行了优化,实际上我们不需要计算每一页的分界点,可以计算出多页的分界点,例如10页、20页、50页等。优化后的代码是将step增大,比如设为10000,然后将不能组成一页的尾部内容去掉。优化后,15MB的文本大约需要4秒左右。需要注意的是,step并不是越大越好,太大会导致渲染页面占用时间过长。


这就是我目前用来解决页面渲染大量文本的方法。如果你有更

作者:60岁咯
来源:juejin.cn/post/7261231729523965989
好的方案,欢迎留言。

收起阅读 »

pnpm 是凭什么对 npm 和 yarn 降维打击的

web
大家最近是不是经常听到 pnpm,我也一样。今天研究了一下它的机制,确实厉害,对 yarn 和 npm 可以说是降维打击。 那具体好在哪里呢? 我们一起来看一下。 我们按照包管理工具的发展历史,从 npm2 开始讲起: npm2 用 node 版本管理工具把...
继续阅读 »

大家最近是不是经常听到 pnpm,我也一样。今天研究了一下它的机制,确实厉害,对 yarn 和 npm 可以说是降维打击。
那具体好在哪里呢? 我们一起来看一下。



我们按照包管理工具的发展历史,从 npm2 开始讲起:


npm2


用 node 版本管理工具把 node 版本降到 4,那 npm 版本就是 2.x 了。


768C1B00093D82D19D2CC333F3221670.jpg


然后找个目录,执行下 npm init -y,快速创建个 package.json。


然后执行 npm install express,那么 express 包和它的依赖都会被下载下来:


FB54F396F6A73093CE052A6881AF7C50.jpg
展开 express,它也有 node_modules:


E652FB00C06BA36FA8861E3B785981BF.jpg
再展开几层,每个依赖都有自己的 node_modules:


75AC9B15A99383C9EA9E5E4EF8302588.jpg
也就是说 npm2 的 node_modules 是嵌套的。


这很正常呀?有什么不对么?


这样其实是有问题的,多个包之间难免会有公共的依赖,这样嵌套的话,同样的依赖会复制很多次,会占据比较大的磁盘空间。


这个还不是最大的问题,致命问题是 windows 的文件路径最长是 260 多个字符,这样嵌套是会超过 windows 路径的长度限制的。


当时 npm 还没解决,社区就出来新的解决方案了,就是 yarn:


yarn


yarn 是怎么解决依赖重复很多次,嵌套路径过长的问题的呢?


铺平。所有的依赖不再一层层嵌套了,而是全部在同一层,这样也就没有依赖重复多次的问题了,也就没有路径过长的问题了。


我们把 node_modules 删了,用 yarn 再重新安装下,执行 yarn add express:


这时候 node_modules 就是这样了:


7AF387F155588612B92C329F91D30BFA.jpg


全部铺平在了一层,展开下面的包大部分是没有二层 node_modules 的:


BBBA3B5F68AB691541FD51569E5B1316.jpg


当然也有的包还是有 node_modules 的,比如这样:


B5E0DBF3C7E6FBEEDC2CC90D350A278C.jpg
为什么还有嵌套呢?


因为一个包是可能有多个版本的,提升只能提升一个,所以后面再遇到相同包的不同版本,依然还是用嵌套的方式。


npm 后来升级到 3 之后,也是采用这种铺平的方案了,和 yarn 很类似:


67B0E1280BAD542944AB08A35CCE88C3.jpg
当然,yarn 还实现了 yarn.lock 来锁定依赖版本的功能,不过这个 npm 也实现了。


yarn 和 npm 都采用了铺平的方案,这种方案就没有问题了么?


并不是,扁平化的方案也有相应的问题。


最主要的一个问题是幽灵依赖,也就是你明明没有声明在 dependencies 里的依赖,但在代码里却可以 require 进来。


这个也很容易理解,因为都铺平了嘛,那依赖的依赖也是可以找到的。


但是这样是有隐患的,因为没有显式依赖,万一有一天别的包不依赖这个包了,那你的代码也就不能跑了,因为你依赖这个包,但是现在不会被安装了。


这就是幽灵依赖的问题。


而且还有一个问题,就是上面提到的依赖包有多个版本的时候,只会提升一个,那其余版本的包不还是复制了很多次么,依然有浪费磁盘空间的问题。


那社区有没有解决这俩问题的思路呢?


当然有,这不是 pnpm 就出来了嘛。


那 pnpm 是怎么解决这俩问题的呢?


pnpm


回想下 npm3 和 yarn 为什么要做 node_modules 扁平化?不就是因为同样的依赖会复制多次,并且路径过长在 windows 下有问题么?


那如果不复制呢,比如通过 link。


首先介绍下 link,也就是软硬连接,这是操作系统提供的机制,硬连接就是同一个文件的不同引用,而软链接是新建一个文件,文件内容指向另一个路径。当然,这俩链接使用起来是差不多的。


如果不复制文件,只在全局仓库保存一份 npm 包的内容,其余的地方都 link 过去呢?


这样不会有复制多次的磁盘空间浪费,而且也不会有路径过长的问题。因为路径过长的限制本质上是不能有太深的目录层级,现在都是各个位置的目录的 link,并不是同一个目录,所以也不会有长度限制。


没错,pnpm 就是通过这种思路来实现的。


再把 node_modules 删掉,然后用 pnpm 重新装一遍,执行 pnpm install。


你会发现它打印了这样一句话:


FA450CB6BE37F7AEDADDD7AF8CB5EBF9.jpg


包是从全局 store 硬连接到虚拟 store 的,这里的虚拟 store 就是 node_modules/.pnpm。


我们打开 node_modules 看一下:


DD21BA4ABF8516795C6BC205C18793E3.jpg
确实不是扁平化的了,依赖了 express,那 node_modules 下就只有 express,没有幽灵依赖。


展开 .pnpm 看一下:


25BF2AA593655F0A20232371A43AB81A.jpg
所有的依赖都在这里铺平了,都是从全局 store 硬连接过来的,然后包和包之间的依赖关系是通过软链接组织的。


比如 .pnpm 下的 expresss,这些都是软链接:


6F84C353D1CFE72E2F820B62C9A3B96E.jpg
也就是说,所有的依赖都是从全局 store 硬连接到了 node_modules/.pnpm 下,然后之间通过软链接来相互依赖。


官方给了一张原理图,配合着看一下就明白了:


0E694CA43CC1E52ED6AF8BCD50882004.jpg
这就是 pnpm 的实现原理。


那么回过头来看一下,pnpm 为什么优秀呢?


首先,最大的优点是节省磁盘空间呀,一个包全局只保存一份,剩下的都是软硬连接,这得节省多少磁盘空间呀。


其次就是快,因为通过链接的方式而不是复制,自然会快。


这也是它所标榜的优点:


image.png


相比 npm2 的优点就是不会进行同样依赖的多次复制。


相比 yarn 和 npm3+ 呢,那就是没有幽灵依赖,也不会有没有被提升的依赖依然复制多份的问题。


这就已经足够优秀了,对 yarn 和 npm 可以说是降维打击。


总结


pnpm 最近经常会听到,可以说是爆火。本文我们梳理了下它爆火的原因:


npm2 是通过嵌套的方式管理 node_modules 的,会有同样的依赖复制多次的问题。


npm3+ 和 yarn 是通过铺平的扁平化的方式来管理 node_modules,解决了嵌套方式的部分问题,但是引入了幽灵依赖的问题,并且同名的包只会提升一个版本的,其余的版本依然会复制多次。


pnpm 则是用了另一种方式,不再是复制了,而是都从全局 store 硬连接到 node_modules/.pnpm,然后之间通过软链接来组织依赖关系。


这样不但节省磁盘空间,也没有幽灵依赖问题,安装速度还快,从机制上来说完胜 npm 和 yarn。


pnpm 就是凭借这个对 npm 和 yarn 降维打击的。


作者:JEECG官方
来源:juejin.cn/post/7260283292754919484
收起阅读 »

前端发展:走进行业迷茫的迷雾中

web
引言 2023年,前端开发作为IT行业中备受关注的领域之一,正在经历着巨大的挑战和变革。然而,在当前行业不景气、失业率居高不下以及裁员潮席卷而来的情况下,许多人开始质疑前端开发的未来前景以及学习它是否依然有意义。本文将探讨这个问题并试图给出一些启示。 第一部...
继续阅读 »

引言


image.png
2023年,前端开发作为IT行业中备受关注的领域之一,正在经历着巨大的挑战和变革。然而,在当前行业不景气、失业率居高不下以及裁员潮席卷而来的情况下,许多人开始质疑前端开发的未来前景以及学习它是否依然有意义。本文将探讨这个问题并试图给出一些启示。


第一部分:前端的价值


image.png
前端开发作为网页和移动应用程序开发的重要组成部分,扮演着连接用户与产品的桥梁。前端技术的发展不仅推动了用户体验的提升,也对整个互联网行业产生了深远的影响。随着移动互联网的普及和技术的进步,前端在用户与产品之间的交互变得越来越重要。


对于企业而言,拥有优秀的前端开发团队意味着能够提供更好的用户体验、增强品牌形象、吸引更多用户和扩大市场份额。因此,前端开发的技能依然是企业争相追求的核心能力之一。


第二部分:行业不景气的背后


image.png
然而,正如每个行业都经历高低起伏一样,前端开发也面临着行业不景气带来的挑战。2023年,全球经济增长乏力、市场竞争激烈以及萧条的就业市场等因素,使得许多公司紧缩预算、停止招聘,并导致了失业率的上升和裁员的潮水。


在这种情况下,前端开发者需要重新审视自己的技能和市场需求。他们需要具备综合能力,包括对最新前端技术的深入了解、与其他团队成员的良好沟通合作能力以及持续学习和适应变化的能力。


第三部分:自我调整与进阶


image.png
面对市场变化和就业压力,前端开发者需要主动调整自己的发展路径。以下是一些建议:



  1. 多元化技能:学习并精通多种前端框架和库,如React、Vue.js和Angular等。同时,了解后端开发和数据库知识,拥有全栈开发的能力,将会让你在就业市场上更具竞争力。

  2. 学习与实践并重:不仅仅是学习新知识,还要将所学应用于实际项目中。积累项目经验,并在GitHub等平台分享你的作品,以展示自己的能力和潜力。同时,参加行业内的比赛、活动和社区,与他人交流并学习他们的经验。

  3. 持续学习:前端技术发展日新月异,不断学习是必需的。关注行业的最新趋势和技术,参加培训、研讨会或在线课程,保持对新知识的敏感度和学习能力。


第四部分:面对就业市场的挑战


image.png
在面对行业不景气和裁员的情况下,重新进入就业市场变得更加具有挑战性。以下是一些建议:



  1. 提升个人竞争力:通过获得认证、实习或自主开发项目等方式,提升自己在简历中的竞争力。扩展自己的专业网络,与其他开发者和雇主建立联系。

  2. 寻找新兴领域:探索新兴的技术领域,如大数据、人工智能和物联网等,这些领域对前端开发者的需求逐渐增加,可能为你提供新的机会。

  3. 转型或深耕细分领域:如果市场需求不断减少,可以考虑转型到与前端相关的领域,如UI设计、交互设计或用户体验设计等。或者在前端领域深耕细分领域,在特定行业或特定技术方向上寻找就业机会。


结论


image.png
虽然当前的行业环境确实严峻,但前端开发作为连接用户与产品的重要纽带,在未来依然有着广阔的发展空间。关键在于前端开发者要不断自我调整与进阶,持续学习并适应市场需求。通过多元化技能、学习实践、提升个人竞争力以及面对市场挑战,前端开发者依然可以在这个变革

作者:Jony_men
来源:juejin.cn/post/7260330862289371173
时代中谋得一席之地。

收起阅读 »

树结构的数据扁平化

web
function flattenTree(data) { data = JSON.parse(JSON.stringify(data)); var res = []; while(data.length) { var n...
继续阅读 »

function flattenTree(data) {
data = JSON.parse(JSON.stringify(data));
var res = [];
while(data.length) {
var node = data.shift();
if (node.children && node.children.length) {
data = data.concat(node.children);
}
delete node.children;
res.push(node);
}
return res;
}


我们用一个数据来测试:



var tree = [{
id: 1,
name: '1',
children: [{
id: 2,
name: '2',
children: [{
id: 3,
name: '3',
children: [{
id: 4,
name: '4'
}]
}, {
id: 6,
name: '6'
}]
}]
}, {
id: 5,
name: '5'
}]


使用:



console.log(flattenTree(tree));


打印结果:


image.png


作者:tntxia
来源:juejin.cn/post/7260500913848090661
收起阅读 »

用Vue.js构建一个Web3应用像,像开发 Web2 一样熟悉

web
作为一名涉足去中心化网络的前端 JavaScript 开发人员,您可能遇到过许多 Web3 开发解决方案。但是,这些解决方案通常侧重于钱包集成和交易执行,这就造成了学习曲线,偏离了熟悉的 Web2 开发体验。 但不用担心!有一种解决方案可以无缝衔接 Web2...
继续阅读 »

作为一名涉足去中心化网络的前端 JavaScript 开发人员,您可能遇到过许多 Web3 开发解决方案。但是,这些解决方案通常侧重于钱包集成和交易执行,这就造成了学习曲线,偏离了熟悉的 Web2 开发体验。


但不用担心!有一种解决方案可以无缝衔接 Web2 和 Web3,它就是 Juno



网址:https://juno.build/



在本篇博文中,我们将探讨如何利用 Vue 和 Juno 的强大功能来开发去中心化应用程序(dApps)。加入我们的旅程,揭开 Juno 的神秘面纱,让您轻松创建非凡的去中心化体验!





导言


在我之前的博文中,我讨论了 React[1]Angular[2] 这两个流行的 JavaScript 前端框架的类似解决方案。如果这两个框架中的任何一个是您的首选,我建议您浏览这些具体的文章,以获得量身定制的见解。


Juno如何工作


如果你还不了解 Juno,它是一个功能强大的开源区块链即服务平台,旨在让去中心化应用程序开发变得更加容易。可以把它想象成一个无服务器平台,类似于谷歌Firebase或AWS Amplify等流行服务,但增加了区块链技术的优势。Juno 完全在区块链上运行您的应用程序,确保完全去中心化和安全的基础设施。


通过利用Internet Computer[3]区块链网络和基础设施,Juno 为您创建的每个应用程序引入了一个名为 “Satellites” 的独特概念。这些 Satellites 作为强大的智能合约,封装了您的整个应用程序,包括 JavaScript、HTML 和图像文件等网络资产,以及简单的数据库、文件存储和身份验证机制。通过 Juno,您可以完全控制应用程序的功能和数据。


构建一个 Dapp


让我们开始构建我们的第一个去中心化应用程序,简称“dapp”。在这个例子中,我们将创建一个笔记应用程序,允许用户存储和检索数据条目,以及上传文件。


本教程和代码示例使用了 Vue Composition API。


初始化


在将 Juno 集成到应用程序之前,需要创建一个 satellite。该过程在文档[4]中有详细的解释。


此外,还需要安装SDK。


npm i @junobuild/core

完成这两个步骤后,您可以在 Vue 应用程序的根目录(例如 App.vue)中使用 satellite ID 初始化 Juno。这将配置库与您的智能合约进行通信。


<script setup lang="ts">
import { onMounted } from 'vue'
import { initJuno } from '@junobuild/core'

onMounted(
  async () =>
    await initJuno({
      satelliteId'pycrs-xiaaa-aaaal-ab6la-cai'
    })
)
</script>

<template>
<h1>Hello World</h1>
</template>

配置完成!现在,您的应用程序已经可以用于 Web3 了!😎


身份验证


为了确保用户身份的安全性和匿名性,需要对用户进行登录和注销。要做到这一点,可以将相关函数绑定到应用程序中任何位置的 call-to-action 按钮。


<script setup lang="ts">
import { signIn, signOut} from '@junobuild/core'
</script>

<button @click="signIn">Sign-in</button>
<button @click="signOut">Sign-out</button>

为了与其他服务建立无缝集成,库和 satellite 组件在用户成功登录后自动在您的智能合约中生成新条目。此功能使库能够在任何数据交换期间有效地验证权限。


为了监视并深入了解该条目,从而访问有关用户状态的信息,Juno提供了一个名为authSubscribe() 的可观察函数。您可以根据需要灵活地多次使用此函数。然而,你也可以创建一个在整个应用中有效传播用户信息的 store。


import { ref, type Ref } from 'vue'
import { defineStore } from 'pinia'
import { authSubscribe, type User } from '@junobuild/core'

export const useAuthStore = defineStore('auth', () => {
  const user: Ref<User | null | undefined> = ref(undefined)

  const unsubscribe = authSubscribe((u) => (user.value = u))

  return { user, unsubscribe }
})

这样,在应用程序的顶层订阅它就变得非常方便。


<script setup lang="ts">
import { useAuthStore } from '../stores/auth.store'
import { storeToRefs } from 'pinia'

const store = useAuthStore()
const { user } = storeToRefs(store)
</script>

<template>
  <template v-if="user !== undefined && user !== null">
    <slot /
>
  </template>

  <template v-else>
    <p>Not signed in.</
p>
  </template>
</
template>

存储文档


Juno提供了一个名为“Datastore”的功能,旨在将数据直接存储在区块链上。Datastore 由一组集合组成,其中每个集合保存文档,这些文档由您选择的键唯一标识。


在本教程中,我们的目标是存储笔记,因此必须按照文档中提供的说明创建一个集合。为集合选择合适的名称,例如“notes”。


一旦设置好应用程序并创建了必要的集合,就可以利用库的 setDoc 函数将数据持久化到区块链上。此功能使您能够安全且不变地存储笔记。


import { setDoc } from "@junobuild/core";

// TypeScript example from the documentation
await setDoc<Example>({
  collection"my_collection_key",
  doc: {
    key"my_document_key",
    data: myExample,
  },
});

由于集合中的文档是通过唯一的密钥来标识的,因此我们使用 nanoid[5] 来创建密钥--这是一种用于 JavaScript 的微型字符串 ID 生成器。


<script lang="ts" setup>
import { ref } from 'vue'
import { setDoc } from '@junobuild/core'
import { nanoid } from 'nanoid'

const inputText = ref('')

const add = async () => {
  const key = nanoid()

  await setDoc({
    collection'notes',
    doc: {
      key,
      data: {
        text: inputText.value
      }
    }
  })
}
</script>

<template>
  <textarea rows="5" placeholder="Your diary entry" 
            v-model="inputText">
</textarea>

  <button @click="add">Add</button>
</template>


请注意,为简单起见,本教程提供的代码片段不包括适当的错误处理,也不包括复杂的表单处理。



检索文档列表


为了检索存储在区块链上的文档集合,我们可以使用库的 listDocs 函数。这个多功能函数允许加入各种参数,以方便数据过滤、排序或分页。


出于本教程的目的,我们将保持示例的简约性。我们的目标是在挂载组件时简单地列出所有用户数据。


<script lang="ts" setup>
import { listDocs } from '@junobuild/core'
import { onMounted, ref } from 'vue'

const items = ref([])

const list = async () => {
  const { items: data } = await listDocs({
    collection'notes'
  })

  items.value = data
}

onMounted(async () => await list())
</script>

<template>
  <p v-for="(item, index) in items">
    <span>
      {{ index + 1 }}
    </span>
    <span>{{ item.data.text }}</span>
  </p>
</template>

文件上传


在去中心化网络上存储数据是一项复杂的任务。然而,Juno 的设计旨在为需要轻松存储和检索用户生成内容(如照片或文件)的应用程序开发人员简化这一过程。


在处理文档时,第一步是按照文档[6]中提供的说明创建一个集合。在本教程中,我们将重点实施图片上传,因此该集合可以恰当地命名为 “images”。


为确保存储数据的唯一性和正确识别,每个文件都有唯一的文件名和路径。这一点非常重要,因为数据是在网络上提供的,每条数据都应该有一个独特的 URL。


要实现这一点,我们可以使用用户唯一ID的文本表示形式和每个上传文件的时间戳的组合来创建一个键。通过访问我们之前在存储中声明的属性,我们可以检索相应的用户键。


<script lang="ts" setup>
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth.store'
import { storeToRefs } from 'pinia'
import { uploadFile } from '@junobuild/core'

const file = ref(undefined)

const store = useAuthStore()
const { user } = storeToRefs(store)

const setFile = (f) => (file.value = f)

const upload = async () => {
  // Demo purpose therefore edge case not properly handled
  if ([nullundefined].includes(user.value)) {
    return
  }

  const filename = `${user.value.key}-${file.value.name}`

  const { downloadUrl } = await uploadFile({
    collection'images',
    data: file.value,
    filename
  })

  console.log('Uploaded', downloadUrl)
}
</script>

<template>
  <input type="file" @change="(event) => setFile(event.target.files?.[0])" />

  <button @click="upload">Upload</button>
</template>

一旦一个资源被上传,一个 downloadUrl 返回,它提供了一个直接的 HTTPS 链接,可以在web上访问上传的资源。


列出资源


为了检索存储在区块链上的资产集合,我们可以利用库提供的 listAssets 函数。这个函数在参数方面提供了灵活性,允许我们根据需要对文件进行过滤、排序或分页。


与前面的文档示例类似,我们将保持这个示例的简约性。


<script lang="ts" setup>
import { listAssets } from '@junobuild/core'
import { onMounted, ref } from 'vue'

const assets = ref([])

const list = async () => {
  const { assets: images } = await listAssets({
    collection'images'
  })

  assets.value = images
}

onMounted(async () => await list())
</script>

<template>
  <img loading="lazy" :src="asset.downloadUrl" v-for="asset in assets" />
</template>

部署 🚀


在开发和构建应用程序之后,下一步是将其部署到区块链上。为此,您需要在终端中执行以下命令来安装 Juno 命令行接口(CLI):


npm i -g @junobuild/cli

安装过程完成后,您可以按照文档[7]中的说明并从终端登录来访问您的 satellite。这将使你的机器能够控制你的 satellite。


juno login

最后,您可以使用以下命令部署项目:


juno deploy

恭喜你!您的 Vue dapp 现在已经上线,并完全由区块链提供支持。🎉


资源





原文:https://betterprogramming.pub/build-a-web3-app-with-vuejs-db1503ca20d2


是哒是哒说


参考资料


[1]

React: https://betterprogramming.pub/build-a-web3-app-with-react-js-6353825baf9a

[2]

Angular: https://levelup.gitconnected.com/develop-an-angular-app-on-blockchain-9cde44ae00b7

[3]

Internet Computer: https://internetcomputer.org/

[4]

文档: https://juno.build/docs/add-juno-to-an-app/create-a-satellite

[5]

nanoid: https://github.com/ai/nanoid

[6]

文档: https://juno.build/docs/build/storage#collections-and-rules

[7]

文档: https://juno.build/docs/miscellaneous/cli#login

[8]

https://juno.build/docs/intro: https://juno.build/docs/intro

[9]

GitHub 代码库: https://github.com/buildwithjuno/examples/tree/main/vue/diary



作者:程序员张张
来源:mdnice.com/writing/26615feb73924bb4821f543e0f041fa4
收起阅读 »

前端开发如何给自己定位?初级?中级?高级!

web
引言 在快速发展的互联网时代,前端开发一直处于高速增长的趋势中。作为构建用户界面和实现交互功能的关键角色,前端开发人员需要不断提升自己的技能和能力,以适应变化的行业需求。本文将为前端开发人员提供一个能力定位指南,帮助他们了解自己在前端领域的定位,内容参考阿里前...
继续阅读 »

引言


在快速发展的互联网时代,前端开发一直处于高速增长的趋势中。作为构建用户界面和实现交互功能的关键角色,前端开发人员需要不断提升自己的技能和能力,以适应变化的行业需求。本文将为前端开发人员提供一个能力定位指南,帮助他们了解自己在前端领域的定位,内容参考阿里前端面试指南,P6/P6+/P7的能力标准。


目录



0.掌握图形学,webgl或熟练使用threejs框架,熟练canvas相关渲染及动画操作的优先。

1.熟练掌握JavaScript。

2.熟悉常用工程化工具,掌握模块化思想和技术实现方案。

3.熟练掌握React前端框架,了解技术底层。同时了解vue以及angular等其他框架者优先。

4.熟练掌握react生态常用工具,redux/react-router等。

5.熟悉各种Web前端技术,包括HTML/XML/CSS等,有基于Ajax的前端应用开发经验。

6.有良好的编码习惯,对前端技术有持续的热情,个性乐观开朗,逻辑性强,善于和各种背景的人合作。

7.具有TS/移动设备上前端开发/NodeJS/服务端开发等经验者优先。



0.掌握图形学,webgl或熟练使用threejs框架,熟练canvas相关渲染及动画操作的优先。


初级:



  • 学习过图形学相关知识,知道矩阵等数学原理在动画中的作用,知道三维场景需要的最基础的构成,能用threejs搭3d场景,知道webgl和threejs的关系。

  • 知道canvas是干嘛的,聊到旋转能说出canvas的api。

  • 知道css动画,css动画属性知道关键字和用法(换句话说,电话面试会当场出题要求口喷css动画,至少能说对大概,而不是回答百度一下就会用)。

  • 知道js动画,能说出1~2个社区js动画库,知道js动画和css动画优缺点以及适用场景。

  • 知道raf和其他达到60fps的方法。


中级:



  • 如果没有threejs,你也能基于webgl自己封装一个简单的threejs出来。

  • 聊到原理能说出四元数,聊到鼠标操作能提到节流,聊到性能能提到restore,聊到帧说出raf和timeout的区别,以及各自在优化时候的作用。

  • 知道怎样在移动端处理加载问题,渲染性能问题。

  • 知道如何结合native能力优化性能。

  • 知道如何排查性能问题。对chrome动画、3d、传感器调试十分了解。


高级:



  • 搭建过整套资源加载优化方案,能说明白整体方案的各个细节,包括前端、客户端、服务端分别需要实现哪些功能点、依赖哪些基础能力,以及如何配合。

  • 设计并实现过前端动画引擎,能说明白一个复杂互动项目的技术架构,知道需要哪些核心模块,以及这些模块间如何配合。

  • 有自己实现的动画相关技术方案产出,这套技术方案必须是解决明确的业务或技术难点问题的。为了业务快速落地而封装一个库,不算这里的技术方案。如果有类似社区方案,必须能从原理上说明白和竞品的差异,各自优劣,以及技术选型的原因。


1.熟练掌握JavaScript。


初级:



  • JavaScript各种概念都得了解,《JavaScript语言精粹》这本书的目录都得有概念,并且这些核心点都能脱口而出是什么。这里列举一些做参考:

  • 知道组合寄生继承,知道class继承。

  • 知道怎么创建类function + class。

  • 知道闭包在实际场景中怎么用,常见的坑。

  • 知道模块是什么,怎么用。

  • 知道event loop是什么,能举例说明event loop怎么影响平时的编码。

  • 掌握基础数据结构,比如堆、栈、树,并了解这些数据结构计算机基础中的作用。

  • 知道ES6数组相关方法,比如forEach,map,reduce。


中级:



  • 知道class继承与组合寄生继承的差别,并能举例说明。

  • 知道event loop原理,知道宏微任务,并且能从个人理解层面说出为什么要区分。知道node和浏览器在实现loop时候的差别。

  • 能将继承、作用域、闭包、模块这些概念融汇贯通,并且结合实际例子说明这几个概念怎样结合在一起。

  • 能脱口而出2种以上设计模式的核心思想,并结合js语言特性举例或口喷基础实现。

  • 掌握一些基础算法核心思想或简单算法问题,比如排序,大数相加。


2.熟悉常用工程化工具,掌握模块化思想和技术实现方案。


初级:



  • 知道webpack,rollup以及他们适用的场景。

  • 知道webpack v4和v3的区别。

  • 脱口而出webpack基础配置。

  • 知道webpack打包结果的代码结构和执行流程,知道index.js,runtime.js是干嘛的。

  • 知道amd,cmd,commonjs,es module分别是什么。

  • 知道所有模块化标准定义一个模块怎么写。给出2个文件,能口喷一段代码完成模块打包和执行的核心逻辑。


中级:



  • 知道webpack打包链路,知道plugin生命周期,知道怎么写一个plugin和loader。

  • 知道常见loader做了什么事情,能几句话说明白,比如babel-loader,vue-loader。

  • 能结合性能优化聊webpack配置怎么做,能清楚说明白核心要点有哪些,并说明解决什么问题,需要哪些外部依赖,比如cdn,接入层等。

  • 了解异步模块加载的实现原理,能口喷代码实现核心逻辑。


高级:



  • 能设计出或具体说明白团队研发基础设施。具体包括但不限于:

  • 项目脚手架搭建,及如何以工具形态共享。

  • 团队eslint规范如何设计,及如何统一更新。

  • 工具化打包发布流程,包括本地调试、云构建、线上发布体系、一键部署能力。同时,方案不仅限于前端工程部分,包含相关服务端基础设施,比如cdn服务搭建,接入层缓存方案设计,域名管控等。

  • 客户端缓存及预加载方案。


3.熟练掌握React前端框架,了解技术底层。同时了解vue以及angular等其他框架者优先。


初级:



  • 知道react常见优化方案,脱口而出常用生命周期,知道他们是干什么的。

  • 知道react大致实现思路,能对比react和js控制原生dom的差异,能口喷一个简化版的react。

  • 知道diff算法大致实现思路。

  • 对state和props有自己的使用心得,结合受控组件、hoc等特性描述,需要说明各种方案的适用场景。

  • 以上几点react替换为vue或angular同样适用。


中级:



  • 能说明白为什么要实现fiber,以及可能带来的坑。

  • 能说明白为什么要实现hook。

  • 能说明白为什么要用immutable,以及用或者不用的考虑。

  • 知道react不常用的特性,比如context,portal。

  • 能用自己的理解说明白react like框架的本质,能说明白如何让这些框架共存。


高级:



  • 能设计出框架无关的技术架构。包括但不限于:

  • 说明如何解决可能存在的冲突问题,需要结合实际案例。

  • 能说明架构分层逻辑、各层的核心模块,以及核心模块要解决的问题。能结合实际场景例举一些坑或者优雅的处理方案则更佳。


4.熟练掌握react生态常用工具,redux/react-router等。


初级:



  • 知道react-router,redux,redux-thunk,react-redux,immutable,antd或同级别社区组件库。

  • 知道vue和angular对应全家桶分别有哪些。

  • 知道浏览器react相关插件有什么,怎么用。

  • 知道react-router v3/v4的差异。

  • 知道antd组件化设计思路。

  • 知道thunk干嘛用的,怎么实现的。


中级:



  • 看过全家桶源码,不要求每行都看,但是知道核心实现原理和底层依赖。能口喷几行关键代码把对应类库实现即达标。

  • 能从数据驱动角度透彻的说明白redux,能够口喷原生js和redux结合要怎么做。

  • 能结合redux,vuex,mobx等数据流谈谈自己对vue和react的异同。


高级:



  • 有基于全家桶构建复杂应用的经验,比如最近很火的微前端和这些类库结合的时候要注意什么,会有什么坑,怎么解决


5.熟悉各种Web前端技术,包括HTML/XML/CSS等,有基于Ajax的前端应用开发经验。


初级:



  • HTML方面包括但不限于:语义化标签,history api,storage,ajax2.0等。

  • CSS方面包括但不限于:文档流,重绘重排,flex,BFC,IFC,before/after,动画,keyframe,画三角,优先级矩阵等。

  • 知道axios或同级别网络请求库,知道axios的核心功能。

  • 能口喷xhr用法,知道网络请求相关技术和技术底层,包括但不限于:content-type,不同type的作用;restful设计理念;cors处理方案,以及浏览器和服务端执行流程;口喷文件上传实现;

  • 知道如何完成登陆模块,包括但不限于:登陆表单如何实现;cookie登录态维护方案;token base登录态方案;session概念;


中级:



  • HTML方面能够结合各个浏览器api描述常用类库的实现。

  • css方面能够结合各个概念,说明白网上那些hack方案或优化方案的原理。

  • 能说明白接口请求的前后端整体架构和流程,包括:业务代码,浏览器原理,http协议,服务端接入层,rpc服务调用,负载均衡。

  • 知道websocket用法,包括但不限于:鉴权,房间分配,心跳机制,重连方案等。

  • 知道pc端与移动端登录态维护方案,知道token base登录态实现细节,知道服务端session控制实现,关键字:refresh token。

  • 知道oauth2.0轻量与完整实现原理。

  • 知道移动端api请求与socket如何通过native发送,知道如何与native进行数据交互,知道ios与安卓jsbridge实现原理。


高级:



  • 知道移动端webview和基础能力,包括但不限于:iOS端uiwebview与wkwebview差异;webview资源加载优化方案;webview池管理方案;native路由等。

  • 登陆抽象层,能够给出完整的前后端对用户体系的整体技术架构设计,满足多业务形态用户体系统一。考虑跨域名、多组织架构、跨端、用户态开放等场景。

  • mock方案,能够设计出满足各种场景需要的mock数据方案,同时能说出对前后端分离的理解。考虑mock方案的通用性、场景覆盖度,以及代码或工程侵入程度。

  • 埋点方案,能够说明白前端埋点方案技术底层实现,以及技术选型原理。能够设计出基于埋点的数据采集和分析方案,关键字包括:分桶策略,采样率,时序性,数据仓库,数据清洗等。


6.有良好的编码习惯,对前端技术有持续的热情,个性乐观开朗,逻辑性强,善于和各种背景的人合作。


初级:



  • 知道eslint,以及如何与工程配合使用。

  • 了解近3年前端较重要的更新事件。

  • 面试过程中遇到答不出来的问题,能从逻辑分析上给出大致的思考路径。

  • 知道几个热门的国内外前端技术网站,同时能例举几个面试过程中的核心点是从哪里看到的。


高级:



  • 在团队内推行eslint,并给出工程化解决方案。

  • 面试过程思路清晰,面试官给出关键字,能够快速反应出相关的技术要点,但是也要避免滔滔不绝,说一堆无关紧要的东西。举例来说,当时勾股老师面试我的时候,问了我一个左图右文的布局做法,我的回答是:我自己总结过7种方案,其中比较好用的是基于BFC的,float的以及flex的三种。之后把关键css口喷了一下,然后css就面完了。


7.具有TS/移动设备上前端开发/NodeJS/服务端开发等经验者优先。



  • 根据了解的深度分初/中/高级。

  • 知道TS是什么,为什么要用TS,有TS工程化实践经验。

  • 知道移动端前端常见问题,包括但不限于:rem + 1px方案;预加载;jsbridge原理等。

  • 能说出大概的服务端技术,包括但不限于:docker;k8s;rpc原理;中后台架构分层;缓存处理;分布式;响应式编程等。


5. 结论与进一步学习


本文为前端开发人员提供了一个能力定位指南,帮助他们了解自己在前端领域的定位,并提供了具体的代码示例来巩固学习成果。通过不断学习和实践,前端开发人员可以逐步提升自己的能力,从初级到中级再到高级。但请注意,在实际工作中,不同公司和项目对于各个级别的要求可能会有所不同。


为了进一步提高自己的水平,前端开发人员可以考虑以下学习路径和资源:



  • 阅读官方文档和教程,如MDN、React官方文档等;

  • 参与开源项目,并与其他开发人员进行交流和合作;

  • 关注前端开发的博客和社区,如Medium、Stack Overflow等;

  • 参加在线或线下的前端开发培训课程;

  • 阅读经典的前端开发书籍,如《JavaScript高级程序设计》、《CSS权威指南》等。


通过持续学习和实践,相信每个前端开发人员都可以不断成长,并在前端领域中取得更好的成就。祝愿大家在前端开

作者:Jony_men
来源:juejin.cn/post/7259961208794628151
发的道路上越走越远!

收起阅读 »

给同学解决问题有感——天下前端是一家!

web
   在毕设如火如荼进行的过程中,大家设计xxx系统时都会有各种各样的界面,这不就到了本菜鸟的领域!hhh,小时候的画家梦也算实现了一半,只不过画笔变成了code~    最近,给两位同学解决了前端方面的问题,但都不是我学的javascript语言,摸索着平时...
继续阅读 »

   在毕设如火如荼进行的过程中,大家设计xxx系统时都会有各种各样的界面,这不就到了本菜鸟的领域!hhh,小时候的画家梦也算实现了一半,只不过画笔变成了code~


   最近,给两位同学解决了前端方面的问题,但都不是我学的javascript语言,摸索着平时学到的前端思想,还是成功的解决了这些问题,有感而发,记录下来~



  •    第一位出场的是正在自学python的学习委员,也是一位准研究生。他遇到的问题是,在a项目里定义了一个复杂界面,在b项目里定义了一个简单页面。他找到我的时候说,启动了b项目,但打开的却是a项目定义的页面。报错如下:


d60d9f2414f5a2867091a5a14db6e54.png
    看了他的页面,这路由和我学的不长得一毛一样!


8bb4fc7bab1580d1baa6692d0f2b801.png
  打开他的浏览器页面,看看页面的网络请求,404,我第一反应会不会是他路由的问题,导致找不到这个页面,显示了之前项目的界面。。。


dc111956dfdb5bc954fd74c3bfdea11.png
  但我转念一想,404 应该不会显示另一个项目的界面呀,除非请求的是之前项目的服务器。再注意到warning中的,use a server instead,这不就是换一个服务器,那一定是之前的端口被占用了,所以相当于请求之前的服务器。于是,搬出来我只会一个cd的小黑窗:


1684658842987.png
   解决占用之后,重启项目,完整的展示了新项目中的页面~
这个问题准确的说属于计网,但前后端的思想还是在里面。果然还是基础的东西~



  • 第二位出场的是一位在做安卓应用(毕设项目)的女同学,躺在床上收到她的连环问,导航栏隐藏?我直接惊坐起:


1684659076469.png
   仔细听了她的描述之后,在有了导航栏之后,页面某些按钮的位置发生了偏差,如下图:


4637c8740c325359481bc6fb139af42.png
  原本卡其色圆圈应该和下面蓝色圆圈重合,通关之后,显示下面的颜色。
因为下面一层的按钮是嵌套的背景图片里,所以不知道固定位置,不能用绝对定位控制两个按钮的位置完全重合(她是用可视化拖动的方法做的页面)。
让导航栏透明肯定是有办法的,但我想如果只透明 但仍然占据文档流,那还是没用呀!


1684659743090.png
  找到网友的方法,尝试之后,模拟机显示确实ok,隐藏了导航栏,位置也消除了偏差,在鼠标接触到导航栏位置时,导航栏显现,很人性化!但她在手机上通过apk安装包查看,还是有一定的偏差,我想是因为屏幕尺寸的问题吗?看了他的代码用的都是相对单位dp,应该可以自适应的呀,这个就不懂了,毕竟适配所有机型的问题,我在实习的时候也很头疼!




PS:
在此应该鸣谢一下我的老师和队友,坚定让我自己做了前后端分离的一个项目,自己建数据库,自己写接口,对前后端请求的
作者:MindedMier
来源:juejin.cn/post/7235458133505491005
发送接收还是有更细致的了解!
收起阅读 »

这一篇浏览器事件循环,可能会颠覆部分人的对宏任务和微任务的理解🤪🤪🤪

web
在这两天里看到一篇文章,发现好像很多人都把事件循环给搞混了,到底是宏任务先执行还是微任务先执行。在写这篇文章之前,我也随机挑选了几位幸运观众来问这个问题,好像大多都是说微任务先执行。 那么从这篇文章里,我们就来探讨一下到底是哪个先执行。 什么是进程 进程是计算...
继续阅读 »

在这两天里看到一篇文章,发现好像很多人都把事件循环给搞混了,到底是宏任务先执行还是微任务先执行。在写这篇文章之前,我也随机挑选了几位幸运观众来问这个问题,好像大多都是说微任务先执行。


那么从这篇文章里,我们就来探讨一下到底是哪个先执行。


什么是进程


进程是计算机系统中正在运行的程序的实例。它是操作系统对一个正在运行的程序的抽象表示,负责管理程序的执行和资源分配。


通常它包括堆栈,例如临时数据,如函数参数、返回地址和局部变量和数据段,其中数据段包括全局变量。


进程还可能包括堆,这是在进程运行时动态分配的内存。在 JavaScript 中,堆和栈的内存分配是通过不同方式进行的:




  1. 堆内存分配:



    • JavaScript 中的对象、数组和函数等复杂数据类型都存储在堆内存中;

    • 使用 new 关键字或对象字面量语法创建对象时,会在堆内存中动态分配相应的内存空间;

    • 堆内存的释放由垃圾回收机制自动处理,当一个对象不再被引用时,垃圾回收机制会自动回收其占用的堆内存,释放资源;




  2. 栈内存分配:



    • JavaScript 中的基本数据类型,如数字、布尔值和字符串以及函数的局部变量保存在栈内存中;

    • 栈内存的分配是静态的,编译器在编译阶段就确定了变量的内存空间大小;

    • 当函数被调用时,会在栈内存中创建一个称为栈帧 stack frame 的数据结构,用于存储函数的参数、局部变量、返回地址等信息;

    • 当函数执行完毕或从函数中返回时,对应的栈帧会被销毁,栈内存中的数据也随之释放;




在操作系统中,每个进程都有自己的地址空间、状态和控制信息。进程可以独立运行,与其他进程离开来,互不干扰。它们可以同时进行,并通过进程间通信机制进行交互。


进程在执行时会改变状态。进程状态,部分取决于进程的当前活动。每个进程可能处于以下状态:



  • 新的: 进程正在创建;

  • 运行: 指令正在运行;

  • 等待: 进程等待发生某个时间,如 I/O 完成或收到信号;

  • 就绪: 进程等待分配处理器;

  • 终止: 进程已经完成执行;


下图完整地显示了一个状态图:
20230725131814


什么是线程


线程是进程中的一个执行路径,是进程的组成部分。在同一个进程中的多个线程共享进程的资源,如内存空间和文件句柄等。不同线程之间可以并发执行,各自堵路地完成特定的任务。


进程和线程之间的关系如下:



  • 一个进程可以创建多个线程,这些线程共享同一个地址空间和资源,能够并发地执行任务;

  • 线程是在进程内部创建和销毁的,它们与进程共享进程的上下文,包括打开的文件、全局变量和堆内存等;

  • 每个进程至少包含一个主线程,主线程用于执行进程的主要业务逻辑。其他线程可以作为辅助线程来完成特定的任务;


主线程是一个程序中的特殊线程,它是程序的入口点和主要执行线程。


在许多编程语言和操作系统中,主线程是程序启动后自动创建的线程,负责执行程序的主要业务逻辑。主线程会按照顺序执行代码,从程序的入口点开始,直到程序结束或主线程显式终止。


你可以理解成一个篮球场,整个篮球场就是一个进程,就是一块能供线程使用的内存。


而线程就是篮球场上的每一个队员,每个人都有不同的职责。


Chrome: 多进程架构浏览器


许多网站包含活动内容,如 JavaScriptFlushHTML5 等,以便提供丰富的、动态的 Web 浏览体验。遗憾的是,这些 Web 应用程序也可能包含软件缺陷,从而导致响应迟滞,有的甚至网络浏览器崩溃。如果一个 Web 浏览器只对一个网站进行浏览,那么这不是一个大问题。


但是,现代 Web 浏览器提供标签是浏览,它运行 Web 浏览器的一个实例,同时打开多个网站,而每个标签代表一个网站。要在不同网站之间切换,用户只需点击响应的标签。这种安排如下图所示:


20230725154126


这种方法的一个问题是: 如果任何标签的 Web 应用程序崩溃,那么整个进程,包括所有其他标签所显示的网站也会崩溃。


GoogleChrome Web 浏览器通过多进程架构的设计解决这以问题。Chrome 具有多种不同类型的进程,这里我们主要讲讲其中三个:



  • 浏览器进程: 主要负责界面显示、用户交互、子进程管理,同时提供存储等功能;

  • 网络进程: 网络进程负责处理浏览器内发起的所有网络请求,例如加载网页、资源文件,如图片、CSS JavaScriptXMLHttpRequest 和 Fetch API 等请求;

  • 渲染进程: 主要负责渲染网页的逻辑。主要处理 HTML、Javascript、图像等等。一般情况下,对应于新标签的每个网站都会创建一个新的渲染进程。因此,可能会有多个渲染进程同时活跃;


如下图所示:
20230725155622


例如我只打开了两个标签也,浏览器就会开辟两个两个不同的进程,两者之间相互独立,一个崩掉了不会另外一个。



这个目前是这样子的,但是后续可能会改,根据 chrome 文档说明,后期可能会修改成每个站点开启一个进程,例如,你访问 b 站,而再 b 站里面的所有页面都不会开启新的渲染进程了,详情请看 chrome 官方文档



渲染主线程是如何工作的


渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:



  • 解析 HTML;

  • 解析 CSS;

  • 计算样式;

  • 布局;

  • 处理图层;

  • 每秒把⻚面画 60 次;

  • 执行全局 JavaScript 代码;

  • 执行事件处理函数;

  • 执行计时器的回调函数;


等等事情,当浏览器的网络线程收到 HTML 文档后,会产生一个渲染任务,并将其传递给渲染主线程的消息队列,在事件循环的作用下,渲染主线程取出消息队列中的渲染任务,并开启渲染流程。整个过程分为多个阶段,分别是: 解析 HTML、样式计算、布局、分层、绘制、分块、光栅化、画,每个阶段都有明确的输入输出,上一个阶段的输出会成为下一个阶段的输入。


首先解析的是 HTML 文档,这里我们忽略 CSS 的文件,如果主线程解析到 script 位置,会停止解析 HTML,转而等待 JavaScript 文件下载好,主线程将 JavaScript 代码解析执行完成后,才能继续解析 HTML。这是因为 JavaScript 代码的执行过程中可能会修改当前的 DOM 树,所以 DOM 树的生成必须暂停。这就是 JavaScript 会阻塞 HTML 解析的根本原因。
20230726082307



前面的这句话中有两个关键字很关键字,画好重点,它们分别是 下载解析



要处理这么多的事情,浏览器给渲染进程采用了多线程,它主要包含了以下线程:



  • 主线程: 主线程负责解析 HTMLCSSJavaScript,构建 DOM 树、CSSOM 树和渲染树,并进行页面布局和绘制。它还处理用户交互,执行 JavaScript 代码以及其他页面渲染相关的任务;

  • 合成线程: 合成线程负责将渲染树转换为图层,并执行图层的合成操作;

  • 网络线程: 网络线程负责处理网络请求和数据传输。当浏览器需要加载网页、图片或其他资源时,网络线程负责发送请求并接收响应数据;

  • 定时器线程: 定时器线程负责管理定时器事件,包括 setTimeoutsetInterval 等。它用于在指定的时间间隔内触发预定的任务;

  • 事件处理线程: 当用户进行交互操作,如点击按钮、滚动页面、输入文本等,需要触发相应的事件处理函数;


由于主线程和合成线程是并行执行的,这就可能导致这两个线程之间存在数据交互的问题。例如,当主线程和合成线程都需要访问相同的共享资源时,就需要进行同步,以避免竞态条件等问题。


这里就设计到消息队列的作用: 主线程和合成线程之间通过消息队列进行通信。主线程将渲染任务和图层数据等信息封装成消息,并将消息放入消息队列中。合成线程从消息队列中获取消息,并执行相应的图层合成操作。


浏览器中出现消息队列是为了处理异步任务和事件。在浏览器当中,有许多人任务是在后台执行或者将来某个事件触发时才执行的,例如:



  • 异步操作: 比如通过 AJAX 请求从服务器获取数据、读取本地文件等,这些操作需要等待网络请求或者文件读取完成后再处理响应的数据;

  • 定时器: 通过 setTimeoutsetInterval 设置的定时器任务,需要在指定的时间间隔后执行;

  • 事件处理: 当用户进行交互操作,如点击按钮、滚动页面、输入文本等,需要触发相应的事件处理函数;


20230726091257


如上图所示,当渲染主线程正在执行一个 JavaScript 函数,执行到一半的时候用户点击了按钮或者碰到了一个定时器,也就是 setTimeout。因为在我们的渲染进程里面是有定时器线程的,定时器线程监听到有这个定时器操作。那么该线程会将 setTimeout 里面的事件处理函数 (setTimeout 的第一个回调函数) 作为一个任务拿去排队。


因为消息队列采用的是队列的数据结构,当渲染主线程将所有任务情况之后,然后从消息队列中拿去最旧的那个任务,假设消息队列之前没有任务的情况下,就拿出 setTimeout 这个事件处理函数。如果在该函数当中又遇到了类似的事件处理函数或者定时器,按照前面的步骤。依此循环,直到所有任务执行完成。


整个过程,就被称之为事件循环。


什么是异步


JavaScript 是一门单线程的编程语言.意味着在一个特定的时间点,只能有一个代码块在执行。当执行一个同步任务时,如果任务需要很长时间才能完成,如网络请求、文件读取等,整个程序会被阻塞,导致用户界面无响应,甚至造成卡顿的问题。这种情况在 Web 应用中尤其常见,因为 JavaScript 经常与网络请求、DOM 操作等耗时任务打交道。


常见的异步操作包括:



  • 网络请求: 发送 HTTP 请求并等待服务器响应时,通常使用异步方式,以允许程序继续执行其他操作;

  • 定时器: 设置定时器,在一段时间后执行某个任务,也是异步操作的一种;

  • 事件处理: 为 DOM 元素注册事件监听器是一种常见的异步任务。当特定事件触发时,相应的事件处理函数将被异步调用,例如 addEventListener


渲染主线程负责处理网页的构建、布局、绘制和用户交互等任务,而异步编程使得我们可以在主线程执行同步代码的同时,处理耗时的异步操作,例如网络请求、文件读写等,以提高程序的性能和用户体验。在 JavaScript 中,通过事件循环机制,异步编程实现了一种非阻塞的执行方式,使得浏览器能够高效地处理各种任务,同时保持用户界面的响应性。


例如你要执行一个 setTimeout:


setTimeout(() => {
console.log(111);
}, 3000);

console.log(222);

在这段代码当中,如果让渲染主线程去等待这个定时器任务执行完再去执行下一个任务,就会导致主线程长期处于阻塞的状态,从而导致浏览器页面长期见不到效果,可能要砸电脑了。


20230726095635



整个流程你可以理解为这样,但也可能不完全正确。



等到整个计时结束,再执行 console.log(222) 的代码,这种模式就叫作同步。整个时候消息队列还有很多任务在等待,可能还存在一些渲染页面的任务,有可能直接导致整个页面卡死。


所以为了这个问题,浏览器采用了异步的方式来解决这个问题,因为渲染主线程承担着及其重要的工作,无论如何都不能阻塞。


20230726100527


当计时开始之后,我就不管你了,就例如你是服务,餐厅里面来了客人,客人点完了菜,你把菜单交给后厨,你就可以先不管了,继续服务下一个客人,也就是从消息队列中拿下一个任务。当计时结束之后,会把该回调函数放入到消息队列末尾.等到剩下的任务完成之后,你就可以给客人端菜了。


任务优先级


任务没有优先级,在消息队列中先进先出,但消息队列是有优先级的。


事件循环在过去的说法中,任务分为两个队列,一个是宏任务,一个是微任务。但是现在已经没有了宏任务的说法了。



因为我在年初的时候就写过相关事件循环的文章,且有在 mdn 上搜索过宏任务的相关概念,但现在在 mdn 已经完全搜索不到了。



根据 W3C 的最新解释:



  • 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。在一次事件循环当中,浏览器可以根据实际情况从不同的队列中取出任务执行;

  • 浏览器必须准备好一个微任务队列,微队列中的任务优先所有其他任务执行;


相关 W3C 连接


Chrome 的实现中,至少包含了下面的队列:



  • 延时队列: 用于存放计时器到达后的回调任务,优先级 ;

  • 交互队列: 用于存放用户操作后产生的事件任务,优先级 ;

  • 微队列: 用户存放需要最快执行的任务,优先级最高;


虽然浏览器最新规范是这样,但是你用之前的宏任务和微任务去答题也完全没有问题的,但是输出的顺序是完全没有变的,况且这篇文章主要内容也不是讲这个,那么在之后的代码中我们就继续以宏任务和微任务的来讲。


宏任务和微任务 重点来啦!!!


宏任务是一组异步任务,这些任务通常由浏览器的事件触发器发起,并在主线程中按照顺序执行。常见的宏任务包括:



  • setTimeoutsetInterval;

  • I/O 操作,例如读取文件、网络请求;

  • DOM 事件,例如点击事件、输入事件;

  • requestAnimationFrame;

  • script 标签;


微任务是一个细微的异步任务,它的执行时机在宏任务之后、渲染之前。微任务通常在一个宏任务执行完毕后立即执行,而不需要等待其他宏任务。这使得微任务的执行优先级比宏任务高。常见的微任务包括:



  • Promiseresolvereject 回调;

  • async/await 中的异步函数;

  • MutationObserver;


很重要的一点来了,为什么说 script 标签是宏任务呢?


如果忘记了,你再看看我们前面中说到的 下载解析 两个关键字。


script 标签包含的 JavaScript 代码在浏览器中执行时,被认为是宏任务。这是因为执行 script 标签内的代码需要进行一系列的操作,包括解析、编译和执行。主要有以下几个理由:



  • 解析和编译: 当浏览器遇到 script 标签时,它会停止当前的文档解析过程,并开始解析 script 内的 JavaScript 代码。解析器将逐行读取代码,并将其转换为可执行的内部表示形式。这个解析和编译的过程是一个比较耗时的操作,需要占用大量的 CPU 资源;

  • 阻塞页面渲染: 由于脚本的执行通常会修改当前页面的结构和样式,浏览器必须等待脚本执行完毕后再进行页面的渲染。也就是说,当浏览器执行 script 标签时,它会阻塞页面的渲染,直到脚本执行完毕才会继续渲染;

  • 可能引起网络请求:在 script 标签中,可以使用外部的 JavaScript 文件引用,例如 <script src="example.js"></script>。当浏览器遇到这样的情况时,它会发起一个网络请求去下载该文件,并等待文件下载完成后再执行。网络请求通常是一个比较耗时的操作,因此将其作为宏任务可以确保脚本的执行按照正确的顺序进行;


总结起来,script 标签被认为是宏任务是因为它需要解析、编译和执行 JavaScript 代码,并且会阻塞页面的渲染。此外,如果使用了外部 JavaScript 文件,还可能引起网络请求,进一步增加了执行时间。这些特性使得 script 标签的执行与其他微任务(如 Promise)不同,被归类为宏任务。


如果你依然觉得理由不够充分的话,请看以下代码:


<!-- 脚本 1 -->
<script>
// 同步
console.log("start1");
// 异步宏
setTimeout(() => console.log("timer1"), 0);
new Promise((resolve, reject) => {
// 同步
console.log("p1");
resolve();
}).then(() => {
// 异步微
console.log("then1");
});
// 同步
console.log("end1");
</script>

<!-- 脚本 2 -->
<script>
// 同步
console.log("start2");
// 异步宏
setTimeout(() => console.log("timer2"), 0);
new Promise((resolve, reject) => {
// 同步
console.log("p2");
resolve();
}).then(() => {
// 异步微
console.log("then2");
});
// 同步
console.log("end2");
</script>

该代码的输出结果如下所示:
20230726105526


如果 script 标签不是宏任务,普通任务的话,是不是应该先执行 start2end2 再执行 then1


所以根据此结论,整个浏览器循环应该是 先执行 宏任务 -> 同步代码 -> 微任务,直到当前宏任务中的微任务清理完毕,继续执行下一个宏任务,以此类推。


最后我再抛出一个问题,没有你这个 script 这个宏任务的出现,你哪来的微任务?


一个事件循环过程模型如下,当调用栈为空时,执行以下步骤:



  1. 选择任务队列中最旧的任务(队列是一个先进先出的队列,最旧的那个就是最先进的,这里是 任务 A);

  2. 如果任务 A为空(意味着任务队列为空),跳转到第6步;

  3. 将当前运行的任务设置为任务 A;

  4. 运行任务 A,意味着运行回调函数;

  5. 运行结束,将当前的任务设置为空,删除任务 A;

  6. 执行微任务队列:

    1. 选择微任务队列中最早的任务 X;

    2. 如果任务 X,代表这微任务为空,跳转到步骤6;

    3. 将当前运行的任务设置为任务 X,并运行该任务;

    4. 运行结束,将当前正在运行的任务设置为空,删除任务 X;

    5. 选择微任务队列中下一个最旧的任务,可以理解为第n+1个入队的,跳转到步骤2;

    6. 完成微任务队列;



  7. 跳转到第1步;


这个事件循环过程模型如下图所示:


image.png


值得注意的是,当一个任务在宏任务队列中正在运行时,可能会注册新事件,因此可能会创建新任务,下面是两个新创建的任务:



  • Promise.then(...) 是一个回调任务:当 promisefulfilled/rejected:任务将被推入当前轮事件循环中的微任务队列;当promisepending:任务将在下一轮事件循环中被推入微任务队列(可能是下一轮);


案例


接下来我们通过一些案例来加深对事件循环的理解。


案例一


setTimeout(() => {
console.log("time1");

new Promise((resolve) => {
resolve();
}).then(() => {
new Promise((resolve) => {
resolve();
}).then(() => {
console.log("then4");
});

console.log("then2");
});
});

new Promise((resolve) => {
console.log("p1");
resolve();
}).then(() => {
console.log("then1");
});

最后的输出结果为 p1 then1 time1 then2 then4,下面就来分析一下这个结果的由来:



  1. 代码首先遇到settimeout,是一个宏任务,里面的代码不会被执行;

  2. 接着代码往下执行,遇到 new Promise(...)中的回调函数是一个同步任务,直接执行;

  3. 直接输出 "p1",调用 resolve(),Promise 的状态变为 fuifilled,当 promise 状态变为 fulfilled/rejected时,任务将被推入当前轮事件循环中的微任务队列,所以后面的 then(...) 会被加入到微任务队列里面;

  4. 主线程中的同步代码执行完,从微任务中取出最旧的那个任务,也就是 then(...),输出 then1,此时微任务队列为空;

  5. 继续执行宏任务,也就是这个 settimeout,代码从上往下执行,首先输出 time1;

  6. 在下面的代码中又遇到了 new Promise(...),并且调用了 resolve(),then(...)被加入到微任务队列中,此时的同步任务已经执行完毕,直接执行这个 then(...);

  7. 又是遇到 new Promise(....),又是调用的 resolve(),所以 then() 方法会被添加到微任务队列中,代码往下执行,输出 "then2",此时微任务then(...)中的代码全部执行完毕;

  8. 此时同步任务执行完毕,继续执行微任务中的 then(...),输出 "then4";

  9. 所有代码运行完毕,程序结束;


案例二


<script>
console.log(1);

setTimeout(() => {
console.log(5);
});

new Promise((resolve) => {
resolve();
}).then(() => {
console.log(3);
});
console.log(2);
</script>

<script>
console.log(4);
</script>

这段代码的最后的输出结果是: 1 2 3 4 5,具体代码执行过程有以下步骤:
首先提醒一点,script 标签本身是一个宏任务,当页面出现多个 script 标签的时候,浏览器会把script 标签作为宏任务来解析。当前实例中两个 script 标签,它们会一次加入到宏任务队列中。



  1. console.log(...) 是同步代码,1首先会被输出,代码往下执行;

  2. 遇到 settimeout(),会被加入到宏任务队列中;

  3. then(...) 会被加入到微任务队列中,代码继续往下执行;

  4. console.log(...) 为同步认为输出 2;

  5. 此时同步任务执行完毕,转而执行微任务 then(...),输出 3;

  6. 当前宏任务执行完毕,此时同步任务和微任务都为空,取出最旧的宏任务,也就是第二个 script 标签;

  7. 输出 4,此时同步代码和微任务队列都为空,继续执行下一个宏任务,也就是 settimeout;

  8. 输出 5;


案例三


async function foo() {
console.log("start");
await bar();
console.log("end");
}

async function bar() {
console.log("bar");
}

console.log(1);

setTimeout(() => {
console.log("time");
});

foo();

new Promise((resolve) => {
console.log("p1");
resolve();
}).then(() => {
console.log("p2");
});

console.log(2);

这段代码的最后的输出结果是: 1 start bar p1 2 end p2 time,下面就来分析一下这段代码的执行过程:



  1. 前面两个是函数定义,不执行,遇到 console.log(),输出 1;

  2. 代码继续往下执行,遇到 settimeout(),代码加入到宏任务队列之中,代码往下执行;

  3. 调用 foo,输出 start;

  4. await 等待 bar() 调用的返回结果;

  5. 执行 bar() 函数,输出 bar;

  6. await 相当于 Promise.then(...),代码被加入到微任务队列中,所以 end 还不执行;

  7. 代码往下执行,遇到 new Promise(...),p1 直接输出,then() 又继续被加入到微任务队列中;

  8. 代码继续往下执行,遇到 console.log(2),输出 2;

  9. 此时主线程代码快为空,执行微任务队列中最旧的那个任务,继续执行 await 后续代码,输出 end;

  10. 执行 then() ,输出 p2;

  11. 最后执行 settimeout,输出 time;


案例四


Promise.resolve()
.then(() => {
console.log(0);
return Promise.resolve(4);
})
.then((res) => {
console.log(res);
});

Promise.resolve()
.then(() => {
console.log(1);
})
.then(() => {
console.log(2);
})
.then(() => {
console.log(3);
})
.then(() => {
console.log(5);
})
.then(() => {
console.log(6);
});

这个案例中,因为每一个 then() 都是一个微任务,所以首先执行的是0,代码继续往下执行,输出同级的 then(),也就是输出 1


如果 Promise 内返回的对象具有可调用的 then() 方法,则会在微任务队列中再插入一个任务,这就慢了一拍,如果这个 then() 方法是来源于 Promise 的,则因为是异步又慢了一拍,所以一共是慢了拍,所以 Promise.resolve(4) 的结果等到 23 输出完成,console.log(res) 的结果才会被输出;


所以该案例的最终结果输出的是 0 1 2 3 4 5 6


参考资料



总结


整个浏览器循环应该是 先执行 宏任务 -> 同步代码 -> 微任务,直到当前宏任务中的微任务清理完毕,继续执行下一个宏任务,以此类推。


最后分享两个我的两个开源项目,它们分别是:



这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🥰🥰🥰


作者:Moment
来源:juejin.cn/post/7259927532249710653
收起阅读 »

今日算法09-青蛙跳台阶问题

web
一、题目描述 题目链接:leetcode.cn/problems/qi… 难易程度:简单 一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。 答案需要取模 1e9+7(1000000007),如计算初始结果为...
继续阅读 »

一、题目描述



题目链接:leetcode.cn/problems/qi…


难易程度:简单



一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。


答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。


示例1
输入:n = 2
输出:2

示例2
输入:n = 7
输出:21

示例3
输入:n = 0
输出:1




二、解题思路


动态规划


当 n 为 1 时,只有一种覆盖方法:





当 n = 2 时,有两种跳法:





跳 n 阶台阶,可以先跳 1 阶台阶,再跳 n-1 阶台阶;或者先跳 2 阶台阶,再跳 n-2 阶台阶。而 n-1 和 n-2 阶台阶的跳法可以看成子问题,该问题的递推公式为:





也就变成了斐波那契数列问题,参考:今日算法07-斐波那契数列


复杂度分析


时间复杂度 O(N) :计算 f(n) 需循环 n 次,每轮循环内计算操作使用 O(1) 。


空间复杂度 O(1) : 几个标志变量使用常数大小的额外空间。


三、代码实现


public int JumpFloor(int n) {
   if (n <= 2)
       return n;
   int pre2 = 1, pre1 = 2;
   int result = 0;
   for (int i = 2; i < n; i++) {
       result = pre2 + pre1;
       pre2 = pre1;
       pre1 = result;
  }
   return result;
}


推荐阅读



封面




今日算法系列,题解更新地址:studeyang.tech/2023/0725.h…



作者:杨同学technotes
来源:juejin.cn/post/7259543257708658747
收起阅读 »

媒体查询,响应式设计?帮帮我!

web
什么是媒体查询?媒体查询是一种 CSS 语言特性,它允许作者根据设备或窗口的特性有条件地应用 CSS 规则来查看应用程序。最常见的情况是根据视口宽度应用 CSS 规则,这样 CSS 作者就能根据窗口或设备的大小创建相应的组件和布局。但这也可能延伸到用户是否偏好...
继续阅读 »

什么是媒体查询?
媒体查询是一种 CSS 语言特性,它允许作者根据设备或窗口的特性有条件地应用 CSS 规则来查看应用程序。最常见的情况是根据视口宽度应用 CSS 规则,这样 CSS 作者就能根据窗口或设备的大小创建相应的组件和布局。但这也可能延伸到用户是否偏好浅色或深色模式,甚至是用户的可访问性偏好,以及更多属性。



什么是响应式设计?


随着各种设备类型和屏幕尺寸的增多,网络应用程序为用户提供更加量身定制的可视化展示,并针对用户首选交互方式的屏幕尺寸进行优化,已变得越来越重要。


响应式设计可以通过多种技术组合来实现,包括有条件地应用 CSS 规则的媒体查询、容器查询,以及根据它们所包含的内容(例如 flexbox 或 grid)选择灵活的布局。在本文中,我们将重点关注媒体查询和响应式布局,但随着浏览器支持程度的增加,容器查询也需要记住。在撰写本文时,它们还没有准备好进入普及阶段,但可以用于渐进式增强


什么是移动优先设计?


移动优先设计是在设计和构建响应式 web 应用时可以采用的原则。理想情况下,这种方法应该在流程的所有阶段--从开始到结束--都作为指导原则。对于设计来说,这意味着原型或 UI 设计的第一次迭代应该专注于移动的体验,然后再转向更宽的视口尺寸。


虽然你可以从另一个方向(宽优先)来处理 Web 应用程序,但随着屏幕空间的增加,在视觉上重新组织组件要比试图将组件塞进更小的屏幕空间容易得多。


类似的规则也适用于开发过程。一般来说,您应该为基本情况(最窄的屏幕)编写标记和样式,并在必要时逐步为更宽的屏幕应用条件样式。


虽然你可以从另一个方向来处理这个问题,或者使用窄优先和宽优先的混合方法,但这会使你的样式更难以理解,并增加了其他人在审查或维护时的精神负担。当然,也有一些例外情况,编写少量的宽优先规则会更简单,所以请谨慎行事。


CSS 像素对比设备像素


当苹果在2011年推出 iPhone 4时,它是第一款采用高密度显示屏的主流智能手机。早期的 iPhone 的显示分辨率为320x480px,当 iPhone 推出所谓的 “视网膜显示屏” 时 -- 在相同的物理显示宽度下,分辨率提高了一倍,达到640x960px -- 这带来了挑战。不希望用户面临他们不断问自己的情况,“这是什么,蚂蚁的网站?”,一个巧妙的解决方案被设计了出来,iPhone 4将遵循 CSS 规则,就好像它仍然是一个320 x480 px 的设备,并简单地以两倍的密度渲染。这使得现有的网站可以按预期运行,而不需要任何代码更改 - 当为 Web 引入新技术时,您会发现这是一个常见的主题。


由此,创建了术语 CSS 像素和设备像素。


W3C CSS 规范将设备像素定义为:



设备像素是设备输出上能够显示其全部颜色范围的最小面积单位。



CSS 像素(也称为逻辑像素或参考像素)由 W3C CSS 规范定义为:



参考像素是设备像素密度为96 dpi 并且与读取器的距离为一臂长的设备上的一个像素的视角。因此,对于28英寸的标称臂长,视角约为0.0213度。因此,对于在臂长处的阅读,lpx 对应于约0.26mm(1/96英寸)。



参考像素的96 DPI 规则并不总是严格遵守,并且可以根据设备类型和典型的观看距离而变化。
设备像素比率(或 dppx)是每个 CSS 像素使用多少设备像素的一维因子。设备像素比通常是整数(例如,整数)。1、2、3),因为这使得缩放更简单,但并不总是(例如,1.25、1.5等)。


如何使我的网站响应?


默认情况下,移动的浏览器将假定网站的设计不适合这种设备的较窄视口。为了向后兼容,这些浏览器可能会呈现一个网站,就好像屏幕更大,然后缩小到适合更小的屏幕。这不是一个理想的体验,用户经常需要缩放和平移页面,但允许网站的功能主要是因为它最初创建的。


要告诉浏览器某个站点正在为所有视口大小提供优化的体验,您可以在文档中包含以下 Meta 标记:


<meta name="viewport" content="width=device-width, initial-scale=1" />

非矩形显示器


如今,一些设备具有圆角或显示遮挡(诸如显示凹口、相机孔穿孔或软件覆盖),这意味着整个矩形对于用于内容来说是不“安全”的,因为它可能被部分地或完全地遮挡。


默认情况下,此类设备上的浏览器将在“安全区”内接矩形和与文档背景匹配的垂直或水平条内显示内容。


有一些方法可以允许内容扩展到这个区域,避免黑边邮筒的丑陋,但这是一个更高级的功能,不是必需的。


要退出默认的黑边和邮筒,并声明您的应用程序可以适当地处理屏幕的安全和不安全区域,您可以包含以下 Meta 标记:


<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

文本大小


移动的浏览器还可能人为地增大字体大小,以使文本更具可读性。如果您的网站已经提供了适当大小的文本,您可以包含以下 CSS 来禁用此行为:


html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}

虽然在无障碍标准中没有规定文本的最小大小,但在大多数情况下 16px 是一个很好的最低限度。


对于输入字段,如果字体大小小于 16px,浏览器可能会在聚焦时放大。有一些方法可以禁用这种行为,例如在 Meta viewport 中设置 maximum-scale=1.0,但强烈建议不要这样做,因为这可能会干扰依赖缩放的用户。更好的解决方案是确保 font-size 大小至少为 16px


什么是断点?


样式中的断点是指条件样式规则停止或开始应用于页面以响应视口大小的点。最常见的是指 min-widthmax-width,但也可以应用于 height


在媒体查询中,这些断点(768px479px)将像这样使用:


@media (min-width: 768px) {
// 宽度 > 768px的条件样式
}

@media (max-width: 479px) {
// 宽度 <= 479px的条件样式
}

当遵循移动优先设计原则时,大多数时候应该使用媒体查询。


还需要注意的是,min-*max-* 查询适用于包含范围,因此在定义断点两侧的样式时,不应使用相同的像素值。


同样重要的是要注意,当放大页面时,以 CSS 像素为单位的视口大小可能会沿着明显的设备像素比率而变化。这可能会导致视口实际上表现得好像其长度是小数值的情况。


@media (max-width: 479px) {
// 宽度 < 479px的条件样式
}

@media (min-width: 480px) {
// 宽度 >= 480px的条件样式
}

在上面的示例中,如果视口(作为缩放的结果)报告为 479.5px,则两个条件规则块都不适用。相反,例如额外的小数值 0.98px 通常应用于查询 max-width


为什么要这么说? 0.02px 是早期版本的 Safari 支持的 CSS 像素的最小分割。参见 WebKit bug #178261


CSS 在 Media Queries Level 4规范中引入了范围查询的概念,其中 <><=, 和 >= 可用于表达性更强的条件,包括包含和排除范围。在撰写本文时,所有主流浏览器都支持这些功能,然而,在 iOS 等平台上的支持还不够。


@media (width < 480px) {
// 宽度 < 480px的条件样式
}

@media (width >= 480px) {
// 宽度 >= 480px的条件样式
}

我应该选择哪些断点?


这是一个经常被问到的问题,但坦率地说,这并不重要,只要一个 Web 应用程序在你选择的断点之间的所有屏幕尺寸上都能正常工作。你也不想选择太多或太少。


iPhone 在2007年首次推出时,屏幕分辨率为320x480px。按照惯例,所有智能手机的视口宽度至少为320 CSS 像素。当建立一个响应式网站时,你至少应该满足这个宽度的设备。


最近,网络变得更容易被一类设备访问,这些设备适合更经典的外形,称为功能手机,以及可穿戴技术。这些设备通常具有小于320px 的视口宽度。


一些设备,如 Apple Watch,将表现得好像它们有一个320px 的视口,以允许与未专门针对极小视口进行优化的网站兼容。如果要声明您确实处理 Apple watch 的较窄视口,请在文档中包含以下 Meta 标记:


<meta name="disable-adaptations" content="watch" />

如果您使用的是设计系统或组件库(例如 Material UI、Bootstrap 等)它为您提供了自己的默认断点,您可能会发现坚持使用这些断点是有益的。


如果你选择自己的断点,有一些历史上相关的断点可以作为灵感:



  • 320px - 智能手机视口的最小宽度

  • 480px - 智能手机和平板电脑之间的近似边界

  • 768px - 最初的 iPad 分辨率为768 x1024 px

  • 1024px - 同上

  • 1280px - 16:9 720p(HD)显示器的标准宽度


通常,给断点命名是个好主意。但是,不要试图称它们为“移动”,“平板电脑”和“桌面”之类的名称。虽然在平板电脑的早期,移动,平板电脑和桌面之间的界限更加清晰,但现在有如此广泛的设备和视口尺寸,以至于这些设备之间的界限变得模糊。如今,我们有屏幕尺寸比一些平板电脑更大的可折叠手机,以及让台式机和笔记本电脑屏幕相形见绌的平板电脑屏幕。


将特定范围称为“平板电脑”或“台式机”可能会让您陷入为单一类型的设备(例如平板电脑)进行构建和设计的陷阱。假设“移动的”或“平板”视口将总是使用触摸屏)。相反,您应该专注于构建在各种设备上工作的体验。


响应式布局技术


有两种 CSS 布局算法特别适合响应式设计:



  • Flexbox

  • Grid


FLEXBOX


Flexbox 是一种 CSS 布局算法,它允许我们指定子元素在页面上的排列方式。此控件应用于特定方向(称为 flex 轴)。


虽然 flexbox 可以用于呈现多行(带换行),但一行中的内容元素不会改变其他行中元素的排列方式。这意味着除非明确设置 flex 项的宽度,否则它们的排列方式可能不一致。如果需要,CSS Grid 可能更合适。


Flex Wrap


使用 Flexbox 时可以不使用媒体查询,而是依靠 flex-wrap 属性,使内容可以根据内容大小多次跨轴。设置 flex-wrap: wrap 将意味着内容在下方( flex-direction: row)或右侧( flex-direction: column)换行。您还可以设置 flex-wrap: wrap-reverse 使内容在上方或左侧换行。


Flex Direction


通常情况下,对于水平空间有限的窄视口,设计可能要求垂直排列内容,但对于屏幕空间较大的宽视口,则可能改为水平排列内容。


.className {
display: flex;
flex-direction: column;
}

@media (min-width: 768px) {
.className {
flex-direction: row;
}
}

长期以来,媒体查询需要在顶层定义,但当相关规则没有在大型样式表中共存时,这会增加维护负担。在撰写本文时,浏览器尚未广泛支持这种做法,但许多工具和预处理器都允许这样做。


.className {
display: flex;
flex-direction: column;

@media (min-width: 768px) {
flex-direction: row;
}
}

GRID


Grid 是一种 CSS 布局算法,它允许我们指定子元素在页面上的排列方式。Grid 允许开发人员指定元素在行和列之间的排列方式。


就可实现的布局类型而言,它与 flexbox 有重叠之处,但也有显著区别。使用 Grid 布局时,网格项会根据横轴和纵轴上的网格轨道进行约束和对齐。


以下是与响应式设计搭配使用的常见布局技术的几个示例。


Columns


设计师通常会使用 12 栏网格(或窄视口的 4 栏网格)。您可以使用 grid-template-columns 在 CSS 中复制这种模式。结合断点,您就可以轻松分配类,使元素跨越特定的列数。


Google的Una Kravets在One Line Layouts开发站点上分享了一些交互式示例。


RAM(重复、自动、最小最大)


另一种网格布局技术通常称为RAM(重复,自动,最小最大)。我鼓励你去看看One Line Layouts开发站点上的交互式示例


当你事先不知道网格需要多少列,而是希望在一些预设的范围内让内容的大小来决定列数时,RAM 是最有用的。auto-fitauto-fill 的工作方式类似,但当项目数量少于填充一行的数量时会发生什么情况除外。


// 网格项目将始终至少150像素宽,
// 并将伸展以填满所有可用空间
.auto-fit {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}

// 网格项目将始终至少150像素宽,
// 并且会伸展直到有足够的空间
// (如果有)添加匹配的空网格轨道
.auto-fill {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}

Grid Template Areas


网格模板区域是用于响应式布局的最强大工具之一,可让您以直观的方式将元素排列到网格上。


举个例子,我们可能会有一个包含页眉、主要部分、侧边栏和页脚的布局,它们都在狭窄的视口上垂直排列。不过,将 grid-template-areas 区域与 grid-template-columnsgrid-template-rows 结合使用,我们可以用相同的标记将这些元素重新排列成网格模式。


代码示例:


.layout {
display: grid;
grid-template-areas:
"header"
"main"
"sidebar"
"footer";
grid-template-rows: auto 1fr auto auto;
}

@media (min-width: 768px) {
.layout {
grid-template-areas:
"header header"
"main sidebar"
"footer footer";
grid-template-columns: 1fr 200px;
grid-template-rows: auto 1fr auto;
}
}

.header {
grid-area: header;
}

.main {
grid-area: main;
}

.sidebar {
grid-area: sidebar;
}

.footer {
grid-area: footer;
}

<div class="layout">
<header class="header">Headerheader>
<main class="main">Mainmain>
<aside class="sidebar">Sidebaraside>
<footer class="footer">Footerfooter>
div>

响应图像


在高密度显示屏上,根据 CSS 像素而不是设备像素来调整图片大小,可能会导致图片质量低于用户的预期,尤其是在显示清晰的文本或矢量资源时,会显得格外刺眼。因此,为用户提供更高密度的图片是有意义的。


如果可能,请使用基于矢量的图像 (SVG)。矢量不是指定像素的光栅,而是描述在屏幕上绘制某些内容的过程,这一过程可以放大/缩小到任何屏幕尺寸,但始终保持清晰。矢量图像通常适用于简单的插图、图标或徽标。它们不适用于照片。



请注意,SVG 可以嵌入光栅图像。如果是这种情况,又无法获得真正的矢量图像,最好直接使用光栅图像。这是因为光栅图像在 SVG 中使用 base64 编码,与普通二进制文件相比,文件大小会增大。


对于光栅图像,有几种为高密度显示指定多个图像源的方法,允许浏览器选择最适合特定设备的图像源。


对于静态大小的图像,你可以使用 x 描述符(指定最佳设备像素比)指定。例如,如果您有一个图标或徽标,显示宽度为44px,你可以创建该图像的多个不同版本,并指定如下内容:


<img
srcset="
/path/
to/img-44w.png 1x,
/path/
to/img-66w.png 1.5x,
/path/
to/img-88w.png 2x,
/path/
to/img-132w.png 3x
"

/>


重要的是,这些 x 描述符只是一种提示,设备仍可能出于各种原因(如用户选择了节省带宽的规定)选择较低分辨率的版本。


在 CSS 中使用 image-set()(注意浏览器的支持并不完善)对background-image也可以采用类似的技术:


.selector {
height: 44px;
width: 44px;
/* 对于不支持 image-set() 的浏览器使用 2x 回退 */
background-image: url(/path/to/img-88w.png);
/* Safari 只支持 -webkit-image-set() */
background-image: -webkit-image-set(
url(/path/to/img-44w.png) 1x,
url(/path/to/img-66w.png) 1.5x,
url(/path/to/img-88w.png) 2x,
url(/path/to/img-132w.png) 3x
);
/* 标准语法 */
background-image: image-set(
url(/path/to/img-44w.png) 1x,
url(/path/to/img-66w.png) 1.5x,
url(/path/to/img-88w.png) 2x,
url(/path/to/img-132w.png) 3x
);
}

对于随着页面大小调整而改变大小的图像,可以组合使用 srcsetsizes 属性。例如。


<img
srcset="
/path/to/img-320w.jpg 320w,
/path/to/img-480w.jpg 480w,
/path/to/img-640w.jpg 640w,
/path/to/img-960w.jpg 960w,
/path/to/img-1280w.jpg 1280w
"

sizes="(min-width: 768px) 480px, 100vw"
/>


在上面的例子中,我们在 srcset 中以不同实际宽度(320px、480px、640px、960px、1280px)的多个不同图像渲染。在 sizes 属性中,我们告诉浏览器这些图像将默认以视口宽度的 100% 显示,然后对于768px 和更宽的视口,图像将以固定的480 CSS 像素宽度显示。然后,浏览器将根据设备像素比为设备选择最佳图像渲染(尽管这只是一个提示,浏览器可以选择使用更高或更低的分辨率选项)。


使用 WebP 和 AVIF 等现代图像格式压缩技术,当图像以 2 倍密度显示时,文件大小通常只比 1 倍版本略有增加。而且,当设备像素比大于 2 时,收益会越来越小。因此,您可以只包括优化的2x 图像。Google Chrome 团队的开发者倡导者 Jake Archibald 写了一篇博客文章讨论了这一问题,并强调了一个事实:你的大多数用户可能都在使用高密度显示器浏览网页。

作者:chansee97
来源:juejin.cn/post/7259605860603576375

收起阅读 »

纯C文件推理Llama 2

web
这段项目可以让你通过PyTorch从头开始训练Llama 2 LLM架构模型,然后将权重保存到一个原始二进制文件中,再将其加载到一个仅有500行的简单C文件(run.c)中,该文件推断模型,目前仅支持fp32。在作者的云Linux开发平台上,一个维度为288的...
继续阅读 »

这段项目可以让你通过PyTorch从头开始训练Llama 2 LLM架构模型,然后将权重保存到一个原始二进制文件中,再将其加载到一个仅有500行的简单C文件(run.c)中,该文件推断模型,目前仅支持fp32。在作者的云Linux开发平台上,一个维度为288的6层6头模型(约15M个参数)推断速度约为每秒100个令牌;在M1 MacBook Air上推断速度也差不多。作者有些惊喜地发现,采用这种简单方法,可以以高度交互的速度运行相当大的模型(几千万个参数)。




参考文献:
https://github.com/karpathy/llama2.c

作者:阿升
来源:mdnice.com/writing/6f98f171b14e4050bf627afe59ccb82a

收起阅读 »

也许跟大家不太一样,我是这么用TypeScript来写前端的

web
一、当前一些写前端的骚操作 先罗列一下见到过的一些写法吧:) 1. interface(或Type)一把梭 掘金上很多文章,一提到 TypeScript,那不得先用 interface 或者 type 来声明个数据结构吗?像这样: type User = { ...
继续阅读 »

一、当前一些写前端的骚操作


先罗列一下见到过的一些写法吧:)


1. interface(或Type)一把梭


掘金上很多文章,一提到 TypeScript,那不得先用 interface 或者 type 来声明个数据结构吗?像这样:


type User = {
nickname: string
avatar?: string
age: number
}

interface User {
nickname: string
avatar?: string
age: number
}

然后其他方法限制下入参类型,搞定,我掌握了 TypeScript 了,工资不得给我涨3000???



这里说明一下, 我司 不允许 直接使用 interface type 来定义非装饰器参数和配置性参数之外其他 任何数据类型



2. 类型体操整花活


要么把属性整成只读了,要么猪狗类型联合了,要么猪尾巴搞丢了,要么牛真的会吹牛逼了。


类型体操确实玩出了很多花活。 昨天说过了:TypeScript最好玩的就是类型体操, 也恰好是最不应该出现的东西


3. hook 的无限神话


不知道什么时候开始,hook 越来越流行。 听说不会写 hook 的前端程序员,已经算不上高阶程序员了, 不 use 点啥都展示不出牛逼的水平。


4. axios 拦截大法好


随便搜索一下 axios 的文章, 没有 拦截器 这个关键词的文章都算不上 axios 的高端用法了。


二、 我们一些不太一样的前端骚操作


昨天的文章有提到一些关于在前端使用 装饰器 来实现一些基于配置的需求实现, 今天其实想重点聊一聊如何在前端优雅的面向对象。


写过 JavaSpringBootJPA 等代码的后端程序员应该非常熟悉的一些概念:



  • 抽象: 万物都可抽象成相关的类和对象

  • 面向对象: 继承、封装、多态等特性的面向对象设计思维

  • 切面: 没有什么是切一刀解决不了的,如果一刀不行, 那就多来几刀。

  • 注解: 没有什么常量是不能使用注解来配置的, 也没有什么注解是切面想切还能躲得掉的

  • 反射: 没有什么是暴力拿取会失败的, 即使失败也没有异常是丢不出来的

  • 实体: 没有什么是不能抽象到实体上的, 万物皆唯一。

  • 很多: 还有很多,以上描述比较主观和随意。


于是我们开始把后端思维往前端来一个个的转移:)


1. 抽象和面向对象


与后端的交互数据对象、 请求的API接口都给抽象到具体的类上去,于是有了:



  • Service API请求类


abstract class AbstractService{
// 实现一个抽象属性 让子类们实现
abstract baseUrl!: string

// 再实现一些通用的 如增删改查之类的网络请求
// save()

// getDetail()

// deleteById()

// select()

// page()

// disabled()

// ......
}


  • Entity 数据实体基类


abstract class AbstractBaseEntityextends AbstractService> {
abstract service!: AbstractService

// 任何数据都是唯一的 ID
id!: number

// 再来实现一些数据实体的更新和删除方法
save(){
await service.save(this.toJson())
Notify.success("新增成功")
}

delete(){
service.deleteById(this.id)
Notify.success("删除成功")
}

async validate(scene: EntityScene):Promise<void>{
return new Promise((resolve,reject)=>{
// 多场景的话 可以Switch
if(...){
Notify.error("XXX校验失败")
reject();
}
resove();
})
}
// ......
}



  • 子类的实现:)


class UserEntity extends AbstractUserEntity<UserService>{
service = new UserService()

nickname!: string
age!: number
avatar?: string

// 用户是否成年人
isAdult(): boolean{
return this.age >= 18
}

async validate(scene: EntityScene): Promise<void> {
return new Promise((resove,reject)=>{
if(!this.isAdult()){
Notify.error("用户未成年, 请确认年龄")
reject();
}
await super.validate(scene)
})
}

}


  • View 视图调用


<template>
<el-input v-model="user.nickname"/>
<el-button @click="onUserSave()">创建用户el-button>
template>
<script setup lang="ts">
const user = ref(new UserEntity())
async function onUserSave(){
await user.validate(EntityScene.SAVE);
await user.save()
}
script>

2. 装饰器/切面/反射


装饰器部分的话,昨天的文章有提到一些了,今天主要所说反射和切面部分。


TypeScript 中, 其实装饰器本身就可以理解为一个切面了, 这里与 Java 中还是有很多不同的, 但概念和思维上是基本一致的。


反射 ReflectTypeScript 中比较坑的一个存在, 目前主要是依赖 reflect-metadata 这个第三方库来实现, 将一些元数据存储到 metadata 中, 在需要使用的时候通过反射的方式来获取。 可以参考这篇文章:TypeScript 中的元数据以及 reflect-metadata 实现原理分析


在实际使用中, 我们早前用的是 class-transformer 这个库, 之前我对这个库的评价应该是非常高的: “如果没有 class-transformer 这个库, TypeScript 狗都不写。”


确实很棒的一个库,但是在后来,我们写了个通用的内部框架, 为了适配 微信小程序端 以及 uniapp 端, 再加上有一些特殊的业务功能以及 class-transfromer 的写法和命名方式我个人不太喜欢的种种原因, 我们放弃了这个库, 但我们仿照了它的思想重新实现了一个内部使用的库,做了一些功能的阉割和新特性的添加。


核心功能的一些说明




  • 通过反射进行数据转换



    如将后端API返回的数据按照前端的数据结构强制进行转换, 当后端数据返回乱七八糟的时候,保证前端数据在使用中不会出现任何问题, 如下 demo



    class UserEntity {
    @Type(String) phone!: string;
    @Type(RoleEntity) roleInfo!: RoleEntity:
    @Type(DeptEntity) @List @Default([]) deptInfoList!: DeptEntity[]
    @Type(Boolean) @Default(false) isDisabled!: boolean
    }



  • 通过反射进行配置的存储和读取



    这个在昨天的文章中有讲到一部分, 比如配置表单、表格、搜索框、权限 等





3. 再次强调面向对象


为了整个前端项目的工程化、结构化、高度抽象化,这里不得不再次强调面向对象的设计:)




  • 这是个拼爹的社会



    一些通用的功能,一旦有复用的可能, 都可以考虑和尝试让其父类进行实现, 如需要子类传入一些特性参数时, 可以使用抽象方法或抽象属性(这可是Java中没有的)来传入父类实现过程中需要的特性参数。





  • 合理的抽象分层



    将一些特性按照不同的抽象概念进行组合与抽离,实现每个类的功能都是尽可能不耦合,实现类的单一职责。如存在多继承, 在考虑实现类的实现成本前提下,可考虑抽象到接口 interface 中。





  • 还有很多,有空再一一列举




4. 严格但又有趣的 tsdoc


我们先来看一些注释的截图吧:)








一些详细的注释、弃用的方法、选填的参数、传入参数后可能影响或依赖的其他参数,在注释里写好玩的 emoji或者图片,甚至是 直接在注释里写调用 demo, 让调用方可以很轻松愉快的对接调用, 玩归玩, 确实对整体项目的质量有很大的帮助。


三、 写在最后


中午跟同事吃饭聊了聊现在国内大前端的一个状态, 当时聊到一个关键词 舒适区, 还有前端整个技术栈过于灵活的一些优缺点, 几个大老爷们都发出了一些感慨, 如果前端能够更标准化一些, 像 Java 一样, 说不定前端还能上升几个高度。


我们基于今天文章里的一些设计写了一些DEMO,但目前不太方便直接开源,如果有兴趣,可以私聊我获取代码链接。


是的, 我还是那个 Java 仔, 是, 也不仅仅是。

作者:Hamm
来源:juejin.cn/post/7259562014417813564

收起阅读 »

请自信的大声告诉面试官forEach跳不出循环

web
如果面试官,或者有人问你foreach怎么跳出循环,请你大声的告诉ta,跳不出!!!!!!!!!! foreach 跳不出循环 为什么呢? 先看看foreach大体实现。 Array.prototype.customForEach = function (fn...
继续阅读 »

如果面试官,或者有人问你foreach怎么跳出循环,请你大声的告诉ta,跳不出!!!!!!!!!!


foreach 跳不出循环


为什么呢?


先看看foreach大体实现。


Array.prototype.customForEach = function (fn) {
for (let i = 0; i < this.length; i++) {
fn.call(this, this[i], i, this)
}
}

list.customForEach((item, i, list) => {
console.log(item, i, list)
})

let list = [1,2,3,4,5]

list.forEach((item,index,list)=>{
console.log(item,index,list)
})

list.customForEach((item,index,list)=>{
console.log(item,index,list)
})




两个输出的结果是一样的没啥问题,这就是foreach的大体实现,既然都知道了它的实现,那么对它为什么跳不出循环♻️应该都知道了,再不清楚的话,再看一下下面的例子。



function demo(){
return 'demo'
}

function demo2(){
demo()
return 'demo2'
}

demo()


在demo2函数里面调用demo函数,demo函数的return能阻止demo2函数下面的执行吗?很明显不行啊,demo函数里的return跟demo2函数一点关系都没有。现在你再回头看看foreach的实现,就明白它跳不出循环一清二楚了。


有点同学说不是可以通过抛出错误跳出循环吗?是的。看看下面例子。



let list = [1,2,3,4,5]

try {
list.forEach((item, index, list) => {
if (index === 2) {
throw new Error('demo')
}
console.log(item)
})
} catch (e) {
// console.log(e)
}




结果是我们想要,但是你看代码,哪个正常人会这样写代码?是非foreach不用吗?还是其他的循环关键字不配呢。


end


有反驳在评论区,show me your code !!!!!!!!!


作者:啥也不懂的前端
来源:juejin.cn/post/7259595485090906149
收起阅读 »

初学矩阵

web
前言 矩阵是人类的瑰宝,矩阵里数字与数字通过关系组在一起。正如大道无形,用不同的视角去解读数的关系,它就有不同的作用。大道至简,难的是解读道的心。(作者发癫中...) 让我们放开的自己的心,不要限制它的解读,(san +++) 下面进行简单的描述。 矩阵 (M...
继续阅读 »

前言


矩阵是人类的瑰宝,矩阵里数字与数字通过关系组在一起。正如大道无形,用不同的视角去解读数的关系,它就有不同的作用。大道至简,难的是解读道的心。(作者发癫中...)


让我们放开的自己的心,不要限制它的解读,(san +++)


下面进行简单的描述。


矩阵 (Matrix)


定义


矩阵由 m 行 n 列 组成的方队,即为 m * n 的矩阵。 其组成的元素可以为实数,虚数。


好了开写。


我这边定义一个枚举类型,因为我想通过矩阵计算对象某个属性。
但是实现实在是有点生草。最开始的时候准备封装个矩阵类,然后通过它进行计算。
但是现实中我那微不足道的 OOP 水平撑不下去了,在矩阵的灵活扩展想法败北了┭┮﹏┭┮。
最后是个四不像的实现。


// 定义矩阵类型
type IMatrix<T = number> = T[][];

class Matrix {
// 获取矩阵常用的坐标集合操作
static getRow<T = number>(matrix: IMatrix<T>, rowIndex: number) {
return matrix?.[rowIndex]
};
static getCol<T = number>(matrix: IMatrix<T>, colIndex: number) {
return matrix.map(row => row[colIndex])
};
static getMatrixLen<T = number>(matrix: IMatrix<T>) {
return {
rowLen: matrix.length,
colLen: Math.max(...matrix.map(row => row.length)),
}
};
}

上面就是获取矩阵常用简化。


这里关于使用类静态方法的考虑是因为我觉得相对比较直观,虽然现在有 esm 现代模块化方案,可以基于文件即模块,但是考虑到更直观的抽象关系,我选择这种方式。第一调用的时候,不会 esm import 那样少了直观的从属关系,esm 的文件即模块确实很方便,但引用代码如果没有插件的话,需要追踪对应的文件模块的话,只能从函数名语义入手。


在项目中有时候会碰到大杂烩语义的文件,比如一个 util文件 会承当各种逻辑封装而失去模块的意义,用类作为一个抽象空间是一种相对方法。


同型矩阵/单位矩阵


同型矩阵就是矩阵之间的行数列数均相同,则视矩阵之间的关系为同型矩阵。符合同型矩阵是一些计算逻辑的前置判断。


单位矩阵是矩阵主对角线之间的数为 1 ,其余为 0 。其实就是行列坐标相同的点就是 1 ,其余就是 0。


interface IGetMatrixValue<T = number> {
(matrixItem: T) : number;
}
//...
static isHomotypeMatrix<T = number>(matrix1: IMatrix<T>, matrix2: IMatrix<T>): boolean {
if (matrix1.length !== matrix2.length) {
return false;
}
if (this.getMatrixLen(matrix1) !== this.getMatrixLen(matrix2)) {
return false;
}

return true;
};

static isUnitMatrix<T = number>(matrix: IMatrix<T>, getMatrixVal?: IGetMatrixValue<T>) {
const handleGetMatrixValue = getMatrixVal || getDefaultMatrixItem;

for (let i = 0; i < matrix.length; i++) {
const row = matrix[i];
for (let j = 0; j < row.length; j++) {
const isSameIdx = i === j;
const val = handleGetMatrixValue(matrix[i][j] as any);

if (isSameIdx && val !== 1) {
return false;
}

if (!isSameIdx && val !== 0) {
return false;
}
}
}

return true;
};


当时写到这里,我感觉脑子乱乱的,可能是经常熬夜吧。
第二个获取单元行数,是不是有点不一样的。其实从这里,我才意识这里正因为我的定义函数因为它太灵活,导致我这边要处理更多的边际逻辑。(当时大脑宕机中...😐)


我希望我的代码可以使用对象运算,但是如何标准的写出可扩展的函数,或许是我要去学习的。 Lodash 源码获取是个不错的选择,但是总有一些事情,让我没有机会。


同型矩阵加/减


同型矩阵同个行列位置的进行加减运算,所以我写道一般,还是抽了一个计算同个位置逻辑的函数,并把其它情况交给调用者自己扩展运算吧。


interface ICustMatrixAWithB<T> {
(matrixAItem: T, matrixBItem: T) : T;
}

static computeHomotypeMatrix<T = number>(matrix1: IMatrix<T>, matrix2: IMatrix<T>, custom: ICustMatrixAWithB<T>): IMatrix<T> {
if (!this.isHomotypeMatrix(matrix1, matrix2)) {
throw new Error('该矩阵非二维数组');
}
const { rowLen, colLen } = this.getMatrixLen(matrix1)

const nextMatrix: T[][] = [];

for (let i = 0; i < rowLen; i++) {
nextMatrix[i] = [];
for (let j = 0; j < colLen; j++) {
nextMatrix[i][j] = custom(matrix1[i]?.[j], matrix2[i]?.[j]);
}
}
return nextMatrix;
};

static addHomotypeMatrix(matrix1: IMatrix<number>, matrix2: IMatrix<number>) {
return this.computeHomotypeMatrix(matrix1, matrix2, (num1, num2) => num1 + num2);
};

static subHomotypeMatrix(matrix1: IMatrix<number>, matrix2: IMatrix<number>) {
return this.computeHomotypeMatrix(matrix1, matrix2, (num1, num2) => num1 - num2);
};

写到这里也说我最纠结的,因为我的封装设计出现了问题,最后一层胶水层没法解决,只能交由调用者使用 computeHomotypeMatrix 去实现自己的加减逻辑。


但我不知道要如何去解决,如果你有办法请留言教教我吧。


这里的逻辑,也让我想起了若川大佬的 vant 组件源码共读中的计算逻辑。只是想不起具体细节,时间真的是改变一切,生物的宿命真是难以跨域。


矩阵乘法 矩阵相乘/矩阵标量


矩阵和标量的乘积,标量指的是一个数,数和矩阵相乘等于数与矩阵的每个元素相乘


矩阵相乘则是比较奇怪,我不太了解原理。是这么一个公式,矩阵1 m * n , 矩阵2 n * p , 当前矩阵的列数等于后矩阵的行数的时候的才可以进行相乘,可以得到这么一个新矩阵 m * p。矩阵的每一项等于 当前行数的前矩阵的列数的项 * 当前的列数的后矩阵的行数的项,两个数组之间的每一元素相乘后累加成项的值


//...
static mapMatralItem<T>(matrix: IMatrix<T>, map: (matrix: T) => T) {

const nextMatrix: IMatrix<T> = [];

for (let i = 0; i < matrix.length; i++) {
const row = matrix[i] || [];
nextMatrix[i] = [];

for (let j = 0; j < row.length; j++) {
const item = row[j];
nextMatrix[i][j] = map(item);
}
}

return nextMatrix;
};
// 数乘
static multipleItemMatrix(matrix: IMatrix<number>, multiple: number) {
return this.mapMatralItem(matrix, (item) => item * multiple);
};
// 矩阵相乘
static multiplyMatrix(matrix1: IMatrix<number>, matrix2: IMatrix<number>) {
const { colLen, rowLen: nexRowLen } = this.getMatrixLen(matrix1);
const { rowLen, colLen: nexColLen } = this.getMatrixLen(matrix2);

if (colLen !== rowLen) {
/** A m * n * B n * p = C m *p */
throw new Error('矩阵乘法必须,前矩阵列数等于后矩阵行数');
}

const nextMatrix: IMatrix = [];

for (let i = 0; i < nexRowLen; i++) {
nextMatrix[i] = [];
const curCol = this.getCol(matrix1, i);
for (let j = 0; j < nexColLen; j++) {
const curRow = this.getRow(matrix2, j);
const len = Math.max(curCol.length, curRow.length);
const computed = Array.from({
length: len,
}, (_, idx) => {
const curColVal = curCol[idx] || 0;
const curRowVal = curRow[idx] || 0;
return curColVal * curRowVal;
});

nextMatrix[i][j] = computed.reduce((acc, cur) => acc + cur, 0);
}
}

return nextMatrix;
};

mapMatralItem 函数封装,它不提供具体的逻辑,只是提供矩阵每个元素的类似数组 map 的能力,但也没有比数组好,因为它再设计的时候少了更多的环境参数。


矩阵转秩


矩阵转秩代表矩阵的行列坐标相互交换,前面有提到的对角线坐标在转秩后仍然还在同一个位置,其它都是 0 交换后也变,所以也可以得出单位矩阵的秩等于单位的结论。


// 矩阵转秩
static randConversionMatrix<T>(matrix: IMatrix<T>) {
const { rowLen, colLen } = this.getMatrixLen(matrix);
const nextMatrix:IMatrix<T> = [];
for (let i = 0; i < colLen; i++) {
nextMatrix[i] = [];
for (let j = 0; j < rowLen; j++) {
nextMatrix[i][j] = matrix[j][i];
}
};

return nextMatrix;
};

矩阵 共轭


这些不懂,暂时跳过,当初不好好学习,上个好的学校 ┭┮﹏┭┮。


矩阵快速幂运算


快速幂是什么,其实就是减少指数,转为同等的底数,来减少计算次数。
看了好久才懂,太菜了。
比方说,一个 2 ** 8 ,我们通过二分指数的方式把底数扩大 (2 ** 2 ) ** 4 -> (4 ** 2) * 2 最后就变小,指数越大效率越高


/**
* 快速幂运算
* @param a 底数
* @param pow 阶乘
*/

const multiQuick = (a: number, pow: number) => {
let curPow = pow, result = 1;

while(curPow) {
if (curPow === 0) {
result *= 1;
} else if (curPow === 1) {
result *= a;
curPow = 0;
} else if (curPow % 2 === 1) {
result *= result * a;
curPow -= 1;
} else if (curPow % 2 === 0) {
result *= result;
curPow >>= 1;
}
};

return result;
};

static multilpyQuiickMatrix(matrix: IMatrix<number>, pow: number) {
return this.mapMatralItem(matrix, (num) => multiQuick(num, pow))
};

这里有小知识点二进制也算复习了,很多东西学了忘,学了忘,只有真正意识到它的价值,才能接纳它。


这里也可以看得出来,虽然我封装得 map 函数不够好,但是确实确实简化很多过程表述。只是从一个数据向另一个数据迁移。


结语


本次文章记录也到到此为止,感谢人生中让我成长的一切,感谢每一个让我快乐的人。


最近行情真的不好,有时候在想,我除了编程还有技能吗?可惜好像没有发现,我学历低,没有大厂背景,真的失业了可能就很难找到工作。


最近也是高考结束了期间,有一批学子成为了准大学生。记得当年报考,我最后还是说服父母说了报考移动应用开发专业。那时候的梦想真的是想学习编程的来开发游戏,然而现在感觉工作了,那股热情却萎了。


人生且叹且前

作者:孤独之舟
来源:juejin.cn/post/7258191640564334653
行,缘分渐行渐无书。

收起阅读 »

希尔排序,我真的领悟了

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

收起阅读 »