普通视图

发现新文章,点击刷新页面。
昨天以前首页

JavaScript 趣味编程:从基础循环到函数式,解锁打印三角形的 N 种姿势

2025年9月3日 15:46

JavaScript 趣味编程:从基础循环到函数式,解锁打印三角形的 N 种姿势

对于许多程序员来说,用代码在控制台打印出一个小小的三角形,是学习生涯中一次难忘的“开窍”时刻。它不仅仅是一个简单的练习,更是我们理解循环、逻辑和模式识别的起点。

今天,我们不只满足于实现它,而是要玩出花样。本文将带你从最经典的双重 for 循环,一路走到更现代、更优雅的函数式写法,让你彻底掌握打印三角形的各种“姿势”。

第一站:梦开始的地方 —— 直角三角形

我们的第一个目标是打印出这样的形状(以5行为例):

*
**
***
****
*****
写法一:双重 for 循环——经典永不过时

这是每个初学者都会接触到的方法,它完美地诠释了嵌套循环的魅力。

function printRightTriangleFor(rows) {
  // 外层循环控制行数 (i)
  for (let i = 1; i <= rows; i++) {
    let rowString = '';
    // 内层循环控制每一行打印的星号数量 (j)
    // 第 i 行,就有 i 个星号
    for (let j = 1; j <= i; j++) {
      rowString += '*';
    }
    console.log(rowString);
  }
}

printRightTriangleFor(5);

思路解析: 外层循环 i 像一个“行长”,负责决定当前是第几行。内层循环 j 是一个“工人”,i 告诉它这一行需要多少个 *,它就勤勤恳恳地拼接多少个。简单、直观,逻辑清晰。

写法二:巧用 .repeat() —— 告别内层循环

进入现代 JavaScript 的世界,我们可以用更简洁的方式完成同样的工作。ES6 的 String.prototype.repeat() 方法简直是为此而生。

function printRightTriangleRepeat(rows) {
  for (let i = 1; i <= rows; i++) {
    // 在第 i 行,直接生成一个重复 i 次的 '*' 字符串
    console.log('*'.repeat(i));
  }
}

printRightTriangleRepeat(5);

思路解析: 这种写法的代码量骤减,意图也更加明确。我们不再需要手动去拼接字符串,而是直接告诉 JavaScript:“给我一个包含 i 个星号的字符串”。代码更具可读性,也更“酷”。

写法三:函数式编程——换个“次元”看问题

如果你想让你的代码看起来更“极客”,不妨试试函数式的写法。它不使用显式的循环,而是通过数组方法来生成结果。

function printRightTriangleFunctional(rows) {
  const triangle = Array.from(
    { length: rows },           // 1. 创建一个指定长度的“空”数组
    (_, index) => '*'.repeat(index + 1) // 2. 映射每一项为所需的字符串
  ).join('\n');                 // 3. 用换行符拼接成最终成品

  console.log(triangle);
}

printRightTriangleFunctional(5);

思路解析: 这种方式将问题分解为三步:创建一个代表“行”的数组,将每一“行”映射成对应的星号字符串,最后将所有行合并。这是一种声明式的编程风格,你只关心“做什么”,而不是“怎么做”,代码非常优雅。


第二站:挑战升级 —— 等腰三角形(金字塔)

现在,让我们来点更有挑战性的。打印一个居中的金字塔(以 6 行为例)。

     *
    ***
   *****
  *******
 *********
***********

逻辑分析: 这次,每一行不仅有星号,还有前导空格。通过观察规律,我们发现对于第 i 行(i 从 1 开始):

  • 空格数量 = 总行数 - i
  • 星号数量 = (2 * i) - 1

有了这个公式,我们可以解锁多种实现方式。

姿势一:单循环 + .repeat() (最常用)

这是最直接、清晰的实现方式,将上面的数学公式直接翻译成代码。

function printIsoscelesTriangle(rows) {
  for (let i = 1; i <= rows; i++) {
    // 计算并生成前导空格
    const spaces = ' '.repeat(rows - i);
    // 计算并生成星号
    const stars = '*'.repeat((2 * i) - 1);
    // 拼接并打印
    console.log(spaces + stars);
  }
}

printIsoscelesTriangle(6);
姿势二:函数式 Array.from + map

延续直角三角形的函数式思路,我们同样可以一行代码解决问题。

function pyramidFunctional(n) {
  const rows = Array.from({ length: n }, (_, idx) => {
    const i = idx + 1;
    return ' '.repeat(n - i) + '*'.repeat(2 * i - 1);
  });
  console.log(rows.join('\n'));
}

pyramidFunctional(6);
姿势三:递归生成

对于喜欢挑战的同学,递归也是一个有趣的选择。

function pyramidRecursive(n, i = 1, out = []) {
  if (i > n) {
    console.log(out.join('\n'));
    return;
  }
  out.push(' '.repeat(n - i) + '*'.repeat(2 * i - 1));
  pyramidRecursive(n, i + 1, out);
}

pyramidRecursive(6);
更多姿势一览

JavaScript 的灵活性远不止于此,我们还可以使用 while 循环、reduce 归纳、甚至是 Generator(生成器)来实现同样的效果。

  • while 循环版本:

    function pyramidWhile(n) {
      let i = 1;
      while (i <= n) {
        console.log(' '.repeat(n - i) + '*'.repeat(2 * i - 1));
        i++;
      }
    }
    pyramidWhile(6);
    
  • 生成器 (Generator) 逐行输出:

    function* pyramidGen(n) {
      for (let i = 1; i <= n; i++) {
        yield ' '.repeat(n - i) + '*'.repeat(2 * i - 1);
      }
    }
    for (const line of pyramidGen(6)) {
      console.log(line);
    }
    

最终站:封装归纳 —— 打造一个通用的图形打印机

作为一名优秀的开发者,我们追求的是代码的复用性。让我们把上面的逻辑封装成一个更通用的函数,让它能打印不同类型、不同行数、甚至不同字符的图形。

/**
 * 打印各种形状的函数
 * @param {number} rows - 总行数
 * @param {string} shape - 'right', 'inverted-right', 'isosceles'
 * @param {string} char - 用于打印的字符,默认为 '*'
 */
function printShape(rows, shape = 'right', char = '*') {
  console.log(`\n--- 打印 ${rows} 行的 ${shape} 三角形 (字符: ${char}) ---`);
  
  switch (shape) {
    case 'right':
      for (let i = 1; i <= rows; i++) console.log(char.repeat(i));
      break;
    case 'inverted-right': // 新增倒立直角
      for (let i = rows; i >= 1; i--) console.log(char.repeat(i));
      break;
    case 'isosceles':
      for (let i = 1; i <= rows; i++) {
        const spaces = ' '.repeat(rows - i);
        const chars = char.repeat((2 * i) - 1);
        console.log(spaces + chars);
      }
      break;
    default:
      console.log('抱歉,我还不会打印这种形状。');
  }
}

// 让我们来试试这个强大的打印机!
printShape(7, 'right', '#');
printShape(6, 'isosceles', '+');
printShape(5, 'inverted-right'); // 打印倒立直角三角形

总结

从一个简单的打印三角形问题,我们回顾了经典的循环逻辑,学习了现代 JavaScript 的简洁语法,体验了函数式编程的优雅,并最终将其封装成一个可复用的工具函数。

这正是编程的乐趣所在:同一个问题,总有更多、更好、更有趣的解法等待我们去发现。

现在,轮到你了!你还能想到哪些有趣的写法?或者,你能挑战一下打印一个菱形或者空心三角形吗?

CSS 三角形绘制终极指南:从 border 到 clip-path

2025年9月2日 19:33

CSS 三角形绘制方法:方法对比和实用技巧的详细解析

在网页设计中,无需依赖图片,仅使用 CSS 即可创建三角形,这已成为一项重要的前端开发技巧。这些由 CSS 生成的三角形在创建工具提示、下拉菜单箭头以及各种用户界面元素中扮演着不可或缺的角色。以下将对创建 CSS 三角形的几种主流方法进行详尽的对比,并提供一系列实用技巧,以助您更灵活地在项目中使用它们。

三角形绘制

svg绘制

等边三角形绘制

方法对比

方法 优点 缺点 兼容性
Border (边框) 兼容性极佳:这是最古老也是最可靠的方法,能够兼容包括 IE6 在内的几乎所有浏览器。 代码简单:通过设置元素的零宽高和透明边框,只需几行代码就能轻松实现。 灵活性有限:难以创建非等腰或不等边三角形等不规则形状。 同时,为这种三角形添加边框或阴影效果也比较复杂。 IE6+
Clip-path (剪切路径) 高度灵活:可以创建任意形状的多边形,包括各种角度的三角形,甚至更复杂的图形。 代码清晰:直接定义裁剪路径,语义更明确。同时支持背景渐变和 filter: drop-shadow() 添加阴影。 兼容性问题:这是一个较新的 CSS 属性,不支持 Internet Explorer 和部分旧版浏览器。 主流现代浏览器
Transform (变换) 动态效果:结合 transform: rotate() 可以轻松创建旋转的三角形,非常适合用于动画效果。 可与其他方法结合:通常与 clip-path 或伪元素结合使用,以实现更丰富的视觉效果。 非独立方法:需要配合其他技术(如 overflow: hidden)来隐藏不需要的部分,实现起来可能稍显复杂。 IE9+
伪元素 (::before / ::after) 不增加额外 HTML 元素:可以在不污染 HTML 结构的情况下添加三角形,非常适合用于创建提示框的小箭头等装饰性元素。 易于定位:结合 position 属性可以精确地将三角形定位在元素的任何位置。 实现略繁琐:相较于 border 方法,代码量会稍多一些。 IE8+
SVG (可缩放矢量图形) 可访问性和语义化:SVG 元素是真实的内容,可以被屏幕阅读器识别,具有更好的可访问性。 强大的控制力:可以轻松实现描边、填充、渐变和滤镜等复杂效果。 增加了代码复杂性:直接在 HTML 中嵌入 SVG 代码会增加文档的复杂度。 支持 SVG 的浏览器

实用技巧

  1. 调整大小和角度

    • border 方法:通过改变 border-width 的值来直接控制三角形的大小。 调整不同方向 border-width 的数值可以创建出不同角度和比例的三角形。
    • clip-path 方法:通过修改 polygon() 函数中的坐标百分比,可以精确地控制三角形的顶点位置,从而改变其大小和角度。
  2. 创建响应式三角形

    • 为了让三角形能够根据视口大小自适应,推荐使用相对单位,如 vw (视口宽度), vh (视口高度), em (相对于父元素的字体大小), 或 % (百分比)。
  3. 添加阴影效果

    • 对于使用 border 方法创建的三角形,添加阴影较为困难,因为 box-shadow 会作用于整个元素(一个零宽高的点),而不是三角形本身。
    • 对于使用 clip-path 创建的三角形,可以非常方便地使用 filter: drop-shadow() 来添加逼真的阴影效果,这个滤镜会作用于裁剪后的可见形状。
  4. 实现渐变和背景填充

    • border 方法无法直接实现渐变填充。
    • clip-path 方法的一大优势是它仅仅作为元素的裁剪蒙版,因此可以直接为元素设置 background-image: linear-gradient() 或其他背景样式来实现渐变或图片填充的三角形。
  5. 创建带边框的三角形

    • 要为 border 方法创建的三角形添加边框,一个常见的技巧是使用两个堆叠的三角形。底部的三角形尺寸稍大,颜色作为边框色;上方的三角形尺寸稍小,颜色作为填充色,覆盖在底部三角形之上。
    • 对于 transform 和伪元素结合的方法,可以通过为伪元素设置 border 属性来实现。

通过深入理解这些方法的优缺点和实用技巧,您可以根据项目的具体需求和浏览器兼容性要求,选择最合适的方式来创建和美化您的 CSS 三角形。

Restful API:互联网软件架构的设计风格

2025年9月1日 16:01

Restful API:互联网软件架构的设计风格

Restful API 是一种针对网络应用程序的设计风格和开发方式,它并非一个强制的标准,而是一套设计原则和规范。 当一个架构符合 REST 的约束条件和原则时,我们称之为 RESTful 架构。 这种架构风格旨在简化不同软件/程序在网络(例如互联网)中的信息传递。 目前,它已成为 Web 应用程序最流行的 API 设计风格之一。


1. API (Application Programming Interface) - 应用程序编程接口

API,即应用程序编程接口,它定义了不同软件系统之间进行通信必须遵循的规则。 开发者通过创建 API,使得他们的应用程序可以与其他应用程序以编程方式进行通信。 我们可以将 Web API 想象成客户端和网络资源之间的一扇大门。 例如,一个天气预报网站可以通过 API 将天气数据提供给其他程序(如手机上的天气应用)使用。


2. Rest (Representational State Transfer) - 表现层状态转化

REST 的全称是 Representational State Transfer,中文通常翻译为“表现层状态转化”或“表述性状态转移”。 这个术语由 Roy Fielding 在 2000 年的博士论文中首次提出,他也是 HTTP 协议的主要编写者之一。 REST 隐藏的理念是更好地利用 Web 现有的特征和能力,遵循现有 Web 标准中的准则和约束。

要深入理解 REST,我们需要拆解它的三个核心概念:资源 (Resource)表现层 (Representational)状态转化 (State Transfer)

1. Resource - 资源

在 REST 架构中,所有的一切都被视为“资源”。 资源可以是任何有价值的信息或实体,例如一个文档、一张图片、一段视频,或者一个用户对象等。

  • URI (Uniform Resource Identifier) - 统一资源标识符: 每个资源在网络上都有一个唯一的标识符,这个标识符就是 URI。 URI 是一个用于标识某一互联网资源名称的字符串。 它的主要目的是唯一地标识一个资源,但不一定需要指明如何定位或获取这个资源。

  • URL (Uniform Resource Locator) - 统一资源定位符: URL 是 URI 的一个子集,它不仅标识了一个资源,还提供了找到该资源的方法(即定位)。 一个典型的 URL 包含协议(如 HTTP)、服务器地址和资源路径。 在 REST 服务中,服务器通常使用 URL 来识别资源。

    URI 与 URL 的关系可以理解为: 所有的 URL 都是 URI,但并非所有的 URI 都是 URL。 简单来说,URI 像是给资源起了一个唯一的名字,而 URL 则像是给出了这个资源的详细地址。

设计原则: 在 RESTful API 的设计中,URI 应该只表示资源的名词,而不应包含动词。 并且,推荐使用复数形式来表示资源的集合。

  • 推荐的 URI 示例: /users, /users/123
  • 不推荐的 URI 示例: /getAllUsers, /createUser

2. Representational - 表现层

“表现层”指的是资源呈现给客户端的具体形式。 资源本身和它在客户端与服务器之间传输的表示是相互独立的。 例如,服务器内部可能以数据库记录的形式存储用户信息,但在响应客户端请求时,可以将其表现为 JSON、XML 或 HTML 等格式。

客户端和服务器通过 HTTP 的 Content-TypeAccept 头字段来进行内容协商,以确定使用哪种数据格式进行通信。

  • Content-Type:定义了请求发送的数据格式。
  • Accept:定义了客户端期望接收的响应格式列表。

目前,JSON 是 RESTful API 中最常用的数据交换格式。

3. State Transfer - 状态转化

“状态转化”指的是客户端通过对资源执行操作,来改变资源的状态。 在 REST 中,这种操作是通过标准的 HTTP 方法(也称为 HTTP 动词)来实现的。

核心原则:无状态 (Stateless) 这是 REST 架构的一个核心原则。 无状态意味着服务器不会保存客户端的会话状态信息。 从客户端到服务器的每个请求都必须包含理解和处理该请求所需的所有信息。 这样做的好处是可以提高系统的可伸缩性和可靠性,因为任何服务器都可以处理任何请求。

  • REST 的核心是:通过 HTTP 动词(方法)对资源状态进行转移

  • 常用 HTTP 方法及语义:

    • GET —— 获取资源
    • POST —— 新建资源
    • PUT —— 更新资源(整体更新)
    • PATCH —— 局部更新资源
    • DELETE —— 删除资源

例如:

  • GET /users → 获取用户列表
  • POST /users → 创建新用户
  • GET /users/123 → 获取 id=123 的用户
  • PUT /users/123 → 更新 id=123 的用户(整体覆盖更新)
  • DELETE /users/123 → 删除 id=123 的用户

通过将这些动词与代表资源的 URI 结合,客户端就可以对服务器上的资源进行操作,从而实现“状态转化”。例如,向 /users 这个 URI 发送一个 POST 请求,就代表着要创建一个新用户,这就会导致服务器上资源状态的改变。 好的,我们来对这份详细的 Restful API 设计规范进行扩充和深化,使其内容更丰富、更具实践指导意义。

Restful API 具体设计规范

一份良好设计的 Restful API 能够让开发者轻松理解和使用,以下是各项规范的详细说明。


1、协议 (Protocol)

始终使用 HTTPS 协议。

安全性是 API 设计的基石。HTTPS 协议通过 SSL/TLS 对 HTTP 通信进行加密,能有效防止数据在传输过程中被窃取或篡改,这对于保护用户凭证、API 密钥、令牌以及其他敏感数据至关重要。 在生产环境中,应强制所有 API 请求都通过 HTTPS 进行,并将来自 HTTP 的请求重定向到 HTTPS。


2、域名 (Domain)

API 的入口点应该清晰易辨。通常有两种主流的做法:

  • 使用子域名 (Subdomain): https://api.kaivon.com

    • 优点: 这是更清晰和专业的做法。它将 API 服务与主网站服务在逻辑上完全分开,便于进行独立的部署、扩展和安全管理。
    • 缺点: 可能需要额外的 DNS 配置和 SSL 证书管理。
  • 使用路径 (Path): https://www.kaivon.com/api/

    • 优点: 配置简单,不需要额外的 DNS 设置。
    • 缺点: API 和主应用部署在一起,耦合度较高,不利于独立扩展。

推荐做法: 对于正式的、对外提供服务的 API,优先选择子域名的方式。


3、版本 (Versioning)

当 API 需要进行不兼容的重大更新时,版本控制是必不可少的,它可以确保旧版本的客户端应用不会因为 API 的变更而中断服务。 有多种流行的版本控制方法:

  • 在 URI 路径中加入版本号 (推荐): https://api.kaivon.com/v1/

    • 优点: 非常直观和明确,开发者在浏览器或日志中一眼就能看出使用的是哪个版本的 API。 这是最常见和易于理解的方式。
    • 缺点: 有人认为这不够“纯粹”,因为它改变了资源的 URI。
  • 通过查询参数 (Query Parameter): https://api.kaivon.com/blogs?version=1

    • 优点: 实现简单。
    • 缺点: 版本信息不够突出,容易被忽略,且可能使 URL 变得混乱。
  • 通过自定义请求头 (Custom Header): 在 HTTP Header 中添加如 Accept-Version: v1 这样的字段。

    • 优点: 保持 URI 的纯净,不包含版本信息。
    • 缺点: 对客户端开发者不够直观,需要通过工具才能查看和设置请求头。

最佳实践: 将版本号放在 URI 路径中是最清晰、最被广泛接受的做法。


4、路径 (Path) / 端点 (Endpoint)

路径应该用来表示资源 (Resource),并且总是使用名词而不是动词。

  • 使用复数名词: 建议统一使用复数形式来表示资源集合,这样能保持 API 的一致性。 例如,使用 /blogs 而不是 /blog
  • 示例:
    • 获取所有文章: https://api.kaivon.com/v1/blogs
    • 获取 ID 为 123 的特定文章: https://api.kaivon.com/v1/blogs/123
  • 关联资源: 对于有关联关系的资源,可以通过层级路径来表示。例如,获取某篇文章下的所有评论:
    • GET /blogs/123/comments

5、HTTP 方法 (HTTP Methods)

使用标准的 HTTP 方法来表示对资源执行的操作 (Action)

  • GET (SELECT): 获取资源
    • GET /blogs: 获取所有文章的列表。
    • GET /blogs/123: 获取 ID 为 123 的单篇文章。
  • POST (CREATE): 新增资源
    • POST /blogs: 创建一篇新文章。请求体 (body) 中包含新文章的数据。
  • PUT (UPDATE): 完整更新资源
    • PUT /blogs/123: 完整替换 ID 为 123 的文章。请求体需要包含该文章的所有属性。
  • PATCH (UPDATE): 部分更新资源
    • PATCH /blogs/123: 只更新 ID 为 123 的文章的部分属性。例如,只修改文章的标题。请求体只需包含要更改的字段。
  • DELETE (DELETE): 删除资源
    • DELETE /blogs/123: 删除 ID 为 123 的文章。

6、数据过滤、排序和分页 (Filtering, Sorting & Pagination)

对于返回资源集合的请求 (如 GET /blogs),应该提供机制来筛选、排序和分页,以避免一次性返回大量数据,造成服务器和客户端的压力。 这些操作通过查询参数 (Query Parameters) 实现。

  • 过滤 (Filtering): 允许客户端根据字段值筛选结果。

    • GET /blogs?state=published: 获取所有状态为“已发布”的文章。
    • GET /blogs?author_id=5: 获取 ID 为 5 的作者的所有文章。
  • 排序 (Sorting): 允许客户端指定返回结果的排序方式。

    • GET /blogs?sort=-published_at: 按发布时间降序排列 (- 通常表示降序)。
    • GET /blogs?sort=title: 按标题升序排列。
  • 分页 (Pagination): 当数据量很大时,分批次返回数据。

    • 基于偏移量 (Offset-based):
      • ?limit=10&offset=20: 返回从第 21 条记录开始的 10 条数据。这是最简单的方式。
    • 基于页码 (Page-based):
      • ?page=3&per_page=10: 返回第 3 页的数据,每页 10 条。对用户更友好。
    • 基于游标 (Cursor-based):
      • ?limit=10&after_cursor=xxxxx: 返回指定游标之后的数据。这种方式在频繁更新的数据集上性能更稳定。

综合示例: GET /blogs?state=published&sort=-published_at&page=2&per_page=10 这个请求的含义是:获取已发布文章的列表,按发布时间倒序排列,并返回第二页的结果,每页显示 10 篇。


7、状态码 (Status Codes)

HTTP 状态码清晰地表明了请求的结果,客户端需要根据状态码来判断如何处理响应。

  • 2xx: 成功 (Success)

    • 200 OK: 请求成功。适用于 GET、PUT、PATCH 请求。
    • 201 Created: 资源创建成功。适用于 POST 请求。
    • 204 No Content: 请求成功,但响应体中没有内容。适用于 DELETE 请求。
  • 4xx: 客户端错误 (Client Error)

    • 400 Bad Request: 请求无效,例如请求参数格式错误或缺失。
    • 401 Unauthorized: 未经授权,需要用户进行身份验证。
    • 403 Forbidden: 服务器理解请求,但拒绝执行。用户可能没有访问该资源的权限。
    • 404 Not Found: 请求的资源不存在。
  • 5xx: 服务器错误 (Server Error)

    • 500 Internal Server Error: 服务器内部发生了未知错误。

8、返回结果 (Response Body)

  • 统一数据结构: 建议所有响应都遵循一个统一的结构,将实际数据包装在一个 data 字段中。这为将来添加元数据(如分页信息)提供了便利。

    {
      "data": {
        "id": 123,
        "title": "My First Blog",
        "content": "..."
      }
    }
    
  • 根据方法返回不同内容:

    • GET: 对于集合,data 字段是一个数组;对于单个资源,data 是一个对象。
    • POST: 返回 201 Created 状态码,并在 data 字段中包含新创建的资源对象。
    • PUT / PATCH: 返回 200 OK 状态码,并在 data 字段中包含更新后的完整资源对象。
    • DELETE: 返回 204 No Content 状态码,响应体为空。
  • 错误信息: 对于失败的请求 (4xx, 5xx),响应体应包含清晰的错误信息。

    {
      "error": {
        "code": "INVALID_PARAMETER",
        "message": "The 'title' field is required."
      }
    }
    

9、返回的数据格式 (Data Format)

始终使用 JSON (JavaScript Object Notation)。

JSON 是目前 Web API 的事实标准。它比 XML 更轻量、更易于人类阅读,并且能被几乎所有现代编程语言轻松解析。


总结

一个设计良好的 RESTful API 应该遵循以下三个核心原则:

  1. 看 URL 就知道要操作什么资源 (What): 路径清晰地指向一个或一组名词资源。
  2. 看 HTTP Method 就知道要干什么 (How): HTTP 动词明确地定义了对资源的操作。
  3. 看 HTTP Status Code 就知道结果如何 (Result): 状态码准确地反馈了请求的最终状态。

JavaScript奇技淫巧:利用Cookie实现一个可记忆位置的拖拽小方块

2025年9月1日 15:14

JavaScript奇技淫巧:利用Cookie实现一个可记忆位置的拖拽小方块

在 Web 开发中,我们常常需要“记住”用户的一些状态或偏好,比如网站的主题颜色、一个可拖动窗口的位置,或者一个折叠面板是否展开。这些看似微小的功能,却能极大地提升用户体验。今天,我们就来探讨实现这一“记忆”功能的经典技术——Cookie,并亲手打造一个能记住自己位置的拖拽元素。

Part 1: 揭开 Cookie 的神秘面纱

在动手之前,我们必须先理解 Cookie 是什么,以及它为何存在。

为什么需要 Cookie?

HTTP 协议本身是无状态(Stateless)的。这意味着服务器不会记住你上一次的请求。你刷新一下页面,服务器就“忘了”你是谁。为了解决这个问题,Cookie 应运而生。它就像是浏览器随身携带的一张“身份证”,每次访问一个网站时,都会自动带上这张身份证,服务器就能通过它认出你。

简单来说,Cookie 就是一小段存储在用户浏览器上的文本数据,它与特定的域名绑定。

Cookie 的核心属性

通过 document.cookie,我们可以在客户端用 JavaScript 来读写 Cookie。但它的操作方式有些“古怪”,更像是在追加字符串,而不是操作一个对象。一个完整的 Cookie 字符串不仅仅是 key=value,它还可以包含多个属性,用分号隔开,来控制其行为。

下面是 Cookie 的核心属性详解:

  1. name=value (键值对) 这是 Cookie 的主体。name 是唯一的标识符。

    // 设置一个最简单的 cookie
    document.cookie = 'username=kaivon';
    // 注意:再次赋值是添加或覆盖,而不是替换整个 cookie 字符串
    document.cookie = 'skin=blue'; 
    // 此时 document.cookie 的值可能是 "username=kaivon; skin=blue"
    
  2. expires (过期时间) 设置一个具体的 GMT 格式的日期和时间。一旦过了这个时间点,浏览器就会自动删除这个 Cookie。

    // 设置一个在2030年1月1日过期的 cookie
    document.cookie = 'user_id=123; expires=' + new Date('2030-01-01').toUTCString();
    
  3. max-age (有效期) 一个更现代的属性,用来设置 Cookie 从创建开始可以存活的秒数

    • 正数:表示存活的秒数。
    • 0:立即删除该 Cookie。
    • 负数或不设置:表示这是一个“会话 Cookie”,当浏览器窗口关闭时,它就会被删除。
    // 设置一个有效期为1周的 cookie
    document.cookie = 'token=xyz; max-age=' + (60 * 60 * 24 * 7);
    
  4. domain (有效域) 指定了哪些域名可以访问这个 Cookie。默认情况下,Cookie 只属于创建它的那个域名。

    // 这个 cookie 只有在 a.example.com 及其子域名(如 b.a.example.com)下有效
    document.cookie = 'data=something; domain=a.example.com';
    
  5. path (有效路径) 指定了域名下哪个路径可以访问 Cookie。默认是 /,即整个域名下都有效。

    // 这个 cookie 只有在 /docs 及其子路径下才会被发送
    document.cookie = 'doc_id=456; path=/docs';
    
  6. Secure 一个布尔标记。如果带上 Secure 属性,那么这个 Cookie 只有在通过 HTTPS 协议请求时才会被发送到服务器。

  7. HttpOnly 一个布尔标记。如果带上 HttpOnly 属性,那么这个 Cookie 将无法通过 JavaScript (document.cookie) 访问。这是一个重要的安全措施,可以有效防止跨站脚本(XSS)攻击者窃取用户的 Cookie。

  8. SameSite 用于防止跨站请求伪造(CSRF)攻击。它可以设置为 Strict, Lax, 或 None,用来控制 Cookie 是否应该在跨域请求中被发送。

小结:原生操作 document.cookie 非常繁琐且容易出错,尤其是在读取和解析特定值时。因此,在实际项目中,我们通常会封装一个 Cookie 管理工具。

Part 2: 实战!打造一个“有记忆”的拖拽元素

理论讲完了,让我们来点有趣的。我们将创建一个可以随意拖动的 <div> 小方块,并利用 Cookie 让它在刷新页面后,依然能“记得”自己上次被拖放的位置。

image.png

1. HTML 和 CSS 结构

首先,我们需要一个简单的 HTML 结构和一些 CSS 样式来定义我们的小方块。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Draggable Box with Memory</title>
    <style>
        body {
            height: 100vh;
            margin: 0;
            overflow: hidden; /* 防止拖动时出现滚动条 */
        }
        #box {
            width: 100px;
            height: 100px;
            position: absolute;
            left: 100px; /* 初始位置 */
            top: 100px;
            background: #409EFF;
            cursor: move;
            user-select: none; /* 防止拖动时选中文本 */
            color: white;
            display: flex;
            justify-content: center;
            align-items: center;
            font-family: sans-serif;
        }
    </style>
</head>
<body>
    <div id="box">Drag Me!</div>
    <script>
        // JavaScript 代码将放在这里
    </script>
</body>
</html>

2. 封装一个更优雅的 Cookie 管理器

为了避免直接操作繁琐的 document.cookie 字符串,我们先创建一个 CookieManager 类来简化操作。

class CookieManager {
    set(name, value, maxAgeSeconds = 3600 * 24 * 7) { // 默认保存7天
        // 使用 encodeURIComponent 对 value 进行编码,防止特殊字符导致 cookie 格式错误
        const encodedValue = encodeURIComponent(value);
        document.cookie = `${name}=${encodedValue}; max-age=${maxAgeSeconds}; path=/`;
    }

    get(name) {
        // 将 cookie 字符串分割成数组
        const cookies = document.cookie.split('; ');
        for (const cookie of cookies) {
            // 分割键和值
            const [key, value] = cookie.split('=');
            if (key.trim() === name) {
                // 找到后,解码并返回
                return decodeURIComponent(value);
            }
        }
        return null; // 未找到则返回 null
    }

    remove(name) {
        // 通过将 max-age 设置为 0 来删除 cookie
        this.set(name, '', 0);
    }
}

这个管理器提供了清晰的 set, get, remove 方法,并且自动处理了值的编解码,非常方便。

3. 实现拖拽逻辑 Draggable

现在是核心部分。我们将创建一个 Draggable 类,它负责处理所有的拖拽逻辑和 Cookie 存储。

class Draggable {
    constructor(element) {
        if (!element) return;
        this.dom = element;
        this.cookie = new CookieManager();
        this.init();
    }

    init() {
        // **记忆功能核心**:尝试从 Cookie 读取上次保存的位置
        const left = this.cookie.get('boxLeft');
        const top = this.cookie.get('boxTop');

        // 如果 Cookie 中有值,就应用它
        if (left !== null && top !== null) {
            this.dom.style.left = `${left}px`;
            this.dom.style.top = `${top}px`;
        }

        // 绑定鼠标按下事件,启动拖拽
        this.dom.onmousedown = this.mouseDown.bind(this);
    }

    mouseDown(e) {
        // 1. 计算鼠标指针与元素左上角的偏移量
        this.disX = e.clientX - this.dom.offsetLeft;
        this.disY = e.clientY - this.dom.offsetTop;

        // 2. 为了避免鼠标移动过快飞出元素导致事件丢失,
        //    将 mousemove 和 mouseup 事件绑定到 document 上
        this.onMouseMove = this.mouseMove.bind(this);
        this.onMouseUp = this.mouseUp.bind(this);

        document.addEventListener('mousemove', this.onMouseMove);
        document.addEventListener('mouseup', this.onMouseUp);
    }

    mouseMove(e) {
        // 3. 根据鼠标的实时位置和偏移量,计算出元素的新位置
        const newLeft = e.clientX - this.disX;
        const newTop = e.clientY - this.disY;

        this.dom.style.left = `${newLeft}px`;
        this.dom.style.top = `${newTop}px`;
    }

    mouseUp() {
        // 4. 鼠标抬起,拖拽结束
        // 解绑 document 上的事件,防止不必要的性能消耗
        document.removeEventListener('mousemove', this.onMouseMove);
        document.removeEventListener('mouseup', this.onMouseUp);

        // **记忆功能核心**:将当前元素的最终位置存入 Cookie
        this.cookie.set('boxLeft', this.dom.offsetLeft);
        this.cookie.set('boxTop', this.dom.offsetTop);
    }
}

4. 启动!

最后,我们只需要获取 DOM 元素并实例化 Draggable 类即可。

// 在 script 标签的末尾添加:
const box = document.getElementById('box');
new Draggable(box);

现在,打开你的 HTML 文件,随意拖动那个蓝色的小方块,然后刷新页面。你会惊喜地发现,它完美地停留在了你上次松开鼠标的位置!

完整代码

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<title></title>
<style>
#box {
width: 100px;
height: 100px;
position: absolute;
left: 100px;
top: 100px;
background: green;
}
</style>
</head>

<body>
<div id="box"></div>
<script>
// 更健壮的 Cookie 管理器
class CookieManager {
set(name, value, maxAgeSeconds) {
const encodedValue = encodeURIComponent(value);
document.cookie = `${name}=${encodedValue}; max-age=${maxAgeSeconds}; path=/`;
}

get(name) {
const cookies = document.cookie.split('; ');
for (const cookie of cookies) {
const [key, value] = cookie.split('=');
if (key.trim() === name) {
return decodeURIComponent(value);
}
}
return null; // 返回 null 表示未找到
}

remove(name) {
this.set(name, '', 0);
}
}

// 使用 class 封装拖拽逻辑
class Draggable {
constructor(element) {
if (!element) return;
this.dom = element;
this.cookie = new CookieManager();
this.init();
}

init() {
// 从 Cookie 或计算样式中恢复位置
const left = this.cookie.get('boxLeft');
const top = this.cookie.get('boxTop');

if (left !== null && top !== null) {
this.dom.style.left = `${left}px`;
this.dom.style.top = `${top}px`;
}

this.dom.onmousedown = this.mouseDown.bind(this);
}

mouseDown(e) {
this.disX = e.clientX - this.dom.offsetLeft;
this.disY = e.clientY - this.dom.offsetTop;

// 绑定到 document 上,并保存引用以便移除
this.onMouseMove = this.mouseMove.bind(this);
this.onMouseUp = this.mouseUp.bind(this);

document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
}

mouseMove(e) {
const newLeft = e.clientX - this.disX;
const newTop = e.clientY - this.disY;

// 使用 transform 提升性能
// this.dom.style.transform = `translate(${newLeft - this.dom.offsetLeft}px, ${newTop - this.dom.offsetTop}px)`;
// 如果仍要用 left/top,则用下面这行
this.dom.style.left = `${newLeft}px`;
this.dom.style.top = `${newTop}px`;
}

mouseUp() {
// 解绑事件
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);

// 每次都保存最新的位置,解决了“只点击不移动”的 bug
this.cookie.set('boxLeft', this.dom.offsetLeft, 3600 * 24 * 7); // 保存7天
this.cookie.set('boxTop', this.dom.offsetTop, 3600 * 24 * 7);

}
}

// 启动
const box = document.getElementById('box');
new Draggable(box);
</script>
</body>

</html>

总结与思考

通过这个小项目,我们不仅复习了 Cookie 的基础知识,还通过一个实用的 CookieManager 类学会了如何优雅地管理它。更重要的是,我们亲手实现了一个结合 DOM 事件和数据持久化的有趣功能。

我们可以更进一步吗? 当然!

  • 替代方案:对于这种纯客户端的状态存储,localStorage 是一个更现代、API 更友好、存储容量也更大的选择(通常为 5MB)。它不会像 Cookie 那样在每次 HTTP 请求中都发送到服务器,因此更适合这种场景。你可以尝试用 localStorage.setItemlocalStorage.getItem 来替换 CookieManager 的逻辑。
  • 性能优化:在 mouseMove 事件中,频繁修改 lefttop 会导致浏览器不断重绘(Repaint)和回流(Reflow)。对于追求极致性能的动画,使用 transform: translate(x, y) 会有更好的表现,因为它通常能利用 GPU 加速。

希望这篇从理论到实践的文章,能帮助你更深入地理解 Cookie,并激发你创造更多有趣的用户体验!

❌
❌