注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

让生成式 AI 触手可及:火山引擎推出 NVIDIA NIM on VKE 最佳部署实践

技术行业近来对大语言模型(LLM)的关注正开始转向生产环境的大规模部署,将 AI 模型接入现有基础设施以优化系统性能,包括降低延迟、提高吞吐量,以及加强日志记录、监控和安全性等。然而这一路径既复杂又耗时,往往需要构建专门的平台和流程。在部署 AI 模型的过程中...
继续阅读 »

技术行业近来对大语言模型(LLM)的关注正开始转向生产环境的大规模部署,将 AI 模型接入现有基础设施以优化系统性能,包括降低延迟、提高吞吐量,以及加强日志记录、监控和安全性等。然而这一路径既复杂又耗时,往往需要构建专门的平台和流程。

在部署 AI 模型的过程中,研发团队通常需要执行以下步骤:

环境搭建与配置:首先需要准备和调试运行环境,这包括但不限于 CUDA、Python、PyTorch 等依赖项的安装与配置。这一步骤往往较为复杂,需要细致地调整各个组件以确保兼容性和性能。

模型优化与封装:接下来进行模型的打包和优化,以提高推理效率。这通常涉及到使用 NVIDIA TensorRT 软件开发套件或 NVIDIA TensorRT-LLM 库等专业工具来优化模型,并根据性能测试结果和经验来调整推理引擎的配置参数。这一过程需要深入的 AI 领域知识,并且工具的使用具有一定的学习成本。

模型部署:最后,将优化后的模型部署到生产环境中。对于非容器化环境,资源的准备和管理也是一个需要精心策划的环节。

为了简化上述流程并降低技术门槛,火山引擎云原生团队推出基于 VKE 的 NVIDIA NIM 微服务最佳实践。通过结合 NIM 一站式模型服务能力,以及火山引擎容器服务 VKE 在成本节约和极简运维等方面的优势,这套开箱即用的技术方案将帮助企业更加快捷和高效地部署 AI 模型。

AI 微服务化:NVIDIA NIM

NVIDIA NIM 是一套经过优化的企业级生成式 AI 微服务,它包括推理引擎,通过 API 接口对外提供服务,帮助企业和个人开发者更简单地开发和部署 AI 驱动的应用程序。

NIM 使用行业标准 API,支持跨多个领域的 AI 用例,包括 LLM、视觉语言模型(VLM),以及用于语音、图像、视频、3D、药物研发、医学成像等的模型。同时,它基于 NVIDIA Triton™ Inference Server、NVIDIA TensorRT™、NVIDIA TensorRT-LLM 和 PyTorch 构建,可以在加速基础设施上提供最优的延迟和吞吐量。

为了进一步降低复杂度,NIM 将模型和运行环境做了解耦,以容器镜像的形式为每个模型或模型系列打包。其在 Kubernetes 内的部署形态如下:


NVIDIA NIM on Kubernetes

火山引擎容器服务 VKE(Volcengine Kubernetes Engine)通过深度融合新一代云原生技术,提供以容器为核心的高性能 Kubernetes 容器集群管理服务,可以为 NIM 提供稳定可靠高性能的运行环境,实现模型使用和运行的强强联合。

同时,模型服务的发布和运行也离不开发布管理、网络访问、观测等能力,VKE 深度整合了火山引擎高性能计算(ECS/裸金属)、网络(VPC/EIP/CLB)、存储(EBS/TOS/NAS)、弹性容器实例(VCI)等服务,并与镜像仓库、持续交付、托管 Prometheus、日志服务、微服务引擎等云产品横向打通,可以实现 NIM 服务构建、部署、发布、监控等全链路流程,帮助企业更灵活、更敏捷地构建和扩展基于自身数据的定制化大型语言模型(LLMs),打造真正的企业级智能化、自动化基础设施。


NVIDIA NIM on VKE 部署流程

下面,我们将介绍 NIM on VKE 的部署流程,助力开发者快速部署和访问 AI 模型。

准备工作

部署 NVIDIA NIM 前,需要做好如下准备:

1. VKE 集群中已安装 csi-nas / prometheus-agent / vci-virtual-kubelet / cr-credential-controller 组件

2. 在 VKE 集群中使用相适配的 VCI GPU 实例规格,具体软硬件支持情况可以查看硬件要求

3. 创建 NAS 实例,作为存储类,用于模型文件的存储

4. 创建 CR(镜像仓库) 实例,用于托管 NIM 镜像

5. 开通 VMP(托管 Prometheus)服务

6. 向 NVIDIA 官方获取 NIM 相关镜像的拉取权限(下述以 llama3-8b-instruct:1.0.0 为例),并生成 API Key

部署

1. 在国内运行 NIM 官方镜像时,为了避免网络访问影响镜像拉取速度,可以提前拉取相应 NIM 镜像并上传到火山引擎镜像仓库 CR,操作步骤如下:


2. Download the code locally, go to the Helm Chart directory of the code, and push Helm Chart to Container Registry (Helm version > 3.7):

下载代码到本地,进入到代码的 helm chart 目录中,把 helm chart 推送到镜像仓库(helm 版本大于 3.7):


3. 在 vke 的应用中心的 helm 应用中选择创建 helm 应用,并选择对应 chart,集群信息,并点击 values.yaml 的编辑按钮进入编辑页


4. 覆盖 values 内容为如下值来根据火山引擎环境调整参数配置,提升部署性能,点击确定完成参数改动,再继续在部署页点击确定完成部署


5. 若 Pod 日志出现如下内容或者 Pod 状态变成 Ready,说明服务已经准备好:


6. 在 VKE 控制台获取 LB Service 地址(Service 名称为-nim-llm)


7. 访问 NIM 服务


The output is as follows:

会有如下输出:


监控

NVIDIA NIM 在 Grafana Dashboard 上提供了丰富的观测指标,详情可参考 Observability

在 VKE 中,可通过如下方法搭建 NIM 监控:

1. 参考文档搭建 Grafana:https://www.volcengine.com/docs/6731/126068

2. 进入 Grafana 中,在 dashboard 菜单中选择 import:


3. 观测面板效果如下:


结语

相比构建大模型镜像,基于 VKE 使用 NVIDIA NIM 部署和访问模型有如下优点:

● 易用性:NIM 提供了预先构建好的模型容器镜像,用户无需从头开始构建和配置环境,配合 VKE 与 CR 的应用部署能力,极大简化了部署过程

● 性能优化:NIM 的容器镜像是经过优化的,可以在 NVIDIA GPU 上高效运行,充分利用 VCI 的硬件性能

● 模型选择:NIM 官方提供了多种大语言模型,用户可以根据需求选择合适的模型,部署在 VKE 中仅需对values.yaml 配置做修改即可

● 自动更新:通过 NGC,NIM 可以自动下载和更新模型,用户无需手动管理模型版本

● 可观测性:NIM 内置了丰富的观测指标,配合 VKE 与 VMP 观测能力开箱即用

目前火山引擎容器服务 VKE 已开放个人用户使用,为个人和企业用户提供高性能、高可靠、极致弹性的企业级容器管理能力,结合 NIM 强大易用的模型部署服务,进一步帮助开发者快速部署 AI 模型,并提供高性能、开箱即用的模型 API 服务。(作者:李双)

收起阅读 »

uniapp-实现安卓app水印相机

写在前面的话:最近要配合项目输出带水印的图片,之前的实现的方式是调uniapp封装好的相机,然后在图片输出的时候用canvas,把水印绘制上去,但是老感觉没有水印相机看着舒服.改成了现在的这种方式。 1.相机实现 水印相机实现有两种方式,在小程序端可以用cam...
继续阅读 »

写在前面的话:最近要配合项目输出带水印的图片,之前的实现的方式是调uniapp封装好的相机,然后在图片输出的时候用canvas,把水印绘制上去,但是老感觉没有水印相机看着舒服.改成了现在的这种方式。


1.相机实现


水印相机实现有两种方式,在小程序端可以用camera来实现,但在安卓端不支持camera,使用uniapp的live-pusher来实现相机。


而live-pusher推荐使用nvue来做,好处是



  1. nvue也可一套代码编译多端。

  2. nvue的cover-view比vue的cover-view更强大,在视频上绘制元素更容易。如果只考虑App端的话,不用cover-view,任意组件都可以覆盖组件,因为nvue没有层级问题

  3. 若需要视频内嵌在swiper里上下滑动(类抖音、映客首页模式),App端只有nvue才能实现 当然nvue相比vue的坏处是css写法受限,如果只开发微信小程序,不考虑App,那么使用vue页面也是一样的。



  • App平台:使用 <live-pusher/> 组件,打包 App 时必须勾选 manifest.json->App 模块权限配置->LivePusher(直播推流) 模块。




上代码!


<template>
<view class="live-camera" :style="{ width: windowWidth, height: windowHeight }">
<view class="preview" :style="{ width: windowWidth, height: windowHeight }">
<live-pusher
id="livePusher"
ref="livePusher"
class="livePusher"
mode="FHD"
beauty="0"
whiteness="0"
:aspect="aspect"
min-bitrate="1000"
audio-quality="16KHz"
device-position="back"
:auto-focus="true"
:muted="true"
:enable-camera="true"
:enable-mic="false"
:zoom="false"
@statechange="statechange"
:style="{ width: windowWidth, height: windowHeight }"
>
</live-pusher>
<!--这里修改水印的样式-->
<cover-view class="remind">
<text class="remind-text" style="">{{ message }}</text>
<text class="remind-text" style="">经度:1002.32</text>
<text class="remind-text" style="">纬度:1002.32</text>
</cover-view>
</view>
<view class="menu">
<!--底部菜单区域背景-->
<cover-image class="menu-mask" src="/static/live-camera/bar.png"></cover-image>

<!--返回键-->
<cover-image class="menu-back" @tap="back" src="/static/live-camera/back.png"></cover-image>

<!--快门键-->
<cover-image class="menu-snapshot" @tap="snapshot" src="/static/live-camera/shutter.png"></cover-image>

<!--反转键-->
<cover-image class="menu-flip" @tap="flip" src="/static/live-camera/flip.png"></cover-image>
</view>
</view>
</template>

<script>
let _this = null;
export default {
data() {
return {
dotype: 'watermark',
message: '水印相机', //水印内容
poenCarmeInterval: null, //打开相机的轮询
aspect: '2:3', //比例
windowWidth: '', //屏幕可用宽度
windowHeight: '', //屏幕可用高度
camerastate: false, //相机准备好了
livePusher: null, //流视频对象
snapshotsrc: null //快照
};
},
onLoad(e) {
_this = this;
if (e.dotype != undefined) this.dotype = e.dotype;
this.initCamera();
},
onReady() {
this.livePusher = uni.createLivePusherContext('livePusher', this);
this.startPreview(); //开启预览并设置摄像头
this.poenCarme();
},
methods: {
//轮询打开
poenCarme() {
//#ifdef APP-PLUS
if (plus.os.name == 'Android') {
this.poenCarmeInterval = setInterval(function () {
console.log(_this.camerastate);
if (!_this.camerastate) _this.startPreview();
}, 2500);
}
//#endif
},
//初始化相机
initCamera() {
uni.getSystemInfo({
success: function (res) {
_this.windowWidth = res.windowWidth;
_this.windowHeight = res.windowHeight;
let zcs = _this.aliquot(_this.windowWidth, _this.windowHeight);
_this.aspect = _this.windowWidth / zcs + ':' + _this.windowHeight / zcs;
console.log('画面比例:' + _this.aspect);
}
});
},

//整除数计算
aliquot(x, y) {
if (x % y == 0) return y;
return this.aliquot(y, x % y);
},

//开始预览
startPreview() {
this.livePusher.startPreview({
success: (a) => {
console.log(a);
}
});
},

//停止预览
stopPreview() {
this.livePusher.stopPreview({
success: (a) => {
_this.camerastate = false; //标记相机未启动
}
});
},

//状态
statechange(e) {
//状态改变
console.log(e);
if (e.detail.code == 1007) {
_this.camerastate = true;
} else if (e.detail.code == -1301) {
_this.camerastate = false;
}
},

//返回
back() {
uni.navigateBack();
},

//抓拍
snapshot() {
this.livePusher.snapshot({
success: (e) => {
_this.snapshotsrc = e.message.tempImagePath;
_this.stopPreview();
_this.setImage();
uni.navigateBack();
}
});
},

//反转
flip() {
this.livePusher.switchCamera();
},

//设置
setImage() {
let pages = getCurrentPages();
let prevPage = pages[pages.length - 2]; //上一个页面

//直接调用上一个页面的setImage()方法,把数据存到上一个页面中去
prevPage.$vm.setImage({ path: _this.snapshotsrc, dotype: this.dotype });
}
}
};
</script>

<style lang="scss">
.live-camera {
justify-content: center;
align-items: center;
.preview {
justify-content: center;
align-items: center;
.remind {
position: absolute;
bottom: 180rpx;
left: 20rpx;
width: 130px;
z-index: 100;
.remind-text {
color: #dddddd;
font-size: 40rpx;
text-shadow: #fff 1px 0 0, #fff 0 1px 0, #fff -1px 0 0, #fff 0 -1px 0;
}
}
}
.menu {
position: absolute;
left: 0;
bottom: 0;
width: 750rpx;
height: 180rpx;
z-index: 98;
align-items: center;
justify-content: center;
.menu-mask {
position: absolute;
left: 0;
bottom: 0;
width: 750rpx;
height: 180rpx;
z-index: 98;
}
.menu-back {
position: absolute;
left: 30rpx;
bottom: 50rpx;
width: 80rpx;
height: 80rpx;
z-index: 99;
align-items: center;
justify-content: center;
}
.menu-snapshot {
width: 130rpx;
height: 130rpx;
z-index: 99;
}
.menu-flip {
position: absolute;
right: 30rpx;
bottom: 50rpx;
width: 80rpx;
height: 80rpx;
z-index: 99;
align-items: center;
justify-content: center;
}
}
}
</style>

2.水印图片绘制


图片水印返回上一页用<canvas>添加水印

<template>
<view class="page">
<view style="height: 80rpx;"></view>
<navigator class="buttons" url="../camera/watermark/watermark"><button type="primary">打开定制水印相机</button></navigator>
<view style="height: 80rpx;"></view>

<view>拍摄结果预览图,见下方</view>
<image class="preview" :src="imagesrc" mode="aspectFit" style="width:710rpx:height:710rpx;margin: 20rpx;"></image>

<canvas id="canvas-clipper" canvas-id="canvas-clipper" type="2d" :style="{width: canvasSiz.width+'px',height: canvasSiz.height+'px',position: 'absolute',left:'-500000px',top: '-500000px'}" />
</view>
</template>

<script>
var _this;
export default {
data() {
return {
windowWidth:'',
windowHeight:'',
imagesrc: null,
canvasSiz:{
width:188,
height:273
}
};
},
onLoad() {
_this= this;
this.init();
},
methods: {

//设置图片
setImage(e) {
console.log(e);
//显示在页面
//this.imagesrc = e.path;
if(e.dotype =='idphoto'){
_this.zjzClipper(e.path);
}else if(e.dotype =='watermark'){
_this.watermark(e.path);
}else{
_this.savePhoto(e.path);
}
},


//添加照片水印
watermark(path){
uni.getImageInfo({
src: path,
success: function(image) {
console.log(image);

_this.canvasSiz.width =image.width;
_this.canvasSiz.height =image.height;

//担心尺寸重置后还没生效,故做延迟
setTimeout(()=>{
let ctx = uni.createCanvasContext('canvas-clipper', _this);

ctx.drawImage(
path,
0,
0,
image.width,
image.height
);

//具体位置如需和相机页面上一致还需另外做计算,此处仅做大致演示
ctx.setFillStyle('white');
ctx.setFontSize(40);
ctx.fillText('live-camera', 20, 100);


//再来加个时间水印
var now = new Date();
var time= now.getFullYear()+'-'+now.getMonth()+'-'+now.getDate()+' '+now.getHours()+':'+now.getMinutes()+':'+now.getMinutes();
ctx.setFontSize(30);
ctx.fillText(time, 20, 140);

ctx.draw(false, () => {
uni.canvasToTempFilePath(
{
destWidth: image.width,
destHeight: image.height,
canvasId: 'canvas-clipper',
fileType: 'jpg',
success: function(res) {
_this.savePhoto(res.tempFilePath);
}
},
_this
);
});
},500)


}
});
},

//保存图片到相册,方便核查
savePhoto(path){
this.imagesrc = path;
//保存到相册
uni.saveImageToPhotosAlbum({
filePath: path,
success: () => {
uni.showToast({
title: '已保存至相册',
duration: 2000
});
}
});
},

//初始化
init(){
let _this = this;
uni.getSystemInfo({
success: function(res) {
_this.windowWidth = res.windowWidth;
_this.windowHeight = res.windowHeight;
}
});
}

}
};
</script>

<style lang="scss">
.page {
width: 750rpx;
justify-content: center;
align-items: center;
flex-direction:column;
display: flex;
.buttons {
width: 600rpx;
}
}


</style>

8dbee86b262efefc549933df666fbc7.jpg


作者:山沫微云
来源:juejin.cn/post/7399983106750447627
收起阅读 »

前端如何做截图?

web
一、 背景 页面截图功能在前端开发中,特别是营销场景相关的需求中, 是比较常见的。比如截屏分享,相对于普通的链接分享,截屏分享具有更丰富的展示、更多的信息承载等优势。最近在需求开发中遇到了相关的功能,所以调研了相关的实现和原理。 二、相关技术 前端要实现页面截...
继续阅读 »

一、 背景


页面截图功能在前端开发中,特别是营销场景相关的需求中, 是比较常见的。比如截屏分享,相对于普通的链接分享,截屏分享具有更丰富的展示、更多的信息承载等优势。最近在需求开发中遇到了相关的功能,所以调研了相关的实现和原理。


二、相关技术


前端要实现页面截图的功能,现在比较常见的方式是使用开源的截图npm库,一般使用比较多的npm库有以下两个:



以上两种常见的npm库,对应着两种常见的实现原理。实现前端截图,一般是使用图形API重新绘制页面生成图片,基本就是SVG(dom-to-image)和Canvas(html2canvas)两种实现方案,两种方案目标相同,即把DOM转为图片,下面我们来分别看看这两类方案。


三、 dom-to-image


dom-to-image库主要使用的是SVG实现方式,简单来说就是先把DOM转换为SVG然后再把SVG转换为图片。


(一)使用方式


首先,我们先来简单了解一下dom-to-image提供的核心api,有如下一些方法:



  • toSvg (dom转svg)

  • toPng (dom转png)

  • toJpeg (dom转jpg)

  • toBlob (dom转二进制格式)

  • toPixelData (dom转原始像素值)


如需要生成一张png的页面截图,实现代码如下:


import domtoimage from "domtoimage"

const node = document.getElementById('node');
domtoimage.toPng(node,options).then((dataUrl) => {
const img = new Image();
img.src = dataUrl;
document.body.appendChild(img);
})

toPng方法可传入两个参数node和options。


node为要生成截图的dom节点;options为支持的属性配置,具体如下:filter,backgroundColor,width,height,style,quality,imagePlaceholder,cacheBust。


(二)原理分析


dom to image的源码代码不是很多,总共不到千行,下面就拿toPng方法做一下简单的源码解析,分析一下其实现原理,简单流程如下:


image.png


整体实现过程用到了几个函数:



  • toPng(调用draw,实现canvas=>png )

  • Draw(调用toSvg,实现dom=>canvas)

  • toSvg(调用cloneNode和makeSvgDataUri,实现dom=>svg)

  • cloneNode(克隆处理dom和css)

  • makeSvgDataUri(实现dom=>svg data:url)

  • toPng


toPng函数比较简单,通过调用draw方法获取转换后的canvas,利用toDataURL转化为图片并返回。


function toPng(node, options) {
return draw(node, options || {})
.then((canvas) => canvas.toDataURL());
}


  • draw


draw函数首先调用toSvg方法获得dom转化后的svg,然后将获取的url形式的svg处理成图片,并新建canvas节点,然后借助drawImage()方法将生成的图片放在canvas画布上。


function draw(domNode, options) {
return toSvg(domNode, options)
// 拿到的svg是image data URL, 进一步创建svg图片
.then(util.makeImage)
.then(util.delay(100))
.then((image) => {
// 创建canvas,在画布上绘制图像并返回
const canvas = newCanvas(domNode);
canvas.getContext("2d").drawImage(image, 0, 0);
return canvas;
});
// 新建canvas节点,设置一些样式的options参数
function newCanvas(domNode) {
const canvas = document.createElement("canvas");
canvas.width = options.width || util.width(domNode);
canvas.height = options.height || util.height(domNode);
if (options.bgcolor) {
const ctx = canvas.getContext("2d");
ctx.fillStyle = options.bgcolor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas;
}
}


  • toSvg

  • toSvg函数实现从dom到svg的处理,大概步骤如下:

  • 递归去克隆dom节点(调用cloneNode函数)

  • 处理字体,获取所有样式,找到所有的@font-face和内联资源,解析并下载对应的资源,将资源转为dataUrl给src使用。把上面处理完的css rules放入中,并把标签加入到clone的节点中去。
  • 处理图片,将img标签的src的url和css中backbround中的url,转为dataUrl使用。

  • 获取dom节点转化的dataUrl数据(调用makeSvgDataUri函数)


function toSvg(node, options) {
options = options || {};
// 处理imagePlaceholder、cacheBust值
copyOptions(options);
return Promise.resolve(node)
.then((node) =>
// 递归克隆dom节点
cloneNode(node, options.filter, true))
// 把字体相关的csstext放入style
.then(embedFonts)
// clone处理图片,将图片链接转换为dataUrl
.then(inlineImages)
// 添加options里的style放入style
.then(applyOptions)
.then((clone) =>
// node节点转化成svg
makeSvgDataUri(clone,
options.width || util.width(node),
options.height || util.height(node)));
// 处理一些options的样式
function applyOptions(clone) {
...
return clone;
}
}


  • cloneNode


cloneNode函数主要处理dom节点,内容比较多,简单总结实现如下:



  • 递归clone原始的dom节点,其中, 其中如果有canvas将转为image对象。

  • 处理节点的样式,通过getComputedStyle方法获取节点元素的所有CSS属性的值,并将这些样式属性插入新建的style标签上面, 同时要处理“:before,:after”这些伪元素的样式, 最后处理输入内容和svg。


function cloneNode(node, filter, root) {
if (!root && filter && !filter(node)) return Promise.resolve();
return Promise.resolve(node)
.then(makeNodeCopy)
.then((clone) => cloneChildren(node, clone, filter))
.then((clone) => processClone(node, clone));
function makeNodeCopy(node) {
// 将canvas转为image对象
if (node instanceof HTMLCanvasElement) return util.makeImage(node.toDataURL());
return node.cloneNode(false);
}
// 递归clone子节点
function cloneChildren(original, clone, filter) {
const children = original.childNodes;
if (children.length === 0) return Promise.resolve(clone);
return cloneChildrenInOrder(clone, util.asArray(children), filter)
.then(() => clone);
function cloneChildrenInOrder(parent, children, filter) {
let done = Promise.resolve();
children.forEach((child) => {
done = done
.then(() => cloneNode(child, filter))
.then((childClone) => {
if (childClone) parent.appendChild(childClone);
});
});
return done;
}
}
function processClone(original, clone) {
if (!(clone instanceof Element)) return clone;
return Promise.resolve()
.then(cloneStyle)
.then(clonePseudoElements)
.then(copyUserInput)
.then(fixSvg)
.then(() => clone);
// 克隆节点上的样式。
function cloneStyle() {
...
}
// 提取伪类样式,放到css
function clonePseudoElements() {
...
}
// 处理Input、TextArea标签
function copyUserInput() {
...
}
// 处理svg
function fixSvg() {
...
}
}
}


  • makeSvgDataUri


首先,我们需要了解两个特性:



  • SVG有一个元素,这个元素的作用是可以在其中使用具有其它XML命名空间的XML元素,换句话说借助标签,我们可以直接在SVG内部嵌入XHTML元素,举个例子:


<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="120" height="50">
<body xmlns="http://www.w3.org/1999/xhtml">
<p>文字。</p>
</body>
</foreignObject>
</svg>

可以看到标签里面有一个设置了xmlns=“http://www.w3.org/1999/xhtml”…标签,此时标签及其子标签都会按照XHTML标准渲染,实现了SVG和XHTML的混合使用。



  • XMLSerializer对象能够把一个XML文档或Node对象转化或“序列化”为未解析的XML标记的一个字符串。


基于以上特性,我们再来看一下makeSvgDataUri函数,该方法实现node节点转化为svg,就用到刚刚提到的两个重要特性。


首先将dom节点通过


XMLSerializer().serializeToString() 序列化为字符串,然后在


标签 中嵌入转换好的字符串,foreignObject 能够在 svg


内部嵌入XHTML,再将svg处理为dataUrl数据返回,具体实现如下:


function makeSvgDataUri(node, width, height) {
return Promise.resolve(node)
.then((node) => {
// 将dom转换为字符串
node.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
return new XMLSerializer().serializeToString(node);
})
.then(util.escapeXhtml)
.then((xhtml) => `<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject>`)
// 转化为svg
.then((foreignObject) =>
// 不指定xmlns命名空间是不会渲染的
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${
foreignObject}
</svg>`
)
// 转化为data:url
.then((svg) => `data:image/svg+xml;charset=utf-8,${svg}`);
}

四、 html2canvas


html2canvas库主要使用的是Canvas实现方式,主要过程是手动将dom重新绘制成canvas,因此,它只能正确渲染可以理解的属性,有许多CSS属性无法正确渲染。


支持的CSS属性的完整列表:


html2canvas.hertzen.com/features/


浏览器兼容性:


Firefox 3.5+ Google Chrome Opera 12+ IE9+ Edge Safari 6+


官方文档地址:


html2canvas.hertzen.com/documentati…


(一)使用方式


// dom即是需要绘制的节点, option为一些可配置的选项
import html2canvas from 'html2canvas'
html2canvas(dom, option).then(canvas=>{
canvas.toDataURL()
})

常用的option配置:


image.png


全部配置文档:


html2canvas.hertzen.com/configurati…


(二)原理分析


html2canvas的内部实现相对dom-to-image来说要复杂一些, 基本原理是读取DOM元素的信息,基于这些信息去构建截图,并呈现在canvas画布中。


其中重点就在于将dom重新绘制成canvas的过程,该过程整体的思路是:遍历目标节点和目标节点的子节点,遍历过程中记录所有节点的结构、内容和样式,然后计算节点本身的层级关系,最后根据不同的优先级绘制到canvas画布中。


由于html2canvas的源码量比较大,可能无法像dom-to-image一样详细的分析,但还是可以大致了解一下整体的流程,首先可以看一下源码中src文件夹中的代码结构,如下图:


image.png


简单解析一下:



  • index:入口文件,将dom节点渲染到一个canvas中,并返回。

  • core:工具函数的封装,包括对缓存的处理函数、Context方法封装、日志模块等。

  • css:对节点样式的处理,解析各种css属性和特性,进行处理。

  • dom:遍历dom节点的方法,以及对各种类型dom的处理。

  • render:基于clone的节点生成canvas的处理方法。


基于以上这些核心文件,我们来简单了解一下html2canvas的解析过程, 大致的流程如下:


image.png



  • 构建配置项


在这一步会结合传入的options和一些defaultOptions,生成用于渲染的配置数据renderOptions。在过程中会对配置项进行分类,比如resourceOptions(资源跨域相关)、contextOptions(缓存、日志相关)、windowOptions(窗口宽高、滚动配置)、cloneOptions(对指定dom的配置)、renderOptions(render结果的相关配置,包括生成图片的各种属性)等,然后分别将各类配置项传到下接下来的步骤中。



  • clone目标节点并获取样式和内容


在这一步中,会将目标节点到指定的dom解析方法中,这个过程会clone目标节点和其子节点,获取到节点的内容信息和样式信息,其中clone dom的解析方法也是比较复杂的,这里不做详细展开。获取到目标节点后,需要把克隆出来的目标节点的dom装载到一个iframe里,进行一次渲染,然后就可以获取到经过浏览器视图真实呈现的节点样式。



  • 解析目标节点


目标节点的样式和内容都获取到了之后,就需要把它所承载的数据信息转化为Canvas可以使用的数据类型。在对目标节点的解析方法中,递归整个DOM树,并取得每一层节点的数据,对于每一个节点而言需要绘制的部分包括边框、背景、阴影、内容,而对于内容就包含图片、文字、视频等。在整个解析过程中,对目标节点的所有属性进行解析构造,转化成为指定的数据格式,基础数据格式可见以下代码:


class ElementContainer {
// 所有节点上的样式经过转换计算之后的信息
readonly styles: CSSParsedDeclaration;
// 节点的文本节点信息, 包括文本内容和其他属性
readonly textNodes: TextContainer[] = [];
// 当前节点的子节点
readonly elements: ElementContainer[] = [];
// 当前节点的位置信息(宽/高、横/纵坐标)
bounds: Bounds;
flags = 0;
...
}

具体到不同类型的元素如图片、IFrame、SVG、input等还会extends ElementContainer拥有自己的特定数据结构,在此不详细贴出。



  • 构建内部渲染器


把目标节点处理成特定的数据结构之后,就需要结合Canvas调用渲染方法了,Canvas绘图需要根据样式计算哪些元素应该绘制在上层,哪些在下层,那么这个规则是什么样的呢?这里就涉及到CSS布局相关的一些知识。


默认情况下,CSS是流式布局的,元素与元素之间不会重叠。不过有些情况下,这种流式布局会被打破,比如使用了浮动(float)和定位(position)。因此需要需要识别出哪些脱离了正常文档流的元素,并记住它们的层叠信息,以便正确地渲染它们。


那些脱离正常文档流的元素会形成一个层叠上下文。元素在浏览器中渲染时,根据W3C的标准,所有的节点层级布局,需要遵循层叠上下文和层叠顺序的规则,具体规则如下:


image.png


在了解了元素的渲染需要遵循这个标准后,Canvas绘制节点的时候,需要生成指定的层叠数据,就需要先计算出整个目标节点里子节点渲染时所展现的不同层级,构造出所有节点对应的层叠上下文在内部所表现出来的数据结构,具体数据结构如下:


// 当前元素
element: ElementPaint;
// z-index为负, 形成层叠上下文
negativeZIndex: StackingContext[];
// z-index为0、auto、transform或opacity, 形成层叠上下文
zeroOrAutoZIndexOrTransformedOrOpacity: StackingContext[];
// 定位和z-index形成的层叠上下文
positiveZIndex: StackingContext[];
// 没有定位和float形成的层叠上下文
nonPositionedFloats: StackingContext[];
// 没有定位和内联形成的层叠上下文
nonPositionedInlineLevel: StackingContext[];
// 内联节点
inlineLevel: ElementPaint[];
// 不是内联的节点
nonInlineLevel: ElementPaint[];

基于以上数据结构,将元素子节点分类,添加到指定的数组中,解析层叠信息的方式和解析节点信息的方式类似,都是递归整棵树,收集树的每一层的信息,形成一颗包含层叠信息的层叠树。



  • 绘制数据


基于上面两步构造出的数据,就可以开始调用内部的绘制方法,进行数据处理和绘制了。使用节点的层叠数据,依据浏览器渲染层叠数据的规则,将DOM元素一层一层渲染到canvas中,其中核心具体源码如下:


async renderStackContent(stack: StackingContext): Promise<void> {
if (contains(stack.element.container.flags, FLAGS.DEBUG_RENDER)) {
debugger;
}
// 1. the background and borders of the element forming the stacking context.
await this.renderNodeBackgroundAndBorders(stack.element);
// 2. the child stacking contexts with negative stack levels (most negative first).
for (const child of stack.negativeZIndex) {
await this.renderStack(child);
}
// 3. For all its in-flow, non-positioned, block-level descendants in tree order:
await this.renderNodeContent(stack.element);
for (const child of stack.nonInlineLevel) {
await this.renderNode(child);
}
// 4. All non-positioned floating descendants, in tree order. For each one of these,
// treat the element as if it created a new stacking context, but any positioned descendants and descendants
// which actually create a new stacking context should be considered part of the parent stacking context,
// not this new one.
for (const child of stack.nonPositionedFloats) {
await this.renderStack(child);
}
// 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
for (const child of stack.nonPositionedInlineLevel) {
await this.renderStack(child);
}
for (const child of stack.inlineLevel) {
await this.renderNode(child);
}
// 6. All positioned, opacity or transform descendants, in tree order that fall int0 the following categories:
// All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
// For those with 'z-index: auto', treat the element as if it created a new stacking context,
// but any positioned descendants and descendants which actually create a new stacking context should be
// considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
// treat the stacking context generated atomically.
//
// All opacity descendants with opacity less than 1
//
// All transform descendants with transform other than none
for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
await this.renderStack(child);
}
// 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
// order (smallest first) then tree order.
for (const child of stack.positiveZIndex) {
await this.renderStack(child);
}
}

在renderStackContent方法中,首先对元素本身调用renderNodeContent和renderNodeBackgroundAndBorders进行渲染处理。


然后处理各个分类的子元素,如果子元素形成了层叠上下文,就调用renderStack方法,这个方法内部继续调用了renderStackContent,这就形成了对于层叠上下文整个树的递归。


如果子元素是正常元素没有形成层叠上下文,就直接调用renderNode,renderNode包括两部分内容,渲染节点内容和渲染节点边框背景色。


async renderNode(paint: ElementPaint): Promise<void> {
if (paint.container.styles.isVisible()) {
// 渲染节点的边框和背景色
await this.renderNodeBackgroundAndBorders(paint);
// 渲染节点内容
await this.renderNodeContent(paint);
}
}

其中renderNodeContent方法是渲染一个元素节点里面的内容,其可能是正常元素、文字、图片、SVG、Canvas、input、iframe,对于不同的内容也会有不同的处理。


以上过程,就是html2canvas的整体内部流程,在了解了大致原理之后,我们再来看一个更为详细的源码流程图,对上述流程进行一个简单的总结。


五、 常见问题总结


在使用html2canvas的过程中,会有一些常见的问题和坑,总结如下:


(一)截图不全


要解决这个问题,只需要在截图之前将页面滚动到顶部即可:


document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;

(二)图片跨域


插件在请求图片的时候会有图片跨域的情况,这是因为,如果使用跨域的资源画到canvas中,并且资源没有使用CORS去请求,canvas会被认为是被污染了,canvas可以正常展示,但是没办法使用toDataURL()或者toBlob()导出数据,详情可参考:developer.mozilla.org/en-US/docs/…


解决方案:在img标签上设置crossorigin,属性值为anonymous,可以开启CROS请求。当然,这种方式的前提还是服务端的响应头Access-Control-Allow-Origin已经被设置过允许跨域。如果图片本身服务端不支持跨域,可以使用canvas统一转成base64格式,方法如下。


function getUrlBase64_pro( len,url ) {
//图片转成base64
var canvas = document.createElement("canvas"); //创建canvas DOM元素
var ctx = canvas.getContext("2d");
return new Promise((reslove, reject) => {
var img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function() {
canvas.height = len;
canvas.width = len;
ctx.drawImage(img, 0, 0, len, len);
var dataURL = canvas.toDataURL("image/");
canvas = null;
reslove(dataURL);
};
img.onerror = function(err){
reject(err)
}
img.src = url;
});
}

(三)截图与当前页面有区别


方式一:如果要从渲染中排除某些elements,可以向这些元素添加data-html2canvas-ignore属性,html2cnavas会将它们从渲染中排除,例如,如果不想截图iframe的部分,可以如下:


html2canvas(ele,{
useCORS: true,
ignoreElements: (element: any) => {
if (element.tagName.toLowerCase() === 'iframe') {
return element;
}
return false;
},
})

方式二:可以将需要转化成图片的部分放在一个节点内,再把整个节点,透明度设置为0, 其他部分层级设置高一些,即可实现截图指定区域。


六、 小结


本文针对前端截图实现的方式,对两个开源库dom-to-image和html2canvas的使用和原理进行了简单的使用方式、实现原理方面,进行介绍和分析。


参考资料:


1.dom-to-image原理


2.html2image原理简述


3.浏览器端网页截图方案详解


4.html2canvas


5.html2canvas实现浏览器截图的原理(包含源码分析的通用方法)


作者:庚云
来源:juejin.cn/post/7400319811358818340
收起阅读 »

好好的短链,url?1=1为啥变成了url???1=1

运营小伙伴突然找到我们说,我们的一个短链有三个? 第一反应就是不可能,但是事实胜于雄辩,还真的就是和运营小伙伴说的一模一样。 到底发生了什么呢?跟着我一起Review一下。 一、URL结构 1.1 URL概述 URL(统一资源定位符)是一个用于标识互联网上资...
继续阅读 »

运营小伙伴突然找到我们说,我们的一个短链有三个?


第一反应就是不可能,但是事实胜于雄辩,还真的就是和运营小伙伴说的一模一样。


到底发生了什么呢?跟着我一起Review一下。



一、URL结构


1.1 URL概述


URL(统一资源定位符)是一个用于标识互联网上资源的地址。一个典型的URL结构通常包括以下几个部分:


image.png



  1. 协议(Scheme) :也称为"服务方式",位于URL的开头,指定了浏览器与服务器之间通信的方式。常见的协议有http(超文本传输协议)、https(安全超文本传输协议)、ftp(文件传输协议)等。

  2. 子域名(Subdomain) :可选部分,位于域名之前,通常用于区分不同的服务或组织。例如,在sub.example.com中,sub是子域名。

  3. 域名(Domain Name) :URL的核心部分,用于唯一标识一个网站。通常是一个组织或公司的名字,如example.com

  4. 端口号(Port) :可选部分,用于指定服务器上的特定服务。如果省略,浏览器将使用默认端口,例如httphttps的默认端口是80和443。

  5. 路径(Path) :指定服务器上的资源位置。路径可以包含多个部分,用斜杠/分隔。例如,在/path/to/resource中,path/to/resource是资源的路径。

  6. 查询字符串(Query String) :可选部分,位于路径之后,用于传递额外的参数或数据。查询字符串以问号?开始,后面跟着一系列的参数,参数之间用和号&分隔。例如,在?key1=value1&key2=value2中,key1key2是参数名,value1value2是对应的值。

  7. 片段标识符(Fragment Identifier) :可选部分,用于指向页面内的特定部分。片段标识符以井号#开始,通常用于锚点链接。例如,在#section2中,section2是页面内的一个锚点。


1.2 URL示例


示例:一个完整的URL示例可能是下面这样的


https://www.xxx.com:8080/path/to/resource?key1=value1&key2=value2#section2

在上面的示例中,详细拆解如下:



https 是协议。
http://www.xxx.com 是域名。
8080 是端口号。
/path/to/resource 是路径。
key1=value1&key2=value2 是查询字符串。
#section2 是片段标识符。



二、URL的意义


URL(统一资源定位符)的意义在于它提供了一种标准化的方法来标识和访问互联网上的资源。它是互联网的基础构件之一,它不仅使得资源的定位和访问变得简单,还支持了互联网的组织、导航、安全和分享等多种功能。以下是URL的几个关键意义:


image.png


这些意义做开发的都懂,不懂的就自己百度吧,这里不做赘述。


三、硬菜:url?1=1为啥变成了url???1=1


3.1 故事背景


我们有一个自己的短链项目,用户访问短链的时候,我们自己服务器会进行重定向,这样的好处是分享出去的链接都是很短的,会有效提升用户的使用体验。


短链触发和服务器的交互流程如下:


sequenceDiagram
用户->>+短链: 点击
短链->>+服务器: 请求
服务器->>+服务器: 找到映射的长链地址
服务器->>+用户: 重定向到长链
用户->>+长链: 请求并得到响应

3.2 事故现场


上面弄清楚了短链的基本触发流程,那我我们看看到底发生了什么。



  • 客户端事故现场截图


image.png



从这个截图就可以明显的看出,这里有三个?,这是不合理的...




  • 数据库存储的事故现场数据截图


image.png


哎,数据库里面只有一个问号吧?


3.3 问题分析和解决方案



  • 问题分析


上面数据库看着正常的,别着急,咱们换个方式看看,我们执行下面这个SQL看看数据存储的实际长度是多少。


SELECT
LENGTH(
CONVERT ( full_link USING utf8 )) AS actual_length
FROM
t_short_link
WHERE
id = '0fcc75b3e1b243c4b36d71b1d58b3b41';

执行结果:


image.png



上面sql执行实际得到的长度是52,但是我们长链的实际长度却是49,那么问题就出来了,数据库里面多了两个我们肉眼看不见的字符,三个问号就是这个来的




  • 解决方案


从上面分析了事故现场,我们已经知道是多了两个字符了,删掉即可。


注意:因为数据库看不到,所以不能直接编辑,可以选择一些可以看到的编辑器编辑之后更新,例如notepad++。


3.4 额外发现


在写文章的时候,我将连接复制到了掘金的MD编辑器,发现这里也是暴露了问题,上面提到的解决方案,大家也是可以复制进来然后删除多余字符的。


image.png


四、总结


程序员大多数都非常自信,相信自己的代码没有bug,相信有bug也不是我的问题,有的时候怼天怼地。


但是真的遇到问题,需要三思而后行,谋定而后动;是不是自己的问题,先检查检查,避免后面发现是自己的问题很尴尬。



希望本文对您有所帮助。如果有任何错误或建议,请随时指正和提出。


同时,如果您觉得这篇文章有价值,请考虑点赞和收藏。这将激励我进一步改进和创作更多有用的内容。


感谢您的支持和理解!



作者:竹子爱揍功夫熊猫
来源:juejin.cn/post/7399985723674394633
收起阅读 »

折腾我2周的分页打印和下载pdf

web
1.背景 一开始接到任务需要打印html,之前用到了vue-print-nb-jeecg来处理Vue2一个打印的问题,现在是遇到需求要在Vue3项目里面去打印十几页的打印和下载为pdf,难点和坑就在于我用的库vue3-print-nb来做分页打印预览,下载pd...
继续阅读 »

1722391577748.jpg


1.背景


一开始接到任务需要打印html,之前用到了vue-print-nb-jeecg来处理Vue2一个打印的问题,现在是遇到需求要在Vue3项目里面去打印十几页的打印和下载为pdf,难点和坑就在于我用的库vue3-print-nb来做分页打印预览,下载pdf后面介绍


2.预览打印实现


    <div id="printMe" style="background:red;">
<p>葫芦娃,葫芦娃</p>
<p>一根藤上七朵花 </p>
<p>小小树藤是我家 啦啦啦啦 </p>
<p>叮当当咚咚当当 浇不大</p>
<p> 叮当当咚咚当当 是我家</p>
<p> 啦啦啦啦</p>
<p>...</p>
</div>

<button v-print="'#printMe'">Print local range</button>

因为官方提供的方案都是DOM加载完成后然后直接打印,但是我的需求是需要点击打印的时候根据id渲染不同的组件然后渲染DOM,后面仔细看官方文档,有个beforeOpenCallback方法在打印预览之前有个钩子,但是这个钩子没办法确定我接口加载完毕,所以我的思路就是用户先点击我写的点击按钮事件,等异步渲染完毕之后,我再同步触发真正的打印预览按钮,这样就变相解决了我的需求。




  1. 没办法处理接口异步渲染数据展示DOM进行打印操作

  2. 在布局相对定位的时候在谷歌浏览器会发现有布局整体变小的问题(后续用zoom处理的)


3.掉头发之下载pdf


下载pdf这种需求才是我每次去理发店不敢让tony把我头发打薄的原因,我看了很多技术文章,结合个人业务情况,采取的方案是html2canvas把html转成canvas然后转成图片然后通过jsPDF截取图片分页最后下载到本地。本人秉承着不生产水,只做大自然的搬运工的匠人精神,迅速而又果断的从社区来到社区去,然后找到了适配当前业务的逻辑代码(实践出真知)。


import html2canvas from 'html2canvas'
import jsPDF, { RGBAData } from 'jspdf'

/** a4纸的尺寸[595.28,841.89], 单位毫米 */
const [PAGE_WIDTH, PAGE_HEIGHT] = [595.28, 841.89]

const PAPER_CONFIG = {
/** 竖向 */
portrait: {
height: PAGE_HEIGHT,
width: PAGE_WIDTH,
contentWidth: 560
},
/** 横向 */
landscape: {
height: PAGE_WIDTH,
width: PAGE_HEIGHT,
contentWidth: 800
}
}

// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element: HTMLElement, width: number) {
if (!element) return { width, height: 0 }

// canvas元素
const canvas = await html2canvas(element, {
// allowTaint: true, // 允许渲染跨域图片
scale: window.devicePixelRatio * 2, // 增加清晰度
useCORS: true // 允许跨域
})

// 获取canvas转化后的宽高
const { width: canvasWidth, height: canvasHeight } = canvas

// html页面生成的canvas在pdf中的高度
const height = (width / canvasWidth) * canvasHeight

// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0)

return { width, height, data: canvasData }
}

/**
* 生成pdf(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
* @param param0
* @returns
*/

export async function outputPDF({
/** pdf内容的dom元素 */
element,

/** 页脚dom元素 */
footer,

/** 页眉dom元素 */
header,

/** pdf文件名 */
filename,

/** a4值的方向: portrait or landscape */
orientation = 'portrait' as 'portrait' | 'landscape'
}
) {
if (!(element instanceof HTMLElement)) {
return
}

if (!['portrait', 'landscape'].includes(orientation)) {
return Promise.reject(
new Error(`Invalid Parameters: the parameter {orientation} is assigned wrong value, you can only assign it with {portrait} or {landscape}`)
)
}
const [A4_WIDTH, A4_HEIGHT] = [PAPER_CONFIG[orientation].width, PAPER_CONFIG[orientation].height]

/** 一页pdf的内容宽度, 左右预设留白 */
const { contentWidth } = PAPER_CONFIG[orientation]

// eslint-disable-next-line new-cap
const pdf = new jsPDF({
unit: 'pt',
format: 'a4',
orientation
})

// 一页的高度, 转换宽度为一页元素的宽度
const { width, height, data } = await toCanvas(element, contentWidth)

// 添加
function addImage(
_x: number,
_y: number,
pdfInstance: jsPDF,
base_data: string | HTMLImageElement | HTMLCanvasElement | Uint8Array | RGBAData,
_width: number,
_height: number
) {
pdfInstance.addImage(base_data, 'JPEG', _x, _y, _width, _height)
}

// 增加空白遮挡
function addBlank(x: number, y: number, _width: number, _height: number) {
pdf.setFillColor(255, 255, 255)
pdf.rect(x, y, Math.ceil(_width), Math.ceil(_height), 'F')
}

// 页脚元素 经过转换后在PDF页面的高度
const { height: tFooterHeight, data: headerData } = footer ? await toCanvas(footer, contentWidth) : { height: 0, data: undefined }

// 页眉元素 经过转换后在PDF的高度
const { height: tHeaderHeight, data: footerData } = header ? await toCanvas(header, contentWidth) : { height: 0, data: undefined }

// 添加页脚
async function addHeader(headerElement: HTMLElement) {
headerData && pdf.addImage(headerData, 'JPEG', 0, 0, contentWidth, tHeaderHeight)
}

// 添加页眉
async function addFooter(pageNum: number, now: number, footerElement: HTMLElement) {
if (footerData) {
pdf.addImage(footerData, 'JPEG', 0, A4_HEIGHT - tFooterHeight, contentWidth, tFooterHeight)
}
}

// 距离PDF左边的距离,/ 2 表示居中
const baseX = (A4_WIDTH - contentWidth) / 2 // 预留空间给左边
// 距离PDF 页眉和页脚的间距, 留白留空
const baseY = 15

// 除去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
const originalPageHeight = A4_HEIGHT - tFooterHeight - tHeaderHeight - 2 * baseY

// 元素在网页页面的宽度
const elementWidth = element.offsetWidth

// PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度 转化为 距离Canvas顶部的高度
const rate = contentWidth / elementWidth

// 每一页的分页坐标, PDF高度, 初始值为根元素距离顶部的距离
const pages = [rate * getElementTop(element)]

// 获取该元素到页面顶部的高度(注意滑动scroll会影响高度)
function getElementTop(contentElement) {
if (contentElement.getBoundingClientRect) {
const rect = contentElement.getBoundingClientRect() || {}
const topDistance = rect.top

return topDistance
}
}

// 遍历正常的元素节点
function traversingNodes(nodes) {
for (const element of nodes) {
const one = element

/** */
/** 注意: 可以根据业务需求,判断其他场景的分页,本代码只判断表格的分页场景 */
/** */

// table的每一行元素也是深度终点
const isTableRow = one.classList && one.classList.contains('ant4-table-row')

// 对需要处理分页的元素,计算是否跨界,若跨界,则直接将顶部位置作为分页位置,进行分页,且子元素不需要再进行判断
const { offsetHeight } = one
// 计算出最终高度
const offsetTop = getElementTop(one)

// dom转换后距离顶部的高度
// 转换成canvas高度
const top = rate * offsetTop
const rateOffsetHeight = rate * offsetHeight

// 对于深度终点元素进行处理
if (isTableRow) {
// dom高度转换成生成pdf的实际高度
// 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
updateTablePos(rateOffsetHeight, top)
}
// 对于普通元素,则判断是否高度超过分页值,并且深入
else {
// 执行位置更新操作
updateNormalElPos(top)
// 遍历子节点
traversingNodes(one.childNodes)
}
updatePos()
}
}

// 普通元素更新位置的方法
// 普通元素只需要考虑到是否到达了分页点,即当前距离顶部高度 - 上一个分页点的高度 大于 正常一页的高度,则需要载入分页点
function updateNormalElPos(top) {
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight)
}
}

// 可能跨页元素位置更新的方法
// 需要考虑分页元素,则需要考虑两种情况
// 1. 普通达顶情况,如上
// 2. 当前距离顶部高度加上元素自身高度 大于 整页高度,则需要载入一个分页点
function updateTablePos(eHeight: number, top: number) {
// 如果高度已经超过当前页,则证明可以分页了
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight)
}
// 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页,将当前高度作为分页位置
else if (
top + eHeight - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight &&
top !== (pages.length > 0 ? pages[pages.length - 1] : 0)
) {
pages.push(top)
}
}

// 深度遍历节点的方法
traversingNodes(element.childNodes)

function updatePos() {
while (pages[pages.length - 1] + originalPageHeight < height) {
pages.push(pages[pages.length - 1] + originalPageHeight)
}
}

// 对pages进行一个值的修正,因为pages生成是根据根元素来的,根元素并不是我们实际要打印的元素,而是element,
// 所��要把它修正,让其值是以真实的打印元素顶部节点为准
const newPages = pages.map(item => item - pages[0])

// 根据分页位置 开始分页
for (let i = 0; i < newPages.length; ++i) {
// 根据分页位置新增图片
addImage(baseX, baseY + tHeaderHeight - newPages[i], pdf, data!, width, height)
// 将 内容 与 页眉之间留空留白的部分进行遮白处理
addBlank(0, tHeaderHeight, A4_WIDTH, baseY)
// 将 内容 与 页脚之间留空留白的部分进行遮白处理
addBlank(0, A4_HEIGHT - baseY - tFooterHeight, A4_WIDTH, baseY)
// 对于除最后一页外,对 内容 的多余部分进行遮白处理
if (i < newPages.length - 1) {
// 获取当前页面需要的内容部分高度
const imageHeight = newPages[i + 1] - newPages[i]
// 对多余的内容部分进行遮白
addBlank(0, baseY + imageHeight + tHeaderHeight, A4_WIDTH, A4_HEIGHT - imageHeight)
}

// 添加页眉
if (header) {
await addHeader(header)
}

// 添加页脚
if (footer) {
await addFooter(newPages.length, i + 1, footer)
}

// 若不是最后一页,则分页
if (i !== newPages.length - 1) {
// 增加分页
pdf.addPage()
}
}
return pdf.save(filename)
}


4.分页的小姿势


如果有需求把打印预览的时候的页眉页脚默认取消不展示,然后自定义页面的边距可以这么设置样式


@page {
size: auto A4 landscape;
margin: 3mm;
}

@media print {
body,
html {
height: initial;
padding: 0px;
margin: 0px;
}
}

5.关于页眉页脚


由于业务是属于比较自定义化的展示,所以我封装成组件,然后根据返回的数据进行渲染到每个界面,然后利用绝对定位放在相同的位置,最后一点小优化就是,公共化提取界面的样式,然后整合为pub.scss然后引入到界面里面,这样即使产品有一定的样式调整,我也可以在公共样式里面去配置和修改,大大的减少本人的工作量。在日常的开发中也是这样,不要去抱怨需求的变动频繁,而是力争在写组件的过程中考虑到组件的健壮性和灵活度,给自己的工作减负,到点下班。


参考文章


juejin.cn/post/732343…


作者:endlesskiller
来源:juejin.cn/post/7397319113796780042
收起阅读 »

借助 LocatorJS ,快速定位本地代码

web
引言 前端coder在刚接触一个新项目时是十分迷茫的,修改一个小 bug 可能要从路由结构入手逐级查找。 LocatorJS 提供了一种更便捷的方式,你只需要按住预设的快捷键选中元素点击,就可以快速打开本地编辑器中的代码,是不是非常神奇? 安装 访问 goo...
继续阅读 »

引言


前端coder在刚接触一个新项目时是十分迷茫的,修改一个小 bug 可能要从路由结构入手逐级查找。 LocatorJS 提供了一种更便捷的方式,你只需要按住预设的快捷键选中元素点击,就可以快速打开本地编辑器中的代码,是不是非常神奇?


安装


访问 google 商店进行插件安装 地址


用法


本文以 MacOS 系统为例, Win系统可以用 Control 键替代 options键使用


LocatorJS 是 Chrome 浏览器的一个扩展程序,使用很便捷,只需要进行下面的三个步骤:



  1. 运行一个本地项目(本文以 LocatorJS源码 的 React 项目为例)

  2. 打开项目访问本地链接(例如:http://localhost:3348 )

  3. 按住键盘的 option 键(win系统是 control)后选中某一个元素并点击


0.jpeg


这时候,就会跳出一个是否打开的提示,点击 “打开Visual Studio Code” 后 元素所在的本地代码就会通过你的 VsCode (或者其他编辑器) 打开。是不是很神奇,那么它是怎么实现的呢?


原理解读


解读 Chrome 扩展程序,我们先打开 apps/extension/src/pages 路径,可以看到如下几个文件夹:


1.png


● Background 是放置后台代码的文件夹,本插件不涉及


● ClientUI 这里只有一行,引入了 @locator/runtime(本插件的核心代码)


● Content 放着插件与浏览器内容页面的代码,与页面代码一起执行


● Popup 文件夹下是点击浏览器插件图标弹出层的代码


4.1 解读  Content/index.ts


  Content/index.ts 中最重要的代码是 injectScript 方法,主要做了两件事情,一个是创建了 Script 标签执行了 hook.bundle.js,另一个是将 client.bundle.js 赋值给了 document.documentElement.dataset.locatorClientUrl(通过 Dom 传值),其余代码是一些监听事件


function injectScript() {
const script = document.createElement('script');
// script.textContent = code.default;
script.src = browser.runtime.getURL('/hook.bundle.js');

document.documentElement.dataset.locatorClientUrl =
browser.runtime.getURL('/client.bundle.js');

// This script runs before the <head> element is created,
// so we add the script to <html> instead.
if (document.documentElement) {
document.documentElement.appendChild(script);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
}
}

4.2 解读 hook.bundle.js


hook.bundle.js 是 hook 文件夹下的 index文件打包后的产物,因此我们去·看 apps/extension/src/pages/hook/index.ts 即可


import { installReactDevtoolsHook } from '@locator/react-devtools-hook';
import { insertRuntimeScript } from './insertRuntimeScript';

installReactDevtoolsHook();
insertRuntimeScript();

● installReactDevtoolsHook 会确保你的 react devtools扩展已安装 (没安装就install一个,猜测是仅涉及使用 API 的轻量版(笔者未深究))


● insertRuntimeScript 会对页面生命周期做一个监听,尝试加载 LocatorJS 的 runtime 组件, 在 insertRuntimeScript() 中,看到了这两行:


  const locatorClientUrl = document.documentElement.dataset.locatorClientUrl;
delete document.documentElement.dataset.locatorClientUrl;

这个 locatorClientUrl 就是之前在 Content/index.ts 里传值的那个 client.bundle.js,这里笔者简单说下,在尝试加载插件的方法 tryToInsertScript() 第一行判断如下:


   if (!locatorClientUrl) {
return 'Locator client url not found';
}

这行判断其实已经可以推测出 client.bundle.js 的重要性了,它加载失败,整个插件直接返回错误信息了。
回过头来看向 ClientUI 文件夹下的 index.tsx 文件:


import '@locator/runtime';

至此,我们已经完成了 locatorJs 的加载逻辑推导,下一步我们讲揭开“定位器”的神秘面纱...


4.3 解读核心代码 runtime 模块


打开 packages/runtime/src/index.ts 文件


3.png
在这里我们看到不论是本地加载 runtime,还是浏览器加载扩展的方式都会去执行 initRuntime


initRuntime.ts


packages/runtime/src/initRuntime.ts 的initRuntime 


4.png
这个文件中声明了一些全局样式,并用 shadow dom 的方式进行了全局的样式隔离,我们关注下底部的这几行代码即可:


  // This weird import is needed because:
// SSR React (Next.js) breaks when importing any SolidJS compiled file, so the import has to be conditional
// Browser Extension breaks when importing with "import()"
// Vite breaks when importing with "require()"
if (typeof require !== "undefined") {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { initRender } = require("./components/Runtime");
initRender(layer, adapter, targets || allTargets);
} else {
import("./components/Runtime").then(({ initRender }) => {
initRender(layer, adapter, targets || allTargets);
});
}

兼容了一下服务端渲染和 SolidJs 的引入方式,引入相对路径下的 ./components/Runtime


核心组件 Runtime.tsx


packages/runtime/src/components/Runtime.tsx
抽丝剥茧,我们终于找到了它的核心组件 Runtime,这是一个使用 SolidJs框架编写的组件,包含了我们选中元素时出现的红框样式,以及所有的事件:


5.png


我们重点关注点击事件 clickListener ,最后点击跳转的方法是 goToLinkProps


export function goToLinkProps(
linkProps: LinkProps,
targets: Targets,
options: OptionsStore
) {
const link = buildLink(linkProps, targets, options);
window.open(link, options.getOptions().hrefTarget || HREF_TARGET);
}

采用逆推的方式,看 clickListener 事件里的 LinkProps 是怎样生成的:


  function clickListener(e: MouseEvent) {
...
const elInfo = getElementInfo(target, props.adapterId);

if (elInfo) {
const linkProps = elInfo.thisElement.link;
...
}
...
}

同样的方式,我们去看看 getElementInfo 怎么返回的(过程略过),我们以 react 的实现为例,打开
packages/runtime/src/adapters/react/reactAdapter.ts, 查看 getElementInfo 方法


export function getElementInfo(found: HTMLElement): FullElementInfo | null {
const labels: LabelData[] = [];

const fiber = findFiberByHtmlElement(found, false);
if (fiber) {
...
const thisLabel = getFiberLabel(fiber, findDebugSource(fiber)?.source);
...
return {
thisElement: {
box: getFiberOwnBoundingBox(fiber) || found.getBoundingClientRect(),
...thisLabel,
},
...
};
}
return null;
}

前面 goToLinkProps 使用的是 thisElement.link 字段, thisLabel 又依赖于 fiber 字段,等等! 这不是我们 react 玩家的老朋友 fiber 吗,我们查看一下生成它的 findFiberByHtmlElement 方法


export function findFiberByHtmlElement(
target: HTMLElement,
shouldHaveDebugSource: boolean
): Fiber | null {
const renderers = window.__REACT_DEVTOOLS_GLOBAL_HOOK__?.renderers;
const renderersValues = renderers?.values();
if (renderersValues) {
for (const renderer of Array.from(renderersValues) as Renderer[]) {
if (renderer.findFiberByHostInstance) {
const found = renderer.findFiberByHostInstance(target as any);
console.log('found', found)
if (found) {
if (shouldHaveDebugSource) {
return findDebugSource(found)?.fiber || null;
} else {
return found;
}
}
}
}
}
return null;
}

可以看到,这里是直接使用的 window 对象下的 __REACT_DEVTOOLS_GLOBAL_HOOK__ 属性做的处理,我们先打印一下 fiber 查看下生成的结构


image.png


惊奇的发现 _debugSource 字段里竟然包含了点击元素所对应本地文件的路径


我们到 goToLinkProps 方法里打印一下跳转的路径发现果然一致,只是实际跳转的路径加上了 vscode:// 开头,进行了协议跳转。


真相解读,_debugOwner 是怎么来的


一路砍瓜切菜终于要接近真相了,回顾代码我们其实只需要搞懂 window.REACT_DEVTOOLS_GLOBAL_HOOK 是怎么来的以及它做了什么,就可以收工了。



  1. _debugOwner 怎么来的?


    _debugOwner 是通过 window.REACT_DEVTOOLS_GLOBAL_HOOK 根据 HtmlElement 生成的 fiber 得来的, 它是 React Devtools 插件的全局变量 HOOK,这就是为什么 hook.bundle.js 要确保安装了 React Devtools


  2. REACT_DEVTOOLS_GLOBAL_HOOK 做了什么


    它是通过 @babel/plugin-transform-react-jsx-source 实现的,这个 plugin 可以在创建 fiber 的时候,将元素本地代码的位置信息保存下来,以 _debugSource 字段进行抛出



总结


LocatorJs 的 React 方案使用 React Devtools 扩展的全局 Hook,由 @babel/plugin-transform-react-jsx-source plugin 将元素所在代码路径写入 fiber 对象当中,通过 HtmlElement 查找到相对应的 fiber,取得本地代码的路径,随即可实现定位代码并跳转的功能。


结语


本文粗略的讲解了 LocatorJs 在 React 框架的原理实现,算是抛砖引玉,供大家参考。



篇幅原因,略过很多细节,感兴趣的朋友建议看看源码,结合调试学习



我是饮东,欢迎点赞关注,江湖再见


作者:饮东
来源:juejin.cn/post/7358274599883653120
收起阅读 »

太方便了!Arthas,生产问题大杀器

一、一个难查的生产问题一天,小王发现生产环境上偶发性地出现某接口耗时过高,但在测试环境又无法复现,小王一筹莫展😔。小王“幻想”到:如果有个工具能记录生产上各个函数的耗时该多好,这样一看不就知道时间花在哪了?这不是幻想,Arthas 已经帮我们解决了这个问题。在...
继续阅读 »

一、一个难查的生产问题

一天,小王发现生产环境上偶发性地出现某接口耗时过高,但在测试环境又无法复现,小王一筹莫展😔。小王“幻想”到:如果有个工具能记录生产上各个函数的耗时该多好,这样一看不就知道时间花在哪了?
这不是幻想,Arthas 已经帮我们解决了这个问题。在介绍它之前,我们先了解下相关背景。

二、动态追踪

现在互联网和大家生活的各个方面都息息相关。相应地,互联网应用的用户规模也变得越来越大。江湖大了,什么风浪都有。开发者们不断被各种诡异问题打扰,接口耗时过大、CPU 占用过高、内存溢出、只有生产环境会报错......
这些问题出现的概率可能是千分之一、乃至万分之一。如果我们能不修改代码、不修改配置、不重启服务,就能看到程序内部在执行什么,这该多好,再大的问题心里也有底了。
动态追踪技术出现了,它诞生于21 世纪初。Sun Microsystems 公司的工程师在解决一个复杂问题时被繁琐的排查过程所困扰,痛定思痛,他们创造了 DTrace 动态跟踪框架。DTrace 奠定了动态追踪的基础,Bryan Cantrill, Mike Shapiro, and Adam Leventhal 三位作者也多次获得行业荣誉。
动态追踪技术出现的时间早,但 Java 语言相关的调试工具链一直不太完善。直到进入移动互联网时代,Java 的发展才进入了快车道。2018 年,Alibaba 开源 Arthas,Java 的动态追踪才真正好用起来
动态追踪可以看作是构建了一个运行时“只读数据库”,这个数据库内部保存了实时变化的进程运行信息,我们通过调用这个“数据库”开放的接口,就能看到进程内部发生了什么。
经验丰富的读者可能会有疑问,现在微服务都用上了 Skywalking 这样的分布式链路追踪技术,通过它也能分阶段地看到各个部分的执行情况,为什么还需要 Arthas?
Arthas 有两大特点:

  1. 低侵入;不需要程序中进行额外配置,更不需要手动埋点。
  2. 功能强大;Arthas 提供了四十多种命令:从查看线程调用链,到查看输入、输出,到反编译代码等,应有尽有。

对于排查接口耗时长这样的情况,Skywalking 可以和 Arthas 配合起来,先用 Skywalking 定位出异常微服务,再用 Arthas 分析单个进程的情况,找到根因。

三、Arthas常用场景

相信你对动态追踪有了基本的了解,Arthas 可以理解为动态追踪在 Java 领域落地的具体工具。下面以场景助学,大家可以参考这些方案,因事制宜来解决自己的问题。

Arthas 的安装和基础使用见官方文档:Introduction | arthas

3.1.接口慢/吞吐量低

在文章开头,小王就遇到了这个问题。现在小王依靠老道的排查经验确定了 MathGame 服务肯定有问题,但具体的点却找不到。小王仔细学习了这篇文章,决定用 Arthas 分以下三步来排查:

  1. profile 明确整体的耗时情况

    profile 命令支持为应用生成火焰图,在 Arthas 终端输入以下命令:

    # 开始对应用中当前执行的活动采样 30 秒,采样结束后默认会生成 HTML 文件
    [arthas@5555]$ profiler start -d 30

    打开 HTML 文件能看到这样的结构:

    image.png
    火焰图

    MathGame 类下的 run 方法占用了大部分的执行时间,接下来我们看看 run 方法内部的耗时情况。

  2. trace 详细查看单个调用的内部耗时

    [arthas@5555]$ trace --skipJDKMethod false demo.MathGame run

    PrintStream 类 print 方法占据了 87% 的时间,这是 JDK 自带的类,这说明我们程序本身并无耗时问题,但 MathGame 类的 primeFactors 方法抛出了异常,我们可以看看具体的异常,再思考怎么优化。

    image.png
    run方法的trace流

    另外,trace 可以选择性地进行调用拦截,比如设置只拦截大于 20ms 的调用:

    [arthas@5555]$ trace demo.MathGame run '#cost > 20'
  3. watch 查看真实的调用数据

    拦截 primeFactors 方法抛出的异常:

    [arthas@5555]$ watch demo.MathGame primeFactors -e "throwExp"

    image.png
    拦截异常

小王从大到小、逐步分析,找出了问题的原因是 primeFactors 抛出了异常,修正参数后,程序恢复了正常。

3.2.CPU 占用过高

CPU 是程序运行的核心计算资源,一旦出现 CPU 占用过高,必定对大部分用户的访问耗时产生影响。针对这类问题,要定位出有问题的线程,并获取该线程当前执行的代码位置
使用 top + jstack 命令可以定位这类问题(见参考资料三),Arthas 也提供了更便捷的一体化工具:

  1. 定位目标线程

    # 调用线程看板,并刷新数据三次
    [arthas@5555]$ dashboard -n 3

    image.png
    示例程序的CPU占用不算高

    DashBoard 刷新三次后,在最新状态中发现示例程序里自己的线程 “main” 占用不算高。说明程序运行正常。如果是要排错,这里就要找出 CPU 占用最高的用户线程的 ID

  2. 查看目标线程执行的代码位置

    # “1” 是上一步定位到的 main 的线程ID
    [arthas@5555]$ thread 1

    image.png

    线程正在“睡觉”,没什么大问题。

3.3 生产环境的效果和测试不一样

有些时候你发现:测试环境正常,但生产就报错了。这类问题主要靠做好上线流程的管控,但也有可能是打包的依赖库出现冲突,造成程序行为不一致。接下来,我们看看怎么用 Arthas 反编译代码,以及怎么对比依赖库的版本。

  1. 反编译代码
    # demo.MathGame 是目标类的全限定名
    [arthas@5555]$ jad demo.MathGame

image.png

  1. 查看目标类所属的依赖包

    # demo.MathGame 是目标类的全限定名
    [arthas@5555]$ sc -d demo.MathGame

    image.png
    目标类所属的包

    如果这里是依赖包,code-source 还可以显示所属包的版本。这样就可以对比本地的代码,从而在打包时设置正确的依赖版本。

3.4 内存溢出

生产问题中内存溢出也有不小的比例。内存溢出的关键是找出高内存占用的对象。命令行操作会比较麻烦,建议转储 Heap Dump 等文件后,通过 Eclipse Memory Analyzer(MAT) 等工具进行分析。

四、运行 Arthas 报错

在有些运行环境下,Arthas 会出现报错。对于以下两种情况,读者可参照文档解决:

五、参考资料


作者:立子
来源:juejin.cn/post/7308230350374256666

收起阅读 »

SpringBoot 这么实现动态数据源切换,就很丝滑!

大家好,我是小富~ 简介 项目开发中经常会遇到多数据源同时使用的场景,比如冷热数据的查询等情况,我们可以使用类似现成的工具包来解决问题,但在多数据源的使用中通常伴随着定制化的业务,所以一般的公司还是会自行实现多数据源切换的功能,接下来一起使用实现自定义注解的形...
继续阅读 »

大家好,我是小富~


简介


项目开发中经常会遇到多数据源同时使用的场景,比如冷热数据的查询等情况,我们可以使用类似现成的工具包来解决问题,但在多数据源的使用中通常伴随着定制化的业务,所以一般的公司还是会自行实现多数据源切换的功能,接下来一起使用实现自定义注解的形式来实现一下。


基础配置


yml配置


pom.xml文件引入必要的Jar


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.6</version>
</parent>
<groupId>com.dynamic</groupId>
<artifactId>springboot-dynamic-datasource</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<mybatis.plus.version>3.5.3.1</mybatis.plus.version>
<mysql.connector.version>8.0.32</mysql.connector.version>
<druid.version>1.2.6</druid.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- springboot核心包 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mysql驱动包 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.connector.version}</version>
</dependency>
<!-- lombok工具包 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis.plus.version}</version>
</dependency>
<!-- druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.7</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

管理数据源


我们应用ThreadLocal来管理数据源信息,通过其中内容的get,set,remove方法来获取、设置、删除当前线程对应的数据源。


/**
* ThreadLocal存放数据源变量
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/

public class DataSourceContextHolder {

private static final ThreadLocal<String> DATASOURCE_HOLDER = new ThreadLocal<>();

/**
* 获取当前线程的数据源
*
* @return 数据源名称
*/

public static String getDataSource() {
return DATASOURCE_HOLDER.get();
}

/**
* 设置数据源
*
* @param dataSourceName 数据源名称
*/

public static void setDataSource(String dataSourceName) {
DATASOURCE_HOLDER.set(dataSourceName);
}

/**
* 删除当前数据源
*/

public static void removeDataSource() {
DATASOURCE_HOLDER.remove();
}
}

重置数据源


创建 DynamicDataSource 类并继承 AbstractRoutingDataSource,这样我们就可以重置当前的数据库路由,实现切换成想要执行的目标数据库。


import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.Map;

/**
* 重置当前的数据库路由,实现切换成想要执行的目标数据库
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/

public class DynamicDataSource extends AbstractRoutingDataSource {

public DynamicDataSource(DataSource defaultDataSource, Map<Object, Object> targetDataSources) {
super.setDefaultTargetDataSource(defaultDataSource);
super.setTargetDataSources(targetDataSources);
}

/**
* 这一步是关键,获取注册的数据源信息
* @return
*/

@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSource();
}
}

配置数据库


在 application.yml 中配置数据库信息,使用dynamic_datasource_1dynamic_datasource_2两个数据库


spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
master:
url: jdbc:mysql://127.0.0.1:3306/dynamic_datasource_1?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 12345
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
url: jdbc:mysql://127.0.0.1:3306/dynamic_datasource_2?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 12345
driver-class-name: com.mysql.cj.jdbc.Driver

再将多个数据源注册到DataSource.


import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

/**
* 注册多个数据源
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/

@Configuration
public class DateSourceConfig {

@Bean
@ConfigurationProperties("spring.datasource.druid.master")
public DataSource dynamicDatasourceMaster() {
return DruidDataSourceBuilder.create().build();
}

@Bean
@ConfigurationProperties("spring.datasource.druid.slave")
public DataSource dynamicDatasourceSlave() {
return DruidDataSourceBuilder.create().build();
}

@Bean(name = "dynamicDataSource")
@Primary
public DynamicDataSource createDynamicDataSource() {
Map<Object, Object> dataSourceMap = new HashMap<>();
// 设置默认的数据源为Master
DataSource defaultDataSource = dynamicDatasourceMaster();
dataSourceMap.put("master", defaultDataSource);
dataSourceMap.put("slave", dynamicDatasourceSlave());
return new DynamicDataSource(defaultDataSource, dataSourceMap);
}
}

启动类配置


在启动类的@SpringBootApplication注解中排除DataSourceAutoConfiguration,否则会报错。


@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)


到这多数据源的基础配置就结束了,接下来测试一下


测试切换


准备SQL


创建两个库dynamic_datasource_1、dynamic_datasource_2,库中均创建同一张表 t_dynamic_datasource_data。


CREATE TABLE `t_dynamic_datasource_data` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`source_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);

dynamic_datasource_1.t_dynamic_datasource_data表中插入


insert int0 t_dynamic_datasource_data (source_name) value ('dynamic_datasource_master');

dynamic_datasource_2.t_dynamic_datasource_data表中插入


insert int0 t_dynamic_datasource_data (source_name) value ('dynamic_datasource_slave');

手动切换数据源


这里我准备了一个接口来验证,传入的 datasourceName 参数值就是刚刚注册的数据源的key。


/**
* 动态数据源切换
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/

@RestController
public class DynamicSwitchController {

@Resource
private DynamicDatasourceDataMapper dynamicDatasourceDataMapper;

@GetMapping("/switchDataSource/{datasourceName}")
public String switchDataSource(@PathVariable("datasourceName") String datasourceName) {
DataSourceContextHolder.setDataSource(datasourceName);
DynamicDatasourceData dynamicDatasourceData = dynamicDatasourceDataMapper.selectOne(null);
DataSourceContextHolder.removeDataSource();
return dynamicDatasourceData.getSourceName();
}
}

传入参数master时:127.0.0.1:9004/switchDataSource/master



传入参数slave时:127.0.0.1:9004/switchDataSource/slave



通过执行结果,我们看到传递不同的数据源名称,已经实现了查询对应的数据库数据。


注解切换数据源


上边已经成功实现了手动切换数据源,但这种方式顶多算是半自动,下边我们来使用注解方式实现动态切换。


定义注解


我们先定一个名为DS的注解,作用域为METHOD方法上,由于@DS中设置的默认值是:master,因此在调用主数据源时,可以不用进行传值。


/**
* 定于数据源切换注解
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DS {
// 默认数据源master
String value() default "master";
}

实现AOP


定义了@DS注解后,紧接着实现注解的AOP逻辑,拿到注解传递值,然后设置当前线程的数据源


import com.dynamic.config.DataSourceContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Objects;

/**
* 实现@DS注解的AOP切面
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/

@Aspect
@Component
@Slf4j
public class DSAspect {

@Pointcut("@annotation(com.dynamic.aspect.DS)")
public void dynamicDataSource() {
}

@Around("dynamicDataSource()")
public Object datasourceAround(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
DS ds = method.getAnnotation(DS.class);
if (Objects.nonNull(ds)) {
DataSourceContextHolder.setDataSource(ds.value());
}
try {
return point.proceed();
} finally {
DataSourceContextHolder.removeDataSource();
}
}
}


测试注解


再添加两个接口测试,使用@DS注解标注,使用不同的数据源名称,内部执行相同的查询条件,看看结果如何?


@DS(value = "master")
@GetMapping("/dbMaster")
public String dbMaster() {
DynamicDatasourceData dynamicDatasourceData = dynamicDatasourceDataMapper.selectOne(null);
return dynamicDatasourceData.getSourceName();
}


@DS(value = "slave")
@GetMapping("/dbSlave")
public String dbSlave() {
DynamicDatasourceData dynamicDatasourceData = dynamicDatasourceDataMapper.selectOne(null);
return dynamicDatasourceData.getSourceName();
}


通过执行结果,看到通过应用@DS注解也成功的进行了数据源的切换。


事务管理


在动态切换数据源的时候有一个问题是要考虑的,那就是事务管理是否还会生效呢?


我们做个测试,新增一个接口分别插入两条记录,其中在插入第二条数据时将值设置超过了字段长度限制,会产生Data too long for column异常。


    /**
* 验证一下事物控制
*/

// @Transactional(rollbackFor = Exception.class)
@DS(value = "slave")
@GetMapping("/dbTestTransactional")
public void dbTestTransactional() {

DynamicDatasourceData datasourceData = new DynamicDatasourceData();
datasourceData.setSourceName("test");
dynamicDatasourceDataMapper.insert(datasourceData);

DynamicDatasourceData datasourceData1 = new DynamicDatasourceData();
datasourceData1.setSourceName("testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest");
dynamicDatasourceDataMapper.insert(datasourceData1);
}

经过测试发现执行结果如下,即便实现动态切换数据源,本地事务依然可以生效。



  • 不加上@Transactional注解第一条记录可以插入,第二条插入失败

  • 加上@Transactional注解两条记录都不会插入成功


本文案例地址:github.com/chengxy-nds…


作者:程序员小富
来源:juejin.cn/post/7316202800663363594
收起阅读 »

这个字符串”2*(1+3-4)“的结果是多少

web
大家好,我是火焱。 前两天,在抖音上刷到一个计算器魔术,很有意思。 于是拿出手机尝试,发现不太对,为什么我的计算器直接把输入的内容都展示出来了?看评论区发现很多人都有类似的问题。 既然自带的计算器不好使,那就用小程序写一个。 产品描述 计算器的显示区只展示...
继续阅读 »

大家好,我是火焱


前两天,在抖音上刷到一个计算器魔术,很有意思。



于是拿出手机尝试,发现不太对,为什么我的计算器直接把输入的内容都展示出来了?看评论区发现很多人都有类似的问题。



既然自带的计算器不好使,那就用小程序写一个。


产品描述


计算器的显示区只展示当前的数字,如果按了运算符(+ - * /),再输入数字时,展示当前的新数字,不展示之前输入的内容,按等于(=)号后,展示计算结果。


从程序员视角看,按等于(=) 时,我们拿到的是四则运算的字符串,比如:"1 + 2 * 3 - 4",然后通过代码计算这个字符串的结果,那么如何计算呢?


初步尝试


对于 javascript,很容易想到通过 eval 或者 new Function 实现,可是小程序...


image.png


既然捷径走不通,那就用逆波兰表达式来解决,我们来看下表达式的三种表示方法。


三种表示


中缀表达式,就是我们常用的表示方式:1 + 2 * 3 - 4


前缀表达式,也叫波兰表达式,是把操作符放到操作数前边,表示成:- + 1 * 2 3 4,由于后缀表达式操作起来比较方便,我们重点看下后缀表达式;


后缀表达式,也叫逆波兰表达式,它是把操作符放到操作数后边,表示成:1 2 3 * + 4 -,有了后缀表达式,我们就可以很容易计算结果了,那如何将中缀表达式转化成后序表达式呢?语言表述比较乏力,直接看代码吧,逻辑比较清晰:


/** 中缀表达式 转 后缀表达式 */
function infixToPostfix(infixExpression) {
let output = [];
// 存放运算符
let stack = [];

for (let i = 0; i < infixExpression.length; i++) {
let char = infixExpression[i];

if (!isOperator(char)) { // char 是数字
output.push(char);
} else { // char 是运算符
while (
// 栈不为空
stack.length > 0 &&
// 栈顶操作符的优先级不小于 char 的优先级
getPrecedence(stack[stack.length - 1]) >= getPrecedence(char)
) {
output.push(stack.pop());
}
stack.push(char);
}
}

// 将剩余的运算符弹出并追加到 output 后边
while (stack.length > 0) {
output.push(stack.pop());
}

return output.join('');
}


结合下图理解一下:


表达式1 + 2 * 3 - 4


image.png


处理括号


带括号的表达式,处理逻辑和不带括号是一样的,只是多了对括号的处理。当遇到右括号时,需要把栈中左括号后面的所有运算符弹出,并追加到 output,举个例子:


计算:2 * ( 1 + 3 - 4)


image.png


通过这个例子,我们可以看出,后缀表示法居然不需要括号,更简洁。


好了,现在已经有了后序表达式,我们如何的到计算结果呢?


计算结果


计算这一步其实比较简单,直接上代码吧:


const operators = {
'+': function (a, b) { return a + b; },
'-': function (a, b) { return a - b; },
'*': function (a, b) { return a * b; },
'/': function (a, b) { return a / b; }
};

const stack = [];
postfixTokens.forEach(function (token) {
if (!isNaN(token)) {
stack.push(token);
} else if (isOperator(token)) {
var b = stack.pop();
var a = stack.pop();
stack.push(operators[token](a, b));
}
});

总结


中缀表达式对于人比较友好,而后缀表达式对计算机友好,通过对数字和运算符的编排即可实现带优先级的运算。如果本文对你有帮助,欢迎点赞、评论。


参考代码:github.com/laohuoyan/m…




作者:程序员火焱
来源:juejin.cn/post/7294441582983528484
收起阅读 »

鸿蒙next高仿微信来了 我不允许你不会

前言导读 各位同学大家,有段时间没有跟大家见面了,因为最近一直在更新鸿蒙的那个实战课程所以就没有去更新文章实在是不好意思, 所以今天就给大家更新一期实战案例 高仿微信案例 希望帮助到各位同学工作和学习 效果图 特点 高仿程度80 目前不支持即时通...
继续阅读 »

前言导读


各位同学大家,有段时间没有跟大家见面了,因为最近一直在更新鸿蒙的那个实战课程所以就没有去更新文章实在是不好意思, 所以今天就给大家更新一期实战案例 高仿微信案例 希望帮助到各位同学工作和学习


效果图


image-20240809140521780


image-20240809140834969


image-20240809140849586


image-20240809140529987


image-20240809140538888


image-20240809140719388


特点



  1. 高仿程度80

  2. 目前不支持即时通讯功能

  3. 支持最新的api 12

  4. 目前做了账号注册和登录自动登录功能入口


具体实现




  • 启动页面




/**
* 创建人:xuqing
* 创建时间:2024年7月14日22:56:15
* 类说明:欢迎页面
*
*/


import router from '@ohos.router';
import { preferences } from '@kit.ArkData';
import CommonConstant from '../common/CommonConstants';
import Logger from '../utils/Logger';
import { httpRequestGet } from '../utils/OkhttpUtils';
import { LoginModel } from '../bean/LoginModel';

let dataPreferences: preferences.Preferences | null = null;


@Entry
@Component
struct Welcome {

  async aboutToAppear(){
    let options: preferences.Options = { name: 'myStore' };
    dataPreferences = preferences.getPreferencesSync(getContext(), options);
    let getusername=dataPreferences.getSync('username','');
    let getpassword=dataPreferences.getSync('password','');
    if(getusername===''||getpassword===''){
      router.pushUrl({
        url:'pages/LoginPage'
      })
    }else {
      let username:string='username=';
      let password:string='&password=';
      let netloginurl=CommonConstant.LOGIN+username+getusername+password+getpassword;
      httpRequestGet(netloginurl).then((data)=>{
        Logger.error("请求数据--->"+ data.toString());
        let loginmodel:LoginModel=JSON.parse(data.toString());
        if(loginmodel.code===200){
          router.pushUrl({
            url:'pages/Index'
          })
        }else{
          router.pushUrl({
            url:'pages/LoginPage'
          })
        }
      })
    }

}

build() {
  RelativeContainer(){
    Image($r('app.media.weixinbg'))
      .width('100%')
      .height('100%')

  }.height('100%')
  .width('100%')
  .backgroundColor(Color.Green)

}
}

登录页面


import CommonConstant, * as commonConst from '../common/CommonConstants';
import Logger from '../utils/Logger';
import { httpRequestGet } from '../utils/OkhttpUtils';
import { LoginData, LoginModel} from '../bean/LoginModel';
import prompt from '@ohos.promptAction';
import router from '@ohos.router';
import { preferences } from '@kit.ArkData';
let dataPreferences: preferences.Preferences | null = null;



/**
* 创建人:xuqing
* 创建时间:2024年7月14日17:00:03
* 类说明:登录页面
*
*/

//输入框样式
@Extend(TextInput) function inputStyle(){
.placeholderColor($r('app.color.placeholder_color'))
.height(45)
.fontSize(18)
.backgroundColor($r('app.color.background'))
.width('100%')
.padding({left:0})
.margin({top:12})
}
//线条样式
@Extend(Line) function lineStyle(){
.width('100%')
.height(1)
.backgroundColor($r('app.color.line_color'))
}
//黑色字体样式
@Extend(Text) function blackTextStyle(size?:number ,height?:number){
.fontColor($r('app.color.black_text_color'))
.fontSize(18)
.fontWeight(FontWeight.Medium)
}

@Entry
@Component
struct LoginPage {

@State accout:string='';
@State password:string='';
async login(){
  let username:string='username=';
  let password:string='&password=';
  let netloginurl=CommonConstant.LOGIN+username+this.accout+password+this.password;
    Logger.error("请求url"+netloginurl);
    await httpRequestGet(netloginurl).then((data)=>{
      Logger.error("请求结果"+data.toString());
      let loginModel:LoginModel=JSON.parse(data.toString());
      let msg=loginModel.msg;
      let logindata:LoginData=loginModel.user;
      let token=loginModel.token;
      let userid=logindata.id;
      let options: preferences.Options = { name: 'myStore' };
      dataPreferences = preferences.getPreferencesSync(getContext(), options);

      if(loginModel.code===200){
        Logger.error("登录成功");
        dataPreferences.putSync('token',token);
        dataPreferences.putSync('id',userid);
        dataPreferences.putSync('username',this.accout);
        dataPreferences.putSync('password',this.password);
        dataPreferences!!.flush()
        router.pushUrl({
          url:'pages/Index'
        })
      }else {
        prompt.showToast({
          message:msg
        })
      }
    })
}


build() {
    Column(){
      Image($r('app.media.weixinicon'))
        .width(48)
        .height(48)
        .margin({top:100,bottom:8})
        .borderRadius(8)
        Text('登录界面')
          .fontSize(24)
          .fontWeight(FontWeight.Medium)
          .fontColor($r('app.color.title_text_color'))
      Text('登录账号以使用更多服务')
        .fontSize(16)
        .fontColor($r('app.color.login_more_text_color'))
        .margin({bottom:30,top:8})

      Row(){
        Text('账号').blackTextStyle()
        TextInput({placeholder:'请输入账号'})
          .maxLength(12)
          .type(InputType.Number)
          .inputStyle()
          .onChange((value:string)=>{
            this.accout=value;
          }).margin({left:20})

      }.justifyContent(FlexAlign.SpaceBetween)
      .width('100%')
      .margin({top:8})
      Line().lineStyle().margin({left:80})



      Row(){
        Text('密码').blackTextStyle()
        TextInput({placeholder:'请输入密码'})
          .maxLength(12)
          .type(InputType.Password)
          .inputStyle()
          .onChange((value:string)=>{
            this.password=value;
          }).margin({left:20})
      }.justifyContent(FlexAlign.SpaceBetween)
      .width('100%')
      .margin({top:8})
      Line().lineStyle().margin({left:80})

      Button('登录',{type:ButtonType.Capsule})
        .width('90%')
        .height(40)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .backgroundColor($r('app.color.login_button_color'))
        .margin({top:47,bottom:12})
        .onClick(()=>{
          this.login()
        })
      Text('注册账号').onClick(()=>{
        router.pushUrl({
          url:'pages/RegisterPage'
        })
      }).fontColor($r('app.color.login_blue_text_color'))
        .fontSize(12)
        .fontWeight(FontWeight.Medium)
    }.backgroundColor($r('app.color.background'))
  .height('100%')
  .width('100%')
  .padding({
    left:12,
    right:12,
    bottom:24
  })

}
}



  • 主页index


    import home from './Home/Home';
    import contacts from './Contact/Contacts';
    import Discover from './Discover/Discover';
    import My from './My/My';
    import common from '@ohos.app.ability.common';
    import prompt from '@ohos.promptAction';


    @Entry
    @Component
    struct Index {
    @State message: string = 'Hello World';

    private backTime :number=0;
    @State fontColor: string = '#182451'
    @State selectedFontColor: string = 'rgb(0,196,104)'
    private controller:TabsController=new TabsController();
    showtoast(msg:string){
      prompt.showToast({
        message:msg
      })
    }



    @State SelectPos:number=0;
    private positionClick(){
      this.SelectPos=0;
      this.controller.changeIndex(0);

    }

    private companyClick(){
      this.SelectPos=1;
      this.controller.changeIndex(1);
    }
    private messageClick(){
      this.SelectPos=2;
      this.controller.changeIndex(2);

    }

    private myClick(){
      this.SelectPos=3;
      this.controller.changeIndex(3);

    }

    onBackPress(): boolean | void {
      let nowtime=Date.now();
      if(nowtime-this.backTime<1000){
        const mContext=getContext(this) as common.UIAbilityContext;
        mContext.terminateSelf()
      }else{
        this.backTime=nowtime;
        this.showtoast("再按一次将退出当前应用")
      }
      return true;
    }


    // ['微信','通讯录','发现','我']
    build() {
      Flex({direction:FlexDirection.Column,alignItems:ItemAlign.Center,justifyContent:FlexAlign.Center}){
        Tabs({controller:this.controller}){
          TabContent(){
            home();
          }
          TabContent(){
            contacts();
          }
          TabContent(){
            Discover();
          }
          TabContent(){
            My()
          }
        }.scrollable(false)
        .barHeight(0)
        .animationDuration(0)

        Row(){
          Column(){
            Image((this.SelectPos==0?$r('app.media.wetab01'):$r('app.media.wetab00')))
              .width(20).height(20)
              .margin({top:5})
            Text('微信')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==0?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.positionClick.bind(this))



          Column(){
            Image((this.SelectPos==1?$r('app.media.wetab11'):$r('app.media.wetab10')))
              .width(20).height(20)
              .margin({top:5})
            Text('通讯录')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==1?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.companyClick.bind(this))


          Column(){
            Image((this.SelectPos==2?$r('app.media.wetab21'):$r('app.media.wetab20')))
              .width(20).height(20)
              .margin({top:5})
            Text('发现')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==2?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.messageClick.bind(this))


          Column(){
            Image((this.SelectPos==5?$r('app.media.wetab31'):$r('app.media.wetab30')))
              .width(20).height(20)
              .margin({top:5})
            Text('我')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==5?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.myClick.bind(this))
        }.alignItems(VerticalAlign.Bottom).width('100%').height(60).margin({top:0,right:0,bottom:0,left:0})

      }.width('100%')
      .height('100%')


    }
    }

    后续目标



    1. 微信朋友圈

    2. 聊天菜单(相册,拍摄...)组件栏

    3. 语音|视频页面

    4. 支持群聊头像

    5. 支持图片,红包等聊天内容类型(现已支持图片类型)

    6. 二维码扫描




最后总结:


因为篇幅有限我也不能整个项目都展开讲,有兴趣的同学能可以关注我B站课程。 后续能我会把这个项目更新到项目里面 供大家学习


B站课程地址:http://www.bilibili.com/cheese/play…


团队介绍


团队介绍:坚果派由坚果等人创建,团队由12位华为HDE以及若干热爱鸿蒙的开发者和其他领域的三十余位万粉博主运营。专注于分享 HarmonyOS/OpenHarmony,ArkUI-X,元服务,仓颉,团队成员聚集在北京,上海,南京,深圳,广州,宁夏等地,目前已开发鸿蒙 原生应用,三方库60+,欢迎进行课程,项目等合作。


作者:坚果派_xq9527
来源:juejin.cn/post/7400741845508522019
收起阅读 »

从一线城市回老家后的2023“躺平”生活

归家 22年的十月份,在上海工作了三年多的我回到了老家。 前端,20年二本毕业的,当时在上海看老家的招聘信息,感觉很棒,很心动。又因为公司在大裁员,刚刚好在最后一轮裁员的时候,被裁了,拿了赔偿金,因为房租没有到期,又最后玩了玩,回了老家。 现实的落差感    ...
继续阅读 »

归家


22年的十月份,在上海工作了三年多的我回到了老家。


前端,20年二本毕业的,当时在上海看老家的招聘信息,感觉很棒,很心动。又因为公司在大裁员,刚刚好在最后一轮裁员的时候,被裁了,拿了赔偿金,因为房租没有到期,又最后玩了玩,回了老家。


现实的落差感


    回到老家后,又休息了十几天吧,就开始看招聘的信息,之前在上海看着很心动的岗位,简历投了又投,要么回复你,岗位已招满,要么压根不理你(后来我才知道,是学历的问题,老家这边的国企,最低学历就是研究生了,压根不看你工作履历)。剩余那些搭理你的公司,大都是小公司,可能只有十个人左右,而且大多都是单休或者大小周,有些甚至五险一金都没有,工资也低的可怜,是之前的三分之一差不多。


    心里难免有很强烈的落差感,但是由于我们(们:我老公,那个时候我们还是情侣,从大学开始的,在一起快7年了)家都是在这边的,两边父母都在这,觉得我们之后就是要在这里发展的,我俩硬着头皮,每天划拉着招聘信息,投着简历,适当地去面试。


    中间有一家公司,我感觉还可以,然后想着去试试,干了两天半。


    刚去的第一天,技术团队是:一个后端和一个外包的后端,以及一个跟我一样刚入职的前端,一共就我们四个人,然后就是老板,只有我是女生。除此之外,还有保洁阿姨(中午会做饭,公司中午管饭)、人事小姐姐等一些非开发人员。下午开会,老板居然直接在会议室抽起了烟(熏得我不要不要的)!然后项目用的是ruoyi的架子,里面有些代码是那俩后端暂时写的,看起来有些乱。就这样干了两天,那俩后端,很爱抽烟,再加上老板也带头会议室开会还抽烟,整天感觉身边烟熏火燎的。


    到第三天的时候,中午开了个会,意思是,之前我们开发的好像需求都不行,并且又提了一堆新需求,还告诉我们说只有两三天的时间搞完。我就意识到不对劲,是逼着人加班,没死没活的干的那种。然后再加上被熏了三天,于是开完会,我就赶紧收拾着我的东西,跑路了,干不了,根本干不了。既不合理,而且办公环境很糟糕(每天烟熏火燎),还没有社保,据说年后才缴纳。下午他们打来电话,问我咋回事,还要给我加薪让我再回去,但我已决心不去那干了。后来的我一点都不后悔这样做,甚至觉得很明智。


    就这样继续在招聘软件上看着,有新岗位咯,就投,就面试。


     突然有一天的周日,我接到了一个电话,说我可以来上班,他们缴纳五险,是双休,还有餐补,并且薪资也比之前面试的也差不多(之前还有个公司给的薪资和他一样,但是他是大小周,我不想去),这种待遇的公司,对于目前的我来说,已经很可以了,然后我就同意了,并且两天后去入职,这家公司就是我现在的公司。相比之前那家“烟熏火燎”的公司,这家就正规了许多,可能因为总部在深圳吧。


我们订婚啦


既然工作稳定了,那就开始丰富生活。2023年02月05日,我们举办了订婚宴~




工作


    这边的前端工作不太是普通的传统前端,而是electron打包出来是个exe啊,或者是针对模型3d渲染引擎啊,依托于基于threeJs二次开发出来的一些第三方,之类的,总之跟之前做的不一样,之前的我做的都是h5、微信小程序、或者接入一些公众号之类的。所以与其说是在工作,不如说是一直在学习吧。公司也知道我不太会,于是乎就给我很长时间先学,先熟悉,然后再去一点点开发。并且我几乎没加过班。


我们结婚啦


后面一切按照计划进行,拍摄婚纱照、男方那边在忙着新房装修,我们这边在置办嫁妆、买车车等。


在2023年10月10日,我们举行了典礼。



安稳且平淡


现在的我们每天安安稳稳,我想着适当提升下自己的学历(因为我们这边的好单位,现在好像都要研究生毕业了),在看着咱们计算机考研408的一些科目(双11心血来潮,一下子买了六七百块的书,不看总觉得买书钱白瞎了TAT),但是每天下班回家,还是忍不住看一些电视剧啥的,佛系考研,阿弥陀佛,哈哈哈哈


我们从上海一直养到现在的猫猫~



这就是我跟大家分享的我的这2023年的一年的经历。说实话,回老家的确比在一线城市更真实,因为身边有父母,有家人,每个周末都可充实。一线城市是素质高、节奏快,人的整个思想境界感觉都跟老家这边的人不一样。但兜兜转转,回老家似乎也并不是“躺平”,有落差感,因为接触过好的了。反正无论怎样,感觉简单、安稳、快乐的过好每一天就挺好。我们一起加油吧~


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

关于我在uni-app中踩的坑

web
前言 这段时间刚入坑uni-app小程序,本人使用的编辑器是VScode(不是HbuliderX!!!),在此记录本人所踩的坑 关于官方模板 我采用的是官方提供的Vue3+Vite+ts模板,使用的包管理工具是pnpm。大家可以使用npx下载模板 $ npx ...
继续阅读 »

前言


这段时间刚入坑uni-app小程序,本人使用的编辑器是VScode(不是HbuliderX!!!),在此记录本人所踩的坑


关于官方模板


我采用的是官方提供的Vue3+Vite+ts模板,使用的包管理工具是pnpm。大家可以使用npx下载模板


$ npx degit dcloudio/uni-preset-vue#vite-ts my-vue3-project

当然不出意外,大家下载都是失败的
So这里附上官方gitee下载地址 点击前去下载

下载解压后运行pnpm i,如果有报错可以尝试切换node版本。


微信小程序开发


第一步注册账号 小程序 (qq.com),按官方所需填写即可。


第二步,登录你的小程序账号,在开发->开发管理->开发设置,获取你的AppID(小程序ID)


第三步,在你的项目工程文件里找到manifest.json中的小程序相关填写你上一步获取的AppID


 "mp-weixin": {
"appid": "替换你的小程序ID",
"setting": {
"urlCheck": false
},
"usingComponents": true
},

然后终端运行pnpm run dev:mp-weixin
然后会生成一个dist目录,这里存放的是编译成微信小程序的源码


第四步,下载安装微信小程序开发工具 微信开发者工具下载地址


第五步,打开并登录微信小程序开发工具,选择导入项目,选择刚刚生成的dist目录下的mp-weixin即可


image.png
成功界面如图
image.png


关于node版本


让我们来看看人家官方是怎么说的



注意



  • Vue3/Vite版要求 node 版本^14.18.0 || >=16.0.0

  • 如果使用 HBuilderX(3.6.7以下版本)运行 Vue3/Vite 创建的最新的 cli 工程,需要在 HBuilderX 运行配置最底部设置 node路径 为自己本机高版本 node 路径(注意需要重启 HBuilderX 才可以生效)



    • HBuilderX Mac 版本菜单栏左上角 HBuilderX->偏好设置->运行配置->node路径

    • HBuilderX Windows 版本菜单栏 工具->设置->运行配置->node路径





当然想要把这个官方模板跑起来还真是不容易(T-T),为什么这么说呢,本人使用node18居然跑不起来,按理说应该是可以的,but我最后选择将node版本降到node16,在前端中我们会经常切换node,小编在这里要强推nvm(一款node版本管理工具),本文不在这里着重介绍,贴心的小编已经为大家附上了nvm的下载地址 点击前去下载


关于easycome配置


对于熟悉前端的小伙伴来说,自定义组件是家常便饭啦,uniapp内置easycom,用于自动导入自己和第三方的组件
首先我们找到pages.json文件,输入(cv)以下代码


 "easycom": {
"autoscan": true,
"custom": {
// uni-ui 规则如下配置
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue",
//自定义规则
"^Xtx(.*)":"@/components/Xtx$1.vue"
}
},

自动查找以uni、Xtx开头的Vue文件,一定要注意规则,否则可能导致导入失败,写完后可以在导入的组件中log一下,判断是否导入成功,配置easycom后无需手动导入组件


关于uni-helper插件


如果你想增加在uni-app中开发体验,你可以选择uni-helper插件,首先确保你在vscode中安装了Vue Language Features (Volar)以及TypeScript Vue Plugin (Volar)插件,这俩插件提供Vue高亮显示和ts语法支持。
安装vscode uni-helper相关插件


image.png


image.png
然后安装3个包


$ pnpm i -D @uni-helper/uni-app-types
$ pnpm i -D @uni-helper/uni-cloud-types
$ pnpm i -D @uni-helper/uni-ui-types

接着在tsconfig.json中将3种类型应用。在compilerOptions的types中添加。配置如下:


{
...
"compilerOptions": {
...
"types": [
"@dcloudio/types",
"@uni-helper/uni-app-types",
"@uni-helper/uni-ui-types",
...
],
}
}


诶,这怎么原生标签报错了呢?别急,出现这个错误是因为unihelp的类型与原生发生了冲突,我们只需要在compilerOptions同级增加以下代码即可解决此问题


{
...
"compilerOptions": {
...
"types": [
"@dcloudio/types",
"@uni-helper/uni-app-types",
"@uni-helper/uni-ui-types",
...
],
}
//增加vueCompilerOptions配置项
"vueCompilerOptions": {
"nativeTags": ["block", "component", "template", "slot"]
},
}


避坑热重载


经过小编的测试发现,把微信开发者工具的自动保存和热重载关闭后,居然可以自动同步代码,起因是一天小编正苦于添加了请求拦截器却无法响应,偶然重新编译后发现可以拦截,于是考虑是否代码没更新,一看源码,果然如此,这里不知道是工具的bug还是vscode编译的bug。有了解的小伙伴可以在评论区留一下言。总之就是踩了很多坑(QWQ)


作者:彼日花
来源:juejin.cn/post/7286762580876902441
收起阅读 »

微信小程序:轻松实现时间轴组件

web
效果图 引言 老板: “我们公司是做基金的,用户都在买买买,可他们的钱去了哪里?没有时间轴,用户会不会觉得自己的钱瞬移了?” 你: “哈哈,确实!时间轴就像用户的投资地图,不然他们可能觉得钱被外星人劫走了。” 老板: “没错!我们得在时间轴上标清‘资金到账...
继续阅读 »

效果图


企业微信截图_17230101989794-imageonline.co-merged.png


引言



老板: “我们公司是做基金的,用户都在买买买,可他们的钱去了哪里?没有时间轴,用户会不会觉得自己的钱瞬移了?”


你: “哈哈,确实!时间轴就像用户的投资地图,不然他们可能觉得钱被外星人劫走了。”


老板: “没错!我们得在时间轴上标清‘资金到账’、‘收益结算’这些节点,这样用户就不会担心他们的钱去买彩-票了。”


你: “放心吧,老板,我马上设计一个时间轴,让用户一看就明白他们的钱在干什么,还能时不时地笑一笑!”


老板: “好,赶紧行动,不然用户要开始给我们寄失踪报告了!”



废话不多说,我们直接开始吧!!!


组件定义


以下代码为时间轴组件的实现,详细注释在代码中。如果有任何疑问,欢迎在评论区留言讨论,或者联系我获取完整案例。


组件的 .js 文件:


/*可视化地呈现时间流信息*/
Component({
 options: {
   multipleSlots: true // 在组件定义时的选项中启用多slot支持
},
 properties: {
   activities: { // 时间轴列表
     type: Array,
     value: []
  },
   shape: { // 时间轴形状
     type: String,
     value: 'circle' // circle | square
  },
   ordinal: { // 是否显示序号
     type: Boolean,
     value: true
  },
   reverse: { // 是否倒序排列
     type: Boolean,
     value: false
  }
},
 lifetimes: {
   attached() {
     // 是否倒序排列操作数据
     const {reverse, activities} = this.data
     if (!reverse) return
     this.setData({
       activities: activities.reverse()
    })
  }
}
})

组件的.wxml文件:


<view class="container">
 <view class="item" wx:for="{{activities}}" wx:key="item">
   <view class="item-tail"></view>
   <view class="item-node {{shape}} {{item.status}}">
     <block wx:if="{{ordinal}}">{{index + 1}}</block>
   </view>
   <view class="item-wrapper">
     <view class="item-news">
       <view class="item-timestamp">{{item.date}}</view>
       <view class="item-mark">收益结算</view>
     </view>
     <view class="item-content">
       <view>{{item.content}}</view>
       <!--动态slot的实现方式-->
       <slot name="operate{{index}}"></slot>
     </view>
   </view>
 </view>
</view>

组件使用


要使用该组件,首先需要在 app.jsonindex.json 中引用组件:


"usingComponents": {
"eod-timeline": "/components/Timeline/Timeline"
}

然后你可以通过以下方式进行基本使用:


<eod-timeline activities="{{dataList}}" ordinal="{{true}}"></eod-timeline>

如果需要结合插槽动态显示操作记录,可以这样实现:


<eod-timeline activities="{{dataList}}" ordinal="{{true}}">
   <!--动态slot的实现方式-->
   <view wx:for="{{dataList}}" wx:for-index="idx" wx:key="idx" slot="operate{{idx}}">
     <view class="row-operate">
       <view>操作记录</view>
       <view>收益记录</view>
       <view>动账记录</view>
     </view>
   </view>
</eod-timeline>

数据结构与属性说明


dataList 数据结构示例如下:


dataList:[
{date: '2023-05-26 12:04:14', status: 'info', content: '内容一'},
{date: '2023-05-25 12:04:14', status: 'success', content: '内容二'},
{date: '2023-05-24 12:04:14', status: 'success', content: '内容三'},
{date: '2023-05-23 12:04:14', status: 'error', content: '内容四'},
{date: '2023-05-22 12:04:14', status: 'warning', content: '内容五'}
]

组件的属性配置如下表所示:


参数说明可选值类型默认值
activities显示的数据array
shape时间轴点形状circle / squarestringcircle
ordinal是否显示序号booleantrue
reverse是否倒序排列booleanfalse

总结


这个时间轴组件提供了一个简单易用的方式来展示事件的时间顺序。组件支持定制形状、序号显示以及正序或倒序排列,同时允许通过插槽自定义内容,增强了组件的灵活性。代码中有详细注释,方便理解和修改。如果需要更详细的案例或有任何疑问,请在评论区留言。希望这篇文章对你有所帮助!


拓展阅读


关于动态 Slot 实现:


由于动态 slot 目前仅可用于 glass-easel 组件框架,而该框架仅可用于 Skyline 渲染引擎,因此这些特性也同样受此限制。如果需要在非 glass-easel 组件框架中实现动态 slot,请参考上文标记了 <!--动态slot的实现方式--> 的代码段。


如需了解更多关于 glass-easel 组件框架的信息,请参阅微信小程序官方开发指南


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

这些天,我们前端组一起处理的网站换肤功能

web
前言  大家好,我是沐浴在曙光下的贰货道士,好久不见,别来无恙!本文主要根据UI设计需求,讲述一套基于scss封装方法的网页响应式布局,以及不同于传统引入element UI主题配色文件的换肤思路。大家仔细看完文章,相信一定会有所收获。倘若本文提供的响应式布局...
继续阅读 »

前言

  大家好,我是沐浴在曙光下的贰货道士,好久不见,别来无恙!本文主要根据UI设计需求,讲述一套基于scss封装方法的网页响应式布局,以及不同于传统引入element UI主题配色文件的换肤思路。大家仔细看完文章,相信一定会有所收获。倘若本文提供的响应式布局思路对您有所帮助,烦请大家一键三连哦。同时如果您有其他响应式布局解决方案或网站换肤思路,欢迎您不吝赐教,在评论区留言分享。感谢大家!

需求分析

  • 早期我们前端项目组开发了一个国外业务网站。这周为了迎合其他国家的喜好,需要在国外业务项目的基础上,新建多个项目,对之前的主题配色和部分布局进行修改,并需要适配不同分辨率下的屏幕。UI提供了包括主题配色页面布局修改在内的一系列项目稿件,这些稿件基于1920px分辨率的屏幕进行处理。前端需要根据UI提供的主题色,修改项目中的颜色变量。接口暂时使用国外业务的那一套接口,后期需要对接这些项目的接口,而我们目前的主要任务就是处理这些项目的静态页面改版。
  • 主题色修改:

    1. 首先,我们前端团队需要根据UI提供的主题色,更新项目中的颜色变量,确保页面上的所有元素都符合新的配色方案。
    2. 页面布局提供的修改稿件,如果有不在主题色内的颜色,需要和UI确认是否需要更换为其他颜色相近的主题配色或者双方都新增主题配色
    3. 检查项目中包括CSSHTML在内的所有带#颜色值的信息。与UI确认后,将其更换为其他颜色接近的主题配色,或者双方共同新增主题配色,以确保配色方案的一致性和协调性。
  • 响应式布局:

    1. 前端需要根据UI提供的稿件和意见,适配项目在不同屏幕下的样式。对于页面上的不同元素,在小于等于1920px的屏幕上进行缩放时,需要保持横纵比,并根据页面大小进行等比例缩放,包括容器宽高、间距等在内的页面布局是否合适都需要与UI确认;在高于1920px屏幕的设备上,需要保持和1920px屏幕的布局风格,即元素的宽高不变。
    2. 然而,字体元素在页面缩放时,需要保持一定的风格。比如:16px的文字最小不能低于14px18px20px以及24px的文字最小不能低于16px32px的文字最小不能低于18px36px的文字最小不能低于20px44px的文字最小不能低于28px48px的文字最小不能低于32px
    3. 在移动设备上,需要保持和800px网页相同的布局。

项目现状

  • 主题色: 早期在与UI团队合作时,我们为国外业务系统确定了一套配色方案,并将其定义在项目的颜色变量中。然而,后续设计稿中出现了一些不在这套配色方案中的色值。 由于种种原因,我们在开发时没有与UI确认这些颜色是否需要更换,也没有将新增的颜色定义到颜色变量中,而是直接在代码中使用了这些颜色值。这导致在此次换肤过程中,仅通过修改颜色变量无法实现统一换肤的效果。我们需要逐一检查代码中硬编码的颜色值,并将其替换为新的颜色变量,以确保换肤的统一性和一致性。
  • 布局: 以前我们使用flex、百分比、最小最大宽度/高度以及element UI的栅格布局做了一些简单的适配,但这些方法不够灵活。为了更好地适应不同分辨率的屏幕,我们需要采用更为灵活和动态的布局方案,以确保在各种设备上的显示效果都能达到预期。

思路分析

主题色

传统的解决方案

  1. 以前在官网上,我们可以直接编辑并修改一套主题色。点击下载后,会生成一个css文件。

    image.png
  2. 将下载后的css文件引入到我们项目中,可以看到编译后的css文件

    image.png
  3. 最后在项目中的入口文件,引入我们下载的css文件(这种方式会增加app.css的体积)。
`main.js`

import '@/styles/theme/index.css'
  1. 后续处理的优化
`将编译后的element样式,从main.js指向到index.html中,减小了main.css体积`
`main.js中的css文件,最终还是会link到index.html中。那为什么还要把它拆开呢?`
`这涉及到css的拆分:浏览器会并行请求加载多个css文件,比单独请求并加载一个css文件要快`
`这样处理的目的是:将main.js中的css文件,抽出一部分放到index.html中`

<link rel="stylesheet" href="<%= BASE_URL %>theme/index.css">
  1. webpack小知识:loader
  • webpack 只识别js文件:当遇到其他非js文件时,因为不识别js文件,所以需要使用loader插件(或引入第三方插件,或自己编写一个loader方法),将其他文件转换为webpack能够识别的js文件。
  • 因此,loader的作用相当于对传入的非js文件做处理,将它转换为 webpack 可识别的js字符串。
  1. 在字体商用不侵权的前提下,严格遵循设计稿的字体样式
`如果用户电脑不存在设计稿上提供的字体样式,则会展示用户电脑的默认字体样式。`
`为此,我们需要下载并引入字体,将字体集成到网站中,确保用户电脑呈现效果与我们开发一致`
`(1) 引入: 在public文件夹下新建fonts文件夹,在fonts文件夹下引入我们下载好的字体样式`
`(2) 在index.html中, 为document增加字体`
`(3) 引入并挂载字体后,我们就可以使用下载的字体了,也可以在body上全局挂载字体`

`类似element字体的引入和挂载`
`FontFace: https://developer.mozilla.org/zh-CN/docs/Web/API/CSS_Font_Loading_API`

const font1 = new FontFace(
'iconfont',
'url(/iconfont/iconfont.woff2?t=1688345853791),
url(/iconfont/iconfont.woff?t=1688345853791),
url(/iconfont/iconfont.ttf?t=1688345853791)'
)

const font2 = new FontFace(
'element-icons',
'url(/theme/fonts/element-icons.woff),
url(/theme/fonts/element-icons.ttf)'
)

font1.load().then(function() {
document.fonts.add(font1)
})

font2.load().then(function() {
document.fonts.add(font2)
})

现在的解决方案

  由于element UI官方已不再维护传统的主题配色下载,我们项目采取官方提供的第二种方式:

  1. 原理: 我们项目使用scss编写csselement UItheme-chalk又恰好使用scss进行编写。在官方定义的scss变量中,使用了!default语法,用于提供默认值。这也就意味着,我们不用考虑css的加载顺序,直接新建scss文件,覆盖定义在theme-chalk文件且在我们系统中常用的scss变量,达到在css编译阶段自定义主题scss变量的效果。
image.png
  1. 引入变量: 新建element-variable.scss文件,在这个文件中引入theme-chalk定义的主题scss变量,同时需要改变icon字体路径变量(使用传统方法不需要改变路径变量,是因为我们直接引入了编译后的css文件,里面已经帮我们做过处理了;而使用现在的解决方案,如果不改变字体路径变量,项目会提示找不到icon字体路径,所以这个配置必填)。此时,将这个文件引入到我们的入口文件,那么系统中已经存在theme-chalk定义好的scss变量了
d227267f21a77a05a67159b1d71ae43a.png
  1. 修改变量: 新建element.scss文件,在里面覆盖我们需要修改的主题变量,最后在vue.config.jssass配置下的additionalData里全局引入到项目中的每个vue文件中(因为是挂载到每个vue文件中,所以这个配置下的scss文件不宜过多),方便在vue文件中直接使用变量。
image.png image.png

优势

1. 定制化和灵活性

  • 更改主题色和变量: 轻松改变Element UI的主题色、字体、间距等变量,而无需过多地覆盖现有的element CSS样式。
  • 精细控制: 原先的配置方式只能配置主题色,无法控制更细粒度的配置,比如边框颜色之类。

2. 避免样式冲突

  • 避免样式覆盖的冲突: 通过直接修改SCSS变量来定制样式,可以避免在使用编译后的 CSS 文件时可能出现的样式覆盖冲突问题。这样可以保证样式的独立性和一致性。

3. 便于维护

  • 集中管理: 所有的样式修改都集中在一个地方(变量文件),这使得维护样式变得更加方便和清晰。只需要修改文件中定义的变量,就可以影响整个项目中的样式,无需逐一查找以及修改每个组件的样式。

缺陷

  • sass loaderadditionalData中配置了过多的全局css变量,添加到每个vue文件中
  • 相比之前的处理方式,在main.js中引入element自定义的主题scss变量,首页加载的css文件更多,

响应式布局

思路分析

  1. UI提供的稿件是1920px,前端需要对UI提供的稿件进行一比一还原;
  2. 网页在小屏缩放时,需要保持元素的横纵比。针对这个问题,我们可以用百分比作为布局单位。 以设计稿宽度1920px为基准,建立pxvw之间的关系。如果把1920px视为100vw,那么1vw = 19.2px。 如果设计稿上某个元素的宽度为192px, 那么将它换算得到的结果将会是192px / 19.2px * 1vw = 10vw。因此我们在布局时,需要严格遵循UI提供的设计稿件,并借助下文封装的方法,将设计稿元素的像素作为第一个形参,传递到下文封装的方法中; 实现思路:为等比例缩放网页元素,先去掉传入的像素单位。最后使用前文提到的换算公式,不论宽高,都将其转换为vw单位,等比缩放
  3. 字体页面元素在放大时,需要限制字体元素展现的最大阈值。 那么我们封装的方法,第二个形参需要控制字体元素的最大阈值; 实现思路:借助scss中的max方法实现。
  4. 字体页面元素在缩小时,需要限制字体元素展现的最小阈值。 那么我们封装的方法,第三个形参需要控制字体元素的最小阈值; 实现思路:借助scss中的min方法实现。
  5. 在高于1920px屏幕的设备上,需要保持和1920px屏幕的布局风格,即元素的宽高不变。 针对这个问题,我们只需要保证方法中的max形参和1920px下的像素值一致,即保证方法中的第一个形参和第二个形参相同。
  6. 在移动设备上,需要使用800px的网页布局。针对这个问题,我们可以使用meta标签进行适配:  
  7. 不同屏幕下的元素显示势必不会那么完美。我们可以通过媒体查询,在不同分辨率的屏幕下,按照UI给定的反馈意见,对网页进行适配,这样就可以解决问题。但是在项目中大量使用媒体查询语法,会导致整个项目看上去很乱。为此,我们可以基于scss语法,对媒体查询语法进行二次封装。
  8. 如何测试我们编写的scss代码? 移步sass在线调试
image.png

自适应scss方法封装

// 自定义scss函数, 作用是去掉传入变量的单位
// 之所以要去掉单位,是为了将传入的px转换为vw单位,自适应布局`

@function stripUnits($value) {

// 对带有单位的变量进行特殊处理,返回去掉单位后的结果`
// 对于scss来说, 90px和90都是number`
// 在scss中,unitless是一个术语,指的是没有单位的数值,not unitless就是变量带单位`

@if type-of($value) == 'number' and not unitless($value) {
// 90px / 1 得到的结果是90px, 90px / 1px得到的结果是90
// 这也是这里为什么要用($value * 0 + 1),而不是直接写1的原因`

@return $value / ($value * 0 + 1);
}
@return $value;
}

/*
自定义scss函数,提供三个参数:
第一个参数是设计稿提供的元素大小,传入会自动转换为vw单位,达到自适应的效果
第二个参数是用来约束这个元素的大小最大不能超过第一个参数和第二个参数的最大值, 必须带单位
第三个参数是用来约束这个元素的大小最小不能小于第一个参数和第三个参数的最小值,必须带单位
如果不传入第二个和第三个参数,则表示元素完全随屏幕响应式缩放

应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为960px时,标题字号缩放为24px,起不到突出的作用。
于是我们可以给它设置一个最小阈值,比如最小不能小于32px;
2. 同理,当屏幕分辨率为3840px时,标题字号放大为96px,我们不希望字号这么大。
于是可以给它设置一个最大阈值,比如最大不能超过60px。
*/


@function auto($raw, $max:null, $min:null) {
$raw: stripUnits($raw);
$str: #{$raw / $proportion}vw;
@if $max {
$str: min(#{$str}, #{$max});
}
@if $min {
$str: max(#{$str}, #{$min});
}
@return $str;
}

/*
自定义scss函数,auto方法的二次封装, 提供两个参数
第一个参数用于设置1920px下的元素大小
第二个参数用于设置这个元素的最小值

应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为3840px时,标题字号放大为96px,我们希望它保持48px大小,
于是我们可以给它设置一个最大阈值48px。同时,我们可以传入一个最小阈值,让它最小不能小于这个参数。
*/


@function autoMax($raw, $min:null) {
@return auto($raw, $raw, $min)
}

// 和上面相反

@function autoMin($raw, $max:null) {
@return auto($raw, $max, $raw)
}

//1vw = 1920 / 100 ;
$proportion: 19.2;

// 根据UI需求,对不同字体大小进行封装
$wb-font-size-mini: 16px; // $text-mini-1
$wb-font-size-extra-small: 18px; // $text-small-1
$wb-font-size-small: 20px; //$text-sm-md-1
$wb-font-size-base: 24px; //$text-medium-1
$wb-font-size-lesser-medium: 32px;
$wb-font-size-medium: 36px; //$text-large-1
$wb-font-size-extra-medium: 44px;
$wb-font-size-large: 48px; //$text-title-1

// 根据UI需求,在屏幕分辨率缩小时,字体响应式变化,并设定最小阈值
// 并在1920px以上的屏幕,保持和1920px一样的字体大小

$wb-auto-font-size-mini: autoMax($wb-font-size-mini, 14px);
$wb-auto-font-size-extra-small: autoMax($wb-font-size-extra-small, 16px);
$wb-auto-font-size-small: autoMax($wb-font-size-small, 16px);
$wb-auto-font-size-base: autoMax($wb-font-size-base, 16px);
$wb-auto-font-size-lesser-medium: autoMax($wb-font-size-lesser-medium, 18px);
$wb-auto-font-size-medium: autoMax($wb-font-size-medium, 20px);
$wb-auto-font-size-extra-medium: autoMax($wb-font-size-extra-medium, 28px);
$wb-auto-font-size-large: autoMax($wb-font-size-large, 32px);
// 严格按照UI稿件提供的元素大小、间距编写代码,以下是示例代码

.title {
padding: 0 autoMax(180px);
font-size: $wb-auto-font-size-large;
font-weight: 600;
text-align: center;
}
image.png

媒体查询语法封装及使用规范

// 导入scss的list和map模块,用于处理相关操作。

@use 'sass:list';
@use "sass:map";

/*
媒体查询映射表,定义各种设备类型的媒体查询范围
key为定义的媒体类型,value为对应的分辨率范围
*/


$media-list: (
mobile-begin: (0, null),
mobile: (0, 800),
mobile-end:(null, 800),
tablet-begin: (801, null),
tablet: (801, 1023),
tablet-end:(null, 1023),
mini-desktop-begin: (1024, null),
mini-desktop: (1024, 1279),
mini-desktop-end: (null, 1279),
small-desktop-begin: (1280, null),
small-desktop: (1280, 1439),
small-desktop-end: (null, 1439),
medium-desktop-begin: (1440, 1919),
medium-desktop: (1440, 1919),
medium-desktop-end: (null, 1919),

large-desktop-begin: (1920, null),
large-desktop: (1920, 2559),
large-desktop-end: (null, 2559),

super-desktop-begin: (2560, null),
super-desktop: (2560, null),
super-desktop-end: (2560, null)
);

/*
创建响应式媒体查询的函数,传参是媒体查询映射表中的媒体类型
从$media-list中获取对应的最小和最大宽度,并返回相应的媒体查询字符串。
*/


@function createResponsive($media) {
$size-list: map.get($media-list, $media);
$min-size: list.nth($size-list, 1);
$max-size: list.nth($size-list, 2);
@if ($min-size and $max-size) {
@return "screen and (min-width:#{$min-size}px) and (max-width: #{$max-size}px)";
} @else if ($max-size) {
@return "screen and (max-width: #{$max-size}px)";
} @else {
@return "screen and (min-width:#{$min-size}px)";
}
}

/*
这个混入接受一个或多个媒体类型参数,调用createResponsive函数生成媒体查询
@content是Scss中的一个占位符,用于在混入中定义块级内容。
它允许你在调用混入时,将实际的样式代码插入到混入定义的样式规则中。
*/


@mixin responsive-to($media...) {
@each $item in $media {
$media-content: createResponsive($item);
@media #{$media-content} {
@content;
}
}
}

// 以下是针对各种媒体类型定义的混入:

@mixin mobile() {
@include responsive-to(mobile) {
@content;
}
}

@mixin tablet() {
@include responsive-to(tablet) {
@content;
}
}

@mixin mini-desktop() {
@include responsive-to(mini-desktop) {
@content;
}
}

@mixin small-desktop() {
@include responsive-to(small-desktop) {
@content;
}
}

@mixin medium-desktop() {
@include responsive-to(medium-desktop) {
@content;
}
}

@mixin large-desktop() {
@include responsive-to(large-desktop) {
@content;
}
}

@mixin super-desktop() {
@include responsive-to(super-desktop) {
@content;
}
}

@mixin mobile-begin() {
@include responsive-to(mobile-begin) {
@content;
}
}

@mixin tablet-begin() {
@include responsive-to(tablet-begin) {
@content;
}
}

@mixin mini-desktop-begin() {
@include responsive-to(mini-desktop-begin) {
@content;
}
}

@mixin small-desktop-begin() {
@include responsive-to(small-desktop-begin) {
@content;
}
}

@mixin medium-desktop-begin() {
@include responsive-to(medium-desktop-begin) {
@content;
}
}

@mixin large-desktop-begin() {
@include responsive-to(large-desktop-begin) {
@content;
}
}

@mixin super-desktop-begin() {
@include responsive-to(super-desktop-begin) {
@content;
}
}


@mixin mobile-end() {
@include responsive-to(mobile-end) {
@content;
}
}

@mixin tablet-end() {
@include responsive-to(tablet-end) {
@content;
}
}

@mixin mini-desktop-end() {
@include responsive-to(mini-desktop-end) {
@content;
}
}

@mixin small-desktop-end() {
@include responsive-to(small-desktop-end) {
@content;
}
}

@mixin medium-desktop-end() {
@include responsive-to(medium-desktop-end) {
@content;
}
}

@mixin large-desktop-end() {
@include responsive-to(large-desktop-end) {
@content;
}
}

@mixin super-desktop-end() {
@include responsive-to(super-desktop-begin) {
@content;
}
}
image.png

需求解决思路:

  • 根据提供的设计稿,使用autoMax系列方法,对页面做初步的响应式布局适配
  • 针对不同屏幕下部分元素布局需要调整的问题,使用封装的媒体查询方法进行处理

书写规范:

  为避免项目中的scss文件过多,搞得整个项目看上去很臃肿,现提供一套书写规范:

  • 在每个路由下的主index.vue文件中,引入同级文件夹scss下的media.scss文件


// 小屏状态下,覆盖前面定义的css样式


  • media.css文件

  写法:以vue文件最外层的类进行包裹,使用deep穿透,以屏幕分辨率大小作为排序依据,从大到小书写媒体查询样式

.about-wrapper::v-deep {
@include small-desktop {
.a {
.b {

}
}
}

@include mini-desktop {
.a {
.b {

}
}
}

@include tablet-end {
.a {
.b {

}
}
}
}

结语

  感谢掘友们耐心看到文末,希望你们不是一路跳转至评论区,我们江湖再见!


作者:沐浴在曙光下的贰货道士
来源:juejin.cn/post/7388753413309775887

收起阅读 »

淘宝、京东复制好友链接弹出商品详情是如何实现的

web
前言: 最近接到了一个需求很有意思,类似于我们经常在逛购物平台中,选择一个物品分享给好友,然后好友复制这段文本打开相对应的平台以后,就可以弹出链接上的物品。实现过程也比较有意思,特来分享一下实现思路🎁。 一. 效果预览 当我在别的界面复制了内容以后,回到主应用...
继续阅读 »

前言: 最近接到了一个需求很有意思,类似于我们经常在逛购物平台中,选择一个物品分享给好友,然后好友复制这段文本打开相对应的平台以后,就可以弹出链接上的物品。实现过程也比较有意思,特来分享一下实现思路🎁。


一. 效果预览


当我在别的界面复制了内容以后,回到主应用,要求可以检测到当前剪切板是什么内容。

1.gif


二. 监听页面跳转动作



  1. 要完成这个需求,整体思路并不复杂。首先我们要解决的就是如何检测到用户从别的应用切回到我们自己的应用。

  2. 这个听起来很复杂,但其实浏览器已经提供了相对应的 api 来帮我们检测用户这个操作----document.visibilitychange
    image.png

  3. 那么我们就可以写下如下代码


    document.addEventListener("visibilitychange", () => {
    console.log("用户切换了");
    });

    相对应的效果如下图所示,你可能会好奇,我明明只切换了一次,但是为什么控制台却执行了两次打印?

    2.gif

    这也不难理解,首先你要理解这个 change 这个动作,你从 tab1 切换到 Tab2 的时候,触发了当前 Tab1可见 变为=> 不可见

    而当你从 tab2 切回 tab1 的时候,触发了当前 Tab1不可见变为了可见。完整动作引起了状态两次变化,所以才有了两次打印。


  4. 而我们的场景只是希望 app 从不可见转变为可见的时候才触发。那么我们就需要用到��外一个变量来配合使用-------document.visibilityState
    image.png
    这个值是一个 document 对象上的一个只读属性,它有三个 string 类型的值 visiblehiddenprerender 。从它的使用说明中不难看出,我们要使用的值是 visible



    tips:hidden 可以用来配合做一些流量控制优化,当用户切换网页到后台的时候,我们可以停止一些不必要的轮询任务,待用户切回后再开启。




  5. 那么我们现在的代码应该是这样的:


    document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "visible") {
    console.log("页面变得可见了!");
    }
    });

    可以看到,现在控制台正确的只执行了一次。

    3.gif



三. 完成读取剪切板内容



  1. 要完成读取剪切板内容需要用到浏览器提供的另外一个 api-----navigator.clipboard。这里穿插一个英语记忆的小技巧,我们要把这个单词分成两部分记忆:clipboardclip 本身就有修剪的意思,board 常作为木板相近的含义和别的单词组合,如:黑板 blackboard棋盘 chessboard。所以这两个单词组合起来的含义就是剪切板。

    image.png

  2. 这里需要注意一句话,这个功能只能用在安全上下文中。这个概念很抽象,如果想深入了解的话,还需自行查阅资料。这里指简单说明这句话的限制:要想使用这个 api 你只能在 localhost、127.0.0.1 这样的本地回环地址或者使用 https 协议的网站中使用。

    image.png

  3. 要快速检测当前浏览器或者网站是否是安全上下文 ,可以使用 Window:isSecureContext 属性来判断。

  4. 你可以动手访问一个 http 的网站,然后在控制台打印一下该属性,你大概率会看到一个 false,则说明该环境不是一个安全上下文,所以 clipboard 在这个环境下大概率不会生效。因为本文章代码都为本地开发(localhost),所以自然为安全上下文
    image.png

  5. 经过上面的知识,那么我们就可以写出下面的兼容性代码。

    image.png

  6. 前置步骤都已经完成,接下来就是具体读取剪切板内容了。关于读取操作,clipboard 提供了两个 api-----readreadText。这里由于我们的需求很明确,我读取的链接本身就是一个字符串类型的数据,所以我们就直接选用 readText 方法即可。稍后在第四章节我会介绍 read 方法。

  7. clipboard 所有操作都是异步会返回一个 Promise 类型的数据的,所以这里我们的代码应该是这样的:


    document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "visible") {
    if (window.isSecureContext && navigator.clipboard) {
    const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
    setTimeout(() => {
    clipboardAPI.readText().then((text) => {
    console.log("text", text);
    });
    }, 1000);
    } else {
    console.log("不支持 clipboard");
    }
    }
    });



    注意⚠️:这里你会看到我使用了 setTimeout 来解决演示的问题,如果你正在跟着练习但是不明白原因,请查看下面链接:

    关于 DOM exception: document is not focused 请查阅stackoverflow 文档未聚焦的解决方案



    相应的效果如下图所示,可以看到我们已经可以正确读取刚刚剪切板的内容了。

    QQ20240629-144157.gif


  8. 此时,当拿到用户剪切板的内容以后,我们就可以根据某些特点来判断弹窗了。这里我随便使用了一个弹出组件来展示效果:

    1.gif

  9. 什么?到这里你还是没看懂和网购平台链接之间有什么关系?ok,让我们仔细看一下我分别从两家平台随手复制的两个链接,看出区别了吗?开头的字符串可以很明显看出是各家的名字。

    image.png

  10. 那么我只需判断用户剪切板上的字符串是否符合站内的某项规则不就行了吗?让我来举个更具体的栗子,下面链接是我掘金的个人首页,假如用户此时复制了这段文本,然后跳转回我们自己的应用后,刚刚的代码就可以加一个逻辑判断,检测用户剪切板上的链接是否是以 juejin.cn 开头的,如果则跳转首页;如果不是,那么什么事情也不做。

    image.png

    对应的代码如下:

    image.png

  11. 那么相对应的效果如下,这也就是为什么复制某宝的链接到某东后没任何反应的原因。某东并不是没读取,而是读取后发现不是自家的就不处理罢了。

    6.gif


四*. 思维拓展:粘贴图片自动转链接的实现



  1. 用过相关写作平台的小伙伴大概对在编辑器中直接接粘贴图片的功能不陌生。如掘金的编辑器,当我复制一个图片以后,直接在编辑器中粘贴即可。掘金会自动将图片转换为一个链接,这样会极大的提高创作者的写作体验。

    7.gif

  2. 那么现在让我们继续发散思维来思考这个需求如何实现,这里我们先随便创建一个富文本框。

    image.png

  3. 既然是粘贴图片,那么最起码我得知道用户什么时候进行粘贴操作吧?这还不简单,直接监听 paste 事件即可。


    document.addEventListener("paste",()=>{
    console.log("用户粘贴了")
    })

    实现的效果如下:

    8.gif


  4. 把之前的 clipboard.readText 替换为 clipboard.read 以后你的代码应该是下面这样的:


    document.addEventListener("paste", () => {
    if (window.isSecureContext && navigator.clipboard) {
    const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
    setTimeout(() => {
    clipboardAPI.read().then((result) => {
    console.log("result", result);
    });
    }, 1000);
    } else {
    console.log("不支持 clipboard");
    }
    });

    让我们复制一张图片到富文本区域执行粘贴操作后,控制台会打印以下信息:

    image.png


  5. clipboardItem 是一个数组,里面有很多子 clipboardItem,是数组的原因是因为你可以一下子复制多张图片,不过在这里我们只考虑一张图片的场景。

  6. 这里我们取第一项,然后调用 clipboardItem.getType 方法,这个方法需要传递一个文件类型的参数,这里我们传入粘贴内容对应的类型即可,这里传入 image/png
    image.png
    在控制台这里可以看到一下输出,就表示我们已经正确拿到图片的 blob 的格式数据了。

    image.png

  7. 此时我们就只需要把相对应的图片数据传递给后端或者 CDN 服务器,让它们返回一个与之对应的链接即可。在掘金的编辑器中,对应的请求就是 get-image-url 这个请求。

    9.gif

  8. 然后调用 textarea.value + link 把链接补充到文章最后位置即可。


五. 源码


<script lang="ts" setup>
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {});
}, 1000);
} else {
console.log("不支持 clipboard");
}
}
});

document.addEventListener("paste", () => {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {
result[0].getType("image/png").then((blob) => {
console.log("blob", blob);
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onload = (e) => {
const img = document.createElement("img");
img.src = e.target?.result;
const wrapper = document.getElementById("han");
wrapper.appendChild(img);
};
});
});
}, 1000);
} else {
console.log("不支持 clipboard");
}
});
</script>
<template>
<div id="han" class="w-full h-full bg-blue">
<textarea class="w-300px h-300px"></textarea>
</div>
</template>

六. 思考 writeText 的用法


有了上面的经验,我相信你已经可以自己理解 clipboard 剩下的两个方法 writewriteText 了。你可以思考下面的问题:


为什么在掘金复制文章内容后,剪切板会自动加版权信息呢?

11.gif


如果你实现了,不妨在评论区写下你的思路~🌹


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

Dart中令人惊艳的8个用法(深入探索)

web
Dart是谷歌开发的现代化编程语言,凭借其简洁的语法和强大的功能,在开发者当中赢得了极高的声誉,尤其是在Flutter框架中发挥了巨大的作用。本文将介绍Dart中的8个令人惊艳的用法,这些用法不仅技术深度足够,充满启发性,而且能够让您的Dart编程效率飞速提升...
继续阅读 »

Dart是谷歌开发的现代化编程语言,凭借其简洁的语法和强大的功能,在开发者当中赢得了极高的声誉,尤其是在Flutter框架中发挥了巨大的作用。本文将介绍Dart中的8个令人惊艳的用法,这些用法不仅技术深度足够,充满启发性,而且能够让您的Dart编程效率飞速提升。


1. 泛型类型别名的高级应用


类型别名可以让你用简单的名称定义更复杂的类型,尤其是在处理大量嵌套的泛型时特别有用。


typedef ComplexList<T> = List<Map<T, T>>;

void main() {
// 适用于需要设置特定键值对类型的列表
ComplexList<String> complexList = [
{'key1': 'value1'},
{'key2': 'value2'},
];

// 复杂集合的操作
complexList.add({'key3': 'value3'});
print(complexList);
}

泛型类型别名可以更好地组织代码,增强代码的可读性。


2. Stream的高级处理技巧


利用Stream提供的各种操作符和转换器,能够更好地处理事件流和异步数据。


Stream<int> timedCounter(Duration interval, int maxCount) async* {
int count = 0;
while (count < maxCount) {
await Future.delayed(interval);
yield ++count;
}
}

void main() async {
// 监听Stream,执行特定逻辑
await for (final count in timedCounter(Duration(seconds: 1), 5)) {
print(count);
}
}

通过async*yield,你可以构建出能够发射数据序列的Stream,为异步编程提供强大支持。


3. Isolate的轻量级并行计算


Isolate可以在不同的执行线程中运是执行并发操作的强大工具。


import 'dart:isolate';

Future<void> computeOnIsolate() async {
final receivePort = ReceivePort();

Isolate.spawn(_heavyComputation, receivePort.sendPort);

final message = await receivePort.first as String;
print(message);
}

void _heavyComputation(SendPort sendPort) {
// 很重的计算
// 假设这是一个令CPU满负荷的操作
sendPort.send('计算完成');
}

void main() {
computeOnIsolate();
}

通过Isolate,你可以在Flutter应用中执行耗时操作而不影响应用的响应性。


4. 使用枚举的高级技巧


枚举类型不仅仅可以代表一组命名常量,通过扩展方法,可以大幅提升它们的功能。


enum ConnectionState {
none,
waiting,
active,
done,
}

extension ConnectionStateX on ConnectionState {
bool get isTerminal => this == ConnectionState.done;
}

void main() {
final state = ConnectionState.active;

print('Is the connection terminal? ${state.isTerminal}');
}

枚举类型的扩展性提供了类似面向对象的模式,从而可以在保证类型安全的前提下,增加额外的功能。


5. 使用高级const构造函数


const构造函数允许在编译时创建不可变实例,有利于性能优化。


class ImmutableWidget {
final int id;
final String name;

const ImmutableWidget({this.id, this.name});

@override
String toString() => 'ImmutableWidget(id: $id, name: $name)';
}

void main() {
const widget1 = ImmutableWidget(id: 1, name: 'Widget 1');
const widget2 = ImmutableWidget(id: 1, name: 'Widget 1');

// 标识符相同,它们是同一个实例
print(identical(widget1, widget2)); // 输出: true
}

使用const构造函数创建的实例,由于它们是不可变的,可以被Dart VM在多个地方重用。


6. 元数据注解与反射


虽然dart:mirrors库在Flutter中不可用,但理解元数据的使用可以为你提供设计灵感。


import 'dart:mirrors'; // 注意在非Web平台上不可用

class Route {
final String path;
const Route(this.path);
}

@Route('/login')
class LoginPage {}

void main() {
final mirror = reflectClass(LoginPage);
for (final instanceMirror in mirror.metadata) {
final annotation = instanceMirror.reflectee;
if (annotation is Route) {
print('LoginPage的路由是: ${annotation.path}');
}
}
}

通过注解,你可以给代码添加可读的元数据,并通过反射在运行时获取它们,为动态功能提供支持,虽然在Flutter中可能会借助其他方式如代码生成来实现。


7. 匿名mixin


创建匿名mixin能够在不暴露mixin到全局作用域的情况下复用代码。


class Bird {
void fly() {
print('飞翔');
}
}

class Swimmer {
void swim() {
print('游泳');
}
}

class Duck extends Bird with Swimmer {}

void main() {
final duck = Duck();
duck.fly();
duck.swim();
}

利用匿名mixin可以在不同的类中混入相同的功能而不需要创建明显的类层次结构,实现了代码的复用。


8. 高级异步编程技巧


在异步编程中,Dart提供了Future、Stream、async和await等强大的工具。


Future<String> fetchUserData() {
// 假设这是一个网络请求
return Future.delayed(Duration(seconds: 2), () => '用户数据');
}

Future<void> logInUser(String userId) async {
print('尝试登录用户...');
try {
final data = await fetchUserData();
print('登录成功: $data');
} catch (e) {
print('登录失败: $e');
}
}

void main() {
logInUser('123');
}

通过使用asyncawait,可以编写出看起来像同步代码的异步操作,使得异步代码更加简洁和易于理解。


作者:慕仲卿
来源:juejin.cn/post/7321526403434315811
收起阅读 »

用了这么多年的字体,你知道它是怎么解析的吗?

web
大家好呀。 因为之前有过字体解析的相关代码开发,一直想把这个过程记录下来,总觉得这个并不是很难,自认为了解得不算全面,就一拖再拖了。今天仅做简单的抛砖引玉,通过本篇文章,可以知道Opentype.js是如何解析字体的。在遇到对应问题的时候,可以有其它的思路去解...
继续阅读 »

大家好呀。


因为之前有过字体解析的相关代码开发,一直想把这个过程记录下来,总觉得这个并不是很难,自认为了解得不算全面,就一拖再拖了。今天仅做简单的抛砖引玉,通过本篇文章,可以知道Opentype.js是如何解析字体的。在遇到对应问题的时候,可以有其它的思路去解决。比如:.ttc的解析。又或者好奇我们开发软件过程中字体是如何解析的。


Opentype.js 使用


看官方readme也可以,这里直接将github代码下载,使用自动化测试目录里的字体文件。


需要注意的是load方法已经被废弃。


function load() {
console.error('DEPRECATED! migrate to: opentype.parse(buffer, opt) See: https://github.com/opentypejs/opentype.js/issues/675');
}

package.json设置为type: module,然后就可以直接使用import了。


import { parse } from './src/opentype.mjs';
import fs from 'fs';
// test/fonts/AbrilFatface-Regular.otf
const buffer = fs.promises.readFile('./test/fonts/AbrilFatface-Regular.otf');
// if not running in async context:
buffer.then(data => {
const font = parse(data);
console.log(font.tables);
})

这样就能得到解析的结果了。


Opentype源码阅读


parseBuffer:解析的入口


通过简单的调用入口,我们可以反查源码。传入文件的ArrayBuffer并返回Font结构的对象,在不清楚会有什么结构的时候,可以通过Font查看,当然了,直接console.log查看更方便。


// Public API ///////////////////////////////////////////////////////////

/**
* Parse the OpenType file data (as an ArrayBuffer) and return a Font object.
* Throws an error if the font could not be parsed.
* @param {ArrayBuffer}
* @param {Object} opt - options for parsing
* @return {opentype.Font}
*/

function parseBuffer(buffer, opt={}) {
// ...
// should be an empty font that we'll fill with our own data.
const font = new Font({empty: true});
}
export {
// ...
parseBuffer as parse,
// ...
};

字体类型判断


接着往下阅读。

根据signature的值,去确认字体类型。粗略看来,这里仅支持了TrueType(.ttf)、CFF(.otf)、WOFFWOFF2


    const signature = parse.getTag(data, 0);
if (signature === String.fromCharCode(0, 1, 0, 0) || signature === 'true' || signature === 'typ1') {
} else if (signature === 'OTTO') {
} else if (signature === 'wOFF') {
} else if (signature === 'wOF2') {
} else {
throw new Error('Unsupported OpenType signature ' + signature);
}

还需要注意的是,signature的值是的获取(后续基本都是这样婶儿获取的信息)。从指定偏移位置开始,读取4个字节的数据,并将每个字节转换为字符,最终返回一个4字符的字符串标签。


// Retrieve a 4-character tag from the DataView.
// Tags are used to identify tables.
function getTag(dataView, offset) {
let tag = '';
for (let i = offset; i < offset + 4; i += 1) {
tag += String.fromCharCode(dataView.getInt8(i));
}

return tag;
}

表入口信息获取


再看TrueTypeCFF字体的处理,除了对font.outlinesFormat属性的设置之外。剩余的处理方式都是:获取表的个数numTables,再获取表的入口偏移信息。


numTables = parse.getUShort(data, 4);
tableEntries = parseOpenTypeTableEntries(data, numTables);

// Table Directory Entries //////////////////////////////////////////////
/**
* Parses OpenType table entries.
* @param {DataView}
* @param {Number}
* @return {Object[]}
*/

function parseOpenTypeTableEntries(data, numTables) {
const tableEntries = [];
let p = 12;
for (let i = 0; i < numTables; i += 1) {
const tag = parse.getTag(data, p);
const checksum = parse.getULong(data, p + 4);
const offset = parse.getULong(data, p + 8);
const length = parse.getULong(data, p + 12);
tableEntries.push({tag: tag, checksum: checksum, offset: offset, length: length, compression: false});
p += 16;
}

return tableEntries;
}

function getUShort(dataView, offset) {
return dataView.getUint16(offset, false);
}
// Retrieve an unsigned 32-bit long from the DataView.
// The value is stored in big endian.
function getULong(dataView, offset) {
return dataView.getUint32(offset, false);
}

留意到tableEntries获取的offset是从12开始的,而获取numTables是从4开始的,也仅仅是getUnit16,也就是说4-12中间还会有别的信息。


表信息标准描述


这时候只能通过查看微软排版文档描述,Microsoft Typography documentation: Organization of an OpenType Font
Organization of an Opentype Font.png
按照8bit计算,这些信息之后,刚好是在12个字节开始。


后续的描述就是parseOpenTypeTableEntries的结构信息了。


表入口数据


以选择的AbrilFatface-Regular.otf 为例。我们可以打断点看看,这两步骤得到的结果,这里Opentype提供了网址,就直接在上面断点了。
parse opentype.png
这里有11个表,在入口分别有对应的名称、偏移量、长度、校验和。


表数据解析


有了表入口信息,就可以通过tableEntries获取表的数据了。接下来的代码就是通过对应的tag(name)去选择对应的解析方式。有些表的信息需要依赖于别的表,则先暂时存起来。比如: name表需要依赖language表。


    case 'ltag':
table = uncompressTable(data, tableEntry);
ltagTable = ltag.parse(table.data, table.offset);
break;
// ...
case 'name':
nameTableEntry = tableEntry;
break;
// ...
const nameTable = uncompressTable(data, nameTableEntry);
font.tables.name = _name.parse(nameTable.data, nameTable.offset, ltagTable);
font.names = font.tables.name;

这里就简单看下ltag表的解析,table = uncompressTable(data, tableEntry);判断是否有压缩,比如WOFF压缩字体,这里没有entry数据就还是原来的。


ltag表的解析


function parseLtagTable(data, start) {
const p = new parse.Parser(data, start);
const tableVersion = p.parseULong();
check.argument(tableVersion === 1, 'Unsupported ltag table version.');
// The 'ltag' specification does not define any flags; skip the field.
p.skip('uLong', 1);
const numTags = p.parseULong();

const tags = [];
for (let i = 0; i < numTags; i++) {
let tag = '';
const offset = start + p.parseUShort();
const length = p.parseUShort();
for (let j = offset; j < offset + length; ++j) {
tag += String.fromCharCode(data.getInt8(j));
}

tags.push(tag);
}

return tags;
}

创了p这个Parser实例,包含各种长度parseShortparseULong等。自动移动offset,避免每次手动传入位置。获取了table的version信息,而后就是循环的获取表内容了。找了好些个字体,都没有ltag表🤦🏻‍♀️


解析小结


这里我们可以初步的了解到整个字体的解析过程,就是按照约定的顺序,有个线头般一点儿一点儿的找到所需,只储存了数据。


如需获取最终字形信息,可能需要经过多个表联合查询,比如loca获取字形数据的偏移量,glyf获取字形数据,又或者camp获取字符代码对应的字形索引。


TTC字体集合的解析


回到前面提出的,ttc字体集合,应该怎么解析呢?参照文档对字体集合的处理 Font Collections,相信大家已经有办法解析了。
TTC header.png
注意:这里截图给出的是1.0的结构,更多的查看文档。


最后


这次的分享就到这里了,对一些有按需解析,自定义解析的场景下,希望对大家有帮助。


作者:斯文的烟鬼去shi吧
来源:juejin.cn/post/7400072326199640100
收起阅读 »

前端身份验证终极指南:Session、JWT、SSO 和 OAuth 2.0

web
Hello,大家好,我是 Sunday 在前端项目开发中,验证用户身份主要有 4 种方式:Session、JWT、SSO 和 OAuth 2.0。 那么这四种方式各有什么优缺点呢?今天,咱们就来对比下! 01:基于 Session 的经典身份验证方案 什么是基...
继续阅读 »

Hello,大家好,我是 Sunday


在前端项目开发中,验证用户身份主要有 4 种方式:Session、JWT、SSO 和 OAuth 2.0


那么这四种方式各有什么优缺点呢?今天,咱们就来对比下!


01:基于 Session 的经典身份验证方案


什么是基于Session的身份验证?


基于 Session 的身份验证是一种在前端和后端系统中常用的用户认证方法。


它主要依赖于服务器端创建和管理用户会话。


Session 运行的基本原理


Session 的运行流程分为 6 步:



  1. 用户登录:用户在登录页面输入凭据(如用户名和密码)。这些凭据通过前端发送到后端服务器进行验证。

  2. 创建会话:后端服务器验证凭据后,创建一个会话(session)。这个会话通常包括一个唯一的会话 ID,该 ID 被存储在服务器端的会话存储中。

  3. 返回会话 ID:服务器将会话 ID 返回给前端,通常是通过设置一个 cookie。这个 cookie 被发送到用户的浏览器,并在后续的请求中自动发送回服务器。

  4. 保存会话 ID:浏览器保存这个 cookie,并在用户每次向服务器发起请求时都会自动包含这个 cookie。这样,服务器就能识别出该用户的会话,从而实现身份验证。

  5. 会话验证:服务器根据会话 ID 查找和验证该用户的会话信息,并确定用户的身份。服务器可以使用会话信息来确定用户的权限和访问控制。

  6. 会话过期与管理:服务器可以设置会话过期时间,定期清除过期的会话。用户注销或会话超时后,服务器会删除或使会话失效。


通过以上流程,我们可以发现:基于 Session 的身份验证,前端是不需要主动参与的。核心是 浏览器 和 服务器 进行处理


优缺点


优点



  • 简单易用:对开发者而言,管理会话和验证用户身份相对简单。

  • 兼容性好:大多数浏览器支持 cookie,能够自动发送和接收 cookie。


缺点



  • 扩展性差:在分布式系统中,多个服务器可能需要共享会话存储,这可能会增加复杂性。

  • 必须配合 HTTPS:如果 cookie 被窃取,可能会导致会话劫持。因此需要使用 HTTPS 来保护传输过程中的安全性,并实施其他安全措施(如设置 cookie 的 HttpOnlySecure 属性)。


示例代码


接下来,我们通过 Express 实现一个基本的 Session 验证示例


const express = require('express'); 
const session = require('express-session');
const app = express();

// 配置和使用 express-session 中间件
app.use(session({
secret: 'your-secret-key', // 用于签名 Session ID cookie 的密钥,确保会话的安全
resave: false, // 是否每次请求都重新保存 Session,即使 Session 没有被修改
saveUninitialized: true, // 是否保存未初始化的 Session
cookie: {
secure: true, // 是否只通过 HTTPS 发送 cookie,设置为 true 需要 HTTPS 支持
maxAge: 24 * 60 * 60 * 1000 // 设置 cookie 的有效期,这里设置为 24 小时
}
}));

// 登录路由处理
app.post('/login', (req, res) => {
// 进行用户身份验证(这里假设用户已经通过验证)
// 用户 ID 应该从数据库或其他存储中获取
const user = { id: 123 }; // 示例用户 ID
req.session.userId = user.id; // 将用户 ID 存储到 Session 中
res.send('登录成功');
});


app.get('/dashboard', (req, res) => {
if (req.session.userId) {
// 如果 Session 中存在用户 ID,说明用户已登录
res.send('返回内容...');
} else {
// 如果 Session 中没有用户 ID,说明用户未登录
res.send('请登录...'); // 提示用户登录
}
});

app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});


02:基于 JWT(JSON Web Token) 的身份验证方案


什么是基于 JWT 的身份验证?


这应该是我们目前 最常用 的身份验证方式。


服务端返回 Token 表示用户身份令牌。在请求中,把 token 添加到请求头中,以验证用户信息。


因为 HTTP 请求本身是无状态的,所以这种方式也被成为是 无状态身份验证方案


JWT 运行的基本原理



  1. 用户登录:用户在登录页面输入凭据(如用户名和密码),这些凭据通过前端发送到后端服务器进行验证。

  2. 生成 JWT:后端服务器验证用户凭据后,生成一个 JWT。这个 JWT 通常包含用户的基本信息(如用户 ID)和一些元数据(如过期时间)。

  3. 返回 JWT:服务器将生成的 JWT 发送回前端,通常通过响应的 JSON 数据返回。

  4. 存储 JWT:前端将 JWT 存储在客户端(Token),通常是 localStorage 。极少数的情况下会保存在 cookie 中(但是需要注意安全风险,如:跨站脚本攻击(XSS)和跨站请求伪造(CSRF))

  5. 使用 JWT 进行请求:在用户进行 API 调用时,前端将 JWT(Token) 附加到请求的 Authorization 头部(格式为 Bearer )发送到服务器。

  6. 验证 JWT:服务器接收到请求后,提取 JWT(Token) 并验证其有效性。验证过程包括检查签名、过期时间等。如果 JWT 合法,服务器会处理请求并返回相应的资源或数据。

  7. 响应请求:服务器处理请求并返回结果,前端根据需要展示或处理这些结果。


优缺点


优点



  • 无状态:JWT 是自包含的,不需要在服务器端存储会话信息,简化了扩展性和负载均衡。

  • 跨域支持:JWT 可以在跨域请求中使用(例如,API 与前端分离的场景)。


缺点



  • 安全性:JWT 的安全性取决于密钥的保护和有效期的管理。JWT 一旦被盗用,可能会带来安全风险。


示例代码


接下来,我们通过 Express 实现一个基本的 JWT 验证示例


const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const app = express();

app.use(bodyParser.json());

const secretKey = 'your-secret-key'; // JWT 的密钥,用于签名和验证

// 登录路由,生成 JWT
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 用户身份验证(假设验证通过)
const user = { id: 1, username: 'user' }; // 示例用户信息
const token = jwt.sign(user, secretKey, { expiresIn: '24h' }); // 生成 JWT
res.json({ token }); // 返回 JWT
});

// 受保护的路由
app.get('/dashboard', (req, res) => {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).send('没有提供令牌');
}
jwt.verify(token, secretKey, (err, decoded) => {
if (err) {
return res.status(401).send('无效的令牌');
}
res.send('返回仪表板内容');
});
});

app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});

03:基于 SSO 的身份验证方案


什么是基于 SSO(Single Sign-On,单点登录) 的身份验证?


SSO 身份验证多用在 “成套” 的应用程序中,通过 登录中心 的方式,可以实现 一次登录,在多个应用中均可以获取身份


SSO 运行的基本原理



  1. 用户访问应用:用户访问一个需要登录的应用(称为服务提供者或 SP)。

  2. 重定向到身份提供者:由于用户尚未登录,应用会将用户重定向到 SSO 身份提供者(Identity Provider,简称 IdP)(一般称为 登录中心)。登录中心 是负责处理用户登录和身份验证的系统。

  3. 用户登录:用户在 登录中心 输入凭据进行登录。如果用户已经在 IdP 处登录过(例如,已登录到公司内部的 SSO 系统),则可能直接跳过登录步骤。

  4. 生成 SSO 令牌:SSO 身份提供者验证用户身份后,生成一个 SSO 令牌(如 OAuth 令牌或 SAML 断言),并将用户重定向回原应用,同时附带令牌。

  5. 令牌验证:原应用(服务提供者)接收到令牌后,会将其发送到 SSO 身份提供者进行验证。SSO 身份提供者返回用户的身份信息。

  6. 用户访问应用:一旦身份验证成功,原应用会根据用户的身份信息提供访问权限。用户现在可以访问应用中的受保护资源,而无需再次登录。

  7. 访问其他应用:如果用户访问其他应用,这些应用会重定向用户到相同的 登录中心 进行身份验证。由于用户已经登录,登录中心 会自动验证并将用户重定向回目标应用,从而实现无缝登录。


优缺点


优点



  • 简化用户体验:用户只需登录一次,即可访问多个应用或系统,减少了重复登录的麻烦。

  • 集中管理:管理员可以集中管理用户的身份和访问权限,提高了管理效率和安全性。

  • 提高安全性:减少了密码泄露的风险,因为用户只需记住一个密码,并且可以使用更强的认证机制(如多因素认证)。


缺点



  • 单点故障:如果 登录中心 出现问题,可能会影响所有依赖该 SSO 服务的应用。

  • 复杂性:SSO 解决方案的部署和维护可能较为复杂,需要确保安全配置和互操作性。


常见的 SSO 实现技术



  • SAML(Security Assertion Markup Language)



    • 一个 XML-based 标准,用于在身份提供者和服务提供者之间传递认证和授权数据。

    • 常用于企业环境中的 SSO 实现。



  • OAuth 2.0 和 OpenID Connect



    • OAuth 2.0 是一种授权框架,用于授权第三方访问用户资源。

    • OpenID Connect 是建立在 OAuth 2.0 之上的身份层,提供用户身份认证功能。

    • 常用于 Web 和移动应用中的 SSO 实现。



  • CAS(Central Authentication Service)



    • 一个用于 Web 应用的开源 SSO 解决方案,允许用户通过一次登录访问多个 Web 应用。




04:基于 OAuth 2.0 的身份验证方案


什么是基于 OAuth 2.0 的身份验证?


基于 OAuth 2.0 的身份验证是一种用于授权第三方应用访问用户资源的标准协议。常见的有:微信登录、QQ 登录、APP 扫码登录等


OAuth 2.0 主要用于授权,而不是身份验证,但通常与身份验证结合使用来实现用户登录功能。


OAuth 2.0 运行的基本原理


OAuth 2.0 比较复杂,在了解它的原理之前,我们需要先明确一些基本概念。


OAuth 2.0 的基本概念



  1. 资源拥有者(Resource Owner):通常是用户,拥有需要保护的资源(如个人信息、文件等)。

  2. 资源服务器(Resource Server):提供资源的服务器,需要保护这些资源免受未经授权的访问。

  3. 客户端(Client):需要访问资源的应用程序或服务。客户端需要获得资源拥有者的授权才能访问资源。

  4. 授权服务器(Authorization Server):责认证资源拥有者并授权客户端访问资源。它颁发访问令牌(Access Token)给客户端,允许客户端访问资源服务器上的受保护资源。


运行原理



  1. 用户授权:用户使用客户端应用进行操作时,客户端会请求授权访问用户的资源。用户会被重定向到授权服务器进行授权。

  2. 获取授权码(Authorization Code):如果用户同意授权,授权服务器会生成一个授权码,并将其发送回客户端(通过重定向 URL)。

  3. 获取访问令牌(Access Token):客户端使用授权码向授权服务器请求访问令牌。授权服务器验证授权码,并返回访问令牌。

  4. 访问资源:客户端使用访问令牌向资源服务器请求访问受保护的资源。资源服务器验证访问令牌,并返回请求的资源。


常见的授权流程



  1. 授权码流程(Authorization Code Flow):最常用的授权流程,适用于需要与用户交互的客户端(如 Web 应用)。用户在授权服务器上登录并授权,客户端获取授权码后再交换访问令牌。

  2. 隐式流程(Implicit Flow):适用于公共客户端(如单页应用)。用户直接获得访问令牌,适用于不需要安全存储的情况,但不推荐用于高度安全的应用。

  3. 资源所有者密码凭据流程(Resource Owner Password Credentials Flow):适用于信任客户端的情况。用户直接将用户名和密码提供给客户端,客户端直接获得访问令牌。这种流程不推荐用于公开的客户端。

  4. 客户端凭据流程(Client Credentials Flow):适用于机器对机器的情况。客户端直接向授权服务器请求访问令牌,用于访问与客户端本身相关的资源。


优缺点


优点



  • 灵活性:OAuth 2.0 支持多种授权流程,适应不同类型的客户端和应用场景。

  • 安全性:通过分离授权和认证,增强了系统的安全性。使用令牌而不是用户名密码来访问资源。


缺点



  • 复杂性:OAuth 2.0 的实现和配置可能较复杂,需要正确管理访问令牌和刷新令牌。

  • 安全风险:如果令牌泄露,可能会导致安全风险。因此需要采取适当的安全措施(如使用 HTTPS 和适当的令牌管理策略)。


示例代码


接下来,我们通过 Express 实现一个基本的 OAuth 2.0 验证示例


const express = require('express');
const axios = require('axios');
const app = express();

// OAuth 2.0 配置
const clientId = 'your-client-id';
const clientSecret = 'your-client-secret';
const redirectUri = 'http://localhost:3000/callback';
const authorizationServerUrl = 'https://authorization-server.com';
const resourceServerUrl = 'https://resource-server.com';

// 登录路由,重定向到授权服务器
app.get('/login', (req, res) => {
const authUrl = `${authorizationServerUrl}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=read`;
res.redirect(authUrl);
});

// 授权回调路由,处理授权码
app.get('/callback', async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).send('Authorization code is missing');
}

try {
// 请求访问令牌
const response = await axios.post(`${authorizationServerUrl}/token`, {
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret
});

const { access_token } = response.data;

// 使用访问令牌访问资源
const resourceResponse = await axios.get(`${resourceServerUrl}/user-info`, {
headers: { Authorization: `Bearer ${access_token}` }
});

res.json(resourceResponse.data);
} catch (error) {
res.status(500).send('Error during token exchange or resource access');
}
});

app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});

总结一下


目前这四种验证方案均有对应的 优缺点、应用场景:



  • Session:非常适合简单的服务器呈现的应用程序

  • JWT:适用于现代无状态架构和移动应用

  • SSO:非常适合具有多种相关服务的企业环境

  • OAuth 2.0:第三方集成和 API 访问的首选

作者:程序员Sunday
来源:juejin.cn/post/7399986979736322063
收起阅读 »

京东企业业务前端监控实践

作者:零售企业业务苏子刚 监控的背景和意义 在现代前端开发中,接入监控系统是一个很重要的环节,它可以帮助开发者、产品、运营了解应用的性能表现,用户的实际体验以及潜在的错误和问题,从而进一步优化用户体验,帮助产品升级迭代。 背景 •应用复杂性增加:随着单页应用(...
继续阅读 »

作者:零售企业业务苏子刚


监控的背景和意义


在现代前端开发中,接入监控系统是一个很重要的环节,它可以帮助开发者、产品、运营了解应用的性能表现,用户的实际体验以及潜在的错误和问题,从而进一步优化用户体验,帮助产品升级迭代。


背景


应用复杂性增加:随着单页应用(SPA)和渐进式网页应用(PWA)的流行,前端应用变得越来越复杂。


网页加载性能要求提高:用户对网页加载速度和交互响应的要求越来越高,性能成为影响用户体验的关键因素。


多样化的设备和网络环境:用户通过各种设备和网络环境访问应用,这些因素都可能影响应用的性能和用户体验。


敏捷开发和持续部署:敏捷开发和 CI/CD 的实践要求开发团队能够快速的响应问题,并持续改进产品。


意义


对应用性能监控,提升用户体验:监控页面的加载时间,交互时间等性能指标,帮助团队优化代码,提升用户体验。


错误追踪,快速定位并解决问题:捕获前端错误和异常,快速定位问题源头,缩短故障的修复时间。


用户行为分析,指导产品快速迭代:了解用户如何与应用互动,哪些功能用户喜欢,哪些路径导致转化,从而指导产品迭代升级。


业务指标监控,保证主流程的稳定性:监控关键业务流程,如:购物车、商详、结算页等黄流,确保业务流程的稳定性。


告警系统,异常情况能够快速响应:通过定时任务自动化巡检/实时监控,当出现性能异常时,能够快速响应。


监控的类别


从上面可看出,通过监控能够促使我们的系统更加健壮、稳定,体验更好。那我们团队也从去年开始逐步将各个应用接入了监控。到目前为止,我们的监控分为两个部分:


•实时监控:成功集成了 SGM 集团内部监控平台,并引入了相应的 SDK 来对前端应用进行实时监控。目前,我们所涉及的 100+ 个应用程序已经完全接入该系统。通过配置有效的告警机制,我们能够及时捕捉并上报线上环境中的错误和异常,确保业务的稳定运行。


•定时任务巡检:实现了定时任务的自动化设置,用于定期执行自动巡检,并自动上报检测结果,还能激活告警系统。通过这种方式,我们能够确保持续监控系统健康状况,并在发现潜在问题时能够迅速响应。


◦使用 Chrome 插件快速创建 UI 测试脚本,随后在 UI啄木鸟平台 上配置定时执行这些脚本进行系统巡检。这一流程实现了自动化的界面测试,确保应用的用户界面按预期工作,同时及时发现并解决潜在的 UI 问题;


◦自主研发一套脚本,这些脚本通过启动一个 Node.js 服务来进行系统巡检。这种方法使我们能够灵活地监控系统性能和功能,及时发现并处理潜在的问题,从而保障系统的稳定运行;


监控整体架构


image.png


监控建设实践


实时监控


通过整合 SGM 监控平台和告警系统,我们实现了对所有接入应用的实时监控。一旦检测到异常,系统会立即通过多种方式(如咚咚、邮件、电话等)通知相关团队成员,确保问题能够被迅速发现和解决。为了提高告警的有效性,我们精心设计了告警策略,确保只有真正的异常情况才会触发告警。以下是我们在优化告警设置过程中积累的一些关键经验和建议:


1.精确度与敏感度的平衡:过于敏感的告警会导致频繁的误报,而设置过于宽松则可能错过关键的异常。找到合适的平衡点至关重要。


2.分级告警机制:根据问题的严重程度设置不同级别的告警,以便采取相应的响应措施。紧急问题可以通过电话直接通知,而较为轻微的问题可以通过邮件或即时消息通知。


3.持续优化告警规则:定期回顾和分析告警的准确性和响应情况,根据实际情况调整告警规则,以提高告警的准确性和有效性。


4.明确责任分配:确保每个告警都有明确的责任人,这样一旦发生异常,能够迅速有人响应。


5.培训和意识提升:对团队成员进行定期的培训,提高他们对告警系统的理解和重视,确保每个人都能正确响应告警。


这些经验和建议是我们在实践中不断摸索和尝试的结果,希望能够帮助你们更有效地管理和响应系统告警,确保应用的稳定运行。


WEB端

用户体验



用户体验是指用户在使用网站过程中的整体感受、情绪和态度,它包括与产品交互的全程。对于开发者而言,打造出色的用户体验意味着关注网站的加载速度、视觉稳定性、交互响应时间、渲染性能等关键因素。Google 提出了一系列 Web 性能标准指标,这些指标旨在量化用户体验的不同层面。SGM 性能监控平台紧跟这些标准,对几项关键指标进行监控并提供反馈,以确保用户能够获得流畅且愉悦的网站使用体验。



•LCP:页面加载过程中最大的内容元素(图片、视频等)渲染完成的时间点。也可近似看作是首屏加载时间。Google 标准是 小于等于 2500ms。









最初,我们遵循了 Google 提出的标准来精确设定我们的告警系统。









配置告警后,我们注意到告警频率较高,主要是因为多数系统的实际网页最大内容绘制(LCP)值普遍超过了预期的 2.5s 标准。然而,这些告警并未实质性影响用户的正常使用体验。为了优化告警机制,我们经过仔细评估后,决定将部分系统的 LCP 阈值暂时调整至 5s,并相应调整了告警级别,以更合理地反映系统性能对用户体验的实际影响。


显然,当前调整的 LCP 阈值 5s 并非我们的最终目标。这一调整是基于应用当前的性能状况所做的临时措施,旨在优化告警的频率和质量。我们计划随着对各个应用性能的持续改进,最终将 LCP 阈值恢复至标准的 2.5s。


•CLS:从页面开始加载到生命周期状态更改为隐藏期间发生的所有意外布局偏移的累计得分。Web 给出的性能指标是 < 0.1


•FCP:从网页开始加载到有内容渲染的耗时。标准性能指标 1.8s


•FID:用户首次发出交互指令到页面可以响应为止的时间差。标准性能指标 100ms


•TTFB:客户端接收到服务器返回的第一个字节响应信息的耗时。标准性能指标 1000ms









从最初启用告警到目前决定关闭大部分告警,我们的决策基于以下考虑:


1.用户体验未受显著影响:即使某些性能指标超出了标准值,我们观察到这并未对用户操作网站造成实质性影响。


2.避免告警疲劳:频繁的告警可能导致开发团队对通知产生麻木感,从而忽视那些真正关键的、影响网站健康的告警。


3.指标作为优化参考:这些性能指标更多地被视为优化网站时的参考点,而对用户的直觉体验影响甚微。


4.有针对性的优化:在网站优化过程中,我们会将这些指标作为健康检查的一部分,进行针对性的改进,而无需依赖告警来频繁提醒。


基于这些理由,我们目前选择只对 LCP 指标保持告警启用,以确保关注到可能影响用户加载体验的关键性能问题。


健康度指标告警开启标准值优化后值告警阀值告警级别
LCP开启<=2500ms针对部分应用 <=5000ms1min 内 耗时>=5000ms(持续优化并更新阀值到2500ms) 调用次数:50 连续次数:1次通知
FCP关闭<=1800ms--
CLS关闭<=0.1--
FID关闭<=100ms--
TTFB关闭<=1000ms--

页面性能



网页性能涉及从加载开始到完全可交互的整个过程,包括页面内容的下载、解析和渲染等多个阶段。在 SGM 监控平台上,我们能够追踪到一系列与页面加载性能相关的指标,如页面加载总时长、DNS 解析时长、DOM 加载完成时间、用户的浏览器和地理分布、首次渲染(白屏)时间、用户行为追踪、性能重现和热力图分析等。这些丰富的数据为我们优化页面性能提供了宝贵的参考。特别是首次渲染时间,或称为白屏时间,是我们监控中特别关注的一个关键指标,因为它直接影响用户的首次印象和整体体验。



•白屏时间:这一指标能够向开发者展示哪些页面出现了白屏现象。在利用此指标之前,我们需要为每个应用单独配置白屏时间的监控参数,以确保准确地捕捉到首次内容呈现的时刻。这有助于我们识别并优化那些影响用户首次加载体验的关键页面。









为了监控白屏时间,我们必须在应用的全局配置中的白屏监控项下,指定每个页面的 URL 及其关键元素。同时,我们还需设定监控的起始时间点和超时阈值。值得注意的是,URL 配置支持正则表达式,这为我们提供了灵活性,以匹配和监控一系列相似的页面路径。









•白屏检测的机制:白屏检测机制的核心在于验证页面上的关键元素是否已经被渲染。所谓关键元素,指的是在配置过程中指定的用于检测的 DOM 元素。这些元素的渲染情况是判断页面是否白屏的依据。


•告警设置:白屏告警功能已经启用,但目前还处于初期阶段,一些功能尚待完善。例如,当前的 URL 配置尚未支持正则表达式匹配。










面对当前的局限性,我们采取了双策略应对。一方面,我们利用白屏告警功能直接监控页面的白屏情况;另一方面,我们通过分析页面加载性能指标中的白屏时间来间接监测潜在的白屏问题。在设置平均耗时时间和告警级别时,我们综合考虑了多个因素,包括用户的网络环境、告警的发生频率以及告警的实际适用性,以确保监控方案的有效性和合理性。



网页性能告警开启优化前值优化后值告警阀值告警级别
首包时间关闭----
页面完全加载时间关闭----
白屏时间开启<=5000ms<=10000ms1min 内 耗时>=10000ms,后期采用白屏告警替代 调用次数:30 连续次数:5 次紧急

•此外,我们的页面追踪功能包括用户行为回溯、页面性能重现以及行为轨迹热力图,这些工具允许我们从多个维度和场景对用户行为进行深入分析,极大地便利了问题的诊断和排查。


JSError监控



我们通过配置错误关键词来匹配控制台的报错信息。报错阈值的设定可以参考各个项目的 QPS,因为在项目实施了恰当的降级策略后,即便控制台出现报错,页面通常仍能正常访问,不会对用户体验造成影响。因此,这个阈值可以适当设置得较高。



这里需要特别注意 “Script error” 的错误,这种错误给不到任何对我们有用的信息。所以需要采用一定的手段避免出现类似的报错:


•这种错误也称之为跨域错误,所以首先,我们需要开启跨域资源共享功能(CORS)。


<script src="http://xxxdomain.com/home.js" crossorigin></script>

•针对 Vue 项目,由于 Vue 重写了 window.onerror 事件,所以我们需要在 Vue 项目中增加 错误处理:


Vue.config.errorHandler = (err, vm, info)=> {
if (err) {
try {
console.error(err);
window.__sgm__.error(err)
} catch (e) {}
}
};

•在某些情况下,“Script error” 可能是无关紧要的,我们可以选择忽略这类特定的错误。为此,可以关闭这些特定错误的监控,具体的错误可以通过它们的 hash 在错误日志中进行识别和过滤。









关键指标关键字(支持正则匹配)触发次数告警级别
js错误null、undefined、error、map、filter、style、length...周期:1min ,错误次数:50/100/200(可参考 QPS 值设置),连续次数:1次严重

API请求监控



这里我们的告警设置主要关注 HTTP 状态码和业务错误码。这两项指标的异常表明我们的应用可能遇到了问题,需要我们迅速进行检查和处理以确保系统的正常运行。



首先,我们必须在应用的监控配置中设定数据采集参数:









关键指标错误码业务域名触发次数告警级别
http错误!200(Http响应非200报警)xx1.jd.com xx2.jd.com周期:1min 错误次数:1 总调用次数:50 连续次数:1严重
业务失败码errCode(根据实际业务线设置 -1,-2等)

针对业务失败码:


1.由于现有应用跨不同业务条线存在异常码的差异,我们需要针对每个业务线收集并配置其特定的异常码。


应用来源标准响应针对性告警
慧采PC 企业购{ "code":null, "success":true, "msg":"操作成功", "result":{} }业务异常码 code ·-1,-2 ·...
锦礼{ "code": 000, "data": {}, "msg": "操作成功" }业务异常码 code ·! (1000 && 3001等 )
color·-1 echo ·1 echo
其他.......

2、对于新增应用,我们实施了后端服务异常码的标准化。因此,在监控方面,我们只需要配置一套统一的标准来进行监控。


{
"50000X": "程序异常,内部",
"500001": "程序异常,上游",
"500002": "程序异常,xx",
"500003": "程序异常,xx",
...
}

资源错误



这里通常指的是 css、js、图片等资源的加载错误



关键指标告警开启告警阀值告警级别
资源错误开启周期:1min 错误次数:200(也可参照QPS进行设置) 连续次数:1严重

对于图片加载错误,只需在项目中实施适当的降级方案。在应用的监控配置中,我们可以设置为不收集图片错误相关的数据。









再来举个例子:


在企业业务的封闭场景中,例如慧采平台,我们集成了埋点 JavaScript 脚本。然而,由于某些客户的网络环境,导致我们的埋点相关静态资源延迟加载。









治理方案:









自定义上报



每个业务流程的关键节点或核心功能实施了专门的监控措施,以便对任何异常状况进行跟踪、监控并及时上报。



目前,几条业务线已经实施了自定义上报机制,其主要目的包括:


•利用自定义上报来捕获接口异常的详细信息,如入参和出参,以便在线上出现异常时,能够依据上报的数据快速进行问题的诊断和定位。


•在复杂的环境下,准确追踪用户行为导致的错误,并利用上报的信息进行有效的问题排查和定位。


锦礼酷兜: 用户在选择地址后,系统未能根据所选地址提供正确的信息,导致页面加载出现异常。


由于这个 H5 页面被嵌入到用户的 App 内,开发者难以直接复现用户遇到的问题。因此,我们利用监控平台的自定义上报功能来收集相关信息,以辅助进行问题排查。


•上报地址组件返回值









•上报接口入参









•然后根据自定义上报日志查看具体信息









E卡:外部引用资源异常上报(设备指纹,eid 等)


在结算页面提交订单时,系统需要获取设备ID。为此,我们实施了降级方案,并通过自定义上报机制对此过程进行监控,以确保流程的顺利执行。


降级方案:如果获取不到,后端会生成 uuid 给到前端。
















企业购注销pc: 我们需要集成科技 SDK,以便在页面上完成用户注销后自动跳转到指定的 URL。上线后,收到客户反馈指出在完成注销流程后页面未能正确跳转。









接口异常: 在接口异常中,添加自定义监控,查看入参和出参信息。










尽管我们已经在应用中引入了自定义监控以便更好地观察和定位问题,但我们仍需进一步细化和规范化这些监控措施。目前,我们正积极对各业务线的功能点进行梳理,以实现更深入的细化监控。我们的目标是为每个业务线、每个应用的关键链路和功能点定制针对各种异常情况的精细化自定义监控(例如,某页面按钮的显示或点击异常)。



自定义告警告警开启告警阀值告警级别
自定义编码开启1min内 调用次数:50 连续次数:1次警告

小程序端


与 Web 端相比,小程序的监控存在一些差异,主要是缺少了如 LCP(最大内容绘制)等特定性能指标。然而,性能问题、JavaScript错误、资源加载错误等其他监控指标仍然可以被捕获。此外,小程序官方和开发者工具都提供了性能检测工具,这些工具便于开发者查看和优化小程序应用的性能。本文将不深入介绍 Web 端的监控指标,而是专注于介绍小程序中独有的监控和分析工具。



小程序官方后台



可以分析接口数据、js 分析等。











利用 SGM 的监控功能结合小程序官方的分析工具,对我们的小程序进行综合优化,是一个有效的策略。



原生应用

基础监控:mPaas、烛龙、SGM


mPaaS崩溃监控。应用崩溃对用户体验有显著影响,是移动端监控中的一个关键指标。通过不断的监控和优化,京东慧采移动端的崩溃率已经降至较低水平,目前的平均用户崩溃率大约为0.03122%。









烛龙:启动耗时、首屏耗时、启动且首焦耗时、卡顿。为了改善首屏加载时间,京东慧采采用了烛龙监控平台并实施了相应的优化措施。优化后,应用的整体性能显著提升,其中 Android 平台的tp95耗时大约为 2764ms,iOS 平台的tp95耗时大约为1791ms,均达到了较低的水平。
















SGM:网络、WebView、原生页面等指标。


业务监控


京东慧采在多个业务模块中,其中登录、商详、订单详情 3 个模块接入了业务监控。


登录:登录接入 SGM 监控平台,自定义了整个流程错误码,并配置了告警规则


(1)600:登录正常流程(必要是可白名单开启)


(2)601:登录防刷验证流程异常监控


(3)602:登录魔方验证流程异常监控


(4)603:登录流程异常监控























商详、订单详情:在商品详情和订单详情页面,我们集成了业务监控 SDK。通过移动配置平台,我们下发了监控规则,以便在接口返回的数据不符合预期时,能够上报错误信息。目前,这些监控信息被上报到崩溃分析平台的自定义异常模块中,以便进行进一步的分析和优化。


(1)接口请求是否成功。


(2)banner 楼层:是否为空、楼层类型是否正常(1 原生)、数据/大小图地址是否为空。


(3)商品信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/商品名称/价格是否为空。


(4)服务楼层:是否为空、楼层类型是否正常(1 原生)、数据/服务信息是否为空。


(5)spu 和物流楼层:是否为空、楼层类型是否正常(1 原生)、数据/sup信息是否为空。


(6)其他:其他/按钮数据/按钮名称是否为空、按钮类型是否正常(排除1/2/3/4/5/6/20000)。


订单详情监控信息:


(1)接口请求是否成功。


(2)基础信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/地址信息是否为空。


(3)商品信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/商品列表是否为空。


(4)支付信息楼层:是否为空、楼层类型是否正常(2 动态)、数据是否为空。


(5)价格楼层:是否为空、楼层类型是否正常(2 动态)、数据是否为空。












定时巡检


定时巡检可以通过两种方法实现:


•利用 UI 啄木鸟平台配置定时执行的任务。


•使用团队开发的自定义脚本,并通过自动启动的服务来执行这些检测任务。


UI 啄木鸟

定时巡检的主要目的是确保每个项目的核心流程在每次迭代中保持稳定。通过配置定时任务,我们能够及时发现并解决线上问题,从而维护系统的稳定性。


什么是 UI 啄木鸟


UI啄木鸟平台,由京东集团-京东科技开发,是一个自动化巡检工具,其主要功能包括:


•Chrome插件:用于录制项目的用户交互步骤。


•定时任务平台:用于在服务器端定期执行已录制的脚本进行巡检。


在使用Chrome插件过程中,我们遇到了一些问题。与京东科技团队沟通后,我们获得了共同开发和升级插件的机会。目前,我们已经添加了新功能并修复了一些已知问题。


1.打开录制和停止录制按钮,分别开启监听和关闭拦截页面事件功能;


2.新增点击录制后保留当前录制步骤功能;


3.在执行事件过程中,禁止再次点击执行,避免执行顺序错乱;


4.点击事件可切换操作类型为 focus 事件,便于监听滚动条滑动;


5.在步骤复现情况下,调整判断元素选择器和屏幕位置的顺序,避免位置出入点击位置错位;


6....


使用chrome扩展程序进行安装即可。









怎么使用


•新建录制脚本









•点击详情,开启录制









•监听步骤









•关联到啄木鸟平台









•啄木鸟平台(调试、配置 Cookie,开启定时任务)









自启动巡检工具


自动化巡检工具能够检测页面上的多种元素和链接,包括 a 标签的外链、接口返回的链接、鼠标悬停元素、点击元素,以及跳转后 URL 的有效性。该工具尤其适合用于频道页,这些页面通常通过投放广告和配置通天塔链接来生成。在大型促销活动期间,我们可以运行脚本来验证广告和通天塔链接的有效性。目前,工具的功能还相对有限,并且对于广告组等特定接口的支持不够通用,这也是我们计划逐步改进和优化的方向。



功能检测


•检测所有 a 标签和所有接口响应数据中包含所有通天塔活动外链是否有效。









{
"cookieThor":"", // 是否依赖cookie等登录态,无需请传空
"urlPattern": "pro\.jd\.com",// 匹配的链接,这里只匹配通天塔
"urls": ["https://b.jd.com/s?entry=newuser"] // 将要检测的url,可填多个
}

运行结果:









•检测鼠标 hover 事件,收集用户交互后的接口响应数据,检测所有活动外联是否有效。









{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"hoverElements": [
{
"item": "#focus-category-id .focus-category-item", // css样式选择器
"target": ".focus-category-item-subtitle"
}
]
}
]
}








•检查 click 事件,收集用户交互后的接口响应数据,检测所有通天塔活动外联是否有效。









{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"clickElements": [
{
"item": "#recommendation-floor .drip-tabs-tab"
}
]
}
]
}








•检测点击后,跳转后的链接有效性。









{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"clickElements": [
{
"item": ".recommendation-product-wrapper .jdb-sku-wrapper"
}
]
}
]
}








检测原理









监控后暴露的问题


•慧采 jsError:


用于埋点数据上报的API,但在一些封闭环境中,由于网络环境,客户可能无法及时访问埋点或指纹识别等 SDK,导致频繁的报错。为了解决这个问题,我们可以通过引入 try...catch 语句来捕获异常,并结合使用队列机制,以确保埋点数据能够在网络条件允许时正常上报。这样既避免了错误的频繁发生,也保障了数据上报的完整性和准确性。


public exposure(exposureId: ExposureId, jsonParam = {}, eventParam = '') {
this.execute(() => {
try {
const exposure = new MPing.inputs.Exposure(exposureId);
exposure.eventParam = eventParam; // 设置click事件参数
exposure.jsonParam = serialize(jsonParam); // 设置json格式事件参数,必须是合法的json字符串
console.log('上报 曝光 info >> ', exposure);
new MPing().send(exposure);
} catch (e) {
console.error(e);
}
});
}

private execute(fn: CallbackFunction) {
if (this._MPing === null) {
this._Queue.push(fn);
} else {
fn();
}
}

•企业购订详异常:
















总结


接入前


在过去,我们的应用上线后,对线上运行状况了解不足,错误发生时往往依赖于用户或运营团队的反馈,这使我们常常处于被动应对状态,有时甚至会导致严重的线上问题,如白屏、设备兼容性问题和异常报错等。为了改变这一局面,我们开始寻找和实施解决方案。从去年开始,我们逐步将所有 100+ 应用接入了监控系统,现在我们能够实时监控应用状态,及时发现并解决问题,大大提高了我们的响应速度和服务质量。


接入后


自从接入监控系统后,我们的问题发现和处理方式从被动等待用户反馈转变为了主动监测。现在我们能够即时发现并快速解决问题。此外,我们还利用监控平台对若干应用进行了性能优化,并为关键功能点制定了预先的降级策略,以保障应用的稳定运行。


•页面健康度监控表明,我们目前已有 50+ 个项目的性能评分达到或超过 85 分。通过分析监控数据,我们对每个应用的各个页面进行了详细分析和梳理。在确保流程准确无误的基础上,我们对那些性能较差的页面进行了持续的优化。目前,我们仍在对一些关键但性能表现一般的应用进行进一步的优化工作。
















•通过配置 JavaScript 错误和资源错误的告警机制,我们在项目迭代过程中及时解决了多个 JavaScript 问题,有效降低了错误率和告警频率。正如前文所述,对于慧采PC 这类封闭环境,客户公司的网络策略可能会阻止埋点 SDK 和设备指纹等 JavaScript 资源的加载,导致全局变量获取时出现错误。我们通过实施异常捕获和队列机制,不仅规避了部分错误,还确保了埋点数据的准确上报。









•通过收集 HTTP 错误码和业务失败码,并设置相应的告警机制,我们能够在接到告警通知的第一时间内分析并解决问题。例如,我们成功地解决了集团内部遇到的 “color404” 问题等。这种做法加快了问题的响应和解决速度,提高了服务的稳定性和用户满意度。


•自定义监控:通过给接口异常做入参、出参的上报、在关键功能点上报有效信息等,能够在应用出现异常时,快速定位问题并及时修复。









•通过实施定时任务巡检,我们有效地避免了迭代更新上线可能对整个流程造成的影响。同时,巡检工具的使用也确保了外部链接的有效性,进一步保障了应用的稳定运行和用户体验。


规划


监控是一个持续长期的过程,我们致力于不断完善,确保系统的稳定性和安全性。基于现有的监控能力,我们计划实施以下几项优化措施:


1.应用性能提升:我们将持续优化我们的应用,目标是让 90% 以上的应用性能评分达到 90 分以上,并对资源错误和 JavaScript 错误进行有效管理。


2.深化监控细节:我们将扩展监控的深度和广度,确保能够捕捉到所有潜在的异常情况。例如,如果一个仅限采购账号使用的按钮错误地显示了,这应该触发自定义异常上报,并在代码层面实施降级处理。


3.巡检工具升级:我们将继续升级我们的 Chrome 巡检插件,提高其智能化程度和覆盖范围,以保持线上主要流程的健壮性和稳定性。


结尾


我们是企业业务大前端团队,会持续针对各端优化升级我们的监控策略,如果您有任何疑问或者有更好的建议,我们非常欢迎您的咨询和交流。


作者:京东零售技术
来源:juejin.cn/post/7400271712359186484
收起阅读 »

💥图片碎片化展示-Javascript

web
写在开头 哈喽吖!各位好!😁 今天刚好是周四呢,疯狂星期四快整起来。🍔🍟🍗 最近,小编从玩了两年多的游戏中退游了😔,本来以为会一直就这么玩下去,和队友们相处很融洽,收获了很多开心快乐的时光😭。可惜,游戏的一波更新......准备要开始收割韭菜了,只能无奈选择弃...
继续阅读 »

写在开头


哈喽吖!各位好!😁


今天刚好是周四呢,疯狂星期四快整起来。🍔🍟🍗


最近,小编从玩了两年多的游戏中退游了😔,本来以为会一直就这么玩下去,和队友们相处很融洽,收获了很多开心快乐的时光😭。可惜,游戏的一波更新......准备要开始收割韭菜了,只能无奈选择弃坑了。


02D754C7.jpg

小编属于贫民玩家,靠着硬肝与白嫖也将游戏号整得还不错,这两天把号给卖了💰。玩了两年多,竟然还能赚一点小钱,很开心😛。只是...多少有点舍不得的一起组队的队友们,唉。😔


记录一下,希望未来还有重逢一日吧,也希望各位一切安好!😆


好,回到正题,本文将分享一个图片碎片化展示的效果,具体效果如下,请诸君按需食用。


06132.gif

原理


这种特效早在几年前就已经出现,属于老演员了😪,它最早是经常在轮播图(banner)上应用的,那会追求各种花里胡哨的特效,而现在感觉有点返璞归真了,简洁实用就行。


今天咱们来看看它的具体实现原理是如何的,且看图:


image.png

一图胜千言,不知道聪明的你是否看明白了?😉


大概原理是:通过容器/图片大小生成一定数量的小块,然后每个小块背景也使用相同图片,再使用 background-sizebackground-position 属性调整背景图片的大小与位置,使小块又合成一整张大图片,这操作和使用"精灵图"的操作是一样的,最后,我们再给每个小块增加动画效果,就大功告成。


简单朴实😁,你可以根据这个原理自个尝试一下,应该能整出来吧。👻


具体实现


布局与样式:


<!DOCTYPE html>
<html>
<head>
<style>
body{
width: 100%;
height: 100vh;
padding: 0;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
.box {
width: var(--width);
height: var(--height);
display: flex;
/* 小块自动换行排列 */
flex-wrap: wrap;
justify-content: center;
}
.small-box {
background-image: url('https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/99b070fcb1de471d9af4f4d5d3f71909~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1120&h=1680&s=2088096&e=png&b=d098d0');
box-sizing: border-box;
background-repeat: no-repeat;
}
</style>
</head>
<body>
<div id="box" class="box"></div>
</body>
</html>

生成无数小块填充:


<script>
document.addEventListener('DOMContentLoaded', () => {
const box = document.getElementById('box');
const { width, height } = box.getBoundingClientRect();
// 定义多少个小块,由多少行和列决定
const row = 14;
const col = 10;
// 计算小块的宽高
const smallBoxWidth = width / col;
const smallBoxHeight = height / row;
/** @name 创建小块 **/
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
const smallBox = document.createElement('div');
smallBox.classList.add('small-box');
smallBox.style.width = smallBoxWidth + 'px';
smallBox.style.height = smallBoxHeight + 'px';
smallBox.style.border = '1px solid red';
// 插入小块
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>

上面,生成多少个小块是由人为规定行(row)与列(col)来决定。可能有的场景想用小块固定的宽高来决定个数,这也是可以的,只是需要注意处理一下"边界"的情况。😶


image.png

调整小块背景图片的大小与位置:


<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
smallBox.style.border = '1px solid red';

// 设置背景偏移量,让小块的背景显示对应图片的位置,和以前那种精灵图一样
const offsetX = j * smallBoxWidth * -1;
const offsetY = i * smallBoxHeight * -1;
smallBox.style.backgroundPosition = `${offsetX}px ${offsetY}px`;
smallBox.style.backgroundSize = `${width}px ${height}px`;

box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>

女神拼接成功,到这里就已经完成一大步了,是不是没什么难度!😋


image.png

小块样式整好后,接下来,我们需要来给小块增加动画,让它们动起来,并且是有规律的动起来。


先来整个简单的透明度动画,且看:


<!DOCTYPE html>
<html>
<head>
<style>
/* ... */
.small-box {
/* ... */
opacity: 0;
animation: smallBoxAnimate 2000ms linear forwards;
}
@keyframes smallBoxAnimate {
0% {
opacity: 0;
}
40% {
opacity: 0;
}
70% {
opacity: 1;
}
100% {
opacity: 1;
}
}
</style>
</head>
<body>
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
// smallBox.style.border = '1px solid red';

// 给每个小块增加不同的延时,让动画不同时间执行
const delay = i * 100; // 延迟时间为毫秒(ms),注意不要太小了
smallBox.style.animationDelay = `${delay}ms`;

box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
</body>
</html>

嘿嘿😃,稍微有点意思了吧?


06133.gif
image.png

Em...等等,你发现没有?怎么有一些小白条?这可不是小编添加的,小块的边框(border)已经是注释了的。😓


一开始小编以为是常见的"图片底部白边"问题,直接设置一下 display: block 或者 vertical-align : middle 就能解决,结果还不是,折腾了很久都没有搞掉这个小白条。😤


最后,竟然通过设置 will-change 属性能解决这个问题❗我所知道的 will-change 应该是应用在性能优化上,解决动画流畅度问题上的,想不到这里竟然也能用。



❗不对不对,当初以为是 will-change 能直接完美解决白边的问题,但是感觉还是不对,但又确实能解决。。。(部分电脑屏幕)


但其实,应该是 smallBoxWidthsmallBoxHeight 变量不是整数的问题,只要小块的宽度与高度保持一个整数,自然就没有这些白边了❗这是比较靠谱的事实,对于当前的高清屏幕来说。


但是,也是很奇怪,在小编另一台电脑(旧电脑)上即使是保持了整数,也会在横向存在一些小白边,太难受了。。。没办法彻底搞定这个问题。


猜测应该是和屏幕分辨率有关,毕竟那才是根源所在。


2024年07月01日



看来得去深度学习一下💪 will-change 属性的原理过程才行,这里也推荐倔友写得一篇文章:传送门


解决相邻背景图片白条/白边间隙问题:


<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...

smallBox.style.willChange = 'transform';
// 在动画执行后,需要重置will-change
const timer = setTimeout(() => {
smallBox.style.willChange = 'initial';
clearTimeout(timer);
}, 2000);

box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>

一定要注意 will-change 不可能被滥用,注意重置回来❗


这下女神在动画执行后,也清晰可见了,这是全部小块拼接组成的图片。


06134.gif

在上述代码中,咱们看到,通过 animation-delay 去延迟动画的执行,就能制造一个从上到下的渐变效果。


那么,咱们再改改延迟时间,如:


// const delay = i * 100; 
// 改成 ⤵
const delay = j * 100;

效果:


06135.gif

这...好像有那么点意思吧。。。


0363D0F3.png

但是,这渐变...好像还达不到我们开头 gif 的碎片化效果吧?


那么,碎片化安排上:


.small-box {
/* ... */
--rotateX: rotateX(0);
--rotateY: rotateY(0);
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
@keyframes smallBoxAnimate {
0% {
opacity: 0;
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
40% {
opacity: 0;
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
70% {
opacity: 1;
transform: rotateX(0) rotateY(0) scale(0.8);
}
100% {
opacity: 1;
transform: rotateX(0) rotateY(0) scale(1);
}
}

其实就是增加小块的样式动画而已,再加点旋转,再加点缩放,都整上,整上。😆


效果:


06136.gif

是不是稍微高级一点?有那味了?😁


看到上面旋转所用的"样式变量"没有?


--rotateX: rotateX(0);
--rotateY: rotateY(0);

不可能无缘无故突然使用,必然是有深意啦。😁


现在效果还不够炫,咱们将样式变量利用起来,让"相邻两个小块旋转相反":


<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...

// 相邻两个小块旋转相反
const contrary = (i + j) % 2 === 0;
smallBox.style.setProperty('--rotateX', `rotateX(${contrary ? -180 : 0}deg)`);
smallBox.style.setProperty('--rotateY', `rotateY(${contrary ? 0 : -180}deg)`);

box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>

效果:


06137.gif

这下对味了。😃


总的来说,我们可以通过"延迟"执行动画与改变"旋转"行为,让小块们呈现不同的动画效果,或者你只要有足够多的设想,你可以给小块添加不同的动画效果,相信也能制造出不错的整体效果。


更多效果


下面列举一些通过"延迟"执行动画产生的效果,可以瞧瞧哈。


随机:


const getRandom = (min, max) => Math.floor(Math.random() * (max - min + 1) + min);
const delay = getRandom(0, col + row) * 100;

06139.gif

从左上角到右下角:


const delay = (i + j) * 100;

06140.gif

其他的从"右上角到左下角"或者"左下角到右上角"等等的,只要反向调整一下变量就行了,就靠你自己悟啦,Come On!👻


从中心向四周扩散:


const delay = ((Math.abs(col / 2 - j) + Math.abs(row / 2 - i))) * 100;

06141.gif

从四周向中心聚齐:


const delay = (col / 2 - Math.abs(col / 2 - j) + (col / 2 - Math.abs(row / 2 - i))) * 100;

06142.gif

那么,到这里就差不多了❗


但还有最后一个问题,那就是图片的大量使用与加载时长的情况可能会导致效果展示不佳,这里你最好进行一些防范措施,如:



  • 图片链接设置缓存,让浏览器缓存到内存或硬盘中。

  • 通过 JS 手动将图片缓存到内存,主要就是创建 Image 对象。

  • 将图片转成 base64 使用。

  • 直接将图片放到代码本地使用。

  • ...


以上等等吧,反正最好就是要等图片完整加载后再进行效果展示。









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


image.png


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

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


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

Flutter局部刷新三剑客

局部刷新作为提高Flutter页面性能的重要手段,是每一个Flutter老手都必须掌握的技巧。当然,我们不用非得使用Riverpod、Provider、Bloc这些状态管理工具来实现局部刷新,Flutter框架本身也给我们提供了很多方便快捷的刷新方案,今天要提...
继续阅读 »

局部刷新作为提高Flutter页面性能的重要手段,是每一个Flutter老手都必须掌握的技巧。当然,我们不用非得使用Riverpod、Provider、Bloc这些状态管理工具来实现局部刷新,Flutter框架本身也给我们提供了很多方便快捷的刷新方案,今天要提的就是Notifier三剑客,用它来处理局部刷新,代码优雅又方便,可谓是居家必备之良器。


ChangeNotifier


ChangeNotifier作为数据提供方,给出了响应式编程的基础,我们先来看看ChangeNotifier的源码。
image.png
作为一个mixin,它就是实现了Listenable,这又是个什么呢?
image.png
这个抽象类,实际上就是实现了addListener和removeListener两个监听的处理。所以接下来我们看看ChangeNotifier是如何实现者两个方法的。
image.png
源码很简单,就是创建的listener添加到_listeners列表中。
image.png
移除也很简单。最后看下核心的notifyListeners方法。
image.png
这个方法就是遍历_listeners,来触发监听Callback。整体就是一个标准的「订阅-发布」流程。


作为Notifier家族的长辈,它的使用会略复杂一些,我们来看一个例子。首先,需要mixin一个ChangeNotifier。


class CountNotifier with ChangeNotifier {
int count = 0;

void increase() {
++count;
notifyListeners();
}
}

然后再创建一个TestWidget来调用这个ChangeNotifier。


class CountNotifierWidget extends StatefulWidget {
const CountNotifierWidget({super.key});

@override
State<StatefulWidget> createState() {
return _CountNotifierState();
}
}

class _CountNotifierState extends State<CountNotifierWidget> {
final CountNotifier _countNotify = CountNotifier();
int _count = 0;

@override
void initState() {
super.initState();
_countNotify.addListener(updateCount);
}

void updateCount() {
setState(() {
_count = _countNotify.count;
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Test: $_count"),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.increase(),
child: const Icon(Icons.add),
),
);
}

@override
void dispose() {
super.dispose();
_countNotify.removeListener(updateCount);
}
}

这样当我们修改ChangeNotifier的value的时候,就会Callback到updateCount实现刷新。


这样就形成了一个响应式的基础模型,数据修改,监听者刷新UI,完成了响应式的同时,也实现了局部刷新的功能,提高了性能。


ValueNotifier


在使用ChangeNotifier的时候,每次在修改变量时,都需要手动调用notifyListeners()方法,所以,Flutter创建了一个新的组件——ValueNotifier,它的源码如下。
image.png
从源码可以看见,ValueNotifier就是在set方法中,帮你调用了下notifyListeners()方法。同时,ValueNotifier封装了一个泛型变量,简化了ChangeNotifier的创建过程,所以大部分时间我们都是直接使用ValueNotifier。


那么有了它之后,我们就可以省去新建类的步骤,对于单一的基础类型变量,直接创建ValueNotifier即可,就像上面的例子,我们可以直接改造成下面这样。


class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<int> _countNotify = ValueNotifier(0);

@override
void initState() {
super.initState();
_countNotify.addListener(updateCount);
}

void updateCount() {
setState(() {});
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Test: ${_countNotify.value}"),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value++,
child: const Icon(Icons.add),
),
);
}

@override
void dispose() {
super.dispose();
_countNotify.removeListener(updateCount);
}
}

这样我们就简化了不少的模板代码。


ValueListenableBuilder


我们从ChangeNotifier到ValueNotifier,逐步减少了模板代码的创建,但是依然还有很多问题,比如我们还是需要手动addListener、removeListener或者是dispose,同时,还需要使用setState来刷新页面,如果Context控制不好,很容易造成整个页面的刷新。因此,Flutter在它们的基础之上,又提供了ValueListenableBuilder来解决上面这些问题。


我们继续改造上面的例子。


class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<int> _countNotify = ValueNotifier(0);

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<int>(
valueListenable: _countNotify,
builder: (context, value, child) {
return Text('Value: $value');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value++,
child: const Icon(Icons.add),
),
);
}

@override
void dispose() {
super.dispose();
_countNotify.dispose();
}
}

可以发现,我们使用ValueListenableBuilder来根据ValueNotifier的改变而刷新Widget。这样不仅简化了代码模板,而且不再使用setState来进行页面刷新。


ValueListenableBuilder作为一个非常经典的Widget,在它的注释中,就有很多教程和示例。
image.png
再看它的源码。
image.png
这里需要接收3个参数,其中valueListenable用来接收ValueNotifier,builder用来构建Widget,而child,用来创建不依赖ValueNotifier构建的Widget(这是一个很经典的性能优化的例子,如果子构建成本高,并且不依赖于通知符的值,我们将使用它进行优化)。



这个优化方案非常经典,在Flutter的很多地方都有使用这个技巧,特别是动画这块的处理。通常来说ValueNotifier对应ValueListenableBuilder,Listenable、ChangeNotifier对应AnimatedBuilder。




自定义类型


在使用自定义类型时,例如一个包装类,那么当你改变它的某个属性值时,ValueListenableBuilder是不会刷新的,我们来看下面这个例子。


class Wrapper {
int age;
String name;

Wrapper({this.age = 0, this.name = ''});
}

class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<Wrapper> _countNotify = ValueNotifier(Wrapper());

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<Wrapper>(
valueListenable: _countNotify,
builder: (context, value, child) {
return Text('Value: ${value.age}');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value.age = _countNotify.value.age + 1,
child: const Icon(Icons.add),
),
);
}

@override
void dispose() {
super.dispose();
_countNotify.dispose();
}
}

这样的话,ValueListenableBuilder就失去作用了,其原因也很简单,ValueNotifier所监听的数据其实并未发生改变,实例的内存地址没发生改变,所以,直接创建一个新的对象,就可以触发更新了,就像下面这样。


onPressed: () => _countNotify.value = Wrapper(age: 10),


自定义类型局部刷新


上面这种自定义模型的刷新方法还是略显复杂了一点,每次更新的时候,都要copy一下数据来实现更新,实际上,ValueNotifier继承自ChangeNotifier,所以可以通过手动调用notifyListeners的方式来进行刷新,我们改造下上面的例子。


class WrapperNotifier extends ValueNotifier<Wrapper> {
WrapperNotifier(Wrapper value) : super(value);

void increment() {
value.age++;
notifyListeners();
}
}

// 调用处
_countNotify.increment();

通过这种方式,我们可以实现当模型内部变量更新时,局部进行刷新了。


欢迎大家关注我的公众号——【群英传】,专注于「Android」「Flutter」「Kotlin」
我的语雀知识库——http://www.yuque.com/xuyisheng


作者:xuyisheng
来源:juejin.cn/post/7381767811679502346
收起阅读 »

快速理解 并发量、吞吐量、日活、QPS、TPS、RPS、RT、PV、UV、DAU、GMV

并发与并行 并发:由于CPU数量或核心数量不够,多个任务并不一定是同时进行的,这些任务交替执行(分配不同的CPU时间片,进程或者线程的上下文切换),所以是伪并行。 并行:多个任务可以在同一时刻同时执行,通常需要多个或多核处理器,不需要上下文切换,真正的并行。...
继续阅读 »

并发与并行



  • 并发:由于CPU数量或核心数量不够,多个任务并不一定是同时进行的,这些任务交替执行(分配不同的CPU时间片,进程或者线程的上下文切换),所以是伪并行。

  • 并行:多个任务可以在同一时刻同时执行,通常需要多个或多核处理器,不需要上下文切换,真正的并行。


并发量(Concurrency)



  • 概念:并发或并行,是程序和运维本身要考虑的问题。而并发量,通常是不考虑程序并发或并行执行,只考虑一个服务端程序单位时间内同时可接受并响应多少个请求,通常以秒为单位,也可乘以86400,以天为单位。

  • 计算方法,通常通过一些压测工具,例如ApiPost压测,或者ab压测来统计,依ab为例:


Window系统:Apache下bin目录有个ab.exe
CentOS系统:yum -y install httpd-tools.x86_64

ab -c 并发数 -n 请求数 网址
ab -c 10 -n 150 127.0.0.1/ 表示对127.0.0.1这个地址,用10个并发一共请求了150次。而不是1500次,
Time taken for tests: 1.249 seconds,说明并发量为 150 / 1.249 ≈ 120 并发,表示系统最多可承载120个并发每秒。

吞吐量(Throughput)



  • 概念:吞吐量是指系统在单位时间能够处理多少个请求,TPS、QPS都是吞吐量的量化指标。
    相比于QPS这些具有清晰定义的书面用语,吞吐量偏向口语化。


日活



  • 概念:每日活跃用户的数量,通常偏向非技术指标用语,这个概念没有清晰的定义,销售运营嘴里的日活,可能是只有一个人1天访问100次,就叫做日活100,也可以说是日活1,中位数日活50,显然意义不大。


QPS(Query Per Second)



  • 概念:每秒查询次数,通常是对读操作的压测指标。服务器在一秒的时间内能处理多少量的请求。和并发量概念差不多,并发量高,就能应对更多的请求。

  • 计算方法,通常通过一些压测工具,例如ApiPost压测,或者ab压测来统计,依ab为例:


ab -c 10 -n 150 127.0.0.1/
其中返回一行数据:
Requests per second: 120.94 [#/sec] (mean)
表示该接口QPS在120左右。

TPS(Transactions Per Second)



  • 概念:每秒处理的事务数目,通常是对写操作的压测指标。这里的事务不是数据库事务,是指服务器接收到请求,再到处理完后响应的过程。TPS表示一秒事件能够完成几次这样的流程。


TPS对比QPS



  • QPS:偏向统计查询性能,一般不涉及数据写操作。

  • TPS:偏向统计写入性能,如插入、更新、删除等。


RPS(Request Per Second)



  • 概念:每秒请求数,和QPS、TPS概念差不多。没有过于清晰的定义,看你怎么用。


RT(Response Time)



  • 概念:响应时间间隔,是指用户发起请求,到接收到请求的时间间隔,越少越好,应当控制在0~150毫秒之间。


PV(Page view)



  • 概念:浏览次数统计,一般以天为单位。范围可以是单个页面,也可以是整个网站,一千个用户一天对该页面访问一万次,那该页面PV就是一万。


UV(Unique Visitor)



  • 概念:唯一访客数。时间单位通常是天,1万个用户一天访问该网站十万次,那么UV是一万。

  • 实现方案:已登录的用户可通过会话区分,未登录的用户可让客户端创建一个唯一标识符当做临时的token用于区分用户。


DAU(Daily Active Use)



  • 概念:日活跃用户数量,来衡量服务的用户粘性以及服务的衰退周期。统计方案各不相同,这要看对活跃的定义,访问一次算活跃,还是在线时长超10分钟算活跃,还是用户完成某项指标算活跃。


GMV(Gross Merchandise Volume)



  • 概念:单位时间内的成交总额。多用于电商行业,一般包含拍下未支付订单金额。


作者:小松聊PHP进阶
来源:juejin.cn/post/7400281441803403275
收起阅读 »

Canvas星空类特效

web
思路绘制单个星星在画布批量随机绘制星星添加星星移动动画页面resize处理Vanilla JavaScript实现初始化一个工程pnpm create vite@latest # 输入工程名后,类型选择Vanilla cd <工程目录> pnp...
继续阅读 »

star-sky.gif

思路

  1. 绘制单个星星
  2. 在画布批量随机绘制星星
  3. 添加星星移动动画
  4. 页面resize处理

Vanilla JavaScript实现

  1. 初始化一个工程
pnpm create vite@latest

# 输入工程名后,类型选择Vanilla

cd <工程目录> pnpm install pnpm dev # 运行本地服务
body {
background-color: black;
overflow: hidden;
}
"use strict";
import './style.css';

document.querySelector('#app').innerHTML = `

`
;
// 后续代码全部在main.js下添加即可
  1. 绘制单个星星

image.png

const hue = 220; // 颜色hue值可以根据自己喜好调整
// 离屏canvas不需要写入到静态html里,所以用createElement
const offscreenCanvas = document.createElement("canvas");
const offscreenCtx = offscreenCanvas.getContext("2d");
offscreenCanvas.width = 100;
offscreenCanvas.height = 100;
const half = offscreenCanvas.width / 2;
const middle = half;
// 设定径向渐变的范围,从画布中心到画布边缘
const gradient = offscreenCtx.createRadialGradient(
middle,
middle,
0,
middle,
middle,
half
);
// 添加多级颜色过渡,可以根据自己喜好调整
gradient.addColorStop(0.01, "#fff");
gradient.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`);
gradient.addColorStop(0.5, `hsl(${hue}, 64%, 6%)`);
gradient.addColorStop(1, "transparent");

// 基于渐变填充色,在画布中心为原点绘制一个圆形
offscreenCtx.fillStyle = gradient;
offscreenCtx.beginPath();
offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
offscreenCtx.fill();

参考链接:

hsl() - CSS:层叠样式表 | MDN

  1. 在画布批量绘制星星

其实要绘制星星,我们只需要在画布上基于离屏画布来在指定位置将离屏画布渲染成图片即可,但是批量绘制以及后续的动画需要我们能记录每颗星星的位置、状态和行驶轨迹,所以可以考虑创建一个星星的类。

// 声明存放星星数据的数组,以及最大星星数量
const stars = [];
const maxStars = 1000;

// 用于提供随机值,不用每次都Math.random()
const random = (min, max) => {
if (!max) {
max = min;
min = 0;
}
if (min > max) {
[min, max] = [max, min];
}
return Math.floor(Math.random() * (max - min + 1)) + min;
};
// 用于计算当前以画布为中心的环绕半径
const maxOrbit = (_w, _h) => {
const max = Math.max(_w, _h);
const diameter = Math.round(Math.sqrt(max * max + max * max));
return diameter / 2;
};

class Star {
constructor(_ctx, _w, _h) {
this.ctx = _ctx;
// 最大轨道半径
this.maxOrbitRadius = maxOrbit(_w, _h);
// 轨道半径
this.orbitRadius = random(this.maxOrbitRadius);
// 星星大小(半径)
this.radius = random(60, this.orbitRadius) / 12;
// 环绕轨道中心,即画布中心点
this.orbitX = _w / 2;
this.orbitY = _h / 2;
// 随机时间,用于动画
this.elapsedTime = random(0, maxStars);
// 移动速度
this.speed = random(this.orbitRadius) / 500000;
// 透明度
this.alpha = random(2, 10) / 10;
}
// 星星的绘制方法
draw() {
// 计算星星坐标[x, y],使用sin和cos函数使星星围绕轨道中心做圆周运动
const x = Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX;
const y = Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY;

// 基于随机数调整星星的透明度
const spark = Math.random();
if (spark < 0.5 && this.alpha > 0) {
this.alpha -= 0.05;
} else if (spark > 0.5 && this.alpha < 1) {
this.alpha += 0.05;
}

// 调整全局绘制透明度,使后续绘制都基于这个透明度绘制,也就是绘制当前星星
// 因为动画里会遍历每一个星星进行绘制,所以透明度会来回改变
this.ctx.globalAlpha = this.alpha;
// 在星星所在的位置基于离屏canvas绘制一张星星的图片
this.ctx.drawImage(offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
// 时间基于星星的移动速度递增,为下一帧绘制做准备
this.elapsedTime += this.speed;
}
}

获取当前画布,批量添加星星

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let w = canvas.width = window.innerWidth;
let h = canvas.height = window.innerHeight;

for (let i = 0; i < maxStars; i++) {
stars.push(new Star(ctx, w, h));
}
  1. 添加星星的移动动画
function animation() {
// 绘制一个矩形作为背景覆盖整个画布,'source-over'是用绘制的新图案覆盖原有图像
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.8;
ctx.fillStyle = `hsla(${hue} , 64%, 6%, 1)`;
ctx.fillRect(0, 0, w, h);
// 绘制星星,'lighter'可以使动画过程中重叠的星星有叠加效果
ctx.globalCompositeOperation = 'lighter';
stars.forEach(star => {
star.draw();
});
window.requestAnimationFrame(animation);
}
// 调用动画
animation();

这样星星就动起来了。

  1. 页面resize处理

其实只需要在resize事件触发时重新设定画布的大小即可

window.addEventListener('resize', () => {
w = canvas.width = window.innerWidth;
h = canvas.height = window.innerHeight;
});

但是有一个问题,就是星星的运行轨迹并没有按比例变化,所以需要添加两处变化

// 在Star类里添加一个update方法
class Star {
constructor(_ctx, _w, _h) {//...//}
//添加部分
update(_w, _h) {
// 计算当前的最大轨道半径和类之前保存的最大轨道半径的比例
const ratio = maxOrbit(_w, _h) / this.maxOrbitRadius;
// 因为每帧动画都会调用这个方法,但比例没变化时不需要按比例改变移动轨道,所以加个判断
if (ratio !== 1) {
// 重新计算最大轨道半径
this.maxOrbitRadius = maxOrbit(_w, _h);
// 按比例缩放轨道半径和星星的半径
this.orbitRadius = this.orbitRadius * ratio;
this.radius = this.radius * ratio;
// 重新设置轨道中心点
this.orbitX = _w / 2;
this.orbitY = _h / 2;
}
}

draw() {//...//}
}

// 在animation函数里调用update
function animation() {
// ...
stars.forEach(star => {
star.update(w, h);
star.draw();
});
// ...
}

React实现

react实现主要需要注意resize事件的处理,怎样避免重绘时对星星数据初始化,当前思路是使用多个useEffect

import React, { useEffect, useRef, useState } from 'react';

const HUE = 217;
const MAX_STARS = 1000;

const random = (min: number, max?: number) => {
if (!max) {
max = min;
min = 0;
}
if (min > max) {
[min, max] = [max, min];
}
return Math.floor(Math.random() * (max - min + 1)) + min;
};

const maxOrbit = (_w: number, _h: number) => {
const max = Math.max(_w, _h);
const diameter = Math.round(Math.sqrt(max * max + max * max));
return diameter / 2;
};

// 离屏canvas只需要执行一次,但是直接在函数外部使用document.createElement会出问题
const getOffscreenCanvas = () => {
const offscreenCanvas = document.createElement('canvas');
const offscreenCtx = offscreenCanvas.getContext('2d')!;
offscreenCanvas.width = 100;
offscreenCanvas.height = 100;
const half = offscreenCanvas.width / 2;
const middle = half;
const gradient = offscreenCtx.createRadialGradient(middle, middle, 0, middle, middle, half);
gradient.addColorStop(0.01, '#fff');
gradient.addColorStop(0.1, `hsl(${HUE}, 61%, 33%)`);
gradient.addColorStop(0.5, `hsl(${HUE}, 64%, 6%)`);
gradient.addColorStop(1, 'transparent');

offscreenCtx.fillStyle = gradient;
offscreenCtx.beginPath();
offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
offscreenCtx.fill();
return offscreenCanvas;
};

class OffscreenCanvas {
static instance: HTMLCanvasElement = getOffscreenCanvas();
}

class Star {
orbitRadius!: number;
maxOrbitRadius!: number;
radius!: number;
orbitX!: number;
orbitY!: number;
elapsedTime!: number;
speed!: number;
alpha!: number;
ratio = 1;
offscreenCanvas = OffscreenCanvas.instance;
constructor(
private ctx: CanvasRenderingContext2D,
private canvasSize: { w: number, h: number; },
) {
this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
this.orbitRadius = random(this.maxOrbitRadius);
this.radius = random(60, this.orbitRadius) / 12;
this.orbitX = this.canvasSize.w / 2;
this.orbitY = this.canvasSize.h / 2;
this.elapsedTime = random(0, MAX_STARS);
this.speed = random(this.orbitRadius) / 500000;
this.alpha = random(2, 10) / 10;
}

update(size: { w: number, h: number; }) {
this.canvasSize = size;
this.ratio = maxOrbit(this.canvasSize.w, this.canvasSize.h) / this.maxOrbitRadius;
if (this.ratio !== 1) {
this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
this.orbitRadius = this.orbitRadius * this.ratio;
this.radius = this.radius * this.ratio;
this.orbitX = this.canvasSize.w / 2;
this.orbitY = this.canvasSize.h / 2;
}
}

draw() {
const x = (Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX);
const y = (Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY);
const spark = Math.random();

if (spark < 0.5 && this.alpha > 0) {
this.alpha -= 0.05;
} else if (spark > 0.5 && this.alpha < 1) {
this.alpha += 0.05;
}

this.ctx.globalAlpha = this.alpha;
this.ctx.drawImage(this.offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
this.elapsedTime += this.speed;
}
}

const StarField = () => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const animationRef = useRefnull>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
const [initiated, setInitiated] = useState(false);
const [stars, setStars] = useState<Star[]>([]);

// 这里会在画布准备好之后初始化星星,理论上只会执行一次
useEffect(() => {
if (canvasRef.current && canvasSize.w !== 0 && canvasSize.h !== 0 && !initiated) {
const ctx = canvasRef.current!.getContext('2d')!;
const _stars = Array.from({ length: MAX_STARS }, () => new Star(ctx, canvasSize));
setStars(_stars);
setInitiated(true);
}
}, [canvasSize.w, canvasSize.h]);
// 这里用于处理resize事件,并重新设置画布的宽高
useEffect(() => {
if (canvasRef.current) {
const resizeHandler = () => {
const { clientWidth, clientHeight } = canvasRef.current!.parentElement!;
setCanvasSize({ w: clientWidth, h: clientHeight });
};
resizeHandler();
addEventListener('resize', resizeHandler);
return () => {
removeEventListener('resize', resizeHandler);
};
}
}, []);
// 这里用于渲染动画,每次画布有变化时都会触发,星星初始化完成时也会触发一次
useEffect(() => {
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d')!;
canvasRef.current!.width = canvasSize.w;
canvasRef.current!.height = canvasSize.h;
const animation = () => {
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.8;
ctx.fillStyle = `hsla(${HUE} , 64%, 6%, 1)`;
ctx.fillRect(0, 0, canvasSize.w, canvasSize.h);

ctx.globalCompositeOperation = 'lighter';
stars.forEach((star) => {
if (star) {
star.update(canvasSize);
star.draw();
}
});

animationRef.current = requestAnimationFrame(animation);
};

animation();
return () => {
cancelAnimationFrame(animationRef.current!);
};
}
}, [canvasSize.w, canvasSize.h, stars]);
return (
<canvas ref={canvasRef}>canvas>
);
};

export default StarField;


作者:oooooold2
来源:juejin.cn/post/7399983901811474484
收起阅读 »

1个demo带你入门canvas

web
canvas画布是前端一个比较重要的能力,在MDN上看到有关canvas的API时,是否会感到枯燥?今天老弟就给各位带来了1个还说得过去的demo,话不多说,咱们一起来尝尝咸淡。 一、小球跟随鼠标移动 先来欣赏一下这段视频: 从上图我们发现,小球跟着我们的鼠...
继续阅读 »

canvas画布是前端一个比较重要的能力,在MDN上看到有关canvas的API时,是否会感到枯燥?今天老弟就给各位带来了1个还说得过去的demo,话不多说,咱们一起来尝尝咸淡。


一、小球跟随鼠标移动


先来欣赏一下这段视频:



从上图我们发现,小球跟着我们的鼠标移动,并且鼠标点到的位置就是小球的中心点。想要实现这样的功能,我们可以将它抽象为下面图里的样子:


canvas2.png


是的,一个是画布(canvas)类,一个是小球(Ball)类。


canvas主要负责尺寸、执行绘画、事件监听。


Ball主要负责圆心坐标、半径、以及更新自己的位置。


接下来就是代码部分,我们先来完成canvas类的实现。


1.1、初始化canvas类属性


从上面的视频以及拆解的图里,我们会发现这个画布至少拥有以下几个属性:



  • width。画布的宽度

  • height。画布的高度

  • element。画布的标签元素

  • context。画布的渲染上下文

  • events。这个画布上的事件集合

  • setWidthAndHeight()。设置画布的大小

  • draw()。用于执行绘画的函数

  • addEventListener()。用于给canvas添加相应的监听函数


因此我们可以得出下面这段代码:


class Canvas {
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
}
}

1.2、设置canvas的大小


class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
}

在上面的代码里,我们手动实现了一个setWidthAndHeight这样的函数,并且当执行new操作的时候,自动这个函数,从而达到设置几何元素大小的作用。


1.3、绘画小球


我们的这个绘画功能应该只负责绘画,也就是说这个小球的位置坐标等信息应该通过传参的形式传入到我们的draw函数里


class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
// 绘画小球
draw(ball){
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
}

1.4、给canvas添加事件监听


根据我们的需求,小球随着鼠标移动,说明我们应该是在canvas标签上监听mouseover事件。我们再来思考一下,其实这个事件监听函数也应该保持纯粹。纯粹的意思就是,你不要在这个方法里去写业务逻辑。这个函数只负责添加相应的事件监听。


接下来我们实现一下这个addEventListener函数。


class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
// 画物体
draw(ball){
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
// 添加监听器(eventName:要监听的事件,eventCallback:事件对应的处理函数)
addEventListener(eventName, eventCallback){
let finalEventName = eventName.toLocaleLowerCase();
if (!this.events.filter(item => item === finalEventName)?.length > 0){
this.events.push(finalEventName);
}
this.element['on' + finalEventName] = (target) => {
eventCallback && eventCallback(target);
}
}
}

好啦,Canvas类的实现到这里先告一段落,我们来看看小球(Ball)类的实现。


1.5、Ball类的初始化


Ball这个类比较简单,4个属性+一个方法。


属性分别是:



  • centerX。小球的圆心横坐标X

  • centerY。小球的圆心纵坐标Y

  • background。小球的背景色

  • radius。小球的半径


方法是updateLocation函数。这个函数同样也是一个纯函数,只负责更新圆心坐标,更新的值也是由参数传递。


class Ball {
constructor(props){
this.centerX = props.centerX;
this.centerY = props.centerY;
this.radius = props.radius;
this.background = props.background || 'orange';
}

// 更新小球的地理位置
updateLocation(x, y){
this.centerX = x;
this.centerY = y;
}
}

1.6、添加推动器


说的直白点,就是我们现在只有2个class类,但是无法实现想要的效果,现在来想想什么时机去触发draw方法。


根据上面的视频,我们知道需要在canvas标签上添加鼠标over事件,然后在over事件里来实时获取小球的位置信息,最后再触发draw方法。


当鼠标离开canvas画布后,需要将画布上的内容清除,不留痕迹。


这样一来,我们不仅要实现类似桥梁(bridge)的功能,还需要在canvas类上实现“画布清空”的功能。



class Canvas {
// ...其他代码不动

// 清空画布的功能
clearCanvas(){
this.canvasContext.clearRect(0, 0, this.width, this.height);
}
}

// 画布对象
let canvas = new Canvas({
element: document.querySelector('canvas'),
width: 300,
height: 300
});

// 小球对象
let ball = new Ball({
centerX: 0,
centerY: 0,
radius: 30,
background: 'orange'
});

// 给canvas标签添加mouseover事件监听
canvas.addEventListener(
'mousemove',
(target) => {
canvasMouseOverEvent(target, canvas.element, ball);
}
)

canvas.addEventListener(
'mouseleave',
(target) => {
canvasMouseLeave(target, canvas);
}
)

// 鼠标滑动事件
function canvasMouseOverEvent(target, canvasElement, ball){
let pointX = target.offsetX;
let pointY = target.offsetY;
ball.updateLocation(pointX, pointY);
canvas.draw(ball);
}

// 鼠标离开事件
function canvasMouseLeave(target, canvasInstance){
canvasInstance.clearCanvas();
}

这样一来,我们便实现了大致的效果,如下:



我们似乎实现了当初的需求,只是为啥目前的表现跟“刮刮乐”差不多?因为下一次绘画的时候,没有将上次的绘画清空,所以才导致了现在的这个样子。


想要解决这个bug,只需在canvas类的draw方法里,加个清空功能就可以了。


class Canvas {
// 画物体
draw(ball){
this.clearCanvas();
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
}

如此一来,这个bug就算解决了。


二、最后


好啦,本期内容到这里就告一段落了,希望这个demo能够帮助你了解一下canvas的相关API,如果能够帮到你,属实荣幸,那么我们下期再见啦,拜拜~~


作者:小九九的爸爸
来源:juejin.cn/post/7388056383642206262
收起阅读 »

丸辣!BigDecimal又踩坑了

丸辣!BigDecimal又踩坑了 前言 小菜之前在国内的一家电商公司自研电商项目,在那个项目中是以人民币的分为最小单位使用Long来进行计算 现在小菜在跨境电商公司也接到了类似的计算需求,在小菜火速完成提交代码后,却被技术leader给叫过去臭骂了一顿 技术...
继续阅读 »

丸辣!BigDecimal又踩坑了


前言


小菜之前在国内的一家电商公司自研电商项目,在那个项目中是以人民币的分为最小单位使用Long来进行计算


现在小菜在跨境电商公司也接到了类似的计算需求,在小菜火速完成提交代码后,却被技术leader给叫过去臭骂了一顿


技术leader让小菜将类型改为BigDecimal,小菜苦思不得其解,于是下班后发奋图强,准备搞懂BigDecimal后再对代码进行修改


...


在 Java 中,浮点类型在进行运算时可能会产生精度丢失的问题


尤其是当它们表示非常大或非常小的数,或者需要进行高精度的金融计算时


为了解决这个问题,Java 提供了 BigDecimal


BigDecimal 使用各种字段来满足高精度计算,为了后续的描述,这里只需要记住两个字段


precision字段:存储数据十进制的位数,包括小数部分


scale字段:存储小数的位数


BigDecimal的使用方式再后续踩坑中进行描述,最终总结出BigDecimal的最佳实践


BigDecimal的坑


创建实例的坑


错误示例:


在BigDecimal有参构造使用浮点型,会导致精度丢失


BigDecimal d1 = new BigDecimal(6.66);

正确的使用方法应该是在有参构造中使用字符串,如果一定要有浮点数则可以使用BigDecimal.valueOf


private static void createInstance() {
//错误用法
BigDecimal d1 = new BigDecimal(6.66);

//正确用法
BigDecimal d2 = new BigDecimal("6.66");
BigDecimal d3 = BigDecimal.valueOf(6.66);

//6.660000000000000142108547152020037174224853515625
System.out.println(d1);
//6.66
System.out.println(d2);
//6.66
System.out.println(d3);
}

toString方法的坑


当数据量太大时,使用BigDecimal.valueOf的实例,使用toString方法时会采用科学计数法,导致结果异常


BigDecimal d2 = BigDecimal.valueOf(123456789012345678901234567890.12345678901234567890);
//1.2345678901234568E+29
System.out.println(d2);

如果要打印正常结果就要使用toPlainString,或者使用字符串进行构造


private static void toPlainString() {
BigDecimal d1 = new BigDecimal("123456789012345678901234567890.12345678901234567890");
BigDecimal d2 = BigDecimal.valueOf(123456789012345678901234567890.12345678901234567890);

//123456789012345678901234567890.12345678901234567890
System.out.println(d1);
//123456789012345678901234567890.12345678901234567890
System.out.println(d1.toPlainString());

//1.2345678901234568E+29
System.out.println(d2);
//123456789012345678901234567890.12345678901234567890
System.out.println(d2.toPlainString());
}

比较大小的坑


比较大小常用的方法有equalscompareTo


equals用于判断两个对象是否相等


compareTo比较两个对象大小,结果为0相等、1大于、-1小于


BigDecimal使用equals时,如果两数小数位数scale不相同,那么就会认为它们不相同,而compareTo则不会比较小数精度


private static void compare() {
BigDecimal d1 = BigDecimal.valueOf(1);
BigDecimal d2 = BigDecimal.valueOf(1.00);

// false
System.out.println(d1.equals(d2));
// 0
System.out.println(d1.compareTo(d2));
}

在BigDecimal的equals方法中能看到,小数位数scale不相等则返回false


public boolean equals(Object x) {
if (!(x instanceof BigDecimal))
return false;
BigDecimal xDec = (BigDecimal) x;
if (x == this)
return true;
//小数精度不相等 返回 false
if (scale != xDec.scale)
return false;
long s = this.intCompact;
long xs = xDec.intCompact;
if (s != INFLATED) {
if (xs == INFLATED)
xs = compactValFor(xDec.intVal);
return xs == s;
} else if (xs != INFLATED)
return xs == compactValFor(this.intVal);

return this.inflated().equals(xDec.inflated());
}

因此,BigDecimal比较时常用compareTo,如果要比较小数精度才使用equals


运算的坑


常见的运算包括加、减、乘、除,如果不了解原理的情况就使用会存在大量的坑


在运算得到结果后,小数位数可能与原始数据发生改变,加、减运算在这种情况下类似


当原始数据为1.00(2位小数位数)和5.555(3位小数位数)相加/减时,结果的小数位数变成3位


	private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);

//1.0
System.out.println(d1);
//5.555
System.out.println(d2);
//6.555
System.out.println(d1.add(d2));
//-4.555
System.out.println(d1.subtract(d2));
}

在加、减运算的源码中,会选择两数中小数位数(scale)最大的当作结果的小数位数(scale)


private static BigDecimal add(final long xs, int scale1, final long ys, int scale2) {
//用差值来判断使用哪个scale
long sdiff = (long) scale1 - scale2;
if (sdiff == 0) {
//scale相等时
return add(xs, ys, scale1);
} else if (sdiff < 0) {
int raise = checkScale(xs,-sdiff);
long scaledX = longMultiplyPowerTen(xs, raise);
if (scaledX != INFLATED) {
//scale2大时用scale2
return add(scaledX, ys, scale2);
} else {
BigInteger bigsum = bigMultiplyPowerTen(xs,raise).add(ys);
//scale2大时用scale2
return ((xs^ys)>=0) ? // same sign test
new BigDecimal(bigsum, INFLATED, scale2, 0)
: valueOf(bigsum, scale2, 0);
}
} else {

int raise = checkScale(ys,sdiff);
long scaledY = longMultiplyPowerTen(ys, raise);
if (scaledY != INFLATED) {
//scale1大用scale1
return add(xs, scaledY, scale1);
} else {
BigInteger bigsum = bigMultiplyPowerTen(ys,raise).add(xs);
//scale1大用scale1
return ((xs^ys)>=0) ?
new BigDecimal(bigsum, INFLATED, scale1, 0)
: valueOf(bigsum, scale1, 0);
}
}
}

再来看看乘法


原始数据还是1.00(2位小数位数)和5.555(3位小数位数),当进行乘法时得到结果的小数位数为5.5550(4位小数)


private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);

//1.0
System.out.println(d1);
//5.555
System.out.println(d2);
//5.5550
System.out.println(d1.multiply(d2));
}

实际上1.00会被优化成1.0(上面代码示例的结果也显示了),在进行乘法时会将scale进行相加,因此结果为1+3=4位


public BigDecimal multiply(BigDecimal multiplicand) {
//小数位数相加
int productScale = checkScale((long) scale + multiplicand.scale);

if (this.intCompact != INFLATED) {
if ((multiplicand.intCompact != INFLATED)) {
return multiply(this.intCompact, multiplicand.intCompact, productScale);
} else {
return multiply(this.intCompact, multiplicand.intVal, productScale);
}
} else {
if ((multiplicand.intCompact != INFLATED)) {
return multiply(multiplicand.intCompact, this.intVal, productScale);
} else {
return multiply(this.intVal, multiplicand.intVal, productScale);
}
}
}

而除法没有像前面所说的运算方法有规律性,因此使用除法时必须要指定保留小数位数以及舍入方式


进行除法时可以立马指定保留的小数位数和舍入方式(如代码d5)也可以除完再设置保留小数位数和舍入方式(如代码d3、d4)


private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);

BigDecimal d3 = d2.divide(d1);
BigDecimal d4 = d3.setScale(2, RoundingMode.HALF_UP);
BigDecimal d5 = d2.divide(d1, 2, RoundingMode.HALF_UP);
//5.555
System.out.println(d3);
//5.56
System.out.println(d4);
//5.56
System.out.println(d5);
}

RoundingMode枚举类提供各种各样的舍入方式,RoundingMode.HALF_UP是常用的四舍五入


除了除法必须指定小数位数和舍入方式外,建议其他运算也主动设置进行兜底,以防意外的情况出现


计算价格的坑


在电商系统中,在订单中会有购买商品的价格明细


比如用完优惠卷后总价为10.00,而买了三件商品,要计算每件商品花费的价格


这种情况下10除3是除不尽的,那我们该如何解决呢?


可以将除不尽的余数加到最后一件商品作为兜底


private static void priceCalc() {
//总价
BigDecimal total = BigDecimal.valueOf(10.00);
//商品数量
int num = 3;
BigDecimal count = BigDecimal.valueOf(num);
//每件商品价格
BigDecimal price = total.divide(count, 2, RoundingMode.HALF_UP);
//3.33
System.out.println(price);

//剩余的价格 加到最后一件商品 兜底
BigDecimal residue = total.subtract(price.multiply(count));
//最后一件价格
BigDecimal lastPrice = price.add(residue);
//3.34
System.out.println(lastPrice);
}

总结


普通的计算可以以最小金额作为计算单位并且用Long进行计算,而面对汇率、计算量大的场景可以采用BigDecimal作为计算单位


创建BigDecimal有两种常用的方式,字符串作为构造的参数以及浮点型作为静态方法valueOf��参数,后者在数据大/小的情况下toString方法会采用科学计数法,因此最好使用字符串作为构造器参数的方式


BigDecimal比较大小时,如果需要小数位数精度都相同就采用equals方法,忽略小数位数比较可以使用compareTo方法


BigDecimal进行运算时,加减运算会采用原始两个数据中精度最长的作为结果的精度,乘法运算则是将两个数据的精度相加得到结果的精度,而除法没有规律,必须指定小数位数和舍入模式,其他运算方式也建议主动设置小数位数和舍入模式进行兜底


当遇到商品平摊价格除不尽的情况时,可以将余数加到最后一件商品的价格进行兜底


最后(不要白嫖,一键三连求求拉~)


本篇文章被收入专栏 Java,感兴趣的同学可以持续关注喔


本篇文章笔记以及案例被收入 Gitee-CaiCaiJavaGithub-CaiCaiJava,除此之外还有更多Java进阶相关知识,感兴趣的同学可以starred持续关注喔~


有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~


关注菜菜,分享更多技术干货,公众号:菜菜的后端私房菜


作者:菜菜的后端私房菜
来源:juejin.cn/post/7400096469723643956
收起阅读 »

全面横评 6 大前端视频播放器 - Vue3 项目应该怎么选?

web
前言 最近,我在负责公司的音视频开发,使用的技术栈是 Vue3,技术方案采用的是基于第三方库二次封装播放器组件,来实现公司的业务定制需求。 市面上有很多第三方视频库,在最开始进行技术选型的时候,我也花了很多时间。 现在初步把我们公司的音视频组件开发完成了,我对...
继续阅读 »

前言


最近,我在负责公司的音视频开发,使用的技术栈是 Vue3,技术方案采用的是基于第三方库二次封装播放器组件,来实现公司的业务定制需求。


市面上有很多第三方视频库,在最开始进行技术选型的时候,我也花了很多时间。


现在初步把我们公司的音视频组件开发完成了,我对视频第三方库的技术选型进行一个总结分享,希望能帮助到正在进行音视频开发的你。


这次技术选型一共会对比 6 个第三方库:xgplayervideo.jsckplayer x3aliplayertcplayerflv.js


在对比每个第三库时,我都会给出文档地址、示例、Git 地址,便于大家自行对比。


我会给出收集到的每个第三方库的优缺点分析。对于部分重要的第三方库,将进行详细的优缺点分析;而对于涉及闭源等不可接受情况的库,优缺点分析将较为简略,不再深入对比。


因为我们技术团队的英文水平还达不到无障碍阅读英文文档的地步,所以第三库的文档是否支持中文,也是我们考虑的一个因素。


我们技术团队详细对比这些第三方库之后,最后选择的是 xgplayer


好了,接下来开始分析以上提到的 6 个音视频第三方库~


1. xgplayer(推荐)


这个库也是我们现在所采用的库,整体使用下来感觉很不错,文档很详细,支持自定义插件,社区活跃~

文档地址: h5player.bytedance.com/

示例地址: h5player.bytedance.com/examples/

Git 地址: github.com/bytedance/x…


优点



  1. 基本满足现有功能,自带截图 等功能

  2. 中文文档,有清晰详细的功能释义

  3. 可通过在线配置和生成对应功能代码参考,预览配置后的视频效果,开发体验好

  4. 项目积极维护

  5. 近期从 v2 版本升级到了 v3版本,优化了很多功能,开发体验更好

  6. 支持自定义插件,对开发业务定制需求很有用


缺点



  1. 直播需要浏览器支持Media Source Extensions

  2. PC Web端支持直接播放mp4视频,播放HLS、FLV、MPEG-DASH需要浏览器支持Media Source Extensions

  3. iOS Web端支持直接播放mp4和HLS,不支持播放FLV、MPEG-DASH(iOS webkitwebview 均不支持MediaSource,因此无法支持flv文件转封装播放)

  4. Android Web端支持直接播放mp4和HLS,播放FLV、MPEG-DASH需要浏览器支持Media Source Extensions

  5. 进度条拖动:拖动时,视频会一直播放当前拖动视频帧的声音,导致挺起来声音一卡一卡的,而且拖动一停止就立马开始播放视频

  6. 自动播放限制:对于大多数移动 webview 和浏览器,默认情况下会阻止有声自动播放。可以设置静音起播,达到自动播放的目的,不能保证一定能够自动播放,不同的app和浏览器配置不一样,表现不一样

  7. 打点功能没有提供图片的配置,可能需要二次开发或者用预览功能

  8. hls和flv不能同时添加,但是可以自己通过逻辑判断,去允许 hls 和 flv 同时播放

  9. Android 在网页端打开后截图很模糊


2. video.js(候选)


文档地址: videojs.com/

示例地址: videojs.com/advanced/?v…

Git 地址: github.com/videojs/vid…


优点



  1. 功能全面:提供暂停、播放等功能,基本满足项目所有功能需求

  2. 社区情况:社区活跃,项目持续维护

  3. 插件和主题丰富:可以根据需求进行定制和扩展

  4. 跨平台和浏览器兼容性:支持跨平台播放,适用于各种设备和操作系统

  5. 进度条拖动时,视频暂停,且能预览当前拖动所处位置,在放开拖动时,才开始播放视频,体验比较好


缺点



  1. 英文文档:上手学习播放器难度大,且后期维护成本高(搭建 demo 时,发现英文文档对开发有影响)

  2. 学习曲线:提供广泛功能,可能需要一定时间来理解其概念、API 等

  3. 不支持 flv 格式,但是可以通过安装 videojs-flvjs-es6 插件,同时安装 flv.js 库,来提供 flv 格式支持(但是 videojs-flvjs-es6 库的 star 太少,可能会出现其他问题)

  4. 没有自带截图功能,需要自己开发


3. ckplayer x3(候选)


文档地址: http://www.ckplayer.com/

示例地址: http://www.ckplayer.com/demo.html

Git 地址: gitee.com/niandeng/ck…


优点



  1. 功能丰富,且提供良好的示例

  2. 中文文档,文档相对比较丰富和专业

  3. 格式支持度较高,通过插件还可以播放 ts、mpd 等视频


缺点



  1. 社区支持不够丰富,如果以后有扩展功能需求,不便开发

  2. git 仓库 issue 响应慢,后续出问题,可能不便解决

  3. 文档的左侧菜单的交互不太友好,功能模块分级不够清晰,导致查找 API 不方便

  4. 没有直接提供视频列表(通道切换)的功能或插件

  5. 进度条拖动:拖动时,视频一直在播放,且没有显示当前所处拖动位置的预览画面,用户不知道当前拖动所处的具体位置,体验不佳


4. aliplayer(候选)


文档地址: player.alicdn.com/aliplayer/i…

示例地址: player.alicdn.com/aliplayer/p…

Git 地址: github.com/aliyunvideo…


优点



  1. 基本满足现有功能需求,自带截图、视频列表等功能

  2. 提供部分功能演示和在线配置

  3. 中文文档

  4. 支持4K视频播放,并且具备高分辨率和高比特率视频的优化能力

  5. 刷新和切换页面的 loading 时间比 xgplayer 短

  6. 播放器内部集成 flv 和 hls 格式,可以直接播放


缺点



  1. Web播放器H5模式在移动端不支持播放FLV视频,但可播 HLS(m3u8)

  2. Web播放器提供的音量调节方法iOS系统和部分Android系统会失效

  3. 自动播放限制:由于浏览器自身的限制,在Web播放器SDK中无法通过设置autoplay属性或者调用play()方法实现自动播放。只有视频静音才可以实现自动播放或者通过用户行为手动触发播放

  4. 截图功能限制:fiv 视频在Safari浏览器下不支持截图功能。即使启用截图按钮也不会出现

  5. 回放时,必须点击播放按钮那个图标才能播放,体验不佳。且鼠标悬停时,会显示视频控制栏,但点击控制栏,视频无对应功能响应,体验不佳

  6. 回放播放效果不统一:同样的设置,刷新页面时视频不会自动播放,切换页面再回来,视频会自动播放

  7. 有些高级功能需要商业授权:Web播放器SDK从2.14.0版本开始支持播放H.265编码协议的视频流,如需使用此功能,您需要先填写表单申请License授权

  8. 文档:文档目录混乱且杂糅其他播放器不需要的文档

  9. 进度条拖动:拖动时,视频会一直播放当前拖动视频帧的声音,导致听起来声音一卡一卡的,而且拖动一停止就立马开始播放视频


5. tcplayer(不推荐)


文档地址: cloud.tencent.com/document/pr…

示例地址: tcplayer.vcube.tencent.com/

Git 地址: 闭源,无 git 仓库


优点



  1. 断点续播:播放失败时自动重试,支持直播的自动重连功能


缺点



  1. 文档不丰富,示例功能不多

  2. 闭源项目,出现问题不好解决

  3. 内置的功能和插件相对阿里云和 CK 较少

  4. web 端截图功能不支持


6. flv.js(不推荐)


文档地址: github.com/bilibili/fl…

示例地址: bilibili.github.io/flv.js/demo…

Git 地址: github.com/bilibili/fl…


优点



  1. 由于浏览器对原生Video标签采用了硬件加速,性能很好,支持高清


缺点



  1. 文档:缺乏详细功能说明文档,只有 md 英文文档,文档阅读不方便

  2. 项目很久未更新,原作已离开哔站,虽已开源,但后期应该不会有啥版本升级和优化

  3. 播放 flv 格式需要依赖 Media Source Extensions,但目前所有 iOS 和 Android4.4.4 以下的浏览器都不支持


结语


以上是我对音视频第三方库进行技术选型对比的一个总结。如果有更好的见解或者其他补充,欢迎在评论区留言或者私聊我进行沟通。


作者:爱听书的程序员阿超
来源:juejin.cn/post/7359083412386807818
收起阅读 »

润开鸿“龙芯+OpenHarmony”开发平台 DAYU431先锋派新品全面开售

近日,江苏润开鸿数字科技有限公司(以下简称“润开鸿”)基于全新龙芯 2K0300 芯片平台推出重磅新品润开鸿 HH-SCDAYU431 先锋派开发平台正式上市,成为润开鸿 DAYU 系列产品中符合 OpenHarmony 生态兼容性标准的第三款龙芯芯片平台产品...
继续阅读 »

近日,江苏润开鸿数字科技有限公司(以下简称“润开鸿”)基于全新龙芯 2K0300 芯片平台推出重磅新品润开鸿 HH-SCDAYU431 先锋派开发平台正式上市,成为润开鸿 DAYU 系列产品中符合 OpenHarmony 生态兼容性标准的第三款龙芯芯片平台产品。当前,该新品已于淘宝“润开鸿企业店”上线,关注“龙芯+OpenHarmony”方案设计与应用的工程师、开发者们可即刻前往了解。

作为 OpenHarmony 项目群初始成员单位、A类捐赠人、核心共建单位,以及最早参与基于“龙芯+OpenHarmony”开发与适配的 OpenHarmony 操作系统发行版厂商,润开鸿本次推出 HH-SCDAYU431 先锋派开发平台,搭载全新龙芯 2K0300 芯片平台,符合 OpenHarmony 生态兼容性标准,旨在助力工程师、开发者们掌握“龙芯+OpenHarmony”适配先机,成为国产自主嵌入式开发的“先锋派”。

龙芯 2K0300 先锋派模组/开发 OpenHarmony 兼容性证书

润开鸿 DAYU431 先锋派开发平台

基于 LoongArch 架构64位 SoC 处理器 2K0300 设计的单板方案,支持 OpenHarmony 小型系统,板卡尺寸为(85mm x 56mm),兼容树莓派 4B 尺寸大小、定位孔及 40 PIN GPIO 定义。板卡接口资源丰富,外设生态扩展方便,支持图形 GUI 开发设计,资料配套齐全。板卡采用全表贴化设计,核心元器件均可采用国产器件替换,具有自主、安全、稳定、可靠、实用性强等特点,可广泛用于工业自动化控制、工业网关,物联网数采、能源电力、智慧水务、轨道交通、教学教具等应用领域的方案学习评估和技术预研。

产品亮点

●高性能低功耗处理器

龙芯 2K0300 处理器基于 LA264 处理器核,采用高集成度设计,满足在低能耗条件下进行高效处理。

●外设生态扩展方便

板卡兼容树莓派4B,可直接复用常见“派”配件模块和开源生态系统,有效降低拥有和使用成本,兼具易学性和可玩性。

●接口丰富

集成网络、LCD、USB、TF卡座、Wi-Fi、音频、ADC、JTAG等接口,扩展能力强,支持高效搭建方案原型和应用开发创新。

●国产自主,安全可控

采用国产龙芯 2K0300 处理器,元器件采用国产方案,板卡国产化率高,具备教育、工业控制等领域推广优势。

●生态兼容性强

支持 OpenHarmony 小型系统,符合生态兼容性标准;支持C/C++/Python等主流编程语言;支持QT、LVGL等多种图形(GUI)框架。

●配套资料齐全

具备完善的产品手册和参考资料。

技术参数

值得一提的是,润开鸿母公司江苏润和软件股份有限公司(以下简称“润和软件”)与广东龙芯中科电子科技有限公司(以下简称“广东龙芯”)的合作最早可以追溯到2022年4月,双方结合各自优势能力、联合多家科技企业共同发起成立了 OpenHarmony LoongArch SIG 组,旨在共建基于 LoongArch 架构平台的 OpenHarmony 国产自主生态及全栈式解决方案。自2022年初开始主导推动 LoongArch 架构在 OpenHarmony 中的适配,润开鸿不断引领技术路径并产出丰富成果,截至目前,已推出 HH-SCDAYU401 (基于龙芯 2K0500 芯片)、HH-SCDAYU410(基于龙芯 2K1000LA 芯片)以及近期上市的 HH-SCDAYU430 蜂鸟开发平台、HH-SCDAYU431 先锋派开发平台(均基于全新龙芯 2K0300 芯片)两款新品;商用设备方面则已成功推出一款龙芯交通控制器设备(HH-SCDAYU410A),并且也已通过 OpenHarmony 兼容性测评。

经过近几年的合作和积累,在适配兼容 OpenHarmony 设备领域,润开鸿是拥有龙芯产品最多、芯片系列最全和经验最丰富的 OpenHarmony 操作系统发行版厂商。未来,润开鸿将持续携手广东龙芯基于“龙芯+OpenHarmony”共同打造全国产化解决方案,在行业场景落地等方面不断加深合作,同时联合更多行业伙伴面向重点产业领域提供有力的技术支撑与使能服务,共同助力国产基础软件与数字经济产业加速形成新质生产力。

收起阅读 »

为什么我建议Flutter中通过构造参数给页面传递信息

哈喽,我是老刘 前段时间有人问我这个问题碰到没有:Flutter - 升级3.19之后页面多次rebuild? 说实话我们没有碰到这个问题 我先来简单解释一下这个问题,本质上是因为使用了 InheritedWidget 通过InheritedWidget向子树...
继续阅读 »

哈喽,我是老刘


前段时间有人问我这个问题碰到没有:
Flutter - 升级3.19之后页面多次rebuild?

说实话我们没有碰到这个问题


我先来简单解释一下这个问题,本质上是因为使用了 InheritedWidget


通过InheritedWidget向子树传递数据


InheritedWidget可以向其子树中的所有Widget提供数据。这使得无关的Widget能方便地获取同一个InheritedWidget提供的数据,实现Widget树中不直接相关Widget之间的数据共享。

Flutter SDK 中正是通过 InheritedWidget 来共享应用主题(Theme)和 Locale(当前语言环境)信息的。

其使用方法如下

实现一个InheritedWidget


class MyInheritedWidget extends InheritedWidget {  // 继承InheritedWidget
final int data;
MyInheritedWidget({required this.data, required Widget child}) : super(child: child);
@override
// 这个方法定义了当数据发送变化时是否通知子树中的子Widget。
// 它返回一个布尔值,true表示通知子Widget,false表示不通知。
bool updateShouldNotify(MyInheritedWidget oldWidget) {
return oldWidget.data != data;
}
// 子Widget可以通过调用MyInheritedWidget.of()静态方法来获取MyInheritedWidget实例,并获取其提供的数据。
static MyInheritedWidget of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType()!;
}
}


获取InheritedWidget中的数据


class MyText extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text(
'${MyInheritedWidget.of(context).data}',
style: Theme.of(context).textTheme.headline4,
);
}
}


当InheritedWidget中的数据发生变化时,就会通知所有通过InheritedWidget.of()方法注册了关注数据变化的成员。

这时这些子树中的组件就会重绘。


其实前面文章中的问题就是当你使用ModalRoute.of(context)方法获取页面路由的参数时,其实也就是向一个全局级别的InheritedWidget节点注册了关注其变化。

而当这个全局的路由节点的行为发生变化后(页面退栈通知数据变化),就会出现原先没有的重绘现象出现了。


为什么我们的代码没有这个问题


我们在传递页面参数时其实是没有使用ModalRoute.of(context)的方式获取页面参数的。

我们使用的是页面类的构造参数。

举个例子,比如打开一个商品详情页,需要传递商品id作为页面参数。

代码如下


class ProductDetailPage extends StatefulWidget {
final String productId;

const ProductDetailPage({required this.productId});

@override
_ProductDetailPageState createState() => _ProductDetailPageState();
}

class _ProductDetailPageState extends State<ProductDetailPage> {
@override
void initState() {
super.initState();
// 使用productId获取商品详情
}

@override
Widget build(BuildContext context) {
// 根据productId构建UI
return Scaffold(
// ...
);
}
}


定义路由可以使用动态生成路由的方式:


MaterialApp(
onGenerateRoute: (RouteSettings settings) {
// 通过settings.name可以获取传入的路由名称,并据此返回不同的路由
final productId = settings.arguments['id'] as String;
return MaterialPageRoute(
builder: (context) => ProductDetailPage(productId: productId),
);
}
)


那为什么我们要选择这种方式传递页面参数,而不是ModalRoute.of(context)的方式呢?

这其实是一种本能,一种下意识的行为。


两个思维习惯


1、减少不可控因素


老刘写了十多年的代码了,光Flutter就写了快6年。

这么多年的实战形成的习惯就是对不可控的外部依赖心怀警惕。

InheritedWidget就是一种很典型的场景。

如果是自己写的InheritedWidget还好,但如果是外部的,比如系统SDK的。

那么你怎么保证它通知的数据变化时你想要的呢?

这次的问题不就是很典型的例子吗。

远隔千里之外的人修改了几行代码,就对你的App的行为造成了影响。


2、模块化思维


也许你觉得写一个页面就是一个页面。

但是在我看来,很有可能某一天它就是某个页面的一个组件。

假设有一天你的产品要适配pad端

那么很有可能商品列表页和商品详情页会合并成一个页面:左边是列表右边是详情。

这时候原先独立的详情页就是页面的一个组件了。


image.png


这时候是不是通过构造参数传递商品id会合理很多?


总结


我们从一个实际的bug出发,解释了为什么建议大家通过构造参数进行页面传参。

进而引出了关于日常编码中的一些很具体的思维习惯。

总之很多时候最简单直接的用法可能也是最好的选择。


作者:程序员老刘
来源:juejin.cn/post/7394823316585168933
收起阅读 »

Flutter-实现悬浮分组列表

在本篇博客中,我们将介绍如何使用 Flutter 实现一个带有分组列表的应用程序。我们将通过 CustomScrollView 和 Sliver 组件来实现该功能。 需求 我们需要实现一个分组列表,分组包含固定的标题和若干个列表项。具体分组如下: 水果 动物...
继续阅读 »

在本篇博客中,我们将介绍如何使用 Flutter 实现一个带有分组列表的应用程序。我们将通过 CustomScrollViewSliver 组件来实现该功能。


需求


我们需要实现一个分组列表,分组包含固定的标题和若干个列表项。具体分组如下:



  • 水果

  • 动物

  • 职业

  • 菜谱


每个分组包含若干个项目,例如水果组包含苹果、香蕉等。


效果



实现思路



  1. 定义数据模型:创建 ItemBean 类来表示每个分组的数据。

  2. 构建主页面:使用 CustomScrollViewSliver 组件构建主页面,其中包含多个分组。

  3. 实现固定标题:通过自定义 SliverPersistentHeaderDelegate 实现固定标题。


实现代码


以下是实现代码:


import 'package:flutter/material.dart';

/// 数据源
/// https://github.com/yixiaolunhui/flutter_xy
class ItemBean {
final String groupName;
final List<String> items;

const ItemBean({required this.groupName, this.items = const []});

static List<ItemBean> get groupListData => const [
ItemBean(groupName: '水果', items: [
'苹果', '香蕉', '橙子', '葡萄', '芒果', '梨', '桃子', '草莓', '西瓜', '柠檬',
'菠萝', '樱桃', '蓝莓', '猕猴桃', '李子', '柿子', '杏', '杨梅', '石榴', '木瓜'
]),
ItemBean(groupName: '动物', items: [
'狗', '猫', '狮子', '老虎', '大象', '熊', '鹿', '狼', '狐狸', '猴子',
'企鹅', '熊猫', '袋鼠', '海豚', '鲨鱼', '斑马', '长颈鹿', '鳄鱼', '孔雀', '乌龟'
]),
ItemBean(groupName: '职业', items: [
'医生', '护士', '教师', '工程师', '程序员', '律师', '会计', '警察', '消防员', '厨师',
'司机', '飞行员', '科学家', '记者', '设计师', '作家', '演员', '音乐家', '画家', '摄影师'
]),
ItemBean(groupName: '菜谱', items: [
'红烧肉', '糖醋排骨', '宫保鸡丁', '麻婆豆腐', '鱼香肉丝', '酸辣汤', '蒜蓉菠菜', '回锅肉', '水煮鱼', '烤鸭',
'蛋炒饭', '蚝油生菜', '红烧茄子', '西红柿炒鸡蛋', '油焖大虾', '香菇鸡汤', '酸菜鱼', '麻辣香锅', '铁板牛肉', '干煸四季豆'
]),
];
}

/// 分组列表
class Gr0upListPage extends StatelessWidget {
const Gr0upListPage({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('分组列表')),
body: CustomScrollView(
slivers: ItemBean.groupListData.map(_buildGr0up).toList(),
),
);
}

Widget _buildGr0up(ItemBean itemBean) {
return SliverMainAxisGr0up(
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: HeaderDelegate(itemBean.groupName),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, index) => _buildItemByUser(itemBean.items[index]),
childCount: itemBean.items.length,
),
),
],
);
}

Widget _buildItemByUser(String item) {
return Container(
alignment: Alignment.center,
height: 50,
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(left: 20, right: 10.0),
child: FlutterLogo(size: 30),
),
Text(
item,
style: const TextStyle(fontSize: 16),
),
],
),
);
}
}

class HeaderDelegate extends SliverPersistentHeaderDelegate {
final String title;

const HeaderDelegate(this.title);

@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
alignment: Alignment.centerLeft,
color: Colors.grey,
padding: const EdgeInsets.only(left: 20),
height: 40,
child: Text(title, style: const TextStyle(fontSize: 16)),
);
}

@override
double get maxExtent => 40;

@override
double get minExtent => 40;

@override
bool shouldRebuild(covariant HeaderDelegate oldDelegate) {
return title != oldDelegate.title;
}
}

通过以上代码,我们实现了一个简单的 Flutter 分组列表应用。每个分组都有固定的标题,点击标题可以展开或收起组内的项目。希望这篇博客对你有所帮助!
详情 :github.com/yixiaolunhui/flutter_xy


作者:一笑轮回
来源:juejin.cn/post/7388091090350702618
收起阅读 »

受够了useState的逻辑分散?来,试试用reducer聚合逻辑

web
useState的缺点 经常写react 的同学都知道,useState 是 React 中的一个 Hook,可以在函数组件中管理组件的状态,并在状态更新时重新渲染组件。 这东西虽然简单好用,但有一个致命缺点:当组件有非常多的状态更新逻辑时,事件处理会非常分散...
继续阅读 »

useState的缺点


经常写react 的同学都知道,useState 是 React 中的一个 Hook,可以在函数组件中管理组件的状态,并在状态更新时重新渲染组件。


这东西虽然简单好用,但有一个致命缺点:当组件有非常多的状态更新逻辑时,事件处理会非常分散,维护起来很头疼!


比如,一个简单的记事本功能



我需要通过三个不同的事件处理程序来实现任务的添加、删除和修改:


const [tasks, setTasks] = useState([  
{ id: 1, title: '去贝加尔湖旅行', completed: false },
{ id: 2, title: '去烟台海边度假', completed: false },
{ id: 3, title: '再去一次厦门看海', completed: false },
]);

// 添加
const addTask = (taskTitle) => {
const newId = tasks.length + 1;
const newTask = { id: newId, title: taskTitle, completed: false };
setTasks([...tasks, newTask]);
};

// 删除
const deleteTask = (taskId) => {
setTasks(tasks.filter(task => task.id !== taskId));
};

// 编辑
const toggleTaskCompletion = (taskId) => {
setTasks(tasks.map(task =>
task.id === taskId ? { ...task, completed: !task.completed } : task
));
};

上面的代码中,每个事件处理程序都通过 setTasks 来更新状态。随着这个组件功能的,其状态逻辑也会越来越多。为了降低这种复杂度,并让所有逻辑都可以存放在一个易于理解的地方,我们可以将这些状态逻辑移到组件之外的一个称为 reducer 的函数中。


使用 reducer 整合状态逻辑


在学习reducer之前,我们先看看使用reducer整合后的代码


import React, { useReducer, useState } from 'react';

// 定义 reducer 函数
const taskReducer = (state, action) => {
switch (action.type) {
case 'ADD_TASK':
const newId = state.length + 1;
return [...state, { id: newId, title: action.payload, completed: false }];
case 'DELETE_TASK':
return state.filter(task => task.id !== action.payload);
case 'TOGGLE_TASK':
return state.map(task =>
task.id === action.payload ? { ...task, completed: !task.completed } : task
);
default:
return state;
}
};

function TaskList() {
const [tasks, dispatch] = useReducer(taskReducer, [
{ id: 1, title: '去贝加尔湖旅行', completed: false },
{ id: 2, title: '去烟台海边度假', completed: false },
{ id: 3, title: '再去一次厦门看海', completed: false },
]);

const [newTaskTitle, setNewTaskTitle] = useState('');

const handleAddTask = () => {
if (newTaskTitle.trim()) {
dispatch({ type: 'ADD_TASK', payload: newTaskTitle });
setNewTaskTitle('');
}
};

return (
<div>
<h2>快乐就是哈哈哈的记事本</h2>
<div>
<input
type="text"
placeholder="添加新任务"
value={newTaskTitle}
onChange={(e) =>
setNewTaskTitle(e.target.value)}
/>
<button onClick={handleAddTask} style={{ marginLeft: '10px' }}>添加</button>
</div>
<div>
{tasks.map(task => (
<div key={task.id} style={{ marginTop: '10px' }}>
<input
type="checkbox"
checked={task.completed}
onChange={() =>
dispatch({ type: 'TOGGLE_TASK', payload: task.id })}
/>
{task.title}
<button onClick={() => { /* Add edit functionality here */ }} style={{ marginLeft: '10px' }}>编辑</button>
<button onClick={() => dispatch({ type: 'DELETE_TASK', payload: task.id })} style={{ marginLeft: '10px' }}>删除</button>
</div>
))}
</div>
</div>

);
}

export default TaskList;

能够看出,所有的逻辑被整合到taskReducer这个函数中了,我们的逻辑聚合度很高,非常好维护!


useReducer的基本语法


const [state, dispatch] = useReducer(reducer, initialArg, init?)

在组件的顶层作用域调用 useReducer 以创建一个用于管理状态的 reducer。


import { useReducer } from 'react';

function reducer(state, action) {
// ...
}

function MyComponent() {
const [state, dispatch] = useReducer(reducer, { age: 42 });
// ...

参数



  • reducer:用于更新 state 的纯函数。参数为 state 和 action,返回值是更新后的 state。state 与 action 可以是任意合法值。

  • initialArg:用于初始化 state 的任意值。初始值的计算逻辑取决于接下来的 init 参数。

  • 可选参数 init:用于计算初始值的函数。如果存在,使用 init(initialArg) 的执行结果作为初始值,否则使用 initialArg


返回值


useReducer 返回一个由两个值组成的数组:



  1. 当前的 state。初次渲染时,它是 init(initialArg)initialArg (如果没有 init 函数)。

  2. dispatch函数。用于更新 state 并触发组件的重新渲染。


dispatch 函数


dispatch 函数可以用来更新 state的值 并触发组件的重新渲染,它的用法其实和vue的store,react的状态管理库非常相似!



dispacth可以有很多,通过dispacth可以发送数据给reducer函数,函数内部,我们通过action可以拿到所有dispatch发送的数据,然后进行逻辑判断,更改state的值。


通常来说 action 是一个对象,其中 type 属性标识类型,其它属性携带额外信息。


代码解读


熟悉了它的语法后,我们的整合逻辑就非常好理解了。我们简化下逻辑:


import React, { useReducer, useState } from 'react';

// 定义 reducer 函数
const taskReducer = (state, action) => {
switch (action.type) {
// 根据不同逻辑,返回一个新的state的值
default:
return state;
}
};

function TaskList() {
const [tasks, dispatch] = useReducer(taskReducer, [
{ id: 1, title: '去贝加尔湖旅行', completed: false },
{ id: 2, title: '去烟台海边度假', completed: false },
{ id: 3, title: '再去一次厦门看海', completed: false },
]);
// 通过dispatch发送数据给taskReducer
return (
<div>
<h2>快乐就是哈哈哈的记事本</h2>
<div key={task.id} style={{ marginTop: '10px' }}>
<input
type="checkbox"
checked={task.completed}
onChange={() =>
dispatch({ type: 'TOGGLE_TASK', payload: task.id })}
/>
<button onClick={() => dispatch({ type: 'DELETE_TASK', payload: task.id })} style={{ marginLeft: '10px' }}>删除</button>
</div>
</div>

);
}

export default TaskList;

useReducer的性能优化


我们先看看下面的代码


function createInitialState(username) {
// ...
// 生成初始值的一些逻辑
}

function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, createInitialState(username));
// ...

createInitialState方法用于生成初始值,但是在每一次渲染的时候都会被调用,如果创建了比较大的数组或计算是比较浪费性能的!


我们可以通过给 useReducer 的第三个参数传入 初始化函数 来解决这个问题:


function createInitialState(username) {
// ...
}

function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, username, createInitialState);
// ...

需要注意的是你传入的参数是 createInitialState 这个 函数自身,而不是执行 createInitialState() 后的返回值


如果createInitialState可以直接计算出初始值,不需要默认的username,上面的代码可以进一步优化


function createInitialState() {
// ...
}

function TodoList() {
const [state, dispatch] = useReducer(reducer, null, createInitialState);
// ...

作者:快乐就是哈哈哈
来源:juejin.cn/post/7399496845277151242
收起阅读 »

才4W条数据页面就崩溃了

web
写过地图需求的同学应该都遇到过地图加载大量点(4W多个点)导致页面十分卡顿的问题吧。 最近项目上线验收,现场直接卡崩溃了,其实在公司还好,因为公司的电脑配置还算可以,没有出现过崩溃的现象(但是也很卡,本来也想偷下懒)。崩溃了怎么办啊(我感觉我更崩溃呢,天天加班...
继续阅读 »

写过地图需求的同学应该都遇到过地图加载大量点(4W多个点)导致页面十分卡顿的问题吧。


最近项目上线验收,现场直接卡崩溃了,其实在公司还好,因为公司的电脑配置还算可以,没有出现过崩溃的现象(但是也很卡,本来也想偷下懒)。崩溃了怎么办啊(我感觉我更崩溃呢,天天加班赶需求哪有时间做优化的啊)。


4F4D1E5D.png


原因:用户想要加载所有的点还不做聚合,而且每个点都要做动态扩散效果,还要实时刷新地图数据。


哎,先说我的解决方案吧。



  1. 取消点的动态扩散效果

  2. 图层层级显示图标点才会更新

  3. 只显示可视范围内的点

  4. 用户操作过程中不更新图层


第一点,我必须拿着我的数据让产品经理去给客户说有动效内存占用在600M到1200M跳动,动效是1s一个循环。那么每次执行动效就会让内存飚到1200M,然后接下来浏览器会回收之前渲染的内存,内存又降至600M。我就说这个实在没法优化只能去掉(我没有试加载动态图片会不会好点,但想来也差不多)。


第二点,我先说明图层的加载方案,当图层层级小于14时就加载全部点否则加载聚合点(what fuck)。客户就是这么牛,一般不应该是反着来吗。。那必须滴,这么多点客户也看不出点的位置更新,看到了也不知道是那个更新了。所以,出于性能考虑给出的方案是:当图层层级小于14的时候就不更新点。那么多点一起更新


75c5e511cde86257cb8afdde8bfef95e_u=4144058492,2792071934&fm=253&fmt=auto&app=138&f=JPEG_w=440&h=390.webp


第三点,前端是首次加载的时候把所有的数据都缓存起来的,由服务端推送更新消息,前端收到消息就维护缓存的数据并做相应的更新逻辑(在线离线/GIS等),会维护一个更新队列,如果数据太大的时候就分次更新。好的,说了那么多废话是不是想水文啊


739fa618a8025ab12c7ead350271d4f0_u=3416052748,1393281168&fm=253&fmt=auto&app=138&f=JPEG_w=440&h=350.webp


首先,每次更新(用户缩放和拖拽地图与推送)之前需要先拿到当前地图的四个角经纬度,然后调用Turf.js库提供的# pointsWithinPolygon方法:


const searchWithin = Turf.multiPolygon([[东],[南],[西],[北]]);
const ptsWithin = Turf.pointsWithinPolygon(Turf.points([...points]), searchWithin);

拿到当前可视范围内的点,再将可视范围内的点渲染到地图上。


第四点,当我开开心心把代码提交上去后,过了一会,我的前端同事给我说感觉页面还是很卡啊(0.0)。我表示不信,然后实际操作了一下,虽然上面的减少点的操作和减少点的数量让浏览器内存占用降了下来页面也确实不卡了,但是当我去拖动地图的时候发现问题了,怎么感觉拖着拖着的地图有规律的卡。怎么回事呢,再梳理下我明白了,之前的地图刷新时间是10s由于客户觉得刷新太慢了,索性就改成了3s,这一改一个不吱声,3s那不是很大概率当用户正在操作地图的时候地图重新渲染了所以感觉卡。知道问题就好办,判断用户当前是否在操作地图,movestart事件时表示用户开始操作地图,moveend事件表示用户结束操作。那就等用户操作地图结束后再更新地图,上手感受了一下一点也不卡了,搞定。


创作不易求,如果你看到这里还请您点赞收藏


02db46e7aba740fde614ed12ca2d902d_u=4047343355,1704049767&fm=253&fmt=auto&app=138&f=JPEG_w=480&h=360.webp


作者:嗨皮儿
来源:juejin.cn/post/7361973121790656562
收起阅读 »

优秀的程序员都有的十条特征,你中了几条?

之前的文章给大家分享的都是DevOps、自动化测试、新技术趋势等前沿知识和技术,实际上目前能完全掌握这些新技术的开发、测试人员都是少数,毕竟大多是人还是专注于自身工作,用于提升、学习新技术的时间较少,而很多新趋势也并未成熟应用到行业。因此,不必为此焦虑,极速变...
继续阅读 »

之前的文章给大家分享的都是DevOps、自动化测试、新技术趋势等前沿知识和技术,实际上目前能完全掌握这些新技术的开发、测试人员都是少数,毕竟大多是人还是专注于自身工作,用于提升、学习新技术的时间较少,而很多新趋势也并未成熟应用到行业。因此,不必为此焦虑,极速变化中总有一些不变,坚守那些基础的不变的能力,并以积极的心态拥抱变化,才是持续而稳定的成长路径。本期分享一些不依赖于新技术、但作为程序员都可遵循的原则,请根据自身情况取舍、实施。


一、及时更新任务清单


当要实现一个功能点时,最好将较大的任务分割成较小且更清晰的任务,这些任务是相对独立的逻辑单元,可以单独进行测试。列一张这样可完成的较小任务的清单,并在完成之后勾选、更新。这样会形成自我激励,并促使自己去不断完成更多的小任务。
优秀程序员-1


目前主流项目管理软件中,往往内置任务分解和更新功能。如在禅道项目管理软件内,开发负责人进行系统分析,拆解成相对独立的任务并指派给个人,而开发人员可以在自己的页面清晰地看到任务数量及剩余工时,完成后进度将同步更新,这种持续的正向反馈会带来极大的成就感。


二、遵循适当的版本控制


通过创建开发、特性、主分支和设置适当的访问权限来遵循适当的版本控制策略。无论何时开始编码,都要确保先获取代码库的最新版本后再开始。在逻辑部分或功能完成后继续提交/推送代码,不要让代码库长时间处于未提交状态。在将代码提交给版本控制之前,始终在本地机器上测试代码。无论变更多么细微,在输入代码时都要检查修改文件的差异,这将帮助追溯意料之外的变更,并有效避免不必要的Bug。


三、持续重构


代码重构是在不改变源代码的功能行为的情况下改变源代码的过程,目的是通过提高代码的可扩展性、降低代码的复杂度,以此来提高代码的可读性和可维护性。未能执行重构可能会导致技术债务的累积,开发人员会在之后的时间里对这些技术债务付出代价。要知道,没有任何一个开发人员愿意处于这种境况中,他们常常拒绝接触已经工作了很长时间的代码。当需要增强现有特性时,问题就出现了。


如果代码的形式不适合进行简易扩展,那么它将是开发人员的地狱。因此,为了避免出现这种情况,需要始终在代码中寻找可以改进的地方。如果你自己做不到,那就向团队寻求帮助。


四、敲代码前先手写代码


优秀程序员-2


在实际将解决方案转换为代码之前,要养成手写算法/伪代码的习惯。手写还可以帮助你在将代码移至计算机之前规划代码。写出需要的函数和类、以及它们如何交互,可以在之后节省大量时间。尽管要比直接敲代码更耗时间,但这种规范会让你打下牢固的基础,实现之后更稳健的成长。


五、给自己的代码进行注释


在自己写的代码中留下注释,解释为什么要做出某些选择。这将帮助到之后拿到这段代码的人,因为不是每个人都清楚你为什么以这种特定的方式编写代码。不需要对非常明显的编码行为进行注释,因为这无关紧要。正确的代码注释将提高代码库的可维护性。


六、善用搜索引擎和论坛


优秀程序员-3


并不是你遇到的所有问题都能自己找出明显的解决方案。所以记得善用搜索引擎,可能会有数百万的开发人员遇到过与您相同的问题,并且已经找到解决方案。所以,不要花过多时间独自寻求解决方案。很多开发人员低估了搜索作为程序员工作中一部分的重要性。


搜索引擎方面Google是不错的选择,论坛则推荐Stack Overflow。有时候工作需要的更多是知道如何获取知识和解决方案,而非实际编程。


七、寻求他人帮助


编程实际上是一种社交活动。我的程序员朋友都会在某些方面有突出优势,所以每当我有问题的时候,都知道该请教其中的哪一个。当他们有问题的时候,我也会帮助他们。这真的是完成任务的绝佳方法。


互相合作可以遵循敏捷开发的结对编程:两个程序员在一个计算机上共同工作。一个人输入代码,而另一个人审查他输入的每一行代码。二人经常互换角色,工作交替进行。在结对编程中,审查的角色需同时考虑工作的战略性方向,提出改进的意见或找出将来可能出现的问题以便处理。


虽然不能有完全的人力成本全面推行结对编程,但寻求他人建议时,看似无法改变的错误或无法学习的话题,可以通过新思维或对这个话题的新的解释来迅速缓解。所以不要在筒仓里编程,要经常讨论并推进。当你重新开始自己编写代码时,接触到多种想法和思维方式将有助于你解决问题。


八、记住技术永远在变化


我将自己的身份首先视为程序员,第二身份才是编程语言专家,因为总有一天我们现在使用的所有编程语言将不再被使用。比如我从80年代开始使用的某些形式的程序集代码,这些代码现在大部分都已经不存在了。这将发生在任何技术上,无论其自身好坏。总会有一天,没有人会再使用Java。


而另一方面,编程语言有一个广泛的范例,存在着相似的族谱。所以,如果你知道一种和另一种语言相似的语言,那么学会这种语言就很容易。


例如,Python和Ruby几乎是同一种编程语言。二者虽在文化上存在着巨大的差异,但除此之外,它们几乎完全相同,所以当你知道另一个的时候学习一个是非常容易的。因此,不要将自己与任何技术或编程语言绑定在一起,而只将它们视为帮助自己解决手头问题的工具。


九、正视并接受Bug的存在


程序员经常看到在自己开发的功能中报告了很多Bug,这意味着大多数时候,任务是失败的。但如果我们所有的程序都是功能完整的,并且没有任何Bug,那么编程就完全不成其为编程了。事实上,我们正处于编程过程中,这意味着我们要么还缺乏很多功能,要么软件有Bug。所以,在某些方面,你作为一个程序员总是失败的,因为总是存在Bug。这可能很奇怪,但你确实需要对不完美和不工作的事情保持良好的心态,因为这正是我们的工作。


编程是一个长期的过程,在这个过程中,您将一直面临新的障碍。养成记录错误的习惯,这样你以后就不会犯同样的错误了,这表明你作为一个开发人员在不断地学习和提升自己。


十、让重复性任务自动化


经常会有一些任务是需要重复做的。例如运行一组命令或执行某些活动,这些活动涉及在多个应用程序和屏幕之间切换,这会占用您的大部分时间。建议将这些耗时的日常活动转换为通过脚本或简单程序(可以通过单击或命令运行)以某种方式自动化。如针对重复的单元、接口等重复的测试执行,可以进行自动化测试。这将节省你的时间,让你可以专注于更有意义的任务,而不必担心日常繁琐的任务。


做好以上十点,相信你能够从合格的程序员,变为优秀的程序员,那么对于新技术和新知识的拥抱,就是一件水到渠成的事。“你的职责是平整土地,而非焦虑时光。你做三四月的事,在八九月自有答案。”


*参考文章:Nitish Deshpande,10Tips to Become a Software Engineer,2020.


作者:陈哥聊测试
来源:juejin.cn/post/7389931555551133732
收起阅读 »

小镇做题家必须要跨过的三道坎

其实我们大多数人都还称不上小镇做题家,因为我们大多都是来自乡村,只不过在乡镇做了上了几年的学而已。 大多数人的人生轨迹基本上都是从乡村到乡镇再到县城,最终去到了市级以上的城市读大学,没有资源,没人指路,毕业后也是一脸茫然,最终回到原点! 所以大多数小镇做题家的...
继续阅读 »

其实我们大多数人都还称不上小镇做题家,因为我们大多都是来自乡村,只不过在乡镇做了上了几年的学而已。


大多数人的人生轨迹基本上都是从乡村到乡镇再到县城,最终去到了市级以上的城市读大学,没有资源,没人指路,毕业后也是一脸茫然,最终回到原点!


所以大多数小镇做题家的生活永远都是那么艰难,很难成为出题家,是因为多数人身上都出现了致命的弱点,而这些弱点多数是由原生家庭带来的。


一.自卑


自卑可以说是原生家庭带来的第一原罪,也是很难突破的一关,而导致自卑的导火索一定是贫穷。


因为我们多数人从小都被父母灌输“我们家穷,所以你不应该这样做”。


所以在进入大学后,多数人的心中都有一个魔咒,我不配拥有,不配出去玩,不配谈恋爱,不配穿好看的衣服,即使是自己打工赚的钱,多花一分都觉得有负罪感。


因为心里会想着,父母那么辛苦,我就不应该去花钱,而是要节省,当然节省并没有不好。


但是从逻辑上就已经搞错了,首先如果自己舍不得钱去学习,去投资自己,一个月挣3000能够省下2000,那么这个省钱毫无意义,因为省下的这点钱根本做不了什么,但是会把自己置入一个更深的漩涡,收入无法提升,眼界无法开阔,而是一直盯着一个月省2000这个感人的骗局。


除了用钱有负罪感,还有不敢向上社交,觉得自己是小山旮旯出来的,不配和优秀的人结伴,因为父母从小就说: 我们家条件没人家好,人家有钱,人家会瞧不起你的。所以很多人内心就已经打了退堂鼓,从而错过了和优秀的人链接的机会。


但是事实上真正优秀的人,人家从来不会有瞧不起人的心态,只要你身上有优点,你们之间能够进行价值交换,别人比你还谦卑。


我这几年从互联网行业链接到的人有1000个以上,优秀的人不在少数,我发现越优秀的人越谦虚。


自卑导致的问题还很多,比如做事扭扭咧咧,不敢上台,不敢发表意见,总是躲在角落里。


但是这个社会没有人喜欢扭扭咧咧的人,都喜欢落落大方的人,即使你的知识不专业,你普通话不好,这些都不重要,但是只要你表现出自信,大方的态度,那么大家都会欣赏你,因为这就是勇敢。


二.面子


有一个事实,越脆弱的人越爱面子,越无能的人越爱面子。


这句话虽然说起来有点不好听,但是这是事实,我们这种小镇做题家最要先放下面子。


比如自己不懂很多东西,但是不愿意去问别人,怕被别人说,在学校的时候,有问题不敢问,于是花费很多时间去弄,到最后还是搞不懂。


进入工作后,不懂的也不好意思问同事和领导,怕被别人说菜,怀疑自己。


其实真的没几个人有那个闲心去关注你的过往,你的家庭,相反越是伪装,越容易暴露。


我很喜欢的一段话: 我穷,但是我正在拼命改变、我菜,但是我可以拼命学!


面子的背后是自负,是错失,是沦陷。


三.认知


认知是一个人的天花板,它把人划分了层级。


有一段话说得很好:你永远无法赚到认知之外的钱,你也无法了解认知之外的世界。


我们多数小镇做题家的思维永远停留在老师和父母的教导:好好读书,将来就能考一个好工作,就能找一个铁饭碗。

然后在职场中听老板的:好好工作,任劳任怨,以后给你升职加薪。


然后多数人就深信不疑,就觉得人生的目标就是铁饭碗,其他的都不行,工作努力就一定能得到升职加薪,实际上这一切是在PUA自己。


当被这个社会毒打后,才发现自己是那么无知,那么天真。


而这一切的罪魁祸首就是自己认知不够,总觉得按部就班就一定能顺利过好一生。


————


自卑,面子,认知这三点是我们多数小镇做题家难以跨过的三道坎。


而这三道坎基本上都是原生家庭和教育造成的。


跨过这三道坎的方法就是逃离和向上链接。


施耐庵在几百年前就总结出的一句话:母弱出商贾,父强做侍郎,族望留原籍,家贫走他乡。


显然我们大多数都是属于第四类,所以远走他乡是唯一的选择,只有脱离原生的环境,才能打开视野。


事实也是如此,我们观察身边没有背景,没有经济条件的人,他们的谷底反弹一定是逃离。


绝非留恋原地!


作者:苏格拉的底牌
来源:juejin.cn/post/7330295661784875043
收起阅读 »

web3入门:编写第一个智能合约

web
1. 引言 Web3 是下一代互联网,它通过区块链技术实现了去中心化。智能合约是 Web3 的核心组件之一,它们是部署在区块链上的自动化程序,可以执行预定义的操作。本学习笔记旨在介绍智能合约的基本概念、开发与部署步骤,并分享一些常见的问题及解决方案。 2. 基...
继续阅读 »

1. 引言


Web3 是下一代互联网,它通过区块链技术实现了去中心化。智能合约是 Web3 的核心组件之一,它们是部署在区块链上的自动化程序,可以执行预定义的操作。本学习笔记旨在介绍智能合约的基本概念、开发与部署步骤,并分享一些常见的问题及解决方案。


2. 基础知识


什么是智能合约?


智能合约是一种在区块链上自动执行的程序,具有以下特点:



  • 自动化执行:无需人工干预,合约条件一旦满足,程序自动执行。

  • 不可篡改:部署到区块链上的合约内容无法被篡改。

  • 透明性:所有交易和代码都是公开的,任何人都可以查看。


关键工具



  • Solidity:用于编写智能合约的编程语言。

  • Remix IDE:在线智能合约开发环境。

  • MetaMask:浏览器插件,用于管理以太坊账户并与区块链交互。

  • Ganache:本地区块链模拟器,用于测试和开发。


3. 智能合约开发


编写第一个智能合约


以下是一个简单的 Solidity 智能合约例子:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract HelloWorld {
string public greeting;

constructor() {
greeting = "Hello, World!";
}

function setGreeting(string memory _greeting) public {
greeting = _greeting;
}

function getGreeting() public view returns (string memory) {
return greeting;
}
}

编译合约


使用 Remix IDE 编写和编译上述合约:



  1. 打开 Remix IDE

  2. 新建文件并命名为 HelloWorld.sol

  3. 将上述代码粘贴到文件中。

  4. 选择适当的编译器版本(如 0.8.0),点击编译按钮。


4. 部署环境设置


安装 Node.js 和 npm


sudo apt update
sudo apt install nodejs npm

安装 Truffle 和 Ganache


npm install -g truffle
npm install -g ganache-cli

创建 Truffle 项目


mkdir MySmartContract
cd MySmartContract
truffle init

配置 Truffle


修改 truffle-config.js 文件以使用本地 Ganache 区块链:


module.exports = {
networks: {
development: {
host: "127.0.0.1",
port: 7545,
network_id: "*"
}
},
compilers: {
solc: {
version: "0.8.0"
}
}
};

复制智能合约 HelloWorld.sol


在项目contracts 文件夹中创建一个新的智能合约文件 HelloWorld.sol,将上面智能合约HelloWorld.sol内容复制过来


5. 部署智能合约


启动本地区块链Ganache


ganache-cli

image.png


image.png


编写迁移脚本


在项目migrations 文件夹中创建一个新的迁移脚本 2_deploy_contracts.js


const HelloWorld = artifacts.require("HelloWorld");

module.exports = function(deployer) {
deployer.deploy(HelloWorld);
};

部署合约


在项目根目录下运行以下命令:


truffle migrate

image.png


6. 互动和验证


连接到已部署的合约


使用 Truffle 控制台与合约互动:


truffle console

在控制台中执行以下命令:


const hello = await HelloWorld.deployed();
let greeting = await hello.getGreeting();
console.log(greeting); // 输出 "Hello, World!"
await hello.setGreeting("Hello, Blockchain!");
greeting = await hello.getGreeting();
console.log(greeting); // 输出 "Hello, Blockchain!"

image.png


7. 常见问题及解决方法


合约部署失败



  • 检查编译器版本:确保 Truffle 配置中的编译器版本与合约代码中使用的版本匹配。

  • 网络设置:确保 Ganache 正在运行且 Truffle 配置中的网络设置正确。


交易被拒绝



  • 账户余额不足:确保用于部署合约的账户有足够的以太币。

  • Gas 限制不足:增加 Gas 限制。


8. 总结


部署 Web3 智能合约需要掌握 Solidity 编程、开发环境设置以及与区块链的交互。通过本学习笔记,您可以了解从编写智能合约到在本地区块链上部署和测试的全过程。随着 Web3 技术的不断发展,掌握这些技能将对未来的区块链应用开发大有裨益。


项目链接地址:github.com/ctq123/web3…


作者:九幽归墟
来源:juejin.cn/post/7399569706183671842
收起阅读 »

前端 Element Plus 简单完美换肤方案

web
前言:本次新项目中,要求加一个换肤功能,要求可以换任何颜色。通过自己的摸索,总结出一套最合适且比较简单的换肤方案,我分享出来供大家参考,如有更好的方案,大家可以在评论区交流一下。 先看效果: 直接上干货,不废话 原理就是修改主题变量,在根html标签上添加...
继续阅读 »

前言:本次新项目中,要求加一个换肤功能,要求可以换任何颜色。通过自己的摸索,总结出一套最合适且比较简单的换肤方案,我分享出来供大家参考,如有更好的方案,大家可以在评论区交流一下。
先看效果:


image.png


image.png


直接上干货,不废话


原理就是修改主题变量,在根html标签上添加内联样式变量,如图


image.png


因为style权重高,会覆盖element plus的颜色变量,这时我们只要选择自己喜欢的颜色替换就行。


image.png


我们开发时可以直接使用--el-color-primary颜色主题变量,更改主题的时候,自己的自定义组件也会随着更改,例如:


      li:hover {
border-color: var(--el-color-primary);
}

上代码


我去掉了无关代码,方便大家理解


<template>
<div>主题颜色</div>
<el-color-picker
v-model="color"
@change="colorChange"
:predefine="predefine"
/>

</template>

<script setup lang="ts">
import { ref } from "vue";
import colorTool from "@/utils/theme"; //引入方法
// 换肤主题
const color = ref<string>("#409eff");
const colorChange = (value: string) => {
if (value) {
color.value = value;
}
把颜色存到本地,持久化,解决刷新页面主题丢失问题
localStorage.setItem("COLOR", JSON.stringify(color.value));

设置html标签style样式变量
document.documentElement.style.setProperty("--el-color-primary", color.value);
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-light-${i}`,
colorTool.lighten(color.value, i / 10)
);
}

//透明
document.documentElement.style.setProperty(
`--el-color-primary-light-10`,
color.value + 15
);

for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-dark-${i}`,
colorTool.darken(color.value, i / 10)
);
}
};

if (localStorage.getItem("COLOR")) {
colorChange(JSON.parse(localStorage.getItem("COLOR") as string));
}
默认颜色板
const predefine = ref<string[]>([
"#409eff",
"#009688",
"#536dfe",
"#ff5c93",
"#c62f2f",
"#fd726d",
]);
</script>

<style scoped lang="scss">
</style>


@/utils/theme文件代码


export default {
//hex颜色转rgb颜色
HexToRgb(str: string) {
str = str.replace("#", "");
var hxs: any= str.match(/../g);
for (var i = 0; i < 3; i++) {
hxs[i] = parseInt(hxs[i], 16)
}
return hxs;
},
//rgb颜色转hex颜色
RgbToHex(a:number, b:number, c:number) {
var hexs = [a.toString(16), b.toString(16), c.toString(16)];
for (var i = 0; i < 3; i++) {
if (hexs[i].length == 1) hexs[i] = "0" + hexs[i];
}
return "#" + hexs.join("");
},
//加深
darken(color: string, level: number) {
var rgbc = this.HexToRgb(color);
for (var i = 0; i < 3; i++) rgbc[i] = Math.floor(rgbc[i] * (1 - level));
return this.RgbToHex(rgbc[0], rgbc[1], rgbc[2]);
},
//变淡
lighten(color:string, level:number) {
var rgbc = this.HexToRgb(color);
for (var i = 0; i < 3; i++)
rgbc[i] = Math.floor((255 - rgbc[i]) * level + rgbc[i]);
return this.RgbToHex(rgbc[0], rgbc[1], rgbc[2]);
},
};


这是现成完整的代码,大家可以直接拿来用。


希望本篇文章能帮到你


作者:分母等于零
来源:juejin.cn/post/7399592120146313243
收起阅读 »

Flutter 3.24 发布啦,快来看看有什么更新

2024年立秋,Flutter 3.24 如期而至,本次更新主要包含 Flutter GPU 的预览,Web 支持嵌入多个 Flutter 视图,还有更多 Cupertino 相关库以及 iOS/MacOS 的更新等,特别是 Flutter GPU 的出现,...
继续阅读 »

2024年立秋,Flutter 3.24 如期而至,本次更新主要包含 Flutter GPU 的预览,Web 支持嵌入多个 Flutter 视图,还有更多 Cupertino 相关库以及 iOS/MacOS 的更新等,特别是 Flutter GPU 的出现,可以说它为 Impeller 未来带来了全新的可能,甚至官方还展示了小米如何使用 Flutter 为 SU7 新能源车开发 App 的案例。



可以看到,曾经 Flutter 的初代 PM 强势回归之后,Flutter 再一次迎来了新的春风。



Flutter GPU


其实这算是我对 3.24 最感兴趣的更新,因为 Flutter GPU 真的为 Flutter 提供了全新的可能。


Flutter GPU 是 Impeller 对于 HAL 的一层很轻的包装,并搭配了关于着色器和管道编排的自动化能力,也通过 Flutter GPU 就可以使用 Dart 直接构建自定义渲染器,所以 Flutter GPU 可以扩展到 Flutter HAL 中直接渲染的内容。


当然,Flutter GPU 由 Impeller 支持,但重要的是要记住它不是 Impeller ,Impeller 的 HAL 是私有内部代码与 Flutter GPU 的要求非常不同, Impeller 的私有 HAL 和 Flutter GPU 的公共 API 设计之间是存在一定差异化实现。


而通过 Flutter GPU,如曾经的 Scene (3D renderer) 支持,也可以被调整为基于 Flutter GPU 的全新模式实现,因为 Flutter GPU 的 API 允许完全控制渲染通道附件、顶点阶段和数据上传到 GPU 的过程,这种灵活性对于创建复杂的渲染解决方案(从 2D 角色动画到复杂的 3D 场景)至关重要。



可以想象,通过 Flutter GPU,Flutter 开发者可以更简单地对 GPU 进行更精细的控制,通过与 HAL 直接通信,创建 GPU 资源并记录 GPU 命令,从而最大限度的发挥 Flutter 的渲染能力。



有关 Flutter GPU 相关的,详细可见:《Flutter GPU 是什么?为什么它对 Flutter 有跨时代的意义?》


如果你对 Flutter Impeller 和其着色器感兴趣,也可以看:



MacOS PlatformView


其实官方并没有提及这一部分,但是其实从 3.22 就已经有相关实现,相信很多 Flutter 开发都十分关系 PC 上的 PlatformView 和 Webview 的进展,这里也简单汇总下。


关于 macOS 上的 PlatformView 支持,其实 2022 年中的时候,大概是 3.1.0 就有雏形,但是那时候发现了不少问题,例如:



  • UiKitView 并不适合 macOS ,因为它本质上使用的 iOS 的 UiView ,而 macOS 上需要使用的是 NSView;所以后续推进了 AppKitView 的出现,从 MacOS 的 Darwin 平台视图基类添加派生类,能力与 UiKitView 大致相同,但两者实现分离

  • 3.22 基本就已经完成了 macOS 上 Webview 的接入支持, #132583 PR 很早就提交了,但是因为此时的 PlatformView 实现还不支持手势(触控板滚动)等支持,并且也还存在一些点击问题,所以还存于 block


所以目前 AppKitView 已经有了,相关的实现也已经支持,但是还有一些问题 block 住了,另外目前 MacOS 上在 #6221 关于 WebView 的支持上,还存在:



  • 不支持滚动 API,WKWebView 在 macOS 上不公开 scrollView ,获取和设置滚动位置的代码不起作用

  • 由于 macOS 上的视图结构不同,因此无法设置背景颜色,NSView 没有与 UIView 相同的颜色和不透明度控制,因此设置背景颜色将需要替代实现



官方也表示,在完善 macOS 的同时,随后也将推出适用于 Windows 的 PlatformView 和 WebView。



而目前 macOS 上 PlatformView 的实现,采用的是 Hybrid composition 模式,这个模式看过我以前文章的应该不会陌生,它的实现相对性能开销上会比较昂贵:



因为 Flutter 中的 UI 是在专用的光栅线程上执行,而该线程很少被阻塞,但是当使用 Hybrid composition 渲染PlatformView 时,Flutter UI 继续从专用的光栅线程合成,但 PlatformView 是在平台线程上执行图形操作。



为了光栅化组合内容,Flutter 需要在在其光栅线程和 PlatformView 线程之间执行同步,因此 PlatformView 线程上的任何卡顿或阻塞操作都会对 Flutter 图形性能产生负面影响。


之前在 Mobile 上出现过的 Hybrid composition 闪烁情况,在这上面还是很大可能会出现,例如 #138936 就提到过类似的问题并修复。


另外还有如 #152178 里的情况,如果 debugRepaintRainbowEnabled 为 true ,PlatformView 可能会不会响应点击效果 。


所以,如果你还在等带 PC 上 PlatformView 和 WebView 等的相关支持,那么今年应该会能看到 MacOS 上比较完善的发布


Framewrok


全新 Sliver


3.24 包含了一套可组合在一起以实现动态 App bar 相关行为的全新 Sliver :



SliverPersistentHeader 可以使用这些全新的 Slivers 来实现浮动、固定或者跟随用户滚动而调整大小的 App bar,这些新的 Slivers 与现有的 Slivers 效果类似 SliverAppBar ,但具有更简单的 API 。


例如 PinnedHeaderSliver ,它就可以很便捷地就重现了 iOS 设置应用的 Appbar 的效果:



Cupertino 更新


3.24 优化了 CupertinoActionSheet 的交互效果,现在用手指在 Sheet 的按钮上滑动时,可以有相关的触觉反馈,并且按钮的字体大小和粗细现在与 iOS 相关的原生风格一致。



另外还为 CupertinoButton 添加了新的焦点属性,同时 CupertinoTextField 也可以自定义的 disabled 颜色。



未来 Cupertino 库还会继续推进,本次回归的 PM 主要任务之一就是针对 iOS 和 macOS 进行全新一轮的迭代。



TreeView


two_dimensional_scrollables 发布了全新的 TreeView 以及相关支持,用于构建高性能滚动树,这些滚动树可以随着树的增长向各个方向滚动,TreeSliver 还添加到了用于在一维滑动中的支持。



CarouselView


CarouselView 作为轮播效果的实现,可以包含滑动的项目列表,滚动到容器的边缘,并且 leading 和 trailing item 可以在进出视图时动态更改大小。



其他 Widget 更新


从 3.24 开始,一些非特定的设计核心 Widget 会从 Material 库中被移出到 Widgets 库,包括:



  • Feedback Widget 支持设备的触摸和音频反馈,以响应点击、长按等手势

  • ToggleableStateMixin / ToggleablePainter用于构建可切换 Widget(如复选框、开关和单选按钮)的基类


AnimationStatus 的增强


AnimationStatus 添加了一些全新的枚举,包括:



  • isDismissed

  • isCompleted

  • isRunning

  • isForwardOrCompleted


其中一些已存在于 Animation子类中 如 AnimationControllerCurvedAnimation , 现在除了 AnimationStatus 之外,所有这些状态都可在 Animation 子类中使用。


最后,AnimationController 中添加了 toggle 方法来切换动画的方向。



SelectionArea 更新


SelectionArea 又又又引来更新,本次 SelectionArea 支持更多原生手势,例如使用鼠标单击三次以及在触摸设备上双击,默认情况下,SelectionAreaSelectableRegion 都支持这些新手势。


单击三次



  • 三次单击 + 拖动:扩展段落块中的选择内容。

  • 三次点击:选择单击位置处的段落块。



双击



  • 双击+拖动:扩展字块的选择范围(Android/Fuchsia/iOS 和 iOS Web)。

  • 双击:选择点击位置的单词(Android/Fuchsia/iOS 和 Android/Fuchsia Web)。



Engine


Impeller


为了今年移除 iOS 上的 Skia 支持,Flutter 一直在努力改进 Impeller 的性能和保真度,例如对文本渲染的一系列改进大大提高了表情符号滚动的性能,消除了滚动大量表情符号时的卡顿,这是对 Impeller 文本渲染功能的一次极好的压力测试。


此外,通过解决一系列问题,还在这个版本中大大提高了 Impeller 文本渲染的保真度,特别是文本粗细、间距和字距调整,现在这些在 Impeller 都和 Skia 的文本保真度相匹配。



Android 预览


3.24 里 Android 继续为预览状态 ,由于Android 14 中的一个错误影响了 Impeller 的 PlatformView API 支持,所以本次延长了 Impeller 在 Android 上的预览期。



目前 Android 官方已经修复了该错误,但在目前市面上已经有许多未修复的 Android 版本在运行,所以解决这些问题意味着需要进行额外的 API 迁移,因此需要额外的稳定发布周期,所以本次推迟了将 Impeller 设为默认渲染器的决定。



改进了 downscaled images 的默认设置


从 3.24 开始,图像的默认值 FilterQuality已从 FilterQuality.low 调整为FilterQuality.medium


因为目前看来, FilterQuality.low 会更容易导致图像看起来出现“像素化”效果,并且渲染速度比 FilterQuality.medium 更慢。


Web


Multi-view 支持


Flutter Web 现在可以利用 Multi-view 嵌入,同时将内容渲染到多个 HTML 元素中,核心是不再只是 Full-screen 模式,此功能称为 “embedded mode” 或者 “multi-view”,可灵活地将 Flutter 视图集成到现有 Web 应用中。


在 multi-view 模式下,Flutter Web 应用不会在启动时立即渲染,相反它会等到 host 应用使用 addView 方法添加第一个“视图” ,host 应用可以动态添加或删除这些视图,Flutter 会相应地调整其 Widget 状态。


要启用 multi-view 模式,可以在 flutter_bootstrap.js 文件中的 initializeEngine方法, 通过 multiViewEnabled: true进行设置。


// flutter_bootstrap.js
{{flutter_js}}
{{flutter_build_config}}

_flutter.loader.load({
onEntrypointLoaded: async function onEntrypointLoaded(engineInitializer) {
let engine = await engineInitializer.initializeEngine({
multiViewEnabled: true, // Enables embedded mode.
});
let app = await engine.runApp();
// Make this `app` object available to your JS app.
}
});

设置之后,就可以通过 JavaScript 管理视图,将它们添加到指定的 HTML 元素并根据需要将其移除,每次添加和移除视图都会触发 Flutter 的更新,从而实现动态内容渲染。


// Adding a view...
let viewId = app.addView({
hostElement: document.querySelector('#some-element'),
});

// Removing viewId...
let viewConfig = flutterApp.removeView(viewId);

另外视图的添加和删除通过类的 WidgetsBinding didChangeMetrics 去管理和感知:


@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_updateViews();
}

@override
void didUpdateWidget(MultiViewApp oldWidget) {
super.didUpdateWidget(oldWidget);
// Need to re-evaluate the viewBuilder callback for all views.
_views.clear();
_updateViews();
}

@override
void didChangeMetrics() {
_updateViews();
}

Map<Object, Widget> _views = <Object, Widget>{};

void _updateViews() {
final Map<Object, Widget> newViews = <Object, Widget>{};
for (final FlutterView view in WidgetsBinding.instance.platformDispatcher.views) {
final Widget viewWidget = _views[view.viewId] ?? _createViewWidget(view);
newViews[view.viewId] = viewWidget;
}
setState(() {
_views = newViews;
});
}



另外通过 final int viewId = View.of(context).viewId; 也可以识别视图, viewId 可用于唯一标识每个视图。



更多可见 docs.flutter.dev/platform-in…



iOS


Swift Package Manager 初步支持


一直以来 Flutter 都是使用 CocoaPods 来管理 iOS 和 macOS 依赖项,而 Flutter 3.24 增加了对 Swift Package Manager 的早期支持,这对于 Flutter 来说,好处就是:



  • Flutter 的 Plugin 可以更贴近 Swift 生态

  • 简化 Flutter 安装环境,Xcode 本身就是包含 Swift Package Manager,如果 Flutter 的项目使用 Swift Package Manager,则完全无需安装 Ruby 和 CocoaPods 等环境


而从目前的官方 Package 上看,#146922 上需要迁移支持的 Package 大部分都已经迁移完毕,剩下的主要文档和脚本部分的支持。




更多详细可见 《Flutter 正在迁移到 Swift Package Manager ,未来会弃用 CocoaPods 吗?》



Ecosystem


SharedPreferences 更新


sharedpreferences 插件添加了两个新 API :SharedPreferencesAsync 和 SharedPreferencesWithCache,最重要的变化是 Android 实现使用 PreferencesDataStore 而不是 SharedPreferences


SharedPreferencesAsync 允许用户直接调用平台来获取设备上保存的最新偏好设置,但代价是异步,速度比使用缓存版本慢一点。这对于可以由其他系统或隔离区更新的偏好设置很有用,因为更新缓存会使缓存失效。


SharedPreferencesWithCache 建立在 SharedPreferencesAsync 之上,允许用户同步访问本地缓存的偏好设置副本。这与旧 API 类似,但现在可以使用不同的参数多次实例化。


这些新 API 旨在将来取代当前的 SharedPreferences API。但是,这是生态系统中最常用的插件之一,我们知道生态系统需要一些时间才能切换到新 API。


DevTools 和 IDE


DevTools Performance 工具新增 Rebuild Stats功能,可以捕获有关在应用中甚至在特定 Flutter 框架中构建 Widget 的次数的信息。


image-20240807052902246


另外,本次还对 Network profilerFlutter Deep Links 等工具进行了完善和关键错误修复,并进行了一些常规改进,如 DevTools 在 VS Code 窗口内打开DevTools在 Android Studio 工具窗口内打开


image-20240807052955842



3.24 版本还对 DevTools Extensions 进行了一些重大改进,现在可以在调试 Dart 或 Flutter 测试时使用 DevTools Extensions ,甚至可以在不调试任何内容而只是在 IDE 中编写代码时使用。


最后


不得不说 Flutter 在新技术投资和跟进上一直很热衷,不管是之前的 WASM Native ,还是 Flutter GPU 的全新尝试,甚至 RN 还在挣扎 Swift Package Manager 的支持时,Flutter 已经初步落地 Swift Package Manager,还有类似 sharedpreferences 跟进到 PreferencesDataStore 等,都可以看出 Flutter 的技术迭代还是相对激进的。


本次更新,Flutter team 也展示了案例:



  • 小米的一个小团队如何以及为何使用 Flutter 为 SU7 新能源车开发 App :flutter.dev/showcase/xi…

  • 法国铁路公司SNCF Connect 在欧洲的案例,它与奥运会合作,为使数百万游客能够在奥运会期间游览法国

  • Whirlpool 正在利用 Flutter 在巴西探索新的销售渠道

  • ·····


另外,2024 年 Fluttercon 欧洲举办了首届 Flutter 和 Dart 生态系统峰会,具体讨论了如:



  • FFI 和 jnigen/ffigen 缺少更多示例和文档

  • method channels 调试插件的支持

  • 合并 UI 和平台线程的可能性

  • 研究减轻插件开发负担的策略

  • 解决包装生态系统碎片化问题


而接下来 9 月份 Fluttercon USA 也将继续在纽约召开深入讨论相关主题,可以看到 Flutter 正在进一步开放和听取社区开发者的意见并改进,Flutter 虽然还有很多坑需要补,但是它也一直在努力变得更好。


所以,骚年,你打算更新 3.24 吃螃蟹了吗?还是打算等 3.24.6


作者:恋猫de小郭
来源:juejin.cn/post/7399952146236571685
收起阅读 »

为什么很多程序员会觉得领导没能力

相信很多人在职场里待久了,都会遇到自己觉得比较差劲的领导,这些人可能除了向上管理能力很强外(会舔老板),其他能力在你看来都挺一般,专业能力一般,超级缝合怪--上级给他的任何任务他都能分配给你们,然后他再缝合一遍完事。 那么遇到这种领导我们该怎么办呢?多数人想到...
继续阅读 »

相信很多人在职场里待久了,都会遇到自己觉得比较差劲的领导,这些人可能除了向上管理能力很强外(会舔老板),其他能力在你看来都挺一般,专业能力一般,超级缝合怪--上级给他的任何任务他都能分配给你们,然后他再缝合一遍完事。


那么遇到这种领导我们该怎么办呢?多数人想到的是跳槽,这确实是一个解法,但你跳到下家公司也保不齐会有这样的领导呀,今天咱们讨论的这个话题就先把条件限定成你不能跳槽,这个时候你该采用什么方法让自己的上班体验变好一些。


多元化自己的评估标准


首先,不能用鄙视的眼光去看待你的领导,觉得他只会舔老板(能舔、会舔也是一种很强的能力呀),有的时候你觉得你领导能力不行,很有可能是因为你的能力评估标准太单一了。


他或许在工作的某个方面不如你,但是他必定在某些方面有自己的长处,努力发现他的长处,认可他的长处,学习他的长处,可以更有助于你和他的相处,也有利于你的进步。


社会是一个大熔炉,你需要的不仅仅是业务能力和展现的舞台,也需要与社会中不同个体的和谐共处。包容、接纳,都是立身处世的能力。


学会变通和沟通,满足领导的存在感


领导之所以会在很多工作上提意见、瞎指挥、乱指挥,更多的情况可能是他知道自己对工作不熟悉,但觉得自己是领导,会有自己独特的见解,想刷自己的存在感。这种情况下,要学会满足领导的存在感。


举个例子说,你在工作中,领导过来给你提了个意见,这个意见明显是不合适的,那你就可以说,“领导,这个思路好,我们之前没往这个角度想,可以从这个角度延展一下……。”他走了,还不是我们自己把控,毕竟他只是过来刷个存在感的,只要最后的方案让客户满意,业绩给领导,把一些光环放在他身上,让他觉得他起到了作用,这些方案和他有关,他通常也不会计较了。


摸清领导管理的思想和套路


说到这里,找到领导心中的关键因素,是非常必要的。在一个项目里,员工承担的通常只是局部,而领导看的是整体,由于高度不同,所以你们考虑的关键因素是不同的。


所以你要知道领导心里到底想要的是什么,提前做好这方面的预期和准备,以及针对领导提出的你们没有考虑到的方面要虚心接受(毕竟领导跟高层接触的更多,有些战略方向上的事情他们会更清楚)。 


比如说,你是一个小编,你在意的是按时完成写作任务、及时发表、赚取眼球,而你的领导主编可能更在意的是你文章的各种数据真实性、转化人群、是否会产生舆情、是否zzzq这些。所以,要搞清领导在意的重要维度,工作才能更有效。


这里有三句话分享给大家:



  • 要能够分清你可以改变的事、无法改变的事;

  • 不去抱怨你服务改变的事;

  • 把精力用在你可以改变的地方。


你的上司,是你改变不了的,但你自己,是可以把握的。当然这篇文章也不是教你怎么委屈自己,只是提供一个不同的角度来讨论"领导不行” 这个事情,以及让你在无法立刻更换环境时,该怎样让当前的环境变得不那么恶劣。


想跳槽的同学还是应该按部就班的准备,骑驴找马有更合适的地方该跳就跳,跳过去了说不定今天学到的这些还能用的上……。


作者:kevinyan
来源:juejin.cn/post/7357911195946336293
收起阅读 »

书评:细读《我与地坛》看史铁生向死而生的自我救赎

读者前言 以前应该是读过《我与地坛》,可能是在课文里或者在网上推荐的断断续续的章节,它实在太有名了。印象中这好像是一个残疾人的励志故事,这次仔细读后才发现,励志并非它的论调,在故事的最开头就已注定绝望的现实将贯穿全文,也贯穿史铁生的一生。 我在想如果它真的仅是...
继续阅读 »

读者前言


以前应该是读过《我与地坛》,可能是在课文里或者在网上推荐的断断续续的章节,它实在太有名了。印象中这好像是一个残疾人的励志故事,这次仔细读后才发现,励志并非它的论调,在故事的最开头就已注定绝望的现实将贯穿全文,也贯穿史铁生的一生


我在想如果它真的仅是一个励志的故事,那史铁生将如何才能扳回这一局?他要以什么结局收尾才能算圆满?难道他要从一个痛苦的残疾人,最终变成一个充实而快乐的残疾人?


最终我认为《我与地坛》绝非一个励志的故事,它甚至没有刻意传达史铁生的人生观,它只是赤裸裸的展示发生在一个人身上的现实;它只是平静的叙述史铁生在这段苦难岁月中的痛苦、挣扎、崩溃与救赎;它只是一遍一遍又一遍的自我剖析、思考、论证,妄图推翻重塑一个全新的人生观和一个全新的自己。


img


文章摘要


出版的《我与地坛》书籍是史铁生多部散文诗歌的合集,而《我与地坛》本身只是一篇散文,而且内容是以故事叙述、内心活动、实景描写为主,只有一万多字,非常容易读。


文中分了七个章节,讲述了史铁生在“最狂妄的年龄上忽地残废了双腿”之后,“摇着轮椅”与地坛公园长达十五年相依相伴的故事。在这漫长的岁月里,史铁生独自一人守在园中,在矛盾与挣扎中反复叩问生命的意义。


第一节:我与地坛


image-20231121141722255.png


地坛就是以前北京还未开发的地坛公园,“园子荒芜冷落得如同一片野地,很少被人记起”,但随着史铁生“忽地残废了双腿”,在十五年前的一个下午,摇着轮椅进入园中,便正式开启了“我与地坛”的篇章。在园子中史铁生开始思考生与死的问题。



在满园弥漫的沉静光芒中,一个人更容易看到时间,并看见自己的身影。



突然的残疾让史铁生近乎绝望,他在痛苦中纠结要不要去死,这样活着还有什么意义。



我一连几小时专心致志地想关于死的事,也以同样的耐心和方式想过我为什么要出生。



就这样几年后他终于明白了,死是一个必然会降临,而不必急于求成的事。这也解决了他的“燃眉之急”,减轻了他在选择生或死的挣扎的痛苦。



一个人,出生了,这就不再是一个可以辩论的问题,而只是上帝交给他的一个事实;上帝在交给我们这件事实的时候,已经顺便保证了它的结果,所以死是一件不必急于求成的事,死是一个必然会降临的节日。这样想过之后我安心多了,眼前的一切不再那么可怕



第二节:母亲


度过了最初痛苦挣扎的几年后,恢复了人气的史铁生,也逐渐回忆起与母亲的生活,儿子经历巨大不幸,母亲的心碎与痛苦也只能隐忍在心里,而当时史铁生沉浸在巨大痛苦中,经常忽视和冷落了母亲。


image-20231121141636640



现在我才想到,当年我总是独自跑到地坛去,曾经给母亲出了一个怎样的难题。




当我不在家里的那些漫长的时间,她是怎样心神不定坐卧难宁,兼着痛苦与惊恐与一个母亲最低限度的祈求。




那时她的儿子还太年轻,还来不及为母亲想,他被命运击昏了头,一心以为自己是世上最不幸的一个,不知道儿子的不幸在母亲那儿总是要加倍的。




有一回我摇车出了小院,想起一件什么事又返身回来,看见母亲仍站在原地,还是送我走时的姿势,望着我拐出小院去的那处墙角,对我的回来竟一时没有反应。



母亲独自照料史铁生多年,直到猝然离世,最终也没能看到史铁生重新回归生活,并在文学上取得如此卓越的成就,这也成为史铁生往后的日子永远无法弥补的遗憾。



有一回我坐在矮树丛中,树丛很密,我看见她没有找到我;她一个人在园子里走,走过我的身旁,走过我经常待的一些地方,步履茫然又急迫。我不知道她已经找了多久还要找多久,我不知道为什么我决意不喊她——但这绝不是小时候的捉迷藏,这也许是出于长大了的男孩子的倔强或羞涩?但这倔强只留给我痛悔,丝毫也没有骄傲。我真想告诫所有长大了的男孩子,千万不要跟母亲来这套倔强,羞涩就更不必,我已经懂了可我已经来不及了。




母亲为什么就不能再多活两年?为什么在她的儿子就快要碰撞开一条路的时候,她却忽然熬不住了?莫非她来此世上只是为了替儿子担忧,却不该分享我的一点点快乐?她匆匆离我去时才只有四十九岁呀!有那么一会儿,我甚至对世界对上帝充满了仇恨和厌恶。




多年来我头一次意识到,这园中不单是处处都有过我的车辙,有过我的车辙的地方也都有过母亲的脚印。



这节里的很多描述,无论是追忆细节亦或内心独白,都极为情真意切,遗憾之情,溢于言表。


第三节:园中四季


史铁生用了非常多的比喻,能将四季比喻成园中的任何东西,十五年的共处,史铁生早已将这个园子与自己融为一体,既是困住自己的牢笼,亦是拯救他的避风港



我甚至现在就能清楚地看见,一旦有一天我不得不长久地离开它,我会怎样想念它,我会怎样想念它并且梦见它,我会怎样因为不敢想念它而梦也梦不到它。



第四节:园中十五年


园子中的十五年,这里曾经的每个人他都记忆犹新,史铁生成为园子中的一部分,仔细观察着这十五年园中的事物更迭,有的人是他多年老友,有的人只有几面之缘,或忽的消失就再也没见。



  1. 一对老夫妻

  2. 热爱唱歌的小伙子

  3. 酗酒的老头

  4. 捕鸟的汉子

  5. 朴素优雅的女工程师

  6. 练长跑的朋友


第五节:智力缺陷的小姑娘


最让史铁生痛心的是一个漂亮的有智力缺陷的小姑娘,可能因为史铁生作为残疾人更能感同身受,更能理解这个世界对他们的不公。被一群小孩欺负的弱智的小女孩,同时也是被迫承受这个世界恶意的他自己。



她呆呆地望着那群跑散的家伙,望着极目之处的空寂,凭她的智力绝不可能把这个世界想明白吧。




哥哥把妹妹扶上自行车后座,带着她无言地回家去了。


无言是对的。要是上帝把漂亮和弱智这两样东西都给了这个小姑娘,就只有无言和回家去是对的。



史铁生并未抱怨这个世界的降临的诸多苦难,因为他看透这个世界的本质,这苦难是无论如何也无法“消灭”的。



假如世界上没有了苦难,世界还能够存在么?要是没有愚钝,机智还有什么光荣呢?要是没了丑陋,漂亮又怎么维系自己的幸运?要是没有了恶劣和卑下,善良与高尚又将如何界定自己又如何成为美德呢?要是没有了残疾,健全会否因其司空见惯而变得腻烦和乏味呢?我常梦想着在人间彻底消灭残疾,但可以相信,那时将由患病者代替残疾人去承担同样的苦难。如果能够把疾病也全数消灭,那么这份苦难又将由(比如说)相貌丑陋的人去承担了。



史铁生深刻的认识到,这可能就是人类的全部剧目,人类有聪明、漂亮、善良、高尚、健全,同时也一定需要愚钝、丑陋、卑鄙、残疾,即使未来能消灭残疾,这种苦难也将由患病者承担,即使疾病也被消灭,那苦难可能也会由相貌丑陋者承担。


因为这个世界本就需要苦难,失去差别的世界将是一潭死水。



于是就有一个最令人绝望的结论等在这里:由谁去充任那些苦难的角色?又由谁去体现这世间的幸福、骄傲和欢乐?只好听凭偶然,是没有道理好讲的。




就命运而言,休论公道。



史铁生终于能接受苦难,虽然充满无奈,但他不会再去抱怨命运的不公,虽然他也是苦难的承受者,但他最终承认“上帝又一次对了”。我觉得从这里能看出,史铁生是真的从心底释怀了。


第六节:人生意义


史铁生在十五年里对生命意义的叩问,总结成三个问题,在反复的纠结与推演中也终于有了答案。这段反复拉扯的心理活动属实是非常真实了。真的很有意思。



其实总共只有三个问题交替着来骚扰我,来陪伴我。第一个是要不要去死,第二个是为什么活,第三个,我干吗要写作。




你看穿了死是一件无须乎着急去做的事,是一件无论怎样耽搁也不会错过的事,便决定活下去试试。




为什么要写作呢?


为了让那个躲在园子深处坐轮椅的人,有朝一日在别人眼里也稍微有点儿光彩,在众人眼里也能有个位置,哪怕那时再去死呢也就多少说得过去了。




要是有人走过来,我就把本子合上把笔叼在嘴里。我怕写不成反落得尴尬。我很要面子。




可是你写成了,而且发表了。


人家说我写的还不坏,他们甚至说:真没想到你写得这么好。我心说你们没想到的事还多着呢。我确实有整整一宿高兴得没合眼。




这一来你中了魔了,整天都在想哪一件事可以写,哪一个人可以让你写成小说。


结果你又发表了几篇,并且出了一点儿小名,可这时你越来越感到恐慌。




我忽然觉得自己活得像个人质,刚刚有点儿像个人了却又过了头,像个人质。


你担心要不了多久你就会文思枯竭,那样你就又完了。


凭什么我总能写出小说来呢?




我为写作而活下来,要是写作到底不是我应该干的事,我想我再活下去是不是太冒傻气了?




恐慌日甚一日,随时可能完蛋的感觉比完蛋本身可怕多了




我想人不如死了好,不如不出生的好,不如压根儿没有这个世界的好。


可你并没有去死。我又想到那是一件不必着急的事。


可是不必着急的事并不证明是一件必要拖延的事呀?


你总是决定活下来,这说明什么?是的,我还是想活。




人为什么活着?因为人想活着,说到底是这么回事,人真正的名字叫作:欲望。


所以您得知道,消灭恐慌的最有效的办法就是消灭欲望。可是我还知道,消灭人性的最有效的办法也是消灭欲望



从开始想为什么不死,到为什么写作,到说服自己开始写,从偷偷写-->发表的兴奋-->玩命写-->文思枯竭的恐慌-->见好就收想放弃-->仍然咬牙坚持-->不明白为何坚持,最终终于想清楚自己其实虽然“不怕死“但也”不想死”,因为人真正的名字叫做欲望。接受了这份欲望,虽然有些苟活的羞愧,但想清楚反而坦然接受了,坦然接受了自己想活着的欲望,也坦然接受了写作就是为了活着,彻底卸下了包袱。


我觉得这段心路历程描写的极为精彩生动,史铁生的内心反复拉扯,矛盾挣扎,活下去的欲望与对现实的恐惧的强烈冲突,我觉得很多受困于自己的人可能都会对这种矛盾非常感同身受,我希望大家都能像史铁生这样,最终坦然的接受。


没有人天生要去当圣人,只是为了更好的生活而已。


当然,很多时候需要很大的勇气,需要痛苦而深刻的反省与自我剖析,可你一旦坦然接受,未来也将是真正的坦途。


第七节:归宿


image-20231121141815162



我忽然觉得,我一个人跑到这世界上来玩真是玩得太久了。




那时您可以想象一个孩子,他玩累了可他还没玩够呢,心里好些新奇的念头甚至等不及到明天。也可以想象是一个老人,无可置疑地走向他的安息地,走得任劳任怨。还可以想象一对热恋中的情人,互相一次次说“我一刻也不想离开你”,又互相一次次说“时间已经不早了”




我说不好我想不想回去。我说不好是想还是不想,还是无所谓。




那时他便明白,每一步每一步,其实一步步都是走在回去的路上。


当牵牛花初开的时节,葬礼的号角就已吹响。




但是太阳,他每时每刻都是夕阳也都是旭日。当他熄灭着走下山去收尽苍凉残照之际,正是他在另一面燃烧着爬上山巅布散烈烈朝辉之时。有一天,我也将沉静着走下山去,扶着我的拐杖。那一天,在某一处山洼里,势必会跑上来一个欢蹦的孩子,抱着他的玩具。


当然,那不是我。但是,那不是我吗?


宇宙以其不息的欲望将一个歌舞炼为永恒。这欲望有怎样一个人间的姓名,大可忽略不计。



到最后一章,我觉得史铁生已经完全想清楚了,不在痛苦与纠结,知行合一,豁达而通透,他内心变得强大而坚韧。他自己一点点塑造起来的内心世界已经可以完全兼容现实世界,他不再去纠结要不要去死,或者追寻生的意义,他也坦然接受了写作这项对他生命非常有意义的工作。


并且他也更坦然的接受了未来最终会走向死亡终点的结局。


《我与地坛》到此完结。


关于读后感


我最直接的感触就是:平静而有力量


虽然在描述苦难,但通篇并不沉重。文字朴素平实,将漫长的十五年的所思、所感、所经历浓缩成的一篇文章,要表达的内容、感情、哲理其实极为丰富和深刻,但文字既不卖弄也不含蓄,就是很平静的阐述,通俗易懂却回味无穷,特别适合反反复复去读。我对文学并无研究,我猜这可能就是文章的内涵吧。


我相信史铁生同样也是一位平静而有力量的作家,我极为敬佩。这也是我一直以来非常想成为的人,我也总在探寻生命的意义,但我内心远没有这么强大,我常会有类似的矛盾,有时想逃避,或者企图找到一个人生终极的方法论,让我能充实幸福的过完这一生。


但我发现这世界并不存在一个唯一终极真理。我们需要用漫长的时间,不断的经历,不断的自我剖析,思考总结,才能得到一个适合自己的真理和与之匹配的心境。


没有经历足够的体验,和这个反复探索的过程,即使聆听再多再深刻的真理,也依然过不好这一生。


如史铁生文中说:



设若智慧或悟性可以引领我们去找到救赎之路,难道所有的人都能够获得这样的智慧和悟性吗?



终究绝大多数人终生都会被困于自己的牢笼,这世界绝大多数人是没有这样的智慧和悟性的,不过有个好消息就是大多数人也不会经历这样大的苦难,虽然生活有时会给我们带来很多烦恼、遗憾、难过,但同样也会有带来很多温馨、甜蜜、快乐,看来上帝还没那么残忍的。


关于人生意义


人生意义这个话题太过深刻,每个人都有不同的答案。简单聊下:



  1. 为什么要寻找人生的意义?


人生的意义其实就是一个终极方法论,来指导我们要以什么样的态度,如何过完这一生。


因为人的一生实在太过漫长,我们需要一个【基座】来【支撑】住,让我们过的充实,否则在人生的很多时刻我们容易陷入一种【虚无、虚妄】,这种【虚无】吞噬人心,会让我们觉得整个人生都没有意义。


所以人为什么要思考人生的意义,是为了人类的进步亦或找到人类存在这宇宙的终极秘密吗?其实并不是,只是我们需要它,它能让我们的生活过的更【踏实】



  1. 如何去寻找人生的意义?


其实不外乎几个方面,代表现代人不同的观念:



  • 个人成就与自我实现:通过追求个人目标和梦想来寻找生活的意义。包括事业、提高技能、创造性表达或个人发展。

  • 人际关系与社会联系:与家人、朋友、同事和社区建立深厚的关系,为许多人提供了生命的重要意义。通过各种关系,人们可以感受到归属感、爱和支持。

  • 精神与宗教信仰:很多人通过宗教信仰或精神的超过寻找人生意义。宗教信仰可以提供一套价值观和生命意义的解释,给予人们心灵的慰藉和方向。

  • 责任、奉献与付出:通过帮助他人和为社会做出贡献,找到了生活的意义,家庭责任、社会奉献甚至报效国家。

  • 感官体验、自由与权利:追求丰富的体验,比如要去吃没吃过的美食,看没看过的风景,人生不过一场体验,不如潇潇洒洒也算不枉此生。


那什么是对,什么是错,哪个高级,哪个低级呢?


不要忘了我们寻找自己人生的意义,最终目的是为了让自己过的更【踏实】,而不是为了正确答案。与其说哪个更好,不如说哪个能撑得住你的人生,如果你觉得感官体验,吃美食,看风景就是你的人生目标,那你就去做就好了。



只是因为我活着,我才不得不写作。或者说只是因为你还想活下去,你才不得不写作。



在地坛公园十五年,史铁生最终给自己人生意义的答案就是:写作


文字的力量


为什么《我与地坛》句子很平实,读起来却总有一种厚重感?我觉得是因为史铁生所描述的视角,常是突破了人的局限,跨越了时间的。


人类最大的局限性莫过于时间,因为我们只能活在当下的某个时间节点,所以我们很难跨越时间去思考问题,这导致我们普通人在思考人生意义这种深刻的命题时,答案往往显得浅薄而幼稚,我们总会在未来的某个时刻推翻过去的所思所想。


我们更常在历史书中体会这种厚重感,短短几行字可能就是一个王朝的百年兴衰,我们感到震撼和唏嘘。《我与地坛》同样会有这种感觉,试问谁又能花十五年的时间去观察一个园子的变化呢?


当时间被慢慢拉长,我们会看到一个个过去【因】链接到了未来的【果】。因果之间错综复杂的链接,在时间的加持下一览无遗,我们会感叹事物的变化会如此复杂,一个很简单的事物,也会变得深刻而有哲理。


我想作为被时间局限住的人来说,这种跨越时间的观察与思考才是我们需要的,我们才能从这复杂的变化中,总结出深刻的哲理,来支撑我们漫长的人生


推荐


《我与地坛》这本书很适合反复阅读,浓缩了史铁生十五年的心路历程,如果你目前也处在人生比较迷茫的阶段,想要走出困境,我推荐你读一下这本书,我相信你能从中获得想要的力量。


这本书不仅仅浓缩了史铁生十五年的所思所想所感,更是对现代的生活方式、生命的意义、人生观、价值观、人性和欲望、生与死进行了非常深刻的剖析,立意深远,这种深刻的问题是人类永恒的话题,所以我相信它将流传的很久,给每代人带去思考与力量。


相关文献


我与地坛(插图版)





作者:阿祖zu
来源:juejin.cn/post/7303798788719198245
收起阅读 »

为什么我们总是被赶着走

最近发生了一些事情,让shigen不禁的思考:为什么我们总是被各种事情赶着走。 一 第一件事情就是工作上的任务,接触的是一个老系统ERP,听说是2018年就在线上运行的,现在出现问题了,需要我去修改一下。在这里,我需要记录一下技术背景: ERP系统背景 后端...
继续阅读 »

最近发生了一些事情,让shigen不禁的思考:为什么我们总是被各种事情赶着走。



第一件事情就是工作上的任务,接触的是一个老系统ERP,听说是2018年就在线上运行的,现在出现问题了,需要我去修改一下。在这里,我需要记录一下技术背景:



ERP系统背景

后端采用的是jfinal框架,让我觉得很奇葩的地方有:



  • 接受前端的参数采用的HashMap封装,意味着前端字段传递的值可以为字符串、数字(float double)

  • 仅仅一个金额,可以有多种形式:1111.001,1,111.001

  • 格式化 1.00000100 小数点保存8位,这样的显示被骂了

  • 数据库采用的是oracle,jfinal的ORM工具可以采取任何的类型存入数据表的字段里,我就遇到了‘1.1111’字符串存入到定义为double的字段中

  • 原来的设计者存储金额、数量全部采用 flaot、double,凭空出现0.0000000000000001的小数,导致数量金额对不上

  • 小数位0.00000000001 会在前端显示成1-e10,直接在sql上格式化

  • sql动辄几百行,上千行,各种连表

  • sql还会连接字典表,显示某个值代表的含义

  • ……


前端不知道啥框架,接近于jquery+原生的js



  • 每改一段代码,都需要重启后端服务

  • 各种代码冗余

  • 后端打包一次40分钟+

  • ……


最关键的是:所有的需求口头说,我也是第一次接触,一次需求没理解,被运维的在办公室大声批评:你让用户怎么想?



后来,需求本来要半个月完成,拖了一个月才勉强结束。一次快下班的时候出现了问题,我没有加班,也因为遇到了问题没人帮忙。第二天问进度,没进展,领导叫去看会,说态度不好。后来换组了……



第二件事情就是我的公众号更新问题,我在八月份的时候个自己定了一个目标:公众号不停更。到最近一段时间发现:很难保持每天更新的需求了。因为我接触到的技巧很少,每篇文章的成本也很大。就拿我的某个需求为例,我需要先把代码写出来,测试完成之后再去写文章,这整个过程最低也需要两个小时的时间。成本很大,所以我有一次很难定顶住这个压力,推荐了往期的文章。


我也经常关注一些技术类的博客,看他们写的文章发现部分的博客都是互相抄袭的,很难保持高质量。更多的是在贩卖焦虑,打广告。


我希望我的每一篇文章都是有意义的,都是原创的、有价值的。所以,我也在陷入了矛盾中,成本这么大,我需要改变一下更新的节奏吗?



最后一件事情就是:我感冒了。


事情是这样的,一连几天没有去跑步了,家里的健腹轮也很少去练了,除了每天骑行了5公里外,我基本没有啥运动量。我以为我吃点维生素B、维生素C我的体质就会好一点,大错特错了。


周一发现嗓子有点干痒疼,晚上还加了班,睡觉的时候已经是凌晨一点了。周二就头很晕、带一点发热的症状,我赶紧下午去医院,在前台测了一下体温,直接烧到了28.4摄氏度。血常规检测发现是病毒性感染,买了两盒药回来了。下午一直在睡觉,睡到了十一点。


也在想:难道我的体质真的这么差吗?如果我坚持那几天戴口罩,坚持运动会不会好一些。我想到了我的拖延症。


我的dock栏永远是满的,各种软件经常打开着,Java、数据库,总是有很多的事情要去做,很忙的样子,最后发现没时间去运动了。一次健腹轮的运动不到十分钟,我都没有去行动。



这次的感冒,让我更加的重视起我的健康了,也让我觉得我丧失了主动性,总是被生活赶着走。


所以,提到了这么多,涉及到了任务的规划、任务中的可变因素……我觉得除了计划之外,更多的是需要保持热爱。不仅仅是热爱生活、热爱运动、热爱事业,更是热爱自己拥有的一切,因为:爱你所爱,即使所爱譬如朝露


作者:shigen01
来源:juejin.cn/post/7280740613891981331
收起阅读 »

谷歌浏览器禁用第三方Cookie:独裁者的新工具?

web
2024年,Chrome将要正式禁用第三方Cookie了,这个变化对Web开发者来说是非常重要的,因为它将改变开发者如何设计网站以及如何搜集和使用用户数据。这是怎么一回事,到底有什么具体影响? 什么是Cookie? 随着互联网技术的发展,我们的生活变得越来越数...
继续阅读 »

2024年,Chrome将要正式禁用第三方Cookie了,这个变化对Web开发者来说是非常重要的,因为它将改变开发者如何设计网站以及如何搜集和使用用户数据。这是怎么一回事,到底有什么具体影响?


什么是Cookie?


随着互联网技术的发展,我们的生活变得越来越数字化,网上购物、社交、阅读新闻成为日常。而在这个数字化的世界中,Cookie扮演了一个不可或缺的角色。


Cookie是一种由浏览器保存在用户电脑上的小块数据,用来帮助网站记住用户的信息和设置。网站可以在前端直接操作Cookie,也可以根据服务器返回的指令设置Cookie,当浏览器请求同一服务器时相应的Cookie会被回传。Cookie让网站能够记住用户的登录状态、购物车中的商品、以及个性化设置等,极大地提升了用户体验。



第三方Cookie是咋回事?


然而,Cookie并非只有一种。其中,第三方Cookie与网站直接设置的第一方Cookie不同,它们通常由第三方广告商或网站分析服务设置。网站一般通过在前端页面引入第三方的Javascript程序文件来实现这种能力。


用于网站分析时,Cookie可以收集和存储有关用户访问网站的数据,如用户的浏览历史、停留时间、点击行为等。这些数据对于网站运营者来说非常有价值,可以帮助他们了解用户的行为和兴趣,优化网站的设计和功能,提升用户的体验。


用于广告时,Cookie可以用来追踪用户在不同网站上的行为,以便提供个性化广告和内容。这种广泛的追踪能力让广告商能够了解用户的喜好和习惯,从而推送更加精准的广告。


为什么要禁用第三方Cookie?


但第三方Cookie也引发了隐私方面的担忧。许多用户和隐私倡导者认为,广告商利用Cookie追踪用户的行为侵犯了个人隐私。


很多同学应该有这样的体验:你在某个网站搜索了某个东西,然后访问其它网站或服务时,网页会向你展示之前搜索过的类似东西。基于某些原因,有时候我们并不想这样被跟踪。



这种担忧导致了对第三方Cookie的禁用呼声。同时隐私担忧不仅仅来自于用户和隐私倡导者,也来自于法规如欧盟的通用数据保护条例(GDPR)和加州消费者隐私法案(CCPA)这样的法律要求。


为了应对这一问题,苹果公司在其Safari浏览器中率先禁用了第三方Cookie,微软和Mozilla也采取了类似措施。


谷歌的行动计划是什么样的?


但是,作为浏览器市场占有率第一的谷歌,却迟迟没有明显的动作,遭到了不少人的非议和批评。


为了解决这些问题,谷歌提出了“隐私沙盒”(Privacy Sandbox)计划,旨在开发一系列新的技术,既能保护用户隐私,又能支持广告商进行有效的广告投放。


想象一下,如果有一个中立的场所,既能让你安心地存放你的个人物品,又能让有需要的人在不侵犯你隐私的情况下了解你的需求,这就是隐私沙盒的理念。


例如,谷歌提出的FLoC(Federated Learning of Cohorts)技术,就是将用户分群,而非单独追踪个人行为,从而在保护个人隐私的同时提供群体级的广告定位。


作为市场份额最大的浏览器,谷歌计划在2024年1月开始逐步禁用第三方Cookie,在2024年第三季度完成第三方Cookie的全面禁用。


禁用导致的问题有哪些?



禁用第三方 Cookie 对网站主、广告商和用户都会产生一系列影响。针对不同群体的影响及解决方案如下:


网站主


第三方 Cookie 的禁用可能会让网站主失去对用户行为的深入分析能力,因为他们将不能再依靠第三方 Cookie 来追踪用户在多个网站上的活动。这可能会影响到网站的个性化服务和广告定位的准确性,进而影响网站收入。


解决方案:网站主可以更多地依赖第一方 Cookie,即直接由网站域设置和读取的 Cookie。这些 Cookie 可以帮助网站主跟踪用户在自己网站上的行为,而不越过隐私边界。此外,网站主可以通过提供更多的价值服务来鼓励用户主动分享信息。


对于拥有多个域名的网站主来说,可以使用同一个根域名来设置Cookie,这样在根域名下的所有子域名都可以访问这些Cookie,这种方法仍然在用户隐私保护的框架内。如果网站主拥有多个相关联的服务,可以实施更安全的单点登录解决方案,比如使用会话令牌和OAuth等协议。


广告商


广告商将难以像过去那样追踪用户在整个互联网的行为,从而无法进行精准的广告定向,这可能导致广告效果下降和收入减少。


解决方案:谷歌提出的隐私沙盒计划中,Event Conversion Measurement API 是一种技术解决方案,它允许广告商在不侵犯用户隐私的情况下测量广告转化率。此外,广告商还可以利用机器学习等技术,通过分析大量的匿名化和聚合数据来预测用户兴趣。不过广告商也有机会使用一些更隐蔽的技术追踪手段来保持原有的业务模式,具体一些技术下文会提到。


用户


用户的隐私得到更好的保护,但可能会失去一些基于个性化广告的便利性,例如推荐系统的准确性可能会下降。


解决方案:用户可以获得更多的隐私控制权,例如通过浏览器设置来决定哪些数据可以被网站使用。同时,随着技术的发展,用户可能会遇到更多基于隐私保护的个性化体验,例如使用本地算法来进行内容推荐,而不需要将个人数据传输到服务器。


谷歌真的做好了吗?


尽管谷歌提出了Privacy Sandbox这样的计划,希望在不依赖个人用户信息的情况下,仍然能够支持广告生态系统,但实际上这个计划也遭到了一些批评。批评者认为,这些新提出的技术可能仍然允许用户被追踪,只不过追踪的方式更加隐蔽了。


例如,FLoC的提出本意是为了代替传统的个人定向广告,它通过对用户进行群组化来推送广告,这样不会直接暴露个人的行为数据。但问题在于,群组化的数据仍然可能被用来间接识别和追踪个人,特别是当某个群组里的用户数量不多时。这就导致了一种新形式的隐私问题,即“群组隐私”的泄露。


还有广告服务商仍可能通过一些技术手段突破隐私限制,比如通过网站转发、Canvas指纹技术、网络信标、用户代理字符串、本地存储和ETag跟踪等。


此外,一些隐私倡导者还担心,谷歌作为一个广告公司,其提出的隐私解决方案可能偏向于维护其在在线广告市场中的主导地位。他们认为,谷歌有动机设计一套系统,搜集用户在搜索、YouTube和其他谷歌服务上的行为数据,使得自家的广告网络相比其他竞争对手拥有优势。而这可能会影响到广告市场的公平竞争,甚至可能对开放网络生态系统构成威胁。




总的来说,谷歌在隐私问题上的努力是一个积极的开始,但隐私保护的路还很长。而对于技术人员而言,理解这些变化和挑战,以及如何在保护用户隐私的同时提供优质服务,将是未来发展的一个重要课题。


关注萤火架构,提升技术不迷路!


作者:萤火架构
来源:juejin.cn/post/7313414896783769609
收起阅读 »

关于菜狗做了一个chrome小插件

web
前言 很多时候,老是质疑反问自己,在空闲时间都在干嘛呢?是沉迷于看动漫、美女视频,打游戏?还是在追求更有意义的事呢?自我回答,没错我是属于前者了(dog),然后前后左右思考,还是决定找一些事做,不能一直这么荒废了,起码做一些是一些,积少成多!但是能做什么呢?这...
继续阅读 »

我爱上班
前言


很多时候,老是质疑反问自己,在空闲时间都在干嘛呢?是沉迷于看动漫、美女视频,打游戏?还是在追求更有意义的事呢?自我回答,没错我是属于前者了(dog),然后前后左右思考,还是决定找一些事做,不能一直这么荒废了,起码做一些是一些,积少成多!但是能做什么呢?这又引起我这不聪明的大脑深深的思考,是从自己从事的职位来,还是从自己的兴趣来呢?然后在这段迷茫的时间一直在寻找中,突然有一天看到别人的文章或视频感触深刻,最后还是敲定做项目!然后就着手开始准备做起来,就这么愉快决定行动起来了,奥利给!


于是利用上班摸鱼时间和下班空闲的时间开始行动起来~


起源


在空闲的摸鱼时间里,我经常喜欢看别人写的文章,读完一篇又一篇,大多数时候都会感叹并羡慕。然鹅,特别是对于某些事情,我会有特别深的感触。因为有时候的情绪只是在特定时刻被触发的,所以我想记录下当时的触发感受和情绪。然后我就去寻找相关的插件,结果找到了一个相当不错的插件。试用了一番后,发现效果还不错,于是我决定将关注点留在这里,尝试着制作这类插件,看看自己做的的效果如何。有时候我在想,为什么要费力自己创建一个插件呢?毕竟市面上已经有现成的插件了,为什么不用呢?然而,答案很明了:一是想要找点事情做,二是想要提升一下自己的技能水平。于是,这样的动力激励着我行动起来。


需求


产品需求其实很明确,因为从我的角度来看已经有现成的产品可供参考,然后只需根据个人需求进行定制。因此,产品的主要目的是为了方便我们的生活。


因此,一个产品的设计应当能够满足不同用户的需求,以提供更好的使用体验。这里着重考虑我自己个人使用,因为每个人的习惯和偏好都不尽相同。


收集的功能需求:



  • 对内容进行划线

  • 记录想法/感想

  • 统计列表数据

  • 预览原文(主要是针对文章)

  • 拷贝(划线、原文、Markdown)内容

  • 下载(划线TXT、原文TXT、原文Markdown)内容


目前第一版只涵盖了这些功能需求(感谢ChatGPT,它帮助解决了我作为菜狗的许多问题),后续将根据需要情况进行调整和完善~


下面是完成的功能截图:


项目截图


项目截图


突然发现我开发这个插件还有一个小用处哈哈哈,针对类似我这种不会写文章格式的小白来说,有时候真的不知道该如何开始写。但有一个现成的文章作为参考,真是太棒了!它不仅能够给我提供灵感,还能够帮助我了解文章的结构和写作风格,让我更加自信地面对写作挑战!


拷贝的文章markdown格式:


markdown格式参考图


最后打算发版到chrome,目前正在审核中~


three-point.PNG


总结


可能我是一个老老实实上班族的一个菜狗,但是勇于尝试也挺好的,毕竟在尝试中,我能够不断学习、成长,迎接新的挑战,拓展自己的能力和视野。


历经三个月的努力,终于完成了第一版。其实本来应该在一个多月内完成的,但中途我可能有些懒惰哈哈。虽然我觉得自己做的东西还不尽如人意,但从某种角度来说,至少我迈出了那一步。哪怕是简单的东西,也是通过自己制造出来的,多多少少都有一些成就感。而且,这个过程也丰富了我的技术栈。因此,我在开始思考如何完成一个要好的项目,是否可以继续打造一款完整的产品,让人们使用,如果有人使用我的产品,我也会感到非常满足!


最后思考


1、有时候,当你不知道该做什么时,一定不要让自己闲着。我深以为然,这句话给了我很大启发,我记得是在阅读一篇文章时被深深触动的。


2、有时候,想得再多也不如行动来得有效。即使方向错误,但这也是我从中获得的宝贵经验。总结经验才能不断进步。


3、时间是可以挤出来的,再忙再累也会有时间的。看个人是否愿意付诸行动,但也要适当放松,保持身心健康。


第一次写文章还是有点乱七八糟的,但这也是成长的一部分。还得继续努力,不断学习,能够更好地传达我的想法和观点!


作者:biboom
来源:juejin.cn/post/7341642966790144035
收起阅读 »

努力学习和工作就等于成长吗?

努力学习和工作与成长的关系是一个值得去深思的问题。 有趣的是,参加实习的时候,我将手上的工作做完之后去学其他的技术了,因为那时候刚好比较忙,所以领导就直接提了一个箱子过来,然我去研究一下那个硬件怎么对接。 我看了下文档,只提供了两种语言,C++和JavaScr...
继续阅读 »

努力学习和工作与成长的关系是一个值得去深思的问题。


有趣的是,参加实习的时候,我将手上的工作做完之后去学其他的技术了,因为那时候刚好比较忙,所以领导就直接提了一个箱子过来,然我去研究一下那个硬件怎么对接。


我看了下文档,只提供了两种语言,C++和JavaScript,显然排除了C++,而是使用JavaScript,不过对于写Java的我来说,虽然也玩过两年的JS,但是明显还是不专业。


我将其快速对接完成后,过了几天,又搞了几台硬件过来叫我对接。


显然这次我不想去写好代码再发给前端了,于是直接拉前段代码来和他们一起开发了。


一个后端程序员硬生生去写了前端。


那么这时候,有些人就会说,“哎呀,能者多劳嘛,你看你多么nb,啥都能干,领导就喜欢这种人了”


屁话,这不是能力,这是陷阱。



之前看到一个大佬在他的文章中写道,“如果前端和后端都能干的人,那么大概率是前端能力不怎么滴,后端能力也不怎么滴”。


我们排除那种天生就学习能力特别强的人,这种人天生脑子就是好,学啥都很快,而且学得特别好,但是这样的人是很少数的,和我们大多数人是没关的。


就像有一个大佬,后端特别厉害,手写各种中间件都不在话下,起初我以为他是个全才。


知道有一天,他要出一门教程,然后自己连最基本的CSS和HTML都不会写,然后叫别人给他写。


那么,这能说明他不厉害吗?


各行各业,精英大多都是在自己的领域深耕的。


这个世界最不缺的就是各领域的高手。


在职场中,也并不是什么都会就代表领导赏识你,只能证明你这颗螺丝比较灵活,可以往左边扭,也可以往右边扭。



在自己擅长的领域去做,把一件事尽可能垂直。


之前和一朋友聊天,他说他干过python,干过java,干过测试,干过开发,干过实施......


反正差不多什么都干过了,但是为什么后面还是啥也没干成?


我们顶多能说他职业经历丰富,但是不能说他职业经验丰富,经历是故事,而经验才是成长。


可见垂直是很重要的,不过执着追求垂直也未必是一件好事,还要看风往那边吹,不然在时代发展的潮流中也会显得无力。


就像前10年左右,PHP可谓是一领Web开发的龙头!


那句“PHP是世界上最好的语言”可谓是一针强心剂。


可是现在看来,PHP已经谈出Web领域了,很多PHP框架早已转型,比如swoole,swoft等,只留下那句“PHP是世界上最好的语言”摇摇欲坠。


可笑的是,之前看到一个群友说,领导叫他去维护一套老系统,而老系统就是PHP写的,于是他去学了好久ThinkPHP框架,但是过了半年,这个项目直接被Java重构了。


真是造化弄人啊!



深度学习和浅尝辄止


在我们还没有工作的时候,在学校看着满入眼帘的技术,心中不免有一种冲动,“老子一定要把它全部学完”


于是从表面去看一遍,会一点了,然后马上在自己学习计划上打一个勾。


但是当遇到另外一个新技术的时候,完全又懵了,于是又重复之前的动作。


这看似学了很多,但是实际上啥也没学会。


个人的精力完全是跟不上时代的发展的,十年前左右,随便会一点编程知识,那找工作简直是别人来请的,但是现在不一样了,即使源码看透了,机会也不多。


而如果掌握了核心,那么无论技术再怎么变革,只需要短暂学习就能熟练了。


就像TCP/IP这么多年了,上层建筑依然是靠它。



看似努力,实则自我感动!


在我们读书的时候,总有个别同学看似很努力,但是考试就是考不好。


究其本质,他的努力只是一种伪装,可能去图书馆5个小时,刷抖音就用了四个小时,然后发个朋友圈,“又是对自己负责的一天”。


也有不少人天天加班,然后也会发个朋友圈,“今天的努力只是为了迎接明天更好的自己”。


事实如此吗?


看到希望,有目的性的努力才是人间清醒。


如果觉得自己学得很累,工作得很累,但是实际上啥也没学到,啥也没收获,那么这样得努力是毫无意义的。


这个世界欺骗别人很容易,但是欺骗自己很难!


作者:苏格拉的底牌
来源:juejin.cn/post/7303804693192081448
收起阅读 »

耗时两周,我终于自己搭了一个流媒体服务器

web
RTSP流媒体播放器 前言:因公司业务需求,研究了下在web端播放rtsp流视频的方案,过程很曲折,但也算是颇有收获。 播放要求 web网页播放或者手机小程序播放 延迟小于500ms 支持多路播放 免费 舍弃的方案 【hls】延时非常高,有时候能达到几十...
继续阅读 »

RTSP流媒体播放器


前言:因公司业务需求,研究了下在web端播放rtsp流视频的方案,过程很曲折,但也算是颇有收获。


播放要求



  • web网页播放或者手机小程序播放

  • 延迟小于500ms

  • 支持多路播放

  • 免费


舍弃的方案



  • 【hls】延时非常高,有时候能达到几十秒,实时性场景直接pass

  • 【转rtmp】需要借助flash插件

  • 【转图片帧】需要后端借助工具将rtsp视频流每一帧转成图片,再通过websocket协议实时传输到前端,前端用canvas绘制,这种方法对后端转流要求较高,每张图片如果太大会掉帧,延时也不稳定


思路尝试


1、 flvjs + ffmpeg + websocket + node



这套方案的核心为 BiLiBiLi 开源的 flvjs,原理是在后端利用 转流工具 FFmpegrtsp流 转成 flv流,然后通过 websocket 传输 flv流,在利用 flvjs 解析成可以在浏览器播放的视频。


flv不支持ios 请自行取舍



参考文章


2、WebRTC



Webrtc是前端的技术,后端使用有点困难,原理是将 rtsp流 转成 webrtc流,直接在video中播放(需要浏览器支持webrtc)



如何将rtsp转成webrtc 基于两个工具实现


参考链接1 :webrtcstreamer.js 前端实现


参考链接2 : mediamtx转流


3、jsmpeg.js + ffmpeg + websocket + node



这种方案是我测试过免费方案中效果最好的,原理是在后端利用 转流工具 FFmpeg 将 rtsp流 转成 图片流,然后通过 websocket 传输 图片,在利用 jsmpeg.js 绘制到canvas上显示




优点:



  • 可以通时兼容多路视频,且对浏览器内存占用不会太高

  • 延迟还可以 测试在300-1000ms左右


缺点:



  • 多路无法使用主码流 会把浏览器卡死

  • 清晰度不够,画面大概在720p左右



前后端代码放jsbin了 地址 :jsbin.com/hazacak/edi…


注意
使用时请下载ffmpeg 并把ffmpeg添加值环境变量


4 、终极方案:ZLMediaKit +h265webjs



该方案应该是此类问题的终极解决方案(个人认为,有好的方案请共享)


原理:


可以看到ZlMediaKit支持把各种流进行转换输出,我们可以使用输出的流进行播放


为了便捷 推荐你使用ZLM文档提供的Docker镜像,同时ZLM提供各种的restful AP供你使用,可以转流,推流等等,具体查看文档中的 restful API部分内容


其中需要注意的是 API中的secret 在镜像文件 /opt/mdeia/conf 文件夹下 请手动复制出来 每次构建镜像 改值会变化


另外 推荐一个ZLM 的管理界面 github.com/1002victor/…


只需要把代码全部复制到 www目录下即可放心食用



image.png
前端部分:



因为前端部分相关的视频库都存在部分协议不支持,没办法完全进行测试


故选择了ZLM官方推荐的 h265webjs这个播放库,经过测试,便捷容易,可安全食用
地址:




作者:xiaoxu_JJ
来源:juejin.cn/post/7399564369229496358
收起阅读 »

如何让 localStorage 存储变为响应式

web
背景 项目上有个更改时区的全局组件,同时还有一个可以更改时区的局部组件,想让更改时区的时候能联动起来,实时响应起来。 其实每次设置完时区的数据之后是存在了前端的 localStorage 里边,时间组件里边也是从 localStorage 拿去默认值来回显。...
继续阅读 »

背景


项目上有个更改时区的全局组件,同时还有一个可以更改时区的局部组件,想让更改时区的时候能联动起来,实时响应起来


image.png


其实每次设置完时区的数据之后是存在了前端的 localStorage 里边,时间组件里边也是从 localStorage 拿去默认值来回显。如果当前页面不刷新,那么时间组件就不能更新到最新的 localStorage 数据。


怎么才能让 localStorage 存储的数也变成响应式呢?


实现



  1. 应该写个公共的方法,不仅仅时区数据能用,万一后边其他数据也能用。

  2. 项目是 React 项目,那就写个 hook

  3. 怎么才能让 localStorage 数据变成响应式呢?监听?


失败的案例 1


首先想到的是按照下边这种方式做,


useEffect(()=>{ 
console.log(11111, localStorage.getItem('timezone'))
},[localStorage.getItem('timezone')])

得到的测试结果肯定是失败的,但是为啥失败?我们也应该知道一下。查了资料说,使用 localStorage.getItem('timezone') 作为依赖项会导致每次渲染都重新计算依赖项,这不是正确的做法。


具体看一下官方文档:useEffect(setup, dependencies?) 


在此说一下第二个参数 dependencies


可选 dependenciessetup 代码中引用的所有响应式值的列表。响应式值包括 props、state 以及所有直接在组件内部声明的变量和函数。如果你的代码检查工具 配置了 React,那么它将验证是否每个响应式值都被正确地指定为一个依赖项。依赖项列表的元素数量必须是固定的,并且必须像 [dep1, dep2, dep3] 这样内联编写。React 将使用 Object.is 来比较每个依赖项和它先前的值。如果省略此参数,则在每次重新渲染组件之后,将重新运行 Effect 函数。



  • 如果你的一些依赖项是组件内部定义的对象或函数,则存在这样的风险,即它们将 导致 Effect 过多地重新运行。要解决这个问题,请删除不必要的 对象函数 依赖项。你还可以 抽离状态更新非响应式的逻辑 到 Effect 之外。


如果你的 Effect 依赖于在渲染期间创建的对象或函数,则它可能会频繁运行。例如,此 Effect 在每次渲染后重新连接,因为 createOptions 函数 在每次渲染时都不同


function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');

function createOptions() { // 🚩 此函数在每次重新渲染都从头开始创建
return {
serverUrl: serverUrl,
roomId: roomId
};
}

useEffect(() => {
const options = createOptions(); // 它在 Effect 中被使用
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // 🚩 因此,此依赖项在每次重新渲染都是不同的
// ...
}

失败的案例 2


一开始能想到的是监听,那就用 window 上监听事件。


在 React 应用中监听 localStorage 的变化,可以使用 window 对象的 storage 事件。这个事件在同一域名的不同文档之间共享,当某个文档修改 localStorage 时,其他文档会收到通知。


写代码...


// useRefreshLocalStorage.js
import { useState, useEffect } from 'react';

const useRefreshLocalStorage = (key) => {
const [storageValue, setStorageValue] = useState(
localStorage.getItem(key)
);

useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === key) {
setStorageValue(event.newValue)
}
};

window.addEventListener('storage', handleStorageChange);

return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [key]);

return [storageValue];
};

export default useRefreshLocalStorage;

使用方式:


// useTimezone.js
import { useState, useEffect } from "react";

import { getTimezone, timezoneKey } from "@/utils/utils";
import useRefreshLocalStorage from "./useRefreshLocalStorage";

function useTimezone() {
const [TimeZone, setTimeZone] = useState(() => getTimezone());
const [storageValue] = useRefreshLocalStorage(timezoneKey);

useEffect(() => {
setTimeZone(() => getTimezone());
}, [storageValue]);

return [TimeZone];
}

export default useTimezone;

经过测试,失败了,没有效果!!!那到底怎么回事呢?哪里出现问题了?查阅��料经过思考,可能出现的问题的原因有:只能监听同源的两个页面之间的 storage 变更,没法监听同一个页面的变更。


成功的案例


import { useState, useEffect } from "react";

// 自定义 Hook,用于监听 localStorage 中指定键的变化
function useRefreshLocalStorage(localStorage_key) {
// 检查 localStorage_key 是否有效
if (!localStorage_key || typeof localStorage_key !== "string") {
return [null];
}

// 创建一个状态变量来保存 localStorage 中的值
const [storageValue, setStorageValue] = useState(
localStorage.getItem(localStorage_key)
);

useEffect(() => {
// 保存原始的 localStorage.setItem 方法
const originalSetItem = localStorage.setItem;
// 重写 localStorage.setItem 方法,添加事件触发逻辑
localStorage.setItem = function(key, newValue) {
// 创建一个自定义事件,用于通知 localStorage 的变化
const setItemEvent = new CustomEvent("setItemEvent", {
detail: { key, newValue },
});
// 触发自定义事件
window.dispatchEvent(setItemEvent);
// 调用原始的 localStorage.setItem 方法
originalSetItem.apply(this, [key, newValue]);
};

// 事件处理函数,用于处理自定义事件
const handleSetItemEvent = (event) => {
const customEvent = event;
// 检查事件的键是否是我们关心的 localStorage_key
if (event.detail.key === localStorage_key) {
// 更新状态变量 storageValue
const updatedValue = customEvent.detail.newValue;
setStorageValue(updatedValue);
}
};

// 添加自定义事件的监听器
window.addEventListener("setItemEvent", handleSetItemEvent);

// 清除事件监听器和还原原始方法
return () => {
// 移除自定义事件监听器
window.removeEventListener("setItemEvent", handleSetItemEvent);
// 还原原始的 localStorage.setItem 方法
localStorage.setItem = originalSetItem;
};
// 依赖数组,只在 localStorage_key 变化时重新运行 useEffect
}, [localStorage_key]);

// 返回当前的 storageValue
// 为啥没有返回 setStorageValue ?
// 因为想让用户直接操作自己真实的 “setValue” 方法,这里只做一个只读。
return [storageValue];
}

export default useRefreshLocalStorage;

具体的实现步骤如上,每一步也加上了注释。


接下来就是测试了,


useTimezone 针对 timezone 数据统一封装,


// useTimezone.js
import { useState, useEffect } from "react";

import { getTimezone, timezoneKey } from "@/utils/utils";
import useRefreshLocalStorage from "./useRefreshLocalStorage";

function useTimezone() {
const [TimeZone, setTimeZone] = useState(() => getTimezone());
const [storageValue] = useRefreshLocalStorage(timezoneKey);

useEffect(() => {
setTimeZone(() => getTimezone());
}, [storageValue]);

return [TimeZone];
}

export default useTimezone;

具体的业务页面组件中使用,


// 页面中
// ...
import useTimezone from "@/hooks/useTimezone";

export default (props) => {
// ...
const [TimeZone] = useTimezone();

useEffect(()=>{
console.log(11111, TimeZone)
},[TimeZone)
}

测试结果必须是成功的啊!!!


小结


其实想要做到该效果,用全局 store 状态管理也能做到,条条大路通罗马嘛!不过本次需求由于历史原因一直使用的是 localStorage ,索性就想着 如何让 localStorage 存储变为响应式 ?


不知道大家还有什么更好的方法吗?


作者:Bigger
来源:juejin.cn/post/7399461786348044325
收起阅读 »

接口一异常你的前端页面就直接崩溃了?

web
前言 在 JavaScript 开发中,细节处理不当往往会导致意想不到的运行时错误,甚至让应用崩溃。可能你昨天上完线还没问题,第二天突然一大堆人艾特你,你就说你慌不慌。 来吧,咱们来捋一下怎么做才能让你的代码更健壮,即使后端数据出问题了咱前端也能稳得一批。 解...
继续阅读 »

前言


在 JavaScript 开发中,细节处理不当往往会导致意想不到的运行时错误,甚至让应用崩溃。可能你昨天上完线还没问题,第二天突然一大堆人艾特你,你就说你慌不慌。


来吧,咱们来捋一下怎么做才能让你的代码更健壮,即使后端数据出问题了咱前端也能稳得一批。


解构失败报错


不做任何处理直接将后端接口数据进行解构


const handleData = (data)=> {
const { user } = data;
const { id, name } = user;
}
handleData({})

VM244:3 Uncaught TypeError: Cannot destructure property 'id' of 'user' as it is undefined.


解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象(装箱)。由于 undefined 、null 无法转为对象,所以对它们进行解构赋值时就会报错。



所以当 data 为 undefined 、null 时候,上述代码就会报错。


第二种情况,虽然给了默认值,但是依然会报错


const handleData = (data)=> {
const { user = {} } = data;
const { id, name } = user;
}
handleData({user: null})


ES6 内部使用严格相等运算符(===)判断一个变量是否有值。所以,如果一个对象的属性值不严格等于 undefined ,默认值是不会生效的。



所以当 props.datanull,那么 const { name, age } = null 就会报错!


good:


const handleData = (data)=> {
const { user } = data;
const { id, name } = user || {};
}
handleData({user: null})

数组方法调用报错


从接口拿回来的数据直接用当成数组来用


const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item.name)
}
handleData({userList: null})
handleData({userList: 123})

VM394:3 Uncaught TypeError: userList.map is not a function

那么问题来了,如果 userList 不符合预期,不是数组时必然就报错了,所以最好判断一下


good:


const handleData = (data)=> {
const { userList } = data;
if(Array.isArray(userList)){
const newList = userList.map((item)=> item.name)
}
}
handleData({userList: 123})

遍历对象数组报错


遍历对象数组时也要注意 nullundefined 的情况


const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item?.name)
}
handleData({userList: [ null, undefined ]})

VM547:3 Uncaught TypeError: Cannot read properties of null (reading 'name')

一旦数组中某项值是 undefined 或 null,那么 item.name 必定报错,可能又白屏了。


good:


const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item?.name)
}
handleData({userList: [null]})

但是如果是这种情况就不good了


const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> `用户id是${item?.id},用户名字是${item?.name},用户年龄是${item?.age}岁了`);
}
handleData({userList: [null]})

? 可选链操作符,虽然好用,但也不能滥用。item?.name 会被编译成 item === null || item === void 0 ? void 0 : item.name,滥用会导致编译后的代码size增大。


good:


const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> {
const { id, name, age } = item || {};
return `用户id是${id},用户名字是${name},用户年龄是${age}岁了`
});
}
handleData({userList: [null]})

当可选链操作符较多的情况时无论是性能还是可读性都明显上面这种方式更好。


复习一下装箱


大家可以思考一下,以下代码会不会报错


const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item.name)
}
handleData({userList: ['', 123]})

是不会报错的,因为在 JavaScript 中,当你在一些基本类型上直接访问属性时这些类型会被自动临时转换成它们对应的对象类型。这种转换称为“装箱”(boxing)。例如:



  • ('').name


    空字符串被临时转换成一个字符串对象。由于没有名为 name 的属性,所以它返回 undefined,但不会报错。


    let str = "hello";
    console.log(str.length); // 5

    在这里,str.length 实际上是在字符串对象上调用的,而不是直接在基本类型字符串上。JavaScript 引擎在幕后将字符串 "hello" 装箱为 String 对象,因此可以访问 length 属性。


  • (123).name


    数字 123 被临时转换成一个数字对象。由于没有名为 name 的属性,所以它返回 undefined,但不会报错。


    let num = 123;
    console.log(num.toFixed(2)); // "123.00"

    num.toFixed(2) 调用了数字对象的 toFixed 方法。JavaScript 将数字 123 装箱为 Number 对象。


  • (null).name


    null 是一个特殊的基本类型,当尝试访问其属性时会报错,因为 null 不能被装箱为对象。


    try {
    const name = (null).name; // TypeError: Cannot read property 'name' of null
    } catch (error) {
    console.error(error);
    }


  • (undefined).name


    undefined 也不能被装箱为对象。


    try {
    const name = (undefined).name; // TypeError: Cannot read property 'name' of undefined
    } catch (error) {
    console.error(error);
    }



JavaScript 中的基本类型包括:


string
number
boolean
symbol
bigint
null
undefined

对应的对象类型是:


String
Number
Boolean
Symbol
BigInt

装箱的工作原理:



当你访问基本类型的属性或方法时,JavaScript 会自动将基本类型装箱为其对应的对象类型。这个临时的对象允许你访问属性和方法,但它是短暂的,一旦属性或方法访问完成,这个对象就会被销毁。



需要注意的是,null 和 undefined 没有对应的对象类型,不能被装箱。所以访问它们的属性或方法会直接报错!所以时刻警惕 nullundefined 这俩坑。


使用对象方法时报错


同理,只要变量能被转成对象,就可以使用对象的方法,但是 undefined 和 null 无法转换成对象。对其使用对象方法时就会报错。


const handleData = (data)=> {
const { user } = data;
const newList = Object.entries(user);
}
handleData({user: null});

VM601:3 Uncaught TypeError: Cannot convert undefined or null to object

下面这两种优化方式都可


good:


const handleData = (data)=> {
const { user } = data;
const newList = Object.entries(user || {})
}
handleData({user: null})

good:


/**
* 判断给定值的类型或获取给定值的类型名称。
*
* @param {*} val - 要判断类型的值。
* @param {string} [type] - 可选,指定的类型名称,用于检查 val 是否属于该类型。
* @returns {string|boolean} - 如果提供了 type 参数,返回一个布尔值表示 val 是* 否属于该类型;如果没有提供 type 参数,返回 val 的类型名称(小写)。
*
* @example
* // 获取类型名称
* console.log(judgeDataType(123)); // 输出 'number'
* console.log(judgeDataType([])); // 输出 'array'
*
* @example
* // 判断是否为指定类型
* console.log(judgeDataType(123, 'number')); // 输出 true
* console.log(judgeDataType([], 'array')); // 输出 true
*/

function judgeDataType(val, type) {
const dataType = Object.prototype.toString.call(val).slice(8, -1).toLowerCase();
return type ? dataType === type : dataType;
}

const handleData = (data)=> {
const { user } = data;
// 判断是否为对象
if(judgeDataType({}, "object")){
const newList = Object.entries(user || {})
}
}
handleData({user: null})


async/await 报错未捕获


这个也是比较容易犯且低级的错误


import React, { useState } from 'react';

const List = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
const res = await fetchListData();
setLoading(false);
}
}


如果 fetchListData() 执行报错,页面就会一直在加载中,所以一定要捕获一下。


good:


const List = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
try {
const res = await queryData();
setLoading(false);
} catch (error) {
setLoading(false);
}
}
}

当然如果觉得这种方式不优雅,用 await-to-js 库或者其他方式都可以,记得捕获就行。


JSON.parse报错


如果传入的不是一个有效的可被解析的 JSON 字符串就会报错啦。


const handleData = (data)=> {
const { userStr } = data;
const user = JSON.parse(userStr);
}
handleData({userStr: 'fdfsfsdd'})


16:06:57.521 VM857:1 Uncaught SyntaxError: Unexpected token 'd', "fdfsfsdd" is not valid JSON

这里没必要去判断一个字符串是否为有效的 JSON 字符串,只要利用 trycatch 来捕获错误即可。


good:


const handleData = (data)=> {
const { userStr } = data;
try {
const user = JSON.parse(userStr);
} catch (error) {
console.error('不是一个有效的JSON字符串')
}
}
handleData({userStr: 'fdfsfsdd'})


动态导入模块失败报错


动态导入某些模块时,也要注意可能会报错


const loadModule = async () => {
const module = await import('./dynamicModule.js');
module.doSomething();
}

如果导入的模块存在语法错误、网络或者跨域问题、文件不存在、循环依赖、甚至文件非常大导致内存不足、模块内的运行时错误等都有可能阻塞后续代码执行。


good:


const loadModule = async () => {
try {
const module = await import('./dynamicModule.js');
module.doSomething();
} catch (error) {
console.error('Failed to load module:', error);
}
}

API 兼容性问题报错


fetch('/api/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));


低版本 Node 不支持 fetch,需要更高兼容性的场景使用 axios 等更好。


其他包括小程序开发,web开发等也同理,如果使用了某些不支持的 es 新特性或者较新版本的平台才支持的api也会导致直接报错,使用时做好判断或直接用兼容性更好的写法。



框架在编译时已经帮我们解决了大部分的兼容性问题,但是有些场景还需额外注意。



内存溢出崩溃


滥用内存缓存可能会导致内存溢出


const cache = {};

function addToCache(key, value) {
cache[key] = value;
// 没有清理机制,缓存会无限增长
}

避免闭包持有大对象的引用


function createClosure() {
const largeData = new Array(1000000).fill('x');

return function() {
console.log(largeData.length);
};
}

const closure = createClosure();
// largeData 现在被闭包引用会一直存活在内存中,即使不再直接使用

closure = null; // 手动解除引用

记得清除定时器和事件监听器


// React
useEffect(() => {
const timeoutId = setTimeout(() => {
// 一些操作
}, 1000);

return () => clearTimeout(timeoutId);
}, []);

function setupHandler() {
const largeData = new Array(1000000).fill('x');
const handler = function() {
console.log(largeData.length);
};

document.getElementById('myButton').addEventListener('click', handler);

return function cleanup() {
document.getElementById('myButton').removeEventListener('click', handler);
};
}

const cleanup = setupHandler();
// 在适当的时候调用
// cleanup();

还有深度递归,JSON.parse() 解析超大数据等都可能会对内存造成压力。


总结


以上列举了js在运行时可能会发生错误而导致的应用崩溃的一些边界情况,这些都是在开发时不那么容易察觉,eslint等静态检查工具也无能为力的场景,当然如果用typescript的话还是可以帮助我们避免大部分坑的,如果不用 ts 的话就不可避免的需要考虑这些情况才能写出健壮的代码。


边界场景的容错一定要做,原则上不信任任何外部输入数据的存在性和类型,历史经验告诉我们,不做容错出错只是早晚的事。


帮别人review代码的时候也可以参考以上清单,如果大家还有补充欢迎讨论,最后祝各位大佬没有bug。


作者:CoderLiu
来源:juejin.cn/post/7388022210856222732
收起阅读 »

为了检测360浏览器,我可是煞费苦心……

web
众所周知,360浏览器一向是特立独行的存在,为了防止用户识别它,隐藏了自己的用户代理(User-Agent)。只有在自己域名下访问,用户代理(User-Agent)才暴露出自己的特征。显然,这样对于开发者想要识别它,造成了不少麻烦。 非360官方网站访问 3...
继续阅读 »

众所周知,360浏览器一向是特立独行的存在,为了防止用户识别它,隐藏了自己的用户代理(User-Agent)。只有在自己域名下访问,用户代理(User-Agent)才暴露出自己的特征。显然,这样对于开发者想要识别它,造成了不少麻烦。


非360官方网站访问


QQ截图20240712220611.png


360官方网站访问


4724d256-a733-40ba-80c6-589883816c95.png


为了识别出360,只能通过Javascript检测360的特殊属性,找和其他浏览器的区别。最常见的方式就是找navigator.mimeTypes或者navigator.plugins 有哪些不一样的值。为此,我在各个版本的360浏览器(360安全浏览器、360极速浏览器)也找到了各种可以利用的特征。


QQ截图20240712225525.png


比如,无论 360安全浏览器 还是 360极速浏览器 的navigator.mimeTypes都可能存在
application/360softmgrplugin , application/mozilla-npqihooquicklogin, application/npjlgplayer3-chrome-jlp,application/vnd.chromium.remoting-viewer这几种类型,我们可以通过判断这几个值是否存在识别 360浏览器。不仅如此,在早期的360浏览器中,明明是chrome内核,还保留着IE时代有了showModalDialog方法,这些都可以用来做识别的依据。


import getMime from '../method/getMime.js';
import _globalThis from '../runtime/globalThis.js';

export default {
name: '360',
match(ua) {
let isMatch = false;
if (_globalThis?.chrome) {
let chrome_version = ua.replace(/^.*Chrome\/([\d]+).*$/, '$1');
if (getMime("type", "application/360softmgrplugin") || getMime("type", "application/mozilla-npqihooquicklogin") || getMime("type", "application/npjlgplayer3-chrome-jlp")) {
isMatch = true;
} else if (chrome_version > 36 && _globalThis?.showModalDialog) {
isMatch = true;
} else if (chrome_version > 45) {
isMatch = getMime("type", "application/vnd.chromium.remoting-viewer");
if (!isMatch && chrome_version >= 69) {
isMatch = getMime("type", "application/asx");
}
}
}
return ua.includes('QihooBrowser')
||ua.includes('QHBrowser')
||ua.includes(' 360 ')
||isMatch;
},
version(ua) {
return ua.match(/QihooBrowser(HD)?\/([\d.]+)/)?.[1]
||ua.match(/Browser \(v([\d.]+)/)?.[1]
||'';
}
};

然而,360并不是只有1种浏览器,还包含了 360安全浏览器, 360极速浏览器,360 AI浏览器等。我们又怎么区分呢,这时候还要寻找它们之间的差别。360AI浏览器较为简单,用户代理(User-Agent)中直接暴露相关信息。


我发现有一个值是360安全浏览器独有的,那就是application/gameplugin,应该是浏览器内置的游戏插件。可是在后续的版本中也消失了,我又发现navigator.userAgentData.brands里的值也有细微区别。于是就可以如下处理:


import getMime from '../method/getMime.js';
import _Chrome from './Chrome.js';
import _360 from './360.js';
import _globalThis from '../runtime/globalThis.js';

export default {
name:'360SE',
match(ua,isAsync=false){
let isMatch = false;
if(_360.match(ua)){
if(getMime("type", "application/gameplugin")){
isMatch = true;
}else if(_globalThis?.navigator?.userAgentData?.brands.filter(item=>item.brand=='Not.A/Brand').length){
isMatch = true;
}
}
return ua.includes('360SE')||isMatch;
},
version(ua){
let hash = {
'122':'15.3',
'114':'15.0',
'108':'14.0',
'86':'13.0',
'78':'12.0',
'69':'11.0',
'63':'10.0',
'55':'9.1',
'45':'8.1',
'42':'8.0',
'31':'7.0',
'21':'6.3'
};
let chrome_version = parseInt(_Chrome.version(ua));
return hash[chrome_version]||'';
}
};

而 360极速浏览器的识别依据就相对较多了!各种身份验证的插件都能在里面找到。


import getMime from '../method/getMime.js';
import _Chrome from './Chrome.js';
import _360 from './360.js';
import _globalThis from '../runtime/globalThis.js';

export default {
name:'360EE',
match(ua){
let isMatch = false;
if(getMime('type','application/cenroll.cenroll.version.1')||getMime('type','application/hwepass2001.installepass2001')){
isMatch = true;
}else if(_360.match(ua)){
if(_globalThis?.navigator?.userAgentData?.brands.find(item=>item.brand=='Not A(Brand'||item.brand=='Not?A_Brand')){
isMatch = true;
}
}
return ua.includes('360EE')||isMatch;
},
version(ua){
let hash = {
'122':'22.3', // 360极速X
'119':'22.0', // 360极速X
'108':'14.0', // 360极速
'95':'21.0', // 360极速X
'86':'13.0',
'78':'12.0',
'69':'11.0',
'63':'9.5',
'55':'9.0',
'50':'8.7',
'30':'7.5'
};
let chrome_version = parseInt(_Chrome.version(ua));
return ua.match(/Browser \(v([\d.]+)/)?.[1]
||hash[chrome_version]
||'';
}
};

可惜的是在Mac系统中的情况复杂点,这些插件的方法都不存在,这下又失去了判断的依据了。还有,在一次无意打开网络连接一次的时候,发现了360浏览器在请求一个奇怪的地址,居然返回了浏览器版本信息。


  import _globalThis from '../runtime/globalThis.js';

const GetDeviceInfo = () => {
return new Promise((resolve) => {
const randomCv = `cv_${new Date().getTime() % 100000}${Math.floor(Math.random()) * 100}`
const params = { key: 'GetDeviceInfo', data: {}, callback: randomCv }
const Data = JSON.stringify(params)
if(_globalThis?.webkit?.messageHandlers){
_globalThis.webkit.messageHandlers['excuteCmd'].postMessage(Data)
_globalThis[randomCv] = function (response) {
delete _globalThis[randomCv];
resolve(JSON.parse(response||'{}'));
}
}else{
return resolve({});
}
})
};

export default {
name: '360EE',
match(ua) {
return GetDeviceInfo().then(function(response){
return response?.pid=='360csexm'||false;
});
},
version(ua) {
return GetDeviceInfo().then(function(response){
return response?.module_version||'';
});
}
};

原本觉得一切应该就这么顺利了,然后当我从Windows10迁移到windows11的时候,发现原来的浏览器中的插件特征识别已经失效了,我找不到360安全浏览器的识别特征。


QQ截图20240712225301.png


于是我疯了……我开始一个个属性对比差异,就是找不到有什么特征是可以区分开的。就在我一筹莫展的时候,我无意间发现,我自己的网站在360安全浏览器中,莫默其妙多了一个奇怪的节点,看样子是一个AI组件。我敢确定,这个节点并不是我写的,于是我断言是360做了什么特殊处理。


f4268868-324f-4606-94fb-7de4e8b6a351.png


经过分析,我发现这是360安全浏览器内置的一个“扩展程序 - 360智脑” ,是默认安装的而且无法卸载。我脑子一下子亮起来了,心想着我可以根据这个节点判断啊,只要检测它是否加载就能判断出来。于是,我写了以下代码:


// 根据检测文档中是否被插入“360智脑”组件,判断是否为360安全浏览器
if(!document?.querySelector('#ai-assist-root')){
return new Promise(function(resolve){
let hander = setTimeout(function(){
resolve(false);
},1500);
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(function($item){
if($item.id=='ai-assist-root'){
hander&&clearTimeout(hander);
resolve(true);
}
});
}
});
});
observer.observe(document,{childList: true, subtree: true});
});
}

但确实也有不足之处,我只能判断出什么时候加载了节点,但是无法对非360浏览器不加载进行判断。只能通过定时器去做超时处理,这就意味着非360浏览器判断需要一定耗时,这样太不友好了。


就在这时候,我发现这个“扩展程序”是需要加载资源的,而这个资源在浏览器本地。也就意味着,我可以直接直接对资源进行判断,这样并不需要等插件加载超时判断,非360安全浏览器可以较快地判断出“非他”的条件。


// 根据判断扩展程序CSS是否加载成功判断是否为360安全浏览器
return new Promise(function(resolve){
fetch('chrome-extension://fjbbmgamncjadhlpmffehlmmkdnkiadk/css/content.css').then(function(){
resolve(true);
}).catch(function(){
resolve(false);
});
});

于是我终于可以判断出这烦人的“小妖精”了!而这也是浏览器嗅探的其中一项工作,为此我还做了诸如:操作系统、屏幕、处理器架构、GPU、CPU、IP地址、时区、语言、网络等浏览器信息的判断和识别。


QQ截图20240712231256.png


浏览器在线检测: passer-by.com/browser/


开源项目仓库地址:github.com/mumuy/brows…


如果你对此感兴趣或者有什么内容要补充,欢迎关注此项目~


作者:passerby6061
来源:juejin.cn/post/7390588322768748580
收起阅读 »

因为不知道Object.keys,被嫌弃了

web
关联精彩文章:# 改进tabs组件切换效果,丝滑的动画获得一致好评~ 背景 今天,同事看到了我一段遍历读取对象key的代码后,居然嘲笑我技术菜(虽然我确实菜) const person = { name: '员工1', age: 30, profe...
继续阅读 »

关联精彩文章:# 改进tabs组件切换效果,丝滑的动画获得一致好评~


背景


今天,同事看到了我一段遍历读取对象key的代码后,居然嘲笑我技术菜(虽然我确实菜)


const person = {
name: '员工1',
age: 30,
profession: 'Engineer'
// ....
};

// 使用 for 循环读取对象的键
const keys = Object.keys(person);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = person[key];
console.log(key + ': ' + value);
}

我说,用for循环读取对象的key不对吗,他二话不说直接给我改代码


const person = {
name: '员工1',
age: 30,
profession: 'Engineer'
};

Object.keys(person).forEach(key => {
console.log(key + ': ' + person[key]);
});

然后很装的走了......钢铁直男啊!我很生气,我这个人比较较劲,我一定要把Object.keys吃透!


Object.keys的基础用法


语法


Object.keys 是 JavaScript 中的一个方法,用于获取对象自身的可枚举属性名称,并以数组形式返回。


Object.keys(obj)


  • 参数:要返回其枚举自身属性的对象

  • 返回值:一个表示给定对象的所有可枚举属性的字符串数组




不同入参的返回值



  • 处理对象,返回可枚举的属性数组


let person = {name:"张三",age:25,address:"深圳",getName:function(){}}

Object.keys(person)

// ["name", "age", "address","getName"]



  • 处理数组,返回索引值数组


let arr = [1,2,3,4,5,6]
Object.keys(arr)
// ["0", "1", "2", "3", "4", "5"]



  • 处理字符串,返回索引值数组


let str = "saasd字符串"
Object.keys(str)
// ["0", "1", "2", "3", "4", "5", "6", "7"]


Object.keys的常用技巧


Object.keys在处理对象属性、遍历对象和动态生成内容时非常有用。


遍历对象属性


当你需要遍历一个对象的属性时,Object.keys 可以将对象的所有属性名以数组形式返回,然后你可以使用 forEachfor...of 来遍历这些属性名。


示例:


const person = {
name: '员工1',
age: 30,
profession: 'Engineer'
};

Object.keys(person).forEach(key => {
console.log(key + ': ' + person[key]);
});

输出:


name: 员工1
age: 30
profession: Engineer

获取对象属性的数量


可以使用 Object.keys 获取对象的属性名数组,然后通过数组的 length 属性来确定对象中属性的数量。


示例:


const person = {
name: '员工2',
age: 30,
profession: 'Engineer'
};

const numberOfProperties = Object.keys(person).length;
console.log(numberOfProperties); // 输出: 3

过滤对象属性


可以使用 Object.keys 来获取对象的属性名数组,然后使用数组的 filter 方法来筛选属性名,从而创建一个新的对象。


示例:


const person = {
name: '员工3',
age: 30,
profession: '钢铁直男'
};

const filteredKeys = Object.keys(person).filter(key => key !== 'age');
const filteredPerson = {};

filteredKeys.forEach(key => {
filteredPerson[key] = person[key];
});

console.log(filteredPerson); // 输出: { name: '员工3', profession: '钢铁直男' }

检查对象是否为空


可以通过检查 Object.keys 返回的数组长度来确定对象是否为空。


示例:


const emptyObject = {};
const nonEmptyObject = { name: '讨厌的人' };

function isEmpty(obj) {
return Object.keys(obj).length === 0;
}

console.log(isEmpty(emptyObject)); // 输出: true
console.log(isEmpty(nonEmptyObject)); // 输出: false

深拷贝对象


虽然 Object.keys 本身并不能进行深拷贝,但它可以与其他方法结合使用来创建对象的深拷贝,特别是当对象的属性是另一层对象时。


示例:


const person = {
name: '快乐就是哈哈哈',
age: 18,
profession: 'coder',
address: {
city: 'Wonderland',
postalCode: '12345'
}
};

function deepCopy(obj) {
const copy = {};
Object.keys(obj).forEach(key => {
if (typeof obj[key] === 'object' && obj[key] !== null) {
copy[key] = deepCopy(obj[key]);
} else {
copy[key] = obj[key];
}
});
return copy;
}

const copiedPerson = deepCopy(person);
console.log(copiedPerson);

是小姐姐,不是小哥哥~


作者:快乐就是哈哈哈
来源:juejin.cn/post/7392115478549069861
收起阅读 »

Java中的双冒号运算符(::)及其应用

在Java 8引入的Lambda表达式和函数式接口之后,双冒号运算符(::)成为了一项重要的功能。它可以将方法或构造函数作为参数传递,简化了编码和提升了代码的可读性。本文将介绍Java中的双冒号运算符及其常见应用场景。 双冒号运算符(::)的语法 双冒号运算符...
继续阅读 »

在Java 8引入的Lambda表达式和函数式接口之后,双冒号运算符(::)成为了一项重要的功能。它可以将方法或构造函数作为参数传递,简化了编码和提升了代码的可读性。本文将介绍Java中的双冒号运算符及其常见应用场景。


双冒号运算符(::)的语法


双冒号运算符的语法是类名/对象名::方法名。具体来说,它有三种不同的使用方式:



  1. 作为静态方法的引用:ClassName::staticMethodName

  2. 作为实例方法的引用:objectReference::instanceMethodName

  3. 作为构造函数的引用:ClassName::new


静态方法引用


首先,我们来看一下如何使用双冒号运算符引用静态方法。假设有一个Utils类,其中有一个静态方法processData


public class Utils {
public static void processData(String data) {
System.out.println("Processing data: " + data);
}
}

我们可以使用双冒号运算符将该方法作为参数传递给其他方法:


List<String> dataList = Arrays.asList("data1", "data2", "data3");
dataList.forEach(Utils::processData);

上述代码等效于使用Lambda表达式的方式:


dataList.forEach(data -> Utils.processData(data));

通过使用双冒号运算符,我们避免了重复写Lambda表达式,使代码更加简洁和易读。


实例方法引用


双冒号运算符还可以用于引用实例方法。假设我们有一个User类,包含了一个实例方法getUserInfo


public class User {
public void getUserInfo() {
System.out.println("Getting user info...");
}
}

我们可以通过双冒号运算符引用该实例方法:


User user = new User();
Runnable getInfo = user::getUserInfo;
getInfo.run();

上述代码中,我们创建了一个Runnable对象,并将user::getUserInfo作为方法引用赋值给它。然后,通过调用run方法来执行该方法引用。


构造函数引用


在Java 8之前,要使用构造函数创建对象,需要通过写出完整的类名以及参数列表来调用构造函数。而使用双冒号运算符,我们可以将构造函数作为方法引用,实现更加简洁的对象创建方式。


假设有一个Person类,拥有一个带有name参数的构造函数:


public class Person {
private String name;

public Person(String name) {
this.name = name;
}

public String getName() {
return name;
}
}

我们可以使用双冒号运算符引用该构造函数并创建对象:


Supplier<Person> personSupplier = Person::new;
Person person = personSupplier.get();
person.getName(); // 调用实例方法

上述代码中,我们使用Person::new将构造函数引用赋值给Supplier接口,然后通过get方法创建了Person对象。


总结


本文介绍了Java中双冒号运算符(::)的语法及其常见的应用场景。通过双冒号运算符,我们可以更方便地引用静态方法、实例方法和构造函数,使得代码更加简洁和可读。双冒号运算符是Java 8引入的重要特性,对于函数式编程和Lambda表达式的使用起到了积极的推动作用。


希望本文能够帮助您理解和应用双冒号运算符,提高Java开发的效率和代码质量。如有任何问题或疑惑,欢迎提问!


作者:每天一个技术点
来源:juejin.cn/post/7316532841923805184
收起阅读 »

揭秘外卖平台的附近公里设计

背景 相信大家都有点外卖的时候去按照附近公里排序的习惯,那附近的公里是怎么设计的呢?今天shigen带你一起揭秘。 分析 我们先明确一下需求,每个商家都有一个地址对吧,我们也有一个地址,我们点餐的时候,就是以我们自己所在的位置为圆心,向外辐射,这一圈上有一堆的...
继续阅读 »

背景


相信大家都有点外卖的时候去按照附近公里排序的习惯,那附近的公里是怎么设计的呢?今天shigen带你一起揭秘。


分析


我们先明确一下需求,每个商家都有一个地址对吧,我们也有一个地址,我们点餐的时候,就是以我们自己所在的位置为圆心,向外辐射,这一圈上有一堆的商家。类似我下方的图展示:



想到了位置,我们自然想到了卫星定位,想到了二维的坐标。那这个需求我们有什么好的设计方案吗?


redis的GEO地理位置坐标这个数据结构刚好能解决我们的需求。


GEO


GEO 是一种地理空间数据结构,它可以存储和处理地理位置信息。它以有序集合(Sorted Set)的形式存储地理位置的经度和纬度,以及与之关联的成员。


以下是 Redis GEO 的一些常见操作:



  1. GEOADD key longitude latitude member [longitude latitude member ...]:将一个或多个地理位置及其成员添加到指定的键中。 示例:GEOADD cities -122.4194 37.7749 "San Francisco" -74.0059 40.7128 "New York"

  2. GEODIST key member1 member2 [unit]:计算两个成员之间的距离。 示例:GEODIST cities "San Francisco" "New York" km

  3. GEOPOS key member [member ...]:获取一个或多个成员的经度和纬度。 示例:GEOPOS cities "San Francisco" "New York"

  4. GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]:根据给定的经纬度和半径,在指定范围内查找与给定位置相匹配的成员。 示例:GEORADIUS cities -122.4194 37.7749 100 km WITHDIST COUNT 5


Redis 的 GEO 功能可用于许多应用场景,例如:



  • 位置服务:可以存储城市、商店、用户等位置信息,并通过距离计算来查找附近的位置。

  • 地理围栏:可以存储地理围栏的边界信息,并检查给定的位置是否在围栏内。

  • 最短路径:可以将城市或节点作为地理位置,结合图算法,查找两个位置之间的最短路径。

  • 热点分析:可以根据位置信息生成热力图,统计热门区域或目标位置的访问频率。


Redis 的 GEO 功能提供了方便且高效的方式来存储和操作地理位置信息,使得处理地理空间数据变得更加简单和快速。



默默的说一句,redis在路径规划下边竟然也这么厉害!



好的,那我们就来开始实现吧。今天我的操作还是用代码来展示,毕竟经纬度在控制台输入可能会出错。


代码实现


今天的案例是将湖北省武汉市各个区的数据存储在redis中,并以我所在的位置计算离别的区距离,以及我最近10km内的区。数据来源



我的测试代码如下,其中的运行结果也在对应的注释上有显示。



因为代码图片的宽度过长,导致代码字体很小,在移动端可尝试横屏观看;在PC端可尝试右键在新标签页打开图片。




以上的代码案例也参考:Redis GEO 常用 RedisTemplate API(Java),感谢作者提供的代码案例支持。


总结


对于需要存储地理数据和需要进行地理计算的需求,可以尝试使用redis进行解决。当然,elasticsearch也提供了对应的数据类型支持。


作者:shigen01
来源:juejin.cn/post/7275595571733282853
收起阅读 »

全网显示IP归属地,免费可用,快来看看

前言 经常浏览小视频或各类帖子的朋友们可能已经注意到,目前许多网络平台都会显示作者和评论区用户的IP归属地。那么,这个功能是如何实现的呢? 某些收费平台的API 我们可以利用一些付费平台的API来实现这一功能,比如一些导航软件的开放平台API等。然而,这些服...
继续阅读 »

前言


经常浏览小视频或各类帖子的朋友们可能已经注意到,目前许多网络平台都会显示作者和评论区用户的IP归属地。那么,这个功能是如何实现的呢?



某些收费平台的API


我们可以利用一些付费平台的API来实现这一功能,比如一些导航软件的开放平台API等。然而,这些服务通常是收费的,而且免费额度有限,适合测试使用,但如果要在生产环境中使用,很可能不够支撑需求。



离线库推荐


那么,有没有免费的离线API库呢?UP现在推荐一个强大的离线库给大家,一个准确率高达99.9%的离线IP地址定位库,查询速度仅需0.0x毫秒,而且数据库仅10兆字节大小。此库提供了Java、PHP、C、Python、Node.js、Golang、C#等多种查询绑定,同时支持Binary、B树和内存三种查询算法。



这个库大家可以在GitHub上搜索:ip2region,即可找到该开源库。


使用


下面使用Java代码给大家演示下如何使用这个IP库,该库目前支持多重主流语言。


1、引入依赖


<dependency>
   <groupId>org.lionsoul</groupId>
   <artifactId>ip2region</artifactId>
   <version>2.7.0</version>
</dependency>

2、下载离线库文件 ip2region.xdb



3、简单使用代码


下面,我们通过Java代码,挑选某个国内的IP进行测试,看看会输出什么样的结果


public class IpTest {

   public static void main(String[] args) throws Exception {
       // 1、创建 searcher 对象 (修改为离线库路径)
       String dbPath = "C:\Users\Administrator\Desktop\ip2region.xdb";
       Searcher searcher = null;
       try {
           searcher = Searcher.newWithFileOnly(dbPath);
      } catch (Exception e) {
           System.out.printf("failed to create searcher with `%s`: %s\n", dbPath, e);
           return;
      }

       // 2、查询
       String ip = "110.242.68.66";
       try {
           long sTime = System.nanoTime(); // Happyjava
           String region = searcher.search(ip);
           long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
           System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
      } catch (Exception e) {
           System.out.printf("failed to search(%s): %s\n", ip, e);
      }

       // 3、关闭资源
       searcher.close();

       // 备注:并发使用,每个线程需要创建一个独立的 searcher 对象单独使用。
  }

}


输出结果为:


{region: 中国|0|河北省|保定市|联通, ioCount: 3, took: 1192 μs}

其中,region的格式为 国家|区域|省份|城市|ISP,缺省的地域信息默认是0。


当然,这个库不只是支持国内的IP,也支持国外的IP。



其他语言可以参考该开源库的说明文档。


总结


这是一个准确率非常高的离线库,如果项目里有IP定位需求的,可以试下该库。


作者:happyjava
来源:juejin.cn/post/7306334713992708122
收起阅读 »