注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

用脚本来写函数式弹窗,更快更爽

web
前言 在业务开发中,弹窗是我们常常能够遇到的开发需求,通常我们使用组件都是通过show变量来控制弹窗的开启或者隐藏。这样面对简单的业务需求的确可以满足了,但是面对深层嵌套,不同地方打开同一个弹窗便会让我们头疼。于是我想通过函数的方式来打开弹窗,通过函数参数的方...
继续阅读 »

前言


在业务开发中,弹窗是我们常常能够遇到的开发需求,通常我们使用组件都是通过show变量来控制弹窗的开启或者隐藏。这样面对简单的业务需求的确可以满足了,但是面对深层嵌套,不同地方打开同一个弹窗便会让我们头疼。于是我想通过函数的方式来打开弹窗,通过函数参数的方式来向弹窗内更新props和处理弹窗的emit事件。


iShot_2023-08-15_10.13.24.gif


<template>
<n-button @click="open">open</n-button>
</template>

<script setup lang="ts">
import { injectTestModal } from "./test-modal/use-test-modal"
const model = injectTestModal()
function open() {
model?.open({
testValue: "传给弹窗组件的值",
onConfirm({ formData }, close, endLoading) {
console.log({ ...formData })
endLoading()
close()
},
})
}
</script>


传统vue编写弹窗


通过变量来直接控制弹窗的开启和关闭。


<template>
<n-button @click="showModal = true">
来吧
</n-button>

<n-modal v-model:show="showModal" preset="dialog" title="Dialog">
<template #header>
<div>标题</div>
</template>
<div>内容</div>
<template #action>
<div>操作</div>
</template>
</n-modal>

</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'

export default defineComponent({
setup () {
return {
showModal: ref(false)
}
}
})
</script>


痛点



  • 深层次的传props让人有很大的心理负担,污染组件props

  • 要关注弹窗show变量的true,false


函数式弹窗


在主页面用Provider包裹一下


// RootPage.vue
<ModalProvider>
<ChildPage></ChildPage>
</ModalProvider>

<script setup lang="ts">
import ModalProvider from "./ModalProvider.vue"
import ChildPage from "./ChildPage.vue"
</script>


在页面内的某个子组件中,直接通过oepn方法打开弹窗


// ChidPage.vue
<template>
<n-button @click="open">open</n-button>
</template>

<script setup lang="ts">
import { injectTestModal } from "./test-modal/use-test-modal"
const model = injectTestModal()
function open() {
model?.open({
testValue: "传给弹窗组件的值",
onConfirm({ formData }, close, endLoading) {
console.log({ ...formData })
endLoading()
close()
},
})
}
</script>


优势



  • 对于使用者来说简单,没有控制show的心理负担

  • 弹窗内容和其他业务代码分离,不会污染其他组件props和结构

  • 能够充分复用同一个弹窗,通过函数参数的方式来更新弹窗内容,在不同地方打开同一个弹窗


劣势



  • 对于一些简单需求的弹窗,用这种函数式的弹窗会有些臃肿

  • 在使用函数式弹窗前需要做一些操作(Provider和Inject),会在后面提到以及解决方案。


如何使用这种函数式的弹窗


原理


通过在根页面将我们的Modal组件挂载上去,然后通过使用hook去管理这个modal组件的状态值(props,ref),然后通过provide将props和event和我们的modal组件联系起来,再在我们需要使用弹窗的地方使用inject更新props来启动弹窗。


步骤1(❌):编写Modal


这里我使用的是Naive Ui的Modal组件,按喜好选择就行。按照你的需求编写弹窗的内容。定义好props和emits,写好他们的类型申明。


// TestModal.vue
<template>
<n-modal
v-model:show="isShowModal"
preset="dialog"
@after-leave="handleClose"
>

...你的弹窗内容
</n-modal>

</template>

<script setup lang="ts">
import { ref, reactive, computed, watch } from "vue"
import { useVModel } from "@vueuse/core"
import { FormRules } from "naive-ui"
interface ModalProps {
show: boolean
testValue: string
}

// 中间区域不要修改
const props = defineProps<ModalProps>()
const formRef = ref()
const loading = ref(false)
const isShowModal = useVModel(props, "show")
// 中间区域不要修改

const rules: FormRules = []

const formData = reactive({
testValue: props.testValue,
})

const callBackData = computed(() => {
return {
formData,
}
})

watch(
() => props.show,
() => {
if (props.show) {
formData.testValue = props.testValue
} else {
formData.testValue = ""
}
}
)

const emits = defineEmits<{
(e: "update:show", value: boolean): void
(e: "close", param: typeof callBackData.value): void
(
e: "confirm",
param: typeof callBackData.value,
close: () => void,
endLoading: () => void
): void
(e: "cancel", param: typeof callBackData.value): void
}>()
function handleCancel() {
// 中间区域不要修改
emits("cancel", callBackData.value)
isShowModal.value = false
// 中间区域不要修改
}

function handleClose() {
// 中间区域不要修改
emits("close", callBackData.value)
isShowModal.value = false
// 中间区域不要修改
}

function handleConfirm() {
// 中间区域不要修改
loading.value = true
emits(
"confirm",
callBackData.value,
() => {
loading.value = false
isShowModal.value = false
},
() => {
loading.value = false
}
)
// 中间区域不要修改
}
</script>

步骤2(❌):编写hook来管理弹窗的状态


在这个文件里面,使用hook管理 TestModal 弹窗需要的props和event事件,然后我们通过ts类型体操来获取TestModal的props类型和event类型,我们向外inject一个 open 函数,这个函数可以更新 TestModal 的props和event,同时打开弹窗,他的参数有完整的类型提示,可以让使用者更加明确的使用我们的弹窗。


// use-test-modal.ts
import {
ref,
provide,
InjectionKey,
inject,
VNodeProps,
AllowedComponentProps,
reactive,
} from "vue";
import Modal from "./TestModal.vue";

/**
* 通过引入弹窗组件来获取组件的除show,update:show以外的props和emits来作为open函数的
*/

type ModalInstance = InstanceType<
typeof Modal extends abstract new (...args: any) => any ? typeof Modal : any
>["$props"];
type OpenParam = Omit<
{
readonly [K in keyof Omit<
ModalInstance,
keyof VNodeProps | keyof AllowedComponentProps
>]: ModalInstance[K];
},
"show" | "onUpdate:show"
>;

interface AnyFileChangeModal {
open: (param?: OpenParam) => Promise<void>;
}

/**
* 通过弹窗实例来获取弹窗组件内需要哪些props
*/

type AllProps = Omit<
OpenParam,
"onClose" | "onCancel" | "onConfirm" | "onUpdate:show"
> & { show: boolean };
const anyModalKey: InjectionKey<AnyFileChangeModal> = Symbol("ModalKey");
export function provideTestModal() {
const allProps: AllProps = reactive({
show: false,
} as AllProps);
const closeCallback = ref();
const cancelCallback = ref();
const confirmCallback = ref();
const handleUpdateShow = (value: boolean) => {
allProps.show = value;
};

/**
* @param param 通过函数来更新props
*/

function updateAllProps(param: OpenParam) {
const excludeKey = ["show", "onClose", "onConfirm", "onCancel"];
for (const [key, value] of Object.entries(param)) {
if (!excludeKey.includes(key)) {
allProps[key] = value;
}
}
}
function clearAllProps() {
for (const [key] of Object.entries(allProps)) {
allProps[key] = undefined;
}
}

async function open(param: OpenParam) {
clearAllProps();
updateAllProps(param);
allProps.show = true;
param.onClose && (closeCallback.value = param.onClose);
param.onConfirm && (confirmCallback.value = param.onConfirm);
param.onCancel && (cancelCallback.value = param.onCancel);
}
provide(anyModalKey, { open });
return {
allProps,
closeCallback,
confirmCallback,
cancelCallback,
handleUpdateShow,
};
}

export function injectTestModal() {
return inject(anyModalKey)
}
Ï

步骤3(❌):提供Provider


在这个文件里,我将TestModal放在了根页面,然后将hook返回的props和event绑定给TestModal


// ModalProvider.vue
<template>
<slot />

<TestModal
v-bind="allTestModalProps"
@update:show="handleTestModalUpdateShow"
@close="closeTestModalCallback"
@confirm="confirmTestModalCallback"
@cancel="cancelTestModalCallback"
/>

<!-- 新增Modal -->
</template>

<script setup lang="ts">
import TestModal from "./test-modal/TestModal.vue";
import { provideTestModal } from "./test-modal/use-test-modal";
/** 新增import */

const {
allProps: allTestModalProps,
handleUpdateShow: handleTestModalUpdateShow,
closeCallback: closeTestModalCallback,
confirmCallback: confirmTestModalCallback,
cancelCallback: cancelTestModalCallback,
} = provideTestModal();
/** 新增provide */
</script>


步骤4(❌):通过函数打开弹窗


<template>
<n-button @click="open">open</n-button>
</template>

<script setup lang="ts">
import { injectTestModal } from "./test-modal/use-test-modal"
const model = injectTestModal()
function open() {
model?.open({
testValue: "传给弹窗组件的值",
onConfirm({ formData }, close, endLoading) {
console.log({ ...formData })
endLoading()
close()
},
})
}
</script>


到这里也就结束了。如果这样写一个弹窗,确实可以达到函数式的打开弹窗,但是他实在是太繁琐了,要写这么一堆东西,不如直接修改弹窗的show来的快。如果看过上面的use-test-modal.ts的话,会发现里面有AllProps,这是为了减少方便使用脚本工具来写通用化的代码,可以大大减少人工的重复代码编写,接下来我们使用一个工具来减少这些繁琐的操作。


步骤1(✅):初始化Provider


通过使用工具生成根页面ModalProvder组件。
具体步骤:在你想放置当前业务版本的弹窗文件夹路径下使用终端执行脚本,选择initProvider


iShot_2023-08-15_15.16.14.gif


步骤2(✅):生成弹窗模板


通过继续使用脚本工具,生成弹窗组件以及hook文件。
具体步骤:在你想放该弹窗的文件夹路径下使用终端,使用脚本工具,选择genModal,然后跟着指令操作就行。比如例子中,我们生产名为test的弹窗,然后告诉脚本我们的ModalProvider组件的绝对路径,脚本帮我生产test-modal的文件夹,里面放着testModal.vueuse-test-modal.ts,这里的use-test-modal.ts文件已经是成品了,不需要你去修改,ModalProvider.vue也不需要你去修改,里面的路径关系也帮你处理好了。


iShot_2023-08-15_15.17.33.gif


步骤3(✅):修改弹窗内容


上一步操作中,脚本帮我写好了TestModal.vue组件,我们可以在他的基础上完善我们的业务需求。


步骤4(✅):调用弹窗


我们找到生成的use-test-modal.ts,里面有一个injectXXXModal的方法,我们在哪个地方用到弹窗就引用执行他,返回的对象中有open方法,通过open方法去开启弹窗并且更新弹窗props。


Demo


预览
Demo地址
里面有完整的demo代码


iShot_2023-08-15_18.32.39.gif


脚本工具


仓库地址
这个是我写的脚本工具,用来减少一些重复代码的工作。用法可看readme


总结


本文先通过比较函数式的弹窗和直接传统编写弹窗的优劣,然后引出本文的主旨(如何编写函数式弹窗),再考虑我这一版函数式弹窗的问题,最后通过脚本工具来解决这一版函数式弹窗的劣势,完整的配合脚本工具,可以极大的加快我们的工作效率。
大家不要看这么写要好几个文件夹,实际使用的时候其实就是脚本生成代码后,我们只需要去修改弹窗主题的内容。有什么疑惑或者建议评论区聊。


作者:恐怖屋
来源:juejin.cn/post/7267418473401057321
收起阅读 »

当别人因为React、Vue吵起来时,我们应该做什么

web
大家好,我卡颂。 最近尤大的一个推文引起了不小热议,大概经过是: 有人在推上夸React文档写的好,把可能的坑点都列出来 尤看到后批评道:框架应该自己处理这些坑点,而不是把他们暴露给用户 尤大在推上的发言一直比较耿直,这次又涉及到React这个老对手,关...
继续阅读 »

大家好,我卡颂。


最近尤大的一个推文引起了不小热议,大概经过是:



  1. 有人在推上夸React文档写的好,把可能的坑点都列出来

  2. 尤看到后批评道:框架应该自己处理这些坑点,而不是把他们暴露给用户



尤大在推上的发言一直比较耿直,这次又涉及到React这个老对手,关注度自然不低。


再加上国内前端自媒体的一波引导发酵,比如知乎下这个话题相关的问题中的措辞是怒喷,懂得都懂。



在这样氛围与二手信源的影响下,会给人一种大佬都亲手下场撕了的感觉,自然会引来ReactVue各自拥趸的一番激烈讨论。


年年都是一样的套路,毫无新意......


面对这样的争吵,我们应该做什么呢?


首先,回到源头本身,尤大diss的有道理么?有。


React的心智负担重么?确实重。比如useEffec这个API,你能想象文档中一个章节居然有6篇文章是教你如何正确使用useEffec的么?



造成这一现象的原因有很多,比如:



  1. Hooks的实现原理使得必须显式声明依赖

  2. 显式声明依赖无法覆盖useEffect所有场景,为此专门提出一个叫Effect Event的概念,以及一个对应的新hook —— useEffectEvent

  3. useEffect承载了太多功能,比如未来Offscreen的显隐回调(类似Vue中的Keep-Alive)也是通过useEffect实现


当我们继续往前回溯,Hooks必须显式声明依赖React更新机制决定的,而React更新机制又是React实现原理的核心。


本质来说,还是React既往的成功、庞大的社区生态让他积重难返,无法从底层重写。


这是历史必然的进程,如果Vue所有新特性都在Vue2基础上迭代(而不是完全重写的Vue3),我相信也是同样的局面。


所以,当前React的迭代方向是 —— 支持上层框架(比如Next.jsRemix),寄希望于靠这些框架的封装能力弥补React自身心智负担重的缺点。这个策略显然也是成功的。


回到这次争吵本身,尤大不知道React文档为什么要花大篇幅帮开发者避坑(以及背后反映的积重难返)么?他显然是知道的。


他如此回复是因为他所处的位置是框架作者React是他的竞争对手。设想一下,如果你的竞争对手在一些方面确实不如你,但他的用户对此的反应不是“太难用了,我要换个好用的”,而是“一定是我用的姿势不对,你快出个文档好好教教我”


面对这样的用户,换谁都得有一肚子牢骚吧~



让我们再把视角转到React的用户(也就是我们这些普通开发者)上。我们为什么选择React呢?


可能有些人是处于喜好。但大部分开发者之所以用React,完全是因为公司要求用React


React的公司多,招React的岗位多,自然选择React的开发者就多了。


那么为什么用React的公司多呢?这显然是多年前React在先发优势、社区生态两场战役取胜后得到的结果。


总结


所以,我们需要尊重两个事实:



  1. React心智负担重是事实

  2. React的公司多也是事实


两者并不矛盾,他们都是历史进程的产物。


VueReact之间的讨论,即使是从技术层面出发,最后也容易陷入“React心智负担这么重,你们还甘之如饴,你们React党是不是傻”这样的争吵中。


这显然就是忽略了历史的进程。


正确的应对方式是多关心关心自己未来的发展:



  • 如果我的重心在海外,那应该给Next.js更多关注。海外远程团队不是Next就是Nest

  • 如果我的重心在国内,国内流量都被小程序分割了。一个长远的增长点应该是鸿蒙


作者:魔术师卡颂
来源:juejin.cn/post/7321589055883427855
收起阅读 »

面试官:手写一个“发布-订阅模式”

web
发布-订阅模式又被称为观察者模式,它是定义在对象之间一对多的关系中,当一个对象发生变化,其他依赖于它的对象收到通知。在javascript的开发中,我们一般用事件模型替代发布-订阅模式。 DOM事件 document.body.addEventListener...
继续阅读 »

发布-订阅模式又被称为观察者模式,它是定义在对象之间一对多的关系中,当一个对象发生变化,其他依赖于它的对象收到通知。在javascript的开发中,我们一般用事件模型替代发布-订阅模式。


DOM事件


document.body.addEventListener('click',function(){

alert(绑定1);

},false);

document.body.click(); //模拟点击

document.body.addEventListener('click',function(){

alert(绑定2);

},false);

document.body.addEventListener('click',function(){

alert(绑定3);

},false);

document.body.click(); //模拟点击

我们可以增加更多订阅者,不会对发布者的代码造成影响。注意,标准浏览器下用dispatchEvent实现。


自定义事件


① 确定发布者。(例如售票处)


② 添加缓存列表,便于通知订阅者。(预订车票列表)


③ 发布消息。遍历缓存列表。依次触发里面存放的订阅者回调函数(遍历列表,逐个发送短信)。


另外,我们还可以在回调函数填入一些参数,例如车票的价格之类信息。


let ticketOffice = {};    //售票处

ticketOffice.clientList = []; //缓存列表,存放订阅者的回调函数

ticketOffice.on = function (fn) { //增加订阅者
this.clientList.push(fn); //订阅的消息添加进缓存列表
};

ticketOffice.emit = function () { //发布消息
for(var i = 0, fn; fn = this.clientList[i++];){
fn.apply(this, arguments); //arguments 是发布消息时带上的参数
}
}

// 下面进行简单测试:

ticketOffice.on(function(time, path){ //小刚订阅消息
console.log('时间:' + time);
console.log('路线:' + path);
});


ticketOffice.on(function(time, path){ //小强订阅消息
console.log('时间:' + time);
console.log('路线:' + path);
});

ticketOffice.emit('晚上8:00','深圳-上海');
ticketOffice.emit('晚上8:10','上海-深圳');

至此,我们实现了一个最简单发布-订阅模式。不过这里存在一些问题,我们运行代码可以看到订阅者接收到了所有发布的消息。


// 时间:晚上8:00
// 路线:深圳-上海
// 时间:晚上8:00
// 路线:深圳-上海
// 时间:晚上8:10
// 路线:上海-深圳
// 时间:晚上8:10
// 路线:上海-深圳

我们有必要加个key让订阅者只订阅 自己感兴趣的消息。改写后的代码如下:


let ticketOffice = {};    //售票处

ticketOffice.clientList = []; //缓存列表,存放订阅者的回调函数

ticketOffice.on = function (key, fn) { //增加订阅者
if (!this.clientList[key]){
this.clientList[key] = [];
}
this.clientList[key].push(fn); //订阅的消息添加进缓存列表
};

ticketOffice.emit = function () { //发布消息
let key = Array.prototype.shift.call(arguments), //取出消息类型
fns = this.clientList[key]; //取出该消息对应的回调函数集合
if (!fns || fns.length === 0) {
return false;
}

fns.forEach(fn => {
fn.apply(this, arguments) //arguments 是发布消息时带上的参数
})
}

// --------- 测试数据 --------------
ticketOffice.on('上海-深圳', function(time){ //小刚订阅消息
console.log('小刚时间:' + time);
});

ticketOffice.on('深圳-上海', function(time){ //小强订阅消息
console.log('小强时间:' + time);
});

ticketOffice.emit('深圳-上海', '晚上8:00');
ticketOffice.emit('上海-深圳', '晚上8:10');

// 小强时间:晚上8:00
// 小刚时间:晚上8:10

这样子,订阅者就可以只订阅自己感兴趣的事件了。


小强临时行程有变,不想订阅对应的消息了,我们还需要再新增一个移除订阅的方法


let ticketOffice = {};    //售票处

ticketOffice.clientList = []; //缓存列表,存放订阅者的回调函数

ticketOffice.on = function (key, fn) { //增加订阅者
if (!this.clientList[key]){
this.clientList[key] = [];
}
this.clientList[key].push(fn); //订阅的消息添加进缓存列表
};

ticketOffice.emit = function () { //发布消息
let key = Array.prototype.shift.call(arguments), //取出消息类型
fns = this.clientList[key]; //取出该消息对应的回调函数集合
if (!fns || fns.length === 0) {
return false;
}

fns.forEach(fn => {
fn.apply(this, arguments) //arguments 是发布消息时带上的参数
})
}

ticketOffice.remove = function (key, fn) {
let fns = this.clientList[key]
if (!fns) return false

if (!fn) {
fns && (fns.length = 0)
} else {
fns.forEach((cb, i) => {
if (cb === fn) {
fns.splice(i, 1)
}
})
}
}

// --------- 测试数据 --------------
const xiaoGangOn = function(time){ //小刚订阅消息
console.log('小刚时间:' + time);
}
const xiaoQiangOn = function(time){ //小强订阅消息
console.log('小强时间:' + time);
}

ticketOffice.on('上海-深圳', xiaoGangOn);
ticketOffice.on('深圳-上海', xiaoQiangOn);

ticketOffice.remove('深圳-上海', xiaoQiangOn);

ticketOffice.emit('深圳-上海', '晚上8:00');
ticketOffice.emit('上海-深圳', '晚上8:10');

// 小刚时间:晚上8:10

至此,我们实现了一个相对完善的发布-订阅模式


但是可以看到使用数组进行时间的push和remove可能绑定相同的事件,且事件remove的效率低,我们可以用Set来替换Array


let ticketOffice = {};    //售票处

ticketOffice.clientList = []; //缓存列表,存放订阅者的回调函数

ticketOffice.on = function (key, fn) { //增加订阅者
if (!this.clientList[key]){
this.clientList[key] = new Set();
}
this.clientList[key].add(fn); //订阅的消息添加进缓存列表
};

ticketOffice.emit = function () { //发布消息
let key = Array.prototype.shift.call(arguments), //取出消息类型
fns = this.clientList[key]; //取出该消息对应的回调函数集合
if (!fns || fns.length === 0) {
return false;
}

fns.forEach(fn => {
fn.apply(this, arguments) //arguments 是发布消息时带上的参数
})
}

// 移除路线的单个订阅
ticketOffice.remove = function (key, fn) {
this.clientList[key]?.delete(fn)
}
// 移除路线的所有订阅
ticketOffice.removeAll = function (key) {
delete this.clientList[key]
}

// --------- 测试数据 --------------
const xiaoGangOn = function(time){ //小刚订阅消息
console.log('小刚时间:' + time);
}
const xiaoQiangOn = function(time){ //小强订阅消息
console.log('小强时间:' + time);
}

ticketOffice.on('上海-深圳', xiaoGangOn);
ticketOffice.on('深圳-上海', xiaoQiangOn);

ticketOffice.remove('深圳-上海', xiaoQiangOn);

ticketOffice.emit('深圳-上海', '晚上8:00');
ticketOffice.emit('上海-深圳', '晚上8:10');

// 小刚时间:晚上8:10

参考资料


《JavaScript 设计模式与开发实践》


作者:dudulala
来源:juejin.cn/post/7320075000702533671
收起阅读 »

普通的文本输入框无法实现文字高亮?试试这个highlightInput吧!

web
背景 前几天在需求评审的时候,产品问我能不能把输入框也做成富文本那样,在输入一些敏感词、禁用词的时候,给它标红。我听完心里一颤,心想:好家伙,又来给我整活。本着能砍就砍的求生法则,我和产品说:输入框输入的文字都会被转成字符串,这没办法去标红呀!产品很硬气的回到...
继续阅读 »

背景


前几天在需求评审的时候,产品问我能不能把输入框也做成富文本那样,在输入一些敏感词、禁用词的时候,给它标红。我听完心里一颤,心想:好家伙,又来给我整活。本着能砍就砍的求生法则,我和产品说:输入框输入的文字都会被转成字符串,这没办法去标红呀!产品很硬气的回到:没办法,这是老板提的需求,你下去研究研究吧。行吧,老板发话说啥也没用,开干吧!


实现思路


实现标红就需要给文字加上html标签和样式,但是输入框会将html都转为字符串,既然输入框无法实现,那么我们换一种思路,通过div代替输入框来显示输入的文本,那我们是不是就可以实现文本标红了?话不多说,直接上代码(文章结尾会附上demo):


<div class="main">
<div id="shadowInput" class="highlight-shadow-input"></div>
<textarea
id="textarea"
cols="30"
rows="10"
class="highlight-input"
>
</textarea>
</div>

.main {
position: relative;
}
.highlight-shadow-input {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
padding: 8px;
border: 1px;
box-sizing: border-box;
font-size: 12px;
font-family: monospace;
overflow-y: auto;
word-break: break-all;
white-space: pre-wrap;
}
.highlight-input {
position: relative;
width: 100%;
padding: 8px;
box-sizing: border-box;
font-size: 12px;
background: rgba(0, 0, 0, 0);
-webkit-text-fill-color: transparent;
z-index: 999;
word-break: break-all;
}

实现这个功能的精髓就在于将输入框的背景和输入的文字设置为透明,然后将其层级设置在div之上,这样用户既可以在输入框中输入,而输入的文字又不会展示出来,然后将输入的文本处理后渲染到div上。


const textarea = document.getElementById("textarea");
const shadowInput = document.getElementById("shadowInput");
const sensitive = ["敏感词", "禁用词"];
textarea.oninput = (e) => {
let value = e.target.value;
sensitive.forEach((word) => {
value = value.replaceAll(
word,
`<span style="color:#e52e2e">${word}</span>`
).replaceAll("\n", "<br>");;
});
shadowInput.innerHTML = value;
};

监听输入框oninput事件,用replaceAll匹配到敏感词并转为html后渲染到shadowInput上。此外,我们还需要对输入框的滚动进行监听,因为shadowInput是固定高度的,如果用户输入的文本出现滚动条,则需要让shadowInput也滚动到对应的位置


<div><div id="shadowInput" class="highlight-shadow-input"></div></div>

textarea.onscroll = (e) => {
shadowInput.scrollTop = e.target.scrollTop;
};
// 此处输入时也需要同步是因为输入触底换行时,div的高度不会自动滚动
textarea.onkeydown = (e) => {
shadowInput.scrollTop = e.target.scrollTop;
};

最终实现效果:


至此一个简单的文本输入框实现文字高亮的功能就完成了,上述代码只是简单示例,在实际业务场景中还需要考虑xss注入、特殊字符处理、特殊字符高亮等等复杂问题。


总结


这篇文章主要给遇到有类似业务需求的同学一个参考,以及激发大家的灵感,用这种方法是不是还可以实现一些简单的富文本功能呢?例如文字加删除线、文字斜体加粗等等。有想法或有问题的小伙伴可以在评论区留言一起探讨哦!


demo



作者:宇智波一打七
来源:juejin.cn/post/7295169886177918985
收起阅读 »

多行标签超出展开折叠功能

web
前言  记录分享每一个日常开发项目中的实用小知识,不整那些虚头巴脑的框架理论与原理,之前分享过抽奖功能、签字功能等,有兴趣的可以看看本人以前的分享。  今天要分享的实用小知识是最近项目中遇到的标签相关的功能,我不知道叫啥,姑且称之为【多行标签展开隐藏】功能吧,...
继续阅读 »

前言


 记录分享每一个日常开发项目中的实用小知识,不整那些虚头巴脑的框架理论与原理,之前分享过抽奖功能、签字功能等,有兴趣的可以看看本人以前的分享。
 今天要分享的实用小知识是最近项目中遇到的标签相关的功能,我不知道叫啥,姑且称之为【多行标签展开隐藏】功能吧,类似于多行文本展开折叠功能,如果超过最大行数则显示展开隐藏按钮,如果不超过则不显示按钮。多行文本展开与折叠功能在网上有相当多的文章了,也有许多开源的封装组件,而多行标签展开隐藏的文章却比较少,刚好最近我也遇到了这个功能,所以就单独拿出来与大家分享如何实现。


出处


 【多行标签展开与隐藏】该功能我们平时可能没注意一般在哪里会有,其实最常见的就是各种APP的搜索页面的历史记录这里,下面是我从拼多多(左)和腾讯学堂小程序(右)截下来的功能样式:


多行标签案列图(pdd/txxt)


其它APP一般搜索的历史记录这里都有这个小功能,比如京东、支付宝、淘宝、抖音、快手等,可能稍有点儿不一样,有的是按钮样式,有的是只有展开没有收起功能,可能我们用过了很多年平时都没有注意到这个小功能,有想了解的可以去看一看哈。如果有一天你们需要开发一个搜索页面的话产品就很有可能出这样的一个功能,接下来我们就来看看这种功能我们该如何实现。


功能实现


我们先看实现的效果图,然后再分析如何实现,效果图如下:



【样式一】:标签容器和展开隐藏按钮分开(效果图样式一)


 标签容器和按钮分开的这种样式功能实现起来的话我个人觉得难度稍微简单一些,下面我们看看如何实现这种分开的功能。


第一种方法:通过与第一个标签左偏移值对比实现

原理:遍历每个标签然后通过与第一个标签左偏移值对比,如果有几个相同偏移值则说明有几个换行


具体实现上代码:


<div class="list-con list-con-1">
<div class="label">人工智能div>
<div class="label">人工智能与应用div>
<div class="label">行业分析与市场数据div>
<div class="label">标签标签标签标签标签标签标签标签div>
<div class="label">标签div>
<div class="label">啊啊啊div>
<div class="label">宝宝贝贝div>
<div class="label">微信div>
<div class="label">吧啊啊div>
<div class="label">哦哦哦哦哦哦哦哦div>
div>
<div class="expand expand-1">展开 ∨div>



解析:HTML布局就不用多说了,是个前端都知道该怎么搞,如果不知道趁早送外卖去吧,多说无益,把机会留给其他人。其次CSS应该也是比较简单的,注意的是有个前提需要先规定容器的最大高度,然后使用overflow超出隐藏,这样展开就直接去掉该属性,让标签自己撑开即可。JavaScript部分我这里没有使用啥框架,因为这块实现就是个简单的Demo所以就用纯原生写比较方便,这里我们先获取容器,然后获取容器的孩子节点(这里我们也可以直接通过className查询出所有标签元素),返回的是一个可遍历的变签对象,然后我们记录第一个标签的offsetLeft左偏移值,接下来遍历所有的标签元素,如果有与第一个标签相同的值则累加,最终line表示有几行,如果超过我们最大行数(demo超出2行隐藏)则显示展开隐藏按钮。


第二种方法:通过计算容器高度对比

原理:通过容器底部与标签top比较,如果有top值大于容器底部bottom则表示超出容器隐藏。


具体上代码:




解析:HTMLCSS同方法一同,不同点在于这里是通过getBoundingClientRect()方法来判断,还是遍历所有标签,不同的是如果有标签的top值大于等于了容器的bottom值,则说明了标签已超出容器,则要显示展开隐藏按钮,展开隐藏还是通过容器overflow属性来实现比较简单。


【样式二】:展开隐藏按钮和标签同级(效果图样式二)


 这种样式也是绝大部分APP产品使用的风格,不信你可以打开抖音商城或汽车之家的搜索历史,十个产品九个是这样设计的,不是这样的我倒立洗头。
 这种放在同级的就相对稍微难一点,因为要把展开隐藏按钮塞到标签的最后,如果是隐藏的话就要切割标签展示数量,那下面我就带大家看看我是是如何实现的。


方法一:通过遍历高度判断

原理:同样式一的高度判断一样,通过容器底部bottom与标签top比较,如果有top值大于容器顶部bottom则表示超出容器隐藏,不同的是如何计算标签展示的长度。有个前提是按钮和标签的的宽度要做限制,最好是一行能放一个标签和按钮。


具体实现上代码:


<div id="app3">
<div class="list-con list-con-3" :class="{'list-expand': isExpand}">
<div class="label" v-for="item in labelArr.slice(0, labelLength)">{{ item }}div>
<div class="label expand-btn" v-if="showExpandBtn" @click="changeExpand">{{ !isExpand ? '展开 ▼' : '隐藏 ▲' }}div>
div>
div>


<script>
const { createApp, nextTick } = Vue
createApp({
props: {
maxLine: {
type: Number,
default: 2
}
},
data () {
return {
labelArr: [],
isExpand: false,
showExpandBtn: false,
labelLength: 0,
hideLength: 0
}
},
mounted () {
const labels = ['人工智能', '人工智能与应用', '行业分析与市场数据', '标签标签标签标签标签标签标签', '标签A', '啊啊啊', '宝宝贝贝', '微信', '吧啊啊', '哦哦哦哦哦哦哦哦', '人工智能', '人工智能与应用']

this.labelArr = labels
this.labelLength = labels.length
nextTick(() => {
this.init()
})
},
methods: {
init () {
const listCon = document.querySelector('.list-con-3')
const labels = listCon.querySelectorAll('.label:not(.expand-btn)')
const expandBtn = listCon.querySelector('.expand-btn')

let labelIndex = 0 // 渲染到第几个
const listConBottom = listCon.getBoundingClientRect().bottom // 容器底部距视口顶部距离
for(let i = 0; i < labels.length; i++) {
const _top = labels[i].getBoundingClientRect().top
if (_top >= listConBottom ) { // 如果有标签顶部距离超过容器底部则表示超出容器隐藏
this.showExpandBtn = true
console.log('第几个索引标签停止', i)
labelIndex = i
break
} else {
this.showExpandBtn = false
}
}
if (!this.showExpandBtn) {
return
}
nextTick(() => {
const listConRect = listCon.getBoundingClientRect()
const expandBtn = listCon.querySelector('.expand-btn')
const expandBtnWidth = expandBtn.getBoundingClientRect().width
const labelMaringRight = parseInt(window.getComputedStyle(labels[0]).marginRight)
for (let i = labelIndex -1; i >= 0; i--) {
const labelRight = labels[i].getBoundingClientRect().right - listConRect.left
if (labelRight + labelMaringRight + expandBtnWidth <= listConRect.width) {
this.hideLength = i + 1
this.labelLength = this.hideLength
break
}
}
})
},
changeExpand () {
this.isExpand = !this.isExpand
console.log(this.labelLength)
if (this.isExpand) {
this.labelLength = this.labelArr.length
} else {
this.labelLength = this.hideLength
}
}
}
}).mount('#app3')
script>


解析:同级样式Demo我们使用vue来实现,HTML布局和CSS样式没有啥可说的,还是那就话,不行真就送外卖去比较合适,这里我们主要分析一下Javascript部分,还是先通过getBoundingClientRect()方法来获取容器的bottom和标签的top,通过遍历每个标签来对比是否超出容器,然后我们拿到第一个超出容器的标签序号,就是我们要截断的长度,这里是通过数组的slice()方法来截取标签长度,接下来最关建的如何把按钮拼接上去,因为标签的宽度是不定的,我们要把按钮显示在最后,我们并不确定按钮拼接到最后是不是会导致宽度不够超出,所以我们倒叙遍历标签,如果(最后一个标签的右边到容器的距离right值+标签的margin值+按钮的width)和小于容器宽度,则说明展示隐藏按钮可以直接拼接在后面,否则标签数组长度就要再减一位来判断是否满足。然后展开隐藏功能就通过切换原标签长度和截取的标签长度来完成即可。


方法二:通过与第一个标签左偏移值对比实现

原理:同样式一的方法原理,遍历每个标签然后通过与第一个标签左偏移值对比判断是否超出行数,然后长度截取同方法一一致。


直接上代码:




这里也无需多做解释了,直接看代码即可。


结尾


上面就是【多行标签展开隐藏】功能的基本实现原理,网上相关实现比较少,我也是只用了Javascript来实现,如果可以纯靠CSS实现,有更简单或更好的方法实现可以留言相互交流学。代码没有封装成组件,但是具有一些参考意义,用于生产可以自己去封装成组件使用,完整的代码在我的GitHub仓库。




作者:Liben
来源:juejin.cn/post/7251394142683742269
收起阅读 »

MyBatis实战指南(二):工作原理与基础使用详解

MyBatis是一个优秀的持久层框架,它支持定制化SQL、存储过程以及高级映射。那么,它是如何工作的呢?又如何进行基础的使用呢?本文将带你了解MyBatis的工作原理及基础使用。一、MyBatis的工作原理1.1 MyBatis的工作原理工作原理图示:1、读取...
继续阅读 »

MyBatis是一个优秀的持久层框架,它支持定制化SQL、存储过程以及高级映射。那么,它是如何工作的呢?又如何进行基础的使用呢?本文将带你了解MyBatis的工作原理及基础使用。

一、MyBatis的工作原理

1.1 MyBatis的工作原理

工作原理图示:
Description

1、读取MyBatis配置文件

mybatis-config.xml为MyBatis的全局配置文件,配置了MyBatis的运行环境等信息,例如数据库连接信息。

2、加载映射文件(SQL映射文件,一般是XXXMapper.xml)

该文件中配置了操作数据库的SQL语句,需要在MyBatis配置文件mybatis-config.xml中加载。

XXXMapper.xml可以在mybatis-config.xml文件可以加载多个映射文件,每个文件对应数据库中的一张表。

3、构造会话工厂

通过MyBatis的环境等配置信息构建会话工厂SqlSessionFactory。

4、创建会话对象

由会话工厂创建SqlSession对象,该对象中包含了执行SQL语句的所有方法。

5、Executor执行器

MyBatis底层定义了一个Executor接口来操作数据库,它将根据SqlSession传递的参数动态地生成需要执行的SQL语句,同时负责查询缓存的维护。

6、MappedStatement对象

在 Executor接口的执行方法中有一个MappedStatement类型的参数,该参数是对映射信息的封装,用于存储要映射的SQL语句的id、参数等信息。

7、输入参数映射

输入参数类型可以是Map、List等集合类型,也可以是基本数据类型和POJO类型。输入参数映射过程类似于JDBC对preparedStatement对象设置参数的过程。

8、输出结果映射

输出结果类型可以是Map、List等集合类型,也可以是基本数据类型和POJO类型。输出结果映射过程类似于JDBC对结果集的解析过程。

1.2 MyBatis架构

Description

API接口层

提供给外部使用的接口API,开发人员通过这些本地API来操纵数据库。接口层一接收到调用请求就会调用数据处理层来完成具体的数据处理。

MyBatis和数据库的交互有两种方式:使用传统的MyBatis提供的API、使用Mapper接口。

1)使用传统的MyBatis提供的API

这是传统的传递Statement Id和查询参数给SqlSession对象,使用SqlSession对象完成和数据库的交互;
Description
MyBatis提供了非常方便和简单的API,供用户实现对数据库的增删改查数据操作,以及对数据库连接信息和MyBatis自身配置信息的维护操作。
示例:

SqlSession session = sqlSessionFactory.openSession();
Category c = new Category();
c.setName("新增加的Category");
session.insert("addCategory",c);

上述使用MyBatis的方法,是创建一个和数据库打交道的SqlSession对象,然后根据Statement Id和参数来操作数据库,这种方式固然很简单和实用,但是它不符合面向对象语言的概念和面向接口编程的编程习惯。

2)使用Mapper接口

MyBatis将配置文件中的每一个<mapper>节点抽象为一个Mapper接口,而这个接口中声明的方法和跟<mapper>节点中的<select|update|delete|insert>节点项对应,

即<select|update|delete|insert>节点的id值为Mapper接口中的方法名称,parameterType值表示Mapper对应方法的入参类型,而resultMap值则对应了Mapper接口表示的返回值类型或者返回结果集的元素类型。

示例:

SqlSession session = sqlSessionFactory.openSession();
CategoryMapper mapper = session.getMapper(CategoryMapper.class);
List<Category> cs = mapper.list();
for (Category c : cs) {
System.out.println(c.getName());
}

根据MyBatis的配置规范配置后,通过SqlSession.getMapper(XXXMapper.class)方法,MyBatis会根据相应的接口声明的方法信息,通过动态代理机制生成一个Mapper实例。
Description

使用Mapper接口的某一个方法时,MyBatis会根据这个方法的方法名和参数类型,确定Statement Id,底层还是通过SqlSession.select(“statementId”,parameterObject)或者SqlSession.update(“statementId”,parameterObject)等等来实现对数据库的操作。

MyBatis引用Mapper接口这种调用方式,纯粹是为了满足面向接口编程的需要。

数据处理层

负责具体的SQL查找、SQL解析、SQL执行和执行结果映射处理等。它主要的目的是根据调用的请求完成一次数据库操作。

1)参数映射和动态SQL语句生成

动态语句生成可以说是MyBatis框架非常优雅的一个设计,MyBatis通过传入的参数值,使用OGNL表达式来动态地构造SQL语句,使得MyBatis有很强的灵活性和扩展性。
Description
参数映射指的是对于Java数据类型和JDBC数据类型之间的转换,这里包括两个过程:

  • 查询阶段,我们要将java类型的数据,转换成JDBC类型的数据,通过preparedStatement.setXXX()来设值;

  • 另一个就是对ResultSet查询结果集的JdbcType 数据转换成Java数据类型。

2)SQL语句的执行以及封装查询结果集成List< E>

动态SQL语句生成之后,MyBatis将执行SQL语句并将可能返回的结果集转换成List<E> 。

MyBatis 在对结果集的处理中,支持结果集关系一对多和多对一的转换,并且有两种支持方式,一种为嵌套查询语句的查询,还有一种是嵌套结果集的查询。

基础支撑层

负责最基础的功能支撑,包括连接管理、事务管理、配置加载和缓存处理,这些都是共用的东西,将他们抽取出来作为最基础的组件。为上层的数据处理层提供最基础的支撑。

MyBatis层次结构

Description

1.3 Executor执行器

Executor的类别

Mybatis有三种基本的Executor执行器:SimpleExecutor、ReuseExecutor和BatchExecutor。

Description

1、SimpleExecutor

每执行一次update或select,就开启一个Statement对象,用完立刻关闭Statement对象。

2、ReuseExecutor

执行update或select,以sql作为key查找Statement对象,存在就使用,不存在就创建,用完后,不关闭Statement对象,而是放置于Map<String, Statement>内,供下一次使用。简言之,就是重复使用Statement对象。

3、BatchExecutor

执行update(没有select,JDBC批处理不支持select),将所有sql都添加到批处理中(addBatch()),等待统一执行(executeBatch()),它缓存了多个Statement对象,每个Statement对象都是addBatch()完毕后,等待逐一执行executeBatch()批处理。与JDBC批处理相同。

作用范围:Executor的这些特点,都严格限制在SqlSession生命周期范围内。

你还在苦恼找不到真正免费的编程学习平台吗?可以试试云端源想!课程视频、在线书籍、在线编程、实验场景模拟、一对一咨询……你想要的全部学习资源这里都有,重点是统统免费!点这里即可查看

Executor的配置

指定Executor方式有两种:

1、在配置文件中指定

<settings>
<setting name="defaultExecutorType" value="BATCH" />
</settings>

2、在代码中指定

在获取SqlSession时设置,需要注意的时是,如果选择的是批量执行器时,需要手工提交事务(默认不传参就是SimpleExecutor)。

示例:

// 获取指定执行器的sqlSession
SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH);
// 获取批量执行器时, 需要手动提交事务
sqlSession.commit();

1.4 Mybatis是否支持延迟加载

延迟加载是什么

MyBatis中的延迟加载,也称为懒加载,是指在进行表的关联查询时,按照设置延迟规则推迟对关联对象的select查询。

例如在进行一对多查询的时候,只查询出一方,当程序中需要多方的数据时,mybatis再发出sql语句进行查询,这样子延迟加载就可以的减少数据库压力。

MyBatis 的延迟加载只是对关联对象的查询有迟延设置,对于主加载对象都是直接执行查询语句的。

假如Clazz 类中有子对象HeadTeacher。两者的关系:

public class Clazz {
private Set<HeadTeacher> headTeacher;
//...
}

是否查出关联对象的示例:

@Test
public void testClazz() {
ClazzDao clazzDao = sqlSession.getMapper(ClazzDao.class);
Clazz clazz = clazzDao.queryClazzById(1);
//只查出主对象
System.out.println(clazz.getClassName());
//需要查出关联对象
System.out.println(clazz.getHeadTeacher().size());
}

延迟加载的设置

在Mybatis中,延迟加载可以分为两种:延迟加载属性和延迟加载集合,association关联对象和collection关联集合对象的延迟加载,association指的就是一对一,collection指的就是一对多查询。

在Mybatis配置文件中,可以配置是否启用延迟加载lazyLoadingEnabled=true|false

1)延迟加载的全局设置

延迟加载默认是关闭的。如果需要打开,需要在mybatis-config.xml中修改:

<settings>

<setting name="lazyLoadingEnabled" value="true" />

<setting name="aggressiveLazyLoading" value="false"/>
</settings>

比如class班级与student学生之间是一对多关系。在加载时,可以先加载class数据,当需要使用到student数据时,我们再加载 student 的相关数据。

  • 侵入式延迟加载:指的是只要主表的任一属性加载,就会触发延迟加载,比如:class的name被加载,student信息就会被触发加载。

  • 深度延迟加载: 指的是只有关联的从表信息被加载,延迟加载才会被触发。通常,更倾向使用深度延迟加载。

2)延迟加载的局部设置

如果设置了全局加载,但是希望在某一个sql语句查询的时候不适用延时策略,可以配置局部的加载策略。

示例:

 <association
property="dept" select="com.test.dao.DeptDao.getDeptAndEmpsBySimple"
column="deptno" fetchType="eager"/>

etchType值有2种,

  • eager:立即加载;

  • lazy:延迟加载。

由于局部的加载策略的优先级高于全局的加载策略。指定属性后,将在映射中忽略全局配置参数lazyLoadingEnabled,使用属性的值。

延迟加载的原理

MyBatis使用Java动态代理来为查询对象生成一个代理对象。当访问代理对象的属性时,MyBatis会检查该属性是否需要进行延迟加载。

如果需要延迟加载,则MyBatis将再次执行SQL查询,并将查询结果填充到代理对象中。

二、MyBatis基础使用示例

1、添加MyBatis依赖

首先,我们需要在项目中添加MyBatis的依赖。如果你使用的是Maven项目,可以在pom.xml文件中添加以下依赖:

<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.6</version>
</dependency>

2、创建实体类

假设我们有一个用户表(user),我们可以创建一个对应的实体类User:

public class User {
private int id;
private String name;
private int age;
// getter和setter方法省略
}

3、创建映射文件UserMapper.xml

在MyBatis的映射文件中,我们需要定义一个与实体类对应的接口。例如,我们可以创建一个名为UserMapper的接口:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.UserMapper">
<select id="getUserById" parameterType="int" resultType="com.example.User">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>

4、创建接口UserMapper.java

接下来,我们需要创建一个与映射文件对应的接口。例如,我们可以创建一个名为UserMapper的接口:

package com.example;

import com.example.User;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;

public interface UserMapper {
@Select("SELECT * FROM user WHERE id = #{id}")
User getUserById(@Param("id") int id);
}

5、使用MyBatis进行数据库操作

最后,我们可以在业务代码中使用MyBatis进行数据库操作。例如,我们可以在一个名为UserService的类中调用UserMapper接口的方法:

public class UserService {
public User getUserById(int id) {
SqlSession sqlSession = MyBatisUtil.getSqlSession();
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.getUserById(id);
sqlSession.close();
return user;
}
}

总结:

MyBatis是一个非常强大的持久层框架,它可以帮助我们简化数据库操作,提高开发效率。在实际开发中,我们还可以使用MyBatis进行更复杂的数据库操作,如插入、更新、删除等。希望这篇文章能帮助你更好地理解和使用MyBatis。

收起阅读 »

10个让你爱不释手的一行Javascript代码

web
在这篇博客中,我们将分享 10+ 个实用的一行 JavaScript 代码,这些代码可以帮助你提高编码效率和代码简洁度。这些代码片段将涵盖各种用途,从操作数组和字符串,到更高级的概念,如异步编程和面向对象编程。 获取数组中的随机元素 使用 Math.rand...
继续阅读 »

freysteinn-g-jonsson-s94zCnADcUs-unsplash.jpg
在这篇博客中,我们将分享 10+ 个实用的一行 JavaScript 代码,这些代码可以帮助你提高编码效率和代码简洁度。这些代码片段将涵盖各种用途,从操作数组和字符串,到更高级的概念,如异步编程和面向对象编程。


获取数组中的随机元素


使用 Math.random() 函数和数组长度可以轻松获取数组中的随机元素:


const arr = [1, 2, 3, 4, 5];
const randomElement = arr[Math.floor(Math.random() * arr.length)];
console.log(randomElement);

数组扁平化


使用 reduce() 函数和 concat() 函数可以轻松实现数组扁平化:


const arr = [[1, 2], [3, 4], [5, 6]];
const flattenedArr = arr.reduce((acc, cur) => acc.concat(cur), []);
console.log(flattenedArr); // [1, 2, 3, 4, 5, 6]

对象数组根据某个属性值进行排序


const sortedArray = array.sort((a, b) => (a.property > b.property ? 1 : -1));

从数组中删除特定元素


const removedArray = array.filter((item) => item !== elementToRemove);

检查数组中是否存在重复项


const hasDuplicates = (array) => new Set(array).size !== array.length;

判断数组是否包含某个值


const hasValue = arr.includes(value);

首字母大写


const capitalized = str.charAt(0).toUpperCase() + str.slice(1);

获取随机整数


const randomInt = Math.floor(Math.random() * (max - min + 1)) + min;

获取随机字符串


const randomStr = Math.random().toString(36).substring(2, length);

使用解构和 rest 运算符交换变量的值:


let a = 1, b = 2
[b, a] = [a, b]
console.log(a, b) // 2, 1

将字符串转换为小驼峰式命名:


const str = 'hello world'
const camelCase = str.replace(/\s(.)/g, ($1) => $1.toUpperCase()).replace(/\s/g, '').replace(/^(.)/, ($1) => $1.toLowerCase())
console.log(camelCase) // "helloWorld"

计算两个日期之间的间隔


const diffInDays = (dateA, dateB) => Math.floor((dateB - dateA) / (1000 * 60 * 60 * 24));

查找日期位于一年中的第几天


const dayOfYear = (date) => Math.floor((date - new Date(date.getFullYear(), 0, 0)) / 1000 / 60 / 60 / 24);

复制内容到剪切板


const copyToClipboard = (text) => navigator.clipboard.writeText(text);

copyToClipboard("Hello World");

获取变量的类型


const getType = (variable) => Object.prototype.toString.call(variable).slice(8, -1).toLowerCase();

getType(''); // string
getType(0); // number
getType(); // undefined
getType(null); // null
getType({}); // object
getType([]); // array
getType(0); // number
getType(() => {}); // function

检测对象是否为空


const isEmptyObject = (obj) => Object.keys(obj).length === 0 && obj.constructor === Object;

系列文章



我的更多前端资讯


欢迎大家技术交流 资料分享 摸鱼 求助皆可 —链接


作者:shichuan
来源:juejin.cn/post/7230810119122190397
收起阅读 »

检测自己网站是否被嵌套在iframe下并从中跳出

web
iframe被用于将一个网页嵌套在另一个网页中,有的时候这会带来一些安全问题,这时我们就需要一些防嵌套操作了。 本文分为俩部分,一部分讲解如何检测或者禁止嵌套操作,另一部分讲解如何从嵌套中跳出。 末尾放了正在使用的完整代码,想直接用的可以拉到最后。 效果 当存...
继续阅读 »

iframe被用于将一个网页嵌套在另一个网页中,有的时候这会带来一些安全问题,这时我们就需要一些防嵌套操作了。

本文分为俩部分,一部分讲解如何检测或者禁止嵌套操作,另一部分讲解如何从嵌套中跳出。


末尾放了正在使用的完整代码,想直接用的可以拉到最后。


效果


当存在嵌套时会出现一个蒙版和窗口,提示用户点击。

点击后会在新窗口打开网站页面。


嵌套展示


嵌套检测


设置响应头


响应头中有一个名为X-Frame-Options的键,可以针对嵌套操作做限制。

它有3个可选值:


DENY:拒绝所有


SAMEORIGIN:只允许同源


ALLOW-FROM origin:指定可用的嵌套域名,新浏览器已弃用


后端检测(以PHP为例)


通过获取$_SERVER中的HTTP_REFERERHTTP_SEC_FETCH_DEST值,可以判断是否正在被iframe嵌套


// 如果不是iframe,就为空的字符串
$REFERER_URL = $_SERVER['HTTP_REFERER'];

// 资源类型,如果是iframe引用的,会是iframe
$SEC_FETCH_DEST = $_SERVER['HTTP_SEC_FETCH_DEST'];

// 默认没有被嵌套
$isInIframe = false;

if (isset($_SERVER['HTTP_REFERER'])) {
$refererUrl = parse_url($_SERVER['HTTP_REFERER']);
$refererHost = isset($refererUrl['host']) ? $refererUrl['host'] : '';

if (!empty($refererHost) && $refererHost !== $_SERVER['HTTP_HOST']) {
$isInIframe = true;
}
}

// 这里通过判断$isInIframe是否为真,来处理嵌套和未嵌套执行的动作。
if($isInIframe){
....
}

前端检测(使用JavaScript)


通过比较window.self(当前窗口对象)和window.top(顶层窗口对象)可以判断是否正在被iframe嵌套


if (window.self !== window.top) {
// 检测到嵌套时该干的事
}

从嵌套中跳出


跳出只能是前端处理,如果使用了PHP等后端检测,可以直接返回前端JavaScript代码,或者HTML的A标签设置转跳。


JavaScript直接转跳(不推荐)


不推荐是因为现在大多浏览器为了防止滥用,会阻止自动弹出新窗口。


window.open(window.location.href, '_blank');

A标签点击转跳(较为推荐)


当发生了用户交互事件,浏览器就不会阻止转跳了,所以这是个不错的方法。


href="https://www.9kr.cc" target="_blank">点击进入博客

JavaScript+A标签(最佳方法)


原理是先使用JavaScript检测是否存在嵌套,

如果存在嵌套,再使用JavaScript加载蒙版和A标签,引导用户点击。


这个方法直接查看最后一节。


正在使用的方法


也就是上一节说的JavaScript+A标签。


先给待会要显示的蒙版和A标签窗口设置样式


/* 蒙版样式 */
.overlay1 {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5); /* 半透明背景颜色 */
z-index: 9999; /* 确保蒙版位于其他元素之上 */
display: flex;
align-items: center;
justify-content: center;
}

/* 窗口样式 */
.modal1 {
background-color: #fff;
padding: 20px;
border-radius: 5px;
}

然后是检测和加载蒙版+A标签的JavaScript代码


if (window.self !== window.top) {
// 创建蒙版元素
var overlay = document.createElement('div');
overlay.className = 'overlay1';

// 创建窗口元素
var modal = document.createElement('div');
modal.className = 'modal1';

// 创建A标签元素
var link = document.createElement('a');
link.href = 'https://www.9kr.cc';
link.target = '_blank'; // 在新窗口中打开链接
link.innerText = '点击进入博客';
//link.addEventListener('click', function(event) {
// event.preventDefault(); // 阻止默认链接行为
// alert('Test');
//});

// 将A标签添加到窗口元素中
modal.appendChild(link);

// 将窗口元素添加到蒙版元素中
overlay.appendChild(modal);

// 将蒙版元素添加到body中
document.body.appendChild(overlay);
}

博客的话,只需要在主题上设置自定义CSS自定义JavaScript即可


博客后台设置




作者:Edit
来源:juejin.cn/post/7272742720841252901
收起阅读 »

原来小程序分包那么简单!

web
前言 没有理论,只有实操,用最直接的方式来了解和使用小程序分包。 文章偏向使用taro来模拟小程序分包配置,在原生小程序中也是几乎差不多的配置方式。 为什么要有小程序分包? 因为上传小程序打包以后的代码包不可以超过2M。但我们在开发小程序的时候需要加载某些依赖...
继续阅读 »

前言


没有理论,只有实操,用最直接的方式来了解和使用小程序分包。


文章偏向使用taro来模拟小程序分包配置,在原生小程序中也是几乎差不多的配置方式。


为什么要有小程序分包?


因为上传小程序打包以后的代码包不可以超过2M。但我们在开发小程序的时候需要加载某些依赖或者一下静态图片,代码包难免超过2M。所以需要小程序分包功能将小程序中所有的代码分别打到不同的代码包里去,避免小程序只能上传2M的限制


目前小程序分包大小有以下限制:



  • 整个小程序所有分包大小不超过 20M(开通虚拟支付后的小游戏不超过30M)

  • 单个分包/主包大小不能超过 2M


如何对小程序进行分包?


本质上就是,配置一下app.json(小程序)或app.config.ts(Taro)中的subpackages字段。注意,分包的这个root路径和原本的pages是同级的。


如下图


image.png


这样配置好了,最基本的分包就完成了。


如何配置多个子包?


subpackages是个数组,在下面加上一样的结构就好了。


image.png
image.png


如何判断分包是否已经生效?


打开微信开发者工具,点击右上角详情 => 基本信息 => 本地代码,展开它。出现 主包,/xxxx/就是分包生效了。


如下图


image.png


所有页面都可以打到分包里面吗?


也不是,小程序规定,Tabbar页面不可以,一定需要在主包里。否则他直接报错。


分包中的依赖资源如何分配?


我们先来了解一下小程序分包资源


 引用原则
`packageA` 无法 require `packageB` JS 文件,但可以 require 主包、`packageA` 内的 JS 文件;使用 [分包异步化](https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages/async.html) 时不受此条限制
`packageA` 无法 import `packageB` 的 template,但可以 require 主包、`packageA` 内的 template
`packageA` 无法使用 `packageB` 的资源,但可以使用主包、`packageA` 内的资源

原因: 分包是依赖主包运行的,所以主包是必然会被加载的,所以当分包引用主包的时候,主包的相关数据已经存在了,所以可以被引用。而分包不能引用其他分包的数据,也是因为加载顺序的问题。如果分包A引用分包B的数据,但分包B尚未被加载,则会出现引用不到数据的问题。


如果主包和分包同时使用了一个依赖,那么这个依赖会被打到哪里去?


会被打到主包


因为主包不能引用分包的资源,但是子包可以引用主包的资源,所以为了两个包都能引用到资源,只能打到主包中


比如以下情况
image.png


分包和主包同时使用了dayjs,那么这个依赖会被打入到主包中。


如果某一个依赖只在分包中使用呢?


如果某一个资源只在某一个分包中使用,那就会被打入到当前分包。


如果两个子包同时使用同一个资源呢?那资源会被打进哪里。


主包,因为两个子包的资源不能互相引用,所以与其给每一个子包都打入一个独立资源。小程序则会直接把资源打到主包中,这样,两个子包就都可以使用了。


分包需要担心低版本的兼容问题吗


不用


由微信后台编译来处理旧版本客户端的兼容,后台会编译两份代码包,一份是分包后代码,另外一份是整包的兼容代码。 新客户端用分包,老客户端还是用的整包,完整包会把各个 subpackage 里面的路径放到 pages 中。


独立分包


什么是独立分包


顾名思义,独立分包就是可以独立运行的分包。


举个例子,如果你的小程序启动页面是分包(普通分包)中的一个页面,那么小程序需要优先下载主包,然后再加载普通分包,因为普通分包依赖主包运行。但是如果小程序从独立分包进入进入小程序,则不需要下载主包,独立分包自己就可以运行。


普通分包所有的限制对独立分包都有效。


为什么要有独立分包,普通分包不够吗


因为独立分包不需要依赖主包,如果有作为打开小程序的的入口的必要,加载速度会比普通分包快,给客户的体验感更好。毕竟谁也不想打开一个页面等半天。


举个例子,如果小程序启动的时候是打开一个普通分包页面。则加载顺序是:加载主包 => 再加载当前分包


但如果小程序启动的时候是打开一个独立分包页面,则加载顺序是:直接加载独立分包,无需加载主包


独立分包相对于普通分包,就是省去了加载主包的时间和消耗。


独立分包如何配置


配置和普通分包一样,加一个independent属性设为true即可。


image.png


独立分包的缺点


既然独立分包可以不依赖主包,那我把每个分包都打成独立分包可以吗。


最好别那么干


理由有四点


1.独立分包因为不依赖主包,所以他不一定能获取到小程序层面的全局状态,比如getApp().也不是完全获取不到,主包被加载的时候还是可以获取到的。概率性出问题,最好别用。


2.独立分包不支持使用插件


3.小程序的公共文件不适用独立分包。比如Taro的app.less或小程序的app.wxss


上述三个,我觉的都挺麻烦的。所以不是作为入口包这种必要的情况下,确实没有使用独立分包的需求。


PS:一个小程序里可以有多个独立分包


独立分包有版本兼容问题吗


有滴,但你不用这个兼容问题直接让你报错
在低于 6.7.2 版本的微信中运行时,独立分包视为普通分包处理,不具备独立运行的特性。


所以,即使在低版本的微信中,也只是会编译成普通分包而已。


注意!!! 这里有一个可能会遇到的,就是如果你在独立分包中使用了app.wxss或者app.less这些小程序层面的公共css文件,那么在低版本(<6.7.2)进行兼容的时候,你就会发现,独立分包的页面会被这些全局的CSS影响。因为那时候独立分包被编译成了普通分包。而普通分包是适用全局公共文件的。


分包预下载


首先我们需要了解,分包是基本功能是,在下程序打包的时候不去加载分包,然后在进入当前分包页面的时候才开始下载分包。一方面目的是为了加快小程序的响应速度。另一方面的原因是避开微信小程序本身只能上传2M的限制。


这里有一个问题,就是我在首次跳转某个分包的某个页面的时候,出现短暂的白屏怎么办?(下载分包的时间+运行接口的时间+渲染视图的时间)。


后两者没法彻底避免,只能优化代码,第一个下载分包的时间可以使用分包预下载功能解决。


我们可以通过分包预下载在进入分包页面之前就开始下载分包,来减少进入分包页面的时间。


如何配置分包预下载


当前的分包预下载只能在app.config(Taro)或者app.json(原生小程序)通过preloadRule字段去配置。


preloadRule字段是一个对象,key是页面的路径,value是进行预加载的分包name或者key,__APP__代表主包


上案例


image.png


通过preloadRule字段去配置


”packageB/pages/user/index“是key


packages:["packageA"]是value


案例上的意思是当进入packageA分包的时候,开始下载分包packageB


如果要某一个分包在加载主包的就开始下载,那么就设置packages:["APP"]即可。


总结



  1. 分包是为了解决小程序超过2m无法上传的问题

  2. 分包依赖于主包,进入分包页面,主包必然需要优先被加在

  3. 主包和分包同时引用一个依赖或资源,则当前依赖或资源会被打入到主包

  4. 两个分包使用了同一个依赖或资源,则该依赖和资源会被打入到主包

  5. 某资源或依赖只在某一个分包中使用,则该资源和依赖会被打入到该分包中

  6. 独立分包的配置相对于普通分包只是多了一个independent字段,设置为true

  7. 独立分包无需依赖主包,可独立加载。

  8. 独立分包中谨慎使用全局属性,最好别用,可能获取不到

  9. 分包可以被预加载,用于解决进入分包页面时才开始加载分包导致页面可能出现的(取决于加载速度)短暂白屏的问题。


分包官方文档


分包官方分包demo-小程序版


如果您认为对您有用的话,留个赞或收藏一下吧~


image.png


作者:工边页字
来源:juejin.cn/post/7321049399281958922
收起阅读 »

现代 CSS 解决方案:文字颜色自动适配背景色!

web
在 23 年的 CSS 新特性中,有一个非常重要的功能更新 -- 相对颜色。简单而言,相对颜色的功能,让我们在 CSS 中,对颜色有了更为强大的掌控能力。其核心功能就是,让我们能够基于一个现有颜色 A,通过一定的转换规则,快速生成我们想要的颜色 B。...
继续阅读 »

在 23 年的 CSS 新特性中,有一个非常重要的功能更新 -- 相对颜色

简单而言,相对颜色的功能,让我们在 CSS 中,对颜色有了更为强大的掌控能力。

其核心功能就是,让我们能够基于一个现有颜色 A,通过一定的转换规则,快速生成我们想要的颜色 B

其功能能够涵盖:

完整的教程,你可以看这里 -- Chrome for Developers- CSS 相对颜色语法

当然,今天我们不会一个一个去过这些功能,更多的时候,我们只需要知道我们能够实现这些功能。

本文,我们将从实际实用角度出发,基于实际的案例,看看 CSS 相对颜色,能够如何解决我们的一些实际问题。

快速语法入门

首先,我们通过一张图,一个案例,快速入门 CSS 相对颜色语法:

相对颜色语法的目标是允许从另一种颜色派生颜色。

上图显示了将原始颜色 green 转换为新颜色的颜色空间后,该颜色会转换为以 r、g、b 和 alpha 变量表示的各个数字,这些数字随后会直接用作新的 rgb() 颜色的值。

举个例子:

<p> CSS Relative Color p>
p {
color: rgb(255, 0, 0);
}

实现一个 color 为红色(rgb 值为 rgb(255, 0, 0))的字体:

基于上面的相对颜色语法,我如何通过一个红色生成绿色文字呢?示意如下:

p {
--color: rgb(255, 0, 0);
color: rgb(from var(--color) calc(r - 255) calc(g + 255) b); /* result = rgb(0, 255, 0) */
}

效果如下,我们就得到绿色字体:

解释一下:

  1. 原本的红色颜色,我们把它设置为 CSS 变量 --color: rgb(255, 0, 0)
  2. 想通过红色得到绿色,对于红色的 rgb 值 rgb(255, 0, 0) 而言,需要转换成 rgb(0, 255, 0)
  3. 使用 CSS 相对颜色语法,就是 rgb(from var(--color) calc(r - 255) calc(g + 255) b)

通过这个 DEMO,我们把几个核心基础语法点学习一下:

  1. from 关键字

from 关键字,它是相对颜色的核心。它表示会将 from 关键字后的颜色定义转换为相对颜色!在 from 关键字后面,CSS 会期待一种颜色,即能够启发生成另一种颜色

  1. from 关键字 后的颜色表示,支持不同颜色表示或者是 CSS 变量

第二个关键点,from 后面通常会接一个颜色值,这个颜色值可以是任意颜色表示法,或者是一个 CSS 变量,下面的写法都是合法的:

p {
color: rgba(from #ff0000) r g b);
color: rgb(from rgb(255, 0, 0) r g b);
color: rgb(from hsl(0deg, 100%, 50%) r g b);
color: rgb(from var(--hotpink) r g b);
}
  1. 对转换后的变量使用 calc() 或其他 CSS 函数

另外一个非常重要的基础概念就是,我们可以对 (from color r g b) 后的转换变量 r g b 使用 calc() 或其他 CSS 函数。

就是我们上面的例子:

p {
--color: rgb(255, 0, 0);
color: rgb(from var(--color) calc(r - 255) calc(g + 255) b); /* result = rgb(0, 255, 0) */
}
  1. 相对颜色语法支持,各种颜色表示函数:

相对颜色的基础的使用规则就是这样,它不仅支持 rgb 颜色表示法,它支持所有的颜色表示法:

使用 CSS 相对颜色,实现统一按钮点击背景切换

通常页面上的按钮,都会有 hover/active 的颜色变化,以增强与用户的交互。

像是这样:

最常见的写法,就是我们需要在 Normal 状态、Hover 状态、Active 状态下写 3 种颜色:

p {
color: #ffcc00;
transition: .3s all;
}
/* Hover 伪类下为 B 颜色 */
p:hover {
color: #ffd21f;
}
/** Active 伪类下为 C 颜色 **/
p:active {
color: #ab8a05;
}

在之前,我们介绍过一种利用滤镜 filter: contrast() 或者 filter: brightness() 的统一解决方案,无需写多个颜色值,可以根据 Normal 状态下的色值,通过滤镜统一实现更亮、或者更暗的伪类颜色。

在今天,我们也可以利用 CSS 相对颜色来做这个事情:

div {
--bg: #fc0;
background: var(--bg);
transition: .3s all;
}

div:hover {
background: hsl(from var(--bg) h s calc(l * 1.2));
}
div:active {
background: hsl(from var(--bg) h s calc(l * 0.8));
}

我们通过 hsl 色相、饱和度、亮度颜色表示法表示颜色。实现:

  1. 在 :hover 状态下,根据背景色,将背景亮度 l 调整为原背景色的 1.2 倍
  2. 在 :avtive 状态下,根据背景色,将背景亮度 l 调整为原背景色的 0.8 倍

在实际业务中,这是一个非常有用的用法。

完整的 DEMO,你可以戳这里:CodePen Demo -- https://codepen.io/Chokcoco/pen/KKEdOeb

使用 CSS 相对颜色,实现文字颜色自适应背景

相对颜色,还有一个非常有意思的场景 -- 让文字颜色能够自适应背景颜色进行展示。

有这么一种场景,有的时候,无法确定文案的背景颜色的最终表现值(因为背景颜色的值可能是后台配置,通过接口传给前端),但是,我们又需要能够让文字在任何背景颜色下都正常展现(譬如当底色为黑色时文字应该是白色,当背景为白色时,文字应该为黑色)。

像是这样:

在不确定背景颜色的情况下,无论什么情况,文字颜色都能够适配背景的颜色。

在之前,纯 CSS 没有特别好的方案,可以利用 mix-blend-mode: difference 进行一定程度的适配:

div {
// 不确定的背景色
}
p {
color: #fff;
mix-blend-mode: difference;
}

实操过这个方案的同学都会知道,在一定情况下,前景文字颜色还是会有一点瑕疵。并且,混合模式这个方案最大的问题是会影响清晰度

有了 CSS 相对颜色后,我们有了更多的纯 CSS 方案。

利用 CSS 相对颜色,反转颜色

我们可以利用相对颜色的能力,基于背景色颜色进行反转,赋值给 color。

一种方法是将颜色转换为 RGB,然后从 1 中减去每个通道的值。

代码非常简单:

p {
/** 任意背景色 **/
--bg: #ffcc00;
background: var(--bg);

color: rgb(from var(--bg) calc(1 - r) calc(1 - g) calc(1 - b)); /** 基于背景反转颜色 **/
}

用 1 去减,而不是用 255 去,是因为此刻,会将 rgb() 表示法中的 0~255 映射到 0~1

效果如下:

配个动图,我们利用背景色的反色当 Color 颜色,适配所有背景情况:

完整的 DEMO 和代码,你可以戳这里:CodePen Demo -- CSS Relatvie Color Adapt BG

当然,这个方案还有两个问题:

  1. 如果颜色恰好是在 #808080 灰色附近,它的反色,其实还是它自己!会导致在灰色背景下,前景文字不可见;
  2. 绝大部分情况虽然可以正常展示,但是并不是非常美观好看

为了解决这两个问题,CSS 颜色规范在 CSS Color Module Level 6 又推出了一个新的规范 -- color-contrast()

利用 color-contrast(),选择高对比度颜色

color-contrast() 函数标记接收一个 color 值,并将其与其他的 color 值比较,从列表中选择最高对比度的颜色。

利用这个 CSS 颜色函数,可以完美的解决上述的问题。

我们只需要提供 #fff 白色和 #000 黑色两种可选颜色,将这两种颜色和提供的背景色进行比较,系统会自动选取对比度更高的颜色。

改造一下,上面的代码,它就变成了:

p {
/** 任意背景色 **/
--bg: #ffcc00;
background: var(--bg);

color: color-contrast(var(--bg) vs #fff, #000); /** 基于背景色,自动选择对比度更高的颜色 **/
}

这样,上面的 DEMO 最终效果就变成了:

完整的 DEMO 和代码,你可以戳这里:CodePen Demo -- CSS Relatvie Color Adapt BG

此方案的优势在于:

  1. 可以限定前景 color 颜色为固定的几个色值,以保证 UI 层面的统一及美观
  2. 满足任何情况下的背景色

当然,唯一限制这个方案的最大问题在于,当前,color-contrast 还只是一个实验室功能,未大规模被兼容。

总结一下

到今天,我们可以利用 CSS 提供的各类颜色函数,对颜色有了更为强大的掌控力。

很多交互效果,不借助 JavaScript 的运算,也能计算出我们想要的最终颜色值。本文简单的借助:

  1. 使用 CSS 相对颜色,实现统一按钮点击背景切换
  2. 使用 CSS 相对颜色,实现文字颜色自适应背景

两个案例,介绍了 CSS 相对颜色的功能。但它其实还有更为广阔的应用场景,完整的教程,你可以看这里 -- Chrome for Developers- CSS 相对颜色语法

最后

好了,本文到此结束,希望本文对你有所帮助 :)

想 Get 到最有意思的 CSS 资讯,千万不要错过我的公众号 -- iCSS前端趣闻 😄

更多精彩 CSS 技术文章汇总在我的 Github -- iCSS ,持续更新,欢迎点个 star 订阅收藏。

如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。


作者:Chokcoco
来源:juejin.cn/post/7321410822789742618
收起阅读 »

产品经理:“一个简单的复制功能也能写出bug?”

web
问题 刚入职时,遇到了一个线上 bug,用户点击复制按钮没办法复制文本,产品经理震怒,“这么简单的一个功能也能出问题?当时是谁验收的?”,因为我刚来还闲着,就把我派去解决这个问题。 我在排查问题时,发现该复制方法写在了一个自定义 hook 中(这里复制方法写在...
继续阅读 »

问题


刚入职时,遇到了一个线上 bug,用户点击复制按钮没办法复制文本,产品经理震怒,“这么简单的一个功能也能出问题?当时是谁验收的?”,因为我刚来还闲着,就把我派去解决这个问题。


我在排查问题时,发现该复制方法写在了一个自定义 hook 中(这里复制方法写在 hook 里没啥意义,但是乙方交付过来的代码好像特别喜欢把工具函数写成个 hook 来用),点进去查看就是简单的一个 navigator.clipboard.writeText()的方法,本地运行我又能复制成功。于是我怀疑是手机浏览器不支持这个 api 便去搜索了一下。


Clipboard


MDN 上的解释:


剪贴板 Clipboard APINavigator 接口添加了只读属性 clipboard,该属性返回一个可以读写剪切板内容的 Clipboard 对象。在 Web 应用中,剪切板 API 可用于实现剪切、复制、粘贴的功能。


只有在用户事先授予网站或应用对剪切板的访问许可之后,才能使用异步剪切板读写方法。许可操作必须通过取得权限 Permissions API (en-US)"clipboard-read" 和/或 "clipboard-write" 项获得。


浏览器兼容性


image.png


使用 document.execCommand() 降级处理


这里我也不清楚用户手机浏览器的版本是多少,那么这个 api 出现之前,是用的什么方法呢?总是可以 polyfill 降级处理的吧!于是我就查到了document.execCommand()这个方法:



  • document.execCommand("copy") : 复制;

  • document.execCommand("cut") : 剪切;

  • document.execCommand("paste") : 粘贴。


对比


Clipboard 的所有方法都是异步的,返回 Promise 对象,复制较大数据时不会造成页面卡顿。但是其支持的浏览器版本较新,且只允许 https 和 localhost 这些安全网络环境可以使用,限制较多。


document.execCommand() 限制较少,使用起来相对麻烦。但是 MDN 上提到该 api 已经废弃:


image.png


image.png


浏览器很可能在某个版本弃用该 api ,不过当前 2023/12/29 ,该复制 api 还是可以正常使用的。


具体代码修改


于是我修改了一下原来的 hook:


import Toast from "~@/components/Toast";

export const useCopy = () => {

const copy = async (text: string, toast?: string) => {

const fallbackCopyTextToClipboard = (text: string, toast?: string) => {
let textArea = document.createElement("textarea");
textArea.value = text;

// Avoid scrolling to bottom
textArea.style.top = "-200";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0"

document.body.appendChild(textArea);
// textArea.focus();
textArea.select();
let msg;
try {
let successful = document.execCommand("copy");
msg = successful ? toast ? toast : "复制成功" : "复制失败";
} catch (err) {
msg = "复制失败";
}
Toast.dispatch({
content: msg,
});
document.body.removeChild(textArea);
};

const copyTextToClipboard = (text: string, toast?: string) => {
if (!navigator.clipboard || !window.isSecureContext) {
fallbackCopyTextToClipboard(text, toast);
return;
}
navigator.clipboard
.writeText(text)
.then(() => {
Toast.dispatch({
content: toast ? toast : "复制成功",
});
})
.catch(() => {
fallbackCopyTextToClipboard(text, toast)
});
};
copyTextToClipboard(text, toast);
};

return copy;
};

上线近一年,这个复制方法没出现异常问题。


作者:HyaCinth
来源:juejin.cn/post/7317577665014448167
收起阅读 »

MyBatis实战指南(一):从概念到特点,助你快速上手,提升开发效率!

MyBatis是一个优秀的持久层框架,它支持定制化SQL、存储过程以及高级映射。MyBatis避免了几乎所有的JDBC代码和手动设置参数以及获取结果集的过程。大家好,今天我们要来聊聊一个在Java开发中非常实用的框架——MyBatis。你是否曾经因为数据库操作...
继续阅读 »

MyBatis是一个优秀的持久层框架,它支持定制化SQL、存储过程以及高级映射。MyBatis避免了几乎所有的JDBC代码和手动设置参数以及获取结果集的过程。

大家好,今天我们要来聊聊一个在Java开发中非常实用的框架——MyBatis。你是否曾经因为数据库操作而感到困扰?是否曾经因为SQL语句的编写而烦恼?那么,MyBatis或许就是你的救星。

接下来,让我们一起来了解一下MyBatis的概念与特点吧!

一、MyBatis基本概念

MyBatis 是一款优秀的半自动的ORM持久层框架,它支持自定义 SQL、存储过程以及高级映射。

MyBatis 免除了几乎所有的 JDBC 代码以及设置参数和获取结果集的工作。

MyBatis 可以通过简单的 XML 或注解来配置和映射原始类型、接口和 Java POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。

那么,什么是ORM?

要了解ORM,先了解下面概念:

持久化

把数据(如内存中的对象)保存到可永久保存的存储设备中。持久化的主要应用是将内存中的数据存储在关系型的数据库中,当然也可以存储在磁盘文件中、XML数据文件中等等。

持久层

即专注于实现数据持久化应用领域的某个特定系统的一个逻辑层面,将数据使用者和数据实体相关联。

ORM, 即Object-Relational Mapping(对象关系映射),它的作用是在关系型数据库和业务实体对象之间作一个映射。这样在具体的操作业务对象的时候,就不需要再去和复杂的SQL语句打交道,只需简单的操作对象的属性和方法。

Description

总结:

  • 它是一种将内存中的对象保存到关系型数据库中的技术;

  • 主要负责实体对象的持久化,封装数据库访问细节;

  • 提供了实现持久化层的另一种模式,采用映射元数据(XML)来描述对象-关系的映射细节,使得ORM中间件能在任何一个Java应用的业务逻辑层和数据库之间充当桥梁。

Java典型的ORM框架:

  • hibernate:全自动的框架,强大、复杂、笨重、学习成本较高;

  • Mybatis:半自动的框架, 必须要自己写sql;

  • JPA:JPA全称Java Persistence API、JPA通过JDK 5.0注解或XML描述对象-表的映射关系,是Java自带的框架。

二、Mybatis的作用

Mybatis是一个Java持久层框架,它主要用于简化与数据库的交互操作。Mybatis的主要作用有以下几点:

  • 将Java对象与数据库表进行映射,通过配置XML文件实现SQL语句的定义和执行,使得开发者可以专注于业务逻辑的实现而无需编写繁琐的JDBC代码。

  • 提供了灵活的SQL映射功能,可以根据需要编写动态SQL,支持复杂的查询条件和更新操作。

  • 支持事务管理,可以确保数据的一致性和完整性。

  • 提供了缓存机制,可以提高数据库查询性能。

  • 可以与Spring、Hibernate等其他框架无缝集成,方便开发者在项目中使用。

Mybatis就是帮助程序员将数据存取到数据库里面。传统的jdbc操作,有很多重复代码块比如: 数据取出时的封装, 数据库的建立连接等等,通过框架可以减少重复代码,提高开发效率 。

MyBatis 是一个半自动化的ORM框架 (Object Relationship Mapping) -->对象关系映射。

所有的事情,不用Mybatis依旧可以做到,只是用了它,会更加方便更加简单,开发更快速。

你还在苦恼找不到真正免费的编程学习平台吗?可以试试云端源想!课程视频、在线书籍、在线编程、实验场景模拟、一对一咨询……你想要的全部学习资源这里都有,重点是统统免费!点这里即可查看

三、MyBatis特点

1、定制化SQL

同为持久层框架的Hibernate,对操作数据库的支持方式较多,完全面向对象的、原生SQL的和HQL的方式。MyBatis只支持原生的SQL语句,这个“定制化”是相对Hibernate完全面向对象的操作方式的。

2、存储过程

储存过程是实现某个特定功能的一组sql语句集,是经过编译后存储在数据库中。当出现大量的事务回滚或经常出现某条语句时,使用存储过程的效率往往比批量操作要高得多。

MyBatis是支持存储过程的,可以看个例子。假设有一张表student:

create table student
(
id bigint not null,
name varchar(30),
sex char(1),
primary key (id)
);

有一个添加记录的存储过程:

create procedure pro_addStudent (IN id bigint, IN name varchar(30), IN sex char(1))
begin
insert into student values (id, name, sex);
end

此时就可以在mapper.xml文件中调用存储过程:

<!-- 调用存储过程 -->
<!-- 第一种方式,参数使用parameterType -->
<select id="findStudentById" parameterType="java.lang.Long" statementType="CALLABLE"
resultType="com.mybatis.entity.Student">
{call pro_getStudent(#{id,jdbcType=BIGINT,mode=IN})}
</select>

<parameterMap type="java.util.Map" id="studentMap">
<parameter property="id" mode="IN" jdbcType="BIGINT"/>
</parameterMap>

<!-- 调用存储过程 -->
<!-- 第二种方式,参数使用parameterMap -->
<select id="findStudentById" parameterMap="studentMap" statementType="CALLABLE"
resultType="com.mybatis.entity.Student">
{call pro_getStudent(?)}
</select>

3、高级映射

可以简单理解为支持关联查询。

4、避免了几乎所有的JDBC代码和手动设置参数以及获取结果集。

使用Mybatis时,数据库的连接配置信息,是在mybatis-config.xml文件中配置的。同时,获取查询结果的代码,也是尽量做到了简洁。以模糊查询为例,需要做两步工作:

1)首先在配置文件中写上SQL语句,示例:

 <mapper namespace="com.test.pojo">
<select id="listCategoryByName" parameterType="string" resultType="Category">
select * from category_ where name like concat('%',#{0},'%')
</select>
</mapper>

2)在Java代码中调用此语句,示例:

        List<Category> cs = session.selectList("listCategoryByName","cat");
for (Category c : cs) {
System.out.println(c.getName());
}

5、Mybatis中ORM的映射方式也是比较简单的

"resultType"参数的值指定了SQL语句返回对象的类型。示例代码:
<mapper namespace="com.test.pojo">
<select id="listCategory" resultType="Category">
select * from category_
</select>
</mapper>

四、Mybatis的适用场景

MyBatis专注于SQL本身,是一个足够灵活的DAO层解决方案。MyBatis因其简单易用、灵活高效的特点,广泛应用于各种Java项目中。

以下是一些常见的应用场景:

  • 数据查询:MyBatis可以执行复杂的SQL查询,返回Java对象或者结果集。

  • 数据插入、更新和删除:MyBatis可以执行INSERT、UPDATE和DELETE等SQL语句。

  • 存储过程和函数调用:MyBatis可以调用数据库的存储过程和函数。

  • 高级映射:MyBatis支持一对一、一对多、多对一等复杂关系的映射。

  • 懒加载:MyBatis支持懒加载,只有在真正需要数据时才会去数据库查询。

  • 缓存机制:MyBatis内置了一级缓存和二级缓存,可以提高查询效率。

为什么说Mybatis是半自动ORM映射工具

Hibernate属于全自动ORM映射工具,使用Hibernate查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取,所以它是全自动的。

而Mybatis在查询关联对象或关联集合对象时,需要手动编写sql来完成,所以,称之为半自动ORM映射工具。

MyBatis作为半自动ORM映射工具与全自动ORM工具相比,有几个主要的区别点:

1.SQL的灵活性

MyBatis作为半自动ORM映射工具,开发人员可以灵活地编写SQL语句,充分发挥数据库的特性和优势。而全自动ORM工具通常会在一定程度上限制开发人员对SQL的灵活控制。

2.映射关系的可定制性

MyBatis允许开发人员通过配置文件(或注解)自定义对象和数据库表之间的映射关系,可以满足各种复杂的映射需求。而全自动ORM工具通常根据约定和规则自动生成映射关系,对于某些特殊需求无法满足。

3.SQL的可复用性

MyBatis支持SQL的可复用性,可以将常用的SQL语句定义为独立的SQL片段,并在需要的地方进行引用。而全自动ORM工具通常将SQL语句直接与对象的属性绑定在一起,缺乏可复用性。

4.性能调优的灵活性

MyBatis作为半自动ORM映射工具,允许开发人员对SQL语句进行灵活的调优,通过手动编写SQL语句和使用高级特性进行性能优化。而全自动ORM工具通常将性能优化的控制权交给框架,开发人员无法灵活地对SQL进行调优。

MyBatis作为一种半自动ORM映射工具,相对于全自动ORM工具具有更高的灵活性和可定制性。通过灵活的SQL控制、自定义的映射关系、可复用的SQL以及灵活的性能调优,MyBatis可以满足各种复杂的映射需求和性能优化需求。

虽然MyBatis相对于全自动ORM工具需要开发人员编写更多的SQL语句,但正是由于这种半自动的特性,使得MyBatis在某些复杂场景下更加灵活和可控。

因此,我们可以说MyBatis是一种半自动ORM映射工具,与全自动的ORM工具相比,它更适用于那些对SQL灵活性和性能调优需求较高的场景。

五、Mybatis的优缺点

Mybatis有以下优点:

1.基于SQL语句编程,相当灵活

SQL写在XML里,解除sql与程序代码的耦合,便于统一管理;提供XML标签,支持编写动态SQL语句,并可重用。

2. 代码量少

与JDBC相比,减少了50%以上的代码量,消除了JDBC大量冗余的代码,不需要手动开关连接。

3.很好的与各种数据库兼容

4.数据库字段和对象之间可以有映射关系

提供映射标签,支持对象与数据库的ORM字段关系映射。

5.能够与Spring很好的集成

Mybatis有以下缺点:

1.SQL语句的编写工作量较大

尤其当字段多、关联表多时,SQL语句较复杂。

2.数据库移植性差

SQL语句依赖于数据库,不能随意更换数据库(可以通过在mybatis-config.xml配置databaseIdProvider来弥补)。

示例:

 <databaseIdProvider type="DB_VENDOR">
<property name="MySQL" value="mysql"/>
<property name="SQL Server" value="sqlserver"/>
<property name="Oracle" value="oracle"/>
</databaseIdProvider>

然后在xml文件中,就可以针对不同的数据库,写不同的sql语句。

3.字段映射标签和对象关系映射标签仅仅是对映射关系的描述,具体实现仍然依赖于sql。

示例:

public class Student{
String name;
List<Interest> interests;
}

public class Interest{
String studentId;
String name;
String direction;
}

<resultMap id="ResultMap" type="com.test.Student">
<result column="name" property="name" />
<collection property="interests" ofType="com.test.Interest">
<result column="name" property="name" />
<result column="direction" property="direction" />
</collection>
</resultMap>

在该例子中,如果查询sql中,没有关联Interest对应的表,则查询出数据映出的Student对象中,interests属性值就会为空。

4.DAO层过于简单,对象组装的工作量较大

即Mapper层Java代码过少,XxxMapper.xml文件中维护数据库字段和实体类字段的工作量较大。

5.不支持级联更新、级联删除

仍以上面的Student和Interest为例,当要更新/删除某个Student的信息时,需要在两个表进行手动更新/删除。

通过以上的介绍,相信大家对MyBatis已经有了更深入的了解。MyBatis是一个非常强大的持久层框架,它的灵活性、易用性、解耦性、高效性和全面性都使得它在Java开发中得到了广泛的应用。

收起阅读 »

啊?两个vite项目怎么共用一个端口号啊

web
问题: 最近在业务开发中遇到一个问题,问题是这样的,当前有一个主项目和一个子项目,主项目通过微前端wujie来嵌套这个子项目,其中呢为了方便项目之间进行通信,所以规定该子项目的端口号必须为5173,否则通信失败,但是这时候发现一个问题,当我启动了该子项目后: ...
继续阅读 »

问题:


最近在业务开发中遇到一个问题,问题是这样的,当前有一个主项目和一个子项目,主项目通过微前端wujie来嵌套这个子项目,其中呢为了方便项目之间进行通信,所以规定该子项目的端口号必须为5173,否则通信失败,但是这时候发现一个问题,当我启动了该子项目后:


image.png


该项目的端口号为5173,但是此时我再次通过vite的官方搭建一个react+ts+vite项目:npm create vite@latest react_demos -- --template react-ts,之后通过npm run dev启动项目,发现端口号并没有更新:


image.png


这是什么原因呢?


寻因:


查阅官方文档,我发现:


image.png


那么我主动在vite.config.ts中添加这个配置:


image.png


正常来说,会出现这个报错:


image.png


但是此时结果依然为:


image.png


我百思不得不得其解,于是再次查阅官方文档:


image.png
我寻思这也与文档描述不一致啊,于是我再次尝试,思考是不是vite版本号的问题,两个项目的版本号分别为:


image.png


image.png


我决定创建一个4版本的项目npm create vite@^4.1.4 react_demos3 -- --template react-ts


image.png


结果发现,还是有这个问题,跟版本号没有关系,于是我又耐心继续看官方文档,看到了这个配置:


image.png
我抱着试试的态度,在其中一个vite项目中添加这个配置:


image.png


发现,果然是这个配置的锅,当其中一个项目host配置为0.0.0.0时,vite不会自动尝试更新端口号


难道vite的端口监测机制与host也有关?


结果:


不甘心的我再次进行尝试,将两个项目的host都设置成:


image.png


image.png


vite会自动尝试更新端口号


原来如此,vite的端口号检测机制在对比端口号之前,会先对比host,由于我的微前端项目中设置了host,而新建的项目中没有设置host,新建的项目host默认值为localhost对比不成功,vite不会自动尝试下一个可用端口,而是共用一个端口


总结:


在遇到问题时,要多多去猜,去想各种可能,并且最重要的是去尝试各种可能,还要加上积极去翻阅官方文档,问题一定会得到解决的;哪怕不能解决,那也会在尝试中,学到很多东西


作者:进阶的鱼
来源:juejin.cn/post/7319699173740363802
收起阅读 »

终于搞懂了网盘网页是怎么唤醒本地应用了

web
写在前面 用百度网盘举例,可以通过页面打开本机的百度网盘软件,很多软件的网站页面都有这个功能。这个事情一直令我比较好奇,这次终于有空抽时间来研究研究了,本篇讲的是Windows的,mac的原理与之类似。 自定义协议 本身单凭浏览器是没有唤醒本地应用这个能力的,...
继续阅读 »

写在前面


用百度网盘举例,可以通过页面打开本机的百度网盘软件,很多软件的网站页面都有这个功能。这个事情一直令我比较好奇,这次终于有空抽时间来研究研究了,本篇讲的是Windows的,mac的原理与之类似。


自定义协议


本身单凭浏览器是没有唤醒本地应用这个能力的,不然随便一个网页都能打开你的所有应用那不就乱套了吗。但是电脑系统本身又可以支持这个能力,就是通过配置自定义协议。


举个例子,当你用浏览器打开一个本地的PDF的时候,你会发现上面是file://path/xxx.pdf,这就是系统内置的一个协议,浏览器可以调用这个协议进行文件读取。


那么与之类似的,windows本身也支持用户自定义协议来进行一些操作的,而这个协议就在注册表中进行配置。


配置自定义协议


这里我用VS Code来举例子,最终我要实现通过浏览器打开我电脑上的VS Code。


我们先编写一个注册表文件


Windows Registry Editor Version 5.00

[HKEY_CLASSES_ROOT\vscode]
@="URL:VSCode Protocol"
"URL Protocol"=""

[HKEY_CLASSES_ROOT\vscode\shell]

[HKEY_CLASSES_ROOT\vscode\shell\open]

[HKEY_CLASSES_ROOT\vscode\shell\open\command]
@=""D:\VScode\Microsoft VS Code\Code.exe" "%1""

这里我逐行解释



  1. Windows Registry Editor Version 5.00 这行表明该文件是一个 Windows 注册表编辑器文件,这是标准的头部,用于告诉 Windows 如何解析文件。

  2. [HKEY_CLASSES_ROOT\vscode] 这是一个注册表键的开始。在这里,\vscode 表示创建一个名为 vscode 的新键。

  3. @="URL:VSCode Protocol"vscode 键下,这行设置了默认值(表示为 @ ),通过 "URL:VSCode Protocol" 对这个键进行描述。

  4. "URL Protocol"="" 这行是设置一个名为 URL Protocol 的空字符串值。这是代表这个新键是一个 URI 协议。

  5. [HKEY_CLASSES_ROOT\vscode\shell] 创建一个名为 shell 的子键,这是一个固定键,代表GUI界面的处理。

  6. [HKEY_CLASSES_ROOT\vscode\shell\open]shell 下创建一个名为 open 的子键。这耶是一个固定键,open 是一个标准动作,用来执行打开操作。

  7. [HKEY_CLASSES_ROOT\vscode\shell\open\command]open 下创建一个名为 command 的子键。这是一个固定键,指定了当协议被触发时要执行命令。

  8. @=""D:\VScode\Microsoft VS Code\Code.exe" "%1""command 键下,设置默认值为 VSCode 的路径。 "%1" 是一个占位符,用于表示传递给协议的任何参数,这里并无实际用处。


写好了注册表文件后,我们将其保存为 vscode.reg,并双击执行,对话框选择是,相应的注册表信息就被创建出来了。



可以通过注册表中查看。


浏览器打开VS Code


这时,我们打开浏览器,输入 vscode://open



可以看到,就像百度网盘一样,浏览器弹出了询问对话框,然后就可以打开VS Code了。


如果想要在网页上进行打开,也简单


<script>
function openVSCode() {
window.location.href = 'vscode://open/';
}
</script>
<button onclick="openVSCode()">打开 VSCode</button>

写一个简单的JS代码即可。


写在最后


至此,终于是了解了这方面的知识。这就是说,在网盘安装的过程中,就写好了这个注册表文件,自定义了网盘的唤醒协议,才可以被识别。


而我也找到了这个注册表



原来叫baiduyunguanjia协议(不区分大小写),使用 baiduyunguanjia://open 可以打开。


作者:银空飞羽
来源:juejin.cn/post/7320513026188460067
收起阅读 »

前端无感知刷新token & 超时自动退出

web
前端无感知刷新token&超时自动退出 一、token的作用 因为http请求是无状态的,是一次性的,请求之间没有任何关系,服务端无法知道请求者的身份,所以需要鉴权,来验证当前用户是否有访问系统的权限。 以oauth2.0授权码模式为例: 每次请求资...
继续阅读 »

前端无感知刷新token&超时自动退出


一、token的作用


因为http请求是无状态的,是一次性的,请求之间没有任何关系,服务端无法知道请求者的身份,所以需要鉴权,来验证当前用户是否有访问系统的权限。


以oauth2.0授权码模式为例:


oauth2授权码模式.png


每次请求资源服务器时都会在请求头中添加 Authorization: Bearer access_token 资源服务器会先判断token是否有效,如果无效或过期则响应 401 Unauthorize。此时用户处于操作状态,应该自动刷新token保证用户的行为正常进行。


刷新token:使用refresh_token获取新的access_token,使用新的access_token重新发起失败的请求。


二、无感知刷新token方案


2.1 刷新方案


当请求出现状态码为 401 时表明token失效或过期,拦截响应,刷新token,使用新的token重新发起该请求。


如果刷新token的过程中,还有其他的请求,则应该将其他请求也保存下来,等token刷新完成,按顺序重新发起所有请求。


2.2 原生AJAX请求


2.2.1 http工厂函数


function httpFactory({ method, url, body, headers, readAs, timeout }) {
   const xhr = new XMLHttpRequest()
   xhr.open(method, url)
   xhr.timeout = isNumber(timeout) ? timeout : 1000 * 60

   if(headers){
       forEach(headers, (value, name) => value && xhr.setRequestHeader(name, value))
  }
   
   const HTTPPromise = new Promise((resolve, reject) => {
       xhr.onload = function () {
           let response;

           if (readAs === 'json') {
               try {
                   response = JSONbig.parse(this.responseText || null);
              } catch {
                   response = this.responseText || null;
              }
          } else if (readAs === 'xml') {
               response = this.responseXML
          } else {
               response = this.responseText
          }

           resolve({ status: xhr.status, response, getResponseHeader: (name) => xhr.getResponseHeader(name) })
      }

       xhr.onerror = function () {
           reject(xhr)
      }
       xhr.ontimeout = function () {
           reject({ ...xhr, isTimeout: true })
      }

       beforeSend(xhr)

       body ? xhr.send(body) : xhr.send()

       xhr.onreadystatechange = function () {
           if (xhr.status === 502) {
               reject(xhr)
          }
      }
  })

   // 允许HTTP请求中断
   HTTPPromise.abort = () => xhr.abort()

   return HTTPPromise;
}

2.2.2 无感知刷新token


// 是否正在刷新token的标记
let isRefreshing = false

// 存放因token过期而失败的请求
let requests = []

function httpRequest(config) {
   let abort
   let process = new Promise(async (resolve, reject) => {
       const request = httpFactory({...config, headers: { Authorization: 'Bearer ' + cookie.load('access_token'), ...configs.headers }})
       abort = request.abort
       
       try {                            
           const { status, response, getResponseHeader } = await request

           if(status === 401) {
               try {
                   if (!isRefreshing) {
                       isRefreshing = true
                       
                       // 刷新token
                       await refreshToken()

                       // 按顺序重新发起所有失败的请求
                       const allRequests = [() => resolve(httpRequest(config)), ...requests]
                       allRequests.forEach((cb) => cb())
                  } else {
                       // 正在刷新token,将请求暂存
                       requests = [
                           ...requests,
                          () => resolve(httpRequest(config)),
                      ]
                  }
              } catch(err) {
                   reject(err)
              } finally {
                   isRefreshing = false
                   requests = []
              }
          }                        
      } catch(ex) {
           reject(ex)
      }
  })
   
   process.abort = abort
   return process
}

// 发起请求
httpRequest({ method: 'get', url: 'http://127.0.0.1:8000/api/v1/getlist' })

2.3 Axios 无感知刷新token


// 是否正在刷新token的标记
let isRefreshing = false

let requests: ReadonlyArray<(config: any) => void> = []

// 错误响应拦截
axiosInstance.interceptors.response.use((res) => res, async (err) => {
   if (err.response && err.response.status === 401) {
       try {
           if (!isRefreshing) {
               isRefreshing = true
               // 刷新token
               const { access_token } = await refreshToken()

               if (access_token) {
                   axiosInstance.defaults.headers.common.Authorization = `Bearer ${access_token}`;

                   requests.forEach((cb) => cb(access_token))
                   requests = []

                   return axiosInstance.request({
                       ...err.config,
                       headers: {
                           ...(err.config.headers || {}),
                           Authorization: `Bearer ${access_token}`,
                      },
                  })
              }

               throw err
          }

           return new Promise((resolve) => {
               // 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
               requests = [
                   ...requests,
                  (token) => resolve(axiosInstance.request({
                       ...err.config,
                       headers: {
                           ...(err.config.headers || {}),
                           Authorization: `Bearer ${token}`,
                      },
                  })),
              ]
          })
      } catch (e) {
           isRefreshing = false
           throw err
      } finally {
           if (!requests.length) {
               isRefreshing = false
          }
      }
  } else {
       throw err
  }
})

三、长时间无操作超时自动退出


当用户登录之后,长时间不操作应该做自动退出功能,提高用户数据的安全性。


3.1 操作事件


操作事件:用户操作事件主要包含鼠标点击、移动、滚动事件和键盘事件等。


特殊事件:某些耗时的功能,比如上传、下载等。


3.2 方案


用户在登录页面之后,可以复制成多个标签,在某一个标签有操作,其他标签也不应该自动退出。所以需要标签页之间共享操作信息。这里我们使用 localStorage 来实现跨标签页共享数据。


在 localStorage 存入两个字段:


名称类型说明说明
lastActiveTimestring最后一次触发操作事件的时间戳
activeEventsstring[ ]特殊事件名称数组

当有操作事件时,将当前时间戳存入 lastActiveTime。


当有特殊事件时,将特殊事件名称存入 activeEvents ,等特殊事件结束后,将该事件移除。


设置定时器,每1分钟获取一次 localStorage 这两个字段,优先判断 activeEvents 是否为空,若不为空则更新 lastActiveTime 为当前时间,若为空,则使用当前时间减去 lastActiveTime 得到的值与规定值(假设为1h)做比较,大于 1h 则退出登录。


3.3 代码实现


const LastTimeKey = 'lastActiveTime'
const activeEventsKey = 'activeEvents'
const debounceWaitTime = 2 * 1000
const IntervalTimeOut = 1 * 60 * 1000

export const updateActivityStatus = debounce(() => {
   localStorage.set(LastTimeKey, new Date().getTime())
}, debounceWaitTime)

/**
* 页面超时未有操作事件退出登录
*/

export function timeout(keepTime = 60) {
   document.addEventListener('mousedown', updateActivityStatus)
   document.addEventListener('mouseover', updateActivityStatus)
   document.addEventListener('wheel', updateActivityStatus)
   document.addEventListener('keydown', updateActivityStatus)

   // 定时器
   let timer;

   const doTimeout = () => {
       timer && clearTimeout(timer)
       localStorage.remove(LastTimeKey)
       document.removeEventListener('mousedown', updateActivityStatus)
       document.removeEventListener('mouseover', updateActivityStatus)
       document.removeEventListener('wheel', updateActivityStatus)
       document.removeEventListener('keydown', updateActivityStatus)

       // 注销token,清空session,回到登录页
       logout()
  }

   /**
    * 重置定时器
    */

   function resetTimer() {
       localStorage.set(LastTimeKey, new Date().getTime())

       if (timer) {
           clearInterval(timer)
      }

       timer = setInterval(() => {
           const isSignin = document.cookie.includes('access_token')
           if (!isSignin) {
               doTimeout()
               return
          }

           const activeEvents = localStorage.get(activeEventsKey)
           if(!isEmpty(activeEvents)) {
               localStorage.set(LastTimeKey, new Date().getTime())
               return
          }
           
           const lastTime = Number(localStorage.get(LastTimeKey))

           if (!lastTime || Number.isNaN(lastTime)) {
               localStorage.set(LastTimeKey, new Date().getTime())
               return
          }

           const now = new Date().getTime()
           const time = now - lastTime

           if (time >= keepTime) {
               doTimeout()
          }
      }, IntervalTimeOut)
  }

   resetTimer()
}

// 上传操作
function upload() {
   const current = JSON.parse(localStorage.get(activeEventsKey))
   localStorage.set(activeEventsKey, [...current, 'upload'])
   ...
   // do upload request
   ...
   const current = JSON.parse(localStorage.get(activeEventsKey))
   localStorage.set(activeEventsKey, Array.isArray(current) ? current.filter((item) => itme !== 'upload'))
}

作者:ww_怒放
来源:juejin.cn/post/7320044522910269478
收起阅读 »

解决扫码枪因输入法中文导致的问题

web
问题 最近公司项目上遇到了扫码枪因搜狗/微软/百度/QQ等输入法在中文状态下,使用扫码枪扫码会丢失字符的问题 思考 这种情况是由于扫码枪的硬件设备,在输入的时候,是模拟用户键盘的按键来实现的字符输入的,所以会触发输入法的中文模式,并且也会触发输入法的自动联想。...
继续阅读 »

问题


最近公司项目上遇到了扫码枪因搜狗/微软/百度/QQ等输入法在中文状态下,使用扫码枪扫码会丢失字符的问题


思考


这种情况是由于扫码枪的硬件设备,在输入的时候,是模拟用户键盘的按键来实现的字符输入的,所以会触发输入法的中文模式,并且也会触发输入法的自动联想。那我们可以针对这个来想解决方案。


方案一


首先想到的第一种方案是,监听keydown的键盘事件,创建一个字符串数组,将每一个输入的字符进行比对,然后拼接字符串,并回填到输入框中,下面是代码:


function onKeydownEvent(e) {
this.code = this.code || ''
const shiftKey = e.shiftKey
const keyCode = e.code
const key = e.key
const arr = ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-']
this.nextTime = new Date().getTime()
const timeSpace = this.nextTime - this.lastTime
if (key === 'Process') { // 中文手动输入
if (this.lastTime !== 0 && timeSpace <= 30) {
for (const a of arr) {
if (keyCode === 'Key' + a) {
if (shiftKey) {
this.code += a
} else {
this.code += a.toLowerCase()
}
this.lastTime = this.nextTime
} else if (keyCode === 'Digit' + a) {
this.code += String(a)
this.lastTime = this.nextTime
}
}
if (keyCode === 'Enter' && timeSpace <= 30) {
if (String(this.code)) {
// TODO
dosomething....
}
this.code = ''
this.nextTime = 0
this.lastTime = 0
}
}
} else {
if (arr.includes(key.toUpperCase())) {
if (this.lastTime === 0 && timeSpace === this.nextTime) {
this.code = key
} else if (this.lastTime !== 0 && timeSpace <= 30) {
// 30ms以内来区分是扫码枪输入,正常手动输入时少于30ms的
this.code += key
}
this.lastTime = this.nextTime
} else if (arr.includes(key)) {
if (this.lastTime === 0 && timeSpace === this.nextTime) {
this.code = key
} else if (this.lastTime !== 0 && timeSpace <= 30) {
this.code += String(key)
}
this.lastTime = this.nextTime
} else if (keyCode === 'Enter' && timeSpace <= 30) {
if (String(this.code)) {
// TODO
dosomething()
}
this.code = ''
this.nextTime = 0
this.lastTime = 0
} else {
this.lastTime = this.nextTime
}
}
}


这种方案能解决部分问题,但是在不同的扫码枪设备,以及不同输入法的情况下,还是会出现丢失问题


方案二


使用input[type=password]来兼容不同输入的中文模式,让其只能输入英文,从而解决丢失问题


这种方案网上也有不少的参考

# 解决中文状态下扫描枪扫描错误

# input type=password 取消密码提示框


使用password密码框确实能解决不同输入法的问题,并且Focus到输入框,输入法会被强制切换为英文模式


添加autocomplete="off"autocomplete="new-password"属性


官方文档:
# 如何关闭表单自动填充


但是在Chromium内核的浏览器,不支持autocomplete="off",并且还是会出现这种自动补全提示:


image.png


上面的属性并没有解决浏览器会出现密码补全框,并且在输入字符后,浏览器还会在右上角弹窗提示是否保存:


image.png


先解决密码补全框,这里我想到了一个属性readonly,input原生属性。input[type=password]readonly
时,是不会有密码补全的提示,并且也不会弹窗提示密码保存。


那好,我们就可以在输入前以及输入完成后,将input[type=password]立即设置成readonly


但是需要考虑下面几种情况:



  • 获取焦点/失去焦点时

  • 当前输入框已focus时,再次鼠标点击输入框

  • 扫码枪输出完成最后,输入Enter键时,如果清空输入框,这时候也会显示自动补全

  • 清空输入框时

  • 切换离开页面时


这几种情况都需要处理,将输入框变成readonly


我用vue+element-ui实现了一份,贴上代码:


<template>
<div class="scanner-input">
<input class="input-password" :name="$attrs.name || 'one-time-code'" type="password" autocomplete="off" aria-autocomplete="inline" :value="$attrs.value" readonly @input="onPasswordInput">
<!-- <el-input ref="scannerInput" v-bind="$attrs" v-on="$listeners" @input="onInput"> -->
<el-input ref="scannerInput" :class="{ 'input-text': true, 'input-text-focus': isFocus }" v-bind="$attrs" v-on="$listeners">
<template v-for="(_, name) in $slots" v-slot:[name]>
<slot :name="name"></slot>
</template>
<!-- <slot slot="suffix" name="suffix"></slot> -->
</el-input>
</div>
</template>

<script>
export default {
name: 'WispathScannerInput',
data() {
return {
isFocus: false
}
},
beforeDestroy() {
this.$el.firstElementChild.setAttribute('readonly', true)
this.$el.firstElementChild.removeEventListener('focus', this.onPasswordFocus)
this.$el.firstElementChild.removeEventListener('blur', this.onPasswordBlur)
this.$el.firstElementChild.removeEventListener('click', this.onPasswordClick)
this.$el.firstElementChild.removeEventListener('mousedown', this.onPasswordMouseDown)
this.$el.firstElementChild.removeEventListener('keydown', this.oPasswordKeyDown)
},
mounted() {
this.$el.firstElementChild.addEventListener('focus', this.onPasswordFocus)
this.$el.firstElementChild.addEventListener('blur', this.onPasswordBlur)
this.$el.firstElementChild.addEventListener('click', this.onPasswordClick)
this.$el.firstElementChild.addEventListener('mousedown', this.onPasswordMouseDown)
this.$el.firstElementChild.addEventListener('keydown', this.oPasswordKeyDown)

const entries = Object.entries(this.$refs.scannerInput)
// 解决ref问题
for (const [key, value] of entries) {
if (typeof value === 'function') {
this[key] = value
}
}
this['focus'] = this.$el.firstElementChild.focus.bind(this.$el.firstElementChild)
},
methods: {
onPasswordInput(ev) {
this.$emit('input', ev.target.value)
if (ev.target.value === '') {
this.$el.firstElementChild.setAttribute('readonly', true)
setTimeout(() => {
this.$el.firstElementChild.removeAttribute('readonly')
})
}
},
onPasswordFocus(ev) {
this.isFocus = true
setTimeout(() => {
this.$el.firstElementChild.removeAttribute('readonly')
})
},
onPasswordBlur() {
this.isFocus = false
this.$el.firstElementChild.setAttribute('readonly', true)
},
// 鼠标点击输入框一瞬间,禁用输入框
onPasswordMouseDown() {
this.$el.firstElementChild.setAttribute('readonly', true)
},
oPasswordKeyDown(ev) {
// 判断enter键
if (ev.key === 'Enter') {
this.$el.firstElementChild.setAttribute('readonly', true)
setTimeout(() => {
this.$el.firstElementChild.removeAttribute('readonly')
})
}
},
// 点击之后,延迟200ms后放开readonly,让输入框可以输入
onPasswordClick() {
if (this.isFocus) {
this.$el.firstElementChild.setAttribute('readonly', true)
setTimeout(() => {
this.$el.firstElementChild.removeAttribute('readonly')
}, 200)
}
},
onInput(_value) {
this.$emit('input', _value)
},
getList(value) {
this.$emit('input', value)
}
// onChange(_value) {
// this.$emit('change', _value)
// }
}
}
</script>

<style lang="scss" scoped>
.scanner-input {
position: relative;
height: 36px;
width: 100%;
display: inline-block;
.input-password {
width: 100%;
height: 100%;
border: none;
outline: none;
padding: 0 16px;
font-size: 14px;
letter-spacing: 3px;
background: transparent;
color: transparent;
// caret-color: #484848;
}
.input-text {
font-size: 14px;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
pointer-events: none;
background-color: transparent;
::v-deep .el-input__inner {
// background-color: transparent;
padding: 0 16px;
width: 100%;
height: 100%;
}
}

.input-text-focus {
::v-deep .el-input__inner {
outline: none;
border-color: #1c7af4;
}
}
}
</style>


至此,可以保证input[type=password]不会再有密码补全提示,并且也不会再切换页面时,会弹出密码保存弹窗。
但是有一个缺点,就是无法完美显示光标。如果用户手动输入和删除,使用起来会有一定的影响。


我想到过可以使用模拟光标,暂时不知道可行性。有哪位同学知道怎么解决的话,可以私信我,非常感谢


作者:香脆又可口
来源:juejin.cn/post/7265666505102524475
收起阅读 »

年会了,公司想要一个离线PC抽奖应用

web
年会了,公司想要一个离线PC抽奖应用 背景 公司年会需要一个能够支撑60000+人的抽奖程序,原本通过找 网页的开源项目 再定制化实现了效果,并成功运行再周年庆上;但是现在又到年会了,领导要求要能够在任何地方、任何人只要有一台电脑就能简单方便的定制自己的PC...
继续阅读 »

年会了,公司想要一个离线PC抽奖应用


封面截图.png


背景


公司年会需要一个能够支撑60000+人的抽奖程序,原本通过找 网页的开源项目 再定制化实现了效果,并成功运行再周年庆上;但是现在又到年会了,领导要求要能够在任何地方、任何人只要有一台电脑就能简单方便的定制自己的PC抽奖应用,所有就有了这么一个主题。


程序需求


以下是领导从其他地方复制粘贴过来的,就是想实现类似的效果而已。



  • 1、支持数字、字母、手机号、姓名部门+姓名、编号、身-份-证号等任意组合的抽奖形式。

  • 2、支持名单粘贴功能,从EXCEL、WORD、TXT等任何可以复制的地方复制名单数据内容,粘贴至抽奖软件中作为名单使用,比导入更方便。

  • 3、支持标题、副标题、奖项提示信息、奖品图片等都可以通过拖拽更改位置。

  • 4、支持内定指定中奖者。

  • 5、支持万人抽奖,非常流畅,中奖机率一致,保证公平性。

  • 6、支持中奖不重复,软件自动排除已中奖人员,每人只有一次中奖机会不会出现重复中奖。

  • 7、支持临时追加奖项、补奖等功能支持自定义公司名称、自定义标题。

  • 8、背景图片,音乐等。

  • 9、支持抽奖过程会自动备份中奖名单(不用担心断电没保存中奖名单)。

  • 10、支持任意添加奖项、标题文字奖项名额,自由设置每次抽奖人数设置不同的字体大小和列数。

  • 11、支持空格或回车键抽奖。

  • 12、支持临时增加摇号/抽奖名单,临时删掉不在场人员名单。


目前未实现的效果


有几个还没实现的



  1. 关于人员信息的任意组合抽奖形式,这边只固定了上传模板的表头,需要组合只能通过改excel的内容。

  2. 对于临时不在场名单,目前只能通过改excel表再上传才能达到效果。


技术选型


由于给的时间不多,能有现成的最好;最终选择下面的开源项目进行集成和修改。


说明:由于之前没看到有electron-vite-vue这个项目,所有自己粗略了用vue3+vite+electron开发了 抽奖程序 , 所以现在就是迁移项目的说明。


github开源项目



根据仓库的说明运行起来


动画.gif


修改web端代码并集成到electron


I 拆分页面


组件说明拼图.png


II 补充组件


​ 本人根据自己想法加了背景图片、奖品展示、操作按钮区、展示全部中奖人员名单这几个组件以及另外9个弹窗设置组件。


III 页面目录结构


目录结构.png

IV 最后就是对开源的网页抽奖项目进行大量的修改了,这里就不详细说了;因为变化太多了,一时半会想不起来改了什么。


迁移项目


I 迁移静态资源


静态资源.png


​ 关于包资源说明,这边因为要做离线的软件,所以我把固定要使用的包保存到本地了;


1. 引入到index.html中

引入资源.png


2. 引入图片静态资源

功能代码调整.png


II 迁移electron代码


说明:由于我之前写的一版代码是用js而不是ts,如果一下子全改为ts需要一些时间;所以嫌麻烦,我直接引用js文件了,后期有时间可以再优化一下。


功能代码调整.png




  1. 这时候先运行一下,看下有没有问题



​ 问题一:


问题一.png


​ 这个是因为 我之前的项目一直是用require 引入的;所以要把里面用到require都改为import引入方式;(在preload.ts里面不能用ESM导入的形式,会报语法错误,要用回require导入)


​ 问题二:


问题二.png


​ __dirname不是ESM默认的变量;改为


import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url))

III 迁移前端代码



  • 目录说明


前端功能代码.png



  • 然后一顿复制粘贴,运行,最后报错;


问题三.png
按提示来改 如下:


修改1.png



  • 问题2:资源报错
    资源报错.png
    修复:


资源变化.png



  • 接下来运行看下是否有问题;
    抽奖运行动画.gif
    ​ 运行成功

  • 下一步试一下功能


功能执行动画.gif
​ 功能报错了



  • 看后台错误打印并修复问题
    保存位置错误.png
    修改:
    路径保存源头.png

  • 再次尝试功能 - 成功
    功能执行动画2.gif


IV 一个流程下来


待使用-删除帧后-运行抽奖一个流程动画-gif.gif


打包安装运行


I 运行“npm run build”之后 报错了


打包-js报错.png
这里再次说明一下;由于本人懒得把原本js文件的代码 改为ts;要快速迁移项目 所以直接使用了js;导致打包报错了,所以需要再 tsconfig.json配置一下才行:


  "compilerOptions": {
"allowJs": true // 把这段加上去
},

II 图标和应用名称错误


default Electron icon is used  reason=application icon is not set
building block map blockMapFile=release\28.0.0\YourAppName-Windows-28.0.0-Setup.exe.blockmap

找到打包的配置文件(electron-builder.json5)进行修改:


1. 更改应用名称
"productName": "抽奖程序",

2. 添加icon图标
"win": {
"icon": "electron/controller/data/img/lottery_icon.ico", // ico保存的位置
},

III 打包后运行;资源路径报错了


打包后资源报错.png
打包后资源路径查询不到.png
由于上面的原因,需要把程序涉及读写的文件目录暴露出来;


1. 在构建配置中加入如下配置,将应用要读写的文件目录暴露出来
"extraResources": [
{
"from": "electron/assets",
"to": "assets"
}
],

剩下的就是要重新调整打包后的代码路径了,保证能够找到读写路径;
路径查找纠正.png


最后打包成功,运行项目


删除帧后-一个完整的流程-gif.gif


总结: 主打的要快速实现,所以这个离线pc抽奖程序还有很多问题,希望大家多多包容;


最后附上github地址:github.com/programbao/…
欢迎大家使用


作者:宝programbao
来源:juejin.cn/post/7319795736153210895
收起阅读 »

前端服务框架调研:Next.js、Nuxt.js、Nest.js、Fastify

web
概述 这次 Node.js 服务框架的调研将着点于各框架功能、请求流程的组织和介入方式,以对前端 Node.js 服务设计和对智联 Ada 架构改进提供参考,不过多关注具体实现。 最终选取了以下具有代表性的框架: Next.js、Nuxt.js:它们是分别与...
继续阅读 »

概述


这次 Node.js 服务框架的调研将着点于各框架功能、请求流程的组织和介入方式,以对前端 Node.js 服务设计和对智联 Ada 架构改进提供参考,不过多关注具体实现。


最终选取了以下具有代表性的框架:



  • Next.js、Nuxt.js:它们是分别与特定前端技术 React、Vue 绑定的前端应用开发框架,有一定的相似性,可以放在一起进行调研对比。

  • Nest.js:是“Angular 的服务端实现”,基于装饰器。可以使用任何兼容的 http 提供程序,如 Express、Fastify 替换底层内核。可用于 http、rpc、graphql 服务,对提供更多样的服务能力有一定参考价值。

  • Fastify:一个使用插件模式组织代码且支持并基于 schema 做了运行效率提升的比较纯粹的偏底层的 web 框架。


Next.js、Nuxt.js


这两个框架的重心都在 Web 部分,对 UI 呈现部分的代码的组织方式、服务器端渲染功能等提供了完善的支持。



  • Next.js:React Web 应用框架,调研版本为 12.0.x。

  • Nuxt.js:Vue Web 应用框架,调研版本为 2.15.x。


功能


首先是路由部分:



  • 页面路由:

    • 相同的是两者都遵循文件即路由的设计。默认以 pages 文件夹为入口,生成对应的路由结构,文件夹内的所有文件都会被当做路由入口文件,支持多层级,会根据层级生成路由地址。同时如果文件名为 index 则会被省略,即 /pages/users 和 /pages/users/index 文件对应的访问地址都是 users。

    • 不同的是,根据依赖的前端框架的不同,生成的路由配置和实现不同:

      • Next.js:由于 React 没有官方的路由实现,Next.js 做了自己的路由实现。

      • Nuxt.js:基于 vue-router,在编译时会生成 vue-router 结构的路由配置,同时也支持子路由,路由文件同名的文件夹下的文件会变成子路由,如 article.js,article/a.js,article/b.js,a 和 b 就是 article 的子路由,可配合 组件进行子路由渲染。





  • api 路由:

    • Next.js:在 9.x 版本之后添加了此功能的支持,在 pages/api/ 文件夹下(为什么放在pages文件夹下有设计上的历史包袱)的文件会作为 api 生效,不会进入 React 前端路由中。命名规则相同,pages/api/article/[id].js -> /api/article/123。其文件导出模块与页面路由导出不同,但不是重点。

    • Nuxt.js:官方未提供支持,但是有其他实现途径,如使用框架的 serverMiddleware 能力。



  • 动态路由:两者都支持动态路由访问,但是命名规则不同:

    • Next.js:使用中括号命名,/pages/article/[id].js -> /pages/article/123。

    • Nuxt.js:使用下划线命名,/pages/article/_id.js -> /pages/article/123。



  • 路由加载:两者都内建提供了 link 类型组件(LinkNuxtLink),当使用这个组件替代 标签进行路由跳转时,组件会检测链接是否命中路由,如果命中,则组件出现在视口后会触发对对应路由的 js 等资源的加载,并且点击跳转时使用路由跳转,不会重新加载页面,也不需要再等待获取渲染所需 js 等资源文件。

  • 出错兜底:两者都提供了错误码响应的兜底跳转,只要 pages 文件夹下提供了 http 错误码命名的页面路由,当其他路由发生响应错误时,就会跳转到到错误码路由页面。


在根据文件结构生成路由配置之后,我们来看下在代码组织方式上的区别:



  • 路由组件:两者没有区别,都是使用默认导出组件的方式决定路由渲染内容,React 导出 React 组件,Vue 导出 Vue 组件:

    • Next.js:一个普普通通的 React 组件:
      export default function About() {
      return <div>About usdiv>
      }


    • Nuxt.js:一个普普通通的 Vue 组件:







在编译构建方面,两者都是基于 webpack 搭建的编译流程,并在配置文件中通过函数参数的方式暴露了 webpack 配置对象,未做什么限制。其他值得注意的一点是 Next.js 在 v12.x.x 版本中将代码压缩代码和与原本的 babel 转译换为了 swc,这是一个使用 Rust 开发的更快的编译工具,在前端构建方面,还有一些其他非基于 JavaScript 实现的工具,如 ESbuild。


在扩展框架能力方面,Next.js 直接提供了较丰富的服务能力,Nuxt.js 则设计了模块和插件系统来进行扩展。


Nest.js


Nest.js 是“Angular 的服务端实现”,基于装饰器。Nest.js 与其他前端服务框架或库的设计思路完全不同。我们通过查看请求生命周期中的几个节点的用法来体验下 Nest.js 的设计方式。


先来看下 Nest.js 完整的的生命周期:



  1. 收到请求

  2. 中间件

    1. 全局绑定的中间件

    2. 路径中指定的 Module 绑定的中间件



  3. 守卫

    1. 全局守卫

    2. Controller 守卫

    3. Route 守卫



  4. 拦截器(Controller 之前)

    1. 全局

    2. Controller 拦截器

    3. Route 拦截器



  5. 管道

    1. 全局管道

    2. Controller 管道

    3. Route 管道

    4. Route 参数管道



  6. Controller(方法处理器)

  7. 服务

  8. 拦截器(Controller 之后)

    1. Router 拦截器

    2. Controller 拦截器

    3. 全局拦截器



  9. 异常过滤器

    1. 路由

    2. 控制器

    3. 全局



  10. 服务器响应


可以看到根据功能特点拆分的比较细,其中拦截器在 Controller 前后都有,与 Koa 洋葱圈模型类似。


功能设计


首先看下路由部分,即最中心的 Controller:



  • 路径:使用装饰器装饰 @Controller 和 @GET 等装饰 Controller 类,来定义路由解析规则。如:
    import { Controller, Get, Post } from '@nestjs/common'

    @Controller('cats')
    export class CatsController {
    @Post()
    create(): string {
    return 'This action adds a new cat'
    }

    @Get('sub')
    findAll(): string {
    return 'This action returns all cats'
    }
    }

    定义了 /cats post 请求和 /cats/sub get 请求的处理函数。

  • 响应:状态码、响应头等都可以通过装饰器设置。当然也可以直接写。如:
    @HttpCode(204)
    @Header('Cache-Control', 'none')
    create(response: Response) {
    // 或 response.setHeader('Cache-Control', 'none')
    return 'This action adds a new cat'
    }


  • 参数解析:
    @Post()
    async create(@Body() createCatDto: CreateCatDto) {
    return 'This action adds a new cat'
    }


  • 请求处理的其他能力方式类似。


再来看看生命周期中其中几种其他的处理能力:



  • 中间件:声明式的注册方法:
    @Module({})
    export class AppModule implements NestModule {
    configure(consumer: MiddlewareConsumer) {
    consumer
    // 应用 cors、LoggerMiddleware 于 cats 路由 GET 方法
    .apply(LoggerMiddleware)
    .forRoutes({ path: 'cats', method: RequestMethod.GET })
    }
    }


  • 异常过滤器(在特定范围捕获特定异常并处理),可作用于单个路由,整个控制器或全局:
    // 程序需要抛出特定的类型错误
    throw new HttpException('Forbidden', HttpStatus.FORBIDDEN)

    // 定义
    @Catch(HttpException)
    export class HttpExceptionFilter implements ExceptionFilter {
    catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const response = ctx.getResponse<Response>()
    const request = ctx.getRequest<Request>()
    const status = exception.getStatus()

    response
    .status(status)
    .json({
    statusCode: status,
    timestamp: new Date().toISOString(),
    path: request.url,
    })
    }
    }
    // 使用,此时 ForbiddenException 错误就会被 HttpExceptionFilter 捕获进入 HttpExceptionFilter 处理流程
    @Post()
    @UseFilters(new HttpExceptionFilter())
    async create() {
    throw new ForbiddenException()
    }


  • 守卫:返回 boolean 值,会根据返回值决定是否继续执行后续声明周期:
    // 声明时需要使用 @Injectable 装饰且实现 CanActivate 并返回 boolean 值
    @Injectable()
    export class AuthGuard implements CanActivate {
    canActivate(context: ExecutionContext): boolean {
    return validateRequest(context);
    }
    }

    // 使用时装饰 controller、handler 或全局注册
    @UseGuards(new AuthGuard())
    async create() {
    return 'This action adds a new cat'
    }


  • 管道(更侧重对参数的处理,可以理解为 controller 逻辑的一部分,更声明式):

    1. 校验:参数类型校验,在使用 TypeScript 开发的程序中的运行时进行参数类型校验。

    2. 转化:参数类型的转化,或者由原始参数求取二级参数,供 controllers 使用:


    @Get(':id')
    findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
    // 使用 id param 通过 UserByIdPipe 读取到 UserEntity
    return userEntity
    }



我们再来简单的看下 Nest.js 对不同应用类型和不同 http 提供服务是怎样做适配的:



  • 不同应用类型:Nest.js 支持 Http、GraphQL、Websocket 应用,在大部分情况下,在这些类型的应用中生命周期的功能是一致的,所以 Nest.js 提供了上下文类 ArgumentsHostExecutionContext,如使用 host.switchToRpc()host.switchToHttp() 来处理这一差异,保障生命周期函数的入参一致。

  • 不同的 http 提供服务则是使用不同的适配器,Nest.js 的默认内核是 Express,但是官方提供了 FastifyAdapter 适配器用于切换到 Fastify。


Fastify


有这么一个框架依靠数据结构和类型做了不同的事情,就是 Fastify。它的官方说明的特点就是“快”,它提升速度的实现是我们关注的重点。


我们先来看看开发示例:


const routes = require('./routes')
const fastify = require('fastify')({
logger: true
})

fastify.register(tokens)

fastify.register(routes)

fastify.listen(3000, function (err, address) {
if (err) {
fastify.log.error(err)
process.exit(1)
}
fastify.log.info(`server listening on ${address}`)
})

class Tokens {
constructor () {}
get (name) {
return '123'
}
}

function tokens (fastify) {
fastify.decorate('tokens', new Tokens())
}

module.exports = tokens

// routes.js
class Tokens {
constructor() { }
get(name) {
return '123'
}
}

const options = {
schema: {
querystring: {
name: { type: 'string' },
},
response: {
200: {
type: 'object',
properties: {
name: { type: 'string' },
token: { type: 'string' }
}
}
}
}
}

function routes(fastify, opts, done) {
fastify.decorate('tokens', new Tokens())

fastify.get('/', options, async (request, reply) => {
reply.send({
name: request.query.name,
token: fastify.tokens.get(request.query.name)
})
})
done()
}
module.exports = routes

可以注意到的两点是:



  1. 在路由定义时,传入了一个请求的 schema,在官方文档中也说对响应的 schema 定义可以让 Fastify 的吞吐量上升 10%-20%。

  2. Fastify 使用 decorate 的方式对 Fastify 能力进行增强,也可以将 decorate 部分提取到其他文件,使用 register 的方式创建全新的上下文的方式进行封装。


没体现到的是 Fastify 请求介入的支持方式是使用生命周期 Hook,由于这是个对前端(Vue、React、Webpack)来说很常见的做法就不再介绍。


我们重点再来看一下 Fastify 的提速原理。


如何提速


有三个比较关键的包,按照重要性排分别是:



  1. fast-json-stringify

  2. find-my-way

  3. reusify



  • fast-json-stringify:
    const fastJson = require('fast-json-stringify')
    const stringify = fastJson({
    title: 'Example Schema',
    type: 'object',
    properties: {
    firstName: {
    type: 'string'
    },
    lastName: {
    type: 'string'
    }
    }
    })

    const result = stringify({
    firstName: 'Matteo',
    lastName: 'Collina',
    })


    • 与 JSON.stringify 功能相同,在负载较小时,速度更快。

    • 其原理是在执行阶段先根据字段类型定义提前生成取字段值的字符串拼装的函数,如:
      function stringify (obj) {
      return `{"firstName":"${obj.firstName}","lastName":"${obj.lastName}"}`
      }

      相当于省略了对字段值的类型的判断,省略了每次执行时都要进行的一些遍历、类型判断,当然真实的函数内容比这个要复杂的多。那么引申而言,只要能够知道数据的结构和类型,我们都可以将这套优化逻辑复制过去。



  • find-my-way:将注册的路由生成了压缩前缀树的结构,根据基准测试的数据显示是速度最快的路由库中功能最全的。

  • reusify:在 Fastify 官方提供的中间件机制依赖库中,使用了此库,可复用对象和函数,避免创建和回收开销,此库对于使用者有一些基于 v8 引擎优化的使用要求。在 Fastify 中主要用于上下文对象的复用。


总结



  • 在路由结构的设计上,Next.js、Nuxt.js 都采用了文件结构即路由的设计方式。Ada 也是使用文件结构约定式的方式。

  • 在渲染方面 Next.js、Nuxt.js 都没有将根组件之外的结构的渲染直接体现在路由处理的流程上,隐藏了实现细节,但是可以以更偏向配置化的方式由根组件决定组件之外的结构的渲染(head 内容)。同时渲染数据的请求由于和路由组件联系紧密也都没有分离到另外的文件,不论是 Next.js 的路由文件同时导出各种数据获取函数还是 Nuxt.js 的在组件上直接增加 Vue options 之外的配置或函数,都可以看做对组件的一种增强。Ada 的方式有所不同,路由文件夹下并没有直接导出组件,而是需要根据运行环境导出不同的处理函数和模块,如服务器端对应的 index.server.js 文件中需要导出 HTTP 请求方式同名的 GET、POST 函数,开发人员可以在函数内做一些数据预取操作、页面模板渲染等;客户端对应的 index.js 文件则需要导出组件挂载代码。

  • 在渲染性能提升方面,Next.js、Nuxt.js 也都采取了相同的策略:静态生成、提前加载匹配到的路由的资源文件、preload 等,可以参考优化。

  • 在请求介入上(即中间件):

    • Next.js、Nuxt.js 未对中间件做功能划分,采取的都是类似 Express 或 Koa 使用 next() 函数控制流程的方式,而 Nest.js 则将更直接的按照功能特征分成了几种规范化的实现。

    • 不谈应用级别整体配置的用法,Nuxt.js 是由路由来定义需要哪个中间件,Nest.js 也更像 Nuxt.js 由路由来决定的方式使用装饰器配置在路由 handler、Controller 上,而 Next.js 的中间件会对同级及下级路由产生影响,由中间件来决定影响范围,是两种完全相反的控制思路。

    • Ada 架构基于 Koa 内核,但是内部中间件实现也与 Nest.js 类似,将执行流程抽象成了几个生命周期,将中间件做成了不同生命周期内功能类型不同的任务函数。对于开发人员未暴露自定义生命周期的功能,但是基于代码复用层面,也提供了服务器端扩展、Web 模块扩展等能力,由于 Ada 可以对页面路由、API 路由、服务器端扩展、Web 模块等统称为工件的文件进行独立上线,为了稳定性和明确影响范围等方面考虑,也是由路由主动调用的方式决定自己需要启用哪些扩展能力。



  • Nest.js 官方基于装饰器提供了文档化的能力,利用类型声明( 如解析 TypeScript 语法、GraphQL 结构定义 )生成接口文档是比较普遍的做法。不过虽然 Nest.js 对 TypeScript 支持很好,也没有直接解决运行时的类型校验问题,不过可以通过管道、中间件达成。

  • Fastify 则着手于底层细节进行运行效率提升,且可谓做到了极致。同时越是基于底层的实现越能够使用在越多的场景中。其路由匹配和上下文复用的优化方式可以在之后进行进一步的落地调研。

  • 除此之外 swc、ESBuild 等提升开发体验和上线速度的工具也是需要落地调研的一个方向。




作者:智联大前端
来源:juejin.cn/post/7030995965272129567
收起阅读 »

我的天!多个知名组件库都出现了类似的bug!

web
前言 首先声明,没有标题党哈! 以下我知道的国内知名react组件库全部都有这个bug,你们现在都能去复现,一个提pr的好机会就让给你们了,哈哈!复现组件库: 阿里系:ant design, fusion design, 字节系:arco design 腾讯...
继续阅读 »

前言


首先声明,没有标题党哈!


以下我知道的国内知名react组件库全部都有这个bug,你们现在都能去复现,一个提pr的好机会就让给你们了,哈哈!复现组件库:



本来字节还有一个semi design,结果我发现它没有Affix组件,也就是固钉组件,让他躲过一劫,他有这个组件我也觉得肯定会复现相同的bug。


Affix组件是什么,以及bug复现


Affix组件(固钉组件)能将页面元素钉在可视范围。如下图:


image.png


这个button组件,会在距离顶部80px的时候会固定在屏幕上(position: fixed),如下图:


image.png


如何复现bug


你在这个button元素任意父元素上,加上以下任意style属性



  • will-change: transform;

  • will-change: filter;

  • will-change: perspective;

  • transform 不为none

  • perspective不为none

  • 非safari浏览器,filter属性不为none

  • 非safari浏览器,backdrop-filter属性不为none

  • 等等


都可以让这个固定组件失效,就是原本是距离顶部80px固定。


我的组件库没有这个bug,哈哈


mx-design


目前组件不是很多,还在努力迭代中,不知道凭借没有这个bug的小小优点,能不能从你手里取一个star,哈哈


bug原因


affix组件无非都是用了fixed布局,我是如何发现这个bug的呢,我的组件库动画系统用的framer-motion,我本来是想在react-router切换路由的时候整点动画的,动画效果就是给body元素加入例如transform的变化。


然后我再看我的固钉组件怎么失效了。。。后来仔细一想,才发现想起来fixed布局的一个坑就是,大家都以为fixed布局相对的父元素是window窗口,其实是错误的!


真正的规则如下(以下说的包含块就是fixed布局的定位父元素):



  1. 如果 position 属性是 absolute 或 fixed,包含块也可能是由满足以下条件的最近父级元素的内边距区的边缘组成的:



    1. transform 或 perspective 的值不是 none

    2. will-change 的值是 transform 或 perspective

    3. filter 的值不是 none 或 will-change 的值是 filter(只在 Firefox 下生效)。

    4. contain 的值是 paint(例如:contain: paint;

    5. backdrop-filter 的值不是 none(例如:backdrop-filter: blur(10px);




评论区有很多同学居然觉的这不是bug?


其实这个问题本质是定位错误,在这些组件库里,同样使用到定位的有,例如Tooltip,Select,Popuver等等,明显这些组件跟写Affix组件的不是一个人,其他组件这个bug是没有的,只有Affix组件出现了,所以你说这是不是bug。


还有,如果因为引用了Affix组件,这个固定元素的任一父元素都不能用以上的css属性,我作为使用者,我用了动画库,动画库使用transfrom做Gpu加速,你说不让我用了,因为引起Affix组件bug,我心里想凭啥啊,明明加两行代码就解决了。


最后,只要做过定位组件的同学,其复杂度在前端算是比较高的了,这也是为什么有些组件库直接用第三方定位组件库(floating-ui,@popper-js),而不是自己去实现,因为自己实现很容易出bug,这也是例如以上组件库Tooltip为什么能适应很多边界case而不出bug。


所以你想想,这仅仅是定位组件遇到的一个很小的问题,你这个都解决不了,什么都怪css,你觉得用户会这么想吗,一有css,你所有跟定位相关的组件全部都不能用了,你们还讲理不?


总之一句话,你不能把定位组件的复杂度高怪用户没好好用,建议去看看floating-ui的源码,或者之前我写的@popper-js定位组件的简要逻辑梳理,你就会意识到定位组件不简单。边界case多如牛毛。


解决方案



  • 首先是找出要固定元素的定位元素(定位元素的判断逻辑上面写了),然后如果定位元素是window,那么跟目前所有组件库的逻辑一样,所以没有bug,如果不是window,就要求出相对定位父元素距离可视窗口顶部的top的值

  • 然后在我们原本要定位的值,比如距离顶部80px的时候固定,此时80px再减去上面说的定位父元素距离可视窗口顶部的top的值,就没有bug了


具体代码如下:



  • offsetParent固定元素的定位上下文,也就是相对定位的父元素

  • fixedTop是我们要触发固定的值,比如距离可视窗口顶部80px就固定



affixDom.style.top = `${isHTMLElement(offsetParent) ? (fixedTop as number) - offsetParent.getBoundingClientRect().top : fixedTop}px`;

如何找出offsetParent,也就是定位上下文


export function getContainingBlock(element: Element) {
let currentNode = element.parentElement;
while (currentNode) {
if (isContainingBlock(currentNode)) return currentNode;
currentNode = currentNode.parentElement;
}
return null;
}

工具方法,isContainingBlock如下:


import { isSafari } from './isSafari';

export function isContainingBlock(element: Element): boolean {
const safari = isSafari();
const css = getComputedStyle(element);

// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
return (
css.transform !== 'none' ||
css.perspective !== 'none' ||
(css.containerType ? css.containerType !== 'normal' : false) ||
(!safari && (css.backdropFilter ? css.backdropFilter !== 'none' : false)) ||
(!safari && (css.filter ? css.filter !== 'none' : false)) ||
['transform', 'perspective', 'filter'].some((value) => (css.willChange || '').includes(value)) ||
['paint', 'layout', 'strict', 'content'].some((value) => (css.contain || '').includes(value))
);
}


本文完毕,求关注,求star!!!对于react组件库感兴趣的小伙伴,欢迎加群一起交流哦!


作者:孟祥_成都
来源:juejin.cn/post/7265121637497733155
收起阅读 »

百度考题:反复横跳的个性签名

web
浅聊一下 在各种社交网站上,都会有个性签名一栏,当没有写入时,默认为 “这个人很懒,什么也没有留下” 当我们没有点击它时,默认为一个文本,当我们点击它时,又变成input框了,我们可以在里面添加自己的个性签名...本篇文章将带大家copy一个低配版的的个性签...
继续阅读 »

浅聊一下


在各种社交网站上,都会有个性签名一栏,当没有写入时,默认为 “这个人很懒,什么也没有留下”


image.png


当我们没有点击它时,默认为一个文本,当我们点击它时,又变成input框了,我们可以在里面添加自己的个性签名...本篇文章将带大家copy一个低配版的的个性签名组件

动手


我们要有JS组件思维,个性签名是很多地方都可以用到的,我们把它做成组件,用到的时候直接搬过去就好了。所以我们将使用原生JS将组件封装起来(大家也可以使用VUE)


EditInPlace 类


我们要使用这个JS组件,首先得将其方法和参数封装在一个类里,再通过类的实例化对象来展示。


function EditInPlace(id,parent,value){
this.id = id;
this.parent = parent;
this.value =value || "这个家伙很懒,什么都没有留下";
this.createElements()//动态装配html结点
this.attachEvents();
}


  1. 将传入的idparentvalue参数赋值给新创建的对象的对应属性。

  2. 如果没有提供value参数,则将默认字符串"这个家伙很懒,什么都没有留下"赋值给新对象的value属性。

  3. 调用createElements方法来动态装配HTML节点。

  4. 调用attachEvents方法来附加事件。


EditInPlace.prototype


在 EditInPlace 类中,我们调用了createElements() attachEvents()两个方法,所以我们得在原型上定义这两个方法


createElements


    createElements:function(){
this.containerElement = document.createElement('div');
this.containerElement.id= this.id;
//签名文字部分
this.staicElement = document.createElement('span');
this.staicElement.innerText = this.value
this.containerElement.appendChild(this.staicElement);
//输入框
this.fieldElement = document.createElement('input')
this.fieldElement.type = 'text'
this.fieldElement.value = this.value;
this.containerElement.appendChild(this.fieldElement);
//save 确认
this.saveButton = document.createElement('input');
this.saveButton.type = 'button'
this.saveButton.value = '保存'
this.containerElement.appendChild(this.saveButton)
//取消按钮
this.cancelButton = document.createElement('input')
this.cancelButton.type = 'button'
this.cancelButton.value = '取消'
this.containerElement.appendChild(this.cancelButton)


this.parent.appendChild(this.containerElement)
this.converToText();

}


  1. 创建一个<div>元素,并将其赋值给this.containerElement属性,并设置其id为传入的id

  2. 创建一个<span>元素,并将其赋值给this.staicElement属性,然后设置其文本内容为传入的value

  3. this.staicElement添加到this.containerElement中。

  4. 创建一个<input>元素,并将其赋值给this.fieldElement属性,设置其类型为"text",并将其值设置为传入的value

  5. this.fieldElement添加到this.containerElement中。

  6. 创建一个保存按钮(<input type="button">),并将其赋值给this.saveButton属性,并设置其值为"保存"。

  7. 将保存按钮添加到this.containerElement中。

  8. 创建一个取消按钮(<input type="button">),并将其赋值给this.cancelButton属性,并设置其值为"取消"。

  9. 将取消按钮添加到this.containerElement中。

  10. this.containerElement添加到指定的父元素this.parent中。

  11. 调用converToText方法。


这个方法主要是用于动态生成包含静态文本、输入框和按钮的编辑组件,并将其添加到指定的父元素中。也就是说我们在这里主要就是创建了一个div,div里面有一个text和一个input,还有保存和取消按钮


div和span的显示


我们怎么样实现点击一下,就让text不显示,input框显示呢?
就是在点击一下以后,让text的display为'none',让input框和按钮为 'inline'就ok了,同样的,在保存或取消时采用相反的方法就好


    converToText:function(){
this.staicElement.style.display = 'inline';
this.fieldElement.style.display = 'none'
this.saveButton.style.display = 'none'
this.cancelButton.style.display = 'none'
},
converToEdit:function(){
this.staicElement.style.display = 'none';
this.fieldElement.style.display = 'inline'
this.saveButton.style.display = 'inline'
this.cancelButton.style.display = 'inline'
}

attachEvents


当然,我们需要在text文本和按钮上添加点击事件


    attachEvents:function(){
var that = this
this.staicElement.addEventListener('click',this.converToEdit.bind(this))
this.cancelButton.addEventListener('click',this.converToText.bind(this))
this.saveButton.addEventListener('click',function(){
var value = that.fieldElement.value;
that.staicElement.innerText = value;
that.converToText();
})
}


  1. 通过var that = this将当前对象的引用保存在变量that中,以便在后续的事件监听器中使用。

  2. 使用addEventListener为静态元素(this.staicElement)添加了一个点击事件的监听器,当静态元素被点击时,会调用this.converToEdit.bind(this)方法,这样做可以确保在converToEdit方法中this指向当前对象。

  3. 为取消按钮(this.cancelButton)添加了一个点击事件的监听器,当取消按钮被点击时,会调用this.converToText.bind(this)方法,同样也是为了确保在converToText方法中this指向当前对象。

  4. 为保存按钮(this.saveButton)添加了一个点击事件的监听器,当保存按钮被点击时,会执行一个匿名函数,该函数首先获取输入框的值,然后将该值更新到静态元素的文本内容中,并最后调用converToText方法,同样使用了变量that来确保在匿名函数中this指向当前对象。


通过这些事件监听器的设置,实现了以下功能:



  • 点击静态元素时,将编辑组件转换为编辑状态。

  • 点击取消按钮时,将编辑组件转换为静态状态。

  • 点击保存按钮时,获取输入框的值,更新静态元素的文本内容,并将编辑组件转换为静态状态。


HTML


在html中通过new将组件挂载在root上


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>就地编辑-EditIntPlace</title>
</head>
<body>
<div id="root"></div>
<script src="./editor_in_place.js"></script>
<script>
// 为了收集签名,给个表单太重了 不好看
// 个性签名,就地编辑
// 面向对象 将整体开发流程封装 封装成一个组件
new EditInPlace('editor',document.getElementById('root'))//名字 挂载点
</script>
</body>
</html>

完整效果



结尾


写的不太美观,掘友们可以自己试试加上样式,去掉按钮,再加上一张绝美的背景图片...


作者:滚去睡觉
来源:juejin.cn/post/7315730050576367616
收起阅读 »

前端实习生如何提升自己

web
这几年就业形势很严峻,很多大学生都选择通过实习来提升自己毕业后的就业压力。 同时,国家也在大力鼓励学生们多去实习,一些高校甚至强制学生大三就要出去实习,用来换成学分,而且比重很大,比如从深圳大学分出来的深圳技术大学。最近,我们就从深圳技术大学招聘了多个前端实习...
继续阅读 »

这几年就业形势很严峻,很多大学生都选择通过实习来提升自己毕业后的就业压力。


同时,国家也在大力鼓励学生们多去实习,一些高校甚至强制学生大三就要出去实习,用来换成学分,而且比重很大,比如从深圳大学分出来的深圳技术大学。最近,我们就从深圳技术大学招聘了多个前端实习生。


正常来讲一个资深的研发,最多可以辅导两个实习生,但因为我们是初创阶段,为了降低成本,我们经常会出现需要同时辅导三个以上实习生的情况。


到网上搜了一圈,都没有系统讲解实习生如何提升自己的资料,本文将结合自己过去十年带人的经验,面向前端岗位,谈一谈前端实习生如何提升自己。


作者简介


先后就读于北京交大和某军校,毕业后任职于原北京军区38集团军,带过两次新兵连,最多时管理过120人左右的团队。


退役后,先进入外企GEMALTO做操作系统研发,后进入搜狐、易车和美团等公司做大前端开发和团队管理,最多时管理过30多人的大前端团队,负责过几百个产品的前端开发工作,做过十多个大型复杂前端项目的架构工作。


目前在艺培行业创业,通过AIGC技术提升艺培机构门店运营和招生推广的效率和降低成本。


如何选择实习?


我永远相信,选择比努力奋斗更重要。选择一家合适的实习单位或公司,可以达到事半功倍的效果。


大公司的优势


毫无疑问,很多大学生都会选择大公司作为自己的实习单位,尤其是一些上市公司,甚至为了求得一个实习机会,即使倒贴钱也愿意。


与小企业相比,大公司一般都会有相对完整的企业制度。从签实习协议开始,如薪资结算、工作安排等,完全按照相关的企业制度,以及法律法规进行,这样一定程度有利于保障实习生的劳动权益。


与此相比,一些小企业甚至连实习协议都不签,这对于实习生来说是非常不利的。


大公司一般都会有日历表和日常的作息时刻表。因此,在大公司工作一般都按照正常的作息时间上下班,不太会出现加班的情况。


而相对来说,小企业由于刚刚起步,工作量非常大,人手常常不够,因此加班加点是家常便饭了。


每个大公司都会具有良好的、独特的企业文化,从办公室的装饰、到公司员工午休时的娱乐活动,无不体现出自己的特性。因此,在大公司里实习,你能够亲身感受到大公司内部的企业文化。


而小企业,则不太可能会有自己的企业文化,即使有多数也是非常糟糕的。


小企业的特点


在大公司实习,由于大公司里本身就人员冗余,再加上对“实习生”的不信任,因此他们给实习生安排的工作常属于打杂性质的。可能你在大公司里呆了两个月,可是连它最基本的业务流程都没弄明白。


而在小企业里,由此大多处于起步,没有太多的职位分化,你可能一人需要兼多职,许多事情都得自己解决,能够提高你独立解决问题的能力。


大公司一般有完整的部门体制,上下级关系相对严格分明,因此,官僚作风比较明显,新人难有发言权。


而在小企业,这方面的阻力则小得多,使新人少受束缚,更自由地施展自己的能力、表达自己的想法。


小结


实习为了给将来就业铺路,因此,实习最重要的在于经验。当然,在大公司实习如果能在增加自己进该公司的概率那固然好,但如果只是为了在简历里加上“在某大公司里实习过”的虚名,那实在是没有必要。


结合未来职业选择,要实习的公司和岗位是和你的职业理想直接相关的,这个岗位的实习和这个公司的进入可以给你日后的职业发展加分。


选你能在实习期间出成果的项目。如果你选了个很大的项目,半年还没做完,这就说不清楚是你没做好,还是项目本身的问题了。


实习的时候要更深入的了解企业的实际情况和问题,结合理论和实际,要给企业解决实际问题,有一个具体的成果,让企业真正感觉到你的项目做好了


我个人建议,在正式工作前,大公司和小企业,最好都能争取去实习一段时间,但第一份实习最好去大公司。


通过实习,希望大家能够验证自己的职业抉择,了解目标工作内容,学习工作及企业标准,找到自身职业的差距。


如何做好实习


选择好合适的实习单位,只是走出了万里长征的第一步,要做好实习,大家可以关注以下四个方面:


端正态度


从实习生个人的角度来看,多数人的出发点都是学习提升,但如果我们只是抱着纯学习而不为企业创造价值的心态的话,实习工作就不容易长久。


我们可以从企业的角度来看:



  • 一是实习提供了观察一位潜在的长期员工工作情况的方法,为企业未来发展培养骨干技术力量与领导人;

  • 二手有利于廉价劳动力争夺人才,刚毕业的学员便于管理,这样不仅能降低成本,还能提高企业的知名度,有利于企业长远发展。


实习生只有表现出了企业看重的那些方面,企业才会投入时间和精力去好好培养他,教一些真本领。


不管是在美团,还是我自己创业的公司,我都是基于相同的原则,去评估是否留下某个实习,进行重点培养。


我个人看重以下几点:



  • 不挑活:即使不是自己喜欢或在工作范围的任务,也能积极去完成;

  • 爱思考:接到任务或经过指导后,能及时反馈存在的问题和提升改进方案;

  • 善沟通:遇到困难及时寻求帮助,工作不开心了不封闭自己、不拒绝沟通。


不管当年我去实习还是上班,我的心态都是先为企业创造价值,在此基础上再争取完成自己的个人目标。


注重习惯


有的人毕业10年都还没毕业3年的人混得好,不是因为能力不行,而是因为他没有养成良好的职场习惯,以至于总是吃亏。


开发人员培养好的习惯至关重要。编写好的代码,持续学习,逐步提高代码质量。


主动反馈和同步信息


这个习惯非常重要,可以避免大家做很多无用功,大大提升大家日常工作的效率,而且也特别容易获得大家的好感与信任。


经常冒烟自测


对于自己负责的功能或产品,不管是测试阶段还是正式发布,只要需要其他人使用,都最好冒烟自测一遍,避免低级错误,进而影响大家对你专业度的信任。


良好的时间管理


先每天花几分钟确定好我今天要办的最重要的三到六件事情,如此能帮助提高整体的效率;把今天要做的所有事情一一写下来,按轻重缓急排序,可以按照要事第一的原则;每做完一件事情就把它划掉,在今天工作结束之前检查我是不是把整个list的工作都完成了。


避免重复的错误


不要犯了错误之后就草草了事,花一点时间深度挖掘清楚我犯错的底层原因是什么,再给自己一些小提示,比如做一个便利贴做提醒,在电脑桌面上面放一个警告,避免相同的错误重复发生。


构建知识体系


在信息爆炸的年代,碎片化的知识很多,系统学习的时间越来越少,如果没有自己的知识体系,很容易被淹没在知识的海洋中,而且难以记忆。


100分程序员推荐的做法,通过Wiki或者其他知识管理工具构建一个知识框架,大的分类可以包括软技能、架构、语言、前端、后端等,小的分类可以更细化。


培养大局观


程序员比较容易陷入的困境是专注于自己的一亩三分地,不关心团队的进度和业绩,也不关心软件的整体架构和其他模块。


这种状态长期下去没有好处,特别是在大公司中,逐渐成长为一颗螺丝钉。100分程序员会在工作之余,多看看其他在做什么,看看团队的整体规划,看看软件系统的架构和说明文档。


对自己的工作更理解,而且知道为什么这个产品应该这样设计,为什么领导应该这样做规划,这种大局观非常有利于自己的职业生涯。


技能提升


通过实习阶段,你才会知道专业知识在工作中的具体运用,才能切身体会专业知识的重要性,这样也让自己在以后的学习中更加的努力和勤奋和有所侧重的安排各科目的学习时间。


对于前端实习生的技能学习,除了打牢HTML、CSS和JS这些基础的前端知识以及熟练掌握vue、react这些框架开发技术栈,建议大家还需要选中自己感兴趣的前端技术领域进行持续深入的学习实践,具体请看我之前总结的文章:前端学哪些技能饭碗越铁收入还高


关系处理


大学生在实习过程中最难适应的就是人际关系,同时还要了解企业组织运行的特点、规章制度,了解企业文化对员工的影响等等,知道职场中和领导及同事该如何沟通,企业对员工有着什么样的需求等。


这些也只有在实习时,让自己处于团队之中,才能切身的体会和参与,只有这样大学生才会对社会生活有深刻的理解,对将来就业才有益处。


不论是对同事,还是对客户,还是对合作伙伴,都不要吝啬你的赞美之词。善于夸赞他人的员工,更容易获得他人的好评,有时还能让你成功避免一些不必要的麻烦。


当然,赞美他人,不要过于浮夸,要能说出赞美的理由,以及哪些是你表示认同的地方。比如,我们在称赞他人方案做得好的时候,不要只是说:“这方案不错”。


可以这么说:“这方案在市场推广方面的内容写得很好,逻辑清晰,数据充分,还能有同行的案例借鉴,十分值得我学习。”


要让别人感受到你是真心的在夸奖他,而不是讨好他。


我们常常很难控制激动时的情绪,发怒时更是。往往这个时候表达出的观点都是偏激的,事后再后悔就来不及了。


如果你与同事之间发生争执而发怒,请在这个时间段保持沉默。给自己找个地方安静一会儿,等到情绪稳定以后,再向他人表达你的观点,慢慢地,你就会更好地控制自己的情绪。


在职场中切忌过于情绪化,学会管理好自己的情绪是一个职场人必备的技能之一。坏情绪并不会帮助你解决任何问题,过多的抱怨只会使你成为负能量的传播者。


协作在软件开发中至关重要。一个好的开发人员应该能够与他人很好地合作,并了解如何有效地协作。这意味着对反馈持开放态度,积极寻求团队成员的意见,并愿意作为团队的一部分从事项目。


协作会带来更好的沟通、更高效的工作流程和更好的产品。


总结


社会实践对大学生的就业有着很大的促进作用,是大学生成功就业的前提和基础。


实习的目的因人而异,可以千差万别,而你实习的真正目的也只有自己才最清楚。只有在开始实习之前首先明确自己的目的,后面的路才会变得清晰明了。


作者:文艺码农天文
来源:juejin.cn/post/7319181229520568371
收起阅读 »

前端学哪些技能饭碗越铁收入还高

web
随着经济的下行以及移动互联网发展趋于成熟,对软件开发人员的需求大大减少,互联网行业所有的公司都在降本增效,合并通道,降薪裁员的新闻层出不穷。 但相比其他行业,互联网行业的从业者薪资还是比较可观的,但要求也比之前高了很多,需要大家掌握更多的技能和在某些技术领域深...
继续阅读 »

随着经济的下行以及移动互联网发展趋于成熟,对软件开发人员的需求大大减少,互联网行业所有的公司都在降本增效,合并通道,降薪裁员的新闻层出不穷。


但相比其他行业,互联网行业的从业者薪资还是比较可观的,但要求也比之前高了很多,需要大家掌握更多的技能和在某些技术领域深耕。


本文,我们就聊聊,掌握了哪些技能,能让前端同学,收入高且稳定。


端智能


首推的是端智能,很多行业大咖都认为,随着ChatGPT的横空出世,开启了第四次工业革命,很多产品都可以用大模型重做一遍。当前,我创业的方向,也和大模型有关。


当前的大模型主要还跑在云端,但云端的成本高,大模型的未来在端智能,这也是小米创始人雷军在今年一次发布会上提出的观点。


在2023年8月14日的雷军年度演讲中,雷军宣布小米已经在手机端跑通13亿参数的大模型,部分场景效果媲美云端。


目前,端上大模型的可行性和前景已经得到了业内的普遍认可,国内外各个科技大厂在大模型的端侧部署领域均开始布局,目前大量工程已在PC端、手机端实现大模型的离线部署,更有部分App登陆应用商店,只需下载即可畅通无阻地对话。


我们相信,在不久的将来,端上大模型推理将会成为智能应用的重要组成部分,为用户带来更加便捷、智能的体验。


我在美团从零研发了web端智能推理引擎,当时立项时,就给老板画饼,美团每天的几百亿推理调用,如果有一半用端智能替代的话,每年能为公司节省上亿元。


要想掌握端智能,需要学习深度学习的基本知识,还要掌握图形学和C++编程,通过webgl或webassembly 技术实现在Web端执行深度学习算法。


图形学


前面提到的端智能,只是涉及到了图形学中的webgl计算,但图形学的未来在元宇宙,通过3D渲染,实现VR、AR、XR等各种R。


计算机图形学是一门快速发展的领域,涵盖了三维建模、渲染、动画、虚拟现实等众多技术和应用。在电影、广告、游戏等领域中,计算机图形学的应用已经非常广泛。


熟练使用threejs开发各种3D应用,只能算是入门。真正的图形学高手,不仅可以架构类似3D家装软件的大型应用,而且能掌握渲染管线的底层原理,熟练掌握各种模型格式和解决各种软件,进行模型转换遇到的各种兼容问题。


随着计算机硬件和算法的不断进步,计算机图形学正迎来新的发展趋势。


首先是实时渲染与逼真度提升



  • 实时渲染技术:随着游戏和虚拟现实的兴起,对实时渲染的需求越来越高。计算机图形学将继续致力于研发更高效的实时渲染算法和硬件加速技术,以实现更逼真、流畅的视觉效果。

  • 光线追踪与全局照明:传统的实时渲染技术在光照模拟方面存在挑战。计算机图形学将借助光线追踪等技术,实现更精确的全局照明效果,提升场景的真实感和细节表现。


其次是虚拟与增强现实的融合



  • 混合现实技术:计算机图形学将与传感器技术、机器视觉等相结合,推动虚拟现实与增强现实的融合发展。通过实时感知和交互,用户可以在真实世界中与虚拟对象进行互动,创造更沉浸式的体验。

  • 空间感知与虚拟对象定位:计算机图形学将致力于解决空间感知和虚拟对象定位的挑战。利用深度学习、摄像头阵列等技术,实现高精度的空间感知和虚实融合,为虚拟与增强现实应用带来更自然、精确的交互方式。


再次是计算机图形学与人工智能的融合



  • 生成对抗网络(GAN)在图形生成中的应用:GAN等人工智能技术为计算机图形学带来了新的创作手段。通过训练模型生成逼真的图像和场景,计算机图形学能够更便捷地创建大量内容,并提供个性化的用户体验。

  • 计算机图形学驱动的虚拟人物与角色生成:结合计算机图形学和人工智能技术,研究人员正在努力开发高度逼真的虚拟人物和角色生成方法。这将应用于游戏、影视等领域,带来更具情感表达和交互性的虚拟角色。


最后是可视化分析与科学研究。



  • 一是大数据可视化:随着大数据时代的到来,计算机图形学在可视化分析方面扮演着关键角色。通过创新的可视化方法和交互技术,研究人员能够更深入地理解和分析庞大而复杂的数据集,揭示潜在的模式和趋势。

  • 二是科学数据可视化:计算机图形学在科学研究中的应用也日益重要。通过将科学数据转化为可视化形式,研究人员能够更直观地理解复杂的数据模式和关系,加快对科学问题的洞察和发现。这种可视化分析有助于领域如天文学、生物学、气象学等的研究进展。


工程提效


其实,过去三年我在美团的工作,至少有一半的精力是做和工程提效相关的事情。当然,也做了降本的事情,从零搭建外包团队。


就像我之前总结的文章:我在美团三年的前端工程化实践回顾 中提到那样,前端工程提效,一般会按照工具化、标准化、平台化和体系化进行演进。


相比前面的端智能和图形学,除了建设低代码平台和WebIDE有点技术难度,其他更多需要的是沟通、整合资源的能力,既要有很强的项目管理能力,又要人产品思维。


前两个方向做好了我们一般称为技术专家,而工程提效则更偏管理者,未来可以成为高管或自己创业。


总结


端智能和图形学,我在美团都尝试过进行深耕,但自己性格外向,很难坐得住。工程提效做得也一般,主要是因为需要换城市而换了部门,没有机缘继续推进,在美团很难往上走,所以只能尝试自己创业。


当前我创业的公司,也需要用到很多端智能的技术,比如我们使用yolov8实现了在web端进行智能抠图(后面计划使用segment anything大模型实现);也需要用到很多图形学的技能,比如开发3D美术馆为艺培机构招生引流,通过3D展示火箭、太阳系、空间站等,提升教学效果。


未来,我们需要招聘很多端智能及图形学方向的技术人才,欢迎大家加入。


作者:三一习惯
来源:juejin.cn/post/7310143510103064585
收起阅读 »

说一说css的font-size: 0?

web
平常我们说的font-size:0;就是设置字体大小为0对吧,但是它的用处不仅仅如此哦,它还可以消除子行内元素间额外多余的空白! 问题描述? 是否出现过当多个img标签平铺的时候,会出现几个像素的间距?就像这样👇(为了醒目加了个红色的框框) 是什么原因造成...
继续阅读 »

平常我们说的font-size:0;就是设置字体大小为0对吧,但是它的用处不仅仅如此哦,它还可以消除子行内元素间额外多余的空白



问题描述?


是否出现过当多个img标签平铺的时候,会出现几个像素的间距?就像这样👇(为了醒目加了个红色的框框)


image.png


是什么原因造成的呢?


大家都知道img是行内元素,比如当我们的标签换行的时候,回车符会解析一个空白符,所以这是造成会有间距的原因之一。


当然喽,不仅仅是img,包括其他的一些常见的行内元素,比如span👇标签回车换行的效果,同样也会间隙,当然如果是缩进、空格等字符同样也会产生空白间隙,导致元素间产生多余的间距


image.png


    <span>背景图</span>
<span>背景图</span>
<span>背景图</span>
<span>背景图</span>
<img class="item-img" src="./src/assets/login-bg.png" alt="背景图" >
<img class="item-img" src="./src/assets/login-bg.png" alt="背景图" >
<img class="item-img" src="./src/assets/login-bg.png" alt="背景图" >

如何解决呢?


那我们首先想到取消换行、空格...


既然是因为标签换行了引起的,那么我们就取消换行、空格等试一试。


image.png


<span>背景图</span><span>背景图</span><span>背景图</span><span>背景图</span>
<img class="item-img" src="./src/assets/login-bg.png" alt="背景图" ><img class="item-img" src="./src/assets/login-bg.png" alt="背景图" ><img class="item-img" src="./src/assets/login-bg.png" alt="背景图" >

证明方法还是有用的~ 那还有没有其他的方法解决呢,那这个时候可以借助font-size:0来用一用。


如何使用font-size: 0 解决呢?


利用font-size:0消除子行内元素间额外多余的空白,需要在父元素上添加font-size:0


image.png


是不是就解决了呀?


看一个完整的完整demo效果


image.png
当然需要注意一下



设置font-size: 0时,子元素必须指定一个font-size大小,否则文本内容不会显示哦



示例代码:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo</title>
<style>
/*************************css代码👇***********************/
ul {
margin: 20px;
display: flex;
gap: 20px;
}
.item {
width: 300px;
height: 200px;
padding: 20px;
border-radius: 10px;
background: #fff;
overflow: hidden;
font-size: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04)
}
.item-img {
width: 100%;
height: 175px;
object-fit: cover;
border-radius: 5px;
}
.item-text {
color: #333;
font-size: 14px;
}
span {
background-color: red;
padding: 10px;
}
</style>
</head>
<body>


<ul>
<li class="item">
<img class="item-img" src="./src/assets/login-bg.png" alt="背景图" ><p class="item-text">《好看的背景图》</p>
</li>
<li class="item">
<img class="item-img" src="./src/assets/login-bg.png" alt="背景图" >
<p class="item-text">《好看的背景图》</p>
</li>
<li class="item">
<img class="item-img" src="./src/assets/login-bg.png" alt="背景图" >
<p class="item-text">《好看的背景图》</p>
</li>
<li class="item">
<img class="item-img" src="./src/assets/login-bg.png" alt="背景图" >
<p class="item-text">《好看的背景图》</p>
</li>
</ul>
</body>
</html>


作者:是小西瓜吖
来源:juejin.cn/post/7260752483055878204
收起阅读 »

一个左侧导航栏的选中状态把我迷得颠三倒四

web
事情是这样的 👇 前段时间我用arco.design这个组件库默认的模板项目开放,刚开始需求是这样的:总共八个页面,其中四个显示在导航栏,四个不在导航栏显示,可以导航的页面里又有俩个需要登录才能访问,俩个不登录就能访问。听着是有些绕不过开发起来不是SO EAS...
继续阅读 »

事情是这样的 👇


前段时间我用arco.design这个组件库默认的模板项目开放,刚开始需求是这样的:总共八个页面,其中四个显示在导航栏,四个不在导航栏显示,可以导航的页面里又有俩个需要登录才能访问,俩个不登录就能访问。听着是有些绕不过开发起来不是SO EASY吗😎


我用的是vue版本的,说白了就一个知识点——路由和菜单。凭借我的聪明才智,肯定一看就会。


路由


首先,需要先了解一下路由表的配置。基本的路由配置请参阅 Vue-Router 官方文档


// 在本例子中,页面最终路径为 /dashboard/workplace
export default {
path: 'dashboard',
name: 'dashboard', // 路由名称
component: () => import('@/views/dashboard/index.vue'),
meta: {
locale: 'menu.dashboard',
requiresAuth: true,
icon: 'icon-dashboard',
},
children: [
{
path: 'workplace',
name: 'workplace',
component: () => import('@/views/dashboard/workplace/index.vue'),
meta: {
locale: 'menu.dashboard.workplace',
requiresAuth: true,
roles: ['admin'],
hideInMenu: false,
},
},
],
};

路由 Meta 元信息


参数名说明类型默认值
roles配置能访问该页面的角色,如果不匹配,则会被禁止访问该路由页面string[]-
requiresAuth是否需要登录鉴权booleanfalse
icon菜单配置iconstring-
locale一级菜单名(语言包键名)string-
hideInMenu是否在左侧菜单中隐藏该项boolean-
hideChildrenInMenu强制在左侧菜单中显示单项boolean-
activeMenu高亮设置的菜单项string-
order排序路由菜单项。如果设置该值,值越高,越靠前number-
noAffix如果设置为true,标签将不会添加到tab-bar中boolean-
ignoreCache如果设置为true页面将不会被缓存boolean-

hideInMenu 控制菜单显示, requiresAuth 控制是否需要登录,没错,以上知识点完全够我用了🤏🤏🤏🤏


三下五除二就开发完成了,正当我沉浸在成功的喜悦时,测试给我提了个bug。


说导航栏目切换,选中状态有俩个正常,俩个不正常,切换页面导航没选中,再点一次才会选中。我擦👏👏👏👏👏,我傻眼了😧😧😧


到底哪里出了问题,我哪里写错了呢,人家官方肯定没问题,于是我开始寻找。是路由名称重复了,还是那个组件写的有问题,还好有俩个正常的,我仔细比对一下不就好了吗,我可真是个小机灵鬼。


就这样,我又一次为我的自大付出了汗水,对比了一天,我感觉好像真不是我写的有问题,不过还是有收获的,我发现requiresAuth 设置为true的导航正常,requiresAuth设置为false的不正常。抱着怀疑的态度我开始找原来模板项目中处理requiresAuth的代码。最后在components》menu》index.vue文件下发现一个方法:


listenerRouteChange((newRoute) => {
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
if (requiresAuth && (!hideInMenu || activeMenu)) {
const menuOpenKeys = findMenuOpenKeys(
(activeMenu || newRoute.name) as string
);

const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
openKeys.value = [...keySet];

selectedKey.value = [
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
];
}
}, true);

这... ... 我人麻了,这不是只有requiresAuth 为true的时候才会有效吗?他为啥这么写呢?还是我复制项目的时候复制错了?然后我改成了


listenerRouteChange((newRoute) => {
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
if ((requiresAuth === true || requiresAuth === false) && (!hideInMenu || activeMenu)) {
const menuOpenKeys = findMenuOpenKeys(
(activeMenu || newRoute.name) as string
);

const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
openKeys.value = [...keySet];

selectedKey.value = [
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
];
}
}, true);

🙈🙈🙈🙈🙈看出来我改哪里了吗,考考你们的眼力,我改的方法是不是很nice。
反正好使了,下班!!!!


作者:一路向北京
来源:juejin.cn/post/7317277887567151145
收起阅读 »

【日常总结】解决el-select数据量过大的3种方法

web
背景 最近做完一个小的后台管理系统,快上线了,发现一个问题,有2个select的选项框线上的数据量是1w+。。而测试环境都是几百的,所以导致页面直接卡住了,over了。 想了一下,如果接口支持搜索和分页,那直接通过上拉加载就可以了。但后端不太愿意改😄。行吧,...
继续阅读 »

背景


最近做完一个小的后台管理系统,快上线了,发现一个问题,有2个select的选项框线上的数据量是1w+。。而测试环境都是几百的,所以导致页面直接卡住了,over了。


image.png


想了一下,如果接口支持搜索和分页,那直接通过上拉加载就可以了。但后端不太愿意改😄。行吧,前端搞也是可以的。


这个故事还有个后续


image.png


过了一周上线后,发现有一个下拉框的数据有30w+!!!加载都加载不出来,哈哈哈哈,接口直接超时报错了,所以又cuocuocuo改了一遍,最后改成了:



  1. 接口翻页请求

  2. 前端使用自定义指令实现上拉加载更多,搜索直接走的后端接口


方案


通过一顿搜索加联想总结了3种方法,以下方法都需要支持开启filterable支持搜索。


标题具体问题
方案1只展示前100条数据,这个的话配合filter-method每次只返回前100条数据。限制展示的条数可能不全,搜索需要多搜索点内容
方案2分页方式,通过指令实现上拉加载,不断上拉数据展示数据。仅过滤加载出来的数据,需要配合filterMethod过滤数据
方案3options列表采用虚拟列表实现。成本高,需要引入虚拟列表组件或者自己手写。经掘友指点,发现element-plus提供了对应的实现,如果是plus,则可以直接使用select-v2

方案一(青铜段位) filterMethod直接过滤数据量


<template>
<el-select
v-model="value"
clearable filterable
:filter-method="filterMethod">

<el-option
v-for="(item, index) in options.slice(0, 100)"
:key="index"
:label="item.label"
:value="item.value">

</el-option>
</el-select>

</template>
export default {
name: 'Demo',
data() {
return {
options: [],
value: ''
}
},
beforeMount() {
this.getList();
},
methods: {
// 模拟获取大量数据
getList() {
for (let i = 0; i < 25000; i++) {
this.options.push({label: "选择"+i,value:"选择"+i});
}
},
filterMethod(val) {
console.log('filterMethod', val);
this.options = this.options.filter(item => item.value.indexOf(val) > -1).slice(0, 100);
},
visibleChange() {
console.log('visibleChange');
}
}
}

方案二(白银段位) 自定义滚动指令,实现翻页加载


写自定义滚动指令,options列表滚动到底部后,再加载下一页。但这时候筛选出来的是已经滚动出来的值。


这里如果直接使用filterable来搜索,搜索出来的内容是已经滑动出来的内容。如果想筛选全部的,就需要重写filterMethod方法来自定义过滤功能。可以根据情况选择是否要重写filterMethod。


image.png


<template>
<div class="dashboard-editor-container">
<el-select
v-model="value"
clearable
filterable
v-el-select-loadmore="loadMore"
:filter-method="filterMethod">

<el-option
v-for="(item, index) in options"
:key="index"
:label="item.label"
:value="item.value">

</el-option>
</el-select>
</div>

</template>
<script>
export default {
name: 'Demo',
data() {
return {
options: [],
value: '',
pageNo: 0
}
},
beforeMount() {
this.getList();
},
methods: {
// 模拟获取大量数据
getList() {
const data = [];
for (let i = 0; i < 25000; i++) {
data.push({label: "选择"+i,value:"选择"+i});
}
this.allData = data;
this.data = data;
this.getPageList()
},
getPageList(pageSize = 10) {
this.pageNo++;
const list = this.data.slice(0, pageSize * (this.pageNo));
this.options = list;
},
loadMore() {
this.getPageList();
},
filterMethod(val) {
this.data = val ? this.allData.filter(item => item.label.indexOf(val) > -1) : this.allData;
this.getPageList();
}
},
directives:{
'el-select-loadmore':(el, binding) => {
// 获取element-ui定义好的scroll父元素
const wrapEl = el.querySelector(".el-select-dropdown .el-select-dropdown__wrap");
if(wrapEl){
wrapEl.addEventListener("scroll", function () {
/**
* scrollHeight 获取元素内容高度(只读)
* scrollTop 获取或者设置元素的偏移值,
* 常用于:计算滚动条的位置, 当一个元素的容器没有产生垂直方向的滚动条, 那它的scrollTop的值默认为0.
* clientHeight 读取元素的可见高度(只读)
* 如果元素滚动到底, 下面等式返回true, 没有则返回false:
* ele.scrollHeight - ele.scrollTop === ele.clientHeight;
*/

if (this.scrollTop + this.clientHeight >= this.scrollHeight) {
// binding的value就是绑定的loadmore函数
binding.value();
}
});
}
},
},
}
</script>

</script>

方案三(黄金段位) 虚拟列表


引入社区的vue-virtual-scroll-list 支持虚拟列表。但这里想的自己再实现一遍虚拟列表,后续再写吧。


另外,element-plus提供了对应的实现,如果是使用的是plus,则可以直接使用 select-v2组件


<template>
<div class="dashboard-editor-container">
<el-select
v-model="value"
clearable
filterable >

<virtual-list
class="list"
style="height: 360px; overflow-y: auto;"
:data-key="'value'"
:data-sources="data"
:data-component="item"
:estimate-size="50"
/>

</el-select>
</div>

</template>
<script>
import VirtualList from 'vue-virtual-scroll-list';
import Item from './item';
export default {
name: 'Demo',
components: {VirtualList, Item},
data() {
return {
options: [],
data: [],
value: '',
pageNo: 0,
item: Item,
}
},
beforeMount() {
this.getList();
},
methods: {
// 模拟获取大量数据
getList() {
const data = [];
for (let i = 0; i < 25000; i++) {
data.push({label: "选择"+i,value:"选择"+i});
}
this.allData = data;
this.data = data;
this.getPageList()
},
getPageList(pageSize = 10) {
this.pageNo++;
const list = this.data.slice(0, pageSize * (this.pageNo));
this.options = list;
},
loadMore() {
this.getPageList();
}
}
}
</script>


// item组件
<template>
<el-option :label="source.label" :value="source.value"></el-option>
</template>


<script>
export default {
name: 'item',
props: {
source: {
type: Object,
default() {
return {}
}
}
}
}
</script>


<style scoped>
</style>



总结


最后我们项目中使用的虚拟列表,为啥,因为忽然发现组件库支持select是虚拟列表,那就直接使用这个啦。


最后的最后


没有用虚拟列表,因为接口数据量过大(你见过返回30w+的接口吗🙄。。),后端接口改成分页,前端支持自定义指令上拉加载,引用的参数增加了remote、remote-method设置为远端的方法。


<template>
<div class="dashboard-editor-container">
<el-select
v-model="value"
clearable
filterable
v-el-select-loadmore="loadMore"
remote
:remote-method="remoteMethod">

<el-option
v-for="(item, index) in options"
:key="index"
:label="item.label"
:value="item.value">

</el-option>
</el-select>
</div>

</template>

参考文章:



作者:searchop
来源:juejin.cn/post/7278238985448341544
收起阅读 »

从 Vue 2 迁移到 Svelte

web
大家好,这里是大家的林语冰。 本周之后,Vue 2 将停止开源维护。所以本期《前端翻译计划》共享的是某企业去年从 Vue 2 迁移到 Svelte 的现实测评,以及 Vue 3 和 Svelte 的“零和博弈”。 在使用 Vue 2 作为我们的前端框架近 2 ...
继续阅读 »

大家好,这里是大家的林语冰。


本周之后,Vue 2 将停止开源维护。所以本期《前端翻译计划》共享的是某企业去年从 Vue 2 迁移到 Svelte 的现实测评,以及 Vue 3 和 Svelte 的“零和博弈”。


在使用 Vue 2 作为我们的前端框架近 2 年后,我们宣布此支持将不再维护,因此我们决定迁移到新框架。但该选谁呢:Vue 3 还是 Svelte 呢


粉丝请注意,我们迁移后的目标也是改善 DX(开发体验),尤其是类型检查、性能和构建时间。我们没有考虑 React,因为它需要投资一大坨时间成本来学习,而且与 Vue 和 Svelte 不同,它没有提供开箱即用的解决方案。此外,后者共享相同的 SFC(单文件组件)概念:同一文件中的逻辑(JS)、结构(HTML)和样式(CSS)。


Svelte vs Vue 3


Svelte 的留存率更高。对于我们的新前端,我们必须从市场上可用的 2 个框架权衡,即 Svelte 和 Vue 3。下面是过去 5 年不同框架留存率的图示(留存率 = 会再次使用/(会再次使用 + 不会再次使用))。JS 现状调查汇编了该领域开发者的数据,如你所见,Svelte 排名第二,而 Vue 3 排名第四。


01-state.jpg


这启示我们,过去使用过 Svelte 的开发者愿意再次使用它的数量比不愿使用它的要多。


Svelte 的类型体验更棒


Vue 2Vue 3Svelte
组件级类型YesYesNo
跨组件类型NoYesYes
类型事件NoNoYes

Svelte 通过更简单的组件设计流程和内置类型事件提供了更好的类型体验,对我们而言十分用户友好。


全局变量访问限制。使用 Svelte,可以从其他文件导入枚举,并在模板中使用它们,而 Vue 3 则达咩。


02-benchmark.png


语法。就我个人而言,私以为 Svelte 语法比 Vue 更优雅和用户友好。您可以瞄一下下面的代码块,并亲自查看它们。


Svelte:


03-svelte.png


Vue:


04-vue.png


没有额外的 HTML div <template>。在 Svelte 中您可以直接编写自己的 HTML。


样式在 Svelte 中会自动确定作用域,这对于可维护性而言是一个优点,且有助于避免 CSS 副作用。每个组件的样式独立,能且仅能影响该组件,而不影响其父/子组件。


更新数据无需计算属性。在 Svelte 中,感觉更像在用纯 JS 撸码。您只需要专注于编写一个箭头函数:


const reset = () => {
firstName = ''
lastName = ''
}

Svelte 中只需单个括号:


//Svelte
{fullName}

//Vue
{{fullName}}

添加纯 JS 插件更简单。此乃使用 Svelte 和 Prism.js 的语法高亮集成用例,如下所示:


05-prism.png


无需虚拟 DOM 即可编译代码。Svelte 和 Vue 之间的主要区别是,减少了浏览器和 App 之间的层数,实现更优化、更快的任务成果。


自动更新。诉诸声明变量的辅助,Svelte 可以自动更新您的数据。这样,您就不必等待变更反映在虚拟结构中,获得更棒的 UX(用户体验)。


Svelte 也有短板


理所当然,Svelte 也有短板,比如社区相对较小,因为它是 2019 年才诞生的。但随着越来越多的开发者可能会认识到其质量和用户友好的内容,支持以及社区未来可能会不断发展壮大。


因此,在审查了此分析的结果后,尽管 SvelteKit 在迁移时仍处于积极开发阶段,我们决定使用 Svelte 和 Svelte Kit 砥砺前行。


06cons.png


如何处理迁移呢?


时间:我们选择在 8 月份处理迁移,当时该 App 用户较少。


时间长度:我们花了 2 周时间将所有文件从 Vue 迁移到 Svelte。


开发者数量:2 名前端开发者全职打工 2 周,另一名开发者全职打工 1 周,因此涉及 3 名开发人员。


工作流:首先,我们使用 Notion 工具将我们的凭据归属于团队的开发者。然后,我们开始在 Storybook 中创建新组件,最后,每个开发者都会奖励若干需要在 Svelte 中重写的页面。


作为一家初创公司,迁移更简单,因为我们没有 1_000 个文件需要重写,因此我们可以快速执行。虽然但是,当 SvelteKit 仍处于积极开发阶段时,我们就冒着风险开始迁移到 SvelteKit,这导致我们在迁移仅 1 个月后就不得不做出破坏性更新。但 SvelteKit 专业且博大精深的团队为我们提供了一个命令(npx svelte-migrate routes),以及一个解释清晰的迁移指南,真正帮助我们快速适应新的更新。


此外,9 月份,SvelteKit 团队宣布该框架终于进入候选版本阶段,这意味着,它的稳定性现在得到了保证!


文件和组件组织


SvelteKit 的“文件夹筑基路由”给我们带来了很多。我们可以将页面拆分为子页面,复用标准变量名,比如 loading/submit 等等。此外,布局直接集成到相关路由中,由于树内组织的增加,访问起来更简单。


那么我们得到了什么?


除了上述好处之外,还值得探讨其他某些关键因素:


性能提高且更流畅。编译完成后,我们可以体会到该 App 的轻量级。与其他框架相比,这提高了加载速度,其他框架在 App 的逻辑代码旁嵌入了“运行时”。


DX 更棒。SvelteKit 使用 Vite 打包器,此乃新一代 JS 构建工具,它利用浏览器中 ES 模块的可用性和编译为原生(compile-to-native)的打包器,为您带来最新 JS 技术的最佳 DX。


代码执行更快。它没有虚拟 DOM,因此在页面上变更时,需要执行的层数少了一层。


启动并运行 SSR(服务器端渲染)。如果最终用户没有良好的互联网连接或启用 JS,平台仍将在 SSR 的帮助下高效运行,因为用户仍能加载网页,同时失去交互性。


代码简洁易懂。Svelte 通过将逻辑(JS)、结构(HTML)和样式(CSS)分组到同一文件中,可以使用更具可读性和可维护性的面向组件的代码。黑科技在于所有这些元素都编译在 .svelte 文件中。


固定类型检查。自从我们迁移到 Svelte 以来,我们已经成功解决了类型检查的最初问题。事实上,我们以前必须处理周期性的通知,而如今时过境迁。不再出现头大的哨兵错误。(见下文)


07-error.jpg


粉丝请注意,此博客乃之前的迁移测评,其中某些基准测试见仁见智,尤大还亲自码字撰写博客布道分享,我们之后会继续翻译 Vue 官方博客详细说明。



免责声明


本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 Migrating from Vue 2 to Svelte



您现在收看的是《前端翻译计划》,学废了的小伙伴可以订阅此专栏合集,我们每天佛系投稿,欢迎关注地球猫猫教。谢谢大家的点赞,掰掰~


26-cat.gif


作者:人猫神话
来源:juejin.cn/post/7317222425384714294
收起阅读 »

看我如何用JDBC数据库连接池,轻松解决大量并发请求问题!

我们已经知道JDBC是Java语言中用来规范客户端程序如何访问数据库的应用程序接口,也是大多数Java开发者与数据库打交道的必备工具。但是,你是否知道,JDBC在处理大量并发请求时,可能会遇到一些问题?这就是我们今天要讨论的主题——JDBC数据库连接池。首先,...
继续阅读 »

我们已经知道JDBC是Java语言中用来规范客户端程序如何访问数据库的应用程序接口,也是大多数Java开发者与数据库打交道的必备工具。

但是,你是否知道,JDBC在处理大量并发请求时,可能会遇到一些问题?这就是我们今天要讨论的主题——JDBC数据库连接池。

首先,让我们来了解一下什么是数据库连接池。

一、数据库连接池简介

JDBC连接池,全称为Java多线程数据库连接池,是一种用于管理数据库连接的技术。其主要作用是减少每次请求时创建和释放数据库连接的开销,以此提高系统性能。

在应用程序和数据库之间,JDBC连接池会建立一个连接池,当需要访问数据库时,无需每次都重新创建连接,而是直接从池中获取已有的连接。

Description

总结一下就是:

  • 数据库连接池是个容器,负责分配、管理数据库连接(Connection)

  • 它允许应用程序重复使用一个现有的数据库连接,而不是再重新建立一个。

  • 释放空闲时间超过最大空闲时间的数据库连接来避免因为没有释放数据库连接而引起的数据库连接遗漏。

那么,为什么我们需要JDBC数据库连接池呢?

这主要有以下几个原因:

1.提高性能: 频繁地创建和销毁数据库连接会消耗大量的系统资源,而通过使用连接池,可以大大减少这部分开销,提高系统的性能。

2.提高稳定性: 在高并发的情况下,如果直接使用JDBC创建数据库连接,可能会出现系统无法创建更多的数据库连接的情况,导致系统崩溃。而通过使用连接池,可以有效地控制并发请求的数量,保证系统的稳定性。

3.提高数据库的响应速度: 通过使用连接池,可以减少等待数据库连接的时间,从而提高系统的响应速度。

之前我们代码中使用连接是没有使用都创建一个Connection对象,使用完毕就会将其销毁。这样重复创建销毁的过程是特别耗费计算机的性能的及消耗时间的。

而数据库使用了数据库连接池后,就能达到Connection对象的复用,如下图:

Description

  • 连接池是在一开始就创建好了一些连接(Connection)对象存储起来。用户需要连接数据库时,不需要自己创建连接;

  • 而只需要从连接池中获取一个连接进行使用,使用完毕后再将连接对象归还给连接池。

这样就可以起到资源重用,也节省了频繁创建连接销毁连接所花费的时间,从而提升了系统响应的速度。

你还在苦恼找不到真正免费的编程学习平台吗?可以试试云端源想!课程视频、在线书籍、在线编程、实验场景模拟、一对一咨询……你想要的全部学习资源这里都有,重点是统统免费!点这里即可查看


二、数据库连接池实现

1、标准接口:

javax.sql.DataSource。

官方(SUN公司)为数据库连接池提供了一套标准接口,由第三方组织实现此接口。

  • 核心方法:Connection getConnection(),获取连接。

Description

2、常见的数据库连接池:

JDBC的数据库连接池使用javax.sql.DataSource来表示,DataSource只是一个接口,该接口通常由第三方来实现。

市面上有很多开源的JDBC数据库连接池,如C3P0、DBCP、Druid等,它们都有各自的特点和优势。

C3P0数据库连接池: 速度相对较慢(只是慢一丢丢),但是稳定性很好,Hibernate,Spring底层用的就是C3P0。

DBCP数据库连接池: 速度比C3P0快,但是稳定性差。

Proxool数据库连接池: 有监控连接池状态的功能,但稳定性仍然比C3P0差一些。

BoneCP数据库连接池: 速度较快。

Druid数据库连接池(德鲁伊连接池): 由阿里提供,集DBCP,Proxool,C3P0连接池的优点于一身,是日常项目开发中使用频率最高的数据库连接池。

三、Durid(德鲁伊连接池)的使用

Druid使用步骤:

  • 导入jar包 druid-1.1.12.jar。

  • 定义配置文件。

  • 加载配置文件。

  • 获取数据库连接池对象。

  • 获取连接。

druid.properties配置文件:

driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql://localhost:3306/jdbc_test?useSSL=false&useServerPrepStmts=true
username=root
password=123456
# 初始化连接数量
initialSize=5
# 最大连接数
maxActive=10
# 最大等待时间
maxWait=3000

代码示例:


package com.green.druid;


import com.alibaba.druid.pool.DruidDataSourceFactory;

import javax.sql.DataSource;
import java.io.FileInputStream;
import java.sql.Connection;
import java.util.Properties;

public class DruidDemo {

public static void main(String[] args) throws Exception {
//1、导入jar包


//2、定义配置文件


//3、加载配置文件
Properties prop = new Properties();
prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
//System.out.println(System.getProperty("user.dir")); //当前文件目录 D:\code\JDBC


//4、获取连接池对象
DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);


//5、获取数据库连接 Connection
Connection conn = dataSource.getConnection();


System.out.println(conn);


}
}

以上就是JDBC数据连接池的简介与常见连接池的基本使用,希望对你有所帮助。在未来的开发过程中,不妨尝试使用JDBC数据库连接池,让你的应用性能更上一层楼!

收起阅读 »

js跨标签页通信

web
一、为什么要跨标签页通信 在web开发中,有时会有这样的情况,A页面中打开了B页面,B页面中操作了一些内容,再回到A页面时,需要看到更新后的内容。这种场景在电商、支付、社交等领域经常出现。 二、实现跨标签页通信的几种方式 2.1 localStorage 打...
继续阅读 »

一、为什么要跨标签页通信


在web开发中,有时会有这样的情况,A页面中打开了B页面,B页面中操作了一些内容,再回到A页面时,需要看到更新后的内容。这种场景在电商、支付、社交等领域经常出现。


二、实现跨标签页通信的几种方式


2.1 localStorage


image.png


打开A页面,可以看到localStorage和sessionStorage中都存储了testA:


image.png
image.png

B页面中,可以获取到localStorage,但是无法获取到sessionStorage:


image.png

2.2 BroadcastChannel


BroadcastChannel允许同源下浏览器不同窗口订阅它,postMessage方法用于发送消息,message事件用于接收消息。


A页面:


      const bc = new BroadcastChannel('test')

bc.postMessage('不去上班行吗?')

B页面:


      const bc = new BroadcastChannel('test')

bc.onmessage = (e) => {
console.log(e)
}

动画.gif


2.3 postMessage(跨源通信)


image.png


2.3.1 iframe跨域数据传递


parent.html


<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<h1>主页面</h1>
<iframe id="child" src="http://10.7.9.69:8080"></iframe>
<div>
<h2>主页面跨域接收消息区域</h2>
<div id="message"></div>
</div>
</body>
<script>
// 传递数据到子页面
window.onload = function () {
// 第二个参数表示哪个窗口可以接收到消息
document.getElementById('child').contentWindow.postMessage('不上班行不行', 'http://10.7.9.69:8080')
}
// 接受子页面传递过来的数据
window.addEventListener('message', function (event) {
document.getElementById('message').innerHTML = '收到' + event.origin + '消息:' + event.data
})
</script>
</html>


App.vue


<template>
<div class="app">
<div id="message"></div>
</div>

</template>
<script>
export default {
created() {
// 接收父页面传过来的数据
window.addEventListener('message', function (event) {
// 处理addEventListener执行两次的情况,避免获取不到data
// 因此判断接收的域是否是父页面
console.log('event', event)
if (event.origin.includes('http://127.0.0.1:5501')) {
document.getElementById('message').innerHTML = '收到' + event.origin + '消息:' + event.data
// 把数据传递给父页面 window.parent === top
window.parent.postMessage('不上班你养我啊', 'http://127.0.0.1:5501')
}
})
},
}
</script>


image.png


注:



  1. http://127.0.0.1:5501/ 是使用 Open with Live Server打开后的地址

  2. http://10.7.9.69:8080/ 是启动vue后的地址


2.3.2 postMessage在window.open()中的使用


作者:蓝色海岛
来源:juejin.cn/post/7315354087829536803
收起阅读 »

【前端考古】没有await,如何处理“回调地狱”

web
太长不看 不要嵌套使用函数。给每个函数命名并把他们放在你代码的顶层 利用函数提升。先使用后声明。 处理每一个异常 编写可以复用的函数,并把他们封装成一个模块 什么是“回调地狱”? 异步Javascript代码,或者说使用callback的Javascrip...
继续阅读 »

太长不看



  • 不要嵌套使用函数。给每个函数命名并把他们放在你代码的顶层

  • 利用函数提升。先使用后声明。

  • 处理每一个异常

  • 编写可以复用的函数,并把他们封装成一个模块


什么是“回调地狱”?


异步Javascript代码,或者说使用callback的Javascript代码,很难符合我们的直观理解。很多代码最终会写成这样:


fs.readdir(source, function (err, files) {
if (err) {
console.log('Error finding files: ' + err)
} else {
files.forEach(function (filename, fileIndex) {
console.log(filename)
gm(source + filename).size(function (err, values) {
if (err) {
console.log('Error identifying file size: ' + err)
} else {
console.log(filename + ' : ' + values)
aspect = (values.width / values.height)
widths.forEach(function (width, widthIndex) {
height = Math.round(width / aspect)
console.log('resizing ' + filename + 'to ' + height + 'x' + height)
this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
if (err) console.log('Error writing file: ' + err)
})
}.bind(this))
}
})
})
}
})

看到上面金字塔形状的代码和那些末尾参差不齐的 }) 了吗?这就是广为人知的回调地狱了。

人们在编写JavaScript代码时,误认为代码是按照我们看到的代码顺序从上到下执行的,这就是造成回调地狱的原因。在其他语言中,例如C,Ruby或者Python,第一行代码执行结束后,才会开始执行第二行代码,按照这种模式一直到执行到当前文件中最后一行代码。随着你学习深入,你会发现JavaScript跟他们是不一样的。


什么是回调(callback)?


某种使用JavaScript函数的惯例用法的名字叫做回调。JavaScript语言中没有一个叫“回调”的东西,它仅仅是一个惯例用法的名字。大多数函数会立刻返回执行结果,使用回调的函数通常会经过一段时间后才输出结果。名词“异步”,简称“async”,只是意味着“这将花费一点时间”或者说“在将来某个时间发生而不是现在”。通常回调只使用在I/O操作中,例如下载文件,读取文件,连接数据库等等。


当你调用一个正常的函数时,你可以向下面的代码那样使用它的返回值:


var result = multiplyTwoNumbers(5, 10)
console.log(result)
// 50 gets printed out

然而使用回调的异步函数不会立刻返回任何结果。


var photo = downloadPhoto('http://coolcats.com/cat.gif')
// photo is 'undefined'!

在这种情况下,上面那张gif图片可能需要很长的时间才能下载完成,但你不想你的程序在等待下载完成的过程中中止(也叫阻塞)。


于是你把需要下载完成后运行的代码存放到一个函数中(等待下载完成后再运行它)。这就是回调!你把回调传递给downloadPhoto函数,当下载结束,回调会被调用。如果下载成功,传入photo给回调;下载失败,传入error给回调。


downloadPhoto('http://coolcats.com/cat.gif', handlePhoto)

function handlePhoto (error, photo) {
if (error) console.error('Download error!', error)
else console.log('Download finished', photo)
}

console.log('Download started')

人们理解回调的最大障碍在于理解一个程序的执行顺序。在上面的例子中,发生了三件事情。



  1. 声明handlePhoto函数

  2. downloadPhoto函数被调用并且传入了handlePhoto最为它的回调

  3. 打印出Download started


请大家注意,起初handlePhoto函数仅仅是被创建并被作为回调传递给了downloadPhoto,它还没有被调用。它会等待downloadPhoto函数完成了它的任务才会执行。这可能需要很长一段时间(取决于网速的快慢)。


这个例子意在阐明两个重要的概念:



  1. handlePhoto回调只是一个存放将来进行的操作的方式

  2. 事情发生的顺序并不是直观上看到的从上到下,它会当某些事情完成后再跳回来执行。


怎样解决“回调地狱”问题?


糟糕的编码习惯造成了回调地狱。幸运的是,编写优雅的代码不是那么难!


你只需要遵循三大原则


1. 减少嵌套层数(Keep your code shallow)


下面是一堆乱糟糟的代码,使用browser-request做AJAX请求。


**


var form = document.querySelector('form')
form.onsubmit = function (submitEvent) {
var name = document.querySelector('input').value
request({
uri: "http://example.com/upload",
body: name,
method: "POST"
}, function (err, response, body) {
var statusMessage = document.querySelector('.status')
if (err) return statusMessage.value = err
statusMessage.value = body
})
}

这段代码包含两个匿名函数,我们来给他们命名。


var form = document.querySelector('form')
form.onsubmit = function formSubmit (submitEvent) {
var name = document.querySelector('input').value
request({
uri: "http://example.com/upload",
body: name,
method: "POST"
}, function postResponse (err, response, body) {
var statusMessage = document.querySelector('.status')
if (err) return statusMessage.value = err
statusMessage.value = body
})
}

如你所见,给匿名函数一个名字是多么简单,而且好处立竿见影:



  • 起一个一望便知其函数功能的名字让代码更易读

  • 当抛出异常时,你可以在stacktrace里看到实际出异常的函数名字,而不是"anonymous"

  • 允许你合理安排函数的位置,并通过函数名字调用它


现在我们可以把这些函数放在我们程序的顶层。


document.querySelector('form').onsubmit = formSubmit

function formSubmit (submitEvent) {
var name = document.querySelector('input').value
request({
uri: "http://example.com/upload",
body: name,
method: "POST"
}, postResponse)
}

function postResponse (err, response, body) {
var statusMessage = document.querySelector('.status')
if (err) return statusMessage.value = err
statusMessage.value = body
}

请大家注意,函数声明在程序的底部,但是我们在函数声明之前就可以调用它。这是函数提升的作用。


2.模块化(Modularize)


任何人都有有能力创建模块,这点非常重要。写出一些小模块,每个模块只做一件事情,然后把他们组合起来放入其他的模块做一个复杂的事情。只要你不想陷入回调地狱,你就不会。让我们把上面的例子修改一下,改为一个模块。


下面是一个名为formuploader.js的新文件,包含了我们之前使用过的两个函数。


module.exports.submit = formSubmit

function formSubmit (submitEvent) {
var name = document.querySelector('input').value
request({
uri: "http://example.com/upload",
body: name,
method: "POST"
}, postResponse)
}

function postResponse (err, response, body) {
var statusMessage = document.querySelector('.status')
if (err) return statusMessage.value = err
statusMessage.value = body
}

module.exports是node.js模块化的用法。现在已经有了 formuploader.js 文件,我们只需要引入它并使用它。请看下面的代码:


var formUploader = require('formuploader')
document.querySelector('form').onsubmit = formUploader.submit

我们的应用只有两行代码并且还有以下好处:



  1. 方便新开发人员理解你的代码 -- 他们不需要费尽力气读完formuploader函数的全部代码

  2. formuploader可以在其他地方复用


3.处理每一个异常(Handle every single error)


有三种不同类型的异常:语法异常,运行时异常和平台异常。语法异常通常由开发人员在第一次解释代码时捕获,运行时异常通常在代码运行过程中因为bug触发,平台异常通常由于没有文件的权限,硬盘错误,无网络链接等问题造成。这一部分主要来处理最后一种异常:平台异常。


前两个大原则意在提高代码可读性,但是第三个原则意在提高代码的稳定性。在你与回调打交道的时候,你通常要处理发送请求,等待返回或者放弃请求等任务。任何有经验的开发人员都会告诉你,你从来不知道哪里回出现问题。所以你有必要提前准备好,异常总是会发生。


把回调函数的第一个参数设置为error对象,是Node.js中处理异常最流行的方式。


var fs = require('fs')

fs.readFile('/Does/not/exist', handleFile)

function handleFile (error, file) {
if (error) return console.error('Uhoh, there was an error', error)
// otherwise, continue on and use `file` in your code
}

把第一个参数设为error对象是一个约定俗成的惯例,提醒你记得去处理异常。如果它是第二个参数,你更容易把它忽略掉。


作者:Max力出奇迹
来源:juejin.cn/post/7294166986195533843
收起阅读 »

做了几年前端,别跟我说没配置过webpack

web
引言 webpack中文官网:webpack.docschina.org/concepts/ webpack是一个javascript常用的静态模块打包工具,通过配置webpack,可以实现包括压缩文件,解析不同文件格式等强大的功能,作者发现很多同学对webp...
继续阅读 »

引言


webpack中文官网:webpack.docschina.org/concepts/


webpack是一个javascript常用的静态模块打包工具,通过配置webpack,可以实现包括压缩文件,解析不同文件格式等强大的功能,作者发现很多同学对webpack的认知都停留在入口出口以及简单的loader和plugin配置上,对webpack的核心原理都一知半解。本文期望通过更深层的解读,让读者能更彻底地理解这个打包工具的来龙去脉。


为什么要用webpack


在webpack等打包工具出世之前,我们普通的H5项目是怎么处理错综复杂的脚本呢?
第一种方式:引用不同的脚本去使用不同的功能,但脚本太多的时候会导致网络瓶颈
第二种方式:使用一个大型js文件去引入所有代码,但这样会严重影响可读性,可维护性,作用域。


举个栗子:
由于浏览器不能直接解析less文件,我们可通过引入转换的插件(file watcher)把less实时转换为css并引入,但项目里面会多出一个map跟css文件,造成项目文件的臃肿。


官方文档的说法:


node.js诞生可以让Javasrcipt在浏览器环境之外使用,而webpack运行在node.js中。CommonJS的require机制允许在文件中引用某个模块,如此一来就可以解决作用域的问题。


    const HtmlWebpackPlugin = require('html-webpack-plugin')

webpack 关心性能和加载时间;它始终在改进或添加新功能,例如:异步地加载 chunk 和预取,以便为你的项目和用户提供最佳体验


核心概念


webpack有7个核心概念:



  1. 入口(entry)

  2. 输出(output)

  3. loader

  4. 插件(plugin)

  5. 模式(mode)

  6. 浏览器兼容性(brower compatibility)

  7. 环境(environment)


新建一个build文件夹,里面新建一个webpack.config.js


入口entry


这是打包的入口文件,所有的脚本将从这个入口文件开始


单入口


    const path = require('path')
module.exports = {
entry: path.resolve(__dirname, '../src/main.js')
}

多入口


使用对象语法配置,更好扩展,例如一个项目有前台网页跟后台管理两个项目可用多入口管理。


    entry: {
app: path.resolve(__dirname, '../src/main.js'),
admin: path.resolve(__dirname, '../src/admin.js'),
},

输出output


打包后输出的文件,[name]跟[hash:8]表示文件名跟入口的保持一致但后面加上了hash的后缀让每次生成的文件名是唯一的。


单入口


module.exports = {
output: {
filename: '[name].[hash:8].js',
path: path.resolve(__dirname, '../dist'),
},
}

多入口


module.exports = {
entry: {
app: './src/app.js',
admin: './src/admin.js',
},
output: {
filename: '\[name].js',
path: \_\_dirname + '/dist',
},
};
// 写入到硬盘:./dist/app.js, ./dist/admin.js

loader转化器


用于模块的源码的转换,也是同学们在配置webpack的时候频繁接触的一个配置项。


举个例子,加载typescript跟css文件需要用到ts-loader跟css-loader、style-loader,
如果没有对应的loader,打包会直接error掉。


image.png


我们可以这么配置:先 npm i css-loader style-loader


module: {
rules: [
{
test: /\.css\$/,
use: ['style-loader','css-loader']
},
{
test: /\.ts\$/,
use: 'ts-loader'
}

必须留意的是,loader的执行是从右到左,就是css-loader执行完,再交给style-loader执行,


plugin插件


这是webpack配置的核心,有一些loader无法实现的功能,就通过plugin去扩展,建立一个规范的插件系统,能让你每次搭建项目的时候省去很多成本。


举个例子,我们会使用HtmlWebpackPlugin这个插件去生成一个html,其中会引入入口文件main.js。
假设不用这个插件,会发生什么?


当然是不会生成这个html,因此HtmlWebpackPlugin插件也是webpack的必备配置之一


const HtmlWebpackPlugin = require('html-webpack-plugin')

plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../public/index.html') // 同时引入main.js的hash名字
}),
],

模式mode


mode一共有production,development,node三种,如果没有设置,会默认为production


不同的mode对于默认优化的选项有所不同,环境变量也不同,具体需要了解每个插件的具体使用


选项描述
development会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 development
production会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 production。为模块和 chunk 启用确定性的混淆名称,FlagDependencyUsagePluginFlagIncludedChunksPluginModuleConcatenationPluginNoEmitOnErrorsPlugin 和 TerserPlugin
none没优化选项

module.exports = {
mode: 'production'
}

source-map 的解读


Sourcemap 本质上是一个信息文件,里面储存着代码转换前后的对应位置信息。它记录了转换压缩后的代码所对应的转换前的源代码位置,是源代码和生产代码的映射。 Sourcemap 解决了在打包过程中,代码经过压缩,去空格以及 babel 编译转化后,由于代码之间差异性过大,造成无法debug的问题


当mode为development时,devtool默认为‘eval’,当mode为production时,devtool默认为false。


sourceMap的分类



  • source-map:外部。可以查看错误代码准确信息和源代码的错误位置。

  • inline-source-map:内联。只生成一个内联 Source Map,可以查看错误代码准确信息和源代码的错误位置

  • hidden-source-map:外部用于生产环境。可以查看错误代码准确信息,但不能追踪源代码错误,只能提示到构建后代码的错误位置。

  • eval-source-map:内联。每一个文件都生成对应的 Source Map,都在 eval 中,可以查看错误代码准确信息 和 源代码的错误位置。

  • nosources-source-map:外部。可以查看错误代码错误原因,但不能查看错误代码准确信息,并且没有任何源代码信息。

  • cheap-source-map:外部。可以查看错误代码准确信息和源代码的错误位置,只能把错误精确到整行,忽略列。

  • cheap-module-source-map:外部用于生产环境。可以错误代码准确信息和源代码的错误位置,module 会加入 loader 的 Source Map。

  • eval-cheap-module-source-map: 内联,用于开发环境,构建跟热更新比较快


内联和外部的区别: 外部生成了文件(.map),内联没有。内联构建速度更快。


笔者用的两种配置分为是


// webpack.dev.js
devtool: 'eval-cheap-module-source-map',

// webpack.prod.js
devtool: 'cheap-module-source-map'

浏览器兼容性 brower compatibility


Webpack 支持所有符合 ES5 标准 的浏览器(不支持 IE8 及以下版本)。webpack 的 import() 和 require.ensure() 需要 Promise。如果你想要支持旧版本浏览器,在使用这些表达式之前,还需要提前加载 polyfill


环境 enviroment


本文使用的是webpack5 ,要求Node.js V在10.13.0+


Loader的汇总


笔者汇总了一部分常用的Loader以及其配置事项



  1. 浏览器兼容性:babel-loader

  2. css相关: css/style/less/postcss-loader

  3. vue: vue-loader



在配置loader前,先了解一下基本的配置



  • test: 匹配的文件,多用正则匹配

  • use: 使用loader,多用数组

  • exclude: 调整Loader解析的范围,包括某个路径下的文件,不如node_modules

  • include: 调整Loader解析的范围,包括某个路径下的文件


解决浏览器兼容性:babel


转义语法的babel-loader


譬如把const转为浏览器认识的var,虽然现在大部分主流浏览器都认识ES5之后的语法。


npm i babel-loader @babel/preset-env @babel/core

在rules配置:


{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
},
exclude: /node_modules/
}

转义ES API的babel/polyfill


如果只有babel-loader,浏览器并不能识别出新的API(promise,proxy,includes),如图:


image.png


因此还需要配置一个babel/polyfill,在入口里面:


// npm i @babel/polyfill

entry: ["@babel/polyfill",path.resolve(__dirname, '../src/main.js')],

解析vue的vue-loader


vue-loader: 解析vue
vue-template-compiler: 编译vue模板


npm i vue-loader vue-template-compiler vue-style-loader
npm i vue

在rules跟plugins配置:


const { VueLoaderPlugin } = require('vue-loader') // vue3的引入跟vue2路径不同

rules:{
{
test: /\.vue$/,
use: ['vue-loader']
}
},
plugins:[
...
new VueLoaderPlugin()
...
]

配置完成后,vue文件就可以正常解析了


image.png


解析CSS文件


需要引入的Loader不止一个



  • 引入的基本Loader: style-loader,css-loader,如有less还需要less-loader

  • postcss-loader 添加不同浏览器的css前缀: 解决部分css语法在不同浏览器的写法不同的弊端


modules.exports = {
modules: {
rules: [{
test: /\.css$/,
use: [
'style-loader',
'css-loader',
'postcss-loader'
] // 解析css文件必须的style-loader,css-loader
},
{
test: /\.less$/,
use: [
'style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
[
'postcss-preset-env' // 解决css不同浏览器兼容性
],
],
},
}
}, 'less-loader'
]
},
}
}

拆分css


mini-css-extract-plugin: 把css拆分出来用外链的形式引入css文件,然后会在dist生成css文件,为每一个包含了 CSS 的 JS 文件创建一个 CSS 文件,
ps: 使用该插件不能重复使用style-loader


··· 
plugins: [
...
new MiniCssExtractPlugin({
filename: devMode ? "[name].css" : "[name].[hash].css",
chunkFilename: devMode ? "[id].css" : "[id].[hash].css",
}),
...
],
module:{
rules: [{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
// 'style-loader',
'css-loader',
'postcss-loader'
] // 解析css文件必须的style-loader,css-loader
}]
}

打包图片,字体,媒体等文件


file-loader: 就是将文件在进行一些处理后(主要是处理文件名和路径、解析文件url),并将文件移动到输出的目录中


url-loader 一般与file-loader搭配使用,功能与 file-loader 类似,如果文件小于限制的大小。则会返回 base64 编码,否则使用 file-loader 将文件移动到输出的目录中



{
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, //媒体文件
use: [{
loader: 'url-loader',
options: {
limit: 10240,
fallback: {
loader: 'file-loader',
options: {
name: 'img/[name].[hash:8].[ext]'
}
}
}
}],
include: [resolve('src/assets/image')],
exclude: /node_modules/
},
{
test: /\.(jpg|png|gif)$/i,
use: [{
loader: 'url-loader',
options: {
limit: 10240, // KB
fallback: {
loader: 'file-loader',
options: {
name: 'img/[name].[hash:8].[ext]'
}
}
}
}],
include: [resolve('src/assets/image')],
exclude: /node_modules/
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/i, // 字体
use: [{
loader: 'url-loader',
options: {
limit: 10240,
fallback: {
loader: 'file-loader',
options: {
name: 'fonts/[name].[hash:8].[ext]'
}
}
}
}],
include: [resolve('src/assets/icon')],
exclude: /node_modules/
},

使用多线程提升构建速度


js是单线程的工程,在构建工程的过程中,要消耗大量的时间在Loader的转换过程中,为了提升构建速度,这里使用了thread-loader将任务拆分为多个线程去处理。 其原理是把任务分配到各个worker线程中,之前多数人会使用happyPack,但webpack官方使用了thread-loader取代happypack。


...
{
test: /\.js$/,
use: [{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', ],
cacheDirectory: true,
}
},
{
loader: 'thread-loader', // 多线程解析模块
options: {
workers: 3, // 开启几个 worker 进程来处理打包,默认是 os.cpus().length - 1
}
}
],
exclude: /node_modules/
}
...

必须使用的插件Plugins


配置plugins必须注意的是,由于我们的模式(mode)区分为development跟production,因此plugins也需要按照实际需要,在config(公用),dev,prod三个配置文件分开加入。


首先先配置公用部分的plugins


公用plugins


清除打包残留文件


每次执行npm run build 会发现dist文件夹里会残留上次打包的文件,在打包输出前清空文件夹


const {
CleanWebpackPlugin
} = require('clean-webpack-plugin')



用外链的形式引入css


当一个html文件里面的css太多,全部把css添加到html中会显得很臃肿,那我们可以用mini-css-extract-plugin 把css拆分成外链引入,为每一个包含了 CSS 的 JS 文件创建一个 CSS 文件。


需要留意的是不能跟style-loader同时使用,下面用了hash


const MiniCssExtractPlugin = require("mini-css-extract-plugin");
....
plugins: [
new MiniCssExtractPlugin({
filename: devMode ? "[name].css" : "[name].[hash].css",
chunkFilename: devMode ? "[id].css" : "[id].[hash].css",
}, {
filename: devMode ? "[name].css" : "[name].[hash].less",
chunkFilename: devMode ? "[id].css" : "[id].[hash].less",
}),
]
....

module:{
rules:[{
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
// 'style-loader',
'css-loader',
'postcss-loader'
]
},
}]
}
....

生产打包后的html


wepback必备的插件之一,上述举例也有提到。
主要是生产打包后的html, 同时由于main.js文件会随机生成新的hash名字,html在引入main.js文件时频繁改名字会很浪费时间,此插件会自动同步改文件名


const HtmlWebpackPlugin = require('html-webpack-plugin')

plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../public/index.html') // 同时引入main.js的hash名字
}),
],

开发环境Dev


热更新: webpack-dev-server


当我们修改文件的内容时,要重新build一次才能看到变化,这样对开发的效率不友好。


需要留意的是,webpack-dev-server只是在开发环境搭建一个服务帮助开发人员提高开发效率,实现了实时更新的功能,在生产环境并不会用到这一个插件.


同时注意在plugins中加入webpack自带的HotModuleReplacementPlugin。


webpack-dev-server这个插件功能十分强大,官方文档有详细的记录
(webpack.docschina.org/configurati…)


npm i webpack-dev-server --save-dev

module.exports = {
devServer: {
// 基本目录
static: {
directory: path.join(__dirname, 'dist'),
},
// 自动压缩代码
compress: true,
port: 9000,
// 自动打开浏览器
open: true,
// 热加载,默认是true
hot: true,
},
plugins: [
new Webpack.HotModuleReplacementPlugin()
]
}

生产环境Prod


由于生产环境对性能的要求跟开发不同,需要引入的插件比较丰富,也更需要对项目构建有更高的熟悉程度


压缩Js文件


webpack mode设置production的时候会自动压缩js代码。原则上不需要引入terser-webpack-plugin进行重复工作。但是optimize-css-assets-webpack-plugin压缩css的同时会破坏原有的js压缩,所以这里我们引入terser-webpack-plugin进行压缩


option很多,使用了dropconsole去除打印的内容


const TerserPlugin = require("terser-webpack-plugin");

...

optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
compress:{
drop_console: true
}
}
}),
],
},
...


压缩CSS


前面有使用mini-css-extract-plugin的插件去拆分css,但这个插件并不能压缩CSS体积,
使用css-minimizer-webpack-plugin 可以压缩css的体积,但不同的是它是被加入到optimization的minimizer中,跟上述的js压缩插件共同作用


const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
...
optimization: {
minimizer: [
new CssMinimizerPlugin(),
new TerserPlugin({
terserOptions: {
compress:{
drop_console: true
}
}
}),
],
},
...


抽离第三方模块


使用DllReferencePlugin把不需要经常变更的静态文件抽离出来,譬如element-ui组件,这样每次打包的时候就不会再去重新打包选中的静态文件了。


如此一来,当我们修改代码后,webpack只需要打包项目的代码而不需要重复去编译没有发生改变的第三方库。这样当我们没有升级第三方库时,webpack就不会再对这些库进行打包,从而提升项目构建的速度。


首先我们在同级目录下新建文件webpack.dll.config.js,在entry的vendor里面配置了vue跟element-ui。


// webpack.dll.config.js
const path = require("path");
const webpack = require("webpack");
// 每次执行npm run build 会发现dist文件夹里会残留上次打包的文件,在打包输出前清空文件夹
const {
CleanWebpackPlugin
} = require('clean-webpack-plugin')

module.exports = {
mode: 'production',
// 你想要打包的模块的数组
entry: {
vendor: ['vue','element-plus']
},
output: {
path: path.resolve(__dirname, '../public/vendor'), // 打包后文件输出的位置,要在静态资源里面避免被打包转义
filename: '[name].dll.js',
library: 'vendor_library'
// 这里需要和webpack.DllPlugin中的`name: '[name]_library',`保持一致。
},
plugins: [
new CleanWebpackPlugin(),
new webpack.DllPlugin({
path: path.resolve(__dirname, '[name]-manifest.json'),
name: 'vendor_library',
context: __dirname
})
]
};



同时在packake.json里面配置dll的命令


"scripts":{
"dll": "webpack --config build/webpack.dll.config.js",
}

最后在webpack.prod.js 加入配置项


···
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require('./vendor-manifest.json')
}),
···

随后执行命令npm run dll
在public/vendor会出现一个vendor.dll.js文件,我们需要在html文件引入这个文件.


<body>
<!-- dll插件的配置路径,注意是打包后的 -->
<script src="./vendor/vendor.dll.js"></script>
<!-- <img src="../assets/image/logo192.png" alt=""> -->
<!-- <img src="../assets/image/loginbg.png" alt=""> -->

<div id="app"></div>
</body>

配置完毕,这样我们在不需要更新第三方包的时候可以不用执行npm run dll,然后直接执行npm run build/dev的时候就会发现构建速度有所提高。


分析打包后的文件


使用webpack-bundle-analyzer,启动项目后会打开一个展示各个包的大小。从图中可以看出来,es6.promise.js这个包



  • stat size: webpack 从入口文件打包递归到的所有模块体积

  • parsed size: 解析与代码压缩后输出到dist目录的体积

  • gzipped size: 开启Gzip之后的体积


image.png


总结


webpack身为前端必备的一项技能,各位在学会基础的配置之后,千万别忘了因地制宜,看看哪些插件更适合自己的项目哦


作者:广州交租公
来源:juejin.cn/post/7277490138518159379
收起阅读 »

亲测实战大屏项目的适配方法

web
背景 想必在平常开发中,遇到要适配各种不同尺寸的屏幕、分辨率,甚至一套代码要兼容PC端到手机端对前端来说是比较头疼和麻烦的事情。 如果要在时间很紧迫的情况下去一套代码,要在大屏、PC和移动端都可运行,且界面不乱,也不超出范围;确实挺头疼。 我这里倒是有一个亲测...
继续阅读 »

背景


想必在平常开发中,遇到要适配各种不同尺寸的屏幕、分辨率,甚至一套代码要兼容PC端到手机端对前端来说是比较头疼和麻烦的事情。

如果要在时间很紧迫的情况下去一套代码,要在大屏、PC和移动端都可运行,且界面不乱,也不超出范围;确实挺头疼。

我这里倒是有一个亲测且实践的一个特别简单的方法,不用换算,设计给多少px前端就写几px,照着UI设计无头脑的搬就可,而且在大屏、电脑pc端、手机移动端,一套代码,就可运行,保证界面不乱,不超出屏幕范围。

这个方法是在我第一次写大屏项目时,用上的,当时时间也是很紧迫,但是项目上线后,对甲方来说很完美无缺了,一次过。

最后发现它在手机端也能看。

如果好奇想知道究竟是什么方法可以这么的丝滑去兼容大屏乃至任何尺寸任何分辨率的设备的小伙伴们,不妨接着往下看。


常见兼容各尺寸的方法


css媒体查询


@media screen

eg:


@media screen and (max-width: 1700px){
//屏幕尺寸小于1700的样式
}

这种方式,太麻烦,如果要求高,就要分的越细,几乎每个尺寸都要兼顾,写进去,实在实在是太太麻烦了。


viewport


<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no">

网页头部加入这段代码,网页宽度自动适应屏幕的宽度。

这种方式大屏中不太适合,这种更加适合移动端。

亲测,大屏不太适合。


rem


要换算,而且每次要根据屏幕尺寸的变化而去重新给一个新的根节点字体大小值。

还是麻烦,嗯,也是不能完美适合大屏。


vw和vh


把整个屏幕宽高分成一百分,一个vw就是1%的宽,一个vh就是10的高。

也是麻烦,而且也不能完美适配大屏。


百分百%


同vw,vh一样也不能完美适配大屏。


重点来了


最终我用了缩放,完美解决了大屏适配问题。

它不仅可以在任何尺寸任何分辨率下,去相对完美的展示,在PC甚至移动端也是可以看的。


如何?


jsx页面里:


 useEffect(() => {
//全屏模式
fullScreens();
//首次加载应用,设置一次
setScale();
//调试比例
window.addEventListener('resize', Listen(setScale, 100));
}, []);
//调试比例
// 监听手机窗口变化,重新设置
function Listen(fn, t) {
const delay = t || 100;
let timer;
return function () {
const args = arguments;
if (timer) {
clearTimeout(timer);
}
const context = this;
timer = setTimeout(() => {
timer = null;
fn.apply(context, args);
}, delay);
};
}

// 获取放大缩小比例
function getScale() {
const w = window.innerWidth / 1920;
const h = window.innerHeight / 1080;
return w < h ? w : h;
}

// 设置比例
function setScale() {
const bigMainDom = document.getElementById('bigMain');
bigMainDom && (bigMainDom.style.transform = 'scale(' + getScale() + ') translate(-50%, -50%)');
}

css:


 width:1920px;
height:1080px;
transform-origin: 0 0;
position: absolute;
left: 50%;
top: 50%;

想要哪个页面去做这种兼容,就可在哪个页面去这样设置;

利用查找id的方式找到对应的页面元素(这里只要能查找到元素就行,怎么写随自己),然后给对应的元素添加样式 transform ,去设置比例


getScale方法里面:

先求一下宽高的比例,因为设计稿一般使用的分辨率是1920和1080,所以这里使用这个取比例;

最后返回比例的时候,取比例比较小的,这样可以保证都显示出来;

只不过相对小的尺寸会出现空白,大的尺寸那个占满全屏;

js这里的translate(-50%, -50%) 和css里面的position: absolute; left:50%;top: 50%;transform-origin:0 0可保证画面在最中间


注意点:transform-origin:0 0这个很重要哦,用来设置动画的基点,没有这个,怎么都不会居中正常显示的哦。


否则就会出现以下这几个情况:


image.png


image.png


而:


// 设置比例
function setScale() {
const bigMainDom = document.getElementById('bigMain');
bigMainDom && (bigMainDom.style.transform = 'scale(' + getScale() + ') translate(0, 0)');
}

则会:


image.png
Listen方式,是专门设备窗口大小变化时,就会自动调用setScale方法,我设置的是每100秒监听一次,相当于做了一个自适应的处理;

setScale在页面加载时调用了一次,然后也加了一个resize方法;

宽度、长度也设置一样,我写的是1920 * 1080


出现问题,两边有空白?


屏幕没有铺满,左右有空白,这是因为设备上面有地址栏,而导致的;

因为地址栏占了一定的高度,但是设计稿把这块高度没有算进去,因此,实际的整体高度会比设计稿的大,而宽度是一样的,为了能够全部展示,就以大的为主,因此,宽度相对比就会出现空白啦;

image.png


解决


设置成全屏就好啦;


image.png

完美;

因为我的笔记本电脑分辨率高,所以,电脑默认缩放设置的是125%,而不是100%,默认设置为100%整体字体什么都都会变小,观看都不高,所以电脑默认设置为125%啦, 无论电脑默认是多少百分比,只要设置成全屏,都是全部铺满的。


image.png


设置全屏的代码


进入全屏、退出全屏、当前是否全屏模式的代码:

commonMethod.js:


//进入全屏
static fullScreen = (element) => {
if (window.ActiveXObject) {
var WsShell = new ActiveXObject('WScript.Shell');
WsShell.SendKeys('{F11}');
}
//HTML W3C 提议
else if (element.requestFullScreen) {
element.requestFullScreen();
}
//IE11
else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
}
// Webkit (works in Safari5.1 and Chrome 15)
else if (element.webkitRequestFullScreen) {
element.webkitRequestFullScreen();
// setTimeout(()=>{
// console.log(isFullScreen());
// },100)
}
// Firefox (works in nightly)
else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
}
};
//退出全屏
static fullExit = () => {
//IE ActiveXObject
if (window.ActiveXObject) {
var WsShell = new ActiveXObject('WScript.Shell');
WsShell.SendKeys('{F11}');
}
//HTML5 W3C 提议
else if (element.requestFullScreen) {
document.exitFullscreen();
}
//IE 11
else if (element.msRequestFullscreen) {
document.msExitFullscreen();
}
// Webkit (works in Safari5.1 and Chrome 15)
else if (element.webkitRequestFullScreen) {
document.webkitCancelFullScreen();
}
// Firefox (works in nightly)
else if (element.mozRequestFullScreen) {
document.mozCancelFullScreen();
}
};
//当前是否全屏模式
static isFullScreen = () =>{
return document.fullScreen ||
document.mozFullScreen ||
document.webkitIsFullScreen ||
document.msFullscreenElement;
}

这是react项目页面里引用的封装好的方法:


 //全屏
const fullScreens = () => {
if (!CommonMethod.isFullScreen()) {
Modal.info({
content: '开启全屏模式,体验更佳',
okText: '确定',
maskClosable: true,
mask: false,
// centered:true,
width: '200px',
height: '100px',
className: 'fullScreenModal',
onOk() {
CommonMethod.fullScreen(document.getElementsByTagName('html')[0]);
},
onCancel() {
CommonMethod.fullScreen(document.getElementsByTagName('html')[0]);
},
});
}
};

然后大屏页面第一次加载时,调用一次全屏模式方法即可,fullScreens


useEffect(() => {
//全屏模式
fullScreens();
}


注意点


如果在使用transform,缩放的地方,就不能使用其他定位,你会发现使用其他定位(绝对定位、相对定位、固定定位等)会失效;

使用antd里的模态框,就会失效哦。


完结


设置缩放的方法可以试试,并且在不同设备尺寸和分辨率下,大屏、pc、手机端都可以试一波。

回头我把自己上面那个项目在不同设备下运行的截图整理一下,补发出来。

对移动端有点要求的其实还会需要重新写一套代码的,我上面这种方式移动端只是样式没有乱也没有超出屏幕而已,毕竟这个只是专门给大屏做的代码,那么大的尺寸怎么的在手机端看也不会很符合的


作者:浅唱_那一缕阳光
来源:juejin.cn/post/7232229178278903865
收起阅读 »

Vue 2 最后之舞“天鹅挽歌”

web
大家好,这里是大家的林语冰。老粉都知道,我们之前已经在《Vue 2 将死》中已经提及 Vue 2 今年年底将全面停止维护,且最终版即将发布,只打补丁,不再增量更新任何新功能。圣诞节前夕,平安夜之际,Vue 团队正式官宣 —— Vue 2 最后一个补丁版本&nb...
继续阅读 »

大家好,这里是大家的林语冰。老粉都知道,我们之前已经在《Vue 2 将死》中已经提及 Vue 2 今年年底将全面停止维护,且最终版即将发布,只打补丁,不再增量更新任何新功能。

圣诞节前夕,平安夜之际,Vue 团队正式官宣 —— Vue 2 最后一个补丁版本 Vue@2.7.16 正式发布,版本代号“Swan Song”(天鹅挽歌)。

01-swan.png

地球人都知道,去年 Vue 2 官宣了最后一个次版本 Vue@2.7.x,如今 Vue 2 官宣最后一个补丁版本 Vue@2.7.16,也算是为 Vue 2 的最后之舞画上惊叹号!此去经年,再无 Vue 2。

虽然但是,前端踏足之地,Vue 亦生生不息,此乃“Vue 之意志”。故本期《前端翻译计划》一起来重温 Vue@2.7 的官方博客,为 Vue 生态的未来规划未雨绸缪。

00-wall.png

今天我们十分鸡冻地官宣,Vue 2.7(版本代号“火影忍者”)正式发布!

尽管 Vue 3 现在已经是默认版本,但我们特别理解,由于依赖兼容性、浏览器支持的要求或带宽不足无法升级,仍然有一大坨用户被迫滞留在 Vue 2。在 Vue 2.7 中,我们向后移植了 Vue 3 中的某些特色功能,Vue 2 爱好者也可以有福同享。

免责声明

本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 Vue 2.7 "Naruto" Released

向后移植的功能

  • 组合式 API
  • SFC 
收起阅读 »

前端部署真的不简单

web
现在大部分的中小型公司部署前端代码都是比较简单的,主要步骤如下: 首先,通过脚手架提供的命令npm run build打包前端代码,生成dist文件夹; 最后,将dist文件夹丢给后台开发人员放在他们的工程里面,随后台一起部署;现在普遍是前后端分开部署,因此,...
继续阅读 »

现在大部分的中小型公司部署前端代码都是比较简单的,主要步骤如下:


首先,通过脚手架提供的命令npm run build打包前端代码,生成dist文件夹;


最后,将dist文件夹丢给后台开发人员放在他们的工程里面,随后台一起部署;现在普遍是前后端分开部署,因此,利用nginx起一个web服务器,将dist文件夹放到指定的路径下,配置下nginx访问路径,对于请求接口使用proxy_pass进行转发,解决跨域的问题。


更加高端一点的操作,是利用CI/CD + Docker进行自动化部署。


但是,你是否真的想过前端部署真的就这么简单吗?


这其实是一个非常严肃且复杂的问题,因为这关系到线上生产环境的稳定


有一天,从自知乎上看到一篇张云龙大佬在2014年写的文章,非常有启发,即使这篇文章距离现在有快10年了,但是其中的思想仍然熠熠生辉。


因为写的真的是太好了,为了让更多的人看到,所以大部分内容直接就照搬过来,为了让自己加深印象。如果想看原文,原文网址在这里。


那让我们从原始的前端开发讲起。


下图是一个 index.html 页面和它的样式文件 a.css,无需编译,本地预览,丢到服务器,等待用户访问。


image.png


哇,前端这么简单,门槛好低啊。这也是前端有太多人涌入进来的原因。


接着,我们访问页面,看到效果,再查看一下网络请求,200!不错,太完美了!


image.png


那么,研发完成。。。。了么?


等等,这还没完呢!


对于像 BAT 这种公司来说,那些变态的访问量和性能指标,将会让前端一点也不好玩。


看看那个 a.css 的请求,如果每次用户访问页面都要加载,是不是很影响性能,很浪费带宽啊,我们希望最好这样:


image.png


利用304,让浏览器使用本地缓存。


但,这样也就够了吗?


不够!


304叫协商缓存,这玩意还是要和服务器通信一次,我们的优化级别是变态级,所以必须彻底灭掉这个请求,要变成这样:


image.png


强制浏览器使用本地缓存(cache-control/expires),不要和服务器通信。


好了,请求方面的优化已经达到变态级别,那问题来了:你都不让浏览器发资源请求了,这缓存咋更新


很好,相信有人想到了办法:通过更新页面中引用的资源路径,让浏览器主动放弃缓存,加载新资源


像这样:


image.png


下次上线,把链接地址改成新的版本,这就更新资源了。


问题解决了么?当然没有,思考这种情况:


image.png


页面引用了3个 css 文件,而某次上线只改了其中的a.css,如果所有链接都更新版本,就会导致b.cssc.css的缓存也失效,那岂不是又有浪费了?


不难发现,要解决这种问题,必须让url的修改与文件内容关联,也就是说,只有文件内容变化,才会导致相应url的变更,从而实现文件级别的精确缓存控制


什么东西与文件内容相关呢?


我们会很自然的联想到利用数据摘要要算法对文件求摘要信息,摘要信息与文件内容一一对应,就有了一种可以精确到单个文件粒度的缓存控制依据了。


OK,那我们把 url 改成带摘要信息的:


image.png


这回再有文件修改,就只更新那个文件对应的 url 了,想到这里貌似很完美了。你觉得这就够了么?


图样图森破!


现代互联网企业,为了进一步提升网站性能,会把静态资源和动态网页分集群部署,静态资源会被部署到CDN节点上,网页中引用的资源也会变成对应的部署路径:


image.png


好了,当我要更新静态资源的时候,同时也会更新 html 中的引用吧,就好像这样:


image.png


这次发布,同时改了页面结构和样式,也更新了静态资源对应的url地址。现在重点来了,现在要发布代码上线,亲爱的前端研发同学,你来告诉我,咱们是先上线页面,还是先上线静态资源



这里的静态资源不仅仅包括css文件,也包括图片,以及不怎么经常变的资源。




  1. 先部署动态页面,再部署静态资源:在二者部署的时间间隔内,如果有用户访问页面,就会在新的页面结构中加载旧的资源,并且把这个旧版本的资源当做新版本缓存起来,其结果就是:用户访问到了一个样式错乱的页面,除非手动刷新,否则在资源缓存过期之前,页面会一直执行错误。

  2. 先部署静态资源,再部署动态页面:在部署时间间隔之内,有旧版本资源本地缓存的用户访问网站,由于请求的页面是旧版本的,资源引用没有改变,浏览器将直接使用本地缓存,这种情况下页面展现正常;但没有本地缓存或者缓存过期的用户访问网站,就会出现旧版本页面加载新版本资源的情况,导致页面执行错误,但当页面完成部署,这部分用户再次访问页面又会恢复正常了。


好的,上面一坨分析想说的就是:先部署谁都不成!都会导致部署过程中发生页面错乱的问题。


所以,访问量不大的项目,可以让研发同学苦逼一把,等到半夜偷偷上线,先上静态资源,再部署页面,看起来问题少一些。这也是很多公司的部署方案。


但是,大公司超变态,没有这样的绝对低峰期,只有相对低峰期。


所以,为了稳定的服务,还得继续追求极致啊!


这个奇葩问题,起源于资源的 覆盖式发布,用待发布资源覆盖已发布资源,就有这种问题。


解决它也好办,就是实现 非覆盖式发布


image.png


看上图,用文件的摘要信息来对资源文件进行重命名,把摘要信息放到资源文件发布路径中,这样,内容有修改的资源就变成了一个新的文件发布到线上,不会覆盖已有的资源文件。上线过程中,先全量部署静态资源,再灰度部署页面,整个问题就比较完美的解决了。


因为很多前端开发同学不怎么接触部署,对灰度部署不太熟悉,下面将介绍下什么是灰度部署。


软件开发一般都是一个版本一个版本的迭代。新版本上线前都会经过测试,但就算这样,也不能保证上线了不出问题。


所以,在公司里上线新版本代码一般都是通过灰度系统。灰度系统可以把流量划分成多份,一份走新版本代码,一份走老版本代码。


image.png


而且灰度系统支持设置流量的比例,比如可以把走新版本代码的流程设置为 5%,没啥问题了再放到 10%,50%,最后放到 100% 全量。这样可以把出现问题的影响降到最低。


不然一上来就全量,万一出了线上问题,那就是大事故。


另外,灰度系统不止这一个用途,比如,产品不确定某些改动是不是有效的,就要做 AB 实验,也就是要把流量分成两份,一份走 A 版本代码,一份走 B 版本代码。


那这样的灰度系统是怎么实现的呢?其实很多都是用 nginx 实现的。


nginx 是一个反向代理的服务,用户请求发给它,由它转发给具体的应用服务器。


image.png


它的过程如下图所示:


image.png


首先,需要对流量进行染色,即对这个用户进行标注,让这个用户访问服务1,另外的用户访问服务2。染色的方式有很多,可以通过cookie来完成。不同的用户携带的cookie是不同的。第一染色的时候,所有的用户都访问服务1。


然后,第二次访问的时候,nginx根据用户携带的cookie进行转发到不同的服务,这样就完成了灰度访问。


好了,灰度部署就介绍到这里,回到原文讲的先全量部署静态资源,再灰度部署页面,这是什么意思呢?


首先,部署静态资源的时候,不要删除原来的静态资源,而是把新的静态资源发复制过去,因为文件名用摘要算法重命名的,所以不会发生重名的问题。


其次,灰度部署动态页面,也就是一部分用户访问老的页面,一部分用户访问新的页面。访问老页面的用户请求的还是老资源,直接使用缓存。访问新页面的用户访问新资源,此时新资源已经部署完成,所以不会访问老的资源,导致页面出现错误。


最后,根据访问情况,利用灰度系统,逐渐把访问老页面的用户过渡到访问新页面上。


所以,大公司的静态资源优化方案,基本上要实现这么几个东西:



  1. 配置超长时间的本地缓存:节省带宽,提高性能

  2. 采用内容摘要作为缓存更新依据:精确的缓存控制

  3. 静态资源CDN部署:优化网络请求

  4. 更资源发布路径实现非覆盖式发布:平滑升级


全套做下来,就是相对比较完整的静态资源缓存控制方案了,而且,还要注意的是,静态资源的缓存控制要求在前端所有静态资源加载的位置都要做这样的处理


是的,所有!


什么js、css自不必说,还要包括js、css文件中引用的资源路径,由于涉及到摘要信息,引用资源的摘要信息也会引起引用文件本身的内容改变,从而形成级联的摘要变化,大概就是:


image.png


到这里本文结束了,我们已经了解了前端部署中关于静态资源缓存要面临的优化和部署问题,新的问题又来了:这™让工程师怎么写码啊!!!


这又会扯出一堆有关模块化开发、资源加载、请求合并、前端框架等等的工程问题。


妈妈,我再也不玩前端了。。。。


作者:小p
来源:juejin.cn/post/7316202725330796571
收起阅读 »

封装一个丝滑的评分组件

web
效果 实现 找到喜欢的图标 http://www.iconfont.cn/ 打开 iconfont,随便搜个点赞,仅需一个点赞即可,节省内存,另一个用CSS完成 下载解压 取出如下文件,在项目入口导入 iconfont.css,或者统一放入一个文件导入也...
继续阅读 »

效果


score.gif


实现


找到喜欢的图标


http://www.iconfont.cn/


打开 iconfont,随便搜个点赞,仅需一个点赞即可,节省内存,另一个用CSS完成


image.png


下载解压


image.png


取出如下文件,在项目入口导入 iconfont.css,或者统一放入一个文件导入也行


image.png


编写组件


一个点赞,就叫Like


一个点踩,就叫Hate


Hate点踩组件


先写属性,就几个,不用注释也明白


分别是



  • 绑定值 modelValue

  • 默认颜色 defaultColor

  • 激活颜色 activeColor

  • 大小 size


image.png


HTML给个容器,再加个固定格式(xxx-container)的容器类名,防止样式穿透


再把iconfont样式加上,图标就出来了


这些用VSCode自定义代码片段都能自动生成


image.png


现在是这样的图标,也就是点赞


image.png


那么怎样让它变成点踩手势呢?答案是rotate旋转


加上下图类名有用吗? 答案是没用,他并不会旋转


image.png


为什么呢?因为i标签是行盒模型,必须变成块盒才行


方式有很多,不过我就爱flex,优点很多,这里就不赘述了


image.png


这不就转起来了吗


image.png


动效


加个鼠标移入变色效果,考虑到一会还有另一个点赞组件,那就写个通用的sass


接收一个激活颜色和默认颜色参数


image.png


导入并使用,下面用了个v-bind绑定当前被点击的颜色,下面来实现逻辑


image.png


上面接收了一个modelValue,类型是布尔值,当他为真时就把颜色改为激活颜色


于是就理所当然的使用计算属性


image.png


事件


当被点击或hover时,就激活图标颜色,现在还差点击


点击事件要做 3 件事



  1. 改变父组件的值

  2. 改变颜色

  3. 实现开头的丝滑动画


image.png


这里用一个showAnimation变量控制动画展示


再改变父组件的值,父组件的值一变,自己的颜色也会跟着变


还差个动画,只要让动画在其中一段不停反复横跳,即可实现国际友好手势


image.png


但这样有问题,只有第一次触发动画才有动效,后续需要改变showAnimation的值


那么怎么知道动画什么时候结束呢?


答案是事件onanimationend,只要在这个事件把动画关掉,点击时开启即可


image.png


ok,实现最主要的组件了,另一个点赞就是复制改东改西,大家都会


score.gif


组合组件


接下来需要把点赞和点踩组合一起,那就叫Gr0up


需要实现逻辑



  1. 根据父组件的值,动态展示

  2. 点击时传递一个事件,告诉父组件,究竟是点赞还是点踩

  3. 提供一个值,锁定按钮,因为点完之后一般都不能反悔

  4. 提供样式设置,传递到子组件


类型


传递一个固定类型的值,才能分辨是什么操作


image.png


null就是初始状态,无操作,另外俩见名知义


那么现在需要编写自身的状态


image.png



  • wasChoose是否有按钮被选中

  • disabled当被选中,同时父组件完成(done),就禁用


样式


image.png


初始化


建议写代码都提供一个入口,后面有空出一期如何像写诗一般写代码


image.png


事件


当被点击时,改变两个状态,没什么好说的,完成


image.png


测试


test.gif



源码: gitee.com/cjl2385/dig…



作者:寅时码
来源:juejin.cn/post/7316321509857034280
收起阅读 »

“来同学在用户点击后退的时候加个弹窗做个引导”

web
文章起因这篇文章的起因来源于一个需求,产品:“我需要在浏览器点击后退的时候,加一个弹框引导用户做其他的操作”,老实讲这一块之前研究了很多次通过popstate去监听,但是始终有问题没有达到理想的状态或者说并没有完全研究透彻,本来想直接回复做不了砍掉这里吧,后来...
继续阅读 »

文章起因

这篇文章的起因来源于一个需求,产品:“我需要在浏览器点击后退的时候,加一个弹框引导用户做其他的操作”,老实讲这一块之前研究了很多次通过popstate去监听,但是始终有问题没有达到理想的状态或者说并没有完全研究透彻,本来想直接回复做不了砍掉这里吧,后来出于职业道德的遵守还是说试试看吧!

后来

之后在网上遨游了一段时间找了很多实现方案最后发现有一个Api叫做prompt,他来自于react-router,正好 项目中目前使用的路由就是react-router

import { Prompt } from 'react-router' //v5.2.0版本

我不太清楚看到这篇文章的同学有没有用到过这个Api,我大致介绍一下用法

const App = () =>{
const [isBlocking, setIsBlocking] = useState(true)

return <>
<Prompt
//这里是个Boolean 控制是否监听浏览器的后退 默认监听
when={isBlocking}
message={(_, action) =>
{
if (action === 'POP') {
Dialog.show({ //普普通通的弹框而已,,,,
title: '提示',
actions: [
{
key: 'back',
text: '回到浪浪山',
onClick() {
history.go(-1)
//用户选择按钮之后关闭掉监听
setIsBlocking(false)
},
},
{
key: 'saveAndBack',
text: '去往光明顶',
onClick: async () => null
},
{
key: 'cancle',
text: intl.t('取消'),
},
],
})
return false // 返回false通知该组件需要拦截到后退的操作 将执行权交给用户
}
return true //返回true 正常后退不做拦截
}}
>Prompt>

{/* ...内容部分 */}

}
export default App

上面这样可以实现我的需求,但是因为之前研究过这好一阵子那会并没找到这个Api,现在找到了本着一种知其然知其所以然的态度,深究一下内部到底是怎么实现可以禁止浏览器后退的,如果你不知道就跟着一起寻找一下吧,可能需要占用一杯咖啡的时间☕️

| Prompt

最初的想法就是直接去看Prompt实现的源码就好了,看看是怎么实现的这里的逻辑 其实在看之前内心是有一些猜测的觉得可能是下面这样做的

  • 可能是有一些浏览器提供的api但是我不清楚可以直接做到禁止后退,然后Prompt内部有调用
  • 或者是先添加了浏览器记录然后在后退的时候监听又删除

git上找react-router源码,注意要切换到对应的版本V5.2.0,免得对不上号

react-router5.2.0版本对应的链接🔽

github.com/remix-run/r…

从这个链接点击去之后我们可以看到Prompt方法,主要是下面这一段我们捡重点解析一下

  1. 获取history上面的block
  2. 在初始化阶段将我们的message传递到block中执行,并且获取到当前的unblock
  3. 离开的时候执行self.release()执行卸载操作
/**
* The public API for prompting the user before navigating away from a screen.
*/

function Prompt({ message, when = true }) {
return (

{context => {
invariant(context, "You should not use outside a ");

if (!when || context.staticContext) return null;

// 这个context是当前使用的环境上下文我们内部路由跳转用的history的包
const method = context.history.block;

return (
{
//初始化的阶段执行该方法
self.release = method(message);
}}
onUpdate={(self, prevProps) => {
if (prevProps.message !== message) {
self.release();
self.release = method(message);
}
}}
onUnmount={self => {
self.release();
}}
message={message}
/>
);
}}

);
}

既然看到了这里再继续看下 Lifecycle 方法其实这个里面就是执行了一些生命周期,这里就不解析了贴个图大家自己看下吧,都能看懂

github.com/remix-run/r…

image.png

到了这里可能有些疑惑,这里好像并没有什么其他操作就是调用了一个block,起初我以为block是原生history上面提供的方法后来去查了一下api发现上面并没有这个方法,此刻就更加好奇block内部的实现方式了

因为我们项目的上下文里面使用的history是来自于一个npm包,后面就继续看看这个history内部怎么实现的

import { createBrowserHistory } from 'history'
const history = createBrowserHistory()

createBrowserHistory

传送门在这里👇🏻感兴趣的同学直接去看源码

github.com/remix-run/h…

直接看里面的block方法

let isBlocked = false

const block = (prompt = false) => {
const unblock = transitionManager.setPrompt(prompt)

if (!isBlocked) {
checkDOMListeners(1)
isBlocked = true
}

return () => {
if (isBlocked) {
isBlocked = false
checkDOMListeners(-1)
}

return unblock()
}
}

现在来分析一下上面的代码

  1. prompt是我们传进来的弹框组件或者普通字符串信息
  2. transitionManager是一个工厂函数将prompt存到了函数内部以便后面触发的时候使用
  3. checkDOMListeners去做挂载操作就是监听popstate那一步
  4. 返回出去的函数是在外面在离开的时候做销毁popstate监听的

现在按照上面的步骤在逐步做代码分析,下面会看具体的部分有些不重要的地方会做删减

| transitionManager.setPrompt

  • 可以看到工厂函数里面存储了prompt
  • 销毁的时机是在上面unblock的时候执行重置prompt
  const createTransitionManager = () => {
let prompt = null
const setPrompt = (nextPrompt) => {
prompt = nextPrompt
return () => {
if (prompt === nextPrompt)
prompt = null
}
}
return {
setPrompt,
}
}

| checkDOMListeners

  • 上面默认传了1初始化的时候会进行popstate监听
  • 离开的时候传了-1移除监听
let listenerCount = 0
const checkDOMListeners = (delta) => {
listenerCount += delta
if (listenerCount === 1) {
addEventListener(window, PopStateEvent, handlePopState)
} else if (listenerCount === 0) {
removeEventListener(window, PopStateEvent, handlePopState)
}
}

| handlePopState

  • 调用getDOMLocation获取到一个location
const handlePopState = (event) => {
handlePop(getDOMLocation(event.state))
}
  • getDOMLocation 内部调用createLocation创建了一个
  • createLocation内部大家感兴趣可以自己去看一下,没有什么可讲的就是创建一些常规的属性
  • 比如state、pathname之类的

const getDOMLocation = (historyState) => {
const { key, state } = (historyState || {})
const { pathname, search, hash } = window.location

let path = pathname + search + hash

if (basename)
path = stripBasename(path, basename)

return createLocation(path, state, key)
}

那我们现在知道getDOMLocation是创建一个location并且传递到了handlePop方法内部现在去看看这个内部都干了啥

| handlePop

  • 我们要看的主要在else里面
  • confirmTransitionTo是我们上面提到的工厂函数里面的一个方法
  • 该方法内部执行了Prompt并返回了Prompt执行后的结果

let forceNextPop = false

const handlePop = (location) => {
if (forceNextPop) {
forceNextPop = false
setState()
} else {
const action = 'POP'

transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
if (ok) {
setState({ action, location })
} else {
revertPop(location)
}
})
}
}

敲黑板 重点来了!!!

现在来看下confirmTransitionTo内部的代码

const confirmTransitionTo = (location, action, getUserConfirmation, callback) => {
if (prompt != null) {
const result = typeof prompt === 'function' ? prompt(location, action) : prompt

if (typeof result === 'string') {
if (typeof getUserConfirmation === 'function') {
getUserConfirmation(result, callback)
} else {
callback(true)
}
} else {
// 重点在这里,result是我们调用block时候的返回参数 true or false
// 如果返回false 那浏览器回退将被禁止 反之则正常
callback(result !== false)
}
} else {
callback(true)
}
}

所以现在回到上面的handlePop函数我们就能推测出如果我们回调中返回的false,说明我们想阻止浏览器的回退操作,那么执行的就是revertPop方法(其实名字大家可能也能猜出来 恢复 pop操作😂)

| revertPop

  • delta的逻辑是计算从开始到目前为止走过的路径做个差值计算
  • 这个时候正常来讲delta应该是1
  • 我们看最后一个逻辑就好这里是禁止撤回的重点
  • 当delta为1的时候就执行了go(1)
  • go方法内部实际调用了window.history.go(n)
const revertPop = (fromLocation) => {
const toLocation = history.location

let toIndex = allKeys.indexOf(toLocation.key)

if (toIndex === -1)
toIndex = 0

let fromIndex = allKeys.indexOf(fromLocation.key)

if (fromIndex === -1)
fromIndex = 0

const delta = toIndex - fromIndex

if (delta) {
forceNextPop = true
//window.history.go
go(delta)
}
}

之前我看到这里有个疑问就是如果最后的结果只是调用了go的话,那这个好像我们自己监听也可以实现一下于是就有了以下代码

function History() {
this.handelState = function (event) {
history.go(1)
}

this.block = function (Prompt) {
window.addEventListener('popstate', this.handelState)
return () => {
window.removeEventListener('popstate', this.handelState)
}
}
}

const newHistory = new History()

等到我实验的时候发现页面回退确实阻止住了,但是会闪一下上一个页面,给大家举个例子

Step1
我从PageA页面一路push到PageC
PageA -> PageB -> PageC

Step2
从PageC页面点击返回,之后页面的过程是这样的
PageC -> PageB -> PageC

就是说我本应该在PageC点击撤回,理想的效果是就停留在了PageC页面,但是目前效果是先回到了PageB因为我使用了go(1)就又回到了PageC,相当于在点击回退的时候多加载了PageB页面

这使我又陷入了沉思,其实研究到这里如果不把这个弄懂之前的努力就白费了,抱着这种想法又扎到了history代码中遨游一番

之后光看代码捋逻辑对这确实有些迷茫,没有办法只能开始调试history的源码了,这里比较简单,history源码下载下来之后做几个步骤

  1. 安装history相关依赖package
  2. 启动服务会有一个本地域名

image.png

  1. 之后在你真实项目中引入这个资源开始做调试

后面其实就一直打log和断点不断调试history源码查看执行路径,发现了问题所在

刚才上面提到的handlePop方法内部有一段代码那会忽略掉了,就是ok为true的时候,因为之前一直关注false的情况忽略了这里,后面把这个里面就研究了一下才明白其中的原委

if (ok) {
setState({ action, location })
} else {
revertPop(location)
}

| setState

这个方法做了几件事

  • 更新本地history状态,nextState可以理解为下一个目标地址其中包含location和action
  • 更新本地history的长度这里没有完全搞懂为什么要更新一下长度,但是猜测可能是为了和原生history状态一直保持同步吧防止出现意外情况
  • 这里又看到了transitionManager工厂函数,此时调用的notifyListeners这个就是解决我们上面的谜团所在
const setState = (nextState) => {

// 1.更新本地history状态
Object.assign(history, nextState)

history.length = globalHistory.length

//2.更新依赖history相关的订阅方法
transitionManager.notifyListeners(
history.location,
history.action
)
}

| notifyListeners

notifyListeners更新订阅的方法,我直接把这块代码贴出来了,一个发布订阅模式没什么好讲的

  let listeners = []

const appendListener = (fn) => {
let isActive = true

const listener = (...args) => {
if (isActive)
fn(...args)
}

listeners.push(listener)

return () => {
isActive = false
listeners = listeners.filter(item => item !== listener)
}
}

const notifyListeners = (...args) => {
listeners.forEach(listener => listener(...args))
}

重点的地方是react-router内部会调用history中的listen,这个listen方法会调用上面的appendListener进行存储,之后在合适的时间点执行react-router中传递的方法

这些方法的入参是目标页面的history属性(location,action),在接收到参数的时候根据参数中的location更新当前的页面

现在可以得出结论我们上面的例子不能成功的原因,是因为我们在执行的过程中没有绕过setState(因为此刻没有能让ok返回false的操作)所以当我们页面路径变更的时候自然页面也会更新

最后整体捋一下这个流程吧

到这里其实细心的同学会发现浏览器的回退我们确实是控制不了的只要点击了就一定会执行后退的操作。在history中针对block方法来说做的事情其实就下面这几步

  1. 封装了一个自己本地的history,当然跳转能力等还是依赖原生的history
  2. 在URL路径变更的时候history可以决定是否通知单页面应用的路由
  3. 如果通知了就相当于我们的ok是true,需要页面也更新一下
  4. 如果未通知相当于ok是false,就不需要页面更新

这就是为什么history里面的block为什么可以用go就能实现当前页面回退,本质上浏览器历史记录确实回退了,但是history并没有通知应用页面更新,并且继续执行go(1)这样给用户看到的视角就是页面没有回退并且url也没有变化

结论

其实在使用的时候history还是有一些问题如果当前页面reload了,那么revertPop里面的go就不会执行,因为此时的delta是0,这样就会导致即使页面没有变化但是url更新成了上一个记录

说一下我的看法这可能是这个history遗留的bug也可能是有意而为之但是也不重要了我们搞懂了原理就好。

其实浏览器后退加监听的行为感觉还是一个比较高频的需求操作,后面我打算自己写一个插件专门就做后退拦截的操作~

到底了------

今天圣诞节了 祝你们圣诞节快乐 Merry Christmas🎄🎄🎄 !!!希望都能收到喜欢的礼物🎁


作者:零狐冲
来源:juejin.cn/post/7316202778790477834

收起阅读 »

浅谈Vue3的逻辑复用

web
Vue3的逻辑复用 使用了 Vue3 Composables 之后,逻辑复用比之前要顺手很多,这里说说前端开发最常用的场景,管理页面的列表查询和增删改查的逻辑复用,欢迎大家共同探讨。 用免费的 render 服务搭建了个在线的预览地址,源码在这里,用了免费的 ...
继续阅读 »

Vue3的逻辑复用


使用了 Vue3 Composables 之后,逻辑复用比之前要顺手很多,这里说说前端开发最常用的场景,管理页面的列表查询和增删改查的逻辑复用,欢迎大家共同探讨。


用免费的 render 服务搭建了个在线的预览地址源码在这里,用了免费的 node 环境和免费的 pg 数据库,对这部分有兴趣的可以看看我以前的分享,我写了个部署 spring-boot 的分享,使用 node 就更简单了。


可能每个人的具体工作内容不一致,但是应该都完成过这样的工作内容:



  1. 列表查询,带分页和过滤条件

  2. 新增,修改,查看,删除

  3. 进行一些快捷操作,比如:激活、通过


这些最基础的工作可能占用了我们很大的时间和精力,下面来讨论下如何逻辑复用,提高工作效率


需求分析


一个后台管理中心,绝大部分都是这种管理页面,那么需要:



  • 首先是封装一些通用的组件,这样代码量最低也容易保持操作逻辑和 UI 的一致性

  • 其次要封装一些逻辑复用,比如进入页面就要进行一次列表查询,翻页的时候需要重新查询

  • 最后需要有一些定制化的能力,最基本的列需要自定义,页面的过滤条件和操作也不一样


统一复用



  1. 发起 http 请求

  2. 展示后端接口返回的信息,有成功或者参数校验失败等信息


列表的查询过程



  1. 页面加载后的首次列表查询

  2. 页面 loading 状态的维护

  3. 翻页的逻辑和翻页后的列表重新查询

  4. 过滤条件和模糊搜索的逻辑,还有对应的列表重新查询


新增 Item、查询 Item、修改 Item



  1. form 在提交过程的禁用状态

  2. 发起网络请求

  3. 后端接口返回的信息提示

  4. 列表重新查询


删除 Item



  1. 删除按钮状态的维护(需要至少一个选中删除按钮才可用)

  2. 发起网络请求

  3. 后端接口返回的信息提示

  4. 列表重新查询


定制化的内容



  1. table 的列数据

  2. item 的属性,也就是具体的表单

  3. 快捷操作:改变 user 激活状态

  4. 列表的过滤条件


成果展示


img



  1. 打开页面会进行一次列表查询

  2. 翻页或者调整页面数量,会进行一次列表查询

  3. 右上角的是否激活筛选状态变更会进行一次列表查询

  4. 右上角模糊搜索,输入内容点击搜索按钮会进行一次列表查询

  5. 点击左上角的新增,弹出表单对话框,进行 item 的新增

  6. 点击操作的“编辑”按钮,弹出表单对话框,对点击的 item 进行编辑

  7. 点击“改变状态”按钮,弹出确认框,改变 user 的激活状态

  8. 选中列表的 checkbox,可以进行删除


代码直接贴在下面了,使用逻辑复用完成以上的内容一共 200 多行,大部分是各种缩进,可以轻松阅读,还写了一个 Work 的管理,也很简单,证明这套东西复用起来没有任何难度。


<template>
<div class="user-mgmt">
<biz-table
:operations="operations"
:filters="filters"
:loading="loading"
:columns="columns"
:data="tableData"
:pagination="pagination"
:row-key="rowKey"
:checked-row-keys="checkedRowKeys"
@operate="onOperate"
@filter-change="onFilterChange"
@update:checked-row-keys="onCheckedRow"
@update:page="onUpdatePage"
@update:page-size="onUpdatePageSize"
/>

<user-item :show="showModel" :item-id="itemId" @model-show-change="onModelShowChange" @refresh-list="queryList" />
</div>
</template>

<script setup name="user-mgmt">
import { h, ref, computed } from 'vue';
import urls from '@/common/urls';
import dayjs from 'dayjs';
import { NButton } from 'naive-ui';
import BizTable from '@/components/table/BizTable.vue';
import UserItem from './UserItem.vue';
import useQueryList from '@/composables/useQueryList';
import useDeleteList from '@/composables/useDeleteList';
import useChangeUserActiveState from './useChangeUserActiveState';

// 自定义列数据
const columns = [
{
type: 'selection'
},
{
title: '姓',
key: 'firstName'
},
{
title: '名',
key: 'lastName'
},
{
title: '是否激活',
key: 'isActive',
render(row) {
return row.isActive ? '已激活' : '未激活';
}
},
{
title: '创建时间',
key: 'createdAt'
},
{
title: '更新时间',
key: 'updatedAt'
},
{
title: '操作',
key: 'actions',
render(row) {
return [
h(
NButton,
{
size: 'small',
onClick: () => onEdit(row),
style: { marginRight: '5px' }
},
{ default: () => '编辑' }
),
h(
NButton,
{
size: 'small',
onClick: () => onChangeUserActiveState(row),
style: { marginRight: '5px' }
},
{ default: () => '改变状态' }
)
];
}
}
];

// 自定义右上角筛选
const filters = ref([
{
label: '是否激活',
type: 'select',
value: '0',
class: 'filter-select',
options: [
{
label: '全部',
value: '0'
},
{
label: '已激活',
value: '1'
},
{
label: '未激活',
value: '2'
}
]
},
{
label: '',
type: 'input',
placeholder: '请输入姓氏',
value: ''
}
]);

// 筛选变化,需要重新查询列表
const onFilterChange = ({ index, type, value }) => {
filters.value[index].value = value;
queryList();
};

// 自定义查询列表参数
const parmas = computed(() => {
return {
isActive: filters.value[0].value,
like: filters.value[1].value
};
});

// 封装好的查询列表方法和返回的数据
const { data, loading, pagination, onUpdatePage, onUpdatePageSize, queryList } = useQueryList(urls.user.user, parmas);

// 经过处理的列表数据,用于在 table 中展示
const tableData = computed(() =>
data.value.list.map(item => {
return {
...item,
createdAt: dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss'),
updatedAt: dayjs(item.updatedAt).format('YYYY-MM-DD HH:mm:ss')
};
})
);

// 删除列表相关逻辑封装
const { checkedRowKeys, onCheckedRow, deleteList } = useDeleteList({
content: '确定删除选中的用户?',
url: urls.user.userDelete,
callback: () => {
queryList();
}
});

// 列表中的快捷操作
const operations = computed(() => {
return [
{
name: 'create',
label: '新增',
type: 'primary'
},
{
name: 'delete',
label: '删除',
disabled: checkedRowKeys.value.length === 0
}
];
});

// 触发操作函数
const onOperate = function (name) {
operationFucs.get(name)();
};

// 新创建 item
const create = () => {
showModel.value = true;
itemId.value = 0;
};

const onModelShowChange = () => {
showModel.value = !showModel.value;
};

const itemId = ref(0);

// 控制模态对话框
const showModel = ref(false);

// 编辑 item
const onEdit = row => {
itemId.value = row.id;
showModel.value = true;
};

const { changeUserActiveState } = useChangeUserActiveState();

// 改变激活状态
const onChangeUserActiveState = row => {
changeUserActiveState({
id: row.id,
isActive: !row.isActive,
loading,
callback: () => {
queryList();
}
});
};

// 指定 table 的 rowKey
const rowKey = row => row['id'];

// operation 函数集合
const operationFucs = new Map();
operationFucs.set('create', create);
operationFucs.set('delete', deleteList);
</script>

<style lang="scss">
.user-mgmt {
height: 100%;
.filter-select {
.biz-select {
width: 100px;
}
}
}
</style>



作者:hezf
来源:juejin.cn/post/7316349124600315940
收起阅读 »

h5端调用手机摄像头实现扫一扫功能

web
一、前言 最近有遇到一个需求,在h5浏览器中实现扫码功能,其本质便是打开手机摄像头定时拍照,特此做一个记录。主要技术栈采用的是vue2,使用的开发工具是hbuilderX。 经过测试发现部分浏览器并不支持打开摄像头,测试了果子,华子和米,发现夸克浏览器无法打...
继续阅读 »

一、前言



最近有遇到一个需求,在h5浏览器中实现扫码功能,其本质便是打开手机摄像头定时拍照,特此做一个记录。主要技术栈采用的是vue2,使用的开发工具是hbuilderX。


经过测试发现部分浏览器并不支持打开摄像头,测试了果子,华子和米,发现夸克浏览器无法打开摄像头实现功能。



h5调用摄像头实现扫一扫只能在https环境下,亦或者是本地调试环境!!


image.png


二、技术方案



经过一番了解之后,找到了两个方案


1.使用html5-qrcode(对二维码的精度要求较高,胜在使用比较方便,公司用的是vue2,因此最终采用此方案)


2.使用vue-qrcode-reader(对vue版本和node有一定要求,推荐vue3使用,这里就不展开说了)



三、使用方式


image.png


当点击中间的扫码时,设置isScanning属性为true,即可打开扫码功能,代码复制粘贴即可放心‘食用’。


使用之前做的准备



通过npm install html5-qrcode 下载包


引入 import { Html5Qrcode } from 'html5-qrcode';



html结构
<view class="reader-box" v-if="isScaning">
<view class="reader" id="reader"></view>
</view>

所用数据
data(){
return{
html5Qrcode: null,
isScaning: false,
}
}


methods方法
openQrcode() {
this.isScaning = true;
Html5Qrcode.getCameras().then((devices) => {
if (devices && devices.length) {
this.html5Qrcode = new Html5Qrcode('reader');
this.html5Qrcode.start(
{
facingMode: 'environment'
},
{
focusMode: 'continuous', //设置连续聚焦模式
fps: 5, //设置扫码识别速度
qrbox: 280 //设置二维码扫描框大小
},
(decodeText, decodeResult) => {
if (decodeText) { //这里decodeText就是通过扫描二维码得到的内容
this.action(decodeText) //对二维码逻辑处理
this.stopScan(); //关闭扫码功能
}
},
(err) => {
// console.log(err); //错误信息
}
);
}
});
},

stopScan() {
console.log('停止扫码')
this.isScaning = false;
if(this.html5Qrcode){
this.html5Qrcode.stop();
}
}

css样式
.reader-box {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.5);
}

.reader {
width:100%;
// width: 540rpx;
// height: 540rpx;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

四、最终效果


image.png


如有问题,欢迎指正,若此文对您有帮助,不要忘记收藏+关注!


作者:极客转
来源:juejin.cn/post/7316795553798815783
收起阅读 »

关于晚上十点和男生朋友打电话调试vue源码那些事

web
简介朋友昨晚让我帮忙看怎么调试vue源码,我当时就不困了啊。兴致勃勃搞了半小时以失败告终。小白我可是永不言败的,早上鼓捣了俩小时总算写了点东西出来。想调试vue源码的小伙伴可以试试我这个思路fork源码首先肯定是要把vue/core代码fork一份到自己的仓库...
继续阅读 »

简介

朋友昨晚让我帮忙看怎么调试vue源码,我当时就不困了啊。兴致勃勃搞了半小时以失败告终。小白我可是永不言败的,早上鼓捣了俩小时总算写了点东西出来。想调试vue源码的小伙伴可以试试我这个思路

fork源码

首先肯定是要把vue/core代码fork一份到自己的仓库 这样后续有改动可以提交一下 也可以从源码一键同步

ps:github.com/baicie/vuej… 我的代码在这里可以参考一下

装包

pnpm i @pnpm/find-workspace-packages @pnpm/types -wD

ps:可以先看看pnpm与monorepo

在根目录执行上述命令装一下依赖-wD含义是在workspace的根安装开发依赖

脚本编写

首先在packages下执行pnpm creata vite创建一个vue项目

然后在scripts文件夹下创建dev.ts

import type { Project as PnpmProject } from '@pnpm/find-workspace-packages'
import { findWorkspacePackages } from '@pnpm/find-workspace-packages'
import type { ProjectManifest } from '@pnpm/types'
import { execa } from 'execa'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import color from 'picocolors'
import { scanEnums } from './const-enum'

export type Manifest = ProjectManifest & {
buildOptions: {
name?: string
compat?: boolean
env?: string
formats: ('global' | 'cjs' | 'esm-bundler' | 'esm-browser')[]
}
}

interface Project extends PnpmProject {
manifest: Manifest
}

const pkgsPath = path.resolve(process.cwd(), 'packages')
const getWorkspacePackages = () => findWorkspacePackages(pkgsPath)

async function main() {
scanEnums()
// 获取所有的包 除了private与没有buildOptions的包
const pkgs = (
(await getWorkspacePackages()).filter(
item => !item.manifest.private
) as Project[]
).filter(item => item.manifest.buildOptions)

await buildAll(pkgs)
}

async function buildAll(target: Project[]) {
// 并行打包
return runParallel(Number.MAX_SAFE_INTEGER, target, build)
}

async function runParallel(
maxConcurrent:
number,
source: Project[],
buildFn: (project: Project) =>
void
) {
const ret: Promise<void>[] = []
const executing: Promise<void>[] = []
for (const item of source) {
const p = Promise.resolve().then(() => buildFn(item))
// 封装所有打包任务
ret.push(p)

//
if (maxConcurrent <= source.length) {
const e: any = p.then(() => executing.splice(executing.indexOf(e), 1))
executing.push(e)
if (executing.length >= maxConcurrent) await Promise.race(executing)
}
}

return Promise.all(ret)
}

async function build(project: Project) {
const pkg = project.manifest
// 获取相对路径 包名
const target = path.relative(pkgsPath, project.dir)
if (pkg.private) {
return
}

const env = (pkg.buildOptions && pkg.buildOptions.env) || 'development'
await execa(
'rollup',
[
`-c`,
// 给rollup配置文件传递参数 watch 监听文件变化
'--watch',
'--environment',
[`NODE_ENV:${env}`, `TARGET:${target}`, `SOURCE_MAP:true`]
.filter(Boolean)
.join(',')
],
{ stdio: 'inherit' }
)
}

main().catch(err => {
console.error(color.red(err))
})

然后在根目录的package.json scripts 添加如下

"my-dev": "tsx scripts/dev.ts"

上述脚本主要是为了扫描工作目录下所有有意义的包并执行rollup打包命令(主要也就为了加一下watch没毛病)

验证

终端打开上吗创建的vite项目然后修改package.json里面的vue

 "vue": "workspace:*"

修改后根目录执行pnpm i建立软连接

1.根目录终端执行pnpm run my-dev

2.vite-project执行pnpm run dev

3.去runtime-core/src/apiCreateApp.ts createAppAPI 的 createApp 方法加一句打印

4.等待根目录终端打包完毕

5.去看看浏览器控制台有没有打印

按理说走完上述流出应该有打印出来哈哈 

优化更快?

然后就是想要快点因为我电脑不太行每次修改要等1.5~2s,然后我就想到了turbo,看了官网发现可行就试了试

修改如下

1. pnpm i turbo -wD

  1. 修改上述的my-dev "my-dev": "tsx scripts/dev.ts --turbo"
  2. 启动并验证

快了一点但不多

最后

新人多多关照哈哈 如果你想变强 b站 掘金搜索小满zs!


作者:白cie
来源:juejin.cn/post/7316539952475996194

收起阅读 »

前端要对用户的电池负责!

web
前言 我有一个坏习惯:就是下班之后不关电脑,但是电脑一般来说第二天电量不会有什么损失。但是后来突然有一天它开不起来了,上午要罢工?那也不好吧,也不能在光天化日之下划水啊;聪明的我还是找到了原因:电池耗尽了;于是赶紧来查一查原因,到底是什么程序把电池耗尽了呢? ...
继续阅读 »

前言


我有一个坏习惯:就是下班之后不关电脑,但是电脑一般来说第二天电量不会有什么损失。但是后来突然有一天它开不起来了,上午要罢工?那也不好吧,也不能在光天化日之下划水啊;聪明的我还是找到了原因:电池耗尽了;于是赶紧来查一查原因,到底是什么程序把电池耗尽了呢?


这里我直接揭晓答案:是一个Web应用,看来这个锅必须要前端来背了;我们来梳理一下js中有哪些耗电的操作。


js中有哪些耗电的操作


js中有哪些耗电的操作?我们不如问问浏览器有哪些耗电的操作,浏览器在渲染一个页面的时候经历了GPU进程、渲染进程、网络进程,由此可见持续的GPU绘制、持续的网络请求和页面刷新都会导致持续耗电,那么对应到js中有哪些操作呢?


Ajax、Fetch等网络请求


单只是一个AjaxFetch请求不会消耗多少电量,但是如果有一个定时器一直轮询地向服务器发送请求,那么CPU一直持续运转,并且网络进程持续工作,它甚至有可能会阻止电脑进行休眠,就这样它会一直轮询到电脑电池不足而关机;
所以我们应该尽量少地去做轮询查询;


持续的动画


持续的动画会不断地触发GPU重新渲染,如果没有进行优化的话,甚至会导致主线程不断地进行重排重绘等操作,这样会加速电池的消耗,但是js动画与css动画又不相同,这里我们埋下伏笔,等到后文再讲;


定时器


持续的定时器也可能会唤醒CPU,从而导致电池消耗加速;


上面都是一些加速电池消耗的操作,其实大部分场景都是由于定时器导致的电池消耗,那么下面来看看怎么优化定时器的场景;


针对定时器的优化


先试一试定时器,当我们关闭屏幕时,看定时器回调是否还会执行呢?


let num = 0;
let timer = null;
function poll() {
clearTimeout(timer);
timer = setTimeout(()=>{
console.log("测试后台时是否打印",num++);
poll();
},1000*10)
}

结果如下图,即使暂时关闭屏幕,它仍然会不断地执行:


未命名.png


如果把定时器换成requestAnimationFrame呢?


let num = 0;
let lastCallTime = 0;
function poll() {
requestAnimationFrame(() =>{
const now = Date.now();
if(now - lastCallTime > 1000*10){
console.log("测试raf后台时是否打印",num++);
lastCallTime = now;
}
poll();
});
}

屏幕关闭之前打印到了1,屏幕唤醒之后才开始打印2,真神奇!


未命名.png


当屏幕关闭时回调执行停止了,而且当唤醒屏幕时又继续执行。屏幕关闭时我们不断地去轮询请求,刷新页面,执行动画,有什么意义呢?因此才出现了requestAnimationFrame这个API,浏览器对它进行了优化,用它来代替定时器能减少很多不必要的能耗;


requestAnimationFrame的好处还不止这一点:它还能节省CPU,只要当页面处于未激活状态,它就会停止执行,包括屏幕关闭、标签页切换;对于防抖节流函数,由于频繁触发的回调即使执行次数再多,它的结果在一帧的时间内也只会更新一次,因此不如在一帧的时间内只触发一次;它还能优化DOM更新,将多次的重排放在一次完成,提高DOM更新的性能;


但是如果浏览器不支持,我们必须用定时器,那该怎么办呢?


这个时候可以监听页面是否被隐藏,如果隐藏了那么清除定时器,如果重新展示出来再创建定时器:


let num = 0;
let timer = null;
function poll() {
clearTimeout(timer);
timer = setTimeout(()=>{
console.log("测试后台时是否打印",num++);
poll();
},1000*10)
}

document.addEventListener('visibilitychange',()=>{
if(document.visibilityState==="visible"){
console.log("visible");
poll();
} else {
clearTimeout(timer);
}
})

针对动画优化


首先动画有js动画、css动画,它们有什么区别呢?


打开《我的世界》这款游戏官网,可以看到有一个keyframes定义的动画:


未命名.png


切换到其他标签页再切回来,这个扫描二维码的线会突然跳转;


因此可以得出一个结论:css动画在屏幕隐藏时仍然会执行,但是这一点我们不好控制。


js动画又分为三种种:canvas动画、SVG动画、使用js直接操作css的动画,我们今天不讨论SVG动画,先来看一看canvas动画(MuMu官网):


未命名.png


canvas动画在页面切换之后再切回来能够完美衔接,看起来动画在页面隐藏时也并没有执行;


那么js直接操作css的动画呢?动画还是按照原来的方式一直执行,例如大话西游这个官网的”获奖公示“;针对这种情况我们可以将动画放在requestAnimationFrame中执行,这样就能在用户离开屏幕时停止动画执行


上面我们对大部分情况已经进行优化,那么其他情况我们没办法考虑周到,所以可以考虑判断当前用户电池电量来兼容;


Battery Status API兜底


浏览器给我们提供了获取电池电量的API,我们可以用上去,先看看怎么用这个API:


调用navigator.getBattery方法,该方法返回一个promise,在这个promise中返回了一个电池对象,我们可以监听电池剩余量、电池是否在充电;


navigator.getBattery().then((battery) => {
function updateAllBatteryInfo() {
updateChargeInfo();
updateLevelInfo();
updateChargingInfo();
updateDischargingInfo();
}
updateAllBatteryInfo();

battery.addEventListener("chargingchange", () => {
updateChargeInfo();
});
function updateChargeInfo() {
console.log(`Battery charging? ${battery.charging ? "Yes" : "No"}`);
}

battery.addEventListener("levelchange", () => {
updateLevelInfo();
});
function updateLevelInfo() {
console.log(`Battery level: ${battery.level * 100}%`);
}

battery.addEventListener("chargingtimechange", () => {
updateChargingInfo();
});
function updateChargingInfo() {
console.log(`Battery charging time: ${battery.chargingTime} seconds`);
}

battery.addEventListener("dischargingtimechange", () => {
updateDischargingInfo();
});
function updateDischargingInfo() {
console.log(`Battery discharging time: ${battery.dischargingTime} seconds`);
}
});


当电池处于充电状态,那么我们就什么也不做;当电池不在充电状态并且电池电量已经到达一个危险的值得时候我们需要暂时取消我们的轮询,等到电池开始充电我们再恢复操作


后记


电池如果经常放电到0,这会影响电池的使用寿命,我就是亲身经历者;由于Web应用的轮询,又没有充电,于是一晚上就耗完了所有的电,等到再次使用时电池使用时间下降了好多,真是一次痛心的体验;于是后来我每次下班前将Chrome关闭,并关闭所有聊天软件,但是这样做很不方便,而且很有可能遗忘;


如果每一个前端都能关注到自己的Web程序对于用户电池的影响,然后尝试从定时器和动画这两个方面去优化自己的代码,那么我想应该就不会发生这种事情了。注意:桌面端程序也有可能是披着羊皮的狼,就是披着原生程序的Web应用;


参考:
MDN


作者:蚂小蚁
来源:juejin.cn/post/7206331674296746043
收起阅读 »

为什么我的页面鼠标一滑过,布局就错乱了?

web
前言 这天刚到公司,测试同事又在群里@我: 为什么页面鼠标一滑过,布局就错乱了? 以前是正常的啊? 刷新后也是一样 快看看怎么回事 同时还给发了一段bug复现视频,我本地跑个例子模拟下 可以看到,鼠标没滑过是正常的,鼠标一滑过图片就换行了,鼠标移出又正常了。...
继续阅读 »

前言


这天刚到公司,测试同事又在群里@我:

为什么页面鼠标一滑过,布局就错乱了?

以前是正常的啊?

刷新后也是一样

快看看怎么回事


同时还给发了一段bug复现视频,我本地跑个例子模拟下


GIF 2023-8-28 11-23-25.gif


可以看到,鼠标没滑过是正常的,鼠标一滑过图片就换行了,鼠标移出又正常了。


正文


首先说下我们的产品逻辑,我们产品的需求是:要滚动的页面,滚动条不应该占据空间,而是悬浮在滚动页面上面,而且滚动条只在滚动的时候展示。


我们的代码是这样写:


  <style>
.box {
width: 630px;
display: flex;
flex-wrap: wrap;
overflow: hidden; /* 注意⚠️ */
height: 50vh;
box-shadow: 0 0 5px rgba(93, 96, 93, 0.5);
}
.box:hover {
overflow: overlay; /* 注意⚠️ */
}
.box .item {
width: 200px;
height: 200px;
margin-right: 10px;
margin-bottom: 10px;
}
img {
width: 100%;
height: 100%;
}
</style>
<div class="box">
<div class="item">
<img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
</div>
<div class="item">
<img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
</div>
<div class="item">
<img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
</div>
<div class="item">
<img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
</div>
</div>

我们使用了overflow属性的overlay属性,滚动条不会占据空间,而是悬浮在页面之上,刚好吻合了产品的需求。


image.png


然后我们在滚动的区域默认是overflow:hidden,正常时候不会产生滚动条,hover的时候把overflow改成overlay,滚动区域可以滚动,并且不占据空间。


简写代码如下:


  .box {
overflow: hidden;
}
.box:hover {
overflow: overlay;
}

然后我们是把它封装成sass的mixins,滚动区域使用这个mixins即可。


上线后没什么问题,符合预期,获得产品们的一致好评。


直接这次bug的出现。


排查


我先看看我本地是不是正常的,我打开一看,咦,我的布局不会错乱。


然后我看了我的chrome的版本,是113版本


然后我问了测试的chrome版本,她是114版本


然后我把我的chrome升级到最新114版本,咦,滑过页面之后我的布局也乱了。


初步判断,那就有可能是chrome版本的问题。


去网上看看chrome的升级日志,看看有没有什么信息。


image.png


具体说明:


image.png


可以看到chrome114有了一个升级,就是把overflow:overlay当作overflow:auto的别名,也就是说,overlay的表现形式和auto是一致的。


实锤了,auto是占据空间的,所以导致鼠标一滑过,布局就乱了。


其实我们从mdn上也能看到,它旁边有个删除按钮,然后滑过有个tips:


image.png


解决方案


第一种方式


既然overflow:overlay表现形式和auto一致,那么我们得事先预留出滚动条的位置,然后设置滚动条的颜色是透明的,滑过才显示颜色,基本上也能符合产品的需求。


代码如下:


  // 滚动条
::-webkit-scrollbar {
background: transparent;
width: 6px;
height: 6px;
}
// 滚动条上的块
::-webkit-scrollbar-thumb {
background-clip: padding-box;
background-color: #d6d6d6;
border: 1px solid transparent;
border-radius: 10px;
}
.box {
overflow: auto;
}
.box::-webkit-scrollbar-thumb {
background-color: transparent;
}
.box:hover::-webkit-scrollbar-thumb {
background-color: #d6d6d6;
}

第二种方式


如果有用element-ui,它内部封装了el-scrollbar组件,模拟原生的滚动条,这个也不占据空间,而是悬浮,并且默认不显示,滑过才显示。



element-ui没有el-scrollbar组件的文档说明,element-plus才有,不过都可以用。



总结


这次算是踩了一个坑,当mdn上面提示有些属性已经要废弃⚠️了,我们就要小心了,尽量不要使用这些属性,就算当前浏览器还是支持的,但是不能保证未来某个时候浏览器会不会废弃这个属性。(比如这次问题,改这些问题挺费时间的,因为用的地方挺多的。)


因为一旦这些属性被废弃了,它预留的bug就等着你去解决,谨记!


作者:答案cp3
来源:juejin.cn/post/7273875079658209319
收起阅读 »

工作踩坑之在浏览器关闭/刷新前发送请求

web
丑话说在前 丑话说在前:当前的浏览器完全没有任何一个可靠、通用、准确区分用户关闭和刷新操作的API。 因此,如果你是单纯想在用户关闭时发送请求,那么没有任何完美答案。如果你只是想在谷歌Chrome、360浏览器的急速模式等一众基于谷歌Chrome浏览器套皮浏览...
继续阅读 »

丑话说在前


丑话说在前:当前的浏览器完全没有任何一个可靠、通用、准确区分用户关闭和刷新操作的API


因此,如果你是单纯想在用户关闭时发送请求,那么没有任何完美答案。如果你只是想在谷歌Chrome360浏览器的急速模式等一众基于谷歌Chrome浏览器套皮浏览器上实现,那么在下面我会提供一个简单的方法,但是Edge并不支持该方法。Edge是真牛啊,青出于蓝胜于蓝?


先来看看浏览器在刷新/关闭时的顺序


为了帮助理解我区分浏览器关闭和刷新操作的方法,先来看看浏览器在关闭/刷新时的执行顺序吧~


在浏览器关闭或刷新页面时,onbeforeunloadonunload 事件的执行顺序是固定的。



  1. 当用户关闭浏览器标签、窗口或者输入新的 URL 地址时,首先会触发 onbeforeunload 事件。

  2. onbeforeunload 事件处理完成后,如果用户选择离开页面(关闭或刷新),则会触发 onunload 事件。


因此,onbeforeunload 事件在用户决定离开页面之前执行,而 onunload 事件在用户离开页面之后执行。这两个事件提供了在用户离开页面前后执行代码的机会,可以用于执行清理操作或者提示用户确认离开等操作。通过对比两个事件的执行时间差,我们就可以简单判断浏览器的关闭或刷新行为啦。


简易判断Chrome浏览器关闭或刷新行为的方法


let beforeTime = 0,
leaveTime = 0;
// 获取浏览器onbeforeunload时期的时间戳
window.onbeforeunload = () => {
beforeTime = new Date().getTime();
};
window.onunload = () => {
// 对比onunload时期和onbeforeunload时期的时间差值
leaveTime = new Date().getTime() - beforeTime;
if (leaveTime < 5) {
// 如果小于5就是关闭
// 你可以在这发送请求
} else {
// 如果大于5就是刷新
// 你可以在这发送请求
}
};

注意:经过本人的测试,该方法仅支持Chrome浏览器等,Edge浏览器无论是关闭还是刷新,时间戳差均小于5ms,而谷歌Chrome浏览器的时间戳差均大于5ms,为7ms-8ms左右。环境不同亦有可能导致结果不同。


详见他人的测试结果图:


1703417937476.jpg


如何发送请求


既然已经区分了Chrome浏览器的关闭和刷新行为,那么该如果发送请求呢?


发送请求的方式主要有以下几种:


1. 使用 Navigator.sendBeacon()


该方法主要用于将统计数据发送到 Web 服务器,同时避免了用传统技术,如XMLHttpRequest所导致的各种问题。


他的使用方法也很简单:


navigator.sendBeacon(url, data);

// url参数表明 data 将要被发送到的网络地址
// data (可选) 参数是将要发送的 ArrayBuffer、ArrayBufferView、Blob、DOMString、FormData 或 URLSearchParams 类型的数据。
// 当用户代理成功把数据加入传输队列时,sendBeacon() 方法将会返回 true,否则返回 false。

怎么样?简简单单一行代码即可实现发送可靠的异步请求,同时不会延迟页面的卸载或影响下一导航的载入性能。但是别忽略的他很重要的一个特点数据是通过 POST 请求发送的。


2. 使用 fetch + keepalive


该方法用于发起获取资源的请求。它返回的是一个 promise。他支持 POST 和 GET 方法,配合 keepalive 参数,可以实现浏览器关闭/刷新行为前发送请求。keepalive可用于超过页面的请求。可以说keepalive就是 Navigator.sendBeacon() 的替代品。


fetch('url',{
method:'GET',
keepalive:true
})

3. 直接发送异步请求


由于从Chrome83开始,onunload里面不允许执行同步的XHR,所以同步请求自然是无法实现的,但是一部请求是可以实现的。但是异步请求发送到设备的成功率并非百分之百,因此并不推荐,也不在此赘述。


总结


以上便是浏览器关闭/刷新前发送请求的几种方法,而我是采用了 fetch + alive 尝试简单实现浏览器仅关闭时发送请求,具体实现代码如下:


let beforeTime = 0,
leaveTime = 0;
window.onbeforeunload = () => {
beforeTime = new Date().getTime();
};
window.onunload = () => {
leaveTime = new Date().getTime() - beforeTime;
if (leaveTime <= 5) {
fetch('/logout.do',{
method:'GET',
keepalive:true
})
}
};

经测试,使用效果如下


使用该方法对于各浏览器的测试结果


浏览器/测试方法关闭tab页关闭浏览器任务管理器关闭刷新
Chrome登出登出登出未登出
Edge登出未登出登出登出
360急速模式登出登出登出未登出
360兼容模式白屏白屏白屏白屏
IE白屏白屏白屏白屏

浏览器/测试方法关闭tab页关闭浏览器任务管理器关闭刷新
Chrome
Edge××
360急速模式
360兼容模式××××
IE××××

小小的吐槽:


后端感知web退出本就不推荐由前端来处理,更优解为 持续ping 或者后端 心跳机制发包 来检测。


既然设备那边提出了这个请求,我们web这也就努力挣扎一下,把测试结果发给评审人员评审一下吧~


作者:bachelor98
来源:juejin.cn/post/7315846825344647194
收起阅读 »

妙用 CSS counters 实现逐层缩进

web
妙用 CSS counters 实现逐层缩进 之前使用纯 CSS 实现了一个树形结构,效果如下 其中,展开收起是用到了原生标签details和summary,有兴趣的可以回顾之前这篇文章 CSS 实现树状结构目录 还有一点,树形结构是逐层缩进的,是使用...
继续阅读 »

妙用 CSS counters 实现逐层缩进



之前使用纯 CSS 实现了一个树形结构,效果如下


image-20231221201613974


其中,展开收起是用到了原生标签detailssummary,有兴趣的可以回顾之前这篇文章



CSS 实现树状结构目录



还有一点,树形结构是逐层缩进的,是使用内边距实现的,但是这样会有点击范围的问题,层级越深,点击范围越小,如下


image-20231221201953463


之前的方案是用绝对定位实现的,比较巧妙,但也有点难以理解,不过现在发现了另一种方式也能很好的实现缩进效果,一起看看吧


一、counter() 与 counters()


我们平时使用的一般都是counter,也就是计数器,比如


<ul>
<li>li>
<li>li>
<li>li>
ul>

加上计数器,通常用伪元素来显示这个计数器


ul {
counter-reset: listCounter; /*初始化计数器*/
}
li {
counter-increment: listCounter; /*计数器增长*/
}
li::before {
content: counter(listCounter); /*计数器显示*/
}

这就是一个最简单的计数器了,效果如下


image-20231221203258255


我们还可以改变计数器的形态,比如改成大写罗马数字(upper-roman


li::before {
content: counter(listCounter, upper-roman);
}

效果如下


image-20231221203158970


有关计数器,网上的教程非常多,大家可以自行搜索


然后我们再来看counters(),比前面的counter()多了一个s,叫做嵌套计数器,有什么区别呢?下面来看一个例子,还是和上面一样,只是结构上复杂一些


<ul>
<li>
<ul>
<li>li>
<li>li>
<li>li>
ul>
li>
<li>li>
<li>li>
<li>
<ul>
<li>li>
<li>
<ul>
<li>li>
<li>li>
<li>li>
ul>
li>
ul>
li>
ul>


效果如下


image-20231221204007978


看着好像也不错?但是好像从计数器上看不出层级效果,我们把counter()换成counters(),注意,counters()要多一个参数,表示连接字符,也就是嵌套时的分隔符,如下


li::before {
content: counters(listCounter, '-');
}

效果如下


image-20231221204311891


是不是可以非常清楚的看出每个列表的层级?下次碰到类似的需求就不需要用 JS 去递归生成了,直接用 CSS 渲染,简单高效,也不会出错。


默认ul是有padding的,我们把这个去除看看,变成了这样


image-20231221204528126


嗯,看着这些长短不一的序号,是不是刚好可以实现树形结构的缩进呢?


二、树形结构的逐层缩进


回到文章开头,我们先去除之前的padding-left,会变成这样


image-20231221224113570


完全看不清结构关系,现在我们加上嵌套计数器


.tree details{
counter-reset: count;
}
.tree summary{
counter-increment: count;
}
.tree summary::before{
content: counters(count,"-");
color: red;
}

由于结构关系,目前序号都是1,没关系,只需要有嵌套关系就行,效果如下


image-20231221224810497


**是不是刚好把每个标题都挤过去了?**然后我们把中间的连接线去除,这样可以更方便的控制缩进的宽度


.tree summary::before{
content: counters(count,"");
color: red;
}

效果如下


image-20231221225225369


最后,我们只需要设置这个计数器的颜色为透明就行了


.tree summary::before{
content: counters(count,"");
color: transparent;
}

最终效果如下


image-20231221225607078


这样做的好处是,每个树形节点都是完整的宽度,所以 可以很轻易的实现hover效果,而无需借助伪元素去扩大点击范围


.tree summary:hover{
background-color: #EEF2FF;
}

效果如下


image-20231221225732065


还可以通过修改计数器的字号来调整缩进,完整代码可以访问以下链接:




三、总结一下


以上就是本文的全部内容了,主要介绍了计数器的两种形态,以及想到的一个应用场景,下面总结一下



  1. 逐层缩进用内边距比较容易实现,但是会造成子元素点击区域过小的问题

  2. counter 表示计数器,比较常规的单层计数器,形如 1、2、3

  3. counters 表示嵌套计数器,在有层级嵌套时,会自动和上一层的计数器相叠加,形如1、1-1、1-2、1-2-1

  4. 嵌套计数器会逐层叠加,计数器的字符会逐层增加,计数器所占据的位置也会越来越大

  5. 嵌套计数器所占据的空间刚好可以用作树形结构的缩进,将计数器的颜色设置为透明就可以了

  6. 用计数器的好处是,每个树形节点都是完整的宽度,而无需借助伪元素去扩大点击范围


一个还算实用的小技巧,你学到了吗?


作者:XboxYan
来源:juejin.cn/post/7315850963343671335
收起阅读 »

🔥图片懒加载🔥三种实现方案

web
一、前言 图片懒加载,当图片出现在可视区域再进行加载,提升用户的体验。因为有些用户不会看完图片,全部加载会浪费流量。在网上查阅资料,总结了三种办法,有各自的利弊,下文一一介绍。 方法优点缺点推荐指数设置img loadingh5的属性,没有兼容问题需要已知图片...
继续阅读 »

一、前言


图片懒加载,当图片出现在可视区域再进行加载,提升用户的体验。因为有些用户不会看完图片,全部加载会浪费流量。在网上查阅资料,总结了三种办法,有各自的利弊,下文一一介绍。


方法优点缺点推荐指数
设置img loadingh5的属性,没有兼容问题需要已知图片高度、宽高比⭐️⭐️
IntersectionObserver API无需知道图片高度低版本需引入polyfill⭐️⭐️⭐️
vue-lazyload 自定义指令无需知道图片高度github现存issues较多,没有解决⭐️⭐️

output.gif


二、实现方式及Demo


1. 设置img标签loading属性


loading属性允许两个值:eager立即加载图像(默认值);lazy延迟加载图像。在使用lazy属性的时候,需要设置<img>标签的高度,否则无法懒加载。


注意: 适用于两种场景,图片高度已知、图片宽高比已知。



  • 已知图片高度


<style>
.img-box img {
width: 100%;
height: 700px; /*设置为图片的真实高度*/
}
</style>

<div class="img-box">
<img src="https://i.postimg.cc/GtN3Cs02/1.jpg" loading="lazy" />
<img src="https://i.postimg.cc/hGdKLGdW/2.jpg" loading="lazy" />
<img src="https://i.postimg.cc/T1SkJTbF/3.jpg" loading="lazy" />
<img src="https://i.postimg.cc/wxPFPTtb/4.jpg" loading="lazy" />
<img src="https://i.postimg.cc/FRkGF28x/5.jpg" loading="lazy" />
<img src="https://i.postimg.cc/05JH9wqq/6.jpg" loading="lazy" />
</div>



  • 已知图片宽高比


 <style>
.img-box div {
position: relative;
padding-top: 66%; /* (你的图片的高度/宽度值) */
overflow: hidden;
}
.img-box img {
position: absolute;
top:0;
right:0;
width:100%;
}
</style>

<div class="img-box">
<div>
<img src="https://i.postimg.cc/GtN3Cs02/1.jpg" loading="lazy" />
</div>
<div>
<img src="https://i.postimg.cc/hGdKLGdW/2.jpg" loading="lazy" />
</div>
<div>
<img src="https://i.postimg.cc/T1SkJTbF/3.jpg" loading="lazy" />
</div>
<div>
<img src="https://i.postimg.cc/wxPFPTtb/4.jpg" loading="lazy" />
</div>
<div>
<img src="https://i.postimg.cc/FRkGF28x/5.jpg" loading="lazy" />
</div>
<div>
<img src="https://i.postimg.cc/05JH9wqq/6.jpg" loading="lazy" />
</div>
</div>


2. 使用 IntersectionObserver


IntersectionObserver接口,可以观察DOM节点是否出现在视口,当DOM节点出现在视口中才加载图片。img必须有高度,否则图片默认都在视口中,会将图片全部加载。可以设置img的src为base64白色图片,然后在替换为真实的图片地址。


注意: 不需要预先知道图片的高度,但是有兼容性问题,低版本需要引入intersection-observer polyfill



  • 已知图片高度


<style>
.img-box .lazy-img {
width: 100%;
height: 600px; /*如果已知图片高度可以设置*/
}
</style>

<div class="img-box">
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/d48aed7c991b43d850d011f2299d852e.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/a588b152c79ac60162ecbdf82b060061.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/eacbc2cd4b6ca636077378182bdfcc88.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/751470f4b478450e8556f78cd7dd3d96.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/e4a531bee5694a4a01dee74b18bbfd8b.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/7d8f107e827a7beaa0b9d231bfa4187f.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/4f7586f6b74f2bd0b94004fcbae69856.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/863849e14e7e8903ed4b27fcbdafe8b0.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/d8bb17fe9a7223f35075014ef250e2fa.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
</div>

<script>
function Observer() {
let images = document.querySelectorAll(".lazy-img");
let observer = new IntersectionObserver(entries => {
entries.forEach(item => {
if (item.isIntersecting) {
item.target.src = item.target.dataset.origin; // 开始加载图片,把data-origin的值放到src
observer.unobserve(item.target); // 停止监听已开始加载的图片
}
});
});
images.forEach(img => observer.observe(img));
}
Observer()
</script>

3. 使用vue-lazyload


在vue2中使用时,建议安装npm i vue-lazyload@1.3.3 -s,使用高版本在main.js中全局自定义指令后依然无法使用指令。在vue3中可以使用 npm i vue3-lazy -s



  • 全局注册自定义指令,在页面就可以使用了


// 全局自定义指令
import Vue from 'vue'
import VueLazyload from 'vue-lazyload'
Vue.use(VueLazyload, {
preLoad: 1,
observer: true // 设置为true,内部使用IntersectionObserver。默认使用
})

/* 在页面中直接使用 */
<div>
<img v-lazy="https://images.djtest.cn/pic/test/d48aed7c991b43d850d011f2299d852e.jpg">
<img v-lazy="https://images.djtest.cn/pic/test/a588b152c79ac60162ecbdf82b060061.jpg">
<img v-lazy="https://images.djtest.cn/pic/test/eacbc2cd4b6ca636077378182bdfcc88.jpg">
<img v-lazy="https://images.djtest.cn/pic/test/751470f4b478450e8556f78cd7dd3d96.jpg">
</div>

作者:起风了啰
来源:juejin.cn/post/7316349850854752294
收起阅读 »

这一年遇到的奇怪bug

web
position sticky 失效 在 Iphone6 plus 上使用 position sticky 不生效 解决办法: position: sticky; position: -webkit-sticky; // 兼容写法需要写在下面 参...
继续阅读 »

position sticky 失效



在 Iphone6 plus 上使用 position sticky 不生效




解决办法:



position: sticky;  
position: -webkit-sticky; // 兼容写法需要写在下面

参考 position sticky 失效 – 有点另类的写法


new Date().toLocaleDateString() 获取当前的日期字符串无效



当系统语言是新加坡英语的时候,使用这个方法获取当前的日期字符串会出现 Invalid Date,toLocaleDateString 是有两个参数的,不指定语言就会出现这个问题,而且只在手机上出现,不太好排查,new Date().toLocaleDateString('en-Us') 调用的时候指定语言就没问题了;



参考 Date.prototype.toLocaleDateString()


两行溢出显示省略号但是部分手机上出现第三行截断痕迹


image.png



例如设置了高度为36px,line-height 18px,但是出现了第三行截断痕迹,应该是文字 baseline 的对其方式问题,试着设置 vertical-align 也不行。解决办法就是不给文字的盒子设置高度,如果一定要个高度兜底,可以在文字的盒子再套一个盒子,在套的那个盒子设置高度。



泰文字体文本溢出隐藏,但是第二行出现截断痕迹



原因,应该是泰语的字体行高要求比较高,暂时的解决办法:加高文本行高



useEffect 首次获取 dom 的 clientHeight 不对



初步感觉是因为 css 样式加载慢了,导致第一次获取到的高度是没有样式的高度,而且又是偶现的;所以在这个组件或者 hooks 重新 render 的时候去获取高度,如果获取到最新的高度发生变化,去同步修改 state 保存的高度。



import { useEffect, useState } from "react";
export default function useTop(){
const [top, setTop]=useState(0);
const [bodyHeight, setBodyHeight] = useState(document.body.clientHeight);
const newestTop = (document.getElementById('nav-header')?.clientHeight || 0) - 1;

if (newestTop !== top) { // nav header height may change
setTop(newestTop);
setBodyHeight(document.body.clientHeight - (newestTop + 1));
}

useEffect(() => {
const nav = document.getElementById('nav-header');
const navHeight = nav?.clientHeight ?? 0;
setTop(navHeight-1);
setBodyHeight(document.body.clientHeight - navHeight);
}, []);
return {top, bodyHeight}
}

一个页面中有两个滚动条,两个滚动条几乎同时触发滚动条的滚动方法,后执行的不生效



两个滚动条,一个使用 scrollBy 方法,另一个使用 scrollIntoView 方法,behavior 属性都为 smooth,这个属性会让滚动条平滑移动,导致滚动条事件一直在触发状态,另一个滚动方法就执行不了了。解决方法:让先执行的方法 behavior 属性为 auto;或者在第一个滚动条结束之后再执行第二个滚动条的方法,可以让第二个方法 setTimeout 100ms 左右,不能超过 300ms,否则用户会感觉卡顿。



iphone6 手机上横向或者纵向滑动不了



原因,可能是dom结构问题,导致低端ios机型没有识别到生成滚动条,导致不能滚动,android 和其他 ios 机型正常;



<div className="list-tabs-wrap">
<div
className="list-tabs"
>

<div className="tab-item">tab1</div>
<div className="tab-item">tab2</div>
<div className="tab-item">tab3</div>
</div>
</div>

.list-tabs-wrap {
width: 100%;
background-color: #fff;
overflow: hidden;
}
.list-tabs {
overflow-x: scroll;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
height: 50px;
background-color: #fff;
}
.list-tabs::-webkit-scrollbar {
display: none;
background-color: transparent;
color: transparent;
width: 0;
height: 0;
}
.tab-item {
width: 50vw;
}


解决办法,新增一个 container 结构,container dom 宽度为 max-content,overflow 拆开写



<div className="list-tabs-wrap">
<div
className="list-tabs"
>

<div className="list-tabs-container">
<div className="tab-item">tab1</div>
<div className="tab-item">tab2</div>
<div className="tab-item">tab3</div>
</div>
</div>
</div>

.list-tabs-container {
overflow-x: scroll; // overflow 拆开写
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
width: max-content; // 纵向设置 height
}


用上面方法解决 iPhone6 滚动条问题后,又出现一个滚动条隐藏样式不生效的问题;解决办法,设置一个外层的盒子,固定高度然后 overflow: hidden,需要滚动的盒子加一个 padding-bottom: 10px,padding 大小看着改,能放下一个滚动条就可以,这样滚动条会出现在padding里,然后又因为外层盒子overflow: hidden 了,所以滚动条和padding都看不到了;愿世界再无 iphone6.



在 Android webview 中,window.location.reload 和 replace 失效


const reload = () => {
const timeStamp = new Date().getTime();
const oldUrl = window.location.href;
const url = `${oldUrl}${oldUrl.includes('?') ? '&' : '?'}timeStamp=${timeStamp}`;
window.location.href = url;
};
const locationReplace = (url) => {
if(history.replaceState) {
history.replaceState(null, document.title, url);
history.go(0);
} else {
location.replace(url);
}
};


部分安卓手机把请求参数的字符串中间的空格转义成+号



发现在 谷歌 Pixel 3 XL 手机上,会把请求参数的字符串中间的空格转义成+号,比如 '[{"filterField":"accommodationType","value":"Hotel,Entire apartment"}]' => '[{"filterField":"accommodationType","value":"Hotel,Entire+apartment"}]'。调试了下,发现在发起请求前参数打印是正常的,是浏览器在请求的时候在请求体中字段转义的。不过好像对后端的搜索结果并不影响,所以这里就没有改动。




解决办法,对字符串encode下,后端收到参数后再decode。



ios 17 input 聚焦页面出现抖动


解决办法: input focus 给 body 添加 height: 100vh; overflow: hidden; 样式。input blur 取消 focus 添加的样式。


作者:wait
来源:juejin.cn/post/7309040097936343103
收起阅读 »

产品经理:实现一个微信输入框

web
近期在开发AI对话产品的时候为了提升用户体验增强了对话输入框的相关能力,产品初期阶段对话框只是一个单行输入框,导致在文本内容很多的时候体验很不好,所以进行体验升级,类似还原了微信输入框的功能(只是其中的一点点哈🤏)。 初期认为这应该改动不大,就是把input换...
继续阅读 »


近期在开发AI对话产品的时候为了提升用户体验增强了对话输入框的相关能力,产品初期阶段对话框只是一个单行输入框,导致在文本内容很多的时候体验很不好,所以进行体验升级,类似还原了微信输入框的功能(只是其中的一点点哈🤏)。


初期认为这应该改动不大,就是把input换成textarea吧。但是实际开发过程发现并没有这么简单,本文仅作为开发过程的记录,因为是基于uniapp开发,相关实现代码都是基于uniapp


简单分析我们大概需要实现以下几个功能点:



  • 默认单行输入

  • 可多行输入,但有最大行数限制

  • 超过限制行术后内容在内部滚动

  • 支持回车发送内容

  • 支持常见组合键在输入框内换行输入

  • 多行输入时高度自适应 & 页面整体自适应


单行输入


默认单行输入比较简单直接使用input输入框即可,使用textarea的时候目前的实现方式是通过设置行内样式的高度控制,如我们的行内高度是36px,那么就设置其高度为36px。为什么要通过这种方式设置呢?因为要考虑后续多行输入超出最大行数的限制,需要通过高度来控制textarea的最大高度。


<textarea style="{ height: 36px }" />


多行输入


多行输入核心要注意的就是控制元素的高度,因为不能随着用户的输入一直增加高度,我们需要设置一个最大的行数限制,超出限制后就不再增加高度,内容可以继续输入,可以在输入框内上下滚动查看内容。


这里需要借助于uniapp内置在textarea@linechange事件,输入框行数变化时调用并回传高度和行数。如果不使用uniapp则需要对输入文字的长度及当前行高计算出对应的行数,这种情况还需要考虑单行文本没有满一行且换行的情况。


代码如下,在linechange事件中获取到最新的高度设置为textarea的高度,当超出最大的行数限制后则不处理。


linechange(event) {
const { height, lineCount } = event.detail
if (lineCount < maxLine) {
this.textareaHeight = height
}
}

这是正常的输入,还有一种情况是用户直接粘贴内容输入的场景,这种时候不会触发@linechange事件,需要手动处理,根据粘贴文本后的textarea的滚动高度进行计算出对应的行数,如超出限制行数则设置为最大高度,否则就设置为实际的行数所对应的高度。代码如下:


const paddingTop = parseInt(getComputedStyle(textarea).paddingTop);
const paddingBottom = parseInt(getComputedStyle(textarea).paddingBottom);
const textHeight = textarea.scrollHeight - paddingTop - paddingBottom;
const numberOfLines = Math.floor(textHeight / lineHeight);

if (numberOfLines > 1 && this.lineCount === 1) {
const lineCount = numberOfLines < maxLine ? numberOfLines : maxLine
this.textareaHeight = lineCount * lineHeight
}

键盘发送内容


正常我们使用电脑聊天时发送内容都是使用回车键发送内容,使用ctrlshiftalt等和回车键的组合键将输入框的文本进行换行处理。所以接下来要实现的就是对键盘事件的监听,基于事件进行发送内容和内容换行输入处理。


首先是事件的监听,uniapp不支持keydown的事件监听,所以这里使用了原生JS做监听处理,为了避免重复监听,对每次开始监听前先进行移除事件的监听,代码如下:


this.$refs.textarea.$el.removeEventListener('keydown', this.textareaKeydownHandle)
this.$refs.textarea.$el.addEventListener('keydown', this.textareaKeydownHandle)

然后是对textareaKeydownHandle方法的实现,这里需要注意的是组合键对内容换行的处理,需要获取到当前光标的位置,使用textarea.selectionStart可获取,基于光标位置增加一个换行\n的输入即可实现换行,核心代码如下:


const cursorPosition = textarea.selectionStart;
if(
(e.keyCode == 13 && e.ctrlKey) ||
(e.keyCode == 13 && e.metaKey) ||
(e.keyCode == 13 && e.shiftKey) ||
(e.keyCode == 13 && e.altKey)
){
// 换行
this.content = `${this.content.substring(0, cursorPosition)}\n${this.content.substring(cursorPosition)}`
}else if(e.keyCode == 13){
// 发送
this.onSend();
e.preventDefault();
}

高度自适应


当多行输入内容时输入框所占据的高度增加,导致页面实际内容区域的高度减小,如果不进行动态处理会导致实际内容会被遮挡。如下图所示,红色区域就是需要动态处理的高度。



主要需要处理的场景就是输入内容行数变化的时候和用户粘贴文本的时候,这两种情况都会基于当前的可视行数计算输入框的高度,那么内容区域的高度就好计算了,使用整个窗口的高度减去输入框的高度和其他固定的高度如导航高度和底部安全距离高度即是真实内容的高度。


this.contentHeight = this.windowHeight - this.navBarHeight - this.fixedBottomHeight - this.textareaHeight;

最后


到此整个输入框的体验优化核心实现过程就结束了,增加了多行输入,组合键换行输入内容,键盘发送内容,整体内容高度自适应等。整体实现过程的细节功能点还是比较多,有实现过类似需求的同学欢迎留言交流~


看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~


专注前端开发,分享前端相关技术干货,公众号:南城大前端(ID: nanchengfe)


作者:南城FE
来源:juejin.cn/post/7267791228872753167
收起阅读 »

面试官问我按钮级别权限怎么控制,我说v-if,面试官说再见

web
最近的面试中有一个面试官问我按钮级别的权限怎么控制,我说直接v-if啊,他说不够好,我说我们项目中按钮级别的权限控制情况不多,所以v-if就够了,他说不够通用,最后他对我的评价是做过很多东西,但是都不够深入,好吧,那今天我们就来深入深入。 因为我自己没有相关实...
继续阅读 »

最近的面试中有一个面试官问我按钮级别的权限怎么控制,我说直接v-if啊,他说不够好,我说我们项目中按钮级别的权限控制情况不多,所以v-if就够了,他说不够通用,最后他对我的评价是做过很多东西,但是都不够深入,好吧,那今天我们就来深入深入。


因为我自己没有相关实践,所以接下来就从这个有16.2k星星的后台管理系统项目Vue vben admin中看看它是如何做的。


获取权限码


要做权限控制,肯定需要一个code,无论是权限码还是角色码都可以,一般后端会一次性返回,然后全局存储起来就可以了,Vue vben admin是在登录成功以后获取并保存到全局的store中:


import { defineStore } from 'pinia';
export const usePermissionStore = defineStore({
state: () => ({
// 权限代码列表
permCodeList: [],
}),
getters: {
// 获取
getPermCodeList(){
return this.permCodeList;
},
},
actions: {
// 存储
setPermCodeList(codeList) {
this.permCodeList = codeList;
},

// 请求权限码
async changePermissionCode() {
const codeList = await getPermCode();
this.setPermCodeList(codeList);
}
}
})

接下来它提供了三种按钮级别的权限控制方式,一一来看。


函数方式


使用示例如下:


<template>
<a-button v-if="hasPermission(['20000', '2000010'])" color="error" class="mx-4">
拥有[20000,2000010]code可见
</a-button>
</template>

<script lang="ts">
import { usePermission } from '/@/hooks/web/usePermission';

export default defineComponent({
setup() {
const { hasPermission } = usePermission();
return { hasPermission };
},
});
</script>

本质上就是通过v-if,只不过是通过一个统一的权限判断方法hasPermission


export function usePermission() {
function hasPermission(value, def = true) {
// 默认视为有权限
if (!value) {
return def;
}

const allCodeList = permissionStore.getPermCodeList;
if (!isArray(value)) {
return allCodeList.includes(value);
}
// intersection是lodash提供的一个方法,用于返回一个所有给定数组都存在的元素组成的数组
return (intersection(value, allCodeList)).length > 0;

return true;
}
}

很简单,从全局store中获取当前用户的权限码列表,然后判断其中是否存在当前按钮需要的权限码,如果有多个权限码,只要满足其中一个就可以。


组件方式


除了通过函数方式使用,也可以使用组件方式,Vue vben admin提供了一个Authority组件,使用示例如下:


<template>
<div>
<Authority :value="RoleEnum.ADMIN">
<a-button type="primary" block> 只有admin角色可见 </a-button>
</Authority>
</div>
</template>
<script>
import { Authority } from '/@/components/Authority';
import { defineComponent } from 'vue';
export default defineComponent({
components: { Authority },
});
</script>

使用Authority包裹需要权限控制的按钮即可,该按钮需要的权限码通过value属性传入,接下来看看Authority组件的实现。


<script lang="ts">
import { defineComponent } from 'vue';
import { usePermission } from '/@/hooks/web/usePermission';
import { getSlot } from '/@/utils/helper/tsxHelper';

export default defineComponent({
name: 'Authority',
props: {
value: {
type: [Number, Array, String],
default: '',
},
},
setup(props, { slots }) {
const { hasPermission } = usePermission();

function renderAuth() {
const { value } = props;
if (!value) {
return getSlot(slots);
}
return hasPermission(value) ? getSlot(slots) : null;
}

return () => {
return renderAuth();
};
},
});
</script>

同样还是使用hasPermission方法,如果当前用户存在按钮需要的权限码时就原封不动渲染Authority包裹的内容,否则就啥也不渲染。


指令方式


最后一种就是指令方式,使用示例如下:


<a-button v-auth="'1000'" type="primary" class="mx-4"> 拥有code ['1000']权限可见 </a-button>

实现如下:


import { usePermission } from '/@/hooks/web/usePermission';

function isAuth(el, binding) {
const { hasPermission } = usePermission();

const value = binding.value;
if (!value) return;
if (!hasPermission(value)) {
el.parentNode?.removeChild(el);
}
}

const mounted = (el, binding) => {
isAuth(el, binding);
};

const authDirective = {
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
mounted,
};

// 注册全局指令
export function setupPermissionDirective(app) {
app.directive('auth', authDirective);
}

只定义了一个mounted钩子,也就是在绑定元素挂载后调用,依旧是使用hasPermission方法,判断当前用户是否存在通过指令插入的按钮需要的权限码,如果不存在,直接移除绑定的元素。


很明显,Vue vben admin的实现有两个问题,一是不能动态更改按钮的权限,二是动态更改当前用户的权限也不会生效。


解决第一个问题很简单,因为上述只有删除元素的逻辑,没有加回来的逻辑,那么增加一个updated钩子:


app.directive("auth", {
mounted: (el, binding) => {
const value = binding.value
if (!value) return
if (!hasPermission(value)) {
// 挂载的时候没有权限把元素删除
removeEl(el)
}
},
updated(el, binding) {
// 按钮权限码没有变化,不做处理
if (binding.value === binding.oldValue) return
// 判断用户本次和上次权限状态是否一样,一样也不用做处理
let oldHasPermission = hasPermission(binding.oldValue)
let newHasPermission = hasPermission(binding.value)
if (oldHasPermission === newHasPermission) return
// 如果变成有权限,那么把元素添加回来
if (newHasPermission) {
addEl(el)
} else {
// 如果变成没有权限,则把元素删除
removeEl(el)
}
},
})

const hasPermission = (value) => {
return [1, 2, 3].includes(value)
}

const removeEl = (el) => {
// 在绑定元素上存储父级元素
el._parentNode = el.parentNode
// 在绑定元素上存储一个注释节点
el._placeholderNode = document.createComment("auth")
// 使用注释节点来占位
el.parentNode?.replaceChild(el._placeholderNode, el)
}

const addEl = (el) => {
// 替换掉给自己占位的注释节点
el._parentNode?.replaceChild(el, el._placeholderNode)
}

主要就是要把父节点保存起来,不然想再添加回去的时候获取不到原来的父节点,另外删除的时候创建一个注释节点给自己占位,这样下次想要回去能知道自己原来在哪。


第二个问题的原因是修改了用户权限数据,但是不会触发按钮的重新渲染,那么我们就需要想办法能让它触发,这个可以使用watchEffect方法,我们可以在updated钩子里通过这个方法将用户权限数据和按钮的更新方法关联起来,这样当用户权限数据改变了,可以自动触发按钮的重新渲染:


import { createApp, reactive, watchEffect } from "vue"
const codeList = reactive([1, 2, 3])

const hasPermission = (value) => {
return codeList.includes(value)
}

app.directive("auth", {
updated(el, binding) {
let update = () => {
let valueNotChange = binding.value === binding.oldValue
let oldHasPermission = hasPermission(binding.oldValue)
let newHasPermission = hasPermission(binding.value)
let permissionNotChange = oldHasPermission === newHasPermission
if (valueNotChange && permissionNotChange) return
if (newHasPermission) {
addEl(el)
} else {
removeEl(el)
}
};
if (el._watchEffect) {
update()
} else {
el._watchEffect = watchEffect(() => {
update()
})
}
},
})

updated钩子里更新的逻辑提取成一个update方法,然后第一次更新在watchEffect中执行,这样用户权限的响应式数据就可以和update方法关联起来,后续用户权限数据改变了,可以自动触发update方法的重新运行。


好了,深入完了,看着似乎也挺简单的,我不确定这些是不是面试官想要的,或者还有其他更高级更优雅的实现呢,知道的朋友能否指点一二,在下感激不尽。


作者:街角小林
来源:juejin.cn/post/7209648356530896953
收起阅读 »

el-table表格大数据卡顿?试试umy-ui

web
最近公司项目表格数据量过大导致页面加载时间非常长,而且还使用了keep-alive缓存,关闭页面时需要卸载大量dom,导致页面卡死、cpu飙到100%的问题 后来在网上查找了很多方法,发现了umy-ui(目前只支持v2),这个表格库是针对element-u...
继续阅读 »

最近公司项目表格数据量过大导致页面加载时间非常长,而且还使用了keep-alive缓存,关闭页面时需要卸载大量dom,导致页面卡死、cpu飙到100%的问题



image.png

后来在网上查找了很多方法,发现了umy-ui(目前只支持v2),这个表格库是针对element-ui的表格做了二次优化,支持el-table的所有方法


image.png

这个表格可以基于可视区域做dom渲染,这样就大大的减少了页面初次渲染的压力。


首先第一步


 npm install umy-ui

或者使用CDN的方式引入


  <!--引入表格样式-->
<link rel="stylesheet" href="https://unpkg.com/umy-ui/lib/theme-chalk/index.css">

<!-- import Vue -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>

<script src="https://unpkg.com/umy-ui/lib/index.js"></script>
<!-- 真实项目不建议你直接引入 <script src="https://unpkg.com/umy-ui/lib/index.js"></script>-->

<!-- 这样去引如会直接下最新版本,如果你的项目打包发布了,然后遇见umy-ui大更新 你可能项目会报错。-->

<!--推荐你这样引入: https://unpkg.com/umy-ui$1.0.1/lib/index.js 加入版本号!-->
<!-- 这样去引如会直接下最新版本,如果你的项目打包发布了,然后遇见umy-ui大更新 你可能项目会报错。-->

<!--推荐你这样引入: https://unpkg.com/umy-ui@1.0.1/lib/index.js 加入版本号!-->

第二步 main.js中全局引入


  import UmyUi from 'umy-ui'
import 'umy-ui/lib/theme-chalk/index.css';// 引入样式
Vue.use(UmyUi)

或按需引入


import { UTable, UTableColumn } from 'umy-ui';

Vue.component(UTable.name, UTable);
Vue.component(UTableColumn.name, UTableColumn);

修改起来也很方便
直接吧 el-table 改成 u-table, el-table-column改成u-table-column,最后添加属性use-virtual这样就可以使用了


示例


<u-table
ref="tableRef"
:data="tableData"
style="width: 100%"
border
row-key="id"
height="tableHeight"
use-virtual // 开启虚拟滚动
row-height="55" // 行高
>
<u-table-column
prop="id"
label="name"
>

...
</u-table-column>

</u-table>

其中的u-table是基础虚拟表格,u-grid是解决冲向列多卡顿的问题、或单元格合并。(这里注意u-grid的没有prop字段!!而是field)


具体详细属性请看umy-ui官网


问题

用完这个表格页面性能虽然提升不少但是当我开启多个keep-alive缓存之后全部关闭时还是会有卡顿


image.png

目前用的是 vue-element-admin 的模板,希望有大佬指点一二


最后

如果文章有帮助到你,帮作者点个赞就好啦


作者:凤栖夜落
来源:juejin.cn/post/7315681269702688779
收起阅读 »

js需要同时发起百条接口请求怎么办?--通过Promise实现分批处理接口请求

web
如何通过 Promise 实现百条接口请求? 实际项目中遇到需要批量发起上百条接口请求怎么办? 最新案例代码在此!点击看看 前言 不知你项目中有没有遇到过这样的情况,反正我的实际工作项目中真的遇到了这种玩意,一个接口获取一份列表,列表中的每一项都有一个属性需...
继续阅读 »
如何通过 Promise 实现百条接口请求?

实际项目中遇到需要批量发起上百条接口请求怎么办?

最新案例代码在此!点击看看


前言



  • 不知你项目中有没有遇到过这样的情况,反正我的实际工作项目中真的遇到了这种玩意,一个接口获取一份列表,列表中的每一项都有一个属性需要通过另一个请求来逐一赋值,然后就有了这份封装

  • 真的是很多功能都是被逼出来的

  • 这份功能中要提醒一下:批量请求最关键的除了分批功能之外,适当得取消任务和继续任务也很重要,比如用户到了这个页面后,正在发起百条数据请求,但是这些批量请求还没完全执行完,用户离开了这个页面,此时就需要取消剩下正在发起的请求了,而且如果你像我的遇到的项目一样,页面还会被缓存,那么为了避免用户回到这个页面,所有请求又重新发起一遍的话,就需要实现继续任务的功能,其实这个继续任务比断点续传简单多了,就是过滤到那些已经赋值的数据项就行了

  • 如果看我啰啰嗦嗦一堆烂东西没看明白的话,就直接看下面的源码吧


源码在此!



  • 【注】:这里的 httpRequest 请根据自己项目而定,比如我的项目是uniapp,里面的http请求是 uni.request,若你的项目是 axios 或者 ajax,那就根据它们来对 BatchHttp 中的某些部分进行相应的修改



    • 比如:其中的 cancelAll() 函数,若你的 http 取消请求的方式不同,那么这里取消请求的功能就需要相应的修改,若你使用的是 fetch 请求,那除了修改 cancelAll 功能之外,singleRequest 中收集请求任务的方式也要修改,因为 fetch 是不可取消的,需要借助 AbortController 来实现取消请求的功能,





    • 提示一下,不管你用的是什么请求框架,你都可以自己二次封装一个 request.js,功能就仿照 axios 这种,返回的对象中包含一个 abort() 函数即可,那么这份 BatchHttp 也就能适用啦



  • BatchHttp.js


// 注:这里的 httpRequest 请根据自己项目而定,比如我的项目是uniapp,里面的http请求是 uni.request,若你的项目是 axios 或者 ajax,那就根据它们来对 BatchHttp 中的某些部分
import httpRequest from './httpRequest.js'

/**
* 批量请求封装
*/

export class BatchHttp {

/**
* 构造函数
* @param {Object} http - http请求对象(该http请求拦截器里切勿带有任何有关ui的功能,比如加载对话框、弹窗提示框之类),用于发起请求,该http请求对象必须满足:返回一个包含取消请求函数的对象,因为在 this.cancelAll() 函数中会使用到
* @param {string} [passFlagProp=null] - 用于识别是否忽略某些数据项的字段名(借此可实现“继续上一次完成的批量请求”);如:passFlagProp='url' 时,在执行 exec 时,会过滤掉 items['url'] 不为空的数据,借此可以实现“继续上一次完成的批量请求”,避免每次都重复所有请求
*/

constructor(http=httpRequest, passFlagProp=null) {
/** @private @type {Object[]} 请求任务数组 */
this.resTasks = []
/** @private @type {Object} uni.request对象 */
this.http = http
/** @private @type {boolean} 取消请求标志 */
this.canceled = false
/** @private @type {string|null} 识别跳过数据的属性 */
this.passFlagProp = passFlagProp
}


/**
* 将数组拆分成多个 size 长度的小数组
* 常用于批量处理控制并发等场景
* @param {Array} array - 需要拆分的数组
* @param {number} size - 每个小数组的长度
* @returns {Array} - 拆分后的小数组组成的二维数组
*/

#chunk(array, size) {
const chunks = []
let index = 0

while(index < array.length) {
chunks.push(array.slice(index, size + index))
index += size;
}

return chunks
}

/**
* 单个数据项请求
* @private
* @param {Object} reqOptions - 请求配置
* @param {Object} item - 数据项
* @returns {Promise} 请求Promise
*/

#singleRequest(reqOptions, item) {
return new Promise((resolve, _reject) => {
const task = this.http({
url: reqOptions.url,
method: reqOptions.method || 'GET',
data: reqOptions.data,
success: res => {
resolve({sourceItem:item, res})
}
})
this.resTasks.push(task)
})
}

/**
* 批量请求控制
* @private
* @async
* @param {Object} options - 函数参数项
* @param {Array} options.items - 数据项数组
* @param {Object} options.reqOptions - 请求配置
* @param {number} [options.concurrentNum=10] - 并发数
* @param {Function} [options.chunkCallback] - 分块回调
* @returns {Promise}
*/

async #batchRequest({items, reqOptions, concurrentNum = 10, chunkCallback=(ress)=>{}}) {
const promiseArray = []
let data = []
const passFlagProp = this.passFlagProp
if(!passFlagProp) {
data = items
} else {
// 若设置独立 passFlagProp 值,则筛选出对应属性值为空的数据(避免每次都重复请求所有数据,实现“继续未完成的批量请求任务”)
data = items.filter(d => !Object.hasOwnProperty.call(d, passFlagProp) || !d[passFlagProp])
}
// --
if(data.length === 0) return

data.forEach(item => {
const requestPromise = this.#singleRequest(reqOptions, item)
promiseArray.push(requestPromise)
})

const promiseChunks = this.#chunk(promiseArray, concurrentNum) // 切分成 n 个请求为一组

for (let ck of promiseChunks) {
// 若当前处于取消请求状态,则直接跳出
if(this.canceled) break
// 发起一组请求
const ckRess = await Promise.all(ck) // 控制并发数
chunkCallback(ckRess) // 每完成组请求,都进行回调
}
}

/**
* 设置用于识别忽略数据项的字段名
* (借此参数可实现“继续上一次完成的批量请求”);
* 如:passFlagProp='url' 时,在执行 exec 时,会过滤掉 items['url'] 不为空的数据,借此可以实现“继续上一次完成的批量请求”,避免每次都重复所有请求
* @param {string} val
*/

setPassFlagProp(val) {
this.passFlagProp = val
}

/**
* 执行批量请求操作
* @param {Object} options - 函数参数项
* @param {Array} options.items - 数据项数组
* @param {Object} options.reqOptions - 请求配置
* @param {number} [options.concurrentNum=10] - 并发数
* @param {Function} [options.chunkCallback] - 分块回调
*/

exec(options) {
this.canceled = false
this.#batchRequest(options)
}

/**
* 取消所有请求任务
*/

cancelAll() {
this.canceled = true
for(const task of this.resTasks) {
task.abort()
}
this.resTasks = []
}
}

调用案例在此!



  • 由于我的项目是uni-app这种,方便起见,我就直接贴上在 uni-app 的页面 vue 组件中的使用案例

  • 案例代码仅展示关键部分,所以比较粗糙,看懂参考即可


<template>
<view v-for="item of list" :key="item.key">
<image :src="item.url"></image>
</view>
</template>
<script>
import { BatchHttp } from '@/utils/BatchHttp.js'

export default {
data() {
return {
isLoaded: false,
batchHttpInstance: null,
list:[]
}
},
onLoad(options) {
this.queryList()
},
onShow() {
// 第一次进页面时,onLoad 和 onShow 都会执行,onLoad 中 getList 已调用 batchQueryUrl,这里仅对缓存页面后再次进入该页面有效
if(this.isLoaded) {
// 为了实现继续请求上一次可能未完成的批量请求,再次进入该页面时,会检查是否存在未完成的任务,若存在则继续发起批量请求
this.batchQueryUrl(this.dataList)
}
this.isLoaded = true
},
onHide() {
// 页面隐藏时,会直接取消所有批量请求任务,避免占用资源(下次进入该页面会检查未完成的批量请求任务并执行继续功能)
this.cancelBatchQueryUrl()
},
onUnload() {
// 页面销毁时,直接取消批量请求任务
this.cancelBatchQueryUrl()
},
onBackPress() {
// 路由返回时,直接取消批量请求任务(虽然路由返回也会执行onHide事件,但是无所胃都写上,会判断当前有没有任务的)
this.cancelBatchQueryUrl()
},
methods: {
async queryList() {
// 接口不方法直接贴的,这里是模拟的列表接口
const res = await mockHttpRequest()
this.list = res.data

// 发起批量请求
// 用 nextTick 也行,只要确保批量任务在列表dom已挂载完成之后执行即可
setTimeout(()=>{this.batchQueryUrl(resData)},0)
},
/**
* 批量处理图片url的接口请求
* @param {*} data
*/
batchQueryUrl(items) {
let batchHttpInstance = this.batchHttpInstance
// 判定当前是否有正在执行的批量请求任务,有则直接全部取消即可
if(!!batchHttpInstance) {
batchHttpInstance.cancelAll()
this.batchHttpInstance = null
batchHttpInstance = null
}
// 实例化对象
batchHttpInstance = new BatchHttp()
// 设置过滤数据的属性名(用于实现继续任务功能)
batchHttpInstance.setPassFlagProp('url') // 实现回到该缓存页面是能够继续批量任务的关键一步 <-----
const reqOptions = { url: '/api/product/url' }
batchHttpInstance.exec({items, reqOptions, chunkCallback:(ress)=>{
let newDataList = this.dataList
for(const r of ress) {
newDataList = newDataList.map(d => d.feId === r['sourceItem'].feId ? {...d,url:r['res'].msg} : d)
}

this.dataList = newDataList
}})

this.batchHttpInstance = batchHttpInstance
},
/**
* 取消批量请求
*/
cancelBatchQueryUrl() {
if(!!this.batchHttpInstance) {
this.batchHttpInstance.cancelAll()
this.batchHttpInstance = null
}
},
}
}
</script>

作者:FE_C_P小麦
来源:juejin.cn/post/7306331039843270667
收起阅读 »

前端中 JS 发起的请求可以暂停吗

web
在前端中,JavaScript(JS)可以使用XMLHttpRequest对象或fetch API来发起网络请求。然而,JavaScript本身并没有提供直接的方法来暂停请求的执行。一旦请求被发送,它会继续执行并等待响应。 尽管如此,你可以通过一些技巧或库来模...
继续阅读 »

在前端中,JavaScript(JS)可以使用XMLHttpRequest对象或fetch API来发起网络请求。然而,JavaScript本身并没有提供直接的方法来暂停请求的执行。一旦请求被发送,它会继续执行并等待响应。


尽管如此,你可以通过一些技巧或库来模拟请求的暂停和继续执行。下面是一种常见的方法:


1. 使用XMLHttpRequest对象


你可以在发送请求前创建一个XMLHttpRequest对象,并将其保存在变量中。然后,在需要暂停请求时,调用该对象的abort()方法来中止请求。当需要继续执行请求时,可以重新创建一个新的XMLHttpRequest对象并发起请求。


var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/api', true);
xhr.send();

// 暂停请求
xhr.abort();

// 继续请求
xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/api', true);
xhr.send();

2. 使用fetch API和AbortController


fetch API与AbortController一起使用可以更方便地控制请求的暂停和继续执行。AbortController提供了一个abort()方法,可以用于中止fetch请求。


var controller = new AbortController();

fetch('https://example.com/api', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));

// 暂停请求
controller.abort();

// 继续请求
controller = new AbortController();

fetch('https://example.com/api', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));

请注意,这些方法实际上是通过中止请求并重新发起新的请求来模拟暂停和继续执行的效果,并不能真正暂停正在进行的请求。


3. 曲线救国


模拟一个假暂停的功能,在前端的业务场景上,需要对这些数据进行处理之后渲染在界面上,如果我们能在请求发起之前增加一个控制器,在请求回来时,如果控制器为暂停状态则不处理数据,等待控制器恢复后再进行处理,也可以达到暂停的效果。


// 创建一个暂停控制器 Promise
function createPauseControllerPromise() {
const result = {
isPause: false, // 标记控制器是否处于暂停状态
resolveWhenResume: false, // 表示在恢复时是否解析 Promise
resolve(value) {}, // 解析 Promise 的占位函数
pause() { // 暂停控制器的函数
this.isPause = true;
},
resume() { // 恢复控制器的函数
if (!this.isPause) return;
this.isPause = false;
if (this.resolveWhenResume) {
this.resolve();
}
},
promise: Promise.resolve(), // 初始为已解决状态的 Promise
};

const promise = new Promise((res) => {
result.resolve = res; // 将解析函数与 Promise 关联
});
result.promise = promise; // 更新控制器中的 Promise 对象

return result; // 返回控制器对象
}

function requestWithPauseControl(request) {
const controller = createPauseControllerPromise(); // 创建暂停控制器对象

const controlRequest = request() // 执行请求函数
.then((data) => { // 请求成功回调
if (!controller.isPause) controller.resolve(); // 如果控制器未暂停,则解析 Promise
return data; // 返回请求结果
})
.finally(() => {
controller.resolveWhenResume = true; // 标记在恢复时解析 Promise
});

const result = Promise.all([controlRequest, controller.promise]).then(
(data) => {
controller.resolve(); // 解析控制器的 Promise
return data[0]; // 返回请求处理结果
}
);

result.pause = controller.pause.bind(controller); // 将暂停函数绑定到结果 Promise 对象
result.resume = controller.resume.bind(controller); // 将恢复函数绑定到结果 Promise 对象

return result; // 返回添加了暂停控制功能的结果 Promise 对象
}

为什么需要创建两个promise


在requestWithPauseControl函数中,需要等待两个Promise对象解析:一个是请求处理的Promise,另一个是控制器的Promise。通过使用Promise.all方法,可以将这两个Promise对象组合成一个新的Promise,该新的Promise会在两个原始Promise都解析后才会解析。这样做的目的是确保在处理请求结果之前,暂停控制器的resolve方法被调用,以便在恢复时解析Promise。


因此,将请求处理的Promise和控制器的Promise放入一个Promise数组,并使用Promise.all等待它们都解析完成,可以确保在两个Promise都解析后再进行下一步操作,以实现预期的功能。


使用


const result = requestWithPauseControl(/*request fn*/).then((data) => {
console.log(data)
})

if (Math.random() > 0.5) { result.pause() }

setTimeout(() => {
result.resume()
}, 4000)

作者:来点vc
来源:juejin.cn/post/7310786521082560562
收起阅读 »

更改官方demo的登录方式—web端

项目场景:在环信官网下载Demo,本地运行只有手机+验证码的方式登录?怎么更改为自己项目的appkey和用户去进行登录呢?往下看👇👇👇VUE2 DEMOvue2 demo源码下载vue2 demo线上体验第一步:更改appkeywebim-vue-demo==...
继续阅读 »

项目场景:
环信官网下载Demo,本地运行只有手机+验证码的方式登录?怎么更改为自己项目的appkey和用户去进行登录呢?往下看👇👇👇

VUE2 DEMO

vue2 demo源码下载

vue2 demo线上体验

第一步:更改appkey
webim-vue-demo===>src===>utils===>WebIMConfig.js


第二步:更改代码
webim-vue-demo===>src===>pages===>login===>index.vue

<template>
<a-layout>
<div class="login">
<div class="login-panel">
<div class="logo">Web IM</div>
<a-input v-model="username" :maxLength="64" placeholder="用户名" />
<a-input v-model="password" :maxLength="64" v-on:keyup.13="toLogin" type="password" placeholder="密码" />
<a-input v-model="nickname" :maxLength="64" placeholder="昵称" v-show="isRegister == true" />

<a-button type="primary" @click="toRegister" v-if="isRegister == true">注册</a-button>
<a-button type="primary" @click="toLogin" v-else>登录</a-button>
</div>
<p class="tip" v-if="isRegister == true">
已有账号?
<span class="green" v-on:click="changeType">去登录</span>
</p>
<p class="tip" v-else>
没有账号?
<span class="green" v-on:click="changeType">注册</span>
</p>

<!-- <div class="login-panel">
<div class="logo">Web IM</div>
<a-form :form="form" >
<a-form-item has-feedback>
<a-input
placeholder="手机号码"
v-decorator="[
'phone',
{
rules: [{ required: true, message: 'Please input your phone number!' }],
},
]"
style="width: 100%"
>
<a-select
initialValue="86"
slot="addonBefore"
v-decorator="['prefix', { initialValue: '86' }]"
style="width: 70px"
>
<a-select-option value="86">
+86
</a-select-option>
</a-select>
</a-input>
</a-form-item>

<a-form-item>
<a-row :gutter="8">
<a-col :span="14">
<a-input
placeholder="短信验证码"
v-decorator="[
'captcha',
{ rules: [{ required: true, message: 'Please input the captcha you got!' }] },
]"
/>
</a-col>
<a-col :span="10">
<a-button v-on:click="getSmsCode" class="getSmsCodeBtn">{{btnTxt}}</a-button>
</a-col>
</a-row>
</a-form-item>
<a-button style="width: 100%" type="primary" @click="toLogin" class="login-rigester-btn">登录</a-button>

</a-form> -->
<!-- </div> -->
</div>
</a-layout>
</template>

<script>
import './index.less';
import { mapState, mapActions } from 'vuex';
import axios from 'axios'
import { Message } from 'ant-design-vue';
const domain = window.location.protocol+'//a1.easemob.com'
const userInfo = localStorage.getItem('userInfo') && JSON.parse(localStorage.getItem('userInfo'));
let times = 60;
let timer
export default{
data(){
return {
username: userInfo && userInfo.userId || '',
password: userInfo && userInfo.password || '',
nickname: '',
btnTxt: '获取验证码'
};
},
beforeCreate() {
this.form = this.$form.createForm(this, { name: 'register' });
},
mounted: function(){
const path = this.isRegister ? '/register' : '/login';

if(path !== location.pathname){
this.$router.push(path);
}
if(this.isRegister){
this.getImageVerification()
}
},
watch: {
isRegister(result){
if(result){
this.getImageVerification()
}
}
},
components: {},
computed: {
isRegister(){
return this.$store.state.login.isRegister;
},
imageUrl(){
return this.$store.state.login.imageUrl
},
imageId(){
return this.$store.state.login.imageId
}
},
methods: {
...mapActions(['onLogin', 'setRegisterFlag', 'onRegister', 'getImageVerification', 'registerUser', 'loginWithToken']),
toLogin(){
this.onLogin({
username: this.username.toLowerCase(),
password: this.password
});
// const form = this.form;
// form.validateFields(['phone', 'captcha'], { force: true }, (err, value) => {
// if(!err){
// const {phone, captcha} = value
// this.loginWithToken({phone, captcha})
// }
// });
},
toReset(){
this.$router.push('/resetpassword')
},
toRegister(e){
e.preventDefault(e);
// this.form.validateFieldsAndScroll((err, values) => {
// if (!err) {
// this.registerUser({
// userId: values.username,
// userPassword: values.password,
// phoneNumber: values.phone,
// smsCode: values.captcha,
// })
// }
// });

this.onRegister({
username: this.username.toLowerCase(),
password: this.password,
nickname: this.nickname.toLowerCase(),
});
},
changeType(){
this.setRegisterFlag(!this.isRegister);
},
getSmsCode(){
if(this.$data.btnTxt != '获取验证码') return
const form = this.form;
form.validateFields(['phone'], { force: true }, (err, value) => {
if(!err){
const {phone, imageCode} = value
this.getCaptcha({phoneNumber: phone, imageCode})
}
});
},
getCaptcha(payload){
const self = this
const imageId = this.imageId
axios.post(domain+`/inside/app/sms/send/${payload.phoneNumber}`, {
phoneNumber: payload.phoneNumber,
})
.then(function (response) {
Message.success('短信已发送')
self.countDown()
})
.catch(function (error) {
if(error.response && error.response.status == 400){
if(error.response.data.errorInfo == 'Image verification code error.'){
self.getImageVerification()
}
if(error.response.data.errorInfo == 'phone number illegal'){
Message.error('请输入正确的手机号!')
}else if(error.response.data.errorInfo == 'Please wait a moment while trying to send.'){
Message.error('你的操作过于频繁,请稍后再试!')
}else if(error.response.data.errorInfo.includes('exceed the limit')){
Message.error('获取已达上限!')
}else{
Message.error(error.response.data.errorInfo)
}
}
});
},
countDown(){
this.$data.btnTxt = times
timer = setTimeout(() => {
this.$data.btnTxt--
times--
if(this.$data.btnTxt === 0){
times = 60
this.$data.btnTxt = '获取验证码'
return clearTimeout(timer)
}
this.countDown()
}, 1000)
}
}
};
</script>


webim-vue-demo===>src===>store===>login.js
只用更改actions下的onLogin,其余不用动

onLogin: function(context, payload){
context.commit('setUserName', payload.username);
let options = {
user: payload.username,
pwd: payload.password,
appKey: WebIM.config.appkey,
apiUrl: 'https://a1.easecdn.com'
};
WebIM.conn.open(options).then((res)=>{
localStorage.setItem('userInfo', JSON.stringify({ userId: payload.username, password: payload.password,accessToken:res.accessToken}));
});

},



VUE3 DEMO:

vue3 demo源码下载

vue3 demo线上体验

第一步:更改appkey
webim-vue-demo===>src===>IM===>config===>index.js


第二步:更改代码
webim-vue-demo===>src===>views===>Login===>components===>LoginInput===>index.vue

<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { EaseChatClient } from '@/IM/initwebsdk'
import { handleSDKErrorNotifi } from '@/utils/handleSomeData'
import { fetchUserLoginSmsCode, fetchUserLoginToken } from '@/api/login'
import { useStore } from 'vuex'
import { usePlayRing } from '@/hooks'
const store = useStore()
const loginValue = reactive({
phoneNumber: '',
smsCode: ''
})
const buttonLoading = ref(false)
//根据登陆初始化一部分状态
const loginState = computed(() => store.state.loginState)
watch(loginState, (newVal) => {
if (newVal) {
buttonLoading.value = false
loginValue.phoneNumber = ''
loginValue.smsCode = ''
}
})
const rules = reactive({
phoneNumber: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的手机号',
trigger: ['blur', 'change']
}
],
smsCode: [
{
required: true,
message: '请输入短信验证码',
trigger: ['blur', 'change']
}
]
})
//登陆接口调用
const loginIM = async () => {
const { clickRing } = usePlayRing()
clickRing()
buttonLoading.value = true
/* SDK 登陆的方式 */
try {
let { accessToken } = await EaseChatClient.open({
user: loginValue.phoneNumber.toLowerCase(),
pwd: loginValue.smsCode.toLowerCase(),
});
window.localStorage.setItem(`EASEIM_loginUser`, JSON.stringify({ user: loginValue.phoneNumber, accessToken: accessToken }))
} catch (error) {
console.log('>>>>登陆失败', error);
const { data: { extraInfo } } = error
handleSDKErrorNotifi(error.type, extraInfo.errDesc);
loginValue.phoneNumber = '';
loginValue.smsCode = '';
}
finally {
buttonLoading.value = false;
}
/* !环信后台接口登陆(仅供环信线上demo使用!) */
// const params = {
// phoneNumber: loginValue.phoneNumber.toString(),
// smsCode: loginValue.smsCode.toString()
// }
// try {
// const res = await fetchUserLoginToken(params)
// if (res?.code === 200) {
// console.log('>>>>>>登陆token获取成功', res.token)
// EaseChatClient.open({
// user: res.chatUserName.toLowerCase(),
// accessToken: res.token
// })
// window.localStorage.setItem(
// 'EASEIM_loginUser',
// JSON.stringify({
// user: res.chatUserName.toLowerCase(),
// accessToken: res.token
// })
// )
// }
// } catch (error) {
// console.log('>>>>登陆失败', error)
// if (error.response?.data) {
// const { code, errorInfo } = error.response.data
// if (errorInfo.includes('does not exist.')) {
// ElMessage({
// center: true,
// message: `用户${loginValue.username}不存在!`,
// type: 'error'
// })
// } else {
// handleSDKErrorNotifi(code, errorInfo)
// }
// }
// } finally {
// buttonLoading.value = false
// }
}
/* 短信验证码相关 */
const isSenedAuthCode = ref(false)
const authCodeNextCansendTime = ref(60)
const sendMessageAuthCode = async () => {
const phoneNumber = loginValue.phoneNumber
try {
await fetchUserLoginSmsCode(phoneNumber)
ElMessage({
type: 'success',
message: '验证码获取成功!',
center: true
})
startCountDown()
} catch (error) {
ElMessage({ type: 'error', message: '验证码获取失败!', center: true })
}
}
const startCountDown = () => {
isSenedAuthCode.value = true
let timer = null
timer = setInterval(() => {
if (
authCodeNextCansendTime.value <= 60 &&
authCodeNextCansendTime.value > 0
) {
authCodeNextCansendTime.value--
} else {
clearInterval(timer)
timer = null
authCodeNextCansendTime.value = 60
isSenedAuthCode.value = false
}
}, 1000)
}
</script>

<template>
<el-form :model="loginValue" :rules="rules">
<el-form-item prop="phoneNumber">
<el-input
class="login_input_style"
v-model="loginValue.phoneNumber"
placeholder="手机号"
clearable
/>
</el-form-item>
<el-form-item prop="smsCode">
<el-input
class="login_input_style"
v-model="loginValue.smsCode"
placeholder="请输入短信验证码"
>
<template #append>
<el-button
type="primary"
:disabled="loginValue.phoneNumber && isSenedAuthCode"
@click="sendMessageAuthCode"
v-text="
isSenedAuthCode
? `${authCodeNextCansendTime}S`
: '获取验证码'
"
></el-button>
</template>
</el-input>
</el-form-item>
<el-form-item>
<div class="function_button_box">
<el-button
v-if="loginValue.phoneNumber && loginValue.smsCode"
class="haveValueBtn"
:loading="buttonLoading"
@click="loginIM"
>登录</el-button
>
<el-button v-else class="notValueBtn">登录</el-button>
</div>
</el-form-item>
</el-form>
</template>

<style lang="scss" scoped>
.login_input_style {
margin: 10px 0;
width: 400px;
height: 50px;
padding: 0 16px;
}

::v-deep .el-input__inner {
padding: 0 20px;
font-style: normal;
font-weight: 400;
font-size: 14px;
line-height: 20px;
letter-spacing: 1.75px;
color: #3a3a3a;

&::placeholder {
font-family: 'PingFang SC';
font-style: normal;
font-weight: 400;
font-size: 14px;
line-height: 20px;
/* identical to box height */
letter-spacing: 1.75px;
color: #cccccc;
}
}

::v-deep .el-input__suffix-inner {
font-size: 20px;
margin-right: 15px;
}

::v-deep .el-form-item__error {
margin-left: 16px;
}

::v-deep .el-input-group__append {
background: linear-gradient(90deg, #04aef0 0%, #5a5dd0 100%);
width: 60px;
color: #fff;
border: none;
font-weight: 400;

button {
font-weight: 300;
}
}

.login_text {
font-family: 'PingFang SC';
font-style: normal;
font-weight: 400;
font-size: 12px;
line-height: 17px;
text-align: right;

.login_text_isuserid {
display: inline-block;
// width: 100px;
color: #f9f9f9;
}

.login_text_tologin {
margin-right: 20px;
width: 80px;
color: #05b5f1;
cursor: pointer;

&:hover {
text-decoration: underline;
}
}
}

.function_button_box {
margin-top: 10px;
width: 400px;

button {
margin: 10px;
width: 380px;
height: 50px;
border-radius: 57px;
}

.haveValueBtn {
background: linear-gradient(90deg, #04aef0 0%, #5a5dd0 100%);
border: none;
font-weight: 300;
font-size: 17px;
color: #f4f4f4;

&:active {
background: linear-gradient(90deg, #0b83b2 0%, #363df4 100%);
}
}

.notValueBtn {
border: none;
font-weight: 300;
font-size: 17px;
background: #000000;
mix-blend-mode: normal;
opacity: 0.3;
color: #ffffff;
cursor: not-allowed;
}
}
</style>


REACT DEMO:

react demo源码下载

react demo线上体验

 第一步:更改appkey
webim-dev===>demo===>src===>config===>WebIMConfig.js


第二步:更改代码
webim-dev===>demo===>src===>config===>WebIMConfig.js
将usePassword改为true



UNIAPP DEMO:

uniapp vue2 demo源码下载

uniapp vue3 demo源码下载

第一步:更改appkey

uniapp vue2 demo
webim-uniapp-demo===>utils===>WebIMConfig.js


uniapp vue3 demo
webim-uniapp-demo===>EaseIM===>config===>index.js


第二步:更改代码

webim-uniapp-demo===>pages===>login===>login.vue



微信小程序 DEMO:

微信小程序源码下载

第一步:更改appkey
webim-weixin-demo===>src===>utils===>WebIMConfig.js


第二步:更改代码
webim-weixin-demo===>src===>pages===>login===>login.wxml

<import src="../../comps/toast/toast.wxml" />
<view class="login">
<view class="login_title">
<text bindlongpress="longpress">登录</text>
</view>

<!-- 测试用 请忽略 -->
<view class="config" wx:if="{{ show_config }}">
<view>
<text>使用沙箱环境</text>
<switch class="config_swich" checked="{{isSandBox? true: false}}" color="#0873DE" bindchange="changeConfig" />
</view>
</view>

<view class="login_user {{nameFocus}}">
<input type="text" placeholder="请输入用户名" placeholder-style="color:rgb(173,185,193)" bindinput="bindUsername" bindfocus="onFocusName" bindblur="onBlurName" />
</view>
<view class="login_pwd {{psdFocus}}">
<input type="text" password placeholder="用户密码" placeholder-style="color:rgb(173,185,193)" bindinput="bindPassword" bindfocus="onFocusPsd" bindblur="onBlurPsd"/>
</view>
<view class="login_btn">
<button hover-class="btn_hover" bind:tap="login">登录</button>
</view>
<template is="toast" data="{{ ..._toast_ }}"></template>
</view>

webim-weixin-demo===>src===>pages===>login===>login.js

let WebIM = require("../../utils/WebIM")["default"];
let __test_account__, __test_psword__;
let disp = require("../../utils/broadcast");

let runAnimation = true
Page({
data: {
name: "",
psd: "",
grant_type: "password",
rtcUrl: '',
show_config: false,
isSandBox: false
},

statechange(e) {
console.log('live-player code:', e.detail.code)
},

error(e) {
console.error('live-player error:', e.detail.errMsg)
},

onLoad: function(option){
const me = this;
const app = getApp();
new app.ToastPannel.ToastPannel();

disp.on("em.xmpp.error.passwordErr", function(){
me.toastFilled('用户名或密码错误');
});
disp.on("em.xmpp.error.activatedErr", function(){
me.toastFilled('用户被封禁');
});

wx.getStorage({
key: 'isSandBox',
success (res) {
console.log(res.data)
me.setData({
isSandBox: !!res.data
})
}
})

if (option.username && option.password != '') {
this.setData({
name: option.username,
psd: option.password
})
}
},

bindUsername: function(e){
this.setData({
name: e.detail.value
});
},

bindPassword: function(e){
this.setData({
psd: e.detail.value
});
},
onFocusPsd: function(){
this.setData({
psdFocus: 'psdFocus'
})
},
onBlurPsd: function(){
this.setData({
psdFocus: ''
})
},
onFocusName: function(){
this.setData({
nameFocus: 'nameFocus'
})
},
onBlurName: function(){
this.setData({
nameFocus: ''
})
},

login: function(){
runAnimation = !runAnimation
if(!__test_account__ && this.data.name == ""){
this.toastFilled('请输入用户名!')
return;
}
else if(!__test_account__ && this.data.psd == ""){
this.toastFilled('请输入密码!')
return;
}
wx.setStorage({
key: "myUsername",
data: __test_account__ || this.data.name.toLowerCase()
});

getApp().conn.open({
user: __test_account__ || this.data.name.toLowerCase(),
pwd: __test_psword__ || this.data.psd,
grant_type: this.data.grant_type,
appKey: WebIM.config.appkey
});
},

longpress: function(){
console.log('长按')
this.setData({
show_config: !this.data.show_config
})
},

changeConfig: function(){
this.setData({
isSandBox: !this.data.isSandBox
}, ()=>{
wx.setStorage({
key: "isSandBox",
data: this.data.isSandBox
});
})

}

});



收起阅读 »