Skip to content

产品笔记

统计信息:字数 24724 阅读50分钟

原始笔记链接:https://cloud.seatable.cn/dtable/external-links/59b453a8639945478de2/

0187 表单长度设计原则

如果是一个 toC 的产品,设计一个调查问卷或者表单,表单项最好不要太长。如果一个表单需要用户填写消耗5分钟以上,大部分用户都会没有耐心。如果是必填的项,那么用户可能随便填写,这样调查就失去了正确性。

所以需要根据用户的水平,确定表单的长度和深度,尽量让用户做选择题,而不是让用户做填空题,而且这样更便于用户填写,便于最后数据分析整理等。

如果表单确实很长,那么可以尝试分页显示,然后顶部显示进度条,表示用户填写了多少表单项。

界面加载过程类似:尽量减少加载时间,时间小于3S,界面显示 loading 图标。加载时间超过 5秒,最好显示进度条。如果已经确定加载的框架,只是需要后台返回数据,那么可以先显示骨架屏。

具体参考:

https://developers.weixin.qq.com/miniprogram/dev/devtools/skeleton.html

0342 简单设计一个后台管理系统

如何做后台管理系统?

可以参考实际项目和小说项目进行说明

功能层面

  • 用户管理:用户的增删改查
  • 群组管理:某个群组对应的用户和权限(ToB 和 C的区分)
  • 权限管理:权限增删改查
  • 通知管理:新增全局通知,删除全局通知
  • 产品管理(表格,小说):具体说某个产品的内容管理,数据变化趋势(图表的库等),表格和折线图等都可使用
  • 投诉管理:处理用户投诉的表格和信息

后台管理系统的交互细节可以不需要很细,主要是展示数据的整理变化

API 层面

后台管理相关 API 通常单独写,加一个 system-admin 前缀,用于和普通 API 的区分

每次进入后台界面,需要单独验证管理员信息,这里设置的 token 过期时间比较短,可能几个小时。相对于应用网页 token 过期时间可能比较久(7天或者30天等)

0348 文件断点续传

主要逻辑:文件切片——上传切片——合并切片——验证切片。

前端浏览器读取文件后,把文件切片后,每一个片段分片上传(滑动窗口算法),然后后端接收到切片后,返回当前已经上传的文件大小,然后把切片放在硬盘的临时目录下。后端检测全部上传后,拼接成完整的大文件,然后把文件路径和成功信息返回给前端。

特殊处理:如果某个切片没有上传到(丢包),那么前端需要循环上传没有成功上传的切片。如果中途网络中断,那么就暂停上传进程。等网络恢复后,再次上传,就实现了断点续传。

适应于大于10M小于1G的文件。超过1G的文件,可能浏览器无法读取到内存中。

后端 Java 实现:https://juejin.cn/post/7266265543412351030

后端 express 实现:https://juejin.cn/post/7233613362888966205

0357 输入框插入图片

Table 中的做法是:

创建一个 div 富文本编辑器,然后插入对应的图片节点,协作人节点。

然后删除时,可以删除对应的节点。这样也支持复制粘贴图片上传,后续可以写个技术文档。content=editable

主要步骤:编辑HTML、提交(转义)、发送到服务器、请求返回本地、渲染只读的HTML

编辑内容如下

test <img src=\"https://2024-02/bird.jpeg\" height=\"60\"> @foo

渲染评论

dangerouslySetInnerHTML={{ __html: commentContent }}

"rest @foo <img src=\"https://test.png\" height=\"60\"> ",

0421 文档树

需求:一个文档树,前端如何维护和更新?

阶段1:静态文档树渲染

  • 从后端获取到文档结构(字符串),使用 JSON.stringify 方法转换成对象,进一步转换成有效的树结构(如果后端存储的是树结构,可以直接使用;如果候选存储的是数组,需要把数组每一项遍历,前端建立树结构)

  • 把 JS 树渲染成 JSX 树结构(对应有 Folder 对象,File 对象,界面上不同层级渲染不同的树节点)

  • 文件夹支持展开和折叠

阶段2:支持文档树编辑

  • 增加:在某个文件夹节点,下面新增一个文件夹或者文件节点

  • 删除:删除某个文件夹或文件夹点(同时删除内部的子树)

  • 重命名:更改某个文件或者文件夹属性

  • 复制:把一个子树节点,复制到另一个节点(注意需要深拷贝树节点)

  • 保存:把树结构转换成字符串,发送到服务器

阶段3:支持复杂操作和边界情况

  • 通过菜单移动:把一个子树节点,移动到另一个子树节点

  • 通过拖拽移动

  • 采用 react-dnd 库,设置全部的节点可以拖动,拖动时包括内部节点;

  • 设置全部的文件夹可以被释放,根据释放的位置,三种情况:

    • 文件夹上面10px 就是移动到文件夹的前一个节点(渲染释放线)

    • 文件夹下面10px 就是移动的文件夹的后一个节点(渲染释放线)

    • 文件夹中间,移动到文件夹内部(背景变色)

  • 设置全部的文件可以被释放,两种情况,分别是前一个位置和后一个位置(渲染释放线)

  • 支持撤销(每一个操作记录到数组中,当撤销时,使用 revert 函数获取对应的反向操作)

  • 支持本地文件拖动到浏览器树结构(把本地文件上传到服务器,服务器返回路径,然后插入到树中)

  • 判断最大树嵌套深度(避免树深度过深,或者某个文件夹内部太多文件,造成性能问题)

0422 图片懒加载

为什么要做图片懒加载?如果一个网页有很多图片,初始网页就加载全部的图片,下载图片比较慢,给服务器带来很多压力。解决的办法就是界面滚动到某个位置后,然后再加载这个位置的图片。适应于内容很多的长网页的实现,例如画廊等。

技术实现:

1、默认把 img 标签的 src 设置一个空,或者是 loading 动画,这样可以从缓存中获取动画。

<div className="container">
    <img src="./loading.gif" alt="" data-src="http://www.baidu.com/logo.png" />
    <img src="./loading.gif" alt="" data-src="http://www.baidu.com/logo.png" />
    <img src="./loading.gif" alt="" data-src="http://www.sina.com/logo.png" />
    <img src="./loading.gif" alt="" data-src="http://www.taobao.com/logo.png" />
</div>

2、计算当前图片是否在网页视口中,也就是计算图片距离网页顶部的位置,和当前滚动位置和屏幕高度。

componentDidMount() {
    this.checkLoad();
}

onScroll = () => {
    this.checkLoad();
}

3、监听界面垂直滚动事件(节流监听),当某个图片开始进入网页内部(或者即将进入网页内部),那么设置成真实的 src,向服务器发出请求,界面显示图片,就实现了图片的懒加载。伪代码如下:

function checkLoad() {
    // 检测当前图片是否在视口内部
    // 图片距离顶部的距离 < 网页滚动距离 + 屏幕高度
    const isLoad = imageOffsetTop < containerSrollTop + window.innerHeight
    if (isLoad) {
        img.src = img.getArrtibute('data-src');
    }
}

0432 鼠标经过切换图片

需求:鼠标经过,切换到另一张图片(犀牛书 21 章第一节的案例,JavaScript权威指南)

原理:把一个图片地址和另一个图片的地址设置到节点中,然后鼠标经过,获取 data 属性然后改变 src 属性,这样实现了图片切换。为了避免缓存,可以先 JS 创建图片,然后直接从缓存中读取图片。

HTML

<img src="https://michael18811380328.github.io/background/pub_31.jpg" data-rollover="https://michael18811380328.github.io/background/pub_32.jpg"/>

JS

  window.onload = (function() {
    for (let i = 0; i < document.images.length; i++) {
      let img = document.images[i];
      let rollover = img.getAttribute('data-rollover');
      // 忽略没有 rollover 属性的图片
      if (!rollover) continue;
      // 缓存图片
      (new Image()).src = rollover;
      img.setAttribute('data-rollout', img.src);
      img.onmouseenter = function() {
        this.src = this.getAttribute('data-rollover');
      }
      img.onmouseleave = function() {
        this.src = this.getAttribute('data-rollout');
      }
    }
  });

本地浏览器调试时,应该设置浏览器允许缓存,这样首次加载全部图片后,然后鼠标进入滑出就不会再次从服务器获取图片了。

上面是原生 JS 的实现,如果是 react 在不同的 image 上绑定 mousenter - mouseleave 事件即可

0434 mp4 视频转换成 m3u8 格式展示

参考:https://blog.csdn.net/weixin_41697143/article/details/139750963

0695 图片9图切换效果实现

需求:产品需要这个效果,具体怎么实现?

从数据层面和交互层面分析

数据分析

1、数组获取:文件树结构,转换成数组

[{ name: xxx, url: xxx, time: xxx }]

2、数据过滤:右上角有过滤和排序,就是自定义 filter 和 sort 函数即可

3、时间实现:循环数组,如果后一个和前一个时间隔天,那么后一个就显示天数

4、动态拖动算法实现:当拖动浏览器或者其他元素,造成当前容器宽度变化时,重新获取容器宽度,按照每行显示N个,计算当前每一个元素的宽度即可:获取屏幕的总宽度(可能动态值)、设置每一行显示的个数范围,例如 4-10个、然后滑块调整时,给出一个百分比的值;实际显示的个数,例如滑动到一半,就是 Math.floor(4 + (10 - 4) * 0.5)  = 7

交互展示

可以使用百分比处理,每一个宽度是 100% / 7

或者使用 grid 进行布局,每一行宽度是7个(因为大小都相等)

性能问题

如果拖动到底部,然后调整过滤筛选等,那么让页面滚动位置是0

如果图片特别多(10000),那么就使用虚拟列表+图片缩略图+图片懒加载实现,避免一次性渲染太多图片造成卡顿

虚拟列表:前面9000个显示成一个 div,后面1000个显示成 img 数组,避免 doms 节点过多

图片缩略图:避免使用原图,减少网络负担

图片懒加载:滚动到某个图片时,再替换 img 的 src 属性进行加载

0475 一个左侧序号列冻结算法

算法:一个左侧序号列冻结算法

需求描述:一个二维表格,上下滚动时,顶部的表头是固定的(已经实现);左右滚动时,需要首列固定。

表格结构:外部一个 div container,然后渲染每一行 div,每一行渲染多个单元格 div 水平排列。

基本思路:

主要处理左侧的冻结的列。

默认状态下:设置第一列是 position: absolute,marginleft 或者 left 是0; 然后这个位置会塌陷,后面使用一个相同的 DIV 占个位置。

上下滚动时,不变化。

左右滚动时:设置第一列是 position: fixed 固定定位,然后设置 marginLeft 是固定的0即可。需要处理表头的 z-index 确保左右滚动时,第一列不会显示到表格外部。

注意顶部表头行的样式(表头滚动,那么全部需要滚动,表内容滚动,也是全部需要滚动,通过 ref 直接更改 style)

0481 如何禁止用户打开浏览器

很多网站为了禁止用户打开控制台,通常使用定时器,循环定时检测用户是否打开控制台(例如文心一言)

那么在网页加载完成后,清空全部定时器即可

参考:https://www.zhihu.com/question/597286223

// 原理
window.onload = function () {
    // 清理所有的定时器
    if (location.host == "yiyan.baidu.com") {
        // 定时器新建过程中是自增长的,那么这里新建一个定时器,index 默认最大,然后循环清空已有定时器即可
        let endTid = setTimeout(function () {});
        for (let i = 0; i <= endTid; i++) {
            clearTimeout(i);
            clearInterval(i);
        }
    }
}

原始版本

setInterval(function() {
  check()
}, 4000);

var check = function() {
  function doCheck(a) {
    if (("" + a/a)["length"] !== 1 || a % 20 === 0) {
      (function() {}
      ["constructor"]("debugger")())
    } else {
      (function() {}
      ["constructor"]("debugger")())
    }
    doCheck(++a)
  } 
  try {
    doCheck(0)
  } catch (err) {}
};
check();

简化版

猜测是在一瞬间瞬间让你的函数调用超出最大限制, 然后导致控制报错,

此时这个函数下的就存在了某个可以让你进行 debugger 操作的函数, 有大佬可以解释下吗?

本意是为了方便开发者调试,结果变成了拦截开发者

const check = () => {
    const doCheck = (a) => {
        (function() {}["constructor"]("debugger")());
        // 立即执行匿名函数 调用 constructor函数传入 字符串 "debugger"
        doCheck(++a); // 递归调用
    };
    try {
        doCheck(0);
    } catch (err) {
        console.log("err", err); // 超出最大调用限制 Maximum call stack size exceeded
    }
};
check();
// 每隔4秒检测一次
setInterval(check, 4000);

0660 如何实现网页中拖动选择多个内容

问题:类似 mac 中选择文件,网页中如何拖动选择多个文件?

undefined

解决:

处理拖动和灰色蒙层

1、监听鼠标点击事件,获取位置 x1, y1

startPoint: { x1: 0, y1: 0 };
endPoint: { x2: 0, y2: 0 };

2、当鼠标移动时,每次移动到的位置是 x2 y2,此时 x1 x2 y1 y2 四个点渲染成一个方形的图层,然后设置边框和背景色,和透明度。

  renderSelectionBox = () => {
    const { startPoint, endPoint } = this.state;
    if (!this.state.isSelecting) return null;
    const left = Math.min(startPoint.x, endPoint.x);
    const top = Math.min(startPoint.y, endPoint.y);
    const width = Math.abs(startPoint.x - endPoint.x);
    const height = Math.abs(startPoint.y - endPoint.y);
    return (
      <div
        className="selection-box"
        style={{ left, top, width, height }}
      />
    );
  };

3、当鼠标抬起,x1 x2 y1 y2 清空,灰色蒙层去掉。

处理选择文件

如果文件绝对定位,那么比较好处理,直接判断文件的坐标 x3x4y3y4 和蒙层是否有交集。如果有交集,那么就是选中。如果没有交集,那么就是没有选中。选中的文件,增加灰色背景,文字背景变成蓝色。

如果文件不是绝对定位,那就比较麻烦。这里假设文件之间没有空位,是按照行列充满进行排列的,那么可以计算出每一行每一列的文件位置。

鼠标上一个的位置 x0, y1 鼠标当前的位置是 x2 y2,x2-x1, y2-y1 那么可以计算出当前鼠标拖动的方向,向左上方,右上方,左下方,右下方四个方向拖动。然后当鼠标进入一个文件,那么就把对应初始点和当前点的位置的文件选中。当鼠标离开一个文件,如果文件不在选区内部,那么这一行和这一列文件就不选中。具体状态可以维护在上层组件,或者组件之间互相通信。

当文件不是规范图形,那么就是两个图,是否存在交集的情况,就是求两个函数是否有交点的问题了,就转化成数学问题。

如果文件不是绝对定位,然后可以通过 DOM 算出每一个文件所在的位置。然后和蒙层求交集即可。

    const selectionRect = {
      left: Math.min(startPoint.x, endPoint.x),
      top: Math.min(startPoint.y, endPoint.y),
      right: Math.max(startPoint.x, endPoint.x),
      bottom: Math.max(startPoint.y, endPoint.y),
    };

    const items = container.querySelectorAll('.file-item');
    items.forEach(item => {
      const bounds = item.getBoundingClientRect();
      const relativeBounds = {
        left: bounds.left - container.getBoundingClientRect().left,
        top: bounds.top - container.getBoundingClientRect().top,
        right: bounds.right - container.getBoundingClientRect().left,
        bottom: bounds.bottom - container.getBoundingClientRect().top,
      };

      // Check if the element is within the selection box's bounds
      if (relativeBounds.left < selectionRect.right && relativeBounds.right > selectionRect.left &&
        relativeBounds.top < selectionRect.bottom && relativeBounds.bottom > selectionRect.top) {
        newSelectedItemsList.push(item);
      }
    });
进阶:如果网页存在滚动条,如何处理拖动到边界的情况?

如果文件很多,向下拖动到边界,网页发生滚动,那么此时主要计算 Y 轴方向的变化。整体蒙层的尺寸变化,选择文件也需要进行变化。(一般不会同时出现四边都滚动的情况)

进阶:如果拖动超出了网页,交互如何实现?

当前拖动范围,存在一个容器。鼠标拖动时,监听 mouseLeave 事件,触发后,关闭蒙层,已选中的文件继续保留,这就可以实现拖出网页的特殊情况。

0682 树状结构左侧加竖线实现

在一个文件树左侧,需要加一个竖线,类似 github 的效果,这个怎么实现?

undefined

产品需求描述:

1、鼠标经过这个树形结构,显示左侧竖线;鼠标移除后不显示竖线

2、竖线和上一级目录的三角形对齐

3、竖线和背景灰色不冲突,竖线浮在背景灰色上面

实现:文件树已经实现,现在只处理竖线。

文件结构是树形结构,那么可以先遍历树节点,然后传递一个变量 offsets = [],每次递归子节点,都增加一个缩进值。例如 [20, 40, 60, 80, 100]。这里的值是缩进值。

在叶子节点中,渲染左侧的N个边框

{offsets.map(offset => {
  return (
    <div style={{
      // height 是每一项的高度
      position: 'absolute', border: '1px solid #ccc', left: offset, height: 28, top: 0,
    }}>
  );
})}

然后进行微调,即可实现左侧的竖线效果。

0442 文件上传的几种情况

普通文件上传

function fn(files) {
  if (files.length) {
    // 优化:如果上传多文件,可以使用循环上传(上传多文件 input multiple 有一部分浏览器不支持,移动端和打开的APP有关)
    for (let file of files) {
      let reader = new FileReader();
      // 不同类型的文件,使用不同的编码上传(readAsText, readAsDataURL)
      // 通常根据文件名后缀判断文件类型,更严格的方法是根据文件开头的编码判断(文件后缀和真实文件类型可能不一样)
      if (/.txt/.test(file.type)) {
        // txt file
        reader.onload = function() {
          console.log(this.result);
        }
        reader.readAsText(file);
      }
      else if (/.png/.test(file.type)) {
        // image file
        reader.onload = function() {
          console.log('success');
        }
        reader.readAsDataURL(file);
      }
    }    
  }
}

大文件分片上传

大文件分片上传思路(详见开课吧笔记)

  • 先把 file 异步读取到 JS 内存中 (fs.readFile)

  • 类数组切片成 chunks (files.slice(current, current + chunkLength))

  • 前端生成一个 hash (三种方法,idle,布隆过滤器)

  • 然后 chunks.map() 给每一个分片name加上hash,调用 API 并上传,根据上传的chunks数量,设置进度条。

  • 上传后,需要后端协同处理(根据文件的 hash 确定文件唯一性,然后根据 chunk 的 index 进行排序,把多个文件片段合并后,存储到数据库)

  • 特殊:如果丢失分片,类似网络请求丢包处理思路(断点续传或者重传)前端再次传递分片

其他特殊情况:

  • 很多小文件上传(本地JS压缩成一个文件,本地用 JSZip 或者 gzip 等格式,然后后端收到再解包);

  • 网络很差(经常中断)前端后端需要查询是否某个片段已经上传,来确定是否重新上传等(断点续传)

  • 拖拽文件上传,复制粘贴上传(需要调用前端的事件获取文件,监听 DIV 的 drag drop 事件,然后从 event 中获取文件)

0267 canvas ——实现刮刮乐

前端刮刮乐的实现,canvas API,先创建一个矩形灰色区域,然后监听鼠标点击事件,绘制新的区域,和第一个区域求差值即可,鼠标按下后,监听鼠标移动事件。鼠标抬起后,清空鼠标移动事件。关键是 canvas 的属性和 API。

https://juejin.cn/post/7142839691203575838

https://blog.csdn.net/qq_44907926/article/details/119881880

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>中奖啦</title>
  <style>
    h1 {
        text-align: center;
    }
    #container {
        width: 400px;
        height: 100px;
        position: relative;
        left: 50%;
        transform: translate(-50%, 0);
    }

    .back, canvas {
        position: absolute;
        width: 400px;
        height: 100px;
        left: 0;
        top: 0;
        text-align: center;
        font-size: 25px;
        line-height: 100px;
        color: deeppink;
    }

  </style>
</head>
<body>
  <h1>刮刮乐</h1>
  <div id="container">
      <div class="back">二等奖</div>
      <!-- 如果背景是图片,那么这里再加一个图片 -->
      <canvas id="canvas" width="400" height="100"></canvas>
  </div>

  <script>
    // 避免选中背景的文字(二等奖)
    document.addEventListener("selectstart", function (e) {
        e.preventDefault();
    });

    // 前景灰色蒙版 canvas
    let canvas = document.querySelector("#canvas");
    let ctx = canvas.getContext('2d');
    ctx.fillStyle = 'darkgray';
    ctx.fillRect(0, 0, 400, 100);

    // 判断当前状态是否点击
    let isDraw = false;
    canvas.onmousedown = function () {
        isDraw = true;
    }

    let containerDom = document.querySelector('#container');

    canvas.onmousemove = function (e) {
        // 鼠标按下时,刮奖
        if (isDraw) {
            let x = e.pageX - containerDom.offsetLeft + containerDom.offsetWidth / 2;
            let y = e.pageY - containerDom.offsetTop;
            ctx.beginPath();
            // 绘制圆形:xy 是圆心坐标,r 是半径,0, 2 * Math.PI 是开始的角度和结束的角度
            ctx.arc(x, y, 30, 0, 2 * Math.PI);
            // 关键: globalCompositeOperation = type 这个属性设定了在画新图形时采用的遮盖策略
            // 具体属性参考:https://www.jianshu.com/p/ff425bfa6f41
            ctx.globalCompositeOperation = 'destination-out';
            ctx.fill();
            ctx.closePath();
        }
    }

    document.onmouseup = function () {
        isDraw = false;
    }

    // 中奖情况
    let arr = [
        { content: '一等奖:一个大嘴巴子', p: 0.1 },
        { content: '二等奖:两个大嘴巴子', p: 0.2 },
        { content: '三等奖:三个大嘴巴子', p: 0.3 }
    ];
    let tmp = Math.random();
    let backImageDom = document.querySelector('.back');
    if (tmp < arr[0].p) {
        backImageDom.innerHTML = arr[0].content;
    } else if (tmp < arr[1].p) {
        backImageDom.innerHTML = arr[1].content;
    } else if (tmp < arr[2].p) {
        backImageDom.innerHTML = arr[2].content;
    }
  </script>
</body>
</html>

Last update: November 9, 2024