阅读视图

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

放弃 try-catch-finally ,试试 JavaScript 的显示资源管理

什么是显示资源管理?

众所周知,JavaScript 有自动的垃圾回收机制(GC),可以自动的释放内存对象,但对与文件句柄、网络连接等资源的释放却无能为力,需要手动的进行资源的释放。因此,我们需要一种明确的、作用域级别的资源清理机制。

在最新的 ECMAScript 规范中,引入 using 关键字,以及 Symbol.disposeSymbol.asyncDispose 两个 Symbol 属性,用来声明一个资源,并在资源释放时进行一些清理操作。

注意:这个最新的功能仅在 Chromium 134Node 24.0.0 以上的版本支持。

using

显示资源管理的核心是使用 using 关键字声明一个资源,来确保资源在当前作用作用域结束时调用 [Symbol.dispose]() 方法,在这个方法中我们可以进行一些资源的释放操作。

如果是异步资源,则需要使用 await using 用来声明,对应的方法是 [Symbol.asyncDispose]()

我们先来看一下传统的资源释放操作:

import fs from 'fs'

function writeLog() {
  let file
  try {
    file = fs.openSync('log.txt', 'a')
    fs.writeSync(file, '写入一条日志\n')
  } finally {
    if (file) {
      fs.closeSync(file) // 释放资源
      console.log('文件关闭')
    }
  }
}
writeLog()

在上面的代码中,为了避免在读取文件的过程中报错导致文件没有关闭,我们需要使用 try finally对代码进行包裹,确保在 finally 块中释放资源。

而使用 using 关键字可以在函数执行结束之后自动帮我们释放资源:

import fs from 'fs';

function writeLog() {
  // 将一个资源对象赋值给 using 关键字声明的变量
  using fileSource = {
    file: fs.openSync('log.txt', 'a'),
    write(content) {
      fs.writeSync(this.file, content)
    },
    [Symbol.dispose]() {
      fs.closeSync(this.file)
      console.log('文件关闭')
    }
  }
  fileSource.write('写入一条日志\n') // 写入日志
}
writeLog() // 在函数执行完毕后,自动释放资源

在上面的代码中,我们定义了一个对象,对象里面有一个 [Symbol.dispose] 方法,在该方法中释放了文件句柄。

然后,我们使用 using 关键字声明了一个 fileSource 变量,来接收对象,using 关键字声明的变量所在的函数执行结束后,会自动调用 fileSource 身上的 [Symbol.dispose] 方法,释放资源

如果是异步的操作,则需要在 using 关键字前面加一个 await,来声明一个异步资源,然后在类中实现 [Symbol.asyncDispose]() 方法。

DisposableStack 和 AsyncDisposableStack

为了方便管理多个资源,我们可以使用 DisposeableAsyncDisposeable 这两个构造器实例化一个类似栈的结构。

我们可以给这个结构不断添加资源,当函数执行结束时,不管是同步资源还是异步资源,这些资源的处理顺序与它们添加的顺序刚好相反,从而确保它们之间的依赖关系能够得到一个正确的处理。

所以,当处理多个有互相依赖关系的资源时,资源的释放和清理过程就得到了简化。

同样的,Disposeable 用来处理同步资源,AsyncDisposeable 用来处理异步资源。

这个结构身上有 useadoptdefermovedispose 等方法。

接下来讲一下他们的用法:

use

use 函数可以将多个实现了 [Symbol.dispose] 方法的对象添加到 DisposableStack 中, 在函数作用域执行结束后,会自动调用这些对象身上的 [Symbol.dispose] 方法。

添加到 DisposableStack 中的资源,必须是一个“可释放对象”(实现了 [Symbol.dispose] 方法的对象)。

import fs from 'fs'

function writeLog() {
  const fileSource = {
    file: fs.openSync('log.txt', 'a'),
    write(content) {
      fs.writeSync(this.file, content)
    },
    [Symbol.dispose]() {
      fs.closeSync(this.file)
      console.log('文件关闭')
    },
  }

  // 创建一个  Disposeable 实例
  using stack = new DisposableStack()

  stack.use(fileSource) // 将资源添加到 Disposeable 实例中
  // stack.use(fileSource1) // 可以添加多个

  fileSource.write('写入一条日志\n') // 写入日志
}
writeLog() // 在函数执行完毕后,自动释放资源

在上面的代码中,我们创建了一个 Disposeable 实例,然后使用 use 方法将 fileSource 资源添加到实例中。

当函数执行结束时,fileSource 资源会被自动释放,因为 stack 实例在函数执行结束时会依次调所加入资源身上的 [Symbol.dispose] 方法,释放所有资源。释放顺序与它们添加的顺序刚好相反。

adopt

如果一个第三方资源没有实现 [Symbol.dispose] 方法,可以使用 adopt 方法给其注册一个清理函数。

import fs from 'fs'

function writeLog() {
  // 创建一个  Disposeable 实例
  using stack = new DisposableStack()

  // 需要被释放的资源
  const file = fs.openSync('adopted-log.txt', 'a');


  // 注册清理函数
  stack.adopt(file, (file)=>{
      fs.closeSync(file)
      console.log('文件关闭')
  })

  fs.writeSync(file, '写入一条日志\n') // 写入日志

}
writeLog() // 在函数执行完毕后,自动释放资源

在上面的代码中,我们创建了一个 Disposeable 实例,然后使用 adopt 方法给 file 资源注册了一个清理函数。当函数执行结束时,file 资源会被自动释放。

defer

defer 方法可以将一个回调函数添加到栈的顶部,不依赖资源对象或资源返回值,只是做清理任务。

using stack = new DisposableStack()

// 注册清理函数
stack.defer(()=>{
    console.log('一些清理任务')
})

move

move 方法可以将一个资源对象从一个 Disposeable 实例中移动到另一个 Disposeable 实例中。

const stack = new DisposableStack();

const file = {
  [Symbol.dispose]() {
    console.log('清理文件');
  }
};
stack.use(file);           // 加入 stack
using newStack = stack.move() // 将资源从 stack 移动到 newStack

dispose

dispose 方法可以手动调用 Disposeable 实例中的资源清理函数。

const stack = new DisposableStack()

const file = {
  [Symbol.dispose]() {
    console.log('清理文件')
  },
}
stack.use(file) // 加入 stack
stack.dispose() // 手动调用清理函数

TinyEngine 2.5版本正式发布:多选交互优化升级,页面预览支持热更新,性能持续跃升!

前言

TinyEngine低代码引擎使开发者能够定制低代码平台。它是低代码平台的底座,提供可视化搭建页面等基础能力,既可以通过线上搭配组合,也可以通过cli创建个人工程进行二次开发,实时定制出自己的低代码平台。适用于多场景的低代码平台开发,如:资源编排、服务端渲染、模型驱动、移动端、大屏端、页面编排等。

近期,TinyEngine v2.5版本带着新的功能和优化一起来咯~ 希望这次更新能为大家的使用带来更多的便利与惊喜。

这次版本特性开发和问题修复已经有更多的开发者朋友参与进来,我们在此诚挚感谢 @BWrong 、@1degrees 积极参加 TinyEngine 的开源共建,同时也邀请大家一起加入开源社区的建设,让 TinyEngine 成长的更加优秀和茁壮。

v2.5.0 变更特性概览

  • 【画布】画布多选支持右键菜单。
  • 【画布】画布多选支持拖拽。
  • 【CDN】修复CDN本地化的支持。
  • 【性能优化】画布 iframe 移除 base64 避免大内存占用问题。
  • 【页面预览】页面预览支持热更新。
  • 【物料配置】物料支持直接在配置中传入对象。
  • 【物料API】物料支持使用 refreshMaterial 进行刷新物料。
  • 【物料】新增 TinyTransfer 组件。
  • 【其他】大量功能细节优化与bug修复。

TinyEngine v2.5.0 新特性解读

1. 【画布】画布多选支持右键菜单。

1.1 基本介绍

多选右键菜单是一个便捷的功能,当用户选中多个组件节点后,右键点击可以呼出专用的多选菜单,提供批量操作能力。

1.2 功能详情

多选状态下的右键菜单包含以下几个主要功能选项:

  • 删除:一键删除所有选中的节点,无需逐个操作

  • 复制:批量复制所有选中的节点,便于快速重用

  • 添加父级:为选中的节点添加共同父容器,包含以下子选项:

    • 容器(批量) :为每个选中节点单独添加父级容器
    • 容器 (公共父级) :仅当选中节点为连续的兄弟节点时可用,创建一个公共父容器
    • 弹出框(公共父级) :仅当选中节点为连续的兄弟节点时可用,创建 TinyPopover 组件作为公共父级
  • 新建区块:基于选中的组件创建可复用的区块组件

注意事项:

  • 多选菜单触发条件:当选中节点的数量大于 1 时,系统自动切换到多选菜单模式

  • 批量添加父级:对于 添加父级 中“容器(批量)”选项,选中多个节点时可用

  • 添加公共父级:对于 添加父级 中“容器(公共父级)”和“弹出框(公共父级)”选项,只有当所选节点是连续的兄弟节点时才可用

1.3 功能使用展示

a. 右键菜单:批量复制 + 批量删除

1.gif b. 右键菜单:添加父级 2.gif c. 右键菜单:支持选中多个节点添加区块
3.gif

2. 【画布】画布多选支持拖拽。

2.1 基本介绍

多选拖拽是一个批量移动节点的功能,用户可以同时选中多个组件节点并作为整体进行拖拽操作,提高组件布局调整的效率。

2.2 使用方法

a. 多选操作

    按键多选:按住 Ctrl 键,点击选中多个组件

b. 拖拽操作

  • 开始拖拽:选中多个组件后,在选中组件上按下鼠标左键
  • 移动过程:拖动鼠标,组件的拖拽元素会同时移动,保持原有的相对位置关系
  • 完成放置:释放鼠标按键,组件将放置在新位置

2.3 界面展示

4.gif

2.4 注意事项

  • 点击已选中的组件但不拖动时,自动切换为单选状态
  • 大量组件同时拖拽可能造成性能下降,建议适量选择

3.【CDN】修复CDN本地化支持

在 1.x 版本中,我们新增了 CDN 本地化的特性。但是在 2.x 版本中,该功能失效了。在 2.5 版本中,我们将该特性迁移上来。

使用示例:在 .env.alpha 或者 .env.prod 环境变量文件中,新增如下配置:

# CDN 本地化配置示例

# 将画布、页面预览需要的 vue、vue-i18n 等等依赖复制到构建产物中
VITE_LOCAL_IMPORT_MAPS=true

# 将本地物料 bundle.json 的 script 和 css 复制到构建产物中
VITE_LOCAL_BUNDLE_DEPS=true

# 将 VITE_LOCAL_BUNDLE_DEPS 复制到构建产物中的目录名称,默认为 local-cdn-static
VITE_LOCAL_IMPORT_PATH=local-cdn-static

然后执行 pnpm build:alpha 或者时 pnmp build:prod  就可以得到带有 CDN 本地化文件的产物啦~

相关联PR:github.com/opentiny/ti…

详细文档:opentiny.design/tiny-engine…

4.【页面预览】页面预览支持热更新。

在前端开发中,有一种特性叫做热更新,我们直接修改代码,保存文件之后,浏览器就会自动刷新网页,我们就可以看到效果了。在 v2.5 版本中,TinyEngine 也带来了一种页面预览热更新的特性。我们打开预览页面,在画布中进行拖拉拽,不需要再次点击页面预览或者刷新页面,预览页面就会自动刷新。

关联PR:github.com/opentiny/ti…

相关联特性支持:

  • 支持配置预览页面跳转的 url。
  • 页面预览热更新功能支持开关。

详情请参照文档:opentiny.design/tiny-engine…

5.【物料配置&API】物料支持直接在配置中传入对象 & 支持使用 refreshMaterial 进行刷新物料

物料支持在配置中传入对象

v2.0 - v2.4 的版本中,我们的物料配置仅支持传递 url 进行获取物料。v2.5 的版本中,我们支持了直接传递对象,使得配置物料的方式更灵活。

配置示例:

5.png

import bundle from './bundle.json'
// engine.config.js 示例
export default {
  // ...
  material: ['/mock/bundle.json', bundle],
}

物料新增 refreshMaterial API

使用场景:二开工程中,允许用户上传物料,或者是动态更新物料后,需要刷新物料,此时可以调用该方法。

更多详情请查看文档:opentiny.design/tiny-engine…

6. 【性能优化】画布 iframe 移除 base64 避免大内存占用问题。

在 2.0+ 的画布优化中,我们使用了 base64 来将 script 传递到画布中,但是 base64 在 src 中却会占用很大的内存,这加剧了页面的卡顿,使用体验变得不好。所以,在 v2.5 的版本中,我们将 base64 去除,避免大内存占用的问题。(Tips:画布后续将进行更多的优化,支持更多的拓展点和更强大的二次开发功能。欢迎大家来使用反馈提PR)

image.png

关联PR:github.com/opentiny/ti…

7.【物料】新增 TinyTransfer 组件

功能概览
新增高性能穿梭框组件,支持数据在左右面板间快速转移,适用于权限分配、数据分类等多选场景。
关键特性
基础穿梭功能:支持单/多选、全选、快捷移动操作,可通过按钮或拖拽交互实现数据转移。
搜索过滤:内置关键词搜索功能,可快速定位目标数据项。

8.【官网】官网Demo刷新为 v2.5 版本

官网的 Demo 从 1.x 刷新为 v2.5 版本。链接:opentiny.design/tiny-engine…

欢迎大家到官网上体验Demo。 8.png

9.【其他】功能细节优化&bug修复

  • 默认出码模板的 vite.config.js 增加 base: './' 配置。 @xuanlid  #1247
  • 解决编辑区块不生效的 bug。 @SonyLeo  #1257
  • 解决获取 globalState 不正确的 bug。 @chilingling  #1292
  • 解决删除绑定事件没有触发保存状态更新的 bug。 @chilingling  #1253
  • 修复 getRenderer 在 canvas init 完成前可能为 null,在控制台报错的问题。 @chilingling  #1254
  • 修复HtmlAttributesConfigurator配置组件样式问题。 @BWrong  #1302
  • 优化TinyCheckboxGroup 和 video 组件配置。 @chilingling  #1294
  • 修复出码单元测试;修复状态变量 key 可能不合法需要增加引号的bug。@chilingling  #1291
  • 修复手动编辑 schema 之后,导致页面无法保存的 bug。@chilingling  #1299
  • 修复父级页面有区块时,页面预览有错误的 bug。@1degrees  #1289
  • AI 对话框不强制要求填 token。@xuanlid  #1310
  • 修复新建区块后 url 未更新导致画布渲染错误的 bug。 @gene9831  #1323
  • 修复清空画布后,页面保存操作失败的 bug。 @1degrees  #1341
  • 修复 i18n 面板打开之后,i18n 词条列表不显示的 bug。@SonyLeo  #1358
  • 修复重复点击一个还原页面之后,接口报错的 bug。@xuanlid  #1368
  • 修复复制页面保存之后,无法切换到新页面的 bug。 @chilingling  #1361
  • 修复画布存在 jsx 函数的时候,报错无法渲染的 bug。 @chilingling  #1376
  • 修复新增插件后,可能无法显示新插件的 bug。@SonyLeo  #1373
  • 修复:点击复制页面,弹出"您即将复制的页面有更改未保存,是否确定跳过更改直接复制?",但复制页面并没有更改未保存的 bug。@SonyLeo #1365
  • 修复 chrome 浏览器 136 版本,清空样式面板的样式类之后,伪类下拉框宽度不正常的 bug。@xuanlid  #1398
  • 修复异步函数在绑定事件函数之后,丢失 async descriptor 描述符的 bug。@chilingling #1396
  • 修复即使固定面板,新增页面之后,页面仍然被关闭的 bug。@SonyLeo  #1393

以上是此次更新问题修复的主要内容,更多细节请查看 v2.5.0 changelog

结语:

TinyEngine 2.5 版本更新不仅修复了许多问题,还对多选功能进行了完善,更有页面预览热更新、CDN 本地化等重要特性支持。每一步前行都值得铭记,感谢有您陪伴我们一起迭代成长,同时也欢迎大家加入社区讨论,参与社区共建!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码:github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

高级前端面试题及答案(最新版)

高级前端面试题及答案(最新版)

JavaScript 深入

1. 解释 Event Loop 机制,包括宏任务和微任务的区别

答案: Event Loop 是 JavaScript 实现异步的核心机制,它由以下部分组成:

  1. 调用栈(Call Stack):同步代码的执行栈
  2. 任务队列(Task Queue):存放宏任务
  3. 微任务队列(Microtask Queue):存放微任务

执行顺序:

  • 执行同步代码(属于第一个宏任务)
  • 执行当前宏任务产生的所有微任务
  • 执行下一个宏任务
  • 循环...

区别

宏任务(Macrotask) 微任务(Microtask)
示例 setTimeout, setInterval, I/O Promise.then, MutationObserver
执行时机 Event Loop的每个循环执行一个 在每个宏任务结束后立即全部执行
API Run by host environment Run by JS engine

2. Proxy/Reflect API的高级应用场景

答案: Proxy可以用于:

// 1. API请求拦截器
const apiHandler = {
    get(target, prop) {
        return async (...args) => {
            console.log(`Calling ${prop} with`, args);
            return target[prop](...args).catch(err => {
                //统一错误处理
                sentry.captureException(err);
                throw err;
            });
        };
    }
};

//2.响应式数据实现(Vue3原理)
function reactive(obj) {
    return new Proxy(obj, {
        get(target, key) {
            track(target, key); //依赖收集
            return Reflect.get(...arguments);
        },
        set(target, key, value) {
            trigger(target, key); //触发更新
            return Reflect.set(...arguments);
        }
    });
}

Reflect的用途:

//1.替代Object上的方法,更函数式
const obj = { foo: 'bar' };
Reflect.has(obj, 'foo'); //替代'foo' in obj

//2.与Proxy配合使用保持默认行为
const proxy = new Proxy(obj, {
    get(target, prop) {
        if(prop === 'secret') return undefined;
        return Reflect.get(...arguments); //保持默认行为
    }
});

React高级特性

###3.React Fiber架构原理

答案: Fiber是React16引入的新协调引擎,核心改进:

  1. 可中断渲染:将渲染工作拆分为多个小单元(fiber节点),每个单元完成后检查剩余时间,没有时间则暂停并让出主线程。

  2. 双缓冲技术:维护两棵fiber树(Current和WorkInProgress),减少直接操作DOM的开销。

  3. 优先级调度:区分不同优先级的更新(如用户交互>数据获取)。

  4. 新的生命周期:划分render阶段(pre-commit phase)和commit阶段。

关键数据结构:

interface FiberNode {
    tag: WorkTag; //组件类型(Function/Class/Host等)
    key: string | null;
    elementType: any;
    stateNode: any; //对应的实例
    
    //链表结构
    return: FiberNode | null; //父节点
    child: FiberNode | null; //第一个子节点
    sibling: FiberNode | null; //兄弟节点
    
    //更新相关 
    memoizedState: any; //hooks链表(Hook会挂载到这里)
    
    //副作用标记(EffectTag)
    flags: Flags;
}

###4.Hooks实现原理及自定义高级Hook示例

实现原理: 1.React通过维护一个"current dispatcher"变量来区分mount/update阶段。 2.Function组件首次渲染时创建hook链表并挂载到fiber.memoizedState上。 3.Hook对象结构:

type Hook = {
    memoizedState: any,     //当前状态值(useState)/effect对象(useEffect)
    baseState: any,
    baseQueue: Update<any>,
    
queue: UpdateQueue<any>,   //待处理的更新队列
    
next: Hook | null          //下一个hook(形成链表)
};

高级Hook示例 - useAsync:

function useAsync(asyncFunction, immediate = true) {
const [status, setStatus] = useState('idle');
const [value, setValue] = useState(null);
const [error, setError] = useState(null);

const execute = useCallback(() =>{
setStatus('pending');
setValue(null);
setError(null);

return asyncFunction()
.then(response =>{
setValue(response);
setStatus('success');
})
.catch(error =>{
setError(error);
setStatus('error');
});
}, [asyncFunction]);

useEffect(() =>{
if (immediate) execute();
}, [execute]);

return { execute, status, value, error };
}

//使用示例:
const { execute }= useAsync(fetchData);
<button onClick={execute}>重新加载</button>

##性能优化专题

###5.Web Worker优化长列表渲染方案

方案代码:

//主线程:
function renderWithWorker(listData){
const worker=new Worker('./listWorker.js');

worker.postMessage({
type:'init',
data:{ items:listData }
});

worker.onmessage=(e)=>{
if(e.data.type==='chunk-ready'){
requestIdleCallback((deadline)=>{
while(deadline.timeRemaining()>0 && e.data.chunk.length){
renderChunk(e.data.chunk.shift());
}
});
}
};
}

//Worker线程(listWorker.js):
self.onmessage=function(e){
if(e.data.type==='init'){
let index=0;
const chunkSize=50;

function processChunk(){
if(index>=e.data.items.length){
self.postMessage({ type:'done' });
return;
}

const chunk=e.data.items.slice(index,
Math.min(index+chunkSize,e.data.items.length));
index+=chunkSize;

self.postMessage({
type:'chunk-ready',
chunk:[chunk] 
});

requestAnimationFrame(processChunk);
}

processChunk();
}
};

关键点说明: 1.Worker处理数据分块避免阻塞主线程UI渲染。 2.requestIdleCallback确保只在浏览器空闲时处理UI更新。 3.chunk大小动态调整可基于设备性能指标。

###6.WebAssembly在前端性能优化的实践案例

典型应用场景及对比:

场景一:图像处理(PDF.js中的色彩转换)

传统JS实现:

function convertRGBtoCMYK(pixels){
for(let i=0;i<pixels.length;i+=4){
let r=pixels[i]/255;
let g=pixels[i+1]/255;
let b=pixels[i+2]/255;

let k=1-Math.max(r,g,b);
pixels[i]=k===1?0:(1-r-k)/(1-k)*255;//C 
pixels[i+1]=k===1?0:(1-g-k)/(1-k)*255;//M 
pixels[i+2]=k===1?0:(1-b-k)/(1-k)*255;//Y 
pixels[i+3]=k*255;//K 
} 
}

WebAssembly版本(Rust):

#[wasm_bindgen]
pub fn convert_rgb_to_cmyk(pixels:&mut[u8]){
for i in (0..pixels.len()).step_by(4){
let r=f64::from(pixels[i])/255.0;
let g=f64::from(pixels[i+1])/255.0;
let b=f64::from(pixels[i+2])/255.0;

let k=1.0-r.max(g).max(b);

pixels[i]=if k==1.0{0}else{((1.0-r-k)/(1.0-k)*255.)as u8}; 
pixels[i+3]=(k*255.)as u8; 

} 
}

性能对比指标:

指标 纯JS实现 WASM版本
10MB图片处理时间 320ms 85ms
CPU占用峰值 98% 45%
内存占用 120MB 65MB

##TypeScript高级特性

###7.TS类型编程实战:实现Vuex的类型推导

完整类型定义方案:

interface ModuleTree<R>{
[key:string]:Module<R>;
}

interface Module<S,R={}>{
namespaced?:boolean;
state:S; 
getters?:Getters<S,R>;
mutations?:Mutations<S>;
actions?:Actions<S,R>;
modules?:ModuleTree<R>;
}

type GetterReturnType<G>={
[K in keyof G]:G[K] extends(...args:any)=>infer R?R:G[K];
};

type ActionContext<S,R>={
dispatch:Dispatch,
commit:MutationFn,
state:S,
rootState:R,
getters:Getters<S,R>
};

type StoreOptions<S>={
state:S,
getters?:Getters<S,S>,
mutations?:Mutations<S>,
actions?:Actions<S,S>,
modules?:ModuleTree<S>
};

//最终Store类型推导  
class VuexStore<
S,
G extends Getters<S,S>,
M extends Mutations<S>,
A extends Actions<S,S>
>{
constructor(options:{
state:S & ThisType<Readonly<S>>,
getters?:G & ThisType<Readonly<GetterReturnType<G>>>,
mutations?:M & ThisType<void>,
actions?:A & ThisType<void>
}){}

get state():S{return {} as S;}
dispatch:K extends keyof A?A[K]:(...args)=>Promise<any>;
commit:K extends keyof M?Parameters<M[K]>[extends undefined?()=>void:P]> :never;

//动态模块注册  
registerModule<N extends string,M>(path:N[],module:M):void{}
}

使用效果:

const store=new VuexStore({
state:{
count:0  
},
mutations:{
increment(state){  
state.count++;  
}  
},
actions:{
asyncIncrement({commit}){
setTimeout(()=>commit('increment'),100);  
}  
}});

store.commit('increment');//✅正确  
store.commit('unknown');//❌错误提示  

store.dispatch('asyncIncrement');//✅正确返回Promise  

store.state.count.toFixed();//✅自动推导为number类型  

关键点说明: -ThisType控制上下文类型推导路径模板字符串类型与条件类型的深度结合。 -递归类型处理嵌套modules的场景。 -infer关键字提取函数返回值类型。

##框架设计原理

###8.Vue3编译器优化细节解析

编译过程关键优化点:

输入模板:<div><span>{{msg}}</span></div>

传统Vue2编译结果:

with(this){return _c('div',[_c('span',[_v(_s(msg))])])}   

存在的问题:-with语句导致作用域不可静态分析 -全量diff无法跳过静态节点

Vue3优化后输出:

import { createVNode as _createVNode } from "vue"  

export function render(_ctx){  
return (_openBlock(),
_createBlock("div",null,[_createVNode("span",null,_toDisplayString(_ctx.message))]))
}   

核心优化技术:

####PatchFlag标记静态分析结果:

在生成的虚拟DOM节点中添加shapeFlag和patchFlag:

_createVNode("span",null,_toDisplayString(_ctx.message),/*TEXT*/8)

其中8表示只有文本内容会变化。

运行时根据这些标记可以跳过不必要的比较:

if(vnode.patchFlag & PatchFlags.TEXT){   
hostSetElementText(el,vnode.children);   
}else if(!optimized){   
patchChildren(n,c,...);//全量diff   
}   

####Block Tree优化:

通过_openBlock()收集动态子节点形成一个block。在父级变动时可以直接跳过整个静态子树。

####静态提升(HoistStatic):

将纯静态节点提升到渲染函数外部:

原始模板中有多个<footer>Copyright</footer>会被编译为:

const _hoisted=_createVNode("footer",null,"Copyright");   

function render(){   
return [_hoisted,...];   
}   

##工程化体系

###9.Monorepo架构下的前端模块化设计

基于pnpm workspace的现代前端架构方案:

项目结构示例:

monorepo/
├── packages/
│   ├── shared/             #公共库      
│   │   └── package.json    
│   ├── react-components/   #React组件库     
│   │   └── package.json    
│   └── vue-components/     #Vue组件库     
│       └── package.json    
├── apps/
│   ├── admin-web/          #管理系统     
│   │   └── package.json    
│   └── mobile-h5/          #移动端H5      
│       └── package.json    
└── package.json            

核心配置要点:

根package.json配置workspaces:

{
"workspaces":[
"packages/*",
"apps/*"
],
"scripts":{
"build":"pnpm -r run build"
}
}

模块依赖管理策略:

packages/react-components/package.json示例:

{
"name":"@mono/react-components",
"dependencies":{
"@mono/shared":"workspace:*",
"react":"^18"
},
peerDependencies:{
"react-dom":"^18"
}
}

构建工具链集成建议:

esbuild+Turborepo构建加速方案配置(turbo.json):

{ "pipeline":{     
 "build":{       
 "dependsOn":["^build"],       
 "outputs":[".dist/**"]     
 },     
 "test":{       
 "dependsOn":["build"],       
 "inputs":["src/**/*"]     
 }   
 } }  

关键优势分析图表:

传统Multirepo vs Monorepo对比指标对比:

指标项 Multirepo Monorepo(with pnpm)

安装依赖时间 ⏱️25min 🚀90s
跨包重构难度 🔥高 👍低
CI缓存命中率 ❌30% ✅85%
磁盘空间占用 💾15GB 🪶4GB

MobX 有什么用?相比 React Hooks 的优点?

一、MobX 的底层原理(简版)

MobX 的核心是通过 依赖追踪发布-订阅模式 实现的响应式系统:

  1. 可观察状态(Observable) 将数据转换为可观察对象。

  2. 依赖收集(Tracking) 在组件渲染或计算过程中,自动记录依赖的可观察属性。

  3. 触发更新(Reaction) 当依赖的状态变化时,自动触发相关组件或计算逻辑的更新。

这种机制比基于手动依赖声明的 useMemouseEffect 更精准和高效。

二、MobX 与 React 函数式组件深度整合

1. mobx vs mobx-react-lite

  • **mobx**MobX 核心库,提供状态管理的基础能力(observable, action, computed, reactionautorun,toJS, runInActionmakeAutoObservable等)。

  • **mobx-react-lite**专为 React 函数式组件设计的轻量级绑定库,提供 observer, useObserver, useLocalStore(已废弃)等 API。特点

    • 仅支持函数组件(类组件需用 mobx-react)。

    • 更小的体积,更好的性能优化。


2. 核心 API 在函数式组件中的应用

(1) observer (组件响应式绑定)

将组件包裹为“观察者”,自动追踪依赖的可观察状态,并在状态变化时重新渲染组件。

import { observer } from "mobx-react-lite";
import { store } from "./store";

const UserList = observer(() => {
  return (
    <div>
      {store.users.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
});

(2) useLocalStore(已废弃 → 改用 useMemo + makeAutoObservable

旧版用于在组件内创建局部可观察状态的 Hook(现推荐直接使用 useMemo)。

import { observer, useLocalStore } from "mobx-react-lite";

const Counter = observer(() => {
  // 旧版写法(已废弃)
  const store = useLocalStore(() => ({
    count: 0,
    increment() {
      store.count++;
    },
  }));

  // 推荐新写法
  const store = useMemo(() => makeAutoObservable({
    count: 0,
    increment() {
      this.count++;
    },
  }), []);

  return <button onClick={store.increment}>{store.count}</button>;
});
makeAutoObservable****

makeAutoObservablemobx** 核心库** 提供的一个 API,用于快速将一个对象转换为可观察的(Observable)状态容器。它的作用是自动推断对象中的属性类型:

  • 普通属性 → 转换为 observable
  • 方法 → 转换为 action
  • Getter 函数 → 转换为 computed
import { makeAutoObservable } from "mobx";

class CounterStore {
  count = 0; // 自动变为 observable

  constructor() {
    makeAutoObservable(this); // 自动处理所有属性和方法
  }

  increment() { // 自动变为 action
    this.count++;
  }

  get double() { // 自动变为 computed
    return this.count * 2;
  }
}

(3) useObserver (局部响应式区域)

在组件内部标记一个需要响应式更新的区域,替代整个组件用 observer 包裹。

import { useObserver } from "mobx-react-lite";

const UserProfile = () => {
  const { user } = store;

  return useObserver(() => (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  ));
};

(4) Observer (组件内局部观察)

类似 useObserver,但以组件形式包裹需要响应式更新的部分。

import { Observer } from "mobx-react-lite";

const UserProfile = () => {
  return (
    <div>
      <Observer>
        {() => (
          <span>{store.user.name}</span>
        )}
      </Observer>
    </div>
  );
};

(5) autorunreaction (副作用管理)

在组件中处理副作用(如日志、网络请求),需在 useEffect 中管理生命周期。

import { autorun, reaction } from "mobx";
import { useEffect } from "react";

const UserTracker = () => {
  useEffect(() => {
    // 自动追踪依赖,当 store.user.id 变化时触发
    const disposer = autorun(() => {
      console.log("User ID changed:", store.user.id);
    });

    // 手动定义依赖,并处理新旧值
    const reactionDisposer = reaction(
      () => store.user.age,
      (age, prevAge) => {
        console.log(`Age changed from ${prevAge} to ${age}`);
      }
    );

    return () => {
      disposer();
      reactionDisposer();
    };
  }, []);

  return null;
};

(6) toJS (转换为普通对象)

将可观察对象转换为普通 JavaScript 对象(常用于传递给外部库或持久化)。

import { toJS } from "mobx";

const DataExporter = observer(() => {
  const handleExport = () => {
    const plainData = toJS(store.data); // 去除 observability
    sendToAPI(plainData);
  };

  return <button onClick={handleExport}>Export Data</button>;
});

(7) actioncomputed (状态管理)

在 Store 类或对象中定义状态修改方法和派生值。

import { makeAutoObservable, action, computed } from "mobx";

class UserStore {
  users = [];
  filter = "";

  constructor() {
    makeAutoObservable(this);
  }

  // Action
  setFilter = action((filter) => {
    this.filter = filter;
  });

  // Computed
  get filteredUsers() {
    return this.users.filter(user => 
      user.name.includes(this.filter)
    );
  }
}

(8) configure (全局配置)

设置 MobX 的全局行为(如严格模式、装饰器兼容性)。

import { configure } from "mobx";

// 强制所有状态修改必须在 action 中
configure({ enforceActions: "observed" });

// 启用装饰器语法支持(如果项目使用装饰器)
configure({ useProxies: "always" });

(9) useAsObservableSource(已废弃 → 改用 useMemo

将外部 props 转换为可观察对象(旧版 API,现推荐其他方式)。

import { useAsObservableSource, observer } from "mobx-react-lite";

const UserProfile = observer((props) => {
  const observableProps = useAsObservableSource(props);

  return <div>{observableProps.user.name}</div>;
});

3. 异步操作处理

在函数式组件中,使用 runInAction 确保异步后的状态修改被追踪。

import { runInAction } from "mobx";

class PostStore {
  posts = [];
  loading = false;

  fetchPosts = async () => {
    this.loading = true;
    try {
      const data = await fetchPostsAPI();
      runInAction(() => {
        this.posts = data;
        this.loading = false;
      });
    } catch (error) {
      runInAction(() => {
        this.loading = false;
      });
    }
  };
}

三、MobX 与 React Hooks

1. 简单场景: useState 足够

如果只是管理 组件内部的简单状态(如一个计数器),useState 完全够用:

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

此时引入 MobX 反而是过度设计。

React Hooks 的适用场景:

  1. 简单组件状态(如表单输入、按钮状态)。

  2. 小型项目或原型开发(快速迭代,无需长期维护)。

  3. 无需复杂状态共享(组件间状态通过 Props 传递即可)。


2. 复杂场景:MobX 的碾压性优势

何时该选择 MobX?

  1. 应用中存在大量跨组件共享状态(如用户信息、主题、全局配置)。

  2. 需要频繁处理派生状态或复杂计算(如仪表盘、实时数据过滤)。

  3. 追求极致的渲染性能(避免不必要的子组件更新)。

  4. 团队熟悉响应式编程概念(降低维护成本)。

如果项目中出现以下信号,就该考虑 MobX 了

  • 你开始频繁使用 useMemomemo 来优化性能。

  • 组件树中多层级传递状态导致代码难以维护。

  • 需要处理大量派生状态或异步副作用。

当遇到以下场景时,MobX 的优势会立刻显现:

(1) 跨组件状态共享

需求: 两个组件(ComponentAComponentB)共享同一个计数器状态,点击按钮时同步更新。

  • 使用 useState + Context

    •   痛点
    • 必须使用 memo 包裹子组件,否则任意状态变化都会导致所有子组件重新渲染。
    • 当 Context 中的状态复杂时,拆分多个 Context 或优化依赖会非常繁琐。
    • // 使用 Context 共享状态
      import React, { useState, createContext, useContext, memo } from "react";
      
      // 定义 Context
      const CounterContext = createContext<{
        count: number;
        increment: () => void;
      }>(null!);
      
      // Provider 组件
      const CounterProvider = ({ children }: { children: React.ReactNode }) => {
        const [count, setCount] = useState(0);
        const increment = () => setCount((c) => c + 1);
      
        return (
          <CounterContext.Provider value={{ count, increment }}>
            {children}
          </CounterContext.Provider>
        );
      };
      
      // 子组件 A(需用 memo 避免无效渲染)
      const ComponentA = memo(() => {
        const { increment } = useContext(CounterContext);
        return <button onClick={increment}>+1</button>;
      });
      
      // 子组件 B(需用 memo 避免无效渲染)
      const ComponentB = memo(() => {
        const { count } = useContext(CounterContext);
        return <span>Count: {count}</span>;
      });
      
      // 使用组件
      const App = () => {
        return (
          <CounterProvider>
            <ComponentA />
            <ComponentB />
          </CounterProvider>
        );
      };
      
  • 使用 MobX直接通过 observer 包裹组件

    • 组件自动追踪依赖,无需手动优化渲染,精准更新。
    • 状态修改直接(无需 setState),逻辑更集中。
import { observer } from "mobx-react-lite";
import { makeAutoObservable } from "mobx";
import { useMemo } from "react";

// 使用 useMemo + makeAutoObservable 创建 Store
const createCounterStore = () => {
  return makeAutoObservable({
    count: 0,
    increment() {
      this.count++;
    },
  });
};

// 组件 A
const ComponentA = observer(() => {
  const store = useMemo(createCounterStore, []); // 实际项目中应通过 Context 共享 Store
  return <button onClick={store.increment}>+1</button>;
});

// 组件 B
const ComponentB = observer(() => {
  const store = useMemo(createCounterStore, []); // 实际项目中应通过 Context 共享 Store
  return <span>Count: {store.count}</span>;
});

// 使用组件(实际项目应通过 Provider 共享 Store)
const App = () => {
  return (
    <>
      <ComponentA />
      <ComponentB />
    </>
  );
};

(2) 派生状态(Computed Values)

需求: 根据用户选择的过滤条件(全部、已完成、未完成),动态显示待办事项列表。

  • 使用 useMemo

    • 必须手动声明 useMemo 的依赖项(todosfilter),若遗漏会导致数据不一致。
    • 当派生逻辑复杂时,代码可读性下降。
import { useState, useMemo } from "react";

const TodoListHooks = () => {
  const [todos, setTodos] = useState<{ text: string; done: boolean }[]>([]);
  const [filter, setFilter] = useState<"all" | "done" | "undone">("all");

  // 手动管理派生状态
  const filteredTodos = useMemo(() => {
    return todos.filter((todo) => {
      if (filter === "all") return true;
      return filter === "done" ? todo.done : !todo.done;
    });
  }, [todos, filter]); // 必须显式声明依赖

  return (
    <div>
      <select value={filter} onChange={(e) => setFilter(e.target.value as any)}>
        <option value="all">All</option>
        <option value="done">Done</option>
        <option value="undone">Undone</option>
      </select>
      <ul>
        {filteredTodos.map((todo, index) => (
          <li key={index}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
};
  • 使用 MobX 的 computed

    • 派生状态通过 getter 自动追踪依赖,无需手动声明。
    • 代码更贴近业务逻辑,可读性更高。
import { observer } from "mobx-react-lite";
import { makeAutoObservable } from "mobx";
import { useMemo } from "react";

// 使用 useMemo + makeAutoObservable 创建 Store
const createTodoStore = () => {
  return makeAutoObservable({
    todos: [] as { text: string; done: boolean }[],
    filter: "all" as "all" | "done" | "undone",
    get filteredTodos() { // 自动追踪依赖
      return this.todos.filter((todo) => {
        if (this.filter === "all") return true;
        return this.filter === "done" ? todo.done : !todo.done;
      });
    },
    setFilter(filter: "all" | "done" | "undone") {
      this.filter = filter;
    },
  });
};

const TodoListMobx = observer(() => {
  const store = useMemo(createTodoStore, []);

  return (
    <div>
      <select
        value={store.filter}
        onChange={(e) => store.setFilter(e.target.value as any)}
      >
        <option value="all">All</option>
        <option value="done">Done</option>
        <option value="undone">Undone</option>
      </select>
      <ul>
        {store.filteredTodos.map((todo, index) => (
          <li key={index}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
});

(3) 异步操作与副作用

需求: 从 API 获取用户数据,处理加载状态和错误。

  • 使用 useEffect

    • 需要手动管理 loadingerror 状态。
    • 异步逻辑分散在组件中,难以复用。
import { useState, useEffect } from "react";

const UserListHooks = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {
    const fetchUsers = async () => {
      setLoading(true);
      try {
        const response = await fetch("/api/users");
        const data = await response.json();
        setUsers(data);
        setError("");
      } catch (err) {
        setError("Failed to fetch users");
      } finally {
        setLoading(false);
      }
    };
    fetchUsers();
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>{error}</div>;
  return <div>{users.map((user) => user.name)}</div>;
};
  • 使用 MobX 的 **autorun****/****reaction**自动追踪依赖,声明式处理副作用。

    • 异步逻辑封装在 Store 中,可复用且易于测试。
    • 使用 runInAction 确保状态修改被追踪,代码更安全。
import { observer } from "mobx-react-lite";
import { makeAutoObservable, runInAction } from "mobx";
import { useMemo } from "react";

// 使用 useMemo + makeAutoObservable 创建 Store
const createUserStore = () => {
  return makeAutoObservable({
    users: [] as any[],
    loading: false,
    error: "",
    async fetchUsers() {
      this.loading = true;
      try {
        const response = await fetch("/api/users");
        const data = await response.json();
        runInAction(() => {
          this.users = data;
          this.error = "";
        });
      } catch (err) {
        runInAction(() => {
          this.error = "Failed to fetch users";
        });
      } finally {
        runInAction(() => {
          this.loading = false;
        });
      }
    },
  });
};

const UserListMobx = observer(() => {
  const store = useMemo(createUserStore, []);
  useEffect(() => {
    store.fetchUsers();
  }, [store]);

  if (store.loading) return <div>Loading...</div>;
  if (store.error) return <div>{store.error}</div>;
  return <div>{store.users.map((user) => user.name)}</div>;
});

(4) 性能优化

  • 使用 **useState**父组件状态变化会导致所有子组件重新渲染,需手动用 memo 或拆分组件。

  • 使用 MobXobserver 组件仅在依赖的状态变化时更新,粒度更细,性能更高


3. MobX 的核心价值总结

场景 React Hooks (useState + Context) MobX
简单组件状态 ✅ 简单直接 ❌ 过度设计
跨组件状态共享 ⚠️ 需手动优化,易导致性能问题 ✅ 自动精准更新
派生状态 ⚠️ 依赖 useMemo,需手动管理依赖 ✅ 自动追踪,声明式
异步/副作用 ⚠️ 需 useEffect 和清理逻辑 ✅ 声明式,自动依赖追踪
性能优化 ⚠️ 依赖 memo 和拆分组件 ✅ 细粒度更新,零成本优化
代码复杂度 ⚠️ 复杂逻辑时代码臃肿 ✅ 逻辑集中,高可维护性

四、最佳实践与常见问题

1. 状态组织

  • 单一 Store vs 多 Store根据项目复杂度拆分 Store(如 UserStore, PostStore, UIStore)。

  • Context API 共享 Store使用 React Context 全局共享 Store。

import { createContext, useContext } from "react";
import { UserStore } from "./stores";

const StoreContext = createContext<UserStore>(null!);

const App = () => {
  const userStore = new UserStore();
  return (
    <StoreContext.Provider value={userStore}>
      <ChildComponent />
    </StoreContext.Provider>
  );
};

const ChildComponent = () => {
  const userStore = useContext(StoreContext);
  return <div>{userStore.username}</div>;
};

2. 性能优化

  • 避免不必要的渲染: 使用 observerObserver 精细控制渲染范围。

  • 细粒度拆分组件: 将大组件拆分为多个观察者组件,减少重渲染范围。


3. 常见陷阱

  • 直接修改状态: 确保在 action 中修改状态(严格模式下会报错)。
  • 未清理副作用: autorunreaction 需在 useEffect 的清理函数中销毁。
  • 过度使用: **toJS**仅在必要时转换,避免破坏响应式。

跟着文档学VUE3(二)

Vue 响应式编程核心揭秘:掌握 ref 与 reactive 的底层逻辑

关键词:Vue3响应式、ref、reactive、Vue Composition API、响应式原理

🔍 一、如何声明响应式状态

使用 [ref()]

✅ ref() 的三大核心细节:

特性 描述
入参 接受任意类型的数据(原始值或对象)
返回值 返回一个带有 .value 属性的响应式对象
模板中使用 在 <script setup> 中无需返回即可直接使用;在模板中自动解包 .value
import { ref } from 'vue'

export default {
  // `setup` 是一个特殊的钩子,专门用于组合式 API。
  setup() {
    const count = ref(0)

    // 将 ref 暴露给模板
    return {
      count
    }
  }
}

💡 小贴士:

  • 使用 <script setup> 语法时,可以直接在模板中使用 ref 变量而无需 return
  • 支持在事件中直接修改 ref,如 count.value++

🧠 二、为何使用响应式状态 ref

🔄 Vue 响应式工作原理四步走:

  1. 首次渲染:Vue 会追踪所有被使用的 ref
  2. 数据变化:当 .value 被修改时触发 setter => ref 被修改时,它会触发追踪它的组件的一次重新渲染。
  3. 依赖收集:通过 getter 进行依赖追踪 => 在ref函数返回的对象内部,Vue 在它的 getter 中执行追踪,在它的 setter 中执行触发
  4. 视图更新:通知组件重新渲染 => 与普通变量不同,你可以将ref 传递给函数,同时保留对最新值和响应式连接的访问。

🧪 看似简单的 ref,背后却有复杂的机制支撑:

// 伪代码,不是真正的实现
const myRef = {
  _value: 0,
  get value() {
    track()
    return this._value
  },
  set value(newValue) {
    this._value = newValue
    trigger()
  }
}

⚡️ 优势对比:

普通变量 ref
无法监听变化 自动追踪依赖
不支持跨函数传递 可以安全地传给其他函数
不具备响应性 是 Vue 响应式系统的核心单元

📦 三、深层响应式 vs 浅层响应式

🔁 深层响应式(默认行为)

  • 对象/数组内部嵌套修改也能被检测到
  • 原理:Vue 内部自动调用了 reactive() 来包装对象
const user = reactive({
    profile: {
      name: 'Tom'
    }
}) 
user.profile.name = 'Jerry'  // 会被检测到

🌊 浅层响应式(使用 shallowRef / shallowReactive

  • 只追踪顶层属性变化
  • 适用于性能敏感场景或大型对象
const shallUser = shallowReactive({
    profile: {
      name: 'Tom'
    }
}) 
user.profile.name = 'Jerry'  // 不会被检测到

🕒 四、DOM更新时机 —— nextTick() 的妙用

Vue 的 DOM 更新是异步进行的,它会在下一个 tick 批量更新所有变更。

  • nextTick() => 如果需要等到DOM更新完成只会再执行额外的代码
import { nextTick } from 'vue'

async function increment() {
  count.value++
  await nextTick()
  // 现在 DOM 已经更新了
}

📌 应用场景

  • 获取更新后的 DOM 元素尺寸
  • 动态加载内容后需要操作 DOM
  • 表单校验反馈等 UI 后续处理

🧩 五、reactive() 详解:让对象原生具有响应性

🔍 ref vs reactive 对比:

特性 ref reactive
数据类型 支持任意类型 仅支持对象类型(对象,数组和Map,Set等集合类型)
响应方式 包装成对象 返回 Proxy 代理
解构问题 ✔️ 安全解构 ❌ 解构会丢失响应性
多次调用 每次返回新 ref 同一个对象返回相同代理

🚫 注意事项:

  • reactive() 不支持原始类型(如 number、string),需用 ref
  • 对同一个对象多次调用 reactive() 返回的是同一个代理

📄 六、在模板中使用响应式状态的注意事项

⚠️ 解包陷阱:顶级 ref 才能自动解包

const object = { id: ref(1) }
// ❌
{{ object.id + 1 }}

// ✅
const { id } = object
{{ id + 1 }}

// 📝 文本插值始终自动解包:
{{ object.id }} // 1

🎯 总结:响应式开发的黄金法则

场景 推荐方案
基础状态管理 ref()
复杂对象/嵌套结构 reactive() 或 ref(object)
需要解构使用 优先使用 ref()
性能敏感型对象 使用 shallowRef / shallowReactive
DOM 更新回调 使用 nextTick() 控制流程

React 的 类组件 和 函数式组件 有什么区别?

React 的函数式组件和类组件是两种不同的组件编写方式,它们在功能上逐渐趋近,但仍有明显的区别和适用场景。以下是它们的联系与区别分析:


一、联系

  1. 核心目标相同无论是函数式组件还是类组件,都是为了构建 React UI,接收 props,返回 React 元素(JSX)。

  2. 生命周期与副作用

    1. 类组件通过生命周期方法(如 componentDidMountcomponentDidUpdate)处理副作用。

    2. 函数式组件通过 useEffect Hook 模拟生命周期行为。

  3. 状态管理

    1. 类组件通过 this.statethis.setState 管理状态。

    2. 函数式组件通过 useStateuseReducer 等 Hooks 管理状态。

  4. 可复用性

    1. 类组件通过高阶组件(HOC)或 Render Props 复用逻辑。

    2. 函数式组件通过自定义 Hooks(如 useFetch)复用逻辑。


二、区别

1. 写法与语法

  • 函数式组件本质是 JavaScript 函数,直接返回 JSX:

    • function FunctionalComponent(props) {
        return <div>{props.text}</div>;
      }
      
  • 类组件需要继承 React.Component,并通过 render 方法返回 JSX:

    • class ClassComponent extends React.Component {
        render() {
          return <div>{this.props.text}</div>;
        }
      }
      

2. 状态管理方式

  • 函数式组件使用 useState Hook:

    • const [count, setCount] = useState(0);
      
  • 类组件通过 this.statethis.setState

    • class ClassComponent extends React.Component {
        constructor(props) {
          super(props);
          this.state = { count: 0 };
        }
        increment = () => {
          this.setState({ count: this.state.count + 1 });
        };
      }
      

3. 生命周期与副作用处理

  • 函数式组件使用 useEffect 处理副作用,无需拆分不同生命周期:

    • useEffect(() => {
        // 相当于 componentDidMount 和 componentDidUpdate
        console.log("Component mounted or updated");
        return () => {
          // 相当于 componentWillUnmount
          console.log("Component will unmount");
        };
      }, [dependencies]);
      
  • 类组件需要明确定义生命周期方法:

    • componentDidMount() { /* 组件挂载 */ }
      componentDidUpdate() { /* 组件更新 */ }
      componentWillUnmount() { /* 组件卸载 */ }
      

4. this 绑定问题

  • 类组件需要手动绑定 this(如事件处理函数),或使用箭头函数避免:

    • // 需要绑定 this
      <button onClick={this.handleClick.bind(this)}>Click</button>
      // 或使用箭头函数
      handleClick = () => { ... }
      
  • 函数式组件this 绑定问题,直接使用函数内变量。


5. Hooks 的独占性

  • 函数式组件可以使用 Hooks(如 useState, useEffect, useContext 等)。类组件无法使用 Hooks


6. 性能优化

  • 函数式组件通过 React.memo 缓存组件,或使用 useMemouseCallback 优化计算和函数引用。

    • const MemoizedComponent = React.memo(FunctionalComponent);
      
  • 类组件通过 shouldComponentUpdateReact.PureComponent 优化渲染。


7. 未来趋势

  • 函数式组件React 官方推荐使用,尤其是 Hooks 引入后,功能已覆盖类组件的所有场景。新项目优先选择函数式组件

  • 类组件旧项目或特定场景(如错误边界 componentDidCatch)可能仍需要。


三、如何选择?

  1. 新项目优先使用函数式组件 + Hooks,代码更简洁,逻辑更集中。

  2. 旧项目维护类组件可以保留,逐步迁移到函数式组件。

  3. 特定场景需要 componentDidCatch(错误边界)时,仍需使用类组件。

  4. 理解 React 设计哲学:React 推崇组合优于继承,这是 HOC 和 Hooks 的设计核心理念。避免过度继承。


四、示例对比

1. 状态与生命周期

// 函数式组件
function Timer() {
  const [time, setTime] = useState(0);
  useEffect(() => {
    const interval = setInterval(() => {
      setTime(t => t + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, []);
  return <div>Time: {time}</div>;
}

// 类组件
class Timer extends React.Component {
  state = { time: 0 };
  componentDidMount() {
    this.interval = setInterval(() => {
      this.setState({ time: this.state.time + 1 });
    }, 1000);
  }
  componentWillUnmount() {
    clearInterval(this.interval);
  }
  render() {
    return <div>Time: {this.state.time}</div>;
  }
}

2. 逻辑复用

解决同一个问题的三种方式

问题:如何让多个组件复用同一套逻辑(比如检查登录、加载数据)?

  • HOC 的解决方式

    • 层层包装:每个功能加一个盒子,比如: 原组件 → 套登录检查盒 → 套数据加载盒 → 最终组件。 → 缺点:盒子太多,拆起来麻烦(嵌套过深),还可能盖住原来的标签(props 冲突)。
  • Hooks 的解决方式

    • 直接调用工具:在组件内部用 useLoginCheck()useDataLoading(),像拼积木一样组合功能。 → 优点:不用包装,直接组装,代码更清晰。
  • 基类继承的方式

// 函数式组件:自定义 Hook
function useCounter(initialValue) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount(c => c + 1);
  return { count, increment };
}

// 类组件:高阶组件(HOC)
function withCounter(Component) {
  return class extends React.Component {
    state = { count: 0 };
    increment = () => this.setState({ count: this.state.count + 1 });
    render() {
      return <Component count={this.state.count} increment={this.increment} />;
    }
  };
}
// 在函数式组件中使用
import React from 'react';
import useCounter from './useCounter';

function CounterComponent() {
  // 直接调用 Hook,解构出状态和方法
  const { count, increment } = useCounter(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+1</button>
    </div>
  );
}
 // 在类组件中使用
import React from 'react';
import withCounter from './withCounter';

class CounterComponent extends React.Component {
  render() {
    // 通过 this.props 访问 HOC 注入的 count 和 increment
    const { count, increment } = this.props;

    return (
      <div>
        <p>Count: {count}</p>
        <button onClick={increment}>+1</button>
      </div>
    );
  }
}

// 用 HOC 包装组件,增强功能
export default withCounter(CounterComponent);
对比总结
方案 基类继承 HOC Hooks
复用方式 继承基类 包装组件 直接调用函数
耦合性 高(子类依赖基类) 低(通过 props 注入) 无(逻辑与组件独立)
动态组合 不支持(单继承限制) 支持(多 HOC 嵌套) 支持(多 Hook 调用)
适用场景 简单复用、类组件 类组件、需组合功能的场景 函数式组件、现代 React 项目
未来兼容性 不推荐(React 不鼓励继承) 逐步被 Hooks 替代 官方推荐的未来方向
如何选择?
  1. 如果你的项目全是类组件且逻辑简单

    1. 基类继承可以快速实现复用,但需注意避免基类膨胀和过度耦合。

  2. 如果需要动态组合多个功能

    1. 使用 HOC 或 Hooks:HOC 适合类组件,Hooks 适合函数式组件。

  3. 如果是新项目或计划迁移到函数式组件

    1. 优先使用 Hooks:更简洁、灵活且符合 React 最佳实践。

  4. 如果遇到多重继承需求

    1. 必须放弃基类继承,改用 HOC 或 Hooks 的组合模式。

举个实际例子

需求:多个组件需要复用 日志记录权限校验 功能。

  1. 基类继承的局限

    1. // 基类 BaseComponent 提供日志和权限方法
      class ComponentA extends BaseComponent { /* 使用日志和权限 */ }
      class ComponentB extends BaseComponent { /* 使用日志和权限 */ }
      
      // 问题:如果某个组件只需要日志,不需要权限,无法剔除基类中的冗余方法。
      
  2. HOC 的灵活组合

    1. // 定义独立的 HOC
      const withLogger = (Component) => { /* 注入日志方法 */ };
      const withAuth = (Component) => { /* 注入权限方法 */ };
      
      // 按需组合
      const ComponentA = withLogger(MyComponent); // 仅日志
      const ComponentB = withAuth(withLogger(MyComponent)); // 日志 + 权限
      
  3. Hooks 的直接调用

    1. function ComponentA() {
        const { log } = useLogger();
        // 仅使用日志
      }
      
      function ComponentB() {
        const { log } = useLogger();
        const { checkAuth } = useAuth();
        // 组合使用日志和权限
      }
      

总结

函数式组件和类组件在功能上已趋于等价,但函数式组件凭借 Hooks 在代码简洁性、逻辑复用性、未来兼容性上更具优势。建议开发者优先掌握函数式组件的使用,同时了解类组件以维护旧代码。

藏起来的JavaScript(一) - 提升与TDZ

前言:于无声处听惊雷

当你写下自己的第一行 JavaScript 代码console.log('Hello world!')并成功运行,为迸现的 “Hello world!” 感到欣喜时,可曾想过这样一行简单的代码执行前要经过多少不为人知的预处理?善战者无赫赫之功。JavaScript 的精华所在,恰恰就是我们经常忽视的地方。现在,让我们重新注视这些被 JS 设计师藏起来的细节,领略 JS 的独特魅力。

PS:本文内容有点长,本人第一次写小长文,有什么不足请您在评论区提出建议。

一. 前置知识准备(已有了解的朋友请移步“提升”)

var、let、const

varletconst是 JavaScript 的三种最常用的变量声明方式。

  • var 是 JavaScript 早期的变量声明方式,可以重复声明,具有函数作用域,存在变量提升的情况。
  • letconst 是 ES6 新增的声明方式,不可以重复声明,它们都具有块级作用域,并且不存在变量提升的现象。
  • const 专门用于声明常量,并且必须初始化,而且一旦赋值就不能再重新赋值,但如果是引用类型的常量(如对象、数组),可以修改其内部的属性。

在实际开发中,由于 var 存在函数作用域和变量提升等问题,容易导致意外错误,已经很少用到var了,主要都是使用const(优先使用)或者let

函数声明

在 JavaScript 里,函数声明是创建可复用代码块的基础方式。

函数是 JavaScript 中的第一等公民,地位极高。别的对象能干的活它能干,别的对象不能干的活,它也能干,作为函数参数,可以作为函数返回值,也可以赋值给变量,简直是为所欲为。

语法格式如下:

function 函数名(参数1, 参数2, ...) {
    // 函数体:实现特定功能的代码
    return 返回值; // 可选,用于返回函数执行结果
}

class、import

classimport声明也是 ES6 新增的声明方式,同样不可重复声明

  • class声明用于声明 JavaScript 中的类,其用法为

    class Person {
        // 构造器
        constructor(name, age) {
            this.name = name;
            this.age = age;
        }
    
        // 方法
        sleep() {
            console.log(`${this.age}岁的${this.name}正在睡大觉!`);
        }
    }
    const tom = new Person('张三', 18);
    tom.sleep(); // 18岁的张三正在睡大觉!
    

    class声明还有一种类表达式的写法:

    const Person = class {
        // 构造器
        constructor(name, age) {
            this.name = name;
            this.age = age;
        }
    
        // 方法
        sleep() {
            console.log(`${this.age}岁的${this.name}正在睡大觉!`);
        }
    }
    const tom = new Person('张三', 18);
    tom.sleep(); // 18岁的张三正在睡大觉!
    console.log(typeof Person);
    

    其实 JavaScript 中的类可以看作是一种特殊的函数,可以通过以下方式验证:

    console.log(typeof Person); // function
    
  • import 声明用于从其他模块引入功能(如变量、函数、类等)。这是 ES6(ES2015)引入的模块系统的核心特性,让代码可以模块化并相互引用。其用法为

// 1. 从模块中导入特定的变量/函数/类
import { function1, variable2 } from './module.js';

// 2. 导入模块的默认导出(每个模块只能有一个默认导出)
import MyClass from './module.js';

// 3. 使用 `as` 关键字重命名导入的内容
import { originalName as newName } from './module.js';

// 4. 将模块的所有导出内容封装到一个对象中
import * as MyModule from './module.js';
// 使用:MyModule.function1()

// 5. 执行模块中的代码,但不导入任何内容
import './sideEffects.js';

注意:import 语句必须处于模块的顶层(也就是最外围的作用域中),不能出现在条件语句或者函数内部,这是因为引擎需要在代码执行前就确定模块间的依赖关系。

作用域

JavaScript 中与提升相关的作用域有全局作用域、函数作用域、块级作用域(ES6)。

  • 全局作用域:最外围的作用域,浏览器环境中由 window 对象表示,Node.js 环境中由 global 对象表示。

    • 没有被任何函数或者块包括的变量或者函数,就处于全局作用域中。
    • 全局作用域中的变量,在代码的任何位置都能被访问(少用,容易引发命名冲突)。
  • 函数作用域: 每个函数都会创建独立的函数作用域,函数作用域嵌套在其定义所在的作用域中(如全局作用域、其他函数作用域或块级作用域)。

    • 在函数内部定义的变量、函数,只能在该函数内部被访问,函数外部无法访问。
    • 函数作用域内的变量在整个函数体内都是可见的,存在变量提升现象。
  • 块级作用域: ES6 引入了块级作用域(通过{}包裹的代码块创建独立作用域),与函数作用域平级,可以嵌套存在。

    • letconst是具有块级作用域特性的变量声明方式,仅在声明所在的代码块{}内可访问。
    • 块级作用域由{}包裹形成,可以独立存在,ifforwhile等语句的代码块(由{}包裹的部分)也会创建块级作用域。

借用《你不知道的JavaScript》这本书中的例子来讲解一下作用域:

3. 作用域说明.png

前 ES6 时代

ES6 之前的全局作用域中,一个个函数作用域嵌套其中,var声明的变量也只能在所处的当前作用域进行提升。函数内部定义的变量以及函数被保护得严严实实。 为了方便理解,我将用小故事为你们讲解作用域。

function func(){
    console.log(a); // undefined(具体为什么,后面的变量提升会讲解)
    var a = 1;
    console.log(a); // 1
}
func();
console.log(a); // 无法访问函数作用域中的变量,报错:ReferenceError: a is not defined

全局作用域就像一个小村庄,函数作用域就像其中的一栋栋居民楼,其中包括房间(嵌套的其他函数作用域)以及家具(声明的变量),夜晚常有强盗游弋,居民楼的大门落锁,外面的人无法得知其中的具体情况,也无法染指其中的财产。而居民可以安全地通过小窗观察外面,并接收外面传进来的物资(全局作用域中的变量以及函数)。

但是有一种特殊的材料(即为{})制成的小房子,它也可以安全地通过小窗观察外面,并接收外面传进来的物资(全局作用域中的变量以及函数)。但是它有一个致命缺陷,它的门是透明的,内部情况一览无余。住进去的居民var也是马大哈,毫不在意隐私外泄(在{}中声明的var变量会泄露到外部),有时候忘记关门,家中所有财产都被强盗拿完了。虽然有时候记得关了门,外面的歹人无法破门而入,但是也将var记在了小本本上(undefined),非常危险

// 忘记关门的情况({}内的代码执行了)
if (true) {
    var a = 1;
    function func() {
        console.log('函数已被调用!');
    }
}
console.log(a); // 1
func(); // 函数已被调用!
// 关门的情况({}的代码未执行)
if (false) {
    var a = 1;
    function func() {
        console.log('函数已被调用!');
    }
}
console.log(a);  // undefined
// 根据 ES5 规范,块内的函数声明会被提升到 最近的函数作用域或全局作用域,无论块是否执行。
func(); // 输出:TypeError: func is not a function(这不是 ES6 之前的行为,而是现在的浏览器为了向前兼容 ES6 规范做的调整。ES6 之前的真正原生行为应该是“函数已被调用!”,大家可以找到真正的 ES5 旧环境去尝试一下。)

1. ES6之前的作用域.png

ES6 时代

ES6 来临之后,小村庄接纳了新居民(letconst声明),新居民很有安全意识,即使住进了这些由{}建成的小房子,仍然很安全,letconst住进去的时候,会拉上自己房间的第二道门(支持块级作用域),保护自己的利益,而var仍然我行我素(不支持块级作用域),即便和letconst住在一起,也难以保障自己的安全。

// 大门没关的情况({}内的代码执行了)
if (true) {
    var a = 1;
    let b = 2;
    const c = 3;
    function func() {
        console.log('函数已被调用!');
    }
}
console.log(a); // 1
func(); // 函数已被调用!
console.log(b); // ReferenceError: b is not defined
// 大门关上的情况({}内的代码未执行)
if (false) {
    var a = 1;
    let b = 2;
    const c = 3;
    function func() {
        console.log('函数已被调用!');
    }
}
console.log(a); // undefined
// func(); 函数的是否泄露取决于你的运行环境,我的运行环境为Node.js v22,结果为TypeError: func is not a function,即为不泄露,但是在旧环境中,仍有可能泄露
console.log(b); // ReferenceError: b is not defined

2. ES6的作用域.png

二. 提升(Hositing)

MDN 中提升的定义

在 JavaScript 中,提升是指解释器在执行代码之前,似乎将函数、变量、类或导入的声明移动到其作用域顶部的过程。

以下任何行为都可以被视为提升:

  1. 能够在声明变量之前在其作用域中使用该变量的值。(“值提升”)
  2. 能够在声明变量之前在其作用域中引用该变量而不抛出 ReferenceError,但值始终是 undefined。(“声明提升”)
  3. 变量的声明导致在声明行之前的作用域中行为发生变化。
  4. 声明的副作用在评估包含该声明的其余代码之前产生。

是不是感觉很晦涩难懂?没事,下面我将一一为你讲解。

1. 变量提升(var)

var声明的变量提升按类别属于上述行为的第 2 种行为

  • 编译阶段: 声明的变量名被添加到当前作用域的顶部,并初始化为 undefined
  • 执行阶段: 赋值操作按代码顺序执行。

示例:

console.log(x); // undefined(变量已提升但未赋值)
var x = 10;
console.log(x); // 10

实际上的执行顺序(逻辑顺序):

var x; // 编译阶段:提升变量声明并初始化为 undefined
console.log(x); // undefined
x = 10; // 执行阶段:赋值操作
console.log(x); // 10

注意:因为var不支持块级作用域,所以在某些情况下,var声明也不算提升。

{
  var x = 1;
}
console.log(x); // 1

这里没有“在声明前访问”,所以不算提升!

2. 函数提升

函数声明的提升表现为上述行为的第 1 种行为,函数提升时,不但会提升声明,还会把定义也一并提升。

  • 编译阶段: 与变量提升不同,函数提升时,整个函数体会被提升到作用域顶部。
  • 执行阶段: 按顺序执行代码,此时函数已存在于作用域中。

作为 JavaScript 的一等公民,函数在 JavaScript 总是有着一些特权的。

整个函数体被提升到作用域顶部,这意味着你可以在函数声明之前调用它。函数提升的优先级高于变量提升,因此函数声明会先于变量声明被提升,并且不会被同名变量的声明所覆盖(但是变量赋值时会被其覆盖)。

示例:

sayHello(); // 可以正常调用,输出:"Hello world!"

function sayHello() {
    console.log("Hello world!");
}

不被同名变量声明所覆盖:

// 不被同名变量覆盖
console.log(func); // 输出:[Function func]
var func = "Hello world!";
console.log(func); // 输出:"Hello world!"

function func() {}

实际执行顺序:

// 1. 函数提升(优先)
function func() {}

// 2. 变量声明提升(但赋值留在原地)
var func; // 重复声明被忽略

// 3. 执行代码
console.log(func); // 此时声明为函数 func,输出:[Function: func]
func = "Hello world!"; // 这时变量执行赋值操作,覆盖了同名函数
console.log(func); // "Hello world!"

但并不是和函数搭上边,就能畅通无阻。以下几种情况并不能拥有最高优先级。

  • 函数表达式不会被提升

    函数表达式不是使用function声明来声明函数,其本质上是使用变量存储函数,遵守的是变量提升的规矩。

    示例:

    sayHi(); // 报错:TypeError: sayHi is not a function(sayHi 不是函数)
    
    var sayHi = function () {
        console.log("Hi!");
    };
    

    实际执行顺序:

    // 1. 变量声明被提升,但初始值为 undefined
    var sayHi;
    
    // 2. 执行代码
    sayHi(); // 此时 sayHi 为 undefined,调用会报错
    sayHi = function() { /* ... */ }; // 赋值操作留在原地
    
  • 箭头函数不会被提升

    箭头函数本质上也是函数表达式,因此同样不会被提升。

    示例:

    greet(); // 报错:TypeError: greet is not a function(greet 不是函数)
    
    var greet = () => console.log("Hello!");
    

3. import

import声明应该是最特殊的提升了,按照 MDN 的分类属于第1、4种行为,别人都是单属一种,它独占两者。而且第 4 种提升行为描述的所谓”声明的副作用在评估包含该声明的其余代码之前产生“,使得import的提升更为晦涩难懂。

但是它其实很简单,MDN 中指出导入声明是提升的。原文:“在这种情况下,这意味着导入的值在模块代码中声明之前就可用,并且导入模块的副作用在模块代码的其余部分开始运行之前就已经产生”。

剖析一下这句话,也就是说,import声明导入的值优先级很高,JavaScript 引擎在执行模块代码之前,会先处理所有的 import 声明,把依赖模块加载进来并建立好绑定关系,直接可以用导入的值。而模块的副作用(比如顶层代码的执行)会在包含该模块的代码执行之前就产生。

示例:

// utils.js
export const PI = 3.14;
console.log('utils.js loaded'); // 将这行代码看作是声明的副作用
// main.js
console.log('Before import, PI =',PI);
import { PI } from './utils.js';
console.log('After import, PI =', PI);

/*
运行结果:
utils.js loaded
Before import 3.14
After import 3.14
*/

执行顺序:模块的副作用(utils.js loaded) -> 主脚本继续执行(Before import 3.14 -> After import 3.14)

这就是“模块的副作用(比如顶层代码的执行)会在包含该模块的代码执行之前就产生”。

从模块内的代码在执行时,导入的内容已经准备妥当可以看出,导入的内容在当前模块的整个作用域变得可用,即便 import 语句位于文件的末尾,这与我们之前说过的函数提升极为相似。

三. TDZ(Temporal dead zone,暂时性死区)

TDZ 是提升的一种特殊情况,指从作用域开始到变量正式声明的这一段区域。在 TDZ 内访问变量会导致ReferenceError,即便变量实际上已经被提升了。

let、const、class

典型代表就是letconstclass声明,按照 MDN 的分类,它们属于第 3 种行为

console.log(a);
let a = 1;
// ReferenceError: Cannot access 'a' before initialization

console.log(b);
const b = 2;
// ReferenceError: Cannot access 'b' before initialization

console.log(c);
class c {
    constructor(name) {
        this.name = name;
    }
    test() {
        console.log('已调用!');
    }
}
// ReferenceError: Cannot access 'c' before initialization

注意:类表达式的提升规则与class声明一致,在定义之前无法使用。

也就是说,只要在变量正式声明之前访问该变量,就一定会报错。有人认为这三者都不算一种提升,因为 TDZ 严格禁止了在声明之前调用变量,将它们认定为提升似乎是全无意义的。但是 MDN 仍然认为它们是提升行为,其实是有一番考量的。

MDN 给出了证据:

const x = 1;
{
  console.log(x); // ReferenceError
  const x = 2;
}

正常情况下,块级作用域可以拿到其父作用域的变量或函数,那么按常理来说,这里打印的应该是 ”1“,但结果却是ReferenceError: Cannot access 'x' before initialization。这恰恰说明了块级作用域内部的const发挥了提升的作用,它的声明覆盖了父作用域中传进来的 x 的声明,但是它声明的常量x在这段 TDZ 中处于一种未初始化的状态,从而引起了报错。

将目光投到letclass上,你会收获一样的答案:

// let 声明
let x = 1;
{
    console.log(x); // ReferenceError: Cannot access 'x' before initialization
    let x = 2;
}
// class 声明
class c {
    constructor(name) {
        this.name = name;
    }
    test() {
        console.log('已调用全局作用域中的类方法!');
    }
}
{
    console.log(c); // ReferenceError: Cannot access 'c' before initialization
    class c {
        constructor(name, age) {
            this.name = name;
            this.age = age;
        }
        test() {
            console.log('已调用块级作用域中的类方法!');
        }
    }
}

如果你把块级作用域中的声明注释掉,那么你就会得到无提升作用下的结果,可以动手试一试,实践出真知!

import 声明的提升是否不存在 TDZ

介绍了 TDZ 之后,我们可以很轻松地看出函数声明的函数提升和 var声明的变量提升都是不存在 TDZ 的(可以在正式声明前访问)。那么对于作用极为类似于函数提升,但是看起来很复杂的import声明,我们是否可以说“import不存在 TDZ ”?

我觉得是可以的,因为根据 ECMAScript 规范,import声明要经过两个阶段:

  1. 模块实例化阶段

    引擎会先创建所有 import 的绑定,但此时这些绑定处于 “未初始化” 状态(类似 let/const 的 TDZ)。 此时绑定不可访问,任何访问都会抛出错误。

  2. 模块执行阶段

    在模块执行前,所有 import 的值会被初始化为对应模块的导出值,因此在模块代码开始执行时,导入的值已经可用。

与 TDZ 的关键区别:

虽然 import 在技术上经历了 “未初始化” 阶段,但这个阶段在模块执行前就已完成,并且模块执行之前,所有import的值已经被初始化为了对应模块的导出值,导致在实际代码中无法观察到 TDZ 错误。也就是说这个阶段对开发者不可见,因此在实际代码中无需考虑,我们大可以直接认为import的提升就不存在 TDZ。

四. 总结

现在我们可以对今天所学的知识做一个总结了,如下表所示:

声明方式 提升特性 TDZ 声明前访问结果
var 提升声明并初始化为 undefined 不存在 undefined
let/const 提升但未初始化 存在 ReferenceError
函数声明 整体提升(声明、定义和赋值) 不存在 可正常访问
函数表达式/箭头函数 按照变量的规则(取决于使用let/const/var 存在(let/const 时)/不存在(var时) ReferenceErrorlet/const);
undefinedvar 声明时以变量形式访问); TypeErrorvar 声明时以函数形式调用)
class类声明 提升但未初始化 存在 ReferenceError
import声明 提升且在模块执行前完成初始化 不存在 可正常访问(模块执行时)

被 JS 设计师藏起来的细节还有很多,让我们来慢慢发现这些细节,领略 JS 的魅力所在。如果这篇文章能让你爱上 JS,那么我的努力就没有白费。

第一次写这种小长文,我肯定有很多不足的地方,希望大家能在评论区提出,帮助我改进,万分感谢!

DOM元素上的key和子组件的key到底是怎样影响组件的渲染的?

vue3对组件、子组件、DOM元素的渲染

我们先思考一下这两个问题:

  • 在menuOptions发生改变的情况下,a-layout组件及其内部的hrp-g组件会不会重新渲染?
  • 在去掉外层 上的key的情况下,内层的getPageKey()发生改变的情况下,内部的hrp-g会不会重新渲染?
<a-layout class="layout" v-if="layoutType === layoutTypeMap.vertical" style="height: 100%;" :key="new Date().getTime()">
  <a-layout-content style="padding: 20px 50px">
    <a-breadcrumb style="padding-bottom: 20px"  v-if="config.showBreadcrumb">
      <a-breadcrumb-item>Home</a-breadcrumb-item>
      <a-breadcrumb-item>List</a-breadcrumb-item>
      <a-breadcrumb-item>App</a-breadcrumb-item>
    </a-breadcrumb>
    <div class="hrp-layout-content-vertical" :key="getPageKey()">
      <hrp-g
          v-if="init"
          :key="contents.pageId"
          :settings="{
            config:contents,
            data:config.name ? data[config.name] : data,
            topData: topData,
            parentData:data,
            parentConfig:config
        }"
      />
    </div>
  </a-layout-content>
</a-layout>

<div 
  class="hrp-context-menu"
  v-show="menuOptions.length > 0 && menuVisible"
  ref="contextMenuWrapper"
  :style="{
    top: menuTop + 'px',
    left: menuLeft + 'px',
    zIndex: zIndex
  }">
  <hrp-g v-for="(item,index) in menuOptions" :key="index" :settings="{
    config:item,
    data:data,
    topData:topData,
    parentData:parentData,
    parentConfig:parentConfig
  }" />
</div>

上面两个问题的答案是:

  • 问题1:会重新渲染。
  • 问题2:不会重新渲染。

为什么会是这样的结果呢?下面我们分析一下。

首先上面绑定了一个值时间戳的key属性,在Vue中,当你给一个组件添加:key="new Date().getTime()" 时,每次重新渲染时都会生成一个新的时间戳值,这会导致Vue认为这是一个全新的组件实例,从而触发整个组件树的重新创建,包括其所有子组件。基于这个,我们很容易就可以分析得到以下结论:

  • 当 menuOptions 发生变化时,组件会重新渲染
  • 如果 有一个动态变化的key (如时间戳),Vue会认为这是一个新组件
  • 这会导致整个 及其所有子组件(包括 hrp-g )被销毁并重新创建
  • 而当没有这个动态key时,Vue只会更新变化的部分,不会重新创建整个组件树

那么有人可能会问,根据上面的解释,问题2应该是会重新渲染才对,为什么是答案是不会渲染呢。

原因是因为: 1、当去掉外层的动态key之后,当menuOptions变化是,Vue会尝试只更新变化的部分,此时内层的:key="getPageKey()"只会影响

这个DOM元素本身,而不会强制其子组件 hrp-g 完全重建。

2、没有外层动态key时, Vue会尽量复用组件实例,hrp-g组件实例会被保留,只要其props变化时或者它自身的key发生变化(:key="contents.pageId")以及条件渲染的条件发生变化时才会更新。

按照上面的解释,似乎还存在一个问题,什么问题呢?

那就是a-layout上面的动态key应该只影响该组件本身才对,而不应该导致内部的hrp-g组件重新渲染,但实际情况却不是这样,这是为什么呢? 这里存在一个关键区别:

  • Vue对DOM元素和组件的不同处理方式。 当一个普通的DOM元素(如div)的key发生变化时,Vue只会重新创建该DOM元素,但会尝试保留其子组件的状态 当一个组件(如)的key发生变化时,Vue会将其视为一个全选的组件实例,导致整个组件树销毁并重建
  • 组件实例的生命周期 组件是有自己的生命周期和内部状态的,它是一个独立的功能单元。相反,DOM元素没有生命周期和内部状态。 当组件因key变化被视为新组件时,会触发完整的销毁和重建过程,包括其所有子组件。

那么内层的 :key="getPageKey()" 只会影响

这个DOM元素本身,而不会强制其子组件 hrp-g 完全重建。这是怎么做到的呢?

这个主要是Vue的渲染机制决定的。

Vue的渲染过程分为几个关键步骤: 1、虚拟DOM的创建:Vue先创建虚拟DOM树 2、差异比较(Diff算法):比较新旧虚拟DOM树的差异 3、DOM更新:只更新变化的部分,为了提高性能,Vue在更新DOM时会尽可能地保留和复用组件实例。

因为本文重点不在解析diff算法就不展开详细讲diff算法了,后续有需求可以针对Vue3的diff算法出一篇分析文章。

手把手教你用 Element Plus 实现动态阶梯表单校验

💻 背景介绍

在今天评审中,产品提出了一个输入“阶梯规则”的功能;用户可以动态新增或删除多个阶梯,每个阶梯包含两个字段:

  • 条件值(conditionValue):必填,且要求下一行的值小于上一行;
  • 比例值(ration):必填,且不能大于100%;

当点击提交按钮时,我们需要对每一个输入项做一下校验:

  • 所有输入项必须填写,不能为空;
  • 阶梯条件值不能重复;
  • 条件值必须按从大到小排序;

设计效果大致如下图所示:

image.png

🎯 实现思路

我们使用Element Plus<el-form>表单组件来实现校验逻辑,同时需要注意一下几点:

  • 多行表单结构:通过v-for渲染stepData数组,动态生成每一行阶梯s项;
  • 嵌套校验规则:每一行都嵌套一个el-form-item,设置自定义校验逻辑;
  • 清空标签label:由于多行为结构化展示,我们将主表单项的label置空;
  • 表单规则配置:包含是否必填、是否重复、是否递减校验等;

🧩 实现代码

<template>

<el-form :model="stepData" ref="formRef" class="config-form">
    <el-form-item
      v-for="(item, index) in stepData"
      :key="index"
      class="step-item"
      label=""
    >
      <div class="rule-row">
        <div class="input-group">
          <span class="label-text">当条件值大于</span>
          <el-form-item
            :prop="`[${index}].conditionValue`"
            :rules="[
              {
                required: true,
                message: '请输入',
                trigger: ['blur', 'change'],
              },
              {
                validator: validateDuplicate,
                trigger: ['blur', 'change']
              },
              {
                validator: validateDescending,
                trigger: ['blur', 'change']
              }
            ]"
            class="inline-form-item"
          >
            <el-input
              v-model="stepData[index].conditionValue"
              placeholder="请输入"
              class="custom-input"
              @input="
                stepData[index].conditionValue = stepData[index].conditionValue
                  .replace(/[^-0-9]/g, '')
                  .replace(/-+/g, '-')
                  .replace(/^(-?)0+(\d+)$/, '$1$2')
              "
            />
          </el-form-item>
          <span class="label-text">时,对应比例为</span>
          <el-form-item
            :prop="`[${index}].ratio`"
            :rules="[
              {
                required: true,
                message: '请输入',
                trigger: ['blur', 'change'],
              }
            ]"
            class="inline-form-item"
          >
            <el-input
              v-model="stepData[index].ratio"
              placeholder="请输入"
              class="custom-input"
              @input="
                stepData[index].ratio = stepData[index].ratio
                  .replace(/[^0-9]/g, '')
                  .replace(/^0+(\d+)/, '$1')
                  .replace(/^(\d{1,3}).*$/, (match, num) =>
                    Number(num) > 100 ? '' : num
                  )
              "
            />
          </el-form-item>
          <span class="label-text">%</span>
        </div>
        <el-button 
          v-if="index !== 0" 
          type="danger" 
          link
          @click="handleDelete(index)"
          class="delete-btn"
        >
          删除
        </el-button>
      </div>
    </el-form-item>
    <div class="action-bar">
      <el-button 
        type="primary" 
        @click="handleAdd"
        :disabled="stepData.length >= 5"
        class="action-btn"
      >
        新增阶梯
      </el-button>
      <el-button 
        type="primary" 
        @click="handleSubmit"
        class="action-btn submit-btn"
      >
        提交
      </el-button>
    </div>
  </el-form>
</template>


<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const formRef = ref(null)
const stepData = ref([
  { conditionValue: "", ratio: "" }
])

// 验证是否有重复的条件值
const validateDuplicate = (rule, value, callback) => {
  if (!value) {
    callback()
    return
  }
  
  const currentIndex = Number(rule.field.match(/\[(\d+)\]/)[1])
  const duplicateIndex = stepData.value.findIndex((item, index) => 
    index !== currentIndex && item.conditionValue === value
  )
  
  if (duplicateIndex !== -1) {
    callback(new Error('条件值不能重复'))
  } else {
    callback()
  }
}

// 验证是否按从大到小排列
const validateDescending = (rule, value, callback) => {
  if (!value) {
    callback()
    return
  }

  const currentIndex = Number(rule.field.match(/\[(\d+)\]/)[1])
  const currentValue = Number(value)
  
  // 检查与前一个值的关系(如果不是第一个)
  if (currentIndex > 0) {
    const prevValue = Number(stepData.value[currentIndex - 1].conditionValue)
    if (!isNaN(prevValue) && currentValue >= prevValue) {
      callback(new Error('条件值必须小于上一行的值'))
      return
    }
  }
  
  // 检查与后一个值的关系(如果不是最后一个)
  if (currentIndex < stepData.value.length - 1) {
    const nextValue = Number(stepData.value[currentIndex + 1].conditionValue)
    if (!isNaN(nextValue) && currentValue <= nextValue) {
      callback(new Error('条件值必须大于下一行的值'))
      return
    }
  }
  
  callback()
}

// 新增行
const handleAdd = () => {
  if (stepData.value.length >= 5) {
    ElMessage.warning('最多只能添加5个阶梯')
    return
  }
  stepData.value.push({ conditionValue: "", ratio: "" })
}

// 删除行
const handleDelete = (index) => {
  if (index === 0) {
    ElMessage.warning('第一行不能删除')
    return
  }
  stepData.value.splice(index, 1)
}

// 提交表单
const handleSubmit = async () => {
  try {
    await formRef.value.validate()
    // 验证通过,可以在这里处理提交逻辑
    ElMessage.success('验证通过,可以提交数据')
    console.log('提交的数据:', stepData.value)
  } catch (error) {
    ElMessage.error('请检查表单是否填写正确')
  }
}

</script>

从树形结构到回溯算法:我在前端学习中遇到的「路径」难题

最近在刷前端算法题时,我遇到了两个特别有意思的问题:一个是把平面列表转成树形结构,另一个是用回溯算法解决数字字母组合问题。原本以为这两个问题毫无关联,直到我在调试代码时盯着 path.pop() 看了半小时——原来它们都藏着「路径探索」的底层逻辑。今天就结合我的学习笔记,和大家聊聊这两个问题背后的思维模式。

从列表转树开始:递归里的「父子关系」

作为前端,我们经常需要处理层级数据。比如菜单权限、省市区联动选择,后端往往会返回一个平面数组,每个节点有 idparentId,我们需要把它转成树形结构。

先看一个典型的列表数据:

let list = [
    { id: '1', title: '节点1', parentId: '' },
    { id: '1-1', title: '节点1-1', parentId: '1' },
    { id: '1-2', title: '节点1-2', parentId: '1' },
    { id: '2', title: '节点2', parentId: '' },
    { id: '2-1', title: '节点2-1', parentId: '2' }
]

目标是把它转成这样的树:

[
    {
        id: '1',
        title: '节点1',
        children: [
            { id: '1-1', title: '节点1-1', children: [] },
            { id: '1-2', title: '节点1-2', children: [] }
        ]
    },
    {
        id: '2',
        title: '节点2',
        children: [ { id: '2-1', title: '节点2-1', children: [] } ]
    }
]

递归解法:最直观的「找爸爸」思路

新手最容易想到的是递归。既然每个节点的 parentId 指向父节点,那我们可以先找根节点(parentId 为空),然后为每个根节点找子节点,子节点的子节点再递归查找。

看我写的第一个版本代码:

function list2Tree(list, parentId = '') {
    // 过滤出当前父节点的直接子节点
    return list.filter(item => item.parentId === parentId)
               .map(item => {
                   // 递归查找子节点的子节点
                   item.children = list2Tree(list, item.id);
                   return item;
               });
}

这段代码的逻辑很简单:用 parentId 过滤出当前层级的节点,然后为每个节点递归查找其子节点(即 parentId 等于当前节点 id 的节点)。

但它有个明显的问题:时间复杂度是 O(n²)。因为每次递归都要遍历整个列表,假设列表有 n 个节点,每个节点都会被遍历 logn 次(树的深度),最坏情况下(比如链表结构)就是 O(n²)。

哈希表优化:用空间换时间的「快速查找」

为了优化时间复杂度,我想到用哈希表预存所有节点。先把每个节点存入哈希表,键是 id,值是节点本身(带 children 数组)。然后再次遍历列表,通过 parentId 直接从哈希表中找到父节点,把当前节点添加到父节点的 children 里。

优化后的代码:

function list2Tree(list) {
    const map = {}; // 哈希表存储所有节点
    const root = []; // 根节点数组

    // 第一步:初始化哈希表,每个节点先添加空children
    list.forEach(item => {
        map[item.id] = { ...item, children: [] };
    });

    // 第二步:关联父子节点
    list.forEach(item => {
        if (item.parentId) {
            // 非根节点:找到父节点,添加到其children
            map[item.parentId].children.push(map[item.id]);
        } else {
            // 根节点:直接加入结果数组
            root.push(map[item.id]);
        }
    });

    return root;
}

这一步优化把时间复杂度降到了 O(n),因为只需要两次遍历列表,而哈希表的查找是 O(1)。这让我意识到:递归虽然直观,但涉及大量重复计算时,用空间换时间是常见的优化思路

从树到回溯:路径探索的「进」与「退」

在掌握了列表转树后,我遇到了另一个问题:用回溯算法解决「数字字母组合」问题。比如,手机键盘上数字 2 对应 "abc",3 对应 "def",输入 "23" 要输出所有可能的字母组合 ["ad","ae","af","bd","be","bf","cd","ce","cf"]。

一开始我觉得这和树形结构没关系,直到画出递归调用图——原来每个数字的字母选择,本质上是在构建一棵「决策树」。比如输入 "23",第一层是数字 2 的 "a""b""c",第二层是数字 3 的 "d""e""f",所有从根到叶子的路径就是最终的组合。

回溯算法的核心:「尝试-回退」的路径管理

回溯算法的代码模板通常是这样的:

function backtrack(路径, 选择列表) {
    if (满足终止条件) {
        结果.add(路径);
        return;
    }
    for (选择 in 选择列表) {
        做选择(路径添加当前选择);
        backtrack(路径, 选择列表);
        撤销选择(路径移除当前选择);
    }
}

翻译成大白话就是:先选一条路走到底,走不通(或走完)就退回来,换另一条路继续试。这和我们生活中走迷宫的思路一模一样——遇到死胡同就回头,直到找到出口。

用字母组合问题拆解回溯过程

以数字组合 "23" 为例,具体看看代码如何实现:

首先,需要一个映射表记录每个数字对应的字母:

const letterMap = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"];

核心函数 letterCombinations

function letterCombinations(digits) {
    const result = []; // 存储最终结果
    const path = []; // 记录当前路径

    if (digits.length === 0) return result;

    // 回溯函数(index表示当前处理到第几个数字)
    function backtracking(index) {
        // 终止条件:路径长度等于数字长度(走到叶子节点)
        if (index === digits.length) {
            result.push(path.join('')); // 将路径转成字符串加入结果
            return;
        }

        // 获取当前数字对应的字母列表
        const digit = digits[index] - '0';
        const letters = letterMap[digit];

        // 遍历当前字母列表,做选择
        for (let i = 0; i < letters.length; i++) {
            path.push(letters[i]); // 选择当前字母,加入路径
            backtracking(index + 1); // 递归处理下一个数字
            path.pop(); // 撤销选择,回到上一层
        }
    }

    backtracking(0); // 从第一个数字开始
    return result;
}

关键步骤:path.pop() 为什么必不可少?

第一次看这段代码时,最困惑的是 path.pop() 的作用。为什么递归之后要把刚才添加的字母移除?

举个具体例子:假设当前处理数字 "2"(对应 "abc"),我们依次选择 "a"、"b"、"c"。当选择 "a" 时,路径变成 ["a"],然后递归处理下一个数字 "3"(对应 "def")。这时会进入内层递归,路径依次添加 "d"、"e"、"f",形成 ["a", "d"]["a", "e"]["a", "f"],每次到达终止条件时,这三个路径会被加入 result

但如果没有 path.pop(),当内层递归处理完 "d" 后,路径是 ["a", "d"],回到外层递归时,路径不会清空,下一次循环选择 "b" 时,路径会变成 ["a", "d", "b"],这显然是错误的。

path.pop() 的本质是「撤销选择」,让路径回到上一层的状态,这样下一次循环才能正确尝试新的选择。就像走迷宫时,你带着一个笔记本记录路径,走到死胡同时,需要把最后一步的记录擦掉,才能重新记录新的路径。

结果收集:何时「保存路径」?

在回溯算法中,终止条件触发时保存路径。比如字母组合问题中,当 index 等于 digits.length 时,说明已经处理完所有数字,当前路径就是一个完整的组合。这时候需要用 path.join('') 将数组转成字符串,否则结果会是数组形式(如 ["a", "d"])。

这里有个细节:为什么不用 path.slice() 复制数组?因为 path 是引用类型,后续的 pop 操作会修改原数组。但在这个问题中,path.join('') 已经生成了新的字符串,所以不需要额外复制。如果是对象或数组类型的结果,可能需要深拷贝。

从树到回溯:思维模式的共通性

列表转树和回溯算法看似不同,本质都是「路径探索」:

  • 列表转树是「从父到子」的路径构建,每个节点需要找到自己的子节点路径;
  • 回溯算法是「从根到叶子」的路径尝试,每条路径代表一种可能的解。

它们都体现了递归的核心思想:将大问题分解为子问题,通过解决子问题来解决整体问题。而回溯算法的特殊之处在于,它不仅需要解决子问题,还要在子问题解决后「恢复现场」,以便尝试其他可能的路径。

学习总结:这些细节需要注意

  1. 递归的终止条件:必须明确何时停止递归,否则会导致栈溢出。比如列表转树中,当没有子节点时递归自然终止;字母组合中,当处理完所有数字时终止。
  2. 路径的管理:无论是树的 children 数组还是回溯的 path 数组,本质都是记录当前探索的路径。需要注意引用类型的修改会影响所有层级,必要时进行深拷贝。
  3. 时间复杂度的优化:递归虽然直观,但可能存在重复计算。哈希表、记忆化搜索等方法可以优化时间复杂度。
  4. 回溯的核心做选择 -> 递归 -> 撤销选择 的循环,其中 撤销选择 是回溯的关键,确保路径可以重复利用。

写在最后

从列表转树到回溯算法,我最大的收获是理解了「路径」在数据结构和算法中的重要性。无论是构建树形结构还是寻找组合解,本质都是在不同的路径中探索。掌握这种思维模式后,再遇到类似的问题(如全排列、子集问题),都能快速找到解题思路。

技术学习就是这样,看似不相关的知识点,往往藏着底层的共通逻辑。多思考、多对比,才能把零散的知识串成体系。下一次遇到新问题时,不妨问问自己:这是不是某种「路径探索」问题?或许会有意外的收获。

从树形结构到回溯算法:我在前端学习中遇到的「路径」难题

最近在刷前端算法题时,我遇到了两个特别有意思的问题:一个是把平面列表转成树形结构,另一个是用回溯算法解决数字字母组合问题。原本以为这两个问题毫无关联,直到我在调试代码时盯着 path.pop() 看了半小时——原来它们都藏着「路径探索」的底层逻辑。今天就结合我的学习笔记,和大家聊聊这两个问题背后的思维模式。

从列表转树开始:递归里的「父子关系」

作为前端,我们经常需要处理层级数据。比如菜单权限、省市区联动选择,后端往往会返回一个平面数组,每个节点有 idparentId,我们需要把它转成树形结构。

先看一个典型的列表数据:

let list = [
    { id: '1', title: '节点1', parentId: '' },
    { id: '1-1', title: '节点1-1', parentId: '1' },
    { id: '1-2', title: '节点1-2', parentId: '1' },
    { id: '2', title: '节点2', parentId: '' },
    { id: '2-1', title: '节点2-1', parentId: '2' }
]

目标是把它转成这样的树:

[
    {
        id: '1',
        title: '节点1',
        children: [
            { id: '1-1', title: '节点1-1', children: [] },
            { id: '1-2', title: '节点1-2', children: [] }
        ]
    },
    {
        id: '2',
        title: '节点2',
        children: [ { id: '2-1', title: '节点2-1', children: [] } ]
    }
]

递归解法:最直观的「找爸爸」思路

新手最容易想到的是递归。既然每个节点的 parentId 指向父节点,那我们可以先找根节点(parentId 为空),然后为每个根节点找子节点,子节点的子节点再递归查找。

看我写的第一个版本代码:

function list2Tree(list, parentId = '') {
    // 过滤出当前父节点的直接子节点
    return list.filter(item => item.parentId === parentId)
               .map(item => {
                   // 递归查找子节点的子节点
                   item.children = list2Tree(list, item.id);
                   return item;
               });
}

这段代码的逻辑很简单:用 parentId 过滤出当前层级的节点,然后为每个节点递归查找其子节点(即 parentId 等于当前节点 id 的节点)。

但它有个明显的问题:时间复杂度是 O(n²)。因为每次递归都要遍历整个列表,假设列表有 n 个节点,每个节点都会被遍历 logn 次(树的深度),最坏情况下(比如链表结构)就是 O(n²)。

哈希表优化:用空间换时间的「快速查找」

为了优化时间复杂度,我想到用哈希表预存所有节点。先把每个节点存入哈希表,键是 id,值是节点本身(带 children 数组)。然后再次遍历列表,通过 parentId 直接从哈希表中找到父节点,把当前节点添加到父节点的 children 里。

优化后的代码:

function list2Tree(list) {
    const map = {}; // 哈希表存储所有节点
    const root = []; // 根节点数组

    // 第一步:初始化哈希表,每个节点先添加空children
    list.forEach(item => {
        map[item.id] = { ...item, children: [] };
    });

    // 第二步:关联父子节点
    list.forEach(item => {
        if (item.parentId) {
            // 非根节点:找到父节点,添加到其children
            map[item.parentId].children.push(map[item.id]);
        } else {
            // 根节点:直接加入结果数组
            root.push(map[item.id]);
        }
    });

    return root;
}

这一步优化把时间复杂度降到了 O(n),因为只需要两次遍历列表,而哈希表的查找是 O(1)。这让我意识到:递归虽然直观,但涉及大量重复计算时,用空间换时间是常见的优化思路

从树到回溯:路径探索的「进」与「退」

在掌握了列表转树后,我遇到了另一个问题:用回溯算法解决「数字字母组合」问题。比如,手机键盘上数字 2 对应 "abc",3 对应 "def",输入 "23" 要输出所有可能的字母组合 ["ad","ae","af","bd","be","bf","cd","ce","cf"]。

image.png

一开始我觉得这和树形结构没关系,直到画出递归调用图——原来每个数字的字母选择,本质上是在构建一棵「决策树」。比如输入 "23",第一层是数字 2 的 "a""b""c",第二层是数字 3 的 "d""e""f",所有从根到叶子的路径就是最终的组合。

回溯算法的核心:「尝试-回退」的路径管理

回溯算法的代码模板通常是这样的:

function backtrack(路径, 选择列表) {
    if (满足终止条件) {
        结果.add(路径);
        return;
    }
    for (选择 in 选择列表) {
        做选择(路径添加当前选择);
        backtrack(路径, 选择列表);
        撤销选择(路径移除当前选择);
    }
}

翻译成大白话就是:先选一条路走到底,走不通(或走完)就退回来,换另一条路继续试。这和我们生活中走迷宫的思路一模一样——遇到死胡同就回头,直到找到出口。

用字母组合问题拆解回溯过程

以数字组合 "23" 为例,具体看看代码如何实现:

首先,需要一个映射表记录每个数字对应的字母:

const letterMap = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"];

核心函数 letterCombinations

function letterCombinations(digits) {
    const result = []; // 存储最终结果
    const path = []; // 记录当前路径

    if (digits.length === 0) return result;

    // 回溯函数(index表示当前处理到第几个数字)
    function backtracking(index) {
        // 终止条件:路径长度等于数字长度(走到叶子节点)
        if (index === digits.length) {
            result.push(path.join('')); // 将路径转成字符串加入结果
            return;
        }

        // 获取当前数字对应的字母列表
        const digit = digits[index] - '0';
        const letters = letterMap[digit];

        // 遍历当前字母列表,做选择
        for (let i = 0; i < letters.length; i++) {
            path.push(letters[i]); // 选择当前字母,加入路径
            backtracking(index + 1); // 递归处理下一个数字
            path.pop(); // 撤销选择,回到上一层
        }
    }

    backtracking(0); // 从第一个数字开始
    return result;
}

关键步骤:path.pop() 为什么必不可少?

第一次看这段代码时,最困惑的是 path.pop() 的作用。为什么递归之后要把刚才添加的字母移除?

举个具体例子:假设当前处理数字 "2"(对应 "abc"),我们依次选择 "a"、"b"、"c"。当选择 "a" 时,路径变成 ["a"],然后递归处理下一个数字 "3"(对应 "def")。这时会进入内层递归,路径依次添加 "d"、"e"、"f",形成 ["a", "d"]["a", "e"]["a", "f"],每次到达终止条件时,这三个路径会被加入 result

但如果没有 path.pop(),当内层递归处理完 "d" 后,路径是 ["a", "d"],回到外层递归时,路径不会清空,下一次循环选择 "b" 时,路径会变成 ["a", "d", "b"],这显然是错误的。

path.pop() 的本质是「撤销选择」,让路径回到上一层的状态,这样下一次循环才能正确尝试新的选择。就像走迷宫时,你带着一个笔记本记录路径,走到死胡同时,需要把最后一步的记录擦掉,才能重新记录新的路径。

结果收集:何时「保存路径」?

在回溯算法中,终止条件触发时保存路径。比如字母组合问题中,当 index 等于 digits.length 时,说明已经处理完所有数字,当前路径就是一个完整的组合。这时候需要用 path.join('') 将数组转成字符串,否则结果会是数组形式(如 ["a", "d"])。

这里有个细节:为什么不用 path.slice() 复制数组?因为 path 是引用类型,后续的 pop 操作会修改原数组。但在这个问题中,path.join('') 已经生成了新的字符串,所以不需要额外复制。如果是对象或数组类型的结果,可能需要深拷贝。

从树到回溯:思维模式的共通性

列表转树和回溯算法看似不同,本质都是「路径探索」:

  • 列表转树是「从父到子」的路径构建,每个节点需要找到自己的子节点路径;
  • 回溯算法是「从根到叶子」的路径尝试,每条路径代表一种可能的解。

它们都体现了递归的核心思想:将大问题分解为子问题,通过解决子问题来解决整体问题。而回溯算法的特殊之处在于,它不仅需要解决子问题,还要在子问题解决后「恢复现场」,以便尝试其他可能的路径。

学习总结:这些细节需要注意

  1. 递归的终止条件:必须明确何时停止递归,否则会导致栈溢出。比如列表转树中,当没有子节点时递归自然终止;字母组合中,当处理完所有数字时终止。
  2. 路径的管理:无论是树的 children 数组还是回溯的 path 数组,本质都是记录当前探索的路径。需要注意引用类型的修改会影响所有层级,必要时进行深拷贝。
  3. 时间复杂度的优化:递归虽然直观,但可能存在重复计算。哈希表、记忆化搜索等方法可以优化时间复杂度。
  4. 回溯的核心做选择 -> 递归 -> 撤销选择 的循环,其中 撤销选择 是回溯的关键,确保路径可以重复利用。

写在最后

从列表转树到回溯算法,我最大的收获是理解了「路径」在数据结构和算法中的重要性。无论是构建树形结构还是寻找组合解,本质都是在不同的路径中探索。掌握这种思维模式后,再遇到类似的问题(如全排列、子集问题),都能快速找到解题思路。

技术学习就是这样,看似不相关的知识点,往往藏着底层的共通逻辑。多思考、多对比,才能把零散的知识串成体系。下一次遇到新问题时,不妨问问自己:这是不是某种「路径探索」问题?或许会有意外的收获。

译:整洁代码心理学—为何我们写出混乱的React组件

译:整洁代码心理学—为何我们写出混乱的React组件

编者注:==这篇文章探讨了开发者为何明知整洁代码重要却仍写出混乱 React 组件的心理原因==。1) ==认知负荷陷阱==让我们在压力下选择快速方案而非最佳实践;2) ==沉没成本谬误==使我们不愿重构已有代码;3) ==复杂性偏见==导致过早优化和过度设计;4) ==决策疲劳==限制了我们持续做出最佳编码决策的能力。文章提出了渐进式开发、心理安全环境和"童子军规则"等实用策略来改善代码质量。

我们都知道应该写整洁代码。我们读过相关书籍,参加过讲座,也认同各种原则。但不知为何,我们仍然会写出混乱的 React 组件。原因不在于我们的技术能力,而在于我们的心理。

认知负荷陷阱

考虑这个常见场景:

const UserDashboard = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [filter, setFilter] = useState("");
  const [sortBy, setSortBy] = useState("name");
  const [page, setPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);
  useEffect(() => {
    fetchUsers();
  }, [filter, sortBy, page]);
  const fetchUsers = async () => {
    try {
      setLoading(true);
      const response = await fetch(
        `/api/users?filter=${filter}&sort=${sortBy}&page=${page}`
      );
      const data = await response.json();
      setUsers(data.users);
      setTotalPages(data.totalPages);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };
  const handleFilterChange = (e) => setFilter(e.target.value);
  const handleSortChange = (e) => setSortBy(e.target.value);
  const handlePageChange = (newPage) => setPage(newPage);
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  return (
    <div>
      <FilterBar
        filter={filter}
        onFilterChange={handleFilterChange}
        sortBy={sortBy}
        onSortChange={handleSortChange}
      />
      <UserList users={users} />
      <Pagination
        currentPage={page}
        totalPages={totalPages}
        onPageChange={handlePageChange}
      />
    </div>
  );
};

这个组件不算糟糕,但也不够好。它承担了太多职责,处理了太多关注点,维护起来会很困难。然而,这正是我们在压力下或试图快速推进时会写出的那种组件。

为何我们写出混乱代码

1. 计划谬误

我们总是低估任务所需时间。这导致:

  • 赶工期
  • 走捷径
  • 跳过重构
  • 忽视最佳实践

2. 沉没成本谬误

一旦写出代码,我们就因以下原因不愿修改它:

  • 已经投入了时间
  • 对自己的解决方案有情感依赖
  • 害怕破坏现有功能

3. 复杂性偏见

我们常常:

  • 过度复杂化简单解决方案
  • 添加未来可能需要的功能
  • 过早创建抽象
  • 为可能永远不会出现的边缘情况编写代码

4. 决策疲劳与认知负荷

神经科学研究表明我们大脑的决策能力有限。Diederich 和 Trueblood(2018)的研究显示:

  • 开发者连续编码 2 小时后错误率增加 30%
  • 组件中每增加一个状态变量,认知负荷增加 37%
  • 复杂组件会触发类似多任务处理的"神经切换成本"

这解释了为何我们常常:

  • 选择快速方案而非适当抽象
  • 复制代码而非重构现有逻辑
  • 留下 TODO 注释而非立即解决问题

Sweller(1988)的==认知负荷理论==表明,工作记忆只能同时保存 4±1 个信息块。当我们的组件管理多个关注点(数据获取、状态管理、UI 渲染)时,就会超出这个限制,代码质量就会下降。

打破循环

1. 从小处着手并迭代

与其写上面那种庞然大物,我们可以从以下开始:

const UserDashboard = () => {
  const { users, loading, error } = useUsers();
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  return <UserList users={users} />;
};

然后根据需要逐步添加功能:

const UserDashboard = () => {
  const { users, loading, error } = useUsers();
  const { filter, setFilter } = useFilter();
  const { sortBy, setSortBy } = useSort();
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  return (
    <div>
      <FilterBar
        filter={filter}
        onFilterChange={setFilter}
        sortBy={sortBy}
        onSortChange={setSortBy}
      />{" "}
      <UserList users={users} />{" "}
    </div>
  );
};

2. 创造心理安全感

  • 留出重构时间
  • 允许承认错误
  • 鼓励代码审查
  • 表彰整洁代码范例

3. 使用"童子军规则"

让代码比你发现时更整洁。这意味着:

  • 看到小问题就修复
  • 逐步重构
  • 随时记录
  • 与团队分享知识

实用策略

1. 五分钟规则

写任何代码前先问:

  • 最简单的可行方案是什么?
  • 我能在 5 分钟内解决这个问题吗?
  • 最少需要做什么?

2. "代码审查"测试

提交代码前问:

  • 我会自豪地在代码审查中展示这个吗?
  • 这是解决问题最整洁的方式吗?
  • 怎样才能让这段代码更好?

3. "未来的我"测试

考虑:

  • 未来的我能理解这段代码吗?
  • 未来的我能轻松修改这段代码吗?
  • 未来的我会感谢现在的我写了这段代码吗?

还有:

  • 我能用一句话说明这个组件的职责吗?
  • 添加新功能需要修改超过 3 个文件吗?
  • 有任何"我当时在想什么?"的代码模式吗?

结论

写整洁代码不仅是技术能力问题,更是理解我们的心理偏见并努力克服它们。通过认识这些模式并实施这些策略,我们可以写出更好的代码,创建更易维护的应用。

记住:整洁代码不是追求完美,而是做出小而持续的改进,并意识到我们天生倾向于走捷径。

延伸阅读

书籍与概述

英文原文

🌟 前端工程师必知的 MCP 秘籍:从渲染优化到性能飞跃

🔍 什么是 MCP?为什么前端要关注?

MCP(Main-Thread Computing Performance) 指的是浏览器主线程的计算性能,它直接影响页面的流畅度响应速度用户体验

作为前端工程师,你可能经常遇到这些问题:

  • 页面卡顿,特别是动画或滚动不流畅
  • 输入延迟,用户点击后反应慢
  • 首屏加载慢,即使资源已经下载完成

👉 这些问题的核心,往往就是 MCP 瓶颈!

📊 浏览器主线程(Main Thread)在做什么?

浏览器的主线程负责处理:
JavaScript 执行(你的 **React/Vue** 代码)
样式计算(CSS 解析与计算)
布局(Layout)(计算元素位置)
绘制(Paint)(生成像素数据)

⚠️ 如果主线程被阻塞,页面就会卡顿!

🚀 前端优化 MCP 的 5 大核心策略

1️⃣ 减少 JavaScript 执行时间(Long Tasks 优化)

📌 问题:超过 50ms 的 JS 任务会让用户感知到延迟。

优化方案:

  • 代码拆分**React.lazy** / **Vue 异步组件**

  • Web Workers 处理计算密集型任务(如大数据解析)

  • 使用 **requestIdleCallback** 执行低优先级任务

    // 使用 Web Worker 处理大数据 const worker = new Worker('data-processor.js'); worker.postMessage(largeData); worker.onmessage = (e) => { console.log('处理完成:', e.data); };

2️⃣ 避免强制同步布局(Layout Thrashing)

📌 问题:JS 频繁读写 DOM 样式,导致浏览器反复计算布局。

优化方案:
批量 DOM 操作(如 **documentFragment**
使用 **FastDOM** (自动优化读写顺序)

// ❌ 错误写法(强制同步布局)
const width = element.offsetWidth; // 读取
element.style.width = width + 10 + 'px'; // 写入
const height = element.offsetHeight; // 又读取 → 触发重排!

// ✅ 正确写法(读写分离)
requestAnimationFrame(() => {
  const width = element.offsetWidth;
  element.style.width = width + 10 + 'px';
});

3️⃣ 优化 CSS 选择器(减少样式计算成本)

📌 问题:复杂的 CSS 选择器会增加样式计算时间。

优化方案:
避免嵌套过深(如 **.nav ul li a span** ❌)
使用 BEM 命名规范(减少选择器复杂度)

/* ❌ 性能较差 */
.nav ul li a span.highlight { 
  color: red;
}

/* ✅ 优化后 */
.nav__link--highlight {
  color: red;
}

4️⃣ 使用 will-change 提示浏览器优化

📌 问题:动画卡顿,因为浏览器没提前准备 GPU 加速。

优化方案:
对动画元素添加 **will-change**

.animated-element {
  will-change: transform, opacity;
  transition: transform 0.3s ease;
}

⚠️ 注意: 滥用 **will-change** 会消耗更多内存!

5️⃣ 监控 MCP 性能(使用 Chrome DevTools)

📌 问题:如何量化主线程负载?

优化方案:

  1. 打开 Chrome DevToolsPerformance 面板

  2. 录制页面操作,分析 Main Thread 火焰图

  3. 关注 Long Tasks(标红的部分)

(图片来源:Chrome 开发者文档)

📈 真实案例:优化后 MCP 提升 60%!

某电商网站优化前:

  • Long Tasks:120ms(导致滚动卡顿)
  • 首次输入延迟(FID):150ms

优化后(应用上述策略):

  • Long Tasks:降至 45ms
  • FID:降至 80ms

🎯 总结:前端 MCP 优化 Checklist

优化方向

具体措施

工具/API

JS 执行

代码拆分、Web Workers

**React.lazy**

**Comlink**

布局抖动

批量 DOM 操作

**FastDOM**

**requestAnimationFrame**

CSS 计算

简化选择器

BEM 命名法

动画性能

**will-change**

CSS 硬件加速

性能监控

分析 Long Tasks

Chrome DevTools

🚀 进阶学习资源

💬 互动话题:
你在项目中遇到过 MCP 问题吗? 欢迎分享你的优化经验! 🚀

(如果觉得有帮助,请点赞/收藏支持!❤️)

错误处理艺术:从异常捕获到体验保障

引言

在开发中,错误就像隐藏的暗礁,表面平静却能在用户体验的海洋中掀起巨浪。一个未处理的异常可能导致整个应用崩溃,而隐蔽的性能问题则会悄无声息地消耗用户耐心。

如何构建强大的错误处理机制,不仅是技术能力的体现,更是我们责任感的象征。

错误处理的基础:理解错误类型

JavaScript错误可分为以下几类:

  • 语法错误:代码结构问题,在解析阶段抛出
  • 运行时错误:执行过程中的异常
  • 逻辑错误:代码能正常运行但结果不符合预期
  • 异步错误:Promise拒绝或异步操作失败
  • 网络错误:API请求失败或超时

try/catch:错误处理的第一道防线

function fetchUserData(userId) {
  try {
    const response = fetch(`/api/users/${userId}`);
    return response.json();
  } catch (error) {
    // 分类处理不同错误
    if (error instanceof TypeError) {
      console.error("网络请求失败:", error.message);
    } else {
      console.error("未知错误:", error);
    }
    // 提供后备方案
    return getLocalUserData(userId);
  } finally {
    // 无论是否出错都需执行的清理操作
    hideLoadingIndicator();
  }
}

局限性分析

  • try/catch无法捕获异步错误,除非在异步函数内部使用
  • 无法捕获不同源的脚本错误(跨域限制)
  • 过度使用会导致代码可读性下降

React错误边界:组件级错误隔离

错误边界是React特有的错误处理机制,可防止组件树中的JavaScript错误导致整个应用崩溃。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null, errorInfo: null };
  }
  
  static getDerivedStateFromError(error) {
    // 更新状态,下次渲染时显示降级UI
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    // 记录错误信息
    logErrorToService(error, errorInfo);
    this.setState({ error, errorInfo });
  }
  
  render() {
    if (this.state.hasError) {
      return (
        <div className="error-container">
          <h2>组件加载失败</h2>
          <details style={{ whiteSpace: 'pre-wrap' }}>
            {this.state.error && this.state.error.toString()}
            <br />
            {this.state.errorInfo.componentStack}
          </details>
          <button onClick={() => this.setState({ hasError: false })}>
            重试
          </button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

// 使用方式
function App() {
  return (
    <div>
      <ErrorBoundary>
        <ProfilePage />
      </ErrorBoundary>
      <ErrorBoundary>
        <FeedComponent />
      </ErrorBoundary>
    </div>
  );
}

特殊情况分析

  • 错误边界无法捕获以下错误:
    • 事件处理函数中的错误
    • 异步代码(setTimeout、Promise等)
    • 服务端渲染
    • 错误边界组件自身的错误

全局错误处理:最后的安全网

为捕获所有可能遗漏的错误,建立全局错误处理机制至关重要。

// 处理同步错误
window.onerror = function(message, source, lineno, colno, error) {
  const errorDetails = {
    message,
    source,
    line: lineno,
    column: colno,
    stack: error?.stack,
    timestamp: new Date().toISOString(),
    userAgent: navigator.userAgent
  };
  
  logErrorToAnalytics(errorDetails);
  // 返回true阻止错误显示在控制台
  return true;
};

// 处理未捕获的Promise拒绝
window.addEventListener('unhandledrejection', event => {
  const errorDetails = {
    type: 'unhandledRejection',
    reason: event.reason?.message || '未知原因',
    stack: event.reason?.stack,
    timestamp: new Date().toISOString()
  };
  
  logErrorToAnalytics(errorDetails);
  // 阻止默认处理
  event.preventDefault();
});

// 处理资源加载错误
window.addEventListener('error', event => {
  // 仅处理资源加载错误
  if (event.target && (event.target.nodeName === 'IMG' || event.target.nodeName === 'SCRIPT' || event.target.nodeName === 'LINK')) {
    const errorDetails = {
      type: 'resourceError',
      element: event.target.nodeName,
      source: event.target.src || event.target.href,
      timestamp: new Date().toISOString()
    };
    
    logErrorToAnalytics(errorDetails);
    // 阻止默认处理
    event.preventDefault();
  }
}, true); // 使用捕获阶段

错误分析与上报系统

有效的错误上报能帮助开发团队快速定位和解决问题。

// 错误上报服务
class ErrorReporter {
  constructor(options = {}) {
    this.apiEndpoint = options.apiEndpoint || '/api/errors';
    this.appVersion = options.appVersion || '1.0.0';
    this.batchSize = options.batchSize || 10;
    this.errorQueue = [];
    
    // 定期发送错误批次
    setInterval(() => this.sendErrorBatch(), 30000);
    
    // 页面卸载前发送所有剩余错误
    window.addEventListener('beforeunload', () => {
      if (this.errorQueue.length > 0) {
        this.sendErrorBatch(true);
      }
    });
  }
  
  logError(error, context = {}) {
    const errorDetails = {
      message: error.message || String(error),
      stack: error.stack,
      timestamp: new Date().toISOString(),
      url: window.location.href,
      userAgent: navigator.userAgent,
      appVersion: this.appVersion,
      context: {
        ...context,
        screenSize: `${window.innerWidth}x${window.innerHeight}`,
        sessionDuration: this.getSessionDuration()
      }
    };
    
    this.errorQueue.push(errorDetails);
    
    // 达到批处理大小时发送
    if (this.errorQueue.length >= this.batchSize) {
      this.sendErrorBatch();
    }
    
    return errorDetails;
  }
  
  sendErrorBatch(isSync = false) {
    if (this.errorQueue.length === 0) return;
    
    const batch = [...this.errorQueue];
    this.errorQueue = [];
    
    const sendMethod = isSync ? this.sendSynchronously : this.sendAsynchronously;
    sendMethod.call(this, batch);
  }
  
  sendAsynchronously(batch) {
    fetch(this.apiEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ errors: batch }),
      // 使用keepalive确保页面卸载时请求仍能完成
      keepalive: true
    }).catch(err => {
      // 发送失败时重新加入队列
      this.errorQueue = [...batch, ...this.errorQueue];
      console.error('Error reporting failed:', err);
    });
  }
  
  sendSynchronously(batch) {
    // 使用Beacon API进行同步发送
    const blob = new Blob(
      [JSON.stringify({ errors: batch })], 
      { type: 'application/json' }
    );
    navigator.sendBeacon(this.apiEndpoint, blob);
  }
  
  getSessionDuration() {
    const sessionStart = sessionStorage.getItem('sessionStart') || Date.now().toString();
    if (!sessionStorage.getItem('sessionStart')) {
      sessionStorage.setItem('sessionStart', sessionStart);
    }
    return Math.floor((Date.now() - parseInt(sessionStart)) / 1000);
  }
}

// 使用
const errorReporter = new ErrorReporter({
  apiEndpoint: 'https://api.yourapp.com/errors',
  appVersion: '2.1.3'
});

// 集成到全局错误处理中
window.onerror = function(message, source, lineno, colno, error) {
  errorReporter.logError(error || new Error(message), { 
    source, 
    line: lineno, 
    column: colno 
  });
  return false;
};

前端调试技巧与工具链

高效控制台使用

// 分组和层次结构
console.group('用户认证流程');
console.log('开始认证请求');
console.groupCollapsed('详细参数');
console.table({
  username: 'user123',
  authType: 'oauth',
  timestamp: Date.now()
});
console.groupEnd();
console.log('认证完成');
console.groupEnd();

// 条件断点
// 在浏览器开发工具中为特定条件设置断点:
// if (user.id === 'problem-user-123')

// 性能分析
console.time('数据处理');
processLargeDataset(rawData);
console.timeEnd('数据处理');

// DOM变化监控
const targetNode = document.getElementById('dynamic-content');
const observer = new MutationObserver(mutations => {
  console.log('DOM发生变化:', mutations);
});
observer.observe(targetNode, { childList: true, subtree: true });

源码映射与生产环境调试

生产环境调试最大的挑战是代码已被压缩和混淆。正确配置source maps可在保护知识产权的同时支持调试。

// webpack配置示例 (webpack.config.js)
module.exports = {
  // ...
  mode: 'production',
  devtool: process.env.NODE_ENV === 'production' 
    ? 'hidden-source-map'  // 仅生成source maps但不在浏览器中引用
    : 'source-map',        // 开发环境使用完整source maps
  // ...
};

预防胜于治疗:错误预防机制

TypeScript静态类型检查

// 定义可能的错误类型
enum ErrorType {
  Network = 'NETWORK_ERROR',
  Validation = 'VALIDATION_ERROR',
  Authentication = 'AUTH_ERROR',
  Unknown = 'UNKNOWN_ERROR'
}

// 定义错误结构
interface AppError {
  type: ErrorType;
  message: string;
  code?: number;
  originalError?: unknown;
}

// 类型安全的错误创建函数
function createError(type: ErrorType, message: string, code?: number, originalError?: unknown): AppError {
  return { type, message, code, originalError };
}

// 使用类型检查确保错误处理的一致性
function handleAppError(error: AppError): void {
  switch (error.type) {
    case ErrorType.Network:
      // 处理网络错误
      showOfflineMessage(error.message);
      break;
    case ErrorType.Validation:
      // 处理表单验证错误
      highlightFormErrors(error.message);
      break;
    case ErrorType.Authentication:
      // 处理认证错误
      redirectToLogin(error.message);
      break;
    case ErrorType.Unknown:
    default:
      // 处理未知错误
      logToAnalytics(error);
      showGenericErrorMessage();
  }
}

前端测试保障

// Jest测试示例,检查错误处理
describe('ErrorBoundary', () => {
  it('正常显示子组件当没有错误时', () => {
    const { getByText } = render(
      <ErrorBoundary>
        <div>正常内容</div>
      </ErrorBoundary>
    );
    expect(getByText('正常内容')).toBeInTheDocument();
  });

  it('显示错误UI当子组件抛出错误', () => {
    // 创建一个会抛出错误的组件
    const BuggyComponent = () => {
      throw new Error('测试错误');
      // eslint-disable-next-line no-unreachable
      return <div>永远不会渲染</div>;
    };

    // 临时屏蔽控制台错误,避免测试输出混乱
    const originalError = console.error;
    console.error = jest.fn();

    const { getByText } = render(
      <ErrorBoundary>
        <BuggyComponent />
      </ErrorBoundary>
    );
    
    // 恢复控制台
    console.error = originalError;

    // 断言错误UI显示
    expect(getByText('组件加载失败')).toBeInTheDocument();
  });
});

处理"看不见的错误"

静默失败检测

// Promise静默失败检测
function trackPromise(promise, context = '') {
  const timeoutDuration = 30000; // 30秒
  let isResolved = false;
  
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => {
      if (!isResolved) {
        reject(new Error(`Promise ${context} 可能已悬挂 - 超过 ${timeoutDuration}ms 无响应`));
      }
    }, timeoutDuration);
  });
  
  // 包装原始Promise以标记完成状态
  const wrappedPromise = promise.then(
    result => {
      isResolved = true;
      return result;
    },
    error => {
      isResolved = true;
      throw error;
    }
  );
  
  // 竞争原始Promise和超时
  return Promise.race([wrappedPromise, timeoutPromise]);
}

// 使用示例
function fetchData() {
  const dataPromise = fetch('/api/data').then(r => r.json());
  return trackPromise(dataPromise, 'fetchData API请求');
}

性能错误监控

// 监控关键操作性能
class PerformanceMonitor {
  constructor(thresholds = {}) {
    this.thresholds = {
      renderTime: 16, // 16ms(60fps的时间预算)
      networkTime: 3000, // 3秒
      responsiveness: 100, // 100ms
      ...thresholds
    };
    
    this.metrics = {
      longRenders: [],
      slowNetworkRequests: [],
      inputLags: []
    };
    
    // 监控渲染性能
    this.setupRenderMonitoring();
    
    // 监控网络请求
    this.setupNetworkMonitoring();
    
    // 监控用户输入响应性
    this.setupInputMonitoring();
    
    // 定期上报性能问题
    setInterval(() => this.reportPerformanceIssues(), 60000);
  }
  
  setupRenderMonitoring() {
    // 使用PerformanceObserver监控长任务
    if ('PerformanceObserver' in window) {
      const observer = new PerformanceObserver(list => {
        for (const entry of list.getEntries()) {
          if (entry.duration > this.thresholds.renderTime) {
            this.metrics.longRenders.push({
              duration: entry.duration,
              timestamp: entry.startTime,
              culprit: entry.name || 'unknown'
            });
          }
        }
      });
      observer.observe({ entryTypes: ['longtask'] });
    }
  }
  
  setupNetworkMonitoring() {
    // 拦截fetch请求
    const originalFetch = window.fetch;
    window.fetch = async (...args) => {
      const startTime = performance.now();
      try {
        const response = await originalFetch(...args);
        const duration = performance.now() - startTime;
        
        if (duration > this.thresholds.networkTime) {
          this.metrics.slowNetworkRequests.push({
            url: args[0].toString(),
            duration,
            timestamp: startTime
          });
        }
        
        return response;
      } catch (error) {
        const duration = performance.now() - startTime;
        this.metrics.slowNetworkRequests.push({
          url: args[0].toString(),
          duration,
          error: error.message,
          timestamp: startTime
        });
        throw error;
      }
    };
  }
  
  setupInputMonitoring() {
    // 监控输入事件响应延迟
    ['click', 'touchstart', 'keydown'].forEach(eventType => {
      document.addEventListener(eventType, event => {
        const startTime = performance.now();
        
        // 在下一帧检查响应时间
        requestAnimationFrame(() => {
          requestAnimationFrame(() => {
            const duration = performance.now() - startTime;
            if (duration > this.thresholds.responsiveness) {
              this.metrics.inputLags.push({
                eventType,
                duration,
                timestamp: startTime,
                target: event.target.tagName
              });
            }
          });
        });
      }, { passive: true });
    });
  }
  
  reportPerformanceIssues() {
    const issues = {
      longRenders: [...this.metrics.longRenders],
      slowNetworkRequests: [...this.metrics.slowNetworkRequests],
      inputLags: [...this.metrics.inputLags]
    };
    
    // 重置收集的指标
    this.metrics.longRenders = [];
    this.metrics.slowNetworkRequests = [];
    this.metrics.inputLags = [];
    
    // 只上报有问题的指标
    const hasIssues = Object.values(issues).some(arr => arr.length > 0);
    if (hasIssues) {
      console.warn('检测到性能问题:', issues);
      // sendToAnalyticsService(issues);
    }
  }
}

// 使用
const perfMonitor = new PerformanceMonitor({
  renderTime: 20, // 根据应用情况调整阈值
  networkTime: 5000
});

错误处理的未来

随着Web应用复杂度不断提高,错误处理可能将面临以下发展趋势:

  1. AI辅助调试:机器学习算法分析错误模式,提供智能修复建议
  2. 分布式跟踪:跨前后端的全链路错误追踪,构建完整错误上下文
  3. 主动预测:基于用户行为和应用状态预测可能出现的错误,提前防范
  4. 差异化处理策略:根据用户重要性、功能关键性动态调整错误处理策略

总结

前端错误处理是一门平衡的艺术——既要捕获足够的错误信息用于修复,又要保持良好的用户体验。构建多层次的错误处理系统,从组件级到全局级,从前端到后端,才能打造出真正健壮的应用。

我们应当关注不仅是"捕获错误",而是"管理用户体验中的不确定性"。

扩展资源


如果你觉得这篇文章有帮助,欢迎点赞收藏,也期待在评论区看到你的想法和建议!👇

终身学习,共同成长。

咱们下一期见

💻

【实战】深入浅出 Rust 并发:RwLock 与 Mutex 在 Tauri 项目中的实践

引言

你是否遇到过 Rust 并发场景下的资源竞争、性能瓶颈? 当多个线程同时抓取网页导致 IP 被封、多线程读写本地数据引发一致性问题时,如何优雅地实现线程安全?

本文结合开源项目 Saga Reader的真实开发场景,深度解析 Arc/Mutex/RwLock 的实战技巧,带你从 “踩坑” 到 “优化”,掌握 Rust 并发编程的核心方法论,文末附项目地址,欢迎 star 交流!

关于开源项目Saga Reader(中文麒睿智库),之前我在博客园中有详细介绍,新朋友可以先阅读《开源我的一款自用AI阅读器,引流Web前端、Rust、Tauri、AI应用开发》

技术背景

在 Rust 编程的世界里,并发编程是一个既强大又充满挑战的领域。为了实现高效、安全的并发操作,Rust 提供了一系列实用的工具,其中 Arc(原子引用计数指针)和 Mutex(互斥锁)、RwLock(读写锁)是非常关键的组件。本文将结合 Saga Reader 项目中的实际应用案例,深入探讨 Arc、Mutex、RwLock 的使用场景、技术要点,并结合我们的 Saga Reader 项目中的实际案例,分享它们在并发场景下的使用技巧和设计哲学。

什么是Saga Reader

基于Tauri开发的开源AI驱动的智库式阅读器(前端部分使用Web框架),能根据用户指定的主题和偏好关键词自动从互联网上检索信息。它使用云端或本地大型模型进行总结和提供指导,并包括一个AI驱动的互动阅读伴读功能,你可以与AI讨论和交换阅读内容的想法。

这个项目我5月刚放到Github上(Github - Saga Reader),欢迎大家关注分享。🧑‍💻码农🧑‍💻开源不易,各位好人路过请给个小星星💗Star💗。

核心技术栈:Rust + Tauri(跨平台)+ Svelte(前端)+ LLM(大语言模型集成),支持本地 / 云端双模式

关键词:端智能,边缘大模型;Tauri 2.0;桌面端安装包 < 5MB,内存占用 < 20MB。

运行截图

image.png

项目核心模块

image.png

问题初现:无 Arc 与 Mutex 的困境

因为是本地桌面端,涉及到本地数据的并发读写以及数据抓取的并发限流控制。以网页内容抓取模块为例,多个线程同时进行网页抓取操作,代码如下:

// 早期网页抓取示例
use reqwest;

async fn scrap_text_by_url(url: &str) -> anyhow::Result<String> {
    let response = reqwest::get(url).await?;
    let text = response.text().await?;
    // 处理网页内容
    Ok(text)
}

由于没有任何同步机制,多个线程可能会同时访问同一个网页资源,服务器可能会将这些请求视为恶意攻击,从而对 IP 进行封禁。同时,多个线程同时处理抓取到的内容,可能会导致数据处理混乱,影响最终结果的准确性。

再比如对本地数据的读取,无并发控制会引起数据不一致问题。

引入 Arc 与 Mutex:柳暗花明

Arc

Arc 是 Rust 标准库中的一个智能指针,全称为 Atomic Reference Counting。在多线程环境中,多个线程可能需要同时访问同一个资源,Arc 可以让多个线程安全地共享同一个数据实例。它通过原子操作来管理引用计数,当引用计数降为 0 时,数据会被自动释放,从而避免了数据竞争和内存泄漏的问题。

Mutex

Mutex 即互斥锁,是一种用于实现线程同步的机制。在多线程编程中,多个线程可能会同时访问和修改共享资源,这可能会导致数据不一致或其他竞态条件。Mutex 可以确保在同一时间只有一个线程能够访问被保护的资源,从而保证数据的一致性和线程安全。

Saga Reader 中的 Mutex 实战

源码:scrap/src/simulator.rs

在 Saga Reader 项目中,我们有一个模拟浏览器行为来抓取网页内容的功能,位于 。由于创建和管理模拟的 Webview 窗口是资源密集型操作,并且可能涉及到一些全局状态或限制(例如,不能同时打开多个同名的模拟窗口),我们需要确保这部分操作的串行化执行。

为什么选择 Mutex 而非其他锁?
> 模拟 Webview 窗口创建是资源密集型操作,且需保证同一时刻仅允许一个实例运行(避免内存泄漏和窗口句柄冲突)。此时写操作(创建窗口)是核心操作,读操作极少,因此选择 Mutex 保证独占性,而非引入 RwLock 的复杂度。

// ... existing code ...
use tokio::sync::{oneshot, Mutex}; // 引入 Tokio 的异步 Mutex
// ... existing code ...

// 使用 once_cell 的 Lazy 来延迟初始化一个全局的、带 Arc 的 Mutex
// Arc<Mutex<()>> 中的 () 表示我们用这个 Mutex 保护的不是具体数据,
// 而是保护一段代码逻辑的独占执行权。
static MUTEX: Lazy<Arc<Mutex<()>>> = Lazy::new(|| Arc::new(Mutex::new(())));

pub async fn scrap_text_by_url<R: Runtime>(
    app_handle: AppHandle<R>,
    url: &str,
) -> anyhow::Result<String> {
    // 在关键代码段开始前,异步获取锁
    // _lock 是一个 RAII 守护(guard),当它离开作用域时,锁会自动释放
    let _lock = MUTEX.lock().await;
    match app_handle.get_webview_window(WINDOW_SCRAP_HOST) {
        Some(_) => {
            error!("The scrap host for simulator was busy to use, scrap pages at the same time was not support currently!");
            Err(anyhow::anyhow!("Scrap host is busy"))
        }
        None => {
            // ... 创建和操作 Webview 窗口的代码 ...
            // 这部分代码在持有锁的期间执行,保证了同一时间只有一个任务能执行到这里
            let window = WebviewWindowBuilder::new(
// ... existing code ...
            Ok(result)
        }
    }
    // _lock 在这里离开作用域,Mutex 自动释放
}

在这个例子中,static MUTEX: Lazy<Arc<Mutex<()>>> 定义了一个全局静态的互斥锁。Arc 使得这个 Mutex 可以在多个异步任务之间安全共享。Lazy 确保 Mutex 只在第一次被访问时初始化。Mutex<()> 表示这个锁并不直接保护某个具体的数据,而是用来控制对一段代码逻辑(即创建和使用 WINDOW_SCRAP_HOST 窗口的过程)的独占访问。通过 MUTEX.lock().await,任何尝试执行 scrap_text_by_url 的任务都必须先获得这个锁,从而保证了模拟器资源的串行使用,避免了潜在的冲突和错误。

读多写少场景的性能利器:RwLock

虽然 Mutex 提供了强大的数据保护能力,但它的独占性在某些场景下可能会成为性能瓶颈。想象一个场景:我们有一个共享的配置对象,它很少被修改(写操作),但会被非常频繁地读取(读操作)。如果使用 Mutex,即使是多个读操作也不得不排队等待,这显然不是最优的。

RwLock<T> (Read-Write Lock) 正是为了解决这类“读多写少”的场景而设计的。它允许多个读取者同时访问共享数据,或者一个写入者独占访问共享数据。规则如下:

  • 共享读:可以有任意数量的读取者同时持有读锁。
  • 独占写:当有写入者持有写锁时,其他所有读取者和写入者都必须等待。
  • 读写互斥:当有任何读取者持有读锁时,写入者必须等待;反之亦然。

RwLock 的核心特性:

  • 提高读并发:在读取操作远多于写入操作时,RwLock 能显著提高并发性能。
  • 写操作依然独占:保证了数据修改时的安全性。

Saga Reader 中的 RwLock 实战

源码:feed_api_rs/src/features/impl_default.rs

在 Saga Reader 的核心功能模块 中,FeaturesAPIImpl 结构体持有一个 ApplicationContext,这个上下文中包含了用户配置 (UserConfig) 和应用配置 (AppConfig) 等共享状态。这些配置信息会被多个 API 调用读取,而修改配置的操作相对较少。

// ... existing code ...
use tokio::sync::RwLock; // 引入 Tokio 的异步 RwLock
// ... existing code ...

pub struct FeaturesAPIImpl {
    // ApplicationContext 被 Arc 和 RwLock 包裹,以便在异步任务间安全共享和并发访问
    context: Arc<RwLock<ApplicationContext>>,
    scrap_provider: ScrapProviderEnums,
    article_recorder_service: ArticleRecorderService,
}

impl FeaturesAPIImpl {
    pub async fn new(ctx: ApplicationContext) -> anyhow::Result<Self> {
        // ... 初始化代码 ...
        let context = Arc::new(RwLock::new(ctx)); // 创建 RwLock 实例
        // ...
        Ok(FeaturesAPIImpl {
            context,
            scrap_provider,
            article_recorder_service,
        })
    }

    // 示例:读取配置 (读操作)
    async fn update_feed_contents<R: Runtime>(
        &self,
        package_id: &str,
        feed_id: &str,
        app_handle: Option<AppHandle<R>>,
    ) -> anyhow::Result<()> {
        let user_config;
        let llm_section;
        {
            // 获取读锁,允许多个任务同时读取 context
            let context_guarded = self.context.read().await;
            user_config = context_guarded.user_config.clone();
            llm_section = context_guarded.app_config.llm.clone();
        } // 读锁在此处释放
        // ... 后续逻辑使用 user_config 和 llm_section ...
        Ok(())
    }

    // 示例:修改用户配置 (写操作)
    async fn add_feeds_package(&self, feeds_package: FeedsPackage) -> anyhow::Result<()> {
        // 获取写锁,独占访问 context
        let context_guarded = &mut self.context.write().await;
        let user_config = &mut context_guarded.user_config;
        if user_config.add_feeds_packages(feeds_package) {
            return self.sync_user_profile(user_config).await;
        }
        // ...
        Err(anyhow::Error::msg(
            "add_feeds_package failure, may be the feeds package already existed",
        ))
        // 写锁在此处释放
    }
}

context: Arc<RwLock<ApplicationContext>> 使得 ApplicationContext 可以在多个异步的 API 请求处理任务之间安全地共享。当一个任务需要读取配置,它会调用 self.context.read().await 来获取一个读锁。多个任务可以同时持有读锁并访问 ApplicationContext。当一个任务需要修改配置,它会调用 self.context.write().await 来获取一个写锁。此时,其他任何尝试获取读锁或写锁的任务都会被阻塞,直到写锁被释放。这种机制极大地提高了读取密集型操作的并发性能,同时保证了写操作的原子性和数据一致性。

关于 tauri::StateArc

我们经常看到 Tauri 命令的参数形如 state: State<'_, Arc<HybridRuntimeState>>。这里的 Arc<HybridRuntimeState> 表明 HybridRuntimeState 是一个被多所有权共享的状态对象。Tauri 的 State 管理器本身会确保以线程安全的方式将这个状态注入到命令处理函数中。如果 HybridRuntimeState 内部的数据需要细粒度的并发控制,那么它内部可能就会使用 MutexRwLock。例如,我们的 FeaturesAPIImpl 实例(它内部使用了 RwLock)就是通过 HybridRuntimeState 共享给各个 Tauri 命令的。

// ... existing code ...
// 在插件初始化时,创建 FeaturesAPIImpl 实例并放入 Arc 中
// 然后通过 app_handle.manage() 交给 Tauri 的状态管理器
.setup(|app_handle, _plugin| {
    let features_api = tauri::async_runtime::block_on(async {
        let context_host = Startup::launch().await.unwrap();
        let context = context_host.copy_context();
        FeaturesAPIImpl::new(context).await.expect("tauri-plugin-feed-api setup the features instance failure")
    });

    app_handle.manage(Arc::new(HybridRuntimeState { features_api })); // features_api 内部有 RwLock
    Ok(())
})
// ... existing code ...
// ... existing code ...
// Tauri 命令通过 State 获取共享的 HybridRuntimeState
#[tauri::command(rename_all = "snake_case")]
pub(crate) async fn get_feeds_packages(
    state: State<'_, Arc<HybridRuntimeState>>,
) -> Result<Vec<FeedsPackage>, ()> {
    // features_api 内部的 RwLock 会在这里发挥作用
    let features_api = &state.features_api;
    Ok(features_api.get_feeds_packages().await)
}
// ... existing code ...

Mutex vs. RwLock:如何选择?

特性 Mutex RwLock
基本原理 独占访问 共享读,独占写
适用场景 写操作频繁,或读写操作均衡,或逻辑简单 读操作远多于写操作,且读操作耗时较长
锁的粒度 通常较粗,保护整个数据结构或代码块 可以更细粒度,但通常也保护整个数据结构
性能(读多) 可能成为瓶颈 显著优于 Mutex
性能(写多) 与 RwLock 类似,或略优(因逻辑更简单) 可能不如 Mutex(因内部状态管理更复杂)
死锁风险 存在(如ABBA死锁) 存在,且可能更复杂(如写锁饥饿读锁)

选择建议:

  • 优先简单:如果不确定,或者共享数据的访问模式不清晰,可以从 Mutex 开始,因为它的语义更简单,更不容易出错。
  • 分析瓶颈:如果性能分析表明某个 Mutex 成为了瓶颈,并且该场景符合“读多写少”的特点,那么可以考虑替换为 RwLock
  • 警惕写锁饥饿RwLock 的一个潜在问题是写锁饥饿。如果读请求非常频繁,写操作可能长时间无法获得锁。一些 RwLock 的实现可能提供公平性策略来缓解这个问题,但仍需注意。
  • 锁的持有时间:无论使用 Mutex 还是 RwLock,都应尽可能缩短锁的持有时间,以减少线程阻塞和提高并发度。将耗时操作移出临界区(持有锁的代码段)。

总结与展望

MutexRwLock 是 Rust 并发编程中不可或缺的同步原语。它们以不同的策略平衡了数据安全和并发性能的需求。在 Saga Reader 项目中,我们根据具体的业务场景和数据访问模式,恰当地选择了 Mutex 来保证资源操作的串行化,以及 RwLock 来优化共享配置的并发读取性能。

理解并熟练运用这些并发工具,是构建高效、健壮的 Rust 应用的基石。随着项目的发展,我们也将持续关注并发性能,并在必要时对锁的使用策略进行调优,以确保 Saga Reader 能够为用户带来流畅、稳定的阅读体验。

参与开源,一起构建高效阅读器!

Saga Reader 是一个完全开源的跨平台项目,目前正在快速迭代中,急需以下方向的贡献者:

  • Rust 开发:优化并发逻辑、扩展本地大模型支持;
  • 前端开发:基于 Svelte 优化用户交互;
  • AI 算法:改进文本总结与伴读功能的语义理解;

如何参与?

  1. 🧑‍💻码农🧑‍💻开源不易,各位好人路过请给个小星星💗Star💗。 → GitHub - Saga Reader
  2. 加入 Issues 讨论:提出功能建议或参与 Bug 修复;
  3. 提交 PR:我们会提供详细的开发文档与技术支持!

福利:活跃贡献者可获得项目代码署名!

一文读懂 webpack 配置选项 output.publicPath

引出问题

入口文件为:

// src/index.js
import path from './assets/webpack.png';
console.log(path);
const img = document.createElement('img');
img.src = path;
document.body.appendChild(img);

配置文件为:

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: 'development',
  output: {
    filename: 'scripts/[name].[chunkhash:3].js',
    clean: true
  },
  devServer: {
    open: 'html/index.html',
    static: './dist'
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'html/index.html'
    })
  ],
  module: {
    rules: [
      {
        test: /\.(png|jpe?g|gif)$/i,
        use: {
          loader: 'file-loader',
          options: {
            name: 'img/[name].[hash:3].[ext]'
          }
        }
      }
    ]
  }
}

打包后 dist 目录的结构为:

dist
  |—— img
    |—— webpack.xxx.png
  |—— scripts
    |—— main.yyy.js
  |—— html
    |—— index.html

打包生成的 dist/html/index.html 文件:

该文件由 html-webpack-plugin 生成,作为一个插件,它知道最终输出的文件在 dist 目录中的位置,因此能够正确引入

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Webpack App</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script defer src="../scripts/main.yyy.js"></script>
  </head>
  <body>
  </body>
</html>

运行 dist/html/index.html 文件(请求 http://localhost:8080/html/index.html 页面),浏览器控制台输出:img/webpack.xxx.png,该相对路径最终转换成的绝对路径为 http://localhost:8080/html/img/webpack.xxx.png,图片找不到

问题出现的原因

src/assets/webpack.png 图片会交给 file-loader 处理,作为一个 loader,它在运行时,webpack 还未输出文件到 dist 目录,因此,它只知道最终生成到 dist 目录中的图片路径为 img/webpack.xxx.png(配置该 loader 时指定的),并不知道该图片将来会被哪个文件使用

解决方法

// webpack.config.js
module.exports = {
    // ...
    output: {
        // ...
        publicPath: '/'
    }
}

output.publicPath 本质就是一个字符串,并不会影响 webpack 的打包构建过程,只不过该字段的值会被某些 plugin 和 loader 读取使用,例如 html-webpack-plugin、file-loader 等等。

配置 output.publicPath 后:

  • html-webpack-plugin 生成的 html 页面中,会将该值作为 script 标签和 link 标签引入的资源路径的前缀
  • file-loader 在处理文件时,会将该值作为输出路径的前缀
  • ...etc

所以配置了 output.publicPath="/" 后,file-loader 处理 webpack.png 图片时,输出的路径为:/img/webpack.xxx.png,转换为绝对路径后为:http://localhost:8080/img/webpack.xxx.png,路径正确,能够正常找到图片

扩展

若某些 plugin 和 loader 需要使用不同的资源路径前缀,那么可以为这些 plugin 和 loader 分别配置 publicPath,以 file-loaderhtml-webpack-plugin 为例:

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      // ...
      publicPath: "abc"
    })
  ],
  module: {
    rules: [
      {
        test: /\.(png|jpe?g|gif)$/i,
        use: {
          loader: 'file-loader',
          options: {
            // ...
            publicPath: "bcd"
          }
        }
      }
    ]
  }
}

tip:并不是所有的 plugin 和 loader 都可以配置自己的 publicPath,具体可以参考其 npm 文档

让文字飞起来!EasyVoice文本转语音神器:cpolar内网穿透实验室第608成功挑战

NO.608  EasyVoice-1.jpg

软件名称:EasyVoice

操作系统支持:飞牛云fnOS

软件介绍: 在这个信息爆炸的时代,内容消费者们常常面临一个棘手的问题:如何高效地获取和消化大量文字信息。无论是想快速获取知识、提升学习效率,还是希望将创意转化为生动的声音,EasyVoice都能成为你的得力助手。

NO.608  EasyVoice-2.jpg

EasyVoice的出色功能

  • 高质量语音输出:让文字活起来,享受自然流畅的聆听体验。
  • 本地部署:通过Docker和Node.js一键安装,数据安全无忧,适合企业或开发者使用。
  • 流式传输:无论文本有多长,都能立即播放,打破了传统转换工具的限制。

实用场景大揭秘

  1. 听小说不停歇:将长篇小说一键转为有声书,即使在忙碌的工作日也能享受阅读的乐趣。
  2. 配音创作自由:轻松为视频或演示文稿添加专业级语音,提升内容质量。
  3. 学习效率翻倍:将笔记和教材转换为语音材料,在通勤路上也能高效学习。

NO.608  EasyVoice-3.jpg

cpolar内网穿透技术带来的便利 通过cpolar的内网穿透技术,用户可以轻松突破局域网限制,使EasyVoice在本地部署后依然能够方便地与外部服务互动。这对于需要在内部网络环境中使用该工具的开发者和企业来说,是一个巨大的优势。

总结 EasyVoice不仅仅是一款文本转语音软件,更是内容消费和创作方式的一次革命。无论你是渴望提升学习效率、还是希望赋予创意生动的声音,EasyVoice都能轻松满足你的需求。

NO.608  EasyVoice-4.jpg

如何在飞牛云fnOS中安装EasyVoice并实现内网穿透,请参考下面教程:

1. 环境准备

本例中在Windows系统使用VMware Workstation安装的fnOS虚拟机,系统版本为V0.8.41。如果不知道如何在虚拟机中安装,可以参考这篇文章:VMware中安装飞牛云(fnOS) NAS系统 如果您想要在x86架构的物理机中安装,可以访问飞牛私有云 fnOS官网下载镜像文件然后使用U盘写入镜像后,进入bios设置U盘启动后像装Windows系统一样安装即可。

EasyVoice项目地址:github.com/cosin2077/e…

启动fnOS系统后,能看到Web UI管理界面的地址:http://192.168.184.130:5666 在浏览器中打开:

image-20250509105552969

2. Docker部署与运行

首先,点击Docker-Compose-新增项目:

image-20250513103247093

在弹出的创建项目窗口中,填写项目名称:easyvoice(可自定义):

image-20250513104035416

点击路径后,在docke文件夹内新建一个名为EasyVoice的项目路径,点击确定:

image-20250513103409909

然后点击创建 docke-compose.yml ,将下面的代码粘贴到输入框:

services:
  easyvoice:
    image: cosincox/easyvoice:latest
    restart: unless-stopped
    container_name: easyvoice
    ports:
      - "9549:3000"
    environment:
      - DEBUG=true
      - OPENAI_BASE_URL=https://openrouter.ai/api/v1/
    volumes:
      - ./audio:/app/audio

image-20250513103939267

勾选创建项目后立即启动,点击确定,自动构建容器:

image-20250513104145965

等待构建完成后,在容器中,能看到easyvoice已经正常启动了:

image-20250513104430536

在浏览器中访问fnOS飞牛nas主机地址加端口号9549: http://192.168.184.130:9549 就能看到EasyVoice的Web UI管理界面了:

image-20250513104605241

3. 简单使用测试

点击立即体验:

image-20250513110831119

在跳转的文本转语音页面,我们可以在左侧手动输入文本或上传txt格式的文本文件来添加需要转换的内容:

image-20250513111012628

而在右侧是对语音进行设置的选项,包括语言、性别、配音角色、语速、音量、音调等多种设置:

image-20250513112139608

输入文字后,点击生成语音:

image-20250513111432386

速度非常快,资源占用也很少,不需要什么性能就可以轻松生成语音:

image-20250513111505721

生成的音频可以直接播放,也可以下载到本地:

image-20250513111605877

再测试一下拖拽文件或点击上传一个txt格式小说试试:

image-20250513114218758

随着需要转换成语音的文字字数增多,生成的时间也会增加:

image-20250513114237849

等待转换结束后,可以看到,一个多小时的文本量也能正常转换成音频:

image-20250513114803797

除了预设语音功能,EasyVoice目前还增加了实验性功能的AI推荐,可以通过AI将需要转换为语音的文字智能推荐不同的角色语音。如果想体验这个功能,我们可以在上边通过docker-compose创建容器时,在代码中的环境变量里添加需要调用的本地大模型地址(本例中的地址为ollama部署的主机IP+端口号)与要使用的模型名称即可:

b44ccf9ead8f60d0bbc18659d17da606

实际测试后确实能分角色朗读,但并不会新增角色语音,也是调用预设语音中的角色进行转换。而且根据不同的模型能力,实际得到的结果也不相同,支持函数调用的模型似乎效果更好一些,还是可以期待后续的优化的。

image-20250513163006659

image-20250513163310851

4. 安装内网穿透

我们现在已经实现了在本地fnOS飞牛云NAS中部署了EasyVoice进行文本转语音,并能在在同一局域网内向其他人分享这个工具的链接在浏览器中进行体验了。但如果你想自己或是异地好友和同事也能远程使用你在本地飞牛云NAS中部署的EasyVoice服务该怎么办呢?很简单,只要安装一个cpolar内网穿透工具就能轻松实现远程访问内网主机中部署的服务了,节约成本,提高效率,接下来介绍一下如何安装cpolar内网穿透。

cpolar官网地址: www.cpolar.com

4.1 开启ssh连接安装cpolar

首先打开飞牛云NAS设置界面,开启ssh 连接,端口默认为22即可,开启后,我们就可以ssh 连接飞牛云NAS执行命令:

853d0e568b7879cca312f7b18d4fbb4.png

然后我们通过输入飞牛云NAS的IP地址ssh远程连接进去,因为fnOS是基于Linux 内核开发的,所以我们可以按照cpolar的Linux安装方法进行安装:

image-20250225152553263

连接后执行下面cpolar Linux 安装命令:

sudo curl https://get.cpolar.sh | sh

再次输入飞牛云nas的密码确认后即可自动安装

安装完成后,执行下方命令查看cpolar服务状态:(如图所示即为正常启动)

sudo systemctl status cpolar

image-20250225153049854

Cpolar安装和成功启动服务后,在浏览器上输入飞牛云主机IP加9200端口即:【http://localhost:9200】访问Cpolar管理界面,使用官网注册的账号登录,登录后即可看到配置界面,接下来在web界面配置即可:

image.png

4.2 创建公网地址

登录cpolar web UI管理界面后,点击左侧仪表盘的隧道管理——创建隧道:

  • 隧道名称:可自定义,本例使用了: easyvoice 注意不要与已有的隧道名称重复
  • 协议:http
  • 本地地址:9549
  • 域名类型:随机域名
  • 地区:选择China Top

image-20250513134512469

创建成功后,打开左侧在线隧道列表,可以看到刚刚通过创建隧道生成了两个公网地址,使用上面的任意一个公网地址在浏览器中访问就可以实现随时随地远程使用你在本地部署的EasyVoice来文本转语音了!

image-20250513134634179

使用cpolar生成的公网地址,无需自己准备云服务器,无公网IP也能轻松搞定跨网络环境远程访问本地服务!

image-20250513134726744

小结

为了方便演示,我们在上边的操作过程中使用cpolar生成的HTTP公网地址隧道,其公网地址是随机生成的。这种随机地址的优势在于建立速度快,可以立即使用。然而,它的缺点是网址是随机生成,这个地址在24小时内会发生随机变化,更适合于临时使用。

如果有长期使用本地飞牛云NAS中部署的EasyVoice文本转语音工具,或者异地访问与使用其他本地部署的服务的需求,但又不想每天重新配置公网地址,还想让公网地址好看又好记并体验更多功能与更快的带宽,那我推荐大家选择使用固定的二级子域名方式来配置公网地址。

5. 配置固定公网地址

接下来演示如何为EasyVoice文本转语音服务配置固定的HTTP公网地址,该地址不会变化,无需每天重复修改服务器地址。

配置固定http端口地址需要将cpolar升级到专业版套餐或以上。

登录cpolar官网,点击左侧的预留,选择保留二级子域名,设置一个二级子域名名称,点击保留,保留成功后复制保留的二级子域名名称:

image-20250513135011703

保留成功后复制保留成功的二级子域名的名称: myeasyv,大家可以设置自己喜欢的名称。

image-20250513135032230

返回Cpolar web UI管理界面,点击左侧仪表盘的隧道管理——隧道列表,找到所要配置的隧道:easyvoice,点击右侧的编辑:

image-20250513135152962

修改隧道信息,将保留成功的二级子域名配置到隧道中

  • 域名类型:选择二级子域名
  • Sub Domain:填写保留成功的二级子域名:myeasyv

点击更新(注意,点击一次更新即可,不需要重复提交)

image-20250513135246942

更新完成后,打开在线隧道列表,此时可以看到公网地址已经发生变化,地址名称也变成了固定的二级子域名名称的域名:

image-20250513135340358

使用上面的任意一个固定的二级子域名公网地址在浏览器中访问,可以看到成功打开EasyVoice文本转语音的Web UI管理界面,现在开始就不用每天都更换随机公网地址来远程访问本地nas中部署的服务了。

image-20250513135611778

同样可以使用AI推荐功能:

image-20250513164000852

总结

在现代数字时代,随着智能语音技术的快速发展,文本转语音(TTS)工具在各类应用场景中发挥着重要作用。本文分享了如何在fnOS飞牛NAS中本地部署EasyVoice文本转语音工具,并结合cpolar内网穿透工具配置固定不变的二级子域名公网地址,实现随时随地远程访问本地部署服务。

通过本教程的完整部署,您已经成功构建了一个可远程访问的本地语音合成服务。该方案不仅解决了传统内网服务的访问限制问题,还通过容器化部署实现了服务的快速扩展和维护。在实际应用中,建议根据具体需求调整性能参数,例如增加GPU加速支持以提升语音合成速度。

这样行云流水般提交代码的体验真是爽爆了!

在项目开发中,要问使用最多的终端命令是什么,那就是git相关的命令,对于最频繁操作的 git 命令,我在《Mac键指如飞攻略之终端alias配置》这篇文章里提到了使用 alias 来简化命令,使得对于频繁操作的提交命令如git commitgit push等整合成了一个命令,如:

gci msg // 暂存代码并提交到本地仓库

gcp msg // 暂存代码并提交到本地仓库同时推送到远程仓库

也就是说通过gcigcp就可以实现本地代码的快速推动到远程的仓库,这篇文章里所做的只是对于 git 命令拼写的简化,也就是减少了对于提交代码操作所需要的输入 git 命令字符的数量。

但存在的痛点仍然还有:

  • 经过alias后的git提交命令仍然需要键盘手输
  • 提交的commit信息还是要手输

之前一直都没有太好的解决方式,直到发现GitHub Copilot更新后在代码管理工具里提供了使用 ai 生成commit信息后,以上的两个痛点才得到了解决。

经过一番探索,最终实现了,无需键盘字符输入无鼠标点击的全键盘操作提交代码,彻底抛弃提交代码命令,极大的提升代码提交的效率。

这里先附上最终实现的效果图:

可以看到整个提交代码的流程我都是没有输入任何命令的,完全都是使用快捷键来实现的,整个提交代码的操作非常的行云流水。其整个操作大致经过了如下四个步骤:

  • 使用快捷键从资源管理器切换到源代码管理器
  • 使用GitHub Copilot生成commit信息的快捷键
  • 使用提交代码的快捷键
  • 使用推送到远程仓库的快捷键

下面我就说下具体的实现步骤:

使用快捷键从资源管理器切换到源代码管理器

vscode 中活动侧边栏都有系统内置的快捷键,分别是

  • 切换到资源管理器⌘ + ⇧ + e
  • 切换到全局搜索⌘ + ⇧ + f
  • 切换到源代码管理器⌘ + ⌃ + g
  • 切换到扩展⌘ + ⇧ + x

在一个步骤,我就是从资源管理器的目录,使用切换源代码管理器的快捷键切换到源代码管理 tab,此时焦点聚焦在了源代码提交的输入框内。

使用GitHub Copilot生成commit信息的快捷键

使用⌘ + ⇧ + x切换到扩展,进入 vscode 扩展商店,搜索Copilot,安装GitHub Copilot,不需要配置,登录自己的github账号就行

安装完成后我们再使用⌘ + ⌃ + g来切换回源代码管理器,就可以看到在输入框的右侧多了一个小✨的小图标,点击后就会调用github copilot提供的智能生成commit信息功能:

当然点击生成 commit 信息不是我们想要的操作,会打断我们整个的无鼠标提交代码流程,这个命令默认是没有配置快捷键的,图上显示的快捷键是我配置后展示的,下面我们就为这个命令匹配快捷键。

打开 vscode 键盘快捷键配置(不知道怎么进入的,可以看我往期文章),搜索:github copilot,第三个就是我们的目标匹配结果,为其设置快捷键⌘ + ⌃ + m,之所以如此设置,是因为m代表了message的含义,表示我们使用这个快捷键来生成提交信息,⌘+⌃的修饰符组合,也不会与其他快捷键冲突,这个快捷键配置可以放心食用。

使用提交代码的快捷键

生成提交信息后,下一步就是将代码提交到远程仓库,土办法当然是点击源代码管理器搜索框下面的提交按钮,但是鼠标操作自然是我们所摒弃的,这个提交操作不需要额外的快捷键配置,直接使用 ⌘+enter就会提交到本地仓储。

使用推送到远程仓库的快捷键

完成了本地仓储的提交,下面一步就是提交到远程仓库,这一步骤 vscode 没有默认的快捷键,但是内置的源代码管理器却提供了相关的命令,所以我们只需要给给相关的命令绑定快捷键就行,同样的打开vscode 键盘快捷键配置,搜索推送,看到Git:推送这个命令就是我们想要的结果,设置快捷键⌘ + ⌃ + p。这样设置的原因是,p代表了push的含义,而且⌘ + ⌃的修饰符组合也和第二步骤保持了一致,使得整个操作的快捷键记都非常统一,且易于记忆。

总结

经过上述的四个步骤,我们就可以实现一次行云流水般的无鼠标点击的提交代码操作,从提交信息到推送到远程仓库全程键盘操作,几秒内完成。

若能熟练掌握这套快捷键,各位coder的提交代码的速度将无人能及。

Vue集成开源的低代码表单设计器教程-兼容Element Plus/Ant Design/Vant,支持PC/移动端

FcDesigner 是一款基于Vue的开源低代码可视化表单设计器工具,通过数据驱动表单渲染。可以通过拖拽的方式快速创建表单,提高开发者对表单的开发效率,节省开发者的时间。并广泛应用于在政务系统、OA系统、ERP系统、电商系统、流程管理等领域。

源码地址: Github | Gitee | 文档 | 在线演示

本项目采用 Vue 和 ElementPlus/ElementUI/AntDesignVue 进行页面构建,内置多语言解决方案,支持二次扩展开发,支持自定义组件扩展。

项目分为设计器 form-create-designer 和 渲染器 form-create,用户可以通过可视化界面快速高效地创建表单,并输出为JSON。并且通过加载JSON,渲染器可以渲染并输出相应的表单。

  • @form-create/designer ElementPlus/ElementUI表单设计器 💻

  • @form-create/antd-designer AntDesignVue表单设计器(Vue3) 💻

  • @form-create/vant-designer Vant移动端表单设计器(Vue3) 📱

Element UI 版本表单设计器

https://view.form-create.com/img/example.jpg

本项目采用 Vue2.7 和 Element UI 进行页面构建,内置多语言解决方案,支持二次扩展开发,支持自定义组件扩展。演示站

安装

要开始使用 @form-create/designer,首先需要将其安装到您的项目中。可以通过 npm 安装:

npm install @form-create/designer@^1
npm install @form-create/element-ui@^2.7
npm install element-ui

如已安装旧版本渲染器,请执行以下命令更新至最新版:

npm update @form-create/element-ui@^2.7

请检查当前 Vue 版本,若版本低于 2.7,请执行以下升级命令:

npm update vue@^2.7

引入

Node.js 引入

对于 Node.js 项目,您需要通过 npm 安装相关依赖,并在您的项目中引入并配置它们。

import Vue from 'vue';
import FcDesigner from '@form-create/designer';
import ELEMENT from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
// 使用 Element UI
Vue.use(ELEMENT);
// 使用 form-create 和 designer
Vue.use(FcDesigner);
Vue.use(FcDesigner.formCreate);

CDN 引入

如果您希望通过 CDN 方式引入 FcDesigner,请确保先引入 Vue.js 和 Element UI。然后引入 @form-create/element-ui@form-create/designer,并在 Vue 实例中进行配置。

<!-- 引入 Vue.js -->
<script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script>
<!-- 引入 Element UI 样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<!-- 引入 Element UI -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<!-- 引入 form-create 和 designer -->
<script src="https://unpkg.com/@form-create/element-ui/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/designer/dist/index.min.js"></script>
<div id="app">
    <fc-designer height="100vh"></fc-designer>
</div>
<script>
    Vue.use(FcDesigner);
    Vue.use(FcDesigner.formCreate);
    new Vue().$mount('#app');
</script>

使用

在 Vue 组件中,您可以像下面这样使用 fc-designer 组件:

<template>
    <fc-designer ref="designer" height="100vh" />
</template>

Element Plus版本表单设计器

@form-create/designer 支持 Vue 3 环境,以下是如何在 Vue 3 项目中安装和使用该库的指南。

演示站

安装

首先,安装 @form-create/designer 的 Vue 3 版本:

npm install @form-create/designer@^3
npm install @form-create/element-ui@^3
npm install element-plus

如已安装旧版本渲染器,请执行以下命令更新至最新版:

npm update @form-create/element-ui@^3

引入

Node.js 引入

对于使用 Node.js 的项目,按照以下步骤在您的 Vue 3 项目中引入并配置:

import { createApp } from 'vue';
import FcDesigner from '@form-create/designer';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
// 创建 Vue 应用
const app = createApp(App);
// 使用 Element Plus 和 FcDesigner
app.use(ElementPlus);
app.use(FcDesigner);
app.use(FcDesigner.formCreate);
// 挂载应用
app.mount('#app');

CDN 引入

如果您选择使用 CDN,可以按照以下步骤在 HTML 文件中引入相关依赖:

<!-- 引入 Element Plus 样式 -->
<link href="https://unpkg.com/element-plus/dist/index.css" rel="stylesheet" />
<!-- 引入 Vue 3 -->
<script src="https://unpkg.com/vue"></script>
<!-- 引入 Element Plus -->
<script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
<!-- 引入 form-create 和 designer -->
<script src="https://unpkg.com/@form-create/element-ui@next/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/designer@next/dist/index.umd.js"></script>
<div id="app">
    <fc-designer height="100vh"></fc-designer>
</div>
<script>
    const { createApp } = Vue;
    const app = createApp({});
    app.use(ElementPlus);
    app.use(FcDesigner);
    app.use(FcDesigner.formCreate);
    app.mount('#app');
</script>

使用

在 Vue 3 组件中,您可以通过以下方式使用 fc-designer 组件:

<template>
    <fc-designer ref="designer" height="100vh" />
</template>
<script setup>
    import { ref } from 'vue';
    // 可以在此处获取设计器实例或进行其他操作
    const designer = ref(null);
</script>

AntDesignVue 版本PC端表单设计器

演示站

本项目采用 Vue3.0 和 Ant Design Vue 进行页面构建,内置多语言解决方案,支持二次扩展开发,支持自定义组件扩展。

https://view.form-create.com/img/example.gif

安装

首先,安装 @form-create/antd-designer

npm install @form-create/antd-designer@^3
npm install @form-create/ant-design-vue@^3
npm install ant-design-vue

如已安装旧版本渲染器,请执行以下命令更新至最新版:

npm update @form-create/ant-design-vue@^3

引入

Node.js 引入

对于使用 Node.js 的项目,按照以下步骤在您的 Vue 3 项目中引入并配置:

import FcDesigner from '@form-create/antd-designer'
import antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
// 创建 Vue 应用
const app = createApp(App);
app.use(antd)
app.use(FcDesigner)
app.use(FcDesigner.formCreate)
// 挂载应用
app.mount('#app');

CDN 引入

如果您选择使用 CDN,可以按照以下步骤在 HTML 文件中引入相关依赖:

<link rel="stylesheet" href="https://unpkg.com/ant-design-vue@4/dist/reset.css"></link>
<link rel="stylesheet" href="https://fastly.jsdelivr.net/npm/vant@4/lib/index.css"></link>
<!-- 引入 Vue 及所需组件 -->
<script src="https://unpkg.com/dayjs/dayjs.min.js"></script>
<script src="https://unpkg.com/dayjs/plugin/customParseFormat.js"></script>
<script src="https://unpkg.com/dayjs/plugin/weekday.js"></script>
<script src="https://unpkg.com/dayjs/plugin/localeData.js"></script>
<script src="https://unpkg.com/dayjs/plugin/weekOfYear.js"></script>
<script src="https://unpkg.com/dayjs/plugin/weekYear.js"></script>
<script src="https://unpkg.com/dayjs/plugin/advancedFormat.js"></script>
<script src="https://unpkg.com/dayjs/plugin/quarterOfYear.js"></script>
<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/ant-design-vue@4/dist/antd.min.js"></script>
<script src="https://fastly.jsdelivr.net/npm/vant@4/lib/vant.min.js"></script>


<!-- 引入 form-create 及 fcDesigner -->
<script src="https://unpkg.com/@form-create/ant-design-vue@^3/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/vant@^3/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/antd-designer@^3/dist/index.umd.js"></script>


<div id="app">
    <fc-designer height="100vh"></fc-designer>
</div>
<!-- 挂载组件 -->
<script>
    // 创建 Vue 应用实例
    const app = Vue.createApp({});
    // 挂载 AntDesignVue
    app.use(antd);
    // 挂载 fcDesignerPro 组件
    app.use(FcDesigner);
    // 挂载 formCreate
    app.use(FcDesigner.formCreate);
    // 挂载 Vue 应用
    app.mount('#app');
</script>

使用

在 Vue 3 组件中,您可以通过以下方式使用 fc-designer 组件:

<template>
    <fc-designer ref="designer" height="100vh" />
</template>
<script setup>
    import { ref } from 'vue';
    // 可以在此处获取设计器实例或进行其他操作
    const designer = ref(null);
</script>

移动端表单设计器

demo1

演示站

本项目采用 Vue3.0 和 ElementPlus 进行移动端页面构建,移动端使用的是vant4.0版本,内置多语言解决方案,支持二次扩展开发,支持自定义组件扩展。

安装

首先,安装 @form-create/vant-designer

npm install @form-create/vant-designer@^3
npm install @form-create/element-ui@^3
npm install @form-create/vant@^3
npm install element-plus
npm install vant

如已安装旧版本渲染器,请执行以下命令更新至最新版:

npm update @form-create/element-ui@^3
npm update @form-create/vant@^3

引入

Node.js 引入

对于使用 Node.js 的项目,按照以下步骤在您的 Vue 3 项目中引入并配置:

import FcDesignerMobile from '@form-create/vant-designer'
import ELEMENT from 'element-plus';
import vant from 'vant';
import 'vant/lib/index.css';
import 'element-plus/dist/index.css';
// 创建 Vue 应用
const app = createApp(App);
app.use(ELEMENT)
app.use(vant)
app.use(FcDesignerMobile)
app.use(FcDesignerMobile.formCreate)
// 挂载应用
app.mount('#app');

CDN 引入

如果您选择使用 CDN,可以按照以下步骤在 HTML 文件中引入相关依赖:

<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css"></link>
<link rel="stylesheet" href="https://unpkg.com/vant@4/lib/index.css"/>
<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
<script src="https://unpkg.com/vant@4/lib/vant.min.js"></script>
<script src="https://unpkg.com/@form-create/element-ui@next/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/vant@next/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/vant-designer@next/dist/index.umd.js"></script>
<div id="app">
    <fc-designer-mobile height="100vh"></fc-designer-mobile>
</div>
<script>
    const { createApp } = Vue;
    const app = createApp({});
    app.use(ElementPlus);
    app.use(vant);
    app.use(FcDesignerMobile);
    app.use(FcDesignerMobile.formCreate);
    app.mount('#app');
</script>

使用

在 Vue 3 组件中,您可以通过以下方式使用 fc-designer 组件:

<template>
    <fc-designer-mobile ref="designer" height="100vh" />
</template>
<script setup>
    import { ref } from 'vue';
    // 可以在此处获取设计器实例或进行其他操作
    const designer = ref(null);
</script>

Cesium 轨迹巡航效果

Cesium 轨迹巡航效果

话不多说,先上图!

Jietu20250519-101743-HD.gif

在这篇文章中,我将分享如何使用 Cesium 实现一个动态的无人机巡航轨迹效果。通过该功能,我们可以模拟无人机沿着指定路径飞行,并实时展示其位置、速度、高度等信息。以下是功能的详细介绍和实现过程。

实现思路

  1. 路径绘制

  2. 动态飞行

  3. 状态更新

    • 在飞行过程中,通过 Cesium 的 JulianDate 和 Cartesian3 获取无人机的实时位置。
    • 计算无人机的速度、高度、飞行距离等信息,并通过回调函数实时更新。

核心代码

以下是实现无人机巡航轨迹的核心代码片段:

1. 路径绘制
const positions = [];
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);

// 鼠标左键点击添加路径点
handler.setInputAction(function (movement) {
  const cartesian = viewer.scene.pickPosition(movement.position);
  if (cartesian) {
    positions.push(cartesian);
    if (positions.length >= 2) {
      drawPolyline(positions);
    }
  }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);

// 绘制路径
function drawPolyline(positions) {
  viewer.entities.add({
    polyline: {
      positions: new Cesium.CallbackProperty(() => positions, false),
      material: new Cesium.PolylineGlowMaterialProperty({
        glowPower: 0.1,
        color: Cesium.Color.YELLOW
      }),
      width: 10,
      clampToGround: true
    }
  });
}
2. 动态飞行
const sampledPosition = new Cesium.SampledPositionProperty();
const start = Cesium.JulianDate.fromDate(new Date());
const stop = Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate());

// 添加路径点和时间点
positions.forEach((position, index) => {
  const time = Cesium.JulianDate.addSeconds(start, index * 10, new Cesium.JulianDate());
  sampledPosition.addSample(time, position);
});

// 添加无人机模型
viewer.entities.add({
  position: sampledPosition,
  orientation: new Cesium.VelocityOrientationProperty(sampledPosition),
  model: {
    uri: "/GroundVehicle.glb",
    scale: 1.0
  }
});

// 设置时间范围
viewer.clock.startTime = start.clone();
viewer.clock.stopTime = stop.clone();
viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP;
viewer.clock.multiplier = 10;
3. 状态更新
function updateStatus(time) {
  const position = sampledPosition.getValue(time);
  const cartographic = Cesium.Cartographic.fromCartesian(position);
  const longitude = Cesium.Math.toDegrees(cartographic.longitude);
  const latitude = Cesium.Math.toDegrees(cartographic.latitude);
  const height = cartographic.height;

  console.log(`经度: ${longitude}, 纬度: ${latitude}, 高度: ${height}`);
}
viewer.clock.onTick.addEventListener(updateStatus);
完整代码
📄 dynamicObject.js
import core from "./Core.js";
import getPosition from "./getPosition.js";

/**
 * 创建浏览对象。
 * @constructor xp
 * @time 2022-12-25
 * @param {*} viewer
 * @param {*} cesium
 */
function dynamicObject(viewer, cesium) {
  this._viewer = viewer;
  this._cesium = cesium;
  this._core = new core();
  this._getPosition = new getPosition(this._viewer, this._cesium);
  this._entityFly = null;
}

/**
 * 这个方法用于创建浏览对象
 * @returns {Promise.<Object>} 返回一个Cesium的对象。
 *
 */
// var ploylinejl = {
//   polyline: {},
//   cameraRoll: null,
//   cameraPitch: null,
//   cameraPosition: null,
//   cameraHeading: null,
//   positions: [],
//   distance: [],
//   Totaltime: "",
//   dsq: null
// };
dynamicObject.prototype.executeFlycesium = function (method) {
  //设置浏览路径
  // var polylines = {};
  var _this = this;
  var PolyLinePrimitive = (function () {
    function execute(positions) {
      this.options = {
        polyline: {
          show: true,
          positions: [],
          material: new _this._cesium.PolylineGlowMaterialProperty({
            glowPower: 0.1,
            color: _this._cesium.Color.YELLOW
          }),
          width: 10,
          clampToGround: true
        }
      };
      this.positions = positions;
      this._init();
    }

    execute.prototype._init = function () {
      var _self = this;
      var _update = function () {
        return _self.positions;
      };
      //实时更新polyline.positions
      this.options.polyline.positions = new _this._cesium.CallbackProperty(
        _update,
        false
      );
      this.flycesium = _this._viewer.entities.add(this.options);
      _this.item = this.flycesium;
    };
    return execute;
  })();
  var handler = (this.handler = new _this._cesium.ScreenSpaceEventHandler(
    _this._viewer.scene.canvas
  ));
  var positions = [];
  // var flyceium = null;
  var distance = 0;
  var poly = undefined;
  var ploylinejl = {
    polyline: {},
    cameraRoll: null,
    cameraPitch: null,
    cameraPosition: null,
    cameraHeading: null,
    positions: [],
    distance: [],
    Totaltime: ""
  };
  //导入tool提示框
  var tooltip = this._core.CreateTooltip();
  //设置鼠标样式
  this._core.mouse(this._viewer.container, 1, window.SmartEarthRootUrl);
  handler.setInputAction(function (movement) {
    var cartesian = _this._getPosition.getMousePosition(movement);
    if (_this._core.getBrowser().pc === "pc" && positions.length == 0) {
      positions.push(cartesian.clone());
    }
    positions.push(cartesian);
    if (positions.length >= 2) {
      if (!_this._cesium.defined(poly)) {
        poly = new PolyLinePrimitive(positions);
      }
      distance = _this._core.getSpaceDistancem(positions, _this._cesium);
    }
  }, this._cesium.ScreenSpaceEventType.LEFT_CLICK);
  //鼠标移动
  handler.setInputAction(function (movement) {
    tooltip.showAt(movement.endPosition, "左键开始,右键结束!");
    var cartesian = _this._getPosition.getMousePosition(movement);
    if (positions.length >= 2) {
      if (!_this._cesium.defined(poly)) {
        poly = new PolyLinePrimitive(positions);
      } else {
        if (cartesian) {
          positions.pop();
          //cartesian.y += (1 + Math.random());
          positions.push(cartesian);
        }
      }
      distance = _this._core.getSpaceDistancem(positions, _this._cesium);
    }
  }, this._cesium.ScreenSpaceEventType.MOUSE_MOVE);
  //单击鼠标右键结束画线
  handler.setInputAction(function () {
    _this.end();
  }, this._cesium.ScreenSpaceEventType.RIGHT_CLICK);

  this.end = function (type) {
    handler.destroy();
    tooltip.show(false);
    //设置鼠标样式
    _this._core.mouse(_this._viewer.container, 0);

    _this.end = undefined;
    _this._viewer.entities.remove(_this.item);

    if (type === "cancel" || positions.length < 2) {
      return;
    }
    distance = _this._core.getSpaceDistancem(positions, _this._cesium);

    ploylinejl.polyline = poly;
    ploylinejl.positions = positions;
    ploylinejl.distance = parseFloat(distance);
    //转化成浏览对象
    _this.setFlycesium(ploylinejl, function (flyceium) {
      _this.flyceium = flyceium;
      _this.ploylinejl = ploylinejl;
      if (typeof method == "function") {
        method(flyceium);
      }
    });
  };
  return this;
};
/**
 * 设置获取浏览对象
 */
dynamicObject.prototype.setFlycesium = function (drawHelper, callback) {
  var _this = this;
  var coordinates = [];
  // var position = null;
  // var heading = null;
  // var pitch = null;
  // var roll = null;
  var maxHeight = 0;
  for (var i = 0; i < drawHelper.positions.length; i++) {
    var cartographic = _this._cesium.Cartographic.fromCartesian(
      drawHelper.positions[i]
    ); //世界坐标转地理坐标(弧度)
    var point = [
      (cartographic.longitude / Math.PI) * 180,
      (cartographic.latitude / Math.PI) * 180,
      cartographic.height
    ]; //地理坐标(弧度)转经纬度坐标
    //console.log(point);
    coordinates.push(point);
  }
  this._core.getPmfxPro(
    drawHelper.positions,
    25,
    0,
    _this._cesium,
    _this._viewer,
    data => {
      maxHeight = data.max;
      var time;
      time = (drawHelper.distance / 50).toFixed(1);
      var pathsData = {
        id: _this._core.getuid(),
        name: "新建路线",
        distance: drawHelper.distance,
        showPoint: false,
        showLine: true,
        showModel: true,
        isLoop: false,
        Totaltime: Math.round(time),
        speed: 50,
        height: (maxHeight + 200).toFixed(2),
        pitch: -20,
        range: 100,
        mode: 0,
        url: "/GroundVehicle.glb",
        geojson: {
          // orientation: {heading: heading, pitch: pitch, roll: roll},
          // position: position,
          geometry: { type: "LineString", coordinates: coordinates }
        }
      };
      callback && callback(pathsData);
    }
  );
};
/**
 * 开始浏览
 */
dynamicObject.prototype.Start = function (data, url, funs) {
  var _this = this;
  // var pathsData = data.geojson;
  if (!data.Totaltime) {
    data.Totaltime = 3000;
  }
  if (_this._entityFly) {
    _this.exit();
  }
  // _this._viewer.camera.setView({
  //     destination: pathsData.position,
  //     orientation: pathsData.orientation,
  // });
  fun = funs;
  setTimeout(function () {
    _this.executeFly3D(data, url);
  }, 200);
  return this;
};
var entityFly = null;
var entityModel = null;
//var start;
var fun = null;
var entityhd = {
  start: null,
  time: null,
  longitude: 0,
  latitude: 0,
  cameraHeight: 100,
  //timedifference: null,
  speed: 50,
  multiplier: 1,

  position: 0
};
// var stop;
var velocityVector, velocityVectorProperty, velocityOrientationProperty;
var AngleProperty, property;
var wheelAngle = 0;

/**
 * 播放路径动画
 * @param {Object} data 数据
 * @param {Object} data.geojson 路线数据
 * @param {Number} [data.lineHeight] 路线高度,默认贴地
 * @param {Boolean} [data.isLoop=false] 是否循环播放
 * @param {String} [url] 模型路径
 */
dynamicObject.prototype.executeFly3D = function (data, url) {
  var _this = this;
  var pathsData = data.geojson;
  velocityVector = new _this._cesium.Cartesian3();
  AngleProperty = new _this._cesium.SampledProperty(Number);
  property = new _this._cesium.SampledPositionProperty();

  if (pathsData && pathsData.geometry) {
    var positionA = pathsData.geometry.coordinates;
    var position = [];
    var position1 = [];
    if (positionA.length > 0) {
      for (var i = 0; i < positionA.length; i++) {
        var x = positionA[i][0];
        var y = positionA[i][1];
        var z = positionA[i][2];
        data.lineHeight !== void 0 && (z = data.lineHeight);
        position1.push(x, y, z);
        position.push({ x: x, y: y, z: z });
      }
    } else {
      return;
    }

    _this._viewer.clock.clockRange = data.isLoop
      ? _this._cesium.ClockRange.LOOP_STOP
      : _this._cesium.ClockRange.CLAMPED; //Loop at the end
    _this._viewer.clock.multiplier = data.multiplier || 1;
    _this._viewer.clock.canAnimate = false;
    _this._viewer.clock.shouldAnimate = true; //设置时间轴动态效果
    entityhd.distance = data.distance;
    entityhd.cameraHeight = data.height;
    entityhd.lineHeight = data.lineHeight;
    entityhd.pitch = data.pitch;
    entityhd.range = data.range;
    entityhd.speed = data.speed || 50;
    entityhd.Totaltime = data.distance / entityhd.speed;

    entityhd.start = _this._cesium.JulianDate.fromDate(new Date());
    entityhd.stop = _this._cesium.JulianDate.addSeconds(
      entityhd.start,
      entityhd.Totaltime,
      new _this._cesium.JulianDate()
    );
    //Make sure viewer is at the desired time.
    _this._viewer.clock.startTime = entityhd.start.clone();
    _this._viewer.clock.stopTime = entityhd.stop.clone();
    _this._viewer.clock.currentTime = entityhd.start.clone();

    var _position = _this.computeCirclularFlight(position);
    entityhd.position = _position;
    entityhd.degrees = position;
    velocityOrientationProperty = new _this._cesium.VelocityOrientationProperty(
      _position
    );

    var mode = {};
    if (url !== "") {
      mode = {
        show: _this._cesium.defaultValue(data.showModel, true),
        scale: _this._cesium.defaultValue(data.modelScale, 1),
        uri: url
      };
    } else {
      // const modelUrl = Cesium.buildModuleUrl(
      //   "Assets/GltfModels/CesiumAir/Cesium_Air.glb"
      // );
      mode = {
        show: _this._cesium.defaultValue(data.showModel, true),
        scale: _this._cesium.defaultValue(data.modelScale, 1)
        // uri: modelUrl
      };
    }
    if (data.modelData) {
      mode = _this._core.extend(mode, data.modelData);
    }
    changeFlyView = function () {};
    entityFly = _this._viewer.entities.add({
      //Set the entity availability to the same interval as the simulation time.
      availability: new _this._cesium.TimeIntervalCollection([
        new _this._cesium.TimeInterval({
          start: entityhd.start,
          stop: entityhd.stop
        })
      ]),
      position: _position,
      //Show the path as a pink line sampled in 1 second increments.
      polyline: {
        clampToGround: entityhd.lineHeight === void 0,
        positions: Cesium.Cartesian3.fromDegreesArrayHeights(position1),
        show: _this._cesium.defaultValue(data.showLine, true),
        material: new _this._cesium.PolylineGlowMaterialProperty({
          glowPower: 0.1,
          color: _this._cesium.Color.YELLOW
        }),
        width: 10
      },
      label: {
        text: new _this._cesium.CallbackProperty(updateSpeedLabel, false),
        font: "20px sans-serif",
        showBackground: false,
        distanceDisplayCondition: new _this._cesium.DistanceDisplayCondition(
          0.0,
          100.0
        ),
        eyeOffset: new _this._cesium.Cartesian3(0, 3.5, 0)
      }
    });
    // console.log(_position);

    entityModel = _this._viewer.entities.add({
      availability: new _this._cesium.TimeIntervalCollection([
        new _this._cesium.TimeInterval({
          start: entityhd.start,
          stop: entityhd.stop
        })
      ]),
      position: _position,
      orientation: velocityOrientationProperty,
      point: {
        show: _this._cesium.defaultValue(data.showPoint, false),
        color: _this._cesium.Color.RED,
        outlineColor: _this._cesium.Color.WHITE,
        outlineWidth: 2,
        pixelSize: 10
      },
      model: mode,
      billboard: data.image,
      viewFrom:
        data.viewFrom || new _this._cesium.Cartesian3(500.0, 500.0, 500.0)
    });
    entitymodels = entityFly;
    _this._viewer.trackedEntity = entityModel;
    _this._entityFly = entityFly;

    data.mode && _this.changeFlyMode(data.mode);

    // setTimeout(function () {
    //     // _this._viewer.camera.zoomOut(500.0);//缩小地图,避免底图没有数据
    //     _this._viewer.camera.zoomOut(300.0);//缩小地图,避免底图没有数据
    // }, 100);
  } else {
    return;
  }

  function updateSpeedLabel(time) {
    //alert(time);
    //entityhd.time = time;
    // var de = entitymodels;
    // var camera = _this._viewer.camera;
    if (
      _this._viewer.clock.clockRange !== 2 &&
      Cesium.JulianDate.equals(
        _this._viewer.clock.currentTime,
        _this._viewer.clock.stopTime
      )
    ) {
      entityFly.label.text = "";
      _this.exit();
      if (fun != null && typeof fun === "function") {
        fun("end");
      }
      changeFlyView = function () {};
      return;
    }
    try {
      var position = entitymodels.position.getValue(
        _this._viewer.clock.currentTime
      );
      var cartographic = _this._cesium.Cartographic.fromCartesian(position);
      //经度
      entityhd.longitude = _this._cesium.Math.toDegrees(cartographic.longitude);
      //纬度
      entityhd.latitude = _this._cesium.Math.toDegrees(cartographic.latitude);
      if (entityhd.lineHeight === void 0) {
        let height1 = _this._viewer.scene.sampleHeight(cartographic, [
          entityModel,
          entitymodels
        ]);
        let height2 = _this._viewer.scene.globe.getHeight(cartographic);
        entityModel.position = _this._cesium.Cartesian3.fromRadians(
          cartographic.longitude,
          cartographic.latitude,
          height2 > height1 ? height2 : height1
        );
      }
    } catch (er) {
      console.log(er);
    }
    try {
      velocityVectorProperty.getValue(time, velocityVector);
      changeFlyView(time);
      var metersPerSecond = _this._cesium.Cartesian3.magnitude(velocityVector);
      var kmPerHour = Math.round(metersPerSecond * 3.6);
      kmPerHour += " km/h";
      //已漫游时间
      entityhd.time = _this._cesium.JulianDate.secondsDifference(
        time,
        entityhd.start
      );
      //已漫游比例
      entityhd.ratio = entityhd.time / entityhd.Totaltime;
      //已漫游距离
      entityhd.distanceTraveled = entityhd.ratio * entityhd.distance;
      //运行速度
      entityhd.speed = kmPerHour;
      //漫游高程
      entityhd.height = cartographic.height;
      //地面高程
      entityhd.globeHeight = _this._viewer.scene.globe.getHeight(cartographic);

      if (fun != null && typeof fun === "function") {
        fun(entityhd);
      }
    } catch (er) {
      console.log(er);
    }
    return "";
  }
};

// var hpr = new Cesium.HeadingPitchRoll();
var flyPosition;
// var Quaternion = new Cesium.Quaternion();
// var _heading = 0;
var entitymodels = null;

//改变视角
var changeFlyView;

function getHeading(time) {
  wheelAngle = AngleProperty.getValue(time);
  entityhd.heading = wheelAngle;
}

function getFlyPosition(position) {
  var cartographic = Cesium.Cartographic.fromCartesian(position);
  var lon = Cesium.Math.toDegrees(cartographic.longitude);
  var lat = Cesium.Math.toDegrees(cartographic.latitude);
  return Cesium.Cartesian3.fromDegrees(lon, lat, entityhd.cameraHeight || 100);
}

/**
 * 显示点
 */
dynamicObject.prototype.showPoint = function (isShow) {
  entityModel && entityModel.point && (entityModel.point.show = isShow);
};

/**
 * 显示线
 */
dynamicObject.prototype.showLine = function (isShow) {
  entityFly && entityFly.polyline && (entityFly.polyline.show = isShow);
};

/**
 * 显示模型
 */
dynamicObject.prototype.showModel = function (isShow) {
  entityModel && entityModel.model && (entityModel.model.show = isShow);
};

//飞行高度
dynamicObject.prototype.setFlyHeight = function (height) {
  entityhd.cameraHeight = height;
};

//飞行距离
dynamicObject.prototype.setFlyDistance = function (distance) {
  entityhd.range = distance;
};

//飞行俯仰角
dynamicObject.prototype.setFlyPitch = function (pitch) {
  entityhd.pitch = pitch;
};

//飞行模式
dynamicObject.prototype.changeFlyMode = function (index) {
  var _this = this;
  switch (index) {
    case 0:
      changeFlyView = function () {};
      _this.BindingModel(true);
      break;
    case 1:
      this.BindingModel(false);
      changeFlyView = function (time) {
        getHeading(time);
        _this.exeuteVisualAngle(
          _this._cesium.Math.toRadians(entityhd.heading),
          _this._cesium.Math.toRadians(entityhd.pitch),
          entityhd.range
        );
      };
      break;
    case 2:
      this.BindingModel(false);
      changeFlyView = function (time) {
        getHeading(time);
        flyPosition = _this._entityFly.position.getValue(
          _this._viewer.clock.currentTime
        );
        if (!flyPosition) return;
        flyPosition = getFlyPosition(flyPosition);
        _this._viewer.camera.setView({
          destination: flyPosition,
          orientation: {
            heading: _this._cesium.Math.toRadians(entityhd.heading),
            pitch: _this._cesium.Math.toRadians(-90),
            roll: 0.0
          }
        });
      };
      break;
  }
};

/**
 * 加速
 */
dynamicObject.prototype.faster = function () {
  this._viewer.animation.viewModel.faster();
};

/**
 * 减速
 */
dynamicObject.prototype.slower = function () {
  // 倍率减
  this._viewer.animation.viewModel.slower();
};

/**
 * 设置倍数
 */
dynamicObject.prototype.setMultiplier = function (multiplier) {
  this._viewer.clock.multiplier = parseFloat(multiplier);
};

/**
 * 是否暂停
 */
dynamicObject.prototype.isPause = function (isPause) {
  var clockViewModel = this._viewer.clockViewModel;
  clockViewModel.shouldAnimate = !isPause;
};

/**
 * 结束飞行
 */
dynamicObject.prototype.exit = function () {
  this.isPause(true);
  this._viewer.clock.multiplier = 1;
  this.executeSignout();
  this.BindingModel(false);
  this._viewer.entities.remove(entityFly);
  this._viewer.entities.remove(entityModel);
  entityFly = null;
  entityModel = null;
  this._entityFly = null;
};

//lable回调函数
dynamicObject.prototype.updateSpeedLabel = function () {
  //if (fun && typeof fun === 'function') {
  //    fun(_this._entityFly);
  //}
  //this.entityhd = {
  //    start: null,
  //    time: null
  //};
  //this.entityhd.time = time;
  //if (this.fun != null && typeof fun === 'function') {
  //    fun(_this._entityFly);
  //}
  //return "";
};

//添加时间位置样本
dynamicObject.prototype.computeCirclularFlight = function (position) {
  var _this = this;
  velocityVectorProperty = new _this._cesium.VelocityVectorProperty(
    property,
    false
  );
  var _time, time, _position, _position1;
  for (var i = 0; i < position.length; i++) {
    if (i === 0) {
      //起点
      time = _this._cesium.JulianDate.addSeconds(
        entityhd.start,
        0,
        new _this._cesium.JulianDate()
      );
      _position = _this._cesium.Cartesian3.fromDegrees(
        position[0].x,
        position[0].y,
        entityhd.lineHeight
      );

      property.addSample(time, _position);
      //计算两点方位角
      wheelAngle = _this._core.TwoPointAzimuth(
        position[0].x,
        position[0].y,
        position[1].x,
        position[1].y
      );
      AngleProperty.addSample(time, wheelAngle);
    }
    try {
      if (i > 0 && i != position.length - 1) {
        _position = new _this._cesium.Cartesian3(
          property._property._values[i * 3 - 3],
          property._property._values[i * 3 - 2],
          property._property._values[i * 3 - 1]
        );
        _position1 = _this._cesium.Cartesian3.fromDegrees(
          position[i].x,
          position[i].y,
          _this._cesium.defaultValue(entityhd.lineHeight, position[i].z)
        );

        var positions = [
          _this._cesium.Cartographic.fromCartesian(_position),
          _this._cesium.Cartographic.fromCartesian(_position1)
        ];
        var a = new _this._cesium.EllipsoidGeodesic(positions[0], positions[1]);
        var long = a.surfaceDistance;
        time = _this._cesium.JulianDate.addSeconds(
          property._property._times[i - 1],
          0.5,
          new _this._cesium.JulianDate()
        );
        _time = _this._cesium.JulianDate.addSeconds(
          property._property._times[i - 1],
          long / entityhd.speed,
          new _this._cesium.JulianDate()
        );

        property.addSample(_time, _position1);
        //计算两点方位角
        wheelAngle = _this._core.TwoPointAzimuth(
          position[i - 1].x,
          position[i - 1].y,
          position[i].x,
          position[i].y
        );
        AngleProperty.addSample(time, wheelAngle);
        AngleProperty.addSample(_time, wheelAngle);
      }
    } catch (e) {
      console.log(e);
    }
  }
  return property;
};
/**
 * 暂停浏览
 */
dynamicObject.prototype.executePauseFly3DPaths = function () {
  var clockViewModel = this._viewer.clockViewModel;
  if (clockViewModel.shouldAnimate) {
    clockViewModel.shouldAnimate = false;
  } else if (this._viewer.clockViewModel.canAnimate) {
    clockViewModel.shouldAnimate = true;
  }
};

/**
 * 添加对象
 */
dynamicObject.prototype.changeModel = function (url) {
  entityModel.model.uri = url;
};
//浏览方式飞向视点
/**
 * 获取视野点
 */
dynamicObject.prototype.PointView = function () {
  var originalCameraLocation = {
    position: Viewer.camera.position.clone(),
    orientation: {
      heading: Viewer.camera.heading,
      pitch: Viewer.camera.pitch,
      roll: Viewer.camera.roll
    }
  };
  return originalCameraLocation;
};
/**
 * 开始浏览。
 * @param {Paths}
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.PlayPaths = function () {
  var that = this;
  setInterval(function () {
    viewer.camera.setView({
      // Cesium的坐标是以地心为原点,一向指向南美洲,一向指向亚洲,一向指向北极州
      // fromDegrees()方法,将经纬度和高程转换为世界坐标
      destination: that._cesium.Cartesian3.fromDegrees(117.48, 30.67, 15000.0),
      orientation: {
        // 指向
        heading: that._cesium.Math.toRadians(90, 0),
        // 视角
        pitch: that._cesium.Math.toRadians(-90),
        roll: 0.0
      }
    });
  }, 2000);
};
/**
 * 绑定模型
 * @param {binding} 是否绑定。
 */
dynamicObject.prototype.BindingModel = function (binding) {
  if (binding) {
    this._viewer.trackedEntity = entityModel;
  } else {
    this._viewer.trackedEntity = undefined;
    this._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
  }
};
/**
 * 改变视角
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.exeuteVisualAngle = function (
  viewHeading,
  viewPitch,
  viewRange
) {
  var hpRange = { heading: null, pitch: null, range: null };
  hpRange.heading = viewHeading || this._cesium.Math.toRadians(90);
  hpRange.pitch = viewPitch || this._cesium.Math.toRadians(0);
  hpRange.range = viewRange || 1000;
  var center = this._entityFly.position.getValue(
    this._viewer.clock.currentTime
  );
  if (!center) return;
  center = getFlyPosition(center);
  var hpRanges = new this._cesium.HeadingPitchRange(
    hpRange.heading,
    hpRange.pitch,
    hpRange.range
  );
  //if (center) this._viewer.camera.lookAt(center, hpRange);
  this._viewer.camera.lookAt(center, hpRanges);
};
/**
 * 是否显示路线
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.Pathshow = function (route) {
  this._entityFly.polyline.show = route;
};
/**
 * 是否显示点
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.Pointshow = function (route) {
  entityModel._point.show = route;
};
/**
 * 是否显示模型
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.Modelshow = function (route) {
  entityModel._model.show = route;
};
/**
 * 向前飞行漫游路径
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.executePlayForwardFly3DPaths = function () {
  var clockViewModel = this._viewer.clockViewModel;
  var multiplier = clockViewModel.multiplier;
  if (multiplier < 0) {
    clockViewModel.multiplier = -multiplier;
  }
  clockViewModel.shouldAnimate = true;
};
/**
 * 向后飞行漫游路径
 */
dynamicObject.prototype.executePlayReverseFly3DPaths = function () {
  var clockViewModel = this._viewer.clockViewModel;
  var multiplier = clockViewModel.multiplier;
  if (multiplier > 0) {
    clockViewModel.multiplier = -multiplier;
  }
  clockViewModel.shouldAnimate = true;
};
/**
 * 退出飞行漫游路径
 */
dynamicObject.prototype.executeSignout = function () {
  var start = this._cesium.JulianDate.fromDate(new Date());
  this._viewer.clock.startTime = start.clone();
  var stop = this._cesium.JulianDate.addSeconds(
    start,
    86400,
    new this._cesium.JulianDate()
  );
  this._viewer.clock.stopTime = stop.clone();
  //this.cesiumViewer.entities.remove(this.entityFly);
};

/**
 * 结束当前操作
 */
dynamicObject.prototype.forceEndHanlder = function () {
  if (this.handler) {
    this.handler.destroy();
    this.handler = undefined;
  }
};
export default dynamicObject;

📄 Core.js
/**
 * 工具类
 * @constructor xp
 * @alias Core
 * @constructor
 *
 */
function Core () { }

//根据经纬度获取高度
Core.prototype.getHeightsFromLonLat = function (
  positions,
  Cesium,
  Viewer,
  callback
) {
  var camera = Viewer.camera;
  var heights = [];
  if (
    Viewer.scene &&
    Viewer.scene.terrainProvider &&
    Viewer.scene.terrainProvider._layers
  ) {
    //根据经纬度计算出地形高度。
    var promise = Cesium.sampleTerrainMostDetailed(
      Viewer.terrainProvider,
      positions
    );
    // var cameraHeight = camera.positionCartographic.height;
    Cesium.when(promise, function (updatedPositions) {
      updatedPositions.forEach(function (item) {
        heights.push(item.height);
      });
      if (typeof callback === "function") {
        callback(heights);
      }
    });
  } else {
    positions.forEach(function (p) {
      heights.push(Viewer.scene.globe.getHeight(p));
    });
    if (typeof callback === "function") {
      callback(heights);
    }
  }
};

/**
 * 创建鼠标Tooltip提示框。
 *
 * @param {*} [styleOrText] 提示框样式或文本内容
 * @param {String} [styleOrText.origin='center'] 对齐方式(center/top/bottom)
 * @param {String} [styleOrText.color='black'] 提示框颜色(black/white/yellow)
 * @param {String} [styleOrText.id=undefined] 提示框唯一id(可选)
 * @param {Object} position 显示位置
 * @param {Boolean} show 是否显示(如果为true,styleOrText必须为显示的文本内容)
 * @returns {Tooltip} Tooltip提示框。
 *
 * @example
 * sgworld.Core.CreateTooltip('这里是提示信息', {x:500, y:500}, true);
 * 或
 * tooltip = sgworld.Core.CreateTooltip();
 * tooltip.showAt({x:500, y:500}, '这里是提示信息');
 *
 * tooltip.show(false); //隐藏提示框
 * tooltip.show(true); //显示提示框
 */
Core.prototype.CreateTooltip = function (styleOrText = {}, position, show) {
  var style, _x, _y, _color, id;
  if (typeof styleOrText === "object") {
    style = styleOrText;
  }
  if (style && style.origin) {
    style.origin === "center" && ((_x = 15), (_y = -12));
    style.origin === "top" && ((_x = 15), (_y = -44));
    style.origin === "bottom" && ((_x = 15), (_y = 20));
  } else {
    (_x = 15), (_y = 20);
  }
  if (style && style.color) {
    style.color === "white" &&
      (_color = "background: rgba(255, 255, 255, 0.8);color: black;");
    style.color === "black" &&
      (_color = "background: rgba(0, 0, 0, 0.5);color: white;");
    style.color === "yellow" &&
      (_color =
        "color: black;background-color: #ffcc33;border: 1px solid white;");
  } else {
    _color = "background: rgba(0, 0, 0, 0.5);color: white;";
  }
  if (style && style.id) {
    id = "toolTip" + style.id;
  } else {
    id = "toolTip";
  }

  var tooltip = document.getElementById(id);

  if (!tooltip) {
    // 创建一个新的 div 元素
    var elementbottom = document.createElement("div");
    // 将元素添加到 .cesium-viewer 容器中
    var cesiumViewer = document.querySelector(".cesium-viewer");
    if (cesiumViewer) {
      cesiumViewer.appendChild(elementbottom);
    }

    // 构建 HTML 字符串
    var html =
      '<div id="' +
      id +
      '" style="display: none;pointer-events: none;position: absolute;z-index: 1000;opacity: 0.8;border-radius: 4px;padding: 4px 8px;white-space: nowrap;font-family:黑体;color:white;font-weight: bolder;font-size: 14px;' +
      _color +
      '"></div>';

    // 创建一个临时容器来解析 HTML 字符串
    var tempDiv = document.createElement("div");
    tempDiv.innerHTML = html;

    // 将解析后的第一个子元素(即 tooltip)添加到 .cesium-viewer 容器中
    if (cesiumViewer) {
      cesiumViewer.appendChild(tempDiv.firstElementChild);
    }

    // 获取刚刚创建的 tooltip 元素
    tooltip = document.getElementById(id);
  }
  if (show) {
    tooltip.innerHTML = styleOrText;
    tooltip.style.left = position.x + _x + "px";
    tooltip.style.top = position.y + _y + "px";
    tooltip.style.display = "block";
  } else {
    tooltip.style.display = "none";
  }
  return {
    tooltip: tooltip,
    style: style,
    showAt: function (position, text) {
      this.tooltip.innerHTML = text;
      if (this.style && this.style.origin) {
        this.style.origin === "center" &&
          ((_x = 15), (_y = -this.tooltip.offsetHeight / 2));
        this.style.origin === "top" &&
          ((_x = 15), (_y = -this.tooltip.offsetHeight - 20));
        this.style.origin === "bottom" && ((_x = 15), (_y = 20));
      } else {
        (_x = 15), (_y = -this.tooltip.offsetHeight / 2);
      }
      this.tooltip.style.left = position.x + _x + "px";
      this.tooltip.style.top = position.y + _y + "px";
      this.tooltip.style.display = "block";
    },
    show: function (show) {
      if (show) {
        this.tooltip.style.display = "block";
      } else {
        this.tooltip.style.display = "none";
      }
    }
  };
};

/**
 * 修改鼠标样式。
 *
 * @param {DOM} container html DOM节点
 * @param {Number} [cursorstyle=0] 鼠标类型(0为默认,1为使用cur图标)
 * @param {String} url cur图标路径。
 *
 * @example
 * sgworld.Core.mouse(Viewer.container, 1, 'draw.cur');
 */
Core.prototype.mouse = function (container, cursorstyle, url) {
  if (cursorstyle == 1) {
    container.style.cursor = "url(" + url + "),auto";
  } else {
    container.style.cursor = "default";
  }
};

// 判断是否为手机浏览器
Core.prototype.getBrowser = function () {
  var ua = navigator.userAgent.toLowerCase();
  var btypeInfo = (ua.match(/firefox|chrome|safari|opera/g) || "other")[0];
  if ((ua.match(/msie|trident/g) || [])[0]) {
    btypeInfo = "msie";
  }
  var pc = "";
  var prefix = "";
  var plat = "";
  //如果没有触摸事件 判定为PC
  var isTocuh =
    "ontouchstart" in window ||
    ua.indexOf("touch") !== -1 ||
    ua.indexOf("mobile") !== -1;
  if (isTocuh) {
    if (ua.indexOf("ipad") !== -1) {
      pc = "pad";
    } else if (ua.indexOf("mobile") !== -1) {
      pc = "mobile";
    } else if (ua.indexOf("android") !== -1) {
      pc = "androidPad";
    } else {
      pc = "pc";
    }
  } else {
    pc = "pc";
  }
  switch (btypeInfo) {
    case "chrome":
    case "safari":
    case "mobile":
      prefix = "webkit";
      break;
    case "msie":
      prefix = "ms";
      break;
    case "firefox":
      prefix = "Moz";
      break;
    case "opera":
      prefix = "O";
      break;
    default:
      prefix = "webkit";
      break;
  }
  plat =
    ua.indexOf("android") > 0 ? "android" : navigator.platform.toLowerCase();
  return {
    version: (ua.match(/[\s\S]+(?:rv|it|ra|ie)[\/: ]([\d.]+)/) || [])[1], //版本
    plat: plat, //系统
    type: btypeInfo, //浏览器
    pc: pc,
    prefix: prefix, //前缀
    isMobile: pc == "pc" ? false : true //是否是移动端
  };
};

//空间距离测量用米
Core.prototype.getSpaceDistancem = function (positions, Cesium) {
  var distance = 0;
  for (var i = 0; i < positions.length - 1; i++) {
    var point1cartographic = Cesium.Cartographic.fromCartesian(positions[i]);
    var point2cartographic = Cesium.Cartographic.fromCartesian(
      positions[i + 1]
    );
    /**根据经纬度计算出距离**/
    var geodesic = new Cesium.EllipsoidGeodesic();
    geodesic.setEndPoints(point1cartographic, point2cartographic);
    var s = geodesic.surfaceDistance;
    //console.log(Math.sqrt(Math.pow(distance, 2) + Math.pow(endheight, 2)));
    //返回两点之间的距离
    s = Math.sqrt(
      Math.pow(s, 2) +
      Math.pow(point2cartographic.height - point1cartographic.height, 2)
    );
    distance = distance + s;
  }
  return distance.toFixed(2);
};

//根据位置(Cartographic)获取3DTiles和Primitives高度
Core.prototype.get3DTileOrPrimitivesHeights = function (position, Viewer) {
  return Viewer.scene.sampleHeight(position);
};

//剖面分析
Core.prototype.getPmfxPro = function (
  _positions,
  pointSum1,
  cyjj,
  Cesium,
  viewer,
  methond
) {
  let _this = this;
  //起止点相关信息
  let pmx = {
    gcs: [],
    min: 99999,
    max: 0,
    juli: 0.0,
    cys: 0
  };
  let positions = [];
  let pointNum = [];
  //获取总间隔点数和距离
  for (let i = 0; i < _positions.length - 1; i++) {
    let julifr = _this.getSpaceDistancem(
      [_positions[i], _positions[i + 1]],
      Cesium
    );
    julifr = parseFloat(julifr);
    pmx.juli += julifr;
    if (cyjj == 0) {
    } else {
      pointSum1 = parseInt(julifr / cyjj);
    }
    pointNum.push(pointSum1);
    pmx.cys += pointSum1;
  }
  let startAnalyse = () => {
    pointNum.forEach((num, i) => {
      let startPoint = _positions[i];
      let endPoint = _positions[i + 1];
      //起点
      let scartographic = Cesium.Cartographic.fromCartesian(startPoint);
      let slongitude = Cesium.Math.toDegrees(scartographic.longitude);
      let slatitude = Cesium.Math.toDegrees(scartographic.latitude);

      //终点
      let ecartographic = Cesium.Cartographic.fromCartesian(endPoint);
      let elongitude = Cesium.Math.toDegrees(ecartographic.longitude);
      let elatitude = Cesium.Math.toDegrees(ecartographic.latitude);

      let pointSum = num; //取样点个数
      let addXTT =
        Cesium.Math.lerp(slongitude, elongitude, 1.0 / pointSum) - slongitude;
      let addYTT =
        Cesium.Math.lerp(slatitude, elatitude, 1.0 / pointSum) - slatitude;

      let Cartesian;

      i === 0 && positions.push(scartographic);
      for (let j = 0; j < pointSum; j++) {
        let longitude = slongitude + (j + 1) * addXTT;
        let latitude = slatitude + (j + 1) * addYTT;
        Cartesian = Cesium.Cartesian3.fromDegrees(longitude, latitude);
        positions.push(Cesium.Cartographic.fromCartesian(Cartesian));
      }
    });

    positions.push(
      Cesium.Cartographic.fromCartesian(_positions[_positions.length - 1])
    );

    let heightArr = [];
    pmx.allPoint = positions;
    this.getHeightsFromLonLat(positions, Cesium, viewer, function (data) {
      if (data) {
        heightArr = data;
        let changeDepthTest = viewer.scene.globe.depthTestAgainstTerrain;
        viewer.scene.globe.depthTestAgainstTerrain = true;
        for (let i = 0; i < heightArr.length; i++) {
          let modelHeight = _this.get3DTileOrPrimitivesHeights(
            positions[i],
            viewer
          );
          if (modelHeight !== undefined) {
            heightArr[i] = modelHeight;
          }
          let he = heightArr[i].toFixed(2);
          if (parseFloat(he) < parseFloat(pmx.min)) {
            pmx.min = parseFloat(he);
          }
          if (parseFloat(he) > parseFloat(pmx.max)) {
            pmx.max = parseFloat(he);
          }
          pmx.gcs.push(he);
        }
        viewer.scene.globe.depthTestAgainstTerrain = changeDepthTest;
        methond && typeof methond == "function" && methond(pmx);
      }
    });
  };
  if (pmx.cys > 1000) {
    layuiLayer &&
      layuiLayer.msg("当前采样点数过多,是否继续分析?", {
        time: 0,
        btn: ["继续", "取消"],
        btnAlign: "c",
        yes: index => {
          layuiLayer.close(index);
          setTimeout(() => {
            startAnalyse();
          }, 10);
        },
        btn2: () => {
          methond && typeof methond == "function" && methond(pmx);
        }
      });
  } else {
    setTimeout(() => {
      startAnalyse();
    }, 10);
  }
};

/**
 * 获取uuid
 */
Core.prototype.uuid = function (len, radix) {
  var chars =
    "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".split("");
  var uuid = [],
    i;
  var uuid = [],
    i;
  radix = radix || chars.length;

  if (len) {
    // Compact form
    for (i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)];
  } else {
    // rfc4122, version 4 form
    var r;

    // rfc4122 requires these characters
    uuid[8] = uuid[13] = uuid[18] = uuid[23] = "-";
    uuid[14] = "4";

    // Fill in random data.  At i==19 set the high bits of clock sequence as
    // per rfc4122, sec. 4.1.5
    for (i = 0; i < 36; i++) {
      if (!uuid[i]) {
        r = 0 | (Math.random() * 16);
        uuid[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r];
      }
    }
  }

  return uuid.join("");
};

Core.prototype.getuid = function () {
  // var idStr = Date.now().toString(36);
  // idStr += Math.random().toString(36).substr(3);
  // return idStr;

  // return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
  //     var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
  //     return v.toString(16);
  // });

  return this.uuid(8, 16);
};

/**
 * 对象参数合并
 * @param {Object} o 对象
 * @param {Object} n 被合并的对象
 * @param {Boolean} [override=false] 是否覆盖原属性值
 * @param {Boolean} [mergeTheSame=false] 是否只合并相同属性
 */
Core.prototype.extend = function (
  o,
  n,
  override = false,
  mergeTheSame = false
) {
  for (var key in n) {
    if (mergeTheSame) {
      if (o.hasOwnProperty(key)) {
        o[key] = n[key];
      }
    } else {
      if (!o.hasOwnProperty(key) || override) {
        o[key] = n[key];
      }
    }
  }
  return o;
};

/**
 * 两点方位角
 * @param {number} lon1 起点经度
 * @param {number} lat1 起点纬度
 * @param {number} lon2 终点经度
 * @param {number} lat2 终点纬度
 */
Core.prototype.TwoPointAzimuth = function (lon1, lat1, lon2, lat2) {
  var result = 0.0;
  var getRad = function (d) {
    return (d * Math.PI) / 180.0;
  };

  var ilat1 = Math.round(0.5 + lat1 * 360000.0);
  var ilat2 = Math.round(0.5 + lat2 * 360000.0);
  var ilon1 = Math.round(0.5 + lon1 * 360000.0);
  var ilon2 = Math.round(0.5 + lon2 * 360000.0);

  lat1 = getRad(lat1);
  lon1 = getRad(lon1);
  lat2 = getRad(lat2);
  lon2 = getRad(lon2);

  if (ilat1 === ilat2 && ilon1 === ilon2) {
    return result;
  } else if (ilon1 === ilon2) {
    if (ilat1 > ilat2) result = 180.0;
  } else {
    var c = Math.acos(
      Math.sin(lat2) * Math.sin(lat1) +
      Math.cos(lat2) * Math.cos(lat1) * Math.cos(lon2 - lon1)
    );
    var A = Math.asin((Math.cos(lat2) * Math.sin(lon2 - lon1)) / Math.sin(c));
    result = (A * 180) / Math.PI;
    if (ilat2 > ilat1 && ilon2 > ilon1) {
    } else if (ilat2 < ilat1 && ilon2 < ilon1) {
      result = 180.0 - result;
    } else if (ilat2 < ilat1 && ilon2 > ilon1) {
      result = 180.0 - result;
    } else if (ilat2 > ilat1 && ilon2 < ilon1) {
      result += 360.0;
    }
  }
  return result;
};
export default Core;
📄 getPosition.js
/**
 *
 * 获取位置。
 * xp
 * @alias getPosition
 * @constructor
 *
 */

function getPosition (viewer, cesium) {
  this._viewer = viewer;
  this._cesium = cesium;
}

getPosition.prototype.getPosition = function () {
  return this._viewer.camera.position;
};

getPosition.prototype.getDegrees = function () {
  var cartographic = this._viewer.camera.positionCartographic;
  var Degrees = {
    lon: this._cesium.Math.toDegrees(cartographic.longitude),
    lat: this._cesium.Math.toDegrees(cartographic.latitude),
    height: cartographic.height
  };

  return Degrees;
};
/**
 * 获取鼠标当前世界坐标
 * @param {object} [movement] 鼠标屏幕位置
 * @param {Array/Object} [objectsToExclude] 排除的实体对象
 * @param {number} [type] 类型(0为模型优先,1为地形优先),默认模型优先
 * @param {boolean} [isAdsorption] true|false 是否吸附,默认否
 * @param {number} [distance] 吸附半径,默认30
 **/
getPosition.prototype.getMousePosition = function (
  movement,
  objectsToExclude,
  type,
  isAdsorption,
  distance
) {
  var mousePosition = movement.endPosition || movement.position || movement;
  type === undefined && (type = 0);
  isAdsorption = this._cesium.defaultValue(isAdsorption, false);
  this.defaultDepthTest === undefined &&
    (this.defaultDepthTest =
      !!this._viewer.scene.globe.depthTestAgainstTerrain);

  var ray, cartesian, _cartesian, feature;
  //this.isObjectsToExcludeShow(objectsToExclude, false);
  var width = isAdsorption ? (distance ? distance : 30) : 1;
  if (type !== 0) {
    //地形优先
    //开启深度检测
    this._viewer.scene.globe.depthTestAgainstTerrain = true;
    ray = this._viewer.camera.getPickRay(mousePosition);
    ray && (cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene));
    //isAdsorption && (mousePosition = this.getAdsorptionPosition(mousePosition, objectsToExclude));

    if (!objectsToExclude || objectsToExclude.length === 0) {
      feature = this._viewer.scene.pick(mousePosition);
      if (feature && feature.id && !this.id3DGraphic(feature.id)) {
        feature = undefined;
      }
    } else {
      feature = this._viewer.scene.drillPick(
        mousePosition,
        objectsToExclude.length,
        width,
        width
      );
      feature = this.getNotExcludedObj(feature, objectsToExclude);
    }

    if (feature && !isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      _cartesian = this._viewer.scene.pickPosition(mousePosition);
      if (_cartesian) {
        cartesian = _cartesian;
      }
    } else {
      cartesian &&
        isAdsorption &&
        (_cartesian = this._getAdsorptionPosition(
          mousePosition,
          feature,
          distance
        )); //吸附
      if (_cartesian) {
        cartesian = _cartesian;
      }
    }
  } else {
    //模型优先
    if (!objectsToExclude || objectsToExclude.length === 0) {
      feature = this._viewer.scene.pick(mousePosition);
      if (feature && feature.id && !this.id3DGraphic(feature.id)) {
        feature = undefined;
      }
    } else {
      feature = this._viewer.scene.drillPick(
        mousePosition,
        (objectsToExclude &&
          objectsToExclude.length &&
          objectsToExclude.length + 1) ||
        1,
        width,
        width
      );
      feature = this.getNotExcludedObj(feature, objectsToExclude);
    }
    if (feature && !isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      cartesian = this._viewer.scene.pickPosition(mousePosition);
    } else if (feature && isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      cartesian = this._viewer.scene.pickPosition(mousePosition);
      _cartesian = this._getAdsorptionPosition(
        mousePosition,
        feature,
        distance
      ); //吸附
      if (_cartesian) {
        cartesian = _cartesian;
      }
    } else {
      //开启深度检测
      this._viewer.scene.globe.depthTestAgainstTerrain = true;
      ray = this._viewer.camera.getPickRay(mousePosition);
      ray &&
        (cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene));
    }
  }
  this._viewer.scene.globe.depthTestAgainstTerrain = !!this.defaultDepthTest;
  this.defaultDepthTest = undefined;

  // console.log(cartesian);
  if (!cartesian) {
    console.log("未拾取到坐标!");
    return;
  }

  return cartesian;
};
/**
 * 获取鼠标当前经纬度
 * @param {object} [movement] 鼠标屏幕位置
 * @param {Array/Object} [objectsToExclude] 排除的实体对象
 * @param {number} [type] 类型(0为模型优先,1为地形优先),默认模型优先
 * @param {boolean} [isAdsorption] true|false 是否吸附,默认否
 * @param {number} [distance] 吸附半径,默认30
 */
getPosition.prototype.getMouseDegrees = function (
  movement,
  objectsToExclude,
  type,
  isAdsorption,
  distance
) {
  var mousePosition = movement.endPosition || movement.position || movement;
  type === undefined && (type = 0);
  isAdsorption = this._cesium.defaultValue(isAdsorption, false);
  this.defaultDepthTest === undefined &&
    (this.defaultDepthTest =
      !!this._viewer.scene.globe.depthTestAgainstTerrain);

  var ray, cartesian, _cartesian, feature;
  //this.isObjectsToExcludeShow(objectsToExclude, false);
  var width = isAdsorption ? (distance ? distance : 30) : 1;
  if (type !== 0) {
    //地形优先
    //开启深度检测
    this._viewer.scene.globe.depthTestAgainstTerrain = true;
    ray = this._viewer.camera.getPickRay(mousePosition);
    ray && (cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene));
    //isAdsorption && (mousePosition = this.getAdsorptionPosition(mousePosition, objectsToExclude));

    if (!objectsToExclude || objectsToExclude.length === 0) {
      feature = this._viewer.scene.pick(mousePosition);
      if (feature && feature.id && !this.id3DGraphic(feature.id)) {
        feature = undefined;
      }
    } else {
      feature = this._viewer.scene.drillPick(
        mousePosition,
        (objectsToExclude &&
          objectsToExclude.length &&
          objectsToExclude.length + 1) ||
        1,
        width,
        width
      );
      feature = this.getNotExcludedObj(feature, objectsToExclude);
    }
    if (feature && !isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      _cartesian = this._viewer.scene.pickPosition(mousePosition);
      if (_cartesian) {
        cartesian = _cartesian;
      }
    } else {
      cartesian &&
        isAdsorption &&
        (_cartesian = this._getAdsorptionPosition(
          mousePosition,
          feature,
          distance
        )); //吸附
      if (_cartesian) {
        cartesian = _cartesian;
      }
    }
  } else {
    //模型优先
    if (!objectsToExclude || objectsToExclude.length === 0) {
      feature = this._viewer.scene.pick(mousePosition);
      if (feature && feature.id && !this.id3DGraphic(feature.id)) {
        feature = undefined;
      }
    } else {
      feature = this._viewer.scene.drillPick(
        mousePosition,
        (objectsToExclude &&
          objectsToExclude.length &&
          objectsToExclude.length + 1) ||
        1,
        width,
        width
      );
      feature = this.getNotExcludedObj(feature, objectsToExclude);
    }
    if (feature && !isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      cartesian = this._viewer.scene.pickPosition(mousePosition);
    } else if (feature && isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      cartesian = this._viewer.scene.pickPosition(mousePosition);
      _cartesian = this._getAdsorptionPosition(
        mousePosition,
        feature,
        distance
      ); //吸附
      if (_cartesian) {
        cartesian = _cartesian;
      }
    } else {
      //开启深度检测
      this._viewer.scene.globe.depthTestAgainstTerrain = true;
      ray = this._viewer.camera.getPickRay(mousePosition);
      ray &&
        (cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene));
    }
  }
  this._viewer.scene.globe.depthTestAgainstTerrain = !!this.defaultDepthTest;
  this.defaultDepthTest = undefined;

  if (!cartesian) {
    console.log("未拾取到坐标!");
    return;
  }

  var cartographic = this._cesium.Cartographic.fromCartesian(cartesian);
  return {
    lon: this._cesium.Math.toDegrees(cartographic.longitude),
    lat: this._cesium.Math.toDegrees(cartographic.latitude),
    height: cartographic.height
  };
};

//判断是否是三维图形
getPosition.prototype.id3DGraphic = function (graphic) {
  let threeD = true;
  if (graphic.polyline || graphic.point || graphic.label || graphic.billboard) {
    threeD = false;
  } else if (graphic.polygon && graphic.polygon.extrudedHeight == undefined) {
    threeD = false;
  } else if (
    graphic.rectangle &&
    graphic.rectangle.extrudedHeight == undefined
  ) {
    threeD = false;
  } else if (graphic.ellipse && graphic.ellipse.extrudedHeight == undefined) {
    threeD = false;
  } else if (graphic.corridor && graphic.corridor.extrudedHeight == undefined) {
    threeD = false;
  }
  return threeD;
};

//吸附坐标-屏幕坐标
getPosition.prototype.getAdsorptionPosition = function (
  mousePosition,
  objectsToExclude
) {
  var dis = 5; //吸附半径
  var ave = 3; //采样数
  var object = this._viewer.scene.drillPick(
    mousePosition,
    (objectsToExclude &&
      objectsToExclude.length &&
      objectsToExclude.length + 1) ||
    3,
    dis + 3,
    dis + 3
  ); //3为默认拾取范围
  var need = false;
  for (var i = 0; i < object.length; i++) {
    if (object[i] && !this.isExcluded(object[i], objectsToExclude)) {
      need = true;
      break;
    }
  }

  if (need) {
    object = this._viewer.scene.pick(mousePosition, 1, 1);
    if (object && !this.isExcluded(object, objectsToExclude)) {
      return mousePosition;
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x + i, y: mousePosition.y },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x + i, y: mousePosition.y };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x + i, y: mousePosition.y + i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x + i, y: mousePosition.y + i };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x, y: mousePosition.y + i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x, y: mousePosition.y + i };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x - i, y: mousePosition.y + i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x - i, y: mousePosition.y + i };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x - i, y: mousePosition.y },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x - i, y: mousePosition.y };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x - i, y: mousePosition.y - i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x - i, y: mousePosition.y - i };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x, y: mousePosition.y - i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x, y: mousePosition.y - i };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x + i, y: mousePosition.y - i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x + i, y: mousePosition.y - i };
      }
    }
  }
  return mousePosition;
};

//吸附坐标
getPosition.prototype._getAdsorptionPosition = function (
  mousePosition,
  feature,
  distance
) {
  var dis = distance ? distance : 30; //吸附半径

  if (feature) {
    feature = this.getFeature(feature);
    var _PosArr = [];
    var CanvasCoordinates;
    for (var i = 0; i < feature.position.length; i++) {
      CanvasCoordinates = this._viewer.scene.cartesianToCanvasCoordinates(
        feature.position[i]
      );
      if (CanvasCoordinates) {
        CanvasCoordinates.index = i;
        _PosArr.push(CanvasCoordinates);
      }
    }
    var compare = function (obj1, obj2) {
      var val1 = obj1.x;
      var val2 = obj2.x;
      if (val1 < val2) {
        return -1;
      } else if (val1 > val2) {
        return 1;
      } else {
        return 0;
      }
    };
    _PosArr = _PosArr.sort(compare);
    if (_PosArr && _PosArr.length > 1) {
      //二分法算最接近下标
      var n = Math.log(_PosArr.length) / Math.log(2);
      var m = 0;
      var maxn = _PosArr.length;
      var minn = 0;
      var zd = -1;

      for (var i = 0; i < n; i++) {
        m = Math.floor((maxn + minn) / 2);
        if (mousePosition.x - _PosArr[m].x > dis) {
          minn = m;
        } else if (mousePosition.x - _PosArr[m].x < -dis) {
          maxn = m;
        } else if (Math.abs(mousePosition.x - _PosArr[m].x) < dis) {
          zd = m;
          break;
        }
      }
      if (zd !== -1) {
        for (var i = m; i < maxn; i++) {
          if (Math.abs(mousePosition.x - _PosArr[i].x) > dis) {
            maxn = i;
            break;
          }
        }
        for (var i = m; i > minn; i--) {
          if (Math.abs(mousePosition.x - _PosArr[i].x) > dis) {
            minn = i + 1;
            break;
          }
        }
        for (var i = minn; i < maxn; i++) {
          if (Math.abs(mousePosition.y - _PosArr[i].y) < dis) {
            return feature.position[_PosArr[i].index];
          }
        }
      }
    }
    if (_PosArr && _PosArr.length === 1) {
      if (
        Math.abs(mousePosition.x - _PosArr[0].x) < dis &&
        Math.abs(mousePosition.y - _PosArr[0].y) < dis
      ) {
        return feature.position[0];
      }
    }
  }
};

getPosition.prototype.getFeature = function (obj) {
  var position;
  var data = {
    position: [],
    object: []
  };
  if (obj && obj.id) {
    if (obj.id instanceof this._cesium.Entity) {
      var entity = obj.id;
      if (entity.billboard) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "billboard",
          feature: entity.billboard
        });
      }
      if (entity.box) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "box",
          feature: entity.box
        });
      }
      if (entity.corridor) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "corridor",
          feature: entity.corridor
        });
      }
      if (entity.cylinder) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "cylinder",
          feature: entity.cylinder
        });
      }
      if (entity.ellipse) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "ellipse",
          feature: entity.ellipse
        });
      }
      if (entity.ellipsoid) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "ellipsoid",
          feature: entity.ellipsoid
        });
      }
      if (entity.label) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "label",
          feature: entity.label
        });
      }
      if (entity.model) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "model",
          feature: entity.model
        });
      }
      if (entity.path) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "path",
          feature: entity.path
        });
      }
      if (entity.plane) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "plane",
          feature: entity.plane
        });
      }
      if (entity.point) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "point",
          feature: entity.point
        });
      }
      if (entity.polygon) {
        position = entity.polygon.hierarchy.getValue(
          this._viewer.clock.currentTime
        );
        data.position = data.position.concat(position.positions);
        data.object.push({
          type: "polygon",
          feature: entity.polygon
        });
      }
      if (entity.polyline) {
        position = entity.polyline.positions.getValue(
          this._viewer.clock.currentTime
        );
        data.position = data.position.concat(position);
        data.object.push({
          type: "polyline",
          feature: entity.polyline
        });
      }
      if (entity.polylineVolume) {
        position = entity.polylineVolume.positions.getValue(
          this._viewer.clock.currentTime
        );
        data.position = data.position.concat(position);
        data.object.push({
          type: "polylineVolume",
          feature: entity.polylineVolume
        });
      }
      if (entity.rectangle) {
        position = entity.rectangle.coordinates.getValue(
          this._viewer.clock.currentTime
        );
        data.position = data.position.concat(position);
        data.object.push({
          type: "rectangle",
          feature: entity.rectangle
        });
      }
      if (entity.wall) {
        position = entity.wall.positions.getValue(
          this._viewer.clock.currentTime
        );
        data.position = data.position.concat(position);
        data.object.push({
          type: "wall",
          feature: entity.wall
        });
      }
    }
  }
  if (obj && obj.primitive) {
    if (obj.primitive instanceof this._cesium.Model) {
      position = obj.primitive.positionObj;
      data.position.push(position);
      data.object.push({
        type: "model",
        feature: obj.primitive
      });
    }
  }
  return data;
};

//是否包含对象
getPosition.prototype.isExcluded = function (object, objectsToExclude) {
  if (
    !this._cesium.defined(object) ||
    !this._cesium.defined(objectsToExclude) ||
    objectsToExclude.length === 0
  ) {
    return false;
  }
  return (
    objectsToExclude.indexOf(object) > -1 ||
    objectsToExclude.indexOf(object.primitive) > -1 ||
    objectsToExclude.indexOf(object.id) > -1
  );
};

//获取不包含的对象
getPosition.prototype.getNotExcludedObj = function (
  objectOrArr,
  objectsToExclude
) {
  if (objectOrArr.length === 0) {
    return false;
  } else if (
    !this._cesium.defined(objectsToExclude) ||
    objectsToExclude.length === 0
  ) {
    return objectOrArr;
  }
  for (var i = 0; i < objectOrArr.length; i++) {
    if (objectOrArr[i] && !this.isExcluded(objectOrArr[i], objectsToExclude)) {
      return objectOrArr[i];
    }
  }
  return false;
};

//控制对象显隐
getPosition.prototype.isObjectsToExcludeShow = function (
  objectsToExclude,
  isShow
) {
  if (
    !this._cesium.defined(objectsToExclude) ||
    objectsToExclude.length === 0
  ) {
    return;
  }
  if (objectsToExclude instanceof Array) {
    objectsToExclude.forEach(function (item) {
      item.show = isShow;
    });
  } else {
    objectsToExclude.show = isShow;
  }
};
export default getPosition;
调用方法
  
   const _dynamicObject = new dynamicObject(window.viewer, Cesium);
  
  _dynamicObject.executeFlycesium(data => {
    data.showPoint = true;
    data.showLine = true;
    data.mode = 2; // 飞行模式
    _dynamicObject.Start(data, data.url);
  });
❌