✨ feat(vscode): 代码阅读的 React 钩子统一成一张钩子卡片,照签名排参数和返回,自定义钩子有自己的卡片

This commit is contained in:
cruldra committed 2026-10-16 20:18:21 +11:00
1 parent 78d0c9dada
commit e7059bcc73
17 files changed
+1409 -589

No files matched your search

+23 -12
View File
@@ -1,5 +1,5 @@
import type { Tree } from '@vscode/tree-sitter-wasm'
import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, NameBinding, Node, PreviewTable, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract'
import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, NameBinding, Node, PreviewTable, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, ThemeColor, TokenRole, Translator } from './contract'
import type { TreeSitterRuntime } from './runtime'
import type { ImportedName } from './symbolLinks'
import { blockAttributes } from './attributes'
@@ -38,6 +38,8 @@ interface BlockViewBase {
commentTarget?: CommentTarget
/** 库规则的卡片才有:筛选栏里这个种类的名字,卡片边框的颜色(RuleCard.badge)。图标在 concepts 里。预览同类块一样默认收起它的子块。 */
badge?: KindBadge
/** 库规则的卡片给了淡底的颜色才有([RuleCard.tint]):卡片的淡底用它,边框仍是 `badge` 的颜色。 */
tint?: ThemeColor
/** 库规则挂在父卡片子行上的子块才有(见 RuleSlot):展开时画在父卡片属性表第 row 行下面,不排在父卡片的子块里。 */
slot?: BlockSlot
/** 父卡片链式调用里收起来的闭包、async 块的子块才有:挂在那一步下面(见 ChainSlot),不排在父卡片的子块里。 */
@@ -47,8 +49,13 @@ interface BlockViewBase {
children: BlockView[]
}
/** 一块能看调用树:有名字的函数、类是 `{}`;讲 HTTP 接口的库规则卡片带上接口(RuleCard.endpoint),调用者里另外并上前端发这个请求的地方。 */
/**
* 一块能看调用树:有名字的函数、类是 `{}`,查块自己的名字([BlockViewBase.defines]);讲 HTTP 接口的库规则卡片带上接口([RuleCard.endpoint]),
* 调用者里另外并上前端发这个请求的地方;库规则卡片查的不是自己的名字时带上那个名字([RuleCard.calls])。
*/
export interface CallTreeTarget {
/** 查这个名字被谁调用(React 钩子卡片返回的 `setOpen`);不填就查块自己的名字 */
name?: SourceRange
endpoint?: HttpEndpoint
}
@@ -389,7 +396,8 @@ export function toBlockViews(
}
/**
* 这块的概念,从左到右,同一个 id 只留第一个:语言的种类(图标、名字用内置种类和翻译插件起的种类名),
* 语言插件另认的,库规则的卡片再加规则的种类和规则另认的。card 不填就是没有规则卡片(认成现成块的也算)。
* 语言插件另认的,库规则的卡片再加规则的种类和规则另认的。card 不填就是没有规则卡片(认成现成块的也算);
* 规则卡片说了不带语言的概念([RuleCard.languageConcepts])时从规则的种类画起。
*/
function blockConcepts(block: CodeBlock, card?: RuleCard): BlockConcept[] {
const seen = new Set<string>()
@@ -400,13 +408,15 @@ export function toBlockViews(
seen.add(concept.id)
concepts.push(concept)
}
add({
id: block.kind,
label: layerTranslator?.attributeNames?.values.kind?.[block.kind] ?? block.kind,
icon: KIND_ICONS[block.kind],
})
for (const concept of layerDocument.language.concepts?.(block) ?? [])
add(concept)
if (card?.languageConcepts !== false) {
add({
id: block.kind,
label: layerTranslator?.attributeNames?.values.kind?.[block.kind] ?? block.kind,
icon: KIND_ICONS[block.kind],
})
for (const concept of layerDocument.language.concepts?.(block) ?? [])
add(concept)
}
if (card) {
add({ id: card.kind, label: card.badge.label, icon: card.badge.icon, color: card.badge.color })
for (const concept of card.concepts ?? [])
@@ -498,7 +508,7 @@ export function toBlockViews(
const { attributes, comments } = block
return plain({ ...replacement.as, node: block.node, header: statement, children: kept, ...(attributes && { attributes }), ...(comments && { comments }) }, span, id, scope)
}
const { kind, badge, rows, defines, attributes, endpoint } = replacement
const { kind, badge, tint, rows, defines, attributes, endpoint, calls } = replacement
const card = cardRows(block, rows)
const places = chainPlaces(kept, card.properties)
for (const { block: child, row, item } of replacement.slots ?? [])
@@ -511,11 +521,12 @@ export function toBlockViews(
...spanLines(span),
headerEndLine: statement.endRow,
...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }),
...(defines && endpoint && { callTree: { endpoint } }),
...(calls ? { callTree: { name: { startIndex: calls.startIndex, endIndex: calls.endIndex } } } : defines && endpoint && { callTree: { endpoint } }),
attributes: { ...blockAttributes(block), ...attributes, kind },
concepts: blockConcepts(block, replacement),
...commentTarget(block),
badge,
...(tint && { tint }),
children: list(kept, `${id}.`, inner, 0, places),
...card,
}
+17 -2
View File
@@ -1043,7 +1043,8 @@ export interface ThemeColor { dark: string, light: string }
* 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 26 个给内置种类(几种共用一个:
* 命令、管道、执行共用 square-terminal,解释器、解析指令共用 terminal,选项、设置、编译属性共用 sliders-horizontal,字段、标签共用 tag),
* 宿主标的入口块也用这组里的 play(entries/entryMarks.ts 的 ENTRY_CONCEPT),
* 后面的给库规则的种类(React 的组件、状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出也在里面);库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标),
* 后面的给库规则的种类和规则另认的概念(React 的组件、钩子、钩子的七种作用、事件处理函数、渲染输出也在里面);库规则的种类(KindBadge.icon)、概念(BlockConcept.icon)只能从这组里挑,
* 要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标),
* 没有按任意名字找图标的路。
*/
export const KIND_ICON_NAMES = [
@@ -1084,9 +1085,11 @@ export const KIND_ICON_NAMES = [
'mouse-pointer-click',
'toggle-right',
'anchor',
'download',
'sigma',
'layout-template',
'fishing-hook',
'share-2',
'hourglass',
] as const
export type KindIconName = (typeof KIND_ICON_NAMES)[number]
@@ -1179,6 +1182,18 @@ export interface RuleCard {
* 不改筛选的种类,也不改卡片边框色(仍是 badge)。
*/
concepts?: BlockConcept[]
/**
* 填 `false` 时卡片左边不带语言认的概念(常量、语句这些种类的图标),从这个种类([RuleCard.badge])画起:
* React 钩子卡片左边第一个是钩子图标,不是它所在那句常量、语句的图标。不填就带上。
*/
languageConcepts?: false
/** 卡片的淡底用这个颜色,不填就用种类色([RuleCard.badge] 的颜色);边框仍是种类色。React 钩子卡片的描边都是钩子紫,淡底按钩子从哪来分。 */
tint?: ThemeColor
/**
* 卡片能看调用树,查的是源码里这个名字被谁调用:钩子卡片查返回的函数(`const [open, setOpen] = useState()` 的 `setOpen`),
* 自定义钩子的卡片查钩子自己。不填就没有调用树按钮(讲 HTTP 接口的卡片另见 [RuleCard.endpoint])。
*/
calls?: SourceRange
rows: PropertyRow[]
/** 块自己的名字(类名)在源码里的范围:跳到的定义就是它时闪整块。 */
defines?: SourceRange
+2 -1
View File
@@ -7,7 +7,7 @@ import type { FileAttribute } from '../attributes'
import type { BlockSlot, CallTreeTarget, ChainSlot, CommentTarget, SourceColors } from '../blockView'
import type { ContractMiss } from '../callTree/endpoint'
import type { CallTree } from '../callTree/model'
import type { BlockAttributes, BlockConcept, KindBadge, Markdown, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from '../contract'
import type { BlockAttributes, BlockConcept, KindBadge, Markdown, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange, ThemeColor } from '../contract'
import type { OpenedFrom } from '../diff/diffBase'
import type { DocumentDiff } from '../diff/previewDiff'
import type { ShownName } from '../nameResolution'
@@ -84,6 +84,7 @@ interface PageCardBase {
concepts: BlockConcept[]
commentTarget?: CommentTarget['kind']
badge?: KindBadge
tint?: ThemeColor
slot?: BlockSlot
step?: ChainSlot
callTree?: CallTreeTarget
@@ -4,9 +4,12 @@ import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { createPythonAdapter } from '../languages/python'
import { createRustAdapter } from '../languages/rust'
import { createTypeScriptAdapter } from '../languages/typescript'
import { reactRule } from '../rules/react'
import { WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { rustZhTranslator } from '../translators/rustZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { pageBlocks } from './pageBlocks'
const py = createPythonAdapter(WASM_DIR)
@@ -88,3 +91,16 @@ describe('链式调用卡片发给页面', () => {
expect(statement?.children[0]?.step).toEqual({ row: 1, step: 1 })
})
})
describe('react 钩子卡片发给页面', () => {
it('带着按来源分的淡底;调用树查的名字(返回的 setN)同别的下标从块第一行的行首算', async () => {
const ts = createTypeScriptAdapter(WASM_DIR)
const source = ['import { useState } from "react";', 'export function Box() {', ' const [n, setN] = useState(0);', ' return <i>{n}</i>;', '}'].join('\n')
const views = await withBlocks(ts, 'typescriptreact', source, blocks =>
toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator, { rules: [reactRule], report: () => {} }))
const hook = pageBlocks(views, source)[1].children[0]
expect(hook.kind).toBe('react.hook')
expect(hook.tint).toEqual({ dark: '#61dafb', light: '#0a7ea4' })
expect(hook.callTree).toEqual({ name: { startIndex: ' const [n, '.length, endIndex: ' const [n, setN'.length } })
})
})
+2 -1
View File
@@ -97,9 +97,10 @@ export function pageCard(block: BlockView, base: number): PageCard {
concepts: block.concepts,
...(block.commentTarget && { commentTarget: block.commentTarget.kind }),
...(block.badge && { badge: block.badge }),
...(block.tint && { tint: block.tint }),
...(block.slot && { slot: block.slot }),
...(block.step && { step: block.step }),
...(block.callTree && { callTree: block.callTree }),
...(block.callTree && { callTree: block.callTree.name ? { ...block.callTree, name: shiftRange(block.callTree.name, base) } : block.callTree }),
}
if ('properties' in block)
return { ...card, properties: block.properties.map(row => shiftRow(row, base)) }
@@ -0,0 +1,60 @@
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
const ThemeContext = createContext<Theme>('light')
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'add': return { ...state, count: state.count + 1 }
default: return state
}
}
export function useChatSession(id: string) {
const [messages, setMessages] = useState<Message[]>([])
useEffect(() => {
const ws = connect(id)
ws.onmessage = e => setMessages(m => [...m, e.data])
return () => ws.close()
}, [id])
return { messages }
}
export function Panel({ id, onClose }: Props) {
const router = useRouter()
const theme = useContext(ThemeContext)
const { messages } = useChatSession(id)
const [open, setOpen] = useState(() => readOpen(id))
const [state, dispatch] = useReducer(reducer, { count: 0 })
const [isPending, startTransition] = useTransition()
const boxRef = useRef<HTMLDivElement>(null)
const timer = useRef<number>()
const total = useMemo(() => {
const sum = messages.length
return sum * 2
}, [messages])
const toggle = useCallback((next: boolean) => {
setOpen(next)
dispatch({ type: 'add' })
}, [])
useEffect(() => {
timer.current = window.setTimeout(() => setOpen(false), 1000)
return () => clearTimeout(timer.current)
}, [open])
useEffect(() => {
document.title = String(total)
})
useLayoutEffect(() => {
boxRef.current?.scrollTo(0, 0)
}, [])
function handleClick() {
startTransition(() => toggle(!open))
}
return (
<div ref={boxRef} onClick={handleClick}>
{isPending && <Spinner />}
{messages.map(m => <Row key={m.id} m={m} />)}
{state.count}
</div>
)
}
+436 -87
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { diffBlockTrees } from '../diff/blockDiff'
import { createTypeScriptAdapter } from '../languages/typescript'
import { inlineOf, previewText, propertyLines, readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { inlineOf, previewText, propertyLines, propertyRow, readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { reactRule } from './react'
@@ -17,8 +17,18 @@ const SHARE = readFixture('shareButton.tsx.txt')
const SETTINGS = readFixture('desktopSettingsDialog.tsx.txt')
const PLAN = readFixture('planChecklist.tsx.txt')
const QUEUED = readFixture('queuedBubble.tsx.txt')
const HOOKS = readFixture('hooksPanel.tsx.txt')
const DATA = { dark: '#9cdcfe', light: '#001080' }
/** 钩子卡片的描边、钩子图标:所有钩子一样的紫 */
const HOOK = { dark: '#b180d7', light: '#7f3fa6' }
/** 钩子卡片的淡底按钩子从哪来分 */
const TINT = {
react: { dark: '#61dafb', light: '#0a7ea4' },
library: { dark: '#e5c07b', light: '#9a6a00' },
own: { dark: '#89d185', light: '#388a34' },
}
const HOOK_BADGE = { label: '钩子', color: HOOK, icon: 'fishing-hook' }
const HOOK_CONCEPT = { id: 'react.hook', label: '钩子', icon: 'fishing-hook', color: HOOK }
/** 规则查名字的定义时查到的值(名字原文 → IndexEntry.value),别的没查到。 */
function known(values: Record<string, string>): RuleContext['lookup'] {
@@ -90,12 +100,12 @@ describe('react 库规则:认领', () => {
'构造参数: 1 个',
' platform | DesktopShell["platform"] | — | 是',
])
expect(kinds(view)).toEqual(['react.source 13', 'react.source 14', 'variable 15', 'react.render 17'])
expect(kinds(view)).toEqual(['react.hook 13', 'react.hook 14', 'variable 15', 'react.render 17'])
expect(view.children.map(child => child.slot)).toEqual([undefined, undefined, undefined, undefined])
})
it('文件里没有组件时 "use client" 照常成块;组件上面没并进块头部的 JSDoc 讲成说明、不单独成块', async () => {
expect((await views('"use client";\nexport const useX = () => 1;')).map(view => view.kind)).toEqual(['statement', 'const'])
expect((await views('"use client";\nexport const useX = () => 1;')).map(view => view.kind)).toEqual(['statement', 'react.customHook'])
const source = ['import { memo } from "react";', '/** 一行标签 */', 'export const Label = memo(({ text }: { text: string }) => <span>{text}</span>);'].join('\n')
const blocks = await views(source)
expect(blocks.map(view => view.kind)).toEqual(['import', 'react.component'])
@@ -257,9 +267,9 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis
expect(properties(view).map(row => row.label)).toEqual(['名称', '说明', '构造参数'])
expect(rowLines(view, '构造参数')[0]).toBe('构造参数: 2 个 · 类型 PlanChecklistProps')
expect(kinds(view)).toEqual([
'react.state 35',
'react.effect 37',
'react.derived 47',
'react.hook 35',
'react.hook 37',
'react.hook 47',
'if 57',
'variable 58',
'variable 59',
@@ -270,26 +280,34 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis
])
})
it('状态:名称 / 初始值 / 更新位置,更新位置只列名字和位置,名字能跳到定义,不带函数体', async () => {
it('useState:钩子 / 参数 / 返回,返回表的名称能跳到定义,改状态的函数用函数色', async () => {
const state = childAt(await card(PLAN, 'PlanChecklist'), 35)
expect(state.badge).toEqual({ label: '状态', color: DATA, icon: 'toggle-right' })
expect(lines(state)).toEqual(['名称: collapsed', '初始值: 假', '更新位置: 副作用、toggle'])
const updates = properties(state)[2]
expect(updates.items).toBeUndefined()
const toggle = !('markdown' in updates.value) ? updates.value.find(piece => 'role' in piece && piece.text === 'toggle') : undefined
const offset = toggle && 'role' in toggle ? toggle.offset ?? -1 : -1
expect(PLAN.slice(offset, offset + 6)).toBe('toggle')
expect(PLAN.slice(0, offset).split('\n').length).toBe(47)
expect(state.badge).toEqual(HOOK_BADGE)
expect(lines(state)).toEqual([
'钩子: useState',
'参数: 1 个',
' initialState | 假',
'返回: 2 个',
' collapsed | 当前值',
' setCollapsed | 修改函数',
])
const returns = propertyRow(state, '返回')
expect(returns.columns).toEqual(['名称', '是什么'])
expect(returns.items?.map(item => item.defines && PLAN.slice(item.defines.startIndex, item.defines.endIndex))).toEqual(['collapsed', 'setCollapsed'])
expect(returns.items?.map(item => inlineOf(item.cells[0])[0])).toMatchObject([{ role: 'variable' }, { role: 'function' }])
expect(propertyRow(state, '参数').columns).toEqual(['参数', '值'])
expect(state.children).toEqual([])
})
it('副作用、useCallback 的派生值:只留各自的几行,函数里的 if / 调用 / 尝试是下面的子卡片', async () => {
it('useEffect、useCallback:函数参数收成小框,函数体照常是子卡片', async () => {
const view = await card(PLAN, 'PlanChecklist')
const effect = childAt(view, 37)
expect(effect.badge).toEqual({ label: '副作用', color: { dark: '#e5c07b', light: '#9a6a00' }, icon: 'zap' })
expect(viewLines([effect])).toEqual([
'react.effect 37',
' 触发时机: 仅挂载时',
'react.hook 37',
' 钩子: useEffect',
' 参数: 2 个',
' effect | ⟦函数|· 1 句⟧',
' deps | 仅挂载时',
' try 38: 尝试',
' if 39: 如果 (localStorage.getItem(PLAN_CHECKLIST_COLLAPSED_KEY) 全等 "1")',
' statement 40: queueMicrotask(() => setCollapsed(真));',
@@ -297,12 +315,14 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis
' 注释: localStorage 不可用 (隐私模式等) 时静默用默认',
])
const toggle = childAt(view, 47)
expect(toggle.badge).toEqual({ label: '派生值', color: DATA, icon: 'sigma' })
expect(viewLines([toggle])).toEqual([
'react.derived 47',
' 名称: toggle',
' 计算表达式: 函数(3 条语句)',
' 依赖项: collapsed(useCallback)',
'react.hook 47',
' 钩子: useCallback',
' 参数: 2 个',
' callback | ⟦函数|· 3 句⟧',
' deps | collapsed 变化时',
' 返回: 1 个',
' toggle | 记住的函数',
' variable 48',
' 名称: next',
' 初始值: collapsed 不成立',
@@ -331,23 +351,32 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis
})
describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」)', () => {
it('原地修改框 QueuedEditor:状态、引用、变量、副作用、两个事件处理函数、渲染输出照源码顺序,函数体是子卡片', async () => {
it('原地修改框 QueuedEditor:三个钩子、变量、两个事件处理函数、渲染输出照源码顺序,函数体是子卡片', async () => {
const view = await card(QUEUED, 'QueuedEditor')
expect(lines(view).slice(0, 1)).toEqual(['名称: QueuedEditor ⟦客户端组件|⟧'])
expect(viewLines(view.children)).toEqual([
'react.state 171',
' 名称: draft',
' 初始值: item.text',
' 更新位置: <textarea>(排队消息内容) 的 onChange',
'react.ref 172',
' 名称: textareaRef',
' 初始值: 空',
' 使用位置: <textarea>(排队消息内容) 的 ref 属性;副作用',
'react.hook 171',
' 钩子: useState',
' 参数: 1 个',
' initialState | item.text',
' 返回: 2 个',
' draft | 当前值',
' setDraft | 修改函数',
'react.hook 172',
' 钩子: useRef',
' 类型参数: HTMLTextAreaElement',
' 参数: 1 个',
' initialValue | 空',
' 返回: 1 个',
' textareaRef | 引用对象',
'variable 173',
' 名称: canSave',
' 初始值: draft.trim().length > 0 或者 item.attachments.length > 0',
'react.effect 175',
' 触发时机: 仅挂载时',
'react.hook 175',
' 钩子: useEffect',
' 参数: 2 个',
' effect | ⟦函数|· 4 句⟧',
' deps | 仅挂载时',
' variable 176',
' 名称: el',
' 初始值: textareaRef.current',
@@ -385,9 +414,9 @@ describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」
])
})
it('引用、事件处理函数的样子;名称能跳到定义', async () => {
it('钩子、事件处理函数的样子;名称能跳到定义', async () => {
const view = await card(QUEUED, 'QueuedEditor')
expect(childAt(view, 172).badge).toEqual({ label: '引用', color: DATA, icon: 'anchor' })
expect(childAt(view, 172).badge).toEqual(HOOK_BADGE)
const handler = childAt(view, 186)
expect(handler.badge).toEqual({ label: '事件处理函数', color: { dark: '#dcdcaa', light: '#795e26' }, icon: 'mouse-pointer-click' })
expect(QUEUED.slice(handler.defines?.startIndex, handler.defines?.endIndex)).toBe('handleKeyDown')
@@ -414,59 +443,362 @@ describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」
})
})
describe('react 库规则:数据源、派生值、副作用、事件处理函数的写法', () => {
it('数据源:库自带的钩子写说明,store 选择器写哪个 store 中的什么、基于哪个 store 计算什么,项目里的是自定义钩子', async () => {
const titlebar = await card(TITLEBAR, 'DesktopTitlebar')
expect(childAt(titlebar, 13).badge).toEqual({ label: '数据源', color: DATA, icon: 'download' })
expect(lines(childAt(titlebar, 13))).toEqual(['变量: pathname', '钩子: usePathname()', '说明: 当前路径'])
expect(lines(childAt(titlebar, 14))).toEqual(['变量: title', '钩子: useDesktopShell((s) => s.title)', '说明: desktop store 中的 title'])
const legal = await card(LEGAL, 'LegalDocPage')
expect(lines(childAt(legal, 58))).toEqual(['变量: cfg', '钩子: useSiteConfig()', '说明: 自定义钩子'])
expect(lines(childAt(legal, 59))).toEqual(['变量: router', '钩子: useRouter()', '说明: 路由器,用于页面跳转'])
expect(rowLines(childAt(await card(SETTINGS, 'WorkspaceRootRow'), 90), '说明')).toEqual(['说明: 基于 desktop store 计算 displayPath(s.workspace_root, s.home)'])
describe('react 库规则:钩子卡片(原型 react-hooks.js 的 Panel.tsx)', () => {
it('组件体里每处钩子调用都是一张「钩子」卡片:钩子 / 类型参数 / 参数 / 返回照签名排,没有的行不显示,函数体照常是子块', async () => {
const view = await card(HOOKS, 'Panel')
expect(kinds(view)).toEqual([
'react.hook 24',
'react.hook 25',
'react.hook 26',
'react.hook 27',
'react.hook 28',
'react.hook 29',
'react.hook 30',
'react.hook 31',
'react.hook 32',
'react.hook 36',
'react.hook 40',
'react.hook 44',
'react.hook 47',
'react.handler 50',
'react.render 53',
])
expect(viewLines(view.children.filter(child => child.kind === 'react.hook'))).toEqual([
'react.hook 24',
' 钩子: useRouter',
' 返回: 1 个',
' router | 路由器,用于页面跳转',
'react.hook 25',
' 钩子: useContext',
' 参数: 1 个',
' context | ThemeContext',
' 返回: 1 个',
' theme | 上下文的值',
'react.hook 26',
' 钩子: useChatSession',
' 参数: 1 个',
' id | id',
' 返回: 1 个',
' messages',
'react.hook 27',
' 钩子: useState',
' 参数: 1 个',
' initialState | 首次渲染时调用 readOpen(id)',
' 返回: 2 个',
' open | 当前值',
' setOpen | 修改函数',
'react.hook 28',
' 钩子: useReducer',
' 参数: 2 个',
' reducer | reducer',
' initialState | { count: 0 }',
' 返回: 2 个',
' state | 当前值',
' dispatch | 派发函数',
'react.hook 29',
' 钩子: useTransition',
' 返回: 2 个',
' isPending | 是否正在过渡',
' startTransition | 启动过渡的函数',
'react.hook 30',
' 钩子: useRef',
' 类型参数: HTMLDivElement',
' 参数: 1 个',
' initialValue | 空',
' 返回: 1 个',
' boxRef | 引用对象',
'react.hook 31',
' 钩子: useRef',
' 类型参数: 数值',
' 参数: 1 个',
' initialValue | 没写,未定义',
' 返回: 1 个',
' timer | 引用对象',
'react.hook 32',
' 钩子: useMemo',
' 参数: 2 个',
' factory | ⟦函数|· 2 句⟧',
' deps | messages 变化时',
' 返回: 1 个',
' total | 算出的值',
' variable 33',
' 名称: sum',
' 初始值: messages.length',
' return 34: 返回 sum * 2',
'react.hook 36',
' 钩子: useCallback',
' 参数: 2 个',
' callback | ⟦函数|(next: 布尔) · 2 句⟧',
' deps | 仅挂载时',
' 返回: 1 个',
' toggle | 记住的函数',
' statement 37: setOpen(next)',
' statement 38: dispatch({ type: \'add\' })',
'react.hook 40',
' 钩子: useEffect',
' 参数: 2 个',
' effect | ⟦函数|· 2 句⟧',
' deps | open 变化时',
' statement 41: timer.current = window.setTimeout(() => setOpen(假), 1000)',
' return 42: 返回 () => clearTimeout(timer.current)',
'react.hook 44',
' 钩子: useEffect',
' 参数: 2 个',
' effect | ⟦函数|· 1 句⟧',
' deps | 没写,每次渲染后',
' statement 45: document.title = String(total)',
'react.hook 47',
' 钩子: useLayoutEffect',
' 参数: 2 个',
' effect | ⟦函数|· 1 句⟧',
' deps | 仅挂载时',
' statement 48: boxRef.current?.scrollTo(0, 0)',
])
})
it('luffy 的 LegalDocPage:useMemo、渲染期间按条件重置写进更新位置、引用的使用位置、清理函数并进副作用、处理函数绑在每个按钮上,注释是讲的那张卡片的「注释」一行', async () => {
it('形参名照 @types/react 写;依赖项、惰性初始值、函数参数各有写法,引用别处定义的名字照抄、带源码下标', async () => {
const view = await card(HOOKS, 'Panel')
const params = (line: number): PropertyItem[] => propertyRow(childAt(view, line), '参数').items ?? []
const [reducer] = params(28)
expect(inlineOf(reducer.cells[1])).toEqual([{ text: 'reducer', role: 'function', offset: HOOKS.indexOf('reducer, {') }])
const [context] = params(25)
expect(inlineOf(context.cells[1])).toMatchObject([{ text: 'ThemeContext', offset: HOOKS.indexOf('ThemeContext)') }])
const [initial] = params(27)
expect(inlineOf(initial.cells[1]).slice(0, 2)).toEqual([{ text: '首次渲染时调用 ', role: 'plain' }, { text: 'readOpen', role: 'function', offset: HOOKS.indexOf('readOpen') }])
const [callback, deps] = params(36)
expect(inlineOf(callback.cells[1])).toMatchObject([{ style: 'fold', label: '函数' }])
expect(inlineOf(deps.cells[1])).toEqual([{ text: '仅挂载时', role: 'keyword' }])
expect(inlineOf(params(40)[1].cells[1])).toEqual([{ text: 'open', role: 'variable', offset: HOOKS.indexOf('open])') }, { text: ' ', role: 'plain' }, { text: '变化时', role: 'keyword' }])
expect(inlineOf(params(44)[1].cells[1])).toEqual([{ text: '没写,', role: 'hint' }, { text: '每次渲染后', role: 'keyword' }])
expect(inlineOf(params(31)[0].cells[1])).toEqual([{ text: '没写,', role: 'hint' }, { text: '未定义', role: 'storage' }])
})
it('返回:内置、第三方钩子是「名称 | 是什么」,自己写的只有名称;返回里的函数用函数色、名称能跳到定义', async () => {
const view = await card(HOOKS, 'Panel')
expect(propertyRow(childAt(view, 24), '返回').columns).toEqual(['名称', '是什么'])
expect(propertyRow(childAt(view, 27), '返回').columns).toEqual(['名称', '是什么'])
expect(propertyRow(childAt(view, 26), '返回').columns).toEqual(['名称'])
const transition = propertyRow(childAt(view, 29), '返回').items ?? []
expect(transition.map(item => inlineOf(item.cells[0]))).toEqual([
[{ text: 'isPending', role: 'variable', offset: HOOKS.indexOf('isPending') }],
[{ text: 'startTransition', role: 'function', offset: HOOKS.indexOf('startTransition') }],
])
expect(transition.map(item => item.defines && HOOKS.slice(item.defines.startIndex, item.defines.endIndex))).toEqual(['isPending', 'startTransition'])
expect(inlineOf((propertyRow(childAt(view, 36), '返回').items ?? [])[0].cells[0])).toMatchObject([{ text: 'toggle', role: 'function' }])
})
it('钩子卡片:描边、钩子图标都是钩子紫,淡底按来源分;作用图标只有 React 内置钩子有,useLayoutEffect 悬停写浏览器绘制前执行', async () => {
const view = await card(HOOKS, 'Panel')
const router = childAt(view, 24)
const context = childAt(view, 25)
const session = childAt(view, 26)
expect([router, context, session].map(item => item.badge)).toEqual([HOOK_BADGE, HOOK_BADGE, HOOK_BADGE])
expect([router, context, session].map(item => item.tint)).toEqual([TINT.library, TINT.react, TINT.own])
expect(router.concepts).toEqual([HOOK_CONCEPT])
expect(session.concepts).toEqual([HOOK_CONCEPT])
expect(context.concepts).toEqual([HOOK_CONCEPT, { id: 'react.hook.context', label: '上下文', icon: 'share-2', color: { dark: '#4ec9b0', light: '#267f99' } }])
expect([27, 30, 32, 29, 40].map(line => childAt(view, line).concepts[1])).toEqual([
{ id: 'react.hook.state', label: '状态', icon: 'toggle-right', color: { dark: '#9cdcfe', light: '#001080' } },
{ id: 'react.hook.ref', label: '引用', icon: 'anchor', color: { dark: '#4fc1ff', light: '#0070c1' } },
{ id: 'react.hook.performance', label: '性能', icon: 'sigma', color: { dark: '#b5cea8', light: '#098658' } },
{ id: 'react.hook.concurrent', label: '并发', icon: 'hourglass', color: { dark: '#ce9178', light: '#a31515' } },
{ id: 'react.hook.effect', label: '副作用', icon: 'zap', color: { dark: '#e5c07b', light: '#9a6a00' } },
])
expect(childAt(view, 47).concepts[1]).toMatchObject({ id: 'react.hook.effect', label: '副作用 · 浏览器绘制前执行' })
})
it('钩子名:自己写的、内置的带源码下标能跳到定义,第三方钩子悬停看来自哪个库', async () => {
const view = await card(HOOKS, 'Panel')
const hookName = (line: number) => inlineOf(propertyRow(childAt(view, line), '钩子').value)
expect(hookName(24)).toEqual([{ text: 'useRouter', role: 'function', offset: HOOKS.indexOf('useRouter()'), tooltip: '来自 next/navigation' }])
expect(hookName(26)).toEqual([{ text: 'useChatSession', role: 'function', offset: HOOKS.indexOf('useChatSession(id)') }])
expect(hookName(27)).toEqual([{ text: 'useState', role: 'function', offset: HOOKS.indexOf('useState(() =>') }])
})
it('调用树:返回里有函数的钩子卡片查那个函数,别的钩子卡片没有调用树按钮', async () => {
const view = await card(HOOKS, 'Panel')
const target = (line: number): string | undefined => {
const name = childAt(view, line).callTree?.name
return name && HOOKS.slice(name.startIndex, name.endIndex)
}
expect([27, 28, 29, 36].map(target)).toEqual(['setOpen', 'dispatch', 'startTransition', 'toggle'])
expect([24, 25, 26, 30, 31, 32, 40, 44, 47].map(line => childAt(view, line).callTree)).toEqual(Array.from({ length: 9 }, () => undefined))
})
})
describe('react 库规则:自定义钩子', () => {
it('function useXxx 是「自定义钩子」卡片:名称(带导出标签)/ 参数「名称 | 类型」/ 返回只列名称;钩子体里的钩子也认成钩子卡片', async () => {
const blocks = await views(HOOKS)
const hook = blocks.find(view => view.kind === 'react.customHook')
expect(hook && viewLines([hook])).toEqual([
'react.customHook 13',
' 名称: useChatSession ⟦具名导出|⟧',
' 参数: 1 个',
' id | 字符串',
' 返回: 1 个',
' messages',
' react.hook 14',
' 钩子: useState',
' 类型参数: Message[]',
' 参数: 1 个',
' initialState | 空数组',
' 返回: 2 个',
' messages | 当前值',
' setMessages | 修改函数',
' react.hook 15',
' 钩子: useEffect',
' 参数: 2 个',
' effect | ⟦函数|· 3 句⟧',
' deps | id 变化时',
' variable 16',
' 名称: ws',
' 初始值: connect(id)',
' statement 17: ws.onmessage = e => setMessages(m => [...m, e.data])',
' return 18: 返回 () => ws.close()',
' return 20: 返回 { messages }',
])
expect(hook?.badge).toEqual({ label: '自定义钩子', color: HOOK, icon: 'fishing-hook' })
expect(hook?.tint).toEqual(TINT.own)
expect(hook?.concepts).toEqual([{ id: 'react.customHook', label: '自定义钩子', icon: 'fishing-hook', color: HOOK }])
expect(propertyRow(hook, '参数').columns).toEqual(['名称', '类型'])
expect(propertyRow(hook, '返回').columns).toEqual(['名称'])
expect(HOOKS.slice(hook?.defines?.startIndex, hook?.defines?.endIndex)).toBe('useChatSession')
const calls = hook?.callTree?.name
expect(calls && HOOKS.slice(calls.startIndex, calls.endIndex)).toBe('useChatSession')
expect(childAt(hook as BlockView, 14).callTree?.name && HOOKS.slice(childAt(hook as BlockView, 14).callTree?.name?.startIndex, childAt(hook as BlockView, 14).callTree?.name?.endIndex)).toBe('setMessages')
})
it('const useXxx = () => … 也是自定义钩子:参数带默认值时多一列,返回读数组、对象简写和改名的键、标识符', async () => {
const source = [
'import { useCallback, useState } from "react";',
'export const useToggle = (initial = false) => {',
' const [on, setOn] = useState(initial);',
' const flip = useCallback(() => setOn(v => !v), []);',
' return [on, flip] as const;',
'};',
'function useLabel(text: string) {',
' const value = useToggle();',
' return { text, toggle: value };',
'}',
'const useNothing = () => {',
' useToggle();',
'};',
].join('\n')
const blocks = await views(source)
expect(blocks.map(view => `${view.kind} ${view.startLine + 1}`)).toEqual(['import 1', 'react.customHook 2', 'react.customHook 7', 'react.customHook 11'])
expect(lines(blocks[1])).toEqual(['名称: useToggle ⟦具名导出|⟧', '参数: 1 个', ' initial | 未标注 | 假', '返回: 2 个', ' on', ' flip'])
expect(propertyRow(blocks[1], '参数').columns).toEqual(['名称', '类型', '默认值'])
expect(kinds(blocks[1])).toEqual(['react.hook 3', 'react.hook 4', 'return 5'])
expect(lines(blocks[2])).toEqual(['名称: useLabel', '参数: 1 个', ' text | 字符串', '返回: 2 个', ' text', ' toggle'])
expect(lines(childAt(blocks[2], 8))).toEqual(['钩子: useToggle', '返回: 1 个', ' value'])
expect(lines(blocks[3])).toEqual(['名称: useNothing', '参数: 无'])
expect(lines(childAt(blocks[3], 12))).toEqual(['钩子: useToggle'])
})
})
describe('react 库规则:钩子从哪来', () => {
const SOURCE = [
'import * as React from "react";',
'import { useQuery } from "@tanstack/react-query";',
'import { useSession } from "@/lib/session";',
'import { useLocal } from "./local";',
'export function Box() {',
' const [n, setN] = React.useState(0);',
' const { data } = useQuery({ queryKey: ["x"] });',
' const session = useSession(options);',
' const local = useLocal();',
' const id = useId();',
' return <i>{n}</i>;',
'}',
].join('\n')
it('从 react 导入、写成 React.useX、没导入的内置钩子名是 React 自带;别的包是第三方;相对路径、路径别名是自己写的', async () => {
const view = await card(SOURCE, 'Box')
expect([6, 7, 8, 9, 10].map(line => childAt(view, line).tint)).toEqual([TINT.react, TINT.library, TINT.own, TINT.own, TINT.react])
expect(childAt(view, 6).concepts[1]).toMatchObject({ id: 'react.hook.state' })
expect(childAt(view, 6).callTree?.name && SOURCE.slice(childAt(view, 6).callTree?.name?.startIndex, childAt(view, 6).callTree?.name?.endIndex)).toBe('setN')
expect(lines(childAt(view, 7))).toEqual(['钩子: useQuery', '参数: 1 个', ' 第 1 个 | { queryKey: ["x"] }', '返回: 1 个', ' data | —'])
expect(lines(childAt(view, 10))).toEqual(['钩子: useId', '返回: 1 个', ' id | 稳定的唯一标识'])
})
it('别的文件里自己写的钩子:参数名经工作区索引查定义处的形参,没查到时照位置写', async () => {
expect(rowLines(childAt(await card(SOURCE, 'Box'), 8), '参数')).toEqual(['参数: 1 个', ' 第 1 个 | options'])
const lookup = known({ useSession: JSON.stringify(['config']) })
expect(rowLines(childAt(await card(SOURCE, 'Box', lookup), 8), '参数')).toEqual(['参数: 1 个', ' config | options'])
})
it('签名表:实参多了按重载挑形参名;没传、签名表里也没给说法的参数不出那一行', async () => {
const source = [
'import { useDeferredValue, useReducer, useState } from "react";',
'export function Box({ q }: { q: string }) {',
' const [state, dispatch] = useReducer(reducer, 0, init);',
' const [n] = useState();',
' const deferred = useDeferredValue(q);',
' return <i>{state}{n}{deferred}</i>;',
'}',
].join('\n')
const view = await card(source, 'Box')
expect(rowLines(childAt(view, 3), '参数')).toEqual(['参数: 3 个', ' reducer | reducer', ' initialArg | 0', ' init | init'])
expect(lines(childAt(view, 4))).toEqual(['钩子: useState', '返回: 1 个', ' n | 当前值'])
expect(rowLines(childAt(view, 5), '参数')).toEqual(['参数: 1 个', ' value | q'])
})
})
describe('react 库规则:钩子卡片的几种写法(luffy 的真实组件)', () => {
it('第三方库的钩子照词表写是什么;项目里自己写的只列名称、参数照位置写', async () => {
const titlebar = await card(TITLEBAR, 'DesktopTitlebar')
const pathname = childAt(titlebar, 13)
expect(pathname.tint).toEqual(TINT.library)
expect(pathname.concepts).toEqual([HOOK_CONCEPT])
expect(lines(pathname)).toEqual(['钩子: usePathname', '返回: 1 个', ' pathname | 当前路径'])
const title = childAt(titlebar, 14)
expect(title.tint).toEqual(TINT.own)
expect(lines(title)).toEqual(['钩子: useDesktopShell', '参数: 1 个', ' 第 1 个 | (s) => s.title', '返回: 1 个', ' title'])
const legal = await card(LEGAL, 'LegalDocPage')
expect(lines(childAt(legal, 58))).toEqual(['钩子: useSiteConfig', '返回: 1 个', ' cfg'])
expect(lines(childAt(legal, 59))).toEqual(['钩子: useRouter', '返回: 1 个', ' router | 路由器,用于页面跳转'])
})
it('luffy 的 LegalDocPage:组件体照源码顺序,useMemo 直接返回表达式的函数照常翻,副作用的清理函数照常是子块,注释是讲的那张卡片的「注释」一行', async () => {
const view = await card(LEGAL, 'LegalDocPage')
expect(kinds(view)).toEqual([
'react.source 58',
'react.source 59',
'react.hook 58',
'react.hook 59',
'variable 60',
'react.derived 61',
'react.ref 63',
'react.state 64',
'react.state 68',
'react.hook 61',
'react.hook 63',
'react.hook 64',
'react.hook 68',
'if 70',
'react.effect 75',
'react.effect 80',
'react.hook 75',
'react.hook 80',
'react.handler 104',
'react.handler 112',
'react.handler 116',
'react.render 124',
])
expect(lines(childAt(view, 61))).toEqual(['名称: headings', '计算表达式: extractHeadings(markdown)', '依赖项: markdown(useMemo)'])
expect(lines(childAt(view, 63))).toEqual(['名称: scrollRef', '初始值: 空', '使用位置: <main> 的 ref 属性;副作用 1、副作用 2、handleSectionClick'])
expect(lines(childAt(view, 64))).toEqual(['名称: activeSection', '初始值: headings[0]?.id ?? ""', '更新位置: 渲染期间,当 doc 不全等 prevDoc 时、副作用 2'])
expect(lines(childAt(view, 61))).toEqual(['钩子: useMemo', '参数: 2 个', ' factory | () => extractHeadings(markdown)', ' deps | markdown 变化时', '返回: 1 个', ' headings | 算出的值'])
expect(lines(childAt(view, 64))).toEqual(['钩子: useState', '类型参数: 字符串', '参数: 1 个', ' initialState | headings[0]?.id ?? ""', '返回: 2 个', ' activeSection | 当前值', ' setActiveSection | 修改函数'])
const effect = childAt(view, 80)
expect(lines(effect)).toEqual(['触发时机: doc、headings 变化时', '注释: 滚动时按 heading.top 计算当前高亮章节.', '清理函数: container.removeEventListener("scroll", handleScroll)'])
expect(kinds(effect)).toEqual(['variable 82', 'if 83', 'function 84', 'statement 100'])
expect(lines(effect)).toEqual(['钩子: useEffect', '注释: 滚动时按 heading.top 计算当前高亮章节.', '参数: 2 个', ' effect | ⟦函数|· 5 句⟧', ' deps | doc、headings 变化时'])
expect(kinds(effect)).toEqual(['variable 82', 'if 83', 'function 84', 'statement 100', 'return 101'])
expect(lines(childAt(view, 104))).toEqual(['名称: handleSectionClick', '参数: id: 字符串', '绑定事件: 每个 <button> 的 onClick(传入 h.id)'])
expect(kinds(childAt(view, 104))).toEqual(['variable 105', 'variable 106', 'if 107'])
expect(rowLines(childAt(view, 112), '绑定事件')).toEqual(['绑定事件: <button>(打印) 的 onClick'])
})
it('luffy 的 ShareButton、PosterDialog:带清理的仅挂载时副作用、useCallback,没绑在 JSX 上的处理函数写由谁调用,一样的位置只写一次', async () => {
it('luffy 的 ShareButton、PosterDialog:带清理的副作用、useCallback,没绑在 JSX 上的处理函数写由谁调用', async () => {
const share = await card(SHARE, 'ShareButton')
expect(lines(childAt(share, 40))).toEqual(['触发时机: 仅挂载时', '清理函数: activeRef.current = 假;'])
expect(lines(childAt(share, 39))).toEqual(['名称: activeRef', '初始值: 真', '使用位置: 副作用 1、startShare'])
expect(lines(childAt(share, 50))).toEqual(['名称: startShare', '计算表达式: 函数(3 条语句)', '依赖项: deployUrl、sessionId、title(useCallback)'])
expect(lines(childAt(share, 40))).toEqual(['钩子: useEffect', '参数: 2 个', ' effect | ⟦函数|· 2 句⟧', ' deps | 仅挂载时'])
expect(kinds(childAt(share, 40))).toEqual(['statement 41', 'return 42'])
expect(lines(childAt(share, 39))).toEqual(['钩子: useRef', '参数: 1 个', ' initialValue | 真', '返回: 1 个', ' activeRef | 引用对象'])
expect(rowLines(childAt(share, 50), '参数')).toEqual(['参数: 2 个', ' callback | ⟦函数|· 3 句⟧', ' deps | deployUrl、sessionId、title 变化时'])
expect(kinds(childAt(share, 50))).toEqual(['if 51', 'statement 52', 'statement 53'])
const poster = await card(POSTER, 'PosterDialog')
expect(lines(childAt(poster, 95))).toEqual(['名称: exportPng', '参数: 无', '绑定事件: 由 download、share 调用'])
expect(rowLines(childAt(poster, 76), '更新位置')).toEqual(['更新位置: 每个 <button> 的 onClick'])
})
it('依赖项、触发时机的几种写法;JSX 的提前返回是如果卡片下面的渲染输出;直接返回 JSX 的组件把大纲写在卡片里', async () => {
it('依赖项、函数参数的几种写法;JSX 的提前返回是如果卡片下面的渲染输出;直接返回 JSX 的组件把大纲写在卡片里', async () => {
const source = [
'export function Box({ items }: { items: string[] }) {',
' const total = useMemo(() => {',
@@ -488,27 +820,42 @@ describe('react 库规则:数据源、派生值、副作用、事件处理函
].join('\n')
const box = await card(source, 'Box')
expect(viewLines(box.children)).toEqual([
'react.derived 2',
' 名称: total',
' 计算表达式: 2 条语句',
' 依赖项: 无(useMemo)',
'react.hook 2',
' 钩子: useMemo',
' 参数: 2 个',
' factory | ⟦函数|· 2 句⟧',
' deps | 仅挂载时',
' 返回: 1 个',
' total | 算出的值',
' variable 3',
' 名称: n',
' 初始值: items.length',
' return 4: 返回 n * 2;',
'react.derived 6',
' 名称: label',
' 计算表达式: format(items)',
' 依赖项: 未指定(useMemo)',
'react.derived 7',
' 名称: onPick',
' 计算表达式: (id: 字符串) => pick(id)',
' 依赖项: items(useCallback)',
'react.effect 8',
' 触发时机: 每次渲染后,浏览器绘制前',
'react.hook 6',
' 钩子: useMemo',
' 参数: 2 个',
' factory | () => format(items)',
' deps | 没写,每次渲染后',
' 返回: 1 个',
' label | 算出的值',
'react.hook 7',
' 钩子: useCallback',
' 参数: 2 个',
' callback | (id: 字符串) => pick(id)',
' deps | items 变化时',
' 返回: 1 个',
' onPick | 记住的函数',
'react.hook 8',
' 钩子: useLayoutEffect',
' 参数: 2 个',
' effect | ⟦函数|· 1 句⟧',
' deps | 没写,每次渲染后',
' statement 9: measure();',
'react.effect 11',
' 触发时机: deps 变化时',
'react.hook 11',
' 钩子: useEffect',
' 参数: 2 个',
' effect | ⟦函数|· 1 句⟧',
' deps | deps 变化时',
' statement 12: sync(items);',
'if 14: 如果 (items.length 全等 0)',
' react.render 14',
@@ -659,7 +1006,7 @@ describe('react 库规则:渲染大纲', () => {
})
describe('react 库规则:代码差异', () => {
it('给组件加一个属性、改一个副作用的依赖:组件卡片标修改、属性表那一行新加,副作用卡片标修改、触发时机逐截标', async () => {
it('给组件加一个属性、改一个副作用的依赖:组件卡片标修改、属性表那一行新加,钩子卡片标修改、参数表 deps 那一格逐截标', async () => {
const after = LEGAL
.replace(' doc: "terms" | "privacy";\n}', ' doc: "terms" | "privacy";\n /** 窄屏时收起目录 */\n compact?: boolean;\n}')
.replace('export const LegalDocPage = ({ doc }: LegalDocPageProps) => {', 'export const LegalDocPage = ({ doc, compact = false }: LegalDocPageProps) => {')
@@ -669,7 +1016,7 @@ describe('react 库规则:代码差异', () => {
const now = await views(after)
const diff = diffBlockTrees({ blocks: before, source: LEGAL }, { blocks: now, source: after })
const component = now.find(view => view.kind === 'react.component') as BlockView
const effect = component.children.find(child => child.kind === 'react.effect') as BlockView
const effect = component.children.find(child => child.kind === 'react.hook' && previewText(propertyRow(child, '钩子').value) === 'useEffect') as BlockView
const componentChange = diff.blocks[component.anchor]
expect(componentChange).toMatchObject({ change: 'mod', own: true })
@@ -680,7 +1027,9 @@ describe('react 库规则:代码差异', () => {
const effectChange = diff.blocks[effect.anchor]
expect(effectChange).toMatchObject({ change: 'mod', own: true })
expect(effectChange.marks?.rows?.changed[0].value).toEqual({
const paramsIndex = properties(effect).findIndex(row => row.label === '参数')
expect(Object.keys(effectChange.marks?.rows?.changed ?? {})).toEqual([String(paramsIndex)])
expect(effectChange.marks?.rows?.changed[paramsIndex].items?.changed[1].cells?.[1]).toEqual({
inline: [
{ segments: [{ text: 'doc', role: 'variable', offset: expect.any(Number) }] },
{ segments: [{ text: '、', role: 'hint' }, { text: 'markdown', role: 'variable', offset: expect.any(Number) }], change: 'add' },
+413 -272
View File
@@ -1,18 +1,21 @@
import type { CodeBlock, Doc, Inline, KindIconName, LibraryRule, Markdown, Node, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, SegmentGroup, SourceRange, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
import type { BlockConcept, CodeBlock, Doc, Inline, KindIconName, LibraryRule, Markdown, Node, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, SegmentGroup, SourceRange, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
import type { ParamDocs } from './docRows'
import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, OutlineNode, Place, PropsFacts, ReadFacts, RefFacts, StateFacts } from './reactTree'
import type { HookRole, HookSignature, ParamKind } from './reactHooks'
import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, CustomHookFacts, FieldFacts, FieldText, HandlerFacts, HookCallFacts, OutlineNode, Place, PropsFacts } from './reactTree'
import { docBodyRows, paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentText, componentOf, componentOfFunction, componentsIn, declaredTypes, enclosingComponent, enclosingFunction, isComponentTag, isFunction, isJsx, named, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
import { paramsFor, REACT_HOOKS } from './reactHooks'
import { argumentsOf, attributeName, attributeValue, bindingsOf, bodyOf, clientDirective, commentText, componentOf, componentOfFunction, componentsIn, customHookOf, customHooksIn, declaredTypes, enclosingComponent, enclosingCustomHook, enclosingFunction, hookCallAt, isComponentTag, isFunction, isJsx, named, outlineOf, parametersOf, placesOf, propsOf, returnedItems, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
import { hint, joined, name, plain, range } from './segments'
/**
* React 库规则:TSX / JSX 照源码从上往下,一块语法一张卡片,和别的语言一样;React 的写法换成专门的卡片。
* React 函数组件(见 reactTree.ts 的 componentAt)是「React 组件」卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/
* 说明 / 构造参数(解构的每个属性一行,类型取属性类型里对应的字段;属性类型写的是一个名字时写出它,导入的类型经工作区索引查它的字段)。
* 组件体的语句不并进卡片,照源码顺序是它的子块,照常交给各规则看;直接写在组件体里的这几种由这个规则认领:
* 状态(useState / useReducer:名称 / 初始值 / 更新位置)、引用(useRef:名称 / 初始值 / 使用位置)、数据源(别的钩子:变量 / 钩子 / 说明)、
* 派生值(useMemo / useCallback:名称 / 计算表达式 / 依赖项)、副作用(useEffect 这类:触发时机 / 清理函数)、
* 事件处理函数(组件里定义的函数:名称 / 参数 / 绑定事件)。组件函数自己返回 JSX 的 return 是渲染输出:JSX 画成缩进的结构大纲,
* 自定义钩子(`function useXxx`、`const useXxx = () => …`)是「自定义钩子」卡片:名称 / 参数 / 返回(只列名称)。
* 组件体、钩子体的语句不并进卡片,照源码顺序是它的子块,照常交给各规则看。直接写在组件体、钩子体里的钩子调用都是同一种「钩子」卡片:
* 钩子 / 类型参数 / 参数 / 返回,参数、返回照钩子签名排(React 自带的钩子照签名表 [REACT_HOOKS]),不分析钩子里写了什么;
* 卡片描边都是钩子紫,淡底按钩子从哪来分,React 自带的钩子左边再加一个作用图标,返回里有函数的能看那个函数的调用树。
* 组件体里定义的函数是事件处理函数(名称 / 参数 / 绑定事件)。组件函数自己返回 JSX 的 return 是渲染输出:JSX 画成缩进的结构大纲,
* 原生标签灰色、组件用组件色能跳到定义,className 收成「样式 N 个类」,`cond &&` 写「当 … 时」,三元分「是 / 否」两支,
* `.map` 写「对 … 的每一项」并标 key;属性值是元素、条件或列表时属性名单独成行,值照同一套大纲缩进在下面。
* 回调、函数里的语句照常是这些卡片的子块;普通变量、if、注释照语言默认的卡片。表格的格子里只写名字和位置,不放函数体。
@@ -25,27 +28,26 @@ const RULE_ID = 'react'
/** 各种卡片的种类 id:筛选的 kind 属性值。 */
const KINDS = {
component: 'react.component',
state: 'react.state',
ref: 'react.ref',
source: 'react.source',
derived: 'react.derived',
effect: 'react.effect',
hook: 'react.hook',
customHook: 'react.customHook',
handler: 'react.handler',
render: 'react.render',
} as const
type CardKind = keyof typeof KINDS
/** 组件色:React 的青色,渲染大纲里的组件、「客户端组件」标签也用它;白底上照原色看不清,亮色主题取同色相深一些的。 */
/** 组件色:React 的青色,渲染大纲里的组件、「客户端组件」标签、React 自带钩子的淡底也用它;白底上照原色看不清,亮色主题取同色相深一些的。 */
const COMPONENT_COLOR: ThemeColor = { dark: '#61dafb', light: '#0a7ea4' }
/** JSX 里的原生标签(div、span):标签尖括号的灰。 */
const NATIVE_TAG_COLOR: ThemeColor = { dark: '#808080', light: '#6f6f6f' }
/** 状态、引用、数据源、派生值都是数据:同变量块的浅蓝,靠图标区分。 */
const DATA_COLOR: ThemeColor = { dark: '#9cdcfe', light: '#001080' }
/** 副作用:同「选项」块的黄;事件处理函数:同「函数」块;渲染输出:同「返回」块的绿。 */
const EFFECT_COLOR: ThemeColor = { dark: '#e5c07b', light: '#9a6a00' }
/** 同「选项」块的黄:第三方库钩子的淡底、副作用的作用图标。 */
const OPTION_COLOR: ThemeColor = { dark: '#e5c07b', light: '#9a6a00' }
/** 同「返回」块的绿:渲染输出、自己写的钩子的淡底。 */
const RETURN_COLOR: ThemeColor = { dark: '#89d185', light: '#388a34' }
/** 钩子:钩子卡片、自定义钩子卡片的描边和钩子图标,所有钩子一样。 */
const HOOK_COLOR: ThemeColor = { dark: '#b180d7', light: '#7f3fa6' }
/** 事件处理函数:同「函数」块。 */
const HANDLER_COLOR: ThemeColor = { dark: '#dcdcaa', light: '#795e26' }
const RENDER_COLOR: ThemeColor = { dark: '#89d185', light: '#388a34' }
/** 导出方式的标签:同 import / export 关键字的颜色。 */
const EXPORT_COLOR: ThemeColor = { dark: '#c586c0', light: '#af00db' }
/** 记忆化、转发 ref 的标签:同 const / function 关键字的颜色。 */
@@ -53,58 +55,64 @@ const WRAPPER_COLOR: ThemeColor = { dark: '#569cd6', light: '#0000ff' }
const LOOKS: Record<CardKind, { color: ThemeColor, icon: KindIconName }> = {
component: { color: COMPONENT_COLOR, icon: 'atom' },
state: { color: DATA_COLOR, icon: 'toggle-right' },
ref: { color: DATA_COLOR, icon: 'anchor' },
source: { color: DATA_COLOR, icon: 'download' },
derived: { color: DATA_COLOR, icon: 'sigma' },
effect: { color: EFFECT_COLOR, icon: 'zap' },
hook: { color: HOOK_COLOR, icon: 'fishing-hook' },
customHook: { color: HOOK_COLOR, icon: 'fishing-hook' },
handler: { color: HANDLER_COLOR, icon: 'mouse-pointer-click' },
render: { color: RENDER_COLOR, icon: 'layout-template' },
render: { color: RETURN_COLOR, icon: 'layout-template' },
}
/** 钩子从哪来:React 自带、第三方库、项目里自己写的。 */
type HookOrigin = 'react' | 'library' | 'own'
/** 钩子卡片的淡底按钩子从哪来分。 */
const ORIGIN_TINTS: Record<HookOrigin, ThemeColor> = { react: COMPONENT_COLOR, library: OPTION_COLOR, own: RETURN_COLOR }
/** React 自带钩子的作用图标:颜色借语法色(状态同变量、引用同常量、性能同数字、上下文同类型、并发同字符串、外部数据源同函数),和钩子紫分得开。 */
const ROLE_LOOKS: Record<HookRole, { color: ThemeColor, icon: KindIconName }> = {
state: { color: { dark: '#9cdcfe', light: '#001080' }, icon: 'toggle-right' },
effect: { color: OPTION_COLOR, icon: 'zap' },
ref: { color: { dark: '#4fc1ff', light: '#0070c1' }, icon: 'anchor' },
performance: { color: { dark: '#b5cea8', light: '#098658' }, icon: 'sigma' },
context: { color: { dark: '#4ec9b0', light: '#267f99' }, icon: 'share-2' },
concurrent: { color: { dark: '#ce9178', light: '#a31515' }, icon: 'hourglass' },
external: { color: { dark: '#dcdcaa', light: '#795e26' }, icon: 'database' },
}
interface Words {
/** 各种卡片的名字,筛选栏里也叫这个 */
kinds: Record<CardKind, string>
labels: Record<'name' | 'doc' | 'props' | 'initial' | 'updates' | 'uses' | 'variable' | 'hook' | 'meaning' | 'expression' | 'deps' | 'trigger' | 'cleanup' | 'parameters' | 'returns' | 'events' | 'outline', string>
/** 事件处理函数的说明里有参数对上了 `@param` 时,参数排成表:名称 | 类型 | 默认值 | 说明 */
labels: Record<'name' | 'doc' | 'props' | 'hook' | 'typeArguments' | 'parameters' | 'returns' | 'events' | 'outline', string>
/** 参数排成的表:名称 | 类型 | 默认值 | 说明(事件处理函数说明里有参数对上了 `@param` 时;自定义钩子只留用得上的列) */
parameterHeads: string[]
/** 触发时机:a、b 变化时,仅挂载时,每次渲染后;useLayoutEffect 在浏览器绘制前跑 */
/** 钩子卡片的参数表:参数 | 值;返回表:名称 | 是什么,自己写的钩子只有名称 */
hookParamHeads: string[]
hookReturnHeads: string[]
/** React 自带钩子的作用(作用图标悬停的名字),签名表给了一句时跟在后面:「副作用 · 浏览器绘制前执行」 */
hookRoles: Record<HookRole, string>
roleNote: (role: string, note: string) => string
/** 第三方钩子的名字悬停:「来自 next/navigation」 */
from: (module: string) => string
/** 不知道形参名时照位置写:「第 1 个」 */
nth: (position: number) => string
/** 依赖项:「a、b 变化时」「仅挂载时」 */
changed: string
mount: string
afterRender: string
beforePaint: string
/** 依赖项:依赖数组是空的、没写依赖数组;后面写是哪个钩子的「(useMemo)」 */
noDeps: string
unspecified: string
hookNote: (hook: string) => string
/** 计算表达式:useCallback 记住的是「函数(3 条语句)」,useMemo 的主体是语句块时「3 条语句」 */
/** 惰性初始值的主体只有一个调用:「首次渲染时调用 readOpen(id)」 */
firstRender: string
/** 函数参数收成的小框:「函数 (next: 布尔) · 2 句」 */
fn: string
statements: (n: number) => string
statementsNote: (n: number) => string
/** 用到的一处:第几个副作用(只有一个时不写几)、渲染输出、渲染期间按条件重置 */
effectPlace: (index?: number) => string
sentences: (n: number) => string
/** 用到的一处:渲染输出 */
renderPlace: string
resetPlace: [string, string]
/** 「<button> 的 onClick」,在列表里一项项渲染的「每个 <button>」,在属性值里调用时传入的实参「(传入 h.id)」 */
of: string
each: string
passes: [string, string]
/** 引用绑定在哪个元素上:「<main> 的 ref 属性」 */
refAttribute: string
/** 处理函数没绑在 JSX 上,被别的函数调用:「由 download、share 调用」 */
calledBy: [string, string]
listSeparator: string
placeSeparator: string
parens: [string, string]
/** 数据源的说明:库自带的钩子是什么(来源模块 → 钩子名 → 说明) */
/** 第三方库的钩子返回的是什么(来源模块 → 钩子名 → 说明) */
hooks: Record<string, Record<string, string>>
/** 「desktop store 中的 title」「基于 desktop store 计算 …」 */
storeField: string
storeComputed: [string, string]
/** 「ThemeContext 上下文的值」 */
context: string
customHook: string
libraryHook: (module: string) => string
/** 渲染大纲一行前面的「当 … 时」「如果 …」「是 / 否」「对 … 的每一项」、不渲染 */
when: [string, string]
if: string
@@ -148,48 +156,36 @@ interface Words {
}
const ZH: Words = {
kinds: { component: 'React 组件', state: '状态', ref: '引用', source: '数据源', derived: '派生值', effect: '副作用', handler: '事件处理函数', render: '渲染输出' },
kinds: { component: 'React 组件', hook: '钩子', customHook: '自定义钩子', handler: '事件处理函数', render: '渲染输出' },
labels: {
name: '名称',
doc: '说明',
props: '构造参数',
initial: '初始值',
updates: '更新位置',
uses: '使用位置',
variable: '变量',
hook: '钩子',
meaning: '说明',
expression: '计算表达式',
deps: '依赖项',
trigger: '触发时机',
cleanup: '清理函数',
typeArguments: '类型参数',
parameters: '参数',
returns: '返回',
events: '绑定事件',
outline: 'JSX 结构大纲',
},
parameterHeads: ['名称', '类型', '默认值', '说明'],
hookParamHeads: ['参数', '值'],
hookReturnHeads: ['名称', '是什么'],
hookRoles: { state: '状态', effect: '副作用', ref: '引用', performance: '性能', context: '上下文', concurrent: '并发', external: '外部数据源' },
roleNote: (role, note) => `${role} · ${note}`,
from: module => `来自 ${module}`,
nth: position => `第 ${position} 个`,
changed: '变化时',
mount: '仅挂载时',
afterRender: '每次渲染后',
beforePaint: ',浏览器绘制前',
noDeps: '无',
unspecified: '未指定',
hookNote: hook => `(${hook})`,
firstRender: '首次渲染时调用 ',
fn: '函数',
statements: n => `${n} 条语句`,
statementsNote: n => `(${n} 条语句)`,
effectPlace: index => index === undefined ? '副作用' : `副作用 ${index}`,
sentences: n => `${n} 句`,
renderPlace: '渲染输出',
resetPlace: ['渲染期间,当 ', ' 时'],
of: ' 的 ',
each: '每个 ',
passes: ['(传入 ', ')'],
refAttribute: ' 的 ref 属性',
calledBy: ['由 ', ' 调用'],
listSeparator: '、',
placeSeparator: ';',
parens: ['(', ')'],
hooks: {
'next/navigation': {
usePathname: '当前路径',
@@ -200,21 +196,8 @@ const ZH: Words = {
useSelectedLayoutSegments: '当前激活的各级布局段',
},
'next/router': { useRouter: '路由器,用于页面跳转' },
'react': {
useId: '稳定的唯一标识',
useTransition: '过渡状态及启动过渡的函数',
useSyncExternalStore: '外部数据源的当前值',
useOptimistic: '乐观更新的值及其更新函数',
useActionState: '表单动作的状态及其触发函数',
useDeferredValue: '延迟更新的值',
},
'react-dom': { useFormStatus: '所在表单的提交状态' },
},
storeField: ' store 中的 ',
storeComputed: ['基于 ', ' store 计算 '],
context: ' 上下文的值',
customHook: '自定义钩子',
libraryHook: module => `${module} 提供的钩子`,
when: ['当', '时'],
if: '如果',
branches: { yes: '是', no: '否' },
@@ -251,14 +234,24 @@ interface Env {
words: Words
}
/** 一张卡片:种类、它的名字和样子、各行,定义范围、并进来的子块有才有。 */
function card(kind: CardKind, rows: PropertyRow[], { words }: Env, more: { defines?: SourceRange, absorbs?: CodeBlock[] } = {}): RuleCard {
/** 卡片除了种类和各行以外,有才给的几样(见 [RuleCard] 的同名字段)。 */
interface CardExtras {
defines?: SourceRange
absorbs?: CodeBlock[]
concepts?: BlockConcept[]
languageConcepts?: false
tint?: ThemeColor
calls?: SourceRange
}
/** 一张卡片:种类、它的名字和样子、各行,别的有才有(见 [CardExtras])。 */
function card(kind: CardKind, rows: PropertyRow[], { words }: Env, { absorbs = [], ...extras }: CardExtras = {}): RuleCard {
return {
kind: KINDS[kind],
badge: { label: words.kinds[kind], ...LOOKS[kind] },
rows,
...(more.defines && { defines: more.defines }),
absorbs: more.absorbs ?? [],
...extras,
absorbs,
}
}
@@ -267,6 +260,11 @@ function tag(label: string, color: ThemeColor): SegmentGroup {
return { style: 'tag', label, segments: [], color }
}
/** 导出方式的小标签:「具名导出」「默认导出」,没导出没有。 */
function exportTags(exported: 'named' | 'default' | undefined, words: Words): SegmentGroup[] {
return exported ? [tag(words.tags[exported], EXPORT_COLOR)] : []
}
/** 规则自己的用词里当关键字着色的:是、否。 */
function yesNo(value: boolean, words: Words): Inline[] {
return [{ text: value ? words.yes : words.no, role: 'storage' }]
@@ -286,7 +284,7 @@ function binding(node: Node, role: TokenRole, { context }: Env): Inline[] {
/** 名称:组件名用组件色,后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签。 */
function componentName(component: ComponentFacts, { words }: Env): PropertyRow {
const tags = [
...(component.exported ? [tag(words.tags[component.exported], EXPORT_COLOR)] : []),
...exportTags(component.exported, words),
...(component.client ? [tag(words.tags.client, COMPONENT_COLOR)] : []),
...component.wrappers.map(wrapper => tag(words.tags[wrapper], WRAPPER_COLOR)),
]
@@ -513,24 +511,25 @@ function jsxPlace(place: Extract<Place, { kind: 'jsx' }>, passes: boolean, { con
]
}
/** 用到的一处写成名字或位置(见 Place):处理函数、派生值、变量、数据源写名字(能跳到定义),副作用写「副作用 2」,JSX 写哪个元素的哪个属性。 */
/**
* 用到的一处写成名字或位置(见 [Place]):处理函数、变量写名字(能跳到定义),钩子调用写接住返回值的名字,
* 没用一个名字接住时写钩子名(同一个钩子这样写了几处时后面跟第几处,钩子紫),JSX 写哪个元素的哪个属性。
*/
function placeText(place: Place, { body }: Analysis, env: Env): Inline[] {
const { context, words } = env
const { words } = env
switch (place.kind) {
case 'effect':
return [{ text: words.effectPlace(body.effects.length > 1 ? place.index + 1 : undefined), role: 'plain', color: EFFECT_COLOR }]
case 'hook': {
const { call } = place
if (call.target?.type === 'identifier')
return [name(call.target, resultOf({ node: call.target, whole: true }, hookSource(call, env).signature)?.fn ? 'function' : 'variable')]
const same = body.hooks.filter(other => other.target?.type !== 'identifier' && other.hook.text === call.hook.text)
const index = same.findIndex(other => sameNode(other.statement, call.statement))
return [{ text: same.length > 1 ? `${call.hook.text} ${index + 1}` : call.hook.text, role: 'plain', color: HOOK_COLOR }]
}
case 'handler':
return [name(place.name, 'function')]
case 'derived': {
const derived = body.derived.find(item => sameNode(item.name, place.name))
return binding(place.name, derived?.hook.text === 'useCallback' ? 'function' : 'variable', env)
}
case 'variable':
return binding(place.name, 'variable', env)
case 'read':
return binding(place.target, 'variable', env)
case 'reset':
return [hint(words.resetPlace[0]), ...context.translate(place.condition), hint(words.resetPlace[1])]
case 'jsx':
return jsxPlace(place, false, env)
default:
@@ -549,39 +548,46 @@ function distinct(parts: Inline[][]): Inline[][] {
return parts.filter((part, index) => parts.findIndex(other => flatText(other) === flatText(part)) === index)
}
// ─── 状态、引用、数据源、派生值 ──────────────────────────────────────────────
// ─── 钩子 ─────────────────────────────────────────────────────────────────────
/** 状态:名称 / 初始值(useReducer 后面括号里写 reducer)/ 更新位置(调用改状态的函数的各处)。 */
function stateCard(state: StateFacts, analysis: Analysis, env: Env): RuleCard {
const { context, words } = env
const initial = state.initial ? context.translate(state.initial) : [hint(words.nothing)]
const reducer = state.reducer ? [hint(words.parens[0]), ...context.translate(state.reducer), hint(words.parens[1])] : []
const updates = state.setter ? placeList(placesOf(state.setter, analysis.component, analysis.body), analysis, env) : [hint(words.nothing)]
return card('state', [
{ label: words.labels.name, value: binding(state.value, 'variable', env) },
{ label: words.labels.initial, value: [...initial, ...reducer] },
{ label: words.labels.updates, value: updates },
], env, { defines: range(state.value) })
/** 一处钩子调用讲的钩子:从哪来、导入它的模块(没导入的没有),React 自带、签名表 [REACT_HOOKS] 里登记了的带上签名。 */
interface HookSource {
origin: HookOrigin
module?: string
signature?: HookSignature
}
/** 引用:名称 / 初始值 / 使用位置(绑在哪个元素的 ref 属性上,再「;」接别处用到它的)。 */
function refCard(ref: RefFacts, analysis: Analysis, env: Env): RuleCard {
const { context, words } = env
const places = placesOf(ref.name, analysis.component, analysis.body)
const attached = places.filter((place): place is Extract<Place, { kind: 'jsx' }> => place.kind === 'jsx' && place.attribute.text === 'ref')
const others = places.filter(place => !attached.includes(place as Extract<Place, { kind: 'jsx' }>))
const parts = [
...attached.map(place => [...elementRef(place.element), plain(words.refAttribute)]),
...(others.length > 0 ? [placeList(others, analysis, env)] : []),
]
return card('ref', [
{ label: words.labels.name, value: binding(ref.name, 'variable', env) },
{ label: words.labels.initial, value: ref.initial ? context.translate(ref.initial) : [hint(words.nothing)] },
{ label: words.labels.uses, value: parts.length > 0 ? joined(parts, hint(words.placeSeparator)) : [hint(words.nothing)] },
], env, { defines: range(ref.name) })
/** 一棵语法树里文件顶层声明的自定义钩子:同一棵树读一次就够。 */
const fileHooks = new WeakMap<object, CustomHookFacts[]>()
function hooksInFile(node: Node): CustomHookFacts[] {
const tree = node.tree
const known = fileHooks.get(tree) ?? customHooksIn(tree.rootNode)
fileHooks.set(tree, known)
return known
}
/** 名字是从哪个模块导入的(去掉引号);`React.useX` 看 React 从哪导入。没导入(同文件里定义的)时没有。 */
/**
* 钩子从哪来:写成 `React.useX`、从 `'react'` 导入的是 React 自带;从别的包导入的是第三方库;从相对路径、`@/` 这类别名导入的是自己写的。
* 没导入的:这个文件里定义了这个钩子的是自己写的,签名表里有的是 React 自带(自动导入 React 钩子的项目这样写),别的也算自己写的。
*/
function hookSource(call: HookCallFacts, env: Env): HookSource {
const callee = unwrap(call.call.childForFieldName('function'))
const owner = callee?.type === 'member_expression' ? callee.childForFieldName('object') : callee
const module = owner ? moduleOf(owner.text, env) : undefined
const hook = call.hook.text
const signature = REACT_HOOKS[hook]
const react = module === 'react'
|| (callee?.type === 'member_expression' && owner?.text === 'React')
|| (!module && !!signature && !hooksInFile(call.statement).some(item => item.name.text === hook))
if (react)
return { origin: 'react', module: 'react', ...(signature && { signature }) }
if (module && !isLocalModule(module))
return { origin: 'library', module }
return { origin: 'own', ...(module && { module }) }
}
/** 名字是从哪个模块导入的(去掉引号);没导入(同文件里定义的)时没有。 */
function moduleOf(local: string, { context }: Env): string | undefined {
for (const facts of context.imports) {
for (const item of facts.items) {
@@ -598,153 +604,299 @@ function isLocalModule(module: string): boolean {
return /^(?:\.|@\/|~\/|#)/.test(module)
}
/** store 叫什么:模块路径最后一节是 store 时取上一节(`@/lib/desktop/store` 是 desktop),否则去掉 store、use 字样,再不行取钩子名。 */
function storeName(hook: string, module: string | undefined): string {
const parts = module?.split('/').filter(Boolean) ?? []
const last = parts.at(-1)
if (last && /^store$/i.test(last) && parts.length > 1)
return parts[parts.length - 2]
const stripped = last?.replace(/[-_.]?store$/i, '').replace(/^use[-_]?/i, '')
return stripped && !stripped.startsWith('@') ? stripped : hook.replace(/^use/, '')
/** 工作区里各文件顶层声明的自定义钩子:名字 → 各形参的写法(JSON 字符串数组),别的文件里自己写的钩子按它写参数名。 */
const CUSTOM_HOOKS_INDEX: WorkspaceIndex = {
id: `${RULE_ID}.customHooks`,
languageId: 'typescriptreact',
include: '**/*.{ts,tsx,js,jsx}',
mentions: source => /\buse[A-Z0-9]/.test(source),
entries: root => customHooksIn(root).map(hook => ({ key: hook.name.text, name: range(hook.name), value: JSON.stringify(parameterNames(hook.fn)) })),
}
/** 选择器 `(s) => s.a.b` 取的那一串属性(a、b);不是从参数上直接取属性的写法时没有。 */
function selectedPath(selector: Node | undefined): Node[] | undefined {
const fn = unwrap(selector)
if (!fn || fn.type !== 'arrow_function')
/** 函数各形参的写法:名字,解构的写成一行。 */
function parameterNames(fn: Node): string[] {
return parametersOf(fn).map(parameter => parameter.name.text.replace(/\s+/g, ' '))
}
/** 自己写的钩子的形参名:同文件定义的从语法树读,别的文件里的查工作区索引;都没有时没有。 */
function ownParameterNames(call: HookCallFacts, { context }: Env): string[] | undefined {
const local = hooksInFile(call.statement).find(hook => hook.name.text === call.hook.text)
if (local)
return parameterNames(local.fn)
const value = context.lookup(CUSTOM_HOOKS_INDEX, call.hook)?.value
if (!value)
return undefined
const [parameter] = parametersOf(fn)
const body = unwrap(fn.childForFieldName('body'))
if (parametersOf(fn).length !== 1 || parameter.name.type !== 'identifier' || !body)
return undefined
const path = (node: Node): Node[] | undefined => {
if (node.type !== 'member_expression')
return undefined
const object = node.childForFieldName('object')
const property = node.childForFieldName('property')
if (!object || !property)
return undefined
if (object.type === 'identifier')
return object.text === parameter.name.text ? [property] : undefined
const inner = path(object)
return inner && [...inner, property]
try {
const parsed: unknown = JSON.parse(value)
return Array.isArray(parsed) && parsed.every(item => typeof item === 'string') ? parsed : undefined
}
return path(body)
catch {
return undefined
}
}
/** 钩子一行:被调的钩子名(带源码下标,能跳到定义);第三方钩子悬停看来自哪个库。 */
function hookRow(call: HookCallFacts, source: HookSource, { words }: Env): PropertyRow {
const tooltip = source.origin === 'library' && source.module ? words.from(source.module) : undefined
return { label: words.labels.hook, value: [{ ...name(call.hook, 'function'), ...(tooltip && { tooltip }) }] }
}
/** 类型参数:源码写了 `useState<Message[]>` 才有,几个「, 」隔开。 */
function typeArgumentsRow(call: HookCallFacts, { context, words }: Env): PropertyRow[] {
const types = named(call.call.childForFieldName('type_arguments')).filter(child => child.type !== 'comment')
return types.length > 0 ? [{ label: words.labels.typeArguments, value: joined(types.map(type => context.translate(type)), plain(', ')) }] : []
}
/** 依赖项:`[a, b]` 是「a、b 变化时」,`[]` 是「仅挂载时」,写成别的表达式时「deps 变化时」。 */
function depsText(deps: Node, { context, words }: Env): Inline[] {
const items = deps.type === 'array' ? named(deps).filter(item => item.type !== 'comment') : undefined
if (items?.length === 0)
return [keyword(words.mount)]
const subjects = items ? joined(items.map(item => context.translate(item)), hint(words.listSeparator)) : context.translate(deps)
return [...subjects, plain(' '), keyword(words.changed)]
}
/**
* 数据源的说明:库自带的钩子照词表(usePathname → 当前路径);useContext 是哪个上下文的值;
* 传选择器的(`useXxx((s) => s.a)`)是哪个 store 中的 a;自己项目里的是自定义钩子,别的库的写是哪个库提供的钩子。
* 写在实参里的函数:主体是语句块时收成小框「函数 (next: 布尔) · 2 句」,函数体是卡片的子块;
* 直接返回表达式的没有子块可放,照常翻整个函数。
*/
function readMeaning(read: ReadFacts, env: Env): Inline[] {
function functionText(fn: Node, env: Env): Inline[] {
const { context, words } = env
const hook = read.hook.text
const callee = unwrap(read.call.childForFieldName('function'))
const owner = callee?.type === 'member_expression' ? callee.childForFieldName('object')?.text : hook
const module = owner ? moduleOf(owner, env) : undefined
const known = module ? words.hooks[module]?.[hook] : undefined
if (known)
return [plain(known)]
const [first] = argumentsOf(read.call)
if (hook === 'useContext' && first)
return [...context.translate(first), hint(words.context)]
const path = selectedPath(first)
const store = storeName(hook, module)
if (path)
return [plain(store), hint(words.storeField), ...joined(path.map(property => [name(property, 'variable')]), plain('.'))]
const selector = unwrap(first)
if (selector?.type === 'arrow_function' && parametersOf(selector).length === 1 && selector.childForFieldName('body')?.type !== 'statement_block')
return [hint(words.storeComputed[0]), plain(store), hint(words.storeComputed[1]), ...context.translate(selector.childForFieldName('body') as Node)]
return [hint(!module || isLocalModule(module) ? words.customHook : words.libraryHook(module))]
if (fn.childForFieldName('body')?.type !== 'statement_block')
return context.translate(fn)
const parameters = parametersOf(fn).map(parameter => [
...binding(parameter.name, 'variable', env),
...(parameter.type ? [plain(': '), ...context.translate(parameter.type)] : []),
])
const head = parameters.length > 0 ? [plain('('), ...joined(parameters, plain(', ')), plain(') ')] : []
return [{ style: 'fold', label: words.fn, segments: [...head, hint(`· ${words.sentences(statementsOf(fn).length)}`)] }]
}
/** 数据源:变量 / 钩子(这次调用照常翻)/ 说明。 */
function sourceCard(read: ReadFacts, env: Env): RuleCard {
const { context, words } = env
return card('source', [
{ label: words.labels.variable, value: binding(read.target, 'variable', env) },
{ label: words.labels.hook, value: context.translate(read.call) },
{ label: words.labels.meaning, value: readMeaning(read, env) },
], env, { defines: range(read.target) })
/** 惰性初始值传的函数:直接返回一次调用的写「首次渲染时调用 readOpen(id)」,别的同函数参数。 */
function lazyText(fn: Node, env: Env): Inline[] {
const body = unwrap(fn.childForFieldName('body'))
return body?.type === 'call_expression' ? [plain(env.words.firstRender), ...env.context.translate(body)] : functionText(fn, env)
}
/** 计算表达式:useMemo 是算值的那个表达式,主体是语句块时写几条语句;useCallback 记住的函数主体是语句块时写「函数(N 条语句)」,否则照常翻。 */
function expressionText(derived: DerivedFacts, { context, words }: Env): Inline[] {
const callback = unwrap(derived.callback)
if (!callback)
return [hint(words.nothing)]
const body = isFunction(callback) ? callback.childForFieldName('body') : undefined
const blockBodied = body?.type === 'statement_block'
if (derived.hook.text === 'useCallback')
return blockBodied ? [plain(words.fn), hint(words.statementsNote(statementsOf(callback).length))] : context.translate(callback)
return blockBodied ? [hint(words.statements(statementsOf(callback).length))] : context.translate(body ?? callback)
/**
* 参数表「值」那一格,按形参的种类(见 [ParamKind]):依赖项、惰性初始值、写在这里的函数各有写法;
* 传名字的函数、引用别处定义的东西(reducer、ThemeContext)照抄成带源码下标的名字;别的照常翻。不知道种类时只认出写在这里的函数。
*/
function argumentText(arg: Node, kind: ParamKind | undefined, env: Env): Inline[] {
const value = unwrap(arg) ?? arg
if (kind === 'deps')
return depsText(value, env)
if (isFunction(value))
return kind === 'lazy' ? lazyText(value, env) : functionText(value, env)
if (kind === 'function')
return env.context.verbatim(value, value.type === 'identifier' ? 'function' : undefined)
if (kind === 'ref')
return env.context.verbatim(value)
return env.context.translate(value)
}
/** 依赖项:依赖数组里的各项,「、」隔开,后面写是哪个钩子;依赖数组是空的写「无」,没写依赖数组写「未指定」。 */
function dependencyText(derived: DerivedFacts, { context, words }: Env): Inline[] {
const deps = unwrap(derived.deps)
const items = deps?.type === 'array' ? named(deps).filter(item => item.type !== 'comment') : undefined
let list: Inline[]
if (!deps)
list = [hint(words.unspecified)]
else if (!items)
list = context.translate(deps)
else if (items.length === 0)
list = [hint(words.noDeps)]
else
list = joined(items.map(item => context.translate(item)), hint(words.listSeparator))
return [...list, hint(words.hookNote(derived.hook.text))]
}
/** 派生值:名称 / 计算表达式 / 依赖项。useCallback、useMemo 的函数主体是语句块时,里面的语句照常是这张卡片的子块。 */
function derivedCard(derived: DerivedFacts, env: Env): RuleCard {
/**
* 参数:「参数 | 值」表,一个实参一行。形参名照签名表(React 自带的),自己写的钩子照定义处,别的照位置写「第 N 个」;
* 签名表里给了没传时的说法的参数,没传也出一行。一行都没有时不出这一行。
*/
function parametersRow(call: HookCallFacts, source: HookSource, env: Env): PropertyRow[] {
const { words } = env
return card('derived', [
{ label: words.labels.name, value: binding(derived.name, 'variable', env) },
{ label: words.labels.expression, value: expressionText(derived, env) },
{ label: words.labels.deps, value: dependencyText(derived, env) },
], env, { defines: range(derived.name) })
const args = argumentsOf(call.call)
const params = source.signature && paramsFor(source.signature, args.length)
const names = params?.map(param => param.name) ?? (source.origin === 'own' ? ownParameterNames(call, env) : undefined)
const items = Array.from({ length: Math.max(args.length, params?.length ?? 0) }, (_, index): PropertyItem[] => {
const param = params?.[index]
const arg = args[index]
const label: Inline[] = [{ text: names?.[index] ?? words.nth(index + 1), role: 'variable' }]
if (arg)
return [{ cells: [label, argumentText(arg, param?.kind, env)] }]
const missing = param?.missing
return missing ? [{ cells: [label, [hint(missing.lead), { text: missing.text, role: missing.role }]] }] : []
}).flat()
return items.length > 0 ? [{ label: words.labels.parameters, value: [hint(words.count(items.length))], columns: words.hookParamHeads, items }] : []
}
// ─── 副作用、事件处理函数 ─────────────────────────────────────────────────────
/** 触发时机:`[a, b]` 是 a、b 变化时,`[]` 是仅挂载时,没写依赖数组是每次渲染后;useLayoutEffect 后面加「,浏览器绘制前」。 */
function triggerText(effect: EffectFacts, { context, words }: Env): Inline[] {
const deps = unwrap(effect.deps)
const items = deps?.type === 'array' ? named(deps).filter(item => item.type !== 'comment') : undefined
let when: Inline[]
if (!deps)
when = [keyword(words.afterRender)]
else if (!items)
when = [...context.translate(deps), plain(' '), keyword(words.changed)]
else if (items.length === 0)
when = [keyword(words.mount)]
else
when = [...joined(items.map(item => context.translate(item)), hint(words.listSeparator)), plain(' '), keyword(words.changed)]
return [...when, ...(effect.hook.text === 'useLayoutEffect' ? [hint(words.beforePaint)] : [])]
/** 接住返回值的一项:名字或解构的一项;数组解构的记着它是元组的第几项,整个接住的标 whole。 */
interface Receiver {
node: Node
position?: number
whole?: true
}
/** 清理函数:返回的清理函数做什么(`() => …` 翻它的主体,语句块一句句翻,别的照常翻)。 */
function cleanupText(cleanup: Node, { context, words }: Env): Inline[] {
const value = unwrap(named(cleanup).find(child => child.type !== 'comment'))
if (!value)
/** 接住返回值的各项:变量名是整个返回值,数组解构照位置(空着的位置跳过),对象解构一个键一项。 */
function receiversOf(target: Node): Receiver[] {
if (target.type === 'identifier')
return [{ node: target, whole: true }]
if (target.type === 'object_pattern')
return bindingsOf(target).map(binding => ({ node: binding.rest ? binding.name.parent ?? binding.name : binding.name }))
if (target.type !== 'array_pattern')
return [{ node: target }]
const found: Receiver[] = []
let position = 0
for (const child of target.children) {
if (child?.type === ',')
position++
else if (child?.isNamed && child.type !== 'comment')
found.push({ node: child, position })
}
return found
}
/** 解构的一项接住的名字:默认值、`...` 去掉;嵌套的解构模式没有一个名字。 */
function receivedName(node: Node): Node | undefined {
if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern')
return node
if (node.type === 'assignment_pattern' || node.type === 'object_assignment_pattern')
return receivedName(node.childForFieldName('left') ?? node)
if (node.type === 'rest_pattern')
return named(node)[0] && receivedName(named(node)[0])
return undefined
}
/** 返回的这一项是什么(签名表的说法):整个接住的是全部各项,数组解构的是那个位置的;对不上时没有。 */
function resultOf(receiver: Receiver, signature: HookSignature | undefined): { say: string, fn?: true } | undefined {
const results = signature?.returns ?? []
if (receiver.whole && results.length === 1)
return results[0]
if (receiver.whole && results.length > 1)
return { say: results.map(result => result.say).join('、') }
return receiver.position !== undefined && results.length > 1 ? results[receiver.position] : undefined
}
/** 「是什么」那一格:React 自带的照签名表,第三方的照词表(整个接住时才对得上),都没有时「—」。 */
function meaningText(receiver: Receiver, call: HookCallFacts, source: HookSource, { words }: Env): Inline[] {
const say = source.signature
? resultOf(receiver, source.signature)?.say
: receiver.whole && source.module ? words.hooks[source.module]?.[call.hook.text] : undefined
return [say ? plain(say) : hint(words.nothing)]
}
/** 名称那一格:接住的名字带源码下标,是函数的用函数色;`...rest` 前面带 `...`,嵌套的解构模式照常翻。 */
function receiverText(receiver: Receiver, fn: boolean, env: Env): Inline[] {
const received = receivedName(receiver.node)
if (!received)
return env.context.translate(receiver.node)
return [...(receiver.node.type === 'rest_pattern' ? [plain('...')] : []), name(received, fn ? 'function' : 'variable')]
}
/**
* 返回:接住返回值的各项一行。React 自带、第三方的钩子是「名称 | 是什么」,自己写的只有名称。没接返回值时不出这一行。
* 也交出返回里第一个是函数的名字:卡片的调用树查它。
*/
function returnsRow(call: HookCallFacts, source: HookSource, env: Env): { rows: PropertyRow[], calls?: SourceRange } {
const { words } = env
if (!call.target)
return { rows: [] }
const own = source.origin === 'own'
const receivers = receiversOf(call.target)
const fns = receivers.map(receiver => !!resultOf(receiver, source.signature)?.fn)
const items = receivers.map((receiver, index): PropertyItem => {
const received = receivedName(receiver.node)
return {
cells: [receiverText(receiver, fns[index], env), ...(own ? [] : [meaningText(receiver, call, source, env)])],
...(received && { defines: range(received) }),
}
})
const called = receivers.find((receiver, index) => fns[index] && receivedName(receiver.node))
const calls = called && receivedName(called.node)
return {
rows: [{ label: words.labels.returns, value: [hint(words.count(items.length))], columns: own ? words.hookReturnHeads.slice(0, 1) : words.hookReturnHeads, items }],
...(calls && { calls: range(calls) }),
}
}
/** React 自带钩子的作用图标(悬停是分类名,签名表给了一句时跟在后面);别的钩子没有。 */
function roleConcepts(source: HookSource, { words }: Env): BlockConcept[] {
const role = source.signature?.role
if (!role)
return []
const body = isFunction(value) ? value.childForFieldName('body') : undefined
if (body?.type === 'statement_block')
return joined(statementsOf(value).map(statement => context.translate(statement)), hint(words.placeSeparator))
return context.translate(body ?? value)
const label = source.signature?.note ? words.roleNote(words.hookRoles[role], source.signature.note) : words.hookRoles[role]
return [{ id: `${KINDS.hook}.${role}`, label, ...ROLE_LOOKS[role] }]
}
/** 副作用:触发时机 / 清理函数(返回清理函数时才有,那句 return 并进来)。副作用函数里别的语句照常是这张卡片的子块。 */
function effectCard(block: CodeBlock, effect: EffectFacts, env: Env): RuleCard {
const { words } = env
const cleanup = effect.cleanup && block.children.find(child => sameNode(child.node, effect.cleanup))
return card('effect', [
{ label: words.labels.trigger, value: triggerText(effect, env) },
...(effect.cleanup ? [{ label: words.labels.cleanup, value: cleanupText(effect.cleanup, env) }] : []),
], env, { absorbs: cleanup ? [cleanup] : [] })
/**
* 钩子卡片:所有钩子共用,只有钩子 / 类型参数 / 参数 / 返回四行,没有的行不显示;不分析钩子里写了什么,实参里函数的语句照常是子块。
* 左边从钩子图标画起(不带它所在那句常量、语句的图标),React 自带的再加作用图标;描边是钩子紫,淡底按来源分。
* 只用一个名字接住返回值时,那个名字是卡片自己的名字;返回里有函数时能看它的调用树。
*/
function hookCard(call: HookCallFacts, env: Env): RuleCard {
const source = hookSource(call, env)
const returns = returnsRow(call, source, env)
const concepts = roleConcepts(source, env)
return card('hook', [
hookRow(call, source, env),
...typeArgumentsRow(call, env),
...parametersRow(call, source, env),
...returns.rows,
], env, {
...(call.target?.type === 'identifier' && { defines: range(call.target) }),
...(concepts.length > 0 && { concepts }),
languageConcepts: false,
tint: ORIGIN_TINTS[source.origin],
...(returns.calls && { calls: returns.calls }),
})
}
// ─── 自定义钩子 ───────────────────────────────────────────────────────────────
/** 自定义钩子的参数:「名称 | 类型」表,有参数带默认值时多一列默认值,说明里有参数对上了 `@param` 时多一列说明;没有参数写「无」。 */
function customParametersRow(hook: CustomHookFacts, docs: ParamDocs, env: Env): PropertyRow {
const { context, words } = env
const parameters = parametersOf(hook.fn)
const defaults = parameters.some(parameter => parameter.default)
const described = parameters.some(parameter => docs.of(parameter.name.text))
const [nameHead, typeHead, defaultHead, docHead] = words.parameterHeads
const items = parameters.map((parameter): PropertyItem => ({
cells: [
binding(parameter.name, 'variable', env),
parameter.type ? context.translate(parameter.type) : [hint(words.unmarked)],
...(defaults ? [parameter.default ? context.translate(parameter.default) : [hint(words.nothing)]] : []),
...(described ? [paramDocCell(docs, parameter.name.text)] : []),
],
defines: range(parameter.name),
}))
return {
label: words.labels.parameters,
value: [hint(words.count(items.length))],
...(items.length > 0 && { columns: [nameHead, typeHead, ...(defaults ? [defaultHead] : []), ...(described ? [docHead] : [])], items }),
}
}
/** 自定义钩子的返回:只列名称(见 [returnedItems]),名字带源码下标;读不出、说明里也没写 `@return` 时不出这一行。 */
function customReturnsRow(hook: CustomHookFacts, doc: Doc | undefined, { context, words }: Env): PropertyRow[] {
const items = (returnedItems(hook) ?? []).map((item): PropertyItem => ({
cells: [['identifier', 'shorthand_property_identifier', 'property_identifier'].includes(item.type) ? [name(item, 'variable')] : context.translate(item)],
}))
if (items.length === 0 && !doc?.returns)
return []
return [{
label: words.labels.returns,
value: items.length > 0 ? [hint(words.count(items.length))] : [],
...returnNote(doc),
...(items.length > 0 && { columns: words.hookReturnHeads.slice(0, 1), items }),
}]
}
/**
* 自定义钩子卡片:名称(后面跟导出方式)/ 说明 / 对不上的参数 / 参数 / 返回 / 异常(说明里的块标签拆开放,同事件处理函数)。
* 钩子体的语句照常是子块;样子同钩子卡片(钩子图标、钩子紫描边、自己写的淡底),能看这个钩子在哪被调用。
*/
function customHookCard(hook: CustomHookFacts, env: Env): RuleCard {
const { context, words } = env
const doc = hook.doc && context.doc(hook.doc)
const docs = paramDocs(doc, parametersOf(hook.fn).map(parameter => parameter.name.text))
return card('customHook', [
{ label: words.labels.name, value: [name(hook.name, 'function'), ...exportTags(hook.exported, words).flatMap(item => [plain(' '), item])] },
...docBodyRows(doc, words.labels.doc),
...unmatchedParamRows(docs.unmatched),
customParametersRow(hook, docs, env),
...customReturnsRow(hook, doc, env),
...throwsRows(doc),
], env, { defines: range(hook.name), languageConcepts: false, tint: ORIGIN_TINTS.own, calls: range(hook.name) })
}
// ─── 事件处理函数 ─────────────────────────────────────────────────────────────
/** 绑定事件:JSX 上的属性(「<button> 的 onClick」);没绑在 JSX 上时写由谁调用,都没有「—」。 */
function eventsText(handler: HandlerFacts, analysis: Analysis, env: Env): Inline[] {
const { words } = env
@@ -1014,32 +1166,18 @@ function renderCard(block: CodeBlock, env: Env): RuleCard | undefined {
// ─── 认领 ─────────────────────────────────────────────────────────────────────
/** 直接写在组件体里的一句:状态、引用、数据源、派生值、副作用、事件处理函数各成一张卡片;别的不认。 */
/** 直接写在组件体、钩子体里的一句:钩子调用是钩子卡片,组件体里定义的函数是事件处理函数卡片;别的不认。 */
function bodyCard(block: CodeBlock, env: Env): RuleCard | undefined {
if (!['statement', 'function', 'const', 'variable'].includes(block.kind))
return undefined
const component = enclosingComponent(block.node)
const call = (component || enclosingCustomHook(block.node)) && hookCallAt(block.node)
if (call)
return hookCard(call, env)
if (!component)
return undefined
const analysis = analysisOf(component)
const { body } = analysis
const at = (facts: { statement: Node }): boolean => sameNode(facts.statement, block.node)
const state = body.states.find(at)
if (state)
return stateCard(state, analysis, env)
const ref = body.refs.find(at)
if (ref)
return refCard(ref, analysis, env)
const read = body.reads.find(at)
if (read)
return sourceCard(read, env)
const derived = body.derived.find(at)
if (derived)
return derivedCard(derived, env)
const effect = body.effects.find(at)
if (effect)
return effectCard(block, effect, env)
const handler = body.handlers.find(at)
const handler = analysis.body.handlers.find(item => sameNode(item.statement, block.node))
return handler && handlerCard(block, handler, analysis, env)
}
@@ -1062,6 +1200,9 @@ export const reactRule: LibraryRule = {
const component = componentOf(block)
if (component)
return componentCard(component, env)
const hook = customHookOf(block)
if (hook)
return customHookCard(hook, env)
if (omitted(block))
return { omit: true }
return block.kind === 'return' ? renderCard(block, env) : bodyCard(block, env)
+175
View File
@@ -0,0 +1,175 @@
import type { TokenRole } from '../contract'
/**
* React 自带钩子的签名表,[React 规则](vscode/src/codeReader/rules/react.ts#reactRule)的钩子卡片照它排「参数」「返回」两行。
*
* 每个钩子登记一次:作用分类、形参(名字照 `@types/react` 的声明)和每个形参的种类、没传时怎么写,返回的各项是什么、哪几项是函数。
* 说法是中文的,同规则词表里的用词。
*/
/** 钩子的作用:卡片左边第二个图标按它画,悬停显示分类名 */
export type HookRole = 'state' | 'effect' | 'ref' | 'performance' | 'context' | 'concurrent' | 'external'
/**
* 形参的种类,决定「值」那一格怎么写:
* - value:普通值,照常翻;
* - lazy:可以是函数的初始值(`useState` 的 `initialState`),传函数时只在首次渲染调用它;
* - function:函数,写在这里的函数收成一个小框(函数体是卡片的子块),传的是名字时照抄;
* - deps:依赖项数组,写成「a、b 变化时」「仅挂载时」;
* - ref:引用别处定义的东西(上下文对象、父组件传进来的 ref),照抄成带源码下标的名字。
*/
export type ParamKind = 'value' | 'lazy' | 'function' | 'deps' | 'ref'
/** 没传这个参数时那一格写的:先一段提示色的字,再一段按 role 着色的字(「没写,」「每次渲染后」)。 */
export interface MissingText {
lead: string
text: string
role: TokenRole
}
/** 一个形参。 */
export interface HookParam {
name: string
kind: ParamKind
/** 没传时怎么写;不给就是没传时不出这一行 */
missing?: MissingText
}
/** 返回的一项。 */
export interface HookResult {
/** 是什么 */
say: string
/** 这一项是函数:卡片的调用树查接住它的那个名字 */
fn?: true
}
/** 一个钩子的签名。 */
export interface HookSignature {
role: HookRole
/** 作用图标悬停时跟在分类名后面的一句(`useLayoutEffect` 的「浏览器绘制前执行」) */
note?: string
params: HookParam[]
/** 传的实参比 `params` 多时换用的重载,挑第一个放得下的(`useReducer` 传三个实参时第二个叫 `initialArg`) */
overloads?: HookParam[][]
/** 返回的各项:返回元组时照位置一项一个,只返回一个值时只有一项,不返回时是空的 */
returns: HookResult[]
}
/** 依赖项没写:每次渲染后都跑(算)一遍 */
const EVERY_RENDER: MissingText = { lead: '没写,', text: '每次渲染后', role: 'keyword' }
const DEPS: HookParam = { name: 'deps', kind: 'deps', missing: EVERY_RENDER }
const REDUCER: HookParam = { name: 'reducer', kind: 'function' }
/** 副作用函数和它的依赖项:`useEffect`、`useLayoutEffect`、`useInsertionEffect` 一样 */
const EFFECT_PARAMS: HookParam[] = [{ name: 'effect', kind: 'function' }, DEPS]
/** 钩子名 → 签名,只登记 `react` 包自己的钩子。 */
export const REACT_HOOKS: Record<string, HookSignature> = {
useState: {
role: 'state',
params: [{ name: 'initialState', kind: 'lazy' }],
returns: [{ say: '当前值' }, { say: '修改函数', fn: true }],
},
useReducer: {
role: 'state',
params: [REDUCER, { name: 'initialState', kind: 'value' }],
overloads: [[REDUCER, { name: 'initialArg', kind: 'value' }, { name: 'init', kind: 'function' }]],
returns: [{ say: '当前值' }, { say: '派发函数', fn: true }],
},
useActionState: {
role: 'state',
params: [{ name: 'action', kind: 'function' }, { name: 'initialState', kind: 'value' }, { name: 'permalink', kind: 'value' }],
returns: [{ say: '当前状态' }, { say: '触发动作的函数', fn: true }, { say: '是否正在执行' }],
},
useOptimistic: {
role: 'state',
params: [{ name: 'passthrough', kind: 'value' }, REDUCER],
returns: [{ say: '乐观显示的值' }, { say: '乐观更新的函数', fn: true }],
},
useId: {
role: 'state',
params: [],
returns: [{ say: '稳定的唯一标识' }],
},
useContext: {
role: 'context',
params: [{ name: 'context', kind: 'ref' }],
returns: [{ say: '上下文的值' }],
},
use: {
role: 'context',
params: [{ name: 'usable', kind: 'value' }],
returns: [{ say: '读出的值' }],
},
useRef: {
role: 'ref',
params: [{ name: 'initialValue', kind: 'value', missing: { lead: '没写,', text: '未定义', role: 'storage' } }],
returns: [{ say: '引用对象' }],
},
useImperativeHandle: {
role: 'ref',
params: [{ name: 'ref', kind: 'ref' }, { name: 'init', kind: 'function' }, DEPS],
returns: [],
},
useEffect: {
role: 'effect',
params: EFFECT_PARAMS,
returns: [],
},
useLayoutEffect: {
role: 'effect',
note: '浏览器绘制前执行',
params: EFFECT_PARAMS,
returns: [],
},
useInsertionEffect: {
role: 'effect',
note: '布局副作用之前执行',
params: EFFECT_PARAMS,
returns: [],
},
useEffectEvent: {
role: 'effect',
params: [{ name: 'callback', kind: 'function' }],
returns: [{ say: '副作用里调用的事件函数', fn: true }],
},
useDebugValue: {
role: 'effect',
params: [{ name: 'value', kind: 'value' }, { name: 'format', kind: 'function' }],
returns: [],
},
useMemo: {
role: 'performance',
params: [{ name: 'factory', kind: 'function' }, DEPS],
returns: [{ say: '算出的值' }],
},
useCallback: {
role: 'performance',
params: [{ name: 'callback', kind: 'function' }, DEPS],
returns: [{ say: '记住的函数', fn: true }],
},
useTransition: {
role: 'concurrent',
params: [],
returns: [{ say: '是否正在过渡' }, { say: '启动过渡的函数', fn: true }],
},
useDeferredValue: {
role: 'concurrent',
params: [{ name: 'value', kind: 'value' }, { name: 'initialValue', kind: 'value' }],
returns: [{ say: '延迟更新的值' }],
},
useSyncExternalStore: {
role: 'external',
params: [{ name: 'subscribe', kind: 'function' }, { name: 'getSnapshot', kind: 'function' }, { name: 'getServerSnapshot', kind: 'function' }],
returns: [{ say: '数据源的当前快照' }],
},
}
/** 这次调用传了 `count` 个实参时的各形参:`params` 放不下时换第一个放得下的重载,都放不下时还是 `params`。 */
export function paramsFor(signature: HookSignature, count: number): HookParam[] {
if (count <= signature.params.length)
return signature.params
return signature.overloads?.find(overload => overload.length >= count) ?? signature.params
}
+82 -41
View File
@@ -3,7 +3,7 @@ import type { ComponentFacts, OutlineNode } from './reactTree'
import { describe, expect, it } from 'vitest'
import { createTypeScriptAdapter } from '../languages/typescript'
import { nodesToText, readFixture, walk, WASM_DIR, withBlocks } from '../testSupport'
import { bodyOf, classesOf, componentOf, componentOfFunction, declaredTypes, enclosingComponent, enclosingFunction, outlineOf, placesOf, propsOf } from './reactTree'
import { bodyOf, classesOf, componentOf, componentOfFunction, customHookOf, declaredTypes, enclosingComponent, enclosingCustomHook, enclosingFunction, hookCallAt, outlineOf, placesOf, propsOf, returnedItems } from './reactTree'
const ts = createTypeScriptAdapter(WASM_DIR)
@@ -237,40 +237,36 @@ describe('react 组件:属性', () => {
})
describe('react 组件:组件体', () => {
it('luffy 的 LegalDocPage:两个状态、一个引用、两处读数据、两个派生值、两个副作用、三个处理函数、一处渲染中重置、一处返回', async () => {
it('luffy 的 LegalDocPage:八处钩子调用、一个变量、三个处理函数、一处返回', async () => {
const facts = await withComponent(LEGAL, 'LegalDocPage', (component) => {
const body = bodyOf(component)
return {
states: nodesToText(body.states),
refs: nodesToText(body.refs),
reads: body.reads.map(read => `${read.target.text} = ${read.hook.text}`),
derived: body.derived.map(item => `${item.name.text} ${item.hook.text} ${item.deps?.text}`),
hooks: body.hooks.map(call => `${call.target?.text ?? '—'} = ${call.hook.text}`),
variables: body.variables.map(item => item.name.text),
effects: body.effects.map(effect => `${effect.hook.text} ${effect.deps?.text} ${effect.cleanup ? '有清理' : '无清理'}`),
handlers: body.handlers.map(handler => handler.name.text),
resets: body.resets.map(reset => reset.condition.text),
returns: body.returns.map(statement => statement.type),
}
})
expect(facts.states).toMatchObject([
{ hook: 'useState', value: 'activeSection', setter: 'setActiveSection', initial: 'headings[0]?.id ?? ""' },
{ hook: 'useState', value: 'prevDoc', setter: 'setPrevDoc', initial: 'doc' },
expect(facts.hooks).toEqual([
'cfg = useSiteConfig',
'router = useRouter',
'headings = useMemo',
'scrollRef = useRef',
'[activeSection, setActiveSection] = useState',
'[prevDoc, setPrevDoc] = useState',
'— = useEffect',
'— = useEffect',
])
expect(facts.refs).toMatchObject([{ name: 'scrollRef', initial: 'null' }])
expect(facts.reads).toEqual(['cfg = useSiteConfig', 'router = useRouter'])
expect(facts.derived).toEqual(['headings useMemo [markdown]'])
expect(facts.variables).toEqual(['markdown'])
expect(facts.effects).toEqual(['useEffect [doc] 无清理', 'useEffect [doc, headings] 有清理'])
expect(facts.handlers).toEqual(['handleSectionClick', 'handlePrint', 'handleBack'])
expect(facts.resets).toEqual(['doc !== prevDoc'])
expect(facts.returns).toEqual(['return_statement'])
})
it('luffy 的 DesktopSettingsDialog:先 return null 的提前返回也算渲染的一句;StepLabel 直接返回的 JSX 是唯一的一句', async () => {
it('luffy 的 DesktopSettingsDialog:先 return null 的提前返回也算渲染的一句;值是钩子调用再取属性的是变量;StepLabel 直接返回的 JSX 是唯一的一句', async () => {
const returns = await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).returns.map(statement => statement.type))
expect(returns).toEqual(['if_statement', 'return_statement'])
const reads = await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).reads.map(read => `${read.target.text} = ${read.call.text}`))
expect(reads).toEqual([
const hooks = await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).hooks.map(call => `${call.target?.text} = ${call.call.text}`))
expect(hooks).toEqual([
'section = useDesktopShell((s) => s.settingsSection)',
'openSettings = useDesktopShell((s) => s.openSettings)',
'closeSettings = useDesktopShell((s) => s.closeSettings)',
@@ -280,51 +276,96 @@ describe('react 组件:组件体', () => {
expect(step).toBe(1)
})
it('luffy 的 ShareButton:useCallback 算派生值、带依赖;没写依赖的副作用、只在挂载时的副作用', async () => {
const facts = await withComponent(SHARE, 'ShareButton', (component) => {
const body = bodyOf(component)
return {
derived: body.derived.map(item => `${item.name.text} ${item.hook.text} ${item.deps?.text}`),
refs: body.refs.map(ref => ref.name.text),
effects: body.effects.map(effect => effect.deps?.text),
}
it('luffy 的 ShareButton:useState、useRef、useEffect、useCallback 都是钩子调用,整句调用的没有接返回值的变量', async () => {
const hooks = await withComponent(SHARE, 'ShareButton', component => bodyOf(component).hooks.map(call => `${call.hook.text}${call.target ? '' : '(整句)'}`))
expect(hooks).toEqual(['useState', 'useState', 'useState', 'useRef', 'useEffect(整句)', 'useRef', 'useCallback', 'useEffect(整句)'])
})
})
describe('react 组件:钩子调用、自定义钩子', () => {
const SOURCE = [
'import * as React from "react";',
'export function useCount(start: number) {',
' const [n, setN] = React.useState(start);',
' useEffect(() => log(n), [n]);',
' const doubled = n * 2;',
' if (n > 9) reset();',
' return { n, setN, twice: doubled };',
'}',
'export default function usePair() {',
' return [useCount(0), useCount(1)];',
'}',
'const useName = () => name;',
'const useWrapped = () => useContext(Ctx) ?? fallback;',
'function use() {}',
].join('\n')
it('认自定义钩子:名字是 use 后面接大写字母或数字的函数声明、值是函数的常量;只叫 use 的不算', async () => {
const found = await withBlocks(ts, 'typescriptreact', SOURCE, blocks => blocks.flatMap((block) => {
const hook = customHookOf(block)
return hook ? [`${hook.name.text} ${hook.exported ?? '不导出'}`] : []
}))
expect(found).toEqual(['useCount named', 'usePair default', 'useName 不导出', 'useWrapped 不导出'])
})
it('hookCallAt:只声明一个变量、值是钩子调用(React.useX 取 useX),或整句就是一次钩子调用;钩子体里的语句认得出在哪个自定义钩子里', async () => {
const found = await withBlocks(ts, 'typescriptreact', SOURCE, (blocks) => {
const lines: string[] = []
walk(blocks, (block) => {
const owner = enclosingCustomHook(block.node)
const call = hookCallAt(block.node)
if (owner)
lines.push(`${owner.name.text}: ${block.node.text.split('\n')[0]} → ${call ? `${call.hook.text}${call.target ? ` ${call.target.text}` : ''}` : '不是'}`)
})
return lines
})
expect(facts.derived).toEqual(['startShare useCallback [deployUrl, sessionId, title]'])
expect(facts.refs).toEqual(['activeRef', 'creatingRef'])
expect(facts.effects).toEqual(['[]', '[open, deployUrl, landingUrl, startShare]'])
expect(found).toEqual([
'useCount: const [n, setN] = React.useState(start); → useState [n, setN]',
'useCount: useEffect(() => log(n), [n]); → useEffect',
'useCount: const doubled = n * 2; → 不是',
'useCount: if (n > 9) reset(); → 不是',
'useCount: return { n, setN, twice: doubled }; → 不是',
'usePair: return [useCount(0), useCount(1)]; → 不是',
])
})
it('返回的各项:return 的对象简写、改名的键、数组、标识符;直接返回别的表达式的读不出', async () => {
const found = await withBlocks(ts, 'typescriptreact', SOURCE, blocks => blocks.flatMap((block) => {
const hook = customHookOf(block)
return hook ? [`${hook.name.text}: ${returnedItems(hook)?.map(item => item.text).join(', ') ?? '读不出'}`] : []
}))
expect(found).toEqual(['useCount: n, setN, twice', 'usePair: useCount(0), useCount(1)', 'useName: name', 'useWrapped: 读不出'])
})
})
describe('react 组件:用在哪', () => {
it('luffy 的 LegalDocPage:改状态的函数在副作用、渲染中重置里;引用挂在 <main> 的 ref 上、副作用和处理函数里读它;处理函数绑在按钮上', async () => {
it('luffy 的 LegalDocPage:名字用在钩子调用、处理函数、JSX 属性上;处理函数绑在按钮上', async () => {
const places = await withComponent(LEGAL, 'LegalDocPage', (component) => {
const body = bodyOf(component)
const say = (name: Node): string[] => placesOf(name, component, body).map((place) => {
switch (place.kind) {
case 'effect':
return `副作用 ${place.index + 1}`
case 'hook':
return `钩子 ${place.call.hook.text} ${place.call.statement.startPosition.row + 1}`
case 'handler':
case 'derived':
return `${place.kind} ${place.name.text}`
case 'reset':
return `重置 ${place.condition.text}`
case 'jsx':
return `<${place.element.childForFieldName('name')?.text}> ${place.attribute.text}${place.each ? ' 每个' : ''}${place.call ? ` 调用 ${place.call.text}` : ''}`
default:
return place.kind
}
})
const target = (hook: string): Node => body.hooks.find(call => call.target?.text === hook)?.target as Node
return {
setActiveSection: say(body.states[0].setter as Node),
scrollRef: say(body.refs[0].name),
setActiveSection: say(target('[activeSection, setActiveSection]').namedChildren[1] as Node),
scrollRef: say(target('scrollRef')),
handleSectionClick: say(body.handlers[0].name),
handleBack: say(body.handlers[2].name),
router: say(body.reads[1].target),
router: say(target('router')),
}
})
expect(places).toEqual({
setActiveSection: ['重置 doc !== prevDoc', '副作用 2'],
scrollRef: ['副作用 1', '副作用 2', 'handler handleSectionClick', '<main> ref'],
setActiveSection: ['钩子 useEffect 81'],
scrollRef: ['钩子 useEffect 76', '钩子 useEffect 81', 'handler handleSectionClick', '<main> ref'],
handleSectionClick: ['<button> onClick 每个 调用 handleSectionClick(h.id)'],
handleBack: ['<button> onClick'],
router: ['handler handleBack'],
+145 -165
View File
@@ -1,7 +1,8 @@
import type { CodeBlock, Node } from '../contract'
/**
* 读 React 函数组件的语法树(React 库规则用,见 react.ts):认组件,读出它的构造参数、组件体里的各种钩子、处理函数和渲染的 JSX。
* 读 React 函数组件、自定义钩子的语法树(React 库规则用,见 react.ts):认组件和自定义钩子,读出组件的构造参数、
* 组件体和钩子体里的钩子调用、组件里的处理函数和渲染的 JSX、自定义钩子返回的各项。
* 只读语法树、不翻译,读出来的节点都指向源码;怎么排成卡片、用什么词归 react.ts。
*/
@@ -251,8 +252,7 @@ export function componentAt(statement: Node): ComponentFacts | undefined {
const body = declared.fn.childForFieldName('body')
if (!body)
return undefined
const exportedName = exportedAs(statement, declared.name.text)
const exported = exportedName && (exportedName === 'default' ? 'default' : 'named')
const exported = exportKind(statement, declared.name.text)
const doc = jsDocBefore(statement)
return {
statement,
@@ -268,6 +268,14 @@ export function componentAt(statement: Node): ComponentFacts | undefined {
}
}
/** 名字是具名导出还是默认导出(见 [exportedAs]);没导出时没有。 */
function exportKind(statement: Node, name: string): 'named' | 'default' | undefined {
const exported = exportedAs(statement, name)
if (exported === undefined)
return undefined
return exported === 'default' ? 'default' : 'named'
}
/** 块是 React 函数组件时读出它(见 componentAt):函数块、只声明一个变量的常量 / 变量块。 */
export function componentOf(block: CodeBlock): ComponentFacts | undefined {
if (block.kind !== 'function' && block.kind !== 'const' && block.kind !== 'variable')
@@ -283,12 +291,17 @@ export function componentsIn(root: Node): ComponentFacts[] {
})
}
/** 语句直接写在哪个组件的组件体里(组件体是语句块时);不是组件体里的语句时没有。 */
export function enclosingComponent(statement: Node): ComponentFacts | undefined {
/** 语句直接写在哪个函数的主体(语句块)里;不是函数主体里的语句时没有。 */
function bodyOwner(statement: Node): Node | undefined {
const body = statement.parent
const fn = body?.type === 'statement_block' ? body.parent : null
const component = fn && isFunction(fn) ? componentOfFunction(fn) : undefined
return component && sameNode(component.body, body) ? component : undefined
return fn && isFunction(fn) && sameNode(fn.childForFieldName('body'), body) ? fn : undefined
}
/** 语句直接写在哪个组件的组件体里(组件体是语句块时);不是组件体里的语句时没有。 */
export function enclosingComponent(statement: Node): ComponentFacts | undefined {
const fn = bodyOwner(statement)
return fn && componentOfFunction(fn)
}
/** node 所在的最内层函数(node 自己不算);不在函数里时没有。 */
@@ -300,9 +313,9 @@ export function enclosingFunction(node: Node): Node | undefined {
return undefined
}
/** 函数是组件函数时读出这个组件;副作用、处理函数这类别的函数没有。 */
export function componentOfFunction(fn: Node): ComponentFacts | undefined {
// 组件函数只会是函数声明、变量的值,或 memo / forwardRef 的实参(可以套几层)
/** 声明这个函数的那条语句(`export` 包着时是 `export` 语句):函数声明自己,或值是它(`memo` / `forwardRef` 包着也算)的变量声明;别的函数没有。 */
function declaringStatement(fn: Node): Node | undefined {
// 声明出来的函数只会是函数声明、变量的值,或 memo / forwardRef 的实参(可以套几层)
let outer: Node | null = fn
if (fn.type !== 'function_declaration') {
let value: Node = fn
@@ -319,10 +332,114 @@ export function componentOfFunction(fn: Node): ComponentFacts | undefined {
}
if (outer?.parent?.type === 'export_statement')
outer = outer.parent
return outer ?? undefined
}
/** 函数是组件函数时读出这个组件;副作用、处理函数这类别的函数没有。 */
export function componentOfFunction(fn: Node): ComponentFacts | undefined {
const outer = declaringStatement(fn)
const component = outer ? componentAt(outer) : undefined
return component && sameNode(component.fn, fn) ? component : undefined
}
// ─── 自定义钩子 ───────────────────────────────────────────────────────────────
/** 认出的一个自定义钩子;节点都指向源码。 */
export interface CustomHookFacts {
/** 钩子所在的那条语句(`export` 包着时是 `export` 语句)。 */
statement: Node
/** 钩子名:`use` 后面接大写字母或数字。 */
name: Node
/** 钩子函数:函数声明、箭头函数、函数表达式。 */
fn: Node
/** 钩子函数的主体:语句块,或箭头函数直接返回的表达式。 */
body: Node
/** 具名导出、默认导出;没导出没有。 */
exported?: 'named' | 'default'
/** 紧贴在前面的 JSDoc:钩子的说明。 */
doc?: Node
}
/** 语句声明的是自定义钩子时读出它:函数声明、值是函数的变量,名字是 `use` 后面接大写字母或数字(只叫 `use` 的是 React 自己的)。 */
export function customHookAt(statement: Node): CustomHookFacts | undefined {
const declared = declaredFunction(statement)
const body = declared?.fn.childForFieldName('body')
if (!declared || declared.wrappers.length > 0 || !body || declared.name.text === 'use' || !isHook(declared.name.text))
return undefined
const exported = exportKind(statement, declared.name.text)
const doc = jsDocBefore(statement)
return { statement, name: declared.name, fn: declared.fn, body, ...(exported && { exported }), ...(doc && { doc }) }
}
/** 块是自定义钩子时读出它(见 [customHookAt]):函数块、只声明一个变量的常量 / 变量块。 */
export function customHookOf(block: CodeBlock): CustomHookFacts | undefined {
if (block.kind !== 'function' && block.kind !== 'const' && block.kind !== 'variable')
return undefined
return customHookAt(block.node)
}
/** 文件顶层声明的各个自定义钩子,照源码顺序。 */
export function customHooksIn(root: Node): CustomHookFacts[] {
return syntax(root).flatMap((statement) => {
const hook = customHookAt(statement)
return hook ? [hook] : []
})
}
/** 语句直接写在哪个自定义钩子的钩子体里(钩子体是语句块时);不是钩子体里的语句时没有。 */
export function enclosingCustomHook(statement: Node): CustomHookFacts | undefined {
const fn = bodyOwner(statement)
const outer = fn && declaringStatement(fn)
const hook = outer ? customHookAt(outer) : undefined
return hook && sameNode(hook.fn, fn) ? hook : undefined
}
/**
* 自定义钩子返回的各项,从最后那句 return(直接返回表达式的箭头函数是那个表达式)读:对象写的是各个键(简写的、改了名的都取键,
* `...x` 是它自己),数组是各项,标识符是它自己。返回别的写法、没有返回时没有。
*/
export function returnedItems(hook: CustomHookFacts): Node[] | undefined {
const value = unwrap(hook.body.type === 'statement_block' ? syntax(ownReturns(hook.body).at(-1))[0] : hook.body)
switch (value?.type) {
case 'identifier':
return [value]
case 'array':
return syntax(value)
case 'object':
return syntax(value).flatMap((member) => {
if (member.type === 'pair' || member.type === 'method_definition')
return member.childForFieldName(member.type === 'pair' ? 'key' : 'name') ?? []
return [member]
})
default:
return undefined
}
}
// ─── 钩子调用 ─────────────────────────────────────────────────────────────────
/** 直接写在组件体、钩子体里的一处钩子调用:`const x = useX(…)`、`const [a, b] = useX(…)`、整句的 `useEffect(…)`。 */
export interface HookCallFacts {
statement: Node
/** 这次调用 */
call: Node
/** 被调的钩子的名字:`useState(…)`、`React.useState(…)` 都是 useState 那一节 */
hook: Node
/** 接住返回值的变量名或解构模式;整句只是调用时没有 */
target?: Node
}
/** 语句是一处钩子调用时读出它(见 [HookCallFacts]):只声明一个变量、值是钩子调用,或整句就是一次钩子调用;别的语句没有。 */
export function hookCallAt(statement: Node): HookCallFacts | undefined {
const declarator = onlyDeclarator(statement)
const target = declarator?.childForFieldName('name') ?? undefined
const call = unwrap(declarator ? declarator.childForFieldName('value') : statement.type === 'expression_statement' ? syntax(statement)[0] : undefined)
const hook = hookOf(call)
if (!call || !hook || (declarator && !target))
return undefined
return { statement, call, hook, ...(target && { target }) }
}
// ─── 属性 ─────────────────────────────────────────────────────────────────────
/** 属性类型里的一个字段;节点都指向源码。 */
@@ -454,7 +571,7 @@ export interface BindingFacts {
}
/** 对象解构的各项,照源码顺序。 */
function bindingsOf(pattern: Node): BindingFacts[] {
export function bindingsOf(pattern: Node): BindingFacts[] {
return syntax(pattern).flatMap((item): BindingFacts[] => {
switch (item.type) {
case 'shorthand_property_identifier_pattern':
@@ -529,47 +646,6 @@ export function propsOf(component: ComponentFacts): PropsFacts {
// ─── 组件体 ───────────────────────────────────────────────────────────────────
/** 状态:`const [a, setA] = useState(初始值)`、`const [state, dispatch] = useReducer(reducer, 初始值)`。 */
export interface StateFacts {
statement: Node
hook: Node
/** 状态的名字(解构的第一项;没解构时是整个变量名)。 */
value: Node
/** 更新它的函数(解构的第二项);没解构出来时没有。 */
setter?: Node
/** 初始值:useState 的实参,useReducer 的第二个实参。 */
initial?: Node
/** useReducer 的第一个实参:按动作算下一个状态的函数。 */
reducer?: Node
}
/** 引用:`const r = useRef(初始值)`。 */
export interface RefFacts {
statement: Node
name: Node
initial?: Node
}
/** 数据源:别的钩子调用赋给的变量(`const pathname = usePathname()`、`const cfg = useSiteConfig()`)。 */
export interface ReadFacts {
statement: Node
/** 赋给的变量(名字,或解构模式)。 */
target: Node
call: Node
hook: Node
}
/** 派生值:useMemo / useCallback 算出来的。 */
export interface DerivedFacts {
statement: Node
name: Node
hook: Node
/** 第一个实参:算值的函数、要记住的函数。 */
callback?: Node
/** 第二个实参:依赖数组。 */
deps?: Node
}
/** 组件体里别的变量(`const x = 表达式`):照常是变量块,记下来只为认出名字用在哪。 */
export interface VariableFacts {
statement: Node
@@ -577,18 +653,6 @@ export interface VariableFacts {
name: Node
}
/** 副作用:`useEffect(() => { … }, [deps])`、useLayoutEffect、useInsertionEffect。 */
export interface EffectFacts {
statement: Node
hook: Node
/** 第一个实参:副作用的函数。 */
callback?: Node
/** 第二个实参:依赖数组;没写时每次渲染后都跑。 */
deps?: Node
/** 副作用函数主体里最后返回清理函数的那句 return;没有时没有。 */
cleanup?: Node
}
/** 处理函数:组件体里定义的函数(函数声明、值是函数的常量)。 */
export interface HandlerFacts {
statement: Node
@@ -596,37 +660,15 @@ export interface HandlerFacts {
fn: Node
}
/** 渲染时按条件改状态(React 的「渲染中重置」写法):`if (doc !== prevDoc) { setPrevDoc(doc); … }`。 */
export interface ResetFacts {
statement: Node
condition: Node
}
/** 组件体里各种语句归到哪儿:规则按它认领组件体里的块,也按它说名字用在哪。 */
/** 组件体里各种语句归到哪儿:规则按它认领组件体里的处理函数,也按它说名字用在哪。 */
export interface BodyFacts {
states: StateFacts[]
refs: RefFacts[]
reads: ReadFacts[]
derived: DerivedFacts[]
hooks: HookCallFacts[]
variables: VariableFacts[]
effects: EffectFacts[]
handlers: HandlerFacts[]
resets: ResetFacts[]
/** 渲染的那几句:return、提前返回的 `if (cond) return …`;组件体是表达式时是它自己。 */
returns: Node[]
}
const STATE_HOOKS = new Set(['useState', 'useReducer'])
const MEMO_HOOKS = new Set(['useMemo', 'useCallback'])
const EFFECT_HOOKS = new Set(['useEffect', 'useLayoutEffect', 'useInsertionEffect'])
/** 副作用函数主体里最后一句:返回清理函数的 return(`return () => …`、`return cleanup`)。 */
function cleanupOf(callback: Node | undefined): Node | undefined {
const body = callback?.childForFieldName('body')
const last = body?.type === 'statement_block' ? syntax(body).at(-1) : undefined
return last?.type === 'return_statement' && !isNothing(syntax(last)[0]) ? last : undefined
}
/** 只声明一个变量的语句里的那个变量;声明了几个、不是声明时没有。 */
function onlyDeclarator(statement: Node): Node | undefined {
if (statement.type !== 'lexical_declaration' && statement.type !== 'variable_declaration')
@@ -647,51 +689,20 @@ function isEarlyReturn(statement: Node): boolean {
return isNothing(value) || isJsx(value)
}
/** 一句 if 的主体都是在调用改状态的函数(没有 else):渲染时按条件重置状态。 */
function resetOf(statement: Node, setters: ReadonlySet<string>): ResetFacts | undefined {
if (statement.type !== 'if_statement' || statement.childForFieldName('alternative'))
return undefined
const consequence = statement.childForFieldName('consequence')
const condition = unwrap(statement.childForFieldName('condition'))
const calls = (consequence?.type === 'statement_block' ? syntax(consequence) : consequence ? [consequence] : [])
.map(item => item.type === 'expression_statement' ? unwrap(syntax(item)[0]) : undefined)
const names = calls.map(call => call?.type === 'call_expression' ? unwrap(call.childForFieldName('function')) : undefined)
if (!condition || names.length === 0 || names.some(name => name?.type !== 'identifier' || !setters.has(name.text)))
return undefined
return { statement, condition }
}
/** 组件体里的各种语句(见 BodyFacts)。 */
export function bodyOf(component: ComponentFacts): BodyFacts {
const facts: BodyFacts = { states: [], refs: [], reads: [], derived: [], variables: [], effects: [], handlers: [], resets: [], returns: [] }
const facts: BodyFacts = { hooks: [], variables: [], handlers: [], returns: [] }
if (component.body.type !== 'statement_block') {
facts.returns.push(component.body)
return facts
}
const setters = new Set<string>()
for (const statement of syntax(component.body)) {
const declarator = onlyDeclarator(statement)
const target = declarator?.childForFieldName('name')
const value = unwrap(declarator?.childForFieldName('value'))
const hook = hookOf(value)
if (declarator && target && value && hook && STATE_HOOKS.has(hook.text)) {
const [first, second] = target.type === 'array_pattern' ? syntax(target) : [target]
const args = argumentsOf(value)
const initial = hook.text === 'useReducer' ? args[1] : args[0]
if (second)
setters.add(second.text)
facts.states.push({ statement, hook, value: first ?? target, ...(second && { setter: second }), ...(initial && { initial }), ...(hook.text === 'useReducer' && args[0] && { reducer: args[0] }) })
}
else if (declarator && target && value && hook?.text === 'useRef') {
const [initial] = argumentsOf(value)
facts.refs.push({ statement, name: target, ...(initial && { initial }) })
}
else if (declarator && target && value && hook && MEMO_HOOKS.has(hook.text)) {
const [callback, deps] = argumentsOf(value)
facts.derived.push({ statement, name: target, hook, ...(callback && { callback }), ...(deps && { deps }) })
}
else if (declarator && target && value && hook) {
facts.reads.push({ statement, target, call: value, hook })
const hook = hookCallAt(statement)
if (hook) {
facts.hooks.push(hook)
}
else if (componentAt(statement)) {
// 组件里又定义的组件照常成块(自己也是一张组件卡片),不算处理函数
@@ -705,23 +716,9 @@ export function bodyOf(component: ComponentFacts): BodyFacts {
else if (statement.type === 'function_declaration' && statement.childForFieldName('name')) {
facts.handlers.push({ statement, name: statement.childForFieldName('name') as Node, fn: statement })
}
else if (statement.type === 'expression_statement') {
const call = unwrap(syntax(statement)[0])
const effect = call?.type === 'call_expression' ? calleeName(call) : undefined
if (call && effect && EFFECT_HOOKS.has(effect.text)) {
const [callback, deps] = argumentsOf(call)
const cleanup = cleanupOf(callback)
facts.effects.push({ statement, hook: effect, ...(callback && { callback }), ...(deps && { deps }), ...(cleanup && { cleanup }) })
}
}
else if (statement.type === 'return_statement' || isEarlyReturn(statement)) {
facts.returns.push(statement)
}
else {
const reset = resetOf(statement, setters)
if (reset)
facts.resets.push(reset)
}
}
return facts
}
@@ -761,17 +758,14 @@ export function statementsOf(fn: Node): Node[] {
// ─── 用在哪 ───────────────────────────────────────────────────────────────────
/**
* 组件里一个名字(改状态的函数、引用、处理函数)用到的一处在哪:某个副作用里(第几个,0 起)、某个处理函数里、某个派生值里、
* 某个别的变量的值里、某个读数据的钩子调用里、渲染时按条件重置状态的 if 里、JSX 的某个属性上(element 是开标签,
* each 表示在 .map 里一项项渲染,call 是在属性值里调用它的那次调用),或渲染的别处(JSX 里、提前返回的条件)。
* 组件里一个名字(处理函数、钩子返回的值)用到的一处在哪:某处钩子调用里(实参的函数里也算)、某个处理函数里、
* 某个别的变量的值里、JSX 的某个属性上(element 是开标签,each 表示在 .map 里一项项渲染,call 是在属性值里调用它的那次调用),
* 或渲染的别处(JSX 里、提前返回的条件)。
*/
export type Place
= | { kind: 'effect', index: number }
= | { kind: 'hook', call: HookCallFacts }
| { kind: 'handler', name: Node }
| { kind: 'derived', name: Node }
| { kind: 'variable', name: Node }
| { kind: 'read', target: Node }
| { kind: 'reset', condition: Node }
| { kind: 'jsx', element: Node, attribute: Node, each: boolean, call?: Node }
| { kind: 'render' }
@@ -809,48 +803,34 @@ function jsxPlace(node: Node, statement: Node): Place {
return { kind: 'jsx', element, attribute: name, each, ...(call && { call }) }
}
/** 名字的一处用到在哪(见 Place);在状态、引用的声明里(初始值)这类不算的是 undefined。 */
/** 名字的一处用到在哪(见 [Place]);组件体里别的语句(`if`、整句的普通调用)里的不算,是 `undefined`。 */
function placeOf(node: Node, component: ComponentFacts, body: BodyFacts): Place | undefined {
const statement = statementIn(node, component)
if (!statement)
return undefined
const at = (facts: { statement: Node }): boolean => sameNode(facts.statement, statement)
const effect = body.effects.findIndex(at)
if (effect >= 0)
return { kind: 'effect', index: effect }
const hook = body.hooks.find(at)
if (hook)
return { kind: 'hook', call: hook }
const handler = body.handlers.find(at)
if (handler)
return { kind: 'handler', name: handler.name }
const derived = body.derived.find(at)
if (derived)
return { kind: 'derived', name: derived.name }
const variable = body.variables.find(at)
if (variable)
return { kind: 'variable', name: variable.name }
const read = body.reads.find(at)
if (read)
return { kind: 'read', target: read.target }
const reset = body.resets.find(at)
if (reset)
return { kind: 'reset', condition: reset.condition }
if (body.returns.some(item => sameNode(item, statement)))
return jsxPlace(node, statement)
return undefined
}
/** 两处用到算同一处:同一个副作用、处理函数、派生值……,JSX 上同一个元素的同一个属性。 */
/** 两处用到算同一处:同一处钩子调用、同一个处理函数、变量,JSX 上同一个元素的同一个属性。 */
function placeKey(place: Place): string {
switch (place.kind) {
case 'effect':
return `effect:${place.index}`
case 'hook':
return `hook:${place.call.statement.startIndex}`
case 'handler':
case 'derived':
case 'variable':
return `${place.kind}:${place.name.startIndex}`
case 'read':
return `read:${place.target.startIndex}`
case 'reset':
return `reset:${place.condition.startIndex}`
case 'jsx':
return `jsx:${place.attribute.startIndex}`
default: