add input event observer and hook the value setter

This commit is contained in:
Yanzhen Yu
2026-04-01 12:00:00 +08:00
parent ad2ac811a3
commit c96052d8a4
4 changed files with 139 additions and 4 deletions

View File

@@ -92,6 +92,16 @@ function record(options: recordOptions) {
}, },
}), }),
), ),
inputCb: v =>
emit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Input,
...v,
},
}),
),
}); });
}); });
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,5 @@
import { INode, serializeNodeWithId } from 'rrweb-snapshot'; import { INode, serializeNodeWithId } from 'rrweb-snapshot';
import { mirror, throttle, on } from '../utils'; import { mirror, throttle, on, hookSetter } from '../utils';
import { import {
mutationCallBack, mutationCallBack,
textMutation, textMutation,
@@ -14,6 +14,9 @@ import {
listenerHandler, listenerHandler,
scrollCallback, scrollCallback,
viewportResizeCallback, viewportResizeCallback,
inputValue,
inputCallback,
hookResetter,
} from '../types'; } from '../types';
function initMutationObserver(cb: mutationCallBack): MutationObserver { function initMutationObserver(cb: mutationCallBack): MutationObserver {
@@ -214,6 +217,87 @@ function initViewportResizeObserver(
return on('resize', updateDimension, window); return on('resize', updateDimension, window);
} }
const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
const HOOK_PROPERTIES: Array<[HTMLElement, string]> = [
[HTMLInputElement.prototype, 'value'],
[HTMLInputElement.prototype, 'checked'],
[HTMLSelectElement.prototype, 'value'],
[HTMLTextAreaElement.prototype, 'value'],
];
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
function initInputObserver(cb: inputCallback): listenerHandler {
function eventHandler(event: Event) {
const { target } = event;
if (
!target ||
!(target as Element).tagName ||
INPUT_TAGS.indexOf((target as Element).tagName) < 0
) {
return;
}
const type: string | undefined = (target as HTMLInputElement).type;
const text = (target as HTMLInputElement).value;
let isChecked = false;
if (type === 'radio' || type === 'checkbox') {
isChecked = (target as HTMLInputElement).checked;
}
cbWithDedup(target, { text, isChecked });
// if a radio was checked
// the other radios with the same name attribute will be unchecked.
const name: string | undefined = (target as HTMLInputElement).name;
if (type === 'radio' && name && isChecked) {
document
.querySelectorAll(`input[type="radio"][name="${name}"]`)
.forEach(el => {
if (el !== target) {
cbWithDedup(el, {
text: (el as HTMLInputElement).value,
isChecked: !isChecked,
});
}
});
}
}
function cbWithDedup(target: EventTarget, v: inputValue) {
const lastInputValue = lastInputValueMap.get(target);
if (
!lastInputValue ||
lastInputValue.text !== v.text ||
lastInputValue.isChecked !== v.isChecked
) {
lastInputValueMap.set(target, v);
const id = mirror.getId(target as INode);
cb({
...v,
id,
});
}
}
const handlers: Array<listenerHandler | hookResetter> = [
'input',
'change',
].map(eventName => on(eventName, eventHandler));
const propertyDescriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
);
if (propertyDescriptor && propertyDescriptor.set) {
handlers.push(
...HOOK_PROPERTIES.map(p =>
hookSetter<HTMLElement>(p[0], p[1], {
set() {
// mock to a normal event
eventHandler({ target: this } as Event);
},
}),
),
);
}
return () => {
handlers.forEach(h => h());
};
}
export default function initObservers(o: observerParam) { export default function initObservers(o: observerParam) {
const mutationObserver = initMutationObserver(o.mutationCb); const mutationObserver = initMutationObserver(o.mutationCb);
const mousemoveHandler = initMousemoveObserver(o.mousemoveCb); const mousemoveHandler = initMousemoveObserver(o.mousemoveCb);
@@ -222,11 +306,13 @@ export default function initObservers(o: observerParam) {
); );
const scrollHandler = initScrollObserver(o.scrollCb); const scrollHandler = initScrollObserver(o.scrollCb);
const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb); const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb);
const inputHandler = initInputObserver(o.inputCb);
return { return {
mutationObserver, mutationObserver,
mousemoveHandler, mousemoveHandler,
mouseInteractionHandler, mouseInteractionHandler,
scrollHandler, scrollHandler,
viewportResizeHandler, viewportResizeHandler,
inputHandler,
}; };
} }

View File

@@ -37,6 +37,7 @@ export enum IncrementalSource {
MouseInteraction, MouseInteraction,
Scroll, Scroll,
ViewportResize, ViewportResize,
Input,
} }
export type mutationData = { export type mutationData = {
@@ -60,12 +61,18 @@ export type viewportResizeData = {
source: IncrementalSource.ViewportResize; source: IncrementalSource.ViewportResize;
} & viewportResizeDimention; } & viewportResizeDimention;
export type inputData = {
source: IncrementalSource.Input;
id: number;
} & inputValue;
export type incrementalData = export type incrementalData =
| mutationData | mutationData
| mousemoveData | mousemoveData
| mouseInteractionData | mouseInteractionData
| scrollData | scrollData
| viewportResizeData; | viewportResizeData
| inputData;
export type event = export type event =
| domContentLoadedEvent | domContentLoadedEvent
@@ -87,6 +94,7 @@ export type observerParam = {
mouseInteractionCb: mouseInteractionCallBack; mouseInteractionCb: mouseInteractionCallBack;
scrollCb: scrollCallback; scrollCb: scrollCallback;
viewportResizeCb: viewportResizeCallback; viewportResizeCb: viewportResizeCallback;
inputCb: inputCallback;
}; };
export type textMutation = { export type textMutation = {
@@ -167,6 +175,13 @@ export type viewportResizeDimention = {
export type viewportResizeCallback = (d: viewportResizeDimention) => void; export type viewportResizeCallback = (d: viewportResizeDimention) => void;
export type inputValue = {
text: string;
isChecked: boolean;
};
export type inputCallback = (v: inputValue & { id: number }) => void;
export type Mirror = { export type Mirror = {
map: idNodeMap; map: idNodeMap;
getId: (n: INode) => number; getId: (n: INode) => number;
@@ -180,3 +195,4 @@ export type throttleOptions = {
}; };
export type listenerHandler = () => void; export type listenerHandler = () => void;
export type hookResetter = () => void;

View File

@@ -1,4 +1,9 @@
import { Mirror, throttleOptions, listenerHandler } from './types'; import {
Mirror,
throttleOptions,
listenerHandler,
hookResetter,
} from './types';
export function on( export function on(
type: string, type: string,
@@ -17,13 +22,14 @@ export const mirror: Mirror = {
getNode(id) { getNode(id) {
return mirror.map[id]; return mirror.map[id];
}, },
// TODO: use a weakmap to get rid of manually memory management
removeNodeFromMap(n) { removeNodeFromMap(n) {
const id = n.__sn && n.__sn.id; const id = n.__sn && n.__sn.id;
delete mirror.map[id]; delete mirror.map[id];
}, },
}; };
// copy from underscore // copy from underscore and modified
export function throttle<T>( export function throttle<T>(
func: (arg: T) => void, func: (arg: T) => void,
wait: number, wait: number,
@@ -56,3 +62,20 @@ export function throttle<T>(
} }
}; };
} }
export function hookSetter<T>(
target: T,
key: string | number | symbol,
d: PropertyDescriptor,
): hookResetter {
const original = Object.getOwnPropertyDescriptor(target, key);
Object.defineProperty(target, key, {
set(value) {
d.set!.call(this, value);
if (original && original.set) {
original.set.call(this, value);
}
},
});
return () => hookSetter(target, key, original || {});
}