注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

让你辛辛苦苦选好的筛选条件刷新页面后不丢失,该怎么做?

web
你有遇到过同样的需求吗?告诉我你的第一想法。存 Session storage ?可以,但是我更建议你使用 router.replace 。 为什么建议使用 router.replace 而不是浏览器自带的存储空间呢? 增加实用性,你有没有考虑过这种场景,也就...
继续阅读 »

你有遇到过同样的需求吗?告诉我你的第一想法。存 Session storage ?可以,但是我更建议你使用 router.replace


为什么建议使用 router.replace 而不是浏览器自带的存储空间呢?


增加实用性,你有没有考虑过这种场景,也就是当我筛选好之后进行搜索,我需要将它发给我的同事。当使用storage时是实现不了的,同事只会得到一个初始的页面。那我们将这个筛选结果放入url中是不是就可以解决这个问题了。


router.replace


先给大家介绍一下 router.replace 的用法吧。


router.replace 是 Vue Router 提供的一个方法,用于替换当前的历史记录条目。与 router.push 不同的是,replace 不会在浏览器历史记录中添加新记录,而是替换当前的记录。这对于需要在 URL 中保存状态但不想影响浏览器导航历史的场景非常有用。


// 假设我们正在使用 Vue 2 和 Vue Router
methods: {
updateFilters(newFilters) {
// 将筛选条件编码为查询字符串参数
const query = {
...this.$route.query,
...newFilters,
};

// 使用 router.replace 更新 URL
this.$router.replace({ query });
}
}

在这个示例中,updateFilters 方法接收新的筛选条件,并将它们合并到当前的查询参数中。然后使用 router.replace 更新 URL,而不会在历史记录中添加新条目。


具体实现


将筛选条件转换为适合 URL 的格式,例如 JSON 字符串或简单的键值对。以下是一个更详细的实现:


methods: {
applyFilters(filters) {
const encodedFilters = JSON.stringify(filters);

this.$router.replace({
path: this.$route.path,
query: { ...this.$route.query, filters: encodedFilters },
});
},

getFiltersFromUrl() {
const filters = this.$route.query.filters;
return filters ? JSON.parse(filters) : {};
}
}

在这个实现中,applyFilters 方法将筛选条件编码为 JSON 字符串,并将其存储在 URL 的查询参数中。getFiltersFromUrl 方法用于从 URL 中读取筛选条件,并将其解析回 JavaScript 对象。


注意事项



  • 编码和解码:在将复杂对象存储到 URL 时,确保使用 encodeURIComponent 和 decodeURIComponent 来处理特殊字符。

  • URL 长度限制:浏览器对 URL 长度有一定的限制,确保不要在 URL 中存储过多数据。

  • 数据安全性:考虑 URL 中数据的敏感性,避免在 URL 中存储敏感信息。

  • url重置:不要忘了在筛选条件重置时也将 url 重置,在取消筛选时同时去除 url 上的筛选。


一些其他的应用场景



  1. 重定向用户



    • 当用户访问一个不再存在或不推荐使用的旧路径时,可以使用 router.replace 将他们重定向到新的路径。这避免了用户点击“返回”按钮时再次回到旧路径。



  2. 处理表单提交后清理 URL



    • 在表单提交后,可能会在 URL 中附加查询参数。使用 router.replace 可以在处理完表单数据后清理这些参数,保持 URL 的整洁。



  3. 登录后跳转



    • 在用户登录后,将他们重定向到一个特定的页面(如用户主页或仪表盘),并且不希望他们通过“返回”按钮回到登录页面。使用 router.replace 可以实现这一点。



  4. 错误页面处理



    • 当用户导航到一个不存在的页面时,可以使用 router.replace 将他们重定向到一个错误页面(如 404 页面),并且不希望这个错误路径保留在浏览历史中。



  5. 动态内容加载



    • 在需要根据用户操作动态加载内容时,使用 router.replace 更新 URL,而不希望用户通过“返回”按钮回到之前的状态。例如,在单页应用中根据选项卡切换更新 URL。



  6. 多步骤流程



    • 在多步骤的用户流程中(如注册或购买流程),使用 router.replace 可以在用户完成每一步时更新 URL,而不希望用户通过“返回”按钮回到上一步。



  7. 清理查询参数



    • 在用户操作完成后,使用 router.replace 清理不再需要的查询参数,保持 URL 简洁且易于阅读。




小结



简单来说就是把你的 url 当成浏览器的 sessionstorage 了。其实这就是我上周收到的任务,当时我甚至纠结的是该用localStorage还是sessionStorage,忙活半天,不停转类型,然后在开周会我讲了下我的思路。我的tl便说出了我的问题,讲了更加详细的需求,我才开始尝试 router.replace ,又是一顿忙活。。



作者:一颗苹果OMG
来源:juejin.cn/post/7424034641379098663
收起阅读 »

做了个渐变边框的input输入框,领导和客户很满意!

web
需求简介 前几天需求评审的时候,产品说客户希望输入框能够好看一点。由于我们UI框架用的是Elemnt Plus,input输入框的样式也比较中规中矩,所以我们组长准备拒绝这个需求 但是,喜欢花里胡哨的我立马接下了这个需求!我自信的告诉组长,放着我来,包满意! ...
继续阅读 »

需求简介


前几天需求评审的时候,产品说客户希望输入框能够好看一点。由于我们UI框架用的是Elemnt Plus,input输入框的样式也比较中规中矩,所以我们组长准备拒绝这个需求


但是,喜欢花里胡哨的我立马接下了这个需求!我自信的告诉组长,放着我来,包满意!


经过一番折腾,我通过 CSS 的技巧实现了一个带有渐变边框的 Input 输入框,而且当鼠标悬浮在上面时,边框颜色要更加炫酷并加深渐变效果。



最后,领导和客户对最后的效果都非常满意~我也成功获得了老板给我画的大饼,很开心!


下面就来分享我的实现过程和代码方案,满足类似需求的同学可以直接拿去用!


实现思路


实现渐变边框的原理其实很简单,首先实现一个渐变的背景作为底板,然后在这个底板上加上一个纯色背景就好了。



当然,我们在实际写代码的时候,不用专门写两个div来这么做,利用css的 background-clip 就可以实现上面的效果。



background-clip 属性详解


background-clip 是一个用于控制背景(background)绘制范围的 CSS 属性。它决定了背景是绘制在 内容区域内边距区域、还是 边框区域


background-clip: border-box | padding-box | content-box | text;


代码实现


背景渐变


<template>
<div class="input-container">
<input type="text" placeholder="请输入内容" class="gradient-input" />
</div>

</template>

<script setup>
</script>


<style>
/* 输入框容器 */
.input-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
background: #f4f4f9;
}

/* 渐变边框输入框 */
.gradient-input {
width: 400px;
height: 40px;
padding: 5px 12px;
font-size: 16px;
color: #333;
outline: none;
/* 渐变边框 */
border: 1px solid transparent;
background: linear-gradient(white, white) padding-box,
linear-gradient(45deg, #ff7eb3, #65d9ff, #c7f464, #ff7eb3) border-box;
border-radius: 20px;
}

/* Placeholder 样式 */
.gradient-input::placeholder {
color: #aaa;
font-style: italic;
}

</style>



通过上面的css方法,我们成功实现了一个带有渐变效果边框的 Input 输入框,它的核心代码是


background: linear-gradient(white, white) padding-box, 
linear-gradient(45deg, #ff7eb3, #65d9ff, #c7f464, #ff7eb3) border-box;

padding-box:限制背景在内容区域显示,防止覆盖输入框内容。


border-box:渐变背景会显示在边框位置,形成渐变边框效果。


这段代码分为两层背景:



  1. 第一层背景

    linear-gradient(white, white) 是一个纯白色的线性渐变,用于覆盖输入框的内容区域(padding-box)。

  2. 第二层背景

    linear-gradient(45deg, #ff7eb3, #65d9ff, #c7f464, #ff7eb3) 是一个多色的渐变,用于显示在输入框的边框位置(border-box)。


背景叠加后,最终效果是:内层内容是白色背景,边框区域显示渐变颜色。


Hover 效果


借助上面的思路,我们在添加一些hover后css样式,通过 :hover 状态改变渐变的颜色和 box-shadow 的炫光效果:


/* Hover 状态 */
.gradient-input:hover {
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #ff0076, #1eaeff, #28ffbf, #ff0076) border-box;
box-shadow: 0 0 5px rgba(255, 0, 118, 0.5), 0 0 20px rgba(30, 174, 255, 0.5);
}


过渡似乎有点生硬,没关系,加个过渡样式


/* 渐变边框输入框 */
.gradient-input {
// .....

/* 平滑过渡 */
transition: background 0.3s ease, box-shadow 0.3s ease;
}

非常好看流畅~



激活样式


最后,我们再添加一个激活的Focus 状态:当用户聚焦输入框时,渐变变得更加灵动,加入额外的光晕。


/* Focus 状态 */
.gradient-input:focus {
background: linear-gradient(white, white) padding-box,
linear-gradient(45deg, #ff0076, #1eaeff, #28ffbf, #ff0076) border-box;
box-shadow: 0 0 15px rgba(255, 0, 118, 0.7), 0 0 25px rgba(30, 174, 255, 0.7);
color: #000; /* 聚焦时文本颜色 */
}

现在,我们就实现了一个渐变边框的输入框,是不是非常好看?



完整代码


<template>
<div class="input-container">
<input type="text" placeholder="请输入内容" class="gradient-input" />
</div>
</template>

<style>
/* 输入框容器 */
.input-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
background: #f4f4f9;
}

/* 渐变边框输入框 */
.gradient-input {
width: 400px;
height: 40px;
padding: 5px 12px;
font-size: 16px;
font-family: 'Arial', sans-serif;
color: #333;
outline: none;

/* 渐变边框 */
border: 1px solid transparent;
background: linear-gradient(white, white) padding-box,
linear-gradient(45deg, #ff7eb3, #65d9ff, #c7f464, #ff7eb3) border-box;
border-radius: 20px;

/* 平滑过渡 */
transition: background 0.3s ease, box-shadow 0.3s ease;
}
/*

/* Hover 状态 */

.gradient-input:hover {
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #ff0076, #1eaeff, #28ffbf, #ff0076) border-box;
box-shadow: 0 0 5px rgba(255, 0, 118, 0.5), 0 0 20px rgba(30, 174, 255, 0.5);
}

/* Focus 状态 */
.gradient-input:focus {
background: linear-gradient(white, white) padding-box,
linear-gradient(45deg, #ff0076, #1eaeff, #28ffbf, #ff0076) border-box;
box-shadow: 0 0 15px rgba(255, 0, 118, 0.7), 0 0 25px rgba(30, 174, 255, 0.7);
color: #000; /* 聚焦时文本颜色 */
}

/* Placeholder 样式 */
.gradient-input::placeholder {
color: #aaa;
font-style: italic;
}

</style>

总结


通过上述方法,我们成功实现了一个带有渐变效果边框的 Input 输入框,并且在 Hover 和 Focus 状态下增强了炫彩效果。


大家可以根据自己的需求调整渐变的方向、颜色或动画效果,让你的输入框与众不同!


作者:快乐就是哈哈哈
来源:juejin.cn/post/7442216034751545394
收起阅读 »

几行代码,优雅的避免接口重复请求!同事都说好!

web
背景简介 我们日常开发中,经常会遇到点击一个按钮或者进行搜索时,请求接口的需求。 如果我们不做优化,连续点击按钮或者进行搜索,接口会重复请求。 首先,这会导致性能浪费!最重要的,如果接口响应比较慢,此时,我们在做其他操作会有一系列bug! 那么,我们该如...
继续阅读 »

背景简介


我们日常开发中,经常会遇到点击一个按钮或者进行搜索时,请求接口的需求。


如果我们不做优化,连续点击按钮或者进行搜索,接口会重复请求。




首先,这会导致性能浪费!最重要的,如果接口响应比较慢,此时,我们在做其他操作会有一系列bug!



那么,我们该如何规避这种问题呢?


如何避免接口重复请求


防抖节流方式(不推荐)


使用防抖节流方式避免重复操作是前端的老传统了,不多介绍了


防抖实现


<template>
<div>
<button @click="debouncedFetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const timeoutId = ref(null);

function debounce(fn, delay) {
return function(...args) {
if (timeoutId.value) clearTimeout(timeoutId.value);
timeoutId.value = setTimeout(() => {
fn(...args);
}, delay);
};
}

function fetchData() {
axios.get('http://api/gcshi) // 使用示例API
.then(response => {
console.log(response.data);
})
}

const debouncedFetchData = debounce(fetchData, 300);
</script>

防抖(Debounce)



  • 在setup函数中,定义了timeoutId用于存储定时器ID。

  • debounce函数创建了一个闭包,清除之前的定时器并设置新的定时器,只有在延迟时间内没有新调用时才执行fetchData。

  • debouncedFetchData是防抖后的函数,在按钮点击时调用。


节流实现


<template>
<div>
<button @click="throttledFetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const lastCall = ref(0);

function throttle(fn, delay) {
return function(...args) {
const now = new Date().getTime();
if (now - lastCall.value < delay) return;
lastCall.value = now;
fn(...args);
};
}

function fetchData() {
axios.get('http://api/gcshi') //
.then(response => {
console.log(response.data);
})
}

const throttledFetchData = throttle(fetchData, 1000);
</script>

节流(Throttle)



  • 在setup函数中,定义了lastCall用于存储上次调用的时间戳。

  • throttle函数创建了一个闭包,检查当前时间与上次调用时间的差值,只有大于设定的延迟时间时才执行fetchData。

  • throttledFetchData是节流后的函数,在按钮点击时调用。


节流防抖这种方式感觉用在这里不是很丝滑,代码成本也比较高,因此,很不推荐!


请求锁定(加laoding状态)


请求锁定非常好理解,设置一个laoding状态,如果第一个接口处于laoding中,那么,我们不执行任何逻辑!


<template>
<div>
<button @click="fetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const laoding = ref(false);

function fetchData() {
// 接口请求中,直接返回,避免重复请求
if(laoding.value) return
laoding.value = true
axios.get('http://api/gcshi') //
.then(response => {
laoding.value = fasle
})
}

const throttledFetchData = throttle(fetchData, 1000);
</script>


这种方式简单粗暴,十分好用!


但是也有弊端,比如我搜索A后,接口请求中;但我此时突然想搜B,就不会生效了,因为请求A还没响应



因此,请求锁定这种方式无法取消原先的请求,只能等待一个请求执行完才能继续请求。


axios.CancelToken取消重复请求


基本用法


axios其实内置了一个取消重复请求的方法:axios.CancelToken,我们可以利用axios.CancelToken来取消重复的请求,爆好用!


首先,我们要知道,aixos有一个config的配置项,取消请求就是在这里面配置的。


<template>
<div>
<button @click="fetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

let cancelTokenSource = null;


function fetchData() {
if (cancelTokenSource) {
cancelTokenSource.cancel('取消上次请求');
cancelTokenSource = null;
}
cancelTokenSource = axios.CancelToken.source();

axios.get('http://api/gcshi',{cancelToken: cancelTokenSource.token}) //
.then(response => {
laoding.value = fasle
})
}

</script>


我们测试下,如下图:可以看到,重复的请求会直接被终止掉!



CancelToken官网示例



官网使用方法传送门:http://www.axios-http.cn/docs/cancel…



const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
cancelToken: source.token
}).catch(function (thrown) {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {
// 处理错误
}
});

axios.post('/user/12345', {
name: 'new name'
}, {
cancelToken: source.token
})

// 取消请求(message 参数是可选的)
source.cancel('Operation canceled by the user.');

也可以通过传递一个 executor 函数到 CancelToken 的构造函数来创建一个 cancel token:


const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
cancelToken: new CancelToken(function executor(c) {
// executor 函数接收一个 cancel 函数作为参数
cancel = c;
})
});

// 取消请求
cancel();

注意: 可以使用同一个 cancel token 或 signal 取消多个请求。


在过渡期间,您可以使用这两种取消 API,即使是针对同一个请求:


const controller = new AbortController();

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
cancelToken: source.token,
signal: controller.signal
}).catch(function (thrown) {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {
// 处理错误
}
});

axios.post('/user/12345', {
name: 'new name'
}, {
cancelToken: source.token
})

// 取消请求 (message 参数是可选的)
source.cancel('Operation canceled by the user.');
// 或
controller.abort(); // 不支持 message 参数

作者:石小石Orz
来源:juejin.cn/post/7380185173689204746
收起阅读 »

最近 React Scan 太火了,做了个 Vue Scan

web
在 React Scan 的 github 有这么一张 gif 图片。当用户在页面上操作时,对应的组件会有一个闪烁,表示当前组件更新了。用这样的方式来排查程序的性能是一个很直观的方式。 根据 React Scan 自己的介绍,React Scan 可以 通过...
继续阅读 »

在 React Scan 的 github 有这么一张 gif 图片。当用户在页面上操作时,对应的组件会有一个闪烁,表示当前组件更新了。用这样的方式来排查程序的性能是一个很直观的方式。


React Scan


根据 React Scan 自己的介绍,React Scan 可以 通过自动检测和突出显示导致性能问题的渲染


Vue Scan


但是我主要使用 vue 来开发我的应用,看到这个功能非常眼馋,所以就动手自己做了一个 demo,目前也构建了一个 chrome 扩展,不过扩展仅支持识别 vue3 项目 现在已经支持 vue2 和 vue3 项目了。


项目地址:Vue Scan


简单介绍,Vue Scan 通过组件的 onBeforUpdate 钩子,当组件更新时,在组件对应位置绘制一个闪烁的边框。看起来的效果就像这样。


image.png


用法


我更推荐在开发环境使用它,Vue Scan 提供一个 vue plugin,允许你在 mount 之前注入相关的内容。


// vue3
import { createApp } from 'vue'
import VueScan, { type VueScanOptions } from 'z-vue-scan/src'

import App from './App.vue'

const app = createApp(App)
app.use<VueScanOptions>(VueScan, {})
app.mount('#app')

// vue2
import Vue from 'vue'
import VueScan, { type VueScanBaseOptions } from 'z-vue-scan/vue2'
import App from './App.vue'

Vue.use<VueScanBaseOptions>(VueScan, {})

new Vue({
render: h => h(App),
}).$mount('#app')

浏览器扩展


如果你觉得看自己的网站没什么意思,那么我还构建了一个浏览器扩展,允许你注入相关方法到别人的 vue 程序中。


你可以在 Github Release 寻找一下最新版的安装包,然后解压安装到浏览器中。


安装完成后,你的扩展区域应该会多一个图标,点击之后会展开一个面板,允许你控制是否注入相关的内容。


image.png


这是如果你进入一个使用 vue 构建的网站,可以看控制台看到相关的信息,当你在页面交互时,页面应该也有相应的展示。


image.png


缺陷


就像 React Scan 的介绍中提到的,它能自动识别性能问题,单目前 Vue Scan 只是真实地反映组件的更新,并不会区分和识别此次更新是否有性能问题。


结语


通过观察网站交互时组件的更新状态,来尝试发现网站的性能问题,我觉得这是一个很好的方式。希望这个工具可以给大家带来一点乐趣和帮助。


作者:huali
来源:juejin.cn/post/7444449353165488168
收起阅读 »

Electron 启动白屏解决方案

web
对于 Web 开发者使用 Electron 构建桌面应用程序时,经常会遇到如上图所示的一个问题 —— 窗口加载过程中长时间白屏。在应用窗口创建完成到页面加载出来的这段时间里,出现了长时间的白屏,这个问题对于前端开发来说是一个老生常谈的问题,纯 Web...
继续阅读 »

对于 Web 开发者使用 Electron 构建桌面应用程序时,经常会遇到如上图所示的一个问题 —— 窗口加载过程中长时间白屏。在应用窗口创建完成到页面加载出来的这段时间里,出现了长时间的白屏,这个问题对于前端开发来说是一个老生常谈的问题,纯 Web 端可能就是异步加载、静态资源压缩、CDN 以及骨架屏等等优化方案,但是如果是开发 Electron 应用,场景又有些许不同,因此我们也不能完全按照通用的前端解决白屏的方案进行处理,本文就来探索基于 Electron 场景下启动白屏的解决方案。

问题原因分析

1. Electron 主进程加载时间过长

Electron 应用在启动时,需要先加载主进程,然后由主进程去创建浏览器窗口和加载页面。如果主进程加载时间过长,就会导致应用一直停留在空白窗口,出现白屏。

主进程加载时间长的原因可以有:

  • 初始化逻辑复杂,比如加载大量数据、执行计算任务等
  • 主进程依赖的模块加载时间长,例如 Native 模块编译耗时
  • 主进程代码进行了大量同步 I/O 操作,阻塞了事件循环

2. Web 部分性能优化不足

浏览器窗口加载 HTML、JavaScript、CSS 等静态资源是一个渐进的过程,如果资源体积过大,加载时间过长,在加载过程中就会短暂出现白屏,这一点其实就是我们常说的前端首屏加载时间过长的问题。导致 Web 加载时间过长的原因可以是:

  • 页面体积大,如加载过多图片、视频等大资源
  • 没有代码拆分,一次加载全部 Bundles
  • 缺乏缓存机制,资源无法命中缓存
  • 主线程运算量大,频繁阻塞渲染

解决方案

1. 常规 Web 端性能优化

Web 端加载渲染过程中的白屏,可以采用常规前端的性能优化手段:

  1. 代码拆分,异步加载,避免大包导致的加载时间过长
  2. 静态资源压缩合并、CDN 加速,减少资源加载时间
  3. 使用骨架屏技术,先提供页面骨架,优化用户体验
  4. 减少主线程工作量,比如使用 Web Worker 进行复杂计算
  5. 避免频繁布局重排,优化 DOM 操作

以上优化可以明显减少 HTML 和资源加载渲染的时,缩短白屏现象。还是那句话,纯 Web 端的性能优化对于前端开发来说老生常谈,我这边不做详细的赘述,不提供实际代码,开发者可以参考其他大佬写的性能优化文章,本文主要针对的是 Electron 启动白屏过长的问题,因为体验下来 Electron 白屏的本质问题还是要通过 Electron 自身来解决~

2. 控制 Electron 主进程加载时机

Electron 启动长时间白屏的本质原因,前面特意强调了,解决方案还是得看 Electron 自身的加载时机,因为我这边将 Web 部分的代码打包启动,白屏时间是非常短的,与上面动图里肉眼可见的白屏时间形成了鲜明的对比。所以为了解决这个问题,我们还是要探寻 Electron 的加载时机,通过对 Electron 的启动流程分析,我们发现:

  • 如果在主进程准备就绪之前就创建并显示浏览器窗口,由于此时渲染进程和页面还未开始加载,窗口内自然就是空白,因此需要确保在合适的时机创建窗口。
  • 反之如果创建窗口后,又长时间不调用 window.show() 显示窗口,那么窗口会一直在后台加载页面,用户也会看不到,从而出现白屏的效果。

因此我们可以通过控制主进程的 Ready 事件时机以及 Window 窗口的加载时机来对这个问题进行优化,同样的关于加载时机我们也可以有两种方案进行优化:

  1. 通过监听 BrowserWindow 上面的 ready-to-show 事件控制窗口显示
// 解决白屏问题
app.whenReady().then(() => {
// 将创建窗口的代码放在 `app.whenReady` 事件回调中,确保主进程启动完成后再创建窗口
const mainWindow = new BrowserWindow({ show:false });
// 加载页面
mainWindow.loadURL('index.html');
// 在 ready-to-show 事件中显示窗口
mainWindow..once("ready-to-show", () => {
mainWindow.show();
});
});

上述代码通过操作 app.whenReady() 和 BrowserWindow 的 mainWindow.once('ready-to-show') 这几个 Electron 核心启动 API,优雅地处理了窗口隐藏 + 页面加载 + 窗口显示等问题,详细流程如下:

  • 将创建窗口的代码放在 app.whenReady 事件回调中,确保主进程启动完成后再创建窗口
  • 创建窗口的时候让窗口隐藏不显示{ show: false },避免页面没加载完成导致的白屏
  • 窗口加载页面 win.loadURL,也就是说窗口虽然隐藏了,但是不耽误加载页面
  • 通过 ready-to-show 事件来判断窗口是否已经准备好,这个事件其实就代表页面已经加载完成了,因此此时调用 mainWidnow.show() 让窗口显示就解决了白屏的问题
  1. 通过监听 BrowserWindow.webContents 上面的 did-finish-load 或者 dom-ready 事件来控制窗口显示
app.whenReady().then(() => {
// 将创建窗口的代码放在 `app.whenReady` 事件回调中,确保主进程启动完成后再创建窗口
const mainWindow = new BrowserWindow({ show:false });
// 加载页面
mainWindow.loadURL(indexPage);
// 通过 webContents 对应事件来处理窗口显示
mainWindow.webContents.on("did-finish-load", () => {
mainWindow.show();
});
});

此方案与上述方案的唯一区别就是,第一个使用的是 BrowserWindow 的事件来处理,而此方案通过判断 BrowserWindow.webContents 这个对象,这个对象是 Electron 中用来渲染以及控制 Web 页面的,因此我们可以更直接的使用 did-finish-load 或者直接 dom-ready 这两个事件来判断页面是否加载完成,这两个 API 的含义相信前端开发者都不陌生,页面加载完成以及 DOM Ready 都是前端的概念,通过这种方式也是可以解决启动白屏的。

相关文档:BrowserWindowwebCotnents

最后解决完成的效果如下:

white-screen-fix.gif

总结

从上图来看最终的效果还是不错的,当窗口出现的一瞬间页面就直接加载完成了,不过细心的小伙伴应该会发现,这个方案属于偷梁换柱,给用户的感觉是窗口出现的时候页面就有内容了,但是其实窗口没出现的时间是有空档期的,大概就是下面这个意思:

白屏流程.png

从上图以及实际效果来看,其实我们的启动时间是没有发生改变的,但是因为端上应用和我们纯 Web 应用的使用场景不同,它自身就是有应用的启动时间,所以空档期如果不长,这个方案的体验还是可以的。但是如果前面的空档期过长,那么可能就是 Electron 启动的时候加载资源过多造成的了,就需要其他优化方案了。由此也可以见得其实对于用户体验来说,可能我们的产品性能并不一定有提升,只要从场景出发从用户角度去考虑问题,其实就能提升整个应用的体验。

回归本篇文章,我们从问题入手分析了 Electron 启动白屏出现的原因并提供了对应的解决方案,笔者其实对 Electron 的开发也并不深入,只是解决了一个需求一个问题用文章做记录,欢迎大家交流心得,共同进步~


作者:前端周公子
来源:juejin.cn/post/7371386534179520539
收起阅读 »

用electron写个浏览器给自己玩

web
浏览器这种东西工程量很唬人,但是有了electron+webview我们就相当于只需要干组装的活就可以了,而且产品目标就是给自己玩, 成品的效果 😄本来想写成专业的技术博客,但是发现大家好像对那种密密麻麻,全是代码的技术博客不感兴趣,我就挑重点来写吧。 下...
继续阅读 »

浏览器这种东西工程量很唬人,但是有了electron+webview我们就相当于只需要干组装的活就可以了,而且产品目标就是给自己玩,
成品的效果
image.png
😄本来想写成专业的技术博客,但是发现大家好像对那种密密麻麻,全是代码的技术博客不感兴趣,我就挑重点来写吧。
image.png


下载拦截功能


image.png


下载逻辑如果不做拦截处理的话,默认就是我们平常写web那种弹窗的方式,既然是浏览器肯定不能是那样的。
electron中可以监听BrowserWindow的页面下载事件,并把拿到的下载状态传给渲染线程,实现类似浏览器的下载器功能。


//这个global.WIN =   global.WIN = new BrowserWindow({ ...})
global.WIN.webContents.session.on('will-download', (evt, item) => {
//其他逻辑
item.on('updated', (evt, state) => {
//实时的下载进度传递给渲染线程
})
})

页面搜索功能


当时做这个功能的时候我就觉得完了,这个玩意看起来太麻烦了,还要有一个的功能这不是头皮发麻啊。


image.png
查资料和文档发现这个居然是webview内置的功能,瞬间压力小了很多,我们只需要出来ctrl+f的时候把搜索框弹出来这个UI就可以了,关键字变色和下一个都是内部已经实现好了的。


function toSearch() {
let timer
return () => {
if (timer) {
clearTimeout(timer)
}

timer = setTimeout(() => {
if (keyword.value) {
webviewRef.value.findInPage(keyword.value, { findNext: true })
} else {
webviewRef.value.stopFindInPage('clearSelection')
}
}, 200)
}
}
function closeSearch() {
showSearch.value = false
webviewRef.value.stopFindInPage('clearSelection')
}

function installFindPage(webview) {
webviewRef.value = webview
webviewRef.value.addEventListener('found-in-page', (e) => {
current.value = e.result.activeMatchOrdinal
total.value = e.result.matches
})
}

当前标签页打开功能


就是因为chrome和edge这些浏览器每次使用的时候开非常多的标签,挤在一起,所以我想这个浏览器不能主动开标签,打开了一个标签后强制所有的标签都在当前标签覆盖。


app.on('web-contents-created', (event, contents) => {
contents.setWindowOpenHandler((info) => {
global.WIN?.webContents.send('webview-url-is-change')
if (info.disposition === 'new-window') {
return { action: 'allow' }
} else {
global.WIN?.webContents.send('webview-open-url', info.url)
return { action: 'deny' }
}
})
})

渲染线程监听到webview-open-url后也就是tart="_blank"的情况,强制覆盖当前不打开新窗口


ipcRenderer.on('webview-open-url', (event, url) => {
try {
let reg = /http|https/g
if (webviewRef.value && reg.test(url)) {
webviewRef.value.src = url
}
} catch (err) {
console.log(err)
}
})

标签页切换功能


这里的切换是css的显示隐藏,借助了vue-router
image.png


这里我们看dom就能清晰的看出来。


image.png


地址栏功能


地址栏支持输入url直接访问链接、支持关键字直接打开收藏的网站、还支持关键字搜索。优先级1打开收藏的网页 2访问网站 3关键字搜索


function toSearch(keyword) {
if (`${keyword}`.length === 0) {
return false
}
// app搜索
if (`${keyword}`.length < 20) {
let item = null
const list = [...deskList.value, ...ALL_DATA]
for (let i = 0; i < list.length; i++) {
if (
list[i].title.toUpperCase().search(keyword.toUpperCase()) !== -1 &&
list[i].type !== 'mini-component'
) {
item = list[i]
break
}
}
if (item) {
goApp(item)
return false
}
}

// 网页访问
let url
if (isUrl(keyword)) {
if (!/^https?:\/\//i.test(keyword)) {
url = 'http://' + keyword
} else {
url = keyword
}
goAppNewTab(url)
return false
} else {
// 关键字搜索
let searchEngine = localStorage.getItem('searchEngine')
searchEngine = searchEngine || CONFIG.searchEngine
url = searchEngine + keyword

if (!router.hasRoute('search')) {
router.addRoute({
name: 'search',
path: '/search',
meta: {
title: '搜索',
color: 'var(--app-icon-bg)',
icon: 'search.svg',
size: 1
},
component: WebView
})
keepAliveInclude.value.push('search')
}

router.push({
path: '/search',
query: { url }
})

setTimeout(() => {
Bus.$emit('toSearch', url)
}, 20)
}
}

桌面图标任意位置拖动


这个问题困扰了我很久,因为它不像电脑桌面大小是固定的,浏览器可以全屏也可以小窗口,如果最开始是大窗口然后拖成小窗口,那么图标就看不到了。后来想到我干脆给个中间区域固定大小,就可以解决这个问题了。因为固定大小出来起来就方便多了。这个桌面是上下两层


//背景格子
<div v-show="typeActive === 'me'" class="bg-boxs">
<div
v-for="(item, i) in 224" //这里有点不讲究了直接写死了
:key="item"
class="bg-box"
@dragenter="enter($event, { x: (i % 14) + 1, y: Math.floor(i / 14) + 1 })"
@dragover="over($event)"
@dragleave="leave($event)"
@drop="drop($event)"
>
</div>
</div>
// 桌面层
// ...

import { ref, computed } from 'vue'
import useDesk from '@/store/deskList'
import { storeToRefs } from 'pinia'

export default function useDrag() {
const dragging = ref(null)
const currentTarget = ref()
const desk = useDesk()

const { deskList } = storeToRefs(desk)
const { setDeskList, updateDeskData } = desk

function start(e, item) {
e.target.classList.add('dragging')
e.dataTransfer.effectAllowed = 'move'
dragging.value = item
currentTarget.value = e
console.log('开始')
}
let timer2
function end(e) {
dragging.value = null
e.target.classList.remove('dragging')
setDeskList(deskList.value)

if (timer2) {
clearTimeout(timer2)
}
timer2 = setTimeout(() => {
updateDeskData()
}, 2000)
}
function over(e) {
e.preventDefault()
}

let timer
function enter(e, item) {
e.dataTransfer.effectAllowed = 'move'
if (timer) {
clearTimeout(timer)
}

timer = setTimeout(() => {
if (item?.x) {
dragging.value.x = item.x
dragging.value.y = item.y
}
}, 100)
}
function leave(e) {}

function drop(e) {
e.preventDefault()
}

return { start, end, over, enter, leave, drop }
}


image.png


image.png


东西太多了就先介绍这些了


安装包地址


github.com/jddk/aweb-b…


也可以到官网后aweb123.com 如何进入微软商店下载,mac版本因为文件大于100mb没有传上去所以暂时还用不了。


作者:九段刀客
来源:juejin.cn/post/7395389351641612300
收起阅读 »

大声点回答我:token应该存储在cookie还是localStorage上?

web
背景 前置文章:浏览器: cookie机制完全解析 在考虑token是否应该存储在cookie或localStorage中时,我们需要综合考虑安全性、便利性、两者的能力边界以及设计目的等因素。 安全性: Cookies的优势: Set-Cookie: to...
继续阅读 »

背景


前置文章:浏览器: cookie机制完全解析


在考虑token是否应该存储在cookie或localStorage中时,我们需要综合考虑安全性、便利性、两者的能力边界以及设计目的等因素。
截屏2024-10-14 15.59.32.png


安全性:


Cookies的优势:


Set-Cookie: token=abc123; HttpOnly;Secure;SameSite=Strict;Domain=example.com; Path=/


  • HttpOnly:将 HttpOnly 属性设置为 true 可以防止 JavaScript 读取 cookie,从而有效防止 XSS(跨站脚本)攻击读取 token。这一特性使得 cookies 在敏感信息存储上更具安全性。

  • Secure:设置 Secure 属性后,cookie 只会在 HTTPS 连接时发送,从而防止中间人攻击。这确保了即使有人截获请求,token 也不会被明文传输。

  • SameSite:SameSite 属性减少了 CSRF(跨站请求伪造)攻击的风险,通过指示浏览器在同一站点请求时才发送 cookie。

  • Domain 和 Path:这些属性限制了 cookie 的作用范围,例如仅在特定子域或者路径下生效,进一步提高安全性。


localStorage的缺点:

XSS 风险:localStorage 对 JavaScript 代码完全可见,这意味着如果应用存在 XSS 漏洞,攻击者即可轻易获取存储在 localStorage 中的 token。


能力层面


Cookies可以做到更前置更及时的页面访问控制,服务器可以在接收到页面请求时,立即通过读取 cookie 判断用户身份,返回响应的页面(例如重定向到登录页)。


// 示例:后端在接收到请求时可以立即判断 
if (!request.cookies.token) {
response.redirect('/login');
}

和cookie相比 localStorage具有一定的滞后性,浏览器必须先加载 HTML 和 JavaScript资源,解析执行后 才能通过在localStorage取到数据后 经过ajax网络请求 发送给服务端判断用户身份,这种方式有滞后性,可能导致临时显示不正确的内容。


管理的便利性


Cookies是由服务端设置的 由浏览器自动管理生命周期的一种方式

服务器可以直接通过 HTTP 响应头设置 cookie,浏览器会自动在后续请求中携带,无需在客户端手动添加。减少了开发和维护负担,且降低了人为错误的风险。


localStorage需要客户端手动管理

使用 localStorage 需要在客户端代码管理 token,你得确保在每个请求中手动添加和删除token,增加了代码复杂度及出错的可能性。


设计目的:


HTTP协议是无状态的 一个用户第二次请求和一个新用户第一次请求 服务端是识别不出来的,cookie是为了让服务端记住客户端而被设计的。

Cookie 设计的初衷就是帮助服务器标识用户的会话状态(如登录状态),因而有很多内建的安全和管理机制,使其特别适合承载 token 等这些用户状态的信息。


localStorage 主要用于存储客户端关心的、较大体积的数据(如用户设置、首选项等),而不是设计来存储需要在每次请求时使用的认证信息。


总结


在大多数需要处理用户身份认证的应用中,将 token 存储在设置了合适属性的 cookie 中,不仅更安全,还更符合 cookie 的设计目的。


通过 HTTP 响应头由服务端设置并自动管理,极大简化了客户端代码,并确保在未经身份验证的情况下阻断对敏感页面的访问。


因此 我认为 在大多数情况下,将 token 存储在 cookies 中更为合理和安全。


补充


然鹅 现实的业务场景往往是复杂多变的 否则也不会有token应该存储在cookie还是localStorage上?这个问题出现了。


localStorage更具灵活性: 不同应用有不同的安全需求,有时 localStorage 可以提供更加灵活和精细化的控制。 开发者可以在 JavaScript 中手动管理 localStorage,包括在每次请求时显式设置认证信息。这种 灵活性 对于一些高级用例和性能优化场景可能非常有用。


所以一般推荐使用cookie 但是在合适的场景下使用localStorage完全没问题。


作者:某某某人
来源:juejin.cn/post/7433079710382571558
收起阅读 »

这次终于轮到前端给后端兜底了🤣

web
需求交代 最近我们项目组开发了个互联网采集的功能,也就是后端合理抓取了第三方的文章,数据结构大致如下: <h1>前端人</h1> <p>学好前端,走遍天下都不怕</p> 数据抓取到后,存储到数据库,然后前端请求...
继续阅读 »

封面.png


需求交代


最近我们项目组开发了个互联网采集的功能,也就是后端合理抓取了第三方的文章,数据结构大致如下:


<h1>前端人</h1>
<p>学好前端,走遍天下都不怕</p>

数据抓取到后,存储到数据库,然后前端请求接口获取到数据,直接在页面预览


<div v-html='articleContent'></div>

image.png


整个需求已经交代清楚


这个需求有点为难后端了


前天,客户说要新增一个文章的pdf导出功能,但就是这么一个合情合理的需求,却把后端为难住了,原因是部分数据采集过来的结构可能是这样的:


<h1>前端人</h1>
<p>学好前端,走遍天下都不怕</p>
<div>前端强,前端狂,交互特效我称王!</div
<p>JS 写得好,需求改不了!</p>
<p>React Vue 两手抓,高薪 offer 到你家!</p>
<p>浏览器里横着走, bug 见我都绕道!</p>
<p>Chrome 调试一声笑, IE 泪洒旧时光!</p>
<span>Git 提交不留情,版本回退我最行!

仔细的人就能发现问题了,很多html元素存在没有完整的闭合情况


image.png


但浏览器是强大的,丝毫不影响渲染效果,原来浏览器自动帮我们补全结构了


image.png


可后端处理这件事就没那么简单了,爬取到的数据也比我举例的要复杂的多,使用第三方插件将html转pdf时会识别标签异常等问题,因此程序会抛异常


来自后端的建议


苦逼的后端折腾了很久,还是没折腾出来,终于他发现前端页面有个右键打印的功能,也就是:


image.png


于是他说:浏览器这玩意整挺好啊,前端能不能研究研究,尝试从前端实现导出


那就研究研究


我印象中,确实有个叫vue-print-nb的前端插件,可以实现这个功能


但.......等等,这个插件仅仅是唤起打印的功能,我总不能真做成这样,让用户另存为pdf吧


于是,只能另辟蹊径,终于我找到了这么个仓库:github.com/burc-li/vue…


里面实现了dom元素导出pdf的功能


image.png


image.png


效果很不错,技术用到了jspdfhtml2canvas这两个第三方库,代码十分简单


const downLoadPdfA4Single = () => {
const pdfContaniner = document.querySelector('#pdfContaniner')
html2canvas(pdfContaniner).then(canvas => {
// 返回图片dataURL,参数:图片格式和清晰度(0-1)
const pageData = canvas.toDataURL('image/jpeg', 1.0)

// 方向纵向,尺寸ponits,纸张格式 a4 即 [595.28, 841.89]
const A4Width = 595.28
const A4Height = 841.89 // A4纸宽
const pageHeight = A4Height >= A4Width * canvas.height / canvas.width ? A4Height : A4Width * canvas.height / canvas.width
const pdf = new jsPDF('portrait', 'pt', [A4Width, pageHeight])

// addImage后两个参数控制添加图片的尺寸,此处将页面高度按照a4纸宽高比列进行压缩
pdf.addImage(
pageData,
'JPEG',
0,
0,
A4Width,
A4Width * canvas.height / canvas.width,
)
pdf.save('下载一页PDF(A4纸).pdf')
})
}

技术流程大致就是:



  • dom -> canvas

  • canvas -> image

  • image -> pdf


似乎一切都将水到渠成了


困在眼前的难题


这个技术栈,最核心的就是:必须要用到dom元素渲染


如果你尝试将打印的元素设置样式:


display: none;


visibility: hidden;



opacity: 0;

执行导出功能都将抛异常或者只能导出一个空白的pdf


这时候有人会问了:为什么要设置dom元素为不可见?


试想一下,你做了一个导出功能,总不能让客户必须先打开页面等html渲染完后,再导出吧?


客户的理想状态是:在列表的操作列里,有个导出按钮,点击就可以导出pdf了


何况还需要实现批量勾选导出的功能,总不能程序控制,导出一个pdf就open一个窗口渲染html吧


寻找新方法


此路不通,就只能重新寻找新的方向,不过也没费太多功夫,就找到了另外一个插件html2pdf.js解决了这事


这插件用起来也极其简单


npm install html2pdf.js

<template>
<div class="container">
<button @click="generatePDF">下载PDF</button>
</div>
</template>
<script setup>
import html2pdf from 'html2pdf.js'

// 使用示例
let element = `
<h1>前端人</h1>
<p>学好前端,走遍天下都不怕</p>
<div>前端强,前端狂,交互特效我称王!</div
<p>JS 写得好,需求改不了!</p>
<p>React Vue 两手抓,高薪 offer 到你家!</p>
<p>浏览器里横着走, bug 见我都绕道!</p>
<p>Chrome 调试一声笑, IE 泪洒旧时光!</p>
<span>Git 提交不留情,版本回退我最行!
`
;

function generatePDF() {
// 配置选项
const opt = {
margin: 10,
filename: 'hello_world.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};
// 生成PDF并导出
html2pdf().from(element).set(opt).save();
}
</script>


功能正常,似乎一切都完美


image.png


问题没有想的那么简单


如果我们的html是纯文本元素,这程序跑起来没有任何问题,但我们抓取的信息都源于互联网,html结构怎么可能会这么简单?如果我们的html中包含图片信息,例如:


// 使用示例
let element = `
<div>
<img src='http://t13.baidu.com/it/u=2041049195,1001882902&fm=224&app=112&f=JPEG?w=500&h=500' style="width: 300px;" />
<p>职业:前端</p>
<p>技能:唱、跳、rap</p>
</div>
`
;

此时你会发现,导出来的pdf,图片占位处是个空白块


image.png



思考一下:类似案例中的图片加载方式,都是get方式的异步请求,而异步请求就会导致图片还没渲染完成,但导出的程序已经执行完成情况(最直接的观察方式就是,把这个元素放到浏览器上渲染,会发现图片也是过一会才慢慢加载完成的)


不过我不确定html2pdf.js这个插件是否会发起图片请求,但不管发不发起,导出的行为明显是在图片渲染前完成的,就导致了这个空白块的存在



问题分析完了,那就解决吧


既然图片异步加载不行,那就使用图片同步加载吧


不是吧,你问我:什么是图片同步加载?我也不晓得,这个词是我自己当下凭感觉造的,如有雷同,纯属巧合了


那我理解的图片同步加载是什么意思呢?简单来说,就是将图片转成Base64,因为这种方式,即使说无网的情况也能正常加载图片,因此我凭感觉断定,这就是图片同步加载


基于这个思路,我写了个demo


<template>
<div class="container">
<button @click="generatePDF">下载PDF</button>
</div>
</template>
<script setup>
import html2pdf from 'html2pdf.js'

async function convertImagesToBase64(htmlString) {
// 创建一个临时DOM元素来解析HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlString;

// 获取所有图片元素
const images = tempDiv.querySelectorAll('img');

// 遍历每个图片并转换
for (const img of images) {
try {
const base64 = await getBase64FromUrl(img.src);
img.src = base64;
} catch (error) {
console.error(`无法转换图片 ${img.src}:`, error);
// 保留原始URL如果转换失败
}
}

// 返回转换后的HTML
return tempDiv.innerHTML;
}

// 图片转base64
function getBase64FromUrl(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous'; // 处理跨域问题

img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;

const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);

// 获取Base64数据
const dataURL = canvas.toDataURL('image/png');
resolve(dataURL);
};

img.onerror = () => {
reject(new Error('图片加载失败'));
};

img.src = url;
});
}

// 使用示例
let element = `
<div>
<img src='http://t13.baidu.com/it/u=2041049195,1001882902&fm=224&app=112&f=JPEG?w=500&h=500' style="width: 300px;" />
<p>职业:前端</p>
<p>技能:唱、跳、rap</p>
</div>
`
;

function generatePDF() {
convertImagesToBase64(element)
.then(convertedHtml => {
// 配置选项
const opt = {
margin: 10,
filename: '前端大法好.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};

// 生成PDF并导出
html2pdf().from(convertedHtml).set(opt).save();
})
.catch(error => {
console.error('转换过程中出错:', error);
});
}
</script>


此时就大功告成啦!不过得提一句:图片的URL链接必须是同源或者允许跨越的,否则就会存在图片加载异常的问题


修复图片过大的问题


部分图片的宽度会过大,导致图片加载不全的问题,这在预览的情况下也存在


image.png


因为需要加上样式限定


img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
height: auto !important;
width: auto !important;
margin: 10px 0;
}

这样就正常啦


image.png


故此需要在导出pdf前,给元素添加一个图片的样式限定


element =`<style>
img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
height: auto !important;
width: auto !important;
margin: 10px 0;
}
</style>`
+ element;

完整代码:


<template>
<div class="container">
<button @click="generatePDF">下载PDF</button>
</div>
</template>
<script setup>
import html2pdf from 'html2pdf.js'
async function convertImagesToBase64(htmlString) {
// 创建一个临时DOM元素来解析HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlString;

// 获取所有图片元素
const images = tempDiv.querySelectorAll('img');

// 遍历每个图片并转换
for (const img of images) {
try {
const base64 = await getBase64FromUrl(img.src);
img.src = base64;
} catch (error) {
console.error(`无法转换图片 ${img.src}:`, error);
// 保留原始URL如果转换失败
}
}

// 返回转换后的HTML
return tempDiv.innerHTML;
}
// 图片转base64
function getBase64FromUrl(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous'; // 处理跨域问题

img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;

const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);

// 获取Base64数据
const dataURL = canvas.toDataURL('image/png');
resolve(dataURL);
};

img.onerror = () => {
reject(new Error('图片加载失败'));
};

img.src = url;
});
}

// 使用示例
let element = `
<div>
<img src='http://t13.baidu.com/it/u=2041049195,1001882902&fm=224&app=112&f=JPEG?w=500&h=500' style="width: 300px;" />
<p>职业:前端</p>
<p>技能:唱、跳、rap</p>
</div>
`
;

function generatePDF() {
element =`<style>
img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
height: auto !important;
width: auto !important;
margin: 10px 0;
}
</style>`
+ element;
convertImagesToBase64(element)
.then(convertedHtml => {
// 配置选项
const opt = {
margin: 10,
filename: '前端大法好.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};

// 生成PDF
html2pdf().from(convertedHtml).set(opt).save();
})
.catch(error => {
console.error('转换过程中出错:', error);
});
}
</script>


后话


前天提的需求,昨天兜的底,今天写的文章记录


这种问题,理应该后端处理,但后端和我吐槽过他处理起来的困难与问题,寻求前端帮助时,我也会积极配合。可在现实中,我遇过很多后端,死活不愿意配合前端,例如日期格式化、数据id类型bigint过大不字符化返回给前端等等,主打一个本着前端可以做就前端做的原则,说实在:属实下头


前后端本应该就是相互打配合的关系,谁方便就行个方便,没必要僵持不下


今天的分享就到此结束,如果你对技术/行业交流有兴趣,欢迎添加howcoder微信,邀你进群交流


往期精彩


《你不了解的Grid布局》


《就你小子还不会 Grid布局是吧?》


《超硬核:从零到一部署指南》


《私活2年,我赚到了人生的第一桶金》


《接入AI后,开源项目瞬间有趣了😎》


《肝了两个月,我们无偿开源了》


《彻底不NG前端路由》


《vue项目部署自动检测更新》


《一个公告滚动播放功能引发的背后思考》


《前端值得学习的开源socket应用》


作者:howcode
来源:juejin.cn/post/7486440418139652137
收起阅读 »

React 官方推荐使用 Vite

web
“技术更替不是一场革命,而是一场漫长的进化过程。” Hello,大家好,我是 三千。 React 官方已明确建议开发者逐步淘汰 Create React App (CRA) ,转而使用 Vite 等现代框架或工具来创建新项目。 那官方为什么要这样做呢? 一...
继续阅读 »

“技术更替不是一场革命,而是一场漫长的进化过程。”



Hello,大家好,我是 三千。


React 官方已明确建议开发者逐步淘汰 Create React App (CRA) ,转而使用 Vite 等现代框架或工具来创建新项目。


那官方为什么要这样做呢?




一、CRA 被淘汰的背景与原因



  1. 历史局限性

    CRA 诞生于 2016 年,旨在简化 React 项目的初始化配置,但其底层基于 Webpack 和 Babel 的架构在性能、扩展性和灵活性上逐渐无法满足现代开发需求。随着项目规模扩大,CRA 的启动和构建速度显著下降,且默认配置难以优化生产包体积。

  2. 维护停滞与兼容性问题

    React 团队于 2023 年宣布停止积极维护 CRA,且 CRA 的最新版本(v5.0.1)已无法兼容 React 19 等新特性,导致其在生产环境中逐渐不适用。

  3. 缺乏对现代开发模式的支持

    CRA 仅提供客户端渲染(CSR)的默认配置,无法满足服务端渲染(SSR)、静态生成(SSG)等需求。此外,其“零配置”理念限制了路由、状态管理等常见需求的灵活实现。




二、Vite 成为 React 官方推荐的核心优势



  1. 性能提升



    • 开发速度:Vite 基于原生 ESM 模块和 esbuild(Go 语言编写)实现秒级启动与热更新,显著优于 CRA 的 Webpack 打包机制。

    • 生产构建:通过 Rollup 优化代码体积,支持 Tree Shaking 和懒加载,减少冗余代码。



  2. 灵活性与生态兼容



    • 配置自由:允许开发者按需定制构建流程,支持 TypeScript、CSS 预处理器、SVG 等特性,无需繁琐的 eject 操作。

    • 框架无关性:虽与 React 深度集成,但也可用于 Vue、Svelte 等项目,适应多样化技术栈。



  3. 现代化开发体验



    • 原生浏览器支持:利用现代浏览器的 ESM 特性,无需打包即可直接加载模块。

    • 插件生态:丰富的 Vite 插件(如 @vitejs/plugin-react)简化了 React 项目的开发与调试。






三、迁移至 Vite 的具体步骤 



  1. 卸载 CRA 依赖


    npm uninstall react-scripts
    npm install vite @vitejs/plugin-react --save-dev


  2. 调整项目结构



    • 将 index.html 移至项目根目录,并更新脚本引用为 ESM 格式:


      <script type="module" src="/src/main.jsx"></script>


    • 将 .js 文件扩展名改为 .jsx(如 App.js → App.jsx)。



  3. 配置 Vite

    创建 vite.config.js 文件:


    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";

    export default defineConfig({
    plugins: [react()],
    });


  4. 更新环境变量

    环境变量前缀需从 REACT_APP_ 改为 VITE_(如 VITE_API_KEY=123)。

  5. 运行与调试

    修改 package.json 脚本命令:


    "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
    }





四、其他官方推荐的 React 框架 



  1. Next.js



    • 适用场景:服务端渲染(SSR)、静态生成(SSG)及全栈应用开发。

    • 优势:内置路由、API 路由、图像优化等功能,适合企业级应用与 SEO 敏感项目。



  2. Remix



    • 适用场景:嵌套路由驱动的全栈应用,注重数据加载优化与渐进增强。

    • 优势:集成数据预加载机制,减少请求瀑布问题。



  3. Astro



    • 适用场景:内容型静态网站(如博客、文档站)。

    • 优势:默认零客户端 JS 开销,通过“岛屿架构”按需激活交互组件。






五、总结与建议



  • 新项目:优先选择 Vite(轻量级 CSR 项目)或 Next.js(复杂全栈应用)。

  • 现有 CRA 项目:逐步迁移至 Vite,或根据需求转向 Next.js/Remix 等框架。

  • 学习曲线:Vite 对 React 核心概念干扰较小,适合初学者;Next.js 功能全面但学习成本较高。


React 生态正朝着  “库+框架”协同发展 的方向演进,开发者需结合项目需求选择工具链,以平衡性能、灵活性与开发效率。


结语


以上就是今天与大家分享的全部内容,你的支持是我更新的最大动力,我们下期见!


打工人肝 文章/视频 不易,期待你一键三连的鼓励 !!!



😐 这里是【程序员三千】,💻 一个喜欢捣鼓各种编程工具新鲜玩法的啊婆主。


🏠 已入驻:抖爸爸、b站、小红书(都叫【程序员三千】)


💽 编程/AI领域优质资源推荐 👉 http://www.yuque.com/xiaosanye-o…



作者:程序员三千_
来源:juejin.cn/post/7472008189976461346
收起阅读 »

async/await 必须使用 try/catch 吗?

web
前言 在 JavaScript 开发者的日常中,这样的对话时常发生: 👨💻 新人:"为什么页面突然白屏了?" 👨🔧 老人:"异步请求没做错误处理吧?" async/await 看似优雅的语法糖背后,隐藏着一个关键问题:错误处理策略的抉择。 在 JavaSc...
继续阅读 »

前言


在 JavaScript 开发者的日常中,这样的对话时常发生:



  • 👨💻 新人:"为什么页面突然白屏了?"

  • 👨🔧 老人:"异步请求没做错误处理吧?"


async/await 看似优雅的语法糖背后,隐藏着一个关键问题:错误处理策略的抉择


在 JavaScript 中使用 async/await 时,很多人会问:“必须使用 try/catch 吗?”


其实答案并非绝对,而是取决于你如何设计错误处理策略和代码风格。


接下来,我们将探讨 async/await 的错误处理机制、使用 try/catch 的优势,以及其他可选的错误处理方法。


async/await 的基本原理


异步代码的进化史


// 回调地狱时代
fetchData(url1, (data1) => {
process(data1, (result1) => {
fetchData(url2, (data2) => {
// 更多嵌套...
})
})
})

// Promise 时代
fetchData(url1)
.then(process)
.then(() => fetchData(url2))
.catch(handleError)

// async/await 时代
async function workflow() {
const data1 = await fetchData(url1)
const result = await process(data1)
return await fetchData(url2)
}

async/await 是基于 Promise 的语法糖,它使异步代码看起来更像同步代码,从而更易读、易写。一个 async 函数总是返回一个 Promise,你可以在该函数内部使用 await 来等待异步操作完成。


如果在异步操作中出现错误(例如网络请求失败),该错误会使 Promise 进入 rejected 状态


async function fetchData() {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
return data;
}

使用 try/catch 捕获错误


打个比喻,就好比铁路信号系统


想象 async 函数是一列高速行驶的列车:



  • await 是轨道切换器:控制代码执行流向

  • 未捕获的错误如同脱轨事故:会沿着铁路网(调用栈)逆向传播

  • try/catch 是智能防护系统

    • 自动触发紧急制动(错误捕获)

    • 启动备用轨道(错误恢复逻辑)

    • 向调度中心发送警报(错误日志)




为了优雅地捕获 async/await 中出现的错误,通常我们会使用 try/catch 语句。这种方式可以在同一个代码块中捕获同步和异步抛出的错误,使得错误处理逻辑更集中、直观。



  • 代码逻辑集中,错误处理与业务逻辑紧密结合。

  • 可以捕获多个 await 操作中抛出的错误。

  • 适合需要在出错时进行统一处理或恢复操作的场景。


async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching data:", error);
// 根据需要,可以在此处处理错误,或者重新抛出以便上层捕获
throw error;
}
}

不使用 try/catch 的替代方案


虽然 try/catch 是最直观的错误处理方式,但你也可以不在 async 函数内部使用它,而是在调用该 async 函数时捕获错误


在 Promise 链末尾添加 .catch()


async function fetchData() {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}

// 调用处使用 Promise.catch 捕获错误
fetchData()
.then(data => {
console.log("Data:", data);
})
.catch(error => {
console.error("Error fetching data:", error);
});

这种方式将错误处理逻辑移至函数调用方,适用于以下场景:



  • 当多个调用者希望以不同方式处理错误时。

  • 希望让 async 函数保持简洁,将错误处理交给全局统一的错误处理器(例如在 React 应用中可以使用 Error Boundary)。


将 await 与 catch 结合


async function fetchData() {
const response = await fetch('https://api.example.com/data').catch(error => {
console.error('Request failed:', error);
return null; // 返回兜底值
});
if (!response) return;
// 继续处理 response...
}

全局错误监听(慎用,适合兜底)


// 浏览器端全局监听
window.addEventListener('unhandledrejection', event => {
event.preventDefault();
sendErrorLog({
type: 'UNHANDLED_REJECTION',
error: event.reason,
stack: event.reason.stack
});
showErrorToast('系统异常,请联系管理员');
});

// Node.js 进程管理
process.on('unhandledRejection', (reason, promise) => {
logger.fatal('未处理的 Promise 拒绝:', reason);
process.exitCode = 1;
});

错误处理策略矩阵


决策树分析


graph TD
A[需要立即处理错误?] -->|是| B[使用 try/catch]
A -->|否| C{错误类型}
C -->|可恢复错误| D[Promise.catch]
C -->|致命错误| E[全局监听]
C -->|批量操作| F[Promise.allSettled]

错误处理体系



  1. 基础层:80% 的异步操作使用 try/catch + 类型检查

  2. 中间层:15% 的通用错误使用全局拦截 + 日志上报

  3. 战略层:5% 的关键操作实现自动恢复机制


小结


我的观点是:不强制要求,但强烈推荐



  • 不强制:如果不需要处理错误,可以不使用 try/catch,但未捕获的 Promise 拒绝(unhandled rejection)会导致程序崩溃(在 Node.js 或现代浏览器中)。

  • 推荐:90% 的场景下需要捕获错误,因此 try/catch 是最直接的错误处理方式。


所有我个人观点:使用 async/await 尽量使用 try/catch好的错误处理不是消灭错误,而是让系统具备优雅降级的能力


你的代码应该像优秀的飞行员——在遇到气流时,仍能保持平稳飞行。大家如有不同意见,还请评论区讨论,说出自己的见解。


作者:雨夜寻晴天
来源:juejin.cn/post/7482013975077928995
收起阅读 »

告别龟速删除!前端老司机教你秒删node_modules的黑科技

web
引言:每个前端的痛——node_modules删除噩梦 “npm install一时爽,删包火葬场。”这几乎是所有Node.js开发者都经历过的痛。尤其是当项目依赖复杂时,动辄几百MB甚至几个G的node_modules文件夹,手动删除时转圈圈的进度条简直让人...
继续阅读 »

引言:每个前端的痛——node_modules删除噩梦


“npm install一时爽,删包火葬场。”这几乎是所有Node.js开发者都经历过的痛。尤其是当项目依赖复杂时,动辄几百MB甚至几个G的node_modules文件夹,手动删除时转圈圈的进度条简直让人抓狂。


如何高效解决这个问题?今天我们就来揭秘几种秒删node_modules的硬核技巧,让你从此告别龟速删除!




一、为什么手动删除node_modules这么慢?


node_modules的目录结构复杂,层级深、文件数量庞大(比如一个中型项目可能有上万个小文件)。手动删除时,操作系统需要逐个处理这些文件,导致效率极低,尤其是Windows系统表现更差。核心原因包括:



  1. 文件系统限制:Windows的NTFS和macOS的HFS+对超多小文件的删除并未优化,系统需要频繁更新索引和缓存,资源占用高。

  2. 权限问题:某些文件可能被进程占用或权限不足,导致删除失败或卡顿。

  3. 递归删除效率低:系统自带的删除命令(如右键删除)是单线程操作,而node_modules的嵌套结构会让递归删除耗时剧增。




二、终极方案:用rimraf实现“秒删”


如果你还在手动拖拽删除,赶紧试试这个Node.js社区公认的神器——rimraf!它的原理是封装了rm -rf命令,通过减少系统调用和优化递归逻辑,速度提升可达10倍以上。


操作步骤



  1. 全局安装rimraf(仅需一次):
    npm install rimraf -g


  2. 一键删除

    进入项目根目录,执行:
    rimraf node_modules

    实测:一个5GB的node_modules,10秒内删干净!


进阶用法



  • 集成到npm脚本:在package.json中添加脚本,直接运行npm run clean
    {
    "scripts": {
    "clean": "rimraf node_modules"
    }
    }


  • 跨平台兼容:无论是Windows、Linux还是macOS,命令完全一致,团队协作无压力。




三、其他高效删除方案


如果不想安装额外工具,系统原生命令也能解决问题:


1. Windows用户:用命令行暴力删除



  • CMD命令
    rmdir /s /q node_modules

    /s表示递归删除,/q表示静默执行(不弹窗确认)。

  • PowerShell(更快)
    Remove-Item -Force -Recurse node_modules



2. Linux/macOS用户:终端直接起飞


rm -rf ./node_modules



四、避坑指南:删不干净怎么办?


有时即使删了node_modules,重新安装依赖仍会报错。此时需要彻底清理残留



  1. 清除npm缓存
    npm cache clean --force


  2. 删除锁文件

    手动移除package-lock.jsonyarn.lock

  3. 重启IDE:确保没有进程占用文件。




五、总结:选对工具,效率翻倍


方案适用场景速度对比
rimraf跨平台、大型项目⚡⚡⚡⚡⚡
系统命令临时快速操作⚡⚡⚡
手动删除极小项目(不推荐)

推荐组合拳:日常使用rimraf+脚本,遇到权限问题时切换系统命令。




互动话题

你遇到过最离谱的node_modules有多大?评论区晒出你的经历!




作者:LeQi
来源:juejin.cn/post/7477926585087606820
收起阅读 »

AI 赋能 Web 页面,图像识别超越想象

web
前言在信息时代,Web 页面成为我们与世界交互的重要窗口。如今,AI 程序的出现,为 Web 页面带来了新的变革。通过在 Web 页面上实现图片识别,我们即将迈入一个更加智能与便捷的时代。它将赋予网页全新的能力,使其能够理解图片的内容,为用户带来前所未有的体验...
继续阅读 »

前言

在信息时代,Web 页面成为我们与世界交互的重要窗口。如今,AI 程序的出现,为 Web 页面带来了新的变革。通过在 Web 页面上实现图片识别,我们即将迈入一个更加智能与便捷的时代。它将赋予网页全新的能力,使其能够理解图片的内容,为用户带来前所未有的体验。让我们一同踏上这充满无限可能的探索之旅。

具体步骤

html部分

我们可以试试通过输入:

main.container>(label.custom-file-upload>input#file-upload)+#image-container+p#status

再按tab键就可以快速生成以下的html框架。

<main class="container">
<label for="file-upload" class="custom-file-upload">
<input type="file" accept="image/*" id="file-upload">
上传图片
label>
<div id="image-container">div>
<p id="status">p>
main>
  • 我们选择使用main标签而不是选择div,是因为main比div更具有语义化。main标签可以表示页面的主体内容。
  • label标签用于将表单中的输入元素(如文本输入框、单选按钮、复选框等)与相关的文本描述关联起来。label标签通过将 for 属性值设置为输入元素的 id 属性值,建立与输入元素的关联。
  • input的type属性值为file,表示是文件输入框;accept属性值为image/*,表示该文件输入框只接收图像类型的文件。

JavaScript部分

这个部分是这篇文章的重点。

第一部分

首先我们通过JavaScript在远程的transformers库中引入pipleline和env模块。我们需要禁止使用本地的模块,而使用远程的模块。

import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0"
env.allowLocalModels = false;

接下来我们要读取用户输入的文件,并且输出在页面上。我们可以这样做:

const fileUpload = document.getElementById('file-upload');
const imageContainer = document.getElementById('image-container')
//添加事件监听器来响应元素上的特定事件
//在fileUpload元素上添加一个change事件监听。当用户更改上传文件时,触发事件处理函数。
fileUpload.addEventListener('change', function (e) {
//e是事件对象
//e.target是触发事件的文件上传元素。
//e.target.files[0]是获取选中的第一个文件
const file = e.target.files[0];
// 新建一个FileReader 对象用于读取文件(异步)
//FileReader 是 JavaScript 中的一个对象,用于异步读取用户选择的文件或输入的内容。
const reader = new FileReader();
//reader.onload就相当于FileReader。onload;FileReader。onload是一个事件,在读取操作完成时触发function(e2)
reader.onload = function (e2) {
//创建一个新的html元素,也就是创建一个img标签
const image = document.createElement('img');
//将image的scr属性的url值改为e2.target.result
image.src = e2.target.result;
//将 image 元素添加作为子节点到 imageContainer 元素中
imageContainer.appendChild(image)
detect(image) // 启动AI任务。是功能模块化,要封装出去
}
//FileReader.readAsDataURL():开始读取指定的 Blob 中的内容。一旦完成,result 属性中将包含一个 data: URL 格式的 Base64 字符串以表示所读取文件的内容。
//用于将指定的文件读取为 DataURL 格式的数据。
reader.readAsDataURL(file)
})

我们先梳理一下我们的思路。

  • 我们要对文件输入框里元素进行操作,所以我们要获取那个元素。我们可以创建一个fileUpload对象获取这个元素。
  • 我们可以对fileUpload对象添加一个change事件监听。这个监听器实现当用户更改上传文件时,触发事件处理函数function(e)实现读取文件并且在页面输出文件的功能。
  • function(e)的功能是用于异步读取用户选择的文件,并且在页面输出它。

    • 因为我们要读取整个图片文件,所以我们可以创建一个FileReader对象读取图片文件。在文件读取完成时我们可以用reader.onload实现在文件读取完毕时在id为mage-container的div标签内添加一个img标签输出图片。
      • 因为要输出图片就要先知道图片的URL,于是我们用e2.target.result获取图片的URL。
      • 因为要在div里添加一个img标签,于是创建一个imageContainer对象获取ID 为 image-container 的元素后用imageContainer.appendChild(image)实现将img标签添加为div的子标签。
  • 我们用detect函数封装AI图片识别任务。

第二部分

接下来我们要完成ai任务———检测图片。

我们要通过ai进行对象检测并且获得检测到的元素的参数。

const status = document.getElementById('status');
const detect = async (image) => {
//在id为status的p标签中添加“分析中...请稍等一会”的文本内容
status.textContent = "分析中...请稍等一会"
//object-detection是对象检查任务;Xenova/detr-resnet-50是指定用于对象检测的模型
const detector = await pipeline("object-detection","Xenova/detr-resnet-50") // model 实例化了detector对象
//使用检测器对象对指定的图像源进行检测,并传入了一些参数:
const output = await detector(image.src, {
threshold: 0.1,
percentage: true
})
//forEach 方法用于遍历数组或类似数组的对象,并对每个元素执行指定的函数
output.forEach(renderBox)
}

分析检测图片和获取检测元素的参数的思路。

  • 首先,对图片进行分析需要一些时间,为了让用户知道分析需要时间并且让用户等待,我们可以用我们html中id="status"的p标签输入一段文字。
    • 首先我们用document.getElementById('status')获取id="status"的元素,并且用textContent方法动态添加id="status"的p标签的文本内容,然后输出在页面上。

detect异步函数

  • 为了实现对象检测我们采用了async/await编写异步代码的方法。使用async关键字来标记一个函数为异步函数,并在其中使用await关键字来等待一个Promise对象的结果。await表达式会暂停当前async function的执行,等待Promise处理完成。
  • 创建一个detector对象接收对象分析结果。再创建output对象接收对象分析结果的参数

output.forEach(renderBox)是让output的所有元素遍历进行渲染。

第三部分

我们用renderBox函数将框出图片中的物品的区域,并且在区域内输出ai识别得出的物品名称。

function renderBox({ box, label }) {
console.log(box, label);
const { xmax, xmin, ymax, ymin } = box
//创建一个盒子
const boxElement = document.createElement("div");
//动态添加div的类名
boxElement.className = "bounding-box"
//通过执行 Object.assign(boxElement.style, {}),可以将一个空对象的属性合并到 boxElement.style 对象中
//准备为 boxElement 设置新的样式属性
Object.assign(boxElement.style, {
borderColor: '#123123',
borderWidth: '1px',
borderStyle: 'solid',
//最左侧
left: 100 * xmin + '%',
//最上侧
top: 100 * ymin + '%',
//宽度
width: 100 * (xmax - xmin) + "%",
//高度
height: 100 * (ymax - ymin) + "%"
})

const labelElement = document.createElement('span');
labelElement.textContent = label;
labelElement.className = "bounding-box-label"
labelElement.style.backgroundColor = '#000000'
//在图片上增加的物品区域范围内添加物品的种类名称的文本内容
boxElement.appendChild(labelElement);
//在图片上增加物品的区域范围
imageContainer.appendChild(boxElement);

}

分析renderBox函数思路。

  • output元素中有许多参数,我们只需要label参数(检测得出的物品名称)和box参数(图片物品的区域参数,分别为xmax、xmin、ymax、ymin)。
  • 实现框出图片中的物品的区域(每一个新建的div都表示一个图片物体)
    • 首先我们创建{ xmax, xmin, ymax, ymin }对象获取位置信息。
    • 通过document.createElement("div")创建一个新的div标签,再用className方法动态得给新建的div标签添加一个bounding-box类。
    • 通过
    • Object.assign(boxElement.style, { })对新建的div添加css样式,并且通过测量图片物体的最左侧和最上侧以及高度和宽度框出图片物体的区域。
    • 再通过imageContainer.appendChild(boxElement)代码添加到id="image-container"的div标签中。
  • 实现在图片物体区域添加ai识别得出的label参数文本
    • 通过document.createElement('span')创建span标签,再用textContent方法将label内容动态输入新建的span标签
    • 再通过 boxElement.appendChild(labelElement)添加到刚刚创建的div标签中,也就是添加到图片物体区域中。

JavaScript部分总结

我们将图片识别功能分别分成三个部分实现。首先通过第一部分读取图片,通过第二部分进行ai分析,再通过第三部分输出结果。

我们要有灵活的封装思想。

css部分

该部分不做过度解释,直接上代码。

.container {
margin: 40px auto;
width: max(50vw, 400px);
display: flex;
flex-direction: column;
align-items: center;
}

.custom-file-upload {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
border: 2px solid black;
padding: 8px 16px;
border-radius: 6px;
}

#file-upload {
display: none;
}

#image-container {
width: 100%;
margin-top: 20px;
position: relative;
}

#image-container>img {
width: 100%;
}

.bounding-box {
position: absolute;
box-sizing: border-box;
}

.bounding-box-label {
position: absolute;
color: white;
font-size: 12px;
}

效果展示

  1. 选择图片上传

屏幕截图 2024-04-19 022250.png

  1. 选择图片后进行分析的过程

屏幕截图 2024-04-19 024403.png

控制台输出的是e2.target.result的内容。

  1. 分析结果

屏幕截图 2024-04-19 024528.png

代码

html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nlp之图片识别title>
<style>
.container {
margin: 40px auto;
width: max(50vw, 400px);
display: flex;
flex-direction: column;
align-items: center;
}

.custom-file-upload {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
border: 2px solid black;
padding: 8px 16px;
border-radius: 6px;
}

#file-upload {
display: none;
}

#image-container {
width: 100%;
margin-top: 20px;
position: relative;
}

#image-container>img {
width: 100%;
}

.bounding-box {
position: absolute;
box-sizing: border-box;
}

.bounding-box-label {
position: absolute;
color: white;
font-size: 12px;
}
style>
head>

<body>

<main class="container">

<label for="file-upload" class="custom-file-upload">

<input type="file" accept="image/*" id="file-upload">
上传图片
label>
<div id="image-container">div>
<p id="status">p>
main>
<script type="module">
// transformers提供js库(https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0)
//从 https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0 导入 pipeline 和 env
import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0"
//禁止加载本地模型,加载远程模型
env.allowLocalModels = false;
//document.getElementById 是 js 中用于通过元素的 ID 来获取特定 HTML 元素的方法
const fileUpload = document.getElementById('file-upload');
const imageContainer = document.getElementById('image-container')
//添加事件监听器来响应元素上的特定事件
//在fileUpload元素上添加一个change事件监听。当用户更改上传文件时,触发事件处理函数。
fileUpload.addEventListener('change', function (e) {
//e是事件对象
//e.target是触发事件的文件上传元素。
//e.target.files[0]是获取选中的第一个文件
const file = e.target.files[0];
// 新建一个FileReader 对象用于读取文件(异步)
//FileReader 是 JavaScript 中的一个对象,用于异步读取用户选择的文件或输入的内容。
const reader = new FileReader();
//reader.onload是一个事件,在读取操作完成时触发
reader.onload = function (e2) {
//用createElement创建一个新的html元素
const image = document.createElement('img');
console.log(e2.target.result);
//将image的scr属性的url值改为e2.target.result
image.src = e2.target.result;
//将 image 元素添加作为子节点到 imageContainer 元素中
imageContainer.appendChild(image)
detect(image) // 启动AI任务 功能模块化,封装出去
}
//用于将指定的文件读取为 DataURL 格式的数据。
reader.readAsDataURL(file)
})


//ai任务
const status = document.getElementById('status');
const detect = async (image) => {
//在id为status的p标签中添加“分析中...请稍等一会”的文本内容
status.textContent = "分析中...请稍等一会"
//object-detection是对象检查任务;Xenova/detr-resnet-50是指定用于对象检测的模型
const detector = await pipeline("object-detection","Xenova/detr-resnet-50") // model 实例化了detector对象
//使用检测器对象对指定的图像源进行检测,并传入了一些参数:
const output = await detector(image.src, {
threshold: 0.1,
percentage: true
})
//forEach 方法用于遍历数组或类似数组的对象,并对每个元素执行指定的函数
output.forEach(renderBox)
}



//封装;渲染盒子
function renderBox({ box, label }) {
console.log(box, label);
const { xmax, xmin, ymax, ymin } = box
//创建一个盒子
const boxElement = document.createElement("div");
//动态添加div的类名
boxElement.className = "bounding-box"
//通过执行 Object.assign(boxElement.style, {}),可以将一个空对象的属性合并到 boxElement.style 对象中
//准备为 boxElement 设置新的样式属性
Object.assign(boxElement.style, {
borderColor: '#123123',
borderWidth: '1px',
borderStyle: 'solid',
//最左侧
left: 100 * xmin + '%',
//最上侧
top: 100 * ymin + '%',
//宽度
width: 100 * (xmax - xmin) + "%",
//高度
height: 100 * (ymax - ymin) + "%"
})

const labelElement = document.createElement('span');
labelElement.textContent = label;
labelElement.className = "bounding-box-label"
labelElement.style.backgroundColor = '#000000'
//在图片上增加的物品区域范围内添加物品的种类名称的文本内容
boxElement.appendChild(labelElement);
//在图片上增加物品的区域范围
imageContainer.appendChild(boxElement);

}
script>
body>

html>

结尾

整个代码还存在一些不足之处,还需要不断完善。希望我的文章可以帮助到你们。欢迎点赞评论加关注。


作者:睡着学
来源:juejin.cn/post/7359084330121789452

收起阅读 »

我的 Electron 客户端被第三方页面入侵了...

web
问题描述 公司有个内部项目是用 Electron 来开发的,有个功能需要像浏览器一样加载第三方站点。 本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面。 这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码...
继续阅读 »

问题描述


公司有个内部项目是用 Electron 来开发的,有个功能需要像浏览器一样加载第三方站点。


本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面


这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码。


    if (window.top !== window.self) {
window.top.location = window.location;
}

翻译一下就是:如果当前窗口不是顶级窗口的话,将当前窗口设置为顶级窗口。


奇怪的是两者不是 跨域 了吗,为什么 iframe 还可以影响顶级窗口。


先说一下我当时的一些解决办法:



  1. webview 替换 iframe

  2. iframe 添加 sandbox 属性


后续内容就是一点复盘工作。


场景复现(Web端)


一开始怀疑是客户端的问题,所以我用在纯 Web 上进行了一次对比验证。


这里我们新建两个文件:1.html2.html,我们称之为 页面A页面B


然后起了两个本地服务器来模拟同源与跨域的情况。


页面A:http://127.0.0.1:5500/1.html


页面B:http://127.0.0.1:5500/2.htmlhttp://localhost:3000/2.html


符合同源策略


<body>
<h1>这是页面A</h1>
<!-- 这是同源的情况 -->
<iframe id="iframe" src="http://127.0.0.1:5500/2.html" />

<script>
iframe.onload = () => {
console.log('iframe loaded..')
console.log('子窗口路径', iframe.contentWindow.location.href)
}
</script>
</body>

<body>
<h2>这是页面B</h2>

<script>
console.log('page2...')
console.log(window === window.top)
console.log('顶部窗口路径', window.top.location.href)
</script>
</body>

我们打开控制台可以看到 页面A 和 页面B 是可以 互相访问 到对方窗口的路径。


image.png


如果这个时候在 页面B 加上文章开头提到的 代码片段,那么显然页面将会发生变化。


image.png


跨域的情况


这时候我们修改 页面A 加载 页面B 的地址,使其不符合同源策略。


image.png


理所应当的是,两个页面不能够相互访问了,这才是正常的,否则内嵌第三方页面可以互相修改,那就太不安全了。


场景复现(客户端)


既然 Web 端是符合预期的,那是不是 Electron 自己的问题呢?


我们通过 electron-vite 快速搭建了一个 React模板的electron应用,版本为:electron@22.3.27,并且在 App 中也嵌入了刚才的 页面B。


function App(): JSX.Element {
return (
<>
<h1>这是Electron页面</h1>
<iframe id="iframe" src="http://localhost:3000/2.html"/>
</>

)
}
export default App

image.png


对不起,干干净净的 Electron 根本不背这个锅,在它身上的表现如同 Web端 一样,也受同源策略的限制。


那么肯定是我的项目里有什么特殊的配置,通过对比主进程的代码,答案终于揭晓。


new BrowserWindow({
...,
webPreferences: {
...,
webSecurity: false // 就是因为它
}
})

Electron 官方文档 里是这么描述 webSecurity 这个配置的。



webSecurity boolean (可选) - 当设置为 false, 它将禁用同源策略 (通常用来测试网站), 如果此选项不是由开发者设置的,还会把 allowRunningInsecureContent设置为 true. 默认值为 true



也就是说,Electron本身是有一层屏障的,但当该属性设置为 false 的时候,我们的客户端将会绕过同源策略的限制,这层屏障也就消失了,因此 iframe 的行为表现得像是嵌套了同源的站点一样。


解决方案


把这个配置去掉,确实是可以解决这个问题,但考虑到可能对其他功能造成的影响,只能采取其他方案。


如文章开头提到的,用 webview 替换 iframe


webviewElectron的一个自定义元素(标签),可用于在应用程序中嵌入第三方网页,它默认开启安全策略,直接实现了主应用与嵌入页面的隔离。


因为目前这个需求是仅作展示,不需要与嵌套页面进行交互以及复杂的通信,因此在一开始的开发过程中,并没有使用它,而是直接采用了 iframe


iframe 也能够实现类似的效果,只需要添加一个 sandbox 属性可以解决。


MDN 中提到,sandbox 控制应用于嵌入在 <iframe> 中的内容的限制。该属性的值可以为空以应用所有限制,也可以为空格分隔的标记以解除特定的限制。


如此一来,就算是同源的,两者也不会互相干扰。


总结


这不是一个复杂的问题,发现后及时修复了,并没有造成很大的影响(还好是自己人用的平台)。


写这篇文章的主要目的是为了记录这次事件,让我意识到在平时开发过程中,把注意力过多的放在了 业务样式性能等这些看得见的问题上,可能很少关注甚至忽略了 安全 这一要素,以为前端框架能够防御像 XSS 这样的攻击就能安枕无忧。


谨记,永远不要相信第三方,距离产生美。


如有纰漏,欢迎在评论区指出。


作者:小陈同学吗
来源:juejin.cn/post/7398418805971877914
收起阅读 »

Electron 30.0.0

web
作者: clavin / VerteDinde 译者: ylduang Electron 30.0.0 已发布! 它包括对 Chromium 124.0.6367.49、V8 12.4 和 Node.js 20.11.1 的升级。 Electron 团队很...
继续阅读 »

作者: clavin / VerteDinde


译者: ylduang



Electron 30.0.0 已发布! 它包括对 Chromium 124.0.6367.49、V8 12.4 和 Node.js 20.11.1 的升级。




Electron 团队很高兴发布了 Electron 30.0.0 ! 你可以通过 npm install electron@latest 或者从我们的发布网站下载它。继续阅读此版本的详细信息。


如果您有任何反馈,请在 TwitterMastodon 上与我们分享,或加入我们的 Discord 社区! Bug 和功能请求可以在 Electron 的问题跟踪器中报告。


重要变化


重点内容



  • Windows 现在支持 ASAR 完整性检查 (#40504)

    • 启用 ASAR 完整性的现有应用程序如果配置不正确,可能无法在 Windows 上工作。使用 Electron 打包工具的应用应该升级到 @electron/packager@18.3.1@electron/forge@7.4.0

    • 查看我们的 ASAR Integrity 教程 以获取更多信息。



  • 添加了 WebContentsViewBaseWindow 主进程模块,废弃并替换 BrowserView (#35658)

    • BrowserView 现在是 WebContentsView 的一个壳,并且旧的实现已被移除。

    • 查看 我们的 Web Embeds 文档 以便将新的 WebContentsView API 和其他类似 API 进行比较。



  • 实现了对 File System API 的支持 (#41827)


架构(Stack)更新



Electron 30 将 Chromium 从 122.0.6261.39 升级到 124.0.6367.49, Node 从 20.9.0 升级到 20.11.1 以及 V8 从 12.2 升级到 12.4


新特性



  • 在 webviews 中添加了 transparent 网页偏好设置。(#40301)

  • 在 webContents API 上添加了一个新的实例属性 navigationHistory,配合 navigationHistory.getEntryAtIndex 方法,使应用能够检索浏览历史中任何导航条目的 URL 和标题。(#41662)

  • 新增了 BrowserWindow.isOccluded() 方法,允许应用检查窗口是否被遮挡。(#38982)

  • 为工具进程中 net 模块发出的请求添加了代理配置支持。(#41417)

  • 添加了对 navigator.serial 中的服务类 ID 请求的蓝牙端口的支持。(#41734)

  • 添加了对 Node.js NODE_EXTRA_CA_CERTS 命令行标志的支持。(#41822)


重大更改


行为变更:跨源 iframe 现在使用 Permission Policy 来访问功能。


跨域 iframe 现在必须通过 allow 属性指定一个给定 iframe 可以访问的功能。


有关更多信息,请参见 文档


移除:--disable-color-correct-rendering 命令行开关


此开关从未正式文档化,但无论如何这里都记录了它的移除。Chromium 本身现在对颜色空间有更好的支持,因此不再需要该标志。


行为变更:BrowserView.setAutoResize 在 macOS 上的行为


在 Electron 30 中,BrowserView 现在是围绕新的 WebContentsView API 的包装器。


以前,BrowserView API 的 setAutoResize 功能在 macOS 上由 autoresizing 支持,并且在 Windows 和 Linux 上由自定义算法支持。
对于简单的用例,比如使 BrowserView 填充整个窗口,在这两种方法的行为上是相同的。
然而,在更高级的情况下,BrowserViews 在 macOS 上的自动调整大小与在其他平台上的情况不同,因为 Windows 和 Linux 的自定义调整大小算法与 macOS 的自动调整大小 API 的行为并不完全匹配。
自动调整大小的行为现在在所有平台上都标准化了。


如果您的应用使用 BrowserView.setAutoResize 做的不仅仅是使 BrowserView 填满整个窗口,那么您可能已经有了自定义逻辑来处理 macOS 上的这种行为差异。
如果是这样,在 Electron 30 中不再需要这种逻辑,因为自动调整大小的行为是一致的。


移除:WebContentscontext-menuparams.inputFormType 属性


WebContentscontext-menu 事件中 params 对象的 inputFormType 属性已被移除。请改用新的 formControlType 属性。


移除:process.getIOCounters()


Chromium 已删除对这些信息的访问。


终止对 27.x.y 的支持


根据项目的支持政策,Electron 27.x.y 已经达到了支持的终点。我们鼓励开发者将应用程序升级到更新的 Electron 版本。


E30(24 年 4 月)E31 (24 年 6 月)E26(24 年 8 月)
30.x.y31.x.y32.x.y
29.x.y30.x.y31.x.y
28.x.y29.x.y30.x.y

接下来


在短期内,您可以期待团队继续专注于跟上构成 Electron 的主要组件的开发,包括 Chromium、Node 和 V8。


您可以在此处找到 Electron 的公开时间表


有关这些和未来变化的更多信息可在计划的突破性变化页面找到。



原文: Electron 30.0.0


Electron China 社区



作者:Electron
来源:juejin.cn/post/7361426249380397068
收起阅读 »

我的 Electron 客户端也可以全量/增量更新了

web
前言 本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。 全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。 增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。 本文并没有拿真实项目来举例子,而是起一个 新的项目 从...
继续阅读 »

前言


本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。


全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。


增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。


本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。


如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。


前置说明:



  1. 由于业务场景的限制,本文介绍的更新仅支持 Windows 操作系统,其余操作系统未作兼容处理。

  2. 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。

  3. 发布方式限制为 generic,线上服务需要配置 nginx 确保访问到资源文件。


准备工作


脚手架搭建项目


我们通过 electron-vite 快速搭建一个基于 Vite + React + TSElectron 项目。


1.jpg


该模板已经包括了我们需要的核心第三方库:electron-builderelectron-updater


前者是用来打包客户端程序的,后者是用来实现自动更新的。


在项目根目录下,已经自动生成了两份配置文件:electron-builder.ymldev-app-update.yml


electron-builder.yml


该文件描述了一些 打包配置,更多信息可参考 官网


在这些配置项中,publish 字段比较重要,因为它关系到更新源。


publish:
provider: generic // 使用一个通用的 HTTP 服务器作为更新源。
url: https://example.com/auto-updates // 存放安装包和描述文件的地址

provider 字段还有其他可选项,但是本文只介绍 generic 这种方式,即把安装包放在 HTTP 服务器里。


dev-app-update.yml


provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: electron-update-demo-updater

其中,updaterCacheDirName 定义下载目录,也就是安装包存放的位置。全路径是C:\Users\用户名\AppData\Local\electron-update-demo-updater,不配置则在C:\Users\用户名\AppData\Local下自动创建文件夹,开发环境下为项目名,生产环境下为项目名-updater


image.png


模拟服务器


我们直接运行 npm run build:win,在默认 dist 文件夹下就出现了打包后的一些资源。


image.png


其实更新的基本思路就是对比版本号,即对比本地版本和 线上服务器 版本是否一致,若不一致则需要更新。


image.png


因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。


新建一个文件夹 mockServer,把打包后的 setup.exe 安装包和 latest.yml 文件粘贴进去,然后通过 serve 命令默认起了一个 http://localhose:3000 的本地服务器。


既然有了存放资源的本地地址,在开发调试的时候,我们更新下 dev-app-update.yml 文件的 url 字段,也就是修改为 http://localhose:3000


注:如果需要测试打包后的更新功能,需要同步修改打包配置文件。


全量更新


与主进程文件同级,创建 update.ts 文件,之后我们的更新逻辑将在这里展开。


import { autoUpdater } from 'electron-updater' //核心库

需要注意的是,在我们开发过程中,通过 npm run dev 起来的 Electron 程序其实不能算是打包后的状态。


你会发现在调用 autoUpdater 的一些方法会提示下面的错误:


Skip checkForUpdates because application is not packed and dev update config is not forced


因此我们需要在开发环境去做一些动作,并且手动指定 updateConfigPath


// update.ts
if (isDev) {
Object.defineProperty(app, 'isPackaged', {
get: () => true
})
autoUpdater.updateConfigPath = path.join(__dirname, '../../dev-app-update.yml')
}

核心对象 autoUpdater 有很多可以主动调用的方法,也有一些监听事件,同时还有一些属性可以设置。


这里只展示了本人项目场景所需的一些配置。


autoUpdater.autoDownload = false // 不允许自动下载更新
autoUpdater.allowDowngrade = true // 允许降级更新(应付回滚的情况)

autoUpdater.checkForUpdates()
autoUpdater.downloadUpdate()
autoUpdater.quitAndInstall()

autoUpdater.on('checking-for-update', () => {
console.log('开始检查更新')
})
autoUpdater.on('update-available', info => {
console.log('发现更新版本')
})
autoUpdater.on('update-not-available', info => {
console.log('不需要全量更新', info.version)
})
autoUpdater.on('download-progress', progressInfo => {
console.log('更新进度信息', progressInfo)
})
autoUpdater.on('update-downloaded', () => {
console.log('更新下载完成')
})
autoUpdater.on('error', errorMessage => {
console.log('更新时出错了', errorMessage)
})

在监听事件里,我们可以拿到下载新版本 整个生命周期 需要的一些信息。


// 新版本的版本信息(已筛选)
interface UpdateInfo {
readonly version: string;
releaseName?: string | null;
releaseNotes?: string | Array<ReleaseNoteInfo> | null;
releaseDate: string;
}

// 下载安装包的进度信息
interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}

在写完这些基本的方法之后,我们需要决定 检验更新 的时机,一般是在应用程序真正启动之后,即 mainWindow 创建之后。


运行项目,预期会提示 不需要全量更新,因为刚才复制到本地服务器的 latest.yml 文件里的版本信息与本地相同。修改 version 字段,重启项目,主进程就会提示有新版本需要更新了。


频繁启动应用太麻烦,除了应用初次启动时主进程主动帮我们检验更新外,还需要用户 手动触发 版本更新检测,此外,由于产品场景需要,当发现有新版本的时候,需要在 渲染进程 通知用户,包括版本更新时间和更新内容。因此我们需要加上主进程与渲染进程的 IPC通信 来实现这个功能。


其实需要的数据主进程都能够提供了,渲染进程具体的展示及交互方式就自由发挥了,这里简单展示下我做的效果。


1. 发现新版本
GIF 2024-9-19 16-48-44.gif


2. 无需更新


image.png


增量更新


为什么要这么做


其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater 封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。


此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知 的形式,只不过我们更新的不是整个应用程序。


由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron 给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台 等特性的同时,还得忍受 臃肿的安装包


带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer,并不包括 dll第三方SDK等资源。


网上有挺多种增量更新的 解决方案,例如:



  1. 通过 win.loadURL(一个线上地址) 实现,相当于就套了一层客户端的壳子,与加载的Web 端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用 node 去操作一些底层的东西。

  2. 设置 asar 的归档方式,替换app.asarapp.asar.unpack来实现。但后者在我实践过程中存在文件路径不存在的问题。

  3. 禁用 asar 归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。

  4. 欢迎补充。


本文我们采用较普遍的 替换asar 来实现。


优化 app.asar 体积



asar是 Electron提供的一种将多个文件合并成一个文件的类 tar 风格的归档格式,不仅可以缓解Windows下路径名过长的问题, 还能够略微加快一下 require的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)



Electron 应用程序启动的时候,会读取 app.asar.unpacked 目录中的内容合并到 app.asar 的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar 这个文件。


例如:D:\你的安装路径\electron-update-demo\resources


image.png


在这个 Demo 项目中,脚手架生成的模板将图标剥离出来,因此出现了一个 app.asar.unpacked 文件夹。我们不难发现,app.asar 这个文件竟然有 66 MB,再回过头看我们真正的代码文件,甚至才 1 MB !那么到底是什么原因导致的这个文件这么大呢,刚才提到了,asar 其实是一种压缩格式,因此我们只要解压看看就知道了。


npm i -g asar // 全局安装

asar e app.asar folder // 解压到folder文件夹

image.png


解压后我们不难发现,out 文件夹里的才是我们打包出来的资源,罪魁祸首竟然是 node_modules,足足有 62.3 MB


查阅资料得知,Electron 在打包的时候,会把 dependencies 字段里的依赖也一起打包进去。而我们的渲染进程是用 React开发的,这些第三方依赖早就通过 Vite 等打包工具打到资源文件里了,根本不需要再打包一次,不然我们重新下载的 app.asar 文件还是很大的,因此需要尽可能减少体积。


优化应用程序体积 == 减少 node_modules 文件夹的大小 == 减少需要打包的依赖数量 == 减少 dependencies 中的依赖。


1. 移除 dependencies


最开始我想的是把 package.json 中的 dependencies 全都移到 devDependencies,打包后的体积果然减小了,但是在测试过程中会发现某些功能依赖包会缺失,比如 @electron/remote。因此在修改这部分代码的时候,需要特别留意,哪些依赖是需要保留下来的。


由于不想影响 package.json 的版本结构,我只是在写了一个脚本,在 npm i 之后,执行打包命令前修改 devDependencies 就好了。


2. 双 package.json 结构


这是 electron-builder 官网上看到的一种技巧,传送门, 创建 app 文件夹,再创建第二个 package.json,在该文件中配置我们应用的名称、版本、主进程入口文件等信息。这样一来,electron-builder 在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。


但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试


image.png


这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。


校验增量更新


全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。


首先明确一下 校验的时机package.jsonversion 字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available 事件。所以我们可以在这个事件的回调函数里来进行校验。


autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })

然后就是 如何校验,我们回过头来看 electron-builder 的打包配置,在 releaseInfo 字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes 来存储更新日志,查阅官网得知还有个 releaseName 好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor 字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)


每次发布新版本的时候,只要不是 Electron自身版本变化 等重大更新,我们都可以通过修改 releaseInforeleaseName 来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 userData 文件夹,用于存储我们应用程序的用户信息,即使程序卸载 了也不会影响里面的资源。在 Windows 下,路径如下:
C:\Users\用户名\AppData\Roaming\electron-update-demo


因此,整个 校验流程 就是,在打开程序的时候,autoUpdater 触发 update-not-available 事件,拿到线上 latest.yml 描述的 releaseName 作为热版本号,与本地配置文件(我们命名为 config.json)里存储的热版本号(我们命名为 hotVersion)进行对比,若不同就去下载最新的 app.asar 文件。


我们使用 electron-log 来记录日志,代码如下所示。


// 本地配置文件
const localConfigFile = path.join(app.getPath('userData'), 'config.json')

const checkHotVersion = (latestHotVersion: string): boolean => {
let needDownload = false
if (!fs.existsSync(localConfigFile)) {
fs.writeFileSync(localConfigFile, '{}', 'utf8')
log.info('配置文件不存在,已自动创建')
} else {
log.info('监测到配置文件')
}

try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
// 对比版本号
if (latestHotVersion !== configData.hotVersion) {
log.info('当前版本不是最新热更版本')
needDownload = true
} else {
log.info('当前为最新版本')
}
} catch (error) {
log.error('读取配置文件时出错', error)
}

return needDownload
}

下载增量更新包


通过刚才的校验,我们已经知道了什么时候该去下载增量更新包。


在开发调试的时候,我们可以把新版本的 app.asar 也放到起了本地服务器的 mockServer 文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejshttp 模块去实现,如果是 https 的需要引用 https 模块。


下载到本地的时候,我是放在了与 app.asar 同级目录的 resources 文件夹,我们不能直接覆盖原文件。一是因为进程权限占用的问题,二是为了容错,所以我们需要以另一个名字来命名下载后的文件(这里我们用的是 app.asar-temp),也就不需要去备份原文件了,代码如下。


const resourcePath = isDev
? path.join('D:\\测试\\electron-update-demo\\resources')
: process.resourcesPath

const localAsarTemp = path.join(resourcePath, 'app.asar-temp')

const asarUrl = 'http://localhost:3000/app.asar'

downloadAsar() {
const fileStream = fs.createWriteStream(localAsarTemp)
http.get(asarUrl, res => {
res.pipe(fileStream)
fileStream
.on('finish', () => {
log.info('asar下载完成')
})
.on('error', error => {
// 删除文件
fs.unlink(localAsarTemp, () => {
log.error('下载出现异常,已中断', error)
})
})
})
}

因此,我们的流程更新为:发现新版本后,下载最新的 app.asarresources 目录,并重命名为 app.asar-temp。这个时候我们启动项目进行调试,找到记录在本地的日志—— C:\Users\用户名\AppData\Roaming\electron-update-demo\logs,会有以下的记录:


[2024-09-20 13:49:22.456] [info]  监测到配置文件
[2024-09-20 13:49:22.462] [info] 当前版本不是最新热更版本
[2024-09-20 13:49:23.206] [info] asar下载完成

在看看项目 resources 文件夹,多了一个 app.asar-temp文件。


image.png


至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。


替换 app.asar 文件


好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。


Windows 操作系统下,直接替换 app.asar 会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。



  1. 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。

  2. 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。


我们有上面两种方案,最终采用了 方案2


在主进程监听 app.on('quit') 事件,在应用退出的时候,判断 app.asarapp.asar-temp 是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs 在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。


nodejs 可以通过 spawnexec 等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs,因为业务方的机器上不一定有这个环境,而是采用了启动 exe 可执行文件的方式。可能有人问为什么不直接运行 .bat 批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawnwindowsHide: true


那么如何获得这个 exe 可执行文件呢,其实是通过 bat 文件去编译的,命令如下:


@echo off
timeout /T 1 /NOBREAK
del /f /q /a %1\app.asar
ren %1\app.asar-temp app.asar

我们加了延时,等待父进程完全关闭后,再去执行后续命令。其中 %1 为运行脚本传入的参数,在我们的场景里就是 resources 文件夹的地址,因为每个人的安装地址应该是不一样的,所以需要作为参数传进去。


转换文件的工具一开始用的是 Bat To Exe Converter 下载地址,但是在实际使用的过程中,我的电脑竟然把转换后的 exe 文件 识别为病毒,但在其他同事电脑上没有这种表现,这肯定就不行了,最后还是同事使用 python 帮我转换生成了一份可用的文件(replace.exe)。


这里我们可以选择不同的方式把 replace.exe 存放到 用户目录 上,要么是从线上服务器下载,要么是安装的时候就已经存放在本地了。我们选择了后者,这需要修改 electron-builder 打包配置,指定 asarUnpack, 这样就会存放在 app.asar.unpacked 文件夹中,不经常修改的文件都可以放在这里,不会随着增量更新而替换掉。


有了这个替换脚本之后,开始编写子进程相关的代码。


import { spawn } from 'child_process'

cosnt localExePath = path.join(resourcePath, 'app.asar.unpacked/resources/replace.exe')

replaceAsar() {
if (fs.existsSync(localAsar) && fs.existsSync(localAsarTemp)) {
const command = `${localExePath} ${resourcePath}`
log.info(command)
const logPath = app.getPath('logs')
const childOut = fs.openSync(path.join(logPath, './out.log'), 'a')
const childErr = fs.openSync(path.join(logPath, './err.log'), 'a')
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true, // 允许子进程独立
shell: true,
stdio: ['ignore', childOut, childErr]
})
child.on('spawn', () => log.info('子进程触发'))
child.on('error', err => log.error('child error', err))
child.on('close', code => log.error('child close', code))
child.on('exit', code => log.error('child exit', code))
child.stdout?.on('data', data => log.info('stdout', data))
child.stderr?.on('data', data => log.info('stderr', data))
child.unref()
}
}

app.on('quit', () => {
replaceAsar()
})

在这块代码中,创建子进程的配置项比较重要,尤其是路径相关的。因为用户安装路径是不同的,可能会存在 文件夹名含有空格 的情况,比如 Program Files,这会导致在执行的时候将空格识别为分隔符,导致命令执行失败,因此需要加上 双引号。此外,detached: true 可以让子进程独立出来,也就是父进程退出后可以执行,而shell: true 可以将路径名作为参数传过去。


const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true,
shell: true,
stdio: ['ignore', childOut, childErr]
})

但这块有个 疑惑,为什么我的 closeexit 以及 stdout 都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。


至此,在关闭应用之后,app.asar 就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion,防止下次又去下载更新包了。


child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})

updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}

增量更新日志提示


既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里


至于更新内容,我们可以复用 releaseInforeleaseNotes 字段,把更新日志写在这里,增量更新完成后展现给用户就好了。


但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了 按钮,或者关闭 Modal 后,把当前的热更新版本号保存在本地配置文件,记录为 logVersion。在下次打开程序或者校验更新的时候,我们判断一下 logVersion === hotVersion若不同,再去提示更新日志。


日志版本 校验和修改的代码如下所示:


checkLogVersion() {
let needShowLog = false
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion, logVersion } = configData
if (hotVersion !== logVersion) {
log.info('日志版本与当前热更新版本不同,需要提示更新日志')
needShowLog = true
} else {
log.info('日志已是最新版本,无需提示更新日志')
}
} catch (error) {
log.error('读取配置文件失败', error)
}
return needShowLog
}

updateLogVersion() {
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion } = configData
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion, logVersion: hotVersion }, null, 2))
log.info('日志版本已更新')
} catch (error) {
log.error('读取配置文件失败', error)
}
}

读取 config.json 文件的方法自行封装一下,我这里就重复使用了。主进程 ipcMain.on 监听一下用户传递过来的事件,再去调用 updateLogVersion 即可,渲染进程效果如下:


提示增量更新日志


image.png


点击 知道了 后,再次打开应用后就不会提示日志了,因为本地配置文件已经被修改了。


image.png


当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes 是否为空的逻辑就好了,也做到了 静默更新


小结


不足之处


本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:



  1. dll第三方SDK 等资源的更新。

  2. 增量更新失败后应该通过全量更新 兜底

  3. 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。


流程图


针对本文的解决方案,我简单画了一个 流程图


未命名绘图.png


参考文章


网上其实有不少关于 Electron 自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。


写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。


鸣谢:



作者:小陈同学吗
来源:juejin.cn/post/7416311252580352034
收起阅读 »

我的 Electron 客户端也可以全量/增量更新了

web
前言 本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。 全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。 增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。 本文并没有拿真实项目来举例子,而是起一个 新的项目 从...
继续阅读 »

前言


本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。


全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。


增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。


本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。


如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。


前置说明:



  1. 由于业务场景的限制,本文介绍的更新仅支持 Windows 操作系统,其余操作系统未作兼容处理。

  2. 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。

  3. 发布方式限制为 generic,线上服务需要配置 nginx 确保访问到资源文件。


准备工作


脚手架搭建项目


我们通过 electron-vite 快速搭建一个基于 Vite + React + TSElectron 项目。


1.jpg


该模板已经包括了我们需要的核心第三方库:electron-builderelectron-updater


前者是用来打包客户端程序的,后者是用来实现自动更新的。


在项目根目录下,已经自动生成了两份配置文件:electron-builder.ymldev-app-update.yml


electron-builder.yml


该文件描述了一些 打包配置,更多信息可参考 官网


在这些配置项中,publish 字段比较重要,因为它关系到更新源。


publish:
provider: generic // 使用一个通用的 HTTP 服务器作为更新源。
url: https://example.com/auto-updates // 存放安装包和描述文件的地址

provider 字段还有其他可选项,但是本文只介绍 generic 这种方式,即把安装包放在 HTTP 服务器里。


dev-app-update.yml


provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: electron-update-demo-updater

其中,updaterCacheDirName 定义下载目录,也就是安装包存放的位置。全路径是C:\Users\用户名\AppData\Local\electron-update-demo-updater,不配置则在C:\Users\用户名\AppData\Local下自动创建文件夹,开发环境下为项目名,生产环境下为项目名-updater


image.png


模拟服务器


我们直接运行 npm run build:win,在默认 dist 文件夹下就出现了打包后的一些资源。


image.png


其实更新的基本思路就是对比版本号,即对比本地版本和 线上服务器 版本是否一致,若不一致则需要更新。


image.png


因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。


新建一个文件夹 mockServer,把打包后的 setup.exe 安装包和 latest.yml 文件粘贴进去,然后通过 serve 命令默认起了一个 http://localhose:3000 的本地服务器。


既然有了存放资源的本地地址,在开发调试的时候,我们更新下 dev-app-update.yml 文件的 url 字段,也就是修改为 http://localhose:3000


注:如果需要测试打包后的更新功能,需要同步修改打包配置文件。


全量更新


与主进程文件同级,创建 update.ts 文件,之后我们的更新逻辑将在这里展开。


import { autoUpdater } from 'electron-updater' //核心库

需要注意的是,在我们开发过程中,通过 npm run dev 起来的 Electron 程序其实不能算是打包后的状态。


你会发现在调用 autoUpdater 的一些方法会提示下面的错误:


Skip checkForUpdates because application is not packed and dev update config is not forced


因此我们需要在开发环境去做一些动作,并且手动指定 updateConfigPath


// update.ts
if (isDev) {
Object.defineProperty(app, 'isPackaged', {
get: () => true
})
autoUpdater.updateConfigPath = path.join(__dirname, '../../dev-app-update.yml')
}

核心对象 autoUpdater 有很多可以主动调用的方法,也有一些监听事件,同时还有一些属性可以设置。


这里只展示了本人项目场景所需的一些配置。


autoUpdater.autoDownload = false // 不允许自动下载更新
autoUpdater.allowDowngrade = true // 允许降级更新(应付回滚的情况)

autoUpdater.checkForUpdates()
autoUpdater.downloadUpdate()
autoUpdater.quitAndInstall()

autoUpdater.on('checking-for-update', () => {
console.log('开始检查更新')
})
autoUpdater.on('update-available', info => {
console.log('发现更新版本')
})
autoUpdater.on('update-not-available', info => {
console.log('不需要全量更新', info.version)
})
autoUpdater.on('download-progress', progressInfo => {
console.log('更新进度信息', progressInfo)
})
autoUpdater.on('update-downloaded', () => {
console.log('更新下载完成')
})
autoUpdater.on('error', errorMessage => {
console.log('更新时出错了', errorMessage)
})

在监听事件里,我们可以拿到下载新版本 整个生命周期 需要的一些信息。


// 新版本的版本信息(已筛选)
interface UpdateInfo {
readonly version: string;
releaseName?: string | null;
releaseNotes?: string | Array<ReleaseNoteInfo> | null;
releaseDate: string;
}

// 下载安装包的进度信息
interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}

在写完这些基本的方法之后,我们需要决定 检验更新 的时机,一般是在应用程序真正启动之后,即 mainWindow 创建之后。


运行项目,预期会提示 不需要全量更新,因为刚才复制到本地服务器的 latest.yml 文件里的版本信息与本地相同。修改 version 字段,重启项目,主进程就会提示有新版本需要更新了。


频繁启动应用太麻烦,除了应用初次启动时主进程主动帮我们检验更新外,还需要用户 手动触发 版本更新检测,此外,由于产品场景需要,当发现有新版本的时候,需要在 渲染进程 通知用户,包括版本更新时间和更新内容。因此我们需要加上主进程与渲染进程的 IPC通信 来实现这个功能。


其实需要的数据主进程都能够提供了,渲染进程具体的展示及交互方式就自由发挥了,这里简单展示下我做的效果。


1. 发现新版本
GIF 2024-9-19 16-48-44.gif


2. 无需更新


image.png


增量更新


为什么要这么做


其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater 封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。


此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知 的形式,只不过我们更新的不是整个应用程序。


由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron 给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台 等特性的同时,还得忍受 臃肿的安装包


带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer,并不包括 dll第三方SDK等资源。


网上有挺多种增量更新的 解决方案,例如:



  1. 通过 win.loadURL(一个线上地址) 实现,相当于就套了一层客户端的壳子,与加载的Web 端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用 node 去操作一些底层的东西。

  2. 设置 asar 的归档方式,替换app.asarapp.asar.unpack来实现。但后者在我实践过程中存在文件路径不存在的问题。

  3. 禁用 asar 归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。

  4. 欢迎补充。


本文我们采用较普遍的 替换asar 来实现。


优化 app.asar 体积



asar是 Electron提供的一种将多个文件合并成一个文件的类 tar 风格的归档格式,不仅可以缓解Windows下路径名过长的问题, 还能够略微加快一下 require的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)



Electron 应用程序启动的时候,会读取 app.asar.unpacked 目录中的内容合并到 app.asar 的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar 这个文件。


例如:D:\你的安装路径\electron-update-demo\resources


image.png


在这个 Demo 项目中,脚手架生成的模板将图标剥离出来,因此出现了一个 app.asar.unpacked 文件夹。我们不难发现,app.asar 这个文件竟然有 66 MB,再回过头看我们真正的代码文件,甚至才 1 MB !那么到底是什么原因导致的这个文件这么大呢,刚才提到了,asar 其实是一种压缩格式,因此我们只要解压看看就知道了。


npm i -g asar // 全局安装

asar e app.asar folder // 解压到folder文件夹

image.png


解压后我们不难发现,out 文件夹里的才是我们打包出来的资源,罪魁祸首竟然是 node_modules,足足有 62.3 MB


查阅资料得知,Electron 在打包的时候,会把 dependencies 字段里的依赖也一起打包进去。而我们的渲染进程是用 React开发的,这些第三方依赖早就通过 Vite 等打包工具打到资源文件里了,根本不需要再打包一次,不然我们重新下载的 app.asar 文件还是很大的,因此需要尽可能减少体积。


优化应用程序体积 == 减少 node_modules 文件夹的大小 == 减少需要打包的依赖数量 == 减少 dependencies 中的依赖。


1. 移除 dependencies


最开始我想的是把 package.json 中的 dependencies 全都移到 devDependencies,打包后的体积果然减小了,但是在测试过程中会发现某些功能依赖包会缺失,比如 @electron/remote。因此在修改这部分代码的时候,需要特别留意,哪些依赖是需要保留下来的。


由于不想影响 package.json 的版本结构,我只是在写了一个脚本,在 npm i 之后,执行打包命令前修改 devDependencies 就好了。


2. 双 package.json 结构


这是 electron-builder 官网上看到的一种技巧,传送门, 创建 app 文件夹,再创建第二个 package.json,在该文件中配置我们应用的名称、版本、主进程入口文件等信息。这样一来,electron-builder 在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。


但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试


image.png


这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。


校验增量更新


全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。


首先明确一下 校验的时机package.jsonversion 字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available 事件。所以我们可以在这个事件的回调函数里来进行校验。


autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })

然后就是 如何校验,我们回过头来看 electron-builder 的打包配置,在 releaseInfo 字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes 来存储更新日志,查阅官网得知还有个 releaseName 好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor 字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)


每次发布新版本的时候,只要不是 Electron自身版本变化 等重大更新,我们都可以通过修改 releaseInforeleaseName 来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 userData 文件夹,用于存储我们应用程序的用户信息,即使程序卸载 了也不会影响里面的资源。在 Windows 下,路径如下:
C:\Users\用户名\AppData\Roaming\electron-update-demo


因此,整个 校验流程 就是,在打开程序的时候,autoUpdater 触发 update-not-available 事件,拿到线上 latest.yml 描述的 releaseName 作为热版本号,与本地配置文件(我们命名为 config.json)里存储的热版本号(我们命名为 hotVersion)进行对比,若不同就去下载最新的 app.asar 文件。


我们使用 electron-log 来记录日志,代码如下所示。


// 本地配置文件
const localConfigFile = path.join(app.getPath('userData'), 'config.json')

const checkHotVersion = (latestHotVersion: string): boolean => {
let needDownload = false
if (!fs.existsSync(localConfigFile)) {
fs.writeFileSync(localConfigFile, '{}', 'utf8')
log.info('配置文件不存在,已自动创建')
} else {
log.info('监测到配置文件')
}

try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
// 对比版本号
if (latestHotVersion !== configData.hotVersion) {
log.info('当前版本不是最新热更版本')
needDownload = true
} else {
log.info('当前为最新版本')
}
} catch (error) {
log.error('读取配置文件时出错', error)
}

return needDownload
}

下载增量更新包


通过刚才的校验,我们已经知道了什么时候该去下载增量更新包。


在开发调试的时候,我们可以把新版本的 app.asar 也放到起了本地服务器的 mockServer 文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejshttp 模块去实现,如果是 https 的需要引用 https 模块。


下载到本地的时候,我是放在了与 app.asar 同级目录的 resources 文件夹,我们不能直接覆盖原文件。一是因为进程权限占用的问题,二是为了容错,所以我们需要以另一个名字来命名下载后的文件(这里我们用的是 app.asar-temp),也就不需要去备份原文件了,代码如下。


const resourcePath = isDev
? path.join('D:\\测试\\electron-update-demo\\resources')
: process.resourcesPath

const localAsarTemp = path.join(resourcePath, 'app.asar-temp')

const asarUrl = 'http://localhost:3000/app.asar'

downloadAsar() {
const fileStream = fs.createWriteStream(localAsarTemp)
http.get(asarUrl, res => {
res.pipe(fileStream)
fileStream
.on('finish', () => {
log.info('asar下载完成')
})
.on('error', error => {
// 删除文件
fs.unlink(localAsarTemp, () => {
log.error('下载出现异常,已中断', error)
})
})
})
}

因此,我们的流程更新为:发现新版本后,下载最新的 app.asarresources 目录,并重命名为 app.asar-temp。这个时候我们启动项目进行调试,找到记录在本地的日志—— C:\Users\用户名\AppData\Roaming\electron-update-demo\logs,会有以下的记录:


[2024-09-20 13:49:22.456] [info]  监测到配置文件
[2024-09-20 13:49:22.462] [info] 当前版本不是最新热更版本
[2024-09-20 13:49:23.206] [info] asar下载完成

在看看项目 resources 文件夹,多了一个 app.asar-temp文件。


image.png


至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。


替换 app.asar 文件


好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。


Windows 操作系统下,直接替换 app.asar 会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。



  1. 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。

  2. 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。


我们有上面两种方案,最终采用了 方案2


在主进程监听 app.on('quit') 事件,在应用退出的时候,判断 app.asarapp.asar-temp 是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs 在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。


nodejs 可以通过 spawnexec 等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs,因为业务方的机器上不一定有这个环境,而是采用了启动 exe 可执行文件的方式。可能有人问为什么不直接运行 .bat 批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawnwindowsHide: true


那么如何获得这个 exe 可执行文件呢,其实是通过 bat 文件去编译的,命令如下:


@echo off
timeout /T 1 /NOBREAK
del /f /q /a %1\app.asar
ren %1\app.asar-temp app.asar

我们加了延时,等待父进程完全关闭后,再去执行后续命令。其中 %1 为运行脚本传入的参数,在我们的场景里就是 resources 文件夹的地址,因为每个人的安装地址应该是不一样的,所以需要作为参数传进去。


转换文件的工具一开始用的是 Bat To Exe Converter 下载地址,但是在实际使用的过程中,我的电脑竟然把转换后的 exe 文件 识别为病毒,但在其他同事电脑上没有这种表现,这肯定就不行了,最后还是同事使用 python 帮我转换生成了一份可用的文件(replace.exe)。


这里我们可以选择不同的方式把 replace.exe 存放到 用户目录 上,要么是从线上服务器下载,要么是安装的时候就已经存放在本地了。我们选择了后者,这需要修改 electron-builder 打包配置,指定 asarUnpack, 这样就会存放在 app.asar.unpacked 文件夹中,不经常修改的文件都可以放在这里,不会随着增量更新而替换掉。


有了这个替换脚本之后,开始编写子进程相关的代码。


import { spawn } from 'child_process'

cosnt localExePath = path.join(resourcePath, 'app.asar.unpacked/resources/replace.exe')

replaceAsar() {
if (fs.existsSync(localAsar) && fs.existsSync(localAsarTemp)) {
const command = `${localExePath} ${resourcePath}`
log.info(command)
const logPath = app.getPath('logs')
const childOut = fs.openSync(path.join(logPath, './out.log'), 'a')
const childErr = fs.openSync(path.join(logPath, './err.log'), 'a')
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true, // 允许子进程独立
shell: true,
stdio: ['ignore', childOut, childErr]
})
child.on('spawn', () => log.info('子进程触发'))
child.on('error', err => log.error('child error', err))
child.on('close', code => log.error('child close', code))
child.on('exit', code => log.error('child exit', code))
child.stdout?.on('data', data => log.info('stdout', data))
child.stderr?.on('data', data => log.info('stderr', data))
child.unref()
}
}

app.on('quit', () => {
replaceAsar()
})

在这块代码中,创建子进程的配置项比较重要,尤其是路径相关的。因为用户安装路径是不同的,可能会存在 文件夹名含有空格 的情况,比如 Program Files,这会导致在执行的时候将空格识别为分隔符,导致命令执行失败,因此需要加上 双引号。此外,detached: true 可以让子进程独立出来,也就是父进程退出后可以执行,而shell: true 可以将路径名作为参数传过去。


const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true,
shell: true,
stdio: ['ignore', childOut, childErr]
})

但这块有个 疑惑,为什么我的 closeexit 以及 stdout 都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。


至此,在关闭应用之后,app.asar 就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion,防止下次又去下载更新包了。


child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})

updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}

增量更新日志提示


既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里


至于更新内容,我们可以复用 releaseInforeleaseNotes 字段,把更新日志写在这里,增量更新完成后展现给用户就好了。


但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了 按钮,或者关闭 Modal 后,把当前的热更新版本号保存在本地配置文件,记录为 logVersion。在下次打开程序或者校验更新的时候,我们判断一下 logVersion === hotVersion若不同,再去提示更新日志。


日志版本 校验和修改的代码如下所示:


checkLogVersion() {
let needShowLog = false
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion, logVersion } = configData
if (hotVersion !== logVersion) {
log.info('日志版本与当前热更新版本不同,需要提示更新日志')
needShowLog = true
} else {
log.info('日志已是最新版本,无需提示更新日志')
}
} catch (error) {
log.error('读取配置文件失败', error)
}
return needShowLog
}

updateLogVersion() {
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion } = configData
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion, logVersion: hotVersion }, null, 2))
log.info('日志版本已更新')
} catch (error) {
log.error('读取配置文件失败', error)
}
}

读取 config.json 文件的方法自行封装一下,我这里就重复使用了。主进程 ipcMain.on 监听一下用户传递过来的事件,再去调用 updateLogVersion 即可,渲染进程效果如下:


提示增量更新日志


image.png


点击 知道了 后,再次打开应用后就不会提示日志了,因为本地配置文件已经被修改了。


image.png


当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes 是否为空的逻辑就好了,也做到了 静默更新


小结


不足之处


本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:



  1. dll第三方SDK 等资源的更新。

  2. 增量更新失败后应该通过全量更新 兜底

  3. 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。


流程图


针对本文的解决方案,我简单画了一个 流程图


未命名绘图.png


参考文章


网上其实有不少关于 Electron 自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。


写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。


鸣谢:



作者:小陈同学吗
来源:juejin.cn/post/7416311252580352034
收起阅读 »

基于英雄联盟人物的加载动画,奇怪的需求又增加了!

web
1、背景 前两天老板找到我说有一个需求,要求使用英雄联盟的人物动画制作一个加载中的组件,类似于下面这样: 我定眼一看:这个可以实现,但是需要UI妹子给切图。 老板:UI? 咱们啥时候招的UI ! 我:老板,那不中呀,不切图弄不成呀。 老板:下个月绩效给你A。...
继续阅读 »

1、背景


前两天老板找到我说有一个需求,要求使用英雄联盟的人物动画制作一个加载中的组件,类似于下面这样:


iShot_2024-06-06_18.09.55.gif


我定眼一看:这个可以实现,但是需要UI妹子给切图。


老板:UI? 咱们啥时候招的UI !


我:老板,那不中呀,不切图弄不成呀。


老板:下个月绩效给你A。


我:那中,管管管。


2、调研


发动我聪明的秃头,实现这个需求有以下几种方案:



  • 切动画帧,没有UI不中❎。

  • 去lol客户端看看能不能搞到什么美术素材,3D模型啥的,可能行❓

  • 问下 gpt4o,有没有哪个老表收集的有lol英雄的美术素材,如果有那就更得劲了✅。


经过我一番搜索,发现了这个网站:model-viewer,收集了很多英雄联盟的人物模型,模型里面还有各种动画,还给下载。老表,这个需求稳了50%了!


image-20240606182312802.png


接下来有几种选择:



  • 将模型动画转成动画帧,搞成雪碧图,较为麻烦,且动画不支持切换。

  • 直接加载模型,将模型放在进度条上,较为简单,支持切换不同动画,而且可以自由过渡。就是模型文件有点大,初始化加载可能耗时较长。但是后续缓存一下就好了。


聪明的我肯定先选第二个方案呀,你糊弄我啊,我糊弄你。


3、实现


web中加载模型可以使用谷歌基于threejs封装的 model-viewer, 使用现代的 web component 技术。简单易用。


先初始化一个vue工程


 npm create vue@latest

然后将里面的初始化的组件和app.vue里面的内容都删除。


安装model-viewer依赖:


npm i three // 前置依赖
npm i @google/model-viewer

修改vite.config.js,将model-viewer视为自定义元素,不进行编译


import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
 plugins: [
   vue({
     template: {
       // 添加以下内容
       compilerOptions: {
         isCustomElement: (tag) => ['model-viewer'].includes(tag)
      }
    }
  })
],
 resolve: {
   alias: {
     '@': fileURLToPath(new URL('./src', import.meta.url))
  }
},
 assetsInclude: ['./src/assets/heros/*.glb']
})


新建 src/components/LolProgress.vue


<template>
 <div class="progress-container">
   <model-viewer
     :src="hero.src"
     disable-zoom
     shadow-intensity="1"
     :camera-orbit="hero.cameraOrbit"
     class="model-viewer"
     :style="heroPosition"
     :animation-name="animationName"
     :camera-target="hero.cameraTarget"
     autoplay
     ref="modelViewer"
   >
</model-viewer>
   <div
     class="progress-bar"
     :style="{ height: strokeWidth + 'px', borderRadius: strokeWidth / 2 + 'px' }"
   >

     <div class="progress-percent" :style="currentPercentStyle"></div>
   </div>
 </div>

</template>

<script setup lang="ts">
import { computed, onMounted, ref, watch, type PropType } from 'vue'
/** 类型 */
interface Hero {
 src: string
 cameraOrbit: string
 progressAnimation: string
 finishAnimation: string
 finishAnimationIn: string
 cameraTarget: string
 finishDelay: number
}
type HeroName = 'yasuo' | 'yi'

type Heros = {
[key in HeroName]: Hero
}
const props = defineProps({
 hero: {
   type: String as PropType<HeroName>,
   default: 'yasuo'
},
 percentage: {
   type: Number,
   default: 100
},
 strokeWidth: {
   type: Number,
   default: 10
},
 heroSize: {
   type: Number,
   default: 150
}
})

const modelViewer = ref(null)

const heros: Heros = {
 yasuo: {
   src: '/src/components/yasuo.glb',
   cameraOrbit: '-90deg 90deg',
   progressAnimation: 'Run2',
   finishAnimationIn: 'yasuo_skin02_dance_in',
   finishAnimation: 'yasuo_skin02_dance_loop',
   cameraTarget: 'auto auto 0m',
   finishDelay: 2000
},
 yi: {
   src: '/src/components/yi.glb',
   cameraOrbit: '-90deg 90deg',
   progressAnimation: 'Run',
   finishAnimationIn: 'Dance',
   finishAnimation: 'Dance',
   cameraTarget: 'auto auto 0m',
   finishDelay: 500
}
}

const heroPosition = computed(() => {
 const percentage = props.percentage > 100 ? 100 : props.percentage
 return {
   left: `calc(${percentage + '%'} - ${props.heroSize / 2}px)`,
   bottom: -props.heroSize / 10 + 'px',
   height: props.heroSize + 'px',
   width: props.heroSize + 'px'
}
})

const currentPercentStyle = computed(() => {
 const percentage = props.percentage > 100 ? 100 : props.percentage
 return { borderRadius: `calc(${props.strokeWidth / 2}px - 1px)`, width: percentage + '%' }
})

const hero = computed(() => {
 return heros[props.hero]
})

const animationName = ref('')

watch(
() => props.percentage,
(percentage) => {
   if (percentage < 100) {
     animationName.value = hero.value.progressAnimation
  } else if (percentage === 100) {
     animationName.value = hero.value.finishAnimationIn
     setTimeout(() => {
       animationName.value = hero.value.finishAnimation
    }, hero.value.finishDelay)
  }
}
)
onMounted(() => {
 setTimeout(() => {
   console.log(modelViewer.value.availableAnimations)
}, 2000)
})
</script>
<style scoped>
.progress-container {
 position: relative;
 width: 100%;
}
.model-viewer {
 position: relative;
 background: transparent;
}
.progress-bar {
 border: 1px solid #fff;
 background-color: #666;
 width: 100%;
}
.progress-percent {
 background-color: aqua;
 height: 100%;
 transition: width 100ms ease;
}
</style>


组件非常简单,核心逻辑如下:



  • 根据传入的英雄名称加载模型

  • 指定每个英雄的加载中的动画,

  • 加载100%,切换完成动作进入动画和完成动画即可。

  • 额外的细节处理。


    最后修改 app.vue:


    <script setup lang="ts">
    import { ref } from 'vue'
    import LolProgress from './components/LolProgress.vue'
    const percentage = ref(0)
    setInterval(() => {
     percentage.value = percentage.value + 1
    }, 100)
    </script>

    <template>
     <main>
       <LolProgress
         :style="{ width: '200px' }"
         :percentage="percentage"
         :heroSize="200"
         hero="yasuo"
       />

     </main>
    </template>

    <style scoped></style>




这不就完成了吗,先拿给老板看看。


老板:换个女枪的看看。


我:好嘞。


iShot_2024-06-06_19.08.49.gif


老板:弄类不赖啊小伙,换个俄洛伊的看看。


4、总结


通过本次需求,了解到了 model-viewer组件。


老板招个UI妹子吧。


在线体验:github-pages


作者:盖伦大王
来源:juejin.cn/post/7377217883305279526
收起阅读 »

组长说:公司的国际化就交给你了,下个星期给我

web
从“跑路程序员”到“摸鱼仙人”,我用这插件把国际化的屎山代码盘活了! tips:使用有道翻译,朋友们,要去有道官网注册一下,有免费额度,github demo的key已经被用完了。 一、命运的齿轮开始转动 “小王啊,海外业务要上线了,国际化你搞一下,下个月...
继续阅读 »

从“跑路程序员”到“摸鱼仙人”,我用这插件把国际化的屎山代码盘活了!



tips:使用有道翻译,朋友们,要去有道官网注册一下,有免费额度,github demo的key已经被用完了。



一、命运的齿轮开始转动


“小王啊,海外业务要上线了,国际化你搞一下,下个月验收。”组长轻描淡写的一句话,让我盯着祖传代码陷入沉思——


翻译代码注释.png
(脑补画面:满屏中文硬编码,夹杂着"确定""取消""加载中..."


正当我准备打开BOSS直聘时,GitHub Trending上一个项目突然闪现——

auto-i18n-translation-plugins

项目简介赫然写着:“不改代码,三天交付国际化需求,摸鱼率提升300%”




二、极限操作:48小时从0到8国语言


🔧 第1步:安装插件(耗时5分钟)


祖训“工欲善其事,必先装依赖”


# 如果你是Vite玩家(比如Vue3项目)
npm install vite-auto-i18n-plugin --save-dev

# 如果你是Webpack钉子户(比如React老项目)
npm install webpack-auto-i18n-plugin --save-dev

🔧 第2步:配置插件(关键の10分钟)


Vue3 + Vite の 摸鱼配置


// vite.config.js
import { defineConfig } from 'vite';
import vitePluginAutoI18n from 'vite-auto-i18n-plugin';

export default defineConfig({
plugins: [
vue(),
vitePluginAutoI18n({
targetLangList: ['en', 'ja', 'ko'], // 要卷就卷8国语言!
translator: new YoudaoTranslator({ // 用有道!不用翻墙!
appId: '你的白嫖ID', // 去官网申请,10秒搞定
appKey: '你的密钥' // 别用示例里的,会炸!
})
})
]
});

🔧 第3步:注入灵魂——配置文件(生死攸关の5分钟)


在项目入口文件(如main.js)的第一行插入


// 这是插件的生命线!必须放在最前面!
import '../lang/index.js'; // 运行插件之后会自动生成引入即可



三、见证奇迹的时刻


🚀 第一次运行(心脏骤停の瞬间)


输入npm run dev,控制台开始疯狂输出:


[插件日志] 检测到中文文本:"登录" → 生成哈希键:a1b2c3  
[插件日志] 调用有道翻译:"登录" → 英文:Login,日文:ログイン...
[插件日志] 生成文件:lang/index.json(翻译の圣杯)

突然!页面白屏了!

别慌!这是插件在首次翻译时需要生成文件,解决方法:



  1. 立即执行一次 npm run build (让插件提前生成所有翻译)

  2. 再次npm run dev → 页面加载如德芙般丝滑




四、效果爆炸:我成了全组の神


1. 不可置信の48小时


当我打开浏览器那一刻——\


Untitled.gif


(瞳孔地震):“卧槽…真成了?!”

组长(凑近屏幕):“这…这是你一个人做的?!”(眼神逐渐迷茫)

产品经理(掏出手机拍照):“快!发朋友圈!《我司技术力碾压硅谷!》”


2. 插件の超能力



  • 构建阶段:自动扫描所有中文 → 生成哈希键 → 调用API翻译

  • 运行时:根据用户语言动态加载对应翻译

  • 维护期:改个JSON文件就能更新所有语言版本


副作用



  • 测试妹子开始怀疑人生:“为什么一个bug都找不到?”

  • 后端同事偷偷打听:“你这插件…能翻译Java注释吗?”




五、职场生存指南:如何优雅甩锅


🔨 场景1:测试妹子提着40米大刀来了!


问题:俄语翻译把“注册”译成“Регистрация”(原意是“登记处”)

传统应对



  • 熬夜改代码 → 重新打包 → 提交测试 → 被骂效率低

    插件玩家



  1. 打开lang/index.json

  2. Регистрация改成Зарегистрироваться(深藏功与名)

  3. 轻描淡写:“这是有道翻译的锅,我手动修正了。”


🔨 场景2:产品经理临时加语言


需求:“老板说下周要加印地语!”

传统灾难



  • 重新配框架 → 人肉翻译 → 测试 → 加班到秃头

    插件玩家



  1. 配置加一行代码:targetLangList: ['hi']

  2. 运行npm run build → 自动生成印地语翻译

  3. 告诉产品经理:“这是上次预留的技术方案。”(其实只改了1行)


🔨 场景3:组长怀疑你摸鱼


质问:“小王啊,你这效率…是不是有什么黑科技?”

标准话术

“组长,这都是因为:



  1. 您制定的开发规范清晰

  2. 公司技术栈先进(Vue3真香)

  3. 我参考了国际前沿方案(打开GitHub页面)”




六、高级摸鱼の奥义


🎯 秘籍1:把翻译文件变成团队武器



  1. lang/index.json扔给产品经理:“这是国际化核心资产!”

  2. 对方用Excel修改后,你直接git pull → 无需动代码

  3. 出问题直接甩锅:“翻译是市场部给的,我只负责技术!”




(脑补画面:产品经理在Excel里疯狂改翻译,程序员在刷剧)


🎯 秘籍2:动态加载の神操作


痛点:所有语言打包进主文件 → 体积爆炸!

解决方案


// 在index.js里搞点骚操作
const loadLanguage = async (lang) => {
const data = await import(`../../lang/${lang}.json`); // 动态加载翻译文件
window.$t.locale(data, 'lang');
};

// 切换语言时调用
loadLanguage('ja'); // 瞬间切换日语,深藏功与名

🎯 秘籍3:伪装成AI大神



  1. 周会汇报:“我基于AST实现了自动化国际翻译中台”

  2. 实际:只是配了个插件

  3. 老板评价:“小王这技术深度,值得加薪!”(真相只有你知道)




七、终局:摸鱼の神,降临!


当组长在庆功会上宣布“国际化项目提前两周完成”时,我正用手机刷着《庆余年2》。


测试妹子:“你怎么一点都不激动?”

(收起手机):“常规操作,要习惯。”(心想:插件干活,我躺平,这才叫真正的敏捷开发!)




立即行动(打工人自救指南)



  1. GitHub搜auto-i18n-translation-plugins(点星解锁摸鱼人生)

  2. 复制我的配置 → 运行 → 见证魔法

  3. 加开发者社群:遇到问题发红包喊“大哥救命!”


终极警告

⚠️ 过度使用此插件可能导致——



  • 你的摸鱼时间超过工作时间,引发HR关注

  • 产品经理产生“国际化需求可以随便加”的幻觉

  • 老板误以为你是隐藏的技术大佬(谨慎处理!)




文末暴击

“自从用了这插件,我司翻译团队的工作量从3周变成了3分钟——现在他们主要工作是帮我选中午吃啥。” —— 匿名用户の真实反馈




常见问题汇总


常见问题汇总


作者:wenps
来源:juejin.cn/post/7480267450286800911
收起阅读 »

实现基于uni-app的项目自动检查APP更新

web
我们平时工作中开发APP时,及时为用户提供应用更新是提升用户体验和保证功能完整性的重要一环。本文将通过一段实际的代码示例,详细介绍如何在基于uni-app框架的项目中实现自动检查应用更新的功能,并引导用户完成升级过程。该功能主要涉及与服务器端交互获取最新版本信...
继续阅读 »

我们平时工作中开发APP时,及时为用户提供应用更新是提升用户体验和保证功能完整性的重要一环。本文将通过一段实际的代码示例,详细介绍如何在基于uni-app框架的项目中实现自动检查应用更新的功能,并引导用户完成升级过程。该功能主要涉及与服务器端交互获取最新版本信息、比较版本号、提示用户升级以及处理下载安装流程。



创建一个checkappupdate.js文件


这个文件是写升级逻辑处理的文件,可以不创建,直接在App.vue中写,但是为了便于维护,还是单独放出来比较好,可以放在common或者util目录中(App.vue能引入到就行,随意放,根目录也行),App.vue中引入该文件,调用升级函数如下图所示:


image.png


js完整代码


为了防止一点点代码写,容易让人云里雾里,先放完整代码,稍后再详细解释,其实看注释也就够了。


//这是服务端请求url配置文件,如果你直接卸载下面的请求中,可以不引入
import configService from '@/common/service/config.service.js'

export default function checkappupdate(param = {}) {
// 合并默认参数
param = Object.assign({
title: "A new version has been detected!",
content: "Please upgrade the app to the latest version!",
canceltext: "No upgrade",
oktext: "Upgrade now"
}, param)

plus.runtime.getProperty(plus.runtime.appid, (widgetInfo) => {
let platform = plus.os.name.toLocaleLowerCase() //Android
let os_version = plus.os.version //13 安卓版本
let vendor = plus.device.vendor //Xiaomi
let url = configService.apiUrl
uni.request({
url: url + '/checkAppUpdate',
method: 'GET',
data: {
platform: platform,
os_version: os_version,
vendor: vendor,
cur_version: widgetInfo.version
},
success(result) {
console.log(result)
let versionCode = parseInt(widgetInfo.versionCode)
let data = result.data ? result.data : null;
// console.log(data);
let downAppUrl = data.url
//判断版本是否需要升级
if (versionCode >= data.versionCode) {
return;
}
//升级提示
uni.showModal({
title: param.title,
content: data.log ? data.log : param.content,
showCancel: data.force ? false : true,
confirmText: param.oktext,
cancelText: param.canceltext,
success: res => {
if (!res.confirm) {
console.log('Cancel the upgrade');
// plus.runtime.quit();
return
}
// if (data.shichang === 1) {
// //去应用市场更新
// plus.runtime.openURL(data.shichangurl);
// plus.runtime.restart();
// } else {
// 开始下载
// 创建下载任务
var dtask = plus.downloader.createDownload(downAppUrl, {
filename: "_downloads/"
},
function (d, status) {
// 下载完成
if (status == 200) {
plus.runtime.install(d.filename, {
force: true
}, function () {
//进行重新启动;
plus.runtime.restart();
}, (e) => {
uni.showToast({
title: 'install fail:' + JSON
.stringify(e),
icon: 'none'
})
console.log(JSON.stringify(e))
});
} else {
this.tui.toast("download fail,error code: " +
status);
}
});
let view = new plus.nativeObj.View("maskView", {
backgroundColor: "rgba(0,0,0,.6)",
left: ((plus.screen.resolutionWidth / 2) - 45) +
"px",
bottom: "80px",
width: "90px",
height: "30px"
})

view.drawText('start download...', {}, {
size: '12px',
color: '#FFFFFF'
});
view.show()
// console.log(dtask);
dtask.addEventListener("statechanged", (e) => {
if (e && e.downloadedSize > 0) {
let jindu = ((e.downloadedSize / e.totalSize) *
100).toFixed(2)
view.reset();
view.drawText('Progress:' + jindu + '%', {}, {
size: '12px',
color: '#FFFFFF'
});
}
}, false);
dtask.start();
// }
},
fail(e) {
console.log(e);
uni.showToast({
title: 'Request error'
})
}
})
}
})

});
}


函数定义:checkappupdate


定义核心函数checkappupdate,它接受一个可选参数param,用于自定义提示框的文案等信息。函数内部首先通过Object.assign合并默认参数与传入参数,以确保即使未传入特定参数时也能有良好的用户体验。


获取应用信息与环境变量


利用plus.runtime.getProperty获取当前应用的详细信息,包括但不限于应用ID、版本号(version)和版本号代码(versionCode),以及设备的操作系统名称、版本和厂商信息。这些数据对于后续向服务器请求更新信息至关重要。


请求服务器检查更新


构建包含平台信息、操作系统版本、设备厂商和当前应用版本号的请求参数,发送GET请求至配置好的API地址/checkAppUpdate,查询是否有新版本可用。后端返回参数参考下面:


   /**
* 检测APP升级
*/

public function checkAppUpdate()
{
$data['versionCode'] = 101;//更新的版本号
$data['url'] = 'http://xxx/app/xxx.apk';//下载地址
$data['force'] = true;//是否强制更新
return json_encode($data);//返回json格式数据到前端
}

比较版本与用户提示


一旦收到服务器响应,解析数据并比较当前应用的版本号与服务器提供的最新版本号。如果存在新版本,使用uni.showModal弹窗提示用户,展示新版本日志(如果有)及升级选项。此步骤充分考虑了是否强制更新的需求,允许开发者灵活配置确认与取消按钮的文案。


下载与安装新版本


用户同意升级后,代码将执行下载逻辑。通过plus.downloader.createDownload创建下载任务,并监听下载进度,实时更新进度提示。下载完成后,利用plus.runtime.install安装新APK文件,并在安装成功后调用plus.runtime.restart重启应用,确保新版本生效。


用户界面反馈


在下载过程中,通过创建原生覆盖层plus.nativeObj.View展示一个半透明遮罩和下载进度信息,给予用户直观的视觉反馈,增强了交互体验,进度展示稍微有点丑,可以提自己改改哈。


image.png


总结



通过上述步骤,我们实现了一个完整的应用自动检查更新流程,不仅能够有效通知用户新版本的存在,还提供了平滑的升级体验。此功能的实现,不仅提升了用户体验,也为产品迭代和功能优化提供了有力支持。开发者可以根据具体需求调整提示文案、下载逻辑、进度样式等细节,以更好地适配自身应用的特点和用户群体。



作者:掘金归海一刀
来源:juejin.cn/post/7367555191337828361
收起阅读 »

Uniapp小程序地图轨迹绘画

web
轨迹绘画 简介 轨迹绘画常用于展示车辆历史轨迹,运动历史记录等,本次案例采用的是汽车案例同时利用腾讯地图API,来实现地图轨迹绘画功能,具体情况根据实际项目变更。 本例是汽车轨迹绘画功能 1.在页面的onReady生命周期中创建map对象 onReady...
继续阅读 »

轨迹绘画


简介



  • 轨迹绘画常用于展示车辆历史轨迹,运动历史记录等,本次案例采用的是汽车案例同时利用腾讯地图API,来实现地图轨迹绘画功能,具体情况根据实际项目变更。


本例是汽车轨迹绘画功能


t7mcx-hlyjp.gif

1.在页面的onReady生命周期中创建map对象


onReady() {
// 创建map对象
this.map = uni.createMapContext('map');
// 获取屏幕高度(此处获取屏幕高度是因为本示例中使用了colorui的cu-custom自定义头部,需根据系统高度来自适应)
uni.getSystemInfo({
success: res => {
this.windowHeight = res.windowHeight;
}
});
},

2.设置轨迹动画事件


页面代码:


<view class="container">
<map id='map' :latitude="latitude" :longitude="longitude" :markers="covers"
:style="{ width: '100%', height: mapHeight + 'px' }" :scale="13" :polyline="polyline">

</map>


<view class="btnBox">
<button :disabled="isDisabled" @click="start" class="cu-btn bg-blue round shadow lg">开始回放</button>
<button @click="pause" class="cu-btn bg-red round shadow lg">暂停</button>
</view>

</view>


逻辑代码:



  • 1.轨迹动画的开始事件


start() {
if (this.movementInterval) {
clearInterval(this.movementInterval);
}
this.isStart = true;
this.moveMarker();
},


  • 2.轨迹动画的暂停事件


pause() {
this.isStart = false;
this.isDisabled = false;
if (this.movementInterval) {
clearInterval(this.movementInterval);
this.movementInterval = null;
}
},


  • 3.轨迹动画移动事件


moveMarker() {
if (!this.isStart) return;

if (this.playIndex >= this.coordinate.length) {
this.playIndex = 0;
uni.showToast({
title: "播放完成",
duration: 1400,
icon: "none",
});
this.isStart = false;
this.isDisabled = false;
return;
}

let datai = this.coordinate[this.playIndex];
this.map.translateMarker({
markerId: 1,
autoRotate: true,
destination: {
longitude: datai.longitude,
latitude: datai.latitude,
},
duration: 700,
complete: () => {
this.playIndex++;
this.moveMarker();
},
});
},


完整代码如下


<!-- 地图轨迹组件 -->
<template>
<view>
<cu-custom class="navBox" bgColor="bg-gradual-blue" :isBack="true">
<block slot="backText">返回</block>
<block slot="content">地图轨迹</block>
</cu-custom>

<view class="container">
<map id='map' :latitude="latitude" :longitude="longitude" :markers="covers"
:style="{ width: '100%', height: mapHeight + 'px' }" :scale="13" :polyline="polyline">

</map>

<view class="btnBox">
<button :disabled="isDisabled" @click="start" class="cu-btn bg-blue round shadow lg">开始回放</button>
<button @click="pause" class="cu-btn bg-red round shadow lg">暂停</button>
</view>
</view>
</view>
</template>


<script>
export default {
data() {
return {
map: null,
movementInterval: null, // 用于存储定时器的引用
windowHeight: 0,
mapHeight: 0,
timer: null,

isDisabled: false,
isStart: false,
playIndex: 1,

id: 0, // 使用 marker点击事件 需要填写id
title: 'map',
latitude: 34.263734,
longitude: 108.934843,
// 标记点
covers: [{
id: 1,
width: 42,
height: 47,
rotate: 270,
latitude: 34.259428,
longitude: 108.947040,
iconPath: 'http://zgonline.top/car.png',
callout: {
content: "鄂A·88888", // <img src="车牌信息" alt="" width="50%" />
display: "ALWAYS",
fontWeight: "bold",
color: "#5A7BEE", //文本颜色
fontSize: "12px",
bgColor: "#ffffff", //背景色
padding: 5, //文本边缘留白
textAlign: "center",
},
anchor: {
x: 0.5,
y: 0.5,
},
}],

// 线
polyline: [],

// 坐标数据
coordinate: [{
latitude: 34.259428,
longitude: 108.947040,
problem: false,
},
{
latitude: 34.252918,
longitude: 108.946963,
problem: false,
},
{
latitude: 34.252408,
longitude: 108.946240,
problem: false,
},
{
latitude: 34.249286,
longitude: 108.946184,
problem: false,
},
{
latitude: 34.248670,
longitude: 108.946640,
problem: false,
},
{
latitude: 34.248129,
longitude: 108.946826,
problem: false,
},
{
latitude: 34.243537,
longitude: 108.946816,
problem: true,
},
{
latitude: 34.243478,
longitude: 108.939003,
problem: true,
},
{
latitude: 34.241218,
longitude: 108.939027,
problem: true,
},
{
latitude: 34.241192,
longitude: 108.934802,
problem: true,
},
{
latitude: 34.241182,
longitude: 108.932235,
problem: true,
},
{
latitude: 34.247227,
longitude: 108.932311,
problem: true,
},
{
latitude: 34.250833,
longitude: 108.932352,
problem: true,
},
{
latitude: 34.250877,
longitude: 108.931756,
problem: true,
},
{
latitude: 34.250944,
longitude: 108.931576,
problem: true,
},
{
latitude: 34.250834,
longitude: 108.929662,
problem: true,
},
{
latitude: 34.250924,
longitude: 108.926015,
problem: true,
},
{
latitude: 34.250802,
longitude: 108.910121,
problem: true,
},
{
latitude: 34.269718,
longitude: 108.909921,
problem: true,
},
{
latitude: 34.269221,
longitude: 108.922366,
problem: false,
},
{
latitude: 34.274531,
longitude: 108.922388,
problem: false,
},
{
latitude: 34.276201,
longitude: 108.923433,
problem: false,
},
{
latitude: 34.276559,
longitude: 108.924004,
problem: false,
},
{
latitude: 34.276785,
longitude: 108.945855,
problem: false,
}
],
posi: {
id: 1,
width: 32,
height: 32,
latitude: 0,
longitude: 0,
iconPath: "http://cdn.zhoukaiwen.com/car.png",
callout: {
content: "鄂A·888888", // 车牌信息
display: "BYCLICK",
fontWeight: "bold",
color: "#5A7BEE", //文本颜色
fontSize: "12px",
bgColor: "#ffffff", //背景色
padding: 5, //文本边缘留白
textAlign: "center",
},
anchor: {
x: 0.5,
y: 0.5,
},
}
}
},
watch: {},
// 分享小程序
onShareAppMessage(res) {
return {
title: '看看这个小程序多好玩~',
};
},
onReady() {
// 创建map对象
this.map = uni.createMapContext('map');
// 获取屏幕高度
uni.getSystemInfo({
success: res => {
this.windowHeight = res.windowHeight;
}
});
},
mounted() {
this.setNavTop('.navBox')

this.polyline = [{
points: this.coordinate,
color: '#025ADD',
width: 4,
dottedLine: false,
}];
},
methods: {
setNavTop(style) {
let view = uni.createSelectorQuery().select(style);
view
.boundingClientRect((data) => {
console.log("tabInList基本信息 = " + data.height);
this.mapHeight = this.windowHeight - data.height;
console.log(this.mapHeight);
})
.exec();
},
start() {
if (this.movementInterval) {
clearInterval(this.movementInterval);
}
this.isStart = true;
this.moveMarker();
},
moveMarker() {
if (!this.isStart) return;

if (this.playIndex >= this.coordinate.length) {
this.playIndex = 0;
uni.showToast({
title: "播放完成",
duration: 1400,
icon: "none",
});
this.isStart = false;
this.isDisabled = false;
return;
}

let datai = this.coordinate[this.playIndex];
this.map.translateMarker({
markerId: 1,
autoRotate: true,
destination: {
longitude: datai.longitude,
latitude: datai.latitude,
},
duration: 700,
complete: () => {
this.playIndex++;
this.moveMarker();
},
});
},
pause() {
this.isStart = false;
this.isDisabled = false;
if (this.movementInterval) {
clearInterval(this.movementInterval);
this.movementInterval = null;
}
},
}
}
</script>


<style lang="scss" scoped>
.container {
position: relative;
}

.btnBox {
width: 750rpx;
position: absolute;
bottom: 60rpx;
z-index: 99;
display: flex;
justify-content: space-around;
}
</style>


作者:Coriander
来源:juejin.cn/post/7406173972738867227
收起阅读 »

使用 canvas 实现电子签名

web
一、引言 电子签名作为数字化身份认证的核心技术之一,已广泛应用于合同签署、审批流程等场景。之前做公司项目时遇到这个需求,于是研究了下,目前前端主要有两种方式实现电子签名:原生Canvason 和 使用signature_pad 依赖库。 本文将基于Vue3 +...
继续阅读 »

一、引言


电子签名作为数字化身份认证的核心技术之一,已广泛应用于合同签署、审批流程等场景。之前做公司项目时遇到这个需求,于是研究了下,目前前端主要有两种方式实现电子签名:原生Canvason 和 使用signature_pad 依赖库。


本文将基于Vue3 + TypeScript技术栈,深入讲解原生Canvas功能实现方案,并提供完整的可落地代码。


二、原生Canvas实现方案


完整代码:GitHub - seapack-hub/seapack-template: seapack-template框架


实现的逻辑并不复杂,就是使用canvas提供一个画板,让用户通过鼠标或者移动端触屏的方式在画板上作画,最后将画板上的图案生成图片保存下来。


(一) 组件核心结构


需要同时处理 鼠标事件(PC端)触摸事件(移动端),实现兼容的效果。


// PC端 鼠标事件
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', endDrawing);

// 移动端 触摸事件
canvas.addEventListener('touchstart', handleTouchStart);
canvas.addEventListener('touchmove', handleTouchMove);
canvas.addEventListener('touchend', endDrawing);

具体流程:通过状态变量控制绘制阶段:


阶段触发事件行为
开始绘制mousedown记录起始坐标,标记isDrawing=true
绘制中mousemove连续绘制路径(lineTo + stroke)
结束绘制mouseup重置isDrawing=false`

代码实现:


<div class="signature-container">
<canvas
ref="canvasRef"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="endDrawing"
@mouseleave="endDrawing"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="endDrawing"
>
</canvas>
<div class="controls">
<button @click="clearCanvas">清除</button>
<button @click="saveSignature">保存签名</button>
</div>
</div>

(二) 类型和变量



//类型定义
type RGBColor = `#${string}` | `rgb(${number},${number},${number})`
type Point = { x: number; y: number }
type CanvasContext = CanvasRenderingContext2D | null

// 配置
const exportBgColor: RGBColor = '#ffffff' // 设置为需要的背景色

//元素引用
const canvasRef = ref<HTMLCanvasElement | null>(null)
const ctx = ref<CanvasContext>()

//绘制状态
const isDrawing = ref(false)
const lastPosition = ref<Point>({ x: 0, y: 0 })

(三) 绘制逻辑实现


初始化画布


//初始化画布
onMounted(() => {
if (!canvasRef.value) return
//设置画布大小
canvasRef.value.width = 800
canvasRef.value.height = 400

//获取2d上下文
ctx.value = canvasRef.value.getContext('2d')
if (!ctx.value) return

//初始化 画笔样式
ctx.value.lineWidth = 2
ctx.value.lineCap = 'round'
ctx.value.strokeStyle = '#000' //线条颜色
// 初始填充背景
fillBackground(exportBgColor)
})

//填充背景方法
const fillBackground = (color: RGBColor) => {
if (!ctx.value || !canvasRef.value) return
ctx.value.save()
ctx.value.fillStyle = color
ctx.value.fillRect(0, 0, canvasRef.value.width, canvasRef.value.height)
ctx.value.restore()
}

获取坐标


将事件坐标转换为 Canvas 内部坐标,兼容滚动偏移


//获取坐标点,将事件坐标转换为 Canvas 内部坐标,兼容滚动偏移
const getCanvasPosition = (clientX: number, clientY: number): Point => {
if (!canvasRef.value) return { x: 0, y: 0 }

//获取元素在视口(viewport)中位置
const rect = canvasRef.value.getBoundingClientRect()
return {
x: clientX - rect.left,
y: clientY - rect.top,
}
}

// 获取事件坐标
const getEventPosition = (e: MouseEvent | TouchEvent): Point => {
//TouchEvent 是在支持触摸操作的设备(如智能手机、平板电脑)上,用于处理触摸相关交互的事件对象
if ('touches' in e) {
return getCanvasPosition(e.touches[0].clientX, e.touches[0].clientY)
}
return getCanvasPosition(e.clientX, e.clientY)
}

开始绘制


将 isDrawing 变量值设置为true,表示开始绘制,并获取当前鼠标点击或手指触摸的坐标。


//开始绘制
const startDrawing = (e: MouseEvent | TouchEvent) => {
isDrawing.value = true
const { x, y } = getEventPosition(e)
lastPosition.value = { x, y }
}

绘制中


每次移动时创建新路径,连接上一个点与当前点。


//绘制中
const draw = (e: MouseEvent | TouchEvent) => {
if (!isDrawing.value || !ctx.value) return
//获取当前所在位置
const { x, y } = getEventPosition(e)
//开始新路径
ctx.value.beginPath()
//移动画笔到上一个点
ctx.value.moveTo(lastPosition.value.x, lastPosition.value.y)
//绘制线条到当前点
ctx.value.lineTo(x, y)
//描边路径
ctx.value.stroke()
//更新最后的位置
lastPosition.value = { x, y }
}

结束绘制


将 isDrawing 变量设为false,结束绘制


//结束绘制
const endDrawing = () => {
isDrawing.value = false
}

添加清除和保存方法


//清除签名
const clearCanvas = () => {
if (!ctx.value || !canvasRef.value) return
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
}

//保存签名
const saveSignature = () => {
if (!canvasRef.value) return
const dataURL = canvasRef.value.toDataURL('image/png')
const link = document.createElement('a')
link.download = 'signature.png'
link.href = dataURL
link.click()
}

移动端适配


// 触摸事件处理
const handleTouchStart = (e: TouchEvent) => {
e.preventDefault();
startDrawing(e.touches[0]);
};

const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
draw(e.touches[0]);
};



(四) 最终效果


image.png


作者:烈风逍遥
来源:juejin.cn/post/7484987385665011762
收起阅读 »

微信小程序主包过大终极解决方案

web
随着小程序项目的不断迭代升级,避免不了体积越来越大。微信限制主包最多2M,然而我们的项目引入了直播插件直接占了1.1M,导致必须采用一些手段去优化。下面介绍一下优化思路和终极解决方案。 1.分包 我相信几乎所有人都能想到的方案,基本上这个方案就能解决问题。具...
继续阅读 »

随着小程序项目的不断迭代升级,避免不了体积越来越大。微信限制主包最多2M,然而我们的项目引入了直播插件直接占了1.1M,导致必须采用一些手段去优化。下面介绍一下优化思路和终极解决方案。



1.分包


我相信几乎所有人都能想到的方案,基本上这个方案就能解决问题。具体如何实现可以参照官方文档这里不做过多说明。(基础能力 / 分包加载 / 使用分包 (qq.com)),但是有时候你会发现分包之后好像主包变化不是很大,这是为什么呢?



  • 痛点1:通过依赖分析,如果分包中引入了第三方依赖,那么依赖的js仍然会打包在主包中,例如echarts、wxparse、socket.io。这就导致我们即使做了分包处理,但是主包还是很大,因为相关的js都会在主包中的vendor.js

  • 痛点2:插件只能在主包中无法分包,例如直播插件直接占据1M
    image.png

  • 痛点3:tabbar页面无法分包,只能在主包内

  • 痛点4:公共组件/方法无法分包,只能在主包内

  • 痛点5:图片只能在主包内


2.图片优化


图片是最好解决的,除了tabbar用到的图标,其余都放在云上就好了,例如oss和obs。而且放在云上还有个好处就是背景图片无需担心引入不成功。


3.tabbar页面优化


这部分可以采用tabbar页面都在放在一个文件夹下,比如一共有4个tab,那么一个文件夹下就只存放这4个页面。其余tabbar的子页面一律采用分包。


4.独立分包


独立分包是小程序中一种特殊类型的分包,可以独立于主包和其他分包运行。从独立分包中页面进入小程序时,不需要下载主包。当用户进入普通分包或主包内页面时,主包才会被下载。
但是使用的时候需要注意:



  • 独立分包中不能依赖主包和其他分包中的内容,包括 js 文件、template、wxss、自定义组件、插件等(使用 分包异步化 时 js 文件、自定义组件、插件不受此条限制)

  • 主包中的 app.wxss 对独立分包无效,应避免在独立分包页面中使用 app.wxss 中的样式;

  • App 只能在主包内定义,独立分包中不能定义 App,会造成无法预期的行为;

  • 独立分包中暂时不支持使用插件。


5.终极方案we-script



我们自己写的代码就算再多,其实增加的kb并不大。大部分大文件主要源于第三方依赖,那么有没有办法像webpack中的externals一样,当进入这个页面的时候再去异步加载js文件而不被打包呢(说白了就是CDN)



其实解决方案就是we-script,他允许我们使用CDN方式加载js文件。这样就不会影响打包体积了。


使用步骤



  1. npm install --save we-script

  2. "packNpmRelationList": [{"packageJsonPath": "./package.json", "miniprogramNpmDistDir":"./dist/"}]

  3. 点击开发者工具中的菜单栏:工具 --> 构建 npm

  4. "usingComponents": {"we-script": "we-script"}

  5. <we-script src="url1" />


使用中存在的坑


构建后可能会出现依赖报错,解决的方式就是将编译好的文件手动拖入miniprogram_npm文件夹中,主要是三个文件夹:we-script,acorn,eval5


最后成功解决了主包文件过大的问题,只要是第三方依赖,都可以通过这个办法去加载。


感谢阅读,希望来个三连支持下,转载记得标注原文地址~


作者:前端小鱼26
来源:juejin.cn/post/7355057488351674378
收起阅读 »

uni-app 接入微信短剧播放器

web
前言 作为一个 uniapp 初学者,恰巧遇到微信短剧播放器接入的需求,在网上检索许久并没有发现傻瓜式教程。于是总结 uni-app 官网文档及微信开放文档,自行实践后,总结出几个步骤,希望为大家提供些帮助。实践后发现其实确实比较简单,大佬们可能也懒得写文档...
继续阅读 »

前言



作为一个 uniapp 初学者,恰巧遇到微信短剧播放器接入的需求,在网上检索许久并没有发现傻瓜式教程。于是总结 uni-app 官网文档及微信开放文档,自行实践后,总结出几个步骤,希望为大家提供些帮助。实践后发现其实确实比较简单,大佬们可能也懒得写文档,那么就由我这个小白大概总结下。本文档仅涉及剧目提审成功后的播放器接入,其余相关问题请参考微信官方文档。



小程序申请插件



参考文档:developers.weixin.qq.com/miniprogram…



首先,需要在小程序后台,申请 appid 为 wx94a6522b1d640c3b 的微信插件,可以在微信小程序管理后台进行添加,路径是 设置 - 第三方设置 - 插件管理 - 添加插件,搜索 wx94a6522b1d640c3b 后进行添加:
小程序管理后台示例
搜索添加


uni-app 项目添加微信插件



参考文档:uniapp.dcloud.net.cn/tutorial/mp…



添加插件完成后,在 manifest.json 中,点击 源码视图,找到如下位置并添加红框内的代码,此步骤意在将微信小程序插件引入项目。
在这里插入图片描述


/* 添加微短剧播放器插件 */
"plugins" : {
"playlet-plugin" : {
"version" : "latest",
"provider" : "wx94a6522b1d640c3b"
}
}

manifest.json 中完成添加后,需要在 pages.json 中找一个页面(我这边使用的是一个新建的空白页面)挂载组件,挂载方式如下图红框中所示,需注意,这里的组件名称需要与 manifest.json 中定义的一致:
pages.json


{
"path": "newPage/newPage",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false,
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
},
"mp-weixin": {
"usingComponents": {
"playlet-plugin": "plugin://playlet-plugin/playlet-plugin"
}
}
}
}

挂载空页面是个笨办法,目前我这边尝试如果不挂载的话,会有些问题,有大神知道别的方法可以在评论区指点一下~


App.vue 配置



参考文档:developers.weixin.qq.com/miniprogram…



首先,找个地方新建一个 playerManager.js,我这边建在了 common 文件夹下。代码如下(代码参考微信官方文档给出的 demo):


	var plugin = requirePlugin("playlet-plugin");
// 点击按钮触发此函数跳转到播放器页面
function navigateToPlayer(obj) {
// 下面的${dramaId}变量,需要替换成小程序管理后台的媒资管理上传的剧目的dramaId,变量${srcAppid}是提审方appid,变量${serialNo}是某一集,变量${extParam}是扩展字段,可通过
const { extParam, dramaId, srcAppid, serialNo } = obj
wx.navigateTo({
url: `plugin-private://wx94a6522b1d640c3b/pages/playlet/playlet?dramaId=${dramaId}&srcAppid=${srcAppid}&serialNo=${serialNo}&extParam=${extParam || ''}`
})
}
const proto = {
_onPlayerLoad(info) {
const pm = plugin.PlayletManager.getPageManager(info.playerId)
this.pm = pm
// encryptedData是经过开发者后台加密后(不要在前端加密)的数据,具体实现见下面的加密章节
this.getEncryptData({serialNo: info.serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
pm.setCanPlaySerialList({
data: res.encryptedData,
freeList: [{start_serial_no: 1, end_serial_no: 10}], // 1~10集是免费剧集
})
})
pm.onCheckIsCanPlay(this.onCheckIsCanPlay)
// 关于分享的处理
// 开启分享以及withShareTicket
pm.setDramaFlag({
share: true,
withShareTicket: true
})
// 获取分享参数,页面栈只有短剧播放器一个页面的时候可获取到此参数
// 例如从分享卡片进入、从投流广告直接跳转到播放器页面,从二维码直接进入播放器页面等情况
plugin.getShareParams().then(res => {
console.log('getLaunch options query res', res)
// 关于extParam的处理,需要先做decodeURIComponent之后才能得到原值
const extParam = decodeURIComponent(res.extParam)
console.log('getLaunch options extParam', extParam)
// 如果设置了withShareTicket为true,可通过文档的方法获取更多信息
// https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share.html
const enterOptions = wx.getEnterOptionsSync()
console.log('getLaunch options shareTicket', enterOptions.shareTicket)
}).catch(err => {
console.log('getLaunch options query err', err)
})
// extParam除了可以通过在path传参,还可以通过下面的接口设置
pm.setExtParam('hellotest')
// 分享部分end
},
onCheckIsCanPlay(param) {
// TODO: 碰到不可以解锁的剧集,会触发此事件,这里可以进行扣币解锁逻辑,如果用户无足够的币,可调用下面的this.isCanPlay设置
console.log('onCheckIsCanPlay param', param)
var serialNo = param.serialNo
this.getEncryptData({serialNo: serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
this.pm.isCanPlay({
data: res.encryptedData,
serialNo: serialNo,
})
})
},
getEncryptData(obj) {
const { serialNo } = obj
// TODO: 此接口请求后台,返回下面的setCanPlaySerialList接口需要的加密参数
const { srcAppid, dramaId } = this.pm.getInfo()
console.log('getEncryptData start', srcAppid, dramaId, serialNo)
return new Promise((resolve, reject) => {
resolve({
encryptedData: '' // TODO: 此参数需从后台接口获取到
})
})
},
}
function PlayerManager() {
var newProto = Object.assign({}, proto)
for (const k in newProto) {
if (typeof newProto[k] === 'function') {
this[k] = newProto[k].bind(this)
}
}
}

PlayerManager.navigateToPlayer = navigateToPlayer
module.exports = PlayerManager

新建完成后,在 App.vue 中进行组件的配置和引用。
在这里插入图片描述


onLaunch: function() {
// playlet-plugin必须和上面的app.json里面声明的插件名称一致
const playletPlugin = requirePlugin('playlet-plugin')

const _onPlayerLoad = (info) => {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
}
// 注册播放器页面的onLoad事件
playletPlugin.onPageLoad(_onPlayerLoad.bind(this))
},
_onPlayerLoad(info) {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
},

页面使用



参考文档:developers.weixin.qq.com/miniprogram…



以上所有步骤完成后,就可以开心的使用短剧播放器了。 我这边临时写了个图片的 click 事件测试了一下:


clk() {
// 逻辑处理...获取你的各种参数
// 打开组件中封装的播放器页面
PlayerManager.navigateToPlayer({
srcAppid: 'wx1234567890123456', // 剧目提审方 appid
dramaId: '100001', // 小程序管理后台的媒资管理上传的剧目的 dramaId
serialNo: '1', // 剧目中的某一集
extParam: encodeURIComponent('a=b&c=d'), // 扩展字段,需要encode
})
},

写在最后:


总结完了,其实整体下来不是很难,对我这种前端小白来说检索和整合的过程是比较痛苦的,所以希望下一个接入的朋友可以少检索一些文档吧。
另附一个短剧播放器接口的文档: developers.weixin.qq.com/miniprogram…
文档主要介绍了短剧播放器插件提供的几个接口,在js代码里,插件接口实例通过下面的代码获取


// 名字playlet-plugin必须和app.json里面引用的插件名一致
const playletPlugin = requirePlugin('playlet-plugin')





读书越多越发现自己的无知,Keep Fighting!


欢迎友善交流,不喜勿喷~


Hope can help~


作者:漠尘
来源:juejin.cn/post/7373473695057428506
收起阅读 »

后端出身的CTO问:"前端为什么没有数据库?",我直接无语......

web
😅【现场还原】 "前端为什么没有自己的数据库?把数据存前端不就解决了后端性能问题" ——当CTO抛出这个灵魂拷问时,会议室突然安静得能听见CPU风扇的嗡鸣,在座所有人都无语了。这场因后端性能瓶颈引发的技术博弈,最终以"前端分页查询+本地筛选"的妥协方案告终。 ...
继续阅读 »

😅【现场还原】


"前端为什么没有自己的数据库?把数据存前端不就解决了后端性能问题" ——当CTO抛出这个灵魂拷问时,会议室突然安静得能听见CPU风扇的嗡鸣,在座所有人都无语了。这场因后端性能瓶颈引发的技术博弈,最终以"前端分页查询+本地筛选"的妥协方案告终。


面对现在几乎所有公司的技术leader都是后端出身,有的不懂前端甚至不懂技术,作为前端开发者,我们真的只能被动接受吗?


😣【事情背景】



  • 需求:前端展示所有文章的标签列表,用户可以选择标签筛选文章,支持多选,每个文章可能有多个标签,也可能没任何标签。

  • 前端观点:针对这种需求,我自然想到用户选中标签后,将标签id传给后端,后端根据id筛选文章列表返回即可。

  • 后端观点:后端数据分库分表,根据标签检索数据还要排序分页,有性能瓶颈会很慢,很慢就会导致天天告警。

  • 上升决策:由于方案有上述分歧,我们就找来了双方leader决策,双方leader也有分歧,最终叫来了CTO。领导想让我们将数据定时备份到前端,需要筛选的时候前端自己筛选。



    CTO语录


    “前端为什么没有数据库?,把数据存前端,前端筛选,数据库不就没有性能压力了”


    "现在手机性能比服务器还强,让前端存全量数据怎么了?"


    "IndexedDB不是数据库?localStorage不能存JSON?"


    "分页?让前端自己遍历数组啊,这不就是你们说的'前端工程化'吗?"





😓【折中方案】


在方案评审会上,我们据理力争:



  1. 分页请求放大效应:用户等待时间=单次请求延迟×页数

  2. 内存占用风险:1万条数据在移动端直接OOM

  3. 数据一致性难题:轮询期间数据更新的同步问题


但现实往往比代码更复杂——当CTO拍板要求"先实现再优化",使用了奇葩的折中方案:



  • 前端轮询获取前1000条数据做本地筛选,用户分页获取数据超过1000条后,前端再轮询获取1000条,以此类推。

  • 前端每页最多获取50条数据,每次最多并发5个请求(后端要求)


只要技术监控不报错,至于用户体验?慢慢等着吧你......


🖨️【批量并发请求】


既然每页只有50条数据,那我至少得发20个请求来拿到所有数据。显然,逐个请求会让用户等待很长时间,明显不符合前端性能优化的原则。于是我选择了 p-limitPromise.all来实现异步并发线程池。通过并发发送多个请求,可以大大减少数据获取的总时间。


import pLimit from 'p-limit';
const limit = pLimit(5); // 限制最多5个并发请求

// 模拟接口请求
const fetchData = (page, pageSize) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`数据页 ${page}${pageSize}条数据`);
}, 1000);
});
};

// 异步任务池
const runTasks = async () => {
const totalData = 1000; // 总数据量
const pageSize = 50; // 每页容量

const totalPages = Math.ceil(totalData / pageSize); // 计算需要多少页

const tasks = [];

// 根据总页数动态创建请求任务
for (let i = 1; i <= totalPages; i++) {
tasks.push(limit(() => fetchData(i, pageSize))); // 使用pLimit限制并发请求
}

const results = await Promise.all(tasks); // 等待所有请求完成
console.log('已完成所有任务:', results);
};

runTasks();



📑【高效本地筛选数据】


当所有数据都请求回来了,下一步就是进行本地筛选。毕竟后端已经将查询任务分配给了前端,所以我得尽可能让筛选的过程更高效,避免在本地做大量的计算导致性能问题。


1. 使用哈希进行高效查找


如果需要根据某个标签来筛选数据,最直接的做法就是遍历整个数据集,但这显然效率不高。于是我决定使用哈希表(或 Map)来组织数据。这样可以在常数时间内完成筛选操作。


const filterDataByTag = (data, tag) => {
const tagMap = new Map();

data.forEach(item => {
if (!tagMap.has(item.tag)) {
tagMap.set(item.tag, []);
}
tagMap.get(item.tag).push(item);
});

return tagMap.get(tag) || [];
};

const result = filterDataByTag(allData, 'someTag');
console.log(result);


2. 使用 Web Workers 进行数据处理


如果数据量很大,筛选过程可能会比较耗时,导致页面卡顿。为了避免这个问题,可以将数据筛选的过程交给 Web Workers 处理。Web Worker 可以在后台线程运行,避免阻塞主线程,从而让用户体验更加流畅。


const worker = new Worker('worker.js');

worker.postMessage(allData);

worker.onmessage = function(event) {
const filteredData = event.data;
console.log('筛选后的数据:', filteredData);
};

// worker.js
onmessage = function(e) {
const data = e.data;
const filteredData = data.filter(item => item.tag === 'someTag');
postMessage(filteredData);
};


📝【总结】


这场技术博弈给我们带来三点深刻启示:



  1. 数据民主化趋势:随着WebAssembly、WebGPU等技术的发展,前端正在获得堪比后端的计算能力

  2. 妥协的艺术:临时方案必须包含演进路径,我们的分页实现预留了切换GraphQL的接口

  3. 性能新思维:从前端到边缘计算,性能优化正在从"减少请求"转向"智能分发"


站在CTO那句"前端为什么没有数据库"的肩膀上,我们正在构建这样的未来:每个前端应用都内置轻量级数据库内核,通过差异同步策略与后端保持数据一致,利用浏览器计算资源实现真正的端智能。这不是妥协的终点,而是下一代Web应用革命的起点。


后记:三个月后,我们基于SQL.js实现了前端SQL查询引擎,配合WebWorker线程池,使得复杂筛选的耗时从秒级降至毫秒级——但这已经是另一个技术突围的故事了。


作者:VeryCool
来源:juejin.cn/post/7472732247932174388
收起阅读 »

Vue3 实现最近很火的酷炫功能:卡片悬浮发光

web
前言 大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~ 有趣的动画效果 前几天在网上看到了一个很有趣的动画效果,如下,光会跟随鼠标在卡片上进行移动,并且卡片会有视差的效果 那么在 Vue3 中应该如何去实现这个效果呢...
继续阅读 »

前言


大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~


有趣的动画效果


前几天在网上看到了一个很有趣的动画效果,如下,光会跟随鼠标在卡片上进行移动,并且卡片会有视差的效果


那么在 Vue3 中应该如何去实现这个效果呢?



基本实现思路


其实实现思路很简单,无非就是分几步:



  • 首先,卡片是相对定位,光是绝对定位

  • 监听卡片的鼠标移入事件mouseenter,当鼠标进入时显示光

  • 监听卡片的鼠标移动事件mouseover,鼠标移动时修改光的left、top,让光跟随鼠标移动

  • 监听卡片的鼠标移出事件mouseleave,鼠标移出时,隐藏光


我们先在 Index.vue 中准备一个卡片页面,光的CSS效果可以使用filter: blur() 来实现



可以看到现在的效果是这样



实现光源跟随鼠标


在实现之前我们需要注意几点:



  • 1、鼠标移入时需要设置卡片 overflow: hidden,否则光会溢出,而鼠标移出时记得还原

  • 2、获取鼠标坐标时需要用clientX/Y而不是pageX/Y,因为前者会把页面滚动距离也算进去,比较严谨


刚刚说到实现思路时我们说到了mouseenter、mousemove、mouseleave,其实mouseenter、mouseleave 这二者的逻辑比较简单,重点是 mouseover 这个监听函数


而在 mouseover 这个函数中,最重要的逻辑就是:光怎么跟随鼠标移动呢?


或者也可以这么说:怎么计算光相对于卡片盒子的 left 和 top


对此我专门画了一张图,相信大家一看就懂怎么算了




  • left = clientX - x - width/2

  • height = clientY - y - height/2


知道了怎么计算,那么逻辑的实现也很明了了~封装一个use-light-card.ts



接着在页面中去使用



这样就能实现基本的效果啦~



卡片视差效果


卡片的视差效果需要用到样式中 transform 样式,主要是配置四个东西:



  • perspective:定义元素在 3D 变换时的透视效果

  • rotateX:X 轴旋转角度

  • rotateY:Y 轴旋转角度

  • scale3d:X/Y/Z 轴上的缩放比例



现在就有了卡片视差的效果啦~



给所有卡片添加光源


上面只是给一个卡片增加光源,接下来可以给每一个卡片都增加光源啦!!!




让光源变成可配置


上面的代码,总感觉这个 hooks 耦合度太高不太通用,所以我们可以让光源变成可配置化,这样每个卡片就可以展示不同大小、颜色的光源了~像下面一样



既然是配置化,那我们希望是这么去使用 hooks 的,我们并不需要自己在页面中去写光源的dom节点,也不需要自己去写光源的样式,而是通过配置传入 hooks 中



所以 hooks 内部要自己通过操作 DOM 的方式,去添加、删除光源,可以使用createElement、appendChild、removeChild 去做这些事~



完整源码


<!-- Index.vue -->

<template>
<div class="container">
<!-- 方块盒子 -->
<div class="item" ref="cardRef1"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef2"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef3"></div>
</div>
</template>

<script setup lang="ts">
import { useLightCard } from './use-light-card';

const { cardRef: cardRef1 } = useLightCard();
const { cardRef: cardRef2 } = useLightCard({
light: {
color: '#ffffff',
width: 100,
},
});
const { cardRef: cardRef3 } = useLightCard({
light: {
color: 'yellow',
},
});
</script>

<style scoped lang="less">
.container {
background: black;
width: 100%;
height: 100%;
padding: 200px;
display: flex;
justify-content: space-between;

.item {
position: relative;
width: 125px;
height: 125px;
background: #1c1c1f;
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
</style>


// use-light-card.ts

import { onMounted, onUnmounted, ref } from 'vue';

interface IOptions {
light?: {
width?: number; // 宽
height?: number; // 高
color?: string; // 颜色
blur?: number; // filter: blur()
};
}

export const useLightCard = (option: IOptions = {}) => {
// 获取卡片的dom节点
const cardRef = ref<HTMLDivElement | null>(null);
let cardOverflow = '';
// 光的dom节点
const lightRef = ref<HTMLDivElement>(document.createElement('div'));
// 设置光源的样式

const setLightStyle = () => {
const { width = 60, height = 60, color = '#ff4132', blur = 40 } = option.light ?? {};
const lightDom = lightRef.value;
lightDom.style.position = 'absolute';
lightDom.style.width = `${width}px`;
lightDom.style.height = `${height}px`;
lightDom.style.background = color;
lightDom.style.filter = `blur(${blur}px)`;
};

// 设置卡片的 overflow 为 hidden
const setCardOverflowHidden = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardOverflow = cardDom.style.overflow;
cardDom.style.overflow = 'hidden';
}
};
// 还原卡片的 overflow
const restoreCardOverflow = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.style.overflow = cardOverflow;
}
};

// 往卡片添加光源
const addLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.appendChild(lightRef.value);
}
};
// 删除光源
const removeLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.removeChild(lightRef.value);
}
};

// 监听卡片的鼠标移入
const onMouseEnter = () => {
// 添加光源
addLight();
setCardOverflowHidden();
};

// use-light-card.ts

// 监听卡片的鼠标移动
const onMouseMove = (e: MouseEvent) => {
// 获取鼠标的坐标
const { clientX, clientY } = e;
// 让光跟随鼠标
const cardDom = cardRef.value;
const lightDom = lightRef.value;
if (cardDom) {
// 获取卡片相对于窗口的x和y坐标
const { x, y } = cardDom.getBoundingClientRect();
// 获取光的宽高
const { width, height } = lightDom.getBoundingClientRect();
lightDom.style.left = `${clientX - x - width / 2}px`;
lightDom.style.top = `${clientY - y - height / 2}px`;

// 设置动画效果
const maxXRotation = 10; // X 轴旋转角度
const maxYRotation = 10; // Y 轴旋转角度

const rangeX = 200 / 2; // X 轴旋转的范围
const rangeY = 200 / 2; // Y 轴旋转的范围

const rotateX = ((clientX - x - rangeY) / rangeY) * maxXRotation; // 根据鼠标在 Y 轴上的位置计算绕 X 轴的旋转角度
const rotateY = -1 * ((clientY - y - rangeX) / rangeX) * maxYRotation; // 根据鼠标在 X 轴上的位置计算绕 Y 轴的旋转角度

cardDom.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; //设置 3D 透视
}
};
// 监听卡片鼠标移出
const onMouseLeave = () => {
// 鼠标离开移出光源
removeLight();
restoreCardOverflow();
};

onMounted(() => {
// 设置光源样式
setLightStyle();
// 绑定事件
cardRef.value?.addEventListener('mouseenter', onMouseEnter);
cardRef.value?.addEventListener('mousemove', onMouseMove);
cardRef.value?.addEventListener('mouseleave', onMouseLeave);
});

onUnmounted(() => {
// 解绑事件
cardRef.value?.removeEventListener('mouseenter', onMouseEnter);
cardRef.value?.removeEventListener('mousemove', onMouseMove);
cardRef.value?.removeEventListener('mouseleave', onMouseLeave);
});

return {
cardRef,
};
};


结语 & 加学习群 & 摸鱼群


我是林三心



  • 一个待过小型toG型外包公司、大型外包公司、小公司、潜力型创业公司、大公司的作死型前端选手;

  • 一个偏前端的全干工程师;

  • 一个不正经的掘金作者;

  • 一个逗比的B站up主;

  • 一个不帅的小红书博主;

  • 一个喜欢打铁的篮球菜鸟;

  • 一个喜欢历史的乏味少年;

  • 一个喜欢rap的五音不全弱鸡


如果你想一起学习前端,一起摸鱼,一起研究简历优化,一起研究面试进步,一起交流历史音乐篮球rap,可以来俺的摸鱼学习群哈哈,点这个,有7000多名前端小伙伴在等着一起学习哦 --> 摸鱼沸点


作者:Sunshine_Lin
来源:juejin.cn/post/7373867360019742758
收起阅读 »

URL地址末尾加不加”/“有什么区别

web
URL 结尾是否带 / 主要影响的是 服务器如何解析请求 以及 相对路径的解析方式,具体区别如下: 1. 基础概念 URL(统一资源定位符) :用于唯一标识互联网资源,如网页、图片、API等。 目录 vs. 资源: 以 / 结尾的 URL 通常表示目录,...
继续阅读 »

URL 结尾是否带 / 主要影响的是 服务器如何解析请求 以及 相对路径的解析方式,具体区别如下:




1. 基础概念



  • URL(统一资源定位符) :用于唯一标识互联网资源,如网页、图片、API等。

  • 目录 vs. 资源



    • / 结尾的 URL 通常表示目录,例如:


      https://example.com/folder/


    • 不以 / 结尾的 URL 通常指向具体的资源(如文件),例如:


      https://example.com/file







2. / 和不带 / 的具体区别


(1)目录 vs. 资源



  • https://example.com/folder/



    • 服务器通常会将其解析为 目录,并尝试返回该目录下的默认文件(如 index.html)。



  • https://example.com/folder



    • 服务器可能会将其视为 文件,如果 folder 不是文件,而是目录,服务器可能会返回 301 重定向到 folder/




📌 示例





(2)相对路径解析


URL 末尾是否有 / 会影响相对路径的解析


假设 HTML 页面包含以下 <img> 标签:


<img src="image.png">

📌 示例:



原因:



  • / 结尾的 URL,浏览器会认为它是一个目录,相对路径会基于 folder/ 解析。

  • 不带 /,浏览器可能认为 folder文件,相对路径解析可能会出现错误。




(3)SEO 影响


搜索引擎对 https://example.com/folder/https://example.com/folder 可能会视为两个不同的页面,导致 重复内容问题,影响 SEO 排名。因此:





(4)API 请求


对于 RESTful API,带 / 和不带 / 可能导致不同的行为:



一些 API 服务器对 / 非常敏感,因此最好遵循 API 文档的规范。




3. 总结


URL 形式作用影响
https://example.com/folder/目录通常返回 folder/ 下的默认文件,如 index.html,相对路径解析基于 folder/
https://example.com/folder资源(或重定向)可能被解析为文件,或者服务器重定向到 folder/,相对路径解析可能错误
https://api.example.com/data/API 路径可能与 https://api.example.com/data 表现不同,具体由 API 设计决定

如果你在开发网站,建议:



  1. 统一 URL 规则,例如所有目录都加 / 或者所有请求都不加 /,然后用 301 重定向 确保一致性。

  2. 测试 API 的行为,确认带 / 和不带 / 是否影响请求结果。


作者:Chiyamin
来源:juejin.cn/post/7468112128928350242
收起阅读 »

用node帮老婆做excel工资表

web
我是天元,立志做1000个有趣的项目的前端。如果你喜欢的话,请点赞,收藏,转发。评论领取零花钱+100勋章 背景 我老婆从事HR的工作,公司有很多连锁店,她需要将所有的门店的工资汇总计算,然后再拆分给各门店请确认,最后再提供给财务发工资。 随着门店数量渐渐增...
继续阅读 »

我是天元,立志做1000个有趣的项目的前端。如果你喜欢的话,请点赞,收藏,转发。评论领取零花钱+100勋章



背景


我老婆从事HR的工作,公司有很多连锁店,她需要将所有的门店的工资汇总计算,然后再拆分给各门店请确认,最后再提供给财务发工资。


随着门店数量渐渐增多,渐渐的我老婆已经不堪重负,每天加班都做不完,严重影响夫妻感情生活。


最终花费了2天的时间,完成了整个node程序,她只需要传入工资表,相应的各种表格在10s内自动输出。目前已正式交付,得到了每月零花钱提高100元的重大成果


整体需求



  • 表格的导入和识别

  • 表格的计算(计算公式要代入),表格样式正确

  • 最终结果按照门店拆分为工资表


需求示例(删减版)


image.png

需求为,根据传入的基本工资及补发补扣,生成总工资表,门店工资表,财务工资表发放表。


工资表中字段为门店,姓名,基本工资,补发补扣,最终工资(基本工资+补发补扣)。最后一行为总计

门店工资表按照每个门店,单独一个表格,字段同工资表。最后一行为总计


工资表


image.png


基础工资


image.png


补发补扣


image.png


技术选型


这次的主力库为exceljs,官方文档介绍如下



读取,操作并写入电子表格数据和样式到 XLSX 和 JSON 文件。


一个 Excel 电子表格文件逆向工程项目



选择exceljs是因为它支持完整的excel的样式及公式。


安装及目录结构


优先安装exceljs


npm init
yarn add exceljs

创建input,out,src三个文件夹,src放入index.js

image.png


package.json增加start脚本


"scripts": {
"start": "node src/index.js"
},

代码相关


导入


通过new Excel.Workbook();创建一个工作簿,通过workbook.xlsx.readFile来导入文件, 注意这是个promise


const ExcelJS = require("exceljs");
const path = require("path");
const inputPath = path.resolve(__dirname, "../input");
const outputPath = path.resolve(__dirname, "../out");

const loadInput =async () => {
const workbook = new ExcelJS.Workbook();
const inputFile = await workbook.xlsx.readFile(inputPath + "/工资表.xlsx")
};

loadInput()

数据拆分


通过getWorksheetApi,我们可以获取到对应的工作表的内容


  const loadInput =async () => {
...
// 基本工资
const baseSalarySheet = inputFile.getWorksheet("基本工资");
// 补发补扣
const supplementSheet = inputFile.getWorksheet("补发补扣");

}

然后我们需要进一步的来进行拆分,因为第一行为每个工作表的头,这部分在我们实际数据处理中不会使用,所以通过getRows来获取实际的内容。


  const baseSalaryContent = baseSalarySheet.getRows(
2,
baseSalarySheet.rowCount
);
baseSalaryContent.map((row) => {
console.log(row.values);
});

/**
[ <1 empty item>, '2024-02', '海贼王', '路飞', 12000 ]
[ <1 empty item>, '2024-02', '海贼王', '山治', 8000 ]
[ <1 empty item>, '2024-02', '火影忍者', '鸣人', '6000' ]
[ <1 empty item>, '2024-02', '火影忍者', '佐助', 7000 ]
[ <1 empty item>, '2024-02', '火影忍者', '雏田', 5000 ]
[ <1 empty item>, '2024-02', '一拳超人', '琦玉', 4000 ]
[]
[]
**/

可以看到实际的内容已经拿到了,我们要根据这些内容拼装一下最终便于后续的调用。

我们可以通过 row.getCellApi获取到对应某一列的内容,例如门店是在B列,那么我们就可以使用row.getCell('B')来获取。

因为我们需要拆分门店,所以这里的基本工资,我们以门店为单位,把数据进行拆分



const baseSalary = {};

baseSalaryContent.forEach((row) => {
const shopName = row.getCell("B").value;
if (!shopName) return; // 过滤空行

const name = row.getCell("C").value;
const salary = row.getCell("D").value;

if (!baseSalary[shopName]) {
baseSalary[shopName] = [];
}
baseSalary[shopName].push({
name,
salary,
});
});

这样我们得到了一个以门店名称为key的对象,value为该门店的员工信息数组。利用相同方法,获取补发补扣。因为每个人已经确定了门店,所以后续只需要根据姓名来做key,拆分成一个object即可


  // 补发补扣
const supplement = {};
supplementSheet.getRows(2, supplementSheet.rowCount).forEach((row) => {
const name = row.getCell("C").value;
const type = row.getCell("H").value;

let count = row.getCell("D").value;
// 如果为补扣,则金额为负数
if (type === "补扣") {
count = -count;
}
if (!supplement[name]) {
supplement[name] = 0;
}
supplement[name] += count;
});



数据组合


门店工资表


因为每个门店需要独立一张表,所以需要遍历baseSalary




Object.keys(baseSalary).forEach((shopName) => {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("工资表");

// 添加表头
worksheet.addRow([
"序号",
"门店",
"姓名",
"基本工资",
"补发补扣",
"最终工资",
]);
baseSalary[shopName].forEach((employee, index) => {
worksheet.addRow([
index + 1,
shopName,
employee.name,
+employee.salary,
supplement[employee.name] || 0,
+employee.salary + (supplement[employee.name] || 0),
]);
});
});

此时你也可以快进到表格输出来查看输出的结果,以便随时调整


这样我们就把基本工资已经写入工作表了,但是这里存在问题,最终工资使用的是一个数值,而没有公式。所以我们需要改动下


 worksheet.addRow([        index + 1,        shopName,        employee.name,        employee.salary,        supplement[employee.name] || 0,
{
formula: `D${index + 2}+E${index + 2}`,
result: employee.salary + (supplement[employee.name] || 0),
},
]);

这里的formula将对应到公式,而result是显示的值,这个值是必须写入的,如果你写入了错误的值,会在表格中显示该值,但是双击后,公式重新计算,会替换为新的值。所以这里必须计算正确


合计


依照上方的逻辑,继续添加一行作为合计,但是之前计算的时候,需要添加一个临时变量,记录下合计的相关内容。


     const count = [0, 0, 0];
baseSalary[shopName].forEach((employee, index) => {
count[0] += +employee.salary;
count[1] += supplement[employee.name] || 0;
count[2] += +employee.salary + (supplement[employee.name] || 0);
worksheet.addRow([
index + 1,
shopName,
employee.name,
+employee.salary,
supplement[employee.name]
|| 0,
{
formula: `D${index + 2}+E${index + 2}`,
result: +employee.salary + (supplement[employee.name] || 0),
},
]);
});

然后在尾部添加一行


worksheet.addRow([      "合计",      "",      "",      {        formula: `SUM(D2:D${baseSalary[shopName].length + 1})`,
result: count[0],
},
{
formula: `SUM(E2:E${baseSalary[shopName].length + 1})`,
result: count[1],
},
{
formula: `SUM(F2:F${baseSalary[shopName].length + 1})`,
result: count[2],
},
]);

美化


表格的合并,可以使用mergeCells


   worksheet.mergeCells(
`A${baseSalary[shopName].length + 2}:C${baseSalary[shopName].length + 2}`
);

这样就合并了我们的最后一行的前三列,接下来我们要给表格添加线条。

对于批量的添加,可以直接使用addConditionalFormatting,它将在一个符合条件的单元格范围内添加规则



worksheet.addConditionalFormatting({
ref: `A1:F${baseSalary[shopName].length + 2}`,
rules: [
{
type: "expression",
formulae: ["true"],
style: {
border: {
top: { style: "thin" },
left: { style: "thin" },
bottom: { style: "thin" },
right: { style: "thin" },
},
alignment: { vertical: "top", horizontal: "left", wrapText: true },
},
},
],
});

表格输出


现在门店工资表已经拆分完成,我们可以直接保存了,使用xlsx.writeFileApi来保存文件


 Object.keys(baseSalary).forEach((shopName) => {

...

workbook.xlsx.writeFile(outputPath + `/${shopName}工资表.xlsx`);
})

最终效果


image.png


image.png


相关代码地址


github.com/tinlee/1000…


作者:天元reborn
来源:juejin.cn/post/7346421986607087635
收起阅读 »

蓝牙耳机丢了,我花几分钟写了一个小程序,找到了!​

web
你是否曾经经历过蓝牙耳机不知道丢到哪里去的困扰?特别是忙碌的早晨,准备出门时才发现耳机不见了,整个心情都被影响。幸运的是,随着技术的进步,我们可以利用一些简单的小程序和蓝牙技术轻松找到丢失的耳机。今天,我要分享的是我如何通过一个自制的小程序,利用蓝牙发现功能,...
继续阅读 »

你是否曾经经历过蓝牙耳机不知道丢到哪里去的困扰?特别是忙碌的早晨,准备出门时才发现耳机不见了,整个心情都被影响。幸运的是,随着技术的进步,我们可以利用一些简单的小程序和蓝牙技术轻松找到丢失的耳机。今天,我要分享的是我如何通过一个自制的小程序,利用蓝牙发现功能,成功定位自己的耳机。这不仅是一次有趣的技术尝试,更是对日常生活中类似问题的一个智能化解决方案。

1. 蓝牙耳机丢失的困扰

现代生活中,蓝牙耳机几乎是每个人的必备品。然而,耳机的体积小、颜色常常与周围环境融为一体,导致丢失的情况时有发生。传统的寻找方式依赖于我们对耳机放置地点的记忆,但往往不尽人意。这时候,如果耳机还保持在开机状态,我们就可以借助蓝牙技术进行定位。然而,市场上大部分设备并没有自带这类功能,而我们完全可以通过编写小程序实现。

2. 蓝牙发现功能的原理

蓝牙发现功能是通过设备之间的信号传输进行连接和识别的。当一个蓝牙设备处于开机状态时,它会周期性地广播自己的信号,周围的蓝牙设备可以接收到这些信号并进行配对。这个过程的背后其实是信号的强度和距离的关系。当我们在手机或其他设备上扫描时,能够检测到耳机的存在,但并不能直接告诉我们耳机的具体位置。此时,我们可以通过信号强弱来推测耳机的大概位置。

3. 实现步骤:从构想到实践

有了这个想法后,我决定动手实践。首先,我使用微信小程序作为开发平台,利用其内置的蓝牙接口实现设备扫描功能。具体步骤如下:

  • • 环境搭建:选择微信小程序作为平台主要因为其开发简便且自带蓝牙接口支持。
  • • 蓝牙接口调用:调用wx.openBluetoothAdapter初始化蓝牙模块,确保设备的蓝牙功能开启。
  • • 设备扫描:通过wx.startBluetoothDevicesDiscovery函数启动设备扫描,并使用wx.onBluetoothDeviceFound监听扫描结果。
  • • 信号强度分析:通过读取蓝牙信号强度(RSSI),结合多次扫描的数据变化,推测设备的距离,最终帮助定位耳机。

在代码的实现过程中,信号强度的变化尤为重要。根据RSSI值的波动,我们可以判断耳机是在靠近还是远离,并通过走动测试信号的变化,逐渐缩小搜索范围。

下面是我使用 Taro 实现的全部代码:

import React, { useState, useEffect } from "react";
import Taro, { useReady } from "@tarojs/taro";
import { ViewText } from "@tarojs/components";
import { AtButtonAtIconAtProgressAtListAtListItem } from "taro-ui";
import "./index.scss";

const BluetoothEarphoneFinder = () => {
  const [isSearching, setIsSearching] = useState(false);
  const [devices, setDevices] = useState([]);
  const [nearestDevice, setNearestDevice] = useState(null);
  const [isBluetoothAvailable, setIsBluetoothAvailable] = useState(false);
  const [trackedDevice, setTrackedDevice] = useState(null);

  useEffect(() => {
    if (isSearching) {
      startSearch();
    } else {
      stopSearch();
    }
  }, [isSearching]);

  useEffect(() => {
    if (devices.length > 0) {
      const nearest = trackedDevice
        ? devices.find((d) => d.deviceId === trackedDevice.deviceId)
        : devices[0];
      setNearestDevice(nearest || null);
    } else {
      setNearestDevice(null);
    }
  }, [devices, trackedDevice]);

  const startSearch = () => {
    const startDiscovery = () => {
      setIsBluetoothAvailable(true);
      Taro.startBluetoothDevicesDiscovery({
        success: () => {
          Taro.onBluetoothDeviceFound((res) => {
            const newDevices = res.devices.map((device) => ({
              name: device.name || "未知设备",
              deviceId: device.deviceId,
              rssi: device.RSSI,
            }));
            setDevices((prevDevices) => {
              const updatedDevices = [...prevDevices];
              newDevices.forEach((newDevice) => {
                const index = updatedDevices.findIndex(
                  (d) => d.deviceId === newDevice.deviceId
                );
                if (index !== -1) {
                  updatedDevices[index] = newDevice;
                } else {
                  updatedDevices.push(newDevice);
                }
              });
              return updatedDevices.sort((a, b) => b.rssi - a.rssi);
            });
          });
        },
        fail: (error) => {
          console.error("启动蓝牙设备搜索失败:", error);
          Taro.showToast({
            title: "搜索失败,请重试",
            icon: "none",
          });
          setIsSearching(false);
        },
      });
    };

    Taro.openBluetoothAdapter({
      success: startDiscovery,
      fail: (error) => {
        if (error.errMsg.includes("already opened")) {
          startDiscovery();
        } else {
          console.error("初始化蓝牙适配器失败:", error);
          Taro.showToast({
            title: "蓝牙初始化失败,请检查蓝牙是否开启",
            icon: "none",
          });
          setIsSearching(false);
          setIsBluetoothAvailable(false);
        }
      },
    });
  };

  const stopSearch = () => {
    if (isBluetoothAvailable) {
      Taro.stopBluetoothDevicesDiscovery({
        complete: () => {
          Taro.closeBluetoothAdapter({
            complete: () => {
              setIsBluetoothAvailable(false);
            },
          });
        },
      });
    }
  };

  const getSignalStrength = (rssi) => {
    if (rssi >= -50return 100;
    if (rssi <= -100return 0;
    return Math.round(((rssi + 100) / 50) * 100);
  };

  const getDirectionGuide = (rssi) => {
    if (rssi >= -50return "非常接近!你已经找到了!";
    if (rssi >= -70return "很近了,继续朝这个方向移动!";
    if (rssi >= -90return "正确方向,但还需要继续寻找。";
    return "信号较弱,尝试改变方向。";
  };

  const handleDeviceSelect = (device) => {
    setTrackedDevice(device);
    Taro.showToast({
      title: `正在跟踪: ${device.name}`,
      icon: "success",
      duration: 2000,
    });
  };

  return (
    <View className="bluetooth-finder">
      {isSearching && (
        <View className="loading-indicator">
          <AtIcon value="loading-3" size="30" color="#6190E8" />
          <Text className="loading-text">搜索中...Text>
        View>
      )}
      {nearestDevice && (
        <View className="nearest-device">
          <Text className="device-name">{nearestDevice.name}Text>
          <AtProgress
            percent={getSignalStrength(nearestDevice.rssi)}
            status="progress"
            isHidePercent
          />

          <Text className="direction-guide">
            {getDirectionGuide(nearestDevice.rssi)}
          Text>
        View>
      )}
      <View className="device-list">
        <AtList>
          {devices.map((device) => (
            <AtListItem
              key={device.deviceId}
              title={device.name}
              note={`${device.rssidBm`}
              extraText={
                trackedDevice && trackedDevice.deviceId === device.deviceId
                  ? "跟踪中"
                  : ""
              }
              arrow="right"
              onClick={() =>
 handleDeviceSelect(device)}
            />
          ))}
        AtList>
      View>
      <View className="action-button">
        <AtButton
          type="primary"
          circle
          onClick={() =>
 setIsSearching(!isSearching)}
        >
          {isSearching ? "停止搜索" : "开始搜索"}
        AtButton>
      View>
    View>
  );
};

export default BluetoothEarphoneFinder;

嘿嘿,功夫不负苦心人,我最终通过自己的小程序找到了我的蓝牙耳机。

我将我的小程序发布到了微信小程序上,目前已经通过审核,可以直接使用了。搜索老码宝箱 即可体验。 

顺带还加了非常多的小工具,而且里面还有非常多日常可能会用到的工具,有些还非常有意思。

比如

绘制函数图

每日一言

汇率转换(实时)

BMI 计算

简易钢琴

算一卦

这还不是最重要的

最重要的是,这里的工具是会不断增加的,而且,更牛皮的是,你还可以给作者提需求,增加你想要的小工具,作者是非常欢迎一起讨论的。有朝一日,你也希望你的工具也出现在这个小程序上,被千万人使用吧。

4. 实际应用与优化空间

这个小程序的实际效果超出了我的预期。我能够通过它快速找到丢失的耳机,整个过程不到几分钟时间。然而,值得注意的是,由于蓝牙信号会受到环境干扰,例如墙体、金属物等,导致信号强度并不总是精确。在后续的优化中,我计划加入更多的信号处理算法,例如利用三角定位技术,结合多个信号源来提高定位精度。此外,还可以考虑在小程序中加入可视化的信号强度图,帮助用户更直观地了解耳机的大致方位。

一些思考:

蓝牙耳机定位这个小程序的开发,展示了技术在日常生活中的强大应用潜力。虽然这个项目看似简单,但背后的原理和实现过程非常具有教育意义。通过这次尝试,我们可以看到,借助开源技术和简单的编程能力,我们能够解决许多日常生活中的实际问题。

参考资料:

    1. 微信小程序官方文档:developers.weixin.qq.com
    1. 蓝牙信号强度(RSSI)与距离关系的研究:http://www.bluetooth.com
    1. 个人开发者经验分享: 利用蓝牙发现功能定位设备

  • 作者:老码小张
    来源:juejin.cn/post/7423610485180727332
    收起阅读 »

    前端可玩性UP项目:大屏布局和封装

    web
    前言 autofit.js 发布马上要一年了,也收获了一批力挺用户,截至目前它在github上有1k 的 star,npm 上有超过 13k 的下载量。 这篇文章主要讲从设计稿到落地开发大屏应用,大道至简,这篇文章能帮助各位潇洒自如的开发大屏。 分析设计稿 分...
    继续阅读 »

    前言


    autofit.js 发布马上要一年了,也收获了一批力挺用户,截至目前它在github上有1k 的 star,npm 上有超过 13k 的下载量。


    这篇文章主要讲从设计稿到落地开发大屏应用,大道至简,这篇文章能帮助各位潇洒自如的开发大屏。


    分析设计稿


    分析设计稿之前先吐槽一下大屏这种展现形式,这简直就是自欺欺人、面子工程的最直接的诠释,是吊用没有,只为了好看,如果设计的再不好看啊,这就纯纯是屎。在我的理解中,这就像把PPT放到了web端,仅此而已。



    但是王哥告诉我:"你看似没有用的东西,其实都有用,很多想真正做出有用的产品的企业,没钱,就要先把面子工程做好,告诉别人他们要做一件什么事,这样投资人才会看到,后面才有机会发展。"



    布局方案


    image.png
    上图展示了一个传统意义上且比较普遍的大屏形态,分为四个部分,分别是


    头部


    头部经常放标题、功能菜单、时间、天气


    左右面板


    左右面板承载了各种数字和报表,还有视频、轮播图等等


    中间


    中间部分一般放地图,这其中又分假地图(一张图片)、图表地图(如echarts)、地图引擎(如:leaflet、mapbox、高德、百度)。或者有的还会放3D场景,一般有专门的同事去做3D场景,然后导入到web端。


    大屏的设计通常的分辨率是 1920*1080 的,这也是迄今为止应用最广泛的显示器配置,当然也有基于客户屏幕做的异形分辨率,这就五花八门了。


    但是万变不离其宗,分辨率的变化不会影响它的基本结构,根据上面的图,我们可以快速构建结构代码


      <div class='Box'>
       <div class="header"></div>
       <div class="body">
         <div class="leftPanel"></div>
         <div class="mainMap"></div>
         <div class="rightPanel"></div>
       </div>
     </div>

    上面的代码实现了最简单的上下(Box)+左右(body)的布局结构,完全不需要任何定位策略。


    要实现上图的效果,只需最简单的CSS即可完成布局。


    组件方案


    大屏虽然是屎,但是是一种可玩性很强的项目,想的越复杂,做起来就越复杂,想的越简单,做起来就越简单。


    可以疯狂封装炫技,因为大屏里面的可玩组件简直太多了,且涵盖的太全了,想怎么玩都可以,包括但不限于 各类图表库的封装(echarts、highCharts、vChart)、轮播图(swiper)、地图引擎、视频库(包括直播流)等等。


    如果想简单,甚至可以不用封装,可以看到结构甚至简单到不用CSS几行就可以搭建出基本框架,只把header、leaftPanel、rightPanel、map封装一下就可以了。


    这里还有一个误区,就是大家都喜欢把 大型的组件库 拉到大屏里来用,结果做完了发现好像只用了一个 toast 和一个下拉组件,项目打包后却增大了几十倍的体积,其实像这种简单的组件,完全可以手写,或者找小的独立包来用,一方面会减小体积,不至于让项目臃肿,另一方面可以锻炼自己的手写能力,这才是有必要的封装。


    适配


    目前主流的适配方案,依然是 rem 方案,其原理就是根据根元素的 font-size 自动计算大小,但是此方法需要手动计算 rem 值,或者使用第三方插件如postcss等,但是此方案还有一个弊端,就是无法向下兼容,因为浏览器中最小的文字大小是12px。


    vh/vw方案就不再赘述了,原理基本和 rem/em 相似,都涉及到单位的转换。


    autofit.js


    主要讲一下使用 autofit.js 如何快速实现适配。


    不支持的场景


    首先 autofit.js 不支持 elementUI(plus)、ant-design等组件库,具体是不支持包含popper.js的组件,popper.js 在计算弹出层位置时,不会考虑 scale 后的元素的视觉大小,所以会造成弹出元素的位置偏移。


    其次,不支持 百度地图,百度地图对窗口缩放事件没有任何处理,有同学反馈说,即使使用了resize属性,百度地图在和autofit.js共同使用时,也会有事件热区偏移的问题。而且百度地图使用 bd-09 坐标系,和其他图商不通用,引擎的性能方面也差点意思,个人不推荐在开发中使用百度地图。


    然后一些拖拽库,如甘特图插件,可能也不支持,他们在计算鼠标位置时同样没有考虑 scale 后的元素的视觉大小。


    用什么单位


    不支持的单位:vh、vw、rem、em


    让我诧异的是,老有人问我该用什么单位,主要徘徊在 px 和 % 之间,加群的同学多数是因为用了相对单位,导致留白了。不过人各有所长,跟着各位大佬我也学到了很多。


    看下图


    image.png
    假如有两个宽度为1000的元素,他们内部都有一个子元素,第一个用百分比设置为 width:50%;left:1% , 第二个设置为 wdith:500px;left:10px 。此时,只要外部的1000px的容器宽度不变,这两个内部元素在视觉上是一模一样的,且在实际数值上也是一模一样的,他们宽度都为500px,距离左侧10px。


    但是如果外部容器变大了,来看一下效果:


    image.png
    在样式不变的情况下,仅改变外部容器大小,差异就出来了,由上图可知,50%的元素依然占父元素的一半,实际宽度变成了 1000px,距离左侧的实际距离变成了 20px。


    这当然不难理解,百分比单位是根据 最近的、有确定大小的父级元素计算的。


    所以,应该用什么单位其实取决于想做什么,举个例子:在1920*1080基础上开发,中间的地图写成了宽度为 500px ,这在正常情况下,看起来没有任何问题,它大概占屏幕的 26%,当屏幕分辨率达到4096*2160时,它在屏幕上只占 12%,看起来就是缩在一角。而当你设置宽度为26%时,无论显示器如何变化,它始终占屏幕26%。


    autofit.js 所干的事,就是把1000px 变成了 2000px或者把2000px变成了1000px,并给它设置了一个合适的缩放大小。


    图表、图片拉伸


    背景或各种图片按需设置 object-fit: cover;即可


    图表如echarts一般推荐使用百分比,且监听窗口变化事件做resize()


    结语


    再次感慨,大道至简,事情往往没有那么复杂,祝各位前程似锦。


    作者:德莱厄斯
    来源:juejin.cn/post/7344625554530779176
    收起阅读 »

    2025年了,令人唏嘘的Angular,现在怎么样了🚀🚀🚀

    web
    迅速崛起和快速退出 时间回到2014年,此时的 Angular 1.x 习得了多种武林秘籍,左手降龙十八掌、右手六脉神剑,哦不,左手双向数据绑定、右手依赖注入、上能模块化开发、下有模板引擎 和 前端路由, 背后还有Google这个风头无两的带头大哥做技术背书,...
    继续阅读 »

    迅速崛起和快速退出


    时间回到2014年,此时的 Angular 1.x 习得了多种武林秘籍,左手降龙十八掌、右手六脉神剑,哦不,左手双向数据绑定、右手依赖注入、上能模块化开发、下有模板引擎前端路由, 背后还有Google这个风头无两的带头大哥做技术背书,可以说集万千功能和宠爱于一身,妥妥的主角光环。


    而此时的江湖,B端开发正尝到了 SPA 的甜头,积极的从传统的 MVC 开发模式转变为更为方便快捷的单页面应用开发模式,



    文章同步在公众号:萌萌哒草头将军,欢迎关注!



    一拍即合,强大的一站式单页面开发框架Angular自然而然,就成了公认的武林盟主,江湖一哥。


    angular下载量


    但是好景不长,2016年9月14日 Angular 2.x 的发布,彻底断送了武林盟主的宝座,



    Vue:大哥,你可是真给机会呀!



    ts下载量


    2.0 使用ts彻底重写(最早支持ts的框架)、放弃了脏检查更新机制,引入了响应式系统、使用现代浏览器标准、加入装饰器语法,和 1.0 完全不兼容。可以从上图看到,此时大家基本上还不太接受ts!


    新手面对陡然升高的学习曲线叫苦连连,已经入坑的开发者因为巨大的迁移工作而怨声载道。


    此时,默默耕耘了两年的小弟,Vue已经拥有完备的本地化文档和丰富的可选生态,而且作为新手你只要会使用htmlcssjavascript,就可以上手写项目了。


    所以,此时的 Vue 振臂一呼:“王侯将相宁有种乎!”,立马新皇加冕!


    积极改变,三拜义父的数据驱动



    忆往昔峥嵘岁月稠,恰同学少年,风华正茂;书生意气,挥斥方遒。



    一转眼,angular 已经发布第19个大版本了(平均一年两个版本)。


    失去武林盟主的Angular,飘零半生,未逢明主,公若不弃,Angular愿拜为义父,


    脏检查机制响应式系统,再到Signals系统Angular 历经沧桑的数据驱动方式可以说是前端发展的缩影。


    脏检查机制


    脏检查机制 是通过拦截异步操作,http setTimeout 用户交互事件等,触发变更检测系统,从根组件开始检查组件中数据是否有更新,有更新时,对应的 $scope 变量会被标记为 ,然后同步的更新dom的内容,重新开始变更检查,直到稳定后标记为干净,即通过稳定性检查!


    <!DOCTYPE html>
    <html lang="en" ng-app="myApp">
    <head>
    <meta charset="UTF-8">
    <title>AngularJS Counter</title>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
    </head>
    <body ng-controller="CounterController as ctrl">

    <h1>Count: {{ ctrl.count }}</h1>
    <h2>Double Count: {{ ctrl.doubleCount() }}</h2>
    <button ng-click="ctrl.increment()">+1</button>

    <script>
    angular.module('myApp', [])
    .controller('CounterController', function() {
    var vm = this;
    vm.count = 0;

    vm.increment = function() {
    vm.count++;
    console.log('Latest count:', vm.count);
    };

    vm.doubleCount = function() {
    return vm.count * 2;
    };
    });
    </script>
    </body>
    </html>

    但是这种检查机制存在缺陷,例如,当数据量十分庞大时,就会触发非常多次的脏检查机制


    响应式系统


    响应式系统 没有出现之前,脏检查机制 是唯一的选择,但是响应式系统凭借快速轻便的特点,立马在江湖上引起了不小的轰动,Angular也放弃了笨重的脏检查机制采用了响应式系统!


    // app.component.ts
    import { Component } from '@angular/core';

    @Component({
    selector: 'app-root',
    template: `
    <h1>Count: {{ count }}</h1>
    <h2>Double Count: {{ doubleCount() }}</h2>
    <button (click)="increment()">+1</button>
    `
    ,
    })
    export class AppComponent {
    count: number = 0;

    increment() {
    this.count++;
    console.log('Latest count:', this.count);
    }

    doubleCount() {
    return this.count * 2;
    }
    }

    和我们熟知的Vue的响应式不同,Angular的响应式采用双向数据流的设计,这也使得它在面对复杂项目时,性能和维护上不如Vue快捷方便。


    所以,为了更好的驾驭双向数据流的响应式系统,Angular也是自创了很多绝学,例如:局部变更检测。


    该绝学主要招式:组件级变更检测策略、引入zonejsOnPush 策略等。


    1. 组件级变更检测策略

    每个组件都有自己的更新策略,只有组件的属性和文本发生变化时,才会触发变更检测!


    2. 引入zonejs

    引入zonejs拦截http setTimeout 用户交互事件等异步操作


    3. OnPush 策略

    默认情况下,整个组件树在变更时更新。


    默认策略


    但是开发者可以选择 OnPush 策略,使得组件仅在输入属性发生变化、事件触发或手动调用时才进行变更检测。这进一步大大减少了变更检测的频率,适用于数据变化不频繁的场景。


    OnPush策略


    Signals系统


    很快啊,当SolidJS凭借优异的信号系统在江湖上闯出了响亮的名声,这时,大家才意识到,原来还有更优秀的开发方式!signal系统的开发方式,也被公认为新一代的武林绝技!


    于是,Angular 16它来了,它带着signalmemoeffect三件套走来了!


    当使用signal时,更新仅仅发生在当前组件。


    signal系统


    // app.component.ts
    import { Component, signal, effect, memo } from '@angular/core';

    @Component({
    selector: 'app-root',
    template: `
    <h1>Count: {{ count() }}</h1>
    <h2>Double Count: {{ doubleCount() }}</h2>
    <button (click)="increment()">+1</button>
    `
    ,
    styles: []
    })
    export class AppComponent {
    // 使用 signal 来管理状态
    count = signal(0);

    // 使用 memo 来计算 doubleCount
    doubleCount = memo(() => this.count() * 2);

    constructor() {
    // 使用 effect 来监听 count 的变化
    effect(() => {
    console.log('Latest count:', this.count());
    });
    }

    increment() {
    // 更新 signal 的值
    this.count.set(this.count() + 1);
    }
    }

    总结


    Angular 虽然在国内市场一蹶不振,但是在国际市场一直默默耕耘 10 年。它作为一站式解决方案的框架,虽然牺牲了灵活性,但是也为开发者提供了沉浸式开发的选择!


    且它不断创新、积极拥抱新技术的精神令人十分钦佩!


    今天的内容就这些了,如果你觉得还不错,可以关注我。


    如果文章中存在问题,欢迎指正!


    作者:萌萌哒草头将军
    来源:juejin.cn/post/7468526097011097654
    收起阅读 »

    ⚔️不让我在控制台上调试,哼,休想🛠️

    web
    在 JavaScript 中,使用 debugger 关键字可以在代码执行到该位置时触发断点调试。这可以帮助开发人员进行代码调试和排错。然而,有些网站开发者可能会故意使用 debugger 关键字来阻止调试,从而增加代码的安全性。但仍然有一些方法可以绕过这种防...
    继续阅读 »

    在 JavaScript 中,使用 debugger 关键字可以在代码执行到该位置时触发断点调试。这可以帮助开发人员进行代码调试和排错。然而,有些网站开发者可能会故意使用 debugger 关键字来阻止调试,从而增加代码的安全性。但仍然有一些方法可以绕过这种防护措施,进行调试和排错。


    禁用浏览器debugger


    因为 debugger 其实就是对应的一个断点,它相当于用代码显示地声明了一个断点,要解除它,我们只需要禁用这个断点就好了。


    禁用全局断点


    全局禁用开关位于 Sources 面板的右上角,如下图所示:


    image-20240516204937081.png


    点击它,该按钮会被激活,变成蓝色。


    这个时候我们再重新点击一下 Resume script execution(恢复脚本执行)按钮,跳过当前断点,页面就不会再进入到无限 debugger 的状态了。


    image-20240516205310992.png


    注意,禁用所有断点可能会导致你错过一些潜在的问题或错误,因为代码将会连续执行而不会在可能的问题点停止。因此,在禁用所有断点之前,请确保你已经理解了代码的行为,并且明白在出现问题时该如何调试。


    禁用局部断点


    尝试使用另一种方法来跳过这个无限 debugger。在 debugger 语句所在的行的行号上单击鼠标右键,此时会出现一个快捷菜单,操作下图所示:


    动画.gif


    添加条件断点


    在JS代码 debugger 行数位置的最左侧点击右键,添加条件断点(满足条件才会进入断点),将条件设置为false,就是条件永远不成立,永远不会断下来。


    动画.gif


    添加条件断点还可以监视获取一些变量信息,还是挺好用的。


    如果是简单的debugger断点,直接用上边的方式就可以,如果是通过定时器触发的debugger断点,就需要进行Hook处理了。


    以上的方案执行完毕之后有时候会跳转空页面,这时候只需要在空页面上打开原先地址即可。


    先打开控制台


    有时候我们一打开网页,就直接进入空页面,控制台上的js和html文件也随之为空,这时候需要在空白页面,或者F12等键无法打开控制台等,这种可以先打开控制台,然后再在空白页面上打开网站即可。


    可以在这个网站上试一下。


    替换文件


    直接使用浏览器开发者工具替换修改js(Sources面板 --> Overrides),或者通过FD工具替换。


    这种方式的核心思路,是替换 JS 文件中的 debugger 关键字,并保存为本地文件,在请求返回的时候、通过正则匹配等方式、拦截并替换返回的 JS 代码,以达到绕过 debugger 的目的。也可以直接删掉相关的debugger代码。


    具体实现可参考:2024最新版JavaScript逆向爬虫教程-------基础篇之无限debugger的原理与绕过


    快捷方案-使用油猴等插件


    使用这种方法,就不需要再打 script 断点。直接安装插件即可。


    image-20240516203434774.png


    参考文献


    2024最新版JavaScript逆向爬虫教程-------基础篇之无限debugger的原理与绕过


    解决浏览器调试无限debugger


    作者:Aplee
    来源:juejin.cn/post/7369505226921738278
    收起阅读 »

    🔏别想调试我的前端页面代码🔒

    web
    这里我们不介绍禁止右键菜单, 禁止F12快捷键和代码混淆方案。 无限debugger 前端页面防止调试的方法主要是通过不断 debugger 来疯狂输出断点,因为 debugger 在控制台被打开的时候就会执行 由于程序被 debugger 阻止,所以无法进...
    继续阅读 »

    71e52c67f5094e44b92ccaed93db15c5.jpg


    这里我们不介绍禁止右键菜单, 禁止F12快捷键代码混淆方案。


    无限debugger



    • 前端页面防止调试的方法主要是通过不断 debugger 来疯狂输出断点,因为 debugger 在控制台被打开的时候就会执行

    • 由于程序被 debugger 阻止,所以无法进行断点调试,所以网页的请求也是看不到的.


    基础方案


    (() => {
     function ban() {
       setInterval(() => { debugger; }, 50);
    }
     try {
       ban();
    } catch (err) { }
    })();


    • setInterval 中的代码写在一行,可以禁止用户断点,即使添加 logpointfalse 也无用

    • 当然即使有些人想到用左下角的格式化代码,将其变成多行也是没用的


    浏览器宽高


    根据浏览器宽高、与打开F12后的宽高进行比对,有差值,说明打开了调试,则替换html内容;



    • 通过检测窗口的外部高度和宽度与内部高度和宽度的差值,如果差值大于 200,就将页面内容设置为 "检测到非法调试"。

    • 通过使用间隔为 50 毫秒的定时器,在每次间隔内执行一个函数,该函数通过创建一个包含 debugger 语句的函数,并立即调用该函数的方式来试图阻止调试器的正常使用。


    (() => {
     function block() {
       if (window.outerHeight - window.innerHeight > 200 || window.outerWidth - window.innerWidth > 200) {
         document.body.innerHTML = "检测到非法调试";
      }
       setInterval(() => {
        (function () {
           return false;
        }
        ['constructor']('debugger')
        ['call']());
      }, 50);
    }
     try {
       block();
    } catch (err) { }
    })();

    关闭断点,调整空页面


    在不打开发者工具的情况下,debugger是不会执行将页面卡住,而恰恰是利用debugger的这一点,如果你打开开发者工具一定会被debugger卡住,那么上下文时间间隔就会增加,在对时间间隔进行判断,就能巧妙的知道绝对开了开发者工具,随后直接跳转到空白页,一气呵成。(文心一言采用方案)


    setInterval(function () {
     var startTime = performance.now();
     // 设置断点
     debugger;
     var endTime = performance.now();
     // 设置一个阈值,例如100毫秒
     if (endTime - startTime > 100) {
       window.location.href = 'about:blank';
    }
    }, 100);

    第三方插件


    disable-devtool



    disable-devtool可以禁用所有一切可以进入开发者工具的方法,防止通过开发者工具进行的代码搬运。



    该库有以下特性:



    1. 支持可配置是否禁用右键菜单

    2. 禁用 f12 和 ctrl+shift+i 等快捷键

    3. 支持识别从浏览器菜单栏打开开发者工具并关闭当前页面

    4. 开发者可以绕过禁用 (url参数使用tk配合md5加密)

    5. 多种监测模式,支持几乎所有浏览器(IE,360,qq浏览器,FireFox,Chrome,Edge...)

    6. 高度可配置、使用极简、体积小巧

    7. 支持npm引用和script标签引用(属性配置)

    8. 识别真移动端与浏览器开发者工具设置插件伪造的移动端,为移动端节省性能

    9. 支持识别开发者工具关闭事件

    10. 支持可配置是否禁用选择、复制、剪切、粘贴功能

    11. 支持识别 eruda 和 vconsole 调试工具

    12. 支持挂起和恢复探测器工作

    13. 支持配置ignore属性,用以自定义控制是否启用探测器

    14. 支持配置iframe中所有父页面的开发者工具禁用


    🦂使用🦂


    <script disable-devtool-auto src='https://cdn.jsdelivr.net/npm/disable-devtool'>script>

    更多使用方法参见官网:disable-devtool


    disable-devtool



    console-ban禁止 F12 / 审查开启控制台,保护站点资源、减少爬虫和攻击的轻量方案,支持重定向、重写、自定义多种策略。



    使用


    <head>

    <script src="https://cdn.jsdelivr.net/npm/console-ban@5.0.0/dist/console-ban.min.js">script>
    <script>
     
    // default options
     
    ConsoleBan.init()
     
    // custom options
     
    ConsoleBan.init({
       
    redirect: '/404'
      })
    script>
    head>

    在项目中使用:


      yarn add console-ban

    import { init } from 'console-ban'

    init(options)

    重定向


    ConsoleBan.init({
    // 重定向至 /404 相对地址
    redirect: '/404',
    // 重定向至绝对地址
    redirect: 'http://domain.com/path'
    })

    使用重定向策略可以将用户指引到友好的相关信息地址(如网站介绍),亦或是纯静态 404 页面,高防的边缘计算或验证码等页面。


    注:若重定向后的地址可以通过 SPA 路由切换或 pjax 局部加载技术等进行非真正意义上的页面切换,则切换后的控制台监测将不会再次生效,对于 SPA 你可以在路由卫士处重新注册本实例,其他情况请引导至真正的其他页面。


    重写


    var div = document.createElement('div')
    div.innerHTML = '不要偷看啦~'

    ConsoleBan.init({
    // 重写 body 为字符串
    write: '

    不要偷看啦~

    '
    ,
    // 可传入节点对象
    write: div
    })

    重写策略可以完全阻断对网站内容的审查,但较不友好,不推荐使用。


    回调函数


    ConsoleBan.init({
    callback: () => {
      // ...
    }
    })

    回调函数支持自定义打开控制台后的策略。


    参数


    namerequiredtypedefaultdescription
    clearnobooleantrue禁用 console.clear 函数
    debugnobooleantrue是否开启定时 debugger 反爬虫审查
    debugTimenonumber3000定时 debugger 时间间隔(毫秒)
    redirectnostring-开启控制台后重定向地址
    writenostring 或Element-开启控制台后重写 document.body 内容,支持传入节点或字符串
    callbacknoFunction-开启控制台后的回调函数
    bfcachenobooleantrue禁用 bfcache 功能

    注:redirectwritecallback 三种策略只能取其一,优先使用回调函数。


    参考文章


    禁止别人调试自己的前端页面代码


    前端防止恶意调试


    禁止调试,阻止浏览器F12开发者工具


    前端防止调试技术


    结语


    需要注意的是,这些技术可以增加攻击者分析和调试代码的难度,但无法完全阻止恶意调试。因此,对于一些敏感信息或关键逻辑,最好的方式是在后端进行处理,而不是完全依赖前端来保护。


    下篇文章主要介绍如何破解这些禁止调试的方法。


    矛与盾:⚔️不让我在控制台上调试,哼,休想🛠️


    作者:Aplee
    来源:juejin.cn/post/7368313344712179739
    收起阅读 »

    表妹问:前端好玩吗?我说好玩,但表妹接下来的回复看哭了我

    web
    表妹问:前端好玩吗?我说好玩,但表妹接下来的回复看哭了我。 是的,回复如下: 这红海血途上,新兵举着 "大前端" 旌旗冲锋,老兵拖着node_modules残躯撤退。资本织机永不停歇,框架版本更迭如暴君换季,留下满地deprecated警告如秋后落叶。 其一...
    继续阅读 »

    表妹问:前端好玩吗?我说好玩,但表妹接下来的回复看哭了我。


    微信聊天图片 - 20250226100527.png


    是的,回复如下:


    这红海血途上,新兵举着 "大前端" 旌旗冲锋,老兵拖着node_modules残躯撤退。资本织机永不停歇,框架版本更迭如暴君换季,留下满地deprecated警告如秋后落叶。


    其一、夹缝中的苦力


    世人都道前端易,不过调接口、改颜色,仿佛稚童搭积木。却不知那屏幕上寸寸像素之间,皆是血泪。产品拍案,需求朝夕三变,昨日之红蓝按钮,今晨便成黑白圆角。UI稿纸翻飞如雪,设计师手持“用户体验”四字大旗,将五更赶工的代码尽数碾碎。后端端坐高台,接口文档空悬如镜花水月,待到交付时辰,方抛来残缺数据。此时节,前端便成了那补天的女娲,于混沌中捏造虚拟对象,用JSON.parse('{"data": undefined}')这等荒诞戏法,将虚无粉饰成真实。


    看这段代码何等悲凉:


    // 后端曰:此接口返data字段,必不为空
    fetch('api/data').then(res => {
    const { data } = res;
    render(data[0].children[3].value || '默认值'); // 层层掘墓,方见白骨
    });

    此乃前端日常——在数据废墟里刨食,用||?.铸成铁锹,掘出三分体面。


    其二、技术的枷锁


    JavaScript本是脚本小儿,如今却要扛鼎江山。君不见React、Vue、Angular三座大山压顶,每年必有新神像立起。昨日方学得Redux真经,今朝GraphQL又成显学。更有Electron、ReactNative、Flutter诸般法器,教人左手写桌面应用,右手调移动端手势。所谓“大前端”,实乃资本画饼之术,人前跨端写,人后页面仔——既要马儿跑,又言食草易。


    且看这跨平台代码何等荒诞:


    // 一套代码统治三界(iOS/Android/Web)
    <View>
    {Platform.OS === 'web' ?
    <div onClick={handleWebClick} /> :
    <TouchableOpacity onPress={handleNativePress} />
    }
    </View>

    此类缝合怪代码,恰似给长衫打补丁,既失体统,又损性能。待到内存泄漏、渲染卡顿时,众人皆指前端曰:"此子学艺不精!"


    何人怜悯前端 node18 react19 逐人老,后端写着 java8 看着 java22 笑。


    其三、尊严的消亡


    领导提拔,必先问尔可懂SpringBoot、MySQL分库分表?纵使前端用WebGL绘出三维宇宙,用WebAssembly重写操作系统,在会议室里仍是“做界面的”。工资单上数字最是直白——同司后端新人起薪一万五,前端老将苦熬三年方摸得此数。更可笑者,产品经理醉酒时吐真言:"你们不就是改改CSS么?"


    再看这可视化代码何等心酸:


    // 用Canvas画十万级数据点
    ctx.beginPath();
    dataPoints.forEach((point, i) => {
    if (i % 100 === 0) ctx.stroke(); // 分段渲染防卡死
    ctx.lineTo(point.x, point.y);
    });

    此等精密计算,在他人眼中不过"动画效果",与美工修图无异。待浏览器崩溃,众人皆曰:"定是前端代码劣质!"


    技术大会,后端高谈微服务、分布式,高并发,满座掌声如雷,实则系统使用量百十来人也是远矣;前端言及 CSS 栅格、浏览器渲染,众人瞌睡连天。领导抚掌笑曰:“后端者,国之重器;前端者,雕虫小技。” 晋升名单,后端之名列如长蛇,前端者埋没于墙角尘埃。纵使将那界面写出花来,终是 “美工” 二字定终身。


    其四、维护者的悲歌


    JavaScript本无类型,如野马脱缰。若非经验老道之一,常写出这等代码:


    function handleData(data) {
    if (data && typeof data === 'object') { // 万能判断
    return data.map(item => ({
    ...item,
    newProp: item.id * Math.random() // 魔改数据
    }));
    }
    return []; // 默认返回空阵,埋下百处报错
    }

    此类代码如瘟疫蔓延,领导却言“这些功能实习生也能写!”,却不顾三月后连作者亦不敢相认,只得下任前端难上加难。


    而后端有Type大法,编译检查护体,有Swagger契约,有Docker容器,纵使代码如乱麻,只需扩内存、增实例,便可遮掩性能疮疤。


    其五、末路者的自白


    诸君且看这招聘启事:"需精通Vue3+TS+Webpack,熟悉React/Node.js,有Electron/小程序经验,掌握Three.js/WebGL者重点考虑。" 薪资却标着"6-8K"。更有机智者发明"全栈"之名,实欲以一人之躯,承三头六臂之劳。


    再看这面试题何等荒谬:


    // 手写Promise实现A+规范
    class MyPromise {
    // 三千行后,方知自己仍是蝼蚁
    }

    此等屠龙之术,入职后唯调API用。恰似逼庖丁解牛,却令其日日杀鸡。


    或以使用组件库之经验薪资招之,又以写不好组件库之责裁出。


    尾声:铁屋中的叩问


    前端者,数字化时代的纺织工也。资本织机日夜轰鸣,框架如梭穿行不息。程序员眼底血丝如网。所谓"全栈工程师",实为包身工雅称;所谓"技术革新",不过剥削新法。


    若仍有少年热血未冷,欲投身此业,且听我一言:君有凌云志,何不学Rust/C++,做那操作系统、数据库等真·屠龙技?莫要困在这CSS牢笼中,为圆角像素折腰,为虚无需求焚膏。前端之路,已是红海血途,望后来者三思,三思!


    作者:四叶草会开花
    来源:juejin.cn/post/7475351155297402891
    收起阅读 »

    这个中国亲戚关系计算器让你告别“社死”

    web
    大家好,我是 Java陈序员。 由于为了生活奔波,常年在外,导致很多关系稍疏远的亲戚之间来往并不多。 因此节假日回家时,往往会搞不清楚哪位亲戚应该喊什么称呼,很容易“社死”。 今天给大家介绍一个亲戚关系计算器,让你快速的计算出正确的亲戚称谓! 关注微信公众号...
    继续阅读 »

    大家好,我是 Java陈序员


    由于为了生活奔波,常年在外,导致很多关系稍疏远的亲戚之间来往并不多。


    因此节假日回家时,往往会搞不清楚哪位亲戚应该喊什么称呼,很容易“社死”。


    今天给大家介绍一个亲戚关系计算器,让你快速的计算出正确的亲戚称谓!



    关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。



    项目介绍


    relationship —— 中国亲戚关系计算器,只需简单的输入即可算出称谓。



    输入框兼容了不同的叫法,你可以称呼父亲为:“老爸”、“爹地”、“老爷子”等等,方便不同地域的习惯叫法。


    快捷输入按键,只需简单的点击即可完成关系输入,算法还支持逆向查找称呼哦~


    功能特色:



    • 使用别称查询:姥姥的爸爸的老窦 = 外曾外曾祖父

    • 使用合称查询:姐夫的双亲 = 姊妹姻父 / 姊妹姻母

    • 大小数字混合查询:大哥的二姑妈的七舅姥爷 = 舅曾外祖父

    • 不限制祖辈孙辈跨度查询:舅妈的婆婆的外甥的姨妈的侄子 = 舅表舅父

    • 根据年龄推导可能性:哥哥的表姐 = 姑表姐 / 舅表姐

    • 根据语境确认性别:老婆的女儿的外婆 = 岳母

    • 支持古文式表达:吾父之舅父 = 舅爷爷

    • 解析某称谓关系链:七舅姥爷 = 妈妈的妈妈的兄弟

    • 算两个亲戚间的合称关系:奶奶 + 外婆 = 儿女亲家


    项目地址:


    https://github.com/mumuy/relationship

    在线体验:


    https://passer-by.com/relationship/

    移动端体验地址:


    https://passer-by.com/relationship/vue/

    功能体验


    1、关系找称呼



    2、称呼找关系



    3、两者间关系



    4、两者的合称



    安装使用


    1、直接引入安装


    <script src="https://passer-by.com/relationship/dist/relationship.min.js">

    获取全局方法 relationship.


    2、使用 npm 包管理安装


    安装依赖:


    npm install relationship.js

    包引入:


    // CommonJS 引入
    const relationship = require("relationship.js");

    // ES Module 引入
    import relationship from 'relationship.js';

    3、使用方法:唯一的计算方法 relationship.



    • 选项模式 relationship(options)


      构造函数:


      var options = {
      text:'', // 目标对象:目标对象的称谓汉字表达,称谓间用‘的’字分隔
      target:'', // 相对对象:相对对象的称谓汉字表达,称谓间用‘的’字分隔,空表示自己
      sex:-1, // 本人性别:0表示女性,1表示男性
      type:'default', // 转换类型:'default'计算称谓,'chain'计算关系链,'pair'计算关系合称
      reverse:false, // 称呼方式:true对方称呼我,false我称呼对方
      mode:'default', // 模式选择:使用setMode方法定制不同地区模式,在此选择自定义模式
      optimal:false, // 最短关系:计算两者之间的最短关系
      };

      代码示例:


      // 如:我应该叫外婆的哥哥什么?
      relationship({text:'妈妈的妈妈的哥哥'});
      // => ['舅外公']

      // 如:七舅姥爷应该叫我什么?
      relationship({text:'七舅姥爷',reverse:true,sex:1});
      // => ['甥外孙']

      // 如:舅公是什么亲戚
      relationship({text:'舅公',type:'chain'});
      // => ['爸爸的妈妈的兄弟', '妈妈的妈妈的兄弟', '老公的妈妈的兄弟']

      // 如:舅妈如何称呼外婆?
      relationship({text:'外婆',target:'舅妈',sex:1});
      // => ['婆婆']

      // 如:外婆和奶奶之间是什么关系?
      relationship({text:'外婆',target:'奶奶',type:'pair'});
      // => ['儿女亲家']


    • 语句模式 relationship(exptession)



      参数 exptession 句式可以为:xxx是xxx的什么人、xxx叫xxx什么、xxx如何称呼xxx等。



      代码示例:


      // 如:舅妈如何称呼外婆?
      relationship('舅妈如何称呼外婆?');
      // => ['婆婆']

      // 如:外婆和奶奶之间是什么关系?
      relationship('外婆和奶奶之间是什么关系?');
      // => ['儿女亲家']



    4、其他 API


    // 获取当前数据表 
    relationship.data

    // 获取当前数据量
    relationship.dataCount

    // 用户自定义模式
    relationship.setMode(mode_name,mode_data)

    最后


    推荐的开源项目已经收录到 GitHub 项目,欢迎 Star


    https://github.com/chenyl8848/great-open-source-project

    或者访问网站,进行在线浏览:


    https://chencoding.top:8090/#/


    大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!



    作者:Java陈序员
    来源:juejin.cn/post/7344573753538330678
    收起阅读 »

    实现抖音 “视频无限滑动“效果

    web
    前言 在家没事的时候刷抖音玩,抖音首页的视频怎么刷也刷不完,经常不知不觉的一刷就到半夜了😅 不禁感叹道 "垃圾抖音,费我时间,毁我青春😅" 这是我的 模仿抖音 系列文章的第二篇,本文将一步步实现抖音首页 视频无限滑动 的效果,干货满满 第一篇:200行代码...
    继续阅读 »

    前言


    在家没事的时候刷抖音玩,抖音首页的视频怎么刷也刷不完,经常不知不觉的一刷就到半夜了😅

    不禁感叹道 "垃圾抖音,费我时间,毁我青春😅"




    这是我的 模仿抖音 系列文章的第二篇,本文将一步步实现抖音首页 视频无限滑动 的效果,干货满满



    第一篇:200行代码实现类似Swiper.js的轮播组件

    第三篇:Vue 路由使用介绍以及添加转场动画

    第四篇:Vue 有条件路由缓存,就像传统新闻网站一样

    第五篇:Github Actions 部署 Pages、同步到 Gitee、翻译 README 、 打包 docker 镜像



    如果您对滑动原理不太熟悉,推荐先看我的这篇文章:200行代码实现类似Swiper.js的轮播组件


    最终效果


    在线预览:dy.ttentau.top/


    Github地址:github.com/zyronon/dou…


    源码:SlideVerticalInfinite.vue


    实现原理


    无限滑动的原理和虚拟滚动的原理差不多,要保持 SlideList 里面永远只有 NSlideItem,就要在滑动时不断的删除和增加 SlideItem

    滑动时调整 SlideList 的偏移量 translateY 的值,以及列表里那几个 SlideItemtop 值,就可以了


    为什么要调整 SlideList 的偏移量 translateY 的值同时还要调整 SlideItemtop 值呢?

    因为 translateY 只是将整个列表移动,如果我们列表里面的元素是固定的,不会变多和减少,那么没关系,只调整 translateY 值就可以了,上滑了几页就减几页的高度,下滑同理


    但是如果整个列表向前移动了一页,同时前面的 SlideItem 也少了一个,,那么最终效果就是移动了两页...因为 塌陷 了一页

    这显然不是我们想要的,所以我们还需要同时调整 SlideItemtop 值,加上前面少的 SlideItem 的高度,这样才能显示出正常的内容


    步骤


    定义




    virtualTotal:页面中同时存在多少个 SlideItem,默认为 5


    //页面中同时存在多少个SlideItem
    virtualTotal: {
    type: Number,
    default: () => 5
    },

    设置这个值可以让外部组件使用时传入,毕竟每个人的需求不同,有的要求同时存在 10 条,有的要求同时存在 5 条即可。

    不过同时存在的数量越大,使用体验就越好,即使用户快速滑动,我们依然有时间处理。

    如果只同时存在 5 条,用户只需要快速滑动两次就到底了(因为屏幕中显示第 3 条,刚开始除外),我们可能来不及添加新的视频到最后




    render:渲染函数,SlideItem内显示什么由render返回值决定


    render: {
    type: Function,
    default: () => {
    return null
    }
    },

    之所以要设定这个值,是因为抖音首页可不只有视频,还有图集、推荐用户、广告等内容,所以我们不能写死显示视频。

    最好是定义一个方法,外部去实现,我们内部去调用,拿到返回值,添加到 SlideList




    list:数据列表,外部传入


    list: {
    type: Array,
    default: () => {
    return []
    }
    },

    我们从 list 中取出数据,然后调用并传给 render 函数,将其返回值插入到 SlideList中


    初始化



    watch(
    () => props.list,
    (newVal, oldVal) => {
    //新数据长度比老数据长度小,说明是刷新
    if (newVal.length < oldVal.length) {
    //从list中取出数据,然后调用并传给render函数,将其返回值插入到SlideList中
    insertContent()
    } else {
    //没数据就直接插入
    if (oldVal.length === 0) {
    insertContent()
    } else {
    // 走到这里,说明是通过接口加载了下一页的数据,
    // 为了在用户快速滑动时,无需频繁等待请求接口加载数据,给用户更好的使用体验
    // 这里额外加载3条数据。所以此刻,html里面有原本的5个加新增的3个,一共8个dom
    // 用户往下滑动时只删除前面多余的dom,等滑动到临界值(virtualTotal/2+1)时,再去执行新增逻辑
    }
    }
    }
    )

    watch 监听 list 是因为它一开始不一定有值,通过接口请求之后才有值

    同时当我们下滑 加载更多 时,也会触发接口请求新的数据,用 watch 可以在有新数据时,多添加几条到 SlideList 的最后面,这样用户快速滑动也不怕了


    如何滑动


    这里就不再赘述,参考我的这篇文章:200行代码实现类似Swiper.js的轮播组件


    滑动结束


    判断滑动的方向


    当我们向上滑动时,需要删除最前面的 dom ,然后在最后面添加一个 dom

    下滑时反之


    slideTouchEnd(e, state, canNext, (isNext) => {
    if (props.list.length > props.virtualTotal) {
    //手指往上滑(即列表展示下一条视频)
    if (isNext) {
    //删除最前面的 `dom` ,然后在最后面添加一个 `dom`
    } else {
    //删除最后面的 `dom` ,然后在最前面添加一个 `dom`
    }
    }
    })

    手指往上滑(即列表展示下一条视频)



    • 首先判断是否要加载更多,快到列表末尾时就要加载更多数据了

    • 再判断是否符合 腾挪 的条件,即当前位置要大于 half,且小于列表长度减 half

    • 在最后面添加一个 dom

    • 删除最前面的 dom

    • 将所有 dom 设置为最新的 top 值(原因前面有讲,因为删除了最前面的 dom,导致塌陷一页,所以要加上删除 dom 的高度)


    let half = (props.virtualTotal - 1) / 2

    //删除最前面的 `dom` ,然后在最后面添加一个 `dom`
    if (state.localIndex > props.list.length - props.virtualTotal && state.localIndex > half) {
    emit('loadMore')
    }

    //是否符合 `腾挪` 的条件
    if (state.localIndex > half && state.localIndex < props.list.length - half) {
    //在最后面添加一个 `dom`
    let addItemIndex = state.localIndex + half
    let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addItemIndex}']`)
    if (!res) {
    slideListEl.value.appendChild(getInsEl(props.list[addItemIndex], addItemIndex))
    }

    //删除最前面的 `dom`
    let index = slideListEl.value
    .querySelector(`.${itemClassName}:first-child`)
    .getAttribute('data-index')
    appInsMap.get(Number(index)).unmount()

    slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
    _css(item, 'top', (state.localIndex - half) * state.wrapper.height)
    })
    }

    手指往下滑(即列表展示上一条视频)


    逻辑和上滑都差不多,不过是反着来而已



    • 再判断是否符合 腾挪 的条件,和上面反着

    • 在最前面添加一个 dom

    • 删除最后面的 dom

    • 将所有 dom 设置为最新的 top


    //删除最后面的 `dom` ,然后在最前面添加一个 `dom`
    if (state.localIndex >= half && state.localIndex < props.list.length - (half + 1)) {
    let addIndex = state.localIndex - half
    if (addIndex >= 0) {
    let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addIndex}']`)
    if (!res) {
    slideListEl.value.prepend(getInsEl(props.list[addIndex], addIndex))
    }
    }
    let index = slideListEl.value
    .querySelector(`.${itemClassName}:last-child`)
    .getAttribute('data-index')
    appInsMap.get(Number(index)).unmount()

    slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
    _css(item, 'top', (state.localIndex - half) * state.wrapper.height)
    })
    }

    其他问题


    为什么不直接用 v-for直接生成 SlideItem 呢?


    如果内容不是视频就可以。要删除或者新增时,直接操作 list 数据源,这样省事多了


    如果内容是视频,修改 list 时,Vue 会快速的替换 dom,正在播放的视频,突然一下从头开始播放了😅😅😅


    如何获取 Vue 组件的最终 dom


    有两种方式,各有利弊



    • Vuerender 方法

      • 优点:只是渲染一个 VNode 而已,理论上讲内存消耗更少。

      • 缺点:但我在开发中,用了这个方法,任何修改都会刷新页面,有点难蚌😅



    • VuecreateApp 方法再创建一个 Vue 的实例

      • 和上面相反😅




    import { createApp, onMounted, reactive, ref, render as vueRender, watch } from 'vue'

    /**
    * 获取Vue组件渲染之后的dom元素
    * @param item
    * @param index
    * @param play
    */

    function getInsEl(item, index, play = false) {
    // console.log('index', cloneDeep(item), index, play)
    let slideVNode = props.render(item, index, play, props.uniqueId)
    const parent = document.createElement('div')
    //TODO 打包到线上时用这个,这个在开发时任何修改都会刷新页面
    if (import.meta.env.PROD) {
    parent.classList.add('slide-item')
    parent.setAttribute('data-index', index)
    //将Vue组件渲染到一个div上
    vueRender(slideVNode, parent)
    appInsMap.set(index, {
    unmount: () => {
    vueRender(null, parent)
    parent.remove()
    }
    })
    return parent
    } else {
    //创建一个新的Vue实例,并挂载到一个div上
    const app = createApp({
    render() {
    return <SlideItem data-index={index}>{slideVNode}</SlideItem>
    }
    })
    const ins = app.mount(parent)
    appInsMap.set(index, app)
    return ins.$el
    }
    }

    总结


    原理其实并不难。主要是一开始可能会用 v-for 去弄,折腾半天发现不行。v-for 不行,就只能想想怎么把 Vue 组件搞到 html 里面去,又去研究如何获取 Vue 组件的最终 dom,又查了半天资料,Vue 官方文档也不写,还得去翻 api ,麻了


    结束



    以上就是文章的全部内容,感谢看到这里,希望对你有所帮助或启发!创作不易,如果觉得文章写得不错,可以点赞收藏支持一下,也欢迎关注我的公众号 前端张余让,我会更新更多实用的前端知识与技巧,期待与你共同成长~



    作者:前端张余让
    来源:juejin.cn/post/7361614921519054883
    收起阅读 »

    autohue.js:让你的图片和背景融为一体,绝了!

    web
    需求 先来看这样一个场景,拿一个网站举例 这里有一个常见的网站 banner 图容器,大小为为1910*560,看起来背景图完美的充满了宽度,但是图片原始大小时,却是: 它的宽度只有 1440,且 background-size 设置的是 contain ...
    继续阅读 »

    需求


    先来看这样一个场景,拿一个网站举例


    image.png


    这里有一个常见的网站 banner 图容器,大小为为1910*560,看起来背景图完美的充满了宽度,但是图片原始大小时,却是:


    image.png


    它的宽度只有 1440,且 background-size 设置的是 contain ,即等比例缩放,那么可以断定它两边的蓝色是依靠背景色填充的。


    那么问题来了,这是一个 轮播banner,如果希望添加一张不是蓝色的图片呢?难道要给每张图片提前标注好背景颜色吗?这显然是非常死板的做法。


    所以需要从图片中提取到图片的主题色,当然这对于 js 来说,也不是什么难事,市面上已经有众多的开源库供我们使用。


    探索


    首先在网络上找到了以下几个库:



    • color-thief 这是一款基于 JavaScript 和 Canvas 的工具,能够从图像中提取主要颜色或代表性的调色板

    • vibrant.js 该插件是 Android 支持库中 Palette 类的 JavaScript 版本,可以从图像中提取突出的颜色

    • rgbaster.js 这是一段小型脚本,可以获取图片的主色、次色等信息,方便实现一些精彩的 Web 交互效果


    我取最轻量化的 rgbaster.js(此库非常搞笑,用TS编写,npm 包却没有指定 types) 来测试后发现,它给我在一个渐变色图片中,返回了七万多个色值,当然,它准确的提取出了面积最大的色值,但是这个色值不是图片边缘的颜色,导致设置为背景色后,并不能完美的融合。


    另外的插件各位可以参考这几篇文章:



    可以发现,这些插件主要功能就是取色,并没有考虑实际的应用场景,对于一个图片颜色分析工具来说,他们做的很到位,但是在大多数场景中,他们往往是不适用的。


    在文章 2 中,作者对比了三款插件对于图片容器背景色的应用,看起来还是 rgbaster 效果好一点,但是我们刚刚也拿他试了,它并不能适用于颜色复杂度高的、渐变色的图片。


    思考


    既然又又又没有人做这件事,正所谓我不入地狱谁入地狱,我手写一个


    整理一下需求,我发现我希望得到的是:



    1. 图片的主题色(面积占比最大)

    2. 次主题色(面积占比第二大)

    3. 合适的背景色(即图片边缘颜色,渐变时,需要边缘颜色来设置背景色)


    这样一来,就已经可以覆盖大部分需求了,1+2 可以生成相关的 主题 TAG、主题背景,3 可以使留白的图片容器完美融合。


    开搞


    ⚠⚠ 本小节内容非常硬核,如果不想深究原理可以直接跳过,文章末尾有用法和效果图 ⚠⚠


    思路


    首先需要避免上面提到的插件的缺点,即对渐变图片要做好处理,不能取出成千上万的颜色,体验太差且实用性不强,对于渐变色还有一点,即在渐变路径上,每一点的颜色都是不一样的,所以需要将他们以一个阈值分类,挑选出一众相近色,并计算出一个平均色,这样就不会导致主题色太精准进而没有代表性。


    对于背景色,需要按情况分析,如果只是希望做一个协调的页面,那么大可以直接使用主题色做渐变过渡或蒙层,也就是类似于这种效果


    image.png


    但是如果希望背景与图片完美衔接,让人看不出图片边界的感觉,就需要单独对边缘颜色取色了。


    最后一个问题,如果图片分辨率过大,在遍历像素点时会非常消耗性能,所以需要降低采样率,虽然会导致一些精度上的丢失,但是调整为一个合适的值后应该基本可用。


    剩余的细节问题,我会在下面的代码中解释


    使用 JaveScript 编码


    接下来我将详细描述 autohue.js 的实现过程,由于本人对色彩科学不甚了解,如有解释不到位或错误,还请指出。


    首先编写一个入口主函数,我目前考虑到的参数应该有:


    export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions)
    type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }
    interface autoColorPickerOptions {
    /**
    * - 降采样后的最大尺寸(默认 100px)
    * - 降采样后的图片尺寸不会超过该值,可根据需求调整
    * - 降采样后的图片尺寸越小,处理速度越快,但可能会影响颜色提取的准确性
    **/

    maxSize?: number
    /**
    * - Lab 距离阈值(默认 10)
    * - 低于此值的颜色归为同一簇,建议 8~12
    * - 值越大,颜色越容易被合并,提取的颜色越少
    * - 值越小,颜色越容易被区分,提取的颜色越多
    **/

    threshold?: number | thresholdObj
    }


    概念解释 Lab ,全称:CIE L*a*bCIE L*a*b*CIE XYZ色彩模式的改进型。它的“L”(明亮度),“a”(绿色到红色)和“b”(蓝色到黄色)代表许多的值。与XYZ比较,CIE L*a*b*的色彩更适合于人眼感觉的色彩,正所谓感知均匀



    然后需要实现一个正常的 loadImg 方法,使用 canvas 异步加载图片


    function loadImage(imageSource: HTMLImageElement | string): Promise<HTMLImageElement> {
    return new Promise((resolve, reject) => {
    let img: HTMLImageElement
    if (typeof imageSource === 'string') {
    img = new Image()
    img.crossOrigin = 'Anonymous'
    img.src = imageSource
    } else {
    img = imageSource
    }
    if (img.complete) {
    resolve(img)
    } else {
    img.onload = () => resolve(img)
    img.onerror = (err) => reject(err)
    }
    })
    }

    这样我们就获取到了图片对象。


    然后为了图片过大,我们需要进行降采样处理


    // 利用 Canvas 对图片进行降采样,返回 ImageData 对象
    function getImageDataFromImage(img: HTMLImageElement, maxSize: number = 100): ImageData {
    const canvas = document.createElement('canvas')
    let width = img.naturalWidth
    let height = img.naturalHeight
    if (width > maxSize || height > maxSize) {
    const scale = Math.min(maxSize / width, maxSize / height)
    width = Math.floor(width * scale)
    height = Math.floor(height * scale)
    }
    canvas.width = width
    canvas.height = height
    const ctx = canvas.getContext('2d')
    if (!ctx) {
    throw new Error('无法获取 Canvas 上下文')
    }
    ctx.drawImage(img, 0, 0, width, height)
    return ctx.getImageData(0, 0, width, height)
    }



    概念解释,降采样:降采样(Downsampling)是指在图像处理中,通过减少数据的采样率或分辨率来降低数据量的过程。具体来说,就是在保持原始信息大致特征的情况下,减少数据的复杂度和存储需求。这里简单理解为将图片强制压缩为 100*100 以内,也是 canvas 压缩图片的常见做法。



    得到图像信息后,就可以对图片进行像素遍历处理了,正如思考中提到的,我们需要对相近色提取并取平均色,并最终获取到主题色、次主题色。


    那么问题来了,什么才算相近色,对于这个问题,在 常规的 rgb 中直接计算是不行的,因为它涉及到一个感知均匀的问题



    概念解释,感知均匀:XYZ系统和在它的色度图上表示的两种颜色之间的距离与颜色观察者感知的变化不一致,这个问题叫做感知均匀性(perceptual uniformity)问题,也就是颜色之间数字上的差别与视觉感知不一致。由于我们需要在颜色簇中计算出平均色,那么对于人眼来说哪些颜色是相近的?此时,我们需要把 sRGB 转化为 Lab 色彩空间(感知均匀的),再计算其欧氏距离,在某一阈值内的颜色,即可认为是相近色。



    所以我们首先需要将 rgb 转化为 Lab 色彩空间


    // 将 sRGB 转换为 Lab 色彩空间
    function rgbToLab(r: number, g: number, b: number): [number, number, number] {
    let R = r / 255,
    G = g / 255,
    B = b / 255
    R = R > 0.04045 ? Math.pow((R + 0.055) / 1.055, 2.4) : R / 12.92
    G = G > 0.04045 ? Math.pow((G + 0.055) / 1.055, 2.4) : G / 12.92
    B = B > 0.04045 ? Math.pow((B + 0.055) / 1.055, 2.4) : B / 12.92

    let X = R * 0.4124 + G * 0.3576 + B * 0.1805
    let Y = R * 0.2126 + G * 0.7152 + B * 0.0722
    let Z = R * 0.0193 + G * 0.1192 + B * 0.9505

    X = X / 0.95047
    Y = Y / 1.0
    Z = Z / 1.08883

    const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)
    const fx = f(X)
    const fy = f(Y)
    const fz = f(Z)
    const L = 116 * fy - 16
    const a = 500 * (fx - fy)
    const bVal = 200 * (fy - fz)
    return [L, a, bVal]
    }

    这个函数使用了看起来很复杂的算法,不必深究,这是它的大概解释:



    1. 获取到 rgb 参数

    2. 转化为线性 rgb(移除 gamma矫正),常量 0.04045 是sRGB(标准TGB)颜色空间中的一个阈值,用于区分非线性和线性的sRGB值,具体来说,当sRGB颜色分量大于0.04045时,需要通过 gamma 校正(即采用 ((R + 0.055) / 1.055) ^ 2.4)来得到线性RGB;如果小于等于0.04045,则直接进行线性转换(即 R / 12.92

    3. 线性RGB到XYZ空间的转换,转换公式如下:



      • X = R * 0.4124 + G * 0.3576 + B * 0.1805

      • Y = R * 0.2126 + G * 0.7152 + B * 0.0722

      • Z = R * 0.0193 + G * 0.1192 + B * 0.9505



    4. 归一化XYZ值,为了参考白点(D65),标准白点的XYZ值是 (0.95047, 1.0, 1.08883)。所以需要通过除以这些常数来进行归一化

    5. XYZ到Lab的转换,公式函数:const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)

    6. 计算L, a, b 分量


      L:亮度分量(表示颜色的明暗程度)



      • L = 116 * fy - 16


      a:绿色到红色的色差分量



      • a = 500 * (fx - fy)


      b:蓝色到黄色的色差分量



      • b = 200 * (fy - fz)




    接下来实现聚类算法


    /**
    * 对满足条件的像素进行聚类
    * @param imageData 图片像素数据
    * @param condition 判断像素是否属于指定区域的条件函数(参数 x, y)
    * @param threshold Lab 距离阈值,低于此值的颜色归为同一簇,建议 8~12
    */

    function clusterPixelsByCondition(imageData: ImageData, condition: (x: number, y: number) => boolean, threshold: number = 10): Cluster[] {
    const clusters: Cluster[] = []
    const data = imageData.data
    const width = imageData.width
    const height = imageData.height
    for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
    if (!condition(x, y)) continue
    const index = (y * width + x) * 4
    if (data[index + 3] === 0) continue // 忽略透明像素
    const r = data[index]
    const g = data[index + 1]
    const b = data[index + 2]
    const lab = rgbToLab(r, g, b)
    let added = false
    for (const cluster of clusters) {
    const d = labDistance(lab, cluster.averageLab)
    if (d < threshold) {
    cluster.count++
    cluster.sumRgb[0] += r
    cluster.sumRgb[1] += g
    cluster.sumRgb[2] += b
    cluster.sumLab[0] += lab[0]
    cluster.sumLab[1] += lab[1]
    cluster.sumLab[2] += lab[2]
    cluster.averageRgb = [cluster.sumRgb[0] / cluster.count, cluster.sumRgb[1] / cluster.count, cluster.sumRgb[2] / cluster.count]
    cluster.averageLab = [cluster.sumLab[0] / cluster.count, cluster.sumLab[1] / cluster.count, cluster.sumLab[2] / cluster.count]
    added = true
    break
    }
    }
    if (!added) {
    clusters.push({
    count: 1,
    sumRgb: [r, g, b],
    sumLab: [lab[0], lab[1], lab[2]],
    averageRgb: [r, g, b],
    averageLab: [lab[0], lab[1], lab[2]]
    })
    }
    }
    }
    return clusters
    }

    函数内部有一个 labDistance 的调用,labDistance 是计算 Lab 颜色空间中的欧氏距离的


    // 计算 Lab 空间的欧氏距离
    function labDistance(lab1: [number, number, number], lab2: [number, number, number]): number {
    const dL = lab1[0] - lab2[0]
    const da = lab1[1] - lab2[1]
    const db = lab1[2] - lab2[2]
    return Math.sqrt(dL * dL + da * da + db * db)
    }


    概念解释,欧氏距离:Euclidean Distance,是一种在多维空间中测量两个点之间“直线”距离的方法。这种距离的计算基于欧几里得几何中两点之间的距离公式,通过计算两点在各个维度上的差的平方和,然后取平方根得到。欧氏距离是指n维空间中两个点之间的真实距离,或者向量的自然长度(即该点到原点的距离)。



    总的来说,这个函数采用了类似 K-means 的聚类方式,将小于用户传入阈值的颜色归为一簇,并取平均色(使用 Lab 值)。



    概念解释,聚类算法:Clustering Algorithm 是一种无监督学习方法,其目的是将数据集中的元素分成不同的组(簇),使得同一组内的元素相似度较高,而不同组之间的元素相似度较低。这里是将相近色归为一簇。




    概念解释,颜色簇:簇是聚类算法中一个常见的概念,可以大致理解为 "一类"



    得到了颜色簇集合后,就可以按照count大小来判断哪个是主题色了


      // 对全图所有像素进行聚类
    let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
    clusters.sort((a, b) => b.count - a.count)
    const primaryCluster = clusters[0]
    const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
    const primaryColor = rgbToHex(primaryCluster.averageRgb)
    const secondaryColor = rgbToHex(secondaryCluster.averageRgb)

    现在我们已经获取到了主题色、次主题色 🎉🎉🎉


    接下来,我们继续计算边缘颜色


    按照同样的方法,只是把阈值设小一点,我这里直接设置为 1 (threshold.top 等都是1)


      // 分别对上、右、下、左边缘进行聚类
    const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
    topClusters.sort((a, b) => b.count - a.count)
    const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor

    const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
    bottomClusters.sort((a, b) => b.count - a.count)
    const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor

    const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
    leftClusters.sort((a, b) => b.count - a.count)
    const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor

    const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
    rightClusters.sort((a, b) => b.count - a.count)
    const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor

    这样我们就获取到了上下左右四条边的颜色 🎉🎉🎉


    这样大致的工作就完成了,最后我们将需要的属性导出给用户,我们的主函数最终长这样:


    /**
    * 主函数:根据图片自动提取颜色
    * @param imageSource 图片 URL 或 HTMLImageElement
    * @returns 返回包含主要颜色、次要颜色和背景色对象(上、右、下、左)的结果
    */

    export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions): Promise<AutoHueResult> {
    const { maxSize, threshold } = __handleAutoHueOptions(options)
    const img = await loadImage(imageSource)
    // 降采样(最大尺寸 100px,可根据需求调整)
    const imageData = getImageDataFromImage(img, maxSize)

    // 对全图所有像素进行聚类
    let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
    clusters.sort((a, b) => b.count - a.count)
    const primaryCluster = clusters[0]
    const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
    const primaryColor = rgbToHex(primaryCluster.averageRgb)
    const secondaryColor = rgbToHex(secondaryCluster.averageRgb)

    // 定义边缘宽度(单位像素)
    const margin = 10
    const width = imageData.width
    const height = imageData.height

    // 分别对上、右、下、左边缘进行聚类
    const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
    topClusters.sort((a, b) => b.count - a.count)
    const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor

    const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
    bottomClusters.sort((a, b) => b.count - a.count)
    const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor

    const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
    leftClusters.sort((a, b) => b.count - a.count)
    const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor

    const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
    rightClusters.sort((a, b) => b.count - a.count)
    const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor

    return {
    primaryColor,
    secondaryColor,
    backgroundColor: {
    top: topColor,
    right: rightColor,
    bottom: bottomColor,
    left: leftColor
    }
    }
    }


    还记得本小节一开始提到的参数吗,你可以自定义 maxSize(压缩大小,用于降采样)、threshold(阈值,用于设置簇大小)


    为了用户友好,我还编写了 threshold 参数的可选类型:number | thresholdObj


    type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }

    可以单独设置主阈值、上下左右四边阈值,以适应更个性化的情况。


    autohue.js 诞生了


    名字的由来:秉承一贯命名习惯,auto 家族成员又多一个,与颜色有关的单词有好多个,我取了最短最好记的一个 hue(色相),也比较契合插件用途。


    此插件已在 github 开源:GitHub autohue.js


    npm 主页:NPM autohue.js


    在线体验:autohue.js 官方首页


    安装与使用


    pnpm i autohue.js

    import autohue from 'autohue.js'

    autohue(url, {
    threshold: {
    primary: 10,
    left: 1,
    bottom: 12
    },
    maxSize: 50
    })
    .then((result) => {
    // 使用 console.log 打印出色块元素s
    console.log(`%c${result.primaryColor}`, 'color: #fff; background: ' + result.primaryColor, 'main')
    console.log(`%c${result.secondaryColor}`, 'color: #fff; background: ' + result.secondaryColor, 'sub')
    console.log(`%c${result.backgroundColor.left}`, 'color: #fff; background: ' + result.backgroundColor.left, 'bg-left')
    console.log(`%c${result.backgroundColor.right}`, 'color: #fff; background: ' + result.backgroundColor.right, 'bg-right')
    console.log(`%clinear-gradient to right`, 'color: #fff; background: linear-gradient(to right, ' + result.backgroundColor.left + ', ' + result.backgroundColor.right + ')', 'bg')
    bg.value = `linear-gradient(to right, ${result.backgroundColor.left}, ${result.backgroundColor.right})`
    })
    .catch((err) => console.error(err))


    最终效果


    image.png


    复杂边缘效果


    image.png


    纵向渐变效果(这里使用的是 left 和 right 边的值,可能使用 top 和 bottom 效果更佳)


    image.png


    纯色效果(因为单独对边缘采样,所以无论图片内容多复杂,纯色基本看不出边界)


    image.png


    突变边缘效果(此时用css做渐变蒙层应该效果会更好)


    image.png


    横向渐变效果(使用的是 left 和 right 的色值),基本看不出边界


    参考资料



    番外


    Auto 家族的其他成员



    作者:德莱厄斯
    来源:juejin.cn/post/7471919714292105270
    收起阅读 »

    停止在TS中使用.d.ts文件

    web
    看到Matt Pocock 在 X 上的一个帖子提到不使用 .d.ts 文件的说法。 你赞同么?是否也应该把 .d.ts 文件都替换为 .ts 文件呢? 我们一起来看看~ .d.ts 文件的用途 首先,我们要澄清的是,.d.ts 文件并不是毫无用处的。 ...
    继续阅读 »

    看到Matt Pocock 在 X 上的一个帖子提到不使用 .d.ts 文件的说法。



    你赞同么?是否也应该把 .d.ts 文件都替换为 .ts 文件呢?


    我们一起来看看~




    .d.ts 文件的用途


    首先,我们要澄清的是,.d.ts 文件并不是毫无用处的。



    .d.ts 文件的用途主要用于为 JavaScript 代码提供类型描述。



    .d.ts 文件是严格的蓝图,用于表示你的源代码可以使用的类型。最早可以追溯到2012年。其设计受到头文件、接口描述语言(IDL)、JSDoc 等实践的启发,可以被视为 TypeScript 版本的头文件。


    .d.ts 文件只能包含声明,所以让我们通过一个代码示例来看看声明和实现之间的区别。假设我们有一个函数,它将两个数字相加:


    // 声明 (.d.ts)
    export function add(num1: number, num2: number): number;

    // 实现 (.ts)
    export function add(num1: number, num2: number): number {
    return num1 + num2;
    }

    正如你所见,add 函数的实现实际上展示了加法的执行过程,并返回结果,而声明则没有。




    那么 .d.ts 文件在实践中是如何使用的呢?


    假设我们有一个 add 函数,分别在两个文件中存储声明和实现:add.d.tsadd.js


    现在我们创建一个新文件 index.js,它将实际使用 add 函数:


    import { add } from "./x";

    const result = add(1, 4);
    console.log(result); // 输出:5

    请注意,在这个 JS 文件中,add 函数具有类型安全性,因为函数在 add.d.ts 中被标注了类型声明。




    替换方案 .ts 文件


    我们已经了解了 .d.ts 文件的工作原理以及它们的用途。Matt 之所以认为不需要.d.ts 文件,是因为它也可以放在一个 .ts 文件中直接创建带有类型标注的实现。也就是说,拥有一个包含声明和实现的单个 add.ts 文件,等同于分别定义了 add.d.tsadd.js 文件。


    这意味着你无需担心将声明文件与其对应的实现文件分开组织。




    不过,针对类库,将 .d.ts 文件与编译后的 JavaScript 源代码一起使用,比存储 .ts 文件更高效,因为你真正需要的只是类型声明,以便用户在使用你的库时能够获得类型安全性。


    这确实没错,需要强调的是,更推荐自动生成。通过更改 package.jsontsconfig.json 文件中的几个设置,从 .ts 文件自动生成 .d.ts 文件:



    • tsconfig.json:确保添加 declaration: true,以支持 .d.ts 文件的生成。


    {
    "compilerOptions": {
    "declaration": true,
    "target": "ES6",
    "module": "commonjs",
    "outDir": "./dist",
    "strict": true
    },
    "include": ["src/**/*"]
    }


    • package.json:确保将 types 属性设置为生成的 .d.ts 文件,该文件位于编译后的源代码旁边。


    {
    "name": "stop using d.ts",
    "version": "1.0.0",
    "main": "dist/index.js",
    "types": "dist/index.d.ts",
    "scripts": {
    "build": "tsc"
    }
    }



    结论


    .d.ts 文件中可以做到的一切,都可以在 .ts 文件中完成。


    .ts 文件中使用 declare global {} 语法时,花括号内的内容将被视为全局环境声明,这本质上就是 .d.ts 文件的工作方式。


    所以即使不使用.d.ts文件,也可以拥有全局可访问的类型。.ts文件在功能上既可以包含代码实现,又可以包含类型声明,而且还能实现全局的类型声明,从而在开发过程中可以更加方便地管理和使用类型,避免了在.d.ts文件和.ts文件之间进行复杂的协调和组织,提高了开发效率和开发体验。




    另外需要注意的是,在大多数项目中,开发者会在 TypeScript 的配置文件(tsconfig.json)中将 skipLibCheck 选项设置为 true。skipLibCheck 的作用是跳过对库文件(包括 .d.ts 文件)的类型检查。当设置为 true 时,TypeScript 编译器不会对这些库文件进行严格的类型检查,从而加快编译速度。但这也会影响项目中自己编写的 .d.ts 文件。这意味着,即使 .d.ts 文件中定义的类型存在错误,TypeScript 编译器也不会报错,从而失去了类型安全性的保障。


    而我们直接使用 .ts 文件,就不会有这个问题了,同事手动编写 .d.ts 文件,也会更加安全和高效。




    因此,.d.ts 文件确实没有必要编写。在 99% 的情况下,.ts 文件更适合使用,可以改善开发体验,并降低库代码中出现类型错误的可能性。


    怎么样??你同意他的看法么?


    作者:叶知秋水
    来源:juejin.cn/post/7463817822474682418
    收起阅读 »

    前端适配:你一般用哪种方案?

    web
    前言 最近在公司改bug,突然发现上一个前端留下的毛病不少,页面存在各种适配问题,为此甲方爸爸时常提出宝贵意见! 你的页面是不是时常是这样: 侧边栏未收缩时: 收缩后: 这样(缩小挤成一坨): 又或是这样: 那么废话不多说,今天由我不是程序猿kk为大家...
    继续阅读 »

    前言


    最近在公司改bug,突然发现上一个前端留下的毛病不少,页面存在各种适配问题,为此甲方爸爸时常提出宝贵意见!


    你的页面是不是时常是这样:


    侧边栏未收缩时:
    image.png


    收缩后:


    image.png


    这样(缩小挤成一坨):


    image.png


    又或是这样:


    image.png


    那么废话不多说,今天由我不是程序猿kk为大家讲解一些前端必备知识:适配工作。


    流式布局


    学会利用相对单位(例如百分比,vh或是vw),而不是只会用px一类固定单位设计布局,前言中提到的收缩后多出一大块空白,就是由于写死了宽度,例如1000px或是89vw,那么当侧边栏进行收缩,右边内容宽度还是只有89个vw,因此我们可以将其更改为100%,这样不论侧边栏是否收缩,内容都会占满屏幕的全部。


    .map {
    width: 100%;
    height: 90vh;
    position: relative;
    }

    image.png


    image.png


    rem和第三方插件


    什么是rem


    rem与em不同,rem会根据html的根节点字体大小进行变换,例如1rem就是一个字体大小那么大,比如根大小font size为12px,那么1rem即12px,大家可以在网上寻找单位换算工具进行换算(从设计稿的px到rem)或是下载相关插件例如gulp-px3rem,这样在不同分辨率,不同缩放比的电脑下都能够轻松应对了。


    使用


    第三方插件,例如做移动端适配的flexible.js,lib-flexible库,其核心原理就是rem,我们需要做的就是根据不同屏幕计算出不同的fontsize,而页面中元素都是用rem做单位,据此实现了自适应


    源码:


    ;(function(win, lib) {
    var doc = win.document;
    var docEl = doc.documentElement;
    var metaEl = doc.querySelector('meta[name="viewport"]');
    var flexibleEl = doc.querySelector('meta[name="flexible"]');
    var dpr = 0;
    var scale = 0;
    var tid;
    var flexible = lib.flexible || (lib.flexible = {});

    if (metaEl) {
    console.warn('将根据已有的meta标签来设置缩放比例');
    var match = metaEl.getAttribute('content').match(/initial-scale=([d.]+)/);
    if (match) {
    scale = parseFloat(match[1]);
    dpr = parseInt(1 / scale);
    }
    } else if (flexibleEl) {
    var content = flexibleEl.getAttribute('content');
    if (content) {
    var initialDpr = content.match(/initial-dpr=([d.]+)/);
    var maximumDpr = content.match(/maximum-dpr=([d.]+)/);
    if (initialDpr) {
    dpr = parseFloat(initialDpr[1]);
    scale = parseFloat((1 / dpr).toFixed(2));
    }
    if (maximumDpr) {
    dpr = parseFloat(maximumDpr[1]);
    scale = parseFloat((1 / dpr).toFixed(2));
    }
    }
    }

    if (!dpr && !scale) {
    var isAndroid = win.navigator.appVersion.match(/android/gi);
    var isIPhone = win.navigator.appVersion.match(/iphone/gi);
    var devicePixelRatio = win.devicePixelRatio;
    if (isIPhone) {
    // iOS下,对于2和3的屏,用2倍的方案,其余的用1倍方案
    if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {
    dpr = 3;
    } else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
    dpr = 2;
    } else {
    dpr = 1;
    }
    } else {
    // 其他设备下,仍旧使用1倍的方案
    dpr = 1;
    }
    scale = 1 / dpr;
    }

    docEl.setAttribute('data-dpr', dpr);
    if (!metaEl) {
    metaEl = doc.createElement('meta');
    metaEl.setAttribute('name', 'viewport');
    metaEl.setAttribute('content', 'initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
    if (docEl.firstElementChild) {
    docEl.firstElementChild.appendChild(metaEl);
    } else {
    var wrap = doc.createElement('div');
    wrap.appendChild(metaEl);
    doc.write(wrap.innerHTML);
    }
    }

    function refreshRem(){
    var width = docEl.getBoundingClientRect().width;
    if (width / dpr > 540) {
    width = 540 * dpr;
    }
    var rem = width / 10;
    docEl.style.fontSize = rem + 'px';
    flexible.rem = win.rem = rem;
    }

    win.addEventListener('resize', function() {
    clearTimeout(tid);
    tid = setTimeout(refreshRem, 300);
    }, false);
    win.addEventListener('pageshow', function(e) {
    if (e.persisted) {
    clearTimeout(tid);
    tid = setTimeout(refreshRem, 300);
    }
    }, false);

    if (doc.readyState === 'complete') {
    doc.body.style.fontSize = 12 * dpr + 'px';
    } else {
    doc.addEventListener('DOMContentLoaded', function(e) {
    doc.body.style.fontSize = 12 * dpr + 'px';
    }, false);
    }


    refreshRem();

    flexible.dpr = win.dpr = dpr;
    flexible.refreshRem = refreshRem;
    flexible.rem2px = function(d) {
    var val = parseFloat(d) * this.rem;
    if (typeof d === 'string' && d.match(/rem$/)) {
    val += 'px';
    }
    return val;
    }
    flexible.px2rem = function(d) {
    var val = parseFloat(d) / this.rem;
    if (typeof d === 'string' && d.match(/px$/)) {
    val += 'rem';
    }
    return val;
    }

    })(window, window['lib'] || (window['lib'] = {}));

    大家如果对相关原理感兴趣,可以阅读:flexible.js如何实现rem自适应-前端开发博客


    在实际开发中应用场景不同效果不同,因此不能写死px。


    在PC端适配我们可以自动转换rem适配方案(postcss-pxtorem、amfe-flexible),这里以vue3+vite为例子。事实上amfe-flexible是lib-flexible的升级版。


    注意: 行内样式px不会转化为rem


    npm install postcss postcss-pxtorem --save-dev  // 我试过了反正我报错了,版本太高 大家可以指定5.1.1
    npm install postcss-pxtorem@^5.1.1
    npm i amfe-flexible --save

    记得在main.js中引入amfe-flexible


    import "amfe-flexible"

    相关配置


    image.png


    媒体查询


    通过查询不同的宽度来执行不同的css代码,最终以达到界面的配置。


    在 CSS 中使用 @media 查询来检测屏幕宽度。当屏幕宽度小于 1024px 时,增加 margin-top 以向下移动表格。



    .responsive-table {
    transition: margin-top 0.3s; /* 添加过渡效果 */
    }

    @media (max-width: 1024px) {
    .responsive-table {
    margin-top: 200px; /* 向下移动的距离 */
    }
    }

    弹性布局


    创建一个响应式的卡片布局,当屏幕宽度减小时,卡片会自动换行。


    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Flexbox Example</title>
    <style>
    body {
    display: flex;
    justify-content: center;
    align-items: center;
    flex-wrap: wrap;
    margin: 0;
    height: 100vh;
    background-color: #f0f0f0;
    }

    .card-container {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    width: 90%;
    }

    .card {
    background-color: white;
    border: 1px solid #ccc;
    border-radius: 5px;
    padding: 20px;
    margin: 10px;
    flex: 1 1 300px; /* 基于300px,允许增长和收缩 */
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    transition: transform 0.3s;
    }

    .card:hover {
    transform: translateY(-5px);
    }
    </style>
    </head>
    <body>
    <div class="card-container">
    <div class="card">Card 1</div>
    <div class="card">Card 2</div>
    <div class="card">Card 3</div>
    <div class="card">Card 4</div>
    <div class="card">Card 5</div>
    </div>
    </body>
    </html>

    小结


    还是多提一嘴,应该不会有小伙伴把字体大小的单位也用rem吧?


    作者:zykk
    来源:juejin.cn/post/7431999862919446539
    收起阅读 »

    制作一个页面时,需要兼容PC端和手机端,你是要分别做两个页面还是只做一个页面自适应?为什么?说说你的理由

    web
    在制作一个页面时,如何兼容PC端和手机端是一个重要的设计决策。这个决策通常有两个选择:分别制作两个页面,或者只制作一个自适应页面。以下是我选择后者的理由,以及如何实现自适应设计的相关内容。 选择自适应设计的理由 提高开发效率 制作一个自适应页面可以显著提高开...
    继续阅读 »

    在制作一个页面时,如何兼容PC端和手机端是一个重要的设计决策。这个决策通常有两个选择:分别制作两个页面,或者只制作一个自适应页面。以下是我选择后者的理由,以及如何实现自适应设计的相关内容。


    选择自适应设计的理由



    1. 提高开发效率

      制作一个自适应页面可以显著提高开发效率。开发者只需编写一次代码,就可以在不同设备上展示同样的内容,而不必为每个设备维护多个代码库。这意味着在后续的更新和维护中,相比于维护两个独立的页面,自适应页面的工作量会大幅减少。

    2. 一致的用户体验

      用户在不同设备上访问同一网站时,能够获得一致的用户体验是极其重要的。自适应设计可以确保在不同屏幕尺寸和分辨率下,用户看到的内容和布局尽可能一致,避免用户在不同设备上获取不同信息的困惑。

    3. SEO优化

      使用单一的自适应页面有助于SEO优化。搜索引擎更倾向于索引和排名内容一致的网站,而不是将相同内容分散在多个页面上。使用自适应设计可以集中流量,提高网站在搜索引擎中的权重。

    4. 成本效益

      维护两个独立的页面不仅需要更多的时间和人力成本,还可能导致代码重复,增加出错的可能性。自适应设计能够通过一个代码库降低开发和维护成本。

    5. 响应式设计的灵活性

      现代的 CSS 和 JavaScript 技术(例如 Flexbox 和 CSS Grid)使得创建自适应布局变得更加简单和灵活。媒体查询(Media Queries)可以帮助开发者根据设备的特性(如宽度、高度、分辨率)调整布局和样式,从而提供最佳的用户体验。


    如何实现自适应设计



    1. 使用媒体查询

      媒体查询是实现自适应设计的核心。它允许你根据设备的屏幕尺寸和特性应用不同的样式。例如:


      /* 默认样式 */
      .container {
      width: 100%;
      padding: 20px;
      }

      /* 针对手机的样式 */
      @media (max-width: 600px) {
      .container {
      padding: 10px;
      }
      }

      /* 针对平板的样式 */
      @media (min-width: 601px) and (max-width: 900px) {
      .container {
      padding: 15px;
      }
      }


    2. 使用流式布局

      使用流式布局可以使元素在不同的屏幕上根据可用空间自动调整大小。例如,使用百分比而不是固定像素值来设置宽度:


      .box {
      width: 50%; /* 宽度为父容器的一半 */
      height: auto; /* 高度自动适应内容 */
      }


    3. 灵活的图片和媒体

      为了确保图片和视频在不同设备上显示良好,使用 max-width: 100% 来确保媒体不会超出其容器的宽度:


      img {
      max-width: 100%;
      height: auto; /* 保持图片的纵横比 */
      }


    4. 测试和优化

      在开发完成后,确保在不同设备和浏览器上进行测试。可以使用 Chrome 的开发者工具模拟不同的设备,以检查自适应设计的效果。同时,收集用户反馈并进行必要的优化。


    总结


    在制作兼容PC端和手机端的页面时,选择制作一个自适应页面是更为高效和经济的方案。它不仅能提高开发效率和维护成本,还能提供一致的用户体验,增强SEO效果。此外,现代的CSS技术使得实现自适应设计变得更加简单。因此,采用自适应设计是现代Web开发的最佳实践之一。


    作者:Riesenzahn
    来源:juejin.cn/post/7476010111887949861
    收起阅读 »

    别让这6个UI设计雷区毁了你的APP!

    web
    一款成功的APP不仅仅取决于其功能性,更取决于用户体验,这其中,UI设计又至关重要。优秀的UI设计能够为用户带来直观、愉悦的交互体验,甚至让用户“一见钟情”,从而大大提高产品吸引力。 然而,有很多设计师在追求创新与美观的同时,往往会不经意间“踩雷”,忽略了一些...
    继续阅读 »

    一款成功的APP不仅仅取决于其功能性,更取决于用户体验,这其中,UI设计又至关重要。优秀的UI设计能够为用户带来直观、愉悦的交互体验,甚至让用户“一见钟情”,从而大大提高产品吸引力。


    然而,有很多设计师在追求创新与美观的同时,往往会不经意间“踩雷”,忽略了一些普通用户更在意的问题和痛点。本文,笔者将谈谈UI设计中的常见误区,同时展示了优秀的UI设计例子,还分享一些行业内推荐的设计工具,希望能助你在设计的道路上更加得心应手~


    UI设计常见误区


    1、过度设计


    设计大师 Dieter Rams 说过一句话:“好的设计是尽可能简单的设计。”


    不过,说起来容易做起来难。产品经理和设计师总是不自觉地让产品过于复杂,今天想在产品中再添加一个功能,明天想在页面上再增加一个元素。


    尤其是新手设计师,总希望通过增加视觉元素、动画效果和复杂的交互来提升用户体验。然而,这种做法往往适得其反,一个简洁、清晰且直观的界面一定比一个装饰过多、功能复杂的界面更能吸引用户。设计师应该专注于APP的核心功能,避免不必要的元素,确保设计美观又实用。



    简约风接单APP界面


    http://www.mockplus.cn/example/rp/…


    2、忽视用户反馈


    有时候,设计师可能过于相信自己的设计,忽略了用户的意见和反馈。这种情况下,即使设计再怎么创新或美观,如果不符合用户的实际需求和使用习惯,也难以获得用户的认可。


    毕竟,UI设计不是艺术创作,用户反馈是设计迭代过程中的宝贵资源,它可以帮助设计师了解用户的真实体验,了解设计的不足,从而进行针对性的优化改进。



    FARFETCH APP界面


    http://www.mockplus.cn/example/rp/…


    3、色彩搭配不合适


    色彩搭配不合适是UI设计中一个常见的误区。有的设计师会自动倾向于选择那些自己喜欢的颜色,而不是基于用户的偏好和产品特征、背景等来选择颜色。有时,颜色的过度使用或不恰当的搭配会分散用户的注意力,甚至造成视觉疲劳。


    另外,为了让用户能看清UI设计的各个方面,需要有足够的对比度。这就是为什么黑色文字配上白色背景效果那么好 —— 文字非常清晰,易于理解。



    插画风APP界面


    http://www.mockplus.cn/example/rp/…


    4、忽略可访问性


    对一个APP来说,所有用户,无论是有视觉障碍的人还是老年用户,都应该能够轻松地使用APP。设计如果不考虑这些用户群体的需要,会无意中排除一大部分潜在用户。


    为了提高APP的可访问性,设计师应该考虑到字体大小、颜色对比度、语音读出功能等,确保所有用户都能舒适地使用。



    社交类APP界面


    http://www.mockplus.cn/example/rp/…


    5、布局空滤不全面


    有的产品界面过多,布局考虑不够全面几乎是常常发生的事。布局不合适有很多种情况,比如元素过于密集、排版杂乱、组件没有对齐、文本大小间距不合适等等。这种情况会导致信息展示不清晰或用户操作不便,非常影响用户的使用体验和效率。


    一个美观舒适的布局应该合理利用空间,确保信息的层次清晰,充分考虑元素的大小和间距,让界面既清晰又易于操作,用户可以轻松地找到他们关心的内容。


    想要避免这一误区的,在设计初期就需要考虑用户的需求和行为模式,不断迭代优化布局设计,找到最佳的解决方案。



    加密货币钱包APP界面


    http://www.mockplus.cn/example/rp/…


    了解了上述UI设计的常见误区后,接下来是选择合适的设计工具来提升设计效率和质量。目前,市面上有很多优秀的UI设计工具,但以下几款因其强大的功能和良好的用户体验受到设计师的广泛推荐,一起来看看!


    UI工具推荐


    1、摹客 DT


    摹客DT(http://www.mockplus.cn/dt)是一款国内很多UI设计师都爱用的工具,它提供了一整套完整的专业矢量编辑功能,丰富的图层样式选项,可以轻松创建高质量的App设计,并且还能在线实时协同,搞定团队资产管理难题不是问题。



    主要功能点和亮点:


    1)所有功能完全免费(参与它们的小活动还能永久免费使用),包含所有高级功能、导出能力等;


    2)颜色、文本样式、图层样式都可以保存为资源,复用到其他设计元素;


    3)支持导出SVG、JPG、PNG、webP、PDF格式文件,适配不同使用场景;


    4)具有完善的团队协作和项目管理能力,改变传统设计流程,降低成本,提升效率。


    **价格:**完全免费


    **学习难度:**简单,新手上手无难度


    **使用环境:**Web/客户端/Android/iOS


    **推荐理由:**摹客DT,是一款更适合中国设计师的UI/UX设计工具。与一些老牌设计工具(Photoshop、XD等),它的学习门槛更低,上手更简单,社区和实时客户支持更迅速友好;作为Web端工具,摹客DT支持多人实时编辑,大大提高了团队设计效率。


    推荐评级:⭐⭐⭐⭐⭐


    2、Figma


    Figma(http://www.figma.com/)是现在最流行的UI设…



    主要功能点及亮点:


    1)丰富的功能和工具:提供了全面的工具和功能来设计界面,包括矢量图形工具、网格和布局指导等。


    2)设计组件和样式库:支持创建可复用的设计组件和样式库,节省时间和工作量。


    3)插件生态系统:有丰富的插件生态系统,可以通过插件扩展功能,增加额外的设计工具和集成,以满足特定需求。


    **价格:**提供免费版和付费版(12美元/月起)


    **学习难度:**对新手相对友好,操作简单。


    **使用环境:**Figma是基于Web的平台,通过浏览器即可使用。


    推荐理由:


    Figma设计功能强大,提供了丰富的插件和集成,还有有一个庞大的社区交流学习,是一款非常适合团队协作的工具。遗憾的是,Figma作为一款国外设计工具,在国内使用还是存在一定的劣势,比如网络访问限制、数据隐私和安全、本地化支持等,所以只能给到四星。


    推荐评级:⭐⭐⭐


    3、Sketch


    Sketch(http://www.sketch.com/)是一款专业的UI/U…



    主要功能及亮点:



    1. 1)矢量编辑:直观的矢量编辑工具和可编辑的布尔运算,支持形状绘制、路径编辑、填充和描边等常见的矢量操作,使用户能够轻松创建和编辑各种设计元素。

    2. 2)设计规范库:设计师可以在sketch中把常用的颜色、图层样式存为规范,也可以将已经设计好的矢量图标存到规范库,在整个设计中复用它们,提高工作效率。


    3)Sketch支持众多插件和第三方资源,使得其生态系统非常丰富,能够更好地满足设计师的需求。


    **价格:**标准订阅 10//人(按年付费),10/月/人(按年付费),12/月/人(按月付费)


    **使用环境:**macOS操作系统


    推荐理由:


    Sketch被广泛应用于移动应用、网页设计和用户界面设计等领域。它友好的界面、丰富的设计资源使设计师们能够快速创建出符合良好用户体验和视觉效果要求的设计。可惜的是,它只支持macOS系统,限制了部分用户使用。


    **推荐评级:**⭐⭐⭐⭐


    4、Adobe XD


    Adobe XD(helpx.adobe.com/support/xd.…



    主要功能及亮点:


    1)丰富的设计和布局功能:设计师可以借助这些功能,轻松创建响应式设计,预览在不同设备上的效果,确保设计的一致性和灵活性。


    2)丰富的交互动画和过渡效果:可以创建十分流畅的交互体验,用来更好地展示APP或网站的交互逻辑。


    3)共享和协作:支持团队协作,用户可以轻松地共享设计文件,协同编辑,以及通过实时协作功能进行设计评审。


    **价格:**提供免费试用,提供付费订阅 $9.99/月


    **学习难度:**中


    **使用环境:**Windows、macOS


    **推荐理由:**Adobe XD与Adobe Creative Cloud紧密集成,十分方便同时使用Adobe其他软件的用户。不过有个问题是,Adobe xd已经官宣停止更新,只向老用户提供服务了,如果是之前没有使用Adobe XD的用户,最好选择其他产品。


    **推荐评级:**⭐️⭐️⭐️


    五、Principle


    Principle是一款专门用于UI/UX设计的软件,它的设计理念是想要让设计师能够轻松创建出高质量、富有吸引力的产品界面效果。



    主要功能及亮点:


    1)高级动画控制:软件支持创建复杂的动画效果,包括过渡、转换和多状态动画。这对于展示复杂的交互过程和动态效果非常有用。


    2)实时预览:Principle允许设计师实时预览他们的工作成果,这意味着可以即时查看和测试动画效果,确保设计的交互体验符合预期。


    3)组件和重复使用:设计师可以创建可重复使用的组件,这大大提高了工作效率,特别是在处理具有相似元素或布局的多个界面时。


    价格:$129


    **学习难度:**中


    **使用环境:**MacOS


    推荐理由:


    设计师通过Principle可以快速地制作出具有丰富交互的产品界面设计,并且它能和Sketch进行无缝链接,可以快速导入你在Sketch里已经做好的图,


    推荐评级:⭐️⭐️⭐️⭐️


    好的UI设计不仅仅是视觉上的享受,更是能让用户能够轻松、愉快地使用APP。避免上述UI设计误区,选择合适的设计工具,可以帮助我们设计出既美观又实用的产品。


    希望本文章能为UI设计师/准UI设计师们有所帮助,创作出更多优秀的设计作品~


    看到这里的读者朋友有福啦!本人吐血搜集整理,全网最全产品设计学习资料!


    只要花1分钟填写**问卷**就能免费领取以下超值礼包:


    1、产品经理必读的100本书 包含:产品思维、大厂案例、技能提升、数据分析、项目管理等各类经典书籍,从产品入门到精通一网打尽! 2、UI/UX设计师必读的115本书 包含:UI/UX设计、平面设计、版式设计、设计心理学、设计思维等各类经典书籍,看完你就是设计大神! 3、30G互联网人知识礼包 包含:



    • 10GPM礼包,产品案例、大咖分享、行业报告等应有尽有

    • 10GUI/UE资源,优秀设计案例、资料包、源文件免费领

    • 5G运营资料包,超全产品、电商、新媒体、活动等运营技能

    • 5G职场/营销资料包,包含产品设计求职面试、营销增长等


    4、50G热门流行的AI学习大礼包


    包含:AI绘画、AIGC精选课程、AI职场实用教程等


    5、30G职场必备技能包


    包含:精选PPT模板、免费可商用字体包、各岗位能力模型、Excel学习资料、求职面试、升职加薪、职场写作和沟通等实用资源。


    礼包资源持续更新,互联网行业知识一网打尽!礼包领取地址:


    docs.qq.com/form/page/D…



    作者:摹客
    来源:juejin.cn/post/7356535808931627046
    收起阅读 »

    后端:没空,先自己 mock 去

    web
    前言后端开发忙,不给你接口?后端抱怨你在测试过程中,频繁的给脏数据?后端修个接口很慢没法测试?有了 mockjs ,这些问题将迎刃而解。不要 998,pnpm i 带回家!真这么丝滑?请看我的使用方式:当后端接口无法满足要求,且不能及时更改时。例如后端返回{ ...
    继续阅读 »

    前言

    后端开发忙,不给你接口?

    后端抱怨你在测试过程中,频繁的给脏数据?

    后端修个接口很慢没法测试?

    image.png

    有了 mockjs ,这些问题将迎刃而解。不要 998,pnpm i 带回家!

    真这么丝滑?

    请看我的使用方式:

    当后端接口无法满足要求,且不能及时更改时。例如后端返回

    {
    "err_no": 0,
    "err_msg": "success",
    "data": [
    {
    "comment_id": "7337487924836287242",
    "user_info": {
    "user_name": "陈陈陈_",
    }
    }
    ],
    }

    但我此时希望增加一个 user_type 来确定页面的展示。

    那我就直接起一个文件:user.js,把刚才的响应 copy 过来,并追加改动

    myMock('/api/v1/user', 'post', () => {
    return {
    "err_no": 0,
    "err_msg": "success",
    "data": [
    {
    "comment_id": "7337487924836287242",
    "user_info": {
    "user_name": "陈陈陈_",
    "user_type": "admin",
    }
    }
    ],
    }
    });

    如此一来,这个请求就被无缝替换为了我们的 mock,可以随便测试了。

    如何接入 mockjs

    有的同学就要问了,主播主播,你的 mockjs 确实很方便,怎么接入比较好呀。别急,我们一步一步来

    1. 安装 mockjs
    pnpm i mockjs

    如果是使用 ts 的同学,可能需要额外安装 type 类型包:@types/mockjs

    1. 新建一个 mock 文件夹,在 mock/index.ts 放入基本路径
    // 各种 mock 的文件,视条件而定,我这里有俩文件就引入了俩
    import './login/user.js';
    import './model/model.js';

    并且在你的项目入口 ts 中引入 mock/index.ts

    import './mock/index'; // 引入 mock 配置
    1. 导出一个 myMock 方法,并追加一个 baseUrl 方便直接联动你的 axios
    import { ENV_TEST } from '@/api/config/interceptor';
    import Mock from 'mockjs';

    export const myMock = (
    path: string,
    method: 'get' | 'post',
    callback: (options: any) => any
    ) => {
    Mock.mock(`${ENV_TEST}${path}`, method, callback);
    };

    如此一来,你就可以在 mock 文件夹下去搞了,比如:

    我想新增一个服务模块的各类接口的 mock,那么我就新增一个 service 文件夹,在其下增加一个 index.ts,并对对应路径进行 mock

    myMock('/api/v1/service', 'get', () => {
    return {
    code: 0,
    msg: 'hello service',
    data: null,
    };
    });

    另外,别忘了在 mock/index.ts 引入文件

    不显示在 network 中?

    需要说明的是,这样走 mock 是不会触发真正的请求的,相当于 xhr 直接被 mock 拦截了下来并给了你返回值。所以你无法在 network 中看到你的请求。

    这是个痛点,目前比较好的解决方案还是起一个单独的服务来 mock。但这样也就意味着,需要另起一个项目来单独做 mock,太不优雅了。

    有没有什么办法,既可以走上述简单的mock,又可以在需要的时候起一个服务来查看 network,并且不需要额外维护两套配置呢?

    有的兄弟,有的。

    import express from 'express';
    import bodyParser from 'body-parser';
    import Mock from 'mockjs';
    import './login/user.js';
    import './model/model.js';
    import { ENV_TEST } from './utils/index.js';

    const app = express();
    const port = 3010;

    // 使用中间件处理请求体和CORS
    app.use(bodyParser.json());

    // 设置CORS头部
    app.use(( _ , res, next) => {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    next();
    });

    // 设置Mock路由的函数
    const setupMockRoutes = () => {
    const mockApis = Mock._mocked || {};

    // 遍历每个Mock API,并生成对应的路由
    Object.keys(mockApis).forEach((key) => {
    const { rurl, rtype, template } = mockApis[key];
    const route = rurl.replace(ENV_TEST, ''); // 去掉环境前缀

    // 根据请求类型(GET, POST, 等)设置路由
    app[rtype.toLowerCase()](route, (req, res) => {
    const data =
    typeof template === 'function' ? template(req.body || {}) : template;
    res.json(Mock.mock(data)); // 返回模拟数据
    });
    });
    };

    // 设置Mock API路由
    setupMockRoutes();

    // 启动服务器
    app.listen(port, () => {
    process.env.NODE_ENV = 'mock'; // 设置环境变量
    console.log(`Mock 服务已启动,访问地址: http://localhost:${port}`);
    });

    直接在 mock 文件夹下追加这个启动文件,当你需要看 network 的时候,将环境切换为 mock 环境即可。本质是利用了 Mock._mocked 可以拿到所有注册项,并用 express 起了一个后端服务响应这些注册项来实现的。

    在拥有了这个能力的基础上,我们就可以调整我们的命令

      "scripts": {
    "dev": "cross-env NODE_ENV=test vite",
    "mock": "cross-env NODE_ENV=mock vite & node ./src/mock/app.js"
    },

    顺便贴一下我的 env 配置:

    export const ENV_TEST = 'https://api-ai.com/fuxi';
    export const ENV_MOCK = 'http://localhost:3010/';

    let baseURL: string = ENV_TEST;

    console.log('目前环境为:' + process.env.NODE_ENV);
    switch (process.env.NODE_ENV) {
    case 'mock':
    baseURL = ENV_MOCK;
    break;
    case 'test':
    baseURL = ENV_TEST;
    break;
    case 'production':
    break;
    default:
    baseURL = ENV_TEST;
    break;
    }

    export { baseURL };

    这样一来,如果你需要看 network ,就 pnpm mock,如果不需要,就直接 pnpm dev,完全不需要其他心智负担。

    三个字:

    image.png

    参数相关

    具体的 api 可查阅:github.com/nuysoft/Moc… 相关的文章也非常多,就不展开说明了。

    如果这篇文章对你有帮助,不妨点个赞吧~


    作者:imoo
    来源:juejin.cn/post/7460091261762125865

    收起阅读 »

    官方回应无虚拟DOM版Vue为什么叫Vapor

    web
    相信很多人和我一样,好奇无虚拟DOM版的Vue为什么叫Vue Vapor。之前看过一个很新颖的观点:Vue1时代就没有虚拟DOM,而那个时代恰好处于前端界的第一次工业革命,也就是以声明式代替命令式的写法。所以无虚拟DOM版Vue让人感觉梦回Vue1,于是就采取...
    继续阅读 »

    相信很多人和我一样,好奇无虚拟DOM版的Vue为什么叫Vue Vapor。之前看过一个很新颖的观点:Vue1时代就没有虚拟DOM,而那个时代恰好处于前端界的第一次工业革命,也就是以声明式代替命令式的写法。所以无虚拟DOM版Vue让人感觉梦回Vue1,于是就采取了Vapor这个名字。



    Vapor是蒸汽的意思,第一次工业革命开创了以机器代替手工劳动的时代,该革命以蒸汽机作为动力机被广泛使用为标志。



    不过这个说法并非来自官方,虽然乍一听还挺有道理的,但总感觉官方并不一定是这么想的。事实也的确如此,在官方的Vue Conf中,Vue Vapor的作者出面说明了Vapor这个名字的含义:


    SCR-20250301-sbrn.png


    由于无虚拟DOM的特性,纯Vapor模式下可以去掉很多代码,比如VDom Diff。所以Vue Vapor的包体积可以做的更加的轻量化,像水蒸气一样轻。



    (前面那段话是官方说的,这段话是我说的)当然不是说Vapor模式就不需要diff算法了,我看过同为无虚拟DOM框架的SvelteSolid源码,无虚拟DOM只是不需要vDom间的Diff算法了,列表之间还是需要diff的。毕竟再怎么编译,你从后端获取到的数组,编译器也不可能预测得到。



    那具体能轻量多少呢?官方给出的数据是比虚拟DOMVue33.6%


    SCR-20250301-scmd.png


    Vapor的名字除了想表示轻量化之外还有一个另外一个原因,那就是Solid。可能有人会说这关Solid什么事啊?实际上Vapor的灵感正是来自于Solid(尤雨溪亲口承认)而Solid代表固体:


    SCR-20250301-sirl.png


    为了跟Solid有个趣味联动,那无虚拟DOM就是气体好了:


    SCR-20250301-sjel.png


    以上就是Vue Vapor作者告诉大家为什么叫Vapor的两大原因。


    性能


    之前都说虚拟DOM是为了性能,怎么现在又反向宣传了?无虚拟DOM怎么又逆流而上成为了性能的标杆了呢?这个话题我们留到下一篇文章去讲,本篇文章我们只看数据:


    SCR-20250301-scoj.png


    SCR-20250301-scsm.png


    从左到右依次为:



    • 原生JS:1.01

    • Solid:1.09

    • Svelte:1.11

    • 无虚拟DOMVue:1.24

    • 虚拟DOMVue:1.32

    • React:1.55


    数字越小代表性能越高,但无论再怎么高都不可能高的过手动优化过的原生JS,毕竟无论什么框架最终打包编译出来的还是JS。不过框架的性能其实已经挺接近原生的了,尤其是以无虚拟DOM著称的SolidSvelte。但无虚拟DOMVue和虚拟DOMVue之间并没有拉开什么很大的差距,1.241.32这两个数字证明了其实二者的性能差距并不明显,这又是怎么一回事呢?


    一个原因是Vue3本来就做了许多编译优化,包括但不限于静态提升、大段静态片段将采用innerHTML渲染、编译时打标记以帮助虚拟DOM走捷径等… 由于本来的性能就已经不错了,所以可提升的空间自然也没多少。


    看完上述原因肯定有人会说:可提升的空间没多少可以理解,那为什么还比同为无虚拟DOMSolidSvelte差那么多?如果VaporSolid性能差不多的话,那可提升空间小倒是说得过去。但要是像现在这样差的还挺多的话,那这个理由是不是就有点站不住脚了?之所以会出现这样的情况是因为Vue Vapor的作者在性能优化和快速实现之间选择了后者,毕竟尤雨溪第一次公布要开始做无虚拟DOMVue的时间是在2020年:


    6ccefc4a3c7e8656b9d0537927bd42b5.jpeg


    而如今已经是2025年了。也就是说如果一个大一新生第一次满心欢喜的听到无虚拟DOMVue的消息后,那么他现在大概率已经开始毕业工作了都没能等到无虚拟DOMVue的发布。这个时间拖的有点太长了,甚至从Vue2Vue3都没用这么久。


    所以现在的首要目标是先实现出来一版能用的,后面再慢慢进行优化。我们工作不也是这样么?先优先把功能做出来,优化的事以后再说。那么具体该怎么优化呢:


    SCR-20250301-scwz.png


    现在的很多渲染逻辑继承自原版Vue3,但无虚拟DOMVue可以采用更优的渲染逻辑。而且现在的Vapor是跟着原版Vue的测试集来做的,这也是为了实现和原版Vue一样的行为。但这可能并不是最优解,之后会慢慢去掉一些不必要的功能,尤其是对性能产生较大影响的功能。


    往期精彩文章:


    《尤雨溪:从Vue1到Vue3.6》


    作者:页面魔术
    来源:juejin.cn/post/7477104460452872202
    收起阅读 »

    Netflix 删除了 React ?

    web
    来源 Netflix Removed React? Netflix 删除了 React "Netflix 删除了 React,网站加载时间减少了 50%!" 这条爆炸性新闻在推特炸开了锅。这不禁让人想起半年前"微软应该禁止所有新项目使用 React" 的新闻 ...
    继续阅读 »

    来源 Netflix Removed React?


    Netflix 删除了 React


    "Netflix 删除了 React,网站加载时间减少了 50%!"


    这条爆炸性新闻在推特炸开了锅。这不禁让人想起半年前"微软应该禁止所有新项目使用 React" 的新闻 - 两大科技巨头相继 "抛弃" React,难道这预示着什么?


    "React 心智负担重、性能差、bundle 体积大...". 一时间,各种唱衰 React 的声音此起彼伏。有人甚至信誓旦旦地表示:"只要像 Netflix 一样干掉 React,你的网站性能立马提升 50%!"


    fake-twitter.png


    这条发布于 2024 年 10 月 27 日的推文有着惊人的 150 万浏览量。但是,这大概率是 AI 生成的假新闻。
    事实上,我们去 Netflix 的官网打开 react-devtools,发现他们依然在使用 React 构建他们的网站。


    netflix-react-devtools.png


    Netflix 的真实案例


    这篇 AI 生成的假新闻灵感来自 2017 年 Netflix 工程师在 hack news 上发布的一篇文章 - Netflix: Removing client-side React.js improved performance by 50%


    他直接移除了这篇文章最重要的部分 - client-side React.js, 也就是客户端的 React.js 代码。


    实际的情况是,Netflix 团队在 2017 年的时候在使用 React 构建他们的 landing page


    为什么在一个简单的 landing page 上要使用 React 呢?因为在 landing page



    • Netflix 需要处理大量的 AB 测试

    • 支持近 200 个国家的本地化

    • 根据用户设备、地理位置等因素动态调整内容

    • 需要服用现有的 React 组件


    基于上述需求的考虑,Netflix 团队选择了使用 React 来构建他们的 landing page


    为了优化页面加载性能,他们采用了服务端渲染的方案。同时,为了保证后续交互的流畅性,系统会预先加载(pre-fetch)后续流程所需的 React/Redux 相关代码。


    从架构上看,这个 landing page 本质上仍然是一个单页面应用(SPA),保持了 SPA 快速响应的优势。不同之处在于首次访问时,页面内容是由服务端直接生成的,这样可以显著提升首屏加载速度。


    这样做的缺点


    显然,Netflix 在 2017 年这么做是有原因的,他们当时的确也没有更好的方案。在 2025 年的今天,
    再来回顾这个方案,显然有以下缺点:


    数据重复获取


    在首屏渲染时,服务端需要获取数据来生成 HTML,而在客户端激活(hydration)后,为了保持交互性,往往又需要重新获取一遍相同的数据。
    这种重复的数据获取不仅浪费资源,还可能带来不必要的性能开销。


    客户端代码体积膨胀


    因为本质上,Netflixlanding page 是一个还是一个 SPA,那么不可避免的,所有可能的 UI 状态都需要打包,
    即使用户只需要其中的一部分代码。例如,在一个很常见的 tabs 页面


      <Tabs
    defaultActiveKey="1"
    items={[
    {
    label: 'Tab 1',
    key: '1',
    children: 'Tab 1',
    },
    {
    label: 'Tab 2',
    key: '2',
    children: 'Tab 2',
    disabled: true,
    },
    ]}
    />

    即使用户只点击了 Tab 1, 即使 Tab 2 没有被渲染,但是 Tab 2 的代码也会被打包。


    如何解决这些问题


    React Server Components (RSC) 为上述问题提供了优雅的解决方案:


    避免数据重复获取


    使用 RSC,组件可以直接在服务器端获取数据并渲染,客户端只接收最终的 HTML 结果。不再需要在客户端重新获取数据。


    智能代码分割


    RSC 允许我们选择性地决定哪些组件在服务器端运行,哪些在客户端运行。例如:


    function TabContent({ tab }: { tab: string }) {
    // 这部分代码只在服务器端运行,不会打包到客户端
    return <div>{tab} 内容</div>
    }

    // 客户端组件
    'use client'
    function TabWrapper({ children }) {
    const [activeTab, setActiveTab] = useState('1')
    return (
    <div>
    {/* Tab 切换逻辑 */}
    {children}
    </div>

    )
    }

    在这个例子中:



    • TabContent 的所有可能状态都在服务器端预渲染

    • 只有实际需要交互的 TabWrapper 会发送到客户端

    • 用户获得了更小的 bundle 体积和更快的加载速度


    这不就是 PHP?


    经常会看到有人说:"Server Components 不就是重新发明了 PHP 吗?都是在服务端生成 HTML。"


    显然,PHP 与现在的 Server Components 在开发体验上有本质的区别。


    1. 细粒度的服务端-客户端混合


    与 PHP 不同,RSC 允许我们在组件级别决定渲染位置,用一个购物车的例子来说明:


    // 服务端组件
    function ProductDetails({ id }: { id: string }) {
    // 在服务器端获取数据和渲染
    const product = await db.products.get(id);
    return <div>{product.name}</div>;
    }

    // 客户端组件
    'use client'
    function AddToCart({ productId }: { productId: string }) {
    // 在客户端处理交互
    return <button onClick={() => addToCart(productId)}>加入购物车</button>;
    }

    // 混合使用
    function ProductCard({ id }: { id: string }) {
    return (
    <div>
    <ProductDetails id={id} />
    <AddToCart productId={id} />
    </div>

    );
    }

    这种设计充分利用了服务端和客户端各自的优势 - 在服务端可以直接访问数据库获取 ProductDetails 所需的数据,而在客户端则能更好地处理 AddToCart 这样的用户交互。这不仅提升了性能,也让代码结构更加清晰合理。


    2. 保持组件的可复用性


    RSC 最强大的特性之一是组件的可复用性不受渲染位置的影响:


    // 这个组件可以在服务端渲染
    function UserProfile({ id }: { id: string }) {
    return <ProfileCard id={id} />;
    }

    // 同样的组件也可以在客户端动态加载
    'use client'
    function UserList() {
    const [selectedId, setSelectedId] = useState(null);
    return selectedId ? <ProfileCard id={selectedId} /> : null;
    }

    因为都是 React 组件,区别仅仅是渲染位置的不同,同一个组件可以:



    • 在服务端预渲染时使用

    • 在客户端动态加载时使用

    • 在流式渲染中使用


    这种统一的组件模型是 PHP 等传统服务端渲染所不具备的。


    3. 智能的序列化


    RSC 还提供了智能的序列化机制,可以自动将组件的 propsstate 序列化,从而在服务端和客户端之间传递。
    避免了重复获取数据的问题。


    // 服务端组件
    async function Comments({ postId }: { postId: string }) {
    // 1. 获取评论数据
    const comments = await db.comments.list(postId);

    // 2. 传递给客户端组件
    return <CommentList initialComments={comments} />;
    }

    // 客户端组件
    'use client'
    function CommentList({ initialComments }) {
    // 3. 直接使用服务端数据,无需重新请求
    const [comments, setComments] = useState(initialComments);

    return (
    // 渲染评论列表
    );
    }

    4. 渐进式增强


    RSC 还提供了渐进式增强的能力,可以在服务端和客户端之间无缝过渡。



    • 首次访问时返回完整的 HTML

    • 按需加载客户端交互代码

    • 保持应用的可访问性


    这让我们能够构建既有良好首屏体验,又能提供丰富交互的现代应用,完美解决了在 2017 年 Netflix 所提出的问题。


    总结


    通过对上面这些案例的分析,我们可以看出


    1. 不要轻信网络传言


    网络上充斥着各种技术传言。虽然像上面这种完全虚构的假新闻容易识破,但有些传言会巧妙地利用真实数据和案例,通过夸张的描述来误导技术选型和决策,这类信息需要我们格外谨慎分辨。
    例如:



    svelte 放弃 TypeScript 改用 JSdoc 进行类型检查



    这个确实是一个真的新闻,但是并不代表着 Typescript 的没落,实际上



    • Svelte 团队选择 JSDoc 是为了减少编译时间

    • 这是针对框架源码的优化,而不是面向使用者的建议

    • Svelte 依然完整支持 TypeScript,用户代码可以继续使用 .ts/.ts



    tauri 打包后的体积比 electron 小多了,我们应该放弃 electron 使用 tauri



    技术选型不能仅仅看单一指标。虽然 tauri 的打包体积确实小于 electron,但在开发体验、性能、稳定性、生态和社区支持等关键维度上都存在明显短板。


    如果你尝试用 tauri 开发复杂应用,很可能会因为生态不完善、社区支持不足而陷入困境。当你遇到问题去 GitHub 寻找解决方案时,看到许多库已经一年未更新,就会明白为什么大多数团队仍在选择 electron


    2. 历史的选择


    2017 年的 Netflix 面临着复杂的业务需求,他们选择了当时最佳的解决方案 - 服务端渲染 + 客户端激活。这个方案虽然解决了问题,但也带来了一些困扰:



    • 数据需要在服务端和客户端重复获取

    • JavaScript bundle 体积过大


    3. RSC 带来的改变


    React Server Components 为这些历史遗留问题带来了全新的解决思路:



    • 服务端渲染与客户端渲染完美融合

    • 智能的代码分割,最小化客户端 bundle 体积

    • 数据获取更高效,避免重复请求

    • 渐进式增强,提供流畅的用户体验


    4. 技术演进的启示


    Netflix 2017 年的实践到今天的 RSC,我们可以看到:



    • 技术方案在不断进化,过去的最佳实践可能已不再适用

    • RSC 不是简单的"回归服务端",而是开创了全新的开发模式

    • 性能与开发体验不再是非此即彼的选择


    RSC 代表了现代前端开发的新趋势 - 既保持了 React 强大的组件化能力,又通过创新的架构设计解决了历史难题。这让我们终于可以在性能和开发体验之间找到完美平衡。


    作者:snow分享
    来源:juejin.cn/post/7459029441039794211
    收起阅读 »

    一次失败的UI规范制定

    web
    前言 在公司中,前端使用了统一的组件element-ui,但是有一些页面大家并没有达成共识,造成了不同的团队开发出来的页面不同,公司决定在24年8月的迭代进行统一调整。在这个中间,我遇到了很多意料之外的问题,希望未来的你遇到了类似的问题,可以尽量避免 为什么会...
    继续阅读 »

    前言


    在公司中,前端使用了统一的组件element-ui,但是有一些页面大家并没有达成共识,造成了不同的团队开发出来的页面不同,公司决定在24年8月的迭代进行统一调整。在这个中间,我遇到了很多意料之外的问题,希望未来的你遇到了类似的问题,可以尽量避免


    为什么会产生这个问题


    image.png
    这个问题我也思考过,大概有以下原因



    1. 我们有4个产品经理,每个人负责不同的模块,都有各自的风格,造成页面不统一

    2. 没有一个严格的UI规范,前端开发和测试并没有一个可以参考标准页面

    3. 22年至今,项目都在疯狂的迭代功能,并没有人停下来看看之前做的功能有哪些问题,我们该怎么去优化


    项目背景


    参与人:UI设计师、前端开发、产品(主要负责审核,并没参与讨论)、测试


    牵头人:UI设计师
    职责:找出问题点,整理为在线文档


    解决者:前端
    职责:整理问题点、改公告组件、输出文档


    主要问题如下



    1. 弹窗(Dialog)组件大小不一。宽度有400px、700px、800px、940px、1250px、50%、60%等,用户在操作的时候,会感觉到页面时大时小,不统一

    2. 表格样式很多(Table)。搜索的列未对齐、分页码数未统一、表格后的操作栏千奇百怪、表头的标题有的进行了换行等

    3. 颜色的乱用。颜色有很多,有各种颜色的红色

    4. 弹窗表单的按钮位置不同。有的取消在左,有的取消在右面。

    5. 等等一些小问题就不一一列举了


    弹窗组件大小不一


    弹窗大小不统一部分截图


    800px
    image.png


    600px


    image.png


    1180px


    image.png


    解决方案


    我们在私服中clone了一份element-ui,直接修改了源码


    默认制定了三个尺寸的size,small(600px), medium(900px),large(1200px),满足基本的需求。当需要支持特殊组件的时候,也可以直接设置Width满足需求


    image.png


    表格不统一


    部分截图


    image.png


    上方的截图有几个问题



    1. 搜索条件(查找人员)没有和新增按钮对齐

    2. 离职和删除的按钮是比较敏感的操作,但是夹在了一堆按钮中间

    3. 操作按钮有的有icon,有的没icon,看着些许的混乱


    进行修改后效果如下,页面看着更加的工整
    image.png


    解决方案如下



    1. 搜索条件的第一个在组件内直接进行计算宽度,防止业务端进行二次修改,如果是自定义的搜索条件,并且设置了宽度,需要手动进行修改

    2. 一些危险的操作:比如离职、删除统一放在最后,并且每个操作按钮都要有icon


    表格按钮的调整


    调整前

    image.png


    调整后

    image.png


    解决方案如下

    表格后的按钮去除所有的icon,直接在组件内进行拦截,默认展示三个按钮,多余的放在dropDown内,当然也支持业务端设置显示几个按钮


    核心部分代码如下

    image.png


    分页数据不统一


    调整前

    image.png


    调整后

    image.png


    解决方案

    分页条数统一改为(20,50,100)


    考虑的因素为:之前的页面有15一页的。当页面过高的时候,会撑不满一个页面,造成table的页面空白,不太美观


    弹窗中,下方的操作栏的按钮位置不统一


    调整前


    image.png


    调整后


    image.png


    解决方案


    所有的按钮都是取消在左,保存在右面,同时,confirm组件,在element-ui的代码中直接修改调整位置,减少业务端工作量


    image.png


    颜色的乱用


    部分截图


    image.png


    image.png


    image.png


    image.png


    解决方案


    在主应用的根节点 使用css变量,在每个子应用的页面直接使用根节点的样式,避免子应用的颜色出现各种奇奇怪怪的颜色。


    image.png


    使用的地方


    image.png


    等等
    当然还有一些基础颜色的修改、按钮最小宽度的修改、一些表格间距的调整等。这些和设计师、产品达成共识后,进行修改就行了,也就不一一列举了


    交付给测试



    1. 我这边开发的差不多了,输出了一个文档,别的前端开发按照着文档进行开发。

    2. 测试按照文档进行编写测试用例


    不好搞了


    image.png


    测试这边疯狂提bug。


    还有一个小小的背景


    测试这边其实是有一个绩效考核:bug提的越多,绩效越高


    但是,我们这边项目上线的时候,其实还有一个要求,bug不能超过9个


    这个UI规范制定,到这个功能的提测,只有10天就项目上线了。


    有的功能并没有做,但是测试觉得不合理,就提了这个bug(比如表单的label也没有对齐,但是这个迭代并没有去做修复),造成我们前端开发这边bug超级多


    同时,开发这边也会有绩效考核,bug越多绩效就会越低,会扣除工资,大家心态崩溃了,改的速度根本赶不上提的速度。


    当然,也有部分功能是我这边测试不充分,造成业务端不好去实现


    找领导协助


    image.png


    这个过程是个很折磨人的过程, 开发同事在抱怨测试乱提、测试也在提本次优化范围外的bug,内心也很着急



    1. 重新定义了测试的范围,超过范围的问题可以先记着,不再提bug,方便后续进行修改

    2. 前端开发的bug都先指派给我,我看看能否从底层解决,我解决后再转给研发经理(研发经理没有此项的考核),当底层解决不了,业务端解决后,也指派给我,这样前端的开发就没有这么暴躁了


    如果再来一次UI规范的升级我会怎么做



    1. 先在每个团队中找一个人配合我,先升级页面后,进行灰度测试

    2. 在决定我们这个页面要成什么样子的时候,一定要拉上产品。设计师在做设计稿的时候,虽然每次都说了找产品的领导确认过了,但是领导还是比较忙, 并没有很多精力来做这件事情,可以找他们组内的一个资历比较深的产品一起讨论下。开发、UI设计师站的角度不同,一定要需要产品来参与讨论,做的页面才会更加的易用

    3. 限制测试的范围。 测试测着测试着会发散思维,导致改不完的bug,最终会导致开发没有了心态

    4. UI标准的功能,越早出来越好,越大后期需要投入的人力越多


    作者:pauldu
    来源:juejin.cn/post/7456685819047608355
    收起阅读 »

    uni-app 实现好看易用的抽屉效果

    web
    往期文章推荐: 软考小工具重磅更新啦!最好用的软考刷题工具 uni-app 高效开发小程序技巧:自动化切换环境变量 JavaScript 访问者模式:打造高扩展性的对象结构 一. 前言 我之前使用 uni-app 和 uniCloud 开发了一款软考刷题应用,...
    继续阅读 »

    往期文章推荐:


    软考小工具重磅更新啦!最好用的软考刷题工具


    uni-app 高效开发小程序技巧:自动化切换环境变量


    JavaScript 访问者模式:打造高扩展性的对象结构


    一. 前言


    我之前使用 uni-appuniCloud 开发了一款软考刷题应用,在这个应用中,我使用了抽屉组件来实现一些功能,切换题库,如下图所示:


    youti1.gif


    在移动应用开发中,抽屉(Drawer)是一种常见的界面设计模式,这个组件可以在需要侧边导航或者额外信息展示的地方使用。它允许用户通过侧滑的效果打开一个菜单或额外的内容区域。


    这种设计不仅能够节省屏幕空间,还能提供一种直观的交互方式。


    例如,在电商应用中,可以将购物车或分类列表放在抽屉里;在新闻阅读器中,可以放置频道选择等;而在有题记刷题软件中,我主要用于题库的选择功能。


    本文将介绍如何使用 uni-app 框架来实现一个简单的抽屉组件:DrawerWindow。文末提供完整的代码示例,让你能够轻松地在 uni-app 中实现抽屉效果。


    二. 实现分析


    Vue 组件的结构通常由三个主要部分组成:模板(<template>)、脚本(<script>)和样式(<style>),标准的的单文件组件(SFC)结构。


    uni-app 也是如此,在这个组件中,我们也将使用 Vue 的单文件组件(SFC)结构,这意味着我们将在一个 .vue 文件中同时包含模板、脚本和样式。


    接下来我们按照这个格式来简单实现一下。


    1. 模板页面 (<template>)


    首先,模版页面是很简单的部分,我们需要创建一个基础的 Vue 组件,该组件包含了主页面、抽屉内容和关闭按钮三个部分。以下是组件的模板代码:


    <template>
    <view class="drawer-window-wrap">
    <scroll-view scroll-y class="DrawerPage" :class="{ show: modalName === 'viewModal' }">
    <!-- 主页面 -->
    <slot></slot>
    </scroll-view>
    <!-- 关闭抽屉 -->
    <view class="DrawerClose" :class="{ show: modalName === 'viewModal' }" @tap="hide">
    <u-icon name="backspace"></u-icon>
    </view>
    <!-- 抽屉页面 -->
    <scroll-view scroll-y class="DrawerWindow" :class="{ show: modalName === 'viewModal' }">
    <slot name="drawer"></slot>
    </scroll-view>
    </view>
    </template>

    在模板部分,我们主要定义了三个主要元素:主页面、关闭按钮和抽屉页面。每个元素都有一个class绑定,这个绑定会根据 modalName 的状态来决定是否添加 .show 类。



    • 主页面 (<scroll-view class="DrawerPage">):



      • 这个滚动视图代表应用的主要内容区域。

      • 当抽屉打开时,它会被缩小并移向屏幕右侧。

      • 提供默认插槽 <slot></slot>,允许父组件传递自定义内容到这个位置。



    • 关闭按钮 (<view class="DrawerClose">):



      • 位于屏幕右侧的一个透明背景层,当点击时触发 hide() 方法来关闭抽屉。

      • 包含了一个图标 <u-icon name="backspace"></u-icon>,这里使用的是 uView UI 库中的图标组件。你可以选用其他组件库里的图标或者图片。



    • 抽屉页面 (<scroll-view class="DrawerWindow">):



      • 这是抽屉本身的内容区域,通常包含菜单或其他附加信息。

      • 同样地,定义特有的插槽名称,<slot name="drawer"></slot> 允许从外部插入特定的内容。

      • 抽屉默认是隐藏的,并且当显示时会有动画效果。




    在这里,我们主要使用了 <slot> 元素来定义可以插入自定义内容的位置。modalName 属性用来控制抽屉的状态。


    2. 逻辑处理 (<script>)


    接下来,逻辑处理其实也很简单,主要会定义打开和关闭抽屉的方法:


    <script>
    export default {
    data() {
    return {
    modalName: null
    }
    },
    methods: {
    // 打开抽屉
    show() {
    this.modalName = 'viewModal';
    },
    // 关闭抽屉
    hide() {
    this.modalName = null;
    }
    }
    }
    </script>


    • 数据 (data):



      • modalName: 用于控制抽屉状态的数据属性。当它的值为 'viewModal' 时,表示抽屉处于打开状态;否则,抽屉是关闭的。



    • 方法 (methods):



      • show(): 将 modalName 设置为'viewModal',从而通过 CSS 样式控制抽屉显示。

      • hide(): 将 modalName 重置为 null,控制抽屉隐藏。




    当调用 show() 方法时,modalName 被设置为 'viewModal',这会触发 CSS 中的 .show 类,从而显示抽屉;反之,调用 hide() 方法则会隐藏抽屉。


    3. 样式设计 (<style>)


    在这个组件中,其实要做好的在样式部分,主要是显示抽屉的动画部分。在主页面,我们主要定义了三个主要的样式类:主页面、关闭按钮和抽屉页面。



    • 主页面样式 (DrawerPage):



      • 初始状态下占据整个屏幕宽度和高度。

      • 当抽屉打开时(即有.show类),页面会缩小并移动到屏幕右侧 85%的位置,同时增加阴影效果以模拟深度。



    • 关闭按钮样式 (DrawerClose):



      • 默认情况下是不可见且不响应用户交互的。

      • 当抽屉打开时,按钮变为可见并可点击,提供了一种关闭抽屉的方式。



    • 抽屉页面样式 (DrawerWindow):



      • 初始状态下位于屏幕左侧外侧,不显示也不响应交互。

      • 当抽屉打开时,抽屉平滑滑入屏幕内,变得完全可见且可以与用户互动。



    • 动画与过渡



      • 所有的 .show 类都带有 transition: all 0.4s;,这使得任何属性的变化都会有一个 0.4 秒的平滑过渡效果。

      • 抽屉和主页面的 transform 属性被用来控制它们的位置和大小变化。

      • opacitypointer-events 属性确保在不需要时抽屉不会影响用户的操作。




    如下代码所示,我们主要添加一些 CSS 样式来实现平滑的过渡效果以及视觉上的美观:


    <style lang="scss">
    // 省略其他样式...
    .DrawerPage.show,
    .DrawerWindow.show,
    .DrawerClose.show {
    transition: all 0.4s;
    }

    .DrawerPage.show {
    transform: scale(0.9, 0.9) translateX(85vw);
    box-shadow: 0 0 60rpx rgba(0, 0, 0, 0.2);
    }

    .DrawerWindow.show {
    transform: scale(1, 1) translateX(0%);
    opacity: 1;
    pointer-events: all;
    }

    .DrawerClose.show {
    width: 15vw;
    color: #fff;
    opacity: 1;
    pointer-events: all;
    }
    </style>

    以上的这些样式确保了当抽屉显示或隐藏时有流畅的动画效果,并且在不需要的时候不会影响用户的交互。


    三. 完整代码


    1. 完整抽屉组件,复制可使用


    <template>
    <view class="drawer-window-wrap">
    <scroll-view scroll-y class="DrawerPage" :class="modalName == 'viewModal' ? 'show' : ''">
    <!-- 主页面 -->
    <slot></slot>
    </scroll-view>
    <!-- 关闭抽屉 -->
    <view class="DrawerClose" :class="modalName == 'viewModal' ? 'show' : ''" @tap="hide()">
    <u-icon name="backspace"></u-icon>
    </view>
    <!-- 抽屉页面 -->
    <scroll-view scroll-y class="DrawerWindow" :class="modalName == 'viewModal' ? 'show' : ''">
    <slot name="drawer"></slot>
    </scroll-view>
    </view>
    </template>

    <script>
    export default {
    data() {
    return {
    modalName: null
    }
    },
    methods: {
    // 打开抽屉
    show() {
    this.modalName = 'viewModal'
    },
    // 关闭抽屉
    hide() {
    this.modalName = null
    }
    }
    }
    </script>

    <style lang="scss">
    page {
    width: 100vw;
    overflow: hidden !important;
    }

    .DrawerPage {
    position: fixed;
    width: 100vw;
    height: 100vh;
    left: 0vw;
    background-color: #f1f1f1;
    transition: all 0.4s;
    }

    .DrawerPage.show {
    transform: scale(0.9, 0.9);
    left: 85vw;
    box-shadow: 0 0 60rpx rgba(0, 0, 0, 0.2);
    transform-origin: 0;
    }

    .DrawerWindow {
    position: absolute;
    width: 85vw;
    height: 100vh;
    left: 0;
    top: 0;
    transform: scale(0.9, 0.9) translateX(-100%);
    opacity: 0;
    pointer-events: none;
    transition: all 0.4s;
    background-image: linear-gradient(45deg, #1cbbb4, #2979ff) !important;
    }

    .DrawerWindow.show {
    transform: scale(1, 1) translateX(0%);
    opacity: 1;
    pointer-events: all;
    }

    .DrawerClose {
    position: absolute;
    width: 40vw;
    height: 100vh;
    right: 0;
    top: 0;
    color: transparent;
    padding-bottom: 50rpx;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    background-image: linear-gradient(90deg, rgba(0, 0, 0, 0.01), rgba(0, 0, 0, 0.6));
    letter-spacing: 5px;
    font-size: 50rpx;
    opacity: 0;
    pointer-events: none;
    transition: all 0.4s;
    }

    .DrawerClose.show {
    opacity: 1;
    pointer-events: all;
    width: 15vw;
    color: #fff;
    }
    </style>

    2. 在父组件中使用抽屉组件


    在父组件中,可以通过以下简单的代码使用它,你可以继续进行丰富:


    <template>
    <drawer-window ref="drawerWindow">
    <view class="main-container" @click="$refs.drawerWindow.show()">
    主页面,点击打开抽屉
    </view>
    <view slot="drawer" class="drawer-container"> 抽屉页面 </view>
    </drawer-window>
    </template>

    <script>
    export default {}
    </script>

    <style lang="scss" scoped>
    .main-container,
    .drawer-container {
    font-weight: 700;
    font-size: 20px;
    text-align: center;
    color: #333;
    padding-top: 100px;
    }
    </style>

    以上代码的实现效果如下图所示:


    youti2.gif


    四. 小程序体验


    以上的组件,来源于我独立开发的软考刷题小程序中的效果,想要体验或软考刷题的掘友可以参考以下文章,文末获取:


    软考小工具重磅更新啦!最好用的软考刷题工具


    五. 结语


    通过以上步骤,我们已经构建了一个基本的抽屉组件。当然,你也可以根据具体的应用场景对这个组件进行进一步的定制和优化。


    作者:前端梦工厂
    来源:juejin.cn/post/7417374536670707727
    收起阅读 »

    个人或个体户,如何免费使用微信小程序授权登录

    web
    需求 个人或个体户,如何免费使用微信小程序授权,快速登录进系统内部? 微信授权登录好处: 不用自己开发一个登录模块,节省开发和维护成本 安全性得到了保障,安全验证完全交由腾讯验证,超级可靠哇 可能有的人会问,为何不用微信公众号授权登录?原因很简单,因为一年...
    继续阅读 »

    需求


    个人或个体户,如何免费使用微信小程序授权,快速登录进系统内部?


    微信授权登录好处:



    1. 不用自己开发一个登录模块,节省开发和维护成本

    2. 安全性得到了保障,安全验证完全交由腾讯验证,超级可靠哇


    可能有的人会问,为何不用微信公众号授权登录?原因很简单,因为一年要300元,小公司得省钱啊!


    实现步骤说明


    所有的步骤里包含四个对象,分别是本地后台本地微信小程序本地网页、以及第三方微信后台



    1. 本地后台调用微信后台https://api.weixin.qq.com/cgi-bin/token接口,get请求,拿到返回的access_token

    2. 本地后台根据拿到的access_token,调用微信后台https://api.weixin.qq.com/wxa/getwxacodeunlimit接口,得到二维码图片文件,将其输出传递给本地网页显示

    3. 本地微信小程序本地网页的二维码图片,跳转至小程序登录页面,通过wx.login方法,在success回调函数内得到code值,并将该值传递给本地后台

    4. 本地后台拿到code值后,调用微信后台https://api.weixin.qq.com/sns/jscode2session接口,get请求,得到用户登录的openid即可。



    注意点:



    1. 上面三个微信接口/cgi-bin/token/getwxacodeunlimit/jscode2session必须由本地后台调用,微信小程序那边做了前端限制;

    2. 本地网页如何得知本地微信小程序已扫码呢?


    本地微信小程序code,通过A接口,将值传给后台,后台拿到openid后,再将成功结果返回给本地微信小程序;同时,本地网页不断地轮询A接口,等待后台拿到openid后,便显示登录成功页面。



    微信小程序核心代码


    Page({
    data: {
    theme: wx.getSystemInfoSync().theme,
    scene: "",
    jsCode: "",
    isLogin: false,
    loginSuccess: false,
    isChecked: false,
    },
    onLoad(options) {
    const that = this;
    wx.onThemeChange((result) => {
    that.setData({
    theme: result.theme,
    });
    });
    if (options !== undefined) {
    if (options.scene) {
    wx.login({
    success(res) {
    if (res.code) {
    that.setData({
    scene: decodeURIComponent(options.scene),
    jsCode: res.code,
    });
    }
    },
    });
    }
    }

    },
    handleChange(e) {
    this.setData({
    isChecked: Boolean(e.detail.value[0]),
    });
    },
    formitForm() {
    const that = this;
    if (!this.data.jsCode) {
    wx.showToast({
    icon: "none",
    title: "尚未微信登录",
    });
    return;
    }
    if (!this.data.isChecked) {
    wx.showToast({
    icon: "none",
    title: "请先勾选同意用户协议",
    });
    return;
    }
    wx.showLoading({
    title: "正在加载",
    });
    let currentTimestamp = Date.now();
    let nonce = randomString();
    wx.request({
    url: `A接口?scene=${that.data.scene}&js_code=${that.data.jsCode}`,
    header: {},
    method: "POST",
    success(res) {
    wx.hideLoading();
    that.setData({
    isLogin: true,
    });
    if (res.statusCode == 200) {
    that.setData({
    loginSuccess: true,
    });
    } else {
    if (res.statusCode == 400) {
    wx.showToast({
    icon: "none",
    title: "无效请求",
    });
    } else if (res.statusCode == 500) {
    wx.showToast({
    icon: "none",
    title: "服务内部错误",
    });
    }
    that.setData({
    loginSuccess: false,
    });
    }
    },
    fail: function (e) {
    wx.hideLoading();
    wx.showToast({
    icon: "none",
    title: e,
    });
    },
    });
    },
    });


    scene为随机生成的8位数字


    本地网页核心代码


        let isInit = true
    function loginWx() {
    isInit = false
    refreshQrcode()
    }
    function refreshQrcode() {
    showQrLoading = true
    showInfo = false
    api.get('/qrcode').then(qRes => {
    if (qRes.status == 200) {
    imgSrc = `${BASE_URL}${qRes.data}`
    pollingCount = 0
    startPolling()
    } else {
    showToast = true
    toastMsg = '二维码获取失败,请点击刷新重试'
    showInfo = true
    }
    }).finally(() => {
    showQrLoading = false
    })
    }

    // 开始轮询
    // 1000毫秒轮询一次
    function startPolling() {
    pollingInterval = setInterval(function () {
    pollDatabase()
    }, 1000)
    }
    function pollDatabase() {
    if (pollingCount >= maxPollingCount) {
    clearInterval(pollingInterval)
    showToast = true
    toastMsg = '二维码已失效,请刷新'
    showInfo = true
    return
    }
    pollingCount++
    api.get('/result').then(res => {
    if (res.status == 200) {
    clearInterval(pollingInterval)
    navigate('/os', { replace: true })
    } else if (res.status == 408) {
    clearInterval(pollingInterval)
    showToast = true
    toastMsg = '二维码已失效,请刷新'
    showInfo = true
    }
    })
    }



    html的部分代码如下所示


         <button class="btn" on:click={loginWx}>微信登录</button>
    <div id="qrcode" class="relative mt-10">
    {#if imgSrc}
    <img src={imgSrc} alt="二维码图片"/>
    {/if}
    {#if showQrLoading}
    <div class="mask absolute top-0 left-0 w-full h-full z-10">
    <Loading height="12" width="12"/>
    </div>
    {/if}
    </div>

    尾声


    若需要完整代码,或想知道如何申请微信小程序,欢迎大家关注或私信我哦~~


    附上网页微信授权登录动画、以及小程序登录成功后的截图


    动画.gif


    微信图片_20240401151120.png


    作者:zwf193071
    来源:juejin.cn/post/7351649413401493556
    收起阅读 »

    基于uniapp带你实现了一个好看的轮播图组件

    web
    背景 最近,朋友说在做uniapp微信小程序项目时接到一个需求,需要实现一个类似上图的轮播图效果,问我有没有什么好的实现方案,他也和我说了他的方案,比如让产品直接把图片切成两部分再分别进行上传,但是我觉得这个方案不够灵活,当每次修改banner图片时,都得让产...
    继续阅读 »

    vi-swiper-66.gif


    背景


    最近,朋友说在做uniapp微信小程序项目时接到一个需求,需要实现一个类似上图的轮播图效果,问我有没有什么好的实现方案,他也和我说了他的方案,比如让产品直接把图片切成两部分再分别进行上传,但是我觉得这个方案不够灵活,当每次修改banner图片时,都得让产品把图切好再分别上传。下文将探究一个更好的实现思路。


    微信图片_20240607010418.jpg


    需求分析


    由文章顶部的gif动图,我们可以看出每次执行轮播动画时,只会裁剪图片的中间部分进行滚动,图片的其余部分保持不变,等待轮播动画执行完成后,再淡化背景图片切换到下一张轮播图。


    从中可得出两点关键信息



    1.两种相同图片堆叠在一起,一张背景图(大图),一张轮播图(小图);


    2.需要对图片中间部分进行裁剪,并且定位到刚好能够和背景图重合得区域;



    根据以上得出的信息,我们还需解决两个疑问:



    1.如何对图片进行裁剪?


    2.图片裁剪后如何定位和背景图重合的区域?



    前端裁剪图片可以使用canvans,但是兼容性不好,太麻烦!还有没有好一点的方法呢?当然有,参考css中的雪碧图进行图片裁剪显示!!但还是有些麻烦,还有没有简单的方式呢?有,那就是使用css属性overflow: hidden;进行图片裁剪,下文也主要是讲这个方案。


    开始实现


    vi-swiper.vue


    <template>
    <view class="v-banner" :style="[boxStyle]">
    <swiper class="v-swiper" autoplay indicator-dots circular
    @animationfinish="onAnimationfinish"
    >

    <swiper-item class="v-swiperi-tem" v-for="(url,index) in list" :key="index">
    <image class="v-img" :src="url" mode="scaleToFill"></image>
    </swiper-item>
    </swiper>
    </view>

    </template>

    <script>
    export default {
    props: {
    // 当前索引
    value: {
    type: Number,
    default: 0
    },
    // 轮播图列表
    list: {
    type: Array,
    default: () => []
    }
    },
    computed: {
    boxStyle() {
    return {
    backgroundImage: `url(${this.list[this.value]})`,
    // 开启background-image转场动画
    transition: '1s background-image'
    }
    }
    },
    methods: {
    // 轮播图动画结束后更新底部更新图索引
    onAnimationfinish(e) {
    this.$emit('input', e.detail.current)
    }
    }
    }
    </script>


    <style lang="scss">
    /*sass变量,用于动态计算*/
    $swiperWidth: 650rpx;
    $swiperHeight: 350rpx;
    $verticalPadding: 60rpx;
    $horizontalPadding: 50rpx;
    $imgWidth: $swiperWidth + $horizontalPadding * 2;
    $imgHeight: $swiperHeight + $horizontalPadding * 2;

    .v-banner {
    /* 因为需要根据内边距动态调节背景图宽高,所以设为行内块 */
    display: inline-block;
    // 背景图铺满容器
    background-size: 100% 100%;
    padding: $verticalPadding $horizontalPadding;
    .v-swiper {
    height: $swiperHeight;
    width: $swiperWidth;
    // 裁剪图片
    overflow: hidden;
    .v-swiperi-tem {
    .v-img {
    width: $imgWidth;
    height: $imgHeight;
    }
    }
    }
    }
    </style>


    以上代码主要实现思路是让底部背景图大小和轮播图大小相同使两种重合,尺寸相等才能重合swiper轮播图容器组件固定宽高,使用overflow: hidden;来裁剪内部图片, 然后给底部背景图容器使用padding内边距来撑开容器,达到两种图片堆叠的效果;图片转场通过transition设置动画。


    以上组件页面显示效果如下:


    image.png


    发现两张图片还没有重合在一起,原因是两张图片虽然大小一致了,但是位置不对,如下图所示:


    image.png


    那么由上图描绘的信息可知,想要两张图重合,那么需要把轮播图分别向上和向左移动对应的内边距距离即可,我们可以通过给轮播图设置负的外边距实现,样式如下:


    .v-img {
    ...
    // 使两张图片重合
    margin-top: -$verticalPadding;
    margin-left: -$horizontalPadding;
    }

    效果如下图所示:


    image.png


    到这我们实现了图片的裁剪和重合,已经实现了最终效果。完整的代码会在文章结尾附上。


    另外,我已经把这个组件发布到了uniapp插件市场,并且做了相应封装,可灵活定制轮播图大小及相关样式,感兴趣的可以点击这里:


    vi-swiper轮播图,跳转到文档查阅源码或使用。


    总结


    这个堆叠轮播图效果实现起来不难,主要是要找对思路,一句话概括就是两张大小相等的图片进行重合,轮播图容器对图片进行裁剪


    完整代码


    vi-swiper.vue


    <template>
    <view class="v-banner" :style="[boxStyle]">
    <swiper class="v-swiper" autoplay indicator-dots circular
    @animationfinish="onAnimationfinish"
    >

    <swiper-item class="v-swiperi-tem" v-for="(url,index) in list" :key="index">
    <image class="v-img" :src="url" mode="scaleToFill"></image>
    </swiper-item>
    </swiper>
    </view>

    </template>

    <script>
    export default {
    props: {
    // 当前索引
    value: {
    type: Number,
    default: 0
    },
    // 轮播图列表
    list: {
    type: Array,
    default: () => []
    }
    },
    computed: {
    boxStyle() {
    return {
    backgroundImage: `url(${this.list[this.value]})`,
    // 开启background-image转场动画
    transition: '1s background-image'
    }
    }
    },
    methods: {
    // 轮播图动画结束后更新底部更新图索引
    onAnimationfinish(e) {
    this.$emit('input', e.detail.current)
    }
    }
    }
    </script>


    <style lang="scss">
    /*sass变量,用于动态计算*/
    $swiperWidth: 650rpx;
    $swiperHeight: 350rpx;
    $verticalPadding: 60rpx;
    $horizontalPadding: 50rpx;
    $imgWidth: $swiperWidth + $horizontalPadding * 2;
    $imgHeight: $swiperHeight + $horizontalPadding * 2;

    .v-banner {
    /* 因为需要根据内边距动态调节背景图宽高,所以设为行内块 */
    display: inline-block;
    // 背景图铺满容器
    background-size: 100% 100%;
    padding: $verticalPadding $horizontalPadding;
    .v-swiper {
    height: $swiperHeight;
    width: $swiperWidth;
    // 裁剪图片
    overflow: hidden;
    .v-swiperi-tem {
    .v-img {
    width: $imgWidth;
    height: $imgHeight;
    margin-top: -$verticalPadding;
    margin-left: -$horizontalPadding;
    }
    }
    }
    }
    </style>


    作者:vilan_微澜
    来源:juejin.cn/post/7377245069474021412
    收起阅读 »

    React:我做出了一个违背祖训的决定!

    web
    React 的 useEffect,大家都熟吧?这玩意儿就像个万金油,啥副作用都能往里塞:取数据、搞订阅、手动操作 DOM……反正渲染完了,它帮你擦屁股。它帮你擦屁股。 但是!React 团队最近搞了个大新闻,他们居然要对 useEffect 动刀子了!而且,...
    继续阅读 »

    image.png


    React 的 useEffect,大家都熟吧?这玩意儿就像个万金油,啥副作用都能往里塞:取数据、搞订阅、手动操作 DOM……反正渲染完了,它帮你擦屁股。它帮你擦屁股。


    但是!React 团队最近搞了个大新闻,他们居然要对 useEffect 动刀子了!而且,这次的改动,用他们的话说,简直是——“违背祖训”!“违背祖训”!


    useEffect 要变身?实验性 CRUD 支持来了!


    新的 useEffect 签名,整合了以前一个实验性的 Hook useResourceEffect 的功能,现在长这样:


    function useEffect(
    create: (() => (() => void) | void) | (() => {...} | void | null),
    createDeps: Array<mixed> | void | null,
    update?: ((resource: {...} | void | null) => void) | void,
    updateDeps?: Array<mixed> | void | null,
    destroy?: ((resource: {...} | void | null) => void) | void,
    ): void

    是不是看得一脸懵逼?别慌,我来给你翻译翻译。


    以前的 useEffect,创建和清理都挤在一个函数里,跟两口子似的,难舍难分。举个栗子:


    useEffect(() => {
    // 创建阶段:发起请求
    const controller = new AbortController();
    fetch('/api/data', { signal: controller.signal })
    .then(response => response.json())
    .then(data => setData(data));

    // 清理阶段:取消请求
    return () => {
    controller.abort();
    };
    }, [someDependency]);

    看到了吧?创建(发起请求)和清理(取消请求)都得写在一个函数里。


    现在好了,React 团队直接把它们拆散了!新签名里,创建、更新、销毁,各司其职,清清楚楚:



    • create: 专门用来造东西(比如,发个请求,整个订阅)。

    • createDeps: create 的跟屁虫,它们一变,create 就得重新执行。

    • update (可选): 想更新?找它!它会拿着 create 造出来的东西,给你更新。

    • updateDeps (可选): update 的小弟,它们一变,update 就得带着老东西,重新来过。

    • destroy: 可选的销毁时候的回调。


    “祖宗之法不可变”?React:我就变!


    自从 Hook 在 2016 年推出,到现在已经九年了!九年啊!“组合优于继承”、“函数式编程”,这些 React 的“祖训”,各路大神、大V,哪个没给你讲过几百遍?哪个没给你讲过几百遍?


    useEffect 把创建和清理揉在一起,也算是“组合”的一种体现,深入人心。可现在呢?React 团队居然亲手把它拆了!这……这简直是自己打自己的脸啊!


    不过,话说回来,这种拆分,对于那些复杂的副作用,确实更清晰、更好管理。以前,你可能得在一个 useEffect 里写一堆 if...else,现在,你可以把它们放到不同的阶段,代码更清爽,逻辑更分明。


    注意!前方高能预警!


    这个 CRUD 功能,现在还是个“试验品”,React 团队还没打算把它放出来。你要是头铁,非要试试,记得先去把 feature flag 打开。不然,你会看到这个:


    useEffect CRUD overload is not enabled in this build of React.

    重要的事情说三遍:这都是猜的!猜的!猜的!猜的!猜的!猜的!


    现在,关于这个新特性,React 团队还没放出任何官方文档或者 RFC。所以,这篇文章,你看看就好,别太当真。它就是基于代码瞎猜的。等官方消息出来了,咱们再好好研究!


    作者:锈儿海老师
    来源:juejin.cn/post/7470819965014474771
    收起阅读 »

    uni-app初体验,如何实现一个外呼APP

    web
    起因 2024年3月31日,我被公司裁员了。 2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。 2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回...
    继续阅读 »

    起因


    2024年3月31日,我被公司裁员了。


    2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。


    2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回上海开始找工作。结果环境比预想的还要差啊,以前简历放开就有人找,现在每天投个几十封都是石沉大海。。。


    2024年4月15日,有个好朋友找我,想让我给他们公司开发一个“拨号APP”(主要原因其实是这个好哥们想让我多个赚钱门路😌),主要的功能就是在他们的系统上点击一个“拨号”按钮,然后员工的工作手机上就自动拨打这个号码。


    可行性分析


    涉及到的修改:



    • 系统前后端

    • 拨号功能的APP


    拿到这个需求之后,我并没有直接拒绝或者同意,而是先让他把他公司那边的的源代码发我了一份,大致看了一下使用的框架,然后找一些后端的朋友看有没人有人一起接这个单子;而我自己则是要先看下能否实现APP的功能(因为我以前从来没有做过APP!!!)。


    我们各自看过自己的东西,然后又沟通了一番简单的实现过程后达成了一致,搞!


    因为我这边之前的技术栈一直是VUE,所以决定使用uni-app实现,主要还是因为它的上手难度会低很多。


    第一版


    需求分析


    虽说主体的功能是拨号,但其实是隐含很多辅助性需求的,比如拨号日志、通时通次统计、通话录音、录音上传、后台运行,另外除了这些外还有额外的例如权限校验、权限引导、获取手机号、获取拨号状态等功能需要实现。


    但是第一次预算给的并不高,要把这些全部实现显然不可能。因此只能简化实现功能实现。



    • 拨号APP

      • 权限校验

        • 实现部分(拨号、录音、文件读写)



      • ❌权限引导

      • 查询当前手机号

        • 直接使用input表单,由用户输入



      • 查询当前手机号的拨号任务

        • 因为后端没有socket,使用setTimeout模拟轮询实现。



      • 拨号、录音、监测拨号状态

        • 根据官网API和一些安卓原生实现



      • 更新任务状态

        • 告诉后端拨号完成



      • ❌通话录音上传

      • ❌通话日志上传

      • ❌本地通时通次统计

      • 程序运行日志

      • 其他

        • 增加开始工作、开启录音的状态切换

        • 兼容性,只兼容安卓手机即可






    基础设计


    一个input框来输入用户手机号,一个开始工作的switch,一个开启录音的切换。用户输入手机号,点击开始工作后开启轮询,轮询到拨号任务后就拨号同时录音,同时监听拨号状态,当挂断后结束录音、更新任务状态,并开启新一轮的轮询。


    开干


    虽然本人从未开发过APP,但本着撸起袖子就是干的原则,直接打开了uni-app的官网就准备开怼。


    1、下载 HbuilderX。


    2、新建项目,直接选择了默认模板。


    3、清空 Hello页面,修改文件名,配置路由。


    4、在vue文件里写主要的功能实现,并增加 Http.jsRecord.jsPhoneCall.jsPower.js来实现对应的模块功能。


    ⚠️关于测试和打包


    运行测试


    在 HbuilderX 中点击“运行-运行到手机或模拟器-运行到Android APP基座”会打开一个界面,让你选择运行到那个设备。这是你有两种选择:



    • 把你手机通过USB与电脑连接,然后刷新列表就可以直接运行了。

      • 很遗憾,可能是苹果电脑与安卓手机的原因,插上后检测不出设备😭。。。



    • 安装Android Studio,然后通过运行内置的模拟器来供代码运行测试。

      • 这种很麻烦,要下载很久,且感觉测试效果并不好,最好还是用windows电脑连接手机的方法测试。




    关于自定义基座和标准基座的差别,如果你没有买插件的话,直接使用基准插座就好。如果你要使用自定义基座,就首先要点击上图中的制作自定义基座,然后再切换到自定义基座执行。


    但是不知道为什么,我这里一直显示安装自定义基座失败。。。


    打包测试


    除了以上运行测试的方法外,你还有一种更粗暴的测试方法,那就是打包成APP直接在手机上安装测试。


    点击“发行-原生APP 云打包”,会生成一个APK文件,然后就可以发送到手机上安装测试。不过每天打包的次数有限,超过次数需要购买额外的打包服务或者等第二天打包。


    我最终就是这样搞得,真的我哭死,我可能就是盲调的命,好多项目都是盲调的。


    另外,在打包之前我们首先要配置manifest.json,里面包含了APP的很多信息。比较重要的一个是AppId,一个是App权限配置。参考uni-app 权限配置Android官方权限常量文档。以下是拨号所需的一些权限:



    // 录制音频
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    // 修改音频设置
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

    // 照相机
    <uses-permission android:name="android.permission.CAMERA" />
    // 写入外部存储
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    // 读取外部存储
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

    // 读取电话号码
    <uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
    // 拨打电话
    <uses-permission android:name="android.permission.CALL_PHONE" />
    // 呼叫特权
    <uses-permission android:name="android.permission.CALL_PRIVILEGED" />
    // 通话状态
    <uses-permission android:name="android.permission.READ_PHONE_STATE" />
    // 读取拨号日志
    <uses-permission android:name="android.permission.READ_CALL_LOG" />
    // 写入拨号日志
    <uses-permission android:name="android.permission.WRITE_CALL_LOG" />
    // 读取联系人
    <uses-permission android:name="android.permission.READ_CONTACTS" />
    // 写入联系人
    <uses-permission android:name="android.permission.WRITE_CONTACTS" />
    // 读取SMS?
    <uses-permission android:name="android.permission.READ_SMS" />

    // 写入设置
    <uses-permission android:name="android.permission.WRITE_SETTINGS" />
    // 唤醒锁定?
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    // 系统告警窗口?
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
    // 接受完整的引导?
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

    ⚠️权限配置这个搞了很长时间,即便现在有些权限还是不太清楚,也不知道是不是有哪些权限没有配置上。。。


    ⚠️权限校验


    1、安卓 1


    好像除了这样的写法还可以写"scope.record"或者permission.CALL_PHONE


    permision.requestAndroidPermission("android.permission.CALL_PHONE").then(res => {
    // 1 获得权限 2 本次拒绝 -1 永久拒绝
    });

    2、安卓 2


    plus.android.requestPermissions(["android.permission.CALL_PHONE"], e => {
    // e.granted 获得权限
    // e.deniedPresent 本次拒绝
    // e.deniedAlways 永久拒绝
    });

    3、uni-app


    这个我没测试,AI给的,我没有用这种方法。有一说一,百度AI做的不咋地。


    // 检查权限
    uni.hasPermission({
    permission: 'makePhoneCall',
    success() {
    console.log('已经获得拨号权限');
    },
    fail() {
    // 示例:请求权限
    uni.authorize({
    scope: 'scope.makePhoneCall',
    success() {
    console.log('已经获得授权');
    },
    fail() {
    console.log('用户拒绝授权');
    // 引导用户到设置中开启权限
    uni.showModal({
    title: '提示',
    content: '请在系统设置中打开拨号权限',
    success: function(res) {
    if (res.confirm) {
    // 引导用户到设置页
    uni.openSetting();
    }
    }
    });
    }
    });
    }
    });

    ✅拨号


    三种方法都可以实现拨号功能,只要有权限,之所以找了三种是为了实现APP在后台的情况下拨号的目的,做了N多测试,甚至到后面搞了一份原生插件的代码不过插件加载当时没搞懂就放弃了,不过到后面才发现原来后台拨号出现问题的原因不在这里,,具体原因看后面。


    另外获取当前设备平台可以使用let platform = uni.getSystemInfoSync().platform;,我这里只需要兼容固定机型。


    1、uni-app API


    uni.makePhoneCall({
    phoneNumber: phone,
    success: () => {
    log(`成功拨打电话${phone}`);
    },
    fail: (err) => {
    log(`拨打电话失败! ${err}`);
    }
    });

    2、Android


    plus.device.dial(phone, false);

    3、Android 原生


    写这个的时候有个小插曲,当时已经凌晨了,再加上我没有复制,是一个个单词敲的,结果竟然敲错了一个单词,测了好几遍都没有成功。。。还在想到底哪里错了,后来核对一遍才发现😭,control cv才是王道啊。


    // Android
    function PhoneCallAndroid(phone) {
    if (!plus || !plus.android) return;
    // 导入Activity、Intent类
    var Intent = plus.android.importClass("android.content.Intent");
    var Uri = plus.android.importClass("android.net.Uri");
    // 获取主Activity对象的实例
    var main = plus.android.runtimeMainActivity();
    // 创建Intent
    var uri = Uri.parse("tel:" + phone); // 这里可修改电话号码
    var call = new Intent("android.intent.action.CALL", uri);
    // 调用startActivity方法拨打电话
    main.startActivity(call);
    }

    ✅拨号状态查询


    第一版用的就是这个获取状态的代码,有三种状态。第二版的时候又换了一种,因为要增加呼入、呼出、挂断、未接等状态的判断。


    export function getCallStatus(callback) {
    if (!plus || !plus.android) return;
    let maintest = plus.android.runtimeMainActivity();
    let Contexttest = plus.android.importClass("android.content.Context");
    let telephonyManager = plus.android.importClass("android.telephony.TelephonyManager");
    let telManager = plus.android.runtimeMainActivity().getSystemService(Contexttest.TELEPHONY_SERVICE);
    let receiver = plus.android.implements('io.dcloud.android.content.BroadcastReceiver', {
    onReceive: (Contexttest, intent) => {
    plus.android.importClass(intent);
    let phoneStatus = telManager.getCallState();
    callback && callback(phoneStatus);
    //电话状态 0->空闲状态 1->振铃状态 2->通话存在
    }
    });
    let IntentFilter = plus.android.importClass("android.content.IntentFilter");
    let filter = new IntentFilter();
    filter.addAction(telephonyManager.ACTION_PHONE_STATE_CHANGED);
    maintest.registerReceiver(receiver, filter);
    }

    ⚠️录音


    录音功能这个其实没啥,都是官网的API,无非是简单的处理一些东西。但是这里有一个大坑!


    一坑


    就是像通话录音这种涉及到的隐私权限很高,正常的这种录音是在通话过程中是不被允许的。


    二坑


    后来一次偶然的想法,在接通之后再开启录音,发现就可以录音了。


    但随之而来的是第二个坑,那就是虽然录音了,但是当播放的时候发现没有任何的声音,还是因为保护隐私的原因,我当时还脱离代码专门试了试手机自带的录音器来在通话时录音,发现也不行。由此也发现uni的录音本身也是用的手机录音器的功能。


    三坑


    虽然没有声音,但是我还是试了下保存,然后就发现了第三个坑,那就是虽然获取了文件权限,但是现在手机给的读写权限都是在限定内的,录音所在的文件夹是无权访问的。。。


    另辟蹊径


    其实除了自己手动录音外还可以通过手机自带的通话录音来实现,然后只要手机去读取录音文件并找到对应的那个就可以了。思路是没啥问题,不过因为设置通话录音指引、获取录音文件都有问题,这一版本就没实现。


    // 录音

    var log = console.log,
    recorder = null,
    // innerAudioContext = null,
    isRecording = false;

    export function startRecording(logFun = console.log) {
    if (!uni.getRecorderManager || !uni.getRecorderManager()) return logFun('不支持录音!');
    log = logFun;
    recorder = uni.getRecorderManager();
    // innerAudioContext = uni.createInnerAudioContext();
    // innerAudioContext.autoplay = true;
    recorder.onStart(() => {
    isRecording = true;
    log(`录音已开始 ${new Date()}`);
    });
    recorder.onError((err) => {
    log(`录音出错:${err}`);
    console.log("录音出错:", err);
    });
    recorder.onInterruptionBegin(() => {
    log(`检测到录音被来电中断...`);
    });
    recorder.onPause(() => {
    log(`检测到录音被来电中断后尝试启动录音..`);
    recorder.start({
    duration: 10 * 60 * 1000,
    });
    });
    recorder.start({
    duration: 10 * 60 * 1000,
    });
    }

    export function stopRecording() {
    if (!recorder) return
    recorder.onStop((res) => {
    isRecording = false;
    log(`录音已停止! ${new Date()}`); // :${res.tempFilePath}
    // 处理录制的音频文件(例如,保存或上传)
    // powerCheckSaveRecord(res.tempFilePath);
    saveRecording(res.tempFilePath);
    });
    recorder.stop();
    }

    export function saveRecording(filePath) {
    // 使用uni.saveFile API保存录音文件
    log('开始保存录音文件');
    uni.saveFile({
    tempFilePath: filePath,
    success(res) {
    // 保存成功后,res.savedFilePath 为保存后的文件路径
    log(`录音保存成功:${res.savedFilePath}`);
    // 可以将res.savedFilePath保存到你的数据中,或者执行其他保存相关的操作
    },
    fail(err) {
    log(`录音保存失败! ${err}`);
    console.error("录音保存失败:", err);
    },
    });
    }

    运行日志


    为了更好的测试,也为了能实时的看到执行的过程,需要一个日志,我这里就直接渲染了一个倒序的数组,数组中的每一项就是各个函数push的字符串输出。简单处理。。。。嘛。


    联调、测试、交工


    搞到最后,大概就交了个这么玩意,不过也没有办法,一是自己确实不熟悉APP开发,二是满共就给了两天的工时,中间做了大量的测试代码的工作,时间确实有点紧了。所幸最起码的功能没啥问题,也算是交付了。


    image.png


    第二版


    2024年05月7日,老哥又找上我了,想让我们把他们的这套东西再给友商部署一套,顺便把这个APP再改一改,增加上通时通次的统计功能。同时也是谈合作,如果后面有其他的友商想用这套系统,他来谈,我们来实施,达成一个长期合作关系。


    我仔细想了想,觉得这是个机会,这块东西的市场需求也一直有,且自己现在失业在家也有时间,就想着把这个简单的功能打磨成一个像样的产品。也算是做一次尝试。


    需求分析



    • ✅拨号APP

      • 登录

        • uni-id实现



      • 权限校验

        • 拨号权限、文件权限、自带通话录音配置



      • 权限引导

        • 文件权限引导

        • 通话录音配置引导

        • 获取手机号权限配置引导

        • 后台运行权限配置引导

        • 当前兼容机型说明



      • 拨号

        • 获取手机号

          • 是否双卡校验

          • 直接读取手机卡槽中的手机号码

          • 如果用户不会设置权限兼容直接input框输入



        • 拨号

        • 全局拨号状态监控注册、取消

          • 支持呼入、呼出、通话中、来电未接或挂断、去电未接或挂断





      • 录音

        • 读取录音文件列表

          • 支持全部或按时间查询



        • 播放录音

        • ❌上传录音文件到云端



      • 通时通次统计

        • 云端数据根据上面状态监控获取并上传

          • 云端另写一套页面



        • 本地数据读取本机的通话日志并整理统计

          • 支持按时间查询

          • 支持呼入、呼出、总计的通话次数、通话时间、接通率、有效率等





      • 其他

        • 优化日志显示形式

          • 封装了一个类似聊天框的组件,支持字符串、Html、插槽三种显示模式

          • 在上个组件的基础上实现权限校验和权限引导

          • 在上两个组件的基础上实现主页面逻辑功能



        • 增加了拨号测试、远端连接测试

        • 修改了APP名称和图标

        • 打包时增加了自有证书






    中间遇到并解决的一些问题


    关于框架模板


    这次重构我使用了uni中uni-starter + uni-admin 项目模板。整体倒还没啥,这俩配合还挺好的,就只是刚开始不知道还要配置东西一直没有启动起来。


    建立完项目之后还要进uniCloud/cloudfunctions/common/uni-config-center/uni-id配置一个JSON文件来约定用户系统的一些配置。


    打包的时候也要在manifest.json将部分APP模块配置进去。


    还搞了挺久的,半天才查出来。。


    类聊天组件实现



    • 设计

      • 每个对话为一个无状态组件

      • 一个图标、一个名称、一个白底的展示区域、一个白色三角

      • 内容区域通过类型判断如何渲染

      • 根据前后两条数据时间差判断是否显示灰色时间



    • 参数

      • ID、名称、图标、时间、内容、内容类型等



    • 样式

      • 根据左边右边区分发送接收方,给与不同的类名

      • flex布局实现




    样式实现这里,我才知道原来APP和H5的展示效果是完全不同的,个别地方需要写两套样式。


    关于后台运行


    这个是除了录音最让我头疼的问题了,我想了很多实现方案,也查询了很多相关的知识,但依旧没效果。总体来说有以下几种思路。



    • 通过寻找某个权限和引导(试图寻找到底是哪个权限控制的)

    • 通过不停的访问位置信息

    • 通过查找相应的插件、询问GPT、百度查询

    • 通过程序切入后台之后,在屏幕上留个悬浮框(参考游戏脚本的做法)

    • 通过切入后台后,发送消息实现(没测试)


    测试了不知道多少遍,最终在一次无意中,终于发现了如何实现后台拨号,并且在之后看到后台情况下拨号状态异常,然后又查询了应用权限申请记录,也终于知道,归根到底能否后台运行还是权限的问题。


    关于通话状态、通话记录中的类型


    这个倒还好,就是测试的问题,知道了上面为啥异常的情况下,多做几次测试,就能知道对应的都是什么状态了。


    通话状态:呼入振铃、通话中(呼入呼出)、通话挂断(呼入呼出)、来电未接或拒绝、去电未接或拒接。


    通话日志:呼入、呼出、未接、语音邮件、拒接


    交付


    总体上来说还过得去,相比于上次简陋的东西,最起码有了一点APP的样子,基本上该有的功能也基本都已经实现了,美中不足的一点是下面的图标没有找到合适的替换,然后录音上传的功能暂未实现,不过这个也好实现了。


    image.png


    后面的计划



    • 把图标改好

    • 把录音文件是否已上传、录音上传功能做好

    • 把APP的关于页面加上,对接方法、使用方法和视频、问题咨询等等

    • 原本通话任务、通时通次这些是放在一个PHP后端的,对接较麻烦。要用云函数再实现一遍,然后对外暴露几个接口,这样任何一个系统都可以对接这个APP,而我也可以通过控制云空间的跨域配置来开放权限

    • 把数据留在这边之后,就可以再把uni-admin上加几个页面,并且绑定到阿里云的云函数前端网页托管上去

    • 如果有可能的话,上架应用商店,增加上一些广告或者换量联盟之类的东西

    • 后台运行时,屏幕上加个悬浮图标,来电时能显示个振铃啥的

    • 增加拨号前的校验,对接平台,对于经常拉黑电销的客户号码进行过滤


    大致的想法就这些了,如果这个产品能继续卖下去,我就会不断的完善它。


    最后


    现在的行情真的是不好啊,不知道有没有大哥给个内推的机会,本人大专计算专业、6.5年Vue经验(专精后台管理、监控大屏方向,其他新方向愿意尝试),多个0-1-2项目经验,跨多个领域如人员管理、项目管理、产品设计、软件测试、数据爬虫、NodeJS、流程规范等等方面均有了解,工作稳定不经常跳,求路过的大哥给个内推机会把!



    😂被举报标题党了,换个名字。


    作者:前端湫
    来源:juejin.cn/post/7368421971384860684
    收起阅读 »

    无构建和打包,浏览器直接吃上Vue全家桶?

    web
    Vue 是易学易用,性能出色,适用场景丰富的 Web 前端渐进式框架;常用来开发单页面应用。 主流开发方式-编译打包 用脚手架工具 create-vue 可以快速通过 npm create vue@latest命令 来定制化新建一个 Vite 驱动的 Vue ...
    继续阅读 »

    Vue 是易学易用,性能出色,适用场景丰富的 Web 前端渐进式框架;常用来开发单页面应用。


    主流开发方式-编译打包


    用脚手架工具 create-vue 可以快速通过 npm create vue@latest命令 来定制化新建一个 Vite 驱动的 Vue 单页面应用项目。


    image.png
    这是常规的使用 Vue 的方式。当然也可以从 Vite 那边入手。


    我们新建一个项目 vue-demo来试试,选上 Vue-Router 和 Pinia, 其余的不选:
    image.png
    访问 http://localhost:5173/, 正常打开:
    image.png
    初始化的模板,用上了 Vue-Router,有两个路由, '/', '/about';那 Pinia 呢?可以看到依赖已经安装了引入了,给了一个 demo 了
    image.png
    我们来用一下 Pinia, 就在about路由组件里面用下吧:
    image.png


    <script setup>
    import { useCounterStore } from '@/stores/counter'
    import { storeToRefs } from 'pinia'
    const store = useCounterStore()
    const { count, doubleCount } = storeToRefs(store)
    const { increment } = store
    </script>

    <template>
    <div class="about">
    <h1>{{ count }}</h1>
    <h1>{{ doubleCount }}</h1>
    <button @click="increment">+1</button>
    </div>
    </template>

    <style>
    @media (min-width: 1024px) {
    .about {
    min-height: 100vh;
    display: flex;
    align-items: center;
    }
    }
    </style>

    image.png
    录屏2024-07-31 23.29.34.gif
    这就是 Vue + Vue-Router + Pinia 全家桶在 打包构建工具 Vite 驱动下的开发方式。
    Vite 开发阶段不打包,但会预构建项目的依赖,需要哪个资源会在请求的时候编译,而项目上线则需要打包。


    完美对吧!但你有没有注意到,官网除了介绍这种方式,还介绍了 “Using Vue from CDN”:
    image.png
    image.png
    image.png
    也就是说,可以 HTML 文件里面直接用上 Vue 的对吧?那我还想要 Vue-Router、 Pinia、Axios、 Element-Plus 呢?怎么全部直接用,而不是通过npm install xxx 在需要构建打包的项目里面用?


    allwant.gif


    如何直接吃上 Vue 全家桶


    我们将会从一个 HTML 文件开始,用浏览器原生的 JavaScript modules 来引入 Vue 、引入 Vue-Router,、引入 Pinia、引入 Axios, 并且构建一个类似工程化的目录结构,但不需要打包,JS 是 ES modules 语法;而项目的运行,只需要用npx serve -s在当前项目目录起一个静态文件服务器,然后浏览器打开即可。


    HTML 文件引入 Vue


    找个空文件夹,我们新建一个 index.html:
    image.png
    把 Vue 文档代码复制过来:


    <script type="importmap">
    {
    "imports": {
    "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
    }
    }
    </script>

    <div id="app">{{ message }}</div>

    <script type="module">
    import { createApp, ref } from 'vue'

    createApp({
    setup() {
    const message = ref('Hello Vue!')
    return {
    message
    }
    }
    }).mount('#app')
    </script>

    当前目录下执行下npx serve -s打开看看
    image.png
    image.png


    没问题。


    但是经常写页面的朋友都知道,肯定得拆分组件,不然全写一个页面不好维护,这点官网也给了例子:
    image.png
    照猫画虎,我们拆分一下:


    新建 src/app.js文件,如下内容:


    import { ref } from 'vue'
    export default {
    setup() {
    const count = ref(0)
    return { count }
    },
    template: `<div @click="count++">Count is: {{ count }}</div>`
    }

    然后在 index.html引入:


    <script type="module">
    import { createApp, ref } from 'vue'
    import App from './src/app.js'

    createApp(App).mount('#app')
    </script>

    image.png
    刷新下页面看看:
    录屏2024-08-01 23.21.39.gif
    Vue 成功引入并使用了。但还有遗憾,就是app.js"组件"的 template 部分是字符串,没有高亮,不利于区分:
    image.png
    关于这点,官网也说了,如果你使用 VS Code, 那你可以安装插件 es6-string-html,用 /*html*/实现高亮:
    image.png
    image.png
    我们来试试看:
    录屏2024-08-01 23.33.43.gif
    至此,我们可以相对舒服地使用 Vue 进行组件开发了。


    HTML 文件引入、Vue 集成 Vue-Router


    项目如果有不同的页面,就需要 Vue-Router 了, Vue-Router官网同样有网页直接引入的介绍:
    image.png
    我们来试一下,先在 Import Maps 添加 vue-router 的引入:
    image.png
    然后写个使用 Vue-Router 的demo: 新建两个路由组件:src/view/home.js, src/view/about.js, 在 HTML 文件中引入:
    image.png
    src/app.js作为根组件,放个 RouterLink、RouterView 组件:
    image.png
    然后我们刷新下页面,看看是否正常生效:
    录屏2024-08-03 18.23.45.gif
    很遗憾,没有生效,控制台报错了:
    image.png
    意思是声明的 vue-router 模块,没有导出我们引用到的方法 createRouter;这说明,Vue-Router 打包的默认文件,并不是默认的 ES Modules 方式,我们得找找对应的构建产物文件才行;


    这对比 Vue 的引入,Vue 引入的是构建产物中的 “esm-browser” 后缀的文件:
    image.png
    那么斗胆猜测下,Vue-Router 同样也有 esm 的构建产物,我们引入下该文件,应该就可以了。


    但是怎么知道 Vue-Router 的构建产物有哪些?难道去翻官方的构建配置吗?不用,我们找个 npm 项目,然后npm install vue-router,在 node_mudules/xxx翻看就知道了。


    我们上面正好有个 vue-demo, 使用了 Vue-Router。我们看看:
    image.png
    我们改下 Import Maps 里面 vue-router 的映射:
    image.png
    刷新下页面看看:
    录屏2024-08-03 18.43.44.gif
    还是有报错:
    image.png
    @vue/devtools-api我们并没有引入,报了这个错,斗胆猜测是 vue-router 中使用的,该模块应该是属于外部模块,我们看看网络里面响应的文件验证下:
    image.png
    确实如此,那么 Import Maps 也补充下引入这个模块,我们先翻看该模块的 npm 包看看,确定下路径:
    image.png
    Import Maps 里面引入:
    image.png
    再刷新下页面试试:
    录屏2024-08-03 18.55.41.gif
    至此,我们成功地在 HTML 文件中引入,在 Vue 中集成了 Vue-Router。


    下面我们来看 Pinia 的


    但在这之前,我们来整理下现在的目录划分吧。


    新建 src/router/index.js 文件,将路由相关的逻辑放到这里:
    image.png
    index.html引入 router:
    image.png
    然后type=module 的 script 里面的内容也可以抽离出来到单独的文件里面:
    image.png
    新建 main.js 文件,将内容搬过去并引入:
    image.png
    页面刷新下,正常运行。


    HTML 文件引入、Vue 集成 Pinia


    有了上面引入 Vue-Router 的经验,我们就知道了,引入其他的库也是相同的套路。我们去之前的脚手架工具生成的项目 vue-demo 的依赖里面翻看一下,Pinia 包的构建产物是如何的,然后在现在的 esm 项目里面引入吧:


    image.png
    image.png
    我们在项目里面使用一下 Pinia, 在main.js里面引入 Pinia:


    import { createApp, ref } from 'vue'
    import App from './src/app.js'
    import router from './src/router/index.js'
    import { createPinia } from 'pinia'

    const app = createApp(App)
    app.use(createPinia())
    app.use(router)
    .mount('#app')

    新建 src/stores/useCounterStore.js文件,填入如下内容:


    import { defineStore } from 'pinia'
    import { ref, computed } from 'vue'

    export default defineStore('counter', () => {

    const count = ref(0)
    const doubleCount = computed(() => count.value * 2)
    const increment = () => {
    count.value++
    }
    return { count, doubleCount, increment }
    })

    即如下:
    image.png
    之后我们在 src/view/home.js组件里面使用一下这个 store:


    import useCounterStore from "../stores/useCounterStore.js"
    import { storeToRefs } from 'pinia'

    export default {
    setup() {
    const store = useCounterStore()
    const { count, doubleCount } = storeToRefs(store)
    const { increment } = store
    return { count, doubleCount, increment }
    },
    template: /*html*/`<div>
    <h1>Home</h1>
    <p>{{ count }}</p>
    <p>{{ doubleCount }}</p>
    <button @click="increment">+1</button>
    </div>`

    }

    我们刷新页面看看,报错了, 缺了一个模块 vue-demi
    image.png
    我们确认一下,在响应的 Pinia 库中确实有对这模块的引入
    image.png
    那么我们也引入一下吧,我们翻看需要的库的文件路径,注意这里的 esm 模块是 .mjs 后缀文件
    image.png
    image.png
    再刷新看看:
    录屏2024-08-04 15.42.18.gif
    至此,我们就在 HTML 文件中直接引入 Vue, 集成了 Vue-Router、Pinia。


    HTML 文件引入 Axios


    接下来,我们来看看网络请求库 Axios


    网络请求, 原生的 fetch API 可以胜任,但是对于项目的网络请求,最好有统一的拦截器处理,而 Axios 已经有了一套可行的方案,所以我项目开发一般会用 Axios。本节不讲Axios封装,只介绍在原生 HTML 文件中直接引入和使用 Axios。


    要以 ESM 方式引入 Axios,我们得知道 Axios esm 模块的路径。我们在上述的工程化项目 vue-demo 中安装和查看路径
    image.png
    我们在 Import Maps 添加引入
    image.png
    我们添加 src/mock/test.json文件,里面存放JSON 数据,然后用 axios 请求试试看:
    image.png
    我们在 src/view/about.js组件里面使用一下 Axios 来获取 mock 数据,并且显示到页面上,代码如下:


    import axios from 'axios'
    import { ref } from 'vue'

    export default {
    setup() {
    const mockData = ref(null)
    axios.get('/src/mock/test.json').then(res => {
    mockData.value = res.data
    })
    return { mockData }
    },
    template: /*html*/`<div>
    <h1>About</h1>
    <pre>
    {{ mockData }}
    </pre>
    </div>`

    }

    刷新看看:
    录屏2024-08-04 16.20.30.gif
    没有问题,可以正常使用,至于 Axios 如何封装得适合项目,这里就不展开了。


    CSS 样式解决方案


    但目前为止,我们几乎没有写样式,但这种纯 ESM 项目,我们应该怎么写样式呢?


    用打包构建工具的项目,一般都有 CSS 的预构建处理工具,比如 Less, Scss等;但实际开发中,大部分就使用一下嵌套而已;


    现在最新的浏览器已经支持 CSS 嵌套了:
    image.png
    还有 CSS 模块化的兼容性也完全没问题:
    image.png
    那么此 ESM 项目我这里给一个建议的方案,读者欢迎评论区留言提供其他方案。


    新建 src/style/index.css 文件,键入如下样式:


    body {
    background-color: aqua;
    }

    index.html文件中引入该样式:
    image.png
    刷新看看是否生效
    image.png
    项目中该怎么进行组件的 CSS 样式隔离呢?这里就建议 采用 ESM 的类名样式方案咯,这里不展开讲,只给一个样式目录参考。建议如下:
    image.png
    将样式放在 src/style下面,按照组件的目录进行放置,然后在src/style/index.css引入:
    image.png
    效果如下:
    录屏2024-08-04 16.45.19.gif


    样式中,我使用了CSS模块化语法和嵌套语法,都生效了。


    HTML 文件引入、Vue 集成 Element-Plus


    最后,我们再引入组件库吧。我这里使用 Element-Plus


    官网可以看到也是支持直接引入的,要注意的是得引入其样式
    image.png
    我们在上面工程化项目 vue-demo 里面安装下 Element-Plus 的 npm 包看看 esm 文件的位置(.mjs后缀文件一般就是esm模块):
    image.png
    index.html 文件里面引入样式,在 Import Maps 里面引入 element-plus:
    image.png
    然后在 main.js 里把所有 element-plus 组件注册为全局组件并在 src/view/home.js使用下 Button 组件:
    image.png
    效果如下:
    image.png
    至此,我们在项目中集成了 Element-Plus 组件库了。


    其他优化


    以上所有的库,都可以在网络的响应里面,复制到本地,作为本地文件引入,这样加载速度更快,没有网络延迟问题。


    总结


    我们先按照 Vue 官方文档使用了常规的项目开发方式创建了一个项目。


    然后我们提出了一个想法:能否直接在 HTML文件中使用 Vue 及其全家桶?


    答案是可行的,因为几乎所有的库都提供了 ESM 的构建文件,而现今的浏览器也都支持 ESM 模块化了。


    我们也探讨和实践了 CSS 模块化 和 CSS 嵌套,用在了 demo 中作为 esm 项目的样式方案。


    最后我们在项目中集成了 Element-Plus 组件库。


    至此,我们可以点题了:无打包构建,浏览器确实能吃上 Vue 全家桶了。但这并不是说,可以在真实项目中这样使用,兼容性就不说了,还有项目的优化,一般得打包构建中做:比如 Tree Shaking、代码压缩等。但如果是一些小玩具项目,可以试试这么玩。无构建和打包,浏览器跑的代码就是你写的源码了。


    本文示例代码地址:gitee.com/GumplinGo/1…


    作者:小江大浪
    来源:juejin.cn/post/7399094428343959552
    收起阅读 »