angular/packages/animations/browser/src/dsl/animation_ast_builder.ts
Matias Niemelä c3a52697f5 perf(animations): reduce size of bundle by removing AST classes (#19539)
This CL refactors the animation AST code to make use of interfaces instead of classes. Given that interfaces are not persisted during runtime the removal of classes should nicely cut down on size for the animations-browser bundle.

-- before --
animations-browser.umd.js = 222kb
animations-browser.umd.min.js = 107kb

-- after --
animations-browser.umd.js = 213kb
animations-browser.umd.min.js = 102kb

PR Close #19539
2017-10-09 10:00:48 -07:00

569 lines
21 KiB
TypeScript

/**
* @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 {AUTO_STYLE, AnimateTimings, AnimationAnimateChildMetadata, AnimationAnimateMetadata, AnimationAnimateRefMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationMetadataType, AnimationOptions, AnimationQueryMetadata, AnimationQueryOptions, AnimationReferenceMetadata, AnimationSequenceMetadata, AnimationStaggerMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata, AnimationTriggerMetadata, style, ɵStyleData} from '@angular/animations';
import {AnimationDriver} from '../render/animation_driver';
import {getOrSetAsInMap} from '../render/shared';
import {ENTER_SELECTOR, LEAVE_SELECTOR, NG_ANIMATING_SELECTOR, NG_TRIGGER_SELECTOR, SUBSTITUTION_EXPR_START, copyObj, extractStyleParams, iteratorToArray, normalizeAnimationEntry, resolveTiming, validateStyleParams, visitDslNode} from '../util';
import {AnimateAst, AnimateChildAst, AnimateRefAst, Ast, DynamicTimingAst, GroupAst, KeyframesAst, QueryAst, ReferenceAst, SequenceAst, StaggerAst, StateAst, StyleAst, TimingAst, TransitionAst, TriggerAst} from './animation_ast';
import {AnimationDslVisitor} from './animation_dsl_visitor';
import {parseTransitionExpr} from './animation_transition_expr';
const SELF_TOKEN = ':self';
const SELF_TOKEN_REGEX = new RegExp(`\s*${SELF_TOKEN}\s*,?`, 'g');
/*
* [Validation]
* The visitor code below will traverse the animation AST generated by the animation verb functions
* (the output is a tree of objects) and attempt to perform a series of validations on the data. The
* following corner-cases will be validated:
*
* 1. Overlap of animations
* Given that a CSS property cannot be animated in more than one place at the same time, it's
* important that this behaviour is detected and validated. The way in which this occurs is that
* each time a style property is examined, a string-map containing the property will be updated with
* the start and end times for when the property is used within an animation step.
*
* If there are two or more parallel animations that are currently running (these are invoked by the
* group()) on the same element then the validator will throw an error. Since the start/end timing
* values are collected for each property then if the current animation step is animating the same
* property and its timing values fall anywhere into the window of time that the property is
* currently being animated within then this is what causes an error.
*
* 2. Timing values
* The validator will validate to see if a timing value of `duration delay easing` or
* `durationNumber` is valid or not.
*
* (note that upon validation the code below will replace the timing data with an object containing
* {duration,delay,easing}.
*
* 3. Offset Validation
* Each of the style() calls are allowed to have an offset value when placed inside of keyframes().
* Offsets within keyframes() are considered valid when:
*
* - No offsets are used at all
* - Each style() entry contains an offset value
* - Each offset is between 0 and 1
* - Each offset is greater to or equal than the previous one
*
* Otherwise an error will be thrown.
*/
export function buildAnimationAst(
driver: AnimationDriver, metadata: AnimationMetadata | AnimationMetadata[],
errors: any[]): Ast<AnimationMetadataType> {
return new AnimationAstBuilderVisitor(driver).build(metadata, errors);
}
const LEAVE_TOKEN = ':leave';
const LEAVE_TOKEN_REGEX = new RegExp(LEAVE_TOKEN, 'g');
const ENTER_TOKEN = ':enter';
const ENTER_TOKEN_REGEX = new RegExp(ENTER_TOKEN, 'g');
const ROOT_SELECTOR = '';
export class AnimationAstBuilderVisitor implements AnimationDslVisitor {
constructor(private _driver: AnimationDriver) {}
build(metadata: AnimationMetadata|AnimationMetadata[], errors: any[]):
Ast<AnimationMetadataType> {
const context = new AnimationAstBuilderContext(errors);
this._resetContextStyleTimingState(context);
return <Ast<AnimationMetadataType>>visitDslNode(
this, normalizeAnimationEntry(metadata), context);
}
private _resetContextStyleTimingState(context: AnimationAstBuilderContext) {
context.currentQuerySelector = ROOT_SELECTOR;
context.collectedStyles = {};
context.collectedStyles[ROOT_SELECTOR] = {};
context.currentTime = 0;
}
visitTrigger(metadata: AnimationTriggerMetadata, context: AnimationAstBuilderContext):
TriggerAst {
let queryCount = context.queryCount = 0;
let depCount = context.depCount = 0;
const states: StateAst[] = [];
const transitions: TransitionAst[] = [];
metadata.definitions.forEach(def => {
this._resetContextStyleTimingState(context);
if (def.type == AnimationMetadataType.State) {
const stateDef = def as AnimationStateMetadata;
const name = stateDef.name;
name.split(/\s*,\s*/).forEach(n => {
stateDef.name = n;
states.push(this.visitState(stateDef, context));
});
stateDef.name = name;
} else if (def.type == AnimationMetadataType.Transition) {
const transition = this.visitTransition(def as AnimationTransitionMetadata, context);
queryCount += transition.queryCount;
depCount += transition.depCount;
transitions.push(transition);
} else {
context.errors.push(
'only state() and transition() definitions can sit inside of a trigger()');
}
});
return {
type: AnimationMetadataType.Trigger,
name: metadata.name, states, transitions, queryCount, depCount,
options: null
};
}
visitState(metadata: AnimationStateMetadata, context: AnimationAstBuilderContext): StateAst {
const styleAst = this.visitStyle(metadata.styles, context);
const astParams = (metadata.options && metadata.options.params) || null;
if (styleAst.containsDynamicStyles) {
const missingSubs = new Set<string>();
const params = astParams || {};
styleAst.styles.forEach(value => {
if (isObject(value)) {
const stylesObj = value as any;
Object.keys(stylesObj).forEach(prop => {
extractStyleParams(stylesObj[prop]).forEach(sub => {
if (!params.hasOwnProperty(sub)) {
missingSubs.add(sub);
}
});
});
}
});
if (missingSubs.size) {
const missingSubsArr = iteratorToArray(missingSubs.values());
context.errors.push(
`state("${metadata.name}", ...) must define default values for all the following style substitutions: ${missingSubsArr.join(', ')}`);
}
}
return {
type: AnimationMetadataType.State,
name: metadata.name,
style: styleAst,
options: astParams ? {params: astParams} : null
};
}
visitTransition(metadata: AnimationTransitionMetadata, context: AnimationAstBuilderContext):
TransitionAst {
context.queryCount = 0;
context.depCount = 0;
const animation = visitDslNode(this, normalizeAnimationEntry(metadata.animation), context);
const matchers = parseTransitionExpr(metadata.expr, context.errors);
return {
type: AnimationMetadataType.Transition,
matchers,
animation,
queryCount: context.queryCount,
depCount: context.depCount,
options: normalizeAnimationOptions(metadata.options)
};
}
visitSequence(metadata: AnimationSequenceMetadata, context: AnimationAstBuilderContext):
SequenceAst {
return {
type: AnimationMetadataType.Sequence,
steps: metadata.steps.map(s => visitDslNode(this, s, context)),
options: normalizeAnimationOptions(metadata.options)
};
}
visitGroup(metadata: AnimationGroupMetadata, context: AnimationAstBuilderContext): GroupAst {
const currentTime = context.currentTime;
let furthestTime = 0;
const steps = metadata.steps.map(step => {
context.currentTime = currentTime;
const innerAst = visitDslNode(this, step, context);
furthestTime = Math.max(furthestTime, context.currentTime);
return innerAst;
});
context.currentTime = furthestTime;
return {
type: AnimationMetadataType.Group,
steps,
options: normalizeAnimationOptions(metadata.options)
};
}
visitAnimate(metadata: AnimationAnimateMetadata, context: AnimationAstBuilderContext):
AnimateAst {
const timingAst = constructTimingAst(metadata.timings, context.errors);
context.currentAnimateTimings = timingAst;
let styleAst: StyleAst|KeyframesAst;
let styleMetadata: AnimationMetadata = metadata.styles ? metadata.styles : style({});
if (styleMetadata.type == AnimationMetadataType.Keyframes) {
styleAst = this.visitKeyframes(styleMetadata as AnimationKeyframesSequenceMetadata, context);
} else {
let styleMetadata = metadata.styles as AnimationStyleMetadata;
let isEmpty = false;
if (!styleMetadata) {
isEmpty = true;
const newStyleData: {[prop: string]: string | number} = {};
if (timingAst.easing) {
newStyleData['easing'] = timingAst.easing;
}
styleMetadata = style(newStyleData);
}
context.currentTime += timingAst.duration + timingAst.delay;
const _styleAst = this.visitStyle(styleMetadata, context);
_styleAst.isEmptyStep = isEmpty;
styleAst = _styleAst;
}
context.currentAnimateTimings = null;
return {
type: AnimationMetadataType.Animate,
timings: timingAst,
style: styleAst,
options: null
};
}
visitStyle(metadata: AnimationStyleMetadata, context: AnimationAstBuilderContext): StyleAst {
const ast = this._makeStyleAst(metadata, context);
this._validateStyleAst(ast, context);
return ast;
}
private _makeStyleAst(metadata: AnimationStyleMetadata, context: AnimationAstBuilderContext):
StyleAst {
const styles: (ɵStyleData | string)[] = [];
if (Array.isArray(metadata.styles)) {
(metadata.styles as(ɵStyleData | string)[]).forEach(styleTuple => {
if (typeof styleTuple == 'string') {
if (styleTuple == AUTO_STYLE) {
styles.push(styleTuple as string);
} else {
context.errors.push(`The provided style string value ${styleTuple} is not allowed.`);
}
} else {
styles.push(styleTuple as ɵStyleData);
}
});
} else {
styles.push(metadata.styles);
}
let containsDynamicStyles = false;
let collectedEasing: string|null = null;
styles.forEach(styleData => {
if (isObject(styleData)) {
const styleMap = styleData as ɵStyleData;
const easing = styleMap['easing'];
if (easing) {
collectedEasing = easing as string;
delete styleMap['easing'];
}
if (!containsDynamicStyles) {
for (let prop in styleMap) {
const value = styleMap[prop];
if (value.toString().indexOf(SUBSTITUTION_EXPR_START) >= 0) {
containsDynamicStyles = true;
break;
}
}
}
}
});
return {
type: AnimationMetadataType.Style,
styles,
easing: collectedEasing,
offset: metadata.offset, containsDynamicStyles,
options: null
};
}
private _validateStyleAst(ast: StyleAst, context: AnimationAstBuilderContext): void {
const timings = context.currentAnimateTimings;
let endTime = context.currentTime;
let startTime = context.currentTime;
if (timings && startTime > 0) {
startTime -= timings.duration + timings.delay;
}
ast.styles.forEach(tuple => {
if (typeof tuple == 'string') return;
Object.keys(tuple).forEach(prop => {
if (!this._driver.validateStyleProperty(prop)) {
context.errors.push(
`The provided animation property "${prop}" is not a supported CSS property for animations`);
return;
}
const collectedStyles = context.collectedStyles[context.currentQuerySelector !];
const collectedEntry = collectedStyles[prop];
let updateCollectedStyle = true;
if (collectedEntry) {
if (startTime != endTime && startTime >= collectedEntry.startTime &&
endTime <= collectedEntry.endTime) {
context.errors.push(
`The CSS property "${prop}" that exists between the times of "${collectedEntry.startTime}ms" and "${collectedEntry.endTime}ms" is also being animated in a parallel animation between the times of "${startTime}ms" and "${endTime}ms"`);
updateCollectedStyle = false;
}
// we always choose the smaller start time value since we
// want to have a record of the entire animation window where
// the style property is being animated in between
startTime = collectedEntry.startTime;
}
if (updateCollectedStyle) {
collectedStyles[prop] = {startTime, endTime};
}
if (context.options) {
validateStyleParams(tuple[prop], context.options, context.errors);
}
});
});
}
visitKeyframes(metadata: AnimationKeyframesSequenceMetadata, context: AnimationAstBuilderContext):
KeyframesAst {
const ast: KeyframesAst = {type: AnimationMetadataType.Keyframes, styles: [], options: null};
if (!context.currentAnimateTimings) {
context.errors.push(`keyframes() must be placed inside of a call to animate()`);
return ast;
}
const MAX_KEYFRAME_OFFSET = 1;
let totalKeyframesWithOffsets = 0;
const offsets: number[] = [];
let offsetsOutOfOrder = false;
let keyframesOutOfRange = false;
let previousOffset: number = 0;
const keyframes: StyleAst[] = metadata.steps.map(styles => {
const style = this._makeStyleAst(styles, context);
let offsetVal: number|null =
style.offset != null ? style.offset : consumeOffset(style.styles);
let offset: number = 0;
if (offsetVal != null) {
totalKeyframesWithOffsets++;
offset = style.offset = offsetVal;
}
keyframesOutOfRange = keyframesOutOfRange || offset < 0 || offset > 1;
offsetsOutOfOrder = offsetsOutOfOrder || offset < previousOffset;
previousOffset = offset;
offsets.push(offset);
return style;
});
if (keyframesOutOfRange) {
context.errors.push(`Please ensure that all keyframe offsets are between 0 and 1`);
}
if (offsetsOutOfOrder) {
context.errors.push(`Please ensure that all keyframe offsets are in order`);
}
const length = metadata.steps.length;
let generatedOffset = 0;
if (totalKeyframesWithOffsets > 0 && totalKeyframesWithOffsets < length) {
context.errors.push(`Not all style() steps within the declared keyframes() contain offsets`);
} else if (totalKeyframesWithOffsets == 0) {
generatedOffset = MAX_KEYFRAME_OFFSET / (length - 1);
}
const limit = length - 1;
const currentTime = context.currentTime;
const currentAnimateTimings = context.currentAnimateTimings !;
const animateDuration = currentAnimateTimings.duration;
keyframes.forEach((kf, i) => {
const offset = generatedOffset > 0 ? (i == limit ? 1 : (generatedOffset * i)) : offsets[i];
const durationUpToThisFrame = offset * animateDuration;
context.currentTime = currentTime + currentAnimateTimings.delay + durationUpToThisFrame;
currentAnimateTimings.duration = durationUpToThisFrame;
this._validateStyleAst(kf, context);
kf.offset = offset;
ast.styles.push(kf);
});
return ast;
}
visitReference(metadata: AnimationReferenceMetadata, context: AnimationAstBuilderContext):
ReferenceAst {
return {
type: AnimationMetadataType.Reference,
animation: visitDslNode(this, normalizeAnimationEntry(metadata.animation), context),
options: normalizeAnimationOptions(metadata.options)
};
}
visitAnimateChild(metadata: AnimationAnimateChildMetadata, context: AnimationAstBuilderContext):
AnimateChildAst {
context.depCount++;
return {
type: AnimationMetadataType.AnimateChild,
options: normalizeAnimationOptions(metadata.options)
};
}
visitAnimateRef(metadata: AnimationAnimateRefMetadata, context: AnimationAstBuilderContext):
AnimateRefAst {
return {
type: AnimationMetadataType.AnimateRef,
animation: this.visitReference(metadata.animation, context),
options: normalizeAnimationOptions(metadata.options)
};
}
visitQuery(metadata: AnimationQueryMetadata, context: AnimationAstBuilderContext): QueryAst {
const parentSelector = context.currentQuerySelector !;
const options = (metadata.options || {}) as AnimationQueryOptions;
context.queryCount++;
context.currentQuery = metadata;
const [selector, includeSelf] = normalizeSelector(metadata.selector);
context.currentQuerySelector =
parentSelector.length ? (parentSelector + ' ' + selector) : selector;
getOrSetAsInMap(context.collectedStyles, context.currentQuerySelector, {});
const animation = visitDslNode(this, normalizeAnimationEntry(metadata.animation), context);
context.currentQuery = null;
context.currentQuerySelector = parentSelector;
return {
type: AnimationMetadataType.Query,
selector,
limit: options.limit || 0,
optional: !!options.optional, includeSelf, animation,
originalSelector: metadata.selector,
options: normalizeAnimationOptions(metadata.options)
};
}
visitStagger(metadata: AnimationStaggerMetadata, context: AnimationAstBuilderContext):
StaggerAst {
if (!context.currentQuery) {
context.errors.push(`stagger() can only be used inside of query()`);
}
const timings = metadata.timings === 'full' ?
{duration: 0, delay: 0, easing: 'full'} :
resolveTiming(metadata.timings, context.errors, true);
return {
type: AnimationMetadataType.Stagger,
animation: visitDslNode(this, normalizeAnimationEntry(metadata.animation), context), timings,
options: null
};
}
}
function normalizeSelector(selector: string): [string, boolean] {
const hasAmpersand = selector.split(/\s*,\s*/).find(token => token == SELF_TOKEN) ? true : false;
if (hasAmpersand) {
selector = selector.replace(SELF_TOKEN_REGEX, '');
}
selector = selector.replace(ENTER_TOKEN_REGEX, ENTER_SELECTOR)
.replace(LEAVE_TOKEN_REGEX, LEAVE_SELECTOR)
.replace(/@\*/g, NG_TRIGGER_SELECTOR)
.replace(/@\w+/g, match => NG_TRIGGER_SELECTOR + '-' + match.substr(1))
.replace(/:animating/g, NG_ANIMATING_SELECTOR);
return [selector, hasAmpersand];
}
function normalizeParams(obj: {[key: string]: any} | any): {[key: string]: any}|null {
return obj ? copyObj(obj) : null;
}
export type StyleTimeTuple = {
startTime: number; endTime: number;
};
export class AnimationAstBuilderContext {
public queryCount: number = 0;
public depCount: number = 0;
public currentTransition: AnimationTransitionMetadata|null = null;
public currentQuery: AnimationQueryMetadata|null = null;
public currentQuerySelector: string|null = null;
public currentAnimateTimings: TimingAst|null = null;
public currentTime: number = 0;
public collectedStyles: {[selectorName: string]: {[propName: string]: StyleTimeTuple}} = {};
public options: AnimationOptions|null = null;
constructor(public errors: any[]) {}
}
function consumeOffset(styles: ɵStyleData | string | (ɵStyleData | string)[]): number|null {
if (typeof styles == 'string') return null;
let offset: number|null = null;
if (Array.isArray(styles)) {
styles.forEach(styleTuple => {
if (isObject(styleTuple) && styleTuple.hasOwnProperty('offset')) {
const obj = styleTuple as ɵStyleData;
offset = parseFloat(obj['offset'] as string);
delete obj['offset'];
}
});
} else if (isObject(styles) && styles.hasOwnProperty('offset')) {
const obj = styles as ɵStyleData;
offset = parseFloat(obj['offset'] as string);
delete obj['offset'];
}
return offset;
}
function isObject(value: any): boolean {
return !Array.isArray(value) && typeof value == 'object';
}
function constructTimingAst(value: string | number | AnimateTimings, errors: any[]) {
let timings: AnimateTimings|null = null;
if (value.hasOwnProperty('duration')) {
timings = value as AnimateTimings;
} else if (typeof value == 'number') {
const duration = resolveTiming(value as number, errors).duration;
return makeTimingAst(duration as number, 0, '');
}
const strValue = value as string;
const isDynamic = strValue.split(/\s+/).some(v => v.charAt(0) == '{' && v.charAt(1) == '{');
if (isDynamic) {
const ast = makeTimingAst(0, 0, '') as any;
ast.dynamic = true;
ast.strValue = strValue;
return ast as DynamicTimingAst;
}
timings = timings || resolveTiming(strValue, errors);
return makeTimingAst(timings.duration, timings.delay, timings.easing);
}
function normalizeAnimationOptions(options: AnimationOptions | null): AnimationOptions {
if (options) {
options = copyObj(options);
if (options['params']) {
options['params'] = normalizeParams(options['params']) !;
}
} else {
options = {};
}
return options;
}
function makeTimingAst(duration: number, delay: number, easing: string | null): TimingAst {
return {duration, delay, easing};
}