普通视图
每日一题-零数组变换 I🟡
给定一个长度为 n
的整数数组 nums
和一个二维数组 queries
,其中 queries[i] = [li, ri]
。
对于每个查询 queries[i]
:
- 在
nums
的下标范围[li, ri]
内选择一个下标 子集。 - 将选中的每个下标对应的元素值减 1。
零数组 是指所有元素都等于 0 的数组。
如果在按顺序处理所有查询后,可以将 nums
转换为 零数组 ,则返回 true
,否则返回 false
。
示例 1:
输入: nums = [1,0,1], queries = [[0,2]]
输出: true
解释:
-
对于 i = 0:
- 选择下标子集
[0, 2]
并将这些下标处的值减 1。 - 数组将变为
[0, 0, 0]
,这是一个零数组。
- 选择下标子集
示例 2:
输入: nums = [4,3,2,1], queries = [[1,3],[0,2]]
输出: false
解释:
-
对于 i = 0:
- 选择下标子集
[1, 2, 3]
并将这些下标处的值减 1。 - 数组将变为
[4, 2, 1, 0]
。
- 选择下标子集
-
对于 i = 1:
- 选择下标子集
[0, 1, 2]
并将这些下标处的值减 1。 - 数组将变为
[3, 1, 0, 0]
,这不是一个零数组。
- 选择下标子集
提示:
1 <= nums.length <= 105
0 <= nums[i] <= 105
1 <= queries.length <= 105
queries[i].length == 2
0 <= li <= ri < nums.length
【模板】差分数组(Python/Java/C++/Go)
题意可以转换成:
- 把 $[l_i,r_i]$ 中的元素都减一,最终数组中的所有元素是否都 $\le 0$?
如果所有元素都 $\le 0$,那么我们可以撤销一部分元素的减一,使其调整为 $0$,从而满足原始题意的要求。
这可以用差分数组计算,原理讲解(推荐和【图解】从一维差分到二维差分 一起看)。
本题视频讲解,欢迎点赞关注~
###py
class Solution:
def isZeroArray(self, nums: List[int], queries: List[List[int]]) -> bool:
diff = [0] * (len(nums) + 1)
for l, r in queries:
# 区间 [l,r] 中的数都加一
diff[l] += 1
diff[r + 1] -= 1
for x, sum_d in zip(nums, accumulate(diff)):
# 此时 sum_d 表示 x=nums[i] 要减掉多少
if x > sum_d: # x 无法变成 0
return False
return True
###java
class Solution {
public boolean isZeroArray(int[] nums, int[][] queries) {
int n = nums.length;
int[] diff = new int[n + 1];
for (int[] q : queries) {
// 区间 [l,r] 中的数都加一
diff[q[0]]++;
diff[q[1] + 1]--;
}
int sumD = 0;
for (int i = 0; i < n; i++) {
sumD += diff[i];
// 此时 sumD 表示 nums[i] 要减掉多少
if (nums[i] > sumD) { // nums[i] 无法变成 0
return false;
}
}
return true;
}
}
###cpp
class Solution {
public:
bool isZeroArray(vector<int>& nums, vector<vector<int>>& queries) {
int n = nums.size();
vector<int> diff(n + 1);
for (auto& q : queries) {
// 区间 [l,r] 中的数都加一
diff[q[0]]++;
diff[q[1] + 1]--;
}
int sum_d = 0;
for (int i = 0; i < n; i++) {
sum_d += diff[i];
// 此时 sum_d 表示 nums[i] 要减掉多少
if (nums[i] > sum_d) { // nums[i] 无法变成 0
return false;
}
}
return true;
}
};
###go
func isZeroArray(nums []int, queries [][]int) bool {
diff := make([]int, len(nums)+1)
for _, q := range queries {
// 区间 [l,r] 中的数都加一
diff[q[0]]++
diff[q[1]+1]--
}
sumD := 0
for i, x := range nums {
sumD += diff[i]
// 此时 sumD 表示 x=nums[i] 要减掉多少
if x > sumD { // x 无法变成 0
return false
}
}
return true
}
复杂度分析
- 时间复杂度:$\mathcal{O}(n+q)$,其中 $n$ 是 $\textit{nums}$ 的长度,$q$ 是 $\textit{queries}$ 的长度。
- 空间复杂度:$\mathcal{O}(n)$。
更多相似题目,见下面数据结构题单中的「§2.1 一维差分」。
分类题单
- 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
- 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
- 单调栈(基础/矩形面积/贡献法/最小字典序)
- 网格图(DFS/BFS/综合应用)
- 位运算(基础/性质/拆位/试填/恒等式/思维)
- 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
- 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
- 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
- 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
- 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
- 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
- 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)
欢迎关注 B站@灵茶山艾府
差分数组
差分
解法:差分
我们首先把题目的包装拆开。如果有 $x$ 个区间覆盖了某个元素,则那个元素最多可以被减去 $x$ 次。因此题目等价于:问每个元素 nums[i]
是否被至少 nums[i]
个询问区间覆盖。
这就是非常经典的差分问题。用差分维护每个元素被几个区间覆盖即可。复杂度 $\mathcal{O}(n)$。
参考代码(c++)
###cpp
class Solution {
public:
bool isZeroArray(vector<int>& nums, vector<vector<int>>& queries) {
int n = nums.size();
// 差分维护每个元素被几个区间覆盖
vector<int> d(n + 1);
for (auto &qry : queries) {
d[qry[0]]++;
d[qry[1] + 1]--;
}
// 枚举每个元素,求区间覆盖数
for (int i = 0, now = 0; i < n; i++) {
now += d[i];
if (now < nums[i]) return false;
}
return true;
}
};
vue3中的form表单层级嵌套问题
先上代码
parent.vue
<script setup>
import { ref, reactive } from "vue";
import TaskList from "./ChildForm.vue";
const formRef = ref();
const formData = reactive({
projectName: "",
manager: "",
tasks: [],
});
const rules = reactive({
projectName: [
{ required: true, message: "项目名称不能为空", trigger: "blur" },
{ min: 3, max: 50, message: "长度在3到50个字符", trigger: "blur" },
],
manager: [{ required: true, message: "负责人不能为空", trigger: "change" }],
});
const validateTasks = (rule, value, callback) => {
if (formData.tasks.length === 0) {
callback(new Error("至少需要添加一个任务"));
} else {
callback();
}
};
const submit = () => {
formRef.value.validate((valid) => {
if (valid) {
console.log("提交数据:", formData);
}
});
};
</script>
<template>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="项目名称" prop="projectName">
<el-input v-model="formData.projectName" />
</el-form-item>
<el-form-item label="负责人" prop="manager">
<el-select v-model="formData.manager">
<el-option label="张三" value="zhangsan" />
<el-option label="李四" value="lisi" />
</el-select>
</el-form-item>
<el-form-item prop="tasks" :rules="[{ validator: validateTasks }]">
<TaskList v-model="formData.tasks" />
</el-form-item>
<el-button type="primary" @click="submit">提交</el-button>
</el-form>
</template>
child.vue
<script setup>
import { ref, computed, reactive } from "vue";
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:modelValue"]);
const taskRules = reactive({
name: [{ required: true, message: "任务名称不能为空", trigger: "blur" }],
priority: [{ required: true, message: "请选择优先级", trigger: "change" }],
});
const tasks = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const addTask = () => {
tasks.value.push({ name: "", priority: "medium" });
};
const removeTask = (index) => {
tasks.value.splice(index, 1);
};
</script>
<template>
<div class="task-list">
<el-button @click="addTask">添加任务</el-button>
<el-form
v-for="(task, index) in tasks"
:key="index"
:model="task"
:rules="taskRules"
class="task-form"
>
<el-form-item prop="name">
<el-input v-model="task.name" placeholder="任务名称" />
</el-form-item>
<el-form-item prop="priority">
<el-select v-model="task.priority">
<el-option label="高" value="high" />
<el-option label="中" value="medium" />
<el-option label="低" value="low" />
</el-select>
</el-form-item>
<el-button @click="removeTask(index)">删除</el-button>
</el-form>
</div>
</template>
<style scoped>
.task-form {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
</style>
效果如下:
一、组件结构设计原理
- 数据流向设计父组件通过v-model实现数据双向绑定:
// ProjectForm.vue
const formData = reactive({
tasks: [] // 自动同步到子组件
})
// TaskList.vue
const tasks = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
2. 验证责任划分
- 父组件:验证任务列表非空
- 子组件:验证单个任务字段
二、分步实现流程
步骤1:父组件基础验证
// ProjectForm.vue
const validateTasks = (_, __, callback) => {
formData.tasks.length === 0
? callback(new Error('至少需要1个任务'))
: callback()
}
步骤2:子组件独立验证
// TaskList.vue
const taskRules = {
name: {
required: true,
trigger: 'blur',
message: '任务名称必填'
}
}
步骤3:动态prop绑定
<el-form-item
:prop="`tasks[${index}].name`"
:rules="taskRules.name">
<el-input v-model="item.name"/>
</el-form-item>
三、验证联动机制
- 提交时联合验证
// ProjectForm.vue
const submit = () => {
formRef.value.validate().then(() => {
taskListRef.value.validate().then(() => {
// 全部验证通过
})
})
}
2. 实时错误反馈
// TaskList.vue
watch(() => props.modelValue, () => {
formRef.value?.validate()
}, { deep: true })
四、异常处理方案
// 统一错误捕获
try {
await Promise.all([
formRef.value.validate(),
taskListRef.value.validate()
])
} catch (errors) {
console.error('验证失败:', errors)
}
一文讲清什么是A记录,CNAME记录,NS记录?
什么是A记录(Address Record)
简而言之,A记录是域名到ip地址的映射关系
例如,如果有一个网站example.sun.com,可能需要将其指向服务器的IP地址192.0.1.1。在DNS配置中添加一条A记录,将example.sun.com指向192.0.1.1即可实现
什么是CNAME记录(Canonical Name Record
也称为别名记录。它允许您为一个域名创建别名。
例如,如果您设置了example.sun.com作为www.dns.com的CNAME,这意味着所有访问example.sun.com的请求实际上都会被路由到www.dns.com。
为什么需要CNAME
简化了DNS记录的管理
例如,如果有好多个域名同时指向一个ip地址
www.cc.com → 1.1.1.1
www.xx.com → 1.1.1.1
www.kk.com → 1.1.1.1
那麽。当换服务器ip的时候,就需要对每个域名重新配置
而如果用CNAME的话呢
www.cc.com → www.cxk.com → 1.1.1.1
www.xx.com → www.cxk.com → 1.1.1.1
www.kk.com → www.cxk.com → 1.1.1.1
那么就只需要改动www.cxk.com映射的ip地址
CNAME的应用场景是什么
CDN是CNAME的主要应用场景
随着网站访问量越来越多,服务器顶不住了,就需要找CDN提供商购买CDN加速服务,这个时候他们要求你的域名做个CNAME指向他们给你的一个域名叫www.dd.cdn.com
www.dd.com → www.dd.cdn.com
当用户访问www.dd.com的时候,本地DNS会获得CDN提供的CNAME域名:www.dd.com,然后再次向DNS调度系统发出请求,通过DNS调度系统的智能解析,把离客户端地理位置最近的(或者相对负载低的,主要看CDN那边智能解析的策略)CDN提供商的服务器IP返回给本地DNS,然后再由本地DNS回给客户端,让用户就近取到想要的资源(如访问网站),大大降低了延迟。
本地运营商的DNS服务器怎么知道一个域名的授权(权威)服务器是哪台?这个域名应该在哪里取解析呢?
首先公司会去找运营商买域名,比如CDN公司买了cdn.com这个一级域名,那么本地运营商会做一个NS记录,即匹配到这个cdn.com后缀的域名都会到CDN服务提供商的DNS服务器做解析,即到权威服务器做解析。
什么是 NS 记录?
NS 记录,即域名服务器记录,用于指定域名应该由哪个 DNS 服务器来进行解析。简单来说,当用户在浏览器中输入一个域名时,NS 记录会告诉互联网中的 DNS 查询系统,应该向哪个 DNS 服务器询问解析结果。NS 记录中的 IP 地址即为负责解析该域名的 DNS 服务器的 IP 地址。
参考文章
- 什么是CNAME以及CDN?zhuanlan.zhihu.com/p/400556541…
- 域名解析 | A记录 ,CNAME,MX,NS 你懂了吗 zhuanlan.zhihu.com/p/91769762?…
Step - 3
Task
Using the
max
andstep
functions, paint only those pixels whose normalized x-coordinate is less than0.25
or greater than0.75
.
使用
max
和step
函数,仅绘制那些标准化坐标下 x 坐标小于0.25
或大于等于0.75
。
Requirements
The shader should avoid using branching or conditional statements in its code, and instead rely on the
step
andmax
functions to determine the color of each pixel.
着色器应避免在其代码中使用分支或条件语句,而是依靠
step
和max
函数来确定每个像素的颜色。
Theory
GLSL 中的函数max
用于返回两个输入值中的最大值。它接受两个参数,并返回两个值中较大的一个。以下是该max
函数在 GLSL 中的详细解释:
函数
float max(float x, float y);
- 如果
x
大于或等于y
,则函数返回x
。 - 如果
y
大于x
,则函数返回y
。
示例用法
float a = 5.0;
float b = 3.0;
float result = max(a, b); // will be 5.0
Answer
uniform vec2 iResolution;
void main() {
// Normalized pixel coordinates (from 0 to 1)
vec2 uv = gl_FragCoord.xy / iResolution.xy;
vec3 color = vec3(1.0, 0.3, 0.3);
float t1 = 1.0 - step(0.25, uv.x);
float t2 = step(0.75, uv.x);
gl_FragColor = vec4(color * max(t1, t2), 1.0);
}
效果
练习
最后
如果你觉得这篇文章有用,记得点赞、关注、收藏,学Shader更轻松!!
抛弃form中的rule验证,利用原生js,来实现表单的验证
先上代码
<script setup>
import { reactive, computed } from "vue";
const formData = reactive({
student: {
name: "",
gender: "",
age: null,
isValid: computed(() => {
return (
formData.student.name &&
formData.student.gender &&
formData.student.age >= 6 &&
formData.student.age <= 25
);
}),
},
parent: {
name: "",
phone: "",
isValid: computed(() => {
return (
formData.parent.name && /^1[3-9]\d{9}$/.test(formData.parent.phone)
);
}),
},
});
const errors = reactive({
student: {
name: "",
gender: "",
age: "",
},
parent: {
name: "",
phone: "",
},
});
const validateStudentName = () => {
if (!formData.student.name) {
errors.student.name = "姓名不能为空";
} else if (formData.student.name.length < 2) {
errors.student.name = "姓名至少2个字符";
} else {
errors.student.name = "";
}
};
const validateStudentAge = () => {
if (!formData.student.age) {
errors.student.age = "年龄不能为空";
} else if (formData.student.age < 6) {
errors.student.age = "年龄不能小于6岁";
} else if (formData.student.age > 25) {
errors.student.age = "年龄不能大于25岁";
} else {
errors.student.age = "";
}
};
const validateParentPhone = () => {
if (!formData.parent.phone) {
errors.parent.phone = "手机号不能为空";
} else if (!/^1[3-9]\d{9}$/.test(formData.parent.phone)) {
errors.parent.phone = "请输入有效的手机号";
} else {
errors.parent.phone = "";
}
};
const submit = () => {
validateStudentName();
validateStudentAge();
validateParentPhone();
if (formData.student.isValid && formData.parent.isValid) {
alert("表单验证通过");
} else {
alert("请检查表单填写");
}
};
</script>
<template>
<form @submit.prevent="submit">
<fieldset>
<legend>学生基本信息</legend>
<div class="form-group">
<label for="student-name">学生姓名:</label>
<input
id="student-name"
v-model="formData.student.name"
@blur="validateStudentName"
placeholder="请输入学生姓名"
/>
<span class="error">{{ errors.student.name }}</span>
</div>
<div class="form-group">
<label for="student-gender">学生性别:</label>
<select id="student-gender" v-model="formData.student.gender">
<option value="">请选择性别</option>
<option value="male">男</option>
<option value="female">女</option>
</select>
<span class="error">{{ errors.student.gender }}</span>
</div>
<div class="form-group">
<label for="student-age">学生年龄:</label>
<input
id="student-age"
type="number"
v-model.number="formData.student.age"
@blur="validateStudentAge"
placeholder="6-25岁"
/>
<span class="error">{{ errors.student.age }}</span>
</div>
</fieldset>
<fieldset>
<legend>家长联系信息</legend>
<div class="form-group">
<label for="parent-name">家长姓名:</label>
<input
id="parent-name"
v-model="formData.parent.name"
placeholder="请输入家长姓名"
/>
<span class="error">{{ errors.parent.name }}</span>
</div>
<div class="form-group">
<label for="parent-phone">联系电话:</label>
<input
id="parent-phone"
v-model="formData.parent.phone"
@blur="validateParentPhone"
placeholder="11位手机号码"
/>
<span class="error">{{ errors.parent.phone }}</span>
</div>
</fieldset>
<button type="submit">提交表单</button>
</form>
</template>
<style scoped>
form {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
fieldset {
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
}
legend {
padding: 0 10px;
font-weight: bold;
}
.form-group {
margin-bottom: 15px;
}
label {
display: inline-block;
width: 100px;
text-align: right;
margin-right: 10px;
}
input,
select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
width: 200px;
}
.error {
color: red;
font-size: 12px;
margin-left: 110px;
display: block;
}
button {
padding: 10px 20px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #369f6b;
}
</style>
本文将基于提供的学生信息表单代码,详细讲解如何使用Vue3原生方式实现表单验证功能。这个实现方案不依赖任何第三方验证库,完全使用Vue3的响应式特性和原生JavaScript实现。
表单验证的核心设计
1. 数据结构设计
表单数据使用Vue3的reactive
创建响应式对象,分为学生信息和家长信息两个部分:
const formData = reactive({
student: {
name: "",
gender: "",
age: null,
isValid: computed(() => { /* 验证逻辑 */ })
},
parent: {
name: "",
phone: "",
isValid: computed(() => { /* 验证逻辑 */ })
}
})
每个字段都有对应的isValid
计算属性,用于实时判断该部分数据是否有效。
2. 错误信息管理
单独定义errors
对象来存储验证错误信息:
const errors = reactive({
student: {
name: "",
gender: "",
age: "",
},
parent: {
name: "",
phone: "",
}
})
这种结构与表单数据保持一致的层级关系,便于管理和访问。
验证函数实现
1. 学生姓名验证
const validateStudentName = () => {
if (!formData.student.name) {
errors.student.name = "姓名不能为空";
} else if (formData.student.name.length < 2) {
errors.student.name = "姓名至少2个字符";
} else {
errors.student.name = "";
}
};
验证逻辑:
- 非空检查
- 最小长度检查
- 验证通过时清空错误信息
2. 学生年龄验证
const validateStudentAge = () => {
if (!formData.student.age) {
errors.student.age = "年龄不能为空";
} else if (formData.student.age < 6) {
errors.student.age = "年龄不能小于6岁";
} else if (formData.student.age > 25) {
errors.student.age = "年龄不能大于25岁";
} else {
errors.student.age = "";
}
};
验证逻辑:
- 非空检查
- 最小值检查
- 最大值检查
- 验证通过时清空错误信息
3. 家长手机号验证
const validateParentPhone = () => {
if (!formData.parent.phone) {
errors.parent.phone = "手机号不能为空";
} else if (!/^1[3-9]\d{9}$/.test(formData.parent.phone)) {
errors.parent.phone = "请输入有效的手机号";
} else {
errors.parent.phone = "";
}
};
验证逻辑:
- 非空检查
- 正则表达式验证手机号格式
- 验证通过时清空错误信息
表单提交处理
const submit = () => {
validateStudentName();
validateStudentAge();
validateParentPhone();
if (formData.student.isValid && formData.parent.isValid) {
alert("表单验证通过");
} else {
alert("请检查表单填写");
}
};
提交时执行所有验证函数,并检查各部分数据的isValid
状态。
模板结构
模板使用标准的HTML表单元素,结合Vue指令:
<form @submit.prevent="submit">
<fieldset>
<legend>学生基本信息</legend>
<!-- 表单字段 -->
</fieldset>
<fieldset>
<legend>家长联系信息</legend>
<!-- 表单字段 -->
</fieldset>
<button type="submit">提交表单</button>
</form>
每个表单字段都绑定到对应的数据属性和验证函数:
<input
id="student-name"
v-model="formData.student.name"
@blur="validateStudentName"
placeholder="请输入学生姓名"
/>
<span class="error">{{ errors.student.name }}</span>
样式设计
样式部分使用scoped CSS,确保只影响当前组件:
.form-group {
margin-bottom: 15px;
}
.error {
color: red;
font-size: 12px;
margin-left: 110px;
display: block;
}
/* 其他样式... */
实现优势
- 响应式验证:利用Vue3的响应式系统,数据变化自动触发界面更新
- 即时反馈:通过
@blur
事件在用户离开输入框时立即验证 - 结构化设计:数据和错误信息采用相同结构,便于维护
- 计算属性:使用
computed
实现自动验证状态计算 - 原生实现:不依赖第三方库,减少项目体积和依赖
我开源了一个基于 Tiptap 实现一个和功能丰富的协同编辑器 🚀🚀🚀
一个基于 Tiptap 和 Next.js 构建的现代化协同文档编辑器,集成了丰富的编辑能力与多人实时协作功能,支持插件扩展、主题切换与持久化存储。适合团队写作、教育笔记、在线文档平台等场景。
无论你是想学习或者想参与开发,你都可以添加我微信 yunmz777
,我拉你进交流群中进行学习交流,我们还有很多其他不同的开源项目。
近期开始准备出一个 前端工程化实战 类的课程,如果你对前端技术迷茫,那么学习前端工程化是最好的一个进阶方案,以下是相关的实战内容大纲:
如果你感兴趣想参与的,可以添加我微信进行更详细的了解。
🚀 功能特性
-
📄 富文本编辑:标题、列表、表格、代码块、数学公式、图片、拖拽等
-
👥 实时协作:使用 Yjs + @hocuspocus/provider 实现高效协同
-
🧩 插件丰富:基于 Tiptap Pro 多种增强功能(如表情、详情组件等)
-
🧰 完善工具链:支持 Prettier、ESLint、Husky、Vitest 等开发工具
📦 技术栈
前端技术栈
技术 | 说明 |
---|---|
Next.js | 构建基础框架,支持 SSR / SSG |
Tiptap | 富文本编辑器,基于 ProseMirror |
Yjs | 协同编辑核心,CRDT 数据结构 |
@hocuspocus | Yjs 的服务端与客户端 Provider |
React 19 | UI 框架,支持 Suspense 等新特性 |
Tailwind CSS | 原子化 CSS,集成动画、表单样式等 |
Socket.io | 协同通信通道 |
Prettier/ESLint | 代码风格统一 |
Vitest/Playwright | 单元测试与端到端测试支持 |
后端技术栈
分类 | 技术 / 工具 | 说明 |
---|---|---|
应用框架 | NestJS | 现代化 Node.js 框架,支持模块化、依赖注入、装饰器和类型安全等特性 |
HTTP 服务 | Fastify | 高性能 Web 服务引擎,替代 Express,默认集成于 NestJS 中 |
协同编辑服务 |
@hocuspocus/server , yjs
|
提供文档协同编辑的 WebSocket 服务与 CRDT 算法实现 |
数据库 ORM | Prisma | 类型安全的数据库访问工具,自动生成 Schema、支持迁移与种子数据 |
数据验证 |
class-validator , class-transformer
|
请求数据验证与自动转换,配合 DTO 使用 |
用户鉴权 |
@nestjs/passport , passport , JWT , GitHub
|
支持本地登录、JWT 认证与 GitHub OAuth 登录 |
缓存与状态 | ioredis |
用于缓存数据、实现限流、协同会话管理或 Pub/Sub 消息推送 |
对象存储 | minio |
私有化部署的 S3 兼容存储服务,支持图片与附件上传 |
图像处理 | sharp |
图像压缩、格式转换、缩略图等操作 |
日志系统 |
winston , winston-daily-rotate-file
|
支持多种格式、日志分级、自动归档的日志方案 |
服务监控 |
@nestjs/terminus , prom-client
|
提供 /health 健康检查和 /metrics Prometheus 指标暴露接口 |
监控平台 | Prometheus, Grafana | 采集与可视化服务运行指标(已内置 Docker 部署配置) |
接口文档 | @nestjs/swagger |
基于代码注解自动生成 Swagger UI 文档 |
安全中间件 |
@fastify/helmet , @fastify/rate-limit
|
添加 HTTP 安全头部、限制请求频率、防止暴力攻击等安全保护 |
文件上传 |
@fastify/multipart , @webundsoehne/nest-fastify-file-upload
|
支持文件流式上传,集成 Fastify 与 NestJS 的多文件上传处理 |
🚀 快速开始
1. 克隆仓库
git clone https://github.com/xun082/DocFlow.git
cd DocFlow
安装依赖
建议使用 pnpm:
pnpm install
启动本地开发环境
pnpm dev
如何部署
确保已安装以下环境:
-
Docker
-
推荐:Linux/macOS 或启用 WSL 的 Windows 环境
1️⃣ 构建镜像
docker build -t doc-flow .
2️⃣ 启动容器
docker run -p 6001:6001 doc-flow
启动完成之后访问地址:
http://localhost:6001
🔧 常用脚本
脚本命令 | 作用说明 |
---|---|
pnpm dev |
启动开发服务器 |
pnpm build |
构建生产环境代码 |
pnpm start |
启动生产环境服务(端口 6001) |
pnpm lint |
自动修复所有 ESLint 报错 |
pnpm format |
使用 Prettier 格式化代码 |
pnpm type-check |
运行 TypeScript 类型检查 |
pnpm test |
启动测试(如配置) |
🧰 开发规范
-
使用 Prettier 和 ESLint 保证代码风格统一
-
配置了 Husky + lint-staged 进行 Git 提交前检查
-
使用 Commitizen + cz-git 管理提交信息格式(支持语义化发布)
初始化 Git 提交规范:
pnpm commit
📌 未来规划(Roadmap)
项目目前已具备基础协作编辑能力,未来将持续完善并拓展更多功能,进一步提升产品的实用性与专业性:
✅ 近期目标
-
完善现有功能体验
- 优化协同冲突解决策略
- 更细粒度的权限管理(只读 / 可评论 / 可编辑)
- 增强拖拽体验与文档结构导航(大纲视图)
-
增强文档组件系统
- 重构基础组件体系:标题、表格、代码块等更智能、模块化
- 增加工具栏、快捷键提示和 Markdown 快速输入支持
-
丰富文档类型与节点支持
-
支持更多 自定义 Tiptap 节点,如:
- 引用评论块(Comment Block)
- 自定义警告框 / 提示框(Tip/Warning)
- UML/流程图嵌入(如支持 Mermaid)
- 数据展示组件(如 TableChart、Kanban)
-
🚀 中期目标
-
引入音视频实时会议能力
-
集成 AI 能力
- 智能语法纠错、改写建议
- 语义搜索与问答(支持上下文理解)
- AI 总结 / 摘要生成
-
多平台同步支持
- PWA 支持,适配移动端和桌面离线编辑
- 跨设备自动同步与版本恢复
🧠 长期方向
-
插件生态系统建设
- 引入用户可安装的第三方插件体系
- 提供插件开发文档与市场入口
-
文档协作平台化
- 支持文档团队空间、多人组织结构
- 文档看板与团队活动看板集成
-
权限与审计系统
- 支持操作日志记录、文档编辑历史审查
- 审批流、编辑建议、协同讨论区等功能
License
本项目采用 MIT 开源协议发布,但包含部分 Tiptap Pro 模板代码除外。
Tiptap Pro 模板版权归 Tiptap GmbH 所有,并根据 Tiptap Pro 授权协议进行授权。
详见:tiptap.dev/pro/license
如需使用本项目中涉及 Tiptap Pro 的部分,必须拥有有效的 Tiptap Pro 订阅授权。
📬 联系方式
有更多的问题或者想参与开源,可以添加我微信 yunmz777
,我们这还有很多开源项目:
「译文」我买不起奔驰 🚗,但是奔驰买得起 Oxlint ⚓️ 和 Rolldown ⚡️!
前言
今天刷推特看到的一篇博客,作者是奔驰技术团队!
往期精彩推荐
- 🚀🚀🚀 pnpm 实践:挑战将前后端项目合并在 monorepo 项目并保留历史提交信息
- 优雅,太优雅了,NestJS 🔥 实在是太优雅了!🚀🚀🚀
- 🚀🚀 🚀 太棒了,有了它,终于不用翻阅屎山💩代码了!
- 更多精彩文章欢迎关注我的公众号:萌萌哒草头将军
正文
在当今快速发展的 JavaScript
生态系统中,现代工具发挥着关键作用,不仅影响我们的编码方式,还影响我们花费在编码上的时间和金钱。
在 JavaScript
生态系统中工作时,我们可以利用各种工具来提升质量、一致性和效率。今天,我们将重点介绍两款出色的工具,它们在实现重大里程碑的同时,仍然是大多数用例的直接替代品。
我们将讨论 捆绑器
和 代码检查器
,因为它们可能会成为开发或生产构建的瓶颈。
在 JavaScript
开发中,捆绑器
采用单个代码模块(无论是以 ESM
还是 CommonJS
编写),并将它们组装成一个有凝聚力的、更大的输出,例如完整的应用程序或可重用的库。
对于 Web
应用而言,即使使用 HTTP/2
,此过程也能提升加载速度和运行时效率。对于库而言,提前打包可以减少使用方项目重新打包的需要,并提升执行性能。
相反,JavaScript
中的 linting
涉及检查代码以捕获潜在的错误、风格不一致以及不良的编码习惯。linter
是一种根据一组定义的规则或标准扫描代码库的工具,它有助于确保代码一致性、增强可读性,并在常见错误成为实际问题之前将其预防。
它是如何变化的?
多年来,各种构建工具层出不穷,包括 Webpack
、Turbopack
、Rspack
和 Vite
。其中,Vite
凭借其灵活性、用户友好性以及与各种项目的无缝兼容性,在 JavaScript
社区中得到了广泛的采用。
Vite
底层使用了两个打包器:esbuild
和 Rollup
。您可以在这里找到更多详细信息,因此我们在此不再赘述。Vite
优秀的开源团队在 VoidZero
的支持下,推出了一款基于 Rust
的全新打包器,旨在取代 esbuild
和 Rollup
。这款新的打包器保留了 Rollup
的 API,同时显著提升了性能并进行了许多其他改进。
在代码检查方面,ESLint
一直以来都是 JavaScript
代码中发现问题的首选工具。然而,VoidZero
也推出了一款基于 Rust
的替代方案 Oxlint
,它的性能得到了提升,运行速度比 ESLint
快 50 到 100 倍。
补充一点,需要记住的是,仅仅迁移到
Rust
并不会自动提升速度。许多此类工具也借此机会考察了其他开源项目的架构,找出了设计瓶颈,并根据我们目前所了解的现实情况,为未来做出更可持续的决策。
它对我们有何影响?
这些新工具更令人印象深刻的是,它们可以直接作为替代品。无需重构代码,也无需花时间思考如何集成所有功能。
在 Mercedes-Benz.io
,我们的前端本质上是微前端,所以我们无法仅仅测试完整构建版本并查看其改进程度。尽管如此,我仍然好奇这些工具会给我们带来多少时间和金钱上的影响,而金钱不一定是机器时间,主要是工程时间。
我从一些团队中挑选了几个代码库,一些团队拥有较多的微前端,而另一些团队拥有较少的微前端,并运行了 4 种类型的测试:
- 使用
Rolldown
进行直接替换 - 使用
SASS-Embedded
的Rolldown
进行直接替换 - 使用
SASS-Embedded
+Lightning CSS
的Rolldown
进行直接替换 - 使用
Oxlint
替代ESLint
由于我已经在测试它,所以我决定采用两个可用于生产的替代品,如上所示。
Sass-Embedded
是 Sass
包的替代方案。它支持与 Sass
相同的 JS API
,并且由同一团队维护。然而,Sass-Embedded
是一个 JavaScript
封装器,封装了原生 Dart
可执行文件。这意味着它通常速度更快,尤其是在处理大型 Sass
编译时。
LightningCSS
是一款用 Rust
编写的超快速 CSS
解析器、转换器、打包器和压缩器。它是一款能够编译、优化和转换 CSS
的工具,旨在比其他传统工具更快、更高效。
让我们来看看数字
在您测试的所有代码库中,平均结果显示:
- 仅使用
Rolldown
,构建时间减少了 23%,最高可达 33%。 - 使用
Rolldown + Sass-Embedded
,构建时间减少了 25%,最高可达 33%。 - 使用
Rolldown + Sass-Embedded + Lightning CSS
,构建时间减少了 34%,最高可达 38%。
在 linting 方面,转向 Oxlint
后,平均减少了 71%,最高减少了 97%。
这只是这些工具的开始。Rolldown
仍在开发中,但已经经过社区的大量测试,而 Oxlint
目前处于 Beta 阶段。这两款工具都计划进行更多改进。
这真是太神奇了:我们谈论的不是重构代码,也不是花时间理解和尝试适应新工具。我们谈论的是一些可以让我们的 CI 流程减少 64% 的嵌入式替换(仅指构建 + linting)。
假设大约有 100 个项目,每个项目平均每年有 600 次提交,每次提交都需要构建和 lint。这意味着每年可以节省大约 500 个小时,差不多 21 天。由于机器成本难以计算,因此不计入机器成本,我们可以想象工程成本,包括生产力、专注度,以及能否顺利进入流程。
这种节省不仅能显著提高开发效率,还能让团队有更多时间专注于功能开发和创新,从而提升整体项目的质量和响应速度。
最后
原文地址:www.mercedes-benz.io/blog/2025-0…
原推特地址:x.com/boshen_c/st…
往期精彩推荐
- 🚀🚀🚀 pnpm 实践:挑战将前后端项目合并在 monorepo 项目并保留历史提交信息
- 优雅,太优雅了,NestJS 🔥 实在是太优雅了!🚀🚀🚀
- 🚀🚀 🚀 太棒了,有了它,终于不用翻阅屎山💩代码了!
- 更多精彩文章欢迎关注我的公众号:萌萌哒草头将军
一次痛苦的内存泄露排查经历
前言
这阵子接手的一个需求就是排查项目是否存在内存泄露问题,找到并解决它
目前是发现了四处问题,一处是 el-select
组件存在泄露问题,这个有做出挣扎解决,但是最终并没有彻底解决,无果;一初是项目封装的 v-delegate
指令存在闭包问题,这个最后是组长帮忙发现的,问题很隐蔽;另一个是有个 timer 没有 clear。最后一个还是闭包,这个就是本篇要讲的,排查过程比较煎熬
前端排查内存泄漏还是非常痛苦的,尤其是面对复杂项目
组里的项目是 黑盒语音 客户端,就是大家熟知的小黑盒旗下的一款语音产品,语音项目本身就对内存占用要求比较苛刻
其实排查内存泄漏,定位到是哪些交互其实还好,痛苦的是找到了交互过后,如何定位到具体代码,这次就是一个异步 + 闭包导致的内存泄露问题,开篇前先介绍下如何利用 performance
和 memory
选项卡定位可疑的交互
如何排查(先 performance
后 memory
)
该项目是 electron + vue2
比如这次碰到的问题是,语聊房的房间设置页面存在多个 tab,我针对所有 tab 依次往下点击然后回到最初的第一个 tab 看 performance
是否存在内存上涨
这里最好记得先把所有的 tab 点击加载一遍,防止有动态加载的组件存在,或者是缓存,异步函数等等。总之最好第一次先点击加载消除某些不可控的影响
这里提一嘴,排查内存泄漏最好是打包之后,或者保证自己的项目不存在 console.log
(感觉手动剔除也不现实) ,因为你开启了 console 台后,log 会保存你的变量,这些变量会留在 windows
中导致内存泄露,打包之后一般 tree-shaking
会帮我们将项目的 log 给自动删除
好,现在我们来利用 performance
进行排查
从 第一个 tab 开始切换下面的所有 tab,然后回到第一个 tab
在录制之前保证点击了所有的 tab 后,开始录制时记得勾选 memory
,这个就是我们要看的 内存 信息,交互前先点击 🧹
图标,最后回到第一个 tab 后结束录制前也记得 点击 🧹
图标
最后我们来注意整个交互区间的 Nodes
上下浮动范围,从图中可以看出,节点从最初的 7000
个增长到了 8000
个左右,明显有内存泄露问题
这也就意味着这么多 tab ,存在一个或者多个导致了内存泄漏,正常来讲我回到页面最初起点,页面的 dom 数量也只会是最初的,增长了也就意味着 dom 可能被某些数据引用了,成了游离 dom,前端内存泄漏最大的问题其实就是游离 dom
接下来的分析方向就比较清晰了,我们需要排查究竟是哪个 tab 导致了内存泄露,后面的定位会比较繁琐,因为你要挨个排查,挨个排查你就得控制好变量,比如我怀疑第一个 tab,那么我就需要将其余 tab 的组件代码的 代码(template + script)
清空,然后针对第一个 tab 来回切换
中间的步骤这里不会展示,这里直接说结论了,就是第一个 tab 有问题,为了再次证明是这个 tab 的问题,我们接下来可以利用 memory 选项卡进行内存快照分析
为了有一个 tab 可以辅助切换,但是又不能有这个 tab 的影响,我就需要将其 template + Script
部分代码置空,比如这里我将 第二个 tab 代码置空
我们现在进行 memory
分析
可以看到第一个tab在与第二个空tab来回频繁切换 10 次后,js heap 上涨了将近 20M,这还是非常恐怖的数据
这个时候我们就可以去定位到是第一个 tab 这个组件的问题所在了,当然我们其实还是可以去继续留意 memory
的变化
我们会发现有两个 很奇怪的 constructor
增长了,一个 t
一个 a
,我将这个项目放到 web 上去观察反而没有这两个变量,我们随机展开一个 t
看内部结构
会发现这应该是 vnode
,这应该是因为 electron 跑的是代码压缩后的结果,还有个 a
应该是 VueComponent
其实内存快照的 comparison
这里也只有 t
好去分析,也就是 vnode
,因为一个 vnode
会对应一个 dom
节点,我们可以看看究竟是哪里多出的 游离节点
其实当我们定位到某个组件的时候,我们还需要进一步分析,因为有些组件可能是由多个封装组件进一步封装的,或者会有多个同级 div,这个时候我们又需要去做一个控制变量分析,依次保留当前组件的某部分 template
然后去拍 内存快照,有时候可能是 js 问题,那又要控制 js 代码。
因为第一个 tab 组件比较复杂,所以这一步废了挺多时间去排查某个具体部分
其实定位到组件的时候,我们可以先目测观察下当前组件是否存在一些没有 off
掉的事件或者没有 clear
的定时器,然而事实却是 on
的事件都有对应的 off
,定时器也都有 clear
,这就加大了排查难度
最后是定位到了一个 异步 methods
,这个 methods
大致如下
其实这个 initData
中间还有很多逻辑,这里只展示了重点。乍这么一看好像也没啥问题,最后我定位的过程中,发现就是 judge
有问题,当我在 judge
中直接 return true
时没问题,只要一引用了 vuex
的值就会有泄露
这里的 channel_list
就是一个 vuex
的 store
值,我若是切换切得很快,这个 异步函数 在组件卸载时可能还没有执行完毕,后面的 initChannels
就会排队执行,这个 initChannels
里面的 judge
又是个 闭包函数,并且通过 vuex
引用了 this
,vuex
本身就是全局唯一的状态管理库,这个值若牵扯到了 this
,也就是 vue
实例,就会引起内存泄漏问题
所以怎么解决这个问题呢,我们可以在 await
后添加一个 逻辑,若组件卸载了就直接 return
,不让继续执行后面的逻辑
这个泄露 bug
排查最后还是组长点醒我的
其实后面排查的过程中,因为用了组件库 element-ui,其中还有个 tab 用到了 el-select
组件,这个组件也存在内存泄露问题,好像 element-ui 但凡涉及到 popover 的组件都存在内存泄露问题,大家使用这个库的时候还是谨慎点
最后
当我们使用 performance
或者 memory
选项卡定位到了某个组件存在内存泄漏问题时,首先应该去判断组件是否存在某些事件没有清除,或者定时器没有 clear
,这个是最重要的,若肉眼难以看出来,那就进一步去怀疑是否存在闭包导致的内存泄漏,然后去通过注释代码的方式去验证想法,过程还是非常麻烦的
再也不怕接口格式变来变去!用自定义请求钩子封死后端奇葩接口
在日常中后台开发中,我们经常需要实现各种数据列表页,包括查询条件表单、数据表格、分页等功能。然而,不同业务的列表页虽然样式和交互相似,但背后的后端接口格式可能千差万别。一旦后端接口格式发生变化,前端列表页的代码就可能需要大幅修改,给维护带来不少麻烦。本文将介绍一套通用的可复用列表页组件方案,通过灵活的配置和技巧来应对各种“奇葩”后端接口。
为什么需要通用的列表页组件?
在中后台项目中,列表页通常由查询条件表单和数据列表展示组成,是重复率很高的功能模块。如果每个列表页都各自实现,会产生大量重复代码,也不利于统一维护。构建一个通用的列表页组件可以带来诸多好处:
- 减少重复代码:将列表页的通用逻辑(如分页处理、表格渲染、查询表单)封装起来,在不同页面复用,避免每次从零编写。
- 统一交互与样式:统一列表页的查询交互(如“展开/收起”高级查询)、表格样式和空数据提示等,提升系统的一致性。
- 应对需求变更:当需要对列表页功能做修改(比如新增导出按钮、调整分页逻辑)时,只需在组件内部修改一次,所有使用该组件的页面都会同步更新。
- 屏蔽后端差异:通过配置来适配不同后端接口的请求和响应格式,列表组件内部消化这些差异,页面使用方无需感知接口的特殊性。
综上,封装通用列表页组件既是工程复用的需要,也是提高开发效率和代码健壮性的有效手段。
requestConfig.buildPayload:适配不同后端接口格式
后端的列表接口往往有不同的请求入参规范。例如,有的接口期望请求体直接提供查询条件,有的则要求将查询条件嵌套在 model
或 pageBean.model
下,还有的分页参数字段名各不相同。如果我们在每个页面手动拼装不同格式的请求,无疑增加了重复劳动和出错概率。
解决方案:在通用列表页组件中引入 requestConfig.buildPayload()
钩子,用于根据统一的查询参数对象构建不同格式的请求载荷。组件对外暴露 requestConfig
配置,使用方可以传入自定义的 buildPayload
函数来自定义请求格式:
// 使用通用列表组件时传入配置
<CommonList
:requestConfig="{
url: '/api/getListData',
method: 'POST',
// 自定义请求载荷构建逻辑
buildPayload: (queryParams, pagination) => {
// 例:将查询参数包裹在 pageBean.model 中,并添加分页信息
return {
pageBean: {
page: pagination.currentPage,
size: pagination.pageSize,
model: queryParams
}
}
}
}"
... />
在组件内部,每当需要发起列表请求时,会调用 requestConfig.buildPayload(formData, pagination)
来获取最终的请求体。例如:
-
直接使用
model
包装:某些接口希望所有查询条件都放在model
字段下,那么buildPayload
可以返回{ pageNum, pageSize, model: { ...查询条件 } }
。 -
嵌套在
pageBean.model
:对于要求分页信息和查询条件一起封装的接口,则返回{ pageBean: { page, size, model: { ...查询条件 } } }
。 -
无特殊包装:如果后端直接接受平铺的查询参数,那么
buildPayload
直接返回{ page, size, ...查询条件 }
即可。
通过这种钩子机制,我们实现了请求格式的适配层。无论后端接口多么“奇葩”,我们都能在不改动组件核心代码的前提下,通过定制 buildPayload
轻松应对。这极大提升了组件的适应性,也让接口变更对前端的影响降到最低。
优雅实现查询表单的展开/收起
高级查询条件往往很多,我们通常提供“展开/收起”按钮来在界面上隐藏部分条件。当用户点击“展开”时显示所有字段,“收起”则只显示基础字段。如何实现这个功能,同时保证表单字段的状态不丢失,是我们要解决的关键问题。
常规做法的问题
一些常见但不理想的实现方式包括:
- 替换表单规则:通过切换不同的表单字段列表(form rule)来控制显示哪些字段。例如收起时使用一套精简字段数组,展开时替换成完整字段数组。然而这样做会导致组件的销毁和重建,已填写的数据会丢失。尤其是在使用表单生成器如 form-create 时,动态增删字段会重置部分已选值。频繁切换规则也增加了实现复杂度。
-
v-if 条件渲染:对每个可收起字段加上
v-if="showAll"
来决定渲染与否。这种方式同样会在收起时移除 DOM 元素,字段状态可能会丢,而且需要在展开时重新挂载组件。类似地,表单验证状态也会被重置。
用隐藏属性控制显示
推荐做法是利用隐藏属性来控制字段显隐,而非移除节点或替换整个规则。在 form-create 中,我们有两种隐藏方式:
-
隐藏字段(无 DOM) :通过调用
fApi.hidden(true, fieldName)
可以隐藏指定字段,隐藏后完全不渲染对应的组件,DOM 节点将移除。这样做适合初始就不需要渲染大量高级字段的场景,减少 DOM 开销。但要注意,字段隐藏后表单验证也不会触发。 -
隐藏组件(保留 DOM) :调用
fApi.display(false, fieldName)
则会将组件通过 CSS 隐藏(display:none
),但组件实例仍然保留在 DOM 中。优点是字段的绑定值和验证状态都不会丢失,再次显示时能保持原有状态。
在实际实现中,我们可以结合两种方式。例如初始进入页面时将高级字段使用 hidden
隐藏以减轻渲染负担;当用户点击“展开”按钮时,再将这些字段用 display
显示出来。收起时,则仅隐藏(display:none)而不销毁组件,以便保留用户可能已输入的内容。
具体代码逻辑示例:
data() {
return {
showAll: false, // 控制展开/收起的状态
advancedFields: ['age', 'address', 'company'] // 需要隐藏的高级查询字段name列表
}
},
methods: {
toggleFields() {
this.showAll = !this.showAll;
if (this.showAll) {
// 展开:显示所有字段
this.fApi.display(true, this.advancedFields);
} else {
// 收起:隐藏高级字段(保留其值和状态)
this.fApi.display(false, this.advancedFields);
}
}
}
通过这种方式,“展开/收起”查询表单非常流畅:组件状态不重建不重置,用户在高级字段中已输入的值在收起后虽然不可见,但再次展开时还能看到,避免了反复输入。同时,隐藏的字段也不会影响布局,表单其余部分不会因为移除节点而闪烁。
隐藏字段的查询与重置处理
实现字段隐藏后,还需要处理两个细节问题:查询时隐藏字段不参与、重置时隐藏字段也要处理。否则可能出现隐藏字段的值误参与查询,或重置操作无法清空隐藏字段等情况。
跳过隐藏字段参与查询
当用户收起高级查询后,再点击查询按钮时,我们不应将隐藏字段的值提交给后端,否则会造成意外的筛选。即使之前用户在高级字段填过值,收起状态下也应视为暂不使用。为此,可以在构造请求参数时过滤掉所有当前隐藏的字段:
const formData = this.fApi.formData(); // 获取表单所有字段的当前值
for (const field of this.advancedFields) {
if (!this.showAll) {
// 收起状态下,直接移除高级字段的参数
delete formData[field];
}
}
const payload = this.requestConfig.buildPayload(formData, this.pagination);
如上,我们利用 fApi.formData()
获取所有字段的值,然后根据 showAll
状态剔除 advancedFields
列表中的字段。这样生成的查询参数就只包含可见的查询条件,保证后台只按用户期望的条件筛选数据。
当然,更严谨的做法是利用 form-create 提供的 hiddenStatus
接口动态判断字段是否隐藏:
Object.keys(formData).forEach(field => {
if (this.fApi.hiddenStatus(field)) {
delete formData[field]; // 隐藏状态则剔除
}
});
这在多处使用隐藏字段的场景下更加通用。
确保重置清空所有字段
点击“重置”按钮或执行表单清空时,我们期望所有查询条件都被清空,包括那些当前隐藏的高级字段。然而,如果直接使用 Element UI 提供的 this.$refs.form.resetFields()
或 form-create 的 fApi.resetFields()
,需要注意默认行为是否覆盖隐藏字段。
在 form-create 中,fApi.resetFields()
会重置表单的所有字段值(也可以选择特定字段)。但是这里的“重置”往往是指恢复初始值:如果某些字段设置了初始值,reset 后会回到初始值而非空。因此,为实现“彻底清空”,我们可能需要做额外处理:
-
未设置初始值的字段:reset 后本来就是空的,可直接使用
resetFields()
清空。 -
有默认初始值的字段:reset 会回到默认值,而我们希望清空为“无”。对于这类字段,可以在重置后调用
setValue
将其设为空字符串或空数组等。 -
隐藏字段:确保在重置时也包含隐藏字段。一种简单方式是直接调用
fApi.resetFields()
不传参,让它重置所有字段。如果我们之前对隐藏字段做了剔除查询等操作,resetFields 仍会把它们复位到初始值。若想完全清除其值,可以在 resetFields 之后再主动将高级字段对应的值设置为''
或undefined
。
综合考虑,最佳实践是封装一个清空方法,既使用 resetFields 恢复默认,又针对有默认值或特殊需求的字段做定制处理。例如:
resetAllFields() {
// 重置所有字段到初始值
this.fApi.resetFields();
// 清空隐藏字段的值(覆盖默认初始值的情况)
this.advancedFields.forEach(field => {
this.fApi.setValue(field, ''); // 将值设为空(或相应类型的初始空值)
});
}
这样,无论字段当前是否显示,我们都能确保查询条件彻底被清空,不会遗留上次的状态。
internalRule:避免直接改动组件属性
在使用 form-create 构建动态表单时,我们经常需要更新字段的属性或状态,例如切换字段的 disabled
、修改占位提示文字等。如果不借助正确的方法,直接修改 form-create 内部生成的组件属性,可能导致不可预期的结果,甚至丢失字段状态。我们通过一个内部规则(internalRule)机制来避免这些问题。
避免直接修改表单项属性
直接操作表单项的 props 可能遇到以下坑:
-
修改不生效:form-create 对传入的规则进行了封装,直接更改
rule.props.x
有时不会触发视图更新,因为内部可能没有观测这些深层变化。 - 状态丢失或重置:粗暴地替换整个规则对象会导致对应字段被重新创建,之前填写的值或校验状态丢失。这和前文提到的增删规则类似,会清空已有输入。
一个典型案例是,动态增删字段或修改其属性后,需要保留用户已填写的数据。form-create 官方建议使用其提供的 API 方法来操作,例如使用 fApi.setValue()
给字段赋值,以及避免直接操作规则数组。正如前述,当我们调用 this.rule.push()
新增规则时,其他字段可能重置;而使用 fApi.append()
或 prepend()
等方法就能避免这种情况。
internalRule 思路
所谓 internalRule,指的是在组件内部维护一份表单规则的源数据副本或额外的配置,用于记录和控制字段属性变化。要点如下:
-
初始规则克隆:在创建表单时,将传入的
formRule
深拷贝一份保存在组件内部(例如this.internalRule = deepClone(props.formRule)
)。后续所有对表单结构的调整,都基于 internalRule 来进行。 -
统一通过 API 更新:当需要修改字段属性(比如隐藏、禁用某字段)时,不直接操作
props.formRule
,而是通过fApi
提供的方法或更新 internalRule 来实现。例如,要禁用名为 "status" 的字段,我们优先选择this.fApi.disabled(true, 'status')
,这会由 form-create 内部去处理 DOM 和状态同步,而不是手动改rule.props.disabled
。 - 确保状态不丢失:由于我们保留了 internalRule,哪怕外部传入的规则在父组件因条件变化而重新计算,我们仍可以根据 internalRule 判断哪些字段之前的状态需要恢复。比如在展开高级查询时,我们知道哪些字段应该处于什么状态,而不依赖于外部重新给我们的规则(因为外部可能不知道用户中途对字段的改动)。
举个例子,假设我们的组件接受一个 formRule
列表作为 Prop。我们在内部保存 internalRule
,并使用它生成 form-create 表单。当父组件可能出于某些原因重新传入一个新 formRule
时,我们可以智能对比 newProps 和 internalRule,仅对差异部分更新,而用户在界面上交互产生的状态(选中的值、隐藏显示状态等)在 internalRule 中有记录,不会无故被覆盖。
通过 internalRule,我们相当于构建了一个单一数据源来管理表单结构和状态的变化,避免了外部频繁调整导致的冲突。这种模式下,组件内部对 form-create 有完全的掌控力,确保了字段状态的稳定与延续。
彻底清空 vs. resetFields:清理查询条件的最佳实践
在实际项目中,用户有时希望“一键清空”所有查询条件,恢复到一个完全空白的初始状态(可能与默认初始值不同)。结合我们前面的讨论,总结出几条最佳实践建议:
-
使用组件方法优于手动操作 DOM:无论是隐藏字段还是清空表单,都应优先使用 form-create 提供的 API(如
hidden/display
、resetFields
、setValue
等)来操作。避免通过操作 DOM 或组件实例属性的方式清理数据,这样更稳健也更易维护。 - 区分重置和清空:resetFields倾向于恢复初始值,而“清空”通常指把用户填写的数据全部清除。根据需求选择合适的方法,必要时组合多种手段。例如先 reset 恢复默认,再二次清理默认值字段。
- 隐藏字段特殊处理:对于暂时隐藏的字段,查询时过滤、清空时仍要重置。这保证了隐藏即忽略,但一旦显现又是干净的新状态,不会把收起时残留的数据误用到下一次查询。
- 保存用户输入体验:在展开/收起切换中,不轻易销毁用户已经输入的内容,而通过隐藏来软控制。这给了用户更好的体验(展开后还能找回之前输入的条件),同时对开发来说也减少了状态管理的复杂度。
最后,经过这套方案改造后的列表页组件,无论面对怎样的后端接口格式变更或需求调整,都能从容应对——后端奇葩接口再也无法威胁我们的前端代码稳定。
总结:构建通用列表页组件时,既要考虑适配各种接口格式(通过 buildPayload
等钩子灵活封装),又要注重前端交互细节(如查询表单的展开收起实现)。利用 form-create 等工具的特性,我们可以优雅地隐藏和显示表单项,避免直接改 DOM 或规则造成的数据丢失。同时,要善用其 API 进行状态管理和表单重置,确保每次查询和重置都符合预期。按照以上最佳实践,就能封死各种后端奇葩接口对前端的影响,稳健地提升列表页的可维护性和用户体验。
Three.js 完全学习指南(二)场景、相机、渲染器基础
场景、相机、渲染器基础
场景(Scene)详解
场景是 Three.js 中所有 3D 对象的容器,它定义了整个 3D 空间。让我们深入了解场景的配置和使用。
图 2.1: 包含多个几何体的场景示例
1. 场景基础配置
import * as THREE from 'three';
// 创建场景
const scene = new THREE.Scene();
// 设置背景色
scene.background = new THREE.Color(0x000000); // 黑色背景
// 添加雾效果
scene.fog = new THREE.Fog(0x000000, 10, 100); // 颜色、近平面、远平面
// 设置场景环境
scene.environment = new THREE.CubeTextureLoader().load([
'px.jpg', 'nx.jpg',
'py.jpg', 'ny.jpg',
'pz.jpg', 'nz.jpg'
]);
图 2.2: 使用环境贴图的场景效果
2. 场景管理
// 添加对象到场景
scene.add(mesh);
// 从场景中移除对象
scene.remove(mesh);
// 获取场景中的所有对象
const objects = scene.children;
// 遍历场景中的对象
scene.traverse((object) => {
if (object.isMesh) {
// 处理网格对象
}
});
graph TD
A[场景] --> B[几何体]
A --> C[光源]
A --> D[相机]
A --> E[辅助对象]
B --> F[网格]
B --> G[线条]
C --> H[环境光]
C --> I[平行光]
E --> J[网格辅助]
E --> K[坐标轴]
图 2.3: 场景对象层级结构
3. 场景优化
// 设置场景自动更新
scene.autoUpdate = true;
// 手动更新场景
scene.updateMatrixWorld(true);
// 清理场景
function disposeScene() {
scene.traverse((object) => {
if (object.geometry) {
object.geometry.dispose();
}
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(material => material.dispose());
} else {
object.material.dispose();
}
}
});
}
相机(Camera)详解
Three.js 提供了多种相机类型,每种类型都有其特定的用途。
1. 透视相机(PerspectiveCamera)
透视相机模拟人眼视角,是最常用的相机类型。
图 2.4: 透视相机的渲染效果
// 创建透视相机
const camera = new THREE.PerspectiveCamera(
75, // 视角(FOV)
window.innerWidth / window.innerHeight, // 宽高比
0.1, // 近平面
1000 // 远平面
);
// 设置相机位置
camera.position.set(0, 5, 10);
// 设置相机朝向
camera.lookAt(0, 0, 0);
// 更新相机参数
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
2. 正交相机(OrthographicCamera)
正交相机没有透视效果,适合用于 2D 场景或等距视图。
图 2.5: 正交相机的渲染效果
// 创建正交相机
const camera = new THREE.OrthographicCamera(
-10, // 左
10, // 右
10, // 上
-10, // 下
0.1, // 近平面
1000 // 远平面
);
// 设置相机位置
camera.position.set(0, 0, 10);
camera.lookAt(0, 0, 0);
3. 相机控制
使用 OrbitControls 实现相机控制:
图 2.6: 使用 OrbitControls 的相机控制效果
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
// 创建控制器
const controls = new OrbitControls(camera, renderer.domElement);
// 配置控制器
controls.enableDamping = true; // 启用阻尼效果
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 10;
controls.maxDistance = 500;
controls.maxPolarAngle = Math.PI / 2;
// 在动画循环中更新控制器
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
渲染器(Renderer)详解
渲染器负责将场景和相机的内容绘制到屏幕上。
1. 基础配置
// 创建渲染器
const renderer = new THREE.WebGLRenderer({
antialias: true, // 抗锯齿
alpha: true, // 透明背景
precision: 'highp' // 精度
});
// 设置渲染器尺寸
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置像素比
renderer.setPixelRatio(window.devicePixelRatio);
// 设置输出编码
renderer.outputEncoding = THREE.sRGBEncoding;
// 启用阴影
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
图 2.7: 不同渲染效果的对比
2. 高级配置
// 设置渲染器参数
renderer.setClearColor(0x000000, 1); // 设置清除颜色
renderer.setClearAlpha(1); // 设置清除透明度
// 配置阴影
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// 配置后期处理
const composer = new THREE.EffectComposer(renderer);
const renderPass = new THREE.RenderPass(scene, camera);
composer.addPass(renderPass);
图 2.8: 使用后期处理的效果
3. 性能优化
// 设置渲染器参数
renderer.powerPreference = 'high-performance';
renderer.precision = 'highp';
// 自动清理
function disposeRenderer() {
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
}
// 处理窗口大小变化
window.addEventListener('resize', () => {
const width = window.innerWidth;
const height = window.innerHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});
实战:创建一个完整的 3D 场景
让我们结合以上知识,创建一个完整的 3D 场景:
图 2.9: 完整的 3D 场景示例
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
// 创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
// 创建相机
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(5, 5, 5);
camera.lookAt(0, 0, 0);
// 创建渲染器
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
document.getElementById('app').appendChild(renderer.domElement);
// 创建控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// 添加网格
const gridHelper = new THREE.GridHelper(10, 10);
scene.add(gridHelper);
// 添加环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
// 添加平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(5, 5, 5);
directionalLight.castShadow = true;
scene.add(directionalLight);
// 创建一个立方体
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({
color: 0x00ff00,
metalness: 0.3,
roughness: 0.4
});
const cube = new THREE.Mesh(geometry, material);
cube.castShadow = true;
cube.receiveShadow = true;
scene.add(cube);
// 创建地面
const planeGeometry = new THREE.PlaneGeometry(10, 10);
const planeMaterial = new THREE.MeshStandardMaterial({
color: 0x808080,
side: THREE.DoubleSide
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.rotation.x = -Math.PI / 2;
plane.position.y = -0.5;
plane.receiveShadow = true;
scene.add(plane);
// 动画循环
function animate() {
requestAnimationFrame(animate);
// 更新控制器
controls.update();
// 旋转立方体
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
// 渲染场景
renderer.render(scene, camera);
}
// 开始动画
animate();
性能优化建议
-
场景优化
- 使用适当的几何体复杂度
- 及时清理不需要的对象
- 使用对象池管理频繁创建的对象
-
相机优化
- 设置合适的视锥体范围
- 使用适当的相机类型
- 优化控制器参数
-
渲染器优化
- 使用适当的像素比
- 启用必要的渲染特性
- 及时释放资源
练习
- 实现相机的自动旋转
- 添加多个光源并观察效果
- 实现场景的昼夜变化
- 添加后期处理效果
下一步学习
在下一章中,我们将学习:
- 几何体的创建和使用
- 材质系统的详细配置
- 纹理的应用
- 对象的变换和组合
JavaScript作用域和作用域链
在JavaScript中,作用域和作用域链是理解代码执行和变量访问的关键概念。它们决定了变量和函数在代码中的可见性和生命周期。
一、作用域(Scope)
(一)什么是作用域?
作用域是在运行时代码中的某些特定部分中变量、函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。
作用域的主要作用是隔离变量,防止不同作用域下的同名变量发生冲突。例如:
function outFun2() {
var inVariable = "内层变量2";
}
outFun2();
console.log(inVariable); // Uncaught ReferenceError: inVariable is not defined
在上面的例子中,变量inVariable
在全局作用域中没有声明,因此在全局作用域下访问它会报错。
(二)全局作用域和函数作用域
1. 全局作用域
全局作用域是指在代码中任何地方都能访问到的对象。以下几种情形拥有全局作用域:
- 最外层函数和在最外层函数外面定义的变量拥有全局作用域。
- 所有未定义直接赋值的变量自动声明为拥有全局作用域。
- 所有
window
对象的属性拥有全局作用域。
var outVariable = "我是最外层变量"; // 最外层变量
function outFun() { // 最外层函数
var inVariable = "内层变量";
function innerFun() { // 内层函数
console.log(inVariable);
}
innerFun();
}
console.log(outVariable); // 我是最外层变量
outFun(); // 内层变量
console.log(inVariable); // inVariable is not defined
全局作用域的弊端是容易污染全局命名空间,引起命名冲突。因此,通常建议将代码封装在函数中,避免全局变量的滥用。
2. 函数作用域
函数作用域是指声明在函数内部的变量,这些变量只能在函数内部访问。例如:
function doSomething() {
var stuName = "zhangsan";
function innerSay() {
console.log(stuName);
}
innerSay();
}
console.log(stuName); // 脚本错误
innerSay(); // 脚本错误
函数作用域的一个重要特点是内层作用域可以访问外层作用域的变量,但外层作用域不能访问内层作用域的变量。
(三)块级作用域
ES6引入了块级作用域,通过let
和const
关键字声明的变量具有块级作用域。块级作用域在以下情况被创建:
- 在一个函数内部。
- 在一个代码块(由一对花括号包裹)内部。
块级作用域的特点包括:
- 声明变量不会提升到代码块顶部。
- 禁止重复声明。
- 循环中的绑定块作用域的妙用。
for (let i = 0; i < 10; i++) {
console.log(i); // i 在循环内部有效
}
console.log(i); // ReferenceError: i is not defined
二、作用域链
(一)什么是自由变量?
自由变量是指在当前作用域中没有定义的变量。例如:
var a = 100;
function fn() {
var b = 200;
console.log(a); // 这里的 a 是一个自由变量
console.log(b);
}
fn();
在fn
函数中,a
是一个自由变量,因为它在fn
函数的作用域中没有定义。
(二)什么是作用域链?
作用域链是指当访问一个变量时,编译器会从当前作用域开始,逐层向上查找,直到找到该变量或到达全局作用域。例如:
var a = 100;
function f1() {
var b = 200;
function f2() {
var c = 300;
console.log(a); // 100
console.log(b); // 200
console.log(c); // 300
}
f2();
}
f1();
在f2
函数中,a
和b
是自由变量,它们的值通过作用域链从外层作用域中获取。
(三)关于自由变量的取值
自由变量的值是在函数定义时确定的,而不是在函数调用时确定的。例如:
var x = 10;
function fn() {
console.log(x);
}
function show(f) {
var x = 20;
(function () {
f(); // 输出 10,而不是 20
})();
}
show(fn);
在fn
函数中,x
的值是在fn
函数定义时确定的,因此输出的是全局作用域中的x
,而不是show
函数中的x
。
三、作用域与执行上下文
许多开发人员经常混淆作用域和执行上下文的概念。虽然它们都与变量的访问和函数的执行有关,但它们是不同的概念。
- 作用域:作用域是在函数定义时确定的,它决定了变量的可见性和生命周期。
-
执行上下文:执行上下文是在函数执行时创建的,它包括变量对象、作用域链和
this
的指向。
(一)执行上下文的生命周期
执行上下文的生命周期分为两个阶段:
-
创建阶段:当代码执行进入一个环境时,会创建一个执行上下文。在这个阶段,执行上下文会进行以下操作:
- 创建变量对象(Variable Object,VO):包括函数的形参、
arguments
对象、函数声明和变量声明。 - 确定
this
的指向。 - 确定作用域链。
- 创建变量对象(Variable Object,VO):包括函数的形参、
-
执行阶段:在执行阶段,代码开始执行,变量被赋值,函数被调用,其他代码按顺序执行。
四、总结
理解作用域和作用域链的工作原理和实际应用,可以帮助你更好地理解代码的执行流程和变量的访问机制。如果你对本文的内容有任何疑问或补充,欢迎在评论区留言讨论。
Flutter核心机制图解说明
一、三棵树协作流程详解
1. 架构关系示意图
[用户代码] → Widget树(声明式配置)
↓ 创建
Element树(生命周期管理)
↓ 绑定
RenderObject树(布局/绘制)
2. 协作流程步骤
-
初始化阶段
-
runApp()
触发根Widget创建 - 生成对应的根Element(
RenderObjectElement
) - Element创建关联的RenderObject
-
-
构建阶段
- Widget树通过
build()
方法递归构建 - Element树通过
inflateWidget
方法逐层创建子元素 - RenderObject树执行
createRenderObject
初始化渲染对象
- Widget树通过
-
更新阶段
- 当Widget发生变更时:
a. Element树对比新旧Widget类型
b. 类型相同 → 更新现有Element配置(
update()
) c. 类型不同 → 销毁旧Element,创建新Element
- 当Widget发生变更时:
a. Element树对比新旧Widget类型
b. 类型相同 → 更新现有Element配置(
-
布局阶段
- RenderObject执行
layout()
方法 - 父节点向子节点传递约束条件(Constraints)
- 子节点返回布局尺寸(Size)
- RenderObject执行
-
绘制阶段
- 生成Layer树提交给Skia引擎
- 通过OpenGL/Vulkan进行GPU渲染
二、Platform Channel架构解析
1. 通信层级结构
[Flutter层] - Dart代码
│
├── MethodChannel (方法调用)
├── EventChannel (事件流)
└── BasicMessageChannel (基础消息)
│
|
[Native层] - 平台原生代码
│
├── Android (Java/Kotlin)
└── iOS (Objective-C/Swift)
2. 数据流向示意图
Flutter → 序列化为二进制 → 平台通道 → 反序列化为原生类型 → Native处理
← 序列化返回数据 ← ← 原生返回结果 ←
3. 核心组件说明表
组件 | 功能特点 | 典型使用场景 |
---|---|---|
MethodChannel | 支持异步方法调用与返回值 | 调用相机/获取地理位置 |
EventChannel | 建立持续事件流(类似观察者模式) | 传感器数据监听/实时定位更新 |
BasicMessageChannel | 基础消息传递(支持自定义编解码器) | 简单数据交换/二进制传输 |
三、Key机制工作原理图示
1. LocalKey复用逻辑
Widget树重建前:
Item1(Key:A) - Item2(Key:B) - Item3(Key:C)
Widget树重建后:
Item2(Key:B) - Item3(Key:C) - Item1(Key:A)
Element树保持:
ElementB ↔ ElementC ↔ ElementA(仅位置变化)
2. GlobalKey定位原理
┌───────────┐
│ GlobalKey │
└─────┬─────┘
│
┌─────▼─────┐
│ Element树 │
└─────┬─────┘
│
┌─────▼─────┐
│ 获取RenderObject │
└───────────┘
四、状态管理数据流模型
1. Provider架构模型
[ChangeNotifier] ← 数据更新
│
├─── notifyListeners()
│
[Consumer] → 局部刷新
│
[Selector] → 精准刷新
2. GetX响应式流程
[Rx变量] → 数据变更
│
├─── 自动触发更新
│
[Obx组件] → 重建依赖部件
│
[GetBuilder] → 手动控制刷新
五、混合开发通信时序图
1. MethodChannel调用流程
Flutter端 Native端
│ │
│ invokeMethod('getInfo')│
│───────────────────────>│
│ ├── 执行原生代码
│ │
│ result(data) │
│<───────────────────────│
│ │
2. EventChannel事件流
Flutter端 Native端
│ │
│ receiveBroadcast() │
│───────────────────────>│
│ ├── 注册监听器
│ │
│ event(data) │
│<───────────────────────│(持续推送)
│ │
六、性能优化关键路径
1. 渲染优化路线
减少Widget重建 → 优化Element复用 → 降低RenderObject计算 → 精简Layer树
↑ ↑ ↑
const构造 Key精准控制 布局边界标记(RepaintBoundary)
2. 内存管理策略
图片缓存控制 → 及时销毁监听 → 避免闭包泄漏 → 使用Isolate计算
↑ ↑ ↑ ↑
LRU策略 dispose()清理 DevTools检测 compute()函数
通过以上文字图解,开发者可以建立清晰的架构认知:
- 三棵树机制:理解声明式UI的核心工作原理
- 平台交互:掌握混合开发的数据通信脉络
- 状态管理:构建可维护的响应式架构
- 性能优化:定位关键瓶颈实施精准优化
建议结合Flutter DevTools的以下功能进行验证:
- Widget Inspector:实时查看三棵树状态
- Timeline:分析渲染流水线性能
- Memory:检测内存泄漏与溢出
跟着文档学VUE3(四)- 类与样式绑定
Class 与 Style 的绑定技巧
关键词:Vue3、class 绑定、style 绑定、动态样式、响应式 UI
在 Vue 开发中,我们经常需要根据组件状态动态地控制元素的类名和内联样式。Vue 提供了非常强大的 :class
和 :style
指令,不仅可以绑定字符串,还能绑定对象或数组,实现灵活的 UI 样式切换。
🧱 一、绑定 class
的多种方式
💡 基本原理:
-
class
是 HTML 元素的一个 attribute。 -
在 Vue 中,使用
v-bind:class
或简写为:class
来动态绑定 class。 -
Vue 对
class
绑定做了增强,支持传入:- 字符串
- 对象
- 数组
✅ 1. 使用对象绑定 class
适用于需要根据布尔值来切换 class 的场景。
// 直接绑定一个对象
<div :class="classObject"></div>
// 内联字面量
<div
class="static
:class="{ active: isActive, 'text-danger': hasError }"
></div>
data() {
return {
isActive: true,
hasError: false,
classObject: { active: true, 'text-danger': false }
}
}
💡 小贴士:
- 如果类名是烤串命名(kebab-case),需要用引号包裹,如
'text-danger'
- 可以将对象提取成一个计算属性,提升可读性和复用性
✅ 2. 使用数组绑定 class
适用于需要绑定多个 class 名字的情况。
📌 动态条件渲染?
可以结合三元表达式或嵌套对象来实现:
<div :class="[isActive ? activeClass : '', errorClass]"></div>
<div :class="[{ [activeClass]: isActive }, errorClass]"></div>
data() {
return {
activeClass: 'active',
errorClass: 'text-danger',
isActive: true,
}
}
// 🧾渲染结果
<div class="active text-danger"></div>
✅ 3. 在组件上使用 class
单根元素组件:
当你给组件添加 class
时,这些类会自动合并到组件的根元素上。
多根元素组件:
如果组件有多个根元素,则需显式指定哪个元素接收 class
示例代码
<!-- 子组件模板 -->
<p :class="$attrs.class">Hi!</p>
<span>This is a child component</span>
<!-- 在使用组件时 -->
<MyComponent class="baz boo" />
<p class="baz boo">Hi!</p>
🎨 二、绑定 style
内联样式的高级用法
💡 基本原理:
-
:style
支持绑定 JavaScript 对象,用于动态设置内联样式。 - 支持 camelCase 和 kebab-case 的 key 写法。
✅ 1. 使用对象绑定 style
// 推荐使用 camelCase, 如果是kebab-cased,则key需要对应它在css中的实际名称
<div :style="{ 'font-size': fontSize + 'px' }"></div>
// 也可以直接绑定一个对象
<div :style="styleObject"></div>
data() {
return {
styleObject: { color: 'red', fontSize: '13px' }
}
}
✅ 2. 使用数组绑定多个样式对象
可以传入多个样式对象,后面的样式会覆盖前面的:
// 也可以绑定一个包含多个样式对象的数组
<div :style="[baseStyles, overridingStyles]"></div>
✅ 3. 自动处理浏览器前缀
- 如果在css中使用了需要浏览器特殊前缀的css,Vue在运行时会检查该属性是否支持在当前浏览器中使用,如果不支持,会自动加前缀
- 可以提供多个不同前缀的值,只会渲染浏览器支持的最后一个值
// 不需要特别前缀的浏览器,最后会渲染为display: flex
<div :style="{ display: ['-webkit-box', '-ms-flexbox', 'flex'] }"></div>
【CodeBuddy】一句话开发一个完整项目之:响应式栅格布局生成器
前言
在响应式网页设计领域,栅格系统是构建页面骨架的核心工具。传统开发中需手动编写重复的CSS代码,本项目通过实现可视化栅格生成器,将布局配置转化为可交互操作,提升前端开发效率。工具采用原生HTML/CSS/JavaScript技术栈,展现基础技术的组合威力。
以下是实际操作中的开发界面与最终呈现效果:
应用场景
- 教学演示 - 直观展示栅格参数变化对布局的影响
- 原型设计 - 快速生成基础布局代码框架
- 样式调试 - 实时预览不同间距/列数的视觉效果
- 主题定制 - 通过色彩变量快速匹配品牌风格
核心功能实现
1. 动态栅格控制
- 通过
<input type="range">
控件绑定columns
和gutter
参数 -
updateGrid()
函数动态重建gridPreview
容器:
function updateGrid() {
gridPreview.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
// 动态创建${columns}个gridItem...
}
2. 实时样式同步
- 颜色选择器使用
<input type="color">
控件 - 通过
document.documentElement.style.setProperty
更新CSS变量:
colorInput.addEventListener('input', () => {
document.documentElement.style.setProperty('--primary-color', color);
});
3. CSS代码生成
-
generateCSS()
方法拼接模板字符串生成可复用代码 - 输出结果包含动态插值变量:
const css = `.grid-container {
grid-template-columns: repeat(${columns}, 1fr);
gap: ${gutter}px;
}`;
过程难点
-
实时响应同步
需处理多个输入控件的input
事件,通过函数节流优化性能,保证高频操作下的流畅体验。 - 跨浏览器兼容
- 统一滑块控件样式:重写
::-webkit-slider-thumb
伪元素 - 颜色输入控件降级方案:保留原生
type="color"
的同时提供备选说明
-
代码复制体验
采用document.execCommand('copy')
实现剪贴板交互,配合视觉反馈提升操作感知:
copyBtn.addEventListener('click', () => {
// 创建Range对象选择代码文本...
this.textContent = '已复制!';
});
总结感悟
- 技术收获
- CSS变量在动态主题中的应用价值
- 原生API实现复杂交互的可能性
- 响应式布局的核心原理实践
- 优化方向
- 增加断点媒体查询配置
- 添加布局模板预设
- 支持SCSS/LESS格式导出
-
开发启示
工具类项目应遵循"所见即所得"原则,通过即时反馈降低用户认知成本。在追求新技术的同时,不应忽视原生技术的潜力挖掘。
🌟 让技术经验流动起来
▌▍▎▏ 你的每个互动都在为技术社区蓄能 ▏▎▍▌
✅ 点赞 → 让优质经验被更多人看见
📥 收藏 → 构建你的专属知识库
🔄 转发 → 与技术伙伴共享避坑指南
点赞 ➕ 收藏 ➕ 转发,助力更多小伙伴一起成长!💪
💌 深度连接:
点击 「头像」→「+关注」
每周解锁:
🔥 一线架构实录 | 💡 故障排查手册 | 🚀 效能提升秘籍
uniapp(微信小程序、App)中获取音频文件时长duration
1.需求介绍
- 在uniapp中获取上传的音频文件的时长,根据时长来校验提交规则。
2.实现
2.1 在微信小程序中获取音频文件时长。
根据 uni.createInnerAudioContext()来获取音频文化时长,点击可直达该api的官方文档说明。
2.1.1 不兼容的写法
- 基于 uni.createInnerAudioContext() 封装的方法
- 在ios微信小程序中可以获取到音频文件时长,在android微信小程序中就获取不到音频文件时长。
const getAudioDurationByWx = (filePath) => {
return new Promise((resolve, reject) => {
// 创建并返回内部 audio 上下文 `innerAudioContext` 对象。
const audioContext = uni.createInnerAudioContext();
audioContext.src = filePath
let retryCount = 0 // 当前重试获取音频文件时长的次数
const maxRetries = 3 // 最大次数重试获取音频文件时长的次数
const checkDuration = () => {
if (audioContext.duration > 0) {
const duration = audioContext.duration // 获取音频文件的时长
audioContext.destroy() // 获取到音频文件的时长后, 直接销毁 `innerAudioContext`对象
resolve(duration) // 音频文件的时长返回出去,供外面使用。
} else if (retryCount < maxRetries) { // 重新试着获取时长。
retryCount++
setTimeout(checkDuration, 500) // 每500ms检查一次
} else {
audioContext.destroy(); // 超过了最大重试获取音频文件时长的次数后,直接销毁 `innerAudioContext`对象
reject(new Error('无法获取音频时长'))
}
}
// 等待一段时间后开始获取时长。
setTimeout(() => {
checkDuration()
}, 100)
// 监听`innerAudioContext`对象的错误回调方法
audioContext.onError((err) => {
audioContext.destroy()
reject(err)
})
})
}
2.1.2 微信小程序中的兼容写法
- 参考博客,点击也可直达文档。
- 如下列出代码
const getAudioDurationByWx = (filePath) => {
return new Promise((resolve, reject) => {
const audioContext = uni.createInnerAudioContext()
audioContext.volume = 0; // 音量0
audioContext.autoplay = true; // 自动播放
audioContext.src = filePath
audioContext.onTimeUpdate(() => {
if (audioContext.duration > 0) {
const duration = audioContext.duration
audioContext.destroy()
resolve(duration)
} else {
audioContext.destroy()
reject('无法获取音频时长')
}
audioContext.destroy(); // 销毁实例
})
audioContext.onError((err) => {
audioContext.destroy()
reject(err)
})
})
}
2.2 在Android中获取音频文件时长
2.2.1 在Android中如下获取
- 注意: 因为是音频文件上传,记得要去manifest.json中配置文件上传权限
const getAudioDurationByApp = (filePath) => {
return new Promise((resolve, reject) => {
try {
if(plus.os.name.toLowerCase() === 'android') {
const MediaMetadataRetriever = plus.android.importClass('android.media.MediaMetadataRetriever')
const retriever = new MediaMetadataRetriever()
retriever.setDataSource(filePath)
const duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
const durationInSeconds = Math.floor(parseInt(duration) / 1000)
retriever.release()
resolve(durationInSeconds)
}
} catch (e) {
console.error('获取音频时长失败:', e)
reject(e)
}
})
}
最后
- 如有不正之处,请佬不吝赐教,万分感谢。
- 祝大家向上!