Skip to content

FindElements Mixin 架构

FindElementsMixin 代表了 Pydoll 中的一个关键架构决策:使用组合优于继承TabWebElement 之间共享元素查找能力,而不通过公共基类耦合它们。本文档探讨 mixin 模式、其实现以及元素定位的内部机制。

实用使用指南

有关实际示例和使用模式,请参阅元素查找指南选择器指南

Mixin 模式:设计理念

什么是 Mixin?

Mixin 是一个旨在向其他类提供方法的类,而不是传统继承层次结构中的基类。与标准继承(建模"is-a"关系)不同,mixin 建模"can-do"能力

# 传统继承:"is-a"
class Animal:
    def breathe(self): ...

class Dog(Animal):  # Dog IS-A Animal(狗是一种动物)
    def bark(self): ...

# Mixin 模式:"can-do"
class FlyableMixin:
    def fly(self): ...

class Bird(Animal, FlyableMixin):  # Bird IS-A Animal, CAN fly(鸟是动物,能飞)
    pass

为什么使用 Mixin 而不是继承?

Pydoll 面临特定的架构挑战:

  • Tab 需要在文档上下文中查找元素
  • WebElement 需要相对于自身查找元素(子元素)
  • 两者都需要相同的选择器逻辑(CSS、XPath、属性构建)

选项 1:共享基类

class ElementLocator:
    def find(...): ...

class Tab(ElementLocator):
    pass

class WebElement(ElementLocator):
    pass

问题: - 紧耦合:TabWebElement 现在共享继承层次结构 - 违反单一职责:Tab 不应该从与 WebElement 相同的类继承 - 难以扩展:添加新功能需要修改基类

选项 2:Mixin 模式(选定方法)

class FindElementsMixin:
    def find(...): ...
    def query(...): ...

class Tab(FindElementsMixin):
    # Tab 特定逻辑
    pass

class WebElement(FindElementsMixin):
    # WebElement 特定逻辑
    pass

优点:

  • 解耦TabWebElement 保持独立
  • 可重用性:两个类中使用相同的元素查找逻辑
  • 可组合性:可以添加其他 mixin 而不会冲突
  • 可测试性:Mixin 可以单独测试

Mixin 特性

  1. 无状态:Mixin 不维护自己的状态(没有 __init__
  2. 依赖注入:假定使用类提供依赖项(例如 _connection_handler
  3. 单一目的:每个 mixin 提供一个内聚的能力
  4. 不可实例化:永远不要直接创建 FindElementsMixin()

Pydoll 中的 Mixin 实现

类结构

FindElementsMixin 使用依赖注入与提供 _connection_handler 的任何类一起工作:

class FindElementsMixin:
    """
    提供元素查找能力的 Mixin。

    假定使用类具有:
    - _connection_handler: 用于 CDP 命令的 ConnectionHandler 实例
    - _object_id: 用于上下文相对搜索的 Optional[str](仅 WebElement)
    """

    if TYPE_CHECKING:
        _connection_handler: ConnectionHandler  # 类型提示,不是实际属性

    async def find(self, ...):
        # 实现使用 self._connection_handler
        # 检查 self._object_id 以确定上下文

关键见解: Mixin 不定义 _connection_handler_object_id。它通过鸭子类型假定它们存在。

Tab 和 WebElement 如何使用 Mixin

# Tab:文档级搜索
class Tab(FindElementsMixin):
    def __init__(self, browser, target_id, connection_port):
        self._connection_handler = ConnectionHandler(connection_port)
        # 没有 _object_id → 从文档根开始搜索

# WebElement:元素相对搜索
class WebElement(FindElementsMixin):
    def __init__(self, object_id, connection_handler, ...):
        self._object_id = object_id  # CDP 对象 ID
        self._connection_handler = connection_handler
        # 有 _object_id → 相对于此元素搜索

关键区别:

  • Tabhasattr(self, '_object_id')False → 使用 RuntimeCommands.evaluate()(文档上下文)
  • WebElementhasattr(self, '_object_id')True → 使用 RuntimeCommands.call_function_on()(元素上下文)

上下文检测

Mixin 动态检测搜索上下文:

async def _find_element(self, by, value, raise_exc=True):
    if hasattr(self, '_object_id'):
        # 相对搜索:在此元素上调用 JavaScript 函数
        command = self._get_find_element_command(by, value, self._object_id)
    else:
        # 文档搜索:在全局上下文中评估 JavaScript
        command = self._get_find_element_command(by, value)

    response = await self._execute_command(command)
    # ...

这个单一实现处理两者:

  • tab.find(id='submit') → 搜索整个文档
  • form_element.find(id='submit') → 在 form_element 内搜索

Mixin 依赖耦合

Mixin 紧密耦合到 CDP 的对象模型。它假定:

  • 元素由 objectId 字符串表示
  • 文档搜索使用 Runtime.evaluate()
  • 元素相对搜索使用 Runtime.callFunctionOn()

这是可以接受的,因为 Pydoll 是 CDP 特定的。更通用的设计需要抽象层。

公共 API 设计

Mixin 暴露两个具有不同设计理念的高级方法:

find():基于属性的选择

@overload
async def find(self, find_all: Literal[False], ...) -> WebElement: ...

@overload
async def find(self, find_all: Literal[True], ...) -> list[WebElement]: ...

async def find(
    self,
    id: Optional[str] = None,
    class_name: Optional[str] = None,
    name: Optional[str] = None,
    tag_name: Optional[str] = None,
    text: Optional[str] = None,
    timeout: int = 0,
    find_all: bool = False,
    raise_exc: bool = True,
    **attributes,
) -> Union[WebElement, list[WebElement], None]:

设计决策:

  1. Kwargs 优于位置 By 枚举

    # Pydoll(直观)
    await tab.find(id='submit', class_name='primary')
    
    # Selenium(冗长)
    driver.find_element(By.ID, 'submit')  # 不容易组合属性
    

  2. 自动解析为最佳选择器

  3. 单个属性 → 使用 By.IDBy.CLASS_NAME 等(最快)
  4. 多个属性 → 构建 XPath(灵活但较慢)

  5. **attributes 用于扩展性

    await tab.find(data_testid='submit-btn', aria_label='Submit form')
    # 构建://\*[@data-testid='submit-btn' and @aria-label='Submit form']
    

query():基于表达式的选择

@overload
async def query(self, expression, find_all: Literal[False], ...) -> WebElement: ...

@overload
async def query(self, expression, find_all: Literal[True], ...) -> list[WebElement]: ...

async def query(
    self, 
    expression: str, 
    timeout: int = 0, 
    find_all: bool = False, 
    raise_exc: bool = True
) -> Union[WebElement, list[WebElement], None]:

设计决策:

  1. 自动检测 CSS vs XPath

    # XPath 检测(以 / 或 ./ 开头)
    await tab.query("//div[@id='content']")
    
    # CSS 检测(默认)
    await tab.query("div#content > p.intro")
    

  2. 单个表达式参数(与 find() 不同):

  3. 假定用户知道选择器语法
  4. 没有抽象开销

  5. 直接传递到浏览器

  6. CSS 使用 querySelector() / querySelectorAll()
  7. XPath 使用 document.evaluate()

类型安全的重载模式

两种方法都使用 @overload 提供精确的返回类型

# IDE 知道返回类型是 WebElement
element = await tab.find(id='submit')

# IDE 知道返回类型是 list[WebElement]
elements = await tab.find(class_name='item', find_all=True)

# IDE 知道返回类型是 Optional[WebElement]
maybe_element = await tab.find(id='optional', raise_exc=False)

这对于 IDE 自动完成和类型检查至关重要。有关详细信息,请参阅类型系统深入了解

选择器解析架构

Mixin 通过解析管道将用户输入转换为 CDP 命令:

阶段 输入 输出 关键决策
1. 方法选择 find() kwargs 或 query() 表达式 选择器策略 基于属性 vs 基于表达式
2. 策略解析 属性或表达式 By 枚举 + 值 单个属性 → 原生方法,多个 → XPath
3. 上下文检测 By + 值 + hasattr(_object_id) CDP 命令类型 文档 vs 元素相对搜索
4. 命令生成 CDP 命令类型 + 选择器 JavaScript + CDP 方法 evaluate() vs callFunctionOn()
5. 执行 CDP 命令 objectIdobjectId 数组 通过 ConnectionHandler
6. WebElement 创建 objectId + 属性 WebElement 实例 工厂函数避免循环导入

关键架构决策

1. 单个 vs 多个属性

# 单个属性 → 直接选择器(快速)
await tab.find(id='username')  # 使用 By.ID → getElementById()

# 多个属性 → XPath(灵活)
await tab.find(tag_name='input', type='password', name='pwd')
# → //input[@type='password' and @name='pwd']

为什么这很重要: - 原生方法(getElementByIdgetElementsByClassName)比 XPath 快 10-50% - 组合属性时 XPath 开销可接受(无替代方案)

2. 选择器类型的自动检测

await tab.query("//div")       # 以 / 开头 → XPath
await tab.query("#login")      # 默认 → CSS

实现:

if expression.startswith(('./', '/', '(/')):
    return By.XPATH
return By.CSS_SELECTOR

启发式是明确的 - CSS 选择器不能以 / 开头。

3. XPath 相对路径调整

对于元素相对搜索,绝对 XPath 必须转换:

# 用户提供://div
# 对于 WebElement:.//div(相对于元素,而不是文档)

def _ensure_relative_xpath(xpath):
    return f'.{xpath}' if not xpath.startswith('.') else xpath

没有这个,element.find() 将从文档根开始搜索。

CDP 命令生成

Mixin 根据搜索上下文路由到不同的 CDP 方法:

上下文 选择器类型 CDP 方法 JavaScript 等价
文档 CSS Runtime.evaluate document.querySelector()
文档 XPath Runtime.evaluate document.evaluate()
元素 CSS Runtime.callFunctionOn this.querySelector()
元素 XPath Runtime.callFunctionOn document.evaluate(..., this)

关键见解: Runtime.callFunctionOn 需要一个 objectId(要调用的元素),而 Runtime.evaluate 在全局范围内执行。

JavaScript 模板

Pydoll 使用预定义的模板以保持一致性和性能:

# CSS 选择器
Scripts.QUERY_SELECTOR = 'document.querySelector("{selector}")'
Scripts.RELATIVE_QUERY_SELECTOR = 'this.querySelector("{selector}")'

# XPath 表达式
Scripts.FIND_XPATH_ELEMENT = '''
    document.evaluate("{escaped_value}", document, null,
                      XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
'''

模板避免运行时字符串连接并集中 JavaScript 代码。

对象 ID 解析和 WebElement 创建

CDP 将 DOM 节点表示为 objectId 字符串。Mixin 抽象了这一点:

单个元素流程: 1. 执行 CDP 命令 → 从响应中提取 objectId 2. 调用 DOM.describeNode(objectId) → 获取属性、标签名 3. 创建 WebElement(objectId, connection_handler, attributes)

多个元素流程: 1. 执行 CDP 命令 → 返回作为单个远程对象的数组 2. 调用 Runtime.getProperties(array_objectId) → 枚举数组索引 3. 为每个元素提取单独的 objectId 4. 描述并为每个创建 WebElement

为什么使用 Runtime.getProperties CDP 不直接返回数组 - 它返回对数组对象的引用。我们必须枚举其属性以提取单个元素。

架构见解和设计权衡

为什么使用 Kwargs 而不是 By 枚举?

Pydoll 的选择:

await tab.find(id='submit', class_name='primary')

Selenium 的方法:

driver.find_element(By.ID, 'submit')  # 不能组合属性

理由:

  • 可发现性:IDE 自动完成显示所有可用参数
  • 可组合性:可以在一次调用中组合多个属性
  • 可读性id='submit'(By.ID, 'submit') 更直观

权衡: Kwargs 对选择器策略不够明确。通过文档和日志记录解决。

为什么自动检测 CSS vs XPath?

_get_expression_type() 启发式消除了用户负担:

await tab.query("//div")       # 自动:XPath
await tab.query("#login")      # 自动:CSS
await tab.query("div > p")     # 自动:CSS

优点:

  • 人体工程学:用户不需要指定选择器类型
  • 正确性:不可能误用(使用 CSS 方法的 XPath,反之亦然)

限制: 无法强制对模糊选择器进行 CSS 解释(罕见的边缘情况)。

防止循环导入:create_web_element()

Mixin 使用工厂函数来避免循环导入:

def create_web_element(*args, **kwargs):
    """在运行时动态导入 WebElement。"""
    from pydoll.elements.web_element import WebElement  # 延迟导入
    return WebElement(*args, **kwargs)

为什么需要?

  • FindElementsMixin → 需要创建 WebElement
  • WebElement → 从 FindElementsMixin 继承
  • 循环依赖!

解决方案: 工厂函数内的延迟导入。导入仅在调用函数时执行,打破循环。

hasattr() 进行上下文检测:优雅还是 Hacky?

Mixin 使用 hasattr(self, '_object_id') 检测 Tab vs WebElement:

if hasattr(self, '_object_id'):
    # WebElement:元素相对搜索
else:
    # Tab:文档级搜索

这是"hacky"吗?

  • :这是鸭子类型(Pythonic 习语)
  • Mixin 不需要知道类层次结构
  • Tab 和 WebElement 都提供 _connection_handler
  • WebElement 另外提供 _object_id

替代方法:

  1. 类型检查if isinstance(self, WebElement) → 将 mixin 耦合到 WebElement
  2. 抽象方法:要求 Tab/WebElement 实现 get_search_context() → 更多样板代码
  3. 依赖注入:将上下文作为参数传递 → 破坏 API 人体工程学

结论: hasattr() 是此用例的最佳解决方案。

关键要点

  1. Mixin 实现代码共享,而不通过继承耦合 TabWebElement
  2. 通过鸭子类型进行上下文检测hasattr)使 mixin 与类层次结构解耦
  3. 自动解析优化性能,通过对单个属性使用原生方法
  4. XPath 构建提供可组合性用于多属性查询
  5. 基于轮询的等待很简单,但以 CPU 周期换取实现简单性
  6. CDP 对象模型复杂性隐藏在 WebElement 抽象后面
  7. 通过重载实现类型安全为 IDE 支持提供精确的返回类型

相关文档

要更深入地了解相关架构组件:

有关实际使用模式: