浏览器提供了丰富的 API 来增强用户交互体验——拖放文件、获取位置、操作剪贴板、管理浏览历史等。这些能力让 Web 应用越来越接近原生体验。
🎯 Drag and Drop API#
原生拖放 API 让元素可以被拖动和放置。
使元素可拖动#
<div id="dragItem" draggable="true">拖动我</div>拖动事件#
| 事件 | 触发时机 | 作用对象 |
|---|---|---|
dragstart | 开始拖动 | 被拖动元素 |
drag | 拖动过程中持续触发 | 被拖动元素 |
dragend | 拖动结束 | 被拖动元素 |
dragenter | 进入放置目标 | 放置目标 |
dragover | 在放置目标上移动 | 放置目标 |
dragleave | 离开放置目标 | 放置目标 |
drop | 在放置目标上释放 | 放置目标 |
DataTransfer 对象#
通过 event.dataTransfer 在拖放之间传递数据:
// 开始拖动时设置数据element.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', 'Hello') e.dataTransfer.setData('application/json', JSON.stringify({ id: 1 }))
// 设置拖动效果 e.dataTransfer.effectAllowed = 'move' // copy, move, link, all})
// 放置时获取数据dropZone.addEventListener('drop', (e) => { e.preventDefault() const text = e.dataTransfer.getData('text/plain') const json = JSON.parse(e.dataTransfer.getData('application/json'))})完整拖放示例#
<style> .container { display: flex; gap: 2rem; } .drop-zone { width: 200px; height: 200px; border: 2px dashed #ccc; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .drop-zone.drag-over { border-color: #3b82f6; background: #eff6ff; } .drag-item { padding: 1rem 2rem; background: #3b82f6; color: white; cursor: grab; border-radius: 0.5rem; } .drag-item.dragging { opacity: 0.5; }</style>
<div class="container"> <div class="drop-zone" id="zone1"> <div class="drag-item" draggable="true" id="item1">拖动我</div> </div> <div class="drop-zone" id="zone2">放置区域</div></div>
<script> const item = document.getElementById('item1') const zones = document.querySelectorAll('.drop-zone')
// 拖动开始 item.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', e.target.id) e.dataTransfer.effectAllowed = 'move' e.target.classList.add('dragging') })
// 拖动结束 item.addEventListener('dragend', (e) => { e.target.classList.remove('dragging') })
// 放置区域事件 zones.forEach((zone) => { zone.addEventListener('dragover', (e) => { e.preventDefault() // 必须阻止默认行为才能触发 drop e.dataTransfer.dropEffect = 'move' })
zone.addEventListener('dragenter', (e) => { e.preventDefault() zone.classList.add('drag-over') })
zone.addEventListener('dragleave', () => { zone.classList.remove('drag-over') })
zone.addEventListener('drop', (e) => { e.preventDefault() zone.classList.remove('drag-over')
const itemId = e.dataTransfer.getData('text/plain') const draggedItem = document.getElementById(itemId) zone.appendChild(draggedItem) }) })</script>文件拖放上传#
<div id="dropZone" class="drop-zone">拖放文件到此处上传</div>
<script> const dropZone = document.getElementById('dropZone')
dropZone.addEventListener('dragover', (e) => { e.preventDefault() e.dataTransfer.dropEffect = 'copy' })
dropZone.addEventListener('drop', (e) => { e.preventDefault()
const files = e.dataTransfer.files
for (const file of files) { console.log('文件名:', file.name) console.log('类型:', file.type) console.log('大小:', file.size, 'bytes')
// 读取文件内容 const reader = new FileReader() reader.onload = () => { console.log('内容:', reader.result) } reader.readAsText(file) // 或 readAsDataURL } })
// 阻止页面默认的文件拖放行为 document.addEventListener('dragover', (e) => e.preventDefault()) document.addEventListener('drop', (e) => e.preventDefault())</script>Geolocation API#
获取用户的地理位置信息。
获取当前位置#
if ('geolocation' in navigator) { navigator.geolocation.getCurrentPosition( // 成功回调 (position) => { console.log('纬度:', position.coords.latitude) console.log('经度:', position.coords.longitude) console.log('精度:', position.coords.accuracy, '米') console.log('海拔:', position.coords.altitude) console.log('时间戳:', position.timestamp) }, // 错误回调 (error) => { switch (error.code) { case error.PERMISSION_DENIED: console.error('用户拒绝了定位请求') break case error.POSITION_UNAVAILABLE: console.error('位置信息不可用') break case error.TIMEOUT: console.error('请求超时') break } }, // 选项 { enableHighAccuracy: true, // 高精度(消耗更多电量) timeout: 10000, // 超时时间(毫秒) maximumAge: 60000, // 缓存有效期(毫秒) } )}持续监听位置#
const watchId = navigator.geolocation.watchPosition( (position) => { console.log( '位置更新:', position.coords.latitude, position.coords.longitude ) }, (error) => { console.error('定位失败:', error.message) }, { enableHighAccuracy: true })
// 停止监听navigator.geolocation.clearWatch(watchId)计算两点距离#
function calculateDistance(lat1, lon1, lat2, lon2) { const R = 6371 // 地球半径(公里) const dLat = ((lat2 - lat1) * Math.PI) / 180 const dLon = ((lon2 - lon1) * Math.PI) / 180
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) return R * c // 返回公里}
// 使用const distance = calculateDistance(39.9042, 116.4074, 31.2304, 121.4737)console.log(`北京到上海: ${distance.toFixed(2)} 公里`)Clipboard API#
现代剪贴板 API,支持异步操作和多种数据类型。
复制文本#
async function copyText(text) { try { await navigator.clipboard.writeText(text) console.log('复制成功') } catch (err) { console.error('复制失败:', err) }}
// 使用copyText('Hello, World!')读取文本#
async function pasteText() { try { const text = await navigator.clipboard.readText() console.log('粘贴内容:', text) return text } catch (err) { console.error('读取失败:', err) }}🔶 权限提示:读取剪贴板需要用户授权,且只能在安全上下文(HTTPS)中使用。
复制图片#
async function copyImage(imgElement) { const canvas = document.createElement('canvas') canvas.width = imgElement.naturalWidth canvas.height = imgElement.naturalHeight
const ctx = canvas.getContext('2d') ctx.drawImage(imgElement, 0, 0)
canvas.toBlob(async (blob) => { const item = new ClipboardItem({ 'image/png': blob }) await navigator.clipboard.write([item]) console.log('图片已复制') })}读取剪贴板内容(含图片)#
async function readClipboard() { const items = await navigator.clipboard.read()
for (const item of items) { for (const type of item.types) { const blob = await item.getType(type)
if (type.startsWith('image/')) { const url = URL.createObjectURL(blob) const img = document.createElement('img') img.src = url document.body.appendChild(img) } else if (type === 'text/plain') { const text = await blob.text() console.log('文本:', text) } } }}一键复制按钮#
<input type="text" id="linkInput" value="https://example.com" readonly /><button onclick="copyLink()">📋 复制链接</button>
<script> async function copyLink() { const input = document.getElementById('linkInput') try { await navigator.clipboard.writeText(input.value) alert('链接已复制!') } catch (err) { // 降级方案 input.select() document.execCommand('copy') alert('链接已复制!') } }</script>History API#
管理浏览器历史记录,实现 SPA 路由的核心 API。
基础导航#
// 后退history.back()
// 前进history.forward()
// 跳转指定步数history.go(-2) // 后退两步history.go(1) // 前进一步
// 历史记录长度console.log(history.length)添加历史记录#
// pushState 添加新记录history.pushState( { page: 'about' }, // state 对象(可序列化) '', // 标题(大多数浏览器忽略) '/about' // 新 URL(同源))
// replaceState 替换当前记录history.replaceState({ page: 'home' }, '', '/home')监听历史变化#
window.addEventListener('popstate', (event) => { console.log('URL 变化:', location.pathname) console.log('State:', event.state)
// 根据路由渲染对应内容 if (event.state) { renderPage(event.state.page) }})🔶 注意:pushState 和 replaceState 不会触发 popstate 事件,只有用户点击前进/后退按钮或调用 history.back()/history.forward() 才会触发。
简单 SPA 路由#
<nav> <a href="/" data-route>首页</a> <a href="/about" data-route>关于</a> <a href="/contact" data-route>联系</a></nav><main id="content"></main>
<script> const routes = { '/': '<h1>首页</h1><p>欢迎来到首页</p>', '/about': '<h1>关于</h1><p>关于我们的介绍</p>', '/contact': '<h1>联系</h1><p>联系方式</p>', }
function navigate(path) { const content = routes[path] || '<h1>404</h1><p>页面不存在</p>' document.getElementById('content').innerHTML = content }
// 拦截链接点击 document.querySelectorAll('a[data-route]').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault() const path = link.getAttribute('href') history.pushState({ path }, '', path) navigate(path) }) })
// 监听前进/后退 window.addEventListener('popstate', (event) => { navigate(location.pathname) })
// 初始加载 navigate(location.pathname)</script>Intersection Observer#
高效监测元素与视口的交叉状态,用于懒加载、无限滚动、动画触发等。
基础用法#
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { console.log('元素进入视口:', entry.target) } else { console.log('元素离开视口:', entry.target) } })})
// 开始观察observer.observe(document.querySelector('#target'))
// 停止观察observer.unobserve(element)
// 停止所有观察observer.disconnect()配置选项#
const observer = new IntersectionObserver(callback, { root: null, // 视口元素,默认浏览器视口 rootMargin: '0px', // 视口边距,支持 '10px 20px 30px 40px' threshold: 0, // 触发阈值,0-1 或数组 [0, 0.5, 1]})Entry 对象属性#
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { console.log(entry.target) // 被观察的元素 console.log(entry.isIntersecting) // 是否在视口内 console.log(entry.intersectionRatio) // 可见比例 0-1 console.log(entry.boundingClientRect) // 元素边界 console.log(entry.intersectionRect) // 交叉区域 console.log(entry.rootBounds) // 视口边界 })})🎯 实战:图片懒加载#
<style> .lazy-image { min-height: 300px; background: #f0f0f0; display: flex; align-items: center; justify-content: center; } .lazy-image img { opacity: 0; transition: opacity 0.3s; } .lazy-image img.loaded { opacity: 1; }</style>
<div class="lazy-image"> <img data-src="image1.jpg" alt="图片 1" /></div><div class="lazy-image"> <img data-src="image2.jpg" alt="图片 2" /></div><div class="lazy-image"> <img data-src="image3.jpg" alt="图片 3" /></div>
<script> const lazyImages = document.querySelectorAll('img[data-src]')
const imageObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target img.src = img.dataset.src img.onload = () => img.classList.add('loaded')
imageObserver.unobserve(img) // 加载后停止观察 } }) }, { rootMargin: '100px', // 提前 100px 开始加载 } )
lazyImages.forEach((img) => imageObserver.observe(img))</script>滚动动画触发#
<style> .animate-item { opacity: 0; transform: translateY(50px); transition: all 0.6s ease-out; } .animate-item.visible { opacity: 1; transform: translateY(0); }</style>
<div class="animate-item">内容 1</div><div class="animate-item">内容 2</div><div class="animate-item">内容 3</div>
<script> const animateItems = document.querySelectorAll('.animate-item')
const animateObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add('visible') } }) }, { threshold: 0.2, // 20% 可见时触发 } )
animateItems.forEach((item) => animateObserver.observe(item))</script>无限滚动#
<ul id="list"></ul><div id="sentinel">加载中...</div>
<script> let page = 1 const list = document.getElementById('list') const sentinel = document.getElementById('sentinel')
async function loadMore() { const response = await fetch(`/api/items?page=${page}`) const items = await response.json()
items.forEach((item) => { const li = document.createElement('li') li.textContent = item.name list.appendChild(li) })
page++ }
const scrollObserver = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { loadMore() } })
scrollObserver.observe(sentinel)
// 初始加载 loadMore()</script>其他实用 API#
Page Visibility API#
检测页面是否可见(用户是否在当前标签页):
document.addEventListener('visibilitychange', () => { if (document.hidden) { console.log('页面隐藏 - 暂停视频/动画') } else { console.log('页面可见 - 恢复播放') }})Fullscreen API#
// 进入全屏element.requestFullscreen()
// 退出全屏document.exitFullscreen()
// 检查全屏状态document.fullscreenElement
// 监听全屏变化document.addEventListener('fullscreenchange', () => { console.log('全屏状态:', !!document.fullscreenElement)})Screen Wake Lock API#
防止屏幕休眠(视频播放、导航应用):
let wakeLock = null
async function requestWakeLock() { try { wakeLock = await navigator.wakeLock.request('screen') console.log('屏幕保持唤醒')
wakeLock.addEventListener('release', () => { console.log('唤醒锁已释放') }) } catch (err) { console.error('无法获取唤醒锁:', err) }}
// 释放wakeLock?.release()常见问题#
🤔 拖放时 drop 事件不触发?#
必须在 dragover 事件中调用 e.preventDefault():
dropZone.addEventListener('dragover', (e) => { e.preventDefault() // 这一行是必须的!})🤔 Geolocation 在 HTTP 页面不工作?#
地理位置 API 只能在安全上下文(HTTPS 或 localhost)中使用。
🤔 Intersection Observer vs scroll 事件?#
Intersection Observer 性能更好:
- scroll 事件在主线程频繁触发,需要节流
- Intersection Observer 由浏览器异步处理,不阻塞主线程