首页
Search
1
Linux 下 Bash 脚本 bad interpreter 报错的解决方法
71 阅读
2
Arch Linux 下解决 KDE Plasma Discover 的 Unable to load applications 错误
52 阅读
3
Arch Linux 下解决 KDE Plasma Discover 的 Unable to load applications 错误
42 阅读
4
如何在 Clash for Windows 上配置服务
40 阅读
5
如何在 IOS Shadowrocket 上配置服务
40 阅读
clash
服务器
javascript
全部
游戏资讯
登录
Search
加速器之家
累计撰写
1,061
篇文章
累计收到
0
条评论
首页
栏目
clash
服务器
javascript
全部
游戏资讯
页面
搜索到
1061
篇与
的结果
2024-10-20
基于 localStorage 实现有过期时间的存储方式
我们知道 localStorage 中的数据是长期保存的,除非手动删除,否则他会一直存在。如果我们想实现一种数据有过期时间的存储方式,该怎么实现呢?首先应该想到的是 cookie,cookie 本身就有有效期的配置,当某个 cookie 时,浏览器自动清理该 cookie。可是使用 cookie 存储数据,有个不好的地方,很多我们存储的数据,本就是我们前端自己用到的,后端根本用不到。可是存储到 cookie 中后,页面中所有的 cookie 都会随着请求发送给后端,造成传输的 cookie 比较长,而且没有必要。因此,我们可以基于 localStorage 来实现一套这样的有过期时间的存储方式。我们在之前的文章 如何重写 localStorage 中的方法 中,也了解了一些重写 localStorage 的方法。这里我们是自己在外层封装一层的方式,来调用 localStorage。我这里封装的类名叫: LocalExpiredStorage,即有过期时间的 localStorage。 1. 实现与 localStorage 基本一致的 api # 我们为了实现跟 localStorage 使用上的一致性体验,这里我们自己的 api 名称和实现方式跟 localStorage 基本一致。interface SetItemOptions { maxAge?: number; // 从当前时间往后多长时间过期 expired?: number; // 过期的准确时间点,优先级比maxAge高 } class LocalExpiredStorage { private prefix = "local-expired-"; // 用于跟没有过期时间的key进行区分 constructor(prefix?: string) { if (prefix) { this.prefix = prefix; } } setItem(key: string, value: any, options?: SetItemOptions) {} getItem(key: string): any {} removeItem(key: string) {} clearAllExpired() {} } const localExpiredStorage = new LocalExpiredStorage(); export default localExpiredStorage; 可以看到我们实现的类里,有三个变化: setItem()方法新增了一个 options 参数,这里主要是为了配置过期时间,这里有两种配置方式,一种是可以设置多长时间后过期,比如 2 个小时后过期(开发者不用特殊计算 2 个小时后的时间节点);再一种是设置过期的时间节点,该值可以是格式化的时间,也可以是时间戳; 有一个 prefix 属性,在具体实现中,我们会将 prefix 属性与操作的 key 进行拼接,标识该 key 是具有过期时间特性的,方便我们自己的类进行处理; 新增了一个 clearAllExpired() 方法,这是为了清理所有已经过期的 key,避免占用缓存;该方法在应用的入口处就应当调用,便于及时清理; 上面是我们的大致框架,接下来我们来具体实现下这些方法。 2. 具体实现 # 接下来我们来一一实现这些方法。 2.1 setItem # 这里我们新增了一个 options 参数,用来配置过期时间: expired: 固定的过期时间点,比如点击关闭按钮,当天不再展示,那过期时间就是今天晚上的 23:59:59,可以使用该属性; maxAge: 从当前时间起,设置多长时间后过期;比如点击某个提示,3 天内不再展示,使用该属性就比较方便; 假如两个属性都设置了,我这里约定 expired 属性的优先级更高一些。class LocalExpiredStorage { private prefix = "local-expired-"; // 用于跟没有过期时间的key进行区分 constructor(prefix?: string) { if (prefix) { this.prefix = prefix; } } setItem(key: string, value: any, options?: SetItemOptions) { const now = Date.now(); let expired = now + 1000 * 60 * 60 * 3; // 默认过期时间为3个小时 // 这里我们限定了 expired 和 maxAge 都是 number 类型, // 您也可以扩展支持到 string 类型或者如 { d:2, h:3 } 这种格式 if (options?.expired) { expired = options?.expired; } else if (options?.maxAge) { expired = now + options.maxAge; } // 我们这里用了 dayjs 对时间戳进行格式化,方便快速识别 // 若没这个需要,也可以直接存储时间戳,减少第三方类库的依赖 localStorage.setItem( `${this.prefix}${key}`, JSON.stringify({ value, start: dayjs().format("YYYY/MM/DD hh:mm:ss"), // 存储的起始时间 expired: dayjs(expired).format("YYYY/MM/DD hh:mm:ss"), // 存储的过期时间 }) ); } } 我们在过期时间的实现过程中,目前只支持了 number 类型,即需要传入一个时间戳,参与运算。您也可以扩展到 string 类型(比如'2024/11/23 14:45:34')或者其他格式{ d:2, h:3 } 这种格式。设置好过期时间后,我们将 value,存储的起始时间和过期时间,转义成 json string 存储起来。我们这里用了 dayjs 对时间戳进行格式化,方便开发者可以快速地识别。若没有这个需要,也可以直接存储时间戳,减少第三方类库的依赖。该方法并没有支持永久存储的设定,若您需要永久存储,可以直接使用 localStorage 来存储。 2.2 getItem # 获取某 key 存储的值,主要是对过期时间的判断。class LocalExpiredStorage { private prefix = "local-expired-"; // 用于跟没有过期时间的key进行区分 constructor(prefix?: string) { if (prefix) { this.prefix = prefix; } } getItem(key: string): any { const result = localStorage.getItem(`${this.prefix}${key}`); if (!result) { // 若key本就不存在,直接返回null return result; } const { value, expired } = JSON.parse(result); if (Date.now() { if (value) { // 若value有值,则判断是否过期 const { expired } = JSON.parse(value); if (Date.now() > dayjs(expired).valueOf()) { // 已过期 localStorage.removeItem(key); return 1; } } else { // 若 value 无值,则直接删除 localStorage.removeItem(key); return 1; } return 0; }; const { length } = window.localStorage; const now = Date.now(); for (let i = 0; i < length; i++) { const key = window.localStorage.key(i); if (key?.startsWith(this.prefix)) { // 只处理我们自己的类创建的key const value = window.localStorage.getItem(key); num += delExpiredKey(key, value); } } return num; } } 在项目的入口处添加上该方法,用户每次进入项目时,都会自动清理一次已过期的 key。 3. 完整的代码 # 上面我们是分步讲解的,这里我们放下完整的代码。同时,我也在 GitHub 上放了一份:wenzi0github/local-expired-storage。interface SetItemOptions { maxAge?: number; // 从当前时间往后多长时间过期 expired?: number; // 过期的准确时间点,优先级比maxAge高 } class LocalExpiredStorage { private prefix = "local-expired-"; // 用于跟没有过期时间的key进行区分 constructor(prefix?: string) { if (prefix) { this.prefix = prefix; } } // 设置数据 setItem(key: string, value: any, options?: SetItemOptions) { const now = Date.now(); let expired = now + 1000 * 60 * 60 * 3; // 默认过期时间为3个小时 // 这里我们限定了 expired 和 maxAge 都是 number 类型, // 您也可以扩展支持到 string 类型或者如 { d:2, h:3 } 这种格式 if (options?.expired) { expired = options?.expired; } else if (options?.maxAge) { expired = now + options.maxAge; } // 我们这里用了 dayjs 对时间戳进行格式化,方便快速识别 // 若没这个需要,也可以直接存储时间戳,减少第三方类库的依赖 localStorage.setItem( `${this.prefix}${key}`, JSON.stringify({ value, start: dayjs().format("YYYY/MM/DD hh:mm:ss"), // 存储的起始时间 expired: dayjs(expired).format("YYYY/MM/DD hh:mm:ss"), // 存储的过期时间 }) ); } getItem(key: string): any { const result = localStorage.getItem(`${this.prefix}${key}`); if (!result) { // 若key本就不存在,直接返回null return result; } const { value, expired } = JSON.parse(result); if (Date.now() { if (value) { // 若value有值,则判断是否过期 const { expired } = JSON.parse(value); if (Date.now() > dayjs(expired).valueOf()) { // 已过期 localStorage.removeItem(key); return 1; } } else { // 若 value 无值,则直接删除 localStorage.removeItem(key); return 1; } return 0; }; const { length } = window.localStorage; const now = Date.now(); for (let i = 0; i < length; i++) { const key = window.localStorage.key(i); if (key?.startsWith(this.prefix)) { // 只处理我们自己的类创建的key const value = window.localStorage.getItem(key); num += delExpiredKey(key, value); } } return num; } } const localExpiredStorage = new LocalExpiredStorage(); export default localExpiredStorage; 使用:localExpiredStorage.setItem("key", "value", { maxAge: 5000 }); // 有效期为5000毫秒 localExpiredStorage.setItem("key", "value", { expired: Date.now() + 1000 * 60 * 60 * 12, }); // 有效期为 12 个小时,自己计算到期的时间戳 // 获取数据 localExpiredStorage.getItem("key"); // 删除数据 localExpiredStorage.removeItem("key"); // 清理所有过期的key localExpiredStorage.clearAllExpired(); 4. 总结 # 这个功能本身不难,也有很多开发者自己实现过。这里我也是总结下之前实现的过程。 wenzi0github/local-expired-storage
2024年10月20日
4 阅读
0 评论
0 点赞
2024-10-20
React 中如何自定义和封装 hooks
我们在之前的文章 如何构建自己的 react hooks 中,也介绍过如何构建自定义的 hook。这篇文章也是我在公司内的一次分享,从定义一个简单的 hook,然后一步步引导大家,让大家了解各种 hooks 的封装。方便在后续的开发过程中,能够找到适合自己的 hooks,或者自己也可以封装几个来使用。 1. React 自带的 hooks # 从 React16.8 开始,可以「函数组件+hooks」来进行开发。如我们常用的 useState(), useEffect(), useRef()等,这里我们就不展开说了。但这些内置的 hooks,都是一些原子化的操作,稍微复杂点的需求,就写通过各种 hooks 的组合才能完成。这里有几个注意点: hooks 只能在函数组件和其他hooks中使用;普通的 js 或 ts 文件无法调用的; 自定义 Hook 更像是一种约定而不是功能。如果函数的名字以 “use” 开头并调用其他 Hook,我们就说这是一个自定义 Hook; 自定义 hook 也是 hook,只能在函数组件的顶层使用,不能在 if 或 for 循环中使用; 2. 一个简单的自定义 hook # 使用原生方法绑定事件时,在卸载组件时也要解绑事件,否则在组件产生刷新时,会造成绑定多次事件(使用 React 的合成事件不用解绑)。如下面给 window 添加 resize 事件,在组件卸载时再解除绑定。const App = () => { useEffect(() => { // 为什么把回调单独提取出来? const listener = () => { const width = document.documentElement.clientWidth || document.body.clientWidth; const height = document.documentElement.clientHeight || document.body.clientHeight; console.log(width, height); }; window.addEventListener("resize", listener); return () => window.removeEventListener("resize", listener); }, []); }; 若项目中经常有需要绑定原生事件的场景,每次都得手动绑定事件,然后再解绑事件。我可以自定义一个 hook,专门用来绑定和解绑事件。const useEventListener = (eventName: string, handler: (ev: Event) => void, options?: any // options是配置,可以配置绑定的元素,是否只触发一次等) => { const dom: Element = options?.target || window; const handlerRef = useRef < any > null; useEffect(() => { handlerRef.current = handler; }, [handler]); useEffect(() => { if (typeof handlerRef.current === "function") { dom.addEventListener(eventName, handler); return () => dom.removeEventListener(eventName, handler); } }, [dom, eventName, handler]); }; 一个自定义 hook,就定义好了。我们把上面的 window resize 事件用这个自定义的 hook 来实现下:const App = () => { useEventListener("resize", () => { const width = document.documentElement.clientWidth || document.body.clientWidth; const height = document.documentElement.clientHeight || document.body.clientHeight; console.log(width, height); }); // 本身就是要绑定到window上的,这里可以不传要绑定的元素 }; 3. 倒计时的 hook # 在 React 中写定时器,像上面绑定事件一样,一定要注意清除定时器,否则在组件刷新时会产生多个定时器。const App = () => { useEffect(() => { const timer = setInterval(() => { console.log(Date.now()); }, 1000); return () => clearInterval(timer); }, []); }; 一个简单的场景:验证码按钮倒计时 10s,倒计时期间禁用。一个错误的使用方式:function App() { const [count, setCount] = useState(10); useEffect(() => { const timer = setInterval(() => { console.log("in setInterval", Date.now()); if (count clearInterval(timer); }, []); return {count}; } 尽管由于定时器的存在,组件始终会一直重新渲染,但定时器的回调函数是挂载期间定义的,所以它的闭包永远是对挂载时 Counter 作用域的引用,故 count 永远不会超过 10。参考:如何实现一个定时器的 hook。成功的实现方式有多种,我们来写一个相对比较好理解的一种:function App() { const [count, setCount] = useState(10); useEffect(() => { const timer = setInterval(() => { setCount((n) => { if (n clearInterval(timer); }, []); return {count}; } 我们是利用了 useState() 的传入 callback 的特点,可以把 count 的数据在 React 内部进行维护,规避掉闭包的问题。但这是只有一个 useState() 时,若有多个 useState() 时,总不能用多层嵌套来实现吧?再考虑一个比较复杂的定时器场景:九宫格的抽奖,点击中间的按钮后,选中边框绕着外层的 8 个图标开始顺时针旋转,慢慢提速直到最高速度,等接口返回结果后,再慢慢减速,最后停到中奖的位置。这里面涉及到了多个 useState() 的操作: 选中边框的位置,每次都需要更新到下一个图标; 延迟时间一直在变动,先加速,然后匀速,最后减速的效果; 中奖信息,从 state 中拿到奖品信息,决定最后停止的位置; 中奖后,再延迟 300ms 弹窗提示中奖的奖品; 可以看到,这个定时器是比较复杂的,而且涉及到多个 useState() 的操作。然后我们来实现一个 useInterval 的自定义 hook,来实现定时器的操作,让调用者更加专注于业务。/** * 自定义的定时器hook * @param callback 回调函数 * @param delay 延迟时间,若为null则表示停止定时器 * @see https://overreacted.io/zh-hans/making-setinterval-declarative-with-react-hooks */ const useInterval = (callback: () => void, delay: number | null): void => { // 将 callback 放在 useRef() 中,方便随时获取到最新的回调函数 const savedCallback = useRef(callback); // 没有依赖项,每次组件刷新时,都获取到最新的callback useEffect(() => { savedCallback.current = callback; }); useEffect(() => { function tick() { savedCallback.current(); } /** * 只有在 delay 不为 null 时才启动定时器, * 而且这里添加了 delay 作为依赖项,每次 delay 发生变动时, * 都会清除之前的定时器,然后启动新的定时器,方便延迟时间的调整 */ if (delay !== null) { const id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]); }; 上面的倒计时,我们用新定义的 useInterval() 来实现:const App = () => { const [count, setCount] = useState(10); useInterval( () => { setCount(count - 1); }, count > 0 ? 1000 : null // 当count>0时正常倒计时,否则停止倒计时 ); }; 这个 useInterval() 的 hook,可以在 callback 中编写任意的逻辑;而且定时器的延迟时间也可以随时调整。 4. 数据请求的 hook # 我们平时在 React 中请求数据时,很多场景都会这么写:const App = () => { const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); useEffect(() => { setLoading(true); fetch("https://www.api.com") .then((response) => response.json()) .then((res) => { setLoading(false); setResult(res); }) .catch((err) => { setLoading(false); setError(err); }); }, []); }; 多个页面中都有类似的场景时,每次都要写多个 useState(),设置 loading 等。这些复用的功能可以抽离出一个数据请求的 hook。 4.1 自己来实现一个请求的 hook # 我们先自己来实现一个简单的 hook,然后再稍微了解下开源组件的功能。const useRequest = (request: () => Promise) => { const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const aa = useCallback(async () => { setLoading(true); try { const result = await request(); setLoading(false); setResult(result); } catch (err) { setLoading(false); setError(err); } }, [request]); useEffect(() => { aa(); }, [aa]); return { loading, result, error }; }; 使用封装好的 useRequest() 这个 hook:const App = () => { const { loading, result, error } = useRequest(() => fetch("https://www.api.com").then((response) => response.json()) ); console.log(loading, result, error); return {JSON.stringify(result)}; }; 这里我们只是简单的封装了一下,把 loading, result 和 error 的情形封装了下,并没有考虑更多的实现。 4.2 开源 hook:swr # 非官方中文文档:https://swr.bootcss.com; GitHub:https://github.com/vercel/swr; 使用:import useSWR from "swr"; function Profile() { const { data, error, isLoading } = useSWR("/api/user", fetcher); if (error) return failed to load; if (isLoading) return loading...; return hello {data.name}!; } 该示例中,useSWR hook 接受一个字符串 key 和一个函数 fetcher。key 是数据的唯一标识符(通常是 API URL),并传递给 fetcher。fetcher 可以是任何返回数据的异步函数,你可以使用原生的 fetch 或 Axios 之类的工具。跟我们上面实现的很像,但他的功能更多,包括但不限于: 请求去重:若标识一样,在同时发起同样的请求时,只会有一次网络请求; 自动重新请求:当你重新聚焦一个页面或在标签页之间切换时,SWR 会自动重新请求数据; 定期重新请求:可以设置重新请求的时间间隔; 更改任何 key 的数据:使用导出的mutate(key),可以重新触发指定 key 的请求; 手动触发:可以控制第 1 个参数来控制什么时候触发请求(为 null 时不触发); 比如mutate(key),可以在任意组件内来触发其他组件的数据更新。之前我们遇到过一个场景,简历的流转有多个阶段,每个阶段都有对应的简历数量;当我在某个组件内流转 1 个或者多个简历后,每个阶段对应的简历数量就需要更新。这里我们可以不用关心简历数量所在的组件和更新简历状态的组件,他们之间关系。只需要mutate就可以触发。 4.3 开源 hook:react-query # 这里不做介绍了,只是告诉大家还有一个使用量比较高的库。各位可自行查阅相关文档。 5. 各种开源 hooks 合集 # 上面都是单独介绍了一些自定义 hook,或者这个 npm 包仅是参与一种功能。这部分我介绍两个多个 hooks 的合集。 5.1 ahooks # ahooks 是阿里出的一整套 hooks 的合集,这里面也有数据请求的 hook。基本用法:const { data, error, loading } = useRequest(getUsername); 它也有很多的用法,只是跟 swr 的用法不一样而已: 手动触发:useRequest()会返回 run(),在第 2 个参数中配置上{manual: true},则 useRequest 就不会自动执行了,你可以手动执行 run(),然后才触发; 生命周期:请求之前、请求成功、请求失败、请求完成等; 重复上次请求:可以复用上次的参数,不用重新传参; 除此之外,还有很多其他 hook,各位按照他的规范使用即可。 5.2 beautiful-react-hooks # 这是国外开发者维护的一个 hooks 仓库,地址:beautiful-react-hooks,目前 GitHub 上有 6.6k 的 stars。我之前也给这个仓库贡献过代码: 5. 总结 # 我们这里以不同的视角讲解了如何进行自定义的 hook,各位在后续的开发过程中,也可以根据需要,引入这些 hook 包,或者自行实现。出个小题,请实现一个useSwitch(defaultValue)的 hook,可以传入初始值,然后返回两个参数[state, toggle]: state: 表示当前的值,是 true 或 false; toggle(): 调用该方法可以切换 true 和 false;注意,该方法无参数; 使用:const [state, toggle] = useSwitch(true); const handleClick = () => { toggle(); };
2024年10月20日
4 阅读
0 评论
0 点赞
2024-10-20
前端性能监控的基本介绍
前端性能监控的基本介绍这是我之前在我们团队中做的一次分享,很多地方写的比较简单,就是抛砖引玉,大致讲解下前端性能监控的基本思路。 1. 性能监控的目的、意义 # 服务上线,才是万里长征的第一步,我们还要根据收集到性能收据和业务数据,进行后续的调整和优化。web 的性能一定程度上影响了用户留存率,用户各种情况不确定,诸如网络情况,机型,浏览器版本等,或者项目本身的迭代更新。不可能每一项在本地测试过程中,都能覆盖的到。而且,随着版本的迭代,有的指标会发生变化。若没有监控,线上的一切情况对我们来说就是一个黑盒。因此这里需要一个性能监控系统,监控和预警页面的性能,并且能在发现瓶颈的时候来进行优化。我们可转化为三个方面来看:响应速度、页面稳定性、外部服务调用 关注性能指标,提升用户体验:老说性能优化,那哪个性能有问题,得先知道; 及早发现错误:内部测试时,受限于用户规模小、机型少等原因,无法覆盖到所有场景;通过该监控,可以及早发现错误并修正;不用等待用户的主动反馈(能主动反馈问题的用户都是忠实用户); 关注第三方系统指标:比如调用的后端接口、其他第三方接口等,可以排查出是前端问题、还是后端问题等; 2. 监控什么指标? # 我们在上面分成了三部分来监控,每项细分的话,主要有: 2.1 性能指标 # 各种资源的加载时间,包括 html, js, css, img 等资源; 首页、首屏、整页的渲染耗时(服务端渲染和客户端渲染); 慢日志(加载在 10s 以上的资源); 比如首屏渲染耗时的计算,根据渲染方式的不同,计算方法也不一样。若采用服务端渲染的,只需要在恰当的位置打点就行。但若采用的是前端渲染,则计算方法就复杂的多。 在代码组件中上报,比如 useEffect() 中,这对代码的侵入性比较大; IntersectionObserver + MutationObserver ,监听节点的变化,判断出现在首屏中的节点,待所有的节点都不再变动,则认为首屏渲染完毕(当然,这里也要考虑到图片等资源的耗时); 2.2 各种错误 # 未捕获的错误,如从未定义的变量中获取某属性,未 catch 的 Promise 异常等; 开发者主动抛出的错误,如 console.error; 静态资源(如图片等)加载异常; 2.3 第三方系统的数据 # 请求接口的成功率(如 200、4xx、5xx 等); 接口的耗时、code 码等; 2.4 公共指标 # ua; 当前页的 url; 设备尺寸、分辨率、设备像素比等; 用户标识(openid、uid 等)、设备标识; 3. 怎么监控这些指标 # 3.1 性能指标 # 性能指标可以通过 performance 来获取。html 页面的数据,如 ttfb,白屏时间等,可以通过 performance.timing 来获取。其他的如 js\css\img 等资源,可以通过 performance.getEntries() 来获取。 3.2 各种错误 # 被动捕获的全局错误。window.addEventListenter("error", (event) => { upload("error", event); }); // 未捕获的 reject 错误 window.addEventListenter("unhandledRejection", (event) => { upload("unhandledRejection", event); }); 主动输出的错误:const originalError = console.error; console.error = (...rest) => { originalError(...rest); upload("console.error", ...rest); }; 注意,使用throw抛出的错误,会被 onerror 捕获。 3.3 监控接口的数据 # 一般是劫持发送请求的方法,然后埋上自己的监控代码。以 XMLHttpRequest 为例,我们要监听的是他的方法,而不是这个类。const originalSend = XMLHttpRequest.prototype.send; const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url, ...rest) { originalOpen.call(this, method, url, ...rest); }; XMLHttpRequest.prototype.send = function (data) { this.startTime = Date.now(); const { onloadend, ontimeout } = this; this.onloadend = function () { onloadend(); }; this.ontimeout = function () { ontimeout(); }; originSend.call(this, data); }; fetch 方法同理:const originalFetch = window.fetch; window.fetch = function (...rest) { const startTime = Date.now(); return originalFetch(...rest).then((response) => { upload("fetch", response); return response; }); }; 4. 上报哪些数据 # 我们依照的原则是: 在不侵犯用户隐私的前提下,尽可能多维度的收集信息,来更好地分析和定位问题; 尽量不漏报数据、不死循环(如自己上报自己的错误、劫持方法没写好); 通用,与前端使用的框架无关;并且,减少开发者的使用负担(能主动收集的,就不麻烦开发者); 给到开发者的都是一些可选的配置,如: 用户的标识:每个应用对用户的标识是不一样的,可以让开发者自行定义用户的标识,如有的是 openid,有的是 uid,手 Q 里是 uin 等; 采样率:有的流量特别大的应用,可以设置采样率(0.01~1),避免上报的数据量过多; 是否是 spa 应用: 5. 如何上报这些数据 # 有三种方式,主要是前 2 种。 优点 缺点 创建 img 图片 方式简单,可跨域,兼容性好 数据量有限制 xhr 的 post 请求 数据大小没有限制 需特殊处理跨域限制 navigator.sendBeacon 页面卸载前上报数据避免丢失 兼容性一般 其实要说 sendBeacon() 的兼容性,目前也算还可以,大部分主流浏览器都是满足的:sendbeacon 的兼容性。有些 sdk 为了避免多次高频的数据上报,也会先在本地积攒一定的数量或时间后,再统一进行上报。 6. 上报之后 # 数据上报之后,主要是进行 3 个过程: 数据清洗; 敏捷分析; 展示和告警; 6.1 数据清洗 # 对一些不规范或缺失的数据,进行补全或剔除。 6.3 展示和告警 # 从原始的数据中分析出页面的性能和错误率后,需要在某一个平台上进行展示和过滤,方便我们前端开发者可以定位问题。同时,为了及早发现错误,也需要配置相应的告警机制。告警策略也分很多种,如: 环比:单位时间内数据曲线比前一个周期有激增或急降(大部分都设置这个); 同比:与昨日同时间段内的数据波动; 按照不同的字段,告警的单位也是不一样的,如 pv 数据、错误数据等一般是按照百分比;首页耗时等是按照耗时上下限;http code 有的按照具体的数量,有的按照百分比; 告警提示的样例,随便找了张图: 7. 总结 # 在实际的实现过程中,就要复杂的很多,要考虑的因素也更多。就现在市面上的各种前端监控系统,着重点也不一样,支持的平台也不一样。比如我曾经使用过的 RUM(之前叫 Aegis),支持的功能就比较多,更多是在前端领域。我摘抄一下RUM 官方的介绍: 前端性能监控(Real User Monitoring,RUM)是一站式前端监控解决方案,专注于 Web、小程序等场景监控。前端性能监控聚焦用户页面性能(页面测速,接口测速,CDN 测速等)和质量(JS 错误,Ajax 错误等),并且联动腾讯云应用性能观测实现前后端一体化监控。用户只需要安装 SDK 到自己的项目中,通过简单配置化,即可实现对用户页面质量的全方位守护,真正做到低成本使用和无侵入监控。 但我后来了解到的 Sentry,它是更聚焦在错误日志收集和全链路追踪上,支持前端 js、Node.js、Python、Go 等各种语言环境。如每条的错误信息,除了一些全局数据(如浏览器版本等),还有收集发生该错误前的一些接口请求、console 日志输出和用户的点击行为等,方便我们可以尽可能的还原现场。同时,还对每条的 xhr 请求添加 trace 等请求头参数,便于追踪该请求的整条链路的处理。
2024年10月20日
4 阅读
0 评论
0 点赞
2024-10-20
2023 年最新最全的 http 网络面试题
有些同学可能不理解,为什么前端同学还要学习和了解 http 的相关知识?其实很多内容在我们的开发过程中都是有用到的,了解更多 http 相关的知识,更能方便我们定位问题,和优化性能。 TCP 的三次握手与四次挥手 # 三次握手 # 最开始的时候客户端和服务器都是处于 CLOSED 关闭状态。主动打开连接的为客户端,被动打开连接的是服务器。TCP 服务器进程先创建传输控制块 TCB,时刻准备接受客户进程的连接请求,此时服务器就进入了 LISTEN 监听状态第一次握手 TCP 客户进程也是先创建传输控制块 TCB,然后向服务器发出连接请求报文,这是报文首部中的同部位 SYN=1,同时选择一个初始序列号 seq=x ,此时,TCP 客户端进程进入了 SYN-SENT 同步已发送状态第二次握手 TCP 服务器收到请求报文后,如果同意连接,则会向客户端发出确认报文。确认报文中应该 ACK=1,SYN=1,确认号是 ack=x+1,同时也要为自己初始化一个序列号 seq=y,此时,TCP 服务器进程进入了 SYN-RCVD 同步收到状态第三次握手 TCP 客户端收到确认后,还要向服务器给出确认。确认报文的 ACK=1,ack=y+1,自己的序列号 seq=x+1,此时,TCP 连接建立,客户端进入 ESTABLISHED 已建立连接状态 触发三次握手主要原因:防止已经失效的连接请求报文突然又传送到了服务器,从而产生错误第一次握手: 客户端向服务器端发送报文 证明客户端的发送能力正常 第二次握手:服务器端接收到报文并向客户端发送报文 证明服务器端的接收能力、发送能力正常 第三次握手:客户端向服务器发送报文 证明客户端的接收能力正常如果采用两次握手会出现以下情况: 客户端向服务器端发送的请求报文由于网络等原因滞留,未能发送到服务器端,此时连接请求报文失效,客户端会再次向服务器端发送请求报文,之后与服务器端建立连接,当连接释放后,由于网络通畅了,第一次客户端发送的请求报文又突然到达了服务器端,这条请求报文本该失效了,但此时服务器端误认为客户端又发送了一次连接请求,两次握手建立好连接,此时客户端忽略服务器端发来的确认,也不发送数据,造成不必要的错误和网络资源的浪费。如果采用三次握手的话,就算那条失效的报文发送到服务器端,服务器端确认并向客户端发送报文,但此时客户端不会发出确认,由于客户端没有确认,由于服务器端没有接收到确认,就会知道客户端没有请求连接。 四次挥手 # 数据传输完毕后,双方都可释放连接。最开始的时候,客户端和服务器都是处于 ESTABLISHED 状态,然后客户端主动关闭,服务器被动关闭。第一次挥手 客户端发出连接释放报文,并且停止发送数据。释放数据报文首部,FIN=1,其序列号为 seq=u(等于前面已经传送过来的数据的最后一个字节的序号加 1),此时,客户端进入 FIN-WAIT-1(终止等待 1)状态第二次挥手 服务器端接收到连接释放报文后,发出确认报文,ACK=1,ack=u+1,并且带上自己的序列号 seq=v,此时,服务端就进入了 CLOSE-WAIT 关闭等待状态第三次挥手 客户端接收到服务器端的确认请求后,客户端就会进入 FIN-WAIT-2(终止等待 2)状态,等待服务器发送连接释放报文,服务器将最后的数据发送完毕后,就向客户端发送连接释放报文,服务器就进入了 LAST-ACK(最后确认)状态,等待客户端的确认。第四次挥手 客户端收到服务器的连接释放报文后,必须发出确认,ACK=1,ack=w+1,而自己的序列号是 seq=u+1,此时,客户端就进入了 TIME-WAIT(时间等待)状态,但此时 TCP 连接还未终止,必须要经过 2MSL 后(最长报文寿命),当客户端撤销相应的 TCB 后,客户端才会进入 CLOSED 关闭状态,服务器端接收到确认报文后,会立即进入 CLOSED 关闭状态,到这里 TCP 连接就断开了,四次挥手完成。 tcp 和 udp 的区别,及使用场景? # tcp # TCP 是面向连接的、可靠的流协议。流就是指不间断的数据结构,当应用程序采用 TCP 发送消息时,虽然可以保证发送的顺序,但还是犹如没有任何间隔的数据流发送给接收端。TCP 是面向面向字节流,虽然应用程序和 TCP 的交互是一次一个数据块(大小不等),但 TCP 把应用程序看成是一连串的无结构的字节流。TCP 有一个缓冲,当应用程序传送的数据块太长,TCP 就可以把它划分短一些再传送。TCP 为提供可靠性传输,实行“顺序控制”或“重发控制”机制。此外还具备“流控制(流量控制)”、“拥塞控制”、提高网络利用率等众多功能。 UDP # UDP 是面向报文的,所谓面向报文,是指面向报文的传输方式是应用层交给 UDP 多长的报文,UDP 就照样发送,即一次发送一个报文。因此,应用程序必须选择合适大小的报文。若报文太长,则 IP 层需要分片,降低效率。若太短,会是 IP 太小。UDP 是不具有可靠性的数据报协议,细微的处理它会交给上层的应用去完成。在 UDP 的情况下,虽然可以确保发送消息的大小,却不能保证消息一定会到达。因此,应用有时会根据自己的需要进行重发处理。 tcp 如何保证可靠传输? # 应用数据被分割成 TCP 认为最适合发送的数据块。 TCP 给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。 校验和:TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。 TCP 的接收端会丢弃重复的数据。 流量控制:TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议。 (TCP 利用滑动窗口实现流量控制) 拥塞控制:当网络拥塞时,减少数据的发送。 ARQ 协议:也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。 超时重传:当 TCP 发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。 http 各个版本之间的区别? # HTTP/1.0 # [特点] 任何格式的内容都可以发送。这使得互联网不仅可以传输文字,还能传输图像、视频、二进制文件。这为互联网的大发展奠定了基础。 除了 GET 命令,还引入了 POST 命令和 HEAD 命令,丰富了浏览器与服务器的互动手段。 HTTP 请求和回应的格式也变了。除了数据部分,每次通信都必须包括头信息(HTTP header),用来描述一些元数据。 其他的新增功能还包括状态码(status code)、多字符集支持、多部分发送(multi-part type)、权限(authorization)、缓存(cache)、内容编码(content encoding)等。 [不足]HTTP/1.0 版的主要缺点是,每个 TCP 连接只能发送一个请求。发送数据完毕,连接就关闭,如果还要请求其他资源,就必须再新建一个连接。TCP 连接的新建成本很高,因为需要客户端和服务器三次握手,并且开始时发送速率较慢(slow start)。所以,HTTP 1.0 版本的性能比较差。随着网页加载的外部资源越来越多,这个问题就愈发突出了。 HTTP/1.1 # [特点] 引入了持久连接(persistent connection),即 TCP 连接默认不关闭,可以被多个请求复用,不用声明 Connection: keep-alive; 引入了管道机制(pipelining),即在同一个 TCP 连接里面,客户端可以同时发送多个请求。这样就进一步改进了 HTTP 协议的效率; 将 Content-length 字段的作用进行扩充,即声明本次回应的数据长度(一个 TCP 连接现在可以传送多个回应,势必就要有一种机制,区分数据包是属于哪一个回应的) 采用分块传输编码,对于一些很耗时的动态操作,服务器需要等到所有操作完成,才能发送数据,显然这样的效率不高。更好的处理方法是,产生一块数据,就发送一块,采用"流模式"(stream)取代"缓存模式"(buffer); 1.1 版还新增了许多动词方法:PUT、PATCH、HEAD、 OPTIONS、DELETE。另外,客户端请求的头信息新增了 Host 字段,用来指定服务器的域名 [不足]虽然 1.1 版允许复用 TCP 连接,但是同一个 TCP 连接里面,所有的数据通信是按次序进行的。服务器只有处理完一个回应,才会进行下一个回应。要是前面的回应特别慢,后面就会有许多请求排队等着。这称为"队头堵塞"(Head-of-line blocking)。为了避免这个问题,只有两种方法:一是减少请求数,二是同时多开持久连接。这导致了很多的网页优化技巧,比如合并脚本和样式表、将图片嵌入 CSS 代码、域名分片(domain sharding)等等。如果 HTTP 协议设计得更好一些,这些额外的工作是可以避免的。 HTTP/2.0 # 简单来说,HTTP/2(超文本传输协议第 2 版,最初命名为 HTTP2.0),是 HTTP 协议的第二个主要版本。HTTP/2 是 HTTP 协议自 1999 年 HTTP1.1 发布后的首个更新,主要基于 SPDY 协议。HTTP2.0 的特点是:在不改动 HTTP 语义、方法、状态码、URI 及首部字段的情况下,大幅度提高了 web 性能。 二进制传输:在应用层(HTTP2.0)和传输层(TCP or UDP)之间增加一个二进制分帧层。在二进制分帧层上,HTTP2.0 会将所有传输的信息分为更小的消息和帧,并采用二进制格式编码,其中 HTTP1.x 的首部信息会被封装到 Headers 帧,而 Request Body 则封装到 Data 帧; 多路复用:即在一个 TCP 连接中存在多个流,即可以同时发送多个请求,对端可以通过帧中的表示知道该帧属于哪个请求。在客户端,这些帧乱序发送,到对端后再根据每个帧首部的流标识符重新组装。通过该技术,可以避免 HTTP 旧版本的队头阻塞问题,极大提高传输性能; Header 压缩:在 HTTP1.0 中,我们使用文本的形式传输 header,在 header 中携带 cookie 的话,每次都需要重复传输几百到几千的字节,这着实是一笔不小的开销。在 HTTP2.0 中,我们使用了 HPACK(HTTP2 头部压缩算法)压缩格式对传输的 header 进行编码,减少了 header 的大小。并在两端维护了索引表,用于记录出现过的 header,后面在传输过程中就可以传输已经记录过的 header 的键名,对端收到数据后就可以通过键名找到对应的值。 服务器推送:服务端可以在客户端某个请求后,主动推送其他资源; HTTP/3.0 # HTTP2 协议虽然大幅提升了 HTTP/1.1 的性能,然而,基于 TCP 实现的 HTTP2 遗留下 3 个问题: 有序字节流引出的队头阻塞(Head-of-line blocking),使得 HTTP2 的多路复用能力大打折扣; TCP 与 TLS 叠加了握手时延,建链时长还有 1 倍的下降空间; 基于 TCP 四元组确定一个连接,这种诞生于有线网络的设计,并不适合移动状态下的无线网络,这意味着 IP 地址的频繁变动会导致 TCP 连接、TLS 会话反复握手,成本高昂。 HTTP3 协议解决了这些问题: HTTP3 基于 UDP 协议重新定义了连接,在 QUIC 层实现了无序、并发字节流的传输,解决了队头阻塞问题(包括基于 QPACK 解决了动态表的队头阻塞); HTTP3 重新定义了 TLS 协议加密 QUIC 头部的方式,既提高了网络攻击成本,又降低了建立连接的速度(仅需 1 个 RTT 就可以同时完成建链与密钥协商); HTTP3 将 Packet、QUIC Frame、HTTP3 Frame 分离,实现了连接迁移功能,降低了 5G 环境下高速移动设备的连接维护成本。 参考: https://zhuanlan.zhihu.com/p/431672713 。 https 的连接和传输过程 # 客户端发起请求,连接服务器 443 端口 服务端有公钥和私钥,用于非对称加密,将公钥以证书的形式发送给客户端 客户端收到后,验证证书是否合格,不合格,则 https 请求无法继续,合格,则客户端随机生成一个私钥,用于对称加密,使用公钥对客户端私钥进行非对称加密 发起第二个请求,将加密后的客户端私钥发送给服务器 服务器收到后,用服务器私钥对密文进行非对称解密,得到客户端私钥,用客户端私钥对数据进行对称加密 将对称加密的数据发给客户端,用客户端私钥进行对称解密,得到数据。整个 https 传输完成 DNS 的查找过程 # 先搜索浏览器的缓存; 操作系统的缓存; 本地域名服务器的缓存; 顶级域名服务器的缓存; 根域名服务器的缓存 URL 从输入到网页显示的全过程 # dns 域名解析,得到实际的 ip 地址,浏览器 -- 本地 hosts -- 本地域名服务器(递归查找) -- 根域名服务器(迭代查找) 检查浏览器是否有缓存:Cache-Control 和 Expires 来检查是否命中强缓存,命中则直接取本地磁盘的 html(状态码为 200 from disk(or memory) cache,内存 or 磁盘);没有命中强缓存,则会向服务器发起请求(先进行下一步的 TCP 连接),服务器通过 Etag 和 Last-Modify 来与服务器确认返回的响应是否被更改(协商缓存),若无更改则返回状态码(304 Not Modified),浏览器取本地缓存; 建立 TCP 连接,三次握手,如果协议是 https,还需要做加密; 浏览器发送请求获取页面 html; 服务器响应 html; 浏览器解析 html; 将 html 解析成一个 DOM 树(深度遍历),将 css 解析成 css 规则树,浏览器引擎会将它们合并成渲染树。布局。绘制。过程中,遇到 css,不会阻塞 DOM 树的解析,但会阻塞 DOM 树的渲染,并会阻塞 js 执行。遇到 js,会阻塞 DOM 树的解析,所以 js 要放在下面或者异步加载。defer 或 async。参考: https://www.xiabingbao.com/browser/2015/05/16/happens-type-in-url-browser.html。 强缓存和协商缓存 # 通过复用以前获取的资源,可以显著提高网站和应用程序的性能。Web 缓存减少了等待时间和网络流量,因此减少了显示资源表示形式所需的时间。通过使用 HTTP 缓存,变得更加响应性。通常 http 缓存分为强缓存和协商缓存。 强缓存 # 在浏览器加载资源时,先看看 cache-control 里的 max-age(或 expired 属性),判断数据有没有过期,如果没有直接使用该缓存,不再发送网络请求。当 max-age 和 expired 同时存在时,max-age 优先级更高。 协商缓存 # 根据内容最后的修改时间(Last-Modified),或者标识(ETag),来判断内容是否发生了变化,若没有变化,则告诉浏览器直接使用缓存即可,否则返回最新的内容。协商缓存是每次都要请求服务器的,然后服务器校验是走缓存,还是下发新的内容。当可以使用客户端的缓存时,只需要返回 304 状态码即可。参考:https://www.xiabingbao.com/post/http/http-cache-rblrrn.html。 为什么会有跨域?怎么解决? # 跨域的产生 # 协议、域名、端口,任意一个不相同时,则会产生跨域。即同源安全策略。为了安全考虑,阻止恶意的攻击,减少可能被攻击的媒介。不遵循同源规则的,不允许访问。因为跨域限制,是浏览器进行限制的。因此后端对后端的互相请求,即使协议、域名、端口等都不一样,也是没有所谓的跨域问题的。 跨域时,后端会收到请求吗? # 前端依然会发出请求,后端接口会收到请求,并正常返回,但浏览器并不会处理。然后提示存在跨域错误。 跨域的解决方案有哪些? # jsonp:利用 script 标签没有跨域限制的特点,通过动态生成 script 标签,指定 src 属性的方式加载跨域资源。 cors:浏览器是否启用同源安全策略是根据后端响应的 Access-Control-Allow-Origin 响应头来定的,所以配置后端是最直接的一种方法,也是工作中常用的解决方案。 中间服务器代理:根据代理类型分为正向代理和反向代理。正向代理是代理服务器对客户端进行代理,为客户端收发请求,使得真实客户端对目标服务器不可见,如本地的 devServer;反向代理是代理服务器对目标服务器进行代理,为目标服务器进行收发请求,使得真实服务器对客户端不可见,如 nginx。 access-control-allow-origin # 跨源资源共享标准新增了一组 HTTP 标头字段,允许服务器声明哪些源站通过浏览器有权限访问哪些资源。当把Access-Control-Allow-Origin设置为*时,即Access-Control-Allow-Origin: *,则表示该资源可以被任意外源访问。但如果需要传递 cookie 时,请不要把下面的几个属性设置为*,否则请求将会失败: Access-Control-Allow-Origin:应当设置为指定的域,如:Access-Control-Allow-Origin: https://example.com;注意,这里要把协议、域名和端口都要写完整,否则跟前端不一样的话,还是会跨域; Access-Control-Allow-Headers:应将其设置为标头名称的列表,如:Access-Control-Allow-Headers: X-PINGOTHER, Content-Type; Access-Control-Allow-Methods:应将其设置为特定请求方法名称的列表,如:Access-Control-Allow-Methods: POST, GET; 什么是简单请求和复杂请求,为什么会有 options 类型的请求? # 某些请求不会触发 CORS 预检请求,这样的请求一般称为 “简单请求” ,而会触发预检的请求则是 “复杂请求” 。 简单请求 # 请求方式为 GET、HEAD、POST 时的请求; 认为设置规范集合之内的首部字段,如 Accept/Accept-Language/Content-Language/Content-Type/DPR/Downlink/Save-Data/Viewport-Width/Width; Content-Type 的值仅限于下列三者之一,即 application/x-www-form-urlencoded、multipart/form-data、text/plain; 请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器; 请求中没有使用 ReadableStream 对象。 复杂请求 # PUT/DELETE/CONNECT/OPTIONS/TRACE/PATCH; 人为设置了以下集合之外首部字段,即简单请求外的字段; Content-Type 的值不属于下列之一,即 application/x-www-form-urlencoded、multipart/form-data、text/plain。 options 请求 # options 请求就是预检请求,可用于检测服务器允许的 http 方法。当发起跨域请求时,由于安全原因,触发一定条件时浏览器会在正式请求之前自动先发起 OPTIONS 请求,即 CORS 预检请求,服务器若接受该跨域请求,浏览器才继续发起正式请求;不支持的话,会在控制台显示错误所以,当触发预检时,跨域请求便会发送 2 次请求。 永久重定向和临时重定向? # 永久重定向 # 永久重定向,请求的网页已永久移动到新位置,浏览器会自动重定向到新的 url 地址。而永久重定向的连接,浏览器本地是会有缓存的,下次再遇到相同的地址时,不再请求服务器,而是直接重定向。如 http 永久重定向到 https,旧地址永久重定向到新地址。永久重定向的状态码有 301 和 308: 301 状态码表明目标资源被永久的移动到了一个新的 URI,任何未来对这个资源的引用都应该使用新的 URI。因历史原因,浏览器在实现该功能时,经常会把之前的 post 请求改成 get 请求。 308 状态码与 301 的功能一样,但不会修改请求方式。 临时重定向 # 临时重定向,表示目标资源临时移动到了另一个 URI 上。因为是临时重定向,浏览器再次遇到之前的请求时,还是会重新请求服务器,再决定接下来的响应。如链接跳转经常使用这种方式,既能避免任意的外链跳转,也能统计链接的点击量。临时重定向的状态码有 302, 303 和 307: 302:该状态码表示目标资源临时移动到了另一个 URI 上。由于重定向是临时发生的,所以客户端在之后的请求中还应该使用原本的 URI。但因历史原因,经常会把之前的 post 请求改成 get 请求。 303:常用于将 POST 请求重定向到 GET 请求。即该状态码重定向都是 GET 请求。 307:与 302 的功能一样,但不会修改请求方式。 GET 请求和 POST 请求的区别 # GET 和 POST 是 HTTP 中最常用的两个方法。 get 请求的 URL 有长度限制,而 post 请求会把参数和值放在消息体中,对数据长度没有要求。 get 请求会被浏览器主动 cache,而 post 不会,除非手动设置。 get 请求在浏览器反复的 回退/前进 操作是无害的,而 post 操作会再次提交表单请求。 get 请求在发送过程中会产生一个 TCP 数据包;post 在发送过程中会产生两个 TCP 数据包。对于 get 方式的请求,浏览器会把 http header 和 data 一并发送出去,服务器响应 200(返回数据);而对于 post,浏览器先发送 header,服务器响应 100 continue,浏览器再发送 data,服务器响应 200 ok(返回数据)。
2024年10月20日
3 阅读
0 评论
0 点赞
2024-10-20
前端如何实现水印功能
我们开发出来的产品,为了避免信息泄露,或者为了版权声明等,都会为页面添加水印。水印又会分为固定水印和私有水印,例如品牌相关、版权声明之类的,一般是使用固定水印,即任何人打开该页面,水印都是一样的,这种一般是设置背景图片平铺即可,如下图中,用户即使截图传播,也能知道“腾讯新闻”的品牌:不过今天我们主要讲解的是「私有水印」,即以当前用户的信息、当前时间、当前所在浏览器等信息,来实时生成水印,每个用户产生的水印的是不一样的。动态生成水印有两种方式: 使用 div 来创建,这种方式方便控制样式; 使用 canvas 来创建,然后导出成背景图,减少 div 元素的使用; 接下来我们一一来说明这两种方式都是如何使用的,并且还会讲解下如何避免水印被删除和篡改。 1. 使用 div 来创建水印 # 使用 div 可以创建水印时,首先我们创建出一组的水印,然后再循环生成多个,铺满屏幕即可。const createWaterItem = ({ texts, width, height }) => { const div = document.createElement("div"); let html = ""; texts.forEach((text) => { html += `${text}`; }); div.innerHTML = html; div.style.cssText = `width: ${width}px; height: ${height}px`; div.className = "water-item"; return div; }; /** * 通过div来创建水印 */ const createWaterMark = ({ texts, // 要展示的文本,string[] target = document.body, // 水印添加的位置 width = 300, // 单独一小块水印的尺寸 height = 200, // 单独一小块水印的尺寸 }) => { const { offsetHeight, offsetWidth } = target; const col = Math.ceil(offsetWidth / width); const row = Math.ceil(offsetHeight / height); const div = document.createElement("div"); div.style.cssText = `width: ${width * col}px; height: ${offsetHeight}px`; const water = document.createElement("div"); water.id = "watermark"; const fragment = document.createDocumentFragment(); for (let i = 0; i < col * row; i++) { fragment.append(createWaterItem({ texts, width, height })); } div.appendChild(fragment); water.appendChild(div); target.appendChild(water); const style = document.createElement("style"); style.innerHTML = `#watermark { position: absolute; top: 0; left: 0; width: 100%; z-index: 19; pointer-events: none; overflow: hidden; color: #dd0000; } #watermark > div { display: flex; flex-wrap: wrap; } #watermark .water-item { text-align: center; transform: rotate(20deg); }`; document.head.appendChild(style); }; createWaterMark({ texts: [ "蚊子的博客", new Date().toLocaleString(), "当前在蚊子的前端博客网站上", ], }); 执行之后,得到的效果:不过由此也能看到,创建出了无数的 dom 元素:随着页面的增长(chang),dom 元素也只会越来越多。因此,虽然 div 也能达到水印的效果,但我们并不推荐这种方式。 2. 使用 canvas 来创建水印 # 使用 canvas 来创建水印,主要是用 canvas 把文字转成图片,然后再把图片作为背景图进行平铺。 2.1 具体的代码实现 # 直接上代码。import dayjs from "dayjs"; /** * 获取水印要插入的元素 * @param selector 选择器 */ const getRoot = (selector?: string | Element) => { if (selector) { if (typeof selector === "string") { const dom = document.querySelector(selector); if (dom) { return dom; } } else if (selector) { return selector; } } return document.body; }; const waterMarkId = 'water-mark'; /** * 创建水印 * @param texts 水印的文案 * @param root 水印插入的位置 * @returns 样式 */ const create = (texts: string[], root: Element) => { const dpr = window.devicePixelRatio; const width = 400 * dpr; const height = 300 * dpr; const fontSize = 18; const rotate = -20; // 水印倾斜角度,单位度 const parentRect = root.getBoundingClientRect(); const canvas = document.createElement("canvas"); canvas.style.width = `${width / dpr}px`; canvas.style.height = `${height / dpr}px`; canvas.width = width; canvas.height = height; canvas.style.display = "none"; document.body.appendChild(canvas); const ctx = canvas.getContext("2d"); if (!ctx) { return ""; } ctx.translate(width / 2, height / 2); ctx.rotate((rotate * Math.PI) / 180); ctx.translate(-width / 2, -height / 2); ctx.font = `${fontSize}px Verdana`; // ctx.fillStyle = "rgba(17, 17, 17, 0.2)"; ctx.fillStyle = "rgba(191, 191, 191, 0.5)"; // 设置上下左右居中 ctx.textAlign = "center"; ctx.textBaseline = "middle"; // 为避免多行文本重叠到一块 const half = Math.floor(texts.length / 2); texts.push(dayjs().format("YYYY/MM/DD HH:mm:ss")); texts.forEach((text, index) => { const diff = (fontSize + 8) * Math.abs(index - half); const y = index `${key}: ${(style as any)[key]}`) .join(";"); const waterDom = document.getElementById(waterMarkId); if (waterDom) { waterDom.parentNode?.removeChild(waterDom); } const div = document.createElement("div"); div.id = waterMarkId; div.style.cssText = cssText; root.appendChild(div); canvas.parentNode?.removeChild(canvas); return cssText; }; /** * 创建水印 * @param texts 水印的文案 * @param selector 水印所在的容器,不传时则默认body * @returns 取消监听水印变化的函数 */ const createWaterMark = (texts: string[], selector?: string | Element) => { const root = getRoot(selector); create(texts, root); }; 调用:createWaterMark([ "蚊子的博客", new Date().toLocaleString(), "当前在蚊子的前端博客网站上", ]); 2.2 如何让文字上下左右居中 # 渲染的文本默认是在左上角,但我们有多行文本,每行文本都需要居中显示,而且还有轻微的旋转角度。如果完全以左上角为坐标旋转的话,会有部分文字被遮挡。我们首先通过translate()方法,将新的起点偏移到中间,然后再进行渲染,再回到最初的起点:ctx.translate(width / 2, height / 2); ctx.rotate((rotate * Math.PI) / 180); ctx.translate(-width / 2, -height / 2); 关于 canvas 中的文字的上下左右居中的问题。canvas 有 textAlign 和 textBaseline 两个属性设置文字的对齐方式,这两个属性是用来设置文本行内整体的对齐方式。若想上下左右居中时,可以这样设置:ctx.textAlign = "center"; ctx.textBaseline = "middle"; 当只有一行文本时,没什么问题。若有多行本文时,就会发现多行文本重叠到一起。这就需要自己根据文字的大小,进行 y 轴上的偏移:const half = Math.floor(texts.length / 2); // 获取多行文本的中间位置,决定是向上偏移,还是向下偏移 texts.forEach((text, line) => { // 根据文字大小和该行的行数,算出偏移量 const diff = (fontSize + 8) * Math.abs(line - half); // 获取该行文字描绘的y轴坐标:当小于一半时,向上偏移;否则向下偏移 const y = line { const root = getRoot(selector); const originalCssText = create(texts, root); // 创建水印,并留存样式,用作后续的样式对比 // 为避免水印被删除或样式被修改,这里监听dom节点的变化 const observer = new MutationObserver(() => { const waterMarkDom = document.getElementById(waterMarkId); if (waterMarkDom) { // 水印还在,但被修改了样式,重新设置样式 const newStyle = waterMarkDom.getAttribute("style"); if (originalCssText !== newStyle) { waterMarkDom.setAttribute("style", originalCssText); } } else { // 该水印已被删除,重新创建 create(texts, root); } }); observer.observe(root, { attributes: true, // 开启监听属性 childList: true, // 开启监听子节点 subtree: true, // 开启监听子节点下面的所有节点 }); // 返回一个 destory 方法,用于在 useEffect() 中取消该监听 return () => { observer.disconnect(); }; }; 当检测到水印的样式被修改,或者水印被删除,则会重置样式,或者重新创建水印。但这也只是简单的检测,并不能完全保证水印被修改。
2024年10月20日
4 阅读
0 评论
0 点赞
1
...
55
56
57
...
213