feat: 优化 lay.extend() 方法 (#2879)

* refactor: 优化 `lay.extend()` 方法

* fix: 优化 lay.extend 合并对象的类型判断

* fix: 优化判断纯对象的方案

* test: 添加可视化简易单元测试模块
This commit is contained in:
贤心
2025-10-23 23:28:32 +08:00
committed by GitHub
parent ef1e14f5aa
commit 6f718190a4
4 changed files with 730 additions and 209 deletions

View File

@@ -3,129 +3,285 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>基础方法测试用例 - layui</title>
<title>基础方法测试 - layui</title>
<link href="../src/css/layui.css" rel="stylesheet">
<link href="./test/test.css" rel="stylesheet">
</head>
<body>
<div class="layui-container" style="padding: 30px 0;">
<div class="" style="padding: 30px 0;">
<blockquote class="layui-elem-quote" style="color: #666;">
点击按钮开始测试,测试结果打开浏览器控制台查看
</blockquote>
<div class="layui-btn-container">
<button class="layui-btn" lay-on="sort">layui.sort</button>
<button class="layui-btn" lay-on="type">layui.type</button>
<button class="layui-btn" lay-on="isArray">layui.isArray</button>
<button class="layui-btn" lay-on="extend">lay.extend</button>
<button class="layui-btn" lay-on="treeToFlat">lay.treeToFlat</button>
<button class="layui-btn" lay-on="flatToTree">lay.flatToTree</button>
<div class="layui-panel test-side">
<ul class="layui-menu">
<li>
<div class="layui-menu-body-title">
<a href="#layui.sort">layui.sort</a>
</div>
</li>
<li>
<div class="layui-menu-body-title">
<a href="#layui.type">layui.type</a>
</div>
</div>
<script src="../src/layui.js"></script>
<script>
layui.use(['lay', 'util'], function() {
</li>
<li>
<div class="layui-menu-body-title">
<a href="#layui.isArray">layui.isArray</a>
</div>
</li>
<li>
<div class="layui-menu-body-title">
<a href="#lay.extend">lay.extend</a>
</div>
</li>
<li>
<div class="layui-menu-body-title">
<a href="#lay.treeToFlat">lay.treeToFlat</a>
</div>
</li>
<li>
<div class="layui-menu-body-title">
<a href="#lay.flatToTree">lay.flatToTree</a>
</div>
</li>
</ul>
</div>
<div class="test-main">
<div id="tests"></div>
</div>
<script src="../src/layui.js"></script>
<script>
layui.use(async function() {
var lay = layui.lay;
var util = layui.util;
var style = 'color: orange; font-size: 16px;';
// 导入测试模块
const { test } = await import('./test/test.js');
const tester = test();
// 事件
util.on({
sort: function(){
//sort
console.log('%c> layui.sort: ', style);
console.log(
'数字-整数型',
layui.sort([{a: 3},{a: 0},{a: 0},{a: -1},{a: -5},{a: 6},{a: 9},{a: -333333}], 'a')
);
console.log(
'数字-浮点型',
layui.sort([{a: 3.5},{a: 0.5},{a: 0.5},{a: -1.5},{a: -5.5},{a: 6.5},{a: 9.5},{a: -333333.5}], 'a')
);
console.log(
'数字-混合型',
layui.sort([{a: 1},{a: 20.5},{a: 20.3},{a: 3},{a: 52},{a: 4.3}], 'a')
);
console.log(
'中文',
layui.sort([{a: '男'},{a: '女'},{a: '男'},{a: '女'},{a: '男'}], 'a')
);
console.log(
'英文',
layui.sort([{a: 'E'},{a: 'B'},{a: 'D'},{a: 'C'},{a: 'A'}], 'a')
);
console.log(
'混合',
layui.sort([
{a: 3}
,{a: ''}
,{a: 0}
,{a: 66}
,{a: 99}
,{a: 'C'}
,{a: '女'}
,{a: 3.5}
,{a: 0}
,{a: -1}
,{a: 'B'}
,{a: 5.5}
,{a: '男'}
,{a: 'A'}
,{a: -5}
,{a: '男'}
,{a: 6}
,{a: 9}
], 'a')
);
console.log(
'数组成员全为数字',
layui.sort([1, 20.5, 19.5, 52, 4.5])
);
console.log(
'数组成员为混合型',
layui.sort([1, {a: 32}, 20.5, {a: 6}, 52, 5.5], 'a') //按成员对象的 key 为 a 进行比较
);
tester.describe('layui.sort', function(it) {
it({
title: '整数型数字排序',
code: `
const arr = layui.sort([{a: 3},{a: 0},{a: 0},{a: -1},{a: -5},{a: 6},{a: 9},{a: -333333}], 'a');
console.log(arr);
`,
expected: [{"a":-333333},{"a":-5},{"a":-1},{"a":0},{"a":0},{"a":3},{"a":6},{"a":9}]
});
it({
title: '浮点型数字排序',
code: `
const arr = layui.sort([{a: 3.5},{a: 0.5},{a: 0.5},{a: -1.5},{a: -5.5},{a: 6.5},{a: 9.5},{a: -333333.5}], 'a');
console.log(arr);
`,
expected: [{"a":-333333.5},{"a":-5.5},{"a":-1.5},{"a":0.5},{"a":0.5},{"a":3.5},{"a":6.5},{"a":9.5}]
});
it({
title: '混合型数字排序',
code: `
const arr = layui.sort([{a: 1},{a: 20.5},{a: 20.3},{a: 3},{a: 52},{a: 4.3}], 'a');
console.log(arr);
`,
expected: [{"a":1},{"a":3},{"a":4.3},{"a":20.3},{"a":20.5},{"a":52}]
});
it({
title: '中文排序',
code: `
const arr = layui.sort([{a: '男'},{a: '女'},{a: '男'},{a: '女'},{a: '男'}], 'a');
console.log(arr);
`,
expected: [{"a": "女"},{"a": "女"},{"a": "男"},{"a": "男"},{"a": "男"}]
});
it({
title: '英文排序',
code: `
const arr = layui.sort([{a: 'E'},{a: 'B'},{a: 'D'},{a: 'C'},{a: 'A'}], 'a');
console.log(arr);
`,
expected: [{"a": "A"},{"a": "B"},{"a": "C"},{"a": "D"},{"a": "E"}]
});
it({
title: '混合排序',
code: `
const arr = layui.sort([
{a: 3},
{a: '男'},
{a: 0},
{a: 66},
{a: 99},
{a: 'C'},
{a: '女'},
{a: 3.5},
{a: 0},
{a: -1},
{a: 'B'},
{a: 5.5},
{a: '男'},
{a: 'A'},
{a: -5},
{a: '男'},
{a: 6},
{a: 9}
], 'a');
console.log(arr);
`,
expected: [{"a":-5},{"a":-1},{"a":0},{"a":0},{"a":3},{"a":3.5},{"a":5.5},{"a":6},{"a":9},{"a":66},{"a":99},{"a":"A"},{"a":"B"},{"a":"C"},{"a":"女"},{"a":"男"},{"a":"男"},{"a":"男"}]
});
it({
title: '数组成员为纯数字',
code: `
const arr = layui.sort([1, 20.5, 19.5, 52, 4.5]);
console.log(arr);
`,
expected: [1, 4.5, 19.5, 20.5, 52]
});
it({
title: '数组成员为混合型',
code: `
// 按成员对象的 key 为 a 进行比较
const arr = layui.sort([1, {a: 32}, 20.5, {a: 6}, 52, 5.5], 'a');
console.log(arr);
`,
expected: [1,5.5,{"a":6},20.5,{"a":32},52]
});
});
tester.describe('layui.type', function(it) {
it({
title: 'new RegExp()',
code: `console.log(layui.type(new RegExp()))`,
expected: 'regexp'
});
it({
title: 'new Date()',
code: `console.log(layui.type(new Date()))`,
expected: 'date'
});
});
tester.describe('layui.isArray', function(it) {
it({
title: '[]',
code: `console.log(layui.isArray([]))`,
expected: true
});
it({
title: 'lay("div")',
code: `console.log(layui.isArray(lay("div")))`,
expected: true
});
it({
title: 'document.querySelectorAll("div")',
code: `console.log(layui.isArray(document.querySelectorAll("div")))`,
expected: true
});
it({
title: '{}',
code: `console.log(layui.isArray({}))`,
expected: false
});
});
tester.describe('lay.extend', function(it) {
const objNCode = `
const objN = [
{
a: [1, 3],
b: {ba: 1}
},
type: function(){
console.log('%c> layui.type: ', style);
console.log(
'new RegExp()', layui.type(new RegExp()),
'\nnew Date()', layui.type(new Date()),
'\n[]', layui.type([])
);
{
a: [5],
b: {bb: 2}
},
{
b: {ba: 3},
c: 3
}
];
`;
isArray: function(){
console.log('%c> layui.isArray: ', style);
console.log(
'[1,6]', layui.isArray([1,6]),
'\nlay("div")', layui.isArray(lay('div')),
'\ndocument.querySelectorAll("div")', layui.isArray(document.querySelectorAll('div')),
'\n{"key": "value"}', layui.isArray({key: 'value'})
);
},
it({
title: '普通拷贝',
code: `
const obj = lay.extend({}, {a:1});
console.log(obj);
`,
expected: {a:1}
});
extend: function(){
console.log('%c> lay.extend: ', style);
console.log(
lay.extend(
{},
{a: 123, c: {ccc: 'ccc'}, arr: [1,3]},
{a: 111, b: 1, c: {bbb: 'bbb'}},
{a: 222222, arr: [5]}
)
);
console.log(
lay.extend([], [1,3,5])
);
},
it({
title: '普通合并',
code: `
const obj = lay.extend({a:1}, {a:3}, {a:5, b:5});
console.log(obj);
`,
expected: {a:5,b:5}
});
treeToFlat: function() {
var data = [
it({
title: '值为「普通对象和数组」的深拷贝合并',
code: `
${objNCode}
const obj = lay.extend({}, ...objN);
console.log(obj);
`,
expected: { a: [5, 3], b: { ba: 3, bb: 2 }, c: 3 }
});
it({
title: '使用 customizer 实现数组覆盖而非合并',
code: `
${objNCode}
const obj = lay.extend({}, ...objN, (objValue, srcValue) => {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return srcValue
// 若为 Object[] 值,可深拷贝数据源覆盖
// return lay.extend([], srcValue);
}
});
console.log(obj);
`,
expected: { a: [5], b: { ba: 3, bb: 2 }, c: 3 }
});
it({
title: '使用 customizer 实现特定字段跳过合并',
code: `
${objNCode}
const obj = lay.extend({}, ...objN, (objValue, srcValue, key) => {
if (key === 'b') {
return objValue;
}
});
console.log(obj);
`,
expected: { a: [5, 3], b: { ba: 1 }, c: 3 }
});
it({
title: '是否存在对象引用',
code: `
const src = { data: [{a: 1, b: 2}] };
const obj = lay.extend({}, src);
// 修改 obj 数组类型中的对象成员,查看 src 是否被影响
obj.data[0].a = '11111';
console.log(obj.data[0].a === src.data[0].a);
`,
expected: false
});
});
tester.describe('lay.treeToFlat', function(it) {
const data = [
{
"title": "节点 1",
"id": "1000",
@@ -152,19 +308,47 @@ layui.use(['lay', 'util'], function() {
}
];
var flatData = lay.treeToFlat(data, {
it({
title: '树状数据展平',
code: `
const data = ${JSON.stringify(data, null, 2)};
// 树状数据展平
const flatData = lay.treeToFlat(data, {
keepChildren: true // 是否保留 children 字段
}); // 树状数据展平
});
console.log(JSON.stringify(flatData));
`,
expected: '子节点被展平到一级数组中(控制台查看)',
assert(actual, expected) {
try {
actual = JSON.parse(actual);
console.log(`${this.title} : `, actual);
return actual.length === 5;
} catch (e) {
return false
}
}
});
console.log('%c> lay.treeToFlat: ', style);
console.log('树转平后:', JSON.stringify(flatData, null, 2));
it({
title: '是否存在对象引用',
code: `
const data = ${JSON.stringify(data, null, 2)};
flatData[0].children[0].title="333333"; // 修改数据
console.log('原始数据:', JSON.stringify(data, null, 2)); // 查看原始数据是否被修改
},
// 树状数据展平
const flatData = lay.treeToFlat(data, {
keepChildren: true // 是否保留 children 字段
});
flatData[0].children[0].title="333333"; // 修改数据
console.log(flatData[0].children[0].title === data[0].children[0].title);
`,
expected: false
});
});
flatToTree: function() {
var data = [
tester.describe('lay.flatToTree', function(it) {
const data = [
{
"title": "节点 1",
"id": "1000",
@@ -192,17 +376,46 @@ layui.use(['lay', 'util'], function() {
}
];
var treeData = lay.flatToTree(data); // 平铺数据转树状
it({
title: '平铺数据转树状',
code: `
const data = ${JSON.stringify(data, null, 2)};
console.log('%c> lay.flatToTree: ', style);
console.log('平转树后:', JSON.stringify(treeData, null, 2));
treeData[0].children[0].title="333333"; // 修改数据
console.log('原始数据:', JSON.stringify(data, null, 2)); // 查看原始数据是否被修改
const treeData = lay.flatToTree(data); // 平铺数据转树状
console.log(JSON.stringify(treeData));
`,
expected: '根据 parentId 字段进行树状数据生成(控制台查看)',
assert(actual, expected) {
try {
actual = JSON.parse(actual);
console.log(`${this.title} : `, actual);
return actual.length === 2;
} catch (e) {
return false
}
})
}
});
});
</script>
it({
title: '是否存在对象引用',
code: `
const data = ${JSON.stringify(data, null, 2)};
const treeData = lay.flatToTree(data); // 平铺数据转树状
treeData[0].children[0].title="333333"; // 修改数据
// 查看原始数据是否被修改
console.log(treeData[0].children[0].title === data[1].title);
`,
expected: false
});
});
// 统计结果
tester.stats();
// 返回顶部
util.fixbar();
});
</script>
</body>
</html>

51
examples/test/test.css Normal file
View File

@@ -0,0 +1,51 @@
/**
* Layui Visual Test
*/
.test-side {
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: 160px;
overflow-x: hidden;
box-sizing: border-box;
}
.test-main {
--padding: 16px;
--border-color: #eee;
}
.test-main {
margin-left: 160px;
padding: 32px;
}
.test-stats {
padding-bottom: var(--padding);
border-bottom: 1px solid var(--border-color);
}
.test-stats span {
padding-right: var(--padding);
}
.test-item {
margin: var(--padding) 0;
padding: var(--padding);
box-shadow: rgba(0, 0, 0, 0.16) 0px 18px 18px -18px,
rgba(0, 0, 0, 0.05) 0px 0px 18px;
}
.test-item > div {
padding: 5px 0;
color: #16b777;
}
.test-result ul li span {
padding-right: var(--padding);
}
.test-result ul li code {
color: #1F1F1F;
font-family: "Courier New", Consolas, "Lucida Console", monospace;
}

172
examples/test/test.js Normal file
View File

@@ -0,0 +1,172 @@
/**
* test
* Layui 可视化简易单元测试
*/
const { lay, util } = layui;
class Test {
constructor(options) {
lay.extend(this.options, options);
this.#init();
this.#render();
}
static stats = {
total: 0,
passes: 0,
failures: 0,
}
options = {
elem: '#tests',
}
#init() {
const options = this.options;
options.el = lay(options.elem);
}
#render() {
const options = this.options;
const { el } = options;
el.html('');
}
static tools = {
isPlainObjectOrArray(obj) {
return lay.isPlainObject(obj) || Array.isArray(obj);
},
equal(a, b) {
const isPlainObjectOrArray = Test.tools.isPlainObjectOrArray;
if (isPlainObjectOrArray(a) || isPlainObjectOrArray(b)) {
a = isPlainObjectOrArray(a) ? JSON.stringify(a) : a;
b = isPlainObjectOrArray(b) ? JSON.stringify(b) : b;
}
return a === b;
},
output(obj) {
const isPlainObjectOrArray = Test.tools.isPlainObjectOrArray;
if (isPlainObjectOrArray(obj)) {
return JSON.stringify(obj);
}
return obj;
}
}
describe(opts, fn) {
const { el } = this.options;
if (typeof opts === 'string') {
opts = {
title: opts,
id: opts
};
}
const describer = this.describer = lay.extend({
suiteElem: lay.elem('div', {
"class": 'layui-text test-suite',
"id": opts.id,
}),
itemsElem: lay.elem('div', {
"class": 'test-items',
}),
}, opts);
lay(describer.suiteElem).append(`<h2>${opts.title} : </h2>`);
lay(describer.suiteElem).append(describer.itemsElem);
el.append(describer.suiteElem);
fn?.(this.#it.bind(this));
return this;
}
#it(opts = {}) {
let { code, expected, title, assert } = opts;
const describer = this.describer;
const itemsElem = lay(describer.itemsElem);
const stats = Test.stats;
const startTime = Date.now();
const actual = (function() {
try {
return new Function(`return function() {
let result;
const console = Object.assign({}, window.console);
console.log = (a) => result = a;
${code}
return result;
}`)()();
} catch(e) {
return e.message;
}
})();
const duration = (Date.now() - startTime) / 1000;
const passed = (
assert ? assert.bind(arguments[0]) : Test.tools.equal.bind(this)
)(actual, expected);
const result = passed ? '✅' : '❌';
const output = Test.tools.output;
const itemElem = lay.elem('div', { "class": 'test-item' });
stats.total++;
title = title || `任务 ${stats.total}`;
if (passed) {
stats.passes++;
} else {
stats.failures++;
console.error(`${describer.title}${title} : Test failed\nExpected: ${output(expected)}\nActual: ${output(actual)}`);
}
itemElem.innerHTML = `
<div><strong>${title}</strong></div>
<pre class="layui-code">${code}</pre>
<div class="test-result">
<ul>
<li><strong>预期:</strong><code>${output(expected)}</code></li>
${
!passed || opts.showActual
? '<li><strong>实际:</strong><code>' + output(actual) + '</code></li>'
: ''
}
<li>
<span><strong>结果:</strong>${output(result)}</span>
<!-- <span><strong>耗时:</strong>${duration.toFixed(2)} s</span> -->
</li>
</ul>
</div>
`;
layui.code({
elem: lay(itemElem).find('.layui-code')[0],
theme: 'dark',
encode: false,
});
itemsElem.append(itemElem);
}
stats() {
const options = this.options;
const { el } = options;
const stats = Test.stats;
const statsElem = lay.elem('div', { "class": 'test-stats' });
lay(statsElem).html(`
<span><strong>测试数量 : </strong>${stats.total}</span>
<span><strong>✅ 通过 : </strong>${stats.passes}</span>
<span><strong>❌ 失败 : </strong>${stats.failures}</span>
`)
el[0].prepend(statsElem);
}
}
export function test(options) {
return new Test(options);
}

View File

@@ -31,6 +31,10 @@
});
};
var fnToString = Function.prototype.toString;
var ObjectFunctionString = fnToString.call(Object);
var hasOwnProperty = Object.prototype.hasOwnProperty;
/*
* API 兼容
*/
@@ -53,40 +57,122 @@
Class.fn = Class.prototype = [];
Class.fn.constructor = Class;
/**
* 将个或多个对象的内容深度合并到第一个对象中
* @callback ExtendFunc
* @param {*} target - 一个对象
* @param {...*} objectN - 包含额外的属性合并到第一个参数
* 将个或多个对象合并到目标对象中
* 对象类型值始终进行「深拷贝」合并。若需浅拷贝合并,请使用 Object.assign()
* @param {*} target - 目标对象
* @param {...*} objectN - 一个或多个包含要应用的属性的源对象
* @param {Function} customizer - 可选的自定义合并函数
* @returns {*} 返回合并后的对象
* @example
*```js
* console.log(lay.extend({}, {a:1})); // expected: {a:1}
* console.log(lay.extend({a:1}, {a:3}, {a:5,b:5})); // expected: {a:5,b:5}
* // 多个相同源对象的不同合并方式
* const objN = [
* {
* a: [1, 3],
* b: {ba: 1}
* },
* {
* a: [5],
* b: {bb: 2}
* },
* {
* b: {ba: 3},
* c: 3
* }
* ];
* console.log(lay.extend({}, ...objN)); // expected: {a:[5,3],b:{ba:3,bb:2},c:3}
* // 使用 customizer 实现数组覆盖而非合并
* const obj1 = lay.extend({}, ...objN, function(objValue, srcValue) {
* if (Array.isArray(objValue) && Array.isArray(srcValue)) {
* return srcValue;
* }
* });
* console.log(obj1); // expected: {a:[5],b:{ba:3,bb:2},c:3}
* // 使用 customizer 实现特定字段跳过合并
* const obj2 = lay.extend({}, ...objN, function(objValue, srcValue, key, target, source) {
* if (key === 'b') {
* return objValue;
* }
* });
* console.log(obj2); // expected: {a:[5,3],b:{ba:1},c:3}
* ```
*/
/** @type ExtendFunc*/
lay.extend = function(){
var ai = 1;
var length;
var args = arguments;
var clone = function(target, obj){
target = target || (layui.type(obj) === 'array' ? [] : {}); // 目标对象
for(var i in obj){
// 若值为普通对象,则进入递归,继续深度合并
target[i] = (obj[i] && obj[i].constructor === Object)
? clone(target[i], obj[i])
: obj[i];
lay.extend = function() {
var args = [].slice.call(arguments);
// 最后一个参数是否为 customizer
var customizer = typeof args[args.length - 1] === 'function'
? args.pop()
: false;
// 深拷贝合并
return args.reduce(function(target, source) {
// 确保 target 始终是一个对象
if (typeof target !== 'object' || target === null) {
target = {};
}
for (var key in source) {
if (!source.hasOwnProperty(key)) continue; // 仅处理自有属性
var targetValue = target[key];
var sourceValue = source[key];
// 自定义合并逻辑(如数组覆盖、特定字段跳过等)
if (customizer) {
var customResult = customizer(targetValue, sourceValue, key, target, source);
if (customResult !== undefined) {
target[key] = customResult;
continue;
}
}
// 默认深拷贝逻辑
if (Array.isArray(sourceValue)) {
targetValue = Array.isArray(targetValue) ? targetValue : []
} else if(lay.isPlainObject(sourceValue)) {
targetValue = lay.isPlainObject(targetValue) ? targetValue : {}
}
target[key] = (lay.isPlainObject(sourceValue) || Array.isArray(sourceValue))
? lay.extend(targetValue, sourceValue, customizer)
: sourceValue;
}
return target;
});
};
args[0] = typeof args[0] === 'object' ? args[0] : {};
length = args.length
/**
* 判断是否为纯对象
* @param {*} obj - 要检查的对象
* @returns {boolean}
*/
lay.isPlainObject = function(obj) {
if (
obj === null ||
typeof obj !== 'object' ||
Object.prototype.toString.call(obj) !== '[object Object]'
) {
return false;
}
for(; ai < length; ai++){
if(typeof args[ai] === 'object'){
clone(args[0], args[ai]);
var proto = Object.getPrototypeOf(obj);
// Object.create(null) 创建的对象
if (proto === null) {
return true;
}
}
return args[0];
// 判定具有原型且由全局 Object 构造函数创建的对象为纯对象(来自 jQuery 方案)
var Ctor = hasOwnProperty.call(proto, 'constructor') && proto.constructor;
return typeof Ctor === 'function' && fnToString.call(Ctor) === ObjectFunctionString;
};
/**
* IE 版本
* @type {string | boolean} - 如果是 IE 返回版本字符串,否则返回 false
@@ -795,7 +881,6 @@
}
};
var hasOwnProperty = Object.prototype.hasOwnProperty;
/**
* 检查对象是否具有指定的属性
* @param {Record<string, any>} obj 要检查的对象