当前位置: 首页 > news >正文

selection.js:简化DOM文本选区管理的轻量级JavaScript库

1. 项目概述:一个被低估的文本选区管理工具

在Web开发中,处理用户选中的文本(Selection)一直是个有点“微妙”的领域。你可能遇到过这样的需求:做一个划词翻译、一个文章批注功能,或者让用户选中一段文字后弹出分享菜单。这时候,你大概率会直接去操作原生的window.getSelection()API。上手写几行代码后,你会发现事情没那么简单——跨浏览器兼容性、选区边界的精确获取、选区变化的事件监听,每一个点都藏着不少坑。我自己就在一个内容协作平台上踩过这些坑,直到我发现了drylikov/selection.js这个项目。

selection.js是一个轻量级的JavaScript库,它的核心使命是封装和简化DOM文本选区API的操作。根据其文档描述,作者创建它的初衷是为了让文章读者能够方便地选中文本,并向作者反馈编辑错误(如样式、语法问题)。虽然它自称并非主要用于操纵选区,但其清晰的API设计让它完全能胜任更复杂的交互场景。这个库非常小巧,压缩后仅约1KB,支持IE9及以上和所有现代浏览器。它解决的不是一个炫酷的UI效果,而是一个基础但至关重要的交互层问题:如何可靠地、无痛地知道用户“选中了什么”以及“什么时候选的”。接下来,我会结合自己的使用经验,带你彻底拆解这个库,看看它如何化繁为简,以及我们在实际项目中如何用好它。

2. 核心设计思路与原理拆解

2.1 为什么需要封装原生Selection API?

在深入selection.js之前,我们必须先理解原生API的痛点。window.getSelection()返回的是一个Selection对象,它包含了用户当前选中的所有范围(Range对象)。一个选区可以包含多个范围(例如在表格中多选,但现代浏览器通常只支持一个)。直接操作这个原生对象,你会面临几个问题:

  1. 信息提取繁琐:要获取选中的纯文本,你需要遍历Range,调用toString()。要获取选区起始和结束的DOM节点(anchorNode/focusNode)和偏移量(anchorOffset/focusOffset),还需要理解“锚点”和“焦点”的概念(即选区的开始和结束,可能与用户拖拽方向有关)。
  2. 事件监听缺失:原生API没有提供直接的“选区改变”事件。你只能通过监听mouseupkeyup等事件来间接判断,但这会引入大量不必要的触发和逻辑判断。
  3. 浏览器兼容性处理:虽然现代API基本统一,但在一些细节和古老浏览器上(如IE9),仍需做兼容性判断。
  4. API不够直观:原生方法如addRange(),removeAllRanges(),collapse()等,在实现具体功能时,代码会显得冗长且意图不清晰。

selection.js的设计哲学正是为了解决这些问题。它不试图创造一个全新的选区模型,而是作为一个“适配器”和“观察者”,提供更友好、更稳定的一层抽象。

2.2 模块的架构与工作流程

从源码和文档可以看出,selection.js的核心架构非常清晰,主要分为两个部分:状态管理事件派发

状态管理的核心是get()方法。它主动查询当前window(或指定iframe)的选区状态,并将原生Selection对象中那些令人困惑的属性(anchorNode,focusNode,anchorOffset,focusOffset),转换成一个结构清晰、命名直观的普通对象。这个对象明确告诉你$start,$end,$top,$bottom分别是什么,省去了开发者自己比较文档位置(compareDocumentPosition)的计算。

事件派发是它的亮点。通过listen()方法,库开始监听页面的mouseupkeyup等可能改变选区的事件。在其内部,它维护了一个上一次选区的状态。当事件触发时,它比较当前选区与上一次的状态:

  • 如果从“无选区”变为“有选区”,则触发自定义的selection事件。
  • 如果从“有选区”变为“无选区”,则触发自定义的deselection事件。
  • 如果选区内容发生了变化(例如用户拖拽扩大了选区),也会触发selection事件,并携带新的选区信息。

这种设计将“轮询”变为了“事件驱动”,让我们的业务逻辑可以更声明式地编写:“当用户选中文字时,执行A操作;当取消选中时,执行B操作”

注意:库的兼容性基于几个关键的DOM Level 2方法,如addEventListenergetSelectiontextContentcompareDocumentPosition。这意味着在IE9及以上的标准模式下,它可以稳定工作。对于更古老的浏览器或怪异模式,则不在其支持范围内,这也是一个合理的取舍。

3. API深度解析与实战应用

3.1 初始化与实例管理

初始化selection.js非常简单,但有一个关键点需要注意:

// 方式一:对当前窗口实例化 const selection = new Selection(); // 或 const selection = new Selection(window); // 方式二:对特定iframe实例化 const iframe = document.getElementById('myFrame'); const selection = new Selection(iframe.contentWindow); if (selection) { // 实例化成功,可以开始使用 selection.listen(); } else { // 实例化失败,通常是因为传入的window对象不支持 getSelection API console.error('Selection API is not supported in this context.'); }

关键细节

  • Selection构造函数接受一个可选的window对象作为参数。这让你可以管理<iframe>内部的选区,这在构建富文本编辑器或复杂应用时非常有用。
  • 构造函数会检查传入的window对象是否存在getSelection方法。如果不存在,构造函数会返回false而不是一个实例。因此,总是检查实例化结果是一个好习惯。这比在后续调用方法时再报错要友好得多。
  • 一个Selection实例绑定一个特定的window对象。如果你需要管理多个窗口或iframe,需要为每一个创建独立的实例。

3.2 事件系统:selectiondeselection

这是库的灵魂所在。使用事件系统,你无需主动轮询,代码逻辑会非常清晰。

const selection = new Selection(); if (!selection) return; // 开始监听选区变化 selection.listen(); // 监听用户选中文本的事件 window.addEventListener('selection', function(event) { const detail = event.detail; console.log('用户选中了文本:', detail.value); console.log('选区的起始节点:', detail.$start); console.log('选区的起始偏移量:', detail.startOffset); // 你可以在这里触发你的业务逻辑,例如: // 1. 显示一个浮动工具栏 // showFloatingToolbar(detail); // 2. 获取选中文本并调用翻译API // translateText(detail.value); // 3. 高亮选中的段落 // highlightRange(detail); }); // 监听用户取消选中文本的事件 window.addEventListener('deselection', function(event) { console.log('选区已被取消'); // 在这里清理状态,例如隐藏浮动工具栏 // hideFloatingToolbar(); }); // 在适当的时候(例如组件销毁时),停止监听 // selection.ignore();

event.detail对象详解: 在selection事件中,event.detail包含了get()方法返回的所有信息,并额外附加了一个originalEvent属性。这个设计非常贴心:

  • detail.value: 选中的纯文本字符串。这是最常用的属性。
  • detail.$start/detail.$end: 选区起点和终点所在的DOM节点。通常是文本节点(#text)。
  • detail.$top/detail.$bottom: 经过文档位置比较后,排在前面和后面的节点。在大多数从左到右、从上到下的选择中,$top就是$start$bottom就是$end。但在用户从右向左选择时,它们的关系会互换。在实现高亮或标记时,使用$top$bottom更为可靠,因为它们总是代表视觉/文档流上的开始和结束。
  • detail.originalEvent: 触发这次选区变化的原始DOM事件(如MouseEventKeyboardEvent)。你可以用它来获取点击坐标、按键信息等,实现更精细的交互。

实操心得: 在实际项目中,我建议将事件监听和业务逻辑解耦。例如,创建一个SelectionManager类,在内部处理selection.js的实例化和事件监听,然后对外提供像onTextSelected(callback)这样的简洁接口。这样,你的业务组件就不需要关心selection.js的具体API,提高了代码的可维护性。

3.3 核心方法详解与应用场景

3.3.1get():获取当前选区状态

这是最基础的方法,返回一个描述当前选区的快照对象。即使你没有使用事件监听,也可以在任何时候主动调用它来获取选区信息。

const selection = new Selection(); selection.listen(); // 假设用户已经选中了一些文字 const selectionInfo = selection.get(); if (selectionInfo.value) { console.log(`选中了"${selectionInfo.value}"`); console.log(`从节点【${selectionInfo.$start.nodeName}】的偏移量${selectionInfo.startOffset}开始`); console.log(`到节点【${selectionInfo.$end.nodeName}】的偏移量${selectionInfo.endOffset}结束`); } else { console.log('当前没有选区。'); }
3.3.2has():快速判断是否存在选区

这是一个轻量级的检查方法,比调用get()并判断value更高效,因为它可能只在内部检查了选区范围的数量。

// 用于条件判断 if (selection.has()) { // 执行需要选区的操作 showContextMenu(); } else { hideContextMenu(); }
3.3.3clear():清除当前选区

这个方法会调用原生的selection.removeAllRanges(),并返回实例自身以支持链式调用。一个常见的场景是,在你完成对选中文本的操作(例如显示了一个自定义工具栏并点击了某个按钮)后,主动清除选区,使页面恢复常态。

document.getElementById('copyButton').addEventListener('click', function() { const sel = selection.get(); if (sel.value) { copyToClipboard(sel.value); // 操作完成后清除选区 selection.clear(); // 可以同时触发一个自定义的“操作完成”事件 } });
3.3.4set():以编程方式设置选区

这是库提供的“操纵”功能。它允许你通过代码来“模拟”一个用户选区。参数设计上,它期望你提供$top(顶部节点)、topOffset(顶部偏移)以及可选的$bottombottomOffset。如果只提供$top,它会尝试智能地选中该节点的全部内容。

// 示例:自动选中一个段落的前三个字 const paragraph = document.querySelector('p'); const textNode = paragraph.firstChild; // 假设段落直接包含文本 if (textNode && textNode.nodeType === Node.TEXT_NODE) { // 设置从该文本节点偏移量0开始,到偏移量3结束的选区 selection.set(textNode, 0, textNode, 3); // 此时,页面上会高亮显示选中的文字,并会触发 `selection` 事件 } // 示例:选中整个元素的内容 const div = document.getElementById('content'); selection.set(div); // 不提供偏移量,默认选中整个节点内容

注意事项

  • set()方法非常强大,但使用时要格外小心。因为它会改变用户的选区状态,可能会干扰用户的正常操作。通常用于实现“全选”按钮、代码示例的“一键复制”等高亮功能。
  • 在调用set()后,记得你可能需要手动触发一些后续逻辑,或者依赖它触发的selection事件。
3.3.5getTop(),getBottom(),getStart(),getEnd()

这组方法是get()返回对象的快捷方式,它们直接返回{ $node, offset }格式的对象。当你只关心选区的某一个边界点时,使用它们可以让代码更简洁。

const topBoundary = selection.getTop(); console.log(`选区顶部节点: ${topBoundary.$node}, 偏移: ${topBoundary.offset}`); // 等价于: const fullInfo = selection.get(); console.log(`选区顶部节点: ${fullInfo.$top}, 偏移: ${fullInfo.topOffset}`);

4. 实战案例:构建一个文章批注系统

让我们用一个更复杂的例子来串联所有知识点。假设我们要实现文档中提到的核心用途:一个让读者可以对文章进行划词批注的系统。

4.1 系统设计与初始化

首先,我们需要一个状态来管理当前是否处于“批注模式”,以及存储所有的批注。

class ArticleAnnotationSystem { constructor(articleElementId) { this.articleEl = document.getElementById(articleElementId); this.isAnnotationMode = false; this.annotations = []; this.currentSelection = null; // 初始化 selection.js this.selection = new Selection(); if (!this.selection) { console.warn('浏览器不支持选区功能,批注系统不可用。'); return; } this.initUI(); this.bindEvents(); } initUI() { // 创建并注入一个浮动工具栏的HTML结构 this.toolbar = document.createElement('div'); this.toolbar.className = 'annotation-toolbar'; this.toolbar.innerHTML = ` <button class="btn-add-note">添加批注</button> <button class="btn-highlight">高亮</button> <button class="btn-cancel">取消</button> `; this.toolbar.style.display = 'none'; this.toolbar.style.position = 'absolute'; document.body.appendChild(this.toolbar); } bindEvents() { // 模式切换按钮 document.getElementById('btn-enable-annotate').addEventListener('click', () => this.enableAnnotationMode()); // 浮动工具栏按钮事件(事件委托) this.toolbar.addEventListener('click', (e) => { if (e.target.classList.contains('btn-add-note')) { this.addAnnotation(); } else if (e.target.classList.contains('btn-highlight')) { this.highlightSelection(); } else if (e.target.classList.contains('btn-cancel')) { this.hideToolbar(); } }); // 监听选区事件 window.addEventListener('selection', this.handleSelection.bind(this)); window.addEventListener('deselection', this.handleDeselection.bind(this)); }

4.2 处理选区与显示工具栏

核心逻辑在于handleSelection方法。我们需要判断选区是否发生在文章区域内,并定位工具栏。

handleSelection(event) { // 如果不在批注模式,忽略所有选区事件 if (!this.isAnnotationMode) return; const detail = event.detail; // 获取选区信息 this.currentSelection = detail; // 检查选区是否完全在文章容器内(简单的示例,实际需要更精确的检查) const selectionRange = this.isSelectionInsideArticle(detail); if (!selectionRange) { this.hideToolbar(); return; } // 显示工具栏,并定位到选区附近 this.showToolbarNear(event.originalEvent || event.detail.originalEvent); } isSelectionInsideArticle(detail) { // 这是一个简化的实现:检查选区顶部和底部节点是否是文章容器的后代 const article = this.articleEl; const topNode = detail.$top; const bottomNode = detail.$bottom; // 使用 Node.contains() 方法判断 if (!article.contains(topNode) || !article.contains(bottomNode)) { return false; } // 更严谨的做法是使用 Range 和 compareBoundaryPoints 来精确判断 // 这里为了示例保持简单 return true; } showToolbarNear(mouseEvent) { this.toolbar.style.display = 'block'; // 使用鼠标事件的坐标定位工具栏 this.toolbar.style.left = mouseEvent.pageX + 'px'; this.toolbar.style.top = (mouseEvent.pageY - 40) + 'px'; // 放在光标上方 } hideToolbar() { this.toolbar.style.display = 'none'; this.currentSelection = null; } handleDeselection() { // 当用户取消选中时,延迟一点隐藏工具栏,避免点击工具栏按钮时立即消失 setTimeout(() => { if (!this.selection.has()) { this.hideToolbar(); } }, 200); }

4.3 实现批注与高亮功能

现在,实现工具栏按钮的具体功能。

addAnnotation() { if (!this.currentSelection || !this.currentSelection.value.trim()) { alert('请先选择一些文字。'); return; } const text = this.currentSelection.value; const note = prompt('请输入您的批注:', ''); if (note) { // 保存批注信息 this.annotations.push({ id: Date.now(), text: text, note: note, rangeInfo: { // 存储用于恢复选区的关键信息 topNode: this.currentSelection.$top, topOffset: this.currentSelection.topOffset, bottomNode: this.currentSelection.$bottom, bottomOffset: this.currentSelection.bottomOffset }, timestamp: new Date() }); // 在页面上插入一个批注标记(例如一个角标) this.insertAnnotationMarker(this.currentSelection); console.log('批注已添加:', this.annotations[this.annotations.length - 1]); } // 操作完成后清除选区并隐藏工具栏 this.selection.clear(); this.hideToolbar(); } highlightSelection() { if (!this.currentSelection) return; // 使用原生的 Range 和 DocumentFragment 来实现高亮 const range = document.createRange(); range.setStart(this.currentSelection.$top, this.currentSelection.topOffset); range.setEnd(this.currentSelection.$bottom, this.currentSelection.bottomOffset); const highlightSpan = document.createElement('span'); highlightSpan.className = 'article-highlight'; highlightSpan.style.backgroundColor = 'yellow'; range.surroundContents(highlightSpan); // 保存高亮信息(可选) this.annotations.push({ type: 'highlight', rangeInfo: this.currentSelection, timestamp: new Date() }); this.selection.clear(); this.hideToolbar(); } insertAnnotationMarker(sel) { // 这是一个简化实现。更复杂的实现可能需要用注释节点来标记位置。 // 这里我们在选区结束位置后插入一个可点击的角标[注] const marker = document.createElement('sup'); marker.className = 'annotation-marker'; marker.textContent = `[${this.annotations.length + 1}]`; marker.style.color = 'blue'; marker.style.cursor = 'pointer'; marker.dataset.annotationId = this.annotations[this.annotations.length - 1].id; // 插入到选区结束节点的后面(这里逻辑简化,实际插入需要更精确的DOM操作) sel.$bottom.parentNode.insertBefore(marker, sel.$bottom.nextSibling); } enableAnnotationMode() { this.isAnnotationMode = true; this.selection.listen(); alert('批注模式已开启,您现在可以选中文字进行批注或高亮。'); } }

4.4 样式与体验优化

最后,添加一些基础样式,并处理一些边界情况。

<style> .annotation-toolbar { background: #333; color: white; padding: 8px 12px; border-radius: 4px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); z-index: 10000; font-size: 14px; } .annotation-toolbar button { background: #555; border: none; color: white; padding: 5px 10px; margin: 0 3px; border-radius: 3px; cursor: pointer; } .annotation-toolbar button:hover { background: #666; } .article-highlight { background-color: #ffeb3b !important; /* 使用 !important 覆盖可能的内联样式 */ padding: 0 1px; } .annotation-marker { color: #2196F3; font-weight: bold; margin-left: 2px; } </style>

这个案例展示了如何利用selection.js清晰的事件流和丰富的选区信息,构建一个功能完整的交互系统。它将复杂的原生API操作封装成了简单的“事件-响应”模式,让我们可以更专注于业务逻辑。

5. 常见问题、排查技巧与进阶思考

5.1 常见问题速查表

问题现象可能原因解决方案
new Selection()返回false在不支持getSelectionAPI的环境下调用,如非常古老的浏览器或某些沙盒环境。进行特性检测,提供降级方案或提示用户升级浏览器。
selection事件不触发1. 没有调用selection.listen()
2. 选区变化发生在iframe内,但监听的是父窗口。
3. 通过set()方法设置的选区可能在某些浏览器下不会触发原生事件,导致库未捕获。
1. 确保在实例化后调用了listen()
2. 为iframecontentWindow创建独立的Selection实例并监听。
3. 在调用set()后,可以手动触发业务逻辑或模拟事件。
获取到的$start/$end不是文本节点用户选区的起始或结束点恰好落在元素节点上(例如,光标在<p>标签之前)。在业务逻辑中处理这种情况。可以使用RangecloneContents()extractContents()来安全地处理选区内容,或者递归查找其下的第一个文本节点。
浮动工具栏定位不准使用event.detail.originalEvent的坐标时,如果页面有滚动或复杂布局,坐标需要转换。使用getBoundingClientRect()获取选区范围的视觉位置,并相对于视口进行定位。不要完全依赖鼠标事件坐标。
在富文本编辑器(如contenteditable)中行为异常富文本编辑器的选区模型更复杂,可能涉及多个Rangeselection.js主要针对普通文档流设计。对于复杂的编辑器,可能需要使用专门处理编辑区选区的库(如rangy),或直接操作原生API。selection.js可能无法完全覆盖所有情况。
内存泄漏长期运行的SPA中,创建了Selection实例但未在组件销毁时调用ignore()在 Vue/React 组件的生命周期钩子(如beforeUnmount,componentWillUnmount)中,务必调用selection.ignore()并置空实例。

5.2 性能与最佳实践

  1. 按需监听:只在需要的时候调用listen(),在功能禁用或页面离开时调用ignore()。持续监听所有选区变化会有微小的性能开销。
  2. 防抖处理selection事件在用户拖拽鼠标选择时会高频触发。如果事件处理逻辑较重(例如频繁操作DOM或发起网络请求),务必使用防抖(debounce)技术。
    let debounceTimer; window.addEventListener('selection', (event) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { // 真正的处理逻辑 this.handleSelectionDebounced(event.detail); }, 150); // 延迟150毫秒 });
  3. 选区验证:在事件处理函数中,不要假设选区总是有效的。先检查event.detail.value是否有意义(非空、非全是空白符)。
    if (!event.detail.value || !event.detail.value.trim()) { this.hideToolbar(); return; }
  4. 跨框架通信:如果你的应用包含iframe,并且需要在主页面和iframe之间同步选区状态,你需要分别在两个上下文中实例化Selection,并通过postMessage进行通信。selection.js本身不处理跨域问题。

5.3 进阶:与现代前端框架集成

在 Vue 或 React 中,你可以将selection.js封装成一个自定义 Hook 或 Composables,以更响应式的方式工作。

React Hook 示例

import { useRef, useEffect, useState } from 'react'; function useTextSelection(onSelectionChange) { const selectionRef = useRef(null); const [selectionData, setSelectionData] = useState(null); useEffect(() => { const sel = new Selection(); if (!sel) return; selectionRef.current = sel; sel.listen(); const handleSelection = (event) => { const detail = event.detail; setSelectionData(detail); if (onSelectionChange) { onSelectionChange(detail); } }; window.addEventListener('selection', handleSelection); window.addEventListener('deselection', () => { setSelectionData(null); }); // 清理函数 return () => { window.removeEventListener('selection', handleSelection); if (selectionRef.current) { selectionRef.current.ignore(); } }; }, [onSelectionChange]); // 依赖 onSelectionChange 回调 const clearSelection = () => { if (selectionRef.current) { selectionRef.current.clear(); } }; return { selectionData, clearSelection }; } // 在组件中使用 function MyComponent() { const { selectionData, clearSelection } = useTextSelection((detail) => { console.log('Selection changed:', detail.value); }); return ( <div> <p>选中一些文字试试...</p> {selectionData && ( <div> 你选中了: <strong>{selectionData.value}</strong> <button onClick={clearSelection}>清除选中</button> </div> )} </div> ); }

这个Hook封装了selection.js的生命周期管理,提供了响应式的selectionData状态和一个清理函数,使得在React组件中使用变得非常简洁和安全。

drylikov/selection.js这个库可能不会经常出现在你的项目依赖列表里,但一旦你遇到需要处理文本选区的场景,它就是一个能让你事半功倍的“瑞士军刀”。它用极小的体积和清晰的抽象,解决了Web开发中一个持久且琐碎的痛点。从我个人的使用经验来看,它的稳定性和API设计都经受住了实践的考验。下次当你再需要实现划词功能时,不妨先试试它,而不是直接跳进原生API的细节里。

http://www.jsqmd.com/news/800017/

相关文章:

  • 轻量级GraphRAG实现:nano-graphrag核心原理与定制指南
  • Viterbi 算法直接用在中文分词上
  • 别再乱调了!大漠模块SetKeypadDelay/SetMouseDelay参数详解与实战避坑(易语言)
  • 第二章-05-目录切换相关命令(cd/pwd)-课后练习
  • Gemini辅助写周报/月报:从零散记录到结构化汇报的提效方法.
  • 3大维度重构游戏体验:DOL汉化美化整合包全指南
  • 2026 Git 高频面试攻坚:从底层原理到企业级救火(进阶实战版)
  • 嵌入式软件架构一:一个能让人放心接手的嵌入式项目,骨架长什么样
  • MinerU 实战训练营:RAG 数据预处理的最后一块拼图
  • 阿里:时序课程解决多轮蒸馏不稳定
  • 手把手调SVPWM:如何根据你的直流母线电压Udc设置正确的调制比不炸管?
  • 从关中到汉中:用Python+DEM数据,分析古代行军路线的地理可行性
  • Awesome List自动化生成:从手工整理到工业化生产的效率革命
  • 健身直播必备:手表心率如何实时显示在手机拍摄画面上?
  • YOLO26引入Dual-ViT自注意力:局部与全局两条主线的完美交汇
  • 基于Agent-Next框架的Polymarket预测市场模拟交易系统构建指南
  • 告别重复劳动:手把手教你用SAP LSMW为MM模块创建第一个数据导入程序
  • 四轴飞行器入门:BNO055与JY901传感器模块选型及实测对比
  • 2026年4月国内知名的数字化服务平台源头厂家推荐,KYN28-12铠装移开式金属封闭开关柜,数字化服务平台公司哪家好 - 品牌推荐师
  • TinyML实战:tiny-ai-client在MCU上的轻量级AI推理部署指南
  • 效率翻倍!依据2026白皮书,这样部署OpenClaw最快(移动云电脑版)
  • 别再死记硬背了!用Python+NumPy图解NCHW与NHWC,彻底搞懂数据排布
  • C++ 入门核心语法|从 Hello World 到基础特性一次性吃透
  • HIOKI-3272 日置 3272 电源 用于3273-50 3274 3275 3276探头
  • LocalChat:零门槛本地部署开源大语言模型,实现隐私安全的离线AI对话
  • 别再花钱买Token了!手把手教你免费申请Wechaty Token,15天体验版保姆级教程
  • 从Excel舍入到IEEE754:你的财务计算和游戏物理引擎可能都错了
  • 电力管供应商/热浸塑电力管厂家哪家靠谱?2026年热浸塑钢管厂家推荐:福派安领衔,口碑好的热浸塑电缆保护管厂家优质盘点 - 栗子测评
  • 收藏!小白程序员必看:LLM推理延迟的“快慢”真相与优化秘籍
  • 2026年4月做得好的网架直销厂家口碑推荐,国内网架口碑推荐,结构稳固,网架承载能力超强大 - 品牌推荐师