每日一题:组课神器
介绍
在很多教育网站的平台上,课程的章节目录会使用树型组件呈现,为了方便调整菜单,前端工程师会为其赋予拖拽功能。本题需要在已提供的基础项目中,完成可拖拽树型组件的功能。
准备
1 2 3 4 5 6 7 8
| ├── effect.gif ├── css │ └── index.css ├── index.html └── js ├── data.json ├── axios.min.js └── index.js
|
其中:
index.html 是主页面。
js/index.js 是待完善的 js 文件。
js/data.json 是存放数据的 json 文件。
js/axios.min.js 是 axios 文件。
css/index.css 是 css 样式文件。
effect.gif 是完成的效果图。
注意:打开环境后发现缺少项目代码,请复制下述命令至命令行进行下载。
1 2 3
| cd /home/project wget -q https://labfile.oss.aliyuncs.com/courses/18213/test8.zip unzip test8.zip && rm test8.zip
|
在浏览器中预览 index.html 页面,显示如下所示:

目标
请在 js/index.js 文件中补全代码。
最终效果可参考文件夹下面的 gif 图,图片名称为 effect.gif (提示:可以通过 VS Code 或者浏览器预览 gif 图片)。

具体需求如下:
- 补全
js/index.js 文件中 ajax 函数,功能为根据请求方式 method 不同,拿到树型组件的数据并返回。具体如下:
- 当
method === "get" 时,判断 localStorage 中是否存在 key 为 data 的数据,若存在,则从 localStorage 中直接获取后处理为 json 格式并返回;若不存在则从 ./js/data.json(必须使用该路径请求,否则可能会请求不到数据)中使用 ajax 获取并返回。
- 当
method === "post" 时,将通过参数 data 传递过来的数据转化为 json 格式的字符串,并保存到 localStorage 中,key 命名为 data。
最终返回的数据格式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| [ { "id": 1001, "label": "第一章 Vue 初体验", "children": [ ... ] }, { "id": 1006, "label": "第二章 Vue 核心概念", "children": [ { "id": 1007, "label": "2.1 概念理解", "children": [ { "id": 1008, "label": "聊一聊虚拟 DOM", "tag":"文档 1" }, ... ] }, { "id": 1012, "label": "2.2 Vue 基础入门", "children": [ { "id": 1013, "label": "Vue 的基本语法", "tag":"实验 6" }, ... ] } ] } ]
|
- 补全
js/index.js 文件中的 treeMenusRender 函数,使用所传参数 data 生成指定 DOM 结构的模板字符串(完整的模板字符串的 HTML 样例结构可以在 index.html 中查看),并在包含 .tree-node 的元素节点上加上指定属性如下:
| 属性名 |
属性值 |
描述 |
data-grade |
${grade} |
表示菜单的层级,整数,由 treeMenusRender 函数的 grade 参数值计算获得,章节是 1,小节是 2,实验文档是 3。 |
data-index |
${id} |
表示菜单的唯一 id,使用每层菜单数据的 id 字段值。 |
- 补全
js/index.js 文件中的 treeDataRefresh 函数,功能为:根据参数列表 { dragGrade, dragElementId }, { dropGrade, dropElementId } 重新生成拖拽后的树型组件数据 treeData(treeData 为全局变量,直接访问并根据参数处理后重新赋值即可)。
方便规则描述,现将 data.json 中的数据扁平化处理,得到的数据顺序如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| [ { grade: "1", label: "第一章 Vue 初体验", id: "1001" }, { grade: "2", label: "1.1 Vue 简介", id: "1002" }, { grade: "3", label: "Vue 的发展历程", id: "1003" }, { grade: "3", label: "Vue 特点", id: "1004" }, { grade: "3", label: "一分钟上手 Vue", id: "1005" }, { grade: "1", label: "第二章 Vue 核心概念", id: "1006" }, { grade: "2", label: "2.1 概念理解", id: "1007" }, { grade: "3", label: "聊一聊虚拟 DOM", id: "1008" }, { grade: "3", label: "感受一下虚拟 DOM", id: "1009" }, { grade: "3", label: "聊一聊 MVVM 设计模式", id: "1010" }, { grade: "3", label: "Vue 中的 MVVM 设计模式", id: "1011" }, { grade: "2", label: "2.2 Vue 基础入门", id: "1012" }, { grade: "3", label: "Vue 的基本语法", id: "1013" }, { grade: "3", label: "第一步,创建 Vue 应用实例", id: "1014" }, ]
|
拖拽前后的规则说明如下:
- 情况一:若拖拽的节点和放置的节点为同级,即
treeDataRefresh 函数参数列表中 dragGrade == dropGrade,则将 id == dragElementId(例如:1011)的节点移动到 id==dropElementId(例如:1008)的节点后,作为其后第一个邻近的兄弟节点。最终生成的数据顺序如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| [ { grade: "1", label: "第一章 Vue 初体验", id: "1001" }, { grade: "2", label: "1.1 Vue 简介", id: "1002" }, { grade: "3", label: "Vue 的发展历程", id: "1003" }, { grade: "3", label: "Vue 特点", id: "1004" }, { grade: "3", label: "一分钟上手 Vue", id: "1005" }, { grade: "1", label: "第二章 Vue 核心概念", id: "1006" }, { grade: "2", label: "2.1 概念理解", id: "1007" }, { grade: "3", label: "聊一聊虚拟 DOM", id: "1008" }, { grade: "3", label: "Vue 中的 MVVM 设计模式", id: "1011" }, { grade: "3", label: "感受一下虚拟 DOM", id: "1009" }, { grade: "3", label: "聊一聊 MVVM 设计模式", id: "1010" }, { grade: "2", label: "2.2 Vue 基础入门", id: "1012" }, { grade: "3", label: "Vue 的基本语法", id: "1013" }, { grade: "3", label: "第一步,创建 Vue 应用实例", id: "1014" } ]
|
- 情况二:若拖拽的节点和放置的节点为上下级,即
treeDataRefresh 函数参数列表中 dragGrade - dropGrade == 1,则将 id == dragElementId(例如:1011)的节点移动到 id==dropElementId(例如:1002)的节点下,并作为其第一个子节点。最终生成的数据顺序如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| [ { grade: "1", label: "第一章 Vue 初体验", id: "1001" }, { grade: "2", label: "1.1 Vue 简介", id: "1002" }, { grade: "3", label: "Vue 中的 MVVM 设计模式", id: "1011" }, { grade: "3", label: "Vue 的发展历程", id: "1003" }, { grade: "3", label: "Vue 特点", id: "1004" }, { grade: "3", label: "一分钟上手 Vue", id: "1005" }, { grade: "1", label: "第二章 Vue 核心概念", id: "1006" }, { grade: "2", label: "2.1 概念理解", id: "1007" }, { grade: "3", label: "聊一聊虚拟 DOM", id: "1008" }, { grade: "3", label: "感受一下虚拟 DOM", id: "1009" }, { grade: "3", label: "聊一聊 MVVM 设计模式", id: "1010" }, { grade: "2", label: "2.2 Vue 基础入门", id: "1012" }, { grade: "3", label: "Vue 的基本语法", id: "1013" }, { grade: "3", label: "第一步,创建 Vue 应用实例", id: "1014" } ];
|
规定
- 请勿修改
js/index.js 文件外的任何内容。
- 请严格按照考试步骤操作,切勿修改考试默认提供项目中的文件名称、文件夹路径、class 名、id 名、图片名等,以免造成无法判题通过。
判分标准
- 完成目标 1,得 5 分。
- 完成目标 2,得 10 分。
- 完成目标 3,得 10 分。
总通过次数: 80 | 总提交次数: 227 | 通过率: 35.2%
难度: 困难 标签: 蓝桥杯, 2023, 省赛, Web 前端, JavaScript, 异步
tijie
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353
| /** * @description 模拟 ajax 请求,拿到树型组件的数据 treeData * @param {string} url 请求地址 * @param {string} method 请求方式,必填,默认为 get * @param {string} data 请求体数据,可选参数 * @return {Array} * */ async function ajax({ url, method = "get", data }) { let result; // TODO:根据请求方式 method 不同,拿到树型组件的数据 // 当method === "get" 时,localStorage 存在数据从 localStorage 中获取,不存在则从 /js/data.json 中获取 // 当method === "post" 时,将数据保存到localStorage 中,key 命名为 data if (method === "get") { let result = localStorage.getItem("data"); if (result === undefined) { console.log(result); } else { result = (await axios({ url, method })).data.data; console.log(result); } } else if (method === "post") { localStorage.setItem("data", data); }
return result; }
/** * @description 找到元素节点的父亲元素中类选择器中含有 tree-node 的元素节点 * @param {Element} node 传入的元素节点 * @return {Element} 得到的元素节点 */ const getTreeNode = (node) => { let curElement = node; while (!curElement.classList.contains("tree-node")) { if (curElement.classList.contains("tree")) { break; } curElement = curElement.parentNode; } return curElement; };
/** * @description 根据 dragElementId, dropElementId 重新生成拖拽完成后的树型组件的数据 treeData * @param {number} dragGrade 被拖拽的元素的等级,值为 dragElement data-grade属性对应的值 * @param {number} dragElementId 被拖拽的元素的id,值为当前数据对应在 treeData 中的id * @param {number} dropGrade 放入的目标元素的等级,值为 dropElement data-grade属性对应的值 * @param {number} dropElementId 放入的目标元素的id,值为当前数据对应在 treeData 中的id */ function treeDataRefresh( { dragGrade, dragElementId }, { dropGrade, dropElementId } ) { if (dragElementId === dropElementId) return; // TODO:根据 `dragElementId, dropElementId` 重新生成拖拽完成后的树型组件的数据 `treeData` }
/** * @description 根据 treeData 的数据生成树型组件的模板字符串,在包含 .tree-node 的元素节点需要加上 data-grade=${index}表示菜单的层级 data-index="${id}" 表示菜单的唯一id * @param {array} data treeData 数据 * @param {number} grade 菜单的层级 * @return 树型组件的模板字符串 * * */ function treeMenusRender(data, grade = 0) { let treeTemplate = ""; // TODO:根据传入的 treeData 的数据生成树型组件的模板字符串 console.log(data); grade = 1; for (let first of data) { // 遍历章 // 结构是章container > 章标题 .... treeTemplate += ` <!-- 每一章的 DOM 结构 start --> <div class="tree-node" data-index="${first.id}" data-grade="${grade}"> <!-- 每一章标题的 DOM 结构 start --> <div class="tree-node-content" style="margin-left: 0px"> <div class="tree-node-content-left"> <img src="./images/dragger.svg" alt="" class="point-svg" /> <span class="tree-node-label">${first.label}</span> <img class="config-svg" src="./images/config.svg" alt="" /> </div> </div> <!-- 每一章标题的 DOM 结构 end --> `; for(let second of first) { treeTemplate += ` <!-- 每一章下所有小节的 DOM 结构 start --> <div class="tree-node-children"> <!-- 第一个小节的 DOM 结构 start --> <div class="tree-node" data-index="${second.id}" data-grade="2"> <!-- 第一个小节标题的 DOM 结构 start --> <div class="tree-node-content" style="margin-left: 15px"> <div class="tree-node-content-left"> <img src="./images/dragger.svg" alt="" class="point-svg" /> <span class="tree-node-label">${second.label}</span> <img class="config-svg" src="./images/config.svg" alt="" /> </div> </div> <!-- 第一个小节标题的 DOM 结构 end --> </div> <!-- 第一个小节的 DOM 结构 end --> ` for(let third of second) { treeTemplate += ` <!-- 每小节下所有文档/实验的 DOM 结构 start --> <div class="tree-node-children"> <!-- 第一个文档/实验的 DOM 结构 start --> <div class="tree-node" data-index="1003" data-grade="3"> <div class="tree-node-content" style="margin-left: 30px"> <div class="tree-node-content-left"> <img src="./images/dragger.svg" alt="" class="point-svg" /> <span class="tree-node-tag">实验1</span> <span class="tree-node-label">Vue 的发展历程</span> </div> <div class="tree-node-content-right"> <div class="students-count"> <span class="number"> 0人完成</span> <span class="line">|</span> <span class="number">0人提交报告</span> </div> <div class="config"> <img class="config-svg" src="./images/config.svg" alt="" /> <button class="doc-link">编辑文档</button> </div> </div> </div> </div> <!-- 第一个文档/实验的 DOM 结构 end --> <!-- 第二个文档/实验的 DOM 结构 start --> <div class="tree-node" data-index="1003" data-grade="3"> <div class="tree-node-content" style="margin-left: 30px"> <div class="tree-node-content-left"> <img src="./images/dragger.svg" alt="" class="point-svg" /> <span class="tree-node-tag">实验1</span> <span class="tree-node-label">Vue 的发展历程</span> </div> <div class="tree-node-content-right"> <div class="students-count"> <span class="number"> 0人完成</span> <span class="line">|</span> <span class="number">0人提交报告</span> </div> <div class="config"> <img class="config-svg" src="./images/config.svg" alt="" /> <button class="doc-link">编辑文档</button> </div> </div> </div> </div> <!-- 第一个文档/实验的 DOM 结构 end --> </div> <!-- 每小节下所有文档/实验的 DOM 结构 end -->` } }
treeTemplate += ` </div> <!-- 每一章下所有小节的 DOM 结构 end -->`
treeTemplate += ` </div> <!-- 每一章的 DOM 结构 end -->`; } return treeTemplate; }
let treeData; // 树型组件的数据 treeData
// 拖拽到目标元素放下后执行的函数 const dropHandler = (dragElement, dropElement) => { let dragElementId = dragElement.dataset.index; let dragGrade = dragElement.dataset.grade; if (dropElement) { let dropElementId = dropElement.dataset.index; let dropGrade = dropElement.dataset.grade;
treeDataRefresh({ dragGrade, dragElementId }, { dropGrade, dropElementId }); document.querySelector(".tree").innerHTML = treeMenusRender(treeData); document.querySelector("#test").innerText = treeData ? JSON.stringify(treeData) : ""; ajax({ url: "./js/data.json", method: "post", data: treeData }); } }; // 初始化 ajax({ url: "./js/data.json" }).then((res) => { treeData = res; document.querySelector("#test").innerText = treeData ? JSON.stringify(treeData) : ""; let treeEle = document.querySelector(".tree"); treeEle.dataset.grade = 0; let treeTemplate = treeMenusRender(treeData); treeTemplate && (treeEle.innerHTML = treeTemplate); const mDrag = new MDrag(".tree-node", dropHandler); // 事件委托,按下小图标记录得到被拖拽的元素,该元素 class 包含 .tree-node document.querySelector(".tree").addEventListener("mousedown", (e) => { e.preventDefault(); if ( e.target.nodeName.toLowerCase() === "img" && e.target.classList.contains("point-svg") ) { let dragElement = getTreeNode(e.target); // MDrag类的drag方法实现拖拽效果 mDrag.drag(e, dragElement); } }); });
/** * @description 实现拖拽功能的类,该类的功能为模拟 HTML5 drag 的功能 * 鼠标按下后,监听 document 的 mousemove 和 mouseup 事件 * 当开始拖拽一个元素后会在 body 内插入对应的克隆元素,并随着鼠标的移动而移动 * 鼠标抬起后,移除克隆元素和 mousemove 事件,如果到达目标触发传入的 dropHandler 方法 */ class MDrag { constructor(dropElementSelector, dropHandler) { // 目标元素的选择器 this.dropElementSelector = dropElementSelector; // 拖拽到目标元素放下后执行的函数 this.dropHandler = dropHandler;
// 保存所有的目标元素 this.dropBoundingClientRectArr = []; // 被拖拽的元素 this._dragElement = null; // 拖拽中移动的元素 this._dragElementClone = null; // 目标元素 this._dropElement = null; // 拖拽移动事件 this._dragMoveBind = null; // 拖拽鼠标抬起事件 this._dragUpBind = null;
this.init(); } init() { const dropElements = document.querySelectorAll(this.dropElementSelector); this.dropBoundingClientRectArr = Array.from(dropElements).map((el) => { return { boundingClientRect: el.getBoundingClientRect(), el }; }); } dragMove(e) { const { pageX, pageY } = e; this._dragElementClone.style.left = `${e.pageX}px`; this._dragElementClone.style.top = `${e.pageY}px`; this.setMouseOverElementStyle(pageX, pageY); } dragend(e) { // 移动到目标元素后mouseup事件触发,删除 this._dragElementClone 元素和解除mousemove/mouseup事件 const { pageX, pageY } = e; document.removeEventListener("mousemove", this._dragMoveBind); document.removeEventListener("mouseup", this._dragUpBind); if ( Array.from(document.body.children).indexOf(this._dragElementClone) != -1 ) { document.body.removeChild(this._dragElementClone); } this._dropElement = this.getActualDropElement(pageX, pageY); this.drop(); } drag(e, dragElement) { this._dragElement = dragElement; this._dragElementClone = dragElement.cloneNode(true); this._dragElementClone.style.position = "absolute"; this._dragElementClone.style.left = `${e.pageX - 20}px`; this._dragElementClone.style.top = `${e.pageY - 20}px`; this._dragElementClone.style.opacity = 0.5; this._dragElementClone.style.width = "800px"; document.body.appendChild(this._dragElementClone); // 绑定mousemove和mouseup事件 this._dragMoveBind = this.dragMove.bind(this); this._dragUpBind = this.dragend.bind(this); document.addEventListener("mousemove", this._dragMoveBind); document.addEventListener("mouseup", this._dragUpBind); return this; } getActualDropElement(pageX, pageY) { const dropAttributeArr = this.dropBoundingClientRectArr.filter( (obj) => pageY >= obj.boundingClientRect.top && pageY <= obj.boundingClientRect.top + obj.boundingClientRect.height ); if (dropAttributeArr.length == 1) { return dropAttributeArr[0].el; } else if (dropAttributeArr.length > 1) { let temp = dropAttributeArr.reduce((prev, next) => { if ( Math.abs(pageY - prev.boundingClientRect.top) <= Math.abs(pageY - next.boundingClientRect.top) ) { return prev; } else { return next; } }); return temp.el; } else { return null; } } setMouseOverElementStyle(pageX, pageY) { let mousemoveEle = this.getActualDropElement(pageX, pageY); if (mousemoveEle) { this.dropBoundingClientRectArr.forEach((obj) => { obj.el.classList.contains("mouseover-active") && obj.el.classList.remove("mouseover-active"); }); mousemoveEle.classList.add("mouseover-active"); } } drop() { this.dropHandler && this.dropHandler(this._dragElement, this._dropElement); this.init(); } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
| function treeMenusRender(data, grade = 0) { let treeTemplate = ""; console.log(data); grade = 1; for (let first of data) { treeTemplate += ` <!-- 每一章的 DOM 结构 start --> <div class="tree-node" data-index="${first.id}" data-grade="${grade}"> <!-- 每一章标题的 DOM 结构 start --> <div class="tree-node-content" style="margin-left: 0px"> <div class="tree-node-content-left"> <img src="./images/dragger.svg" alt="" class="point-svg" /> <span class="tree-node-label">${first.label}</span> <img class="config-svg" src="./images/config.svg" alt="" /> </div> </div> <!-- 每一章标题的 DOM 结构 end --> <!-- 每一章下所有小节的 DOM 结构 start --> <div class="tree-node-children"> `;
for (let second of first.children) { treeTemplate += ` <!-- 第一个小节的 DOM 结构 start --> <div class="tree-node" data-index="${second.id}" data-grade="2"> <!-- 第一个小节标题的 DOM 结构 start --> <div class="tree-node-content" style="margin-left: 15px"> <div class="tree-node-content-left"> <img src="./images/dragger.svg" alt="" class="point-svg" /> <span class="tree-node-label">${second.label}</span> <img class="config-svg" src="./images/config.svg" alt="" /> </div> </div> <!-- 第一个小节标题的 DOM 结构 end --> <!-- 每小节下所有文档/实验的 DOM 结构 start --> <div class="tree-node-children"> `; for (let third of second.children) { treeTemplate += ` <!-- 第一个文档/实验的 DOM 结构 start --> <div class="tree-node" data-index="${third.id}" data-grade="3"> <div class="tree-node-content" style="margin-left: 30px"> <div class="tree-node-content-left"> <img src="./images/dragger.svg" alt="" class="point-svg" /> <span class="tree-node-tag">${third.tag}</span> <span class="tree-node-label">${third.label}</span> </div> <div class="tree-node-content-right"> <div class="students-count"> <span class="number"> 0人完成</span> <span class="line">|</span> <span class="number">0人提交报告</span> </div> <div class="config"> <img class="config-svg" src="./images/config.svg" alt="" /> <button class="doc-link">编辑文档</button> </div> </div> </div> </div> <!-- 第一个文档/实验的 DOM 结构 end --> `; } treeTemplate += ` </div> <!-- 第一个小节的 DOM 结构 end --> </div> <!-- 每小节下所有文档/实验的 DOM 结构 end -->` }
treeTemplate += ` </div> <!-- 每一章下所有小节的 DOM 结构 end -->`;
treeTemplate += ` </div> <!-- 每一章的 DOM 结构 end -->`; } return treeTemplate; }
|