指南和最佳实践

本指南概述了使用 @beforesemicolon/markup 编写声明式、干净且高性能的反应式应用程序的核心约定、代码设计模式和常见重构工作流程。


核心规则

  1. 优先选择声明式帮助程序而不是分支:避免在模板 HTML 字符串内编写内联 JavaScript 三元操作或命令式函数。始终青睐内置 Markup 帮助程序(whenrepeatpickis 等)。
  2. 将副作用排除在渲染函数之外:保持模板渲染严格无副作用。副作用应存在于 effect() 块或 Web Component 生命周期挂钩(例如 onMount)内。
  3. 状态不变性:不要直接改变用作规范状态的对象。单独跟踪派生/挂起的更改并仅在渲染时合并它们。
  4. 保留源集合:在派生过滤或排序视图时保持源数组完整。清除过滤器或搜索输入应恢复完整的源列表,而不需要手动数据重置。
  5. 反应式值绑定:直接绑定 props 和 state(例如 disabled="${isDisabled}" 而不是 disabled="${isDisabled()}"),让 Markup 通过手术设置侦听器。

重构工作流程

以下是如何将传统的命令式习惯迁移到干净的声明式 Markup 代码中。

1. 条件UI(if/else)

避免在模板内编写内联 JavaScript 条件或逻辑门。

必须/避免:

javascript
html`
    <div>
        ${() => (isLoading() ? html`<p>Loading...</p>` : html`<p>Loaded!</p>`)}
    </div>
`

声明式/首选:

javascript
html`
    <div>${when(isLoading, html`<p>Loading...</p>`, html`<p>Loaded!</p>`)}</div>
`

如果您打算渲染一次并且不希望数据更新,您可以直接使用三元:

javascript
html` <div>${isLoading ? html`<p>Loading...</p>` : html`<p>Loaded!</p>`}</div> `

2. 渲染列表

避免在模板内使用 .map() 生成动态列表节点。使用 .map() 会在每次更新时销毁并重建节点,而 repeat() 在幕后使用外科手术记忆。

必须/避免:

javascript
html`
    <ul>
        ${() => items().map((item) => html`<li>${item.name}</li>`)}
    </ul>
`

声明式/首选:

javascript
html`
    <ul>
        ${repeat(items, (item) => html`<li>${item.name}</li>`)}
    </ul>
`

如果您打算渲染一次并且/或不期望更新,则可以直接使用 map

javascript
html`
    <ul>
        ${list.map((item) => html`<li>${item.name}</li>`)}
    </ul>
`

3. 嵌套可选读取

避免直接在 UI 插值内使用嵌套可选链接 (?.)。如果链的某些部分未定义或未解析,这可能会导致运行时错误。

必须/避免:

javascript
html`
    <div>
        <h2>${() => user()?.profile?.details?.name || 'Guest'}</h2>
    </div>
`

声明式/首选:

javascript
html`
    <div>
        <h2>
            ${pick(user, 'profile.details.name', (name) => name || 'Guest')}
        </h2>
    </div>
`

选择选项允许您定义后备或处理格式化和/或其他处理的值。

javascript
const over18 = (age) => (age > 18 ? 'Over 18' : 'Under 18')

html`
    <div>
        <h2>${pick(user, 'profile.details.age', over18)}</h2>
    </div>
`

4. 布尔表达式组合

避免编写仅将多个状态与 &&|| 组合的自定义函数。使用 Markup 布尔组合器组合它们。

必须/避免:

javascript
const canPublish = () => !isSaving() && hasChanges() && hasPermission()

html` <button disabled="${() => !canPublish()}">Publish</button> `

声明式/首选:

javascript
const canPublish = and(isNot(isSaving), is(hasChanges), is(hasPermission))

html` <button disabled="${isNot(canPublish)}">Publish</button> `

Markup 邀请函数组合并使用有状态函数。您应该更多地了解如何创建自定义帮助程序 以了解更多信息。


规范模式

状态搜索和过滤列表

这是使用动态过滤渲染集合的标准模式。源状态 (items) 保持完全不可变。

typescript
import { html, state, when, repeat, is, pick } from '@beforesemicolon/markup'

const [query, setQuery] = state('')
const [items] = state<Project[]>([])

// Derive filtered list reactively
const filtered = () =>
    items().filter((p) => p.name.toLowerCase().includes(query().toLowerCase()))

const handleInput = (event: Event) => {
    setQuery((event.target as HTMLInputElement).value)
}

const View = html`
    <input value="${query}" oninput="${handleInput}" />
    <ul>
        ${repeat(
            filtered,
            (item) => html`<li>${item.name}</li>`,
            () => html`<p>No results found.</p>`
        )}
    </ul>
`

这允许状态保持不可变,并且您可以创建用于渲染的派生状态,组合不同的状态以解析为所需的状态。

异步老虎机(悬念)

使用 suspense 通过错误和回退(加载时)渲染处理程序干净地渲染异步 UI:

typescript
import { html, suspense } from '@beforesemicolon/markup'

const resource = async () => {
    const res = await fetch('/api/data')

    const data = await res.json()

    return html`<p>Resolved: ${data.message}</p>`
}

const ResourceView = html`
    ${suspense(
        resource,
        html`<p>Loading resource...</p>`,
        (err) => html`<p class="error">Error: ${err.message}</p>`
    )}
`

更常见的模式

您可以复制粘贴以下更典型的配方来满足常见的 UI 要求:

会员资格检查和选项交换

typescript
import { html, state, when, oneOf } from '@beforesemicolon/markup'

const [mode, setMode] = state<'view' | 'edit' | 'preview'>('view')

const View = html`
    ${when(
        oneOf(mode, ['edit', 'preview']),
        html`<button onclick="${() => setMode('view')}">Done</button>`,
        html`<button onclick="${() => setMode('edit')}">Edit</button>`
    )}
`

反应式 CSS 变量和样式

typescript
import { html, state } from '@beforesemicolon/markup'

const [gap] = state(12)

// Reactive style bindings cleared and updated dynamically
const Box = html`
    <div style="--gap: ${() => `${gap()}px`}; margin: ${gap}px">
        Spacing Gap: ${gap}px
    </div>
`

嵌套值渲染

typescript
import { html, state, pick } from '@beforesemicolon/markup'

const [currentEntity] = state({ details: { author: { name: 'Ada Lovelace' } } })

// Safe nested navigation via pick
const AuthorHeader = html`
    <h1>Written by: ${pick(currentEntity, 'details.author.name')}</h1>
`

共享状态存储

typescript
import { state } from '@beforesemicolon/markup'

export const [todos, setTodos] = state<Todo[]>([])
export const [loadingState, setLoadingState] = state<
    'idle' | 'loading' | 'error'
>('idle')

export const fetchTodos = async () => {
    setLoadingState('loading')
    try {
        const response = await fetch('/api/todos')
        const list = await response.json()
        setTodos(list)
        setLoadingState('idle')
    } catch {
        setLoadingState('error')
    }
}

会议及护栏

编辑本文档