UNPKG

@ones-design/utils

Version:

ONES Design

575 lines (539 loc) 17.7 kB
import { isEqual as isEqual$1, cloneDeep } from '@senojs/lodash'; import { Children, Fragment, isValidElement, cloneElement as cloneElement$1, useCallback, useRef, useLayoutEffect as useLayoutEffect$1, useEffect, useState } from 'react'; /** * 比较 arr1 是否是 arr2 从 0 开始相同的子集 * @param arr1 较短的数组 * @param arr2 较长的数组 * @returns arr1 是否是 arr2 从 0 开始相同的子集 */ function isSubArrayFromStart(arr1, arr2) { if (!Array.isArray(arr1) || !Array.isArray(arr2) || arr1.length > arr2.length) { return false; } const sub = arr2.slice(0, arr1.length); return isEqual$1(arr1, sub); } /** * 移除指定位置的节点 * @param nodes 节点数组 * @param pos 节点位置 * @param childrenName 存放子节点的字段名 * @returns 移除的节点和更新后的树形数组 */ function removeTreeNode(nodes, pos, childrenName = 'children') { if (!Array.isArray(nodes) || nodes.length === 0) { return { node: null, nodes }; } if (!Array.isArray(pos)) { pos = [pos]; } nodes = [...nodes]; const index = Number(pos[0]); if (pos.length === 1) { const node = nodes.splice(index, 1)[0]; return { node, nodes }; } else { const parent = { ...nodes[index] }; const result = removeTreeNode(parent[childrenName], pos.slice(1), childrenName); parent[childrenName] = result.nodes; nodes[index] = parent; return { node: result.node, nodes }; } } /** * 在指定位置插入节点 * @param nodes 节点数组 * @param target 插入的目标位置 * @param node 要插入的节点 * @param pos 相对目标的位置,-1 为上面,0 为里面,1 为下面 * @param childrenName 存放子节点的字段名 * @returns 更新后的数组 */ function insertTreeNode(nodes, target, node, pos, childrenName = 'children') { if (!Array.isArray(nodes) || nodes.length === 0) { return nodes; } if (!Array.isArray(target)) { target = [target]; } nodes = [...nodes]; const index = Number(target[0]); const parent = { ...nodes[index] }; if (target.length === 1) { if (pos === 0) { var _ref; (_ref = parent[childrenName]) !== null && _ref !== void 0 ? _ref : parent[childrenName] = []; parent[childrenName].unshift(node); nodes[index] = parent; } else if (pos === -1) { // 插入到目标节点上面 nodes.splice(index, 0, node); } else if (pos === 1) { // 插入到目标节点下面 nodes.splice(index + 1, 0, node); } return nodes; } else { parent[childrenName] = insertTreeNode(parent[childrenName], target.slice(1), node, pos, childrenName); nodes[index] = parent; return nodes; } } /** * 获取指定位置的节点 * @param nodes 节点数组 * @param pos 节点位置 * @param childrenName 存放子节点的字段名 * @returns 父节点 */ function getTreeNode(nodes, pos, childrenName = 'children') { if (!Array.isArray(pos)) { pos = [pos]; } if (pos.length === 0) return null; let node = nodes[Number(pos[0])]; for (let i = 1; i < pos.length; i++) { node = node[childrenName][pos[i]]; } return node; } /** * 移动树节点的位置 * @param nodes 节点数组 * @param source 原位置 * @param target 目标位置 * @param pos 相对目标的位置,-1 为上面,0 为里面,1 为下面 * @param childrenName 存放子节点的字段名 * @returns 更新后的新数组 */ function moveTreeNode(nodes, source, target, pos, childrenName = 'children') { if (!Array.isArray(nodes) || nodes.length === 0) { return nodes; } if (!Array.isArray(source)) { source = [source]; } if (!Array.isArray(target)) { target = [target]; } nodes = cloneDeep(nodes); const sourceParentPos = source.slice(0, -1); const targetParentPos = target.slice(0, -1); const sourceParent = getTreeNode(nodes, sourceParentPos, childrenName); const targetParent = getTreeNode(nodes, targetParentPos, childrenName); const sourceParentChildren = sourceParent ? sourceParent[childrenName] : nodes; const targetParentChildren = targetParent ? targetParent[childrenName] : nodes; const sourceIndex = Number(source[source.length - 1]); const targetIndex = Number(target[target.length - 1]); const [sourceNode] = sourceParentChildren.splice(sourceIndex, 1); // 如果是在同一个父节点并且插入位置在原来的节点之后,调整插入位置 const sameParent = sourceParentPos.join('-') === targetParentPos.join('-'); let insertIndex = targetIndex; if (sameParent && insertIndex > sourceIndex) { insertIndex -= 1; } if (pos === 0) { var _ref2; (_ref2 = targetParentChildren[insertIndex][childrenName]) !== null && _ref2 !== void 0 ? _ref2 : targetParentChildren[insertIndex][childrenName] = []; targetParentChildren[insertIndex][childrenName].unshift(sourceNode); } else { // 插入到目标节点的上面或下面 insertIndex += pos === 1 ? 1 : 0; targetParentChildren.splice(insertIndex, 0, sourceNode); } return nodes; } /** * 移动节点的位置 * @param nodes 节点数组 * @param source 原位置 * @param target 目标位置 * @returns 更新后的新数组 */ function moveNode(nodes, source, target) { source = Number(source); target = Number(target); if (!Array.isArray(nodes) || nodes.length === 0 || source === target || source < 0 || source >= nodes.length || target < 0 || target >= nodes.length) { return nodes; } nodes = [...nodes]; const [node] = nodes.splice(source, 1); nodes.splice(target, 0, node); return nodes; } /** * 根据类名向上寻找 parent * @param target 当前 DOM 元素 * @param selectors 目标选择器 | 目标选择器数组 * @returns 如果能找到,则返回被找到的 parent,否则返回 false */ function hasParent(target, selectors) { selectors = Array.isArray(selectors) ? selectors : [selectors]; selectors = selectors.filter(Boolean).join(','); return selectors.length > 0 && (target === null || target === void 0 ? void 0 : target.closest(selectors)); } /** * @file 工具函数 - 控制台警告 * @author htmlin */ let logs = { error: {}, warn: {} }; let timer = null; /** * 合并 1s 内的重复日志 * @param log 日志信息 * @param type 日志类型 */ function debounceLogs(log, type = 'error') { const assertlogs = logs[type]; if (!assertlogs[log]) { assertlogs[log] = 1; } else { assertlogs[log]++; } if (timer) { clearTimeout(timer); } timer = setTimeout(() => { Object.entries(logs).forEach(([key, value]) => { for (const log in value) { console[key](`[ONES Design] ${log}` + (value[log] > 1 ? ` (×${value[log]})` : '')); } }); logs = { error: {}, warn: {} }; timer = null; }, 500); } /** * 错误警告 * @param {boolean} condition - 条件表达式 * @param {string} msg - 当 condition 结果为 false 时提示的警告信息 */ function warn(condition, msg) { if (condition) return; debounceLogs(msg); } /** * 警告提示 * @param {boolean} condition - 条件表达式 * @param {string} msg - 当 condition 结果为 false 时提示的警告信息 */ function tip(condition, msg) { if (condition) return; debounceLogs(msg, 'warn'); } /** * fork rc-util * * @param obj1 * @param obj2 * @param shallow * @returns boolean */ function isEqual(obj1, obj2, shallow = false) { // https://github.com/mapbox/mapbox-gl-js/pull/5979/files#diff-fde7145050c47cc3a306856efd5f9c3016e86e859de9afbd02c879be5067e58f const refSet = new Set(); function deepEqual(a, b, level = 1) { const circular = refSet.has(a); warn(!circular, 'Warning: There may be circular references'); if (circular) { return false; } if (a === b) { return true; } if (shallow && level > 1) { return false; } refSet.add(a); const newLevel = level + 1; if (Array.isArray(a)) { if (!Array.isArray(b) || a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (!deepEqual(a[i], b[i], newLevel)) { return false; } } return true; } if (a && b && typeof a === 'object' && typeof b === 'object') { const keys = Object.keys(a); if (keys.length !== Object.keys(b).length) { return false; } return keys.every(key => deepEqual(a[key], b[key], newLevel)); } // other return false; } return deepEqual(obj1, obj2); } /** * @file 工具函数 - React * @author htmlin */ function replaceElement(element, replacement, props) { if (! /*#__PURE__*/isValidElement(element)) return replacement; return /*#__PURE__*/cloneElement$1(element, typeof props === 'function' ? props(element.props || {}) : props); } /** * 是否为有效的子元素 * @param children React 的子元素 * @return 是否为有效的子元素 */ function isValidChildren(children) { // 忽略 undefined null 空白字符串,React.Children.toArray 会把 undefined null [undefined, null] 过滤掉 const isValid = Children.toArray(children).filter(child => { return typeof child != 'string' || child.trim() !== ''; }).length > 0; return isValid; } /** * 可以接收 ReactNode 的拷贝方法 (React cloneElement 的增强版) * 使用场景,想使用 React.cloneElement 但是又不想传入 ReactElement 时可以使用 * @param element React 元素 * @param props 属性 * @returns 拷贝后的 ReactElement */ function cloneElement(element, props) { return replaceElement(element, element, props); } /** * 判断一个元素是否为 Fragment 类型 * @param element 需判断的元素 */ function isFragment(element) { if (typeof element === 'object' && element.type && element.type === Fragment) return true; return false; } /** * 将传入的 ReactNode 节点转换为数组 * @param children 被转换的 ReactNode * @param option 自定义配置 */ function toArray(children, option = {}) { let ret = []; Children.forEach(children, child => { if ((child === undefined || child === null) && !option.keepEmpty) { return; } if (Array.isArray(child)) { ret = ret.concat(toArray(child)); } else if (isFragment(child) && child.props) { ret = ret.concat(toArray(child.props.children, option)); } else { ret.push(child); } }); return ret; } /** * Replace with template. * `I'm ${name}` + { name: 'bamboo' } = I'm bamboo * `I has apple, orange${, :fruit}` + { fruit: 'banana' } = I has apple, orange, banana * `I has apple, orange${, :fruit}` + { fruit: '' } = I has apple, orange */ function replaceMessage(template = '', kv, split = ':') { return template.replace(/\$\{.+\}/g, str => { const keys = str.slice(2, -1).split(split); const values = keys.map(key => { var _kv$key; return (_kv$key = kv[key]) !== null && _kv$key !== void 0 ? _kv$key : key; }); return values.some(value => value === '') ? '' : values.join(''); }); } function useCombineRefs(refs) { return useCallback(node => { refs.forEach(ref => { if (typeof ref === 'function') { ref(node); } else if (ref !== null && ref !== void 0 && ref.current) { ref.current = node; } }); }, // eslint-disable-next-line react-hooks/exhaustive-deps [...refs]); } function useEvent(callback) { const fnRef = useRef(); fnRef.current = callback; const memoFn = useCallback((...args) => { var _fnRef$current; return (_fnRef$current = fnRef.current) === null || _fnRef$current === void 0 ? void 0 : _fnRef$current.call(fnRef, ...args); }, []); return memoFn; } function canUseDom() { return !!(typeof window !== 'undefined' && window.document && window.document.createElement); } /** * Wrap `useLayoutEffect` which will not throw warning message in test env * 判断环境,只有浏览器环境下可以使用 useLayoutEffect * useLayoutEffect: value change -> layoutEffect -> re-render */ const useInternalLayoutEffect = process.env.NODE_ENV !== 'test' && canUseDom() ? useLayoutEffect$1 : useEffect; const useLayoutUpdateEffect = (callback, deps) => { const firstMountRef = useRef(true); useInternalLayoutEffect(() => { if (!firstMountRef.current) { return callback(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); // We tell react that first mount has passed useInternalLayoutEffect(() => { firstMountRef.current = false; return () => { firstMountRef.current = true; }; }, []); }; const useLayoutEffect = useInternalLayoutEffect; const defaultShouldUpdate = (prev, next) => { if (Array.isArray(prev) && Array.isArray(next)) { if (prev.length !== next.length) { process.env.NODE_ENV !== 'production' && warn(false, 'useMemo: condition length changed.'); return true; } return prev.some((item, index) => item !== next[index]); } else { process.env.NODE_ENV !== 'production' && warn(false, `useMemo: condition must be an array but got ${prev} and ${next} `); return true; } }; /** * 手动控制是否触发 value 更新 * * @param getValue * @param condition * @param shouldUpdate * @returns */ function useMemo(getValue, condition, shouldUpdate = defaultShouldUpdate) { const cacheRef = useRef({}); if (!('value' in cacheRef.current) || shouldUpdate(cacheRef.current.condition, condition)) { cacheRef.current.value = getValue(cacheRef.current.value); cacheRef.current.condition = condition; } return cacheRef.current.value; } var Source = /*#__PURE__*/function (Source) { Source[Source["INNER"] = 0] = "INNER"; Source[Source["PROP"] = 1] = "PROP"; return Source; }(Source || {}); // props 传入的值 /** We only think `undefined` is empty */ function hasValue(value) { return value !== undefined; } /** * Similar to `useState` but will use props value if provided. * Note that internal use rc-util `useState` hook. * 和 useState 类似,如果该参数由 props 提供,优先使用 props 内的参数 * 保证当 value 在非空值和 undefined 之间切换时可以自动切换受控和非受控模式 */ function useMergedState(defaultStateValue, option) { const { defaultValue, value, onChange, postState } = option || {}; // ======================= Init ======================= const [mergedValue, setMergedValue] = useState(() => { let finalValue; let source; if (hasValue(value)) { finalValue = value; source = Source.PROP; } else if (hasValue(defaultValue)) { finalValue = typeof defaultValue === 'function' ? defaultValue() : defaultValue; source = Source.PROP; } else { // value 是 undefined,表示未传入 props,可以直接使用内部值 finalValue = typeof defaultStateValue === 'function' ? defaultStateValue() : defaultStateValue; source = Source.INNER; } return [finalValue, source, finalValue]; }); const chosenValue = hasValue(value) ? value : mergedValue[0]; const postMergedValue = postState ? postState(chosenValue) : chosenValue; // ======================= Sync ======================= useLayoutUpdateEffect(() => { setMergedValue(([prevValue]) => [value, Source.PROP, prevValue]); }, [value]); // ====================== Update ====================== const changeEventPrevRef = useRef(); const triggerChange = useEvent(updater => { setMergedValue(prev => { const [prevValue, prevSource, prevPrevValue] = prev; const nextValue = typeof updater === 'function' ? updater(prevValue) : updater; // Do nothing if value not change if (nextValue === prevValue) { return prev; } const overridePrevValue = prevSource === Source.INNER && changeEventPrevRef.current !== prevPrevValue ? prevPrevValue : prevValue; return [nextValue, Source.INNER, overridePrevValue]; }); }); // ====================== Change ====================== const onChangeFn = useEvent(onChange); useLayoutEffect(() => { const [current, source, prev] = mergedValue; if (current !== prev && source === Source.INNER) { onChangeFn(current, prev); changeEventPrevRef.current = prev; } }, [mergedValue, onChangeFn]); return [postMergedValue, triggerChange]; } function useIntersectionObserver(elementRef, { threshold = 0, root = null, rootRef, rootMargin = '0%', freezeOnceVisible = false }, active) { const [entry, setEntry] = useState(); const frozen = (entry === null || entry === void 0 ? void 0 : entry.isIntersecting) && freezeOnceVisible; const updateEntry = ([entry]) => { setEntry(entry); }; useEffect(() => { const node = elementRef === null || elementRef === void 0 ? void 0 : elementRef.current; // DOM Ref const hasIOSupport = !!window.IntersectionObserver; if (!active || !hasIOSupport || frozen || !node) return; const observerParams = { threshold, root: root !== null && root !== void 0 ? root : rootRef.current, rootMargin }; const observer = new IntersectionObserver(updateEntry, observerParams); observer.observe(node); return () => observer.disconnect(); }, [elementRef, threshold, root, rootMargin, frozen, active, rootRef]); return entry; } export { cloneElement, getTreeNode, hasParent, insertTreeNode, isEqual, isFragment, isSubArrayFromStart, isValidChildren, moveNode, moveTreeNode, removeTreeNode, replaceMessage, tip, toArray, useCombineRefs, useEvent, useIntersectionObserver, useLayoutEffect, useMemo, useMergedState, warn };