阅读视图

发现新文章,点击刷新页面。

HTML代码规范

HTML代码规范

缩进

建议缩进4个字符,在现代前端工程化,缩进4个字符比2字符更好的可读性。也不影响最终的结果

DOCTYPE 声明

统一使用HTML5声明<!DOCTYPE html>

meta 标签

  • 编码格式
<meta charset="utf-8"/>
  • SEO优化
<!-- 网页标题 -->
<title>网页标题</title>
<!-- 页面关键词 -->
<meta name ="keywords" content =""/>
<!-- 页面描述 -->
<meta name ="description" content =""/>
<!-- 网页作者 -->
<meta name ="author" content =""/>
  • 优先使用 IE 最新版本和 Chrome
<meta http-equiv="X-UA-Compatible" content="IE = edge,chrome = 1"/>
  • 为移动设备添加视口
<!-- device-width 是指这个设备最理想的 viewport 宽度 -->
<!-- initial-scale=1.0 是指初始化的时候缩放大小是1,也就是不缩放 -->
<!-- user-scalable=no 是指禁止用户进行缩放 yes 允许缩放-->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
  • 禁止自动识别页面中有可能是电话格式的数字
<meta name="format-detection" content="telephone=no"/>

pc端建议

<title>网页标题</title>
<meta charset="utf-8"/>
<meta name="keywords" content="your keywords"/>
<meta name="description" content="your description"/>
<meta name="author" content="author,email address"/>
<meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1"/>

移动端建议

<title>网页标题</title>
<meta charset="utf-8"/>
<meta name="keywords" content="your keywords"/>
<meta name="description" content="your description"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
<meta name="format-detection" content="telephone=no"/>

标签

  • 所有标签都统一使用小写字母
  • 自闭合标签:写法<br/>后面的/不要省略,常用的自闭合标签:
<br/>
<img/>
<input/>
<link/>
<meta/>
<base/>
<source/>
  • 非自闭合标签必须有结束标签,且不要省略:
<html></html>
<body></body>
<head></head>
<script></script>
<style></style>
<iframe></iframe>
<noscript></noscript>
<title></title>
<!-- 普通标签 -->
<h1></h1>
<p></p>
<ul></ul>
<ol></ol>
<li></li>
<option></option>
<div></div>
<span></span>
<a></a>
<form></form>
<label></label>
<button></button>
<textarea></textarea>
<select></select>
<canvas></canvas>
<audio></audio>
<video></video>
<!-- 语义化标签 -->
<header></header>
<footer></footer>
<nav></nav>
<menu></menu>
<main></main>
<article></article>
<section></section>
<aside></aside>
<figure></figure>
<figcatption></figcaption>
<time></time>
<address></address>
  • 尽量减少标签数量:如果设计到div装饰的小物件,尽量使用伪元素实现,而不是先建立一个元素实现
  • vue组件自定义标签: 如果设计到多个单词便签不要使用驼峰法,而是使用-分割。且需要闭合标签
  • 对于<span><a>、等行内元素不要在嵌套其他块级元素。
  • 块元素不要和行内元素并列

转移字符

  1. &nbsp;空格
  2. &lt;小于
  3. &gt;大于
  4. &amp;
  5. &quot;引号

元素属性

  • 元素属性值使用双引号语法
  • 自定义数据属性data-*,使用小写字符,避免使用特殊字符,多单词使用-分割

js 使用

// 获取属性data-user-id="123"
let userId = userDiv.dataset.userId
userId = userDiv.getAttribute('data-user-id');
// 修改
userDiv.dataset.userId = "456";
userDiv.setAttribute('data-user-id', '789');
// 删除属性
delete userDiv.dataset.userRole;
userDiv.removeAttribute('data-user-id');
//访问所有data
const allData = element.dataset; // 返回一个 DOMStringMap 对象

css 使用

[data-role="admin"] {
  background-color: #ffebee;
}
.tooltip::before {
  content: attr(data-tooltip);
  /* 其他样式 */
}

其他规范

  1. img必须(尽量)要使用alt属性
  2. 多个表单组合应该放入form表单内
  3. html 注释必须要单独一行,不能和任何标签同行
<!-- Comment Text -->
<div>...</div>
  1. 单行字数限制120字符比较合适
  2. 多属性建议分行写(属性超过3个建议分行)
<input type="text"
       class="form-control"
       id="exampleInputEmail1"
       placeholder="Enter email"
       data-attribute1="value1"
       data-attribute2="value2"/>

vscode提示找不到名称“Map”的解决方案

错误提示

“编辑器报错:找不到名称“Map”。是否需要更改目标库? 请尝试将 “lib” 编译器选项更改为“es2015”或更高版本。”

原因:

Map 是 ES2015(ES6)引入的新特性,而 TS 默认的 lib 配置可能只包含 ES5 相关的类型定义(ES5 中没有 Map),因此编译器不认识 Map,从而抛出 “找不到名称‘Map’” 的错误。

解决方案(两种方式,任选其一)

方式 1:修改 tsconfig.json 配置(推荐,全局生效)

这是最规范的做法,通过配置文件指定 TS 编译时使用的库版本:

  1. 找到项目根目录的 tsconfig.json 文件(如果没有,执行 tsc --init 生成);
  2. 找到 compilerOptions 中的 lib 字段,添加 ES2015 或更高版本(如 ES2020),同时保留必要的 DOM(如果涉及浏览器环境);
  3. 保存后重启编辑器(如 VS Code),报错会自动消失。

完整的 tsconfig.json 示例

json

{
  "compilerOptions": {
    "target": "ES2015", // 编译目标版本,建议和 lib 版本匹配
    "lib": ["ES2015", "DOM"], // 核心:添加 ES2015 及以上
    "module": "CommonJS",
    "strict": true, // 开启严格模式,增强类型检查
    "esModuleInterop": true
  }
}
  • lib 字段说明:

    • ES2015:包含 ES6 所有新特性(Map、Set、Promise 等)的类型定义;
    • DOM:包含浏览器环境的类型定义(如 documentwindow),如果是 Node.js 项目可省略。

方式 2:临时指定文件级别的 lib(仅当前文件生效)

如果不想修改全局配置,可在使用 Map 的 TS 文件顶部添加一行注释,强制指定该文件使用的库版本:

typescript

运行

/// <reference lib="es2015" />

// 此时 Map 就能被识别
const map = new Map<string, number>();

验证是否解决问题

修改配置后,编写以下代码测试:

typescript

运行

const map = new Map<string, number>();
map.set("test", 123);
console.log(map.get("test"));
  • 如果编辑器不再报 “找不到名称‘Map’” 的错误,且代码能正常编译(执行 tsc 无报错),说明配置生效。

补充说明

  • target vs lib

    • target:指定 TS 编译后生成的 JS 版本(如 ES5、ES2015);
    • lib:指定 TS 编译时参考的类型定义库版本(决定 TS 认识哪些内置对象 / 方法);
    • 建议 targetlib 版本保持一致(如都设为 ES2015),避免类型和编译结果不匹配。
  • 若项目是 Node.js 环境:除了 ES2015,还可根据 Node 版本选择 ES2020ESNext 等,无需添加 DOM

总结

  1. 报错核心原因:TS 编译器的 lib 配置未包含 ES2015,导致无法识别 Map 类型;
  2. 最优解决方案:在 tsconfig.jsoncompilerOptions.lib 中添加 ES2015(或更高版本);
  3. 关键配置:lib 字段控制 TS 能识别的内置 API 类型,target 控制编译后的 JS 版本,两者建议匹配。

一看就懂的 Haskell 教程 - 作用域

一、核心前提:词法作用域(静态作用域)

核心:绑定的有效范围由代码物理结构(缩进/块)决定,与运行时执行流程无关,看代码就能判断访问范围。

二、三大核心规则

规则1:块级隔离——绑定仅在定义块内有效,外部不可访问

核心:绑定归属明确代码块,块内可用、块外报错,不同块完全隔离。

关键块界定+示例

-- 1. let绑定:仅紧跟的in块有效
circleLet :: Double -> Double
circleLet r = let pi = 3.14  -- 定义块:let
              in 2*pi*r     -- 仅in块可访问pi ✅
-- 直接写pi → 报错(in块外无作用域)❌

-- 2. where绑定:仅紧邻的主表达式有效
factorial :: Int -> Int
factorial 0 = 1  -- 无法访问doubleN ❌(非所属主表达式)
factorial n = n * doubleN * factorial (n-1)
  where doubleN = n*2  -- 仅factorial n的主表达式可访问 ✅

-- 3. 条件分支:独立块,绑定互不访问
ifScope :: Int -> Int
ifScope x = if x>0
            then let y=x*2 in y+3  -- y仅then块有效 ✅
            else 0  -- 无法访问y ❌(else是独立块)

规则2:嵌套遮蔽——内层同名新绑定遮蔽外层,不修改原绑定

核心:内层创同名新绑定→内层优先使用,外层值/作用域不变(仅暂时隐藏),本质是「新绑定」非「修改」。

示例

-- 1. let嵌套遮蔽
scopeDemo :: Int
scopeDemo = let x=10        -- 外层x=10
            in x + let x=20 -- 内层新x=20,遮蔽外层(外层仍为10)
                   in x     -- 内层用x=20 → 结果10+20=30 ✅

-- 2. 函数参数+let遮蔽
shadowDemo :: Int -> Int
shadowDemo x = let x=x+1    -- 新x=原x+1,遮蔽参数x(原x不变)
               in x*2       -- 用内层x → 调用shadowDemo3 → (3+1)*2=8

规则3:全域不可变——绑定一旦定义,值永久固定

核心:无命令式「赋值修改」,全局/局部绑定值始终不变;编译期强制保障,直接修改报错;看似「修改」实则是新绑定遮蔽

示例

-- 1. 直接修改→编译报错(违反不可变性)
invalid :: Int -> Int
invalid x = x = x+1  -- 语法错误 ❌(无赋值操作)

-- 2. 看似修改,实际是多层新绑定遮蔽
validImmu :: Int -> Int
validImmu x = let x=x+1  -- 新绑定x1=原x+1
              in let x=x*2  -- 新绑定x2=x1*2,遮蔽x1
                 in x       -- 用x2 → 调用3 → (3+1)*2=8 ✅
-- 原x=3、x1=4均不变,仅被遮蔽

-- 3. where绑定不可变
circleWhere :: Double -> Double
circleWhere r = c+a
  where pi=3.14; c=2*pi*r; a=pi*r*r  -- pi/c/a值始终固定 ✅

三、let 表达式 vs where 子句

二者均遵循三大规则,仅作用域粒度、嵌套灵活性、使用场景不同,按需选择,示例对比更直观。

特性 let 表达式(表达式级绑定) where 子句(定义级绑定)
书写顺序 先定义绑定,后在in块使用 先写主表达式(用绑定),后定义绑定
作用域范围 仅紧跟的单个in块(细粒度) 仅紧邻的主表达式/函数分支
嵌套灵活性 可嵌套在任意表达式(if/let/where) 仅附着在函数/模式匹配/case
代码结构 需in包裹,多层嵌套有括号 扁平无嵌套,无需额外包裹
核心场景 某段具体表达式创局部绑定 整个函数/单个分支创辅助绑定

对比示例

-- let:嵌套在then分支,仅服务于该段表达式(细粒度)
letDemo :: Int -> Int
letDemo x = if x>0 then let y=x*2 in y+3 else 0  -- y仅then块有效

-- where:服务于整个函数主表达式,结构扁平
whereDemo :: Int -> Int
whereDemo x = res
  where y=x*2; res=if x>0 then y+3 else 0  -- y覆盖整个函数体

-- let嵌套在where内:仅in块有效,where其他绑定不可访问
mixDemo :: Int -> Int
mixDemo x = a + b
  where a=x*2; b=let c=x*3 in a+c  -- c仅b的in块有效 ✅

四、where 嵌套(核心+示例)

核心:内层where仅紧跟单个绑定(值/函数) ,为其提供专属辅助定义,层级隔离、互不干扰。

示例

-- 功能:环形面积(外圆-内圆)+外圆周长
ringCalc :: Double -> Double -> Double
ringCalc r1 r2 = ringArea + outerCir  -- 主表达式
  where
    ringArea = calcArea r1 - calcArea r2  -- 一级绑定
    outerCir = 2 * pi * r1                -- 一级绑定(用外层pi)
    pi = 3.14
    calcArea r = pi' * r * r              -- 二级函数绑定
      where pi' = 3.1415926               -- 内层where:仅calcArea可用 ✅
-- 内层pi'遮蔽外层pi,仅calcArea生效;外层outerCir仍用pi=3.14

五、终极总结(3句话概括所有)

  1. 作用域看结构:词法作用域,代码物理块决定绑定访问范围;
  2. 内外严隔离:块级作用域,绑定仅在定义块内有效,不污染外部;
  3. 值定永不改:全域不可变,嵌套遮蔽是新绑定,非修改原绑定。

let/where 选择一句话原则

  • 某段具体表达式创局部绑定 → 用let(细粒度、灵活);
  • 整个函数/单个模式分支创辅助绑定 → 用where(扁平、突出核心)。

vue2 甘特图 vxe-gantt 一行渲染多个子任务的配置

vue2 甘特图 vxe-gantt 一行渲染多个子任务的配置,但需要在父级任务直接显示所有子任务时,可以通过 task-bar-subview-config 和父级任务设置type= VxeGanttTaskType.Subview 来设置,会自动将所有子任务渲染到父级任务中

gantt.vxeui.com

在这里插入图片描述 设置 task-bar-subview-config.showOverview 设置是否任务总览,当子任务被展开后自动显示任务总览

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script>
import { VxeGanttTaskType } from 'vxe-gantt'

export default {
    data() {
        const ganttOptions = {
              border: true,
  treeConfig: {
    transform: true,
    rowField: 'id',
    parentField: 'parentId'
  },
  taskConfig: {
    startField: 'start',
    endField: 'end',
    typeField: 'type',
    progressField: 'progress'
  },
  taskBarSubviewConfig: {
    showOverview: true 
  },
  taskBarConfig: {
    showContent: true,
    barStyle: {
      round: true
    }
  },
  taskViewConfig: {
    tableStyle: {
      width: 280
    }
  },
  columns: [
    { field: 'title', title: '任务名称', minWidth: 140, treeNode: true },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 },
    { field: 'progress', title: '进度(%)', width: 80 }
  ],
  data: [
    { id: 10001, parentId: null, title: '我的项目1', start: '', end: '', progress: 0, type: VxeGanttTaskType.Subview },
    { id: 10002, parentId: 10001, title: '我的项目2', start: '2024-03-02', end: '2024-03-05', progress: 70 },
    { id: 10003, parentId: null, title: '我的项目3', start: '', end: '', progress: 0, type: VxeGanttTaskType.Subview },
    { id: 10004, parentId: 10003, title: '我的项目4', start: '2024-03-03', end: '2024-03-04', progress: 50 },
    { id: 10005, parentId: 10003, title: '我的项目5', start: '2024-03-05', end: '2024-03-06', progress: 50 },
    { id: 10006, parentId: 10003, title: '我的项目6', start: '2024-03-08', end: '2024-03-11', progress: 60 },
    { id: 10008, parentId: null, title: '我的项目7', start: '', end: '', progress: 0, type: VxeGanttTaskType.Subview },
    { id: 10009, parentId: 10008, title: '我的项目8', start: '2024-03-07', end: '2024-03-09', progress: 50 },
    { id: 10010, parentId: 10008, title: '我的项目9', start: '2024-03-10', end: '2024-03-12', progress: 50 },
    { id: 10011, parentId: 10008, title: '我的项目10', start: '2024-03-13', end: '2024-03-15', progress: 50 }
  ]
        };
        return {
            ganttOptions
        };
    }
};
</script>

gitee.com/x-extends/v…

【配置化 CRUD 02】 useTable Hooks:列表查询、分页封装

一:前言

在业务的CRUD开发体系中 ,几乎所有业务模块都需要实现列表查询与分页功能,你是不是经常会看到类似代码:

// 1.每个页面都要单独引入接口 
import { getListApi } from '@/api/user' 

// 2. 定义分页列表参数(每个列表页都要写一遍)
const current = ref(1) 
const pageSize = ref(10) 
const total = ref(0) 
const tableData = ref([]) 
const loading = ref(false)
const searchParams = ref({})

// 3. 重复拼接请求参数(分页+搜索)
const getListData = async () => { 
  loading.value = true 
  try {
  const params = { 
      current: current.value, 
      pageSize: pageSize.value, 
      ...searchParams.value 
     } 
  const res = await getListApi(params) 
  // 重复解析响应数据(每个页面可能还要适配不同后端格式)
  tableData.value = res.data.list 
  total.value = res.data.total 
  }catch (error) { 
     console.error('列表查询失败:', error) } 
  finally { 
    loading.value = false 
  } 
}

// 4. 重复编写分页联动监听
watch([current, pageSize], async () => { await getListData() }, 
{ immediate: true })
...

类似这样的代码,每新增一个列表页就需要几乎完整复制一遍,基于此,我们针对性封装 useTable Hooks,延续配置化 CRUD体系「通用逻辑内聚、业务逻辑外溢」的设计原则,将列表查询请求、分页参数管理、加载状态控制、查询与分页联动等通用逻辑全部抽离,支持自定义请求接口、灵活配置分页参数...,可联动专栏前一期的搜索重置组件,这样在实现列表页时,无需关注底层的查询与分页逻辑,只需传入简单配置,即可快速实现列表查询与分页功能,专注于业务本身的表单配置与数据展示。

文章指路(一起食用,味道更加):

1.动态表单深度实践: juejin.cn/post/757946…

2.搜索重置组件:juejin.cn/post/760056…

二:useTable hooks实现

首先明确useTable Hooks的实现目标,所有代码实现均围绕目标展开:

1.冗余逻辑抽离:将分页参数定义、列表查询请求、分页联动、搜索联动、表格操作等通用逻辑全部内聚;

2.配置化适配:支持通过入参灵活配置请求接口、响应数据格式、分页参数、默认参数等,适配不同业务模块、不同后端接口规范;

3.联动兼容:支持与前一期封装的搜索重置组件联动,同时适配表格组件,提供实例注册、选中行获取、表格列设置等方法,实现全流程联动;

...

接下来我们开始实现:

2.1.useTable Hooks Props类型约束

interface UseTableConfig<T = any> {
    // 请求Url 或函数
    getListApi: string | Function
    // 返回数据格式配置 -- 可指定对应列表和总数的对应字段
    response?: {
        list: string
        total?: string
    }
    // 接收默认参数, 如 pageType:"testPage",不参与响应式更新!
    initParams?: Object 
    // 搜索组件默认参数,注意跟搜索组件初始值保持一致,不参与响应式更新!
    searchInitParams?: Object 
    // 针对table的相关配置
    tableObject?: Object
}

2.2 数据状态:统一维护表格、搜索组件、初始参数等相关状态

// 表格组件实例引用,用于调用表格内部方法
const tableRef = ref(null) 
// 接收搜索组件传递的实时参数,参与响应式更新!
const searchParamObj = ref({}) 
// 表格相关
const tableObjects = ref({
    current: 1,    // 默认当前页为1
    pageSize: 10, // 默认页大小为10
    pageSizeOptions: ['5', '10', '15', '20'], // 默认分页大小可选值
    total: 0, // 数据总数,初始为0
    tableData: [], // 表格渲染数据,初始为空数组
    // 允许外部传入覆盖默认配置
    ...tableObject, 
    loading: false, // 表格加载状态,控制加载动画
})

// 利用`computed`计算属性自动组装请求参数
const parmsObj = computed(() => {
    return {
        current: tableObjects.value.current, // 分页-当前页
        pageSize: tableObjects.value.pageSize, // 分页-页大小
        ...initParams, // 固定参数(不响应式)
        ...searchInitParams, // 搜索默认参数(不响应式)
        ...searchParamObj.value, // 搜索实时参数(响应式)
    }
})

2.3 核心方法:封装查询、表格操作与搜索联动逻辑

2.3.1 register:注册表格ref

// 注册表格ref
const register = (ref: any) => {
   tableRef.value = ref
}
// 获取表格实例
const getTable = () => {
   return tableRef.value
}

2.3.2 getListData : 获取列表数据

const getListData = async () => {
     tableObjects.value.loading = true
     console.log('getListData::列表调用参数:', parmsObj.value)
     try {
           const res = await Http({
                 method,
                 url:getListApi,
                 params:parmsObj.value
            })
           tableObjects.value.total = res[response?.total || 'total']
           tableObjects.value.tableData = res[response?.list || 'list']
           tableObjects.value.loading = false
        } catch (error) {
           message.error(error)
        }
    }

2.3.3 setSearchParams : 搜索联动

const setSearchParams = (data: any, getList = true, isReset = false) => {
        Object.assign(searchParamObj.value, data)
        if (isReset) {
            // 重置
            searchParamObj.value = {}
        }
        // 是否获取数据
        getList && getListData()
    }

2.3.4 setRowChecked : 设置列选择

const setRowChecked = (row: any) => {
     const table = getTable() as any
     table && table.toggleRowSelection(row, true)
}

...

2.4 联动监听:分页参数变化自动触发查询

// 监听当前页变化,自动刷新列表
watch(() => tableObjects.value.current, async () => {
    getListData()
}, { immediate: true, deep: true })

// 监听页大小变化,自动刷新列表
watch(() => tableObjects.value.pageSize, async () => {
    getListData()
}, { immediate: true, deep: true })

2.5 API统一暴露:对外提供简洁易用的调用接口

return {
    // 核心方法集合,供外部调用
    methods: { 
        getListData, // 列表查询
        getTable, // 获取表格实例
        setRowChecked, // 列选择
        setSearchParams, // 设置查询参数
    },
    tableRef, // 表格实例引用(可选暴露,供外部手动操作)
    register, // 表格实例注册方法(表格组件必须调用)
    tableObject: tableObjects.value, // 分页与表格核心状态(响应式,供表格组件绑定)
}

2.6 完整代码

import { ref, watch, computed } from 'vue'
interface UseTableConfig<T = any> {
    getListApi: string | Function
    // 请求方式
    method?: 'get' | 'post'
    //返回数据格式配置 -- 可指定对应列表和总数的取值
    response?: {
        list: string
        total?: string
    }
    initParams?: Object //接收默认参数, 如 pageType:"testPage",不参与响应式更新!
    searchInitParams?: Object //搜索组件默认参数,注意跟搜索组件初始值保持一致,不参与响应式更新!
    tableObject?: Object // 针对table的相关配置
}

export const useTable = (
    config: UseTableConfig
) => {
    const { method = 'get',getListApi, response = {}, initParams = {}, tableObject = {}, searchInitParams = {} } = config
    const tableRef = ref(null)
    // 接收搜索组件传递的实时参数,参与响应式更新!
    const searchParamObj = ref({})
    //表格配置
    const tableObjects = ref({
        current: 1,    // 当前页
        pageSize: 10, // 页限制
        pageSizeOptions: ['5', '10', '15', '20'], // 分页配置
        total: 0, // 总数
        tableData: [], // 表格数据
        ...tableObject,
        loading: false, //表格加载状态
    })
    const parmsObj = computed(() => {
        return {
            current: tableObjects.value.current,
            pageSize: tableObjects.value.pageSize,
            ...initParams, // 固定限制参数
            ...searchInitParams, // 搜索默认参数(不响应式)
            ...searchParamObj.value, // 搜索实时参数(响应式)
        }
    })
    const getListData = async () => {
        tableObjects.value.loading = true
        console.log('getListData::列表调用参数:', parmsObj.value)
        try {
            const res = await Http({
              method,
              url:getListApi,
              params:parmsObj.value
            })
            tableObjects.value.total = res[response?.total || 'total']
            tableObjects.value.tableData = res[response?.list || 'list']
            tableObjects.value.loading = false
        } catch (error) {
            message.error(error)
        }
    }
    //统一拿到table的Ref
    const getTable = () => {
        return tableRef.value
    }
    //获取表格选中的列
    const getSelections = async () => {
        const table = getTable() as any
        return table.getSelectionRows()
    }
    //从外界直接传入的表格列参数
    const setcolumns = (columns: any) => {
        const table = getTable() as any
        table && table?.setcolumns(columns)
    }
    //设置列选择
    const setRowChecked = (row: any) => {
        const table = getTable() as any
        table && table.toggleRowSelection(row, true)
    }
    //注册表格ref
    const register = (ref: any) => {
        tableRef.value = ref
    }
    // 支持从外面设置搜索参数,并查询
    const setSearchParams = (data: any, getList = true, isReset = false) => {
        Object.assign(searchParamObj.value, data)
        if (isReset) {
            searchParamObj.value = {}
        }
        getList && getListData()
    }
    //检测到分页变化 重新获取数据
    watch(() => tableObjects.value.current, async () => {
        getListData()
    },
        { immediate: true, deep: true })
    //检测到分页变化 重新获取数据
    watch(() => tableObjects.value.pageSize, async () => {
        getListData()
    }, { immediate: true, deep: true })

    return {
        methods: {
            getListData,
            getTable,
            getSelections,
            setcolumns,
            setRowChecked,
            setSearchParams,
        },
        tableRef,
        register,
        tableObject: tableObjects.value,
    }
}

三: 实战使用(useTable结合搜索、表格组件)

页面引入Hooks使用:

const { methods, register, tableObject } = useTable({
  getListApi: 'test/list',
  initParams: {
    pageType:"testPage"
  },
  searchInitParams:{
    age:10,
    name:"初始化名字"
  }
})

const { getListData, setSearchParams } = methods

getListData()

搜索组件联动:search和reset方法改造

const searchColumn = [
  {
    label: '姓名',
    prop: 'name',
    initValue: '初始化名字',
    component: 'Input',
    componentProps: {
      onInput: (e: any) => {
         console.log('姓名输入框输入事件', e)
         searchRef.value?.setSchemas([
             {
                  prop: 'age',
                  path: 'componentProps.placeholder',
                  value: `请输入${e}的年龄`
            }
         ])
      }
    }
  },
  {
    label: '年龄',
    prop: 'age',
    component: 'Input',
    initValue: 10,
    formItemProps: {
      rules:[
      {
        required: true,
        message: '请输入年龄',
        trigger: 'blur'
      }
    ]
    }
  },
  {
    label: '上学阶段',
    prop: 'jieduan',
    component: 'Select',
    componentProps: {
        options: [
          {
            label: '幼儿园',
            value: 1
          },
          {
            label: '其他阶段',
            value: 2
          }
        ]
      }
  },
]

// 改动前:
<Search 
   ref="searchRef" 
   :schema="searchColumn" 
   @search="(params) => console.log('点击查询:',{params})" 
   :showReset="false" 
   :isVaildSearch="true"
   > 
</Search>

// 改动后:
<Search
   ref="searchRef"
   :schema="searchColumn"
   @search="setSearchParams"
   :showReset="false" 
   :isVaildSearch="true"
>
</Search>

表格组件支持:提供register方法、及相关props接收

const tablecolumns = [
  {
    label: '姓名',
    prop: 'name',
    minWidth: '140px',
  },
  {
    label: '年龄',
    prop: 'age',
      minWidth: '140px',
  },
  {
     label: '上学阶段',
     prop: 'jieduan',
    minWidth: '140px',
  }
]

 <Comtable
     v-model:current="tableObject.current"
     v-model:pageSize="tableObject.pageSize"
     :rowKey="'id'"
     :columns="tablecolumns"
     :dataList="tableObject.tableData"
     :pagination="tableObject"
     :loading="tableObject.loading"
     @register="register"
   >
      <template #operation="{ row }">
         <el-button
            type="primary"
            size="small"
            link
            @click="editClick(row)"
            >{{$t('Edit')}}</el-button
         >
      </template>
</Comtable>

运行截图:

初始状态:

image.png

输入搜索:

image.png

分页变化:

image.png

以上就是useTable Hooks 的封装过程以及实战案例~

2025-last-week-summary

根据容器宽度超长省略

image.png

async fn 一定会返回一个promise, 它的返回是同步的,状态的改变时异步的

一定需要注意的性能优化和用户体验

  1. 产品,项目,测试和客户都比较注意的一个点就是对于列表的频繁切换,这里一定要用节流,就是在一定时间内的频繁点击,需要节流为一次点击。 不然就会出现快速点击十次,这时候如果再配合接口反应慢,会出现内容响应延迟, 内容跳动的情况。

2.如果遇到许多接口需要来自某一个变化的参数的话,比如列表点击会改变当前选中的行,当前行中存在详情接口,以及若干别的依赖的参数a, 那么就可以基于详情接口是否请求完毕,去做防抖,就是频繁点击详情接口的时候防抖,然后详情接口请求完毕之后在去请求别的接口,这种就不会出现多接口频繁请求混乱的问题

markdown 处理组件


import { Thinking } from "vue-element-plus-x";
import MarkdownIt from "markdown-it";
import MdMermaid from "mermaid-it-markdown";
import mermaid from "mermaid";

前端导出Pdf

import jsPDF from "jspdf";

// 函数:将HTML元素转换为PDF
const exportLoading = ref(false);
const headRef = ref();
const contentRef = ref();

const exportToPDF = () => {
  exportLoading.value = true;
  // 获取预览元素
  const previewElement = document.querySelector("#ai-report-content");
  // 使用html2canvas将预览内容转换为图片
  html2canvas(previewElement, {
    scale: 2, // 提高分辨率
    useCORS: true,
    logging: false
  })
    .then((canvas) => {
      // 创建jsPDF实例
      const pdf = new jsPDF("p", "mm", "a4");
      const ctx = canvas.getContext("2d");
      const a4w = 170;
      const a4h = 250; // A4大小,210mm x 297mm,四边各保留20mm的边距,显示区域170x257
      const imgHeight = Math.floor((a4h * canvas.width) / a4w); // 按A4显示比例换算一页图像的像素高度
      let renderedHeight = 0;

      while (renderedHeight < canvas.height) {
        const page = document.createElement("canvas");
        page.width = canvas.width;
        page.height = Math.min(imgHeight, canvas.height - renderedHeight); // 可能内容不足一页

        // 用getImageData剪裁指定区域,并画到前面创建的canvas对象中
        page
          .getContext("2d")
          .putImageData(
            ctx.getImageData(
              0,
              renderedHeight,
              canvas.width,
              Math.min(imgHeight, canvas.height - renderedHeight)
            ),
            0,
            0
          );
        pdf.addImage(
          page.toDataURL("image/jpeg", 1.0),
          "JPEG",
          20,
          20,
          a4w,
          Math.min(a4h, (a4w * page.height) / page.width)
        ); // 添加图像到页面,保留10mm边距

        renderedHeight += imgHeight;
        if (renderedHeight < canvas.height) {
          pdf.addPage(); // 如果后面还有内容,添加一个空页
        }
        // 预览pdf(这里我用的是事件总线把canvas传递过去展示,达到模拟pdf预览的效果,有用但效果不是很好,有需要的可以自行修改)
        //this.$EventBus.$emit('open-pdf', canvas);
      }

      // 保存PDF
      pdf.save(
        `${get(curCase, "value.case_name") || curCase.value?.police_number}-研判报告.pdf`
      );
    })
    .catch((error) => {
      console.error("生成PDF时出错:", error);
    })
    .finally(() => {
      exportLoading.value = false;
    });
};

Nest 和 Express 是什么关系?

Express 是一个处理请求、响应的库,基础使用

const express = require('express')
const cookieParser = require('cookie-parser')
const cookieValidator = require('./cookieValidator')

const app = express()

async function validateCookies (req, res, next) {
  await cookieValidator(req.cookies)
  next()
}

app.use(cookieParser())

app.use(validateCookies)

app.use((err, req, res, next) => {
  res.status(400).send(err.message)
})

app.listen(3000)

通过 use 一个个中间件来处理请求、返回响应。

这种调用链叫做洋葱模型

image.png

基于中间件能完成各种功能。

但是 Express 只是一个处理请求的库,并没有提供组织代码的架构能力

企业级开发,我们会用对它封装了一层的库,比如 Nest

Nest 提供了 IOC、AOP 等架构特性,规定了代码组织的形式,而且对 websocket、graphql、orm 等各种方案都提供了开箱即用的支持

Node 写服务三个顺序

  • 直接使用 http、https 的模块
  • 使用 express、koa 这种库
  • 使用 Nest 这种企业级框架

Nest 相当于 java 中的 Spring

一、Nest 和 Express 的真实关系

1️⃣ Nest 默认就是用 Express 跑的

const app = await NestFactory.create(AppModule)

这行代码底层等价于:

  • 创建一个 Express 实例
  • Nest 把所有 Controller / Middleware / Guard / Pipe 等
  • 统一挂到 Express 上

Nest ≈ Express + 架构层 + DI + AOP

当然你也可以切换成 Fastify:

NestFactory.create(AppModule, new FastifyAdapter())

二、Nest 在 Express 之上到底封装了什么?

1️⃣ 路由层:从「函数」升级为「类 + 装饰器」

Express 写法

app.get('/users/:id', (req, res) => {
  res.send(req.params.id)
})

Nest 写法(本质还是 Express)

@Controller('users')
export class UserController {
  @Get(':id')
  getUser(@Param('id') id: string) {
    return id
  }
}

Nest 做了什么?

  • 装饰器收集元数据

  • 启动时:

    • 扫描所有 Controller
    • 生成路由映射
    • 最终调用的还是 expressRouter.get(...)

👉 你写的是“声明式路由”,Nest 帮你转成 Express 路由


2️⃣ 中间件体系:Express Middleware + 生命周期顺序

Nest 没有发明新的中间件模型,而是:

  • 完整兼容 Express middleware
  • 但多了一层 执行顺序管理

Nest 的完整请求链路(关键)

Express Middleware
   ↓
Guard(权限)
   ↓
Pipe(参数校验 / 转换)
   ↓
Interceptor(before)
   ↓
Controller
   ↓
Interceptor(after)
   ↓
Exception Filter

Express 里你只能靠「中间件顺序 + 约定」
Nest 里这是 框架级别保证的顺序


3️⃣ DI(依赖注入):Express 完全没有的能力

Express 的痛点

const userService = new UserService()
const orderService = new OrderService(userService)
  • 谁先创建?
  • 单例怎么管?
  • 测试怎么 mock?

Nest:DI 容器统一托管

@Injectable()
export class UserService {}

@Controller()
export class AppController {
  constructor(private readonly userService: UserService) {}
}

Nest 做了什么?

  • 扫描 @Injectable() 元数据
  • 分析 constructor 参数
  • 自动创建依赖图
  • 控制生命周期(singleton / request / transient)

👉 这是 Nest 和 Express 最大的本质差异


4️⃣ AOP 能力(Express 没有)

Nest 的这些东西,在 Express 里你只能“手搓”:

Nest 能力 作用
Guard 权限、登录态
Pipe DTO 校验、类型转换
Interceptor 日志、耗时、缓存
Filter 统一异常处理

而且可以 精确到方法级别

@UseGuards(LoginGuard)
@UseInterceptors(TimeInterceptor)
@Get()
getData() {}

Express:全局 or 路由级 middleware
Nest:方法级 AOP


5️⃣ 模块系统:解决 Express 项目后期失控的问题

Express 项目后期常见问题

  • routes / services 到处 import
  • 依赖关系混乱
  • 拆服务极痛苦

Nest 的 Module 设计

@Module({
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

Nest 在 Express 之上加了:

  • 模块边界
  • provider 可见性控制
  • 子模块隔离

👉 这是为“多人协作 + 大项目”准备的


6️⃣ 底层依然是 Express API

Nest 不封死 Express

import { Request, Response } from 'express'

@Get()
get(@Req() req: Request, @Res() res: Response) {
  res.send('ok')
}

你甚至可以直接用原生 middleware:

app.use((req, res, next) => {
  console.log(req.url)
  next()
})

三、总结

Express 是 HTTP 框架

Nest 是“后端应用架构框架”

Nest 用 Express 负责:

  • 路由
  • 请求 / 响应

Nest 自己负责:

  • 架构
  • 依赖注入
  • 模块化
  • AOP
  • 可测试性
  • 工程规范

四、什么时候该用谁?

用 Express 就够的场景

  • 小工具
  • BFF / 轻接口
  • 一个人维护
  • 生命周期短

用 Nest 更稳的场景(企业级)

  • 中大型后台
  • 多模块、多角色
  • 需要长期维护
  • 对结构、测试、扩展有要求

React-深度解析Diff 算法中Key 的作用

前言

在 React 开发中,我们经常会在控制台看到 Each child in a list should have a unique "key" prop 的警告。Key 到底是什么?它仅仅是一个为了消除警告的“随机字符串”吗?本文将带你从底层原理出发,看透 Key 在 Diff 算法中的核心价值。

一、 核心概念:什么是 Key?

Key 是 React 用于追踪列表元素身份的唯一辅助标识。它就像是每个 DOM 元素的“身份证号”,让 React 在复杂的更新过程中,能够精准地识别哪些元素被修改、添加或删除。

  • 唯一性:Key 必须在同级元素(Siblings)之间保持唯一。
  • 稳定性:一个元素的 Key 应该在其整个生命周期内保持不变,不建议使用 Math.random() 动态生成。

二、 Key 在 Diff 算法中的作用

React 的 Diff 算法通过 Key 来实现节点复用,这是性能优化的关键:

  1. 匹配新旧元素:当状态更新引发列表变化时,React 会对比新旧两棵虚拟 DOM 树,寻找具有相同 Key 的元素。
  2. 复用现有节点:如果 Key 相同,React 会认为这是同一个组件实例。它会选择复用现有的 DOM 节点和组件状态,仅仅更新发生变化的属性(如 textContent 或 className)。
  3. 减少重绘:由于复用了节点,浏览器不需要执行昂贵的“销毁旧节点 -> 创建新节点”操作,极大提高了更新效率。

三、 实战:Key 的正确用法

在 TSX 中,当我们使用 map 方法渲染列表时,务必在返回的最外层标签上绑定 Key。

import React, { useState } from 'react';

interface Todo {
  id: string; // 唯一标识符
  text: string;
}

const TodoList: React.FC = () => {
  const [todos, setTodos] = useState<Todo[]>([
    { id: '1', text: '学习 React' },
    { id: '2', text: '整理掘金笔记' }
  ]);

  return (
    <ul>
      {/* 这里的 Key 使用数据的唯一 ID */}
      {todos.map((todo) => (
        <li key={todo.id}>
          {todo.text}
          <input type="checkbox" />
        </li>
      ))}
    </ul>
  );
};

export default TodoList;

四、 注意事项:为什么不能盲目使用 Index 作为 Key?

很多新手喜欢直接用数组的 index 作为 Key,但在逆序添加、删除或排序列表时,这会导致严重的性能问题和 UI Bug。

1. 性能降低

假设你在列表头部插入一条数据,原来的 index 0 变成了 index 1。React 会发现 Key 对应的“数据”变了,从而导致原本可以复用的节点全部被迫重新渲染(Re-render)。

2. 状态错位 Bug

如果列表项中包含非受控组件(如 <input />),使用 Index 作为 Key 会导致输入框内容“串位”。因为 React 认为 Key 没变,就复用了旧的 Input 节点及其内部的本地状态。


五、 总结与最佳实践

  • 首选方案:使用来自数据库的唯一 ID(如 UUID 或主键 ID)。
  • 备选方案:如果数据确实是静态的(永远不会排序、过滤、增删),且没有唯一 ID,可以使用 Index。
  • 禁忌:绝对不要在渲染时使用 Math.random()Date.now() 生成 Key。这会导致每次渲染 Key 都不同,React 将无法复用任何节点,造成巨大的性能浪费。

uni.request 二次封装

基于uni.request api进行二次封装

核心功能

  • 响应内容格式和请求参数格式类型定义
  • 请求拦截器与响应拦截器配置
  • 设置请求头和params参数处理
  • 加载提示与自定义提示文本
  • 错误统一处理
  • 接口缓存
  • 取消请求功能
  • 失败自动重试机制
  • 并发请求控制

核心代码

// 响应内容格式
export interface InResult<T> {
  code: number | string
  message: string
  success: boolean
# data: T
}
// 请求参数格式
interface RequestOptions {
  baseURL?: string
  url: string
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
  data?: Record<string, any>
  params?: Record<string, any>
  header?: Record<string, string>
  loading?: boolean // 是否显示加载中提示
  loadingText?: string // 加载中提示文本
  removeToken?: boolean // 是否移除token
  cache?: boolean // 是否缓存响应结果
  returnResponse?: boolean // 直接返回响应
}

// 设置请求头
function setRequestHeaders(url: string) {
  const LOGIN_URL = '/auth/oauth2/token'
  if (url.includes(LOGIN_URL)) {
    return { Authorization: `Basic Z2VveHNwYWNlOmdlb3hzcGFjZQ==` }
  }
  const token = uni.getStorageSync('token')
  if (token) {
    return { Authorization: `Bearer ${token}` }
  }
  return {} as Record<string, string>
}
// 请求和响应拦截器
function requestAndResponseInterceptor() {
  uni.addInterceptor('request', {
    // 调用前置拦截器
    invoke(options: RequestOptions) {
      if (options.loading) {
        uni.showLoading({
          title: options.loadingText || '加载中...',
          mask: true,
        })
      }

      options.header = {
        ...options.header,
        ...setRequestHeaders(options.url),
      }

      // 移除token
      if (options.removeToken) {
        delete options.header.Authorization
      }

      // 处理params 参数
      if (options.params) {
        const urlPrams: string[] = []
        Object.keys(options.params).forEach((key) => {
          urlPrams.push(`${key}=${options.params![key]}`)
        })
        if (options.url.includes('?')) {
          options.url += urlPrams.join('&')
        }
        else {
          options.url += `?${urlPrams.join('&')}`
        }
      }
      return options
    },

    // 调用后置拦截器
    success(res) {
      return res
    },
    fail(err) {
      uni.showToast({
        title: '网络请求失败',
        icon: 'none',
      })
      return Promise.reject(err)
    },

    complete(option) {
      console.log('option.errMsg', option.errMsg)
      setTimeout(() => {
        uni.hideLoading()
      }, 15000)
    },
  })
}

// 调用请求拦截器和响应拦截器
requestAndResponseInterceptor()

const cacheMap = new Map<string, any>()
// 封装网络请求
export async function request(options: RequestOptions): Promise<any> {
  const { baseURL, url, header = {}, cache, returnResponse } = options
  // 合并配置
  const config = {
    ...options,
    url: url.startsWith('http') ? options.url : baseURL + options.url,
    header: {
      'Content-Type': 'application/json',
      ...header, // 允许自定义header
    },
    timeout: 10000, // 超时时间(ms)
  }

  if (cache) {
    if (cacheMap.has(url)) {
      return cacheMap.get(url)
    }
  }

  try {
    const response = await uni.request(config)

    if (options.loading) {
      uni.hideLoading()
    }
    // 响应拦截器
    if (response.statusCode === 200) {
      const data = returnResponse ? response : response.data
      if (cache) {
        cacheMap.set(url, data)
      }

      // @ts-expect-error 判断异常
      if (response.data.code !== '20000') {
        // @ts-expect-error 判断异常
        toast(response.data.msg, {
          icon: 'fail',
        })
      }

      return data
    }
    if (response.statusCode === 401) {
      toast('登录已过期,请重新登录!', {}, () => {
        uni.redirectTo({
          url: '/user/login/index',
        })
      })
    }
    else {
      toast('系统服务异常!')
    }
  }
  catch (error) {
    return Promise.reject(error)
  }
}

toast 封装代码

export function toast(title: string, options?: { duration?: number, icon?: 'success' | 'loading' | 'error' | 'none' | 'fail' | 'exception', mask?: boolean }, callback?: () => void) {
  const { mask = true, duration = 1000, icon = 'none' } = options || {}

  if (title && title.length > 14) {  // 当作字符长度>14时使用showModal展示
    uni.showModal({
      content: title,
      showCancel: false,
      success() {
        if (callback && typeof callback === 'function') {
          const timer = setTimeout(() => {
            callback()
            clearTimeout(timer)
          }, duration)
        }
      },
    })
  }
  else {
    uni.showToast({
      title,
      message: title,
      icon,
      mask,
      duration,
      success() {
        if (callback && typeof callback === 'function') {
          const timer = setTimeout(() => {
            callback()
            clearTimeout(timer)
          }, duration)
        }
      },
    })
  }
}

使用示例

import { type InResult, request } from '@/utils/request'

export interface IUser {
    id: string,
    name: string,
    age?: number
}

export function getUserList(data: any): Promise<InResult<{ records: Array<IUser>, total: number }>> {
  return request({
    baseURL,
    url: '/user/page',
    method: 'POST',
    params: data,
    loading: true,
  })
}

一篇文章搞懂响应式布局:从媒体查询到 Bootstrap 栅格系统

在移动端成为主流的今天,「一套页面适配多种设备」已经是前端的基本能力。
这篇文章是我系统学习**响应式布局(Responsive Web Design)**后的阶段性总结,涵盖:

  • 响应式布局的核心思想
  • 媒体查询的使用方式
  • Bootstrap 框架与栅格系统
  • 实战中的布局策略与技术选型

如果你正在从 PC 端迈向移动端,这篇文章会非常有帮助。


一、什么是响应式布局?

响应式布局的本质只有一句话:

根据不同屏幕尺寸,动态调整页面的布局和样式,让同一套 HTML 在不同设备上都有良好的显示效果。

常见适配设备包括:

  • 手机
  • 平板
  • 笔记本
  • 桌面显示器

响应式布局的核心技术是:CSS 媒体查询(Media Query)


二、响应式布局的核心原理

1. 设备尺寸的常见划分

在实际开发中,通常会按以下区间来划分设备宽度:

  • 超小屏幕(手机):小于 768px
  • 小屏设备(平板):768px ~ 991px
  • 中等屏幕(桌面):992px ~ 1199px
  • 大屏设备(大屏显示器):1200px 及以上

注意:
这些不是死规定,而是业界常用参考,实际项目可以按设计稿自行调整


2. 响应式布局容器的思想

响应式页面通常会有一个最外层布局容器,用于控制整体宽度。

典型思路是:

  • 小屏设备:容器宽度 100%
  • 中屏设备:固定宽度(如 970px)
  • 大屏设备:更大的固定宽度(如 1170px)

通过媒体查询,在不同屏幕下修改容器宽度,从而影响内部布局。

示例:

.container {
  width: 100%;
  margin: 0 auto;
}

@media (min-width: 768px) {
  .container {
    width: 750px;
  }
}

@media (min-width: 992px) {
  .container {
    width: 970px;
  }
}

@media (min-width: 1200px) {
  .container {
    width: 1170px;
  }
}

三、响应式布局实战:导航栏案例思路

假设有一个导航栏,包含 8 个菜单项,需求如下:

  • 屏幕 ≥ 800px:

    • 导航栏宽度 800px
    • 每个菜单项固定宽度,一行显示
  • 屏幕 < 800px:

    • 导航栏宽度 100%
    • 每行显示 3 个菜单项,自动换行

实现关键点:

  • 外层容器负责宽度变化
  • 子元素通过百分比宽度适配
  • 使用媒体查询切换布局规则

这种「一套结构,多套样式」的思想,是响应式布局的核心。


四、为什么要使用 Bootstrap?

1. Bootstrap 是什么?

Bootstrap 是由 Twitter 推出的前端开发框架,提供了一整套:

  • 规范化的 CSS 样式
  • 成熟的响应式布局方案
  • 丰富的 UI 组件

可以把它理解为:
已经帮你写好的响应式解决方案集合


2. 使用 Bootstrap 的好处

  • 开发速度快
  • 样式统一、规范
  • 天然支持响应式布局
  • 社区成熟,文档齐全

特别适合:

  • 初学响应式布局
  • 快速搭建页面
  • 中后台或展示型项目

五、Bootstrap 栅格系统详解(重点)

1. 栅格系统的核心概念

Bootstrap 将页面横向划分为 12 等份,通过列数控制布局。

常见类前缀:

  • .col-xs-*:超小屏(手机)
  • .col-sm-*:小屏(平板)
  • .col-md-*:中屏(桌面)
  • .col-lg-*:大屏(大屏显示器)

示例:

<div class="row">
  <div class="col-md-4 col-sm-6">内容</div>
  <div class="col-md-8 col-sm-6">内容</div>
</div>

含义是:

  • 中屏以上:4 + 8
  • 小屏:6 + 6

2. 行(row)与列(column)

几点非常重要的规则:

  • .row 用来包裹列
  • .row 会抵消容器左右 15px 的 padding
  • 每一列默认有左右 15px 内边距
  • 一行的列数加起来不能超过 12

3. 列嵌套

列中可以继续使用 .row.col-*,实现更复杂的布局:

<div class="col-sm-4">
  <div class="row">
    <div class="col-sm-6">小列</div>
    <div class="col-sm-6">小列</div>
  </div>
</div>

4. 列偏移与排序

偏移列:

<div class="col-md-4 col-md-offset-4"></div>

排序列:

<div class="col-md-4 col-md-push-8"></div>
<div class="col-md-8 col-md-pull-4"></div>

在不改 HTML 结构的情况下,就能调整显示顺序,非常适合响应式场景。


六、响应式工具类的使用

Bootstrap 提供了一些工具类,用于控制元素显示与隐藏:

  • .hidden-xs
  • .hidden-sm
  • .hidden-md
  • .hidden-lg

可以非常方便地为不同设备定制内容展示策略。


七、真实项目中的布局策略总结

在实际开发中,我总结出一套比较稳妥的流程:

  1. 先写 PC 端(md 及以上)布局
  2. 再针对小屏、超小屏做调整
  3. 栅格系统优先,其次再写自定义样式
  4. 不追求「完全一样」,而是「体验合理」

八、移动端布局方案对比

常见方案包括:

  • 流式布局(百分比)
  • Flex 弹性布局(推荐)
  • rem 适配布局(推荐)
  • 响应式布局(媒体查询)

实际项目中,往往是多种方案混合使用,而不是只选一种。


九、总结

响应式布局并不是某一个 CSS 属性,而是一整套设计思想:

  • 一套 HTML
  • 多套 CSS
  • 根据设备动态切换布局规则

Bootstrap 帮我们降低了学习和使用门槛,但真正重要的,是理解:

屏幕变化 → 容器变化 → 子元素重新排列

理解了这一点,响应式布局就不再神秘了。

React-深度拆解 React Render 机制

前言

在 React 中,我们常说“渲染(Render)”,但它不仅仅是将 HTML 丢给浏览器那么简单。Render 是一个包含 计算(Reconciliation)提交(Commit) 的复杂过程。理解这一过程,能帮助我们写出更高性能的代码。

一、 Render 的核心三部曲

当 React 决定更新界面时,会经历以下三个关键阶段:

1. 创建虚拟 DOM (Virtual DOM)

JSX 本质上是 React.createElement() 的语法糖。Babel 会将 JSX 编译为 JS 调用,生成一个描述 UI 的对象树(即虚拟 DOM)。

结构定义参考:

// 编译后的逻辑(简化版)
const vDom = {
  type: 'div',
  props: {
    className: 'active',
    children: 'Hello'
  }
};

2. Diff 算法比较 (Reconciliation)

React 并不会盲目替换整个 DOM,而是通过 Diff 算法 对比“新旧两棵虚拟 DOM 树”。

  • 同层比较:只比较同一层级的节点。
  • 类型检查:如果节点类型变了(如 divp),则直接销毁重建。
  • Key 值优化:通过 key 属性识别节点是否只是移动了位置。

3. 渲染真实 DOM (Commit)

在计算出最小差异(Patches)后,React 的渲染器(如 react-dom)会将这些变更同步到真实浏览器环境,触发重绘与回流,使用户看到更新。


二、 触发渲染的四大时机

在函数式组件中,Render 过程可能由以下四种情况触发:

触发场景 描述
首次渲染 应用启动,将组件树完整挂载到页面上。
State 改变 当调用 useStateset 函数或 useReducerdispatch 时。
Props 改变 父组件重新渲染导致传给子组件的属性发生变化。
Context 改变 组件通过 useContext 订阅了上下文,且 Providervalue 发生变更。

三、 实战演示:观测渲染行为

我们可以通过简单的日志输出,来观察不同场景下的渲染行为。

import React, { useState, useContext, createContext } from 'react';

// 创建 Context
const AppContext = createContext(0);

// 子组件
const Child: React.FC<{ count: number }> = ({ count }) => {
  console.log("子组件 Render...");
  return <div>父级传入的 Props: {count}</div>;
};

// 顶层组件
const Home: React.FC = () => {
  const [num, setNum] = useState<number>(0);
  const [other, setOther] = useState<boolean>(false);

  console.log("Home 组件 Render...");

  return (
    <AppContext.Provider value={num}>
      <div style={{ padding: '20px' }}>
        <h2>Render 触发测试</h2>
        
        {/* 1. 修改 State 触发 */}
        <button onClick={() => setNum(prev => prev + 1)}>
          修改 State (Count: {num})
        </button>

        {/* 2. 这里的修改虽然没传给 Child,但父组件重新渲染会导致 Child 也重新渲染 */}
        <button onClick={() => setOther(!other)}>
          无关渲染测试: {String(other)}
        </button>

        {/* 3. Props 改变触发子组件渲染 */}
        <Child count={num} />
      </div>
    </AppContext.Provider>
  );
};

export default Home;

从打包到优化|Vue3 可 Tree-shaking API 分类与避坑要点

在 Vue3 的性能优化体系中,除了静态提升、PatchFlag、Block Tree 等渲染层面的优化,Tree-shaking(摇树优化)是构建层面的核心手段——它能自动移除项目中未被使用的代码,大幅缩减打包体积,尤其对中小型项目而言,体积优化效果可达 30% 以上。不同于 Vue2 全局引入导致大量冗余代码的问题,Vue3 从架构设计上原生支持 Tree-shaking,核心 API 均采用“按需引入”模式。本文将聚焦 Tree-shaking 在 Vue3 中的具体体现,重点梳理哪些 API 可被摇树、分类说明适用场景,同时拆解其底层实现逻辑与生效注意事项,完善 Vue3 优化知识体系。

一、先理清:Tree-shaking 是什么?Vue3 为何能原生支持?

Tree-shaking 本质是“消除死代码”的打包优化技术,依赖 ES6 模块的 import/export 语法(静态模块解析)——打包工具(Webpack、Vite、Rollup)能在编译阶段分析模块依赖,识别出未被引用的代码,将其从最终打包产物中移除。

Vue2 无法很好地支持 Tree-shaking,核心原因是其 API 多为全局挂载(如 Vue.componentVue.directive),即使未使用,也会被打包进产物;而 Vue3 彻底重构了 API 架构,采用“模块化导出”模式,所有 API 均通过 ES6 模块单独导出,未被引用的 API 可被打包工具精准识别并摇掉,实现“用多少、打包多少”。


// Vue2 全局挂载(无法 Tree-shaking)
import Vue from 'vue'
Vue.component('HelloWorld', HelloWorld) // 即使未使用该组件,也会打包

// Vue3 模块化导出(支持 Tree-shaking)
import { defineComponent, ref } from 'vue'
// 仅引用 defineComponent,ref 未被使用,打包时会被摇掉
export default defineComponent({
  setup() {
    // 未使用 ref
    return {}
  }
})

关键前提:Vue3 的 Tree-shaking 仅在 生产环境 生效,且打包工具需支持 ES6 模块解析(Webpack 4+、Vite、Rollup 均支持);开发环境为便于调试,不会移除未使用代码。

二、Vue3 中可被 Tree-shaking 的 API 全分类(附场景)

Vue3 的 API 按“功能模块”可分为核心 API、组件 API、指令 API、工具类 API 四大类,其中绝大多数 API 均可被 Tree-shaking,仅少数全局 API(需挂载到 App 实例)无法被摇掉。以下按分类梳理,明确每类可摇树 API 的用途与示例。

1. 核心响应式 API(最常用,均可摇树)

这类 API 是 Vue3 响应式系统的核心,均支持按需引入,未被使用时会被 Tree-shaking 移除,也是日常开发中体积优化的重点。

API 名称 功能说明 可摇树性 使用示例
ref 创建基本类型响应式数据 import { ref } from 'vue';
reactive 创建引用类型响应式数据 import { reactive } from 'vue';
computed 创建计算属性 import { computed } from 'vue';
watch 监听响应式数据变化 import { watch } from 'vue';
watchEffect 自动追踪响应式依赖的监听 import { watchEffect } from 'vue';
toRefs 将 reactive 对象转为 ref 集合 import { toRefs } from 'vue';

注意:这类 API 若仅引入未使用(如 import { ref } from 'vue' 但未创建 ref 数据),打包时会被完全摇掉,不会产生任何冗余代码。

2. 组件核心 API(组件开发必备,均可摇树)

这类 API 用于组件定义、生命周期管理,仅在组件开发中使用,未被引用时可被摇掉,核心包括组件定义、生命周期钩子、组件通信相关 API。

  • 组件定义 API:defineComponent(定义组件,支持 TypeScript 类型推导)、defineProps(定义组件 props)、defineEmits(定义组件事件)、defineExpose(暴露组件内部属性/方法)——均支持 Tree-shaking,仅在组件中使用时才会被打包。
  • 生命周期钩子:onMounted、onUpdated、onUnmounted、onBeforeMount 等所有组合式 API 生命周期钩子——按需引入,未使用的钩子会被摇掉(区别于 Vue2 全局生命周期)。
  • 组件通信 API:useAttrs、useSlots、provide、inject——仅在需要组件通信时引入,未使用时会被移除。

<script setup>
// 仅引入需要的组件 API,未使用的会被 Tree-shaking
import { defineProps, onMounted, provide } from 'vue'

const props = defineProps(['title'])

onMounted(() => {
  console.log('组件挂载完成')
})

// provide 仅引入未使用,打包时会被摇掉
</script>

3. 内置指令 API(按需使用,均可摇树)

Vue3 的内置指令中,除了 v-textv-html 等基础指令(默认打包,体积极小),其他指令均支持 Tree-shaking,仅在模板中使用时才会被打包。

核心可摇树指令 API 及场景:

  • v-model 相关:vModelText、vModelNumber、vModelCheckbox 等——仅在使用对应类型 v-model 时引入(如 v-model.number 需引入 vModelNumber)。
  • v-show:单独打包,未使用时会被摇掉(区别于 v-if,v-if 是模板语法,无需额外打包)。
  • 自定义指令相关:withDirectives(用于封装自定义指令)——仅在开发自定义指令时使用,未使用时会被移除。

4. 工具类 API(按需引入,均可摇树)

Vue3 提供了一系列工具类 API,用于辅助开发(如响应式数据转换、模板渲染等),这类 API 均为模块化导出,未被使用时会被 Tree-shaking 移除,核心包括:

  • 响应式工具:isRef、isReactive、isComputed、unref(判断/转换响应式数据);
  • 模板工具:h(创建 VNode,用于渲染函数)、render(渲染 VNode 到 DOM);
  • 其他工具:nextTick(等待 DOM 更新)、mergeProps(合并组件 props)。

// 按需引入工具类 API,未使用的会被摇掉
import { isRef, nextTick, h } from 'vue'

const count = ref(0)
if (isRef(count)) {
  console.log('count 是 ref 类型')
}

// nextTick 仅引入未使用,打包时被摇掉
// h 仅引入未使用,打包时被摇掉

5. 不可被 Tree-shaking 的 API(少数全局 API)

并非 Vue3 所有 API 都能被摇掉,以下全局 API 需挂载到 App 实例(如 app.use()app.component()),或为全局运行时依赖,即使未使用,也会被打包进产物(体积极小,无需担心):

  • 创建 App 相关:createApp(必用,全局依赖);
  • 全局挂载相关:app.use()、app.component()、app.directive()(挂载全局插件、组件、指令);
  • 基础全局 API:Vue.version(获取 Vue 版本,极少使用)。

三、Tree-shaking 在 Vue3 中的底层实现逻辑

Vue3 能实现高效 Tree-shaking,核心依赖“模块化架构设计”与“编译时标记”,具体分为两个层面:

1. 架构层面:ES6 模块化导出,避免全局挂载

Vue3 源码采用 ES6 模块编写,所有 API 均通过 export 单独导出(如 export const ref = ...),而非像 Vue2 那样挂载到全局 Vue 对象上。这种设计让打包工具能精准识别“哪些 API 被 import 且被使用”,未被引用的 API 会被判定为死代码,在打包时移除。

2. 编译层面:标记未使用代码,辅助打包工具摇树

Vue3 编译器在编译阶段(如 SFC 编译),会对引入但未使用的 API 进行标记,同时移除模板中未使用的指令、组件相关代码,为打包工具的 Tree-shaking 提供辅助,确保冗余代码被彻底移除。

例如,在 <script setup> 中引入但未使用的 API,编译器会在生成的代码中移除该 import 语句,进一步确保打包工具无需额外处理:


// 开发时代码(引入未使用)
import { ref, computed } from 'vue'
const count = 1

// 编译器处理后代码(移除未使用的 import)
import { } from 'vue'
const count = 1

// 打包工具最终处理(无任何冗余)
const count = 1

四、Tree-shaking 生效条件与避坑要点

Vue3 虽然原生支持 Tree-shaking,但在实际开发中,若配置不当或使用方式有误,会导致 Tree-shaking 失效,冗余代码无法被移除。以下是关键生效条件与常见坑点。

1. 生效条件(必满足)

  • 打包工具支持 ES6 模块解析:Webpack 4+、Vite、Rollup 均支持,需确保配置中未禁用 ES6 模块(如 Webpack 中 module.exports = { mode: 'production' } 即可)。
  • 使用 ES6 模块语法:必须使用 import/export 引入/导出 API,不可使用 CommonJS 语法(require/module.exports),否则 Tree-shaking 失效。
  • 生产环境打包:Vue3 仅在生产环境(process.env.NODE_ENV === 'production')下开启 Tree-shaking 相关优化,开发环境不会移除未使用代码。

2. 常见避坑点

  • 误区1:全局引入 Vue3 会触发 Tree-shaking——若使用 import Vue from 'vue' 全局引入,所有 API 都会被打包,无法摇掉;必须使用按需引入(import { ref } from 'vue')。
  • 误区2:引入但未使用的 API 一定会被摇掉——若 API 被间接引用(如通过第三方插件引用),或打包工具无法识别死代码(如配置错误),可能导致 Tree-shaking 失效,需检查打包配置。
  • 误区3:自定义指令/插件会影响 Tree-shaking——自定义指令/插件若采用按需引入模式(如 import { MyDirective } from './directive'),未使用时会被摇掉;若全局挂载(app.directive('my-dir', MyDirective)),则无法摇掉。
  • 误区4:Tree-shaking 会摇掉必要的运行时代码——Vue3 源码中已做好兼容,运行时核心依赖(如响应式系统基础逻辑)会被自动保留,不会被误摇掉。

五、实战验证:Tree-shaking 的体积优化效果

以一个简单的 Vue3 项目为例,对比“全局引入”与“按需引入”的打包体积差异(基于 Vite 打包,生产环境):

  1. 全局引入(未使用 Tree-shaking):import Vue from 'vue',打包后 Vue 相关体积约 50KB(gzip 后约 18KB);
  2. 按需引入(使用 Tree-shaking):仅引入 refdefineComponent,打包后 Vue 相关体积约 15KB(gzip 后约 6KB),体积缩减 70%。

实际项目中,随着 API 使用数量的增加,体积优化效果会略有下降,但总体仍能比 Vue2 减少 30%~50% 的冗余体积,尤其对轻量级应用(如移动端 H5、小程序),体积优化带来的加载速度提升更为明显。

六、总结:Tree-shaking 与 Vue3 优化体系的协同价值

Tree-shaking 在 Vue3 中的体现,本质是“模块化架构”与“按需引入”理念的落地,它与前文提到的静态提升、PatchFlag、Block Tree 等优化特性,形成了“构建层面+渲染层面”的全链路优化:

  • Tree-shaking:构建阶段移除未使用代码,缩减打包体积,提升加载速度;
  • 静态提升/PatchFlag/Block Tree:渲染阶段优化 VNode 创建与 Diff 效率,提升运行时性能。

梳理可被 Tree-shaking 的 API 后,我们能在实际开发中更有针对性地优化:优先采用按需引入模式,避免全局引入 API;减少“引入未使用”的冗余代码;合理使用第三方插件(选择支持 Tree-shaking 的插件)。

Vue3 对 Tree-shaking 的原生支持,彻底解决了 Vue2 体积冗余的痛点,让开发者无需额外配置,就能轻松实现项目体积优化。理解其底层原理与可摇树 API 清单,不仅能帮助我们写出更轻量的代码,更能深入掌握 Vue3 的架构设计思路,在性能优化场景中精准发力。

相关文章

避坑+实战|Vue3 hoistStatic静态提升,让渲染速度翻倍的秘密

吃透 Vue3 PatchFlag!8 大类标识含义+精准比对逻辑

Vue3 渲染优化双核心:Block Tree 原理与 Fragment 根节点妙用

volatile和synchronize有什么区别

volatile和synchronize有什么区别?

volatile关键字干了什么?(什么叫指令重排)

volatile 能否保证线程安全?在DCL上的作用是什么?

一.工作内存,主内存,CPU缓存是什么?线程在读写时是怎么运作的?

工作内存
  • 工作内存包括二个部分,一个是线程私有栈,一个是对主内存部分变量进行拷贝的寄存器【程序计数器PC和CPU高速缓存区】
  • 程序计数器PC【Program Counter】是用来存储 程序执行到 【当前行代码指令】的地址,程序在执行的过程中,PC会自动更新下一条指令。
  • 工作内存是是每个线程的私有内存区域(栈内存),用于存储线程的是私有数据,包括局部变量,方法,参数等,工作内存中的数据是主内存中的数据副本。
主内存(堆内存+java8叫元空间【java6叫方法区】)
  • 主内存是所有线程共享的内存区域,用于存储对象实例,静态变量等共享数据。保证正了数据的一致性和可见性。
CPU缓存
  • CPU缓存用于CPU和主内存之间的高速缓存(变量被缓存到CPU缓存中的时机取决于CPU的缓存策略、程序的数据访问模式以及变量的访问频率)

二.线程的读写机制

读取数据
  • 如果该变量在CPU缓存中存在,直接从CPU缓存中读取数据,并将数据复制到工作内存中,无需访问主内存。如果该变量在CPU缓存中不存在,从主内存中读取数据,并将数据复制到工作内存中.
写入数据
  • 线程首先在自己的工作内存中更新变量的值,会在合适的时候(线程结束,线程释放锁,使用volatile关键字修饰的变量,使用synchronized同步的代码块或者方法)将修改的值同步更新到主内存。

三.禁止指令重排(保证有序性)

  • 指令重排序是JVM为了优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度
  • volatile禁止指令重排,在读写操作指令前后会插入内存屏障
示例说明:
double r = 2.1; //(1)
double pi = 3.14;//(2)
double area = pi*r*r;//(3)

虽然代码语句的定义顺序为1->2->3,但是计算顺序1->2->3与2->1->3对结果并无影响,所以编译时和运行时可以根据需要对1、2语句进行重排序。

  • 指令重排序带来的问题 如果一个操作不是原子的,就会给JVM留下重排的机会。
线程A中
{
   context=loadContext();
   init=true;
}
线程B
{
   if(inited){
      fun(context)
   }
}

如果线程A中发生了指令重排序,那么B中很可能拿到一个尚未初始化完成的context,从而引发程序错误。

  • 指令重排在双重锁定单例模式中的影响基于双重检验的单例模式(懒汉型)
public class Singleton3 {
   private static Singleton3 instance = null;
   private Singleton3() {}
   public static Singleton3 getInstance() {
       if (instance == null) {
           synchronized(Singleton3.class) {
               if (instance == null)
                   instance = new Singleton3();// 非原子操作,可能会被指令重排
          }
      }
       return instance;
  }
}

instance= new Singleton()并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:

memory=allocate();//1.分配对象的内存空间
initInstance(memory);//2.初始化对象的引用
instance=memory;//3.将内存空间的地址赋值给对像的引用

上面的操作2依赖于操作1,但是操作3并不依赖于操作2。所以JVM是可以针对他们进行指令的优化,经过重排序后:

memory=allocate();//1.分配对象的内存空间
instance=memory;//3.将内存空间的地址赋值给对像的引用
initInstance(memory);//2.初始化对象

指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。

volatile在DCL(double-check-lock)的作用是什么?解决指令重排序

用volatile关键字修饰instance变量,使得instance在读、写操作前后都会插入内存屏障,避免重排序。

public class Singleton3 {
   private static volatile Singleton3 instance = null;
   private Singleton3() {}
   public static Singleton3 getInstance() {
       if (instance == null) {
           synchronized(Singleton3.class) {
               if (instance == null)
                   instance = new Singleton3();
          }
      }
       return instance;
  }
}

四.volatile和synchronize的区别?

  • volatile只能作用于变量(基本数据类型和对象引用 ),但是synchronized可以作用于变量(仅对象引用),方法,类(代码块).
  • volatile之保证了可见性(禁止Cpu缓存),有序性(禁止编译器优化),无法保证原子性(其读写操作在单个线程内看似连续,但实际上在多线程环境中可能受到线程切换的影响而被中断比如:count++),synchronized可以保证线程间的有序性,原子性和可见性。
可见性:synchronized 关键字可以保证可见性。当一个线程释放了一个 synchronized 监听器(锁)后,它对共享变量的修改会立即对其他线程可见。其他线程在获取同一个锁时,会看到最新的变量值。

有序性:synchronized 关键字也保证了有序性。它禁止了指令重排序,确保了代码的执行顺序与程序中的顺序一致。这有助于防止由于编译器或处理器的优化而导致的多线程问题。

原子性:synchronized 关键字可以保证原子性。在一个 synchronized 块或方法中,所有操作都是原子的,即要么全部执行,要么全部不执行。这确保了在多线程环境下对共享资源的访问是安全的。

  • volatile线程不阻塞,synchronized线程是阻塞的.
  • volatile本质是告诉jvm当前变量在CPU cache中的值是不安全的,需要从主内存中读取;synchronized则是锁定当前变量,只有当前线程可以访问当当前变量,其他线程被阻塞。
  • volatile标记的变量不会被编译器优化(禁止指令重排),synchronized标记的变量可以被编译器优化(可以被指令重排)

五.volatile能否保证线程的安全?

  • volatile无法保证线程的安全和变量的原子性,只能保证线程的可见性和有序性。
  • 原子性指的是一个或者多个操作在 CPU 执行的过程中不被中断的特性。
//其读写操作在单个线程内看似连续,但实际上在多线程环境中可能受到线程切换的影响而被中断
public class Main {
    public volatile static int count = 0;
    public static void main(String[] args) {
        count=1;
        //开启5个线程
        for (int i = 0; i < 5; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //让count的值自增100次
                    for (int j = 0; j < 100; j++) {
                        count++;
                        System.out.println("count= " + count);
                    }
                }
            }).start();
        }
    }
}
输出数据:
...
count= 495
count= 496
count= 497

上面的代码,我们开启了5个线程,每隔线程让静态变量count自增100次,执行之后会发现,最终的count 的结果值未必是500,有可能小于500,出现上面的情况的原因是volatile没有保证变量读写过程中的原子性,例如:A线程获取到count的值为2,此时主内存与工作内存一致,然后我们执行自增操作,count值为3,但是主内存的值很有可能被其他线程更新为了8或者其他数值,如果A线程执行更新主内存,那么数目就相当于往下降低了

六.作用范围对比

1.volatile作用的对象 volatile仅能修饰变量(基本数据类+对象引用),其核心功能是保证变量的可见性和禁止指令重排序。

private volatile int count=0;  //基本数据类
private static  volatile  SingleTon instance=null; //对象引用

2.synchronized 作用的对象

  • 变量 (锁定对象引用)
public class Count{
    private int count=0;
    private final Object   lock=new Object(); //锁定当前对象实例
    public void increment(){
          synchronized(lock){  /* 代码块 */
                 count++;
          }
    }
    public void getCount(){
         synchronized(lock){  /* 代码块 */
            return count;        
         }
    }
}

  • 普通方法 【锁定当前对象实例】
public synchronized void method{ /*方法体*/  }
  • 静态方法 【锁定的是类对象 MyClass.class】
public static  synchronized void method{ /* 方法体*/ }
  • 类对象
synchronized(MyClass.class){  /* 代码块 */  }

七 .并发控制的影响

1.实例方法锁的局限性
  • 同一个实例的线程竞争:若多个线程操作同一个实例的synchronized 普通方法,会因为争夺this 锁而互斥。

  • 不同实例的线程竞争:不同实例的线程调用各自的synchronized 普通方法,不会互相阻塞,因为锁的对象不同。

2.静态方法锁的全局性
  • 跨实例的线程竞争:所有线程调用给类的静态方法时,均需要竞争同一个类锁(Class对象),无论是哪个实例。
  • 静态与非静态的独立性:静态方法锁和实例方法锁互不干扰,因为锁对象不同(Class 对象 vs this)
场景:
SynchronizedExample obj1 = new SynchronizedExample();
SynchronizedExample obj2 = new SynchronizedExample();

// 线程1:调用 obj1 的实例方法(锁 obj1)
new Thread(() -> obj1.method()).start();

// 线程2:调用 obj2 的实例方法(锁 obj2)
new Thread(() -> obj2.method()).start(); // 不会阻塞,因锁对象不同

// 线程3:调用静态方法(锁 SynchronizedExample.class)
new Thread(() -> SynchronizedExample.staticMethod()).start(); // 所有静态方法调用均互斥

八.适用范围

  • 实例方法锁:适用于保护实例级别的共享资源,锁粒度小,适合多实例场景.
  • 静态方法锁:适用于保护类级别的全局资源,锁粒度较大,注意性能影响.

微信小程序开发中碰到在组件中监听app.globalData 中值得变化 处理相对应的数据

1.方法一 如果是简单数据 可以用Object.defineProperty 进行监听处理

Object.defineProperty 是 JavaScript 中的一个方法,用于直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。这个方法允许你精确地控制属性的特性,比如是否可写、是否可枚举、是否可配置等。

以下是 Object.defineProperty 方法的基本用法和参数说明
Object.defineProperty(obj, prop, descriptor)
  • obj:要在其上定义或修改属性的对象。
  • prop:要定义或修改的属性的名称,可以是一个字符串或 Symbol
  • descriptor:一个描述符对象,包含以下属性:
  • configurable:布尔值,表示属性是否可以被删除或重新配置。
  • enumerable:布尔值,表示属性是否可以在 for...in 循环或 Object.keys() 方法中被枚举。
  • writable:布尔值,表示属性值是否可以被改变。
  • value:属性的数据值,对于数据属性是必需的。
  • get:获取属性的访问器函数,对于存取器属性是必需的。
  • set:设置属性的访问器函数,对于存取器属性是必需的。
小程序中用法
//在app.js中配置全局函数
setWatcher(targetKey, callback) {
    const globalData = this.globalData;
    // 如果该属性尚未被监听,则为其定义访问器
    if (!globalData.hasOwnProperty(`_${targetKey}`)) {
      let val = globalData[targetKey];
      
      Object.defineProperty(globalData, targetKey, {
        configurable: true,
        enumerable: true,
        set(value) {
          val = value;
          // 调用所有注册的回调函数
          if (globalData._watchers && globalData._watchers[targetKey]) {
            globalData._watchers[targetKey].forEach((cb) => cb(value, val));
          }
        },
        get() {
          return val;
        }
      });
      
      // 初始化 watchers 存储对象
      if (!globalData._watchers) {
        globalData._watchers = {};
      }
      // 初始化该属性的回调函数数组
      if (!globalData._watchers[targetKey]) {
        globalData._watchers[targetKey] = [];
      }
    }
    
    // 将回调函数添加到该属性的监听列表中
    globalData._watchers[targetKey].push(callback);
  },
  
  
  //在相对应的组件中lifetimes中调用
   apps.setWatcher('role', (newValue:any, oldValue:any) => {
        console.log('role 已更新:', newValue);
        this.setData({
          userInfo: newValue
        });
      });
效果

971e286ff8719e8093d95b562db847b3.png

5408e68e0d633a90b79bbdd24fbc520b.png

2.方法二

例:

user:{ roles:{ role:0 } }需要修改role的值

可以用Proxy 对globalData进行代理

JavaScript的Proxy是一种对象代理机制,它可以在对象和函数之间添加一个中间层,从而实现对对象和函数的拦截和控制。Proxy可以用于拦截对象的读写、函数的调用、属性的枚举等操作,并在拦截时执行自定义的操作。使用Proxy可以实现各种高级功能,例如数据绑定、事件监听、缓存等。

以下是 Proxy 方法的基本用法和参数说明
let proxy = new Proxy(target, handler);

其中,target是要被代理的目标对象,handler是一个对象,用于定义拦截目标对象的各种操作的行为。handler对象可以包含以下方法:

  • get(target, prop, receiver):拦截对象属性的读取操作。
  • set(target, prop, value, receiver):拦截对象属性的写入操作。
  • apply(target, thisArg, args):拦截函数的调用操作。
  • construct(target, args, newTarget):拦截new操作符的调用操作。
  • has(target, prop):拦截in操作符的调用操作。
  • deleteProperty(target, prop):拦截delete操作符的调用操作。
  • defineProperty(target, prop, descriptor):拦截Object.defineProperty()方法的调用操作。
  • getOwnPropertyDescriptor(target, prop):拦截Object.getOwnPropertyDescriptor()方法的调用操作。
  • getPrototypeOf(target):拦截Object.getPrototypeOf()方法的调用操作。
  • setPrototypeOf(target, proto):拦截Object.setPrototypeOf()方法的调用操作。
  • isExtensible(target):拦截Object.isExtensible()方法的调用操作。
  • preventExtensions(target):拦截Object.preventExtensions()方法的调用操作。
  • ownKeys(target):拦截Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()、Object.keys()等方法的调用操作。
小程序中用法
//app.js中
// 代理全局数据
  initGlobalDataProxy() {
    const self = this;
    
    // 递归创建代理
    const createProxy = (data, path) => {
      if (typeof data !== 'object' || data === null) {
        return data;
      }
      
      return new Proxy(data, {
        get(target, prop, receiver) {
          const value = Reflect.get(target, prop, receiver);
          // 如果是对象则继续代理
          if (typeof value === 'object' && value !== null) {
            return createProxy(value, path ? `${path}.${String(prop)}` : String(prop));
          }
          return value;
        },
        set(target, prop, value, receiver) {
          const currentPath = path ? `${path}.${String(prop)}` : String(prop);
          const oldValue = Reflect.get(target, prop, receiver);
          const result = Reflect.set(target, prop, value, receiver);

          if (oldValue !== value) {
            console.log('数据变化:', currentPath, '新值:', value);
            
            // 1. 触发精确路径监听
            if (self?.globalDataListeners && self.globalDataListeners[currentPath]) {
              self.globalDataListeners[currentPath].forEach((callback) => {
                callback(value, currentPath);
              });
            }
            
            // 2. 触发父级路径监听(例如修改 user.role 时,如果监听了 user,也会触发)
            let parentPath = path;
            while (parentPath) {
              if (self?.globalDataListeners && self.globalDataListeners[parentPath]) {
                const parentValue = parentPath.split('.').reduce((obj, key) => obj[key], self.globalData);
                self.globalDataListeners[parentPath].forEach((callback) => {
                  callback(parentValue, parentPath);
                });
              }
              const lastDot = parentPath.lastIndexOf('.');
              parentPath = lastDot !== -1 ? parentPath.substring(0, lastDot) : '';
            }
          }
          return result;
        }
      });
    };

    this.globalData = createProxy(this.globalData);
  },
  // 添加监听器
  addGlobalDataListener(prop:any, callback:any) {
    if (!this.globalDataListeners) {
      this.globalDataListeners = {};
    }
    if (!this.globalDataListeners[prop]) {
      this.globalDataListeners[prop] = [];
    }
    this.globalDataListeners[prop].push(callback);
  }
  
  
  
  //在相对应的组件中lifetimes中调用
  apps.initGlobalDataProxy();
    
  // 监听 userInfo 变化
  apps.addGlobalDataListener('user', (newValue:any) => {
  console.log("更改值了",newValue.roles.role)
  });
 
效果

72185a009e9cb02f783c400ac40a8a30.png

就可以动态更改组件的需求了 当然这只是其中几种方法 可能有的小伙伴会有更好的方法 欢迎来讨论

为何 Node.js 环境中没有 DOM 和 BOM?

为何 Node.js 环境中没有 DOM 和 BOM?

核心答案

因为 DOM 和 BOM 不是 JavaScript 语言本身的一部分,而是浏览器提供的宿主环境 API。

  • DOM (Document Object Model):浏览器解析 HTML 后生成的文档对象模型
  • BOM (Browser Object Model):浏览器窗口相关的对象(window、navigator、location 等)

Node.js 是服务端运行时,没有浏览器窗口,没有 HTML 文档,自然不需要也无法提供这些 API。Node.js 提供的是服务端所需的 API(文件系统、网络、进程等)。

深入解析

底层机制

1. ECMAScript vs 宿主环境

类别 来源 示例
ECMAScript 标准 JS 语言规范 Array, Promise, class, async/await
浏览器宿主 API W3C/WHATWG 规范 document, window, fetch, localStorage
Node.js 宿主 API Node.js 项目 fs, http, process, Buffer

2. 为什么这样设计?

浏览器的职责:                    Node.js 的职责:
├── 渲染网页                      ├── 执行服务端逻辑
├── 处理用户交互                   ├── 文件读写
├── 管理页面导航                   ├── 网络服务
└── 多媒体播放                     └── 系统调用

不同的运行环境有不同的需求,提供不同的 API 是合理的设计。

3. V8 引擎的角色

V8 只负责执行 JavaScript 代码,它本身不包含 DOM/BOM:

V8 引擎提供:
├── JS 代码解析和编译
├── 执行字节码
├── 垃圾回收
└── ECMAScript 标准内置对象

V8 不提供:
├── DOM 操作
├── 网络请求
├── 文件系统
└── 任何 I/O 操作

常见误区

  1. "JavaScript 天生就有 document 和 window"

    • 错误!这些是浏览器注入的全局对象
  2. "Node.js 是阉割版的 JavaScript"

    • 错误!Node.js 完整实现了 ECMAScript,只是宿主 API 不同
  3. "console.log 是 JavaScript 的一部分"

    • 严格来说不是!console 是宿主环境提供的,只是浏览器和 Node.js 都实现了它

代码示例

环境检测

// 检测当前运行环境
function detectEnvironment() {
    // 浏览器环境
    if (typeof window !== 'undefined' && typeof document !== 'undefined') {
        console.log('浏览器环境');
        console.log('window:', typeof window);      // object
        console.log('document:', typeof document);  // object
        console.log('navigator:', typeof navigator); // object
    }

    // Node.js 环境
    if (typeof process !== 'undefined' && process.versions?.node) {
        console.log('Node.js 环境');
        console.log('process:', typeof process);    // object
        console.log('__dirname:', typeof __dirname); // string (CommonJS)
        console.log('window:', typeof window);      // undefined
        console.log('document:', typeof document);  // undefined
    }
}

detectEnvironment();

跨环境兼容代码

// 同构/通用 JavaScript 代码示例
const isNode = typeof process !== 'undefined'
    && process.versions != null
    && process.versions.node != null;

const isBrowser = typeof window !== 'undefined'
    && typeof window.document !== 'undefined';

// 根据环境使用不同的 API
async function fetchData(url) {
    if (isBrowser) {
        // 浏览器使用 fetch API
        return fetch(url).then(res => res.json());
    } else if (isNode) {
        // Node.js 18+ 也有 fetch,或使用 http 模块
        const { default: fetch } = await import('node-fetch');
        return fetch(url).then(res => res.json());
    }
}

Node.js 中模拟 DOM(jsdom)

// 在 Node.js 中使用 jsdom 模拟浏览器环境
const { JSDOM } = require('jsdom');

const dom = new JSDOM(`
    <!DOCTYPE html>
    <html>
        <body>
            <div id="app">Hello</div>
        </body>
    </html>
`);

// 现在可以使用 DOM API 了
const document = dom.window.document;
const app = document.getElementById('app');
console.log(app.textContent); // "Hello"

app.textContent = 'Hello from Node.js!';
console.log(dom.serialize()); // 输出修改后的 HTML

全局对象对比

// 浏览器中的全局对象
// window === globalThis === self (在主线程中)

// Node.js 中的全局对象
// global === globalThis

// 通用写法(ES2020+)
console.log(globalThis); // 在任何环境都能获取全局对象

面试技巧

面试官可能的追问方向

  1. "那 Node.js 怎么做服务端渲染 (SSR)?"

    • 回答:使用 jsdom、happy-dom 等库模拟 DOM 环境,或使用 React/Vue 的服务端渲染 API(renderToString)直接生成 HTML 字符串,不需要真正的 DOM
  2. "fetch 是 JavaScript 的一部分吗?"

    • 回答:不是,fetch 是 WHATWG 规范定义的 Web API。Node.js 18+ 才原生支持,之前需要 node-fetch 等 polyfill
  3. "为什么 setTimeout 在 Node.js 中也能用?"

    • 回答:因为 Node.js 选择实现了这个 API 以保持兼容性,但实现机制不同(Node.js 用 libuv,浏览器用事件循环)
  4. "globalThis 是什么?"

    • 回答:ES2020 引入的标准,统一获取全局对象的方式,解决了 window/global/self 在不同环境不一致的问题

如何展示深度理解

  • 区分 ECMAScript 规范宿主环境 API
  • 了解 V8 引擎 的职责边界
  • 知道 jsdomhappy-dom 等工具的存在和用途
  • 理解 同构 JavaScript 的概念和挑战
  • 提及 DenoBun 等新运行时对 Web API 的支持程度不同

一句话总结

DOM/BOM 是浏览器的"特产",不是 JavaScript 的"标配"——JS 只是一门语言,能做什么取决于宿主环境给它什么 API。

向完全不懂编程的产品经理解释 JavaScript 是什么。你会怎么说?

想象你正站在一个完全不懂编程的 产品经理 面前,试图向他解释 JavaScript 是什么。你会怎么说?请尝试用通俗易懂的语言(比如打比方)向他解释 ECMAScript、DOM 和 BOM 的关系。

核心答案

我会这样向产品经理解释:

JavaScript 就像是一个"万能工人",它能让网页从"死"的变成"活"的。

打个比方:

  • ECMAScript 是工人的"技能手册"——规定了他会哪些基本动作(比如说话、走路、数数)
  • DOM 是工人和"页面内容"打交道的方式——让他能修改页面上的文字、图片、按钮
  • BOM 是工人和"浏览器环境"打交道的方式——让他能控制浏览器窗口、历史记录、弹出提示框

简单关系图:ECMAScript(核心能力) + DOM(操作页面) + BOM(操作浏览器) = 完整的 JavaScript


深入解析

1. 用更形象的比喻

想象你在装修一个房子

组成部分 比喻 实际作用
ECMAScript 装修队的施工规范 定义语法、变量、循环、函数等基本规则
DOM 和家具、墙壁打交道 操作页面元素:修改文字、样式、添加删除元素
BOM 和房子本身打交道 操作浏览器:控制窗口大小、跳转页面、本地存储

2. 技术层面的解释

ECMAScript
  • 是 JavaScript 的语言标准,由 ECMA 国际组织制定
  • 只规定语言的语法和核心功能
  • 不涉及任何浏览器或环境相关的内容
  • 最新版本:ES6/ES2015、ES2024 等
DOM(Document Object Model)
  • 把 HTML 文档解析成树形结构
  • 提供了一组 API 让 JavaScript 能操作页面
  • 是 W3C 标准,不只是 JavaScript 专用
BOM(Browser Object Model)
  • 提供与浏览器交互的接口
  • 没有统一标准,不同浏览器实现有差异
  • 主要对象:windowlocationnavigatorhistorylocalStorage

3. 常见误区

误区1:ECMAScript 和 JavaScript 是同一个东西

纠正:ECMAScript 是标准规范,JavaScript 是这个规范的实现(类似接口和实现类的关系)

误区2:DOM 是 JavaScript 的一部分

纠正:DOM 是独立的标准,其他语言(如 Python)也能操作 DOM

误区3:BOM 有统一标准

纠正:BOM 长期缺乏标准,各浏览器实现不同,HTML5 规范后才逐步统一


代码示例

// ========== ECMAScript:核心语法 ==========
// 这些语法在任何符合 ES 标准的环境中都能运行

// 1. 基础语法
const name = 'JavaScript';
let count = 0;

// 2. 函数
function greet(user) {
    return `Hello, ${user}`;
}

// 3. 循环
for (let i = 0; i < 5; i++) {
    count++;
}

// 4. 对象和数组
const user = {
    name: 'Alice',
    age: 25,
    skills: ['js', 'html']
};


// ========== DOM:操作页面内容 ==========
// 只有在浏览器环境才可用

// 1. 获取元素
const button = document.querySelector('#myButton');
const title = document.getElementById('title');

// 2. 修改内容
title.textContent = '新的标题';

// 3. 修改样式
button.style.backgroundColor = 'blue';

// 4. 事件监听
button.addEventListener('click', () => {
    alert('按钮被点击了!');
});

// 5. 动态创建元素
const newDiv = document.createElement('div');
newDiv.className = 'box';
document.body.appendChild(newDiv);


// ========== BOM:操作浏览器 ==========
// 不同浏览器可能有差异

// 1. window 对象(BOM 的核心)
console.log(window.innerWidth);   // 浏览器窗口宽度
window.scrollTo(0, 500);          // 滚动页面

// 2. location(页面跳转)
console.log(location.href);       // 当前 URL
location.reload();                // 刷新页面

// 3. history(历史记录)
history.back();                   // 返回上一页
history.go(2);                    // 前进两页

// 4. navigator(浏览器信息)
console.log(navigator.userAgent);  // 浏览器标识

// 5. localStorage(本地存储)
localStorage.setItem('key', 'value');
const data = localStorage.getItem('key');

// 6. 定时器(window 的方法)
setTimeout(() => {
    console.log('1秒后执行');
}, 1000);

面试技巧

面试官可能的追问

  1. "ES6 新增了哪些特性?"

    • 回答:let/const、箭头函数、解构赋值、Promise、class、模块化等
  2. "DOM 操作为什么慢?"

    • 回答:会触发重排和重绘,建议用文档片段或虚拟 DOM
  3. "BOM 中有哪些常用的 API?"

    • 回答:localStorage、sessionStorage、history、location、navigator
  4. "JavaScript 只能在浏览器运行吗?"

    • 回答:不是,Node.js 让 JS 能在服务器运行,但没有 DOM/BOM

如何展示深度理解

  1. 提到标准的演进:说明 DOM 从混乱到标准化的历史
  2. 谈性能问题:DOM 操作的性能影响,如何优化
  3. 跨平台思考:Node.js、React Native 如何复用 ECMAScript
  4. 实际经验:举例说明处理过浏览器兼容性问题

一句话总结

ECMAScript 是语言规则,DOM 是操作页面的手,BOM 是操作浏览器的手——三者共同构成了我们在网页开发中使用的 JavaScript。

【连接篇】TCP/UDP 与前端性能的物理极限

作为《前端视角下的网络协议》系列的第一篇,我们不聊那些复杂的报文格式,而是聊聊物理规律

对于前端开发者来说,网络环境是一个“黑盒”。你写了一行 fetch('/api/data'),但在数据到达浏览器之前,它必须跨越数千公里的光纤、路由器和交换机。在这段旅程中,TCP 的连接机制决定了你的首屏加载速度(FP)存在一个无法逾越的“物理极限”。


一、 TCP 三次握手:避不开的 RTT 消耗

每一个 HTTP/1.x 或 HTTP/2 连接在传输数据前,都必须经过 TCP 三次握手。

  1. SYN (浏览器 -> 服务器)
  2. SYN-ACK (服务器 -> 浏览器)
  3. ACK (浏览器 -> 服务器)

对于前端性能来说,这意味着:在你的第一个字节(TTFB)发出之前,已经消耗了 1.5 个 RTT(往返时延)。

  • 物理极限: 如果用户在上海,服务器在纽约,单程延迟约 150ms,那么握手就要消耗 450ms。无论你的前端代码优化得多么极致,这近半秒的白屏是物理层面的“死刑”。

  • 优化对策: * CDN 边缘加速: 让握手发生在离用户最近的节点。

    • Connection Keep-Alive: 复用长连接,避免为每个请求重新握手。

二、 慢启动与“14KB 规则”:为什么 HTML 体积至关重要?

这是前端开发者最容易忽略的 TCP 特性:拥塞窗口(Congestion Window)

TCP 为了防止网络拥塞,不会一上来就全速发送数据。它会从一个很小的初始窗口(通常是 10 个段,约 14.6KB)开始尝试。如果接收方成功确认,窗口才会翻倍(20, 40, 80...)。

  • 前端含义: 你的首屏关键 HTML 和内联 CSS 最好控制在 14KB 以内。

    • 如果 HTML 是 10KB:只需 1 个往返即可完成下载并开始解析渲染。
    • 如果 HTML 是 20KB:需要 2 个往返。在网络环境差的情况下,这多出来的一个 RTT 可能意味着用户多看 200ms 的白屏。
  • 结论: 极致的性能优化,是从精简首屏 HTML 字节数开始的。


三、 TCP 队头阻塞:HTTP/2 的软肋

虽然 HTTP/2 实现了多路复用(Multiplexing),让我们可以并行下载资源,但它依然跑在 TCP 上。

  • 问题所在: TCP 是一个“流”协议,它必须保证包的顺序。如果其中一个 TCP 包在传输中丢失,即使后续的包已经到达浏览器,TCP 也必须等那个丢掉的包重传成功后,才能把数据交给浏览器。
  • 性能杀手: 即使你并行下载 10 个图片,只要丢了一个包,所有的图片下载都会被卡住。这就是 TCP 级别的队头阻塞(Head-of-Line Blocking)

四、 UDP 与 HTTP/3:打破物理枷锁

为了彻底解决 TCP 的顽疾,Google 推出了基于 UDP 的 QUIC 协议(即 HTTP/3)。

  1. 0-RTT 握手: UDP 无需握手。QUIC 在建立连接时可以实现 0-RTT 或 1-RTT,比 TCP 快得多。
  2. 解决队头阻塞: QUIC 在 UDP 之上实现了自己的流控制。如果一个流丢包,只会影响该流,其他流(其他资源请求)可以继续传输。
  3. 连接迁移: 用户从 WiFi 切换到 5G 时,IP 地址变了,TCP 连接会断开。但 QUIC 基于 Connection ID,可以实现无缝切换,不断连。

💡 前端开发者的硬核总结

  1. 首屏 HTML 尽量压缩在 14KB 以内: 避开 TCP 慢启动的第二次往返。
  2. 利用 Preconnect: 在 HTML 头部加入 <link rel="preconnect" href="https://api.example.com">,让浏览器提前完成那耗时的 TCP 握手。
  3. 关注 HTTP/3 普及率: 如果你的用户群网络环境复杂(如移动端、弱网),开启 HTTP/3 将带来质的飞跃。

结语

理解了 TCP 的物理限制,你就明白了为什么“减少请求数”和“减小包体积”永远是前端优化的金科玉律。网络协议不是为了增加难度,而是划定了性能的边界。

🔥 Vue3 + TS 实现长按指令 v-longPress:优雅解决移动端/PC端长按交互需求

🔥 Vue3 + TS 实现长按指令 v-longPress:优雅解决移动端/PC端长按交互需求

在前端开发中,长按(Long Press)是高频交互场景(如移动端删除操作、PC端右键菜单、批量操作触发等)。原生HTML没有直接的长按事件,通常需要通过 mousedown/touchstart 结合定时器实现。本文将教你基于 Vue3 + TypeScript 实现一个功能完善、体验友好、跨端兼容v-longPress 自定义指令,支持自定义长按时长、触发回调、取消触发等特性,开箱即用。

在这里插入图片描述

🎯 指令核心特性

  • ✅ 跨端兼容:同时支持PC端(鼠标长按)和移动端(触摸长按)
  • ✅ 自定义配置:支持自定义长按时长、触发回调、取消回调
  • ✅ 防误触优化:按下后移动超过阈值自动取消长按
  • ✅ 完整 TypeScript 类型定义,开发提示友好
  • ✅ 支持指令参数动态更新,适配动态业务场景
  • ✅ 自动清理定时器/事件监听,无内存泄漏
  • ✅ 支持阻止默认行为/冒泡,适配复杂交互场景

📁 完整代码实现(v-longPress.ts)

// directives/v-longPress.ts
import type { ObjectDirective, DirectiveBinding, App } from 'vue'

/**
 * 长按指令配置接口
 */
export interface LongPressOptions {
  /** 长按触发时长(ms),默认500ms */
  duration?: number
  /** 长按触发的回调函数 */
  handler: (e: MouseEvent | TouchEvent) => void
  /** 长按取消的回调函数(可选) */
  cancelHandler?: (e: MouseEvent | TouchEvent) => void
  /** 移动阈值(px),超过则取消长按,默认5px */
  moveThreshold?: number
  /** 是否阻止默认行为,默认true */
  preventDefault?: boolean
  /** 是否阻止事件冒泡,默认false */
  stopPropagation?: boolean
}

/**
 * 扩展元素属性,存储长按相关状态
 */
interface LongPressElement extends HTMLElement {
  _longPress?: {
    options: LongPressOptions
    timer: number | null          // 长按定时器
    startX: number                // 按下时X坐标
    startY: number                // 按下时Y坐标
    isPressing: boolean           // 是否正在长按中
    // 事件处理函数(绑定this,便于移除监听)
    mouseDownHandler: (e: MouseEvent) => void
    touchStartHandler: (e: TouchEvent) => void
    mouseUpHandler: (e: MouseEvent) => void
    touchEndHandler: (e: TouchEvent) => void
    mouseMoveHandler: (e: MouseEvent) => void
    touchMoveHandler: (e: TouchEvent) => void
    mouseLeaveHandler: (e: MouseEvent) => void
  }
}

/**
 * 默认配置
 */
const DEFAULT_OPTIONS: Omit<LongPressOptions, 'handler'> = {
  duration: 500,
  moveThreshold: 5,
  preventDefault: true,
  stopPropagation: false
}

/**
 * 计算两点之间的距离
 * @param x1 起始X坐标
 * @param y1 起始Y坐标
 * @param x2 结束X坐标
 * @param y2 结束Y坐标
 * @returns 距离(px)
 */
const calculateDistance = (x1: number, y1: number, x2: number, y2: number): number => {
  return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
}

/**
 * 清除长按相关状态和定时器
 * @param el 目标元素
 * @param e 触发事件(可选,用于取消回调)
 */
const clearLongPressState = (el: LongPressElement, e?: MouseEvent | TouchEvent) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // 清除定时器
  if (longPressData.timer) {
    clearTimeout(longPressData.timer)
    longPressData.timer = null
  }

  // 触发取消回调
  if (longPressData.isPressing && longPressData.options.cancelHandler && e) {
    longPressData.options.cancelHandler(e)
  }

  // 重置状态
  longPressData.isPressing = false
}

/**
 * 绑定事件监听
 * @param el 目标元素
 */
const bindEvents = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // PC端事件
  el.addEventListener('mousedown', longPressData.mouseDownHandler)
  el.addEventListener('mouseup', longPressData.mouseUpHandler)
  el.addEventListener('mousemove', longPressData.mouseMoveHandler)
  el.addEventListener('mouseleave', longPressData.mouseLeaveHandler)

  // 移动端事件
  el.addEventListener('touchstart', longPressData.touchStartHandler)
  el.addEventListener('touchend', longPressData.touchEndHandler)
  el.addEventListener('touchmove', longPressData.touchMoveHandler)
}

/**
 * 解绑事件监听
 * @param el 目标元素
 */
const unbindEvents = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // PC端事件
  el.removeEventListener('mousedown', longPressData.mouseDownHandler)
  el.removeEventListener('mouseup', longPressData.mouseUpHandler)
  el.removeEventListener('mousemove', longPressData.mouseMoveHandler)
  el.removeEventListener('mouseleave', longPressData.mouseLeaveHandler)

  // 移动端事件
  el.removeEventListener('touchstart', longPressData.touchStartHandler)
  el.removeEventListener('touchend', longPressData.touchEndHandler)
  el.removeEventListener('touchmove', longPressData.touchMoveHandler)
}

/**
 * 初始化长按事件处理函数
 * @param el 目标元素
 */
const initHandlers = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return
  const { options } = longPressData

  // PC端鼠标按下
  longPressData.mouseDownHandler = (e: MouseEvent) => {
    // 阻止右键菜单(仅左键触发)
    if (e.button !== 0) return

    // 阻止默认行为/冒泡
    if (options.preventDefault) e.preventDefault()
    if (options.stopPropagation) e.stopPropagation()

    // 记录起始坐标
    longPressData.startX = e.clientX
    longPressData.startY = e.clientY
    longPressData.isPressing = true

    // 设置长按定时器
    longPressData.timer = window.setTimeout(() => {
      if (longPressData.isPressing) {
        options.handler(e)
        longPressData.isPressing = false
      }
    }, options.duration!) as unknown as number
  }

  // 移动端触摸开始
  longPressData.touchStartHandler = (e: TouchEvent) => {
    // 阻止默认行为/冒泡
    if (options.preventDefault) e.preventDefault()
    if (options.stopPropagation) e.stopPropagation()

    // 记录起始坐标(取第一个触摸点)
    const touch = e.touches[0]
    longPressData.startX = touch.clientX
    longPressData.startY = touch.clientY
    longPressData.isPressing = true

    // 设置长按定时器
    longPressData.timer = window.setTimeout(() => {
      if (longPressData.isPressing) {
        options.handler(e)
        longPressData.isPressing = false
      }
    }, options.duration!) as unknown as number
  }

  // PC端鼠标抬起
  longPressData.mouseUpHandler = (e: MouseEvent) => {
    clearLongPressState(el, e)
  }

  // 移动端触摸结束
  longPressData.touchEndHandler = (e: TouchEvent) => {
    clearLongPressState(el, e)
  }

  // PC端鼠标移动
  longPressData.mouseMoveHandler = (e: MouseEvent) => {
    if (!longPressData.isPressing) return

    // 计算移动距离,超过阈值取消长按
    const distance = calculateDistance(
      longPressData.startX,
      longPressData.startY,
      e.clientX,
      e.clientY
    )

    if (distance > options.moveThreshold!) {
      clearLongPressState(el, e)
    }
  }

  // 移动端触摸移动
  longPressData.touchMoveHandler = (e: TouchEvent) => {
    if (!longPressData.isPressing) return

    // 计算移动距离,超过阈值取消长按
    const touch = e.touches[0]
    const distance = calculateDistance(
      longPressData.startX,
      longPressData.startY,
      touch.clientX,
      touch.clientY
    )

    if (distance > options.moveThreshold!) {
      clearLongPressState(el, e)
    }
  }

  // PC端鼠标离开元素
  longPressData.mouseLeaveHandler = (e: MouseEvent) => {
    clearLongPressState(el, e)
  }
}

/**
 * 清理所有长按相关资源
 * @param el 目标元素
 */
const cleanup = (el: LongPressElement) => {
  // 清除状态和定时器
  clearLongPressState(el)
  
  // 解绑事件
  unbindEvents(el)
  
  // 删除扩展属性
  delete el._longPress
}

/**
 * v-longPress 自定义指令实现
 */
export const longPressDirective: ObjectDirective<LongPressElement, LongPressOptions | (() => void)> = {
  /**
   * 指令挂载时初始化
   */
  mounted(el: LongPressElement, binding: DirectiveBinding<LongPressOptions | (() => void)>) {
    // 1. 解析指令参数
    let options: LongPressOptions = {
      ...DEFAULT_OPTIONS,
      handler: () => {}
    }

    if (typeof binding.value === 'function') {
      // 直接传函数:作为长按触发回调,使用默认配置
      options.handler = binding.value
    } else if (typeof binding.value === 'object' && binding.value !== null) {
      // 传对象:合并配置
      options = {
        ...DEFAULT_OPTIONS,
        ...binding.value
      }
    }

    // 校验必填项
    if (typeof options.handler !== 'function') {
      console.warn('[v-longPress] 必须指定有效的长按回调函数')
      return
    }

    // 2. 初始化长按状态
    el._longPress = {
      options,
      timer: null,
      startX: 0,
      startY: 0,
      isPressing: false,
      mouseDownHandler: () => {},
      touchStartHandler: () => {},
      mouseUpHandler: () => {},
      touchEndHandler: () => {},
      mouseMoveHandler: () => {},
      touchMoveHandler: () => {},
      mouseLeaveHandler: () => {}
    }

    // 3. 初始化事件处理函数
    initHandlers(el)

    // 4. 绑定事件监听
    bindEvents(el)

    // 5. 添加长按样式提示
    el.style.cursor = 'pointer'
  },

  /**
   * 指令更新时处理参数变化
   */
  updated(el: LongPressElement, binding: DirectiveBinding<LongPressOptions | (() => void)>) {
    // 先清理旧配置
    cleanup(el)
    
    // 重新初始化
    this.mounted(el, binding)
  },

  /**
   * 指令卸载时清理资源
   */
  unmounted(el: LongPressElement) {
    cleanup(el)
  }
}

/**
 * 全局注册长按指令
 * @param app Vue应用实例
 * @param directiveName 指令名称,默认longPress
 */
export const setupLongPressDirective = (app: App, directiveName: string = 'longPress') => {
  app.directive(directiveName, longPressDirective)
}

// TypeScript 类型扩展
declare module 'vue' {
  export interface ComponentCustomDirectives {
    longPress: typeof longPressDirective
  }
}

🚀 快速上手

1. 全局注册指令(main.ts)

在 Vue3 入口文件中注册指令,全局可用:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { setupLongPressDirective } from './directives/v-longPress'

const app = createApp(App)

// 注册长按指令(默认名称v-longPress)
setupLongPressDirective(app)

app.mount('#app')

2. 基础使用(直接传回调)

最简单的用法:直接传递长按触发的回调函数,使用默认配置(500ms长按时长):

<template>
  <!-- PC端鼠标长按/移动端触摸长按触发 -->
  <button v-longPress="handleLongPress">
    长按500ms触发
  </button>
</template>

<script setup lang="ts">
// 长按触发回调
const handleLongPress = (e: MouseEvent | TouchEvent) => {
  console.log('长按触发', e)
  alert('长按成功!')
}
</script>

3. 高级使用(自定义配置)

通过对象参数配置完整的长按规则,支持自定义时长、移动阈值、取消回调等:

<template>
  <div class="card">
    <div 
      class="delete-btn"
      v-longPress="{
        duration: 800,          // 长按800ms触发
        moveThreshold: 10,      // 移动超过10px取消
        handler: handleDelete,  // 长按触发回调
        cancelHandler: handleCancel, // 长按取消回调
        preventDefault: true,   // 阻止默认行为
        stopPropagation: true   // 阻止事件冒泡
      }"
    >
      长按删除(800ms)
    </div>
  </div>
</template>

<script setup lang="ts">
// 长按删除回调
const handleDelete = () => {
  console.log('执行删除操作')
  if (confirm('确定要删除这条数据吗?')) {
    // 实际业务逻辑:调用删除接口
    alert('删除成功!')
  }
}

// 长按取消回调
const handleCancel = () => {
  console.log('长按取消')
  // 可添加取消提示,如:
  // ElMessage.info('已取消删除')
}
</script>

<style scoped>
.card {
  padding: 20px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  width: 300px;
  margin: 20px;
}
.delete-btn {
  color: #ff4d4f;
  cursor: pointer;
  padding: 8px 16px;
  border: 1px solid #ff4d4f;
  border-radius: 4px;
  display: inline-block;
}
.delete-btn:hover {
  background: #fff2f2;
}
</style>

4. 动态配置长按指令

适配动态变化的长按配置(如根据不同状态调整长按时长):

<template>
  <div>
    <div>
      <label>长按时长(ms):</label>
      <input 
        type="number" 
        v-model.number="pressDuration"
        min="100"
        max="2000"
        step="100"
      />
    </div>
    
    <button 
      v-longPress="{
        duration: pressDuration,
        handler: handleDynamicLongPress,
        cancelHandler: handleDynamicCancel
      }"
    >
      动态配置长按按钮
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 动态长按时长
const pressDuration = ref(500)

// 动态长按回调
const handleDynamicLongPress = () => {
  alert(`长按${pressDuration.value}ms触发成功!`)
}

// 动态取消回调
const handleDynamicCancel = () => {
  console.log(`长按${pressDuration.value}ms取消`)
}
</script>

5. 结合UI库实现长按菜单

实际业务中常结合下拉菜单实现长按操作(以Element Plus为例):

<template>
  <div>
    <el-button 
      ref="btnRef"
      v-longPress="{
        handler: openContextMenu,
        duration: 600
      }"
    >
      长按打开操作菜单
    </el-button>
    
    <!-- 自定义上下文菜单 -->
    <el-dropdown 
      ref="dropdownRef"
      :visible="menuVisible"
      @visible-change="menuVisible = false"
    >
      <el-dropdown-menu>
        <el-dropdown-item @click="handleEdit">编辑</el-dropdown-item>
        <el-dropdown-item @click="handleCopy">复制</el-dropdown-item>
        <el-dropdown-item @click="handleDelete" divided>删除</el-dropdown-item>
      </el-dropdown-menu>
    </el-dropdown>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const btnRef = ref<HTMLButtonElement>(null)
const dropdownRef = ref()
const menuVisible = ref(false)

// 打开上下文菜单
const openContextMenu = () => {
  if (btnRef.value && dropdownRef.value) {
    const rect = btnRef.value.getBoundingClientRect()
    // 设置菜单位置
    dropdownRef.value.$el.style.position = 'absolute'
    dropdownRef.value.$el.style.top = `${rect.bottom + 10}px`
    dropdownRef.value.$el.style.left = `${rect.left}px`
    menuVisible.value = true
  }
}

// 编辑操作
const handleEdit = () => {
  ElMessage.info('执行编辑操作')
  menuVisible.value = false
}

// 复制操作
const handleCopy = () => {
  ElMessage.success('复制成功')
  menuVisible.value = false
}

// 删除操作
const handleDelete = () => {
  ElMessage.warning('执行删除操作')
  menuVisible.value = false
}
</script>

🔧 核心知识点解析

1. 长按实现原理

长按的核心逻辑是**“按下计时 + 抬起/移动取消”**:

  1. 按下(mousedown/touchstart):记录起始坐标,启动定时器,达到指定时长触发回调。
  2. 抬起/离开(mouseup/touchend/mouseleave):清除定时器,取消长按。
  3. 移动(mousemove/touchmove):计算移动距离,超过阈值自动取消长按,防止误触。

2. 跨端兼容处理

  • PC端:监听 mousedown/mouseup/mousemove/mouseleave 事件。
  • 移动端:监听 touchstart/touchend/touchmove 事件。
  • 统一的坐标计算逻辑,兼容鼠标和触摸事件的坐标获取方式。

3. 防误触优化

通过 moveThreshold 移动阈值实现防误触:

  • 默认阈值为5px,按下后移动超过该距离自动取消长按。
  • 计算两点间距离使用勾股定理:distance=(x2x1)2+(y2y1)2distance = \sqrt{(x2-x1)^2 + (y2-y1)^2}

4. 内存泄漏防护

  • unmounted 钩子中清理定时器、解绑所有事件监听。
  • updated 钩子中先清理旧配置,再初始化新配置。
  • 使用元素扩展属性存储状态,卸载时删除属性释放内存。

5. TypeScript 类型优化

  • 定义 LongPressOptions 接口,明确配置项类型。
  • 扩展 HTMLElement 类型,添加长按状态属性。
  • 支持两种参数类型(函数/对象),类型推导自动适配。

📋 配置项说明

配置项 类型 默认值 说明
duration number 500 长按触发时长,单位ms
handler (e: MouseEvent | TouchEvent) => void - 长按触发的回调函数(必填)
cancelHandler (e: MouseEvent | TouchEvent) => void - 长按取消的回调函数(可选)
moveThreshold number 5 移动阈值,超过则取消长按,单位px
preventDefault boolean true 是否阻止默认行为(如移动端长按弹出菜单)
stopPropagation boolean false 是否阻止事件冒泡

🎯 常见使用场景

场景1:移动端列表项长按操作

<template>
  <div class="list">
    <div 
      class="list-item"
      v-for="item in list"
      :key="item.id"
      v-longPress="{
        duration: 600,
        handler: () => showActionSheet(item),
        cancelHandler: () => hideActionSheet()
      }"
    >
      {{ item.name }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const list = ref([
  { id: 1, name: '微信' },
  { id: 2, name: '支付宝' },
  { id: 3, name: '抖音' }
])

// 显示操作面板
const showActionSheet = (item: any) => {
  console.log('长按触发', item)
  // 实际开发中可调用移动端ActionSheet组件
  alert(`长按${item.name},显示操作菜单`)
}

// 隐藏操作面板
const hideActionSheet = () => {
  console.log('长按取消')
}
</script>

<style scoped>
.list {
  width: 300px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}
.list-item {
  padding: 12px 16px;
  border-bottom: 1px solid #f0f0f0;
  cursor: pointer;
}
.list-item:last-child {
  border-bottom: none;
}
.list-item:hover {
  background: #f5f5f5;
}
</style>

场景2:PC端长按批量选择

<template>
  <div class="table-container">
    <el-table 
      :data="tableData"
      @row-click="handleRowClick"
    >
      <el-table-column 
        label="名称"
        prop="name"
      >
        <template #default="scope">
          <div 
            v-longPress="{
              handler: () => toggleSelect(scope.row),
              duration: 400
            }"
          >
            {{ scope.row.name }}
          </div>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="100">
        <template #default="scope">
          <el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <div class="selected-count" v-if="selectedRows.length">
      已选择 {{ selectedRows.length }} 条数据
      <el-button size="small" @click="selectedRows = []">清空选择</el-button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 模拟表格数据
const tableData = ref([
  { id: 1, name: '数据1' },
  { id: 2, name: '数据2' },
  { id: 3, name: '数据3' }
])

// 选中的行
const selectedRows = ref<any[]>([])

// 长按切换选择状态
const toggleSelect = (row: any) => {
  const index = selectedRows.value.findIndex(item => item.id === row.id)
  if (index > -1) {
    selectedRows.value.splice(index, 1)
  } else {
    selectedRows.value.push(row)
  }
}

// 点击行(取消批量选择)
const handleRowClick = (row: any) => {
  selectedRows.value = []
}

// 编辑操作
const handleEdit = (row: any) => {
  console.log('编辑', row)
}
</script>

<style scoped>
.table-container {
  width: 500px;
  margin: 20px;
}
.selected-count {
  margin-top: 10px;
  padding: 8px;
  background: #f5f5f5;
  border-radius: 4px;
}
</style>

🚨 注意事项

  1. 移动端默认行为:长按会触发浏览器默认菜单(如复制、保存图片),需设置 preventDefault: true 阻止。
  2. 触摸事件穿透:移动端需注意事件穿透问题,可结合 pointer-events: none 处理。
  3. 性能优化:避免在长按回调中执行复杂计算,建议使用防抖/节流。
  4. 兼容性
    • PC端:所有现代浏览器均支持。
    • 移动端:iOS Safari/Android Chrome 均支持,低版本安卓需测试。
  5. 右键菜单冲突:PC端长按左键触发,避免与右键菜单冲突,可通过 e.button !== 0 过滤右键。

📌 总结

本文实现的 v-longPress 指令具备以下核心优势:

  1. 跨端兼容:同时支持PC端和移动端,一套代码适配多端。
  2. 体验优秀:内置防误触机制,支持自定义移动阈值,避免误触发。
  3. 配置灵活:支持自定义长按时长、回调函数、事件行为,适配各种业务场景。
  4. 类型安全:基于TypeScript开发,类型提示完善,减少开发错误。
  5. 性能优异:自动清理定时器和事件监听,无内存泄漏问题。

这个指令可以直接集成到你的Vue3项目中,解决各种长按交互需求。如果需要进一步扩展,可以在此基础上增加:

  • 支持长按进度条显示
  • 支持多段长按(短按/中按/长按触发不同操作)
  • 支持长按拖拽
  • 支持自定义长按样式(如按压反馈)

希望这篇文章对你有帮助,欢迎点赞、收藏、评论交流!

Vue 3中实现打字机效果的技术指南

一、效果简介

打字机效果是一种常见的页面动画效果,它模拟了打字机逐字输入的过程,为静态页面增添了动态感和趣味性。在Vue 3中,我们可以通过组合式API、响应式数据和定时器来实现这一效果。

二、实现原理

打字机效果的核心原理是:

  1. 逐字显示:通过定时器逐个获取文本字符并更新显示
  2. 响应式数据:利用Vue 3的响应式系统实时更新界面
  3. 状态控制:通过变量控制动画的开始、进行和结束

三、基础实现(单次显示)

1. Vue组件模板

<template>
  <!-- 标题/标语区 -->
  <div class="header">
    <h1 class="main-title" style="min-height: 60px;">{{ typedTitle }}</h1>
    <p class="sub-title">AI智能优化,让简历闪闪发光</p>
  </div>
</template>

2. Vue组件逻辑

<script setup>
import { ref, onMounted } from 'vue';

const typedTitle = ref('');
const fullTitle = '为你的工作经历画龙点睛';

// 页面加载完成后执行
onMounted(() => {
  typeWriter();
});

// 打字机效果函数
function typeWriter() {
  let typedText = '';
  let index = 0;
  
  const typeInterval = setInterval(() => {
    if (index < fullTitle.length) {
      typedText += fullTitle.charAt(index);
      typedTitle.value = typedText;
      index++;
    } else {
      clearInterval(typeInterval);
    }
  }, 150); // 每个字符的间隔时间,单位毫秒
}
</script>

3. CSS样式

<style scoped>
.main-title {
  font-size: 32px;
  font-weight: bold;
  color: #333;
  margin-bottom: 20px;
  line-height: 1.3;
}

.sub-title {
  font-size: 18px;
  color: #666;
  margin: 0;
}
</style>

四、高级实现(循环展示)

1. 效果说明

循环展示的打字机效果包括以下阶段:

  • 打字阶段:逐字显示文本
  • 停留阶段:完整显示后短暂停留
  • 消失阶段:逐字消失文本
  • 等待阶段:完全消失后短暂等待,然后重新开始

2. Vue组件实现

<template>
  <!-- 标题/标语区 -->
  <div class="header">
    <h1 class="main-title" style="min-height: 60px;">{{ typedTitle }}</h1>
    <p class="sub-title">AI智能优化,让简历闪闪发光</p>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';

const typedTitle = ref('');
const fullTitle = '为你的工作经历画龙点睛';

// 页面加载完成后执行
onMounted(() => {
  typeWriter();
});

// 打字机效果函数 - 循环版
function typeWriter() {
  let typedText = '';
  let index = 0;
  
  // 打字阶段
  const typeInterval = setInterval(() => {
    if (index < fullTitle.length) {
      typedText += fullTitle.charAt(index);
      typedTitle.value = typedText;
      index++;
    } else {
      clearInterval(typeInterval);
      // 打字完成后,等待2秒开始消失
      setTimeout(() => {
        fadeOutTitle();
      }, 2000);
    }
  }, 150); // 每个字符的间隔时间,单位毫秒
}

// 标题淡出效果
function fadeOutTitle() {
  let typedText = fullTitle;
  let index = fullTitle.length - 1;
  
  const fadeInterval = setInterval(() => {
    if (index >= 0) {
      typedText = typedText.substring(0, index);
      typedTitle.value = typedText;
      index--;
    } else {
      clearInterval(fadeInterval);
      // 消失完成后,等待1秒重新开始
      setTimeout(() => {
        typeWriter();
      }, 1000);
    }
  }, 100); // 每个字符的消失间隔时间,单位毫秒
}
</script>

<style scoped>
.main-title {
  font-size: 32px;
  font-weight: bold;
  color: #333;
  margin-bottom: 20px;
  line-height: 1.3;
}

.sub-title {
  font-size: 18px;
  color: #666;
  margin: 0;
}
</style>

五、优化建议

1. 性能优化

  • 合理设置定时器间隔:建议在100-200毫秒之间,过快会导致动画不流畅,过慢会影响用户体验
  • 及时清除定时器:使用clearInterval确保定时器在完成后被清除,避免内存泄漏
  • 避免频繁响应式更新:虽然逐字更新需要多次更新响应式数据,但Vue 3的响应式系统已经做了优化

2. 用户体验优化

  • 添加最小高度:为标题容器添加min-height,避免打字过程中页面布局跳动
  • 调整动画速度:根据文本长度和场景调整打字速度,确保用户有足够时间阅读
  • 考虑不同场景:单次显示适合重要标题,循环显示适合需要持续吸引注意力的场景

3. 扩展功能

  • 添加光标效果:可以在打字过程中添加光标闪烁效果,增强真实感
  • 多文本切换:可以实现多个文本的循环切换显示,丰富页面内容
  • 响应式调整:根据设备尺寸和屏幕方向调整字体大小和动画速度
  • 封装为可复用组件:将打字机效果封装为独立组件,方便在多个地方使用

六、应用场景

打字机效果适用于以下场景:

  1. 页面标题:为页面主标题添加动态效果,提升页面吸引力
  2. 品牌标语:突出展示品牌核心价值,增强记忆点
  3. 引导文本:引导用户进行下一步操作,提高转化率
  4. 加载提示:在数据加载过程中显示动态提示,缓解用户等待焦虑

七、代码示例

以下是一个完整的Vue 3组件示例,包含了循环打字机效果的实现:

完整Vue组件

<template>
  <div class="container">
    <!-- 标题/标语区 -->
    <div class="header">
      <h1 class="main-title" style="min-height: 60px;">{{ typedTitle }}</h1>
      <p class="sub-title">AI智能优化,让简历闪闪发光</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const typedTitle = ref('');
const fullTitle = '为你的工作经历画龙点睛';
let typeInterval = null;
let fadeInterval = null;

// 页面加载完成后执行
onMounted(() => {
  typeWriter();
});

// 组件卸载时清除定时器
onUnmounted(() => {
  if (typeInterval) clearInterval(typeInterval);
  if (fadeInterval) clearInterval(fadeInterval);
});

// 打字机效果函数 - 循环版
function typeWriter() {
  let typedText = '';
  let index = 0;
  
  // 打字阶段
  typeInterval = setInterval(() => {
    if (index < fullTitle.length) {
      typedText += fullTitle.charAt(index);
      typedTitle.value = typedText;
      index++;
    } else {
      clearInterval(typeInterval);
      // 打字完成后,等待2秒开始消失
      setTimeout(() => {
        fadeOutTitle();
      }, 2000);
    }
  }, 150); // 每个字符的间隔时间,单位毫秒
}

// 标题淡出效果
function fadeOutTitle() {
  let typedText = fullTitle;
  let index = fullTitle.length - 1;
  
  fadeInterval = setInterval(() => {
    if (index >= 0) {
      typedText = typedText.substring(0, index);
      typedTitle.value = typedText;
      index--;
    } else {
      clearInterval(fadeInterval);
      // 消失完成后,等待1秒重新开始
      setTimeout(() => {
        typeWriter();
      }, 1000);
    }
  }, 100); // 每个字符的消失间隔时间,单位毫秒
}
</script>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  padding: 40px 30px 60px;
  min-height: 100vh;
  background: linear-gradient(135deg, #f0f9ff 0%, #e6f7ff 100%);
}

.header {
  text-align: center;
  margin-bottom: 60px;
}

.main-title {
  font-size: 32px;
  font-weight: bold;
  color: #333;
  margin-bottom: 20px;
  line-height: 1.3;
}

.sub-title {
  font-size: 18px;
  color: #666;
  margin: 0;
}
</style>

封装为可复用组件

如果需要在多个地方使用打字机效果,可以将其封装为一个可复用的组件:

<template>
  <div class="typewriter-container">
    <slot :text="typedText">{{ typedText }}</slot>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';

const props = defineProps({
  text: {
    type: String,
    default: '为你的工作经历画龙点睛'
  },
  speed: {
    type: Number,
    default: 150
  },
  loop: {
    type: Boolean,
    default: false
  },
  delay: {
    type: Number,
    default: 2000
  }
});

const typedText = ref('');
let typeInterval = null;
let fadeInterval = null;

// 监听文本变化
watch(() => props.text, () => {
  if (typeInterval) clearInterval(typeInterval);
  if (fadeInterval) clearInterval(fadeInterval);
  typedText.value = '';
  typeWriter();
});

// 页面加载完成后执行
onMounted(() => {
  typeWriter();
});

// 组件卸载时清除定时器
onUnmounted(() => {
  if (typeInterval) clearInterval(typeInterval);
  if (fadeInterval) clearInterval(fadeInterval);
});

// 打字机效果函数
function typeWriter() {
  let text = '';
  let index = 0;
  
  // 打字阶段
  typeInterval = setInterval(() => {
    if (index < props.text.length) {
      text += props.text.charAt(index);
      typedText.value = text;
      index++;
    } else {
      clearInterval(typeInterval);
      
      if (props.loop) {
        // 打字完成后,等待指定时间开始消失
        setTimeout(() => {
          fadeOutTitle();
        }, props.delay);
      }
    }
  }, props.speed);
}

// 标题淡出效果
function fadeOutTitle() {
  let text = props.text;
  let index = props.text.length - 1;
  
  fadeInterval = setInterval(() => {
    if (index >= 0) {
      text = text.substring(0, index);
      typedText.value = text;
      index--;
    } else {
      clearInterval(fadeInterval);
      // 消失完成后,等待1秒重新开始
      setTimeout(() => {
        typeWriter();
      }, 1000);
    }
  }, props.speed * 0.6); // 消失速度稍快
}
</script>

<style scoped>
.typewriter-container {
  /* 可根据需要添加样式 */
}
</style>

使用示例:

<template>
  <div class="container">
    <h1 class="main-title" style="min-height: 60px;">
      <Typewriter 
        text="为你的工作经历画龙点睛" 
        :loop="true" 
        :speed="150"
      />
    </h1>
    <p class="sub-title">AI智能优化,让简历闪闪发光</p>
  </div>
</template>

<script setup>
import Typewriter from './components/Typewriter.vue';
</script>

八、总结

打字机效果是一种简单但有效的页面动画技术,通过JavaScript定时器和小程序的数据绑定机制,我们可以轻松实现这一效果。无论是单次显示还是循环展示,都可以为页面增添动态感和趣味性,提升用户体验。

在实现过程中,我们需要注意性能优化和用户体验,合理设置动画参数,确保效果流畅自然。同时,我们也可以根据具体场景进行扩展和定制,创造出更加丰富多样的动态效果。

希望本指南对你有所帮助,祝你在微信小程序开发中创造出更多精彩的页面效果**!**

❌