50行代码使用Canvas实现五子棋

前言

最近新开了一个专栏,纯Canvas实现一些经典的小游戏,如五子棋、俄罗斯方块、2048、贪吃蛇等,主打是纯Canvas和简洁的ES6语法。今天我们先从五子棋开始吧!

第一步,绘制棋盘

思路:创建Canvas对象,设置宽高,填充背景色,并将其水平垂直居中于页面正中间,然后绘制15*15的网格线,较为简单,直接上代码。(这里的代码不用细看,简单过一下就行了,都是调用Canvas的API)

细节:如果Canvas没有代码提示,可在创建Canvas对象前加上这个注释/** @type {HTMLCanvasElement} */inset:0left: 0; right: 0; top: 0; bottom: 0的缩写,不兼容IE。

js 复制代码
const SIZE = 15,       // 棋盘15*15=225个点
  W = 50 ,             // 棋盘格子大小
  SL = W * (SIZE + 1); // 边长 = 棋盘宽高

/** @type {HTMLCanvasElement} */
let canvas = document.createElement('canvas'), ctx = canvas.getContext('2d');
canvas.width = canvas.height = SL; // 棋盘宽高 = 边长
canvas.style.cssText = 'position: absolute; inset: 0; margin: auto; cursor: pointer;';
document.body.appendChild(canvas);

// 绘制棋盘(棋盘背景色 && 网格线)
const drawBoard = () => {
  ctx.fillStyle = '#E4A751';
  ctx.fillRect(0, 0, SL, SL);
  for (let i = 0; i < SIZE; i++) {
    drawLine(0, i, SIZE - 1, i);
    drawLine(i, 0, i, SIZE - 1);
  }
}

// 两点连线:(x1, y1) <-> (x2, y2),设置线条宽度lineWidth和颜色LINE_COLOR
const drawLine = (x1, y1, x2, y2, lineWidth = 1, lineColor = '#000000') => {
  ctx.lineWidth = lineWidth;
  ctx.strokeStyle = lineColor;
  ctx.beginPath();
  ctx.moveTo(x1 * W + W, y1 * W + W);
  ctx.lineTo(x2 * W + W, y2 * W + W);
  ctx.stroke();
}

window.onload = drawBoard

第二步,绘制棋子

思路:监听棋盘的点击事件,获取坐标值。若该位置没有棋子,则可落子,另外黑白棋切换。

细节:我们需要将点击位置的e.offsetXe.offsetY转换成相应的坐标值,通过这个坐标值进行棋子的绘制和后续的判断胜负。

js 复制代码
// 记录棋盘的黑白棋,15*15的二维数组,初始值:-1,黑棋:1,白棋:2
let chess = Array.from({ length: SIZE }, () => Array(SIZE).fill(-1)),
  isBlack = true; // 黑棋先下

// 监听棋盘点击位置
canvas.onclick = e => {
  let [x, y] = [e.offsetX, e.offsetY].map(p => Math.round(p / W) - 1);
  if (chess[x][y] !== -1 || x < 0 || x > SIZE - 1 || y < 0 || y > SIZE - 1) return alert('该位置不可落子!');
  drawPiece(x, y, isBlack);
  chess[x][y] = isBlack ? 1 : 2;
  isBlack = !isBlack;
}

// 绘制棋子,x、y为坐标,isBlack判断黑白棋
const drawPiece = (x, y, isBlack) => {
  ctx.beginPath();
  ctx.arc(x * W + W, y * W + W, W * 0.4, 0, 2 * Math.PI);
  ctx.closePath();
  ctx.fillStyle = isBlack ? 'black' : 'white';
  ctx.fill();
}

第三步,判断胜负

核心算法:从落子的位置开始,往横向、纵向、正斜、反斜四个方向判断是否五子连线。

我们先看看横向怎么判断吧! 如上图所示,落子的位置坐标为(x,y),向右一位的坐标为(x+1,y),向右i位的坐标为(x+i,y),向左一位的坐标为(x-1,y),向左j位的坐标为(x-j,y)。因此,我们可以得到的代码如下。

js 复制代码
// 判断横向是否获胜,(先往右查询,再往左查询,判断左右是否有4个和当前落子颜色一样的棋子)
// x, y:当前落子的坐标
// role:当前落子的值,黑1白2
// chess:存储所有坐标对应的值,黑1白2,空的位置-1
const isWin = (x, y, role, chess) => {
  // count计数(落子就有,从1开始算),i为向右查询次数,j为向左查询次数
  let count = 1, i = 0, j = 0;
  // 向右,遇到同色棋子,count++;遇到不同的颜色的棋子、空的位置、或者count=5退出
  while (count < 5 && chess[x + i++][y] === role) count++
  // 向左,遇到同色棋子;count++,遇到不同的颜色的棋子、空的位置、或者count=5退出
  while (count < 5 && chess[x - j++][y] === role) count++
  // 正负两个方向都查询完毕,count的值小于5或刚好等于5,如果等于5,判定为胜,否则为负
  if (count === 5) {
    return true
  }
  return false
}

上面的代码只能专门用来判断横向的,我们现在改写一下,使其可以适配另外3个方向。向左右移动一步时x是加1或减1,可以定义为x变化量dx = 1;向左右移动一步是y始终不变,可以定义为y的变化量为dy = 0。向右设为正方向,正方向走了i步之后:x + dx * i,向左设为负方向,负方向走了j步之后,x - dx * i。y也是同理。在上面的代码基础上改写一下,新代码如下:

js 复制代码
// 判断横向是否获胜,(先往右查询,再往左查询,判断左右是否有4个和当前落子颜色一样的棋子)
// x, y:当前落子的坐标
// role:当前落子的值,黑1白2
// chess:存储所有坐标对应的值(15*15的二维数组),黑1白2,空的位置-1
const isWin = (x, y, role, chess) => {
  // dx、dy为变化量,count计数(落子就有,从1开始算),i为往正方向查询次数,j为负方向查询次数
  let dx  = 1, dy  = 0, count = 1, i = 0, j = 0;
  // 向右(正方向查询),遇到同色棋子,count++;遇到不同的颜色的棋子、空的位置、或者count=5退出
  while (count < 5 && chess[x + dx * i++][y + dy * i] === role) count++
  // 向左(负方向查询),遇到同色棋子,count++;遇到不同的颜色的棋子、空的位置、或者count=5退出
  while (count < 5 && chess[x + dx * j++][y + dy * j] === role) count++
  // 正负两个方向都查询完毕,count的值小于5或刚好等于5,如果等于5,判定为胜,否则为负
  if (count === 5) {
    return true
  }
  return false
}
x - 1, y - 1 x, y - 1 x + 1, y - 1
x - 1, y x, y x + 1, y
x - 1, y + 1 x, y + 1 x + 1, y + 1

根据上面的规律,我们可以得到另外三个方向的变化量的值

竖向 :dx = 0, dy = 1

斜向 :dx = 1, dy = 1

反斜向:dy = 1, dy = -1

四个方向的代码进行合并,如下

js 复制代码
// 判断横向是否获胜,(先往正方向查询,再往负方向查询,判断是否有4个和当前落子颜色一样的棋子)
// x, y:当前落子的坐标
// role:当前落子的值,黑1白2
// chess:存储所有坐标对应的值(15*15的二维数组),黑1白2,空的位置-1
const isWin = (x, y, role, chess) => {
  // dx、dy为变化量,count计数(落子就有,从1开始算),i为往正方向查询次数,j为负方向查询次数
  for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
    let [count, i, j] = [1, 0, 0];
    // 正方向查询,遇到同色棋子,count++;遇到不同的颜色的棋子、空的位置、或者count=5退出
    while(count < 5 && chess[x + dx * ++i][y + dy * i] === role) count++
    // 负方向查询,遇到同色棋子,count++;遇到不同的颜色的棋子、空的位置、或者count=5退出
    while(count < 5 && chess[x - dx * ++j][y - dy * j] === role) count++
    if (count === 5) {
      return true
    }
  }
  return false
}

当然,往各个方向查询的时候,我们也要注意一下边界值

js 复制代码
while(
count < 5 &&
x + dx * ++i >= 0 && x + dx * i < SIZE &&
y + dy * i >= 0 && y + dy * i < SIZE &&
chess[x + dx * i][y + dy * i] === role) count++

显然,这样写很麻烦,那有没有优雅一点的写法呢?当然有!我们可以利用ES6的链判断运算符?.

js 复制代码
// 看一下chess[x]?.[y],x和y变化时,值的情况
// x范围0-14,y的范围0-14,正常值黑1白2空位-1
chess[3]?.[3]   // x正常,y正常,得到正常的值1、2或-1
chess[3]?.[25]  // x正常,y超界,得到异常的值undefined
chess[25]?.[3]  // x超界,y正常,得到异常的值undefined
chess[25]?.[25] // x超界,y超界,得到异常的值undefined
// 额外说明:都不会报错

由上可知,只有在x和y都处于正常范围时,我们才能得到正常的值,否则都是返回undefined,因此我们可以将判断的代码改写如下

js 复制代码
while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++

chess[x + dx * ++i]?.[y + dy * i]有一个坐标超界了,返回undefined,肯定不会等于role,退出循环,刚好可以达到我们的目的。所以最后完整的算法代码如下:

js 复制代码
// 判断是否获胜
const isWin = (x, y, role, chess) => {
  for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
    let count = 1, i = 0, j = 0;
    while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
    while(count < 5 && chess[x - dx * ++j]?.[y - dy * j] === role) count++
    if (count === 5) return true
  }
  return false
}

这就是最核心的算法代码,只用几行就可以很优雅的解决。再补上和棋的代码,五子棋最简单的版本就完成了。最简单版本的完整代码如下:

js 复制代码
const SIZE = 15,              // 棋盘15*15=225个点
  W = 50 ,                    // 棋盘格子大小
  SL = W * (SIZE + 1),        // 边长 = 棋盘宽高
  TOTAL_STEPS = SIZE * SIZE;  // 总步数

/** @type {HTMLCanvasElement} */
let canvas = document.createElement('canvas'), ctx = canvas.getContext('2d');
canvas.width = canvas.height = SL; // 棋盘宽高 = 边长
canvas.style.cssText = 'position: absolute; inset: 0; margin: auto; cursor: pointer;';
document.body.appendChild(canvas);

// 记录棋盘的黑白棋,15*15的二维数组,初始值:-1,黑棋:1,白棋:2
let chess = Array.from({ length: SIZE }, () => Array(SIZE).fill(-1)),
  isBlack = true, // 黑棋先下
  moveSteps = 0; // 下棋步数

// 监听棋盘点击位置
canvas.onclick = e => {
  let [x, y] = [e.offsetX, e.offsetY].map(p => Math.round(p / W) - 1);
  if (chess[x]?.[y] !== -1) return alert('该位置不可落子!');
  drawPiece(x, y, isBlack);
  chess[x][y] = isBlack ? 1 : 2;
  isWin(x, y, chess[x][y], chess) ? alert(`${isBlack ? '黑' : '白'}棋赢了!`) :
    ++moveSteps === TOTAL_STEPS ? alert('游戏结束,平局!') : isBlack = !isBlack;
}

// 绘制棋盘(棋盘背景色 && 网格线)
const drawBoard = () => {
  ctx.fillStyle = '#E4A751';
  ctx.fillRect(0, 0, SL, SL);
  for (let i = 0; i < SIZE; i++) {
    drawLine(0, i, SIZE - 1, i);
    drawLine(i, 0, i, SIZE - 1);
  }
}

// 两点连线:(x1, y1) <-> (x2, y2),设置线条宽度lineWidth和颜色LINE_COLOR
const drawLine = (x1, y1, x2, y2, lineWidth = 1, lineColor = '#000000') => {
  ctx.lineWidth = lineWidth;
  ctx.strokeStyle = lineColor;
  ctx.beginPath();
  ctx.moveTo(x1 * W + W, y1 * W + W);
  ctx.lineTo(x2 * W + W, y2 * W + W);
  ctx.stroke();
}

// 绘制棋子,x、y为坐标,isBlack判断黑白棋
const drawPiece = (x, y, isBlack) => {
  ctx.beginPath();
  ctx.arc(x * W + W, y * W + W, W * 0.4, 0, 2 * Math.PI);
  ctx.closePath();
  ctx.fillStyle = isBlack ? 'black' : 'white';
  ctx.fill();
}

// 判断游戏胜负,(x, y)当前下棋坐标,role:黑1白2,chess:棋盘数据,二维数组,黑1白2空位-1
const isWin = (x, y, role, chess) => {
  for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
    let count = 1, i = 0, j = 0;
    while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
    while(count < 5 && chess[x - dx * ++j]?.[y - dy * j] === role) count++
    if (count === 5) return true
  }
  return false
}

window.onload = drawBoard

去掉注释空行的话,大概只有50行代码,而且在html中不写任何标签和css,即可非常优雅地实现完整的功能。

第四步,美化界面,抽离全局变量

美化1:根据页面宽高,计算出每个格子的宽度,然后让棋盘尽可能铺满页面

js 复制代码
const SIZE = 15, // 棋盘15*15=225个点
  W = Math.min(window.innerWidth, window.innerHeight) / (SIZE + 3) , // 棋盘格子大小
  SL = W * (SIZE + 1), // 边长 = 棋盘宽高

美化2:棋子添加渐变和阴影,使其看起来有一定的立体感

js 复制代码
// 绘制棋子
const drawPiece = (x, y, isBlack) => {
  ctx.save();
  ctx.beginPath();
  x = x * W + W;
  y = y * W + W;
  ctx.arc(x, y, W * 0.4, 0, 2 * Math.PI);
  ctx.closePath();
  ctx.shadowColor = SHADOW_COLOR;
  ctx.shadowOffsetX = ctx.shadowOffsetY = W * 0.06;
  ctx.shadowBlur = W * 0.04;
  const gradient = ctx.createRadialGradient(x, y, 0, x, y, W * 0.4);
  gradient.addColorStop(0, isBlack ? BLACK_CHESS_TOP_COLOR : WHITE_CHESS_TOP_COLOR);
  gradient.addColorStop(1, isBlack ? BLACK_CHESS_COLOR : WHITE_CHESS_COLOR);
  ctx.fillStyle = gradient;
  ctx.fill();
  ctx.restore();
}

第五步,功能扩展

功能1:最后一步棋子标记小红点

js 复制代码
// 清除棋子(标记红点时,要把之前那个棋子涂抹掉,重新绘制)
const clearPiece = (x, y) => {
  ctx.fillStyle = BOARD_BG_COLOR;
  ctx.fillRect(x * W + W / 2, y * W + W / 2, W, W);
  drawLine(x, y > 0 ? y - 0.5 : y, x, y < SIZE - 1 ? y + 0.5 : y);
  drawLine(x > 0 ? x - 0.5 : x, y, x < SIZE - 1 ? x + 0.5 : x, y);
}

// 绘制小红点
const drawRedPoint = (x, y, r = 0.05 * W) => {
  ctx.beginPath();
  ctx.fillStyle = RED_POINT_COLOR;
  ctx.arc(x * W + W, y * W + W, r, 0, 2 * Math.PI);
  ctx.closePath();
  ctx.fill();
}

// 监听棋盘点击位置
canvas.onclick = e => {
  ... 省略无关代码
  if (steps.length > 0) { // 删除上一步的棋子(有红点),重新绘制
    let {x, y, isBlack} = steps.at(-1)
    clearPiece(x, y);
    drawPiece(x, y, isBlack);
  };
  // 绘制新的棋子并标记小红点
  drawPiece(x, y, isBlack);
  drawRedPoint(x, y);
  steps.push({x, y, isBlack})
  ... 省略无关代码
}

功能2:获胜时,将获胜的五个棋子连成一条线

思路:判断胜负的算法中,i代表向正方向走的步数,j代表向负方向走的步数,所以i和j代表的就是查询时停下的位置,正好就是我们想要的点,根据i和j的值,可以获得这两个点的坐标,然后使用我们绘制网格时用到的绘制线段的函数drawLine(两个坐标值)即可。不过,这里有个问题,如下图所示: 出现问题的原因:优先正方向查询,所以i可能出现刚好停下的位置就是最后的位置,也有可能会超出一位(查询到不符合条件的点)。所以期望的i值可能是正确的,也可能超过1。换个思路,负方向是后面再查询的,所以j的值一定是对的,而获胜的时候,i + j = 4,所以可以通过j推断出正确的i值,i = 4 - j。因此,代码如下

js 复制代码
// 判断游戏胜负,(x, y)当前下棋坐标,role:黑1白2,chess:棋盘信息
const isWin = (x, y, role, chess) => {
  for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
    let [count, i, j] = [1, 0, 0];
    while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
    while(count < 5 && chess[x - dx * ++j]?.[y - dy * j] === role) count++
    if (count === 5) {
      i = 4 - j;
      drawLine(x + dx * i, y + dy * i, x - dx * j, y - dy * j, 线段宽度, 线段颜色)
      return true
    }
  }
  return false
}

这里提一个比较偏的语法:逗号操作符(不建议使用!!!)

js 复制代码
// 逗号操作符,依次执行函数f1,函数f2,a = a + 1,返回最后一项的值true
(f1(), f2(), a = a + 1, true)

// 初始代码
if (count === 5) {
  i = 4 - j;
  drawLine(参数)
  return true
}

// 简化
if (count === 5) return (i = 4 - j, drawLine(参数), true)

// 再简化,有return后面的语句就不用括号包着
if (count === 5) return i = 4 - j, drawLine(参数), true

// 特别说明:这种写法尽量不要用,前面的语句会对后面的语句造成影响,风险极大。开源项目装B可用。

还有悔棋的功能,大概实现思路,删掉最后一个棋子,绘制同时被删掉的棋盘线,给前一个棋子标记红点。这里就不再赘述了,有兴趣的可以试着实现。

总结 && 亮点

  1. 将点击位置转换成相应的坐标,无论是绘制、判断胜负等都是使用转换后的坐标
  2. 开发思路及编码过程:START → 绘制棋盘 → 监听棋盘点击 → 绘制棋子 → 判断是否获胜(算法)→ 美化界面 → 抽离全局变量 → 优化代码 → 功能扩展 → 整理代码 → 优化注释 → END
  3. 谨慎使用的偏门语法:逗号操作符:(f1(), f2(), a = a + 1, true)if (count === 5) return i = 4 - j, drawLine(参数), true。平时不要这样写,开源项目可以。
  4. 核心算法,直接上代码,前面很详细了,这里就不再赘述。
js 复制代码
const isWin = (x, y, role, chess) => {
  for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
    let count = 1, i = 0, j = 0;
    while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
    while(count < 5 && chess[x - dx * ++j]?.[y - dy * j] === role) count++
    if (count === 5) return i = 4 - j, drawLine(参数), true
  }
  return false
}

完整代码

简洁、优雅、清爽、自由,这也是JavaScript的魅力所在吧!

js 复制代码
const SIZE = 15, // 棋盘15*15=225个点
  W = Math.min(window.innerWidth, window.innerHeight) / (SIZE + 3) , // 棋盘格子大小
  SL = W * (SIZE + 1), // 边长 = 棋盘宽高
  BOARD_BG_COLOR = '#E4A751', // 棋盘背景颜色
  LINE_WIDTH = 1, // 默认线条宽度
  LINE_COLOR = '#000000', // 棋盘线颜色
  WIN_LINE_WIDTH = 5, // 获胜时连线的宽度
  WIN_LINE_COLOR = '#F05459', // 获胜线颜色
  BLACK_CHESS_COLOR = '#000000', // 黑棋底黑
  BLACK_CHESS_TOP_COLOR = '#707070', // 黑棋顶灰,顶灰过渡到底黑+阴影=立体
  WHITE_CHESS_COLOR = '#D5D8DC',  // 白棋底灰
  WHITE_CHESS_TOP_COLOR = '#FFFFFF', // 白棋顶白,顶白过渡到底灰+阴影=立体
  SHADOW_COLOR = 'rgba(0, 0, 0, 0.5)', // 阴影颜色
  EMPTY_ROLE = -1, // 空位
  BLACK_ROLE = 1, // 黑棋
  WHITE_ROLE = 2, // 白棋
  TOTAL_STEPS = SIZE * SIZE; // 总步数

/** @type {HTMLCanvasElement} */
let canvas = document.createElement('canvas'), ctx = canvas.getContext('2d');
canvas.width = canvas.height = SL; // 棋盘宽高 = 边长
canvas.style.cssText = 'position: absolute; inset: 0; margin: auto;cursor: pointer;';
document.body.appendChild(canvas);

// 记录棋盘的黑白棋,15*15的二维数组,初始值:0,黑棋:1,白棋:2
let chess = Array.from({ length: SIZE }, () => Array(SIZE).fill(EMPTY_ROLE)),
  isBlack = true, // 黑棋先下
  moveSteps = 0; // 下棋步数

// 监听棋盘点击位置
canvas.onclick = e => {
  let [x, y] = [e.offsetX, e.offsetY].map(p => Math.round(p / W) - 1);
  if (chess[x]?.[y] !== EMPTY_ROLE) return;
  drawPiece(x, y, isBlack);
  chess[x][y] = isBlack ? BLACK_ROLE : WHITE_ROLE;
  isWin(x, y, chess[x][y], chess) ? alert(`${isBlack ? '黑' : '白'}棋赢了!`) :
    ++moveSteps === TOTAL_STEPS ? alert('游戏结束,平局!') : isBlack = !isBlack;
}

// 绘制棋盘(棋盘背景色 && 网格线)
const drawBoard = () => {
  ctx.fillStyle = BOARD_BG_COLOR;
  ctx.fillRect(0, 0, SL, SL);
  for (let i = 0; i < SIZE; i++) {
    drawLine(0, i, SIZE - 1, i);
    drawLine(i, 0, i, SIZE - 1);
  }
}

// 两点连线:(x1, y1) - (x2, y2),设置线条宽度lineWidth和颜色LINE_COLOR
const drawLine = (x1, y1, x2, y2, lineWidth = LINE_WIDTH, lineColor = LINE_COLOR) => {
  ctx.lineWidth = lineWidth;
  ctx.strokeStyle = lineColor;
  ctx.beginPath();
  ctx.moveTo(x1 * W + W, y1 * W + W);
  ctx.lineTo(x2 * W + W, y2 * W + W);
  ctx.stroke();
}

// 绘制棋子
const drawPiece = (x, y, isBlack) => {
  ctx.save();
  ctx.beginPath();
  x = x * W + W;
  y = y * W + W;
  ctx.arc(x, y, W * 0.4, 0, 2 * Math.PI);
  ctx.closePath();
  ctx.shadowColor = SHADOW_COLOR;
  ctx.shadowOffsetX = ctx.shadowOffsetY = W * 0.06;
  ctx.shadowBlur = W * 0.04;
  const gradient = ctx.createRadialGradient(x, y, 0, x, y, W * 0.4);
  gradient.addColorStop(0, isBlack ? BLACK_CHESS_TOP_COLOR : WHITE_CHESS_TOP_COLOR);
  gradient.addColorStop(1, isBlack ? BLACK_CHESS_COLOR : WHITE_CHESS_COLOR);
  ctx.fillStyle = gradient;
  ctx.fill();
  ctx.restore();
}

// 判断游戏胜负,(x, y)当前下棋坐标,role:黑1白2,chess:棋盘信息
const isWin = (x, y, role, chess) => {
  for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
    let count = 1, i = 0, j = 0;
    while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
    while(count < 5 && chess[x - dx * ++j]?.[y - dy * j] === role) count++
    if (count === 5) return i = 4 - j, drawLine(x + dx * i, y + dy * i, x - dx * j, y - dy * j, WIN_LINE_WIDTH, WIN_LINE_COLOR), true
  }
  return false
}

window.onload = drawBoard

多说两句

Github:github.com/gaoxiaosi/c...

这是一个系列,纯Canvas实现经典小游戏,目前只完成了五子棋和2048。如果感兴趣的话,可以关注一下,后续会持续更新,谢谢!

相关推荐
程序猿online1 分钟前
nvm安装使用,控制node版本
开发语言·前端·学习
web Rookie11 分钟前
React 中 createContext 和 useContext 的深度应用与优化实战
前端·javascript·react.js
男孩1215 分钟前
react高阶组件及hooks
前端·javascript·react.js
m0_7482517235 分钟前
DataOps驱动数据集成创新:Apache DolphinScheduler & SeaTunnel on Amazon Web Services
前端·apache
珊珊来吃36 分钟前
EXCEL中给某一列数据加上双引号
java·前端·excel
onejason1 小时前
深度解析:利用Python爬虫获取亚马逊商品详情
前端·python
胡西风_foxww1 小时前
【ES6复习笔记】Spread 扩展运算符(8)
前端·笔记·es6·扩展·运算符·spread
小林爱1 小时前
【Compose multiplatform教程08】【组件】Text组件
android·java·前端·ui·前端框架·kotlin·android studio
跨境商城搭建开发2 小时前
一个服务器可以搭建几个网站?搭建一个网站的流程介绍
运维·服务器·前端·vue.js·mysql·npm·php
hhzz2 小时前
vue前端项目中实现电子签名功能(附完整源码)
前端·javascript·vue.js