likes
comments
collection
share

web自定义视频控件:提升用户体验

作者站长头像
站长
· 阅读数 65

需求:由于默认的控件无法满足我们的使用需求,我们希望对自定义视频控件进行美化和简化按钮操作。

目的:提升用户体验,使视频控件更具吸引力和易用性,满足产品的需要。

web自定义视频控件:提升用户体验

解析video标签

属性解析

属性说明
src指定要播放的视频文件的 URL
autoplay指定视频在加载完成后自动播放。可以设置为 autoplay
controls显示视频的控制条,包括播放/暂停、音量控制、进度条等。可以设置为 controls
loop设置视频循环播放。可以设置为 loop
muted设置视频静音播放。可以设置为 muted
poster指定视频封面图像的 URL,用于在视频加载之前显示
preload指定视频的预加载行为。可选值包括 autometadatanone,分别表示自动预加载、仅预加载元数据和不预加载
width设置视频播放区域的宽度
height设置视频播放区域的高度

事件解析:

html/Reactvue说明
onloadstart@loadstart视频开始加载时触发
onloadedmetadata@loadedmetadata视频的元数据加载完成时触发
onloadeddata@loadeddata视频的第一帧数据加载完成时触发
oncanplay@canplay视频可以开始播放时触发
oncanplaythrough@canplaythrough视频可以完整播放时触发
onplay@play视频开始播放时触发
onpause@pause视频暂停播放时触发
onended@ended视频播放结束时触发
onseeking@seeking 视频正在寻找指定的播放位置时触发
onseeked@seeked 视频寻找指定的播放位置完成时触发
onprogress@progress 视频正在下载中,触发下载进度事件
onerror @error 视频加载或播放错误时触发

这些事件可以通过给 <video> 标签添加对应的事件属性来监听。

例如:事件监听:

    <video  onplay="handlePlay()" onpause = "handlePause()">
    </video>

需要注意的是,不同浏览器对于视频事件的支持和触发时机可能会有一些差异,因此在开发过程中应该进行兼容性测试,并根据需要做出相应的兼容处理。

开始设计

取消默认controls

首先,您可以使用:controls="false"或者不在 <video> 标签中写入 controls 属性来隐藏默认的控件

另外,您也可以使用以下 CSS 代码隐藏控件:

    .custom-video::-webkit-media-controls {
        display: none !important;
    }

接下来,您可以设计一个全新的控件,并将其放置在与 <video> 标签并列的位置。

    <div class="player">
         <!-- 上方滚动的标题 -->
        <div class="noticeBar"></div>
        <video class="custom-video" preload="auto" src="./1.mp4">
            您的浏览器不支持video标签。
        </video>
        <div class="ui-wrapper">
            <!-- 遮罩层 -->
            <div class="ui-mask"></div>
            <!-- 控件 -->
            <div class="ui-control">
                <!-- 控件的具体内容 -->
            </div>
            <!-- 加载逻辑  -->
            <div class="ui-loading"></div>
        </div>
    </div>

设计控件空间

按照需求去设计

下面是我按照市场常用的样式,我分了 上下两块、 上部分:进度条 下部分:按键操作

    <div class="ui-control">
         <!-- 进度条 -->
        <div class="progress">
            <input type="range" min="0" max="100" id="myRange">
        </div>
        <!-- 控制详情 -->
        <div class="control-type">
            <!-- ...... -->
        </div>
    </div>

控件可以分为左边和右边两部分。

左边部分包括常规的暂停、播放等按钮。

右边部分包括全屏、页面内全屏、画中画、倍速、声音等按钮。

     <!-- 控制详情 -->
    <div class="control-type">
         <!-- 左边部分 -->
        <div class="left">
             <!-- 暂停/播放按钮 -->
            <div class="play_pause">
                <img id="play_pause_img" class="control-img" src="./image/pause.png" alt />
            </div>
            <!-- 时间显示 -->
            <div class="timer">00:00 / 00:00</div>
        </div>
        <!-- 右边部分 -->
        <div class="right">
             <!-- 页面内全屏按钮 -->
            <img class="control-img" src="./image/pageFullScreen.png" alt/>
            <!-- 画中画按钮 -->
            <img class="control-img" src="./image/pictureInPictureScreen.png" alt />
              <!-- 全屏按钮 -->
            <img class="control-img" src="./image/windowFullScreen.png" alt />
        </div>
    </div>

CSS处理

接下来是对 CSS 的处理:

视频完整展示

为了确保视频能够完整显示,可以使用以下 CSS 代码:

    .player {
        position: relative;
        width: 766px;  /* 视频容器的宽度 */
        height: 431px; /* 视频容器的高度 */
        background-color: #000;
        overflow: hidden;
    }
    .custom-video {
         width: 100%;
         height: 100%;
    }

对视频控件布局

对控件进行封装:

    .ui-wrapper {
        position: absolute;
        top: 0;
        bottom: 0;
        left: 0;
        right: 0;
    }

    /* mask 肯定要将视频完全掩盖住 */
    .ui-wrapper .ui-mask {
        position: absolute;
        top: 0;
        bottom: 0;
        left: 0;
        right: 0;
        z-index: 1;
    }

    /* control 肯定要比mask层级 + 1 */
    .ui-wrapper .ui-control {
        position: absolute;
        display: flex;
        width: 100%;
        flex-direction: column;
        align-items: center;
        bottom: 0px;
        border-radius: 5px;
        padding: 12px 0;
        background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, #000000 100%);
        z-index: 2;
        transition: all 0.25s cubic-bezier(0.39, -0.04, 0.09, 0.75);
    }

处理进度条,使其占满整个宽度:

    .progress {
        width: 100%;
    }

    input[type="range"] {
        width: 100%;
    }

根据喜好设计其他按钮的样式:

    .control-type {
         display: flex;
         justify-content: space-between;
         width: 100%;
    }
    .control-type .left {
        display: flex;
        align-self: flex-start;
        gap: 15px;
    }
    .control-type .right {
        display: flex;
        gap: 15px;
    }
    .......

通过以上优化和说明,您可以根据需要进行进一步的调整和样式设计,使您的自定义视频控件满足您的需求。

事件处理

视频标签有很多事件

    <video 
           class="custom-video" 
           preload="auto" 
           src="./1.mp4" 
           ondurationchange="handleDurationChange(event)"
           ontimeupdate="handleUpdateVideo(event)" 
           onwaiting="handleVideoLoading()" 
           onended="handleVideoEnded()"
           onloadedmetadata="handleVideoCache(event)"
    >
            您的浏览器不支持video标签。
    </video>

参数

提供处理事件思路:

    // 参数
    let speed = 1; // 视频的倍速
    let duration = 0; // 视频的时间
    let videoPlayState = 0; /// 0-暂停 1-播放 2-加载 3-视频播放结束

    let ispageFullScreen = false;// 页面全屏的状态
    let iswindowFullScreen = false;// 浏览器全屏的状态
    let prePlayerStyle = { // 保留全屏前的页面视频参数
        width: "766px",
        height: "431px"
    };


    // 获取dom
    // 获取player为了处理全屏问题
    let playerNode = document.querySelector(".player");
    // 获取videoDom为了处理,视频的暂停与播放,缓冲等
    let videoNode = playerNode.querySelector(".custom-video");
    // 在html中写的,需要动态的变化数值时间
    let timerNode = document.querySelector(".timer");
    // 获取progress进度条,修改进度value
    let myRangeNode = document.querySelector("#myRange");
    // 在html中写的,需要动态的变化暂停与播放图标
    let playPauseImgNode = document.querySelector("#play_pause_img");

视频的操作

视频的暂停与播放

    // 视频的暂停与播放
    function videoPlayOrPause () {
        videoNode.paused ? videoNode.play() : videoNode.pause();
    }

    // 监听视频是否播放,修改图标与状态
    videoNode.addEventListener('play', videoPlayedListener)
    function videoPlayedListener (e) {
        if (e.returnValue) {
            videoPlayState = 1;
            playPauseImgNode.src = "./image/play.png";
        }
    }
    videoNode.addEventListener('pause', videoPausedListener);
    function videoPausedListener (e) {
        if (e.returnValue) {
            videoPlayState = 0;
            playPauseImgNode.src = "./image/pause.png";
        }
    }

获取视频的时间


// 获取视频的时长
function handleDurationChange (event) {
// 获取duration的数据
const videoElement = event.target;
duration = videoElement.duration
// 未播放视频的时候,格式化时间
formatTimeString(videoElement);
}

// 格式化视频进度时间
function formatTimeString (node) {
let currentTime = node.currentTime;
myRangeNode.value = currentTime / duration \* 100;
const formatTime = (t) => {
// 时间格式化
let h = parseInt(t / 3600);
let m = parseInt((t % 3600) / 60);
m = h \* 60 + m;
m = m < 10 ? "0" + m : m;
let s = parseInt(t % 60);
s = s < 10 ? "0" + s : s;
return m + ":" + s;
}

    timerNode.innerText = `${formatTime(currentTime)} / ${formatTime(duration)}`

}

注意:在格式化时间的时候,修改了myRangeNode.value的值,也就是进度条的进度。

视频时间进度

利用上面的时间格式化

    function handleUpdateVideo (event) {
        const videoElement = event.target;
        formatTimeString(videoElement);
    }

视频进度条

拖动进度条,时间就会发生变化

     // 设计进度条
    function onInputProgress (event) {
        // 点击判断修改事件
        videoNode.currentTime = duration * event.target.value / 100;
    }

同样可以运用到音量上面。

视频倍速

通过点击某一个按钮,传递来number数据:比如:0.511.523

    // 比如
    onChageSpeed(count) {
    	// 视频获取速率
        let videoSpeed = videoNode.playbackRate;
        
        // 视频倍速和上一次一样就无需修改
        if (videoSpeed !== speed) {
            // 存储要修改的倍速
          	speed = count;
            videoNode.playbackRate = speed;
        }
    } 

浏览器全屏

视频全屏问题:

videoDom的视频全屏时,视频控件会自动出现,

解决办法:使用视频容器全屏,就不会出现这个问题。

页面全屏时,仅仅是video容器全屏了,不然不能实现video控件的自定义

web自定义视频控件:提升用户体验


// const element = document.querySelector(".player");
// windows的窗口全屏
function handlewindowFullScreen (currentIsFull, isChangeWindow = true) {

    const pageFullScreen = () => {
        if (playerNode.requestFullscreen) {
            playerNode.requestFullscreen();
        } else if (playerNode.mozRequestFullScreen) {
            playerNode.mozRequestFullScreen();
        } else if (playerNode.webkitRequestFullscreen) {
            playerNode.webkitRequestFullscreen();
        } else if (playerNode.msRequestFullscreen) {
            playerNode.msRequestFullscreen();
        }
        return true;
    }

    const exitpageFullScreen = () => {
        if (document.exitFullScreen) {
            document.exitFullScreen();
        } else if (document.mozCancelFullScreen) {
            document.mozCancelFullScreen();
        } else if (document.webkitExitFullscreen) {
            document.webkitExitFullscreen();
        } else if (document.msExitFullscreen) {
            document.msExitFullscreen();
        }
        return false;
    }


    return !currentIsFull ? pageFullScreen() : exitpageFullScreen();

}

解释:

当前的全屏状态为true,表示状态为全屏中,你点击了按钮,就表示,需要exit退出全屏,使用 true ---》exitpageFullScreen()

页面内全屏

制造页面下的全屏

web自定义视频控件:提升用户体验

     // 全屏的使用StyleModify修改
    function handleFullScreenStyle (currentIsFull) {
        if (currentIsFull) {
            playerNode.style.position = 'fixed';
            playerNode.style.height = "100vh";
            playerNode.style.width = "100vw";
        } else {
            playerNode.style.position = 'relative';
            playerNode.style.height = prePlayerStyle.height;
            playerNode.style.width = prePlayerStyle.width;
        }
    }
    // 页面的全屏
    function handlePageFullScreen (isFull) {
        handleFullScreenStyle(!isFull);
        // 点击了,就切换一下
        return !isFull;
    }

画中画

画中画(Picture-in-Picture)是一种在网页中播放视频时,将视频窗口缩小并浮动在其他内容上方的功能。它可以提供更好的多任务处理和用户体验

web自定义视频控件:提升用户体验

     // 画中画的视角
    function handlePictureInPicture () {
        
        const enterPictureInPicture = ()=> {
            videoNode.requestPictureInPicture()
                .then(() => {
                // 进入画中画模式成功
                console.error('进入画中画模式成功');
            })
                .catch(error => {
                console.error('进入画中画模式失败:', error);
            });
        }
        
        if (document.pictureInPictureEnabled) {
            // 浏览器支持画中画功能
            enterPictureInPicture();
        } else {
            // 浏览器不支持画中画功能
        }
    }

缓冲

看看视频加载了多少

    function handleVideoCache (event) {
        // 视频的缓冲进度
        const buffered = event.target.buffered;
        let bufferedTime = 0;
        if (this.bufferedLength !== buffered.length) {
            for (let i = 0; i < buffered.length; i++) {
                bufferedTime += buffered.end(i) - buffered.start(i);
            }
            console.log(`已缓冲 ${bufferedTime} 秒`);
            this.bufferedTime = bufferedTime;
            this.bufferedLength = buffered.length;
        }
    }

视频加载中

一般是:视频开始加载中,就去展示加载的动画。

    handleVideoWaiting() {
        // 加载动画, videoPlayState加载状态
        videoPlayState = 2;
    },

视频播放结束

一般是,读秒开始下一个视频或者去重新循环播放视频

     handleVideoEnded() {
        // 视频播放完成,videoPlayState的完成状态
     	 videoPlayState = 3;
     },

优化操作

点击遮罩层

    function handleMaskClick () {
        // 处理视频的播放暂停
        videoPlayOrPause();
        // 控制栏隐藏
        controlShow(2);
    }

自动隐藏控制栏

    let controlNode = playerNode.querySelector(".ui-control");
    let controlTimer = null;
    // 默认2.5s后隐藏
    function controlShow (closeTime = 2.5) {
        // 点击了,就取消对control的隐藏,重新倒数
        controlNode.style.transform = `translateY(0)`;
        clearTimeout(controlTimer);
        controlTimer = setTimeout(() => {
            controlNode.style.transform = `translateY(100%)`;
            clearTimeout(controlTimer);
        }, closeTime * 1000);
    }

上述代码中,通过使用 HTML 和 CSS 创建了一个包含自定义视频控件的容器,并为播放/暂停按钮和进度条添加了相应的样式。然后,通过 JavaScript 获取相关的 DOM 元素,并为播放/暂停按钮添加点击事件和视频的时间更新事件,以实现播放控制和进度条更新的功能

下面我给出Demo,是部分的实现。

Demo

视频的icon

web自定义视频控件:提升用户体验

视频的链接

我使用的本地 web自定义视频控件:提升用户体验

完整代码:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Demo</title>
  <style>
    .container {
      height: 100vh;
      max-width: 100vw;
      /* min-width: 1080px; */
      margin: 0 auto;
      display: flex;
      align-items: center;
      justify-content: center;
      box-sizing: content-box;
    }

    .container .player {
      position: relative;
      width: 766px;
      height: 431px;
      background-color: #000;
      overflow: hidden;
    }

    .custom-video {
      width: 100%;
      height: 100%;
    }

    .content {
      width: 304px;
      background-color: rgb(47, 218, 218);
    }

    .ui-wrapper {
      position: absolute;
      top: 0;
      bottom: 0;
      left: 0;
      right: 0;
    }

    .ui-wrapper .ui-mask {
      position: absolute;
      top: 0;
      bottom: 0;
      left: 0;
      right: 0;
      z-index: 1;
    }



    .ui-wrapper .ui-control {
      position: absolute;
      display: flex;
      width: 100%;
      flex-direction: column;
      align-items: center;
      bottom: 0px;
      border-radius: 5px;
      padding: 12px 0;
      background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, #000000 100%);
      z-index: 2;
      transition: all 0.25s cubic-bezier(0.39, -0.04, 0.09, 0.75);
    }

    .progress {
      width: 100%;
    }

    input[type="range"] {
      width: 100%;
    }

    .control-type {
      display: flex;
      justify-content: space-between;
      width: 100%;
    }

    .control-type .left {
      display: flex;
      align-self: flex-start;
      gap: 15px;
    }

    .timer {
      color: #FFF;
    }

    .control-type .right {
      display: flex;
      gap: 15px;
    }

    img[class="control-img"] {
      width: 24px;
      height: 24px;
      display: block;
    }
  </style>
</head>

<body>
  <div class="container">
    <div class="player">
      <div class="noticeBar"></div>
      <video class="custom-video" preload="auto" src="./1.mp4" ondurationchange="handleDurationChange(event)"
        ontimeupdate="handleUpdateVideo(event)" onwaiting="handleVideoLoading()" onended="handleVideoEnded()"
        onloadedmetadata="handleVideoCache(event)">
        您的浏览器不支持video标签。
      </video>
      <div class="ui-wrapper">
        <div class="ui-mask" onclick="handleMaskClick()"></div>
        <div class="ui-control" onclick="controlShow(2)">

          <div class="progress">
            <input type="range" min="0" max="100" id="myRange" oninput="onInputProgress(event)">
          </div>
          <!-- 控制详情 -->
          <div class="control-type">
            <div class="left">
              <div class="play_pause" onclick="videoPlayOrPause()">
                <img id="play_pause_img" class="control-img" src="./image/pause.png" alt />
                <!-- <img class="control-img" src="./image/pause.png" alt /> -->
              </div>
              <div class="timer">00:00 / 00:00</div>
            </div>
            <div class="right">
              <!-- <DoubleSpeed ref="doubleSpeedRef" class="ctr-double-speed" @onChageSpeed="onChageSpeed" /> -->
              <img class="control-img" src="./image/pageFullScreen.png" alt onclick="handleChangeScreen('page')" />
              <img class="control-img" src="./image/pictureInPictureScreen.png" alt
                onclick="handleChangeScreen('pictureInPicture')" />
              <img class="control-img" src="./image/windowFullScreen.png" alt onclick="handleChangeScreen('window')" />
            </div>
          </div>
        </div>
        <div class="ui-loading">
        </div>
      </div>
    </div>
    <div class="content">1</div>
  </div>

  <script>
    // 参数
    let duration = 0;
    let videoPlayState = false;  // 0-暂停 1-播放 2-加载

    let playerNode = document.querySelector(".player");
    let videoNode = playerNode.querySelector(".custom-video");
    let timerNode = document.querySelector(".timer");
    let myRangeNode = document.querySelector("#myRange");
    let playPauseImgNode = document.querySelector("#play_pause_img");

    function handleMaskClick () {
      // 处理视频的播放
      videoPlayOrPause();
      controlShow(2);
    }


    let controlNode = playerNode.querySelector(".ui-control");
    let controlTimer = null;

    function controlShow (closeTime = 2.5) {
      // 点击了,就取消对control的隐藏,重新倒数
      controlNode.style.transform = `translateY(0)`;
      clearTimeout(controlTimer);
      controlTimer = setTimeout(() => {
        // controlNode.style.transform = `translateY(100%)`;
        clearTimeout(controlTimer);
      }, closeTime * 1000);
    }


    // 设计进度条
    function onInputProgress (event) {
      // 点击判断修改事件
      videoNode.currentTime = duration * event.target.value / 100;
    }
    // 操作
    function videoPlayOrPause () {
      videoNode.paused ? videoNode.play() : videoNode.pause();
    }

    // 监听视频是否播放
    videoNode.addEventListener('play', videoPlayedListener)
    function videoPlayedListener (e) {
      if (e.returnValue) {
        videoPlayState = 1;
        playPauseImgNode.src = "./image/play.png";
      }
    }

    videoNode.addEventListener('pause', videoPausedListener);
    function videoPausedListener (e) {
      if (e.returnValue) {
        videoPlayState = 0;
        playPauseImgNode.src = "./image/pause.png";
      }
    }


    //. video标签的一些事件
    function handleDurationChange (event) {
      // 获取duration的数据
      const videoElement = event.target;
      duration = videoElement.duration
      // 未播放视频的时候,格式化时间
      formatTimeString(videoElement);
    }

    function handleUpdateVideo (event) {
      const videoElement = event.target;
      formatTimeString(videoElement);
    }

    function handleVideoCache (event) {
      // 视频的缓冲进度
      const buffered = event.target.buffered;
      let bufferedTime = 0;
      if (this.bufferedLength !== buffered.length) {
        for (let i = 0; i < buffered.length; i++) {
          bufferedTime += buffered.end(i) - buffered.start(i);
        }
        console.log(`已缓冲 ${bufferedTime} 秒`);
        this.bufferedTime = bufferedTime;
        this.bufferedLength = buffered.length;
      }
    }
    function handleVideoEnded () {
      console.log("视频播放结束:");
    }
    function handleVideoLoading () {
      // 视频正在加载中动画
      console.log("视频正在加载");
    }

    let ispageFullScreen = false;
    let iswindowFullScreen = false;
    let prePlayerStyle = {
      width: "766px",
      height: "431px"
    };
    // 全屏
    function handleChangeScreen (type) {
      switch (type) {
        case "page":
          ispageFullScreen = handlePageFullScreen(ispageFullScreen);
          break;
        case "window":
          iswindowFullScreen = handlewindowFullScreen(iswindowFullScreen);
          break;
        case "pictureInPicture":
          handlePictureInPicture();
          break;
        default:
          break;
      }
    }

    function handleFullScreenStyle (currentIsFull) {
      if (currentIsFull) {
        playerNode.style.position = 'fixed';
        playerNode.style.height = "100vh";
        playerNode.style.width = "100vw";
      } else {
        playerNode.style.position = 'relative';
        playerNode.style.height = prePlayerStyle.height;
        playerNode.style.width = prePlayerStyle.width;
      }
    }
    // 页面的全屏
    function handlePageFullScreen (isFull) {
      handleFullScreenStyle(!isFull);
      // 点击了,就切换一下
      return !isFull;
    }

    // 页面全屏时,仅仅时element全屏了,不然不能实现video控件的自定义
    // 获取整个页面元素
    function handlewindowFullScreen (isFull, isChangeWindow = true) {

      const pageFullScreen = () => {
        if (playerNode.requestFullscreen) {
          playerNode.requestFullscreen();
        } else if (playerNode.mozRequestFullScreen) {
          playerNode.mozRequestFullScreen();
        } else if (playerNode.webkitRequestFullscreen) {
          playerNode.webkitRequestFullscreen();
        } else if (playerNode.msRequestFullscreen) {
          playerNode.msRequestFullscreen();
        }
        return true;
      }

      const exitpageFullScreen = () => {
        if (document.exitFullScreen) {
          document.exitFullScreen();
        } else if (document.mozCancelFullScreen) {
          document.mozCancelFullScreen();
        } else if (document.webkitExitFullscreen) {
          document.webkitExitFullscreen();
        } else if (document.msExitFullscreen) {
          document.msExitFullscreen();
        }
        return false;
      }

      return !isFull ? pageFullScreen() : exitpageFullScreen();
    }

    document.addEventListener('fullscreenchange', function (e) {
    
      // 监听document页面是否全屏
      if (document.fullscreenElement === playerNode) {
        // 视频进入全屏状态
        console.log("视频进入全屏状态");
        // 将页面变成false;
        ispageFullScreen = false;
      } else {
        // 视频退出全屏状态
        // ispageFullScreen = false;
        // iswindowFullScreen = false;
      }
    });
    // 画中画的视角

    function handlePictureInPicture (isFull) {

      if (document.pictureInPictureEnabled) {
        // 浏览器支持画中画功能
        enterPictureInPicture();
      } else {
        // 浏览器不支持画中画功能
      }


      function enterPictureInPicture () {
        videoNode.requestPictureInPicture()
          .then(() => {
            // 进入画中画模式成功
            console.error('进入画中画模式成功');
          })
          .catch(error => {
            console.error('进入画中画模式失败:', error);
          });
      }
    }

    // 格式化时间
    function formatTimeString (node) {
      let currentTime = node.currentTime;
      myRangeNode.value = currentTime / duration * 100;
      const formatTime = (t) => {
        // 时间格式化
        let h = parseInt(t / 3600);
        let m = parseInt((t % 3600) / 60);
        m = h * 60 + m;
        m = m < 10 ? "0" + m : m;
        let s = parseInt(t % 60);
        s = s < 10 ? "0" + s : s;
        return m + ":" + s;
      }

      timerNode.innerText = `${formatTime(currentTime)} / ${formatTime(duration)}`
    }
  </script>
</body>

</html>

大概就这些了,😄。

转载自:https://juejin.cn/post/7241101553712365627
评论
请登录