From 6f718190a482357baf95f62314fa814729e9e696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B4=A4=E5=BF=83?= <3277200+sentsim@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:28:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20`lay.extend()`=20?= =?UTF-8?q?=E6=96=B9=E6=B3=95=20(#2879)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 优化 `lay.extend()` 方法 * fix: 优化 lay.extend 合并对象的类型判断 * fix: 优化判断纯对象的方案 * test: 添加可视化简易单元测试模块 --- examples/base.html | 575 ++++++++++++++++++++++++++++------------- examples/test/test.css | 51 ++++ examples/test/test.js | 172 ++++++++++++ src/modules/lay.js | 141 ++++++++-- 4 files changed, 730 insertions(+), 209 deletions(-) create mode 100644 examples/test/test.css create mode 100644 examples/test/test.js diff --git a/examples/base.html b/examples/base.html index 7c1904db..c8bdedfc 100644 --- a/examples/base.html +++ b/examples/base.html @@ -3,206 +3,419 @@ - 基础方法测试用例 - layui + 基础方法测试 - layui + - -
-
-
- 点击按钮开始测试,测试结果打开浏览器控制台查看 -
-
- - - - - - -
+
+
-
+
+
+
+ + - +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", + "children": [ + { + "title": "节点 1-1", + "id": "1001", + }, + { + "title": "节点 1-2", + "id": "1002", + "children": [ + { + "title": "节点 1-2-1", + "id": "1003", + } + ] + } + ] + }, + { + "title": "节点 2", + "id": "2000", + } + ]; + + 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 + } + } + }); + + it({ + title: '是否存在对象引用', + code: ` +const data = ${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 + }); + }); + + tester.describe('lay.flatToTree', function(it) { + const data = [ + { + "title": "节点 1", + "id": "1000", + "parentId": null + }, + { + "title": "节点 1-1", + "id": "1001", + "parentId": "1000" + }, + { + "title": "节点 1-2", + "id": "1002", + "parentId": "1000" + }, + { + "title": "节点 1-2-1", + "id": "1003", + "parentId": "1002" + }, + { + "title": "节点 2", + "id": "2000", + "parentId": null + } + ]; + + it({ + title: '平铺数据转树状', + code: ` +const data = ${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 + } + } + }); + + 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(); + }); + diff --git a/examples/test/test.css b/examples/test/test.css new file mode 100644 index 00000000..e29f99e8 --- /dev/null +++ b/examples/test/test.css @@ -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; +} diff --git a/examples/test/test.js b/examples/test/test.js new file mode 100644 index 00000000..07ef348f --- /dev/null +++ b/examples/test/test.js @@ -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(`

${opts.title} :

`); + 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 = ` +
${title}:
+
${code}
+
+ +
+ `; + + 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(` + 测试数量 : ${stats.total} + ✅ 通过 : ${stats.passes} + ❌ 失败 : ${stats.failures} + `) + el[0].prepend(statsElem); + } +} + +export function test(options) { + return new Test(options); +} diff --git a/src/modules/lay.js b/src/modules/lay.js index e09d5ce2..ee605c8b 100644 --- a/src/modules/lay.js +++ b/src/modules/lay.js @@ -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 - - for(; ai < length; ai++){ - if(typeof args[ai] === 'object'){ - clone(args[0], args[ai]); - } - } - return args[0]; + }); }; + /** + * 判断是否为纯对象 + * @param {*} obj - 要检查的对象 + * @returns {boolean} + */ + lay.isPlainObject = function(obj) { + if ( + obj === null || + typeof obj !== 'object' || + Object.prototype.toString.call(obj) !== '[object Object]' + ) { + return false; + } + + var proto = Object.getPrototypeOf(obj); + + // Object.create(null) 创建的对象 + if (proto === null) { + return true; + } + + // 判定具有原型且由全局 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} obj 要检查的对象