组课神器
发表于:2024-12-10
字数统计:4021 字
预计阅读14分钟
介绍
在很多教育网站的平台上,课程的章节目录会使用树型组件呈现,为了方便调整菜单,前端工程师会为其赋予拖拽功能。本题需要在已提供的基础项目中,完成可拖拽树型组件的功能。
准备
text
├── 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是完成的效果图。
注意:打开环境后发现缺少项目代码,请复制下述命令至命令行进行下载。
shell
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。
最终返回的数据格式如下:
js
[
{
"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 中的数据扁平化处理,得到的数据顺序如下:
js
[
{ 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)的节点后,作为其后第一个邻近的兄弟节点。最终生成的数据顺序如下:
js
[
{ 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)的节点下,并作为其第一个子节点。最终生成的数据顺序如下:
js
[
{ 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 分。
总通过次数: 115 | 总提交次数: 311 | 通过率: 37%
难度: 困难 标签: 蓝桥杯, 2023, 省赛, Web 前端, JavaScript, 异步
题解
我重复测试后未果,故参考他人答案
参考自:蓝桥杯题解区:若始
js
/**
* @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') {
const dataList = localStorage.getItem('data')
result = dataList ? JSON.parse(dataList) : (await axios({ url, method })).data.data
}
if (method === 'post') {
// result = (await axios({ url, method, data })).data
localStorage.setItem('data', JSON.stringify(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`
let dragStr = JSON.stringify(getDragElement(treeData, dragElementId))
let dropStr = JSON.stringify(getDragElement(treeData, dropElementId))
let treeDataStr = JSON.stringify(treeData)
if (dragGrade === dropGrade) {
treeDataStr = treeDataStr.replace(dragStr, '')
treeDataStr = treeDataStr.replace(dropStr, dropStr + ',' + dragStr)
}
if (dragGrade - dropGrade == 1) {
if (dropStr.includes(dragStr)) dropStr = dropStr.replace(dragStr, '')
const newDragStr = `${dragStr},`
const newDropStr = dropStr.replace('[', '[' + newDragStr)
treeDataStr = treeDataStr.replace(dragStr, '')
treeDataStr = treeDataStr.replace(dropStr, newDropStr)
}
// 处理多余字符
treeDataStr = treeDataStr.replace(',,', ',').replace('[,', '[').replace(',]', ']')
treeData = JSON.parse(treeDataStr)
}
function getDragElement(data, id) {
for (const obj of flatObj(data)) {
if (obj.id == id) return obj
}
}
function flatObj(data) {
return data.reduce((prev, cur) => {
prev = [...prev, cur]
if (cur?.children) prev = [...prev, ...flatObj(cur.children)]
return prev
}, [])
}
/**
* @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 的数据生成树型组件的模板字符串
grade++
for (obj of data) {
treeTemplate +=
grade === 3
? `<div class="tree-node" data-index="${obj.id}" data-grade="${grade}">
<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">${obj.tag}</span>
<span class="tree-node-label">${obj.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 class="tree-node" data-index="${obj.id}" data-grade="${grade}">
<div class="tree-node-content" style="margin-left: ${grade === 2 && 15}px">
<div class="tree-node-content-left">
<img src="./images/dragger.svg" alt="" class="point-svg" />
<span class="tree-node-label">${obj.label}</span>
<img class="config-svg" src="./images/config.svg" alt="" />
</div>
</div>`
if (obj?.children) treeTemplate += `<div class="tree-node-children">${treeMenusRender(obj.children, grade)}</div>`
treeTemplate += `</div>`
}
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();
}
}