Skip to content

Commit 0c9ff2a

Browse files
committed
perf: optimize detection of specific CSS properties
1 parent e7f8e79 commit 0c9ff2a

4 files changed

Lines changed: 67 additions & 69 deletions

File tree

src/clone.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
traverse,
1111
type ElementWithStyle,
1212
} from './utils';
13-
import { getStyle, getElementStyle, getPseudoElementStyle, PSEUDO_ELECTORS } from './style';
13+
import { getStyle, getElementStyle, getPseudoElementStyle, PSEUDO_ELECTORS, compareRule } from './style';
1414
import { isOpenShadowElement, cloneOpenShadowRoot, type ShadowElement } from './shadowDOM';
1515

1616
/** clone element style */
@@ -23,13 +23,13 @@ function cloneElementStyle<T extends ElementWithStyle>(
2323
// identical inline styles are omitted.
2424
const injectionStyle = getElementStyle(target, origin, originStyle);
2525
if (!injectionStyle) return;
26-
const cssText = `${origin.style.cssText}${injectionStyle}`;
26+
const cssText = origin.style.cssText + injectionStyle;
2727
// Inline style trigger an immediate layout reflow,
2828
// after which fewer and correct rules have to be resolved for the children; in practice this is measurably faster.
2929
// The downside is their sky-high specificity: overriding them with mediaPrintStyle is painful,
3030
// We therefore strip the inline declarations once cloning finishes and hand the job over to a clean style sheet.
3131
target.setAttribute('style', cssText);
32-
const styleRule = `${context.getSelector(target)}{${cssText}}`;
32+
const styleRule = compareRule(context.getSelector(target), cssText);
3333
context.addTask(() => {
3434
// Inline style carry higher specificity; strip them to let the `injectionStyle` (external style) prevail.
3535
target.removeAttribute('style');
@@ -50,7 +50,7 @@ function clonePseudoElementStyle<T extends Element>(
5050
const style = getPseudoElementStyle(target, origin, originStyle, pseudoElt);
5151
if (!style) continue;
5252
selector ??= context.getSelector(target);
53-
styleRules += `${selector}${pseudoElt}{${style}}`;
53+
styleRules += compareRule(selector + pseudoElt, style);
5454
}
5555
context.appendStyle(styleRules);
5656
}

src/style.ts

Lines changed: 57 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,79 @@
1-
import {
2-
whichElement,
3-
isBlockContainer,
4-
type ElementWithStyle,
5-
hasIntrinsicAspectRatio,
6-
getOwnerWindow,
7-
} from './utils';
1+
import { whichElement, isBlockContainer, getOwnerWindow } from './utils';
82

9-
export function getStyle(element: Element, pseudoElt?: string) {
3+
type Style = CSSStyleDeclaration;
4+
5+
export function getStyle(element: Element, pseudoElt?: string): Style {
106
return getOwnerWindow(element).getComputedStyle(element, pseudoElt);
117
}
128

13-
function toCSSText(styles: Record<string, string>) {
14-
let cssText = '';
15-
const properties = Object.keys(styles);
9+
export function compareRule(selector: string, text: string) {
10+
return `${selector}{${text}}`;
11+
}
12+
13+
function compare(cssText: string, property: Lowercase<string>, value: string) {
14+
return value ? cssText + property + ': ' + value + ';' : cssText;
15+
}
16+
17+
function isBorderChanged(targetStyle: Style, originStyle: Style) {
18+
const border = originStyle.borderStyle;
19+
return border !== targetStyle.borderStyle && border !== 'none' && border !== 'hidden';
20+
}
21+
22+
function isSizeChanged(targetStyle: Style, originStyle: Style) {
23+
// Changing `padding`, `margin` or `border` alters the element’s size.
24+
return (
25+
originStyle.boxSizing === 'border-box' &&
26+
(originStyle.padding !== targetStyle.padding || originStyle.borderWidth !== targetStyle.borderWidth)
27+
);
28+
}
29+
30+
/** Whether the element has intrinsic aspect ratio */
31+
function isIntrinsicAspectRatio(el: Element, style: Style) {
32+
if (whichElement(el, 'img') || whichElement(el, 'video')) return true;
33+
// SVG element’s aspect ratio is dictated by its `viewBox` by default.
34+
if (whichElement(el, 'svg') && el.getAttribute('viewBox')) return true;
35+
return !!style.aspectRatio && style.aspectRatio !== 'auto';
36+
}
37+
38+
function diff(cssText: string, properties: ArrayLike<string>, targetStyle: Style, originStyle: Style) {
1639
for (let i = 0; i < properties.length; i++) {
17-
const property = properties[i];
18-
cssText += `${property}:${styles[property]};`;
40+
const property = properties[i] as Lowercase<string>;
41+
const value = originStyle.getPropertyValue(property);
42+
if (value && value !== targetStyle.getPropertyValue(property)) {
43+
cssText = compare(cssText, property, value);
44+
}
1945
}
2046
return cssText;
2147
}
2248

2349
// When accessing `CSSStyleDeclaration` by index, the property name doesn’t include `counter`.
2450
const CSS_PROPERTIES_ADDED = ['counter-reset', 'counter-set', 'counter-increment'] as const;
25-
const BORDER_PATTERN = /^border/;
26-
// Changing `padding`, `margin` or `border` alters the element’s size.
27-
const SIZE_PATTERN = /^(padding|margin|border)/;
2851

29-
function getCSSText(targetStyle: CSSStyleDeclaration, originStyle: CSSStyleDeclaration, origin: Element) {
30-
const styles: Record<string, string> = {};
31-
// If `border-style` is neither `none` nor `hidden`, the browser falls back the corresponding border-width to its initial value—medium
52+
function getCSSText(targetStyle: Style, originStyle: Style, origin: Element) {
53+
let cssText = '';
54+
cssText = diff(cssText, originStyle, targetStyle, originStyle);
55+
cssText = diff(cssText, CSS_PROPERTIES_ADDED, targetStyle, originStyle);
56+
// If `border-style` is neither `none` nor `hidden`, the browser falls back the corresponding `border-width` to its initial value—medium
3257
// (3 px per spec, though engines variously resolve it to 2 px or 3 px).
33-
let isBorderChanged = false;
58+
if (isBorderChanged(targetStyle, originStyle)) {
59+
cssText = compare(cssText, 'border-width', originStyle.borderWidth);
60+
}
3461
// For elements with an aspect ratio, always supply both width and height
3562
// to prevent incorrect auto-sizing based on that ratio.
36-
let isSizeChanged = hasIntrinsicAspectRatio(origin as ElementWithStyle);
37-
38-
for (let index = 0; index < originStyle.length; index++) {
39-
const property = originStyle[index];
40-
const value = originStyle.getPropertyValue(property);
41-
if (value && value !== targetStyle.getPropertyValue(property)) {
42-
styles[property] = value;
43-
isBorderChanged ||= BORDER_PATTERN.test(property);
44-
isSizeChanged ||= isBorderChanged || SIZE_PATTERN.test(property);
45-
}
46-
}
47-
48-
for (let index = 0; index < CSS_PROPERTIES_ADDED.length; index++) {
49-
const property = CSS_PROPERTIES_ADDED[index];
50-
const value = originStyle.getPropertyValue(property);
51-
if (value && value !== targetStyle.getPropertyValue(property)) {
52-
styles[property] = value;
53-
}
63+
if (isSizeChanged(targetStyle, originStyle) || isIntrinsicAspectRatio(origin, originStyle)) {
64+
cssText = compare(cssText, 'width', originStyle.width);
65+
cssText = compare(cssText, 'height', originStyle.height);
5466
}
55-
56-
if (isBorderChanged) {
57-
styles['border-width'] = originStyle.borderWidth;
58-
}
59-
if (isSizeChanged) {
60-
styles.width = originStyle.width;
61-
styles.height = originStyle.height;
62-
} else if (originStyle.display === 'table') {
63-
// The `table` layout is always influenced by content;
64-
// whether `table-layout` is `auto` or `fixed`, we must give the table an explicit width to ensure accuracy.
65-
styles.width = originStyle.width;
67+
// The `table` layout is always influenced by content;
68+
// whether `table-layout` is `auto` or `fixed`, we must give the table an explicit width to ensure accuracy.
69+
else if (originStyle.display === 'table') {
70+
cssText = compare(cssText, 'width', originStyle.width);
6671
}
67-
return toCSSText(styles);
72+
return cssText;
6873
}
6974

7075
/** Clone element style; identical inline styles are omitted. */
71-
export function getElementStyle<T extends ElementWithStyle>(target: T, origin: T, originStyle: CSSStyleDeclaration) {
76+
export function getElementStyle<T extends Element>(target: T, origin: T, originStyle: Style) {
7277
return getCSSText(getStyle(target), originStyle, origin);
7378
}
7479

@@ -86,7 +91,7 @@ export const PSEUDO_ELECTORS = [
8691
export function getPseudoElementStyle<T extends Element>(
8792
target: T,
8893
origin: T,
89-
originStyle: CSSStyleDeclaration,
94+
originStyle: Style,
9095
pseudoElt: (typeof PSEUDO_ELECTORS)[number]
9196
) {
9297
if (pseudoElt === '::placeholder') {

src/utils.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function isExternalStyleElement(el: Element): el is ExternalStyleElement
5454
* @internal
5555
* Exporting this constant is solely for the convenience of testing.
5656
*/
57-
export const BLOCK_CONTAINER_DISPLAY: readonly string[] = [
57+
export const BLOCK_CONTAINER_DISPLAY = [
5858
'block',
5959
'inline-block',
6060
'list-item',
@@ -63,7 +63,7 @@ export const BLOCK_CONTAINER_DISPLAY: readonly string[] = [
6363
'table-cell',
6464
'table-column',
6565
'table-column-group',
66-
];
66+
] as const;
6767
/** Block container
6868
* @see https://developer.mozilla.org/docs/Web/CSS/CSS_display/Visual_formatting_model#block_containers
6969
*/
@@ -147,14 +147,6 @@ export function getOwnerWindow(element: Element) {
147147

148148
// Equal to: HTMLElement | SVGElement | MathMLElement
149149
export type ElementWithStyle = Element & ElementCSSInlineStyle;
150-
const INVALID_ASPECT_RATIO = ['', 'auto', 'unset', 'initial'] as const;
151-
/** Whether the element has intrinsic aspect ratio */
152-
export function hasIntrinsicAspectRatio(el: ElementWithStyle) {
153-
// SVG element’s aspect ratio is dictated by its `viewBox` by default.
154-
if (whichElement(el, 'img') || whichElement(el, 'video') || (whichElement(el, 'svg') && el.getAttribute('viewBox')))
155-
return true;
156-
return !includes(el.style.aspectRatio, INVALID_ASPECT_RATIO);
157-
}
158150

159151
export interface ElementWalker<Root extends Node> extends TreeWalker {
160152
currentNode: Element | Root;

tests/browser/realWorld.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ test('GitHub Repository', async ({ page }, testInfo) => {
3838
await page.setViewportSize({ width: 1200, height: 1080 });
3939
test.setTimeout(120_000);
4040
await page.goto('https://github.com/xunmi1/light-print');
41+
await page.waitForTimeout(2000);
4142
await page.evaluate(() => {
4243
document.body.id = 'app';
4344
const style = document.createElement('style');
@@ -55,9 +56,9 @@ test('GitHub Repository', async ({ page }, testInfo) => {
5556
});
5657

5758
test('Node.js Homepage', async ({ page }, testInfo) => {
58-
await page.setViewportSize({ width: 1500, height: 1080 });
59+
await page.setViewportSize({ width: 1600, height: 1080 });
5960
await page.goto('https://nodejs.org');
60-
await page.waitForTimeout(1000);
61+
await page.waitForTimeout(2000);
6162
await page.evaluate(() => {
6263
document.body.id = 'app';
6364
const style = document.createElement('style');
@@ -68,7 +69,7 @@ test('Node.js Homepage', async ({ page }, testInfo) => {
6869
const lightPrint = await loadPrintScript(page);
6970
await lightPrint('#app');
7071
const container = getPrintContainter(page);
71-
await container.evaluate(element => (element.style = 'width: 100%; height: 2000px; border: none'));
72+
await container.evaluate(element => (element.style = 'width: 100%; height: 1200px; border: none'));
7273
const buffer = await screenshot(container.contentFrame().locator('#app'));
7374
expect(buffer).toMatchSnapshot('nodejs.png', { maxDiffPixelRatio: 0.003 });
7475
});

0 commit comments

Comments
 (0)