✨ feat(vscode): 代码阅读 React 组件卡片按钩子分区:状态 / 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理;副作用、处理函数成子卡片放进卡片那一行下面(库规则新增把子块放进属性表某一行的机制,事件处理表点名称展开)

This commit is contained in:
cruldra committed 2026-10-03 03:11:46 +08:00
1 parent 15f3a6600d
commit ee807ec7a4
19 files changed
+1057 -56

No files matched your search

+14 -4
View File
@@ -31,9 +31,17 @@ interface BlockViewBase {
attributes: BlockAttributes
/** 库规则的卡片才有:种类的名字、颜色和图标(RuleCard.badge)。预览同类块一样默认收起它的子块。 */
badge?: KindBadge
/** 库规则放进父卡片里的子块才有(见 RuleSlot):画在父卡片属性表第 row 行下面,不排在父卡片的子块里。 */
slot?: BlockSlot
children: BlockView[]
}
/** 子块画在父卡片的哪儿:属性表第 row 行下面;item 是讲它的那个子行,点那个子行的第一格才展开(见 RuleSlot.item)。 */
export interface BlockSlot {
row: number
item?: number
}
/**
* 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(TextTranslation.table),块的说明(TextBlock.doc,翻译插件给的 Markdown 原文)。
@@ -325,6 +333,7 @@ export function toBlockViews(
return plain({ ...replacement.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope)
}
const { kind, badge, rows, defines, attributes } = replacement
const slots = new Map((replacement.slots ?? []).map(({ block: child, row, item }): [CodeBlock, BlockSlot] => [child, { row, ...(item !== undefined && { item }) }]))
const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(span))
return {
id,
@@ -335,18 +344,19 @@ export function toBlockViews(
...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }),
attributes: { ...blockAttributes(block), ...attributes, kind },
badge,
children: list(kept, `${id}.`, inner),
children: list(kept, `${id}.`, inner, 0, slots),
properties: rows,
}
}
/** 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。 */
function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0): BlockView[] {
/** 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。slots 是父卡片放进它属性表里的那几块(见 BlockSlot)。 */
function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0, slots?: ReadonlyMap<CodeBlock, BlockSlot>): BlockView[] {
const spans = blockSpans(siblings, source)
const views: BlockView[] = []
siblings.forEach((block, i) => {
const view = build(block, spans[i], `${prefix}${from + views.length}`, scope)
const slot = slots?.get(block)
if (view)
views.push(view)
views.push(slot ? { ...view, slot } : view)
})
return views
}
+13
View File
@@ -813,6 +813,19 @@ export interface RuleCard {
* 这一块的编辑范围同别的块,是原块整条语句(类就是整个类),盖住留下的子块:它编辑时预览不画这些子块,同一行不会同时在两处能改。
*/
absorbs: CodeBlock[]
/** 画在卡片某一行下面的子块(见 RuleSlot);一块只能放一处。 */
slots?: RuleSlot[]
}
/**
* 卡片里的一处子块:block(原块 children 里的对象,没并进来的)不排在卡片后面,画在卡片第 row 行下面当子卡片,
* 照常转成块(翻译、库规则、编辑、问 AI、差异都照常)。item 是这一行的子行里讲它的那一个(第几个,0 起):
* 这时它默认收着,点那个子行的第一格才展开、一次只展开一个(React 组件的事件处理表点名称展开处理函数)。
*/
export interface RuleSlot {
block: CodeBlock
row: number
item?: number
}
/**
+28 -1
View File
@@ -1,5 +1,5 @@
import type { BlockView, RuleRun } from './blockView'
import type { CodeBlock, LibraryRule, RuleBlock, RuleLocation, RuleWorkspace, Segment } from './contract'
import type { CodeBlock, LibraryRule, RuleBlock, RuleLocation, RuleSlot, RuleWorkspace, Segment } from './contract'
import { describe, expect, it } from 'vitest'
import { fileAttributes } from './attributes'
import { toBlockViews } from './blockView'
@@ -193,6 +193,33 @@ describe('库规则:认领', () => {
expect(reports).toEqual(['库规则 omit 给第 3 行的 class 块的替身不合约定,这块照常显示:要略去的块还有子块'])
})
it('放进卡片属性表里的子块:照常转成块,记下画在哪一行(哪个子行);不是留下的子块、没有那一行 / 子行、放了两处的不合约定', async () => {
const rows = [{ label: '字段', value: [], items: [{ cells: [[{ text: 'name', role: 'variable' as const }]] }], columns: ['名称'] }, { label: '方法', value: [] }]
const slotting = (slots: (block: CodeBlock) => RuleSlot[], id = 'slot'): LibraryRule => ({
...cardRule(id),
claim: block => block.kind === 'class'
? { kind: `${id}.card`, badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows, absorbs: block.children.slice(0, 1), slots: slots(block) }
: undefined,
})
const [, card] = await views(MODEL, [slotting(block => [{ block: block.children[2], row: 0, item: 0 }, { block: block.children[3], row: 1 }])])
expect(card.children.map(child => [child.kind, child.slot])).toEqual([['comment', undefined], ['variable', { row: 0, item: 0 }], ['function', { row: 1 }]])
const reports: string[] = []
const bad = await views(MODEL, [
slotting(block => [{ block: block.children[0], row: 1 }], 'absorbed'),
slotting(block => [{ block: block.children[3], row: 5 }], 'row'),
slotting(block => [{ block: block.children[3], row: 1, item: 0 }], 'item'),
slotting(block => [{ block: block.children[3], row: 1 }, { block: block.children[3], row: 0 }], 'twice'),
], reports)
expect(bad[1].kind).toBe('class')
expect(reports.map(report => report.replace(/^.*照常显示:/, ''))).toEqual([
'放进卡片里的块不是留下的子块',
'放进卡片第 5 行下面的块,卡片没有这一行',
'放进卡片第 1 行第 0 个子行的块,这一行没有这个子行',
'同一块放进了卡片里的几处',
])
})
it('认成契约里现成的属性表块:种类、属性表、筛选属性照语言插件报的这种块,编辑范围是整个类;并进来的不是子块时照常显示', async () => {
const asEnum = (absorbs: (block: CodeBlock) => CodeBlock[]): LibraryRule => ({
...cardRule('lib'),
+11
View File
@@ -48,6 +48,17 @@ function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): str
return undefined
if (!claimed.kind.startsWith(`${rule.id}.`))
return `种类 id「${claimed.kind}」没以「${rule.id}.」开头`
const slots = claimed.slots ?? []
if (slots.some(slot => !block.children.includes(slot.block) || claimed.absorbs.includes(slot.block)))
return '放进卡片里的块不是留下的子块'
if (new Set(slots.map(slot => slot.block)).size < slots.length)
return '同一块放进了卡片里的几处'
for (const { row, item } of slots) {
if (!claimed.rows[row])
return `放进卡片第 ${row} 行下面的块,卡片没有这一行`
if (item !== undefined && !claimed.rows[row].items?.[item])
return `放进卡片第 ${row} 行第 ${item} 个子行的块,这一行没有这个子行`
}
for (const { reference, offset } of referencedSegments(claimed.rows)) {
if (reference?.rule !== rule.id)
return `引用「${reference?.key}」标的规则是「${reference?.rule}」,不是自己`
+95
View File
@@ -151,3 +151,98 @@ describe('react 库规则:属性', () => {
expect(rowLines(await card(source, 'Empty'), '属性')).toEqual(['属性: 无'])
})
})
/** 块和它的子块写成文字:「种类 行号 @行/子行」(放进父卡片属性表里的写在哪),子块多缩进两格。 */
function outlineOf(view: BlockView, pad = ''): string[] {
const slot = view.slot ? ` @${view.slot.row}${view.slot.item !== undefined ? `/${view.slot.item}` : ''}` : ''
return [`${pad}${view.kind} ${view.startLine + 1}${slot}`, ...view.children.flatMap(child => outlineOf(child, `${pad} `))]
}
describe('react 库规则:分区', () => {
it('luffy 的 LegalDocPage:状态(渲染中重置写进改它的)/ 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理,讲进卡片的语句不再单独成块', async () => {
const view = await card(LEGAL, 'LegalDocPage')
expect(propertyLines(properties(view)).slice(4)).toEqual([
'状态: 2 个',
' activeSection | headings[0]?.id ?? "" | setActiveSection(渲染时,当 doc 不全等 prevDoc 时;副作用 2)',
' prevDoc | doc | setPrevDoc(渲染时,当 doc 不全等 prevDoc 时)',
'引用: 1 个',
' scrollRef | 空 | 挂在 <main> 的 ref 上;副作用 1、副作用 2、handleSectionClick 里用它',
'读取的数据: 2 个',
' cfg | useSiteConfig() | 自定义钩子',
' router | useRouter() | 路由器,跳转用',
'派生值: 2 个',
' markdown | 如果 doc 全等 "terms" 则 cfg.termsContent 否则 cfg.privacyContent | 每次渲染',
' headings | extractHeadings(markdown) | markdown 变了时(useMemo)',
'副作用: 2 个',
'事件处理: 3 个',
' handleSectionClick | 每个 <button> 的 onClick(传 h.id) | 3 句',
' handlePrint | <button>(打印) 的 onClick | 1 句',
' handleBack | <button>(返回) 的 onClick | 2 句',
])
// 没讲进卡片的注释照常是子块;副作用放在「副作用」那一行下面,处理函数放在事件处理表对应的子行上
expect(outlineOf(view).filter(line => !/^ {4}/.test(line))).toEqual([
'react.component 51',
' comment 68',
' react.effect 76 @7',
' react.effect 81 @7',
' react.handler 104 @8/0',
' react.handler 112 @8/1',
' react.handler 116 @8/2',
' return 124',
])
})
it('副作用子卡片:时机按依赖读、做什么是上面的注释、返回的清理函数并进「清理」,别的语句放在「做什么」下面', async () => {
const view = await card(LEGAL, 'LegalDocPage')
const [first, second] = view.children.filter(child => child.kind === 'react.effect')
expect(first.badge).toEqual({ label: '副作用', color: { dark: '#e5c07b', light: '#9a6a00' }, icon: 'zap' })
expect(propertyLines(properties(first))).toEqual(['时机: doc 变了时', '做什么: 切换文档时正文滚回顶部 (DOM 副作用).'])
expect(propertyLines(properties(second))).toEqual([
'时机: doc、headings 变了时',
'做什么: 滚动时按 heading.top 计算当前高亮章节.',
'清理: container.removeEventListener("scroll", handleScroll)',
])
expect(second.children.map(child => `${child.kind} ${child.startLine + 1} @${child.slot?.row}`)).toEqual(['variable 82 @1', 'if 83 @1', 'function 84 @1', 'statement 100 @1'])
})
it('处理函数子卡片:名称(能跳到定义)/ 参数 / 做什么,函数里的语句放在「做什么」下面', async () => {
const view = await card(LEGAL, 'LegalDocPage')
const handler = view.children.find(child => child.kind === 'react.handler')
expect(handler?.badge).toEqual({ label: '事件处理', color: { dark: '#dcdcaa', light: '#795e26' }, icon: 'mouse-pointer-click' })
expect(handler && LEGAL.slice(handler.defines?.startIndex, handler.defines?.endIndex)).toBe('handleSectionClick')
expect(propertyLines(properties(handler))).toEqual(['名称: handleSectionClick', '参数: id: 字符串', '做什么: 3 句'])
expect(handler?.children.map(child => `${child.kind} @${child.slot?.row}`)).toEqual(['variable @2', 'variable @2', 'if @2'])
})
it('luffy 的 ShareButton:useCallback 算派生值、写依赖,主体是语句块时点名称展开它的子卡片;只在挂载时的副作用', async () => {
const view = await card(SHARE, 'ShareButton')
expect(rowLines(view, '派生值')).toEqual(['派生值: 1 个', ' startShare | 函数 3 句 | deployUrl、sessionId、title 变了时(useCallback)'])
expect(rowLines(view, '引用')).toEqual([
'引用: 2 个',
' activeRef | 真 | 副作用 1、startShare 里用它',
' creatingRef | 假 | startShare 里用它',
])
const callback = view.children.find(child => child.kind === 'react.handler')
expect(callback?.slot).toEqual({ row: 5, item: 0 })
const [mounted] = view.children.filter(child => child.kind === 'react.effect')
expect(propertyLines(properties(mounted))).toEqual(['时机: 只在挂载时', '做什么: 2 句', '清理: activeRef.current = 假;'])
})
it('读取的数据:store 选择器读成从哪个 store 读什么,别的库的钩子写哪个库;没绑在 JSX 上的处理函数写被谁调用', async () => {
const titlebar = await card(TITLEBAR, 'DesktopTitlebar')
expect(rowLines(titlebar, '读取的数据')).toEqual([
'读取的数据: 2 个',
' pathname | usePathname() | 当前路径',
' title | useDesktopShell((s) => s.title) | 从 desktop store 读 title',
])
expect(rowLines(titlebar, '派生值')).toEqual(['派生值: 1 个', ' center | title ?? PAGE_TITLES[pathname] ?? "" | 每次渲染'])
const poster = await card(POSTER, 'PosterDialog')
expect(rowLines(poster, '事件处理')).toEqual([
'事件处理: 3 个',
' exportPng | 被 download、share 调用 | 1 句',
' download | <Button> 的 onClick | 9 句',
' share | <Button> 的 onClick | 4 句',
])
expect(rowLines(poster, '副作用')).toEqual(['副作用: 1 个'])
})
})
+541 -15
View File
@@ -1,29 +1,79 @@
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, SegmentGroup, ThemeColor, WorkspaceIndex } from '../contract'
import type { BindingFacts, ComponentFacts, FieldFacts, FieldText, PropsFacts } from './reactTree'
import { clientDirective, commentText, componentAt, componentOf, componentsIn, declaredTypes, propsOf, sameNode } from './reactTree'
import { hint, name, plain, range } from './segments'
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
import type { BindingFacts, BodyFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, Place, PropsFacts, ReadFacts } from './reactTree'
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
import { hint, joined, name, plain, range } from './segments'
/**
* React 库规则:TSX / JSX 里的 React 函数组件(见 reactTree.ts 的 componentAt)整块换成「React 组件」卡片,不再是普通函数卡片的输入参数 / 输出参数。
* 卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/ 说明 / 属性(解构的每个属性一行,类型取属性类型里对应的字段;
* 属性类型是接口、类型别名时写来自哪个类型,导入的类型经工作区索引查它的字段),再按钩子分区。
* 属性类型是接口、类型别名时写来自哪个类型,导入的类型经工作区索引查它的字段),再按钩子分区,没有就不画:
* 状态(useState / useReducer:名称 / 初始值 / 改它的,渲染时按条件重置状态的 if 也写进改它的)、引用(useRef)、
* 读取的数据(别的钩子:变量 / 来自 / 意思)、派生值(组件体里别的常量、useMemo / useCallback:名称 / 怎么算 / 什么时候算)、
* 副作用(每个 useEffect / useLayoutEffect 一张子卡片:时机 / 做什么 / 清理)、事件处理(组件里定义的函数:名称 / 绑定在 / 做什么,
* 点名称展开它的子卡片)。讲进分区的语句并进卡片;副作用、处理函数(和主体是语句块的 useMemo / useCallback)照常是块,放进卡片那一行下面。
* 文件开头的 "use client" 讲成了「客户端组件」、组件上面的 JSDoc 讲成了「说明」,都不再单独成块。
* 新的 JSX 写法不用导入 react,规则不看导入:TSX / JSX 文件都启用。用词都在词表里,按翻译插件的显示语言挑。
*/
const RULE_ID = 'react'
const KIND = 'react.component'
const EFFECT_KIND = 'react.effect'
const HANDLER_KIND = 'react.handler'
/** 组件色:React 的青色,渲染大纲里的组件、「客户端组件」标签也用它;白底上照原色看不清,亮色主题取同色相深一些的。 */
const COMPONENT_COLOR: ThemeColor = { dark: '#61dafb', light: '#0a7ea4' }
/** JSX 里的原生标签(div、span):标签尖括号的灰。 */
const NATIVE_TAG_COLOR: ThemeColor = { dark: '#808080', light: '#6f6f6f' }
/** 副作用子卡片:同「选项」块的黄;事件处理子卡片:同「函数」块。 */
const EFFECT_COLOR: ThemeColor = { dark: '#e5c07b', light: '#9a6a00' }
const HANDLER_COLOR: ThemeColor = { dark: '#dcdcaa', light: '#795e26' }
/** 导出方式的标签:同 import / export 关键字的颜色。 */
const EXPORT_COLOR: ThemeColor = { dark: '#c586c0', light: '#af00db' }
/** 记忆化、转发 ref 的标签:同 const / function 关键字的颜色。 */
const WRAPPER_COLOR: ThemeColor = { dark: '#569cd6', light: '#0000ff' }
interface Words {
kinds: Record<'component', string>
labels: Record<'name' | 'doc' | 'props', string>
kinds: Record<'component' | 'effect' | 'handler', string>
labels: Record<'name' | 'doc' | 'props' | 'state' | 'refs' | 'reads' | 'derived' | 'effects' | 'handlers' | 'timing' | 'does' | 'cleanup' | 'parameters', string>
/** 各分区表格的列 */
heads: Record<'state' | 'refs' | 'reads' | 'derived' | 'handlers', string[]>
/** 副作用的时机、派生值什么时候算:a、b 变了时,只在挂载时,每次渲染后,每次渲染 */
changed: string
mount: string
afterRender: string
everyRender: string
/** useLayoutEffect 在浏览器绘制前跑 */
beforePaint: string
/** 派生值是哪个钩子算的:「(useMemo)」 */
hookNote: (hook: string) => string
/** 函数主体有几句 */
statements: (n: number) => string
/** useCallback 记住的是一个函数 */
fn: string
/** 用在哪:第几个副作用(只有一个时不写几)、渲染时、渲染时按条件重置 */
effectPlace: (index?: number) => string
renderPlace: string
resetPlace: [string, string]
/** 「<button> 的 onClick」,在列表里一项项渲染的「每个 <button>」,在属性值里调用时带的实参「(传 h.id)」 */
of: string
each: string
passes: [string, string]
/** 引用挂在哪个元素上:「挂在 <main> 的 ref 上」;在别处用到:「副作用 1、handleSectionClick 里用它」 */
attached: [string, string]
usedIn: 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 算 …」 */
store: [string, string, string]
/** 「从 ThemeContext 上下文读」 */
context: [string, string]
customHook: string
libraryHook: (module: string) => string
tags: Record<'named' | 'default' | 'client' | 'memo' | 'forwardRef', string>
/** 属性名后面的标签:函数类型的属性、children */
roles: Record<'callback' | 'children', string>
@@ -46,8 +96,73 @@ interface Words {
}
const ZH: Words = {
kinds: { component: 'React 组件' },
labels: { name: '名称', doc: '说明', props: '属性' },
kinds: { component: 'React 组件', effect: '副作用', handler: '事件处理' },
labels: {
name: '名称',
doc: '说明',
props: '属性',
state: '状态',
refs: '引用',
reads: '读取的数据',
derived: '派生值',
effects: '副作用',
handlers: '事件处理',
timing: '时机',
does: '做什么',
cleanup: '清理',
parameters: '参数',
},
heads: {
state: ['名称', '初始值', '改它的'],
refs: ['名称', '初始值', '用在'],
reads: ['变量', '来自', '意思'],
derived: ['名称', '怎么算', '什么时候算'],
handlers: ['名称', '绑定在', '做什么'],
},
changed: '变了时',
mount: '只在挂载时',
afterRender: '每次渲染后',
everyRender: '每次渲染',
beforePaint: ',浏览器绘制前',
hookNote: hook => `(${hook})`,
statements: n => `${n} 句`,
fn: '函数 ',
effectPlace: index => index === undefined ? '副作用' : `副作用 ${index}`,
renderPlace: '渲染时',
resetPlace: ['渲染时,当 ', ' 时'],
of: ' 的 ',
each: '每个 ',
passes: ['(传 ', ')'],
attached: ['挂在 ', ' 的 ref 上'],
usedIn: ' 里用它',
calledBy: ['被 ', ' 调用'],
listSeparator: '、',
placeSeparator: ';',
parens: ['(', ')'],
hooks: {
'next/navigation': {
usePathname: '当前路径',
useRouter: '路由器,跳转用',
useSearchParams: '地址栏里的查询参数',
useParams: '路径里的动态参数',
useSelectedLayoutSegment: '当前选中的下一级布局段',
useSelectedLayoutSegments: '当前选中的各级布局段',
},
'next/router': { useRouter: '路由器,跳转用' },
'react': {
useId: '一个稳定的唯一 id',
useTransition: '在不在过渡中,和开始过渡的函数',
useSyncExternalStore: '外部数据源眼下的值',
useOptimistic: '乐观更新后的值,和更新它的函数',
useActionState: '表单动作的状态,和触发它的函数',
useDeferredValue: '晚一步更新的值',
},
'react-dom': { useFormStatus: '所在表单的提交状态' },
},
store: ['从 ', ' store 读 ', ' store 算 '],
context: ['从 ', ' 上下文读'],
customHook: '自定义钩子',
libraryHook: module => `${module} 的钩子`,
tags: { named: '具名导出', default: '默认导出', client: '客户端组件', memo: '记忆化', forwardRef: '转发 ref' },
roles: { callback: '回调', children: '子元素' },
propHeads: ['名称', '类型', '默认值', '必填'],
@@ -230,16 +345,425 @@ function propsRow(component: ComponentFacts, env: Env): PropertyRow {
}
}
// ─── 时机、用在哪 ─────────────────────────────────────────────────────────────
/** 组件卡片的各分区要用的:组件,和组件体里各种语句归到哪儿。 */
interface Analysis {
component: ComponentFacts
body: BodyFacts
}
function keyword(text: string): Inline {
return { text, role: 'keyword' }
}
/** 名字照抄(能跳到定义);解构模式照常翻。 */
function binding(node: Node, role: TokenRole, { context }: Env): Inline[] {
return node.type === 'identifier' ? [name(node, role)] : context.translate(node)
}
/** 依赖数组读成什么时候跑 / 算:`[a, b]` 是 a、b 变了时,`[]` 是只在挂载时,没写是 fallback(每次渲染后 / 每次渲染)。 */
function timing(deps: Node | undefined, fallback: string, { context, words }: Env): Inline[] {
const value = unwrap(deps)
if (!value)
return [keyword(fallback)]
if (value.type !== 'array')
return [...context.translate(value), plain(' '), keyword(words.changed)]
const items = named(value).filter(item => item.type !== 'comment')
if (items.length === 0)
return [keyword(words.mount)]
return [...joined(items.map(item => context.translate(item)), hint(words.listSeparator)), plain(' '), keyword(words.changed)]
}
/** 元素写成 `<Tag>`:组件用组件色、能跳到定义,原生标签灰色;带 aria-label / title 字符串时后面写上它,好认是哪个按钮。 */
function elementRef(element: Node): Inline[] {
const tagName = element.childForFieldName('name')
if (!tagName)
return [plain('<>')]
const component = isComponentTag(tagName)
const labelled = named(element).find(child => child.type === 'jsx_attribute' && ['aria-label', 'title'].includes(attributeName(child)))
const label = labelled && stringContent(attributeValue(labelled))
return [
{ text: `<${tagName.text}>`, role: 'type', color: component ? COMPONENT_COLOR : NATIVE_TAG_COLOR, ...(component && { refers: range(tagName) }) },
...(label ? [hint(`(${label})`)] : []),
]
}
/** 绑在 JSX 属性上的一处:「每个 <button> 的 onClick(传 h.id)」。 */
function jsxPlace(place: Extract<Place, { kind: 'jsx' }>, { context, words }: Env): Inline[] {
const args = place.call ? argumentsOf(place.call) : []
return [
...(place.each ? [plain(words.each)] : []),
...elementRef(place.element),
plain(words.of),
{ text: place.attribute.text, role: 'variable' },
...(args.length > 0 ? [hint(words.passes[0]), ...joined(args.map(arg => context.translate(arg)), plain(', ')), hint(words.passes[1])] : []),
]
}
/** 用到的一处读成什么(见 Place)。 */
function placeText(place: Place, { body }: Analysis, env: Env): Inline[] {
const { context, words } = env
switch (place.kind) {
case 'effect':
return [hint(words.effectPlace(body.effects.length > 1 ? place.index + 1 : undefined))]
case 'handler':
return [name(place.name, 'function')]
case 'derived':
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, env)
default:
return [hint(words.renderPlace)]
}
}
/** 几处写在括号里、用分号隔开:「(副作用 2;渲染时,当 … 时)」;没有时什么都不写。 */
function inParens(parts: Inline[][], { words }: Env): Inline[] {
return parts.length > 0 ? [hint(words.parens[0]), ...joined(parts, hint(words.placeSeparator)), hint(words.parens[1])] : []
}
/** 注释说的做什么:去掉注释符号,几行接成一行。 */
function commentLine(comments: readonly Node[]): string | undefined {
const text = commentText(comments).replace(/\s*\n\s*/g, ' ')
return text || undefined
}
// ─── 状态、引用、读取的数据、派生值 ──────────────────────────────────────────
/** 状态:名称 / 初始值 / 改它的(改状态的函数,括号里写在哪儿改:副作用、处理函数、渲染时按条件重置……)。 */
function stateRow(analysis: Analysis, env: Env): PropertyRow {
const { component, body } = analysis
const { context, words } = env
const items = body.states.map((state): PropertyItem => {
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 changes = state.setter
? [name(state.setter, 'function'), ...inParens(placesOf(state.setter, component, body).map(place => placeText(place, analysis, env)), env)]
: [hint(words.nothing)]
return { cells: [binding(state.value, 'variable', env), [...initial, ...reducer], changes], defines: range(state.value) }
})
return { label: words.labels.state, value: [hint(words.count(items.length))], items, columns: words.heads.state }
}
/** 引用:名称 / 初始值 / 用在(挂在哪个元素的 ref 上,在哪些副作用、处理函数里用它)。 */
function refRow(analysis: Analysis, env: Env): PropertyRow {
const { component, body } = analysis
const { context, words } = env
const items = body.refs.map((ref): PropertyItem => {
const places = placesOf(ref.name, component, 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 => [plain(words.attached[0]), ...elementRef(place.element), plain(words.attached[1])]),
...(others.length > 0 ? [[...joined(others.map(place => placeText(place, analysis, env)), hint(words.listSeparator)), hint(words.usedIn)]] : []),
]
return {
cells: [binding(ref.name, 'variable', env), ref.initial ? context.translate(ref.initial) : [hint(words.nothing)], parts.length > 0 ? joined(parts, hint(words.placeSeparator)) : [hint(words.nothing)]],
defines: range(ref.name),
}
})
return { label: words.labels.refs, value: [hint(words.count(items.length))], items, columns: words.heads.refs }
}
/** 名字是从哪个模块导入的(去掉引号);`React.useX` 看 React 从哪导入。没导入(同文件里定义的)时没有。 */
function moduleOf(local: string, { context }: Env): string | undefined {
for (const facts of context.imports) {
for (const item of facts.items) {
const bound = item.kind === 'named' ? (item.alias ?? item.name) : item.alias
if (bound?.text === local)
return facts.sources[0]?.text.replace(/^(['"`])([\s\S]*)\1$/, '$2')
}
}
return undefined
}
/** 自己项目里的模块:相对路径、`@/` 这类路径别名。 */
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/, '')
}
/** 选择器 `(s) => s.a.b` 取的那一串属性(a、b);不是从参数上直接取属性的写法时没有。 */
function selectedPath(selector: Node | undefined): Node[] | undefined {
const fn = unwrap(selector)
if (!fn || fn.type !== 'arrow_function')
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]
}
return path(body)
}
/**
* 读的数据是什么:库自带的钩子照词表(usePathname → 当前路径);useContext 读哪个上下文;
* 传选择器的(`useXxx((s) => s.a)`)是从哪个 store 读 a;自己项目里的是自定义钩子,别的库的写是哪个库的钩子。
*/
function readMeaning(read: ReadFacts, 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 [hint(words.context[0]), ...context.translate(first), hint(words.context[1])]
const path = selectedPath(first)
const store = storeName(hook, module)
if (path)
return [hint(words.store[0]), plain(store), hint(words.store[1]), ...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.store[0]), plain(store), hint(words.store[2]), ...context.translate(selector.childForFieldName('body') as Node)]
return [hint(!module || isLocalModule(module) ? words.customHook : words.libraryHook(module))]
}
/** 读取的数据:变量 / 来自(钩子调用照常翻)/ 意思。 */
function readRow(analysis: Analysis, env: Env): PropertyRow {
const { context, words } = env
const items = analysis.body.reads.map((read): PropertyItem => ({
cells: [binding(read.target, 'variable', env), context.translate(read.call), readMeaning(read, env)],
defines: range(read.target),
}))
return { label: words.labels.reads, value: [hint(words.count(items.length))], items, columns: words.heads.reads }
}
/** useMemo / useCallback 的函数主体是语句块:点名称展开看它的代码。 */
function blockBodied(item: DerivedFacts): boolean {
return item.memo?.callback?.childForFieldName('body')?.type === 'statement_block'
}
/** 派生值:名称 / 怎么算(照常翻;useMemo 是算值的那个表达式,useCallback 是个函数)/ 什么时候算(useMemo、useCallback 按依赖)。 */
function derivedRow(analysis: Analysis, env: Env): PropertyRow {
const { context, words } = env
const items = analysis.body.derived.map((item): PropertyItem => {
const { memo } = item
const callback = unwrap(memo?.callback)
const body = callback?.childForFieldName('body')
let how: Inline[]
if (!memo || !callback)
how = context.translate(item.value)
else if (memo.hook.text === 'useCallback')
how = [hint(words.fn), ...(blockBodied(item) ? [hint(words.statements(statementsOf(callback).length))] : context.translate(callback))]
else
how = body?.type === 'statement_block' ? [hint(words.statements(statementsOf(callback).length))] : context.translate(body ?? callback)
const when = memo ? [...timing(memo.deps, words.everyRender, env), hint(words.hookNote(memo.hook.text))] : [plain(words.everyRender)]
return { cells: [binding(item.name, 'variable', env), how, when], defines: range(item.name) }
})
return { label: words.labels.derived, value: [hint(words.count(items.length))], items, columns: words.heads.derived }
}
// ─── 副作用、事件处理 ─────────────────────────────────────────────────────────
/** 副作用、处理函数做什么:上面的注释(子卡片里用说明色,表格里用正文色,见 role);没写注释时直接返回的表达式照常翻,函数主体写几句。 */
function doesText(comments: readonly Node[], fn: Node | undefined, env: Env, role: 'hint' | 'plain' = 'hint'): Inline[] {
const comment = commentLine(comments)
if (comment)
return [{ text: comment, role }]
const body = fn?.childForFieldName('body')
if (body && body.type !== 'statement_block')
return env.context.translate(body)
return [hint(env.words.statements(fn ? statementsOf(fn).length : 0))]
}
/** 清理:返回的清理函数做什么(`() => …` 翻它的主体,语句块一句句翻,别的照常翻)。 */
function cleanupText(cleanup: Node, { context, words }: Env): Inline[] {
const value = unwrap(named(cleanup).find(child => child.type !== 'comment'))
if (!value)
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)
}
function effectBadge(words: Words): KindBadge {
return { label: words.kinds.effect, color: EFFECT_COLOR, icon: 'zap' }
}
function handlerBadge(words: Words): KindBadge {
return { label: words.kinds.handler, color: HANDLER_COLOR, icon: 'mouse-pointer-click' }
}
/** 一个副作用的子卡片:时机 / 做什么(下面是副作用函数里的语句)/ 清理(返回清理函数时才有,那句 return 并进来)。 */
function effectCard(block: CodeBlock, effect: EffectFacts, env: Env): RuleCard {
const { words } = env
const callback = unwrap(effect.callback)
const cleanup = effect.cleanup && block.children.find(child => sameNode(child.node, effect.cleanup))
const rows: PropertyRow[] = [
{ label: words.labels.timing, value: [...timing(effect.deps, words.afterRender, env), ...(effect.hook.text === 'useLayoutEffect' ? [hint(words.beforePaint)] : [])] },
{ label: words.labels.does, value: doesText(effect.comments, callback, env) },
...(effect.cleanup ? [{ label: words.labels.cleanup, value: cleanupText(effect.cleanup, env) }] : []),
]
return {
kind: EFFECT_KIND,
badge: effectBadge(words),
rows,
absorbs: cleanup ? [cleanup] : [],
slots: block.children.filter(child => child !== cleanup).map(child => ({ block: child, row: 1 })),
}
}
/** 一个处理函数(和 useCallback 记住的函数)的子卡片:名称 / 参数 / 做什么(下面是函数里的语句)。 */
function handlerCard(block: CodeBlock, fnName: Node, fn: Node, comments: readonly Node[], env: Env): RuleCard {
const { context, words } = env
const parameters = parametersOf(fn).map(parameter => [
...binding(parameter.name, 'variable', env),
...(parameter.type ? [plain(': '), ...context.translate(parameter.type)] : []),
...(parameter.default ? [plain(' = '), ...context.translate(parameter.default)] : []),
])
const rows: PropertyRow[] = [
{ label: words.labels.name, value: [name(fnName, 'function')] },
...(parameters.length > 0 ? [{ label: words.labels.parameters, value: joined(parameters, plain(', ')) }] : []),
{ label: words.labels.does, value: doesText(comments, fn, env) },
]
return {
kind: HANDLER_KIND,
badge: handlerBadge(words),
rows,
defines: range(fnName),
absorbs: [],
slots: block.children.map(child => ({ block: child, row: rows.length - 1 })),
}
}
/** 处理函数绑在哪:JSX 上的属性(「<button> 的 onClick」);没绑在 JSX 上时写被谁调用。 */
function boundText(handler: HandlerFacts, analysis: Analysis, env: Env): Inline[] {
const { words } = env
const places = placesOf(handler.name, analysis.component, analysis.body)
const bound = places.filter((place): place is Extract<Place, { kind: 'jsx' }> => place.kind === 'jsx')
if (bound.length > 0)
return joined(bound.map(place => jsxPlace(place, env)), hint(words.listSeparator))
const callers = places.filter(place => place.kind !== 'render')
if (callers.length > 0)
return [hint(words.calledBy[0]), ...joined(callers.map(place => placeText(place, analysis, env)), hint(words.listSeparator)), hint(words.calledBy[1])]
return [hint(words.nothing)]
}
/** 事件处理:名称 / 绑定在 / 做什么,点名称在表下面展开它的子卡片。 */
function handlerRow(analysis: Analysis, env: Env): PropertyRow {
const { words } = env
const items = analysis.body.handlers.map((handler): PropertyItem => ({
cells: [[name(handler.name, 'function')], boundText(handler, analysis, env), doesText(handler.comments, handler.fn, env, 'plain')],
defines: range(handler.name),
}))
return { label: words.labels.handlers, value: [hint(words.count(items.length))], items, columns: words.heads.handlers }
}
/** 组件体里的一句是副作用、处理函数、useCallback 记住的函数时,它的子卡片;别的没有。 */
function nestedCard(block: CodeBlock, env: Env): RuleCard | undefined {
if (!['statement', 'function', 'const', 'variable'].includes(block.kind))
return undefined
const component = enclosingComponent(block.node)
if (!component)
return undefined
const body = bodyOf(component)
const at = (facts: { statement: Node }): boolean => sameNode(facts.statement, block.node)
const effect = body.effects.find(at)
if (effect)
return effectCard(block, effect, env)
const handler = body.handlers.find(at)
if (handler)
return handlerCard(block, handler.name, handler.fn, handler.comments, env)
const callback = body.derived.find(item => at(item) && item.memo?.hook.text === 'useCallback' && blockBodied(item))
const fn = unwrap(callback?.memo?.callback)
return callback && fn ? handlerCard(block, callback.name, fn, commentsBefore(callback.statement), env) : undefined
}
// ─── 认领 ─────────────────────────────────────────────────────────────────────
/** 组件卡片。 */
function componentCard(component: ComponentFacts, env: Env): RuleCard {
function componentCard(block: CodeBlock, component: ComponentFacts, env: Env): RuleCard {
const { words } = env
const analysis: Analysis = { component, body: bodyOf(component) }
const { body } = analysis
const rows: PropertyRow[] = [nameRow(component, env), ...docRows(component, env), propsRow(component, env)]
const absorbs: CodeBlock[] = []
const slots: RuleSlot[] = []
const childAt = (node: Node | undefined): CodeBlock | undefined => node && block.children.find(child => sameNode(child.node, node))
/** 讲进卡片里的语句(和讲它的注释)并进来,不再单独成块。 */
const absorb = (...nodes: (Node | undefined)[]): void => {
for (const child of nodes.map(childAt)) {
if (child && !absorbs.includes(child))
absorbs.push(child)
}
}
if (body.states.length > 0) {
rows.push(stateRow(analysis, env))
absorb(...body.states.map(state => state.statement), ...body.resets.map(reset => reset.statement))
}
if (body.refs.length > 0) {
rows.push(refRow(analysis, env))
absorb(...body.refs.map(ref => ref.statement))
}
if (body.reads.length > 0) {
rows.push(readRow(analysis, env))
absorb(...body.reads.map(read => read.statement))
}
if (body.derived.length > 0) {
const row = rows.push(derivedRow(analysis, env)) - 1
body.derived.forEach((item, index) => {
const child = childAt(item.statement)
if (child && blockBodied(item))
slots.push({ block: child, row, item: index })
else
absorb(item.statement)
})
}
if (body.effects.length > 0) {
const row = rows.push({ label: words.labels.effects, value: [hint(words.count(body.effects.length))] }) - 1
for (const effect of body.effects) {
const child = childAt(effect.statement)
if (child)
slots.push({ block: child, row })
absorb(effect.comments[0])
}
}
if (body.handlers.length > 0) {
const row = rows.push(handlerRow(analysis, env)) - 1
body.handlers.forEach((handler, index) => {
const child = childAt(handler.statement)
if (child)
slots.push({ block: child, row, item: index })
absorb(handler.comments[0])
})
}
return {
kind: KIND,
badge: badge(env.words),
rows: [nameRow(component, env), ...docRows(component, env), propsRow(component, env)],
badge: badge(words),
rows,
defines: range(component.name),
absorbs: [],
absorbs,
slots,
}
}
@@ -272,7 +796,9 @@ export const reactRule: LibraryRule = {
const env: Env = { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH }
const component = componentOf(block)
if (component)
return componentCard(component, env)
return omitted(block) ? { omit: true } : undefined
return componentCard(block, component, env)
if (omitted(block))
return { omit: true }
return nestedCard(block, env)
},
}
+35
View File
@@ -680,6 +680,9 @@ export function bodyOf(component: ComponentFacts): BodyFacts {
else if (declarator && target && value && hook) {
facts.reads.push({ statement, target, call: value, hook })
}
else if (componentAt(statement)) {
// 组件里又定义的组件照常成块(自己也是一张组件卡片),不算处理函数
}
else if (declarator && target?.type === 'identifier' && value && isFunction(value)) {
facts.handlers.push({ statement, name: target, fn: value, comments: commentsBefore(statement) })
}
@@ -714,6 +717,38 @@ export function bodyOf(component: ComponentFacts): BodyFacts {
return facts
}
/** 函数的一个参数:名字(或解构模式)、类型、默认值。 */
export interface ParameterText {
name: Node
type?: Node
default?: Node
}
/** 函数的参数,照源码顺序(不带括号的单参数箭头函数 `x => …` 也算)。 */
export function parametersOf(fn: Node): ParameterText[] {
const single = fn.childForFieldName('parameter')
if (single)
return [{ name: single }]
return syntax(fn.childForFieldName('parameters')).map((parameter): ParameterText => {
if (parameter.type === 'required_parameter' || parameter.type === 'optional_parameter') {
const type = syntax(parameter.childForFieldName('type'))[0]
const fallback = parameter.childForFieldName('value')
return { name: parameter.childForFieldName('pattern') ?? parameter, ...(type && { type }), ...(fallback && { default: fallback }) }
}
if (parameter.type === 'assignment_pattern') {
const fallback = parameter.childForFieldName('right')
return { name: parameter.childForFieldName('left') ?? parameter, ...(fallback && { default: fallback }) }
}
return { name: parameter }
})
}
/** 函数主体的语句(去掉注释);直接返回表达式的箭头函数没有。 */
export function statementsOf(fn: Node): Node[] {
const body = fn.childForFieldName('body')
return body?.type === 'statement_block' ? syntax(body) : []
}
// ─── 用在哪 ───────────────────────────────────────────────────────────────────
/**