refactor(ivy): Add style reconciliation algorithm (#34004)

This change introduces class/style reconciliation algorithm for DOM elements.
NOTE: The code is not yet hooked up, it will be used by future style algorithm.

Background:
Styling algorithm currently has [two paths](https://hackmd.io/@5zDGNGArSxiHhgvxRGrg-g/rycZk3N5S)
when computing how the style should be rendered.
1. A direct path which concatenates styling and uses `elemnent.className`/`element.style.cssText` and
2. A merge path which uses internal data structures and uses `element.classList.add/remove`/`element.style[property]`.

The situation is confusing and hard to follow/maintain. So a future PR will remove the merge-path and do everything with
direct-path. This however breaks when some other code adds class or style to the element without Angular's knowledge.
If this happens instead of switching from direct-path to merge-path algorithm, this change provides a different mental model
whereby we always do `direct-path` but the code which writes to the DOM detects the situation and reconciles the out of bound write.

The reconciliation process is as follows:
1. Detect that no one has modified `className`/`cssText` and if so just write directly (fast path).
2. If out of bounds write did occur, switch from writing using `className`/`cssText` to `element.classList.add/remove`/`element.style[property]`.
   This does require that the write function computes the difference between the previous Angular expected state and current Angular state.
   (This requires a parser. The advantage of having a parser is that we can support `style="width: {{exp}}px" kind of bindings.`)
   Compute the diff and apply it in non destructive way using `element.classList.add/remove`/`element.style[property]`

Properties of approach:
- If no out of bounds style modification:
  - Very fast code path: Just concatenate string in right order and write them to DOM.
  - Class list order is preserved
- If out of bounds style modification detected:
  - Penalty for parsing
  - Switch to non destructive modification: `element.classList.add/remove`/`element.style[property]`
  - Switch to alphabetical way of setting classes.

PR Close #34004
This commit is contained in:
Misko Hevery
2019-11-22 20:40:29 -08:00
committed by Miško Hevery
parent 622737cee3
commit b57f7c7776
15 changed files with 1110 additions and 4 deletions

View File

@ -9,6 +9,7 @@ ts_library(
),
deps = [
"//packages/core",
"//packages/core/src/util",
"@npm//@types/jasmine",
"@npm//@types/node",
],
@ -215,3 +216,16 @@ ng_benchmark(
name = "duplicate_map_based_style_and_class_bindings",
bundle = ":duplicate_map_based_style_and_class_bindings_lib",
)
ng_rollup_bundle(
name = "split_class_list_lib",
entry_point = ":split_class_list.ts",
deps = [
":perf_lib",
],
)
ng_benchmark(
name = "split_class_list",
bundle = ":split_class_list_lib",
)

View File

@ -65,7 +65,7 @@ export function createBenchmark(benchmarkName: string): Benchmark {
if (!runAgain) {
// tslint:disable-next-line:no-console
console.log(
` ${formatTime(iterationTime_ms)} (count: ${profile.sampleCount}, iterations: ${profile.iterationCount})`);
` ${formatTime(profile.bestTime)} (count: ${profile.sampleCount}, iterations: ${profile.iterationCount})`);
}
}
iterationCounter = profile.iterationCount;

View File

@ -0,0 +1,64 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {processClassToken, splitClassList} from '@angular/core/src/render3/styling/class_differ';
import {createBenchmark} from './micro_bench';
const benchmark = createBenchmark('split_class_list');
const splitTime = benchmark('String.split(" ")');
const splitRegexpTime = benchmark('String.split(/\\s+/)');
const splitClassListTime = benchmark('splitClassList');
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const CLASSES: string[] = [LETTERS];
for (let i = 0; i < LETTERS.length; i++) {
CLASSES.push(LETTERS.substring(0, i) + ' ' + LETTERS.substring(i, LETTERS.length));
}
let index = 0;
let changes = new Map<string, boolean|null>();
let parts: string[] = [];
while (splitTime()) {
changes = clearArray(changes);
const classes = CLASSES[index++];
parts = classes.split(' ');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part !== '') {
processClassToken(changes, part, false);
}
}
if (index === CLASSES.length) index = 0;
}
const WHITESPACE = /\s+/m;
while (splitRegexpTime()) {
changes = clearArray(changes);
const classes = CLASSES[index++];
parts = classes.split(WHITESPACE);
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part !== '') {
processClassToken(changes, part, false);
}
}
if (index === CLASSES.length) index = 0;
}
while (splitClassListTime()) {
changes = clearArray(changes);
splitClassList(CLASSES[index++], changes, false);
if (index === CLASSES.length) index = 0;
}
benchmark.report();
function clearArray(a: Map<any, any>): any {
a.clear();
}

View File

@ -0,0 +1,108 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {computeClassChanges, removeClass, splitClassList} from '../../../src/render3/styling/class_differ';
describe('class differ', () => {
describe('computeClassChanges', () => {
function expectComputeClassChanges(oldValue: string, newValue: string) {
const changes: (boolean | null | string)[] = [];
const newLocal = computeClassChanges(oldValue, newValue);
sortedForEach(newLocal, (value, key) => { changes.push(key, value); });
return expect(changes);
}
it('should detect no changes', () => {
expectComputeClassChanges('', '').toEqual([]);
expectComputeClassChanges('A', 'A').toEqual(['A', null]);
expectComputeClassChanges('A B', 'A B').toEqual(['A', null, 'B', null]);
});
it('should detect no changes when out of order', () => {
expectComputeClassChanges('A B', 'B A').toEqual(['A', null, 'B', null]);
expectComputeClassChanges('A B C', 'B C A').toEqual(['A', null, 'B', null, 'C', null]);
});
it('should detect additions', () => {
expectComputeClassChanges('A B', 'A B C').toEqual(['A', null, 'B', null, 'C', true]);
expectComputeClassChanges('Alpha Bravo', 'Bravo Alpha Charlie').toEqual([
'Alpha', null, 'Bravo', null, 'Charlie', true
]);
expectComputeClassChanges('A B ', 'C B A').toEqual(['A', null, 'B', null, 'C', true]);
});
it('should detect removals', () => {
expectComputeClassChanges('A B C', 'A B').toEqual(['A', null, 'B', null, 'C', false]);
expectComputeClassChanges('B A C', 'B A').toEqual(['A', null, 'B', null, 'C', false]);
expectComputeClassChanges('C B A', 'A B').toEqual(['A', null, 'B', null, 'C', false]);
});
it('should detect duplicates and ignore them', () => {
expectComputeClassChanges('A A B C', 'A B C').toEqual(['A', null, 'B', null, 'C', null]);
expectComputeClassChanges('A A B', 'A A C').toEqual(['A', null, 'B', false, 'C', true]);
});
});
describe('splitClassList', () => {
function expectSplitClassList(text: string) {
const changes: (boolean | null | string)[] = [];
const changesMap = new Map<string, boolean|null>();
splitClassList(text, changesMap, false);
changesMap.forEach((value, key) => changes.push(key, value));
return expect(changes);
}
it('should parse a list', () => {
expectSplitClassList('').toEqual([]);
expectSplitClassList('A').toEqual(['A', false]);
expectSplitClassList('A B').toEqual(['A', false, 'B', false]);
expectSplitClassList('Alpha Bravo').toEqual(['Alpha', false, 'Bravo', false]);
});
it('should ignore extra spaces', () => {
expectSplitClassList(' \n\r\t').toEqual([]);
expectSplitClassList(' A ').toEqual(['A', false]);
expectSplitClassList(' \n\r\t A \n\r\t B\n\r\t ').toEqual(['A', false, 'B', false]);
expectSplitClassList(' \n\r\t Alpha \n\r\t Bravo \n\r\t ').toEqual([
'Alpha', false, 'Bravo', false
]);
});
it('should remove duplicates', () => {
expectSplitClassList('').toEqual([]);
expectSplitClassList('A A').toEqual(['A', false]);
expectSplitClassList('A B B A').toEqual(['A', false, 'B', false]);
expectSplitClassList('Alpha Bravo Bravo Alpha').toEqual(['Alpha', false, 'Bravo', false]);
});
});
describe('removeClass', () => {
it('should remove class name from a class-list string', () => {
expect(removeClass('', '')).toEqual('');
expect(removeClass('A', 'A')).toEqual('');
expect(removeClass('AB', 'AB')).toEqual('');
expect(removeClass('A B', 'A')).toEqual('B');
expect(removeClass('A B', 'A')).toEqual('B');
});
it('should not remove a sub-string', () => {
expect(removeClass('ABC', 'A')).toEqual('ABC');
expect(removeClass('ABC', 'B')).toEqual('ABC');
expect(removeClass('ABC', 'C')).toEqual('ABC');
expect(removeClass('ABC', 'AB')).toEqual('ABC');
expect(removeClass('ABC', 'BC')).toEqual('ABC');
});
});
});
export function sortedForEach<V>(map: Map<string, V>, fn: (value: V, key: string) => void): void {
const keys: string[] = [];
map.forEach((value, key) => keys.push(key));
keys.sort();
keys.forEach((key) => fn(map.get(key) !, key));
}

View File

@ -0,0 +1,124 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Renderer3, domRendererFactory3} from '@angular/core/src/render3/interfaces/renderer';
import {writeAndReconcileClass, writeAndReconcileStyle} from '@angular/core/src/render3/styling/reconcile';
describe('styling reconcile', () => {
[document, domRendererFactory3.createRenderer(null, null)].forEach((renderer: Renderer3) => {
let element: HTMLDivElement;
beforeEach(() => { element = document.createElement('div'); });
describe('writeAndReconcileClass', () => {
it('should write new value to DOM', () => {
writeAndReconcileClass(renderer, element, '', 'A');
expect(getSortedClassName(element)).toEqual('A');
writeAndReconcileClass(renderer, element, 'A', 'C B A');
expect(getSortedClassName(element)).toEqual('A B C');
writeAndReconcileClass(renderer, element, 'C B A', '');
expect(getSortedClassName(element)).toEqual('');
});
it('should write value alphabetically when existing class present', () => {
element.className = 'X';
writeAndReconcileClass(renderer, element, '', 'A');
expect(getSortedClassName(element)).toEqual('A X');
writeAndReconcileClass(renderer, element, 'A', 'C B A');
expect(getSortedClassName(element)).toEqual('A B C X');
writeAndReconcileClass(renderer, element, 'C B A', '');
expect(getSortedClassName(element)).toEqual('X');
});
});
describe('writeAndReconcileStyle', () => {
it('should write new value to DOM', () => {
writeAndReconcileStyle(renderer, element, '', 'width: 100px;');
expect(getSortedStyle(element)).toEqual('width: 100px;');
writeAndReconcileStyle(
renderer, element, 'width: 100px;', 'color: red; height: 100px; width: 100px;');
expect(getSortedStyle(element)).toEqual('color: red; height: 100px; width: 100px;');
writeAndReconcileStyle(renderer, element, 'color: red; height: 100px; width: 100px;', '');
expect(getSortedStyle(element)).toEqual('');
});
it('should not clobber out of bound styles', () => {
element.style.cssText = 'color: red;';
writeAndReconcileStyle(renderer, element, '', 'width: 100px;');
expect(getSortedStyle(element)).toEqual('color: red; width: 100px;');
writeAndReconcileStyle(renderer, element, 'width: 100px;', 'width: 200px;');
expect(getSortedStyle(element)).toEqual('color: red; width: 200px;');
writeAndReconcileStyle(renderer, element, 'width: 200px;', 'width: 200px; height: 100px;');
expect(getSortedStyle(element)).toEqual('color: red; height: 100px; width: 200px;');
writeAndReconcileStyle(renderer, element, 'width: 200px; height: 100px;', '');
expect(getSortedStyle(element)).toEqual('color: red;');
});
it('should support duplicate styles', () => {
element.style.cssText = 'color: red;';
writeAndReconcileStyle(renderer, element, '', 'width: 100px; width: 200px;');
expect(getSortedStyle(element)).toEqual('color: red; width: 200px;');
writeAndReconcileStyle(
renderer, element, 'width: 100px; width: 200px;',
'width: 100px; width: 200px; height: 100px;');
expect(getSortedStyle(element)).toEqual('color: red; height: 100px; width: 200px;');
writeAndReconcileStyle(renderer, element, 'width: 100px; height: 100px;', '');
expect(getSortedStyle(element)).toEqual('color: red;');
});
});
});
});
function getSortedClassName(element: HTMLElement): string {
const names: string[] = [];
const classList = element.classList || [];
for (let i = 0; i < classList.length; i++) {
const name = classList[i];
if (names.indexOf(name) === -1) {
names.push(name);
}
}
names.sort();
return names.join(' ');
}
function getSortedStyle(element: HTMLElement): string {
const names: string[] = [];
const style = element.style;
// reading `style.color` is a work around for a bug in Domino. The issue is that Domino has stale
// value for `style.length`. It seems that reading a property from the element causes the stale
// value to be updated. (As of Domino v 2.1.3)
style.color;
for (let i = 0; i < style.length; i++) {
const name = style.item(i);
if (names.indexOf(name) === -1) {
names.push(name);
}
}
names.sort();
let sorted = '';
names.forEach(key => {
const value = style.getPropertyValue(key);
if (value != null && value !== '') {
if (sorted !== '') sorted += ' ';
sorted += key + ': ' + value + ';';
}
});
return sorted;
}

View File

@ -0,0 +1,129 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {StyleChangesMap, parseKeyValue, removeStyle} from '@angular/core/src/render3/styling/style_differ';
import {consumeSeparator, consumeStyleValue} from '@angular/core/src/render3/styling/styling_parser';
import {CharCode} from '@angular/core/src/util/char_code';
import {sortedForEach} from './class_differ_spec';
describe('style differ', () => {
describe('parseStyleValue', () => {
it('should parse empty value', () => {
expectParseValue(':').toBe('');
expectParseValue(':;🛑ignore').toBe('');
expectParseValue(': ;🛑ignore').toBe('');
expectParseValue(':;🛑ignore').toBe('');
expectParseValue(': \n\t\r ;🛑').toBe('');
});
it('should parse basic value', () => {
expectParseValue(':a').toBe('a');
expectParseValue(':text').toBe('text');
expectParseValue(': text2 ;🛑').toBe('text2');
expectParseValue(':text3;🛑').toBe('text3');
expectParseValue(': text3 ;🛑').toBe('text3');
expectParseValue(': text1 text2;🛑').toBe('text1 text2');
expectParseValue(': text1 text2 ;🛑').toBe('text1 text2');
});
it('should parse quoted values', () => {
expectParseValue(':""').toBe('""');
expectParseValue(':"\\\\"').toBe('"\\\\"');
expectParseValue(': ""').toBe('""');
expectParseValue(': "" ').toBe('""');
expectParseValue(': "text1" text2 ').toBe('"text1" text2');
expectParseValue(':"text"').toBe('"text"');
expectParseValue(': \'hello world\'').toBe('\'hello world\'');
expectParseValue(':"some \n\t\r text ,;";🛑').toBe('"some \n\t\r text ,;"');
expectParseValue(':"\\"\'";🛑').toBe('"\\"\'"');
});
it('should parse url()', () => {
expectParseValue(':url(:;)').toBe('url(:;)');
expectParseValue(':URL(some :; text)').toBe('URL(some :; text)');
expectParseValue(': url(text);🛑').toBe('url(text)');
expectParseValue(': url(text) more text;🛑').toBe('url(text) more text');
expectParseValue(':url(;"\':\\))').toBe('url(;"\':\\))');
expectParseValue(': url(;"\':\\)) ;🛑').toBe('url(;"\':\\))');
});
});
describe('parseKeyValue', () => {
it('should parse empty value', () => {
expectParseKeyValue('').toEqual([]);
expectParseKeyValue(' \n\t\r ').toEqual([]);
});
it('should prase single style', () => {
expectParseKeyValue('width: 100px').toEqual(['width', '100px', null]);
expectParseKeyValue(' width : 100px ;').toEqual(['width', '100px', null]);
});
it('should prase multi style', () => {
expectParseKeyValue('width: 100px; height: 200px').toEqual([
'height', '200px', null, //
'width', '100px', null, //
]);
expectParseKeyValue(' height : 200px ; width : 100px ').toEqual([
'height', '200px', null, //
'width', '100px', null //
]);
});
});
describe('removeStyle', () => {
it('should remove no style', () => {
expect(removeStyle('', 'foo')).toEqual('');
expect(removeStyle('abc: bar', 'a')).toEqual('abc: bar');
expect(removeStyle('abc: bar', 'b')).toEqual('abc: bar');
expect(removeStyle('abc: bar', 'c')).toEqual('abc: bar');
expect(removeStyle('abc: bar', 'bar')).toEqual('abc: bar');
});
it('should remove all style', () => {
expect(removeStyle('foo: bar', 'foo')).toEqual('');
expect(removeStyle('foo: bar; foo: bar;', 'foo')).toEqual('');
});
it('should remove some of the style', () => {
expect(removeStyle('a: a; foo: bar; b: b', 'foo')).toEqual('a: a; b: b');
expect(removeStyle('a: a; foo: bar; b: b; foo: bar; c: c', 'foo'))
.toEqual('a: a; b: b; c: c');
});
it('should remove trailing ;', () => {
expect(removeStyle('a: a; foo: bar', 'foo')).toEqual('a: a');
expect(removeStyle('a: a ; foo: bar ; ', 'foo')).toEqual('a: a');
});
});
});
function expectParseValue(
/**
* The text to parse.
*
* The text can contain special 🛑 character which demarcates where the parsing should stop
* and asserts that the parsing ends at that location.
*/
text: string) {
let stopIndex = text.indexOf('🛑');
if (stopIndex < 0) stopIndex = text.length;
const valueStart = consumeSeparator(text, 0, text.length, CharCode.COLON);
const valueEnd = consumeStyleValue(text, valueStart, text.length);
const valueSep = consumeSeparator(text, valueEnd, text.length, CharCode.SEMI_COLON);
expect(valueSep).toBe(stopIndex);
return expect(text.substring(valueStart, valueEnd));
}
function expectParseKeyValue(text: string) {
const changes: StyleChangesMap = new Map<string, any>();
parseKeyValue(text, changes, false);
const list: any[] = [];
sortedForEach(changes, (value, key) => list.push(key, value.old, value.new));
return expect(list);
}