前端中的“+”连接符,居然有鲜为人知的强大功能!
故事背景:"0"和"1"布尔判断
这几天开发,遇到一个问题:根据后端返回的isCritical判断页面是否展示【关键标签】
很难受的是,后端的isCritical的枚举值是字符串
”0“: 非关键
”1“ :关键
这意味着前端得自己转换一下,于是我写出了这样的代码
// html
<van-icon v-if="getCriticalStatus(it.isCritical)" color="#E68600"/>
// js
const getCriticalStatus = (critical: string) => {
if (critical.value === "1") return true;
return false;
}
我以为我这样写很简单了,没想到同事看到后,说我这样写麻烦了,于是给我改了一下代码
// html
<van-icon v-if="+it.isCritical" color="#E68600"/>
我大惊失色脱水缩合,这就行了?看来,我还是小看"+"运算符了!
"+"的常见使用场景
前端对"+"连字符一定不陌生,它的算术运算符功能和字符串连接功能,我们用脚趾头也能敲出来。
算术运算符
在 JavaScript 中,+
是最常见的算术运算符之一,可以用来执行加法运算。
let a = 5;
let b = 10;
let sum = a + b;
// sum的值为15
字符串连接符
+
还可以用来连接字符串。
let firstName = "石";
let lastName = "小石";
let fullName = firstName + " " + lastName; // fullName的值为"石小石"
如果是数字和字符连接,它会把数字转成字符
const a = 1
const b = "2"
const c = a + b; // c的值为字符串"12"
"+"的高级使用场景
除了上述的基本使用场景,其实它还有一些冷门但十分使用的高级使用场景。
URL编码中的空格
在 URL 编码中,+
字符可以表示空格,尤其是在查询字符串中。
http://shixiaoshi.com/search?query=hello+world
上面的代码中,hello+world
表示查询 hello world
,其中的 +
会被解码为一个空格。
但要注意的是,现代 URL 编码规范中推荐使用 %20
表示空格,而不是 +
。
一元正号运算符
+
的高级用法,再下觉得最牛逼的地方就是可以作为一元运算符使用!
+
作为一元运算符时,可以将一个值转换为数字(如果可能的话)。
let str = "123";
let num = +str;
// num的值为123,类型为number
这一用法在处理表单输入时特别有用,因为表单输入通常是字符串类型。
let inputValue = "42";
let numericValue = +inputValue; // 将字符串转换为数字42
那么回到文章开头的问题,我们看看下面的代码为什么可以生效
// html
<van-icon v-if="getCriticalStatus(it.isCritical)" color="#E68600"/>
// js
const getCriticalStatus = (critical: string) => {
if (critical.value === "1") return true;
return false;
}
// html 优化后的代码
<van-icon v-if="+it.isCritical" color="#E68600"/>
由于it.isCritical的值是字符"0"或"1",通过"+it.isCritical"转换后,其值是数字0或1,而恰好0可以当false使用,1可以当true使用!因此,上述代码可以生效!
JavaScript 中的类型转换规则会将某些值隐式转换为布尔值:
- 假值 :在转换为布尔值时被视为
false
的值,包括:false
、0
(数字零)、-0
(负零)、""
(空字符串)、null
、undefined
、NaN
(非数字)
- 真值 :除了上述假值外,所有其他值在转换为布尔值时都被视为
true
。
一元正号运算符的原理
通过上文,我们知道:当使用 +
操作符时,JavaScript 会尝试把目标值转换为数字,它遵循以下规则:。
转换规则
数字类型:
如果操作数是数字类型,一元正号运算符不会改变其值。
例如:+5
还是 5
。
// 数字类型
console.log(+5); // 5(数字)
字符串类型:
如果字符串能够被解析为有效的数字,则返回相应的数字。
如果字符串不能被解析为有效的数字(如含有非数字字符),则返回 NaN
(Not-a-Number)。
例如:+"123"
返回 123
,+"abc"
返回 NaN
。
// 字符串类型
console.log(+"42"); // 42
console.log(+"42abc"); // NaN
布尔类型:
true
会被转换为 1
。
false
会被转换为 0
。
// 布尔类型
console.log(+true); // 1
console.log(+false); // 0
null:
null
会被转换为 0
。
// null
console.log(+null); // 0
undefined:
undefined
会被转换为 NaN
。
// undefined
console.log(+undefined); // NaN
对象类型:
对象首先会通过内部的 ToPrimitive
方法被转换为一个原始值,然后再进行数字转换。通常通过调用对象的 valueOf
或 toString
方法来实现,优先调用 valueOf
。
// 对象类型
console.log(+{}); // NaN
console.log(+[]); // 0
console.log(+[10]); // 10
console.log(+["10", "20"]); // NaN
底层原理
不重要,简单说说:
在 JS引擎内部,执行一元正号运算符时,实际调用了 ToNumber
抽象操作,这个操作试图将任意类型的值转换为数字。ToNumber
操作依据 ECMAScript 规范中的规则,将不同类型的值转换为数字。
总结
一元正号运算符 +
是一个简便的方法,用于将非数字类型转换为数字。
如果你们后端返回字符串0和1,你需要转换成布尔值,使用"+"简直不要太爽
// isCritical 是字符串"0"或"1"
<van-icon v-if="+isCritical" color="#E68600"/>
或者处理表单输入时用
let inputValue = "42";
let value = +inputValue; // 将字符串转换为数字42
来源:juejin.cn/post/7402076531294863360
还在封装 xxxForm,xxxTable 残害你的同事?试试这个工具
之前写过一篇文章 我理想中的低代码开发工具的形态,已经吐槽了各种封装 xxxForm,xxxTable 的行为,这里就不啰嗦了。今天再来看看我的工具达到了什么程度。
多图预警。。。
以管理后台一个列表页为例
选择对应的模板
截图查询区域,使用 OCR 初始化查询表单的配置
截图表头,使用 OCR 初始化 table 的配置
使用 ChatGPT 翻译中文字段
生成代码
效果
目前我们没有写一行代码,就已经达到了如下的效果
下面是一部分生成的代码
import { reactive, ref } from 'vue'
import { IFetchTableListResult } from './api'
interface ITableListItem {
/**
* 决算单状态
*/
settlementStatus: string
/**
* 主合同编号
*/
mainContractNumber: string
/**
* 客户名称
*/
customerName: string
/**
* 客户手机号
*/
customerPhone: string
/**
* 房屋地址
*/
houseAddress: string
/**
* 工程管理
*/
projectManagement: string
/**
* 接口返回的数据,新增字段不需要改 ITableListItem 直接从这里取
*/
apiResult: IFetchTableListResult['result']['records'][0]
}
interface IFormData {
/**
* 决算单状态
*/
settlementStatus?: string
/**
* 主合同编号
*/
mainContractNumber?: string
/**
* 客户名称
*/
customerName?: string
/**
* 客户手机号
*/
customerPhone?: string
/**
* 工程管理
*/
projectManagement?: string
}
interface IOptionItem {
label: string
value: string
}
interface IOptions {
settlementStatus: IOptionItem[]
}
const defaultOptions: IOptions = {
settlementStatus: [],
}
export const defaultFormData: IFormData = {
settlementStatus: undefined,
mainContractNumber: undefined,
customerName: undefined,
customerPhone: undefined,
projectManagement: undefined,
}
export const useModel = () => {
const filterForm = reactive<IFormData>({ ...defaultFormData })
const options = reactive<IOptions>({ ...defaultOptions })
const tableList = ref<(ITableListItem & { _?: unknown })[]>([])
const pagination = reactive<{
page: number
pageSize: number
total: number
}>({
page: 1,
pageSize: 10,
total: 0,
})
const loading = reactive<{ list: boolean }>({
list: false,
})
return {
filterForm,
options,
tableList,
pagination,
loading,
}
}
export type Model = ReturnType<typeof useModel>
这就是用模板生成的好处,有规范,随时可以改,而封装 xxxForm,xxxTable 就是一个黑盒。
原理
下面大致说一下原理
首先是写好一个个模版,vscode 插件读取指定目录下模版显示到界面上
每个模版下可能包含如下内容:
选择模版后,进入动态表单配置界面
动态表单是读取 config/schema.json 里的内容进行动态渲染的,目前支持 amis、form-render、formily
配置表单是为了生成 JSON 数据,然后根据 JSON 数据生成代码。所以最终还是无法避免的使用私有的 DSL ,但是生成后的代码是没有私有 DSL 的痕迹的。生成代码本质是 JSON + EJS 模版引擎编译 src 目录下的 ejs 文件。
为了加快表单的配置,可以自定义脚本进行操作
这部分内容是读取 config/preview.json 内容进行显示的
选择对应的脚本方法后,插件会动态加载 script/index.js 脚本,并执行里面对应的方法
以 initColumnsFromImage 方法为例,这个方法是读取剪贴板里的图片,然后使用百度 OCR 解析出文本,再使用文本初始化表单
initColumnsFromImage: async (lowcodeContext) => {
context.lowcodeContext = lowcodeContext;
const res = await main.handleInitColumnsFromImage();
return res;
},
export async function handleInitColumnsFromImage() {
const { lowcodeContext } = context;
if (!lowcodeContext?.clipboardImage) {
window.showInformationMessage('剪贴板里没有截图');
return lowcodeContext?.model;
}
const ocrRes = await generalBasic({ image: lowcodeContext!.clipboardImage! });
env.clipboard.writeText(ocrRes.words_result.map((s) => s.words).join('\r\n'));
window.showInformationMessage('内容已经复制到剪贴板');
const columns = ocrRes.words_result.map((s) => ({
slot: false,
title: s.words,
dataIndex: s.words,
key: s.words,
}));
return { ...lowcodeContext.model, columns };
}
反正就是可以根据自己的需求定义各种各样的脚本。比如使用 ChatGPT 翻译 JSON 里的指定字段,可以看我的上一篇文章 TypeChat、JSONSchemaChat实战 - 让ChatGPT更听你的话
再比如要实现把中文翻译成英文,然后英文使用驼峰语法,这样就可以将中文转成英文代码变量,下面是实现的效果
选择对应的命令菜单后 vscode 插件会加载对应模版里的脚本,然后执行里面的 onSelect 方法。
main.ts 代码如下
import { env, window, Range } from 'vscode';
import { context } from './context';
export async function bootstrap() {
const clipboardText = await env.clipboard.readText();
const { selection, document } = window.activeTextEditor!;
const selectText = document.getText(selection).trim();
let content = await context.lowcodeContext!.createChatCompletion({
messages: [
{
role: 'system',
content: `你是一个翻译家,你的目标是把中文翻译成英文单词,请翻译时使用驼峰格式,小写字母开头,不要带翻译腔,而是要翻译得自然、流畅和地道,使用优美和高雅的表达方式。请翻译下面用户输入的内容`,
},
{
role: 'user',
content: selectText || clipboardText,
},
],
});
content = content.charAt(0).toLowerCase() + content.slice(1);
window.activeTextEditor?.edit((editBuilder) => {
if (window.activeTextEditor?.selection.isEmpty) {
editBuilder.insert(window.activeTextEditor.selection.start, content);
} else {
editBuilder.replace(
new Range(
window.activeTextEditor!.selection.start,
window.activeTextEditor!.selection.end,
),
content,
);
}
});
}
使用了 ChatGPT。
再来看看,之前生成管理后台 CURD 页面的时候,连 mock 也一起生成了,主要逻辑放在了 complete 方法里,这是插件的一个生命周期函数。
因为 mock 服务在另一个项目里,所以需要跨目录去生成代码,这里我在 mock 服务里加了个接口返回 mock 项目所在的目录
.get(`/mockProjectPath`, async (ctx, next) => {
ctx.body = {
status: 200,
msg: '',
result: __dirname,
};
})
生成代码的时候请求这个接口,就知道往哪个目录生成代码了
const mockProjectPathRes = await axios
.get('http://localhost:3001/mockProjectPath', { timeout: 1000 })
.catch(() => {
window.showInformationMessage(
'获取 mock 项目路径失败,跳过更新 mock 服务',
);
});
if (mockProjectPathRes?.data.result) {
const projectName = workspace.rootPath
?.replace(/\\/g, '/')
.split('/')
.pop();
const mockRouteFile = path.join(
mockProjectPathRes.data.result,
`${projectName}.js`,
);
let mockFileContent = `
import KoaRouter from 'koa-router';
import proxy from '../middleware/Proxy';
import { delay } from '../lib/util';
const Mock = require('mockjs');
const { Random } = Mock;
const router = new KoaRouter();
router{{mockScript}}
module.exports = router;
`;
if (fs.existsSync(mockRouteFile)) {
mockFileContent = fs.readFileSync(mockRouteFile).toString().toString();
const index = mockFileContent.lastIndexOf(')') + 1;
mockFileContent = `${mockFileContent.substring(
0,
index,
)}{{mockScript}}\n${mockFileContent.substring(index)}`;
}
mockFileContent = mockFileContent.replace(/{{mockScript}}/g, mockScript);
fs.writeFileSync(mockRouteFile, mockFileContent);
try {
execa.sync('node', [
path.join(
mockProjectPathRes.data.result
.replace(/\\/g, '/')
.replace('/src/routes', ''),
'/node_modules/eslint/bin/eslint.js',
),
mockRouteFile,
'--resolve-plugins-relative-to',
mockProjectPathRes.data.result
.replace(/\\/g, '/')
.replace('/src/routes', ''),
'--fix',
]);
} catch (err) {
console.log(err);
}
mock 项目也可以通过 vscode 插件快速创建和使用
上面展示的模版都放在了 github.com/lowcode-sca… 仓库里,照着 README 步骤做就可以使用了。
来源:juejin.cn/post/7315242945454735414
领导:你加的水印怎么还能被删掉的,扣工资!
故事是这样的
领导:小李,你加的水印怎么还能被删掉的?这可是关乎公司信息安全的大事!这种疏忽怎么能不扣工资呢?
小李:领导,请您听我解释一下!我确实按照常规的方法加了水印,可是……
领导:(打断)但是什么?难道这就是你对公司资料的保护吗?
小李:我也不明白,按理说水印是无法删除的,我会再仔细检查一下……
领导:我不能容忍这样的失误。这种安全隐患严重影响了我们的机密性。
小李焦虑地试图解释,但领导的目光如同刀剑一般锐利。他决定,这次一定要找到解决方法,否则,这将是一场职场危机……
水印组件
小李想到antd中有现成的水印组件,便去研究了一下。即使删掉了水印div,水印依然存在,因为瞬间又生成了一个相同的水印div。他一瞬间想到了解决方案,并开始了重构水印组件。
原始代码
//app.vue
<template>
<div>
<Watermark text="前端百事通">
<div class="content"></div>
</Watermark>
</div>
</template>
<script setup>
import Watermark from './components/Watermark.vue';
</script>
<style scoped>
.content{
width: 400px;
height: 400px;
background-color: aquamarine;
}
</style>
//watermark.vue
<template>
<div ref="watermarkRef" class="watermark-container">
<slot>
</slot>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
const watermarkRef=ref(null)
const props = defineProps({
text: {
type: String,
default: '前端百事通'
},
fontSize: {
type: Number,
default: 14
},
gap: {
type: Number,
default: 50
},
rotate: {
type: Number,
default: 45
}
})
onMounted(() => {
addWatermark()
})
const addWatermark = () => {
const { rotate, gap, text, fontSize } = props
const color = 'rgba(0, 0, 0, 0.3)'; // 可以从props中传入
const watermarkContainer = watermarkRef.value;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const font=fontSize+'px DejaVu Sans Mono'
// 设置水印文字的宽度和高度
const metrics = context.measureText(text);
const canvasWidth=metrics.width+gap
canvas.width=canvasWidth
canvas.height=canvasWidth
// 绘制水印文字
context.translate(canvas.width/2,canvas.height/2)
context.rotate((-1 * rotate * Math.PI / 180));
context.fillStyle = color;
context.font=font
context.textAlign='center'
context.textBaseline='middle'
context.fillText(text,0,0)
// 将canvas转为图片
const url = canvas.toDataURL('image/png');
// 创建水印元素并添加到容器中
const watermarkLayer = document.createElement('div');
watermarkLayer.style.position = 'absolute';
watermarkLayer.style.top = '0';
watermarkLayer.style.left = '0';
watermarkLayer.style.width = '100%';
watermarkLayer.style.height = '100%';
watermarkLayer.style.pointerEvents = 'none';
watermarkLayer.style.backgroundImage = `url(${url})`;
watermarkLayer.style.backgroundRepeat = 'repeat';
watermarkLayer.style.zIndex = '9999';
watermarkContainer.appendChild(watermarkLayer);
}
</script>
<style>
.watermark-container {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
}
</style>
防篡改思路
- 监听删除dom操作,在删除dom操作的瞬间重新生成一个相同的dom元素
- 监听修改dom样式操作
- 不能使用onMounted,改为watchEffect进行监听操作
使用MutationObserver监听整个区域
let ob
onMounted(() => {
ob=new MutationObserver((records)=>{
console.log(records)
})
ob.observe(watermarkRef.value,{
childList:true,
attributes:true,
subtree:true
})
})
onUnmounted(()=>{
ob.disconnect()
})
在删除水印div之后,打印一下看看records是什么。
在修改div样式之后,打印一下records
很明显,如果是删除,我们就关注removedNodes字段,如果是修改,我们就关注attributeName字段。
onMounted(() => {
ob=new MutationObserver((records)=>{
for(let item of records){
//监听删除
for(let ele of item.removedNodes){
if(ele===watermarkDiv){
generateFlag.value=!generateFlag.value
return
}
}
//监听修改
if(item.attributeName==='style'){
generateFlag.value=!generateFlag.value
return
}
}
})
ob.observe(watermarkRef.value,{
childList:true,
attributes:true,
subtree:true
})
})
watchEffect(() => {
//generateFlag的用处是让watchEffect收集这个依赖
//通过改变generateFlag的值来重新调用生成水印的函数
generateFlag.value
if(watermarkRef.value){
addWatermark()
}
})
最终,小李向领导展示了新的水印组件,取得了领导的认可和赞许,保住了工资。
全剧终。
文章同步发表于前端百事通公众号,欢迎关注!
来源:juejin.cn/post/7362309246556356647
终于搞懂类型声明文件.d.ts和declare了,原来用处如此大
项目中的.d.ts和declare
最近开发项目,发现公司代码里都有一些.d.ts后缀的文件
还有一些奇奇怪怪的declare代码
秉持着虚心学习的态度,我向同事请教了这些知识点,发现这些东西居然蛮重要的。于是,我根据自己的理解,把这些知识简单总结一下。
类型声明文件.d.ts
为什么需要 .d.ts
文件?
如果我们在ts项目中使用第三方库时,如果这个库内置类型声明文件.d.ts,我们在写代码时可以获得对应的代码补全、接口提示等功能。
比如,我们在index.ts中使用aixos时:
当我们引入axios时,ts会检索aixos的package.json文件,并通过其types属性查找类型声明文件,查找到index.d.ts这个文件后,就会根据其内部配置进行语法提示。
但是如果某个库没有内置类型声明文件时,我们使用这个库,不会获得Ts的语法提示,甚至会有类型报错警告
像这种没有内置类型声明文件的,我们就可以自己创建一个xx.d.ts的文件来自己声明,ts会自动读取到这个文件里面的内容的。比如,我们在index.ts中使用"vue-drag",会提示缺少声明文件。
由于这个库没有@types/xxxx声明包,因此,我们可以在项目内自定义一个vueDrag.d.ts声明文件。
// vueDrag.d.ts
declare module 'vue-drag'
这个时候,就不会报错了,没什么警告了。
第三方库的默认类型声明文件
当我们引入第三方库时,ts会自动检索aixos的package.json文件,并通过其types属性查找类型声明文件,查找到index.d.ts这个文件后,就会根据其内部配置进行语法提示。比如,我们刚才说的axios
- "typings"与"types"具有相同的意义,也可以使用它。
- 主声明文件名是index.d.ts并且位置在包的根目录里(与index.js并列),你就不需要使用"types"属性指定了。
第三方库的@types/xxxx类型声明文件
如express这类框架,它们的开发时Ts还没有流行,自然没有使用Ts进行开发,也自然不会有ts的类型声明文件。如果你想引入它们时也获得Ts的语法提示,就需要引入它们对应的声明文件npm包了。
使用声明文件包,不用重构原来的代码就可以在引入这些库时获得Ts的语法提示
比如,我们安装express对应的声明文件包后,就可以获得相应的语法提示了。
npm i --save-dev @types/express
@types/express包内的声明文件
.d.ts声明文件
通过上述的几个示例,我们可以知道.d.ts文件的作用和@types/xxxx包一致,@type/xxx需要下载使用,而.d.ts是我们自己创建在项目内的。
.d.ts文件除了可以声明模块,也可以用来声明变量。
例如,我们有一个简单的 JavaScript 函数,用于计算两个数字的总和:
// math.js
const sum = (a, b) => a + b
export { sum }
TypeScript 没有关于函数的任何信息,包括名称、参数类型。为了在 TypeScript 文件中使用该函数,我们在 d.ts 文件中提供其定义:
// math.d.ts
declare function sum(a: number, b: number): number
现在,我们可以在 TypeScript 中使用该函数,而不会出现任何编译错误。
.ts 是标准的 TypeScript 文件。其内容将被编译为 JavaScript。
*.d.ts 是允许在 TypeScript 中使用现有 JavaScript 代码的类型定义文件,其不会编译为 JavaScript。
shims-vue.d.ts
shims-vue.d.ts
文件的主要作用是声明 Vue 文件的模块类型,使得 TypeScript 能够正确地处理 .vue
文件,并且不再报错。通常这个文件会放在项目的根目录或 src
目录中。
shims-vue.d.ts
文件的内容一般长这样:
// shims-vue.d.ts
declare module '*.vue' {
import { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module '*.vue'
: 这行代码声明了一个模块,匹配所有以.vue
结尾的文件。*
是通配符,表示任意文件名。import { DefineComponent } from 'vue';
: 引入 Vue 的DefineComponent
类型。这是 Vue 3 中定义组件的类型,它具有良好的类型推断和检查功能。const component: DefineComponent<{}, {}, any>;
: 定义一个常量component
,它的类型是DefineComponent
,并且泛型参数设置为{}
表示没有 props 和 methods 的基本 Vue 组件类型。any
用来宽泛地表示组件的任意状态。export default component;
: 将这个组件类型默认导出。这样,当你在 TypeScript 文件中导入.vue
文件时,TypeScript 就知道导入的内容是一个 Vue 组件。
declare
.d.ts 文件中的顶级声明必须以 “declare” 或 “export” 修饰符开头。
通过declare声明的类型或者变量或者模块,在include包含的文件范围内,都可以直接引用而不用去import或者import type相应的变量或者类型。
- declare声明一个类型
declare type Asd {
name: string;
}
- declare声明一个模块
declare module '*.css';
declare module '*.less';
declare module '*.png';
.d.ts文件顶级声明declare最好不要跟export同级使用,不然在其他ts引用这个.d.ts的内容的时候,就需要手动import导入了
在.d.ts文件里如果顶级声明不用export的话,declare和直接写type、interface效果是一样的,在其他地方都可以直接引用
declare type Ass = {
a: string;
}
type Bss = {
b: string;
};
来源:juejin.cn/post/7402891257196691468
前端:金额高精度处理
Decimal 是什么
想必大家在用js 处理 数字的 加减乘除的时候,或许都有遇到过 精度不够 的问题,还有那些经典的面试题 0.2+0.1 !== 0.3,
至于原因,那就是 js 计算底层用的是 IEEE 754 ,精度上有限制,
那么Decimal.js 就是帮助我们解决 js中的精度失准的问题。
原理
- 它的原理就是将数字用字符串表示,字符串在计算机中可以说是无限的。
- 并使用基于字符串的算术运算,以避免浮点数运算中的精度丢失。它使用了一种叫做十进制浮点数算术(Decimal Floating Point Arithmetic)的算法来进行精确计算。
- 具体来说,decimal.js库将数字表示为一个字符串,其中包含整数部分、小数部分和一些其他的元数据。它提供了一系列的方法和运算符,用于执行精确的加减乘除、取模、幂运算等操作。
精度丢失用例
const a = 31181.82
const b = 50090.91
console.log(a+b) //81272.73000000001
Decimal 的引入 与 加减乘除
- 如何引入
npm install --save decimal.js // 安装
import Decimal from "decimal.js" // 具体文件中引入
- 加
let a = 1
let b = 6
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new(推荐带new)
let res = new Decimal(a).add(new Decimal(b))
let res = Decimal(a).add(Decimal(b))
- 减
let a = "4"
let b = "8"
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new
let res = new Decimal(a).sub(new Decimal(b))
let res = Decimal(a).sub(Decimal(b))
- 乘
let a = 1
let b = 6
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new
let res = new Decimal(a).mul(new Decimal(b))
let res = Decimal(a).mul(Decimal(b))
- 除
let a = 1
let b = 6
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new
let res = new Decimal(a).div(new Decimal(b))
let res = Decimal(a).div(Decimal(b))
注意
上面的结果是 一个 Decimal 对象,你可以转换成 Number 或则 String
let res = Decimal(a).div(Decimal(b)).toNumber() // 结果转换成 Number
let res = Decimal(a).div(Decimal(b)).toString() // 结果转换成 String
关于保存几位小数相关
//查看有几位小数 (注意不计算 小数点 最后 末尾 的 0)
y = new Decimal(987000.000)
y.sd() // '3' 有效位数
y.sd(true) // '6' 总共位数
// 保留 多少个位数 (小数位 会补0)
x = 45.6
x.toPrecision(5) // '45.600'
// 保留 多少位有效位数(小数位 不会补0,是计算的有效位数)
x = new Decimal(9876.5)
x.toSignificantDigits(6) // '9876.5' 不会补0 只是针对有效位数
// 保留几位小数 , 跟 js 中的 number 一样
toFixed
x = 3.456
// 向下取整
x.toFixed(2, Decimal.ROUND_DOWN) // '3.45' (舍入模式 向上0 向下1 四舍五入 4,7)
// 向上取整
Decimal.ROUND_UP
//四舍五入
ROUND_HALF_UP //(主要)
// 使用例子
let num2 = 0.2
let num3 = 0.1
let res = new Decimal(num2).add(new Decimal(num3)).toFixed(2, Decimal.ROUND_HALF_UP)
console.log(res); //返回值是字符串类型
超过 javascript 允许的数字
如果使用超过 javascript 允许的数字的值,建议传递字符串而不是数字,以避免潜在的精度损失。
new Decimal(1.0000000000000001); // '1'
new Decimal(88259496234518.57); // '88259496234518.56'
new Decimal(99999999999999999999); // '100000000000000000000'
new Decimal(2e308); // 'Infinity'
new Decimal(1e-324); // '0'
new Decimal(0.7 + 0.1); // '0.7999999999999999'
可读性
与 JavaScript 数字一样,字符串可以包含下划线作为分隔符以提高可读性。
x = new Decimal("2_147_483_647");
其它进制的数字
如果包含适当的前缀,则也接受二进制、十六进制或八进制表示法的字符串值。
x = new Decimal("0xff.f"); // '255.9375'
y = new Decimal("0b10101100"); // '172'
z = x.plus(y); // '427.9375'
z.toBinary(); // '0b110101011.1111'
z.toBinary(13); // '0b1.101010111111p+8'
x = new Decimal(
"0b1.1111111111111111111111111111111111111111111111111111p+1023"
);
// '1.7976931348623157081e+308'
最后:希望本篇文章能帮到您!
来源:juejin.cn/post/7405153695507234867
视差滚动效果实现
视差滚动是一种在网页设计和视频游戏中常见的视觉效果技术,它通过在不同速度上移动页面或屏幕上的多层图像,创造出深度感和动感。
这种效果通过前景、中景和背景以不同的速度移动来实现,使得近处的对象看起来移动得更快,而远处的对象移动得较慢。
在官网中适当的使用视差效果,可以增加视觉吸引力,提高用户的参与度,从而提升网站和品牌的形象。本文通过JavaScript、CSS多种方式并在React框架下进行了视差效果的实现,供你参考指正。
实现方式
1、background-attachment
通过配置该 CSS 属性值为fixed
可以达到背景图像的位置相对于视口固定,其他元素正常滚动的效果。但该方法的视觉表现单一,没有纵深,缺少动感。
.parallax-box {
width: 100%;
height: 100vh;
background-image: url("https://picsum.photos/800");
background-size: cover;
background-attachment: fixed;
display: flex;
justify-content: center;
align-items: center;
}
2、Transform 3D
在 CSS 中使用 3D 变换效果,通过将元素划分至不同的纵深层级,在滚动时相对视口不同距离的元素,滚动所产生的位移在视觉上就会呈现越近的元素滚动速度越快,相反越远的元素滚动速度就越慢。
为方便理解,你可以想象正开车行驶在公路上,汽车向前移动,你转头看向窗外,近处的树木一闪而过,远方的群山和风景慢慢的渐行渐远,逐渐的在视野中消失,而天边的太阳却只会在很长的一段距离细微的移动。
.parallax {
perspective: 1px; /* 设置透视效果,为3D变换创造深度感 */
overflow-x: hidden;
overflow-y: auto;
height: 100vh;
}
.parallax__group {
transform-style: preserve-3d; /* 保留子元素3D变换效果 */
position: relative;
height: 100vh;
}
.parallax__layer {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
}
/* 背景层样式,设置为最远的层 */
.parallax__layer--back {
transform: translateZ(-2px) scale(3);
z-index: 1;
}
/* 中间层样式,设置为中等距离的层 */
.parallax__layer--base {
transform: translateZ(-1px) scale(2);
z-index: 2;
}
/* 前景层样式,设置为最近的层 */
.parallax__layer--front {
transform: translateZ(0px);
z-index: 3;
}
实现原理
通过设置 perspective 属性,为整个容器创建一个 3D 空间。
使用 transform-style: preserve-3d 保持子元素的 3D 变换效果。
将内容分为多个层(背景、中间、前景),使用 translateZ() 将它们放置在 3D 空间的不同深度。
对于较远的层(如背景层),使用 scale() 进行放大,以补偿由于距离产生的视觉缩小效果。
当用户滚动页面时,由于各层位于不同的 Z 轴位置,它们会以不同的速度移动,从而产生视差效果。
3、ReactScrollParallax
想得到更炫酷的滚动视差效果,纯 CSS 的实现方式就会有些吃力。
如下是在 React 中实现示例,通过监听滚动事件,封装统一的视差组件,来达到多样的动画效果。
const Parallax = ({ children, effects = [], speed = 1, style = {} }) => {
// 状态hooks:用于存储动画效果的当前值
const [transform, setTransform] = useState("");
useEffect(() => {
if (!Array.isArray(effects) || effects.length === 0) {
console.warn("ParallaxElement: effects should be a non-empty array");
return;
}
const handleScroll = () => {
// 计算滚动进度
const scrollProgress =
(window.scrollY /
(document.documentElement.scrollHeight - window.innerHeight)) *
speed;
let transformString = "";
// 处理每个效果
effects.forEach((effect) => {
const { property, startValue, endValue, unit = "" } = effect;
const value =
startValue +
(endValue - startValue) * Math.min(Math.max(scrollProgress, 0), 1);
switch (property) {
case "translateX":
case "translateY":
transformString += `${property}(${value}${unit}) `;
break;
case "scale":
transformString += `scale(${value}) `;
break;
case "rotate":
transformString += `rotate(${value}${unit}) `;
break;
// 更多的动画效果...
default:
console.warn(`Unsupported effect property: ${property}`);
}
});
// 更新状态
setTransform(transformString);
};
window.addEventListener("scroll", handleScroll);
// 初始化位置
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [effects, speed]);
// 渲染带有计算样式的子元素
return <div style={{ ...style, transform }}>{children}</div>;
};
在此基础上你可以添加缓动函数
使动画效果更加平滑;以及使用requestAnimationFrame
获得更高的动画性能。
requestAnimationFrame 带来的性能提升
同步浏览器渲染周期:requestAnimationFrame 会在浏览器下一次重绘之前调用指定的回调函数。这确保了动画更新与浏览器的渲染周期同步,从而产生更流畑的动画效果。
提高性能:与使用 setInterval 或 setTimeout 相比,requestAnimationFrame 可以更高效地管理动画。它只在浏览器准备好进行下一次重绘时才会执行,避免了不必要的计算和重绘。
优化电池使用:在不可见的标签页或最小化的窗口中,requestAnimationFrame 会自动暂停,这可以节省 CPU 周期和电池寿命。
适应显示器刷新率:requestAnimationFrame 会自动适应显示器的刷新率。这意味着在 60Hz、120Hz 或其他刷新率的显示器上,动画都能保持流畑。
避免丢帧:由于与浏览器的渲染周期同步,使用 requestAnimationFrame 可以减少丢帧现象,特别是在高负荷情况下。
更精确的时间控制:requestAnimationFrame 提供了一个时间戳参数,允许更精确地控制动画的时间。
4、组件库方案
在当前成熟的前端生态中,想要获得精彩的视差动画效果,你可以通过现有的开源组件库来高效的完成开发。
以下是一些你可以尝试的主流组件库:
引用参考
How to create parallax scrolling with CSS
来源:juejin.cn/post/7406161967617163301
阶层必然会分化,但维度不只有金钱
前言
Hi 你好,我是东东拿铁,一个在“玩游戏”的后端程序员。
先问大家一个问题,如果阶层分化是必然的,你还会有玩下去人生这个“无限游戏”的动力吗?
让我们从一个游戏说起。
1996年,通过计算机建模理解社会演化的思潮在学术界正兴,美国布鲁金斯学会的艾伯斯坦和阿克斯特尔设计了一个关于财富分配的游戏,命名为“糖人世界”(Sugarscape)。
他们设计出一个模拟的地形图,深色区域含糖量高,浅色区域含糖量少,而白色区域则不产糖,对应资源富裕区、有限区、贫困区和沙漠区。
糖在被吃掉以后过一段时间会再长出来。然后他们会随机丢一些小糖人上去——这些小糖人遵循几个简单规则:
- 看四周6个方格,找到含糖量最高的区域,移动过去吃糖;
- 每天会消耗一定的糖(新陈代谢),如果消耗大于产出,则会死掉出局;
- 每个糖人的天赋、视力和新陈代谢是随机的。有人天生视力好,别人看1格,自己看4格,比较占优势;有人则比别人消耗少,别人每天消耗2格,他只要1格,可理解为体力好。还有一些天生富二代,携带更多糖出生。
一开始的时候,大家都差不多,最富裕的24个人有10块糖;但跑着跑着,不均衡开始出现。在第189回合以后,贫富差距出现了,最富裕的2人有225块糖,而有131个人只有1块。
注:横轴为财富数,纵轴为人数。
具体细节大家可以自行了解,但游戏告诉我们一个结论:在一个流动、开放的社会里,阶层分化是稳定且可预期的。
游戏如此,现实世界也如此。
阶级分化
我看过一部分书,比如《皮囊》、《活着》、《许三观卖血记》、《兄弟》,也听过很多耳熟能详的作品比如《平凡的世界》、《人世间》,我特别喜欢类似的作品。
虽然自己出生在城市,生活的时代早已和历史上的时代有所不同,也不需要为物质、精神需求所忧虑,但在某些时候,却又能感同身受。
因为这些优秀的作品背后有一个共性,就是关注普通人的生活与命运。
投射现实,我们大概率属于拥有更少糖的那部分小糖人。
比如,出生时拥有的糖不够多,又或者生在离糖山更远的地方。
当然,现实生活比游戏复杂的多,但我们能做的事情,也比小糖人能做的多。
比如
- 小糖人无法学习,我们可以
- 小糖人走到糖山,需要走很多步,但是我们有网络,交通工具
- 游戏中的糖山,是不会移动的,但是我们时代和机会是在不断变化的
所以,阶层固化是必然的,但是个体的命运却不是。
阶级分化,到底是分化了什么。
我之前肤浅的认为,阶级分化,无非就是财富的分化。
我有一个很有钱的阿姨,她的女儿比我小一些,之前一起吃饭的时候,聊起买衣服的话题。有一个服装品牌叫“优衣库”,大家应该都听过。经济实惠,质量虽然赶不上大品牌,但也还可以,我现在去商场也会习惯性的去优衣库逛逛。
有一次一起吃饭,我记着我们聊到优衣库的时候,她面露难色的说了一句:“优衣库的衣服能穿吗。”
毕竟平常接触到的非常有钱的人的机会不多,即使十年过去了,这件事给正在上学的我留下的印象非常深刻。
毕竟我们就是要让一部分人先富起来,先富带动后富,所以分化就是财富的分化。
心智
最近在看古典老师的书《跃迁》,这本书里举的一个例子,让我对分化,有了不一样的看法。
原文如下:
我在当GRE老师的时候,曾经教过收费很高的一对一英语私教班,学生家长一般分成两种:一种是真的很有钱,不在乎钱的家长;有一些则是收入中等,希望孩子有出息,一咬牙花大价钱的家长。对于前者,我倾尽全力让自己对得起这个价格;对于后者,我则更加苦口婆心,小心谨慎,偶尔还开个小灶,我知道这些钱对这个家庭意味着什么。
每次,我都会跟接孩子的家长聊聊孩子的学习进度。我会说“你们家孩子词汇量还不够,要把这6000词汇尽快背完,然后阅读分才会好。”这个时候,我经常收到两种回答。那些中产阶层家庭的家长会说:“听到没有!要听古典老师的话!回去好好背,好不好?”孩子温顺地点头。而有些真正聪明的家长则会笑着说:“古典老师,我们家孩子就是不爱背单词,但是他喜欢阅读。我们进度不需要那么赶,你能不能陪他多读点儿有趣的英文书?”后面这种回答,震撼了我。
这段描述给我了非常大的冲击,毕竟教育是每一个人都会经历的,我不禁回忆起了我上学的时候,想想看,如果当我们遇到单词量不够的时候,父母怎么面对这个问题,我们又是如何面对这个问题呢?
无论是初中、高中、还是大学,在我想提高单词量的时候,我会选择打开常用词汇的小册子,打开当时很流行的什么“百词斩”等app,开始背单词。
父母应该会问,单词背的怎么样了,老师布置的任务都完成了吗?
所以最后我印象最深的词汇就是“abandon”。
我从初中开始便开始住校,直到大学毕业,住校的时间足足有十年。初中、高中寄宿制的生活,接受的是军事化、填鸭式的教育,我们只管上课、做题,如果你表示不满,老师就会让你去操场跑几圈。
直到近几年我才明白,我才知道住校期间的那段学习,有多么的低效。
现在我也当了父亲,有了孩子后,我时常在考虑,相比于我们这一代的放养式教育,我应该如何把我学会的技巧、方法和道理传递给孩子,让他更高效的成长。
比如我教会他100种背单词的技巧,告诉他艾宾浩斯遗忘曲线记忆法?还是便利贴贴满冰箱让他实时看到,利用碎片化时间记忆?还是让他像我一样借助一些APP辅助记忆?
在我眼里这些技巧确实是对的,也都是有一定效果的,但是孩子真的能够听进去,学会这些吗?我总是感觉到,这并不是父母能做到最好的。
我之前肤浅的认为,阶级分化,无非就是财富的分化,毕竟我们就是要让一部分人先富起来,带动后富,所以分化的就是分化的财富。
而古典老师的这个例子,让我看到了不同阶级的家庭,对于背单词这件事情不一样的选择。
前者,被动接受,按部就班,最后成长成为一个优秀的员工。
后者,主动选择,试图寻找最高效的方式,创造一些传统教育教不会的学习思维。
父母在做,孩子在看。
教育与学习,不同家庭会有如此大的差异,那么个人成长呢,工作、甚至人生选择呢,不同的思维又会对我们产生多大的影响呢?
看到这里,你是否对阶级分化有了不一样的看法,我发现分化的不仅是财富,还有心智。
影响心智的因素
是什么导致了心智的分化?看了许多书,也看过很多大佬给出的建议和方法,我想从两个方面来聊聊,一个是自控力,一个是思维带宽。
自控力
先从自控力说起,自控力是什么,《自控力》中是这样定义的:
自控力是控制自己的注意力、情绪和欲望的能力,由 “我要做”“我不要”“我想要” 这三种力量组成。
我要做:是为了更好的未来做自己不喜欢的事情的能力,比如坚持学习、完成工作任务等;
我不要:是即使面对诱惑也能说 “不” 的能力,像抵制美食、视频的诱惑等;
我想要:是记住自己真正想要的东西的能力,它能让我们在面对短期诱惑时,不忘长期目标。
你如果白天面临较多需要自控的事情,比如美食、工作、阅读等,下班回到家一旦自控力耗尽,就很容易放纵,开始打游戏、刷视频。
一次放纵对于富人来说也许不算是损失,对于穷人来说则会浪费很多宝贵的机会。
普通人并非不懂得延迟满足,只是他们对自己延迟满足的肌肉的操控力,早就被诱惑消耗得所剩无几。
刚刚在我写着这篇文章,不自觉地拿起了手机刷小红书,幸亏写的是这个主题,我及时的意识到又把我拉了回来。
但是,《自控力》书中还提到了“自控力肌肉”这个概念,的角度解释过这个问题。自控力如肌肉,用多了会疲劳。
说一个最近的例子,上周孩子睡觉一直很晚,几乎要到晚上十点多才能入睡,为了保证阅读时间,晚上收拾完看书,睡觉的时候几乎都在十二点以后了。
但实际上,我能够看书的时间依然很少,我总会用“我都这么晚看书了,不如先放松一下”的心态,开始去刷视频或者一些别的东西,然而回过神来,一个小时就过去了。
思维带宽
《稀缺》这本书中提出了“思维带宽”的概念,思维带宽是指心智的容量,它由认知能力和执行控制力构成。
当你处于稀缺状态时,无论是时间稀缺、金钱稀缺还是其他资源稀缺,你的注意力会高度集中在稀缺的事物上,这就导致心智容量被过度占据。
这种情况下,你用于处理其他事务的认知能力和执行控制力就会减弱。
例如,普通人可能会因过于关注当下的金钱问题,而在做决策时忽视了对未来的投资和规划;工作忙碌的人可能因专注于眼前的紧急任务,而忽略了重要但不紧急的事情,如锻炼身体、陪伴家人等。
如果总想着怎么换个大房子,怎么换个好车子,哪里有时间思考什么个人发展、儿女教育呢?发展战略显然才是核心。如果说贫穷是一种“思维带宽”的稀缺,注意力资源就变得非常重要,提升认知时大部分都是反人性的,需要巨大的带宽。
所以,如果你注意力稀缺,即使你知道要做些什么,也会陷入战术勤奋、战略懒惰的困局。
在《贫穷的本质》这本书里,作者阿比吉特和埃斯特观察到很多捐赠者的本意,是希望穷人将捐款用在教育、健康上,实际却往往被花在了消费品、奢侈品上,因为穷人和富人处于不同的自控力和心智资源层面。
如果把这种根据社会学尺度观察到的贫富现象平移到我们身边,就会发现,其实贫穷早就不是一个财富数字,而是一种稀缺的心理状态。
正视自控力,提升思维带宽
幸运的是,上面的两种能力,都是可以通过训练来提升的。
对于自控力,我们可以通过如下方式训练我们的自控力肌肉:
- 训练大脑,比如冥想、深呼吸等方式
- 调整生活方式,比如充足的睡眠、健康的饮食、充足的锻炼
- 改变思维方式,比如接纳自己的情绪,而不是压抑。养成成长型思维等
甚至,我们还可以借助一些工具,比如Flora等。
对于思维带宽,我们也有不少方法,避免稀缺,提升我们的思维带宽
- 进行时间规划,避免紧急重要的任务投入太多时间。对重要不紧急的事情,每周固定留出一部分时间,避免因为时间带来的负担
- 对于支出制定预算,明确收支范围,避免因为财务紧张陷入金钱稀缺的状态
- 学会留白,拒绝不重要的活动、聚会,而是什么都不做,让自己自由思考、放松等,或者是拒绝一份996的工作
- 培养正确的认知,认识到稀缺对于我们心态的影响,当真的面临时间、金钱的压力时,提醒自己不要陷入狭隘的视角,从更广阔的角度去看问题
方法很多,就不一一列举了。
说在最后
好了,文章到这里就要结束了,感谢你能看到最后。
从看到小糖人游戏带给我的结论时,我先是感到无奈与不甘,因为这个结论,对于一个普通人来说,实在是太让人失望了。
洋洋洒洒这么多,还是想给自己泼一盆冷水,那便是我们即使不断的提升我们的心智,我们大大大概率也不能够拥有完全足够的财富。
我只能说,通过心智的不断提升,即使财富无法追上那20%的人们,也可以在思维、心智上完成跃迁。只有这样,财富或许才会到来。
因为相信,所以看见,让我们继续玩人生这个无限游戏吧。
来源:juejin.cn/post/7405889205476032552
为什么vue:deep、/deep/、>>>样式能穿透到子组件
为什么vue:deep、/deep/、>>>样式能穿透到子组件
在scoped标记的style中,只要涉及三方组件,那deep符号会经常被使用,用来修改外部组件的样式。
小试牛刀
不使用deep
要想修改三方组件样式,只能添加到scoped之外,弊端是污染了全局样式,后续可能出现样式冲突。
<style lang="less">
.container {
.el-button {
background: #777;
}
}
使用 /deep/ deprecated
.container1 {
/deep/ .el-button {
background: #000;
}
}
使用 >>> deprecated
.container2 >>> .el-button {
background: #222;
}
当在vue3使用/deep/
或者>>>
、::v-deep
,console面板会打印警告信息:
the >>> and /deep/ combinators have been deprecated. Use :deep() instead.
由于/deep/
或者>>>
在less或者scss中存在兼容问题,所以不推荐使用了。
使用:deep
.container3 {
:deep(.el-button) {
background: #444;
}
}
那么问题来了,如果我按以下的方式嵌套deep,能生效吗?
.container4 {
:deep(.el-button) {
:deep(.el-icon) {
color: #f00;
}
}
}
源码解析
/deep/或>>>会被编译为什么
编译后的代码为:
.no-deep .container1[data-v-f5dea59b] .el-button { background: #000; }
源代码片段:
if (
n.type === 'combinator' &&
(n.value === '>>>' || n.value === '/deep/')
) {
n.value = ' '
n.spaces.before = n.spaces.after = ''
warn(
`the >>> and /deep/ combinators have been deprecated. ` +
`Use :deep() instead.`,
)
return false
}
当vue编译样式时,先将样式解析为AST对象,例如deep/ .el-button
会被解析为Selector对象,/deep/ .el-button
解析后生成的Selector包含的字段:
{ type: 'combinator', value: '/deep/' }
然后将n.value由/deep/
替换为空
。所以转换出来的结果,.el-button直接变为.container
下的子样式。
:deep会被编译为什么?
编译后的代码:
.no-deep .container3[data-v-f5dea59b] .el-button { background: #444; }
源代码片段:
// .foo :v-deep(.bar) -> .foo[xxxxxxx] .bar
let last: selectorParser.Selector['nodes'][0] = n
n.nodes[0].each(ss => {
selector.insertAfter(last, ss)
last = ss
})
// insert a space combinator before if it doesn't already have one
const prev = selector.at(selector.index(n) - 1)
if (!prev || !isSpaceCombinator(prev)) {
selector.insertAfter(
n,
selectorParser.combinator({
value: ' ',
}),
)
}
selector.removeChild(n)
还是以.container4 :deep(.el-button)
为例,当解析到:deep符号式,selector快照为
parent为.container4 :deep(.el-button)
,当前selector的type正好为伪类标识pseudo
,nodes节点包含一个.el-button
。
经过递归遍历,生成的selector结构为.container4 :deep(.el-button).el-button
,
最后一行代码selector.removeChild(n)
会将:deep(.el-button)
移出,所以输出的最终样式为.container4 .el-button
。
如果样式为:deep(.el-button) { :deep(.el-icon) { color: #f00 } }
,当遍历.el-icon时找不到ancestor,所以直接将:deep(.el-icon)
作为其icon时找不到ancestor,其结果为:
.no-deep .container4[data-v-f5dea59b] .el-button :deep(.el-icon) { color: #f00; }
因此,deep是不支持嵌套的。
结尾
插个广告,麻烦各位大佬为小弟开源项目标个⭐️,点点关注:
- react-native-mapa, react native地图组件库
- mapboxgl-syncto-any,三维地图双屏联动
来源:juejin.cn/post/7397285315822632997
前端经理岗的面试技巧
本人最早是2017年,开始在上市公司作为高级经理,管理大前端团队,包括安卓、iOS和前端工程师,但这么多年过去了,我的管理工作还有很多需要改进的地方。
不管我是继续创业,还是去上班,我都需要考虑前端团队管理的事情。所以,本文我将结合过去多年的前端管理实践和面试经历,分享我对前端管理的思考、前端管理的高频面试题和一些学习资料。
关于前端经理岗的思考
在我成为程序员之前,我是北京军区38集团军某部的指挥军官,在部队最多时管理过130多人,在聚焦谈前端经理岗之前,我先放大聊一下管理。
什么是管理
管理是一个多维度的过程,既有对项目的管理,也有对人员的管理,还有向上管理、同级管理、自我管理和向下管理。
对于前端经理岗的管理,需要以人员管理为主,但也要以项目管理为本,而前端架构岗,则以项目管理为主,但需兼顾人员管理。
狭义的管理是指一定组织中的管理者,通过计划、组织、领导、协调和控制等手段,对组织所拥有的资源进行有效的整合和利用,以达成组织既定目标的过程。
技术团队管理的方法论
技术团队的管理是指运用一系列的管理原则和实践,对技术人员进行组织、指导和协调,以实现团队目标的过程。
技术团队管理的关键在于平衡技术发展、团队建设和业务需求之间的关系,确保团队的高效运作和创新能力的持续发展。
通过询问国内几个主流的大模型和查阅相关的资料,我最推荐的是一个美团同事在团队管理方面的总结的方法论。
他不仅在IGT、腾讯和新美大工作期间,参加了各种培训和大佬分享,平时还阅读了二十多本团队管理有关的书籍,并且在美团也做了多年的技术团队管理。
他采用的是自底向上方式,先是将所有知识打碎,然后重新归类汇总。先列举出了六十多种实践或方法,然后将它们划分成不同模块,并且思考这些模块之间的关系,最终建立一个相对完整且自洽的体系。
他将团队管理的整个体系分为两个维度,十个模块。有了这个体系,我们就能够以更高的视角,来看待团队管理中的各种事务,并且有针对性地加以改善。
上面的图,只是列了一下技术管理相关工作大纲,但要开展好管理工作,推荐的还是 PDCA 管理,这是美国质量管理专家休哈特博士首先提出的,由戴明采纳、宣传,也被称为“戴明环”。
它是全面质量管理所应遵循的科学程序,不仅在质量管理体系中运用,也适用于一切循序渐进的管理工作。它通过循环不断地进行计划(plan)、执行(do)、检查(check)和处理(ack) ,以达到不断完善过程和提高结果的目标。
PDCA 的管理可以总结为四阶段及八步骤:
- P阶段--计划:即根据顾客的要求和组织的方针,为提供结果建立必要的目标和过程。
- 步骤一:选择课题,分析现状,找出问题
- 步骤二:设定目标,分析产生问题的原因
- 步骤三:提出各种方案并确定最佳方案,区分主因和次因
- 步骤四:根据已知的内外部信息,设计出具体的行动方法、方案
- D阶段--执行:即按照预定的计划、标准,努力实现预期目标的过程。
- 步骤五:设计出具体的行动方法、方案,进行布局,采取有效的行动
- C阶段--检查:即确认实施方案是否达到了目标。
- 步骤六:效果检查,检查验证、评估效果
- A阶段--处理
- 步骤七:对已被证明的有成效的措施,要进行标准化,以便以后的执行和推广。
- 步骤八:问题总结,处理遗留问题,为开展新一轮的PDCA循环提供依据。
我在 PDCA 上加了扩展,变成了 O + PDCA + R:其中 O 是机会,只有发现并抓住了机会,才能开展 PDCA 的管理过程;而 R 则是Review,需要结合周报、月报、季度和年终总结定期进行总结复盘。
我的管理复盘
先谈一下我对管理认知:管理可以理解为管人理事的缩写,其中管人,包括选用育留开;而理事,则包括对公司及部门的战略理解,所负责或参与项目的规划、实施和复盘,整体的质量、效率和体验的提升。
因为我是国防生,大学期间就开始接受部队系统的管理培训,所以看了很多管理相关的书籍,我的管理理念和风格依然受部队的管理很大。
很多人会误解部队的管理是简单粗暴的命令式管理,但我党我军管理的精髓是民主集中制,既有民主,又要确保集中,注重以身作则、平等待人和思想工作。
所以,我实际工作中,不管是直线管理团队,还是通过项目管理相关同事,都会坚持民主集中制的原则,倡导用规则管理团队,并鼓励大家从团队出发,完善或提出规则,让大家都能参与到管理中。
大家若能参与到团队管理规则的制定,并且管理者能否以身作则,奖惩公平,树立起规则的权威,这样的团队才比较容易做到上下同心,营造出平等、自驱、高效和快乐的团队氛围,团队业绩也会更好。
管理团队的前期,我一般会制定三个原则:
- 每周跑一个3公里(女生只需1.5公里);
- 每周抄写10个英语词汇(每月安排一个同学提供);
- 每周写一篇技术博客或学习笔记。
做不到的需要交10元团建费(一般由女同学负责管理),在每周的技术分享会上用来买水果糕点吃。
制定第一个原则的考虑是,很多码农缺乏运动,身体亚健康的比例很高,另外对男女生的要求不同,也是让大家知道待人着想。而第二个原则呢,则是现在技术更新很快,很多新技术及遇到的问题,只有英文资料。最后呢,则是提升大家的写作基本功和让大家养成持续学习的习惯。
这三个原则执行起来后,我就会鼓励部分同学提出一些新的管理规则,然后在周会上提出,大家匿名表决,对于规则的完善建议也是如此,比如参加会议迟到也要交10元团建费或加一些奖励的规则。
回顾过去几年的管理工作,我觉得《高效能力的七个习惯》对我的启示很大,要想做好前端团队的管理,先从做好自我管理入手,然后主动做好向上管理,接着兼顾组织目标和团队个人目标做好向下管理,但也不要忘了关注协作方相关的管理工作。
一般一个月以后,团队能够按照规则良好管理起来后,我就会把重心过渡到核心项目的管理和架构上面,既会做前沿技术的调研,又会参与一些紧急项目的代码开发。
对我来说,我不愿成为一个官僚式的纯管理者,而是要主动拥抱前沿技术,具备大型复杂项目的管理和架构能力,这样在职场上才能保持持续的竞争力。
前端经理岗的高频面试题
这几年不管我是面试开发岗,还是前端经理岗或总监岗,有一些共同问题高频出现,但不同的岗位回答各有侧重,才能提升通过面试的概率。
本节我既会针对一些高频的共同问题,也会针对只有前端经理这个岗位才可能遇到的问题,分享一些我的思考。
请先做一下自我介绍?
不管是面试什么行业或什么岗位,也不管是第几轮面试,我们面试需要回答的第一个问题就是介绍自己。
很多人都会简单准备一个自我介绍,并且每次面试的介绍都一样。要想面试过程更顺利,我们最好根据面试公司的业务、岗位要求、面试官等情况,适当地调整自我介绍。
比如面试腾讯TEG 大模型的开发岗,我会这么介绍自己:我是XXX,从XX毕业已经12年了,最开始在外企以开发操作系统为主,16年开始转型做大前端开发和架构工作,技术栈以react 为主(因为JD说了项目用的是react),19年开始自学深度学习,过去三年都在做模型应用开相关的工作,特别是在美团,从零实现了一个web推理引擎。
而面试另外一个上市公司的管理岗,我则会这么介绍自己:我是XXX,从XX毕业后,一直都从事管理相关的工作,最开始在外企做操作系统时,负责团队的新人培训和团建工作,2016年进入互联网行业后,在做好项目架构和管理的基础上,还经常需要做团队管理,最多时管理过35人的大前端团队,既有前端开发、也有安卓和iOS开发,因为在部队接受过系统的指挥军官培训,我的管理遵循毛主席提出的民主集中制原则,以团队共同制定的规则管理各项工作,团队良好运转后,我会把精力聚焦在项目架构优化和研发质量和效率的提升上。
如何克服管理遇到的挑战?
技术团队管理面临的挑战多种多样,包括但不限于沟通不畅、资源分配不合理、团队成员技能不匹配、项目延期等,在面试的过程中,我们挑选一两个例子进行展开即可。
最难凝聚的,是人心:信任和凝聚力是团队成功的基石。每个团队成员都有自己的思想和需求,如何将这些不同的个体凝聚成一个有战斗力的集体,是每位管理者必须面对的挑战。管理者需要倾听员工的声音,尊重他们的意见,关心他们的成长。只有当员工感到被尊重和重视时,他们才会真心投入到工作中,与团队共同进退。
最难驾驭的,是情绪:管理者不仅是团队的领航者,更是情绪的调控者。一个稳定、积极的情绪状态能够激发团队的士气,反之,则可能引发团队的动荡。情绪的传染力极强,管理者的每一次情绪失控都可能成为团队士气的“病毒”。
最难把握的,是人性:孔子说:“己所不欲,勿施于人。” 管理者应将心比心,尊重员工的个人价值和需求。同时,通过合理的激励机制,激发员工的积极性和创造力。人性是不变的,但人心是流动的。管理者需要通过不断观察和学习,把握员工的心理变化,适时调整管理策略。
最难管理的,是期望:员工和上级都对管理者有着不同的期望,这些期望往往难以完全满足。上级领导希望团队能够不断超越,达成更高的业绩目标,而员工则希望得到更多的关注和支持。管理者需要深入了解各方的期望,并制定合理的策略来满足他们。
最难摆正的,是心态:管理者需要摆正自己的心态,从“保姆”转变为“教练”。通过引导和激励,帮助团队成员提升自我管理的能力,而不是替他们解决所有问题。应给予团队成长的空间和时间,而不是急于求成。
最难排解的,是委屈:管理者需要有宽广的胸襟,能够承受来自各方的压力和误解。在管理的过程中,管理者常常处于上下夹击的境地,既要面对上级的高要求,又要应对下属的期望和不满。应有谦逊和自省的精神,面对挑战和压力时,能够坦然接受,积极寻求解决方案。
分享你工作中最大的成果?
技术管理的最大成果不仅体现在项目交付或技术创新上,更在于其对整个组织的长远影响。可以从以下角度挑一两个展开:
- 业务项目:带领团队按时、按预算、高质量完成了一个或多个关键项目,满足了业务需求和客户期望,并通过技术驱动,促进了公司业务增长,如增加了用户数量、提升了用户活跃度或实现了收入增长。
- 技术创新:推动了技术创新,如引入新的技术栈、开发新的产品功能或改进现有的技术方案,从而提升了产品的竞争力和市场份额。
- 流程优化:改进了开发和运维流程,如引入敏捷开发、持续集成/持续部署(CI/CD)等,提高了工作效率和产品质量,在面对突发事件或危机时,能够迅速做出反应,采取有效措施,最大限度地减少了损失,并从中恢复过来。
- 人才培养:建立了积极向上的团队文化,增强了团队的凝聚力和员工的归属感,通过培训和指导,成功地培养了技术和管理方面的后备人才,为公司未来的发展打下了坚实的基础。
如何去做好这个管理岗位?
朝哪个方向走,判断的核心是深刻理解市场、业务的趋势。这其中,要对技术的未来做判断,对产品的未来做判断,相对而言,大部分人都能看到技术的发展趋势,困难的是判断未来的产品形态。
要有效地管理前端团队,需要从团队组建与选拔、技能提升与培训、项目管理与协作、质量控制与测试以及团队氛围与文化等多个方面入手。
通过明确团队目标、合理分配任务、持续培训、建立有效的沟通机制和加强团队文化建设,可以提升团队的整体效率和协作能力。
回答这个问题时,最好以互动问答的方式进行,多向面试官了解相关的情况,然后针对岗位职责和存在问题,进行适当的展开。
你为何离职?之后有何规划
回答离职原因和后续规划的问题时,重要的是要保持诚实、积极,并展现出你对新机会的热情和期待。
离职原因
- 诚实但策略性:选择离职原因时,应避免负面评价前雇主,而是强调个人成长和职业发展的需求。
- 具体原因与改进:即使是因为薪资、工作环境或领导问题离职,也应表达出你从中学到了什么,以及你如何准备在新工作中避免类似问题。
后续规划
- 职业目标:清晰地表达你的职业目标,展示你对前端技术领域的热情和承诺。
- 技能提升:强调你计划如何继续提升自己的技能,包括学习新技术和参与项目实践 。
- 对新机会的期望:表达你对新公司的期望,包括你希望在新角色中实现的目标和对公司的贡献。
通过上述策略,你不仅可以有效地回答离职原因和后续规划的问题,还能够给面试官留下一个积极、有目标的印象。
相关的学习资料
主要推荐一些我阅读过的书籍或看过的视频。
书籍:
- 《高效能人士的七个习惯》
- 《卓有成效的管理者》
- 《领导梯队》
- 《重新定义团队:谷歌如何工作》
- 《OKR工作法》
视频:
总结
人,是企业最宝贵的财富,甚至更胜于商业模式。管理能力的核心包含两方面,一是针对人,对人性的了解程度以及沟通能力。二是对事,是否有强大的统筹规划协调能力。
这是对于广义上管理能力的定义,对于技术团队,其本身有一些特殊性,所以在管理技术团队的时候,要充分考虑到这些特殊性。
管理是一件很复杂的事情,但是我认为管理技术团队相对并不复杂,可能是大多数技术人员都还是比较单纯吧。
技术团队的管理者,特别是中层管理者,其实就是个夹心层,经常受夹包气。其实你想想,作为一个承上启下的职位,压力同时来自于下面和上面,收夹包气也就正常了。
但是如果我们能够科学的规划任务,坚持以事驱动人,事前做计划,事中做追踪,事后做分析,调动团队积极性,我想再困难的任务也能够分解成一个一个不困难的小任务,分而破之。
对于团队内的一些声音和反馈的问题,能够耐心倾听、加以思考和用心解决,成员自然能够很好地完成管理者布置的任务。
关于面试相关话题,欢迎大家在评论区或加我微信交流:waxyysys82。
来源:juejin.cn/post/7396575930964934708
程序员:全栈的痛你不知道
上周一个同事直接对我开喷,骂我无能,说:“你怎么一个人就搞不定所有系统呢?”,我半支烟纵横IT江湖14余年,还是第一次被人这么嫌弃。
事情缘由
某公司的业务线特别多,有个业务线前后端项目共计上百个,半支烟带着1个大前端、1个Android外包、1个iOS外包在支撑业务线的发展。
突然,有一天大前端同事有事不在,运营同事找到我开发功能,我说要等等,我现在一个人搞不懂所有的端口。此时,运营同事一着急就上头,直接质问我,为什么你不能一个人搞定所有端口?
我当时立马怒怼,我说我一个人确实无法同时搞定IT基建、搞定后端、搞定H5、搞定Android、搞定 iOS、搞定PC、搞定小程序、搞定自动化爬虫。如果觉得我无能,你可以找个全部能搞定的过来。
然后,就是各种撕逼......
这个事情对我还是很触动,倒不是说跟同时互撕了一顿。只是觉得,现在的IT环境真的是看的人后背发凉,不但机会少,对人的要求还特别的高。
在想想前些时间,某高校降低要求大量扩招计算机专业学生,简直是坑学生啦。
全栈的优势
半支烟2010年毕业于计算机专业,工作14余年,后端干过JAVA、Python、Golang,大前端干过React、Vue、Android、iOS,还搞过IT基建运维。
半支烟对全栈还算有些理解,下面说说全栈的优势吧。
个人觉得,最好的技能人才是一专多能,这个绝对毋庸置疑。就是要在某个领域精通之后,在别的领域持续开花结果。说到底还是要做一个全栈的技术人。
全栈的优势非常多,比如:
- 在中小企业,一个人胜任多个岗位,可保饭碗无忧。
- 全栈人解决问题更快,因为全栈人的视角更加全面。
- 可以做一个独立开发者。
- 可以从事各种副业。
- 如果还会懂一些产品运营,那直接可以开个赚钱的小公司了。
全栈的痛
虽然全栈有一些优点,但是全栈的痛点也非常明显,比如:
- 全栈人要学习的技能或者知识非常多,但人的精力是有限的,无法真正做到每个技能栈都非常熟悉。
- 全栈人找工作会招人嫌弃,尤其是大厂会觉得你不是专业的螺丝钉,经常用某个领域的一些八股文去否定你。
- 很对人虽说是全栈,但是没有站在解决问题的角度去思考,而只是作为一个会多个技术栈的工具人。这样的思想其实偏离了全栈的初衷。
个人建议
个人觉得,全栈对个人职业发展很有优势,我建议在精通一个领域后做一个全栈人。
我这里说的全栈,不只是IT技术栈,还有更多的是产品运营思维。任何时候全栈人都应该用解决问题、推动事情往前发展的思维去做事。
当前大环境不乐观,未来也未必乐观,中小企业都偏向找全栈人,大公司偏向找专业高级螺丝钉。虽说背点八股文对找工作有优势,但是将来将一文不值。
因为AI发展太迅速了,获取知识已经变更更加便捷。我更不建议做一个高级螺丝钉,那样只会成为工具人,最后失业时一无所有。
我建议,不管你在哪里企业,自己的成长要放在第一位。
尤其在当下这个AI时代,可以让IT人更轻松的成为全栈人,我们应该把握机会,让自己成为一个优秀的超级个体,努力搞出点自己的事业来。
来源:juejin.cn/post/7406254193433100351
8年前端总结和感想
8年前端总结和感想
本文是我前端工作 8 年的一些总结和感想
主要记录下个人点滴、前端知识点、场景应用、未来的憧憬以及个人规划,供自己以后查漏补缺,也欢迎同道朋友交流学习。
自我介绍
我是一名工作在非知名公司的 8 年前端,双非普通本科,自动化专业(非计算机)。目前也在努力的提升自己,积极找工作状态。虽然工作已经 8 年了,但没待过超 10 人的前端团队,更没有开发过千万级、亿级流量的应用的经历,也是一种遗憾。
16 年才工作那会儿使用原生 JS 和 JQ
比较多,经常写 CSS
动画,主要做企业建站,自学了 PHP
和 ReactNative
,还学过 krpano
的使用;17 年到 19 年,主要使用 react
、 React Native
做 H5 和 跨端开发,也维护过老 NG2.x
项目;19年到22年主要使用 vue、react 做hybrid APP
及小程序,自学了electron
、node
、MongoDB
;22 年至今主要从事 B 端的开发,C端也有部分,也主要是 react
和 vue
相关技术栈;
前端应用场景
前端是直面浏览器的,也可以说是直面用户的,我们的应用场景远广泛于后端,用到的 UI 组件库、插件、基础框架、基础语言也非常繁杂,所以在面试和自我学习提升的时候需要准备的东西非常多,有种学不动的感觉。
常见的应用场景就是 PC
浏览器,需要我们掌握一些兼容不同版本和不同浏览器的知识点,一般采取渐进增强或者优雅降级去处理;当然现在很多公司已经不做IE的兼容了,复杂度降低很多;同时大部分新项目都会使用 postcss
去 autoprefixer
,给 css 加兼容的前缀。其他的就是做后台的表单为主了,用的基本上都是 antd design
或 element ui
。当然复杂点的要涉及到网页编辑器、Low Code、No Code等。
另一个主要场景就是手机浏览器
和 APP
了,H5 WebAPP
会遇到 Android
、IOS
的一些样式和行为需要兼容、列表和图片懒加载问题,还有调用原生的 SDK 进行地图、OCR、拍照、扫一扫等功能进行开发;为了更好的体验,还会选择 RN、flutter 进行跨端开发;复杂的处理场景一般涉及原生端,例如聊天室、直播等。
另一个场景就是小程序,其实还是写H5,主要是使用微信或者支付宝等相关 SDK 去实现,看官网就行了,文档也比较全。
还有一些是做 H5 小游戏,要求数学逻辑思维和算法更好点,初级一点的用 canvas+css3
去做,好一点的用游戏引擎去做,一般是 egret
、 Laya
、 createjs
、 threejs
、 cocos
。
还有一些场景是做TV端的,有的是基于PC浏览器的,有些是套壳APP,一般使用 AntV
、 echarts
做图表数据展示,3D一般用 threejs
去做。
还有一些做桌面应用的,一般来说桌面应用大多基于 C,但一些简单应用前端可以使用 electron
去进行桌面端开发,一般也用于大屏可视化,做数据展示的,当然我们熟悉的 vscode
也基于 electron 开发。
还有一些是做 AR
、 VR
、 3D全景图
的,一般使用 WebGL3D引擎
:threejs
、 babylon.js
、 playcanvas
等,还可以用 css3d-engine
、 krpano
、 pano2vr
去做。
还有一些场景是做 web3
的 DAPP
(去中心化应用程序),大部分是做区块链和数字藏品的,推荐的技术是 Solidity
、 web3.js
、 Ethers.js
。
前端网络
我们前端不管是开发 PC、 H5、小程序、 HyBrid App 还是其他应用,始终离不开是浏览器和 web-view
,与浏览器交互就要了解基础的 HTTP、网络安全、 nginx
方面的知识。
浏览器
浏览器的发展简史和市场份额竞争有空可以自行了解,首先我们要了解计算机架构:
- 底层是机器硬件结构:简单的来说就是电脑主机+各种
IO
设备;复杂的来说有用于输入的鼠标键盘,用于输出的显示器、打印等设备,用于控制计算机的控制器和 CPU(核心大脑),用于存储的硬盘、内存,用于计算的CPU和GPU; - 中层是操作系统:常见的就是
Windows
、MAC OS
、Linux
、CentOS
,手机就是安卓和 IOS(当然还有华为的鸿蒙);可以了解内存分配、进程和线程管理等方面知识。 - 上层就是我们最熟悉的应用程序:有系统自带的核心应用程序、浏览器、各个公司和开发者开发的各种应用。
前端开发必要了解的就是chrome浏览器,可以说大部分开发基于此浏览器去做的。需要了解 进程和线程
概念、了解 chrome
多进程架构(浏览器主进程
、GPU进程
、网络进程
、渲染进程
、插件进程
)。
其中最主要的是要了解主进程,包含多个线程:GUI渲染线程
、JS引擎线程(V8引擎)
、定时触发器线程
、事件线程
、异步HTTP请求线程
。其中 V8
引擎又是核心,需要了解其现有架构:
了解 JS 编译成机器可以识别的机器码的过程:简单的说就是把 JS 通过 Lexer
词法分析器分解成一系列词法 tokens
,再通过 Parser 语法分析为语法树 AST
,再通过 Bytecode Generator
把语法树转成二进制代码 Bytecode
,二进制代码再通过实时编译 JST
编译成机器能识别的汇编代码 MachineCode
去执行。
代码的执行必然会占用大量的内存,那如何自动的把不需要使用的变量回收就叫作 GC 垃圾回收
,有空可以了解其算法和回收机制。
HTTP
对于Http,我们前端首先需要了解其网络协议分层: OSI七层协议
、 TCP/IP四层协议
和 五层协议
,这有助于我们了解应用层和传输层及网络层的工作流程;同时我们也要了解应用层的核心 http1.0
、 http1.1
、 http2.0
及 https
的区别;还要了解传输层的 TCP
、 UDP
、 webSocket
。
- 在前后端交互方面必须了解
GET
和POST
的请求方式,以及浏览器返回状态200
、3xx
、4xx
、5xx
的区别;还有前后端通信传输的request header
头、响应报文体response body
,通信的session
和cookie
。 - 网络安全方面需要了解
https
,了解非对称算法rsa
和对称算法des
,登录认证的JWT(JSON Web Token)
;同时也需要了解怎么防范XSS
、CSRF
、SQL注入
、URL跳转漏洞
、点击劫持
和OS命令注入攻击
。
Nginx
我们的网页都是存储在 web 服务器上的,公司一般都会进行 nginx
的配置,可以对资源进行 gzip
压缩,redirect
重定向,解决 CROS
跨域问题,配置 history
路由拦截。技术方面,我们还要了解其安装、常用命令、反向代理、正向代理和负载均衡。
前端三剑客
前端简单的说就是在写 html
、 css
和 js
的,一般来说 js 我们会更多关注,其实 html 和 css 也大有用处。
HTML
html 的历史可以自行了解,我们需要更关注 文档声明
、各种 标签元素
、 块级元素及非块级元素
、 语义化
、 src与href的区别
、 WebStorage
和 HTML5
的新特性。复杂的页面和功能会更依赖于我们的 canvas
。
css
css 方面主要了解布局相关 盒子模型
、 position
、 伪类和伪元素
、 css选择器优先级
、 各种 水平垂直居中
方法、 清除浮动
、 CSS3新特性
、 CSS动画
、 响应式布局
相关的 rem
、 flex
、 @media
。当然也有部分公司非常重视用户的交互体验和 UI 效果,那会更依赖我们 CSS3 的使用。
JS
js 在现代开发过程中确实是最重要的,我们更关心其底层原理、使用的方法、异步的处理及 ES6
的使用。
- 在底层方面我们需要了解其
作用域及作用域链
、闭包
、this绑定
、原型和原型链
、继承和类
、属性描述符defineProperty
和事件循环Event Loop
。
可以详看我写的javascript随笔
- 在使用方面我们需要了解
值和类型
的判断、内置类型的null
、undefined
、boolean
、number
、string
、object
和symbol
,其中对象类型是个复杂类型,数组
、函数
、Date
、RegExp
等都是一个对象;数组的各种 API 是我们开发中最常用的,了解Dom操作
的API也是必要的。 ES6
方面要了解let、const声明
、块作用域
、解构赋值
、箭头函数
、class
、promise
、async await
、Set
、WeakSet
、Map
、WeakMap
、proxy
和Reflect
。
可以详看我写的(ES6+)随笔
TypeScript
在前端的使用越来越广泛,如果要搞NodeJS
基本上是标配了,而且也是大厂的标配,还是有必要学习下的。要了解TypeScript
的安装配置、基本语法、Type
、泛型<T>
、Class
、Interface
、Enum
、命名空间
和模块
。
可以详看我写的typescript随笔
前端框架
我们在开发过程中直接操作 dom 已经不多了,有的公司可能还要部分维护 JQ,但大多都在使用 React
、 Vue
、 Angular
这三个基础前端框架,很多其他跨平台框架及 UI 组件库都基于此,目前来说国内 React 和 Vue 是绝对的主流,我本人就更擅长React。
React
开发 react,也就是在写 all in js
,或者说是 JSX
,那就必须了解其底层 JSX 是如何转化成虚拟节点 VDom
的。在转换 jsx 转换成 VDom,VDom在转换成真实 Dom,react 的底层做了很多优化,其中大家熟悉的就是 Fiber
、 diff
、 生命周期
以及 事件绑定
。
那我们写 react 都是在写组件化的东西, 组件通信
的各种方式也是需要了解的;还要了解 PureComponent
、 memo
、 forwardRef
等组件类的方法;了解 createElement
、 cloneElement
、 createContext
等工具类的方法;了解 useState
、 useEffect
、 useMemo
、 useCallback
、 useRef
等hooks的使用;还有了解 高阶组件HOC
及自定义 hooks
。
了解 react16
、 react17
、 react18
做了哪些优化。
Vue
vue 方面,我们需要了解 MVVM
原理、 template
的解析、数据的 双向绑定
、vue2 和 vue3 的响应式原理
、其数据更新的 diff
算法;使用方面要了解其生命周期
、组件通信
的各种方式和 vue3
的新特性。
前端工程化
上面写到了前端框架,在使用框架开发的过程中,我们必不可少的在整个开发过程向后端看齐,工程化的思想也深入前端。代码提交时可以使用git的钩子hooks进行流水线的自动化拉取,然后使用 webpack
、 rollup
、 gulp
以及 vite
进行代码编译打包,最后使用 jenkins
、 AWS
、 阿里云效
等平台进行自动化部署,完成整个不同环境的打包部署流程。
可以详看我写的webpack随笔 和 使用rollup搭建工具库并上传npm
webpack
在代码编译打包这块儿, webpack是最重要的,也是更复杂的,所以我们有必要多了解它。
在基础配置方面,我们要了解 mode
、 entry
、 output
、 loader
和 plugin
,其中 loader 和 plugin 是比较复杂的,webpack 默认只支持 js,那意味着要使用 es6 就要用 babel-loader
,css 方面要配置 css-loader
、 style-loader
、 less-loader
、 sass-loader
等,图片文件等资源还要配置 file-loader
;
plugin
方面要配置 antd
的相关配置、清空打包目录的 clean-webpack-plugin
、多线程打包的 HappyPack
、分包的 splitChunks
等等配置。
在不同环境配置方面要基于 cross-env
配置 devServer
和 sourcemap
。
在构建优化方面要配置按需加载
、 hash
、 cache
、 noParse
、 gzip压缩
、 tree-shaking
和 splitChunks
等。
幸运的是,现在很多脚手架都自动的帮你配置了很多,并且支持你选择什么模版去配置。
环境部署
环境部署方面,第一家公司我用的软件 FileZilla
进行手动上传 FTP
服务器,虽然也没出过啥错,但不智能,纯依靠人工,如果项目多,时间匆忙,很容易部署错环境,而且还要手动备份数据。后面学了点终端命令,使用 SSH
远程上传文件,其实还没有软件上传来的直接,也容易出错。后面换了公司,也用上了 CI/CD
持续集成,其本质就是平台帮你自动的执行配置好的命令,有 git
拉取代码的命令、npm run build
的打包命令,最后 SSH 远程存到服务器的目录文件,并重启 nginx
的 web 服务器。
CI/CD
可让持续自动化和持续监控贯穿于应用的整个生命周期(从集成和测试阶段,到交付和部署)。
后端服务
为了更好的完成整个应用,了解后端技术也是必要的,我们可以从 nodejs
、MongoDB
、MySQL
等入手。如果有余力,了解 java
、c#
、c++
也可以帮助我们更好的开发安卓和 IOS 应用。前后端都通了话,不管对于我们工作、面试、接活儿或者做独立开发者都是很必要的。
node
node 这方面,我们了解常用模块
和 Event Loop
是必要的,框架可以选择 express
、 koa
、 egg
,还有我最近刚学的NestJS
也非常不错。
形而上学
了解完上面的文章,基本上你就了解了整个前端大体的开发流程、所需的知识点、环境的部署、线上网络安全。但如果需要进阶且不局限于前端和后端,我们需要了解数据结构
、 设计模式
、 算法
和 英语
。
数据结构
常见的数据结构有8种: 数组
、 栈
、 队列
、 链表
、 树
、 散列表
、 堆
和 图
。
可以详看我写的算法随笔-数据结构(栈)
可以详看我写的算法随笔-数据结构(队列)
设计模式
设计模式方面我们需要了解:
- 六大原则:
单一职责原则
、开放封闭原则
、里氏替换原则
、依赖倒置原则
、接口隔离原则
和迪米特原则(最少知道原则)
- 创建型设计模式:
单例模式
、原型模式
、工厂模式
、抽象工厂模式
和建造者模式
- 结构型设计模式:
适配器模式
、装饰器模式
、代理模式
、外观模式
、桥接模式
、组合模式
和享元模式
- 行为型设计模式:
观察者模式
、迭代器模式
、策略模式
、模板方法模式
、职责链模式
、命令模式
、备忘录模式
、状态模式
、访问者模式
、中介者模式
和解释器模式
算法
算法方面我们需要了解:
- 基础概念:
时间复杂度
和空间复杂度
- 排序方法:初级排序的
选择排序
、插入排序
和冒泡排序
,高级排序的快速排序
、归并排序
、堆排序
- 搜索:
深度优先搜索
和广度优先搜索
- 其他:
递归
、分治
、回溯
、动态规划
和贪心算法
可以详看我写的算法随笔-基础知识
英语
学生时代,觉得英语离我们挺远,进社会就用不到了。现在发现学好英语非常有用,我们可以入职福利待遇比较好的外企、可以更好的看懂文档、甚至起个文件名和变量名都好的多。最近我也在用多邻国学英语,目标是能进行简单的商务交流和国外旅游,还能在未来辅导下孩子英语作业。
前端未来
目前,初级前端确实饱和了,各个公司对前端已经不像我入职第一家公司那样简单就可以找到工作的了,尤其是在这个各种卷的环境里,我们不得不多学习更多前端方面的知识。对于初学者,我建议更多的了解计算机基础、js原理、框架的底层;对于已经工作一俩年想提升的,不妨多学点跨端、跨平台技术,还有后端的一些技术;对于工作多年想让未来路子更宽的,不得不内卷的学习更多的应用场景所需要的知识。
关于AI,我觉得并不是会代替我们的工具,反而缩小了我们和资深前端的距离。我们可以借助AI翻译国外的一些技术文档,学习更新的技术;可以帮我们进行代码纠错;还可以帮助我们实现复杂的算法和逻辑;善用 AI
,让它成为我们的利器;
感想和个人规划
前端很复杂,并不是像很多后端所说的那么简单,处理的复杂度和应对多样的客户群都是比较大的挑战。资深的前端能很快的完成任务需求开发、并保证代码质量,并搭建更好的基础架构,但就业行情的不景气让我一度很迷茫,我们大龄程序员的出路在哪里,经验就不值钱了嘛?
对于未来,我会更多的学习英语、学习后端,向独立开发者转型。
谨以此文,献给未来的自己和同道中人!
来源:juejin.cn/post/7387420922809942035
市场行情再差,也不要选择996,它真的会吞噬你
前言
最近,一连有两个朋友和我聊天,说新公司996,自己很犹豫,是不是应该换个工作。
我的回答是,快走,一秒都不要犹豫。
但在目前市场行情不佳的情况下,似乎也很难让人下定决心放弃一份已有的工作。
恰巧,我也曾经入职过一家996的公司,而且也只有这么一家。我想把这段经历分享出来,希望能对你有一点点帮助。
重复的工作,会限制人的视野。其实在世界上,还存在着无法想象的广袤天地。
满怀憧憬
2019年,我终于决定从当前的小公司离职,准备换一家更大规模的公司。
经过几轮面试,没想到很顺利的拿到一家公司的offer,涨薪50%,单纯从薪资角度来说,达到了我的预期,及时入职前,HR给我说,我们这边996,你是否能够接受,我也很果断的接了offer。
那时候想法很简单,谁给的钱多,我就去哪里。初入职场的我们,大概率都是这么想的。
由于换工作换的很快,我还没来得及搬家,前公司坐标丰台区,并且我住的地方离地铁站很远,需要坐公交车。
早上七点,起床洗漱,抓紧时间下楼去赶7:20的公交车。七点四十坐上地铁,在八点五十左右到达望京附近,一路小跑在九点前完成打卡。
前面也说了,既然是996,晚上九点才能下班,事实上九点公司根本没人走,但是我是刚入职,自然不会跟着他们一起加班。下班后打车回家,一般在十点半到家,结束一天的工作。
太可怕了,我除了睡觉,剩下的时间不是在上班,就是在上班的路上。
上班第二天,联系了自如的管家,我早下班去看了周边三个地方的房子,准备搬到这附近来,最后还交了定金,这样每天起码能省下通勤的时间。
大家可以看到,这时候我还是很想留下来的,但接下来的两天,我便意识到,此地不宜久留。
为了加班而加班
上面讲过,因为我是新人,加上住的地方离着公司很远,所以我都是九点就赶紧下班跑了。
和我同时进入公司的还有一个哥们,就叫他阿文吧。
阿文挺平易近人,对待工作也挺踏实。他告诉我他就住在离公司两三公里的地方,所以即使是新人,他下班都是和他们那些老员工一起下班。
那么老员工下班是几点呢,他说一般大家都是11点才走,然后第二天早上再晚点来公司,而他依然会九点按时上班。
中午吃饭的时候,大家普遍也是坐着不动,等到12点半才陆陆续续去楼下吃饭。
第一周的周六加班,我七点下班了。周一的时候,阿文告诉我,周六他加班到11点才走。
我是一个不会把工作往后拖的人,有工作就抓紧完成,但问题是白天的时间大家感觉也不是很忙,虽然是996,入职一周了,没有人强调过接下来项目的紧迫性,手头的工作也是一些边边角角的功能。
此时的我已经心里交瘁了,一周的早起晚归,加上比较迷茫的工作内容,已经让我有了要辞职的想法。
接下来的一周,依然是熟悉的内耗与纠结,同时也感到迷茫,如果在这里996下去,对自己的职业生涯,能有帮助吗?
最终,入职半个月后,我决定离开这家公司。
996背后是什么
开头说到,996的公司直到今天依然存在,甚至存在于一些外包公司。
大部分实行996的公司,背后一定处于基建建设差、管理能力不足,战略目标不清的状态。为了兜底,强制实行996试图弥补这两个缺陷。
作为一个开发人员,你对自己一天中高效的工作时间,心里是有一定的预估的。
以我和之前身边同事为例,仅从代码产出的角度来说,一天高效写代码4-5小时,已经相当不错了。
长期加班,强制实行996,一定会出现无效加班的现象。因为持续的加班,会触发我们基因中的“调节回路”,降低我们的效率。
调节回路具体是什么,可以看下我之前写的这篇文章。四个字解释明白,常常说的程序员35岁危机,到底说的是什么?
996可怕的不是长达12小时(可能更多)在公司的时间,可怕的是996会极大的丧失我们对于新事物的接受力,影响我们的思考能力。
关注研发效能
软件开发是一个创造性很高的过程,开发者之间的效率相差很大。就比如,10x程序员的生产效率可以达到普通开发者的10倍。其实,不仅是个人,团队间的效率相差也很大。相比工作时长而言,我们更应该关注的是研发效能。
研发效能,是团队能够持续为用户产生有效价值的效率,包括有效性
(Effectiveness)、效率(Efficiency)和可持续性(Sustainability)三方面。简单来说,就是开发者是否能够长期既快又准地产生用户价值。
软件开发流程本质上就是一套流水线,比如需求设计、技术设计、开发、构建、测试、发布、部署、验证。
其中,流水线的每一套流程都可以细化拆分,找到提高效能的方式。
举个例子,不知道你有没有过部署服务的经验,最基础的部署方式,是手动在本地打包,通过工具把打包的程序上传,替换,再重启服务。
但随着服务器数量越来越多,手动的方式会占用大量时间。
部署的方式也持续在升级,从虚拟化到容器化,再到容器编排,开发人员可以轻松的管理大规模的容器集群。
假设不关注效能,只是招人、996来完成日益增长的大规模部署,得需要多少人来完成这件事呢?
说在最后
好了,文章到这里就要结束了,感谢你能看到最后。
有些人,工作对于他们而言,只是在固定的时间出现在固定的地点,做着固定的事情——重复,重复,再重复。他们或许想的是,“就这样吧,反正也没的选”。
高效的完成工作,留出更多时间给自己。
来源:juejin.cn/post/7398150089556606988
同事一行代码,差点给我整破防了!
大家好,我是多喝热水。
最近开发公司项目的时候遇到一个哭笑不得的问题,知道真相的我差点破防!
还原现场
周一开周会的时候正常评审需求,在演示的过程中发生了一点小插曲,我们的聚合搜索功能它不能正常使用了,搜到的内容还是首次加载的数据,如下:
看到这种情况,我下意识的以为是后端返回的数据的问题,所以结束会议后我就着手排查了,如下:
结果发现后端的数据是没问题的,这我就很奇怪了,其他的 tab 都能正常展示数据,为什么就只有综合出现了问题?
开始排查
因为这个无限滚动组件是我封装的,所以我猜测会不会是这个组件出了什么问题?
但经过排查我发现,这个组件接收到的数据是没问题的。
那就很奇怪了,我传递的参数是正确的,后端返回的数据也是没问题的,凭什么你不能正常渲染?
直到我看到了这一行代码,我沉默了:
woc,你小子在代码里下毒!
看到这里我基本上可以确定就是这个 index 搞的鬼,在我尝试把它修改成 item.id 后,搜索功能就能正常使用了,如下:
问题复盘
为什么用 id 就正常了?
这里涉及到 React 底层 diff 算法的优化,有经验的小伙伴应该知道,React 源码中判断两个节点是否是同一个节点就是通过这个 key 属性来判断的,key 相同的话会直接复用旧的节点,如下:
这也就解释了为什么切换 tab 后列表中始终都是旧数据,因为我们使用了 index 作为 key,而 index 它是会重复的,新 index 和旧 index 对比,两者相等,React 就直接复用了旧的节点!
但 id 就不一样了,id 我们可以确保它就是唯一的,不会发生重复!
哎,排查问题半小时,解决问题只花 3 秒钟,我 tm.....
这个故事告诉我们:
一定不要在循环节点的时候使用 index 作为 key!
一定不要在循环节点的时候使用 index 作为 key!
一定不要在循环节点的时候使用 index 作为 key!
养成好习惯,特别是这种数据会动态变化的场景!!!
来源:juejin.cn/post/7391744516111564852
切!我又不是第一次没人要🤡
我和你一样都经历过 家里蹲 狗都嫌
的尴尬时期,每天早上起来拿着手机不断刷着招聘软件,
海投几百份还是杳无音讯
,在BOSS直拒
、前程堪忧
、失联招聘
、猎空
之间反复横跳...
还经历了十分灰暗的阶段,焦虑导致出现躯体化反应(头痛、严重失眠、吃不下东西等)
整夜整夜睡不着,躺下脑子都是工作、面试、人生选择
带来的压力
不想出门社交,害怕面试。
其实,我想跟你说:裸辞并不是终点。
1.裸辞/辞职并不是终点
当我扛着我的键盘收拾东西离开工位,第一次对辞职的 “人走茶凉”
有了实感,
下午六点跟对我很好的前辈们告了别,公司离地铁有点远,和往常不一样,天还没黑,有黄昏相伴
三号线还是这么挤 还有点闷。
算起来这是第二次辞职,但第一次辞职找了一个礼拜就顺利入职了
,这次好像有点久,今年大家都在说被裁员
、大环境差
,同学领证成家的也不少。
我意识到人与人的节奏不同
,而我好像又一次走到了岔路口
,上一次这么慌张还是在高考前
即便我从来没后悔过离职
这个决定,但还是会因为面试带来的压力感到局促不安
每次离职就像是一场查漏补缺
的大考,对勇气
,对储蓄
,对知识点
的大考
唯有拆迁
和认亲
能打破这场突如其来的考验。。。啊不是。。。我想说:
唯有行动能打破僵局!!
行动!!!去吃个冰淇淋!!。。。果然有灵感了
短暂的欢愉
后,是与台灯的昼夜相守,与简历
的交织缠绵
(简历编。。不是。。写不出来呀!!!)
反复改了几版之后确实多了一些“打招呼”的机会,但是实际面试机会还是屈指可数呀,
切!又不是第一次没人要🤡,拒绝我的多的去了,得从巴黎排到广州...
继续努力,等待运气,厚积薄发
2.当知识脱离了考试,真理和美丽才慢慢浮现
2.1 心态调整(分享一下最近对我有帮助的书)
- 《见识》 - 吴军
这是第一年出来工作,遇上了很好的领导送我的书,每当迷茫的时候再拿出来翻翻有了不一样的感悟,很多我们看上去非做不可的事情,其实想通了并没有那么重要,无论在职场上还是在生活中,提高效率都需要从拒绝伪工作
开始,有些苦是可以不用吃的,苦难并非造就人类
。
幸福是目的,成功是手段
- 《意志力》 - 罗伊·鲍迈斯特
技术行业的人都知道学习是个漫长/终身的事情,跟考公考研短期爆发式集中不同,我们更需要坚持
、长期
一点点做下去,我认识到所有人的意志力
都是有限的,使用就会消耗,压力
也并非与动力
画等号,人也跟机器一样需要“充电”
和合理分配,每个人的节奏和身体承受能力也不同。
- 《被讨厌的勇气》 - 岸见一郎、古贺史健编著
在心情动荡的时期,这本书就像开了一盏加热灯
一样在一旁无声陪伴,那会我就像婴儿
一样无意识地
紧紧抓着自己的头发,直到我睁开眼看见了、意识到了,放下禁锢着工作、生活、交友的课题的手,更能轻松地赶路了。
生活的方式千千万,人生的意义,由我自己决定
- 《法律常识全知道》 - 李桥
读书的时候没有一门跟社会
接轨的课程,毕业了也一直专研技术,导致一毕业不知道劳动合同/租房
有什么坑,把仲裁和维权
看得过于艰难,法律条例
密密麻麻 一时间不知从何下手,这本书就很适合我这种来一线城市
打工没什么社会经验
的小白,用简单的案例植入“NPC游戏”攻略,和《影响力》这本书加一起简直就是进城防骗指南
哈哈哈
免费法律援助电话:12348
2.2 前端学习路线图:
各位摸鱼的小伙伴下次见,这篇便是我的2023年终总结:
裸辞不是终点,唯有行动才能打破僵局,当知识脱离了考试,真理和美丽才慢慢浮现。
参考资料:
来源:juejin.cn/post/7312304122535133220
这两年,我把28年以来欠的亏都吃完了...
前言
很长一段时间没有总结一下过去几个月的状态了,今天思绪万千,脑海中浮现了很多经历和生活片段,我把它记录下来。顺便今天聊一聊认知突破,分享我在买房这段时间吃过的亏,也希望作为你的前车之鉴。
买房
21年
底的时候,那时刚好毕业三年,也正是互联网公司996最流行的阶段,由于平时我不怎么花钱,也很少买衣服,上网买东西是个矛盾体,需要花很多时间对比,经常看了一件东西很久,最后又不买。加上比较高强度的工作状态,两点一线,可以说是没时间花钱,再加上自己把钱都拿去理财了,也赚了几万块,最后一共攒了几十万下来。我从小就立志要走出农村,而且认为以后有女朋友结婚也要房子,加上当时花比较多时间在理财上面,那时候其实行情已经不好了,工作上没什么突破,比较迷茫,于是想着干脆就把钱花出去了,自己也就有动力去搞各种路子尝试赚钱。在没有经过任何对比之后就在佛山买了一套房子,房价正是高峰的时候,于是我成功站岗!因为这个契机,躲过了持续了2年多的低迷股市,却没躲过低迷的房地产。
while(true) { 坑++ }
我买的是期房
,当时不知道期房会有这么多坑,比如期间不确定开发商会不会破产,我这个开发商(龙光)就差点破产了,房产证无着落,相当于花了200w
买了一个无证的房子,这辈子就算是搭进去了。
对于整个购房过程也是很懵逼,对流程完全不熟悉,当时去翻了政府规划文件,看那个地段后续有没有涨价空间,然后跟着亲戚介绍的销售转圈圈,当时说给我免3年物业费
,合计也有几万块。在签合同之前销售都有说可以给到,但由于第一次没有录音,导致在签合同的时候销售反口,不承认,我们也没有证据,最后吃了哑巴亏。
开始的时候谈好了一个价格167w
,然后销售私下打电话给我洗脑说我给点辛苦费1.5w
,他可以向领导申请多几万块优惠。我知道这是他们的销售套路,但是架不住给我优惠5w
啊,中间反复拉扯最后说给他8k
,采用线下现金交易的方式。这一次我有录音了,因为私底下交易没有任何痕迹,也不合法,所以留了一手,也成为我后面维权时争取话语权的基础。
中介佣金是很乐观的,当时由于我亲戚推荐我去,销售承诺税前有4w
,当时看中这个返佣也促使我火急火燎的交了定金。现在3年
过去了,这个佣金依旧没有到账,我一度怀疑是中介搞ABC套路把我这个钱💰吃了,其他邻居的推荐佣金都到了账,加上现在地产商没钱了,同时跟那个亲戚有些过节,这个返佣更是遥遥无期。最后通过上面的录音获得了一丝话语权,知道了这个钱还在开发商手上,一直没有拨款下来到中介公司。下面是部分聊天记录:
不接受微信语音沟通,文字可以留给自己思考的时间,同时也更好收集证据。
然后去找相关人员把信息拉出来给我看,显示开发商未付款状态,这个状态维持2年
了,目前看来只能再等下去。
签合同的时候,有个律师所说是协助我们签合同、备案、办房产证等各种边缘工作,糊里糊涂交了700元
律师费,不交不行,甚至律师所连发票都没有给,而我都没有意识到这个最基本的法律法规问题。现在交房了可以办理房产证了,拿证下来也就80块
登记费,居然收我700
,其他业主有些是600多,400多
,顿时觉得智商受到了侮辱,看了网上铁头各种打假的视频,我觉得自己也应该勇敢发声。现在也在收集商家各种违规证据,提交给相关部门解决。
后面市场监督管理局收到投诉,应该是有协商,意识到没有给我们发票,过来几天之后才把发票补过来,开票日期不是付款时候的2022年
,而是2024年
,明显属于偷税了。目前跟他要发票的应该只有我,估算2300
多户业主都没有开发票的。
当时我首付需要50w
,自己手上不够,我爸干建筑一辈子,辛苦供我们两个孩子上了大学,山上建了两层楼,手里没钱。我妈是一辈子没打过工,消极派,说出来没几句好话,家里不和睦的始作俑者,更不可能有钱支持。所以我还有20w
是首付贷,也就是跟开发商借的,利率10%
,这个利息很高了。销售当时说可以优惠到5%,但是优惠金额是补贴到总房价里面去,其实这也是他们的一种销售套路,这亏我也吃了,2年之后我连本带息还24w
。当时认为自己应该一年左右能还完,但是实际远远高估自己的能力,买完房子接着我爸又生病在医院待了几个月,前后花了十几万,人生一下子跌入了谷底。
从头再来
后面2023
一年,夫妻出去创业,很多人不赞同,期间遇到了不少小人诋毁我们两夫妻,当时我老婆还在怀孕,但我们最后都熬过来了,还生了一个儿子,6斤多。期间一年赚了十几万,但是开支也大,加上父母要养,我爸还要吃药,房子要供,最后还是选择了先稳定下来,我重新回到了职场,空窗一年后在这个环境下拿了一个还不错的offer,同时也想自己沉淀一下。
自从有了宝宝之后,生活似乎都往好的方面发展,出版社找我出书,为了契合自己的职业发展,我选择了写书《NestJS全栈开发秘籍》
,从2023年11月份
开始,迄今快半年了,在收尾阶段,希望尽快与各位读者们见面。同时,等了3年的房子也收房了,由于是高层,质量相对其他邻居好,没有出现成片天花掉下来或者漏水的情况。我们经常都说他是天使宝宝
,是来报恩的。
由于我们公司技术部门是属于后勤支持性质的,技术变化不大,Vue2+微前端和React
管理系统那一套,没有太多的新技术扩展,意味着不确定也大。业务发展不好考虑的是减少这些部门的开支,所以不出意外最近也迎来了降薪。这不是最可怕的,对于我们技术人来讲,最可怕的是我认为在业务中成长停滞了,或者没有业务来锻炼技术,所以在业余时间也选择了参与一些开源项目,如hello-alog
开源算法书的代码贡献,并且这也是选择写书的原因。很简单地说,当下一个面试官问到我的时候,我不可能什么都讲不出来,最经典的问题就是:在这个公司期间你做过最有成就感的事情是什么?现在,我有了答案!
哲学
我的人生哲学是不断改变,拥抱不确定性!这么看来,我的确在这些年上了不少当,吃了不少亏,把自己搞的很累,甚至连累到家里人。但,用我老婆经常说的一句话:人生这么长,总是要经历点什么,再说现在也没有很差。的确,不断将自己处于变化之中,当不确定性降临到普罗大众时,我们唯一的优势,就是更加从容!
总结
人们还在行走,我们的故事还在继续~
来源:juejin.cn/post/7349136892333981711
火山引擎谭待:没必要将AI和云对立,大模型是“云2.0”的组成部分
8月21日,2024火山引擎AI创新巡展(上海站)如期举行。火山引擎总裁谭待在媒体采访中表示,大模型是云的构成部分,不应把AI和云对立起来。否则,AI很容易演化为项目制商业模式,很难做大做强。
谭待认为,在公共云上调用大模型API,本质仍是云端PaaS,只是相较于DevOps等PaaS的重要程度更高、Workload更大,或者也可以叫“云2.0”。
谭待表示,大模型时代,火山引擎的定位没有变,仍是云服务厂商。但在当前时代,云服务厂商要在做好降本增效,通过规模优势把成本降低的同时,做好大模型,助力企业做好创新。
以往,企业决策者很难评估云的质量优劣,只能看看PPT。但在当前,评价大模型的优劣,下载APP体验即可得到最直观的了解。
对于大模型的具体应用,许多细分场景,如文档、客服等等,企业可以慢慢应用到自身实际运用中去,而没必要“一步到位”上大系统,这样一来,无论在可感知性、可量化性,还是决策的容易性方面,都会更高,从而真正加速大模型的落地。
对于媒体提出的“云计算市场格局是否已相对清晰”的问题,谭待表达了不同看法。他认为,2012年到2013年的中国电商市场,当时大部分的观点也认为市场格局已相对固化,但随着新的业态不断涌现,如今的电商市场仍然竞争激烈。
谭待表示,随着数字化的GDP占比越来越高,云对于数字化转型和发展也越来越重要,企业未来一定会用到云,而今天中国的云市场只有几千亿的规模,还远没有见顶。他预判,未来的中国云市场发展到万亿规模,只是时间问题,并且一定是多云成为主流。
云市场的本质是规模经济,规模大意味着更强的竞争力、更好的弹性、更低的成本。火山引擎决定进入云市场,很重要的一点是因为“内外复用”,火山引擎与抖音是资源并池的,两者相加的规模,在中国排在前列,火山引擎完全有信心越做越好。
同时,火山引擎还要抓住AI这一重大技术变革机遇,目前在用户量层面,豆包APP是中国最大的AIGC应用,而对于大模型来说,只有更多的用户使用量,才能锻炼出最好的技术。
谭待表示目前全球大模型价格已处于合理价位,有利于真正促进应用生态繁荣。尽管单价较低,但随着日均tokens的不断攀升,特别是多模态模型的普及,客户的各类场景中都会出现Agent,tokens的消耗量会非常大,从而创造很大价值,即使按现在的价格来看,也是非常有吸引力的市场。(作者:李双)
收起阅读 »前端开发,不应止于前端
重新思考前端开发以及自己未来的规划。先说结论,再展开。
- 烂程序员关心的是代码,好的程序员关心的是数据结构和它们之间的关系。—— Torvalds
- 编程也是内容创作,认清自己是哪个层级的创作者。
亲身经历
公司在去年选择了一款 Plasmic 这个低码平台,希望未来由业务部的人来使用低码平台构建页面,由开发部的人负责研究平台的使用方法、解决复杂的问题。后来,公司有 100+ 个问卷需要制作,并且这个业务是在内网中,不方便使用第三方的表单服务。这 100 个问卷中,有几十个在另一个 Vue 系统中有实现过,还有一些可能需要新增。我推荐让开发部来写这部分代码,但领导还是决定让业务部门的人使用低码平台(基于 NextJS)来构建。理由是这些问卷在另一个 Vue 中有实现过(我实现的),照着页面抄就行了。
在业务部的人经过一个星期的折腾之后,做了几十个问卷,最终因为难以维护而停止。这几十个问卷每个都是单独的页面,每个页面都需要对接口,需要一个问题一个问题的拖拽然后配置。在检查的时候,出现了各种各样的问题,量表出现问题时,还得一个问题一个问题的排查,排查起来不如重做。更何况,业务部的人也不会排查页面,她们没有什么编程基础。
这个问题并不难解决,只是这个决策让人有点令人匪夷所思。我的解决步骤大致分为两个步骤:将 NextJS 中的 Form 的编辑变成声明式的、对 Vue 中的 Form 进行迁移。
将 Form 的编辑变成声明式的,也就是用 JSON 来配置。例如在 Vue 中可以使用 FormCreate、在 NextJS 中可以使用 nice-form-react。我其实并不喜欢拖拽式的低码,拖拽之后再通过点击来配置其实很费时间,在经过我的实践发现,这种声明式的配置配合 GPT 来使用非常妙,只需要把配置相关的要求告诉他,就可以让他完成大部分的工作。前两天发现有类似的文章:低代码与大语言模型的探索实践。把编写 Form 页面变成编写 JSON 文件,复杂度直线下降。业务部的人可以直接通过 ChatGPT 生成 JSON,我只需要给他们一个工具来校验和展示 JSON 所对应的量表即可。
将 Vue 中的 Form 转换到 Next 中,有些人可能会考虑思考 FormCreate 底层的逻辑,然后在 NextJS 中再实现一遍,这样就能无缝迁移。并不需要这么做,这么做工作量非常大,只需要看数据结构怎么转换即可。
总结:Vue 中的 FormCreate 经过了魔改,支持根据题目算分。在 NextJS 先实现类似的声明式构建的机制,然后再将数据库中的 JSON 配置进行转换。
最终,加上业务部的配合,使用 GPT 来生成 JSON 文件,两三天的时间就全部迁移完成。大胆预测一下,拖拽式低码平台后面会被懂 DSL 的平台所替代,利用语义化的 DSL 配合大语言模型,效率应该会非常高。
重新思考前端开发
Linux 的创始人 Torvalds 曾说过一句话:“烂程序员关心的是代码,好的程序员关心的是数据结构和它们之间的关系”。
上面的声明式的 Form 工具是我构建的,由业务部的人来使用,当他们有什么需求的时候,我能很容易知道现有的结构能否实现。将 React 想象成一个更复杂的声明式的 Form 工具,那么熟悉它底层原理的人应该也可以拓展它的边界。
于是我尝试着阅读了一些 React 相关的源码,根据《🚀 万字好文 —— 手把手教你实现史上功能最丰富的简易版 react》构建了一个 mini-react。理解了 React 本质上是 DSL + 高效更新树的算法。JSX 作为 DSL 来引入组件化和简化树形结构的编写,Fiber 树和 Diff 算法来高效的更新树。
沿着树形结构稍微发散一下,就知道为什么会有 React Native 了。在网页中使用 DOM 树,在安卓中使用 View,在 IOS 中使用 UIView,这些都可以被称为布局树。当然除了更新视图,还有很多需要兼容的问题,例如:
我还继续沿着树形结构去探索 React 的可能性,想到了 Svg、Canvas、Three.js 这几种技术。我并不是特别了解这几种技术,只是简单的 check 是否有人将 React 用于这些方向,最后发现 React Three Fiber 这个项目。Three.js 中需要渲染场景树,Fiber 树现有的逻辑为什么不能用,非常好奇作者是如何将 Fiber 应用于 Three.js、为什么需要再实现一个 Fiber。
学会用 React 写页面非常简单,从零基础开始学,最多几天就能写出基本的页面,最麻烦的可能就是调 CSS,但是这些问题的复杂度都并不高,多搜索多尝试多问 GPT,基本也花不了多长时间就能解决。
但如果我们遇到的需求是制作 Su7 在线预览、制作 Win98 在线版、制作 Figma(通过 Webasmbly 与浏览器 Canvas 交互,让用户在浏览器端体验到了 Native 软件能力),那我们应该怎么办?薪资水平上不去的原因有部分原因就是这个了吧。我想到的解决方案是:重视源码阅读、重视数据结构和算法、甚至重视底层的数学。虽然上大学的时候就一直听说这句话,但是一直感悟不深。
在我重视了源码阅读之后,开始尝试去阅读 React 源码,然后顺着树形数据结构更新这个核心概念,延展出了 React Three Fiber。假设将时间线往前移动几年,并且我的设计和开发能力足够,可能我就成为了 React Three Fiber 的 Creator。也就是说,我顺着这样的思路再往后学习,可能也会发现一些新的场景,然后做出一些还不错的工具。
在 React 源码中有很多对于树这种数据结构的操作,让我重拾了对数据结构和算法的乐趣。在了解到还有 React Three Fiber 之后,我意识到常见的 Web 页面只是 2D 上交互,未来可能还有很多 3D 交互(比如在 Vision Pro 上),我想自学一下计算机图形学(在这之前可能得补一下线性代数、物理的光学)。好像挺多人推荐 GAMES101: 现代计算机图形学入门 这门课的。
内容创作者
作为一个前端开发者,有时候会逛到一些特别好看官网,就会特别羡慕这些开发者那么强。如:ONEUPSTUDIO、GSAP。后面我发现,网站好看与否很大程度上取决于设计师,软件好不好用取决于产品,功能能否实现取决于程序员。
开发者的职责是用代码将他们的想法表达出来,或者制作更好的工具帮助他们去创作和表达。工具之间的区别在于表现力、使用场景和使用范围大小,并且这些工具通常都是声明式的。设计师能使用 Webflow 这样的软件开开发出好看的网页,但是前端开发不一定能做到。作为程序员,最好的做法是制作更好的工具,如果直接表达想法,就会写出维护性和拓展性很差的代码。就像我上面提到的 Form,我应该提供一些制作 Form 的工具,而不是一个页面一个页面的绘制。
我们常见的 CSS 就是表现力非常强大的工具集,里面包含了 Table、Flex、Grid 等等工具。在没有 Flex 的时候,我们可以用 table-cell 这种这种的办法来实现垂直居中,但是在很多场景下使用起来比较麻烦,这就是工具的表现力差。后来引入 Flex,可以轻松的实现垂直居中。
程序员也是一种内容创作者,我大致的划分了一下互联网上的内容创作者,大致有如下的观点(不一定准确):
- 内容创作者可以大致分为:数学/物理、框架/库、软件/游戏/网页、视频/图片/文字
- 内容创作者如果从上到下排布,创作人数会呈现金字塔形状,从上到下创作难度从高到低、内容的数量会由少到多。
- 上层创作者为下层创作者提供基础设施(声明式的工具),并且可以简化下层创作的难度,但有可能会让下层创作者失去工作。
我在写 mini-react 的时候,相当于自己处于「框架/库」的内容创作者,我尝试直接在 codepen 中找一些有意思的作品,然后尽量去提供相关的机制(渲染、Diff、Hook)。作为「框架/库」的内容创作的的时候,可以很容易分析出整个软件的边界,我能知道 mini-react 能否做到某些功能。具体的页面绘制可以交给另一个层级的内容创作者,也可以尝试去复用他们的作品。
不同人有不同的喜好和擅长点。我很喜欢好看的软件,但我在绘画和艺术方面没有非常高的热情 Passion,往「数学/物理」这个方向去拓展应该会更适合我。有些人可能喜欢画画,会往「视频/图片/文字」这个方向,最终成为设计师。
过早优化是万恶之源
“过早优化是万恶之源”来源于 Donald Knuth 的《计算机编程艺术》一书,其原话是:
The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming.
真正的问题在于程序员在错误的地方和错误的时间花费了太多时间去关注效率;过早优化是万恶之源(或至少是大多数问题的根源)。
在学习 mini-react 的时候,我想到了一些问题:
- React 中还有很多种 API 和配置,那我是不是要都实现一遍?
- React 有很多优化手段,我是否都要看看?
答案都是“不”。通过 React 能实现的网页,用 JQuery 也能实现。这些优化手段和 API 是通常都针对非常具体的场景,其通用性是比较低的。在掌握了整个 React 的核心原理之后,遇到这些场景再去学习会更好。它们大多也是基于核心原理去拓展的,理解起来并不会非常耗时间。比如 React Hooks 和 Fiber 树紧密关联。
半年计划
用以终为始的眼光来看,我十年之后还会在现在这家公司吗?答案是绝对不会。不在沉默中死亡,就在沉默中爆发。继续在公司苟着也只是将问题隐藏起来,所以还是决定了离职。
现在找工作难度越来越大,前端更是重灾区,一个岗位可能几十个人投。在这种环境下,调整好自己的心态比较关键。苦思冥想后,想到了一句可以让自己保持 Passion 的话:“找不到工作这段时间就是提升能力,找到工作就是幸运的”。
最近看到一篇博文非常喜欢,这里转载一下《前端开发的瓶颈与未来之路》 中的一段文字:
我在 2018 年有幸参加了 TypeScirpt 的推广大会,TypeScript 的作者 Anders Hejlsberg 亲自主讲。一位将近 60 岁的程序员在讲台上滔滔不绝的讲技术方案,TS 的设计理念。你真的很难想像这样一位处于「知天命」阶段的老头子(实际上很年轻)讲的东西。
QA 环节有个年轻小伙问到 Anders「在中国做程序员很累、很难应该怎么坚持下去(类似这样的描述,细节记不清楚了)」的问题。
Anders 几乎毫不犹豫的说出了「Passion」这个单词。我瞬间就被打动了。因为在此之前我对于「激情」这个词的认识还停留在成功人士的演讲说辞层面,当 Anders 亲口说出 Passion 一词的时候,让人感觉真的是一字千金。
这里还有一些我非常喜欢的文章,给大家推荐一下:
来源:juejin.cn/post/7399273700117479474
一个普通人的27岁
致工作三年即将27岁的自己
这是一篇自己的碎碎念、即回顾自己以前的成长经历、也小小的持有一下对未来的期待。
我是一个双非本科从事于Java开发的一名普普通通的码农、不同于大多数人的27岁、大部分人在这个年龄都已经工作了4/5年、而我也恰恰刚刚满三年而已。
读书
小时候的记忆很模糊、很少关于有父母的记忆、从小的印象就是他们在很远的地方打工、那边还有一个从未谋面的哥哥、小时候的记忆更多是和爷爷奶奶在一起,爷爷在我记事起、他就很忙、很少在家里也或许是我不记事或者缺少了这部分的记忆。
在小时候的记忆里、住在茅草屋里面、那个时候家里还没有完全通电、印象里经常点煤油灯、这个时间段应该是02/03年的时候、记忆里这个时候家里养了一头牛、是一头老黄牛。家里在需要耕田播种的时候、不管风吹日晒、都能看见爷爷在田里一边驾驭着黄牛、嘴边一直在说什么、应该是教导牛牛该怎么走以便使的犁田犁的更好。记不清了、只知道每次遇到下雨的时候、爷爷披着蓑衣带着一个草帽、颇有一些武林大侠的气息。
那个时候家里有一条很凶很凶的狗、幺爷爷家里还是一条白猫、年龄比我那个时候都大。这条很凶的狗已经不记得长啥样了、甚至什么时候去世的都没有印象。
关于这条狗不多的记忆就是、它很凶、但凡看见我们在地坝(四川话:门前小院的意思、通常用于晒一些农作物的地方、或者夜晚乘凉的地方)里面打闹。它都会狂吠不止、这是对它的一个记忆。
还有一个记忆就是,记得是在某一个夏天、在屋后发现了一只野兔、这个时候不记得是不是爸妈在家了、全家都在追这个野兔、追了好久、这条狗也在追、有一个画面就是在我小时候的眼里那个农田的岸边很高、这个直接就从岸边往下面的田里跳下去、连续跳了好几个这样的梯田、那个姿势在我眼里好帅好帅、现在都记得很清楚。最后这个野兔是被抓住了、炸了酥肉、那个味道真的很香、现在都记忆深刻。毕竟小时候家里都是吃猪油、用很小很小的一块、煎出油炒菜。
幺爷爷在的猫是条白猫,印象里是一条抓老鼠的好手、但是不知道它什么死的、只记得大概有十三岁左右。
奶奶有风湿心脏病、那个时候总会吃一些药和一些偏方、记忆里有这么一幕、爷爷把刚出生的小狗狗杀掉、给奶奶治病、嘴馋的我也要吃、结果吃了一口就闷住了。
奶奶在我的记忆里有个画面就是我不想去读书、在上学的路上跑到了一个斜坡上、就如同那个时候黑白电视机上播放的游击战争片一样、以为自己躲在这里他们指定找不到、当然了最后肯定少不了一顿打。印象里只被奶奶打了这一次。
奶奶是在06年走的、不到六十岁。记忆特别深,当时哥哥从东北回老家也快一年了、那是在一个夜晚、哥哥先去睡了、我和其他堂哥堂姐在家里看电视、电视里播放的是洪金宝主演的、是一部战争片、大概就是他们去越南救什么人、有一个人在飞机上跳伞的时候说要倒数十个数、然后打开伞、结果这个嘴吃的人没数到就摔死了。里面有个画面用草杀人、后面还依葫芦画瓢学过这个东西。
奶奶走的时候、爷爷是第一个发现的、我记得爷爷发现之后、我去把哥哥喊醒了、然后我就一直在哭。虽然当时不知道死亡意味着什么、就是在哭、那个时候我上三年级了。奶奶走的那天的天气很好、我还记得我捡了一个螺母回家、后来我把这个螺母扔掉了、当时就想如果不捡这个螺母就好了、奶奶就不会走了。
第一次见哥哥的时候是在一个夏天、爸妈把他从东北送了回来、打算让他家里面读书、当然读书的地方现在已经垮掉了。那个时候家里的公路还是泥巴路、泥巴路大概还是前一年挖机采挖的、挖坏了几个秧田。他们在回来的前一年、写了一封信寄回来、内容是什么记不住了、只记得有一封信、分别向爷爷奶奶以及我都写了点东西。初次见面的时候很陌生、眼前这个和我差不多高有点黑的就是我哥、我的关注并没有在他的身上、更多的是他们提的袋子里面、因为有一袋子糖。
当然了小时候的记忆还有几个画面、就埋藏在心里吧、为什么说上面的狗狗很凶、因为他在我堂姐的脸上留下了印子、现在都能看见。
奶奶走掉之后、我和我哥就去了东北、因为家里没人会做饭了。就去东北读书、东北的记忆说不上多好、校园霸凌确实存在、我也是被霸凌的那一员、我不像我哥哥那样、他们总是有勇气、那个时候的我没有。
在东北这三年、父母总是在吵架打架、住在平房里面、附近都是和父母一样的体力劳动者、他们一闹附近的人都会知道。我们的右边住了一个也是一个外出务工者、他们的有个女儿、比我和我哥都大、长得很白。在我们的左边也是一户外地务工者、不过是东三省的、不是四川的、这家的女主人好像很贤惠很好看、长得也很白。
在这期间、四川发生了很大的一件事、汶川地震、当时我记得我和附近的小孩偷偷跑去上网、结果附近的网吧都没网、然后回家就看到电视上到处都在播放新闻、去上学的时候、学校组织了捐款、我捐了五块钱。
小学结束之后、过完了六年级的暑假我就被送回到了老家、走的时候是和爸爸在工地上的工友一路的、正好他要回家。他和我们是一个地方的,记得大概是午饭后、叫了一辆出租车、我就和这个工友上了车、爸爸的这个工友坐在了副驾、我坐在了后排、送行的人有几个、车窗升起、行驶了一段路后、眼泪就落下了、大概知道了以后又不会在爸妈身边了、也不知道为什么没有哭出声、就和电视里面一样。这就是小学的记忆。
大概走了三四天、回到家了、就开始上初中了。
报名的时候见到了很多小学同学、他们很容易就认出我来了、然而我并没有很快的认出他们、他们说我五官没什么变化、很好认。
初中是在一所民办初中读的、我们这边的公立学校很水、很乱、上课打牌抽烟都存在、老师也不会管。而且离我们也很远。民办学校离我家很近、这里的校长和附近的家长都很熟悉、自然而然的就去读了、自然而然的也会听到这样的交代、娃儿不听话不好好读书就整哦。初中的算是目前为止的小高光、因为那个时候自己还算聪明、成绩也还算可以、被当着升学的苗子重点关注。当然最后也还算争气、以A+1
的成绩考进县一中、我们这一届也还算争气、有一个去了同济大学、算是历史最好的一届了,当然这个学校现在也垮了。
高中的时候流传出了一个梗、你的数学不会是体育老师教的吧、那个时候会自嘲、我初中的时候、不止是数学是体育老师教的、历史和物理也是体育老师教的、这个老师还没上过高中。
高中是lol
很火的时候、那个时候脱离了棍棒教育的我、理所当然的沉迷了进去、高一上学期还好、棍棒教育的习惯还在、期末考试全年级2000多人我考了200名左右、班上第五名好像。
学习态度的变化不止是因为lol
、还记得当时班上有个人说我很努力、所以成绩这样、当时不知道是脑子抽了还是咋了就认为别人在说我笨、然后就慢慢放弃了之前的学习方式、再加上联盟的影响、自然而然的成绩一落千丈、后来也就去复读了。
复读这一年没什么特别的记忆、涨了几十分、去了一所双非学校。还是没有做到高一班主任说的那样、你好好读上个一本不成问题。当时学校的升学率是前60
名可以上川大的程度、200
名左右上一个一本好像确实不是什么问题。但也确实没做到。
上了大学就和大部分人一样、加部门、当班干部、实际上就是混吃等死。不同的是大二那年、由于初中埋下的病因、做了双侧股骨头置换手术、这一下就把家里面掏空了、手术是在北京做的、花了20+、是在18年、三月一号做的左腿三月14号下午14:17做的右腿、刚检测出来的时候很崩溃、出了诊室就哭了、因为知道这么大笔钱家里出不起、当时借住在北京的姐姐家,在十五楼窗口处、恐惧战胜了勇气、没有跳下去。
查出来的时候就告知了父母、父母当时在深圳上班、我一个人去的北京找的姐姐、父亲先赶过来、看见父亲憔悴的面庞、自己也彻底取消了跳下去的想法、太憔悴了、没见过这个样子的父亲、也无法去想象如果跳了父亲会咋样、只知道那个时候父亲的头发白了很多、然后开始秃头了。
做手术的那几天恰逢过年期间、医院的人很多、见识了人生百态、有的人痛苦呻吟着想活下去,有的人沉默不语想离开人世,坐在轮椅上的时候、被推出去透透风、看见了一个和我一般大的人、少了一条腿,那个时候心里想着都是苦命人。不同于大一暑假工被晒的黢黑的我,在学校看到一个老外、老外的黑衬托出我的白,那个时候由于被晒的黢黑心情很糟糕,见到这个交换生之后得到了极大的安慰。
因为这个手术需要人照顾、学校是上下铺、因此休学一年、手术很顺利、在我们眼里是一个天大的事情、在医生眼里如果一个小手术一般、就和普通的感冒差不多。术后也会恢复的很好、有一段时间是长短腿、走路一瘸一拐的、过了两个多月吧就彻底正常了。到目前为止至少没什么问题。唱跳rap不打篮球。
后面的大学时光就很平平无奇、本以为就和之前的师兄师姐一样正常大学然后毕业、后面就遇到了口罩事件、在学校都没有好好学习、在家里怎么可能会好好学习、真的是在混吃等死、大学期间没有什么特别的记忆、唯一的印象就是大一老校区是一群室友、大二搬到新校区、又换了一批室友、寝室从原来和其他专业的混寝、变成了同专业的混寝、但是由于休学一年、复学的时候又被安排到新的寝室、又换了一批室友、读了一年这一批室友毕业了、我大四的时候又换了一批室友。也就是一年一批室友。也算是独一份了。不过后面的都没怎么联系了。
这就是整个读书生涯了。还有很多画面就埋藏在心底吧。
工作
毕业之后、第一年认识了一个老师、养鱼达人、第一次约她出来玩、就问我用什么去接他、给了刚毕业的我一个暴击。于是呼在工作上加把力,从刚毕业的几千块不到一年的时间就破万了。也就是在22年左右吧。这个时候总觉得自己谈恋爱应该有点底气了。可在24年又给了我一个暴击。也就是今年。
在整个22年里面、由于工作还行、有大量的自由时间、在b上学习了尚硅谷的mysql和jvm课程、在慕课网上学习Java高并发课程、还算充实、虽然工作上用到的不多。
在22年、养了一只猫取名壹贰、是只三花、很粘人,也很喜欢、但我把它放在老家了。我的头像就是它很可爱吧。
在23年里、由于之前的学习累积、总觉得要记录一下、避免用的时候又到处找、就开始了写博客这个过程、博客更新的速度很稳定、生活节奏也很稳定、每天下班之后、买菜回家做晚饭和第二天中午的午餐、厨艺和刀工得到了大涨,每天晚上还能学习两小时、从周末开始选题、工作日开始编码、验证写博客、一切都有条不紊的进行着,生活节奏很稳定、窗外的阳光也很温暖。
23年发生了一件事、就是爷爷走了、遗憾的是没有带个孙媳妇回去让他看一眼、爷爷是五月份走的、守灵的那个晚上、睡在爷爷旁边、没有丝毫的害怕、下葬的那一天、没有哭但全是遗憾。至此带我长大的两个人都离开了人世。
在23年11月份的时候、认识了一个菇凉、她的名字很好听、长得也很好看、她的生活多姿多彩、现在都觉得她活得很多姿多彩。就和大家想的那样、慢慢的喜欢上了这个人、好巧不巧的是她对我也有点点意思吧、然后就约着出来玩、一起看电影、一起跨年等等、初期总是美好的、回忆也是。她不会做饭、总是吃外卖、我会让她点菜然后我做好了带给她吃、无论什么时候会送她回家然后自己再回家、每次见面都会给她准备一点零食或者小惊喜、理所当然的我们在一起了、直到过完年之后的某一个周末、我朋友约我们出去玩、在晚上回来的时候、我朋友买了房子(和女朋友一起买的)、刚好又说到这个问题、我就说了一句以后我们也一起买、用公积金带款、然后就因为这个问题讨论了一周、直到最后的分手。
具体的细节问题就不说了。我工作三年攒了一些钱、家里修房子我出了一点钱。一时间我家肯定是拿不出来的、我想让他给我点时间、结果不愿意、她之前有过很长一段时间的恋情被分手、大概是害怕再浪费时间、也能理解。
刚分手那段时间、感觉像是丢了半条命。心态很崩溃、觉得自己很差劲、一眼望到了头、好像也成不了家。掘金的更新速度就能看出来影响,虽然在一起的时间不长、三月份分的手、到现在为止有些时候都会因为这件事emo。
分手之前很喜欢做饭、分手之后再也没做过饭、看着那些为了给她做菜买的调味品以及打包盒、总是别有一番滋味、有时候总觉得自己要是当时做的再好一点就好了。在这期间看了一些心理学相关的书、也学会了一些调整自己的方法。
分手这段时间里、激情消费买了辆车、自驾去了一趟若尔盖大草原、草原很好看、自此身上的积蓄被自己花得差不多了。不止如此、由于工作上没有任何发展、总是干一些和Java无关的事情、甚至打算让我做嵌入式开发和大模型这一类工作,职业发展也看到了头。
整理生活这段时间丢了很多东西、总感觉自己也把自己丢了、好在慢慢的把自己拼好重新捡起来了。
下一个月也就马上27岁了、看着身边同龄的人要么成家、要么即将成家、要么事业有成、自己还是孤家寡人,多多少少也很羡慕。
站在生活这条十字路口、迷茫、彷徨、不安、焦虑每隔一段时间都会出现在自己身边、好在自己的调整能力得到了极大的提升、看书总归是有用的。
古人云:三十而立、至少现在看来、在这有限的时间里很难立起来了、但总要去试试、说不定就成了呢。
未来会是什么样子的呢?不知道,能把自己的生活过好就已经很不错了。感知幸福是一种能力、感知焦虑也是。
对生活的感悟如同总有千言万语、却有一种如鲠在喉的感觉。不知道命运会给我带来什么样的生活?不管怎么样都坦然接受吧。期待吗?期待吧。
写到这里、感受万千、内心细腻的人总是容易伤春悲秋。
回顾过往、就如同这篇文字一样、普普通通平平无奇、都无法用鸡肋来形容。但相信生活不会辜负每一个好好生活的人、始终对未来抱有期待与憧憬。不管最终如何、终将相信我们都会过上自己想要的生活。
最后给自己定一个目标吧:
- 坚持写博客、写到35岁,我相信自己会一直从事计算机行业的!
- 健健康康的活到退休。
窗外的天空很蓝、阳光很温暖、最近的心情也很好、希望您也是。
谢谢您能看到这里,祝君心想事成、万事顺遂。
来源:juejin.cn/post/7396609176744886310
fabric.js 实现服装/商品定制预览效果
大家好,我是秦少卫,vue-fabric-editor 开源图片编辑项目的作者,很多开发者有问过我如何使用 fabric.js 实现商品定制的预览效果,今天跟大家分享一下实现思路。
预览图:
简单介绍大部分开发这类产品的开发者,都会提到一个关键词叫做 POD ,按需定制,会通过设计工具简单的对产品进行颜色、图片的修改后,直接下单,获得自己独一无二的商品。
POD是什么?
按需定制(Print On Demand,简称POD),是一种订单履约方式,卖家提前设计好商品模板上架到销售平台,出单后,同步订到给供应商进行生产发货。
使用 fabric.js 实现商品定制预览,有 4 种实现方式
方式一:镂空 PNG 素材
这种方式最简单方便,只需要准备镂空的png素材,将图层放置在顶部不可操作即可,定制的图案在图层底部,进行拖拽修改即可,优点是简单方便,缺点是只能针对一个部位操作。
方式二:png阴影 + 色块 + 图案叠加
如果要进一步实现多个部位的定制设计,不同部位使用不同的定制图,第一种方案就无法满足了,那么可以采用透明阴影 + 色块叠加图案的方式来实现多个位置的定制。
例如这样的商品,上下需要 2 张不同的定制图案。
我们需要准备透明的阴影素材在最上方,下方添加色块区域并叠加图案:
最底部放上原始的图片即可。
方式三:SVG + 图案/颜色填充
fabric.js 支持导入 svg图片,如果是SVG形式的设计文件,只需要导入到编辑器中,对不同区域修改颜色或者叠加图案就可以。
方式四:平面图 + 3D 贴图
最后一种是平面图设计后,将平面图贴图到 3D 模型,为了效果更逼真,需要增加光源、法线等贴图,从实现上并不会太复杂,只是运营成本比较高,每一个 SKU 都需要做一个 3D模型。
参考 Demo:
结束
以上就是fabric.js 实现服装/商品定制预览效果的 4 种思路,如果你正在开发类似产品,也可以使用开源项目快速构建你的在线商品定制工具。
来源:juejin.cn/post/7403245452215386150
前端转产品一年总结!
截止至本月,我从前端研发转岗为产品经理已经一年左右了,这篇文章就讲讲我这一年中的一些经验和总结。希望能为同样身为前端也有想法转产品的你提供一点帮助。
为什么转产品
工作原因
我是22年毕业的,在刚刚毕业的那段时间,我还是对前端开发很有热情的,经常把工作中的一些内容给总结成文章在掘金发布,而且开发时我会比其他人更加注重整体的代码质量和实现的完整度,毕竟也要写文章嘛,输出倒逼输入。但是随着工作年限的增加(其实也没多少),我渐渐的感觉公司的业务没有什么挑战性了,而且最开始同事们还比较注重代码质量,但随着业务越来越复杂,排期越来越紧张,代码质量慢慢的就开始妥协了。
而且有一些功能实现方式比我想象的复杂很多,但是他们依然执着的要引用一些我不认可的框架或者库,代码这件事大家应该清楚,写的爽是很重要的,如果一个项目的实现都是我不喜欢的实现方式,为了一些虚无缥缈的性能优化把代码写的很复杂。
不知道大家有没有用过 SWR 这个请求库,我曾经写过一篇文章介绍这个工具 《都什么时代还在发传统请求?来看看 SWR 如何用 React Hook 实现优雅请求》,这是我很喜欢的一个请求库。它可以帮助我们在组件中获取请求数据时,不用在父组件获取后一个个通过 props 传递,而是直接在子组件中获取请求数据,并且不用重复请求。
但我有个同事认为直接使用 SWR 性能不好,因为每个组件都调用 hook 去请求,虽然 SWR key 是一样的,那肯定有个判重机制,这个判重机制对性能有影响。我当时就很无语,我说判重机制不就是用避免重复请求这件事情的嘛。更无语的是,他的解决方法时在父组件中使用 react context 创建上下文,通过 SWR 获取数据,将 SWR 中的数据传入 Context,然后在子组件中消费 Context。我当时就觉得这种写法就是恶心,脱裤子放屁行为,这么写何必用 SWR,直接用 Axios 请求后传子组件得了,为什么不直接在子组件里面调用 SWR 拿数据?
后面我与他掰扯,说我们按照 SWR 官方文档的方式来用就好了,无需多此一举,他的观点是 “SWR 又不是 React 官方的。他们的用法也不见得合适”,其他前端同事也没有很大异议,后续就是那段代码依然合并进去了,而且后续他都是这么写的。除此还有很多在 js 代码中使用单字母简写的变量,问就是跟xxx语言学的,我个人是觉得前端项目的变量名还是写全称,尽可能完整,让代码可读性更强,但我也懒得去争了,一旦大家的代码规范不同,那对我来说写代码就是很难受的一件事情了。关于这件事情我希望能在评论区看到大家的看法。
职业规划
其次是职业规划,这个倒不是说我最初就决定了想走产品这条路,而是我不太想一直走前端的路子了。我最初之所以选择前端开发的岗位,是由于我的大学专业是计算机相关,并且在我毕业前有过一段创业的经历,在那段经历中我担任的就是产品经理+前端开发的角色,因此找工作时也自然而然的投递前端相关的岗位。
但慢慢的,我在工作中感受不到前端技术很大的热情,脑袋里的灵感越来越少,工作上的代码写起来越来越乏味,而且社区里还是各种面试拷打,八股文,算法题最吃香。我是真的很讨厌八股文,我知道基础对于前端来说很重要,但还是讨厌,想到一旦要跳槽就要重拾八股文,我就觉得真的很无聊,所以我心理也埋下了要转产品的种子。
兴趣驱动
前面有讲到我在大学期间有创业过一阵子,当时我们一边做外包项目,一边做自己的一些校园产品,不论是网页还是移动端的产品,各个主流组件库都门清儿,而且也画过原型图,对一个产品从零到一的流程比较清晰,自己也对设计一个优秀的产品有执念,虽然至今还不算完成,但毕竟如果一件事情能让你有成就感,那么它就会推着你向前走。
怎么转的产品
在去年八月份,我们公司的产品要做一个大版本的迭代,很多功能都需要重新设计实现。在一个新产品诞生的初期,研发是依赖非常产品经理的产品文档,而产品经理则需要进行产品调研,概念设计等等流程才能写出一份产品方案,这就导致了产品有很大的产品方案设计的工作量,而研发只能先做一些基础框架搭建,写写技术方案,一旦这些简单工作做完就必须停下来等产品出文档,十几个研发两三天没活儿干,对于一个企业来说肯定是无法接受的。因此我们的领导就开始招聘产品经理,与此同时也在内部会议时主动问到有没有愿意尝试一下产品的工作。我本身就与公司里的产品比较熟悉,又有这么一个机会,于是我就毛遂自荐,并且当时在会上主动提出参与的也就我一个,于是就顺理成章的开始了研发向产品的转变。
遇到的问题
相信点进这篇文章的同学,有很多都是有过转岗为产品的想法的,那么我就说说从我自身出发,在从研发转为产品之后遇到了哪些问题是我没有意料到的。
专业知识
当研发时,每一次产品需求评审会上,我会去看这次新增了什么概念,思考这个需求如何实现,产品画的原型图交互流程有没有可以优化的地方,似乎自己对需求的理解能力还是很强的,也能够理解业务。
例如一个微信朋友圈的功能,产品经理提出放在发现页面,然后单击右上角加号可以选择照片发布,长按加号可以直接发布文字,那么我就会想长按这个操作是不是不够直观呢,会不会有用户很长时间了都没有发现这里是可以长按直接发布文字的?于是我就提出问题与产品探讨,长此以往,我会觉得我们的产品有时候考虑问题也不是特别全面嘛,给了我一种我上也行的错觉。
到了实际转变为产品经理后,你就会发现你要做的不只是思考朋友圈的发布是从个入口进去,而是老板给了你一个需求,是用户如何在微信中分享自己的动态。那这时候你就要想:
- “我是不是加一个类似 QQ 空间的功能?这个功能就叫动态?还是空间?还是叫微博?还是叫圈子?”
- “这个功能要放在我的页面还是聊天页面,还是说新增一个底部栏?”
- “这个功能和微博或 QQ 空间有什么本质上的不同?”
或者评审时有同事或领导问你“为什么要叫朋友圈?我觉得叫朋友圈我完全 get 不到是什么意思?”,你应该如何应对?
我在刚刚转产品的初期几乎每次评审都是心惊胆颤,因为我很难接住同事问的问题,而且我也不是一个性格刚猛的人,可以接受讨论但不太喜欢很激烈的争论。这个问题出在两个方面:
一是刚刚转产品,我对于过往的一些概念只是了解但没有深入了解。例如领导问道:“xxx功能为什么要这么设计?”,这个功能并不是一个新功能,而是一个存在已久的功能,但是设计上有些许不合理,但是你并不清楚这个功能具体的上下文,那么人家一问,我也只能支支吾吾。这样的经历在有过几次后就会给人一种你不够专业的感觉,你连系统里的功能设计都不清楚,你还配当产品经理?
二是对自己的职责定位及权限边界还不够清晰,以上面朋友圈的例子来说,我觉得我拍板不了朋友圈具体的名称,我得去找更有经验的同事一起讨论,虽然同事会给你提供意见参考,最终的决定还是得你来做。在方案评审时,发现领导对朋友圈这个概念设计有很多疑问,例如问你:“为什么不参考竞品?”,“这个入口放在发现页也太隐蔽了吧,我作为用户完全不会点进去?”
诸如此类的问题,刚开始我是完全接不住的,总是想着领导说的也有道理,那我就按照他的想法改吧。这样一来,领导提出的意见一旦比较尖锐,你就只能顺从,说明你没有自己的想法,没有自己的主见,即使有想法也不能坚持。尤其是你还没有足够的经验,更容易被人质疑。此时如果没有一颗大心脏,没有一个非常全面的调研和思考,就容易变得很不自信,从而丧失对产品这个岗位的工作热情。
刚刚转产品的那个月我非常煎熬,每次做方案和评审前都特别紧张,有时候睡觉前都在想要是每天同事问了这个问题要怎么回答?
与研发的对接的边界
在我还是前端的时候,我单纯的认为很多边界情况就是需要前端去考虑的,例如页面的加载状态是骨架屏还是加载动画,表单字段在输入时是失焦时触发表单验证还是点击提交按钮时触发表单验证,返回上一个页面是否需要保留页面状态,文本溢出时的省略效果如何。
但当我自己转为产品后,似乎这些情况还是得我来考虑,每天需要面对各种研发提出的各种组件边界情况如何处理,很多边界情况如果你的文档中没有写,那么研发可能就不考虑了,不做任何处理,直到问题被领导或用户发现了,这时候只要你文档中没有提,那多半就要背锅了。一个你自认为通用的空数据页,你这个页面画了图,那个页面没有画空页面的图,那么研发就可以理所当然的在这个页面没有数据时啥都不展示。
这个现象主要出现在产品初期。随着产品迭代到一定程度,产品团队和研发团队通常会达成共识。此时前端的组件已经覆盖了大多数边界场景,产品团队也了解研发团队可能在哪些地方容易疏忽,因此需要特别强调这些方面。
时间分配
前面讲到研发会时不时找你对各种事情,这就导致了你平时可能很少有大段的时间专注于做一件事,每天上午研发问几个小问题,下午问几个小问题,一天就这么过去了。相比于写代码,有时思路清晰的话就可以一整天都在写,遇到问题了再停下来查一查想一想,相对来说还是会有大片的时间去做事。
对于我个人来说,有时候一个需求给到我,我可能一时半会儿没有一个好的思路,这时候你即便怎么想,怎么查都很难有突破,但有时你睡个午觉,或者放空一下,就突然有解决方向了。这导致了我做为产品经理时比较少进入心流状态,我需要思考,需要头脑风暴,但有时候想不出来也不能僵在那儿,而一件事情悬而未决又让我很难受,就容易有焦虑的情绪。
职业规划的变化
虽然很多人说技术人员越老越不吃香,产品就不一样了,可以积累经验,未来的道路也更加广阔,有机会走的更远。话虽如此,但实际上对于前端开发来说,基本上工作的两三年就基本上对整体的前端技术栈有个深入使用的经验,无非就是 React 和 Vue 以及相关的生态,,再深入点就是一些特殊协议或者可视化方面的更深入的技术,在跳槽时,只要技术栈匹配基本都可以尝试。但是产品经理的话,通常都会要求有同个赛道的经验,例如做社交产品的公司,当然希望找有社交产品经验的产品经理,而产品的赛道可就多了
前端转产品的优势
前面提了这么多研发转产品可能遇到的问题,也不能光抽巴掌不喂糖,所以下面再讲下前端转产品的优势,这些点大家可能会更有共鸣一些。
技术背景
关于这一点,有很多网上的文章唱反调说你有研发背景,肯定满脑子技术思维,做产品的时候创新能力肯定不行,你被你技术背景束缚啦。
这一点从我个人出发,我只能说产品懂技术绝对是一个优势,无论是从功能设计,到与研发沟通,天然的少了一层隔阂,尤其我们公司的产品是基础软件,用户群体本身就是相对懂技术的人。刚刚转为产品经理时,产品相关专业能力虽然不足,考虑事情不周全,但是懂技术起码给你兜了底,你不会有过于天马行空的想法,研发在你面前不能也信口开河,大致的实现成本,排期时间你能够做到心里有数。
前端思维
前端思维和技术背景还是有些不同的,前端和后端对产品的理解方向有些不同,前端偏向交互,后端更偏向业务。因此这里我将技术背景和前端思维分开讲。
不同公司使用的原型工具可能不同,我们公司是直接由产品来出高保真原型,因此我们使用的工具是 Figma,
在我转岗产品一个月左右 《👨💻 Figma 协作设计工具:前端开发者视角快速上手指南》 我就写了一篇关于 Figma 的使用文章,我认为我的 Figma 上手速度是非常快的,这可能由两方面的因素。
- 一是我大学的时候就有自学平面设计,对于 PPT,PS 这类创意类工具的使用方式比较了解。
- 二就是前端思维了,因为做原型其实和用代码写页面的思路是一致的,你看到了一个页面,首先你会想这个页面会由哪些组件组成,这些组件是否已经实现过了?如果没有实现过是否可以抽离到组件库进行复用?
Figma 这个工具是我转产品后,公司才开始使用的,因此大家都不熟悉,那么交互设计的大头就落在我身上,我就拉着我们的 UI 同学一起搭建了一个组件库,这个组件库中包含了常用的所有表单组件,表格组件,还有一些页面组件,其中我根据过往的经验为组件配置了很多参数和插槽,保证插件的拓展性。
如果我没有前端相关的经验,我敢肯定从头要去了解这套组件体系,并熟练的去构建出一套兼容性好的组件库。是要花很多时间的。
后续我们还用上了 Figma 的分支管理功能,这一点和 Git 的分支管理也有些许类似,逻辑上的快速迁移学习为我上手相关工具增速了很多。
对未来的思考
虽然在当前公司已经当了一年的产品经理了,大部分的事情都可以轻松 cover,也算是处于舒适圈了,但是心里还是会焦虑,毕竟目前只是身处一个小公司,而且在产品方面的专业能力又难以评估,网上想要学习关于产品经理的相关专业知识几乎都是卖课的,而且我愈发觉得产品相关的知识不是看任何书籍或课程可以快速提升的,产品经理相关的能力以及可能碰到的问题都是要在解决实际需求的过程中去提升,而且专业的能力与产品的赛道息息相关,你在一个赛道的积累可能换个赛道就派不上用场了,因此还是得持续学习,让自己不论做什么行业都能保持竞争力。
代码的话现在偶尔写写自己感兴趣的小项目,只是没那么多了,公司内偶尔也需要我去支援研发。如果大家有有意思的小项目,小比赛欢迎拉我一起去折腾(不做外包)。
来源:juejin.cn/post/7395559155686604809
哭了,朋友当韭菜被割惨了
最近我的朋友,被某些知识付费坑得很惨。全程毫无干货可言。内容仅仅只适用于初级、或者说部分中级的程序员。为此,我的朋友交了大几千的学费,却收获甚微。
当然,你可能说,是你的朋友问题啊?你朋友烂泥扶不上墙,学习方法不对,别人都有很多成功的案例。什么offer收到手酸,外包入大厂。
我买这些课就是为了学习,入门一些语言。知识付费很合理呀!!
于是我跟我朋友在微信彻夜长谈,有了如下分析
先说结论
请擦亮你的慧眼,你的一分一毫来之不易。不到迫不得已,才当学费
为什么这么说?
首先,不管你是想就业,还是想学习一些新的技术,网上都有例子,github上也会有前沿的项目提供学习。
类型 | 结论 |
---|---|
学习新技术 | 某项技术开源出来,作为技术的布道者,恨不得你免费过去学习,然后你再发一篇文章,越来越多人学习你的技术。 |
就业 | 简历包装无非就是抄抄抄,抄别人的优秀代码。github开源项目就非常合适 |
其次,你学费,一定要做到利益最大化。必须要有以下两点
- 能学到大部分人都学不到的技术亮点。记住,是大部分人,一定要做到差异化
- 能学到优秀的学习方法,push你前进。
开启慧眼
现在市面的学习机构,鱼龙混杂。,B站大学,某识xin球,某ke时jian 甚至,在某音上,都有那种连麦做模拟面试,然后引导你付费学习。
就业环境不好,买方市场竞争激烈,某些人就抓住你的焦虑心理,坑你一把。回想你的求学生涯,是否也有类似被坑经历?醒醒吧,少年。能救你的,只有你自己。
当然,小海也会有潜龙。不可否认,知识付费为我们提供了便利性。
- 原本散乱无章的知识点,人家给你整理好了,你尽管就是学习,实践
- 面对焦虑,你觉得很迷茫,需要一个人指点你前进
- 能认识更多同样诉求的人,为以后学习,就业,甚至做生意提供可能
但是,某些不法分子,就是抓住你的这个心理,疯狂ge你韭菜。什么10块钱知识手册,19.9面试题,100块钱的项目视频。天天一大早,就转发一些公众号到你群上,dddd。
这些内容,不是说没有用。我们讨论适合人群,这类东西不适合中高级程序员。
说那么多,你得学会判断这个人是不是大佬
你都可以简历包装,为什么‘大佬’就不会是被包装的
那就稍微整理一下,哪些是真大佬,伪大佬
真伪大佬
某佬 | 博客 | 开源项目 | 学习人群 | 是否顺眼 |
---|---|---|---|---|
伪大佬 | 面试题居多,很多基础内容,没有干货 | 无,或者很少。动不动就是商城,博客 | 应届生占比较多 | 可能顺眼 |
真大佬 | 博客、论坛内容干货。整理分类完善,你能学到东西 | 有,某些大项目的贡献,同时也有优秀开源项目 | 应届生,中高级都有 | 大多数不顺眼,因为实在优秀 |
就学习人群做一个说明
- 在就业容易程度上,相对于初中高级别的程序员,应届生无论从考察的内容,招聘的人数。都会容易丢丢。
- 他说跟着他学,offer赢麻了。但是其中,找到工作的大多数都是应届生
就这些点,我们其实可以能判断个大概了。
记住,你想知识付费。一定要摸清他的底细,不能认为他说得都是对的。人家也是会包装的
你的hello world
或许每个程序员的第一行代码,都是
print("hello world")
我想说的是,请你记住你的初心。
- 转行过来当程序员,就是为了狠狠赚他一笔
- 喜欢写代码,苦中作乐
情况每个人都不太一样,这里不细说。明白你是谁,你还是否有动力能坚持下去。明白这一点,远比你在迷茫的时候病急乱投医更为重要,请勿过度焦虑
为此,后面会说一下如何学习,以及找工作如何不被骗
力量大会
事关钱包的问题,我们都得谨慎谨慎。就业市场那恶劣,朋友找不到工作还被坑了一把。骗子实在可恶。请你先自身强大,先自己找出问题,不花冤枉钱,避免传销式编程
如有雷同,纯属巧合,没有针对任何人,也没有动某些人的饭碗。
来源:juejin.cn/post/7357231056288055336
揭秘小米手机被疯狂吐槽的存储扩容技术
前段时间,在小米14的发布会上,雷布斯公布了名为“Xiaomi Ultra Space存储扩容”的技术,号称可以在512G的手机中再搞出来16G,256G的手机中再搞出8G。对于普通用户来说,能多得一些存储空间,无异是个很好的福利,不过也有网友说这是以损害存储使用寿命为代价的,那么真相到底如何呢?这篇文章我就从技术角度来给大家详细分析下。
认识闪存
首先让我们来了解一些手机存储的基本知识。
手机存储使用的是闪存技术,其本质和U盘、固态硬盘都是一样的。
在闪存中读写的基本单位是页(Page),比页更大的概念是块(Block),一个块会包含很多页。
虽然读写的基本单位都是页,但是写实际操作的很可能是块,这是为什么呢?
这要从删除谈起,在闪存中删除数据时不会立即删除页上的数据,而只是给页打上一个空闲的标签。这是因为谁也不知道这个页什么时候会再写入数据,这样处理起来比较简单快速。
再看写操作,如果写入分配的页是因为删除而空闲的,数据并不能立即写入,根据闪存的特性,此时需要先把页上之前存储的数据擦除,然后才能写入;但是闪存中擦除操作的基本单位是块,此时就需要先把整个块中的有效数据读出来,然后再擦除块,最后再向块中写入修改后的整块数据;这整个操作称为“读-改-写”。当然如果写入分配的页是空白的,并不需要先进行擦除,此时直接写入就可以了。
预留空间
小米这次抠出来的存储空间来源于一个称为“预留空间”的区域,它的英文全称是Over Provisio,简称 OP。
那么“预留空间”是什么呢?我将通过5个方面来介绍它的用途,让大家近距离认识下。
提高写入速度
在上面介绍闪存的基本知识时,我们谈到闪存的写操作存在一种“读-改-写”的情况,因为额外的读和擦除操作,这种方法的耗时相比单纯的写入会增加不少,闪存使用的时间越长,空白的空间越少,这种操作越容易出现,闪存的读写性能下降的越快。
为了提升写入的性能,我们可以先将新数据写入到预留空间,此时上层系统就可以认为已经写入完成,然后我们在后台将预留空间中的新数据和原数据块中需要保留的数据合并到一个新的数据块中,这样就避免了频繁的读-修改-写操作,从而可以大大提高写入速度。
垃圾回收和整理
在上面介绍闪存的基本知识时,我们还谈到删除数据并不是立即清除空间,而是给数据页打一个标签,这样做的效率比较高。这样做就像我们标记了垃圾,但是并没有把它们运走,时间久了,这些垃圾会占用很多的空间。这些垃圾空间就像一个个的小碎片,所以有时也把这个问题称为碎片化问题。
虽然我们可以通过“读-改-写”操作来重新利用这些碎片空间,包括通过异步的“读-改-写”操作来提升上层应用的写入效率,但无疑还是存在写入的难度,实际写入之前还是要先进行擦除。
为了解决上述问题,聪明的设计师们又想到了新办方法:让存储器在后台自动检测、自动整理存储中的数据碎片,而不是等到写入数据时再进行整理。
考虑到闪存的读擦写特性,当需要移除数据块中部分碎片或者将不同数据碎片合并时,就得把需要保留的数据先放到一个临时空间中,以免数据出现丢失,待存储中的数据块准备好之后再重新写入,预留空间就可以用作这个临时空间。
磨损均衡
闪存中每个块的写入次数都是有限制的,超过这个限制,块就可能会变得不可靠,不能再被使用。这就是我们通常所说的闪存的磨损。
为了尽可能延长闪存的使用寿命,我们需要尽量均匀地使用所有的闪存块,确保每个块的使用频率大致相同。这就是磨损均衡的主要目标。
假设我们发现块A的使用频率过高,我们需要将它的数据移动到没怎么用过的块B去,以达到磨损均衡的目的。首先,我们需要读取块A中的数据,然后将这些数据暂时存储到预留空间。然后,我们擦除块A,将它标记为空闲。最后,我们从预留空间中取出数据,写入到块B。实际上,磨损均衡的策略比这更复杂,不仅仅是看使用频率,还需要考虑其他因素,比如块的寿命,数据的重要性等。
可以看到,预留空间在这个过程中起到了临时存储数据的作用。
不过你可能会问,为什么不直接将块A的数据复制到块B,而需要一个临时空间?
这是因为在实际操作中直接复制块A的数据到块B会带来一些问题和限制。
假如直接进行这种数据复制,那么在数据从块A复制到块B的过程中,块A和块B中都会存在一份相同的数据,如果有其他进程在这个过程中访问了这份数据,可能会产生数据一致性的问题。此外,如果移动过程中发生意外中断,如电源故障,可能会导致数据在块B中只复制了一部分,而块A中的数据还未被擦除,这样就可能导致数据丢失或者数据不一致的问题。
而如果我们使用预留空间,也就是引入一个第三方,就可以缓解这些问题。我们先将数据从块A复制到预留空间,然后擦除块A,最后再将预留空间中的数据写入到块B。在这个过程中,我们可以借助预留空间来实现一些原子性的机制,来保证数据不会丢失和数据的一致性。
错误校正
预留空间还可以用来存储错误校正码(ECC)。如果在读取数据时发现有错误,可以用错误校正码来修复这些错误,提高数据的可靠性。
很多同学可能也不了解这个错误校正码的来龙去脉,这里多说几句。
我们知道计算机中的数据最终都是二进制的0和1,0和1使用硬件比较好表达,比如我们使用高电压表示1,低电压表示0。但是硬件有时候会出错,本来写进去的是1,读出来的却是0。为了解决这个问题,设计师们就搞出来个错误校正码,这个校正码是使用某些算法基于要存储的数据算出来的,存储数据的时候把它一起保存起来。读取数据的时候再使用相同的算法进行计算,如果两个校正码对不上,就说明存储的数据出现错误了。然后ECC算法可以通过计算知道是哪一位出现了错误,改正它就可以恢复正确的数据了。
注意ECC能够修正的二进制位数有限,因为可以修复的位数越多,额外需要的存储空间也越大,具体能修复几位要考虑出现坏块的概率以及数据的重要性。
坏块管理
当闪存单元变为坏块时,预留空间可以提供新的闪存单元来替代坏块,此时读取对应数据时不再访问坏块,而是通过映射表转到预留空间中读取,从而保证数据的存储和读取不受影响,提高了固态硬盘的可靠性和耐用性。
综上所述,预留空间在提升固态硬盘性能,延长其使用寿命,提高数据的可靠性等方面发挥着重要的作用。
小米的优化
根据公开资料,小米将预留空间的占比从6.9%压缩到了约3%。
那么小米是怎么做到的呢?以下是官方说法:
小米在主机端也基于文件管理深度介入了 UFS 的资源管理,通过软件实现“数据非必要不写入(UFS)”,通过软件 + 固件实现“写入数据非必要不迁移”,减少写入量的同时也实现了更好的 wear-leveling 和 WAF
还有一张图:
优化解读
这里用了一些术语,文字也比较抽象,我这里解读下:
UFS(Universal Flash Storage)即通用闪存存储,可以理解为就是手机中的存储模块。
“数据非必要不写入(UFS)”也就是先把数据写入到缓冲区,然后等收到足够的数据之后(比如1页),再写入闪存单元,这样就可以减少闪存单元的擦写次数,自然就能延长闪存单元的使用寿命,推迟坏块的产生。这个缓冲区类似于计算机的内存,如果突然掉电可能会丢失一部分数据,但是对于手机来说,突然掉电这个情况发生的几率极低,所以小米在这里多缓存点数据对数据丢失的影响很小,不过还是需要注意缓冲空间有限,这个值也不能太大,具体多少小米应该经过大量测试之后做了评估。
“写入数据非必要不迁移” 没有细说怎么做的,大概率说的是优化磨损均衡、垃圾回收和整理策略,没事别瞎整理,整理的时候尽量少擦写,目的还是延长闪存单元的使用寿命。
“增加坏块预留” 小米可以根据用户的使用情况调整坏块预留区的大小,比如用户是个重度手机使用狂,他用1年相当于别人用4年,小米系统就会增加坏块预留区,以应对擦写次数增加带来的坏块几率增加。注意这个调整是在云端实现的,如果手机不联网,这个功能还用不上。
wear-leveling:就是上面提到的磨损均衡,小米优化了均衡算法,减少擦写。
WAF:写放大,Write Amplification Factor,缩写WAF。写放大就是上面提到的“读-改-写”操作引起的,因为擦除必须擦掉整个块的数据,所以上层系统只需要写一个页的情况下,底层存储可能要重写一个块,从页到块放大了写操作的数据量。因为闪存的寿命取决于擦除次数,所以写放大会影响到闪存的使用寿命。
概括来说就是,小米从存储的预留空间中抠出来一部分作为用户存储,不过预留空间的减小,意味着坏块管理、错误纠正等可以使用的空间变小,这些空间变小会减少存储的使用寿命,所以小米又通过各种算法延缓了手机存储的磨损速度,如此则对大家的使用没有什么影响,而用户又能多得一些存储空间。
小米的测试结果
对于大家担心小米手机存储的寿命问题,小米手机系统软件部总监张国全表示:“按照目前重度用户的模型来评估,在每天写入40GB数据的条件下, 256GB的扩容芯片依然可以保证超过10年, 512GB可以超过20年,请大家放心。”
同时一般固态硬盘往往都拥有5年的质保,而很多消费者往往会5年之内更换手机。因此按着这个寿命数据来看,普通消费者并不用太担心“扩容芯片”的寿命问题。所以如果你的手机用不了10年,可以不用担心这个问题。
当然更多的测试细节,小米并没有透漏,比如读写文件的大小等。不过按照小米的说法,存储的供应商也做了测试,没有什么问题。这个暂时只能相信小米是个负责任的企业,做好了完备的测试。
最后小米搞了这个技术,申请了专利,但是又把标准和技术方案贡献给了UFS协会,同时还要求存储芯片厂商设置了半年的保护期,也就是说技术可以分享给大家,但是请大家体谅下原创的辛苦,所以半年后其它手机厂商才能用上。
大家猜一下半年后其它手机厂商会跟进吗?
来源:juejin.cn/post/7297423930225639465
差生文具多,这么些年,为了写代码我花了多少钱?
背景
转眼写代码有10多年了,林林总总花费了很多的钱,现在主要按照4大件来盘点下我都买了啥。
电脑
acer 4741g ¥4500+
这是我入门时的一款电脑,整体配置在当时还是属于中等的。
当时用的编辑器还是notepad++,在这个配置下,还是可以愉快的编码的。
mac air 2013 ¥8800+
当时被苹果的放进信封的广告创意所折服,这也是我的第一台apple,在之后就一直用苹果了。到手后的感觉是,薄,确实薄,大概只有我宏基的1/3-1/4厚。
当时apple的简洁,快速,很少的配置,让我在环境变量上苦苦挣扎的心酸得以释放。以后也不用比较各种笔记本参数了
mac book pro13 2015 ¥9000+
当时买这台的原因是因为air进水了,经常死机,修了2次后,又坏了。一怒之下,直接买了一台。
换了新的retina屏之后,色彩质量和效果都提升了不少,对比原来的air性能也是拉升了超级多。但是因为是16上半年买的,所以没体验到toch bar,到现在都没体验过。。。
这是我真正意义上的第一台十分满意的电脑,大概是当时的理想型了。
公司电脑
2016年下半年进了一家创业公司,公司配置了mac book pro,比我的配置还高,所以之后一直就是用公司的。
2021年换新公司,公司配了thinkpad,又一次开始用win。然后又被win各种打败,有时又有了换回mac的想法。
当前–mac book pro14 2021 ¥21999
主要入手的原因是公司的电脑我觉得太慢了。当时开发小程序,脚手架是公司自己的,每次打包都是全量的,没有缓存。所以每次打包短则7,8分钟,长则10多分钟。加上切分支/安装依赖(如果两个分支依赖版本不同,需要强制更新),导致我每天花费大量的时间等待上。
同事早于我入手了M1,反馈巨好,于是我也买了,想着配置拉的满点,但是还是高估了自己的钱包,低估了苹果的价格,只能退而求其次的选择了中档配置。
每次看着低低的负载,都是满满的安全感。
另外m1是支持stable diffusion的,所以偶尔我也会炼丹
显示器
dell U2424H ¥1384
其实在写代码之前也买过几台显示器,但是以程序员视角来说,第一台是这台。原因是当时公司也是这个型号,主要是能旋转,谁能拒绝一台自由旋转的显示器呢?
而且dell的质量和做工都不错,在当时是十分喜欢的。
小米 Redmi 27 ¥789.9
dell那台显示器是放在家里的,公司也需要显示器,而且自带设备每个月可以补贴100,所以就入手了这款,原因无他:便宜,也够大。
但是用久了,发现也有些问题。例如失真等,但是真的便宜,
厂家送寄,但因为合作内容没谈拢,本周寄回
键盘
当前-cherry G80-3000 ¥689
一把真正可以用到包浆的键盘,大多数看到这个键盘的感觉应该都是黄色,而不是原本的白色,不知道是不是材质的问题,极其容易变黄。同时由于键帽又不变黄,所以呈现了诡异的脏脏的颜色。
因为本身机械键盘的高度,所以建议加个手托比较好。各种轴也齐全,任君选择。
目前这个键盘在家里游戏了,毕竟是个全键盘
当前–京造C2 ¥253
选择这个键盘的原因嘛,同事有了,并且是一个带灯的键盘。手感比cherry硬一些,但还属于是能接受的程度,整体延迟比较低(也可能是因为有线的原因)。目前是在办公室使用的一款,当前这篇文章就是用这个敲出来的。
鼠标
总览
鼠标其实留在手边的不太多,大多数都是消耗品了,这么些年,各种有用过。大概用了不下10个鼠标,我只挑2个重点的说吧。
微软ie 3.0 ¥359
这是我用过最好的鼠标,没有之一。握感极佳,用久了也不累,比其他的鼠标都舒服万分。
当前–apple magic trapad ¥899
mac用户的最终归属就是板子,如果你刚开始用mac,那么建议直接用板子吧。支持原生手势操作,各种mac本身触控板的事情都完美适用,真正的跟你的电脑和为一体。
欢迎评论区留言你的设备
如上所述,我这年的大头是电脑,消耗品是鼠标、,那么你都花了多少钱呢?
来源:juejin.cn/post/7395473411651682343
30岁之前透支,30岁之后还债。
前言
看到不少私信问我为啥没有更新了,我没有一一回复,实在是身体抱恙,心情沉重,加上应付于工作,周旋于家庭,自然挤压了自我空间。
今天思来想去,重新执键,决定久违地又一次写点分享,奉劝大家珍惜身体,愉悦生活。
愉悦二字说来容易,但各位都一样,奔波于现实,劳累于生活,岂是三言两语就能改变的。
病来如山倒
我又病了,有些意外和突然的,令我措手不及。
一天早上我起来,脖子有些酸,就伸手揉揉捏捏,忽然发现脖颈左侧有一个肿块,仔细拿捏,发现竟然是在里面,而且硬邦邦的,伴有轻微的疼痛感。
当时早上对着镜子拍下来的肿块,我还保留了照片。
立马便一身冷汗冒出,我从未经历过这样的事情,去年身体毕竟出过问题,两相叠加之下,内心更是难以描述。
因为是周一,怀着忐忑的心情去上班了,接下来一直都有些神经兮兮,觉得自己身体出了大问题。
之前我有文章讲过自己去年其实已经检查出血脂的问题,停更半年之久,调养了一番,才真正感觉到身体有所恢复,根据我发文的日期可见一二。
恢复更新的这段时间,报复式地写作和分享,一度不知不觉地排到榜单第二,今天登录看了一下,居然还在月榜前三没下来,也是意外。
话说回来,人一旦身体冒出点病痛,整个心情都显得低沉萎靡,很快就能在方方面面反应出来。
我是硬着头皮上班的,抽空网上查了下好让自己有个心理准备。
百度一搜便是绝症,这是很多年前就知道的,但病急乱投医果然是人之本性,我毅然决然还是搜了。
然后,各种甲状腺之类的就来了,再搜,淋巴瘤也来了,再搜,好家伙,直接恶性肿瘤十有八九了。
面对未知而产生的接近绝望的心情,想必不少人有类似经验。
比如我,下意识先想到的竟然不是我是不是要完蛋了,而是想到自己是家中独子,父母年迈身体有恙,妻子操劳,孩子尚小,家中主要经济来源也是我。
我一旦倒下,实在不敢想,往深了一想各种负面因子都蜂拥而来。
我不知道有多少人和我的性格相似,就是身体出了这种未知的问题,一面觉得应该去医院看看,一面又怕折腾来去最后拿到最不可接受的结果,可能不知道反而能活久一点,大概就是这种心情了。
是的,我大体是个胆子还算大的人,也猛然间抗拒去医院了。
不去医院的结果,就是你每天都在意这个肿块,每天都要摸摸它是不是变小了,是不是消失了,每天都小心呵护着它,甚至还想对它说说话倾诉一下,像是自己偷养的小情人一样。
只盼着某天睡觉醒来,用手一摸,哈哈没有了这样。
我就是差不多一个月都这样惶惶不可终日地度过,直到这周六才被妻子赶去医院做了检查。
透支和还债
30岁之前透支,30岁之后还债。
说来好笑,摸到肿块的第二天吧,还有朋友私信找我合作,换做平时,我肯定欣然接受,并开始设计文稿。
但身体有问题,一切都索然无味了,再次真切地体会到这种被现实打碎一切欲望的撕裂感。
为什么我30岁之后身体慢慢开始出现各种问题,这两年我有静下心来思考过。
到底还是30岁之前透支太多了,30岁之后你依然养成30岁之前的生活习惯,无异于自杀行为。
我把身体比作一根橡皮筋,它大概只能扯那么长,我长期将它扯那么那么长,我以为它没事,直到有一次我将它扯那么那么那么长,砰的一声它就断了。
我们都无法知道自己的这根橡皮筋到底能扯多长,只要它没断,我们都觉得它还能扯很长,代价就是,只需断一次,你再也无法重来了。
30岁之前,我努力学习各种知识,熬夜那是家常便饭,睡一觉便生龙活虎。
我就像以前上学的三好学生一样,在学校我扎扎实实,放学了我还进补习班,补习班回来了我还上网学知识。
回头想想,真特么离谱啊,我上学都没这样,走上社会了竟然付出了之前在学校几倍的努力。
早知如此,我好好上学读书最后进入一个更优质的圈子,不就少走很多弯路了吗,但是谁又会听当年的老师和父母一番肺腑之言呢。
埋怨过去没有什么意义,只能偶尔借着都市小说幻想一下带着记忆重生回校园的自己。
细数下来,我30岁之前熬过的夜比我加的班还多,我不是天天加班,但好像真的天天熬夜。
可我身体一点问题都没有,我觉得自己不是那种被命运抛弃的人,内心一直这么侥幸,你是不是也和我一样呢。
30岁之后,该来的还是来了,32岁那年,我有一次咳嗽入院,反复高烧,退了又发烧,医生一度以为是新冠,或结核,或白血病什么的,后来全部检查了都不是,发现就是普通的肺部感染。
每天两瓶抗病毒的点滴,大概半个月才逐渐恢复,人都瘦脱相了,这是我人生头一次住院,躺在病床上像废人一样。
等到33岁也就是去年,偶然头晕了一次,那种眩晕,天旋地转,犯恶心,怎么站怎么坐怎么躺都不行,真正要死的感觉。
后面我一度以为是年纪轻轻得了高血压,结果查了下是血脂的问题,还不算严重,但继续下去很可能会变成一些心脑血管疾病。
我难以置信,这可都是老年病啊,我一个30几岁的程序员说来就来了?
调养半年多,肉眼可见身体有好转,我又开始没忍住熬夜了,想做自己的课题,想分享更多的东西,这些都要花时间,而且包括一些其他领域的内容,想得太多,自然花的时间就多。
一不小心就连续熬了一个多月,平均每晚都是2点左右躺下,有时中午还不午休,刷手机找素材。
终于,脖子上起了肿块,让我整个人都蒙圈了,觉得一切努力都是在玩弄自己,忽然间什么都没意思了。
我尽量把这种感受描述出来,希望你们能看明白,真切体会一二。
为什么30岁之后我一熬夜就有问题出现,说白了,30岁之前透支了已经,一来是身体负荷达到临界,二来养成了多年的坏习惯,一时想改还改不过来。
30岁之前真别玩弄自己的身体了xdm,橡皮筋断了就真断了,接不上了,接上了也没以前的弹性了。
健康取决于自律和心情
对于程序员来说,健康取决于两点:自律和心情。
30岁之前,请学会自律,学习时间自律,生活作息自律,一日三餐自律
,养成这样的习惯,30岁之后的你会受益匪浅。
自律真的很难,我就是一个很难做到的人,我有倔强地适应过,却又悲哀地失败了。
就像你是一个歇斯底里的人,忽然让你温文尔雅,你又能坚持多久呢。
我用很多鸡汤说服过自己,对于已经30几岁的我来说,也只能维持一段时间。
想看的多,想玩的多,想学的也多,时间是真不够啊,真想向天再借五百年。
我应该算是幸运的那一类,至少我这般透支身体,我还活着,也没用余生去直面绝望。
我用这两年的身体故障给自己上了重要的一课,人死如灯灭。
如果能重来,我一定会学习时间规划,我一定会把每天的时间安排的好好的。
我一定会保证一日三餐不落下,少吃外卖,多吃水果蔬菜。
我一定会保证每晚充足的睡眠,早睡早起,绝不熬夜。
我一定会每天下班和放假抽出一些时间运动和锻炼。
我不是说给自己听的,因为我已经透支了。
我是说给在看文章的你们听的,还年轻点的,还没透支的,请用我的现在当做你可能更坏的未来,早点醒悟,为时不晚。
自律很难,但不自律可能等死,这个选择一点也不难。
工作压力大,作为程序员是避免不了的,所以我以前有劝过大家,薪水的重要性只占一半,你应该追寻一份薪水尚可,但压力一定在承受范围内的工作,这是我认为在国内对于程序员来说相对友好的途径。
我进入IT行业目前为止的整个生涯中,学习阶段听到过传智播客张孝祥老师的猝死,工作阶段听说过附近的4396游戏公司里面30多岁程序员猝死,今年又听到了左耳朵耗子先生的离世。
我想着,那一天,离我和你还有多远。
心情真的很重要,至少能快速反应在身体上。
当我这周六被妻子劝说去检查的时候,我内心一直是紧张的,妻子没去,就在家陪着孩子,跟我说你自己去吧,如果有坏消息就别回复了,等回来再说,如果没什么事那就发个微信。
我想我理解她的意思了,点了点头就骑车去了医院。
医院真不是什么好地方,我就是给医院干活的,我全身上下都讨厌这里。
最煎熬的时间是做彩超前的一个多小时,因为人太多,我得排队,盯着大屏上的号序,我脑子里想了很多事情,甚至连最坏的打算都想好了。
人就很奇怪,越是接近黑暗,越是能回忆起非常多的往事,连高中打篮球挥洒汗水的模样和搞笑的投篮姿势都能想起来。
喊到我的时候,我心跳了一下,然后麻木地进去了,躺下的时候,医生拿着仪器对着我的脖子扫描,此时的我是近一个月以来第一次内心平静,当真好奇怪的感觉。
随着医生一句:没什么事,就一个淋巴结。
犹如审判一般,我感觉一下无罪释放了。
当时听到这句话简直犹如天籁,这会儿想起来还感觉毛孔都在欢快地愉悦。
我问她不是什么肿瘤或甲状腺吧,她说不是,就一个正常的淋巴结,可能是炎症导致了增生,这种一般3个多月至半年才会完全消掉。
这是当时拍的结果
拿给主任医师看了之后,对方也说一点事没有,只是告诫我别再熬夜了。
我不知道人生还会给我几次机会,但我从20几岁到30几岁,都没有重视过这个问题,也没有认真思考过。
直到最近,我才发现,活着真好。
当晚是睡得最踏实的一晚,一点梦都没做,中途也没醒,一觉到天亮。
更离谱的是,早上我摸了一下脖子,竟然真的小了点,这才短短一天,说了都没人信。
我头一次相信,心情真的会影响身体,你心情好了,身体的器官和血液仿佛都欢腾了起来。
如何保持一个好心情,原来这般重要,我拿自己的身体给大家做实验了,有用!
希望大家每天在自律的基础上保持好心情,不负年华,不负自己。
总结
xdm,好好活着,快乐活着。
来源:juejin.cn/post/7300564263344128051
三个月内遭遇的第二次比特币勒索
早前搭过一个wiki (可点击wiki.dashen.tech 查看),用于"团队协作与知识分享".把游客账号给一位前同事,其告知登录出错.
用我记录的账号密码登录,同样报错; 打开数据库一看,疑惑全消.
To recover your lost Database and avoid leaking it: Send us 0.05 Bitcoin (BTC) to our Bitcoin address 3F4hqV3BRYf9JkPasL8yUPSQ5ks3FF3tS1 and contact us by Email with your Server IP or Domain name and a Proof of Payment. Your Database is downloaded and backed up on our servers. Backups that we have right now: mm_wiki, shuang. If we dont receive your payment in the next 10 Days, we will make your database public or use them otherwise.
(按照今日比特币价格,0.05比特币折合人民币4 248.05元..)
大多时候不使用该服务器上安装的mysql,因而账号和端口皆为默认,密码较简单且常见,为在任何地方navicat也可连接,去掉了ip限制...对方写一个脚本,扫描各段ip地址,用常见的几个账号和密码去"撞库",几千几万个里面,总有一两个能得手.
被窃取备份而后删除的两个库,一个是来搭建该wiki系统,另一个是用来亲测mysql主从同步,详见此篇,价值都不大
实践告诉我们,不要用默认账号,不要用简单密码,要做ip限制。…
- 登录服务器,登录到mysql:
mysql -u root -p
- 修改密码:
尝试使用如下语句来修改
set password for 用户名@yourhost = password('新密码');
结果报错;查询得知是最新版本更改了语法,需用
alter user 'root'@'localhost' identified by 'yourpassword';
成功~
但在navicat里,原连接依然有效,而输入最新的密码,反倒是失败
打码部分为本机ip
在服务器执行
-- 查询所有用户
select user from mysql.user;
再执行
select host,user,authentication_string from mysql.user;
user及其后的host组合在一起,才构成一个唯一标识;故而在user表中,可以存在同名的root
使用
alter user 'root'@'%' identified by 'xxxxxx';
注意主机此处应为%
再使用
select host,user,authentication_string from mysql.user;
发现 "root@%" 对应的authentication_string
已发生改变;
在navicat中旧密码已失效,需用最新密码才可登录
参考:
这不是第一次遭遇"比特币勒索",在四月份,收到了这么一封邮件:
后来证明这是唬人的假消息,但还是让我学小扎,把Mac的摄像头覆盖了起来..
来源:juejin.cn/post/7282666367239995392
让生成式 AI 触手可及:火山引擎推出 NVIDIA NIM on VKE 最佳部署实践
技术行业近来对大语言模型(LLM)的关注正开始转向生产环境的大规模部署,将 AI 模型接入现有基础设施以优化系统性能,包括降低延迟、提高吞吐量,以及加强日志记录、监控和安全性等。然而这一路径既复杂又耗时,往往需要构建专门的平台和流程。
在部署 AI 模型的过程中,研发团队通常需要执行以下步骤:
环境搭建与配置:首先需要准备和调试运行环境,这包括但不限于 CUDA、Python、PyTorch 等依赖项的安装与配置。这一步骤往往较为复杂,需要细致地调整各个组件以确保兼容性和性能。
模型优化与封装:接下来进行模型的打包和优化,以提高推理效率。这通常涉及到使用 NVIDIA TensorRT 软件开发套件或 NVIDIA TensorRT-LLM 库等专业工具来优化模型,并根据性能测试结果和经验来调整推理引擎的配置参数。这一过程需要深入的 AI 领域知识,并且工具的使用具有一定的学习成本。
模型部署:最后,将优化后的模型部署到生产环境中。对于非容器化环境,资源的准备和管理也是一个需要精心策划的环节。
为了简化上述流程并降低技术门槛,火山引擎云原生团队推出基于 VKE 的 NVIDIA NIM 微服务最佳实践。通过结合 NIM 一站式模型服务能力,以及火山引擎容器服务 VKE 在成本节约和极简运维等方面的优势,这套开箱即用的技术方案将帮助企业更加快捷和高效地部署 AI 模型。
AI 微服务化:NVIDIA NIM
NVIDIA NIM 是一套经过优化的企业级生成式 AI 微服务,它包括推理引擎,通过 API 接口对外提供服务,帮助企业和个人开发者更简单地开发和部署 AI 驱动的应用程序。
NIM 使用行业标准 API,支持跨多个领域的 AI 用例,包括 LLM、视觉语言模型(VLM),以及用于语音、图像、视频、3D、药物研发、医学成像等的模型。同时,它基于 NVIDIA Triton™ Inference Server、NVIDIA TensorRT™、NVIDIA TensorRT-LLM 和 PyTorch 构建,可以在加速基础设施上提供最优的延迟和吞吐量。
为了进一步降低复杂度,NIM 将模型和运行环境做了解耦,以容器镜像的形式为每个模型或模型系列打包。其在 Kubernetes 内的部署形态如下:
NVIDIA NIM on Kubernetes
火山引擎容器服务 VKE(Volcengine Kubernetes Engine)通过深度融合新一代云原生技术,提供以容器为核心的高性能 Kubernetes 容器集群管理服务,可以为 NIM 提供稳定可靠高性能的运行环境,实现模型使用和运行的强强联合。
同时,模型服务的发布和运行也离不开发布管理、网络访问、观测等能力,VKE 深度整合了火山引擎高性能计算(ECS/裸金属)、网络(VPC/EIP/CLB)、存储(EBS/TOS/NAS)、弹性容器实例(VCI)等服务,并与镜像仓库、持续交付、托管 Prometheus、日志服务、微服务引擎等云产品横向打通,可以实现 NIM 服务构建、部署、发布、监控等全链路流程,帮助企业更灵活、更敏捷地构建和扩展基于自身数据的定制化大型语言模型(LLMs),打造真正的企业级智能化、自动化基础设施。
NVIDIA NIM on VKE 部署流程
下面,我们将介绍 NIM on VKE 的部署流程,助力开发者快速部署和访问 AI 模型。
准备工作
部署 NVIDIA NIM 前,需要做好如下准备:
1. VKE 集群中已安装 csi-nas / prometheus-agent / vci-virtual-kubelet / cr-credential-controller 组件
2. 在 VKE 集群中使用相适配的 VCI GPU 实例规格,具体软硬件支持情况可以查看硬件要求
3. 创建 NAS 实例,作为存储类,用于模型文件的存储
4. 创建 CR(镜像仓库) 实例,用于托管 NIM 镜像
5. 开通 VMP(托管 Prometheus)服务
6. 向 NVIDIA 官方获取 NIM 相关镜像的拉取权限(下述以 llama3-8b-instruct:1.0.0 为例),并生成 API Key
部署
1. 在国内运行 NIM 官方镜像时,为了避免网络访问影响镜像拉取速度,可以提前拉取相应 NIM 镜像并上传到火山引擎镜像仓库 CR,操作步骤如下:
2. Download the code locally, go to the Helm Chart directory of the code, and push Helm Chart to Container Registry (Helm version > 3.7):
下载代码到本地,进入到代码的 helm chart 目录中,把 helm chart 推送到镜像仓库(helm 版本大于 3.7):
3. 在 vke 的应用中心的 helm 应用中选择创建 helm 应用,并选择对应 chart,集群信息,并点击 values.yaml 的编辑按钮进入编辑页
4. 覆盖 values 内容为如下值来根据火山引擎环境调整参数配置,提升部署性能,点击确定完成参数改动,再继续在部署页点击确定完成部署
5. 若 Pod 日志出现如下内容或者 Pod 状态变成 Ready,说明服务已经准备好:
6. 在 VKE 控制台获取 LB Service 地址(Service 名称为
7. 访问 NIM 服务
The output is as follows:
会有如下输出:
监控
NVIDIA NIM 在 Grafana Dashboard 上提供了丰富的观测指标,详情可参考 Observability
在 VKE 中,可通过如下方法搭建 NIM 监控:
1. 参考文档搭建 Grafana:https://www.volcengine.com/docs/6731/126068
2. 进入 Grafana 中,在 dashboard 菜单中选择 import:
3. 观测面板效果如下:
结语
相比构建大模型镜像,基于 VKE 使用 NVIDIA NIM 部署和访问模型有如下优点:
● 易用性:NIM 提供了预先构建好的模型容器镜像,用户无需从头开始构建和配置环境,配合 VKE 与 CR 的应用部署能力,极大简化了部署过程
● 性能优化:NIM 的容器镜像是经过优化的,可以在 NVIDIA GPU 上高效运行,充分利用 VCI 的硬件性能
● 模型选择:NIM 官方提供了多种大语言模型,用户可以根据需求选择合适的模型,部署在 VKE 中仅需对values.yaml 配置做修改即可
● 自动更新:通过 NGC,NIM 可以自动下载和更新模型,用户无需手动管理模型版本
● 可观测性:NIM 内置了丰富的观测指标,配合 VKE 与 VMP 观测能力开箱即用
目前火山引擎容器服务 VKE 已开放个人用户使用,为个人和企业用户提供高性能、高可靠、极致弹性的企业级容器管理能力,结合 NIM 强大易用的模型部署服务,进一步帮助开发者快速部署 AI 模型,并提供高性能、开箱即用的模型 API 服务。(作者:李双)
收起阅读 »uniapp-实现安卓app水印相机
写在前面的话:最近要配合项目输出带水印的图片,之前的实现的方式是调uniapp封装好的相机,然后在图片输出的时候用canvas,把水印绘制上去,但是老感觉没有水印相机看着舒服.改成了现在的这种方式。
1.相机实现
水印相机实现有两种方式,在小程序端可以用camera来实现,但在安卓端不支持camera,使用uniapp的live-pusher来实现相机。
而live-pusher推荐使用nvue来做,好处是
- nvue也可一套代码编译多端。
- nvue的cover-view比vue的cover-view更强大,在视频上绘制元素更容易。如果只考虑App端的话,不用cover-view,任意组件都可以覆盖组件,因为nvue没有层级问题。
- 若需要视频内嵌在swiper里上下滑动(类抖音、映客首页模式),App端只有nvue才能实现 当然nvue相比vue的坏处是css写法受限,如果只开发微信小程序,不考虑App,那么使用vue页面也是一样的。
- App平台:使用
<live-pusher/>
组件,打包 App 时必须勾选 manifest.json->App 模块权限配置->LivePusher(直播推流) 模块。
上代码!
<template>
<view class="live-camera" :style="{ width: windowWidth, height: windowHeight }">
<view class="preview" :style="{ width: windowWidth, height: windowHeight }">
<live-pusher
id="livePusher"
ref="livePusher"
class="livePusher"
mode="FHD"
beauty="0"
whiteness="0"
:aspect="aspect"
min-bitrate="1000"
audio-quality="16KHz"
device-position="back"
:auto-focus="true"
:muted="true"
:enable-camera="true"
:enable-mic="false"
:zoom="false"
@statechange="statechange"
:style="{ width: windowWidth, height: windowHeight }"
></live-pusher>
<!--这里修改水印的样式-->
<cover-view class="remind">
<text class="remind-text" style="">{{ message }}</text>
<text class="remind-text" style="">经度:1002.32</text>
<text class="remind-text" style="">纬度:1002.32</text>
</cover-view>
</view>
<view class="menu">
<!--底部菜单区域背景-->
<cover-image class="menu-mask" src="/static/live-camera/bar.png"></cover-image>
<!--返回键-->
<cover-image class="menu-back" @tap="back" src="/static/live-camera/back.png"></cover-image>
<!--快门键-->
<cover-image class="menu-snapshot" @tap="snapshot" src="/static/live-camera/shutter.png"></cover-image>
<!--反转键-->
<cover-image class="menu-flip" @tap="flip" src="/static/live-camera/flip.png"></cover-image>
</view>
</view>
</template>
<script>
let _this = null;
export default {
data() {
return {
dotype: 'watermark',
message: '水印相机', //水印内容
poenCarmeInterval: null, //打开相机的轮询
aspect: '2:3', //比例
windowWidth: '', //屏幕可用宽度
windowHeight: '', //屏幕可用高度
camerastate: false, //相机准备好了
livePusher: null, //流视频对象
snapshotsrc: null //快照
};
},
onLoad(e) {
_this = this;
if (e.dotype != undefined) this.dotype = e.dotype;
this.initCamera();
},
onReady() {
this.livePusher = uni.createLivePusherContext('livePusher', this);
this.startPreview(); //开启预览并设置摄像头
this.poenCarme();
},
methods: {
//轮询打开
poenCarme() {
//#ifdef APP-PLUS
if (plus.os.name == 'Android') {
this.poenCarmeInterval = setInterval(function () {
console.log(_this.camerastate);
if (!_this.camerastate) _this.startPreview();
}, 2500);
}
//#endif
},
//初始化相机
initCamera() {
uni.getSystemInfo({
success: function (res) {
_this.windowWidth = res.windowWidth;
_this.windowHeight = res.windowHeight;
let zcs = _this.aliquot(_this.windowWidth, _this.windowHeight);
_this.aspect = _this.windowWidth / zcs + ':' + _this.windowHeight / zcs;
console.log('画面比例:' + _this.aspect);
}
});
},
//整除数计算
aliquot(x, y) {
if (x % y == 0) return y;
return this.aliquot(y, x % y);
},
//开始预览
startPreview() {
this.livePusher.startPreview({
success: (a) => {
console.log(a);
}
});
},
//停止预览
stopPreview() {
this.livePusher.stopPreview({
success: (a) => {
_this.camerastate = false; //标记相机未启动
}
});
},
//状态
statechange(e) {
//状态改变
console.log(e);
if (e.detail.code == 1007) {
_this.camerastate = true;
} else if (e.detail.code == -1301) {
_this.camerastate = false;
}
},
//返回
back() {
uni.navigateBack();
},
//抓拍
snapshot() {
this.livePusher.snapshot({
success: (e) => {
_this.snapshotsrc = e.message.tempImagePath;
_this.stopPreview();
_this.setImage();
uni.navigateBack();
}
});
},
//反转
flip() {
this.livePusher.switchCamera();
},
//设置
setImage() {
let pages = getCurrentPages();
let prevPage = pages[pages.length - 2]; //上一个页面
//直接调用上一个页面的setImage()方法,把数据存到上一个页面中去
prevPage.$vm.setImage({ path: _this.snapshotsrc, dotype: this.dotype });
}
}
};
</script>
<style lang="scss">
.live-camera {
justify-content: center;
align-items: center;
.preview {
justify-content: center;
align-items: center;
.remind {
position: absolute;
bottom: 180rpx;
left: 20rpx;
width: 130px;
z-index: 100;
.remind-text {
color: #dddddd;
font-size: 40rpx;
text-shadow: #fff 1px 0 0, #fff 0 1px 0, #fff -1px 0 0, #fff 0 -1px 0;
}
}
}
.menu {
position: absolute;
left: 0;
bottom: 0;
width: 750rpx;
height: 180rpx;
z-index: 98;
align-items: center;
justify-content: center;
.menu-mask {
position: absolute;
left: 0;
bottom: 0;
width: 750rpx;
height: 180rpx;
z-index: 98;
}
.menu-back {
position: absolute;
left: 30rpx;
bottom: 50rpx;
width: 80rpx;
height: 80rpx;
z-index: 99;
align-items: center;
justify-content: center;
}
.menu-snapshot {
width: 130rpx;
height: 130rpx;
z-index: 99;
}
.menu-flip {
position: absolute;
right: 30rpx;
bottom: 50rpx;
width: 80rpx;
height: 80rpx;
z-index: 99;
align-items: center;
justify-content: center;
}
}
}
</style>
2.水印图片绘制
图片水印返回上一页用<canvas>添加水印
<template>
<view class="page">
<view style="height: 80rpx;"></view>
<navigator class="buttons" url="../camera/watermark/watermark"><button type="primary">打开定制水印相机</button></navigator>
<view style="height: 80rpx;"></view>
<view>拍摄结果预览图,见下方</view>
<image class="preview" :src="imagesrc" mode="aspectFit" style="width:710rpx:height:710rpx;margin: 20rpx;"></image>
<canvas id="canvas-clipper" canvas-id="canvas-clipper" type="2d" :style="{width: canvasSiz.width+'px',height: canvasSiz.height+'px',position: 'absolute',left:'-500000px',top: '-500000px'}" />
</view>
</template>
<script>
var _this;
export default {
data() {
return {
windowWidth:'',
windowHeight:'',
imagesrc: null,
canvasSiz:{
width:188,
height:273
}
};
},
onLoad() {
_this= this;
this.init();
},
methods: {
//设置图片
setImage(e) {
console.log(e);
//显示在页面
//this.imagesrc = e.path;
if(e.dotype =='idphoto'){
_this.zjzClipper(e.path);
}else if(e.dotype =='watermark'){
_this.watermark(e.path);
}else{
_this.savePhoto(e.path);
}
},
//添加照片水印
watermark(path){
uni.getImageInfo({
src: path,
success: function(image) {
console.log(image);
_this.canvasSiz.width =image.width;
_this.canvasSiz.height =image.height;
//担心尺寸重置后还没生效,故做延迟
setTimeout(()=>{
let ctx = uni.createCanvasContext('canvas-clipper', _this);
ctx.drawImage(
path,
0,
0,
image.width,
image.height
);
//具体位置如需和相机页面上一致还需另外做计算,此处仅做大致演示
ctx.setFillStyle('white');
ctx.setFontSize(40);
ctx.fillText('live-camera', 20, 100);
//再来加个时间水印
var now = new Date();
var time= now.getFullYear()+'-'+now.getMonth()+'-'+now.getDate()+' '+now.getHours()+':'+now.getMinutes()+':'+now.getMinutes();
ctx.setFontSize(30);
ctx.fillText(time, 20, 140);
ctx.draw(false, () => {
uni.canvasToTempFilePath(
{
destWidth: image.width,
destHeight: image.height,
canvasId: 'canvas-clipper',
fileType: 'jpg',
success: function(res) {
_this.savePhoto(res.tempFilePath);
}
},
_this
);
});
},500)
}
});
},
//保存图片到相册,方便核查
savePhoto(path){
this.imagesrc = path;
//保存到相册
uni.saveImageToPhotosAlbum({
filePath: path,
success: () => {
uni.showToast({
title: '已保存至相册',
duration: 2000
});
}
});
},
//初始化
init(){
let _this = this;
uni.getSystemInfo({
success: function(res) {
_this.windowWidth = res.windowWidth;
_this.windowHeight = res.windowHeight;
}
});
}
}
};
</script>
<style lang="scss">
.page {
width: 750rpx;
justify-content: center;
align-items: center;
flex-direction:column;
display: flex;
.buttons {
width: 600rpx;
}
}
</style>
来源:juejin.cn/post/7399983106750447627
前端如何做截图?
一、 背景
页面截图功能在前端开发中,特别是营销场景相关的需求中, 是比较常见的。比如截屏分享,相对于普通的链接分享,截屏分享具有更丰富的展示、更多的信息承载等优势。最近在需求开发中遇到了相关的功能,所以调研了相关的实现和原理。
二、相关技术
前端要实现页面截图的功能,现在比较常见的方式是使用开源的截图npm库,一般使用比较多的npm库有以下两个:
- dom-to-image: github.com/tsayen/dom-…
- html2canvas: github.com/niklasvh/ht…
以上两种常见的npm库,对应着两种常见的实现原理。实现前端截图,一般是使用图形API重新绘制页面生成图片,基本就是SVG(dom-to-image)和Canvas(html2canvas)两种实现方案,两种方案目标相同,即把DOM转为图片,下面我们来分别看看这两类方案。
三、 dom-to-image
dom-to-image库主要使用的是SVG实现方式,简单来说就是先把DOM转换为SVG然后再把SVG转换为图片。
(一)使用方式
首先,我们先来简单了解一下dom-to-image提供的核心api,有如下一些方法:
- toSvg (dom转svg)
- toPng (dom转png)
- toJpeg (dom转jpg)
- toBlob (dom转二进制格式)
- toPixelData (dom转原始像素值)
如需要生成一张png的页面截图,实现代码如下:
import domtoimage from "domtoimage"
const node = document.getElementById('node');
domtoimage.toPng(node,options).then((dataUrl) => {
const img = new Image();
img.src = dataUrl;
document.body.appendChild(img);
})
toPng方法可传入两个参数node和options。
node为要生成截图的dom节点;options为支持的属性配置,具体如下:filter,backgroundColor,width,height,style,quality,imagePlaceholder,cacheBust。
(二)原理分析
dom to image的源码代码不是很多,总共不到千行,下面就拿toPng方法做一下简单的源码解析,分析一下其实现原理,简单流程如下:
整体实现过程用到了几个函数:
- toPng(调用draw,实现canvas=>png )
- Draw(调用toSvg,实现dom=>canvas)
- toSvg(调用cloneNode和makeSvgDataUri,实现dom=>svg)
- cloneNode(克隆处理dom和css)
- makeSvgDataUri(实现dom=>svg data:url)
- toPng
toPng函数比较简单,通过调用draw方法获取转换后的canvas,利用toDataURL转化为图片并返回。
function toPng(node, options) {
return draw(node, options || {})
.then((canvas) => canvas.toDataURL());
}
- draw
draw函数首先调用toSvg方法获得dom转化后的svg,然后将获取的url形式的svg处理成图片,并新建canvas节点,然后借助drawImage()方法将生成的图片放在canvas画布上。
function draw(domNode, options) {
return toSvg(domNode, options)
// 拿到的svg是image data URL, 进一步创建svg图片
.then(util.makeImage)
.then(util.delay(100))
.then((image) => {
// 创建canvas,在画布上绘制图像并返回
const canvas = newCanvas(domNode);
canvas.getContext("2d").drawImage(image, 0, 0);
return canvas;
});
// 新建canvas节点,设置一些样式的options参数
function newCanvas(domNode) {
const canvas = document.createElement("canvas");
canvas.width = options.width || util.width(domNode);
canvas.height = options.height || util.height(domNode);
if (options.bgcolor) {
const ctx = canvas.getContext("2d");
ctx.fillStyle = options.bgcolor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas;
}
}
- toSvg
- toSvg函数实现从dom到svg的处理,大概步骤如下:
- 递归去克隆dom节点(调用cloneNode函数)
- 处理字体,获取所有样式,找到所有的@font-face和内联资源,解析并下载对应的资源,将资源转为dataUrl给src使用。把上面处理完的css rules放入中,并把标签加入到clone的节点中去。
- 处理图片,将img标签的src的url和css中backbround中的url,转为dataUrl使用。
- 获取dom节点转化的dataUrl数据(调用makeSvgDataUri函数)
function toSvg(node, options) {
options = options || {};
// 处理imagePlaceholder、cacheBust值
copyOptions(options);
return Promise.resolve(node)
.then((node) =>
// 递归克隆dom节点
cloneNode(node, options.filter, true))
// 把字体相关的csstext放入style
.then(embedFonts)
// clone处理图片,将图片链接转换为dataUrl
.then(inlineImages)
// 添加options里的style放入style
.then(applyOptions)
.then((clone) =>
// node节点转化成svg
makeSvgDataUri(clone,
options.width || util.width(node),
options.height || util.height(node)));
// 处理一些options的样式
function applyOptions(clone) {
...
return clone;
}
}
- cloneNode
cloneNode函数主要处理dom节点,内容比较多,简单总结实现如下:
- 递归clone原始的dom节点,其中, 其中如果有canvas将转为image对象。
- 处理节点的样式,通过getComputedStyle方法获取节点元素的所有CSS属性的值,并将这些样式属性插入新建的style标签上面, 同时要处理“:before,:after”这些伪元素的样式, 最后处理输入内容和svg。
function cloneNode(node, filter, root) {
if (!root && filter && !filter(node)) return Promise.resolve();
return Promise.resolve(node)
.then(makeNodeCopy)
.then((clone) => cloneChildren(node, clone, filter))
.then((clone) => processClone(node, clone));
function makeNodeCopy(node) {
// 将canvas转为image对象
if (node instanceof HTMLCanvasElement) return util.makeImage(node.toDataURL());
return node.cloneNode(false);
}
// 递归clone子节点
function cloneChildren(original, clone, filter) {
const children = original.childNodes;
if (children.length === 0) return Promise.resolve(clone);
return cloneChildrenInOrder(clone, util.asArray(children), filter)
.then(() => clone);
function cloneChildrenInOrder(parent, children, filter) {
let done = Promise.resolve();
children.forEach((child) => {
done = done
.then(() => cloneNode(child, filter))
.then((childClone) => {
if (childClone) parent.appendChild(childClone);
});
});
return done;
}
}
function processClone(original, clone) {
if (!(clone instanceof Element)) return clone;
return Promise.resolve()
.then(cloneStyle)
.then(clonePseudoElements)
.then(copyUserInput)
.then(fixSvg)
.then(() => clone);
// 克隆节点上的样式。
function cloneStyle() {
...
}
// 提取伪类样式,放到css
function clonePseudoElements() {
...
}
// 处理Input、TextArea标签
function copyUserInput() {
...
}
// 处理svg
function fixSvg() {
...
}
}
}
- makeSvgDataUri
首先,我们需要了解两个特性:
- SVG有一个元素,这个元素的作用是可以在其中使用具有其它XML命名空间的XML元素,换句话说借助标签,我们可以直接在SVG内部嵌入XHTML元素,举个例子:
<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="120" height="50">
<body xmlns="http://www.w3.org/1999/xhtml">
<p>文字。</p>
</body>
</foreignObject>
</svg>
可以看到标签里面有一个设置了xmlns=“http://www.w3.org/1999/xhtml”…标签,此时标签及其子标签都会按照XHTML标准渲染,实现了SVG和XHTML的混合使用。
- XMLSerializer对象能够把一个XML文档或Node对象转化或“序列化”为未解析的XML标记的一个字符串。
基于以上特性,我们再来看一下makeSvgDataUri函数,该方法实现node节点转化为svg,就用到刚刚提到的两个重要特性。
首先将dom节点通过
XMLSerializer().serializeToString() 序列化为字符串,然后在
标签 中嵌入转换好的字符串,foreignObject 能够在 svg
内部嵌入XHTML,再将svg处理为dataUrl数据返回,具体实现如下:
function makeSvgDataUri(node, width, height) {
return Promise.resolve(node)
.then((node) => {
// 将dom转换为字符串
node.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
return new XMLSerializer().serializeToString(node);
})
.then(util.escapeXhtml)
.then((xhtml) => `<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject>`)
// 转化为svg
.then((foreignObject) =>
// 不指定xmlns命名空间是不会渲染的
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${
foreignObject}</svg>`)
// 转化为data:url
.then((svg) => `data:image/svg+xml;charset=utf-8,${svg}`);
}
四、 html2canvas
html2canvas库主要使用的是Canvas实现方式,主要过程是手动将dom重新绘制成canvas,因此,它只能正确渲染可以理解的属性,有许多CSS属性无法正确渲染。
支持的CSS属性的完整列表:
html2canvas.hertzen.com/features/
浏览器兼容性:
Firefox 3.5+ Google Chrome Opera 12+ IE9+ Edge Safari 6+
官方文档地址:
html2canvas.hertzen.com/documentati…
(一)使用方式
// dom即是需要绘制的节点, option为一些可配置的选项
import html2canvas from 'html2canvas'
html2canvas(dom, option).then(canvas=>{
canvas.toDataURL()
})
常用的option配置:
全部配置文档:
html2canvas.hertzen.com/configurati…
(二)原理分析
html2canvas的内部实现相对dom-to-image来说要复杂一些, 基本原理是读取DOM元素的信息,基于这些信息去构建截图,并呈现在canvas画布中。
其中重点就在于将dom重新绘制成canvas的过程,该过程整体的思路是:遍历目标节点和目标节点的子节点,遍历过程中记录所有节点的结构、内容和样式,然后计算节点本身的层级关系,最后根据不同的优先级绘制到canvas画布中。
由于html2canvas的源码量比较大,可能无法像dom-to-image一样详细的分析,但还是可以大致了解一下整体的流程,首先可以看一下源码中src文件夹中的代码结构,如下图:
简单解析一下:
- index:入口文件,将dom节点渲染到一个canvas中,并返回。
- core:工具函数的封装,包括对缓存的处理函数、Context方法封装、日志模块等。
- css:对节点样式的处理,解析各种css属性和特性,进行处理。
- dom:遍历dom节点的方法,以及对各种类型dom的处理。
- render:基于clone的节点生成canvas的处理方法。
基于以上这些核心文件,我们来简单了解一下html2canvas的解析过程, 大致的流程如下:
- 构建配置项
在这一步会结合传入的options和一些defaultOptions,生成用于渲染的配置数据renderOptions。在过程中会对配置项进行分类,比如resourceOptions(资源跨域相关)、contextOptions(缓存、日志相关)、windowOptions(窗口宽高、滚动配置)、cloneOptions(对指定dom的配置)、renderOptions(render结果的相关配置,包括生成图片的各种属性)等,然后分别将各类配置项传到下接下来的步骤中。
- clone目标节点并获取样式和内容
在这一步中,会将目标节点到指定的dom解析方法中,这个过程会clone目标节点和其子节点,获取到节点的内容信息和样式信息,其中clone dom的解析方法也是比较复杂的,这里不做详细展开。获取到目标节点后,需要把克隆出来的目标节点的dom装载到一个iframe里,进行一次渲染,然后就可以获取到经过浏览器视图真实呈现的节点样式。
- 解析目标节点
目标节点的样式和内容都获取到了之后,就需要把它所承载的数据信息转化为Canvas可以使用的数据类型。在对目标节点的解析方法中,递归整个DOM树,并取得每一层节点的数据,对于每一个节点而言需要绘制的部分包括边框、背景、阴影、内容,而对于内容就包含图片、文字、视频等。在整个解析过程中,对目标节点的所有属性进行解析构造,转化成为指定的数据格式,基础数据格式可见以下代码:
class ElementContainer {
// 所有节点上的样式经过转换计算之后的信息
readonly styles: CSSParsedDeclaration;
// 节点的文本节点信息, 包括文本内容和其他属性
readonly textNodes: TextContainer[] = [];
// 当前节点的子节点
readonly elements: ElementContainer[] = [];
// 当前节点的位置信息(宽/高、横/纵坐标)
bounds: Bounds;
flags = 0;
...
}
具体到不同类型的元素如图片、IFrame、SVG、input等还会extends ElementContainer拥有自己的特定数据结构,在此不详细贴出。
- 构建内部渲染器
把目标节点处理成特定的数据结构之后,就需要结合Canvas调用渲染方法了,Canvas绘图需要根据样式计算哪些元素应该绘制在上层,哪些在下层,那么这个规则是什么样的呢?这里就涉及到CSS布局相关的一些知识。
默认情况下,CSS是流式布局的,元素与元素之间不会重叠。不过有些情况下,这种流式布局会被打破,比如使用了浮动(float)和定位(position)。因此需要需要识别出哪些脱离了正常文档流的元素,并记住它们的层叠信息,以便正确地渲染它们。
那些脱离正常文档流的元素会形成一个层叠上下文。元素在浏览器中渲染时,根据W3C的标准,所有的节点层级布局,需要遵循层叠上下文和层叠顺序的规则,具体规则如下:
在了解了元素的渲染需要遵循这个标准后,Canvas绘制节点的时候,需要生成指定的层叠数据,就需要先计算出整个目标节点里子节点渲染时所展现的不同层级,构造出所有节点对应的层叠上下文在内部所表现出来的数据结构,具体数据结构如下:
// 当前元素
element: ElementPaint;
// z-index为负, 形成层叠上下文
negativeZIndex: StackingContext[];
// z-index为0、auto、transform或opacity, 形成层叠上下文
zeroOrAutoZIndexOrTransformedOrOpacity: StackingContext[];
// 定位和z-index形成的层叠上下文
positiveZIndex: StackingContext[];
// 没有定位和float形成的层叠上下文
nonPositionedFloats: StackingContext[];
// 没有定位和内联形成的层叠上下文
nonPositionedInlineLevel: StackingContext[];
// 内联节点
inlineLevel: ElementPaint[];
// 不是内联的节点
nonInlineLevel: ElementPaint[];
基于以上数据结构,将元素子节点分类,添加到指定的数组中,解析层叠信息的方式和解析节点信息的方式类似,都是递归整棵树,收集树的每一层的信息,形成一颗包含层叠信息的层叠树。
- 绘制数据
基于上面两步构造出的数据,就可以开始调用内部的绘制方法,进行数据处理和绘制了。使用节点的层叠数据,依据浏览器渲染层叠数据的规则,将DOM元素一层一层渲染到canvas中,其中核心具体源码如下:
async renderStackContent(stack: StackingContext): Promise<void> {
if (contains(stack.element.container.flags, FLAGS.DEBUG_RENDER)) {
debugger;
}
// 1. the background and borders of the element forming the stacking context.
await this.renderNodeBackgroundAndBorders(stack.element);
// 2. the child stacking contexts with negative stack levels (most negative first).
for (const child of stack.negativeZIndex) {
await this.renderStack(child);
}
// 3. For all its in-flow, non-positioned, block-level descendants in tree order:
await this.renderNodeContent(stack.element);
for (const child of stack.nonInlineLevel) {
await this.renderNode(child);
}
// 4. All non-positioned floating descendants, in tree order. For each one of these,
// treat the element as if it created a new stacking context, but any positioned descendants and descendants
// which actually create a new stacking context should be considered part of the parent stacking context,
// not this new one.
for (const child of stack.nonPositionedFloats) {
await this.renderStack(child);
}
// 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
for (const child of stack.nonPositionedInlineLevel) {
await this.renderStack(child);
}
for (const child of stack.inlineLevel) {
await this.renderNode(child);
}
// 6. All positioned, opacity or transform descendants, in tree order that fall int0 the following categories:
// All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
// For those with 'z-index: auto', treat the element as if it created a new stacking context,
// but any positioned descendants and descendants which actually create a new stacking context should be
// considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
// treat the stacking context generated atomically.
//
// All opacity descendants with opacity less than 1
//
// All transform descendants with transform other than none
for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
await this.renderStack(child);
}
// 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
// order (smallest first) then tree order.
for (const child of stack.positiveZIndex) {
await this.renderStack(child);
}
}
在renderStackContent方法中,首先对元素本身调用renderNodeContent和renderNodeBackgroundAndBorders进行渲染处理。
然后处理各个分类的子元素,如果子元素形成了层叠上下文,就调用renderStack方法,这个方法内部继续调用了renderStackContent,这就形成了对于层叠上下文整个树的递归。
如果子元素是正常元素没有形成层叠上下文,就直接调用renderNode,renderNode包括两部分内容,渲染节点内容和渲染节点边框背景色。
async renderNode(paint: ElementPaint): Promise<void> {
if (paint.container.styles.isVisible()) {
// 渲染节点的边框和背景色
await this.renderNodeBackgroundAndBorders(paint);
// 渲染节点内容
await this.renderNodeContent(paint);
}
}
其中renderNodeContent方法是渲染一个元素节点里面的内容,其可能是正常元素、文字、图片、SVG、Canvas、input、iframe,对于不同的内容也会有不同的处理。
以上过程,就是html2canvas的整体内部流程,在了解了大致原理之后,我们再来看一个更为详细的源码流程图,对上述流程进行一个简单的总结。
五、 常见问题总结
在使用html2canvas的过程中,会有一些常见的问题和坑,总结如下:
(一)截图不全
要解决这个问题,只需要在截图之前将页面滚动到顶部即可:
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
(二)图片跨域
插件在请求图片的时候会有图片跨域的情况,这是因为,如果使用跨域的资源画到canvas中,并且资源没有使用CORS去请求,canvas会被认为是被污染了,canvas可以正常展示,但是没办法使用toDataURL()或者toBlob()导出数据,详情可参考:developer.mozilla.org/en-US/docs/…
解决方案:在img标签上设置crossorigin,属性值为anonymous,可以开启CROS请求。当然,这种方式的前提还是服务端的响应头Access-Control-Allow-Origin已经被设置过允许跨域。如果图片本身服务端不支持跨域,可以使用canvas统一转成base64格式,方法如下。
function getUrlBase64_pro( len,url ) {
//图片转成base64
var canvas = document.createElement("canvas"); //创建canvas DOM元素
var ctx = canvas.getContext("2d");
return new Promise((reslove, reject) => {
var img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function() {
canvas.height = len;
canvas.width = len;
ctx.drawImage(img, 0, 0, len, len);
var dataURL = canvas.toDataURL("image/");
canvas = null;
reslove(dataURL);
};
img.onerror = function(err){
reject(err)
}
img.src = url;
});
}
(三)截图与当前页面有区别
方式一:如果要从渲染中排除某些elements,可以向这些元素添加data-html2canvas-ignore属性,html2cnavas会将它们从渲染中排除,例如,如果不想截图iframe的部分,可以如下:
html2canvas(ele,{
useCORS: true,
ignoreElements: (element: any) => {
if (element.tagName.toLowerCase() === 'iframe') {
return element;
}
return false;
},
})
方式二:可以将需要转化成图片的部分放在一个节点内,再把整个节点,透明度设置为0, 其他部分层级设置高一些,即可实现截图指定区域。
六、 小结
本文针对前端截图实现的方式,对两个开源库dom-to-image和html2canvas的使用和原理进行了简单的使用方式、实现原理方面,进行介绍和分析。
参考资料:
1.dom-to-image原理
2.html2image原理简述
3.浏览器端网页截图方案详解
4.html2canvas
5.html2canvas实现浏览器截图的原理(包含源码分析的通用方法)
来源:juejin.cn/post/7400319811358818340
好好的短链,url?1=1为啥变成了url???1=1
运营小伙伴突然找到我们说,我们的一个短链有三个?
第一反应就是不可能,但是事实胜于雄辩,还真的就是和运营小伙伴说的一模一样。
到底发生了什么呢?跟着我一起Review一下。
一、URL结构
1.1 URL概述
URL(统一资源定位符)是一个用于标识互联网上资源的地址。一个典型的URL结构通常包括以下几个部分:
- 协议(Scheme) :也称为"服务方式",位于URL的开头,指定了浏览器与服务器之间通信的方式。常见的协议有
http
(超文本传输协议)、https
(安全超文本传输协议)、ftp
(文件传输协议)等。 - 子域名(Subdomain) :可选部分,位于域名之前,通常用于区分不同的服务或组织。例如,在
sub.example.com
中,sub
是子域名。 - 域名(Domain Name) :URL的核心部分,用于唯一标识一个网站。通常是一个组织或公司的名字,如
example.com
。 - 端口号(Port) :可选部分,用于指定服务器上的特定服务。如果省略,浏览器将使用默认端口,例如
http
和https
的默认端口是80和443。 - 路径(Path) :指定服务器上的资源位置。路径可以包含多个部分,用斜杠
/
分隔。例如,在/path/to/resource
中,path/to/resource
是资源的路径。 - 查询字符串(Query String) :可选部分,位于路径之后,用于传递额外的参数或数据。查询字符串以问号
?
开始,后面跟着一系列的参数,参数之间用和号&
分隔。例如,在?key1=value1&key2=value2
中,key1
和key2
是参数名,value1
和value2
是对应的值。 - 片段标识符(Fragment Identifier) :可选部分,用于指向页面内的特定部分。片段标识符以井号
#
开始,通常用于锚点链接。例如,在#section2
中,section2
是页面内的一个锚点。
1.2 URL示例
示例:一个完整的URL示例可能是下面这样的
https://www.xxx.com:8080/path/to/resource?key1=value1&key2=value2#section2
在上面的示例中,详细拆解如下:
https
是协议。
http://www.xxx.com
是域名。
8080
是端口号。
/path/to/resource
是路径。
key1=value1&key2=value2
是查询字符串。
#section2
是片段标识符。
二、URL的意义
URL(统一资源定位符)的意义在于它提供了一种标准化的方法来标识和访问互联网上的资源。它是互联网的基础构件之一,它不仅使得资源的定位和访问变得简单,还支持了互联网的组织、导航、安全和分享等多种功能。以下是URL的几个关键意义:
这些意义做开发的都懂,不懂的就自己百度吧,这里不做赘述。
三、硬菜:url?1=1为啥变成了url???1=1
3.1 故事背景
我们有一个自己的短链项目,用户访问短链的时候,我们自己服务器会进行重定向,这样的好处是分享出去的链接都是很短的,会有效提升用户的使用体验。
短链触发和服务器的交互流程如下:
sequenceDiagram
用户->>+短链: 点击
短链->>+服务器: 请求
服务器->>+服务器: 找到映射的长链地址
服务器->>+用户: 重定向到长链
用户->>+长链: 请求并得到响应
3.2 事故现场
上面弄清楚了短链的基本触发流程,那我我们看看到底发生了什么。
- 客户端事故现场截图
从这个截图就可以明显的看出,这里有三个?,这是不合理的...
- 数据库存储的事故现场数据截图
哎,数据库里面只有一个问号吧?
3.3 问题分析和解决方案
- 问题分析
上面数据库看着正常的,别着急,咱们换个方式看看,我们执行下面这个SQL看看数据存储的实际长度是多少。
SELECT
LENGTH(
CONVERT ( full_link USING utf8 )) AS actual_length
FROM
t_short_link
WHERE
id = '0fcc75b3e1b243c4b36d71b1d58b3b41';
执行结果:
上面sql执行实际得到的长度是52,但是我们长链的实际长度却是49,那么问题就出来了,数据库里面多了两个我们肉眼看不见的字符,三个问号就是这个来的
- 解决方案
从上面分析了事故现场,我们已经知道是多了两个字符了,删掉即可。
注意:因为数据库看不到,所以不能直接编辑,可以选择一些可以看到的编辑器编辑之后更新,例如notepad++。
3.4 额外发现
在写文章的时候,我将连接复制到了掘金的MD编辑器,发现这里也是暴露了问题,上面提到的解决方案,大家也是可以复制进来然后删除多余字符的。
四、总结
程序员大多数都非常自信,相信自己的代码没有bug,相信有bug也不是我的问题,有的时候怼天怼地。
但是真的遇到问题,需要三思而后行,谋定而后动;是不是自己的问题,先检查检查,避免后面发现是自己的问题很尴尬。
希望本文对您有所帮助。如果有任何错误或建议,请随时指正和提出。
同时,如果您觉得这篇文章有价值,请考虑点赞和收藏。这将激励我进一步改进和创作更多有用的内容。
感谢您的支持和理解!
来源:juejin.cn/post/7399985723674394633
折腾我2周的分页打印和下载pdf
1.背景
一开始接到任务需要打印html,之前用到了vue-print-nb-jeecg来处理Vue2一个打印的问题,现在是遇到需求要在Vue3项目里面去打印十几页的打印和下载为pdf,难点和坑就在于我用的库vue3-print-nb来做分页打印预览,下载pdf后面介绍
2.预览打印实现
<div id="printMe" style="background:red;">
<p>葫芦娃,葫芦娃</p>
<p>一根藤上七朵花 </p>
<p>小小树藤是我家 啦啦啦啦 </p>
<p>叮当当咚咚当当 浇不大</p>
<p> 叮当当咚咚当当 是我家</p>
<p> 啦啦啦啦</p>
<p>...</p>
</div>
<button v-print="'#printMe'">Print local range</button>
因为官方提供的方案都是DOM加载完成后然后直接打印,但是我的需求是需要点击打印的时候根据id渲染不同的组件然后渲染DOM,后面仔细看官方文档,有个beforeOpenCallback方法在打印预览之前有个钩子,但是这个钩子没办法确定我接口加载完毕,所以我的思路就是用户先点击我写的点击按钮事件,等异步渲染完毕之后,我再同步触发真正的打印预览按钮,这样就变相解决了我的需求。
坑
- 没办法处理接口异步渲染数据展示DOM进行打印操作
- 在布局相对定位的时候在谷歌浏览器会发现有布局整体变小的问题(后续用zoom处理的)
3.掉头发之下载pdf
下载pdf这种需求才是我每次去理发店不敢让tony把我头发打薄的原因,我看了很多技术文章,结合个人业务情况,采取的方案是html2canvas把html转成canvas然后转成图片然后通过jsPDF截取图片分页最后下载到本地。本人秉承着不生产水,只做大自然的搬运工的匠人精神,迅速而又果断的从社区来到社区去,然后找到了适配当前业务的逻辑代码(实践出真知)。
import html2canvas from 'html2canvas'
import jsPDF, { RGBAData } from 'jspdf'
/** a4纸的尺寸[595.28,841.89], 单位毫米 */
const [PAGE_WIDTH, PAGE_HEIGHT] = [595.28, 841.89]
const PAPER_CONFIG = {
/** 竖向 */
portrait: {
height: PAGE_HEIGHT,
width: PAGE_WIDTH,
contentWidth: 560
},
/** 横向 */
landscape: {
height: PAGE_WIDTH,
width: PAGE_HEIGHT,
contentWidth: 800
}
}
// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element: HTMLElement, width: number) {
if (!element) return { width, height: 0 }
// canvas元素
const canvas = await html2canvas(element, {
// allowTaint: true, // 允许渲染跨域图片
scale: window.devicePixelRatio * 2, // 增加清晰度
useCORS: true // 允许跨域
})
// 获取canvas转化后的宽高
const { width: canvasWidth, height: canvasHeight } = canvas
// html页面生成的canvas在pdf中的高度
const height = (width / canvasWidth) * canvasHeight
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0)
return { width, height, data: canvasData }
}
/**
* 生成pdf(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
* @param param0
* @returns
*/
export async function outputPDF({
/** pdf内容的dom元素 */
element,
/** 页脚dom元素 */
footer,
/** 页眉dom元素 */
header,
/** pdf文件名 */
filename,
/** a4值的方向: portrait or landscape */
orientation = 'portrait' as 'portrait' | 'landscape'
}) {
if (!(element instanceof HTMLElement)) {
return
}
if (!['portrait', 'landscape'].includes(orientation)) {
return Promise.reject(
new Error(`Invalid Parameters: the parameter {orientation} is assigned wrong value, you can only assign it with {portrait} or {landscape}`)
)
}
const [A4_WIDTH, A4_HEIGHT] = [PAPER_CONFIG[orientation].width, PAPER_CONFIG[orientation].height]
/** 一页pdf的内容宽度, 左右预设留白 */
const { contentWidth } = PAPER_CONFIG[orientation]
// eslint-disable-next-line new-cap
const pdf = new jsPDF({
unit: 'pt',
format: 'a4',
orientation
})
// 一页的高度, 转换宽度为一页元素的宽度
const { width, height, data } = await toCanvas(element, contentWidth)
// 添加
function addImage(
_x: number,
_y: number,
pdfInstance: jsPDF,
base_data: string | HTMLImageElement | HTMLCanvasElement | Uint8Array | RGBAData,
_width: number,
_height: number
) {
pdfInstance.addImage(base_data, 'JPEG', _x, _y, _width, _height)
}
// 增加空白遮挡
function addBlank(x: number, y: number, _width: number, _height: number) {
pdf.setFillColor(255, 255, 255)
pdf.rect(x, y, Math.ceil(_width), Math.ceil(_height), 'F')
}
// 页脚元素 经过转换后在PDF页面的高度
const { height: tFooterHeight, data: headerData } = footer ? await toCanvas(footer, contentWidth) : { height: 0, data: undefined }
// 页眉元素 经过转换后在PDF的高度
const { height: tHeaderHeight, data: footerData } = header ? await toCanvas(header, contentWidth) : { height: 0, data: undefined }
// 添加页脚
async function addHeader(headerElement: HTMLElement) {
headerData && pdf.addImage(headerData, 'JPEG', 0, 0, contentWidth, tHeaderHeight)
}
// 添加页眉
async function addFooter(pageNum: number, now: number, footerElement: HTMLElement) {
if (footerData) {
pdf.addImage(footerData, 'JPEG', 0, A4_HEIGHT - tFooterHeight, contentWidth, tFooterHeight)
}
}
// 距离PDF左边的距离,/ 2 表示居中
const baseX = (A4_WIDTH - contentWidth) / 2 // 预留空间给左边
// 距离PDF 页眉和页脚的间距, 留白留空
const baseY = 15
// 除去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
const originalPageHeight = A4_HEIGHT - tFooterHeight - tHeaderHeight - 2 * baseY
// 元素在网页页面的宽度
const elementWidth = element.offsetWidth
// PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度 转化为 距离Canvas顶部的高度
const rate = contentWidth / elementWidth
// 每一页的分页坐标, PDF高度, 初始值为根元素距离顶部的距离
const pages = [rate * getElementTop(element)]
// 获取该元素到页面顶部的高度(注意滑动scroll会影响高度)
function getElementTop(contentElement) {
if (contentElement.getBoundingClientRect) {
const rect = contentElement.getBoundingClientRect() || {}
const topDistance = rect.top
return topDistance
}
}
// 遍历正常的元素节点
function traversingNodes(nodes) {
for (const element of nodes) {
const one = element
/** */
/** 注意: 可以根据业务需求,判断其他场景的分页,本代码只判断表格的分页场景 */
/** */
// table的每一行元素也是深度终点
const isTableRow = one.classList && one.classList.contains('ant4-table-row')
// 对需要处理分页的元素,计算是否跨界,若跨界,则直接将顶部位置作为分页位置,进行分页,且子元素不需要再进行判断
const { offsetHeight } = one
// 计算出最终高度
const offsetTop = getElementTop(one)
// dom转换后距离顶部的高度
// 转换成canvas高度
const top = rate * offsetTop
const rateOffsetHeight = rate * offsetHeight
// 对于深度终点元素进行处理
if (isTableRow) {
// dom高度转换成生成pdf的实际高度
// 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
updateTablePos(rateOffsetHeight, top)
}
// 对于普通元素,则判断是否高度超过分页值,并且深入
else {
// 执行位置更新操作
updateNormalElPos(top)
// 遍历子节点
traversingNodes(one.childNodes)
}
updatePos()
}
}
// 普通元素更新位置的方法
// 普通元素只需要考虑到是否到达了分页点,即当前距离顶部高度 - 上一个分页点的高度 大于 正常一页的高度,则需要载入分页点
function updateNormalElPos(top) {
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight)
}
}
// 可能跨页元素位置更新的方法
// 需要考虑分页元素,则需要考虑两种情况
// 1. 普通达顶情况,如上
// 2. 当前距离顶部高度加上元素自身高度 大于 整页高度,则需要载入一个分页点
function updateTablePos(eHeight: number, top: number) {
// 如果高度已经超过当前页,则证明可以分页了
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight)
}
// 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页,将当前高度作为分页位置
else if (
top + eHeight - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight &&
top !== (pages.length > 0 ? pages[pages.length - 1] : 0)
) {
pages.push(top)
}
}
// 深度遍历节点的方法
traversingNodes(element.childNodes)
function updatePos() {
while (pages[pages.length - 1] + originalPageHeight < height) {
pages.push(pages[pages.length - 1] + originalPageHeight)
}
}
// 对pages进行一个值的修正,因为pages生成是根据根元素来的,根元素并不是我们实际要打印的元素,而是element,
// 所��要把它修正,让其值是以真实的打印元素顶部节点为准
const newPages = pages.map(item => item - pages[0])
// 根据分页位置 开始分页
for (let i = 0; i < newPages.length; ++i) {
// 根据分页位置新增图片
addImage(baseX, baseY + tHeaderHeight - newPages[i], pdf, data!, width, height)
// 将 内容 与 页眉之间留空留白的部分进行遮白处理
addBlank(0, tHeaderHeight, A4_WIDTH, baseY)
// 将 内容 与 页脚之间留空留白的部分进行遮白处理
addBlank(0, A4_HEIGHT - baseY - tFooterHeight, A4_WIDTH, baseY)
// 对于除最后一页外,对 内容 的多余部分进行遮白处理
if (i < newPages.length - 1) {
// 获取当前页面需要的内容部分高度
const imageHeight = newPages[i + 1] - newPages[i]
// 对多余的内容部分进行遮白
addBlank(0, baseY + imageHeight + tHeaderHeight, A4_WIDTH, A4_HEIGHT - imageHeight)
}
// 添加页眉
if (header) {
await addHeader(header)
}
// 添加页脚
if (footer) {
await addFooter(newPages.length, i + 1, footer)
}
// 若不是最后一页,则分页
if (i !== newPages.length - 1) {
// 增加分页
pdf.addPage()
}
}
return pdf.save(filename)
}
4.分页的小姿势
如果有需求把打印预览的时候的页眉页脚默认取消不展示,然后自定义页面的边距可以这么设置样式
@page {
size: auto A4 landscape;
margin: 3mm;
}
@media print {
body,
html {
height: initial;
padding: 0px;
margin: 0px;
}
}
5.关于页眉页脚
由于业务是属于比较自定义化的展示,所以我封装成组件,然后根据返回的数据进行渲染到每个界面,然后利用绝对定位放在相同的位置,最后一点小优化就是,公共化提取界面的样式,然后整合为pub.scss然后引入到界面里面,这样即使产品有一定的样式调整,我也可以在公共样式里面去配置和修改,大大的减少本人的工作量。在日常的开发中也是这样,不要去抱怨需求的变动频繁,而是力争在写组件的过程中考虑到组件的健壮性和灵活度,给自己的工作减负,到点下班。
参考文章
来源:juejin.cn/post/7397319113796780042
借助 LocatorJS ,快速定位本地代码
引言
前端coder在刚接触一个新项目时是十分迷茫的,修改一个小 bug 可能要从路由结构入手逐级查找。 LocatorJS 提供了一种更便捷的方式,你只需要按住预设的快捷键选中元素点击,就可以快速打开本地编辑器中的代码,是不是非常神奇?
安装
访问 google 商店进行插件安装 地址
用法
本文以 MacOS 系统为例, Win系统可以用 Control 键替代 options键使用
LocatorJS 是 Chrome 浏览器的一个扩展程序,使用很便捷,只需要进行下面的三个步骤:
- 运行一个本地项目(本文以 LocatorJS源码 的 React 项目为例)
- 打开项目访问本地链接(例如:http://localhost:3348 )
- 按住键盘的 option 键(win系统是 control)后选中某一个元素并点击
这时候,就会跳出一个是否打开的提示,点击 “打开Visual Studio Code” 后 元素所在的本地代码就会通过你的 VsCode
(或者其他编辑器) 打开。是不是很神奇,那么它是怎么实现的呢?
原理解读
解读 Chrome 扩展程序,我们先打开 apps/extension/src/pages 路径,可以看到如下几个文件夹:
● Background 是放置后台代码的文件夹,本插件不涉及
● ClientUI 这里只有一行,引入了 @locator/runtime(本插件的核心代码)
● Content 放着插件与浏览器内容页面的代码,与页面代码一起执行
● Popup 文件夹下是点击浏览器插件图标弹出层的代码
4.1 解读 Content/index.ts
Content/index.ts 中最重要的代码是 injectScript
方法,主要做了两件事情,一个是创建了 Script 标签执行了 hook.bundle.js,另一个是将 client.bundle.js 赋值给了 document.documentElement.dataset.locatorClientUrl
(通过 Dom 传值),其余代码是一些监听事件
function injectScript() {
const script = document.createElement('script');
// script.textContent = code.default;
script.src = browser.runtime.getURL('/hook.bundle.js');
document.documentElement.dataset.locatorClientUrl =
browser.runtime.getURL('/client.bundle.js');
// This script runs before the <head> element is created,
// so we add the script to <html> instead.
if (document.documentElement) {
document.documentElement.appendChild(script);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
}
}
4.2 解读 hook.bundle.js
hook.bundle.js 是 hook 文件夹下的 index文件打包后的产物,因此我们去·看 apps/extension/src/pages/hook/index.ts 即可
import { installReactDevtoolsHook } from '@locator/react-devtools-hook';
import { insertRuntimeScript } from './insertRuntimeScript';
installReactDevtoolsHook();
insertRuntimeScript();
● installReactDevtoolsHook 会确保你的 react devtools扩展已安装 (没安装就install一个,猜测是仅涉及使用 API 的轻量版(笔者未深究))
● insertRuntimeScript 会对页面生命周期做一个监听,尝试加载 LocatorJS 的 runtime
组件, 在 insertRuntimeScript()
中,看到了这两行:
const locatorClientUrl = document.documentElement.dataset.locatorClientUrl;
delete document.documentElement.dataset.locatorClientUrl;
这个 locatorClientUrl
就是之前在 Content/index.ts
里传值的那个 client.bundle.js
,这里笔者简单说下,在尝试加载插件的方法 tryToInsertScript()
第一行判断如下:
if (!locatorClientUrl) {
return 'Locator client url not found';
}
这行判断其实已经可以推测出 client.bundle.js
的重要性了,它加载失败,整个插件直接返回错误信息了。
回过头来看向 ClientUI 文件夹下的 index.tsx 文件:
import '@locator/runtime';
至此,我们已经完成了 locatorJs
的加载逻辑推导,下一步我们讲揭开“定位器”的神秘面纱...
4.3 解读核心代码 runtime 模块
打开 packages/runtime/src/index.ts 文件
在这里我们看到不论是本地加载 runtime,还是浏览器加载扩展的方式都会去执行 initRuntime
initRuntime.ts
packages/runtime/src/initRuntime.ts
的initRuntime
这个文件中声明了一些全局样式,并用 shadow dom 的方式进行了全局的样式隔离,我们关注下底部的这几行代码即可:
// This weird import is needed because:
// SSR React (Next.js) breaks when importing any SolidJS compiled file, so the import has to be conditional
// Browser Extension breaks when importing with "import()"
// Vite breaks when importing with "require()"
if (typeof require !== "undefined") {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { initRender } = require("./components/Runtime");
initRender(layer, adapter, targets || allTargets);
} else {
import("./components/Runtime").then(({ initRender }) => {
initRender(layer, adapter, targets || allTargets);
});
}
兼容了一下服务端渲染和 SolidJs 的引入方式,引入相对路径下的 ./components/Runtime
核心组件 Runtime.tsx
packages/runtime/src/components/Runtime.tsx
抽丝剥茧,我们终于找到了它的核心组件 Runtime
,这是一个使用 SolidJs
框架编写的组件,包含了我们选中元素时出现的红框样式,以及所有的事件:
我们重点关注点击事件 clickListener
,最后点击跳转的方法是 goToLinkProps
export function goToLinkProps(
linkProps: LinkProps,
targets: Targets,
options: OptionsStore
) {
const link = buildLink(linkProps, targets, options);
window.open(link, options.getOptions().hrefTarget || HREF_TARGET);
}
采用逆推的方式,看 clickListener
事件里的 LinkProps
是怎样生成的:
function clickListener(e: MouseEvent) {
...
const elInfo = getElementInfo(target, props.adapterId);
if (elInfo) {
const linkProps = elInfo.thisElement.link;
...
}
...
}
同样的方式,我们去看看 getElementInfo
怎么返回的(过程略过),我们以 react
的实现为例,打开
packages/runtime/src/adapters/react/reactAdapter.ts
, 查看 getElementInfo
方法
export function getElementInfo(found: HTMLElement): FullElementInfo | null {
const labels: LabelData[] = [];
const fiber = findFiberByHtmlElement(found, false);
if (fiber) {
...
const thisLabel = getFiberLabel(fiber, findDebugSource(fiber)?.source);
...
return {
thisElement: {
box: getFiberOwnBoundingBox(fiber) || found.getBoundingClientRect(),
...thisLabel,
},
...
};
}
return null;
}
前面 goToLinkProps
使用的是 thisElement.link
字段, thisLabel
又依赖于 fiber
字段,等等! 这不是我们 react
玩家的老朋友 fiber
吗,我们查看一下生成它的 findFiberByHtmlElement
方法
export function findFiberByHtmlElement(
target: HTMLElement,
shouldHaveDebugSource: boolean
): Fiber | null {
const renderers = window.__REACT_DEVTOOLS_GLOBAL_HOOK__?.renderers;
const renderersValues = renderers?.values();
if (renderersValues) {
for (const renderer of Array.from(renderersValues) as Renderer[]) {
if (renderer.findFiberByHostInstance) {
const found = renderer.findFiberByHostInstance(target as any);
console.log('found', found)
if (found) {
if (shouldHaveDebugSource) {
return findDebugSource(found)?.fiber || null;
} else {
return found;
}
}
}
}
}
return null;
}
可以看到,这里是直接使用的 window
对象下的 __REACT_DEVTOOLS_GLOBAL_HOOK__
属性做的处理,我们先打印一下 fiber 查看下生成的结构
惊奇的发现 _debugSource 字段里竟然包含了点击元素所对应本地文件的路径
我们到 goToLinkProps 方法里打印一下跳转的路径发现果然一致,只是实际跳转的路径加上了 vscode://
开头,进行了协议跳转。
真相解读,_debugOwner 是怎么来的
一路砍瓜切菜终于要接近真相了,回顾代码我们其实只需要搞懂 window.REACT_DEVTOOLS_GLOBAL_HOOK 是怎么来的以及它做了什么,就可以收工了。
- _debugOwner 怎么来的?
_debugOwner
是通过 window.REACT_DEVTOOLS_GLOBAL_HOOK 根据 HtmlElement 生成的 fiber 得来的, 它是 React Devtools 插件的全局变量 HOOK,这就是为什么hook.bundle.js
要确保安装了 React Devtools - REACT_DEVTOOLS_GLOBAL_HOOK 做了什么
它是通过 @babel/plugin-transform-react-jsx-source 实现的,这个 plugin 可以在创建 fiber 的时候,将元素本地代码的位置信息保存下来,以
_debugSource
字段进行抛出
总结
LocatorJs 的 React 方案使用 React Devtools 扩展的全局 Hook,由 @babel/plugin-transform-react-jsx-source
plugin 将元素所在代码路径写入 fiber 对象当中,通过 HtmlElement 查找到相对应的 fiber,取得本地代码的路径,随即可实现定位代码并跳转的功能。
结语
本文粗略的讲解了 LocatorJs 在 React 框架的原理实现,算是抛砖引玉,供大家参考。
篇幅原因,略过很多细节,感兴趣的朋友建议看看源码,结合调试学习
我是饮东,欢迎点赞关注,江湖再见
来源:juejin.cn/post/7358274599883653120
太方便了!Arthas,生产问题大杀器
一、一个难查的生产问题
一天,小王发现生产环境上偶发性地出现某接口耗时过高,但在测试环境又无法复现,小王一筹莫展😔。小王“幻想”到:如果有个工具能记录生产上各个函数的耗时该多好,这样一看不就知道时间花在哪了?
这不是幻想,Arthas 已经帮我们解决了这个问题。在介绍它之前,我们先了解下相关背景。
一天,小王发现生产环境上偶发性地出现某接口耗时过高,但在测试环境又无法复现,小王一筹莫展😔。小王“幻想”到:如果有个工具能记录生产上各个函数的耗时该多好,这样一看不就知道时间花在哪了?
这不是幻想,Arthas 已经帮我们解决了这个问题。在介绍它之前,我们先了解下相关背景。
二、动态追踪
现在互联网和大家生活的各个方面都息息相关。相应地,互联网应用的用户规模也变得越来越大。江湖大了,什么风浪都有。开发者们不断被各种诡异问题打扰,接口耗时过大、CPU 占用过高、内存溢出、只有生产环境会报错......
这些问题出现的概率可能是千分之一、乃至万分之一。如果我们能不修改代码、不修改配置、不重启服务,就能看到程序内部在执行什么,这该多好,再大的问题心里也有底了。
动态追踪技术出现了,它诞生于21 世纪初。Sun Microsystems 公司的工程师在解决一个复杂问题时被繁琐的排查过程所困扰,痛定思痛,他们创造了 DTrace 动态跟踪框架。DTrace 奠定了动态追踪的基础,Bryan Cantrill, Mike Shapiro, and Adam Leventhal 三位作者也多次获得行业荣誉。
动态追踪技术出现的时间早,但 Java 语言相关的调试工具链一直不太完善。直到进入移动互联网时代,Java 的发展才进入了快车道。2018 年,Alibaba 开源 Arthas,Java 的动态追踪才真正好用起来。
动态追踪可以看作是构建了一个运行时“只读数据库”,这个数据库内部保存了实时变化的进程运行信息,我们通过调用这个“数据库”开放的接口,就能看到进程内部发生了什么。
经验丰富的读者可能会有疑问,现在微服务都用上了 Skywalking 这样的分布式链路追踪技术,通过它也能分阶段地看到各个部分的执行情况,为什么还需要 Arthas?
Arthas 有两大特点:
- 低侵入;不需要程序中进行额外配置,更不需要手动埋点。
- 功能强大;Arthas 提供了四十多种命令:从查看线程调用链,到查看输入、输出,到反编译代码等,应有尽有。
对于排查接口耗时长这样的情况,Skywalking 可以和 Arthas 配合起来,先用 Skywalking 定位出异常微服务,再用 Arthas 分析单个进程的情况,找到根因。
现在互联网和大家生活的各个方面都息息相关。相应地,互联网应用的用户规模也变得越来越大。江湖大了,什么风浪都有。开发者们不断被各种诡异问题打扰,接口耗时过大、CPU 占用过高、内存溢出、只有生产环境会报错......
这些问题出现的概率可能是千分之一、乃至万分之一。如果我们能不修改代码、不修改配置、不重启服务,就能看到程序内部在执行什么,这该多好,再大的问题心里也有底了。
动态追踪技术出现了,它诞生于21 世纪初。Sun Microsystems 公司的工程师在解决一个复杂问题时被繁琐的排查过程所困扰,痛定思痛,他们创造了 DTrace 动态跟踪框架。DTrace 奠定了动态追踪的基础,Bryan Cantrill, Mike Shapiro, and Adam Leventhal 三位作者也多次获得行业荣誉。
动态追踪技术出现的时间早,但 Java 语言相关的调试工具链一直不太完善。直到进入移动互联网时代,Java 的发展才进入了快车道。2018 年,Alibaba 开源 Arthas,Java 的动态追踪才真正好用起来。
动态追踪可以看作是构建了一个运行时“只读数据库”,这个数据库内部保存了实时变化的进程运行信息,我们通过调用这个“数据库”开放的接口,就能看到进程内部发生了什么。
经验丰富的读者可能会有疑问,现在微服务都用上了 Skywalking 这样的分布式链路追踪技术,通过它也能分阶段地看到各个部分的执行情况,为什么还需要 Arthas?
Arthas 有两大特点:
- 低侵入;不需要程序中进行额外配置,更不需要手动埋点。
- 功能强大;Arthas 提供了四十多种命令:从查看线程调用链,到查看输入、输出,到反编译代码等,应有尽有。
对于排查接口耗时长这样的情况,Skywalking 可以和 Arthas 配合起来,先用 Skywalking 定位出异常微服务,再用 Arthas 分析单个进程的情况,找到根因。
三、Arthas常用场景
相信你对动态追踪有了基本的了解,Arthas 可以理解为动态追踪在 Java 领域落地的具体工具。下面以场景助学,大家可以参考这些方案,因事制宜来解决自己的问题。
Arthas 的安装和基础使用见官方文档:Introduction | arthas。
相信你对动态追踪有了基本的了解,Arthas 可以理解为动态追踪在 Java 领域落地的具体工具。下面以场景助学,大家可以参考这些方案,因事制宜来解决自己的问题。
Arthas 的安装和基础使用见官方文档:Introduction | arthas。
3.1.接口慢/吞吐量低
在文章开头,小王就遇到了这个问题。现在小王依靠老道的排查经验确定了 MathGame 服务肯定有问题,但具体的点却找不到。小王仔细学习了这篇文章,决定用 Arthas 分以下三步来排查:
- profile 明确整体的耗时情况
profile 命令支持为应用生成火焰图,在 Arthas 终端输入以下命令:
# 开始对应用中当前执行的活动采样 30 秒,采样结束后默认会生成 HTML 文件
[arthas@5555]$ profiler start -d 30
打开 HTML 文件能看到这样的结构:

火焰图
MathGame 类下的 run 方法占用了大部分的执行时间,接下来我们看看 run 方法内部的耗时情况。
- trace 详细查看单个调用的内部耗时
[arthas@5555]$ trace --skipJDKMethod false demo.MathGame run
PrintStream 类的 print 方法占据了 87% 的时间,这是 JDK 自带的类,这说明我们程序本身并无耗时问题,但 MathGame 类的 primeFactors 方法抛出了异常,我们可以看看具体的异常,再思考怎么优化。

run方法的trace流
另外,trace 可以选择性地进行调用拦截,比如设置只拦截大于 20ms 的调用:
[arthas@5555]$ trace demo.MathGame run '#cost > 20'
- watch 查看真实的调用数据
拦截 primeFactors 方法抛出的异常:
[arthas@5555]$ watch demo.MathGame primeFactors -e "throwExp"

拦截异常
小王从大到小、逐步分析,找出了问题的原因是 primeFactors 抛出了异常,修正参数后,程序恢复了正常。
在文章开头,小王就遇到了这个问题。现在小王依靠老道的排查经验确定了 MathGame 服务肯定有问题,但具体的点却找不到。小王仔细学习了这篇文章,决定用 Arthas 分以下三步来排查:
- profile 明确整体的耗时情况
profile 命令支持为应用生成火焰图,在 Arthas 终端输入以下命令:
# 开始对应用中当前执行的活动采样 30 秒,采样结束后默认会生成 HTML 文件
[arthas@5555]$ profiler start -d 30
打开 HTML 文件能看到这样的结构:
火焰图MathGame 类下的 run 方法占用了大部分的执行时间,接下来我们看看 run 方法内部的耗时情况。
- trace 详细查看单个调用的内部耗时
[arthas@5555]$ trace --skipJDKMethod false demo.MathGame run
PrintStream 类的 print 方法占据了 87% 的时间,这是 JDK 自带的类,这说明我们程序本身并无耗时问题,但 MathGame 类的 primeFactors 方法抛出了异常,我们可以看看具体的异常,再思考怎么优化。
run方法的trace流另外,trace 可以选择性地进行调用拦截,比如设置只拦截大于 20ms 的调用:
[arthas@5555]$ trace demo.MathGame run '#cost > 20'
- watch 查看真实的调用数据
拦截 primeFactors 方法抛出的异常:
[arthas@5555]$ watch demo.MathGame primeFactors -e "throwExp"
拦截异常
小王从大到小、逐步分析,找出了问题的原因是 primeFactors 抛出了异常,修正参数后,程序恢复了正常。
3.2.CPU 占用过高
CPU 是程序运行的核心计算资源,一旦出现 CPU 占用过高,必定对大部分用户的访问耗时产生影响。针对这类问题,要定位出有问题的线程,并获取该线程当前执行的代码位置。
使用 top + jstack 命令可以定位这类问题(见参考资料三),Arthas 也提供了更便捷的一体化工具:
- 定位目标线程
# 调用线程看板,并刷新数据三次
[arthas@5555]$ dashboard -n 3

示例程序的CPU占用不算高
DashBoard 刷新三次后,在最新状态中发现示例程序里自己的线程 “main” 占用不算高。说明程序运行正常。如果是要排错,这里就要找出 CPU 占用最高的用户线程的 ID。
- 查看目标线程执行的代码位置
# “1” 是上一步定位到的 main 的线程ID
[arthas@5555]$ thread 1

线程正在“睡觉”,没什么大问题。
CPU 是程序运行的核心计算资源,一旦出现 CPU 占用过高,必定对大部分用户的访问耗时产生影响。针对这类问题,要定位出有问题的线程,并获取该线程当前执行的代码位置。
使用 top + jstack 命令可以定位这类问题(见参考资料三),Arthas 也提供了更便捷的一体化工具:
- 定位目标线程
# 调用线程看板,并刷新数据三次
[arthas@5555]$ dashboard -n 3
示例程序的CPU占用不算高DashBoard 刷新三次后,在最新状态中发现示例程序里自己的线程 “main” 占用不算高。说明程序运行正常。如果是要排错,这里就要找出 CPU 占用最高的用户线程的 ID。
- 查看目标线程执行的代码位置
# “1” 是上一步定位到的 main 的线程ID
[arthas@5555]$ thread 1
线程正在“睡觉”,没什么大问题。
3.3 生产环境的效果和测试不一样
有些时候你发现:测试环境正常,但生产就报错了。这类问题主要靠做好上线流程的管控,但也有可能是打包的依赖库出现冲突,造成程序行为不一致。接下来,我们看看怎么用 Arthas 反编译代码,以及怎么对比依赖库的版本。
- 反编译代码
# demo.MathGame 是目标类的全限定名
[arthas@5555]$ jad demo.MathGame

- 查看目标类所属的依赖包
# demo.MathGame 是目标类的全限定名
[arthas@5555]$ sc -d demo.MathGame

目标类所属的包
如果这里是依赖包,code-source 还可以显示所属包的版本。这样就可以对比本地的代码,从而在打包时设置正确的依赖版本。
有些时候你发现:测试环境正常,但生产就报错了。这类问题主要靠做好上线流程的管控,但也有可能是打包的依赖库出现冲突,造成程序行为不一致。接下来,我们看看怎么用 Arthas 反编译代码,以及怎么对比依赖库的版本。
- 反编译代码
# demo.MathGame 是目标类的全限定名
[arthas@5555]$ jad demo.MathGame
- 查看目标类所属的依赖包
# demo.MathGame 是目标类的全限定名
[arthas@5555]$ sc -d demo.MathGame
如果这里是依赖包,code-source 还可以显示所属包的版本。这样就可以对比本地的代码,从而在打包时设置正确的依赖版本。
目标类所属的包
3.4 内存溢出
生产问题中内存溢出也有不小的比例。内存溢出的关键是找出高内存占用的对象。命令行操作会比较麻烦,建议转储 Heap Dump 等文件后,通过 Eclipse Memory Analyzer(MAT) 等工具进行分析。
四、运行 Arthas 报错
在有些运行环境下,Arthas 会出现报错。对于以下两种情况,读者可参照文档解决:
- 不兼容 Skywalking
Compatible-with-other-javaagent-bytecode-processing - Alpine 镜像的容器无法生成火焰图
Alpine容器镜像中生成火焰图错误的其它解决方案
五、参考资料
作者:立子
来源:juejin.cn/post/7308230350374256666
来源:juejin.cn/post/7308230350374256666
SpringBoot 这么实现动态数据源切换,就很丝滑!
大家好,我是小富~
简介
项目开发中经常会遇到多数据源同时使用的场景,比如冷热数据的查询等情况,我们可以使用类似现成的工具包来解决问题,但在多数据源的使用中通常伴随着定制化的业务,所以一般的公司还是会自行实现多数据源切换的功能,接下来一起使用实现自定义注解的形式来实现一下。
基础配置
yml配置
pom.xml
文件引入必要的Jar
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.6</version>
</parent>
<groupId>com.dynamic</groupId>
<artifactId>springboot-dynamic-datasource</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<mybatis.plus.version>3.5.3.1</mybatis.plus.version>
<mysql.connector.version>8.0.32</mysql.connector.version>
<druid.version>1.2.6</druid.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- springboot核心包 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mysql驱动包 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.connector.version}</version>
</dependency>
<!-- lombok工具包 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis.plus.version}</version>
</dependency>
<!-- druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.7</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
管理数据源
我们应用ThreadLocal来管理数据源信息,通过其中内容的get,set,remove方法来获取、设置、删除当前线程对应的数据源。
/**
* ThreadLocal存放数据源变量
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
public class DataSourceContextHolder {
private static final ThreadLocal<String> DATASOURCE_HOLDER = new ThreadLocal<>();
/**
* 获取当前线程的数据源
*
* @return 数据源名称
*/
public static String getDataSource() {
return DATASOURCE_HOLDER.get();
}
/**
* 设置数据源
*
* @param dataSourceName 数据源名称
*/
public static void setDataSource(String dataSourceName) {
DATASOURCE_HOLDER.set(dataSourceName);
}
/**
* 删除当前数据源
*/
public static void removeDataSource() {
DATASOURCE_HOLDER.remove();
}
}
重置数据源
创建 DynamicDataSource 类并继承 AbstractRoutingDataSource,这样我们就可以重置当前的数据库路由,实现切换成想要执行的目标数据库。
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.Map;
/**
* 重置当前的数据库路由,实现切换成想要执行的目标数据库
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
public DynamicDataSource(DataSource defaultDataSource, Map<Object, Object> targetDataSources) {
super.setDefaultTargetDataSource(defaultDataSource);
super.setTargetDataSources(targetDataSources);
}
/**
* 这一步是关键,获取注册的数据源信息
* @return
*/
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSource();
}
}
配置数据库
在 application.yml 中配置数据库信息,使用dynamic_datasource_1
、dynamic_datasource_2
两个数据库
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
master:
url: jdbc:mysql://127.0.0.1:3306/dynamic_datasource_1?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 12345
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
url: jdbc:mysql://127.0.0.1:3306/dynamic_datasource_2?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 12345
driver-class-name: com.mysql.cj.jdbc.Driver
再将多个数据源注册到DataSource
.
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
/**
* 注册多个数据源
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
@Configuration
public class DateSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.druid.master")
public DataSource dynamicDatasourceMaster() {
return DruidDataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.druid.slave")
public DataSource dynamicDatasourceSlave() {
return DruidDataSourceBuilder.create().build();
}
@Bean(name = "dynamicDataSource")
@Primary
public DynamicDataSource createDynamicDataSource() {
Map<Object, Object> dataSourceMap = new HashMap<>();
// 设置默认的数据源为Master
DataSource defaultDataSource = dynamicDatasourceMaster();
dataSourceMap.put("master", defaultDataSource);
dataSourceMap.put("slave", dynamicDatasourceSlave());
return new DynamicDataSource(defaultDataSource, dataSourceMap);
}
}
启动类配置
在启动类的@SpringBootApplication
注解中排除DataSourceAutoConfiguration
,否则会报错。
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
到这多数据源的基础配置就结束了,接下来测试一下
测试切换
准备SQL
创建两个库dynamic_datasource_1、dynamic_datasource_2,库中均创建同一张表 t_dynamic_datasource_data。
CREATE TABLE `t_dynamic_datasource_data` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`source_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
dynamic_datasource_1.t_dynamic_datasource_data表中插入
insert int0 t_dynamic_datasource_data (source_name) value ('dynamic_datasource_master');
dynamic_datasource_2.t_dynamic_datasource_data表中插入
insert int0 t_dynamic_datasource_data (source_name) value ('dynamic_datasource_slave');
手动切换数据源
这里我准备了一个接口来验证,传入的 datasourceName 参数值就是刚刚注册的数据源的key。
/**
* 动态数据源切换
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
@RestController
public class DynamicSwitchController {
@Resource
private DynamicDatasourceDataMapper dynamicDatasourceDataMapper;
@GetMapping("/switchDataSource/{datasourceName}")
public String switchDataSource(@PathVariable("datasourceName") String datasourceName) {
DataSourceContextHolder.setDataSource(datasourceName);
DynamicDatasourceData dynamicDatasourceData = dynamicDatasourceDataMapper.selectOne(null);
DataSourceContextHolder.removeDataSource();
return dynamicDatasourceData.getSourceName();
}
}
传入参数master时:127.0.0.1:9004/switchDataSource/master
传入参数slave时:127.0.0.1:9004/switchDataSource/slave
通过执行结果,我们看到传递不同的数据源名称,已经实现了查询对应的数据库数据。
注解切换数据源
上边已经成功实现了手动切换数据源,但这种方式顶多算是半自动,下边我们来使用注解方式实现动态切换。
定义注解
我们先定一个名为DS
的注解,作用域为METHOD方法上,由于@DS中设置的默认值是:master,因此在调用主数据源时,可以不用进行传值。
/**
* 定于数据源切换注解
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DS {
// 默认数据源master
String value() default "master";
}
实现AOP
定义了@DS
注解后,紧接着实现注解的AOP逻辑,拿到注解传递值,然后设置当前线程的数据源
import com.dynamic.config.DataSourceContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Objects;
/**
* 实现@DS注解的AOP切面
*
* @author 公众号:程序员小富
* @date 2023/11/27 11:02
*/
@Aspect
@Component
@Slf4j
public class DSAspect {
@Pointcut("@annotation(com.dynamic.aspect.DS)")
public void dynamicDataSource() {
}
@Around("dynamicDataSource()")
public Object datasourceAround(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
DS ds = method.getAnnotation(DS.class);
if (Objects.nonNull(ds)) {
DataSourceContextHolder.setDataSource(ds.value());
}
try {
return point.proceed();
} finally {
DataSourceContextHolder.removeDataSource();
}
}
}
测试注解
再添加两个接口测试,使用@DS
注解标注,使用不同的数据源名称,内部执行相同的查询条件,看看结果如何?
@DS(value = "master")
@GetMapping("/dbMaster")
public String dbMaster() {
DynamicDatasourceData dynamicDatasourceData = dynamicDatasourceDataMapper.selectOne(null);
return dynamicDatasourceData.getSourceName();
}
@DS(value = "slave")
@GetMapping("/dbSlave")
public String dbSlave() {
DynamicDatasourceData dynamicDatasourceData = dynamicDatasourceDataMapper.selectOne(null);
return dynamicDatasourceData.getSourceName();
}
通过执行结果,看到通过应用@DS
注解也成功的进行了数据源的切换。
事务管理
在动态切换数据源的时候有一个问题是要考虑的,那就是事务管理是否还会生效呢?
我们做个测试,新增一个接口分别插入两条记录,其中在插入第二条数据时将值设置超过了字段长度限制,会产生Data too long for column
异常。
/**
* 验证一下事物控制
*/
// @Transactional(rollbackFor = Exception.class)
@DS(value = "slave")
@GetMapping("/dbTestTransactional")
public void dbTestTransactional() {
DynamicDatasourceData datasourceData = new DynamicDatasourceData();
datasourceData.setSourceName("test");
dynamicDatasourceDataMapper.insert(datasourceData);
DynamicDatasourceData datasourceData1 = new DynamicDatasourceData();
datasourceData1.setSourceName("testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest");
dynamicDatasourceDataMapper.insert(datasourceData1);
}
经过测试发现执行结果如下,即便实现动态切换数据源,本地事务依然可以生效。
- 不加上
@Transactional
注解第一条记录可以插入,第二条插入失败 - 加上
@Transactional
注解两条记录都不会插入成功
本文案例地址:github.com/chengxy-nds…
来源:juejin.cn/post/7316202800663363594
这个字符串”2*(1+3-4)“的结果是多少
大家好,我是火焱。
前两天,在抖音上刷到一个计算器魔术,很有意思。
于是拿出手机尝试,发现不太对,为什么我的计算器直接把输入的内容都展示出来了?看评论区发现很多人都有类似的问题。

既然自带的计算器不好使,那就用小程序写一个。
产品描述
计算器的显示区只展示当前的数字,如果按了运算符(+ - * /),再输入数字时,展示当前的新数字,不展示之前输入的内容,按等于(=)号后,展示计算结果。
从程序员视角看,按等于(=) 时,我们拿到的是四则运算的字符串,比如:"1 + 2 * 3 - 4",然后通过代码计算这个字符串的结果,那么如何计算呢?
初步尝试
对于 javascript,很容易想到通过 eval 或者 new Function 实现,可是小程序...
既然捷径走不通,那就用逆波兰表达式来解决,我们来看下表达式的三种表示方法。
三种表示
中缀表达式,就是我们常用的表示方式:1 + 2 * 3 - 4
前缀表达式,也叫波兰表达式,是把操作符放到操作数前边,表示成:- + 1 * 2 3 4,由于后缀表达式操作起来比较方便,我们重点看下后缀表达式;
后缀表达式,也叫逆波兰表达式,它是把操作符放到操作数后边,表示成:1 2 3 * + 4 -,有了后缀表达式,我们就可以很容易计算结果了,那如何将中缀表达式转化成后序表达式呢?语言表述比较乏力,直接看代码吧,逻辑比较清晰:
/** 中缀表达式 转 后缀表达式 */
function infixToPostfix(infixExpression) {
let output = [];
// 存放运算符
let stack = [];
for (let i = 0; i < infixExpression.length; i++) {
let char = infixExpression[i];
if (!isOperator(char)) { // char 是数字
output.push(char);
} else { // char 是运算符
while (
// 栈不为空
stack.length > 0 &&
// 栈顶操作符的优先级不小于 char 的优先级
getPrecedence(stack[stack.length - 1]) >= getPrecedence(char)
) {
output.push(stack.pop());
}
stack.push(char);
}
}
// 将剩余的运算符弹出并追加到 output 后边
while (stack.length > 0) {
output.push(stack.pop());
}
return output.join('');
}
结合下图理解一下:
表达式:1 + 2 * 3 - 4
处理括号
带括号的表达式,处理逻辑和不带括号是一样的,只是多了对括号的处理。当遇到右括号时,需要把栈中左括号后面的所有运算符弹出,并追加到 output,举个例子:
计算:2 * ( 1 + 3 - 4)
通过这个例子,我们可以看出,后缀表示法居然不需要括号,更简洁。
好了,现在已经有了后序表达式,我们如何的到计算结果呢?
计算结果
计算这一步其实比较简单,直接上代码吧:
const operators = {
'+': function (a, b) { return a + b; },
'-': function (a, b) { return a - b; },
'*': function (a, b) { return a * b; },
'/': function (a, b) { return a / b; }
};
const stack = [];
postfixTokens.forEach(function (token) {
if (!isNaN(token)) {
stack.push(token);
} else if (isOperator(token)) {
var b = stack.pop();
var a = stack.pop();
stack.push(operators[token](a, b));
}
});
总结
中缀表达式对于人比较友好,而后缀表达式对计算机友好,通过对数字和运算符的编排即可实现带优先级的运算。如果本文对你有帮助,欢迎点赞、评论。
来源:juejin.cn/post/7294441582983528484
鸿蒙next高仿微信来了 我不允许你不会
前言导读
各位同学大家,有段时间没有跟大家见面了,因为最近一直在更新鸿蒙的那个实战课程所以就没有去更新文章实在是不好意思, 所以今天就给大家更新一期实战案例 高仿微信案例 希望帮助到各位同学工作和学习
效果图
特点
- 高仿程度80
- 目前不支持即时通讯功能
- 支持最新的api 12
- 目前做了账号注册和登录自动登录功能入口
具体实现
启动页面
/**
* 创建人:xuqing
* 创建时间:2024年7月14日22:56:15
* 类说明:欢迎页面
*
*/
import router from '@ohos.router';
import { preferences } from '@kit.ArkData';
import CommonConstant from '../common/CommonConstants';
import Logger from '../utils/Logger';
import { httpRequestGet } from '../utils/OkhttpUtils';
import { LoginModel } from '../bean/LoginModel';
let dataPreferences: preferences.Preferences | null = null;
@Entry
@Component
struct Welcome {
async aboutToAppear(){
let options: preferences.Options = { name: 'myStore' };
dataPreferences = preferences.getPreferencesSync(getContext(), options);
let getusername=dataPreferences.getSync('username','');
let getpassword=dataPreferences.getSync('password','');
if(getusername===''||getpassword===''){
router.pushUrl({
url:'pages/LoginPage'
})
}else {
let username:string='username=';
let password:string='&password=';
let netloginurl=CommonConstant.LOGIN+username+getusername+password+getpassword;
httpRequestGet(netloginurl).then((data)=>{
Logger.error("请求数据--->"+ data.toString());
let loginmodel:LoginModel=JSON.parse(data.toString());
if(loginmodel.code===200){
router.pushUrl({
url:'pages/Index'
})
}else{
router.pushUrl({
url:'pages/LoginPage'
})
}
})
}
}
build() {
RelativeContainer(){
Image($r('app.media.weixinbg'))
.width('100%')
.height('100%')
}.height('100%')
.width('100%')
.backgroundColor(Color.Green)
}
}
登录页面
import CommonConstant, * as commonConst from '../common/CommonConstants';
import Logger from '../utils/Logger';
import { httpRequestGet } from '../utils/OkhttpUtils';
import { LoginData, LoginModel} from '../bean/LoginModel';
import prompt from '@ohos.promptAction';
import router from '@ohos.router';
import { preferences } from '@kit.ArkData';
let dataPreferences: preferences.Preferences | null = null;
/**
* 创建人:xuqing
* 创建时间:2024年7月14日17:00:03
* 类说明:登录页面
*
*/
//输入框样式
@Extend(TextInput) function inputStyle(){
.placeholderColor($r('app.color.placeholder_color'))
.height(45)
.fontSize(18)
.backgroundColor($r('app.color.background'))
.width('100%')
.padding({left:0})
.margin({top:12})
}
//线条样式
@Extend(Line) function lineStyle(){
.width('100%')
.height(1)
.backgroundColor($r('app.color.line_color'))
}
//黑色字体样式
@Extend(Text) function blackTextStyle(size?:number ,height?:number){
.fontColor($r('app.color.black_text_color'))
.fontSize(18)
.fontWeight(FontWeight.Medium)
}
@Entry
@Component
struct LoginPage {
@State accout:string='';
@State password:string='';
async login(){
let username:string='username=';
let password:string='&password=';
let netloginurl=CommonConstant.LOGIN+username+this.accout+password+this.password;
Logger.error("请求url"+netloginurl);
await httpRequestGet(netloginurl).then((data)=>{
Logger.error("请求结果"+data.toString());
let loginModel:LoginModel=JSON.parse(data.toString());
let msg=loginModel.msg;
let logindata:LoginData=loginModel.user;
let token=loginModel.token;
let userid=logindata.id;
let options: preferences.Options = { name: 'myStore' };
dataPreferences = preferences.getPreferencesSync(getContext(), options);
if(loginModel.code===200){
Logger.error("登录成功");
dataPreferences.putSync('token',token);
dataPreferences.putSync('id',userid);
dataPreferences.putSync('username',this.accout);
dataPreferences.putSync('password',this.password);
dataPreferences!!.flush()
router.pushUrl({
url:'pages/Index'
})
}else {
prompt.showToast({
message:msg
})
}
})
}
build() {
Column(){
Image($r('app.media.weixinicon'))
.width(48)
.height(48)
.margin({top:100,bottom:8})
.borderRadius(8)
Text('登录界面')
.fontSize(24)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.title_text_color'))
Text('登录账号以使用更多服务')
.fontSize(16)
.fontColor($r('app.color.login_more_text_color'))
.margin({bottom:30,top:8})
Row(){
Text('账号').blackTextStyle()
TextInput({placeholder:'请输入账号'})
.maxLength(12)
.type(InputType.Number)
.inputStyle()
.onChange((value:string)=>{
this.accout=value;
}).margin({left:20})
}.justifyContent(FlexAlign.SpaceBetween)
.width('100%')
.margin({top:8})
Line().lineStyle().margin({left:80})
Row(){
Text('密码').blackTextStyle()
TextInput({placeholder:'请输入密码'})
.maxLength(12)
.type(InputType.Password)
.inputStyle()
.onChange((value:string)=>{
this.password=value;
}).margin({left:20})
}.justifyContent(FlexAlign.SpaceBetween)
.width('100%')
.margin({top:8})
Line().lineStyle().margin({left:80})
Button('登录',{type:ButtonType.Capsule})
.width('90%')
.height(40)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.backgroundColor($r('app.color.login_button_color'))
.margin({top:47,bottom:12})
.onClick(()=>{
this.login()
})
Text('注册账号').onClick(()=>{
router.pushUrl({
url:'pages/RegisterPage'
})
}).fontColor($r('app.color.login_blue_text_color'))
.fontSize(12)
.fontWeight(FontWeight.Medium)
}.backgroundColor($r('app.color.background'))
.height('100%')
.width('100%')
.padding({
left:12,
right:12,
bottom:24
})
}
}
主页index
import home from './Home/Home';
import contacts from './Contact/Contacts';
import Discover from './Discover/Discover';
import My from './My/My';
import common from '@ohos.app.ability.common';
import prompt from '@ohos.promptAction';
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
private backTime :number=0;
@State fontColor: string = '#182451'
@State selectedFontColor: string = 'rgb(0,196,104)'
private controller:TabsController=new TabsController();
showtoast(msg:string){
prompt.showToast({
message:msg
})
}
@State SelectPos:number=0;
private positionClick(){
this.SelectPos=0;
this.controller.changeIndex(0);
}
private companyClick(){
this.SelectPos=1;
this.controller.changeIndex(1);
}
private messageClick(){
this.SelectPos=2;
this.controller.changeIndex(2);
}
private myClick(){
this.SelectPos=3;
this.controller.changeIndex(3);
}
onBackPress(): boolean | void {
let nowtime=Date.now();
if(nowtime-this.backTime<1000){
const mContext=getContext(this) as common.UIAbilityContext;
mContext.terminateSelf()
}else{
this.backTime=nowtime;
this.showtoast("再按一次将退出当前应用")
}
return true;
}
// ['微信','通讯录','发现','我']
build() {
Flex({direction:FlexDirection.Column,alignItems:ItemAlign.Center,justifyContent:FlexAlign.Center}){
Tabs({controller:this.controller}){
TabContent(){
home();
}
TabContent(){
contacts();
}
TabContent(){
Discover();
}
TabContent(){
My()
}
}.scrollable(false)
.barHeight(0)
.animationDuration(0)
Row(){
Column(){
Image((this.SelectPos==0?$r('app.media.wetab01'):$r('app.media.wetab00')))
.width(20).height(20)
.margin({top:5})
Text('微信')
.size({width:'100%',height:30}).textAlign(TextAlign.Center)
.fontSize(15)
.fontColor((this.SelectPos==0?this.selectedFontColor:this.fontColor))
}.layoutWeight(1)
.backgroundColor($r('app.color.gray2'))
.height('100%')
.onClick(this.positionClick.bind(this))
Column(){
Image((this.SelectPos==1?$r('app.media.wetab11'):$r('app.media.wetab10')))
.width(20).height(20)
.margin({top:5})
Text('通讯录')
.size({width:'100%',height:30}).textAlign(TextAlign.Center)
.fontSize(15)
.fontColor((this.SelectPos==1?this.selectedFontColor:this.fontColor))
}.layoutWeight(1)
.backgroundColor($r('app.color.gray2'))
.height('100%')
.onClick(this.companyClick.bind(this))
Column(){
Image((this.SelectPos==2?$r('app.media.wetab21'):$r('app.media.wetab20')))
.width(20).height(20)
.margin({top:5})
Text('发现')
.size({width:'100%',height:30}).textAlign(TextAlign.Center)
.fontSize(15)
.fontColor((this.SelectPos==2?this.selectedFontColor:this.fontColor))
}.layoutWeight(1)
.backgroundColor($r('app.color.gray2'))
.height('100%')
.onClick(this.messageClick.bind(this))
Column(){
Image((this.SelectPos==5?$r('app.media.wetab31'):$r('app.media.wetab30')))
.width(20).height(20)
.margin({top:5})
Text('我')
.size({width:'100%',height:30}).textAlign(TextAlign.Center)
.fontSize(15)
.fontColor((this.SelectPos==5?this.selectedFontColor:this.fontColor))
}.layoutWeight(1)
.backgroundColor($r('app.color.gray2'))
.height('100%')
.onClick(this.myClick.bind(this))
}.alignItems(VerticalAlign.Bottom).width('100%').height(60).margin({top:0,right:0,bottom:0,left:0})
}.width('100%')
.height('100%')
}
}
后续目标
- 微信朋友圈
- 聊天菜单(相册,拍摄...)组件栏
- 语音|视频页面
- 支持群聊头像
- 支持图片,红包等聊天内容类型(现已支持图片类型)
- 二维码扫描
最后总结:
因为篇幅有限我也不能整个项目都展开讲,有兴趣的同学能可以关注我B站课程。 后续能我会把这个项目更新到项目里面 供大家学习
B站课程地址:http://www.bilibili.com/cheese/play…
团队介绍
团队介绍:坚果派由坚果等人创建,团队由12位华为HDE以及若干热爱鸿蒙的开发者和其他领域的三十余位万粉博主运营。专注于分享 HarmonyOS/OpenHarmony,ArkUI-X,元服务,仓颉,团队成员聚集在北京,上海,南京,深圳,广州,宁夏等地,目前已开发鸿蒙 原生应用,三方库60+,欢迎进行课程,项目等合作。
来源:juejin.cn/post/7400741845508522019
从一线城市回老家后的2023“躺平”生活
归家
22年的十月份,在上海工作了三年多的我回到了老家。
前端,20年二本毕业的,当时在上海看老家的招聘信息,感觉很棒,很心动。又因为公司在大裁员,刚刚好在最后一轮裁员的时候,被裁了,拿了赔偿金,因为房租没有到期,又最后玩了玩,回了老家。
现实的落差感
回到老家后,又休息了十几天吧,就开始看招聘的信息,之前在上海看着很心动的岗位,简历投了又投,要么回复你,岗位已招满,要么压根不理你(后来我才知道,是学历的问题,老家这边的国企,最低学历就是研究生了,压根不看你工作履历)。剩余那些搭理你的公司,大都是小公司,可能只有十个人左右,而且大多都是单休或者大小周,有些甚至五险一金都没有,工资也低的可怜,是之前的三分之一差不多。
心里难免有很强烈的落差感,但是由于我们(们:我老公,那个时候我们还是情侣,从大学开始的,在一起快7年了)家都是在这边的,两边父母都在这,觉得我们之后就是要在这里发展的,我俩硬着头皮,每天划拉着招聘信息,投着简历,适当地去面试。
中间有一家公司,我感觉还可以,然后想着去试试,干了两天半。
刚去的第一天,技术团队是:一个后端和一个外包的后端,以及一个跟我一样刚入职的前端,一共就我们四个人,然后就是老板,只有我是女生。除此之外,还有保洁阿姨(中午会做饭,公司中午管饭)、人事小姐姐等一些非开发人员。下午开会,老板居然直接在会议室抽起了烟(熏得我不要不要的)!然后项目用的是ruoyi的架子,里面有些代码是那俩后端暂时写的,看起来有些乱。就这样干了两天,那俩后端,很爱抽烟,再加上老板也带头会议室开会还抽烟,整天感觉身边烟熏火燎的。
到第三天的时候,中午开了个会,意思是,之前我们开发的好像需求都不行,并且又提了一堆新需求,还告诉我们说只有两三天的时间搞完。我就意识到不对劲,是逼着人加班,没死没活的干的那种。然后再加上被熏了三天,于是开完会,我就赶紧收拾着我的东西,跑路了,干不了,根本干不了。既不合理,而且办公环境很糟糕(每天烟熏火燎),还没有社保,据说年后才缴纳。下午他们打来电话,问我咋回事,还要给我加薪让我再回去,但我已决心不去那干了。后来的我一点都不后悔这样做,甚至觉得很明智。
就这样继续在招聘软件上看着,有新岗位咯,就投,就面试。
突然有一天的周日,我接到了一个电话,说我可以来上班,他们缴纳五险,是双休,还有餐补,并且薪资也比之前面试的也差不多(之前还有个公司给的薪资和他一样,但是他是大小周,我不想去),这种待遇的公司,对于目前的我来说,已经很可以了,然后我就同意了,并且两天后去入职,这家公司就是我现在的公司。相比之前那家“烟熏火燎”的公司,这家就正规了许多,可能因为总部在深圳吧。
我们订婚啦
既然工作稳定了,那就开始丰富生活。2023年02月05日,我们举办了订婚宴~
工作
这边的前端工作不太是普通的传统前端,而是electron打包出来是个exe啊,或者是针对模型3d渲染引擎啊,依托于基于threeJs二次开发出来的一些第三方,之类的,总之跟之前做的不一样,之前的我做的都是h5、微信小程序、或者接入一些公众号之类的。所以与其说是在工作,不如说是一直在学习吧。公司也知道我不太会,于是乎就给我很长时间先学,先熟悉,然后再去一点点开发。并且我几乎没加过班。
我们结婚啦
后面一切按照计划进行,拍摄婚纱照、男方那边在忙着新房装修,我们这边在置办嫁妆、买车车等。
在2023年10月10日,我们举行了典礼。
安稳且平淡
现在的我们每天安安稳稳,我想着适当提升下自己的学历(因为我们这边的好单位,现在好像都要研究生毕业了),在看着咱们计算机考研408的一些科目(双11心血来潮,一下子买了六七百块的书,不看总觉得买书钱白瞎了TAT),但是每天下班回家,还是忍不住看一些电视剧啥的,佛系考研,阿弥陀佛,哈哈哈哈
我们从上海一直养到现在的猫猫~
这就是我跟大家分享的我的这2023年的一年的经历。说实话,回老家的确比在一线城市更真实,因为身边有父母,有家人,每个周末都可充实。一线城市是素质高、节奏快,人的整个思想境界感觉都跟老家这边的人不一样。但兜兜转转,回老家似乎也并不是“躺平”,有落差感,因为接触过好的了。反正无论怎样,感觉简单、安稳、快乐的过好每一天就挺好。我们一起加油吧~
来源:juejin.cn/post/7311206584205869096
关于我在uni-app中踩的坑
前言
这段时间刚入坑uni-app小程序,本人使用的编辑器是VScode(不是HbuliderX!!!),在此记录本人所踩的坑
关于官方模板
我采用的是官方提供的Vue3+Vite+ts模板,使用的包管理工具是pnpm。大家可以使用npx下载模板
$ npx degit dcloudio/uni-preset-vue#vite-ts my-vue3-project
当然不出意外,大家下载都是失败的
So这里附上官方gitee下载地址 点击前去下载
下载解压后运行pnpm i,如果有报错可以尝试切换node版本。
微信小程序开发
第一步注册账号 小程序 (qq.com),按官方所需填写即可。
第二步,登录你的小程序账号,在开发->开发管理->开发设置,获取你的AppID(小程序ID)
第三步,在你的项目工程文件里找到manifest.json中的小程序相关填写你上一步获取的AppID
"mp-weixin": {
"appid": "替换你的小程序ID",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
然后终端运行pnpm run dev:mp-weixin
然后会生成一个dist目录,这里存放的是编译成微信小程序的源码
第四步,下载安装微信小程序开发工具 微信开发者工具下载地址
第五步,打开并登录微信小程序开发工具,选择导入项目,选择刚刚生成的dist目录下的mp-weixin即可
成功界面如图
关于node版本
让我们来看看人家官方是怎么说的
注意
- Vue3/Vite版要求 node 版本
^14.18.0 || >=16.0.0
- 如果使用 HBuilderX(3.6.7以下版本)运行 Vue3/Vite 创建的最新的 cli 工程,需要在 HBuilderX 运行配置最底部设置 node路径 为自己本机高版本 node 路径(注意需要重启 HBuilderX 才可以生效)
- HBuilderX Mac 版本菜单栏左上角 HBuilderX->偏好设置->运行配置->node路径
- HBuilderX Windows 版本菜单栏 工具->设置->运行配置->node路径
当然想要把这个官方模板跑起来还真是不容易(T-T),为什么这么说呢,本人使用node18居然跑不起来,按理说应该是可以的,but我最后选择将node版本降到node16
,在前端中我们会经常切换node,小编在这里要强推nvm(一款node版本管理工具),本文不在这里着重介绍,贴心的小编已经为大家附上了nvm的下载地址 点击前去下载
关于easycome配置
对于熟悉前端的小伙伴来说,自定义组件是家常便饭啦,uniapp内置easycom,用于自动导入自己和第三方的组件
首先我们找到pages.json文件,输入(cv)以下代码
"easycom": {
"autoscan": true,
"custom": {
// uni-ui 规则如下配置
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue",
//自定义规则
"^Xtx(.*)":"@/components/Xtx$1.vue"
}
},
自动查找以uni、Xtx开头的Vue文件,一定要注意规则,否则可能导致导入失败,写完后可以在导入的组件中log一下,判断是否导入成功,配置easycom后无需手动导入组件
关于uni-helper插件
如果你想增加在uni-app中开发体验,你可以选择uni-helper插件,首先确保你在vscode中安装了Vue Language Features (Volar)以及TypeScript Vue Plugin (Volar)插件,这俩插件提供Vue高亮显示和ts语法支持。
安装vscode uni-helper相关插件
然后安装3个包
$ pnpm i -D @uni-helper/uni-app-types
$ pnpm i -D @uni-helper/uni-cloud-types
$ pnpm i -D @uni-helper/uni-ui-types
接着在tsconfig.json中将3种类型应用。在compilerOptions的types中添加。配置如下:
{
...
"compilerOptions": {
...
"types": [
"@dcloudio/types",
"@uni-helper/uni-app-types",
"@uni-helper/uni-ui-types",
...
],
}
}
诶,这怎么原生标签报错了呢?别急,出现这个错误是因为unihelp的类型与原生发生了冲突,我们只需要在compilerOptions同级增加以下代码即可解决此问题
{
...
"compilerOptions": {
...
"types": [
"@dcloudio/types",
"@uni-helper/uni-app-types",
"@uni-helper/uni-ui-types",
...
],
}
//增加vueCompilerOptions配置项
"vueCompilerOptions": {
"nativeTags": ["block", "component", "template", "slot"]
},
}
避坑热重载
经过小编的测试发现,把微信开发者工具的自动保存和热重载关闭后,居然可以自动同步代码,起因是一天小编正苦于添加了请求拦截器却无法响应,偶然重新编译后发现可以拦截,于是考虑是否代码没更新,一看源码,果然如此,这里不知道是工具的bug还是vscode编译的bug。有了解的小伙伴可以在评论区留一下言。总之就是踩了很多坑(QWQ)
来源:juejin.cn/post/7286762580876902441
微信小程序:轻松实现时间轴组件
效果图
引言
老板: “我们公司是做基金的,用户都在买买买,可他们的钱去了哪里?没有时间轴,用户会不会觉得自己的钱瞬移了?”
你: “哈哈,确实!时间轴就像用户的投资地图,不然他们可能觉得钱被外星人劫走了。”
老板: “没错!我们得在时间轴上标清‘资金到账’、‘收益结算’这些节点,这样用户就不会担心他们的钱去买彩-票了。”
你: “放心吧,老板,我马上设计一个时间轴,让用户一看就明白他们的钱在干什么,还能时不时地笑一笑!”
老板: “好,赶紧行动,不然用户要开始给我们寄失踪报告了!”
废话不多说,我们直接开始吧!!!
组件定义
以下代码为时间轴组件的实现,详细注释在代码中。如果有任何疑问,欢迎在评论区留言讨论,或者联系我获取完整案例。
组件的 .js
文件:
/*可视化地呈现时间流信息*/
Component({
options: {
multipleSlots: true // 在组件定义时的选项中启用多slot支持
},
properties: {
activities: { // 时间轴列表
type: Array,
value: []
},
shape: { // 时间轴形状
type: String,
value: 'circle' // circle | square
},
ordinal: { // 是否显示序号
type: Boolean,
value: true
},
reverse: { // 是否倒序排列
type: Boolean,
value: false
}
},
lifetimes: {
attached() {
// 是否倒序排列操作数据
const {reverse, activities} = this.data
if (!reverse) return
this.setData({
activities: activities.reverse()
})
}
}
})
组件的.wxml
文件:
<view class="container">
<view class="item" wx:for="{{activities}}" wx:key="item">
<view class="item-tail"></view>
<view class="item-node {{shape}} {{item.status}}">
<block wx:if="{{ordinal}}">{{index + 1}}</block>
</view>
<view class="item-wrapper">
<view class="item-news">
<view class="item-timestamp">{{item.date}}</view>
<view class="item-mark">收益结算</view>
</view>
<view class="item-content">
<view>{{item.content}}</view>
<!--动态slot的实现方式-->
<slot name="operate{{index}}"></slot>
</view>
</view>
</view>
</view>
组件使用
要使用该组件,首先需要在 app.json
或 index.json
中引用组件:
"usingComponents": {
"eod-timeline": "/components/Timeline/Timeline"
}
然后你可以通过以下方式进行基本使用:
<eod-timeline activities="{{dataList}}" ordinal="{{true}}"></eod-timeline>
如果需要结合插槽动态显示操作记录,可以这样实现:
<eod-timeline activities="{{dataList}}" ordinal="{{true}}">
<!--动态slot的实现方式-->
<view wx:for="{{dataList}}" wx:for-index="idx" wx:key="idx" slot="operate{{idx}}">
<view class="row-operate">
<view>操作记录</view>
<view>收益记录</view>
<view>动账记录</view>
</view>
</view>
</eod-timeline>
数据结构与属性说明
dataList
数据结构示例如下:
dataList:[
{date: '2023-05-26 12:04:14', status: 'info', content: '内容一'},
{date: '2023-05-25 12:04:14', status: 'success', content: '内容二'},
{date: '2023-05-24 12:04:14', status: 'success', content: '内容三'},
{date: '2023-05-23 12:04:14', status: 'error', content: '内容四'},
{date: '2023-05-22 12:04:14', status: 'warning', content: '内容五'}
]
组件的属性配置如下表所示:
参数 | 说明 | 可选值 | 类型 | 默认值 |
---|---|---|---|---|
activities | 显示的数据 | — | array | — |
shape | 时间轴点形状 | circle / square | string | circle |
ordinal | 是否显示序号 | — | boolean | true |
reverse | 是否倒序排列 | — | boolean | false |
总结
这个时间轴组件提供了一个简单易用的方式来展示事件的时间顺序。组件支持定制形状、序号显示以及正序或倒序排列,同时允许通过插槽自定义内容,增强了组件的灵活性。代码中有详细注释,方便理解和修改。如果需要更详细的案例或有任何疑问,请在评论区留言。希望这篇文章对你有所帮助!
拓展阅读
关于动态 Slot
实现:
由于动态 slot
目前仅可用于 glass-easel
组件框架,而该框架仅可用于 Skyline
渲染引擎,因此这些特性也同样受此限制。如果需要在非 glass-easel
组件框架中实现动态 slot
,请参考上文标记了 <!--动态slot的实现方式-->
的代码段。
如需了解更多关于 glass-easel
组件框架的信息,请参阅微信小程序官方开发指南。
来源:juejin.cn/post/7399983901812604980
这些天,我们前端组一起处理的网站换肤功能
前言
大家好,我是沐浴在曙光下的贰货道士,好久不见,别来无恙!本文主要根据UI
设计需求,讲述一套基于scss
封装方法的网页响应式布局,以及不同于传统引入element UI
主题配色文件的换肤思路。大家仔细看完文章,相信一定会有所收获。倘若本文提供的响应式布局思路对您有所帮助,烦请大家一键三连哦。同时如果您有其他响应式布局解决方案或网站换肤思路,欢迎您不吝赐教,在评论区留言分享。感谢大家!
大家好,我是沐浴在曙光下的贰货道士,好久不见,别来无恙!本文主要根据UI
设计需求,讲述一套基于scss
封装方法的网页响应式布局,以及不同于传统引入element UI
主题配色文件的换肤思路。大家仔细看完文章,相信一定会有所收获。倘若本文提供的响应式布局思路对您有所帮助,烦请大家一键三连哦。同时如果您有其他响应式布局解决方案或网站换肤思路,欢迎您不吝赐教,在评论区留言分享。感谢大家!
需求分析
- 早期我们前端项目组开发了一个国外业务网站。这周为了迎合其他国家的喜好,需要在国外业务项目的基础上,新建多个项目,对之前的主题配色和部分布局进行修改,并需要适配不同分辨率下的屏幕。
UI
提供了包括主题配色和页面布局修改在内的一系列项目稿件,这些稿件基于1920px
分辨率的屏幕进行处理。前端需要根据UI提供的主题色,修改项目中的颜色变量。接口暂时使用国外业务的那一套接口,后期需要对接这些项目的接口,而我们目前的主要任务就是处理这些项目的静态页面改版。 - 主题色修改:
- 首先,我们前端团队需要根据
UI
提供的主题色,更新项目中的颜色变量,确保页面上的所有元素都符合新的配色方案。 页面布局
提供的修改稿件,如果有不在主题色内的颜色,需要和UI
确认是否需要更换为其他颜色相近的主题配色或者双方都新增主题配色- 检查项目中包括
CSS
和HTML
在内的所有带#
颜色值的信息。与UI
确认后,将其更换为其他颜色接近的主题配色,或者双方共同新增主题配色,以确保配色方案的一致性和协调性。
- 响应式布局:
- 前端需要根据
UI
提供的稿件和意见,适配项目在不同屏幕下的样式。对于页面上的不同元素,在小于等于1920px
的屏幕上进行缩放时,需要保持横纵比,并根据页面大小进行等比例缩放,包括容器宽高、间距等在内的页面布局是否合适都需要与UI
确认;在高于1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 - 然而,字体元素在页面缩放时,需要保持一定的风格。比如:
16px
的文字最小不能低于14px
,18px
、20px
以及24px
的文字最小不能低于16px
,32px
的文字最小不能低于18px
,36px
的文字最小不能低于20px
,44px
的文字最小不能低于28px
,48px
的文字最小不能低于32px
。 - 在移动设备上,需要保持和800px网页相同的布局。
- 早期我们前端项目组开发了一个国外业务网站。这周为了迎合其他国家的喜好,需要在国外业务项目的基础上,新建多个项目,对之前的主题配色和部分布局进行修改,并需要适配不同分辨率下的屏幕。
UI
提供了包括主题配色和页面布局修改在内的一系列项目稿件,这些稿件基于1920px
分辨率的屏幕进行处理。前端需要根据UI提供的主题色,修改项目中的颜色变量。接口暂时使用国外业务的那一套接口,后期需要对接这些项目的接口,而我们目前的主要任务就是处理这些项目的静态页面改版。 - 主题色修改:
- 首先,我们前端团队需要根据
UI
提供的主题色,更新项目中的颜色变量,确保页面上的所有元素都符合新的配色方案。 页面布局
提供的修改稿件,如果有不在主题色内的颜色,需要和UI
确认是否需要更换为其他颜色相近的主题配色或者双方都新增主题配色- 检查项目中包括
CSS
和HTML
在内的所有带#
颜色值的信息。与UI
确认后,将其更换为其他颜色接近的主题配色,或者双方共同新增主题配色,以确保配色方案的一致性和协调性。
- 首先,我们前端团队需要根据
- 响应式布局:
- 前端需要根据
UI
提供的稿件和意见,适配项目在不同屏幕下的样式。对于页面上的不同元素,在小于等于1920px
的屏幕上进行缩放时,需要保持横纵比,并根据页面大小进行等比例缩放,包括容器宽高、间距等在内的页面布局是否合适都需要与UI
确认;在高于1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 - 然而,字体元素在页面缩放时,需要保持一定的风格。比如:
16px
的文字最小不能低于14px
,18px
、20px
以及24px
的文字最小不能低于16px
,32px
的文字最小不能低于18px
,36px
的文字最小不能低于20px
,44px
的文字最小不能低于28px
,48px
的文字最小不能低于32px
。 - 在移动设备上,需要保持和800px网页相同的布局。
- 前端需要根据
项目现状
- 主题色: 早期在与UI团队合作时,我们为国外业务系统确定了一套配色方案,并将其定义在项目的颜色变量中。然而,后续设计稿中出现了一些不在这套配色方案中的色值。 由于种种原因,我们在开发时没有与UI确认这些颜色是否需要更换,也没有将新增的颜色定义到颜色变量中,而是直接在代码中使用了这些颜色值。这导致在此次换肤过程中,仅通过修改颜色变量无法实现统一换肤的效果。我们需要逐一检查代码中硬编码的颜色值,并将其替换为新的颜色变量,以确保换肤的统一性和一致性。
- 布局: 以前我们使用
flex
、百分比、最小最大宽度/高度以及element UI
的栅格布局做了一些简单的适配,但这些方法不够灵活。为了更好地适应不同分辨率的屏幕,我们需要采用更为灵活和动态的布局方案,以确保在各种设备上的显示效果都能达到预期。
- 主题色: 早期在与UI团队合作时,我们为国外业务系统确定了一套配色方案,并将其定义在项目的颜色变量中。然而,后续设计稿中出现了一些不在这套配色方案中的色值。 由于种种原因,我们在开发时没有与UI确认这些颜色是否需要更换,也没有将新增的颜色定义到颜色变量中,而是直接在代码中使用了这些颜色值。这导致在此次换肤过程中,仅通过修改颜色变量无法实现统一换肤的效果。我们需要逐一检查代码中硬编码的颜色值,并将其替换为新的颜色变量,以确保换肤的统一性和一致性。
- 布局: 以前我们使用
flex
、百分比、最小最大宽度/高度以及element UI
的栅格布局做了一些简单的适配,但这些方法不够灵活。为了更好地适应不同分辨率的屏幕,我们需要采用更为灵活和动态的布局方案,以确保在各种设备上的显示效果都能达到预期。
思路分析
主题色
传统的解决方案
- 以前在官网上,我们可以直接编辑并修改一套主题色。点击下载后,会生成一个
css
文件。
- 将下载后的
css
文件引入到我们项目中,可以看到编译后的css
文件
- 最后在项目中的入口文件,引入我们下载的
css
文件(这种方式会增加app.css
的体积)。
`main.js`
import '@/styles/theme/index.css'
- 后续处理的优化
`将编译后的element样式,从main.js指向到index.html中,减小了main.css体积`
`main.js中的css文件,最终还是会link到index.html中。那为什么还要把它拆开呢?`
`这涉及到css的拆分:浏览器会并行请求加载多个css文件,比单独请求并加载一个css文件要快`
`这样处理的目的是:将main.js中的css文件,抽出一部分放到index.html中`
<link rel="stylesheet" href="<%= BASE_URL %>theme/index.css">
- webpack小知识:
loader
webpack
只识别js
文件:当遇到其他非js
文件时,因为不识别js
文件,所以需要使用loader
插件(或引入第三方插件,或自己编写一个loader
方法),将其他文件转换为webpack
能够识别的js
文件。- 因此,
loader
的作用相当于对传入的非js
文件做处理,将它转换为 webpack
可识别的js
字符串。
在字体商用不侵权的前提下,
严格遵循设计稿的字体样式
`如果用户电脑不存在设计稿上提供的字体样式,则会展示用户电脑的默认字体样式。`
`为此,我们需要下载并引入字体,将字体集成到网站中,确保用户电脑呈现效果与我们开发一致`
`(1) 引入: 在public文件夹下新建fonts文件夹,在fonts文件夹下引入我们下载好的字体样式`
`(2) 在index.html中, 为document增加字体`
`(3) 引入并挂载字体后,我们就可以使用下载的字体了,也可以在body上全局挂载字体`
`类似element字体的引入和挂载`
`FontFace: https://developer.mozilla.org/zh-CN/docs/Web/API/CSS_Font_Loading_API`
const font1 = new FontFace(
'iconfont',
'url(/iconfont/iconfont.woff2?t=1688345853791),
url(/iconfont/iconfont.woff?t=1688345853791),
url(/iconfont/iconfont.ttf?t=1688345853791)')
const font2 = new FontFace(
'element-icons',
'url(/theme/fonts/element-icons.woff),
url(/theme/fonts/element-icons.ttf)')
font1.load().then(function() {
document.fonts.add(font1)
})
font2.load().then(function() {
document.fonts.add(font2)
})
- 以前在官网上,我们可以直接编辑并修改一套主题色。点击下载后,会生成一个
css
文件。 - 将下载后的
css
文件引入到我们项目中,可以看到编译后的css
文件 - 最后在项目中的入口文件,引入我们下载的
css
文件(这种方式会增加app.css
的体积)。
`main.js`
import '@/styles/theme/index.css'
- 后续处理的优化
`将编译后的element样式,从main.js指向到index.html中,减小了main.css体积`
`main.js中的css文件,最终还是会link到index.html中。那为什么还要把它拆开呢?`
`这涉及到css的拆分:浏览器会并行请求加载多个css文件,比单独请求并加载一个css文件要快`
`这样处理的目的是:将main.js中的css文件,抽出一部分放到index.html中`
<link rel="stylesheet" href="<%= BASE_URL %>theme/index.css">
loader
webpack
只识别js
文件:当遇到其他非js
文件时,因为不识别js
文件,所以需要使用loader
插件(或引入第三方插件,或自己编写一个loader
方法),将其他文件转换为webpack
能够识别的js
文件。loader
的作用相当于对传入的非js
文件做处理,将它转换为 webpack
可识别的js
字符串。在字体商用不侵权的前提下,
严格遵循设计稿的字体样式`如果用户电脑不存在设计稿上提供的字体样式,则会展示用户电脑的默认字体样式。`
`为此,我们需要下载并引入字体,将字体集成到网站中,确保用户电脑呈现效果与我们开发一致`
`(1) 引入: 在public文件夹下新建fonts文件夹,在fonts文件夹下引入我们下载好的字体样式`
`(2) 在index.html中, 为document增加字体`
`(3) 引入并挂载字体后,我们就可以使用下载的字体了,也可以在body上全局挂载字体`
`类似element字体的引入和挂载`
`FontFace: https://developer.mozilla.org/zh-CN/docs/Web/API/CSS_Font_Loading_API`
const font1 = new FontFace(
'iconfont',
'url(/iconfont/iconfont.woff2?t=1688345853791),
url(/iconfont/iconfont.woff?t=1688345853791),
url(/iconfont/iconfont.ttf?t=1688345853791)')
const font2 = new FontFace(
'element-icons',
'url(/theme/fonts/element-icons.woff),
url(/theme/fonts/element-icons.ttf)')
font1.load().then(function() {
document.fonts.add(font1)
})
font2.load().then(function() {
document.fonts.add(font2)
})
现在的解决方案
由于element UI
官方已不再维护传统的主题配色下载,我们项目采取官方提供的第二种方式:
- 原理: 我们项目使用
scss
编写css
,element UI
的theme-chalk
又恰好使用scss
进行编写。在官方定义的scss
变量中,使用了!default
语法,用于提供默认值。这也就意味着,我们不用考虑css
的加载顺序,直接新建scss
文件,覆盖定义在theme-chalk
文件且在我们系统中常用的scss
变量,达到在css
编译阶段自定义主题scss
变量的效果。

- 引入变量: 新建
element-variable.scss
文件,在这个文件中引入theme-chalk
定义的主题scss
变量,同时需要改变icon
字体路径变量(使用传统方法不需要改变路径变量,是因为我们直接引入了编译后的css
文件,里面已经帮我们做过处理了;而使用现在的解决方案,如果不改变字体路径变量,项目会提示找不到icon
字体路径,所以这个配置必填)。此时,将这个文件引入到我们的入口文件,那么系统中已经存在theme-chalk
定义好的scss
变量了

- 修改变量: 新建
element.scss
文件,在里面覆盖我们需要修改的主题变量,最后在vue.config.js
中sass
配置下的additionalData
里全局引入到项目中的每个vue
文件中(因为是挂载到每个vue
文件中,所以这个配置下的scss文件不宜过多),方便在vue
文件中直接使用变量。


优势
1. 定制化和灵活性
- 更改主题色和变量: 轻松改变
Element UI
的主题色、字体、间距等变量,而无需过多地覆盖现有的element CSS
样式。 - 精细控制: 原先的配置方式只能配置主题色,无法控制更细粒度的配置,比如边框颜色之类。
2. 避免样式冲突
- 避免样式覆盖的冲突: 通过直接修改
SCSS
变量来定制样式,可以避免在使用编译后的 CSS 文件时可能出现的样式覆盖冲突问题。这样可以保证样式的独立性和一致性。
3. 便于维护
- 集中管理: 所有的样式修改都集中在一个地方(变量文件),这使得维护样式变得更加方便和清晰。只需要修改文件中定义的变量,就可以影响整个项目中的样式,无需逐一查找以及修改每个组件的样式。
缺陷
- 在
sass loader
的additionalData
中配置了过多的全局css
变量,添加到每个vue
文件中 - 相比之前的处理方式,在
main.js
中引入element
自定义的主题scss
变量,首页加载的css
文件更多,
sass loader
的additionalData
中配置了过多的全局css
变量,添加到每个vue
文件中main.js
中引入element
自定义的主题scss
变量,首页加载的css
文件更多,响应式布局
思路分析
UI
提供的稿件是1920px
,前端需要对UI
提供的稿件进行一比一还原;- 网页在小屏缩放时,需要保持元素的横纵比。针对这个问题,我们可以用百分比作为布局单位。 以设计稿宽度
1920px
为基准,建立px
和vw
之间的关系。如果把1920px
视为100vw
,那么1vw = 19.2px
。 如果设计稿上某个元素的宽度为192px
, 那么将它换算得到的结果将会是192px / 19.2px * 1vw = 10vw
。因此我们在布局时,需要严格遵循UI
提供的设计稿件,并借助下文封装的方法,将设计稿元素的像素作为第一个形参,传递到下文封装的方法中; 实现思路:为等比例缩放网页元素,先去掉传入的像素单位。最后使用前文提到的换算公式,不论宽高,都将其转换为
vw单位,等比缩放
。 - 字体页面元素在放大时,需要限制字体元素展现的最大阈值。 那么我们封装的方法,第二个形参需要控制字体元素的最大阈值;
实现思路:借助
scss中的
max方法实现。
- 字体页面元素在缩小时,需要限制字体元素展现的最小阈值。 那么我们封装的方法,第三个形参需要控制字体元素的最小阈值;
实现思路:借助
scss中的
min方法实现。
- 在高于
1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 针对这个问题,我们只需要保证方法中的max
形参和1920px
下的像素值一致,即保证方法中的第一个形参和第二个形参相同。 - 在移动设备上,需要使用
800px
的网页布局。针对这个问题,我们可以使用meta
标签进行适配:
- 不同屏幕下的元素显示势必不会那么完美。我们可以通过媒体查询,在不同分辨率的屏幕下,按照
UI
给定的反馈意见,对网页进行适配,这样就可以解决问题。但是在项目中大量使用媒体查询语法,会导致整个项目看上去很乱。为此,我们可以基于scss
语法,对媒体查询语法进行二次封装。 - 如何测试我们编写的
scss
代码? 移步sass在线调试

UI
提供的稿件是1920px
,前端需要对UI
提供的稿件进行一比一还原;- 网页在小屏缩放时,需要保持元素的横纵比。针对这个问题,我们可以用百分比作为布局单位。 以设计稿宽度
1920px
为基准,建立px
和vw
之间的关系。如果把1920px
视为100vw
,那么1vw = 19.2px
。 如果设计稿上某个元素的宽度为192px
, 那么将它换算得到的结果将会是192px / 19.2px * 1vw = 10vw
。因此我们在布局时,需要严格遵循UI
提供的设计稿件,并借助下文封装的方法,将设计稿元素的像素作为第一个形参,传递到下文封装的方法中;实现思路:为等比例缩放网页元素,先去掉传入的像素单位。最后使用前文提到的换算公式,不论宽高,都将其转换为
vw单位,等比缩放
。 - 字体页面元素在放大时,需要限制字体元素展现的最大阈值。 那么我们封装的方法,第二个形参需要控制字体元素的最大阈值;
实现思路:借助
scss中的
max方法实现。
- 字体页面元素在缩小时,需要限制字体元素展现的最小阈值。 那么我们封装的方法,第三个形参需要控制字体元素的最小阈值;
实现思路:借助
scss中的
min方法实现。
- 在高于
1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 针对这个问题,我们只需要保证方法中的max
形参和1920px
下的像素值一致,即保证方法中的第一个形参和第二个形参相同。 - 在移动设备上,需要使用
800px
的网页布局。针对这个问题,我们可以使用meta
标签进行适配: - 不同屏幕下的元素显示势必不会那么完美。我们可以通过媒体查询,在不同分辨率的屏幕下,按照
UI
给定的反馈意见,对网页进行适配,这样就可以解决问题。但是在项目中大量使用媒体查询语法,会导致整个项目看上去很乱。为此,我们可以基于scss
语法,对媒体查询语法进行二次封装。 - 如何测试我们编写的
scss
代码? 移步sass在线调试
自适应scss
方法封装
// 自定义scss函数, 作用是去掉传入变量的单位
// 之所以要去掉单位,是为了将传入的px转换为vw单位,自适应布局`
@function stripUnits($value) {
// 对带有单位的变量进行特殊处理,返回去掉单位后的结果`
// 对于scss来说, 90px和90都是number`
// 在scss中,unitless是一个术语,指的是没有单位的数值,not unitless就是变量带单位`
@if type-of($value) == 'number' and not unitless($value) {
// 90px / 1 得到的结果是90px, 90px / 1px得到的结果是90
// 这也是这里为什么要用($value * 0 + 1),而不是直接写1的原因`
@return $value / ($value * 0 + 1);
}
@return $value;
}
/*
自定义scss函数,提供三个参数:
第一个参数是设计稿提供的元素大小,传入会自动转换为vw单位,达到自适应的效果
第二个参数是用来约束这个元素的大小最大不能超过第一个参数和第二个参数的最大值, 必须带单位
第三个参数是用来约束这个元素的大小最小不能小于第一个参数和第三个参数的最小值,必须带单位
如果不传入第二个和第三个参数,则表示元素完全随屏幕响应式缩放
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为960px时,标题字号缩放为24px,起不到突出的作用。
于是我们可以给它设置一个最小阈值,比如最小不能小于32px;
2. 同理,当屏幕分辨率为3840px时,标题字号放大为96px,我们不希望字号这么大。
于是可以给它设置一个最大阈值,比如最大不能超过60px。
*/
@function auto($raw, $max:null, $min:null) {
$raw: stripUnits($raw);
$str: #{$raw / $proportion}vw;
@if $max {
$str: min(#{$str}, #{$max});
}
@if $min {
$str: max(#{$str}, #{$min});
}
@return $str;
}
/*
自定义scss函数,auto方法的二次封装, 提供两个参数
第一个参数用于设置1920px下的元素大小
第二个参数用于设置这个元素的最小值
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为3840px时,标题字号放大为96px,我们希望它保持48px大小,
于是我们可以给它设置一个最大阈值48px。同时,我们可以传入一个最小阈值,让它最小不能小于这个参数。
*/
@function autoMax($raw, $min:null) {
@return auto($raw, $raw, $min)
}
// 和上面相反
@function autoMin($raw, $max:null) {
@return auto($raw, $max, $raw)
}
//1vw = 1920 / 100 ;
$proportion: 19.2;
// 根据UI需求,对不同字体大小进行封装
$wb-font-size-mini: 16px; // $text-mini-1
$wb-font-size-extra-small: 18px; // $text-small-1
$wb-font-size-small: 20px; //$text-sm-md-1
$wb-font-size-base: 24px; //$text-medium-1
$wb-font-size-lesser-medium: 32px;
$wb-font-size-medium: 36px; //$text-large-1
$wb-font-size-extra-medium: 44px;
$wb-font-size-large: 48px; //$text-title-1
// 根据UI需求,在屏幕分辨率缩小时,字体响应式变化,并设定最小阈值
// 并在1920px以上的屏幕,保持和1920px一样的字体大小
$wb-auto-font-size-mini: autoMax($wb-font-size-mini, 14px);
$wb-auto-font-size-extra-small: autoMax($wb-font-size-extra-small, 16px);
$wb-auto-font-size-small: autoMax($wb-font-size-small, 16px);
$wb-auto-font-size-base: autoMax($wb-font-size-base, 16px);
$wb-auto-font-size-lesser-medium: autoMax($wb-font-size-lesser-medium, 18px);
$wb-auto-font-size-medium: autoMax($wb-font-size-medium, 20px);
$wb-auto-font-size-extra-medium: autoMax($wb-font-size-extra-medium, 28px);
$wb-auto-font-size-large: autoMax($wb-font-size-large, 32px);
// 严格按照UI稿件提供的元素大小、间距编写代码,以下是示例代码
.title {
padding: 0 autoMax(180px);
font-size: $wb-auto-font-size-large;
font-weight: 600;
text-align: center;
}

// 自定义scss函数, 作用是去掉传入变量的单位
// 之所以要去掉单位,是为了将传入的px转换为vw单位,自适应布局`
@function stripUnits($value) {
// 对带有单位的变量进行特殊处理,返回去掉单位后的结果`
// 对于scss来说, 90px和90都是number`
// 在scss中,unitless是一个术语,指的是没有单位的数值,not unitless就是变量带单位`
@if type-of($value) == 'number' and not unitless($value) {
// 90px / 1 得到的结果是90px, 90px / 1px得到的结果是90
// 这也是这里为什么要用($value * 0 + 1),而不是直接写1的原因`
@return $value / ($value * 0 + 1);
}
@return $value;
}
/*
自定义scss函数,提供三个参数:
第一个参数是设计稿提供的元素大小,传入会自动转换为vw单位,达到自适应的效果
第二个参数是用来约束这个元素的大小最大不能超过第一个参数和第二个参数的最大值, 必须带单位
第三个参数是用来约束这个元素的大小最小不能小于第一个参数和第三个参数的最小值,必须带单位
如果不传入第二个和第三个参数,则表示元素完全随屏幕响应式缩放
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为960px时,标题字号缩放为24px,起不到突出的作用。
于是我们可以给它设置一个最小阈值,比如最小不能小于32px;
2. 同理,当屏幕分辨率为3840px时,标题字号放大为96px,我们不希望字号这么大。
于是可以给它设置一个最大阈值,比如最大不能超过60px。
*/
@function auto($raw, $max:null, $min:null) {
$raw: stripUnits($raw);
$str: #{$raw / $proportion}vw;
@if $max {
$str: min(#{$str}, #{$max});
}
@if $min {
$str: max(#{$str}, #{$min});
}
@return $str;
}
/*
自定义scss函数,auto方法的二次封装, 提供两个参数
第一个参数用于设置1920px下的元素大小
第二个参数用于设置这个元素的最小值
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为3840px时,标题字号放大为96px,我们希望它保持48px大小,
于是我们可以给它设置一个最大阈值48px。同时,我们可以传入一个最小阈值,让它最小不能小于这个参数。
*/
@function autoMax($raw, $min:null) {
@return auto($raw, $raw, $min)
}
// 和上面相反
@function autoMin($raw, $max:null) {
@return auto($raw, $max, $raw)
}
//1vw = 1920 / 100 ;
$proportion: 19.2;
// 根据UI需求,对不同字体大小进行封装
$wb-font-size-mini: 16px; // $text-mini-1
$wb-font-size-extra-small: 18px; // $text-small-1
$wb-font-size-small: 20px; //$text-sm-md-1
$wb-font-size-base: 24px; //$text-medium-1
$wb-font-size-lesser-medium: 32px;
$wb-font-size-medium: 36px; //$text-large-1
$wb-font-size-extra-medium: 44px;
$wb-font-size-large: 48px; //$text-title-1
// 根据UI需求,在屏幕分辨率缩小时,字体响应式变化,并设定最小阈值
// 并在1920px以上的屏幕,保持和1920px一样的字体大小
$wb-auto-font-size-mini: autoMax($wb-font-size-mini, 14px);
$wb-auto-font-size-extra-small: autoMax($wb-font-size-extra-small, 16px);
$wb-auto-font-size-small: autoMax($wb-font-size-small, 16px);
$wb-auto-font-size-base: autoMax($wb-font-size-base, 16px);
$wb-auto-font-size-lesser-medium: autoMax($wb-font-size-lesser-medium, 18px);
$wb-auto-font-size-medium: autoMax($wb-font-size-medium, 20px);
$wb-auto-font-size-extra-medium: autoMax($wb-font-size-extra-medium, 28px);
$wb-auto-font-size-large: autoMax($wb-font-size-large, 32px);
// 严格按照UI稿件提供的元素大小、间距编写代码,以下是示例代码
.title {
padding: 0 autoMax(180px);
font-size: $wb-auto-font-size-large;
font-weight: 600;
text-align: center;
}
媒体查询语法封装及使用规范
// 导入scss的list和map模块,用于处理相关操作。
@use 'sass:list';
@use "sass:map";
/*
媒体查询映射表,定义各种设备类型的媒体查询范围
key为定义的媒体类型,value为对应的分辨率范围
*/
$media-list: (
mobile-begin: (0, null),
mobile: (0, 800),
mobile-end:(null, 800),
tablet-begin: (801, null),
tablet: (801, 1023),
tablet-end:(null, 1023),
mini-desktop-begin: (1024, null),
mini-desktop: (1024, 1279),
mini-desktop-end: (null, 1279),
small-desktop-begin: (1280, null),
small-desktop: (1280, 1439),
small-desktop-end: (null, 1439),
medium-desktop-begin: (1440, 1919),
medium-desktop: (1440, 1919),
medium-desktop-end: (null, 1919),
large-desktop-begin: (1920, null),
large-desktop: (1920, 2559),
large-desktop-end: (null, 2559),
super-desktop-begin: (2560, null),
super-desktop: (2560, null),
super-desktop-end: (2560, null)
);
/*
创建响应式媒体查询的函数,传参是媒体查询映射表中的媒体类型
从$media-list中获取对应的最小和最大宽度,并返回相应的媒体查询字符串。
*/
@function createResponsive($media) {
$size-list: map.get($media-list, $media);
$min-size: list.nth($size-list, 1);
$max-size: list.nth($size-list, 2);
@if ($min-size and $max-size) {
@return "screen and (min-width:#{$min-size}px) and (max-width: #{$max-size}px)";
} @else if ($max-size) {
@return "screen and (max-width: #{$max-size}px)";
} @else {
@return "screen and (min-width:#{$min-size}px)";
}
}
/*
这个混入接受一个或多个媒体类型参数,调用createResponsive函数生成媒体查询
@content是Scss中的一个占位符,用于在混入中定义块级内容。
它允许你在调用混入时,将实际的样式代码插入到混入定义的样式规则中。
*/
@mixin responsive-to($media...) {
@each $item in $media {
$media-content: createResponsive($item);
@media #{$media-content} {
@content;
}
}
}
// 以下是针对各种媒体类型定义的混入:
@mixin mobile() {
@include responsive-to(mobile) {
@content;
}
}
@mixin tablet() {
@include responsive-to(tablet) {
@content;
}
}
@mixin mini-desktop() {
@include responsive-to(mini-desktop) {
@content;
}
}
@mixin small-desktop() {
@include responsive-to(small-desktop) {
@content;
}
}
@mixin medium-desktop() {
@include responsive-to(medium-desktop) {
@content;
}
}
@mixin large-desktop() {
@include responsive-to(large-desktop) {
@content;
}
}
@mixin super-desktop() {
@include responsive-to(super-desktop) {
@content;
}
}
@mixin mobile-begin() {
@include responsive-to(mobile-begin) {
@content;
}
}
@mixin tablet-begin() {
@include responsive-to(tablet-begin) {
@content;
}
}
@mixin mini-desktop-begin() {
@include responsive-to(mini-desktop-begin) {
@content;
}
}
@mixin small-desktop-begin() {
@include responsive-to(small-desktop-begin) {
@content;
}
}
@mixin medium-desktop-begin() {
@include responsive-to(medium-desktop-begin) {
@content;
}
}
@mixin large-desktop-begin() {
@include responsive-to(large-desktop-begin) {
@content;
}
}
@mixin super-desktop-begin() {
@include responsive-to(super-desktop-begin) {
@content;
}
}
@mixin mobile-end() {
@include responsive-to(mobile-end) {
@content;
}
}
@mixin tablet-end() {
@include responsive-to(tablet-end) {
@content;
}
}
@mixin mini-desktop-end() {
@include responsive-to(mini-desktop-end) {
@content;
}
}
@mixin small-desktop-end() {
@include responsive-to(small-desktop-end) {
@content;
}
}
@mixin medium-desktop-end() {
@include responsive-to(medium-desktop-end) {
@content;
}
}
@mixin large-desktop-end() {
@include responsive-to(large-desktop-end) {
@content;
}
}
@mixin super-desktop-end() {
@include responsive-to(super-desktop-begin) {
@content;
}
}

// 导入scss的list和map模块,用于处理相关操作。
@use 'sass:list';
@use "sass:map";
/*
媒体查询映射表,定义各种设备类型的媒体查询范围
key为定义的媒体类型,value为对应的分辨率范围
*/
$media-list: (
mobile-begin: (0, null),
mobile: (0, 800),
mobile-end:(null, 800),
tablet-begin: (801, null),
tablet: (801, 1023),
tablet-end:(null, 1023),
mini-desktop-begin: (1024, null),
mini-desktop: (1024, 1279),
mini-desktop-end: (null, 1279),
small-desktop-begin: (1280, null),
small-desktop: (1280, 1439),
small-desktop-end: (null, 1439),
medium-desktop-begin: (1440, 1919),
medium-desktop: (1440, 1919),
medium-desktop-end: (null, 1919),
large-desktop-begin: (1920, null),
large-desktop: (1920, 2559),
large-desktop-end: (null, 2559),
super-desktop-begin: (2560, null),
super-desktop: (2560, null),
super-desktop-end: (2560, null)
);
/*
创建响应式媒体查询的函数,传参是媒体查询映射表中的媒体类型
从$media-list中获取对应的最小和最大宽度,并返回相应的媒体查询字符串。
*/
@function createResponsive($media) {
$size-list: map.get($media-list, $media);
$min-size: list.nth($size-list, 1);
$max-size: list.nth($size-list, 2);
@if ($min-size and $max-size) {
@return "screen and (min-width:#{$min-size}px) and (max-width: #{$max-size}px)";
} @else if ($max-size) {
@return "screen and (max-width: #{$max-size}px)";
} @else {
@return "screen and (min-width:#{$min-size}px)";
}
}
/*
这个混入接受一个或多个媒体类型参数,调用createResponsive函数生成媒体查询
@content是Scss中的一个占位符,用于在混入中定义块级内容。
它允许你在调用混入时,将实际的样式代码插入到混入定义的样式规则中。
*/
@mixin responsive-to($media...) {
@each $item in $media {
$media-content: createResponsive($item);
@media #{$media-content} {
@content;
}
}
}
// 以下是针对各种媒体类型定义的混入:
@mixin mobile() {
@include responsive-to(mobile) {
@content;
}
}
@mixin tablet() {
@include responsive-to(tablet) {
@content;
}
}
@mixin mini-desktop() {
@include responsive-to(mini-desktop) {
@content;
}
}
@mixin small-desktop() {
@include responsive-to(small-desktop) {
@content;
}
}
@mixin medium-desktop() {
@include responsive-to(medium-desktop) {
@content;
}
}
@mixin large-desktop() {
@include responsive-to(large-desktop) {
@content;
}
}
@mixin super-desktop() {
@include responsive-to(super-desktop) {
@content;
}
}
@mixin mobile-begin() {
@include responsive-to(mobile-begin) {
@content;
}
}
@mixin tablet-begin() {
@include responsive-to(tablet-begin) {
@content;
}
}
@mixin mini-desktop-begin() {
@include responsive-to(mini-desktop-begin) {
@content;
}
}
@mixin small-desktop-begin() {
@include responsive-to(small-desktop-begin) {
@content;
}
}
@mixin medium-desktop-begin() {
@include responsive-to(medium-desktop-begin) {
@content;
}
}
@mixin large-desktop-begin() {
@include responsive-to(large-desktop-begin) {
@content;
}
}
@mixin super-desktop-begin() {
@include responsive-to(super-desktop-begin) {
@content;
}
}
@mixin mobile-end() {
@include responsive-to(mobile-end) {
@content;
}
}
@mixin tablet-end() {
@include responsive-to(tablet-end) {
@content;
}
}
@mixin mini-desktop-end() {
@include responsive-to(mini-desktop-end) {
@content;
}
}
@mixin small-desktop-end() {
@include responsive-to(small-desktop-end) {
@content;
}
}
@mixin medium-desktop-end() {
@include responsive-to(medium-desktop-end) {
@content;
}
}
@mixin large-desktop-end() {
@include responsive-to(large-desktop-end) {
@content;
}
}
@mixin super-desktop-end() {
@include responsive-to(super-desktop-begin) {
@content;
}
}
需求解决思路:
- 根据提供的设计稿,使用
autoMax
系列方法,对页面做初步的响应式布局适配 - 针对不同屏幕下部分元素布局需要调整的问题,使用封装的媒体查询方法进行处理
书写规范:
为避免项目中的scss
文件过多,搞得整个项目看上去很臃肿,现提供一套书写规范:
- 在每个路由下的主
index.vue
文件中,引入同级文件夹scss
下的media.scss
文件
// 小屏状态下,覆盖前面定义的css样式
media.css
文件
写法:以vue
文件最外层的类进行包裹,使用deep
穿透,以屏幕分辨率大小作为排序依据,从大到小书写媒体查询样式
.about-wrapper::v-deep {
@include small-desktop {
.a {
.b {
}
}
}
@include mini-desktop {
.a {
.b {
}
}
}
@include tablet-end {
.a {
.b {
}
}
}
}
结语
感谢掘友们耐心看到文末,希望你们不是一路跳转至评论区,我们江湖再见!
来源:juejin.cn/post/7388753413309775887
淘宝、京东复制好友链接弹出商品详情是如何实现的
前言: 最近接到了一个需求很有意思,类似于我们经常在逛购物平台中,选择一个物品分享给好友,然后好友复制这段文本打开相对应的平台以后,就可以弹出链接上的物品。实现过程也比较有意思,特来分享一下实现思路🎁。
一. 效果预览
当我在别的界面复制了内容以后,回到主应用,要求可以检测到当前剪切板是什么内容。
二. 监听页面跳转动作
- 要完成这个需求,整体思路并不复杂。首先我们要解决的就是如何检测到用户从别的应用切回到我们自己的应用。
- 这个听起来很复杂,但其实浏览器已经提供了相对应的
api
来帮我们检测用户这个操作----document.visibilitychange
。
- 那么我们就可以写下如下代码
document.addEventListener("visibilitychange", () => {
console.log("用户切换了");
});
相对应的效果如下图所示,你可能会好奇,我明明只切换了一次,但是为什么控制台却执行了两次打印?
这也不难理解,首先你要理解这个change
这个动作,你从 tab1 切换到 Tab2 的时候,触发了当前 Tab1 从可见 变为=> 不可见 。
而当你从 tab2 切回 tab1 的时候,触发了当前 Tab1 从不可见变为了可见。完整动作引起了状态两次变化,所以才有了两次打印。 - 而我们的场景只是希望 app 从不可见转变为可见的时候才触发。那么我们就需要用到��外一个变量来配合使用-------
document.visibilityState
。
这个值是一个document
对象上的一个只读属性,它有三个string
类型的值visible
、hidden
、prerender
。从它的使用说明中不难看出,我们要使用的值是visible
。
tips:hidden 可以用来配合做一些流量控制优化,当用户切换网页到后台的时候,我们可以停止一些不必要的轮询任务,待用户切回后再开启。
- 那么我们现在的代码应该是这样的:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
console.log("页面变得可见了!");
}
});
可以看到,现在控制台正确的只执行了一次。
三. 完成读取剪切板内容
- 要完成读取剪切板内容需要用到浏览器提供的另外一个
api
-----navigator.clipboard
。这里穿插一个英语记忆的小技巧,我们要把这个单词分成两部分记忆:clip 和 board。clip 本身就有修剪的意思,board 常作为木板相近的含义和别的单词组合,如:黑板 blackboard、棋盘 chessboard。所以这两个单词组合起来的含义就是剪切板。
- 这里需要注意一句话,这个功能只能用在安全上下文中。这个概念很抽象,如果想深入了解的话,还需自行查阅资料。这里指简单说明这句话的限制:要想使用这个
api
你只能在localhost、127.0.0.1
这样的本地回环地址或者使用https
协议的网站中使用。
- 要快速检测当前浏览器或者网站是否是安全上下文 ,可以使用
Window:isSecureContext
属性来判断。 - 你可以动手访问一个 http 的网站,然后在控制台打印一下该属性,你大概率会看到一个
false
,则说明该环境不是一个安全上下文,所以 clipboard 在这个环境下大概率不会生效。因为本文章代码都为本地开发(localhost),所以自然为安全上下文。
- 经过上面的知识,那么我们就可以写出下面的兼容性代码。
- 前置步骤都已经完成,接下来就是具体读取剪切板内容了。关于读取操作,
clipboard
提供了两个api
-----read
和readText
。这里由于我们的需求很明确,我读取的链接本身就是一个字符串类型的数据,所以我们就直接选用readText
方法即可。稍后在第四章节我会介绍read
方法。 clipboard
所有操作都是异步会返回一个Promise
类型的数据的,所以这里我们的代码应该是这样的:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.readText().then((text) => {
console.log("text", text);
});
}, 1000);
} else {
console.log("不支持 clipboard");
}
}
});
注意⚠️:这里你会看到我使用了
setTimeout
来解决演示的问题,如果你正在跟着练习但是不明白原因,请查看下面链接:
关于 DOM exception: document is not focused 请查阅stackoverflow 文档未聚焦的解决方案
相应的效果如下图所示,可以看到我们已经可以正确读取刚刚剪切板的内容了。
- 此时,当拿到用户剪切板的内容以后,我们就可以根据某些特点来判断弹窗了。这里我随便使用了一个弹出组件来展示效果:
- 什么?到这里你还是没看懂和网购平台链接之间有什么关系?ok,让我们仔细看一下我分别从两家平台随手复制的两个链接,看出区别了吗?开头的字符串可以很明显看出是各家的名字。
- 那么我只需判断用户剪切板上的字符串是否符合站内的某项规则不就行了吗?让我来举个更具体的栗子,下面链接是我掘金的个人首页,假如用户此时复制了这段文本,然后跳转回我们自己的应用后,刚刚的代码就可以加一个逻辑判断,检测用户剪切板上的链接是否是以
juejin.cn
开头的,如果是则跳转首页;如果不是,那么什么事情也不做。
对应的代码如下:
- 那么相对应的效果如下,这也就是为什么复制某宝的链接到某东后没任何反应的原因。某东并不是没读取,而是读取后发现不是自家的就不处理罢了。
:
四*. 思维拓展:粘贴图片自动转链接的实现
- 用过相关写作平台的小伙伴大概对在编辑器中直接接粘贴图片的功能不陌生。如掘金的编辑器,当我复制一个图片以后,直接在编辑器中粘贴即可。掘金会自动将图片转换为一个链接,这样会极大的提高创作者的写作体验。
- 那么现在让我们继续发散思维来思考这个需求如何实现,这里我们先随便创建一个富文本框。
- 既然是粘贴图片,那么最起码我得知道用户什么时候进行粘贴操作吧?这还不简单,直接监听
paste
事件即可。
document.addEventListener("paste",()=>{
console.log("用户粘贴了")
})
实现的效果如下:
- 把之前的
clipboard.readText
替换为clipboard.read
以后你的代码应该是下面这样的:
document.addEventListener("paste", () => {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {
console.log("result", result);
});
}, 1000);
} else {
console.log("不支持 clipboard");
}
});
让我们复制一张图片到富文本区域执行粘贴操作后,控制台会打印以下信息:
clipboardItem
是一个数组,里面有很多子clipboardItem
,是数组的原因是因为你可以一下子复制多张图片,不过在这里我们只考虑一张图片的场景。- 这里我们取第一项,然后调用
clipboardItem.getType
方法,这个方法需要传递一个文件类型的参数,这里我们传入粘贴内容对应的类型即可,这里传入image/png
。
在控制台这里可以看到一下输出,就表示我们已经正确拿到图片的blob
的格式数据了。
- 此时我们就只需要把相对应的图片数据传递给后端或者
CDN
服务器,让它们返回一个与之对应的链接即可。在掘金的编辑器中,对应的请求就是get-image-url
这个请求。
- 然后调用
textarea.value + link
把链接补充到文章最后位置即可。
五. 源码
<script lang="ts" setup>
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {});
}, 1000);
} else {
console.log("不支持 clipboard");
}
}
});
document.addEventListener("paste", () => {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {
result[0].getType("image/png").then((blob) => {
console.log("blob", blob);
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onload = (e) => {
const img = document.createElement("img");
img.src = e.target?.result;
const wrapper = document.getElementById("han");
wrapper.appendChild(img);
};
});
});
}, 1000);
} else {
console.log("不支持 clipboard");
}
});
</script>
<template>
<div id="han" class="w-full h-full bg-blue">
<textarea class="w-300px h-300px"></textarea>
</div>
</template>
六. 思考 writeText 的用法
有了上面的经验,我相信你已经可以自己理解 clipboard
剩下的两个方法 write
和 writeText
了。你可以思考下面的问题:
为什么在掘金复制文章内容后,剪切板会自动加版权信息呢?。
如果你实现了,不妨在评论区写下你的思路~🌹
来源:juejin.cn/post/7385776238789181449
Dart中令人惊艳的8个用法(深入探索)
Dart是谷歌开发的现代化编程语言,凭借其简洁的语法和强大的功能,在开发者当中赢得了极高的声誉,尤其是在Flutter框架中发挥了巨大的作用。本文将介绍Dart中的8个令人惊艳的用法,这些用法不仅技术深度足够,充满启发性,而且能够让您的Dart编程效率飞速提升。
1. 泛型类型别名的高级应用
类型别名可以让你用简单的名称定义更复杂的类型,尤其是在处理大量嵌套的泛型时特别有用。
typedef ComplexList<T> = List<Map<T, T>>;
void main() {
// 适用于需要设置特定键值对类型的列表
ComplexList<String> complexList = [
{'key1': 'value1'},
{'key2': 'value2'},
];
// 复杂集合的操作
complexList.add({'key3': 'value3'});
print(complexList);
}
泛型类型别名可以更好地组织代码,增强代码的可读性。
2. Stream的高级处理技巧
利用Stream提供的各种操作符和转换器,能够更好地处理事件流和异步数据。
Stream<int> timedCounter(Duration interval, int maxCount) async* {
int count = 0;
while (count < maxCount) {
await Future.delayed(interval);
yield ++count;
}
}
void main() async {
// 监听Stream,执行特定逻辑
await for (final count in timedCounter(Duration(seconds: 1), 5)) {
print(count);
}
}
通过async*
和yield
,你可以构建出能够发射数据序列的Stream,为异步编程提供强大支持。
3. Isolate的轻量级并行计算
Isolate可以在不同的执行线程中运是执行并发操作的强大工具。
import 'dart:isolate';
Future<void> computeOnIsolate() async {
final receivePort = ReceivePort();
Isolate.spawn(_heavyComputation, receivePort.sendPort);
final message = await receivePort.first as String;
print(message);
}
void _heavyComputation(SendPort sendPort) {
// 很重的计算
// 假设这是一个令CPU满负荷的操作
sendPort.send('计算完成');
}
void main() {
computeOnIsolate();
}
通过Isolate,你可以在Flutter应用中执行耗时操作而不影响应用的响应性。
4. 使用枚举的高级技巧
枚举类型不仅仅可以代表一组命名常量,通过扩展方法,可以大幅提升它们的功能。
enum ConnectionState {
none,
waiting,
active,
done,
}
extension ConnectionStateX on ConnectionState {
bool get isTerminal => this == ConnectionState.done;
}
void main() {
final state = ConnectionState.active;
print('Is the connection terminal? ${state.isTerminal}');
}
枚举类型的扩展性提供了类似面向对象的模式,从而可以在保证类型安全的前提下,增加额外的功能。
5. 使用高级const构造函数
const构造函数允许在编译时创建不可变实例,有利于性能优化。
class ImmutableWidget {
final int id;
final String name;
const ImmutableWidget({this.id, this.name});
@override
String toString() => 'ImmutableWidget(id: $id, name: $name)';
}
void main() {
const widget1 = ImmutableWidget(id: 1, name: 'Widget 1');
const widget2 = ImmutableWidget(id: 1, name: 'Widget 1');
// 标识符相同,它们是同一个实例
print(identical(widget1, widget2)); // 输出: true
}
使用const构造函数创建的实例,由于它们是不可变的,可以被Dart VM在多个地方重用。
6. 元数据注解与反射
虽然dart:mirrors
库在Flutter中不可用,但理解元数据的使用可以为你提供设计灵感。
import 'dart:mirrors'; // 注意在非Web平台上不可用
class Route {
final String path;
const Route(this.path);
}
@Route('/login')
class LoginPage {}
void main() {
final mirror = reflectClass(LoginPage);
for (final instanceMirror in mirror.metadata) {
final annotation = instanceMirror.reflectee;
if (annotation is Route) {
print('LoginPage的路由是: ${annotation.path}');
}
}
}
通过注解,你可以给代码添加可读的元数据,并通过反射在运行时获取它们,为动态功能提供支持,虽然在Flutter中可能会借助其他方式如代码生成来实现。
7. 匿名mixin
创建匿名mixin能够在不暴露mixin到全局作用域的情况下复用代码。
class Bird {
void fly() {
print('飞翔');
}
}
class Swimmer {
void swim() {
print('游泳');
}
}
class Duck extends Bird with Swimmer {}
void main() {
final duck = Duck();
duck.fly();
duck.swim();
}
利用匿名mixin可以在不同的类中混入相同的功能而不需要创建明显的类层次结构,实现了代码的复用。
8. 高级异步编程技巧
在异步编程中,Dart提供了Future、Stream、async和await等强大的工具。
Future<String> fetchUserData() {
// 假设这是一个网络请求
return Future.delayed(Duration(seconds: 2), () => '用户数据');
}
Future<void> logInUser(String userId) async {
print('尝试登录用户...');
try {
final data = await fetchUserData();
print('登录成功: $data');
} catch (e) {
print('登录失败: $e');
}
}
void main() {
logInUser('123');
}
通过使用async
和await
,可以编写出看起来像同步代码的异步操作,使得异步代码更加简洁和易于理解。
来源:juejin.cn/post/7321526403434315811
用了这么多年的字体,你知道它是怎么解析的吗?
大家好呀。
因为之前有过字体解析的相关代码开发,一直想把这个过程记录下来,总觉得这个并不是很难,自认为了解得不算全面,就一拖再拖了。今天仅做简单的抛砖引玉,通过本篇文章,可以知道Opentype.js是如何解析字体的。在遇到对应问题的时候,可以有其它的思路去解决。比如:.ttc的解析。又或者好奇我们开发软件过程中字体是如何解析的。
Opentype.js 使用
看官方readme也可以,这里直接将github代码下载,使用自动化测试目录里的字体文件。
需要注意的是load方法已经被废弃。
function load() {
console.error('DEPRECATED! migrate to: opentype.parse(buffer, opt) See: https://github.com/opentypejs/opentype.js/issues/675');
}
将package.json
设置为type: module
,然后就可以直接使用import
了。
import { parse } from './src/opentype.mjs';
import fs from 'fs';
// test/fonts/AbrilFatface-Regular.otf
const buffer = fs.promises.readFile('./test/fonts/AbrilFatface-Regular.otf');
// if not running in async context:
buffer.then(data => {
const font = parse(data);
console.log(font.tables);
})
这样就能得到解析的结果了。
Opentype源码阅读
parseBuffer:解析的入口
通过简单的调用入口,我们可以反查源码。传入文件的ArrayBuffer并返回Font结构的对象,在不清楚会有什么结构的时候,可以通过Font查看,当然了,直接console.log查看更方便。
// Public API ///////////////////////////////////////////////////////////
/**
* Parse the OpenType file data (as an ArrayBuffer) and return a Font object.
* Throws an error if the font could not be parsed.
* @param {ArrayBuffer}
* @param {Object} opt - options for parsing
* @return {opentype.Font}
*/
function parseBuffer(buffer, opt={}) {
// ...
// should be an empty font that we'll fill with our own data.
const font = new Font({empty: true});
}
export {
// ...
parseBuffer as parse,
// ...
};
字体类型判断
接着往下阅读。
根据signature
的值,去确认字体类型。粗略看来,这里仅支持了TrueType
(.ttf)、CFF
(.otf)、WOFF
、WOFF2
。
const signature = parse.getTag(data, 0);
if (signature === String.fromCharCode(0, 1, 0, 0) || signature === 'true' || signature === 'typ1') {
} else if (signature === 'OTTO') {
} else if (signature === 'wOFF') {
} else if (signature === 'wOF2') {
} else {
throw new Error('Unsupported OpenType signature ' + signature);
}
还需要注意的是,signature
的值是的获取(后续基本都是这样婶儿获取的信息)。从指定偏移位置开始,读取4个字节的数据,并将每个字节转换为字符,最终返回一个4字符的字符串标签。
// Retrieve a 4-character tag from the DataView.
// Tags are used to identify tables.
function getTag(dataView, offset) {
let tag = '';
for (let i = offset; i < offset + 4; i += 1) {
tag += String.fromCharCode(dataView.getInt8(i));
}
return tag;
}
表入口信息获取
再看TrueType
和CFF
字体的处理,除了对font.outlinesFormat
属性的设置之外。剩余的处理方式都是:获取表的个数numTables
,再获取表的入口偏移信息。
numTables = parse.getUShort(data, 4);
tableEntries = parseOpenTypeTableEntries(data, numTables);
// Table Directory Entries //////////////////////////////////////////////
/**
* Parses OpenType table entries.
* @param {DataView}
* @param {Number}
* @return {Object[]}
*/
function parseOpenTypeTableEntries(data, numTables) {
const tableEntries = [];
let p = 12;
for (let i = 0; i < numTables; i += 1) {
const tag = parse.getTag(data, p);
const checksum = parse.getULong(data, p + 4);
const offset = parse.getULong(data, p + 8);
const length = parse.getULong(data, p + 12);
tableEntries.push({tag: tag, checksum: checksum, offset: offset, length: length, compression: false});
p += 16;
}
return tableEntries;
}
function getUShort(dataView, offset) {
return dataView.getUint16(offset, false);
}
// Retrieve an unsigned 32-bit long from the DataView.
// The value is stored in big endian.
function getULong(dataView, offset) {
return dataView.getUint32(offset, false);
}
留意到tableEntries
获取的offset是从12开始的,而获取numTables
是从4开始的,也仅仅是getUnit16
,也就是说4-12中间还会有别的信息。
表信息标准描述
这时候只能通过查看微软排版文档描述,Microsoft Typography documentation: Organization of an OpenType Font。
按照8bit计算,这些信息之后,刚好是在12个字节开始。
后续的描述就是parseOpenTypeTableEntries
的结构信息了。
表入口数据
以选择的AbrilFatface-Regular.otf 为例。我们可以打断点看看,这两步骤得到的结果,这里Opentype提供了网址,就直接在上面断点了。
这里有11个表,在入口分别有对应的名称、偏移量、长度、校验和。
表数据解析
有了表入口信息,就可以通过tableEntries
获取表的数据了。接下来的代码就是通过对应的tag(name)
去选择对应的解析方式。有些表的信息需要依赖于别的表,则先暂时存起来。比如: name表需要依赖language表。
case 'ltag':
table = uncompressTable(data, tableEntry);
ltagTable = ltag.parse(table.data, table.offset);
break;
// ...
case 'name':
nameTableEntry = tableEntry;
break;
// ...
const nameTable = uncompressTable(data, nameTableEntry);
font.tables.name = _name.parse(nameTable.data, nameTable.offset, ltagTable);
font.names = font.tables.name;
这里就简单看下ltag
表的解析,table = uncompressTable(data, tableEntry);
判断是否有压缩,比如WOFF压缩字体,这里没有entry数据就还是原来的。
ltag表的解析
function parseLtagTable(data, start) {
const p = new parse.Parser(data, start);
const tableVersion = p.parseULong();
check.argument(tableVersion === 1, 'Unsupported ltag table version.');
// The 'ltag' specification does not define any flags; skip the field.
p.skip('uLong', 1);
const numTags = p.parseULong();
const tags = [];
for (let i = 0; i < numTags; i++) {
let tag = '';
const offset = start + p.parseUShort();
const length = p.parseUShort();
for (let j = offset; j < offset + length; ++j) {
tag += String.fromCharCode(data.getInt8(j));
}
tags.push(tag);
}
return tags;
}
创了p
这个Parser
实例,包含各种长度parseShort
、parseULong
等。自动移动offset,避免每次手动传入位置。获取了table的version信息,而后就是循环的获取表内容了。找了好些个字体,都没有ltag表🤦🏻♀️
解析小结
这里我们可以初步的了解到整个字体的解析过程,就是按照约定的顺序,有个线头般一点儿一点儿的找到所需,只储存了数据。
如需获取最终字形信息,可能需要经过多个表联合查询,比如loca获取字形数据的偏移量,glyf获取字形数据,又或者camp获取字符代码对应的字形索引。
TTC字体集合的解析
回到前面提出的,ttc字体集合,应该怎么解析呢?参照文档对字体集合的处理 Font Collections,相信大家已经有办法解析了。
注意:这里截图给出的是1.0的结构,更多的查看文档。
最后
这次的分享就到这里了,对一些有按需解析,自定义解析的场景下,希望对大家有帮助。
来源:juejin.cn/post/7400072326199640100
前端身份验证终极指南:Session、JWT、SSO 和 OAuth 2.0
Hello,大家好,我是 Sunday
在前端项目开发中,验证用户身份主要有 4 种方式:Session、JWT、SSO 和 OAuth 2.0
。
那么这四种方式各有什么优缺点呢?今天,咱们就来对比下!
01:基于 Session 的经典身份验证方案
什么是基于Session的身份验证?
基于 Session 的身份验证是一种在前端和后端系统中常用的用户认证方法。
它主要依赖于服务器端创建和管理用户会话。
Session 运行的基本原理
Session 的运行流程分为 6 步:
- 用户登录:用户在登录页面输入凭据(如用户名和密码)。这些凭据通过前端发送到后端服务器进行验证。
- 创建会话:后端服务器验证凭据后,创建一个会话(session)。这个会话通常包括一个唯一的会话 ID,该 ID 被存储在服务器端的会话存储中。
- 返回会话 ID:服务器将会话 ID 返回给前端,通常是通过设置一个 cookie。这个 cookie 被发送到用户的浏览器,并在后续的请求中自动发送回服务器。
- 保存会话 ID:浏览器保存这个 cookie,并在用户每次向服务器发起请求时都会自动包含这个 cookie。这样,服务器就能识别出该用户的会话,从而实现身份验证。
- 会话验证:服务器根据会话 ID 查找和验证该用户的会话信息,并确定用户的身份。服务器可以使用会话信息来确定用户的权限和访问控制。
- 会话过期与管理:服务器可以设置会话过期时间,定期清除过期的会话。用户注销或会话超时后,服务器会删除或使会话失效。
通过以上流程,我们可以发现:基于 Session 的身份验证,前端是不需要主动参与的。核心是 浏览器 和 服务器 进行处理
优缺点
优点:
- 简单易用:对开发者而言,管理会话和验证用户身份相对简单。
- 兼容性好:大多数浏览器支持 cookie,能够自动发送和接收 cookie。
缺点:
- 扩展性差:在分布式系统中,多个服务器可能需要共享会话存储,这可能会增加复杂性。
- 必须配合 HTTPS:如果 cookie 被窃取,可能会导致会话劫持。因此需要使用 HTTPS 来保护传输过程中的安全性,并实施其他安全措施(如设置 cookie 的
HttpOnly
和Secure
属性)。
示例代码
接下来,我们通过 Express 实现一个基本的 Session 验证示例
const express = require('express');
const session = require('express-session');
const app = express();
// 配置和使用 express-session 中间件
app.use(session({
secret: 'your-secret-key', // 用于签名 Session ID cookie 的密钥,确保会话的安全
resave: false, // 是否每次请求都重新保存 Session,即使 Session 没有被修改
saveUninitialized: true, // 是否保存未初始化的 Session
cookie: {
secure: true, // 是否只通过 HTTPS 发送 cookie,设置为 true 需要 HTTPS 支持
maxAge: 24 * 60 * 60 * 1000 // 设置 cookie 的有效期,这里设置为 24 小时
}
}));
// 登录路由处理
app.post('/login', (req, res) => {
// 进行用户身份验证(这里假设用户已经通过验证)
// 用户 ID 应该从数据库或其他存储中获取
const user = { id: 123 }; // 示例用户 ID
req.session.userId = user.id; // 将用户 ID 存储到 Session 中
res.send('登录成功');
});
app.get('/dashboard', (req, res) => {
if (req.session.userId) {
// 如果 Session 中存在用户 ID,说明用户已登录
res.send('返回内容...');
} else {
// 如果 Session 中没有用户 ID,说明用户未登录
res.send('请登录...'); // 提示用户登录
}
});
app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});
02:基于 JWT(JSON Web Token) 的身份验证方案
什么是基于 JWT 的身份验证?
这应该是我们目前 最常用 的身份验证方式。
服务端返回 Token
表示用户身份令牌。在请求中,把 token
添加到请求头中,以验证用户信息。
因为 HTTP
请求本身是无状态的,所以这种方式也被成为是 无状态身份验证方案
JWT 运行的基本原理
- 用户登录:用户在登录页面输入凭据(如用户名和密码),这些凭据通过前端发送到后端服务器进行验证。
- 生成 JWT:后端服务器验证用户凭据后,生成一个 JWT。这个 JWT 通常包含用户的基本信息(如用户 ID)和一些元数据(如过期时间)。
- 返回 JWT:服务器将生成的 JWT 发送回前端,通常通过响应的 JSON 数据返回。
- 存储 JWT:前端将 JWT 存储在客户端(Token),通常是 localStorage 。极少数的情况下会保存在 cookie 中(但是需要注意安全风险,如:跨站脚本攻击(XSS)和跨站请求伪造(CSRF))
- 使用 JWT 进行请求:在用户进行 API 调用时,前端将 JWT(Token) 附加到请求的 Authorization 头部(格式为
Bearer
)发送到服务器。 - 验证 JWT:服务器接收到请求后,提取 JWT(Token) 并验证其有效性。验证过程包括检查签名、过期时间等。如果 JWT 合法,服务器会处理请求并返回相应的资源或数据。
- 响应请求:服务器处理请求并返回结果,前端根据需要展示或处理这些结果。
优缺点
优点
- 无状态:JWT 是自包含的,不需要在服务器端存储会话信息,简化了扩展性和负载均衡。
- 跨域支持:JWT 可以在跨域请求中使用(例如,API 与前端分离的场景)。
缺点
- 安全性:JWT 的安全性取决于密钥的保护和有效期的管理。JWT 一旦被盗用,可能会带来安全风险。
示例代码
接下来,我们通过 Express 实现一个基本的 JWT 验证示例
const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
const secretKey = 'your-secret-key'; // JWT 的密钥,用于签名和验证
// 登录路由,生成 JWT
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 用户身份验证(假设验证通过)
const user = { id: 1, username: 'user' }; // 示例用户信息
const token = jwt.sign(user, secretKey, { expiresIn: '24h' }); // 生成 JWT
res.json({ token }); // 返回 JWT
});
// 受保护的路由
app.get('/dashboard', (req, res) => {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).send('没有提供令牌');
}
jwt.verify(token, secretKey, (err, decoded) => {
if (err) {
return res.status(401).send('无效的令牌');
}
res.send('返回仪表板内容');
});
});
app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});
03:基于 SSO 的身份验证方案
什么是基于 SSO(Single Sign-On,单点登录) 的身份验证?
SSO 身份验证多用在 “成套” 的应用程序中,通过 登录中心 的方式,可以实现 一次登录,在多个应用中均可以获取身份
SSO 运行的基本原理
- 用户访问应用:用户访问一个需要登录的应用(称为服务提供者或 SP)。
- 重定向到身份提供者:由于用户尚未登录,应用会将用户重定向到 SSO 身份提供者(Identity Provider,简称 IdP)(一般称为 登录中心)。登录中心 是负责处理用户登录和身份验证的系统。
- 用户登录:用户在 登录中心 输入凭据进行登录。如果用户已经在 IdP 处登录过(例如,已登录到公司内部的 SSO 系统),则可能直接跳过登录步骤。
- 生成 SSO 令牌:SSO 身份提供者验证用户身份后,生成一个 SSO 令牌(如 OAuth 令牌或 SAML 断言),并将用户重定向回原应用,同时附带令牌。
- 令牌验证:原应用(服务提供者)接收到令牌后,会将其发送到 SSO 身份提供者进行验证。SSO 身份提供者返回用户的身份信息。
- 用户访问应用:一旦身份验证成功,原应用会根据用户的身份信息提供访问权限。用户现在可以访问应用中的受保护资源,而无需再次登录。
- 访问其他应用:如果用户访问其他应用,这些应用会重定向用户到相同的 登录中心 进行身份验证。由于用户已经登录,登录中心 会自动验证并将用户重定向回目标应用,从而实现无缝登录。
优缺点
优点
- 简化用户体验:用户只需登录一次,即可访问多个应用或系统,减少了重复登录的麻烦。
- 集中管理:管理员可以集中管理用户的身份和访问权限,提高了管理效率和安全性。
- 提高安全性:减少了密码泄露的风险,因为用户只需记住一个密码,并且可以使用更强的认证机制(如多因素认证)。
缺点
- 单点故障:如果 登录中心 出现问题,可能会影响所有依赖该 SSO 服务的应用。
- 复杂性:SSO 解决方案的部署和维护可能较为复杂,需要确保安全配置和互操作性。
常见的 SSO 实现技术
- SAML(Security Assertion Markup Language):
- 一个 XML-based 标准,用于在身份提供者和服务提供者之间传递认证和授权数据。
- 常用于企业环境中的 SSO 实现。
- OAuth 2.0 和 OpenID Connect:
- OAuth 2.0 是一种授权框架,用于授权第三方访问用户资源。
- OpenID Connect 是建立在 OAuth 2.0 之上的身份层,提供用户身份认证功能。
- 常用于 Web 和移动应用中的 SSO 实现。
- CAS(Central Authentication Service):
- 一个用于 Web 应用的开源 SSO 解决方案,允许用户通过一次登录访问多个 Web 应用。
04:基于 OAuth 2.0 的身份验证方案
什么是基于 OAuth 2.0 的身份验证?
基于 OAuth 2.0 的身份验证是一种用于授权第三方应用访问用户资源的标准协议。常见的有:微信登录、QQ 登录、APP 扫码登录等
OAuth 2.0 主要用于授权,而不是身份验证,但通常与身份验证结合使用来实现用户登录功能。
OAuth 2.0 运行的基本原理
OAuth 2.0 比较复杂,在了解它的原理之前,我们需要先明确一些基本概念。
OAuth 2.0 的基本概念
- 资源拥有者(Resource Owner):通常是用户,拥有需要保护的资源(如个人信息、文件等)。
- 资源服务器(Resource Server):提供资源的服务器,需要保护这些资源免受未经授权的访问。
- 客户端(Client):需要访问资源的应用程序或服务。客户端需要获得资源拥有者的授权才能访问资源。
- 授权服务器(Authorization Server):责认证资源拥有者并授权客户端访问资源。它颁发访问令牌(Access Token)给客户端,允许客户端访问资源服务器上的受保护资源。
运行原理
- 用户授权:用户使用客户端应用进行操作时,客户端会请求授权访问用户的资源。用户会被重定向到授权服务器进行授权。
- 获取授权码(Authorization Code):如果用户同意授权,授权服务器会生成一个授权码,并将其发送回客户端(通过重定向 URL)。
- 获取访问令牌(Access Token):客户端使用授权码向授权服务器请求访问令牌。授权服务器验证授权码,并返回访问令牌。
- 访问资源:客户端使用访问令牌向资源服务器请求访问受保护的资源。资源服务器验证访问令牌,并返回请求的资源。
常见的授权流程
- 授权码流程(Authorization Code Flow):最常用的授权流程,适用于需要与用户交互的客户端(如 Web 应用)。用户在授权服务器上登录并授权,客户端获取授权码后再交换访问令牌。
- 隐式流程(Implicit Flow):适用于公共客户端(如单页应用)。用户直接获得访问令牌,适用于不需要安全存储的情况,但不推荐用于高度安全的应用。
- 资源所有者密码凭据流程(Resource Owner Password Credentials Flow):适用于信任客户端的情况。用户直接将用户名和密码提供给客户端,客户端直接获得访问令牌。这种流程不推荐用于公开的客户端。
- 客户端凭据流程(Client Credentials Flow):适用于机器对机器的情况。客户端直接向授权服务器请求访问令牌,用于访问与客户端本身相关的资源。
优缺点
优点
- 灵活性:OAuth 2.0 支持多种授权流程,适应不同类型的客户端和应用场景。
- 安全性:通过分离授权和认证,增强了系统的安全性。使用令牌而不是用户名密码来访问资源。
缺点
- 复杂性:OAuth 2.0 的实现和配置可能较复杂,需要正确管理访问令牌和刷新令牌。
- 安全风险:如果令牌泄露,可能会导致安全风险。因此需要采取适当的安全措施(如使用 HTTPS 和适当的令牌管理策略)。
示例代码
接下来,我们通过 Express 实现一个基本的 OAuth 2.0 验证示例
const express = require('express');
const axios = require('axios');
const app = express();
// OAuth 2.0 配置
const clientId = 'your-client-id';
const clientSecret = 'your-client-secret';
const redirectUri = 'http://localhost:3000/callback';
const authorizationServerUrl = 'https://authorization-server.com';
const resourceServerUrl = 'https://resource-server.com';
// 登录路由,重定向到授权服务器
app.get('/login', (req, res) => {
const authUrl = `${authorizationServerUrl}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=read`;
res.redirect(authUrl);
});
// 授权回调路由,处理授权码
app.get('/callback', async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).send('Authorization code is missing');
}
try {
// 请求访问令牌
const response = await axios.post(`${authorizationServerUrl}/token`, {
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret
});
const { access_token } = response.data;
// 使用访问令牌访问资源
const resourceResponse = await axios.get(`${resourceServerUrl}/user-info`, {
headers: { Authorization: `Bearer ${access_token}` }
});
res.json(resourceResponse.data);
} catch (error) {
res.status(500).send('Error during token exchange or resource access');
}
});
app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});
总结一下
目前这四种验证方案均有对应的 优缺点、应用场景:
- Session:非常适合简单的服务器呈现的应用程序
- JWT:适用于现代无状态架构和移动应用
- SSO:非常适合具有多种相关服务的企业环境
- OAuth 2.0:第三方集成和 API 访问的首选
来源:juejin.cn/post/7399986979736322063
京东企业业务前端监控实践
作者:零售企业业务苏子刚
监控的背景和意义
在现代前端开发中,接入监控系统是一个很重要的环节,它可以帮助开发者、产品、运营了解应用的性能表现,用户的实际体验以及潜在的错误和问题,从而进一步优化用户体验,帮助产品升级迭代。
背景
•应用复杂性增加:随着单页应用(SPA)和渐进式网页应用(PWA)的流行,前端应用变得越来越复杂。
•网页加载性能要求提高:用户对网页加载速度和交互响应的要求越来越高,性能成为影响用户体验的关键因素。
•多样化的设备和网络环境:用户通过各种设备和网络环境访问应用,这些因素都可能影响应用的性能和用户体验。
•敏捷开发和持续部署:敏捷开发和 CI/CD 的实践要求开发团队能够快速的响应问题,并持续改进产品。
意义
•对应用性能监控,提升用户体验:监控页面的加载时间,交互时间等性能指标,帮助团队优化代码,提升用户体验。
•错误追踪,快速定位并解决问题:捕获前端错误和异常,快速定位问题源头,缩短故障的修复时间。
•用户行为分析,指导产品快速迭代:了解用户如何与应用互动,哪些功能用户喜欢,哪些路径导致转化,从而指导产品迭代升级。
•业务指标监控,保证主流程的稳定性:监控关键业务流程,如:购物车、商详、结算页等黄流,确保业务流程的稳定性。
•告警系统,异常情况能够快速响应:通过定时任务自动化巡检/实时监控,当出现性能异常时,能够快速响应。
监控的类别
从上面可看出,通过监控能够促使我们的系统更加健壮、稳定,体验更好。那我们团队也从去年开始逐步将各个应用接入了监控。到目前为止,我们的监控分为两个部分:
•实时监控:成功集成了 SGM 集团内部监控平台,并引入了相应的 SDK 来对前端应用进行实时监控。目前,我们所涉及的 100+ 个应用程序已经完全接入该系统。通过配置有效的告警机制,我们能够及时捕捉并上报线上环境中的错误和异常,确保业务的稳定运行。
•定时任务巡检:实现了定时任务的自动化设置,用于定期执行自动巡检,并自动上报检测结果,还能激活告警系统。通过这种方式,我们能够确保持续监控系统健康状况,并在发现潜在问题时能够迅速响应。
◦使用 Chrome 插件快速创建 UI 测试脚本,随后在 UI啄木鸟平台 上配置定时执行这些脚本进行系统巡检。这一流程实现了自动化的界面测试,确保应用的用户界面按预期工作,同时及时发现并解决潜在的 UI 问题;
◦自主研发一套脚本,这些脚本通过启动一个 Node.js 服务来进行系统巡检。这种方法使我们能够灵活地监控系统性能和功能,及时发现并处理潜在的问题,从而保障系统的稳定运行;
监控整体架构
监控建设实践
实时监控
通过整合 SGM 监控平台和告警系统,我们实现了对所有接入应用的实时监控。一旦检测到异常,系统会立即通过多种方式(如咚咚、邮件、电话等)通知相关团队成员,确保问题能够被迅速发现和解决。为了提高告警的有效性,我们精心设计了告警策略,确保只有真正的异常情况才会触发告警。以下是我们在优化告警设置过程中积累的一些关键经验和建议:
1.精确度与敏感度的平衡:过于敏感的告警会导致频繁的误报,而设置过于宽松则可能错过关键的异常。找到合适的平衡点至关重要。
2.分级告警机制:根据问题的严重程度设置不同级别的告警,以便采取相应的响应措施。紧急问题可以通过电话直接通知,而较为轻微的问题可以通过邮件或即时消息通知。
3.持续优化告警规则:定期回顾和分析告警的准确性和响应情况,根据实际情况调整告警规则,以提高告警的准确性和有效性。
4.明确责任分配:确保每个告警都有明确的责任人,这样一旦发生异常,能够迅速有人响应。
5.培训和意识提升:对团队成员进行定期的培训,提高他们对告警系统的理解和重视,确保每个人都能正确响应告警。
这些经验和建议是我们在实践中不断摸索和尝试的结果,希望能够帮助你们更有效地管理和响应系统告警,确保应用的稳定运行。
WEB端
用户体验
用户体验是指用户在使用网站过程中的整体感受、情绪和态度,它包括与产品交互的全程。对于开发者而言,打造出色的用户体验意味着关注网站的加载速度、视觉稳定性、交互响应时间、渲染性能等关键因素。Google 提出了一系列 Web 性能标准指标,这些指标旨在量化用户体验的不同层面。SGM 性能监控平台紧跟这些标准,对几项关键指标进行监控并提供反馈,以确保用户能够获得流畅且愉悦的网站使用体验。
•LCP:页面加载过程中最大的内容元素(图片、视频等)渲染完成的时间点。也可近似看作是首屏加载时间。Google 标准是 小于等于 2500ms。
最初,我们遵循了 Google 提出的标准来精确设定我们的告警系统。
配置告警后,我们注意到告警频率较高,主要是因为多数系统的实际网页最大内容绘制(LCP)值普遍超过了预期的 2.5s 标准。然而,这些告警并未实质性影响用户的正常使用体验。为了优化告警机制,我们经过仔细评估后,决定将部分系统的 LCP 阈值暂时调整至 5s,并相应调整了告警级别,以更合理地反映系统性能对用户体验的实际影响。
显然,当前调整的 LCP 阈值 5s 并非我们的最终目标。这一调整是基于应用当前的性能状况所做的临时措施,旨在优化告警的频率和质量。我们计划随着对各个应用性能的持续改进,最终将 LCP 阈值恢复至标准的 2.5s。
•CLS:从页面开始加载到生命周期状态更改为隐藏期间发生的所有意外布局偏移的累计得分。Web 给出的性能指标是 < 0.1
•FCP:从网页开始加载到有内容渲染的耗时。标准性能指标 1.8s
•FID:用户首次发出交互指令到页面可以响应为止的时间差。标准性能指标 100ms
•TTFB:客户端接收到服务器返回的第一个字节响应信息的耗时。标准性能指标 1000ms
从最初启用告警到目前决定关闭大部分告警,我们的决策基于以下考虑:
1.用户体验未受显著影响:即使某些性能指标超出了标准值,我们观察到这并未对用户操作网站造成实质性影响。
2.避免告警疲劳:频繁的告警可能导致开发团队对通知产生麻木感,从而忽视那些真正关键的、影响网站健康的告警。
3.指标作为优化参考:这些性能指标更多地被视为优化网站时的参考点,而对用户的直觉体验影响甚微。
4.有针对性的优化:在网站优化过程中,我们会将这些指标作为健康检查的一部分,进行针对性的改进,而无需依赖告警来频繁提醒。
基于这些理由,我们目前选择只对 LCP 指标保持告警启用,以确保关注到可能影响用户加载体验的关键性能问题。
健康度指标 | 告警开启 | 标准值 | 优化后值 | 告警阀值 | 告警级别 |
---|---|---|---|---|---|
LCP | 开启 | <=2500ms | 针对部分应用 <=5000ms | 1min 内 耗时>=5000ms(持续优化并更新阀值到2500ms) 调用次数:50 连续次数:1次 | 通知 |
FCP | 关闭 | <=1800ms | - | | - |
CLS | 关闭 | <=0.1 | - | | - |
FID | 关闭 | <=100ms | - | | - |
TTFB | 关闭 | <=1000ms | - | | - |
页面性能
网页性能涉及从加载开始到完全可交互的整个过程,包括页面内容的下载、解析和渲染等多个阶段。在 SGM 监控平台上,我们能够追踪到一系列与页面加载性能相关的指标,如页面加载总时长、DNS 解析时长、DOM 加载完成时间、用户的浏览器和地理分布、首次渲染(白屏)时间、用户行为追踪、性能重现和热力图分析等。这些丰富的数据为我们优化页面性能提供了宝贵的参考。特别是首次渲染时间,或称为白屏时间,是我们监控中特别关注的一个关键指标,因为它直接影响用户的首次印象和整体体验。
•白屏时间:这一指标能够向开发者展示哪些页面出现了白屏现象。在利用此指标之前,我们需要为每个应用单独配置白屏时间的监控参数,以确保准确地捕捉到首次内容呈现的时刻。这有助于我们识别并优化那些影响用户首次加载体验的关键页面。
为了监控白屏时间,我们必须在应用的全局配置中的白屏监控项下,指定每个页面的 URL 及其关键元素。同时,我们还需设定监控的起始时间点和超时阈值。值得注意的是,URL 配置支持正则表达式,这为我们提供了灵活性,以匹配和监控一系列相似的页面路径。
•白屏检测的机制:白屏检测机制的核心在于验证页面上的关键元素是否已经被渲染。所谓关键元素,指的是在配置过程中指定的用于检测的 DOM 元素。这些元素的渲染情况是判断页面是否白屏的依据。
•告警设置:白屏告警功能已经启用,但目前还处于初期阶段,一些功能尚待完善。例如,当前的 URL 配置尚未支持正则表达式匹配。
面对当前的局限性,我们采取了双策略应对。一方面,我们利用白屏告警功能直接监控页面的白屏情况;另一方面,我们通过分析页面加载性能指标中的白屏时间来间接监测潜在的白屏问题。在设置平均耗时时间和告警级别时,我们综合考虑了多个因素,包括用户的网络环境、告警的发生频率以及告警的实际适用性,以确保监控方案的有效性和合理性。
网页性能 | 告警开启 | 优化前值 | 优化后值 | 告警阀值 | 告警级别 |
---|---|---|---|---|---|
首包时间 | 关闭 | - | - | - | - |
页面完全加载时间 | 关闭 | - | - | - | - |
白屏时间 | 开启 | <=5000ms | <=10000ms | 1min 内 耗时>=10000ms,后期采用白屏告警替代 调用次数:30 连续次数:5 次 | 紧急 |
•此外,我们的页面追踪功能包括用户行为回溯、页面性能重现以及行为轨迹热力图,这些工具允许我们从多个维度和场景对用户行为进行深入分析,极大地便利了问题的诊断和排查。
JSError监控
我们通过配置错误关键词来匹配控制台的报错信息。报错阈值的设定可以参考各个项目的 QPS,因为在项目实施了恰当的降级策略后,即便控制台出现报错,页面通常仍能正常访问,不会对用户体验造成影响。因此,这个阈值可以适当设置得较高。
这里需要特别注意 “Script error” 的错误,这种错误给不到任何对我们有用的信息。所以需要采用一定的手段避免出现类似的报错:
•这种错误也称之为跨域错误,所以首先,我们需要开启跨域资源共享功能(CORS)。
<script src="http://xxxdomain.com/home.js" crossorigin></script>
•针对 Vue 项目,由于 Vue 重写了 window.onerror 事件,所以我们需要在 Vue 项目中增加 错误处理:
Vue.config.errorHandler = (err, vm, info)=> {
if (err) {
try {
console.error(err);
window.__sgm__.error(err)
} catch (e) {}
}
};
•在某些情况下,“Script error” 可能是无关紧要的,我们可以选择忽略这类特定的错误。为此,可以关闭这些特定错误的监控,具体的错误可以通过它们的 hash 在错误日志中进行识别和过滤。
关键指标 | 关键字(支持正则匹配) | 触发次数 | 告警级别 |
---|---|---|---|
js错误 | null、undefined、error、map、filter、style、length... | 周期:1min ,错误次数:50/100/200(可参考 QPS 值设置),连续次数:1次 | 严重 |
API请求监控
这里我们的告警设置主要关注 HTTP 状态码和业务错误码。这两项指标的异常表明我们的应用可能遇到了问题,需要我们迅速进行检查和处理以确保系统的正常运行。
首先,我们必须在应用的监控配置中设定数据采集参数:
关键指标 | 错误码 | 业务域名 | 触发次数 | 告警级别 |
---|---|---|---|---|
http错误 | !200(Http响应非200报警) | xx1.jd.com xx2.jd.com | 周期:1min 错误次数:1 总调用次数:50 连续次数:1 | 严重 |
业务失败码 | errCode(根据实际业务线设置 -1,-2等) |
针对业务失败码:
1.由于现有应用跨不同业务条线存在异常码的差异,我们需要针对每个业务线收集并配置其特定的异常码。
应用来源 | 标准响应 | 针对性告警 |
---|---|---|
慧采PC 企业购 | { "code":null, "success":true, "msg":"操作成功", "result":{} } | 业务异常码 code ·-1,-2 ·... |
锦礼 | { "code": 000, "data": {}, "msg": "操作成功" } | 业务异常码 code ·! (1000 && 3001等 ) |
color | | ·-1 echo ·1 echo |
其他 | ... | .... |
2、对于新增应用,我们实施了后端服务异常码的标准化。因此,在监控方面,我们只需要配置一套统一的标准来进行监控。
{
"50000X": "程序异常,内部",
"500001": "程序异常,上游",
"500002": "程序异常,xx",
"500003": "程序异常,xx",
...
}
资源错误
这里通常指的是 css、js、图片等资源的加载错误
关键指标 | 告警开启 | 告警阀值 | 告警级别 |
---|---|---|---|
资源错误 | 开启 | 周期:1min 错误次数:200(也可参照QPS进行设置) 连续次数:1 | 严重 |
对于图片加载错误,只需在项目中实施适当的降级方案。在应用的监控配置中,我们可以设置为不收集图片错误相关的数据。
再来举个例子:
在企业业务的封闭场景中,例如慧采平台,我们集成了埋点 JavaScript 脚本。然而,由于某些客户的网络环境,导致我们的埋点相关静态资源延迟加载。
治理方案:
自定义上报
每个业务流程的关键节点或核心功能实施了专门的监控措施,以便对任何异常状况进行跟踪、监控并及时上报。
目前,几条业务线已经实施了自定义上报机制,其主要目的包括:
•利用自定义上报来捕获接口异常的详细信息,如入参和出参,以便在线上出现异常时,能够依据上报的数据快速进行问题的诊断和定位。
•在复杂的环境下,准确追踪用户行为导致的错误,并利用上报的信息进行有效的问题排查和定位。
锦礼酷兜: 用户在选择地址后,系统未能根据所选地址提供正确的信息,导致页面加载出现异常。
由于这个 H5 页面被嵌入到用户的 App 内,开发者难以直接复现用户遇到的问题。因此,我们利用监控平台的自定义上报功能来收集相关信息,以辅助进行问题排查。
•上报地址组件返回值
•上报接口入参
•然后根据自定义上报日志查看具体信息
E卡:外部引用资源异常上报(设备指纹,eid 等)
在结算页面提交订单时,系统需要获取设备ID。为此,我们实施了降级方案,并通过自定义上报机制对此过程进行监控,以确保流程的顺利执行。
降级方案:如果获取不到,后端会生成 uuid 给到前端。
企业购注销pc: 我们需要集成科技 SDK,以便在页面上完成用户注销后自动跳转到指定的 URL。上线后,收到客户反馈指出在完成注销流程后页面未能正确跳转。
接口异常: 在接口异常中,添加自定义监控,查看入参和出参信息。
尽管我们已经在应用中引入了自定义监控以便更好地观察和定位问题,但我们仍需进一步细化和规范化这些监控措施。目前,我们正积极对各业务线的功能点进行梳理,以实现更深入的细化监控。我们的目标是为每个业务线、每个应用的关键链路和功能点定制针对各种异常情况的精细化自定义监控(例如,某页面按钮的显示或点击异常)。
自定义告警 | 告警开启 | 告警阀值 | 告警级别 |
---|---|---|---|
自定义编码 | 开启 | 1min内 调用次数:50 连续次数:1次 | 警告 |
小程序端
与 Web 端相比,小程序的监控存在一些差异,主要是缺少了如 LCP(最大内容绘制)等特定性能指标。然而,性能问题、JavaScript错误、资源加载错误等其他监控指标仍然可以被捕获。此外,小程序官方和开发者工具都提供了性能检测工具,这些工具便于开发者查看和优化小程序应用的性能。本文将不深入介绍 Web 端的监控指标,而是专注于介绍小程序中独有的监控和分析工具。
小程序官方后台
可以分析接口数据、js 分析等。
利用 SGM 的监控功能结合小程序官方的分析工具,对我们的小程序进行综合优化,是一个有效的策略。
原生应用
基础监控:mPaas、烛龙、SGM
mPaaS:崩溃监控。应用崩溃对用户体验有显著影响,是移动端监控中的一个关键指标。通过不断的监控和优化,京东慧采移动端的崩溃率已经降至较低水平,目前的平均用户崩溃率大约为0.03122%。
烛龙:启动耗时、首屏耗时、启动且首焦耗时、卡顿。为了改善首屏加载时间,京东慧采采用了烛龙监控平台并实施了相应的优化措施。优化后,应用的整体性能显著提升,其中 Android 平台的tp95耗时大约为 2764ms,iOS 平台的tp95耗时大约为1791ms,均达到了较低的水平。
SGM:网络、WebView、原生页面等指标。
业务监控
京东慧采在多个业务模块中,其中登录、商详、订单详情 3 个模块接入了业务监控。
登录:登录接入 SGM 监控平台,自定义了整个流程错误码,并配置了告警规则
(1)600:登录正常流程(必要是可白名单开启)
(2)601:登录防刷验证流程异常监控
(3)602:登录魔方验证流程异常监控
(4)603:登录流程异常监控
商详、订单详情:在商品详情和订单详情页面,我们集成了业务监控 SDK。通过移动配置平台,我们下发了监控规则,以便在接口返回的数据不符合预期时,能够上报错误信息。目前,这些监控信息被上报到崩溃分析平台的自定义异常模块中,以便进行进一步的分析和优化。
(1)接口请求是否成功。
(2)banner 楼层:是否为空、楼层类型是否正常(1 原生)、数据/大小图地址是否为空。
(3)商品信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/商品名称/价格是否为空。
(4)服务楼层:是否为空、楼层类型是否正常(1 原生)、数据/服务信息是否为空。
(5)spu 和物流楼层:是否为空、楼层类型是否正常(1 原生)、数据/sup信息是否为空。
(6)其他:其他/按钮数据/按钮名称是否为空、按钮类型是否正常(排除1/2/3/4/5/6/20000)。
订单详情监控信息:
(1)接口请求是否成功。
(2)基础信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/地址信息是否为空。
(3)商品信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/商品列表是否为空。
(4)支付信息楼层:是否为空、楼层类型是否正常(2 动态)、数据是否为空。
(5)价格楼层:是否为空、楼层类型是否正常(2 动态)、数据是否为空。
定时巡检
定时巡检可以通过两种方法实现:
•利用 UI 啄木鸟平台配置定时执行的任务。
•使用团队开发的自定义脚本,并通过自动启动的服务来执行这些检测任务。
UI 啄木鸟
定时巡检的主要目的是确保每个项目的核心流程在每次迭代中保持稳定。通过配置定时任务,我们能够及时发现并解决线上问题,从而维护系统的稳定性。
什么是 UI 啄木鸟
UI啄木鸟平台,由京东集团-京东科技开发,是一个自动化巡检工具,其主要功能包括:
•Chrome插件:用于录制项目的用户交互步骤。
•定时任务平台:用于在服务器端定期执行已录制的脚本进行巡检。
在使用Chrome插件过程中,我们遇到了一些问题。与京东科技团队沟通后,我们获得了共同开发和升级插件的机会。目前,我们已经添加了新功能并修复了一些已知问题。
1.打开录制和停止录制按钮,分别开启监听和关闭拦截页面事件功能;
2.新增点击录制后保留当前录制步骤功能;
3.在执行事件过程中,禁止再次点击执行,避免执行顺序错乱;
4.点击事件可切换操作类型为 focus 事件,便于监听滚动条滑动;
5.在步骤复现情况下,调整判断元素选择器和屏幕位置的顺序,避免位置出入点击位置错位;
6....
使用chrome扩展程序进行安装即可。
怎么使用
•新建录制脚本
•点击详情,开启录制
•监听步骤
•关联到啄木鸟平台
•啄木鸟平台(调试、配置 Cookie,开启定时任务)
自启动巡检工具
自动化巡检工具能够检测页面上的多种元素和链接,包括 a 标签的外链、接口返回的链接、鼠标悬停元素、点击元素,以及跳转后 URL 的有效性。该工具尤其适合用于频道页,这些页面通常通过投放广告和配置通天塔链接来生成。在大型促销活动期间,我们可以运行脚本来验证广告和通天塔链接的有效性。目前,工具的功能还相对有限,并且对于广告组等特定接口的支持不够通用,这也是我们计划逐步改进和优化的方向。
功能检测
•检测所有 a 标签和所有接口响应数据中包含所有通天塔活动外链是否有效。
{
"cookieThor":"", // 是否依赖cookie等登录态,无需请传空
"urlPattern": "pro\.jd\.com",// 匹配的链接,这里只匹配通天塔
"urls": ["https://b.jd.com/s?entry=newuser"] // 将要检测的url,可填多个
}
运行结果:
•检测鼠标 hover 事件,收集用户交互后的接口响应数据,检测所有活动外联是否有效。
{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"hoverElements": [
{
"item": "#focus-category-id .focus-category-item", // css样式选择器
"target": ".focus-category-item-subtitle"
}
]
}
]
}
•检查 click 事件,收集用户交互后的接口响应数据,检测所有通天塔活动外联是否有效。
{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"clickElements": [
{
"item": "#recommendation-floor .drip-tabs-tab"
}
]
}
]
}
•检测点击后,跳转后的链接有效性。
{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"clickElements": [
{
"item": ".recommendation-product-wrapper .jdb-sku-wrapper"
}
]
}
]
}
检测原理
监控后暴露的问题
•慧采 jsError:
用于埋点数据上报的API,但在一些封闭环境中,由于网络环境,客户可能无法及时访问埋点或指纹识别等 SDK,导致频繁的报错。为了解决这个问题,我们可以通过引入 try...catch 语句来捕获异常,并结合使用队列机制,以确保埋点数据能够在网络条件允许时正常上报。这样既避免了错误的频繁发生,也保障了数据上报的完整性和准确性。
public exposure(exposureId: ExposureId, jsonParam = {}, eventParam = '') {
this.execute(() => {
try {
const exposure = new MPing.inputs.Exposure(exposureId);
exposure.eventParam = eventParam; // 设置click事件参数
exposure.jsonParam = serialize(jsonParam); // 设置json格式事件参数,必须是合法的json字符串
console.log('上报 曝光 info >> ', exposure);
new MPing().send(exposure);
} catch (e) {
console.error(e);
}
});
}
private execute(fn: CallbackFunction) {
if (this._MPing === null) {
this._Queue.push(fn);
} else {
fn();
}
}
•企业购订详异常:
总结
接入前
在过去,我们的应用上线后,对线上运行状况了解不足,错误发生时往往依赖于用户或运营团队的反馈,这使我们常常处于被动应对状态,有时甚至会导致严重的线上问题,如白屏、设备兼容性问题和异常报错等。为了改变这一局面,我们开始寻找和实施解决方案。从去年开始,我们逐步将所有 100+ 应用接入了监控系统,现在我们能够实时监控应用状态,及时发现并解决问题,大大提高了我们的响应速度和服务质量。
接入后
自从接入监控系统后,我们的问题发现和处理方式从被动等待用户反馈转变为了主动监测。现在我们能够即时发现并快速解决问题。此外,我们还利用监控平台对若干应用进行了性能优化,并为关键功能点制定了预先的降级策略,以保障应用的稳定运行。
•页面健康度监控表明,我们目前已有 50+ 个项目的性能评分达到或超过 85 分。通过分析监控数据,我们对每个应用的各个页面进行了详细分析和梳理。在确保流程准确无误的基础上,我们对那些性能较差的页面进行了持续的优化。目前,我们仍在对一些关键但性能表现一般的应用进行进一步的优化工作。
•通过配置 JavaScript 错误和资源错误的告警机制,我们在项目迭代过程中及时解决了多个 JavaScript 问题,有效降低了错误率和告警频率。正如前文所述,对于慧采PC 这类封闭环境,客户公司的网络策略可能会阻止埋点 SDK 和设备指纹等 JavaScript 资源的加载,导致全局变量获取时出现错误。我们通过实施异常捕获和队列机制,不仅规避了部分错误,还确保了埋点数据的准确上报。
•通过收集 HTTP 错误码和业务失败码,并设置相应的告警机制,我们能够在接到告警通知的第一时间内分析并解决问题。例如,我们成功地解决了集团内部遇到的 “color404” 问题等。这种做法加快了问题的响应和解决速度,提高了服务的稳定性和用户满意度。
•自定义监控:通过给接口异常做入参、出参的上报、在关键功能点上报有效信息等,能够在应用出现异常时,快速定位问题并及时修复。
•通过实施定时任务巡检,我们有效地避免了迭代更新上线可能对整个流程造成的影响。同时,巡检工具的使用也确保了外部链接的有效性,进一步保障了应用的稳定运行和用户体验。
规划
监控是一个持续长期的过程,我们致力于不断完善,确保系统的稳定性和安全性。基于现有的监控能力,我们计划实施以下几项优化措施:
1.应用性能提升:我们将持续优化我们的应用,目标是让 90% 以上的应用性能评分达到 90 分以上,并对资源错误和 JavaScript 错误进行有效管理。
2.深化监控细节:我们将扩展监控的深度和广度,确保能够捕捉到所有潜在的异常情况。例如,如果一个仅限采购账号使用的按钮错误地显示了,这应该触发自定义异常上报,并在代码层面实施降级处理。
3.巡检工具升级:我们将继续升级我们的 Chrome 巡检插件,提高其智能化程度和覆盖范围,以保持线上主要流程的健壮性和稳定性。
结尾
我们是企业业务大前端团队,会持续针对各端优化升级我们的监控策略,如果您有任何疑问或者有更好的建议,我们非常欢迎您的咨询和交流。
来源:juejin.cn/post/7400271712359186484
💥图片碎片化展示-Javascript
写在开头
哈喽吖!各位好!😁
今天刚好是周四呢,疯狂星期四快整起来。🍔🍟🍗
最近,小编从玩了两年多的游戏中退游了😔,本来以为会一直就这么玩下去,和队友们相处很融洽,收获了很多开心快乐的时光😭。可惜,游戏的一波更新......准备要开始收割韭菜了,只能无奈选择弃坑了。

小编属于贫民玩家,靠着硬肝与白嫖也将游戏号整得还不错,这两天把号给卖了💰。玩了两年多,竟然还能赚一点小钱,很开心😛。只是...多少有点舍不得的一起组队的队友们,唉。😔
记录一下,希望未来还有重逢一日吧,也希望各位一切安好!😆
好,回到正题,本文将分享一个图片碎片化展示的效果,具体效果如下,请诸君按需食用。

原理
这种特效早在几年前就已经出现,属于老演员了😪,它最早是经常在轮播图(banner)上应用的,那会追求各种花里胡哨的特效,而现在感觉有点返璞归真了,简洁实用就行。
今天咱们来看看它的具体实现原理是如何的,且看图:

一图胜千言,不知道聪明的你是否看明白了?😉
大概原理是:通过容器/图片大小生成一定数量的小块,然后每个小块背景也使用相同图片,再使用 background-size
与 background-position
属性调整背景图片的大小与位置,使小块又合成一整张大图片,这操作和使用"精灵图"的操作是一样的,最后,我们再给每个小块增加动画效果,就大功告成。
简单朴实😁,你可以根据这个原理自个尝试一下,应该能整出来吧。👻
具体实现
布局与样式:
<!DOCTYPE html>
<html>
<head>
<style>
body{
width: 100%;
height: 100vh;
padding: 0;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
.box {
width: var(--width);
height: var(--height);
display: flex;
/* 小块自动换行排列 */
flex-wrap: wrap;
justify-content: center;
}
.small-box {
background-image: url('https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/99b070fcb1de471d9af4f4d5d3f71909~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1120&h=1680&s=2088096&e=png&b=d098d0');
box-sizing: border-box;
background-repeat: no-repeat;
}
</style>
</head>
<body>
<div id="box" class="box"></div>
</body>
</html>
生成无数小块填充:
<script>
document.addEventListener('DOMContentLoaded', () => {
const box = document.getElementById('box');
const { width, height } = box.getBoundingClientRect();
// 定义多少个小块,由多少行和列决定
const row = 14;
const col = 10;
// 计算小块的宽高
const smallBoxWidth = width / col;
const smallBoxHeight = height / row;
/** @name 创建小块 **/
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
const smallBox = document.createElement('div');
smallBox.classList.add('small-box');
smallBox.style.width = smallBoxWidth + 'px';
smallBox.style.height = smallBoxHeight + 'px';
smallBox.style.border = '1px solid red';
// 插入小块
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
上面,生成多少个小块是由人为规定行(row
)与列(col
)来决定。可能有的场景想用小块固定的宽高来决定个数,这也是可以的,只是需要注意处理一下"边界"的情况。😶

调整小块背景图片的大小与位置:
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
smallBox.style.border = '1px solid red';
// 设置背景偏移量,让小块的背景显示对应图片的位置,和以前那种精灵图一样
const offsetX = j * smallBoxWidth * -1;
const offsetY = i * smallBoxHeight * -1;
smallBox.style.backgroundPosition = `${offsetX}px ${offsetY}px`;
smallBox.style.backgroundSize = `${width}px ${height}px`;
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
女神拼接成功,到这里就已经完成一大步了,是不是没什么难度!😋

小块样式整好后,接下来,我们需要来给小块增加动画,让它们动起来,并且是有规律的动起来。
先来整个简单的透明度动画,且看:
<!DOCTYPE html>
<html>
<head>
<style>
/* ... */
.small-box {
/* ... */
opacity: 0;
animation: smallBoxAnimate 2000ms linear forwards;
}
@keyframes smallBoxAnimate {
0% {
opacity: 0;
}
40% {
opacity: 0;
}
70% {
opacity: 1;
}
100% {
opacity: 1;
}
}
</style>
</head>
<body>
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
// smallBox.style.border = '1px solid red';
// 给每个小块增加不同的延时,让动画不同时间执行
const delay = i * 100; // 延迟时间为毫秒(ms),注意不要太小了
smallBox.style.animationDelay = `${delay}ms`;
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
</body>
</html>
嘿嘿😃,稍微有点意思了吧?


Em...等等,你发现没有?怎么有一些小白条?这可不是小编添加的,小块的边框(border
)已经是注释了的。😓
一开始小编以为是常见的"图片底部白边"问题,直接设置一下 display: block
或者 vertical-align : middle
就能解决,结果还不是,折腾了很久都没有搞掉这个小白条。😤
最后,竟然通过设置 will-change 属性能解决这个问题❗我所知道的 will-change
应该是应用在性能优化上,解决动画流畅度问题上的,想不到这里竟然也能用。
❗不对不对,当初以为是
will-change
能直接完美解决白边的问题,但是感觉还是不对,但又确实能解决。。。(部分电脑屏幕)
但其实,应该是
smallBoxWidth
与smallBoxHeight
变量不是整数的问题,只要小块的宽度与高度保持一个整数,自然就没有这些白边了❗这是比较靠谱的事实,对于当前的高清屏幕来说。
但是,也是很奇怪,在小编另一台电脑(旧电脑)上即使是保持了整数,也会在横向存在一些小白边,太难受了。。。没办法彻底搞定这个问题。
猜测应该是和屏幕分辨率有关,毕竟那才是根源所在。
2024年07月01日
看来得去深度学习一下💪 will-change
属性的原理过程才行,这里也推荐倔友写得一篇文章:传送门。
解决相邻背景图片白条/白边间隙问题:
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
smallBox.style.willChange = 'transform';
// 在动画执行后,需要重置will-change
const timer = setTimeout(() => {
smallBox.style.willChange = 'initial';
clearTimeout(timer);
}, 2000);
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
一定要注意 will-change
不可能被滥用,注意重置回来❗
这下女神在动画执行后,也清晰可见了,这是全部小块拼接组成的图片。

在上述代码中,咱们看到,通过 animation-delay
去延迟动画的执行,就能制造一个从上到下的渐变效果。
那么,咱们再改改延迟时间,如:
// const delay = i * 100;
// 改成 ⤵
const delay = j * 100;
效果:

这...好像有那么点意思吧。。。

但是,这渐变...好像还达不到我们开头 gif
的碎片化效果吧?
那么,碎片化安排上:
.small-box {
/* ... */
--rotateX: rotateX(0);
--rotateY: rotateY(0);
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
@keyframes smallBoxAnimate {
0% {
opacity: 0;
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
40% {
opacity: 0;
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
70% {
opacity: 1;
transform: rotateX(0) rotateY(0) scale(0.8);
}
100% {
opacity: 1;
transform: rotateX(0) rotateY(0) scale(1);
}
}
其实就是增加小块的样式动画而已,再加点旋转,再加点缩放,都整上,整上。😆
效果:

是不是稍微高级一点?有那味了?😁
看到上面旋转所用的"样式变量"没有?
--rotateX: rotateX(0);
--rotateY: rotateY(0);
不可能无缘无故突然使用,必然是有深意啦。😁
现在效果还不够炫,咱们将样式变量利用起来,让"相邻两个小块旋转相反":
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
// 相邻两个小块旋转相反
const contrary = (i + j) % 2 === 0;
smallBox.style.setProperty('--rotateX', `rotateX(${contrary ? -180 : 0}deg)`);
smallBox.style.setProperty('--rotateY', `rotateY(${contrary ? 0 : -180}deg)`);
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
效果:

这下对味了。😃
总的来说,我们可以通过"延迟"执行动画与改变"旋转"行为,让小块们呈现不同的动画效果,或者你只要有足够多的设想,你可以给小块添加不同的动画效果,相信也能制造出不错的整体效果。
更多效果
下面列举一些通过"延迟"执行动画产生的效果,可以瞧瞧哈。
随机:
const getRandom = (min, max) => Math.floor(Math.random() * (max - min + 1) + min);
const delay = getRandom(0, col + row) * 100;

从左上角到右下角:
const delay = (i + j) * 100;

其他的从"右上角到左下角"或者"左下角到右上角"等等的,只要反向调整一下变量就行了,就靠你自己悟啦,Come On!👻
从中心向四周扩散:
const delay = ((Math.abs(col / 2 - j) + Math.abs(row / 2 - i))) * 100;

从四周向中心聚齐:
const delay = (col / 2 - Math.abs(col / 2 - j) + (col / 2 - Math.abs(row / 2 - i))) * 100;

那么,到这里就差不多了❗
但还有最后一个问题,那就是图片的大量使用与加载时长的情况可能会导致效果展示不佳,这里你最好进行一些防范措施,如:
- 图片链接设置缓存,让浏览器缓存到内存或硬盘中。
- 通过
JS
手动将图片缓存到内存,主要就是创建Image
对象。 - 将图片转成
base64
使用。 - 直接将图片放到代码本地使用。
- ...
以上等等吧,反正最好就是要等图片完整加载后再进行效果展示。
至此,本篇文章就写完啦,撒花撒花。
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。
来源:juejin.cn/post/7379856289487831074
Flutter局部刷新三剑客
局部刷新作为提高Flutter页面性能的重要手段,是每一个Flutter老手都必须掌握的技巧。当然,我们不用非得使用Riverpod、Provider、Bloc这些状态管理工具来实现局部刷新,Flutter框架本身也给我们提供了很多方便快捷的刷新方案,今天要提的就是Notifier三剑客,用它来处理局部刷新,代码优雅又方便,可谓是居家必备之良器。
ChangeNotifier
ChangeNotifier作为数据提供方,给出了响应式编程的基础,我们先来看看ChangeNotifier的源码。
作为一个mixin,它就是实现了Listenable,这又是个什么呢?
这个抽象类,实际上就是实现了addListener和removeListener两个监听的处理。所以接下来我们看看ChangeNotifier是如何实现者两个方法的。
源码很简单,就是创建的listener添加到_listeners列表中。
移除也很简单。最后看下核心的notifyListeners方法。
这个方法就是遍历_listeners,来触发监听Callback。整体就是一个标准的「订阅-发布」流程。
作为Notifier家族的长辈,它的使用会略复杂一些,我们来看一个例子。首先,需要mixin一个ChangeNotifier。
class CountNotifier with ChangeNotifier {
int count = 0;
void increase() {
++count;
notifyListeners();
}
}
然后再创建一个TestWidget来调用这个ChangeNotifier。
class CountNotifierWidget extends StatefulWidget {
const CountNotifierWidget({super.key});
@override
State<StatefulWidget> createState() {
return _CountNotifierState();
}
}
class _CountNotifierState extends State<CountNotifierWidget> {
final CountNotifier _countNotify = CountNotifier();
int _count = 0;
@override
void initState() {
super.initState();
_countNotify.addListener(updateCount);
}
void updateCount() {
setState(() {
_count = _countNotify.count;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Test: $_count"),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.increase(),
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
super.dispose();
_countNotify.removeListener(updateCount);
}
}
这样当我们修改ChangeNotifier的value的时候,就会Callback到updateCount实现刷新。
这样就形成了一个响应式的基础模型,数据修改,监听者刷新UI,完成了响应式的同时,也实现了局部刷新的功能,提高了性能。
ValueNotifier
在使用ChangeNotifier的时候,每次在修改变量时,都需要手动调用notifyListeners()方法,所以,Flutter创建了一个新的组件——ValueNotifier,它的源码如下。
从源码可以看见,ValueNotifier就是在set方法中,帮你调用了下notifyListeners()方法。同时,ValueNotifier封装了一个泛型变量,简化了ChangeNotifier的创建过程,所以大部分时间我们都是直接使用ValueNotifier。
那么有了它之后,我们就可以省去新建类的步骤,对于单一的基础类型变量,直接创建ValueNotifier即可,就像上面的例子,我们可以直接改造成下面这样。
class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<int> _countNotify = ValueNotifier(0);
@override
void initState() {
super.initState();
_countNotify.addListener(updateCount);
}
void updateCount() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Test: ${_countNotify.value}"),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value++,
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
super.dispose();
_countNotify.removeListener(updateCount);
}
}
ValueListenableBuilder
我们从ChangeNotifier到ValueNotifier,逐步减少了模板代码的创建,但是依然还有很多问题,比如我们还是需要手动addListener、removeListener或者是dispose,同时,还需要使用setState来刷新页面,如果Context控制不好,很容易造成整个页面的刷新。因此,Flutter在它们的基础之上,又提供了ValueListenableBuilder来解决上面这些问题。
我们继续改造上面的例子。
class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<int> _countNotify = ValueNotifier(0);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<int>(
valueListenable: _countNotify,
builder: (context, value, child) {
return Text('Value: $value');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value++,
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
super.dispose();
_countNotify.dispose();
}
}
可以发现,我们使用ValueListenableBuilder来根据ValueNotifier的改变而刷新Widget。这样不仅简化了代码模板,而且不再使用setState来进行页面刷新。
ValueListenableBuilder作为一个非常经典的Widget,在它的注释中,就有很多教程和示例。
再看它的源码。
这里需要接收3个参数,其中valueListenable用来接收ValueNotifier,builder用来构建Widget,而child,用来创建不依赖ValueNotifier构建的Widget(这是一个很经典的性能优化的例子,如果子构建成本高,并且不依赖于通知符的值,我们将使用它进行优化)。
这个优化方案非常经典,在Flutter的很多地方都有使用这个技巧,特别是动画这块的处理。通常来说ValueNotifier对应ValueListenableBuilder,Listenable、ChangeNotifier对应AnimatedBuilder。
自定义类型
在使用自定义类型时,例如一个包装类,那么当你改变它的某个属性值时,ValueListenableBuilder是不会刷新的,我们来看下面这个例子。
class Wrapper {
int age;
String name;
Wrapper({this.age = 0, this.name = ''});
}
class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<Wrapper> _countNotify = ValueNotifier(Wrapper());
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<Wrapper>(
valueListenable: _countNotify,
builder: (context, value, child) {
return Text('Value: ${value.age}');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value.age = _countNotify.value.age + 1,
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
super.dispose();
_countNotify.dispose();
}
}
这样的话,ValueListenableBuilder就失去作用了,其原因也很简单,ValueNotifier所监听的数据其实并未发生改变,实例的内存地址没发生改变,所以,直接创建一个新的对象,就可以触发更新了,就像下面这样。
onPressed: () => _countNotify.value = Wrapper(age: 10),
自定义类型局部刷新
上面这种自定义模型的刷新方法还是略显复杂了一点,每次更新的时候,都要copy一下数据来实现更新,实际上,ValueNotifier继承自ChangeNotifier,所以可以通过手动调用notifyListeners的方式来进行刷新,我们改造下上面的例子。
class WrapperNotifier extends ValueNotifier<Wrapper> {
WrapperNotifier(Wrapper value) : super(value);
void increment() {
value.age++;
notifyListeners();
}
}
// 调用处
_countNotify.increment();
通过这种方式,我们可以实现当模型内部变量更新时,局部进行刷新了。
欢迎大家关注我的公众号——【群英传】,专注于「Android」「Flutter」「Kotlin」
我的语雀知识库——http://www.yuque.com/xuyisheng
来源:juejin.cn/post/7381767811679502346
快速理解 并发量、吞吐量、日活、QPS、TPS、RPS、RT、PV、UV、DAU、GMV
并发与并行
- 并发:由于CPU数量或核心数量不够,多个任务并不一定是同时进行的,这些任务交替执行(分配不同的CPU时间片,进程或者线程的上下文切换),所以是伪并行。
- 并行:多个任务可以在同一时刻同时执行,通常需要多个或多核处理器,不需要上下文切换,真正的并行。
并发量(Concurrency)
- 概念:并发或并行,是程序和运维本身要考虑的问题。而并发量,通常是不考虑程序并发或并行执行,只考虑一个服务端程序单位时间内同时可接受并响应多少个请求,通常以秒为单位,也可乘以86400,以天为单位。
- 计算方法,通常通过一些压测工具,例如ApiPost压测,或者ab压测来统计,依ab为例:
Window系统:Apache下bin目录有个ab.exe
CentOS系统:yum -y install httpd-tools.x86_64
ab -c 并发数 -n 请求数 网址
ab -c 10 -n 150 127.0.0.1/ 表示对127.0.0.1这个地址,用10个并发一共请求了150次。而不是1500次,
Time taken for tests: 1.249 seconds,说明并发量为 150 / 1.249 ≈ 120 并发,表示系统最多可承载120个并发每秒。
吞吐量(Throughput)
- 概念:吞吐量是指系统在单位时间能够处理多少个请求,TPS、QPS都是吞吐量的量化指标。
相比于QPS这些具有清晰定义的书面用语,吞吐量偏向口语化。
日活
- 概念:每日活跃用户的数量,通常偏向非技术指标用语,这个概念没有清晰的定义,销售运营嘴里的日活,可能是只有一个人1天访问100次,就叫做日活100,也可以说是日活1,中位数日活50,显然意义不大。
QPS(Query Per Second)
- 概念:每秒查询次数,通常是对读操作的压测指标。服务器在一秒的时间内能处理多少量的请求。和并发量概念差不多,并发量高,就能应对更多的请求。
- 计算方法,通常通过一些压测工具,例如ApiPost压测,或者ab压测来统计,依ab为例:
ab -c 10 -n 150 127.0.0.1/
其中返回一行数据:
Requests per second: 120.94 [#/sec] (mean)
表示该接口QPS在120左右。
TPS(Transactions Per Second)
- 概念:每秒处理的事务数目,通常是对写操作的压测指标。这里的事务不是数据库事务,是指服务器接收到请求,再到处理完后响应的过程。TPS表示一秒事件能够完成几次这样的流程。
TPS对比QPS
- QPS:偏向统计查询性能,一般不涉及数据写操作。
- TPS:偏向统计写入性能,如插入、更新、删除等。
RPS(Request Per Second)
- 概念:每秒请求数,和QPS、TPS概念差不多。没有过于清晰的定义,看你怎么用。
RT(Response Time)
- 概念:响应时间间隔,是指用户发起请求,到接收到请求的时间间隔,越少越好,应当控制在0~150毫秒之间。
PV(Page view)
- 概念:浏览次数统计,一般以天为单位。范围可以是单个页面,也可以是整个网站,一千个用户一天对该页面访问一万次,那该页面PV就是一万。
UV(Unique Visitor)
- 概念:唯一访客数。时间单位通常是天,1万个用户一天访问该网站十万次,那么UV是一万。
- 实现方案:已登录的用户可通过会话区分,未登录的用户可让客户端创建一个唯一标识符当做临时的token用于区分用户。
DAU(Daily Active Use)
- 概念:日活跃用户数量,来衡量服务的用户粘性以及服务的衰退周期。统计方案各不相同,这要看对活跃的定义,访问一次算活跃,还是在线时长超10分钟算活跃,还是用户完成某项指标算活跃。
GMV(Gross Merchandise Volume)
- 概念:单位时间内的成交总额。多用于电商行业,一般包含拍下未支付订单金额。
来源:juejin.cn/post/7400281441803403275
Canvas星空类特效
思路
- 绘制单个星星
- 在画布批量随机绘制星星
- 添加星星移动动画
- 页面resize处理
Vanilla JavaScript实现
- 初始化一个工程
pnpm create vite@latest
# 输入工程名后,类型选择Vanilla
cd <工程目录> pnpm install pnpm dev # 运行本地服务
body {
background-color: black;
overflow: hidden;
}
"use strict";
import './style.css';
document.querySelector('#app').innerHTML = `
`;
// 后续代码全部在main.js下添加即可
- 绘制单个星星
const hue = 220; // 颜色hue值可以根据自己喜好调整
// 离屏canvas不需要写入到静态html里,所以用createElement
const offscreenCanvas = document.createElement("canvas");
const offscreenCtx = offscreenCanvas.getContext("2d");
offscreenCanvas.width = 100;
offscreenCanvas.height = 100;
const half = offscreenCanvas.width / 2;
const middle = half;
// 设定径向渐变的范围,从画布中心到画布边缘
const gradient = offscreenCtx.createRadialGradient(
middle,
middle,
0,
middle,
middle,
half
);
// 添加多级颜色过渡,可以根据自己喜好调整
gradient.addColorStop(0.01, "#fff");
gradient.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`);
gradient.addColorStop(0.5, `hsl(${hue}, 64%, 6%)`);
gradient.addColorStop(1, "transparent");
// 基于渐变填充色,在画布中心为原点绘制一个圆形
offscreenCtx.fillStyle = gradient;
offscreenCtx.beginPath();
offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
offscreenCtx.fill();
参考链接:
- 在画布批量绘制星星
其实要绘制星星,我们只需要在画布上基于离屏画布来在指定位置将离屏画布渲染成图片即可,但是批量绘制以及后续的动画需要我们能记录每颗星星的位置、状态和行驶轨迹,所以可以考虑创建一个星星的类。
// 声明存放星星数据的数组,以及最大星星数量
const stars = [];
const maxStars = 1000;
// 用于提供随机值,不用每次都Math.random()
const random = (min, max) => {
if (!max) {
max = min;
min = 0;
}
if (min > max) {
[min, max] = [max, min];
}
return Math.floor(Math.random() * (max - min + 1)) + min;
};
// 用于计算当前以画布为中心的环绕半径
const maxOrbit = (_w, _h) => {
const max = Math.max(_w, _h);
const diameter = Math.round(Math.sqrt(max * max + max * max));
return diameter / 2;
};
class Star {
constructor(_ctx, _w, _h) {
this.ctx = _ctx;
// 最大轨道半径
this.maxOrbitRadius = maxOrbit(_w, _h);
// 轨道半径
this.orbitRadius = random(this.maxOrbitRadius);
// 星星大小(半径)
this.radius = random(60, this.orbitRadius) / 12;
// 环绕轨道中心,即画布中心点
this.orbitX = _w / 2;
this.orbitY = _h / 2;
// 随机时间,用于动画
this.elapsedTime = random(0, maxStars);
// 移动速度
this.speed = random(this.orbitRadius) / 500000;
// 透明度
this.alpha = random(2, 10) / 10;
}
// 星星的绘制方法
draw() {
// 计算星星坐标[x, y],使用sin和cos函数使星星围绕轨道中心做圆周运动
const x = Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX;
const y = Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY;
// 基于随机数调整星星的透明度
const spark = Math.random();
if (spark < 0.5 && this.alpha > 0) {
this.alpha -= 0.05;
} else if (spark > 0.5 && this.alpha < 1) {
this.alpha += 0.05;
}
// 调整全局绘制透明度,使后续绘制都基于这个透明度绘制,也就是绘制当前星星
// 因为动画里会遍历每一个星星进行绘制,所以透明度会来回改变
this.ctx.globalAlpha = this.alpha;
// 在星星所在的位置基于离屏canvas绘制一张星星的图片
this.ctx.drawImage(offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
// 时间基于星星的移动速度递增,为下一帧绘制做准备
this.elapsedTime += this.speed;
}
}
获取当前画布,批量添加星星
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let w = canvas.width = window.innerWidth;
let h = canvas.height = window.innerHeight;
for (let i = 0; i < maxStars; i++) {
stars.push(new Star(ctx, w, h));
}
- 添加星星的移动动画
function animation() {
// 绘制一个矩形作为背景覆盖整个画布,'source-over'是用绘制的新图案覆盖原有图像
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.8;
ctx.fillStyle = `hsla(${hue} , 64%, 6%, 1)`;
ctx.fillRect(0, 0, w, h);
// 绘制星星,'lighter'可以使动画过程中重叠的星星有叠加效果
ctx.globalCompositeOperation = 'lighter';
stars.forEach(star => {
star.draw();
});
window.requestAnimationFrame(animation);
}
// 调用动画
animation();
这样星星就动起来了。
- 页面resize处理
其实只需要在resize事件触发时重新设定画布的大小即可
window.addEventListener('resize', () => {
w = canvas.width = window.innerWidth;
h = canvas.height = window.innerHeight;
});
但是有一个问题,就是星星的运行轨迹并没有按比例变化,所以需要添加两处变化
// 在Star类里添加一个update方法
class Star {
constructor(_ctx, _w, _h) {//...//}
//添加部分
update(_w, _h) {
// 计算当前的最大轨道半径和类之前保存的最大轨道半径的比例
const ratio = maxOrbit(_w, _h) / this.maxOrbitRadius;
// 因为每帧动画都会调用这个方法,但比例没变化时不需要按比例改变移动轨道,所以加个判断
if (ratio !== 1) {
// 重新计算最大轨道半径
this.maxOrbitRadius = maxOrbit(_w, _h);
// 按比例缩放轨道半径和星星的半径
this.orbitRadius = this.orbitRadius * ratio;
this.radius = this.radius * ratio;
// 重新设置轨道中心点
this.orbitX = _w / 2;
this.orbitY = _h / 2;
}
}
draw() {//...//}
}
// 在animation函数里调用update
function animation() {
// ...
stars.forEach(star => {
star.update(w, h);
star.draw();
});
// ...
}
React实现
react实现主要需要注意resize事件的处理,怎样避免重绘时对星星数据初始化,当前思路是使用多个useEffect
import React, { useEffect, useRef, useState } from 'react';
const HUE = 217;
const MAX_STARS = 1000;
const random = (min: number, max?: number) => {
if (!max) {
max = min;
min = 0;
}
if (min > max) {
[min, max] = [max, min];
}
return Math.floor(Math.random() * (max - min + 1)) + min;
};
const maxOrbit = (_w: number, _h: number) => {
const max = Math.max(_w, _h);
const diameter = Math.round(Math.sqrt(max * max + max * max));
return diameter / 2;
};
// 离屏canvas只需要执行一次,但是直接在函数外部使用document.createElement会出问题
const getOffscreenCanvas = () => {
const offscreenCanvas = document.createElement('canvas');
const offscreenCtx = offscreenCanvas.getContext('2d')!;
offscreenCanvas.width = 100;
offscreenCanvas.height = 100;
const half = offscreenCanvas.width / 2;
const middle = half;
const gradient = offscreenCtx.createRadialGradient(middle, middle, 0, middle, middle, half);
gradient.addColorStop(0.01, '#fff');
gradient.addColorStop(0.1, `hsl(${HUE}, 61%, 33%)`);
gradient.addColorStop(0.5, `hsl(${HUE}, 64%, 6%)`);
gradient.addColorStop(1, 'transparent');
offscreenCtx.fillStyle = gradient;
offscreenCtx.beginPath();
offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
offscreenCtx.fill();
return offscreenCanvas;
};
class OffscreenCanvas {
static instance: HTMLCanvasElement = getOffscreenCanvas();
}
class Star {
orbitRadius!: number;
maxOrbitRadius!: number;
radius!: number;
orbitX!: number;
orbitY!: number;
elapsedTime!: number;
speed!: number;
alpha!: number;
ratio = 1;
offscreenCanvas = OffscreenCanvas.instance;
constructor(
private ctx: CanvasRenderingContext2D,
private canvasSize: { w: number, h: number; },
) {
this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
this.orbitRadius = random(this.maxOrbitRadius);
this.radius = random(60, this.orbitRadius) / 12;
this.orbitX = this.canvasSize.w / 2;
this.orbitY = this.canvasSize.h / 2;
this.elapsedTime = random(0, MAX_STARS);
this.speed = random(this.orbitRadius) / 500000;
this.alpha = random(2, 10) / 10;
}
update(size: { w: number, h: number; }) {
this.canvasSize = size;
this.ratio = maxOrbit(this.canvasSize.w, this.canvasSize.h) / this.maxOrbitRadius;
if (this.ratio !== 1) {
this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
this.orbitRadius = this.orbitRadius * this.ratio;
this.radius = this.radius * this.ratio;
this.orbitX = this.canvasSize.w / 2;
this.orbitY = this.canvasSize.h / 2;
}
}
draw() {
const x = (Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX);
const y = (Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY);
const spark = Math.random();
if (spark < 0.5 && this.alpha > 0) {
this.alpha -= 0.05;
} else if (spark > 0.5 && this.alpha < 1) {
this.alpha += 0.05;
}
this.ctx.globalAlpha = this.alpha;
this.ctx.drawImage(this.offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
this.elapsedTime += this.speed;
}
}
const StarField = () => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const animationRef = useRefnull>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
const [initiated, setInitiated] = useState(false);
const [stars, setStars] = useState<Star[]>([]);
// 这里会在画布准备好之后初始化星星,理论上只会执行一次
useEffect(() => {
if (canvasRef.current && canvasSize.w !== 0 && canvasSize.h !== 0 && !initiated) {
const ctx = canvasRef.current!.getContext('2d')!;
const _stars = Array.from({ length: MAX_STARS }, () => new Star(ctx, canvasSize));
setStars(_stars);
setInitiated(true);
}
}, [canvasSize.w, canvasSize.h]);
// 这里用于处理resize事件,并重新设置画布的宽高
useEffect(() => {
if (canvasRef.current) {
const resizeHandler = () => {
const { clientWidth, clientHeight } = canvasRef.current!.parentElement!;
setCanvasSize({ w: clientWidth, h: clientHeight });
};
resizeHandler();
addEventListener('resize', resizeHandler);
return () => {
removeEventListener('resize', resizeHandler);
};
}
}, []);
// 这里用于渲染动画,每次画布有变化时都会触发,星星初始化完成时也会触发一次
useEffect(() => {
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d')!;
canvasRef.current!.width = canvasSize.w;
canvasRef.current!.height = canvasSize.h;
const animation = () => {
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.8;
ctx.fillStyle = `hsla(${HUE} , 64%, 6%, 1)`;
ctx.fillRect(0, 0, canvasSize.w, canvasSize.h);
ctx.globalCompositeOperation = 'lighter';
stars.forEach((star) => {
if (star) {
star.update(canvasSize);
star.draw();
}
});
animationRef.current = requestAnimationFrame(animation);
};
animation();
return () => {
cancelAnimationFrame(animationRef.current!);
};
}
}, [canvasSize.w, canvasSize.h, stars]);
return (
<canvas ref={canvasRef}>canvas>
);
};
export default StarField;
来源:juejin.cn/post/7399983901811474484
1个demo带你入门canvas
canvas画布是前端一个比较重要的能力,在MDN上看到有关canvas的API时,是否会感到枯燥?今天老弟就给各位带来了1个还说得过去的demo,话不多说,咱们一起来尝尝咸淡。
一、小球跟随鼠标移动
先来欣赏一下这段视频:
从上图我们发现,小球跟着我们的鼠标移动,并且鼠标点到的位置就是小球的中心点。想要实现这样的功能,我们可以将它抽象为下面图里的样子:
是的,一个是画布(canvas)类,一个是小球(Ball)类。
canvas主要负责尺寸、执行绘画、事件监听。
Ball主要负责圆心坐标、半径、以及更新自己的位置。
接下来就是代码部分,我们先来完成canvas类的实现。
1.1、初始化canvas类属性
从上面的视频以及拆解的图里,我们会发现这个画布至少拥有以下几个属性:
- width。画布的宽度
- height。画布的高度
- element。画布的标签元素
- context。画布的渲染上下文
- events。这个画布上的事件集合
- setWidthAndHeight()。设置画布的大小
- draw()。用于执行绘画的函数
- addEventListener()。用于给canvas添加相应的监听函数
因此我们可以得出下面这段代码:
class Canvas {
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
}
}
1.2、设置canvas的大小
class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
}
在上面的代码里,我们手动实现了一个setWidthAndHeight
这样的函数,并且当执行new操作的时候,自动这个函数,从而达到设置几何元素大小的作用。
1.3、绘画小球
我们的这个绘画功能应该只负责绘画,也就是说这个小球的位置坐标等信息应该通过传参的形式传入到我们的draw函数里
。
class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
// 绘画小球
draw(ball){
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
}
1.4、给canvas添加事件监听
根据我们的需求,小球随着鼠标移动,说明我们应该是在canvas标签上监听mouseover事件。我们再来思考一下,其实这个事件监听函数也应该保持纯粹。纯粹的意思就是,你不要在这个方法里去写业务逻辑。这个函数只负责添加相应的事件监听。
接下来我们实现一下这个addEventListener函数。
class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
// 画物体
draw(ball){
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
// 添加监听器(eventName:要监听的事件,eventCallback:事件对应的处理函数)
addEventListener(eventName, eventCallback){
let finalEventName = eventName.toLocaleLowerCase();
if (!this.events.filter(item => item === finalEventName)?.length > 0){
this.events.push(finalEventName);
}
this.element['on' + finalEventName] = (target) => {
eventCallback && eventCallback(target);
}
}
}
好啦,Canvas类的实现到这里先告一段落,我们来看看小球(Ball)类的实现。
1.5、Ball类的初始化
Ball这个类比较简单,4个属性+一个方法。
属性分别是:
- centerX。小球的圆心横坐标X
- centerY。小球的圆心纵坐标Y
- background。小球的背景色
- radius。小球的半径
方法是updateLocation
函数。这个函数同样也是一个纯函数,只负责更新圆心坐标,更新的值也是由参数传递。
class Ball {
constructor(props){
this.centerX = props.centerX;
this.centerY = props.centerY;
this.radius = props.radius;
this.background = props.background || 'orange';
}
// 更新小球的地理位置
updateLocation(x, y){
this.centerX = x;
this.centerY = y;
}
}
1.6、添加推动器
说的直白点,就是我们现在只有2个class类,但是无法实现想要的效果,现在来想想什么时机去触发draw方法。
根据上面的视频,我们知道需要在canvas标签上添加鼠标over事件,然后在over事件里来实时获取小球的位置信息,最后再触发draw方法。
当鼠标离开canvas画布后,需要将画布上的内容清除,不留痕迹。
这样一来,我们不仅要实现类似桥梁(bridge)的功能,还需要在canvas类上实现“画布清空”的功能。
class Canvas {
// ...其他代码不动
// 清空画布的功能
clearCanvas(){
this.canvasContext.clearRect(0, 0, this.width, this.height);
}
}
// 画布对象
let canvas = new Canvas({
element: document.querySelector('canvas'),
width: 300,
height: 300
});
// 小球对象
let ball = new Ball({
centerX: 0,
centerY: 0,
radius: 30,
background: 'orange'
});
// 给canvas标签添加mouseover事件监听
canvas.addEventListener(
'mousemove',
(target) => {
canvasMouseOverEvent(target, canvas.element, ball);
}
)
canvas.addEventListener(
'mouseleave',
(target) => {
canvasMouseLeave(target, canvas);
}
)
// 鼠标滑动事件
function canvasMouseOverEvent(target, canvasElement, ball){
let pointX = target.offsetX;
let pointY = target.offsetY;
ball.updateLocation(pointX, pointY);
canvas.draw(ball);
}
// 鼠标离开事件
function canvasMouseLeave(target, canvasInstance){
canvasInstance.clearCanvas();
}
这样一来,我们便实现了大致的效果,如下:
我们似乎实现了当初的需求,只是为啥目前的表现跟“刮刮乐”差不多?因为下一次绘画的时候,没有将上次的绘画清空,所以才导致了现在的这个样子。
想要解决这个bug,只需在canvas类的draw方法里,加个清空功能就可以了。
class Canvas {
// 画物体
draw(ball){
this.clearCanvas();
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
}
如此一来,这个bug就算解决了。
二、最后
好啦,本期内容到这里就告一段落了,希望这个demo能够帮助你了解一下canvas的相关API,如果能够帮到你,属实荣幸,那么我们下期再见啦,拜拜~~
来源:juejin.cn/post/7388056383642206262
丸辣!BigDecimal又踩坑了
丸辣!BigDecimal又踩坑了
前言
小菜之前在国内的一家电商公司自研电商项目,在那个项目中是以人民币的分为最小单位使用Long来进行计算
现在小菜在跨境电商公司也接到了类似的计算需求,在小菜火速完成提交代码后,却被技术leader给叫过去臭骂了一顿
技术leader让小菜将类型改为BigDecimal,小菜苦思不得其解,于是下班后发奋图强,准备搞懂BigDecimal后再对代码进行修改
...
在 Java 中,浮点类型在进行运算时可能会产生精度丢失的问题
尤其是当它们表示非常大或非常小的数,或者需要进行高精度的金融计算时
为了解决这个问题,Java 提供了 BigDecimal
类
BigDecimal 使用各种字段来满足高精度计算,为了后续的描述,这里只需要记住两个字段
precision字段:存储数据十进制的位数,包括小数部分
scale字段:存储小数的位数
BigDecimal的使用方式再后续踩坑中进行描述,最终总结出BigDecimal的最佳实践
BigDecimal的坑
创建实例的坑
错误示例:
在BigDecimal有参构造使用浮点型,会导致精度丢失
BigDecimal d1 = new BigDecimal(6.66);
正确的使用方法应该是在有参构造中使用字符串,如果一定要有浮点数则可以使用BigDecimal.valueOf
private static void createInstance() {
//错误用法
BigDecimal d1 = new BigDecimal(6.66);
//正确用法
BigDecimal d2 = new BigDecimal("6.66");
BigDecimal d3 = BigDecimal.valueOf(6.66);
//6.660000000000000142108547152020037174224853515625
System.out.println(d1);
//6.66
System.out.println(d2);
//6.66
System.out.println(d3);
}
toString方法的坑
当数据量太大时,使用BigDecimal.valueOf
的实例,使用toString方法时会采用科学计数法,导致结果异常
BigDecimal d2 = BigDecimal.valueOf(123456789012345678901234567890.12345678901234567890);
//1.2345678901234568E+29
System.out.println(d2);
如果要打印正常结果就要使用toPlainString
,或者使用字符串进行构造
private static void toPlainString() {
BigDecimal d1 = new BigDecimal("123456789012345678901234567890.12345678901234567890");
BigDecimal d2 = BigDecimal.valueOf(123456789012345678901234567890.12345678901234567890);
//123456789012345678901234567890.12345678901234567890
System.out.println(d1);
//123456789012345678901234567890.12345678901234567890
System.out.println(d1.toPlainString());
//1.2345678901234568E+29
System.out.println(d2);
//123456789012345678901234567890.12345678901234567890
System.out.println(d2.toPlainString());
}
比较大小的坑
比较大小常用的方法有equals
和compareTo
equals
用于判断两个对象是否相等
compareTo
比较两个对象大小,结果为0相等、1大于、-1小于
BigDecimal使用equals时,如果两数小数位数scale不相同,那么就会认为它们不相同,而compareTo则不会比较小数精度
private static void compare() {
BigDecimal d1 = BigDecimal.valueOf(1);
BigDecimal d2 = BigDecimal.valueOf(1.00);
// false
System.out.println(d1.equals(d2));
// 0
System.out.println(d1.compareTo(d2));
}
在BigDecimal的equals方法中能看到,小数位数scale不相等则返回false
public boolean equals(Object x) {
if (!(x instanceof BigDecimal))
return false;
BigDecimal xDec = (BigDecimal) x;
if (x == this)
return true;
//小数精度不相等 返回 false
if (scale != xDec.scale)
return false;
long s = this.intCompact;
long xs = xDec.intCompact;
if (s != INFLATED) {
if (xs == INFLATED)
xs = compactValFor(xDec.intVal);
return xs == s;
} else if (xs != INFLATED)
return xs == compactValFor(this.intVal);
return this.inflated().equals(xDec.inflated());
}
因此,BigDecimal比较时常用compareTo,如果要比较小数精度才使用equals
运算的坑
常见的运算包括加、减、乘、除,如果不了解原理的情况就使用会存在大量的坑
在运算得到结果后,小数位数可能与原始数据发生改变,加、减运算在这种情况下类似
当原始数据为1.00(2位小数位数)和5.555(3位小数位数)相加/减时,结果的小数位数变成3位
private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);
//1.0
System.out.println(d1);
//5.555
System.out.println(d2);
//6.555
System.out.println(d1.add(d2));
//-4.555
System.out.println(d1.subtract(d2));
}
在加、减运算的源码中,会选择两数中小数位数(scale)最大的当作结果的小数位数(scale)
private static BigDecimal add(final long xs, int scale1, final long ys, int scale2) {
//用差值来判断使用哪个scale
long sdiff = (long) scale1 - scale2;
if (sdiff == 0) {
//scale相等时
return add(xs, ys, scale1);
} else if (sdiff < 0) {
int raise = checkScale(xs,-sdiff);
long scaledX = longMultiplyPowerTen(xs, raise);
if (scaledX != INFLATED) {
//scale2大时用scale2
return add(scaledX, ys, scale2);
} else {
BigInteger bigsum = bigMultiplyPowerTen(xs,raise).add(ys);
//scale2大时用scale2
return ((xs^ys)>=0) ? // same sign test
new BigDecimal(bigsum, INFLATED, scale2, 0)
: valueOf(bigsum, scale2, 0);
}
} else {
int raise = checkScale(ys,sdiff);
long scaledY = longMultiplyPowerTen(ys, raise);
if (scaledY != INFLATED) {
//scale1大用scale1
return add(xs, scaledY, scale1);
} else {
BigInteger bigsum = bigMultiplyPowerTen(ys,raise).add(xs);
//scale1大用scale1
return ((xs^ys)>=0) ?
new BigDecimal(bigsum, INFLATED, scale1, 0)
: valueOf(bigsum, scale1, 0);
}
}
}
再来看看乘法
原始数据还是1.00(2位小数位数)和5.555(3位小数位数),当进行乘法时得到结果的小数位数为5.5550(4位小数)
private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);
//1.0
System.out.println(d1);
//5.555
System.out.println(d2);
//5.5550
System.out.println(d1.multiply(d2));
}
实际上1.00会被优化成1.0(上面代码示例的结果也显示了),在进行乘法时会将scale进行相加,因此结果为1+3=4位
public BigDecimal multiply(BigDecimal multiplicand) {
//小数位数相加
int productScale = checkScale((long) scale + multiplicand.scale);
if (this.intCompact != INFLATED) {
if ((multiplicand.intCompact != INFLATED)) {
return multiply(this.intCompact, multiplicand.intCompact, productScale);
} else {
return multiply(this.intCompact, multiplicand.intVal, productScale);
}
} else {
if ((multiplicand.intCompact != INFLATED)) {
return multiply(multiplicand.intCompact, this.intVal, productScale);
} else {
return multiply(this.intVal, multiplicand.intVal, productScale);
}
}
}
而除法没有像前面所说的运算方法有规律性,因此使用除法时必须要指定保留小数位数以及舍入方式
进行除法时可以立马指定保留的小数位数和舍入方式(如代码d5)也可以除完再设置保留小数位数和舍入方式(如代码d3、d4)
private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);
BigDecimal d3 = d2.divide(d1);
BigDecimal d4 = d3.setScale(2, RoundingMode.HALF_UP);
BigDecimal d5 = d2.divide(d1, 2, RoundingMode.HALF_UP);
//5.555
System.out.println(d3);
//5.56
System.out.println(d4);
//5.56
System.out.println(d5);
}
RoundingMode枚举类提供各种各样的舍入方式,RoundingMode.HALF_UP是常用的四舍五入
除了除法必须指定小数位数和舍入方式外,建议其他运算也主动设置进行兜底,以防意外的情况出现
计算价格的坑
在电商系统中,在订单中会有购买商品的价格明细
比如用完优惠卷后总价为10.00,而买了三件商品,要计算每件商品花费的价格
这种情况下10除3是除不尽的,那我们该如何解决呢?
可以将除不尽的余数加到最后一件商品作为兜底
private static void priceCalc() {
//总价
BigDecimal total = BigDecimal.valueOf(10.00);
//商品数量
int num = 3;
BigDecimal count = BigDecimal.valueOf(num);
//每件商品价格
BigDecimal price = total.divide(count, 2, RoundingMode.HALF_UP);
//3.33
System.out.println(price);
//剩余的价格 加到最后一件商品 兜底
BigDecimal residue = total.subtract(price.multiply(count));
//最后一件价格
BigDecimal lastPrice = price.add(residue);
//3.34
System.out.println(lastPrice);
}
总结
普通的计算可以以最小金额作为计算单位并且用Long进行计算,而面对汇率、计算量大的场景可以采用BigDecimal作为计算单位
创建BigDecimal有两种常用的方式,字符串作为构造的参数以及浮点型作为静态方法valueOf��参数,后者在数据大/小的情况下toString方法会采用科学计数法,因此最好使用字符串作为构造器参数的方式
BigDecimal比较大小时,如果需要小数位数精度都相同就采用equals方法,忽略小数位数比较可以使用compareTo方法
BigDecimal进行运算时,加减运算会采用原始两个数据中精度最长的作为结果的精度,乘法运算则是将两个数据的精度相加得到结果的精度,而除法没有规律,必须指定小数位数和舍入模式,其他运算方式也建议主动设置小数位数和舍入模式进行兜底
当遇到商品平摊价格除不尽的情况时,可以将余数加到最后一件商品的价格进行兜底
最后(不要白嫖,一键三连求求拉~)
本篇文章被收入专栏 Java,感兴趣的同学可以持续关注喔
本篇文章笔记以及案例被收入 Gitee-CaiCaiJava、 Github-CaiCaiJava,除此之外还有更多Java进阶相关知识,感兴趣的同学可以starred持续关注喔~
有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~
关注菜菜,分享更多技术干货,公众号:菜菜的后端私房菜
来源:juejin.cn/post/7400096469723643956