@ones-design/utils
Version:
ONES Design
575 lines (539 loc) • 17.7 kB
JavaScript
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 };