/* * Tencent is pleased to support the open source community by making * Hippy available. * * Copyright (C) 2017-2019 THL A29 Limited, a Tencent company. * All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-disable no-underscore-dangle */ /* eslint-disable no-param-reassign */ /** * Virtual DOM to Native DOM */ import { GLOBAL_STYLE_NAME, GLOBAL_DISPOSE_STYLE_NAME, ROOT_VIEW_ID, } from '../runtime/constants'; import { getApp, trace, warn, isDev, isFunction, convertImageLocalPath, deepCopy, isTraceEnabled, getBeforeRenderToNative, isStyleNotEmpty, } from '../util'; import { EventHandlerType, NativeEventMap, translateToNativeEventName, isNativeGesture, } from '../util/event'; import { isRTL, } from '../util/i18n'; import { setCacheNodeStyle, getCacheNodeStyle, } from '../util/node-style'; import { isHippyTextNode, isStyleMatched, preCacheNode } from '../util/node'; import { fromAstNodes, SelectorsMap } from '../style'; import { CallbackType, NeedToTyped } from '../types/native'; import ElementNode from '../renderer/element-node'; import ViewNode from '../renderer/view-node'; const LOG_TYPE = ['%c[native]%c', 'color: red', 'color: auto']; interface BatchType { [key: string]: any; } /** * UI Operations */ const NODE_OPERATION_TYPES: BatchType = { createNode: Symbol('createNode'), updateNode: Symbol('updateNode'), deleteNode: Symbol('deleteNode'), moveNode: Symbol('moveNode'), updateEvent: Symbol('updateEvent'), }; let batchIdle = true; let batchNodes: NeedToTyped = []; /** * Convert an ordered node array into multiple fragments */ function chunkNodes(batchNodes: NeedToTyped) { const result: NeedToTyped = []; for (let i = 0; i < batchNodes.length; i += 1) { const chunk = batchNodes[i]; const { type, nodes, eventNodes, printedNodes } = chunk; const lastChunk = result[result.length - 1]; if (!lastChunk || lastChunk.type !== type) { result.push({ type, nodes, eventNodes, printedNodes, }); } else { lastChunk.nodes = lastChunk.nodes.concat(nodes); lastChunk.eventNodes = lastChunk.eventNodes.concat(eventNodes); lastChunk.printedNodes = lastChunk.printedNodes.concat(printedNodes); } } return result; } function handleEventListeners(eventNodes: HippyTypes.EventNode[] = [], sceneBuilder: NeedToTyped) { eventNodes.forEach((eventNode) => { if (eventNode) { const { id, eventList } = eventNode; (eventList as any).forEach((eventAttribute: NeedToTyped) => { const { name, type, listener } = eventAttribute; let nativeEventName; if (isNativeGesture(name)) { nativeEventName = NativeEventMap[name]; } else { nativeEventName = translateToNativeEventName(name); } if (type === EventHandlerType.REMOVE) { sceneBuilder.removeEventListener(id, nativeEventName, listener); } if (type === EventHandlerType.ADD) { sceneBuilder.removeEventListener(id, nativeEventName, listener); sceneBuilder.addEventListener(id, nativeEventName, listener); } }); } }); } /** * Initial CSS Map; */ let cssMap: NeedToTyped; /** * print nodes operation log * @param {HippyTypes.PrintedNode[]} printedNodes * @param {string} nodeType */ function printNodeOperation(printedNodes: HippyTypes.PrintedNode[], nodeType: NeedToTyped) { if (isTraceEnabled()) { trace(...LOG_TYPE, nodeType, printedNodes); } } function endBatch(app: NeedToTyped) { if (!batchIdle) return; batchIdle = false; if (batchNodes.length === 0) { batchIdle = true; return; } const { $nextTick, $options: { rootViewId, }, } = app; $nextTick(() => { const chunks = chunkNodes(batchNodes); const sceneBuilder = new global.Hippy.SceneBuilder(rootViewId); chunks.forEach((chunk) => { switch (chunk.type) { case NODE_OPERATION_TYPES.createNode: printNodeOperation(chunk.printedNodes, 'createNode'); sceneBuilder.create(chunk.nodes); handleEventListeners(chunk.eventNodes, sceneBuilder); break; case NODE_OPERATION_TYPES.updateNode: printNodeOperation(chunk.printedNodes, 'updateNode'); sceneBuilder.update(chunk.nodes); handleEventListeners(chunk.eventNodes, sceneBuilder); break; case NODE_OPERATION_TYPES.deleteNode: printNodeOperation(chunk.printedNodes, 'deleteNode'); sceneBuilder.delete(chunk.nodes); break; case NODE_OPERATION_TYPES.moveNode: printNodeOperation(chunk.printedNodes, 'moveNode'); sceneBuilder.move(chunk.nodes); break; case NODE_OPERATION_TYPES.updateEvent: handleEventListeners(chunk.eventNodes, sceneBuilder); break; default: } }); sceneBuilder.build(); batchIdle = true; batchNodes = []; }); } function getCssMap() { /** * To support dynamic import, cssMap can be loaded from different js file. * cssMap should be 'create/append' if global[GLOBAL_STYLE_NAME] exists; */ if (!cssMap || global[GLOBAL_STYLE_NAME]) { /** * Here is a secret startup option: beforeStyleLoadHook. * Usage for process the styles while styles loading. */ const cssRules = fromAstNodes(global[GLOBAL_STYLE_NAME]); if (cssMap) { cssMap.append(cssRules); } else { cssMap = new SelectorsMap(cssRules); } global[GLOBAL_STYLE_NAME] = undefined; } if (global[GLOBAL_DISPOSE_STYLE_NAME]) { global[GLOBAL_DISPOSE_STYLE_NAME].forEach((id: NeedToTyped) => { cssMap.delete(id); }); global[GLOBAL_DISPOSE_STYLE_NAME] = undefined; } return cssMap; } /** * Translate to native props from attributes and meta */ function getNativeProps(node: ElementNode) { // Initial the props with empty const props: NeedToTyped = {}; // Get the default native props from meta if (node.meta.component.defaultNativeProps) { Object.keys(node.meta.component.defaultNativeProps).forEach((key) => { // Skip the defined attribute if (node.getAttribute(key) !== undefined) { return; } // Get the default props const defaultNativeProps = node.meta.component.defaultNativeProps[key]; if (isFunction(defaultNativeProps)) { props[key] = defaultNativeProps(node); } else { props[key] = defaultNativeProps; } }); } // Get the processed props from node attributes Object.keys(node.attributes).forEach((key) => { let value = node.getAttribute(key); // No defined map if (!node.meta.component.attributeMaps || !node.meta.component.attributeMaps[key]) { props[key] = value; return; } // Defined mapped props. const map = node.meta.component.attributeMaps[key]; if (typeof map === 'string') { props[map] = value; return; } // Define mapped props is a function. if (isFunction(map)) { props[key] = map(value); return; } // Defined object map with value const { name: propsKey, propsValue, jointKey } = map; if (isFunction(propsValue)) { value = propsValue(value); } // if jointKey set, multi attributes will be assigned to the same jointKey object. if (jointKey) { props[jointKey] = props[jointKey] || {}; Object.assign(props[jointKey], { [propsKey]: value, }); } else { props[propsKey] = value; } }); // Get the force props from meta, it can't be overridden if (node.meta.component.nativeProps) { Object.assign(props, node.meta.component.nativeProps); } return props; } /** * parse TextInput component on special condition * @param targetNode * @param style */ function parseTextInputComponent(targetNode: NeedToTyped, style: NeedToTyped) { if (targetNode.meta.component.name === 'TextInput') { // Change textAlign to right if display direction is right to left. if (isRTL()) { if (!style.textAlign) { style.textAlign = 'right'; } } } } /** * parse view component on special condition * @param targetNode * @param nativeNode * @param style */ function parseViewComponent(targetNode: NeedToTyped, nativeNode: NeedToTyped, style: NeedToTyped) { if (targetNode.meta.component.name === 'View') { // Change View to ScrollView when meet overflow=scroll style. if (style.overflowX === 'scroll' && style.overflowY === 'scroll') { warn('overflow-x and overflow-y for View can not work together'); } if (style.overflowY === 'scroll') { nativeNode.name = 'ScrollView'; } else if (style.overflowX === 'scroll') { nativeNode.name = 'ScrollView'; // Necessary for horizontal scrolling nativeNode.props.horizontal = true; // Change flexDirection to row-reverse if display direction is right to left. style.flexDirection = isRTL() ? 'row-reverse' : 'row'; } // Change the ScrollView child collapsable attribute if (nativeNode.name === 'ScrollView') { if (targetNode.childNodes.length !== 1) { warn('Only one child node is acceptable for View with overflow'); } if (targetNode.childNodes.length) { targetNode.childNodes[0].setStyle('collapsable', false); } } if (style.backgroundImage) { style.backgroundImage = convertImageLocalPath(style.backgroundImage); } } } /** * getElemCss * @param {ElementNode} element * @returns {{}} */ function getElemCss(element: NeedToTyped) { const style = Object.create(null); try { getCssMap().query(element).selectors.forEach((matchedSelector: NeedToTyped) => { if (!isStyleMatched(matchedSelector, element)) return; matchedSelector.ruleSet.declarations.forEach((cssStyle: NeedToTyped) => { style[cssStyle.property] = cssStyle.value; }); }); } catch (err) { console.error('getDomCss Error:', err); } return style; } /** * Get target node attributes, use to chrome devTool tag attribute show while debugging * @param targetNode * @returns attributes|{} */ function getTargetNodeAttributes(targetNode: NeedToTyped) { try { const targetNodeAttributes = deepCopy(targetNode.attributes); const classInfo = Array.from(targetNode.classList || []).join(' '); const { id, nodeId } = targetNode; const attributes = { id, hippyNodeId: `${nodeId}`, class: classInfo, ...targetNodeAttributes, }; delete attributes.text; delete attributes.value; Object.keys(attributes).forEach((key) => { if (key !== 'id' && key !== 'hippyNodeId' && key !== 'class') { // value may big int that iOS do not support, should delete delete attributes[key]; } }); return attributes; } catch (e) { warn('getTargetNodeAttributes error:', e); return {}; } } function processModalView(nativeNode: NeedToTyped) { if (nativeNode.props.__modalFirstChild__) { const nodeStyle = nativeNode.props.style; Object.keys(nodeStyle).some((styleKey) => { if (styleKey === 'position' && nodeStyle[styleKey] === 'absolute') { if (isDev()) { console.error(`it cannot set { position: absolute } for the first child node of , please remove it. If you want to make dialog cover fullscreen, please use { flex: 1 } or set height and width to the first child node of `); } ['position', 'left', 'right', 'top', 'bottom'].forEach(positionProp => delete nodeStyle[positionProp]); return true; } return false; }); } } /** * getEventNode - translate event info event node. * @param targetNode */ function getEventNode(targetNode: NeedToTyped) { let eventNode: NeedToTyped = undefined; const eventsAttributes = targetNode.events; if (eventsAttributes) { const eventList: NeedToTyped = []; Object.keys(eventsAttributes) .forEach((key) => { const { name, type, isCapture, listener } = eventsAttributes[key]; eventList.push({ name, type, isCapture, listener, }); }); eventNode = { id: targetNode.nodeId, eventList, }; } return eventNode; } /** * Render Element to native */ function renderToNative(rootViewId, targetNode, refInfo = {}, notUpdateStyle = false) { if (targetNode.meta.skipAddToDom) { return []; } if (!targetNode.meta.component) { throw new Error(`Specific tag is not supported yet: ${targetNode.tagName}`); } let style; let resultStyle; if (notUpdateStyle) { // No need to update CSS, use cache directly style = getCacheNodeStyle(targetNode.nodeId); resultStyle = style; } else { // Recalculate CSS styles style style = getElemCss(targetNode); // Merge styles style style = { ...style, ...targetNode.style }; // CSS preprocessing getBeforeRenderToNative()(); // style before merge inherit style const originStyle = Object.assign({}, style); if (targetNode.parentNode) { const parentNodeStyle = getCacheNodeStyle(targetNode.parentNode.nodeId); const styleAttributes = ['color', 'fontSize', 'fontWeight', 'fontFamily', 'fontStyle', 'textAlign', 'lineHeight']; styleAttributes.forEach((attribute) => { if (!isStyleNotEmpty(style[attribute]) && isStyleNotEmpty(parentNodeStyle[attribute])) { style[attribute] = parentNodeStyle[attribute]; } }); } // Cache css result setCacheNodeStyle(targetNode.nodeId, style); // style after merge inherit style resultStyle = isHippyTextNode(targetNode) ? style : originStyle; // use defaultNativeStyle later to avoid incorrect compute style from inherit node // in beforeRenderToNative hook if (targetNode.meta.component.defaultNativeStyle) { resultStyle = { ...targetNode.meta.component.defaultNativeStyle, ...resultStyle }; } } // Translate to native node const nativeNode: NeedToTyped = { id: targetNode.nodeId, pId: (targetNode.parentNode?.nodeId) || rootViewId, name: targetNode.meta.component.name, props: { ...getNativeProps(targetNode), style: resultStyle, }, tagName: targetNode.tagName, }; processModalView(nativeNode); parseViewComponent(targetNode, nativeNode, resultStyle); parseTextInputComponent(targetNode, resultStyle); // Convert to real native event const eventNode: NeedToTyped = getEventNode(targetNode); let printedNode: NeedToTyped = undefined; // Add nativeNode attributes info for Element debugging if (isDev()) { // generate printedNode for debugging const listenerProp = {}; if (eventNode && Array.isArray(eventNode.eventList)) { eventNode.eventList.forEach((eventListItem) => { const { name, listener, type } = eventListItem; type === EventHandlerType.ADD && Object.assign(listenerProp, { [name]: listener }); }); } // Add nativeNode attributes info for debugging (nativeNode.props as any).attributes = getTargetNodeAttributes(targetNode); Object.assign(printedNode = {}, nativeNode, refInfo); (printedNode as any).listeners = listenerProp; } // convert to translatedNode const translatedNode = [nativeNode, refInfo]; return [translatedNode, eventNode, printedNode]; } /** * Render Element with children to native * @param {number} rootViewId - root view id * @param {ViewNode} node - target node to be traversed * @param {Function} [callback] - function called on each traversing process * @param {Object} refInfo - referenceNode information * @returns {[]} */ function renderToNativeWithChildren(rootViewId: NeedToTyped, node: NeedToTyped, callback?: CallbackType, refInfo = {}) { const nativeLanguages: NeedToTyped = []; const eventLanguages: NeedToTyped = []; const printedLanguages: NeedToTyped = []; node.traverseChildren((targetNode: NeedToTyped, refInfo: NeedToTyped) => { const [nativeNode, eventNode, printedNode] = renderToNative(rootViewId, targetNode, refInfo); if (nativeNode) { nativeLanguages.push(nativeNode); } if (eventNode) { eventLanguages.push(eventNode); } if (printedNode) { printedLanguages.push(printedNode); } if (typeof callback === 'function') { callback(targetNode); } }, refInfo); return [nativeLanguages, eventLanguages, printedLanguages]; } function isLayout(node: NeedToTyped, rootView: NeedToTyped) { if (node.nodeId === ROOT_VIEW_ID) { return true; } // Check the id is specific for rootView. if (isDev()) { if (!rootView) { warn('rootView option is necessary for new HippyVue()'); } if (rootView.charAt(0) !== '#') { warn('rootView option must be unique ID selector start with # '); } } return node.id === rootView.slice(1 - rootView.length); } function insertChild(parentNode: ViewNode, childNode: ViewNode, refInfo = {}) { if (!parentNode || !childNode) { return; } if (childNode.meta.skipAddToDom) { return; } const app = getApp(); if (!app) { return; } const { $options: { rootViewId, rootView } } = app; const renderRootNodeCondition = isLayout(parentNode, rootView) && !parentNode.isMounted; const renderOtherNodeCondition = parentNode.isMounted && !childNode.isMounted; // Render the root node or other nodes if (renderRootNodeCondition || renderOtherNodeCondition) { // Start real native work. const [nativeLanguages, eventLanguages, printedLanguages] = renderToNativeWithChildren( rootViewId, renderRootNodeCondition ? parentNode : childNode, (node: ViewNode) => { if (!node.isMounted) { node.isMounted = true; } preCacheNode(node, node.nodeId); }, refInfo, ); batchNodes.push({ type: NODE_OPERATION_TYPES.createNode, nodes: nativeLanguages, eventNodes: eventLanguages, printedNodes: printedLanguages, }); endBatch(app); } } function removeChild(parentNode: NeedToTyped, childNode: NeedToTyped) { if (!childNode || childNode.meta.skipAddToDom) { return; } childNode.isMounted = false; const app = getApp(); const { $options: { rootViewId } } = app; const nativeNode = { id: childNode.nodeId, pId: childNode.parentNode ? childNode.parentNode.nodeId : rootViewId, }; const deleteNodeIds = [ [ nativeNode, {}, ], ]; const printedNodes = isDev() ? [nativeNode] : []; batchNodes.push({ printedNodes, type: NODE_OPERATION_TYPES.deleteNode, nodes: deleteNodeIds, eventNodes: [], }); endBatch(app); } function moveChild(parentNode: NeedToTyped, childNode: NeedToTyped, refInfo = {}) { if (parentNode?.meta && isFunction(parentNode.meta.removeChild)) { parentNode.meta.removeChild(parentNode, childNode); } if (!childNode || childNode.meta.skipAddToDom) { return; } if (refInfo && (refInfo as any).refId === childNode.nodeId) { // ref节点与childNode节点相同, 属于无效操作, 这里先过滤 return; } const app = getApp(); const { $options: { rootViewId } } = app; const nativeNode = { id: childNode.nodeId, pId: childNode.parentNode ? childNode.parentNode.nodeId : rootViewId, }; const moveNodeIds = [ [ nativeNode, refInfo, ], ]; const printedNodes = isDev() ? [{ ...nativeNode, ...refInfo }] : []; batchNodes.push({ printedNodes, type: NODE_OPERATION_TYPES.moveNode, nodes: moveNodeIds, eventNodes: [], }); endBatch(app); } function updateEvent(parentNode: NeedToTyped) { if (!parentNode.isMounted) { return; } const app = getApp(); const eventNode = getEventNode(parentNode); batchNodes.push({ type: NODE_OPERATION_TYPES.updateEvent, nodes: [], eventNodes: [eventNode], printedNodes: [], }); endBatch(app); } function updateChild(parentNode: NeedToTyped, notUpdateStyle = false) { if (!parentNode.isMounted) { return; } const app = getApp(); const { $options: { rootViewId } } = app; const [nativeNode, eventNode, printedNode] = renderToNative(rootViewId, parentNode, {}, notUpdateStyle); if (nativeNode) { batchNodes.push({ type: NODE_OPERATION_TYPES.updateNode, nodes: nativeNode ? [nativeNode] : [], eventNodes: eventNode ? [eventNode] : [], printedNodes: isDev() && printedNode ? [printedNode] : [], }); endBatch(app); } } function updateWithChildren(parentNode: NeedToTyped) { if (!parentNode.isMounted) { return; } const app = getApp(); const { $options: { rootViewId } } = app; const [nativeLanguages, eventLanguages, printedLanguages] = renderToNativeWithChildren(rootViewId, parentNode); batchNodes.push({ type: NODE_OPERATION_TYPES.updateNode, nodes: nativeLanguages, eventNodes: eventLanguages, printedNodes: printedLanguages, }); endBatch(app); } export { getCssMap, renderToNative, renderToNativeWithChildren, insertChild, removeChild, updateChild, updateEvent, moveChild, updateWithChildren, getElemCss, };