【Chrome插件开发】某视频网站的m4s视频/音频下载方案,及其Chrome插件实现-v250415

引言

Sample:aHR0cHM6Ly93d3cuYmlsaWJpbGkuY29tL3ZpZGVvL0JWMXBwNDIxZDc1cQ==

我去年写了这篇文章:【前端甜点】某视频网站的m4s视频/音频下载方案(20240420),但大概从去年8月开始,我发现,下载质量较高的音频时会报错net::ERR_FAILED 206,较低的则不报错。我百思不得其解,只能暂且猜测:

  1. 该网站发现了xhr请求和fetch api发的请求的差异,导致失败。
  2. 跨域问题。

但在某次尝试中,我copy as fetch,重放请求,发现虽然报错,但这个错误似乎可以解决:

csharp 复制代码
<详情页url>:1 Access to fetch at '<m4s url>' from origin 'https://www.example.com' has been blocked by CORS policy: Request header field pragma is not allowed by Access-Control-Allow-Headers in preflight response.

做法很简单,报错提到什么字段,我就删什么字段,比如根据上面的报错,我们要删"pragma"。如此反复做几次,就能得到可以成功下载的fetch代码。据此,我新写了一版代码:

js 复制代码
// https://www.cnblogs.com/Sherries/p/14840404.html
function blobToDataURI(blob) {
  return new Promise((res, rej) => {
    const reader = new FileReader();
    reader.readAsDataURL(blob);
    reader.onload = (e) => {
      res(e.target.result);
    };
    reader.onerror = () => {
      rej(new Error('文件流异常'));
    };
  });
}

function sendFetchReq(url, rangeStart = 0) {
  return fetch(url, {
    headers: {
      'accept': '*/*',
      'accept-language': 'zh-CN,zh;q=0.9',
      'range': `bytes=${rangeStart}-`,
      // This header is unnecessary. 'sec-ch-ua': '"Chromium";v="134", "Not:A-Brand";v="24", "Google Chrome";v="134"',
      'sec-ch-ua-mobile': '?0',
      'sec-ch-ua-platform': '"Windows"',
      'sec-fetch-dest': 'empty',
      'sec-fetch-mode': 'cors',
      'sec-fetch-site': 'cross-site'
    },
    referrerPolicy: 'no-referrer-when-downgrade',
    body: null,
    method: 'GET',
    mode: 'cors',
    credentials: 'omit'
  }).then(res => res.blob());
}

async function downloader(url, rangeStart = 0) {
  const blob = await sendFetchReq(url, rangeStart);
  console.log(blob); // type is Blob
  const dataURI = await blobToDataURI(blob);
  // data:application/octet-stream;base64,
  const rawBase64Str = String(dataURI);
  const base64Str = rawBase64Str.substring(rawBase64Str.indexOf('base64,') + 7);
  console.log(rawBase64Str.substring(0, 100));
  console.log(base64Str); // 之后在浏览器控制台复制字符串,粘贴到 base64_str_m4s.txt
}

downloader('<m4s file url>');

其他函数不变,sendXhrReq改成sendFetchReq,将原本的XMLHttpRequest改成现在的fetch函数。

写完这段代码后一段时间,我刷到了码农高天的视频。我想,现在LLM已经很厉害了,不妨让AI辅助我,根据这段代码,快速生成一个Chrome插件。

本文52pojie:www.52pojie.cn/thread-2026...

本文CSDN:blog.csdn.net/hans7748829...

本文juejin:juejin.cn/post/749600...

作者:hans774882968以及hans774882968以及hans774882968

效果

v1.0.0

github.com/Hans7748829...

TODO

这里只给出最重要的TODO,完整版见:其他笔记.md

  1. 番剧页面的代码要求我们改用Babel AST 来分析代码,而且window.__playinfo__的数据结构也和视频详情页不同。如下文bangumi.js所示。另外,限免集和会员集的数据结构也不一样。限免集是有video_info.dash的,仍然给你视频和音频的m4s;会员集则只有video_info.durls,直接给你mp4文件。

让AI写初稿

最初的Prompt传送门

请帮我写一个Chrome扩展程序。要求如下:

  1. 使用manifest v3,只在 www.example.com/ 激活。
  2. 请生成前端工程,使用React 18和Yarn,组件库用ant-design。
  3. 为鼠标右键添加一个选项,点击产生一个弹窗。弹窗由两个div组成,分别占据弹窗的左侧和右侧。左侧和右侧div的class名分别为"url-list"和"result",下文分别用url-list和div.result指代。这两个div的width和height都应一样。url-list是一个列表,每个条目展示一个url,每个条目的右侧都有一个按钮,文案为"获取"。div.result展示一个base64字符串,文本过长则用省略号省略过长部分,并提供一个按钮,文案为"复制",点击后将字符串复制到剪贴板。
  4. 监听网络请求,如果某请求的url包含字符串30216.m4s、30232.m4s、30264.m4s、30280.m4s中的一个,则将该url添加到url-list中展示,注意它要添加到url-list的开头,使得最新的请求排在最前面。点击某url右侧"获取"按钮,则向该url发送get请求,将返回体转为blob,进而编码为base64字符串,展示在div.result中。
  5. url-list提供分页功能,每页展示10个条目。

生成的代码有很多问题,比如不是React脚手架生成的、没有package.json等。所以有了后续的Chat:

  • 该react项目应使用vite脚手架生成
  • 请提供package.json

写README的时候再回顾这段Prompt,感觉可能是我脑海里的需求点太过模糊,也可能是我对Chrome插件的API不够熟悉,才导致了AI生成的代码离可用有很大的鸿沟。当然,它还是给了我一点点启发。

两条路:在content.js里,还是popup.js里发请求?

我们知道Chrome插件分为3层,分别为content.jsbackground.jspopup.html / popup.jscontent.js注入某个标签页中运行,background.js是插件的后台程序,popup.js则可以弹出一个网页。所以理论上,我们可以在content.js中或popup.js中发请求。前者的流程图(参考链接3):

后者的流程图:

前者需要让数据从content.js,流到background.js,再到popup.js。后者需要我们用纯JS弹出一个窗口,作为插件的UI。那选哪条路呢?理论上,两条路都是OK的,只是实现上的难点略有差异。

但实际上,经过我亲自开发踩坑,因为该网站有请求头校验,而fetch API无法修改请求头的Origin,所以在popup.js中发请求的方案是不可行的。因为也浪费了些精力,所以我仍然把该方案的代码留了下来,放在bili-m4s-fetch-demo-deprecated文件夹。我会在我的下一篇blog(TODO: Chrome插件实现PPT编辑)中简单讲下其难点。

World in content.js

新建项目

powershell 复制代码
yarn create vite bili-m4s-fetch-demo

即可新建Vite + React项目。因为我感觉这只是一个袖珍项目,所以并没有为项目引入TypeScript。

项目结构:

css 复制代码
.
│  .gitignore
│  background.js
│  eslint.config.js
│  index.html
│  manifest.json
│  package.json
│  README.md
│  vite.config.js
│  
├─bili-m4s-fetch-demo-deprecated
│  │  省略
│          
├─public
│      favicon.ico
│      
├─README_assets
│      1-example-v1.0.0.jpg
│      2-3-layers.mmd
│      2-3-layers.png
│      3-world-in-content.mmd
│      3-world-in-content.png
│      
└─src
    │  App.jsx
    │  App.scss
    │  content.js
    │  main.jsx
    │  
    ├─clamp-js
    │      HansClamp.jsx
    │      
    ├─common
    │      downloadFile.js
    │      getUrlsFromExampleCom.js
    │      M4sUrlDesc.js
    │      request.js
    │      utils.js
    │      
    └─images
            bili.png

如何打包

代码传送门

manifest.json等文件,需要复制到dist文件夹,可用rollup-plugin-copy实现。

为了方便调试,我们希望content.js能在开发环境模拟生产环境注入某标签页的场景,因此我们约定入口为index.html,相应地,该html要在vite给的初始html的基础上改一下。在生产环境,入口显然为content.js。问deepseek可知,defineConfig提供了mode参数判断是什么环境,而在content.js中,可用const isDev = import.meta.env.MODE === 'development';判断。

另外,问deepseek可知,需要build.rollupOptions.output.entryFileNames: '[name].js',让产物JS文件名没有hash。deepseek还很贴心地给了张对比表:

配置项 默认行为 我们需要的行为
input 从 index.html 开始打包 直接从 content.js 开始打包
entryFileNames 带 hash 的文件名 (content.[hash].js) 固定文件名 (content.js)
chunkFileNames 带 hash 的 chunk 名 固定 chunk 名

相关Prompt:

  1. 打包框架是vite,这是一个vite脚手架生成的react项目。我希望修改vite.config.js,以content.js为打包入口,去掉没必要存在的index.html,且打包后对应的js文件名仍为content.js。请根据以上信息修改上述代码。
  2. 请调整vite.config.js及其他文件,实现:在开发模式下,入口为content.html,引用content.js;在生产模式下,和原来一样,入口为content.js

background.js:在鼠标右键菜单添加一个选项,点击后通知content.js第一次创建弹窗

问AI即可拿到可用的代码:

js 复制代码
const MENU_ITEM_ID = 'bili-m4s-fetch-demo';

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: MENU_ITEM_ID,
    title: 'Bili M4S Fetch Demo',
  });
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === MENU_ITEM_ID && tab.id) {
    chrome.tabs.sendMessage(tab.id, { type: 'openDownloaderDialog' });
  }
});

console.log(`[${MENU_ITEM_ID}] background.js loaded`);

但有个无伤大雅的问题:在其他网站仍然会出现该选项,点击后却没有反应。从交互的角度来说,在其他网站,要么不展示它,要么点击后弹出提示。于是我问AI要代码

请修改background.js,实现:只有当前标签页url在manifest.json中host_permissions指定的url列表中时,才为右键菜单添加选项

我在AI给的代码的基础上改了一点点:

js 复制代码
const MENU_ITEM_ID = 'bili-m4s-fetch-demo';

let menuItemCreated = false;

// 检查URL是否匹配host_permissions
function isUrlInHostPermissions(url) {
  if (!url) return false;

  try {
    const allowedPatterns = [
      'https://www.example.com/*'
    ];

    return allowedPatterns.some((pattern) => {
      const regex = new RegExp(pattern
        .replace(/\./g, '\\.')
        .replace(/\*/g, '.*'));
      return regex.test(url);
    });
  } catch (error) {
    console.error('URL检查出错:', error);
    return false;
  }
}

// 维护状态机:创建或移除右键菜单
async function updateContextMenu(tabId) {
  try {
    const tab = await chrome.tabs.get(tabId);
    const urlAllowed = isUrlInHostPermissions(tab.url);
    // AI 代码的 bug : menuItemCreated 是互斥锁,需要在耗时操作之前就上锁,耗时操作之后再解锁,否则会有并发问题
    if (urlAllowed && !menuItemCreated) {
      menuItemCreated = true;
      chrome.contextMenus.create({
        id: MENU_ITEM_ID,
        title: 'Bili M4S Fetch Demo',
      });
    } else if (!urlAllowed && menuItemCreated) {
      // AI 没发现的区别:创建菜单不会返回 Promise ,移除菜单则会,所以记得加 await
      await chrome.contextMenus.remove(MENU_ITEM_ID);
      menuItemCreated = false;
    }
  } catch (error) {
    console.error('更新右键菜单出错:', error);
  }
}

// 监听标签页更新
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
  if (changeInfo.url || changeInfo.status === 'complete') {
    updateContextMenu(tabId);
  }
});

// 监听标签页切换
chrome.tabs.onActivated.addListener((activeInfo) => {
  updateContextMenu(activeInfo.tabId);
});

chrome.runtime.onInstalled.addListener(async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab) {
    updateContextMenu(tab.id);
  }
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === MENU_ITEM_ID && tab.id) {
    chrome.tabs.sendMessage(tab.id, { type: 'openDownloaderDialog' });
  }
});

console.log(`[${MENU_ITEM_ID}] background.js loaded`);

生成的代码有70行,但思路很简单,就是写一个函数,维护一个状态机,在标签页切换时调用,写过算法题的同学们都很熟悉。但chrome.contextMenus.remove()只传了一个参数,所以返回Promise,却忘记加await,导致并发问题:有可能重复创建相同ID的菜单选项。加上await以后,发现还是有并发问题,后来我猜测是互斥锁应该要更早上锁,所以加了那行注释。因为已经采用了下文的解决方案,所以我不打算检验这段代码的正确性了。

在我一筹莫展时,查文档发现,其实Chrome已经提供了一个documentUrlPatterns参数来实现这个功能,哪里需要这70行代码......完整代码:

js 复制代码
const MENU_ITEM_ID = 'bili-m4s-fetch-demo';

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: MENU_ITEM_ID,
    title: 'Bili M4S Fetch Demo',
    documentUrlPatterns: ['https://www.example.com/*']
  });
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === MENU_ITEM_ID && tab.id) {
    chrome.tabs.sendMessage(tab.id, { type: 'openDownloaderDialog' });
  }
});

console.log(`[${MENU_ITEM_ID}] background.js loaded`);

eslint 9如何镇压__dirname的eslint报错

eslint 8的老配置是:{ env: { node: true, browser: true }}(它们并不冲突),而新版要改为:

js 复制代码
    languageOptions: {
      // https://stackoverflow.com/questions/48584556/eslint-chrome-is-not-defined-no-undef
      globals: {
        ...globals.node,
        ...globals.browser,
        ...globals.webextensions
      },
    }

同理,它们并不冲突。

content.css:希望像普通的前端项目一样写CSS

为了能像普通的前端项目一样写CSS,我们希望让jsx import的CSS文件能被打包为content.css

首先,需要修改manifest.json,让插件在标签页注入CSS:

json 复制代码
  "content_scripts": [
    {
      "matches": [
        "https://www.example.com/*"
      ],
      "js": [
        "content.js"
      ],
      "css": [
        "content.css"
      ]
    }
  ],

接着,修改打包配置:build.rollupOptions.output.assetFileNames: '[name].[ext]'。然后,React组件正常import CSS文件就行:import './App.scss';。为了使用scss,需要先装好sass包:yarn add -D sass

content.js挂载组件

参考Vite + React项目模板main.jsx的代码,我们不难想到,需要用react-dom才能做到。

jsx 复制代码
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';

let dialogRef = null;

export function renderDownloaderDialog(initialUrls = []) {
  if (dialogRef) {
    return;
  }
  const dialog = document.createElement('div');
  dialog.id = 'bili-downloader-dialog';
  dialog.style.position = 'relative'; // for expand-dialog-btn, may be unnecessary
  document.body.appendChild(dialog);
  dialogRef = dialog;
  createRoot(dialog).render(
    <StrictMode>
      <App initialUrls={initialUrls} />
    </StrictMode>,
  );
}

难点1:在万恶的manifest V3中,如何拿到视频URL

一开始我考虑的方案非常simple非常naive:监听网络请求,匹配30280.m4s等。在manifest V2里,这完全OK。但现在万恶的咕果逼我们使用manifest V3,而相关资料少得可怜。

被PASS的方案:onRuleMatchedDebug

于是我问doubao:

使用Chrome的manifest v3 declarativeNetRequest如何获取每一个网络请求的URL

得到代码:

manifest.json

json 复制代码
{
    "manifest_version": 3,
    "name": "URL Catcher",
    "version": "1.0",
    "description": "Catch all network request URLs",
    "permissions": [
        "declarativeNetRequest",
        "declarativeNetRequestFeedback",
        "activeTab"
    ],
    "background": {
        "service_worker": "background.js"
    }
}

background.js

js 复制代码
// 规则 ID
const RULE_ID = 1;

// 定义规则,匹配所有请求
const rules = [
    {
        id: RULE_ID,
        priority: 1,
        action: { type: 'allow' },
        condition: { urlFilter: '*', resourceTypes: ['main_frame', 'sub_frame', 'stylesheet', 'script', 'image', 'font', 'object', 'xmlhttprequest', 'ping', 'csp_report', 'media', 'websocket', 'webtransport', 'webbundle'] }
    }
];

// 注册规则
chrome.declarativeNetRequest.updateDynamicRules({
    removeRuleIds: [RULE_ID],
    addRules: rules
}, () => {
    console.log('Rules updated');
});

// 监听请求反馈
chrome.declarativeNetRequest.onRuleMatchedDebug.addListener(({ request, rule }) => {
    if (rule.id === RULE_ID) {
        console.log('Caught URL:', request.url);
    }
});

方案:解析window.__playinfo__

但后来我发现,该网站提供了window.__playinfo__,只需要解析它就行,所以我没运行代码就PASS掉了上述方案。

js 复制代码
{
    code: 0, data: { dash: {
    audio: [{ id: 30280 / 30232 / 30216, baseUrl: "", backupUrl: "" }] // 3 items
    video: [{ id: 80 / 64 / 32 / 16, baseUrl: "", backupUrl: "" }] // 4 * 3 items
    }}
}

content.js是独立的执行环境,拿不到这个变量,所以我们需要用JS去读script标签的代码。于是继续问doubao拿代码(getUrlsFromExampleCom.js):

js 复制代码
function parseScriptTags(scriptTags) {
  for (const scriptTag of scriptTags) {
    const scriptContent = scriptTag.textContent;
    if (!scriptContent || !scriptContent.includes('window.__playinfo__')) continue;
    const startIndex = scriptContent.indexOf('{');
    const endIndex = scriptContent.lastIndexOf('}') + 1;
    const jsonString = scriptContent.slice(startIndex, endIndex);
    try {
      const playInfo = JSON.parse(jsonString);
      return playInfo;
    } catch (error) {
      console.error('Error parsing playInfo JSON:', error);
    }
  }
  return {};
}

export function getPlayInfoFromScriptTag() {
  if (window.__playinfo__ && typeof window.__playinfo__ === 'object') {
    return window.__playinfo__;
  }
  const scriptTags = [...document.getElementsByTagName('script')];
  return parseScriptTags(scriptTags);
}

因为这个script标签只有window.__playinfo__ = {...}这一行赋值语句,所以可以用这个很粗糙的算法去拿。数据处理的代码比较杂乱,在此只展示一点点:

js 复制代码
const isDev = import.meta.env.MODE === 'development';

export function getUrlsFromExampleCom() {
  if (isDev) {
    return [
      new M4sUrlDesc('not a url 1', 80, M4sUrlDesc.VIDEO),
      new M4sUrlDesc('not a url 2', 64, M4sUrlDesc.VIDEO),
      new M4sUrlDesc('not a url 3', 32, M4sUrlDesc.VIDEO),
      new M4sUrlDesc('not a url 4', 16, M4sUrlDesc.VIDEO),
      new M4sUrlDesc('not a url 5', 30280, M4sUrlDesc.AUDIO),
      new M4sUrlDesc('not a url 6', 30232, M4sUrlDesc.AUDIO),
      new M4sUrlDesc('not a url 7', 30216, M4sUrlDesc.AUDIO),
      new M4sUrlDesc(`this is a ${'long'.repeat(200)} url`, 114514, M4sUrlDesc.AUDIO)
    ];
  }
  const playInfo = getPlayInfoFromScriptTag();
  const urlsObj = getUrlsObjFromPlayInfo(playInfo);
  const urls = [...urlsObj.videoUrls, ...urlsObj.audioUrls];
  return urls;
}

接下来我们要考虑如何让组件拿到这些URL。最后我认为,因为在同一个详情页中,window.__playinfo__一直不变,所以渲染时把URL初值从content.js传进组件就OK。

js 复制代码
const isDev = import.meta.env.MODE === 'development';
const initialUrls = getUrlsFromExampleCom();
if (isDev) {
  renderDownloaderDialog(initialUrls);
} else {
  chrome.runtime.onMessage.addListener((message) => {
    if (message.type === 'openDownloaderDialog') {
      renderDownloaderDialog(initialUrls);
    }
  });
}

方案升级:使用Babel AST解析代码

TODO

难点2:切换到其他视频详情页,该网站并不刷新,也不更新window.__playinfo__

这就导致我的组件拿不到最新的视频URL。我抓包发现,它请求了https://api.example.com/x/player/wbi/playurl,参数很复杂,不想花精力去逆向它。然后我想到,只需要用fetch API再向网页发一次请求即可。相关代码:

js 复制代码
// 在同一个url请求,会得到301,但无伤大雅
export async function getNewPlayInfoFromHtml() {
  const resp = await fetch(window.location.href);
  const htmlStr = await resp.text();
  const parser = new DOMParser();
  const doc = parser.parseFromString(htmlStr, 'text/html');
  const scriptTags = [...doc.getElementsByTagName('script')];
  return parseScriptTags(scriptTags);
}

export async function getNewUrlsFromHtml() {
  const playInfo = await getNewPlayInfoFromHtml();
  const urlsObj = getUrlsObjFromPlayInfo(playInfo);
  const urls = [...urlsObj.videoUrls, ...urlsObj.audioUrls];
  return urls;
}

然后在UI的对话框加一个"同步"按钮,提示用户手动按按钮去拿到最新的URL。

jsx 复制代码
  const [urls, setUrls] = useState(initialUrls);

  const updateM4sUrls = async () => {
    const newUrls = await getNewUrlsFromHtml();
    setUrls(newUrls);
    messageApi.success('同步完成');
  };
        // 组件:
        <Header className="toolbar">
          <Button
            type="primary"
            icon={<ReloadOutlined />}
            onClick={updateM4sUrls}
          >
            同步
          </Button>
          <Tooltip title={syncBtnTooltipTitle}>
            <QuestionCircleTwoTone style={{ fontSize: '16px' }} />
          </Tooltip>
        </Header>

难点3:用户多次点击右键菜单新增的条目,如何应对?用"用户思维"规避技术难题

  1. 让对话框再次出现。content.js的代码在组件之外,所以这不现实。
  2. 叉掉对话框时,让它被销毁,而非被隐藏。看了下控制台,对话框的DOM元素并不是<div id="root">的子元素。如果要强行实现这个方案,代码会比较丑。

后来我意识到,不需要在技术层面让组件消失,只需要在对话框隐藏期间,让组件对用户来说不太显眼。相应地,多次点击右键菜单新增条目,我们不予理会。于是我们用用户思维成功规避了技术难题。但在对话框隐藏期间,我们如何唤出它?很简单,我们展示一个悬浮在网页右上角的按钮,用户点击按钮时,对话框便再次出现。

jsx 复制代码
  const [isDialogOpen, setIsDialogOpen] = useState(true);
  const expandDialog = () => {
    setIsDialogOpen(true);
  };
  const expandDialogBtn = !isDialogOpen && (
    <Button
      type="primary"
      className="expand-dialog-btn"
      style={{ position: 'fixed', zIndex: 114514, top: '80px', right: '16px' }}
      icon={<ArrowsAltOutlined />}
      onClick={expandDialog}
    >
      展开下载助手
    </Button>
  );

还有个小插曲。我给按钮设置了如下CSS:

scss 复制代码
.expand-dialog-btn {
  position: fixed; // 这条属性被去掉是因为antd为button指定了position: relative,所以我们为它指定同样的内联样式
  z-index: 114514;
  top: 80px;
  right: 16px;
}

但调试时发现,position: fixed这行CSS被划线了。后来发现,这是因为antd为button指定了position: relative,且优先级比我这行CSS高。于是我把这几行CSS都复制到了组件的内联样式中。

常规1:提供文件下载功能

下载文件的JS代码,网上随处可见。原理很简单:创建a标签,设置download属性,将blob装进去。

js 复制代码
export function downloadFileByALink(blob, fileName) {
  const aLink = document.createElement('a');
  document.body.appendChild(aLink);
  aLink.style.display = 'none';
  const objectUrl = window.URL.createObjectURL(blob);
  aLink.href = objectUrl;
  aLink.download = fileName;
  aLink.click();
  document.body.removeChild(aLink);
}

接着在按钮点击时调用:

js 复制代码
  const handleExport = () => {
    downloadFileByALink(blobResult, m4sFileName);
  };

至于blobResult,只需要:

js 复制代码
fetch().then(res => res.blob());

就能拿到。

难点4:在React项目中实现,多行文本只展示前10行(clamp-js-main)

我不仅在界面提供了可下载的文件,还提供了可复制的文件内容的Base64字符串。于是我希望只展示前10行文本,其余的显示省略号。一开始我用了纯CSS:

scss 复制代码
.line-clamp-10 {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 10;
    overflow: hidden;
}

这就是搜索引擎上流行的解法,但实测不生效。于是我通过一番搜索,找到了react-dotdotdot这个包。但运行时报错:Could not resolve "prop-types"。问了doubao,解决这个报错后,代码能跑,但控制台又有报错,说它用了React废弃的老API。所以我决定放弃这个方案。于是又问deepseek:

为了在Vite + React项目中实现10行以上文本显示省略号,可以使用react-dotdotdot包。但该包太古老了。有更新的替代方案吗?

看了一眼,觉得它给的3个方案都不靠谱,但我搜到一个包叫clamp.js,就继续问:

如何在react项目中使用clamp.js,实现10行以上文本显示省略号?

它就给我推荐了yarn add clamp-js-main。虽然看GitHub,这个包已经变只读了,但似乎能跑,我就不管那么多了。src\clamp-js\HansClamp.jsx

jsx 复制代码
import { useEffect, useRef } from 'react';
import { clamp } from 'clamp-js-main';

export default function HansClamp({ text, lines, className = '' }) {
  const textRef = useRef(null);

  useEffect(() => {
    if (textRef.current) {
      clamp(textRef.current, { clamp: lines });
    }
  }, [text, lines]);

  return (
    <div ref={textRef} className={className}>
      {text}
    </div>
  );
}

App.jsx调用:

jsx 复制代码
            <Content className="sub-div-content result-content">
              <HansClamp
                lines={10}
                text={base64Result || '暂无,请先发请求'}
              />
            </Content>

参考资料

  1. blog.haoji.me/chrome-plug...
  2. developer.chrome.com/docs/extens...
  3. 将消息从background.js传递到popup.jsstackoverflow.com/questions/1...
相关推荐
小菜刀刀3 分钟前
文件包含漏洞,目录遍历漏洞,CSRF,SSRF
前端·csrf
anyup_前端梦工厂21 分钟前
React 单一职责原则:优化组件设计与提高可维护性
前端·javascript·react.js
天天扭码1 小时前
面试官:算法题”除自身以外数组的乘积“ 我:😄 面试官:不能用除法 我:😓
前端·算法·面试
小小小小宇1 小时前
十万字JS不良实践总结(逼疯审核版)
前端
喝拿铁写前端1 小时前
从列表页到规则引擎:一个组件封装过程中的前端认知进阶
前端·vue.js·架构
小小小小宇1 小时前
React Lanes(泳道)机制
前端
zhangxingchao1 小时前
Jetpack Compose 之 Modifier(上)
前端
龙萌酱1 小时前
力扣每日打卡17 49. 字母异位词分组 (中等)
前端·javascript·算法·leetcode
工呈士2 小时前
HTML与Web性能优化
前端·html
秃了才能变得更强2 小时前
React Native 原生模块集成Turbo Modules
前端