AbstractRange 是一个抽象接口,它是所有 DOM 范围类型的基础。范围(Range)是文档中一段连续内容的起点和终点的标识。理解 AbstractRange 及其派生接口,对于实现文本选择、内容复制、富文本编辑器等复杂功能至关重要。
一、AbstractRange 的基本概念
AbstractRange 是一个抽象接口,不能直接实例化。它定义了所有范围对象共有的只读属性。实际开发中,我们使用的是继承自它的 Range 和 StaticRange 两个具体接口。Range 支持动态修改,而 StaticRange 创建后不可变。
javascript
// 演示 AbstractRange 的抽象特性
function demonstrateAbstractRange() {
// 不能直接实例化 AbstractRange,会抛出错误
try {
const abstractRange = new AbstractRange();
console.log('这行不会执行');
} catch (error) {
console.log('AbstractRange 无法直接实例化:', error.message);
}
// 正确的方式:使用 Range 或 StaticRange
const range = document.createRange(); // 返回 Range 实例
const staticRange = new StaticRange({
startContainer: document.body,
startOffset: 0,
endContainer: document.body,
endOffset: 0
});
console.log('range 是否是 AbstractRange 的实例:', range instanceof AbstractRange); // true
console.log('staticRange 是否是 AbstractRange 的实例:', staticRange instanceof AbstractRange); // true
console.log('range 的类型:', range.constructor.name); // Range
console.log('staticRange 的类型:', staticRange.constructor.name); // StaticRange
}
// 检查范围对象的基本属性
function inspectRangeProperties() {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
// 这些属性都继承自 AbstractRange
console.log('范围是否折叠:', range.collapsed);
console.log('起始容器:', range.startContainer);
console.log('起始偏移:', range.startOffset);
console.log('结束容器:', range.endContainer);
console.log('结束偏移:', range.endOffset);
} else {
console.log('页面上没有选中的内容');
}
}
demonstrateAbstractRange();
理解 AbstractRange 的关键在于认识到它是一个"只读视图"。它描述了范围的位置信息,但不提供修改这些位置的方法。修改能力是由 Range 接口额外提供的。
二、AbstractRange 的实例属性详解
AbstractRange 定义了五个只读属性,用于描述范围的位置和状态。这些属性是所有范围对象共有的基础信息。
javascript
// 全面演示 AbstractRange 的各个属性
function demonstrateRangeProperties() {
// 准备测试用的 DOM 结构
const container = document.getElementById('testContainer');
if (!container) {
const div = document.createElement('div');
div.id = 'testContainer';
div.innerHTML = '<p><strong>Hello</strong> <em>World</em>!</p>';
document.body.appendChild(div);
}
const testDiv = document.getElementById('testContainer');
const firstP = testDiv.querySelector('p');
const textNode = firstP.firstChild; // <strong> 内的文本节点 "Hello"
// 创建一个范围,选中 "Hello" 中的 "ell"
const range = document.createRange();
range.setStart(textNode, 1); // 从索引 1 开始('e')
range.setEnd(textNode, 4); // 到索引 4 结束('l' 之后)
// 演示 collapsed 属性
console.log('范围是否折叠(start 和 end 相同):', range.collapsed); // false
// 创建一个折叠的范围(光标位置)
const collapsedRange = document.createRange();
collapsedRange.setStart(textNode, 2);
collapsedRange.setEnd(textNode, 2);
console.log('折叠范围 collapsed:', collapsedRange.collapsed); // true
// 演示 startContainer 和 startOffset
console.log('起始容器节点类型:', range.startContainer.nodeType); // 3 (TEXT_NODE)
console.log('起始容器内容:', range.startContainer.textContent); // "Hello"
console.log('起始偏移位置:', range.startOffset); // 1
// 演示 endContainer 和 endOffset
console.log('结束容器节点类型:', range.endContainer.nodeType); // 3
console.log('结束容器内容:', range.endContainer.textContent); // "Hello"
console.log('结束偏移位置:', range.endOffset); // 4
// 实用函数:获取范围内容的文本
function getRangeText(range) {
const fragment = range.cloneContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
return tempDiv.textContent;
}
console.log('选中的文本内容:', getRangeText(range)); // "ell"
}
// 处理文本节点偏移量的注意事项
function textNodeOffsetExample() {
const element = document.createElement('div');
element.innerHTML = 'Hello <span>World</span>!';
document.body.appendChild(element);
// 获取文本节点
const textNode = element.firstChild; // "Hello " 文本节点
console.log('文本节点内容:', textNode.textContent); // "Hello "
console.log('文本节点长度:', textNode.length); // 6
// 创建范围 - 正确获取文本节点内的偏移
const range = document.createRange();
range.setStart(textNode, 0); // 从 "Hello " 的开头
range.setEnd(textNode, 5); // 到 "Hello" 的末尾(不包括空格)
// 获取选中内容
const selectedText = range.toString();
console.log('选中的文本:', selectedText); // "Hello"
// 清理
element.remove();
}
demonstrateRangeProperties();
textNodeOffsetExample();
属性 collapsed 特别有用,它可以判断用户是否只是放置了光标而没有选中任何内容。startContainer 和 endContainer 可以是不同类型的节点,包括元素节点、文本节点和注释节点。
三、Range 与 StaticRange 的区别与选择
Range 和 StaticRange 是 AbstractRange 的两个具体实现。Range 是"活"的范围,会随着 DOM 的变化而自动调整;StaticRange 是"快照",创建后完全不受 DOM 变化的影响。
javascript
// 对比 Range 和 StaticRange 的行为差异
function compareRangeAndStaticRange() {
// 准备测试 DOM
const container = document.createElement('div');
container.id = 'comparisonContainer';
container.innerHTML = '<p>原始内容</p>';
document.body.appendChild(container);
const originalP = container.querySelector('p');
const textNode = originalP.firstChild;
// 创建 Range(动态)
const dynamicRange = document.createRange();
dynamicRange.setStart(textNode, 0);
dynamicRange.setEnd(textNode, 2);
// 创建 StaticRange(静态快照)
const staticRange = new StaticRange({
startContainer: textNode,
startOffset: 0,
endContainer: textNode,
endOffset: 2
});
console.log('=== 创建时的状态 ===');
console.log('dynamicRange 内容:', dynamicRange.toString()); // "原始"
console.log('staticRange 内容:', staticRange.toString()); // "原始"
// 修改 DOM 内容
textNode.textContent = '修改后的内容';
console.log('=== DOM 修改后的状态 ===');
console.log('dynamicRange 内容(自动更新):', dynamicRange.toString()); // "修改"
console.log('staticRange 内容(保持不变):', staticRange.toString()); // "原始"
// Range 可以通过方法修改端点
dynamicRange.setEnd(textNode, 3);
console.log('dynamicRange 修改后:', dynamicRange.toString()); // "修改后"
// StaticRange 是只读的,不能修改
try {
staticRange.setEnd(textNode, 3);
console.log('这行不会执行');
} catch (error) {
console.log('StaticRange 不可修改:', error.message);
}
// 清理
container.remove();
}
// 性能对比演示
async function performanceComparison() {
const container = document.createElement('div');
container.innerHTML = '<div id="perfTest">' + '内容 '.repeat(1000) + '</div>';
document.body.appendChild(container);
const targetNode = container.querySelector('#perfTest');
const textNode = targetNode.firstChild;
// 测试 Range 创建性能
console.time('Range 创建');
for (let i = 0; i < 1000; i++) {
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, 10);
const content = range.cloneContents();
}
console.timeEnd('Range 创建');
// 测试 StaticRange 创建性能
console.time('StaticRange 创建');
for (let i = 0; i < 1000; i++) {
const staticRange = new StaticRange({
startContainer: textNode,
startOffset: 0,
endContainer: textNode,
endOffset: 10
});
const content = staticRange.cloneContents();
}
console.timeEnd('StaticRange 创建');
container.remove();
}
// 选择建议
function selectionGuidelines() {
console.log('=== 选择 Range 还是 StaticRange ===');
console.log('使用 Range 的场景:');
console.log('1. 需要动态调整范围端点(如拖拽选择)');
console.log('2. 范围内容会随着 DOM 变化自动更新');
console.log('3. 需要调用 range 的修改方法如 setStart/setEnd');
console.log('4. 实现富文本编辑器的选区操作');
console.log('使用 StaticRange 的场景:');
console.log('1. 只需要一次性读取范围内容');
console.log('2. 对性能有较高要求的批量操作');
console.log('3. 需要在 Web Worker 中处理范围数据');
console.log('4. 创建后不再需要修改范围');
}
compareRangeAndStaticRange();
performanceComparison();
selectionGuidelines();
Range 适合需要与用户交互、动态更新的场景。StaticRange 则更适合一次性读取、对性能敏感的场景。StaticRange 的另一个优势是可以在 Web Worker 中使用,而 Range 依赖 DOM 环境。
四、创建和操作范围的完整示例
虽然 AbstractRange 本身不提供操作方法,但通过 Range 接口我们可以执行创建、修改、删除等各种范围操作。
javascript
// 创建范围的各种方式
function createRangesDemos() {
const container = document.createElement('div');
container.innerHTML = `
<div id="demoArea">
<h2>标题内容</h2>
<p>这是第一个段落,包含<strong>加粗文字</strong>和普通文字。</p>
<p>第二个段落,<em>斜体文字</em>在这里。</p>
<ul>
<li>列表项一</li>
<li>列表项二</li>
</ul>
</div>
`;
document.body.appendChild(container);
const demoArea = document.getElementById('demoArea');
const h2 = demoArea.querySelector('h2');
const firstP = demoArea.querySelector('p');
const strong = firstP.querySelector('strong');
const strongText = strong.firstChild;
const pTextNode = firstP.childNodes[1]; // 第一个段落中的文本节点
// 方式1:使用 setStart 和 setEnd
const range1 = document.createRange();
range1.setStart(strongText, 0);
range1.setEnd(strongText, 2);
console.log('范围1(选中"加粗"中的"加"字):', range1.toString()); // "加粗" 的前两个字符
// 方式2:使用 setStartBefore 和 setEndAfter
const range2 = document.createRange();
range2.setStartBefore(strong);
range2.setEndAfter(strong);
console.log('范围2(整个strong元素):', range2.toString()); // "加粗文字"
// 方式3:使用 selectNode 和 selectNodeContents
const range3 = document.createRange();
range3.selectNode(firstP);
console.log('范围3(整个段落元素):');
console.log('起始容器:', range3.startContainer.nodeName);
console.log('结束容器:', range3.endContainer.nodeName);
const range4 = document.createRange();
range4.selectNodeContents(firstP);
console.log('范围4(段落内容,不包括标签):', range4.toString());
// 方式4:使用 setStartAfter 和 setEndBefore
const range5 = document.createRange();
range5.setStartAfter(h2);
range5.setEndBefore(demoArea.querySelector('ul'));
console.log('范围5(从标题后到列表前):', range5.toString());
// 获取范围中选中的文本
function getSelectedText() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
return selection.getRangeAt(0).toString();
}
return '';
}
container.remove();
}
// 修改范围的操作
function modifyRangeDemo() {
const container = document.createElement('div');
container.innerHTML = '<p id="modifyTest">这是一段测试文本,用于演示范围修改。</p>';
document.body.appendChild(container);
const p = document.getElementById('modifyTest');
const textNode = p.firstChild;
let range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, 6);
console.log('原始范围:', range.toString()); // "这是一段测试"
// 修改起始位置
range.setStart(textNode, 3);
console.log('修改起始位置后:', range.toString()); // "段测试"
// 修改结束位置
range.setEnd(textNode, 6);
console.log('修改结束位置后:', range.toString()); // "段测"
// 折叠范围(将端点对齐)
range.collapse(true); // true 表示折叠到起点
console.log('折叠后是否 collapsed:', range.collapsed); // true
console.log('折叠后的位置:', range.startOffset); // 3
// 重新设置范围
range.setStart(textNode, 0);
range.setEnd(textNode, 10);
// 删除范围内容
const beforeDelete = range.toString();
range.deleteContents();
console.log('删除内容后剩余:', p.textContent);
// 从范围创建新的 DOM 节点
const newRange = document.createRange();
newRange.setStart(textNode, 0);
newRange.setEnd(textNode, 2);
const fragment = newRange.cloneContents();
console.log('克隆的内容:', fragment.textContent);
container.remove();
}
createRangesDemos();
modifyRangeDemo();
掌握这些创建和修改范围的方法,可以实现文本高亮、部分内容提取、智能粘贴等高级功能。
五、跨节点范围的深入理解
范围的一个重要特性是可以跨越多个节点,甚至跨越不同的层级。理解 DOM 树结构对于正确创建复杂范围至关重要。
javascript
// 跨节点范围的高级示例
function crossNodeRangeDemo() {
// 构建复杂的 DOM 结构
const container = document.createElement('div');
container.className = 'article';
container.innerHTML = `
<section>
<h2>第一章:<span>引言</span></h2>
<p>这是一个<strong>非常重要</strong>的概念,需要<em>深入理解</em>。</p>
<p>第二段内容,包含<a href="#">链接</a>和普通文本。</p>
</section>
`;
document.body.appendChild(container);
const section = container.querySelector('section');
const h2 = section.querySelector('h2');
const h2TextNode = h2.firstChild; // "第一章:" 文本节点
const span = section.querySelector('span');
const spanTextNode = span.firstChild; // "引言" 文本节点
const firstP = section.querySelector('p');
const strong = firstP.querySelector('strong');
const strongTextNode = strong.firstChild; // "非常重要"
const em = firstP.querySelector('em');
const emTextNode = em.firstChild; // "深入理解"
// 创建一个从 "章:" 之后到 "理解" 之前的范围
const complexRange = document.createRange();
complexRange.setStart(h2TextNode, 2); // "第一章:" 中,从索引2之后("章"之后)
complexRange.setEnd(emTextNode, 2); // "深入理解" 中,只取前两个字
console.log('跨节点范围的内容:', complexRange.toString());
// 预期输出包含: "引言这是一个非常重要"
// 提取范围内容并查看结构
const fragment = complexRange.cloneContents();
// 分析提取出的文档片段结构
function analyzeFragment(fragment) {
const walker = document.createTreeWalker(
fragment,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
);
const nodes = [];
let node;
while (node = walker.nextNode()) {
nodes.push({
type: node.nodeType === 1 ? 'Element' : 'Text',
name: node.nodeName,
content: node.textContent?.substring(0, 20)
});
}
return nodes;
}
console.log('提取的片段结构:', analyzeFragment(fragment));
// 结果会显示为了保持结构完整性而自动补充的节点
// 演示范围折叠时的 DOM 树关系
function getCommonAncestor(range) {
let startNode = range.startContainer;
let endNode = range.endContainer;
while (startNode !== endNode) {
if (startNode.compareDocumentPosition(endNode) & Node.DOCUMENT_POSITION_FOLLOWING) {
startNode = startNode.parentNode;
} else {
endNode = endNode.parentNode;
}
}
return startNode;
}
const commonAncestor = getCommonAncestor(complexRange);
console.log('范围的公共祖先节点:', commonAncestor.nodeName); // 应该是 section 或 p
container.remove();
}
// 处理范围边界的实用函数
function rangeBoundaryUtilities() {
// 获取范围起始位置的前一个字符
function getPreviousCharacter(range) {
const container = range.startContainer;
const offset = range.startOffset;
if (container.nodeType === Node.TEXT_NODE && offset > 0) {
return container.textContent[offset - 1];
}
if (offset > 0) {
const previousNode = container.childNodes[offset - 1];
if (previousNode && previousNode.nodeType === Node.TEXT_NODE) {
return previousNode.textContent.slice(-1);
}
}
return null;
}
// 获取范围结束位置的后一个字符
function getNextCharacter(range) {
const container = range.endContainer;
const offset = range.endOffset;
if (container.nodeType === Node.TEXT_NODE && offset < container.length) {
return container.textContent[offset];
}
if (offset < container.childNodes.length) {
const nextNode = container.childNodes[offset];
if (nextNode && nextNode.nodeType === Node.TEXT_NODE) {
return nextNode.textContent[0];
}
}
return null;
}
// 判断范围是否完全在一个元素内
function isRangeWithinElement(range, element) {
return element.contains(range.startContainer) &&
element.contains(range.endContainer);
}
console.log('实用函数已定义,可以在实际选择中使用');
}
crossNodeRangeDemo();
rangeBoundaryUtilities();
理解范围在 DOM 树中的表现是掌握复杂范围操作的关键。当范围跨越节点边界时,提取的内容会自动补全必要的父节点结构以保持文档的完整性。
六、实际应用场景示例
范围 API 在实际开发中有众多应用场景,从简单的文本复制到复杂的富文本编辑器。
javascript
// 实现文本高亮功能
function textHighlighter() {
class TextHighlighter {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.highlights = [];
}
highlightCurrentSelection(className = 'highlight') {
const selection = window.getSelection();
if (selection.isCollapsed) {
console.log('没有选中任何文本');
return null;
}
const range = selection.getRangeAt(0);
// 创建一个高亮 span
const span = document.createElement('span');
span.className = className;
// 用高亮 span 包裹选中的内容
range.surroundContents(span);
// 保存高亮信息
const highlightInfo = {
element: span,
text: range.toString(),
timestamp: Date.now()
};
this.highlights.push(highlightInfo);
// 清除选区
selection.removeAllRanges();
return highlightInfo;
}
removeAllHighlights() {
this.highlights.forEach(highlight => {
const parent = highlight.element.parentNode;
while (highlight.element.firstChild) {
parent.insertBefore(highlight.element.firstChild, highlight.element);
}
parent.removeChild(highlight.element);
});
this.highlights = [];
}
extractHighlightedText() {
return this.highlights.map(h => h.text);
}
}
// 创建测试界面
const container = document.createElement('div');
container.id = 'highlighterDemo';
container.innerHTML = `
<div style="border:1px solid #ccc; padding:10px; margin:10px">
<p>这是一段可以高亮的文本。你可以选中任意文字,然后点击高亮按钮。</p>
<p>第二个段落同样支持高亮功能,<strong>加粗文字</strong>也可以被高亮。</p>
<button id="highlightBtn">高亮选中文本</button>
<button id="clearBtn">清除所有高亮</button>
<button id="extractBtn">提取高亮文本</button>
<div id="extractedResult" style="margin-top:10px; background:#f0f0f0"></div>
</div>
`;
document.body.appendChild(container);
const highlighter = new TextHighlighter('highlighterDemo');
// 注意:需要使用实际的 container 元素,这里简化演示逻辑
document.getElementById('highlightBtn')?.addEventListener('click', () => {
const style = document.createElement('style');
style.textContent = '.highlight { background-color: yellow; }';
if (!document.querySelector('style[data-highlight]')) {
style.setAttribute('data-highlight', 'true');
document.head.appendChild(style);
}
const selection = window.getSelection();
if (!selection.isCollapsed) {
const range = selection.getRangeAt(0);
const span = document.createElement('span');
span.className = 'highlight';
try {
range.surroundContents(span);
selection.removeAllRanges();
console.log('高亮成功');
} catch (e) {
console.log('无法高亮跨元素的选择:', e.message);
}
}
});
}
// 实现文本锚点链接(滚动到指定位置)
function textAnchorDemo() {
class TextAnchor {
static createAnchorFromSelection(anchorId) {
const selection = window.getSelection();
if (selection.isCollapsed) {
throw new Error('请先选中要锚定的文本');
}
const range = selection.getRangeAt(0);
// 创建锚点元素
const anchor = document.createElement('span');
anchor.id = anchorId;
anchor.className = 'text-anchor';
try {
range.surroundContents(anchor);
return anchorId;
} catch (e) {
// 如果选中内容跨节点,使用备用方案
const fragment = range.cloneContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
anchor.textContent = tempDiv.textContent;
range.deleteContents();
range.insertNode(anchor);
return anchorId;
}
}
static scrollToAnchor(anchorId, behavior = 'smooth') {
const anchor = document.getElementById(anchorId);
if (anchor) {
anchor.scrollIntoView({ behavior, block: 'center' });
// 添加临时高亮效果
anchor.style.transition = 'background-color 0.5s';
anchor.style.backgroundColor = '#ffff99';
setTimeout(() => {
anchor.style.backgroundColor = '';
}, 2000);
return true;
}
return false;
}
}
// 添加演示样式
const style = document.createElement('style');
style.textContent = '.text-anchor { display: inline-block; }';
document.head.appendChild(style);
console.log('TextAnchor 类已定义,可以使用 TextAnchor.createAnchorFromSelection() 创建锚点');
}
textHighlighter();
textAnchorDemo();
范围 API 是实现这些功能的基础。surroundContents 方法可以用新元素包裹范围内容,提取和插入操作可以实现复杂的文本处理功能。
七、浏览器兼容性与注意事项
AbstractRange 及其派生接口在现代浏览器中支持良好。Chrome 90、Firefox 69、Safari 14.1 及以上版本完全支持。
javascript
// 兼容性检测和降级方案
function rangeCompatibilityCheck() {
const compat = {
abstractRange: typeof AbstractRange !== 'undefined',
range: typeof Range !== 'undefined',
staticRange: typeof StaticRange !== 'undefined',
rangeMethods: {
surroundContents: false,
cloneContents: false,
deleteContents: false,
extractContents: false
}
};
// 检测 Range 方法
if (typeof Range !== 'undefined') {
const testRange = document.createRange();
compat.rangeMethods.surroundContents = typeof testRange.surroundContents === 'function';
compat.rangeMethods.cloneContents = typeof testRange.cloneContents === 'function';
compat.rangeMethods.deleteContents = typeof testRange.deleteContents === 'function';
compat.rangeMethods.extractContents = typeof testRange.extractContents === 'function';
}
console.log('兼容性检测结果:', compat);
return compat;
}
// 跨节点 surroundContents 的替代方案
function safeSurroundContents(range, wrapperElement) {
try {
// 尝试直接使用 surroundContents
range.surroundContents(wrapperElement);
return true;
} catch (e) {
if (e.name === 'InvalidStateError') {
console.log('范围跨越多个节点边界,使用替代方案');
// 替代方案:提取内容,删除原内容,插入包装后的内容
const fragment = range.extractContents();
wrapperElement.appendChild(fragment);
range.insertNode(wrapperElement);
return true;
}
throw e;
}
}
// 创建 StaticRange 时的注意事项
function createStaticRangeSafely(startContainer, startOffset, endContainer, endOffset) {
// 验证参数有效性
function isValidOffset(node, offset) {
if (node.nodeType === Node.TEXT_NODE) {
return offset >= 0 && offset <= node.length;
}
return offset >= 0 && offset <= node.childNodes.length;
}
if (!isValidOffset(startContainer, startOffset) ||
!isValidOffset(endContainer, endOffset)) {
throw new Error('无效的范围偏移量');
}
// 检查 StaticRange 是否可用
if (typeof StaticRange !== 'undefined') {
return new StaticRange({
startContainer,
startOffset,
endContainer,
endOffset
});
}
// 降级方案:使用 Range 并冻结
const range = document.createRange();
range.setStart(startContainer, startOffset);
range.setEnd(endContainer, endOffset);
// 返回一个只读代理
return new Proxy(range, {
set(target, prop) {
if (prop === 'startContainer' || prop === 'endContainer' ||
prop === 'startOffset' || prop === 'endOffset') {
throw new Error('此范围是只读的');
}
target[prop] = arguments[1];
return true;
},
get(target, prop) {
const value = target[prop];
if (typeof value === 'function') {
if (prop === 'setStart' || prop === 'setEnd' || prop === 'setStartBefore' ||
prop === 'setEndBefore' || prop === 'setStartAfter' || prop === 'setEndAfter' ||
prop === 'selectNode' || prop === 'selectNodeContents') {
throw new Error('此范围是只读的');
}
return value.bind(target);
}
return value;
}
});
}
rangeCompatibilityCheck();
使用范围 API 时需要注意:处理文本节点时偏移量不能超过节点长度;surroundContents 方法对跨节点范围有限制;StaticRange 创建后完全不可变;删除范围内容后需要小心处理 DOM 引用。
想要解锁更多HTML 核心标签实战、前端零基础入门干货、开发避坑全指南吗?
持续关注,后续将更新CSS 布局实战、JavaScript 交互基础、全站导航开发等硬核内容,带你从新手快速进阶,轻松搞定前端开发!