29 KiB
定位器
何时使用:每次需要在页面上查找元素时。在考虑使用 CSS 或 XPath 之前,请从这里入手。 前置知识:core/configuration.md
快速参考
// 优先级顺序——优先使用第一个能生效的:
page.getByRole("button", { name: "Submit" }) // 1. 角色(默认)
page.getByLabel("Email address") // 2. 标签(表单字段)
page.getByText("Welcome back") // 3. 文本(非交互元素)
page.getByPlaceholder("Search...") // 4. 占位符
page.getByAltText("Company logo") // 5. 替代文本(图片)
page.getByTitle("Close dialog") // 6. Title 属性
page.getByTestId("checkout-summary") // 7. 测试 ID(最后一个语义选项)
page.locator("css=.legacy-widget >> internal:role=button") // 8. CSS/XPath(最后手段)
模式
基于角色的定位器(默认选择)
何时使用:始终如此。这是每个元素的起点。 何时避免:当元素没有 ARIA 角色,并且添加角色不在你的控制范围内时。
基于角色的定位器反映了辅助技术识别页面的方式。它们在重构、类名重命名和组件库替换后仍能正常工作。
TypeScript
import { test, expect } from "@playwright/test"
test("role-based locators cover most UI elements", async ({ page }) => {
await page.goto("/dashboard")
// 按钮——匹配 <button>、<input type="submit">、role="button"
await page.getByRole("button", { name: "Save changes" }).click()
// 链接——匹配 <a href>
await page.getByRole("link", { name: "View profile" }).click()
// 标题——使用 level 定位特定的 h1-h6
await expect(page.getByRole("heading", { name: "Dashboard", level: 1 })).toBeVisible()
// 文本输入框——通过可访问名称(标签关联)匹配
await page.getByRole("textbox", { name: "Email" }).fill("user@example.com")
// 复选框和单选按钮
await page.getByRole("checkbox", { name: "Remember me" }).check()
await page.getByRole("radio", { name: "Monthly billing" }).click()
// 下拉框——<select> 元素
await page.getByRole("combobox", { name: "Country" }).selectOption("US")
// 导航区域
const nav = page.getByRole("navigation", { name: "Main" })
await expect(nav.getByRole("link", { name: "Settings" })).toBeVisible()
// 表格
const table = page.getByRole("table", { name: "Recent orders" })
await expect(table.getByRole("row")).toHaveCount(5)
// 对话框
const dialog = page.getByRole("dialog", { name: "Confirm deletion" })
await dialog.getByRole("button", { name: "Delete" }).click()
// 精确匹配——防止 "Log" 匹配到 "Log out"
await page.getByRole("button", { name: "Log", exact: true }).click()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("role-based locators cover most UI elements", async ({ page }) => {
await page.goto("/dashboard")
await page.getByRole("button", { name: "Save changes" }).click()
await page.getByRole("link", { name: "View profile" }).click()
await expect(page.getByRole("heading", { name: "Dashboard", level: 1 })).toBeVisible()
await page.getByRole("textbox", { name: "Email" }).fill("user@example.com")
await page.getByRole("checkbox", { name: "Remember me" }).check()
await page.getByRole("radio", { name: "Monthly billing" }).click()
await page.getByRole("combobox", { name: "Country" }).selectOption("US")
const nav = page.getByRole("navigation", { name: "Main" })
await expect(nav.getByRole("link", { name: "Settings" })).toBeVisible()
const dialog = page.getByRole("dialog", { name: "Confirm deletion" })
await dialog.getByRole("button", { name: "Delete" }).click()
await page.getByRole("button", { name: "Log", exact: true }).click()
})
基于标签的定位器
何时使用:定位拥有关联 <label> 元素或 aria-label 的表单字段时。
何时避免:当元素不是表单控件时。改用 getByRole 配合可访问名称——它也能覆盖标签。
getByLabel 是表单字段的快捷方式。它匹配 <label for="">、包裹式 <label> 以及 aria-label / aria-labelledby。
TypeScript
import { test, expect } from "@playwright/test"
test("fill a registration form using labels", async ({ page }) => {
await page.goto("/register")
await page.getByLabel("First name").fill("Jane")
await page.getByLabel("Last name").fill("Doe")
await page.getByLabel("Email address").fill("jane@example.com")
await page.getByLabel("Password", { exact: true }).fill("s3cure!Pass")
await page.getByLabel("Confirm password").fill("s3cure!Pass")
await page.getByLabel("I agree to the terms").check()
await page.getByRole("button", { name: "Create account" }).click()
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("fill a registration form using labels", async ({ page }) => {
await page.goto("/register")
await page.getByLabel("First name").fill("Jane")
await page.getByLabel("Last name").fill("Doe")
await page.getByLabel("Email address").fill("jane@example.com")
await page.getByLabel("Password", { exact: true }).fill("s3cure!Pass")
await page.getByLabel("Confirm password").fill("s3cure!Pass")
await page.getByLabel("I agree to the terms").check()
await page.getByRole("button", { name: "Create account" }).click()
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible()
})
基于文本的定位器
何时使用:定位非交互内容——状态消息、段落、横幅、表单外部的标签。
何时避免:当元素是按钮、链接、标题或表单字段时。改用 getByRole。
TypeScript
import { test, expect } from "@playwright/test"
test("verify text content on the page", async ({ page }) => {
await page.goto("/order/confirmation")
// 子串匹配(默认)
await expect(page.getByText("Order confirmed")).toBeVisible()
// 精确匹配——当子串匹配到多个元素时使用
await expect(page.getByText("Order #12345", { exact: true })).toBeVisible()
// 正则匹配——用于动态内容模式
await expect(page.getByText(/Order #\d+/)).toBeVisible()
// 不要对按钮或链接使用 getByText:
// 错误:page.getByText('Submit')
// 正确:page.getByRole('button', { name: 'Submit' })
})
JavaScript
const { test, expect } = require("@playwright/test")
test("verify text content on the page", async ({ page }) => {
await page.goto("/order/confirmation")
await expect(page.getByText("Order confirmed")).toBeVisible()
await expect(page.getByText("Order #12345", { exact: true })).toBeVisible()
await expect(page.getByText(/Order #\d+/)).toBeVisible()
})
测试 ID 定位器
何时使用:当没有任何语义定位器可用时——元素没有可访问角色、标签或稳定的文本。常见于自定义 Canvas 渲染组件、复杂数据网格或第三方小部件。
何时避免:当任何语义定位器(getByRole、getByLabel、getByText)都能识别该元素时。测试 ID 对用户和辅助技术不可见。
在 playwright.config 中配置一次属性名:
TypeScript
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
use: {
testIdAttribute: "data-testid", // 默认值;根据你的代码库修改
},
})
import { test, expect } from "@playwright/test"
test("interact with a custom widget using test IDs", async ({ page }) => {
await page.goto("/analytics")
// 仅在图表组件没有暴露任何可访问角色时使用
const chart = page.getByTestId("revenue-chart")
await expect(chart).toBeVisible()
await chart.click({ position: { x: 150, y: 75 } })
await expect(page.getByTestId("chart-tooltip")).toContainText("$12,400")
})
JavaScript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
use: {
testIdAttribute: "data-testid",
},
})
const { test, expect } = require("@playwright/test")
test("interact with a custom widget using test IDs", async ({ page }) => {
await page.goto("/analytics")
const chart = page.getByTestId("revenue-chart")
await expect(chart).toBeVisible()
await chart.click({ position: { x: 150, y: 75 } })
await expect(page.getByTestId("chart-tooltip")).toContainText("$12,400")
})
CSS/XPath——最后手段
何时使用:当你对标记完全没有任何控制权,没有测试 ID,没有可访问名称,也无法添加它们时。适用于使用生成类名且无语义 HTML 的遗留应用。 何时避免:当任何其他定位器类型能生效时。始终如此。
TypeScript
import { test, expect } from "@playwright/test"
test("legacy app with no semantic markup", async ({ page }) => {
await page.goto("/legacy-admin")
// CSS——优先使用简短、结构化的选择器,而非脆弱的类名链
await page.locator('table.report-grid td:has-text("Overdue")').first().click()
// XPath——仅在 CSS 无法表达查询时使用(例如,文本结合祖先遍历)
await page.locator('xpath=//td[contains(text(),"Overdue")]/ancestor::tr//button').click()
// 结合 CSS 与 Playwright 伪选择器以增强鲁棒性
await page.locator('.sidebar >> role=button[name="Expand"]').click()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("legacy app with no semantic markup", async ({ page }) => {
await page.goto("/legacy-admin")
await page.locator('table.report-grid td:has-text("Overdue")').first().click()
await page.locator('xpath=//td[contains(text(),"Overdue")]/ancestor::tr//button').click()
await page.locator('.sidebar >> role=button[name="Expand"]').click()
})
定位器链式调用与过滤
何时使用:当单个定位器匹配到多个元素,你需要通过上下文、内容或位置来缩小范围时。
何时避免:当直接使用 getByRole 配合 name 已经能唯一标识元素时。
TypeScript
import { test, expect } from "@playwright/test"
test("chaining and filtering locators", async ({ page }) => {
await page.goto("/products")
// 链式调用:将一个定位器的作用域限定在另一个之内
const productCard = page.getByRole("listitem").filter({ hasText: "Running Shoes" })
await productCard.getByRole("button", { name: "Add to cart" }).click()
// 通过子定位器过滤——比 hasText 更精确
const row = page.getByRole("row").filter({
has: page.getByRole("cell", { name: "Premium Plan" }),
})
await row.getByRole("button", { name: "Upgrade" }).click()
// 使用 hasNot 过滤——排除包含某个子元素的元素
const availableItems = page.getByRole("listitem").filter({
hasNot: page.getByText("Sold out"),
})
await expect(availableItems).toHaveCount(3)
// 使用 hasNotText 过滤——按文本内容排除
const nonFeatured = page.getByRole("listitem").filter({
hasNotText: "Featured",
})
// 位置定位:nth、first、last——谨慎使用,仅在顺序稳定时使用
const thirdItem = page.getByRole("listitem").nth(2) // 从 0 开始索引
const firstItem = page.getByRole("listitem").first()
const lastItem = page.getByRole("listitem").last()
// 组合多个过滤器
const activeAdminRow = page
.getByRole("row")
.filter({ has: page.getByRole("cell", { name: "Admin" }) })
.filter({ has: page.getByText("Active") })
await expect(activeAdminRow).toHaveCount(1)
})
JavaScript
const { test, expect } = require("@playwright/test")
test("chaining and filtering locators", async ({ page }) => {
await page.goto("/products")
const productCard = page.getByRole("listitem").filter({ hasText: "Running Shoes" })
await productCard.getByRole("button", { name: "Add to cart" }).click()
const row = page.getByRole("row").filter({
has: page.getByRole("cell", { name: "Premium Plan" }),
})
await row.getByRole("button", { name: "Upgrade" }).click()
const availableItems = page.getByRole("listitem").filter({
hasNot: page.getByText("Sold out"),
})
await expect(availableItems).toHaveCount(3)
const thirdItem = page.getByRole("listitem").nth(2)
const firstItem = page.getByRole("listitem").first()
const lastItem = page.getByRole("listitem").last()
const activeAdminRow = page
.getByRole("row")
.filter({ has: page.getByRole("cell", { name: "Admin" }) })
.filter({ has: page.getByText("Active") })
await expect(activeAdminRow).toHaveCount(1)
})
框架定位器
何时使用:与 <iframe> 或 <frame> 元素内部的内容交互时——支付小部件、嵌入式编辑器、第三方小部件。
何时避免:当内容在主框架或 Shadow DOM 中时(改用穿透方式)。
TypeScript
import { test, expect } from "@playwright/test"
test("interact with content inside an iframe", async ({ page }) => {
await page.goto("/checkout")
// 定位 iframe,然后在其内部使用常规定位器
const paymentFrame = page.frameLocator('iframe[title="Payment"]')
await paymentFrame.getByLabel("Card number").fill("4242424242424242")
await paymentFrame.getByLabel("Expiration").fill("12/28")
await paymentFrame.getByLabel("CVC").fill("123")
await paymentFrame.getByRole("button", { name: "Pay" }).click()
// 嵌套 iframe——链式调用 frameLocator
const nestedFrame = page.frameLocator("#outer-frame").frameLocator("#inner-frame")
await expect(nestedFrame.getByText("Payment confirmed")).toBeVisible()
// 按第 N 个索引定位框架——当没有更好的选择器时
const secondFrame = page.frameLocator("iframe").nth(1)
await expect(secondFrame.getByRole("heading")).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("interact with content inside an iframe", async ({ page }) => {
await page.goto("/checkout")
const paymentFrame = page.frameLocator('iframe[title="Payment"]')
await paymentFrame.getByLabel("Card number").fill("4242424242424242")
await paymentFrame.getByLabel("Expiration").fill("12/28")
await paymentFrame.getByLabel("CVC").fill("123")
await paymentFrame.getByRole("button", { name: "Pay" }).click()
const nestedFrame = page.frameLocator("#outer-frame").frameLocator("#inner-frame")
await expect(nestedFrame.getByText("Payment confirmed")).toBeVisible()
const secondFrame = page.frameLocator("iframe").nth(1)
await expect(secondFrame.getByRole("heading")).toBeVisible()
})
Shadow DOM 穿透
何时使用:定位使用 Shadow DOM 的 Web 组件内部的元素时(自定义元素、设计系统组件、Salesforce Lightning)。 何时避免:当元素在常规 DOM 或 iframe 中时。
Playwright 使用 locator() 自动穿透开放的 Shadow DOM。getByRole / getByText 系列默认也能穿透 shadow root。
TypeScript
import { test, expect } from "@playwright/test"
test("interact with Shadow DOM elements", async ({ page }) => {
await page.goto("/design-system-demo")
// getByRole 自动穿透开放的 Shadow DOM——正常使用即可
await page.getByRole("button", { name: "Toggle menu" }).click()
// 使用 CSS 的 locator() 默认也能穿透 shadow root
await page.locator("my-dropdown").getByRole("option", { name: "Settings" }).click()
// 链式进入嵌套的 Shadow DOM
await page.locator("my-app").locator("my-sidebar").getByRole("link", { name: "Profile" }).click()
// 如果你明确需要非穿透行为(极少见),使用 css=light/
// await page.locator('css:light=.outer-only').click();
})
JavaScript
const { test, expect } = require("@playwright/test")
test("interact with Shadow DOM elements", async ({ page }) => {
await page.goto("/design-system-demo")
await page.getByRole("button", { name: "Toggle menu" }).click()
await page.locator("my-dropdown").getByRole("option", { name: "Settings" }).click()
await page.locator("my-app").locator("my-sidebar").getByRole("link", { name: "Profile" }).click()
})
动态内容——等待元素
何时使用:当元素在 API 调用、动画、懒加载或路由切换之后才出现时。 何时避免:当元素已经在页面上时。Playwright 对操作会自动等待,因此显式等待很少需要。
永远不要使用 page.waitForTimeout()。改用自动等待断言或显式事件等待。
TypeScript
import { test, expect } from "@playwright/test"
test("handle dynamic content without manual waits", async ({ page }) => {
await page.goto("/search")
// 自动等待:click()、fill() 等操作会自动等待元素出现
await page.getByRole("textbox", { name: "Search" }).fill("playwright")
await page.getByRole("button", { name: "Search" }).click()
// Web 优先断言:自动重试直到超时(默认 5 秒)
await expect(page.getByRole("listitem")).toHaveCount(10)
// 等待异步操作后出现特定元素
await expect(page.getByRole("heading", { name: "Results" })).toBeVisible()
// 等待加载指示器消失
await expect(page.getByRole("progressbar")).toBeHidden()
// 等待网络依赖内容:先等待响应,再断言
const responsePromise = page.waitForResponse("**/api/search*")
await page.getByRole("button", { name: "Load more" }).click()
await responsePromise
await expect(page.getByRole("listitem")).toHaveCount(20)
// 等待导航后的 URL 变化
await page.getByRole("link", { name: "First result" }).click()
await page.waitForURL("**/results/**")
await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("handle dynamic content without manual waits", async ({ page }) => {
await page.goto("/search")
await page.getByRole("textbox", { name: "Search" }).fill("playwright")
await page.getByRole("button", { name: "Search" }).click()
await expect(page.getByRole("listitem")).toHaveCount(10)
await expect(page.getByRole("heading", { name: "Results" })).toBeVisible()
await expect(page.getByRole("progressbar")).toBeHidden()
const responsePromise = page.waitForResponse("**/api/search*")
await page.getByRole("button", { name: "Load more" }).click()
await responsePromise
await expect(page.getByRole("listitem")).toHaveCount(20)
await page.getByRole("link", { name: "First result" }).click()
await page.waitForURL("**/results/**")
await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
})
决策指南
| 元素类型 | 推荐定位器 | 示例 | 原因 |
|---|---|---|---|
| 按钮 | getByRole('button', { name }) |
getByRole('button', { name: 'Submit' }) |
匹配 <button>、<input type="submit">、role="button" |
| 链接 | getByRole('link', { name }) |
getByRole('link', { name: 'Home' }) |
匹配任何 <a href>,无论样式如何 |
| 文本输入框 | getByRole('textbox', { name }) |
getByRole('textbox', { name: 'Email' }) |
通过可访问名称(标签)匹配 |
| 密码输入框 | getByLabel() |
getByLabel('Password') |
密码字段没有独特角色;标签是最佳匹配 |
| 复选框 | getByRole('checkbox', { name }) |
getByRole('checkbox', { name: 'Agree' }) |
同时推荐使用 .check() / .uncheck() 而非 .click() |
| 单选按钮 | getByRole('radio', { name }) |
getByRole('radio', { name: 'Express' }) |
使用 getByRole('radiogroup') 对单选按钮分组 |
| 选择框/下拉框 | getByRole('combobox', { name }) |
getByRole('combobox', { name: 'Country' }) |
原生 <select> 映射为 combobox 角色 |
| 自定义下拉框 | getByRole('listbox') + getByRole('option') |
点击触发器,然后 getByRole('option', { name }) |
自定义下拉框的 ARIA listbox 模式 |
| 标题 | getByRole('heading', { name, level }) |
getByRole('heading', { name: 'Dashboard', level: 2 }) |
使用 level 区分 h1-h6 |
| 表格 | getByRole('table', { name }) |
getByRole('table', { name: 'Users' }) |
配合 getByRole('row')、getByRole('cell') 链式使用 |
| 表格行 | getByRole('row').filter({ has }) |
.filter({ has: getByRole('cell', { name: 'Jane' }) }) |
通过单元格内容过滤行 |
| 导航 | getByRole('navigation', { name }) |
getByRole('navigation', { name: 'Main' }) |
匹配带 aria-label 的 <nav> |
| 对话框/弹窗 | getByRole('dialog', { name }) |
getByRole('dialog', { name: 'Confirm' }) |
将所有对话框交互限定在此范围内 |
| 标签页 | getByRole('tab', { name }) |
getByRole('tab', { name: 'Settings' }) |
配合 getByRole('tabpanel') 使用 |
| 图片 | getByAltText() |
getByAltText('User avatar') |
匹配 alt 属性 |
| 表单字段(任意) | getByLabel() |
getByLabel('Date of birth') |
当角色不明确时的备选方案(日期选择器、自定义输入框) |
| 静态文本 | getByText() |
getByText('No results found') |
仅限非交互内容 |
| 无语义标记 | getByTestId() |
getByTestId('sparkline-chart') |
使用 CSS 之前的最后一个语义选项 |
| iframe 内容 | frameLocator() 然后任意定位器 |
frameLocator('#payment').getByLabel('Card') |
跨框架访问时必需 |
| Shadow DOM | getByRole() / locator() |
自动生效 | Playwright 默认穿透开放的 shadow root |
反模式
| 不要这样做 | 问题 | 应该这样做 |
|---|---|---|
page.locator('.btn-primary') |
当 CSS 类名更改时失效(重命名、CSS Modules、Tailwind) | page.getByRole('button', { name: 'Save' }) |
page.locator('#submit-btn') |
ID 是实现细节;通常由工具自动生成 | page.getByRole('button', { name: 'Submit' }) |
page.locator('div > span:nth-child(3)') |
任何 DOM 结构调整都会导致失效 | page.getByText('Expected content') 或 getByTestId() |
page.locator('xpath=//div[@class="form"]//input[2]') |
脆弱、不可读、依赖位置 | page.getByLabel('Last name') |
对按钮使用 page.getByText('Submit') |
文本定位器不验证元素是否可交互 | page.getByRole('button', { name: 'Submit' }) |
在动态列表上使用 page.locator('.item').nth(0) |
当项目被添加/删除/重新排序时,索引会变化 | .filter({ hasText: 'Specific item' }) |
page.getByText('Aceptar') 硬编码国际化文本 |
当语言环境变化时失效 | page.getByRole('button', { name: /accept/i }) 或 getByTestId('confirm-btn') |
await page.waitForTimeout(3000) |
任意延迟;在快环境中太慢,在慢环境中太短 | await expect(locator).toBeVisible() |
page.locator('.card').locator('.card-title').locator('a') |
深度 CSS 链式调用在任何结构变更时都会失效 | page.getByRole('link', { name: 'Card title text' }) |
page.$('selector')(ElementHandle API) |
返回快照而非自动等待;已弃用的模式 | page.locator('selector')——定位器是惰性的且会自动等待 |
page.locator('text=Click here') |
遗留的文本选择器语法 | page.getByText('Click here') 或带 name 的 getByRole |
| 对一个元素依次使用多个定位器 | 链中的每次 locator() 调用都会重新开始搜索 |
保存为变量:const btn = page.getByRole('button', { name: 'Save' }) |
故障排除
"strict mode violation"——定位器匹配到多个元素
原因:定位器不够具体,Playwright 拒绝替你选择一个。
// 错误:locator.click: strict mode violation, getByRole('button') 解析到 5 个元素
await page.getByRole("button").click() // 太宽泛
// 修复 1:添加名称过滤器
await page.getByRole("button", { name: "Save" }).click()
// 修复 2:在父元素内限定范围
await page.getByRole("dialog").getByRole("button", { name: "Save" }).click()
// 修复 3:当名称之间存在子串关系时,使用精确匹配
await page.getByRole("button", { name: "Save", exact: true }).click()
// 修复 4:使用 filter 缩小范围
await page.getByRole("button").filter({ hasText: "Save draft" }).click()
// 调试:查看匹配到了什么
console.log(await page.getByRole("button").all()) // 列出所有匹配项
元素存在但定位器超时
原因:元素位于 iframe、Shadow DOM 内部,或被遮挡/隐藏。
// 检查元素是否在 iframe 内
const frame = page.frameLocator("iframe")
await frame.getByRole("button", { name: "Submit" }).click()
// 检查元素是否在 Shadow DOM 中——getByRole 自动穿透,
// 但 page.$() 和 CSS 选择器可能无法穿透。改用 getByRole。
// 检查元素是否隐藏(display: none、visibility: hidden、opacity: 0)
// 使用 toBeVisible() 确认,或如果隐藏是预期行为则使用 toBeAttached()
await expect(page.getByRole("button", { name: "Submit" })).toBeAttached()
getByRole 找不到元素
原因:元素的隐式 ARIA 角色与你预期的不符,或者它没有角色。
// 调试:检查可访问性树
const snapshot = await page.accessibility.snapshot()
console.log(JSON.stringify(snapshot, null, 2))
// 常见不匹配:
// - <div onclick="..."> 没有按钮角色 → 添加 role="button" 或使用 <button>
// - 没有标签的 <input type="text"> 没有可访问名称 → 添加 <label> 或 aria-label
// - 不带 href 的 <a> 没有链接角色 → 添加 href 或 role="link"
// 备选链:依次尝试 getByLabel → getByText → getByTestId → locator()
相关
- core/locator-strategy.md——在项目层面选择定位器策略的深度决策框架
- core/assertions-and-waiting.md——将定位器与 Web 优先断言配合使用
- pom/page-object-model.md——将定位器封装在页面对象中
- core/iframes-and-shadow-dom.md——高级 iframe 和 Shadow DOM 模式
- core/i18n-and-localization.md——面向国际化应用的定位器策略