集成环信uni-app sdk遇到的问题及解决方法
1. 打包问题
问题描述:
a. 打包h5后报错 [system] API connectSocket is not yet implemented
b. 打包后登录时请求token有问题。
解决方案:如果打包h5平台出现以上两种情况,可以看下打包时想优化包体积大小是否有开启【摇钱树】具体配置如图:
ps: 不了解该配置的可以看下uniapp的官方文档介绍,附上链接https://uniapp.dcloud.io/collocation/manifest?id=treeshaking
问题原因:如果开启这个配置项,打包后所有uni没用到的方法都不会打包进去,这样就会导致SDK内部 uni去用request请求就拿不到,这样后续token就会有问题,或者识别不到scoket api等报错。
2. uniapp运行真机报错 【addEventListener is not defind】
解决方案:升级到4.1.0的uni sdk即可。
问题原因:addEventListener 这个是监听浏览器网络变化的,移动端下不支持,所以提示未定义,但实际上并不会影响其他功能,在后续的版本也修复了下该报错~
3. 参考demo报错【this.setData is not a function】如图:
集成过程中可能疑惑this.setData应该是小程序中的方法,为什么uni中会有,是因为demo中有对该方法重写通过minxin,具体在main.js文件中体现,如下图:
所以如果参照demo报此错可以看下这块是否有复制过来呢~
4. uniapp运行h5发送语音报错
目前的录音实现依赖uni.getRecorderManager()方式, 是不支持 H5的 可以参考下这个文章
https://en.uniapp.dcloud.io/api/media/record-manager.html#getrecordermanager
5. 登录报错 elapse 如图:
解决方案:1)看下当前是否有链接网络 2)是否有开启vpn
6. uni-app中有时会用到nvue组件,订阅事件将会在nvue中失效,所以如果有发布订阅事件需求推荐使用,uni.$emit发布,uni.$on监听。
今天问题就分享到这里啦,感谢大家的阅读!
收起阅读 »Vue PC前端扫码登录
需求描述
目前大多数PC端应用都有配套的移动端APP,如微信,淘宝等,通过使用手机APP上的扫一扫功能去扫页面二维码图片进行登录,使得用户登录操作更方便,安全,快捷。
思路解析
PC 扫码原理?
扫码登录功能涉及到网页端、服务器和手机端,三端之间交互大致步骤如下:
网页端展示二维码,同时不断的向服务端发送请求询问该二维码的状态;
手机端扫描二维码,读取二维码成功后,跳转至确认登录页,若用户确认登录,则服务器修改二维码状态,并返回用户登录信息;
网页端收到服务器端二维码状态改变,则跳转登录后页面;
若超过一定时间用户未操作,网页端二维码失效,需要重新刷新生成新的二维码。
前端功能实现
如何生成二维码图片?
二维码内容是一段字符串,可以使用uuid 作为二维码的唯一标识;
使用qrcode插件 import QRCode from 'qrcode'; 把uuid变为二维码展示给用户
import {v4 as uuidv4} from "uuid"
import QRCode from "qrcodejs2"
let timeStamp = new Date().getTime() // 生成时间戳,用于后台校验有效期
let uuid = uuidv4()
let content = `uid=${uid}&timeStamp=${timeStamp}`
this.$nextTick(()=> {
const qrcode = new QRCode(this.$refs.qrcode, {
text: content,
width: 180,
height: 180,
colorDark: "#333333",
colorlight: "#ffffff",
correctLevel: QRCode.correctLevel.H,
render: "canvas"
})
qrcode._el.title = ''
如何控制二维码的时效性?
使用前端计时器setInterval, 初始化有效时间effectiveTime, 倒计时失效后重新刷新二维码
export default {
name: "qrCode",
data() {
return {
codeStatus: 1, // 1- 未扫码 2-扫码通过 3-过期
effectiveTime: 30, // 有效时间
qrCodeTimer: null // 有效时长计时器
uid: '',
time: ''
};
},
methods: {
// 轮询获取二维码状态
getQcodeStatus() {
if(!this.qsCodeTimer) {
this.qrCodeTimer = setInterval(()=> {
// 二维码过期
if(this.effectiveTime <=0) {
this.codeStatus = 3
clearInterval(this.qsCodeTimer)
this.qsCodeTimer = null
return
}
this.effectiveTime--
}, 1000)
}
},
// 刷新二维码
refreshCode() {
this.codeStatus = 1
this.effectiveTime = 30
this.qsCodeTimer = null
this.generateORCode()
}
},
前端如何获取服务器二维码的状态?
前端向服务端发送二维码状态查询请求,通常使用轮询的方式
定时轮询:间隔1s 或特定时段发送请求,通过调用setInterval(), clearInterval()来停止;
长轮询:前端判断接收到的返回结果,若二维码仍未被扫描,则会继续发送查询请求,直至状态发生变化(失效或扫码成功)
Websocket:前端在生成二维码后,会与后端建立连接,一旦后端发现二维码状态变化,可直接通过建立的连接主动推送信息给前端。
使用长轮询实现:
// 获取后台状态
async checkQRcodeStatus() {
const res = await checkQRcode({
uid: this.uid,
time: this.time
})
if(res && res.code == 200) {
let codeStatus - res.codeStatus
this.codeStatus = codeStatus
let loginData = res.loginData
switch(codeStatus) {
case 3:
console.log("二维码过期")
clearInterval(this.qsCodeTimer)
this.qsCodeTimer = null
this.effectiveTime = 0
break;
case 2:
console.log("扫码通过")
clearInterval(this.qsCodeTimer)
this.qsCodeTimer = null
this.$emit("login", loginData)
break;
case 1:
console.log("未扫码")
this.effectiveTime > 0 && this.checkQRcodeStatus()
break;
default:
break;
}
}
},
参考资料:
作者:前端碎碎念
来源:juejin.cn/post/7179821690686275621
环信web、uniapp、微信小程序sdk报错详解---注册篇(一)
项目场景:
记录对接环信sdk时遇到的一系列问题,总结一下避免大家再次踩坑。这里主要针对于web、uniapp、微信小程序在对接环信sdk时遇到的问题。注册篇(一)
在初始化完成之后,就卡在了第一步注册用户,注册用户居然报错401,上截图
原因分析:
从console控制台输出及network请求返回入手分析
可以看到报错描述Open registration doesn't allow, so register user need token,也就是注册用户需要token,知道问题所在就比较好解决了
解决方案:
解决思路,文档描述
文档描述:若支持SDK注册,需登录环信即时通讯云控制台 (https://console.easemob.com/app/im-service/detail),选择即时通讯 > 服务概览,将 设置下的用户注册模式设置为开放注册。可见文档地址:http://docs-im-beta.easemob.com/document/web/overview.html#sdk-%E6%B3%A8%E5%86%8C
拓展:
上文提到的用户注册模式是什么
据了解,环信的用户注册模式分为两种,一种是授权注册,一种是开放注册,这两种注册模式在即时通讯>服务概览>设置>用户注册模式可以看到,但是这两种注册模式有什么区别呢?
以下是环信文档对于开放注册和授权注册的解释,文档地址:http://docs-im-beta.easemob.com/document/server-side/account_system.html#%E5%BC%80%E6%94%BE%E6%B3%A8%E5%86%8C%E5%8D%95%E4%B8%AA%E7%94%A8%E6%88%B7
通俗解释就是授权注册比开放注册增加了token认证,授权注册更安全,但是如果在端上启用授权注册会比较麻烦,还需要自己封装请求,我这边建议大家注册还是交给后端同事来搞吧~~~~
react的useState源码分析
前言
简单说下为什么React选择函数式组件,主要是class组件比较冗余、生命周期函数写法不友好,骚写法多,functional组件更符合React编程思想等等等。更具体的可以拜读dan大神的blog。其中Function components capture the rendered values这句十分精辟的道出函数式组件的优势。
但是在16.8之前react的函数式组件十分羸弱,基本只能作用于纯展示组件,主要因为缺少state和生命周期。本人曾经在hooks出来前负责过纯函数式的react项目,所有状态处理都必须在reducer中进行,所有副作用都在saga中执行,可以说是十分艰辛的经历了。在hooks出来后我在公司的一个小中台项目中使用,落地效果不错,代码量显著减少的同时提升了代码的可读性。因为通过custom hooks可以更好地剥离代码结构,不会像以前类组件那样在cDU等生命周期堆了一大堆逻辑,在命令式代码和声明式代码中有一个良性的边界。
useState在React中是怎么实现的
Hooks take some getting used to — and especially at the boundary of imperative and declarative code.
如果对hooks不太了解的可以先看看这篇文章:前情提要,十分简明的介绍了hooks的核心原理,但是我对useEffect,useRef等钩子的实现比较好奇,所以开始啃起了源码,下面我会结合源码介绍useState的原理。useState具体逻辑分成三部分:mountState,dispatch, updateState
hook的结构
首先的是hooks的结构,hooks是挂载在组件Fiber结点上memoizedState的
//hook的结构
export type Hook = {
memoizedState: any, //上一次的state
baseState: any, //当前state
baseUpdate: Update<any, any> | null, // update func
queue: UpdateQueue<any, any> | null, //用于缓存多次action
next: Hook | null, //链表
};
renderWithHooks
在reconciler中处理函数式组件的函数是renderWithHooks,其类型是:
renderWithHooks(
current: Fiber | null, //当前的fiber结点
workInProgress: Fiber,
Component: any, //jsx中用<>调用的函数
props: any,
refOrContext: any,
nextRenderExpirationTime: ExpirationTime, //需要在什么时候结束
): any
在renderWithHooks,核心流程如下:
//从memoizedState中取出hooks
nextCurrentHook = current !== null ? current.memoizedState : null;
//判断通过有没有hooks判断是mount还是update,两者的函数不同
ReactCurrentDispatcher.current =
nextCurrentHook === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
//执行传入的type函数
let children = Component(props, refOrContext);
//执行完函数后的dispatcher变成只能调用context的
ReactCurrentDispatcher.current = ContextOnlyDispatcher;
return children;
useState构建时流程
mountState
在HooksDispatcherOnMount中,useState调用的是下面的mountState,作用是创建一个新的hook并使用默认值初始化并绑定其触发器,因为useState底层是useReducer,所以数组第二个值返回的是dispatch。
type BasicStateAction<S> = (S => S) | S;
function mountState<S>(
initialState: (() => S) | S,
){
const hook = mountWorkInProgressHook();
//如果入参是func则会调用,但是不提供参数,带参数的需要包一层
if (typeof initialState === 'function') {
initialState = initialState();
}
//上一个state和基本(当前)state都初始化
hook.memoizedState = hook.baseState = initialState;
const queue = (hook.queue = {
last: null,
dispatch: null,
eagerReducer: basicStateReducer, // useState使用基础reducer
eagerState: (initialState: any),
});
//返回触发器
const dispatch: Dispatch<
//useState底层是useReducer,所以type是BasicStateAction
(queue.dispatch = (dispatchAction.bind(
null,
//绑定当前fiber结点和queue
((currentlyRenderingFiber: any): Fiber),
queue,
): any));
return [hook.memoizedState, dispatch];
}
mountWorkInProgressHook
这个函数是mountState时调用的构建hook的方法,在初始化完毕后会连接到当前hook.next(如果有的话)
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
queue: null,
baseUpdate: null,
next: null,
};
if (workInProgressHook === null) {
// 列表中的第一个hook
firstWorkInProgressHook = workInProgressHook = hook;
} else {
// 添加到列表的末尾
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
dispatch分发函数
在上面我们提到,useState底层是useReducer,所以返回的第二个参数是dispatch函数,其中的设计十分巧妙。
假设我们有以下代码:
相关参考视频讲解:进入学习
const [data, setData] = React.useState(0)
setData('first')
setData('second')
setData('third')
在第一次setData后, hooks的结构如上图
在第二次setData后, hooks的结构如上图
在第三次setData后, hooks的结构如上图
在正常情况下,是不会在dispatcher中触发reducer而是将action存入update中在updateState中再执行,但是如果在react没有重渲染需求的前提下是会提前计算state即eagerState。作为性能优化的一环。
function dispatchAction<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
) {
const alternate = fiber.alternate;
{
flushPassiveEffects();
//获取当前时间并计算可用时间
const currentTime = requestCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);
const update: Update<S, A> = {
expirationTime,
action,
eagerReducer: null,
eagerState: null,
next: null,
};
//下面的代码就是为了构建queue.last是最新的更新,然后last.next开始是每一次的action
// 取出last
const last = queue.last;
if (last === null) {
// 自圆
update.next = update;
} else {
const first = last.next;
if (first !== null) {
update.next = first;
}
last.next = update;
}
queue.last = update;
if (
fiber.expirationTime === NoWork &&
(alternate === null || alternate.expirationTime === NoWork)
) {
// 当前队列为空,我们可以在进入render阶段前提前计算出下一个状态。如果新的状态和当前状态相同,则可以退出重渲染
const lastRenderedReducer = queue.lastRenderedReducer; // 上次更新完后的reducer
if (lastRenderedReducer !== null) {
let prevDispatcher;
if (__DEV__) {
prevDispatcher = ReactCurrentDispatcher.current; // 暂存dispatcher
ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
}
try {
const currentState: S = (queue.lastRenderedState: any);
// 计算下次state
const eagerState = lastRenderedReducer(currentState, action);
// 在update对象中存储预计算的完整状态和reducer,如果在进入render阶段前reducer没有变化那么可以服用eagerState而不用重新再次调用reducer
update.eagerReducer = lastRenderedReducer;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// 在后续的时间中,如果这个组件因别的原因被重渲染且在那时reducer更变后,仍有可能重建这次更新
return;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally {
if (__DEV__) {
ReactCurrentDispatcher.current = prevDispatcher;
}
}
}
}
scheduleWork(fiber, expirationTime);
}
}
useState更新时流程
updateReducer
因为useState底层是useReducer,所以在更新时的流程(即重渲染组件后)是调用updateReducer的。
function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, (initialState: any));
}
所以其reducer十分简单
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}
我们先把复杂情况抛开,跑通updateReducer流程
function updateReducer(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
){
// 获取当前hook,queue
const hook = updateWorkInProgressHook();
const queue = hook.queue;
queue.lastRenderedReducer = reducer;
// action队列的最后一个更新
const last = queue.last;
// 最后一个更新是基本状态
const baseUpdate = hook.baseUpdate;
const baseState = hook.baseState;
// 找到第一个没处理的更新
let first;
if (baseUpdate !== null) {
if (last !== null) {
// 第一次更新时,队列是一个自圆queue.last.next = queue.first。当第一次update提交后,baseUpdate不再为空即可跳出队列
last.next = null;
}
first = baseUpdate.next;
} else {
first = last !== null ? last.next : null;
}
if (first !== null) {
let newState = baseState;
let newBaseState = null;
let newBaseUpdate = null;
let prevUpdate = baseUpdate;
let update = first;
let didSkip = false;
do {
const updateExpirationTime = update.expirationTime;
if (updateExpirationTime < renderExpirationTime) {
// 优先级不足,跳过这次更新,如果这是第一次跳过更新,上一个update/state是newBaseupdate/state
if (!didSkip) {
didSkip = true;
newBaseUpdate = prevUpdate;
newBaseState = newState;
}
// 更新优先级
if (updateExpirationTime > remainingExpirationTime) {
remainingExpirationTime = updateExpirationTime;
}
} else {
// 处理更新
if (update.eagerReducer === reducer) {
// 如果更新被提前处理了且reducer跟当前reducer匹配,可以复用eagerState
newState = ((update.eagerState: any): S);
} else {
// 循环调用reducer
const action = update.action;
newState = reducer(newState, action);
}
}
prevUpdate = update;
update = update.next;
} while (update !== null && update !== first);
• if (!didSkip) {
• newBaseUpdate = prevUpdate;
• newBaseState = newState;
• }
• // 只有在前后state变了才会标记
• if (!is(newState, hook.memoizedState)) {
• markWorkInProgressReceivedUpdate();
• }
• hook.memoizedState = newState;
• hook.baseUpdate = newBaseUpdate;
• hook.baseState = newBaseState;
queue.lastRenderedState = newState;
}
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
export function markWorkInProgressReceivedUpdate() {
didReceiveUpdate = true;
}
后记
作为系列的第一篇文章,我选择了最常用的hooks开始,抛开提前计算及与react-reconciler的互动,整个流程是十分清晰易懂的。mount的时候构建钩子,触发dispatch时按序插入update。updateState的时候再按序触发reducer。可以说就是一个简单的redux。
作者:flyzz177
来源:juejin.cn/post/7184636589564231735
IM会话列表刷新优化思考
背景
脱离业务场景讲技术方案都是耍流氓
最近接手了IM的业务,一上来就来了几个大需求,搞得有点手忙脚乱。在做需求的过程中发现,我们的会话列表(RecyclerView)居然每次更新都是notifyDataSetChanged(),因为IM的刷新频率是非常高的
大家可以想象一下微信消息列表,每来1条消息,就全局调用notifyDataSetChanged。
这里瞎猜一下,可能由于历史原因,之前设计的同学也是不得已而为之。既然发现了这个问题,那么我们如何来进行优化呢?
IM列表跟普通列表的区别
有序性:列表中的Item按时间排序,或者其他规则(置顶也是修改时间实现)
唯一性:每个会话都是唯一的,不存在重复
单item更新频率高:可以参考微信的会话列表
DiffUtil
首先想到的是DiffUtil,它用来比较两个数据集,寻找出旧数据集->新数据集的最小变化量
实现思路:
获取原始会话数据,进行排序,去重操作
采用DiffUtil自动计算新老数据集差异,自动完成定向刷新
这里只摘取DiffUtil关键使用部分,至于高级用法和更高级的用法不再赘述
class DiffMsgCallBack: DiffUtil.Callback() {
private val oldData: MutableList<MsgItem> = mutableListOf()
private val newData: MutableList<MsgItem> = mutableListOf()
//老数据集size
override fun getOldListSize(): Int {
return oldData.size
}
//新数据集size
override fun getNewListSize(): Int {
return newData.size
}
/**
* 比较的是position,被DiffUtil调用,用来判断两个对象是否是相同的Item
* 例如,如果你的Item有唯一的id字段,这个方法就 判断id是否相等
*/
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldData[oldItemPosition].id == newData[newItemPosition].id
}
/**
* 用来检查 两个item是否含有相同的数据,当前item的内容是否发生了变化,这个方法仅仅在areItemsTheSame()返回true时,才调用
*/
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
if (oldData[oldItemPosition].id != newData[newItemPosition].id){
return false
}
if (oldData[oldItemPosition].content != newData[newItemPosition].content){
return false
}
if (oldData[oldItemPosition].time != newData[newItemPosition].time){
return false
}
return true
}
/**
* 高级用法:实现部分(partial)绑定的方法,需要配合onBindViewHolder的3个参数的方法
* 更高级用法:AsyncListDiffer+ListAdapter
*
*/
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
return super.getChangePayload(oldItemPosition, newItemPosition)
}
SortedList
当我以为DiffUtil
已经可以满足需求的时候,无意间又发现了一个SortedList
。
SortedList是一个有序列表(数据集)的实现,可以保持ItemData都是有序的,并(自动)通知列表(RecyclerView)(数据集)中的更改。
搭配RecyclerView使用,去重,有序,自动定向刷新
这里也只摘取关键使用部分,具体用法不再详解
class SortListCallBack(adapter: RecyclerView.Adapter<*>?) : SortedListAdapterCallback<MsgItem>(adapter) {
/**
* 排序条件,实现排序的逻辑
*/
override fun compare(o1: MsgItem?, o2: MsgItem?): Int {
o1 ?: return -1
o2 ?: return -1
return o1.time - o2.time
}
/**
* 和DiffUtil方法一致,用来判断 两个对象是否是相同的Item。
*/
override fun areItemsTheSame(item1: MsgItem?, item2: MsgItem?): Boolean {
return item1?.id == item2?.id
}
/**
* 和DiffUtil方法一致,返回false,代表Item内容改变。会回调mCallback.onChanged()方法;
* 相同:areContentsTheSame+areItemsTheSame
*/
override fun areContentsTheSame(oldItem: MsgItem?, newItem: MsgItem?): Boolean {
if (oldItem?.id != newItem?.id){
return false
}
if (oldItem?.content != newItem?.content){
return false
}
if (oldItem?.time != newItem?.time){
return false
}
return true
}
/**
* 高级用法:实现部分绑定的方法,需要配合onBindViewHolder的3个参数的方法
*/
override fun getChangePayload(item1: MsgItem?, item2: MsgItem?): Any? {
return super.getChangePayload(item1, item2)
}
}
对比
DiffUtil和SortedList是非常相似的,修改过数据后,内部持有的回调接口都是同一个:androidx.recyclerview.widget.ListUpdateCallback
/**
* An interface that can receive Update operations that are applied to a list.
* <p>
* This class can be used together with DiffUtil to detect changes between two lists.
*/
public interface ListUpdateCallback {
void onInserted(int position, int count);
void onRemoved(int position, int count);
void onMoved(int fromPosition, int toPosition);
void onChanged(int position, int count, @Nullable Object payload);
DiffUtil计算出Diff或者SortedList察觉出数据集有改变后,会回调ListUpdateCallback接口的这四个方法,DiffUtil和SortedList提供的默认Callback实现中,都会通知Adapter完成定向刷新。 这就是自动定向刷新的原理
总结
DiffUtil比较两个数据源(一般是List)的差异(Diff),Callback中比对时传递的参数是 position
SortedList能完成数据集的排序和去重,Callback中比对时,传递的参数是ItemData
都能完成自动定向刷新 + 部分绑定,一种自动定向刷新的手段
DiffUtil: 检测不出重复的,会被认为是新增的
DiffUtil高级用法支持子线程中处理数据,而SortList不支持
理想与现实
2种方案都有了,是不是可以进行IM会话列表的优化了呢,答案是不能
业务需求迭代,牵一发而动全身
祖传代码,无人敢动,更别说优化了
有时候我们写代码会想着后面再优化一下,然而很多时候都不会给你优化的机会,除非重大需求变动,所以一开始设计框架的时候就要结合业务场景尽量设计的更加合理
参考文章:blog.csdn.net/zxt0601/art…
作者:掀乱书页的风
来源:juejin.cn/post/7183517773790707769
前端白屏的检测方案,让你知道自己的页面白了
前言
页面白屏,绝对是让前端开发者最为胆寒的事情,特别是随着 SPA 项目的盛行,前端白屏的情况变得更为复杂且棘手起来( 这里的白屏是指页面一直处于白屏状态 )
要是能检测到页面白屏就太棒了,开发者谁都不想成为最后一个知道自己页面白的人😥
web-see 前端监控方案,提供了 采样对比+白屏修正机制 的检测方案,兼容有骨架屏、无骨架屏这两种情况,来解决开发者的白屏之忧
知道页面白了,然后呢?
web-see 前端监控,会给每次页面访问生成一个唯一的uuid,当上报页面白屏后,开发者可以根据白屏的uuid,去监控后台查询该id下对应的代码报错、资源报错等信息,定位到具体的源码,帮助开发者快速解决白屏问题
白屏检测方案的实现流程
采样对比+白屏修正机制的主要流程:
1、页面中间取17个采样点(如下图),利用 elementsFromPoint api 获取该坐标点下的 HTML 元素
2、定义属于容器元素的集合,如 ['html', 'body', '#app', '#root']
3、判断17这个采样点是否在该容器集合中。说白了,就是判断采样点有没有内容;如果没有内容,该点的 dom 元素还是容器元素,若17个采样点都没有内容则算作白屏
4、若初次判断是白屏,开启轮询检测,来确保白屏检测结果的正确性,直到页面的正常渲染
采样点分布图(蓝色为采样点):
如何使用
import webSee from 'web-see';
Vue.use(webSee, {
dsn: 'http://localhost:8083/reportData', // 上报的地址
apikey: 'project1', // 项目唯一的id
userId: '89757', // 用户id
silentWhiteScreen: true, // 开启白屏检测
skeletonProject: true, // 项目是否有骨架屏
whiteBoxElements: ['html', 'body', '#app', '#root'] // 白屏检测的容器列表
});
下面聊一聊具体的分析与实现
白屏检测的难点
1) 白屏原因的不确定
从问题推导现象虽然能成功,但从现象去推导问题却走不通。白屏发生时,无法和具体某个报错联系起来,也可能根本没有报错,比如关键资源还没有加载完成
导致白屏的原因,大致分两种:资源加载错误、代码执行错误
2) 前端渲染方式的多样性
前端页面渲染方式有多种,比如 客户端渲染 CSR 、服务端渲染 SSR 、静态页面生成 SSG 等,每种模式各不相同,白屏发生的情况也不尽相同
很难用一种统一的标准去判断页面是否白了
技术方案调研
如何设计出一种,在准确性、通用型、易用性等方面均表现良好的检测方案呢?
本文主要讨论 SPA 项目的白屏检测方案,包括有无骨架屏的两种情况
方案一:检测根节点是否渲染
原理很简单,在当前主流 SPA 框架下,DOM 一般挂载在一个根节点之下(比如 <div id="app"></div>
),发生白屏后通常是根节点下所有 DOM 被卸载,该方法通过检测根节点下是否挂载 DOM,若无则证明白屏
这是简单明了且有效的方案,但缺点也很明显:其一切建立在 白屏 === 根节点下 DOM 被卸载
成立的前提下,缺点是通用性较差,对于有骨架屏的情况束手无策
方案二:Mutation Observer 监听 DOM 变化
通过此 API 监听页面 DOM 变化,并告诉我们每次变化的 DOM 是被增加还是删除
但这个方案有几个缺陷
1)白屏不一定是 DOM 被卸载,也有可能是压根没渲染,且正常情况也有可能大量 DOM 被卸载
2)遇到有骨架屏的项目,若页面从始至终就没变化,一直显示骨架屏,这种情况 Mutation Observer 也束手无策
方案三:页面截图检测
这种方式是基于原生图片对比算法处理白屏检测的 web 实现
整体流程:对页面进行截图,将截图与一张纯白的图片做对比,判断两者是否足够相似
但这个方案有几个缺陷:
1、方案较为复杂,性能不高;一方面需要借助 canvas 实现前端截屏,同时需要借助复杂的算法对图片进行对比
2、通用性较差,对于有骨架屏的项目,对比的样张要由纯白的图片替换成骨架屏的截图
方案四:采样对比
该方法是对页面取关键点,进行采样对比,在准确性、易用性等方面均表现良好,也是最终采用的方案
对于有骨架屏的项目,通过对比前后获取的 dom 元素是否一致,来判断页面是否变化(这块后面专门讲解)
采样对比代码:
// 监听页面白屏
function whiteScreen() {
// 页面加载完毕
function onload(callback) {
if (document.readyState === 'complete') {
callback();
} else {
window.addEventListener('load', callback);
}
}
// 定义外层容器元素的集合
let containerElements = ['html', 'body', '#app', '#root'];
// 容器元素个数
let emptyPoints = 0;
// 选中dom的名称
function getSelector(element) {
if (element.id) {
return "#" + element.id;
} else if (element.className) {// div home => div.home
return "." + element.className.split(' ').filter(item => !!item).join('.');
} else {
return element.nodeName.toLowerCase();
}
}
// 是否为容器节点
function isContainer(element) {
let selector = getSelector(element);
if (containerElements.indexOf(selector) != -1) {
emptyPoints++;
}
}
onload(() => {
// 页面加载完毕初始化
for (let i = 1; i <= 9; i++) {
let xElements = document.elementsFromPoint(window.innerWidth * i / 10, window.innerHeight / 2);
let yElements = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight * i / 10);
isContainer(xElements[0]);
// 中心点只计算一次
if (i != 5) {
isContainer(yElements[0]);
}
}
// 17个点都是容器节点算作白屏
if (emptyPoints == 17) {
// 获取白屏信息
console.log({
status: 'error'
});
}
}
}
白屏修正机制
若首次检测页面为白屏后,任务还没有完成,特别是手机端的项目,有可能是用户网络环境不好,关键的JS资源或接口请求还没有返回,导致的页面白屏
需要使用轮询检测,来确保白屏检测结果的正确性,直到页面的正常渲染,这就是白屏修正机制
白屏修正机制图例:
轮询代码:
// 采样对比
function sampling() {
let emptyPoints = 0;
……
// 页面正常渲染,停止轮询
if (emptyPoints != 17) {
if (window.whiteLoopTimer) {
clearTimeout(window.whiteLoopTimer)
window.whiteLoopTimer = null
}
} else {
// 开启轮询
if (!window.whiteLoopTimer) {
whiteLoop()
}
}
// 通过轮询不断修改之前的检测结果,直到页面正常渲染
console.log({
status: emptyPoints == 17 ? 'error' : 'ok'
});
}
// 白屏轮询
function whiteLoop() {
window.whiteLoopTimer = setInterval(() => {
sampling()
}, 1000)
}
骨架屏
对于有骨架屏的页面,用户打开页面后,先看到骨架屏,然后再显示正常的页面,来提升用户体验;但如果页面从始至终都显示骨架屏,也算是白屏的一种
骨架屏示例:
骨架屏的原理
无论 vue 还是 react,页面内容都是挂载到根节点上。常见的骨架屏插件,就是基于这种原理,在项目打包时将骨架屏的内容直接放到 html 文件的根节点中
有骨架屏的html文件:
骨架屏的白屏检测
上面的白屏检测方案对有骨架屏的项目失灵了,虽然页面一直显示骨架屏,但判断结果页面不是白屏,不符合我们的预期
需要通过外部传参明确的告诉 SDK,该页面是不是有骨架屏,如果有骨架屏,通过对比前后获取的 dom 元素是否一致,来实现骨架屏的白屏检测
完整代码:
/**
* 检测页面是否白屏
* @param {function} callback - 回到函数获取检测结果
* @param {boolean} skeletonProject - 页面是否有骨架屏
* @param {array} whiteBoxElements - 容器列表,默认值为['html', 'body', '#app', '#root']
*/
export function openWhiteScreen(callback, { skeletonProject, whiteBoxElements }) {
let _whiteLoopNum = 0;
let _skeletonInitList = []; // 存储初次采样点
let _skeletonNowList = []; // 存储当前采样点
// 项目有骨架屏
if (skeletonProject) {
if (document.readyState != 'complete') {
sampling();
}
} else {
// 页面加载完毕
if (document.readyState === 'complete') {
sampling();
} else {
window.addEventListener('load', sampling);
}
}
// 选中dom点的名称
function getSelector(element) {
if (element.id) {
return '#' + element.id;
} else if (element.className) {
// div home => div.home
return ('.' + element.className.split(' ').filter(item => !!item).join('.'));
} else {
return element.nodeName.toLowerCase();
}
}
// 判断采样点是否为容器节点
function isContainer(element) {
let selector = getSelector(element);
if (skeletonProject) {
_whiteLoopNum ? _skeletonNowList.push(selector) : _skeletonInitList.push(selector);
}
return whiteBoxElements.indexOf(selector) != -1;
}
// 采样对比
function sampling() {
let emptyPoints = 0;
for (let i = 1; i <= 9; i++) {
let xElements = document.elementsFromPoint(
(window.innerWidth * i) / 10,
window.innerHeight / 2
);
let yElements = document.elementsFromPoint(
window.innerWidth / 2,
(window.innerHeight * i) / 10
);
if (isContainer(xElements[0])) emptyPoints++;
// 中心点只计算一次
if (i != 5) {
if (isContainer(yElements[0])) emptyPoints++;
}
}
// 页面正常渲染,停止轮训
if (emptyPoints != 17) {
if (skeletonProject) {
// 第一次不比较
if (!_whiteLoopNum) return openWhiteLoop();
// 比较前后dom是否一致
if (_skeletonNowList.join() == _skeletonInitList.join())
return callback({
status: 'error'
});
}
if (window._loopTimer) {
clearTimeout(window._loopTimer);
window._loopTimer = null;
}
} else {
// 开启轮训
if (!window._loopTimer) {
openWhiteLoop();
}
}
// 17个点都是容器节点算作白屏
callback({
status: emptyPoints == 17 ? 'error' : 'ok',
});
}
// 开启白屏轮训
function openWhiteLoop() {
if (window._loopTimer) return;
window._loopTimer = setInterval(() => {
if (skeletonProject) {
_whiteLoopNum++;
_skeletonNowList = [];
}
sampling();
}, 1000);
}
}
如果不通过外部传参,SDK 能否自己判断是否有骨架屏呢? 比如在页面初始的时候,根据根节点上有没有子节点来判断
因为这套检测方案需要兼容 SSR 服务端渲染的项目,对于 SSR 项目来说,浏览器获取 html 文件的根节点上已经有了 dom 元素,所以最终采用外部传参的方式来区分
总结
这套白屏检测方案是从现象推导本质,可以覆盖绝大多数 SPA 项目的应用场景
小伙们若有其他检测方案,欢迎多多讨论与交流 💕
作者:海阔_天空
来源:juejin.cn/post/7176206226903007292
前端常见登录方案梳理
前端登录有很多种方式,我们来挑一些常见的方案先梳理一下,后续再补充更多的。
账号密码登录
在系统数据库中已经有了账号密码,或者通过注册渠道生成了账号和密码,此时可以直接通过账号密码登录,只要账号密码正确就认为身份合法,可以换到系统访问的 token,用于后续业务鉴权。
验证码登录
比如手机验证码,邮箱验证码等等。用户首先提供手机号/邮箱,后端根据会话信息生成一个特定的码下发到用户的手机或者邮箱(通过运营商提供的能力)。
用户得到这个码后填入登录表单,随手机号/邮箱一并发给后端,后端拿到手机号/邮箱、码后,与会话信息做校验,确认身份信息是否合法。
如果一致就检查数据库中是否有这个手机号/邮箱,有的话就不用创建用户了,直接通过登录;没有的话就说明是新用户,可以先创建用户,绑定好手机号/邮箱,然后通过登录。
第三方授权
比如微信授权,github授权之类的,可以通过OAuth授权得到访问对方开放API的能力。
OAuth 协议读起来很复杂,其实本质上就是:
我是开发者,有个自己的业务系统。
用户想图方便,希望通过一些常用的平台(比如微信,支付宝等)登录到我的业务系统。
但是这也不是你想用就能用的,我首先要去三方平台登记一下我的应用,比如注册一个微信公众号,公众号再绑定我的业务域名(验证所有权),可能还要交个费做微信认证之类的。
交了保护费后(经过上面的操作),我的业务系统就是某三方平台的合法应用了,就可以使用某三方平台的开放接口了。
此时用户来到我的业务系统客户端,点击微信一键登录。
然后我的业务系统就会按照微信的规矩生成一些鉴权需要的信息,拉起微信的中间页(如果是手机客户端,那可能就是通过 SDK 拉起手机微信)让用户授权。
用户同意授权,微信的中间页鉴权成功后,就会给我的客户端返回一个 code 之类的回调信息,客户端需要把这个 code 传给后端。
后端拿到这个 code 可以去微信服务器换取 access_token,基于这个 access_token,可以获取微信用户基本开放信息和帮助用户实现基础开放功能等。
后端也可以基于此封装自定义的登录态返给客户端,如有必要,也可以生成用户表中的记录。
此时我就认为这个用户是通过微信合法登录到我的系统中了。
有些字段或者信息之类的可能会描述得不够精确,但是整个鉴权的思路大概就是这样。
微信小程序登录
wx.login + code2Session 无感登录
如果你的业务系统需要鉴权大部分接口,但是又不想让用户一打开小程序就去输入啥或者点啥按钮登录,那么无感登录是比较适合的。
关键是找到能唯一标识用户身份的东西,openid 或者 unionid 就不错。那么怎么无感得到这些?wx.login + code2Session 值得拥有。
小程序前端 wx.login 得到用户登录凭证 code(目前说的有效期是五分钟),然后把 code 传给服务端,服务端调用微信服务的 auth.code2Session,使用 code 换取 openid、unionid、session_key 等信息,session_key 相当于是当前用户在微信的会话标识,我们可以基于此自定义登录态再返回给前端,前端拿着登录态再访问后端的业务接口。
getPhonenumber授权手机号登录
当指定 button 组件的 open-type 为 getPhoneNumber 时,可以拉起手机号授权,手机号某种程度上可以标识用户身份,自然也可以用来做登录。
旧版方案中,getPhonenumber得到的 e 对象中有 encryptedData, iv 字段,传给后端,根据解密算法能得到手机号和区号等信息。手机号也相当于是一种可以唯一标识用户的信息(虽然一个人可以有多个手机号,不过宽松点来说也可以用来标识用户),自然可以用来生成用户表记录,后续再与其他信息做关联即可。
但是旧版方案已经不建议使用了,目前 getPhonenumber得到的 e 对象中有 code 字段,这个 code 和 wx.login 得到的 code 不是同一回事。我们把这个 code 传给后端,后端再调用 phonenumber.getPhoneNumber得到手机号信息。
接着再封装登录态返回给前端即可。
微信公众号登录
首先分析一下渠道,在微信环境中,用户可能会直接通过链接访问 H5,也可能通过公众号菜单进入 H5。
微信公众号网页提供了授权方案,具体可以参考这个网页授权文档。
授权有两种形式,snsapi_base 和 snsapi_userinfo。
这个授权是支持无感的,具体见这个解释。
关于特殊场景下的静默授权
上面已经提到,对于以snsapi_base为 scope 的网页授权,就静默授权的,用户无感知;
对于已关注公众号的用户,如果用户从公众号的会话或者自定义菜单进入本公众号的网页授权页,即使是 scope 为snsapi_userinfo,也是静默授权,用户无感知。
这基本上就是说,如果是 snsapi_base 方式,目的主要是取 token 和 openid,用来做后续业务鉴权,那就是无感的。
如果是 snsapi_userinfo 方式,除了拿鉴权信息,还要要拿头像昵称等信息,可能需要用户授权,不过只要关注了该公众号,也可以不出现授权中间页,也是无感的。
下面说下具体的交互形式。
snsapi_base 场景下,需要绑定一个回调地址,交互形式是:
根据标准格式提供链接:
https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect
你可以在公众号菜单跳转这个标准链接,或者通过其他网页跳转这个链接。这个链接是个微信鉴权的中间页,如果鉴权没问题就会回调到 REDIRECT_URI 对应的业务系统页面,也就是用户真正前往的网页,用户能感知到的就是网页的进度条加载了两次,然后就到目标页面了,基本上是无感的。
页面在回调时会在 querystring 上携带 code 参数。前端在这个页面拿到 code 后,可以传给后端,后端就可以调下面这个接口得到 token 信息,然后封装出登录态返给前端。
https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
具体实现时,不一定要在页面层级上完成 code 换 token 的操作,也可以在应用层级上实现。
后续可以根据需要进行 refreshToken。
snsapi_userinfo 场景下,也是跳一个标准链接。与 snsapi_base 场景相比,除了 scope 参数不一样,其他都一样。跳转这个标准链接时会根据有没有关注公众号决定是否要拉起授权中间页面。
https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect
接着也可以根据 code 换 token,进行必要的 refreshToken。
最重要的是,在 scope=snsapi_userinfo 场景下,还可以发起获取用户信息的请求,这才是它与 snsapi_base 的本质区别。如果 scope 不符合要求,则无法通过调用下面的接口得到用户信息。
https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN
还有一些公告调整内容要注意一下:
结语
好了,前端常见的一些登录方式先整理到这里,实际上还有很多种方案还没提到,比如生物认证登录,运营商验证登录等等,后面再补充,只要是双方互相认可的方案,并且能标识用户身份,不管是严格的还是宽松的,都可以拿来做认证使用,具体还要根据你的业务特性决定。
作者:Tusi
来源:juejin.cn/post/7172026468535369735
给你的网站接入 github 提供的第三方登录
什么年代了还在用传统账号密码登录?没钱买手机号验证码组合?直接把鉴权和用户系统全盘托出依赖第三方(又不是不能用),省去鉴权系列 SQL
攻击、密码加密、CSRF
攻击、XSS
攻击,老板再也不用担心黑产盗号了(我们的系统根本没有号)
要实现上面的功能就得接入第三方登录,接下来就随着文章一起试试吧!
github
本章节将使用 github
作为第三方登录服务提供商
github
不愧是阿美力卡之光,极其简便的操作即可开启你的第三方登录之旅,经济又实惠,你可以通过快捷链接进入创建 OAuth
应用界面,也可以按照下面的顺序
然后填写相应的信息
生成你的密钥(Client secrets
),就可以去试试第三方登录了
组合 URL
您可以在线查看本章节源代码
这里我使用的是 express-generator
去生成项目,并且前后端分离,在选项上不需要 HTML
渲染器
npx express --no-view your-project-path && cd your-project-path
前端部分简单设置一下跳转验证
<html>
<body>
<div>
第三方登录
<br />
<button onclick="handleGithubLoginClick()">github</button>
</div>
</body>
<script>
const handleGithubLoginClick = () => {
const state = Math.floor(Math.random() * Math.pow(10, 8));
localStorage.setItem("state", state);
window.open(
`https://github.com/login/oauth/authorize?client_id=b351931efd1203b2230e&redirect_uri=http://localhost:8080&state=${state}`,
"_blank"
);
};
</script>
</html>
其中有三个比较重要的 params
redirect_uri
默认是注册 OAuth
应用(Register a new OAuth application
)是填写的授权回调 URL
(Authorization callback URL
)
而对于 state
就在前端用随机字符串模拟,通常此类加密的敏感数据会再后端生成,而这里为了方便演示就采用了前端生成
详细参数请参考文档
鉴权验证
登录之后就可以进行相对应的验证,比如输入账号密码、授权、Github
客户端验证
成功鉴权后会再新弹出的页面重定向至 redirect_uri
注意要在属于用户操作的范畴下,比如点击按钮的操作,去使用 window.open(strUrl, strWindowName, [strWindowFeatures])
这种方式去跳转鉴权,否则像 window.open("https://github.com...", "_blank")
这种常见的写法,会报错
浏览器会以为是弹窗式广告,所以我推荐使用直接在当前窗口跳转的方法,而不是选择新开窗口或者浮动窗口
window.location.href = "https://github.com/login/oauth/authorize?client_id=b351931efd1203b2230e&redirect_uri=http://localhost:8080";
处理回调
通过用户授权时,Github
的响应如下
GET redirect_uri
参数
名称 | 类型 | 说明 |
---|---|---|
code | string | 鉴权通过的响应代码 |
state | string | 请求第三方登录时防 csrf 凭证 |
state
参数负责安全非常重要,想要快速通关的选手可以跳过这部分
对于这里的 state
处理可以分为前端处理和后端处理
前端处理
当 redirect_uri
是前端路由时,可以将之前提交的 state
从 localStorage
或者 sessionStorage
中取出,验证是否一致,再去向后端请求并带上 state
和 code
优点
无需缓存
state
缺点
需要防止
XSS
的DOM
型攻击
后端处理
当 redirect_uri
是后端时,后端需要持有 state
的缓存,具体做法可以在前端处理第三方登录时同步随机生成的 state
,并在后端缓存
优点
不需要防止
XSS
的DOM
型攻击
缺点
需要缓存
state
科普:早期 token
其实就是这里的 state
获取 token
第三方登录从本质上来讲就是获取到 token
,在安全的拿到 code
和 state
之后,需要向 github
发送获取 token
请求,其文档如下
POST https://github.com/login/oauth/access_token
参数
名称 | 类型 | 说明 |
---|---|---|
client_id | string | 必填。 从 GitHub 收到的 OAuth App 的客户端 ID。 |
client_secret | string | 必填。 从 GitHub 收到的 OAuth App 的客户端密码。 |
code | string | 必填。 收到的作为对步骤 1 的响应的代码。 |
redirect_uri | string | 用户获得授权后被发送到的应用程序中的 URL。 |
响应
名称 | 类型 | 说明 |
---|---|---|
access_token | string | github 的 token |
scope | string | 参考文档 |
token_type | string | token 类型 |
注意因为 client_secret
属于私钥,所以该请求必须放在后端,不能在前端请求!否则会失去登录的意义
const { default: axios } = require("axios");
const express = require("express");
const router = express.Router();
router.post("/redirect", function (req, res, next) {
const { code } = req.body;
axios({
method: "POST",
url: "https://github.com/login/oauth/access_token",
headers: {
"Accept": "application/json",
},
timeout: 60 * 1000,
data: {
client_id: "your_client_id",
client_secret: "your_client_secret",
code,
},
})
.then((response) => {
res.send(response.data);
})
.catch((e) => {
res.status(404);
});
});
module.exports = router;
注意,由于 github
的服务器在国外,所以这个请求非常容易超时或者失效,建议做好对应的处理(或者设置一个比较长的时间)
最后拿到对应的 token
总结
如果还没有了解过第三方登录的同学可以试试,毕竟不需要审核,有对应的 github
账号就行,截至写完文章的现在,我仍然没有通过微博第三方登录的审核/(ㄒoㄒ)/~~
参考资料
作者:2分钟速写快排
来源:juejin.cn/post/7181114761394782269
electron-egg 当代桌面开发框架,轻松入门electron
当前技术社区中出现了各种下一代技术或框架,却很少有当代可以用的,于是electron-egg就出现了。
它愿景很大:希望所有开发者都能学会桌面软件开发
当前桌面软件技术有哪些?
语言 | 技术 | 优点 | 缺点 |
---|---|---|---|
C# | wpf | 专业的桌面软件技术,功能强大 | 学习成本高 |
Java | swing/javaFx | 跨平台和语言流行 | GUI库少,界面不美观 |
C++ | Qt | 跨平台,功能和类库丰富 | 学习成本高 |
Swift | 无 | 非跨平台,文档不友好,UI库少 | |
JS | electron | 跨平台,入门简单,UI强大,扩展性强 | 内存开销大,包体大。 |
为什么使用electron?
某某说:我们的应用要兼容多个平台,原生开发效率低,各平台研发人员不足,我们没有资源。
也许你觉得只是中小公司没有资源,no!大公司更没有资源。因为软件体量越大,所需研发人员越多。再加上需要多平台支持的话,研发人员更是指数级增长的。
我们来看看QQ团队负责人最近的回应吧:
“感谢大家对新版桌面QQ NT的使用和关注,今年QQ团队启动了QQ的架构升级计划,第一站就是解决目前桌面端迭代慢的问题,我们使用新架构从前到后对QQ代码进行了重构,而其中选择使用Electron作为新版QQ桌面端UI跨平台解决方案,是基于提升研发效率、框架成熟度、团队技术及人才积累等几个方面综合考虑的结果。”
也许electron的缺点很明显,但它的投入产出比却是最高的。
所以,对企业而言,效率永远是第一位的。不要用程序员的思维去思考产品。
哪些企业或软件在使用electron?
国内:抖音客户端、百度翻译、阿里云盘、B站客户端、迅雷、网易有道云、QQ(doing) 等
国外:vscode、Slack、Atom、Discord、Skype、WhatsApp、等
你的软件用户体量应该没有上面这些公司多吧?所以你还有什么可担心的呢?
开发者 / 决策者不要去关心性能、包体大小这些东西,当你的产品用户少时,它没意义;当你的产品用户多时,找nb的人把它优化。
聊聊electron-egg框架
EE是一个业务框架;就好比 Spring之于java,thinkphp之于php,nuxt.js之于vue;electron只提供了基础的函数和api,但你写项目的时候,业务和代码工程化是需要自己实现的,ee就提供了这个工程化能力。
特性
🍄 跨平台:一套代码,可以打包成windows版、Mac版、Linux版、国产UOS、Deepin、麒麟等
🌹 简单高效:只需学习 js 语言
🌱 前端独立:理论上支持任何前端技术,如:vue、react、html等等
🌴 工程化:可以用前端、服务端的开发思维,来编写桌面软件
🍁 高性能:事件驱动、非阻塞式IO
🌷 功能丰富:配置、通信、插件、数据库、升级、打包、工具... 应有尽有
🌰 安全:支持字节码加密、压缩混淆加密
💐 功能demo:桌面软件常见功能,框架集成或提供demo
谁可以使用electron-egg?
前端、服务端、运维、游戏等技术人员皆可使用。我相信在你的工作生涯中,或多或少都接触过js,恭喜你,可以入门了。
为什么各种技术栈的开发者都能使用electron-egg?
这与它的架构有关。
第一:前端独立
你可以用vue、react、angular等开发框架;也可用antdesign、layui、bootstrap等组件库;或者你用cococreater开发游戏也行; 框架只需要最终构建的资源(html/css/js)。
第二:工程化-MVC编程模式
如果你是java、php、python等后端开发者,不懂js那一套编程模式怎么办?
没关系,框架已经为你提供了MVC(controller/service/model/view),是不是很熟悉?官方提供了大量业务场景demo,直接开始撸代码吧。
开箱即用
编程方法、插件、通信、日志、数据库、调试、脚本工具、打包工具等开发需要的东西,框架都已经提供好了,你只需要专注于业务的实现。
十分钟体验
安装
# 下载
git clone https://gitee.com/dromara/electron-egg.git
# 安装依赖
npm install
# 启动
npm run start
效果
界面中的功能是demo,方便初学者入门。
项目案例
EE框架已经应用于医疗、学校、政务、股票交易、ERP、娱乐、视频、企业等领域客户端
以下是部分开发者使用electron-egg开发的客户端软件,请看效果
后语
仓库地址,欢迎给项目点赞!
gitee : gitee.com/dromara/ele… 2300+
github : github.com/dromara/ele… 500+
关于 Dromara
Dromara 是由国内顶尖的开源项目作者共同组成的开源社区。提供包括分布式事务,流行工具,企业级认证,微服务RPC,运维监控,Agent监控,分布式日志,调度编排等一系列开源产品、解决方案与咨询、技术支持与培训认证服务。技术栈全面开源共建、 保持社区中立,致力于为全球用户提供微服务云原生解决方案。让参与的每一位开源爱好者,体会到开源的快乐。
Dromara开源社区目前拥有10+GVP项目,总star数量超过十万,构建了上万人的开源社区,有成千上万的个人及团队在使用Dromara社区的开源项目。
electron-egg已加入dromara组织。
作者:哆啦好梦
来源:juejin.cn/post/7181279242628366397
纯 JS 简单实现类似 404 可跳跃障碍物页面
废话开篇:一些 404 页面为了体现趣味性会添加一些简单的交互效果。 这里用纯 JS 简单实现类似 404 可跳跃障碍物页面,内容全部用 canvas 画布实现。
一、效果展示
二、画面拆解
1、绘制地平线
地平线这里就是简单的一条贯穿屏幕的线。
2、绘制红色精灵
绘制红色精灵分为两部分:
(1)上面圆
(2)下面定点与上面圆的切线。
绘制结果:
进行颜色填充,再绘制中小的小圆,绘制结果:
(3)绘制障碍物
这里绘制的是一个黑色的长方形。最后的实现效果:
三、逻辑拆解
1、全局定时器控制画布重绘
创建全局的定时器。
它有两个具体任务:
(1)全局定时刷新重置,将画布定时擦除之前的绘制结果。
(2)全局定时器刷新动画重绘新内容。
2、精灵跳跃动作
在接收到键盘 “空格” 点击的情况下,让精灵起跳一定高度,到达顶峰的时候进行回落,当然这里设计的是匀速。
3、障碍物移动动作
通过定时器,重绘障碍物从最右侧移动到最左侧。
4、检测碰撞
在障碍物移动到精灵位置时,进行碰撞检测,判断障碍物最上端的左、右顶点是否在精灵的内部。
5、绘制提示语
提示语也是用 canvas 绘制的,当障碍物已移动到左侧的时候进行,结果判断。如果跳跃过程中无碰撞,就显示 “完美跳跃~”,如果调跃过程中有碰撞,就显示 “再接再厉”。
四、代码讲解
1、HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<script src="./wsl404.js"></script>
</head>
<body>
<div id="content">
<canvas id="myCanvas">
</canvas>
</div>
</body>
<script>
elves.init();
</script>
</html>
2、JS
(function WSLNotFoundPage(window) {
var elves = {};//精灵对象
elves.ctx = null;//画布
elves.width = 0;//屏幕的宽度
elves.height = 0;//屏幕的高度
elves.point = null;//精灵圆中心
elves.elvesR = 20;//精灵圆半径
elves.runloopTargets = [];//任务序列(暂时只保存跳跃)
elves.upDistance = 50;//当前中心位置距离地面高度
elves.upDistanceInitNum = 50;//中心位置距离地面高度初始值
elves.isJumping = false;//是否跳起
elves.jumpTarget = null;//跳跃任务
elves.jumpTop = false;//是否跳到最高点
elves.maxCheckCollisionWith = 0;//碰撞检测的最大宽度尺寸
elves.obstaclesMovedDistance = 0;//障碍物移动的距离
elves.isCollisioned = false;//是否碰撞过
elves.congratulationFont = 13;//庆祝文字大小
elves.congratulationPosition = 40;//庆祝文字位移
elves.isShowCongratulation = false;//是否展示庆祝文字
elves.congratulationContent = "完美一跃~";
elves.congratulationColor = "red";
//初始化
elves.init = function(){
this.drawFullScreen("content");
this.drawElves(this.upDistance);
this.keyBoard();
this.runloop();
}
//键盘点击事件
elves.keyBoard = function(){
var that = this;
document.onkeydown = function whichButton(event)
{
if(event.keyCode == 32){
//空格
that.elvesJump();
}
}
}
//开始跑圈
elves.runloop = function(){
var that = this;
setInterval(function(){
//清除画布
that.cleareAll();
//绘制障碍物
that.creatObstacles();
if(that.isJumping == false){
//未跳起时重绘精灵
that.drawElves(that.upDistanceInitNum);
}
//绘制地面
that.drawGround();
//跳起任务
for(index in that.runloopTargets){
let target = that.runloopTargets[index];
if(target.isRun != null && target.isRun == true){
if(target.runCallBack){
target.runCallBack();
}
}
}
//碰撞检测
that.checkCollision();
//展示庆祝文字
if(that.isShowCongratulation == true){
that.congratulation();
}
},10);
}
//画布
elves.drawFullScreen = function (id){
var element = document.getElementById(id);
this.height = window.screen.height - 200;
this.width = window.screen.width;
element.style.width = this.width + "px";
element.style.height = this.height + "px";
element.style.background = "white";
this.getCanvas("myCanvas",this.width,this.height);
}
elves.getCanvas = function(id,width,height){
var c = document.getElementById(id);
this.ctx = c.getContext("2d");
//锯齿修复
if (window.devicePixelRatio) {
c.style.width = this.width + "px";
c.style.height = this.height + "px";
c.height = height * window.devicePixelRatio;
c.width = width * window.devicePixelRatio;
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
};
//绘制地面
elves.drawGround = function() {
// 设置线条的颜色
this.ctx.strokeStyle = 'gray';
// 设置线条的宽度
this.ctx.lineWidth = 1;
// 绘制直线
this.ctx.beginPath();
// 起点
this.ctx.moveTo(0, this.height / 2.0 + 1);
// 终点
this.ctx.lineTo(this.width,this.height / 2.0);
this.ctx.closePath();
this.ctx.stroke();
}
//绘制精灵
elves.drawElves = function(upDistance){
//绘制圆
var angle = Math.acos(this.elvesR / upDistance);
this.point = {x:this.width / 3,y : this.height / 2.0 - upDistance};
this.ctx.fillStyle = "#FF0000";
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.ctx.arc(this.point.x,this.point.y,this.elvesR,Math.PI / 2 + angle,Math.PI / 2 - angle,false);
//绘制切线
var bottomPoint = {x:this.width / 3,y : this.point.y + this.upDistanceInitNum};
let leftPointY = this.height / 2.0 - (upDistance - Math.cos(angle) * this.elvesR);
let leftPointX = this.point.x - (Math.sin(angle) * this.elvesR);
var leftPoint = {x:leftPointX,y:leftPointY};
let rightPointY = this.height / 2.0 - (upDistance - Math.cos(angle) * this.elvesR);
let rightPointX = this.point.x + (Math.sin(angle) * this.elvesR);
var rightPoint = {x:rightPointX,y:rightPointY};
this.maxCheckCollisionWith = (rightPointX - leftPointX) * 20 / (upDistance - Math.cos(angle) * this.elvesR);
this.ctx.moveTo(bottomPoint.x, bottomPoint.y);
this.ctx.lineTo(leftPoint.x,leftPoint.y);
this.ctx.lineTo(rightPoint.x,rightPoint.y);
this.ctx.closePath();
this.ctx.fill();
//绘制小圆
this.ctx.fillStyle = "#FFF";
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.ctx.arc(this.point.x,this.point.y,this.elvesR / 3,0,Math.PI * 2,false);
this.ctx.closePath();
this.ctx.fill();
}
//清除画布
elves.cleareAll = function(){
this.ctx.clearRect(0,0,this.width,this.height);
}
//精灵跳动
elves.elvesJump = function(){
if(this.isJumping == true){
return;
}
this.isJumping = true;
if(this.jumpTarget == null){
var that = this;
this.jumpTarget = {type:'jump',isRun:true,runCallBack:function(){
let maxDistance = that.upDistanceInitNum + 55;
if(that.jumpTop == false){
if(that.upDistance > maxDistance){
that.jumpTop = true;
}
that.upDistance += 1;
} else if(that.jumpTop == true) {
that.upDistance -= 1;
if(that.upDistance < 50) {
that.upDistance = 50;
that.jumpTop = false;
that.jumpTarget.isRun = false;
that.isJumping = false;
}
}
that.drawElves(that.upDistance);
}};
this.runloopTargets.push(this.jumpTarget);
} else {
this.jumpTarget.isRun = true;
}
}
//绘制障碍物
elves.creatObstacles = function(){
let obstacles = {width:20,height:20};
if(this.obstaclesMovedDistance != 0){
this.ctx.clearRect(this.width - obstacles.width - this.obstaclesMovedDistance + 0.5, this.height / 2.0 - obstacles.height,obstacles.width,obstacles.height);
}
this.obstaclesMovedDistance += 0.5;
if(this.obstaclesMovedDistance >= this.width + obstacles.width) {
this.obstaclesMovedDistance = 0;
//重置是否碰撞
this.isCollisioned = false;
}
this.ctx.beginPath();
this.ctx.fillStyle = "#000";
this.ctx.moveTo(this.width - obstacles.width - this.obstaclesMovedDistance, this.height / 2.0 - obstacles.height);
this.ctx.lineTo(this.width - this.obstaclesMovedDistance,this.height / 2.0 - obstacles.height);
this.ctx.lineTo(this.width - this.obstaclesMovedDistance,this.height / 2.0);
this.ctx.lineTo(this.width - obstacles.width - this.obstaclesMovedDistance, this.height / 2.0);
this.ctx.closePath();
this.ctx.fill();
}
//检测是否碰撞
elves.checkCollision = function(){
var obstaclesMarginLeft = this.width - this.obstaclesMovedDistance - 20;
var elvesUpDistance = this.upDistanceInitNum - this.upDistance + 20;
if(obstaclesMarginLeft > this.point.x - this.elvesR && obstaclesMarginLeft < this.point.x + this.elvesR && elvesUpDistance <= 20) {
//需要检测的最大范围
let currentCheckCollisionWith = this.maxCheckCollisionWith * elvesUpDistance / 20;
if((obstaclesMarginLeft < this.point.x + currentCheckCollisionWith / 2.0 && obstaclesMarginLeft > this.point.x - currentCheckCollisionWith / 2.0) || (obstaclesMarginLeft + 20 < this.point.x + currentCheckCollisionWith / 2.0 && obstaclesMarginLeft + 20 > this.point.x - currentCheckCollisionWith / 2.0)){
this.isCollisioned = true;
}
}
//记录障碍物移动到精灵左侧
if(obstaclesMarginLeft + 20 < this.point.x - this.elvesR && obstaclesMarginLeft + 20 > this.point.x - this.elvesR - 1){
if(this.isCollisioned == false){
//跳跃成功,防止检测距离内重复得分置为true,在下一次循环前再置为false
this.isCollisioned = true;
//庆祝
if(this.isShowCongratulation == false) {
this.congratulationContent = "完美一跃~";
this.congratulationColor = "red";
this.isShowCongratulation = true;
}
} else {
//鼓励
if(this.isShowCongratulation == false) {
this.isShowCongratulation = true;
this.congratulationColor = "gray";
this.congratulationContent = "再接再厉~";
}
}
}
}
//庆祝绘制文字
elves.congratulation = function(){
this.congratulationFont += 0.1;
this.congratulationPosition += 0.1;
if(this.congratulationFont >= 30){
//重置
this.congratulationFont = 13;
this.congratulationPosition = 30;
this.isShowCongratulation = false;
return;
}
this.ctx.fillStyle = this.congratulationColor;
this.ctx.font = this.congratulationFont + 'px "微软雅黑"';
this.ctx.textBaseline = "bottom";
this.ctx.textAlign = "center";
this.ctx.fillText( this.congratulationContent, this.point.x, this.height / 2.0 - this.upDistanceInitNum - this.congratulationPosition);
}
window.elves = elves;
})(window)
五、总结与思考
逻辑注释基本都写在代码里,里面的一些计算可能会绕一些。
作者:头疼脑胀的代码搬运工
来源:juejin.cn/post/7056610619490828325
关于自建组件库的思考
很多公司都会有自己的组件库,但是在使用起来都不尽如人意,这里分享下我自己的一些观点和看法
问题思考
在规划这种整个团队都要用的工具之前要多思考,走一步想一步的方式是不可取的
首先,在开发一个组件库之前先要明确以下几点:
目前现状
不自建的话会有哪些问题,为什么不用 antd/element
哪些人提出了哪些的问题
分析为什么会出现这些问题
哪些问题是必须解决的,哪些是阶段推进的
期望目标
组件库的定位是什么
自建组件库是为了满足什么场景
阶段目标是什么
最终期望达到什么效果
具体实现
哪些问题用哪些方法来解决
关于后续迭代是怎么考虑的
目前现状
仅仅是因为前端开发为了部分代码或者样式不用重复写就封装一个组件甚至组件库是一件很搞笑的事情,最终往往会出现以下问题:
代码分散但是却高耦合,存在很多职责不明确
封装过于死板,且暴露的属性职责不明确
可维护性低,无法应对不断变化的需求
可靠性低,对上游数据不做错误处理,对下游使用者不做兼容处理
最后没法迭代,因为代码质量及版本问题,连原始开发者都改不动的,相关使用者怨声载道,然后又重构一遍,还是同样的设计思路,只不过基于已知业务场景改了写法,然后过一段时间又成为一个新的历史包袱。。。
当你为了方便改别人的代码而选择 fork 别人的组件库下来简单改改再输出时,难道你觉得别人不会对“你写的”这个组件库持同样的看法么?
你会发现,如果仅仅以一个业务员的角度去寻求解决办法的话,最后往往不能够得到其他业务员的认可的~
组件库的存在目的是为了提高团队的工作效率,不是单纯为了个别人能少写代码,前者才是目的,后者只是其中一种实现方式(这句话自己悟吧)
期望目标
一个合格的组件库应该要让使用者感受到两点:
约束(为什么只能这样传嘛?)
方便(只要这样传就可以耶~)
不合格的组件库往往只关注后者,但是其实前者更加重要
在能实现甲方的需求前提下,约束的树立会让团队对某一问题形成一个固有的解决方案,这个使用过程会促成惯性的产生
同时,这个惯性一旦建立,就能促成两个结果:
弥合了人与人之间的差异
提高了交流效率(不单单是开发,还包括设计、产品、测试等一条工作链路上的相关人)
要知道的是,团队合作过程中,效率最低的环节永远是沟通,一个好的团队不是全员大神,而是做什么事情以一个整体,每个人步调趋于一致,这样效率才高~
具体实现
编写一个公共库需要考虑很多东西,下面主要分三点来阐述
逻辑的分割
避免一次性、不通用、没必要的封装
不允许出现相互跨级或交叉引用的情况,应形成明确的上下级关系
被抽离的逻辑代码应该尽可能的“独立“,避免变成”谁也离不开谁”
逻辑的封装
对于一个管理平台框架来说,宗旨是让开发少写代码、产品少写文档,不需要每次有新业务都要重复产出
对于开发来说,具体有两点:
大部分情况下,能拷贝下 demo 即可实现各类交互效果
小部分情况下,组件能提供其他更多的可能以满足特殊需求
封装过程中,仅暴露关键属性,提供多种可能,并且以比较常用的值作为“默认值”并明确定义,即可满足“大部分需求只需无脑引用,同时小部分的特殊需求也能被满足”
维护与开发
作为一个上游的 UI 库,要充分考虑下游使用者的情况
做到升级后保证下游大部分情况下不需要改动
组件的新增、删除、修改要有充分的理由(需求或 bug),并且要遵循最小影响原则
组件的设计要充分考虑日后可能发生的变化
未来展望
仅靠一个 UI 框架难以解决问题,对于未来的想法有分成三个阶段:
UI 库,沉淀稳定高效的组件
代码片段生成器,收集业务案例代码
页面生成器,输出有效模版
这里更多面向的是中后台项目的解决方案
总结
组件库输出约束和统一解决办法,前者通过抚平团队中个体的差异来提高团队的沟通效率,后者通过形成工作惯性来提高团队的工作效率
作者:tellyourmad
来源:juejin.cn/post/7063017892714905608
不就是代码缩进吗?别打起来啊
免战申明
本文不讨论两种缩进方式的优劣,只提供一种能够同时兼容大家关于缩进代码风格的解决方案
字符缩进,2还是4?
很久之前,组内开发时发现大家的tab.size不一样,有的伙伴表示都能接受,有的伙伴习惯使用2字符缩进,有的伙伴习惯4字符缩进,导致开发起来很痛苦,一直在寻找兼容大家代码风格的办法,今天终于研究出一种解决方案(不一定适用所有人)。
工具准备
vscode
prettierrc插件
解决方案
首先设置"editor.tabSize"为自己习惯的tabSize
设置tab按下时不插入空格"editor.insertSpaces": false
项目根目录下创建.prettierrc(可添加到.gitignore),设置"useTabs": true
{
"printWidth": 180,
"semi": true,
"singleQuote": true,
//使用tab进行格式化
"useTabs": true
}
设置展示效果"editor.renderWhitespace": "selection"
最终效果
可以看到,编辑器内设置不同的代码缩进,展示效果不同,但最终提交的代码风格一致。 (小缺陷:对于强制要求使用空格代替tab的情况不适用)
作者:断律绎殇
来源:juejin.cn/post/7095001798120833061
面试官:你如何实现大文件上传
提到大文件上传,在脑海里最先想到的应该就是将图片保存在自己的服务器(如七牛云服务器),保存在数据库,不仅可以当做地址使用,还可以当做资源使用;或者将图片转换成base64,转换成buffer流,但是在javascript这门语言中不存在,但是这些只适用于一些小图片,对于大文件还是束手无策。
一、问题分析
如果将大文件一次性上传,会发生什么?想必都遇到过在一个大文件上传、转发等操作时,由于要上传大量的数据,导致整个上传过程耗时漫长,更有甚者,上传失败,让你重新上传!这个时候,我已经咬牙切齿了。先不说上传时间长久,毕竟上传大文件也没那么容易,要传输更多的报文,丢包也是常有的事,而且在这个时间段万不可以做什么其他会中断上传的操作;其次,前后端交互肯定是有时间限制的,肯定不允许无限制时间上传,大文件又更容易超时而失败....
一、解决方案
既然大文件上传不适合一次性上传,那么将文件分片散上传是不是就能减少性能消耗了。
没错,就是分片上传。分片上传就是将大文件分成一个个小文件(切片),将切片进行上传,等到后端接收到所有切片,再将切片合并成大文件。通过将大文件拆分成多个小文件进行上传,确实就是解决了大文件上传的问题。因为请求时可以并发执行的,这样的话每个请求时间就会缩短,如果某个请求发送失败,也不需要全部重新发送。
二、具体实现
1、前端
(1)读取文件
准备HTML结构,包括:读取本地文件(input
类型为file
)、上传文件按钮、上传进度。
<input type="file" id="input">
<button id="upload">上传</button>
<!-- 上传进度 -->
<div style="width: 300px" id="progress"></div>
JS实现文件读取:
监听input
的change
事件,当选取了本地文件后,打印事件源可得到文件的一些信息:
let input = document.getElementById('input')
let upload = document.getElementById('upload')
let files = {}//创建一个文件对象
let chunkList = []//存放切片的数组
// 读取文件
input.addEventListener('change', (e) => {
files = e.target.files[0]
console.log(files);
//创建切片
//上传切片
})
观察控制台,打印读取的文件信息如下:
(2)创建切片
文件的信息包括文件的名字,文件的大小,文件的类型等信息,接下来可以根据文件的大小来进行切片,例如将文件按照1MB或者2MB等大小进行切片操作:
// 创建切片
function createChunk(file, size = 2 * 1024 * 1024) {//两个形参:file是大文件,size是切片的大小
const chunkList = []
let cur = 0
while (cur < file.size) {
chunkList.push({
file: file.slice(cur, cur + size)//使用slice()进行切片
})
cur += size
}
return chunkList
}
切片的核心思想是:创建一个空的切片列表数组chunkList
,将大文件按照每个切片2MB进行切片操作,这里使用的是数组的Array.prototype.slice()
方法,那么每个切片都应该在2MB大小左右,如上文件的大小是8359021
,那么可得到4个切片,分别是[0,2MB]、[2MB,4MB]、[4MB,6MB]、[6MB,8MB]。调用createChunk函数
,会返回一个切片列表数组,实际上,有几个切片就相当于有几个请求。
调用创建切片函数:
//注意调用位置,不是在全局,而是在读取文件的回调里调用
chunkList = createChunk(files)
console.log(chunkList);
观察控制台打印的结果:
(3)上传切片
上传切片的个关键的操作:
第一、数据处理。需要将切片的数据进行维护成一个包括该文件,文件名,切片名的对象,所以采用FormData
对象来进行整理数据。FormData 对象
用以将数据编译成键值对,可用于发送带键数据,通过调用它的append()
方法来添加字段,FormData.append()方法会将字段类型为数字类型的转换成字符串(字段类型可以是 Blob、File
或者字符串:如果它的字段类型不是 Blob 也不是 File,则会被转换成字符串类。
第二、并发请求。每一个切片都分别作为一个请求,只有当这4个切片都传输给后端了,即四个请求都成功发起,才上传成功,使用Promise.all()
保证所有的切片都已经传输给后端。
//数据处理
async function uploadFile(list) {
const requestList = list.map(({file,fileName,index,chunkName}) => {
const formData = new FormData() // 创建表单类型数据
formData.append('file', file)//该文件
formData.append('fileName', fileName)//文件名
formData.append('chunkName', chunkName)//切片名
return {formData,index}
})
.map(({formData,index}) =>axiosRequest({
method: 'post',
url: 'http://localhost:3000/upload',//请求接口,要与后端一一一对应
data: formData
})
.then(res => {
console.log(res);
//显示每个切片上传进度
let p = document.createElement('p')
p.innerHTML = `${list[index].chunkName}--${res.data.message}`
document.getElementById('progress').appendChild(p)
})
)
await Promise.all(requestList)//保证所有的切片都已经传输完毕
}
//请求函数
function axiosRequest({method = "post",url,data}) {
return new Promise((resolve, reject) => {
const config = {//设置请求头
headers: 'Content-Type:application/x-www-form-urlencoded',
}
//默认是post请求,可更改
axios[method](url,data,config).then((res) => {
resolve(res)
})
})
}
// 文件上传
upload.addEventListener('click', () => {
const uploadList = chunkList.map(({file}, index) => ({
file,
size: file.size,
percent: 0,
chunkName: `${files.name}-${index}`,
fileName: files.name,
index
}))
//发请求,调用函数
uploadFile(uploadList)
})
2、后端
(1)接收切片
主要工作:
第一:需要引入multiparty
中间件,来解析前端传来的FormData
对象数据;
第二:通过path.resolve()
在根目录创建一个文件夹--qiepian
,该文件夹将存放另一个文件夹(存放所有的切片)和合并后的文件;
第三:处理跨域问题。通过setHeader()
方法设置所有的请求头和所有的请求源都允许;
第四:解析数据成功后,拿到文件相关信息,并且在qiepian
文件夹创建一个新的文件夹${fileName}-chunks
,用来存放接收到的所有切片;
第五:通过fse.move(filePath,fileName)
将切片移入${fileName}-chunks
文件夹,最后向前端返回上传成功的信息。
//app.js
const http = require('http')
const multiparty = require('multiparty')// 中间件,处理FormData对象的中间件
const path = require('path')
const fse = require('fs-extra')//文件处理模块
const server = http.createServer()
const UPLOAD_DIR = path.resolve(__dirname, '.', 'qiepian')// 读取根目录,创建一个文件夹qiepian存放切片
server.on('request', async (req, res) => {
// 处理跨域问题,允许所有的请求头和请求源
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
if (req.url === '/upload') { //前端访问的地址正确
const multipart = new multiparty.Form() // 解析FormData对象
multipart.parse(req, async (err, fields, files) => {
if (err) { //解析失败
return
}
console.log('fields=', fields);
console.log('files=', files);
const [file] = files.file
const [fileName] = fields.fileName
const [chunkName] = fields.chunkName
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)//在qiepian文件夹创建一个新的文件夹,存放接收到的所有切片
if (!fse.existsSync(chunkDir)) { //文件夹不存在,新建该文件夹
await fse.mkdirs(chunkDir)
}
// 把切片移动进chunkDir
await fse.move(file.path, `${chunkDir}/${chunkName}`)
res.end(JSON.stringify({ //向前端输出
code: 0,
message: '切片上传成功'
}))
})
}
})
server.listen(3000, () => {
console.log('服务已启动');
})
通过node app.js
启动后端服务,可在控制台打印fields和files
:
(2)合并切片
第一:前端得到后端返回的上传成功信息后,通知后端合并切片:
// 通知后端去做切片合并
function merge(size, fileName) {
axiosRequest({
method: 'post',
url: 'http://localhost:3000/merge',//后端合并请求
data: JSON.stringify({
size,
fileName
}),
})
}
//调用函数,当所有切片上传成功之后,通知后端合并
await Promise.all(requestList)
merge(files.size, files.name)
第二:后端接收到合并的数据,创建新的路由进行合并,合并的关键在于:前端通过POST
请求向后端传递的合并数据是通过JSON.stringify()
将数据转换成字符串,所以后端合并之前,需要进行以下操作:
解析POST请求传递的参数,自定义函数
resolvePost
,目的是将每个切片请求传递的数据进行拼接,拼接后的数据仍然是字符串,然后通过JSON.parse()
将字符串格式的数据转换为JSON对象;接下来该去合并了,拿到上个步骤解析成功后的数据进行解构,通过
path.resolve
获取每个切片所在的路径;自定义合并函数
mergeFileChunk
,只要传入切片路径,切片名字和切片大小,就真的将所有的切片进行合并。在此之前需要将每个切片转换成流stream
对象的形式进行合并,自定义函数pipeStream
,目的是将切片转换成流对象,在这个函数里面创建可读流,读取所有的切片,监听end
事件,所有的切片读取完毕后,销毁其对应的路径,保证每个切片只被读取一次,不重复读取,最后将汇聚所有切片的可读流汇入可写流;最后,切片被读取成流对象,可读流被汇入可写流,那么在指定的位置通过
createWriteStream
创建可写流,同样使用Promise.all()
的方法,保证所有切片都被读取,最后调用合并函数进行合并。
if (req.url === '/merge') { // 该去合并切片了
const data = await resolvePost(req)
const {
fileName,
size
} = data
const filePath = path.resolve(UPLOAD_DIR, fileName)//获取切片路径
await mergeFileChunk(filePath, fileName, size)
res.end(JSON.stringify({
code: 0,
message: '文件合并成功'
}))
}
// 合并
async function mergeFileChunk(filePath, fileName, size) {
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
let chunkPaths = await fse.readdir(chunkDir)
chunkPaths.sort((a, b) => a.split('-')[1] - b.split('-')[1])
const arr = chunkPaths.map((chunkPath, index) => {
return pipeStream(
path.resolve(chunkDir, chunkPath),
// 在指定的位置创建可写流
fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
})
)
})
await Promise.all(arr)//保证所有的切片都被读取
}
// 将切片转换成流进行合并
function pipeStream(path, writeStream) {
return new Promise(resolve => {
// 创建可读流,读取所有切片
const readStream = fse.createReadStream(path)
readStream.on('end', () => {
fse.unlinkSync(path)// 读取完毕后,删除已经读取过的切片路径
resolve()
})
readStream.pipe(writeStream)//将可读流流入可写流
})
}
// 解析POST请求传递的参数
function resolvePost(req) {
// 解析参数
return new Promise(resolve => {
let chunk = ''
req.on('data', data => { //req接收到了前端的数据
chunk += data //将接收到的所有参数进行拼接
})
req.on('end', () => {
resolve(JSON.parse(chunk))//将字符串转为JSON对象
})
})
}
还未合并前,文件夹如下图所示:
合并后,文件夹新增了合并后的文件:
作者:来碗盐焗星球
来源:juejin.cn/post/7177045936298786872
我最喜欢高效学习前端的两种方式
先说结论:
看经典书
看官方文档
为什么是经典书籍
我买过很多本计算机、前端、JavaScript方面的书,在这方面也踩过一些坑,分享下我的经验。
在最早我也买过亚马逊的kindle,kindle的使用体验还不错,能达到和纸书差不多的阅读体验,而且很便携,但由于上面想看的书很多都没有,又懒得去折腾,所以后来就卖了。
之后就转到了纸书上,京东经常搞100减50的活动,买了很多的书,这些书有的只翻了几页,有的翻来覆去看了几遍。
翻了几页的也有两种情况,一种是内容质量太差,完全照抄文档,而且还是过时的文档,你们都知道,前端的技术更新是比较快的,框架等更新也很快,所以等书出版了,技术可能就已经翻篇了。
另一种就是过于专业的,比较复杂难懂,比如编译原理,深入了解计算机系统 算法(第4版)等这种计算机传世经典之作。
纸书其实也有缺点,它真的是太沉了,如果要是出差的话想要带几本书的话,都得去考虑考虑,带多了是真的重。
还有就是在搬家的时候,真的就是噩梦,我家里有将近100本的纸书,每次搬家真的就是累死了。
所以最近一两年我都很少买纸书了,如果有需要我都会尽量选择电子版。
电子版书走到哪里都能看,不会有纸书那么多限制,唯一缺点可能就是没有纸书那股味道。
还有就是电子书平台的问题,一个平台的书可能不全,比如我就有微信图书和京东这两个,这也和听歌看剧一样,想看个东西,还得去多个平台,如果要是能够统一的话就好了。
还有就是盗版的pdf,这个我也看过,有一些已经买不到的书,没办法只能去网上寻找资源了。建议大家如果能支持正版,还是支持正版,如果作者赚不到钱,慢慢就没有人愿意创作优质内容,久而久之形成了恶性循环。
看经典书学习前端,是非常好的方式之一,因为书是一整套系统的内容,它不同于网上的碎片化文章。同时好书也是经过成千上万人验证后的,我们只需选择对的就可以了。
我推荐几本我读过的比较好的前端方面的书
javascript高级程序设计
你不知道的javascript 上 中 下卷
狼书 卷1 卷2
关于计算机原理方面的书
编码:隐匿在计算机软硬件背后的语言
算法图解
图解http
大话数据结构
上面的书都是我买过,看过的,可能还有我不知道的,欢迎在评论中留言
这些书都有一些共同的特征,就是能经过时间的检验,不会过时,可以重复的去阅读,学习。
为什么是官方API文档
除了经典书之外,就是各种语言、框架的官方文档,这里一定注意是“官方文档”,因为百度里面搜索的结果里,有很多镜像的文档网站,官方第一时间发布的更新,他们有时并不能及时同步,所以接受信息就比人慢一步。所以一定要看“官方文档”。
比如要查询javascript、css的内容,就去mdn上查看。要去看nodejs就去nodejs的官网,要去看react、vue框架就去官网。尽量别去那些第三方网站。
作者:小帅的编程笔记
来源:juejin.cn/post/7060102025232515086
JS封装覆盖水印
废话开篇:简单实现一个覆盖水印的小功能,水印一般都是添加在图片上,然后直接加载处理过的图片url即可,这里并没有修改图片,而是直接的在待添加水印的 dom 上添加一个 canvas 蒙版。
一、效果
处理之前
DIV
IMG
处理之后
DIV
IMG
这里添加 “水印”(其实并不是真正的水印) 到 DIV 的时候按钮点击事件并不会因为有蒙版遮挡而无法点击
二、JS 代码
class WaterMark{
//水印文字
waterTexts = []
//需要添加水印的dom集合
needAddWaterTextElementIds = null
//保存添加水印的dom
saveNeedAddWaterMarkElement = []
//初始化
constructor(waterTexts,needAddWaterTextElementIds){
if(waterTexts && waterTexts.length != 0){
this.waterTexts = waterTexts
} else {
this.waterTexts = ['水印文字哈哈哈哈','2022-12-08']
}
this.needAddWaterTextElementIds = needAddWaterTextElementIds
}
//开始添加水印
startWaterMark(){
const self = this
if(this.needAddWaterTextElementIds){
this.needAddWaterTextElementIds.forEach((id)=>{
let el = document.getElementById(id)
self.saveNeedAddWaterMarkElement.push(el)
})
} else {
this.saveNeedAddWaterMarkElement = Array.from(document.getElementsByTagName('img'))
}
this.saveNeedAddWaterMarkElement.forEach((el)=>{
self.startWaterMarkToElement(el)
})
}
//添加水印到到dom对象
startWaterMarkToElement(el){
let nodeName = el.nodeName
if(['IMG','img'].indexOf(nodeName) != -1){
//图片,需要加载完成进行操作
this.addWaterMarkToImg(el)
} else {
//普通,直接添加
this.addWaterMarkToNormalEle(el)
}
}
//给图片添加水印
async addWaterMarkToImg(img){
if(!img.complete){
await new Promise((resolve)=>{
img.onload = resolve
})
}
this.addWaterMarkToNormalEle(img)
}
//给普通dom对象添加水印
addWaterMarkToNormalEle(el){
const self = this
let canvas = document.createElement('canvas')
canvas.width = el.width ? el.width : el.clientWidth
canvas.height = el.height ? el.height : el.clientHeight
let ctx = canvas.getContext('2d')
let maxSize = Math.max(canvas.height, canvas.width)
let font = (maxSize / 25)
ctx.font = font + 'px "微软雅黑"'
ctx.fillStyle = "rgba(195,195,195,1)"
ctx.textAlign = "left"
ctx.textBaseline = "top"
ctx.save()
let angle = -Math.PI / 10.0
//进行平移,计算平移的参数
let translateX = (canvas.height) * Math.tan(Math.abs(angle))
let translateY = (canvas.width - translateX) * Math.tan(Math.abs(angle))
ctx.translate(-translateX / 2.0, translateY / 2.0)
ctx.rotate(angle)
//起始坐标
let x = 0
let y = 0
//一组文字之间间隔
let sepY = (font / 2.0)
while(y < canvas.height){
//当前行的y值
let rowCurrentMaxY = 0
while(x < canvas.width){
let totleMaxX = 0
let currentY = 0
//绘制水印
this.waterTexts.forEach((text,index)=>{
currentY += (index * (sepY + font))
let rect = self.drawWater(ctx,text,x,y + currentY)
let currentMaxX = (rect.x + rect.width)
totleMaxX = (currentMaxX > totleMaxX) ? currentMaxX: totleMaxX
rowCurrentMaxY = currentY
})
x = totleMaxX + 20
}
//重置x,y值
x = 0
y += (rowCurrentMaxY + (sepY + font + (canvas.height / 5)))
}
ctx.restore()
//添加canvas
this.addCanvas(canvas,el)
}
//绘制水印
drawWater(ctx,text,x,y){
//绘制文字
ctx.fillText(text,x,y)
//计算尺度
let textRect = ctx.measureText(text)
let width = textRect.width
let height = textRect.height
return {x,y,width,height}
}
//添加canvas到当前标签的父标签上
addCanvas(canvas,el){
//创建div(canvas需要依赖一个div进行位置设置)
let warterMarDiv = document.createElement('div')
//关联水印dom对象
el.warterMark = warterMarDiv
//添加样式
this.resetCanvasPosition(el)
//添加水印
warterMarDiv.appendChild(canvas)
//添加到父标签
el.parentElement.insertBefore(warterMarDiv,el)
}
//重新计算位置
resetCanvasPosition(el){
if(el.warterMark){
//设置父标签的定位
el.parentElement.style.cssText = `position: relative;`
//设施水印载体的定位
el.warterMark.style.cssText = 'position: absolute;top: 0px;left: 0px;pointer-events:none'
}
}
}
用法
<div>
<!-- 待加水印的IMG -->
<img style="width: 100px;height: auto" src="" alt="">
</div>
let waterMark = new WaterMark()
waterMark.startWaterMark();
ctx.save() 与 ctx.restore() 其实在这里的作用不是很大,但还是添加上了,目的是保存添加水印前的上下文,跟结束绘制后恢复水印前的上下文,这样,这些斜体字只在这两行代码之间生效,下面如果再绘制其他,那么,将不受影响。
防止蒙版水印遮挡底层按钮或其他事件,需要添加 pointer-events:none 属性到蒙版标签上。
添加水印的标签外需要添加一个 父标签 ,这个 父标签 的作用就是添加约束 蒙版canvas 的位置,这里想通过 MutationObserver 观察 body 的变化来进行更新 蒙版canvas 的位置,这个尝试失败了,因为复杂的布局只要变动会都在这个回调里触发。因此,直接在添加水印的标签外需要添加一个 父标签 ,用这个 父标签 来自动约束 蒙版canvas 的位置。
MutationObserver 逻辑如下,在监听回调里可以及时修改布局或者其他操作(暂时放弃)。
var MutationObserver = window.MutationObserver || window.webkitMutationObserver || window.MozMutationObserver;
var mutationObserver = new MutationObserver(function (mutations) {
//修改水印位置
})
mutationObserver.observe(document.getElementsByTagName('body')[0], {
childList: true, // 子节点的变动(新增、删除或者更改)
attributes: true, // 属性的变动
characterData: true, // 节点内容或节点文本的变动
subtree: true // 是否将观察器应用于该节点的所有后代节点
})
图片的大小只有在加载完成之后才能确定,所以,对于 IMG 的操作,需要观察它的 complete 事件。
三、总结与思考
用 canvas ctx.drawImage(img, 0, 0) 进行绘制,再将 canvas.toDataURL('image/png') 生成的 url 加载到之前的图片上,也是一种方式,但是,有时候会因为图片的原因导致最后的合成图片的 base64 数据是空,所以,直接增加一个蒙版,本身只是为了显示,并不是要生成真正的合成图片。实现了简单的伪水印,没有特别复杂的代码,代码拙劣,大神勿笑。
作者:头疼脑胀的代码搬运工
来源:juejin.cn/post/7174695149195231293
前端实现电子签名(web、移动端)通用
前言
在现在的时代发展中,从以前的手写签名,逐渐衍生出了电子签名。电子签名和纸质手写签名一样具有法律效应。电子签名目前主要还是在需要个人确认的产品环节和司法类相关的产品上较多。
举个常用的例子,大家都用过钉钉,钉钉上面就有电子签名,相信大家这肯定是知道的。
那作为前端的我们如何实现电子签名呢?其实在html5
中已经出现了一个重要级别的辅助标签,是啥呢?那就是canvas。
什么是canvas
Canvas(画布)
是在HTML5
中新增的标签用于在网页实时生成图像,并且可以操作图像内容,基本上它是一个可以用JavaScript
操作的位图(bitmap)
。Canvas
对象表示一个 HTML
画布元素 -。它没有自己的行为,但是定义了一个 API 支持脚本化客户端绘图操作。
大白话就是canvas
是一个可以在上面通过javaScript
画图的标签,通过其提供的context(上下文)
及Api
进行绘制,在这个过程中canvas
充当画布的角色。
<canvas></canvas>
如何使用
canvas
给我们提供了很多的Api
,供我们使用,我们只需要在body
标签中创建一个canvas
标签,在script
标签中拿到canvas
这个标签的节点,并创建context(上下文)
就可以使用了。
...
<body>
<canvas></canvas>
</body>
<script>
// 获取canvas 实例
const canvas = document.querySelector('canvas')
canvas.getContext('2d')
</script>
...
步入正题。
实现电子签名
知道几何的朋友都很清楚,线有点绘成,面由线绘成。
多点成线,多线成面。
所以我们实际只需要拿到当前触摸的坐标点,进行成线处理就可以了。
在body
中添加canvas
标签
在这里我们不仅需要在在body
中添加canvas
标签,我们还需要添加两个按钮,分别是取消
和保存
(后面我们会用到)。
<body>
<canvas></canvas>
<div>
<button>取消</button>
<button>保存</button>
</div>
</body>
添加文件
我这里全程使用js
进行样式设置及添加。
// 配置内容
const config = {
width: 400, // 宽度
height: 200, // 高度
lineWidth: 5, // 线宽
strokeStyle: 'red', // 线条颜色
lineCap: 'round', // 设置线条两端圆角
lineJoin: 'round', // 线条交汇处圆角
}
获取canvas
实例
这里我们使用querySelector
获取canvas
的dom实例,并设置样式和创建上下文。
// 获取canvas 实例
const canvas = document.querySelector('canvas')
// 设置宽高
canvas.width = config.width
canvas.height = config.height
// 设置一个边框,方便我们查看及使用
canvas.style.border = '1px solid #000'
// 创建上下文
const ctx = canvas.getContext('2d')
基础设置
我们将canvas
的填充色为透明,并绘制填充一个矩形,作为我们的画布,如果不设置这个填充背景色,在我们初识渲染的时候是一个黑色背景,这也是它的一个默认色。
// 设置填充背景色
ctx.fillStyle = 'transparent'
// 绘制填充矩形
ctx.fillRect(
0, // x 轴起始绘制位置
0, // y 轴起始绘制位置
config.width, // 宽度
config.height // 高度
);
上次绘制路径保存
这里我们需要声明一个对象,用来记录我们上一次绘制的路径结束坐标点及偏移量。
保存上次坐标点这个我不用说大家都懂;
为啥需要保存偏移量呢,因为鼠标和画布上的距离是存在一定的偏移距离,在我们绘制的过程中需要减去这个偏移量,才是我们实际的绘制坐标。
但我发现
chrome
中不需要减去这个偏移量,拿到的就是实际的坐标,之前在微信小程序中使用就需要减去偏移量,需要在小程序中使用的朋友需要注意这一点哦。
// 保存上次绘制的 坐标及偏移量
const client = {
offsetX: 0, // 偏移量
offsetY: 0,
endX: 0, // 坐标
endY: 0
}
设备兼容
我们需要它不仅可以在web
端使用,还需要在移动端
使用,我们需要给它做设备兼容处理。我们通过调用navigator.userAgent
获取当前设备信息,进行正则匹配判断。
// 判断是否为移动端
const mobileStatus = (/Mobile|Android|iPhone/i.test(navigator.userAgent))
初始化
这里我们在监听鼠标按下(mousedown)
(web端)/触摸开始(touchstart)
的时候进行初始化,事件监听采用addEventListener
。
// 创建鼠标/手势按下监听器
window.addEventListener(mobileStatus ? "touchstart" : "mousedown", init)
三元判断说明: 这里当
mobileStatus
为true
时则表示为移动端
,反之则为web端
,后续使用到的三元
依旧是这个意思。
声明初始化方法
我们添加一个init
方法作为监听鼠标按下
/触摸开始
的回调方法。
这里我们需要获取到当前鼠标按下
/触摸开始
的偏移量和坐标,进行起始点绘制。
Tips:
web端
可以直接通过event
中取到,而移动端则需要在event.changedTouches[0]
中取到。
这里我们在初始化后再监听鼠标的移动。
// 初始化
const init = event => {
// 获取偏移量及坐标
const { offsetX, offsetY, pageX, pageY } = mobileStatus ? event.changedTouches[0] : event
// 修改上次的偏移量及坐标
client.offsetX = offsetX
client.offsetY = offsetY
client.endX = pageX
client.endY = pageY
// 清除以上一次 beginPath 之后的所有路径,进行绘制
ctx.beginPath()
// 根据配置文件设置进行相应配置
ctx.lineWidth = config.lineWidth
ctx.strokeStyle = config.strokeStyle
ctx.lineCap = config.lineCap
ctx.lineJoin = config.lineJoin
// 设置画线起始点位
ctx.moveTo(client.endX, client.endY)
// 监听 鼠标移动或手势移动
window.addEventListener(mobileStatus ? "touchmove" : "mousemove", draw)
}
绘制
这里我们添加绘制draw
方法,作为监听鼠标移动
/触摸移动
的回调方法。
// 绘制
const draw = event => {
// 获取当前坐标点位
const { pageX, pageY } = mobileStatus ? event.changedTouches[0] : event
// 修改最后一次绘制的坐标点
client.endX = pageX
client.endY = pageY
// 根据坐标点位移动添加线条
ctx.lineTo(pageX , pageY )
// 绘制
ctx.stroke()
}
结束绘制
添加了监听鼠标移动
/触摸移动
我们一定要记得取消监听并结束绘制,不然的话它会一直监听并绘制的。
这里我们创建一个cloaseDraw
方法作为鼠标弹起
/结束触摸
的回调方法来结束绘制并移除鼠标移动
/触摸移动
的监听。
canvas
结束绘制则需要调用closePath()
让其结束绘制
// 结束绘制
const cloaseDraw = () => {
// 结束绘制
ctx.closePath()
// 移除鼠标移动或手势移动监听器
window.removeEventListener("mousemove", draw)
}
添加结束回调监听器
// 创建鼠标/手势 弹起/离开 监听器
window.addEventListener(mobileStatus ? "touchend" :"mouseup", cloaseDraw)
ok,现在我们的电子签名功能还差一丢丢可以实现完了,现在已经可以正常的签名了。
我们来看一下效果:
取消功能/清空画布
我们在刚开始创建的那两个按钮开始排上用场了。
这里我们创建一个cancel
的方法作为取消并清空画布使用
// 取消-清空画布
const cancel = () => {
// 清空当前画布上的所有绘制内容
ctx.clearRect(0, 0, config.width, config.height)
}
然后我们将这个方法和取消按钮
进行绑定
<button onclick="cancel()">取消</button>
保存功能
这里我们创建一个save
的方法作为保存画布上的内容使用。
将画布上的内容保存为图片/文件
的方法有很多,比较常见的是blob
和toDataURL
这两种方案,但toDataURL
这哥们没blob
强,适配也不咋滴。所以我们这里采用a
标签 ➕ blob
方案实现图片的保存下载。
// 保存-将画布内容保存为图片
const save = () => {
// 将canvas上的内容转成blob流
canvas.toBlob(blob => {
// 获取当前时间并转成字符串,用来当做文件名
const date = Date.now().toString()
// 创建一个 a 标签
const a = document.createElement('a')
// 设置 a 标签的下载文件名
a.download = `${date}.png`
// 设置 a 标签的跳转路径为 文件流地址
a.href = URL.createObjectURL(blob)
// 手动触发 a 标签的点击事件
a.click()
// 移除 a 标签
a.remove()
})
}
然后我们将这个方法和保存按钮
进行绑定
<button onclick="save()">保存</button>
我们将刚刚绘制的内容进行保存,点击保存按钮,就会进行下载保存
完整代码
<!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>Document</title>
<style>
* {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<canvas></canvas>
<div>
<button onclick="cancel()">取消</button>
<button onclick="save()">保存</button>
</div>
</body>
<script>
// 配置内容
const config = {
width: 400, // 宽度
height: 200, // 高度
lineWidth: 5, // 线宽
strokeStyle: 'red', // 线条颜色
lineCap: 'round', // 设置线条两端圆角
lineJoin: 'round', // 线条交汇处圆角
}
// 获取canvas 实例
const canvas = document.querySelector('canvas')
// 设置宽高
canvas.width = config.width
canvas.height = config.height
// 设置一个边框
canvas.style.border = '1px solid #000'
// 创建上下文
const ctx = canvas.getContext('2d')
// 设置填充背景色
ctx.fillStyle = 'transparent'
// 绘制填充矩形
ctx.fillRect(
0, // x 轴起始绘制位置
0, // y 轴起始绘制位置
config.width, // 宽度
config.height // 高度
);
// 保存上次绘制的 坐标及偏移量
const client = {
offsetX: 0, // 偏移量
offsetY: 0,
endX: 0, // 坐标
endY: 0
}
// 判断是否为移动端
const mobileStatus = (/Mobile|Android|iPhone/i.test(navigator.userAgent))
// 初始化
const init = event => {
// 获取偏移量及坐标
const { offsetX, offsetY, pageX, pageY } = mobileStatus ? event.changedTouches[0] : event
// 修改上次的偏移量及坐标
client.offsetX = offsetX
client.offsetY = offsetY
client.endX = pageX
client.endY = pageY
// 清除以上一次 beginPath 之后的所有路径,进行绘制
ctx.beginPath()
// 根据配置文件设置相应配置
ctx.lineWidth = config.lineWidth
ctx.strokeStyle = config.strokeStyle
ctx.lineCap = config.lineCap
ctx.lineJoin = config.lineJoin
// 设置画线起始点位
ctx.moveTo(client.endX, client.endY)
// 监听 鼠标移动或手势移动
window.addEventListener(mobileStatus ? "touchmove" : "mousemove", draw)
}
// 绘制
const draw = event => {
// 获取当前坐标点位
const { pageX, pageY } = mobileStatus ? event.changedTouches[0] : event
// 修改最后一次绘制的坐标点
client.endX = pageX
client.endY = pageY
// 根据坐标点位移动添加线条
ctx.lineTo(pageX , pageY )
// 绘制
ctx.stroke()
}
// 结束绘制
const cloaseDraw = () => {
// 结束绘制
ctx.closePath()
// 移除鼠标移动或手势移动监听器
window.removeEventListener("mousemove", draw)
}
// 创建鼠标/手势按下监听器
window.addEventListener(mobileStatus ? "touchstart" : "mousedown", init)
// 创建鼠标/手势 弹起/离开 监听器
window.addEventListener(mobileStatus ? "touchend" :"mouseup", cloaseDraw)
// 取消-清空画布
const cancel = () => {
// 清空当前画布上的所有绘制内容
ctx.clearRect(0, 0, config.width, config.height)
}
// 保存-将画布内容保存为图片
const save = () => {
// 将canvas上的内容转成blob流
canvas.toBlob(blob => {
// 获取当前时间并转成字符串,用来当做文件名
const date = Date.now().toString()
// 创建一个 a 标签
const a = document.createElement('a')
// 设置 a 标签的下载文件名
a.download = `${date}.png`
// 设置 a 标签的跳转路径为 文件流地址
a.href = URL.createObjectURL(blob)
// 手动触发 a 标签的点击事件
a.click()
// 移除 a 标签
a.remove()
})
}
</script>
</html>
各内核和浏览器支持情况
Mozilla 程序从 Gecko 1.8 (Firefox 1.5 (en-US)) 开始支持 <canvas>
。它首先是由 Apple 引入的,用于 OS X Dashboard 和 Safari。Internet Explorer 从 IE9 开始支持<canvas>
,更旧版本的 IE 中,页面可以通过引入 Google 的 Explorer Canvas 项目中的脚本来获得<canvas>
支持。Google Chrome 和 Opera 9+ 也支持 <canvas>
。
小程序中提示
在小程序中我们如果需呀实现的话,也是同样的原理哦,只是我们需要将创建实例和上下文
的Api
进行修改,因为小程序中是没有dom
,既然没有dom
,哪来的操作dom
这个操作呢。
如果是
uni-app
则需要使用uni.createCanvasContext进行上下文创建如果是原生微信小程序则使用
wx.createCanvasContext
进行创建(2.9.0)之后的库不支持
作者:桃小瑞
来源:juejin.cn/post/7174251833773752350
记一次代码评鉴
前言
近期公司组织了一次代码评鉴,在这边记录下学习到的一些规范吧
案例
案例1
参数过多,改为对象好一些
const start = (filename, version, isFirst, branch, biz) => {
// ....
}
案例2
query不应该直接透传
对象解构可能导致覆盖,可以调下顺序
// ...
await axios.post('xxx', {
data: {
host: 'xxx'
...getQuery()
}
})
案例3
超过三个条件的判断抽出为表达式或者函数
魔法数字用变量代替
与和非不一起使用
if (bottom < boxMaxH && topRemain < boxMax || top > 20) {
}
作者:沐晓
来源:juejin.cn/post/7173595497641443364
10年老前端,开发的一款文档编辑器(年终总结)
2022年接近尾声,鸽了近一年,是时候补一下去年的年终总结了。
2021年对我来说是一个意义重大的一年。
这一年,我们团队开发出了一款基于canvas的类word文档编辑器,并因此产品获得了公司最高荣誉——产品创新奖。
当时感慨良多,早该总结一下的,终因自己的懒惰,拖到了现在。
直到这周五晚上,在我想着罗织什么借口推迟,以便于周末能放飞自我的时候,老天终于看不下去了,我被电话告知了核酸同管阳性……
产品介绍
懒惰是可耻的,发自内心的忏悔过后,我还是要稍稍骄傲的介绍下编辑器产品:
整个编辑器都是用canvas底层API绘制的,包括了它的光标,滚动条。
除了弹窗及右键菜单的UI组件外,所有的核心功能都是手搓TS,没有用任何的插件。
包括:核心,排版,光标管理,分页,文本编辑,图片,表格,列表,表单控件,撤销还原,页面设置,页眉页脚等的所有功能,都只源于canvas提供的这几个底层的API接口:
在直角坐标系下,从一点到另一点画一个矩形,或圆,或三角。
测绘字体宽高。
从某一点绘制一个指定样式的字。
接口简单,但是经过层层封装,配合健壮的架构和性能良好的算法,就实现了各种复杂的功能。
看一下几个特色功能:
丰富的排版形式:
复杂的表格拆分:
灵活的列表:
表单控件:
独有的字符对齐:
辅助输入
痕迹对比:
此外,我们开发了c++打印插件,可以灵活的定制各种打印功能。
基础的排版也不演示了,“,。》”等标点不能在行首,一些标点不能在行尾,文字基线等排版基础省略一百八十二个字,
性能也非常不错,三百页数据秒级加载。
提供全个功能的程序接口,借助模版功能,完成各种复杂的操作功能。
心路历程
开发
这么复杂的项目我们开发了多长时间呢?
答案是一年。事实是前年底立项,去年初开始开发,团队基本只有我一人(其实项目初期还有另一个老技术人员,技术也很强,很遗憾开始合作不到两周老技术员就离开这个项目了),一直到7月份团队进了4个强有力的新成员,又经过了半年的紧锣密鼓的开发,不出意外的就意外开发完了。
真实怀念那段忙碌的日子,仿佛一坐下一抬头就要吃午饭了,一坐一抬头又晚上了,晚上还要继续在小区里一圈圈散步考虑各种难点的实现技术方案。真是既充实又酣畅淋漓。
由衷的感谢每一位团队成员的辛苦付出,尽管除了我这个半混半就得老开发,其他还都是1年到4年开发经验的伪新兵蛋子,但是每个人都表现出了惊人的开发效率和潜力。
这让我深刻理解到,任何一个牛掰的项目,都是需要团队齐心协力完成的。现在这个战斗力超强的团队,也是我值得骄傲的底气。
上线,惨遭毒打
事实证明,打江山难,守江山更难,项目开发亦是如此,尤其是在项目刚刚面向用户使用阶段。
当我们还沉浸在获得成功的喜悦中时,因为糟糕的打印速度及打印清晰度问题被用户一顿骑脸输出,打印相关体验之前从未在我们的优化计划之内。而这是用户难以忍受的。
好在持续半个月驻现场加班加点,终于得到了一定的优化。后面我们也是自研c++打印插件,打印问题算是得到彻底解决。
之后仍然有大大小小的问题层出不穷,还好渐渐趋于稳定。
当然现在还是有一些小问题,这是属于这个产品成长的必经之路。
现在,该产品在成千上万用户手中得以稳定运行,偶尔博得称赞,既感到骄傲,又感觉所有辛苦与委屈不值一提。
未来
之前跟领导沟通过开源的问题,领导也有意向开源,佩服领导的远大格局及非凡气度。但现在还不太成熟,仍需从长计议。
随着编辑器功能的完善,一些难以解决的问题也浮出水面,例如对少数民族语言的支持。开源是一个好的方式,可以让大家一同来完善它。
感慨
勇气,是你最走向成功的首要前提。当我主动申请要做这个项目时,身边大部分人给我的忠告是不要做。不尝试一下,怎么知道能不能做好呢。不给自己设限,大胆尝试。
满足来源于专注。
小团队作战更有效率。
产品与技术不分家,既要精进技术,也要有产品思维。技术是产品的工具,产品是技术的目的。如何做出用户体验良好的产品,是高级研发的高级技能。
感悟很多,一时不知道说啥了,有时间单独再细聊聊。
碎碎念
不知道是幸运还是不幸,公司秃然安排研发在线版excel了,无缝衔接了属于是,身为高质量打工人,抖M属性值点满,没有困难创造困难也要上。
同时今年也发生了一件十分悲痛的事,好朋友的身体垮了。身体是革命的本钱。最后就总结三个重点:健康,健康,还是TMD健康。
作者:张三风
来源:juejin.cn/post/7172975010724708389
都2202年了,不会有人还不会发布npm包吧
背景
恰逢最近准备写一个跨框架组件库(工作量很大,前端三个小伙伴利用空闲时间在卷,待组件库完善后会分享给大家,敬请期待),需要学习发布npm包,昨天就想着利用空闲时间把之前写的去除重复请求的axios封装发布为npm包,便于代码复用,回馈社区的同时也能学以致用。
阅读本文,你将收获:
从0开始创建并发布npm的全过程
一个持续迭代且简单实用的axios请求去重工具库
工具库准备
创建一个新项目,包含package.json
{
"name": "drrq",
"type": "module",
"version": "1.0.0"
}
功能实现 /src/index.js
npm i qs axios
主要思路是用请求的url和参数作为key记录请求队列,当出现重复请求时,打断后面的请求,将前面的请求结果返回时共享给后面的请求。
import qs from "qs";
import axios from "axios";
let pending = []; //用于存储每个ajax请求的取消函数和ajax标识
let task = {}; //用于存储每个ajax请求的处理函数,通过请求结果调用,以ajax标识为key
//请求开始前推入pending
const pushPending = (item) => {
pending.push(item);
};
//请求完成后取消该请求,从列表删除
const removePending = (key) => {
for (let p in pending) {
if (pending[p].key === key) {
//当前请求在列表中存在时
pending[p].cancelToken(); //执行取消操作
pending.splice(p, 1); //把这条记录从列表中移除
}
}
};
//请求前判断是否已存在该请求
const existInPending = (key) => {
return pending.some((e) => e.key === key);
};
// 创建task
const createTask = (key, resolve) => {
let callback = (response) => {
resolve(response.data);
};
if (!task[key]) task[key] = [];
task[key].push(callback);
};
// 处理task
const handleTask = (key, response) => {
for (let i = 0; task[key] && i < task[key].length; i++) {
task[key][i](response);
}
task[key] = undefined;
};
const getHeaders = { 'Content-Type': 'application/json' };
const postHeaders = { 'Content-Type': 'application/x-www-form-urlencoded' };
const fileHeaders = { 'Content-Type': 'multipart/form-data' };
const request = (method, url, params, headers, preventRepeat = true, uploadFile = false) => {
let key = url + '?' + qs.stringify(params);
return new Promise((resolve, reject) => {
const instance = axios.create({
baseURL: url,
headers,
timeout: 30 * 1000,
});
instance.interceptors.request.use(
(config) => {
if (preventRepeat) {
config.cancelToken = new axios.CancelToken((cancelToken) => {
// 判断是否存在请求中的当前请求 如果有取消当前请求
if (existInPending(key)) {
cancelToken();
} else {
pushPending({ key, cancelToken });
}
});
}
return config;
},
(err) => {
return Promise.reject(err);
}
);
instance.interceptors.response.use(
(response) => {
if (preventRepeat) {
removePending(key);
}
return response;
},
(error) => {
return Promise.reject(error);
}
);
// 请求执行前加入task
createTask(key, resolve);
instance(Object.assign({}, { method }, method === 'post' || method === 'put' ? { data: !uploadFile ? qs.stringify(params) : params } : { params }))
.then((response) => {
// 处理task
handleTask(key, response);
})
.catch(() => {});
});
};
export const get = (url, data = {}, preventRepeat = true) => {
return request('get', url, data, getHeaders, preventRepeat, false);
};
export const post = (url, data = {}, preventRepeat = true) => {
return request('post', url, data, postHeaders, preventRepeat, false);
};
export const file = (url, data = {}, preventRepeat = true) => {
return request('post', url, data, fileHeaders, preventRepeat, true);
};
export default { request, get, post, file };
新增示例代码文件夹/example
示例入口index.js
import { exampleRequestGet } from './api.js';
const example = async () => {
let res = await exampleRequestGet();
console.log('请求成功 ');
};
example();
api列表api.js
import { request } from './request.js';
// 示例请求Get
export const exampleRequestGet = (data) => request('get', '/xxxx', data);
// 示例请求Post
export const exampleRequestPost = (data) => request('post', '/xxxx', data);
// 示例请求Post 不去重
export const exampleRequestPost2 = (data) => request('post', '/xxxx', data, false);
// 示例请求Post 不去重
export const exampleRequestFile = (data) => request('file', '/xxxx', data, false);
全局请求封装request.js
import drrq from '../src/index.js';
const baseURL = 'https://xxx';
// 处理请求数据 (拼接url,data添加token等) 请根据实际情况调整
const paramsHandler = (url, data) => {
url = baseURL + url;
data.token = 'xxxx';
return { url, data };
};
// 处理全局接口返回的全局处理相关逻辑 请根据实际情况调整
const resHandler = (res) => {
// TODO 未授权跳转登录,状态码异常报错等
return res;
};
export const request = async (method, _url, _data = {}, preventRepeat = true) => {
let { url, data } = paramsHandler(_url, _data);
let res = null;
if (method == 'get' || method == 'GET' || method == 'Get') {
res = await drrq.get(url, data, preventRepeat);
}
if (method == 'post' || method == 'POST' || method == 'Post') {
res = await drrq.post(url, data, preventRepeat);
}
if (method == 'file' || method == 'FILE' || method == 'file') {
res = await drrq.file(url, data, preventRepeat);
}
return resHandler(res);
};
测试功能
代码写完后,我们需要验证功能是否正常,package.json加上
"scripts": {
"test": "node example"
},
执行npm run test
功能正常,工具库准备完毕。
(eslint和prettier读者可视情况选用)
打包
一般项目的打包使用webpack,而工具库的打包则使用rollup
安装 Rollup
通过下面的命令安装 Rollup:
npm install --save-dev rollup
创建配置文件
在根目录创建一个新文件 rollup.config.js
export default {
input: "src/index.js",
output: {
file: "dist/drrp.js",
format: "esm",
name: 'drrp'
}
};
input —— 要打包的文件
output.file —— 输出的文件 (如果没有这个参数,则直接输出到控制台)
output.format —— Rollup 输出的文件类型
安装babel
如果要使用 es6 的语法进行开发,还需要使用 babel 将代码编译成 es5。因为rollup的模块机制是 ES6 Modules,但并不会对 es6 其他的语法进行编译。
安装模块
rollup-plugin-babel 将 rollup 和 babel 进行了完美结合。
npm install --save-dev rollup-plugin-babel@latest
npm install --save-dev @babel/core
npm install --save-dev @babel/preset-env
根目录创建 .babelrc
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
]
]
}
兼容 commonjs
rollup 提供了插件 rollup-plugin-commonjs,以便于在 rollup 中引用 commonjs 规范的包。该插件的作用是将 commonjs 模块转成 es6 模块。
rollup-plugin-commonjs 通常与 rollup-plugin-node-resolve 一同使用,后者用来解析依赖的模块路径。
安装模块
npm install --save-dev rollup-plugin-commonjs rollup-plugin-node-resolve
压缩 bundle
添加 UglifyJS 可以通过移除注上释、缩短变量名、重整代码来极大程度的减少 bundle 的体积大小 —— 这样在一定程度降低了代码的可读性,但是在网络通信上变得更有效率。
安装插件
用下面的命令来安装 rollup-plugin-uglify:
npm install --save-dev rollup-plugin-uglify
完整配置
rollup.config.js 最终配置如下
import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import babel from 'rollup-plugin-babel';
import { uglify } from 'rollup-plugin-uglify';
import json from '@rollup/plugin-json'
const paths = {
input: {
root: 'src/index.js',
},
output: {
root: 'dist/',
},
};
const fileName = `drrq.js`;
export default {
input: `${paths.input.root}`,
output: {
file: `${paths.output.root}${fileName}`,
format: 'esm',
name: 'drrq',
},
plugins: [
json(),
resolve(),
commonjs(),
babel({
exclude: 'node_modules/**',
runtimeHelpers: true,
}),
uglify(),
],
};
在package.json中加上
"scripts": {
"build": "rollup -c"
},
即可执行npm run build将/src/index.js打包为/dist/drrq.js
发包前的准备
准备npm账号,通过npm login或npm adduser。这里有一个坑,终端内连接不上npm源,需要在上网工具内复制终端代理命令后到终端执行才能正常连接。
准备一个简单清晰的readme.md
修改package.json
完整的package.json如下
{
"name": "drrq",
"private": false,
"version": "1.3.5",
"main": "/dist/drrq.js",
"repository": "https://gitee.com/yuanying-11/drrq.git",
"author": "it_yuanying",
"license": "MIT",
"description": "能自动取消重复请求的axios封装",
"type": "module",
"keywords": [
"取消重复请求",
],
"dependencies": {
"axios": "^1.2.0",
"qs": "^6.11.0"
},
"scripts": {
"test": "node example",
"build": "rollup -c"
},
"devDependencies": {
...
}
}
name 包名称 一定不能与npm已有的包名重复,想一个简单易记的
private 是否为私有
version 版本
main 入口文件位置
repository git仓库地址
author 作者
license 协议
description 描述
keywords 关键词,便于检索
每个 npm 包都需要一个版本,以便开发人员在安全地更新包版本的同时不会破坏其余的代码。npm 使用的版本系统被叫做 SemVer,是 Semantic Versioning 的缩写。
不要过分担心理解不了相较复杂的版本名称,下面是他们对基本版本命名的总结: 给定版本号 MAJOR.MINOR.PATCH,增量规则如下:
MAJOR 版本号的变更说明新版本产生了不兼容低版本的 API 等,
MINOR 版本号的变更说明你在以向后兼容的方式添加功能,接下来
PATCH 版本号的变更说明你在新版本中做了向后兼容的 bug 修复。
表示预发布和构建元数据的附加标签可作为 MAJOR.MINOR.PATCH 格式的扩展。
最后,执行npm publish就搞定啦
本文的完整代码已开源至gitee.com/yuanying-11… ,感兴趣的读者欢迎fork和star!
另外,本文参考了juejin.cn/post/684490… 和juejin.cn/post/684490…
作者:断律绎殇
来源:juejin.cn/post/7172240485778456606
比 JSON.stringify 快两倍的fast-json-stringify
前言
相信大家对JSON.stringify
并不陌生,通常在很多场景下都会用到这个API,最常见的就是HTTP请求中的数据传输, 因为HTTP 协议是一个文本协议,传输的格式都是字符串,但我们在代码中常常操作的是 JSON 格式的数据,所以我们需要在返回响应数据前将 JSON 数据序列化为字符串。但大家是否考虑过使用JSON.stringify
可能会带来性能风险🤔,或者说有没有一种更快的stringify
方法。
JSON.stringify的性能瓶颈
由于 JavaScript 是动态语言,它的变量类型只有在运行时才能确定,所以 JSON.stringify 在执行过程中要进行大量的类型判断,对不同类型的键值做不同的处理。由于不能做静态分析,执行过程中的类型判断这一步就不可避免,而且还需要一层一层的递归,循环引用的话还有爆栈的风险。
我们知道,JSON.string的底层有两个非常重要的步骤:
类型判断
递归遍历
既然是这样,我们可以先来对比一下JSON.stringify与普通遍历的性能,看看类型判断这一步到底是不是影响JSON.stringify性能的主要原因。
JSON.stringify 与遍历对比
const obj1 = {}, obj2 = {}
for(let i = 0; i < 1000000; i++) {
obj1[i] = i
obj2[i] = i
}
function fn1 () {
console.time('jsonStringify')
const res = JSON.stringify(obj1) === JSON.stringify(obj2)
console.timeEnd('jsonStringify')
}
function fn2 () {
console.time("for");
const res = Object.keys(obj1).every((key) => {
if (obj2[key] || obj2[key] === 0) {
return true;
} else {
return false;
}
});
console.timeEnd("for");
}
fn1()
fn2()
从结果来看,两者的性能差距在4倍左右,那就证明JSON.string
的类型判断这一步还是非常耗性能的。如果JSON.stringify能够跳过类型判断这一步是否对类型判断有帮助呢?
定制化更快的JSON.stringify
基于上面的猜想,我们可以来尝试实现一下:
现在我们有下面这个对象
const obj = {
name: '南玖',
hobby: 'fe',
age: 18,
chinese: true
}
上面这个对象经过JSON.stringify
处理后是这样的:
JSON.stringify(obj)
// {"name":"南玖","hobby":"fe","age":18,"chinese":true}
现在假如我们已经提前知道了这个对象的结构
键名不变
键值类型不变
这样的话我们就可以定制一个更快的JSON.stringify方法
function myStringify(obj) {
return `{"name":"${obj.name}","hobby":"${obj.hobby}","age":${obj.age},"chinese":${obj.chinese}}`
}
console.log(myStringify(obj) === JSON.stringify(obj)) // true
这样也能够得到JSON.stringify一样的效果,前提是你已经知道了这个对象的结构。
事实上,这是许多JSON.stringify
加速库的通用手段:
需要先确定对象的结构信息
再根据结构信息,为该种结构的对象创建“定制化”的
stringify
方法内部实现依然是这种字符串拼接
更快的fast-json-stringify
fast-json-stringify 需要JSON Schema Draft 7输入来生成快速
stringify
函数。
这也就是说fast-json-stringify
这个库是用来给我们生成一个定制化的stringily函数,从而来提升stringify
的性能。
这个库的GitHub简介上写着比 JSON.stringify() 快 2 倍,其实它的优化思路跟我们上面那种方法是一致的,也是一种定制化stringify
方法。
语法
const fastJson = require('fast-json-stringify')
const stringify = fastJson(mySchema, {
schema: { ... },
ajv: { ... },
rounding: 'ceil'
})
schema
: $ref 属性引用的外部模式。ajv
: ajv v8 实例对那些需要ajv
.rounding
: 设置当integer
类型不是整数时如何舍入。largeArrayMechanism
:设置应该用于处理大型(默认情况下20000
或更多项目)数组的机制
scheme
这其实就是我们上面所说的定制化对象结构,比如还是这个对象:
const obj = {
name: '南玖',
hobby: 'fe',
age: 18,
chinese: true
}
它的JSON scheme是这样的:
{
type: "object",
properties: {
name: {type: "string"},
hobby: {type: "string"},
age: {type: "integer"},
chinese: {type: 'boolean'}
},
required: ["name", "hobby", "age", "chinese"]
}
AnyOf 和 OneOf
当然除了这种简单的类型定义,JSON Schema 还支持一些条件运算,比如字段类型可能是字符串或者数字,可以用 oneOf 关键字:
"oneOf": [
{
"type": "string"
},
{
"type": "number"
}
]
fast-json-stringify
支持JSON 模式定义的anyOf和oneOf关键字。两者都必须是一组有效的 JSON 模式。不同的模式将按照指定的顺序进行测试。stringify
在找到匹配项之前必须尝试的模式越多,速度就越慢。
anyOf和oneOf使用ajv作为 JSON 模式验证器来查找与数据匹配的模式。这对性能有影响——只有在万不得已时才使用它。
关于 JSON Schema 的完整定义,可以参考 Ajv 的文档,Ajv 是一个流行的 JSON Schema验证工具,性能表现也非常出众。
当我们可以提前确定一个对象的结构时,可以将其定义为一个 Schema,这就相当于提前告诉 stringify 函数,需序列化的对象的数据结构,这样它就可以不必再在运行时去做类型判断,这就是这个库提升性能的关键所在。
简单使用
const fastJson = require('fast-json-stringify')
const stringify = fastJson({
title: 'myObj',
type: 'object',
properties: {
name: {
type: 'string'
},
hobby: {
type: 'string'
},
age: {
description: 'Age in years',
type: 'integer'
},
chinese: {
type: 'boolean'
}
}
})
console.log(stringify({
name: '南玖',
hobby: 'fe',
age: 18,
chinese: true
}))
生成 stringify 函数
fast-json-stringify
是跟我们传入的scheme
来定制化生成一个stringily
函数,上面我们了解了怎么为我们对象定义一个scheme
结构,接下来我们再来了解一下如何生成stringify
。
这里有一些工具方法还是值得了解一下的:
const asFunctions = `
function $asAny (i) {
return JSON.stringify(i)
}
function $asNull () {
return 'null'
}
function $asInteger (i) {
if (isLong && isLong(i)) {
return i.toString()
} else if (typeof i === 'bigint') {
return i.toString()
} else if (Number.isInteger(i)) {
return $asNumber(i)
} else {
return $asNumber(parseInteger(i))
}
}
function $asNumber (i) {
const num = Number(i)
if (isNaN(num)) {
return 'null'
} else {
return '' + num
}
}
function $asBoolean (bool) {
return bool && 'true' || 'false'
}
// 省略了一些其他类型......
从上面我们可以看到,如果你使用的是 any 类型,它内部依然还是用的 JSON.stringify。 所以我们在用TS进行开发时应避免使用 any 类型,因为如果是基于 TS interface
生成JSON Schema
的话,使用 any 也会影响到 JSON 序列化的性能。
然后就会根据 scheme 定义的具体内容生成 stringify 函数的具体代码。而生成的方式也比较简单:通过遍历 scheme,根据不同数据类型调用上面不同的工具函数来进行字符串拼接。感兴趣的同学可以在GitHub上查看源码
总结
事实上fast-json-stringify
只是通过静态的结构信息将优化与分析前置了,通过开发者定义的scheme
内容可以提前知道对象的数据结构,然后会生成一个stringify
函数供开发者调用,该函数内部其实就是做了字符串的拼接。
开发者定义 Object 的
JSON scheme
stringify 库根据 scheme 生成对应的模版方法,模版方法里会对属性与值进行字符串拼接
最后开发者调用生成的stringify 方法
作者:前端南玖
来源:juejin.cn/post/7173482852695146510
从0到1搭建前端监控平台,面试必备的亮点项目
前言
常常会苦恼,平常做的项目很普通,没啥亮点;面试中也经常会被问到:做过哪些亮点项目吗?
前端监控就是一个很有亮点的项目,各个大厂都有自己的内部实现,没有监控的项目好比是在裸奔
文章分成以下六部分来介绍:
自研监控平台解决了哪些痛点,实现了什么亮点功能?
相比sentry等监控方案,自研监控的优势有哪些?
前端监控的设计方案、监控的目的
数据的采集方式:错误信息、性能数据、用户行为、加载资源、个性化指标等
设计开发一个完整的监控SDK
监控后台错误还原演示示例
痛点
某⼀天用户:xx商品无法下单!
⼜⼀天运营:xx广告在手机端打开不了!
大家反馈的bug,怎么都复现不出来,尴尬的要死!😢
如何记录项目的错误,并将错误还原出来,这是监控平台要解决的痛点之一
错误还原
web-see 监控提供三种错误还原方式:定位源码、播放录屏、记录用户行为
定位源码
项目出错,要是能定位到源码就好了,可线上的项目都是打包后的代码,也不能把 .map 文件放到线上
监控平台通过 source-map 可以实现该功能
最终效果:
播放录屏
多数场景下,定位到具体的源码,就可以定位bug,但如果是用户做了异常操作,或者是在某些复杂操作下才出现的bug,仅仅通过定位源码,还是不能还原错误
要是能把用户的操作都录制下来,然后通过回放来还原错误就好了
监控平台通过 rrweb 可以实现该功能
最终效果:
回放的录屏中,记录了用户的所有操作,红色的线代表了鼠标的移动轨迹
前端录屏确实是件很酷的事情,但是不能走极端,如果把用户的所有操作都录制下来,是没有意义的
我们更关注的是,页面报错的时候用户做了哪些操作,所以监控平台只把报错前10s的视频保存下来(单次录屏时长也可以自定义)
记录用户行为
通过 定位源码 + 播放录屏 这套组合,还原错误应该够用了,同时监控平台也提供了 记录用户行为 这种方式
假如用户做了很多操作,操作的间隔超过了单次录屏时长,录制的视频可能是不完整的,此时可以借助用户行为来分析用户的操作,帮助复现bug
最终效果:
用户行为列表记录了:鼠标点击、接口调用、资源加载、页面路由变化、代码报错等信息
通过 定位源码、播放录屏、记录用户行为
这三板斧,解决了复现bug的痛点
自研监控的优势
为什么不直接用sentry私有化部署,而选择自研前端监控?
这是优先要思考的问题,sentry作为前端监控的行业标杆,有很多可以借鉴的地方
相比sentry,自研监控平台的优势在于:
1、可以将公司的SDK统一成一个,包括但不限于:监控SDK、埋点SDK、录屏SDK、广告SDK等
2、提供了更多的错误还原方式,同时错误信息可以和埋点信息联动,便可拿到更细致的用户行为栈,更快的排查线上错误
3、监控自定义的个性化指标:如 long task、memory页面内存、首屏加载时间等。过多的长任务会造成页面丢帧、卡顿;过大的内存可能会造成低端机器的卡死、崩溃
4、统计资源缓存率,来判断项目的缓存策略是否合理,提升缓存率可以减少服务器压力,也可以提升页面的打开速度
设计思路
一个完整的前端监控平台包括三个部分:数据采集与上报、数据分析和存储、数据展示
监控目的
异常分析
按照 5W1H 法则来分析前端异常,需要知道以下信息
What,发⽣了什么错误:JS错误、异步错误、资源加载、接口错误等
When,出现的时间段,如时间戳
Who,影响了多少用户,包括报错事件数、IP
Where,出现的页面是哪些,包括页面、对应的设备信息
Why,错误的原因是为什么,包括错误堆栈、⾏列、SourceMap、异常录屏
How,如何定位还原问题,如何异常报警,避免类似的错误发生
错误数据采集
错误信息是最基础也是最重要的数据,错误信息主要分为下面几类:
JS 代码运行错误、语法错误等
异步错误等
静态资源加载错误
接口请求报错
错误捕获方式
1)try/catch
只能捕获代码常规的运行错误,语法错误和异步错误不能捕获到
示例:
// 示例1:常规运行时错误,可以捕获 ✅
try {
let a = undefined;
if (a.length) {
console.log('111');
}
} catch (e) {
console.log('捕获到异常:', e);
}
// 示例2:语法错误,不能捕获 ❌
try {
const notdefined,
} catch(e) {
console.log('捕获不到异常:', 'Uncaught SyntaxError');
}
// 示例3:异步错误,不能捕获 ❌
try {
setTimeout(() => {
console.log(notdefined);
}, 0)
} catch(e) {
console.log('捕获不到异常:', 'Uncaught ReferenceError');
}
复制代码
2) window.onerror
window.onerror 可以捕获常规错误、异步错误,但不能捕获资源错误
/**
* @param { string } message 错误信息
* @param { string } source 发生错误的脚本URL
* @param { number } lineno 发生错误的行号
* @param { number } colno 发生错误的列号
* @param { object } error Error对象
*/
window.onerror = function(message, source, lineno, colno, error) {
console.log('捕获到的错误信息是:', message, source, lineno, colno, error )
}
复制代码
示例:
window.onerror = function(message, source, lineno, colno, error) {
console.log("捕获到的错误信息是:", message, source, lineno, colno, error);
};
// 示例1:常规运行时错误,可以捕获 ✅
console.log(notdefined);
// 示例2:语法错误,不能捕获 ❌
const notdefined;
// 示例3:异步错误,可以捕获 ✅
setTimeout(() => {
console.log(notdefined);
}, 0);
// 示例4:资源错误,不能捕获 ❌
let script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://www.test.com/index.js";
document.body.appendChild(script);
复制代码
3) window.addEventListener
当静态资源加载失败时,会触发 error 事件, 此时 window.onerror 不能捕获到
示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<script>
window.addEventListener('error', (error) => {
console.log('捕获到异常:', error);
}, true)
</script>
<!-- 图片、script、css加载错误,都能被捕获 ✅ -->
<img src="https://test.cn/×××.png">
<script src="https://test.cn/×××.js"></script>
<link href="https://test.cn/×××.css" rel="stylesheet" />
<script>
// new Image错误,不能捕获 ❌
// new Image运用的比较少,可以自己单独处理
new Image().src = 'https://test.cn/×××.png'
</script>
</html>
复制代码
4)Promise错误
Promise中抛出的错误,无法被 window.onerror、try/catch、 error 事件捕获到,可通过 unhandledrejection 事件来处理
示例:
try {
new Promise((resolve, reject) => {
JSON.parse("");
resolve();
});
} catch (err) {
// try/catch 不能捕获Promise中错误 ❌
console.error("in try catch", err);
}
// error事件 不能捕获Promise中错误 ❌
window.addEventListener(
"error",
error => {
console.log("捕获到异常:", error);
},
true
);
// window.onerror 不能捕获Promise中错误 ❌
window.onerror = function(message, source, lineno, colno, error) {
console.log("捕获到异常:", { message, source, lineno, colno, error });
};
// unhandledrejection 可以捕获Promise中的错误 ✅
window.addEventListener("unhandledrejection", function(e) {
console.log("捕获到异常", e);
// preventDefault阻止传播,不会在控制台打印
e.preventDefault();
});
复制代码
Vue 错误
Vue项目中,window.onerror 和 error 事件不能捕获到常规的代码错误
异常代码:
export default {
created() {
let a = null;
if(a.length > 1) {
// ...
}
}
};
复制代码
main.js中添加捕获代码:
window.addEventListener('error', (error) => {
console.log('error', error);
});
window.onerror = function (msg, url, line, col, error) {
console.log('onerror', msg, url, line, col, error);
};
复制代码
控制台会报错,但是 window.onerror 和 error 不能捕获到
vue 通过 Vue.config.errorHander
来捕获异常:
Vue.config.errorHandler = (err, vm, info) => {
console.log('进来啦~', err);
}
复制代码
控制台打印:
errorHandler源码分析
在src/core/util
目录下,有一个error.js
文件
function globalHandleError (err, vm, info) {
// 获取全局配置,判断是否设置处理函数,默认undefined
// 配置config.errorHandler方法
if (config.errorHandler) {
try {
// 执行 errorHandler
return config.errorHandler.call(null, err, vm, info)
} catch (e) {
// 如果开发者在errorHandler函数中,手动抛出同样错误信息throw err,判断err信息是否相等,避免log两次
if (e !== err) {
logError(e, null, 'config.errorHandler')
}
}
}
// 没有配置,常规输出
logError(err, vm, info)
}
function logError (err, vm, info) {
if (process.env.NODE_ENV !== 'production') {
warn(`Error in ${info}: "${err.toString()}"`, vm)
}
/* istanbul ignore else */
if ((inBrowser || inWeex) && typeof console !== 'undefined') {
console.error(err)
} else {
throw err
}
}
复制代码
通过源码明白了,vue 使用 try/catch 来捕获常规代码的报错,被捕获的错误会通过 console.error 输出而避免应用崩溃
可以在 Vue.config.errorHandler 中将捕获的错误上报
Vue.config.errorHandler = function (err, vm, info) {
// handleError方法用来处理错误并上报
handleError(err);
}
复制代码
React 错误
从 react16 开始,官方提供了 ErrorBoundary 错误边界的功能,被该组件包裹的子组件,render 函数报错时会触发离当前组件最近父组件的ErrorBoundary
生产环境,一旦被 ErrorBoundary 捕获的错误,也不会触发全局的 window.onerror 和 error 事件
父组件代码:
import React from 'react';
import Child from './Child.js';
// window.onerror 不能捕获render函数的错误 ❌
window.onerror = function (err, msg, c, l) {
console.log('err', err, msg);
};
// error 不能render函数的错误 ❌
window.addEventListener( 'error', (error) => {
console.log('捕获到异常:', error);
},true
);
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// componentDidCatch 可以捕获render函数的错误
console.log(error, errorInfo)
// 同样可以将错误日志上报给服务器
reportError(error, errorInfo);
}
render() {
if (this.state.hasError) {
// 自定义降级后的 UI 并渲染
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
function Parent() {
return (
<div>
父组件
<ErrorBoundary>
<Child />
</ErrorBoundary>
</div>
);
}
export default Parent;
复制代码
子组件代码:
// 子组件 渲染出错
function Child() {
let list = {};
return (
<div>
子组件
{list.map((item, key) => (
<span key={key}>{item}</span>
))}
</div>
);
}
export default Child;
复制代码
同vue项目的处理类似,react项目中,可以在 componentDidCatch 中将捕获的错误上报
componentDidCatch(error, errorInfo) {
// handleError方法用来处理错误并上报
handleError(err);
}
复制代码
跨域问题
如果当前页面中,引入了其他域名的JS资源,如果资源出现错误,error 事件只会监测到一个 script error
的异常。
示例:
window.addEventListener("error", error => {
console.log("捕获到异常:", error);
}, true );
// 当前页面加载其他域的资源,如https://www.test.com/index.js
<script src="https://www.test.com/index.js"></script>
// 加载的https://www.test.com/index.js的代码
function fn() {
JSON.parse("");
}
fn();
复制代码
报错信息:
只能捕获到 script error
的原因:
是由于浏览器基于安全考虑
,故意隐藏了其它域JS文件抛出的具体错误信息,这样可以有效避免敏感信息无意中被第三方(不受控制的)脚本捕获到,因此,浏览器只允许同域下的脚本捕获具体的错误信息
解决方法:
前端script加crossorigin,后端配置 Access-Control-Allow-Origin
<script src="https://www.test.com/index.js" crossorigin></script>
复制代码
添加 crossorigin 后可以捕获到完整的报错信息:
如果不能修改服务端的请求头,可以考虑通过使用 try/catch 绕过,将错误抛出
<!doctype html>
<html>
<body>
<script src="https://www.test.com/index.js"></script>
<script>
window.addEventListener("error", error => {
console.log("捕获到异常:", error);
}, true );
try {
// 调用https://www.test.com/index.js中定义的fn方法
fn();
} catch (e) {
throw e;
}
</script>
</body>
</html>
复制代码
接口错误
接口监控的实现原理:针对浏览器内置的 XMLHttpRequest、fetch 对象,利用 AOP 切片编程重写该方法,实现对请求的接口拦截,从而获取接口报错的情况并上报
1)拦截XMLHttpRequest请求示例:
function xhrReplace() {
if (!("XMLHttpRequest" in window)) {
return;
}
const originalXhrProto = XMLHttpRequest.prototype;
// 重写XMLHttpRequest 原型上的open方法
replaceAop(originalXhrProto, "open", originalOpen => {
return function(...args) {
// 获取请求的信息
this._xhr = {
method: typeof args[0] === "string" ? args[0].toUpperCase() : args[0],
url: args[1],
startTime: new Date().getTime(),
type: "xhr"
};
// 执行原始的open方法
originalOpen.apply(this, args);
};
});
// 重写XMLHttpRequest 原型上的send方法
replaceAop(originalXhrProto, "send", originalSend => {
return function(...args) {
// 当请求结束时触发,无论请求成功还是失败都会触发
this.addEventListener("loadend", () => {
const { responseType, response, status } = this;
const endTime = new Date().getTime();
this._xhr.reqData = args[0];
this._xhr.status = status;
if (["", "json", "text"].indexOf(responseType) !== -1) {
this._xhr.responseText =
typeof response === "object" ? JSON.stringify(response) : response;
}
// 获取接口的请求时长
this._xhr.elapsedTime = endTime - this._xhr.startTime;
// 上报xhr接口数据
reportData(this._xhr);
});
// 执行原始的send方法
originalSend.apply(this, args);
};
});
}
/**
* 重写指定的方法
* @param { object } source 重写的对象
* @param { string } name 重写的属性
* @param { function } fn 拦截的函数
*/
function replaceAop(source, name, fn) {
if (source === undefined) return;
if (name in source) {
var original = source[name];
var wrapped = fn(original);
if (typeof wrapped === "function") {
source[name] = wrapped;
}
}
}
复制代码
2)拦截fetch请求示例:
function fetchReplace() {
if (!("fetch" in window)) {
return;
}
// 重写fetch方法
replaceAop(window, "fetch", originalFetch => {
return function(url, config) {
const sTime = new Date().getTime();
const method = (config && config.method) || "GET";
let handlerData = {
type: "fetch",
method,
reqData: config && config.body,
url
};
return originalFetch.apply(window, [url, config]).then(
res => {
// res.clone克隆,防止被标记已消费
const tempRes = res.clone();
const eTime = new Date().getTime();
handlerData = {
...handlerData,
elapsedTime: eTime - sTime,
status: tempRes.status
};
tempRes.text().then(data => {
handlerData.responseText = data;
// 上报fetch接口数据
reportData(handlerData);
});
// 返回原始的结果,外部继续使用then接收
return res;
},
err => {
const eTime = new Date().getTime();
handlerData = {
...handlerData,
elapsedTime: eTime - sTime,
status: 0
};
// 上报fetch接口数据
reportData(handlerData);
throw err;
}
);
};
});
}
复制代码
性能数据采集
谈到性能数据采集,就会提及加载过程模型图:
以Spa页面来说,页面的加载过程大致是这样的:
包括dns查询、建立tcp连接、发送http请求、返回html文档、html文档解析等阶段
最初,可以通过 window.performance.timing
来获取加载过程模型中各个阶段的耗时数据
// window.performance.timing 各字段说明
{
navigationStart, // 同一个浏览器上下文中,上一个文档结束时的时间戳。如果没有上一个文档,这个值会和 fetchStart 相同。
unloadEventStart, // 上一个文档 unload 事件触发时的时间戳。如果没有上一个文档,为 0。
unloadEventEnd, // 上一个文档 unload 事件结束时的时间戳。如果没有上一个文档,为 0。
redirectStart, // 表示第一个 http 重定向开始时的时间戳。如果没有重定向或者有一个非同源的重定向,为 0。
redirectEnd, // 表示最后一个 http 重定向结束时的时间戳。如果没有重定向或者有一个非同源的重定向,为 0。
fetchStart, // 表示浏览器准备好使用 http 请求来获取文档的时间戳。这个时间点会在检查任何缓存之前。
domainLookupStart, // 域名查询开始的时间戳。如果使用了持久连接或者本地有缓存,这个值会和 fetchStart 相同。
domainLookupEnd, // 域名查询结束的时间戳。如果使用了持久连接或者本地有缓存,这个值会和 fetchStart 相同。
connectStart, // http 请求向服务器发送连接请求时的时间戳。如果使用了持久连接,这个值会和 fetchStart 相同。
connectEnd, // 浏览器和服务器之前建立连接的时间戳,所有握手和认证过程全部结束。如果使用了持久连接,这个值会和 fetchStart 相同。
secureConnectionStart, // 浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,返回 0。
requestStart, // 浏览器向服务器发起 http 请求(或者读取本地缓存)时的时间戳,即获取 html 文档。
responseStart, // 浏览器从服务器接收到第一个字节时的时间戳。
responseEnd, // 浏览器从服务器接受到最后一个字节时的时间戳。
domLoading, // dom 结构开始解析的时间戳,document.readyState 的值为 loading。
domInteractive, // dom 结构解析结束,开始加载内嵌资源的时间戳,document.readyState 的状态为 interactive。
domContentLoadedEventStart, // DOMContentLoaded 事件触发时的时间戳,所有需要执行的脚本执行完毕。
domContentLoadedEventEnd, // DOMContentLoaded 事件结束时的时间戳
domComplete, // dom 文档完成解析的时间戳, document.readyState 的值为 complete。
loadEventStart, // load 事件触发的时间。
loadEventEnd // load 时间结束时的时间。
}
复制代码
后来 window.performance.timing 被废弃,通过 PerformanceObserver 来获取。旧的 api,返回的是一个 UNIX
类型的绝对时间,和用户的系统时间相关,分析的时候需要再次计算。而新的 api,返回的是一个相对时间,可以直接用来分析
现在 chrome 开发团队提供了 web-vitals 库,方便来计算各性能数据
用户行为数据采集
用户行为包括:页面路由变化、鼠标点击、资源加载、接口调用、代码报错等行为
设计思路
1、通过Breadcrumb类来创建用户行为的对象,来存储和管理所有的用户行为
2、通过重写或添加相应的事件,完成用户行为数据的采集
用户行为代码示例:
// 创建用户行为类
class Breadcrumb {
// maxBreadcrumbs控制上报用户行为的最大条数
maxBreadcrumbs = 20;
// stack 存储用户行为
stack = [];
constructor() {}
// 添加用户行为栈
push(data) {
if (this.stack.length >= this.maxBreadcrumbs) {
// 超出则删除第一条
this.stack.shift();
}
this.stack.push(data);
// 按照时间排序
this.stack.sort((a, b) => a.time - b.time);
}
}
let breadcrumb = new Breadcrumb();
// 添加一条页面跳转的行为,从home页面跳转到about页面
breadcrumb.push({
type: "Route",
form: '/home',
to: '/about'
url: "http://localhost:3000/index.html",
time: "1668759320435"
});
// 添加一条用户点击行为
breadcrumb.push({
type: "Click",
dom: "<button id='btn'>按钮</button>",
time: "1668759620485"
});
// 添加一条调用接口行为
breadcrumb.push({
type: "Xhr",
url: "http://10.105.10.12/monitor/open/pushData",
time: "1668760485550"
});
// 上报用户行为
reportData({
uuid: "a6481683-6d2e-4bd8-bba1-64819d8cce8c",
stack: breadcrumb.getStack()
});
复制代码
页面跳转
通过监听路由的变化来判断页面跳转,路由有history、hash
两种模式,history模式可以监听popstate
事件,hash模式通过重写 pushState和 replaceState
事件
vue项目中不能通过 hashchange
事件来监听路由变化,vue-router
底层调用的是 history.pushState
和 history.replaceState
,不会触发 hashchange
vue-router源码:
function pushState (url, replace) {
saveScrollPosition();
var history = window.history;
try {
if (replace) {
history.replaceState({ key: _key }, '', url);
} else {
_key = genKey();
history.pushState({ key: _key }, '', url);
}
} catch (e) {
window.location[replace ? 'replace' : 'assign'](url);
}
}
...
// this.$router.push时触发
function pushHash (path) {
if (supportsPushState) {
pushState(getUrl(path));
} else {
window.location.hash = path;
}
}
复制代码
通过重写 pushState、replaceState 事件来监听路由变化
// lastHref 前一个页面的路由
let lastHref = document.location.href;
function historyReplace() {
function historyReplaceFn(originalHistoryFn) {
return function(...args) {
const url = args.length > 2 ? args[2] : undefined;
if (url) {
const from = lastHref;
const to = String(url);
lastHref = to;
// 上报路由变化
reportData("routeChange", {
from,
to
});
}
return originalHistoryFn.apply(this, args);
};
}
// 重写pushState事件
replaceAop(window.history, "pushState", historyReplaceFn);
// 重写replaceState事件
replaceAop(window.history, "replaceState", historyReplaceFn);
}
function replaceAop(source, name, fn) {
if (source === undefined) return;
if (name in source) {
var original = source[name];
var wrapped = fn(original);
if (typeof wrapped === "function") {
source[name] = wrapped;
}
}
}
复制代码
用户点击
给 document 对象添加click事件,并上报
function domReplace() {
document.addEventListener("click",({ target }) => {
const tagName = target.tagName.toLowerCase();
if (tagName === "body") {
return null;
}
let classNames = target.classList.value;
classNames = classNames !== "" ? `` : "";
const id = target.id ? ` id="${target.id}"` : "";
const innerText = target.innerText;
// 获取包含id、class、innerTextde字符串的标签
let dom = `<${tagName}${id}${
classNames !== "" ? classNames : ""
}>${innerText}</${tagName}>`;
// 上报
reportData({
type: 'Click',
dom
});
},
true
);
}
复制代码
资源加载
获取页面中加载的资源信息,比如它们的 url 是什么、加载了多久、是否来自缓存等
可以通过 performance.getEntriesByType('resource') 获取,包括静态资源和动态资源,同时可以结合 initiatorType 字段来判断资源类型,对资源进行过滤
其中 PerformanceResourceTiming 来分析资源加载的详细数据
获取资源加载时长为 duration
字段,即 responseEnd 与 startTime
的差值
获取加载资源列表:
一个真实的页面中,资源加载大多数是逐步进行的,有些资源本身就做了延迟加载,有些是需要用户发生交互后才会去请求一些资源
如果我们只关注首页资源,可以在 window.onload
事件中去收集
如果要收集所有的资源,需要通过定时器反复地去收集,并且在一轮收集结束后,通过调用 clearResourceTimings 将 performance entries 里的信息清空,避免在下一轮收集时取到重复的资源
个性化指标
long task
执行时间超过50ms的任务,被称为 long task 长任务
获取页面的长任务列表:
const entryHandler = list => {
for (const long of list.getEntries()) {
// 获取长任务详情
console.log(long);
}
};
let observer = new PerformanceObserver(entryHandler);
observer.observe({ entryTypes: ["longtask"] });
复制代码
memory页面内存
performance.memory
可以显示此刻内存占用情况,它是一个动态值,其中:
jsHeapSizeLimit 该属性代表的含义是:内存大小的限制。
totalJSHeapSize 表示总内存的大小。
usedJSHeapSize 表示可使用的内存的大小。
通常,usedJSHeapSize 不能大于 totalJSHeapSize,如果大于,有可能出现了内存泄漏
// load事件中获取此时页面的内存大小
window.addEventListener("load", () => {
console.log("memory", performance.memory);
});
复制代码
首屏加载时间
首屏加载时间和首页加载时间不一样,首屏指的是屏幕内的dom渲染完成的时间
比如首页很长需要好几屏展示,这种情况下屏幕以外的元素不考虑在内
计算首屏加载时间流程
1)利用MutationObserver
监听document
对象,每当dom变化时触发该事件
2)判断监听的dom是否在首屏内,如果在首屏内,将该dom放到指定的数组中,记录下当前dom变化的时间点
3)在MutationObserver的callback函数中,通过防抖函数,监听document.readyState
状态的变化
4)当document.readyState === 'complete'
,停止定时器和 取消对document的监听
5)遍历存放dom的数组,找出最后变化节点的时间,用该时间点减去performance.timing.navigationStart
得出首屏的加载时间
监控SDK
监控SDK的作用:数据采集与上报
整体架构
整体架构使用 发布-订阅 设计模式,这样设计的好处是便于后续扩展与维护,如果想添加新的hook
或事件,在该回调中添加对应的函数即可
SDK 入口
src/index.js
对外导出init事件,配置了vue、react项目的不同引入方式
vue项目在Vue.config.errorHandler中上报错误,react项目在ErrorBoundary中上报错误
事件发布与订阅
通过添加监听事件来捕获错误,利用 AOP 切片编程,重写接口请求、路由监听等功能,从而获取对应的数据
src/load.js
用户行为收集
core/breadcrumb.js
创建用户行为类,stack用来存储用户行为,当长度超过限制时,最早的一条数据会被覆盖掉,在上报错误时,对应的用户行为会添加到该错误信息中
数据上报方式
支持图片打点上报和fetch请求上报两种方式
图片打点上报的优势:
1)支持跨域,一般而言,上报域名都不是当前域名,上报的接口请求会构成跨域
2)体积小且不需要插入dom中
3)不需要等待服务器返回数据
图片打点缺点是:url受浏览器长度限制
core/transportData.js
数据上报时机
优先使用 requestIdleCallback,利用浏览器空闲时间上报,其次使用微任务上报
监控SDK,参考了 sentry、 monitor、 mitojs
项目后台demo
主要用来演示错误还原功能,方式包括:定位源码、播放录屏、记录用户行为
后台demo功能介绍:
1、使用 express 开启静态服务器,模拟线上环境,用于实现定位源码的功能
2、server.js 中实现了 reportData(错误上报)、getmap(获取 map 文件)、getRecordScreenId(获取录屏信息)、 getErrorList(获取错误列表)的接口
3、用户可点击 'js 报错'、'异步报错'、'promise 错误' 按钮,上报对应的代码错误,后台实现错误还原功能
4、点击 'xhr 请求报错'、'fetch 请求报错' 按钮,上报接口报错信息
5、点击 '加载资源报错' 按钮,上报对应的资源报错信息
通过这些异步的捕获,了解监控平台的整体流程
安装与使用
npm官网搜索 web-see
仓库地址
监控SDK: web-see
监控后台: web-see-demo
总结
目前市面上的前端监控方案可谓是百花齐放,但底层原理都是相通的。从基础的理论知识到实现一个可用的监控平台,收获还是挺多的
有兴趣的小伙伴可以结合git仓库的源码玩一玩,再结合本文一起阅读,帮助加深理解
作者:海阔_天空
来源:juejin.cn/post/7172072612430872584
这样封装列表 hooks,一天可以开发 20 个页面
前言
在做移动端的需求时,我们经常会开发一些列表页,这些列表页大多数有着相似的功能:分页获取列表、上拉加载、下拉刷新···
在 Vue
出来 compositionAPI
之前,我们想要复用这样的逻辑还是比较麻烦的,好在现在 Vue2.7+
都支持 compositionAPI
语法了,这篇文章我将 手把手
带你用 compositionAPI
封装一个名为 useList
的 hooks
来实现列表页的逻辑复用。
基础版
需求分析
一个列表,最基本的需求应该包括: 发起请求,获取到列表的数组,然后将该数组渲染成相应的 DOM
节点。要实现这个功能,我们需要以下变量:
list : 数组变量,用来存放后端返回的数据,并在
template
模板中使用v-for
来遍历渲染成我们想要的样子。listReq: 发起 http 请求的函数,一般是
axios
的实例
代码实现
有了上面的分析,我们可以很轻松地在 setup
中写出如下代码:
import { ref } from 'vue'
import axios from 'axios' // 简单示例,就不给出封装axios的代码了
const list = ref([])
const listReq = () => {
axios.get('/url/to/getList').then((res) => {
list.value = res.list
})
}
listReq()
这样,我们就完成了一个基本的列表需求的逻辑部分。大部分的列表需求都是类似的逻辑,既然如此,Don't Repeat Yourself!
(不要重复写你的代码!),我们来把它封装成通用的方法:
首先,既然是通用的,会在多个地方使用,那么数据肯定不能乱了,我们要在每次使用
useList
的时候都拿到独属于自己的那一份数据。是不是感觉很熟悉?对的,就是以前的data为什么是一个函数
那个问题!所以我们的useList
是需要导出一个函数,我们从这个函数中获取数据与方法。让这个函数导出一个对象/数组,这样调用的时候解构
就可以拿到我们需要的变量和方法了
// useList.js 中
const useList = () => {
// 待补充的函数体
return {}
}
export default useList
然后,不同的地方调用的接口肯定不一样,我们想一次封装,不再维护,那么咱们干脆在使用的时候,把调用接口的方法传进来就可以了
// useList.js 中
import { ref } from 'vue'
const useList = (listReq) => {
if (!listReq) {
return new Error('请传入接口调用方法!')
}
const list = ref([])
const getList = () => {
listReq().then((res) => (list.value = res.list))
}
return {
list,
getList,
}
}
export default useList
这样,我们就完成了一个简单的列表 hooks
,使用的时候直接:
// setup中
import useList from '@/utils'
const { list, getList } = useList(axios.get('url/to/get/list'))
getList()
等等!列表好像不涉及到 DOM
操作,那咱们再偷点懒,直接在 useList
内部就调用了吧!
// useList.js中
import { ref } from 'vue'
const useList = (listReq) => {
if (!listReq) {
return new Error('请传入接口调用方法!')
}
const list = ref([])
const getList = () => {
listReq().then((res) => (list.value = res.list))
}
getList() // 直接初始化,省去在外面初始化的步骤
return {
list,
getList,
}
}
export default useList
这时有老哥要说了,那我要是一个页面有多个列表怎么办?嘿嘿,别忘了,解构的时候是可以重命名的
// setup中
const { list: goodsList, getList: getGoodsList } = useList(
axios.get('/url/get/goods')
)
const { list: recommendList, getList: getRecommendList } = useList(
axios.get('/url/get/goods')
)
这样,我们就同时在一个页面里面,获取到了商品列表以及推荐列表所需要的变量与方法啦
带分页版
如果数据量比较大的话,所有的数据全部拿出来渲染显然不合理,所以我们一般要进行分页处理,我们来分析一下这个需求:
需求分析
要分页,那咱们肯定要告诉后端当前请求的是第几页、每页多少条,可能有些地方还需要展示总共有多少条,为了方便管理,咱们把这些分页数据统一放到
pageInfo对象中
分页了,那咱们肯定还有加载下一页的需求,需要一个
loadmore
函数分页了,那咱们肯定还会有刷新的需求,需要一个
initList
函数
代码实现
需求分析好了,代码实现起来就简单了,废话少说,上代码!
// useList.js中
import { ref } from 'vue'
const useList = (listReq) => {
if (!listReq) {
return new Error('请传入接口调用方法!')
}
const list = ref([])
// 新增pageInfo对象保存分页数据
const pageInfo = ref({
pageNum: 1,
pageSize: 10,
total: 0,
})
const getList = () => {
// 分页数据作为参数传递给接口调用函数即可
// 将请求这个Promise返回出去,以便链式then
return listReq(pageInfo.value).then((res) => {
list.value = res.list
// 更新总数量
pageInfo.value.total = res.total
// 返回出去,交给then默认的Promise,以便后续使用
return res
})
}
// 新增加载下一页的函数
const loadmore = () => {
// 下一页,那咱们把当前页自增一下就行了
pageInfo.value.pageNum += 1
// 如果已经是最后一页了(本次获取到空数组)
getList().then((res) => {
if (!res.list.length) {
uni.showToast({
title: '没有更多了',
icon: 'none',
})
}
})
}
// 新增初始化
const initList = () => {
// 初始化一般是要把所有的查询条件都初始化,这里只有分页,咱就回到第一页就行
pageInfo.value.pageNum = 1
getList()
}
getList()
return {
list,
getList,
loadmore,
initList,
}
}
export default useList
完工!跑起来试试,Perfec......等等,好像不太对...
加载更多,应该是把两次请求的数据合并到一起渲染出来才对,这怎么直接替换掉了?
回头看看代码,原来是咱们漏了拼接的逻辑,补上,补上
// useList.js中
// ...省略其余代码
const getList = () => {
// 分页数据作为参数传递给接口调用函数即可
return listReq(pageInfo.value).then((res) => {
// 当前页不为1则是加载更多,需要拼接数据
if (pageInfo.value.pageNum === 1) {
list.value = res.list
} else {
list.value = [...list.value, ...res.list]
}
pageInfo.value.total = res.total
return res
})
}
// ...省略其余代码
带 hooks 版
上面的分页版,我们给出了 加载更多
和 初始化列表
功能,但是还是要手动调用。仔细想想,咱们刷新列表,一般都是在页面顶部下拉的时候刷新的;而加载更多,一般都是在滚动到底部的时候加载的。既然都是一样的触发时机,那咱们继续封装吧!
需求分析
uni-app 中提供了
onPullDownRefresh
和onReachBottom
钩子,在其中处理相关逻辑即可有些列表可能不是在页面中,而是在
scroll-view
中,还是需要手动处理,因此上面的函数咱们依然需要导出
代码实现
钩子函数(hooks)接受一个回调函数作为参数,咱们直接把上面的函数传入即可
需要注意的是,uni-app 中,下拉刷新的动画需要手动关闭,咱们还需要改造一下 listReq
函数
// useList中
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
// ...省略其余代码
onPullDownRefresh(initList)
onReachBottom(loadmore)
const getList = () => {
// 分页数据作为参数传递给接口调用函数即可
return listReq(pageInfo.value)
.then((res) => {
// ...省略其余代码
})
.finally((info) => {
// 不管成功还是失败,关闭下拉刷新的动画
uni.stopPullDownRefresh()
// 在最后再把前面返回的消息return出去,以便后续处理
return info
})
}
// ...省略其余代码
带参数
其实在实际开发中,我们在发起请求时可能还需要其他的参数,上面我们都是固定的只有分页的参数,可以稍加改造
需求分析
可能大家第一反应是多一个参数,或者用 展开运算符 (...)再定义一个形参就行了。这么做肯定是没问题的,不过在这里的话不够优雅~
我们这里是要增加一个传给后端的参数,一般都是一起以 JSON 对象的形式传过去,既然如此,那咱们把所有的参数都用一个对象接受,发起请求的时候和分页参数对象合并为一个对象,代码的可读性会更高,使用者在使用时也可以自由地定义 key-value
键值对
代码实现
// useList中
const useList = (listReq, data) => {
// ...省略其余代码
// 判断第二个参数是否是对象,以免后面使用展开运算符时报错
if (data && Object.prototype.toString.call(data) !== '[object Object]') {
return new Error('额外参数请使用对象传入')
}
const getList = () => {
const params = {
...pageInfo.value,
...data,
}
return listReq(params).then((res) => {
// ...省略其余代码
})
}
// ...省略其余代码
}
// ...省略其余代码
带默认配置版
有些时候我们的列表是在页面中间,不需要触底加载更多;有时候我们可能需要在不同的地方调用相同的接口,但是需要获取的数据量不一样....
为了适应各种各样的需求,我们可以稍加改造,添加一个带有默认值的配置对象,
// useList.js中
const defaultConfig = {
pageSize: 10, // 每页数量,其实也可以在data里面覆盖
needLoadMore: true, // 是否需要下拉加载
data: {}, // 这个就是给接口用的额外参数了
// 还可以根据自己项目需求添加其他配置
}
// 添加一个有默认值的参数,依然满足大部分列表页传入接口即可使用的需求
const useList = (listReq, config = defaultConfig) => {
// 解构的时候赋上初始值,这样即使配置参数只传了一个参数,也不影响其他的配置
const {
pageSize = defaultConfig.pageSize,
needLoadMore = defaultConfig.needLoadMore,
data = defaultConfig.data,
} = config
// 应用相应的配置
if (needLoadMore) {
onReachBottom(loadmore)
}
const pageInfo = ref({
pageNum: 1,
pageSize,
total: 0,
})
// ...省略其余代码
}
// ...省略其余代码
这样一来,咱们就实现了一个满足大部分移动端列表页的逻辑复用 hooks
web 端的几乎只有加载更多(翻页)的时候逻辑不太一样,不需要拼接数据,在封装的时候可以把分页器的处理逻辑一起封装进来
总结
在这篇文章中,咱们从需求分析开始,到代码关键逻辑分析,再到实现后的 bug 修复,再到功能扩展,基本完整地复现了编码的思考过程,希望能给大家带来一些收获~
同时,欢迎大家在评论区和谐讨论~
作者:八宝粥要加纯牛奶
来源:juejin.cn/post/7165467345648320520
关于无感刷新Token,我是这样子做的
什么是JWT
JWT
是全称是JSON WEB TOKEN
,是一个开放标准,用于将各方数据信息作为JSON格式进行对象传递,可以对数据进行可选的数字加密,可使用RSA
或ECDSA
进行公钥/私钥签名。
使用场景
JWT
最常见的使用场景就是缓存当前用户登录信息,当用户登录成功之后,拿到JWT
,之后用户的每一个请求在请求头携带上Author ization
字段来辨别区分请求的用户信息。且不需要额外的资源开销。
相比传统session的区别
比起传统的session
认证方案,为了让服务器能识别是哪一个用户发过来的请求,都需要在服务器上保存一份用户的登录信息(通常保存在内存中),再与浏览器的cookie
打交道。
安全方面 由于是使用
cookie
来识别用户信息的,如果cookie
被拦截,用户会很容易受到跨站请求伪造的攻击。负载均衡 当服务器A保存了用户A的数据之后,在下一次用户A服务器A时由于服务器A访问量较大,被转发到服务器B,此时服务器B没有用户A的数据,会导致
session
失效。内存开销 随着时间推移,用户的增长,服务器需要保存的用户登录信息也就越来越多的,会导致服务器开销越来越大。
为什么说JWT不需要额外的开销
JWT
为三个部分组成,分别是Header
,Payload
,Signature
,使用.
符号分隔。
// 像这样子
xxxxx.yyyyy.zzzzz
标头 header
标头是一个JSON
对象,由两个部分组成,分别是令牌是类型(JWT
)和签名算法(SHA256
,RSA
)
{
"alg": "HS256",
"typ": "JWT"
}
负荷 payload
负荷部分也是一个JSON
对象,用于存放需要传递的数据,例如用户的信息
{
"username": "_island",
"age": 18
}
此外,JWT规定了7个可选官方字段(建议)
属性 | 说明 |
---|---|
iss | JWT签发人 |
exp | JWT过期时间 |
sub | JWT面向用户 |
aud | JWT接收方 |
nbf | JWT生效时间 |
iat | JWT签发时间 |
jti | JWT编号 |
签章 signature
这一部分,是由前面两个部分的签名,防止数据被篡改。 在服务器中指定一个密钥,使用标头中指定的签名算法,按照下面的公式生成这签名数据
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
在拿到签名数据之后,把这三个部分的数据拼接起来,每个部分中间使用.
来分隔。这样子我们就生成出一个了JWT
数据了,接下来返回给客户端储存起来。而且客户端在发起请求时,携带这个JWT
在请求头中的Authorization
字段,服务器通过解密的方式即可识别出对应的用户信息。
JWT优势和弊端
优势
数据体积小,传输速度快
无需额外资源开销来存放数据
支持跨域验证使用
弊端
生成出来的
Token
无法撤销,即使重置账号密码之前的Token
也是可以使用的(需等待JWT过期)无法确认用户已经签发了多少个
JWT
不支持
refreshToken
关于refreshToken
refreshToken
是Oauth2
认证中的一个概念,和accessToken
一起生成出来的。
当用户携带的这个accessToken
过期时,用户就需要在重新获取新的accessToken
,而refreshToken
就用来重新获取新的accessToken
的凭证。
为什么要有refreshToken
当你第一次接触的时候,你有没有一个这样子的疑惑,为什么需要refreshToken
这个东西,而不是服务器端给一个期限较长甚至永久性的accessToken
呢?
抱着这个疑惑我在网上搜寻了一番,
其实这个accessToken
的使用期限有点像我们生活中的入住酒店,当我们在入住酒店时,会出示我.们的身份证明来登记获取房卡,此时房卡相当于accessToken
,可以访问对应的房间,当你的房卡过期之后就无法再开启房门了,此时就需要再到前台更新一下房卡,才能正常进入,这个过程也就相当于refreshToken
。
accessToken
使用率相比refreshToken
频繁很多,如果按上面所说如果accessToken
给定一个较长的有效时间,就会出现不可控的权限泄露风险。
使用refreshToken可以提高安全性
用户在访问网站时,
accessToken
被盗取了,此时攻击者就可以拿这个accessToke
访问权限以内的功能了。如果accessToken
设置一个短暂的有效期2小时,攻击者能使用被盗取的accessToken
的时间最多也就2个小时,除非再通过refreshToken
刷新accessToken
才能正常访问。设置
accessToken
有效期是永久的,用户在更改密码之后,之前的accessToken
也是有效的
总体来说有了refreshToken
可以降低accessToken
被盗的风险
关于JWT无感刷新TOKEN方案(结合axios)
业务需求
在用户登录应用后,服务器会返回一组数据,其中就包含了accessToken
和refreshToken
,每个accessToken
都有一个固定的有效期,如果携带一个过期的token
向服务器请求时,服务器会返回401的状态码来告诉用户此token
过期了,此时就需要用到登录时返回的refreshToken
调用刷新Token
的接口(Refresh
)来更新下新的token
再发送请求即可。
话不多说,先上代码
工具
axios
作为最热门的http
请求库之一,我们本篇文章就借助它的错误响应拦截器来实现token
无感刷新功能。
具体实现
本次基于axios-bz代码片段封装响应拦截器 可直接配置到你的项目中使用 ✈️ ✈️
利用interceptors.response
,在业务代码获取到接口数据之前进行状态码401
判断当前携带的accessToken
是否失效。 下面是关于interceptors.response
中异常阶段处理内容。当响应码为401时,响应拦截器会走中第二个回调函数onRejected
下面代码分段可能会让大家阅读起来不是很顺畅,我直接把整份代码贴在下面,且每一段代码之间都添加了对应的注释
// 最大重发次数
const MAX_ERROR_COUNT = 5;
// 当前重发次数
let currentCount = 0;
// 缓存请求队列
const queue: ((t: string) => any)[] = [];
// 当前是否刷新状态
let isRefresh = false;
export default async (error: AxiosError<ResponseDataType>) => {
const statusCode = error.response?.status;
const clearAuth = () => {
console.log('身份过期,请重新登录');
window.location.replace('/login');
// 清空数据
sessionStorage.clear();
return Promise.reject(error);
};
// 为了节省多余的代码,这里仅展示处理状态码为401的情况
if (statusCode === 401) {
// accessToken失效
// 判断本地是否有缓存有refreshToken
const refreshToken = sessionStorage.get('refresh') ?? null;
if (!refreshToken) {
clearAuth();
}
// 提取请求的配置
const { config } = error;
// 判断是否refresh失败且状态码401,再次进入错误拦截器
if (config.url?.includes('refresh')) {
clearAuth();
}
// 判断当前是否为刷新状态中(防止多个请求导致多次调refresh接口)
if (isRefresh) {
// 设置当前状态为刷新中
isRefresh = true;
// 如果重发次数超过,直接退出登录
if (currentCount > MAX_ERROR_COUNT) {
clearAuth();
}
// 增加重试次数
currentCount += 1;
try {
const {
data: { access },
} = await UserAuthApi.refreshToken(refreshToken);
// 请求成功,缓存新的accessToken
sessionStorage.set('token', access);
// 重置重发次数
currentCount = 0;
// 遍历队列,重新发起请求
queue.forEach((cb) => cb(access));
// 返回请求数据
return ApiInstance.request(error.config);
} catch {
// 刷新token失败,直接退出登录
console.log('请重新登录');
sessionStorage.clear();
window.location.replace('/login');
return Promise.reject(error);
} finally {
// 重置状态
isRefresh = false;
}
} else {
// 当前正在尝试刷新token,先返回一个promise阻塞请求并推进请求列表中
return new Promise((resolve) => {
// 缓存网络请求,等token刷新后直接执行
queue.push((newToken: string) => {
Reflect.set(config.headers!, 'authorization', newToken);
// @ts-ignore
resolve(ApiInstance.request<ResponseDataType<any>>(config));
});
});
}
}
return Promise.reject(error);
};
抽离代码
把上面关于调用刷新token
的代码抽离成一个refreshToken
函数,单独处理这一情况,这样子做有利于提高代码的可读性和维护性,且让看上去代码不是很臃肿
// refreshToken.ts
export default async function refreshToken(error: AxiosError<ResponseDataType>) {
/*
将上面 if (statusCode === 401) 中的代码贴进来即可,这里就不重复啦
代码仓库地址: https://github.com/QC2168/axios-bz/blob/main/Interceptors/hooks/refreshToken.ts
*/
}
经过上面的逻辑抽离,现在看下拦截器中的代码就很简洁了,后续如果要调整相关逻辑直接在refreshToken.ts
文件中调整即可。
import refreshToken from './refreshToken.ts'
export default async (error: AxiosError<ResponseDataType>) => {
const statusCode = error.response?.status;
// 为了节省多余的代码,这里仅展示处理状态码为401的情况
if (statusCode === 401) {
refreshToken()
}
return Promise.reject(error);
};
作者:_island
来源:juejin.cn/post/7170278285274775560
收起阅读 »万维网之父:Web3 根本不是 Web,我们应该忽略它
万维网之父、英国计算机科学家 Tim Berners-Lee 在 2022 年 Web 峰会上表示,区块链并不是构建下一代互联网的可行解决方案,我们应该忽略它。
他有自己的 Web 去中心化项目,叫作 Solid。
Berners-Lee 在里斯本举行的 Web 峰会上说,“在讨论新技术的影响时,你必须理解我们正在讨论的术语的真正含义,而不仅仅是停留在流行词的层面,这一点很重要。”
“事实上,Web3 被以太坊那班人用在了区块链上,这是一件可耻的事。事实上,Web3 根本就不是 Web。”
在科技行业,Web3 是一个模糊的术语,被用来描述一个假设的未来互联网版本,它比现在更加去中心化,不被亚马逊、微软和谷歌等少数巨头玩家所主导。
它涉及到一些新的技术,包括区块链、加密货币和非同质化的的代币。
虽然 Berners-Lee 的目标是将个人数据从大型科技公司的控制中解放出来,但他不相信支撑比特币等加密货币的分布式账本技术区块链会是解决方案。
他说,“区块链协议可能对某些事情有用,但对 Solid 来说不是。”Solid 是 Berners-Lee 领导的一个 Web 去中心化项目。“它们太慢、太贵、太公开。个人数据存储必须快速、廉价和私密。”
他说,“忽略所谓的 Web3,那些构建在区块链之上的随机的 Web3,我们不会把它用在 Solid 上。”
Berners-Lee 说,人们经常把 Web3 和“Web 3.0”混为一谈,而“Web 3.0”是他提出的重塑互联网的提议。他的初创公司 Inrupt 旨在让用户控制自己的数据,包括如何访问和存储数据。据 TechCrunch 报道,该公司在去年 12 月获得了一轮 3000 万美元的融资。
Berners-Lee 表示,个人数据被谷歌和 Facebook 等少数大型科技平台独自占有,它们利用这些数据“将我们锁定在它们的平台上”。
他说,“其结果就是一场大数据竞赛,赢家是控制最多数据的公司,其他的都是输家。”
他的初创公司旨在通过三种方式解决这个问题:
全球“单点登录”功能,可以让任何人从任何地方登录。
允许用户与其他人共享数据的登录 ID。
一个“通用 API”或应用程序编程接口,允许应用程序从任何来源提取数据。
Berners-Lee 并不是唯一一个对 Web3 持怀疑态度的知名科技人士。一些硅谷领袖也对 Web3 提出了异议,比如推特联合创始人 Jack Dorsey 和特斯拉首席执行官 Elon Musk。
批评人士表示,Web3 容易出现与加密货币相同的问题,比如欺诈和安全缺陷。
原文链接:https://www.cnbc.com/2022/11/04/web-inventor-tim-berners-lee-wants-us-to-ignore-web3.html
作者 | Ryan Browne
译者 | 明知山
策划 | Tina
收起阅读 »每个前端都应该掌握的7个代码优化的小技巧
本文将介绍7种JavaScript的优化技巧,这些技巧可以帮助你更好的写出简洁优雅的代码。
1. 字符串的自动匹配(Array.includes
)
在写代码时我们经常会遇到这样的需求,我们需要检查某个字符串是否是符合我们的规定的字符串之一。最常见的方法就是使用||
和===
去进行判断匹配。但是如果大量的使用这种判断方式,定然会使得我们的代码变得十分臃肿,写起来也是十分累。其实我们可以使用Array.includes
来帮我们自动去匹配。
代码示例:
// 未优化前的写法
const isConform = (letter) => {
if (
letter === "a" ||
letter === "b" ||
letter === "c" ||
letter === "d" ||
letter === "e"
) {
return true;
}
return false;
};
// 优化后的写法
const isConform = (letter) =>
["a", "b", "c", "d", "e"].includes(letter);
2.for-of
和for-in
自动遍历
for-of
和for-in
,可以帮助我们自动遍历Array
和object
中的每一个元素,不需要我们手动跟更改索引来遍历元素。
注:我们更加推荐对象(object
)使用for-in
遍历,而数组(Array
)使用for-of
遍历
for-of
const arr = ['a',' b', 'c'];
// 未优化前的写法
for (let i = 0; i < arr.length; i++) {
const element = arr[i];
console.log(element);
}
// 优化后的写法
for (const element of arr) {
console.log(element);
}
// expected output: "a"
// expected output: "b"
// expected output: "c"
for-in
const obj = {
a: 1,
b: 2,
c: 3,
};
// 未优化前的写法
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = obj[key];
// ...
}
// 优化后的写法
for (const key in obj) {
const value = obj[key];
// ...
}
3.false判断
如果你想要判断一个变量是否为null、undefined、0、false、NaN、''
,你就可以使用逻辑非(!
)取反,来帮助我们来判断,而不用每一个值都用===
来判断
// 未优化前的写法
const isFalsey = (value) => {
if (
value === null ||
value === undefined ||
value === 0 ||
value === false ||
value === NaN ||
value === ""
) {
return true;
}
return false;
};
// 优化后的写法
const isFalsey = (value) => !value;
4.三元运算符代替(if/else
)
在我们编写代码的时候肯定遇见过if/else
选择结构,而三元运算符可以算是if/else
的一种语法糖,能够更加简洁的表示if/else
。
// 未优化前的写法
let info;
if (value < minValue) {
info = "Value is最小值";
} else if (value > maxValue) {
info = "Value is最大值";
} else {
info = "Value 在最大与最小之间";
}
//优化后的写法
const info =
value < minValue
? "Value is最小值"
: value > maxValue ? "Value is最大值" : "在最大与最小之间";
5.函数调用的选择
三元运算符还可以帮我们判断当前情况下该应该调用哪一个函数,
function f1() {
// ...
}
function f2() {
// ...
}
// 未优化前的写法
if (condition) {
f1();
} else {
f2();
}
// 优化后的写法
(condition ? f1 : f2)();
6.用对象代替switch/case选择结构
switch case
通常是有一个case
值对应一个返回值,这样的结构就类似于我们的对象,也是一个键对应一个值。我们就可以用我们的对象代替我们的switch/case
选择结构,使代码更加简洁
const dayNumber = new Date().getDay();
// 未优化前的写法
let day;
switch (dayNumber) {
case 0:
day = "Sunday";
break;
case 1:
day = "Monday";
break;
case 2:
day = "Tuesday";
break;
case 3:
day = "Wednesday";
break;
case 4:
day = "Thursday";
break;
case 5:
day = "Friday";
break;
case 6:
day = "Saturday";
}
// 优化后的写法
const days = {
0: "Sunday",
1: "Monday",
2: "Tuesday",
3: "Wednesday",
4: "Thursday",
5: "Friday",
6: "Saturday",
};
const day = days[dayNumber];
7. 逻辑或(||
)的运用
如果我们要获取一个不确定是否存在的值时,我们经常会运用if判断先去判断值是否存在,再进行获取。如果不存在我们就会返回另一个值。我们可以运用逻辑或(||
)的特性,去优化我们的代码
// 未优化前的写法
let name;
if (user?.name) {
name = user.name;
} else {
name = "Anonymous";
}
// 优化后的写法
const name = user?.name || "Anonymous";
作者:zayyo
来源:juejin.cn/post/7169420903888584711
[YYEVA]一个极致的特效框架
今年在公司内开发了一个mp4的特效框架,用于支撑各种礼物特效的玩法,是继SVGA特效框架的另外一个极致的特效框架。这里介绍的是YYEVA框架生成原理
为何要选用MP4资源作为特效框架?
这里一张图告诉你透明MP4特效的优势
可以看到透明mp4框架支持软解硬解,H264/265压缩,支持特效元素替换,支持透明通道。
为何称为极致?
YYEVA-Android 稳定版本是1.0.11版本,支持了业界中独有功能,例如文字左右对齐,元素图片缩放方式,支持嵌入背景图片,循环播放。
YYEVA-Android 已经出了2.0.0-beta版本,为大家带来业界领先的功能。
1.这个版本支持了框架多进程,将解码器放到子进程远程。
支持多进程解码,让主进程内存压力减少,让主进程更专注于渲染效果。 开发中主要遇到是,进程间的渲染的生命周期的回调,主进程中如何剥离出独立解码器等问题。
这里有个小插曲,尝试过是否能够单独使用子进程进行主进程传递的Surface渲染以及解码,答案是无法做到的,因为主进程创建Surface的egl环境无法和子进程共通,所以只能独立出解码器。或者使用Service创建Dialog依附新的windows来来创建egl环境和surface来做独立渲染。
2.支持高清滤镜,未来支持更多的高清滤镜功能。
支持高清滤镜,小尺寸资源,缩放效果不再纯粹的线性缩放,可以带有高清的滤镜计算来优化,各种屏幕上的表现。当然高清滤镜需要耗费一些性能,由开发接入sdk来自行判断使用策略。
现在分别支持 lagrange和hermite两种不同的滤镜算法,这两种算法已经在手Y中得到很好的实践,还有更加强大的高清滤镜正在试验中。
如果有更好的滤镜算法,也可以提供我们嵌入优化。
3.将opengles从2.0升级到3.1,并加入多种opengles的特性来优化整个gpu的缓存读取
使用了vbo,ebo,vao等opengles缓存技术来优化整个gpu运行缓存。优化特效渲染的压力,让特效渲染更好更快。 将原来Java层I妈个View中进行图片变换效果,完全转移到opengles来完成,进一步提高了整个绘制效率。还有将整个点击触摸系统反馈系统缩放计算置于Native中。
4.将硬解解码器下放到native层,未来正式版将兼容ffmpeg软解。
将原来1.0版本视频解码模块,音频解码和音频播放逻辑,转移到Native层实现,更好的功能代码统一性。 未来我们将加入ffmpeg软解/硬解,能够更好支持解码嵌入技术。
YYEVA未来将会提供更多业界领先的能力,发布更多重磅功能,欢迎大家点赞收藏一波
作者:Cang_Wang
来源:juejin.cn/post/7166071141226774565
原生 canvas 如何实现大屏?
前言
可视化大屏该如何做?有可能一天完成吗?废话不多说,直接看效果,线上 Demo 地址 lxfu1.github.io/large-scree…。
看完这篇文章(这个项目),你将收获:
全局状态真的很简单,你只需 5 分钟就能上手
如何缓存函数,当入参不变时,直接使用缓存值
千万节点的图如何分片渲染,不卡顿页面操作
项目单测该如何写?
如何用 canvas 绘制各种图表,如何实现 canvas 动画
如何自动化部署自己的大屏网站
实现
项目基于 Create React App --template typescript
搭建,包管理工具使用的 pnpm ,pnpm 的优势这里不多介绍(快+节省磁盘空间),之前在其它平台写过相关文章,后续可能会搬过来。由于项目 package.json 里面有限制包版本(最新版本的 G6 会导致 OOM,官方短时间能应该会修复),如果使用的 yarn 或 npm 的话,改为对应的 resolutions 即可。
"pnpm": {
"overrides": {
"@antv/g6": "4.7.10"
}
}
"resolutions": {
"@antv/g6": "4.7.10"
},
启动
clone项目
git clone https://github.com/lxfu1/large-screen-visualization.git
pnpm 安装
npm install -g pnpm
启动:
pnpm start
即可,建议配置 alias ,可以简化各种命令的简写 eg:p start
,不出意外的话,你可以通过 http://localhost:3000/ 访问了测试:
p test
构建:
p build
强烈建议大家先 clone 项目!
分析
全局状态
全局状态用的 valtio ,位于项目 src/models
目录下,强烈推荐。
优点:数据与视图分离的心智模型,不再需要在 React 组件或 hooks 里用 useState 和 useReducer 定义数据,或者在 useEffect 里发送初始化请求,或者考虑用 context 还是 props 传递数据。
缺点:兼容性,基于 proxy 开发,对低版本浏览器不友好,当然,大屏应该也不会考虑 IE 这类浏览器。
import { proxy } from "valtio";
import { NodeConfig } from "@ant-design/graphs";
type IState = {
sliderWidth: number;
sliderHeight: number;
selected: NodeConfig | null;
};
export const state: IState = proxy({
sliderWidth: 0,
sliderHeight: 0,
selected: null,
});
状态更新:
import { state } from "src/models";
state.selected = e.item?.getModel() as NodeConfig;
状态消费:
import { useSnapshot } from "valtio";
import { state } from "src/models";
export const BarComponent = () => {
const snap = useSnapshot(state);
console.log(snap.selected)
}
当我们选中图谱节点的时候,由于 BarComponent 组件监听了 selected 状态,所以该组件会进行更新。有没有感觉非常简单?一些高级用法建议大家去官网查看,不再展开。
函数缓存
为什么需要函数缓存?当然,在这个项目中函数缓存比较鸡肋,为了用而用,试想,如果有一个函数计算量非常大,组件内又有多个 state 频繁更新,怎么确保函数不被重复调用呢?可能大家会想到 useMemo``useCallback
等手段,这里要介绍的是 React 官方的 cache 方法,已经在 React 内部使用,但未暴露。实现上借鉴(抄袭)ReactCache,通过缓存的函数 fn 及其参数列表来构建一个 cacheNode 链表,然后基于链表最后一项的状态来作为函数 fn 与该组参数的计算缓存结果。
代码位于 src/utils/cache
interface CacheNode {
/**
* 节点状态
* - 0:未执行
* - 1:已执行
* - 2:出错
*/
s: 0 | 1 | 2;
// 缓存值
v: unknown;
// 特殊类型(object,fn),使用 weakMap 存储,避免内存泄露
o: WeakMap<Function | object, CacheNode> | null;
// 基本类型
p: Map<Function | object, CacheNode> | null;
}
const cacheContainer = new WeakMap<Function, CacheNode>();
export const cache = (fn: Function): Function => {
const UNTERMINATED = 0;
const TERMINATED = 1;
const ERRORED = 2;
const createCacheNode = (): CacheNode => {
return {
s: UNTERMINATED,
v: undefined,
o: null,
p: null,
};
};
return function () {
let cacheNode = cacheContainer.get(fn);
if (!cacheNode) {
cacheNode = createCacheNode();
cacheContainer.set(fn, cacheNode);
}
for (let i = 0; i < arguments.length; i++) {
const arg = arguments[i];
// 使用 weakMap 存储,避免内存泄露
if (
typeof arg === "function" ||
(typeof arg === "object" && arg !== null)
) {
let objectCache: CacheNode["o"] = cacheNode.o;
if (objectCache === null) {
objectCache = cacheNode.o = new WeakMap();
}
let objectNode = objectCache.get(arg);
if (objectNode === undefined) {
cacheNode = createCacheNode();
objectCache.set(arg, cacheNode);
} else {
cacheNode = objectNode;
}
} else {
let primitiveCache: CacheNode["p"] = cacheNode.p;
if (primitiveCache === null) {
primitiveCache = cacheNode.p = new Map();
}
let primitiveNode = primitiveCache.get(arg);
if (primitiveNode === undefined) {
cacheNode = createCacheNode();
primitiveCache.set(arg, cacheNode);
} else {
cacheNode = primitiveNode;
}
}
}
if (cacheNode.s === TERMINATED) return cacheNode.v;
if (cacheNode.s === ERRORED) {
throw cacheNode.v;
}
try {
const res = fn.apply(null, arguments as any);
cacheNode.v = res;
cacheNode.s = TERMINATED;
return res;
} catch (err) {
cacheNode.v = err;
cacheNode.s = ERRORED;
throw err;
}
};
};
如何验证呢?我们可以简单看下单测,位于src/__tests__/utils/cache.test.ts
:
import { cache } from "src/utils";
describe("cache", () => {
const primitivefn = jest.fn((a, b, c) => {
return a + b + c;
});
it("primitive", () => {
const cacheFn = cache(primitivefn);
const res1 = cacheFn(1, 2, 3);
const res2 = cacheFn(1, 2, 3);
expect(res1).toBe(res2);
expect(primitivefn).toBeCalledTimes(1);
});
});
可以看出,即使我们调用了 2 次 cacheFn,由于入参不变,fn 只被执行了一次,第二次直接返回了第一次的结果。
项目里面在做 circle 动画的时候使用了,因为该动画是绕圆周无限循环的,当循环过一周之后,后的动画和之前的完全一致,没必要再次计算对应的 circle 坐标,所以我们使用了 cache ,位于src/components/background/index.tsx。
const cacheGetPoint = cache(getPoint);
let p = 0;
const animate = () => {
if (p >= 1) p = 0;
const { x, y } = cacheGetPoint(p);
ctx.clearRect(0, 0, 2 * clearR, 2 * clearR);
createCircle(aCtx, x, y, circleR, "#fff", 6);
p += 0.001;
requestAnimationFrame(animate);
};
animate();
分片渲染
你有审查元素吗?项目背景图是通过 canvas 绘制的,并不是背景图片!通过 canvas 绘制如此多的小圆点,会不会阻碍页面操作呢?当数据量足够大的时候,是会阻碍的,大家可以把 NodeMargin 设置为 0.1 ,同时把 schduler 调用去掉,直接改为同步绘制。当节点数量在 500 W 的时候,如果没有开启切片,页面白屏时间在 MacBook Pro M1 上白屏时间大概是 8.5 S;开启分片渲染时页面不会出现白屏,而是从左到右逐步绘制背景图,每个任务的执行时间在 16S 左右波动。
const schduler = (tasks: Function[]) => {
const DEFAULT_RUNTIME = 16;
const { port1, port2 } = new MessageChannel();
let isAbort = false;
const promise: Promise<any> = new Promise((resolve, reject) => {
const runner = () => {
const preTime = performance.now();
if (isAbort) {
return reject();
}
do {
if (tasks.length === 0) {
return resolve([]);
}
const task = tasks.shift();
task?.();
} while (performance.now() - preTime < DEFAULT_RUNTIME);
port2.postMessage("");
};
port1.onmessage = () => {
runner();
};
});
// @ts-ignore
promise.abort = () => {
isAbort = true;
};
port2.postMessage("");
return promise;
};
分片渲染可以不阻碍用户操作,但延迟了任务的整体时长,是否开启还是取决于数据量。如果每个分片实际执行时间大于 16ms 也会造成阻塞,并且会堆积,并且任务执行的时候没有等,最终渲染状态和预期不一致,所以 task 的拆分也很重要。
单测
这里不想多说,大家可以运行 pnpm test
看看效果,环境已经搭建好;由于项目里面用到了 canvas 所以需要 mock 一些环境,这里的 mock 可以理解为“我们前端代码跑在浏览器里运行,依赖了浏览器环境以及对应的 API,但由于单测没有跑在浏览器里面,所以需要 mock 浏览器环境”,例如项目里面设置的 jsdom、jest-canvas-mock 以及 worker 等,更多推荐直接访问 jest 官网。
// jest-dom adds custom jest matchers for asserting on DOM nodes.
import "@testing-library/jest-dom";
Object.defineProperty(URL, "createObjectURL", {
writable: true,
value: jest.fn(),
});
class Worker {
onmessage: () => void;
url: string;
constructor(stringUrl) {
this.url = stringUrl;
this.onmessage = () => {};
}
postMessage() {
this.onmessage();
}
terminate() {}
onmessageerror() {}
addEventListener() {}
removeEventListener() {}
dispatchEvent(): boolean {
return true;
}
onerror() {}
}
window.Worker = Worker;
自动化部署
开发过项目的同学都知道,前端编写的代码最终是要进行部署的,目前比较流行的是前后端分离,前端独立部署,通过 proxy 的方式请求后端服务;或者是将前端构建产物推到后端服务上,和后端一起部署。如何做自动化部署呢,对于一些不依赖后端的项目来说,我们可以借助 github 提供的 gh-pages 服务来做自动化部署,CI、CD 仅需配置对应的 actions 即可,在仓库 settings/pages 下面选择对应分支即可完成部署。
例如项目里面的.github/workflows/gh-pages.yml
,表示当 master 分支有代码提交时,会执行对应的 jobs,并借助 peaceiris/actions-gh-pages@v3
将构建产物同步到 gh-pages 分支。
name: github pages
on:
push:
branches:
- master # default branch
env:
CI: false
PUBLIC_URL: '/large-screen-visualization'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: yarn
- run: yarn build
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./build
总结
写文档不易,如果看完有收获,记得给个小星星!欢迎大家 PR!
作者:小丑竟然是我
来源:juejin.cn/post/7165564571128692773
收起阅读 »localStorage容量太小?试试它们
localStorage
是前端本地存储的一种,其容量一般在 5M-10M
左右,用来缓存一些简单的数据基本够用,毕竟定位也不是大数据量的存储。
在某些场景下 localStorage
的容量就会有点捉襟见肘,其实浏览器是有提供大数据量的本地存储的如 IndexedDB
存储数据大小一般在 250M
以上。
弥补了localStorage
容量的缺陷,但是使用要比localStorage
复杂一些 mdn IndexedDB
不过已经有大佬造了轮子封装了一些调用过程使其使用相对简单,下面我们一起来看一下
localforage
localforage 拥有类似 localStorage
API,它能存储多种类型的数据如 Array
ArrayBuffer
Blob
Number
Object
String
,而不仅仅是字符串。
这意味着我们可以直接存 对象、数组类型的数据避免了 JSON.stringify
转换数据的一些问题。
存储其他数据类型时需要转换成上边对应的类型,比如vue3中使用 reactive
定义的数据需要使用toRaw
转换成原始数据进行保存, ref
则直接保存 xxx.value
数据即可。
安装
下载最新版本 或使用 npm
bower
进行安装使用。
# 引入下载的 localforage 即可使用
<script src="localforage.js"></script>
<script>console.log('localforage is: ', localforage);</script>
# 通过 npm 安装:
npm install localforage
# 或通过 bower:
bower install localforage
使用
提供了与 localStorage
相同的api,不同的是它是异步的调用返回一个 Promise
对象
localforage.getItem('somekey').then(function(value) {
// 当离线仓库中的值被载入时,此处代码运行
console.log(value);
}).catch(function(err) {
// 当出错时,此处代码运行
console.log(err);
});
// 回调版本:
localforage.getItem('somekey', function(err, value) {
// 当离线仓库中的值被载入时,此处代码运行
console.log(value);
});
提供的方法有
getItem
根据数据的key
获取数据 差不多返回null
setItem
根据数据的key
设置数据(存储undefined
时getItem获取会返回null
)removeItem
根据key删除数据length
获取key的数量key
根据 key 的索引获取其名keys
获取数据仓库中所有的 key。iterate
迭代数据仓库中的所有value/key
键值对。
配置
完整配置可查看文档 这里说个作者觉得有用的
localforage.config({ name: 'My-localStorage' });
设置仓库的名字,不同的名字代表不同的仓库,当一个应用需要多个本地仓库隔离数据的时候就很有用。
const store = localforage.createInstance({
name: "nameHere"
});
const otherStore = localforage.createInstance({
name: "otherName"
});
// 设置某个数据仓库 key 的值不会影响到另一个数据仓库
store.setItem("key", "value");
otherStore.setItem("key", "value2");
同时也支持删除仓库
// 调用时,若不传参,将删除当前实例的 “数据仓库” 。
localforage.dropInstance().then(function() {
console.log('Dropped the store of the current instance').
});
// 调用时,若参数为一个指定了 name 和 storeName 属性的对象,会删除指定的 “数据仓库”。
localforage.dropInstance({
name: "otherName",
storeName: "otherStore"
}).then(function() {
console.log('Dropped otherStore').
});
// 调用时,若参数为一个仅指定了 name 属性的对象,将删除指定的 “数据库”(及其所有数据仓库)。
localforage.dropInstance({
name: "otherName"
}).then(function() {
console.log('Dropped otherName database').
});
idb-keyval
idb-keyval
是用IndexedDB
实现的一个超级简单的基于 promise
的键值存储。
安装
npm npm install idb-keyval
// 全部引入
import idbKeyval from 'idb-keyval';
idbKeyval.set('hello', 'world')
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
// 按需引入会摇树
import { get, set } from 'idb-keyval';
set('hello', 'world')
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
get('hello').then((val) => console.log(val));
浏览器直接引入 <script src="https://cdn.jsdelivr.net/npm/idb-keyval@6/dist/umd.js"></script>
暴露的全局变量是 idbKeyval
直接使用即可。
提供的方法
由于其没有中文的官网,会把例子及自己的理解附上
set 设置数据
值可以是 数字、数组、对象、日期、Blobs等
,尽管老Edge不支持null。
键可以是数字、字符串、日期
,(IDB也允许这些值的数组,但IE不支持)。
import { set } from 'idb-keyval';
set('hello', 'world')
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
setMany 设置多个数据
一个设置多个值,比一个一个的设置更快
import { set, setMany } from 'idb-keyval';
// 不应该:
Promise.all([set(123, 456), set('hello', 'world')])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
// 这样做更快:
setMany([
[123, 456],
['hello', 'world'],
])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
get 获取数据
如果没有键,那么val
将返回undefined
的。
import { get } from 'idb-keyval';
// logs: "world"
get('hello').then((val) => console.log(val));
getMany 获取多个数据
一次获取多个数据,比一个一个获取数据更快
import { get, getMany } from 'idb-keyval';
// 不应该:
Promise.all([get(123), get('hello')]).then(([firstVal, secondVal]) =>
console.log(firstVal, secondVal),
);
// 这样做更快:
getMany([123, 'hello']).then(([firstVal, secondVal]) =>
console.log(firstVal, secondVal),
);
del 删除数据
根据 key
删除数据
import { del } from 'idb-keyval';
del('hello');
delMany 删除多个数据
一次删除多个键,比一个一个删除要快
import { del, delMany } from 'idb-keyval';
// 不应该:
Promise.all([del(123), del('hello')])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
// 这样做更快:
delMany([123, 'hello'])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
update 排队更新数据,防止由于异步导致数据更新问题
因为 get
与 set
都是异步的使用他们来更新数据可能会存在问题如:
// Don't do this:
import { get, set } from 'idb-keyval';
get('counter').then((val) =>
set('counter', (val || 0) + 1);
);
get('counter').then((val) =>
set('counter', (val || 0) + 1);
);
上述代码我们期望的是 2
但实际结果是 1
,我们可以在第一个回调执行第二次操作。
更好的方法是使用 update
来更新数据
// Instead:
import { update } from 'idb-keyval';
update('counter', (val) => (val || 0) + 1);
update('counter', (val) => (val || 0) + 1);
将自动排队更新,所以第一次更新将计数器设置为1
,第二次更新将其设置为2
。
clear 清除所有数据
import { clear } from 'idb-keyval';
clear();
entries 返回 [key, value]
形式的数据
import { entries } from 'idb-keyval';
// logs: [[123, 456], ['hello', 'world']]
entries().then((entries) => console.log(entries));
keys 获取所有数据的 key
import { keys } from 'idb-keyval';
// logs: [123, 'hello']
keys().then((keys) => console.log(keys));
values 获取所有数据 value
import { values } from 'idb-keyval';
// logs: [456, 'world']
values().then((values) => console.log(values));
createStore 自定义仓库
文字解释:表 === store === 商店 一个意思
// 自定义数据库名称及表名称
// 创建一个数据库: 数据库名称为 tang_shi, 表名为 table1
const tang_shi_table1 = idbKeyval.createStore('tang_shi', 'table1')
// 向对应仓库添加数据
idbKeyval.set('add', 'table1 的数据', tang_shi_table1)
// 默认创建的仓库名称为 keyval-store 表名为 keyval
idbKeyval.set('add', '默认的数据')
使用 createStore
创建的数据库一个库只会创建一个表即:
// 同一个库有不可以有两个表,custom-store-2 不会创建成功:
const customStore = createStore('custom-db-name', 'custom-store-name');
const customStore2 = createStore('custom-db-name', 'custom-store-2');
// 不同的库 有相同的表名 这是可以的:
const customStore3 = createStore('db3', 'keyval');
const customStore4 = createStore('db4', 'keyval');
promisifyRequest
自己管理定制商店,这个没搞太明白,看文档中说既然都用到这个了不如直接使用idb 这个库
总结
本文介绍了两个 IndexedDB
的库,用来解决 localStorage
存储容量太小的问题
localforage
与 idb-keyval
之间我更喜欢 localforage
因为其与 localStorage
相似的api几乎没有上手成本。
如果需要更加灵活的库可以看一下 dexie.js、PouchDB、idb、JsStore 或者 lovefield 之类的库
感谢观看!
作者:唐诗
来源:juejin.cn/post/7163075131261059086
Next.js 和 React 到底该选哪一个?
这篇文章将从流行度、性能、文档生态等方面对next.js 和 react 做一个简单的比较。我们那可以根据正在构建的应用的规模和预期用途,选择相应开发框架。
web技术在不断发展变化,js的生态系统也在不断的更新迭代,相应的React和Next也不断变化。
作为前端开发人员,可能我们的项目中已经使用了react, 或者我们可能考虑在下一个项目中使用next.js。理解这两个东西之间的关系或者异同点,可以帮助我们作出更好的选择。
React
按照官方文档的解释:
React是一个声明性、高效且灵活的JavaScript库,用于构建用户界面。它允许我们从称为“组件”的代码片段组成复杂的UI。
React的主要概念是虚拟DOM
,虚拟的dom对象保存在内存中,并通过ReactDOM
等js库与真实DOM同步。
使用React我们可以进行单页程序、移动端程序和服务器渲染等应用程序的开发。
但是,React通常只关心状态管理以及如何将状态呈现到DOM,因此创建React应用程序时通常需要使用额外的库进行路由,以及某些客户端功能。
Next.js
维基百科对Next.js的解释:
Next.js是一个由Vercel
创建的开源web开发框架,支持基于React的web应用程序进行服务器端渲染并生成静态网站。
Next.js提供了一个生产环境需要的所有特性的最佳开发体验:前端静态模版、服务器渲染、支持TypeScript、智能绑定、预获取路由等,同时也不需要进行配置。
React 的文档中将Next.js列为推荐的工具,建议用Next.js+Node.js 进行服务端渲染的开发。
Next.js的主要特性是:使用服务器端渲染来减轻web浏览器的负担,同时一定程度上增强了客户端的安全性。它使用基于页面的路由以方便开发人员,并支持动态路由。
其他功能包括:模块热更新、代码自动拆分,仅加载页面所需的代码、页面预获取,以减少加载时间。
Next.js还支持增量静态再生和静态站点生成。网站的编译版本通常在构建期间构建,并保存为.next文件夹。当用户发出请求时,预构建版本(静态HTML页面)将被缓存并发送给他们。这使得加载时间非常快,但这并不适用于所有的网站,比如经常更改内容且使用有大量用户输入交互的网站。
Next.js vs React
我们可以简单做个比较:
Next.js | React |
---|---|
Next 是 React 的一个框架 | React 是一个库 |
可以配置需要的所有内容 | 不可配置 |
客户端渲染 & 服务端渲染 而为人们所知 | - |
构件web应用速度非常快 | 构建速度相对较慢 |
会react上手非常快 | 上手稍显困难 |
社区小而精 | 非常庞大的社区生态 |
对SEO 优化较好 | 需要做些支持SEO 优化的配置 |
不支持离线应用 | 支持离线应用 |
利弊分析
在看了上面的比较之后,我们可能对应该选择哪个框架有一些自己的想法。
React的优势:
易学易用
使用虚拟DOM
可复用组件
可以做SEO优化
提供了扩展能力
需要较好的抽象能力
强有力的社区
丰富的插件资源
提供了debug工具
React的劣势:
发展速度快
缺少较好的文档
sdk更新滞后
Next.js的优势:
提供了图片优化功能
支持国际化
0配置
编译速度快
即支持静态站也可以进行服务端渲染
API 路由
内置CSS
支持TypeScript
seo友好
Next.js的劣势:
缺少插件生态
缺少状态管理
相对来说是一个比较固定的框架
选 Next.js 还是 React ?
这个不太好直接下结论,因为React是一个用于构建UI的库,而Next是一个基于React构建整个应用程序的框架。
React有时比Next更合适,但是有时候Next比React更合适。
当我们需要很多动态路由,或者需要支持离线应用,或者我们对jsx非常熟悉的时候,我们就可以选择React进行开发。
当我们需要一个各方面功能都很全面的框架时,或者需要进行服务端渲染时,我们就可以使用next.js进行开发。
最后
虽然React很受欢迎,但是Nextjs提供了服务器端渲染、非常快的页面加载速度、SEO功能、基于文件的路由、API路由,以及许多独特的现成特性,使其在许多情况下都是一种非常方便的选择。
虽然我们可以使用React达到同样的目的,但是需要自己去熟悉各种配置,配置的过程有时候也是一件非常繁琐的事情。
作者:前端那些年
来源:juejin.cn/post/7163660046734196744
用了这个设计模式,我优化了50%表单校验代码
表单校验
背景
假设我们正在编写一个注册页面,在点击注册按钮之时,有如下几条校验逻辑:
用户名不能为空
密码长度不能少于6位
手机号码必须符合格式
常规写法:
const form = document.getElementById('registerForm');
form.onsubmit = function () {
if (form.userName.value === '') {
alert('用户名不能为空');
return false;
}
if (form.password.value.length < 6) {
alert('密码长度不能少于6位');
return false;
}
if (!/^1[3|5|8][0-9]{9}$/.test(form.phoneNumber.value)) {
alert('手机号码格式不正确');
return false;
}
...
}
这是一种很常见的代码编写方式,但它有许多缺点:
onsubmit
函数比较庞大,包含了很多if-else
语句,这些语句需要覆盖所有的校验规则。onsubmit
函数缺乏弹性,如果增加了一种新的校验规则,或者想把密码的长度从6改成8,我们都必须深入obsubmit
函数的内部实现,这是违反开放-封闭原则
的。算法的复用性差,如果在项目中增加了另外一个表单,这个表单也需要进行一些类似的校验,我们很可能将这些校验逻辑复制得漫天遍野。
如何避免上述缺陷,更优雅地实现表单校验呢?
策略模式介绍
💡 策略模式是一种行为设计模式, 它能让你定义一系列算法, 把它们一个个封装起来, 并使它们可以相互替换。
真实世界类比
此图源自 refactoringguru.cn/design-patt…
假如你需要前往机场。 你可以选择骑自行车、乘坐大巴或搭出租车。这三种出行策略就是广义上的“算法”,它们都能让你从家里出发到机场。你无需深入它们的内部实现细节,如怎么开大巴、公路系统如何确保你家到机场有通路等。你只需要了解这些策略的各自特点:所需要花费的时间与金钱,你就可以根据预算和时间等因素来选择其中一种策略。
更广义的“算法”
在实际开发中,我们通常会把算法的含义扩散开来,使策略模式也可以用来封装一系列的“业务规则”。只要这些业务规则指向的目标一致,并且可以被替换使用,我们就可以用策略模式来封装它们。
策略模式的组成
一个策略模式至少由两部分组成。
第一个部分是一组策略类,策略类封装了具体的算法,并负责具体的计算过程。
第二个部分是环境类 Context,Context 接受客户的请求,随后把请求委托给某一个策略类。
利用策略模式改写
定义规则(策略),封装表单校验逻辑:
const strategies = {
isNonEmpty: function (value, errMsg) {
if (value === '') {
return errMsg;
}
},
minLenth: function (value, length, errMsg) {
if (value.length < length) {
return errMsg;
}
},
isMobile: function (value, errMsg) {
if (!/^1[3|5|8][0-9]{9}$/.test(value)) {
return errMsg;
}
}
}
定义环境类 Context,进行表单校验,调用策略:
form.onsubmit = function () {
const validator = new Validator();
validator.add(form.userName, 'isNonEmpty', '用户名不能为空');
validator.add(form.password, 'minLength:6', '密码长度不能少于6位');
validator.add(form.phoneNumber, 'isMobile', '手机号码格式不正确');
const errMsg = validator.start();
if (errMsg) {
alert(errMsg);
return false;
}
}
Validator 类代码如下:
class Validator {
constructor() {
this.cache = [];
}
add(dom, rule, errMsg) {
const arr = rule.split(':');
this.cache.push(() => {
const strategy = arr.shift();
arr.unshift(dom.value);
arr.push(errMsg);
return strategies[strategy].apply(dom, arr);
})
}
start() {
for (let i = 0; i < this.cache.length; i++) {
const msg = this.cache[i]();
if (msg) return msg;
}
}
}
使用策略模式重构代码之后,我们消除了原程序中大片的条件分支语句。我们仅仅通过“配置”的方式就可以完成一个表单校验,这些校验规则也能在程序中任何地方复用,还能作为插件的形式,方便地移植到其他项目中。
策略模式优缺点
优点:
可以有效地避免多重条件选择语句。
对
开放-封闭原则
完美支持,将算法封装在独立的 strategy 中,使得它们易于切换,易于理解,易于扩展。可以使算法复用在系统的其他地方,避免许多重复的复制粘贴工作。
缺点:
使用策略模式会在程序中增加许多策略类或策略对象
要使用策略模式,必须了解所有的 strategy,了解它们的不同点,我们才能选择一个合适的 strategy。这是违反
最少知识原则
的。
策略模式适合应用场景
💡 当你想使用对象中各种不同的算法变体, 并希望能在运行时切换算法时, 可使用策略模式。
策略模式让你能够将对象关联至可以不同方式执行特定子任务的不同子对象, 从而以间接方式在运行时更改对象行为。
💡 当你有许多仅在执行某些行为时略有不同的相似类时, 可使用策略模式。
策略模式让你能将不同行为抽取到一个独立类层次结构中, 并将原始类组合成同一个, 从而减少重复代码。
💡 如果算法在上下文的逻辑中不是特别重要, 使用该模式能将类的业务逻辑与其算法实现细节隔离开来。
策略模式让你能将各种算法的代码、 内部数据和依赖关系与其他代码隔离开来。 不同客户端可通过一个简单接口执行算法, 并能在运行时进行切换。
💡 当类中使用了复杂条件运算符以在同一算法的不同变体中切换时, 可使用该模式。
策略模式将所有继承自同样接口的算法抽取到独立类中, 因此不再需要条件语句。 原始对象并不实现所有算法的变体, 而是将执行工作委派给其中的一个独立算法对象。
总结
在上述例子中,使用策略模式虽然使得程序中多了许多策略对象和执行策略的代码。但这些代码可以在应用中任意位置的表单复用,使得整个程序代码量大幅减少,且易维护。下次面对多表单校验的需求时,别再傻傻写一堆 if-else
逻辑啦,快试试策略模式!
引用资料
作者:前端唯一深情
来源:juejin.cn/post/7069395092036911140
一个瞬间让你的代码量暴增的脚本
1 功能概述
在某些特殊情况下,需要凑齐一定的代码量,或者一定的提交次数,为了应急不得不采用一些非常规的手段来保证达标。本文分享的是一段自动提交代码的脚本,用于凑齐code review流程数量,将单次code review代码修改行数拉下来(备注:如果git开启自动生成code review流程,则每次push操作就会自动生成一次code review流程)。
2 友情提示
本脚本仅用于特殊应急场景下,平时开发中还是老老实实敲代码。
重要的事情说三遍:
千万不要在工作中使用、千万不要在工作中使用、千万不要在工作中使用
3 实现思路
3.1 准备示例代码
可以多准备一些样例代码,然后随机取用, 效果会更好。例如:
需要确保示例代码是有效的代码,有些项目可能有eslint检查,如果格式不对可能导致无法自动提交
function huisu(value, index, len, arr, current) {
if (index >= len) {
if (value === 8) {
console.log('suu', current)
}
console.log('suu', current)
return
}
for (let i = index; i < len; i++) {
current.push(arr[i])
console.log('suu', current)
if (value + arr[i] === 8) {
console.log('结果', current)
return
}
huisu(value + arr[i], i + 1, len, arr, [...current])
console.log('suu', value)
current.pop()
onsole.log('suu', current)
}
}
3.2、准备一堆文件名
准备一堆文件名,用于生成新的问题,如果想偷懒,直接随机生成问题也不大。例如:
// 实现准备好的文件名称,随机也可以
const JS_NAMES = ['index.js', 'main.js', 'code.js', 'app.js', 'visitor.js', 'detail.js', 'warning.js', 'product.js', 'comment.js', 'awenk.js', 'test.js'];
3.3 生成待提交的文件
这一步策略也很简单,就是根据指定代码输出文件夹内已有的文件数量,来决定是要执行新增文件还是删除文件
if (codeFiles.length > MIN_COUNT) {
rmFile(codeFiles);
} else {
createFile(codeDir);
}
【新增文件】
根据前面两步准备的示例代码和文件命名,随机获取文件名和代码段,然后创建新文件
// 创建新的代码文件
function createFile(codeDir) {
const ran = Math.floor(Math.random() * JS_NAMES.length);
const name = JS_NAMES[ran];
const filePath = `${codeDir}/${name}`;
const content = getCode();
writeFile(filePath, content);
}
【删除文件】
这一步比较简单,直接随机删除一个就行了
// 随机删除一个文件
function rmFile(codeFiles) {
const ran = Math.floor(Math.random() * codeFiles.length);
const filePath = codeFiles[ran];
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (e) {
console.error('removeFile', e);
}
}
3.4 准备commit信息
这一步怎么简单怎么来,直接准备一堆,然后随机取一个就可以了
const msgs = ['feat:消息处理', 'feat:详情修改', 'fix: 交互优化', 'feat:新增渠道', 'config修改'];
const ran = Math.floor(Math.random() * msgs.length);
console.log(`${msgs[ran]}--测试提交,请直接通过`);
3.5 扩大增幅
上述步骤执行一次可能不太够,咱们可以循环多来几次。随机生成一个数字,用来控制循环的次数
const ran = Math.max(3, parseInt(Math.random() * 10, 10));
console.log(ran);
3.6 组合脚本
组合上述步骤,利用shell脚本执行git提交,详细代码如下:
#! /bin/bash
git pull
cd $(dirname $0)
# 执行次数
count=$(node ./commit/ran.js)
echo $count
# 循环执行
for((i=0;i<$count;i++))
do
node ./commit/code.js
git add .
msg=$(node ./commit/msg.js)
git commit -m "$msg"
git push
done
总结
总的来就就是利用shell脚本执行git命令,随机生成代码或者删除代码之后执行commit提交,最后push推送到远程服务器。
源码
欢迎有需要的朋友取用,《源码传送门》
作者:先秦剑仙
来源:juejin.cn/post/7160649931928109092
入坑两个月自研创业公司
一、拿 offer
其实入职前,我就感觉到有点不对劲,居然要自带电脑。而且人事是周六打电话发的 offer!自己多年的工作经验,讲道理不应该入这种坑,还是因为手里没粮心中慌,工作时间长的社会人,还是不要脱产考研、考公,疫情期间更是如此,本来预定 2 月公务员面试,结果一直拖到 7 月。
二、入职工作
刚入职工作时,一是有些抗拒,二呢是有些欣喜。抗拒是因为长时间呆家的惯性,以及人的惰性,我这只是呆家五个月,那些呆家一年两年的,再进入社会,真的很难,首先心理上他们就要克服自己的惰性和惯性,平时生活习惯也要发生改变
三、人言可畏
刚入职工作时,有工作几个月的老员工和我说,前公司的种种恶心人的操作,后面呢我也确实见识到了:无故扣绩效,让员工重新签署劳动协议,但是,也有很多不符实的,比如公司在搞幺蛾子的时候,居然传出来我被劝退了……
四、为什么离开
最主要的原因肯定还是因为发不出工资,打工是为了赚钱,你想白嫖我?现在公司规模也不算小了,想要缓过来,很难。即便缓过来,以后就不会出现这样的状况了?公司之前也出现过类似的状况,挺过来的老员工们我也没看到有什么优待,所以这家公司不值得我去熬。技术方面我也基本掌握了微信和支付宝小程序开发,后面不过是需求迭代。个人成长方面,虽然我现在是前端部门经理,但前端组跑的最快,可以预料后面我将面临无人可用的局面,我离职的第二天,又一名前端离职了,约等于光杆司令,没意义。
五、收获
1. 不要脱产,不要脱产
2. 使用 uniapp 进行微信和支付宝小程序开发
3. 工作离家近真的很爽
4. 作为技术人员,只要你的上司技术还行,你的工期他是能正常估算,有什么难点说出来,只要不是借口,他也能理解,同时,是借口他也能一下识别出来,比如,一个前端和我说:“后端需求不停调整,所以没做好。” 问他具体哪些调整要两个星期?他又说不出来。这个借口就不要用了,但是我也要走了,我也没必要去得罪他。
5. 进公司前,搞清楚公司目前是盈利还是靠融资活,靠融资活的创业公司有风险…
六、未来规划
关于下一份工作:
南京真是外包之城,找了两周只有外包能满足我目前 18k 的薪资,还有一家还降价了 500…
目前 offer 有
vivo 外包,20k
美的外包,17.5k
自研中小企业,18.5k
虽然美的外包薪资最低,但我可能还是偏向于美的外包。原因有以下几点:
1. 全球手机出货量下降,南京的华为外包被裁了不少,很难说以后 vivo 会不会也裁。
2. 美的目前是中国家电行业的龙头老大,遥遥领先第二名,目前在大力发展 b2c 业务,我进去做的也是和商场相关。
3. 美的的办公地点离我家更近些
4. 自研中小企业有上网限制,有过类似经验的开发人,懂得都懂,很难受。
关于考公:
每年 10 月到 12 月准备下,能进就进,不能再在考公上花费太多时间了。
链接:https://juejin.cn/post/7160138475688165389
阿里面试官:请设计一个不能操作DOM和调接口的环境
前言
四面的时候被问到了这个问题,当时第一时间没有反应过来,觉得这个需求好奇特
面试官给了一些提示,我才明白这道题目的意思,最后回答的也是磕磕绊绊
后来花了一些时间整理了下思路,那么如何设计这样的环境呢?
最终实现
实现思路:
1)利用 iframe 创建沙箱,取出其中的原生浏览器全局对象作为沙箱的全局对象
2)设置一个黑名单,若访问黑名单中的变量,则直接报错,实现阻止\隔离的效果
3)在黑名单中添加 document 字段,来实现禁止开发者操作 DOM
4)在黑名单中添加 XMLHttpRequest、fetch、WebSocket 字段,实现禁用原生的方式调用接口
5)若访问当前全局对象中不存在的变量,则直接报错,实现禁用三方库调接口
6)最后还要拦截对 window 对象的访问,防止通过 window.document 来操作 DOM,避免沙箱逃逸
下面聊一聊,为何这样设计,以及中间会遇到什么问题
如何禁止开发者操作 DOM ?
在页面中,可以通过 document 对象来获取 HTML 元素,进行增删改查的 DOM 操作
如何禁止开发者操作 DOM,转化为如何阻止开发者获取 document 对象
1)传统思路
简单粗暴点,直接修改 window.document 的值,让开发者无法获取 document
// 将document设置为null
window.document = null;
// 设置无效,打印结果还是document
console.log(window.document);
// 删除document
delete window.document
// 删除无效,打印结果还是document
console.log(window.document);
好吧,document 修改不了也删除不了🤔
使用 Object.getOwnPropertyDescriptor 查看,会发现 window.document 的 configurable
属性为 false(不可配置的)
Object.getOwnPropertyDescriptor(window, 'document');
// {get: ƒ, set: undefined, enumerable: true, configurable: false}
configurable 决定了是否可以修改属性描述对象,也就是说,configurable为false时,value、writable、enumerable和configurable 都不能被修改,以及无法被删除
此路不通,推倒重来
2)有点高大上的思路
既然 document 对象修改不了,那如果环境中原本就没有 document 对象,是不是就可以实现该需求?
说到环境中没有 document 对象,Web Worker
直呼内行,我曾在《一文彻底了解Web Worker,十万、百万条数据都是弟弟🔥》中聊过如何使用 Web Worker,和对应的特性
并且 Web Worker 更狠,不但没有 document 对象,连 window 对象也没有😂
在worker线程中打印window
onmessage = function (e) {
console.log(window);
postMessage();
};
浏览器直接报错
在 Web Worker 线程的运行环境中无法访问 document 对象,这一条符合当前的需求,但是该环境中能获取 XMLHttpRequest 对象,可以发送 ajax 请求,不符合不能调接口的要求
此路还是不通……😓
如何禁止开发者调接口 ?
常规调接口方式有:
1)原生方式:XMLHttpRequest、fetch、WebSocket、jsonp、form表单
2)三方实现:axios、jquery、request等众多开源库
禁用原生方式调接口的思路:
1)XMLHttpRequest、fetch、WebSocket 这几种情况,可以禁止用户访问这些对象
2)jsonp、form 这两种方式,需要创建script或form标签,依然可以通过禁止开发者操作DOM的方式解决,不需要单独处理
如何禁用三方库调接口呢?
三方库很多,没办法全部列出来,来进行逐一排除
禁止调接口的路好像也被封死了……😰
最终方案:沙箱(Sandbox)
通过上面的分析,传统的思路确实解决不了当前的需求
阻止开发者操作DOM和调接口,沙箱说:这个我熟啊,拦截隔离这类的活,我最拿手了😀
沙箱(Sandbox) 是一种安全机制,为运行中的程序提供隔离环境,通常用于执行未经测试或不受信任的程序或代码,它会为待执行的程序创建一个独立的执行环境,内部程序的执行不会影响到外部程序的运行
前端沙箱的使用场景:
1)Chrome 浏览器打开的每个页面就是一个沙箱,保证彼此独立互不影响
2)执行 jsonp 请求回来的字符串时或引入不知名第三方 JS 库时,可能需要创造一个沙箱来执行这些代码
3)Vue 模板表达式的计算是运行在一个沙箱中,模板字符串中的表达式只能获取部分全局对象,详情见源码
4)微前端框架 qiankun ,为了实现js隔离,在多种场景下均使用了沙箱
沙箱的多种实现方式
先聊下 with 这个关键字:作用在于改变作用域,可以将某个对象添加到作用域链的顶部
with对于沙箱的意义:可以实现所有变量均来自可靠或自主实现的上下文环境,而不会从全局的执行环境中取值,相当于做了一层拦截,实现隔离的效果
简陋的沙箱
题目要求: 实现这样一个沙箱,要求程序中访问的所有变量,均来自可靠或自主实现的上下文环境,而不会从全局的执行环境中取值
举个🌰: ctx
作为执行上下文对象,待执行程序code
可以访问到的变量,必须都来自ctx对象
// ctx 执行上下文对象
const ctx = {
func: variable => {
console.log(variable);
},
foo: "f1"
};
// 待执行程序
const code = `func(foo)`;
沙箱示例:
// 定义全局变量foo
var foo = "foo1";
// 执行上下文对象
const ctx = {
func: variable => {
console.log(variable);
},
foo: "f1"
};
// 非常简陋的沙箱
function veryPoorSandbox(code, ctx) {
// 使用with,将eval函数执行时的执行上下文指定为ctx
with (ctx) {
// eval可以将字符串按js代码执行,如eval('1+2')
eval(code);
}
}
// 待执行程序
const code = `func(foo)`;
veryPoorSandbox(code, ctx);
// 打印结果:"f1",不是最外层的全局变量"foo1"
这个沙箱有一个明显的问题,若提供的ctx上下文对象中,没有找到某个变量时,代码仍会沿着作用域链一层层向上查找
假如上文示例中的 ctx 对象没有设置 foo属性,打印的结果还是外层作用域的foo1
With + Proxy 实现沙箱
题目要求: 希望沙箱中的代码只在手动提供的上下文对象中查找变量,如果上下文对象中不存在该变量,则提示对应的错误
举个🌰: ctx
作为执行上下文对象,待执行程序code
可以访问到的变量,必须都来自ctx对象,如果ctx对象中不存在该变量,直接报错,不再通过作用域链向上查找
实现步骤:
1)使用 Proxy.has()
来拦截 with 代码块中的任意变量的访问
2)设置一个白名单,在白名单内的变量可以正常走作用域链的访问方式,不在白名单内的变量,会继续判断是否存 ctx 对象中,存在则正常访问,不存在则直接报错
3)使用new Function
替代eval,使用 new Function() 运行代码比eval更为好一些,函数的参数提供了清晰的接口来运行代码
沙箱示例:
var foo = "foo1";
// 执行上下文对象
const ctx = {
func: variable => {
console.log(variable);
}
};
// 构造一个 with 来包裹需要执行的代码,返回 with 代码块的一个函数实例
function withedYourCode(code) {
code = "with(shadow) {" + code + "}";
return new Function("shadow", code);
}
// 可访问全局作用域的白名单列表
const access_white_list = ["func"];
// 待执行程序
const code = `func(foo)`;
// 执行上下文对象的代理对象
const ctxProxy = new Proxy(ctx, {
has: (target, prop) => {
// has 可以拦截 with 代码块中任意属性的访问
if (access_white_list.includes(prop)) {
// 在可访问的白名单内,可继续向上查找
return target.hasOwnProperty(prop);
}
if (!target.hasOwnProperty(prop)) {
throw new Error(`Not found - ${prop}!`);
}
return true;
}
});
// 没那么简陋的沙箱
function littlePoorSandbox(code, ctx) {
// 将 this 指向手动构造的全局代理对象
withedYourCode(code).call(ctx, ctx);
}
littlePoorSandbox(code, ctxProxy);
// 执行func(foo),报错: Uncaught Error: Not found - foo!
执行结果:
天然的优质沙箱(iframe)
iframe 标签可以创造一个独立的浏览器原生级别的运行环境,这个环境由浏览器实现了与主环境的隔离
利用 iframe 来实现一个沙箱是目前最方便、简单、安全的方法,可以把 iframe.contentWindow 作为沙箱执行的全局 window 对象
沙箱示例:
// 沙箱全局代理对象类
class SandboxGlobalProxy {
constructor(sharedState) {
// 创建一个 iframe 标签,取出其中的原生浏览器全局对象作为沙箱的全局对象
const iframe = document.createElement("iframe", { url: "about:blank" });
iframe.style.display = "none";
document.body.appendChild(iframe);
// sandboxGlobal作为沙箱运行时的全局对象
const sandboxGlobal = iframe.contentWindow;
return new Proxy(sandboxGlobal, {
has: (target, prop) => {
// has 可以拦截 with 代码块中任意属性的访问
if (sharedState.includes(prop)) {
// 如果属性存在于共享的全局状态中,则让其沿着原型链在外层查找
return false;
}
// 如果没有该属性,直接报错
if (!target.hasOwnProperty(prop)) {
throw new Error(`Not find: ${prop}!`);
}
// 属性存在,返回sandboxGlobal中的值
return true;
}
});
}
}
// 构造一个 with 来包裹需要执行的代码,返回 with 代码块的一个函数实例
function withedYourCode(code) {
code = "with(sandbox) {" + code + "}";
return new Function("sandbox", code);
}
function maybeAvailableSandbox(code, ctx) {
withedYourCode(code).call(ctx, ctx);
}
// 要执行的代码
const code = `
console.log(history == window.history) // false
window.abc = 'sandbox'
Object.prototype.toString = () => {
console.log('Traped!')
}
console.log(window.abc) // sandbox
`;
// sharedGlobal作为与外部执行环境共享的全局对象
// code中获取的history为最外层作用域的history
const sharedGlobal = ["history"];
const globalProxy = new SandboxGlobalProxy(sharedGlobal);
maybeAvailableSandbox(code, globalProxy);
// 对外层的window对象没有影响
console.log(window.abc); // undefined
Object.prototype.toString(); // 并没有打印 Traped
可以看到,沙箱中对window的所有操作,都没有影响到外层的window,实现了隔离的效果😘
需求实现
继续使用上述的 iframe 标签来创建沙箱,代码主要修改点
1)设置 blacklist
黑名单,添加 document、XMLHttpRequest、fetch、WebSocket 来禁止开发者操作DOM和调接口
2)判断要访问的变量,是否在当前环境的 window 对象中,不在的直接报错,实现禁止通过三方库调接口
// 设置黑名单
const blacklist = ['document', 'XMLHttpRequest', 'fetch', 'WebSocket'];
// 黑名单中的变量禁止访问
if (blacklist.includes(prop)) {
throw new Error(`Can't use: ${prop}!`);
}
但有个很严重的漏洞,如果开发者通过 window.document 来获取 document 对象,依然是可以操作 DOM 的😱
需要在黑名单中加入 window 字段,来解决这个沙箱逃逸的漏洞,虽然把 window 加入了黑名单,但 window 上的方法,如 open、close 等,依然是可以正常获取使用的
最终代码:
// 沙箱全局代理对象类
class SandboxGlobalProxy {
constructor(blacklist) {
// 创建一个 iframe 标签,取出其中的原生浏览器全局对象作为沙箱的全局对象
const iframe = document.createElement("iframe", { url: "about:blank" });
iframe.style.display = "none";
document.body.appendChild(iframe);
// 获取当前HTMLIFrameElement的Window对象
const sandboxGlobal = iframe.contentWindow;
return new Proxy(sandboxGlobal, {
// has 可以拦截 with 代码块中任意属性的访问
has: (target, prop) => {
// 黑名单中的变量禁止访问
if (blacklist.includes(prop)) {
throw new Error(`Can't use: ${prop}!`);
}
// sandboxGlobal对象上不存在的属性,直接报错,实现禁用三方库调接口
if (!target.hasOwnProperty(prop)) {
throw new Error(`Not find: ${prop}!`);
}
// 返回true,获取当前提供上下文对象中的变量;如果返回false,会继续向上层作用域链中查找
return true;
}
});
}
}
// 使用with关键字,来改变作用域
function withedYourCode(code) {
code = "with(sandbox) {" + code + "}";
return new Function("sandbox", code);
}
// 将指定的上下文对象,添加到待执行代码作用域的顶部
function makeSandbox(code, ctx) {
withedYourCode(code).call(ctx, ctx);
}
// 待执行的代码code,获取document对象
const code = `console.log(document)`;
// 设置黑名单
// 经过小伙伴的指导,新添加Image字段,禁止使用new Image来调接口
const blacklist = ['window', 'document', 'XMLHttpRequest', 'fetch', 'WebSocket', 'Image'];
// 将globalProxy对象,添加到新环境作用域链的顶部
const globalProxy = new SandboxGlobalProxy(blacklist);
makeSandbox(code, globalProxy);
打印结果:
持续优化
经过与评论区小伙伴的交流,可以通过 new Image()
调接口,确实是个漏洞
// 不需要创建DOM 发送图片请求
let img = new Image();
img.src= "http://www.test.com/img.gif";
黑名单中添加'Image'字段,堵上这个漏洞。如果还有其他漏洞,欢迎交流讨论💕
总结
通过解决面试官提出的问题,介绍了沙箱的基本概念、应用场景,以及如何去实现符合要求的沙箱,发现防止沙箱逃逸是一件挺有趣的事情,就像双方在下棋一样,你来我往,有攻有守😄
关于这个问题,小伙伴们如果有其他可行的方案,或者有要补充、指正的,欢迎交流讨论
参考资料:
浅析 JavaScript 沙箱机制
作者:海阔_天空
来源:juejin.cn/post/7157570429928865828
后端一次给你10万条数据,如何优雅展示,到底考察我什么
前言
大家好,我是林三心,用最通俗的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心,今天跟大家来唠唠嗑,如果后端真的返回给前端10万条数据,咱们前端要怎么优雅地展示出来呢?(哈哈假设后端真的能传10万条数据到前端)
前置工作
先把前置工作给做好,后面才能进行测试
后端搭建
新建一个server.js
文件,简单起个服务,并返回给前端10w
条数据,并通过nodemon server.js
开启服务
没有安装
nodemon
的同学可以先全局安装npm i nodemon -g
// server.js
const http = require('http')
const port = 8000;
http.createServer(function (req, res) {
// 开启Cors
res.writeHead(200, {
//设置允许跨域的域名,也可设置*允许所有域名
'Access-Control-Allow-Origin': '*',
//跨域允许的请求方法,也可设置*允许所有方法
"Access-Control-Allow-Methods": "DELETE,PUT,POST,GET,OPTIONS",
//允许的header类型
'Access-Control-Allow-Headers': 'Content-Type'
})
let list = []
let num = 0
// 生成10万条数据的list
for (let i = 0; i < 100000; i++) {
num++
list.push({
src: 'https://p3-passport.byteacctimg.com/img/user-avatar/d71c38d1682c543b33f8d716b3b734ca~300x300.image',
text: `我是${num}号嘉宾林三心`,
tid: num
})
}
res.end(JSON.stringify(list));
}).listen(port, function () {
console.log('server is listening on port ' + port);
})
前端页面
先新建一个index.html
// index.html
// 样式
<style>
* {
padding: 0;
margin: 0;
}
#container {
height: 100vh;
overflow: auto;
}
.sunshine {
display: flex;
padding: 10px;
}
img {
width: 150px;
height: 150px;
}
</style>
// html部分
<body>
<div id="container">
</div>
<script src="./index.js"></script>
</body>
然后新建一个index.js
文件,封装一个AJAX
函数,用来请求这10w
条数据
// index.js
// 请求函数
const getList = () => {
return new Promise((resolve, reject) => {
//步骤一:创建异步对象
var ajax = new XMLHttpRequest();
//步骤二:设置请求的url参数,参数一是请求的类型,参数二是请求的url,可以带参数
ajax.open('get', 'http://127.0.0.1:8000');
//步骤三:发送请求
ajax.send();
//步骤四:注册事件 onreadystatechange 状态改变就会调用
ajax.onreadystatechange = function () {
if (ajax.readyState == 4 && ajax.status == 200) {
//步骤五 如果能够进到这个判断 说明 数据 完美的回来了,并且请求的页面是存在的
resolve(JSON.parse(ajax.responseText))
}
}
})
}
// 获取container对象
const container = document.getElementById('container')
直接渲染
最直接的方式就是直接渲染出来,但是这样的做法肯定是不可取的,因为一次性渲染出10w
个节点,是非常耗时间的,咱们可以来看一下耗时,差不多要消耗12秒
,非常消耗时间
const renderList = async () => {
console.time('列表时间')
const list = await getList()
list.forEach(item => {
const div = document.createElement('div')
div.className = 'sunshine'
div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
container.appendChild(div)
})
console.timeEnd('列表时间')
}
renderList()
setTimeout分页渲染
这个方法就是,把10w
按照每页数量limit
分成总共Math.ceil(total / limit)
页,然后利用setTimeout
,每次渲染1页数据,这样的话,渲染出首页数据的时间大大缩减了
const renderList = async () => {
console.time('列表时间')
const list = await getList()
console.log(list)
const total = list.length
const page = 0
const limit = 200
const totalPage = Math.ceil(total / limit)
const render = (page) => {
if (page >= totalPage) return
setTimeout(() => {
for (let i = page * limit; i < page * limit + limit; i++) {
const item = list[i]
const div = document.createElement('div')
div.className = 'sunshine'
div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
container.appendChild(div)
}
render(page + 1)
}, 0)
}
render(page)
console.timeEnd('列表时间')
}
requestAnimationFrame
使用requestAnimationFrame
代替setTimeout
,减少了重排
的次数,极大提高了性能,建议大家在渲染方面多使用requestAnimationFrame
const renderList = async () => {
console.time('列表时间')
const list = await getList()
console.log(list)
const total = list.length
const page = 0
const limit = 200
const totalPage = Math.ceil(total / limit)
const render = (page) => {
if (page >= totalPage) return
// 使用requestAnimationFrame代替setTimeout
requestAnimationFrame(() => {
for (let i = page * limit; i < page * limit + limit; i++) {
const item = list[i]
const div = document.createElement('div')
div.className = 'sunshine'
div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
container.appendChild(div)
}
render(page + 1)
})
}
render(page)
console.timeEnd('列表时间')
}
文档碎片 + requestAnimationFrame
文档碎片
的好处
1、之前都是每次创建一个
div
标签就appendChild
一次,但是有了文档碎片
可以先把1页的div
标签先放进文档碎片
中,然后一次性appendChild
到container
中,这样减少了appendChild
的次数,极大提高了性能2、页面只会渲染
文档碎片
包裹着的元素,而不会渲染文档碎片
const renderList = async () => {
console.time('列表时间')
const list = await getList()
console.log(list)
const total = list.length
const page = 0
const limit = 200
const totalPage = Math.ceil(total / limit)
const render = (page) => {
if (page >= totalPage) return
requestAnimationFrame(() => {
// 创建一个文档碎片
const fragment = document.createDocumentFragment()
for (let i = page * limit; i < page * limit + limit; i++) {
const item = list[i]
const div = document.createElement('div')
div.className = 'sunshine'
div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
// 先塞进文档碎片
fragment.appendChild(div)
}
// 一次性appendChild
container.appendChild(fragment)
render(page + 1)
})
}
render(page)
console.timeEnd('列表时间')
}
懒加载
为了比较通俗的讲解,咱们启动一个vue
前端项目,后端服务还是开着
其实实现原理很简单,咱们通过一张图来展示,就是在列表尾部放一个空节点blank
,然后先渲染第1页数据,向上滚动,等到blank
出现在视图中,就说明到底了,这时候再加载第二页,往后以此类推。
至于怎么判断blank
出现在视图上,可以使用getBoundingClientRect
方法获取top
属性
IntersectionObserver
性能更好,但是我这里就拿getBoundingClientRect
来举例
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
const getList = () => {
// 跟上面一样的代码
}
const container = ref<HTMLElement>() // container节点
const blank = ref<HTMLElement>() // blank节点
const list = ref<any>([]) // 列表
const page = ref(1) // 当前页数
const limit = 200 // 一页展示
// 最大页数
const maxPage = computed(() => Math.ceil(list.value.length / limit))
// 真实展示的列表
const showList = computed(() => list.value.slice(0, page.value * limit))
const handleScroll = () => {
// 当前页数与最大页数的比较
if (page.value > maxPage.value) return
const clientHeight = container.value?.clientHeight
const blankTop = blank.value?.getBoundingClientRect().top
if (clientHeight === blankTop) {
// blank出现在视图,则当前页数加1
page.value++
}
}
onMounted(async () => {
const res = await getList()
list.value = res
})
</script>
<template>
<div id="container" @scroll="handleScroll" ref="container">
<div class="sunshine" v-for="(item) in showList" :key="item.tid">
<img :src="item.src" />
<span>{{ item.text }}</span>
</div>
<div ref="blank"></div>
</div>
</template>
结语
如果你觉得此文对你有一丁点帮助,点个赞,鼓励一下林三心哈哈。
作者:Sunshine_Lin
来源:juejin.cn/post/7031923575044964389
我的灿烂前端人生
本人是 95 前端菜鸟一枚,目前在广州打工混口饭吃。刚好换了工作,感觉生活节奏变得慢了下来,打了这么多年工总觉得想纪录些什么,怕以后自己老了忘记自己还有这么一些风流往事。书接上回
公司太子
北京时间 18 点 50 分,离下班时间还有十分钟,本该是令人愉悦的时刻,我心里的雾霾又浓郁了一分。因为我在公司当太子
当了大半年了。
能力出众
遥想今年年初,领着上家公司大礼包四处求职碰壁,踏破铁鞋寻寻觅觅,靠着投机取巧的八股文背诵,终于求得广州一家高大上小企业
公司的岗位。入职不到一周立刻加入新的项目团队,做一个抽奖小程序,技术栈是 typescript+taro
,我之前没有深入开发过,十分的开心,又可以边工作边学习了。花费三个多月,与团队之间不断擦枪走火,这个项目也是勉强完成,开发完成之余,我有空也加入了测试大军,生怕自己第一个项目上线后因为自己的 bug 造成毁灭的影响毕竟以前经常发生
。万万没想到,这个项目最终没有落地,老板总结就是我们做的打不过别人竞品,没啥创新,让团队去搞商城小程序去了。我万分失落惊喜
,心里想着这样岂不是等于我做了三个月的项目稳定在线上运行,没有bug,不会被用户投诉,也不会被影响绩效,安稳白嫖三个月薪资?
美滋滋!。
度过三个月的试用期,因为项目线上无 bug,能力出众,我也如愿以偿拿下转正。
虚空需求
完成了上一任务,接下来 leader 给我分配了一个大 project,重构以前管理后台的权限。这波重构任务,是 leader 直接文字需求下达指令了,我有点头皮发麻,好几年没遇到这种需求了,真的是梦回 S1 赛季,本来和我合作的小伙伴说他要做个原型出来,结果因为分配任务我负责管理后台前端,他负责管理后台 nodejs 的代码,他也就没有做出来,让原型图随风而去,跟我说了句一把梭
。我也想一把梭,但我发现 leader 的需求十分灵性
,加之我对之前的业务也不熟悉,想着还是花点时间加班把原型图做一下吧。
我战战兢兢的把原型图发到群里,leader 已读并回了没啥问题了,可以开工。我悬着的心放了下来,撸起袖子大胆干。说实话,我心里其实很慌的,首先对 React+Typescript
不熟悉,且这套管理后台十分深奥,用的是自研的核心框架,各种 typescript abstract
抽象类,复杂的类型泛型,对我这个半吊子前端还是比较吃力的。但好在我是拷贝忍者,写业务代码先找下之前代码是怎么写的,CCCV,改个英文单词,就是我的杰作
。
TX leader 真的很严格
我的 leader 是腾讯大厂出来的,我也是打心底里对他有一丝敬畏,毕竟大厂大佬恐怖如斯,技术水平肯定不是我这种切图仔比拟的。
任务花费了三周多一点,包含联调自测,自测完后就提个 MQ
上去了,信心十足。万万没想到,leaderCode Review
对着我的杰作
一顿输出,大概有二十几个修改建议,我都有仔细去看,发现很多都是代码规范,代码优化,leader 都给了一定的建议。说实话,一开始我的心里多多少少有些芥蒂,但是谁让别人是领导呢?开个玩笑
。但是 leader 指出来的问题的确是不容忽视的,程序员就是要有更好的追求,其实有人把问题指出来,才是对我最大的帮助,我也是花了不少时间去更改这些问题。下面就放一些 bad code
出来献丑。
之前一直想不明白,传进来的组件是在 children 里面,我如何去改变组件的点击函数,想来想去想不懂,脑门一热直接在组件上加一层蒙层,通过蒙层阻碍组件点击,当时设计完出来我还挺高兴,leader 也直呼天才,送了我两个字 ———— 重做
因为我技术能力确实平庸,只能请教我的良师百度
,不断去寻找 children 是否有什么方法或钩子处理事件,功夫不负有心人,果真被我找到了。下面就是修改后的方法
// after
return permission ? children : React.Children.map(children, child => React.cloneElement(child as React.ReactElement, { onClick: () => { message.error('无权限'); } }));
ps:leader 也勉为其难的接受这个方法,可能他不知道有什么更好的方法。如果观众大佬们知道,可以提下意见,不胜感激。
设计组织架构图
先让大伙看看原来的功能图吧,之后我们开了一个会议,这里要重做。
我心想我发原型图出来的时候,大佬您可是没有半个不字,怎么 codereview 直接改了一个方向了啊?
不过,毕竟他是我的 leader,我的生死全由他掌控
,我也不敢多言,上网找了一个 npm 库 react-organizational-chart
。react 的社区就是强~下面是更改后的视图
不得不说,的确是更饱满更清晰直观了一些,leader 还是很有远见的怕他也上掘金,吹了再说
。
这个项目陆陆续续做了三个月了,因为 leader 平时也很忙,两个城市飞,导致这个项目的进度也进展缓慢,而我就在空闲时间上上掘金学习技术,刷刷 leetcode。
来了大半年,我深刻明白我对公司的建设为 0,所做项目为公司带来 0 收入,就是我的价值完全没有体现,公司把我当太子
养了大半年,我非常感谢公司。然后每天都会浏览 boss 直聘,深怕下午就被拉进小黑屋,在这个大环境下,我也时刻准备着,毕竟也有前车之鉴,我明白我只是个平庸的程序员,只能尽力做好自己的本分,随时做好最坏的打算,当真正的打击来临之时,我也不会手忙脚乱。
灿烂?摆烂!
最近 IT 的 HRBP 要我一个新入职的去做一场技术分享,我在这里呆了大半年,没有等来其他前端大佬的分享,竟然是要我亲自上阵,小丑竟是我自己
。
空虚寂寞冷
回想了一下这六个月,其实自己的水平真的没有半点进步,我想不到有什么可以拿来分享的。而且从入职以来,我在这个公司说的话可能没有超过 100句
,其实有时我也纳闷,我印象中自己不是一个这么闷的一个人,在上家公司我吹 * 技术游走于天地之间,能很好的融入团队,并能展开身心为其奋斗前期战神,后期老油条
。但是来了新公司之后,我只会干完手头上的活,也没有跟其他同事聊聊天,不过我附近的同事也极少聊天,感觉稍微有点死气沉沉。
以前年轻的时候,看到一些新入职的同事,闷葫芦一个,找他搭话或者说骚话,他都没啥兴趣,现在的我,好像成为了自己以前眼中的怪人
。我苦思久已,只能得出几个结论,第一点可能是我以前投入太多,经历过分离,不想再投入更多的感情,投入的越深,离开时就越痛 1000-7=? 痛,太痛了
。第二点是因为现在的大环境,让我精神焦虑,我深怕我和某位同事今天刚去饭堂吃个饭,明天人就没了。想看我之前为啥被裁,可以看我往期文章。
不过,我觉得出来工作,重点是挣钱,以这个为核心,其他一切都是空谈。而且,解决我的聊天需求还有一大神器,不是陌陌,而是网易狼人杀APP
。自从入职新公司以来,每天下班回到家根本不想学习,不想运动,只想躺着,然后冲进大师场厮杀,里面个个都是人才,说话又好听,我喜欢这个游戏,因为它能锻炼提高我的骗人能力当然是表达能力啦!
而且它还夹杂着些许人性的味道,人性的魅力也让我欲罢不能。网易打钱
。所以要我分享,我真不知道分享什么,难道分享如何悍跳吃警徽,狼查杀狼打板子做高狼同伴身份?
保持平常心
最终 leader 让我去分享一下这个重构项目,我想了一下也可以,其实它不是一次分享,可以把它当做一次项目复盘,把自己的问题抛出来给到大家欣赏
,虽然有点丢人,但是赚钱嘛,不寒碜
。而且自己的技术也拉胯,可以让自己加深这些问题的印象,对自己成长的路也是有极大帮助的。
不止是大环境,最近社会也出现了许多光怪陆离的事情,心态也有些许变化,我不再绞尽脑汁去想着如何跳槽获得高薪,我只想取悦自己,做自己认为让自己开心而正确的事情,心累了就去外面走走,馋了就去吃点美食,觉得知识匮乏了就化身小厂做题家
刷刷 leetcode,看看别人的源码见解虽然多数都看不懂
。偶尔什么都想学,什么都学不进去的时候,也会焦虑,解决焦虑的办法,我常常是...... 奖励自己
。
当下所面临的的困难、焦虑,都会被时间而抚平,我作为一个平庸程序员,面对每天新开始的人生,我只能对自己说一句,啊,又是新的一天
。
来源:稀土掘金
组员大眼瞪小眼,forEach 处理异步任务遇到的坑
一位组员遇到一个问题,几个同事都没能帮忙解决,我在这边就开门见山直接描述当时他遇到的问题。他在 forEach 处理了异步,但是始终不能顺序执行,至此想要的数据怎么都拿不到,组员绞尽脑汁,不知道问题发生在哪里。此篇文章我们就来探究下 forEach 循环下处理异步会发生什么样的情况。
探索
我们先看一段简单的 forEach 处理异步的代码
//forEach 处理
let promiseTasek = (num) => {
return new Promise((resolve, rejece) => {
setTimeout(() => {
console.log(num)
resolve(true)
}, 4000)
})
}
function toTaskByForEach() {
const arr = [1, 2, 3, 4, 5, 6]
arr.forEach(async (item) => {
await promiseTasek(item)
})
}
toTaskByForEach()
执行结果 注意执行输出的变化,他会直接打印出 1,2,3,4,5,6 本来想录制一个 gif 的,确实没找到一个好的工具录制浏览器的控制台
我们尝试换一种循环 for of 看一下效果对比一下
let promiseTasek = (num) => {
return new Promise((resolve, rejece) => {
setTimeout(() => {
console.log(num)
resolve(true)
}, 4000)
})
}
async function toTaskByForOf(){
const arr = [1,2,3,4,5,6]
for (let i of arr) {
await promiseTasek(i)
}
}
toTaskByForOf()
来看下执行结果 他会按顺序执行依次打印出 1,2,3,4,5,6
所以这是为啥呢
后来我们研究了一下 map
//forEach 处理
let promiseTasek = (num) => {
return new Promise((resolve, rejece) => {
setTimeout(() => {
console.log(num)
resolve(true)
}, 4000)
})
}
function toTaskByMap() {
const arr = [1, 2, 3, 4, 5, 6]
arr.map(async (item) => {
await promiseTasek(item)
})
}
toTaskByMap()
输出结果和 forEach 一样
后来我们发现 Array.prototype.forEach 不是一个 async 函数,即使 Array.prototype.forEach 的参数 callback 是 async 函数,也暂停不了 Array.prototype.forEach 函数,map 也是同理
await Promise.all(arr.map(async (item) => { /** ... */ }))
Vue.js 3 开源组件推荐:代码差异查看器插件
一个Vue.js差异查看器插件,可以用来比较两个代码片断之间的差异。
Github地址:github.com/hoiheart/vu…
支持语言:
css
xml: xml, html, xhtml, rss, atom, xjb, xsd, xsl, plist, svg
markdown: markdown, md, mkdown, mkd
javascript: javascript, js, jsx
json
plaintext: plaintext, txt, text
typescript: typescript, ts
如何使用:
导入并注册diff查看器。
import VueDiff from 'vue-diff'
import 'vue-diff/dist/index.css'
app.use(VueDiff);
2.向模板中添加组件。
<Diff />
3.可用的组件props。
mode: {
type: String as PropType<Mode>,
default: 'split' // or unified
},
theme: {
type: String as PropType<Theme>,
default: 'dark' // or light
},
language: {
type: String,
default: 'plaintext'
},
prev: {
type: String,
default: ''
},
current: {
type: String,
default: ''
},
inputDelay: {
type: Number,
default: 0
},
virtualScroll: {
type: [Boolean, Object] as PropType<boolean|VirtualScroll>,
default: false
}
4.使用 highlight.js 扩展插件。
// 注册一门新语言
import yaml from 'highlight.js/lib/languages/yaml'
VueDiff.hljs.registerLanguage('yaml', yaml)
作者:杭州程序员张张
来源:juejin.cn/post/7156839676677423112
图片不压缩,前端要背锅
背景
🎨(美术): 这是这次需求的切图 📁 ,你看看有没问题?
🧑💻(前端): 好的。
页面上线 ...
🧑💼(产品): 这图片怎么半天加载不出来 💢 ?
🧑💻(前端): 我看看 🤔 (卑微)。
... 📁(size: 15MB)
🧑💻(前端): 😅。
很多时候,我们从 PS
、蓝湖
或摹客
等工具导出来的图片,或者是美术直接给到切图,都是未经过压缩的,体积都比较大。这里,就有了可优化的空间。
TinyPng
TinyPNG
使用智能的「有损压缩技术」来减少WEBP
、JPEG
和PNG
文件的文件大小。通过选择性地减少图像中的「颜色数量」,使用更少的字节来存储数据。这种效果几乎是看不见的,但在文件大小上有非常大的差别。
使用过TinyPng的都知道,它的压缩效果非常好,体积大幅度降低且显示效果几乎没有区别( 👀 看不出区别)。因此,选择其作为压缩工具,是一个不错的选择。
TinyPng
提供两种压缩方法:
通过在官网上进行手动压缩;
通过官方提供的
tinify
进行压缩;
身为一个程序员 🧑💻 ,是不能接受手动一张张上传压缩这种方法的。因此,选择第二种方法,通过封装一个工具,对项目内的图片自动压缩,彻底释放双手 🤲 。
工具类型
第一步,思考这个工具的「目的」是什么?没错,「压缩图片」。
第二步,思考在哪个「环节」进行压缩?没错,「发布前」。
这样看来,开发一个webpack plugin
是一个不错选择,在打包「生产环境」代码的时候,启用该plugin
对图片进行处理,完美 🥳 !
但是,这样会面临两个问题 🤔 :
页面迭代,新增了几张图片,重新打包上线时,会导致旧图片被多次压缩;
无法选择哪些图片要被压缩,哪些图片不被压缩;
虽然可以通过「配置」的方式解决上述问题,但每次打包都要特殊配置,略显麻烦,这样看来plugin
好像不是最好的选择。
以上两个问题,使用「命令行工具」就能完美解决。在打包「生产环境」代码之前,执行「压缩命令」,通过命令行交互,选择需要压缩的图片。
效果演示
话不多说,先上才艺 💃 !
安装
$ npm i yx-tiny -D
使用
$ npx tiny
根据命令行提示输入
流程:输入「文件夹名称-tinyImg
」,接着工具会找到当前项目下所有的tinyImg
,接着选择一或多个tinyImg
,紧接着,工具会找出tinyImg
下所有的png
、jpe?g
和svga
,最后选择压缩模式「全量」或「自定义」,选择需要压缩的图片。
从最后的输出结果可以看到,压缩前的资源体积为2.64MB
,压缩后体积为1.02MB
,足足压缩了1.62MB
👍 !
实现思路
总体分为五个过程:
查找:找出所有的图片资源;
分配:均分任务到每个进程;
上传:把原图上传到
TinyPng
;下载:从
TinyPng
中下载压缩好的图片;写入:用下载的图片覆盖本地图片;
项目地址:yx-tiny
查找
找出所有的图片资源。
packages/tiny/src/index.ts
/**
* 递归找出所有图片
* @param { string } path
* @returns { Array<imageType> }
*/
interface IdeepFindImg {
(path: string): Array<imageType>
}
let deepFindImg: IdeepFindImg
deepFindImg = (path: string) => {
// 读取文件夹的内容
const content = fs.readdirSync(path)
// 用于保存发现的图片
let images: Array<imageType> = []
// 遍历该文件夹内容
content.forEach(folder => {
const filePath = resolve(path, folder)
// 获取当前内容的语法信息
const info = fs.statSync(filePath)
// 当前内容为“文件夹”
if (info.isDirectory()) {
// 对该文件夹进行递归操作
images = [...images, ...deepFindImg(filePath)]
} else {
const fileNameReg = /\.(jpe?g|png|svga)$/
const shouldFormat = fileNameReg.test(filePath)
// 判断当前内容的路径是否包含图片格式
if (shouldFormat) {
// 读取图片内容保存到images
const imgData = fs.readFileSync(filePath)
images.push({
path: filePath,
file: imgData
})
}
}
})
return images
}
通过命令行交互后,拿到目标文件夹的路径path
,然后获取该path
下的所有内容,接着遍历所有内容。首先判断该内容的文件信息:若为“文件夹”,则把该文件夹路径作为path
,递归调用deepFindImg
;若不为“文件夹”,判断该内容为图片,则读取图片数据,push
到images
中。最后,返回所有找到的图片。
分配
均分任务到每个进程。
packages/tiny/src/index.ts
// ...
cluster.setupPrimary({
exec: resolve(__dirname, 'features/process.js')
})
// 若资源数小于则创建一个进程,否则创建多个进程
const works: Array<{
work: Worker;
tasks: Array<imageType>
}> =[]
if (list.length <= cpuNums) {
works.push({
work: cluster.fork(),
tasks: list
})
} else {
for (let i = 0; i < cpuNums; ++i) {
const work = cluster.fork()
works.push({
work,
tasks: []
})
}
}
// 平均分配任务
let workNum = 0
list.forEach(task = >{
if (works.length === 1) {
return
} else if (workNum >= works.length) {
works[0].tasks.push(task)
workNum = 1
} else {
works[workNum].tasks.push(task)
workNum += 1
}
})
// 用于记录进程完成数
let pageNum = works.length
// 初始化进度条
// ...
works.forEach(({
work,
tasks
}) = >{
// 发送任务到每个进程
work.send(tasks)
// 接收任务完成
work.on('message', (details: Idetail[]) = >{
// 更新进度条
// ...
pageNum--
// 所有任务执行完毕
if (pageNum === 0) {
// 关闭进程
cluster.disconnect()
}
})
})
使用cluster
,根据「cpu核心数」创建等量的进程,works
用于保存已创建的进程,list
中保存的是要处理的压缩任务,通过遍历list
,把任务依次分给每一个进程。接着遍历works
,通过send
方法发送进程任务。通过监听message
事件,利用pageNum
记录进程任务的完成情况,当所有进程任务执行完毕后,则关闭进程。
上传
官方提供的tinify
工具有「500张/月」的限额,超过限额后,需要付费。
由于家境贫寒,且出于学习的目的,就没有使用tinify
,而是通过构造随机IP
来直接请求「压缩接口」来达到「破解限额」的目的。大家在真正使用的时候,还是要使用tinyfy
来压缩,不要做这种投机取巧的事。
好了,回到正文。
把原图上传到TinyPng
。
packages/tiny/src/features/index.ts
/**
* 上传函数
* @param { Buffer } file 文件buffer数据
* @returns { Promise<DataUploadType> }
*/
interface Iupload {
(file: Buffer): Promise<DataUploadType>
}
export let upload: Iupload
upload = (file: Buffer) => {
// 生成随机请求头
const header = randomHeader()
return new Promise((resolve, reject) => {
const req = Https.request(header, res => {
res.on('data', data => {
try {
const resp = JSON.parse(data.toString()) as DataUploadType
if (resp.error) {
reject(resp)
} else {
resolve(resp)
}
} catch (err) {
reject(err)
}
})
})
// 上传图片buffer
req.write(file)
req.on('error', err => reject(err))
req.end()
})
}
使用node
自带的Https
模块,构造请求头,把deepFindImg
中返回的图片进行上传。上传成功后,会返回已经压缩好的图片的url
链接。
下载
从TinyPng
中下载压缩好的图片。
packages/tiny/src/features/index.ts
/**
* 下载函数
* @param { string } path
* @returns { Promise<string> }
*/
interface Idownload {
(path: string): Promise<string>
}
export let download: Idownload
download = (path: string) => {
const header = new Url.URL(path)
return new Promise((resolve, reject) => {
const req = Https.request(header, res => {
let content = ''
res.setEncoding('binary')
res.on('data', data => (content += data))
res.on('end', () => resolve(content))
})
req.on('error', err => reject(err))
req.end()
})
}
使用node
自带的Https
模块把upload
中返回的图片链接进行下载。下载成功后,返回图片的buffer
数据。
写入
把下载好的图片覆盖本地图片。
packages/tiny/src/features/process.ts
/**
* 接收进程任务
*/
process.on('message', (tasks: imageType[]) => {
;(async () => {
// 优化 png/jpg
const data = tasks
.filter(({ path }: { path: string }) => /\.(jpe?g|png)$/.test(path))
.map(ele => {
return compressImg({ ...ele, file: Buffer.from(ele.file) })
})
// 优化 svga
const svgaData = tasks
.filter(({ path }: { path: string }) => /\.(svga)$/.test(path))
.map(ele => {
return compressSvga(ele.path, Buffer.from(ele.file))
})
const details = await Promise.all([
...data.map(fn => fn()),
...svgaData.map(fn => fn())
])
// 写入
await Promise.all(
details.map(
({ path, file }) =>
new Promise((resolve, reject) => {
fs.writeFile(path, file, err => {
if (err) reject(err)
resolve(true)
})
})
)
)
// 发送结果
if (process.send) {
process.send(details)
}
})()
})
process.on
监听每个进程发送的任务,当接收到任务类型为「图片」,使用compressImg
方法来处理图片。当任务类型为「svga」,使用compressSvga
方法来处理svga
。最后把处理好的资源写入到本地覆盖旧资源。
compressImg
packages/tiny/src/features/process.ts
/**
* 压缩图片
* @param { imageType } 图片资源
* @returns { promise<Idetail> }
*/
interface IcompressImg {
(payload: imageType): () => Promise<Idetail>
}
let compressImg: IcompressImg
compressImg = ({ path, file }: imageType) => {
return async () => {
const result = {
input: 0,
output: 0,
ratio: 0,
path,
file,
msg: ''
}
try {
// 上传
const dataUpload = await upload(file)
// 下载
const dataDownload = await download(dataUpload.output.url)
result.input = dataUpload.input.size
result.output = dataUpload.output.size
result.ratio = 1 - dataUpload.output.ratio
result.file = Buffer.alloc(dataDownload.length, dataDownload, 'binary')
} catch (err) {
result.msg = `[${chalk.blue(path)}] ${chalk.red(JSON.stringify(err))}`
}
return result
}
}
compressImg
返回一个async
函数,该函数先调用upload
进行图片上传,接着调用download
进行下载,最终返回该图片的buffer
数据。
compressSvga
packages/tiny/src/features/process.ts
/**
* 压缩svga
* @param { string } path 路径
* @param { buffer } source svga buffer
* @returns { promise<Idetail> }
*/
interface IcompressSvga {
(path: string, source: Buffer): () => Promise<Idetail>
}
let compressSvga: IcompressSvga
compressSvga = (path, source) => {
return async () => {
const result = {
input: 0,
output: 0,
ratio: 0,
path,
file: source,
msg: ''
}
try {
// 解析svga
const data = ProtoMovieEntity.decode(
pako.inflate(toArrayBuffer(source))
) as unknown as IsvgaData
const { images } = data
const list = Object.keys(images).map(path => {
return compressImg({ path, file: toBuffer(images[path]) })
})
// 对svga图片进行压缩
const detail = await Promise.all(list.map(fn => fn()))
detail.forEach(({ path, file }) => {
data.images[path] = file
})
// 压缩buffer
const file = pako.deflate(
toArrayBuffer(ProtoMovieEntity.encode(data).finish() as Buffer)
)
result.input = source.length
result.output = file.length
result.ratio = 1 - file.length / source.length
result.file = file
} catch (err) {
result.msg = `[${chalk.blue(path)}] ${chalk.red(JSON.stringify(err))}`
}
return result
}
}
compressSvga
的「输入」、「输出」和compressImg
保持一致,目的是为了可以使用promise.all
同时调用。在compressSvga
内部,对svga
进行解析成data
,获取到svga
的图片列表images
,接着调用compressImg
对images
进行压缩,使用压缩后的图片覆盖data.images
,最后再把data
编码后,写入到本地覆盖原本的svga
。
最后
再说一遍,大家真正使用的时候,要使用官方的tinify
进行压缩。
参考文章:
祝大家生活愉快,工作顺利!
作者:JustCarryOn
链接:juejin.cn/post/7153086294409609229
学长突然问我用过 Symbol 吗,我哽咽住了(准备挨骂)
这天在实验室和学长一起写学校的项目,学长突然问我一句:“你用过 Symbol 吗?” 然而我的大脑却遍历不出这个关键性名词,啊,又要补漏了
Symbol
对于一些前端小白(比如我)来讲,没有特别使用过,只是在学习 JS 的时候了解了大概的概念,当时学习可能并没有感觉到 Symbol
在开发中有什么特别的作用,而在学习一段时间后回头看一遍,顿悟!
而本文将带读者从基本使用,特性应用到内置 Symbol 三个方面,带大家深入 Symbol
这个神奇的类型!
什么是 Symbol
😶🌫️
Symbol
作为原始数据类型的一种,表示独一无二的值,在之前,对象的键以字符串的形式存在,所以极易引发键名冲突问题,而 Symbol
的出现正是解决了这个痛点,它的使用方式也很简单。
Symbol
的使用
创建一个 Symbol
与创建 Object
不同,只需要 a = Symbol()
即可
let a = Symbol()
typeof a
使用时需要注意的是:不可以使用 new
来搭配 Symbol()
构造实例,因为其会抛出错误
let a = new Symbol()
typeof a // Symbol is not a constructor
通常使用 new
来构造是想要得到一个包装对象,而 Symbol
不允许这么做,那么如果我们想要得到一个 Symbol()
的对象形式,可以使用 Object()
函数
let a = Symbol()
let b = Object(a)
typeof b // object
介绍到这里,问题来了,Symbol
看起来都一样,我们怎么区分呢?我们需要传入一个字符串的参数用来描述 Symbol()
let a = Symbol()
let b = Symbol()
上面看来 a
和 b
的值都是 Symbol
,代码阅读上,两者没区分,那么我们调用 Symbol()
函数的时候传入字符串用来描述我们构建的 Symbol()
let a = Symbol("a")
let b = Symbol("b")
Symbol 的应用✌️
Symbol 的应用其实利用了唯一性的特性。
作为对象的属性
大家有没有想过,如果我们在不了解一个对象的时候,想为其添加一个方法或者属性,又怕键名重复引起覆盖的问题,而这个时候我们就需要一个唯一性的键来解决这个问题,于是 Symbol 出场了,它可以作为对象的属性的键,并键名避免冲突。
let a = Symbol()
let obj = {}
obj[a] = "hello world"
我在上面创建了一个 symbol
作为键的对象,其步骤如下
创建一个 Symbol
创建一个对象
通过 obj[]
将 Symbol
作为对象的键
值得注意的是我们无法使用.
来调用对象的 Symbol
属性,所以必须使用 []
来访问 Symbol
属性
降低代码耦合
我们经常会遇到这种代码
if (name === "猪痞恶霸") {
console.log(1)
}
又或者
switch (name) {
case "猪痞恶霸"
console.log(1)
case "Ned"
console.log(2)
}
"猪痞恶霸"
与 "Ned"
被称为魔术字符串,即与代码强耦合的字符串,可以理解为:与我们的程序代码强制绑定在一起,然而这会导致一个问题,在条件判断复杂的情况下,我们想要更改我们的判断条件,就需要更改每一个判断控制,维护起来非常麻烦,所以我们可以换一种形式来解决字符串与代码强耦合。const judge = {
name_1:"猪痞恶霸"
name_2:"Ned"
}
switch (name) {
case judge.name_1
console.log(1)
case judge.name_2
console.log(2)
}
我们声明了一个存储判断条件字符串的对象,通过修改对象来自如地控制判断条件,当然本小节的主题是 Symbol
,所以还能继续优化!
const judge = {
rectangle:Symbol("rectangle"),
triangle:Symbol("triangle")
}
function getArea(model, size) {
switch (model) {
case judge.rectangle:
return size.width * size.height
case judge.triangle:
return size.width * size.height / 2
}
}
let area = getArea(judge.rectangle ,{width:100, height:200})
console.log(area)
为了更加直观地了解我们优化的过程,上面我创建了一个求面积的工具函数,利用 Symbol
的特性,我们使我们的条件判断更加精确,而如果是字符串形式,没有唯一的特点,可能会出现判断错误的情况。
全局共享 Symbol
如果我们想在不同的地方调用已经同一 Symbol
即全局共享的 Symbol
,可以通过 Symbol.for()
方法,参数为创建时传入的描述字符串,该方法可以遍历全局注册表中的的 Symbol
,当搜索到相同描述,那么会调用这个 Symbol
,如果没有搜索到,就会创建一个新的 Symbol
。
为了更好地理解,请看下面例子
let a = Symbol.for("a")
let b = Symbol.for("a")
a === b // true
如上创建 Symbol
首先通过 Symbol.for()
在全局注册表中寻找描述为 a
的 Symbol
,而目前没有符合条件的 Symbol
,所以创建了一个描述为 a
的 Symbol
当声明 b
并使用 Symbol.for()
在全局注册表中寻找描述为 a
的 Symbol
,找到并赋值
比较 a
与 b
结果为 true
反映了 Symbol.for()
的作用
let a = Symbol("a")
let b = Symbol.for("a")
a === b // false
woc,结果竟然是 false
,与上面的区别仅仅在于第一个 Symbol
的创建方式,带着惊讶的表情,来一步一步分析一下为什么会出现这样的结果、
使用 Symbol("a")
直接创建,所以该 Symbol("a")
不在全局注册表中
使用 Symbol.for("a")
在全局注册表中寻找描述为 a
的 Symbol
,并没有找到,所以在全局注册表中又创建了一个描述为 a
的新的 Symbol
秉承 Symbol
创建的唯一特性,所以 a
与 b
创建的 Symbol
不同,结果为 false
问题又又又来了!我们如何去判断我们的 Symbol
是否在全局注册表中呢?
Symbol.keyFor()
帮我们解决了这个问题,他可以通过变量名查询该变量名对应的 Symbol
是否在全局注册表中
let a = Symbol("a")
let b = Symbol.for("a")
Symbol.keyFor(a) // undefined
Symbol.keyFor(b) // 'a'
如果查询存在即返回该 Symbol
的描述,如果不存在则返回 undefined
以上通过使用 Symbol.for()
实现了 Symbol
全局共享,下面我们来看看 Symbol
的另一种应用
内置 Symbol
值又是什么❔
上面的 Symbol
使用是我们自定义的,而 JS 有内置了 Symbol
值,个人的理解为:由于唯一性特点,在对象内,作为一个唯一性的键并对应着一个方法,在对象调用某方法的时候会调用这个 Symbol
值对应的方法,并且我们还可以通过更改内置 Symbol
值对应的方法来达到更改外部方法作用的效果。
为了更好地理解上面这一大段话,咱们以 Symbol.hasInstance
作为例子来看看内置 Symbol
到底是个啥!
class demo {
static [Symbol.hasInstance](item) {
return item === "猪痞恶霸"
}
}
"猪痞恶霸" instanceof demo // true
Symbol.hasInstance
对应的外部方法是 instanceof
,这个大家熟悉吧,经常用于判断类型。而在上面的代码片段中,我创建了一个 demo
类,并重写了 Symbol.hasInstance
,所以其对应的 instanceof
行为也会发生改变,其内部的机制是这样的:当我们调用 instanceof
方法的时候,内部对应调用 Symbol.hasInstance
对应的方法即 return item === "猪痞恶霸"
注:更多相关的内置 Symbol
可以查阅相关文档😏
链接:https://juejin.cn/post/7143252808257503240
埋点统计优化,优化首屏加载速度提升
埋点统计
在我们业务里经常有遇到,或者很普遍的,我们自己网站也会加入第三方统计,我们会看到动态加载方式去加载jsdk
,也就是你常常看到的insertBefore
操作,我们很少考虑到为什么这么做,直接同步加载不行吗?统计代码会影响业务首屏加载吗?同步引入方式,当然会,我的业务代码还没加载,首屏就加载一大段统计的jsdk
,在移动端页面打开要求比较高的苛刻条件下,首屏优化,你可以在埋点统计
上做些优化,那么页面加载会有一个很大的提升,本文是一篇笔者关于埋点优化的笔记,希望看完在项目中有所思考和帮助。
正文开始...
最近遇到一个问题,先看一段代码
<!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>埋点</title>
<script>
window.formateJson = (data) => JSON.stringify(data, null, 2);
</script>
<script async defer>
(function (win, head, attr, script) {
console.log("---111---");
win[attr] = win[attr] || [];
const scriptDom = document.createElement(script);
scriptDom.async = true;
scriptDom.defer = true;
scriptDom.src = "./js/tj.js";
scriptDom.onload = function () {
win[attr].push({
id: "maic",
});
win[attr].push({
id: "Tom",
});
console.log("---2222---");
console.log(formateJson(win[attr]));
};
setTimeout(() => {
console.log("setTimeout---444---");
head.parentNode.insertBefore(scriptDom, head);
}, 1000);
})(window, document.getElementsByTagName("head")[0], "actd", "script");
</script>
<script async defer src="./js/app.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
我们会发现,打印的顺序结果是下面这样的:
---111---
app.js:2 ---333--- start load app.js
app.js:4 [
{
"id": "pink"
}
]
(index):30 setTimeout---444---
(index):26 ---2222---
(index):27 [
{
"id": "pink"
},
{
"id": "maic"
},
{
"id": "Tom"
}
]
冥思苦想,我们发现最后actd
的结果是
[
{
"id": "pink"
},
{
"id": "maic"
},
{
"id": "Tom"
}
]
其实我想要的结果是先添加maic
,Tom
,最后添加pink
,需求就是,必须先在这个ts.js
执行后,预先添加基础数据,然后在其他业务app.js
添加其他数据,所以此时,无论如何都是满足不了我的需求。
试下想,为什么没有按照我的预期的要求走,问题就是出现在这个onload
方法上
onload事件
于是查询资料寻得,onload事件
是会等引入的外部资源
加载完毕后才会触发
外部资源加载完毕是什么意思?
举个栗子,我在引入的index2.html
引入index2.js
,然后在引入脚本上写一个onload
事件测试loadIndex2
方法是否在我延时加载后进行调用的
<!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>Document</title>
</head>
<body>
<script>
function loadIndex2() {
console.log("script loader...");
}
</script>
<script src="./js/index2.js" onload="loadIndex2()"></script>
</body>
</html>
index2.js
中写入一段代码
var startTime = Date.now()
const count = 1000;
let wait = 10000;
/// 设置延时
const time = wait * count;
for (let i = 0; i < time; i++) { }
var endTime = Date.now()
console.log(startTime, endTime)
console.log(`延迟了:${Math.ceil((endTime - startTime) / 1000)}s后执行的`)
最后看下打印结果
所以可以证实,onload
是会等资源下载完了后,才会立即触发
所以我们回头来看
在浏览器的事件循环中,同步任务主线程肯定优先会先顺序执行
从打开印---111---
,
然后到onload
此时不会立即执行
遇到定时器,定时器设置了1s
后会执行,是个宏任务,会放入队列中,此时不会立即执行
然后接着会执行<script async defer src="./js/app.js"></script>
脚本
所以此时,执行该脚本后,我们可以看到会先执行push
方法。
所以我们看到pink
就最先被推入数组中,当该脚本执行完毕后,此时会去执行定时器
定时器里我们看到我们插入方式insertBefore
,当插入时成功时,此时会调用onload
方法,所以此时就会添加maic
与Tom
很明显,我们此时的需求不满足我们的要求,而且一个onload
方法已经成了拦路虎
那么我去掉onload
试试,因为onload
方法只会在脚本加载完毕后去执行,他只会等执行定时器后,成功插入脚本后才会真正执行,而此时其他脚本已经优先它的执行了。
那该怎么解决这个问题呢?
我把onload
去掉试试,于是我改成了下面这样
<script async defer>
(function (win, head, attr, script) {
console.log("---111---");
win[attr] = win[attr] || [];
const scriptDom = document.createElement(script);
scriptDom.async = true;
scriptDom.defer = true;
scriptDom.src = "./js/tj.js";
win[attr].push({
id: "maic",
});
win[attr].push({
id: "Tom",
});
console.log("---2222---");
console.log(formateJson(win[attr]));
setTimeout(() => {
console.log("setTimeout---444---");
head.parentNode.insertBefore(scriptDom, head);
}, 1000);
})
(window, document.getElementsByTagName("head")
[0], "actd", "script");
</script>
去掉onload
后,我确实达到了我想要的结果
最后的结果是
[
{
"id": "maic"
},
{
"id": "Tom"
},
{
"id": "pink"
}
]
但是你会发现
我先保证了window.actd
添加了我预定提前添加的基础信息,但是此时,这个脚本并没有真正添加到dom中,我们执行完同步任务后,就会执行app.js
,当1s
后,我才真正执行了这个插入的脚本,而且我统计
脚本你会发现此时是在先执行了app.js
再加载tj.js
的
当执行setTimeout
时,我们会发现先执行了内部脚本,然后才执行打印
<script async defer>
(function (win, head, attr, script) {
console.log("---111---");
win[attr] = win[attr] || [];
const scriptDom = document.createElement(script);
scriptDom.async = true;
scriptDom.defer = true;
scriptDom.src = "./js/tj.js";
win[attr].push({
id: "maic",
});
win[attr].push({
id: "Tom",
});
console.log("---2222---");
console.log(formateJson(win[attr]));
setTimeout(() => {
console.log("setTimeout---444444---");
window.actd.push({
id: "setTimeout",
});
head.parentNode.insertBefore(scriptDom, head);
console.log(formateJson(window.actd));
}, 1000);
})(window, document.getElementsByTagName("head")[0], "actd", "script");
</script>
最后的结果,可以看到是这样的
[
{
"id": "maic"
},
{
"id": "Tom"
},
{
"id": "pink"
},
{
"id": "setTimeout"
}
]
看到这里不知道你心里有没有一个疑问,为什么在动态插入脚本时,我要用一个定时器1s
钟?为什么我需要用insertBefore
这种方式插入脚本?,我同步方式引入不行吗?不要定时器又会有什么样的结果?
我们通常在接入第三方统计时,貌似都是一个这样一个insertBefore
插入的jsdk
方式(但是一般我们都是同步方式引入jsdk
)
没有使用定时器(3237ms
)
<script async defer>
(function (win, head, attr, script) {
...
console.log("setTimeout---444444---");
window.actd.push({
id: "setTimeout",
});
head.parentNode.insertBefore(scriptDom, head);
console.log(formateJson(window.actd));
})(window, document.getElementsByTagName("head")[0], "actd", "script");
</script>
结果:
[
{
"id": "maic"
},
{
"id": "Tom"
},
{
"id": "setTimeout"
},
{
"id": "pink"
},
]
使用用定时器的(1622ms
)
<script async defer>
(function (win, head, attr, script) {
...
setTimeout(() => {
console.log("setTimeout---444444---");
window.actd.push({
id: "setTimeout",
});
head.parentNode.insertBefore(scriptDom, head);
console.log(formateJson(window.actd));
}, 1000);
})(window, document.getElementsByTagName("head")[0], "actd", "script");
</script>
当我们用浏览器的Performance
去比较两组数据时,我们会发现总长时间,使用定时器
的性能大概比没有使用定时器
的性能时间上大概要少50%
,在summary
中所有数据均有显著的提升。
不经感叹,就一个定时器
这一点点的改动,对整个应用提升有这么大的提升,我领导说,快应用在线加载时,之前因为这个统计js的加载明显阻塞了业务页面打开速度,做了这个优化后,打开应用显著提升不少。
我们再继续上一个问题,为什么不同步加载?
我把代码改造一下,去除了一些无关紧要的代码
<!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>js执行的顺序问题</title>
<script>
window.formateJson = (data) => JSON.stringify(data, null, 2);
</script>
<script async defer src="./js/tj.js"></script>
<script async defer>
(function (win, head, attr, script) {
win[attr] = win[attr] || [];
win[attr].push({
id: "maic",
});
win[attr].push({
id: "Tom",
});
console.log("---2222---");
console.log(formateJson(win[attr]));
})(window, document.getElementsByTagName("head")[0], "actd", "script");
</script>
<script async defer src="./js/app.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
结果
[
{
"id": "maic"
},
{
"id": "Tom"
},
{
"id": "pink"
}
]
嘿,需求是达到了,因为我的业务app.js
加的数据是最后一条,说明业务功能上是ok
的,但是我们看下分析数据
首先肯定是加载顺序会发生变化,会先加载tj.js
然后再加载业务app.js
,你会发现同步加载这种方式有个弊端,假设tj.js
很大,那么是会阻塞影响页面首屏打开速度的,所以在之前采用异步,定时器方式,首屏加载会有显著提升。
同步加载(1846ms
)
我们发现tj.js
与app.js
相隔的时间很少,且我们从火焰图中分析看到,Summary
的数据是1846ms
综上比较,虽然同步加载
依然比不上使用定时器
的加载方式,使用定时器
相比较同步加载
,依然是领先11%
左右
异步标识async/defer
在上面的代码中,我们多次看到async
和defer
标识,在之前文章中笔者有写过一篇你真的了解esModule吗,阐述一些关于script
标签中type="moudle", defer,async
的几个标识,今天再次回顾下
其实从脚本优先级来看,同步的永远优先最高,当一个script
标签没有指定任何标识时,此时根据js引擎执行
来说,谁放前面,谁就会优先执行,前面没执行完,后面同步的script
就不会执行
注意到没有,我在脚本上有加async
与defer
在上面栗子中,我们使用insertBefore
方式,这就将该插入的js
脚本的优先级降低了。
我们从上面火焰图中可以分析得处结论,排名先后顺序依次如下
1、setTimeout+insertBefore
执行顺序:app.js->tj.js
2、同步脚本加载
执行顺序:tj.js->app.js
3、不使用定时器+insertBefore
执行顺序:app.js->tj.js
当我们知道在1
中,app.js
优先于tj.js
因为insertBefore
就是一种异步动态加载方式
举个例子
<script async defer>
// 执行
console.log(1)
// 2 insertBefore 这里再动态添加js
</script>
<script async defer>
// 执行
console.log(3)
</script>
执行关系就是1,3,2
关于async
与defer
谁先执行时,defer
的优先级比较低,会等异步标识的async
下载完后立马执行,然后再执行defer
的脚本,具体可以参考以前写的一篇文章你真的了解esModule吗
总结
统计脚本,我们可以使用
定时器+insertBefore
方式可以大大提高首屏的加载速度,这也给我们了一些启发,首屏加载,非业务代码,比如埋点统计
可以使用该方案做一点小优化加快首屏加载速度如果使用
insertBefore
方式,非常不建议同步方式
+insertBefore
,这种方式还不如同步加载统计脚本在特殊场景下,我们需要加载统计脚本,有基础信息的依赖后,我们也需要在业务代码使用统计,我们不要在动态加载脚本的同时使用
onload
,在onload
中尝试添加基础信息,实际上这种方式并不能满足你的需求一些关于
async
与defer
的特性,记住,执行顺序,同步任务会优先执行,async
是异步,脚本下载完就执行,defer
优先级比较低。本文示例code example
作者:Maic
来源:juejin.cn/post/7153216620406505480
一盏茶的功夫,拿捏作用域&作用域链
酸奶喝对,事半功倍!对于一些晦涩难懂,近乎神话的专业名词,切莫抓耳挠腮,我们直接上代码,加上通俗易懂地语言去渲染,且看今天我们如何拿捏javascript中的小山丘--作用域&作用域链,不止精解。
前言
我们需要先知道的是引擎,引擎的工作简单粗暴,就是负责javascript从头到尾代码的执行。引擎的一个好朋友是编译器,主要负责代码的分析和编译等;引擎的另一个好朋友就是今天的主角--作用域。那么作用域用来干什么呢?作用域链跟作用域又有什么关系呢?
一、作用域(scope)
作用域的定义:作用域是在运行时代码中的某些特定部分中变量,函数和对象的可访问性。
1、作用域的分类
(1)全局作用域
var name="global";
function foo(){
console.log(name);
}
foo();//global
这里函数foo()内部并没有声明name变量,但是依然打印了name的值,说明函数内部可以访问到全局作用域,读取name变量。再来一个例子:
hobby='music';
function foo(){
hobby='book';
console.log(hobby);
}
foo();//book
这里全局作用域和函数foo()内部都没有声明hobby这个变量,为什么不会报错呢?这是因为hobby='music';
写在了全局作用域,就算没有var,let,const的声明,也会被挂在window对象上,所以函数foo()不仅可以读取,还可以修改值。也就是说hobby='music';
等价于window.hobby='music';
。
(2)函数体作用域 函数体的作用域是通过隐藏内部实现的。换句话说,就是我们常说的,内层作用域可以访问外层作用域,但是外层作用域不能访问内层。原因,说到作用域链的时候就迎刃而解了。
function foo(){
var age=19;
console.log(age);
}
console.log(age);//ReferenceError:age is not defined
很明显,全局作用域下并没有age变量,但是函数foo()内部有,但是外部访问不到,自然而然就会报错了,而函数foo()没有调用,也就不会执行。
(3)块级作用域 块级作用域更是见怪不怪,像我们接触的let作用域,代码块{},for循环用let时的作用域,if,while,switch等等。然而,更深刻理解块级作用域的前提是,我们需要先认识认识这几个名词:
--标识符:能在作用域生效的变量。函数的参数,变量,函数名。需要格外注意的是:函数体内部的标识符外部访问不到
。
--函数声明:function 函数名(){}
--函数表达式: var 函数名=function(){}
--自执行函数: (function 函数名(){})();自执行函数前面的语句必须有分号
,通常用于隐藏作用域。
接下来我们就用一个例子,一口气展示完吧
function foo(sex){
console.log(sex);
}
var f=function(){
console.log('hello');
}
var height=180;
(
function fn(){
console.log(height);
}
)();
foo('female');
//依次打印:
//180
//female
分析一下:标识符:foo,sex,height,fn;函数声明:function foo(sex){};函数表达式:var f=function(){};自执行函数:(function fn(){})();需要注意,自执行函数fn()前面的var height=180;
语句,分号不能抛弃
。否则,你可以试一下。
二、预编译
说好只是作用域和作用域链的,但是考虑到理解作用域链的必要性,这里还是先聊聊预编译吧。先讨论预编译在不同环境发生的情况下,是如何进行预编译的。
1. 发生在代码执行之前
(1)声明提升
console.log(b);
var b=123;//undefined
这里打印undefined,这不是报错,与Refference:b is not defined不同。这是代码执行之前,预编译的结果,等同于以下代码:
var b;//声明提升
console.log(b);//undefined
b=123;
(2)函数声明整体提升
test();//hello123 调用函数前并没有声明,但是任然打印,是因为函数声明整体提升了
function test(){
var a=123;
console.log('hello'+a);
}
2.发生在函数执行之前
理解这个只需要掌握四部曲
:
(1)创建一个AO(Activation Object)
(2)找形参和变量声明,然后将形参和变量声明作为AO的属性名,属性值为undefined
(3)将实参和形参统一
(4)在函数体内找函数声明,将函数名作为AO对象的属性名,属性值予函数体 那么接下来就放大招了:
var global='window';
function foo(name,sex){
console.log(name);
function name(){};
console.log(name);
var nums=123;
function nums(){};
console.log(nums);
var fn=function(){};
console.log(fn);
}
foo('html');
这里的结果是什么呢?分析如下:
//从上到下
//1、创建一个AO(Activation Object)
AO:{
//2、找形参和变量声明,然后将形参和变量声明作为AO的属性名,属性值为undefined
name:undefined,
sex:undefined,
nums=undefined,
fn:undefined,
//3、将实参和形参统一
name:html,
sex:undefined,
nums=123,
fn:function(){},
//4、在函数体内找函数声明,将函数名作为AO对象的属性名,属性值予函数体
name:function(){},
sex:undefined,
fn:function(){},
nums:123//这里不仅存在nums变量声明,也存在nums函数声明,但是取前者的值
以上步骤得到的值,会按照后面步骤得到的值覆盖前面步骤得到的值
}
//依次打印
//[Function: name]
//[Function: name]
//123
//[Function: fn]
3.发生在全局(内层作用域可以访问外层作用域)
同发生在函数执行前一样,发生在全局的预编译也有自己的三部曲
:
(1)创建GO(Global Object)对象
(2)找全局变量声明,将变量声明作为GO的属性名,属性值为undefined
(3)在全局找函数声明,将函数名作为GO对象的属性名,属性值赋予函数体 举个栗子:
var global='window';
function foo(a){
console.log(a);
console.log(global);
var b;
}
var fn=function(){};
console.log(fn);
foo(123);
console.log(b);
这个例子比较简单,一样的步骤和思路,就不在赘述分析了,相信你已经会了。打印结果依次是:
[Function: fn]
123
window
ReferenceError: b is not defined
好啦,进入正轨,我们接着说作用域链。
三、作用域链
作用域链就可以帮我们找到,为什么内层可以访问到外层,而外层访问不到内层?但是同样的,在认识作用域链之前,我们需要见识见识一些更加晦涩抽象的名词。
执行期上下文
:当函数执行的时候,会创建一个称为执行期上下文的对象(AO对象),一个执行期上下文定义了一个函数执行时的环境。 函数每次执行时,对应的执行上下文都是独一无二的,所以多次调用一个函数会导致创建多个执行期上下文,当函数执行完毕,它所产生的执行期上下文会被销毁。查找变量
:从作用域链的顶端依次往下查找。 3.[[scope]]
:作用域属性,也称为隐式属性,仅支持引擎自己访问。函数作用域,是不可访问的,其中存储了运行期上下文的结合。
我们先看一眼函数的自带属性:
function test(){//函数被创建的那一刻,就携带name,prototype属性
console.log(123);
}
console.log(test.name);//test
console.log(test.prototype);//{} 原型
// console.log(test[[scope]]);访问不到,作用域属性,也称为隐式属性
// test() --->AO:{}执行完毕会回收
// test() --->AO:{}执行完毕会回收
接下来看看作用域链怎么实现的:
var global='window';
function foo(){
function fn(){
var fn=222;
}
var foo=111;
console.log(foo);
}
foo();
分析:
GO:{
foo:function(){}
}
fooAO:{
foo:111,
fn:function(){}
}
fnAO:{
fn:222
}
// foo定义时 foo.[[scope]]---->0:GO{}
// foo执行时 foo.[[scope]]---->0:AO{} 1:GO{} 后访问的在前面
//fn定义时 fn.[[scope]]---->0:fnAO{} 1:fooAO{} 2:GO{}
fnAO:fn的AO对象;fooAO:foo的AO对象
综上而言:作用域链就是[[scope]]中所存储的执行期上下文对象的集合,这个集合呈链式链接,我们把这种链式链接叫做作用域链。
作者:来碗盐焗星球
来源:juejin.cn/post/7116516393100853284
不使用第三方库怎么实现【前端引导页】功能?
前言
随着应用功能越来越多,繁多而详细的功能使用和说明文档,已经不能满足时代追求 快速 的需求,而 引导页(或分步引导) 本质就是 化繁为简,将核心功能以更简单、简短、明了的文字指引用户去使用对应的功能,特别是 ToB
的项目,各种新功能需求迭代非常快,免不了需要 引导页 的功能来快速帮助用户引导。
下面我们通过两个方面来围绕着【前端引导页】进行展开:
哪些第三方库可以直接使用快速实现功能?
如何自己实现前端引导页的功能?
第三方库的选择
如果你不知道如何做技术选型,可以看看 山月大佬 的这一篇文章 在前端中,如何更好地做技术选型?,下面就简单列举几个相关的库进行简单介绍,具体需求具体分析选择,其他和 API
使用、具体实现效果可以通过官方文档或对应的 README.md
进行查看。
vue-tour
vue-tour
是一个轻量级、简单且可自定义的 Tour
插件,配置也算比较简单清晰,但只适用于 Vue2
的项目,具体效果可以直接参考对应的前面链接对应的内容。
driver.js
driver.js
是一个强大而轻量级的普通 JavaScript
引擎,可在整个页面上驱动用户的注意力,只有 4kb
左右的体积,并且没有外部依赖,不仅高度可定制,还可以支持所有主流浏览器。
shepherd.js
shepherd.js
包含的 API
众多,大多场景都可以通过其对应的配置得到,缺点就是整体的包体积较大,并且配置也比较复杂,配置复杂的内容一般都需要进行二次封装,将可变和不可变的配置项进行抽离,具体效果可见其 官方文档。
intro.js
intro.js
是是一个开源的 vanilla Javascript/CSS
库,用于添加分步介绍或提示,大小在 10kB
左右,属于轻量级的且无外部依赖,详情可见 官方文档。
实现引导页功能
引导页核心功能其实就两点:
一是 高亮部分
二是 引导部分
而这两点其实真的不难实现,无非就是 引导部分 跟着 高亮部分 移动,并且添加一些简单的动画或过渡效果即可,也分为 蒙层引导 和 无蒙层引导,这里介绍相对比较复杂的 蒙层引导,下面就简单介绍两种简单的实现方案。
cloneNode + position + transition
核心实现:
高亮部分
通过
el.cloneNode(true)
复制对应目标元素节点,并将克隆节点添加到蒙层上
通过
margin
(或tranlate
、position
等)实现克隆节点的位置与目标节点重合
引导部分 通过
position: fixed
实现定位效果,并通过动态修改left、top
属性实现引导弹窗跟随目标移动过渡动画 通过
transition
实现位置的平滑移动页面 位置/内容 发生变化时(如:
resize、scroll
事件),需要重新计算位置信息
缺点:
目标节点需要被深度复制
不能实现边引导边操作
效果演示:
核心代码:
// 核心配置参数
const selectors = [
{
selector: "#btn1",
message: "点此【新增】数据!",
},
{
selector: "#btn2",
message: "小心【删除】数据!",
},
{
selector: "#btn3",
message: "可通过此按钮【修改】数据!",
},
{
selector: "#btn4",
message: "一键【完成】所有操作!",
},
];
// Guide.vue
<script setup>
import { computed, onMounted, ref } from "vue";
const props = defineProps({
selectors: Array,
});
const guideModalRef = ref(null);
const guideBoxRef = ref(null);
const index = ref(0);
const show = ref(true);
let cloneNode = null;
let currNode = null;
let message = computed(() => {
return props.selectors[index.value]?.message;
});
const genGuide = (hasChange = true) => {
// 前置操作
cloneNode && guideModalRef.value?.removeChild(cloneNode);
// 所有指引完毕
if (index.value > props.selectors.length - 1) {
show.value = false;
return;
}
// 获取目标节点信息
currNode =
currNode || document.querySelector(props.selectors[index.value].selector);
const { x, y, width, height } = currNode.getBoundingClientRect();
// 克隆节点
cloneNode = hasChange ? currNode.cloneNode(true) : cloneNode;
cloneNode.id = currNode.id + "_clone";
cloneNode.style = `
margin-left: ${x}px;
margin-top: ${y}px;
`;
// 指引相关
if (guideBoxRef.value) {
const halfClientHeight = guideBoxRef.value.clientHeight / 2;
guideBoxRef.value.style = `
left:${x + width + 10}px;
top:${y <= halfClientHeight ? y : y - halfClientHeight + height / 2}px;
`;
guideModalRef.value?.appendChild(cloneNode);
}
};
// 页面内容发生变化时,重新计算位置
window.addEventListener("resize", () => genGuide(false));
window.addEventListener("scroll", () => genGuide(false));
// 上一步/下一步
const changeStep = (isPre) => {
isPre ? index.value-- : index.value++;
currNode = null;
genGuide();
};
onMounted(() => {
genGuide();
});
</script>
<template>
<teleport to="body">
<div v-if="show" ref="guideModalRef" class="guide-modal">
<div ref="guideBoxRef" class="guide-box">
<div>{{ message }}</div>
<button class="btn" :disabled="index === 0" @click="changeStep(true)">
上一步
</button>
<button class="btn" @click="changeStep(false)">下一步</button>
</div>
</div>
</teleport>
</template>
<style scoped>
.guide-modal {
position: fixed;
z-index: 999;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
}
.guide-box {
width: 150px;
min-height: 10px;
border-radius: 5px;
background-color: #fff;
position: absolute;
transition: 0.5s;
padding: 10px;
text-align: center;
}
.btn {
margin: 20px 5px 5px 5px;
}
</style>
z-index + position + transition
核心实现:
高亮部分 通过控制
z-index
的值,让目标元素展示在蒙层之上引导部分 通过
position: fixed
实现定位效果,并通过动态修改left、top
属性实现引导弹窗跟随目标移动过渡动画 通过
transition
实现位置的平滑移动页面 位置/内容 发生变化时(如:
resize、scroll
事件),需要重新计算位置信息
缺点:
当目标元素的父元素
position: fixed | absolute | sticky
时,目标元素的z-index
无法超过蒙版层(可参考shepherd.js
的svg
解决方案)
效果演示:
核心代码:
<script setup>
import { computed, onMounted, ref } from "vue";
const props = defineProps({
selectors: Array,
});
const guideModalRef = ref(null);
const guideBoxRef = ref(null);
const index = ref(0);
const show = ref(true);
let preNode = null;
let message = computed(() => {
return props.selectors[index.value]?.message;
});
const genGuide = (hasChange = true) => {
// 所有指引完毕
if (index.value > props.selectors.length - 1) {
show.value = false;
return;
}
// 修改上一个节点的 z-index
if (preNode) preNode.style = `z-index: 0;`;
// 获取目标节点信息
const target =
preNode = document.querySelector(props.selectors[index.value].selector);
target.style = `
position: relative;
z-index: 1000;
`;
const { x, y, width, height } = target.getBoundingClientRect();
// 指引相关
if (guideBoxRef.value) {
const halfClientHeight = guideBoxRef.value.clientHeight / 2;
guideBoxRef.value.style = `
left:${x + width + 10}px;
top:${y <= halfClientHeight ? y : y - halfClientHeight + height / 2}px;
`;
}
};
// 页面内容发生变化时,重新计算位置
window.addEventListener("resize", () => genGuide(false));
window.addEventListener("scroll", () => genGuide(false));
const changeStep = (isPre) => {
isPre ? index.value-- : index.value++;
genGuide();
};
onMounted(() => {
genGuide();
});
</script>
<template>
<teleport to="body">
<div v-if="show" ref="guideModalRef" class="guide-modal">
<div ref="guideBoxRef" class="guide-box">
<div>{{ message }}</div>
<button class="btn" :disabled="index === 0" @click="changeStep(true)">
上一步
</button>
<button class="btn" @click="changeStep(false)">下一步</button>
</div>
</div>
</teleport>
</template>
<style scoped>
.guide-modal {
position: fixed;
z-index: 999;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
}
.guide-box {
width: 150px;
min-height: 10px;
border-radius: 5px;
background-color: #fff;
position: absolute;
transition: 0.5s;
padding: 10px;
text-align: center;
}
.btn {
margin: 20px 5px 5px 5px;
}
</style>
【扩展】SVG
如何完美解决 z-index
失效的问题?
这里以 shepherd.js
来举例说明,先来看起官方文档展示的 demo
效果:
在上述展示的效果中进行了一些验证:
正常点击
NEXT
进入下一步指引,仔细观察SVG
相关数据发生了变化等到指引部分指向代码块的内容区时,复制了此时
SVG
中和path
相关的参数返回到第一步很明显此时的高亮部分高度较小,将上一步复制的参数直接替换当前
SVG
中和path
相关的参数,此时发现整体SVG
高亮内容宽高发生了变化
核心结论:通过 SVG
可编码的特点,利用 SVG
来实现蒙版效果,并且在绘制蒙版时,预留出目标元素的高亮区间(即 SVG
不需要绘制这一部分),这样就解决了使用 z-index
可能会失效的问题。
最后
以上就是一些简单实现,但还有很多细节需要考虑,比如:边引导边操作的实现、定位原因导致的图层展示问题等仍需要优化。
相信大部分人第一直觉是:直接使用第三方库实现功能就好了呀,自己实现功能不全、也未必好用,属实没有必要。
对于这一点其实在早前看到的一句话说的挺好:了解底层实现原理比使用库本身更有意义,当然每个人的想法不同,不过如果你想开始了解原理又不能立马挑战一些高深的内容,为什么不先从自己感兴趣的又不是那么复杂的功能开始呢?
作者:熊的猫
来源:juejin.cn/post/7142633594882621454
一组纯CSS开发的聊天背景图,帮助避免发错消息的尴尬
我与好友的故事
我好友,人美心善,就是做事有点小迷糊。这不,她最近好几次差点消息发错群。主要是群太多,不好区分。
于是,我准备想个法子,省得她一不小心,变成大型社死现场。
2小时之后
来自网友的智慧
网友提供了一组聊天背景图,右上是群分类,几种分类,我挑了三个很适合好友的:交流群、工作群、摸鱼群。
文字在图片右侧,自己没发言,就能很清楚的看到文字。还有一群可爱的小动物,为背景图增加了一丝趣味。
一组聊天背景图
上效果
先来看最终实现的效果
一张背景图
从上面的代码展示中不难发现,整个背景图左侧是很空旷的。因为群聊里,一般其他人的发言在屏幕的左侧,自己的发言在右侧,所以没有发言之前,可以很清晰的看到右侧的背景信息。而背景图的右上角是当前群的类型名,基本打开群聊,一眼就发现背景图上的文字了。
垂直书写模式
文字的垂直书写模式是通过CSS提供的writing-mode实现的。
writing-mode定义了文本在水平或垂直方向上如何排布。
以下知识点来自菜鸟教程
参数 | 描述 |
---|---|
horizontal-tb | 水平方向自上而下的书写方式。即 left-right-top-bottom |
vertical-rl | 垂直方向自右而左的书写方式。即 top-bottom-right-left |
vertical-lr | 垂直方向内内容从上到下,水平方向从左到右 |
sideways-rl | 内容垂直方向从上到下排列 |
sideways-lr | 内容垂直方向从下到上排列 |
背景图中文字的效果就是为文本设置了writing-mode属性值为vertical-rl。
.chat-title {
writing-mode: vertical-rl;
font-size: 32px;
font-weight: 600;
position: absolute;
top: 80px;
right: 0;
}
一组卡通形象
文字下面是一组可爱的卡通形象。我摸了摸下巴,感觉是可以用CSS实现的。
小鸡 🐤
小鸡图形由这以下部分组成:
头、一只眼睛、嘴巴、左手臂、右手臂
基本都是用圆和椭圆组成的,整体色调是黄色的,除了鼻子设计成了橘色,基本没有什么实现难度。
注:温馨提示,如果有四肢的卡通形象,如果后面没有遮挡物,最好把身体画出来。
熊猫 🐼
熊猫图形由这以下部分组成:
头、脸、左眼睛、右眼睛、左腮红、右腮红、鼻子、嘴巴、左耳朵
除了嘴巴基本都是用圆和椭圆组成的,整体色调是黑、白色,除了腮红设计成了粉色,基本没有什么实现难度。
说说嘴巴的实现吧。
一些卡通形象或者颜文字中,会有向下的尖括号代表嘴巴,比如(╥╯^╰╥)、(〒︿〒)、╭(╯^╰)╮。一般表示不开心或者傲娇。而这里的熊猫整体是有些高冷的,所以嘴巴没有设计成小羊或者青蛙那样张开的。
这种类型的嘴巴用CSS实现很简单,有几种方式,我一般是用两个直线,结合定位+旋转实现。
.panda-mouth {
width: 3px;
height: 5px;
background: #000001;
border-radius: 2px;
position: absolute;
top: 19px;
z-index: 199;
}
.panda-mouth-left {
left: 16px;
transform: rotate(20deg);
}
.panda-mouth-right {
left: 20px;
transform: rotate(-30deg);
}
<div class="panda-mouth panda-mouth-left"></div>
<div class="panda-mouth panda-mouth-right"></div>
青蛙 🐸
青蛙图形由这以下部分组成:
头、左眼睛、右眼睛、鼻子、嘴巴、舌头、左手臂
基本都是用圆和椭圆组成的,整体色调是黑、白、绿色,除了舌头设计成了粉色,基本没有什么实现难度。
小羊 🐑
小羊图形由这以下部分组成:
头、脸、右眼睛、嘴巴、舌头、耳朵
基本都是用圆和椭圆组成的,整体色调是黑、白色,舌头和腮红是粉色,基本没有什么实现难度。
介绍一下耳朵的实现。
一般羊的耳朵尖而长,是耷拉在脑袋两侧的,所以这里也是这样设计的,因为小羊是侧颜,所以只需要实现一只耳朵即可。因为耳朵也是白色的,所以要展示一部分颜色深的地方好和头进行区分。
这样实现方式就有很多了,加阴影啦,使用两层元素啦,伪元素啦,都可以,我这里用了伪元素实现的。
.sheep-ear {
position: absolute;
width: 20px;
height: 40px;
border-radius: 100%;
background: #10140a;
top: 8px;
right: 5px;
transform: rotate(6deg);
}
.sheep-ear::before {
content: '';
width: 20px;
height: 39px;
border-radius: 100%;
background: #fff;
position: absolute;
top: -1px;
left: 1px;
z-index: 199;
}
<div class='sheep-ear'></div>
比啾
这个卡通形象眼熟,但是叫不上来名字,所以我给它起名叫“比啾”。(因为罗小黑里有一个比丢也很可爱)
比啾图形由这以下部分组成:
头、脸、左眼睛、右眼睛、左腮红、右腮红、鼻子。左耳朵、右耳朵
基本都是用圆和椭圆组成的,整体色调是黑、粉色,脸是藕色,基本没有什么实现难度。
一组背景图
不同类型群组的背景图,除了名字不同,卡通的顺序也适当的做了调整,避免看错群。
注入灵魂
背景图是静态的,但是我们的页面可以是动起来的。所以我为背景图注入了一丝灵动。
三个心,有间隔的从第一个玩偶边上飞出来,飞一段时间消失。
我基本实现心形都是中间一个矩形、两边各一个圆形。
飞出来和消失使用animation动画实现,因为三颗心路径是一致的,所以需要设置间隔时间,否则就会重叠成一个。
.chat-heart {
position: absolute;
left: 200px;
top: 200px;
}
.heart {
position: absolute;
width: 20px;
height: 20px;
background-color: #e64356;
opacity: 0;
top: 6px;
left: 45px;
}
.heart:before,
.heart:after {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #e64356;
}
.heart:after {
bottom: 0px;
left: -53%;
}
.heart:before {
top: -53%;
right: 0px;
transform: rotate(45deg);
}
.heart1 {
animation: heartfly 2s ease-out infinite 0.5s;
}
.heart2 {
animation: heartfly 2s ease-out infinite 1s;
}
.heart3 {
animation: heartfly 2s ease-out infinite 1.5s;
}
@keyframes heartfly {
70% {
opacity: 1;
}
100% {
transform: rotate(35deg) translateY(-100px) translateX(-100px);
opacity: 0;
}
}
<div class='chat-heart'>
<div class='heart heart1'></div>
<div class='heart heart2'></div>
<div class='heart heart3'></div>
</div>
故事的结尾
故事的结尾就是,有人更换了微信聊天背景,有人写完了一篇文章,愿友谊地久天长。
不会以为这就是结尾吧,哈哈哈。
作者:叶一一
来源:juejin.cn/post/7141316944354885669
前端线上图片生成马赛克
说起图片的马赛克,可能一般都是由后端实现然后传递图片到前端,但是前端也是可以通过canvas来为图片加上马赛克的,下面就通过码上掘金来进行一个简单的实现。
最开始需要实现马赛克功能是需要通过canvas提供的一个获取到图片每一个像素的方法,我们都知道,图片本质上只是由像素组成的,越清晰的图片,就有着越高的像素,而像素的本质,就只是一个个拥有颜色的小方块而已,只要把一张图片放大多倍,就能够清楚的发现。
通过 canvas 的 getImageData 这个方法,我们就能够拿到图像上所有像素组成的数组,并且需要生成马赛克,意味着我们需要把一个范围内的色块的颜色都改成一样的,也就是通过canvas来重绘图片,
let pixeArr = ctx.getImageData(0, 0, w, h).data;
let sampleSize = 40;
for (let i = 0; i < h; i += sampleSize) {
for (let j = 0; j < h; j += sampleSize) {
let p = (j + i * w) * 4;
ctx.fillStyle =
"rgba(" +
pixeArr[p] +
"," +
pixeArr[p + 1] +
"," +
pixeArr[p + 2] +
"," +
pixeArr[p + 3] +
")";
ctx.fillRect(j, i, sampleSize, sampleSize);
}
}
而上文中出现问题的图片是存放在本地的或者线上的,本地的图片默认是没有域名的,线上的图片并且是跨域的,所以浏览器都认为你是跨域,导致报错。
那么对于本地图片,我们只需要将图片放到和html对应的文件夹下,子文件夹也是不可以的,就能够解决,对于线上的图片,我们可以采用先把它下载下来,再用方法来获取数据的这种方式来进行。
function getBase64(imgUrl) {
return new Promise(function (resolve, reject) {
window.URL = window.URL || window.webkitURL;
let xhr = new XMLHttpRequest();
xhr.open("get", imgUrl, true);
xhr.responseType = "blob";
xhr.onload = function () {
if (this.status == 200) {
let blob = this.response;
let oFileReader = new FileReader();
oFileReader.onloadend = function (e) {
let base64 = e.target.result;
resolve(base64);
};
oFileReader.readAsDataURL(blob);
}
};
xhr.send();
});
}
下载图片就不说了,通过浏览器提供的 API 或者其他封装好的请求工具都是可以的,在请求成功之后,我们将图片转化为 base64 并且返回,这样就能够获取线上图片的数据了。
本文提供了一种前端生成马赛克图片的方案,并且对于线上的图片,也能够通过先异步下载图片在进行转换的策略,实现了图片添加马赛克的功能。
链接:https://juejin.cn/post/7142406330618216456
用video.js和H5实现一个漂亮的 收看M3U8直播的网站
国庆节快到了,在这里祝大家节日快乐
长假七天乐确实很爽,只是疫情不稳定,还是呆在家里安全些,
在这宅在家的七天里,何不找点有趣的小demo耍耍
本期教大家制作一个 能播放M3U8直播源
的在线电视台网站
,
既能学到知识技术,又可以方便在家看看电视节目,直播节目,何乐而不为
以下是实现的效果图:
这个小demo完成时间快两年了,所以里面有一些m3u8直播地址用不了
而且直播源的地址经常崩,所以会出现视频播放不了的情况
有需要直接百度搜 m3u8电视直播
具体实现
m3u8 以及 video.js介绍
为什么要介绍这两个东西呢?
因为我们大部分的电视直播在网络上都是m3u8格式的
m3u8准确来说是一种索引文件,使用m3u8文件实际上是通过它来解析对应的放在服务器上的视频网络地址,从而实现在线播放。
我不喜欢太过于术语的解释。
简单来讲,我们看到的直播都是服务器把视频切片,然后一段一段给你发过来,客户端自己处理,整成视频给我们看
这就是 m3u8
但是浏览器并不支持video直接播放m3u8格式的视频
所以我们需要video.js来帮助我们,把这些切片的音视频给整成可以看的东西
Video.js 是一个通用的在网页上嵌入视频播放器的 JS 库
Video.js 可以自动检测浏览器对 HTML5 的支持情况,如果不支持 HTML5 则自动使用 Flash 播放器
咋解决这个问题呢,很简单,在html导入我们的video.js就可以了
<!DOCTYPE html>
<html lang="zn">
<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">
<!-- 引用video.js -->
<link href="https://cdn.bootcdn.net/ajax/libs/video.js/5.18.4/video-js.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/video.js/5.18.4/video.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/videojs-contrib-hls/5.15.0/videojs-contrib-hls.min.js" type="text/javascript"></script>
引入之后呢,咋用?
在html标签上写上我们的元素,然后在js里面获取我们的播放器,
之后就可以自由用代码控制播放地址源,还有控制播放暂停等等功能
<video id="my-player" class="video-js" controls style="width: 800px;height: 500px;">
<source src="http://amdlive.ctnd.com.edgesuite.net/arirang_1ch/smil:arirang_1ch.smil/playlist.m3u8" type="application/x-mpegURL">
<p class="vjs-no-js">not support</p>
</video>
// video.js
var player = videojs('my-player', {
});
function play_show(TV_m3u8){
// alert("正在播放:"+TV_m3u8);
document.getElementById("my_vedio_fixed").style.display="block";
player.src([{
type: "application/x-mpegURL",
src: TV_m3u8
}])
player.play()
}
具体学习video.js可以去 GitHub上去看
传送门:GitHub - 视频.js:视频.js - 开源HTML5视频播放器)
看不懂英文,右键翻译成中文就可以
以上的代码,我只是粗略的从我写的小demo中抓取出来,完整代码在下方。
是的,两年前的我甚至不会把数据保存到js中
傻傻的丢在div里面,傻傻的把div隐藏了起来
最后居然傻傻的去切割字符串成数组
沃德天,果然兴趣是最好的老师,野路子有够猛
然后小demo里面
用到了一些字体图标
还有一些图片
当然,这些素材没有都无伤大雅
作者:冰镇生鲜
来源:https://juejin.cn/post/7149152825409273870
我也写了个低仿网易云音乐播放器,这是我的感受
开发一个基于Vue的低仿mac网易云音乐web播放器及开后感
前言
感谢大佬提供的api
项目简介
技术栈
webpack4(打包工具, 这个项目中我并没有用vue-cli, 因为想体验下自己搭建webpack有多痛苦:( )
element-ui (用到了其中轮播图, 表格等部分组件)
sass (css预处理器)
Vue全家桶
辅助工具 & 插件
better-scroll(歌词滚动)
xgplayer (西瓜视频播放器)]
postcss-pxtorem (px转rem工具, 自己搭webpack 加这玩意儿实在太费劲了)
charles (抓包工具)
axios
项目功能
登录(账号密码 & 网易云Id)
音乐播放
视频播放
歌单 & 专辑页
搜索结果, 搜索面板
播放记录 & 播放列表
排行榜 & 最新音乐 & 个性推荐
我的收藏歌单列表
歌词, 评论, 相关推荐
有些功能相较于网易云音乐是残疾版, 因为提供的接口是2年前的, 所以有些不支持现在的业务逻辑
项目预览
跑一下
cnpm i
npm run start 本地预览
npm run build 打包
npm run analyz 打包文件分析
npm run release 部署到服务器
webpack
这个项目写到目前为止, 我花费精力最多是webpack相关以及打包优化相关的内容(这里的精力 = 花费时间 / 代码量). 脚手架 很方便, 但是我还是想体验下从0搭建一个小项目的webpack配置
个人觉得自己配置webpack起手式, 就是碰到问题去搜, 逐个击破, 像我这样的小白千万不要代码还没开始写就想撘出个脚手架级别的配置, 像这样...
搜着搜着 就这样了
简述打包优化历程
先上一张啥也没有优化时的图片
呵呵呵呵... 一个破音乐播放器 6.1M 48.9s
开始优化
在生产环境的配置文件中, 加上(
mode: production
), 有了这句话, webpack会自动帮你压缩代码, 且效果非常显著
2. 使用gzip
, 这一步需要在webpack使用compression-webpack-plugin
插件
plugins: [
...
new CompressionWebpackPlugin({
algorithm: 'gzip',
test: /\.js(\?.*)?$/i,
threshold: 10240,
minRatio: 0.8
}),
以及nginx配置文件中配置
http{
....
gzip on;
gzip_comp_level 6;
gzip_types text/xml text/plain text/css application/javascript application/x-javascript application/rss+xml;
gzip_disable "MSIE[1-6]\.";
使用过程中我发现webpack不配置gzip压缩仅配置nginx, 在最终访问项目时, 拿到的文件也是gzip格式的. 查阅后,才知道 gzip 服务端也能进行压缩, 但是如果客户端直接把压缩好的gzip文件传到服务端 可以节省服务端在收到请求后对文件进行的压缩的性能损耗
webpack端配置gzip压缩
webpack端不配置gzip压缩
使用
ParallelUglifyPlugin
, 开启多个子进程并行压缩 节省压缩时间, 并且去除调试日志
plugins:[
...
new ParallelUglifyPlugin({
cacheDir: '.cache/',
uglifyJS:{
output: {
comments: false
},
warnings: false,
compress: {
drop_debugger: true, // 去除生产环境的 debugger 和 console.log
drop_console: true
}
}
}),
将一些依赖 用cdn链接引入, 并且使用dns预解析
// webpack.prod.conf.js
externals:{
vue: 'Vue',
'vue-router': 'VueRouter',
vuex: 'Vuex',
axios: 'axios',
},
// index.html
<head>
//使用dns预解析(将域名解析成ip是很耗时的)
<link rel="dns-prefetch" href="//cdn.bootcss.com">
<link rel="dns-prefetch" href="//cdnjs.cloudflare.com">
</head>
...
<body>
//这串奇怪的代码html-webpack-plugin插件会解析的
<% if ( process.env.NODE_ENV === 'production' ) { %>
<script src="https://cdn.bootcss.com/vue/2.6.10/vue.runtime.min.js"></script>
<script src="https://cdn.bootcss.com/vue-router/3.1.3/vue-router.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.0/axios.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vuex/3.1.2/vuex.min.js"></script>
<%} %>
使用
splitChunks
, 这个插件不需要install, 直接使用即可, 它的作用是将公共依赖单独提取出来,避免被重复打包, 具体细节可以看这
splitChunks: {
chunks: 'all',
cacheGroups: {
xgplayer: {
test: /xgplayer/,
priority: 0,
name: 'xgplayer'
},
vendor: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
name: 'vendors',
minChunks: 10
}
}
}
注意下
'xgplayer'
, 这是个视频播放器库, 我这里单独配置也是为了优化打包, 第7点会说
至此, 我的初步优化已经完成了, 那还有没有优化空间呢, 这里可以先用下打包分析工具
webpack-bundle-analyzer
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
plugins: [
// 打包分析
new BundleAnalyzerPlugin(
{
analyzerMode: 'server',
analyzerHost: '127.0.0.1',
analyzerPort: 8888,
reportFilename: 'report.html',
defaultSizes: 'parsed',
openAnalyzer: true,
generateStatsFile: false,
statsFilename: 'stats.json',
statsOptions: null,
logLevel: 'info'
}
),
],
从图中可以清晰的看到打包后代码的结构,
moment
这个库中有很多的语言包, 可以用webpack自带的ContextReplacementPlugin
插件进行过滤
//过滤moment其他语言包 打包体积缩小200kb
new webpack.ContextReplacementPlugin(
/moment[/\\]locale$/,
/zh-cn/,
),
xgplayer
也占用了很大的体积, 那如何优化呢? 这里引入一个'prefetching'
概念, 其思想就是将一些文件在浏览器资源空闲时去分配资源下载, 从业务逻辑考虑, 在用户初次访问项目时, 是不需要用到视频库的资源的, 所以可以把浏览器资源分配给首屏需要的文件. 在业务逻辑中这样配置
watch: {
url: {
handler(newV, oldV) {
if (newV && newV !== oldV) {
if (!this.player) {
import(/* webpackPrefetch:true */'xgplayer').then((module) => {
xyPlayer = module.default;
this.initVideo()
//这里这样写的目的是,如果有用户通过url直接打开视频页, 那我也可以同步加载完视频库文件后, 再初始化视频组件
})
} else {
this.player.src = newV
this.player.reload()
}
}
},
immediate: !0
}
}
至至至此, 我的第二步优化已经完成了, 那还有没有优化空间呢, 这里可以用下chrome浏览器的调试工具
coverage
, 这个工具可以帮你分析出文件利用率(即加载的文件中, 真正用到的代码有哪些), 附上一张我优化好的截图
首屏加载的文件利用率只有35%,该部分优化的核心思想就是将首屏看不见的资源全部异步导入, 例如采用component: () => import('xxxx')
路由懒加载, 将需要用户交互才会用到的逻辑代码单独封装,按需加载,例如
//click.js
function click() {
....
}
export default click
//main.js
document.addEventListener('click', () => {
import('./click').then(({ default: click }) => {
click()
})
})
当然这样做会很繁琐, 不过对于追求极致体验的应用来说, 也是个路子...
附上两张优化完状态, 当然 这不是还不是最佳的状态...
总结
不用脚手架从0搭webpack及优化打包能让自己接触到很多业务代码以外的东西, 这些东西也是前端职责中很重要的但也常常被忽视的模块, 过程很艰难但也充满意义.
作者:stormsprit
来源:juejin.cn/post/6844904045765722125
前端人抓包羊了个羊,玩一次就过关
1. 前言
最近微信小游戏「羊了个羊」非常火爆,火爆的原因不是因为它很好玩,而是第二关难度非常高,据说只有 0.1% 的人能通关。我也尝试了下,第一关非常容易,第二关玩到对自己的智商产生了怀疑:真的有人自己打通关吗?既然不能常规方法通关,能不能通过别的方式通关呢?答案是可以的,我们可以使用抓包工具进行通关,如果你不知道抓包是什么,可以看看《前端人必须掌握的抓包技能》,里面有较详尽的解释。本文主要讲述羊了羊的通关原理以及使用 whistle 进行抓包通关。
2. 通关原理
2.1 游戏玩法
羊了个羊是一个消消乐类的游戏,只不过主角是羊,点击要消除的蔬菜类食物,三个进入槽内就可以消除。
一共有两关,两关都通关后即可获得一套新羊装皮肤,并加入自己所属省份的羊群去,为自己的省份排名出一分力。
可以看到第一关是非常容易的,一般都不需要使用任何道具就可以轻松过关。第二关显然要难得多,
既然如此,能否通过抓包的方式,篡改第二关的地图数据,让它加载第一关的数据呢。
2.2 环境配置
只要地图数据是通过服务端返回给客户端的,就可以通过抓包工具抓取篡改,现在先做好环境的配置:
whistle 是基于 Node 实现的跨平台抓包免费调试工具,可以使用 npm 进行安装
先安装 node,建议用 nvm 管理
全局安装 whistle
npm i -g whistle & w2 start
成功启动服务后,就可以通过浏览器访问 http://127.0.0.1:8899/ 查看抓包、修改请求等。
由于羊了羊客户端与服务端的通信是 https 协议,需要把 whistle 的 https 根证书安装到手机证书管理处并信任。
此时,再在与电脑连接了同一个 wifi 的手机上配置代理指向 PC 电脑的 IP 和 whistle 监听的端口即可在电脑上截获数据包。
通过电脑抓包,可以发现地图接口请求路径如下:
# 第一关地图数据
https://cat-match.easygame2021.com/sheep/v1/game/map_info_new?map_id=80001
响应数据:{"err_code":0,"err_msg":"","data":"046ef1bab26e5b9bfe2473ded237b572"}
# 第二关地图数据
https://cat-match.easygame2021.com/sheep/v1/game/map_info_new?map_id=90019
响应数据:{"err_code":0,"err_msg":"","data":"fdc2ccf2856998d37446c004bcc0aae7"}
知道了地图数据的请求路径,就可以改写响应了。
2022-09-20 更新
地图接口请求的数据变更为:
https://cat-match.easygame2021.com/sheep/v1/game/map_info_ex?matchType=3
3. 通关方式
3.1 改写响应体
在 whistle 中添加过滤规则,拦截第二关地图的请求,返回第一关地图的响应数据。
先在 rules 面板添加一条过滤规则,然后再 values 添加一条返回值,注意要检查是否对应清楚,否则会请求会一直卡住无法响应。
规则设置完后,删除小游戏,重新进入,即可看到抓取的第二关地图请求返回的数据时第一关地图的。
在测试过程中,发现第二关地图的请求 id 以日期递增,比如 900018、900019,注意修改,具体以抓取到的地图请求路径为准。
2022-09-20 更新
上面这种方式已被官方优化,不再有先有第一关和第二关先后请求,但原理是一样的。
添加 whistle 规则:
https://cat-match.easygame2021.com/sheep/v1/game/map_info_ex?matchType=3 resBody://{ylgyV2}
对应的 values 设置为:
{
"err_code":0,
"err_msg":"",
"data":{
"map_md5":[
"046ef1bab26e5b9bfe2473ded237b572", // 第一关
"046ef1bab26e5b9bfe2473ded237b572" // 第二关,用第一关的值替换第二关
],
"map_seed": [4208390475,3613589232,3195281918,329197835]
}
}
3.2 302 重定向
客户端与服务端是通过 https 通信,传递 HTTP 报文,HTTP 报文包括起始行、首部和主体。
HTTP 请求报文中包含命令和 URL,HTTP 响应报文中包含了事务的结果,而响应的状态码为客户端提供了一种理解事务处理结果的便捷方式。其中 300 ~ 399 代表重定向状态码,重定向状态码要么告知客户端使用替代位置来访问目标资源内容,
要么就提供一个替代的响应而不是资源的内容。如果资源已被移动,可发送一个重定向状态码和一个可选的 Location 首部来告知客户端资源已被移走,以及现在可以在哪里找到它。
常见的重定向状态对比:
301:表明目标资源被永久的移动到了一个新的 URI,任何未来对这个资源的引用都应该使用新的 URI
302:所请求的页面已经临时转移到新的 URI,302 允许各种各样的重定向,一般情况下都会实现为到 GET 的重定向,但是不能确保 POST 会重定向为 POST。
301和302跳转,最终看到的效果是一样的,但对于 SEO 来说有两个区别:
301 重定向是永久的重定向,搜索引擎在抓取新内容的同时也将旧的网址替换为重定向之后的网址。
302 存在网址URL劫持,一个不道德的人在他自己的网址A做一个302重定向到你的网址B,出于某种原因, Google搜索结果所显示的仍然是网址A,但是所用的网页内容却是你的网址B上的内容,这种情况就叫做网址URL劫持
知道重定向的原理后,请求第二关地图时,就可以通过返回重定向响应状态码,告诉客户端,资源已被移动,可以去请求第一关地图数据,
在 whistle 添加 302 重定向规则如下:
https://cat-match.easygame2021.com/sheep/v1/game/map_info_new?map_id=90019 redirect://https://cat-match.easygame2021.com/sheep/v1/game/map_info_new?map_id=80001
2022-09-20 更新,上面这种方式已被官方优化,不再有先有第一关和第二关先后请求,此方式不再可行。
要实现了羊了个羊通关,除了更改地图数据还可以篡改道具数据,但尝试时发现它获取道具的方式不是通关网络请求,而是通关转发朋友圈/看广告后获得回调,前端直接做的逻辑处理,因此作罢。
4. 总结
本文是《前端人必须掌握的抓包技能》的案例实践,简单地讲述如何使用 whistle 实现羊了羊通关。考虑到羊了个羊的官方不断更新迭代,现在的漏洞很快会被修复,本文的通关策略会很快失效。如果你能学会到本文的抓包技巧,能给你在日常的开发调试工作中提供一种思路,本文的目的也就达到了。
感谢 Kagol 大佬的建议,才有此篇文章的延生。
声明:本文所述相关技术仅供学习交流使用。
作者:jecyu
来源:juejin.cn/post/7145256312488591391
前端按钮/组件权限管理
最近项目中遇到了按钮权限管理的需求,整理了一下目前的方案,有不对的地方望大家指出~
方案1:数组+自定义指令
把权限放到数组中,通过vue的自定义指令来判断是否拥有该权限,有则显示,反之则不显示
我们可以把这个按钮需要的权限放到组件上
<el-button
v-hasPermi="['home:advertising:update']"
>新建</el-button>
自定义指令:
逻辑就是我们在登陆后会获取该用户的权限,并存储到localStorage中,当一个按钮展示时会判断localStorage存储的权限列表中是否存在该按钮所需的权限。
/**
* 权限处理
*/
export default {
inserted(el, binding, vnode) {
const { value } = binding;
const SuperPermission = "superAdmin"; // 超级用户,用于开发和测试
const permissions = localStorage.getItem('userPermissions')&& localStorage.getItem('userPermissions').split(',');
// 判断传入的组件权限是否符合要求
if (value && value instanceof Array && value.length > 0) {
const permissionFlag = value;
const hasPermissions = permissions && permissions.some(permission => all_permission === permission || permissionFlag.includes(permission));
// 判断是否有权限是否要展示
if (!hasPermissions) {
el.parentNode && el.parentNode.removeChild(el);
}
} else {
throw new Error(`请设置操作权限标签值`);
}
},
};
注册权限
import Vue from 'vue';
import Vpermission from "./permission";
// 按钮权限 自定义指令
Vue.directive('permission', Vpermission);
关于路由权限
数组的方案也可以用到菜单权限上,可以在路由的meta中携带该路由所需的权限,例如:
const router = [{
path: 'needPermissionPage',
name: 'NeedPermissionPage',
meta: {
role: ['permissionA', 'permissionB'],
},
}]
这个时候就需要在渲染权限的时候动态渲染了,该方案可以看一下其他的文章或成熟的项目,写的非常好
方案2: 二进制
通过二进制来控制权限:
假设我们有增删改查四个基本权限:
const UPDATE = 0b000001;
const DELETE = 0b000010;
const ADD = 0b000100;
const SEARCH = 0b001000;
每一位代表是否有该权限,有该权限则是1,反之是0
表达权限:
我们可以使用或运算来表达一个权限结果,或运算:两个任何一个为1,结果就为1
const reslut = UPDATE | DELETE | SEARCH;
console.log(reslut); // 11
变成了十进制,我们可以通过toString方法变为二进制结果
const reslut = UPDATE | DELETE | SEARCH;
console.log(reslut.toString(2)); // 1011
result 这个结果就代表我们既拥有更新权限,同时也拥有删除和查询的权限
那么我们可以将十进制的reslut当作该用户的权限,把这个结果给后台,下次用户登陆后只需要返回这个结果就可以了。
权限判断
我们了解了如何表达一个权限,那如何做权限的判断呢?
可以通过且运算,且运算:两位都为1,这一位的结果才是1。
还是用上面的结果,当我们从接口中拿到了reslut,判断他是否有 DELETE 权限:
console.log((reslut & DELETE) === DELETE); // true
是否有新增的权限
console.log((result & ADD) === ADD); // false
判断和使用
/**
* 接受该组件所需的权限,返回用户权限列表是否有该权限
* @param {String} permission
* @returns {Boolean}
*/
function hasPermission(permission) {
const permissionList = {
UPDATE: 0b000001,
DELETE: 0b000010,
CREATE: 0b000100,
SEARCH: 0b001000
}
let btnPermission = permissionList[permission] ? permissionList[permission] : -1;
if (btnPermission === -1) return false;
const userPermission = localStorage.getItem('userPermissions');
// 将本地十进制的值转换为二进制
const userPermissionBinary = userPermission.toString(2);
// 对比组件所需权限和本地存储的权限
return (userPermissionBinary & btnPermission) === btnPermission;
}
直接在组件中通过v-show/v-if来控制是否展示
<el-button v-show="hasPermission('UPDATE')">更新</el-button>
小结
我理解来说,对于方案1来说,方案2的优势在于更简洁,后台仅需要存储一个十进制的值,但如果后期新增需求更新了新的权限,可能需要调整二进制的位数来满足业务需求。方案1的优势在于更加易懂,新增权限时仅需要更新组件自定义指令的数组。
原文:https://juejin.cn/post/7142778249171435551
这一次,放下axios,使用基于rxjs的响应式HTTP客户端
众所周知,在浏览器端和 Node.js 端使用最广泛的 HTTP 客户端为 axios 。想必大家都对它很熟悉,它是一个用于浏览器和 Node.js 的、基于 Promise
的 HTTP 客户端,但这次的主角不是它。
起源
axios
的前身其实是 AngularJS 的 $http
服务。
为了避免混淆,这里需要澄清一下:
AngularJS
并不等于Angular
,AngularJS
是特指 angular.js v1.x 版本,而Angular
特指 angular v2+ (没有 .js)和其包含的一系列工具链。
这样说可能不太严谨,但 axios
深受 AngularJS
中提供的$http
服务的启发。归根结底,axios
是为了提供一个类似独立的服务,以便在 AngularJS
之外使用。
发展
但在 Angular
中,却没有继续沿用之前的 $http
服务,而是选择与 rxjs 深度结合,设计出了一个比 $http
服务更先进的、现代化的,响应式的 HTTP 客户端。 在这个响应式的 HTTP Client 中,发送请求后接收到的不再是一个 Promise
,而是来自 rxjs
的 Observable,我们可以订阅它,从而侦听到请求的响应:
const observable = http.get('url');
observable.subscribe(o => console.log(o));
有关它的基本形态及详细用法,请参考官方文档。
正文
@ngify/http 是一个形如 Angular HttpClient
的响应式 HTTP 客户端。@ngify/http
的目标与 axios
相似:提供一个类似独立的服务,以便在 Angular
之外使用。
@ngify/http
提供了以下主要功能:
先决条件
在使用 @ngify/http
之前,您应该对以下内容有基本的了解:
JavaScript / TypeScript 编程。
HTTP 协议的用法。
RxJS Observable 相关技术和操作符。请参阅 Observables 指南。
API
有关完整的 API 定义,请访问 ngify.github.io/ngify.
可靠性
@ngify/http
使用且通过了 Angular HttpClient
的单元测试(测试代码根据 API 的细微差异做出了相应的更改)。
安装
npm i @ngify/http
基本用法
import { HttpClient, HttpContext, HttpContextToken, HttpHeaders, HttpParams } from '@ngify/http';
import { filter } from 'rxjs';
const http = new HttpClient();
http.get<{ code: number, data: any, msg: string }>('url', 'k=v').pipe(
filter(({ code }) => code === 0)
).subscribe(res => console.log(res));
http.post('url', { k: 'v' }).subscribe(res => console.log(res));
const HTTP_CACHE_TOKEN = new HttpContextToken(() => 1800000);
http.put('url', null, {
context: new HttpContext().set(HTTP_CACHE_TOKEN)
}).subscribe(res => console.log(res));
http.patch('url', null, {
params: { k: 'v' }
}).subscribe(res => console.log(res));
http.delete('url', new HttpParams('k=v'), {
headers: new HttpHeaders({ Authorization: 'token' })
}).subscribe(res => console.log(res));
拦截请求和响应
借助拦截机制,你可以声明一些拦截器,它们可以检查并转换从应用中发给服务器的 HTTP 请求。这些拦截器还可以在返回应用的途中检查和转换来自服务器的响应。多个拦截器构成了请求/响应处理器的双向链表。
@ngify/http
会按照您提供拦截器的顺序应用它们。
import { HttpClient, HttpHandler, HttpRequest, HttpEvent, HttpInterceptor, HttpEventType } from '@ngify/http';
import { Observable, tap } from 'rxjs';
const http = new HttpClient([
new class implements HttpInterceptor {
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// 克隆请求以修改请求参数
request = request.clone({
headers: request.headers.set('Authorization', 'token')
});
return next.handle(request);
}
},
{
intercept(request: HttpRequest<unknown>, next: HttpHandler) {
request = request.clone({
params: request.params.set('k', 'v')
});
console.log('拦截后的请求', request);
return next.handle(request).pipe(
tap(response => {
if (response.type === HttpEventType.Response) {
console.log('拦截后的响应', response);
}
})
);
}
}
]);
虽然拦截器有能力改变请求和响应,但 HttpRequest
和 HttpResponse
实例的属性是只读的,因此让它们基本上是不可变的。
有充足的理由把它们做成不可变对象:应用可能会重试发送很多次请求之后才能成功,这就意味着这个拦截器链表可能会多次重复处理同一个请求。 如果拦截器可以修改原始的请求对象,那么重试阶段的操作就会从修改过的请求开始,而不是原始请求。 而这种不可变性,可以确保这些拦截器在每次重试时看到的都是同样的原始请求。
如果你需要修改一个请求,请先将它克隆一份,修改这个克隆体后再把它传递给 next.handle()
。
替换 HTTP 请求类
@ngify/http
内置了以下 HTTP 请求类:
HTTP 请求类 | 描述 |
---|---|
HttpXhrBackend | 使用 XMLHttpRequest 进行 HTTP 请求 |
HttpFetchBackend | 使用 Fetch API 进行 HTTP 请求 |
HttpWxBackend | 在 微信小程序 中进行 HTTP 请求 |
默认使用 HttpXhrBackend
,可以通过修改配置切换到其他的 HTTP 请求类:
import { HttpFetchBackend, HttpWxBackend, setupConfig } from '@ngify/http';
setupConfig({
backend: new HttpFetchBackend()
});
你还可使用自定义的 HttpBackend
实现类:
import { HttpBackend, HttpClient, HttpRequest, HttpEvent, setupConfig } from '@ngify/http';
import { Observable } from 'rxjs';
// 需要实现 HttpBackend 接口
class CustomHttpBackend implements HttpBackend {
handle(request: HttpRequest<any>): Observable<HttpEvent<any>> {
// ...
}
}
setupConfig({
backend: new CustomHttpBackend()
});
如果需要为某个 HttpClient
单独配置 HttpBackend
,可以在 HttpClient
构造方法中传入:
const http = new HttpClient(new CustomHttpBackend());
// 或者
const http = new HttpClient({
interceptors: [/* 一些拦截器 */],
backend: new CustomHttpBackend()
});
在 Node.js 中使用
@ngify/http
默认使用浏览器实现的 XMLHttpRequest
与 Fetch API
。要在 Node.js 中使用,您需要进行以下步骤:
XMLHttpRequest
如果需要在 Node.js 环境下使用 XMLHttpRequest
,可以使用 xhr2,它在 Node.js API 上实现了 W3C XMLHttpRequest 规范。
要使用 xhr2 ,您需要创建一个返回 XMLHttpRequest
实例的工厂函数,并将其作为参数传递给 HttpXhrBackend
构造函数:
import { HttpXhrBackend, setupConfig } from '@ngify/http';
import * as xhr2 from 'xhr2';
setupConfig({
backend: new HttpXhrBackend(() => new xhr2.XMLHttpRequest())
});
Fetch API
如果需要在 Node.js 环境下使用 Fetch API
,可以使用 node-fetch 和 abort-controller。
要应用它们,您需要分别将它们添加到 Node.js
的 global
:
import fetch from 'node-fetch';
import AbortController from 'abort-controller';
import { HttpFetchBackend, HttpWxBackend, setupConfig } from '@ngify/http';
global.fetch = fetch;
global.AbortController = AbortController;
setupConfig({
backend: new HttpFetchBackend()
});
传递额外参数
为保持 API 的统一,需要借助 HttpContext
来传递一些额外参数。
Fetch API 额外参数
import { HttpContext, FETCH_TOKEN } from '@ngify/http';
// ...
// Fetch API 允许跨域请求
http.get('url', null, {
context: new HttpContext().set(FETCH_TOKEN, {
mode: 'cors',
// ...
})
});
微信小程序额外参数
import { HttpContext, WX_UPLOAD_FILE_TOKEN, WX_DOWNLOAD_FILE_TOKEN, WX_REQUSET_TOKEN } from '@ngify/http';
// ...
// 微信小程序开启 HTTP2
http.get('url', null, {
context: new HttpContext().set(WX_REQUSET_TOKEN, {
enableHttp2: true,
})
});
// 微信小程序文件上传
http.post('url', null, {
context: new HttpContext().set(WX_UPLOAD_FILE_TOKEN, {
filePath: 'filePath',
fileName: 'fileName'
})
});
// 微信小程序文件下载
http.get('url', null, {
context: new HttpContext().set(WX_DOWNLOAD_FILE_TOKEN, {
filePath: 'filePath'
})
});
更多
有关更多用法,请访问 angular.cn。
作者:Sisyphus
来源:juejin.cn/post/7079724273929027597
前端怎么样限制用户截图?
做后台系统,或者版权比较重视的项目时,产品经常会提出这样的需求:能不能禁止用户截图?有经验的开发不会直接拒绝产品,而是进行引导。
先了解初始需求是什么?是内容数据过于敏感,严禁泄漏。还是内容泄漏后,需要溯源追责。不同的需求需要的方案也不同。来看看就限制用户截图,有哪些脑洞?
有哪些脑洞
v站和某乎上的大佬给出了不少脑洞,我又加了点思路。
1.基础方案,阻止右键保存和拖拽。
这个方案是最基础,当前可只能阻拦一些小白用户。如果是浏览器,分分钟调出控制台,直接找到图片url。还可以直接ctrl+p,进入打印模式,直接保存下来再裁减。
2.失焦后加遮罩层
这个方案有点意思,看敏感信息时,必须鼠标点在某个按钮上,照片才完整显示。如果失去焦点图片显示不完整或者直接遮罩盖住。
3.高速动态马赛克
这个方案是可行的,并且在一些网站已经得到了应用,在视频或者图片上随机插像素点,动态跑来跑去,对客户来说,每一时刻屏幕上显示的都是完整的图像,靠用户的视觉残留看图或者视频。即时手机拍照也拍不完全。实际应用需要优化的点还是挺多的。比如用手机录像就可以看到完整内容,只是增加了截图成本。
下面是一个知乎上的方案效果。(原地址):
正经需求vs方案
其实限制用户截图这个方案本身就不合理,除非整个设备都是定制的,在软件上阉割截图功能。为了这个需求添加更复杂的功能对于一些安全性没那么高的需求来说,有点本末倒置了。
下面聊聊正经方案:
1.对于后台系统敏感数据或者图片,主要是担心泄漏出去,可以采用斜45度七彩水印,想要完全去掉几乎不可能,就是观感比较差。
2.对于图片版权,可以使用现在主流的盲水印,之前看过腾讯云提供的服务,当然成本比较高,如果版权需求较大,使用起来效果比较好。
3.视频方案,tiktok下载下来的时候会有一个水印跑来跑去,当然这个是经过处理过的视频,非原画,画质损耗也比较高。Netflix等视频网站采用的是服务端权限控制,走的视频流,每次播放下载加密视频,同时获得短期许可,得到许可后在本地解密并播放,一旦停止播放后许可失效。
总之,除了类似于Android提供的截图API等底层功能,其他的功能实现都不完美。即使是底层控制了,一样可以拍照录像,没有完美的方案。不过还是可以做的相对安全。
作者:正经程序员
来源:juejin.cn/post/7127829348689674253