tree组件的halfChecked值应该后端存储还是前端计算?

背景

昨天在做一个树形组件权限控制的功能,包括菜单和按钮的访问权限。
熟悉 ant design 组件库 tree 组件的都知道,如果一个父节点,他的子节点存在未选中的,那么这个父节点应该是一个半选(半选可以直观表示其子节点存在未选中)的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const treeData = [
{
title: '0-0',
key: '0-0',
children: [
{
title: '0-0-0',
key: '0-0-0',
children: [
{
title: '0-0-0-0',
key: '0-0-0-0',
},
{
title: '0-0-0-1',
key: '0-0-0-1',
},
{
title: '0-0-0-2',
key: '0-0-0-2',
},
],
},
],
},
];

如果节点 0-0-0-0 未选中,那么此时实际权限为 ['0-0-0-1', '0-0-0-2', '0-0-0', '0-0'],tree 组件为了父节点显示为半选效果,tree.checkedKeys 应该为 ['0-0-0-1', '0-0-0-2']

tree 组件 onCheck 事件可以得到 checkedKeys: ['0-0-0-1', '0-0-0-2']halfCheckedKeys: ['0-0-0', '0-0']

最简单的方案是前端传一个实际权限数组 permissionsList 和一个过滤掉半选状态的数组 filteredCheckedKeys 给后端,后端再返回给前端做回显即可。如下

1
2
const permissionsList = [...checkedKeys, ...halfCheckedKeys];
const filteredCheckedKeys = [...checkedKeys];

问题来了,后端觉得没必要保存两个字段,他只希望接收 permissionsList。后端如果不处理,只能前端计算 filteredCheckedKeys

考虑到用户量和权限树节点比较少,前端算就前端算,chatGpt 三下五除二就搞定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// Function to recursively calculate halfCheckedKeys
function getHalfCheckedKeys(treeData, checkedKeys) {
const halfChecked = [];

// Helper function to recursively traverse the tree
function traverse(node) {
if (!node.children || node.children.length === 0) {
// Leaf node: Return if it's checked or not
return checkedKeys.includes(node.key) ? 'checked' : 'unchecked';
}

let allChecked = true;
let anyChecked = false;

// Recursively check all child nodes
node.children.forEach((child) => {
const childStatus = traverse(child);

if (childStatus === 'checked') {
anyChecked = true;
} else if (childStatus === 'unchecked') {
allChecked = false;
} else if (childStatus === 'half-checked') {
anyChecked = true;
allChecked = false;
}
});

if (allChecked) {
return 'checked';
} else if (anyChecked) {
halfChecked.push(node.key);
return 'half-checked';
} else {
return 'unchecked';
}
}

// Start traversing the tree
treeData.forEach(traverse);

return halfChecked;
}

// 示例已选中的 keys
const permissionsList = ['0-0-0-1', '0-0-0-2', '0-0-0', '0-0'];

// 获取 halfCheckedKeys
const halfCheckedKeys = getHalfCheckedKeys(treeData, permissionsList);

console.log('Half Checked Keys:', halfCheckedKeys); // 输出: ['0-0-0', '0-0']

// 过滤 permissionsList,排除 halfCheckedKeys 中的键
const filteredCheckedKeys = permissionsList.filter(
(key) => !halfCheckedKeys.includes(key),
);

到此,前端多了一步计算 getHalfCheckedKeys, 后端少存了一个字段。

但我不想就此结束,可以前端写,但前端不能没理由的写,于是我问了 chatgpt 两个方案的差异,如下:

总结对比

特性 前端动态计算 halfCheckedKeys 后端存储 halfCheckedKeys
性能 对大树结构可能性能较差 性能较好,前端无需计算
实现复杂度 前端需要复杂的递归计算 后端存储和同步逻辑复杂
一致性 基于前端状态,完全一致 依赖于前后端的状态同步
灵活性 可随时重新计算,无需后端依赖 必须依赖后端返回,灵活性较低
维护成本 代码复杂且随应用增长增加维护难度 增加存储和同步逻辑,前后端需同时维护
数据管理 前端本地管理,无需后端存储 后端需存储和管理 halfCheckedKeys

适用场景

方案一(前端动态计算)适用于

  • 权限树结构较小、节点较少时。
  • 前端有足够的性能来处理权限树。
  • 需要高交互性、即时反馈的场景,减少与后端的交互次数。

方案二(后端存储)适用于

  • 权限树结构非常庞大、节点数量较多时。
  • 前端性能较低或应用规模较大,减少前端计算压力。
  • 希望减少前端逻辑复杂度,后端统一管理和处理数据的场景。

最佳实践

  • 如果权限树数据规模较大,或者操作频繁,方案二(后端存储 halfCheckedKeys)会更具优势,提升前端性能和简化实现。
  • 如果权限树较小或者需要灵活性、即时反馈,方案一(前端动态计算)可能更适合,并且减少了后端的复杂性。

后面又聊着聊着,后端也发现了还是后端存储这种方案会比较简单。如果这个权限树节点很多,getHalfCheckedKeys 的计算可能会导致前端页面卡顿。

开发环境代理 proxy

前端项目本地开发过程中,常常会遇到跨域问题,这是因为我们的本地服务域名为localhost,而后端服务一般都是在后端服务器上,所以两个不同的域名之间通信就会存在跨域,严谨点来说是受同源策略的影响。

假设后端的完整 api 路径:https://pruduct.com/api/chat/user/list,前端在localhost下想要去访问这个 api

有个重要的前提是,后端需要在响应头设置'Access-Control-Allow-Origin': '*' ,该属性表示允许任何的源都有权限访问资源,*表示通配符。
如果后端没有设置这个属性,就算前端设置了本地代理proxy也是无法成功访问的。

后端设置了Access-Control-Allow-Origin后,其实本地可以不需要 proxy 也不会产生跨域问题,因为已经允许了任何源都有权限访问。

下面这个示例直接写死 baseURL 发起请求,不会跨域:

1
2
3
4
5
const request = axios.create({
baseURL: 'https://pruduct.com',
});

request('/api/chat/user/list');

那什么场景需要使用proxy代理呢?

以下场景,proxy就派上了用场:

  • 想在本地调用/打包不同环境的数据,不想手动修改 request baseURL
  • 本地需要同时调用多个服务(测试环境/正式环境,可能还有一些第三方环境)的接口,写死 baseURL无法满足要求

来看下如何使用proxy解决这两个问题

脚本调用/打包不同服务

如果不想手动修改 request baseURL,可以通过脚本调用不同的环境变量来实现,假如环境变量为API_BASE_URL

1
2
3
const request = axios.create({
baseURL: API_BASE_URL,
});
1
2
3
4
"scripts": {
"dev": "cross-env UMI_ENV=test max dev",
"dev:online": "cross-env UMI_ENV=online max dev",
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// .umirc.online.ts 生产环境配置文件
export default {
define: {
API_BASE_URL: 'https://pruduct.com', // 正式环境
},
};

// .umirc.test.ts 测试环境配置文件
export default {
define: {
API_BASE_URL: 'https://test.com', // 测试环境
},
};

同时调用多个服务

就好比想同时请求两个服务时,/api/user这个接口在https://pruduct.com下,/test/userhttps://test.com下,本地代理就派上用场了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const request = axios.create({
baseURL: '/',
})

proxy: {
'/api': {
target: 'https://pruduct.com',
changeOrigin: true,
},
'/test': {
target: 'https://test.com',
changeOrigin: true,
}
}

proxy 怎么用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
API_BASE_URL='https://pruduct.com'

// 组件发起请求
const request = axios.create({
baseURL: '/',
})
const apiUrl = '/api/user'
request(apiUrl)

proxy: {
'/api': {
target: API_BASE_URL,
changeOrigin: true,
},
}

发起/api/user请求时,匹配到 proxy 对象的路径/api后,那么实际请求地址为target + /api/user,即 https://pruduct.com + /api/user

应该有部分人以为 proxy 匹配路径必须是后端定义的,其实不是。
假设前端想自定义路径,或者说后端没有统一的路径,那么就可以自定义匹配路径

1
2
3
4
5
6
7
8
9
10
11
12
13
API_BASE_URL='https://pruduct.com'

// 组件发起请求
request('/customApiPrefix/api/user')

proxy: {
'/customApiPrefix': {
target: API_BASE_URL,
changeOrigin: true,
// 重写匹配的字段,如果不想出现在请求路径上,可以重写为""
pathRewrite: { "^/customApiPrefix": "" }
},
}

实际请求地址还是为https://pruduct.com + /api/user

小结:
开发过程中出现跨域是因为受同源策略的约束影响,localhost不能与后端服务通信,想要通信,后端需要设置允许请求的源'Access-Control-Allow-Origin': '*'。设置'Access-Control-Allow-Origin': '*'后,如果后端服务只有一个,可以写死baseUrl无需 proxy。如果有多个服务,那么通过proxy和环境变量的配置,可以让我们在开发或打包时更方便。

K线图

技术难点

value 对应的纵坐标

已知数据里的最大值maxValue、最小值minValue、y 轴的高度yAxisHeight,那么就可以得到 value 与yAxisHeight的比例ratio。想要得到刻度对应的数值,只需要将最小值+刻度间距iratio即可。
比如最大值 1000,最小值 500,y 轴高度为 100px,那么 1px 所代表(1000 - 500) / 100,即 5。
如果 y 轴刻度间距为 30px,那么对应的数值为500 + 30*i*ratio

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 计算Y轴刻度对应的数值:根据最大最小值动态变化
* @param {number} i 刻度对应下标
* @param {number} maxValue 最大值
* @param {number} minValue 最小值
* @param {number} yAxisHeight y轴高度
* @param {number} yAxisTickSpace y轴刻度间距
* @returns number 刻度对应的数值
*/
export const yAxisTickText = (
i,
maxValue,
minValue,
yAxisHeight,
yAxisTickSpace,
) => {
// ratio表示value 与 y轴高度的比例
const ratio = (maxValue - minValue) / yAxisHeight;
const value = (minValue + yAxisTickSpace * i * ratio).toFixed(2);
return value;
};

x 轴元素动态隔点展示

x 轴元素的数量是处于变化的,但 x 轴宽度是已知的,这样我们就能算出元素之间的间距xAxisItemSpace是多少。知道了xAxisItemSpace,那我们就能知道 x 轴刻度横坐标。

1
2
3
4
5
6
7
8
9
10
/**
* 求x轴刻度横坐标
* @param {number} i 下标
* @param {number} xAxisPointX x轴原点横坐标
* @param {number} xAxisItemSpace x轴刻度间距
* @returns number x轴刻度横坐标
*/
export const xAxisTickPointX = (i, xAxisPointX, xAxisItemSpace) => {
return xAxisPointX + i * xAxisItemSpace;
};

缩放时,考虑当元素太多时,如果展示所有的元素,会出现拥挤的情况。所以我们只能展示部分元素。
隔点展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 绘制x轴刻度与文字
const xAxisItemMaxShowNumber = 4; // 展示个数
const remainder = Math.ceil(xAxisItemLength / (xAxisItemMaxShowNumber - 1));
for (let i = 0; i < xAxisItemLength; i++) {
const xAxisTickX = xAxisTickPointX(i, originalPointX, xAxisItemSpace);

// 隔点展示
if (i % remainder === 0 || i === xAxisItemLength - 1) {
renderText(
ctx,
xAxisTickX,
yAxisOriginPointY + tickWidth + 10,
myDataSource.current.map((x) => x.date)[i],
'center',
TEXT_COLOR.PRIMARY,
);
renderLine(
ctx,
xAxisTickX,
yAxisOriginPointY,
xAxisTickX,
yAxisOriginPointY + tickWidth,
COLOR.LINE,
);
}
}

三次贝塞尔曲线前后控制点

受前后元素纵坐标影响,由前后两个点和当前点的纵坐标构成一个平行四边形,即可得到当前元素的前后控制点。

还需要考虑首尾元素没有前后控制点的边界问题,所以要加入两个虚拟点。

辅助线

在 canvas 里,更新画布既是重选渲染整个画布,所以辅助线的绘制采用分层处理,创建一个新的画布覆盖上去,独立开来,不影响展示画布。

  1. 监听鼠标移动事件mousemove
  2. 清除画布
  3. 如果在 gird 区域,绘制辅助线和提示框

拖拽

  1. 监听鼠标按下事件mousedown,并创建拖动元素。可以做记忆化处理(优化手段)
  2. 监听开始拖动目标元素事件dragstart,并记录光标位置,即event.offsetX
  3. 监听拖动事件drag,拖动过程中,达到一定距离,清除画布(clearRect),然后更新要展示的数据,重新渲染即可
  4. 拖动结束时,需要隐藏拖动元素,并且如果左侧临时集合数据小于页数pageSize请求接口数据

缩放

  1. 监听滚轮事件wheel
  2. 放大event.deltaY > 0时,删除展示集合dataSource前后数据,并分别扔到存储被删除数据的临时集合里,直到最小展示条数
  3. 缩小时「尽可能多的展示数据」,分两个情况,
    1. 当已展示条数大于最大展示条数时 或 左侧临时集合条数小于最小展示条数时,请求接口并将请求数据合并到左侧临时集合中
    2. 删除临时集合的数据,并扔到展示集合中
  4. 处理完数据,清除画布,重新渲染

应用层的思考

数据如何来:
初始时,默认展示为 10 条pageSize=10,最大展示条数为 20 条maxShowSize = pageSize*2,所以需要预准备 30 条数据pageSize+maxShowSize,也就是首次加载 30 条数据。
更新时

  1. 拖拽什么时候请求新数据:拖拽结束后请求新数据

因为一次最多拖maxShowSize当左侧临时集合数据小于**maxShowSize**,请求接口数据,请求maxShowSize

  1. 缩放什么时候请求新数据:缩放结束后,当左侧临时集合数据小于**maxShowSize**

因为没有缩放结束事件,我们可以观察下缩放时的时间间隔,在wheel事件里打印Date.now(),发现滚动时间间隔在 200ms 以内,保险起见我们取个 500ms。
当滚动时间超过了 500ms,我们就判断为滚动结束:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let timer = null;

kWrapNode.addEventListener('wheel', function (e) {
// 1. 先判断是否停止 2.停止后做什么
if (timer) {
clearTimeout(timer);
}
// 模拟缩放结束事件
const wheelStop = () => {
// 滚动停止时执行的代码
console.warn('wheelStop');
if (leftDataSource.length < maxShowSize) {
// 请求数据
loadData(maxShowSize, dataSource[0].date).then((res) => {
leftDataSource = [...res, ...leftDataSource];
});
}
};
timer = setTimeout(wheelStop, 500);
});

代码规范

eslint

通过配置.eslintrc.*指定团队代码规范。在编码过程中,对于不规范的代码能够进行提示,帮助开发者更正代码。同时,提供 auto-fix 能力。
auto-fix 能够修复的问题比较有限,无法修复较大变动的规范问题,如:单行不能超过 80 个字符。

prettier

prettier 是什么?

prettier 能根据.eslintrc.*的约定,自动格式化代码。具有比 eslint auto-fix 更加强大的代码修复能力。

配置 prettier 的几种方式:

可以使用 vscode settings.json、prettier 配置文件或.editorconfig 文件。VSCode settings.json 是用来作为备用的,通常只用于非项目文件,不能用于 prettier CLI。建议你总是在项目中包含一个 prettier 配置文件,这将确保无论你以何种方式运行 prettier(通过 prettier-vscode 插件 or prettier CLI [prettier –single-quote ]),都将应用同样的格式化选项。
推荐使用 prettier 配置文件来设置格式化选项。如果你想将 prettier 的格式化选项应用于整个项目,只需在项目根目录下添加一个 prettier 配置文件即可。

配置 prettier

所有的 prettier 配置项都可以直接在 settings.json 中配置,这些配置的默认值始终是 prettier 2.0 的默认值。

一般我们需要npm install prettier进行安装,再进行使用,这里先不介绍如何使用,主要介绍下在 vs code 中的使用。

在 vs code 中使用 prettier

  • 在 vs code 应用商店安装 prettier 插件

pic.1708325653485

  • 配置插件.prettierrc
1
2
3
4
5
6
7
8
9
10
module.exports = {
printWidth: 80, //一行的字符数,如果超过会进行换行,默认为80
tabWidth: 2, //一个tab代表几个空格数,默认为2
useTabs: false, //是否使用tab进行缩进,默认为false,表示用空格进行缩减
singleQuote: false, //字符串是否使用单引号,默认为false,使用双引号
semi: true, //行位是否使用分号,默认为true
trailingComma: 'none', //是否使用尾逗号,有三个可选值"<none|es5|all>"
bracketSpacing: true, //对象大括号直接是否有空格,默认为true,效果:{ foo: bar }
parser: 'babylon', //代码的解析引擎,默认为babylon,与babel相同。
};

eslint 与 prettier

区别:

eslint 主要解决代码质量问题。代码风格问题并没有完全做完。而 Prettier 认为代码风格更重要,所以格式问题由 Prettier 处理。
但是 Prettier 的处理结果可能 eslint 不会完全满意,但绝对不丑,而且给予了部分配置项,可以通过.prettierrc文件修改。

冲突:

因为 eslint 和 prettier 一起使用会有冲突。可以通过eslint-config-prettier 这个插件解决。

eslint-config-prettier:用 prettier 的规则,覆盖掉 eslint:recommended 的部分规则。因此不会有冲突。
eslint-plugin-prettier:将 prettier 的能力集成到 eslint。按照 prettier 的规则检查代码规范性,并进行修复。

.editorconfig

开发者使用不同编辑器或系统时,将.editorconfig文件保存到项目根目录下,即可让项目下所有文件都统一编辑器代码风格。
这个文件会覆盖编辑器的默认配置。
https://www.cnblogs.com/lwming/p/15270816.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# http://editorconfig.org
root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 80
trim_trailing_whitespace = true

[*.md]
max_line_length = 0
trim_trailing_whitespace = false

vscode settings.json

settings.json 文件是 VS Code 众多“配置文件”中的一个,用来控制诸多工作项的配置。
按快捷键【Ctrl】+【,】,打开 VS Code 的设置界面,
pic.1708325666467
可以看到设置内容有“用户” “工作区”两个分支,它们其实就分别对应了两个不同位置的 settings.json 文件,前者位于与用户相关联的特定文件夹中,后者就会位于你当前打开的文件夹的.vscode 子目录下。这两个 settings.json 文件其实都只会包含你修改过的设置项,而软件的全局默认设置则记录在软件安装目录中的某个位置,用户是不必理会的。
pic.1708325675886
这些配置文件具有工作区>用户>全局默认 的优先级,也就是前面的配置覆盖后面的,这样做的好处是你可以为不同工作区、不同用户做不同配置,而不会互相干扰。而当你不小心在某个工作区做了错误的配置导致软件不能正常工作时,只需删除相应的设置项,或者 settings.json 文件,即可恢复到用户配置或默认配置。

1
2
3
4
5
6
7
8
9
// 工作区分支 .vscode/setting.json

{
"prettier.printWidth": 100,
"editor.formatOnSave": true,
// 一个制表符等于的空格数。
"editor.tabSize": 2,
"editor.fontSize": 13
}

以“prettier”开头的配置项可以放在.prettierrc 文件中

总结

对于 vscode 配置,setting.json 文件只会包含你「手动添加」过的设置项,在「设置页面中勾选」和「手动添加」设置项的效果是一致的。为了保持多项目使用同一份配置,建议将需要的配置『手动添加』到工作区分支的 setting.json 文件中,后续只需拷贝 setting.json 文件即可。

参考
https://its401.com/article/yexudengzhidao/113249805
prettier: https://juejin.cn/post/6914549928131821576

Q&A

修改了.gitignore 文件但无效?
如果在开发的过程中添加或者修改了.gitignore 文件,那么它可能不会生效,因为一些需要忽略的文件已经加入了 git 的追踪列表中,可以通过清除 git 缓存来使新的.gitignore 生效。方法如下:
:::info
第一步:cd 到项目目录
第二步:git rm -r –cached .
第三步:git add .
第四步:git commit -m ‘update .gitignore’
:::

开发环境和线上环境

  • 开发环境:表示本地开发时的环境,便于开发人员调试。代码中可能会包含测试代码,调试工具等,这些东西没必要放到线上环境中
  • 线上环境:追求快速化、最小化。包括测试、生产等非开发环境

在 node.js 中约定了一个环境变量 NODE_ENV,用于环境区分。

未定义任何环境变量时,production表示线上环境,development表示开发环境。

线上环境:线上环境不等于生产环境。在未定义其他环境时,线上环境表示生产环境;定义了其他环境(比如测试环境)时,线上环境表示除开发环境的其他环境。

一般我们还会有一个测试环境,通常情况下都是把线上接口改为测试接口,但是这样容易出错。
为了避免手动修改接口,我们可以定义一个测试环境test

umi 环境变量配置

UMI_ENV

当指定 UMI_ENV 时,会额外加载指定值的配置文件。参考: https://umijs.org/docs/guides/env-variables#umi_env

多了测试环境,这时候的production可能会包含test,所以我们再定义一个 online,用online表示生产。
:::info
注意:umi4.x 版本里,自定义变量时不能用 prod 表示生产环境,所以这里使用 online .
:::

1
2
3
4
5
"scripts": {
"dev": "umi dev",
"build:test": "cross-env UMI_ENV=test umi build",
"build:online": "cross-env UMI_ENV=online umi build"
},

打包时,除了加载.umirc.tc,还会额外加载.umirc.${UMI_ENV}.ts文件。

区分本地开发和线上

1
2
const isDev = process.env.NODE_ENV === 'development';
const notDev = process.env.NODE_ENV === 'production';

微信网页分享「自定义标题、描述和图片」

记录一下通过微信网页分享时,需要自定义标题、描述和图片的功能。参考 JSSDJ 使用步骤

步骤解读

步骤一:JS 接口安全域名,指的是后端 API 接口域名。

步骤二:使用ES MODULE加载微信 JS SDK文件。
pnpm i weixin-js-sdk

步骤三:注入权限验证配置

1
2
3
4
5
6
7
8
wx.config({
debug: true, // 开启调试模式,调用的所有 api 的返回值会在客户端 alert 出来,若要查看传入的参数,可以在 pc 端打开,参数信息会通过 log 打出,仅在 pc 端时才会打印。
appId: '', // 必填,公众号的唯一标识
timestamp: , // 必填,生成签名的时间戳
nonceStr: '', // 必填,生成签名的随机串
signature: '',// 必填,签名
jsApiList: [] // 必填,需要使用的 JS 接口列表
});

jsApiList指的是微信 JS 提供的接口,如“分享给朋友”接口updateAppMessageShareData
timestampnonceStrsignature三个签名信息,让后端参考使用签名算法 将其返回给前端即可。
后端需要做的是:

  1. 获取access_token
  2. 通过access_token获取jsapi_ticket,并为jsapi_ticket做缓存处理
  3. 返回签名信息:签名、生产签名的时间戳和随机串

步骤四:
获取签名信息是异步的,并且如果页面加载时就调用微信 JS 提供的接口,需将微信 JS 提供的接口放在wx.ready()中调用。

步骤五:
通过 wx 对象的通用参数来验证微信 JS 提供的接口是否调用成功,如success参数

1
2
3
4
5
6
7
8
9
wx.updateAppMessageShareData({
title: '分享标题',
desc: '分享描述',
imgUrl: '分享图片链接',
link: '分享页面链接',
success: () => {
alert('updateAppMessageShareData接口调用成功');
},
});

封装和使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// hooks/useWXShare.tsx
import wx from 'weixin-js-sdk';
import request from '@/api';
import urlMap from '@/api/url-map';

const { weChat } = urlMap;
const appId = 'xxxxxxxxxxxxx';

interface ShareData {
title: string;
desc?: string;
link: string;
imgUrl: string;
success?: any;
}
// 微信网页分享
// 注意,通过 ready 接口处理成功验证
const useWXShare = () => {
// 自定义“分享给朋友”及“分享到QQ”按钮的分享内容
const updateAppMessageShareData = (props: ShareData) => {
wx.updateAppMessageShareData(props);
};

// 自定义“分享到朋友圈”及“分享到QQ空间”按钮的分享内容
const updateTimelineShareData = (props: ShareData) => {
wx.updateTimelineShareData(props);
};

const getSign = () => {
request
.post(weChat.sign, {
url: location.href,
})
.then((res) => {
const { timestamp, nonceStr, signature } = res.data;
// 签名需要后端返回
wx.config({
// debug: true, // 开启调试模式,调用的所有 api 的返回值会在客户端 alert 出来,若要查看传入的参数,可以在 pc 端打开,参数信息会通过 log 打出,仅在 pc 端时才会打印。
appId, // 必填,公众号的唯一标识
timestamp, // 必填,生成签名的时间戳
nonceStr, // 必填,生成签名的随机串
signature, // 必填,签名
jsApiList: ['updateTimelineShareData', 'updateAppMessageShareData'], // 必填,需要使用的 JS 接口列表
});
});
};

return {
wx,
updateAppMessageShareData,
updateTimelineShareData,
getSign,
};
};
export default useWXShare;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { useWXShare } from '@/hooks';

export default () => {
const { wx, getSign, updateAppMessageShareData, updateTimelineShareData } = useWXShare();

useEffect(() => {
getSign();

onLoading();
request
.get(home.articlesDetail, { uri })
.then((res) => {
const { title, description } = res.data;
offLoading();

wx.ready(() => {
updateAppMessageShareData({
title,
desc: description,
imgUrl: 'https:xx.jpeg',
link: detailLink,
});
updateTimelineShareData({
title,
imgUrl: 'https:xx.jpeg',
link: detailLink,
});
});
})
.finally(() => offLoading());
}, []);

return (
<>微信网页分享<>
)
}

微信网页分享朋友圈无法显示图片?
pic.1708334234054

文件篇

文件上传和下载是很常见的功能,今天就来梳理一下操作文件过程中常见的概念和 api,加深对文件的理解。

概念

Blob 对象

当用户通过一个input元素选择文件时,浏览器会创建一个Blob对象代表该文件的二进制数据。如果要在将文件数据上传到服务器或者存储到本地之前对其进行操作,你可能需要使用 Blob对象 。

举个例子,你可以使用URL.createObjectURL方法创建一个URL代表这个Blob对象,然后用这个URL<img>video元素中展示这个文件。如:

1
2
const img = document.createElement('img');
img.src = URL.createObjectURL(file);

File 对象

当用户通过一个input元素选择文件时,浏览器会创建一个基于Blob对象的File对象,并添加文件名name、大小size、上次修改日期lastModifiedData等属性。如果要在将文件上传到服务器之前验证文件的属性,则可能需要使用File对象。

:::info
也就是说,File 对象是一种 Blob 对象,他包含了文件的附加信息,如 name、size、lastModifiedDate 等属性。开发人员无法直接访问底层的 Blob 对象,而是通过 api 与 File 对象进行交互。
:::

base 64

base64 编码将二进制数据表示为一串 ASCII 字符「我们常说的字符串」。
有些场合并不能传输或者储存二进制流,这时候就需要使用 base64 编码。

比如,一个传输协议是基于 ASCII 文本的,那么他就不能传输二进制流,想要传输该二进制流就得编码。常用的 http 协议的 url 就是纯文本的,不能直接放二进制流。
大多数现代语言的 String 类型,都不能直接存储二进制流,但可以储存 base64 编码的字符串。

举个例子,你可以使用FileReader.readAsDataURL()方法读取 File 对象的内容并将其转换为 base64 编码的 data:URL 格式的字符串,简称DataURL
这个 DataURL 表示所读取的文件内容,可以将其发送到服务器。然后服务器可以将 DataURL 解码回二进制数据并将其保存为文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* file 转 base64编码
* @param {File} file 文件流
* @param {base64Callback} callback 回调函数 - 处理响应
*/
export const fileByBase64 = (file, callback) => {
var reader = new FileReader();

// onload事件在读取操作完成时触发
reader.onload = function (e) {
// e.target.result/reader.target 该属性表示目标对象的DataURL
console.log(e.target.result);
callback && callback(e.target.result);
};

// 以 data:URL 的形式读取数据
reader.readAsDataURL(file);
};

简单的文件上传

文件上传的传统形式,是使用type='file'input表单元素

1
<input type="file" id="file-input" accept=".jpg, .jpeg, .png" multiple />

可以添加change事件监听读取文件对象列表event.target.files

1
2
3
4
const fileInput = document.getElementById('file-input');
fileInput.addEventListener('change', (e) => {
const files = e.target.files;
});

File 对象:
pic.1708334253215

文件上传前,可以通过File对象,验证文件大小、类型等信息,决定是否进行下一步,比如验证文件大小。

验证文件大小

1
2
<input type="file" id="file-input" />
<button id="upload-btn">Upload File</button>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fileInput = document.getElementById('file-input');
const uploadBtn = document.getElementById('upload-btn');

uploadBtn.addEventListener('click', () => {
const selectedFile = fileInput.files[0];
const fileSizeLimit = 5 * 1024; // 5KB in bytes

if (selectedFile.size > fileSizeLimit) {
alert(
'Selected file exceeds the size limit of 5 KB. Please select a smaller file.',
);
return;
}

// If file size is within the limit, proceed with file upload
// Your code for uploading the file to the server goes here
});
1
2
3
4
5
6
获取 files 的方式:

- 在 input 元素的 change 事件中,可通过 e.target.files 获取;
- 或者通过 input 元素直接获取,如:document.getElementById('file-input').files;

也就是说`e.target === fileEl`

显示读取进度(下载文件的场景)

FileReader.onprogress
pregress事件,在读取Blob时触发。在下载文件并显示进度这个场景下能够派上用场。

1
2
3
4
5
6
<input type="file" id="file-input" />
<div>
<label id="progress-label" for="progress">Upload File</label>
<progress id="progress" value="0" max="100" value="0">0</progress>
<button id="read-blob">读取Blob</button>
</div>

readAsDataURL能够读取Blob对象,然后监听FileReaderprogress事件,通过ProgressEvent.loadedProgressEvent.total计算读取的进度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const fileInput = document.getElementById('file-input');
const readBlobBtn = document.getElementById('read-blob');

// 显示文件读取进度
const reader = new FileReader();

reader.addEventListener('progress', (e) => {
if (e.loaded && e.total) {
// 计算完成百分比
const percent = (e.loaded / e.total) * 100;
// 将值设置为进度组件
progress.value = percent;
}
});

readBlobBtn.addEventListener('click', () => {
const selectedFile = fileInput.files[0];
// 以 URL 格式的字符串的形式读取数据
reader.readAsDataURL(selectedFile);
});

显示上传进度

要显示文件的上传进度,可以使用 JavaScript 中的 XMLHttpRequest (XHR) 对象将文件上传到服务器并使用 XMLHttpRequest.upload.onprogress 事件跟踪进度。这是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const fileInput = document.getElementById('file-input');
const uploadBtn = document.getElementById('upload-btn');
const uploadProgress = document.getElementById('upload-progress');

uploadBtn.addEventListener('click', () => {
const selectedFile = fileInput.files[0];
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');

// Track the upload progress
xhr.upload.onprogress = (event) => {
const progress = (event.loaded / event.total) * 100;
uploadProgress.value = progress;
};

xhr.onload = () => {
console.log('File uploaded successfully');
};

xhr.onerror = () => {
console.log('File upload failed');
};

const formData = new FormData();
formData.append('file', selectedFile);

xhr.send(formData);
});

上传目录

input元素的webkitdirectory属性,表示允许用户选择文件目录,而不是文件。

1
<input type="file" id="file-input" webkitdirectory />

选择目录时,该目录下的文件会全部选中(包括子孙文件)。

拖放上传

设置一个放置文件的目标元素。

1
<div id="drop-zone">Drop files here</div>

调用 event.preventDefault(),这使它能够接收 drop 事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const dropZone = document.getElementById('drop-zone');

// dragover 事件在可拖动元素被拖进放置目标时被触发
dropZone.addEventListener('dragover', (event) => {
event.preventDefault();
dropZone.classList.add('drag-over');
});

// dragleave 事件在可拖动元素离开放置目标时被触发
dropZone.addEventListener('dragleave', (event) => {
event.preventDefault();
dropZone.classList.remove('drag-over');
});

// drop 事件在可拖动元素放置在放置目标时被触发
dropZone.addEventListener('drop', (event) => {
event.preventDefault();
dropZone.classList.remove('drag-over');
// 获取文件
const files = event.dataTransfer.files;
console.log('files: ', files);

const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');

xhr.onload = () => {
console.log('File uploaded successfully');
};

xhr.onerror = () => {
console.log('File upload failed');
};

const formData = new FormData();
formData.append('file', files[0]);

xhr.send(formData);
});

Content-Type(表单的 enctype 属性)

当 method 属性值为 post 时,enctype 就是将表单的内容提交给服务器的数据编码类型。可能的取值有:

  • application/x-www-form-urlencoded:未指定属性时的默认值。
  • multipart/form-data:当表单包含 type=file input 元素时使用此值
  • text/plain:出现于 HTML5,用于调试。这个值可被 <button><input type="submit"><input type="image"> 元素上的 formenctype 属性覆盖。

application/x-www-form-urlencoded

这种数据编码类型只支持传输文本数据

1
2
3
4
POST http://www.example.com HTTP/1.1
Content-Type: application/x-www-form-urlencoded;charset=utf-8

title=test&sub%5B%5D=1&sub%5B%5D=2&sub%5B%5D=3

首先,Content-Type 被指定为 application/x-www-form-urlencoded;其次,提交的数据按照 key1=val1&key2=val2 的方式进行编码,keyval 都进行了 URL 转码。大部分服务端语言都对这种方式有很好的支持。

此类型不适合用于传输大型二进制数据或者包含非 ASCII 字符的数据。平常我们使用这个类型都是把表单数据使用 url 编码后传送给后端,二进制文件当然没办法一起编码进去了。所以 multipart/form-data 就诞生了。

multipart/form-data

为了支持文件上传,表单数据必须使用multipart/form-data内容类型进行编码。这种编码格式允许二进制数据作为请求主体的一部分发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
POST http://www.example.com HTTP/1.1
Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryrGKCBY7qhFd3TrwA

------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="text"

title
------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="file"; filename="chrome.png"
Content-Type: image/png

PNG ... content of chrome.png ...
------WebKitFormBoundaryrGKCBY7qhFd3TrwA--

Content-Type被指定为multipart/form-databoundary----WebKitFormBoundaryrGKCBY7qhFd3TrwAboundary用于分割提交的数据。
Content-Disposition包含文件的基本信息,Content-Type表示文件内容类型。
第一部分是一个名为text的表单字段,该字段的内容是字符串title。第二部分是名为file的字段,文件名为chrome.png,文件内容类型为image/png,内容为PNG ... content of chrome.png ...,然后以boundary为结尾。

FormData()

这是一个传统的最简单的 form 表单上传如下:

1
2
3
4
5
<form method="POST" enctype="multipart/form-data">
<input type="file" name="file" value="请选择文件" />

<input type="submit" />
</form>

FormData 的由来:
当使用 Ajax 上传文件时,如果不指定enctype="multipart/form-data",会导致后端在解析 Form 表单的数据格式时与 Ajax 上传的数据格式不一致的问题。为了后端能够使用相同的代码解析这两种提交方式,所以出现了FormData

FormData 接口提供了一种表示表单数据的键值对key/value的构造方式,可以轻松的将数据通过 Ajax 发送出去。

1
2
3
4
5
6
7
8
9
const formData = new FormData();

// 单文件
formData.append('files', file);

// 多文件
_files.forEach((file) => {
formData.append('files', file);
});

使用 FormData 上传文件时,无需手动设置Content-Type='multipart/form-data',FormData 会自动设置正确的 Content-Type 和 数据类型。

实际开发过程中也是如此,通常会使用 FormData 格式保存文件,关于Content-Type需要什么类型,取决于后端是怎么设计的。

下载

window.open

open()方法,用于将指定的资源加载到浏览器新的窗口或者标签页。
当我们指定一个图片链接时,浏览器会自动下载该资源,下载后自动关闭该窗口。

1
2
3
const imgUrl =
'https://nd-news-mangement.oss-cn-hangzhou.aliyuncs.com/2023/04/274a8263e05f37a5d8663193b86e1a0583.png';
window.open(imgUrl);

location.href

location.href表示将当前页面的 URL 设置为一个新的值。它是一个字符串,包含当前页面的完成 URL,包括协议、域名、路径、查询参数和片段标识符。

1
2
3
const imgUrl =
'https://nd-news-mangement.oss-cn-hangzhou.aliyuncs.com/2023/04/274a8263e05f37a5d8663193b86e1a0583.png';
location.href = imgUrl;

window.openlocation.href的区别:

  • window.open会打开一个新窗口或选项卡,而location.href会替换当前页
  • window.open 打开太多新窗口可能会对用户体验产生负面影响
  • location.href可能会刷新整个页面(但如果在输入框中输入了文本,加载新页面时文本内容不会丢失,这是因为浏览器通常将表单数据保存在浏览器的缓存中)

a 标签

a 标签通常用于用户启动的交互,而location.href通常用于响应用户事件。
a 标签的方式属性更多,如downloadtarget,也更为灵活,在实际开发过程中,我们通常会使用 a 标签封装一个下载功能的函数来使用,如下:

1
2
3
4
5
6
7
8
9
10
11
function downloadFile(url, fileName) {
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
// download仅适用于同源 URL
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}

总结

  • File 对象是基于 Blob 对象的,只不过多了一些附加信息,如 namesize
  • 想要显示文件读取进度时,我们可以通过 FileReader 的 readAsDataURL(file) 方法,然后监听FileReaderprogress事件
  • 想要显示上传到服务器的进度时,可以通过xhr.upload.onprogress事件
  • 使用 FormData 上传文件时,无需手动设置Content-Type='multipart/form-data',FormData 会自动设置正确的 Content-Type 和 数据类型

实际项目应用中,大多数的表单场景都是手动上传文件到服务器,也就是说在提交到服务器之前,我们是不需要使用后端接口的。但现实中遇到的情况往往是后端提供一个上传接口,再提供一个提交表单数据的接口,前一个接口就显得很多余,而且存在数据库内存被乱用的风险。

参考:

防止验证码轰炸_谷歌人机校验_recaptchaV3

在注册页,往往需要通过短信或者邮箱进行验证再注册。
如果短信平台接口被恶意调用,短信通道金额会被严重消耗,严重影响平台的利益。

recaptcha V3 是谷歌开发的一款可以验证人机交互是否合法的插件。它是一个 JS API,我们可以自定义一个 0-1 分数,来决定本次交互的合法性。假设我们自定义分数为0.9,当谷歌给本次交互打的分数低于0.9时,则表示本次交互不合法,从而达到拦截目的。

用法

  1. 创建秘钥。可参考 https://blog.csdn.net/weixin_59127121/article/details/127092549?spm=1001.2101.3001.6650.5

公钥Site Key 和 私钥Secret Key

1
2
Site Key=XXXXXXXXXXXXXXXXXX
Secret Key=AAAAAAAAAAAAAAAAAA
  1. 引入谷歌提供的 script,将Site Key放上去
1
<script src="https://www.recaptcha.net/recaptcha/api.js?render=XXXXXXXXXXXXXXXXXX"></script>

:::info
注意:国内使用,需要将 www.google.com 替换成 www.recaptcha.net
:::

  1. 在需要人机验证的操作上调用grecaptcha.execut,填入Site Key,生成谷歌返回的token
1
2
3
const token = await window.grecaptcha.execute('XXXXXXXXXXXXXXXXXX', {
action: 'login',
});
  1. 通过后端接口验证token是否有效,如果成功再进行提交/登录等操作
1
2
3
4
5
6
7
console.log({ token });
const formData = new FormData();
formData.append('token', token);
const { data, code, msg } = await robotVerApi.post({
body: formData,
});
// 校验成功,进行注册等其他操作

遇到的问题

在未加入人机验证之前,我们遇到以下问题

  1. 短信验证平台被恶意轰炸
  2. 注册数量暴增(注册邀请人成功后有返利的原因)

解决办法

控制短信验证平台的频繁请求。
控制了短信验证之后,注册接口也就不会频繁请求了。

一开始的解决办法:

  1. 前端发起人机校验
  2. 后端进行 token 认证,返回状态
  3. 校验成功,前端请求短信平台

以上能防止一些恶意的页面频繁操作,但是通过数据发现短信平台接口还在暴增,也就是说攻击者绕过前面两个步骤,通过脚本在轰炸短信平台。

所以我们改把前端发送短信的操作放在后端,避免脚本攻击,步骤如下:

  1. 前端发起后端自定义的短信请求接口
  2. 后端人机校验,token 认证成功
  3. 后端请求短信平台
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 获取token
const token = await window.grecaptcha.execute('XXXXXXXXXXXXXXXXXX', {
action: 'login',
});

// 将token和邮箱传给后端,后端认证token并请求短信平台
const { data, msg, code } = await backendCustomApi.post({
body: { email, token },
});

// 将短信平台的状态返回
if (code === '000') {
console.log('邮箱已发送');
}

参考:

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×