⚡ perf(vscode): 代码阅读预览发给页面的块带内容指纹:扩展端按卡片内容、在源码里占的地方连同子块的指纹算摘要,页面按指纹认出没变的块沿用原来的对象,不再逐块深比内容,5126 行的文档到了比对 10.6ms → 0.4ms
This commit is contained in:
1 parent
ad5f559e92
commit
266dc42bc5
13 files changed
+194
-26
No files matched your search
@@ -1,13 +1,14 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Root } from 'react-dom/client'
|
||||
import type { BlockView, CallTree, DocumentMessage, ExtensionToWebview, WebviewToExtension } from './types'
|
||||
import type { UnhashedBlock } from './testBlocks'
|
||||
import type { CallTree, DocumentMessage, ExtensionToWebview, WebviewToExtension } from './types'
|
||||
import type { ViewState, ViewStateStore } from './viewState'
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { App } from './App'
|
||||
import { NO_SPAN } from './testBlocks'
|
||||
import { hashed, NO_SPAN } from './testBlocks'
|
||||
import { createViewStateStore } from './viewState'
|
||||
|
||||
/**
|
||||
@@ -113,7 +114,7 @@ async function deliver(msg: ExtensionToWebview): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function block(id: string, anchor: string): BlockView {
|
||||
function block(id: string, anchor: string): UnhashedBlock {
|
||||
return {
|
||||
id,
|
||||
anchor,
|
||||
@@ -127,8 +128,8 @@ function block(id: string, anchor: string): BlockView {
|
||||
}
|
||||
}
|
||||
|
||||
function documentOf(version: number, blocks: BlockView[]): DocumentMessage {
|
||||
return { type: 'document', version, languageId: 'python', blocks, source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] }
|
||||
function documentOf(version: number, blocks: UnhashedBlock[]): DocumentMessage {
|
||||
return { type: 'document', version, languageId: 'python', blocks: hashed(blocks), source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] }
|
||||
}
|
||||
|
||||
function tree(id: string, name: string): CallTree {
|
||||
@@ -224,7 +225,7 @@ describe('代码配色跟着当前颜色主题', () => {
|
||||
|
||||
describe('一条消息只重画受影响的卡片', () => {
|
||||
/** 三张卡片,译文里各有一个带下标的名字(下标 10、20、30) */
|
||||
function named(id: string, name: string, offset: number, text = '调用'): BlockView {
|
||||
function named(id: string, name: string, offset: number, text = '调用'): UnhashedBlock {
|
||||
return { ...block(id, name), translation: [{ text, role: 'keyword' }, { text: name, role: 'function', offset }] }
|
||||
}
|
||||
const DOC = documentOf(3, [named('0', 'load', 10), named('1', 'save', 20), named('2', 'drop', 30)])
|
||||
|
||||
@@ -22,7 +22,7 @@ import { createPageStore } from './pageState'
|
||||
import { NO_PLACEMENT, placementOf } from './placement'
|
||||
import { chatOf, PATH_ASK_ANCHOR, sameChat } from './qa'
|
||||
import { TextMarksContext } from './SegmentText'
|
||||
import { shareUnchanged } from './sharing'
|
||||
import { shareBlocks, shareUnchanged } from './sharing'
|
||||
import { StickyLines } from './StickyLines'
|
||||
import { createStore, useStore } from './store'
|
||||
import { useChangeNav } from './useChangeNav'
|
||||
@@ -151,8 +151,8 @@ export function App(): ReactElement | null {
|
||||
case 'document': {
|
||||
const restoring = restoreCall.current
|
||||
restoreCall.current = undefined
|
||||
// 和上一份内容一样的块还是原来的对象:没变的卡片不重画
|
||||
const next = shareUnchanged(page.get().doc, msg)
|
||||
// 指纹没变的块还是原来的对象:没变的卡片不重画
|
||||
const next = { ...msg, blocks: shareBlocks(page.get().doc?.blocks, msg.blocks) }
|
||||
const reopened = restoring && openingCall(next, restoring.anchor, restoring.entry)
|
||||
marks.set(current => ({ ...current, names: nameMap(msg.names), placement: placementOf(next.blocks) }))
|
||||
page.update(current => ({
|
||||
|
||||
@@ -11,7 +11,7 @@ import { BlockTree, treeState } from './BlockTree'
|
||||
import { CallTreeDialog, splitWhere } from './CallTreeDialog'
|
||||
import { NO_PLACEMENT } from './placement'
|
||||
import { staticStore } from './store'
|
||||
import { NO_SPAN } from './testBlocks'
|
||||
import { hashed, NO_SPAN } from './testBlocks'
|
||||
|
||||
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
|
||||
|
||||
@@ -36,7 +36,7 @@ async function render(element: ReactElement): Promise<void> {
|
||||
}
|
||||
|
||||
function block(kind: string, id = kind, callTree?: BlockView['callTree']): BlockView {
|
||||
return {
|
||||
return hashed([{
|
||||
id,
|
||||
anchor: id,
|
||||
kind,
|
||||
@@ -46,7 +46,7 @@ function block(kind: string, id = kind, callTree?: BlockView['callTree']): Block
|
||||
concepts: [{ id: kind, label: kind, icon: kind === 'class' ? 'box' : 'braces' }],
|
||||
children: [],
|
||||
translation: [{ text: id, role: 'plain' }],
|
||||
}
|
||||
}])[0]
|
||||
}
|
||||
|
||||
/** 块树:callTree 是各块的棵数、开着哪一块的弹框和点按钮时做什么;edit 是 false 时是只读的树 */
|
||||
|
||||
@@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DefinitionPeeks, linkPeekSource } from './definitionPeek'
|
||||
import { DiffText } from './DiffText'
|
||||
import { commentLinkOf } from './repoLink'
|
||||
import { NO_SPAN } from './testBlocks'
|
||||
import { hashed, NO_SPAN } from './testBlocks'
|
||||
import { MarkdownKindContext, TextPreview } from './TextPreview'
|
||||
|
||||
let container: HTMLDivElement
|
||||
@@ -135,7 +135,7 @@ describe.each(['TextPreview', 'DiffText'] as const)('%s 的 Markdown 页面元
|
||||
const peek: DefinitionPeek = {
|
||||
path: 'client.py',
|
||||
line: 1,
|
||||
block: { id: '1', anchor: '1', kind: 'class', span: NO_SPAN, attributes: { kind: 'class' }, concepts: [], children: [], translation: [] },
|
||||
block: hashed([{ id: '1', anchor: '1', kind: 'class', span: NO_SPAN, attributes: { kind: 'class' }, concepts: [], children: [], translation: [] }])[0],
|
||||
names: [],
|
||||
}
|
||||
await act(async () => controller.answer(1, peek))
|
||||
|
||||
@@ -92,7 +92,7 @@ function memoizeOne<A extends unknown[], R>(compute: (...args: A) => R): (...arg
|
||||
}
|
||||
|
||||
export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>): PageStore {
|
||||
// 按 doc.blocks 而不是 doc 认:同一版重发、只改了版本号时块是原来那份(见 shareUnchanged),算出来的也还是原来的
|
||||
// 按 doc.blocks 而不是 doc 认:同一版重发、只改了版本号时块是原来那份(见 shareBlocks),算出来的也还是原来的
|
||||
const changesOf = memoizeOne((blocks: BlockView[], version: number, error: boolean, diff: DocumentDiff | null, excluded: Excluded) => error
|
||||
? undefined
|
||||
// 删掉的块按块筛选同样藏
|
||||
|
||||
@@ -22,8 +22,8 @@ function byId(blocks: BlockView[]): Map<string, BlockView> {
|
||||
return new Map(flatten(blocks).map(block => [block.id, block]))
|
||||
}
|
||||
|
||||
/** 一块自己的样子(不连子块):子块变了不算它变了 */
|
||||
function own({ children: _, ...rest }: BlockView): Omit<BlockView, 'children'> {
|
||||
/** 一块自己的样子(不连子块和连子块算的指纹):子块变了不算它变了 */
|
||||
function own({ children: _, hash: __, ...rest }: BlockView): Omit<BlockView, 'children' | 'hash'> {
|
||||
return rest
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
import type { UnhashedBlock } from './testBlocks'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shareUnchanged } from './sharing'
|
||||
import { shareBlocks, shareUnchanged } from './sharing'
|
||||
import { hashed, NO_SPAN } from './testBlocks'
|
||||
|
||||
describe('新文档里指纹没变的块沿用上一份的对象', () => {
|
||||
function card(id: string, text: string, children: UnhashedBlock[] = []): UnhashedBlock {
|
||||
return { id, anchor: id, kind: 'statement', span: NO_SPAN, attributes: {}, concepts: [], children, translation: [{ text, role: 'plain' }] }
|
||||
}
|
||||
const PREV = hashed([card('0', 'a'), card('1', 'b', [card('1.0', 'c'), card('1.1', 'd')]), card('2', 'e')])
|
||||
|
||||
it('整份一样:交回上一份本身;没有上一份时原样交回', () => {
|
||||
const same = hashed([card('0', 'a'), card('1', 'b', [card('1.0', 'c'), card('1.1', 'd')]), card('2', 'e')])
|
||||
expect(shareBlocks(PREV, same)).toBe(PREV)
|
||||
expect(shareBlocks(undefined, same)).toBe(same)
|
||||
})
|
||||
|
||||
it('子块改了:它和包着它的块是新的,别的块(连同改了的块旁边没变的子块)还是原来的对象', () => {
|
||||
const next = hashed([card('0', 'a'), card('1', 'b', [card('1.0', 'c'), card('1.1', 'D')]), card('2', 'e')])
|
||||
const shared = shareBlocks(PREV, next)
|
||||
expect(shared).toEqual(next)
|
||||
expect(shared).not.toBe(PREV)
|
||||
expect(shared[0]).toBe(PREV[0])
|
||||
expect(shared[2]).toBe(PREV[2])
|
||||
expect(shared[1]).not.toBe(PREV[1])
|
||||
expect(shared[1].children[0]).toBe(PREV[1].children[0])
|
||||
expect(shared[1].children[1]).not.toBe(PREV[1].children[1])
|
||||
})
|
||||
|
||||
it('多了、少了块:那一层是新的数组,没变的块照样沿用', () => {
|
||||
const longer = shareBlocks(PREV, hashed([card('0', 'a'), card('1', 'b', [card('1.0', 'c'), card('1.1', 'd')]), card('2', 'e'), card('3', 'f')]))
|
||||
expect(longer).not.toBe(PREV)
|
||||
expect(longer.slice(0, 3)).toEqual(PREV)
|
||||
longer.slice(0, 3).forEach((block, index) => expect(block).toBe(PREV[index]))
|
||||
const shorter = shareBlocks(PREV, hashed([card('0', 'a'), card('1', 'b', [card('1.0', 'c')]), card('2', 'e')]))
|
||||
expect(shorter[1]).not.toBe(PREV[1])
|
||||
expect(shorter[1].children[0]).toBe(PREV[1].children[0])
|
||||
expect(shorter[2]).toBe(PREV[2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('新消息里没变的部分沿用上一份的对象', () => {
|
||||
const prev = {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { BlockView } from './types'
|
||||
|
||||
/**
|
||||
* 新收到的一份数据里,和上一份内容一样的部分换回上一份里的那个对象(整份一样就交回上一份本身),
|
||||
* 同 TanStack Query 的 replaceEqualDeep:扩展端每次发来的都是新对象,换回去之后没变的块、行还是原来那个,按卡片订阅的组件看得出没变、不重画。
|
||||
* 只认消息里那样的数据:普通对象、数组、原始值。
|
||||
* 同 TanStack Query 的 replaceEqualDeep:扩展端每次发来的都是新对象(差异、问答的消息),换回去之后没变的部分还是原来那个,按卡片订阅的组件看得出没变、不重画。
|
||||
* 只认消息里那样的数据:普通对象、数组、原始值。文档的块树带内容指纹,见 shareBlocks。
|
||||
*/
|
||||
export function shareUnchanged<T>(prev: unknown, next: T): T {
|
||||
if (Object.is(prev, next))
|
||||
@@ -29,6 +31,30 @@ export function shareUnchanged<T>(prev: unknown, next: T): T {
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 新收到的块树里,内容指纹(BlockView.hash)和上一份里某块一样的块换回上一份里的那个对象(连子块整棵沿用),某一层的块全都沿用时交回上一份的那一层;
|
||||
* 整份一样就交回上一份本身。指纹由扩展端按块的内容(连子块、在源码里占的地方)算好,这边不用逐块比内容。
|
||||
*/
|
||||
export function shareBlocks(prev: BlockView[] | undefined, next: BlockView[]): BlockView[] {
|
||||
if (!prev)
|
||||
return next
|
||||
const byHash = new Map<string, BlockView>()
|
||||
const byId = new Map<string, BlockView>()
|
||||
const collect = (list: readonly BlockView[]): void => {
|
||||
for (const block of list) {
|
||||
byHash.set(block.hash, block)
|
||||
byId.set(block.id, block)
|
||||
collect(block.children)
|
||||
}
|
||||
}
|
||||
collect(prev)
|
||||
const share = (list: BlockView[], before: BlockView[] | undefined): BlockView[] => {
|
||||
const shared = list.map(block => byHash.get(block.hash) ?? { ...block, children: share(block.children, byId.get(block.id)?.children) })
|
||||
return before && before.length === shared.length && shared.every((block, index) => block === before[index]) ? before : shared
|
||||
}
|
||||
return share(next, prev)
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null)
|
||||
return false
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
import type { BlockSpan, BlockView } from './types'
|
||||
|
||||
/** 逐个去掉联合类型各支里的键(Omit 会把译文、属性表两种卡片并成一种) */
|
||||
type Without<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
|
||||
|
||||
/** 测试里手写的块,还没有内容指纹(hashed 补上) */
|
||||
export type UnhashedBlock = Without<BlockView, 'hash' | 'children'> & { children: UnhashedBlock[] }
|
||||
|
||||
/**
|
||||
* 测试里手写的块:照文件里的位置写,下标就是文件里的(各块的开头都在下标 0),lines 是块在文件里的 [首行, 尾行](不写是接着前一块的尾行)。
|
||||
*/
|
||||
export type WrittenBlock = Omit<BlockView, 'span' | 'children'> & { lines?: [number, number], children: WrittenBlock[] }
|
||||
export type WrittenBlock = Without<BlockView, 'hash' | 'span' | 'children'> & { lines?: [number, number], children: WrittenBlock[] }
|
||||
|
||||
/** 下标、行号都不挪:块里的位置就是文件里的 */
|
||||
export const NO_SPAN: BlockSpan = { lineGap: 0, gap: 0, lines: 0, length: 0 }
|
||||
|
||||
/** 补上内容指纹:扩展端是按内容算的摘要(见 pageBlocks.ts),测试里直接拿内容本身当指纹,内容一样指纹就一样。 */
|
||||
export function hashed(blocks: readonly UnhashedBlock[]): BlockView[] {
|
||||
return blocks.map(({ children, ...rest }) => {
|
||||
const kids = hashed(children)
|
||||
return { ...rest, hash: JSON.stringify([rest, kids.map(kid => kid.hash)]), children: kids } as BlockView
|
||||
})
|
||||
}
|
||||
|
||||
/** 手写的块排成扩展端发来的样子:span 按各块的首尾行累加(见 placement.ts),各块的开头都在下标 0,块里的下标就是文件里的。 */
|
||||
export function placed(blocks: readonly WrittenBlock[], from = 0): BlockView[] {
|
||||
return hashed(spanned(blocks, from))
|
||||
}
|
||||
|
||||
function spanned(blocks: readonly WrittenBlock[], from: number): UnhashedBlock[] {
|
||||
let cursor = from
|
||||
return blocks.map(({ lines, children, ...rest }) => {
|
||||
const [start, end] = lines ?? [cursor, cursor]
|
||||
const span: BlockSpan = { lineGap: start - cursor, gap: 0, lines: end - start, length: 0 }
|
||||
cursor = end
|
||||
return { ...rest, span, children: placed(children, start) } as BlockView
|
||||
return { ...rest, span, children: spanned(children, start) } as UnhashedBlock
|
||||
})
|
||||
}
|
||||
@@ -186,10 +186,11 @@ export type CardView = CardViewBase & ({ translation: PreviewText, items?: Prope
|
||||
|
||||
/**
|
||||
* 一块:卡片的内容,在源码里占的地方(span,相对的;块编辑时编辑框里是它的整行源码,子孙都不画,见 coveredByEditing),子块。
|
||||
* 内容没变、只是挪了位置的块,扩展端发来的一模一样
|
||||
* 内容没变、只是挪了位置的块,扩展端发来的一模一样;hash 是扩展端按这些(连子块)算的内容指纹,页面按它认出没变的块(见 shareBlocks)
|
||||
*/
|
||||
export type BlockView = CardView & {
|
||||
span: BlockSpan
|
||||
hash: string
|
||||
children: BlockView[]
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user