{"id":18748891,"url":"https://github.com/rayjason/puzzlecaptcha","last_synced_at":"2025-07-19T10:35:15.454Z","repository":{"id":155316603,"uuid":"590519961","full_name":"RayJason/puzzleCaptcha","owner":"RayJason","description":null,"archived":false,"fork":false,"pushed_at":"2023-01-18T16:10:11.000Z","size":3559,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2025-07-13T19:53:46.497Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/RayJason.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2023-01-18T15:52:16.000Z","updated_at":"2023-01-18T16:05:43.000Z","dependencies_parsed_at":null,"dependency_job_id":"3c8ca4c1-94c2-4b28-a29c-c773be8101e3","html_url":"https://github.com/RayJason/puzzleCaptcha","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/RayJason/puzzleCaptcha","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/RayJason%2FpuzzleCaptcha","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/RayJason%2FpuzzleCaptcha/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/RayJason%2FpuzzleCaptcha/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/RayJason%2FpuzzleCaptcha/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/RayJason","download_url":"https://codeload.github.com/RayJason/puzzleCaptcha/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/RayJason%2FpuzzleCaptcha/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":265917616,"owners_count":23848976,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-11-07T17:05:27.441Z","updated_at":"2025-07-19T10:35:15.365Z","avatar_url":"https://github.com/RayJason.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# 拼图验证组件 🧩\n使用 HTML + CSS + JavaScript 原生实现的拼图验证组件  \n\n![最终效果](pics/demo.gif)\n\n## HTML 结构\n拼图验证组件需要以下几种元素：\n- 拼图区（图片）\n  - 拼图\n  - 拼图插槽\n  - 成功反馈\n- 拖动区\n  - 拖动滑块\n  - 滑动进度条\n  - 提示文字\n\n组件垂直布局，上为拼图区，下为拖动条。拼图将覆盖在插槽上，层级更高；进度条在拖动滑块左边，将覆盖在提示文字上方。因此结构如下：\n```HTML\n\u003cdiv class=\"verification\"\u003e\n  \u003cp class=\"title\"\u003e拼图验证\u003c/p\u003e\n  \u003cp class=\"tip\"\u003e\u003c/p\u003e\n\n  \u003c!-- 拼图区 --\u003e\n  \u003cdiv class=\"check-wrapper\"\u003e\n    \u003c!-- 目标插槽 --\u003e\n    \u003cdiv class=\"check-target\"\u003e\u003c/div\u003e\n\n    \u003c!-- 拼图 --\u003e\n    \u003cdiv class=\"check-box\"\u003e\u003c/div\u003e\n\n    \u003c!-- 成功 --\u003e\n    \u003cdiv class=\"check-state\"\u003e\n      \u003cimg src=\"https://cos.rayjason.cn/images/check-ok.svg\" alt=\"check ok\"\u003e\u003c/img\u003e\n      \u003cp\u003e验证成功\u003c/p\u003e\n      \u003cbutton type=\"button\" class=\"resetButton\"\u003e确认\u003c/button\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\n  \u003c!-- 拖动区 --\u003e\n  \u003cdiv class=\"drag-wrapper\"\u003e\n    \u003cp class=\"drag-tip\"\u003e\n      \u003cspan\u003e拖动按钮完成上方拼图验证\u003c/span\u003e\n    \u003c/p\u003e\n\n    \u003c!-- 已拖过的进度条 --\u003e\n    \u003cdiv class=\"drag-progress\"\u003e\u003c/div\u003e\n    \u003c!-- 拖动滑块 --\u003e\n    \u003cdiv class=\"drag-box\"\u003e\u003c/div\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n```\n\n## CSS 样式\n组件常出现于页面正中，因此这里使用固定定位 `fixed`。如果只是希望相对于父组件定位，可以把 `position` 改成 `absolute`。组件内部使用 `flex` 垂直布局。\n\n```CSS\n.verification {\n  display: flex;\n  align-items: center;\n  flex-direction: column;\n  width: 400px;\n  height: 400px;\n  margin: auto;\n  padding: 10px;\n  border-radius: 10px;\n  box-shadow: 0px 0 1px 0px #8a8a8a;\n  border: 1px transparent solid;\n  position: fixed;\n  inset: 0;\n  box-sizing: border-box;\n}\n\n.title,\n.tip {\n  margin: 0 0 4px 0;\n  align-self: flex-start;\n}\n\n.tip {\n  height: 1em;\n  margin-bottom: 10px;\n  font-size: 12px;\n  color: #8a8a8a;\n}\n```\n\n### 拼图区\n在没有全局初始化 `box-sizing` 默认值的时候，有 `border` 属性的样式我都会手动加上 `box-sizing: border-box;` ，使得实际宽度符合预期。\n```CSS\n.check-wrapper {\n  width: 100%;\n  height: 300px;\n  border: 1px solid #8a8a8a;\n  background-repeat: no-repeat;\n  background-size: 100% 100%;\n  position: relative;\n  box-sizing: border-box;\n}\n```\n\n拼图和插槽都使用绝对定位 `absolute`，位置由 js 计算随机生成 `top` 和 `left` 得到：\n```CSS\n.check-box {\n  width: 50px;\n  height: 50px;\n  border: 1px solid #fff;\n  background-image: inherit;\n  background-repeat: inherit;\n  position: absolute;\n  box-sizing: border-box;\n}\n\n.check-target {\n  width: 50px;\n  height: 50px;\n  background: rgba(0, 0, 0, 0.7);\n  border: 1px solid #fff;\n  position: absolute;\n  box-sizing: border-box;\n}\n```\n\n### 拖动区\n```CSS\n.drag-wrapper {\n  width: 100%;\n  height: 50px;\n  background-color: #e3e3e3;\n  margin-top: 10px;\n  position: relative; \n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.drag-tip {\n  font-size: 14px;\n  color: #8a8a8a;\n  user-select: none;\n}\n```\n\n滑块和进度条初始位置在拖动条的最左边，因此设置 `top` 和 `left` 为 0；拖动后的滑块位置通过 js 控制 `transform` 实现，进度条的宽度也将由 js 计算控制：\n```CSS\n.drag-box {\n  width: 50px;\n  height: 100%;\n  background-color: aquamarine;\n  position: absolute;\n  top: 0;\n  left: 0;\n}\n\n.drag-progress {\n  height: 50px;\n  background-color: cadetblue;\n  position: absolute;\n  left: 0;\n  top: 0;\n}\n```\n\n拼图验证失败后，滑块和进度条会平滑回到原点，整个组件出现红色边框并伴随抖动：\n```CSS\n@keyframes move {\n  to {\n    transform: translateX(0);\n  }\n}\n\n@keyframes elongation {\n  to {\n    width: 0px;\n  }\n}\n\n@keyframes failShake {\n  0% {\n    transform: translateX(0px);\n    border: 1px red solid;\n  }\n\n  25% {\n    transform: translateX(5px);\n  }\n\n  50% {\n    transform: translateX(-5px);\n  }\n\n  75% {\n    transform: translateX(5px);\n  }\n\n  100% {\n    transform: translateX(0px);\n  }\n}\n```\n\n`check-wrapper` 和 `drag-wrapper` 都需要设置 `position` 属性，因为他们有绝对定位的子元素，要相对于他们定位。如果没有设置 `position` 属性，子元素会基于最近一个设置了 `position` 属性的节点定位，直到根节点。\n\n![完成样式布局](pics/html\u0026css.png)\n\n## JavaScript 逻辑\n桌面端实现拼图的原理是监听 `mousedown` 、`mousemove` 和 `mouseup` 方法，鼠标点击后移动，计算鼠标移动的距离，然后控制滑块的 `transform: translateX(..px)`，以实现滑动移动的效果。`check-box` 和 `drag-box` 平行同步位移。\n\n验证成功的依据是判断滑块是否与插槽重合，验证成功会调用 `success` 方法，失败调用 `reset` 方法。若失败 3 次以上，将刷新背景图和拼图插槽位置。\n\n需要考虑滑块的边界条件和容差范围。\n\n### 全局变量\n拿到要操作的 Element\n![layout](pics/layout.png)\n```JavaScript\nconst wrapperEl = document.querySelector('.verification')\nconst tipEl = document.querySelector('.tip')\n\n// 拼图区\nconst checkWrapperEl = document.querySelector('.check-wrapper')\nconst checkEl = document.querySelector('.check-box')\nconst targetEl = document.querySelector('.check-target')\n\n// 拖动区\nconst dragEl = document.querySelector('.drag-box')\nconst dragProgressEl = document.querySelector('.drag-progress')\n\n// 结果区\nconst stateEl = document.querySelector('.check-state')\nconst resetButtonEl = document.querySelector('.resetButton') // 校验成功页确认按钮\n```\n\n计算实际渲染的位置和尺寸\n![size](pics/size.png)\n```JavaScript\nconst { width: checkWrapperW } = checkWrapperEl.getBoundingClientRect()\nconst { x: dragX, width: dragW } = dragEl.getBoundingClientRect()\nconst { width: targetW, height: targetH } = targetEl.getBoundingClientRect()\n```\n\n声明一些常量和变量\n```JavaScript\nconst tolerances = 5 // 容差\nlet clickOffsetX = 0 // 鼠标到滑块左边的距离\nlet targetX = 50 // 拼图插槽到页面最左边的距离\nlet failTimes = 0 // 拖动失败次数\n```\n\n### 随机生成背景图和拼图插槽\n随机生成拼图插槽，出现在图中红色区域    \n![拼图插槽位置](pics/randomPosition.png)\n```JavaScript\nconst randomPosition = (wrapperW = 400, wrapperH = 300, w = 50, h = 50) =\u003e {\n  const bleed = w / 2 // 出血\n  const left = Math.random() * (wrapperW - 3 * w) + w + bleed\n  const top = Math.random() * (wrapperH - 2 * h) + bleed\n\n  return [Math.floor(left), Math.floor(top)]\n}\n```\n\n从图片列表中随机选择图片\n```JavaScript\nconst randomImage = (checkWrapperEl) =\u003e {\n  const imageList = [\n    'https://cos.rayjason.cn/images/temp1.png',\n    'https://cos.rayjason.cn/images/temp2.png',\n  ]\n  const index = Math.round(Math.random() * (imageList.length - 1))\n\n  checkWrapperEl.style.backgroundImage = `url(${imageList[index]})`\n}\n```\n\n初始化拼图组件\n```JavaScript\nfunction init(targetEl, checkEl, targetW = 50, targetH = 50) {\n  // 随机生成图片\n  randomImage(checkWrapperEl)\n\n  const { width: cwW, height: cwH } = checkWrapperEl.getBoundingClientRect()\n  // 设置拼图插槽随机位置\n  const [targetLeft, targetTop] = randomPosition(cwW, cwH, targetW, targetH)\n  targetEl.style.left = `${targetLeft}px`\n  targetEl.style.top = `${targetTop}px`\n  checkEl.style.top = `${targetTop}px`\n\n  // 设置拼图背景\n  checkEl.style.backgroundSize = `${cwW}px ${cwH}px`\n  checkEl.style.backgroundPosition = `-${targetLeft}px -${targetTop}px`\n\n  // 初始化一些全局变量\n  targetX = targetEl.getBoundingClientRect().x\n  failTimes = 0\n  dragTimes.innerHTML = ``\n}\n```\n\n### 声明回调方法\n成功通过校验\n```JavaScript\n// 成功通过校验\nconst onButtonClick = (event) =\u003e {\n  reset()\n  init(targetEl, checkEl, targetW, targetH)\n  resetButtonEl.removeEventListener('click', onButtonClick)\n  stateEl.style.display = 'none'\n}\n\nconst success = () =\u003e {\n  stateEl.style.display = 'flex'\n  resetButtonEl.addEventListener('click', onButtonClick)\n}\n```\n\n回到起点，无过渡动画。适用于初始化。\n```JavaScript\nconst reset = () =\u003e {\n  dragEl.style.transform = 'translateX(0px)'\n  checkEl.style.transform = 'translateX(0px)'\n  dragProgressEl.style.width = '0px'\n}\n```\n\n回到起点，有过渡动画。适用于拼图失败后滑块缓动回到起点。\n```JavaScript\nconst animateReset = () =\u003e {\n  // 添加过渡动画\n  wrapperEl.style.animation = 'failShake 0.5s ease-in-out'\n  dragEl.style.animation = 'move 0.5s ease-in-out'\n  checkEl.style.animation = 'move 0.5s ease-in-out'\n  dragProgressEl.style.animation = 'elongation 0.5s ease-in-out'\n\n  // 动画结束回调\n  const animationEnd = () =\u003e {\n    reset()\n\n    // 清除过渡动画\n    wrapperEl.style.animation = ''\n    dragEl.style.animation = ''\n    checkEl.style.animation = ''\n    dragProgressEl.style.animation = ''\n\n    document.removeEventListener('animationend', animationEnd)\n  }\n\n  // 添加监听动画结束\n  document.addEventListener('animationend', animationEnd)\n}\n```\n\n### 声明监听事件回调\n滑块移动的距离需要考虑边界范围\n![计算](pics/calc.png)\n```JavaScript\n// 鼠标按下事件\nconst onDragMouseDown = (event) =\u003e {\n  // 添加鼠标移动事件\n  document.addEventListener('mousemove', onDragMouseMove)\n  // 添加鼠标弹起事件\n  document.addEventListener('mouseup', onDragMouseUP)\n\n  const { offsetX } = event\n  clickOffsetX = offsetX\n}\n\n// 监听鼠标移动事件\nconst onDragMouseMove = (event) =\u003e {\n  const { pageX } = event // 鼠标的 x 坐标\n  const x = pageX - dragX - clickOffsetX // drag 移动的距离\n\n  // 鼠标移出左边界\n  if (x \u003c 0) {\n    if (dragEl.style.transform !== 'translateX(0px)') {\n      dragEl.style.transform = 'translateX(0px)'\n      checkEl.style.transform = 'translateX(0px)'\n      dragProgressEl.style.width = '0px'\n    }\n    return\n  }\n\n  // 鼠标移出右边界\n  const rightBoundary = checkWrapperW - dragW\n  if (x \u003e rightBoundary) {\n    if (dragEl.style.transform !== `translateX(${rightBoundary}px)`) {\n      dragEl.style.transform = `translateX(${rightBoundary}px)`\n      checkEl.style.transform = `translateX(${rightBoundary}px)`\n      dragProgressEl.style.width = `${rightBoundary}px`\n    }\n    return\n  }\n\n  // 修改盒子坐标\n  dragEl.style.transform = `translateX(${x}px)`\n  checkEl.style.transform = `translateX(${x}px)`\n  dragProgressEl.style.width = `${x}px`\n}\n\n// 结束鼠标监听事件\nconst onDragMouseUP = (event) =\u003e {\n  document.removeEventListener('mousemove', onDragMouseMove)\n  document.removeEventListener('mouseup', onDragMouseUP)\n\n  const { pageX } = event\n\n  const passRange = [\n    targetX - tolerances + clickOffsetX,\n    targetX + tolerances + clickOffsetX,\n  ]\n\n  if (pageX \u003e= passRange[0] \u0026\u0026 pageX \u003c= passRange[1]) {\n    success()\n  } else {\n    if (failTimes \u003e 1) {\n      reset()\n      init(targetEl, checkEl, targetW, targetH)\n      return\n    }\n    failTimes++\n    tipEl.innerHTML = `请重试，剩余 ${3 - failTimes} 次机会`\n    animateReset()\n  }\n}\n```\n\n### 执行\n监听滑块的点击事件，只有点击滑块才会触发回调事件。  \n拖动滑块或拼图都能移动滑块位置。\n```JavaScript\nconst main = () =\u003e {\n  init(targetEl, checkEl, targetW, targetH)\n\n  dragEl.addEventListener('mousedown', onDragMouseDown)\n  checkEl.addEventListener('mousedown', onDragMouseDown)\n}\n\nmain()\n```\n\n## 总结\n实现这个组件中需要掌握以下能力：\n- 通过 className 查询节点：`document.querySelector('.className')`\n- 修改节点样式：`dragEl.style.transform = 'translateX(0px)'`\n- 监听按钮事件： `buttonEl.addEventListener('click', onButtonClick)` \n- 移除监听事件： `buttonEl.removeEventListener('click', onButtonClick)` \n- 计算节点的位置和尺寸：`const { x: dragX, width: dragW } = dragEl.getBoundingClientRect()`\n- 了解 `offsetX` / `pageX` / `x` / `clientX` 等属性的区别\n\n现在前端项目的开发很少使用原生去实现了，写这篇文章的目的就是复习一下操作 DOM 和计算页面节点位置的方法。只要掌握了思路，无论什么框架都可以轻松移植。\n\n组件的不足之处就是没有适配移动端，可以通过监听 `touchstart` / `touchmove` / `touchend` 来实现，因为 touch 事件和 mouse 事件返回的属性有出入，我这里暂时没有精力适配了，欢迎有余力的朋友补充。如果文中有错误或可以优化的地方，请在 issue 讨论，感恩！\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frayjason%2Fpuzzlecaptcha","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frayjason%2Fpuzzlecaptcha","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frayjason%2Fpuzzlecaptcha/lists"}