✨ feat(vscode): 代码阅读的 React 钩子统一成一张钩子卡片,照签名排参数和返回,自定义钩子有自己的卡片
This commit is contained in:
1 parent
78d0c9dada
commit
e7059bcc73
17 files changed
+1409
-589
No files matched your search
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user