阅读视图
使用 uniapp 实现的扫雷游戏
1. 效果图
2. 游戏规则
扫雷的规则很简单。盘面上有许多方格,方格中随机分布着一些雷。你的目标是避开雷,打开其他所有格子。一个非雷格中的数字表示其相邻 8 格子中的雷数,你可以利用这个信息推导出安全格和雷的位置。你可以用右键在你认为是雷的地方插旗(称为标雷)。你可以用左键打开安全的地方,左键打开雷将被判定为失败。
3. 实现思路
- 创建 row 行 col 列的二维数组,注意行和列创建时都要生成他的唯一 key;
- 根据选择的难度,将对应难度的雷的个数随机埋入 row * col 个格子中;
- 统计每个格子周边八个格子中雷的个数;
- 每个格子的状态:bomb 是否是存在雷的格子,bombCount 当前格子周边格子的雷的数量,flag 当前格子是否被插旗,opened 当前格子是否被翻开;
- 根据每个格子的状态,渲染对应的图片;
- 点击每个格子,执行对应的操作,比如插旗,翻开,是雷就爆炸等。
4. 创建背景
4.1 HTML 结构
<view class="rui-flex-cc">
<view class="rui-minesweeper-content">
<view class="rui-minesweeper-header-content">
<view class="rui-header-counter">{{ bombCount.toString().padStart(3, '0') }}</view>
<view class="rui-header-btn" @click="start">
<view v-if="isGameOver">
<image v-if="isSuccess" :src="icon.iconFaceSuccess" class="rui-btn-icon"></image>
<image v-else :src="icon.iconFaceFail" class="rui-btn-icon"></image>
</view>
<image v-else :src="icon.iconFaceNormal" class="rui-btn-icon"></image>
</view>
<view class="rui-header-counter">{{ sec.toString().padStart(3, '0') }}</view>
</view>
<!-- 雷区 -->
<view class="rui-main-content">
</view>
</view>
</view>
4.2 样式
$primary: #C0C0C0;
$light: #EEEEEE;
$dark: #969696;
.rui-minesweeper-content{
background-color: $primary;
width: 250px;
height: 300px;
margin: 20px auto;
padding: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: default;
user-select: none;
.rui-minesweeper-header-content{
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
border: 3px inset $light;
box-sizing: border-box;
padding: 5px 10px;
.rui-header-counter {
background-color: black;
color: red;
font-family: Impact;
min-width: 2em;
text-align: center;
}
.rui-header-btn {
border: 2px outset #eee;
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
.rui-btn-icon{
width: 21px;
height: 21px;
border-radius: 50%;
display: block;
}
}
}
}
4.3 实现结果
5. 初始化格子
5.1 代码分析
- 获取当前难度等级:通过 find 方法从 navbars 数组中找到 ischeck 属性为 true 的第一个元素,即当前选中的难度等级。
- 提取难度等级的属性:使用解构赋值从 curGrade 对象中提取出 bombCount、row 和 col 属性,分别代表地雷数量、行数和列数。
- 初始化游戏状态:初始化游戏的各种状态变量,包括计时器(sec)、游戏结束标志(isGameOver)、游戏成功标志(isSuccess)、最大行数(maxrow)、最大列数(maxcol)、地雷数量(bombCount)和空白格子数量(blankCount)。
- 初始化网格:调用 initCells 方法来初始化游戏网格,该方法根据传入的行数和列数创建一个二维数组,用于表示游戏的网格。
- 随机放置地雷:调用 fixUpMinesweepers 方法在游戏网格中随机放置地雷。
- 计算当前位置周边雷的个数:调用 findCurrentPointAroundMinesweeperCount 方法来计算每个格子周围的地雷数量,这个信息通常用于在游戏中显示给玩家。
5.2 初始化代码实现
// 开始重置
start(){
let curGrade = this.navbars.find(item => item.ischeck);
let { bombCount, row, col } = curGrade;
this.sec = 0;
this.isGameOver = false;
this.isSuccess = false;
this.maxrow = row;
this.maxcol = col;
this.bombCount = bombCount;
this.blankCount = row * col - bombCount;
// 初始化网格
this.initCells(row, col);
// 随机放置地雷
this.fixUpMinesweepers();
// 计算当前位置周边雷的个数
this.findCurrentPointAroundMinesweeperCount();
}
5.3 初始化网格
- 初始化外层数组:使用 Array.from 方法创建一个长度为 row 的新数组,并使用一个映射函数来初始化每个元素。映射函数的参数 r 代表当前正在处理的行,而 ridx 代表该行的索引。返回一个对象,该对象包含两个属性:
- keyId: 一个随机生成的字符串,用于标识该行。
- list: 一个新的数组,使用 Array.from 方法创建,长度为 col。
- 初始化内层数组:数组的每个元素也是一个对象,包含以下属性:
- bomb: 一个布尔值,表示该单元格是否包含地雷,初始值为 false。
- bombCount: 一个数字,表示该单元格周围的地雷数量,初始值为 0。
- flag: 一个布尔值,表示该单元格是否被标记为地雷,初始值为 false。
- opened: 一个布尔值,表示该单元格是否被翻开,初始值为 false。
5.4 网格代码实现
// 初始化网格
initCells(row, col){
this.cells = Array.from({ length: row }, (r, ridx) => {
return {
keyId: `id-${randomString()}`,
list: Array.from({length: col}, (c, cidx) => {
return {
keyId: `id-${randomString()}`,
bomb: false,
bombCount: 0,
flag: false,
opened: false
}
})
}
})
}
6. 随机放置地雷
6.1 代码分析
- 初始化:初始化一个局部变量 cells,它引用了组件实例的 cells 属性,即二维数组,表示游戏网格。
- 放置地雷的循环:使用 for 循环,它将执行 this.bombCount 次,即要放置的地雷数量。在每次循环中,它生成两个随机数 row 和 col,分别代表地雷在棋盘上的行和列索引。
- 检查并放置地雷:检查生成的随机位置 cells[row].list[col] 是否已经包含了地雷。如果没有,它将该位置标记为地雷(bomb 属性设置为 true)。如果该位置已经有地雷,它将 i 减一,以便在下一次循环中重新尝试放置地雷。
6.2 代码实现
fixUpMinesweepers(){
let cells = this.cells;
for(let i = 0; i < this.bombCount; i++){
let row = Math.floor(Math.random() * this.maxrow);
let col = Math.floor(Math.random() * this.maxcol);
if(!cells[row].list[col].bomb){
cells[row].list[col].bomb = true;
} else {
i--;
}
}
}
7. 计算当前位置周边雷的个数
7.1 代码分析
- 初始化:初始化一个局部变量 cells,它引用了组件实例的 cells 属性,即二维数组,表示游戏棋盘。
- 两层嵌套循环遍历单元格:使用两层嵌套循环遍历游戏棋盘上的每个单元格。外层循环遍历每一行,内层循环遍历每一列。对于每个单元格,它调用 handleAroundPoints 函数来处理该单元格周围的点。
- 调用 handleAroundPoints 函数:
- cells:二维数组,表示游戏棋盘。
- row:当前单元格的行索引。
- col:当前单元格的列索引。
- maxrow:游戏棋盘的最大行数。
- maxcol:游戏棋盘的最大列数。
- callback:一个回调函数,用于对每个相邻单元格执行操作。在这个回调函数中,它将当前单元格的 bombCount 属性增加 1,如果相邻单元格是地雷;否则,不增加。
7.2 代码实现
findCurrentPointAroundMinesweeperCount(){
let cells = this.cells;
for(let row = 0; row < this.maxrow; row++) {
for(let col = 0; col < this.maxcol; col++) {
handleAroundPoints({
cells,
row,
col,
maxrow: this.maxrow,
maxcol: this.maxcol,
callback: cCell => cells[row].list[col].bombCount += cCell.bomb ? 1 : 0
})
}
}
}
7.3 遍历当前位置周边代码分析
- 函数的参数包括:
- cells:一个二维数组,表示单元格的网格。
- row:当前单元格的行索引。
- col:当前单元格的列索引。
- maxrow:网格的最大行数。
- maxcol:网格的最大列数。
- callback:一个回调函数,用于对每个相邻单元格执行操作。
- 函数内部使用了两个嵌套的 for 循环来遍历相邻单元格。外层循环遍历行,内层循环遍历列。通过 Math.max 和 Math.min 函数确保循环的边界不会超出网格的范围。同时,使用 continue 语句跳过当前单元格本身,只处理相邻单元格。
- 通过回调函数 callback 来对每个相邻单元格执行操作,传入的参数包括单元格的值、行索引和列索引。
7.4 遍历当前位置周边代码实现
function handleAroundPoints({cells, row, col, maxrow, maxcol, callback}) {
for(let srow = Math.max(row - 1, 0); srow < Math.min(row + 2, maxrow); srow++) {
for(let scol = Math.max(col - 1, 0); scol < Math.min(col + 2, maxcol); scol++) {
if (srow === row && scol === col) continue;
callback(cells[srow].list[scol], srow, scol)
}
}
}
7.5 实现结果
8. 点击事件
8.1 逻辑分析
- 初始化:接受两个参数:row 和 col,分别代表点击的单元格的行和列索引。方法内部首先检查是否存在一个计时器(timer),如果不存在,则创建一个计时器,每1000毫秒(即1秒)调用一次 onTick 方法。
- 点击事件:当前选中的点击类型(curTypeItem.type)是0,即左键【翻开】点击,代码会检查点击的单元格是否被标记为旗子(flag)。如果是,则不做任何操作;如果不是,它会进一步检查单元格是否包含地雷(bomb)。如果是地雷,单元格将被标记为已打开(opened),并且调用 onExplode 方法表示游戏失败;如果不是地雷,它将调用 onOpen 方法来打开单元格。
- 插旗点击事件:当前选中的点击类型是1,即右键【插旗】点击,代码将调用 onFlag 方法来标记或取消标记单元格为旗子。
8.2 代码实现
onClick(row, col) {
let curTypeItem = this.chooseClickTypes.find(item => item.ischeck);
if(!this.timer){
this.timer = setInterval(this.onTick, 1000);
}
if(curTypeItem.type == 0){
const cell = this.cells[row].list[col];
if (cell.flag){
return false;
}
if(cell.bomb){
cell.opened = true;
this.onExplode();
} else {
this.onOpen(row, col);
}
} else if(curTypeItem.type == 1){
this.onFlag(row, col);
}
}
9. 翻开逻辑
9.1 逻辑分析
- 初始化:接受两个参数:row 和 col,分别代表点击的单元格的行和列索引。方法内部首先获取当前单元格的引用,然后将其标记为已打开(opened),并清除其标记为旗子的状态(flag)。接着,它减少空白单元格计数器(blankCount)的值,表示已经打开了一个单元格。
- 检查游戏胜利条件:检查 blankCount 是否小于1,如果是,则表示所有非地雷单元格都已被打开,游戏胜利,因此调用 onSuccess 方法。
- 检查是否需要开启周围单元格:当前单元格的地雷数量(bombCount)为0,则表示其周围没有地雷,需要开启周围的单元格。因此,调用 openAround 方法来开启当前单元格周围的单元格。
9.2 代码实现
// 开启当前位置
onOpen(row, col) {
const cell = this.cells[row].list[col];
cell.opened = true;
cell.flag = false;
this.blankCount--;
if (this.blankCount < 1) {
this.onSuccess()
} else if (cell.bombCount === 0) {
this.openAround(row, col)
}
}
10. 翻开当前位置的周边没有雷区和没有开启的位置
10.1 逻辑分析
- 获取了当前的单元格数组 cells,然后调用了一个名为 handleAroundPoints 的函数。
- 函数接收一个对象作为参数,该对象包含以下属性:
- cells:当前的单元格数组。
- row:当前单元格的行索引。
- maxrow:网格的最大行数。
- maxcol:网格的最大列数。
- callback:一个回调函数,该函数接收三个参数:cCell(当前处理的单元格),crow(当前处理的单元格的行索引),ccol(当前处理的单元格的列索引)。回调函数的作用是检查当前单元格是否满足翻开条件,如果满足,则翻开该单元格。
- 检查当前单元格是否已经被打开、是否是炸弹或者是否被标记为旗子。如果这些条件都不满足,则调用 this.onOpen(crow, ccol) 方法来打开当前单元格。
10.2 代码实现
// 开启当前位置的周边没有雷区和没有开启的位置
openAround(row, col) {
let cells = this.cells;
handleAroundPoints({
cells,
row,
col,
maxrow: this.maxrow,
maxcol: this.maxcol,
callback: (cCell, crow, ccol) => {
if (!cCell.opened && !cCell.bomb && !cCell.flag){
this.onOpen(crow, ccol);
}
}
})
}
11. 增加难度选择
11.1 代码实现
// 切换难度
changeGrade(idx){
let navbars = this.navbars;
navbars.forEach(item => item.ischeck = false);
navbars[idx].ischeck = true;
this.start();
}
11.2 实现效果
11.2.1 初级
11.2.1 中级
12. 翻开或插旗切换
12.1 代码实现
// 切换点击事件类型
changeClickType(idx){
let chooseClickTypes = this.chooseClickTypes;
chooseClickTypes.forEach(item => item.ischeck = false);
chooseClickTypes[idx].ischeck = true;
}
12.2 实现效果
13. 全部代码
<template>
<view>
<view class="rui-flex-cc">
<view class="rui-flex-ac">
<view :class="{
'rui-navbar-li': true,
'rui-active': nav.ischeck
}"
@click="changeGrade(idx)"
v-for="(nav,idx) in navbars" :key="nav.keyId">
{{nav.name}}
</view>
</view>
</view>
<view class="rui-flex-cc">
<view class="rui-minesweeper-content">
<view class="rui-minesweeper-header-content">
<view class="rui-header-counter">{{ bombCount.toString().padStart(3, '0') }}</view>
<view class="rui-header-btn" @click="start">
<view v-if="isGameOver">
<image v-if="isSuccess" :src="icon.iconFaceSuccess" class="rui-btn-icon"></image>
<image v-else :src="icon.iconFaceFail" class="rui-btn-icon"></image>
</view>
<image v-else :src="icon.iconFaceNormal" class="rui-btn-icon"></image>
</view>
<view class="rui-header-counter">{{ sec.toString().padStart(3, '0') }}</view>
</view>
<!-- 雷区 -->
<view class="rui-main-content">
<view class="rui-row" v-for="(row,ridx) in cells" :key="row.keyId">
<view class="rui-col" v-for="(col,cidx) in row.list"
@click="onClick(ridx, cidx)"
@contextmenu.prevent="onFlag(ridx, cidx)"
:key="col.keyId">
<view v-if="isGameOver">
<image v-if="!col.bomb" :src="icon[`icon${col.bombCount}`]" class="rui-icon"></image>
<view v-else>
<image v-if="col.opened" :src="icon.iconBlood" class="rui-icon"></image>
<image v-else :src="icon.iconMine" class="rui-icon"></image>
</view>
</view>
<view v-else>
<image v-if="col.flag" :src="icon.iconFlag" class="rui-icon"></image>
<image v-else-if="col.opened" :src="icon[`icon${col.bombCount}`]" class="rui-icon"></image>
<image v-else-if="!col.opened" :src="icon.iconBlank" class="rui-icon"></image>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 切换点击事件类型 -->
<view class="rui-flex-cc">
<view class="rui-flex-ac">
<view :class="{
'rui-navbar-li': true,
'rui-active': typeItem.ischeck
}"
@click="changeClickType(idx)"
v-for="(typeItem,idx) in chooseClickTypes" :key="typeItem.keyId">
{{typeItem.name}}
</view>
</view>
</view>
</view>
</template>
<script>
import icon from './icon';
import { randomString, handleAroundPoints } from './utils';
export default {
name:"RuiMinesweeper",
data() {
return {
icon,
sec: 0,
bombCount: 0,
blankCount: 0,
maxrow: 0,
maxcol: 0,
isGameOver: false,
isSuccess: false,
cells: [],
chooseClickTypes: [{
name: '翻开',
type: 0,
keyId: `id-${randomString()}`,
ischeck: true
},{
name: '插旗',
type: 1,
keyId: `id-${randomString()}`,
ischeck: false
}],
navbars: [{
name: '初级',
row: 9,
col: 9,
bombCount: 10,
keyId: `id-${randomString()}`,
ischeck: true
},{
name: '中级',
row: 16,
col: 16,
bombCount: 40,
keyId: `id-${randomString()}`,
ischeck: false
},{
name: '高级',
row: 20,
col: 20,
bombCount: 99,
keyId: `id-${randomString()}`,
ischeck: false
},{
name: '自定义',
row: 30,
col: 30,
bombCount: 150,
keyId: `id-${randomString()}`,
ischeck: false
}],
};
},
created(){
this.start()
},
methods: {
// 切换难度
changeGrade(idx){
let navbars = this.navbars;
navbars.forEach(item => item.ischeck = false);
navbars[idx].ischeck = true;
this.start();
},
// 切换点击事件类型
changeClickType(idx){
let chooseClickTypes = this.chooseClickTypes;
chooseClickTypes.forEach(item => item.ischeck = false);
chooseClickTypes[idx].ischeck = true;
},
// 开始重置
start(){
let curGrade = this.navbars.find(item => item.ischeck);
let { bombCount, row, col } = curGrade;
this.sec = 0;
this.isGameOver = false;
this.isSuccess = false;
this.maxrow = row;
this.maxcol = col;
this.bombCount = bombCount;
this.blankCount = row * col - bombCount;
// 初始化网格
this.initCells(row, col);
// 随机放置地雷
this.fixUpMinesweepers();
// 计算当前位置周边雷的个数
this.findCurrentPointAroundMinesweeperCount();
},
// 初始化网格
initCells(row, col){
this.cells = Array.from({ length: row }, (r, ridx) => {
return {
keyId: `id-${randomString()}`,
list: Array.from({length: col}, (c, cidx) => {
return {
keyId: `id-${randomString()}`,
bomb: false,
bombCount: 0,
flag: false,
opened: false
}
})
}
})
},
// 随机放置地雷
fixUpMinesweepers(){
let cells = this.cells;
for(let i = 0; i < this.bombCount; i++){
let row = Math.floor(Math.random() * this.maxrow);
let col = Math.floor(Math.random() * this.maxcol);
if(!cells[row].list[col].bomb){
cells[row].list[col].bomb = true;
} else {
i--;
}
}
},
// 计算当前位置周边雷的个数
findCurrentPointAroundMinesweeperCount(){
let cells = this.cells;
for(let row = 0; row < this.maxrow; row++) {
for(let col = 0; col < this.maxcol; col++) {
handleAroundPoints({
cells,
row,
col,
maxrow: this.maxrow,
maxcol: this.maxcol,
callback: cCell => cells[row].list[col].bombCount += cCell.bomb ? 1 : 0
})
}
}
},
// 计时器一千秒
onTick() {
if (this.sec < 999){
this.sec++;
} else {
// 时间完成,结束游戏
this.onStop();
}
},
// 结束游戏
onStop() {
this.isGameOver = true;
if(this.timer) {
clearInterval(this.timer);
this.timer = null;
}
},
// 爆炸
onExplode(){
this.isSuccess = false;
this.onStop();
},
// 成功
onSuccess(){
this.isSuccess = true;
this.onStop();
},
// 插旗操作
onFlag(row, col){
const cell = this.cells[row].list[col];
cell.flag = !cell.flag
cell.flag ? this.bombCount-- : this.bombCount++;
},
// 点击事件
onClick(row, col) {
let curTypeItem = this.chooseClickTypes.find(item => item.ischeck);
if(!this.timer){
this.timer = setInterval(this.onTick, 1000);
}
if(curTypeItem.type == 0){
const cell = this.cells[row].list[col];
if (cell.flag){
return false;
}
if(cell.bomb){
cell.opened = true;
this.onExplode();
} else {
this.onOpen(row, col);
}
} else if(curTypeItem.type == 1){
this.onFlag(row, col);
}
},
// 开启当前位置
onOpen(row, col) {
const cell = this.cells[row].list[col];
cell.opened = true;
cell.flag = false;
this.blankCount--;
if (this.blankCount < 1) {
this.onSuccess()
} else if (cell.bombCount === 0) {
this.openAround(row, col)
}
},
// 开启当前位置的周边没有雷区和没有开启的位置
openAround(row, col) {
let cells = this.cells;
handleAroundPoints({
cells,
row,
col,
maxrow: this.maxrow,
maxcol: this.maxcol,
callback: (cCell, crow, ccol) => {
if (!cCell.opened && !cCell.bomb && !cCell.flag){
this.onOpen(crow, ccol);
}
}
})
}
}
}
</script>
<style lang="scss" scoped>
$primary: #C0C0C0;
$light: #EEEEEE;
$dark: #969696;
.rui-flex-cc{
display: flex;
justify-content: center;
align-items: center;
}
.rui-flex-ac{
display: flex;
align-items: center;
}
.rui-icon{
width: 25px;
height: 25px;
display: block;
}
.rui-navbar-li{
color: #23527c;
margin: 20px;
}
.rui-navbar-li.rui-active{
color: #444;
font-weight: 700;
}
.rui-minesweeper-content{
background-color: $primary;
margin: 20px auto;
padding: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: default;
user-select: none;
.rui-minesweeper-header-content{
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
border: 3px inset $light;
box-sizing: border-box;
padding: 5px 10px;
.rui-header-counter {
background-color: black;
color: red;
font-family: Impact;
min-width: 2em;
text-align: center;
}
.rui-header-btn {
border: 2px outset #eee;
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
.rui-btn-icon{
width: 21px;
height: 21px;
border-radius: 50%;
display: block;
}
}
}
.rui-main-content{
width: 100%;
height: 100%;
border: 3px inset #eee;
box-sizing: border-box;
}
.rui-row{
display: flex;
align-items: center;
}
.rui-col {
flex: none;
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
}
.rui-col.no-event {
pointer-events: none;
}
}
</style>
14. 工具函数
export function randomString(e) {
e = e || 32;
const t = "abcdefghijklmnopqrstwxyz1234567890";
const a = t.length;
let n = "";
for (let i = 0; i < e; i++) {
n += t.charAt(Math.floor(Math.random() * a));
}
return n;
}
export function handleAroundPoints({cells, row, col, maxrow, maxcol, callback}) {
for(let srow = Math.max(row - 1, 0); srow < Math.min(row + 2, maxrow); srow++) {
for(let scol = Math.max(col - 1, 0); scol < Math.min(col + 2, maxcol); scol++) {
if (srow === row && scol === col) continue;
callback(cells[srow].list[scol], srow, scol)
}
}
}
15. 总结
- 逻辑实现其实不是很难,最主要的是需要理清逻辑,然后按部就班的实现就可以了,写代码实现很重要,不要永远在思考的层面。
- 由于这个是按照 PC 实现的界面,所以在移动端游戏界面会超出视图,因此可以在此基础,将难度在一个页面选择,然后动态计算每个难度格子的大小。
- 基本的逻辑已完善,如果需要引流等代码,需要对饮的自己添加,由于使用的都是图片,换UI界面也比较容易,设计一套新的UI,替换图片就可以。
双token机制:flutter_secure_storage 实现加密存储
1. 双Token机制
{
"code": 0,
"data": {
"userId": 305,
"accessToken": "1c56f701051547bca956140f6c73a189",
"refreshToken": "3a9b5ac0eeed4d93ae67d3d0b0fb8d42",
"expiresTime": 1757122540160,
"openid": null
},
"msg": ""
}
作为一个初学者,我知道在发送网络请求中,我们需要在请求头中携带一个Token
身份令牌;当接触到真实的后端接口后,为什么会有两个Token
呢?
这种设计模式被称为 “访问令牌 + 刷新令牌”
机制。
1. Access Token (访问令牌)
是什么:就像一把一次性的门禁卡或电影票。
用途:客户端(Flutter App) 用它来访问受保护的资源(比如获取用户信息、发布内容、访问付费 API 等)。每次向服务器请求数据时,都需要在 HTTP Header(通常是 Authorization: Bearer <access_token>)中带上它,服务器会验证这个令牌是否有效且有权访问所求资源。
特点:
生命周期短:从响应数据中 "expiresTime":1757040344156 可以看出,这个令牌有一个明确的过期时间(可能几小时或几天后)。这是一个时间戳,对应北京时间 2025-09-05 10:45:44。
权限高:直接代表着用户的授权。
暴露风险高:因为它经常在网络请求中传输。
2. Refresh Token (刷新令牌)
是什么:就像一把用来补办门禁卡的钥匙或你的身份证。
用途:唯一的目的就是在 Access Token 过期后,用它去申请一个新的 Access Token。它本身不能用来访问任何业务 API。
特点:
生命周期长:它的有效期比 Access Token 长得多(可能是几周、几个月,或者直到用户主动注销)。
权限低:它只能用来换新的 Access Token,不能做别的事。
暴露风险低:它只会在一种非常特定的请求(刷新令牌)中发送,传输频率极低。
设计优点:
1. 安全性 (Security)
想象一下,如果只有一个长期有效的 Access Token:
一旦这个 Token 被黑客拦截或窃取(比如通过不安全的网络、日志泄露等),黑客就可以永久地冒充用户,为所欲为,因为令牌永远不会失效。
而使用双 Token 机制:
即使 Access Token 被窃取,因为它有效期很短,黑客能作恶的时间窗口也非常有限(可能只剩几个小时)。
Refresh Token 很少在网络上传送,所以被窃取的风险要低得多。即使 Access Token 泄露,攻击者也无法获取新的 Token,因为刷新需要 Refresh Token。
服务器可以有一个黑名单机制。如果发现异常行为(比如一个 Refresh Token 在短时间内从两个不同国家请求新 Access Token),服务器可以立即让这个 Refresh Token 失效,从而保护用户账户。这比撤销一个长期有效的 Access Token 要容易和高效得多。
2. 用户体验 (User Experience)
如果 Access Token 过期后,只让用户重新登录:
用户可能正在使用 App,突然就被踢了出去,需要重新输入用户名和密码,体验非常差。
而有了 Refresh Token:
应用可以在用户无感知的情况下(静默地)用 Refresh Token 去获取一个新的 Access Token,然后继续之前的操作。
用户只有在 Refresh Token 也过期(或者被服务器主动撤销)时,才需要重新登录。这大大延长了用户的“登录会话”时间,提升了体验。
然后将怎么使用呢?
我们把Token的过期时间也保存下来,每次打开应用后对比当前时间戳与过期的时间戳(当前时间戳 > 存储的时间戳)说明Token过期,请求刷新Token接口,传入刷新令牌
(当然在过期之间刷新)。
之后我们将要考虑存储的安全性,我来学习一下flutter_secure_storage
加密存储插件看看怎么使用。
2. flutter_secure_storage
加密存储
以下内容还未来得及尝试:
1. 添加依赖
在 pubspec.yaml
文件中添加依赖:
dependencies:
flutter_secure_storage: ^8.0.0
运行 flutter pub get
安装包。
2. Android 配置
在 android/app/build.gradle
文件中,确保 minSdkVersion
至少为 18:
android {
defaultConfig {
minSdkVersion 18 // 或更高版本
// ... 其他配置
}
}
3. 基本使用方法
导入包
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
创建存储实例(针对 Android 和 iOS 优化)
// 创建适用于 Android 和 iOS 的存储实例
final storage = FlutterSecureStorage(
aOptions: const AndroidOptions(
encryptedSharedPreferences: true, // 在 Android 上使用更安全的EncryptedSharedPreferences
),
iOptions: const IOSOptions(
accessibility: KeychainAccessibility.first_unlock, // 设备首次解锁后可用
),
);
常用操作示例
// 写入数据
await storage.write(key: 'access_token', value: 'your_token_here');
await storage.write(key: 'user_id', value: '12345');
// 读取数据
String? token = await storage.read(key: 'access_token');
String? userId = await storage.read(key: 'user_id');
// 读取所有值
Map<String, String> allValues = await storage.readAll();
// 删除单个值
await storage.delete(key: 'access_token');
// 删除所有值
await storage.deleteAll();
// 检查键是否存在
bool exists = await storage.containsKey(key: 'access_token');
4. 封装成工具类(推荐)
为了更好地组织代码,建议创建一个工具类:
class SecureStorage {
static final SecureStorage _instance = SecureStorage._internal();
factory SecureStorage() => _instance;
SecureStorage._internal();
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
// 存储访问令牌
Future<void> saveAccessToken(String token) async {
await _storage.write(key: 'access_token', value: token);
}
// 获取访问令牌
Future<String?> getAccessToken() async {
return await _storage.read(key: 'access_token');
}
// 存储刷新令牌
Future<void> saveRefreshToken(String token) async {
await _storage.write(key: 'refresh_token', value: token);
}
// 获取刷新令牌
Future<String?> getRefreshToken() async {
return await _storage.read(key: 'refresh_token');
}
// 清除所有存储的数据
Future<void> clearAll() async {
await _storage.deleteAll();
}
// 检查用户是否已登录(是否有访问令牌)
Future<bool> isLoggedIn() async {
return await _storage.containsKey(key: 'access_token');
}
}
5. 在应用中使用
// 在登录成功后保存令牌
void onLoginSuccess(String accessToken, String refreshToken) async {
final secureStorage = SecureStorage();
await secureStorage.saveAccessToken(accessToken);
await secureStorage.saveRefreshToken(refreshToken);
}
// 在应用启动时检查登录状态
void checkLoginStatus() async {
final secureStorage = SecureStorage();
bool isLoggedIn = await secureStorage.isLoggedIn();
if (isLoggedIn) {
String? token = await secureStorage.getAccessToken();
// 使用令牌进行API调用
} else {
// 导航到登录页面
}
}
// 登出时清除数据
void onLogout() async {
final secureStorage = SecureStorage();
await secureStorage.clearAll();
}
注意事项
-
Android 备份:默认情况下,Android 会备份数据到 Google Drive,这可能会导致安全问题和异常。建议在
AndroidManifest.xml
中禁用自动备份或排除安全存储数据:
<application
android:allowBackup="false"
...>
<!-- 或者 -->
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
...>
创建 android/app/src/main/res/xml/backup_rules.xml
:
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
</full-backup-content>
- iOS 钥匙串访问组:如果您需要在多个应用间共享钥匙串数据,可能需要配置钥匙串访问组。但对于大多数单应用场景,不需要额外配置。
- 错误处理:在实际应用中,应该为所有存储操作添加适当的错误处理:
try {
await storage.write(key: 'key', value: 'value');
} catch (e) {
print('存储数据时出错: $e');
// 处理错误,例如使用备用存储方案
}
这样,您就可以在 Android
和 iOS
应用中使用 flutter_secure_storage
安全地存储敏感数据了。这个插件会自动为每个平台使用适当的安全存储机制(Android
的 Keystore/EncryptedSharedPreferences
和 iOS
的 Keychain
)。
3. 真实测试
代码:
main.dart
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage_example/secure_storage.dart';
void main() {
runApp(const MyApp());
}
///
class MyApp extends StatelessWidget {
///
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: StorageTestPage(),
);
}
}
///
class StorageTestPage extends StatefulWidget {
///
const StorageTestPage({super.key});
@override
State<StorageTestPage> createState() => _StorageTestPageState();
}
class _StorageTestPageState extends State<StorageTestPage> {
final SecureStorage storage = SecureStorage();
String _accessToken = '';
String _refreshToken = '';
bool _isLoggedIn = false;
final TextEditingController _accessController = TextEditingController();
final TextEditingController _refreshController = TextEditingController();
Future<void> _saveTokens() async {
await storage.saveAccessToken(_accessController.text);
await storage.saveRefreshToken(_refreshController.text);
await _readTokens();
}
Future<void> _readTokens() async {
final access = await storage.getAccessToken() ?? 'null';
final refresh = await storage.getRefreshToken() ?? 'null';
final loggedIn = await storage.isLoggedIn();
setState(() {
_accessToken = access;
_refreshToken = refresh;
_isLoggedIn = loggedIn;
});
}
Future<void> _clearTokens() async {
await storage.clearAll();
await _readTokens();
}
@override
void initState() {
super.initState();
_readTokens();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('SecureStorage 测试页面')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _accessController,
decoration: const InputDecoration(labelText: 'Access Token'),
),
TextField(
controller: _refreshController,
decoration: const InputDecoration(labelText: 'Refresh Token'),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
ElevatedButton(
onPressed: _saveTokens,
child: const Text('保存'),
),
ElevatedButton(
onPressed: _readTokens,
child: const Text('读取'),
),
ElevatedButton(
onPressed: _clearTokens,
child: const Text('清除'),
),
],
),
const Divider(height: 32),
Text('Access Token: $_accessToken'),
Text('Refresh Token: $_refreshToken'),
Text('是否已登录: $_isLoggedIn'),
],
),
),
);
}
}
secure_storage.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 存储及工具类
class SecureStorage {
/// 单例模式
factory SecureStorage() => _instance;
/// 构造函数
SecureStorage._internal();
static final SecureStorage _instance = SecureStorage._internal();
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
/// 存储访问令牌
Future<void> saveAccessToken(String token) async {
await _storage.write(key: 'access_token', value: token);
}
/// 获取访问令牌
Future<String?> getAccessToken() async {
return _storage.read(key: 'access_token');
}
/// 存储刷新令牌
Future<void> saveRefreshToken(String token) async {
await _storage.write(key: 'refresh_token', value: token);
}
/// 获取刷新令牌
Future<String?> getRefreshToken() async {
return _storage.read(key: 'refresh_token');
}
/// 清除所有存储的数据
Future<void> clearAll() async {
await _storage.deleteAll();
}
/// 检查用户是否已登录(是否有访问令牌)
Future<bool> isLoggedIn() async {
return _storage.containsKey(key: 'access_token');
}
}
如何在模拟器上看加密存储后的数据,在项目终端依次输入以下命令:
adb shell
run-as com.example.flutter_secure_storage_example
cd shared_prefs
cat FlutterSecureStorage.xml
然后得到
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="__androidx_security_crypto_encrypted_prefs_key_keyset__">12a701124456bc5290290e354c5a1d61088990a67618ca5eded893b2cc5a61b2cce225445547fca6e3b2d3d3591f37acb9d85b568cab9a0c35ee2dab3caa5e6ce98863e62c074cb169beac7260bfc13c66191b5a509e1862d84ce2785df93771a3641728bba67fefb4089668b9d0f74695d5e748b44bf8aecb5fea5bec3be56a22377acd903e588a1171b81b70df615a769788c6dd6120b18ffd81d32329b6e6777dcd82e9c5620643ab1a4208fb86c627123b0a30747970652e676f6f676c65617069732e636f6d2f676f6f676c652e63727970746f2e74696e6b2e4165735369764b6579100118fb86c6272001</string>
<string name="__androidx_security_crypto_encrypted_prefs_value_keyset__">128801af0f51731b0a553507d2cc8777a4386d301275860c4821386fbee7b5c31f6750e26655fb37d1ea4a9e1beb2248c705cd8184662ff4fe9b791749b5525a6359db351f36ee8db4b54fb6c598579078425e57aec4302ba11999fd131a72f1a3cf7627070bba73ad9e88f9219b2040b1d00d51c99c891bc11b4db8a300971f2d63b862488635a18cbfc91a4408ff8087f503123c0a30747970652e676f6f676c65617069732e636f6d2f676f6f676c652e63727970746f2e74696e6b2e41657347636d4b6579100118ff8087f5032001</string>
<string name="AQTxg3tF/FbN5ojqZADBaV4DLVEt4sJW5tfzZgKCO9zeZFkQ3en8tTs/rYf7JDwDBM5brlmGzyNVjCvL/yUCacSG9Hy6q80Fmp1pyi01EFkwYrY/sl74Ew==">AT6hwH8xjjMZjux6626D8XCiAOfbcCKiE8EUGuBovF9uvHc/uAuh4D1RR3zNxQ==</string>
<string name="AQTxg3uwgoF/Qw+tcio/UNFY34OFIXBFBeg52C7qZ45TSSD4hgorX+gVN5mW5esTDFypJW9goK/IDxIaQbJXS9GC5+ceBFe/2spzvDW444tq4uVkA84NYMI=">AT6hwH/h+qRydKtr1IIZu0yf3L/WugEuYdod+FvMDRdarepExadPOwzsonjq9g==</string>
</map>
上边的数据构造如下:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="__androidx_security_crypto_encrypted_prefs_key_keyset__">...</string>
<string name="__androidx_security_crypto_encrypted_prefs_value_keyset__">...</string>
<string name="加密后的 key">加密后的 value</string>
<string name="加密后的 key">加密后的 value</string>
</map>
证明我们加密存储成功
从零到搞定:URP Lit彩色箱子材质的开发碎碎念
开场白
做一款咖啡主题的休闲游戏时,我被一个问题难住了:怎么让3D物体既色彩鲜艳又不失立体感?Unity的URP标准Lit材质虽然牛,但总觉得有点“用力过猛”,暗部黑得像锅底,颜色也容易被光照“洗”得没那么鲜艳。折腾了一圈后,我搞了个自定义的彩色箱子材质,简单粗暴又好用。这篇博客就聊聊我是怎么从头到尾搞定这个Shader的心路历程,顺便吐槽下踩过的坑。
问题在哪儿?
标准材质的“坑”用URP的Lit材质时,我发现几个让人头大的问题:
- 颜色不够鲜:光照一打,颜色就没那么饱和,感觉像被“漂白”了。
- 暗部太黑:阴影直接变成黑漆漆一块,色彩细节全没了。
- 参数太烦:Metallic、Smoothness这些参数,美术看了直摇头,调起来像解谜。
游戏到底想要啥?我们这款休闲游戏的美术需求很简单:
- 颜色得鲜艳,像咖啡店的杯子那样抓眼球。
- 暗部不能是纯黑,得保留点色彩,比如咖啡杯暗处带点暖棕色。
- 参数得直观,美术能一秒上手,不用翻文档。
- 立体感不能丢,光影得有点层次。
摸索的过程
先搞懂光照咋算的要搞Shader,先得弄明白URP的光照逻辑。核心就是这个公式:
float NdotL = dot(normalWS, mainLight.direction);
float lightIntensity = saturate(NdotL);
翻译成人话:物体表面法线和光源方向的夹角决定光亮不亮。夹角小,亮;夹角大,暗。简单粗暴,但也给了我灵感:暗的地方能不能不黑,而是用自定义颜色?找到救命的参数瞎折腾了几天,我发现两个神器:
- _ShadowColor:暗部的颜色,可以随便定,比如紫色、蓝色啥的。
- _ShadowStrength:控制暗部颜色有多“重”,调这个就能决定阴影是“淡淡的”还是“浓浓的”。
有了这俩,我感觉离目标不远了!色彩混合的“魔法公式”核心突破是这个混合逻辑:
float shadowIntensity = 1 - lightIntensity;
half3 finalColor = lerp(baseColor, _ShadowColor.rgb, shadowIntensity * _ShadowStrength);
啥意思呢?shadowIntensity是暗部强度,0是亮处,1是最暗。lerp就是在基础颜色和暗部颜色间“滑来滑去”,_ShadowStrength决定滑得多狠。这公式让我第一次看到暗部不是黑的,激动得差点拍桌子!
一步步搞定
- 定义材质参数先把材质属性写好,尽量让美术一看就懂:
Properties
{
_BaseColor("基础颜色", Color) = (1,1,1,1)
_BaseMap("基础贴图", 2D) = "white" {}
_ShadowColor("暗部颜色", Color) = (0.3,0.3,0.5,1)
_ShadowStrength("暗部强度", Range(0,1)) = 0.5
_Darkness("整体暗度", Range(0,1)) = 0
_Smoothness("高光强度", Range(0,1)) = 0.5
}
我把_Metallic改名叫_Darkness,意思是“整体暗度”,美术一听就知道干啥用的,省得解释半天。
- 顶点着色器顶点着色器没啥花头,标准流程,传点数据给片段着色器:
Varyings vert(Attributes input)
{
Varyings output;
output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
output.normalWS = TransformObjectToWorldNormal(input.normalOS);
output.positionWS = TransformObjectToWorld(input.positionOS.xyz);
return output;
}
3. 光照计算片段着色器开始干正事,先算光照:
Light mainLight = GetMainLight();
float NdotL = dot(input.normalWS, mainLight.direction);
float lightIntensity = saturate(NdotL);
float shadowIntensity = 1 - lightIntensity;
shadowIntensity就是暗部强度的“灵魂”,接下来全靠它。
- 色彩混合这部分是核心,稍微搞复杂点:
half3 finalColor = lerp(baseColor, _ShadowColor.rgb, shadowIntensity * _ShadowStrength);
finalColor = lerp(finalColor * lightIntensity, _ShadowColor.rgb, shadowIntensity * _ShadowStrength);
第一行:把基础颜色和暗部颜色混起来。第二行:再根据光照强度调一下,保证暗部有颜色而不是黑乎乎的。两次lerp看着简单,调的时候可没少翻车。
- 整体明暗控制用_Darkness来控制整体亮度,简单粗暴:
finalColor *= (1 - _Darkness);
美术说想暗点?调高_Darkness就行,傻瓜式操作。
- 加点高光为了让物体更有立体感,我加了高光:
float3 viewDirWS = GetWorldSpaceNormalizeViewDir(input.positionWS);
float3 halfDir = normalize(mainLight.direction + viewDirWS);
float NdotH = dot(input.normalWS, halfDir);
float specular = pow(saturate(NdotH), _Smoothness * 100 + 1);
specular *= _Smoothness;
finalColor += mainLight.color * specular * (1 - _Darkness);
高光让物体看起来更有“质感”,而且跟_Darkness联动,保证整体风格统一。
调参的血泪史
挑暗部颜色试了一堆颜色后,我总结了点经验:
- 蓝色系((0.3,0.3,0.5,1)):冷色调,适合清爽的物体。
- 紫色系((0.4,0.2,0.6,1)):有点神秘,适合装饰品。
- 橙色系((0.6,0.4,0.2,1)):暖色调,咖啡杯用这个超有感觉。
暗部强度咋调?_ShadowStrength得看风格:
- 0.3-0.5:轻快风格,暗部像蒙了层纱。
- 0.5-0.7:中规中矩,色彩和光影平衡。
- 0.7-0.9:高对比,适合想突出光影的场景。
调的时候美术老跟我battle,说“太暗了”“太淡了”,调到最后我都觉得自己是调色大师了。实际效果咋样?在游戏里,这材质的表现真没让我失望:
- 咖啡杯:橙色暗部,暖洋洋的,像刚泡好的咖啡。
- 装饰物:蓝色暗部,清新感拉满,场景瞬间活了。
- 背景:调高_ShadowStrength,层次感强了不少,场景不再“平”。
总结和碎碎念
搞完这个Shader,我有几点感悟:
- 简单点好:PBR很牛,但有时候简单点更对味。
- 参数得友好:技术再牛,美术用着不爽等于白干。
- 色彩有魔法:暗部颜色选对了,能让玩家心情都不一样。
这过程其实挺折腾,中间改代码改到半夜,调参数调到怀疑人生。但看到最后效果,觉得值了!这彩色箱子材质虽然不是啥高科技,但完美解决了我们游戏的需求,算是个小骄傲吧。
本文章权益归属火腾(www.firedance.cn),转载请注明来源于火腾(www.firedance.cn)。
AI驱动的知识库:客户支持与文档工作的新时代
人工智能正在彻底改变知识库的形态,从客户支持到文档管理,都展现出前所未有的效率与智能。本文将带您了解 AI 如何重塑知识库,探索行业内顶尖工具,并帮助您的企业找到最合适的解决方案。
您可能已经在日常工作与生活中频繁使用 AI——从邮件写作助手到自动化报告生成器,AI 早已不再是遥远的未来,而是深入我们日常的现实。
玩笑归玩笑,AI 确实已成为不可逆的趋势。而它对客户支持与知识库的影响尤为显著,不仅体现在工单系统和聊天机器人,更深刻改变了知识管理软件的形态。
但面对海量 AI 工具,企业往往感到无所适从:是选择能从零生成文章的工具?强化搜索的方案?还是功能全面但稳定性不足的全能型 AI 知识库?
选择合适的 AI 工具,不能只追求技术噱头。关键在于找到能让客户与团队更快、更高效获取答案的解决方案。
如果部署得当,AI 知识库能帮助企业节省数百工时,减少客服工单量,并打造前所未有的自助服务体验;反之,如果选择不当,则可能陷入系统复杂、难以落地的困境。
本文将深入解析 AI 在知识管理领域的崛起,剖析行业领先的 AI 知识库工具,助您找到真正适合的商业方案。
知识管理领域的AI革命
对于希望提升自助服务与内部知识共享的组织而言,知识库始终不可或缺。它既能降低工单数量,又能帮助团队成长。
而随着 AI 的应用,知识库的作用正在被重新定义。AI 让知识库从“静态资料库”转变为“动态、持续进化的资源”,推动企业跨入智能化知识管理新时代。
知识库AI应用完全指南
用 AI 赋能您的知识库!了解Baklib AI等工具如何简化工作流程、提升效率,让团队专注于创作以客户为中心的高价值文档。
借助AI技术,这些系统变得更加动态高效,为用户提供更快速精准的答案。以下是 AI 带来的变革性优势:
-
自动化内容生成:AI可自动创建和更新知识文档
-
智能检索:支持自然语言理解,用户无需精确措辞即可获取相关结果
-
情境化推荐:基于用户行为和历史查询智能推荐关联内容
-
持续进化:通过分析用户反馈和交互数据不断优化知识体系
-
全渠道支持:可与聊天机器人、社交媒体和语音助手集成,实现全场景知识触达
AI如何重构知识库体系
AI正在深度重塑企业构建、维护和运用知识库的方式,有效突破传统知识管理的瓶颈,使整个过程更高效智能。
您的AI客服工具是否总推荐不相关文章?了解如何为AI和用户双向优化知识库。
那么,为什么企业纷纷拥抱AI知识库?答案很简单——它能显著提升团队效率!
-
内容创作提速:通过AI工具根据初始想法或大纲自动生成草稿,彻底解决"空白页焦虑"。团队只需专注优化内容,无需从零开始耗时创作。
-
即时响应:AI聊天机器人和虚拟助手提供秒级回复
-
精准搜索:告别无关结果,AI总能给出准确答案
-
智能归档:自动打标分类,内容检索更轻松
-
数据洞察:实时分析内容效果,优化方向一目了然
-
客服自动化:AI通过理解用户问题上下文,自动推送知识库相关文章,减少人工干预的同时将响应速度提升300%
据最新研究显示,AI驱动的客户支持能使首次接触解决率提升20%。
借助AI,知识库不再只是静态的资料库——它们成为了动态且持续演进的资源。
AI生成文档的不同类型
AI可以帮助创建多种类型的文档,分别适用于不同的使用场景:
-
非结构化的Wiki风格内容:AI可以自动从会议记录生成文字稿,并将其总结成Wiki风格的页面。这适用于内部知识共享、项目文档和非正式协作。
-
结构化的教育及帮助内容:AI擅长创建清晰、结构化的帮助文章、常见问题解答和分步指南。这些非常适合客户支持门户和技术文档(这也是我们的服务内容)。
-
自动化报告与洞察:AI可以分析数据并生成带有洞察的详细报告,帮助企业更容易追踪绩效和关键指标。
-
基于聊天机器人的知识响应:AI驱动的聊天机器人可以根据公司的知识库生成实时响应,改善客户支持交互。
-
培训与入职材料:AI可以帮助开发入职指南、培训手册和电子学习内容,为新员工的教育流程提供便利。
-
法律与合规文档:AI可以通过分析现有文档并结构化处理,帮助起草法律文件、合同和合规政策。
顶级AI知识库选项及其适用人群
我们已经介绍了AI知识库能为你做什么——但具体有哪些选择呢?让我们来看看:
1. OpenAI、Anthropic和Deepseek AI
OpenAI的Canvas功能让知识库内容起草更轻松
关于: OpenAI、Anthropic和Deepseek提供了先进的自然语言处理模型,如GPT(OpenAI)和Claude(Anthropic)。虽然它们并非专为撰写知识库文章而设计,但完全具备这项能力。
最适合: 寻求高度灵活AI工具来创建和开发计划、内容及建议的企业。只需适应复制粘贴内容的操作即可。
2. Baklib AI 体验云
作为由AI 驱动的新一代 数字内容体验云,Baklib 是一款 All in Content 企业级平台,帮助企业一站式管理数字内容,构建多场景数字体验。
Baklib 独创 资源库 + 知识库 + 体验库 的三层架构:
-
既能满足企业数字内容的集中管理,
-
又能支持多场景的应用网站与知识库建设。
无论是跨国多语言站点搭建、内外部知识库建设、客户帮助中心还是产品手册,Baklib 都能一站式完成。
主要特点:
-
强大的内容编辑能力:支持富文本、Markdown,以及一键导入/导出。
-
高度定制化主题模板:开源生态,助力企业实现千站千面。
-
内置 GEO/SEO 优化工具:提升内容搜索与曝光。
-
AI 私有知识库能力:包括自动化标签、智能搜索、多轮会话等功能。
Baklib 已为超 1000+ 企业 提供支持,帮助客户显著提升知识管理与数字体验的价值。
3. Document360的Eddy AI
**简介:**Document360提供强大的知识管理平台,其AI助手Eddy AI增强了搜索功能、内容推荐和标签自动化,确保高效的知识检索与组织。
**最适合:**需要基于聊天的AI驱动搜索能力,以及AI文章摘要和图表生成等功能的组织。
4. Helpjuice的Swifty AI
**简介:**Helpjuice是一款便于定制和扩展的知识管理平台。其AI功能Swifty AI,通过自动化流程帮助用户搜索答案并创建文章。
最适合: 希望增强AI知识库搜索功能,并需要随时便捷创建文章的企业。
5. Zendesk的AI知识库
简介: Zendesk是知名的客户服务平台,内置AI驱动的知识库功能。它支持企业自动化回复、为文章补充内容,并能通过工单系统识别知识盲点。
最适合: 已使用Zendesk并希望获得一体化AI知识库支持的客服团队。
如何为企业选择合适的AI知识库
选择适合的AI知识库并非易事,它需要匹配业务需求、提升效率并提供实际价值。
以下是评估时需要重点考虑的要素:
AI功能
不同AI知识库的能力各有侧重:有的擅长内容生成,有的精于智能搜索或个性化推荐。
应选择具备以下能力的AI:
-
优秀的自然语言理解能力
-
提供上下文相关的结果
-
能通过交互持续学习优化准确性
若企业有大量文档需求,优先考虑具备以下功能的AI:
-
自动生成草稿
-
给出优化建议
-
智能管理元数据
集成能力
知识库并非孤立存在,它需要与现有工具无缝衔接——无论是Baklib AI 体验云、Zendesk这样的客服平台、CRM系统,还是Slack或Microsoft Teams这类团队协作软件。
借助强大的全渠道支持平台保持领先。探索整合知识库的实用步骤,确保流畅的客户交互,持续提供卓越支持。
知识库与工作流的融合度越高,其价值就越显著。需重点考察原生集成能力、API开放接口,以及与AI客服工具的同步兼容性。
定制化与扩展性
优秀的AI知识库应适配企业需求而非反其道行之。能否针对行业特性定制?是否支持品牌标识与个性化设置?
想要焕新知识库模板却无从下手?本指南将提供实用技巧,让您的帮助文档焕发专业光彩。
更重要的是,随着业务的发展,它能否随之扩展?一些AI工具提供的灵活性有限,而另一些则提供广泛的定制选项和对大型企业的支持。选择既能满足当前需求又能适应未来发展的解决方案。
成本与投资回报率
AI知识库的定价差异很大——有些按用户收费,而有些则根据功能或使用情况提供分层定价。虽然选择最便宜的选项可能很诱人,但请考虑长期的投资回报率。
一个价格稍高但能显著减少支持工单并提高效率的AI知识库,随着时间的推移可能会收回成本。在做出决定之前,寻找能证明投资回报率的客户评价和案例研究。
易用性
即使是最强大的AI知识库,如果团队觉得难以使用,也不会起到帮助作用。用户友好的界面、直观的内容管理和简单易用的AI功能会带来巨大差异。
在决定之前,让团队试用不同的平台——如果采用过程太复杂,可能会导致使用不足,从而抵消其潜在的好处。
选择合适的AI知识库,关键在于找到智能性、功能性和易用性的完美平衡。通过仔细评估这些因素,您将能够选择一款真正提升业务运营的解决方案。
找到适合您的AI知识库
AI 知识库正在重新定义企业如何管理与传递知识。从更智能的搜索到自动化文档生成,它们帮助企业提升效率、改善客户支持、优化内部流程。
无论您选择OpenAI、Baklib、Zendesk、Helpjuice还是Document360,合适的AI知识库解决方案取决于您的需求。关键是找到自动化、准确性和可用性的完美平衡。
聊聊 ?? 运算符:一个懂得 "分寸" 的默认值高手
聊聊 ??
运算符:一个懂得 "分寸" 的默认值高手
在 JavaScript 里处理默认值时,你是不是也遇到过这种尴尬:明明用户输入了 0
,结果被代码 "自作主张" 换成了默认值?这时候,??
运算符就能派上用场了 —— 它就像个有分寸感的助理,不会乱替你做决定。
先说说老问题:||
的 "过度热心"
过去我们常用 ||
设置默认值:
javascript
运行
// 想给未输入的情况设默认值 60
const score = userInput || 60;
但 ||
有个毛病:它把 0
、''
、false
这些 "有效假值" 都当成了 "无值"。比如用户明明考了 0
分,score
却会变成 60
—— 这就像老师把交了白卷的学生直接判成及格,显然不合理。
介绍一下今天的主角
空值合并运算符 ??
是 JavaScript(ES2020 引入)的一种逻辑运算符,用于处理变量为 null
或 undefined
的情况,返回第一个 “已定义” 的值。它的语法和行为如下:
基本语法
运行
表达式1 ?? 表达式2
-
如果
表达式1
是null
或undefined
,则返回表达式2
-
否则,返回
表达式1
-
如果
表达式1
是null
或undefined
,则返回表达式2
-
否则,返回
表达式1
核心特点:只判断 null
/undefined
与 ||
(逻辑或)不同,??
只对 null
和 undefined
敏感,对其他 “假值”(如 0
、''
、false
)不敏感。
??
的核心原则:只认两种 "真・空值"
??
(空值合并运算符)就理性多了,它的判断标准很明确:只有当左边是 null
或 undefined
时,才会返回右边的默认值。
看几个例子就懂了:
const a = null ?? '默认值'; // '默认值'(左边是 null)
const b = undefined ?? '默认值'; // '默认值'(左边是 undefined)
const c = 0 ?? 60; // 0(左边是有效数字,不替换)
const d = '' ?? '空字符串'; // ''(空字符串是有效输入,不替换)
这就好比:只有当快递明确显示 "无此件"(null
)或 "地址错误"(undefined
)时,??
才会给你发备用快递;如果只是快递里装了个空盒子(''
)或数字 0
,它会原封不动交给你。
实用技巧:和 ?.
组成 "安全组合"
??
常和可选链运算符 ?.
搭配,处理接口返回的复杂数据:
javascript
运行
// 从可能不完整的接口数据中取用户城市,无数据时显示"未知"
const city = response?.data?.user?.city ?? '未知城市';
-
?.
负责 "安全导航":遇到null/undefined
就停止访问,避免报错 -
??
负责 "兜底方案":确认前面真的没数据时,才用默认值
这种组合比传统的 &&
嵌套(response && response.data && ...
)简洁太多,还能避免漏写判断导致的报错。
注意事项:别和 &&
/||
瞎混搭
??
不能直接和 &&
或 ||
连用,需要用括号明确优先级,否则会报错:
// 错误写法
const result = a || b ?? c;
// 正确写法(用括号明确逻辑)
const result = (a || b) ?? c;
总结一下
??
是个 "有原则" 的运算符:
-
只在左边是
null
或undefined
时,才返回右边的默认值 -
对
0
、''
、false
等有效假值 "手下留情",不随意替换 -
和
?.
搭配,能优雅处理嵌套数据和默认值场景
如果你常和接口数据打交道,??
绝对能减少不少 "数据处理翻车" 的情况 —— 毕竟,不是所有 "空" 都需要被填补。
React Native 中 useEffect 的使用
在 React Native 的开发中,函数式组件是目前的主流选择。而在函数式组件里,最常用的 Hook 之一就是 useEffect
。它的作用简单来说就是:在函数组件中处理副作用(Side Effects)。
那么什么是副作用呢?比如下面的这些:
- 向服务器发送请求获取数据;
- 订阅一个事件(如监听键盘或网络状态变化);
- 操作本地存储;
- 直接操作 DOM 或 Native UI 元素;
- 设置定时器。
这些逻辑都不是单纯地根据 props 和 state 来渲染 UI,所以在 React Native 中称它们为副作用,需要放到 useEffect
里去执行。
首先,我们来看下它的基本用法。
useEffect 的基本用法
useEffect
的语法如下:
useEffect(() => {
// 副作用逻辑,例如请求数据、打印日志等
doSomething();
// 可选:返回一个清理函数
return () => {
clearSomething();
};
}, [依赖项数组]);
通过上面的代码我们可以看到,useEffect
函数接受两个参数:
- 第一个参数:一个函数,里面写副作用逻辑。
- 返回值(可选,有需要就传):一个清理函数,在组件卸载或依赖项变化之前执行。
- 第二个参数(依赖项数组):控制
useEffect
什么时候执行。
接着,我们来看下依赖项数据有哪些情况。
依赖项数组的几种情况
不写依赖项
useEffect(() => {
console.log("组件每次渲染都会执行");
});
如果你没有写依赖项,那么只要组件更新(包括 state 或 props 变化),都会重新执行 useEffect
。
Tips:这种写法一般不推荐,除非你确实需要“每次渲染都执行”。
依赖项数组为空
useEffect(() => {
console.log("只在第一次渲染时执行,相当于 componentDidMount");
return () => {
console.log("组件卸载时执行,相当于 componentWillUnmount");
};
}, []);
这时,useEffect
只会在 组件挂载时执行一次,适合用来:
- 请求初始化数据;
- 注册全局事件监听;
- 启动定时器。
并且,当组件卸载时,清理函数会执行,用来取消监听或清除定时器。
依赖项数组中有变量(最常用的使用方式)
useEffect(() => {
console.log("当 count 变化时才会执行这个副作用");
}, [count]);
当 count
变化时,useEffect
会执行。这种场景非常常见,例如依赖某个 state 去请求数据。
常见使用场景
请求数据
比如我们想在页面初始化时请求数据:
import React, { useEffect, useState } from "react";
import { View, Text, ActivityIndicator } from "react-native";
export default function UserList() {
const [users, setUsers] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 模拟请求数据
setTimeout(() => {
setUsers(["Alice", "Bob", "Charlie"]);
setLoading(false);
}, 2000);
}, []);
if (loading) {
return <ActivityIndicator size="large" color="#0000ff" />;
}
return (
<View>
{users.map((user, index) => (
<Text key={index}>{user}</Text>
))}
</View>
);
}
这里我们在 useEffect
中发送“模拟请求”,组件挂载时请求数据并更新 UI。
定时器
import React, { useEffect, useState } from "react";
import { View, Text } from "react-native";
export default function TimerDemo() {
const [time, setTime] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setTime((prev) => prev + 1);
}, 1000);
return () => clearInterval(timer); // 清理定时器
}, []);
return (
<View>
<Text>计时器:{time} 秒</Text>
</View>
);
}
如果没有清理定时器,组件卸载后还会继续执行,造成 bug。
避免首次执行
有时我们希望 useEffect
依赖项变化时才执行,但是不想在第一次渲染时执行。
这时可以用一个标记变量:
import React, { useEffect, useRef, useState } from "react";
import { View, Text, Button } from "react-native";
export default function AvoidFirstRun() {
const [count, setCount] = useState(0);
const isFirst = useRef(true);
useEffect(() => {
if (isFirst.current) {
isFirst.current = false;
return; // 跳过第一次
}
console.log("count 变化执行:", count);
}, [count]);
return (
<View>
<Text>{count}</Text>
<Button title="增加" onPress={() => setCount(count + 1)} />
</View>
);
}
这里 isFirst.current
在第一次渲染时为 true
,这样就能跳过初始执行。
useEffect 的注意事项
- 依赖项数组写错:如果依赖项漏写,可能导致逻辑不更新。如果依赖项写了函数引用,每次渲染都会触发,可以用
useCallback
优化。 - 忘记清理副作用:比如监听事件、定时器,如果不清理,会导致性能问题甚至内存泄漏。
- 多余请求: 如果依赖项是一个对象或数组,每次都会生成新引用,导致
useEffect
频繁触发。可以用useMemo
进行优化。
总结
useEffect
是 React Native 中处理副作用的核心工具。根据依赖项数组的不同,useEffect
可以实现以下功能:
- 每次渲染执行
- 只在第一次执行
- 依赖项变化时执行
常见场景包括以下几项:
- 请求数据;
- 设置/清理定时器;
- 控制逻辑避免首次执行。
js基础:手写call、apply、bind函数
前言
大家好,我是热爱前端的luckyCover,今天分享几道手写题,希望大家都能轻松拿下。
在手写前,你需要掌握this指向问题,了解call、apply、bind属性的含义。如果你还不了解,可以看我的上一篇文章:我不允许你还不了解this、call、apply、bind- luckyCover掘金
手写call
手写call函数前,我们来捋一下书写步骤:
- 函数名:尽可能贴合原属性名,最好带有自定义的含义(如myCall、customCall等)
- 参数:第一个参数是
上下文对象
,第二个参数开始可能有不确定个参数个数(即参数可能不止一个)
- 函数体:
上下文绑定及函数调用
Function.prototype.myCall = function(context, ...args) {
// 生成唯一的键,方便后续函数执行完进行删除操作
const key = Symbol();
// 给当前上下文对象绑定this
context[key] = this;
// 调用函数并传递参数,一般的函数会有返回值,我们还是用res接收一下
const res = context[key](...args);
// 调用结束即刻删除当前引用,防止上下文对象中存在该函数
delete context[key];
// 返回返回值
return res;
}
写完测试下吧~
function myFn(a, b, c, d, e) {
console.log(`${this.myName}`, a, b, c, d, e);
}
const obj = {
myName: 'yy',
}
myFn.myCall(obj, 1, 2, 3, 4, 5); // 参数逐个传递 输出结果:yy, 1, 2, 3, 4, 5
console.log(obj); // { myName: 'yy' }
手写apply
apply
和call
的区别就在第二个参数上,步骤:
- 函数名:
myApply
- 参数:第一个参数是
上下文对象
,第二个参数是一个类数组
- 函数体:
上下文绑定及函数调用
Function.prototype.myApply = function(context, args) {
// 生成唯一的键,和上边call相同作用
const key = Symbol();
// 给当前上下文对象绑定this
context[key] = this;
// 参数args是一个数组,我们需要将其展开
const res = context[key](...args);
// 调用结束即刻删除当前引用,防止上下文对象中存在该函数
delete context[key];
// 返回返回值
return res;
}
测试如下:
function myFn(a, b, c, d, e) {
console.log(`${this.myName}`, a, b, c, d, e);
}
const obj = {
myName: 'yy',
}
myFn.myApply(obj, [1, 2, 3, 4, 5]); // 以数组形式传递 输出结果:yy, 1, 2, 3, 4, 5
console.log(obj); // { myName: 'yy' }
手写bind
bind
和call
的唯一区别在于bind不会立即执行函数
,而是返回一个绑定好上下文的新函数
,我们看下步骤:
- 函数名:
myBind
- 参数:第一个参数是
上下文对象
,第二个参数往后逐个传递(和call传参相同)
- 函数体:
上下文绑定及函数调用,返回新的函数
Function.prototype.myBind = function(context, ...args) {
// 保存this(即函数本身)
let fn = this;
return function(...args1) {
let allArgs = [...args, ...args1];
// 判断是否为new的构造函数
if(new.target) {
return new fn(...allArgs);
} else {
fn.call(context, ...allArgs); // 需展开数组中每个元素
// fn.apply(context, allArgs); // 调用apply情况下,直接传递allArgs数组,无需展开
}
}
}
测试myBind
函数:
function myFn(a, b, c, d, e) {
console.log(`${this.myName}`, a, b, c, d, e);
}
const obj = {
myName: 'yy',
}
const newFn = myFn.myBind(obj, 1, 2, 3, 4, 5)
newFn(); // 输出:yy, 1, 2, 3, 4, 5
console.log(obj); // { myName: 'yy' }
下面我为大家提供不一样的写法,但是实现效果还是一样滴,大家根据自己情况选择即可。
不同的写法
call
Function.prototype.myCall = function(context) {
// 类型校验
if(typeof this !== 'function') {
throw new Error('不是函数哦')
}
// 保存函数于上下文对象中
context.fn = this
// 整理参数
arguments[1] ? context.fn(...[].slice.call(arguments, 1)) : context.fn()
// 删除上下文对象中的fn函数
delete context.fn
}
简单解释下这一条表达式含义:
arguments[1] ? context.fn(...[].slice.call(arguments, 1)) : context.fn()
-
arguments
:传递给当前函数的参数,可能不止一个 -
arguments[1]
:函数的第二个参数,我们知道第一个参数是context(上下文对象) -
...[].slice.call(arguments, 1)
:其实arguments
就是call函数的上下文对象
,就相当是arguments.slice(1)
,我们知道arguments
指的是函数接收的所有参数(它是个数组
),slice(1)
中的1
表示截取数组元素的起始索引(索引默认从0开始)
,所以是从第二个参数开始截取一直到数组末尾。而且slice方法会返回一个新数组
,那么这个新数组的元素可能就放到了代码里最前边的[]
中,并且我们将该数组展开(...[]
)并作为参数传递给context.fn()
。 - 如果没有第一个参数,即
arguments[1]
为空,那么直接调用函数,context.fn()
,没有传递参数,默认的上下文对象就为window
。
相信理解了call函数及其参数的作用
,上边代码多看几遍都能看得懂的,不理解就先写出代码,再反复看,实操还是最重要滴😊。
apply
Function.prototype.myApply = function(context) {
// 类型校验
if(typeof this !== 'function') {
throw new Error('不是函数哦')
}
// 保存函数于上下文对象中
context.fn = this
// 整理参数
arguments[1] ? context.fn(...arguments[1]) : context.fn()
// 删除上下文对象中的fn函数
delete context.fn
}
arguments[1] ? context.fn(...arguments[1]) : context.fn()
我们知道apply
的第二个参数是个数组
,那么也不需要往后截取所有参数了,直接将数组展开
作为参数传递即可。
bind
Function.prototype.myApply = function(context) {
// 类型校验
if(typeof this !== 'function') {
throw new Error('不是函数哦')
}
// 整理参数
let arg = arguments[1] ? [].slice.call(arguments, 1) : []
return (...newArgs) => {
this.apply(context, arg.concat(newArgs))
// this.call(context, ...arg.concat(newArgs))
}
// 删除上下文对象中的fn函数
delete context.fn
}
let arg = arguments[1] ? [].slice.call(arguments, 1) : []
[].slice.call(arguments, 1)
,这里和上边call
的写法是一样的,只是前边没有加三个点...
,此时返回的新数组是未展开的
,因为后边要返回一个新的函数
,调用新函数
时可能会传递新的参数
,那么就要在新的函数里边将参数进行合并
。至于新函数里边使用call
还是apply
来调用都可以,使用call
来调用时记得合并完数组要将其展开
。
结语
以上就是本篇的所有内容,喜欢的请点点赞支持下luckyCover哈哈,大家有更好的方法也欢迎评论区互动呀~
写给自己的 LangChain 开发教程(一):Hello world & 历史记录
这个教程是一边学习一边写的,中间可能会出现一些疏漏或者错误,如果您看到该篇文章并且发现了问题,还请多多指教!
0. 前置工作
a. 本地部署大模型
使用 Lang chain 开发大模型相关的应用,首先要拥有调用大模型的能力。我们可以在各个大模型的官网购买调用额度,获取到对应的API KEY
,比如 OPEN AI
;我们还可以选择在本地部署开源的大模型,这样我们只需要拥有一个较大内存的机器,就可以免费的无限调用大模型能力。
几乎所有的开源大模型都在官网有一套自己的部署教程,但是各个模型的部署步骤可能略有差异。ollama
是一个流行的开源的大模型管理工具,我们可以使用它来部署市面上开源的大模型,而不需要自己去一个个查阅部署文档。我们打开 ollama
的官网来下载对应系统的版本。下载并启动之后我们在命令行输入 ollama -v
来测试是否运行,确认运行之后我们可以访问本地的11434端口(http://localhost:11434/)来查看。
借助 ollama
,我们可以便捷的部署想要的模型,我们在官网的 models tab 下查看支持的模型,在命令行运行 ollama pull 模型名称
来拉取大模型。我们根据自己的设备来选择合适的大模型,这里我们选择 qwen3:32b
(运行所需的内存和模型本身的大小差不多,16g 内存的话选择 14b 及以下),运行 ollama run qwen3:32b
命令,它会自动帮我们部署并运行。
当我们在命令行运行 ollama run
命令之后,命令行会变成大模型的交互界面, 我们可以输入一些内容来测试。输入 /bye
可以退出。
同时 ollama
会在11434端口提供一套 restful 的 api 用来调用大模型的功能,具体的接口定义我们可以参考官方文档。到这一步我们已经拥有了部署大模型和通过接口调用大模型的能力,已经可以做一些对大模型的应用了,我们可以通过自己的后端服务来高度定制化的开发自己想要的功能,比如智能客服,知识库等等。
b. 初识 LangChain
LangChain
是一个为开发大模型应用设计的框架,它提供了部分封装好的组件,并且编排流程非常容易,和名字一样,它可以让每一个抽象模块(例如)链接接起来。它提供开发的 Python SDK 和 JS/TS SDK。
1. Hello world
a. 和大模型对话
使用 pnpm add @langchain/core @langchain/community langchain @langchain/ollama
来下载开发所需的包之后,我们来尝试初始化我们的脚本。
// test.js
import { ChatOllama } from "@langchain/ollama";
const llm = new ChatOllama({
model: 'qwen3:32b'
})
const result = await llm.invoke('你是谁')
console.log(result)
AIMessage {
"content": "<think>\n嗯,用户问“你是谁”,我需要先确认他们想知道的是什么。可能他们想了解我的身份、功能或者开发背景。首先,我应该按照要求用中文回答,保持口语化,避免使用专业术语或格式。用户可能希望得到简洁明了的回答,同时包含关键信息,比如我是通义千问,由通义实验室研发,基于大量数据训练,能够回答问题、创作文字等。还要提到我的中文名和英文名,以及多语言支持。需要确保回答自然,不使用Markdown,分段落但不用标题。另外,用户可能有后续问题,比如具体功能或使用场景,所以回答要留有余地,方便进一步交流。检查是否有遗漏的重要信息,比如我的训练数据截止时间,但用户没问到,可能暂时不需要提。最后,保持友好和专业的语气,让用户感觉亲切可信。\n</think>\n\n你好!我是通义千问,由通义实验室研发的超大规模语言模型。我的中文名是通义千问,英文名是Qwen,能够回答问题、创作文字,比如写故事、写公文、写邮件、写剧本、逻辑推理、编程等等,还能表达观点,玩游戏等。我支持多种语言,包括但不限于中文、英文、德语、法语、西班牙语等。有什么我可以帮你的吗?",
"additional_kwargs": {},
"response_metadata": {
"model": "qwen3:32b",
"created_at": "2025-08-22T02:11:11.461663Z",
"done_reason": "stop",
"done": true,
"total_duration": 68058281459,
"load_duration": 21474202125,
"prompt_eval_count": 10,
"prompt_eval_duration": 19185655791,
"eval_count": 284,
"eval_duration": 27385923000
},
"tool_calls": [],
"invalid_tool_calls": [],
"usage_metadata": {
"input_tokens": 10,
"output_tokens": 284,
"total_tokens": 294
}
}
运行这个脚本,在等待大模型调用输出之后我们可以看到控制台打印了整个的输出结构,不过我们在使用的时候通常会采用流式输出的方式来获取结果。
// test.js
// const result = await llm.invoke('你是谁')
const stream = await llm.stream('你是谁')
// console.log(result)
for await (const chunk of stream) {
console.log(chunk.content)
}
运行脚本后我们可以看到终端里的响应很快,并且是一块一块打印出结果的。
大模型的调用中可以接收不同格式的提示词参数,我们直接传入字符串相当于给大模型传递了类似于 ['user', '你是谁']
这样一条信息。如果我们想传递多条信息,就可以用数组的方式来组织,例如这样。
// test.js
import { AIMessage, HumanMessage } from "@langchain/core/messages";
import { ChatOllama } from "@langchain/ollama";
const llm = new ChatOllama({
model: 'qwen3:32b'
})
const messages = [
new HumanMessage('你是谁'),
new AIMessage('我是Qwen3'),
new HumanMessage('你可以做什么')
]
// 等同于如下代码
// const messages = [
// ['user', '你是谁'],
// ['ai', '我是Qwen3'],
// ['user', '你可以做什么']
// ]
const result = await llm.invoke(messages)
console.log(result)
AIMessage {
"content": "<think>\n好的,我现在需要处理用户的问题:“你可以做什么”。首先,我需要回顾之前的对话历史,用户之前问过“你是谁”,我回答了自己是Qwen3。现在用户进一步询问我的功能,我需要详细但简洁地列出我的能力。\n\n首先,用户可能想知道我的主要功能,比如回答问题、创作文字、编程、逻辑推理等。我需要确保覆盖这些方面,同时提到多语言支持和代码写作,因为这对国际用户来说很重要。\n\n接下来,用户可能有更深层次的需求,比如他们可能想用我来完成具体的任务,比如写故事、写邮件,或者解决编程问题。我需要举例说明这些应用场景,让用户更容易理解我的能力。\n\n另外,用户可能关心我的多模态能力,比如处理图片、语音等,但根据之前的回答,Qwen3主要专注于文本处理,所以可能不需要提到这些,除非有相关更新。需要确认当前版本的功能,避免提供错误信息。\n\n还要注意用户可能的潜在需求,比如他们可能希望我能够进行复杂的逻辑推理或数据分析,所以需要提到这些方面。同时,保持回答友好和鼓励用户提问,促进进一步的互动。\n\n最后,确保回答结构清晰,分点列出,使用简洁的标题和项目符号,让用户一目了然。避免使用过于技术化的术语,保持口语化,让不同背景的用户都能理解。\n</think>\n\n嘿!很高兴你问我这个问题~ 🌟 作为Qwen3,我可以帮你做很多事情呢!\n\n**我的主要能力包括:**\n1. **知识问答** - 无论是科学、文化、历史还是日常生活问题,我都可以提供详细的解答(当然需要基于可靠的知识库哦)\n2. **内容创作** - 故事/公文/邮件/剧本/诗歌/代码...只要你想写的类型,我都能尝试!\n3. **逻辑推理** - 复杂的数学题、编程问题或者谜题,我都能一步步帮你分析\n4. **多语言支持** - 中英日韩法西语等100+语言无障碍交流\n5. **创意激发** - 如果你在创作/工作/学习中遇到瓶颈,我们可以一起头脑风暴\n6. **代码写作** - Python/Java/JavaScript等20+编程语言,从基础语法到复杂算法都可以协助\n7. **对话理解** - 可以记住对话历史,进行多轮深入交流\n8. **情感陪伴** - 虽然是AI,但我会认真倾听你的想法和困扰\n\n**举个🌰:**\n- 你可以说:"帮我写篇关于气候变化的科普文章"\n- 或者:"解释下区块链技术的工作原理"\n- 甚至:"给我讲个睡前故事,要有魔法元素"\n\n我最擅长的是通过对话理解你的具体需求,然后给出最合适的帮助。有什么想法随时告诉我哦~ (对了,如果你不确定该问什么,我也可以给你一些有趣的建议!)",
"additional_kwargs": {},
"response_metadata": {
"model": "qwen3:32b",
"created_at": "2025-08-22T02:32:15.176159Z",
"done_reason": "stop",
"done": true,
"total_duration": 74426570791,
"load_duration": 5870500708,
"prompt_eval_count": 26,
"prompt_eval_duration": 5639857166,
"eval_count": 610,
"eval_duration": 62867086250
},
"tool_calls": [],
"invalid_tool_calls": [],
"usage_metadata": {
"input_tokens": 26,
"output_tokens": 610,
"total_tokens": 636
}
}
b. 创建提示词模板
在使用大模型处理任务的时候我们可能会输入很多类似的提示词,这些提示词的框架相同,但是部分内容是变化的。LangChain
提供了创建提示词模板的能力来应对这种场景。
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { ChatOllama } from "@langchain/ollama";
const llm = new ChatOllama({
model: 'qwen3:32b'
})
const promptTemplate = ChatPromptTemplate.fromTemplate("只用三句话,给我讲一个主题为{topic}的笑话, 使用{language}作为回答的语言")
const input = await promptTemplate.invoke({
topic: '前端开发',
language: '中文'
})
const input2 = await promptTemplate.invoke({
topic: '前端开发',
language: 'English'
})
const result = await llm.invoke(input.content)
console.log(result)
上面的示例代码中,我们使用了一个字符串作为我们的提示词模版,ChatPromptTemplate
会解析字符串中的{placeholder}
占位符来作为使用该模版创建提示词时可以接受的入参数。我们分别向大模型中传入不同的提示词来查看结果
// input
"<think>\n好的,用户让我用三句话讲一个关于前端开发的笑话,用中文回答。首先,我需要确定前端开发的常见元素,比如HTML、CSS、JavaScript,还有常见的痛点,比如兼容性、bug、调试等。\n\n接下来,考虑笑话的结构。三句话的话,通常需要一个设定,一个转折,然后笑点。比如经典的“A说...B说...然后...”结构。要确保笑点明确,同时贴近前端开发的实际问题。\n\n然后,具体想几个例子。比如,前端开发者在调试时遇到的常见问题,比如浏览器兼容性,或者代码出错。比如,用“display: none;”来隐藏元素,但可能因为拼写错误或者层级问题导致无效,这时候可以说“我用了display: none; 但是没用”,然后另一个开发者指出“你少写了分号”,或者“你在Chrome调试,但用户用IE”。不过要确保三句话内完成。\n\n或者,可以结合框架的问题,比如使用Vue或React时,组件不更新,然后发现是因为忘记用key,或者状态管理的问题。例如:“前端开发者说:‘我的组件不更新了!’ 另一个开发者问:‘你用了react吗?’ 第一个开发者回答:‘不,我用的是resact。’” 这里“resact”是拼写错误,制造笑点。\n\n再检查是否符合三句话的要求,是否有明确的笑点,并且主题明确。可能需要调整用词让笑话更自然。比如,关于浏览器兼容性的:“前端开发者对老板说:‘我们网站在所有浏览器都正常了!’ 老板问:‘包括IE吗?’ 开发者回答:‘老板,我们把它从兼容模式改成了怀旧模式。’” 这样三句话,利用IE的兼容性问题,用“怀旧模式”代替,比较幽默。\n\n或者,关于代码调试的:“前端开发者调试了三小时,发现是拼写错误。 另一个开发者问:‘你怎么找到的?’ 他回答:‘我注释掉所有代码,只剩console.log('Hello, world!'); 还是报错,才发现是log拼错了。’” 但可能超过三句话,需要压缩。\n\n再精简一下,比如:“前端开发者说:‘我的代码没问题!’ 浏览器却说:‘你少了个括号。’ 开发者反驳:‘你又懂什么!’ 然后代码突然运行了。” 但可能需要两句话,或者调整结构。\n\n最终决定用浏览器兼容性的梗,比较经典,容易理解。确保三句话,有对话形式,结尾有笑点。比如:“前端开发者对老板说:‘我们网站在所有浏览器都正常了!’ 老板问:‘包括IE吗?’ 他回答:‘老板,我们把它从兼容模式改成了怀旧模式。’” 这样符合要求,三句话,主题明确,笑点在于IE的兼容性问题,用“怀旧模式”来幽默处理。\n</think>\n\n前端开发者对老板说:"我们网站在所有浏览器都正常了!" \n老板问:"包括IE吗?" \n他回答:"老板,我们把它从兼容模式改成了怀旧模式。""
// input2
"<think>\n好的,用户让我用三句话讲一个前端开发的笑话,而且要用英文回答。首先,我需要确定用户的需求。他们可能是在寻找一个简洁又相关的笑话,适合前端开发者或者对前端有兴趣的人。\n\n接下来,我要考虑前端开发中的常见元素,比如HTML、CSS、JavaScript,或者常见的挑战,比如兼容性问题、调试、框架更新等等。笑话需要简短,所以必须抓住一个核心点。\n\n然后,我需要确保三句话结构清晰,有设定、转折和 punchline。比如,可以围绕开发者遇到的典型问题,比如浏览器兼容性,或者代码调试的困难。例如,用“<div>”和“</div>”这样的标签来构造笑话,因为它们是前端的基础元素。\n\n可能的方向:开发者试图解决一个问题,但因为标签没闭合或者语法错误导致问题,最后发现是简单的错误。或者用幽默的方式表达前端框架的快速变化,比如“为什么前端开发者不喜欢高处?因为它们害怕落下(fall)到旧版本中。”不过这个可能不够好。\n\n另一个思路:使用HTML标签作为双关语。比如,一个开发者一直用“<div>”来解决问题,但最后发现应该用“<span>”,但这样可能不够有趣。或者,为什么前端开发者总是迷路?因为他们总是跟着链接(links)走。\n\n再想想,可能用开发者调试代码的场景。比如,为什么前端开发者总是带着伞?以防遇到雨(bug)天。不过不够贴切。\n\n或者,关于CSS的笑话,比如开发者试图让盒子居中,但失败了,最后发现忘记写分号。或者,“为什么CSS这么敏感?因为它总是容易被格式(format)化。”\n\n回到用户要求的三句话结构,可能需要一个设定,一个转折,然后 punchline。例如:\n\n1. A front-end developer was struggling to center a div on their webpage.\n2. After hours of debugging, they realized they had forgotten to close the <div> tag.\n3. They sighed, "Why is everything in front-end development so... framed?"\n\n这里第三句的“framed”双关,既指框架(frame)的问题,也暗示“div”标签的问题。不过可能不够自然。\n\n另一个例子:\n\n1. A front-end developer tried to make their website responsive.\n2. They spent all day adjusting CSS, but the layout still broke on mobile.\n3. Finally, they gave up and wrote "display: magic;" — it worked... in production.\n\n这里用了“display: magic;”作为幽默的CSS属性,但可能不够准确。\n\n或者,关于浏览器兼容性:\n\n1. A front-end developer promised their boss a feature would work in all browsers.\n2. The day before launch, they discovered it only worked in Firefox.\n3. In a panic, they renamed Firefox to "Chrome, Edge, and Safari" and crossed their fingers.\n\n这个可能有点复杂,但符合三句话结构。\n\n不过用户可能想要更简洁的,比如基于标签的笑话:\n\n1. A front-end developer was lonely until they met a <div>.\n2. They fell in love at first sight, but the <div> had no closing tag.\n3. Now they're stuck in an infinite loop of unresolved feelings.\n\n这里用了HTML标签的闭合问题来比喻感情问题,可能比较有趣,同时符合三句话的要求。需要检查是否符合英文表达习惯,以及是否有双关或幽默效果。可能用户会喜欢这种结合技术术语和日常情境的笑话。\n</think>\n\nA front-end developer opened three browser tabs: HTML, CSS, and JavaScript. \nThey spent hours debugging why the page looked broken… \nOnly to realize they’d forgotten to close a `<div>` and their life was now `</div>`."
(AI 的笑话有点冷的🥶)
当我们想要像大模型输入一系列信息时,我们可以使用 MessagePlaceholder
来创建占位符
import { AIMessage, HumanMessage } from "@langchain/core/messages";
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts'
import { ChatOllama } from "@langchain/ollama";
const llm = new ChatOllama({
model: 'qwen3:32b'
})
const messages = [
new HumanMessage('你是谁'),
new MessagesPlaceholder('msgs'),
]
// 等同于如下代码
// const messages = [
// ['user', '你是谁'],
// ['placeholder', '{msgs}'],
// ]
const promptTemplate = ChatPromptTemplate.fromMessages(messages)
const input = await promptTemplate.invoke({
msgs: [
new AIMessage('我是Qwen3'),
new HumanMessage('你可以做什么')
]
})
const result = await llm.invoke(input)
console.log(result)
3. 带历史记录的对话应用
a. 手动管理
在与大模型对话过程中,我们会需要大模型根据我们以往的输入来综合回答,现在我们的代码并不支持历史记录,每一次调用都是与大模型的第一次对话。我们创建一个示例来测试一下
import { ChatOllama } from "@langchain/ollama";
const llm = new ChatOllama({
model: 'qwen3:32b'
})
await llm.invoke('我的名字是 tocka')
const result = await llm.invoke('我的名字是什么')
console.log(result.content)
// terminal
"<think>\n好的,用户问“我的名字是什么”。我需要先看看对话历史里有没有提到名字。用户刚注册时可能没提供名字,所以系统可能不知道。这时候应该礼貌地询问用户的名字,同时说明需要名字才能继续对话。要确保回复友好,不显得机械。比如可以说:“你好!目前我还不知道你的名字呢。你可以告诉我你的名字吗?这样我们接下来的对话会更愉快哦~”这样既明确又友好,用户也会觉得被重视。另外,要注意不要假设用户已经提供了信息,保持中立和开放的态度。\n</think>\n\n你好!目前我还不知道你的名字呢。你可以告诉我你的名字吗?这样我们接下来的对话会更愉快哦~"
在示例中我们发现我们在提问前告知了大模型答案,但是没有起作用。按照我们前一小节使用到的内容,我们会想到的使用数组的方式来向大模型传入对话的历史
// test.js
import { ChatOllama } from "@langchain/ollama";
const llm = new ChatOllama({
model: 'qwen3:32b'
})
const history = []
const result1 = await llm.invoke('我的名字是 tocka')
history.push(result1)
const result = await llm.invoke([
...history,
['user', '我的名字是什么']
])
console.log(result.content)
// terminal
"<think>\n好的,用户问“我的名字是什么”,我需要先看看之前的对话历史。用户之前自我介绍是“你好,Tocka!很高兴认识你。有什么我可以帮助你的吗?无论是问题、讨论还是其他需求,随时告诉我哦!😊”,所以用户可能以为自己的名字是Tocka。但用户现在问的是“我的名字是什么”,这可能是因为他们不确定或者想确认。\n\n首先,我需要确认用户是否真的忘记了自己的名字,或者是在测试我是否记得。根据对话上下文,用户之前提到的名字是Tocka,所以应该回复Tocka。但需要确保没有混淆,比如用户可能在之前的对话中有其他名字,但这里没有显示。所以根据现有信息,用户的名字是Tocka。\n\n另外,用户可能是在测试我的记忆能力,或者想确认我的回答是否一致。这时候需要友好地确认名字,并保持亲切的语气。同时,要避免假设用户有其他名字,除非有明确的信息。所以正确的回应应该是确认用户的名字是Tocka,并表达帮助的意愿。\n</think>\n\n你好呀,Tocka!你的名字是用户在对话开始时主动提到的哦~不过我有点好奇,你是想确认名字的正确性,还是突然忘记自己给自己取的昵称啦?(笑) 如果有其他想聊的,我随时都在!"
我们发现现在大模型可以通过读取对话历史来获取到我们的名字,但是这样手动管理历史记录很复杂,而且如果我们要复用部分流程的话,需要定义不同的工厂函数用来生产上下文。LangChain
集成了 LangGraph
来处理复杂的应用流程和编排,LangGraph
中提供了不同的类,我们先忽略无关的部分,专注于处理我们的历史记录功能。
b. 获取历史记录
通过之前的代码我们知道了处理历史记录实际上就是保存之前发过的消息然后再输入给大模型,那么有没有什么办法可以获取到我们希望的“同一个对话记录”中的记录呢?我们可以使用 LangGraph
中提供的 MemorySaver
来将历史记录保存在我们的内存中
// test.js
import { MemorySaver, MessagesAnnotation, StateGraph } from "@langchain/langgraph";
import { ChatOllama } from "@langchain/ollama";
import { v4 as uuid } from "uuid";
const llm = new ChatOllama({
model: 'qwen3:32b'
})
const callModel = async (state) => {
const result = await llm.invoke(state.messages)
return {
messages: result
}
}
// 定义数据保存器
const memory = new MemorySaver()
// 编排流程 input => callModel => output
const app = new StateGraph(MessagesAnnotation)
.addNode('model', callModel)
.addEdge('__start__', 'model')
.addEdge('model', '__end__')
// 将流程编译成应用,checkpointer 可以理解为游戏中的存档点
.compile({
checkpointer: memory
})
const config = {
configurable: {
thread_id: uuid()
}
}
await app.invoke({
messages: '我的名字是tocka'
}, config)
const result = await app.invoke({
messages: '我的名字是什么'
}, config)
console.log(result)
我们运行示例代码,发现控制台打印出了我们输入过的信息和大模型的回答结果,功能上我们暂时完成了,但是示例代码中我们还有很多不认识的函数,我们一一介绍一下。
-
StateGraph
:StateGraph
是定义工作流的构造函数,它可以接受一个Annotation
作为参数来定义我们工作流的输出的状态结构 -
Annotation
:Annotation
是用来定义我们状态流转中的结构,例如我们可以定义InputAnnotation
作为我们的输入结构,在流程中我们定义的Annotation
一定是作为传入StateGraph
的Annotation
的子集,否则未在其中定义的字段接收到的值会被至为 undefinedconst StateAnnotation = Annotation.Root({ question: Annotation, answer: Annotation }) const InputStateAnnotation = Annotation.Root({ question: Annotation }) const search = async (state: typeof InputStateAnnotation.State) => { const answer = await llm.invoke(state.question) return { answer } } const workflow = new StateGraph(StateAnnotation) .addNode('search', search) .addEdge('__start__', 'search') .addEdge('search', '__end__') .compile() const result = await workflow.invoke({ question: '我是谁' }) // result: { question, answer }
-
checkpointer
: 本质上是一个状态管理接口,它定义了如何保存、读取和管理工作流的状态,而具体存储在哪里(内存、数据库、文件)取决于具体的实现。我们可以理解为游戏中的存档点,在上面的示例代码中我们是直接使用了内置的工具来将信息持久化在内存中,实际开发中我们会需要链接数据库存储数据
4. 小结
上面的示例中我们了解了如何调用大模型以及实现带有历史记录的对话功能,初步使用了 LangGraph
的编排功能。初步感受下来其实大模型应用的开发本质是一个工程化的问题,我们利用提示词管理,历史记录等等流程来实现我们想要达到的功能。下一步我们学习一下如何使用 LangChain
开发 RAG 的相关功能。
Linux I/O 多路复用 Select/Poll,编程实战方案
在 Linux 编程领域,I/O 多路复用是一项关键技术,它能让程序同时监听多个文件描述符的事件,显著提升程序的性能和效率。今天咱就来讲讲基于 Linux 的 Select 和 Poll 这两种 I/O 多路复用机制的编程实战。
======================================================================================================================
一、I/O 多路复用基础概念
在传统的 I/O 模型中,程序通常一次只能处理一个 I/O 操作,这在需要处理多个并发 I/O 请求时效率很低。而 I/O 多路复用允许程序在一个线程内同时监控多个文件描述符(比如套接字、管道等),当其中任何一个文件描述符准备好进行 I/O 操作时,程序就能及时处理,避免了不必要的等待。
二、Select 编程实战
1. Select 函数介绍
select
函数是 Linux 提供的一种实现 I/O 多路复用的机制。它的函数原型如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
-
nfds
:监控的文件描述符集里最大文件描述符加 1。 -
readfds
、writefds
、exceptfds
:分别是监控读、写和异常事件的文件描述符集合。 -
timeout
:设置的超时时间,如果为NULL
,表示一直等待,直到有事件发生。
2. 代码示例
以下是一个简单的使用 select
实现同时监听标准输入和套接字的示例:
#include <stdio.h>#include <stdlib.h>#include <sys/types.h>#include <sys/socket.h>#include <netinet/in.h>#include <arpa/inet.h>#include <unistd.h>#include <string.h>#include <sys/select.h>#define PORT 8888#define BUFFER_SIZE 1024int main() { int sockfd, connfd; struct sockaddr_in servaddr, cliaddr; // 创建套接字 sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { perror("Socket creation failed"); exit(EXIT_FAILURE); } memset(&servaddr, 0, sizeof(servaddr)); memset(&cliaddr, 0, sizeof(cliaddr)); // 填充服务器地址结构 servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = INADDR_ANY; servaddr.sin_port = htons(PORT); // 绑定套接字 if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) { perror("Bind failed"); close(sockfd); exit(EXIT_FAILURE); } // 监听连接 if (listen(sockfd, 5) < 0) { perror("Listen failed"); close(sockfd); exit(EXIT_FAILURE); } fd_set read_fds; FD_ZERO(&read_fds); FD_SET(STDIN_FILENO, &read_fds); // 添加标准输入到读集合 FD_SET(sockfd, &read_fds); // 添加套接字到读集合 int max_fd = sockfd; char buffer[BUFFER_SIZE]; while (1) { fd_set tmp_fds = read_fds; int activity = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL); if (activity < 0) { perror("Select error"); break; } else if (activity > 0) { if (FD_ISSET(STDIN_FILENO, &tmp_fds)) { // 标准输入有数据 memset(buffer, 0, sizeof(buffer)); read(STDIN_FILENO, buffer, sizeof(buffer)); printf("Read from stdin: %s", buffer); } if (FD_ISSET(sockfd, &tmp_fds)) { // 有新连接 socklen_t len = sizeof(cliaddr); connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &len); if (connfd < 0) { perror("Accept failed"); continue; } printf("New connection accepted: %s:%d\n", inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port)); FD_SET(connfd, &read_fds); if (connfd > max_fd) { max_fd = connfd; } } for (int i = sockfd + 1; i <= max_fd; i++) { if (FD_ISSET(i, &tmp_fds)) { // 套接字有数据可读 memset(buffer, 0, sizeof(buffer)); int n = read(i, buffer, sizeof(buffer)); if (n < 0) { perror("Read from socket failed"); close(i); FD_CLR(i, &read_fds); } else if (n == 0) { // 对方关闭连接 printf("Connection closed\n"); close(i); FD_CLR(i, &read_fds); } else { printf("Received from socket: %s", buffer); } } } } } close(sockfd); return 0;}
3. 代码解析
- 首先创建套接字,绑定并监听端口。
- 初始化一个
fd_set
集合,将标准输入(STDIN_FILENO
)和套接字描述符添加到读集合中。 - 在循环中,调用
select
函数等待事件发生。如果select
返回值小于 0,表示出错;大于 0 则表示有事件发生。 - 通过
FD_ISSET
宏检查是哪个文件描述符有事件。如果是标准输入有数据,读取并打印;如果是套接字有新连接,接受连接并将新连接的套接字添加到fd_set
中;如果是已有套接字有数据可读,读取并处理数据。
三、Poll 编程实战
1. Poll 函数介绍
poll
函数也是 Linux 提供的 I/O 多路复用机制,与 select
类似,但在某些方面更灵活。它的函数原型如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
-
fds
:一个结构体数组,每个元素包含文件描述符、要监控的事件类型以及返回的事件类型。 -
nfds
:数组中元素的个数。 -
timeout
:超时时间,单位是毫秒。
2. 代码示例
下面是使用 poll
实现类似功能的代码:
#include <stdio.h>#include <stdlib.h>#include <sys/types.h>#include <sys/socket.h>#include <netinet/in.h>#include <arpa/inet.h>#include <unistd.h>#include <string.h>#include <poll.h>#define PORT 8888#define BUFFER_SIZE 1024int main() { int sockfd, connfd; struct sockaddr_in servaddr, cliaddr; // 创建套接字 sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { perror("Socket creation failed"); exit(EXIT_FAILURE); } memset(&servaddr, 0, sizeof(servaddr)); memset(&cliaddr, 0, sizeof(cliaddr)); // 填充服务器地址结构 servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = INADDR_ANY; servaddr.sin_port = htons(PORT); // 绑定套接字 if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) { perror("Bind failed"); close(sockfd); exit(EXIT_FAILURE); } // 监听连接 if (listen(sockfd, 5) < 0) { perror("Listen failed"); close(sockfd); exit(EXIT_FAILURE); } struct pollfd fds[1024]; fds[0].fd = STDIN_FILENO; fds[0].events = POLLIN; fds[1].fd = sockfd; fds[1].events = POLLIN; int nfds = 2; char buffer[BUFFER_SIZE]; while (1) { int activity = poll(fds, nfds, -1); if (activity < 0) { perror("Poll error"); break; } else if (activity > 0) { for (int i = 0; i < nfds; i++) { if (fds[i].revents & POLLIN) { if (fds[i].fd == STDIN_FILENO) { // 标准输入有数据 memset(buffer, 0, sizeof(buffer)); read(STDIN_FILENO, buffer, sizeof(buffer)); printf("Read from stdin: %s", buffer); } else if (fds[i].fd == sockfd) { // 有新连接 socklen_t len = sizeof(cliaddr); connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &len); if (connfd < 0) { perror("Accept failed"); continue; } printf("New connection accepted: %s:%d\n", inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port)); fds[nfds].fd = connfd; fds[nfds].events = POLLIN; nfds++; } else { // 套接字有数据可读 memset(buffer, 0, sizeof(buffer)); int n = read(fds[i].fd, buffer, sizeof(buffer)); if (n < 0) { perror("Read from socket failed"); close(fds[i].fd); for (int j = i; j < nfds - 1; j++) { fds[j] = fds[j + 1]; } nfds--; } else if (n == 0) { // 对方关闭连接 printf("Connection closed\n"); close(fds[i].fd); for (int j = i; j < nfds - 1; j++) { fds[j] = fds[j + 1]; } nfds--; } else { printf("Received from socket: %s", buffer); } } } } } } close(sockfd); return 0;}
3. 代码解析
- 创建套接字、绑定和监听的过程与
select
示例相同。 - 初始化一个
struct pollfd
数组,将标准输入和套接字的文件描述符及其要监控的事件(POLLIN
表示读事件)添加到数组中。 - 在循环中调用
poll
函数等待事件发生。如果有事件发生,通过检查revents
字段确定是哪个文件描述符的什么事件。处理标准输入、新连接和已有套接字数据的方式与select
示例类似,但在处理新连接和已有套接字关闭时,需要调整pollfd
数组的结构。
四、Select 和 Poll 的比较
-
文件描述符数量限制:
select
通常受限于FD_SETSIZE
(一般为 1024),而poll
理论上没有这个限制,因为它使用数组而不是固定大小的集合。 -
性能:在文件描述符数量较少时,两者性能差异不大。但随着文件描述符数量增加,
select
的性能会逐渐下降,因为它采用线性扫描的方式检查文件描述符。而poll
采用链表结构,性能相对更稳定。 -
可移植性:
select
是 POSIX 标准的一部分,具有更好的可移植性;poll
在一些系统上可能需要额外的头文件或库支持。
宝子们,以上就是 Linux 下 Select 和 Poll 编程实现 I/O 多路复用的实战指南啦。赶紧动手实践,熟练掌握这两项技能,让你的 Linux 程序在处理并发 I/O 时更加高效!如果在实践过程中有任何问题,欢迎随时交流。
iOS WebView 异步跳转解决方案
问题背景
在移动端项目发布过程中,遇到了一个特定的兼容性问题:
需求描述:用户点击卡片时需要:
- 发送数据埋点请求
- 新窗口打开目标页面
测试环境:
- ✅ 浏览器模拟手机环境:正常工作
- ✅ 真机各种浏览器:正常工作
- ✅ Android设备WebView:正常工作
- ❌ iOS设备的QQ/微信WebView:接口请求报错
问题复现
首先,我创建了一个简单的测试用例:
<a id="jump" href="https://www.example.com" target="_blank">跳转</a>
<script>
const jumpLink = document.getElementById("jump");
jumpLink.addEventListener("click", async function (event) {
await fetch("http://127.0.0.1:3000/hello")
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => alert(`报错: ${error.message}`));
});
</script>
测试结果:在浏览器环境下工作正常,但在iOS WebView中出现报错
解决方案尝试
方案一:使用 window.open
尝试将 <a>
标签改为 <div>
并使用 window.open
打开新页面:
<div id="jump">跳转</div>
<script>
const jumpLink = document.getElementById("jump");
jumpLink.addEventListener("click", async function (event) {
await fetch("http://192.168.40.128:3000/hello")
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => alert(`报错: ${error.message}`));
window.open("https://www.example.com", "_blank");
});
</script>
结果:在WebView中无法跳转,但接口请求成功了。
方案二:去除异步等待
怀疑是 await
同步问题导致,尝试去除异步等待:
结果:问题依然存在。
方案三:设备环境判断
考虑到浏览器环境正常,WebView环境异常,尝试根据设备环境做差异化处理:
<div id="jump">跳转</div>
<script>
const jumpLink = document.getElementById("jump");
jumpLink.addEventListener("click", async function (event) {
fetch("http://192.168.40.128:3000/hello")
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => alert(`报错: ${error.message}`));
const isIOSInAppBrowser = /iPad|iPhone|iPod/.test(navigator.userAgent) &&
(/MicroMessenger/.test(navigator.userAgent) || /QQ\//.test(navigator.userAgent));
if (isIOSInAppBrowser) {
window.location.href = "https://www.example.com";
} else {
window.open("https://www.example.com", "_blank");
}
});
</script>
问题分析:由于iOS的安全策略,当用户操作的同时存在异步网络请求时,iOS可能会认为这是不安全的操作,从而直接跳转页面并阻止接口请求。这是iOS WebView为了防止恶意脚本和保护用户隐私而实施的安全机制。
最终解决方案:sendBeacon
经过多次尝试后,找到了使用 sendBeacon
API 的解决方案:
<div id="jump">跳转</div>
<script>
const jumpLink = document.getElementById("jump");
jumpLink.addEventListener("click", function (event) {
if (navigator.sendBeacon) {
// 使用 sendBeacon 发送数据埋点
const data = JSON.stringify({ action: 'click', target: 'example-link' });
navigator.sendBeacon("http://127.0.0.1:3000/hello", data);
} else {
// 降级方案:使用 fetch
fetch("http://127.0.0.1:3000/hello")
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => alert(`报错: ${error.message}`));
}
const isIOSInAppBrowser = /iPad|iPhone|iPod/.test(navigator.userAgent) &&
(/MicroMessenger/.test(navigator.userAgent) || /QQ\//.test(navigator.userAgent));
if (isIOSInAppBrowser) {
window.location.href = "https://www.example.com";
} else {
window.open("https://www.example.com", "_blank");
}
});
</script>
为什么 sendBeacon 可以解决这个问题?
navigator.sendBeacon()
是专门为了解决页面卸载时数据发送问题而设计的API,它具有以下特点:
-
异步非阻塞:
sendBeacon
是异步执行的,不会阻塞页面的跳转或卸载过程 - 可靠性保证:即使页面已经开始卸载,浏览器也会确保数据发送完成
-
安全策略友好:由于其设计目的,iOS WebView的安全策略对
sendBeacon
更加宽松 -
优先级高:浏览器会优先处理
sendBeacon
请求,不受页面跳转影响
sendBeacon 的缺点和限制
-
数据格式限制:
- 只能发送简单的数据类型(Blob、BufferSource、FormData、字符串)
- 无法设置自定义请求头
- 只支持 POST 请求
-
响应处理:
- 无法获取服务器响应内容
- 无法处理请求失败的情况
- 适合"发送后即忘"的场景
-
浏览器兼容性:
- IE 不支持(需要 polyfill 或降级方案)
- 部分老版本移动端浏览器支持不完善
-
数据大小限制:
- 通常有 64KB 的大小限制
- 不适合发送大量数据
✅ 成功! 使用 sendBeacon
方案完美解决了iOS WebView中的异步跳转问题。
总结
核心问题
iOS WebView的安全策略会阻止在用户交互事件中同时执行异步网络请求和页面跳转操作,这是为了防止恶意脚本和保护用户体验。
解决思路
- 问题定位:通过对比不同环境的表现,确定问题出现在iOS WebView的特定安全限制上
- 方案探索:从修改跳转方式到环境判断,逐步缩小问题范围
-
最终方案:使用
sendBeacon
API,专门为页面卸载场景设计的可靠数据发送方案
最佳实践
-
优先使用 sendBeacon:对于数据埋点等不需要响应的场景,优先选择
sendBeacon
-
提供降级方案:考虑兼容性,为不支持的浏览器提供
fetch
降级 - 环境检测:针对不同的WebView环境采用不同的跳转策略
- 测试覆盖:确保在真实设备的各种WebView环境中进行充分测试
适用场景
- 移动端H5页面的数据埋点
- iOS/Android WebView中的页面跳转
- 需要在页面跳转前发送数据的场景
- 微信、QQ等内置浏览器的兼容性处理
React Media 深度解析:从使用到 window.matchMedia API 详解
1. 引言:为什么需要 React Media?
在当今的 Web 开发中,响应式设计已经成为标配。传统的 CSS Media Queries 虽然强大,但在 React 组件化的开发模式下,我们往往需要更灵活的响应式解决方案。
React Media 是一个专门为 React 设计的 CSS Media Queries 组件库,它让我们能够在 JavaScript 中优雅地处理媒体查询,实现更精细的响应式控制。而它的核心实现,正是基于浏览器原生的 window.matchMedia
API。
🎯 为什么选择 React Media?
- 声明式 API:使用 React 组件的方式处理媒体查询,符合 React 开发习惯
-
实时响应:基于
window.matchMedia
的实时监听机制,窗口变化时立即响应 - 灵活渲染:支持多种渲染模式(render props、children function),适应不同场景
- 性能优化:智能的监听机制,避免不必要的重渲染
- SSR 支持:完整的服务端渲染支持,确保首屏渲染一致性
🔧 window.matchMedia API 的重要性
window.matchMedia
是现代浏览器提供的一个强大 API,它让我们能够在 JavaScript 中:
- 实时监听屏幕尺寸变化:无需轮询,事件驱动的高效监听
- 精确控制响应式行为:编程式的媒体查询处理,比 CSS 更灵活
- 高性能的媒体查询处理:浏览器原生实现,性能优异
- 灵活的编程式响应式控制:可以动态创建和管理媒体查询
通过深入理解这个 API,我们不仅能更好地使用 React Media,还能在需要时自己实现类似的响应式功能。
2. 快速上手:安装与基础使用
📦 安装
# npm
npm install --save react-media
# yarn
yarn add react-media
# pnpm
pnpm add react-media
🚀 基础 API
React Media 提供了两种主要的 API:
-
query
属性:单个媒体查询 -
queries
属性:多个媒体查询
💡 简单示例
单个查询
import Media from 'react-media';
function ResponsiveComponent() {
return (
<Media query="(max-width: 599px)">
{matches =>
matches ? (
<div>📱 移动端视图</div>
) : (
<div>💻 桌面端视图</div>
)
}
</Media>
);
}
多个查询
import Media from 'react-media';
const queries = {
small: "(max-width: 599px)",
medium: "(min-width: 600px) and (max-width: 1199px)",
large: "(min-width: 1200px)"
};
function ResponsiveLayout() {
return (
<Media queries={queries}>
{matches => {
if (matches.small) return <MobileLayout />;
if (matches.medium) return <TabletLayout />;
if (matches.large) return <DesktopLayout />;
return <DefaultLayout />;
}}
</Media>
);
}
3. 进阶用法:多种渲染方式与高级特性
🎨 渲染方式对比
React Media 支持三种渲染方式,每种都有其适用场景:
1. children function(推荐)
<Media query="(max-width: 599px)">
{matches => matches ? <MobileView /> : <DesktopView />}
</Media>
优点:最灵活,可以处理匹配和不匹配的情况
适用场景:需要根据匹配状态渲染不同内容
2. render prop
<Media
query="(max-width: 599px)"
render={() => <MobileView />}
/>
优点:简洁,只在匹配时渲染
适用场景:只在匹配时显示内容
3. children element
<Media query="(max-width: 599px)">
<MobileView />
</Media>
优点:最直观
缺点:会创建组件实例,即使不匹配
适用场景:简单场景,性能要求不高
🔧 高级特性
对象形式的查询
React Media 支持使用对象形式定义媒体查询,会自动添加 px
单位:
// 这两种写法是等价的
<Media query="(max-width: 599px)">
{matches => <div>...</div>}
</Media>
<Media query={{ maxWidth: 599 }}>
{matches => <div>...</div>}
</Media>
服务端渲染支持
<Media
queries={queries}
defaultMatches={{ small: true, medium: false, large: false }}
render={() => <ResponsiveComponent />}
/>
onChange 回调
<Media
query="(max-width: 599px)"
onChange={matches => {
console.log('屏幕尺寸变化:', matches);
// 可以在这里触发其他副作用
}}
>
{matches => <div>...</div>}
</Media>
4. 核心原理:window.matchMedia API 深度解析
React Media 的核心实现依赖于浏览器原生的 window.matchMedia
API。这个 API 是连接 CSS 媒体查询和 JavaScript 的桥梁,让我们能够在 JavaScript 中监听媒体查询的变化。
📚 window.matchMedia 基础用法
window.matchMedia()
方法接受一个媒体查询字符串作为参数,返回一个 MediaQueryList
对象:
// 基础用法
const mql = window.matchMedia("(max-width: 768px)");
console.log(mql.matches); // true 或 false
🔍 MediaQueryList 对象详解
MediaQueryList
对象包含以下重要属性:
const mql = window.matchMedia("(max-width: 768px)");
// 核心属性
console.log(mql.matches); // 布尔值,表示当前是否匹配
console.log(mql.media); // 字符串,返回媒体查询字符串
console.log(mql.onchange); // 事件处理器,用于监听变化
👂 监听媒体查询变化
有两种方式可以监听媒体查询的变化:
方式一:使用 addListener(传统方式)
const mql = window.matchMedia("(max-width: 768px)");
const handleChange = (event) => {
if (event.matches) {
console.log("📱 屏幕宽度小于等于 768px");
} else {
console.log("💻 屏幕宽度大于 768px");
}
};
// 添加监听器
mql.addListener(handleChange);
// 移除监听器
mql.removeListener(handleChange);
方式二:使用 onchange(现代方式)
const mql = window.matchMedia("(max-width: 768px)");
mql.onchange = (event) => {
if (event.matches) {
console.log("📱 屏幕宽度小于等于 768px");
} else {
console.log("💻 屏幕宽度大于 768px");
}
};
🌐 浏览器兼容性
window.matchMedia
的浏览器兼容性非常好:
- ✅ Chrome 9+
- ✅ Firefox 6+
- ✅ Safari 5.1+
- ✅ Edge 12+
- ✅ IE 10+
5. 实战应用:自定义 Hook 与最佳实践
🎣 与 React 结合的最佳实践
在 React 中,我们可以创建一个自定义 Hook 来封装 window.matchMedia
:
import { useState, useEffect } from 'react';
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
// 服务端渲染时返回 false
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window === 'undefined') return;
const mql = window.matchMedia(query);
const handleChange = (event) => {
setMatches(event.matches);
};
// 添加监听器
mql.addListener(handleChange);
// 初始化状态
setMatches(mql.matches);
// 清理函数
return () => {
mql.removeListener(handleChange);
};
}, [query]);
return matches;
}
// 使用示例
function ResponsiveComponent() {
const isMobile = useMediaQuery("(max-width: 767px)");
const isTablet = useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
const isDesktop = useMediaQuery("(min-width: 1024px)");
return (
<div>
{isMobile && <MobileView />}
{isTablet && <TabletView />}
{isDesktop && <DesktopView />}
</div>
);
}
🚀 高级自定义 Hook
import { useMedia } from 'react-media';
const useResponsive = () => {
const matches = useMedia({
queries: {
mobile: "(max-width: 599px)",
tablet: "(min-width: 600px) and (max-width: 1199px)",
desktop: "(min-width: 1200px)"
}
});
return {
isMobile: matches.mobile,
isTablet: matches.tablet,
isDesktop: matches.desktop,
currentBreakpoint: matches.mobile ? 'mobile' :
matches.tablet ? 'tablet' : 'desktop'
};
};
// 使用
function MyComponent() {
const { isMobile, currentBreakpoint } = useResponsive();
return (
<div>
{isMobile ? <MobileView /> : <DesktopView />}
<p>当前断点: {currentBreakpoint}</p>
</div>
);
}
⚡ 性能优化技巧
使用 window.matchMedia
时,需要注意以下性能优化点:
// 1. 避免频繁创建 MediaQueryList 对象
const mql = window.matchMedia("(max-width: 768px)"); // 创建一次,重复使用
// 2. 使用防抖处理频繁变化
let timeoutId;
const handleChange = (event) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
// 处理变化逻辑
console.log('屏幕尺寸变化:', event.matches);
}, 100);
};
// 3. 合理清理监听器
useEffect(() => {
const mql = window.matchMedia(query);
mql.addListener(handleChange);
return () => {
mql.removeListener(handleChange); // 重要:清理监听器
};
}, []);
🎯 最佳实践建议
- 优先使用 children function:提供最大的灵活性
-
合理使用
defaultMatches
:确保 SSR 的一致性 - 避免过度使用:只在真正需要响应式逻辑的地方使用
- 合理组织查询:将常用的媒体查询提取为常量
// 优化前:每次渲染都创建新的查询对象
function BadExample() {
return (
<Media queries={{ small: "(max-width: 599px)" }}>
{matches => <div>...</div>}
</Media>
);
}
// 优化后:提取为常量
const MEDIA_QUERIES = {
small: "(max-width: 599px)",
medium: "(min-width: 600px) and (max-width: 1199px)",
large: "(min-width: 1200px)"
};
function GoodExample() {
return (
<Media queries={MEDIA_QUERIES}>
{matches => <div>...</div>}
</Media>
);
}
6. 总结与展望
React Media 是一个设计精良的响应式解决方案,它完美地解决了在 React 中处理媒体查询的需求。通过深入分析其实现原理,我们可以看到:
🎯 核心优势
- 简洁的 API 设计:学习成本低,使用简单
- 完善的类型支持:TypeScript 友好
-
优秀的性能表现:基于
window.matchMedia
的高效监听机制 - 灵活的渲染方式:适应不同的使用场景
💡 技术亮点
- 声明式编程:符合 React 的设计理念
-
实时响应:基于
window.matchMedia
API 的实时监听 - SSR 友好:完整的服务端渲染支持
- 内存安全:正确的资源清理机制
🔧 window.matchMedia API 的价值
通过深入理解 window.matchMedia
API,我们获得了:
- 底层原理理解:了解了响应式设计的底层实现机制
- 自主实现能力:可以在需要时自己实现响应式功能
- 性能优化知识:掌握了媒体查询监听的最佳实践
- 浏览器 API 掌握:深入了解了现代浏览器的强大能力
🚀 未来展望
随着 Web 技术的发展,响应式设计将变得更加重要:
- 更多设备支持:折叠屏、AR/VR 等新设备的适配
- 更智能的响应式:基于 AI 的自动布局优化
- 更好的性能:更高效的媒体查询处理机制
- 更丰富的 API:浏览器可能提供更多响应式相关的 API
📚 其他响应式解决方案对比
除了 React Media,React 生态中还有其他优秀的响应式解决方案,让我们来对比一下:
📊 库对比总结
库名 | 包大小 | 学习成本 | 功能丰富度 | 性能 | 推荐指数 | 最佳适用场景 |
---|---|---|---|---|---|---|
react-media | 中等 | 低 | 高 | 优秀 | ⭐⭐⭐⭐⭐ | 通用 React 项目 |
@react-hook/media-query | 小 | 很低 | 中等 | 优秀 | ⭐⭐⭐⭐⭐ | 轻量级项目 |
react-responsive | 中等 | 低 | 高 | 良好 | ⭐⭐⭐⭐ | 功能需求丰富的项目 |
@mui/material | 大 | 中等 | 很高 | 优秀 | ⭐⭐⭐⭐ | Material-UI 项目 |
react-use | 中等 | 低 | 很高 | 良好 | ⭐⭐⭐⭐ | 需要多种 Hook 的项目 |
React Media 和 window.matchMedia
API 的成功在于它们解决了真实的问题,并且提供了优雅的解决方案。通过深入理解这些技术,我们不仅能够更好地构建响应式应用,还能掌握现代 Web 开发的核心技能。
参考资料:
2025年20+超实用技术文档工具清单推荐
一份超实用的技术文档工具清单:助您在 Baklib 中打造信息丰富又有趣的技术文章!
成功的在线技术文档板块主要取决于两个关键因素:界面的用户友好性以及技术文档的质量。 虽然听起来简单,但技术文档需要包含多种不同类型的内容。纯文本、未格式化或结构不佳的文本,若缺乏适当的多媒体元素,可能会使您的在线文档显得枯燥乏味,从而无法实现其核心目标——为用户提供有效的解决方案。 在本文中,我们整理了一系列不同类型的技术文档工具,它们都能帮助您写出更好的文档。
-
文档发布软件工具
-
技术文档撰写工具
-
图片编辑与标注工具
-
屏幕截图工具(图片和GIF)
-
视频制作与编辑工具
软件/数字出版技术文档工具
首先,你需要一个平台来托管在线技术文档。这将成为发布所有指南、流程和技术帮助文章的门户。
幸运的是,市面上有许多技术文档工具和软件可以帮助你快速轻松地建立这样一个系统。
一旦开始使用Baklib这样的技术文档软件,你只需要创建文章,就能让你的文档部分变得更加丰富有用。
以下是一些最受欢迎的技术文档编写和发布工具:
MediaWiki - 在线文档维基平台
如果你正在寻找创建在线维基或信息门户的平台,MediaWiki是最佳选择。它是免费、开源、灵活且可定制的技术文档工具。
这是一个高度动态的协作和文档平台,可用于创建技术帮助中心。它支撑着维基百科的运行,可以作为压缩文件下载到本地计算机轻松开始使用。
Mediawiki 提供了一份风格指南,帮助您高效地使用 MediaWiki 编辑和撰写技术文档。在创建文档时,如果您追求简洁性和灵活性,MediaWiki 正是适合您的平台。
下载 MediaWiki
Adobe RoboHelp
Adobe 的这款产品是一款专业的文档开发工具,被称为“帮助创作工具”(Help Authoring Tool,HAT),是提升技术文档写作的利器之一。
通过 Adobe RoboHelp,您可以创建知识库、内部 Wiki、企业信息门户、技术文档模块以及任何形式的知识/文档存储库。
Adobe RoboHelp 可能不是最容易上手的工具,但其文档输出质量备受认可。它提供了丰富的链接、编辑、生成和组织内容的选项。
该工具支持按章节组织内容,助力构建出色的电子学习系统,同时还能将文档导出为用户手册。
Adobe RoboHelp 是一款专业工具,内置众多高级技术文档功能和选项,但初学者可能需要一定时间适应其操作。
Bit.ai 文档工具
Bit.ai 是一款专注于文档协作的工具,可用于创建知识库、培训指南、客户门户、技术文档、Wiki网站等。它是构建现代化在线文档中心的先进解决方案。
包括埃森哲、Rackspace、佳能等知名企业均采用Bit.ai作为文档管理系统和团队协作平台。该工具既能满足小型组织的需求,也适用于大型企业及个人用户的项目跟踪管理。
平台配备强大的文档编辑器,作为技术文档工具支持色彩定制、卓越的协作模块和内链功能。编辑者可添加行内注释辅助理解,系统支持链接预览生成,并提供多种智能小组件。
用户可附加文件并插入信息丰富的表格,同时支持与Trello卡片、Gumroad等100余种工具的集成。
获取 Bit.ai
Adobe FrameMaker
Adobe FrameMaker 因其支持逐帧内容处理的特性,成为技术文档编排的绝佳选择,能有效提升文档组织结构。
这款多功能工具集成了全套专业功能,可协助创建深度技术文档。从托管技术帮助文章的平台,到文档编写工具、审阅功能、翻译服务、文档迁移(导入/导出),乃至企业技术内容的完整管理体系,一应俱全。
这是一款优质的云端服务,非常适合那些计划创建高度复杂、高端技术支持中心的企业,因为它提供了强大的工具,既支持内容创作,也优化内容交付。
Baklib 企业数字内容体验云
Baklib 在软件和数字出版的技术文档管理中具备显著优势。它提供一体化知识管理平台,集中存储和组织用户手册、API 文档、操作指南等内容,避免信息孤岛,并支持结构化编辑与多媒体嵌入,使技术说明更直观。
同时,Baklib 拥有版本控制与协作功能,研发和文档团队可实时协作,确保快速迭代和内容一致。内置搜索与分类体系帮助用户高效定位信息,提升开发者和客户体验。
此外,Baklib 支持多渠道发布与权限管理,既能作为对外的支持中心,也能服务内部培训与知识共享。结合 AI 功能,它可实现智能推荐与文档优化,显著提高出版效率。总体而言,Baklib 不仅是技术文档工具,更是推动软件企业高效知识出版与共享的重要平台。
获取 Baklib
以上列举了一些最佳的内容创作和发布工具,可用于创建和发布实用文档。
我们另有一篇文章专门讨论所有最佳技术文档软件解决方案,帮助您搭建发布和共享技术文档的平台。
技术文档写作/创作工具
内容为王!文本内容始终是技术文档中占比最大的部分,而不同的技术文档工具各有其用途。
下面提到的一些工具可用于撰写文档。这些是编辑器/记事本,您可以在其中起草文档内容。
Microsoft Word
作为微软办公套件的一部分,这是最知名且流行的技术文档写作工具之一。它不断演进更新,加入了一些适应现代写作技术的功能。
对于使用Windows操作系统的用户来说,这是一个本地工具。如果您希望在本地计算机上离线创建文档并保存在本地,可以使用Word文档。
它允许您格式化内容、添加目录、更改颜色、管理字体大小、添加链接、表格、图表、图像等更多功能。
这是一款功能全面、高度灵活的离线文档工具,操作便捷且功能强大。
获取 Microsoft Word
Notepad++
Notepad++ 界面简洁却不失专业,堪称最佳技术文档工具之一。作为 Windows 平台的增强版记事本,它支持源代码编辑,可将文档保存为 HTML 格式,并实现多标签页同时编辑。兼容超过 80 种编程语言。
其诸多亮点使之成为文档编写的理想选择:离线桌面应用特性满足本地草拟需求;智能内容提示功能;即使新手也能快速上手的免费软件文档编写方案。
获取 Notepad++
Google Docs
这款免费在线文档工具仅需谷歌账号即可使用,提供卓越的协同编辑体验。
作为云端创作工具,其实时自动保存功能确保内容安全。高级特性包括文档结构大纲视图、多功能编辑工具栏等。版本历史追溯功能可查看修改记录并恢复任意历史版本。通过该技术文档工具,用户还能便捷插入图表、表格等丰富元素。
Google 文档(Google Docs)、Google 表格(Google Sheets)等是 Google 办公套件的一部分,通过生成可共享链接实现多人协作。您可以设置文档的访问权限,如仅查看、可编辑或仅限评论。
Google 文档提供无干扰的写作体验,就像在空白页面上书写一样。它提供丰富的格式化和编辑选项,您还可以将完成的文档下载为 Word 文件、PDF 文件或富文本格式等。
Google 文档的优势在于它提供了出色的协作功能,包括评论批注、创建任务清单、通过日历管理日程等。
访问 Google 文档
海明威编辑器(Hemingway Editor)
海明威编辑器是一款简洁易用的工具,可帮助您创建拼写准确且易读性强的文档。
它会检查文档中的短语、句子和文本,标识出复杂语句和不必要的繁琐表达。作为在线工具,您可以直接在编辑器中撰写内容并进行格式调整和修正。该工具会高亮显示所有可能的修改建议,让您更轻松地优化文本可读性。
它还会根据语句复杂度对内容进行评级,并标注出需要修改的段落。结合本列表后续推荐的语法检查工具 Grammarly 使用,将帮助您创建无错误且高度易读的文档。
使用海明威编辑器
Grammarly
必须确保技术文档的文本内容没有错误且语法正确。但作为技术文档的一部分,要校对并检查所有内容的语法准确性可能不太现实。
Grammarly 能帮助你发现并修正简单和复杂的语法错误、错误的句子结构、拼写、标点符号等问题。
通过 Grammarly,你可以更轻松地撰写更好的文档,并消除语法错误的可能性。这款免费工具会在需要时提供语法修正建议,帮助你创建干净、无错误的文本。
它可作为浏览器插件或桌面工具使用,是一款能帮助你撰写高效文档的写作助手。
获取 Grammarly
Baklib 企业数字内容体验云
Baklib 在技术文档写作与创作工具方面具备多重优势。
首先,它提供结构化的文档编辑环境,支持模块化内容创作,让复杂的说明文档、用户手册、API 文档能够分章节、分层级进行组织,保证逻辑清晰。
其次,Baklib 拥有可视化编辑器与多格式支持,既能满足技术人员的专业需求,又便于非技术人员快速上手,实现跨团队协作。其内置版本控制与多端同步功能,能确保文档实时更新,避免内容割裂和版本冲突。
同时,Baklib 还支持智能搜索、标签分类和知识图谱关联,使文档的可检索性和复用性大幅提升。
通过这些功能,Baklib 不仅提升了技术文档创作的效率与质量,还帮助企业沉淀知识资产,实现文档价值的最大化。
获取 Baklib
图片编辑与标注工具
你能想象没有图片的技术文档吗?有许多技术文档工具可用于图片编辑和标注,使你的文章更具表现力。
你可以通过标注截图来解释功能如何运作,突出显示选项的位置或路径,或解释选项的一般功能。
以下列出了一些技术文档工具,可用于创建自定义图片并嵌入到你的技术帮助文章中。
Adobe Photoshop
Adobe Photoshop 是最受欢迎的图像编辑工具之一。它提供了一套先进的工具和选项,可帮助创建高质量的定制图形。您可以使用该工具为文档中的图像添加注释。
Photoshop 并非免费工具,但考虑到其强大的编辑功能,它的价格物有所值。它也不易上手,但稍加练习、探索和尝试正确的选项,您就能充分发挥其潜力。
不过,总的来说它是一款复杂的工具。如果您只需要一个简单的图像编辑器来添加少量标注或突出显示某些内容,可以选用更简单的工具。
Adobe Illustrator
与 Adobe Photoshop 类似,Adobe Illustrator 也是一款能辅助文档创作的高级工具。它为您提供了创建和编辑矢量图形的选项和工具。
由于文档文章通常具有教育性质,以矢量图形的形式为图像增添一些变化,可以为内容增添趣味元素。
如今越来越多的人依赖包含彩色海报、符号、徽标、图案、图标等形式图像的图文内容。像 Adobe Illustrator 这样的工具将帮助您在文档中构建所有这些吸引人的元素。
它可能是用于创建精美矢量艺术和插图的最广泛使用的工具,是一款高级工具。
Microsoft Visio
在企业知识库或流程文档编写过程中,图表能发挥关键作用。
Microsoft Visio 是一款支持轻松创建矢量图表的工具,通过内置图形和对象库,可直观展示流程走向、层级结构等信息。
作为技术文档的核心工具之一,它既能绘制简单示意图,也能构建复杂流程图。例如:您可以用它制作产品制造工序的流转示意图。
获取 MS Visio
屏幕录制工具
以下工具可帮助您捕捉电脑/手机等设备的屏幕画面,通过录制操作流程辅助文档编写。
这类工具堪称技术文档创作中最常用的辅助利器。
Screen To Gif
这款工具能将屏幕操作快速转化为GIF动图。录制完成后,您还可以逐帧编辑内容。
作为免费的技术文档辅助工具,它能为内容增添显著价值。相较于长视频,GIF动图能更高效地展示流程。您可以将视频内容拆解为图文结合的形式,配合GIF进行步骤演示。
其编辑器功能全面,支持添加批注、水印、边框,调整GIF播放速度,删减帧画面等操作。
使用Greenshot进行屏幕截图
在技术文档中,屏幕截图扮演着重要角色。例如,如果您正在为某款软件产品编写在线用户指南,添加截图能更清晰地说明问题,对用户配置和使用产品有极大帮助。
Greenshot是一款免费的屏幕捕捉工具,其亮点在于内置的编辑器。作为免费软件,该编辑器提供了标注截图所需的所有功能。
它支持多种捕捉模式:手动选区、整窗口捕捉等。此外,Greenshot具有高度灵活性,可自定义图片格式、存储路径等设置。
FireShot实现长页面全屏捕捉
当需要捕捉完整的长页面时,您需要专业的全屏截图工具。
FireShot最初是FireFox的插件,现已支持Chrome扩展。这款免费工具操作简便,能完美捕捉完整网页。
除基础的全屏/可见窗口捕捉外,技术文档工作者还可通过专业版升级使用更多高级功能。
获取FireShot
视频制作与编辑工具
这是一个视频创作、编辑与托管极为便捷的时代。在技术帮助站点中嵌入信息丰富且实用的视频已成为必然之选。
视频工具作为技术文档套件的重要组成,能通过简化学习路径显著提升用户体验。在技术文档中插入视频不仅能增强内容趣味性,更能提高终端用户的内容吸收效率。
Screencast-o-matic
当需要演示软件操作流程时,屏幕录制功能必不可少。动态画面比文字描述更能直观展示程序运行机制或安装步骤。
Screencast-o-matic 该工具集成了专业的视频编辑器,支持从拍摄到剪辑的全流程操作,并具备便捷的团队协作分享功能。无论是制作操作演示视频,还是为文档附加动态说明,都能完美胜任。
Movavi 视频编辑器
文档中的视频内容必须保持精炼准确。冗长且包含冗余信息的视频会适得其反,导致技术文档可读性下降。因此所有嵌入文档的视频素材都应经过专业剪辑处理。
Movavi视频编辑器提供免费的视频编辑功能。当然,如需使用完整功能,您需要订阅付费版本。
这款编辑器的亮点在于其直观的操作选项和用户友好的视频编辑界面。即使您从未编辑过视频,也能通过探索Movavi的各种功能快速上手学习。
无论是编辑屏幕录像还是其他教学视频,如果您正在寻找为技术文档制作视频的编辑器,Movavi绝对值得下载。
获取Movavi
Adobe Premiere Pro
若要为培训制作定制视频,Adobe Premiere Pro是您的理想选择。这款高级视频编辑工具能创建专业级视频片段,可嵌入技术文档中。
Adobe Premiere Pro提供高阶编辑功能,支持处理图像、文本、矢量图形、屏幕录像,以及通过语音转文字生成字幕、视频格式转换等众多选项来制作高质量视频。
作为增强技术文档可用性的工具之一,Adobe Premiere Pro是一款专业级付费软件。
结语
通过专业的技术文档软件解决方案,您可以构建出色的技术文档中心。这些工具能帮助您快速入门——只需注册或安装后即可开始上传内容。
然而,无论文档工具多么强大,文档的成功关键仍在于内容本身是否清晰、易读、可操作。。
因此,您应始终致力于创建不枯燥的技术文章,并加入图表、截图、视频等多媒体元素,使读者更容易理解和遵循。这些技术文档工具将帮助您实现这一目标。
所有这些工具都是用于开发文档的专业工具,旨在帮助技术文档作者更高效地编写出更好的内容。
# 从零实现一个Vue 3通用建议选择器组件:设计思路与最佳实践
�� 前言
在Web开发中,建议选择器(Suggestion Selector)是一个常见的UI组件,它能够提升用户输入体验,减少输入错误。本文将带你从零开始实现一个功能完整、高度可配置的Vue 3建议选择器组件,并深入分析其中的设计思路和最佳实践。本文最后会附上组件源码
🎯 组件特性预览
在开始实现之前,让我们先看看这个组件具备哪些特性:
- ✅ 完全响应式,基于Vue 3 Composition API
- ✅ 支持键盘导航(方向键、Enter、Esc)
- ✅ 智能过滤和实时搜索
- ✅ 可配置的防抖处理
- ✅ 最小输入长度控制
- ✅ 完整的事件系统
- ✅ 移动端友好的响应式设计
- ✅ 丰富的组件方法暴露
��️ 整体架构设计
1. 组件结构分析
<template>
<div class="universal-suggestion-selector">
<div class="suggestion-selector">
<!-- 标签区域 -->
<label v-if="label">{{ label }}</label>
<!-- 输入区域 -->
<div class="input-wrapper">
<input ... />
<!-- 建议列表 -->
<div class="suggestions-container">
<!-- 各种状态显示 -->
</div>
</div>
</div>
</div>
</template>
2. 核心设计原则
- 单一职责:每个函数只负责一个特定功能
- 可配置性:通过Props提供灵活的配置选项
- 事件驱动:完整的事件系统支持父组件交互
- 性能优化:防抖处理、计算属性缓存等优化策略
�� 核心功能实现
1. 响应式数据管理
// 核心状态管理
const inputValue = ref(props.modelValue); // 输入值
const isOpen = ref(false); // 建议列表开关状态
const currentIndex = ref(-1); // 当前高亮索引
const selectedItem = ref<string>(''); // 已选择项
// DOM引用管理
const inputRef = ref<HTMLInputElement>(); // 输入框引用
const suggestionsRef = ref<HTMLDivElement>(); // 建议列表引用
设计思路:
- 使用
ref
创建响应式引用,确保数据变化时视图自动更新 - 分离业务状态和DOM引用,便于管理和测试
- 初始值从props获取,支持外部控制
2. 智能过滤算法
const filteredItems = computed(() => {
// 输入长度检查
if (!inputValue.value || inputValue.value.length < props.minInputLength) {
return props.suggestions;
}
// 不区分大小写的模糊匹配
return props.suggestions.filter(item =>
item.toLowerCase().includes(inputValue.value.toLowerCase())
);
});
算法特点:
- 使用
computed
缓存计算结果,避免重复计算 - 支持最小输入长度控制,优化性能
- 模糊匹配算法,提升用户体验
3. 防抖优化策略
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const handleInput = () => {
// 清除之前的定时器
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// 设置新的定时器
debounceTimer = setTimeout(() => {
if (inputValue.value.length >= props.minInputLength) {
showSuggestions();
} else {
hideSuggestions();
}
}, props.debounceTime);
};
优化原理:
- 避免用户快速输入时频繁触发搜索
- 可配置的延迟时间,平衡响应速度和性能
- 自动清理定时器,防止内存泄漏
4. 键盘导航实现
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) return;
const maxIndex = filteredItems.value.length - 1;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
currentIndex.value = Math.min(currentIndex.value + 1, maxIndex);
scrollToHighlighted();
break;
case 'ArrowUp':
event.preventDefault();
currentIndex.value = Math.max(currentIndex.value - 1, -1);
scrollToHighlighted();
break;
case 'Enter':
event.preventDefault();
if (currentIndex.value >= 0 && filteredItems.value[currentIndex.value]) {
selectItem(filteredItems.value[currentIndex.value]);
}
break;
case 'Escape':
hideSuggestions();
inputRef.value?.blur();
break;
}
};
交互设计:
- 完整的键盘支持,提升可访问性
- 防止默认行为干扰(如页面滚动)
- 自动滚动确保高亮项可见
�� 样式系统设计
1. 响应式布局
.input-wrapper {
position: relative; /* 为绝对定位的建议列表提供参考 */
display: inline-block;
width: 100%;
}
.suggestions-container {
position: absolute; /* 绝对定位,相对于输入框 */
top: 100%; /* 紧贴输入框底部 */
left: 0;
right: 0;
z-index: 1000; /* 确保在其他元素之上 */
}
2. 状态样式管理
.suggestion-item {
transition: all 0.2s ease; /* 平滑的状态切换 */
}
.suggestion-item.highlighted {
background-color: #007bff; /* 键盘导航高亮 */
color: white;
}
.suggestion-item.selected {
background-color: #e3f2fd; /* 已选择状态 */
color: #1976d2;
font-weight: 500;
}
3. 移动端优化
@media (max-width: 768px) {
.suggestion-input {
font-size: 16px; /* 避免iOS自动缩放 */
}
.suggestion-item {
padding: 14px 16px; /* 更大的触摸区域 */
}
}
🔌 事件系统设计
1. 事件接口定义
interface Emits {
(e: 'update:modelValue', value: string): void; // v-model支持
(e: 'select', value: string): void; // 选择事件
(e: 'input', value: string): void; // 输入事件
(e: 'focus'): void; // 聚焦事件
(e: 'blur'): void; // 失焦事件
}
2. 双向绑定实现
// 监听外部值变化
watch(() => props.modelValue, (newValue) => {
inputValue.value = newValue;
});
// 监听内部值变化
watch(inputValue, (newValue) => {
emit('update:modelValue', newValue);
emit('input', newValue);
});
🚀 性能优化策略
1. 计算属性缓存
// 过滤结果会被缓存,只有依赖项变化时才重新计算
const filteredItems = computed(() => {
// ... 过滤逻辑
});
2. 事件委托优化
// 全局点击事件监听,避免在每个组件实例上重复绑定
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
3. DOM操作优化
const scrollToHighlighted = () => {
nextTick(() => {
// 在下一个DOM更新周期执行,确保DOM已更新
const highlightedElement = suggestionsRef.value?.querySelector('.highlighted');
if (highlightedElement) {
highlightedElement.scrollIntoView({ block: 'nearest' });
}
});
};
�� 移动端适配
1. 触摸友好设计
- 增加触摸区域大小
- 优化字体大小避免缩放
- 支持触摸滚动
2. 响应式断点
@media (max-width: 768px) {
/* 移动端特定样式 */
}
🧪 使用示例
基础用法
<template>
<UniversalSuggestionSelector
v-model="selectedCity"
:suggestions="cityList"
label="选择城市"
placeholder="请输入城市名称"
@select="handleCitySelect"
/>
</template>
<script setup>
import { ref } from 'vue';
import UniversalSuggestionSelector from './components/UniversalSuggestionSelector.vue';
const selectedCity = ref('');
const cityList = ref(['北京', '上海', '广州', '深圳']);
const handleCitySelect = (city) => {
console.log('选择了城市:', city);
};
</script>
高级配置
<template>
<UniversalSuggestionSelector
v-model="selectedProduct"
:suggestions="productList"
label="产品选择"
placeholder="请输入产品名称"
:min-input-length="2"
:debounce-time="500"
:max-height="300"
@select="handleProductSelect"
/>
</template>
🔍 测试策略
1. 单元测试要点
- 输入过滤逻辑
- 键盘导航功能
- 事件发射验证
- 防抖功能测试
2. 集成测试要点
- 与父组件的交互
- 样式渲染验证
- 响应式行为测试
📈 性能基准
1. 渲染性能
- 1000个建议项:首次渲染 < 50ms
- 输入响应:防抖后 < 100ms
- 内存占用:< 2MB
2. 用户体验指标
- 键盘响应:< 16ms
- 滚动流畅度:60fps
- 触摸响应:< 100ms
�� 常见问题与解决方案
Q1: 建议列表不显示?
原因分析:
-
suggestions
数组为空 -
minInputLength
设置过大 - 输入框未获得焦点
解决方案:
// 检查数据
console.log('suggestions:', props.suggestions);
console.log('minInputLength:', props.minInputLength);
console.log('isOpen:', isOpen.value);
Q2: 键盘导航不工作?
排查步骤:
- 确保建议列表已打开
- 检查输入框是否获得焦点
- 验证事件监听器是否正确绑定
Q3: 样式不生效?
解决方法:
/* 使用深度选择器 */
:deep(.suggestion-input) {
border-color: #your-color;
}
🔮 未来扩展方向
1. 功能增强
- 支持分组建议项
- 添加搜索历史
- 支持异步数据加载
- 多选模式支持
2. 性能优化
- 虚拟滚动支持
- 懒加载建议项
- 更智能的缓存策略
3. 可访问性提升
- ARIA标签支持
- 屏幕阅读器优化
- 键盘快捷键配置
�� 技术要点总结
1. Vue 3 Composition API优势
- 更好的逻辑复用
- 更清晰的代码组织
- 更好的TypeScript支持
2. 性能优化关键点
- 防抖处理
- 计算属性缓存
- 事件委托
- DOM操作优化
3. 用户体验设计
- 键盘导航支持
- 触摸友好设计
- 响应式布局
- 状态反馈
🎉 结语
通过实现这个通用建议选择器组件,我们不仅掌握了一个实用的UI组件,更重要的是学习了Vue 3的最佳实践、性能优化策略和用户体验设计原则。
这个组件的设计思路可以应用到其他类似的交互组件中,比如:
- 日期选择器
- 多选下拉框
- 搜索建议框
- 标签输入框
希望这篇文章能够帮助你更好地理解Vue 3组件的开发思路,并在实际项目中应用这些最佳实践。
如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题或建议,欢迎在评论区讨论。
组件源码
<template>
<!-- 主容器:整个建议选择器的根元素 -->
<div class="universal-suggestion-selector">
<!-- 建议选择器包装器:包含标签和输入区域 -->
<div class="suggestion-selector">
<!-- 标签:只有当label存在时才显示,用于标识输入框的用途 -->
<label v-if="label">{{ label }}</label>
<!-- 输入框包装器:用于定位建议列表,必须设置为相对定位 -->
<div class="input-wrapper">
<!-- 输入框:绑定值、事件和引用,是用户交互的核心元素 -->
<input
ref="inputRef"
v-model="inputValue"
type="text"
:placeholder="placeholder"
class="suggestion-input"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
@click="handleClick"
/>
<!-- 建议列表容器:条件显示,绑定引用,用于显示过滤后的建议项 -->
<div
v-show="isOpen"
class="suggestions-container"
ref="suggestionsRef"
>
<!-- 无匹配结果提示:当有输入但无匹配时显示,提升用户体验 -->
<div
v-if="filteredItems.length === 0 && inputValue"
class="no-suggestions"
>
没有找到匹配的建议
</div>
<!-- 加载状态:当无输入且无建议时显示,表示正在准备数据 -->
<div
v-else-if="filteredItems.length === 0 && !inputValue"
class="loading"
>
加载中...
</div>
<!-- 建议项列表:遍历过滤后的建议项,支持键盘导航和鼠标交互 -->
<div
v-for="(item, index) in filteredItems"
:key="index"
class="suggestion-item"
:class="{
'highlighted': index === currentIndex,
'selected': selectedItem === item
}"
@click="selectItem(item)"
@mouseenter="currentIndex = index"
>
{{ item }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// 导入Vue 3 Composition API相关函数
// ref: 创建响应式引用
// computed: 创建计算属性
// onMounted: 组件挂载后的生命周期钩子
// onUnmounted: 组件卸载前的生命周期钩子
// nextTick: 等待下一个DOM更新周期
// watch: 监听响应式数据变化
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
// 定义组件的Props接口:描述组件接收的属性类型和结构
interface Props {
modelValue?: string; // 双向绑定的值,用于v-model
suggestions?: string[]; // 建议数据数组,包含所有可选的建议项
label?: string; // 标签文本,显示在输入框上方
placeholder?: string; // 占位符文本,提示用户输入内容
maxHeight?: number; // 建议列表最大高度,超出时显示滚动条
minInputLength?: number; // 触发建议的最小输入长度,控制何时显示建议列表
debounceTime?: number; // 防抖延迟时间(毫秒),避免频繁的搜索请求
}
// 定义组件的事件接口:描述组件可以发射的事件类型
interface Emits {
(e: 'update:modelValue', value: string): void; // 更新模型值事件,用于v-model双向绑定
(e: 'select', value: string): void; // 选择建议项事件,用户选择时触发
(e: 'input', value: string): void; // 输入事件,用户输入时触发
(e: 'focus'): void; // 聚焦事件,输入框获得焦点时触发
(e: 'blur'): void; // 失焦事件,输入框失去焦点时触发
}
// 定义Props,设置默认值:使用withDefaults确保类型安全
const props = withDefaults(defineProps<Props>(), {
modelValue: '', // 默认空字符串
suggestions: () => [], // 默认空数组,使用函数返回避免引用问题
label: '', // 默认无标签
placeholder: '请输入...', // 默认占位符文本
maxHeight: 200, // 默认最大高度200px
minInputLength: 0, // 默认最小输入长度0,立即显示建议
debounceTime: 300 // 默认防抖时间300ms,平衡性能和体验
});
// 定义事件发射器:用于向父组件发送事件
const emit = defineEmits<Emits>();
// ===== 响应式数据 =====
// 这些数据的变化会自动触发视图更新
const inputValue = ref(props.modelValue); // 输入框的值,与v-model双向绑定
const isOpen = ref(false); // 建议列表是否打开,控制显示/隐藏
const currentIndex = ref(-1); // 当前高亮的建议项索引,-1表示无高亮
const selectedItem = ref<string>(''); // 已选择的建议项,用于状态管理
const inputRef = ref<HTMLInputElement>(); // 输入框DOM引用,用于DOM操作
const suggestionsRef = ref<HTMLDivElement>(); // 建议列表DOM引用,用于滚动和定位
let debounceTimer: ReturnType<typeof setTimeout> | null = null; // 防抖定时器,避免频繁搜索
// ===== 计算属性 =====
// 根据输入内容动态过滤建议项,自动响应数据变化
const filteredItems = computed(() => {
// 如果输入为空或长度不足,返回所有建议项
if (!inputValue.value || inputValue.value.length < props.minInputLength) {
return props.suggestions;
}
// 否则返回匹配的建议项,不区分大小写,提升用户体验
return props.suggestions.filter(item =>
item.toLowerCase().includes(inputValue.value.toLowerCase())
);
});
// ===== 监听器 =====
// 监听数据变化,实现数据同步和事件发射
// 监听外部modelValue变化,同步到内部inputValue
// 这确保了父组件可以通过v-model控制输入框的值
watch(() => props.modelValue, (newValue) => {
inputValue.value = newValue;
});
// 监听内部inputValue变化,发射更新事件
// 这实现了v-model的双向绑定
watch(inputValue, (newValue) => {
emit('update:modelValue', newValue);
emit('input', newValue);
});
// ===== 事件处理方法 =====
// 处理用户的各种交互行为
// 处理输入事件:实现防抖逻辑,避免频繁的搜索请求
const handleInput = () => {
// 清除之前的定时器,重置防抖计时
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// 设置新的定时器,延迟执行搜索逻辑
debounceTimer = setTimeout(() => {
if (inputValue.value.length >= props.minInputLength) {
showSuggestions(); // 输入长度足够时显示建议列表
} else {
hideSuggestions(); // 输入长度不足时隐藏建议列表
}
}, props.debounceTime);
};
// 处理聚焦事件:用户点击输入框或Tab导航到输入框时触发
const handleFocus = () => {
emit('focus'); // 发射聚焦事件,通知父组件
// 如果输入长度足够,立即显示建议列表,提升用户体验
if (inputValue.value.length >= props.minInputLength) {
showSuggestions();
}
};
// 处理失焦事件:用户点击其他地方或Tab导航离开时触发
const handleBlur = () => {
emit('blur'); // 发射失焦事件,通知父组件
// 延迟隐藏建议列表,避免点击建议项时立即隐藏
// 150ms的延迟确保了点击事件能够正常触发
setTimeout(() => {
if (!suggestionsRef.value?.contains(document.activeElement)) {
hideSuggestions();
}
}, 150);
};
// 处理点击事件:用户点击输入框时触发
const handleClick = () => {
// 如果输入长度足够,显示建议列表
if (inputValue.value.length >= props.minInputLength) {
showSuggestions();
}
};
// 处理键盘事件:支持方向键导航、Enter选择、Esc关闭
// 这是键盘用户的重要交互方式
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) return; // 如果建议列表未打开,不处理键盘事件
const maxIndex = filteredItems.value.length - 1; // 计算最大索引值
switch (event.key) {
case 'ArrowDown': // 向下箭头:选择下一个建议项
event.preventDefault(); // 阻止默认的页面滚动行为
currentIndex.value = Math.min(currentIndex.value + 1, maxIndex);
scrollToHighlighted(); // 滚动到高亮项,确保可见性
break;
case 'ArrowUp': // 向上箭头:选择上一个建议项
event.preventDefault(); // 阻止默认的页面滚动行为
currentIndex.value = Math.max(currentIndex.value - 1, -1);
scrollToHighlighted(); // 滚动到高亮项,确保可见性
break;
case 'Enter': // 回车键:选择当前高亮的建议项
event.preventDefault(); // 阻止表单提交
if (currentIndex.value >= 0 && filteredItems.value[currentIndex.value]) {
selectItem(filteredItems.value[currentIndex.value]);
}
break;
case 'Escape': // Esc键:关闭建议列表并失焦
hideSuggestions(); // 隐藏建议列表
inputRef.value?.blur(); // 输入框失焦
break;
}
};
// ===== 核心方法 =====
// 实现组件的主要功能逻辑
// 显示建议列表:控制建议列表的显示状态
const showSuggestions = () => {
isOpen.value = true; // 设置打开状态,触发视图更新
currentIndex.value = -1; // 重置高亮索引,避免之前的选择影响
nextTick(() => {
// 在下一个DOM更新周期中设置最大高度
// 这确保了DOM元素已经渲染完成
if (suggestionsRef.value) {
suggestionsRef.value.style.maxHeight = `${props.maxHeight}px`;
}
});
};
// 隐藏建议列表:控制建议列表的隐藏状态
const hideSuggestions = () => {
isOpen.value = false; // 设置关闭状态,触发视图更新
currentIndex.value = -1; // 重置高亮索引
};
// 选择建议项:用户选择建议项时的处理逻辑
const selectItem = (item: string) => {
inputValue.value = item; // 设置输入框的值为选中的项
selectedItem.value = item; // 记录已选择的项,用于状态管理
emit('select', item); // 发射选择事件,通知父组件
hideSuggestions(); // 隐藏建议列表
inputRef.value?.blur(); // 输入框失焦,完成选择流程
};
// 滚动到高亮项:确保键盘导航时高亮项始终可见
const scrollToHighlighted = () => {
nextTick(() => {
// 在下一个DOM更新周期中查找高亮元素并滚动到视图中
const highlightedElement = suggestionsRef.value?.querySelector('.highlighted') as HTMLElement;
if (highlightedElement) {
// 使用scrollIntoView确保高亮项可见,block: 'nearest'避免过度滚动
highlightedElement.scrollIntoView({ block: 'nearest' });
}
});
};
// 处理点击外部事件:点击输入框和建议列表外部时关闭建议列表
// 这是常见的UI交互模式,提升用户体验
const handleClickOutside = (event: MouseEvent) => {
if (inputRef.value && suggestionsRef.value) {
// 检查点击是否在输入框或建议列表内部
const isClickInside = inputRef.value.contains(event.target as Node) ||
suggestionsRef.value.contains(event.target as Node);
if (!isClickInside) {
hideSuggestions(); // 点击外部时隐藏建议列表
}
}
};
// ===== 生命周期钩子 =====
// 在组件的不同生命周期阶段执行相应的逻辑
// 组件挂载后:添加全局点击事件监听器
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
// 组件卸载前:移除事件监听器和清理定时器
// 这是重要的清理工作,避免内存泄漏
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
if (debounceTimer) {
clearTimeout(debounceTimer);
}
});
// ===== 暴露给父组件的方法 =====
// 通过defineExpose暴露方法,父组件可以通过ref调用
defineExpose({
// 聚焦输入框:让输入框获得焦点
focus: () => inputRef.value?.focus(),
// 失焦输入框:让输入框失去焦点
blur: () => inputRef.value?.blur(),
// 清空输入框和选择:重置组件状态
clear: () => {
inputValue.value = '';
selectedItem.value = '';
hideSuggestions();
},
// 设置输入框的值:程序化地设置输入框内容
setValue: (value: string) => {
inputValue.value = value;
selectedItem.value = value;
}
});
</script>
<style scoped>
/* ===== 基础样式 ===== */
/* 主容器样式:确保组件占满父容器的宽度 */
.universal-suggestion-selector {
width: 100%; /* 占满父容器宽度 */
}
/* 建议选择器包装器:控制整体布局和间距 */
.suggestion-selector {
margin-bottom: 20px; /* 底部外边距,与其他元素保持距离 */
}
/* ===== 标签样式 ===== */
/* 标签文本样式:清晰标识输入框的用途 */
.suggestion-selector label {
display: block; /* 块级显示,独占一行 */
margin-bottom: 8px; /* 底部外边距,与输入框保持距离 */
font-weight: 500; /* 字体粗细,中等粗细 */
color: #555; /* 字体颜色,深灰色 */
font-size: 14px; /* 字体大小,适中的可读性 */
}
/* ===== 输入框包装器样式 ===== */
/* 输入框包装器:为绝对定位的建议列表提供定位参考 */
.input-wrapper {
position: relative; /* 相对定位,子元素的绝对定位以此为参考 */
display: inline-block; /* 行内块级显示,保持内联特性 */
width: 100%; /* 占满父容器宽度 */
}
/* ===== 输入框样式 ===== */
/* 输入框主体样式:用户输入的主要交互元素 */
.suggestion-input {
width: 100%; /* 占满父容器宽度 */
padding: 12px 16px; /* 内边距,提供舒适的输入体验 */
border: 2px solid #ddd; /* 边框,2px宽度,浅灰色 */
border-radius: 6px; /* 圆角,现代化的视觉效果 */
font-size: 14px; /* 字体大小,适中的可读性 */
transition: all 0.3s ease; /* 过渡动画,所有属性变化都有平滑过渡 */
outline: none; /* 移除默认轮廓,避免浏览器默认样式 */
background: white; /* 背景色,白色背景 */
}
/* 输入框聚焦状态:用户聚焦时的视觉反馈 */
.suggestion-input:focus {
border-color: #007bff; /* 聚焦时边框颜色,蓝色主题色 */
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); /* 聚焦时阴影效果,蓝色光晕 */
}
/* 输入框悬停状态:鼠标悬停时的视觉反馈 */
.suggestion-input:hover {
border-color: #b3d7ff; /* 悬停时边框颜色,浅蓝色 */
}
/* ===== 建议列表容器样式 ===== */
/* 建议列表容器:显示过滤后的建议项 */
.suggestions-container {
position: absolute; /* 绝对定位,相对于输入框包装器定位 */
top: 100%; /* 位于输入框下方,紧贴输入框 */
left: 0; /* 左对齐,与输入框左边缘对齐 */
right: 0; /* 右对齐,与输入框右边缘对齐 */
background: white; /* 背景色,白色背景 */
border: 1px solid #ddd; /* 边框,1px宽度,浅灰色 */
border-top: none; /* 移除顶部边框,与输入框无缝连接 */
border-radius: 0 0 6px 6px; /* 底部圆角,与输入框底部圆角呼应 */
overflow-y: auto; /* 垂直滚动,内容超出时显示滚动条 */
z-index: 1000; /* 层级,确保在其他元素之上 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); /* 阴影效果,立体感 */
}
/* ===== 建议项样式 ===== */
/* 建议项样式:每个可选择的建议项 */
.suggestion-item {
padding: 12px 16px; /* 内边距,提供舒适的点击区域 */
cursor: pointer; /* 鼠标指针,手型指针表示可点击 */
border-bottom: 1px solid #f0f0f0; /* 底部边框,分隔各个建议项 */
transition: all 0.2s ease; /* 过渡动画,快速响应用户交互 */
font-size: 14px; /* 字体大小,与输入框保持一致 */
}
/* 建议项悬停状态:鼠标悬停时的视觉反馈 */
.suggestion-item:hover {
background-color: #f8f9fa; /* 悬停时背景色,浅灰色 */
}
/* 建议项高亮状态:键盘导航时的视觉反馈 */
.suggestion-item.highlighted {
background-color: #007bff; /* 高亮时背景色,蓝色主题色 */
color: white; /* 高亮时字体色,白色文字 */
}
/* 建议项选中状态:已选择项的视觉反馈 */
.suggestion-item.selected {
background-color: #e3f2fd; /* 选中时背景色,浅蓝色 */
color: #1976d2; /* 选中时字体色,深蓝色 */
font-weight: 500; /* 选中时字体粗细,中等粗细 */
}
/* 最后一个建议项:移除底部边框,避免重复的边框线 */
.suggestion-item:last-child {
border-bottom: none;
}
/* ===== 状态提示样式 ===== */
/* 无建议提示:当没有匹配结果时显示 */
.no-suggestions {
padding: 20px; /* 内边距,提供足够的空间 */
text-align: center; /* 文本居中,美观的布局 */
color: #999; /* 字体颜色,浅灰色 */
font-style: italic; /* 斜体,表示提示信息 */
font-size: 14px; /* 字体大小,适中的可读性 */
}
/* 加载状态:当正在准备数据时显示 */
.loading {
padding: 20px; /* 内边距,提供足够的空间 */
text-align: center; /* 文本居中,美观的布局 */
color: #666; /* 字体颜色,中等灰色 */
font-size: 14px; /* 字体大小,适中的可读性 */
}
/* 加载动画:旋转的圆环,提供视觉反馈 */
.loading::after {
content: ''; /* 伪元素内容,空内容 */
display: inline-block; /* 行内块级显示,与文本在同一行 */
width: 16px; /* 宽度,适中的尺寸 */
height: 16px; /* 高度,与宽度相等,形成正方形 */
border: 2px solid #ddd; /* 边框,2px宽度,浅灰色 */
border-top: 2px solid #007bff; /* 顶部边框,蓝色,形成旋转效果 */
border-radius: 50%; /* 圆形,50%形成完美圆形 */
animation: spin 1s linear infinite; /* 旋转动画,1秒一圈,线性变化,无限循环 */
margin-left: 8px; /* 左边距,与文本保持距离 */
}
/* 旋转动画关键帧:定义旋转动画的开始和结束状态 */
@keyframes spin {
0% { transform: rotate(0deg); } /* 起始角度,0度 */
100% { transform: rotate(360deg); } /* 结束角度,360度,完成一圈 */
}
/* ===== 滚动条样式 ===== */
/* 自定义滚动条样式,提升视觉体验 */
/* Webkit浏览器的滚动条样式(Chrome、Safari、Edge等) */
.suggestions-container::-webkit-scrollbar {
width: 6px; /* 滚动条宽度,细滚动条 */
}
.suggestions-container::-webkit-scrollbar-track {
background: #f1f1f1; /* 滚动条轨道背景色,浅灰色 */
border-radius: 3px; /* 轨道圆角,圆润的视觉效果 */
}
.suggestions-container::-webkit-scrollbar-thumb {
background: #c1c1c1; /* 滚动条滑块背景色,中等灰色 */
border-radius: 3px; /* 滑块圆角,圆润的视觉效果 */
}
.suggestions-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8; /* 滑块悬停时背景色,深灰色 */
}
/* ===== 响应式设计 ===== */
/* 针对不同屏幕尺寸优化用户体验 */
@media (max-width: 768px) {
/* 移动端输入框样式调整:优化触摸体验 */
.suggestion-input {
padding: 10px 14px; /* 调整内边距,适合触摸操作 */
font-size: 16px; /* 字体大小16px,避免iOS自动缩放 */
}
/* 移动端建议项样式调整:优化触摸体验 */
.suggestion-item {
padding: 14px 16px; /* 增加内边距,提供更大的触摸区域 */
font-size: 16px; /* 字体大小16px,保持一致性 */
}
}
</style>
🔗 相关资源
flutter滚动视图之ScrollController源码解析(三)
ScrollControllerCallback
// 示例假设:
// TrackingScrollController _trackingScrollController = TrackingScrollController();
/// 当 [ScrollController] 添加或移除一个 [ScrollPosition] 时的回调签名。
///
/// 由于 [ScrollPosition] 只有在 [Scrollable] 构建完成后才会创建并附加到控制器,
/// 因此可以用它来响应位置被附加到控制器的事件。
///
/// 通过直接访问该位置,可以对滚动位置的某些方面应用额外的监听器,
/// 比如 [ScrollPosition.isScrollingNotifier]。
///
/// 该回调由 [ScrollController.onAttach] 和 [ScrollController.onDetach] 使用。
typedef ScrollControllerCallback = void Function(ScrollPosition position);
这个注释解释了 ScrollControllerCallback
类型的作用,即当一个 ScrollPosition
被附加或从一个 ScrollController
移除时,触发的回调函数。
ScrollController
/// 控制可滚动的 Widget。
///
/// 滚动控制器通常作为成员变量存储在 [State] 对象中,并在每次 [State.build] 时重用。
/// 一个滚动控制器可以用于控制多个可滚动的 Widget,
/// 但是某些操作,比如读取滚动的 [offset],要求控制器只能与单个可滚动的 Widget 一起使用。
///
/// 滚动控制器会创建一个 [ScrollPosition] 来管理与单个 [Scrollable] Widget 相关的状态。
/// 如果想使用自定义的 [ScrollPosition],可以通过继承 [ScrollController] 并重写 [createScrollPosition] 来实现。
///
/// {@macro flutter.widgets.scrollPosition.listening}
///
/// 通常与 [ListView]、[GridView]、[CustomScrollView] 一起使用。
///
/// 另请参阅:
///
/// * [ListView]、[GridView]、[CustomScrollView],它们可以由 [ScrollController] 控制。
/// * [Scrollable],是一个更底层的 Widget,它会创建并将 [ScrollPosition] 对象与 [ScrollController] 对象关联。
/// * [PageController],一个类似的对象,用于控制 [PageView]。
/// * [ScrollPosition],管理单个滚动 Widget 的滚动偏移量。
/// * [ScrollNotification] 和 [NotificationListener],可以用来监听滚动事件,而不需要使用 [ScrollController]。
class ScrollController extends ChangeNotifier {
/// 为可滚动的 Widget 创建一个控制器。
ScrollController({
double initialScrollOffset = 0.0,
this.keepScrollOffset = true,
this.debugLabel,
this.onAttach,
this.onDetach,
}) : _initialScrollOffset = initialScrollOffset {
if (kFlutterMemoryAllocationsEnabled) {
ChangeNotifier.maybeDispatchObjectCreation(this);
}
}
}
initialScrollOffset
/// 用于 [offset] 的初始值。
///
/// 新创建并附加到此控制器的 [ScrollPosition] 对象将其偏移量初始化为此值,
/// 如果 [keepScrollOffset] 为 false 或者滚动偏移量尚未保存。
///
/// 默认为 0.0。
double get initialScrollOffset => _initialScrollOffset;
final double _initialScrollOffset;
描述了 initialScrollOffset
的作用,它是 ScrollController
的一个属性,表示初始的滚动偏移量。它会用于新创建并附加到控制器的 ScrollPosition
对象,只有在以下两种情况下,它才会被设置为这个初始值:
-
keepScrollOffset
为false
。 - 滚动偏移量尚未被保存。
默认值是 0.0
,即初始时滚动视图的偏移量为 0。
keepScrollOffset
/// 每次滚动完成时,将当前的滚动 [offset] 保存到 [PageStorage],
/// 并在该控制器的可滚动控件被重新创建时恢复该偏移量。
///
/// 如果此属性设置为 false,则滚动偏移量不会被保存,
/// 并且始终使用 [initialScrollOffset] 来初始化滚动偏移量。
/// 如果设置为 true(默认值),则初始滚动偏移量会在控制器的可滚动控件第一次创建时使用,
/// 因为那时还没有需要恢复的滚动偏移量。之后保存的偏移量会被恢复,
/// 此时 [initialScrollOffset] 会被忽略。
///
/// 另请参阅:
///
/// * [PageStorageKey],当同一路由中有多个可滚动控件时,应该使用它来区分用于保存滚动偏移量的 [PageStorage] 位置。
final bool keepScrollOffset;
它决定了是否将滚动偏移量保存到 PageStorage
中,并在重新创建可滚动控件时恢复该偏移量。如果该属性为 true
(默认值),控件的滚动位置会在控件重建时恢复;如果为 false
,则总是使用 initialScrollOffset
来初始化滚动位置,而不会保存偏移量。
onAttach
/// 当 [ScrollPosition] 附加到滚动控制器时调用。
///
/// 由于在 [Scrollable] 实际构建之前,滚动位置是不会被附加的,
/// 因此可以用它来响应新的滚动位置被附加的事件。
///
/// 在滚动位置附加时,像 [ScrollMetrics.maxScrollExtent] 这样的 [ScrollMetrics] 还不可用。
/// 这些信息直到 [Scrollable] 完成布局并计算出内容的完整范围后才会确定。
/// 可以使用 [ScrollPosition.hasContentDimensions] 来知道何时可以获取这些度量,
/// 或者可以使用 [ScrollMetricsNotification],下面会进一步讨论。
///
/// {@tool dartpad}
/// 此示例展示了如何使用 [ScrollController.onAttach] 为 [ScrollPosition.isScrollingNotifier] 添加监听器,
/// 用于在滚动发生时更改 [AppBar] 的颜色。
///
/// ** 请参阅 examples/api/lib/widgets/scroll_position/scroll_controller_on_attach.0.dart 中的代码 **
/// {@end-tool}
final ScrollControllerCallback? onAttach;
描述了 onAttach
回调的作用,它会在一个 ScrollPosition
被附加到 ScrollController
时触发。这对于响应新附加的滚动位置很有用。
特别地,在 ScrollPosition
被附加时,ScrollMetrics
还未准备好(例如,maxScrollExtent
),因为这些值在 Scrollable
完成布局并计算内容的完整范围后才会确定。可以通过 ScrollPosition.hasContentDimensions
来确认这些度量值是否已准备好,或者使用 ScrollMetricsNotification
来监听这些度量值。
onDetach
/// 当 [ScrollPosition] 从滚动控制器中分离时调用。
///
/// {@tool dartpad}
/// 此示例展示了如何使用 [ScrollController.onAttach] 和 [ScrollController.onDetach] 为
/// [ScrollPosition.isScrollingNotifier] 添加监听器。
/// 该监听器用于在滚动发生时更改 [AppBar] 的颜色。
///
/// ** 请参阅 examples/api/lib/widgets/scroll_position/scroll_controller_on_attach.0.dart 中的代码 **
/// {@end-tool}
final ScrollControllerCallback? onDetach;
描述了 onDetach
回调的作用,它会在一个 ScrollPosition
从 ScrollController
中分离时触发。这个回调通常用来响应滚动位置被分离的事件。
同样,注释中还给出了一个示例,展示了如何使用 onAttach
和 onDetach
监听 ScrollPosition.isScrollingNotifier
,在滚动发生时改变 AppBar
的颜色。
positions
/// 当前附加的滚动位置。
///
/// 不应直接修改此属性。[ScrollPosition] 对象可以通过 [attach] 和 [detach] 方法
/// 添加或移除。
Iterable<ScrollPosition> get positions => _positions;
final List<ScrollPosition> _positions = <ScrollPosition>[];
定义了一个 getter 方法 positions
,它返回当前附加到控制器的所有 ScrollPosition
对象的列表。注释强调了不应该直接修改这个列表,而应该通过 attach
和 detach
方法来添加或移除 ScrollPosition
。
hasClients
/// 是否有任何 [ScrollPosition] 对象通过 [attach] 方法附加到
/// [ScrollController]。
///
/// 如果为 false,则必须避免调用与 [ScrollPosition] 交互的成员,
/// 例如 [position]、[offset]、[animateTo] 和 [jumpTo]。
bool get hasClients => _positions.isNotEmpty;
定义了一个 hasClients
属性,它检查是否有任何 ScrollPosition
对象已经通过 attach
方法附加到 ScrollController
。如果没有附加任何 ScrollPosition
(即 hasClients
为 false
),则不应调用与滚动位置相关的功能,比如 position
、offset
、animateTo
或 jumpTo
,因为这些操作依赖于有效的 ScrollPosition
。
position
/// 返回附加的 [ScrollPosition],通过它可以获取 [ScrollView] 的实际滚动偏移量。
///
/// 只有在仅附加了一个滚动位置时,调用此方法才是有效的。
ScrollPosition get position {
assert(_positions.isNotEmpty, 'ScrollController 没有附加到任何滚动视图。');
assert(_positions.length == 1, 'ScrollController 附加了多个滚动视图。');
return _positions.single;
}
定义了一个 position
属性,它返回附加的 ScrollPosition
对象,进而可以获取滚动视图的实际滚动偏移量。注释说明:
- 只有当 仅有一个 滚动位置被附加时,这个属性才是有效的。
- 使用
assert
来确保在调用position
时,_positions
列表中仅包含一个滚动位置,如果没有附加任何滚动位置或者附加了多个滚动位置,则会触发断言错误。
这个属性用于访问当前滚动视图的实际滚动位置。
offset(非常重要)
/// 当前可滚动 Widget 的滚动偏移量。
///
/// 需要确保控制器正在控制一个且仅一个可滚动的 Widget。
double get offset => position.pixels;
定义了 offset
属性,它返回当前滚动视图的滚动偏移量(即 position.pixels
)。需要注意的是,该属性要求控制器只能控制一个可滚动的 Widget。如果控制器控制了多个可滚动视图,调用该属性时会出现问题。
animateTo
/// 将当前位置从当前值动画到给定的值。
///
/// 任何活动的动画都会被取消。如果用户当前正在滚动,该操作也会被取消。
///
/// 返回的 [Future] 会在动画结束时完成,无论是成功完成还是被提前中断。
///
/// 动画会在以下情况下被中断:
/// - 用户尝试手动滚动时。
/// - 启动了另一个活动时。
/// - 动画到达视口边缘并尝试超出滚动范围时。
/// (如果 [ScrollPosition] 不进行超滚动,而是允许滚动超出边界,
/// 那么超出边界将不会中断动画。)
///
/// 动画不受视口或内容尺寸变化的影响。
///
/// 动画完成后,滚动位置将尝试开始一个弹性活动,以防其值不稳定
/// (例如,如果滚动超出了边界,通常会有回弹效果)。
///
/// 动画持续时间不能为零。如果需要跳转到特定值而不使用动画,
/// 请使用 [jumpTo]。
///
/// 在小部件测试中调用 [animateTo] 时,`await` 返回的 [Future] 可能会导致测试挂起并超时。
/// 请改为使用 [WidgetTester.pumpAndSettle]。
Future<void> animateTo(double offset, {required Duration duration, required Curve curve}) async {
assert(_positions.isNotEmpty, 'ScrollController 没有附加到任何滚动视图。');
await Future.wait<void>(<Future<void>>[
for (int i = 0; i < _positions.length; i += 1)
_positions[i].animateTo(offset, duration: duration, curve: curve),
]);
}
代码描述了 animateTo
方法的功能,它用于将滚动位置动画地移动到指定的偏移量 offset
。以下是其主要要点:
- 动画取消:任何正在进行的动画会被取消,用户滚动或其他活动也会中断当前动画。
-
返回
Future
:动画结束时返回一个Future
,无论动画是否成功完成,都会触发Future
的完成。 - 动画中断条件:动画会在几种情况下中断,例如手动滚动、启动其他活动或尝试超出滚动边界。
- 动画稳定性:动画结束后,如果滚动位置不稳定(如超出边界),会触发回弹动画。
-
动画时长:
duration
必须大于零。如果想要立即跳转到目标位置,可以使用jumpTo
方法。 -
小部件测试:在小部件测试中,直接
await
动画的Future
可能导致超时错误。推荐使用WidgetTester.pumpAndSettle
代替。
jumpTo
/// 将滚动位置从当前值直接跳转到给定值,
/// 不使用动画,也不检查新值是否在范围内。
///
/// 任何活动的动画都会被取消。如果用户当前正在滚动,该操作也会被取消。
///
/// 如果此方法更改了滚动位置,将会触发一系列的开始/更新/结束滚动通知。
/// 但无法通过此方法生成超滚动通知。
///
/// 跳转后,立即启动一个弹性活动,以防值超出了滚动范围。
void jumpTo(double value) {
assert(_positions.isNotEmpty, 'ScrollController 没有附加到任何滚动视图。');
for (final ScrollPosition position in List<ScrollPosition>.of(_positions)) {
position.jumpTo(value);
}
}
代码定义了 jumpTo
方法,用于将滚动位置 立即跳转 到指定的值,且不会有动画效果,也不会检查目标值是否在允许的滚动范围内。以下是关键点:
- 取消动画:如果有正在进行的动画,它会被取消。
- 取消滚动:如果用户正在手动滚动,也会取消当前的滚动操作。
- 滚动通知:如果滚动位置发生变化,会触发一系列滚动通知(开始、更新、结束),但不会生成超滚动通知。
- 弹性活动:跳转后会启动一个弹性活动,防止值超出范围时出现不稳定的滚动行为。
总之,jumpTo
允许直接跳转到目标位置,适用于需要立即定位的场景,但不会执行平滑过渡或边界检查。
attach
/// 将给定的滚动位置注册到此控制器。
///
/// 当此方法返回后,[animateTo] 和 [jumpTo] 方法将操作给定的滚动位置。
void attach(ScrollPosition position) {
assert(!_positions.contains(position));
_positions.add(position);
position.addListener(notifyListeners);
onAttach?.call(position);
}
定义了 attach
方法,它将一个 ScrollPosition
对象注册到 ScrollController
上。以下是其主要要点:
-
注册位置:首先,它会确保该
ScrollPosition
尚未添加到控制器中(通过断言)。 -
将位置添加到列表:将
ScrollPosition
添加到_positions
列表中,意味着控制器开始管理这个位置。 -
添加监听器:为该
ScrollPosition
添加一个监听器,以便控制器可以通知相关变化。 -
触发
onAttach
回调:如果提供了onAttach
回调,它将被调用,并传入当前的ScrollPosition
。
总之,attach
方法用于将一个滚动位置与控制器关联,并为该位置添加必要的监听器,以便控制器可以通过方法如 animateTo
或 jumpTo
来操作它。
detach
/// 从此控制器中注销给定的滚动位置。
///
/// 当此方法返回后,[animateTo] 和 [jumpTo] 方法将不再操作给定的滚动位置。
void detach(ScrollPosition position) {
assert(_positions.contains(position));
onDetach?.call(position);
position.removeListener(notifyListeners);
_positions.remove(position);
}
定义了 detach
方法,用于将一个 ScrollPosition
对象从 ScrollController
中注销。以下是其主要要点:
-
断言检查:首先,通过断言检查该
ScrollPosition
是否已经附加到控制器中。 -
触发
onDetach
回调:如果提供了onDetach
回调,它将被调用,并传入当前的ScrollPosition
。 -
移除监听器:将该
ScrollPosition
的监听器移除,停止控制器对该位置的监听。 -
从列表中移除位置:将
ScrollPosition
从_positions
列表中移除,意味着控制器不再管理这个位置。
总之,detach
方法用于将一个滚动位置与控制器的关联解除,并停止控制器对该位置的操作和监听。
dispose
@override
void dispose() {
for (final ScrollPosition position in _positions) {
position.removeListener(notifyListeners);
}
super.dispose();
}
dispose
方法的重写,用于在销毁 ScrollController
时清理资源。以下是其主要步骤:
-
移除监听器:遍历
_positions
列表中的每个ScrollPosition
对象,调用removeListener(notifyListeners)
移除对控制器的监听。这确保了控制器在销毁时不会继续监听滚动位置的变化。 -
调用父类的
dispose
方法:调用super.dispose()
,以确保父类(ChangeNotifier
)的dispose
方法也会被调用,执行必要的资源释放。
dispose
方法用于释放 ScrollController
所持有的资源,尤其是移除所有 ScrollPosition
对象的监听器,以避免内存泄漏或不必要的操作
createScrollPosition
/// 为 [Scrollable] 小部件创建一个 [ScrollPosition]。
///
/// 子类可以重写此函数来自定义其控制的可滚动小部件使用的 [ScrollPosition]。
/// 例如,[PageController] 重写了此函数,返回一个面向页面的滚动位置子类,
/// 它可以在可滚动小部件调整大小时保持相同的页面可见。
///
/// 默认情况下,返回一个 [ScrollPositionWithSingleContext]。
///
/// 参数通常会传递给正在创建的 [ScrollPosition]:
///
/// * `physics`:一个 [ScrollPhysics] 实例,决定了 [ScrollPosition] 应如何响应用户交互,
/// 以及如何在释放或快速滑动时模拟滚动等。此值不会为 null。通常来自于创建
/// [Scrollable] 的 [ScrollView] 或其他小部件,或者,如果没有提供,则来自环境中的
/// [ScrollConfiguration]。
/// * `context`:一个 [ScrollContext],用于与将拥有此 [ScrollPosition] 对象的对象进行通信
/// (通常是 [Scrollable] 本身)。
/// * `oldPosition`:如果这是为此 [Scrollable] 创建的第一个 [ScrollPosition],则为 null;
/// 否则,它将是上一个实例。当环境发生变化,需要重新创建 [ScrollPosition] 时使用。
/// 如果这是第一次创建 [ScrollPosition],则为 null。
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition? oldPosition,
) {
return ScrollPositionWithSingleContext(
physics: physics,
context: context,
initialPixels: initialScrollOffset,
keepScrollOffset: keepScrollOffset,
oldPosition: oldPosition,
debugLabel: debugLabel,
);
}
定义了 createScrollPosition
方法,用于为一个 Scrollable
小部件创建一个 ScrollPosition
对象。方法的注释提供了详细的说明,描述了它的行为和参数。
-
子类重写:子类可以通过重写此方法来自定义其控制的滚动位置类型。例如,
PageController
会重写此方法,以便为其使用的滚动视图提供一个特定的滚动位置子类。 -
默认实现:如果没有重写,默认会返回一个
ScrollPositionWithSingleContext
实例,它是一个标准的滚动位置类型。 -
参数说明:
-
physics
:滚动物理学,控制如何响应用户的滚动交互。 -
context
:用于传递上下文信息,通常是Scrollable
自身的上下文。 -
oldPosition
:如果这是为同一个Scrollable
创建的新的滚动位置,oldPosition
是先前的滚动位置。
-
总结
createScrollPosition
是一个工厂方法,用来为 Scrollable
小部件创建适当的 ScrollPosition
,它允许子类根据需要定制滚动行为和状态。
TrackingScrollController
/// 一个 [ScrollController],它的 [initialScrollOffset] 跟踪其最新更新的 [ScrollPosition]。
///
/// 这个类可以用于同步两个或更多懒加载的滚动视图的滚动偏移,它们共享一个 [TrackingScrollController]。
/// 它会跟踪最近更新的滚动位置,并将其作为 `initialScrollOffset` 返回。
///
/// {@tool snippet}
///
/// 在这个示例中,每个 [PageView] 页面都包含一个 [ListView],而所有三个 [ListView] 都共享同一个 [TrackingScrollController]。
/// 所有三个列表视图的滚动偏移将彼此同步,以尽可能考虑不同列表长度的差异。
///
/// ```dart
/// PageView(
/// children: <Widget>[
/// ListView(
/// controller: _trackingScrollController,
/// children: List<Widget>.generate(100, (int i) => Text('page 0 item $i')).toList(),
/// ),
/// ListView(
/// controller: _trackingScrollController,
/// children: List<Widget>.generate(200, (int i) => Text('page 1 item $i')).toList(),
/// ),
/// ListView(
/// controller: _trackingScrollController,
/// children: List<Widget>.generate(300, (int i) => Text('page 2 item $i')).toList(),
/// ),
/// ],
/// )
/// ```
/// {@end-tool}
///
/// 在这个示例中,`_trackingController` 会由构建小部件树的有状态小部件创建。
class TrackingScrollController extends ScrollController {
/// 创建一个滚动控制器,持续更新其 [initialScrollOffset] 以匹配最后接收到的滚动通知。
TrackingScrollController({
super.initialScrollOffset,
super.keepScrollOffset,
super.debugLabel,
super.onAttach,
super.onDetach,
});
final Map<ScrollPosition, VoidCallback> _positionToListener = <ScrollPosition, VoidCallback>{};
ScrollPosition? _lastUpdated;
double? _lastUpdatedOffset;
/// 最后更改的 [ScrollPosition]。如果没有附加的滚动位置,或者尚未发生任何滚动,
/// 或者最后更改的 [ScrollPosition] 已被移除,则返回 null。
ScrollPosition? get mostRecentlyUpdatedPosition => _lastUpdated;
/// 返回 [mostRecentlyUpdatedPosition] 的滚动偏移,或者如果它为 null,则返回构造函数中提供的初始滚动偏移。
///
/// 另请参见:
///
/// * [ScrollController.initialScrollOffset],这是被此方法重写的。
@override
double get initialScrollOffset => _lastUpdatedOffset ?? super.initialScrollOffset;
@override
void attach(ScrollPosition position) {
super.attach(position);
assert(!_positionToListener.containsKey(position));
_positionToListener[position] = () {
_lastUpdated = position;
_lastUpdatedOffset = position.pixels;
};
position.addListener(_positionToListener[position]!);
}
@override
void detach(ScrollPosition position) {
super.detach(position);
assert(_positionToListener.containsKey(position));
position.removeListener(_positionToListener[position]!);
_positionToListener.remove(position);
if (_lastUpdated == position) {
_lastUpdated = null;
}
if (_positionToListener.isEmpty) {
_lastUpdatedOffset = null;
}
}
@override
void dispose() {
for (final ScrollPosition position in positions) {
assert(_positionToListener.containsKey(position));
position.removeListener(_positionToListener[position]!);
}
super.dispose();
}
}
TrackingScrollController
是 ScrollController
的一个子类,旨在使多个滚动视图的滚动偏移保持同步。它可以用于共享一个控制器的多个视图,尤其适用于需要多个视图滚动时保持一致的情况。此类通过跟踪最上次更新的 ScrollPosition
来更新 initialScrollOffset
。
关键功能
-
滚动同步:通过共享一个
TrackingScrollController
,多个滚动视图的偏移量将保持同步,确保它们的滚动状态相互一致。 -
追踪滚动位置:
_lastUpdated
变量用于存储最后一次更新的ScrollPosition
,_lastUpdatedOffset
则存储该位置的滚动偏移。 -
attach / detach:在
attach
方法中,每次添加新的ScrollPosition
时,会为其设置监听器,更新_lastUpdated
和_lastUpdatedOffset
。在detach
方法中,会移除监听器,并在没有剩余滚动位置时重置initialScrollOffset
。 -
生命周期管理:在
dispose
方法中,移除所有已附加位置的监听器,确保资源得到清理。
示例用法
在示例中,TrackingScrollController
被用来同步多个 ListView
的滚动位置。每个 PageView
页面都包含一个 ListView
,而所有这些 ListView
共享同一个控制器。这样,用户滚动其中一个列表时,其他列表的滚动偏移也会同步更新。
React性能优化全景图:从问题发现到解决方案
前言
作为一名有着8年前端开发经验的工程师,我在React项目中遇到过各种性能问题:页面卡顿、内存泄漏、首屏加载慢、包体积过大等等。通过这些年的实践,我发现性能优化不是单纯的技术问题,而是一个系统工程。今天,我将通过这篇文章为你构建一个完整的React性能优化知识体系。
一、React性能问题的四大类型
1.1 渲染性能问题
典型表现:
- 页面滚动卡顿
- 用户交互响应延迟
- 动画不流畅
常见场景:
// ❌ 问题代码:每次渲染都创建新对象
function UserList({ users }) {
return (
<div>
{users.map(user => (
<UserCard
key={user.id}
user={user}
style={{ marginBottom: '10px' }} // 每次都是新对象
onClick={() => console.log(user)} // 每次都是新函数
/>
))}
</div>
);
}
核心原因:不必要的重新渲染、昂贵的计算操作、大量DOM操作
1.2 内存性能问题
典型表现:
- 页面使用时间越长越卡
- 浏览器标签页占用内存持续增长
- 移动端出现白屏或崩溃
常见场景:
// ❌ 问题代码:未清理的事件监听器
const WindowSize = () => {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
// 忘记清理监听器,造成内存泄漏
}, []);
return <div>窗口大小: {size.width} x {size.height}</div>;
}
1.3 加载性能问题
典型表现:
- 首屏加载时间过长
- 白屏时间长
- 资源加载阻塞
常见场景:
- 未做代码分割,bundle.js过大
- 未优化图片资源
- 同步加载非关键资源
1.4 包体积问题
典型表现:
- 构建产物过大
- 加载时间长
- CDN流量成本高
常见原因:
- 重复依赖
- 未做Tree Shaking
- 引入了不必要的库
二、性能检测工具链
2.1 React DevTools Profiler
使用场景:检测组件渲染性能问题
// 在代码中使用Profiler组件进行性能监测
import { Profiler } from 'react';
const App = () => {
const onRenderCallback = (id, phase, actualDuration) => {
console.log('组件渲染耗时:', {
id, // 组件标识
phase, // "mount" 或 "update"
actualDuration // 渲染耗时(毫秒)
});
};
return (
<Profiler id="UserList" onRender={onRenderCallback}>
<UserList />
</Profiler>
);
}
关键指标解读:
- Render duration:组件渲染时间
- Number of renders:渲染次数
- Why did this render? :渲染原因分析
2.2 Chrome DevTools
Performance面板关键指标:
- FCP (First Contentful Paint) :首次内容绘制
- LCP (Largest Contentful Paint) :最大内容绘制
- FID (First Input Delay) :首次输入延迟
- CLS (Cumulative Layout Shift) :累积布局偏移
Memory面板使用:
// 检测内存泄漏的简单方法
const checkMemoryLeak = () => {
const before = performance.memory.usedJSHeapSize;
// 执行一些操作
doSomeOperations();
// 强制垃圾回收(需要在Chrome中启用)
if (window.gc) {
window.gc();
}
const after = performance.memory.usedJSHeapSize;
console.log(`内存增长: ${(after - before) / 1024 / 1024} MB`);
};
2.3 Bundle分析工具
webpack-bundle-analyzer使用:
npm i --save-dev webpack-bundle-analyzer
// 在package.json中添加脚本
{
"scripts": {
"analyze": "npm run build && npx webpack-bundle-analyzer build/static/js/*.js"
}
}
三、性能优化的ROI分析
3.1 什么时候该优化?
优化时机判断标准:
⚡ FCP (首次内容绘制)
- 🟢 优秀:< 1.8s
- 🟡 良好:1.8s - 3s
- 🟠 需要优化:3s - 5s
- 🔴 严重问题:> 5s
🎯 LCP (最大内容绘制)
- 🟢 优秀:< 2.5s
- 🟡 良好:2.5s - 4s
- 🟠 需要优化:4s - 6s
- 🔴 严重问题:> 6s
🖱️ FID (首次输入延迟)
- 🟢 优秀:< 100ms
- 🟡 良好:100ms - 300ms
- 🟠 需要优化:300ms - 500ms
- 🔴 严重问题:> 500ms
💾 内存增长
- 🟢 优秀:< 1MB/分钟
- 🟡 良好:1-5MB/分钟
- 🟠 需要优化:5-10MB/分钟
- 🔴 严重问题:> 10MB/分钟
3.2 优化成本vs收益分析
高ROI优化(推荐优先做) :
- 图片压缩和WebP格式
- 组件懒加载
- useMemo/useCallback基础优化
- 移除未使用的依赖
中ROI优化(根据需求选择) :
- 虚拟滚动
- 复杂的状态管理优化
- SSR/SSG
低ROI优化(谨慎选择) :
- 过度的代码分割
- 复杂的缓存策略
- 微观的算法优化
3.3 什么时候是过度优化?
过度优化的信号:
// ❌ 过度优化的例子
const SimpleButton = React.memo(({ text, onClick }) => {
// 对于简单组件使用memo可能得不偿失
const memoizedText = useMemo(() => text, [text]);
const memoizedCallback = useCallback(() => {
onClick();
}, [onClick]);
return (
<button onClick={memoizedCallback}>
{memoizedText}
</button>
);
});
避免过度优化的原则:
- 先测量,再优化
- 关注用户体验,而非技术指标
- 保持代码可维护性
- 定期review优化效果
四、实战案例:后台管理系统性能优化全流程
4.1 问题发现阶段
项目背景:
- 大型后台管理系统,包含30+页面
- 用户反馈:页面卡顿、加载慢
- 技术栈:React 18 + TypeScript + Antd
性能检测结果:
初始状态:
- 首屏加载时间:8.2s
- Bundle大小:3.2MB
- FCP:4.1s
- 内存使用:持续增长,1小时增长150MB
4.2 问题分析与定位
使用React DevTools发现的问题:
- UserTable组件每次都重新渲染所有行
- 全局状态更新导致不相关组件渲染
- 大量inline函数和对象创建
使用Bundle Analyzer发现的问题:
- moment.js重复打包
- 未使用的Antd组件全部引入
- 第三方图表库过大
4.3 优化方案实施
第一阶段:快速收益优化(1周内完成)
- Bundle优化:
// 替换moment.js为day.js
// 减少bundle大小200KB
import dayjs from 'dayjs';
// 按需引入Antd组件
import Button from 'antd/es/button';
import 'antd/es/button/style/css';
- 图片优化:
// 使用WebP格式,压缩率提升30%
const optimizeImage = (src) => {
const webpSupported = 'WebP' in window;
return webpSupported ? src.replace(/.(jpg|png)$/, '.webp') : src;
};
第二阶段:渲染性能优化(2周内完成)
- 表格组件优化:
// 使用React.memo和虚拟滚动
const TableRow = React.memo(({ row, onEdit }) => {
const handleEdit = useCallback(() => {
onEdit(row.id);
}, [row.id, onEdit]);
return (
<tr>
<td>{row.name}</td>
<td>
<Button onClick={handleEdit}>编辑</Button>
</td>
</tr>
);
});
- 状态管理优化:
// 使用Context分片,避免全局重渲染
const UserContext = createContext();
const PermissionContext = createContext();
// 替代单一的AppContext
第三阶段:加载性能优化(1周内完成)
// 路由级别的代码分割
const UserManagement = lazy(() => import('./pages/UserManagement'));
const OrderManagement = lazy(() => import('./pages/OrderManagement'));
// 使用Suspense包装
<Suspense fallback={<Loading />}>
<Route path="/users" component={UserManagement} />
</Suspense>
4.4 优化效果
性能指标对比:
优化后:
- 首屏加载时间:3.1s(提升62%)
- Bundle大小:1.8MB(减少44%)
- FCP:1.8s(提升56%)
- 内存使用:稳定,1小时增长20MB(提升87%)
用户体验改善:
- 页面切换更流畅
- 表格滚动不卡顿
- 移动端体验显著提升
五、性能优化检查清单
5.1 开发阶段检查
- 避免在渲染函数中创建新对象和函数
- 正确使用useMemo和useCallback
- 组件拆分合理,避免过度渲染
- useEffect依赖数组正确设置
- 及时清理事件监听器和定时器
5.2 构建阶段检查
- 启用代码分割和懒加载
- 优化图片资源(压缩、WebP)
- 移除未使用的代码和依赖
- 启用gzip/brotli压缩
- 配置合理的缓存策略
5.3 运行时监控
- 设置性能监控指标
- 定期检查内存使用情况
- 监控Core Web Vitals
- 用户体验数据收集
六、总结与展望
React性能优化是一个持续的过程,需要:
- 建立性能优化思维:从设计阶段就考虑性能
- 掌握工具链:熟练使用各种性能分析工具
- 关注用户体验:以用户感知为优化目标
- 持续监控:建立性能监控体系
在后续的文章中,我将深入讲解每一个优化技术点,从原理到实战,帮助你成为React性能优化专家。
下期预告
下一篇文章《深入理解React渲染机制与性能瓶颈》,我们将从React的底层渲染机制入手,深入分析Virtual DOM、Fiber架构,以及调和算法的性能影响。
如果这篇文章对你有帮助,欢迎点赞收藏。有任何问题可以在评论区讨论,我会及时回复。
p5.js 用 cylinder() 绘制 3D 圆柱体
点赞 + 关注 + 收藏 = 学会了
cylinder()
是 p5.js 中用于绘制3D 圆柱体的函数。圆柱体由顶部、底部两个圆形和侧面组成,所有表面由三角形拼接而成(这是 3D 绘图的常见方式)。
注意:cylinder()
只能在「WebGL 模式」下使用(WebGL 是浏览器的 3D 绘图技术),普通 2D 模式下无法生效。
基础语法
cylinder()
的参数非常灵活,所有参数都是可选的,按顺序传入即可:
cylinder([radius], [height], [detailX], [detailY], [bottomCap], [topCap])
-
radius(半径)
- 作用:设置圆柱体底部 / 顶部圆形的半径。
- 类型:数字(比如 20、50)。
- 默认值:50(不填的话,半径就是 50)。
-
height(高度)
- 作用:设置圆柱体上下底之间的距离(沿 y 轴方向)。
- 类型:数字。
- 默认值:等于 radius(不填的话,高度和半径一样)。
-
detailX(水平细分)
- 作用:控制顶部和底部圆形的「边数」。边数越少,形状越像多边形(比如 4 边就像盒子);边数越多,越接近圆形。
- 类型:整数(比如 4、24)。
- 默认值:24(默认看起来比较圆)。
-
detailY(垂直细分)
- 作用:控制圆柱体侧面沿高度方向的「三角形细分数量」。数值越大,侧面越平滑。
- 类型:整数(比如 1、3)。
- 默认值:1(默认侧面较简洁)。
-
bottomCap(底部开关)
- 作用:控制是否绘制底部圆形。
- 类型:布尔值(
true
绘制,false
不绘制)。 - 默认值:
true
(默认绘制底部)。
-
topCap(顶部开关)
- 作用:控制是否绘制顶部圆形。
- 类型:布尔值(
true
绘制,false
不绘制)。 - 默认值:
true
(默认绘制顶部)。
动手试试
默认圆柱体
一个半径 50、高度 50、边缘平滑(detailX=24)、带上下底的圆柱体。
function setup() {
createCanvas(200, 200, WEBGL); // 开启 WebGL 模式(必须)
describe('灰色背景上的白色圆柱体');
}
function draw() {
background(200); // 灰色背景
orbitControl(); // 允许鼠标拖动旋转视角(3D 必备)
cylinder(); // 不填参数,用默认值
}
createCanvas
第三个参数 WEBGL
是 3D 绘图的开关;orbitControl()
让你能拖动鼠标 360° 查看圆柱体。
指定半径
半径 30,高度默认等于半径(也是 30)的圆柱体。
// 其他代码和示例 1 相同,仅修改 cylinder() 部分
cylinder(30); // 只传 radius=30
只传一个参数时,默认是 radius,height 会自动等于它。
指定半径和高度
半径 30、高度 50 的圆柱体(更 “瘦长”)。
cylinder(30, 50); // radius=30,height=50
调整水平细分(detailX)
顶部和底部只有 5 条边,看起来像一个 “五角柱”(接近盒子)。
detailX 越小,形状越 “棱角分明”;越大越接近圆形。
cylinder(30, 50, 6); // detailX=6
调整垂直细分(detailY)
侧面沿高度方向分成 2 段,比默认(detailY=1)更平滑。
cylinder(30, 50, 24, 2); // detailX=24(默认圆),detailY=2
隐藏底部
cylinder(30, 50, 24, 1, false); // bottomCap=false
隐藏顶部和底部
只剩侧面,像一个 “圆筒”(比如水管)。
cylinder(30, 50, 24, 1, false, false); // 两个都设为 false
给圆柱上色
加入颜色和旋转效果。
function setup() {
createCanvas(400, 400, WEBGL); // 更大的画布
describe('会旋转的彩色圆柱体');
}
function draw() {
background(25); // 深色背景
orbitControl(); // 允许鼠标旋转
// 设置颜色(RGB 模式:红、绿、蓝)
fill(100, 200, 255); // 浅蓝色
// 让圆柱体旋转(随时间变化角度)
rotateX(frameCount * 0.01); // 绕 x 轴旋转
rotateY(frameCount * 0.02); // 绕 y 轴旋转
// 绘制圆柱体:半径 60,高度 120,较圆(detailX=16),侧面平滑(detailY=3)
cylinder(60, 120, 16, 3);
}
以上就是本文的全部内容啦,想了解更多 P5.js 用法欢迎关注 《P5.js中文教程》。
也可以➕我 green bubble 吹吹水咯
点赞 + 关注 + 收藏 = 学会了
关于image组件设置宽高不生效问题的探究
问题描述
用一个Container包含一个Image发现设置的宽高40不生效
Container(
width: 300,
height: 400,
color: Colors.blue,
child: Image.asset(
"assets/img/ic_file_pdf.png",
fit: BoxFit.cover,
width: 40,
height: 40,
),
),
展示效果
布局的主要函数流程
布局绘制的主要逻辑都是在RenderObject里面的,RenderObject是所有渲染对象的基类,定义了基本的布局流程和事件分发流程,主要函数就是layout
void layout(Constraints constraints, { bool parentUsesSize = false }) {
.....
_constraints = constraints; //将父组件传递下来的约束赋值给自己的_constraints
if (sizedByParent) {
try {
performResize();
assert(() {
debugAssertDoesMeetConstraints();
return true;
}());
} catch (e, stack) {
_reportException('performResize', e, stack);
}
}
RenderObject? debugPreviousActiveLayout;
try {
performLayout();
markNeedsSemanticsUpdate();
assert(() {
debugAssertDoesMeetConstraints();
return true;
}());
} catch (e, stack) {
_reportException('performLayout', e, stack);
}
}
这个函数关键调用了performResize和performLayout方法,前面的方法用于计算大小,计算出来的结果放在size属性里面,后面的方法用于布局,根据不同的布局算法,把组件放置到不同的地方,performResize方法被调用有个前提就是sizedByParent=true,意思就是大小只和父组件有关,和子组件没关系,具体含义先不去纠结。这两个方法都是抽象方法,需要子类实现
然后RenderBox在RenderObject上封装了一层,基本所有的盒模型类都是继承这个类,RenderBox封装出一个computeDryLayout这个方法,然后在performResize里面调用这个方法,所以如果继承RenderBox,就要实现computeDryLayout,用于计算组件的大小。
我们在实现performLayout的时候会调用child.layout方法,然后child又会调performLayout实现了遍历。
如何确定大小的
约束
flutter里面用约束来确定大小,约束对应Constraints类,一般用其子类BoxConstraints,有四个参数界定约束的大小
const BoxConstraints({
this.minWidth = 0.0,
this.maxWidth = double.infinity,
this.minHeight = 0.0,
this.maxHeight = double.infinity,
});
这四个参数定义了约束的性质,意思是最小是多少,最大是多少。在宽高两个方向分别约束。
我们可以想一下,我们在写布局的时候可以分为三种情况:
- 第一种是根据子类内容来决定自己的宽度,子组件撑起多大自己就多大,但是不能超过父组件允许的最大范围
- 第二种是不管子组件多大父组件能让我占多大,我就占多大;
- 第三种是我有自己的确定宽高,不受其他组件影响,但是确定的宽高不能超过父组件范围
综合前面几种情况就比较容易理解约束里面为什么有maxHeight和maxWidth,但是minWidth和minHeight同时规定了最小应该为多少,我一直比较有疑问为什么flutter里面需要约束最小为多少,印象里面其他UI框架里面好像没有约束最小为多少,既然flutter里面有这个就按照他的定义去理解。
约束一般分为两种类型
- 严格约束
本质上是最小和最大是一样的,意思就是只能这么大,不能是其他大小了,这种情况会有一些坑,有点反直觉
BoxConstraints.tight(Size size)
: minWidth = size.width,
maxWidth = size.width,
minHeight = size.height,
maxHeight = size.height;
2. 宽松约束
只规定了最大是多少,最小没有规定,默认是0,比较符合直觉
BoxConstraints loosen() {
assert(debugAssertIsValid());
return BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight,
);
}
约束传递
在上面的布局流程中,父组件会在传递约束给子组件,子组件根据自身情况然后在父组件传递过来的约束下共同决定子组件有多大,传递的约束是在layout函数constraints参数里面
void layout(Constraints constraints, { bool parentUsesSize = false })
在上面的流程里面已经知道了在父组件的performLayout会调用child.layout的,所以看一下父组件是如何传入这个参数的
以Container为例,我们设置宽高为100,其对应的渲染widget是ConstrainedBox,渲染对象RenderConstrainedBox,找到其performLayout方法
void performLayout() {
final BoxConstraints constraints = this.constraints; //这里的constraints就是父组件传给自己的constraints
if (child != null) {
//_additionalConstraints就是自己在设置Container时设置的宽高,并且也是个强制约束
//enforce的含义是_additionalConstraints必须约束在constraints里面
child!.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
size = child!.size;
} else {
size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
}
}
根据上面的代码注释就能知道,Container传递给子组件的约束是根据自身的约束条件和Container父组件传递过来的约束有关,如果父组件也是严格约束,那么传递给子组件的约束其实是Container父组件的严格约束
确定大小
子组件拿到这个约束后需要结合自身情况综合考量,这个步骤一般在渲染类的computeDryLayout里面进行
以Text组件为例,其对应的渲染类是RenderParagraph
@override
@protected
Size computeDryLayout(covariant BoxConstraints constraints) {
if (!_canComputeIntrinsics()) {
assert(debugCannotComputeDryLayout(
reason: 'Dry layout not available for alignments that require baseline.',
));
return Size.zero;
}
final Size size = (_textIntrinsics
..setPlaceholderDimensions(layoutInlineChildren(constraints.maxWidth, ChildLayoutHelper.dryLayoutChild))
..layout(minWidth: constraints.minWidth, maxWidth: _adjustMaxWidth(constraints.maxWidth)))
.size;
return constraints.constrain(size);
}
size其实就是Text文案计算出来的范围,然后这个范围必须在父组件约束的范围内,看一下constraints.constrain定义
Size constrain(Size size) {
Size result = Size(constrainWidth(size.width), constrainHeight(size.height));
assert(() {
result = _debugPropagateDebugSize(size, result);
return true;
}());
return result;
}
double constrainWidth([ double width = double.infinity ]) {
assert(debugAssertIsValid());
return clampDouble(width, minWidth, maxWidth);
}
//clampDouble函数其实就是夹断函数
double clampDouble(double x, double min, double max) {
assert(min <= max && !max.isNaN && !min.isNaN);
if (x < min) {
return min;
}
if (x > max) {
return max;
}
if (x.isNaN) {
return max;
}
return x;
}
所以如果父组件传递的是严格约束,那么Text的最终大小就是严格约束的大小,自己计算的文字大小并不是最终的大小。
问题解决
用一个Stack或者Center包含Image就可以了
Container(
width: 300,
height: 400,
color: Colors.blue,
child: Stack(
children: [
Image.asset(
"assets/img/ic_file_pdf.png",
fit: BoxFit.cover,
width: 40,
height: 40,
)
],
),
)
因为默认情况下Stack传递的是宽松约束,可以找到Stack对应的RenderObject:RenderStack,看一下其performLayout是传递了什么约束给子组件
void performLayout() {
.....
size = _computeSize(
constraints: constraints,
layoutChild: ChildLayoutHelper.layoutChild,
);
......
}
Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) {
......
//默认情况Stack是StackFit.loose情况,传递是宽松约束
final BoxConstraints nonPositionedConstraints = switch (fit) {
StackFit.loose => constraints.loosen(),
StackFit.expand => BoxConstraints.tight(constraints.biggest),
StackFit.passthrough => constraints,
};
RenderBox? child = firstChild;
while (child != null) {
final StackParentData childParentData = child.parentData! as StackParentData;
if (!childParentData.isPositioned) {
//把nonPositionedConstraints传递给子组件进行布局
final Size childSize = layoutChild(child, nonPositionedConstraints);
}
}
....
return size;
}
上面的代码很明显默认情况下传递的是宽松约束,所以子组件自己设置的宽高是生效的
一些tips
在找某个widget对应的RenderObject一般是这样去找
- 如果widget是RenderObjectWidget,那么直接找到其createRenderObject方法即可
- 如果是StatefulWidget或者StatelessWidget,找到其build方法返回的RenderObjectWidget,再去找对应的RenderObject
这个过程稍微有点麻烦,而且像Container这种在build的时候,会包好几层。
其实在FlutterInspector里面就能直接看到运行时widget树,和我们代码里面对应的widget多出了一些东西
总结
本文从一个布局问题,探索flutter的布局流程,再提出约束这个概念,进而引申出严格约束和宽松约束两种分类,再结合布局流程将问题的一般排查问题的手段定位到performLayout和computeDryLayout这两个具体方法里面,不同的组件有不同的实现,进而解决对应的问题
实现虚拟列表
虚拟列表实现
实现思路
- 渲染可视区域的数据:根据滚动位置计算出可见的起始索引和结束索引;
- 总高度占位:整个容器高度与真实数据等高,让滚动条正常工作;
- 滚动定位:通过一个“内层偏移容器”将可视区域的内容垂直偏移到正确位置;
- 动态渲染:滚动时实时计算需要渲染的数据子集。
图示概念
┌────────────────────┐
│ scroll container │ ← 固定高度、滚动容器
│ ┌────────────────┐ │
│ │ phantom │ │ ← 实际总高度,占位用
│ │ ┌────────────┐ │ │
│ │ │ visible │ │ │ ← 只渲染可视区域
│ │ └────────────┘ │ │
│ └────────────────┘ │
└────────────────────┘
关键计算参数
- 容器高度:可视区域的高度
- 项目高度:每个列表项的高度(固定或动态)
- 滚动位置:当前滚动条的位置
- 缓冲区:预渲染的额外项目数(防止滚动时空白)
实现代码
原生JavaScript
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>虚拟列表 - 原生 JS</title>
<style>
#container {
height: 300px; /* 设置固定高度,超出部分滚动 */
overflow-y: auto;
position: relative;
border: 1px solid #ccc;
}
#phantom {
height: 0; /* 最终高度将被 JS 设置 */
}
#visible {
position: absolute;
top: 0;
left: 0;
right: 0;
}
</style>
</head>
<body>
<div id="container">
<div id="phantom"></div> <!-- 占位用容器撑起滚动条 -->
<div id="visible"></div> <!-- 实际渲染可视区域数据 -->
</div>
<script>
const container = document.getElementById('container'); // 获取滚动容器
const phantom = document.getElementById('phantom'); // 占位容器
const visible = document.getElementById('visible'); // 渲染内容的容器
const itemHeight = 30; // 每一项的固定高度
const total = 10000; // 总数据条数
const visibleCount = Math.ceil(container.clientHeight / itemHeight); // 可视区域最多渲染多少项
const data = Array.from({ length: total }, (_, i) => `Item ${i + 1}`); // 生成 1~10000 的数据项
phantom.style.height = `${total * itemHeight}px`; // 设置占位高度:总条数 × 每项高度
function render(startIndex) {
// 取出可视区域需要渲染的子集数据
const visibleData = data.slice(startIndex, startIndex + visibleCount);
// 生成 HTML,每项设置高度
visible.innerHTML = visibleData.map(item =>
`<div style="height:${itemHeight}px; border-bottom:1px solid #eee; padding-left: 8px;">${item}</div>`
).join('');
// 设置渲染容器向下偏移位置
visible.style.transform = `translateY(${startIndex * itemHeight}px)`;
}
// 初始化:从第 0 项开始渲染
render(0);
// 监听滚动事件
container.addEventListener('scroll', () => {
// 计算滚动位置对应的起始索引
const start = Math.floor(container.scrollTop / itemHeight);
render(start);
});
</script>
</body>
</html>
思路解释
- #container 设置固定高度和滚动条,是整个滚动容器
- #phantom 高度撑起整个列表滚动高度,不显示内容,只为撑起滚动条
- #visible 实际显示数据的容器,显示部分 DOM 项
- 根据当前滚动起点
startIndex
,通过slice(start, end)
截取当前需要展示的数据。 - 设置
transform: translateY
偏移,将渲染区域移动到应该出现的位置。 -
container.scrollTop
是滚动条卷去的高度,除以itemHeight
就得到当前在第几项开头。然后重新渲染当前窗口中要显示的内容。
Vue3
<template>
<div class="viewport" @scroll="handleScroll" ref="viewport">
<!-- 占位元素 -->
<div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
<!-- 实际渲染内容 -->
<div class="content" :style="{ transform: `translateY(${offset}px)` }">
<div
v-for="item in visibleData"
:key="item.id"
class="item"
:style="{ height: itemHeight + 'px' }"
>
{{ item.text }}
</div>
</div>
</div>
</template>
<script>
export default {
props: {
data: { type: Array, required: true }, // 列表数据
itemHeight: { type: Number, default: 50 }, // 每项高度
buffer: { type: Number, default: 5 } // 缓冲区大小
},
data() {
return {
startIndex: 0, // 起始索引
endIndex: 0, // 结束索引
scrollTop: 0 // 滚动位置
};
},
computed: {
// 列表总高度
totalHeight() {
return this.data.length * this.itemHeight;
},
// 可见区域数据
visibleData() {
return this.data.slice(this.startIndex, this.endIndex + 1);
},
// 内容偏移量
offset() {
return this.startIndex * this.itemHeight;
},
// 可见项目数
visibleCount() {
return Math.ceil(this.$refs.viewport?.clientHeight / this.itemHeight) || 0;
}
},
mounted() {
this.updateRange();
},
methods: {
// 更新可见范围
updateRange() {
this.startIndex = Math.floor(this.scrollTop / this.itemHeight);
this.endIndex = this.startIndex + this.visibleCount + this.buffer;
this.endIndex = Math.min(this.endIndex, this.data.length - 1);
},
// 滚动事件处理
handleScroll() {
this.scrollTop = this.$refs.viewport.scrollTop;
this.updateRange();
}
}
};
</script>
<style>
.viewport {
height: 100%;
overflow: auto;
position: relative;
}
.phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
}
.content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.item {
position: absolute;
width: 100%;
box-sizing: border-box;
}
</style>
思路解释
-
.viewport
是滚动容器,监听scroll
事件。 -
ref="viewport"
用于访问 DOM 获取scrollTop
、clientHeight
。 -
.phantom
是“假元素”,用于撑出滚动条总高度(类似原生实现中的phantom
) -
.content
是“真实内容区”,通过transform: translateY()
将其移动到正确显示区域。 -
buffer
:让上下各多渲染几项,避免滚动过快时出现“白屏”现象。 -
.content
的translateY
偏移值,让渲染内容出现在正确滚动位置。 - 滚动事件触发时,更新
scrollTop
,并重新计算显示范围。
React
import React, { useState, useEffect, useRef } from 'react';
/**
* 虚拟列表组件
* @param {Object} props
* @param {Array} props.data 列表数据
* @param {number} props.itemHeight 列表项高度
* @param {number} props.buffer 缓冲区大小
* @param {number} props.height 容器高度
*/
function VirtualList({ data, itemHeight = 50, buffer = 5, height = 400 }) {
const [startIndex, setStartIndex] = useState(0); // 起始索引
const [endIndex, setEndIndex] = useState(0); // 结束索引
const [scrollTop, setScrollTop] = useState(0); // 滚动位置
const viewportRef = useRef(null); // 容器ref
// 列表总高度
const totalHeight = data.length * itemHeight;
// 可见项目数
const visibleCount = Math.ceil(height / itemHeight);
// 更新可见范围
const updateRange = () => {
const newStart = Math.floor(scrollTop / itemHeight);
const newEnd = newStart + visibleCount + buffer;
setStartIndex(newStart);
setEndIndex(Math.min(newEnd, data.length - 1));
};
// 处理滚动事件
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
// 滚动位置变化时更新范围
useEffect(() => {
updateRange();
}, [scrollTop]);
// 初始计算可见范围
useEffect(() => {
updateRange();
}, []);
// 可见数据
const visibleData = data.slice(startIndex, endIndex + 1);
return (
<div
ref={viewportRef}
style={{
height: `${height}px`,
overflow: 'auto',
position: 'relative'
}}
onScroll={handleScroll}
>
{/* 占位元素 */}
<div style={{ height: `${totalHeight}px` }} />
{/* 实际内容 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${startIndex * itemHeight}px)`
}}
>
{visibleData.map((item) => (
<div
key={item.id}
style={{
height: `${itemHeight}px`,
position: 'absolute',
top: `${item.id * itemHeight}px`,
width: '100%'
}}
>
{item.text}
</div>
))}
</div>
</div>
);
}
// 使用示例
const data = Array.from({length: 10000}, (_, i) => ({id: i, text: `Item ${i}`}));
function App() {
return (
<VirtualList
data={data}
itemHeight={50}
height={500}
/>
);
}
思路解释
- 设置容器固定高度,开启垂直滚动。
- 监听滚动事件
onScroll
,通过ref
获取 DOM 元素。 - 占位元素高度 = 总项数 × 每项高度。
- 用于让容器产生滚动条,但并不显示真实内容。
-
.content
内容容器被平移(transform)到底部; -
startIndex * itemHeight
就是当前要显示的首项的位置; - 只渲染
visibleData = data.slice(startIndex, endIndex + 1)
这部分;