注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

CSS链接悬停效果的的小创意

web
前言 每次写a标签的时候我都烦躁,为什么默认是蓝色的,就跟奇怪,关键他那个颜色也用不上,今天想让平凡的a标签也能做出令人眼前一亮的效果,大家以后直接cv多好 悬停滑动高亮链接效果 鼠标悬停后,链接有一个过渡动画,填充背景色,我们从链接周围的填充开始,然后添加相...
继续阅读 »

前言


每次写a标签的时候我都烦躁,为什么默认是蓝色的,就跟奇怪,关键他那个颜色也用不上,今天想让平凡的a标签也能做出令人眼前一亮的效果,大家以后直接cv多好


悬停滑动高亮链接效果


鼠标悬停后,链接有一个过渡动画,填充背景色,我们从链接周围的填充开始,然后添加相同值的负边距以防止填充破坏文本流。我们将使用box-shadow而不是 background 属性,因为它允许我们转换。


a { 
box-shadow: inset 0 0 0 0 #54b3d6;
color: #54b3d6;
margin: 0 -.25rem;
padding: 0 .25rem;
transition: color .3s ease-in-out, box-shadow .3s ease-in-out;
}
a:hover {
box-shadow: inset 100px 0 0 0 #54b3d6;
color: white;
}


悬停链接文本交换效果


我们在悬停时将链接的文本与其他一些文本交换。将鼠标悬停在文本上,链接的文本会随着新文本的滑入而滑出。


 <p><a href="#" data-replace="给个三连,好不好嘛"><span>鼠标放到这里试一试</span></a></p>

让我们给链接一些基本样式。我们需要给它相对定位来固定伪元素,确保它的显示是inline-block为了获得盒子元素样式的可供性,并隐藏伪元素可能导致的任何溢出。


  a {
overflow: hidden;
position: relative;
display: inline-block;
}

::before,::after设置为链接的全宽,左侧位置为零,并且绝对定位。


a::before,
a::after {
content: '';
position: absolute;
width: 100%;
left: 0;
}

::after伪元素从 HTML 标记中的链接数据属性获取内容:


a::after {
content: attr(data-replace);
}

transform: translate3d()::after伪元素元素向右移动 200%,悬停再回到以前的位置。


a::after {
content: attr(data-replace);
top: 0;
transform-origin: 100% 50%;
transform: translate3d(200%, 0, 0);
}

a:hover::after,
a:focus::after {
transform: translate3d(0, 0, 0);
}

我们使用transform: scaleX(0)::before伪元素,因此默认情况下它是隐藏的。悬停后我们将使它显示出来,就像2px高度一样,并将其固定到 上bottom,使其看起来像文本上的下划线那种感觉,看一下代码就理解我说的意思了


a::before {
background-color: #54b3d6;
height: 2px;
bottom: 0;
transform-origin: 100% 50%;
transform: scaleX(0);
}

a:hover::before,
a:focus::before {
transform-origin: 0% 50%;
transform: scaleX(1);
}

随后加入了transform效果、一些颜色等等以获得完整的效果。

作者:前端高级工程师宋
来源:juejin.cn/post/7143596588579946503
an>

收起阅读 »

五分钟实现一个chatGPT打字效果

web
由于chatGPT最近大火,甲方爸爸觉得这样的打字效果很酷,必须要在项目中安排一下,所以动手实现了这个效果 打字状态分析 loading - 在等待打字内容的时候光标会一直显示且闪烁 tyeing - 在打字中光标会显示但不闪烁 end - 在打字结束后光标...
继续阅读 »

由于chatGPT最近大火,甲方爸爸觉得这样的打字效果很酷,必须要在项目中安排一下,所以动手实现了这个效果


打字状态分析



  1. loading - 在等待打字内容的时候光标会一直显示且闪烁

  2. tyeing - 在打字中光标会显示但不闪烁

  3. end - 在打字结束后光标隐藏


样式


// 光标字符显示
.typing::after {
content: '▌';
}
// 光标闪烁动画
.blinker::after {
animation: blinker 1s step-end infinite;
}
@keyframes blinker {
0% {
visibility: visible;
}
50% {
visibility: hidden;
}
100% {
visibility: visible;
}
}

内容打印功能实现


结合定时器和光标样式设置


**
* @description:
* @param {HTMLElement} dom - 打印内容的dom
* @param {string} content - 打印文本内容
* @param {number} speed - 打印速度
* @return {void}
*/
function printText(dom, content, speed = 50) {
let index = 0
setCursorStatus(dom, 'typing')
let printInterval = setInterval(() => {
dom.innerText += content[index]
index++
if (index >= content.length) {
setCursorStatus(dom, 'end')
clearInterval(printInterval)
}
}, speed)
}

/**
* @description: 设置dom的光标状态
* @param {HTMLElement} dom - 打印内容的dom
* @param {"loading"|"typing"|"end"} status - 打印状态
* @return {void}
*/

function setCursorStatus(dom, status) {
const classList = {
loading: 'typing blinker',
typing: 'typing',
end: '',
}
dom.className = classList[status]
}

效果预览


作者:chansee97
来源:juejin.cn/post/7221368910139113531
an>

收起阅读 »

同一页面多次调用图形验证码

缘由一个页面需要两个验证码,使用同一个验证码调用两次会导致有前一个失效。那么我们需要创建不同的两个验证码,分别做验证。截图展示具体实现同时引入多个KgCaptcha的js。引入多个JS时,请定义 plural 参数;通过该参数区分定义对象名...
继续阅读 »

缘由

一个页面需要两个验证码,使用同一个验证码调用两次会导致有前一个失效。那么我们需要创建不同的两个验证码,分别做验证。


截图展示



具体实现

  • 同时引入多个KgCaptcha的js。
  • 引入多个JS时,请定义 plural 参数;通过该参数区分定义对象名,如plural=1,则对象名为kg1,以此类推。
<script src="captcha.js?appid=XXX&plural=1" id="KgCaptcha1"></script>
<script src="captcha.js?appid=XXX&plural=2" id="KgCaptcha2"></script>
  • 初始化验证码
<script type="text/javascript">

// 第一个验证码
kg1.captcha({
// 绑定元素,验证框显示区域
bind: "#captchaBox1",
// 验证成功事务处理
success: function(e) {
console.log(e);
},
// 验证失败事务处理
failure: function(e) {
console.log(e);
},
// 点击刷新按钮时触发
refresh: function(e) {
console.log(e);
}
});

// 第二个验证码
kg2.captcha({
// 绑定元素,验证框显示区域
bind: "#captchaBox2",
// 验证成功事务处理
success: function(e) {
console.log(e);
},
// 验证失败事务处理
failure: function(e) {
console.log(e);
},
// 点击刷新按钮时触发
refresh: function(e) {
console.log(e);
}
});

</script>

  • 创建验证码框区域
<!-- 第一个验证码 -->
<div id="captchaBox1"></div>
<!-- 第二个验证码 -->
<div id="captchaBox2"></div>


总结

SDK开源地址:https://github.com/KgCaptcha,顺便做了一个演示:https://www.kgcaptcha.com/demo/

收起阅读 »

一个Node.js图形验证码的生成

效果图准备访问KgCaptcha网站,注册账号后登录控制台,访问“无感验证”模块,申请开通后系统会分配给应用一个唯一的AppId、AppSecret。提供后端SDK来校验token(即安全凭据)是否合法 ,目前支持PHP版、Python版、Java/JSP版、...
继续阅读 »

效果图


准备

  • 访问KgCaptcha网站,注册账号后登录控制台,访问“无感验证”模块,申请开通后系统会分配给应用一个唯一的AppId、AppSecret。
  • 提供后端SDK来校验token(即安全凭据)是否合法 ,目前支持PHP版、Python版、Java/JSP版、.Net C#版。
  • 访问Node.js官网,下载Node.js运行环境,访问Vue.js中文官网,安装下载Vue.js,创建一个Vue项目,具体操作请查看Vue.js中文官网。

项目目录


index.html

项目根目录index.html文件,头部引用KgCaptcha的js。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<!--引入凯格行为验证码js-->
<script id="KgCaptcha" src="captcha.js?appid=XXX"></script>
<!--引入凯格行为验证码js-->
</head>
<body>
<!--Vue主体-->
<div id="app"></div>
<!--Vue主体-->
</body>
</html>

main.js

src/main.js文件中,配置路由。

import Vue from 'vue'
import App from './App'
import router from './router'
Vue.config.productionTip = false
// 配置全局路由、组件
new Vue({
el: '#app',
router,
components: { App },
template: ''
})

App.vue

src/App.vue文件中,定义html。

<template>
<div id="app">
<!--自定义组件、内容-->
<form id="form">
token: <input name="token" _cke_saved_name="token" _cke_saved_name="token" _cke_saved_name="token" id="token">
<!--凯格行为验证码组件-->
<div id="captchaBox"></div>
<!--凯格行为验证码组件-->
<button type="submit">提交</button>
</form>
<!--自定义组件、内容-->
</div>
</template>

<script>
export default {
name: 'App',
}
//初始化凯格行为验证码
kg.captcha({
// 绑定元素,验证框显示区域
bind: "#captchaBox",
// 验证成功事务处理
success: function(e) {
console.log(e);
kg.$('#token').value = e['token']
},
// 验证失败事务处理
failure: function(e) {
console.log(e);
},
// 点击刷新按钮时触发
refresh: function(e) {
console.log(e);
}
});
</script>


总结

SDK开源地址:https://github.com/KgCaptcha,顺便做了一个演示:https://www.kgcaptcha.com/demo/


收起阅读 »

Vue.js 滑动拼图验证码实现笔记

背景关于验证码的使用场景还是非常多的,很多网站上的验证码可谓是五花八门,下面是我使用Vue.js实现滑动拼图验证码做的一个笔记。效果展示准备工作访问KgCaptcha网站,注册账号后登录控制台,访问“无感验证”模块,申请开通后系统会分配给应用一个唯一的AppI...
继续阅读 »

背景

关于验证码的使用场景还是非常多的,很多网站上的验证码可谓是五花八门,下面是我使用Vue.js实现滑动拼图验证码做的一个笔记。

效果展示



准备工作

  • 访问KgCaptcha网站,注册账号后登录控制台,访问“无感验证”模块,申请开通后系统会分配给应用一个唯一的AppId、AppSecret。
  • 提供后端SDK来校验token(即安全凭据)是否合法 ,目前支持PHP版、Python版、Java/JSP版、.Net C#版。
  • 访问Vue.js中文官网,复制Vue.js插件链接。
  • 注意:先HTML头部初始化行为验证码,然后HTML底部初始化Vue.js,否则KgCaptcha的js部分函数与被Vue.js发生冲突,导致失效。

实现代码

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<!--头部引入Vue.js插件-->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<!--头部引入Vue.js插件-->
<!--头部引入行为验证码js插件-->
<script id="KgCaptcha" src="captcha.js?appid=XXX"></script>
<script>
kg.captcha({
// 绑定元素,验证框显示区域
bind: "#captchaBox",
// 验证成功事务处理
success: function(e) {
console.log(e);
kg.$('#token').value = e['token'];
},
// 验证失败事务处理
failure: function(e) {
console.log(e);
},
// 点击刷新按钮时触发
refresh: function(e) {
console.log(e);
}
});
</script>
<!--头部引入行为验证码js插件-->
</head>

<body>
<div id="app">
<!--自定义内容、Vue组件-->
token: <input name="token" id="token" />
<!--行为验证码组件-->
<div id="captchaBox"></div>
<!--行为验证码组件-->
<button type="button">提交</button>
<!--自定义内容、Vue组件-->
</div>
</body>

<!--底部运行Vue.js代码-->
<script>
var app = new Vue({
el: '#app',
})
</script>
<!--底部运行Vue.js代码-->

</html>


最后

SDK开源地址:https://github.com/KgCaptcha,顺便做了一个演示:https://www.kgcaptcha.com/demo/

收起阅读 »

整个活儿~永远加载不满的进度条

web
前言各位开发大佬,平时肯定见到过这种进度条吧,一直在加载,但等了好久都是在99% 如下所示: 有没有好奇这个玩意儿咋做的呢? 细听分说 (需要看使用:直接看实践即可)fake-progress如果需要实现上面的这个需求,其实会涉及到fake-progre...
继续阅读 »

前言

各位开发大佬,平时肯定见到过这种进度条吧,一直在加载,但等了好久都是在99%

如下所示:

有没有好奇这个玩意儿咋做的呢?
细听分说 (需要看使用:直接看实践即可)

fake-progress

如果需要实现上面的这个需求,其实会涉及到fake-progress这个库,具体是干嘛的呢?
这个库会提供一个构造函数,创建一个实例对象后,里面的属性会给我们进度条需要的数据等信息。
如图所示:


fake-progress库的源码如下:

/**
* Represents a fakeProgress
* @constructor
* @param {object} options - options of the contructor
* @param {object} [options.timeConstant=1000] - the timeConstant in milliseconds (see https://en.wikipedia.org/wiki/Time_constant)
* @param {object} [options.autoStart=false] - if true then the progress auto start
*/

const FakeProgress = function (opts) {
 if (!opts) {
   opts = {};
}
 // 时间快慢
 this.timeConstant = opts.timeConstant || 1000;
 // 自动开始
 this.autoStart = opts.autoStart || false;
 this.parent = opts.parent;
 this.parentStart = opts.parentStart;
 this.parentEnd = opts.parentEnd;
 this.progress = 0;
 this._intervalFrequency = 100;
 this._running = false;
 if (this.autoStart) {
   this.start();
}
};

/**
* Start fakeProgress instance
* @method
*/

FakeProgress.prototype.start = function () {
 this._time = 0;
 this._intervalId = setInterval(
   this._onInterval.bind(this),
   this._intervalFrequency
);
};

FakeProgress.prototype._onInterval = function () {
 this._time += this._intervalFrequency;
 this.setProgress(1 - Math.exp((-1 * this._time) / this.timeConstant));
};

/**
* Stop fakeProgress instance and set progress to 1
* @method
*/

FakeProgress.prototype.end = function () {
 this.stop();
 this.setProgress(1);
};

/**
* Stop fakeProgress instance
* @method
*/

FakeProgress.prototype.stop = function () {
 clearInterval(this._intervalId);
 this._intervalId = null;
};

/**
* Create a sub progress bar under the first progres
* @method
* @param {object} options - options of the FakeProgress contructor
* @param {object} [options.end=1] - the progress in the parent that correspond of 100% of the child
* @param {object} [options.start=fakeprogress.progress] - the progress in the parent that correspond of 0% of the child
*/

FakeProgress.prototype.createSubProgress = function (opts) {
 const parentStart = opts.start || this.progress;
 const parentEnd = opts.end || 1;
 const options = Object.assign({}, opts, {
   parent: this,
   parentStart: parentStart,
   parentEnd: parentEnd,
   start: null,
   end: null,
});

 const subProgress = new FakeProgress(options);
 return subProgress;
};

/**
* SetProgress of the fakeProgress instance and updtae the parent
* @method
* @param {number} progress - the progress
*/

FakeProgress.prototype.setProgress = function (progress) {
 this.progress = progress;
 if (this.parent) {
   this.parent.setProgress(
    (this.parentEnd - this.parentStart) * this.progress + this.parentStart
  );
}
};

我们需要核心关注的参数只有timeConstant,autoStart这两个参数,通过阅读源码可以知道timeConstant相当于分母,分母越大则加的越少,而autoStart则是一个开关,如果开启了直接执行start方法,开启累计的定时器。
通过这个库,我们实现一个虚拟的进度条,永远到达不了100%的进度条。
但是如果这时候像接口数据或其他什么资源加载完了,要到100%了怎么办呢?可以看到代码中有end()方法,因此显示的调用下实例的end()方法即可。

实践

上面讲了这么多下面结合圆形进度条(后面再出个手写圆形进度条)来实操一下,效果如下:


代码如下所示:

<template>
 <div ref="main" class="home">
   </br>
   <div>{{ fake.progress }}</div>
   </br>
   <Progress type="circle" :percentage="parseInt(fake.progress*100)"/>
   </br></br>
   <el-button @click="stop">停止</el-button>
   </br></br>
   <el-button @click="close">关闭</el-button>
 </div>
</template>

<script>
import FakeProgress from "fake-progress";

export default {
 data() {
   return {
     fake: new FakeProgress({
       timeConstant : 6000,
       autoStart : true
    })
  };
},
 methods:{
   close() {
     this.fake.end()
  },
   stop() {
     this.fake.stop()
  }
},
};
</script>

总结

如果需要实现一个永远不满的进度条,那么你可以借助fake-progress
核心是1 - Math.exp((-1 * this._time) / this.timeConstant) 这个公式
涉及到一个数据公式: e的负无穷次方 趋近于0。所以1-e^-x永远到不了1,但趋近于1

核心原理就是:用时间做分子,传入的timeConstant做分母,通过Math.exp((-1 * this._time) / this.timeConstant) 可知,如果时间不断累积且为负值,那么Math.exp((-1 * this._time) / this.timeConstant) 就无限趋近于0。所以1 - Math.exp((-1 * this._time) / this.timeConstant) 就可以得到无限趋近于1 的值

总结,如果需要使用的话,在使用的地方创建一个实例即可(配置autoStart之后就会自动累加):

new FakeProgress({
   timeConstant : 6000,
   autoStart : true
})

如果需要操作停止或介绍使用其实例下的对应方法即可

this.fake.end()
this.fake.stop()

作者:前端xs
来源:juejin.cn/post/7219195850539057212

收起阅读 »

低代码开发,是稳扎稳打还是饮鸩止渴?

web
2023年,从业者对低代码的发展充满了想象,人们认为,未来低代码它的商业价值不可估量。据Gartner的最新报告显示,到2023年,全球低代码开发技术市场规模预计将达到269亿美元,比2022年增长19.6%。 随着数字化进入深水区,企业碎片化、个性化、临时...
继续阅读 »

2023年,从业者对低代码的发展充满了想象,人们认为,未来低代码它的商业价值不可估量。据Gartner的最新报告显示,到2023年,全球低代码开发技术市场规模预计将达到269亿美元,比2022年增长19.6%。



随着数字化进入深水区,企业碎片化、个性化、临时化的需求不断涌现,而无论传统应用还是SaaS服务,都无法满足企业的全部需求,企业组织越来越多地转向低代码开发技术,以满足对快速应用交付和高度定制的自动化工作流程不断增长的需求。


image.png


中小企业的IT基础薄弱,人才有限,自研难度很大;中大型企业虽然有专门的IT部门,但审核流程长,业务部门的需求也无法立马满足。而低代码开发,只需编写少量代码或无需代码,就可以快速生成应用程序,在理论上刚好是解决这类问题的钥匙。


全民开发


低代码确实可以满足企业大部分IT需求,普通的业务人员也能进行应用搭建,成为平台的最终用户,写更少的代码,花更少的钱,干更多的事。就算是拥有独立IT部门的中大型企业,也会存在大量临时性边缘的业务需求,低代码可以很好的应对。


image.png


目前市场上有三种类型的低代码厂家:原生厂商、应用软件厂商、云厂商。随着低代码玩家越来越多,整个赛道的竞争将越来越激烈,有从业者发出呐喊:低代码产品未来到底是继续加功能,让更多开发者进来,以此满足客户普遍需求?还是通过一些其他模块或者应用市场的方式来解决客户专业需求?


一些厂商认为应该细分领域,比如深耕CRM、进销存、OKR、人事管理等热门应用模板;还有一部分厂商认为低代码的发展应该要走一条农村包围城市的路,从小处着眼,走普遍路线,主协作,帮助产研内部进行更高效的协同和项目管理,帮助IT部门更好地与业务部门建立起协作关系即可。


image.png


所以,在低代码赛道上,未来的“分流”趋势或将越来越明显。以JNPF为代表的“轻应用”派,由表单所驱动,重视数据处理能力、快速开发能力、低门槛等。


JNPF,立足于低代码开发技术,采用主流的两大技术Java/.Net开发,专注低代码开发,有拖拽式的代码生成器,灵活的权限配置、SaaS服务,强大的接口对接,随心可变的工作流引擎。支持多端协同操作,100%提供源码,支持多种云环境部署、本地部署。


image.png


基于代码生成器,可一站式开发多端使用Web、Android、IOS、微信小程序。代码自动生成后可以下载本地,进行二次开发,有效提高整体开发效率。


开源入口:http://www.yinmaisoft.com/?from=jeuji…


已经覆盖零售、医疗、制造、银行、建筑、教育、社会治理等主流行业,一站式搭建:生产管理系统、项目管理系统、进销存管理系统、OA办公系统、人事财务等等。可以节省开发人员80%时间成本,并且有以构建业务流程、逻辑和数据模型等所需的功能。



这是看得见的价值,但也有看不见的顾虑


有人认为,低代码应用是一种“饮鸩止渴”的行为,会让部分企业觉得,数字化转型就那样,哪些业务需要,就采用低代码应用“缝缝补补”即可,最终浅尝辄止,公司的整个数字化转型停在半道,欠缺完备性、统一性以及系统性。类似的问题,或许在未来会出现,也可能会在低代码应用的迭代过程中被解决。



2023,行至水深处,低代码的路会越来越难走,但这也是黎明前必经的黑暗。稻盛和夫曾说,人生如粥,熬出至味,相信在穿过重重迷雾后,2023年低代

作者:jnpfsoft
来源:juejin.cn/post/7220696541308436541
码也将迎来新的发展。

收起阅读 »

产品说要让excel在线编辑,我是这样做的。

web
背景 最近公司项目有需求, 某导入功能, 想让客户选完excel文件, 直接将加载到web的excel编辑器中, 修改、确认, 之后上传导入。 效果查看 选择 Luckysheet(dream-num.github.io/LuckysheetD…) ,一款...
继续阅读 »

背景


最近公司项目有需求, 某导入功能, 想让客户选完excel文件, 直接将加载到web的excel编辑器中, 修改、确认, 之后上传导入。


效果查看


Kapture 2023-04-13 at 13.37.05.gif


选择



就看到了这两个, 最后选择了Luckysheet, 看他的star比较多, 哈哈。


需求实现分析


分析一下整个流程。


其实大体就两步, 搞进去,抽离出来。


一、加载本地excel到web编辑器中


1、拿到本地excel文件流


2、转换为 Luckysheet 要的格式


3、new 一个 Luckysheet 实例, 挂在到对应标签上


完成以上就把excel加载进去了, 显示出来了。


在线编辑的事就是这个库帮咱们搞定了.


二、 从web编辑器导出文件流 上传


等客户在线编辑完成, 就需要点击一个按钮, 导出文件流, 确认并调接口上传


1、获取 Luckysheet里工作表的数据


image.png


luckysheet.getAllSheets()

2、将数据加工并使用xlsx或者exceljs导出文件流


导出为为arrayBuffer, 再将arrayBuffer转为Blob


3、调后端接口上传


开发实践


一、引入 lucky-sheet


有两种方式


1、官方文档里的cdn


这种加载有点慢


<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/css/pluginsCss.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/plugins.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/css/luckysheet.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/assets/iconfont/iconfont.css' />
<script src="https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/js/plugin.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luckysheet/dist/luckysheet.umd.js"></script>

2、自己打包, 传到oss, 引入(推荐)



第一种第三方的cdn不稳定, 有时候很慢,还是建议,拉他的仓库,然后打个包,传到自己静态资源库, 来使用



npm run builddist 传上去使用


二、指定容器


<div id="luckysheet"></div>

三、导入本地文件


1、 用elment的上传文件组件 选择文件


但是这里不上传,仅仅是用它选择文件拿到文件对象File


<div class="import-okr">
<!-- ,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet -->
<el-upload
v-model:file-list="fileList"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
class="upload-demo"
:before-upload="beforeUpload"
action=""
:show-file-list="false"
>
<button @click="uploadFile">上传数据</button>
</el-upload>
</div>

2、beforeUpload 方法拿到文件


const beforeUpload = (file) => {
console.log(file)
}

image.png


3、将文件流转换为lucky要的格式


github.com/dream-num/L…


安装转换工具


npm install luckyexcel

使用


// After getting the xlsx file
LuckyExcel.transformExcelToLucky(file,
function(exportJson, luckysheetfile){
// exportJson就是转换后的数据
},
function(error){
// handle error if any thrown
}

4、将转换后的数据创建表格


// 将拿到的数据创建表格
luckysheet.create({
container: 'luckysheet', // luckysheet is the container id
data:exportJson.sheets,
title:exportJson.info.name,
userInfo:exportJson.info.creator,
lang: 'zh', // 设定表格语言
myFolderUrl: window.location.href,
showtoolbarConfig: {
pivotTable: false, //'数据透视表'
// protection: false, // '工作表保护'
print:false, // '打印'
image: false, // 插入图片
},
showinfobar: false,
options: {
// 其他配置
userImage:'http://qzz-static.forwe.store/public-assets/pgy_kj_pic_logo.png?x-oss-process=image/resize,m_fill,w_72,h_72', // 头像url
userName:'Lucky', // 用户名
}
});

完整代码


const beforeUpload = (file) => {
console.log(file)
// 转换工具, 将文件流转换为lucky要的格式
LuckyExcel2.transformExcelToLucky(
file,
function(exportJson, luckysheetfile){
isShowExcel.value = true
console.log(exportJson)
nextTick(() => {
window.luckysheet.destroy();
// 将拿到的数据创建表格
luckysheet.create({
container: 'luckysheet', // luckysheet is the container id
data:exportJson.sheets,
title:exportJson.info.name,
userInfo:exportJson.info.creator,
lang: 'zh', // 设定表格语言
myFolderUrl: window.location.href,
showtoolbarConfig: {
pivotTable: false, //'数据透视表'
// protection: false, // '工作表保护'
print:false, // '打印'
image: false, // 插入图片
},
showinfobar: false,
options: {
// 其他配置
userImage:'http://qzz-static.forwe.store/public-assets/pgy_kj_pic_logo.png?x-oss-process=image/resize,m_fill,w_72,h_72', // 头像url
userName:'Lucky', // 用户名
}
});
})
},
function(err){
logger.error('Import failed. Is your fail a valid xlsx?');
});
}

四、导出


1、利用 luckysheet.getAllSheets() 获取表数据


console.log(luckysheet.getAllSheets())

image.png


2、exceljs将上述对象转换为excel文件流


import Excel  from 'exceljs'
// 导出excel
const exportExcel = async function (luckysheet) { // 参数为luckysheet.getluckysheetfile()获取的对象
// 1.创建工作簿,可以为工作簿添加属性
const workbook = new Excel.Workbook()
// 2.创建表格,第二个参数可以配置创建什么样的工作表
luckysheet.every(function (table) {
if (table.data.length === 0) return true
const worksheet = workbook.addWorksheet(table.name)
// 3.设置单元格合并,设置单元格边框,设置单元格样式,设置值
setStyleAndValue(table.data, worksheet)
setMerge(table.config.merge, worksheet)
setBorder(table.config.borderInfo, worksheet)
return true
})
// 4.写入 buffer
const buffer = await workbook.xlsx.writeBuffer()
return buffer
}

3、 写个方法,执行上述两步


// 保存文件
const onClickSaveFile = async ( ) => {
console.log(luckysheet.getAllSheets())
const buf = await exportExcel(luckysheet.getAllSheets())
const blob = new Blob([buf]);
// $emit('file', blob)
handleUpload(blob)
}

4、上传方法


利用formData, 将生成的文件二进制流发给后端


const handleUpload = async(file) => {
// isShowExcel.value = false
const loading = ElLoading.service({
fullscreen: true,
text: '上传中,请稍等',
background: 'rgba(0,0,0,0.1)'
});
try {
const formData = new FormData()
formData.append('file', file)
const {code, data, message } = await IMPORT_OKR(formData)
if(code === 1) {
//...
}
loading.close()
} catch (error) {
console.log(error)
loading.close()
}
}

遇到问题


1、iconfont冲突


lucky-sheet这个项目里的iconfont类名和我项目里一样,导致有些被覆盖了.
image.png


解决: 将他项目里 iconfont 换成 lucky-sheet, 相关类名也全部替换, 然后重新打包,再引入,即可解决


2、lucky-sheet层级不够高,无法编辑


image.png


elmentui和antd的一些组件层级比较高,所以, 让kucky的层级更高即可


解决: 增加下述css即可


.luckysheet-input-box { z-index: 2000; } .luckysheet-cols-menu { z-index: 2001; }

最后


妥妥的都是站在巨人的肩膀上


求赞


作者:浏览器API调用工程师
来源:juejin.cn/post/7221368910139342907
收起阅读 »

KgCaptcha滑动拼图验证码在搜索中的作用

开头验证码应用于我们生活、工作的方方面面,比如注册登录账号、支付订单、修改密码等。下面我是在一次项目中利用滑动拼图验证码和搜索功能“合作共赢”的记录。验证码展示具体实现前端代码// 引入js<script src="captcha.js?appid=XX...
继续阅读 »

开头

验证码应用于我们生活、工作的方方面面,比如注册登录账号、支付订单、修改密码等。下面我是在一次项目中利用滑动拼图验证码和搜索功能“合作共赢”的记录。

验证码展示



具体实现

前端代码
// 引入js
<script src="captcha.js?appid=XXX"></script>
<script>
kg.captcha({
// 绑定弹窗按钮
button: "#captchaButton",

// 验证成功事务处理
success: function (e) {
// 验证成功,直接提交表单
// form1.submit();
console.log(e);
},

// 验证失败事务处理
failure: function (e) {
console.log(e);
},

// 点击刷新按钮时触发
refresh: function (e) {
console.log(e);
}
});
</script>

<a id="captchaButton"></a>



验证结果说明

 

字段名
数据类型描述
 

code
 

number
 

返回code信息
 

msg
 

string
 

验证结果信息
 

rid
 

number
 

用户的验证码应用id
 

sense
 

number
 

是否开启无感验证,0-关闭,1-开启
 

token
 

string
 

验证成功才有:token
 

weight
 

number
 

错误严重性,0正常错误,可以继续操作,1一般错误,刷新/重新加载拼图,2严重错误,错误次数过多拒绝访问


Python代码

from wsgiref.simple_server import make_server
from KgCaptchaSDK import KgCaptcha
def start(environ, response):
# 填写你的 AppId,在应用管理中获取
AppID = "AppId"
# 填写你的 AppSecret,在应用管理中获取
AppSecret = "AppSecret"
request = KgCaptcha(AppID, AppSecret)
# 填写应用服务域名,在应用管理中获取
request.appCdn = "https://cdn.kgcaptcha.com"
# 请求超时时间,秒
request.connectTimeout = 10
# 用户id/登录名/手机号等信息,当安全策略中的防控等级为3时必须填写
request.userId = "kgCaptchaDemo"
# 使用其它 WEB 框架时请删除 request.parse,使用框架提供的方法获取以下相关参数
parseEnviron = request.parse(environ)
# 前端验证成功后颁发的 token,有效期为两分钟
request.token = parseEnviron["post"].get("kgCaptchaToken", "") # 前端 _POST["kgCaptchaToken"]
# 客户端IP地址
request.clientIp = parseEnviron["ip"]
# 客户端浏览器信息
request.clientBrowser = parseEnviron["browser"]
# 来路域名
request.domain = parseEnviron["domain"]
# 发送请求
requestResult = request.sendRequest()
if requestResult.code == 0:
# 验证通过逻辑处理
html = "验证通过"
else:
# 验证失败逻辑处理
html = f"{requestResult.msg} - {requestResult.code}"
response("200 OK", [("Content-type", "text/html; charset=utf-8")])
return [bytes(str(html), encoding="utf-8")]
httpd = make_server("0.0.0.0", 8088, start) # 设置调试端口 http://localhost:8088/
httpd.serve_forever()


最后

SDK开源地址:KgCaptcha (KgCaptcha) · GitHub,顺便做了一个演示:凯格行为验证码在线体验

收起阅读 »

Java实现KgCaptcha短信验证码

背景Java是一种流行的编程语言,验证码是一种常用的网络安全技术。Java发展至今,网上也出现了各种各样的验证码,本人初学Java,下面是我用Java实现短信验证码的总结。截图展示实现代码后台接收前台的kgCaptchaToken进行验证,验证成功执行成功处理...
继续阅读 »

背景

Java是一种流行的编程语言,验证码是一种常用的网络安全技术。Java发展至今,网上也出现了各种各样的验证码,本人初学Java,下面是我用Java实现短信验证码的总结。

截图展示



实现代码

后台接收前台的kgCaptchaToken进行验证,验证成功执行成功处理,验证失败返回错误代码及信息。

package com.kyger;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;

public class demo extends HttpServlet {
private static final long serialVersionUID = 1L;

public demo() {
super();
}

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

// 编码
request.setCharacterEncoding("utf-8");
response.setCharacterEncoding("utf-8");;
response.setContentType("text/html; charset=utf-8");

// 后台处理
if (request.getMethod().equals("POST")){
String html, appId, appSecret, Token;

// 设置 AppId 及 AppSecret,在应用管理中获取
appId = "appId";
appSecret = "appSecret";

// 填写你的 AppId 和 AppSecret,在应用管理中获取
KgCaptchaSDK KgRequest = new KgCaptchaSDK(appId, appSecret);


// 前端验证成功后颁发的 token,有效期为两分钟
KgRequest.token = request.getParameter("kgCaptchaToken");
// System.out.print(KgRequest.token);

// 填写应用服务域名,在应用管理中获取
KgRequest.appCdn = "https://cdn.kgcaptcha.com";

// 请求超时时间,秒
KgRequest.connectTimeout = 5;

// 用户登录或尝试帐号,当安全策略中的防控等级为3时必须填写,一般情况下可以忽略
// 可以填写用户输入的登录帐号(如:request.getParameter("username"),可拦截同一帐号多次尝试等行为
KgRequest.userId = "kgCaptchaDemo";

// request 对象,当安全策略中的防控等级为3时必须填写,一般情况下可以忽略
KgRequest.request = request;
// java 环境中无法提供 request 对象,请分别定义:clientIp|clientBrowser|domain 参数,即:
// KgRequest.clientIp = "127.0.0.1"; // 填写客户端IP
// KgRequest.clientBrowser = ""; // 客户端浏览器信息
// KgRequest.domain = "http://localhost"; // 你的授权域名或服务IP

// 发送验证请求
Map requestResult = KgRequest.sendRequest();
if("0".toString().equals(requestResult.get("code"))) {
// 验签成功逻辑处理 ***

// 这里做验证通过后的数据处理
// 如登录/注册场景,这里通常查询数据库、校验密码、进行登录或注册等动作处理
// 如短信场景,这里可以开始向用户发送短信等动作处理
// ...

html = "";
} else {
// 验签失败逻辑处理
html = "";
}

response.getWriter().append(html);
} else {
response.sendRedirect("index.html");
}
}

protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
doGet(request, response);
}

}


后端检测

后台接收数据,同时对来源及应用进行检测。

# 服务器黑名单检测
if self.auth.client_blacklist():
return self.r_code(20017) # 服务器黑名单

# 验签次数限制检测
excess = self.auth.excess(2)
if excess:
return self.r_code(code=[20020, 20021, 20022][excess - 1])

# 来路域名检测
if not self.kg["HTTP_REFERER"]: return self.r_code(20004) # 域名不合法,无法获取来路域名
if not self.auth.domain_auth(): return self.r_code(20005) # 来源域名未授权

# 应用有效时间检测
validity = self.auth.app_validity()
if validity[0] == 1: return self.r_code(20006) # 授权未开始
if validity[0] == 2: return self.r_code(20007) # 授权已结束

if self.auth.app_state(): return self.r_code(20008) # 当前应用/域名被禁用


结尾

SDK开源地址:KgCaptcha (KgCaptcha) · GitHub,顺便做了一个演示:凯格行为验证码在线体验

收起阅读 »

🚀 我用一小时实现的娃娃机,你敢信?

web
生活不止眼前的苟且,还有诗和远方 掘友们,大家好我是前端奶爸,入行5年的前端小学生🥜~ 工作八小时摸鱼四小时,喜欢跑步但不是为了让自己更瘦,而是为了让自己活得更久~ 活到九十九,卷到九十九~ 前言 前段时间去商场吃饭的时候看到一个有趣的娃娃机,一个密封的机...
继续阅读 »

生活不止眼前的苟且,还有诗和远方



掘友们,大家好我是前端奶爸,入行5年的前端小学生🥜~

工作八小时摸鱼四小时,喜欢跑步但不是为了让自己更瘦,而是为了让自己活得更久~

活到九十九,卷到九十九~



前言


前段时间去商场吃饭的时候看到一个有趣的娃娃机,一个密封的机器里底部放着一些被捆绑好的龙虾,可以买币去抓龙虾,抓到以后可以初加工费找附近的商家给做成龙虾大餐,感觉很有意思,把抓抓玩出了一个新的高度~


主要是抓到以后还可以出手工费进行烹饪,很吸引人,周边围观的人也很多,观察了一会发现。爪子的抓力不够,龙虾在水里还能移动,而且感觉每一个个头都不小,那小爪感觉根本抓不起来~~


到家后孩子就说爸爸你可不可以做一个娃娃机呢?


身为一个程序员,这点要求我感觉还是难不倒我,然后就突发奇想,给孩子在手机上做一个简易娃娃机。起初的想法是哄她开心,看到掘金最近有小游戏的活动,顺便分享给大家~~


效果


简易娃娃机.gif


如上图,一个移动的抓手,以及几个礼物样品,还有左右移动,抓起按钮,素材很简单,但是做出来的效果还是有娃娃机的感觉的~


地址


代码托管地址在:github在线预览地址资源路径不对无法访问,如果有需要源码的同学可以自行去git仓库获取~


布局


布局部分比较简单,直接贴代码了。可以根据自己的需求不同自定义即可~


<div class="page-portrait" id="page-portrait">
<div id="pageContainer" class="page-container game-box">
<div class="poster-main">
<ul class="poster-list">
<li class="item lw1"><img src="images/dx-lw1.png" alt=""></li>
<li class="item lw2"><img src="images/dx-lw2.png" alt=""></li>
<li class="item lw3"><img src="images/dx-lw3.png" alt=""></li>
<li class="item lw4"><img src="images/dx-lw4.png" alt=""></li>
<li class="item lw5"><img src="images/dx-lw5.png" alt=""></li>
<li class="item lw6"><img src="images/dx-lw6.png" alt=""></li>
</ul>
</div>
<div id="stop" class="button"></div>
<div id="left" class="left-btn"></div>
<div id="right" class="right-btn"></div>
<div class="zhua-top">
<span class="zhua-zuo"></span>
<span class="zhua-zhu"></span>
<div class="zhua zhuamove"></div>
</div>
</div>
</div>

css用到了几个运动处理了爪子的动效,如下方代码所示


@keyframes run {
0% {
background-image: url(../images/dx-zhua3.png);
}
25% {
background-image: url(../images/dx-zhua2.png);
}
50% {
background-image: url(../images/dx-zhua1.png);
}
75% {
background-image: url(../images/dx-zhua2.png);
}
100% {
background-image: url(../images/dx-zhua3.png);
}
}
@keyframes zhuashou {
0% {
top: 360px;
background-image: url(../images/dx-zhua1.png);
}
100% {
top: 360px;
background-image: url(../images/dx-zhua2.png);
}
}
@keyframes zhuadown {
0% {
top: 138px;
background-image: url(../images/dx-zhua1.png);
}
100% {
top: 360px;
background-image: url(../images/dx-zhua1.png);
}
}
@keyframes zhua-slideUp {
0% {
top: 360px;
background-image: url(../images/dx-zhua2.png);
}
100% {
top: 138px;
background-image: url(../images/dx-zhua2.png);
}
}
@keyframes img-slideUp {
0% {
top: 23px;
}
100% {
top: -200px;
}
}

js代码创建了一个控制器类,处理事件以及动画效果的交替等。


var Carousel = {
data: {
result: 1
},
init: function () {
Carousel.control();
},
stop: function () {
$(".zhua").removeClass("zhuamove").addClass("zhuadown");
$(".zhua-zhu").addClass("zhudown");
var timer01 = setTimeout(function () {
$(".zhua").removeClass("zhuadown").addClass("zhuashou");
var timer03 = setTimeout(function () {
$(".zhua").removeClass("zhuashou").addClass("zhuaup");
$(".zhua-zhu").removeClass("zhudown").addClass("zhuup");
$(".poster-list .lw" + (Carousel.data.result + 1)).addClass("img-slideUp");
clearTimeout(timer03);
timer03 = null;
}, 800);
var timer02 = setTimeout(function () {
$(".zhua").removeClass("zhuaup").removeClass("zhuaup1");
$(".zhua-zhu").removeClass("zhuup");
clearTimeout(timer02);
timer02 = null;
alert("恭喜您抽中一等奖~");
Carousel.start();
}, 2500);
clearTimeout(timer01);
timer01 = null;
}, 1000);
},
start: function () {
$(".zhua").addClass("zhuamove");
$(".zhua").removeClass("zhuadown").removeClass("zhuaup1").removeClass("zhuaup");
$(".poster-list .item").removeClass("img-slideUp").removeClass("img-slideOutUp");
},
zhuaMove: function (num) {
switch (num) {
case 0:
$(".zhua-top").animate({
left: -145,
},300);
break;
case 1:
$(".zhua-top").animate({
left: 0,
},300);
break;
case 2:
$(".zhua-top").animate({
left: 145,
},300);
break;
}
},
control: function () {
$("#left").on("click", function () {
Carousel.data.result--;
if (Carousel.data.result <= 0) {
Carousel.data.result = 0;
}
Carousel.zhuaMove(Carousel.data.result);
});
$("#stop").click(Carousel.stop);
$("#right").on("click", function () {
Carousel.data.result++;
if (Carousel.data.result >= 2) {
Carousel.data.result = 2;
}
Carousel.zhuaMove(Carousel.data.result);
});
},
};

总结


css现在有很多的新的特性可以解决我们工作中遇到的动效以及兼容问题,有心的同学可以多多查阅文档,写一写自己感兴趣的小demo,或者给孩子做一个小游戏来玩,何尝不是一件有成就的事呢~


我是奶爸,喜欢我的可以关注我,有什么新的想法或者意见也可以在评论区留言,我们共同学习,共同进步~



最后希望疫情早早结束,微风袭来,春暖花开~~~



作者:前端奶爸
来源:juejin.cn/post/7089371535588196366
收起阅读 »

【404】你访问的页面需要关灯后查看!

web
前言 今天在掘金首页刷到一篇文章,就是那种文字根据不同的色块显示不同的颜色,我想着能不能做一个探照灯似的 404 页面呢。毕竟也可以根据不同的白色光照来改变文字颜色的。 为了酷炫一点,先来个背景 👉 背景相对来说比较简单了,就是一些纯粹的漂浮点 <div...
继续阅读 »

前言


今天在掘金首页刷到一篇文章,就是那种文字根据不同的色块显示不同的颜色,我想着能不能做一个探照灯似的 404 页面呢。毕竟也可以根据不同的白色光照来改变文字颜色的。


为了酷炫一点,先来个背景


👉 背景相对来说比较简单了,就是一些纯粹的漂浮点


<div>
<div class="starsec"></div>
<div class="starthird"></div>
<div class="starfourth"></div>
<div class="starfifth"></div>
</div>

👉 为了显得与众不同,我们就用四个不同的 div 元素来写样式


.starsec {
content: " ";
position: absolute;
width: 3px;
height: 3px;
background: transparent;
box-shadow: 571px 173px #00BCD4, 1732px 143px #00BCD4, 1745px 454px #FF5722, 234px 784px #00BCD4, 1793px 1123px #FF9800, 1076px 504px #03A9F4, 633px 601px #FF5722, 350px 630px #FFEB3B, 1164px 782px #00BCD4, 76px 690px #3F51B5, 1825px 701px #CDDC39, 1646px 578px #FFEB3B, 544px 293px #2196F3, 445px 1061px #673AB7, 928px 47px #00BCD4, 168px 1410px #8BC34A, 777px 782px #9C27B0, 1235px 1941px #9C27B0, 104px 1690px #8BC34A, 1167px 1338px #E91E63, 345px 1652px #009688, 1682px 1196px #F44336, 1995px 494px #8BC34A, 428px 798px #FF5722, 340px 1623px #F44336, 605px 349px #9C27B0, 1339px 1344px #673AB7, 1102px 1745px #3F51B5, 1592px 1676px #2196F3, 419px 1024px #FF9800, 630px 1033px #4CAF50, 1995px 1644px #00BCD4, 1092px 712px #9C27B0, 1355px 606px #F44336, 622px 1881px #CDDC39, 1481px 621px #9E9E9E, 19px 1348px #8BC34A, 864px 1780px #E91E63, 442px 1136px #2196F3, 67px 712px #FF5722, 89px 1406px #F44336, 275px 321px #009688, 592px 630px #E91E63, 1012px 1690px #9C27B0, 1749px 23px #673AB7, 94px 1542px #FFEB3B, 1201px 1657px #3F51B5, 1505px 692px #2196F3, 1799px 601px #03A9F4, 656px 811px #00BCD4, 701px 597px #00BCD4, 1202px 46px #FF5722, 890px 569px #FF5722, 1613px 813px #2196F3, 223px 252px #FF9800, 983px 1093px #F44336, 726px 1029px #FFC107, 1764px 778px #CDDC39, 622px 1643px #F44336, 174px 1559px #673AB7, 212px 517px #00BCD4, 340px 505px #FFF, 1700px 39px #FFF, 1768px 516px #F44336, 849px 391px #FF9800, 228px 1824px #FFF, 1119px 1680px #FFC107, 812px 1480px #3F51B5, 1438px 1585px #CDDC39, 137px 1397px #FFF, 1080px 456px #673AB7, 1208px 1437px #03A9F4, 857px 281px #F44336, 1254px 1306px #CDDC39, 987px 990px #4CAF50, 1655px 911px #00BCD4, 1102px 1216px #FF5722, 1807px 1044px #FFF, 660px 435px #03A9F4, 299px 678px #4CAF50, 1193px 115px #FF9800, 918px 290px #CDDC39, 1447px 1422px #FFEB3B, 91px 1273px #9C27B0, 108px 223px #FFEB3B, 146px 754px #00BCD4, 461px 1446px #FF5722, 1004px 391px #673AB7, 1529px 516px #F44336, 1206px 845px #CDDC39, 347px 583px #009688, 1102px 1332px #F44336, 709px 1756px #00BCD4, 1972px 248px #FFF, 1669px 1344px #FF5722, 1132px 406px #F44336, 320px 1076px #CDDC39, 126px 943px #FFEB3B, 263px 604px #FF5722, 1546px 692px #F44336;
animation: animStar 150s linear infinite;
}

👉 颜色阴影部分都是一样的,不一样的地方就在于宽高和动画时长。


👉 大家可以根据自己的想法去修改不同的宽高和时长哦


👉 动画效果需要额外写一下的哦


@keyframes animStar {
0% {
transform: translateY(0px);
}

100% {
transform: translateY(-2000px);
}
}

screenshots.gif


画灯杆(电线)


👉 一般探照灯都是在顶上的,所以就需要用一根电线连接在顶部


<div class="lamp__wrap">
<div class="lamp">
<div class="cable"></div>
</div>
</div>


  • 后面的灯元素相关内容都会在 lamp 样式标签下面哦!


.lamp__wrap {
max-height: 100vh;
overflow: hidden;
max-width: 100vw;
}
.lamp {
position: absolute;
left: 0px;
right: 0px;
top: 0px;
margin: 0px auto;
width: 300px;
display: flex;
flex-direction: column;
align-items: center;
transform-origin: center top;
animation-timing-function: cubic-bezier(0.6, 0, 0.38, 1);
animation: move 5.1s infinite;
}


  • 在处理动画的时候,使用了一个 cubic-bezier 方法,它是用来定义贝塞尔曲线的


@keyframes move {
0% {
transform: rotate(40deg);
}

50% {
transform: rotate(-40deg);
}

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


  • 动画效果就是将灯杆旋转不同的角度



注意一下,动画效果是在整个灯的样式中完成的,所以后面的都只需要写各自的样式就行了,不需要补充动画效果。



.cable {
width: 8px;
height: 248px;
background-image: linear-gradient(rgb(32 148 218 / 70%), rgb(193 65 25)), linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7));
}


  • 灯杆给了一个渐变色的样式效果


screenshots.gif


画灯罩


👉 灯杆已经有了,那就加一个灯罩就行了


<div class="cover"></div>

.cover {
width: 200px;
height: 80px;
background: #0bd5e8;
border-top-left-radius: 50%;
border-top-right-radius: 50%;
position: relative;
z-index: 200;
}


  • 灯罩是通过不同的 border-radius 的效果画出来的


screenshots.gif


画灯泡


👉 灯泡也是比较简单的样式,一个半圆加一部分阴影即可


<div class="in-cover">
<div class="bulb"></div>
</div>

.in-cover {
width: 100%;
max-width: 200px;
height: 20px;
border-radius: 100%;
background: #08ffff;
position: absolute;
left: 0px;
right: 0px;
margin: 0px auto;
bottom: -9px;
z-index: 100;
}

.in-cover .bulb {
width: 50px;
height: 50px;
background-color: #08fffa;
border-radius: 50%;
position: absolute;
left: 0px;
right: 0px;
bottom: -20px;
margin: 0px auto;
-webkit-box-shadow: 0 0 15px 7px rgba(0, 255, 255, 0.8), 0 0 40px 25px rgba(0, 255, 255, 0.5), -75px 0 30px 15px rgba(0, 255, 255, 0.2);
box-shadow: 0 0 25px 7px rgb(127 255 255 / 80%), 0 0 64px 47px rgba(0, 255, 255, 0.5), 0px 0 30px 15px rgba(0, 255, 255, 0.2);
}

screenshots.gif


来一束追光效果吧


👉 追光就是通过一个边框线画出来的


<div class="light"></div>

.light {
width: 200px;
height: 0px;
border-bottom: 900px solid rgb(44 255 255 / 24%);
border-left: 50px solid transparent;
border-right: 50px solid transparent;
position: absolute;
left: 0px;
right: 0px;
top: 270px;
margin: 0px auto;
z-index: 1;
border-radius: 90px 90px 0px 0px;
}


  • 给边框的宽度和背景透明色就可以看出追光的效果了。


screenshots.gif


文字


👉 文字通过定位居中之后,刚好显示在灯光动画效果范围之内


<section class="error">
<div class="error__content">
<div class="error__message message">
<h1 class="message__title">掘金错误页面</h1>
<p class="message__text">不好意思,你访问的页面不存在,请关灯后重新尝试</p>
</div>
</div>
</section>

👉 文字颜色和背景色一致之后,通过灯光的透明度效果就可以实现文字显隐了。


.error {
min-height: 100vh;
position: relative;
padding: 240px 0;
box-sizing: border-box;
width: 100%;
height: 100%;
text-align: center;
margin-top: 70px;
}

.error__overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}

.error__content {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
-webkit-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}

.error__message {
text-align: center;
color: #181828;
}

.message__title {
font-family: 'Montserrat', sans-serif;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 5px;
font-size: 5.6rem;
padding-bottom: 40px;
max-width: 960px;
margin: 0 auto;
}

.message__text {
font-family: 'Montserrat', sans-serif;
line-height: 42px;
font-size: 18px;
padding: 0 60px;
max-width: 680px;
margin: auto;
}

screenshots.gif


码上掘金查看效果



作者:蜡笔小心_
来源:juejin.cn/post/7150950812489875469
收起阅读 »

记录一次机器学习模型部署

web
简介:做了一个语音识别心脏病的机器学习模型,想要实现前后端简单的部署 用到的技术栈:Python、Flask、uni-app 前端 使用uni-app做一个小程序。需要具有语音采集、录音回放、录音上传等功能。使用uni.getRecorderManager()...
继续阅读 »

简介:做了一个语音识别心脏病的机器学习模型,想要实现前后端简单的部署


用到的技术栈:Python、Flask、uni-app


前端


使用uni-app做一个小程序。需要具有语音采集、录音回放、录音上传等功能。使用uni.getRecorderManager()实现对录音全局的控制。


屏幕截图 2023-04-04 155350.png


这里给出实现上传录音并接收请求结果的主要代码


upload() {
console.log(this.voicePath)
uni.uploadFile({
url: 'http://202.115.52.33:9500/process_data',
filePath: this.voicePath,
name: 'file',
fileType: "audio", //文件类型
success: (res) => {
console.log('success',res.data)
uni.showToast({
title: '上传成功',
icon: 'success',
});
if(res.data*1 < 0.35){
uni.showModal({
title: '检测结果',
content: '您患心脏病概率为:'+ Number(res.data*100).toFixed(2) + '%'+',心脏健康请继续保持',
success: function (res) {
if (res.confirm) {
console.log('用户点击确定');
} else if (res.cancel) {
console.log('用户点击取消');
}
}
});
}else{
uni.showModal({
title: '检测结果',
content: '您患心脏病概率为:'+ Number(res.data*100).toFixed(2) + '%'+',请及时到医院检查',
success: function (res) {
if (res.confirm) {
console.log('用户点击确定');
} else if (res.cancel) {
console.log('用户点击取消');
}
}
});
}

},
fail: (err) => {
console.log((err))
uni.showToast({
title: '上传失败',
icon: 'none',
});
},
});
}

这里有个坑需要注意,微信开发者工具模拟器录音上传到服务器,服务器无法正常使用录音(一直以为是前端上传语音的问题)。开发者工具录音文件为silk格式,说是silk其实是base64加密后的webm格式,不是普通的wav格式(貌似只能用chrome浏览器打开)。可以参考这篇文章微信小程序-录音文件无法播放问题 - 知乎 (zhihu.com),真机调试则不会出现这个问题。


后端


采用Flask来进行机器学习或者深度学习模型的部署。


# app.py
from flask import Flask, request
from predict import predict

app = Flask(__name__)

@app.route('/process_data', methods=['POST'])
def process_data():
    # 从前端接收音频文件
    fileStorage = request.files['file']  # 视频文件
    buffer_data = fileStorage.read()
    filename = request.files['file'].filename
    temp_path = 'upload/'+filename
    with open(temp_path, 'wb+') as f:
        f.write(buffer_data)  # 二进制转为音频文件
    # 模型推理
    predict_outcome = round(predict(temp_path), 4)
    # 返回结果
    return str(predict_outcome)


if __name__ == "__main__":
    app.run()

部署


使用宝塔面板实现Flask项目快速部署。



  1. 在宝塔面板中安装Python项目管理器软件


屏幕截图 2023-04-04 162932.png



  1. 上传Flask项目到服务器相应目录

  2. 在Python项目管理选择Flask框架,安装Flask项目中需要的第三方包
    这里有个需要注意的问题,我修改了第三方包的源码,下载的第三方包存放目录:上传项目文件夹/一串数字_venv/lib/python3.7/site-packages,在这里修改源码重启Python服务才能生效。

  3. Python项目管理器配置参考


bind = '0.0.0.0:5000'
user = 'scu611'
workers = 1
threads = 2
backlog = 512
daemon = True
chdir = '/www/server/phpmyadmin/heartbroken'
access_log_format = '%(t)s %(p)s %(h)s "%(r)s" %(s)s %(L)s %(b)s %(f)s" "%(a)s"'
loglevel = 'info'
worker_class = 'geventwebsocket.gunicorn.workers.GeventWebSocketWorker'
errorlog = chdir + '/logs/error.log'
accesslog = chdir + '/logs/access.log'
pidfile = chdir + '/logs/heartbroken.pid'

作者:用户7850680667062
来源:juejin.cn/post/7218098727608549432
收起阅读 »

GeoJSON:地理信息的JSON表示法

web
简介 GeoJSON 是一种使用 JSON 来编码各种地理数据结构的格式,是一种轻量级的数据交换格式,可以用来表示几何对象、属性数据、空间参考系统等信息 由两种对象组成:Geometry(几何对象)和 Feature(空间行状) 几何对象用来描述地理空间中的...
继续阅读 »

简介


GeoJSON 是一种使用 JSON 来编码各种地理数据结构的格式,是一种轻量级的数据交换格式,可以用来表示几何对象、属性数据、空间参考系统等信息


由两种对象组成:Geometry(几何对象)和 Feature(空间行状)



  • 几何对象用来描述地理空间中的点、线、面等几何形状

  • 空间行状用来描述一个有界的实体,包括几何对象和其他属性信息


几何对象类型有:



  • 点:Point

  • 多点:MultiPoint

  • 线:LineString

  • 多线:MultiLineString

  • 面:Polygon

  • 多面:MultiPolygon

  • 几何集合:GeometryCollection


空间行状类型有:



  • 空间行状:Feature

  • 空间形状集合:FeatureCollection


举例


几何对象和空间行状可以相互嵌套


const GeoJSON = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: [121.4737, 31.2304] },
properties: { id: 1 },
},
{
type: "Feature",
geometry: { type: "Point", coordinates: [121.4837, 31.2504] },
properties: { id: 2 },
},
],
};

空间行状


FeatureCollection


FeatureCollectionFeature 对象的集合,用来表示一组 Feature 对象


typefeatures 两个属性组成:



  • type 属性的值为 FeatureCollection

  • features 属性的值为 Feature 对象的数组


const FeatureCollectionJSON = {
type: "FeatureCollection",
features: [feature],
};

Feature


Feature 对象用来表示几何对象的属性信息


typegeometryproperties 三个属性组成:



  • type 属性的值为 Feature

  • geometry 属性的值为 Geometry 几何对象

  • properties 属性的值为属性对象(可选)


const FeatureJSON = {
type: "Feature",
geometry: { type: "Point", coordinates: [121.4737, 31.2304] },
properties: { id: 1 },
};

几何对象


Point


Point 用来表示一个点


typecoordinates 两个属性组成:



  • type 属性的值为 Point

  • coordinates 属性的值为一个数组,数组的第一个元素为经度,第二个元素为纬度


const PointJSON = {
type: "Point",
coordinates: [121.4737, 31.2304],
};

MultiPoint


MultiPoint 用来表示多个点


typecoordinates 两个属性组成:



  • type 属性的值为 MultiPoint

  • coordinates 属性的值为一个数组,数组的每个元素都是一个点的坐标


const MultiPointJSON = {
type: "MultiPoint",
coordinates: [
[121.4737, 31.2304],
[121.4837, 31.2504],
],
};

LineString


LineString 用来表示一条线


typecoordinates 两个属性组成:



  • type 属性的值为 LineString

  • coordinates 属性的值为一个数组,数组的每个元素都是一个点的坐标


const LineStringJSON = {
type: "LineString",
coordinates: [
[121.4737, 31.2304],
[121.4837, 31.2504],
],
};

MultiLineString


MultiLineString 用来表示多条线


typecoordinates 两个属性组成:



  • type 属性的值为 MultiLineString

  • coordinates 属性的值为一个数组,数组的每个元素都是一个线的坐标数组


const MultiLineStringJSON = {
type: "MultiLineString",
coordinates: [
[
[121.4737, 31.2304],
[121.4837, 31.2504],
],
[
[121.4727, 31.2314],
[121.4827, 31.2514],
],
],
};

Polygon


Polygon 用来表示一个面


typecoordinates 两个属性组成:



  • type 属性的值为 Polygon

  • coordinates 属性的值为一个数组,数组的第一个元素为外环的坐标数组,后面的元素为内环的坐标数组


polygon 的坐标数组的第一个元素和最后一个元素是相同的,表示闭合


const PolygonJSON = {
type: "Polygon",
coordinates: [
[
[121.4737, 31.2304],
[121.4837, 31.2504],
[121.4937, 31.2304],
[121.4737, 31.2304],
],
[
[121.4717, 31.2314],
[121.4827, 31.2524],
[121.4937, 31.2334],
[121.4757, 31.2344],
],
],
};

MultiPolygon


MultiPolygon 用来表示多个面


typecoordinates 两个属性组成:



  • type 属性的值为 MultiPolygon

  • coordinates 属性的值为一个数组,数组的每个元素都是一个面的坐标数组


const MultiPolygonJSON = {
type: "MultiPolygon",
coordinates: [
[
[
[121.4737, 31.2304],
[121.4837, 31.2504],
[121.4937, 31.2304],
[121.4737, 31.2304],
],
[
[121.4737, 31.2304],
[121.4837, 31.2504],
[121.4937, 31.2304],
[121.4737, 31.2304],
],
],
[
[
[121.4737, 31.2304],
[121.4837, 31.2504],
[121.4937, 31.2304],
[121.4737, 31.2304],
],
[
[121.4737, 31.2304],
[121.4837, 31.2504],
[121.4937, 31.2304],
[121.4737, 31.2304],
],
],
],
};

GeometryCollection


GeometryCollection 用来表示几何对象的集合


typegeometries 两个属性组成:



  • type 属性的值为 GeometryCollection

  • geometries 属性的值为几何对象的数组


const GeometryCollectionJSON = {
type: "GeometryCollection",
geometries: [
{ type: "Point", coordinates: [121.4737, 31.2304] },
{
type: "LineString",
coordinates: [
[121.4737, 31.2304],
[121.4837, 31.2504],
],
},
],
};

可选属性


这些属性都是 GeoJSON 的扩展属性,不是 GeoJSON 规范的一部分



  • id 属性,用来描述 FeatureCollection 的唯一标识

  • bbox 属性,用来描述 FeatureCollection 的边界框

    • 四至坐标,一般用来做数据裁剪

    • 这是一组左上角和右下角的坐标,示例:[minLon, minLat, maxLon, maxLat]



  • properties 属性,用来描述 FeatureCollection 的属性

  • crs 属性,用来描述坐标参考系


其他


coordinate


coordinate 是一个数组,表示一个点的坐标,数组的长度表示坐标的维度,一般是 2 维或 3



  • 2 维:[lon, lat]

  • 3 维:[lon, lat, height]


coordinate 的第一个元素表示经度,第二个元素表示纬度,第三个元素表示高度


坐标顺序是 [lon, lat],这个是推荐顺序,可由 crs 属性指定


coordinates 是多维数组:



  • 点:[lon, lat]

  • 线:[[lon, lat], [lon, lat]]

  • 面:[[[lon, lat], [lon, lat]]]

  • 多面:[[[[lon, lat], [lon, lat]]]]


坐标参考系


最常使用的坐标系是 EPSG:4326EPSG:3857



  • EPSG:4326WGS84(CGCS2000,大地) 坐标系,是 GeoJSON 规范的默认坐标系

  • EPSG:3857Web Mercator(墨卡托) 坐标系,是 OpenLayers 的默认坐标系


它们的区别:



  • EPSG:4326 是经纬度坐标系,EPSG:3857 是投影坐标系

  • EPSG:4326 的坐标范围是 [-180, -90, 180, 90]EPSG:3857 的坐标范围是 [-20037508.342789244, -20037508.342789244, 20037508.342789244, 20037508.342789244]

  • EPSG:4326 的坐标单位是度,EPSG:3857 的坐标单位是米

  • EPSG:4326 的坐标原点是 [0, 0]EPSG:3857 的坐标原点是 [-20037508.342789244, -20037508.342789244]

  • EPSG:4326 的坐标轴方向是 [x, y]EPSG:3857 的坐标轴方向是 [x, -y]


在 ts 中使用


为了在 ts 使用 GeoJSON 能够有类型约束,我整理整理了一些 GeoJSONts 类型定义和创建 GeoJSON 的方法:



举例:




  1. 表示一个点和多个点的 GeoJSON 集合:


    使用geojson.d.ts


    type PointType = FeatureCollection<Point | MultiPoint, GeoJsonProperties<T>>;

    const point2Geojson: PointType<{ id: string; name?: string }> = {
    type: "FeatureCollection",
    features: [
    {
    type: "Feature",
    geometry: {
    type: "Point",
    coordinates: [120.4737, 31.2304],
    },
    properties: { id: "12", name: "uccs" },
    },
    {
    type: "Feature",
    geometry: {
    type: "MultiPoint",
    coordinates: [
    [121.4737, 31.2304],
    [111.4737, 31.2204],
    ],
    },
    properties: { id: "1" },
    },
    ],
    };



  2. 创建一个几何对象


    使用geojson.helper.ts


    const pointGeometry = point<{ id: string }>([120.4737, 31.2304], {
    id: "1",
    });
    const featureGeoJSON = feature<Point>(pointGeometry);



参考


收起阅读 »

css是你永远学不会的语言

web
在网上冲浪的时候,看到有这么一个网页效果;如下图: 分析: 从图中我们可以看出,当我们鼠标移入的时候,下划线冲左侧开始展示;当鼠标移出的时候,下划线从左侧开始消失 既然我们知道了大致的效果,那我们就得想想怎么实现这个效果了。 我是没有想出来的,我用F12查看...
继续阅读 »

在网上冲浪的时候,看到有这么一个网页效果;如下图:


20230330_10:43:33_1.gif


分析:


从图中我们可以看出,当我们鼠标移入的时候,下划线冲左侧开始展示;当鼠标移出的时候,下划线从左侧开始消失


既然我们知道了大致的效果,那我们就得想想怎么实现这个效果了。


我是没有想出来的,我用F12查看了一下;如下图代码:


未移入样式:


微信图片_20230330105126.png


移入样式(hover):


微信图片_20230330105126.png


代码分析



  • 背景色渐变以及方向(移入移出)

  • 背景大小

  • 过度时间


示列代码


解释:



  • 我们在css中写span的样式时;background需要禁止平铺,然后是靠右(right)并且是底部的;原因是以为收回去的时候需要方向是右侧(right);然后background-size需要将宽度设置为0 高度为2(你可以根据自己的需要设置);最后是给background-size一个过度效果既可以

  • 然后hover事件的时候需要将定位给到左侧(left)并且将background-size宽度百分之百;这样就会根据过度时间显示完成


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.title {
color: #333;
line-height: 2px;
}

.title span {
background: linear-gradient(to right, #ec695c,#61c454) no-repeat right bottom;
background-size: 0 2px;
transition: background-size 1300ms;
}
.title span:hover {
background-position-x: left;
background-size: 100% 2px;
}
</style>
</head>
<body>
<h2 class="title">
<span>
Migrating From MySQL to YugabyteDB Using YugabyteDB Voyager</span>
</h2>
</body>
</html>

以上就是今天的全部内容了!大家可以复制以上代码,即可展现效果。


当我做了以后发现,css真的是我的弱势;或者说大部分人都不怎么关注css;毕竟面试的时候大部分公司都不是很要求这个,从而我们就忽略了个语言;css真的我永远学不会的语言啊


后面我也就开一个专辑,我所遇到的css相关的一些东西


往期文章



作者:雾恋
来源:juejin.cn/post/7216163778059550757
收起阅读 »

纯前端就可以实现的两个需求

web
一:多文件下载并压缩成zip形式   我们一般看到这个需求,大多前端都会觉得这不就是一个接口的事嘛,咱们直接window.open()就搞定了,emm...,我之前也是这样想的,可惜现在是在三线城市的小互联网公司,人家后端说服务器不行,带宽不够,出不来接口0....
继续阅读 »

一:多文件下载并压缩成zip形式


  我们一般看到这个需求,大多前端都会觉得这不就是一个接口的事嘛,咱们直接window.open()就搞定了,emm...,我之前也是这样想的,可惜现在是在三线城市的小互联网公司,人家后端说服务器不行,带宽不够,出不来接口0.0,所以我就尝试着寻找……最终找到了解决办法——


  前端可以直接从cos资源服务器中下载多个文件,并放进压缩包中哦,这样就省去了后端在中间中转的那个环节,实现方式如下:


1.安装依赖


  我们需要用到两个第三方依赖哦,分别执行以下安装


npm i jszip
npm i file-saver

2.引入 


  在需要使用的时候,我们引入


import JSZip from "jszip";
import FileSaver from "file-saver";

3.实现过程


  我这里是在vue框架中,所以方法是在methods中哦,先在data里声明一个文件demo数据


data () {
return {
fileList: [ //这里的数据 实际中 应该是从后端接口中get { name: "test1.doc", url: "https://collaboration-pat-1253575580.cos.ap-shenzhen-fsi.myqcloud.com/baseFile/202303/bdf4303f-4e8d-4d95-977c-4fd5ef28d354/new.doc" }, { name: "test2.doc", url:"https://collaboration-pat-1253575580.cos.ap-shenzhen-fsi.myqcloud.com/baseFile/202303/bdf4303f-4e8d-4d95-977c-4fd5ef28d354/new.doc" } ],
}
}

methods中的方法:


handleClickDownAll () { //实现多文件压缩包下载
let _this = this
let zip = new JSZip()
let cache = {}
let promises = []
for(let item of this.fileList) {
const promise = _this.getFileArrBuffer(item.url).then(res => {
//下载文件, 并存成ArrayBuffer对象(blob)
zip.file(item.name,res,{binary:true}) //逐个添加文件
cache[item.name] = data
})
promises.push(promise) }
Promese.all(promises).then(() => {
zip.generateAsync({type:'blob'}).then(content => {
FileSaver.saveAs(content,"压缩包名字") //利用file-saver保存文件 自定义文件名
})
})
},
getFileArrBuffer(url) {
return new Promise((resolve) => {
let xmlhttp = new XMLHttpRequest()
xmlhttp.open('GET',url,true)
xmlhttp.responseType = 'blob'
xml.onload = function () {
resolve(this.response)
}
xmlhttp.send()
})
}

二:electron-vue中,生成二维码,并支持复制二维码图片


要实现的功能如下,就是点击这个“复制二维码”,可以直接把二维码图片复制下来



1.安装依赖


npm i qrcodejs2

2.引入


import QRCode from 'qrcodejs2';
import { clipboard, nativeImage} = require('electron')

3.实现


  要先在template中写一个这样的元素,用来显示二维码图片框


<div id="qrcodeImg" class="qrcode" style="height: 120px"></div>

然后再写一个画二维码的方法,如下:


drawQrcode() {
new QRCode("qrcodeImg",{
width:120,
height:120,
text:"http://www.baidu.com",
colorDark:"#000",
colorLight:"#fff"
})
}

复制二维码的方法如下:


copyCode() {
let src = document.getElementById("qrcodeImg").getElementsByTagName("img")[0].src
const image = nativeImage.createFromDataURL(src)
clipboard.writeImage(image)
this.$Message.success('复制成功')
}

4.使用


要先确保dom元素已经有了,所以在mounted中调用drawQrcode()这个方法,然后点击“复制二维码”时,调用 copyCode()这个方法就可以实现啦




作者:wenLi
来源:juejin.cn/post/7213983712732348474
收起阅读 »

再也不用手动改package.json的版本号

web
本文的起因是有在代码仓库发包后,同事问我“为什么package.json 里的版本还是原来的,有没有更新?”,这个时候我意识到,我们完全没有必要在每次发布的时候还特意去关注这个仓库的版本号,只要在发布打tag的时候同步一下即可,于是有了本文的实践。 node....
继续阅读 »

本文的起因是有在代码仓库发包后,同事问我“为什么package.json 里的版本还是原来的,有没有更新?”,这个时候我意识到,我们完全没有必要在每次发布的时候还特意去关注这个仓库的版本号,只要在发布打tag的时候同步一下即可,于是有了本文的实践。


node.js 部分,我们得有一个更改仓库代码的脚本留给ci执行


我们首先需要在工程目录中的 ./script/..目录下增加一个 update-version.js脚本



//update-version.js

const path = require('path');
const fs = require('fs');
const newVersion = process.argv[2].replace(/^v/, '');; // 获取命令行参数中的新版本号,并过滤v字头

if (!newVersion) {
console.log('请传入新版本号,版本号遵循semver规范 .eg: 1.0.0, 1.0.1, 1.1.0');
process.exit(1);

}

// 获取当前命令行上下文路径

const currentDirectory = process.cwd();

// 获取 package.json 文件中的版本号
const packageJsonPath = path.join(currentDirectory, 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);
const currentVersion = packageJson.version;

// 更新 package.json 文件中的版本号

packageJson.version = newVersion;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
console.log(`版本号已从 ${currentVersion} 更新为 ${newVersion}`);


接下来在 package.json script 配置后可以直接使用 npm run version <version> 中触发变更版本号脚本。当然这个前提是想要让这个脚本保留给开发者命令行使用。



{

"name": "version workflow",
"version": "1.0.0",
"description": "version update demo",
"main": "index.js",
"scripts": {
//...
"version": "node ./scripts/update-version.js"
},
//...

}


CI :如何让发布包的行为直接和代码仓库中的版本号同步?


接下来算重头戏,如何让发布包的行为直接和代码仓库中的版本号同步?这里我们使用的是github 提供的github action,具体操作和语法可以查看一下官方文档,本文就不过多展开。


我们需要在仓库根目录增加如下路径的文件 .github/workflows/update-action.yml



name: Update Package Version

on:
release:
types: [released]

jobs:
update:
runs-on: ubuntu-latest
steps:

- name: Checkout code
uses: actions/checkout@v3

- name: Update package.json
run: |
node ./scripts/update-version.js ${{ github.event.release.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Commit changes
run: |
git config user.name "Your github name"
git config user.email "your github email"
git add .
git commit -m "Update version to ${{ github.event.release.tag_name }} for release ${{ github.ref }}"

- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}


我们在 release hook 中的 released 状态下增加了一个 update job。 它会做下面几件事情(在脚本步骤中有)



  1. 【Checkout code】 切出新的代码分支;

  2. 【 Update package.json】在新分支执行 update-version.js 传入tag_name更新我们的工程版本号;

  3. 【Commit changes】以你定制的 git config user 信息创建一个新提交;

  4. 【Push changes】推送变更回到主干;


ps:正确来说应该在发布执行动作前prereleased执行我们的 job 但是没用这个的原因如下:



Note:  The prereleased type will not trigger for pre-releases published from draft releases, but the published type will trigger. If you want a workflow to run when stable and pre-releases publish, subscribe to published instead of released and prereleased.



当这个脚本推送后,执行发布后自动更新版本,不用在关注这个版本修改问题。
你会得到下面的效果。


在你的仓库发布界面填写正确tag后发布
image.png


触发update job 更改完成
image.png


你可能遇到最多的坑



  1. action 执行失败



Process completed with exit code 129." Node.js 12 actions are deprecated. Please update the following actions to use Node.js 16: actions/checkout@v2. For more information, see https://github.blog/changelog/2022-09-22-github-actions-all-actions-will-begin-running-on-node16-instead-of-node12/.



这是由于默认action job 执行环境的nodejs 版本与actions 包中执行脚本不匹配导致,所以一定要使用 checkout@v3 版本 actions/checkout@v3



  1. 各种不熟悉 action 语法取值导致的问题


可以优化的地方


我们前面提交的这个流程发布还是有个问题,你永远有个更超前的 commit hash 在你发布的 tag 之后


image.png
所以这个action 还有需要继续优化的地方,那就是同步更新tag hash


name: Update Package Version

on:
release:
types: [released]

jobs:
update:
runs-on: ubuntu-latest
steps:

- name: Checkout code
uses: actions/checkout@v3

- name: Update package.json
run: |
node ./scripts/update-version.js ${{ github.event.release.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Commit changes
run: |
git config user.name "Your github name"
git config user.email "your github email"
git add .
git commit -m "Update version to ${{ github.event.release.tag_name }} for release ${{ github.ref }}"
git_hash=$(git rev-parse --short HEAD)

- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

- name: Tag Push changes
run: |
git tag -f ${{ github.event.release.tag_name }} $git_hash
git push --force origin ${{ github.event.release.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


这里相比之前的版本增加了
Tag Push changes 这个步骤,在最后获取这个版本更新产生的 $git_hash强制更新到发布的 tag 上。


我们看看效果
image.png


最后我们看版本发布管理中的 tag hash
image.png
搞定!


可以再优化的地方


现在我们还有个问题,就是在执行 Commit changes 这个步骤时每次 git config user.name "Your github name" git config user.email "your github email" 这里是写死的,我们可以根据 GitHub Actions 中有一些预设的环境变量可以读取到当前用户的账号和邮箱信息。通过 ${{ env.GITHUB_ACTOR }} 获取到当前执行的 Actions 的用户账号,通过 ${{ env.GITHUB_ACTOR }}@users.noreply.github.com 获取到当前执行的 Actions 的用户邮箱(该邮箱为 noreply 邮箱,用于 GitHub 的通知,无法发送邮件)。注意,该邮箱不一定是用户本身的真实邮箱,可能是 GitHub 默认的邮箱。



如果需要获取当前 GitHub 账号的真实邮箱地址,可以通过 GitHub REST API 进行查询,具体可以参考官方文档:



这样我们就需要在Commit Changes之前再加一个Set Git user步骤


- name: Set Git user
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_EMAIL: ${{ github.actor }}@users.noreply.github.com
run: |
git config --global user.name "${{ env.GITHUB_ACTOR }}"
git config --global user.email "${{ env.GITHUB_EMAIL }}"

这样我们最终的 Github action 脚本长这样



name: Update Package Version

on:
release:
types: [released]

jobs:
update:
runs-on: ubuntu-latest
steps:

- name: Checkout code
uses: actions/checkout@v3

- name: Update package.json
run: |
node ./scripts/update-version.js ${{ github.event.release.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Set Git user
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_EMAIL: ${{ github.actor }}@users.noreply.github.com
run: |
git config --global user.name "${{ env.GITHUB_ACTOR }}"
git config --global user.email "${{ env.GITHUB_EMAIL }}"

- name: Commit changes
run: |
git add .
git commit -m "Update version to ${{ github.event.release.tag_name }} for release ${{ github.ref }}"
git_hash=$(git rev-parse --short HEAD)

- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

- name: Tag Push changes
run: |
git tag -f ${{ github.event.release.tag_name }} $git_hash
git push --force origin ${{ github.event.release.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


最后


如果我的文章对你有帮助欢迎点赞+收藏支持


作者:Jervis_cen
来源:juejin.cn/post/7220164534316433467
收起阅读 »

nginx带宽限制 limit_rate limit_rate_after

web
知识梳理 在高负载的网络环境下,为了保持服务的稳定性,限速 (download rate) 是一种必要的控制访问量的手段。Nginx 是一款高性能的 Web 服务器和反向代理服务器,可以使用 limit_rate_after 和 limit_rate 两个主要...
继续阅读 »

知识梳理


在高负载的网络环境下,为了保持服务的稳定性,限速 (download rate) 是一种必要的控制访问量的手段。Nginx 是一款高性能的 Web 服务器和反向代理服务器,可以使用 limit_rate_after 和 limit_rate 两个主要指令来完成流量控制和限速。


limit_rate_after 指令


指令 limit_rate_after 会在客户端成功建立连接之后,指定的大小后开始限制发送速度。这个指令的含义就是在连接建立后的 limit_rate_after 大小之后,数据发送速率将被限制。


以下是limit_rate_after 的语法和示例:


Syntax:	limit_rate_after size;
Default:
limit_rate_after 0;
Context: http, server, location, if in location

limit_rate_after 50m;

这个指令可以帮助您限制连接的初始流量,以便于服务器的带宽资源分配更为合理。


limit_rate 指令


limit_rate 指令是用来控制发送至客户端的数据传输速度的,它可以限制整个连接的流量,也可以限制单个客户端访问速度。


以下是 limit_rate 的语法和示例:


syntax:		limit_rate rate;
default: —
context: http, server, location

limit_rate 1k;

这个配置的作用是:在与客户端建立连接之后的 10 秒内,限制每秒发送的数据量不超过 50kB;之后如果连接仍然打开,则限制与该客户端的速率为 50kB/s。


需要提醒的一点是,尽管 limit_rate 可以一定程度上保护服务器资源,但是并不足以完全阻止恶意饱和攻击。因此,在考虑流量控制和限速的同时,还应该结合其他安全和防护机制来更好地保护服务器。


实验


配置传输速度为 1k



  • nginx配置


location / {
limit_rate 1k;
root html;
}



配置下载50m后开始限制传输速度



  • nginx配置


location / {
limit_rate_after 50m;
limit_rate 1k;
root html;
}


可以看到开始下载速度很快


在这里插入图片描述
在下载50m后,速度限制在1k以内
在这里插入图片描述


我遇到的坑



因为我的portal.tar文件没有读的权限,导致浏览器下载报403,使用 chmod 755 portal.tar 修改portal.tar文件的权限,如下图:


在这里插入图片描述


总结


Nginx 的限速功能对于控制访问量、防止恶意攻击具有很高的研究价值和实际意义。limit_rate 和 limit_rate_after 是 Nginx 常见的两个限速指令,它们可以配置在 http、server、location 等区块中,实现不同级别的流量限制和控制。一般情况下为了取得更好的限速效果,我们会同时使用两个指令,通过多事件流的限速进行灵活的控制。


希望这篇文章能够对 Nginx 限速功能有更深入的理解,帮助开发者在实际的生产环境中使用它来进行更好的流量控制和管理。


参考


nginx.org/en/docs/htt…
nginx.org/en/docs/htt…


作者:黄娟
来源:juejin.cn/post/7219889814115811388
收起阅读 »

藏在微信里的温度,无障碍开发框架分享

web
👉 腾小云导读 现我国现有4471w视障/听障人士,60岁及以上人群达2.6亿规模。微信作为国民级应用,实现无障碍迫在眉睫。为了帮助他们更好地使用微信 App,Android微信完成了适老化及无障碍改造。本文主要介绍Android 微信开发团队根据适老化及无障...
继续阅读 »

图片


图片


👉 腾小云导读


现我国现有4471w视障/听障人士,60岁及以上人群达2.6亿规模。微信作为国民级应用,实现无障碍迫在眉睫。为了帮助他们更好地使用微信 App,Android微信完成了适老化及无障碍改造。本文主要介绍Android 微信开发团队根据适老化及无障碍需求,完成的一个协助业务侧进行无障碍功能开发的框架。希望能给广大开发爱好者带来帮助和启发!




👉 看目录,点收藏


1 无障碍需求框架背景


1.1 无障碍需求


1.2 框架简介


2 无障碍开发基础知识


2.1 读屏软件识别View原理


2.2 读屏软件后的事件分发原理


3 框架实现的整体流程和执行原理


3.1 整体流程


3.2 执行原理


4 核心说明:全局热区补足机制


4.1 背景说明


4.2 具体实现


4.3 额外说明


5 走查工具


6 总结


01、无障碍需求框架


目前,业界已经有共识性的无障碍开发守则。例如 Web Content Accessibility Guidelines (WCAG) 2.0,它是由互联网的主要国际标准组织万维网联盟 (W3C) 的Web可访问性倡议 (WAI) 发布的一系列 Web 可访问性指南的一部分。


此外,WAI-ARIA(可访问的富Internet应用程序套件)是由万维网联盟(W3C)发布的一项关于 A11 Y技术应用规范。该规范定义了一种使残障人士更易于访问 Web 内容和 Web 应用程序的方法,增加 HTML、JavaScript 和相关技术开发的网站动态内容以及用户界面组件的可访问性。


目前,Android没有官方统一、方便的框架,官方提供的原生api并不是特别好用,所以微信团队对其进行参考,开发了一个无障碍框架,基于原生的api进行了再封装,将繁琐的无障碍适配逻辑封装在底层,以声明式接口的形式,让上层业务能以更简便更解耦的代码,完成无障碍的适配。接下来我们进行分享:


1.1无障碍需求


本框架主要具备以下特性:



  • 可感知性 :包括大字体适配,颜色对比度等 。

  • 可操作性 :主要是过小热区的放大,提高老年人/残疾人的交互体验 。

  • 可理解性 :微信应提供读屏文案等信息,帮助盲人在开启 Talkback 等读屏软件的情况下,正常使用微信。


下面给出一些较为典型的需求:



  • 需求1:过小热区的放大


需求是要求微信内的所有可交互控件,可点击范围不得低于 44dp * 44dp。


大小不合规的控件,如果一个个进行排查、布局修改。工程量庞大。



  • 需求2:响应区域会随无障碍开关发生变化


图片


该 Item 由一个 SwitchButton + TextView 组成。


开启 Talkback 时,整个 Item 识别为一个焦点,选中双击是触发点击 switch的逻辑。在无障碍模式下,选中双击是直接触发相应控件的 Click 事件。但是在不开 Talkback 的情况下点击 Item 又无需响应,只响应 SwitchButton 。也就是点击区域会随 Talkback 开关发生变化。


实现可能是:在 ItemClick 中进行 if 判断。但这样写侵入性高,难维护。



  • 需求3:读屏文案由其他的控件的值组合


图片


选中头像,读屏文案:腾讯行政的头像,有 2 条未读消息。需要读出列表中其他关联内容,这种只能把适配代码侵入到 Adapter中。


1.2 框架简介


框架将不同的无障碍需求的实现进行封装,抽象成不同的规则。


业务侧可以将一个页面/业务的无障碍需求,在一个配置类里使用规则表达出来,再由框架进行处理。实现相应的效果。


class ChatAccessibility(activity: AppCompatActivity) :  
BaseAccessibilityConfig(activity) {
  override fun initConfig() {
        // 设置 contentDesc
   view(rootId,viewId).desc(R.string.send_smiley)
        // ...
  }
}

框架基类 BaseAccessibilityConfig 提供了一系列用于表达规则的 api,包括但不限于如下功能:




  • 通过配置统一设置 contentDescription




  • 支持把多个 View 组合成一体进行读屏




  • 通过配置禁用某个View被 Talkback 聚焦的能力




  • 支持按指定顺序进行读屏,支持局部控制 Talkback 聚焦顺序




  • 支持设定在 Activity 启动后的第一个读屏控件




  • 支持对某个父 View 的 disableChildren 功能




  • 在某个 View 满足条件时,对其进行读屏,但不聚焦




  • 在某个 View 满足条件时,读出提前设定的 string,但不聚焦




  • 全局热区宽高补齐至 44dp,并提供自定义热区放大/禁用热区放大的功能 ...




02、无障碍开发基础知识


在深入了解框架的设计前,先来介绍一些无障碍功能开发的基础知识。


2.1 基础知识1:读屏软件识别 View 原理


图片


读屏软件无法直接识别到View,只能识别到View提供的虚拟节点「Node」,View 和虚拟节点一般是一一对应的。当页面内容发生变化,比如 View 被设值,或者发生滚动等情况,View 会向无障碍系统发送一个事件,通知系统。


然后系统就回头向 View 索取节点,组成页面更新后新的节点树,而 「节点树 和 ViewTree 是一一对应的」。此时读屏软件拿到的就是新的内容了。


2.2 基础知识2:读屏软件后的事件分发流程


分为上下两部分:读屏软件拦截处理行为、读屏软件接受事件。


图片


流程如下:




  • 读屏软件拦截用户 Touch 事件,根据事件的坐标去定位到目标节点。




  • 将 Touch 事件解释为节点行为,这里以触摸选中为例,那么就是聚焦行为。




  • 读屏软件通过该节点向无障碍系统发送,无障碍系统又转发给View(聚焦产生的绿框就是在View的内部处理里去绘制的)。




  • 生成新的虚拟节点并提供给读屏软件后,读屏软件组合信息,通过 TTS 语音引擎的 api 读出。




读屏软件展示给用户的所有信息,全部来自虚拟节点。可以在节点生成的过程中,修改节点的信息,所以这里是一个绝佳的**「信息自定义」**的地方。


采用将所有的 View 都 「Wrap 一层 AccessibilityDelegate」 的方式,「在 onInitializeAccessibilityNodeInfo 方法中修改节点信息」。


03、框架实现整体流程与执行原理


3.1 整体流程


图片




  1. 业务侧实现规则配置类,编写的规则会进入配置池。




  2. 框架在View生成节点给系统的时候进行拦截 「(onInitializeAccessibilityNodeInfo)」




  3. 在配置池中寻找匹配的规则。




  4. 根据匹配的规则对节点进行修改。




  5. 最后生成的节点就会由系统交由给读屏软件进行读屏。




3.2 执行原理


图片


核心原理:采用基于责任链的流水线来处理。整体流程主要分为两部分:




  • View 预处理责任链(图示左边):执行预出来操作,如异步生成缓存、View标记等;




  • 节点处理责任链(图示右边):节点处理的同时会同步查找规则进行设置。




接下来主要简单介绍下框架的一个核心功能的实现:「全局热区补足机制」 (位于框架流程中的预处理责任链中的一环)。


04、核心说明:全局热区补足机制


4.1 背景说明



  • 需求说明


过小热区放大,即微信内的所有可交互控件可点击范围不得低于 44dp * 44dp,像一些大小不合规的控件,如果一个个进行排查、布局修改,工程量太庞大。还有热区其他一些需求 etc。



  • 问题难点


一般会选择直接修改 padding,有些甚至需要改动相应布局,但这样的改动工作量太大且容易影响原来视图布局。



  • 解决方案


需要一个全局的热区补足机制,将过小热区补足至规范。


4.2 具体实现


「创建 View 的统一入口」 去设置 TouchDelegate 代理,由父 View 作为TouchDelegate 的承载 View 去代理 Touch 事件,这里有几个问题需要解决:




  • 如何找到合适的承载View




  • 热区及时更新




  • 性能优化




  • 读屏模式下的热区扩大




下面我们分别展开讲。




  • 重点问题1:如何找到合适的承载 View




从目标 View 向上冒泡,找到一个合适的父 View。那么需要 「冒泡终止条件」。 首先条件一肯定是 「足够大」。当前 View 够大了就没必要再往上冒了。


图片


但是这样会存在问题:子 View 的 Click优先级高于父View的TouchDelegate。事件派发机制:


从父 View 往子 View 派发,从子 View 向上处理。View 的事件处理顺序是先 OnTouchListener,然后是 TouchDelegate,再是Click、LongClick。


所以会导致下图的情况:


图片


目前进行了折中处理,相比上图,显然是下图的放大后的体验更佳:


图片


同时加入了条件二:「该承载 View 是 Clickable、LongClickable」。最终方案流程确定如下:


图片




  • 重点问题2:热区及时更新




背景: 承载 View 的 TouchDelegate 需要的参数包含一个 Rect,也就是对扩大的热区进行响应。


问题: 这个矩阵是提前传入,且和 小 View 没有直接的关系。如果小 View 的布局发生变动,会导致扩大后热区没有及时跟上变化。导致热区错位。


解决方案: 在 小 View 的 onLayoutChange 中重新进行一遍 ·View 扩大方案· 的处理。同时为了防止 onLayoutChange  执行过于频繁,将 onLayoutChange 包装成 View 的一个事件。如果短时间内多次 onLayoutChange  ,则只在最后一次 onLayoutChange 的时候进行  「View扩大方案」处理。



  • 重点问题3:性能优化


背景 :最初的 View 扩大方案执行时机是在创建 View 的统一入口,也就是在 LayoutInflate 的 onCreateView 中同步执行,每个 View 都得执行。


问题:由于 View 数量较为庞大,所以存在较大的性能隐患。


解决方案:采用了异步方案并同时对 View 处理任务进行收拢。将执行时机提前到 LayoutInflate.inflate 并异步处理,在异步任务中去遍历该 inflate 的根 View的所有子 View。尽量不去阻塞主线程的运行。




  • 重点问题4:读屏模式下的热区扩大




通过上面的实现,点击热区确实是扩大了。但是在读屏模式下选中的时候,选中的框并没有扩大。那么首先需要知道,选中时的框是以什么作为 Bound。


绿框的绘制核心逻辑位于 ViewRootImpl 中的一个 drawAccessibilityFocusedDrawableIfNeeded(),该方法的调用时机是用户触摸选中某个View后,传递到 ViewRootImpl 时进行调用,也就是读屏选中的绿框是由系统绘制的,而不是由读屏软件绘制的。从源码中能够得知的是,绿框的Bound 根据是否有虚拟节点,分为两种情况:


private void drawAccessibilityFocusedDrawableIfNeeded(Canvas canvas) {  
    final Rect bounds = mAttachInfo.mTmpInvalRect;
    if (getAccessibilityFocusedRect(bounds)) {
        final Drawable drawable = getAccessibilityFocusedDrawable();
        if (drawable != null) {
            drawable.setBounds(bounds);
            drawable.draw(canvas);
        }
    } else if (mAttachInfo.mAccessibilityFocusDrawable != null) {
        mAttachInfo.mAccessibilityFocusDrawable.setBounds(0000);
    }
}

private boolean getAccessibilityFocusedRect(Rect bounds) {
    ...
    final AccessibilityNodeProvider provider = host.getAccessibilityNodeProvider();
    if (provider == null) {
        host.getBoundsOnScreen(bounds, true);
    } else if (mAccessibilityFocusedVirtualView != null) {
        mAccessibilityFocusedVirtualView.getBoundsInScreen(bounds);
    } else {
        return false;
    }
  ...
    return !bounds.isEmpty();
}

经过跟踪源码发现,这是因为 「绿框的绘制」 是根据 View.getBoundInScreen 获取的矩阵来做到的。而 TouchDelegate 的设置无法改变 View.getBoundInScreen 获取到的矩阵。在使用虚拟节点的情况下,才会使用虚拟节点的Bound进行绘制。


对于这个问题,我们的解决思路是:




  • 对每个 View 设置自定义的 AccessibilityDelegate, 并实现其中的 getAccessibilityNodeProvider 方法。




  • 如果判断 View 需要扩大,在 getAccessibilityNodeProvider 中返回自定义的 Provider。




  • 在自定义的 Provider 中,计算 View 的扩大后的矩阵在屏幕上的位置。




  • 将矩阵设置给虚拟节点,并返回给系统。




4.3 额外说明



  • 如何匹配规则与View?


框架将配置池按 Activity 划分,极大减少冲突概率,同时减少配置池大小,加快查找规则的速度,提供 layoutId + viewId,rootId + viewId 两种形式的 View 定位机制。由两个 Id 确定一个 View,减少冲突。



  • 查找规则时间长可能导致的主线程卡顿?


由于查找规则的时机是在生成节点,是由系统触发且无法异步。在查找规则的过程中,使用预处理的时候提前生成的缓存进行查找,尽可能减少耗时。


05、走查工具


5.1 背景


当完成无障碍需求的开发后,需进行验证。在验证过程中发现开启验证效率低下,需开启读屏软件后,逐个元素验证。


5.1.1 解决方案与原理


基于无障碍服务(AccessibilityService)开发、集成了在不开启 Talkback 的情况下能展现读屏区域一个无障碍功能走查工具,无需开启 Talkback 逐个手动触摸,就能高效检查无障碍适配情况。


图片


实现原理如下:




  • 自定义实现一个 AccessibilityService 用于获取到当前活跃窗口的根节点。




  • 每隔 0.5s 进行一次节点的获取:从当前活跃窗口的根节点遍历所有的节点,逐个进行判断是否会被聚焦。




  • 对通过允许聚焦的节点进行信息收集,在一次遍历完成后通知到 DrawService。




  • 提前在window中添加一个 View 用于绘制信息,由 DrawService 进行绘制。




5.2 具体实现


关键实现:如何判断一个节点能否被聚焦,即需理解 Talkback 是如何聚焦,流程如下:


1、如果是支持 WebView 中 Html 无障碍,特殊判断。


2、如果不可见,则不聚焦。


3、判断是否是画中画,像下图的红框这种就是画中画,如果是画中画,这个就是焦点。


图片


4、该节点是否和 window 边界重合等大。对于这种和 window 等大的节点,Talkback 选择不做聚焦。


5、检查该节点是否 clickable/longClickable/focusable 或者是列表的“会说话的” 顶层视图(满足->6 不满足->7)列表(ListView/RecycleView)的顶层视图例子如下:


图片


但是聚焦的前提是“会说话的”。“会说话的”包括以下几个条件:




  • HasText:包括 contentDescription、text、hintText(包括 Button 的 Text)。




  • hasStateDescription:包括 CheckBox 的已选未选状态、进度条的进度状态等。




  • hasNonActionableSpeakingChildren:含有无法聚焦、点击但是 HasText 的子 View(如上图通讯录中的 “新的朋友” TextView,就是无法聚焦、点击但是 HasText 的子 View)。




6、基本上满足了步骤5就可以视为可聚焦了,但是有一些View仅仅是 Focusable,但是却 ”什么话都没得说“ ,对于这种 View 应该是要排除的。故按如下步骤做判断:只要是没有子节点的 focusable/clickable/longclickable 的 View,全部聚焦 、“会说话的” 全部聚焦 6.3 剩下的就不聚焦了(“不会说话”、“有子节点”)。


7、能到这一步,说明步骤 5 不满足,即该节点是普通的不可聚焦的 View。但是防止错过一些没有点击事件的 TextView 之类的需要聚焦,需要再最后做一步判断(这一步也是啥为了保证所有的信息都可以不遗漏);如果没有可聚焦父节点,但仍然 hasText 或 hasStateDescription,聚集该节点。


8、一路闯关到这的 View,就终于逃离 TalkBack 的聚焦了。


06、总结


为了帮助老年人、视障/听障人群等更好地使用微信 App,Android微信完成了适老化及无障碍改造如上。本文主要介绍 Android 微信开发团队根据适老化及无障碍需求,完成的一个协助业务侧进行无障碍功能开发的框架。我们在介绍了无障碍开发所涉及的2大重点基础知识(读屏识别View原理和读屏软件后的事件分发原理)之后,为各位展开回顾了我们框架具体细节和方法。


以上是本次分享全部内容,欢迎大家在评论区分享交流。如果觉得内容有用,欢迎转发~


-End-


原创作者|许怀鑫


技术责编|许怀鑫


图片


现我国现有4471w视障/听障人士,60岁及以上人群达到2.6亿规模。信息无障碍(Web Accessibility)的概念在近几年受到关注。 信息无障碍是指通过信息化手段弥补身体机能、所处环境等存在的差异,使任何人(无论是健全人还是残疾人、无论是年轻人还是老年人)都能平等、方便、安全地获取、交互、使用信息。微信、QQ、腾讯新闻和腾讯地图等应用加适老化元素,配备为老人而设的“关怀模式”;搜狗输入法推出为视障群体量身打造的“保益盲人输入法”......


当说到无障碍,大家第一反应是弱势群体。实际上,无障碍是适用于全民的。每个人都可能有遇障时刻。当你手提重物或受伤时,你可能会选择乘坐无障碍电梯;当你处在嘈杂的环境下看视频时,你可能需要通过字幕获取信息……每个人都是无障碍环境的受益者,视障、听障人群、含残疾人、老年人是信息无障碍的重点受益群体。


事件分享:你还见到过哪些让你眼前一亮的信息无障碍案例?


脑洞时刻:程序员还可以为信息无障碍做些什么?


欢迎在公众号评论区聊一聊你的看法。在4月10日前将你的评论记录截图,发送给腾讯云开发者公众号后台,可领取腾讯云「开发者春季限定红包封面」一个,数量有限先到先得😄。我们还将选取点赞量最高的1位朋友,送出腾讯QQ公仔1个。4月10日中午12点开奖。快邀请你的开发者朋友们一起来参与吧!


回复「微信」,领取更多微信的技术case和论文资源


图片


阅读原文


作者:腾讯云开发者
来源:juejin.cn/post/7218015602769133625
收起阅读 »

接地气的前端代码规范

web
背景: 技术栈为 vue全家桶 更细节、更符合公司现状的一些约定、规范 优先级 A:必要的 这些规则会帮你规避错误,减少可能会产生的缺陷或者性能隐患。 JavaScript 在使用变量前,必须进行判空,必要时还需进行类型判断;若是对象,建议使用可选链 我们...
继续阅读 »

背景:



  • 技术栈为 vue全家桶

  • 更细节、更符合公司现状的一些约定、规范


优先级 A:必要的


这些规则会帮你规避错误,减少可能会产生的缺陷或者性能隐患。


JavaScript


在使用变量前,必须进行判空,必要时还需进行类型判断;若是对象,建议使用可选链


我们经常会遇到这样的情况:在定义变量时未赋默认值;根据接口返回值进行赋值,因数据等问题导致字段有缺失。若我们在使用这些变量时,未进行必要的判断,理所当然地去使用变量的属性、方法等,轻则导致console上出现一些error信息,再则出现功能无法正确运行,重则直接出现整个系统白屏!
注:在<template>中使用的变量,出现undefined而未进行判空,会导致系统白屏。目前vue@2.6.x还未支持<template>中使用可选链,后续会考虑是否升级到2.7.x。


// 反例
let a, b, c;

a = res.data.data.a;

b = JSON.parse(a);

c = b.includes("1");

// 正例
let a, b, c;

a = res?.data?.data?.a;

if (!!a) {
b = JSON.parse(a);
}

if (Array.isArray(b)) {
c = b.includes("1");
}

必须对接口报错进行处理,至少需进行错误提示


目前系统中对接口错误状态码、错误提示的处理良莠不齐,导致部分接口一旦出错,页面无任何反应,对用户很不友好。



  • 针对接口出现一些错误状态码(如status: 500),后续会在组件库的interceptor中对所有axios进行统一处理,给出错误提示,并往外抛。各个业务层可以对组件库抛出的信息进行进一步的处理,如关闭loading,回退处理等等。

  • 针对接口status: 200``success: false,需要在各个调用接口的地方给出提示语。优先以后端返回为准,否则提示语默认为:系统异常,请联系管理员。

  • 针对接口返回blob文件或其他可能会出现异常的情况,建议使用try...catch来捕获异常。


// 反例
function fetchUser (userId) {
return fetch(`/xxx/xxx/${userId}`);
}

// Promise的实现
function updateUserInfo (userId) {
fetchUser(userId).then(res => {
if (res.data.success) {
doSuccessAction();
}
})
}

// async/await的实现
async function updateUserInfo (userId) {
const res = await fetchUser(userId);
if (res.data.success) {
doSuccessAction();
}
}

// 正例
function fetchUser (userId) {
return fetch(`/xxx/xxx/${userId}`);
}

// Promise的实现
function updateUserInfo (userId) {
fetchUser(userId).then(res => {
if (res.data.success) {
doSuccessAction();
} else {
const errorInfo = res.data.error || "系统异常,请联系管理员";
this.$Message.error(errorInfo);
}
}).catch (error => {
this.$Message.error(error);
});
}

// async/await的实现
async function updateUserInfo (userId) {
try {
const res = await fetchUser(userId);
if (res.data.success) {
doSuccessAction();
} else {
const errorInfo = res.data.error || "系统异常,请联系管理员";
this.$Message.error(errorInfo);
}
} catch (error) {
this.$Message.error(error);
}
}

禁止频繁调用同一个接口,包括循环、监听、或未做节流防抖的按钮等情况下调用接口


频繁调用接口,会产生很多问题,列举如下:



  • 接口耗时长,页面白屏,用户体验不好

  • 对后端服务器造成一定压力

  • 同一个接口,在短时间内同时发出,因为网络延迟等因素,会造成接口返回不一定按照接口发起的顺序,导致最终结果与预期不符


目前代码中会有这些常见情况导致频繁调用,以下给出对应的解决方法:



  • 循环中调用:进行接口聚合,比如原先是每一次给后端一个key,后端返回对应的枚举值,可以改为将这些key组合成数组,一次性请求,获取所有对应的枚举值。

  • 监听中调用:这种情况最大的问题是对watch或者computed的触发场景或次数未知。这个没有统一的解法,需要具体情况具体分析。

  • 按钮中调用:点击按钮后调用接口,是一个特别常见的场景,一般情况下我们不会主动去在接口点击后频繁调用同一个接口,但是要防止用户频繁点击按钮。我们需要在按钮点击后,进入loading状态,或者加上节流或防抖,以避免上述用户操作导致的问题。

  • 表单中调用:在input、select、cascader组件的on-change 事件中调用接口,可以改为在输入框失焦,下拉面板收起时触发,即on-blur、on-open-change、visible-change。


Prop 定义应该尽量详尽,至少指定类型


细致的 prop 定义有两个好处:



  • 它们写明了组件的 API,所以很容易看懂组件的用法;

  • 在开发环境下,如果向一个组件提供格式不正确的 prop,Vue 将会告警,以帮助你捕获潜在的错误来源。


// 这样做只有开发原型系统时可以接受
props: ['status']

props: {
status: String
}

// 更好的做法!
props: {
status: {
type: String,
required: true,
validator: function (value) {
return [
'syncing',
'synced',
'version-conflict',
'error'
].indexOf(value) !== -1
}
}
}

拒绝硬编码值;拒绝魔法数字和字符串;


硬编码值和魔法数字和字符串在编程中往往代表着不好的编码习惯,缺点也很明显:



  1. 值的意义难以了解。

  2. 值需要变动时,需要频繁变更,而且可能要改不只一个地方。


// 反例
for (let i = 0; i < 10; i++) {
//...
}

// 正例
const numApples = 10;
for (let i = 0; i < numApples; i++) {
//...
}


// 反例
<template>
<section class="demo-page">
<span v-if="status === '0'">待付款</span>
<span v-if="status === '1'">待发货</span>
<span v-if="status === '2'">待收货</span>
<span v-if="status === '3'">待评价</span>
</section>
</template>
<script>
export default {
data() {
return {
status: "0",
};
},
...
}
</script>

// 正例
<template>
<section class="demo-page">
<span>{{ statusMap[status] }}</span>
</section>
</template>
<script>
import { getStatusMapApi } from "@/api/index";
export default {
data() {
return {
status: "0",
statusMap:{}
};
},
mounted() {
getStatusMapApi().then(res => {
/* {
"0": 待付款,
"1": 待发货,
"2": 待收货,
"3": 待评价,
} */
this.statusMap = ...
})
}
}
</script>

禁止使用refs.children[i]获取子组件,建议用ref属性;不建议使用ref直接调用子组件的api,以保持组件的独立性


refs是一个对象,持有注册过 ref attribute 的所有 DOM 元素和组件实例。children是当前实例的直接子组件,它并不保证顺序,也不是响应式的。因此使用refs.children[i]获取子组件,是一种不稳定的操作。
ref属性可以访问子组件实例或子元素,但这仅仅是一个直接操作子组件的应急方案;为了保持组件的独立性、稳定性,建议不要直接使用子组件的方法、变量等。


禁止在watch和computed中用$route


由于我们目前都是keep-alive模式,若是在watch和computed中用$route,那么在包括tab页签打开、切换等操作在内的每一次路由变化,都会触发watch和computed,不论是否跟本页面本组件有关系。这样子带来了巨大的性能损耗和一些奇奇怪怪的缺陷产生。


禁止增删改JavaScript 对象或Vue的原型,造成原型污染


原型上的属性可以通过遍历访问到的,原型污染会引起性能消耗或意外BUG。
实际上,大多数在写业务代码的场景下,修改原型的方式都可以采用别的方式替代。


注释要保证详细、完整


推荐使用vscode的koroFileHeader插件,进行快捷注释操作。
文件注释去掉,可以留一个description;
代码有更新,注释记得也要更新;


/**
* @description 这个方法是干嘛用的
* @param {*}
* @return {*}
*/


/**
* @description 这个接口是干嘛用的
* @param {*}
* @see yapi地址
*/


// 这个变量是干嘛用的

工程目录、文件(夹)命名、组件内部命名等需遵循以下内部规范


因篇幅过长,单独整理


CSS


必须为组件样式设置作用域,建议采用scoped属性或者class策略。


设置样式的作用域可以有效确保你的样式只会运用在你想要作用的组件上,而不会造成”污染“。



  • scoped 属性:控制CSS 只作用于当前组件中的元素,需要给<style> 标签加上 scoped属性。

  • class策略:不止要使用 scoped属性,使用唯一的 class 名可以帮你确保那些三方库的 CSS或者其他组件的CSS 不会运用在你自己的 HTML 上。


// 反例
<template>
<span class="title">xxxxxxxxxx</span>
</template>

<style>
.title {
color: red;
}
</style>

// 正例
<template>
<div class="xxx-mgr-page">
<span class="title">xxxxxxxxxx</span>
</div>
</template>

<!-- 使用 `scoped` attribute -->
<style scoped>
.xxx-mgr-page{
.title {
color: red;
}
}
</style>

禁止使用全局选择器、类型选择器等作用范围太大的选择器添加css规则,推荐使用类选择器进行精细化控制。


简单说一下这两种被禁止的选择器:



  • 全局选择器,是由一个星号(*)代指的,它选中了文档中的所有内容。

  • 类型选择器,也叫做”标签名选择器“或者是”元素选择器“,因为它在文档中选择了一个 HTML 标签/元素的缘故。


使用他们添加css规则,会造成以下影响:



  • 作用范围太大,会造成一些不想作用的地方却误伤到了

  • 从性能角度考虑,标签选择器的性能比类选择器要慢


禁止通过css选择器的权重和优先规则来覆盖样式


在项目中,可能一个简单的按钮,它的样式会取决于很多地方很多层:组件库为它定义了最底层、最基本的外观 -> 业务项目中的公共样式为它定义了本项目中的统一样式 -> 页面样式为它定义了布局 -> 具体到这个按钮的样式定义了它的独特样式。正是由于这么多层这么复杂的样式组成,导致在需要更新样式的时候,会出现一些很”偷懒“的做法——通过直接覆盖样式,而不是去找到原先写样式的地方去修改。


// 反例
<template>
<button class="ivu-btn btn-close" style="color: white;">X</button>
</template>

<style>
.btn-close {
color: red;
}
</style>

// 正例
<template>
<button class="ivu-btn btn-close">X</button>
</template>

<style>
.btn-close {
color: white;
}
</style>

使用不常用的js api 和 css attribute,注意确认下浏览器兼容性


本条推荐理由很简单。我们推荐使用了chrome浏览器版本号为80+,那兼容性就需要考虑。常用属性已经验证过了没问题,不常用的就需要自行验证。建议可以通过mdn web docs(developer.mozilla.org/zh-CN/docs/…)来查询。
image.png


超长溢出统一用title,而不是tooltip,以提高性能


推荐理由如下:



  • 由于我们全平台中产品设计倾向于单行文本显示,包括标题文本、下拉表单项、表格单元格等等,一个页面中有可能就有上千个。

  • tooltip是iview组件,样式美观可调整,但包含了多个DOM节点;title是HTML属性,样式无法变更。两者性能差异大。


因为涉及范围之广、两者性能差异之大,所以我们建议用title来处理超长溢出。




优先级 B:推荐的


这些规则能够在绝大多数工程中改善可读性和开发体验。即使你违反了,代码还是能照常运行,但例外应该尽可能少且有合理的理由。


禁止单个vue文件超过1000行,尽量500行;禁止复制黏贴超20行的代码


在平常项目开发中,大家都深深体会到了:一个文件太长,维护起来头很大,开发模式下编译时间也很长;大段相似的代码,很不优雅,若产生问题也很容易只改一处,造成缺陷。
之所以限制1000行、500行、20行,凭以往经验决定;只要有充分理由,可以灵活应变。不断地去抽象,去提炼,去封装,也是很考验开发者的功力,很有助于我们的成长。
注:后续会考虑通过eslint+git-hooks阻止超过1000行的文件被提交。


建议不要在html中有超过两个条件的逻辑判断;在js中不要超过两个并列的if,可以考虑优雅的if-else


html中,不要有超过两句话(尽量一个操作符)的逻辑,否则就用computed
js中,一段逻辑不要超过两个if(如果你是第三个应该评估优化一下),优雅的维护if-else。嵌套的if尽可能减少或者注释清楚判断逻辑


代码优化之后,确定不需要的代码建议直接删除,不确定的代码进行注释并写明注释原因;注释或删除一段代码,要把相关的代码一并处理干净


现有情况是存在很多大段注释的代码,太过冗余杂乱,影响代码阅读,因此建议不需要的代码直接删除。
但又存在部分情况是产品提出的要求暂时隐藏某个功能,后续可能会重新启用,因此只需进行注释即可。建议这种情况下,写明注释原因,供他人后续阅读代码或者优化代码提供指引。
注释或删除一段代码时,现在会存在部分情况下,只删除直接相关代码,其他相关代码放任不管。举个例子,比如产品要求隐藏”保存并启用“功能,最差最直接的做法是隐藏这个按钮就完成,但是发现要获取这个按钮权限,需要watch中调用接口,因此导致这个功能被隐藏了,但是接口调用仍在频繁调用。


布局嵌套尽量不要层级太深;不加没有必要的DOM节点;




优先级 C:小tips


这个分类下的是一些项目开发的小技能、小知识点或业务相关的点。


路由组件一定要有name ,以确保keep-alive生效


<keep-alive>includeexclude prop 允许组件有条件地缓存。匹配首先检查组件自身的 name 选项,如果 name 选项不可用,则匹配它的局部注册名称 (父组件 components 选项的键值)。匿名组件不能被匹配。


弹窗或其他未激活的tab别在mounted阶段加载,以减小白屏时间


操作闭环、逻辑闭环考虑


举个栗子:



  • 比如弹窗要考虑确定、取消、关闭、重新打开等一系列闭环操作的正确性;

  • 比如详情的新建、查看、编辑;

  • 比如表格分页,考虑页码跳转,分页器大小变化,过滤条件变化时初始化页码等;

  • 比如v-if v-else-if v-else;


使用every和some方法时,记得排除空数组;every返回始终为true,some始终false


let a = [];
a.every(i => { ... }) // true
a.some(i => { ... }) // false

let a = [];
if (a.length > 0) {
a.every(i => { ... }) // true
a.some(i => { ... }) // false
} else {
...
}

对象浅拷贝时,分清对象展开运算符和Object.assign的区别


let aa = { a : 1, b : 2, c : 3};
let bb = Object.assign(aa, {d : 4});

// 修改aa
delete aa.a;
// 结果bb也发生了变化
console.log(bb); // {b: 2, c: 3, d: 4}

let aa = { a : 1, b : 2, c : 3};
// 解法1
let bb = Object.assign({}, aa, {d : 4});
// 解法2
let cc = {...aa, d : 4};
// 修改aa
delete aa.a;
console.log(bb); // {a: 1, b: 2, c: 3, d: 4}
console.log(cc); // {a: 1, b: 2, c: 3, d: 4}

在mounted或created阶段获取路由信息


连续调用接口的方法中如果有路由这种会变化的传参时,不能使用this.$route获取路由,避免执行方法时用户通过点击页签切换路由导致后续接口报错


样式尽可能考虑不同分辨率的自适应,如1366、1920


作者:是秋天啊
来源:juejin.cn/post/7216526817371504697
收起阅读 »

前端正确处理“文字溢出”的思路

web
前言: 最近在项目中需要做到类似于 Mac 下这种,当屏幕宽度足以容下当前文件名称的时候,文件名称全部展示,不做省略。 然而当用户缩放浏览器显示的尺寸时,我们需要做到省略中间的文字,选择保留后缀这种方案。如下图所示: 我个人也是感觉这个方案是最好的,因为大...
继续阅读 »

前言: 最近在项目中需要做到类似于 Mac 下这种,当屏幕宽度足以容下当前文件名称的时候,文件名称全部展示,不做省略。

image.png

然而当用户缩放浏览器显示的尺寸时,我们需要做到省略中间的文字,选择保留后缀这种方案。如下图所示:

1.gif


我个人也是感觉这个方案是最好的,因为大部分情况下,用户更关心的是这个文件的类型,而后缀名的保留往往是最佳的选择。我个人也查阅了很多相关文章,并且借鉴了一些已有轮子的代码思路,实现了一个符合我们项目中需求的一个组件。




一.组件效果预览




  1. 单行文字溢出时自动省略,并且不保留后缀。

    image.png




  2. 单行文字溢出时自动省略,并且保留后缀。

    image.png




  3. 多行文字溢出时,然后再开始省略。这个情况是我们项目中比较特殊的场景。简单来说就是假设我现在想让文字显示两行,如果两行的时候没有溢出,那么正常显示。如果两行情况下还是溢出了,那么我再去处理溢出的文字。
    假设这是没有做任何操作的的效果:

    image.png

    使用我们的组件以后的效果:

    image.png

    (tips:不一定必须是两行,三行,四行都是可以的。我们接下来实现的组件会让你高度自定义去处理文字溢出的场景。)




  4. 如果你想自己先尝试一下效果,那么你可以快速使用 npm 安装一下。




    • npm i auto-ellipsis-text




    • pnpm i auto-ellipsis-text




    • yarn add auto-ellipsis-text




    原仓库地址: 🫱AutoEllipsisTxt自动省略文字




  5. 使用起来也非常简单,你只需要包裹住你的文字即可
    image.png
    image.png




  6. 话回正题,接下来我会一步一步讲解我实现这个组件的思路,我写的这个组件不一定是最优的,你需要做到知其然并知其所以然,然后完善我写的组件的不足之处,你可以实现自己的自动省略文本方案,才是本文的目的。




二. 单行溢出的处理




  1. 我们先只考虑单行的情况。通常我们在自己的应用中展示很多文件信息的时候,往往选择的布局方式就是高度是一定的,说白了就是高度其实我们是定死的,宽度我们不确定,因为用户有可能会在某些情况下拖动浏览器,造成宽度发生变化,但是总会给宽度一个最小值和一个最大值来保障排版的统一性。

    image.png




  2. 样式方面,在这里我使用的是 UnoCSS ,将样式內联在了标签里,如果你还不了解这种写法,你可以点击下方的文章学习。不过即使你之前从未了解过 UnoCSS ,也不会影响你下面的阅读,因为样式不是本文的重点,并不影响整体阅读。

    🫱手把手教你如何创建一个代码仓库




  3. 让我们先创造一个简单的溢出场景,代码很简单,容器是一个 width 最大值为 200pxheight 为固定 30pxdiv

    image.png

    现在页面上的效果如下图:

    image.png




  4. 可以很清晰的看出,由于我们文字在容器内放不下,但是我们又没对溢出这一特殊场景做出处理,所以就造成了当前页面的效果。先别急,我们一步一步来。




  5. 最开始我去查阅 MDN 的时候,查阅到了一个 “确认过眼神,你就是我要找到人” 的属性。

    image.png




  6. 什么?text-overflow,我们要找到不就是文字溢出时候的处理吗?我兴奋的赶快添加到了我的组件上。

    image.png

    效果如下:

    image.png

    然后看着毫无变化的页面,开始怀疑我自己是不是单词拼错了,然后一个字母字母的比对,排除了单词打错字的情况,但页面还是没有变化。🤔




  7. 于是我又返回 MDN 去查看自己是否遗漏了哪些东西,发现了这样一段文字。

    image.png

    这里直接说结论,其实 text-overflow 这个属性不会为了让文字省略而去创造省略的场景。它其实是在你处理过溢出场景之后,帮你做对于文字溢出的的二次特殊处理。当你对于页面溢出做没有任何操作时,这个属性其实是无效的。 (注意:它仅仅只处理文字溢出的场景。)




  8. 既然你说了,让我们添加额外的属性:overflow-hiddenwhite-space,那么我们就自己添加。我们先只添加一个 overflow-hidden 来看看会发生什么。

    image.png

    我们发现,下面多出去的文字倒是被省略了,但是我们的省略号呢??我就不卖官子了,其实造成这个的原因的答案就是下面这句话:

    image.png




  9. 我们仔细看上面我们溢出的场景。

    image.png

    我们下面两行文字其实是溢出在了盒子下方,正好对应了上面 text-overflow 的介绍,“无法在盒子下面溢出” 这句话。




  10. 在这里我们就需要制造一个让文字强制不换行的场景。那么就需要用到我们另外一个十分重要的属性,white-space

    image.png

    我们本节只需要关系 nowrap 这一个值即可。剩下的值如果读者有兴趣可以自行了解,我们不过多解释。




  11. 首先你要知道,其实我们 web 页面的换行,并不是毫无意义的自己就换行了,而是都有一个隐藏的换行符,你可以把这个隐藏的换行符浅浅的理解为 white-space(空格)

    image.png




  12. 理解了上面那段话,那我们的属性 white-space:nowrap 的中文含义就十分明显了。white-space对应空格no-wrap 代表不换行。连起来的意思就是,遇到空格不换行。而我们的换行其实有一个隐藏的 white-space ,那么我们添加这个属性以后,就会造成一个不会换行的场景。




  13. 让我们先把 text-ellipsisoverflow-hidden 属性删除,只添加 white-space:nowrap 看看页面效果会是怎么样。

    image.png

    效果如下:

    image.png

    可以看到,我们省略了那个隐藏的换行符,所以文字不会自动换行了,那么整段文字都显示到了一行上。此时我们再加上我们的两个属性,overflow-hiddentext-ellipsis,神奇的一幕就发生了。

    image.png

    我们仅仅只使用了几个 CSS 属性就完成了单行情况下不保留后缀的文字溢出处理。




三. 前期准备




  1. 首先你需要准备一个 autoEllipsis.vue 文件,首先写出下面的代码,来和我一起完成这个组件。


    <template>
    <div id="autoEllipsisWrapper" ref="container" v-bind="$attrs">
    <span ref="text">
    <slot />
    </span>
    </div>
    </template>




  2. 请注意这个 id 叫做 containerdiv 元素将在接下来的内容中起到至关重要的作用。




  3. 接下来使用 ref 分别去拿到这两个 dom 元素。

    image.png




  4. 最后我们需要设计一个函数,在组件挂载以后,让它去正确处理我们文字溢出的场景。

    image.png




  5. 接下来的需求就是,这个 autoEllipsis 函数如何去实现。别着急写代码,我知道你现在有可能还是一头雾水无从下手,让我先带你理清思路然后再开始写代码。




四. 理清思路




  1. 首先我们因为要做到通用性所以, container 的宽度是不能确定的,它的宽度需要根据它外层的父元素来决定,也就是上文中我们提到的有一个最大值最小值宽度的元素。

    image.png

    换句话说,我们这个 container 要去动态的拿到外层父元素的宽度。




  2. 我们先不讲代码如何实现,我们假设现在我们已经拿到了,就叫做 fatherWidth。然后我们再通过刚刚的 ref 获取到的 text dom 元素去拿到外面传进来的文字内容。通过拿到这个 span 元素的 offsetWidth ,就可以拿到文字的长度。通过判断文字的 offsetWidth 是否大于 fatherWidth 。然后我们通过两个宽度相减,可以得出我们到底溢出的文字宽度为多少。

    image.png




  3. 拿到溢出的宽度以后,那么我们就可以用溢出宽度来除以文字大小,(overWidth/fontSize) ,就可以算出我们到底溢出了多少文字。




  4. 假设现在我们现在溢出宽度为 200px。我们的文字大小为 20px,那么 200/20 就算出我们现在溢出了 10 个字。




  5. 我们并且一开始就拿到了总的文字内容,假如我们之前的文字总数为 30 个。那么在这个情况下我们屏幕上只展示了 20 个文字,因为有 10 个字溢出被我们忽略了。




  6. 到这里之后,我们要做的事情就非常简单了,我们只需要从原来 30 个字的中间开始做切割。一边去掉 5 个,那么此时容器恰好可以容下 20 个字。中间我们再手动加上 “...” 省略号不就完美达成了吗?




  7. 上面想表达的意思用大白话来讲,其实也就是去掉中间的10个文字,然后随便再找一个字替换成字符串三个点 ...




五. 完成 autoEllipsis 函数




  1. 第一步就是为了拿到我们放入的文字宽度。注释已经写的很清楚了,就不过多赘述。

    image.png




  2. 然后我们再去拿外面父元素的宽度。此时会出现第一个分支, container 的宽度小于父元素的宽度,很容易可以猜到现在我们的文字内容是完全可以容纳的,不需要做特殊处理。

    image.png




  3. 第二个分支,当我们的 container 宽度大于了父亲元素的宽度,那么我们可以通过传递 props 来区分是否需要保留后缀,如果不需要保留后缀,我们直接给 container设置我们第二个标题讲解的知识就OK了。

    image.png




六. 保留后缀的实现




  1. 如果看到这里,你还没有正确的保留后缀思路,我建议你重新去观看一下标题四,这里我们大致的思路就是为了拿到父元素可以容纳多少文字。

    image.png




  2. 这里我们的思路其实就是计算出得出我们需要删除多少个文字

    image.png




  3. 很简单的思路,就是字符串使用 slice 切割我们上面计算得出的,两边需要删除多少文字。

    image.png




  4. 最后的关键一步,我们需要把 containerwhite-space 属性设置为 normal,因为我们已经正确的处理了文字数量,现在的 container 已经不会溢出了。

    image.png




七. 源码


下面是本组件的核心代码 autoEllipsis 函数的源码


function autoEllipsis(container: HTMLElement, textNode: HTMLSpanElement) {
const str = premitiveText; //1.拿到的所有文字信息
textNode.textContent = str; //2.将所有文字放入到我们的 span 标签中
container.style.whiteSpace = "nowrap"; //3.先将文字全部放入到《一行》中,为了计算整体宽度
container.style.width = "fit-content"; //4. 给 container 设置 fit-content 属性,就可以拿到正确的内容宽度
const containerWidth = container.clientWidth; //5. 拿到了 container 的宽度

const parent = container.parentElement; // 拿到外部父元素的宽度
const parentWidth = parent!.clientWidth || parent!.offsetWidth;
if (containerWidth <= parentWidth) {
//如果container 的宽度《小于》父元素的宽度,不做任何处理
textNode.textContent = str;
return;
} else if (cssEntirely.value) {
container.style.width = parentWidth + "px";
container.style.whiteSpace = "nowrap";
container.style.textOverflow = "ellipsis";
container.style.overflow = "hidden";
return;
} else {
const textWidth = textNode.offsetWidth; //1. 拿到文字节点的宽度
const strNumer = str.length; //2. 拿到文字的数量
const avgStrWidth = textWidth / strNumer; //3. 拿到平均每个文字多少宽度
const canFitStrNumber = Math.floor(
(parentWidth * props.startEllipsisLine) / avgStrWidth //4. 根据父元素的宽度来计算出可以容纳多少文字
);

const shouldDelNumber = strNumer - canFitStrNumber + 1.5; //1. 算出需要删除几个文字(1.5是为了省略号的宽度
const delEachSide = shouldDelNumber / 2; //2. 因为要保留中间,所以我们不能只从开头删除,也需要从两头删除
const endLeft = Math.floor(strNumer / 2 - delEachSide); //3. 因为下面要用到 slice 所以需要计算出 index
const startRight = Math.ceil(strNumer / 2 + delEachSide); //4. 和上面同理

switch (props.suffix) {
case true: {
textNode.textContent =
str.slice(0, endLeft) + "..." + str.slice(startRight);
break;
}
case false: {
textNode.textContent = str.slice(0, -shouldDelNumber) + "...";

break;
}
}
container.style.wordBreak = "break-all";
container.style.whiteSpace = "normal";
}
}


八. 优化点


这个组件目前在 ... 省略号的文字占用上,并不能准确的根据文字大小调整所需的字数。也就是下面的 1.5 这个数字无法精确的算出,但是目前我们项目的文字大小是确定的,所以我也就没有再优化了,还希望各位可以提交 Pr 来一起完善这个组件。

image.png


原仓库地址: 🫱AutoEllipsisTxt自动省略文字


作者:韩振方
来源:juejin.cn/post/7218411904699924540
收起阅读 »

孤独的游戏少年

web
本人是95前端菜鸟一枚,目前在广州打工混口饭吃。刚好换了工作,感觉生活节奏变得慢了下来,打了这么多年工总觉得想纪录些什么,怕以后自己老了忘记自己还有这么一些风流往事。书接上回。 楔子 又是一个闲暇的周末,正读一年级的我坐在床上,把象棋的旗子全部倒了出来,根据...
继续阅读 »

本人是95前端菜鸟一枚,目前在广州打工混口饭吃。刚好换了工作,感觉生活节奏变得慢了下来,打了这么多年工总觉得想纪录些什么,怕以后自己老了忘记自己还有这么一些风流往事。书接上回。



楔子


又是一个闲暇的周末,正读一年级的我坐在床上,把象棋的旗子全部倒了出来,根据颜色分为红色和绿色阵营,旗子翻盖为建筑,正面为单位,被子和枕头作为地图障碍,双手不断的移动着双方阵营的象棋,脑海中演练着星级争霸的游戏画面,将两枚不同阵营的旗子进行碰撞后,通过我对两枚旗子主观的判断,一方阵营的旗子直接被销毁,进入回收,一方的单位对建筑物全部破坏取得游戏胜利。因为我的父亲酷爱玩这款游戏,年少的我也被其玩法、画面所深深吸引,不过最主要的还是父亲获胜或者玩累后,能幸运的奖励我玩上几把。星际争霸成了我第一个启蒙游戏,那时候怎么样都获胜不了,直到发现了show me the money。因为Blizzard这个英文单词一直在游戏的启动界面一闪一闪,那时候还以为这款游戏的名字叫Blizzard,最后才发现,其实Blizzard是魔鬼的意思。


纸笔乐趣


小学一二年级的时候家里管得严,电视也不给我看,电脑直接上锁,作为家里的独生子女,没有同龄人的陪伴,闲暇时间要不就看《格林童话》、《安徒生童话》、《伊索寓言》,要不就打开这副象棋在那里自娱自乐。



起源



在某一天的音乐课上,老师喉咙不舒服,在教室播放猫和老鼠给我们观看,正当我看的津津有味,前面的同学小张突然转过头来,问我“要不要跟我玩个游戏”。小孩子一般都比较贪玩,我直接拒绝了他,“我想看猫和老鼠”。小张满脸失落转了回去,因为我是个特别怕伤害别人情绪的人,所以不忍心又用铅笔的橡皮端戳了戳他,问他“什么游戏,好玩不”。他顿时也来了精神,滔滔不绝的介绍起了这款游戏。


“游戏的规则非常的简单~~在一张纸上中间画一条分割线,双方有各自的基地,每人基地都有5点血量,双方通过猜拳,获胜的一方可以在自己阵营画一个火柴人,火柴人可以攻击对面的火柴人和基地,基地被破坏则胜利。管你听没听懂,试一把就完了!”



我呆呆的看着他,“这么无聊,赢了又怎样?”。他沉默了一会,好像我说的的确有点道理,突然想到了一个好办法,“谁获得胜利扇对方一巴掌”。我顿时来了兴趣。随着游戏逐渐深入,我们的猜拳速度和行动力也越来越快,最后发现扇巴掌还是太狠,改为扇对方的手背,这节课以双方手背通红结束了。


游戏改良


这个《火柴人对战》小游戏在班里火了一会儿,但很快就又不火了,我玩着玩着也发现没啥意思,总是觉得缺少点什么,但毕竟我也只是个没有吃APTX4869的小学生,想不出什么好点子。时间一晃,受到九年义务教育的政策,我也成功成为了一名初中生。在一节音乐课上,老师想让我们放松放松,给我们班看猫和老鼠,隔壁同桌小王撕了一张笔记本的纸,问我有没有玩过《火柴人对战》游戏,只能说,熟悉的配方,熟悉的味道。


当天晚上回到家,闲来无事,我在想这个《火柴人游戏》是不是可以更有优化,这种形式的游戏是不是可以让玩家更有乐趣。有同学可能会问,你那个年代没东西玩的吗?既然你诚心诚意发问,那我就大发慈悲的告诉你。玩的东西的确很多,但是能光明正大摆在课桌上玩的基本没有,一般有一个比较新鲜的好玩的东西,都会有一群人围了过来,这时候老师会默默站在窗户旁的阴暗角落,见一个收一个。


坐在家里的椅子上,我整理了一下思绪,突然产生了灵感,将《魔兽争霸》《游戏王》这两个游戏产生化学反应,游戏拥有着资源,单位,建筑,单位还有攻击力,生命值,效果,攻击次数。每个玩家每回合通过摇骰子的方式获得随机能源点,能源能够解锁建筑,建筑关联着高级建筑和单位,通过单位进行攻击,直至对方玩家生命值为0,那么如何在白纸上面显示呢?我想到比较好的解决方案,单位的画像虽然名字叫骷髅,但是在纸上面用代号A表示,建筑骷髅之地用代号1表示。我花了几天时间,弄了两个阵营,不死族和冰结界。立刻就拿去跟同桌试玩了一下,虽然游戏很丰富,但是有一个严重的弊端就是玩起来还挺耗费时间的,而且要人工计算单位之间的扣血量,玩家的剩余生命,在纸片上去完成这些操作,拿个橡皮擦来擦去,突然觉得有点蠢,有点尴尬,突然明白,一张白纸的承受能力是有限的。之后,我再也没有把游戏拿出来玩过,但我没有将他遗忘,而是深深埋藏在我的心里。


筑梦


直到大学期间《炉石传说》横空出世,直到《游戏王》上架网易,直到我的项目组完成1.0后迎来空窗期一个月,我再也蚌埠住了,之前一直都对微信小游戏很有兴趣,每天闲着也是闲着,所以我有了做一个微信小游戏的想法。而且,就做一款在十几年前,就已经被我设计好的游戏。


但是我不从来不是一个好学的人,领悟能力也很低,之前一直在看cocos和白鹭引擎学习文档,也很难学习下去,当然也因为工作期间没有这么多精力去学习,所以我什么框架也不会,不会框架,那就用原生。我初步的想法是,抛弃所有花里胡哨的动效,把基础的东西做出来,再作延伸。第一次做游戏,我也十分迷茫,最好的做法肯定是打飞机————研究这个微信项目如何用js原生,做出一个小游戏。



虽然微信小游戏刚出来的时候看过代码,但是也只是一扫而过,而这次带着目标进行细细品味,果然感觉不一样。微信小游戏打飞机这个项目是js原生使用纯gL的模式编写的,主要就是在canvas这个画布上面作展示和用户行为。


  // 触摸事件处理逻辑
touchEventHandler(e) {
e.preventDefault()

const x = e.touches[0].clientX
const y = e.touches[0].clientY

const area = this.gameinfo.btnArea

if (x >= area.startX
&& x <= area.endX
&& y >= area.startY
&& y <= area.endY) this.restart()
}

点击事件我的理解就是用户点击到屏幕的坐标为(x, y),如果想要一个按钮上面做处理逻辑,那么点击的范围就要落在这个按钮的范围内。当我知道如何在canvas上面做点击行为时,我感觉我已经成功了一半,接下来就是编写基础js代码。


首先这个游戏确定的元素分别为,场景,用户,单位,建筑,资源(后面改用能源替代),我先将每个元素封装好一个类,一边慢慢的回忆着之前游戏是如何设计的,一边编程,身心完全沉浸进去,已经很久很久没有试过如此专注的去编写代码。用了大概三天的时间,我把基本类该有的逻辑写完了,大概长这个样子



上面为敌方的单位区域,下方为我方的单位区域,单位用ABCDEFG表示,右侧1/1/1 则是 攻击力/生命值/攻击次数,通过点击最下方的icon弹出创建建筑,然后创建单位,每次的用户操作,都是一个点击。


一开始我设想的游戏名为想象博弈,因为每个单位每个建筑都只有名称,单位长什么样子的就需要玩家自己去脑补了,我只给你一个英文字母,你可以想象成奥特曼,也可以想象成哥斯拉,只要不是妈妈生的就行。



湿了


游戏虽然基本逻辑都写好了,放到整个微信小游戏界别人一定会认为是依托答辩,但我还是觉得这是我的掌上明珠,虽然游戏没有自己的界面,但是它有自己的玩法。好像上天也认可我的努力,但是觉得这个游戏还能更上一层楼,在某个摸鱼的moment,我打开了微信准备和各位朋友畅谈人生理想,发现有位同学发了一幅图,上面有四个格子,是赛博朋克风格的一位篮球运动员。他说这个AI软件生成的图片很逼真,只要把你想要的图片告诉给这个AI软件,就能发你一幅你所描绘的图片。我打开了图片看了看,说实话,质感相当不错,在一系列追问下,我得知这个绘图AI软件名称叫做midjourney



midjourney



我迫不及待的登录上去,询问朋友如何使用后,我用我蹩脚的英格力士迫不及待的试了试,让midjourney帮我画一个能源的icon,不试不要紧,一试便湿了,眼睛留下了感动地泪水,就像一个阴暗的房间打开了一扇窗,一束光猛地照射了进来。


WechatIMG35.jpeg


对比我之前在iconfont下载的免费图标,midjourney提供这些图片简直就是我的救世主,我很快便将一开始的免费次数用完,然后氪了一个30美刀的会员,虽然有点肉痛,但是为了儿时的梦想,这点痛算什么


虽然我查找了一下攻略,别人说可以使用gpt和midjourney配合起来,我也试了一下,效果一般,可能姿势没有对,继续用我的有道翻译将重点词汇翻译后丢给midjourney。midjourney不仅可以四选一,还可以对图片不断优化,还有比例选择,各种参数,但是我也不需要用到那么多额外的功能,总之一个字,就是棒。


但当时的我突然意识到,这个AI如此厉害,那么会不会对现在行业某些打工人造成一定影响呢,结果最近已经出了篇报道,某公司因为AI绘图工具辞退了众多插画师,事实不一定真实,但是也不是空穴来风,结合众多外界名人齐心协力抵制gpt5.0的研发,在担心数据安全之余,是否也在担心着AI对人类未来生活的各种冲击。焦虑时时刻刻都有,但解决焦虑的办法,我有一个妙招,仍然还是奖励自己


门槛


当我把整个小游戏焕然一新后,便兴冲冲的跑去微信开放平台上传我的伟大的杰作。但微信突然泼了我一盆冷水,上传微信小游戏前的流程有点出乎意外,要写游戏背景、介绍角色、NPC、介绍模块等等,还要上传不同的图片。我的小游戏一共就三个界面,有六个大板块要填写,每个板块还要两张不同的图片,我当时人就麻了。我只能创建一个单位截一次图,确保每张图片不一样。做完这道工序,还要写一份自审自查报告。


就算做完了这些前戏,我感觉我的小游戏还是难登大雅之堂,突然,我又想到了这个东西其实是不是也能运行在web端呢,随后我便立刻付诸行动,创建一个带有canvas的html,之前微信小游戏是通过weapp-adapter这个文件把canvas暴露到全局,所以在web端使用canvas的时候,只需要使用document.getElementById('canvas')暴露到全局即可。然后通过http-server对应用进行启动,这个小游戏便以web端的形式运行到浏览器上了,终于也能理解之前为啥微信小游戏火起来的时候,很多企业都用h5游戏稍微改下代码进行搬运,原来两者之间是有异曲同工之妙之处的。


关于游戏


龙族.png


魔法学院.png
上面两张便是两个种族对应的生产链,龙族是我第一个创建的,因为我自幼对龙产生好感和兴趣,何况我是龙的传人/doge。魔法学院则是稍微致敬一下《游戏王》中黑魔导卡组吧。


其实开发难度最难的莫过于是AI,也就是人机,如何让人机在有限的资源做出合理的选择,是一大难题,也是我后续要慢慢优化的,一开始我是让人机按照创建一个建筑,然后创建一个单位这种形式去做运营展开,但后来我想到一个好的点子,我应该可以根据每个种族的特点,走一条该特点的独有运营,于是人机龙族便有了龙蛋破坏龙两种流派,强度提升了一个档次。


其实是否能上架到微信小游戏已经不重要了,重要的是这个过程带给我的乐趣,一步一步看着这个游戏被创建出来的成就感,就算这个行业受到什么冲击,我需要被迫转行,我也不曾后悔,毕竟是web前端让我跨越了十几年的时光,找到了儿时埋下的种子,浇水,给予阳光,让它在我的心中成长为一棵充实的参天大树


h5地址:hslastudio.com/game/


github地址: github.com/FEA-Dven/wa…


作者:很饿的男朋友
来源:juejin.cn/post/7218570025376350263
收起阅读 »

【干货】验证码的常见类型总结

前言验证码是一种区分用户是计算机和人的公共全自动程序。简单来说,验证码就是验证操作是人还是机器。下面我就总结一下常见的验证码类型都有哪些?数字、字母组合这种形式最为常见,也很简单。有的是单独使用这两种,也有的是数字、字母混合而成,为了提高识别难度,有的会添加干...
继续阅读 »

前言

验证码是一种区分用户是计算机和人的公共全自动程序。简单来说,验证码就是验证操作是人还是机器。下面我就总结一下常见的验证码类型都有哪些?



数字、字母组合

这种形式最为常见,也很简单。有的是单独使用这两种,也有的是数字、字母混合而成,为了提高识别难度,有的会添加干扰线,如在背景中添加干扰线。



代码如下:

<?php 
// 丢弃输出缓冲区的内容 **
ob_clean();

// 创建画布
$image = imagecreatetruecolor(110, 30);

// 设置白色底
$bgColor = imagecolorallocate($image, 255, 255, 255);
imagefill($image, 0, 0, $bgColor);

// 添加四个随机数字字母
for($i=0;$i<4;$i++) {
$fontSize = 6;
// 随机分配颜色
$fontColor = imagecolorallocate($image, rand(0, 120), rand(0, 120), rand(0, 120));
// 生成内容
$data = "abcdefghijkmnpqrstuvwxy3456789";
// 如果内容为空,重新输出1
do {
$fontCont = substr($data, rand(0, strlen($data)), 1);
} while ($fontCont == '');
// 设置范围
$x = ($i*110/4)+rand(5, 10);
$y = rand(5, 10);
// 图片加入数字
imagestring($image, $fontSize, $x, $y, $fontCont, $fontColor);
}

// 添加干扰点元素
for($j=0;$j<200;$j++) {
// 点颜色
$pointColor = imagecolorallocate($image, rand(50, 200), rand(50, 200), rand(50, 200));
imagesetpixel($image, rand(1, 99), rand(1, 29), $pointColor);
}

// 添加干扰线元素
for($z=0;$z<4;$z++) {
// 生成颜色线
$lineColor = imagecolorallocate($image, rand(80, 220), rand(80, 220), rand(80, 220));
imageline($image, rand(1, 99), rand(1, 29), rand(1, 99), rand(1, 29), $lineColor);
}

header("Content-type:image/png");
// 输出图片
imagepng($image);
// 销毁内存中的图片
imagedestroy($image);

?>


短信验证码

随着手机的普及,很多APP都是用手机号注册的。为了验证手机号码的真实性,防止恶意注册,通常会向手机发送验证码。网上有专门的短信发送平台,向电信运营商支付短信费用,接入即可使用。



图片识别

根据提示,点击对应的元素。逻辑解题能力结合图形符号等元素识别能力。适用于安全要求超高的业务场景。

使用KgCaptcha,在用户控制台设置验证类型,多种类型选择,如滑动拼图、文字点选、语序点选、字体识别、空间推理。

<script src="captcha.js?appid=xxx"></script>
<script>
kg.captcha({
// 绑定元素,验证框显示区域
bind: "#captchaBox2",
// 验证成功事务处理
success: function(e) {
console.log(e);
},
// 验证失败事务处理
failure: function(e) {
console.log(e);
},
// 点击刷新按钮时触发
refresh: function(e) {
console.log(e);
}
});
</script>
<div id="captchaBox2">载入中 ...</div>

最后

SDK开源地址:https://github.com/KgCaptcha,顺便做了一个演示:https://www.kgcaptcha.com/demo/

收起阅读 »

环信web、uniapp、微信小程序sdk报错详解---注册篇(二、三)

项目场景:记录对接环信sdk时遇到的一系列问题,总结一下避免大家再次踩坑。这里主要针对于web、uniapp、微信小程序在对接环信sdk时遇到的问题。注册篇(二)注册用户报错400原因分析:从console控制台输出`及`network请求返回入手分析可以看到...
继续阅读 »

项目场景
记录对接环信sdk时遇到的一系列问题,总结一下避免大家再次踩坑。这里主要针对于web、uniapp、微信小程序在对接环信sdk时遇到的问题。
注册篇(二)
注册用户报错400


原因分析:

console控制台输出`及`network请求返回入手分析
可以看到报错描述user requires that property named username be unique, value of chai exists,翻译一下可以知道是用户名必须唯一,该用户已存在

解决方案:

在知道是因为用户名重复导致的报错,那么在注册时就要确保用户名唯一

注册篇(三)

注册用户报错429


原因分析:

同样从console控制台输出`及`network请求返回入手分析

可以看到报错描述You have exceeded the limit of the community edition,Please upgrade to the enterprise edition,大概翻译一下可以看到是您已超过社区版的限制,请升级到企业版

解决方案:
联系商务经理将appkey版本升级到企业版即可,免费版的appkey注册用户数只有100个,在超过100个之后就会报错429

拓展:
有些同学在调用api时也会出现429的报错情况,但是报错描述为Too Many Requests: [{"exception":"com.easemob.flow.exceptions.ReachLimitException","duration":0,"error":"reach_limit","error_description":"This request has reached api limit.","timestamp":1660188532229}]

这种情况是因为超过了API 调用频率限制,可以看一下环信关于Restful API 调用频率限制的文档,https://docs-im-beta.easemob.com/document/server-side/limitationapi.html。超限之后可以暂停一会再继续调用,或者可以联系商务经理调整该限制

需要注意一下,两处429的报错描述有所区别,大家需要仔细甄别一下~

收起阅读 »

为什么你永远不应该在CSS中使用px来设置字体大小

web
代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug。 在Josh Collinsworth的博客文章“永远不要用px作为字体大小”中,作者讨论了为什么不应...
继续阅读 »

image.png


代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug


在Josh Collinsworth的博客文章“永远不要用px作为字体大小”中,作者讨论了为什么不应该使用像素(px)作为网页字体大小的单位[1]。作者指出,相对于容器、浏览器或用户的字体大小,px值是静态的。无论用户的字体偏好设置如何,当我们以静态像素设置值时,它将覆盖用户的选择,以我们指定的确切值替代。这意味着,如果我wu7的样式表使用像素单位,可能导致访问网站的用户难以阅读。


因此,作者建议使用相对单位,如em、rem或百分比,而不是像素。这些单位是基于用户的字体大小偏好设置进行缩放的,从而提供了更好的可访问性和可读性。尤其是在设计响应式网站时,相对单位能够提高跨设备的兼容性。通过使用相对单位,设计师可以确保网站在不同设备和浏览器中以合适的字体大小显示[1]。


下面是正文:


在 Web 开发领域中,有很多误解流传,即使它们被反驳了很多次也仍然存在。"外部链接应该总是在新标签页中打开" 就是一个很好的例子。CSS Tricks 在将近十年前就对此进行了详细的解释(简而言之:大多数情况下是错误的),但它似乎仍然在某些角落中存在。


案例证明:在CSS中, pxemrem 单位之间没有功能上的区别的想法是一个我一遍又一遍听到的误解,因此我想在这里发帖来解决这个问题。


我们要非常清楚:在CSS中使用的单位绝对很重要。并且在设置时 font-size 应尽可能避免使用 px


我们在谈论什么单位,它们是做什么的?


在我们讨论为什么应该避免使用 px 作为 font-size 之前,让我们确保我们都清楚我们正在谈论哪些单位,以及它们的一般行为。


px


px 是像素的缩写……虽然现在大多数情况下它不再是一个真正的像素。在显示器通常是一个相对可预测的低分辨率像素比例,比如1024×768的时代, 1px 通常等于屏幕上的一个实际像素。



屏幕使用称为像素的彩色光点阵来显示图像。一个像素是显示器上的一个彩色光点;硬件能够呈现的最小可能的“点”。这就是我在本节中所说的“字面上的”、“实际的”或“设备”像素;物理世界中的一个像素。



然而,当高分辨率(有时称为“视网膜”)屏幕出现时,设备开始将更多的像素压缩到更小的空间中,这些物理设备像素变得非常微小。在高分辨率屏幕上浏览网页,如果CSS中的 1px 仍然对应于一个字面设备像素,那么甚至阅读任何内容都将非常困难,因为像素本身正在迅速缩小。毕竟,现代智能手机的分辨率甚至比高清电视还要高。


所以现在, 1px 通常对应于放大的“缩放”像素的大小,而不是实际硬件上的字面像素。在我们的 CSS 中, 1px 的东西可能会占用多个物理硬件像素,而我们没有任何纯 CSS 的方法来指定一个字面设备像素。但这没关系,因为它们通常太小了,我们不想去处理它们。



一个例子:iPhone 14 Pro 上的像素非常微小,16px 在字面上的设备像素大小大约相当于2pt字号的印刷字体大小。好在浏览器为我们缩放了它们!



大多数情况下,这些并不在本讨论的语境中真正重要,但我认为了解这些还是很好的。重要的部分是: 1px 等于浏览器视为单个像素的任何内容(即使在硬件屏幕上它不是真正的像素)。


em 和 rem


这就带我们来到了 emrem ,它们彼此相似。继续讲述不严格相关但仍然有趣的小知识: "em" 是一个排版术语,实际上比计算机早了几十年。在排版上,一个 em 等于当前字体大小。


如果你将字体大小设置为 32pt(“pt”是另一个仍然有时使用的旧排版术语),那么 1em 就是32pt。如果当前字体大小为 20px ,那么 1em = 20px


在网页上,默认字体大小为 16px 。一些用户从不更改默认设置,但许多人会更改。但默认情况下, 1em1rem 都将等于 16px 。



“Em” 最初是指 “M” 字符的宽度,这也是名称的由来。但现在它指的是当前字体大小,而不是特定字形的尺寸。



EM 和 REM 之间的区别


为了区分这两者: 1rem 始终等于浏览器的字体大小,或者更准确地说是 html 元素的字体大小。 rem 代表“根em”,而网页的根是标签。因此, 1rem = document 字体大小。(默认情况下,这是 16px ,但可以被用户覆盖。)


另一方面,em是当前元素的字体大小。看下面的CSS:


.container {
font-size: 200%;
}

p {
font-size: 1em;
}

考虑到上述 CSS, .container 元素内的段落将会变成原来的两倍大小。这是因为 1em 表示“当前字体大小”,在 .container 内,它是200%1em × 200% = 2em (默认为 32px )。


然而, .container 元素外的段落仍将是 1em 的正常字体大小(默认为 16px )。


如果我们在上面的CSS中将 em 更改为 rem ,那么所有段落标签的字体大小将始终是浏览器的默认大小,无论它们在哪里。



font-size: 1em 等同于 font-size: 100% 。
em 和 % 单位在其他情况下并不总是等价的;例如, width: 1emwidth: 100% 很可能会非常不同,因为此时百分比是基于父容器的宽度而不是其字体大小。但是,就 font-size 属性而言, %em 是相同的。



总结一下:




  • 1em 是当前元素的字体大小。




  • 1rem (根em)是文档的字体大小(即浏览器的字体大小)。




好的,那就是单位的含义和来源。现在让我们回答为什么使用哪个单位很重要。


为什么这一切都很重要


再次强调的误解是:既然 1em16px 相等,那么选择哪个单位并不重要。这似乎是合理的;如果 16px = 1rem ,那么选择哪种方式输入似乎并不重要。


记住, emrem 是相对的;默认情况下,它们都(最终)基于浏览器的字体大小。


2rem 是浏览器字体大小的两倍; 0.5rem 是其一半,依此类推。因此,如果用户更改其首选字体大小,如果使用 emrem ,则网站上的所有文本都会相应更改,就像应该的那样。 2rem 仍然是该字体大小的两倍; 0.5rem 仍然是其一半。


相比之下, px 值是静态的。无论容器、浏览器或用户的字体大小如何, 20px 只是 20px 。当设置静态像素值时,无论用户的字体偏好大小如何,它都会覆盖该选择并使用指定的确切值。


批判性地说,这意味着如果你的样式表使用 px 在任何地方设置 font-size ,那么基于该值的任何文本都将无法由用户更改。


那是非常糟糕的事情。它是不可访问的,甚至可能会阻止某人完全使用该网站。


因此,虽然可能存在一些有效的用例来解释这种行为,但它绝对不是你想要的默认行为。



这也是避免使用视口单位(如 vw 或 vh )设置字体大小的非常好的理由。它们也是静态的,用户无法覆盖。
最多,像 calc(1rem + 1vw) 这样的值可能是可以接受的,因为它仍然包含 rem 作为基础。即便如此,我仍建议使用 clamp() 或媒体查询来设置最小和最大值,因为屏幕尺寸往往远远超出我们所期望或测试的范围。



超出字体大小的差异


好的,现在让我们谈谈当我们不特别处理 font-size 属性时, px em / rem 如何变化。


开发人员通常通过缩放页面来进行测试,我认为这就是本文中心误解的来源。当你缩放时,所有内容都会被缩放(放大或缩小),在这种情况下,选择 pxem / rem 作为你的CSS单位通常并不重要。就缩放而言,两者的行为方式相同。而且,大多数视力良好的开发人员可能不会意识到其中还有更多内容。然而,棘手的问题是:


即使超出 font-sizepx 的行为也与 emrem 不同。


px 单位仍然与屏幕上像素的缩放值相关联。 emrem 与文档的字体大小相关联,而不是页面的缩放或比例。


为了演示,请看这个 CodePen:


codepen.io/collinswort…


HTML CSSResult Skip Results Iframe
EDIT ON
<p>Lorem, ipsum dolor sit amet consectetur adipisicing elit. Nam eum aliquam eveniet.p>
<p>Sapiente delectus in ab excepturi, commodi placeat quaerat saepe voluptas sunt numquam.p>
<p>Rerum veniam, quidem voluptatibus deleniti nihil consequatur blanditiis explicabo eum quos. Nam.p>
<p>Natus necessitatibus delectus neque tenetur sint illum obcaecati similique sequi doloribus eligendi?p>
<p>Eos quidem iure debitis dolorum repellendus ab incidunt ipsam suscipit, autem consequuntur?p>

p {
border-bottom: 2px solid black;
margin-top: 0;
margin-bottom: 20px;
}

我们有几个段落,每个段落底部有 2px 边框,并且它们之间有 20px 边距。请注意,我们对两者都使用 px 单位。


如果你放大或缩小,元素的大小和距离保持相对不变。也就是说:你放大得越多,那条线就越粗,段落之间的间距就越大。


为了方便起见,这里有一张截图,显示了同一支笔的400%缩放。文本、线条和间距都变大了4倍;它们相对于彼此的大小保持不变:


image.png


当涉及到缩放时, pxemrem 之间没有真正的区别。但缩放并不是用户使网站更易用的唯一方法。


如前所述,用户还可以指定默认和/或最小字体大小。当他们这样做时,功能开始分歧。


在下面的截图中,我已将Firefox的默认字体大小设置为 64px 。看一下:


image.png


将屏幕截图中的文本与其上方的文本进行比较。请注意,这一次,行并没有变粗,段落之间的边距也没有成比例增加。只有文本本身变大了。因为边框宽度和边距都是在 px 中设置的,它们保持不变,不会缩放。


但是请注意,如果将CSS中的 px 更改为相应的 rem 值,会发现线条和间距确实变大了! (zh-Hans)


image.png


所以,这里的总结是:



  • 当用户更改字体大小时, px 值不会缩放。

  • em 和 rem 的值会随字体大小成比例调整。


如果你想要一个交互式演示,将所有这些内容联系在一起,请查看最终的 CodePen;调整顶部的滑块以查看修改文档字体大小对各种元素的影响,基于它们使用的 CSS 单位。
codepen.io/collinswort…


选择哪一个


因此,知道 emrem 会随字体大小缩放,但 px 值不会,那么我们该怎么办?我们应该永远不使用 px 吗?


虽然我认为如果你选择这条路,你可能会没事,但我仍然认为 px 有其存在的意义。


我们知道当用户调整字体大小时 px 值不会改变,这意味着像素单位实际上是某些美学元素的不错选择。也许我们有一定的间距,我们不希望在字体大小变大时变得更大。(如果默认情况下是一个大块的负空间,也许允许它缩放到更大的尺寸是没有意义的。)


也许有一些边框大小我们不想改变,或者页面上有用 CSS 创建的装饰元素,在更大的字体大小下看起来效果不佳。也许我们不希望填充随着字体大小的增加而膨胀。在所有这些情况下, px 仍然是一个不错的选择。


我个人建议使用 rem 来设置所有的大小。我只在想要与当前字体大小成比例的东西(例如,与一些文本旁边的图标应该与字符的高度完全相同,并且在一侧有半个字符的情况)中添加 em 。我不会在任何地方使用 px ,除非是明确不想随字体大小缩放的设计元素。


永远不要用 px 单位中设置 font-size ,除非你非常确定你在做什么,它会如何行动,以及在你这样做时它是否仍然可访问。


关于媒体查询的重要说明


出于与上述所有原因相同的原因,重要的是要避免在 @media 查询中使用 px ;当用户缩放时,它将正常工作,但是使用 px 的媒体查询将在用户自己设置更大的字体大小时失败。


@media (min-width: 800px) {
/* Changing font size does NOT affect this breakpoint */
}

@media (min-width: 50rem) {
/* Changing font size DOES affect this breakpoint */
}

这是因为随着字体大小的增加, 50rem 会根据用户的偏好变成不同的值,而 800px 则不会。


很可能,当我们为较大的断点编写CSS时,我们认为有足够的屏幕空间让元素扩展。如果用户设置了非常大的字体大小,则可能不是这种情况,将媒体查询设置为 rem 而不是 px 可以帮助我们避免这种假设并响应用户的偏好。


我在这个网站上遇到了这个问题;我把所有的断点都设置在 px 上。然而,当我将默认字体大小设置得更大时,我的媒体查询没有响应,因为它们仍然只查看屏幕的像素宽度。因此,我仍然有一个微小的侧边栏,里面塞满了难以辨认的巨大文本,因为我没有考虑用户的偏好。在那之后,我立即改为 rem ,问题得到了解决。


简而言之:在媒体查询中,除非您确定自己知道在浏览器中设置自己的字体大小会对用户产生什么影响,否则一定要避免使用 px


原文:joshcollinsworth.com/blog/never-…


作者:王大冶
来源:juejin.cn/post/7218013539675750437
收起阅读 »

预测2024年之后的前端开发模式

web
大家好,我卡颂。 最近AIGC(AI Generated Content,利用AI生成内容)非常热,技术圈也受到了很大冲击。目前来看,利用LLM(Large Language Model,大语言模型)辅助开发还停留在非常早期的阶段,主要应用是辅助编码,即用自然...
继续阅读 »

大家好,我卡颂。


最近AIGC(AI Generated Content,利用AI生成内容)非常热,技术圈也受到了很大冲击。目前来看,利用LLM(Large Language Model,大语言模型)辅助开发还停留在非常早期的阶段,主要应用是辅助编码,即用自然语言输入需求,模型输出代码。更近一步的探索也仅仅是在此基础上的一层封装(比如copilot Xcursor)。


但即使在如此早期阶段,也对开发者的心智产生极大震撼,AI让程序员失业这样的论调甚嚣尘上。


LLM的爆发对前端意味着什么?本文尝试预测一波2024年之后的前端开发模式,这个预测遵循如下原则:




  • 尊重技术客观发展规律。以当前已有技术为基础预测,而不是将预测建立在某种虚无缥缈的高端技术,或者假想某些技术突破重大瓶颈




  • 尊重人性。程序员只是谋生的职业,新的开发模式即使再厉害,如果让程序员赚不到钱,那也是很难推广开的




欢迎加入人类高质量前端交流群,带飞


范式迁移的本质


为了预测未来,先看看我们是如何走到现在的。


在前端开发领域,我们经历了从jQuery为代表的面向过程编程向前端框架为代表的状态驱动模式的迁移。


当问到该选Vue还是React开发?,这样的问题会引起很大争议,但如果问到该选jQuery还是框架开发?,这样的问题就不会有太多争议。


为什么前端领域普遍接受了这种范式的迁移?在我看来,有两个原因:


1. 开发效率提高


这一点毋需多言,相信前端同学都有体会。


2. 门槛提高


面向过程编程是非常浅显易懂的开发模式。君不见,曾经的前端靠一本锋利的jQuery就能打天下。相比之下,状态驱动就有一定学习门槛。



当一项有一定门槛的技术(这里指前端框架)变为行业事实上的标准时,行业门槛就提升了,这为从业者构筑了行业壁垒。


事实上,正是由于:




  1. web应用复杂度提高




  2. 前端框架的流行




才让后端工程师工作职责中的view层,分化出前端工程师这一职业。


对于前端领域来说,只有同时平衡了提效提高门槛的技术,才会被市场(这里的消费者指前端工程师)接受。


举个反例,Angular全家桶的模式虽然提高了开发效率,但是同时,门槛提高太多了。


而且更糟的是,Angular中的很多概念都是从后端迁移而来,作为一款前端框架,对后端更亲和且门槛高,这对本身就是从后端view层中分化出的前端工程师来说,是比较排斥的。


再举个反例 —— Vue。有同学会说,Vue这么流行的前端框架,你说他是反例?


还是从提效提高门槛的角度看,Vue提效的同时,由于其模版语法、响应式更新等特性,他是降低了开发门槛的,这意味着使用Vue时:




  1. 同样是开发业务,老前端与新前端差距不大




  2. 必要时后端经过简单的学习,也能接手部分需求




重申一下,我并不是说Vue不好,相反,他是很优秀的前端框架。这里只是从人性的角度分析,并且这个分析很有可能是主观、带有偏见的。


再看个正面例子 —— React HooksHooks对开发效率、组件复用性以及他对React未来发展的影响这里不赘述了。主要聊聊提高门槛




  1. 一方面,什么时候封装自定义Hook,如何封装自定义Hook,如何规避Hook的坑,老前端与新前端有比较大的差异




  2. 更重要的是,后端改改JSX还行,要改基于Hooks的组件逻辑,是有一定难度的




既提效,又提高门槛,我认为这才是Hooks在前端领域火热的原因。



同样的原因,从人性的角度,我很看好Vue Composition API



所以,前端编程范式迁移的本质是:把握提高效率提高门槛之间的平衡。


这个结论会成为后面预测未来开发模式的依据。


当范式无法再迁移时


当前端框架成为事实上的标准后很长一段时间,业界也在不断探索新的开发范式。


有一种开发模式每过几年都会被搬出来炒一遍,他就是低代码。用我们上面的结论来分析下:在市场选择的情况下,先抛开低代码是否能提高效率不谈,显然他的目的是降低门槛


从人性的角度出发,他就很难在程序员群体中自发传播开。


那么,如果没有新的范式出现,会发生什么事情?会内卷。


我们会发现,这几年前端的发展轨迹,就是在重复一件事:




  1. 围绕前端框架周边,不断探索各细分领域的最佳实践




  2. 当探索出最佳实践后,就把他集成到框架中




举个例子,React Router作为React技术栈中路由这一细分领域的一个开源库,经过长期迭代,逐渐成为主流路由方案之一。


React Router团队基于React Router开发出Remix这一React框架。



这么做,在没有新的范式出现前,也能基于当前范式(前端框架),达到上述2个目的:




  • 提高效率:框架集成了最佳实践,开发效率更高




  • 提高门槛:除了学习React,还得学习新的上层框架




类似的,各种CSS解决方案(比如tailwind css)也是同样的道理:




  • 提高效率:提高CSS编写效率




  • 提高门槛:新的概念、语法需要学习




那么,未来围绕提高效率提高门槛的平衡,前端开发模式会如何发展呢?


从考虑范式到考虑流程


首先,我认为,在有限的未来,不会出现新的更先进的范式能让前端领域普遍认可并大规模迁移(就像从jQuery到前端框架的迁移)。


那么,为了提高效率,除了改变范式范式内 内卷两个选择外,还有个选择 —— 让整个开发流程提效。


从需求文档到最终代码,存在4级抽象:




  1. PM用自然语言编写的需求文档




  2. 需求评审时,PM给开发描述需求后,开发脑海里形成的业务逻辑




  3. 开发根据业务逻辑划分各个模块或组件




  4. 开发实现各个模块或组件的具体代码




当前我们使用LLM辅助编程时(比如以chatGPT为例),主要是用自然语言输入模块或组件业务逻辑,再让模型输出具体代码。也就是借助模型自动完成从3到4级抽象的转变。


比如说下图我们让chatGPT实现一个计时器:



这个计时器可能是我们需求中的某个模块,在此chatGPT帮我们完成了从抽象3(实现一个计时器组件)到抽象4(计时器组件的代码)。


如果仅仅到这一步,只能说这是个更高效的辅助工具,并不能达到整个开发流程提效的程度。为了达到这种程度,我们需要让LLM帮我们完成从抽象1到4的整个过程。


LLM如何完成4级抽象转换


接下来我们来看,基于当前已有的模型,如何完成抽象1到抽象4的自动转换。


首先,来看抽象1(PM用自然语言编写的需求文档)。chatGPT当前已经掌握基础的理解能力,所以他是能够理解需求文档的含义的。


下图是我从网上找的某需求文档中的登录功能流程图:



以当前主流的GPT-3.5举例,虽然GPT-3.5不能理解图片(不能理解需求文档中的流程图),但我们可以将流程图用文字描述出来(最新的GPT-4已经拥有理解图片含义的能力)。


上述登录功能流程图可以用文字概括为:



  1. 打开App后有3个选项,分别是“账号密码登录”、“快捷登录”、“第三方登录”

  2. 选择“第三方登录”,进入第三方,同意授权后登录成功

  3. 选择“快捷登录”,输入手机号和验证码并选择身份,点击登录后登录成功

  4. 选择“账号密码登录”,输入手机号,如果已注册,输入密码,点击登录后登录成功

  5. 选择“账号密码登录”,输入手机号,如果未注册,进入注册页,输入手机号,如果手机号已注册,回到“账号密码登录”

  6. 选择“账号密码登录”,输入手机号,如果未注册,进入注册页,输入手机号,如果手机号未注册,填写手机号、验证码、密码、姓名、选择身份,点击注册,完毕


抽象1到抽象2


如何完成从抽象1到抽象2(业务逻辑)的转变呢?换句话说,如何用一种介于自然语言与实际代码之间的规范描述业务逻辑?


这种规范应该拥有完备的数据结构(类似JSONXML),因为这样会带来很多好处:




  • 相比于自然语言,用规范的数据结构表示的业务逻辑能够传达更准确的意图




  • 业务需求的不断增多,仅仅对应数据结构体积的增大,即使再复杂的业务,只需要分批将业务逻辑代表的数据结构投喂给模型,模型就能完全理解我们的业务




  • 数据结构可以保存在变量中,通过变量名就能指代业务逻辑,无需再用自然语言大段的向模型描述业务逻辑




我们可以利用SCXML(State Chart XML)格式。SCXML是由W3C定义的一种表示状态机XML格式,他能够表示状态之间的变化。


前端应用的本质其实就包括两部分:




  • 状态的变化




  • 状态到视图的映射




其中状态到视图的映射框架已经帮我们做了。所以,只要能表示状态的变化,其实就能表示业务逻辑。


现在,我们让chatGPT将流程图翻译为SCXML格式:



得到如下结构(你不用细看,了解个大概就行):


<scxml version="1.0" xmlns="http://www.w3.org/2005/07/scxml">
<state id="mainMenu">
<transition event="THIRD_PARTY_LOGIN" target="thirdPartyLogin" />
<transition event="QUICK_LOGIN" target="quickLogin" />
<transition event="ACCOUNT_LOGIN" target="accountLogin" />
</state>

<state id="thirdPartyLogin">
<onentry>
<send event="AGREE_AUTHORIZATION" />
<send event="LOGIN_SUCCESS" />
</onentry>
</state>

<state id="quickLogin">
<onentry>
<send event="INPUT_PHONE_NUMBER" />
<send event="INPUT_VERIFICATION_CODE" />
<send event="SELECT_IDENTITY" />
<send event="LOGIN_SUCCESS" />
</onentry>
</state>

<state id="accountLogin">
<state id="inputPhoneNumber">
<transition event="PHONE_NUMBER_SUBMITTED" target="checkRegistered" />
</state>

<state id="checkRegistered">
<onentry>
<send event="CHECK_REGISTERED" />
</onentry>

<transition event="REGISTERED" target="inputPassword" />
<transition event="UNREGISTERED" target="register" />
</state>

<state id="inputPassword">
<onentry>
<send event="INPUT_PASSWORD" />
<send event="LOGIN_SUCCESS" />
</onentry>
</state>

<state id="register">
<state id="inputPhoneNumber">
<transition event="PHONE_NUMBER_SUBMITTED" target="checkRegistered" />
</state>

<state id="checkRegistered">
<onentry>
<send event="CHECK_REGISTERED" />
</onentry>

<transition event="REGISTERED" target="inputPassword" />
<transition event="UNREGISTERED" target="inputUserInfo" />
</state>

<state id="inputUserInfo">
<onentry>
<send event="INPUT_PHONE_NUMBER" />
<send event="INPUT_VERIFICATION_CODE" />
<send event="INPUT_PASSWORD" />
<send event="INPUT_NAME" />
<send event="SELECT_IDENTITY" />
<send event="REGISTER_SUCCESS" />
</onentry>
</state>
</state>
</state>
</scxml>

至此,我们完成了抽象1到抽象2的转变。


抽象2到抽象3


SCXML格式没法直接在JS代码中使用。为了用代码实现逻辑,我们需要使用遵循SCXML规范的库。xstateJS中比较流行的状态机开源库。



所以接下来我们让chatGPT将上述SCXML格式转换为xstate语法:



得到结果(同样,具体代码你不用在意,了解我想表达的转换意思就行):


import { Machine, sendParent } from 'xstate';

const loginMachine = Machine({
id: 'login',
initial: 'mainMenu',
states: {
mainMenu: {
on: {
THIRD_PARTY_LOGIN: 'thirdPartyLogin',
QUICK_LOGIN: 'quickLogin',
ACCOUNT_LOGIN: 'accountLogin'
}
},
thirdPartyLogin: {
onEntry: [
sendParent('AGREE_AUTHORIZATION'),
sendParent('LOGIN_SUCCESS')
]
},
quickLogin: {
onEntry: [
sendParent('INPUT_PHONE_NUMBER'),
sendParent('INPUT_VERIFICATION_CODE'),
sendParent('SELECT_IDENTITY'),
sendParent('LOGIN_SUCCESS')
]
},
accountLogin: {
initial: 'inputPhoneNumber',
states: {
inputPhoneNumber: {
on: {
PHONE_NUMBER_SUBMITTED: 'checkRegistered'
}
},
checkRegistered: {
onEntry: sendParent('CHECK_REGISTERED'),
on: {
REGISTERED: 'inputPassword',
UNREGISTERED: 'register.inputPhoneNumber'
}
},
inputPassword: {
onEntry: [
sendParent('INPUT_PASSWORD'),
sendParent('LOGIN_SUCCESS')
]
},
register: {
initial: 'inputPhoneNumber',
states: {
inputPhoneNumber: {
on: {
PHONE_NUMBER_SUBMITTED: 'checkRegistered'
}
},
checkRegistered: {
onEntry: sendParent('CHECK_REGISTERED'),
on: {
REGISTERED: 'inputPassword',
UNREGISTERED: 'inputUserInfo'
}
},
inputPassword: {
onEntry: [
sendParent('INPUT_PASSWORD'),
sendParent('REGISTER_SUCCESS')
]
},
inputUserInfo: {
onEntry: [
sendParent('INPUT_PHONE_NUMBER'),
sendParent('INPUT_VERIFICATION_CODE'),
sendParent('INPUT_PASSWORD'),
sendParent('INPUT_NAME'),
sendParent('SELECT_IDENTITY'),
sendParent('REGISTER_SUCCESS')
]
}
}
}
}
}
}
});

export default loginMachine;

这段代码我们可以直接粘贴到xstate的可视化编辑器中查看:



图中初始状态可以转移到3个状态(这些状态都是chatGPT生成的),其中:




  • QUICK_LOGIN —— 快捷登录




  • ACCOUNT_LOGIN —— 账号密码登录




  • THIRD_PARTY_LOGIN —— 第三方登录




每个状态接下来的变化逻辑都清晰可见。比如,当进入ACCOUNT_LOGIN状态后,后续会根据是否登录(UNREGISTEREDREGISTERED)进入不同逻辑:



也就是说,chatGPT理解了需求文档想表达的业务逻辑后,将业务逻辑转换成代码表示。



读者可将上述xstate代码复制到可视化编辑器中看到效果



抽象3到抽象4


接下来,我们只需要让chatGPT根据上述xstate状态机生成组件代码即可。


这时有同学会问:chatGPT对话有token限制,没法生成太多代码怎么办?


实际上,这可能并不是坏事。在我曾经供职的一家公司,前端团队有条不成文的规矩 —— 如果一个组件超过200行,那你就应该拆分他。


同样的,如果chatGPT生成的组件超过了token限制,那么应该让他拆分新的组件。


拆分组件的前提是 —— chatGPT需要懂业务逻辑。显然,他已经懂了xstate数据结构所代表的业务逻辑。


更妙的是,我们可以让chatGPTSCXML格式转换而来的xstate数据结构保存在一个变量中,在后续对话中,我们用一个变量名就能指代他背后所表示的业务逻辑(这里保存在变量m中)。



当我们要生成业务组件代码时,让chatGPT从模块中导出m实现组件逻辑:



对于实际场景下比较复杂的需求,经过从抽象1到抽象3的转换,我们会得到代表业务逻辑的不同变量,比如:




  • signin变量代表登录逻辑




  • login变量代表注册逻辑




  • PopupAD变量代表弹窗广告逻辑




如果弹窗广告的逻辑和是否登录相关,那么要实现弹窗广告组件代码只需要告诉chatGPT


根据signinPopupAD实现弹窗广告的react组件,其中signin变量由xxx模块导出,PopupAD变量由yyy导出。


如果你司使用其他框架,只需将其中react换成其他框架名即可。当大家还在争论哪个框架更优秀时,LLM已经悄悄帮开发者实现了框架自由


新开发模式的优势


让我们从提高效率提高门槛的角度分析这种新开发模式的优势。


提高效率


首先,这种新模式能显著提高开发效率。本质来说,他将前端工程师从实现需求的角色转变为review代码的角色。


极端的讲,当需求评审会结束的那一刻,第一版前端代码就生成了。


其次,他能解放部分测试同学的生产力(抢部分测试同学的活儿)。对于维护过屎山代码的同学,肯定遇到过这样的场景:明明只是改动一个小需求,测试问你改动影响的范围,你自己都不清楚会有多大影响,为了稳妥起见只能让测试覆盖更大的回归测试范围。


在使用基于状态机的开发模式后,任何改动会造成的影响在状态图中都清晰可见。同时,由于代码逻辑的实现基于状态机,可以据此自动生成端到端的测试用例,模型也能根据状态机描述的逻辑自己补足其他单测。


提高门槛


接下来,我们从提高门槛的角度分析。


首先,能够对模型生成的代码进行查漏补缺本身就要求开发者有一定前端开发水平。


其次,这种开发模式引入了新的抽象层 —— 状态机,这无疑会增加上手门槛。


但这都不是最重要的,最重要的是 —— 这套模式强迫前端开发需要更懂业务。


以前,拿到产品的需求文档后,你可以在做的过程中遇到不懂的再问产品。使用新的开发模式后,你必须很懂业务,做到在需求评审时就能指出需求文档中不合理的地方


因为当需求评审结束后,你会将这份需求文档投喂给模型直接生成业务代码(中间会经历生成SCXML生成xstate数据结构保存xstate变量、使用变量生成组件代码)。


当大家技术水平旗鼓相当时,懂业务才是前端的核心竞争力。


综上,这套开发模式在极大提高效率的同时提高了门槛,我认为在未来很有可能成为主流前端开发模式。


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

快速入门 GraphQL:一个接口实现所有 CRUD

web
作为前端开发,想必经常做的事情就是:调接口、画页面、调接口、画页面... 调用的接口大概率是 restful 的,也就是类似这种: /students 查询所有学生信息 /student/1 查询 id 为 1 的学生信息 上面说的是 get 请求。 如果对 ...
继续阅读 »

作为前端开发,想必经常做的事情就是:调接口、画页面、调接口、画页面...


调用的接口大概率是 restful 的,也就是类似这种:


/students 查询所有学生信息


/student/1 查询 id 为 1 的学生信息


上面说的是 get 请求。


如果对 /student/1 发送 POST、PUT、DELETE 请求,就分别代表了新增、修改、删除。


这就是 restful 风格的 web 接口。


这种接口返回什么信息是服务端那边决定的,客户端只是传一下参数。


而不同场景下需要的数据不同,这时候可能就得新开发一个接口。特别是在版本更新的时候,接口会有所变动。


这样就很容易导致一大堆类似的接口。


facebook 当时也遇到了这个问题,于是他们创造了一种新的接口实现方案:GraphQL。


用了 GraphQL 之后,返回什么数据不再是服务端说了算,而是客户端自己决定。


服务端只需要提供一个接口,客户端通过这个接口就可以取任意格式的数据,实现 CRUD。


比如想查询所有的学生,就可以这样:



想再查询他们的年龄,就可以这样:



想查询老师的名字和他教的学生,就可以这样:



而这些都是在一个 http 接口里完成的!


感受了 GraphQL 的好处了没?


一个 http 接口就能实现所有的 CRUD!


那这么强大的 GraphQL 是怎么实现的呢?


我们先写个 demo 快速入门一下:


facebook 提供了 graphql 的 npm 包,但那个封装的不够好,一般我们会用基于 graphql 包的 @apollo/server 和 @apollo/client 的包来实现 graphql。


首先引入这个包:


import { ApolloServer } from '@apollo/server';

然后写一段这样的代码:


import { ApolloServer } from '@apollo/server';

const typeDefs = `
type Student {
id: String,
name: String,
sex: Boolean
age: Int
}

type Teacher {
id: String,
name: String,
age: Int,
subject: [String],
students: [Student]
}

type Query {
students: [Student],
teachers: [Teacher],
}

schema {
query: Query
}
`
;

比较容易看懂,定义了一个 Student 的对象类型,有 id、name、sex、age 这几个字段。


又定义了一个 Teacher 的对象类型,有 id、name、age、subject、students 这几个字段。students 字段是他教的学生的信息。


然后定义了查询的入口,可以查 students 和 teachers 的信息。


这样就是一个 schema。


对象类型和对象类型之间有关联关系,老师关联了学生、学生也可以关联老师,关联来关联去这不就是一个图么,也就是 graph。


GraphQL 全称是 graph query language,就是从这个对象的 graph 中查询数据的。


现在我们声明的只是对象类型的关系,还要知道这些类型的具体数据,取数据的这部分叫做 resolver。


const students = [
{
id: '1',
name: async () => {
await '取数据';
return '光光'
},
sex: true,
age: 12
},
{
id: '2',
name:'东东',
sex: true,
age: 13
},
{
id: '3',
name:'小红',
sex: false,
age: 11
},
];

const teachers = [
{
id: '1',
name: '神光',
sex: true,
subject: ['体育', '数学'],
age: 28,
students: students
}
]

const resolvers = {
Query: {
students: () => students,
teachers: () => teachers
}
};

resolver 是取对象类型对应的数据的,每个字段都可以写一个 async 函数,里面执行 sql、访问接口等都可以,最终返回取到的数据。


当然,直接写具体的数据也是可以的。


这里我就 student 里那个 name 用 async 函数的方式写了一下。


这样有了 schema 类型定义,有了取数据的 resovler,就可以跑起 graphql 服务了。


也就是这样:


import { startStandaloneServer } from '@apollo/server/standalone' 

const server = new ApolloServer({
typeDefs,
resolvers,
});

const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});

console.log(`🚀 Server ready at: ${url}`);

传入 schema 类型定义和取数据的 resolver,就可以用 node 把服务跑起来。



有同学可能问了,node 可以直接解析 esm 模块么?


可以的。只需要在 package.json 中声明 type 为 module:



那所有的 .js 就都会作为 esm 模块解析:



跑起来之后,浏览器访问一下:


就可以看到这样的 sandbox,这里可以执行 graphql 的查询:



(graphql 接口是监听 POST 请求的,用 get 请求这个 url 才会跑这个调试的工具)


我查询所有学生的 id、name、age 就可以这样:




这里 “光光” 那个学生是异步取的数据,resolver 会执行对应的异步函数,拿到最终数据:



取老师的信息就可以这样:



这样我们就实现了一个 graphql 接口!


感觉到什么叫客户端决定取什么数据了么?


当然,我们这里是在 sandbox 里测的,用 @apollo/client 包也很简单。


比如 react 的 graphql 客户端是这样的:



一个 gql 的 api 来写查询语言,一个 useQuery 的 api 来执行查询。


学起来很简单。


我们之后还是直接在 sandbox 里测试。


有的同学可能会说,如果我想查询某个名字的老师的信息呢?


怎么传参数?


graphql 当然是支持的,这样写:


type Query {
students: [Student],
teachers: [Teacher],
studentsbyTeacherName(name: String!): [Student]
}

新加一个 query 入口,声明一个 name 的参数。(这里 String 后的 ! 代表不能为空)


然后它对应的 resolver 就是这样的:


const resolvers = {
Query: {
students: () => students,
teachers: () => teachers,
studentsbyTeacherName: async (...args) => {
console.log(args);

await '执行了一个异步查询'
return students
}
}
};

studentsbyTeacherName 字段的 resolver 是一个异步函数,里面执行了查询,然后返回了查到的学生信息。


我们打印下参数看看传过来的是什么。


有参数的查询是这样的:



传入老师的 name 参数为 111,返回查到的学生的 id、name 信息。


可以看到返回的就是查询到的结果。


而服务端的 resolver 接收到的参数是这样的:



其余的几个参数不用管,只要知道第二个参数就是客户端传过来的查询参数就好了。


这样我们就可以根据这个 name 参数实现异步的查询,然后返回数据。


这就实现了有参数的查询。


不是说 graphql 能取代 restful 做 CRUD 么?那增删改怎么做呢?


其实看到上面的有参数的查询应该就能想到了,其实写起来差不多。


在 schema 里添加这样一段类型定义:


type Res {
success: Boolean
id: String
}

type Mutation {
addStudent(name:String! age:Int! sex:Boolean!): Res

updateStudent(id: String! name:String! age:Int! sex:Boolean!): Res

deleteStudent(id: String!): Res
}

schema {
mutation: Mutation
query: Query
}

和有参数的查询差不多,只不过这部分增删改的类型要定义在 mutation 部分。


然后 resolver 也要有对应的实现:


async function addStudent (_, { name, age, sex }) {
students.push({
id: '一个随机 id',
name,
age,
sex
});
return {
success: true,
id: 'xxx'
}
}

async function updateStudent (_, { id, name, age, sex }) {

return {
success: true,
id: 'xxx'
}
}

async function deleteStudent (_, { id }) {
return {
success: true,
id: 'xxx'
}
}

const resolvers = {
Query: {
students: () => students,
teachers: () => teachers,
studentsbyTeacherName: async (...args) => {
console.log(args);

await '执行了一个异步查询'
return students
}
},
Mutation: {
addStudent: addStudent,
updateStudent: updateStudent,
deleteStudent: deleteStudent
}
};


和 query 部分差不多,只不过这里实现的是增删改。


我只对 addStudent 做了实现。


我们测试下:


执行 addStudent,添加一个学生:



然后再次查询所有的学生:



就可以查到刚来的小刚同学。


这样,我们就可以在一个 graphql 的 POST 接口里完成所有的 CRUD!


全部代码如下,感兴趣可以跑一跑(注意要在 package.json 里加个 type: "module")


import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone'

const typeDefs = `
type Student {
id: String,
name: String,
sex: Boolean
age: Int
}

type Teacher {
id: String,
name: String,
age: Int,
subject: [String],
students: [Student]
}

type Query {
students: [Student],
teachers: [Teacher],
studentsbyTeacherName(name: String!): [Student]
}

type Res {
success: Boolean
id: String
}

type Mutation {
addStudent(name:String! age:Int! sex:Boolean!): Res

updateStudent(id: String! name:String! age:Int! sex:Boolean!): Res

deleteStudent(id: String!): Res
}

schema {
mutation: Mutation
query: Query
}
`;

const students = [
{
id: '1',
name: async () => {
await '取数据';
return '光光'
},
sex: true,
age: 12
},
{
id: '2',
name:'东东',
sex: true,
age: 13
},
{
id: '3',
name:'小红',
sex: false,
age: 11
},
];

const teachers = [
{
id: '1',
name: '神光',
sex: true,
subject: ['体育', '数学'],
age: 28,
students: students
}
]

async function addStudent (_, { name, age, sex }) {
students.push({
id: '一个随机 id',
name,
age,
sex
});
return {
success: true,
id: 'xxx'
}
}

async function updateStudent (_, { id, name, age, sex }) {

return {
success: true,
id: 'xxx'
}
}

async function deleteStudent (_, { id }) {
return {
success: true,
id: 'xxx'
}
}

const resolvers = {
Query: {
students: () => students,
teachers: () => teachers,
studentsbyTeacherName: async (...args) => {
console.log(args);

await '执行了一个异步查询'
return students
}
},
Mutation: {
addStudent: addStudent,
updateStudent: updateStudent,
deleteStudent: deleteStudent
}
};

const server = new ApolloServer({
typeDefs,
resolvers,
});

const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});

console.log(`🚀 Server ready at: ${url}`);

完成了 graphql 的入门,我们再稍微思考下它的原理。graphql 是怎么实现的呢?


回顾整个流程,我们发现涉及到两种 DSL(领域特定语言),一个是 schema 定义的 DSL,一个是查询的 DSL。


服务端通过 schema 定义的 DSL 来声明 graph 图,通过 resolver 来接受参数,执行查询和增删改。


客户端通过查询的 DSL 来定义如何查询和如何增删改,再发给服务端来解析执行。


通过这种 DSL 实现了动态的查询。


确实很方便很灵活,但也有缺点,就是 parse DSL 为 AST 性能肯定是不如 restful 那种直接执行增删改查高的。


具体要不要用 graphql 还是要根据具体场景来做判断。


总结


restful 接口是 url 代表资源,GET、POST、PUT、DELETE 请求代表对资源的增删改查。


这种接口返回什么数据完全由服务端决定,每次接口变动可能就得新加一种接口。


为了解决这种问题,facebook 创造了 graphql,这种接口返回什么数据完全由客户端决定。增删改查通过这一个接口就可以搞定。


graphql 需要在服务端定义 schema,也就是定义对象类型和它的字段,对象类型和对象类型之间会有关联,也就是一个 graph,查询就是从这个 graph 里查询数据。


除了 schema 外,还需要有 resolver,它负责接受客户端的参数,完成具体数据的增删改查。


graphql 会暴露一个 post 接口,通过查询语言的语法就可以从通过这个接口完成所有增删改查。


本地测试的时候,get 请求会跑一个 sandbox,可以在这里测试接口。


整个流程涉及到两种新语言: schema 定义语言和 query 查询语言。入门之后向深入的话就是要学下这两种 DSL 的更多语法。


感受到 graphql 的强大之处了么?一个接口就可以实现所有的 CRUD!


作者:zxg_神说要有光
来源:juejin.cn/post/7218396786187042853
收起阅读 »

打造你自己的 JavaScript 运行时

web
原文:deno.com/blog/roll-y… 译者:李瑞丰 在这篇文章中,我们将介绍如何创建自定义 JavaScript 运行时。我们称之为 runjs。想象一下,我们正在构建一个(更)简化的 deno 版本。这篇文章的目标是创建一个 CLI,可以执行本...
继续阅读 »

原文:deno.com/blog/roll-y…
译者:李瑞丰



在这篇文章中,我们将介绍如何创建自定义 JavaScript 运行时。我们称之为 runjs。想象一下,我们正在构建一个(更)简化的 deno 版本。这篇文章的目标是创建一个 CLI,可以执行本地 JavaScript 文件,读取文件,写入文件,删除文件,并具有简化的 console API。


让我们开始吧。


前提


这篇教程假设读者具有以下知识:



  • Rust 基础知识

  • JavaScript 事件循环基础知识


确保你的机器上安装了 Rust(以及 cargo),并且它至少是 1.62.0 版本。访问 rust-lang.org 安装 Rust 编译器和 cargo


确保我们已经准备好了:


$ cargo --version
cargo 1.62.0 (a748cf5a3 2022-06-08)

Hello, Rust!


首先,让我们创建一个新的 Rust 项目,它将是一个名为 runjs 的二进制 crate:


$ cargo init --bin runjs
Created binary (application) package

让我们进入 runjs 目录并在编辑器中打开它。确保一切都设置正确:


$ cd runjs
$ cargo run
Compiling runjs v0.1.0 (/Users/ib/dev/runjs)
Finished dev [unoptimized + debuginfo] target(s) in 1.76s
Running `target/debug/runjs`
Hello, world!

很好!现在让我们开始创建我们自己的 JavaScript 运行时。


依赖


接下来,让我们将 deno_coretokio 依赖项添加到我们的项目中:


$ cargo add deno_core
Updating crates.io index
Adding deno_core v0.142.0 to dependencies.
$ cargo add tokio --features=full
Updating crates.io index
Adding tokio v1.19.2 to dependencies.

我们更新后的 Cargo.toml 文件应该如下所示:


[package]
name = "runjs"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
deno_core = "0.142.0"
tokio = { version = "1.19.2", features = ["full"] }

deno_core 是 Deno 团队的一个 crate,它抽象了与 V8 JavaScript 引擎的交互。V8 是一个复杂的项目,有成千上万的 API,因此为了简化使用它们,deno_core 提供了一个 JsRuntime 结构体,它封装了一个 V8 引擎实例(称为 Isolate),并允许与事件循环集成。


tokio 是一个异步的 Rust 运行时,我们将使用它作为事件循环。Tokio 负责与操作系统抽象(如网络套接字或文件系统)进行交互。deno_coretokio 一起,允许 JavaScript 的 Promise 映射到 Rust 的 Future


拥有 JavaScript 引擎和事件循环,使我们能够创建 JavaScript 运行时。


Hello, runjs!


让我们从编写一个异步的 Rust 函数开始,该函数将创建一个 JsRuntime 实例,该实例负责 JavaScript 执行。


// main.rs
use std::rc::Rc;
use deno_core::error::AnyError;

async fn run_js(file_path: &str) -> Result<(), AnyError> {
let main_module = deno_core::resolve_path(file_path)?;
let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
..Default::default()
});

let mod_id = js_runtime.load_main_module(&main_module, None).await?;
let result = js_runtime.mod_evaluate(mod_id);
js_runtime.run_event_loop(false).await?;
result.await?
}

fn main() {
println!("Hello, world!");
}

这里有很多东西要解释。异步的 run_js 函数创建了一个新的 JsRuntime 实例,该实例使用基于文件系统的模块加载器。之后,我们将模块加载到 js_runtime 运行时中,对其进行评估,并运行一个事件循环直到完成。


这个 run_js 函数封装了我们的 JavaScript 代码将要经历的整个生命周期。但是在我们能够这样做之前,我们需要创建一个单线程的 tokio 运行时,以便能够执行我们的 run_js 函数:


// main.rs
fn main() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
if let Err(error) = runtime.block_on(run_js("./example.js")) {
eprintln!("error: {}", error);
}
}

让我们尝试执行一些 JavaScript 代码!创建一个 example.js 文件,它将打印 "Hello runjs!":


// example.js
Deno.core.print("Hello runjs!");

注意,我们使用的是 Deno.core 中的 print 函数 - 这是一个全局可用的内置对象,由 deno_core Rust crate 提供。


现在运行它:


cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/runjs`
Hello runjs!⏎

成功!在仅 25 行 Rust 代码中,我们创建了一个简单的 JavaScript 运行时,可以执行本地文件。当然,此时此运行时不能做太多事情(例如,console.log 还不能工作 - 尝试一下!),但是我们已经将 V8 JavaScript 引擎和 tokio 集成到我们的 Rust 项目中。


添加 console API


让我们开始处理 console API。首先,创建 src/runtime.js 文件,该文件将实例化并使 console 对象全局可用:


// runtime.js
((globalThis) => {
const core = Deno.core;

function argsToMessage(...args) {
return args.map((arg) => JSON.stringify(arg)).join(" ");
}

globalThis.console = {
log: (...args) => {
core.print(`[out]: ${argsToMessage(...args)}\n`, false);
},
error: (...args) => {
core.print(`[err]: ${argsToMessage(...args)}\n`, true);
},
};
})(globalThis);

函数 console.logconsole.error 将接受多个参数,将它们转换为 JSON(以便我们可以检查非原始 JS 对象)并在每个消息前加上 logerror 前缀。这是一个“普通的” JavaScript 文件,就像我们在 ES 模块之前在浏览器中编写 JavaScript 一样。


为了确保我们不会污染全局作用域,我们在 IIFE 中执行此代码。如果我们没有这样做,那么 argsToMessage 辅助函数将在我们的运行时中全局可用。


现在,让我们将此代码包含在我们的二进制文件中,并在每次运行时执行:


let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
..Default::default()
});
+ js_runtime.execute_script("[runjs:runtime.js]", include_str!("./runtime.js")).unwrap();

最后,让我们使用我们的新 console API 更新 example.js


- Deno.core.print("Hello runjs!");
+ console.log("Hello", "runjs!");
+ console.error("Boom!");

再次运行它:


cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/runjs`
[out]: "Hello" "runjs!"
[err]: "Boom!"

它起作用了!现在让我们添加一个 API,它将允许我们与文件系统进行交互。


添加一个基本的文件系统 API


让我们从更新我们的 runtime.js 文件开始:


};

+ core.initializeAsyncOps();
+ globalThis.runjs = {
+ readFile: (path) => {
+ return core.ops.op_read_file(path);
+ },
+ writeFile: (path, contents) => {
+ return core.ops.op_write_file(path, contents);
+ },
+ removeFile: (path) => {
+ return core.ops.op_remove_file(path);
+ },
+ };
})(globalThis);

我们刚刚添加了一个新的全局对象,称为 runjs,它有三个方法:readFilewriteFileremoveFile。前两个方法是异步的,而第三个是同步的。


你可能想知道这些 core.ops.[op name] 调用是什么 - 它们是 deno_core crate 中用于绑定 JavaScript 和 Rust 函数的机制。当你调用其中任何一个时,deno_core 将查找具有 #[op] 属性和匹配名称的 Rust 函数。


让我们通过更新 main.rs 来看看它的作用:


+ use deno_core::op;
+ use deno_core::Extension;
use deno_core::error::AnyError;
use std::rc::Rc;

+ #[op]
+ async fn op_read_file(path: String) -> Result<String, AnyError> {
+ let contents = tokio::fs::read_to_string(path).await?;
+ Ok(contents)
+ }
+
+ #[op]
+ async fn op_write_file(path: String, contents: String) -> Result<(), AnyError> {
+ tokio::fs::write(path, contents).await?;
+ Ok(())
+ }
+
+ #[op]
+ fn op_remove_file(path: String) -> Result<(), AnyError> {
+ std::fs::remove_file(path)?;
+ Ok(())
+ }

我们刚刚添加了三个可以从 JavaScript 调用的 ops。但是,在这些 ops 可用于我们的 JavaScript 代码之前,我们需要通过注册“扩展”来告诉 deno_core


async fn run_js(file_path: &str) -> Result<(), AnyError> {
let main_module = deno_core::resolve_path(file_path)?;
+ let runjs_extension = Extension::builder("runjs")
+ .ops(vec![
+ op_read_file::decl(),
+ op_write_file::decl(),
+ op_remove_file::decl(),
+ ])
+ .build();
let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
+ extensions: vec![runjs_extension],
..Default::default()
});

Extensions 允许你配置你的 JsRuntime 实例,并将不同的 Rust 函数暴露给 JavaScript,以及执行更高级的操作,如加载其他 JavaScript 代码。


让我们再次更新我们的 example.js


console.log("Hello", "runjs!");
console.error("Boom!");
+
+ const path = "./log.txt";
+ try {
+ const contents = await runjs.readFile(path);
+ console.log("Read from a file", contents);
+ } catch (err) {
+ console.error("Unable to read file", path, err);
+ }
+
+ await runjs.writeFile(path, "I can write to a file.");
+ const contents = await runjs.readFile(path);
+ console.log("Read from a file", path, "contents:", contents);
+ console.log("Removing file", path);
+ runjs.removeFile(path);
+ console.log("File removed");
+

再次运行它:



$ cargo run
Compiling runjs v0.1.0 (/Users/ib/dev/runjs)
Finished dev [unoptimized + debuginfo] target(s) in 0.97s
Running `target/debug/runjs`
[out]: "Hello" "runjs!"
[err]: "Boom!"
[err]: "Unable to read file" "./log.txt" {"code":"ENOENT"}
[out]: "Read from a file" "./log.txt" "contents:" "I can write to a file."
[out]: "Removing file" "./log.txt"
[out]: "File removed"


恭喜,我们的 runjs 运行时现在可以与文件系统一起工作!注意,从 JavaScript 调用 Rust 代码所需的代码量非常少 - deno_core 负责在 JavaScript 和 Rust 之间传递数据,因此我们不需要自己进行任何转换。


总结


在这个简短的例子中,我们开始了一个集成了强大的 JavaScript 引擎(V8)和高效的事件循环实现(tokio)的 Rust 项目。


本文由 李瑞丰 翻译,原文地址:deno.com/blog/roll-y…


此教程的第二部分已经发布,实现了 fetch-like API 并添加了 TypeScript 转译功能。


完整的示例代码可以在 denoland 的 GitHub。也可以在译者的仓库查看第一部分代码



作者:李瑞丰_liruifengv
来源:juejin.cn/post/7218466428766453817


收起阅读 »

看了antfu大佬的v-lazy-show,我学会了怎么编译模板指令

web
前言 一开始关注到 antfu 是他的一头长发,毕竟留长发的肯定是技术大佬。果不其然,antfu 是个很高产、很 creative 的大佬,我也很喜欢他写的工具,无论是@antfu/eslint-config、unocss、还是vitest等等。 而这篇文章故...
继续阅读 »

前言


一开始关注到 antfu 是他的一头长发,毕竟留长发的肯定是技术大佬。果不其然,antfu 是个很高产、很 creative 的大佬,我也很喜欢他写的工具,无论是@antfu/eslint-configunocss、还是vitest等等。


而这篇文章故事的起源是,我今天中午逛 github 的时候发现大佬又又又又开了一个新的 repo(这是家常便饭的事),v-lazy-show


image.png


看了下是两天前的,所以好奇点进去看看是什么东东。


介绍是:A compile-time directive to lazy initialize v-show for Vue. It makes components mount after first truthy value (v-if), and the DOM keep alive when toggling (v-show).


简单的说,v-lazy-show 是一个编译时指令,就是对 v-show 的一种优化,因为我们知道,v-show 的原理只是基于简单的切换 display none,false则为none,true则移除


bite-me-i-dare-you.gif


但即使在第一次条件为 falsy 的时候,其依然会渲染对应的组件,那如果该组件很大,就会带来额外的渲染开销,比如我们有个 Tabs,默认初始显示第一个 tab,但后面的 tab 也都渲染了,只是没有显示罢了(实际上没有必要,因为可能你点都不会点开)。


那基于此种情况下,我们可以优化一下,即第一次条件为 falsy 的情况下,不渲染对应的组件,直到条件为 truthy 才渲染该组件。


将原本的 v-show 改为 v-lazy-show 或者 v-show.lazy


<script setup lang="ts">
import { ref } from 'vue'
import ExpansiveComponent from './ExpansiveComponent.vue'

const enabled = ref(false)
</script>

<template>
<button @click="enabled = !enabled">
Toggle
</button>

<div class="hello-word-wrapper">
<ExpansiveComponent v-lazy-show="enabled" msg="v-lazy-show" />
<ExpansiveComponent v-show.lazy="enabled" msg="v-lazy.show" />

<ExpansiveComponent v-show="enabled" msg="v-show" />

<ExpansiveComponent v-if="enabled" msg="v-if" />
</div>
</template>

<!-- ExpansiveComponent.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'

const props = defineProps({
msg: {
type: String,
required: true,
},
})

onMounted(() => {
console.log(`${props.msg} mounted`)
})
</script>

<template>
<div>
<div v-for="i in 1000" :key="i">
Hello {{ msg }}
</div>
</div>
</template>

2023-04-03 15.55.15.gif



ExpansiveComponent 渲染了 1000 行 div,在条件 enabled 初始为 false 的情况下,对应 v-show 来说,其依然会渲染,而对于 v-lazy-show 或 v-show.lazy 来说,只有第一次 enabled 为 true 才渲染,避免了不必要的初始渲染开销



如何使用?


国际惯例,先装下依赖,这里强烈推荐 antfu 大佬的 ni


npm install v-lazy-show -D
yarn add v-lazy-show -D
pnpm add v-lazy-show -D
ni v-lazy-show -D

既然是个编译时指令,且是处理 vue template 的,那么就应该在对应的构建工具中配置,如下:


如果你用的是 vite,那么配置如下


// vite.config.ts
import { defineConfig } from 'vite'
import { transformLazyShow } from 'v-lazy-show'

export default defineConfig({
plugins: [
Vue({
template: {
compilerOptions: {
nodeTransforms: [
transformLazyShow, // <--- 加在这里
],
},
},
}),
]
})

如果你用的是 Nuxt,那么应该这样配置:


// nuxt.config.ts
import { transformLazyShow } from 'v-lazy-show'

export default defineNuxtConfig({
vue: {
compilerOptions: {
nodeTransforms: [
transformLazyShow, // <--- 加上这行
],
},
},
})

那么,该指令是如何起作用的?


上面的指令作用很好理解,那么其是如何实现的呢?我们看下大佬是怎么做的。具体可见源码


源码不多,我这里直接贴出来,再一步步看如何实现(这里快速过一下即可,后面会一步步分析):


import {
CREATE_COMMENT,
FRAGMENT,
createCallExpression,
createCompoundExpression,
createConditionalExpression,
createSequenceExpression,
createSimpleExpression,
createStructuralDirectiveTransform,
createVNodeCall,
traverseNode,
} from '@vue/compiler-core'

const indexMap = new WeakMap()

// https://github.com/vuejs/core/blob/f5971468e53683d8a54d9cd11f73d0b95c0e0fb7/packages/compiler-core/src/ast.ts#L28
const NodeTypes = {
SIMPLE_EXPRESSION: 4,
}

// https://github.com/vuejs/core/blob/f5971468e53683d8a54d9cd11f73d0b95c0e0fb7/packages/compiler-core/src/ast.ts#L62
const ElementTypes = {
TEMPLATE: 3,
}

// https://github.com/vuejs/core/blob/f5971468e53683d8a54d9cd11f73d0b95c0e0fb7/packages/shared/src/patchFlags.ts#L19
const PatchFlags = {
STABLE_FRAGMENT: 64,
}

export const transformLazyShow = createStructuralDirectiveTransform(
/^(lazy-show|show)$/,
(node, dir, context) => {
// forward normal `v-show` as-is
if (dir.name === 'show' && !dir.modifiers.includes('lazy')) {
return () => {
node.props.push(dir)
}
}

const directiveName = dir.name === 'show'
? 'v-show.lazy'
: 'v-lazy-show'

if (node.tagType === ElementTypes.TEMPLATE || node.tag === 'template')
throw new Error(`${directiveName} can not be used on <template>`)

if (context.ssr || context.inSSR) {
// rename `v-lazy-show` to `v-if` in SSR, and let Vue handles it
node.props.push({
...dir,
exp: dir.exp
? createSimpleExpression(dir.exp.loc.source)
: undefined,
modifiers: dir.modifiers.filter(i => i !== 'lazy'),
name: 'if',
})
return
}

const { helper } = context
const keyIndex = (indexMap.get(context.root) || 0) + 1
indexMap.set(context.root, keyIndex)

const key = `_lazyshow${keyIndex}`

const body = createVNodeCall(
context,
helper(FRAGMENT),
undefined,
[node],
PatchFlags.STABLE_FRAGMENT.toString(),
undefined,
undefined,
true,
false,
false /* isComponent */,
node.loc,
)

const wrapNode = createConditionalExpression(
createCompoundExpression([`_cache.${key}`, ' || ', dir.exp!]),
createSequenceExpression([
createCompoundExpression([`_cache.${key} = true`]),
body,
]),
createCallExpression(helper(CREATE_COMMENT), [
'"v-show-if"',
'true',
]),
) as any

context.replaceNode(wrapNode)

return () => {
if (!node.codegenNode)
traverseNode(node, context)

// rename `v-lazy-show` to `v-show` and let Vue handles it
node.props.push({
...dir,
modifiers: dir.modifiers.filter(i => i !== 'lazy'),
name: 'show',
})
}
},
)

createStructuralDirectiveTransform


因为是处理运行时的指令,那么自然用到了 createStructuralDirectiveTransform 这个函数,我们先简单看下其作用:


createStructuralDirectiveTransform 是一个工厂函数,用于创建一个自定义的 transform 函数,用于在编译过程中处理特定的结构性指令(例如 v-for, v-if, v-else-if, v-else 等)。


该函数有两个参数:




  • nameMatcher:一个正则表达式或字符串,用于匹配需要被处理的指令名称。




  • fn:一个函数,用于处理结构性指令。该函数有三个参数:



    • node:当前节点对象。

    • dir:当前节点上的指令对象。

    • context:编译上下文对象,包含编译期间的各种配置和数据。




createStructuralDirectiveTransform 函数会返回一个函数,该函数接收一个节点对象和编译上下文对象,用于根据指定的 nameMatcher 匹配到对应的指令后,调用用户自定义的 fn 函数进行处理。


在编译过程中,当遇到符合 nameMatcher 的结构性指令时,就会调用返回的处理函数进行处理,例如在本例中,当遇到 v-show 或 v-lazy-show 时,就会调用 transformLazyShow 处理函数进行处理。


不处理 v-show


if (dir.name === 'show' && !dir.modifiers.includes('lazy')) {
return () => {
node.props.push(dir)
}
}

因为 v-show.lazy 是可以生效的,所以 v-show 会进入该方法,但如果仅仅只是 v-show,而没有 lazy 修饰符,那么实际上不用处理


这里有个细节,为何要将指令对象 push 进 props,不 push 行不行?


原先的表现是 v-show 条件为 false 时 display 为 none,渲染了节点,只是不显示:


image.png


而注释node.props.push(dir)后,看看页面表现咋样:


image.png


v-show 的功能没了,也就是说指令的功能会添加到 props 上,所以这里要特别注意,不是单纯的返回 node 即可。后来还有几处node.props.push,原理跟这里一样。


服务端渲染目前是转为 v-if


if (context.ssr || context.inSSR) {
// rename `v-lazy-show` to `v-if` in SSR, and let Vue handles it
node.props.push({
...dir,
exp: dir.exp
? createSimpleExpression(dir.exp.loc.source)
: undefined,
modifiers: dir.modifiers.filter(i => i !== 'lazy'),
name: 'if',
})
return
}

将 v-lazy-show 改名为 v-if,且过滤掉修饰符


createVNodeCall 给原先节点包一层 template


顾名思义,createVNodeCall 是 用来创建一个 vnode 节点的函数:


const body = createVNodeCall(
/** 当前的上下文 (context) 对象,即 CodegenContext */
context,
/** helper 函数是 Vue 内部使用的帮助函数。FRAGMENT 表示创建 Fragment 节点的 helper 函数 */
helper(FRAGMENT),
/** 组件的 props */
undefined,
/** 当前节点的子节点数组,即包含有指令的节点本身 */
[node],
/** 表示该节点的 PatchFlag,指明了该节点是否稳定、是否具有一些特定的更新行为等。STABLE_FRAGMENT 表示该 Fragment 节点是一个稳定的节点,即其子节点不会发生改变 */
PatchFlags.STABLE_FRAGMENT.toString(),
/** 该节点的动态 keys */
undefined,
/** 该节点的模板引用 (ref) */
undefined,
/** 表示该节点是否需要开启 Block (块) 模式,即是否需要对其子节点进行优化 */
true,
/** 表示该节点是否是一个 Portal 节点 */
false,
/** 表示该节点是否是一个组件 */
false /* isComponent */,
/** 该节点在模板中的位置信息 */
node.loc,
)

参数含义如下,简单了解即可(反正看了就忘)


也就是说,其会生成如下模板:


<template>
<ExpansiveComponent v-lazy-show="enabled" msg="v-lazy-show" />
</template>

关键代码(重点)


接下来这部分是主要原理,请打起十二分精神。


先在全局维护一个 map,代码中叫 indexMap,是一个 WeakMap(不知道 WeakMap 的可以去了解下)。然后为每一个带有 v-lazy-show 指令的生成一个唯一 key,这里叫做_lazyshow${keyIndex},也就是第一个就是_lazyshow1,第二个是_lazyshow2...


  const keyIndex = (indexMap.get(context.root) || 0) + 1
indexMap.set(context.root, keyIndex)

const key = `_lazyshow${keyIndex}`

然后将生成的key放到渲染函数的_cache上(渲染函数的第二个参数,function render(_ctx, _cache)),即通过_cache.${key}作为辅助变量。之后会根据 createConditionalExpression 创建一个条件表达式


const wrapNode = createConditionalExpression(
createCompoundExpression([`_cache.${key}`, ' || ', dir.exp!]),
createSequenceExpression([
createCompoundExpression([`_cache.${key} = true`]),
body,
]),
// 生成一个注释节点 `<!--v-show-if-->`
createCallExpression(helper(CREATE_COMMENT), [
'"v-show-if"',
'true',
]),
)

也就是说, v-lazy-show 初始传入的条件为 false 时,那么会为你创建一个注释节点,用来占位:


createCallExpression(helper(CREATE_COMMENT), [
'"v-show-if"',
'true',
])

image.png



这个跟 v-if 一样



直到第一次条件为真时,将 _cache.${key} 置为 true,那么以后的行为就跟 v-show 一致了,上面的 dir.exp 即指令中的条件,如


<div v-show="enabled"/>

enabled 即 exp,表达式的意思。


readme给出的转换如下:


<template>
<div v-lazy-show="foo">
Hello
</div>
</template>

会转换为:


import { Fragment as _Fragment, createCommentVNode as _createCommentVNode, createElementBlock as _createElementBlock, createElementVNode as _createElementVNode, openBlock as _openBlock, vShow as _vShow, withDirectives as _withDirectives } from 'vue'

export function render(_ctx, _cache) {
return (_cache._lazyshow1 || _ctx.foo)
? (_cache._lazyshow1 = true, (_openBlock(),
_withDirectives(_createElementVNode('div', null, ' Hello ', 512 /* NEED_PATCH */), [
[_vShow, _ctx.foo]
])))
: _createCommentVNode('v-show-if', true)
}

你可以简单理解为会将<ExpansiveComponent msg="v-lazy-show" v-lazy-show=""enabled"/>转为下面:


<template v-if="_cache._lazyshow1 || enabled">
<!-- 为true时会把_cache._lazyshow1置为true,那么以后的v-if就用于为true了 -->
<ExpansiveComponent msg="v-lazy-show" v-lazy-show="enabled"/>
</template>
<template v-else>
<!--v-show-if-->
</template>

<template v-if="_cache._lazyshow2 || enabled">
<!-- 为true时会把_cache._lazyshow2置为true,那么以后的v-if就用于为true了 -->
<ExpansiveComponent msg="v-lazy-show" v-show.lazy="enabled"/>
</template>
<template v-else>
<!--v-show-if-->
</template>

然后将原先节点替换为处理后的 wrapperNode 即可


context.replaceNode(wrapNode)

最后将 v-lazy-show | v-shouw.lazy 处理为 v-show


因为 vue 本身是没有 v-lazy-show 的,v-show 也没有 lazy 的的修饰符,那么要让指令生效,就要做到两个:



  1. 将原先的 show-lazy 改名为 show

  2. 过滤掉 lazy 的修饰符


node.props.push({
...dir,
modifiers: dir.modifiers.filter(i => i !== 'lazy'),
name: 'show',
})

也就变成这样啦:


<template v-if="_cache._lazyshow1 || enabled">
<!-- 为true时会把_cache._lazyshow1置为true,那么以后的v-if就用于为true了 -->
<ExpansiveComponent msg="v-lazy-show" v-show="enabled"/>
</template>
<template v-else>
<!--v-show-if-->
</template>


<template v-if="_cache._lazyshow2 || enabled">
<!-- 为true时会把_cache._lazyshow2置为true,那么以后的v-if就用于为true了 -->
<ExpansiveComponent msg="v-show.lazy" v-show="enabled"/>
</template>
<template v-else>
<!--v-show-if-->
</template>

小结一下:




  1. 为每一个使用 v-lazy-show 分配唯一的 key,放到渲染函数内部的_cache上,即借助辅助变量_cache.${key}



    • 当初始条件为 falsy 时不渲染节点,只渲染注释节点 <!--v-show-if-->

    • 直到条件为真时将其置为 true,之后的表现就跟 v-show 一致了





  1. 由于 vue 不认识 v-lazy-show,v-show.lazy,使用要将指令改回 v-show,且过滤掉 lazy 修饰符(如果使用 v-show.lazy 的话)


最后


以上就是我对该运行时编译插件的认识了,可以将 repo 拉下来,上面有个 playground,可以自己调试调试,说不定有新的认识。


好了,文章到此为止,你今天学废了吗?


image.png



作者:暴走老七
来源:juejin.cn/post/7217836890119995450
收起阅读 »

给轮播图做一个自适应的高度。

web
不知道大家有没有遇到这样的需求或者说看到类似的效果,就是列表进去详情看轮播图的时候,当手指滚动轮播图时轮播的高度容器会自适应,这样下面的内容就向上挤,滑动的过程会计算高度,释放的时候也会滚到下一张,也会计算对应图片的高度,然后做一个缓动的动画效果。就像下面这张...
继续阅读 »

不知道大家有没有遇到这样的需求或者说看到类似的效果,就是列表进去详情看轮播图的时候,当手指滚动轮播图时轮播的高度容器会自适应,这样下面的内容就向上挤,滑动的过程会计算高度,释放的时候也会滚到下一张,也会计算对应图片的高度,然后做一个缓动的动画效果。就像下面这张图的样子。


1.gif


可以看到上面的图片内容文字,随着轮播的滑动高度也在变化。费话不多说直接上代码。


实现方法


可以通过监听鼠标mounse 或者手指的滑动 touch 事件来控制图片,这里本文只说一下轮播的功能实现思路,重点说的是怎么实现高度的自适应。


直接开始正文,先看 html 代码结构。


html 结构


<div class="container">
 <div class="wrapper">
   <div class="swiper">
     <div class="item">
       <img src="https://ci.xiaohongshu.com/776d1cc7-ff36-5881-ad8f-12a5cd1c3ab3?imageView2/2/w/1080/format/jpg" alt="">
     </div>
     <div class="item">
       <img src="https://ci.xiaohongshu.com/b8e16620-66a0-79a5-8a4b-5bfee1028554?imageView2/2/w/1080/format/jpg" alt="">
     </div>
     <div class="item">
       <img src="https://ci.xiaohongshu.com/e12013c2-3c46-a2cc-7fda-1e0b20b36f3d?imageView2/2/w/1080/format/jpg" alt="">
     </div>
   </div>
 </div>
 <div class="content">这是一段内容</div>
</div>

css 样式


.container {
 width: 100%;
 overflow: hidden;
}
.wrapper {
 width: 100%;
}
.swiper {
 font-size: 0;
 white-space: nowrap;
}
.item {
 display: inline-block;
 width: 100%;
 vertical-align: top; // 一定要使用顶部对齐,不然会出现错位的情况
}
.item img {
 width: 100%;
 height: auto;
 display: block;
}
.content {
 position: relative;
 z-index: 9;
 font-size: 14px;
 text-align: center;
 padding-top: 20px;
 background-color: #fff;
 height: 200px;
}

值得注意的地方有几点;



  1. 在使用父级 white-space 时,子集元素设置 display: inline-block 会出现高度不同的排列错位,解决办法就是加上一句 vertical-align: top ,具体什么原因我也不细讲了。

  2. 另外父级还要设置 font-size: 0 ,如果没加上的话,就会出现两个子集有空隙出现,加上之后空隙就会去掉。

  3. img 图片最好设置成高度自适应,宽度100% 还要加上 display: block ,没有的话底部就会出现间隙。


写好上面的 html容器部分和 样式,下面就看一下 js 上是怎么处理的。


Js 实现


开始之前我们先思考一下去怎么实现这个轮播以及高度的自适应问题,分为几步操作;



  1. 鼠标按下时,需要记录当前的位置和一些其他初始化的信息,并且给当前的父元素添加相应的鼠标事件。

  2. 鼠标移动时,需要通过当前实时移动时点位和按下时点位的相减,得到移动的距离位置,然后再赋值给父元素设置其样式 transform 位置,中间还做其他的边界处理,当然还有高度的变化。

  3. 鼠标释放是,通过移动时记录的距离信息判断是左滑还是右滑,拿到其对应的索引,通过索引就可以计算到滚动下一张的距离,释放之后设置 transition 过渡动画即可。


按照我们试想的思路,开始正文;


初始化数据


const data = {
 ele: null,
 width: 0,
 len: 0,
 proportion: .3,
 type: false,
 heights: [500, 250, 375],
 currentIndex: 0,
 startOffset: 0,
 clientX: 0,
 distanceX: 0,
 duration: 30,
 touching: false
}

const wrapper = data.ele = document.querySelector('.wrapper')
const items = document.querySelectorAll('.item')
data.width = wrapper.offsetWidth
data.len = items.length - 1
wrapper.addEventListener('touchstart', onStart)
wrapper.addEventListener('mousedown', onStart)

注意,这里在做高度之前,我们需要等图片加载完成之后才能拿到每一个元素的高度,我这里为了省懒就没写具体代码,上面的 heights 对应的是每个图片在渲染之后的高度,一般情况下最好让后端传回来带宽高,这样就不需要用 onload 再去处理这个。


鼠标按下时


function onStart(event) {
 if (event.type === 'mousedown' && event.which !== 1) return
 if (event.type === 'touchstart' && event.touches.length > 1) return
 data.type = event.type === 'touchstart'
 const events = data.type ? event.touches[0] || event : event

 data.touching = true
 data.clientX = events.clientX
 data.startOffset = data.currentIndex * -data.width

 data.ele.style.transition = `none`
 window.addEventListener(data.type ? 'touchmove' : 'mousemove', onMove, { passive: false })
 window.addEventListener(data.type ? 'touchend' : 'mouseup', onEnd, false)
}

上面的代码里面我做了PC和移动端的兼容,跟计划的一样,保存一下 clientX 坐标和一个初始的坐标 startOffset 这个由当前索引和父级宽度计算得到,场景是当从第二张图片滚动到第三张图片时,会把之前的第一张图片的距离也要加上去,不然就计算错误,看下面滑动时的代码。


另外在做监听移动的时候加上了 passive: false 是为了在移动端兼容处理。


鼠标移动时


function onMove(event) {
 event.preventDefault()
 if (!data.touching) return
 const events = data.type ? event.touches[0] || event : event

 data.distanceX = events.clientX - data.clientX

 let translatex = data.startOffset + data.distanceX
 if (translatex > 0) {
   translatex = translatex > 30 ? 30 : translatex
} else {
   const d = -(data.len * data.width + 30)
   translatex = translatex < d ? d : translatex
}

 data.ele.style.transform = `translate3d(${translatex}px, 0, 0)`
 data.ele.style.webkitTransform = `translate3d(${translatex}px, 0, 0)`
}

做了一个边界处理的,超了 30 的距离就不让继续滑动了,加上之前保存的 startOffset 的值,得到的就是具体移动的距离了。


鼠标释放时


function onEnd() {
 if (!data.touching) return
 data.touching = false

 // 通过计算 proportion 滑动的阈值拿到释放后的索引
 if (Math.abs(data.distanceX) > data.width * data.proportion) {
   data.currentIndex -= data.distanceX / Math.abs(data.distanceX)
}
 if (data.currentIndex < 0) {
   data.currentIndex = 0
} else if (data.currentIndex > data.len) {
   data.currentIndex = data.len
}
 const translatex = data.currentIndex * -data.width

 data.ele.style.transition = 'all .3s ease'
 data.ele.style.transform = `translate3d(${translatex}px, 0, 0)`
 data.ele.style.webkitTransform = `translate3d(${translatex}px, 0, 0)`

 window.removeEventListener(data.type ? 'touchmove' : 'mousemove', onMove, { passive: false })
 window.removeEventListener(data.type ? 'touchend' : 'mouseup', onEnd, false)
}

通过计算 proportion 滑动的阈值拿到释放后的索引,也就是超过父级宽度的三分之一时释放就会滚动到下一张,拿到索引之后就可以设置需要移动的最终距离,记得加上 transition 做一个缓动效果,最后也别忘记移除事件的监听。


至此上面的简单的轮播效果就大功告成了,但是还缺少一点东西,就是本篇需要讲的自适应高度,为了方便理解就单独拿出来说一下。


高度自适应


在移动时就可以在里面做相关的代码整理了, onMove 函数里加上以下代码,来获取实时的高度。


const index = data.currentIndex
const currentHeight = data.heights[index]
   
// 判断手指滑动的方向拿到下一张图片的高度
let nextHeight = data.distanceX > 0 ? data.heights[index - 1] : data.heights[index + 1]
let diffHeight = Math.abs((nextHeight - currentHeight) * (data.distanceX / data.width))
let realHeight = currentHeight + (nextHeight - currentHeight > 0 ? diffHeight : -diffHeight)

data.ele.style.height = `${realHeight}px`

这里是移动时的高度变化,另外还需要在释放时也要处理, onEnd 函数里加上以下代码。


// ... 因为上面已经拿到了下一张的索引 currentIndex
const currentHeight = data.heights[data.currentIndex]

data.ele.style.height = `${currentHeight}px`

因为上面已经拿到了下一张的索引 currentIndex 所以再滚动到下一张是就直接通过数据获取就可以了。


可以在线预览一下效果。


作者:ZHOUYUANN
来源:juejin.cn/post/7213654163317162045
收起阅读 »

简析无感知刷新Token

web
在前后端分离的应用中,使用Token进行认证是一种较为常见的方式。但是,由于Token的有效期限制,需要不断刷新Token,否则会导致用户认证失败。为了解决这个问题,可以实现无感知刷新Token的功能,本文将介绍如何实现无感知刷新Token。 Token认证的...
继续阅读 »

在前后端分离的应用中,使用Token进行认证是一种较为常见的方式。但是,由于Token的有效期限制,需要不断刷新Token,否则会导致用户认证失败。为了解决这个问题,可以实现无感知刷新Token的功能,本文将介绍如何实现无感知刷新Token。


Token认证的原理


在Web应用中,常见的Token认证方式有基于Cookie和基于Token的认证。基于Cookie的认证方式是将认证信息保存在Cookie中,每次请求时将Cookie发送给服务器进行认证;而基于Token的认证方式是将认证信息保存在Token中,每次请求时将Token发送给服务器进行认证。


在基于Token的认证方式中,客户端将认证信息保存在Token中,而不是保存在Cookie中。在认证成功后,服务器将生成一个Access Token和一个Refresh Token,并将它们返回给客户端。Access Token用于访问受保护的API,Refresh Token用于获取新的Access Token。


什么是无感知刷新Token


无感知刷新Token是指,在Token过期之前,系统自动使用Refresh Token获取新的Access Token,从而实现Token的无感知刷新,用户可以无缝继续使用应用。


在实现无感知刷新Token的过程中,需要考虑以下几个方面:



  • 如何判断Token是否过期?

  • 如何在Token过期时自动使用Refresh Token获取新的Access Token?

  • 如何处理Refresh Token的安全问题?


下面将介绍如何实现无感知刷新Token的具体步骤。


实现步骤


步骤一:获取Access Token和Refresh Token


在认证成功后,需要将Access Token和Refresh Token发送给客户端。Access Token用于访问受保护的API,Refresh Token用于获取新的Access Token。可以使用JWT(JSON Web Token)或OAuth2(开放授权)等方式实现认证。


在JWT中,可以使用如下代码生成Access Token和Refresh Token:


const accessToken = jwt.sign({userId: '123'}, 'ACCESS_TOKEN_SECRET', {expiresIn: '15m'});
const refreshToken = jwt.sign({userId: '123'}, 'REFRESH_TOKEN_SECRET', {expiresIn: '7d'});

步骤二:在请求中携带Access Token


在每个需要认证的API请求中,需要在请求头中携带Access Token,如下所示:


GET /api/user HTTP/1.1
Host: example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

在前端中,可以使用Axios等库设置请求头:


axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;

步骤三:拦截401 Unauthorized响应


在服务器返回401 Unauthorized响应时,说明Access Token已经过期,需要使用Refresh Token获取新的Access Token。可以使用Axios拦截器或Fetch API的中间件实现拦截。


在Axios中,可以使用如下代码实现拦截器:


axios.interceptors.response.use(response => {
return response;
}, error => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; //防止无限调用
return axios.post('/api/refresh_token', {refreshToken})
.then(response => {
const { access_token, refresh_token } = response.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return axios(originalRequest);
});
}
return Promise.reject(error);
});

在Fetch中,可以使用如下代码实现中间件:


function authMiddleware(request) {
const access_token = localStorage.getItem('access_token');
if (access_token) {
request.headers.set('Authorization', `Bearer ${access_token}`);
}
return request;
}

function tokenRefreshMiddleware(response) {
if (response.status === 401) {
const refreshToken = localStorage.getItem('refresh_token');
return fetch('/api/refresh_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken })
}).then(response => {
if (response.ok) {
return response.json();
}
throw new Error('Refresh Token failed');
}).then(data => {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
return Promise.resolve('refreshed');
}).catch(error => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
return Promise.reject(error);
});
}
return Promise.resolve('ok');
}

fetch('/api/user', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
middleware: [authMiddleware, tokenRefreshMiddleware]
}).then(response => {
console.log(response);
}).catch(error => {
console.error(error);
});

在上述代码中,使用Axios或Fetch拦截器拦截401 Unauthorized响应,如果发现Access Token已经过期,则发送Refresh Token请求获取新的Access Token,并将新的Access Token设置到请求头中,重新发送请求。


步骤四:服务器处理Refresh Token请求


在服务器端,需要编写API处理Refresh Token请求,生成新的Access Token,并返回给客户端。


JWT中,可以使用如下代码生成新的Access Token:


const accessToken = jwt.sign({userId: '123'}, 'ACCESS_TOKEN_SECRET', {expiresIn: '15m'});

在刷新Token时,需要验证Refresh Token的合法性,可以使用如下代码验证Refresh Token:


try {
const payload = jwt.verify(refreshToken, 'REFRESH_TOKEN_SECRET');
const accessToken = jwt.sign({userId: payload.userId}, 'ACCESS_TOKEN_SECRET', {expiresIn: '15m'});
const refreshToken = jwt.sign({userId: payload.userId}, 'REFRESH_TOKEN_SECRET', {expiresIn: '7d'});
res.json({access_token: accessToken, refresh_token: refreshToken});
} catch (err) {
res.sendStatus(401);
}

在上述代码中,使用JWT的verify方法验证Refresh Token的合法性,如果验证成功,则生成新的Access Token和Refresh Token,并返回给客户端。


步骤五:设置定时刷新Token


为了避免Access Token过期时间太长,可以设置定时刷新Token的功能。可以使用定时器或Web Workers等方式实现定时刷新Token。在每次刷新Token时,需要重新获取新的Access Token和Refresh Token,并保存到客户端。


function refreshToken() {
const refreshToken = localStorage.getItem('refresh_token');
axios.post('/api/refresh_token', {refreshToken})
.then(response => {
const { access_token, refresh_token } = response.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
})
.catch(error => {
console.error(error);
});
}

setInterval(refreshToken, 14 * 60 * 1000); // 每14分钟刷新Token

在上述代码中,使用定时器每14分钟刷新Token。在刷新Token成功后,将新的Access Token和Refresh Token保存到客户端,并将新的Access Token设置到请求头中。


安全性考虑


在实现无感知刷新Token的过程中,需要考虑到Refresh Token的安全性问题。因为Refresh Token具有长期的有效期限,一旦Refresh Token被泄露,攻击者就可以使用Refresh Token获取新的Access Token,从而绕过认证机制,访问受保护的API。


为了增加Refresh Token的安全性,可以考虑以下几种措施:



  • 将Refresh Token保存在HttpOnly Cookie中,可以避免在客户端被JavaScript获取;

  • 对Refresh Token进行加密或签名,可以增加其安
    作者:XinD
    来源:juejin.cn/post/7215569601161150522
    全性。

收起阅读 »

从0搭建nestjs项目并部署到本地docker

web
开发目标:快速搭建nestjs项目本地环境,并测试本地打包方便后期部署到服务器。 项目准备:node环境、npm依赖、docker 创建项目并启动 使用typeorm连接mysql 使用class-validate校验入参 使用全局filter处理异常,使用...
继续阅读 »

开发目标:快速搭建nestjs项目本地环境,并测试本地打包方便后期部署到服务器。


项目准备:node环境、npm依赖、docker



  1. 创建项目并启动

  2. 使用typeorm连接mysql

  3. 使用class-validate校验入参

  4. 使用全局filter处理异常,使用全局interceptor处理成功信息

  5. 使用ioredis连接redis

  6. 使用swaager文档

  7. 使用docker-compose打包并运行

  8. 总结


一、创建项目并启动


1、全局安装nestjs并创建项目

npm i -g @nestjs/cli
nest new nest-demo

2、使用热更新模式运行项目

npm run start:dev

此时访问 http://localhost:3000就可以看到 Hello World!


3、使用cli一键生成一个user模块

nest g resource system/user

选择REST API和自动生成CURD


4、设置全局api前缀

src/main.ts


async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); // 设置全局api前缀
await app.listen(3000);
}
bootstrap();

更多nestjs入门教程查看:# 跟随官网学nestjs之入门


二、使用typeorm连接并操作mysq


1、安装依赖

npm i @nestjs/typeorm typeorm mysql @nestjs/config -S

2、在src下创建 config/env.ts 用来判断当前环境,抛出配置文件地址

src/config/env.ts


import * as fs from 'fs';
import * as path from 'path';
const isProd = process.env.NODE_ENV == 'prod';

function parseEnv() {
const localEnv = path.resolve('.env');
const prodEnv = path.resolve('.env.prod');

if (!fs.existsSync(localEnv) && !fs.existsSync(prodEnv)) {
throw new Error('缺少环境配置文件');
}

const filePath = isProd && fs.existsSync(prodEnv) ? prodEnv : localEnv;
return { path: filePath };
}
export default parseEnv();

3、在src下创建.env配置文件

src/.env


# default
PORT=9000

# database
DB_HOST=localhost
DB_PORT=3306
DB_USER=demo_user
DB_PASSWD=123456
DB_DATABASE=demo_db


4、在app.module内挂载全局配置和mysql

src/app.module.ts


import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService, ConfigModule } from '@nestjs/config';
import envConfig from './config/env';
import { AppService } from './app.service';
import { UserModule } from './system/user/user.module';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 设置为全局
envFilePath: [envConfig.path],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
type: 'mysql',
host: configService.get('DB_HOST', 'localhost'), // 主机,默认为localhost
port: configService.get<number>('DB_PORT', 3306), // 端口号
username: configService.get('DB_USER', 'root'), // 用户名
password: configService.get('DB_PASSWORD', '123456'), // 密码
database: configService.get('DB_DATABASE', 'test_db'), //数据库名
entities: ['dist/**/*.entity{.ts,.js}'],
timezone: '+08:00', //服务器上配置的时区
synchronize: true, //根据实体自动创建数据库表, 生产环境建议关闭
autoLoadEntities: true,
}),
}),
UserModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

5、定义userEntity实体

src/system/user/entities/user.entity.ts


import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity('user_tb')
export class UserEntity {
@PrimaryGeneratedColumn()
s_id: string;

@Column({ type: 'varchar', length: 20, default: '', comment: '名称' })
s_name: string;

@Column({ type: 'int', default: 0, comment: '年龄' })
s_age: number;
}

6、user.module内引入entity实体

import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
// 引入typeorm和Enetiy实例
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './entities/user.entity';

@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
controllers: [UserController],
providers: [UserService],
}
)

export class UserModule {}

7、在控制器user.controller修改api地址

@Post('create')
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}

地址拼接为:全局前缀api+模块user+自定义create = localhost:3000/api/user/crtate


image.png


image.png


三、使用class-validato校验入参


1、安装依赖

npm i class-validator class-transformer -S

2、配置校验规则

src/system/user/dto/create-user.dto.ts


import { IsNotEmpty } from 'class-validator';

export class CreateUserDto {
@IsNotEmpty({ message: '名称不能为空' })
readonly s_name: string;
}

image.png


更多校验规则查看:git文档


四、使用filter全局错误过滤、interceptor全局成功过滤


1、使用cli自动生成过滤器


nest g filter common/http-exception
nest g interceptor common/transform

2、编写过滤器


src/common/http-exception/http-exception.filter.ts


import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp(); // 获取请求上下文
const response = ctx.getResponse(); // 获取请求上下文中的 response对象
const status = exception.getStatus(); // 获取异常状态码

let resultMessage = exception.message;

// 拦截class-validate错误信息
try {
const exceptionResponse = exception.getResponse() as any;
if (Object.hasOwnProperty.call(exceptionResponse, 'message')) {
resultMessage = exceptionResponse.message;
}
} catch (e) {}

const errorResponse = {
data: null,
message: resultMessage,
code: '9999',
};

// 设置返回的状态码, 请求头,发送错误信息
response.status(status);
response.header('Content-Type', 'application/json; charset=utf-8');
response.send(errorResponse);
}
}

src/common/transform/transform.interceptor.ts


import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
return {
data,
code: '0000',
msg: '请求成功',
};
}),
);
}
}

3、在main.ts里挂载


import { HttpExceptionFilter } from './common/http-exception/http-exception.filter';
import { TransformInterceptor } from './common/transform/transform.interceptor';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter()); // 全局注册错误的过滤器(错误异常)
app.useGlobalInterceptors(new TransformInterceptor()); // 全局注册成功过滤器
await app.listen(3000);
}
bootstrap();

手动抛出异常错误只需在service的方法里


throw new HttpException('message', HttpStatus.BAD_REQUEST)


五、使用idredis连接redis


1、安装依赖

npm i ioredis -S

2、在.env文件添加reids配置

# redis
REDIS_HOST=localhost
REIDS_PORT=6379
REIDS_PASSWD=
REIDS_DB=3

3、在common目录下创建cache模块,连接redis

nest g mo cache common && nest g s cache common

src/common/cache/cache.service.ts


import { Injectable, Logger } from '@nestjs/common';
import { Redis } from 'ioredis';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class CacheService {
public client;
constructor(private readonly configService: ConfigService) {
this.getClient();
}

async getClient() {
const client = new Redis({
host: this.configService.get('REDIS_HOST', 'localhost'), // 主机,默认为localhost
port: this.configService.get<number>('REIDS_PORT', 6379), // 端口号
password: this.configService.get('REIDS_PASSWD', ''), // 密码
db: this.configService.get<number>('REIDS_DB', 3),
});
// 连接成功提示
client.on('connect', () =>
Logger.log(
`redis连接成功,端口${this.configService.get<number>(
'REIDS_PORT',
3306,
)}
`
,
),
);
client.on('error', (err) => Logger.error('Redis Error', err));

this.client = client;
}

public async set(key: string, val: string, second?: number) {
const res = await this.client.set(key, val, 'EX', second);
return res === 'OK';
}

public async get(key: string) {
const res = await this.client.get(key);
return res;
}
}

在cache.module内抛出service
src/common/cache/cache.module.ts


@Module({
providers: [CacheService],
exports: [CacheService],
})

4、在user.module内引入cacheModule并在user.service内使用

src/system/user/user.module.ts


import { CacheModule } from 'src/common/cache/cache.module';
@Module({
imports: [CacheModule],
controllers: [UserController],
providers: [UserService],
})

export class UserModule {}

src/system/user/user.service.ts


import { CacheService } from '@src/common/cache/cache.service';

@Injectable()
export class UserService {
constructor(
private readonly cacheService: CacheService,
) {}

async create(createUserDto: CreateUserDto) {
const redisTest = await this.cacheService.get('redisTest');

Logger.log(redisTest, 'redisTest');
if (!redisTest) {
await this.setRedis();
return this.create(createUserDto);
}

...
}
async setRedis() {
const res = await this.cacheService.set(
'redisTest',
'test_val',
12 * 60 * 60,
);
if (!res) {
Logger.log('redis保存失败');
} else {
Logger.log('redis保存成功');
}
}
}

image.png


image.png


六、使用swagger生成文档


1、安装依赖

npm i @nestjs/swagger swagger-ui-express -S

2、在main.ts引入并配置

import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 设置swaager
const options = new DocumentBuilder()
.setTitle('nest-demo example')
.setDescription('The nest demo API description')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup('swagger', app, document);

...
}
bootstrap();

此时访问http://wwww.localhost:9000/swagge就可以看到文档


image.png


3、在控制器为业务模块和api打上标签

src/system/user/user.controller.ts


import { ApiTags, ApiOperation } from '@nestjs/swagger';

@ApiTags('user')
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}

@ApiOperation({
summary: '创建用户',
})
@Post('create')
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
}

4、在dto内为字段设置名称

src/system/user/dto/create-user.dto.ts


import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
@ApiProperty({ type: 'string', example: '用户名称' })
@IsNotEmpty({ message: '名称不能为空' })
readonly s_name: string;

@ApiProperty({ type: 'number', example: '用户年龄' })
readonly s_age: number;
}

这时刷新浏览器,就能看到文档更新了


image.png


更多swaager配置查看:官方文档


七、使用docker-compose自动部署到本地docker


1、在根目录下创建docker-compose.yml

version: "3.0"

services:
# docker容器启动的redis默认是没有redis.conf的配置文件,所以用docker启动redis之前,需要先去官网下载redis.conf的配置文件
redis_demo: # 服务名称
container_name: redis_demo # 容器名称
image: daocloud.io/library/redis:6.0.3-alpine3.11 # 使用官方镜像
# 配置redis.conf方式启动
# command: redis-server /usr/local/etc/redis/redis.conf --requirepass 123456 --appendonly yes # 设置redis登录密码 123456、--appendonly yes:这个命令是用于开启redis数据持久化
# 无需配置文件方式启动
command: redis-server --appendonly yes # 开启redis数据持久化
ports:
- 6379:6379 # 本机端口:容器端口
restart: on-failure # 自动重启
volumes:
- ./deploy/redis/db:/data # 把持久化数据挂载到宿主机
- ./deploy/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf # 把redis的配置文件挂载到宿主机
- ./deploy/redis/logs:/logs # 用来存放日志
environment:
- TZ=Asia/Shanghai # 解决容器 时区的问题
networks:
- my-server_demo

mysql_demo:
container_name: mysql_demo
image: daocloud.io/library/mysql:8.0.20 # 使用官方镜像
ports:
- 3306:3306 # 本机端口:容器端口
restart: on-failure
environment:
MYSQL_DATABASE: demo_db
MYSQL_ROOT_PASSWORD: 123456
MYSQL_USER: demo_user
MYSQL_PASSWORD: 123456
MYSQL_ROOT_HOST: '%'
volumes:
- ./deploy/mysql/db:/var/lib/mysql # 用来存放了数据库表文件
- ./deploy/mysql/conf/my.cnf:/etc/my.cnf # 存放自定义的配置文件
# 我们在启动MySQL容器时自动创建我们需要的数据库和表
# mysql官方镜像中提供了容器启动时自动docker-entrypoint-initdb.d下的脚本的功能
- ./deploy/mysql/init:/docker-entrypoint-initdb.d/ # 存放初始化的脚本
networks:
- my-server_demo

server_demo: # nestjs服务
container_name: server_demo
build: # 根据Dockerfile构建镜像
context: .
dockerfile: Dockerfile
ports:
- 9003:9003
restart: on-failure # 设置自动重启,这一步必须设置,主要是存在mysql还没有启动完成就启动了node服务
networks:
- my-server_demo
depends_on: # node服务依赖于mysql和redis
- redis_demo
- mysql_demo

# 声明一下网桥 my-server。
# 重要:将所有服务都挂载在同一网桥即可通过容器名来互相通信了
# 如nestjs连接mysql和redis,可以通过容器名来互相通信
networks:
my-server_demo:

2、在根目录创建Dockerfile文件

FROM daocloud.io/library/node:14.7.0

# 设置时区
ENV TZ=Asia/Shanghai \
DEBIAN_FRONTEND=noninteractive
RUN ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && dpkg-reconfigure --frontend noninteractive tzdata && rm -rf /var/lib/apt/lists/*

# 创建工作目录
RUN mkdir -p /app

# 指定工作目录
WORKDIR /app

# 复制当前代码到/app工作目录
COPY . ./

# npm 源,选用国内镜像源以提高下载速度
RUN npm config set registry https://registry.npm.taobao.org/

# npm 安装依赖
COPY package.json /app/package.json
RUN rm -rf /app/package-lock.json
RUN cd /app && rm -rf /app/node_modules && npm install

# 打包
RUN cd /app && rm -rf /app/dist && npm run build

# 启动服务
# "start:prod": "cross-env NODE_ENV=production node ./dist/src/main.js",
CMD npm run start:prod

EXPOSE 9003

3、修改.env.prod正式环境配置

# default
PORT=9003
HOST=localhost

# database
DB_HOST=mysql_demo #使用容器名称连接
DB_PORT=3306
DB_USER=demo_user
DB_PASSWD=123456
DB_DATABASE=demo_db

# redis
REDIS_HOST=redis_demo #使用容器名称连接
REIDS_PORT=6379
REIDS_PASSWD=
REIDS_DB=3

4、修改main.ts启动端口

import { ConfigService } from '@nestjs/config';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService); // 获取全局配置
const PORT = configService.get<number>('PORT', 9000);
const HOST = configService.get('HOST', 'localhost');
await app.listen(PORT, () => {
Logger.log(`服务已经启动,接口请访问:http://wwww.${HOST}:${PORT}`);
});
}
bootstrap();

5、前台运行打包

docker-compose up


运行完成后大概率会报错,因为我们使用的mysql账号没有权限,所以需要进行设置


image.png


// 进入mysql容器命令
docker ecex -it mysql_demo /bin/bash
// 登录mysql
mysql -uroot -p123456
// 查询数据库后进入mysql查询数据表
show databases;
use mysql;
show tables;
// 查看user表中的数据
select User,Host from user;
// 刚创建的用户表没有我们设置连接的用户和host,所以需要创建
CREATE USER 'demo_user'@'%' IDENTIFIED BY '123456';
// 给创建的用户赋予权限
GRANT ALL ON *.* TO 'demo_user'@'%';
// 刷新权限
flush privileges;

如果还报错修改下密码即可
Pasted Graphic 1.png


ALTER USER 'demo_user'@'%' IDENTIFIED WITH mysql_native_password BY '123456';

此时项目应该能正常启动并成功访问


image.png


image.png


6、切换后台运行

// Ctrl+C 终止程序后执行后台运行命令
docker-compose up -d

八、总结


docker-compose up正常用来测试本地打包,和第一次构建redismysql容器,后续需要在本地运行开发模式只需保证redismysql容器正常运行即可,如需再次打包,删除server容器和镜像再次执行即可


docker ps -a // 查询docker容器
docker rm server_demo // 删除server容器
docker images // 查询镜像
docker rmi nest-demo_server_demo // 删除server镜像, server镜像名称:项目名称_容器名称
docker-compose up -d // 重新打包

本地开发模式只需关闭server容器,然后在项目内只需 start:dev即可


docker stop server_demo
npm run start:dev

作者:jjggddb
来源:juejin.cn/post/7215844385614528549
收起阅读 »

keepAlive模式下切换页面时缓存页面中el-select已展开的选项框无法自动关闭解决方案

web
问题描述 如下图,在keepAlive缓存的页面中使用element中的select选择器,打开弹出框后不手动关闭,直接切换页面,会出现弹出框仍然展示在页面上的现象。 问题原因 select选择器提供一个属性 popper-append-to-body 为...
继续阅读 »

问题描述


如下图,在keepAlive缓存的页面中使用element中的select选择器,打开弹出框后不手动关闭,直接切换页面,会出现弹出框仍然展示在页面上的现象。


select-bug.gif


问题原因



  1. select选择器提供一个属性 popper-append-to-body 为false时,弹出框是放置在select选择器所在层级中,为true时,允许将弹出框插入至body元素中。


image.png


image.png



  1. 本页面被keepAlive缓存后 再切出本页面时不会触发select选择器组件的blur事件


所以当弹出框被插入至body元素中时,切出缓存页面 无法触发select选择器组件的blur事件,弹出框在body中无法隐藏


解决方法1


设置属性 popper-append-to-body为false,弹出框不会直接插入至body元素中,页面切换后弹出框也会被隐藏


局限性:
某些场景需要设置select选择器上级元素超出隐藏,弹出框如果超出上级元素的范围则无法完全展示


image.png


解决方法2


elementselect选择器源码中弹出框开启关闭由变量visible控制,将elselect组件包装一下,在deactivated生命周期钩子里设置弹出框关闭,注册组件时


image.png


// SelectWrapper 组件
<script lang="ts">
import { Mixins, Component, Watch } from 'vue-property-decorator';
import { Select } from 'element-ui';

@Component({
name: 'ElSelect',
})
export default class ElSelect extends Mixins(Select) {
visible: boolean | undefined;

deactivated() {
this.visible = false;
}
}
</script>

入口文件全局注册新的SelectWrapper组件,替换掉elementselect选择器,这样可以做到在业务组件中无感使用


 app.component('el-select', SelectWrapper);

作者:Eden的前端笔记
来源:juejin.cn/post/7215855138812461115
收起阅读 »

iframe之间的通信

web
前言 iframe 想必大家都挺熟悉的了,就不多说了👍👍。写这篇文章的初衷主要是丰富自己的知识和解决遇到的问题。因为我基本上没接触过 iframe ,所以对它的通信方式不是很了解。前几天,跟我的一个朋友(在下杨公子)聊天时,他提到了 iframe 的通信方式,...
继续阅读 »

前言


iframe 想必大家都挺熟悉的了,就不多说了👍👍。写这篇文章的初衷主要是丰富自己的知识和解决遇到的问题。因为我基本上没接触过 iframe ,所以对它的通信方式不是很了解。前几天,跟我的一个朋友(在下杨公子)聊天时,他提到了 iframe 的通信方式,我觉得很有意思,就开始了解和学习。在这篇文章中,我将分享我所学到的内容,希望对大家有所帮助🤪🤪。


接下来我们就一起来学习一下关于 iframe 通信的相关知识吧😁


iframe通信的几种方式😶‍🌫️😶‍🌫️



  1. URL 传参:父窗口可以通过在 iframe 的 src 属性后添加参数来向子窗口传递数据,子窗口可以通过 location.searchlocation.hash 来获取参数✨✨。



  • 使用 ? 拼接参数,子页面使用 location.search 接收参数


// parent.html
<iframe id="iframe1" src="./child1.html?name=来自parent的消息" frameborder="0"></iframe>

// child1.html
<script>
console.log(window.decodeURIComponent(location.search)) // ?name=来自parent的消息
</script>



  • 使用 # 拼接参数,子页面使用 location.hash 接收参数,同时还可以使用 window.onhashchange 来监听参数的变化。


// parent.html
<iframe id="iframe1" src="./child1.html#name=来自parent的消息" frameborder="0"></iframe>
<script>
const iframe1 = document.getElementById('iframe1')
// 在2s后更改hash
setTimeout(() => {
iframe1.src = './child1.html#age=12'
}, 2000)
</script>


// child1.html
<script>
console.log('hash', window.decodeURIComponent(location.hash)) // #name=来自parent的消息
window.onhashchange = () => {
console.log('hashchange', window.location.hash) // #age=12
}
</script>



⚡⚡需要注意的是通过 URL 传参 的时候,传输携带中文的话,记得使用 decodeURIComponent 进行解码。




  1. window.postMessage:安全、可靠且支持跨域的 iframe 通信方式,它可以在两个窗口之间异步传递消息✨✨✨✨✨。



  • 在发送方中,使用 window.postMessage() 方法向另一个窗口发送消息。该方法接收两个参数:要发送的消息和目标窗口的源(例如,"http://127.0.0.1:5500/child.html" 或 "*")。


window.postMessage('Hello world!', 'http://127.0.0.1:5500/child.html')


  • 在接收方中,使用 window.addEventListener() 方法监听 message 事件。该事件对象包含三个属性:data 表示接收到的数据,origin 表示发送方的源,source 表示发送方窗口的引用。


window.addEventListener('message', function(event) {
// 判断消息是否来自可信任的源
if (event.origin === 'http://127.0.0.1:5500/child.html') {
console.log('message: ' + event.data)
}
})

兼容性,来自 window.postMessage | MDN


image.png



  1. window.name:可以使用一个隐藏的iframe和window.name属性在不同的窗口之间共享数据✨✨。



  • 在子页面中,将要传递给父页面的数据保存在 window.name 属性中。


例如


window.name = 'Hello Parent!';


  • 在父页面中,创建一个隐藏的 iframe 元素,并且将其源设置为子页面的 URL


const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = 'http://127.0.0.1:5500/child1.html';
document.body.appendChild(iframe);


  • 在父页面中,等待 iframe 加载完成后,通过访问 iframe.contentWindow.name 属性来获取子页面中保存的数据。


iframe.onload = function() {
const childData = iframe.contentWindow.name;
onsole.log('message:', childData); // 输出:message: Hello Parent!
};


⚡⚡注意:使用 window.name 进行跨域 iframe 通信存在安全性问题,因为所有具有相同名称的窗口都可以访问和修改 window.name




  1. 服务器端转发:可以将消息从一个iframe发送到服务器,然后再由服务器将其转发到另一个iframe。✨✨✨



博客主要记录一些学习的文章,如有不足,望大家指出,谢谢。



作者:树深遇鹿
来源:juejin.cn/post/7215854856731934781
收起阅读 »

女朋友想学webGL修图,安排!

web
前言 看完小白可以用webgl实现修图功能!我们平常生活中都使用过adobe photoshop修图,各种各样的滤镜以及特效眼花缭乱,实现高斯模糊,雕刻,曝光等这些特效看起来似乎很难,那么今天我们来手敲一个简单实现。 之前讲了简单的webgl的原理与点的绘制、...
继续阅读 »

前言


看完小白可以用webgl实现修图功能!我们平常生活中都使用过adobe photoshop修图,各种各样的滤镜以及特效眼花缭乱,实现高斯模糊,雕刻,曝光等这些特效看起来似乎很难,那么今天我们来手敲一个简单实现。


之前讲了简单的webgl的原理与点的绘制、以及webgl在vscode需要注意的点,本文将接着介绍如何做个简单的修图功能,由于篇幅有限,只讲基本的语法、多边形绘制、缓冲区、帧缓存、纹理uv等。


预览


chrome-capture-2023-2-30.gif


canvas也可以更简单的实现,getImageData可以得到点的集合,然后putImageData绘制就行了。但是一些复杂的算法,例如高斯模糊、雕刻效果,貌似就没有webgl灵活了。


createBuffer 缓冲区


缓冲区你可以理解canvas的save()保存状态,但是这里我们一般是点的集合,这里我不会讲具体的api细节,但是知道具体的代码流程就行,就是创建buffer及数据 -> 绑定数据 -> 如何加载


let bufferOrigin = gl.createBuffer()
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, bufferOrigin);

gl.enableVertexAttribArray(positionAttributeLocation); // 告诉缓冲区怎么加载
gl.vertexAttribPointer(positionAttributeLocation, size, type, normalize, stride, offset);

Program 对象


const canvas = document.querySelector("#canvas");
image.width = 540
image.height = 720

canvas.style.width = 540 + 'px'
canvas.style.height = 720 + 'px'

const gl = canvas.getContext("webgl");
if (!gl) {
return;
}
const program = webglUtils.createProgramFromScripts(gl, ["vertex-shader-2d", "fragment-shader-2d"]);

image加载的dom对象,设置宽高,webglUtils是封装的方法,其实就是之前的初始化的着色器,返回program程序对象。


shader 着色器


先看看着色器源码


<script id="vertex-shader-2d" type="x-shader/x-vertex">
attribute vec2 a_position; // attribute在顶点着色器处理
attribute vec2 a_texCoord; // 纹理参数
uniform vec2 u_resolution; // 页面的坐标
attribute vec4 a_composeColor; // 纹理增强的向量
varying vec4 v_composeColor;

void main() {
// 屏幕坐标 -> 裁剪坐标
vec2 zeroToOne = a_position / u_resolution;
vec2 zeroToTwo = zeroToOne * 2.0;

vec2 clipSpace = zeroToTwo - 1.0;

gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
v_composeColor = a_composeColor;
v_texCoord = a_texCoord;
}
</script>

attribute类型用于顶点着色器的属性,一般可以在后期动态添加一些控制,但是uniform是只能静态编译的时候就决定了,所以一般用于控制材质、光照等确定的值。为了能控制到片元着色器,那么一定要使用varying这个类型,一般通过变量传递给片元着色器做动态的渲染,所以一般会配合attriubute + varying


<script id="fragment-shader-2d" type="x-shader/x-fragment">
precision mediump float;

uniform sampler2D u_image;
uniform vec2 u_textureSize;
uniform float u_kernel[9];
uniform float u_kernelWeight;
varying vec2 v_texCoord;
varying vec4 v_composeColor;

void main() {
vec2 onePixel = vec2(1.0, 1.0) / u_textureSize;
// 卷积内核的前置处理,u_kernel我们传递的核心数据
vec4 colorSum =
texture2D(u_image, v_texCoord + onePixel * vec2(-1, -1)) * u_kernel[0] +
texture2D(u_image, v_texCoord + onePixel * vec2( 0, -1)) * u_kernel[1] +
//.....
// 计算最终的颜色结果
gl_FragColor = vec4((colorSum / u_kernelWeight).rgb, 1) * v_composeColor;
}
</script>


这里返回的结果gl_FragColor就是最终绘制的颜色。注意颜色范围是0到1需要做个转换,这里的最核心的也就是卷积的算法


卷积


卷积就是一个 3×3 的矩阵, 矩阵中的每一项代表当前处理的像素和周围8个像素的乘法因子, 相乘后将结果加起来除以内核权重(内核中所有值的和或 1.0 ,取二者中较大者)


image.png


像素矩阵 * 修改矩阵 = 赋值于内核也就是中心位置


这也就是我们能处理模糊、锐化等特效的原理, 下面是简单的计算


// 将周围八个点相加用于平均数相除
function computeKernelWeight(kernel) {
const weight = kernel.reduce(function(prev, curr) {
return prev + curr;
});
return weight <= 0 ? 1 : weight;
}

 gl_FragColor = vec4((colorSum / u_kernelWeight).rgb, 1) * v_composeColor;

滤镜


 const kernelsFilter = {
sharpness: {
name: '锐度',
data: [
0, -1, 0,
-1, 5, -1,
0, -1, 0
],
},
gaussianBlur: {
name: '高斯模糊',
data: [
0, 1, 0,
1, 1, 1,
0, 1, 0
],
},
edgeDetect2: {
name: '反相',
data: [
-1, -1, -1,
-1, 8, -1,
-1, -1, -1
],
},
emboss: {
name: '浮雕效果',
data: [
-2, -1, 0,
-1, 1, 1,
0, 1, 2
],
},
};

// 向量乘积的滤镜
const composeFilter = {
light: {
name: '曝光',
data: new Float32Array([1.2, 1.2, 1.2, 1])
},
langmanmeigui: {
name: '浪漫玫瑰',
data: new Float32Array([1.1, 1, 1, 1])
},
// ....
}

将上面的参数传入对上面的着色器,然后通过卷积赋值于gl_FragColor,这样简单的修图工具就大功告成了。


texcoord 纹理


const texcoordLocation = gl.getAttribLocation(program, "a_texCoord");
// ...
gl.vertexAttribPointer(texcoordLocation, size2, type2, normalize2, stride2, offsetVal2);

这里用缓冲区处理,本质图片也就是4个点的矩形,因为每个点其实对应像素和位置, 下面是创建纹理的标准代码,将image传入到textImage2D,然后将缓冲区绑定到这样我们就可以绘制纹理了


 // webgl创建纹理,并设置基本纹理参数,载入image图片
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);

//定义纹理处理能力
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);


帧缓冲


如何给图片施加多种状态的叠加效果,也就是图片 -> 纹理一 -> 纹理一 + 纹理二 -> 画布,那么我们需要用到帧缓冲,其实就是通过不断的bindTexture来覆盖之前的状态。


// 绘制帧缓冲
function drawFrames () {
const originTexture = createAndSetupTexture(gl)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
let textures = []
let frameBuffers = []
const kernelsFilterList = ['gaussianBlur', 'emboss', 'boxBlur',
'gaussianBlur', 'boxBlur', 'gaussianBlur', 'boxBlur', 'gaussianBlur'] //叠加效果的数组

for (let i = 0; i < kernelsFilterList.length; i++) {
let texture = createAndSetupTexture(gl)
textures.push(texture)

gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, null);

var fBuffer = gl.createFramebuffer()
frameBuffers.push(fBuffer)
gl.bindFramebuffer(gl.FRAMEBUFFER, fBuffer);
// 绑定纹理到帧缓冲
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
}
gl.bindTexture(gl.TEXTURE_2D, originTexture);

for (var i = 0; i < kernelsFilterList.length; i++) {
setFramebuffer(frameBuffers[i], image.width, image.height);
drawWithKernel(kernelsFilterList[i]);
// 叠加
gl.bindTexture(gl.TEXTURE_2D, textures[i]);
}

// 绘制
setFramebuffer(null, canvas.width, canvas.height);
drawWithKernel("normal");

function setFramebuffer (fbo, width, height) {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); // 绑定帧缓存
gl.uniform2f(resolutionLocation, width, height); // 设置到裁剪坐标
gl.viewport(0, 0, width, height); // 将裁剪坐标自适应到屏幕坐标
}
}


总结


通过基本的语法、纹理使用、帧缓存等,我们对webgl的基本的2d图形处理有了一定的认知,正常在绘制三角形,四边形,圆形,我们都可以使用缓存区,最后drawArrays绘制,在一些图形的渲染需要保存之前的状态的时候,我们可以使用帧缓存处理。关于当前页面的优化,当前的修图页面应该将各种调色分到不同的glsl文件,同样我们也可以做裁剪,上传图片编辑并下载。



如果觉得文章对你有帮助,不要忘了一键三连 👍



附录



  1. 内卷年代,是该学学WebGL了 - 掘金 (juejin.cn)

  2. 为什么我的WebGL开发这么丝滑 🌊 - 掘金 (juejin.cn)


作者:谦宇
来源:juejin.cn/post/7215977393696522299
收起阅读 »

硬盘坏了,一气之下用 js 写了个恢复程序

web
硬盘坏了,一气之下写了个恢复程序 师傅拯救无望 硬盘已经寄过去超过一周了,一问竟然是还没开始弄??? 再过一周,上来就问我分几个区?我要恢复哪些数据?我要恢复的数据在哪个位置? 那好吧,既然给了钱师傅也都放弃了,我也没什么好寄托希望的了。况且经过这三个...
继续阅读 »

硬盘坏了,一气之下写了个恢复程序


师傅拯救无望


硬盘已经寄过去超过一周了,一问竟然是还没开始弄???


2023-03-24-14-15-16.png


再过一周,上来就问我分几个区?我要恢复哪些数据?我要恢复的数据在哪个位置?


2023-03-24-14-18-50.png


2023-03-24-14-19-30.png


2023-03-24-14-20-05.png


那好吧,既然给了钱师傅也都放弃了,我也没什么好寄托希望的了。况且经过这三个星期的缓解,心情已经平复了很多,就像时光,回不来了就是回不来了。


自救之路


在把硬盘寄过去的时间里,等待师傅的修复结果的时间里,我并没有闲着(在摸鱼)。


经过调研,数据恢复方法通常有:



  • 硬件损坏,对坏的盘进行修复

  • 误删或逻辑错误等,文件扫描修复

  • git 重置恢复


很明显,这些都不适用于我现在的场景。因为师傅能不能修好是未知的,我只是数据盘没了,系统盘还在。由于 vscode 的数据目录空间占比较小,就没有搬迁到数据盘里,这刚好可以为恢复代码提供了可能。


这是因为新版 vscode 有一个时间线功能,这个时间线数据是默认存储在用户目录下的。


我从 C:/Users/love/AppData/Roaming/Code/User/History 目录中确实找到了很多名为 entries.json 的文件,结构如下:


{
// 配置版本
"version": 1,
// 原来文件所在位置
"resource": "file:///d%3A/git2/cloudcmd/.madrun.mjs",
// 文件历史
"entries": [
{
// 历史文件存储的名称
"id": "YFRn.mjs",
"source": "工作区编辑",
// 修改的时间
"timestamp": 1656583915880
},
{
"id": "Vfen.mjs",
"timestamp": 1656585664751
},
]
}

通过上面的文件大概可以看到,每一个时间点的文件都保存在另一个随机命名的文件里。而网上的方法基本都是自己一个个手动到目录里去根据最新的 id 去找对应的文件内容,然后创建文件并把内容复制出来。


这个过程恢复一两个文件还好,但我这可是要恢复整个 git 工作区,大概有几十个项目上千个文件。


这时候当然是在网上找找有没有什么 vscode 数据恢复 相关的工具,很遗憾找了大半天都没有找到。


气死我了,一气之下就自己写个!


恢复程序开发步骤


毕竟只要数据在磁盘上,无非就是一个文件读取操作的问题,还要拿在这水文章,见谅见谅。


首先考虑需求:



  • 我要实现一个自动扫描 vscode 数据目录

  • 然后以原始的目录结构还原出来,不需要我自己去创建文件夹和文件

  • 如果还原的文件最新的那份不是我想要的,我还能根据时间线进行对比和选择

  • 扫描出来有N个项目时,我可以指定只还原某此项目

  • 我可以搜索文件、目录名或文件内容进行还原

  • 为了方便,我还要一个看起来不太丑的操作界面


大概就上面这些吧。


然后考虑实现:


我要实现一个自动扫描 vscode 数据目录


要的就是我自己连数据目录和恢复地址也不需要填写,就能自动恢复的那种。那么就让程序来自动查找数据目录。经过调研,各版本的 vscode 的数据目录一般保存在这些地方:


参考: stackoverflow.com/a/72610691


  - win -- C:\Users\Mark\AppData\Roaming\Code\User\History
- win -- C:\Users\Mark\AppData\Roaming\Code - Insiders\User\History
- /home/USER/.config/VSCodium/User/History/
- C:\Users\USER\AppData\Roaming\VSCodium\User\History

大概有上面这些路径,当然不排除使用者故意把默认位置修改掉这种边缘情况,或者使用者就只想扫描某个数据目录的情况,所以我也要支持手动输入目录:


  let { historyPath, toDir } = req.body
const homeDir = os.userInfo().homedir
const pathList = [
historyPath,
`${homeDir}/AppData/Roaming/Code/User/History/`,
`${homeDir}/AppData/Roaming/Code - Insiders/User/History/`,
`${homeDir}/AppData/Roaming/VSCodium/User/History`,
`${homeDir}/.config/VSCodium/User/History/`,
]
historyPath = (() => {
return pathList.find((path) => path && fs.existsSync(path))
})()
toDir = toDir || normalize(`${process.cwd()}/re-store/`)

然后以原始的目录结构还原出来……


这就需要解析扫描到的时间线文件 entries.json 了。我们先把解析结果放到一个 list 中,以下是一个完整的解析方法。


然后再把列表转换为树型,与硬盘上的状态对应起来,这样便于调试数据和可视化。


function scan({ historyPath, toDir } = {}) {
const gitRoot = `${historyPath}/**/entries.json`

fs.existsSync(toDir) === false && fs.mkdirSync(toDir, { recursive: true })
const globbyList = globby.sync([gitRoot], {})

let fileList = globbyList.map((file) => {
const data = require(file)
const dir = path.parse(file).dir
// entries.json 地址
data.from = file
data.fromDir = dir
// 原文件地址
data.resource = decodeURIComponent(data.resource).replace(
/.*?\/\/\/(.*$)/,
`$1`
)
// 原文件存储目录
data.resourceDir = path.parse(data.resource).dir
// 恢复后的完整地址
data.rresource = `${toDir}/${data.resource.replace(/:\//g, `/`)}`
// 恢复后的目录
data.rresourceDir = `${toDir}/${path
.parse(data.resource)
.dir.replace(/:\//g, `/`)}
`

const newItem = [...data.entries].pop()
// 创建文件所在目录
fs.mkdirSync(data.rresourceDir, { recursive: true })
const binary = fs.readFileSync(`${dir}/${newItem.id}`, {
encoding: `binary`,
})
fs.writeFileSync(data.rresource, binary, { encoding: `binary` })
return data
})

const tree = pathToTree(fileList, { key: `resource` })
return tree
}

为了方便,我还要一个看起来不太丑的操作界面


我们要把文件树的形式展示出来,还要方便切换。后面决定使用 macos 的文件管理器风格,大概如下。


image.png


如果还原的文件最新的那份不是我想要的,我还能根据时间线进行对比和选择


理论上这里应该要做一个像 vscode 对比文件那样,有代码高亮功能,并且把有差异的字符高亮出来。


实际上,这个需求得加钱。


2023-03-24-15-09-25.png


由于界面是在浏览器里的,需要自动打开,浏览器与系统交互需要一个接口,所以我们使用 opener 来自动打开浏览器。


使用 get-port 来自动生成接口服务的端口,避免使用时出现占用。


  const opener = require(`opener`)
const { portNumbers, default: getPort } = await import(`get-port`)
const port = await getPort({ port: portNumbers(3000, 3100) })
const server = express()
server.listen(port, `0.0.0.0`, () => {
const link = `http://127.0.0.1:${port}`
opener(link)
})

封装成工具,我为人人


理论上我根本不需要什么 UI 界面,也不需要配置,因为我的文件都恢复出来了我还花时间去搞毛线?


实际上,万一别人也有这个恢复文件的需要呢?那么他只要运行下面这条命令代码就能立刻恢复到当前目录啦!


npx vscode-file-recovery

这就是恢复后的文件在硬盘里的样子啦:


2023-03-24-15-22-23.png


所有代码位于:



建议收藏,以备不时之需。/手动狗头


作者:程序媛李李李李李蕾
来源:juejin.cn/post/7213994684262826040
收起阅读 »

面试官:判断图是否有环

web
面试官让我写一个判断图是否有环,我没写出来,心想又是“面试造火箭,入职拧螺丝”。我把面试官pass了。没想到开发中真的遇到了判断有向图是否有环。 图是一种常见的数据结构,分为有向图和无向图。图是由边和节点组成的。 在前端开发中,接触到图的场景不算多。常见的有流...
继续阅读 »

  • 面试官让我写一个判断图是否有环,我没写出来,心想又是“面试造火箭,入职拧螺丝”。我把面试官pass了。没想到开发中真的遇到了判断有向图是否有环。

  • 图是一种常见的数据结构,分为有向图和无向图。图是由边和节点组成的。

  • 在前端开发中,接触到图的场景不算多。常见的有流程、图形可视化等场景。

  • 我们在配置题目流程时遇到了需要判断图是否有环的需求。


背景



  • 简单介绍需求,通过可视化流程配置答题流程,题目与题目之间用线连接,箭头的方向代表下一个题目。回答完当前题目,根据不同的条件,跳到下一题;如果题目流程中有循环,会导致答题流程无法结束,所以需要校验题目的流程中不能有循环。

  • 下面的是有循环,不符合条件


image.png



  • 下面的是无循环,符合条件


image.png
image.png
image.png
image.png


技术方案



  • 根据需求,我们把题目的流程配置抽象成有向图,题目是节点,题目之间的连线是边。

  • 需求里的有无循环,最终可以转换成图是否有环的问题。从图的某个节点作为起点,根据边的方向出发跳到下一个节点,最终是否回到起点。如果回到起点,就是有循环、有环,否则是无循环、无环。

  • 去除题目和各种条件等无关的结构,数据结构如下。


//边
export interface Edge {
id: string;
source: {
cell: string; //这条边的起点的id
[x: string]: any;
};
target: {
cell: string; //这条边的终点的id
[x: string]: any;
};
data: {
type: 'EDGE',
[x: string]: any;
}
[x: string]: any;
};
//节点
export interface Node {
id: string;
data: {
type: 'NODE';
name: string;
[x: string]: any;
};
[x: string]: any;
};
export type Data = Node | Edge;


  • 测试数据如下


const data: Data[] = [
{
id: '1',
data: {
type: 'NODE',
name: '节点1'
}
},
{
id: '2',
data: {
type: 'NODE',
name: '节点2'
}
},
{
id: '3',
data: {
type: 'NODE',
name: '节点3'
}
},
{
id: '4',
source: {
cell: '1'
},
target: {
cell: '2'
},
data: {
type: 'EDGE'
}
},
{
id: '5',
source: {
cell: '1'
},
target: {
cell: '3'
},
data: {
type: 'EDGE'
}
}
];


  • 根据数据结构和测试数据data:Data[],分为以下几个步骤:

    1. 获得边的集合和节点的集合。

    2. 根据边的集合和节点的集合,获得每个节点的有向邻居节点的集合。即以每个节点的为起点,通过边连接的下一个节点的集合。例如测试数据节点1,通过边id4和边id5,可以连接节点2节点3,所以节点1的邻居节点是节点2节点3,而节点2节点3无有向邻居节点。

    3. 最后根据有向邻居节点的集合,判断是否有环。




具体实现



  • 获得边的集合和节点的集合


const edges: Map<string, Edge> = new Map(), nodes: Map<string, Node> = new Map();
const idMapTargetNodes: Map<string, Node[]> = new Map();
const initGraph = () => {
for (const item of data) {
const { id } = item;
if (item.data.type === 'EDGE') {
edges.set(id, item as Edge);
} else {
nodes.set(id, item as Node);
}
}
};


  • 获取有向邻居节点的集合,这里的集合,可以优化成id。我为了方便处理,存储了节点


const idMapTargetNodes: Map<string, Node[]> = new Map();
const initTargetNodes = () => {
for (const [id, edge] of edges) {
const { source, target } = edge;
const sourceId = source.cell, targetId = target.cell;
if (nodes.has(sourceId) && nodes.has(targetId)) { //防止有空的边,即边的起点和终点不在节点的集合里
const targetNodes = idMapTargetNodes.get(sourceId);
if (Array.isArray(targetNodes)) {
targetNodes.push(nodes.get(targetId) as Node);
} else {
idMapTargetNodes.set(sourceId, [nodes.get(targetId) as Node]);
}
}
}
};


  • 最后判断是否有环,有两种方式:递归和循环。都是深度优先遍历。execute是遍历所有节点,hasCycle是把图的某个节点做为起点,判断是否有环。如果以所有节点为起点,都没有环,说明这个图没有环。

    1. 递归。hasCycle判断当前节点是否有环;checked是做优化,防止某些节点多次检查,回溯阶段,把当前节点加入checkedvisited记录当前执行的hasCycle里是否访问过,如果访问过,就是有环。需要注意的是,每次执行hasCycle时,visited用的是一个变量,所以在回溯阶段需要把当前节点从visited里删除。


    const checked: Set<string> = new Set();
    const hasCycle = (node: Node, visited: Set<Node>) => {
    if (checked.has(node.id)) return false;
    if (visited.has(node)) return true;
    visited.add(node);
    const { id } = node;
    const targetNodes = idMapTargetNodes.get(id);
    if (Array.isArray(targetNodes)) {
    for (const item of targetNodes) {
    if (hasCycle(item, visited)) return true;
    }
    }
    checked.add(node.id);
    visited.delete(node);
    return false;
    };
    const execute = () => {
    const visited: Set<Node> = new Set();
    for (const [id, node] of nodes) {
    if (hasCycle(node, visited)) return true;
    checked.add(id);
    }
    return false;
    };


    1. 循环。checked和递归时,作用一样,这里不做说明。visited是用来判断当前的节点是否遍历过,如果遍历过,就是有环。用循环实现深度优先遍历时,需要用来存储当前链路上的节点,即当前节点已经后代节点。并且从里面获取最后一个节点,作为当前遍历的节点。如果当前节点有向邻居节点不为空,就把有向邻居节点的最后一个节点拿出来压栈;如果有向邻居节点为空,就把当前的节点出栈。在压栈时,如果当前节点在visited里,就说明有环,如果没有就要把这个节点加入到visited。在出栈时,把当前节点从visited里删除掉,因为如果不删掉,当一个节点的多个邻居节点最终指向同一个节点时,会判断为有环。


    const checked: Set<string> = new Set();
    const hasCycle = (node: Node) => {
    const { id } = node;
    if (checked.has(id)) return false;
    const stack = [id];
    const visited: Set<string> = new Set();
    visited.add(id);
    while (stack.length > 0) {
    const lastId = stack[stack.length - 1];
    const targetNodes = idMapTargetNodes.get(lastId) || [];
    if (targetNodes.length > 0) {
    const { id } = targetNodes.pop() as Node;
    if (visited.has(id)) return true;
    stack.push(id);
    visited.add(id);
    } else {
    stack.pop();
    visited.delete(lastId);
    }
    }
    return false;
    };
    const execute = () => {
    for (const [id, node] of nodes) {
    if (hasCycle(node)) return true;
    checked.add(id);
    }
    return false;
    };



总结



  • 要掌握常见的数据结构与算法,本例中用到了图、深度优先遍历。


源码



作者:PlayerWho
来源:juejin.cn/post/7213945427853443131
收起阅读 »

给自己编写一个批量填写日报的工具

web
背景 公司要求我们每天填写工时,每天的时间都花在了哪些地方,干了什么。平时没顾得上填,欠下了一屁股工时债。收到邮件催填通知后,发现要补两个月的工时,填了一会儿,感觉变化的只是日期和工作内容,其它的内容项都是固定内容。一天一天填着实费劲,于是决定写一个填写日报的...
继续阅读 »

背景


公司要求我们每天填写工时,每天的时间都花在了哪些地方,干了什么。平时没顾得上填,欠下了一屁股工时债。收到邮件催填通知后,发现要补两个月的工时,填了一会儿,感觉变化的只是日期和工作内容,其它的内容项都是固定内容。一天一天填着实费劲,于是决定写一个填写日报的小工具,只要在js文件中,补充一下每天的工作内容,然后执行node命令,批量完成工时的填写。


思路



  1. 先根据设置的起始结束时间,查询一下当月有多少个工作日,要补多少天的工时。要排除当月每周周末,法定节假日的日期,加上调休补班的日期。

  2. 根据计算出来的需要补充工时的天数,编辑好要补填的工作内容条数,然后批量发送网络请求,完成工时的填写。


工作日查询实现


发现了一个叫蛙蛙工具的网站,免费提供接口给第三方使用,可以用来查询工作日。每分钟限制查10次。下图是抓取的响应数据:重点说一下要用到的weekend_date_listholiday_date_list字段;



  • weekend_date_list 周末日期

  • holiday_date_list 法定节假日


只要排除这两个数组中的日期,剩下的就是查询日期时间段工作日。


image.png


思路有了,来看看实现。首先要写好发送查询请求的逻辑,要知道请求地址,请求参数,请求参数格式,响应数据内容。其次,拿到响应结果后,生成一个从开始日期到结束日期,格式为YYYY-MM-DD的数组,从这个数组中剔除周末和法定节假日,剩余的日期就是工作日,知道工作日的天数后,就知道要写几天的工作日报。代码如下:


import axios from "axios";
import dayjs from "dayjs";
import { startDate,endDate } from "./config.js";


// 查询从本月的工作日
export const queryWorkingDay = () => {
return new Promise((resolve, reject) => {
const url = "https://www.iamwawa.cn/home/workingday/ajax";
const params = {
start_date: startDate.format("YYYY-MM-DD"),
end_date: endDate.format("YYYY-MM-DD"),
};

axios
.post(url, params, { headers: { "Content-Type": "application/x-www-form-urlencoded" } })
.then(({ data: res }) => {
const { status, data, info } = res;
const {
// 平常的周末
weekend_date_list = [],
// 法定节假日
holiday_date_list = [],
// 工作日天数
working_date_count,
} = data;

// console.log(data);

// 生成设置的当月起始结束日期数组
const dayOfMonth = genNumArr(startDate.date(), endDate.date()).map((day) =>
dayjs().date(day).format("YYYY-MM-DD")
);

// 需要排除的法定节假日和周末日期
const excludeDays = [
weekend_date_list.map((item) => item.date),
holiday_date_list.map((item) => item.date),
].flat();

// 工作日
const workDays = dayOfMonth.filter((day) => !excludeDays.includes(day));

// console.log(status,data,info);
console.log(`本月你需要补充${working_date_count}天日报`);
console.log(`需要填写的日期:`);
workDays.forEach((day) => {
console.log(day);
});

console.log(`需要排除的日期:`);
excludeDays.forEach((day) => {
console.log(day);
});

resolve(workDays);
});
});
};

// 生成连续数字数组
function genNumArr(start, end) {
return Array.from(new Array(end + 1).keys()).slice(start);
}


提交工时实现


先登录填报工时网站, 手动填写一条,在调试模式下查看一下请求地址和请求参数。
1679728451342.png
请求地址我就不贴出来了,这里只提供思路,请求数据为:


{
"workDate": "2023-03-14",
"tapdId": null,
"groupId": 12,
"projectId": 159,
"lineId": 2,
"taskId": 16,
"workContent": "xxxxxx",
"workHours": 8
}

如法炮制查询工作日的方法,发起工时提交请求,结果吃了闭门羹。提示没有权限。


image.png


后面经过排查,发现网络请求的请求头,需要带一个authorization的参数,服务器根据这个参数判断有没有提交权限。这个参数你必须登录原网站才能拿到,把这个参数复制出来,配置到代码中,再发请求,这次很顺利的提交了。


image.png


提交数据跑通之后,要实现批量提交数据就很Easy了,循环调用提交单条数据的接口就可以了。有个细节需要注意一下,提交请求太快,服务器会返回错误,所以每个请求之间加了一个500ms的延时。提交工时的代码如下:


import axios from "axios";
import { queryWorkingDay } from "./queryDay.js";
import { authorization, workContentList } from "./config.js";

// 提交每月的工时
export const submitMonthWorkHour = async () => {
const workDays = await queryWorkingDay();

for (let index = 0, len = workContentList.length; index < len; index++) {
await submitEachDayData(workDays[index], workContentList[index]);
}
};

/**
* 提交每天的工时数据
* @param {*} workDate 工作日期
* @param {*} workContent 工作内容
*/

const submitEachDayData = (workDate, workContent) => {
return new Promise((resolve, reject) => {
const url = "https://xxx/xxx",
authorization,
"Content-Type": "application/json",
};

const params = {
workDate,
workContent,
tapdId: null,
groupId: 12,
projectId: 159,
lineId: 2,
taskId: 16,
workHours: 8,
};

setTimeout(() => {
axios
.post(url, params, { headers })
.then(({ data }) => {
const { ret, retdata, retmsg } = data;
// if (ret === 0) {
console.log(`${workDate}--${retmsg}`);
resolve("ok");
// }
})
.catch((err) => reject(err));
}, 500);
});
};

主流程实现


在package.json中配置两条指令,一条用于查询设置的起始结束时间有多少个工作日,需要补充多少天的工作日报,接着在上面的submitMonthWorkHour方法中,手动编辑,给工作内容列表workContentList填充数据,一条数据对应一天的工作日报。填写完之后,执行提交命令。


{
"license":"MIT",
"scripts": {
"query": "node main.js query",
"submit": "node main.js submit"
},
"dependencies": {
"axios": "^1.3.4",
"dayjs": "^1.11.7"
},
"type": "module",
"devDependencies": {}
}


顺便说一下,node v9+版本,若要使用import/export语法, 需要在package.json中指定 "type": "module"


在主函数中, 根据不同的指令执行不同的操作。实际使用时, 肯定是要先调用yarn query查询补充多少天日报才行。


import { queryWorkingDay } from "./queryDay.js";
import { submitMonthWorkHour } from "./submitData.js";

main();
// 主流程
function main() {
const argv = process.argv;
// 先查询需要补充多少天日报
if (argv.includes("query")) {
queryWorkingDay();
} else if (argv.includes("submit")) {
submitMonthWorkHour();
} else {
console.log('指令错误');
process.exit(1);
}
}


把配置数据放到config.js中, 这里要说一下dayjs().date()dayjs().daysInMonth(), 它们的执行结果都是一个数字,代表的含义是这个月的日期,默认开始时间是当天日期,结束时间是月底日期。可手动修改。


import dayjs from "dayjs";
// 设置查询工作日的开始时间
export const startDate = dayjs().set("date", dayjs().date());
export const endDate = dayjs().set("date", dayjs().daysInMonth());
// 每次先登录一下填报工时的网站,把http请求头中的authorization复制出来
export const authorization = "";
// 手动填写需要补充的工时
export const workContentList = [""];

结语


至此,批量提交日报的小工具就开发完了。爱因斯坦说, 比知识更重要的是想象力。文中列举的知识点大家可能都懂,但是要把这些知识串接起来,开发一个有实用价值的工具,是需要一点灵动和想象力的。而灵动来源于优化意识,需要一个善于发现问题的心灵,洞悉生活中,工作中的痛点,寻找改进之法。 这个小工具已上传至码云,感兴趣的朋友可点击这里下载


作者:去伪存真
来源:juejin.cn/post/7214349925064802362
收起阅读 »

去哪儿低代码平台跨端渲染方案及落地

web
作者介绍 何欣宇,2021年入职去哪儿旅行,目前担任门票前端开发负责人,擅长iOS、Android以及RN技术,主导了Qunar低代码平台跨端渲染方案的设计开发以及落地工作。 一、低代码平台跨端渲染现状 去哪儿网目前的低代码平台已经搭建了上万个活动页面,包含小...
继续阅读 »

作者介绍


何欣宇,2021年入职去哪儿旅行,目前担任门票前端开发负责人,擅长iOS、Android以及RN技术,主导了Qunar低代码平台跨端渲染方案的设计开发以及落地工作。


一、低代码平台跨端渲染现状


去哪儿网目前的低代码平台已经搭建了上万个活动页面,包含小程序、touch和 APP 多个平台。去哪儿低代码平台是基于 Shark 框架开发。Shark 是一款有着跨平台(一套代码支持跨端渲染)、按需加载(仅加载页面配置所需代码文件)等特性的类 React 框架。有着缓存、消息中心等多种能力。Shark 和低代码平台的无缝结合,给现在低代码平台带来了跨端、“所见即所得”得等多种特性。而“所见即所得”,就是一种动态加载的功能:我们在低代码平台上配置一个页面所需组件和对应的各种属性,可以及时的在各个端上看到。


随着低代码平台的推广应用,接入了越来越多的业务的核心流程,对于加载性能上的要求越来越高。在当前阶段,低代码平台在 APP 端是利用 H5 的方式来渲染页面。但是这种方式首先需要加载 WebView ,然后才会去绘制页面,导致白屏时间比较久。


去年遇到了一个契机,门票业务在对主流程进行了大改版,当时人力相对比较紧张,而且业务侧希望页面的组件是可配置的,对于这个挑战,结合低代码平台进行了思考,代码平台天然是可视化配置的,也支持多端运行,美中不足的是在端内是以 H5 方式运行的,如果在端内支持 RN 运行,补齐性能的短板,整体来讲将会是一个很好的方案。


二、APP 端替代 HY 方案调研以及可行性分析


说到既满足灵活发版,又能跨平台,还有较高的性能来解决前面的白屏时间久和性能差的问题,要同时满足这三个特点的技术。当前状况去哪儿 APP 是以React Native 为主的;于是我们提出了一个想法:Shark 和 React Native 能否结合一下呢?结合两家之长处,即实现灵活可配,又能保持高性能和跨平台,将扩大我们低代码平台的边界,提供更多可能性,于是我们开始了 Shark 和 RN 的结合探索之旅;


首先,我们开始分析 Shark 组件和 React Native 组件之间的区别;一个 Shark 组件主要是由 JS 文件以及 Scss 文件两个文件组成。那么作为一个类React框架,它和 RN 的代码有多大的区别那?通过下图对比我们可以看到差异点(左图是 Shark 组件代码,右图是 RN 组件代码):


图片


通过上面的对比,我们可以看到 Shark 和普通 RN 代码的区别在



  • 布局名称、方式和 RN 区别较大

  • 语法树标签主要是 View,可以看到交互和文字展示都是 View ,但在 RN中是不同标签

  • 标签属性的不同,在 RN 和 Shark 中点击事件不同等

  • ......


对比完 JS 文件,那布局文件的差异又有多大那?依旧可以通过下图的对比看到差异点


图片


上面是布局Scss文件部分,可以看到区别主要集中在



  • 布局名的嵌套

  • 单位的不同

  • 属性名和RN不同


通过上面的分析观察,我们可以看到 Shark 的代码和 RN 的代码虽然具有一定的区别但是相似度还是很高的。那如果我们先手动将这些差异点修改,能否将这份代码在 APP 上运行起来?下面我们先完成第一步:修改差异点。


图片


我们将手动修改后的代码嵌入在 RN 业务组件中,通过实验得知,这段代码是可以通过编译并正常运行的。


通过这些分析和实验得知,通过修改是可以将 Shark 的代码在 RN 上运行的。在上面的实验中,我们是通过手动修改的方式来达到目的,但是在实际项目中这样做肯定是不切实际的,我们可以通过 Babel 来编写自己的转化器,来达到批量转换的目的。


三、APP端实践


方案简述


通过上面的分析可知,Shark 的核心代码是可以通过 Babel 转换为 RN 的代码并在 APP 上直接运行的。在整个过程中是“代码转化”和“运行时能力提供”两个部分。通过下面的图,我们可以看到整体流程


图片


编译


整个编译时期我们的任务就是将 Shark 的源码转化为 RN 可以直接使用的代码。我们利用 Babel 编写了自己的工具:shark-cli,通过这个工具我们实现代码的适时转换。


- JS文件


JS 文件主要处理包括语法树(标签的替换、布局抹平、标签属性替换等)和JS( document 等的处理)两个部分。针对其中几个主要的问题展开讨论


标签以及属性的转换:


Shark 中绝大部分标签都是 View ,但是在 RN 中不同,RN 中不同的标签会承担不同的功能。比如在 Shark 里 View 还可以接收点击事件,但是在 RN 中只能是 TouchableOpacity 等少数组件。针对这一情况我们根据一些属性,当发现是一些特殊组合的时候就会在代码中替换组件。通过下面的映射表,我们将不同标签和属性的组合映射到 RN 中对应的标签和属性。


shark组件RN组件
ViewView、TouchableOpacity(当有onClick时转换为此组件)
InputTextInput
TextText
ImageImageImage + TouchableOpacity (当有onClick时转换)
ScrollViewScrollView

import的处理:


和 Shark 不同,RN 需要将使用到的标签、组件显示的引入并指明它在哪个库当中,比如我们经常遇到的下面的代码。


图片


为此,我们在代码转换时,准备了一个映射表,里面针对 react-native 的组件,可以直接 import 。但是这样并不能很好的支持,因为三方库和标签并不一样并不能枚举,为了解决这个问题我们提供了另一个能力,支持在标签上新增了两个属性,指定标签名称和来源来达到这一目的


图片


嵌套布局的抹平:


整个嵌套算是 Shark 和 RN 上分歧最大的地方,布局上要将Shark多种写法统一成 RN 的写法,其次就是要将 Shark 嵌套的布局在 RN 上抹平。过程如下图所示。


图片


对于不同的 class 或者 style 写法,在 babel 中都是不同的节点要单独处理,对于不同的节点我们应用不同的规则。我们收集到统一的格式之后,就可以运用一个规则去处理抹平。在 scss 文件处理的过程中,嵌套文件拿到的最后的属性名都是多层拼接完成的,比如 styles. 层级1_层级2_层级3,但是在 js 文件中处理完的都是 styles. 属性名,这就引出了嵌套布局抹平的问题。我们维护了一个当前布局层级的栈,我们在每一层 View 进入的时候入栈,记录一次布局名称,每一层 View 结束的时候作为出栈。在前面处理 scss 文件时,我们拿到了所有布局的嵌套关系,根据这个栈和我们拥有的嵌套关系去遍历,去匹配是否有布局嵌套,如果有就替换如果没有则进行下一次匹配。通过这种方式我们来解决嵌套布局的问题。


- Scss文件


图片


scss 文件和 RN 使用的布局属性,其实差异不大。我们最重要的是处理类型名的嵌套,整个的转换我们分为两步,每一步去处理不同的问题。


第一步:将 scss 文件转换为 css 文件。在转换的同时,我们将单位 rem 删除、嵌套的类名抹平,这时得到了我们想要的中间文件 .css 文件。


第二部:将 css 文件转换为 RN 的布局文件即 style.js 。在这一过程中,我们要记录所有嵌套布局的路径给将来index.js去处理嵌套布局。同时为了解决属性上的问题,我们通过配置文件将不支持的属性删除,并替换不同属性值的问题。通过这个方式我们获取到 RN 可使用的一个 object 对象并保存为一个 style.js 的文件。


整体 scss 文件的转换思路如下图所示


图片


Babel详解


编写自己的Babel插件


AST


整个工作流程可以描述为 AST → visitor 修改 AST→ 获取目标代码。在这其中,理解清楚 AST 十分重要,我们之所以需要将代码转换为 AST 也是为了让计算机能够更好地进行理解。我们可以来看看下面这段代码被解析成 AST 后对应的结构图:


以这一行代码为例子,它的语法树如下


图片


所有的 AST 根节点都是 Program 节点,从上图中我们可以看到解析生成的 AST 的结构的各个 Node 节点都很细微,github.com/babel/babyl… (不过这个文档并没有说明具体输出的样式,有时同一个节点,输入不同参数输出的代码可以差距非常大,尤其是在格式化时这点就非常重要)这个文档对每个节点类型都做了详细的说明,你可以对照各个节点类型在这查找到所需要的信息。通过astexplorer.net/ 可以有效的观察代码对应的节点,以及节点的各种属性关系。熟悉了 AST 之后,就可以通过 Visitor 来遍历节点,更改我们想要的代码。


Visitor


image.png


在 visitor 中引入了 path 的概念,它中包含了节点的信息以及节点和所在的位置,以供对特定节点进行操作。不仅包含了当前节点的信息,也有当前节点的父节点的信息,同时也包含了添加、更新、移动和删除节点有关的其他很多方法。具体地,Path 对象包含的属性和方法主要如下:


image.png


整个 visitor 的过程,可以简述为通过修改 path 来改变 AST 语法树的过程。


image.png


如上所示,我们在修改的过程中针对 path 对替换或者修改,生成新的节点,就可以达到我们的目标。以我们这次的代码为例子,当发现 onClick 属性时,我们要将 View 标签替换为 TouchableOpacity 标签,onClick 替换为 onPress 。


image.png


通过语法树分析,onClick 在语法树中的层级是 JSXOpeningElement → attributes → JSXAttribute → JSXIdentifier → name。这时才找到了 name = "onClick"。


image.png


此时我们在 visitor 中找到 JSXIdentifier 并通过 path 找到 name 


image.png


我们找到了对应的节点后,问题就是要替换成什么。通过上一步分析语法树的方式我们可以知道,onClick 转换为 onPress ,语法树上只是对应的 name 有变化。此时我们需要生成一个新的 JSXIdentifier 类型的节点。对应到我们的插件里如下图所示:


image.png


这样,我们就达到了转换的目的。通过总结不同的节点的替换和修改,就能达到修改语法树,转换代码的目的。


运行时


简述


拿到了通过编译态转换好的 RN 代码后,就是运行时要做的工作了。运行时的主要工作可以分为和低代码平台通讯获取需要渲染哪些组件、获取这些组件的实例进行组装,最后进行渲染。整体工作流程如下图所示。


image.png


可以把整个容器可以理解为一个 RN 业务组件。这个容器包含了缓存、动态加载、页面的绘制、提供 Shark 能力等几个部分。


提供Shark能力


整个 Shark 的能力包括几个部分:ability、core 以及 Qunar 特性,三个部分。其中 ability 是 shark 和 core 是 Shark 的核心能力。他们包括了如下能力:



  • message

  • request

  • jump

  • logger

  • ……


在运行时,我们要提供这些能力,让转换后的代码能够在 RN 上无缝运行。


缓存


如果每次进去页面都需要实时获取页面配置信息,这样很影响用户体验。针对这个问题,我们设计了一套缓存策略,让用户可以无感知的更新配置。我们缓存了不同页面的配置信息,在用户进入页面时可以通过直接读取缓存,省下了等待接口的时间。在用户进入页面的同时,去获取最新的配置信息来更新缓存内容。缓存包括内置配置文件和 cache 的二级缓存系统,当进入页面时会实时更新 cache ,如果有 cache 则优先读 cache ,否则就读取内置配置文件。整体流程如下


image.png


动态加载


有了上面两点能力,我们就可以实现在文章开头提到的动态加载的能力。由于 RN 只能渲染已经加载完的代码,为了达到这个目标,在容器端获取页面的配置信息,通过这个配置文件,我们能够获取到页面的组件信息,包括需要哪些组件、每个组件的属性,由于 RN 框架限制做不到去动态的加载一个新的组件,只能加载已经打包的组件,否则还是要去更新版本。通过一个专门的组装器,注册组件、解析组件属性并赋值。通过这个组装器,我们来选择对应的页面的对应的组件,来动态加载组件或更新组件信息。我们又借助上面提到的缓存的能力,来减少了用户的感知时间。


image.png


在获取到了页面配置信息之后,我们将配置信息交给容器。容器可以通过组件名称来找到需要渲染的组件。容器可以通过这个配置文件实现组件的选择、props的传递,来达到低代码平台组件所见即所得的功能。


成果展示


编译态代码转换结果。左图是低代码平台组件的源码,右图是经过编译态转换后的结果。


image.png


最后实际效果:
下方图 1 可以看到我们在低代码平台上配置的信息,图 2 是对应 APP 内页面的截图。可以看到左侧在平台上配置各个组件,最后可以做到在平台上配置组件属性和组件修改,在 APP 内部可以即使生效以及组件的灵活上下线。


image.png


(图1)


image.png


(图2)


针对我们开始提到的性能问题,根据TTI监控指标,我们可以看到,P90 和 P50 的平均时间都在 2 秒之内的,这样看整体的方案是达到了我们最初的目标。


image.png


四、未来规划


经过在实际业务线两个页面的尝试,整个低代码平台的跨平台能力已经经受了实际项目的验证。但是在开发人员实际使用的过程中还是有可以优化的地方,来提升开发人员的转换效率。将来考虑能将一些属性或者能力做成配置开放给开发人员,这样能做到更加个性化。


现在只有一个业务线尝试过这个功能,但是整个低代码平台其实是很大的一个平台且使用业务线众多,后面也要考虑针对业务线的推广,来扩大使用人群。


作者:去哪儿技术沙龙
来源:juejin.cn/post/7213665606628327461
收起阅读 »

svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退

web
在之前的系列文章中,我们介绍了图形编辑器基础的 移动、缩放、旋转 等基础编辑能力,以及吸附、网格、辅助线、锚点、连接线等辅助编辑的能力。这些能力提高了编辑功能的上限,本文将介绍的是提效相关的功能:右键菜单、快捷键、撤销回退。 一、右键菜单 1. 右键菜单底层...
继续阅读 »

在之前的系列文章中,我们介绍了图形编辑器基础的 移动缩放旋转 等基础编辑能力,以及吸附、网格、辅助线、锚点、连接线等辅助编辑的能力。这些能力提高了编辑功能的上限,本文将介绍的是提效相关的功能:右键菜单、快捷键、撤销回退。



一、右键菜单


1. 右键菜单底层方案


关于右键菜单的通用底层解决方案,我之前已经写了一篇文章专门介绍,有兴趣的同学欢迎参考:



功能:



  • 每个菜单项都可以独立设置是否禁用、是否隐藏

  • 支持子菜单

  • 支持显示icon图标、提示语、快捷键等

  • 与业务完全解耦,通过简单配置即可定制出功能各异的菜单


menu



  • 使用通用右键菜单组件演示:


import { ContextMenu, IContextMenuItem } from 'context-menu-common-react';

// 菜单配置数据
const menuList: IContextMenuItem[] = [
{ text: '复制', key: 'copy' },
{ text: '粘贴', key: 'paste', shortcutKeyDesc: `${cmd}+V` },
{
text: '对齐',
key: 'align',
children: [
{ text: '水平垂直居中', key: 'horizontalVerticalAlign' },
{ text: '水平居中', key: 'horizontalAlign' },
],
},
];

export () => {
const containerDomRef = React.useRef();
// 菜单点击触发
const handleMenuTrigger = (menu: IContextMenuItem) => {
console.log(menu); // { text: '复制', key: 'copy' }
// 这里处理触发菜单后的逻辑....

};
return (
<div
ref={containerDomRef}
style={{ position: 'relative' }}>

<ContextMenu
getContainerDom={() =>
containerDomRef.current}
menuList={menuList}
onTrigger={handleMenuTrigger}
/>
</div>

);
};


2. 图形编辑器右键菜单定制


上面的文章介绍了一种通过数据配置生成右键菜单的通用解决方案,它和业务没有任何的耦合,是一个独立功能。


但是仅有上面的功能在面临复杂业务的时候使用体验就不是很好了,例如:



  • 某个特殊的精灵想右键菜单在自己身上触发的时候,显示一个独属于自己的菜单项。

    • 比如富文本精灵提供清除内容富文本格式的功能,把加粗、字体大小等等样式全部清除变为普通无样式文本




这里我们为了提升右键菜单的扩展性易用性,会基于上面的方案做一些抽象和定制,例如:



  1. 菜单配置数据提供注册机制:以便于在不同的模块里维护属于自己模块的菜单项功能;

  2. 每个菜单项都可以独立定义点击触发时的操作:不在一个同一个onTrigger触发器里分发处理每个菜单项的点击逻辑;

  3. 为菜单项触发时处理函数里添加图形编辑器相关的上下文,以方便使用;


import { IContextMenuItem } from "context-menu-common-react";
import ContextMenu from "context-menu-common-react";
import React from "react";
import { ISprite, IStageApis } from "../../demo3-drag/type";
import { GraphicEditorCore } from "../../demo3-drag/graphic-editor";

export * from "context-menu-common-react";

export interface ITriggerParmas {
stage: GraphicEditorCore;
activeSpriteList: ISprite[];
menuItem: IEditorContextMenuItem;
}

export type IEditorContextMenuItem = IContextMenuItem & {
onTrigger: (params: ITriggerParmas) => void;
};

interface IProps {
getStage: () => GraphicEditorCore;
}

interface IState {
menuItemList: IContextMenuItem[];
}

export class EditorContextMenu extends React.Component<IProps, IState> {
triggerList: any[] = [];

stage: GraphicEditorCore | null = null;

menuItemMap: Record<string, IEditorContextMenuItem> = {};

state: IState = {
menuItemList: []
};

componentDidMount() {
this.stage = this.props.getStage?.();
}

public registerItemList = (_menuItemList: IEditorContextMenuItem[]) => {
const { menuItemList } = this.state;
_menuItemList.forEach((e) => {
this.menuItemMap[e.key] = e;
});
this.setState({ menuItemList: [...menuItemList, ..._menuItemList] });
};

public registerItem = (menuItem: IEditorContextMenuItem) => {
const { menuItemList } = this.state;
this.menuItemMap[menuItem.key] = menuItem;
this.setState({ menuItemList: [...menuItemList, menuItem] });
return () => this.remove(menuItem);
};

public remove = (menuItem: IEditorContextMenuItem | string) => {
const { menuItemList } = this.state;
const list = [...menuItemList];
const key = typeof menuItem === "string" ? menuItem : menuItem.key;
const index = list.findIndex((e) => e.key === key);
delete this.menuItemMap[key];
if (index !== -1) {
list.splice(index);
this.setState({ menuItemList: list });
}
};

public has = (menuItem: IEditorContextMenuItem | string) => {
const key = typeof menuItem === "string" ? menuItem : menuItem.key;
return Boolean(this.menuItemMap[key]);
};

handleTrigger = (menuItem: IContextMenuItem) => {
const { stage } = this;
const { activeSpriteList } = stage?.state || ({} as any);
const item = this.menuItemMap[menuItem?.key];
if (typeof item?.onTrigger === "function") {
item?.onTrigger({
menuItem,
stage: this.stage as any,
activeSpriteList
});
}
};

render() {
const { menuItemList } = this.state;
return (
<ContextMenu
getContainerDom={() =>
document.body}
menuList={menuItemList}
onTrigger={this.handleTrigger}
/>

);
}
}



3. 一些通用的右键操作方法


3.1 复制


const handleCopy = ({ stage, activeSprite }) => {
const jsonData = JSON.stringify({ type: 'activeSprite', content: activeSprite });
return navigator.clipboard.writeText(jsonData);
};
const menuItem: IContextMenuItem = {
text: '复制',
key: 'copy',
// 此菜单项是否禁用
disabled: ({ activeSprite }) => Boolean(activeSprite),
onTrigger: handleCopy,
};

stage.apis.contextMenu.registerItem(menuItem);

3.2 粘贴


const handlePaste = async ({ stage }) => {
const jsonData = await navigator.clipboard.readText();
const jsonObj = JSON.parse(jsonData);
if (jsonObj?.type === 'activeSprite') {
stage.apis.addSpriteToStage(jsonObj.content);
}
};
const menuItem: IContextMenuItem = {
text: '粘贴',
key: 'paste',
onTrigger: handlePaste,
};

stage.apis.contextMenu.registerItem(menuItem);

3.3 删除


const handleRemove = async ({ stage, activeSprite }) => {
stage.apis.removeSprite(activeSprite);
};
const menuItem: IContextMenuItem = {
text: '删除',
key: 'remove',
onTrigger: handleRemove,
};

stage.apis.contextMenu.registerItem(menuItem);

3.4 剪切


const handleCut = async ({ stage, activeSprite }) => {
const jsonData = JSON.stringify({ type: 'activeSprite', content: activeSprite });
// 先复制, 再删除
const res = await navigator.clipboard.writeText(jsonData);
stage.apis.removeSprite(activeSprite);
return res;
};
const menuItem: IContextMenuItem = {
text: '剪切',
key: 'cut',
onTrigger: handleCut,
};

stage.apis.contextMenu.registerItem(menuItem);

3.5 撤销、重做


const menuItem: IContextMenuItem = {
text: '撤销',
key: 'redo',
onTrigger: ({ stage }) => stage.apis.redo(),
};

stage.apis.contextMenu.registerItem(menuItem);

const menuItem: IContextMenuItem = {
text: '重做',
key: 'undo',
onTrigger: ({ stage }) => stage.apis.undo(),
};
stage.apis.contextMenu.registerItem(menuItem);

4. 精灵注册属于自己的右键菜单快捷操作


// 文本精灵组件
export class RichTextSprite extends BaseSprite<IProps> {

componentDidMount() {
const { stage } = this.props;
const { contextMenu } = stage.apis;
if (!contextMenu.has('clearRichTextFormat')) {
const menuItem: IContextMenuItem = {
text: '清除富文本格式',
key: 'clearRichTextFormat',
// 显示此菜单项的条件
condition: ({ sprite }) => sprite.type === 'RichTextSprite',
onTrigger: this.handleClearTextFormat,
};
stage.apis.contextMenu.registerItem(menuItem);
}
}

componentWillUnmount() {
if (contextMenu.has('clearRichTextFormat')) {
stage.apis.contextMenu.remove('clearRichTextFormat');
}
}

handleClearTextFormat = () => {
const { stage, sprite } = this.props;
const { content } = sprite.props;

const text = clearTextFormat(content);
const newProps = { ...sprite.props, content: text };
stage.apis.updateSpriteProps(sprite.id, newProps);
}

render() {
const { sprite } = this.props;
const { props, attrs } = sprite;
const { content } = props;
return (
<foreignObject
<span {...props}>
{content}</span>
</foreignObject>

);
}
}


二、快捷键


1. 图形编辑器快捷键定制


/**
* 快捷键配置
*/

export const shortcutOpts: IShortcutOpt[] = [
{
name: ShortcutNameEnum.copy,
title: '复制',
keys: ['c'],
containerSelectors: ['.div-1'],
option: { metaPress: true },
// 触发当前快捷键时执行
onTrigger: ({ opt, event }) => {
// 这里处理触发后的逻辑
},
},
{
name: ShortcutNameEnum.undo,
title: '重做',
keys: ['z'],
option: { metaPress: true, shiftPress: true },
// 触发当前快捷键时执行
onTrigger: ({ opt, event }) => {
// 这里处理触发后的逻辑
},
},
];

export default () => {

useEffect(() => {
// 实例化
const keyboardOpt = new KeyBoardOperate({
preventDefault: true,
onTrigger: (opt: IShortcutOpt, e) => {
console.info('bingo', opt, e);
// 所有快捷键触发后都会执行
},
});
shortcutOpts.forEach(e => keyboardOpt.registerShortcutKey(e));
return () => {
keyboardOpt.removeAllEventListener();
};
}, []);

return null
};

2. 精灵注册属于自己的快捷键操作


// 文本精灵组件
export class RichTextSprite extends BaseSprite<IProps> {
componentDidMount() {
const { stage } = this.props;
const { shortcutKey } = stage.apis;
if (!shortcutKey.has('clearRichTextFormat')) {
const opt: IShortcutOpt = {
title: '清除富文本格式',
name: 'clearRichTextFormat',
keys: ['c', 'l'],
option: { metaPress: true },
onTrigger: this.handleClearTextFormat,
};
stage.apis.shortcutKey.registerItem(menuItem);
}
}
componentWillUnmount() {
if (stage.apis.shortcutKey.has('clearRichTextFormat')) {
stage.apis.shortcutKey.remove('clearRichTextFormat');
}
}
render() {
...
}
}


3. 快捷键底层方案


这里的实现思路和右键菜单的注册思路类似,为了快捷键的稳定性和兼容性我们借助hotkeys-js这个包来实现快捷键的监听。


export interface IShortcutOpt {
// 快捷键的名字,不能重复,否则会报错
name: string;
// 按键数组
keys: string[];
// 容器选择器,选择快捷键生效的触发区域,支持class选择器和id选择器,例如:['.title', '#root']
containerSelectors?: string[];
// 名称
title?: string;
// 配置
option?: IShortcutOption;
// 触发回调
onTrigger?: (params: { opt: IShortcutOpt; event: KeyboardEvent }) => void;
}

上面就是一个快捷键的配置,我们的设计如下:



  • 使用option表示是否需要meta、shift等键按下

  • 使用keys表示监听的键,例如复制['c']

  • onTrigger表示快捷键被触发了时执行的回调

  • 同样支持 registerShortcutKey方法来注册上面的单个快捷键


以下是快捷键的源码:


import hotkeys from 'hotkeys-js';
import { getHotkeysStr, selectParents } from './helper';
import { IShortcutOpt, ITriggerCallback } from './types';

export class KeyBoardOperate {
// 快捷键映射
shortcutKeyMap: Record<string, IShortcutOpt[]> = {};

onTrigger: ITriggerCallback;

preventDefault: boolean = true;

clickEle: any;

constructor({
shortcutOpts = [],
preventDefault = true,
onTrigger = () => '',
}: {
shortcutOpts: IShortcutOpt[];
preventDefault?: boolean;
onTrigger?: ITriggerCallback;
}
) {
this.preventDefault = preventDefault;
this.onTrigger = (opt: IShortcutOpt, e: KeyboardEvent) => {
opt.onTrigger?.({ opt, event: e });
onTrigger?.(opt, e);
};
shortcutOpts.forEach(opt => this.registerShortcutKey(opt));
document.addEventListener('click', (e: MouseEvent) => {
this.clickEle = e.target;
});
console.log('yf123', this);
}

/**
* 注册快捷键
*
* @param shortcutOpt - 快捷键操作
* @param shortcutOpt.name - 快捷键操作名字,同时作为映射的key,要保证唯一性
* @param shortcutOpt.keys - 按键数组
* @param shortcutOpt.option - 配置
*/

public registerShortcutKey(shortcutOpt: IShortcutOpt) {
const { name, keys } = shortcutOpt;
if (!Array.isArray(keys)) {
throw new Error(`注册快捷键时, keys 参数是必要的!`);
}
// 避免重复
if (this.shortcutKeyMap[name]) {
throw new Error(`快捷键操作「${name}」已存在,请更换`);
}
this.addEventListener(shortcutOpt);
}

public removeAllEventListener() {
hotkeys.unbind();
}

private addEventListener(shortcutOpt: IShortcutOpt) {
const keyStr = getHotkeysStr(shortcutOpt);
hotkeys(keyStr, (e: KeyboardEvent) => this.handleKeyTrigger(e, shortcutOpt));
}

private removeEventListener(shortcutOpt: IShortcutOpt) {
const keyStr = getHotkeysStr(shortcutOpt);
hotkeys.unbind(keyStr);
}

private handleKeyTrigger = (event: KeyboardEvent, shortcutOpt: IShortcutOpt) => {
if (this.preventDefault) {
event.preventDefault();
}
// 如果配置了生效区域,但是触发快捷键的节点不在容器里,就认为是无效操作
const { containerSelectors = [] } = shortcutOpt;
if (containerSelectors.length > 0) {
const parents = selectParents(this.clickEle, containerSelectors);
if (parents.length === 0) {
return;
}
}
// 成功命中快捷键
this.onTrigger(shortcutOpt, event);
};
}



  • 工具函数


import { IShortcutOpt } from './types';

// 利用原生Js获取操作系统版本
export function getOS() {
const isWin =
navigator.platform === 'Win32' || navigator.platform === 'Windows';
const isMac =
navigator.platform === 'Mac68K' ||
navigator.platform === 'MacPPC' ||
navigator.platform === 'Macintosh' ||
navigator.platform === 'MacIntel';
if (isMac) {
return 'Mac';
}
const isLinux = String(navigator.platform).includes('Linux');
if (isLinux) {
return 'Linux';
}
if (isWin) {
return 'Win';
}
return 'other';
}

export const isMac = getOS() === 'Mac';

export const getMetaStr = () => (isMac ? 'command' : 'ctrl');

export const getHotkeysStr = (opt: IShortcutOpt) => {
const { metaPress, shiftPress, altPress } = opt.option || {};
let key = '';
if (metaPress) {
key += `${getMetaStr()}+`;
}
if (shiftPress) {
key += 'shift+';
}
if (altPress) {
key += 'alt+';
}
key += `${opt.keys.join('+')}`;
return key;
};

export const findDomParents = (dom: any) => {
const arr: any = [];
const findParent = (e: any) => {
if (e?.parentNode) {
arr.push(e);
findParent(e.parentNode);
}
};
findParent(dom);
return arr;
};

export const selectParents = (dom: any, selectors: string[]) => {
const results: any[] = [];
const parents = findDomParents(dom);
selectors.forEach((selector: string) => {
for (const node of parents) {
const selectorName = selector.slice(1);
if (selector.startsWith('#')) {
if (
node.getAttribute('id') === selectorName &&
!results.find(e => e === node)
) {
results.push(node);
}
} else if (selector.startsWith('.')) {
if (
node.classList.contains(selectorName) &&
!results.find(e => e === node)
) {
results.push(node);
}
}
}
});
return results;
};


  • types


export interface IShortcutOption {
metaPress?: boolean;
shiftPress?: boolean;
altPress?: boolean;
}

export type ITriggerCallback = (opt: IShortcutOpt, e: KeyboardEvent) => void;

export interface IShortcutOpt {
// 快捷键的名字,不能重复,否则会报错
name: string;
// 按键数组
keys: string[];
// 容器选择器,选择快捷键生效的触发区域,支持class选择器和id选择器,例如:['.title', '#root']
containerSelectors?: string[];
// 名称
title?: string;
// 配置
option?: IShortcutOption;
// 触发回调
onTrigger?: (params: { opt: IShortcutOpt; event: KeyboardEvent }) => void;
}

三、撤销回退


history.gif


1. 撤销回退底层方案


关于历史记录的通用底层解决方案,我之前已经写了一篇文章专门介绍,有兴趣的同学欢迎参考:



这个方案比较简单,是存储全量数据的,如果需要使用仅存储增量数据,欢迎在评论区分享方案讨论~


2. 图形编辑器中使用撤销回退


我们需要在图形编辑器里操作精灵列表spriteList数据的核心api里加上历史记录相关的操作。



export class GraphicEditorCore extends React.Component<IProps, IState> {
private readonly registerSpriteMetaMap: Record<string, ISpriteMeta> = {};

// 历史记录 - 添加
public pushHistory = (spriteList: ISprite[]) => {
history: string[] = [];

const { history } = this;
history.push(
JSON.stringify({ ...this.getMetaData(), children: spriteList }),
);
};

// 历史记录 - 撤销
public undo = () => {
const { history } = this;
if (history.getLength() > 1) {
history.undo();
history.currentValue &&
this.setSpriteList(JSON.parse(history.currentValue).children, false);
}
};

// 历史记录 - 重做
public redo = () => {
const { history } = this;
history.redo();
history.currentValue &&
this.setSpriteList(JSON.parse(history.currentValue).children, false);
};

public addSpriteToStage = (sprite: ISprite | ISprite[]) => {
const { spriteList } = this.state;
const newSpriteList = [...spriteList];
if (Array.isArray(sprite)) {
newSpriteList.push(...sprite);
} else {
newSpriteList.push(sprite);
}
this.setState({ spriteList: newSpriteList });
// 在操作精灵列表数据的方法里都加上历史记录的操作即可
this.pushHistory(newSpriteList);
};

setSpriteList = (newSpriteList: ISprite[]) => {
this.setState({ spriteList: newSpriteList });
};


四、总结


本文介绍了编辑器常用的三种提效功能:右键菜单、快捷键、历史记录,可以使我们编辑操作的效率得到大大的提升,优化体验,并且每个功能都做了分层抽象,可以形成解决方案,在别的业务中复用。


加下来我们会继续介绍提升编辑效率的功能:多选组合,以方便批量操作精灵,提升效率。


作者:前端君
来源:juejin.cn/post/7213757571960799291
收起阅读 »

深入 React Context 源码与实现原理

web
前置知识 本文假设你对 context 基础用法和 React fiber 渲染流程有一定的了解,因为这些知识不会介绍详细。本文基于 React v18.2.0 Context API React 渲染流程 React 渲染分为 render 阶段和 com...
继续阅读 »

前置知识


本文假设你对 context 基础用法和 React fiber 渲染流程有一定的了解,因为这些知识不会介绍详细。本文基于 React v18.2.0


Context API


image.png


React 渲染流程


React 渲染分为 render 阶段和 commit 阶段,其中 render 阶段分为两步(深度优先遍历)



  1. beginWork(进入节点的过程向下遍历,协调子元素)

  2. completeUnitOfWork(离开节点的过程向上回溯)


区别 render 和 beginWork


为了避免与上面的阶段混淆,以下 render 都代指开发者层面的 render,即指类组件执行 render 方法或函数组件执行



  • 如果一个组件发生更新,当前组件到 fiber root 上的父级链上的所有 fiber,都会执行 beginWork,但执行 beginWork,不代表触发了组件的 render(fiber 会检查组件是否需要进行渲染,不需要则会跳过复用旧的 fiber 节点)所以 render 不等于 beginWork

  • 如果组件 render 执行了,则一定经历了 beginWork 流程,触发了 beginWork


综上 beginWork 的工作是进入节点时协调子元素,如果 fiber 类型是类组件或者函数组件,则需检测比较组件是否需要执行 render,不需要则会跳过复用旧的 fiber 节点


React.createContext 原理


const MyContext = React.createContext(defaultValue)


创建一个 Context 对象。只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效



源码位置:packages/react/src/ReactContext.js


createContext 函数的核心逻辑是返回一个 context 对象,其中包括三个重要属性:



  • ProviderConsumer 两个组件(React Element 对象)属性

  • _currentValue :保存 context 的值,用来保存传递给 Provider 的 value 属性)


下列是精简去除类型定义和引入的源码,后面源码举例都这么处理,为了方便直观的看:


const REACT_PROVIDER_TYPE = Symbol.for('react.provider')
const REACT_CONTEXT_TYPE = Symbol.for('react.context')

export function createContext(defaultValue) {
const context = {
$$typeof: REACT_CONTEXT_TYPE, // 本质就是 Consumer Element 类型
_currentValue: defaultValue, // 保存 context 的值
_currentValue2: defaultValue, // 为了支持多个并发渲染器,适配不同的平台
_threadCount: 0, // 跟踪当前有多少个并发渲染器
Provider: null,
Consumer: null,
}
// 添加 Provider 属性,本质就是 Provider Element 类型
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context,
}
// 添加 Consumer 属性
context.Consumer = context

return context
}


JSX 语法在进入 render 时会被编译成 React Element 对象



Context.Provider 原理


<MyContext.Provider value={/* 某个值 */}>

image.png


先来了解 Provider 的特性:



  • 每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化

  • Provider 接收一个  value  属性,传递给消费组件。一个 Provider 可以和多个消费组件有对应关系。

  • 只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效

  • 多个相同的 Provider 也可以嵌套使用,里层的会覆盖外层的数据。

  • 当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染,可跳过 shouldComponentUpdate 强制更新


如果一个组件发生更新,那么当前组件到 fiber root 上的父级链上的所有 fiber,更新优先级都会升高,都会触发 beginWork,但不一定会 render


当初次 Fiber 树渲染,进入 beginWork 方法,其中对应的节点处理函数是 updateContextProvider


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ContextProvider:
return updateContextProvider(current, workInProgress, renderLanes)
}
}

进入 updateContextProvider 方法:


function updateContextProvider(current, workInProgress, renderLanes) {
const providerType = workInProgress.type
const context = providerType._context

const newProps = workInProgress.pendingProps
const oldProps = workInProgress.memoizedProps
// 新的 value 值
const newValue = newProps.value
// 获取 Provider 上的 value
pushProvider(workInProgress, context, newValue)

// 更新阶段
if (oldProps !== null) {
const oldValue = oldProps.value
// 使用 Object.is 来比较新旧值是否发生变化
if (is(oldValue, newValue)) {
// context 值没有变更,则提前退出
if (
oldProps.children === newProps.children &&
!hasLegacyContextChanged()
) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
)
}
} else {
// context 值发生改变,深度优先遍历查找 consumer 消费组件,标记更新
propagateContextChange(workInProgress, context, renderLanes)
}
}

// 继续向下调和子代 fiber
const newChildren = newProps.children
reconcileChildren(current, workInProgress, newChildren, renderLanes)
return workInProgress.child
}

// 使用栈存储 context._currentValue 值,设置 context._currentValue 为最新值
function pushProvider(providerFiber, context, nextValue) {
// 压栈
push(valueCursor, context._currentValue, providerFiber)
// 修改 context 的值
context._currentValue = nextValue
}


  • 首次执行时,保存 workInProgress.pendingProps.value 值作为最新值,然后调用 pushProvider 方法设置context._currentValue

  • pushProvider:存储 context 值的函数,利用栈先进后出的特性,先把 context._currentValue 压栈;与后面流程的 popProvider(出栈)函数相对应。

  • 更新阶段时通过浅比较(Object.is)来判断新旧 context 值是否发生改变,没发生改变则调用 bailoutOnAlreadyFinishedWork 进入 bailout,复用当前 Fiber 节点,改变则调用propagateContextChange方法


我们总结下 Context.Provider 的 Fiber 更新方法 —— updateContextProvider的核心逻辑



  1. 将 Provider 的 value 属性赋值给 context._currentValue(压栈)

  2. 通过 Object.is 浅比较 context 新旧值是否发生变化

  3. 发生变化时,调用 propagateContextChange 走更新的流程,深度优先遍历查找消费组件来标记更新



propagateContextChange 逻辑:深度优先遍历所有的子代 fiber ,然后找到里面具有 dependencies 的属性,对比 dependencies 中的 context 和当前 Provider 的 context 是否是同一个,如果是同一个,会提高 fiber 的更新优先级,让 fiber 在接下来的调和过程中,处于一个高优先级待更新的状态,而高优先级的 fiber 都会 beginWork



消费 Context 原理


由上文知识我们简略粗暴的说:Provider 一顿操作核心就是修改 context._currentValue 的值,那么消费 Context 值的原理也就是想方设法读取 context._currentValue 的值了。


image.png


Context.Consumer(函数组件)


<MyContext.Consumer>
{value => /* 基于 context 值进行渲染*/}
</MyContext.Consumer>


一个 React 组件可以订阅 context 的变更,此组件可以让你在函数式组件中可以订阅 context。这种方法需要一个函数作为子元素(function as a child)。这个函数接收当前的 context 值,并返回一个 React 节点。传递给函数的 value 值等价于组件树上方离这个 context 最近的 Provider 提供的 value 值



当 context 值更新时,Fiber 树渲染时,进入 beginWork 方法,beginWork 中对于 ContextConsumer 的节点处理函数是 updateContextConsumer


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ContextConsumer:
return updateContextConsumer(current, workInProgress, renderLanes)
}
}

updateContextConsumer的核心逻辑:



  1. 调用 prepareToReadContextreadContext 读取最新的 context 值。

  2. 通过 render props 函数,传入最新的 context value 值,得到最新的 children 。

  3. 调和 children


function updateContextConsumer(current, workInProgress, renderLanes) {
// 拿到 context
let context = workInProgress.type
context = context._context

const newProps = workInProgress.pendingProps
// 获取 Consumer 组件的 render props children
const render = newProps.children
// 读取 context 前的准备工作
prepareToReadContext(workInProgress, renderLanes)
// 读取最新 context._currentValue 值
const newValue = readContext(context)

let newChildren
// 最新的 children element
newChildren = render(newValue)

// 进入主流程,调和 children
reconcileChildren(current, workInProgress, newChildren, renderLanes)
return workInProgress.child
}

useContext(函数组件)


const value = useContext(MyContext)


接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。



看如下代码,useContext Hook 挂载阶段和更新阶段,本质都是调用 readContext 函数,readContext 函数会返回 context._currentValue。而且也是调用了 prepareToReadContextreadContext


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
)
}
}

function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes,
) {
prepareToReadContext(workInProgress, renderLanes)
// 处理各种hooks逻辑
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
)
// ...
}

renderWithHooks 函数是调用函数组件的主要函数


function renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
) {
// ...
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount // 挂载阶段
: HooksDispatcherOnUpdate // 更新阶段
}

// 确保 Hooks 只能在函数组件内部或自定义 Hooks 中使用,提供正确的调度程序
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current
return dispatcher
}

function useContext(Context) {
const dispatcher = resolveDispatcher()
return dispatcher.useContext(Context)
}

const HooksDispatcherOnMount = {
useContext: readContext,
// ...
}
const HooksDispatcherOnUpdate = {
useContext: readContext,
// ...
}

Class.contextType(类组件)


class MyClass extends React.Component {
componentDidMount() {
let value = this.context
/* 在组件挂载完成后,使用 MyContext 组件的值来执行一些有副作用的操作 */
}
componentDidUpdate() {
let value = this.context
/* ... */
}
componentWillUnmount() {
let value = this.context
/* ... */
}
render() {
let value = this.context
/* 基于 MyContext 组件的值进行渲染 */
}
}
MyClass.contextType = MyContext


挂载在 class 上的 contextType 属性可以赋值为由 React.createContext() 创建的 Context 对象。此属性可以让你使用 this.context 来获取最近 Context 上的值。你可以在任何生命周期中访问到它,包括 render 函数中。




  • 类组件会判断类组件上是否有静态属性 contextType

  • 如果有则调用 readContext 方法,并赋值给类实例的 context 属性,所以我们才可以使用 this.context 获取 context 值


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ClassComponent:
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
)
}
}

function updateClassComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes,
) {
// ...
prepareToReadContext(workInProgress, renderLanes)
mountClassInstance(workInProgress, Component, nextProps, renderLanes)
// ...
}

function mountClassInstance(workInProgress, ctor, newProps, renderLanes) {
// ...
const instance = workInProgress.stateNode
// 判断类组件上是否有静态属性 contextType
const contextType = ctor.contextType
// 有则调用 readContext
if (typeof contextType === 'object' && contextType !== null) {
// 赋值给类实例的 context 属性
instance.context = readContext(contextType)
}
}

综上,以上三种方式只是 React 根据不同使用场景封装的 API,它们在消费/订阅 context 的共同操作:



  1. 先调用 prepareToReadContext 进行准备工作

  2. 再调用 readContext 方法读取 context 值(readContext 方法返回 context._currentValue 最新值)


上文提到 propagateContextChange ,如果组件订阅了 context,不管是函数组件还是类组件,都会将 fiber.lanes 设置为 renderLanes。在 beginWork 阶段,发现 fiber.lanes 等于 renderLanes,则走 beginWork 的逻辑,强制组件更新


prepareToReadContext 和 readContext 逻辑


prepareToReadContext 的核心逻辑:



  • 设置全局变量 currentlyRenderingFiber 为当前工作的 fiber,并重置lastContextDependency 等全局变量


function prepareToReadContext(workInProgress, renderLanes) {
// 设置全局变量 currentlyRenderingFiber 为当前工作的 fiber, 为 readContext 做准备
currentlyRenderingFiber = workInProgress
// 用于构造 dependencies 列表
lastContextDependency = null
// 将全局变量 lastFullyObservedContext (保存的是 context 对象) 重置为 null
lastFullyObservedContext = null

const dependencies = workInProgress.dependencies
if (dependencies !== null) {
const firstContext = dependencies.firstContext
if (firstContext !== null) {
if (includesSomeLane(dependencies.lanes, renderLanes)) {
// Context list has a pending update. Mark that this fiber performed work.
markWorkInProgressReceivedUpdate()
}
// 重置 fiber context 依赖
dependencies.firstContext = null
}
}
}

readContext 的核心逻辑:



  • 收集组件依赖的所有不同的 context,如果组件订阅了 context,则将 context 添加到 fiber.dependencies 链表中

  • 返回context._currentValue, 并构造一个contextItem添加到workInProgress.dependencies 链表之后。


function readContext(context) {
return readContextForConsumer(currentlyRenderingFiber, context)
}

function readContextForConsumer(consumer, context) {
// ReactDOM 中 isPrimaryRenderer 为 true,则一直返回 context._currentValue
const value = isPrimaryRenderer
? context._currentValue
: context._currentValue2

// 相等说明是同一个 Context,不处理为了防止重复添加依赖
if (lastFullyObservedContext === context) {
// Nothing to do. We already observe everything in this context.
} else {
const contextItem = {
context: context,
memoizedValue: value,
next: null,
}
// 构造一个 contextItem, 加入到 workInProgress.dependencies 链表之后
if (lastContextDependency === null) {
lastContextDependency = contextItem
// dependencies 属性用于判定是否依赖了 ContextProvider 中的值
consumer.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
}
} else {
// 将 context 添加到 fiber.dependencies 链表末尾
lastContextDependency = lastContextDependency.next = contextItem
}
}
// 返回 context._currentValue
return value
}

Context 原理八连问


上面源码实际上还是讲解不够完整的,在这推荐一篇文章:【React 源码系列】React Context 原理,如何合理设计共享状态,个人认为相对讲得很清晰了。


想知道自己对原理的理解,除了输出就是回答解决一些提问了,这里列举了一些原理相关的问题,写下简略的解答,看看自己是否了解。


Provider 如何传递 context?


通过将 Provider 的 value 属性值赋值给 context._currentValue


没有 Provider 包裹,为什么读不到最新的 context 值?


render() {
return (
<>
<TestContext.Provider value={10}>
{/* 可读到 context 值最新值 10 */}
<Test />
</TestContext.Provider>
{/* 只能读到 context 初始值(createContext 函数的参数 defaultValue) */}
<Test />,
</>

)
}

消费 context 时是读取 context._currentValue 值,理论上其它组件也是读取该最新值的。Provider 其中一个特性是只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。所以没有被 Provider 包裹的组件,是只能读到默认值的。


React 在深度优先遍历 fiber 树时,最外层 Provider 开始 beginWork,会先将 context._currentValue 的旧值保存起来,赋新的值给 context._currentValue(所以在里层的组件都能读到最新值),在离开 Provider 节点时会调用 completeUnitOfWork 完成工作,在此会将 context._currentValue 恢复成旧值,到遍历第二个 <Test /> 节点时就读的是 context 的默认值(不被 Provider 包裹的组件 render 时 beginWork 的时候就读到旧值了)。


相同 Provider 嵌套使用,里层的会覆盖外层的数据是怎么实现的?


render() {
return (
<>
<TestContext.Provider value={10}>
<Test1 />
<TestContext.Provider value={100}>
<Test2 />
</TestContext.Provider>
</TestContext.Provider>
</>

)
}

在这场景下, <Test1 /><Test2 /> 组件读取的值分别是 10 和 100。


为了实现嵌套的机制,React 利用的是的特性(后入先出),通过 pushProviderpopProvider


Fiber 深度优先遍历时:



  • 最外层 Provider 将 value 值 10 压入栈 pushProvider,此时栈顶是 10

  • 遍历里层 Provider 时将 value 值 100 压入栈 pushProvider,此时栈顶是 100,即context._currentValue 的值为 100


消费组件 <Test2 />读取时,在其所在 Provider 范围内先读取栈顶的值,所以读取的是 100;里层的 Provider 完成遍历工作离开时,弹出栈顶 popProvider的值 100,此时栈顶的值是 10, 即 context._currentValue 的值为 10,<Test1 /> 里面读到的值也就为 10 了。


由于 React 调和过程就是 Fiber 树深度优先遍历的过程, 向下遍历(beginWork)和向上回溯(completeWork)恰好符合栈的特性(入栈和出栈),Context 的嵌套读取就是利用了这个特性。


三种消费 context 的原理



  • useContext:本质上调用 readContext 方法

  • Context.Consumer:本质上是类型为 REACT_CONTEXT_TYPE 的 React Element 对象,context 本身就存在 Consumer 里面,本质也是调用 readContext

  • Class.contextType:通过静态属性 contextType 建立联系 ,在类组件实例化的时候被使用,本质上也是调用 readContext


三种方式只是 React 根据不同使用场景封装的 API,本质都是调用了 readContext 方法读取 context._currentValue


context 的存取发生在 React 渲染的哪些阶段


context 的存取就是发生在 beginWork 阶段,在 beginWork 阶段,如果当前组件订阅了 context,则从 context 中读取 _currentValue


消费 context 的组件,context 改变为什么会订阅更新?



  • 当 Provider 的 context value 值更新时,会调用 updateContextProvider 方法,里面的 propagateContextChange 方法会对 fiber 子树向下深度优先遍历所有的 fiber 节点,目的是为了找到消费组件标记更新。如果 fiber.dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,就会被标记更新。

  • 而消费组件调用的 readContext 方法则会把 fiber.dependencies 和 context 对象建立关联,fiber.dependencies 用于判断是否依赖了 ContextProvider 中的值

  • context 值更新时消费 context 的 fiber 和父级链都会提高更新优先级,向上遍历时,会设置消费节点的父路径上所有节点的 fiber.childLanes 属性,(childLanes 属性用于判断子节点是否需要更新)需要更新则子节点就会进入更新逻辑(开始 beginWork)。


消费 context 的组件是如何跳过 PureComponent、shouldComponentUpdate 强制 render?



  • 类组件更新流程中,强制更新会跳过 PureComponentshouldComponentUpdate 等优化策略,在外部代码层面,我们可调用 this.forceUpdate(),就会给类组件打上强制更新的 tag。而在内部实现上, context 的 value 改变时,要想订阅 context 的类组件更新,相应的也得打上强制更新的 tag

  • 当 context 值发生变化时,会调用 propagateContextChange 对 Fiber 子树向下深度优先遍历所有的 fiber 节点,如果 fiber.dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,如果 fiber 节点是类组件, 则会创建一个 update 对象,并将 update.tag 标记为 ForceUpdate;而处理 update 时,发现 tag 为 ForceUpdate 的话,会将全局变量 hasForceUpdate 设置为 true, 这决定了类组件会强制更新。



updateClassComponent 中会调用 updateClassInstance 判断类组件是否应该更新。在 updateClassInstance 中会判断全局变量 hasForceUpdate 或者组件的 shouldComponentUpdate 的返回值是否为 true, true 则表示要强制更新。



简述 Context 原理


Context 的实现原理:



  • 创建 Context:createContext 返回一个 context 对象,对象包括 ProviderConsumer 两个组件属性,并创建 _currentValue 属性用来保存 context 的值

  • Provider 负责传递 context 值,并使用栈的特性存储修改 context 值

  • 消费 Context:消费组件节点调用 readContext 读取 context._currentValue 获取最新值

  • Provider 更新 Context:ContextProvider 节点深度优先遍历子代 fiber,消费 context 的 fiber 和父级链都会提升更新优先级;对于类组件的 fiber ,会被 forceUpdate 处理。接下来所有消费的 fiber,都会执行 beginWork


结语


本文对 Context 源码的理解有限,暂未能完全读完,只是过了一遍大致实现,如有错误恳请纠正。


参考文章




作者:JackySummer
来源:juejin.cn/post/7213752661761523772
收起阅读 »

css-transform2D变换

web
CSS transform 属性允许你旋转,缩放,倾斜或平移给定元素。 常用的transform 属性有下面几个 属性说明translate(0, 0)位移rotate(0deg)旋转scale(1)缩放skew(0deg)斜切 transform的说明文档:...
继续阅读 »

CSS transform 属性允许你旋转,缩放,倾斜或平移给定元素。
常用的transform 属性有下面几个


属性说明
translate(0, 0)位移
rotate(0deg)旋转
scale(1)缩放
skew(0deg)斜切

transform的说明文档:developer.mozilla.org/zh-CN/docs/…


下面分别说一下这几个方法


translate() 位移


translate通过x、y轴的参数来实现偏移
语法:transform: translate(10px, 10px); x轴偏移10pxy轴偏移10px
也可以单独对某一个轴进行偏移设置,css提供了x、y轴的语法:
transform: translateX(10px);
transform: translateY(10px);


translate的参数可以使用百分比,如果参数是百分比的话,实际的偏移距离是以自身大小为参考的,例如:一个100px的正方形,translateX(50%),那么实际x轴的偏移量是自身的100px * 50% = 50px,有了这个特性之后,可以通过transform: translate(-50%, -50%); 的写法实现垂直定位居中。


.box{
width: 20px;
height: 20px;
background: #e94242;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}

在这里插入图片描述


transform: translate第一个参数偏移自身x轴的50%,第二个参数偏移自身y50%,另外left偏移50%,假如自身100px
那么:left + 自身 - x轴自身50% = 50% + 100px - 50px = 偏移量正好居中,y轴同理。



另外,translate是不受文档流影响的,direction: ltr;文档流为左,translateX依然往右偏移。





rotate() 旋转


rotate() 用于设置元素的旋转角度,rotate(45deg)就是顺时针旋转45°rotate()的旋转受锚点的影响(transform-origin),锚点的问题在下文。
rotate() 有四个单位,分别是:deg角度、grad百分度、rad弧度 、return圈度,最常用的就是deg角度,其它的日常项目基本用不到。


.box{
width: 20px;
height: 20px;
background: #e94242;
transform: rotate(45deg);
}

在这里插入图片描述




scale()缩放


scale()有两个参数,语法:transform: scale(参数一 , 参数二),分别对应横向和纵向的放大和缩小,默认值为1(不放大)。


transform: scale(2); /**等比放大2倍 */
transform: scaleX(2); /**水平放大2倍 */
transform: scaleY(2); /**垂直放大2倍 */
transform: scale(2,1); /**x轴放大2倍,y轴不变 */
transform: scale(2,0.5); /**x轴放大2倍,y轴缩小一半 */

.shiftBox{
width: 80px;
height: 80px;
transform: scale(2,0.5); /**x轴放大2倍,y轴缩小一半 */
}

在这里插入图片描述




skew() 斜切


斜切字面意思就是将物体倾斜的意思,语法:transform: skew(10deg, 5deg)表示水平斜切10度 垂直斜切5度,它接受两个参数,第一个参数表示x轴,第二个参数y轴。
也可以单独对某一个轴进行斜切,css提供了x、y轴的语法:
transform: skewX(10deg):水平斜切10
transform: skewY(10deg):垂直斜切10


/* skew() 斜切 */
.shiftBox{
width: 80px;
height: 80px;
background: #80c342;
transform: skew(10deg, 5deg); /**水平斜切10度 垂直斜切5度 */
}

在这里插入图片描述


斜切可以应用在图形的变换,只通过调整x、y轴的倾斜角度即可实现一些画面效果,某些场合下比裁切属性(clip-path)方便。
例如:实现当前任务的进度展示


在这里插入图片描述


这种效果只需要绘制一个矩形,将x轴倾斜45


在这里插入图片描述


再绘制一个矩形,x轴倾斜 -45°即可实现


在这里插入图片描述




transform的细节和特性


元素引用transform属性值不会影响元素的尺寸和位置


我们在日常布局的时候,使用margin或者定位通常会影响到其他的元素


在这里插入图片描述


比如上面这个案例,第二个按钮设置了margin-left,导致第三个按钮的位置也发生变化。
如果第二个按钮使用的是transform: translateX()偏移,那么第三个按钮的位置并不会受到影响,因为transform属性值不会影响原始位置


在这里插入图片描述


另外,内联元素是不受transform所有的变换特性的影响的,必须转为行内块才可以。


span{
/* 内联元素不受transform所有的变换特性 */
display: inline-block; /* 设置行内块后,受transform影响,解决 */
transform: translateX(50px);
}



参数的顺序不同,会影响结果


transform的参数,会按照先后顺序执行,同样的参数,位置不同则会影响执行结果。


.order{
width: 200px;
height: 200px;
border: 1px solid red;
:nth-child(1){
width: 20px;
height: 20px;
background: #4d90fe;
transform: translateX(50px) scale(2); /* 先位移再放大,顺序影响结果 */
}
:nth-child(2){
width: 20px;
height: 20px;
background: #80c342;
transform: scale(2) translateX(50px); /* 先放大再位移,顺序影响结果 */
}
}

在这里插入图片描述


这里b盒子先放大后,再执行translateX,按照放大后的比例进行的偏移,所以b的偏移量比a的远。


有两点需要注意:
1、transformclip-path同时使用时,先裁剪再变换
2、transformmargin,应该优选选择transform,性能更高,因为transform属性值不会影响原始位置。




transform会创建新的层叠上下文


多个元素叠在一起时,通常后执行的元素会覆盖先执行的元素,类似下面的:


在这里插入图片描述


一层叠一层,如果想突出展示元素可以设置z-index来改变层级,其实这里使用transform也可以实现,transform会创建新的层叠上下文,后执行的元素会覆盖先执行的,所以这里无需z-index也可以实现突出展示层级效果,这里使用了transform: scale(1); 原大小保持不变,相当于没对元素做任何操作,但是层叠顺序改变了,如下:


.layer{
width: 200px;
height: 50px;
border: 1px solid red;
padding-left: 20px;
margin: 50px;
>img{
width: 50px;
margin-left: -20px;
}
>img:hover{
transform: scale(1); /*原大小*/
box-shadow: 0px 0px 5px black;
}
}

在这里插入图片描述




固定定位实效


固定定位fixed:元素会被移出正常文档流,并不为元素预留空间,而是通过指定元素相对于屏幕视口(viewport)的位置来指定元素位置。元素的位置在屏幕滚动时不会改变。
但如果fixed的父级设置了transform,那么固定定位将会实效。


/* 固定定位实效 */
.positions{
width: 200px;
height: 50px;
border: 1px solid red;
margin-top: 10px;
.positionBox{
width: 50px;
height: 50px;
background: #80c342;
transform: translateX(10px);
.positionInner{
width: 20px;
height: 20px;
background: #e94242;
right: 0px;
position: fixed; /* 父级设置了transform导致fixed失效 */

}
}
}

在这里插入图片描述




改变overflow对元素的限制


父级元素设置overflow: hidden;是不能对设置了绝对定位的子级元素产生影响的,子级内容超出父级范围不能被隐藏。


.overFlow{
width: 100px;
height: 100px;
background: #4d90fe;
overflow: hidden;
>img{
width: 200px;
height: 50px;
position: absolute; /* 绝对定位不受overflow:hidden影响 */
border: 1px solid red;
}
}

在这里插入图片描述


但如果给父级设置了transform,则会更改overflow的限制,绝对定位的子元素也受到到影响


.overFlow2{
width: 100px;
height: 100px;
background: #80c342;
overflow: hidden;
transform: scale(1); /* transform更改overflow的限制,绝对定位的子元素也受到到影响 */
>img{
width: 200px;
height: 50px;
position: absolute;
bottom: 0;
border: 1px solid red;
}
}

在这里插入图片描述


在这里还有个注意点,img图片跑到底部了,因为父级元素设置了transform,只要transform属性值不为none的元素也可以作为绝对定位元素的包含块 ,相当于开启了相对定位。




transform-origin更改元素变换的中心坐标


transform-origin CSS 属性让你更改一个元素变形的原点。其实就是元素的锚点坐标,默认锚点在元素的中心。


.innerBox2{
width: 20px;
height: 20px;
background: #e94242;
transform: rotate(20deg); /*顺时针旋转20°*/
}

在这里插入图片描述


锚点在中心,顺时针旋转20°,如果更改锚点的位置为右上角,那么会出现下面的效果


.innerBox2{
width: 20px;
height: 20px;
background: #e94242;
transform: rotate(20deg);
transform-origin: right top; /**受锚点影响 */
}


锚点可以使用方向关键字,也可以使用参数。


在这里插入图片描述


关于锚点的介绍,请看文档:developer.mozilla.org/zh-CN/docs/…


下面通过锚点实现钟摆效果


<div class="originPointer"></div>

.originPointer{
width: 10px;
height: 100px;
margin: 50px;
&::before{
content: '';
width: 10px;
height: 10px;
position: absolute;
background: #80c342;
border-radius: 50%;
transform: translateY(-50%);
}
&::after{
content: '';
width: 10px;
height: 100px;
background: #4d90fe;
position: absolute;
clip-path: polygon(50% 0%, 50% 0%, 100% 100%, 0% 100%);
transform: rotate(0deg);
/* transform-origin: top left; */ /* 改变锚点为左上角 */
transform-origin: 0px 0px; /* 锚点左上角 x轴和y轴,默认起点在最左侧 */
animation: pointer 2s infinite linear; /* 添加linear使画面流程不卡顿 */
}
@keyframes pointer {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(20deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-20deg);
}
100% {
transform: rotate(0deg);
}
}
}

在这里插入图片描述




案例源码:gitee.com/wang_fan_w/…


如果觉得这篇文章对你有帮助,欢迎点赞、收藏、转发哦~


作者:fanction
来源:juejin.cn/post/7211451845032902711
收起阅读 »

前端加载超大图片(100M以上)实现秒开解决方案

web
前言前端加载超大图片时,一般可以采取以下措施实现加速:图片压缩:将图片进行压缩可以大幅减小图片的大小,从而缩短加载时间。压缩图片时需要注意保持图片质量,以免影响图片显示效果。图片分割:将超大图片分割成多个小图块进行加载,可以避免一次性加载整个图片,从而加快加载...
继续阅读 »

前言

前端加载超大图片时,一般可以采取以下措施实现加速:

  1. 图片压缩:将图片进行压缩可以大幅减小图片的大小,从而缩短加载时间。压缩图片时需要注意保持图片质量,以免影响图片显示效果。

  2. 图片分割:将超大图片分割成多个小图块进行加载,可以避免一次性加载整个图片,从而加快加载速度。这种方式需要在前端实现图片拼接,需要确保拼接后的图片无缝衔接。

  3. CDN 加速:使用 CDN(内容分发网络)可以将图片缓存在离用户更近的节点上,从而加速图片加载速度。如果需要加载的图片是静态资源,可以将其存储在 CDN 上,以便快速访问。

  4. 懒加载:懒加载是一种图片延迟加载的方式,即当用户浏览到需要加载的图片时才进行加载,可以有效避免一次性加载大量图片而导致页面加载速度缓慢。

  5. WebP 格式:使用 WebP 格式可以将图片大小减小到 JPEG 和 PNG 的一半以下,从而加快图片加载速度。

  6. HTTP/2:使用 HTTP/2 协议可以并行加载多个图片,从而加快页面加载速度。

  7. 预加载:预加载是在页面加载完毕后,提前加载下一步所需要的资源。在图片加载方面,可以在页面加载完毕后提前加载下一个需要显示的图片,以便用户快速浏览。

而对于几百M或上G的大图而言,不管对图片进行怎么优化或加速处理,要实现秒开也是不太可能的事情。而上面介绍的第二条“图像分割切片”是最佳解决方案。下面介绍下如何对大图进行分割,在前端进行拼接实现秒开。

图像切片原理介绍

图像切片是指将一张大图分割成若干个小图的过程,以便于存储和处理。图像切片常用于网络地图、瓦片地图、图像拼接等应用中。

切片原理主要包括以下几个步骤:

  1. 定义切片大小:首先需要定义每个小图的大小,一般情况下是正方形或矩形。

  2. 计算切片数量:根据定义的切片大小,计算原始图像需要被切成多少个小图。计算公式为:切片数量 = 原始图像宽度 / 切片宽度 × 原始图像高度 / 切片高度。

  3. 切割图像:按照计算出的切片数量,将原始图像分割成相应数量的小图。可以使用图像处理库或自己编写代码实现。

  4. 存储切片:将切割后的小图存储到磁盘上,可以使用常见的图片格式,如JPEG、PNG等。

  5. 加载切片:在需要显示切片的地方,根据需要加载相应的小图,组合成完整的图像。

使用图像切片可以降低处理大图像的复杂度,同时也能够提高图像的加载速度,使得用户可以更快地查看图像的细节。图像切片广泛应用于需要处理大图像的场景,能够提高图像处理和显示效率,同时也能够提高用户的体验。

实现

先上效果图


上传打开图形

先上传大图,至后台进行切片处理, 上传相关代码为:

async onChangeFile(file) {
           try {
               message.info('文件上传中,请稍候...')
               this.isSelectFile = false;
               this.uploadMapResult = await svc.uploadMap(file.raw);
               if (this.uploadMapResult.error) {
                   message.error('上传图形失败!' + this.uploadMapResult.error)
                   return
              }
               this.form.mapid = this.uploadMapResult.mapid;
               this.form.uploadname = this.uploadMapResult.uploadname;
               this.maptype = this.uploadMapResult.maptype || '';
               this.dialogVisible = true;
          } catch (error) {
               console.error(error);
               message.error('上传图形失败!', error)
          }
      }

如果需要上传后对图像进行处理,可以新建一个cmd.txt文件,把处理的命令写进文件中,然后和图像一起打包成zip上传。

如需要把1.jpg,2.jpg拼接成一个新的图片m1.png再打开,cmd.txt的写法如下:

join
1.jpg
2.jpg
m1.png
horizontal

再把1.jpg,2.jpg,cmd.txt三个文件打包成zip文件上传即可

打开图像相关代码

async onOpenMap() {
           try {
               let mapid = this.form.mapid;
               let param = {
                   ...this.uploadMapResult,
                   // 图名称
                   mapid: this.form.mapid,
                   // 上传完返回的fileid
                   fileid: this.uploadMapResult.fileid,
                   // 上传完返回的文件名
                   uploadname: this.form.uploadname,
                   // 地图打开方式
                   mapopenway: this.form.openway === "直接打开图形" ? vjmap.MapOpenWay.Memory : vjmap.MapOpenWay.GeomRender,
                   // 如果要密码访问的话,设置秘钥值
                   secretKey: this.form.isPasswordProtection ? svc.pwdToSecretKey(this.form.password) : undefined,
                   style: vjmap.openMapDarkStyle(),// div为深色背景颜色时,这里也传深色背景样式
                   // 图像类型设置地图左上角坐标和分辨率
                   imageLeft: this.form.imageLeft ? +this.form.imageLeft : undefined,
                   imageTop: this.form.imageTop ? +this.form.imageTop : undefined,
                   imageResolution: this.form.imageResolution ? +this.form.imageResolution : undefined,
              }
               let isVectorStyle = this.form.openway === "存储后渲染矢量";
               await openMap(param, isVectorStyle);
          } catch (error) {
               console.error(error);
               message.error('打开图形失败!', error)
          }
      }

应用案例

应用一 对图像进行拼接前端查看

原始图片为



最终效果为:


体验地址: vjmap.com/app/cloud/#…

应用二 对tiff影像进行切片并与CAD图叠加校准

对tiff影像上传时可设置地理坐标范围。

tiff/tfw, jpg/jpgw坐标文件的格式(6个参数) 0.030000 0.0000000000 0.0000000000 -0.030000 451510.875000 3358045.000000

以上每行对应的含义:

1 地图单元中的一个象素在X方向上的X分辨率尺度。 2 平移量。 3 旋转量。 4 地图单元中的一个象素在Y方向上的Y分辨率尺度的负值。 5 象素1,1(左上方)的X地坐标。 6 象素1,1(左上方)的Y地坐标。

在上传图时需要根据文件中的第一个,第五个和第六个值设置地图范围


或者上传完后,操作菜单中点击设置地图范围进行设置


影像地图切片完成后,可与CAD图进行叠加校准。效果如下


体验地址: vjmap.com/demo/#/demo…

作者:vjmap
来源:juejin.cn/post/7212270321622106170

收起阅读 »

面试官问我按钮级别权限怎么控制,我说v-if,面试官说再见

web
最近的面试中有一个面试官问我按钮级别的权限怎么控制,我说直接v-if啊,他说不够好,我说我们项目中按钮级别的权限控制情况不多,所以v-if就够了,他说不够通用,最后他对我的评价是做过很多东西,但是都不够深入,好吧,那今天我们就来深入深入。 因为我自己没有相关实...
继续阅读 »

最近的面试中有一个面试官问我按钮级别的权限怎么控制,我说直接v-if啊,他说不够好,我说我们项目中按钮级别的权限控制情况不多,所以v-if就够了,他说不够通用,最后他对我的评价是做过很多东西,但是都不够深入,好吧,那今天我们就来深入深入。


因为我自己没有相关实践,所以接下来就从这个有16.2k星星的后台管理系统项目Vue vben admin中看看它是如何做的。


获取权限码


要做权限控制,肯定需要一个code,无论是权限码还是角色码都可以,一般后端会一次性返回,然后全局存储起来就可以了,Vue vben admin是在登录成功以后获取并保存到全局的store中:


import { defineStore } from 'pinia';
export const usePermissionStore = defineStore({
state: () => ({
// 权限代码列表
permCodeList: [],
}),
getters: {
// 获取
getPermCodeList(){
return this.permCodeList;
},
},
actions: {
// 存储
setPermCodeList(codeList) {
this.permCodeList = codeList;
},

// 请求权限码
async changePermissionCode() {
const codeList = await getPermCode();
this.setPermCodeList(codeList);
}
}
})

接下来它提供了三种按钮级别的权限控制方式,一一来看。


函数方式


使用示例如下:


<template>
<a-button v-if="hasPermission(['20000', '2000010'])" color="error" class="mx-4">
拥有[20000,2000010]code可见
</a-button>
</template>

<script lang="ts">
import { usePermission } from '/@/hooks/web/usePermission';

export default defineComponent({
setup() {
const { hasPermission } = usePermission();
return { hasPermission };
},
});
</script>

本质上就是通过v-if,只不过是通过一个统一的权限判断方法hasPermission


export function usePermission() {
function hasPermission(value, def = true) {
// 默认视为有权限
if (!value) {
return def;
}

const allCodeList = permissionStore.getPermCodeList;
if (!isArray(value)) {
return allCodeList.includes(value);
}
// intersection是lodash提供的一个方法,用于返回一个所有给定数组都存在的元素组成的数组
return (intersection(value, allCodeList)).length > 0;

return true;
}
}

很简单,从全局store中获取当前用户的权限码列表,然后判断其中是否存在当前按钮需要的权限码,如果有多个权限码,只要满足其中一个就可以。


组件方式


除了通过函数方式使用,也可以使用组件方式,Vue vben admin提供了一个Authority组件,使用示例如下:


<template>
<div>
<Authority :value="RoleEnum.ADMIN">
<a-button type="primary" block> 只有admin角色可见 </a-button>
</Authority>
</div>
</template>
<script>
import { Authority } from '/@/components/Authority';
import { defineComponent } from 'vue';
export default defineComponent({
components: { Authority },
});
</script>

使用Authority包裹需要权限控制的按钮即可,该按钮需要的权限码通过value属性传入,接下来看看Authority组件的实现。


<script lang="ts">
import { defineComponent } from 'vue';
import { usePermission } from '/@/hooks/web/usePermission';
import { getSlot } from '/@/utils/helper/tsxHelper';

export default defineComponent({
name: 'Authority',
props: {
value: {
type: [Number, Array, String],
default: '',
},
},
setup(props, { slots }) {
const { hasPermission } = usePermission();

function renderAuth() {
const { value } = props;
if (!value) {
return getSlot(slots);
}
return hasPermission(value) ? getSlot(slots) : null;
}

return () => {
return renderAuth();
};
},
});
</script>

同样还是使用hasPermission方法,如果当前用户存在按钮需要的权限码时就原封不动渲染Authority包裹的内容,否则就啥也不渲染。


指令方式


最后一种就是指令方式,使用示例如下:


<a-button v-auth="'1000'" type="primary" class="mx-4"> 拥有code ['1000']权限可见 </a-button>

实现如下:


import { usePermission } from '/@/hooks/web/usePermission';

function isAuth(el, binding) {
const { hasPermission } = usePermission();

const value = binding.value;
if (!value) return;
if (!hasPermission(value)) {
el.parentNode?.removeChild(el);
}
}

const mounted = (el, binding) => {
isAuth(el, binding);
};

const authDirective = {
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
mounted,
};

// 注册全局指令
export function setupPermissionDirective(app) {
app.directive('auth', authDirective);
}

只定义了一个mounted钩子,也就是在绑定元素挂载后调用,依旧是使用hasPermission方法,判断当前用户是否存在通过指令插入的按钮需要的权限码,如果不存在,直接移除绑定的元素。


很明显,Vue vben admin的实现有两个问题,一是不能动态更改按钮的权限,二是动态更改当前用户的权限也不会生效。


解决第一个问题很简单,因为上述只有删除元素的逻辑,没有加回来的逻辑,那么增加一个updated钩子:


app.directive("auth", {
mounted: (el, binding) => {
const value = binding.value
if (!value) return
if (!hasPermission(value)) {
// 挂载的时候没有权限把元素删除
removeEl(el)
}
},
updated(el, binding) {
// 按钮权限码没有变化,不做处理
if (binding.value === binding.oldValue) return
// 判断用户本次和上次权限状态是否一样,一样也不用做处理
let oldHasPermission = hasPermission(binding.oldValue)
let newHasPermission = hasPermission(binding.value)
if (oldHasPermission === newHasPermission) return
// 如果变成有权限,那么把元素添加回来
if (newHasPermission) {
addEl(el)
} else {
// 如果变成没有权限,则把元素删除
removeEl(el)
}
},
})

const hasPermission = (value) => {
return [1, 2, 3].includes(value)
}

const removeEl = (el) => {
// 在绑定元素上存储父级元素
el._parentNode = el.parentNode
// 在绑定元素上存储一个注释节点
el._placeholderNode = document.createComment("auth")
// 使用注释节点来占位
el.parentNode?.replaceChild(el._placeholderNode, el)
}

const addEl = (el) => {
// 替换掉给自己占位的注释节点
el._parentNode?.replaceChild(el, el._placeholderNode)
}

主要就是要把父节点保存起来,不然想再添加回去的时候获取不到原来的父节点,另外删除的时候创建一个注释节点给自己占位,这样下次想要回去能知道自己原来在哪。


第二个问题的原因是修改了用户权限数据,但是不会触发按钮的重新渲染,那么我们就需要想办法能让它触发,这个可以使用watchEffect方法,我们可以在updated钩子里通过这个方法将用户权限数据和按钮的更新方法关联起来,这样当用户权限数据改变了,可以自动触发按钮的重新渲染:


import { createApp, reactive, watchEffect } from "vue"
const codeList = reactive([1, 2, 3])

const hasPermission = (value) => {
return codeList.includes(value)
}

app.directive("auth", {
updated(el, binding) {
let update = () => {
let valueNotChange = binding.value === binding.oldValue
let oldHasPermission = hasPermission(binding.oldValue)
let newHasPermission = hasPermission(binding.value)
let permissionNotChange = oldHasPermission === newHasPermission
if (valueNotChange && permissionNotChange) return
if (newHasPermission) {
addEl(el)
} else {
removeEl(el)
}
};
if (el._watchEffect) {
update()
} else {
el._watchEffect = watchEffect(() => {
update()
})
}
},
})

updated钩子里更新的逻辑提取成一个update方法,然后第一次更新在watchEffect中执行,这样用户权限的响应式数据就可以和update方法关联起来,后续用户权限数据改变了,可以自动触发update方法的重新运行。


好了,深入完了,看着似乎也挺简单的,我不确定这些是不是面试官想要的,或者还有其他更高级更优雅的实现呢,知道的朋友能否指点

作者:街角小林
来源:juejin.cn/post/7209648356530896953
一二,在下感激不尽。

收起阅读 »

canvas绘制行星环绕

web
前言 最近学校学了一些JavaScript课程,其中涉及到了部分有关于canvas的知识点,万万没想到老师只是用了一节课提了一下有关canvas的一些有关使用就布置下来了一个作业--采用canvas绘制一个简易太阳系,咱作为学生还能说啥,只能冲啦。 实现原理 ...
继续阅读 »
太阳与月亮.gif

前言


最近学校学了一些JavaScript课程,其中涉及到了部分有关于canvas的知识点,万万没想到老师只是用了一节课提了一下有关canvas的一些有关使用就布置下来了一个作业--采用canvas绘制一个简易太阳系,咱作为学生还能说啥,只能冲啦。


实现原理


只是单纯的canvas方法的使用再加上一点点js的使用就可以实现这个简单的实例啦。


实现代码


html部分


<!-- 画布元素 -->
<canvas id="canvas"></canvas>

初始化画布

js获取画布元素,初始化画布背景色为黑色,设置画布真实绘制宽高为1200,浏览器呈现宽高为600px,getContext('2d')获取画布的2D上下文。


let canvas = document.getElementById('canvas')
canvas.style.background = 'black'
// 浏览器渲染出画布宽高
canvas.style.width = 600 + 'px'
canvas.style.height = 600 + 'px'
// 绘制画布真实宽高
canvas.width = 1200
canvas.height = 1200
let context = canvas.getContext('2d');

绘制太阳

绘制一个圆心为(600,600)半径为100的圆,在绘制前有几点要了解,因为canvas只支持两种形式的图形绘制:矩形和路径(由一系列点连成的线段),所以我们要使用到路径绘制函数。其中beginPath()新建一条路径,在该路径闭合前,图像绘制将在该路径中进行,其中fillSyle设置的是图像填充色,通常以closePath()闭合该路径,但由于fill()会自动闭合路径所以closePath()可以省去。详情可以参考MDN|Canvas


context.beginPath() // 开始路径绘制
context.arc(600, 600, 100, 0, Math.PI*2, true)
context.fillStyle = 'red' // 图形填充色
context.fill() // 进行填充

绘制地球轨道

与上面太阳的绘制相差不大,将填充换为了描边。strokeStyle定义图形轮廓颜色,stroke()开始绘制轮廓,最后采用closePath()闭合路径。


context.beginPath()
context.arc(600, 600, 300, 0, Math.PI*2, true) // 圆心(300,300) 半径为150的圆环
context.strokeStyle = 'rgb(255,255,255,0.3)'
context.stroke()
context.closePath()

绘制地球

注意: 这里地球的圆心坐标为(0,0)这是因为我们调用了translate()这一函数,通过这一函数我们将起始点偏移到指定位置,下文将以此坐标为新的起始点。此外需要用save()保存当前画布状态,不然后续循环会出问题。再调用rotate()方法实现旋转,其中rotate()是使得其下文绘制的图形实现旋转,旋转中心为当前起始点坐标。


context.save(); // 保存当前状态

var angle=time*Math.PI/180/8;
context.translate(600,600); // 起始点偏移量,太阳中心
context.rotate(angle);

context.translate(300,0); // 地球,月球轨道中心
context.beginPath()
context.arc(0,0,40,0,2*Math.PI,false);
context.fillStyle = 'blue'
context.strokeStyle = 'blue'
context.fill()

月球轨道及月球


// 月球轨道
context.beginPath()
context.arc(0, 0, 100, 0, Math.PI*2, true)
context.strokeStyle = 'rgb(255,255,255,0.3)'
context.stroke()
context.closePath()

context.rotate(-8*angle);

// 月球
context.beginPath()
context.arc(100,0,20,0,2*Math.PI,false);
context.fillStyle = '#fff'
context.fill()

js完整部分

定义一个绘制函数draw(),通过setInterval()函数循环调用,其中要注意在使用save()函数后要调用restore()函数恢复状态,为下次的绘制做准备。


let canvas = document.getElementById('canvas')
canvas.style.background = 'black'
// 浏览器渲染出画布宽高
canvas.style.width = 600 + 'px'
canvas.style.height = 600 + 'px'
// 绘制画布真实宽高
canvas.width = 1200
canvas.height = 1200
let context = canvas.getContext('2d');
// context.scale(2, 2)

let time = 0
function draw() {
context.clearRect(0,0,canvas.width,canvas.height); // 清除所选区域
// 绘制太阳
context.beginPath() // 开始路径绘制
context.arc(600, 600, 100, 0, Math.PI*2, true)
context.fillStyle = 'red' // 图形填充色
context.fill() // 进行填充
// 绘制地球轨道
context.beginPath()
context.arc(600, 600, 300, 0, Math.PI*2, true) // 圆心(300,300) 半径为150的圆环
context.strokeStyle = 'rgb(255,255,255,0.3)'
context.stroke()
context.closePath()

context.save(); // 保存当前状态

var angle=time*Math.PI/180/8;
context.translate(600,600); // 起始点偏移量,太阳中心
context.rotate(angle);

context.translate(300,0); // 地球,月球轨道中心
// 地球
context.beginPath()
context.arc(0,0,40,0,2*Math.PI,false);
context.fillStyle = 'blue'
context.strokeStyle = 'blue'
context.fill()

// 月球轨道
context.beginPath()
context.arc(0, 0, 100, 0, Math.PI*2, true)
context.strokeStyle = 'rgb(255,255,255,0.3)'
context.stroke()
context.closePath()

context.rotate(-8*angle);

// 月球
context.beginPath()
context.arc(100,0,20,0,2*Math.PI,false);
context.fillStyle = '#fff'
context.fill()

context.restore(); // 恢复状态
time++
}
setInterval(draw,30)


结语


以上过程便能简单的绘制一个简易太阳系图形动画了,通过文档就能快速的绘制一个简单的图形,但是要绘制复杂的图形的话还是要花时间去研究一下文档。


作者:codePanda
来源:juejin.cn/post/7212442380263112760
收起阅读 »

如何进行图片压缩

web
前言 最近要搞图像处理服务,其中一个是要实现图片压缩功能。以前前端开发的时候只要利用canvas现成的API处理下就能实现,后端可能也有现成的API但我并不知道。仔细想想,我从来没有详细了解过图片压缩原理,那刚好趁这次去调研学习下,所以有了这篇文章来记录。老样...
继续阅读 »

前言


最近要搞图像处理服务,其中一个是要实现图片压缩功能。以前前端开发的时候只要利用canvas现成的API处理下就能实现,后端可能也有现成的API但我并不知道。仔细想想,我从来没有详细了解过图片压缩原理,那刚好趁这次去调研学习下,所以有了这篇文章来记录。老样子,如有不对的地方,DDDD(带带弟弟)。


我们先把图片上传到后端,看看后端接收了什么样的参数。这里后端我用的是Node.js(Nest),图片我以PNG图片为例。


接口和参数打印如下:


@Post('/compression')
@UseInterceptors(FileInterceptor('file'))
async imageCompression(@UploadedFile() file: Express.Multer.File) {

return {
file
}
}


要进行压缩,我们就需要拿到图像数据。可以看到,唯一能藏匿图像数据的就是这串buffer。那这串buffer描述了什么,就需要先弄清什么是PNG。


PNG


这里是PNG的WIKI地址。


阅读之后,我了解到PNG是由一个8 byte的文件头加上多个的块(chunk)组成。示意图如下:



其中:


文件头是由一个被称为magic number的组成。值为 89 50 4e 47 0d 0a 1a 0a(16进制)。它标记了这串数据是PNG格式。


块分为两种,一种叫关键块(Critical chunks),一种叫辅助块(Ancillary chunks)。关键块是必不可少的,没有关键块,解码器将不能正确识别并展示图片。辅助块是可选的,部分软件在处理图片之后就有可能携带辅助块。每个块都是四部分组成:4 byte 描述这个块的内容有多长,4 byte 描述这个块的类型是什么,n byte 描述块的内容(n 就是前面4 byte 值的大小,也就是说,一个块最大长度为28*4),4 byte CRC校验检查块的数据,标记着一个块的结束。其中,块类型的4 byte 的值为4个acsii码,第一个字母大写表示是关键块小写表示是辅助块;第二个字母大写表示是公有小写表示是私有;第三个字母必须是大写,用于PNG后续的扩展;第四个字母表示该块不识别时,能否安全复制,大写表示未修改关键块时才能安全复制,小写表示都能安全复制。PNG官方提供很多定义的块类型,这里只需要知道关键块的类型即可,分别是IHDR,PLTE,IDAT,IEND。


IHDR


PNG要求第一个块必须是IHDR。IHDR的块内容是固定的13 byte,包含了图片的以下信息:


宽度 width (4 byte) & 高度 height (4 byte)


位深 bit depth (1 byte,值为1,2,4,8或者16) & 颜色类型 color type (1 byte,值为0,2,3,4或者6)


压缩方法 compression method (1 byte,值为0) & 过滤方式 filter method (1 byte,值为0)


交错方式 interlace method (1 byte,值为0或者1)


宽度和高度很容易理解,剩下的几个好像都很陌生,接下来我将进行说明。


在说明位深之前,我们先来看颜色类型,颜色类型有5种值:




  • 0 表示灰度(grayscale)它只有一个通道(channel),看成rgb的话,可以理解它的三色通道值是相等的,所以不需要多余两个通道表示。




  • 2 表示真实色彩(rgb)它有三个通道,分别是R(红色),G(绿色),B(蓝色)。




  • 3 表示颜色索引(indexed)它也只有一个通道,表示颜色的索引值。该类型往往配备一组颜色列表,具体的颜色是根据索引值和颜色列表查询得到的。




  • 4 表示灰度和alpha 它有两个通道,除了灰度的通道外,多了一个alpha通道,可以控制透明度。




  • 6 表示真实色彩和alpha 它有四个通道。




之所以要说到通道,是因为它和这里的位深有关。位深的值就定义了每个通道所占的位数(bit)。位深跟颜色类型组合,就能知道图片的颜色格式类型和每个像素所占的内存大小。PNG官方支持的组合如下表:


2023-03-17_180115.png


过滤和压缩是因为PNG中存储的不是图像的原始数据,而是处理后的数据,这也是为什么PNG图片所占内存较小的原因。PNG使用了两步进行了图片数据的压缩转换。


第一步,过滤。过滤的目的是为了让原始图片数据经过该规则后,能进行更大的压缩比。举个例子,如果有一张渐变图片,从左往右,颜色依次为[#000000, #000001, #000002, ..., #ffffff],那么我们就可以约定一条规则,右边的像素总是和它前一个左边的像素进行比较,那么处理完的数据就变成了[1, 1, 1, ..., 1],这样是不是就能进行更好的压缩。PNG目前只有一种过滤方式,就是基于相邻像素作为预测值,用当前像素减去预测值。过滤的类型一共有五种,(目前我还不知道这个类型值在哪里存储,有可能在IDAT里,找到了再来删除这条括号里的已确定该类型值储存在IDAT数据中)如下表所示:


Type byteFilter namePredicted value
0不做任何处理
1Sub左侧相邻像素
2Up上方相邻像素
3AverageMath.floor((左侧相邻像素 + 上方相邻像素) / 2)
4Paeth取(左侧相邻像素 + 上方相邻像素 - 左上方像素)最接近的值

第二步,压缩。PNG也只有一种压缩算法,使用的是DEFLATE算法。这里不细说,具体看下面的章节。


交错方式,有两种值。0表示不处理,1表示使用Adam7 算法进行处理。我没有去详细了解该算法,简单来说,当值为0时,图片需要所有数据都加载完毕时,图片才会显示。而值为1时,Adam7会把图片划分多个区域,每个区域逐级加载,显示效果会有所优化,但通常会降低压缩效率。加载过程可以看下面这张gif图。



PLTE


PLTE的块内容为一组颜色列表,当颜色类型为颜色索引时需要配置。值得注意的是,颜色列表中的颜色一定是每个通道8bit,每个像素24bit的真实色彩列表。列表的长度,可以比位深约定的少,但不能多。比如位深是2,那么22,最多4种颜色,列表长度可以为3,但不能为5。


IDAT


IDAT的块内容是图片原始数据经过PNG压缩转换后的数据,它可能有多个重复的块,但必须是连续的,并且只有当上一个块填充满时,才会有下一个块。


IEND


IEND的块内容为0 byte,它表示图片的结束。


阅读到这里,我们把上面的接口改造一下,解析这串buffer。


@Post('/compression')
@UseInterceptors(FileInterceptor('file'))
async imageCompression(@UploadedFile() file: Express.Multer.File) {
const buffer = file.buffer;

const result = {
header: buffer.subarray(0, 8).toString('hex'),
chunks: [],
size: file.size,
};

let pointer = 8;
while (pointer < buffer.length) {
let chunk = {};
const length = parseInt(buffer.subarray(pointer, pointer + 4).toString('hex'), 16);
const chunkType = buffer.subarray(pointer + 4, pointer + 8).toString('ascii');
const crc = buffer.subarray(pointer + length, pointer + length + 4).toString('hex');
chunk = {
...chunk,
length,
chunkType,
crc,
};

switch (chunkType) {
case 'IHDR':
const width = parseInt(buffer.subarray(pointer + 8, pointer + 12).toString('hex'), 16);
const height = parseInt(buffer.subarray(pointer + 12, pointer + 16).toString('hex'), 16);
const bitDepth = parseInt(
buffer.subarray(pointer + 16, pointer + 17).toString('hex'),
16,
);
const colorType = parseInt(
buffer.subarray(pointer + 17, pointer + 18).toString('hex'),
16,
);
const compressionMethod = parseInt(
buffer.subarray(pointer + 18, pointer + 19).toString('hex'),
16,
);
const filterMethod = parseInt(
buffer.subarray(pointer + 19, pointer + 20).toString('hex'),
16,
);
const interlaceMethod = parseInt(
buffer.subarray(pointer + 20, pointer + 21).toString('hex'),
16,
);

chunk = {
...chunk,
width,
height,
bitDepth,
colorType,
compressionMethod,
filterMethod,
interlaceMethod,
};
break;
case 'PLTE':
const colorList = [];
const colorListStr = buffer.subarray(pointer + 8, pointer + 8 + length).toString('hex');
for (let i = 0; i < colorListStr.length; i += 6) {
colorList.push(colorListStr.slice(i, i + 6));
}
chunk = {
...chunk,
colorList,
};
break;
default:
break;
}
result.chunks.push(chunk);
pointer = pointer + 4 + 4 + length + 4;
}

return result;
}


这里我测试用的图没有PLTE,刚好我去TinyPNG压缩我那张测试图之后进行上传,发现有PLTE块,可以看一下,结果如下图。



通过比对这两张图,压缩图片的方式我们也能窥探一二。


PNG的压缩


前面说过,PNG使用的是一种叫DEFLATE的无损压缩算法,它是Huffman Coding跟LZ77的结合。除了PNG,我们经常使用的压缩文件,.zip,.gzip也是使用的这种算法(7zip算法有更高的压缩比,也可以了解下)。要了解DEFLATE,我们首先要了解Huffman Coding和LZ77。


Huffman Coding


哈夫曼编码忘记在大学的哪门课接触过了,它是一种根据字符出现频率,用最少的字符替换出现频率最高的字符,最终降低平均字符长度的算法。


举个例子,有字符串"ABCBCABABADA",如果按照正常空间存储,所占内存大小为12 * 8bit = 96bit,现对它进行哈夫曼编码。


1.统计每个字符出现的频率,得到A 5次 B 4次 C 2次 D 1次


2.对字符按照频率从小到大排序,将得到一个队列D1,C2,B4,A5


3.按顺序构造哈夫曼树,先构造一个空节点,最小频率的字符分给该节点的左侧,倒数第二频率的字符分给右侧,然后将频率相加的值赋值给该节点。接着用赋值后节点的值和倒数第三频率的字符进行比较,较小的值总是分配在左侧,较大的值总是分配在右侧,依次类推,直到队列结束,最后把最大频率和前面的所有值相加赋值给根节点,得到一棵完整的哈夫曼树。


4.对每条路径进行赋值,左侧路径赋值为0,右侧路径赋值为1。从根节点到叶子节点,进行遍历,遍历的结果就是该字符编码后的二进制表示,得到:A(0)B(11)C(101)D(100)。


完整的哈夫曼树如下(忽略箭头,没找到连线- -!):



压缩后的字符串,所占内存大小为5 * 1bit + 4 * 2bit + 2 * 3bit + 1 * 3bit = 22bit。当然在实际传输过程中,还需要把编码表的信息(原始字符和出现频率)带上。因此最终占比大小为 4 * 8bit + 4 * 3bit(频率最大值为5,3bit可以表示)+ 22bit = 66bit(理想状态),小于原有的96bit。


LZ77


LZ77算法还是第一次知道,查了一下是一种基于字典和滑动窗的无所压缩算法。(题外话:因为Lempel和Ziv在1977年提出的算法,所以叫LZ77,哈哈哈😂)


我们还是以上面这个字符串"ABCBCABABADA"为例,现假设有一个4 byte的动态窗口和一个2byte的预读缓冲区,然后对它进行LZ77算法压缩,过程顺序从上往下,示意图如下:



总结下来,就是预读缓冲区在动态窗口中找到最长相同项,然后用长度较短的标记来替代这个相同项,从而实现压缩。从上图也可以看出,压缩比跟动态窗口的大小,预读缓冲区的大小和被压缩数据的重复度有关。


DEFLATE


DEFLATE【RFC 1951】是先使用LZ77编码,对编码后的结果在进行哈夫曼编码。我们这里不去讨论具体的实现方法,直接使用其推荐库Zlib,刚好Node.js内置了对Zlib的支持。接下来我们继续改造上面那个接口,如下:


import * as zlib from 'zlib';

@Post('/compression')
@UseInterceptors(FileInterceptor('file'))
async imageCompression(@UploadedFile() file: Express.Multer.File) {
const buffer = file.buffer;

const result = {
header: buffer.subarray(0, 8).toString('hex'),
chunks: [],
size: file.size,
};

// 因为可能有多个IDAT的块 需要个数组缓存最后拼接起来
const fileChunkDatas = [];
let pointer = 8;
while (pointer < buffer.length) {
let chunk = {};
const length = parseInt(buffer.subarray(pointer, pointer + 4).toString('hex'), 16);
const chunkType = buffer.subarray(pointer + 4, pointer + 8).toString('ascii');
const crc = buffer.subarray(pointer + length, pointer + length + 4).toString('hex');
chunk = {
...chunk,
length,
chunkType,
crc,
};

switch (chunkType) {
case 'IHDR':
const width = parseInt(buffer.subarray(pointer + 8, pointer + 12).toString('hex'), 16);
const height = parseInt(buffer.subarray(pointer + 12, pointer + 16).toString('hex'), 16);
const bitDepth = parseInt(
buffer.subarray(pointer + 16, pointer + 17).toString('hex'),
16,
);
const colorType = parseInt(
buffer.subarray(pointer + 17, pointer + 18).toString('hex'),
16,
);
const compressionMethod = parseInt(
buffer.subarray(pointer + 18, pointer + 19).toString('hex'),
16,
);
const filterMethod = parseInt(
buffer.subarray(pointer + 19, pointer + 20).toString('hex'),
16,
);
const interlaceMethod = parseInt(
buffer.subarray(pointer + 20, pointer + 21).toString('hex'),
16,
);

chunk = {
...chunk,
width,
height,
bitDepth,
colorType,
compressionMethod,
filterMethod,
interlaceMethod,
};
break;
case 'PLTE':
const colorList = [];
const colorListStr = buffer.subarray(pointer + 8, pointer + 8 + length).toString('hex');
for (let i = 0; i < colorListStr.length; i += 6) {
colorList.push(colorListStr.slice(i, i + 6));
}
chunk = {
...chunk,
colorList,
};
break;
case 'IDAT':
fileChunkDatas.push(buffer.subarray(pointer + 8, pointer + 8 + length));
break;
default:
break;
}
result.chunks.push(chunk);
pointer = pointer + 4 + 4 + length + 4;
}

const originFileData = zlib.unzipSync(Buffer.concat(fileChunkDatas));

// 这里原图片数据太长了 我就只打印了长度
return {
...result,
originFileData: originFileData.length,
};
}


最终打印的结果,我们需要注意红框的那几个部分。可以看到上图,位深和颜色类型决定了每个像素由4 byte组成,然后由于过滤方式的存在,会在每行的第一个字节进行标记。因此该图的原始数据所占大小为:707 * 475 * 4 byte + 475 * 1 byte = 1343775 byte。正好是我们打印的结果。


我们也可以试试之前TinyPNG压缩后的图,如下:



可以看到位深为8,索引颜色类型的图每像素占1 byte。计算得到:707 * 475 * 1 byte + 475 * 1 byte = 336300 byte。结果也正确。


总结


现在再看如何进行图片压缩,你可能很容易得到下面几个结论:


1.减少不必要的辅助块信息,因为辅助块对PNG图片而言并不是必须的。


2.减少IDAT的块数,因为每多一个IDAT的块,就多余了12 byte。


3.降低每个像素所占的内存大小,比如当前是4通道8位深的图片,可以统计整个图片色域,得到色阶表,设置索引颜色类型,降低通道从而降低每个像素的内存大小。


4.等等....


至于JPEG,WEBP等等格式图片,有机会再看。溜了溜了~(还是使用现成的库处理压缩吧)。


好久没写文章,写完才发现语雀不能免费共享,发在这里吧。


作者:月下风物语
来源:juejin.cn/post/7211434247146782775
收起阅读 »

摸鱼两天,彻底拿下虚拟滚动!

web
总结 通过自己的实践发现,网上相传的虚拟滚动实现方案有种是行不通的(涉及浏览器机制) 实现虚拟滚动,滚动元素中利用上下两个只有高度的空盒子撑开空间是不可行的 html布局示意: <div class="content-container"> ...
继续阅读 »

总结


通过自己的实践发现,网上相传的虚拟滚动实现方案有种是行不通的(涉及浏览器机制)




  • 实现虚拟滚动,滚动元素中利用上下两个只有高度的空盒子撑开空间是不可行的


    html布局示意


    <div class="content-container">
     <div class="top-padding"></div>

     <div class="content-item"></div>
     <div class="content-item"></div>
     <div class="content-item"></div>

     <div class="bottom-padding"></div>
    </div>



  • 可行方案:


    html布局示意


    <div class="scroll-container">
     <div class="content-container">
       <div class="content-item"></div>
      ...
       <div class="content-item"></div>
     </div>
    </div>



如果您和我一样,想自己实现一下虚拟滚动,下面 实现虚拟滚动 部分 中我会尽可能保姆级详细的复现我当时写代码的所有过程(包括建项目...),适合新手(但是不能是小白,需要知道虚拟滚动是干啥的东西,因为我没有去介绍虚拟滚动)。


如果您对这玩意的实现完全没啥好奇的,可以看看 部分,我详细记录了一个关于浏览器滚动条的特点,或许对你来说有点意思。


实现虚拟滚动


下面用vue3写一个demo,并没封装多完善,也不是啥生产可用的东西,但绝对让你清晰虚拟滚动的实现思路。


项目搭建


pnpm create vite创建一个项目,项目名、包名输入virtualScrollDemo,选择技术栈Vue + TypeScript;再简单安装个less,即pnpm install less less-loader -D,然后配一下vite.config.ts,顺便给src配个别名。


vite.config.ts


import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path"; // 让ts识别模块,这里还需要 pnpm i @types/node

// https://vitejs.dev/config/
export default defineConfig({
 plugins: [vue()],
 css: {
   preprocessorOptions: {
     less: {
    },
  },
},
 resolve: {
   alias: [
    {
       find: "@",
       replacement: resolve(__dirname, "/src"),
    },
  ],
},
});

App.vueimport VirtualScroll from '@/components/VirtualScroll.vue'还是报错,ts还要配置别名才行,tsconfig.json中加一下baseUrlpaths即可


tsconfig.json


{
 "compilerOptions": {
   "target": "ESNext",
   "useDefineForClassFields": true,
   "module": "ESNext",
   "moduleResolution": "Node",
   "strict": true,
   "jsx": "preserve",
   "resolveJsonModule": true,
   "isolatedModules": true,
   "esModuleInterop": true,
   "lib": ["ESNext", "DOM"],
   "skipLibCheck": true,
   "noEmit": true,
   "baseUrl": "./",
   "paths": {
     "@/*": ["src/*"]
  }
},
 "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
 "references": [{ "path": "./tsconfig.node.json" }]
}

然后项目删一删没用的就成了这样:


src/
├── App.vue
├── components/
│   └── VirtualScroll.vue
└── shared/
  └── dataConstant.ts

dataConstant.ts是准备的一个长列表渲染的数据源:


export const dataSource = [
{
   text: "jrd",
},
{
   text: "jrd1",
},
 ...
]

结构搭建


为了突出重点,实现虚拟滚动逻辑必要的样式我都写在:style中了,辅助性的样式都写在<style></style>


先把长列表搭建出来:


基本长列表.gif


<template>
 <div
   class="scroll-container"
   :style="{
     overflow: 'auto',
     height: `${viewPortHeight}px` // 列表视口高度(值自定义即可)
   }"

 >

 <div
   class="content-container"
   :style="{
     height: `${itemHeight * dataSource.length}px`
   }"

 >

   <div
     class="content-item"
     v-for="(data, index) in dataSource"
   >

     {{ data.text }}
   </div>
 </div>
</div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { dataSource } from "@/shared/dataConstant";

export default defineComponent({
 name: "VirtualScroll",
 setup() {
   const viewPortHeight = 500; // 滚动列表的可视高度
   const itemHeight = 50; // 一个列表项的高度
   return {
     viewPortHeight,
     dataSource,
     itemHeight
  }
},
});
</script>

<style scoped lang="less">
.scroll-container {
 border: 2px solid red;
 width: 300px;
 .content-container {
   .content-item {
     height: 50px;
     background-image: linear-gradient(0deg, pink, blue);
  }
}
}

</style>

注释:


html结构三层嵌套,最外层是div.scroll-container,里面依次是div.content-containerdiv.content-item


div.scroll-container容器是出现滚动条的容器,所以它需要一个固定高度(可视区域的高度)以及overflow: auto,这样他内部元素超过了它的高度它才会出现滚动条;div.content-container的作用就是撑开div.scroll-container,解释一下,因为我们最终要的效果是只渲染一小部分元素,单单渲染的这一小部分内容肯定是撑不开div.scroll-container的,所以根据渲染项的多少以及每个渲染项的高度写死div.content-container的高度,不管渲染项目多少,始终保持div.scroll-containerscrollHeight正常。


核心计算


监听div.scroll-container的滚动事件,滚动回调中计算startIndexendIndex,截取数据源(截取要渲染的一小部分数据,即renderDataList = dataSource.slice(startIndex, endIndex)):


计算startIndex和endIndex.gif


<template>
 <div
   class="scroll-container"
   :style="{
     overflow: 'auto',
     height: `${viewPortHeight}px` // 列表视口高度(值自定义即可)
   }"

   ref="scrollContainer"
   @scroll="handleScroll"
 >

 <div
   class="content-container"
   :style="{
     height: `${itemHeight * dataSource.length}px`
   }"

 >

   <div
     class="content-item"
     v-for="(data, index) in dataSource"
   >

     {{ data.text }}
   </div>
 </div>
</div>

</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
import { dataSource } from "@/shared/dataConstant";

export default defineComponent({
 name: "VirtualScroll",
 setup() {
   const viewPortHeight = 525; // 滚动列表的可视高度
   const itemHeight = 50; // 一个列表项的高度
   const startIndex = ref(0);
   const endIndex = ref(0);
   const scrollContainer = ref<HTMLElement | null>(null);
   const handleScroll = () => {
     if(!scrollContainer.value) return
     const scrollTop = scrollContainer.value.scrollTop;
     startIndex.value = Math.floor(scrollTop / itemHeight);
     endIndex.value = Math.ceil((scrollTop + viewPortHeight) / itemHeight) - 1;
     console.log(startIndex.value, endIndex.value);
  }
   return {
     viewPortHeight,
     dataSource,
     itemHeight,
     scrollContainer,
     handleScroll
  }
},
});
</script>

<style scoped lang="less">
.scroll-container {
 border: 2px solid red;
 width: 300px;
 .content-container {
   .content-item {
     height: 50px;
     background-image: linear-gradient(0deg, pink, blue);
  }
}
}

</style>

注释:


startIndexendIndex我们都按照从0开始(而非1开始)的标准来计算。 startIndex对应div.scroll-container上边界压住的div.content-itemindexendIndex对应div.scroll-container下边界压住的div.content-itemindex,也就是说,startIndexendIndex范围内的数据,是我们在保证可视区域不空白的前提下至少要进行渲染的数据,我可能表述不很清楚,静心想一想不难理解的。


收尾


最后的两步就是根据startIndexendIndexdataSource中动态截取出来renderDataListv-for只渲染renderDataList,然后把渲染出来的div.content-item通过定位 + transform移动到正确的位置即可了。


监听startIndexendIndex,变化时修改renderDataList


逻辑:


// 因为slice函数是左闭右开,所以截取时为endIndex.value + 1
const renderDataList = ref(dataSource.slice(startIndex.value, endIndex.value + 1));
watch(() => startIndex.value, () => {
renderDataList.value = dataSource.slice(startIndex.value, endIndex.value + 1);
})
watch(() => endIndex.value, () => {
renderDataList.value = dataSource.slice(startIndex.value, endIndex.value + 1);
})

结构:


<div 
class="content-item"
v-for="(data, index) in renderDataList"
>
{{ data.text }}
</div>

这时候,数据已经正确渲染了,只是位置还不太对


效果:


数据结构正确渲染.gif


我们要做的就是通过css把渲染出来的dom移动到正确的位置,这里采取的方案就是div.content-container相对定位,div.content-item绝对定位,并且topleft都设置为0(所有都移动到左上角),然后通过translate: transformY把它们移动到“正确”的位置:


结构:


<div 
class="content-item"
v-for="(data, index) in renderDataList"
:style="{
position: 'absolute',
top: '0',
left: '0',
transform: `translateY(${(startIndex + index) * itemHeight}px)`
}"
>
{{ data.text }}
</div>

经过上面的修改之后已经基本收工了,不知道是哪个样式的原因div.content-item的宽度不是100%了,手动加上就好了


效果:


虚拟滚动大功告成.gif


优化



  1. 给滚动事件添加节流

  2. 引入缓冲结点数变量countOfBufferItem,适当扩充(startIndex, endIndex)渲染区间,防止滑动过快出现空白


最终代码:


<template>
<div
class="scroll-container"
:style="{
overflow: 'auto',
height: `${viewPortHeight}px` // 列表视口高度(值自定义即可)
}"

ref="scrollContainer"
@scroll="handleScroll"
>

<div
class="content-container"
:style="{
height: `${itemHeight * dataSource.length}px`,
position: 'relative'
}"

>

<div
class="content-item"
v-for="(data, index) in renderDataList"
:style="{
position: 'absolute',
top: '0',
left: '0',
transform: `translateY(${(startIndex + index) * itemHeight}px)`
}"

>

{{ data.text }}
</div>
</div>
</div>

</template>

<script lang="ts">
import { defineComponent, ref, watch } from "vue";
import { dataSource } from "@/shared/dataConstant";

export default defineComponent({
name: "VirtualScroll",
setup() {
const viewPortHeight = 525; // 滚动列表的可视高度
const itemHeight = 50; // 一个列表项的高度
const startIndex = ref(0);
const endIndex = ref(Math.ceil(viewPortHeight / itemHeight) - 1);
const scrollContainer = ref<HTMLElement | null>(null);

let isHandling = false; // 节流辅助变量
const countOfBufferItem = 2; // 缓冲结点数量
const handleScroll = () => {
if(isHandling) return;
isHandling = true;
setTimeout(() => {
if(!scrollContainer.value) return
const scrollTop = scrollContainer.value.scrollTop;
startIndex.value = Math.floor(scrollTop / itemHeight);
startIndex.value = startIndex.value - countOfBufferItem >= 0 ? startIndex.value - countOfBufferItem : 0; // 扩充渲染区间
endIndex.value = Math.ceil((scrollTop + viewPortHeight) / itemHeight) - 1;
endIndex.value = endIndex.value + countOfBufferItem >= dataSource.length - 1 ? dataSource.length - 1 : endIndex.value + countOfBufferItem; // 扩充渲染区间
isHandling = false;
}, 30)
}

const renderDataList = ref(dataSource.slice(startIndex.value, endIndex.value + 1));
watch(() => startIndex.value, () => {
renderDataList.value = dataSource.slice(startIndex.value, endIndex.value + 1);
})
watch(() => endIndex.value, () => {
renderDataList.value = dataSource.slice(startIndex.value, endIndex.value + 1);
})
return {
viewPortHeight,
dataSource,
itemHeight,
scrollContainer,
handleScroll,
renderDataList,
startIndex,
endIndex
}
},
});
</script>

<style scoped lang="less">
.scroll-container {
border: 2px solid red;
width: 300px;
.content-container {
.content-item {
height: 50px;
background-image: linear-gradient(0deg, pink, blue);
width: 100%;
}
}
}

</style>

虽说没啥bug吧,但是滚动的快了还是有空白啥的,这应该也算是这个技术方案的瓶颈。



bug复现


我一开始的思路是一个外层div.container,设置overflow: hidden,然后内部上中下三部分,上面一个空盒子,高度为startIndex * listItemHeight;中间部分为v-for渲染的列表,下面又是一个空盒子,高度(dataSource.length - endIndex - 1) * listItemHeight,总之三部分的总高度始终维持一个定值,即这个值等于所有数据完全渲染时div.containerscrollHeight


实现之后,问题出现了:


不受控制的滚动.gif


一旦触发了“机关”,滚动条就会不受控制的滚动到底


我把滚动回调的节流时间设置长为500ms


不受控制的滚动-长节流.gif


发现滚动条似乎陷入了一种循环之中,每次向下移动一个数据块的高度。 分析这个现象,需要下面一些关于滚动条特性的认知。


滚动条的特性


先给结论:当一个定高(scrollHeight固定)的滚动元素,其(撑开其高度的)子元素高度发生变化时(高度组成发生变化,比如一个变高,一个变低,但保持滚动元素的scrollHeight总高度不变),滚动条位置也会发生变化,变化遵循一个原则:保持当前可视区域展示的元素在可视区域内位置不变。


写个demo模拟一下上面说的场景,div.container是一个滚动且定高的父元素,点击按钮后其内部的div.top变高,div.bottom变矮


Test.vue:


<template>
<div class="container" ref="container">
<div
class="top"
:style="{
height: `${topHeight}px`,
}"

>
</div>
<div class="content"></div>
<div
class="bottom"
:style="{
height: `${bottomHeight}px`,
}"

>
</div>
</div>

<button @click="test">按钮</button>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";

export default defineComponent({
setup() {
const topHeight = ref(300);
const bottomHeight = ref(300);
const container = ref(null);
const test = () => {
topHeight.value += 50;
bottomHeight.value -= 50;
};
return {
topHeight,
bottomHeight,
test,
container,
};
},
});
</script>


<style scoped lang="less">
.container {
width: 200px;
height: 600px;
overflow: auto;
border: 1px solid green;
.top {
width: 100%;
border: 3px solid red;
}
.content {
height: 1000px;
}
.bottom {
width: 100%;
border: 3px solid black;
}
}
</style>


滚动条位置变化demo展示


仔细观察滚动条:


滚动条位置变化demo.gif


解释一下上图,首先是上面一个红色盒子,底部一个黑色盒子:



  • 我们可视区域的左上角在红色区域时点击按钮,这时候浏览器底层判断我们正在浏览红色元素,所以虽然内部元素高度变化,但我们的可视区域相对于红色盒子左上角的位置不变

  • 第一次刷新之后,我们可视区域的左上角在中间盒子上,这时候我们点击按钮,红色盒子高度增加,黑色盒子高度减小,中间盒子的相对整个滚动区域的位置就靠下了,但是——浏览器的滚动条也随之向下移动了(而且,滚动条向下移动的距离 === 红色盒子高度增加值 === 黑色盒子高度减小值 === 中间盒子相对滚动区域向下偏移值

  • 第二次刷新后,更直观的表现了滚动条的这个特点:我把滚动条恰好移动到中间盒子上,上面紧邻红色盒子,点击三次按钮后,滚动条下移三次,此时我向上滚动一点,接着看到了红色盒子。


bug原因分析


有了上面的认知,再来看这个图


不受控制的滚动-长节流.gif


bug的“生命周期”:


1.我们手动向下滚动滚动条 ——> 2.内部计算(startIndex以及endIndex的改变)触发上方占位的<div>元素高度增加,下方占位<div>高度减小,中间渲染的内容部分整体位置相对于整个滚动元素下移 ——> 3.(浏览器为了保持当前可视区域展示的元素在可视区域内位置不变)滚动条自动下移 ——> 4.触发新的计算 ——> 2.


感慨:上中下三个部分,上下动态修改高度占位,中间部分渲染数据,思路多么清晰的方案,但谁能想到浏览器滚动条出来加了道菜呢


网上不少地方都给了这个方案...


成功的虚拟滚动、带bug的虚拟滚动和测试组件的源码我都放到这里了,需要的话可以去clone:github.com/jinrd123/Vi…(带bug的虚拟滚动是我第一次实现时随性写的,代码组织以及注释可能不是很规范)


作者:荣达
来源:juejin.cn/post/7211088034179366973
收起阅读 »

来看看这个很酷的按钮交互效果

web
今天分享一个很有特色的按钮交互效果,如封面图所示,保证让你停不下来,原作者是Adam Kuhn,有兴趣的可以去codepen体验,地址:codepen,本文将核心功能逐一讲解。 基于这个动图可以将主要实现的几个功能点拆分为以下几点: 按钮的径向渐变背景色可以...
继续阅读 »

今天分享一个很有特色的按钮交互效果,如封面图所示,保证让你停不下来,原作者是Adam Kuhn,有兴趣的可以去codepen体验,地址:codepen,本文将核心功能逐一讲解。


基于这个动图可以将主要实现的几个功能点拆分为以下几点:



  • 按钮的径向渐变背景色可以随着鼠标的移动变化

  • 按钮的背景区域会随着鼠标的移动产生弹性变化效果

  • 按钮的文字阴影会随着鼠标的变化而变化


鼠标位置获取


在正式开始前做一些准备工作,分析主要的这几个功能点可以发现每个功能都和鼠标的移动有关,都需要借助于鼠标移动的坐标,所以我们首先获取鼠标的位置并传递到css中,代码如下:


document.querySelectorAll(".inner").forEach((button) => {
button.onmousemove = (e) => {
const target = e.target;
const rect = target.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;

button.style.setProperty("--x", `${x}px`);
button.style.setProperty("--y", `${y}px`);
button.style.setProperty("--height", `${rect.height}px`);
button.style.setProperty("--width", `${rect.width}px`);
};
});

这里除开传递鼠标的位置,还传递了当前按钮的宽高用于后续按钮文案阴影的依赖。


径向渐变背景动起来


背景色默认是纯色,随着鼠标的产生变化,所以这里和两个关键点有关,鼠标移入hover,移动过程中的坐标变化。实现过程核心是通过background定义两个背景色,默认的显示部分background-size是100%,渐变部分的background-size是0,待hover时设置为100%,这时就会显示渐变背景色内容了。


  background: 
// 渐变背景色
radial-gradient(
circle at center,
var(--lightest),
var(--light) 5%,
var(--dark) 30%,
var(--darkest) 50%
),
// 默认显示背景色
var(--darkest);
background-size: 0px 0px, 100%;

:hover {
background-size: 100%, 100%;
}

显示之后要动起来,基于js传入的坐标值应用到transformtranslate平移,这里注意移动是要基于当前元素的中心点位所以x和y都要减去自身的50%。


transform: translate(calc(var(--x) - 50%), calc(var(--y) - 50%));

如图所示,绿色区域是按钮部分,整个背景的中心点要和鼠标移动的坐标一致,所以要减去自身宽高的各一半。还有一点需要注意的是不能在移动的过程中让背景色漏出,所以背景区域是整个按钮的2倍。



这时整个背景区域很大,这里使用了CSS3的混合模式mix-blend-mode: lighten,最终只会应用亮色部分也就是中间的绿色区域。这里的混合模式给下一步中的弹性伸缩效果起到重要的作用。


此时的效果就是这样的,原代码在此基础上还增加了transition和filter体验让效果更佳,因涉及篇幅较长这里就不一一说明了,



背景区域弹性变化交互效果


背景弹性交互效果需要增加一个元素,与当前按钮同级别。此时的html如下:


<div class="inner">
<button type="button">南城FEbutton>
<div class="blob">div>
div>

blob元素和button都使用了绝对定位,因为按钮上面有文字,所以层级上button更高。blob元素增加了两个伪元素,先看after


&:after {
width: calc(100% - 4rem);
height: calc(100% - 4rem);
top: 2rem;
left: 2rem;
border-radius: 5rem;
box-shadow: 0 0 0 8rem #fff;
}

基于当前界面减少实际按钮的区域,并通过定位居中,再通过box-shadow填充白色背景,还增加了圆角,此时按钮的背景变成如下所示,按钮的雏形已经有了。



然后before主要也是通过box-shadow来增加额外的元素显示,分为三个部分,中间部分跟随鼠标移动,上下两个部分为鼠标移动到边界的反向效果区域。核心代码如下:


box-shadow: 0 0 0 0.75rem #fff, 0 -8rem 0 2rem #fff, 0 8rem 0 2rem #fff;

再配合基于js传入的坐标值应用到translate平移,box-shadow部分的内容即可跟随鼠标动起来了。这里用到了一个css3的函数clamp,它可以用来限制一个值的范围。clamp函数接受三个参数,分别表示最小值、推荐值和最大值。函数的返回值为推荐值,但是它会被限制在最小值和最大值之间。所以这里超出按钮的显示区域会有临界点,不会完全脱离,核心代码如下:


transform: translate(
clamp(5%, calc(var(--x) - 50%), 550%),
clamp(1rem, calc(var(--y) - 50%), 5rem)
);

此时按钮的效果如下,圆形部分即是上面的0 0 0 0.75rem #fff,下面的半圆即是0 8rem 0 2rem #fff,因为增加了圆角border-radius: 100%所以都是圆形。为什么下面的是半圆白色,因为after中的box-shadow白色背景遮挡了,所以不会完全显示,又因为是白色阴影加上混合模式所以这块区域以亮色白色显示。



是不是和目标效果有些接近了,加上一行关键代码即可。


filter: blur(12px) contrast(50);

这里使用filter属性处理,首先对元素进行模糊处理,如果只是增加模糊的效果如下,可以看到增加的伪元素圆形都被磨平了,完美的融入到了按钮本身的背景色中。



再加上contrast调整元素的对比度即可达到最终的效果,这里切记执行的顺序不能写反。在CSS中 filter 属性中的函数是按照从左到右的顺序执行的。如果你在 filter 属性中使用了多个函数,那么它们会按照从左到右的顺序依次执行。



按钮的文字阴影变化


文字的阴影变化主要是改变其水平和垂直的偏移量,以及模糊半径,这里就要用到最开始传入的按钮宽高的数据了,因为偏移量的计算会基于整个按钮的面积,这样才会显得更逼真。


先看水平和垂直的偏移量,核心还是基于clamp函数,设置最小值,最大值,中间的推荐值则会随着鼠标的坐标值变化而变化,具体的数值有兴趣的可以调整体验,以下是文字阴影的水平和垂直的偏移量计算的代码:


clamp(-6px, calc((var(--width) / 2 - var(--x)) / 12), 6px)
clamp(-4px, calc((var(--height) / 2 - var(--y)) / 16), 4px)

然后是模糊半径的计算,这里用到了max函数,最大取5px,其他情况基于坐标值和宽高计算得出。


max(
calc((var(--width) / 2 - var(--x)) / 8 +
((var(--height) / 2 - var(--y)) / 3)),
calc((
((var(--width) / 2 - var(--x)) / 8) +
((var(--height) / 2 - var(--y)) / 3)
) * -1
),
5px
)

最终的效果如下:



最后


到此整个核心的实现过程就结束了,整个代码中我们使用了box-shadowtext-shadowmix-blend-modefilter等属性,还有CSS3函数maxclampcalc。还有transition动画相关没有说明,涉及的知识点比较多,有兴趣的同学可以看源码了解。


在线代码预览:



到此本文就结束了,看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~



作者:南城FE
来源:juejin.cn/post/7212516589060849720
收起阅读 »

我在字节的这两年

web
前言 作为脉脉和前端技术社区的活跃分子,我比较幸运的有了诸多面试机会并最终一路升级打怪如愿来到了这里。正式入职时间为2021年1月4日,也就是元旦后的第一个工作日。对于这一天,我印象深刻。踩着2020年的尾巴接到offer,属实是过了一个快乐的元旦。不知不觉已...
继续阅读 »

前言


作为脉脉和前端技术社区的活跃分子,我比较幸运的有了诸多面试机会并最终一路升级打怪如愿来到了这里。正式入职时间为2021年1月4日,也就是元旦后的第一个工作日。对于这一天,我印象深刻。踩着2020年的尾巴接到offer,属实是过了一个快乐的元旦。不知不觉已经两年多了,细细回想起来,更多的是岁月推移,并没有回头看看现在的自己和两年前的自己有什么差别。


决定写文章记录一下还要感谢那个离职前在飞书上和我告别的老哥,他说已经学到了想学的


那我呢?似乎还没有。


和优秀的人做有挑战的事不止是简单的一句话。


在字节停留时间越久,越是能感觉到身边人的优秀,也正是这份优秀推动着我不断前进。


本文将会从思维方式、问题排查、技术思考三个方面以回顾自我成长的视角展开叙述,欢迎阅读。


思维方式


思维方式指的是看待事物的角度、方式和方法。放到工作当中来看,我逐渐摸索出了几个具体的点。


工作优先级


曾很长一段时间里,我在工作上没有刻意区分优先级或者说有优先级但是区分度不是那么明显。这意味着只要不是恰好有紧急事情处理,基本上业务方提过来的合理需求我都会第一时间安排。不论需求大小,也不问紧急程度,都默认当作紧急处理。


诚然,在交付后得到业务方肯定的那一刻是有成就感的。但我逐渐意识到,这真的是有点本末倒置。由于我负责的这部分工作和底层数据相关,可能很多需求直接或间接的都会找到我。事实上,完成对齐过的工作才是我更应该高优做的事,剩下时间用来完成这些零散需求才更为合理。


起初我觉得有些小需求可能就是一两行代码的事,顺手一个分支就带上去了。但仔细想想,这好像引发了蝴蝶效应。一件事仅仅完成是不够的,该有的环节要有。 开发,测试,上线,周知业务方验收。这样一个小流程走下来耗费的时间可不仅仅是一两行代码占用的时间可比。更何况,可能还不止一个零散需求。时不时被打断,自然就会导致原有工作安排非预期delay。


在意识到这个问题后,来自业务方的需求我会主动问一下优先级。如果不是特别紧急的事情将不会安排在当前周期的工作计划里。此外,优先级判定上我会和业务方确认完使用场景后有自己的思考。对接次数多了,发现有些紧急并不是真的紧急,只是单纯的性子急。后来,对于这种零散需求,我会在项目管理平台写好描述和需求提出人,方便后续沟通。


这个记录还是很有意义的,深感好处明显。



  • 可以起到一个备忘录的作用,定期查看,提醒自己有todo要处理

  • 业务方(需求提出人)可能因业务场景变更或有了其他解决方案,不再需要后续支持

  • 原业务方(需求提出人)转岗或离职,不再需要后续支持


等到决定去做的时候,如果发现时间间隔较久,不要急着写代码,先和业务方二次确认这个需求是否有必要继续做。试想,如果耗时耗力做完,最后邀请业务方验收时候对方又反馈用不到了。什么心情?那肯定满脸黑人问号啊?实惨如我,曾有过这样的经历。深感前置确认真的很有必要,这样能有效避免打黑工的场景。


在有意识对工作优先级进行划分后,原定对齐的工作进展基本都可以得到保障。等到工作周期结束进行总结的时候,看到比较高的完成度,我觉得这份成就感更高。


ROI考量


ROI 全称为 Return On Investment,指的是投资回报率。我是在完成一个比较重要的功能模块迁移后才更加认识到这个东西的重要性。在做数据迁移的时候,我写脚本进行的全量迁移。为了兼容新旧平台的格式差异,我做了好几处的格式转换,过程中还遇到好几个bad case需要手动处理,总之并不是那么顺利。等到一切准备就绪,我开始拉群周知用户并以表格形式逐个进行使用情况的回访。结果很尴尬,实际使用的用户远低于历史存量用户。量少到我完全可以采用更快的手动迁移,省去做格式转换和写脚本的时间。


对于那些实际没人用的数据,我后来又进行了删除处理。这一波操作下来,真的投入产出比就不高了。算是吃一堑长一智吧,在对一个功能模块进行迁移的时候,前置工作除了搞清楚历史背景,实现原理,更应该确定实际使用人群。尤其是对于一个存在年头比我入职时间还久的功能,更应该花时间在这个点上好好调研下。确定目标人群才好"对症下药",这样才有可能是多人的狂欢而非仅仅是一个人单纯完成迁移工作的孤独玩耍。


有心和无意真的是两种不同的感觉。 实际上,在经历这个事情之前我对自己研发的模块也会有很多想法。有较长一段时间里,我脑海中冒出来的小想法会连同某个分支功能带上去,改动不大,但是可能要思考的点会比较多。现在回想起来,大多数属于ROI比较低的。而现在,不论是业务方提出的需求还是我自己的小想法我都会优先考虑ROI的问题。时间是很宝贵的,在有限时间内产生更高价值带来的成就感和自我认同感绝对是翻倍的。


技术与业务关联


在来字节前,我很喜欢花大把的时间去钻研一些自己喜欢但可能实际未必会用到或者说使用场景比较局限的东西。比如我曾跟着视频教程鼓捣过一段时间的Angular 1.x 。当时觉得ng-xx这个指令写起来倍感新奇,有种发现新大陆的小激动。也曾跟风学过一段时间的php,被其数量庞大的内置函数所震惊。等转回到业务上,发现花费大量时间研究的东西和业务根本不沾边或者说没必要为了尝试而去强切技术栈。如此一来,割裂就产生了。我曾好长一段时间困在这个技术和业务二选一的局面走不出来。


等入职字节并工作了一段时间后,我发现当业务形态开始变得复杂,对技术的考验也会随之而来善于运用技术恰到好处地解决业务痛点,远远比单纯研究技术有意义。 自嗨终究是自嗨,没有实际落地场景,过一段时间就会忘记。如果还没想清楚技术服务于业务这个关键点,那就会陷入【钻研技术->长久不用->遗忘->钻研技术】这个循环。保持技术热情是好事,但是对于一个几乎没有业务落地场景的技术,投入大把时间研究又有什么用呢?知识是检索的,当需要时自然会朝着这个方向靠近,有具体落地场景才能更好地巩固。


进一步让我体会到技术与业务是相辅相成的契机是对图数据库bytegraph的相关技术调研和最终的投入使用。业务场景需要,我这边会涉及不同类型数据之间关联关系的管理(CRUD操作)。这个关联有层级的概念,全部关联建立数据量已到千万级别。从设计角度和实践角度综合考量,已经不是MySQL擅长的场景。细想一下,层层关联铺开不就是一张图吗?自然是图数据库存储更为合适。


在我看完bytegraph相关文档并使用Gremlin图数据库语言写了几个符合自我预期的基础语句后,突然又找回了曾经独自钻研技术的快乐。在使用过程中,很自然的就和业务关联起来了。比如如何设计点和边?如何提高关联图查询速度?我曾写过一篇关于图数据库bytegraph介绍和基本使用的文档,有同学在看过后就着某个具体业务场景下点该如何设计这个话题和我进行了语音交流,最后我结合实际使用场景给出了有效结论,被肯定的瞬间同样是成就感满满。此外,在工作中对bytegraph的使用诉求,还推动了bytegraph NodeJS SDK 的诞生。有幸成为第一个吃螃蟹的人,真的很有纪念意义。


寻求长期方案


很多时候,解决问题的方案都不止一个。绝大多数情况下,选择临时解决方案是最快最省力的。当然,也不排除某些极限情况下足够的临时趋近于长久。但临时终归是临时,这意味着中后期规划可能会有变更,从而导致现有的方案不再适用,所以说寻求长期稳定的解决方案才是最终目的。尤其是当系统稳定性和切换成本冲突时,更应攻坚克难去破局。近期完成了权限平台相关接口的升级替换,由于历史包袱沉重,旧的权限接口越来越不稳定,已经影响平台侧权限的正常使用。在这种情况下,真的是不得不换。好处还是很明显的,虽然过程艰难,但稳定性上确实得到了保障。


相信字节内很多平台都是对权限系统强依赖的,这意味着一旦权限系统服务出了问题,其他的下游服务都会受牵连。这种权限问题感知相当明显,最简单的一个例子:为什么自己创建的东西在操作时提示没权限?


为了降低权限系统不可用对自身业务的影响,我用redis对所有涉及权限读数据的地方做了缓存(如用户权限列表)。每次刷新页面会在获取用户信息的同时查询最新的权限信息,当检测到返回结构非预期时,则不再更新,直接返回缓存数据。一般来说,读权限场景比写权限场景更多,有这样一层缓存来兜底,还是很有价值的。


此外,为了避免自己创建的东西在操作时提示没权限的尴尬局面,我进行了业务自身数据库优先权限系统接口查询的处理。这个很好理解,写到自己数据库再读取往往比写到权限系统数据库再读取来的方便,后者可能会有延迟。完成整体权限系统接口升级替换,再结合redis缓存,数据库优先权限系统接口读取这两个策略,在业务侧整体权限稳定性上可以看作是一个长期稳定的方案了。


直面问题


对于一个开发来说,出现问题在所难免。解决问题固然重要,但是摆正心态也同样重要。工作中基本都是多人协作开发,当收到线上报警消息时,如果能确定和自己的某些操作有关应及时和相关同学说明,避免其他人一同跟着排查。有句话听起来很矛盾,但是语境还挺合适的:"我知道你很慌,但是先别慌。" 出现问题,排查清楚后,及时修复就好,切莫讳疾忌医。


此外,有些问题隐藏比较深,复现链路较为隐晦,甚至可能除了开发自身,其他人几乎不会有感知。我曾遇到过一个这样的case,代码写完过了一年,也没有人反馈,最后还是我自己在某次调试时候发现并修复的。随着编码经验的积累,思维发散性也会更广,不同阶段考虑的点自然也有差异。没必要过多纠结当时为什么没有考虑到这个场景,更应该思量的是下次遇到类似情况如何避免。亡羊补牢,为时未晚。


问题排查


问题排查可以说是一个开发人员必备的能力。个人感觉保证开发永远不出bug的方式就是不去开发。当然,这并不现实。在字节这两年多的时间里,我踩过好多的坑,也出过事故,逐渐摸索出了一些问题排查的经验。


环境一致性校验


工作中我这边常用到的是本地环境、测试环境(boe),生产预览环境(ppe)和正式生产环境(prod)。每个阶段都有可能会引发问题,在开始排查问题前,需要先确定自己的调试环境与引发问题的环境一致。乍一看可能感觉这句话是废话,但是有过相关经验的人都知道这一条真的很重要。


说来惭愧,我有过本地调试半天发现死活不生效最后意识到看的是生产环境页面的尴尬经历,真的是又气又无奈。


优先保证这一点,能少走很多弯路。


格式一致性校验


格式一致性校验指的是确认原始数据在有意格式处理或漏处理后,是否和后续程序要接收的数据格式保持一致。


一般来说,编码粗心或者测试不够充分都有可能引发格式相关的问题。


有意处理的场景:


const list=[1,2,3]
// 有意处理
const formatList =list.map(d=>({
id:d
}))
// 省略一大段代码

// 此处错误传入了list,应使用formatList
getData(list)

function getData(list){
// do something...
return xxx
}

在前端操纵数据store也有可能存在类似的问题,原始数据格式在某个组件里被修改导致另一个组件无法预期解析。


漏处理的场景:


// sequelize findAll查询 限定只返回id属性
const ids = await modelA.findAll({
attributes: ['id'],
});

await modelB.findAll({
where: {
id: ids,//这里漏掉了对ids的处理
},
});

如图,使用了sequelize model方法中的findAll查询并限定只返回id属性,且变量命名为ids。


实际上,返回的结构是对象数组{id:number}[],而不是数字数组number[]。


请求响应一致性校验


服务里定义的路由地址和前端请求时的地址对不上,导致请求404。


可能是因为单词拼写错误:username or ursename? cornjob or cronjob? 或者cv后没有改全。


前置条件确认


这个偏向于涉及事件触发的场景,要先满足其前置条件。


下面列举几个有代表性的场景:



  1. 如果想在群里接收某个机器人推送的消息,需要先把机器人拉进群

  2. 如果想在eventbus消费生产者产生的数据,需要确保消费者是开启状态

  3. 如果想使用sdk正常解析hive数据,需要先申请表权限


分区间排查


这种方式适用于排查由程序代码引起但尚不确定具体代码位置的场景。


我将其划分为三段式:



  1. 给怀疑会出问题的代码圈定一个区间,非怀疑区间代码直接注释(前端更有效)或return掉(后端更有效)

  2. 添加相关打印并重新运行程序,观测输出和程序运行结果是否符合预期

  3. 收缩区间,重复1,2步骤,直至发现问题




这里举一个我在使用bytegraph过程中亲身遇到的一个cpu暴涨的例子。


最初bytegraph并不支持全图查询,所以在获取某个点所在的整张关联图谱时拆分成了以下三个步骤:



  1. 查询某个点在整张图上的关联点

  2. 遍历每个点,查询入边和出边

  3. 根据边的指向拼出完整的图谱


伪代码如下:


function getGraph(vertex:Vertex){
// 查询某个点在整张图上的关联点
const nodes=await getNodes(vertex);
console.log('get nodes')
// return 分割区间一,后续直接return
// 遍历每个点,查询入边和出边。
const edges=await getEdges(nodes)
console.log('get edges')
// return 分割区间二,后续直接return
// ... other
}

async function getEdges(vertexs: Vertex[]) {
let res: any = [];
for (let i = 0; i < vertexs.length; i++) {
const vertex = vertexs[i];
// 根据点查询入边和出边
const itemEdges=await findEdge(vertex);
res = [ ... res, ... itemEdges];
}
// return res 分割区间三,不执行uniqWith返回res
// 深度去重
return uniqWith(res, isEqual);
}

采用分区间排查问题的思路,在关键节点添加打印日志,触发调试。


查看打印信息,发现每次都是在获取所有边那里卡住。


此时可以进到getEdges里边查看,发现内部有一个去重操作。


试着去掉这个过程,再重试,问题未复现。ok,定位问题。




针对这个问题,我写了一个可复现的最小demo,感兴趣的可自行尝试。


结论是lodash的uniqWith和isEqual方法对大数据 重复率不高的数据进行深度去重会导致cpu暴涨。


const { uniqWith, isEqual } = require('lodash');
const http = require('http');
http
.createServer(async (req, res) => {
const arr = [];
for (let i = 0; i < 10000; i++) {
arr.push({
n: Math.random() * 20000,
m: Math.random() * 20000,
});
}
console.log(uniqWith(arr, isEqual));
res.end('hello world');
})
.listen(3000);

请求溯源


对于有提供Open API 给其他业务方使用或者说当前服务存在开放性接口(未设置权限)的情况下,都有可能存在非预期调用,其中最典型的是参数错误和session信息缺失。


我有过类似经历,某个已经线上稳定运行过一段时间的接口突然开始报错,从错误信息来看是参数错误。随后我仔细查找了代码里的调用点,只有可能在平台使用时触发。进一步查看,确认是开放性接口,没有权限管控。意识到应该是某个用户手动触发的,因为平台侧正常使用的请求参数符合预期。如果能定位到具体的人自然最好,如果找不到人就需要在代码层面做一个参数校验,如果传递过来的参数不符合预期,直接return掉。类似的,平台侧调用一定可以拿到session信息,但是接连几次报错都是拿不到session导致的,怀疑是非常规调用,直接return。


安全日志记录


我负责的工作中涉及很多底层数据,这些数据属性变更有可能会引发非预期的安全卡点。开启卡点的资产越多,类似问题感知就会越明显。内部定时任务,外部平台配置变更,扫描任务,人工变更都可以导致资产属性发生变化。因此,究竟是哪一环节发生的变更显得尤为重要,这能有效缩短问题排查链路。


通过在每个变更节点添加一条安全日志记录,可以有效辅助排查。此外,还可以作为业务方溯源的一个途径。比如解答某个资产卡点什么时候开启的?卡点开启同步自哪个部门?


审查数据库字段


在某些业务场景里会在数据库中存储JSON 字符串,此时需要对实际可能的JSON大小做一个预判,之后再设定与之匹配的字段类型和数据大小。否则当实际长度超过数据库设定字段长度时,JSON字符串就会被截断,导致最后的解析环节出错。


超时归因


开发中遇到网络超时问题太常见了,大多数情况下都可以通过添加重试机制,延长timeout的方式解决。这里我想说的是一个比较特别的场景,海外,国内跨机房通信。 绝大多数海外和国内的通信都是存在区域隔离的,调用不通表现上可能就是网络超时,这种情况下,重试也没用。解决途径也比较直观,要么直接避免这种情况,海外调海外,国内调国内,要么申请豁免。


善用工具


argos观测诊断平台


在问题排查上,观测诊断平台能起到有效的辅助作用。除了报错日志,还可以看到所在服务psm,集群,机房。这些都是缩短问题排查链路的有效信息,在服务实例比较多的情况下表现尤为明显。此外,还可以配置报警规则,命中后会有报警机器人进行推送,可及时感知线上问题的发生。


飞书机器人


真心觉得飞书机器人是一个很好用的小东西。用它可以干很多事,比如按时提醒该喝水了。在报警感知上,也可以通过机器人搞点事情。例如在某个装饰器里对核心接口请求地址(如包含/core/)进行识别,随后在catch代码块里捕获错误,最后将error message or error stack 推送到指定的飞书群里,这样团队其他成员也能及时感知。


飞书表格


个人精力有限,不可能时时刻刻盯着报警信息其他什么都不干。对于一些看起来影响不大,不用紧急修复的报警可以先通过飞书表格记录下来,等有时间后当成待办事项逐一解决。亲测,这种先收集后集中处理的方式比发现一个处理一个更省时间。


技术思考


规范


很长一段时间里我对技术的理解是运用掌握的知识完成开发,仅此而已。但事实上,开发流程不应仅局限于开发环节,还有其他很多有价值的事情需要关注,比如一些规范。团队协作和独立开发还是有明显区别的,没有规矩不成方圆。既然是协作,就要有达成一致的规范。


我曾写过一篇关于lint的文章并在小组内和其他同事对齐,共同商讨缩进风格,哪些规则要开启,哪些规则要禁用。项目编码风格统一的管控实现上依赖husky和lint-staged,在提交代码时进行lint检测,不符合检测规则无法提交,这样可以有效避免个人编码风格差异导致的格式change。


在代码提交上,由组内另一个同学制定了git工作流规范,共同约定了不同功能分支如何命名,分支间如何检出与合并,commit 应该如何编写。这种规范形成文档后作用明显,不论是日常开发还是线上部署,都有了更清晰的操作流程。此外,见名知意的commit message也更有助于查找具体功能点。试想一下,如果简写一个fix,或fix err ,等过段时间再看,哪里还记得到底fix了个什么?


类似的,小组内还有需求迭代,上线部署等相关规范,这些规范站在开发的全局视角来看,都是很有价值的。


质量


研发质量问题是一个非常值得重视的点,开发完成并不意味着整个研发环节就结束了,质量过关才是最后的收尾节点。简单来说,上线后功能平稳运行,无bug和性能问题,这样才算是合格。虽说百密一疏,但反复踩同样的坑或者踩不应该踩的坑就有些说不过去了。我印象比较深刻的踩坑点在于数据格式处理,这个在上文报警排查处有提到,不再赘述。还有一点,对于跨越大版本的sdk升级,一定要认真且足够详细的审查是否存在break change。有些break change是比较隐晦的,乍一看可能察觉不到玄机,切记想当然,在项目代码中搜索看看,总比自我回忆要可信的多。想要收获一批忠实用户,研发质量一定是排位比较靠前的。


稳定性


这里特指研发的系统稳定性,初期我这边涉及到的系统架构比较简单,所有功能模块共用一个服务。这样好处是很多代码可以复用,开发和上线也比较方便。祸福相依,但是一旦服务崩溃,除了影响自身业务正常使用,还会朝着下游其他业务辐射。具体表现上来看,一般是OEPN API不可用。为避免类似问题再发生,我和小组内其他同事一起完成了服务架构升级,将不同子模块拆分成不同的服务,接口层面根据重要等级和业务类型并借助负载均衡能力,分散至各自所在服务的不同集群。架构升级完成后,即使某个子模块出现问题,也不至于牵动整个服务崩盘。在此次架构升级中更深刻体会到了不同类型数据库在特定场景下的使用,Redis,MySQL,MongoDB,bytegraph都有涉及,收获颇多。


文档先行


对于一些偏复杂的模块,先找个文档梳理一下,逐步拆解清楚后再开始编码,属于磨刀不误砍柴工。以前我的习惯是想一个大概,然后投入开发,写着写着发现之前想错了,然后删掉代码,再写新的,这个过程可能会反复好几次。冷静下来好好想想,真不如先写清楚文档更省时省力。实测,让思维在文档上交锋,远比在编辑器里打架轻松的多。


沉淀总结


我始终觉得,有输入就应该有输出。不论是日常基础搬砖,还是攻坚克难了某个业务痛点,又或者加深了自己对某项技术的理解,都应该有所展现。并不是说非要落笔成文,但至少应该在一个属于自己的小天地里留些痕迹。如果实在懒得打字,不妨试试拍照式记忆。亲测,这个是科学中带有点玄学的方法。


先找到想要记住的画面,可以是控制台的数据打印,也可以是bug调试截图,又或者某段关键代码,然后想一个主题,与之进行关联,重复思考几次。好的,记住了。


还是那句话,有心和无意是不一样的。有心留意,这份记忆就会更为深刻。当下次遇到类似场景,近乎是条件反射的思维反应。比如我现在每次写删除语句一定会检查是否加上了where条件。这是有特殊意义的一段经历,不堪回首。


落地统计


辛辛苦苦搬砖究竟产生了怎样的价值呢?究竟有哪些人在用?这同样是一个比较关键的点。我曾梳理了一个关于OPEN API 业务落地情况的表格,里边记载了哪些业务方在用,什么场景下会用,对接人是谁。这样除了价值考量,还可以在接口变更或下线时及时联系使用方,避免造成非预期的影响。


总结


不知不觉,洋洋洒洒写了几千字,梦回毕业论文。曾觉得自己属于有所成长,但是成长算不上快那种。写完这篇文章后再回首,竟也方方面面很多点。不错,经过一番努力,终于从一棵小葱茁壮成长为一棵参天大葱了。


回到最初的问题上,时至今日,我仍然觉得还有很多东西要学。距离把想学的都学到,大概还有很长一段路要走。


好在这一路不算孤独,能和身边优秀的人一起做有挑战的事。


前方的路,仍然值得期待。


作者:冷月心
来源:juejin.cn/post/7211716002383429693

完结,撒花!

收起阅读 »

后端一次给你10万条数据,如何优雅展示,到底考察我什么?

web
前言 大家好,我是林三心,用最通俗的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心,今天跟大家来唠唠嗑,如果后端真的返回给前端10万条数据,咱们前端要怎么优雅地展示出来呢?(哈哈假设后端真的能传10万条数据到前端) 前置工作 先把前置工作给做好,后...
继续阅读 »

前言


大家好,我是林三心,用最通俗的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心,今天跟大家来唠唠嗑,如果后端真的返回给前端10万条数据,咱们前端要怎么优雅地展示出来呢?(哈哈假设后端真的能传10万条数据到前端)


image.png


前置工作


先把前置工作给做好,后面才能进行测试


后端搭建


新建一个server.js文件,简单起个服务,并返回给前端10w条数据,并通过nodemon server.js开启服务



没有安装nodemon的同学可以先全局安装npm i nodemon -g



// server.js

const http = require('http')
const port = 8000;

http.createServer(function (req, res) {
// 开启Cors
res.writeHead(200, {
//设置允许跨域的域名,也可设置*允许所有域名
'Access-Control-Allow-Origin': '*',
//跨域允许的请求方法,也可设置*允许所有方法
"Access-Control-Allow-Methods": "DELETE,PUT,POST,GET,OPTIONS",
//允许的header类型
'Access-Control-Allow-Headers': 'Content-Type'
})
let list = []
let num = 0

// 生成10万条数据的list
for (let i = 0; i < 100000; i++) {
num++
list.push({
src: 'https://p3-passport.byteacctimg.com/img/user-avatar/d71c38d1682c543b33f8d716b3b734ca~300x300.image',
text: `我是${num}号嘉宾林三心`,
tid: num
})
}
res.end(JSON.stringify(list));
}).listen(port, function () {
console.log('server is listening on port ' + port);
})

前端页面


先新建一个index.html


// index.html

// 样式
<style>
* {
padding: 0;
margin: 0;
}
#container {
height: 100vh;
overflow: auto;
}
.sunshine {
display: flex;
padding: 10px;
}
img {
width: 150px;
height: 150px;
}
</style>

// html部分
<body>
<div id="container">
</div>
<script src="./index.js"></script>
</body>


然后新建一个index.js文件,封装一个AJAX函数,用来请求这10w条数据


// index.js

// 请求函数
const getList = () => {
return new Promise((resolve, reject) => {
//步骤一:创建异步对象
var ajax = new XMLHttpRequest();
//步骤二:设置请求的url参数,参数一是请求的类型,参数二是请求的url,可以带参数
ajax.open('get', 'http://127.0.0.1:8000');
//步骤三:发送请求
ajax.send();
//步骤四:注册事件 onreadystatechange 状态改变就会调用
ajax.onreadystatechange = function () {
if (ajax.readyState == 4 && ajax.status == 200) {
//步骤五 如果能够进到这个判断 说明 数据 完美的回来了,并且请求的页面是存在的
resolve(JSON.parse(ajax.responseText))
}
}
})
}

// 获取container对象
const container = document.getElementById('container')

直接渲染


最直接的方式就是直接渲染出来,但是这样的做法肯定是不可取的,因为一次性渲染出10w个节点,是非常耗时间的,咱们可以来看一下耗时,差不多要消耗12秒,非常消耗时间


截屏2021-11-18 下午10.07.45.png


const renderList = async () => {
console.time('列表时间')
const list = await getList()
list.forEach(item => {
const div = document.createElement('div')
div.className = 'sunshine'
div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
container.appendChild(div)
})
console.timeEnd('列表时间')
}
renderList()

setTimeout分页渲染


这个方法就是,把10w按照每页数量limit分成总共Math.ceil(total / limit)页,然后利用setTimeout,每次渲染1页数据,这样的话,渲染出首页数据的时间大大缩减了


截屏2021-11-18 下午10.14.46.png


const renderList = async () => {
console.time('列表时间')
const list = await getList()
console.log(list)
const total = list.length
const page = 0
const limit = 200
const totalPage = Math.ceil(total / limit)

const render = (page) => {
if (page >= totalPage) return
setTimeout(() => {
for (let i = page * limit; i < page * limit + limit; i++) {
const item = list[i]
const div = document.createElement('div')
div.className = 'sunshine'
div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
container.appendChild(div)
}
render(page + 1)
}, 0)
}
render(page)
console.timeEnd('列表时间')
}

requestAnimationFrame


使用requestAnimationFrame代替setTimeout,减少了重排的次数,极大提高了性能,建议大家在渲染方面多使用requestAnimationFrame


const renderList = async () => {
console.time('列表时间')
const list = await getList()
console.log(list)
const total = list.length
const page = 0
const limit = 200
const totalPage = Math.ceil(total / limit)

const render = (page) => {
if (page >= totalPage) return
// 使用requestAnimationFrame代替setTimeout
requestAnimationFrame(() => {
for (let i = page * limit; i < page * limit + limit; i++) {
const item = list[i]
const div = document.createElement('div')
div.className = 'sunshine'
div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
container.appendChild(div)
}
render(page + 1)
})
}
render(page)
console.timeEnd('列表时间')
}

文档碎片 + requestAnimationFrame


文档碎片的好处



  • 1、之前都是每次创建一个div标签就appendChild一次,但是有了文档碎片可以先把1页的div标签先放进文档碎片中,然后一次性appendChildcontainer中,这样减少了appendChild的次数,极大提高了性能

  • 2、页面只会渲染文档碎片包裹着的元素,而不会渲染文档碎片


const renderList = async () => {
console.time('列表时间')
const list = await getList()
console.log(list)
const total = list.length
const page = 0
const limit = 200
const totalPage = Math.ceil(total / limit)

const render = (page) => {
if (page >= totalPage) return
requestAnimationFrame(() => {
// 创建一个文档碎片
const fragment = document.createDocumentFragment()
for (let i = page * limit; i < page * limit + limit; i++) {
const item = list[i]
const div = document.createElement('div')
div.className = 'sunshine'
div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
// 先塞进文档碎片
fragment.appendChild(div)
}
// 一次性appendChild
container.appendChild(fragment)
render(page + 1)
})
}
render(page)
console.timeEnd('列表时间')
}

懒加载


为了比较通俗的讲解,咱们启动一个vue前端项目,后端服务还是开着


其实实现原理很简单,咱们通过一张图来展示,就是在列表尾部放一个空节点blank,然后先渲染第1页数据,向上滚动,等到blank出现在视图中,就说明到底了,这时候再加载第二页,往后以此类推。


至于怎么判断blank出现在视图上,可以使用getBoundingClientRect方法获取top属性



IntersectionObserver 性能更好,但是我这里就拿getBoundingClientRect来举例



截屏2021-11-18 下午10.41.01.png


<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
const getList = () => {
// 跟上面一样的代码
}

const container = ref<HTMLElement>() // container节点
const blank = ref<HTMLElement>() // blank节点
const list = ref<any>([]) // 列表
const page = ref(1) // 当前页数
const limit = 200 // 一页展示
// 最大页数
const maxPage = computed(() => Math.ceil(list.value.length / limit))
// 真实展示的列表
const showList = computed(() => list.value.slice(0, page.value * limit))
const handleScroll = () => {
// 当前页数与最大页数的比较
if (page.value > maxPage.value) return
const clientHeight = container.value?.clientHeight
const blankTop = blank.value?.getBoundingClientRect().top
if (clientHeight === blankTop) {
// blank出现在视图,则当前页数加1
page.value++
}
}

onMounted(async () => {
const res = await getList()
list.value = res
})
</script>

<template>
<div id="container" @scroll="handleScroll" ref="container">
<div class="sunshine" v-for="(item) in showList" :key="item.tid">
<img :src="item.src" />
<span>{{ item.text }}</span>
</div>
<div ref="blank"></div>
</div>
</template>


虚拟列表


虚拟列表需要讲解的比较多,在这里我分享一下我的一篇虚拟列表的文章,哈哈我自认为讲的不错吧哈哈哈哈哈哈


结合“康熙选秀”,给大家讲讲“虚拟列表”


结语


如果你觉得此文对你有一丁点帮助,点个赞,鼓励一下林三心哈哈。


如果你想一起学习前端或者摸鱼,那你可以加我,加入我的摸鱼学习群,点击这里 ---> 摸鱼沸点


如果你是有其他目的的,别加我,我不想跟你交朋友,我只想简简单单学习前端,不想搞一些有的没的!!!


作者:Sunshine_Lin
来源:juejin.cn/post/7031923575044964389
收起阅读 »