/
githubmirror
/
amphtml
Обзор
Документация
Войти
/
githubmirror
/
amphtml
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
extensions/amp-analytics/0.1/analytics-root.js
576 строк
16 KB
Shihua Zheng
commit (#39097)
06 июн 2023, 02:37
Не верифицирован
06 июн 2023, 02:37
649e4ac
Код
Авторство
О чём код?
import {tryResolve} from '#core/data-structures/promise'; import {getDataParamsFromAttributes} from '#core/dom'; import {layoutRectLtwh} from '#core/dom/layout/rect'; import { closestAncestorElementBySelector, matches, scopedQuerySelector, } from '#core/dom/query'; import {isArray} from '#core/types'; import {map} from '#core/types/object'; import {Services} from '#service'; import {dev, user, userAssert} from '#utils/log'; import {ScrollManager} from './scroll-manager'; import {provideVisibilityManager} from './visibility-manager'; import {whenContentIniLoad} from '../../../src/ini-load'; import {getMode} from '../../../src/mode'; const TAG = 'amp-analytics/analytics-root'; const VARIABLE_DATA_ATTRIBUTE_KEY = /^vars(.+)/; /** * An analytics root. Analytics can be scoped to either ampdoc, embed or * an arbitrary AMP element. * * TODO(#22733): merge analytics root properties into ampdoc. * * @implements {../../../src/service.Disposable} * @abstract */ export class AnalyticsRoot { /** * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc */ constructor(ampdoc) { /** @const */ this.ampdoc = ampdoc; /** @const */ this.trackers_ = map(); /** @private {?./visibility-manager.VisibilityManager} */ this.visibilityManager_ = null; /** @private {?./scroll-manager.ScrollManager} */ this.scrollManager_ = null; } /** @override */ dispose() { for (const k in this.trackers_) { this.trackers_[k].dispose(); delete this.trackers_[k]; } if (this.visibilityManager_) { this.visibilityManager_.dispose(); } if (this.scrollManager_) { this.scrollManager_.dispose(); } } /** * Returns the type of the tracker. * @return {string} * @abstract */ getType() {} /** * The root node the analytics is scoped to. * * @return {!Document|!ShadowRoot} * @abstract */ getRoot() {} /** * The root element within the analytics root. * * @return {!Element} */ getRootElement() { const root = this.getRoot(); // In the case of a shadow doc, its host will be used as // a refrence point return dev().assertElement( root.host || root.documentElement || root.body || root ); } /** * The host element of the analytics root. * * @return {?Element} * @abstract */ getHostElement() {} /** * The signals for the root. * * @return {!../../../src/utils/signals.Signals} * @abstract */ signals() {} /** * Whether this analytics root contains the specified node. * * @param {!Node} node * @return {boolean} */ contains(node) { return this.getRoot().contains(node); } /** * Returns the element with the specified ID in the scope of this root. * * @param {string} unusedId * @return {?Element} * @abstract */ getElementById(unusedId) {} /** * Returns the tracker for the specified name and list of allowed types. * * @param {string} name * @param {!{[key: string]: typeof ./events.EventTracker}} allowlist * @return {?./events.EventTracker} */ getTrackerForAllowlist(name, allowlist) { const trackerProfile = allowlist[name]; if (trackerProfile) { return this.getTracker(name, trackerProfile); } return null; } /** * Returns the tracker for the specified name and type. If the tracker * has not been requested before, it will be created. * * @param {string} name * @param {typeof ./events.CustomEventTracker|typeof ./events.ClickEventTracker|typeof ./events.ScrollEventTracker|typeof ./events.SignalTracker|typeof ./events.IniLoadTracker|typeof ./events.VideoEventTracker|typeof ./events.VideoEventTracker|typeof ./events.VisibilityTracker|typeof ./events.AmpStoryEventTracker} klass * @return {!./events.EventTracker} */ getTracker(name, klass) { let tracker = this.trackers_[name]; if (!tracker) { tracker = new klass(this); this.trackers_[name] = tracker; } return tracker; } /** * Returns the tracker for the specified name or `null`. * @param {string} name * @return {?./events.EventTracker} */ getTrackerOptional(name) { return this.trackers_[name] || null; } /** * Searches the element that matches the selector within the scope of the * analytics root in relationship to the specified context node. * * @param {!Element} context * @param {string} selector DOM query selector. * @param {?string=} selectionMethod Allowed values are `null`, * `'closest'` and `'scope'`. * @return {!Promise<!Element>} Element corresponding to the selector. */ getElement(context, selector, selectionMethod = null) { // Special case selectors. The selection method is irrelavant. // And no need to wait for document ready. if (selector == ':root') { return tryResolve(() => this.getRootElement()); } if (selector == ':host') { return new Promise((resolve) => { resolve( user().assertElement( this.getHostElement(), `Element "${selector}" not found` ) ); }); } // Wait for document-ready to avoid false missed searches return this.ampdoc.whenReady().then(() => { let found; let result = null; // Query search based on the selection method. try { if (selectionMethod == 'scope') { found = scopedQuerySelector(context, selector); } else if (selectionMethod == 'closest') { found = closestAncestorElementBySelector(context, selector); } else { found = this.getRoot().querySelector(selector); } } catch (e) { userAssert(false, `Invalid query selector ${selector}`); } // DOM search can "look" outside the boundaries of the root, thus make // sure the result is contained. if (found && this.contains(found)) { result = found; } return user().assertElement(result, `Element "${selector}" not found`); }); } /** * @param {!Array<string>} selectors Array of DOM query selectors. * @param {boolean} useDataVars Indicator if DataVars restristiction should be applied. * Default set to true. * @return {!Promise<!Array<!Element>>} Element corresponding to the selector. */ getElementsByQuerySelectorAll_(selectors, useDataVars = true) { // Wait for document-ready to avoid false missed searches return this.ampdoc.whenReady().then(() => { let elements = []; for (let i = 0; i < selectors.length; i++) { let nodeList; let elementArray = []; const selector = selectors[i]; try { nodeList = this.getRoot().querySelectorAll(selector); } catch (e) { userAssert(false, `Invalid query selector ${selector}`); } for (let j = 0; j < nodeList.length; j++) { if (this.contains(nodeList[j])) { elementArray.push(nodeList[j]); } } elementArray = useDataVars ? this.getDataVarsElements_(elementArray, selector) : elementArray; userAssert(elementArray.length, `Element "${selector}" not found`); elements = elements.concat(elementArray); } // Return unique return elements.filter( (element, index) => elements.indexOf(element) === index ); }); } /** * Return all elements that have a data-vars attribute. * @param {!Array<!Element>} elementArray * @param {string} selector * @return {!Array<!Element>} */ getDataVarsElements_(elementArray, selector) { let removedCount = 0; const dataVarsArray = []; for (let i = 0; i < elementArray.length; i++) { const dataVarKeys = Object.keys( getDataParamsFromAttributes( elementArray[i], /* computeParamNameFunc */ undefined, VARIABLE_DATA_ATTRIBUTE_KEY ) ); if (dataVarKeys.length) { dataVarsArray.push(elementArray[i]); } else { removedCount++; } } if (removedCount) { user().warn( TAG, '%s element(s) ommited from selector "%s"' + ' because no data-vars-* attribute was found.', removedCount, selector ); } return dataVarsArray; } /** * Searches the AMP element that matches the selector within the scope of the * analytics root in relationship to the specified context node. * * @param {!Element} context * @param {string} selector DOM query selector. * @param {?string=} selectionMethod Allowed values are `null`, * `'closest'` and `'scope'`. * @return {!Promise<!AmpElement>} AMP element corresponding to the selector if found. */ getAmpElement(context, selector, selectionMethod) { return this.getElement(context, selector, selectionMethod).then( (element) => { this.verifyAmpElements_([element], selector); return element; } ); } /** * Searches for the element(s) that matches the selector * within the scope of the analytics root in relationship to * the specified context node. * * @param {!Element} context * @param {!Array<string>|string} selectors DOM query selector(s). * @param {?string=} selectionMethod Allowed values are `null`, * `'closest'` and `'scope'`. * @param {boolean} useDataVars Indicator if DataVars restristiction should be applied. * Default set to true. * @return {!Promise<!Array<!Element>>} Array of elements corresponding to the selector if found. */ getElements(context, selectors, selectionMethod, useDataVars = true) { if (isArray(selectors)) { userAssert( !selectionMethod, 'Cannot have selectionMethod %s defined with an array selector.', selectionMethod ); return this.getElementsByQuerySelectorAll_( /** @type {!Array<string>} */ (selectors), useDataVars ); } return this.getElement( context, /** @type {string} */ (selectors), selectionMethod ).then((element) => [element]); } /** * @param {!Array<Element>} elements * @param {string} selector */ verifyAmpElements_(elements, selector) { for (let i = 0; i < elements.length; i++) { userAssert( elements[i].classList.contains('i-amphtml-element'), 'Element "%s" is required to be an AMP element', selector ); } } /** * Creates listener-filter for DOM events to check against the specified * selector. If the node (or its ancestors) match the selector the listener * will be called. * * @param {function(!Element, !Event)} listener The first argument is the * matched target node and the second is the original event. * @param {!Element} context * @param {string} selector DOM query selector. * @param {?string=} selectionMethod Allowed values are `null`, * `'closest'` and `'scope'`. * @return {function(!Event)} */ createSelectiveListener(listener, context, selector, selectionMethod = null) { return (event) => { if (selector == ':host') { // `:host` is not reachable via selective listener b/c event path // cannot be retargeted across the boundary of the embed. return; } // Navigate up the DOM tree to find the actual target. const rootElement = this.getRootElement(); const isSelectAny = selector == '*'; const isSelectRoot = selector == ':root'; let {target} = event; while (target) { // Target must be contained by this root. if (!this.contains(target)) { break; } // `:scope` context must contain the target. if ( selectionMethod == 'scope' && !isSelectRoot && !context.contains(target) ) { break; } // `closest()` target must contain the conext. if (selectionMethod == 'closest' && !target.contains(context)) { // However, the search must continue! target = target.parentElement; continue; } // Check if the target matches the selector. if ( isSelectAny || (isSelectRoot && target == rootElement) || tryMatches_(target, selector) ) { listener(target, event); // Don't fire the event multiple times even if the more than one // ancestor matches the selector. break; } target = target.parentElement; } }; } /** * Returns the promise that will be resolved as soon as the elements within * the root have been loaded inside the first viewport of the root. * @return {!Promise} * @abstract */ whenIniLoaded() {} /** * Returns the visibility root corresponding to this analytics root (ampdoc * or embed). The visibility root is created lazily as needed and takes * care of all visibility tracking functions. * @return {!./visibility-manager.VisibilityManager} */ getVisibilityManager() { if (!this.visibilityManager_) { this.visibilityManager_ = provideVisibilityManager(this.getRoot()); } return this.visibilityManager_; } /** * Returns the Scroll Managet corresponding to this analytics root. * The Scroll Manager is created lazily as needed, and will handle * calling all handlers for a scroll event. * @return {!./scroll-manager.ScrollManager} */ getScrollManager() { // TODO (zhouyx@): Disallow scroll trigger with host API if (!this.scrollManager_) { this.scrollManager_ = new ScrollManager(this); } return this.scrollManager_; } } /** * The implementation of the analytics root for an ampdoc. */ export class AmpdocAnalyticsRoot extends AnalyticsRoot { /** * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc */ constructor(ampdoc) { super(ampdoc); } /** @override */ getType() { return 'ampdoc'; } /** @override */ getRoot() { return this.ampdoc.getRootNode(); } /** @override */ getHostElement() { // ampdoc is always the root of everything - no host. return null; } /** @override */ signals() { return this.ampdoc.signals(); } /** @override */ getElementById(id) { return this.ampdoc.getElementById(id); } /** @override */ whenIniLoaded() { const viewport = Services.viewportForDoc(this.ampdoc); let rect; if (getMode(this.ampdoc.win).runtime == 'inabox') { // TODO(dvoytenko, #7971): This is currently addresses incorrect position // calculations in a in-a-box viewport where all elements are offset // to the bottom of the embed. The current approach, even if fixed, still // creates a significant probability of risk condition. // Once address, we can simply switch to the 0/0 approach in the `else` // clause. rect = viewport.getLayoutRect(this.getRootElement()); } else { const size = viewport.getSize(); rect = layoutRectLtwh(0, 0, size.width, size.height); } return whenContentIniLoad(this.ampdoc, this.ampdoc.win, rect); } } /** * The implementation of the analytics root for FIE. * TODO(#22733): merge into AnalyticsRoot once ampdoc-fie is launched. */ export class EmbedAnalyticsRoot extends AnalyticsRoot { /** * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc * @param {!../../../src/friendly-iframe-embed.FriendlyIframeEmbed} embed */ constructor(ampdoc, embed) { super(ampdoc); /** @const */ this.embed = embed; } /** @override */ getType() { return 'embed'; } /** @override */ getRoot() { return this.embed.win.document; } /** @override */ getHostElement() { return this.embed.iframe; } /** @override */ signals() { return this.embed.signals(); } /** @override */ getElementById(id) { return this.embed.win.document.getElementById(id); } /** @override */ whenIniLoaded() { return this.embed.whenIniLoaded(); } } /** * @param {!Element} el * @param {string} selector * @return {boolean} */ function tryMatches_(el, selector) { try { return matches(el, selector); } catch (e) { user().error(TAG, 'Bad query selector.', selector, e); return false; } }