优雅的处理async/await错误
async/await使用
async/await
解决了Promise的链式调用(.then)造成的回调地狱,使异步回调变得像同步一样美观!
使用的方式如下:
// 异步函数1
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun1') }, 2000)
})
}
// 异步函数2
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun2') }, 1000)
})
}
// async/await
async function syncFun() {
let s1 = await postFun1()
console.log(s1)
let s2 = await postFun2()
console.log(s2)
console.log('s1、s2都获取到了,我才会执行')
}
syncFun()
可以看出,在syncFun函数中,我们获取异步信息,书写方式就跟同步一样,不用.then套.then,很美观!
不捕获错误会怎样
// 异步函数1
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun1') }, 2000)
})
}
// 异步函数2
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err') }, 1000)
})
}
async function asyncFun() {
let s1 = await postFun1();
let s2 = await postFun2();
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
可以看出,控制台没有我们想要打印的信息console.log('s1、s2都获取到了,我才会执行')
try/catch捕获错误
我们日常开发中,都是使用try/catch捕获错误,方式如下:
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err1') }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err2') }, 1000)
})
}
async function asyncFun() {
try{
let s1 = await postFun1();
let s2 = await postFun2();
}catch(e){
console.log(e)
}
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
可以看出,我们抛出两个reject,但是只捕获到了一个错误!
那么捕获多个错误,我们就需要多个try/catch如此,代码便像现在这样:
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err1') }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err2') }, 1000)
})
}
async function asyncFun() {
try{
let s1 = await postFun1();
}catch(e){
console.log(e)
}
try{
let s2 = await postFun2();
}catch(e){
console.log(e)
}
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
仅仅是两个try/catch已经看起来很难受了,那么10个呢?
await-to-js
/**
* @param promise 传进去的请求函数
* @param errorExt 拓展错误信息
* @return 返回一个Promise
*/
function to(promise, errorExt) {
return promise
.then(res => [null, res])
.catch(err => {
if (errorExt) {
const parsedError = Object.assign({}, err, errorExt)
return [parsedError, undefined]
}
return [err, undefined]
})
}
await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值
这里封装了一个
to
函数,接收promise和扩展的错误信息为参数,返回promise
。[err,res]
分别代表错误信息和成功结果,.then()成功时,[null,res]代表错误信息为null
;.catch()失败时,[err,undefined]代表,成功结果为undefined
。我们获取捕获的结果直接从返回的数组中取就行,第一个是失败信息,第二个是成功结果!
完整代码加使用
function to(promise, errorExt) {
return promise
.then(res => [null, res])
.catch(err => {
if (errorExt) {
const parsedError = Object.assign({}, err, errorExt)
return [parsedError, undefined]
}
return [err, undefined]
})
}
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject({err:'err1'}) }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject({err:'err2'}) }, 1000)
})
}
async function asyncFun() {
let [err1,res1] = await to(postFun1(), {msg:'抱歉1'});
let [err2,res2] = await to(postFun2(), {msg:'抱歉2'});
console.log(err1,err2)
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun()
把这个学会,在面试官面前装一波,面试官定会直呼优雅!!!
来源:juejin.cn/post/7278280824846925861
threejs 搭建智驾自车场景
智能驾驶业务里常用web 3d来可视化地图和传感器数据、显示路径规划结果等方法协助算法调试和仿真,可以用threejs来做,毕竟在国内社区相对活跃,也比较容易上手,效果类似下图:
当然以上图片都是客户端的版本,web3d版本的ui其实并不会这么精致,毕竟只是服务于内部算法和研发。这个专栏纯属作者一时兴起并希望能产出一个麻雀虽小五脏俱全的行泊场景(简称人太闲),本文就先把自车的基础场景搭建起来
本文基于 three^0.167.1 版本
初始化项目
用 Vite 脚手架快速搭一个 react 项目用来调试
pnpm create vite autopilot --template react-ts
把 threejs 官网的例子稍微改下,加到项目里看看。新建一个 renderer 对象如下:
// src/renderer/index.ts
import * as THREE from "three";
class Renderer {
constructor() {
//
}
initialize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
const camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 10);
camera.position.z = 1;
const scene = new THREE.Scene();
const geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
const material = new THREE.MeshNormalMaterial();
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setAnimationLoop(animate);
container.appendChild(renderer.domElement);
function animate(time: number) {
mesh.rotation.x = time / 2000;
mesh.rotation.y = time / 1000;
renderer.render(scene, camera);
}
}
}
export const myRenderer = new Renderer();
// App.tsx
import { useEffect } from "react";
import { myRenderer } from "./renderer";
import "./App.css";
function App() {
useEffect(() => {
myRenderer.initialize();
}, []);
return (
<>
<div id="my-canvas"></div>
</>
);
}
export default App;
加载自车
ok,跨出第一步了,接下来整辆自车(egoCar)
“自车”指的是自动驾驶汽车本身,它能够通过搭载的传感器、计算平台和软件系统实现自主导航和行驶
可以上 free3d 下载个免费的车辆模型,里面有很多种格式的,尽量找 gltf/glb 格式的(文件体积小,加载比较快)。
这里以加载 glb 格式的模型为例,可以先把模型文件放到 public 目录下,因为加载器相对网页的根路径(index.html)解析,而 public 目录在打包后会原封不动保存到根目录里
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const gltfLoader = new GLTFLoader();
class Renderer {
scene = new THREE.Scene();
// ...
loadEgoCar() {
gltfLoader.load("./su7.glb", (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
// ...
initialize() {
// ...
this.loadEgoCar();
}
}
但如果一定要放到 src/assets/models
目录里呢?然后通过import方式引入文件来用,那这么操作下来就会遇到这个报错(You may need to install appropriate plugins to handle the .glb file format, or if it's an asset, add "**/*.glb" to assetsInclude
in your configuration):
怎么解?在 vite.config.ts
文件加入 assetsInclude
。顺带把 vite 指定路径别名 alias 也支持一下
// vite .config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// 指定路径别名
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
assetsInclude: ["**/*.glb"],
});
node:url 如果提示没有该模块,先安装下@types/node,可能要重启下vscode才能生效
pnpm i @types/node -D
接下来就可以直接用 import 导入 glb 文件来用了
import carModel from "@/assets/models/su7.glb";
class Renderer {
// ...
loadEgoCar() {
gltfLoader.load(carModel, (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
}
OrbitControls
增加 OrbitControls 插件,便于调节自车视角,这个插件除了围绕目标点(默认是原点[0,0,0])旋转视角,还支持缩放(滚轮)和平移(鼠标右键,触摸板的话是双指长按)
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
class Renderer {
initialize() {
// ...
const controls = new OrbitControls(camera, renderer.domElement);
function animate() {
// ...
controls.update();
renderer.render(scene, camera);
}
}
}
光源设置
看起来场景和自车都比较暗,咱们调下光源,加一个环境光 AmbientLight 和平行光 DirectionalLight,平行光位置放自车后上方,沿着自车方向(也就是原点方向)发射光源
// ...
// 没有特定方向,影响整个场景的明暗
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);
// 平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(60, 80, 40);
scene.add(directionalLight);
地面网格
增加坐标网格,新建一个 Grid 对象,提供一个水平的基准面,便于观察
// ...
// 50表示网格模型的尺寸大小,20表示纵横细分线条数量
const gridHelper = new THREE.GridHelper(50, 20);
scene.add(gridHelper);
// 顺带调高下相机位置
camera.position.set(0, 1, 1.8);
// 设置场景背景色(颜色值,透明度)
renderer.setClearColor(0x000000, 0.85);
道路实现
这里先简单实现一段不规则道路,封装一个 freespace
对象,还要考虑它的不规则和带洞的可能,所以需要做好接口定义,其实数据主要是点集,一般这些点集都是地图上游发下来的,可能是 protobuf 或者 json 的格式
export interface IFreespace {
// 一般可以用于判断元素是否可复用
id: string;
position: IPos;
contour: IPos[];
// 洞可能有多个,所以这里应该设置成二维数组
holes?: IPos[][];
color?: IColor;
}
export interface IPos {
x: number;
y: number;
z?: number;
}
export interface IColor {
r: number;
g: number;
b: number;
a?: number;
}
因为只是一个平面形状,所以可以用 THREE.Shape
来实现,它可以和 ExtrudeGeometry
、ShapeGeometry
一起使用来创建二维形状
// src/renderers/freespace.ts
class Freespace {
scene = new THREE.Scene();
constructor(scene: THREE.Scene) {
this.scene = scene;
}
draw(data: IFreespace) {
const {
contour,
holes = [],
color = { r: 0, g: 0, b: 0 },
position,
} = data;
if (contour.length < 3) {
return;
}
const shape = new THREE.Shape();
// 先绘制轮廓
// 设置起点
shape.moveTo(contour[0].x, contour[0].y);
contour.forEach((item) => shape.lineTo(item.x, item.y));
// 绘制洞
holes.forEach((item) => {
if (item.length < 3) {
return;
}
const path = new THREE.Path();
path.moveTo(item[0].x, item[0].y);
item.forEach((subItem) => {
path.lineTo(subItem.x, subItem.y);
});
// 注意这一步
shape.holes.push(path);
});
const shapeGeometry = new THREE.ShapeGeometry(shape);
const material = new THREE.MeshPhongMaterial();
// 注意:setRGB传参颜色值需要介于0-1之间
material.color.setRGB(color.r / 255, color.g / 255, color.b / 255);
material.opacity = color.a || 1;
const mesh = new THREE.Mesh(shapeGeometry, material);
mesh.position.set(position.x, position.y, position.z || 0);
mesh.rotateX(-Math.PI / 2);
this.scene.add(mesh);
}
}
export default Freespace;
ok先用mock的数据画一段带洞的十字路口,加在 initialize
代码后就行,其实道路上还应该有一些交通标线,后面再加上吧
最后再监听下界面的 resize 事件,使其能根据容器实际大小变化动态调整场景
// ...
constructor() {
// 初始化渲染对象
this.renderers = {
freespace: new Freespace(this.scene),
};
}
initialize() {
// ...
this.loadEgoCar();
this.registerDefaultEvents();
// mock
this.mockData();
}
mockData() {
this.renderers.freespace.draw(freespaceData1);
}
// 监听resize事件
registerDefaultEvents() {
window.addEventListener("resize", this.onResize.bind(this), false);
}
unmountDefaultEvents() {
window.removeEventListener("resize", this.onResize.bind(this), false);
}
onResize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
最后
ok先到这了,主要是先把项目搭起来,后面会继续分享下更多地图和感知元素以及他车、行人、障碍物等效果的实现
来源:juejin.cn/post/7406643531697913867
想学 pinia ?一文就够了
有时候不得不承认,官方的总结有时就是最精简的:
Pinia 是 Vue 的存储库,它允许您跨组件/页面共享状态。
虽然作为Vuex的升级版,但为了尊重原作者,所以取名pinia,而没有取名Vuex,所以大家可以直接将pinia比作为Vue3的Vuex,同时,pinia提供了一种更简洁、更直观的方式来处理应用程序的状态,更为重要的是,pinia的学习成本更低,低到一篇文章就能涵盖pinia的全部。
Pinia的安装与配置:
首先自然是安装pinia,在基于Vue3的项目环境中,提供了npm
与yarn
两种安装方式:
npm install pinia
yarn add pinia
随后,通常就是在src
目录下新建一个专属的store
文件夹,在其中的js
文件中创建并抛出这个仓库。
import { createPinia } from 'pinia' // 引入pinia模块
const store = createPinia() // 创建一个仓库
export default store // 抛出这个仓库
既然把这个仓库抛出了,那么现在便是让它能在全局起作用,于是在Vue的主要应用文件中(通常为main.js),引入使用pinia
。
import { createApp } from 'vue'
import App from './App3.vue'
import store from './store' //引入这个仓库
createApp(App).use(store).mount('#app') // 再use一下
这样一来pinia
仓库就能全局生效了!
Pinia的主要功能:
在官方文档中,Pinia提供了四种功能,分别是:
- Store:在Pinia中,每个状态管理模块都被称为一个Store。开发者需要创建一个Store实例来定义和管理状态。
- State:在Store中定义状态。可以使用defineState函数来定义一个状态,并通过state属性来访问它。
- Getters:类似于Vuex中的getters,用于从State中派生出一些状态。可以使用
defineGetters
函数来定义getters。 - Actions:在Pinia中,Actions用于处理异步操作或执行一些副作用。可以使用
defineActions
函数来定义Actions。
那么接下来我会通过一个具体的实例来表现出这四个功能,如下图:
分别是充当仓库的Store功能。存储子组件User.vue
中数据的State功能。另一个子组件Update-user.vue
中,点击按钮后数据会实现更新,也就是修改State中数据的Actions功能。与无论点击多少次” 经过一年后按钮 ”,页面都会实现同步更新的Getters功能。
State:
简单来说,State的作用就是作为仓库的数据源。
就比如说,我想在仓库的数据源里面放上一个对象来进行使用,那我们只需在先前创建的store
文件夹中再创建一个js
文件,这里我给它起名为user
,然后再其中这样添加对象。
(第一行引入的defineStore
代表defineStore
是store
的一部分。)
import { defineStore } from 'pinia' // defineStore 是 store 的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
})
})
那么现在,我们想使用仓库中的数据就成为了一件非常容易的事。
正如上图,这里有一个父组件App.vue
,两个子组件User.vue
、Update-user.vue
。
父组件不做任何动作,只包含对两个子组件的引用:
<template>
<User/>
<Updateuser/>
</template>
<script setup>
import User from './components/User.vue'
import Updateuser from './components/Update-user.vue'
</script>
<style lang="css" scoped>
</style>
子组件User.vue:
可以看到在这个子组件中,我们通过import { useUserStore } from '@/store/user'
引用仓库,从而获得了仓库中小明姓名、年龄、性别的数据。
由于接下来的Update-user.vue
组件中会添加几个按钮对这些数据进行修改,那么我们就要把这些数据设置成响应式。
正常情况下,store
自带响应性,但如果我们不想每次都写userStore.userInfo.name
这么长一大串,就可以尝试将这些值取出来赋给其他变量:
这里有两种方法,第一种是引入computed
模块,如第14行年龄的修改。另一种是引入storeToRefs
模块,这是一种属于Pinia
仓库的模块,将整个userInfo
变成响应式。
于是接下来,就轮到我们的Actions登场了
<template>
<ul>
<li>姓名:{{ userStore.userInfo.name }}</li>
<li>年龄:{{ age }}</li>
<li>性别;{{ userInfo.sex }}</li>
</ul>
</template>
<script setup>
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const age = computed(() => userStore.userInfo.age) // 1. 计算属性使响应式能生效
const { userInfo } = storeToRefs(userStore) // 2. 专门包裹仓库中函数用来返回对象
</script>
<style lang="scss" scoped>
</style>
Actions:
简单来说,Actions的作用就是专门用来修改State,如果你想要修改仓库中的响应式元素,只需要进行两步操作:
第一步:在user.js
也就是我们的仓库中添加actions
,专门设置函数用来修改state对象中的值。例如changeUserName
作用是修改姓名, changeUserSex
作用是修改性别。
import { defineStore } from 'pinia' // defineStore 是 store的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
}),
actions: { // 专门用来修改state
changeUserName(name) {
this.userInfo.name = name
},
changeUserSex(sex){
this.userInfo.sex = sex
}
}
})
子组件Update-user.vue:
第二步,在控制按钮的组件Update-user.vue
中触发这两个函数,就如第10与14行的两个箭头函数。
<template>
<button @click="changeName">修改仓库中用户姓名</button>
<button @click="changeSex">修改仓库中用户性别</button>
</template>
<script setup>
import { useUserStore } from '@/store/user' // 引入Pinia仓库
const userStore = useUserStore() // 声明仓库
const changeName = () => { // 触发提供的函数
userStore.changeUserName('小红')
}
const changeSex = () => {
userStore.changeUserSex('gril')
}
</script>
<style lang="css" scoped>
</style>
这样一来,依赖于Actions,我们就成功完成了响应式修改仓库中数据的功能,也就是前两个按钮的功能!
Getters:
简单来说Getters就是仓库中的计算属性。
现在我们来实现第三个按钮功能,首先就是在User.vue
组件中第5行,添加 “ 十年之后年龄 ” 一栏:
<template>
<ul>
<li>姓名:{{userStore.userInfo.name}}</li>
<li>年龄:{{ age }}</li>
<li>十年后年龄:{{ userStore.afterAge }}</li> // 添加的栏
<li>性别:{{ userInfo.sex }}</li>
</ul>
</template>
<script setup>
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const age = computed(() => userStore.userInfo.age)
const { userInfo } = storeToRefs(userStore)
</script>
<style lang="scss" scoped>
</style>
那么现在你一定能注意到这一栏其中的userStore.afterAge
,这正是我们将在getters中返回的值。
那么关于getters,具体的使用方法就是继续在user.js
中添加进getters,我们在其中打造了一个afterAge
函数来返回userStore.afterAge
,正如第25行。
import { defineStore } from 'pinia' // defineStore 是 store的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
}),
actions: { // 专门用来修改state
changeUserName(name) {
this.userInfo.name = name
},
changeUserSex(sex){
this.userInfo.sex = sex
},
changeUserAge(age){ // 新添加的一年后年龄计算方法
this.userInfo.age += age
}
},
getters: { // 仓库中的计算属性,所依赖的值改变会重新执行
afterAge(state) {
return state.userInfo.age + 10
}
}
})
准备工作完毕,现在就该在页面上添加这个按钮,于是在组件Update-user.vue
添加上按钮与执行函数。
<button @click="changeAge">经过一年后</button>
const changeAge = () => {
userStore.changeUserAge(1)
}
有了这些之后,这个项目的功能便彻底完善,无论点击多少次“ 经过一年后 ”按钮,在页面上显示的值都是正确且实时更新的,这就是Getters的功劳!
补充:数据持久化
关于整个项目的功能实现确实已经结束,但人的贪心却是不得满足的,如果我们想要在原有的基础上实现网页刷新数据却不刷新,也就是说数据的持久化,那又该怎么办呢?
很简单,也就是堪堪三步,便能实现。
第一步:安装persist
插件。
npm i pinia-plugin-persist
第二步:在store
的js
文件中引入这个插件。
import { createPinia } from 'pinia'
import piniaPluginPersist from 'pinia-plugin-persist' //引入插件
const store = createPinia()
store.use(piniaPluginPersist) // 使用插件
export default store
第三步:在我们前文user.js
的defineStore
库内继续添加上persist
功能。
persist: { // 持久化
enabled: true,
strategies: [ // 里面填想要持久化的数据
{
paths: ['userInfo'], // 指明持久化的数据
storage: localStorage // 指明存储
}
]
}
现在可以看到点击按钮后的数据都被存储到浏览器的存储空间中,无论多少次刷新都不会被重置!
最后:
至此,这样一个简简单单的项目,却解释清楚了Pinia功能的核心,读完这篇文章,相信每一个学习Pinia的人都能有所收获。
来源:juejin.cn/post/7407407711879807026
你知道为什么template中不用加.value吗?
Vue3 中定义的ref
类型的变量,在setup
中使用这些变量是需要带上.value
才可以访问,但是在template
中却可以直接使用。
询其原因,可能会说 Vue 自动进行ref
解包了,那具体如何实现的呢?
proxyRefs
Vue3 中有有个方法proxyRefs
,这属于底层 API 方法,在官方文档中并没有阐述,但是 Vue 里是可以导出这个方法。
例如:
<script setup>
import { onMounted, proxyRefs, ref } from "vue";
const user = {
name: "wendZzoo",
age: ref(18),
};
const _user = proxyRefs(user);
onMounted(() => {
console.log(_user.name);
console.log(_user.age);
console.log(user.age);
});
</script>
上面代码定义了一个普通对象user
,其中age
属性的值是ref
类型。当访问age
值的时候,需要通过user.age.value
,而使用了proxyRefs
,可以直接通过user.age
来访问。
这也就是为何template
中不用加.value
的原因,Vue3 源码中使用proxyRefs
方法将setup
返回的对象进行处理。
实现proxyRefs
单测
it("proxyRefs", () => {
const user = {
name: "jack",
age: ref(10),
};
const proxyUser = proxyRefs(user);
expect(user.age.value).toBe(10);
expect(proxyUser.age).toBe(10);
proxyUser.age = 20;
expect(proxyUser.age).toBe(20);
expect(user.age.value).toBe(20);
proxyUser.age = ref(30);
expect(proxyUser.age).toBe(30);
expect(user.age.value).toBe(30);
});
定义一个age
属性值为ref
类型的普通对象user
。proxyRefs
方法需要满足:
proxyUser
直接访问age
是可以直接获取到 10 。- 当修改
proxyUser
的age
值切这个值不是ref
类型时,proxyUser
和原数据user
都会被修改。 age
值被修改为ref
类型时,proxyUser
和user
也会都更新。
实现
既然是访问和修改对象内部的属性值,就可以使用Proxy
来处理get
和set
。先来实现get
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key) {}
});
}
需要实现的是proxyUser.age
能直接获取到数据,那原数据target[key]
是ref
类型,只需要将ref.value
转成value
。
使用unref
即可实现,unref
的实现参见本专栏上篇文章,文章地址:mp.weixin.qq.com/s/lLkjpK9TG…
get(target, key) {
return unref(Reflect.get(target, key));
}
实现set
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key) {
return unref(Reflect.get(target, key));
},
set(target, key, value) {},
});
}
从单侧中可以看出,我们是测试了两种情况,一种是修改proxyUser
的age
为ref
类型, 一种是修改成不是ref
类型的,但是结果都是同步更新proxyUser
和user
。那实现上也需要考虑这两种情况,需要判断原数据值是不是ref
类型,新赋的值是不是ref
类型。
使用isRef
可以判断是否为ref
类型,isRef
的实现参见本专栏上篇文章,文章地址:mp.weixin.qq.com/s/lLkjpK9TG…
set(target, key, value) {
if (isRef(target[key]) && !isRef(value)) {
return (target[key].value = value);
} else {
return Reflect.set(target, key, value);
}
}
当原数据值是ref
类型且新赋的值不是ref
类型,也就是单测中第 1 个情况赋值为 10,将ref
类型的原值赋值为value
,ref
类型值需要.value
访问;否则,也就是单测中第 2 个情况,赋值为ref(30)
,就不需要额外处理,直接赋值即可。
验证
执行单测yarn test ref
来源:juejin.cn/post/7303435124527333416
将html转化成图片
如何将指定html内容转化成图片保存?这个问题很值得深思,实际应用中也很有价值。最直接的想法就是使用
canvas
,熟悉canvas
的同学可以尝试一下。这里不做太多的说明,本文采用html2canvas
库来实现。
html2canvas
库的使用非常简单,只需要引入html2canvas
库,然后调用html2canvas
方法即可,官方地址。
接下来说一下简单的使用,以react
项目为例。
获取整个页面截图,可以使用底层IDroot
,这样下载的就是root
下的所有元素。

import html2canvas from "html2canvas";
const saveCanvas = () => {
// 画布基础元素,要绘制的元素
const canvas: any = document.getElementById("root");
const options: any = { scale: 1, useCORS: true };
html2canvas(canvas, options).then((canvas) => {
const type = "png";
// 返回值是一个数据url,是base64组成的图片的源数据
let imgDt = canvas.toDataURL(type);
let fileName = "img" + "." + type;
// 保存为文件
let a = document.createElement("a");
document.body.appendChild(a);
a.href = imgDt;
a.download = fileName;
a.click();
});
};
图片的默认背景色是#ffffff
,如果想要透明色可设置为null
,比如设置为红色。

const options: any = { scale: 1, useCORS: true, backgroundColor: "red" };
正常情况下网络图片是无法渲染的,可以使用useCORS
属性,设置为true
即可。

const options: any = { scale: 1, useCORS: true };
保存某块元素的截图

const canvas: any = document.getElementById("swiper");
如果希望将某些元素排除,可以将data-html2canvas-ignore
属性添加到这些元素中,html2canvas
将从渲染中排除这些元素。

<Button
data-html2canvas-ignore
color="primary"
fill="solid"
onClick={saveCanvas}
>
download
</Button>
完整代码
npm install html2canvas
// demo.less
.contentSwiper {
width: 710px;
height: 375px;
color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
font-size: 48px;
user-select: none;
}
.swiper {
padding: 0 20px;
}
import React from "react";
import { Button, Space, Swiper } from "antd-mobile";
import html2canvas from "html2canvas";
import styles from "./demo.less";
export default () => {
const saveCanvas = () => {
// 画布基础元素,要绘制的元素
const canvas: any = document.getElementById("root");
const options: any = { scale: 1, useCORS: true, backgroundColor: "red"
};
html2canvas(canvas, options).then((canvas) => {
const type = "png";
// 返回值是一个数据url,是base64组成的图片的源数据
let imgDt = canvas.toDataURL(type);
let fileName = "img" + "." + type;
// 保存为文件
let a = document.createElement("a");
document.body.appendChild(a);
a.href = imgDt;
a.download = fileName;
a.click();
});
};
const colors: string[] = ["#ace0ff", "#bcffbd", "#e4fabd", "#ffcfac"];
const items = colors.map((color, index) => (
<Swiper.Item key={index}>
<div className={styles.contentSwiper} style={{ background: color }}>
{index + 1}
</div>
</Swiper.Item>
));
return (
<div className="content">
<div id="swiper" className={styles.swiper}>
<Swiper
style={{
"--track-padding": " 0 0 16px",
}}
defaultIndex={1}
>
{items}
</Swiper>
</div>
<div>
<img
width={200}
src="https://t7.baidu.com/it/u=2621658848,3952322712&fm=193&f=GIF"
/>
</div>
<Space>
<Button
data-html2canvas-ignore
color="primary"
fill="solid"
onClick={saveCanvas}
>
download
</Button>
<Button color="primary" fill="solid">
Solid
</Button>
<Button color="primary" fill="outline">
Outline
</Button>
<Button color="primary" fill="none">
</Button>
</Space>
</div>
);
};
来源:juejin.cn/post/7407457177483608118
Vue3中watch好用,但watchEffect、watchSyncEffect、watchPostEffect简洁
比较好奇vue项目中使用watch还是watchEffect居多,查看了element-plus、ant-design-vue两个UI库, 整体上看,watch使用居多,而watchEffect不怎么受待见,那这两者之间有什么关系?
API | watch | watchEffect | watchSyncEffect | watchPostEffect |
---|---|---|---|---|
element-plus | 198 | 28 | 0 | 0 |
ant-design-vue | 263 | 168 | 0 | 0 |
watchEffect是watch的衍生
为什么说watchEffect是watch的衍生?
- 首先,两者提供功能是有重叠。大部分监听场景,两者都能满足。
const list = ref([]);
const count = ref(0);
watch(
list,
(newValue) => {
count.value = newValue.length;
}
)
watchEffect(() => {
count.value = list.value.length;
})
- 其次,源码上两者也都是同一出处。以下是两者的函数定义:
export function watch(
source: T | WatchSource,
cb: any,
options?: WatchOptions,
): WatchStopHandle {
return doWatch(source as any, cb, options)
}
export function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase,
): WatchStopHandle {
return doWatch(effect, null, options)
}
两者内部都调用doWatch函数,并且返回都是WatchStopHandle类型。唯独入参上有比较大的区别,watch的source参数就像大杂烩,支持PlainObject、Ref、ComputedRef以及函数类型;而watchEffect的effect参数仅仅是一个函数类型。
watch早于watchEffect诞生,watch源代码有这样一句提示:
if (__DEV__ && !isFunction(cb)) {
warn(
`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
`Use \`watchEffect(fn, options?)\` instead. \`watch\` now only ` +
`supports \`watch(source, cb, options?) signature.`,
)
}
也就是说历史的某一个版本,watch也是支持watch(fn, options?)
用法,但为了降低API复杂度,将这部分功能迁移至watchEffect函数。一个优秀框架的发展历程也不过如此,都是在不断的重构升级。
话又说回来,到目前,为什么大部分Vue开发者更偏向于使用watch,而不是watchEffect?
,带着这个问题,庖丁解牛式层层分析。
watch、watchEffect底层逻辑
当我们把watch、watchEffect底层逻辑看透,剩下的watchSyncEffect、watchPostEffect也就自然了解。
先回顾下watch、watchEffect内部调用doWatch的参数:
// watch
doWatch(source as any, cb, options)
// demo
watch(
list,
(newValue) => {
count.value = newValue.length;
}
)
// watchEffect
doWatch(effect, null, options)
// demo
watchEffect(() => {
count.value = list.value.length;
})
入参的区别,如下表所示:
API | arg1 | arg2 | arg3 |
---|---|---|---|
watch | T | WatchSource | cb | WatchOptions |
watchEffect | WatchEffect | null | WatchOptionsBase |
根据参数对比,先抛出两个问题:
1. doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?
2. 第三个参数WatchOptions
watchOptions
export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
immediate?: Immediate
deep?: boolean
once?: boolean
}
export interface WatchOptionsBase extends DebuggerOptions {
flush?: 'pre' | 'post' | 'sync'
}
WatchOptionsBase仅提供了flush,因此watchEffect函数的第三个参数也只有flush一个选项。
flush包含pre
、post
、sync
三个值,缺省为pre。它明确了监听器的触发时机,pre和post比较明确,对应渲染前、渲染后。
sync官方定义为:在某些特殊情况下 (例如要使缓存失效),可能有必要在响应式依赖发生改变时立即触发侦听器
。简而言之,依赖的多个变量,只要其中一个有更新,监听器就会触发一次。
const list = ref([]);
const page = ref(1);
const message = ref('');
watchEffect(() => {
message.value = `总量${list.value.length}, 当前页:${page.value}`
console.log(message.value);
}, { flush: 'sync' })
例如上述的list、page任意一个有更新,则会输出一次console。sync模式得慎重使用,例如监听的是数组,其中一项有更新都会触发监听器,可能带来不可预知的性能问题。
post
也有明确的应用场景,例如:当页面侧边栏显示或隐藏后,需要容器渲染完成后再更新内部的图表等元素。不使用flush选项的解法,一般是监听visible变化并使用setTimeout延迟更新。有了post
,一个属性即可搞定。
watch(visible, (value) => {
setTimeout(() => {
// 更新容器内图表
}, 1000);
})
watch(visible, (value) => {
// 更新容器内图表
}, { flush: 'post' })
完成了第二个问题的解答, 要回答第一个问题,需要深入doWatch函数, 在上一篇《写Vue大篇幅的ref、computed,而reactive为何少见?》也有对doWatch做局部介绍,可以作为辅助参考。
doWatch源码
先从doWatch函数签名上,对其有概括性的认识:
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{
immediate,
deep,
flush,
once,
onTrack,
onTrigger,
}: WatchOptions = EMPTY_OBJ,
): WatchStopHandle
由于我们主要目的是回答问题:doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?
因此仅分析source为WatchEffect
的情况,此时,cb为null, 第三个参数仅有flush选项。
WatchEffect
类型定义如下:
export type WatchEffect = (onCleanup: OnCleanup) => void
onCleanup
参数的作用是,在下一次监听器执行前被触发,通常用于状态清理。
doWatch函数实现,最核心的片段是ReactiveEffect的生成:
const effect = new ReactiveEffect(getter, NOOP, scheduler)
为什么ReactiveEffect是其核心?因为它起到了"中介"的作用,在监听器函数内,每一个可监听
的变量都对应有依赖项集合deps,当调用这些变量的getter时,ReactiveEffect会把自身注入到依赖集合deps中,这样每当执行变量的setter时,deps集合中的副作用都会触发,而每个副作用effect内部会调用scheduler, scheduler可理解为调度器,负责处理视图更新时机,scheduler内部选择合适的时机触发监听器。
接下来着重看getter、scheduler定义,当source为WatchEffect
类型时,getter定义片段如下:
// no cb -> simple effect
getter = () => {
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onCleanup],
)
}
首先执行cleanup,也就是说如果参数传入有onCleanup回调,那么每次在获取新值前都会触发onCleanup。其次是return语句,调用callWithAsyncErrorHandling函数,从函数可探察之,一方面支持异步,另一方面处理异常错误。
支持异步:也就是我们传入的监听器可以是一个异步函数,那么我们可以在其中执行远程请求的调用,例如官方给的示例, 当id.value值变化,从远端请求数据await response
,并赋值给data.value。
watchEffect(async (onCleanup) => {
const { response, cancel } = doAsyncWork(id.value)
// `cancel` 会在 `id` 更改时调用
// 以便取消之前未完成的请求
onCleanup(cancel)
data.value = await response
})
上述示例中,如果id.value频繁更新,则会导致触发多次远端请求,要解决该问题,可调用onCleanup(cancel)
,将cancel传入到doWatch内部,并且每次执行cleanup
时被调用。onCleanup定义如下:
let cleanup: (() => void) | undefined
let onCleanup: OnCleanup = (fn: () => void) => {
cleanup = effect.onStop = () => {
callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
cleanup = effect.onStop = undefined
}
}
其中,fn即为上述示例中的cancel,这样就建立了cancel和cleanup的关联,因此每次更新前,先调用cancel中断上一次请求。
callWithAsyncErrorHandling
函数定义如下:
export function callWithAsyncErrorHandling(fn,instance,type,args?): any {
...
const res = callWithErrorHandling(fn, instance, type, args)
if (res && isPromise(res)) {
res.catch(err => {
handleError(err, instance, type)
})
}
return res
...
}
res为fn函数执行结果,由于支持同步、异步。如果fn为异步函数,那么res为Promise类型,并且对异常做了兜底处理。
当fn函数执行后,内部所有可监听变量的deps都会添加上当前effect,所以只要变量有更新,effect的scheduler就被触发。
watchEffect官方定义有:立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。“立即运行一个函数”如何体现?
doWatch
函数的最后几行代码如下:
if (flush === 'post') {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense,
)
} else {
effect.run()
}
如果flush不为post
,那么立即执行effect.run()
, 而run函数会调用getter,因此会立即运行监听器函数一次;如果flush为post
,那么effect将会在vue下一次渲染前第一次执行effect.run()
。
至此,我们就分析完watchEffect
的底层逻辑,总结其特点:立即执行,支持异步,并且会自动监听变量更新。
为什么不能两者取一,而必须共存
再次回顾watch的定义:
export function watch(
source: T | WatchSource,
cb: any,
options?: WatchOptions,
) : WatchStopHandle {
return doWatch(source as any, cb, options)
}
其中WatchOptions包含的选项有:immediate、deep、once、flush。如果是watchEffect,选项仅有flush,并且immediate相当于true,剩下的deep、once不支持配置。
先说watchEffect的缺点:
- 不支持immediate为false,必须是立即执行。例如下面的代码,由于autoplay默认false,初始化时不需要立即执行。如果是watchEffect,则pauseTimer初始化会执行一次,完全没必要。
watch(
() => props.autoplay,
(autoplay) => {
autoplay ? startTimer() : pauseTimer()
}
)
- 不支持deep为true的场景,只能见监听当前使用的属性。但如果是调用
watch(source, cb, { deep: true })
, 则会通过traverse(source)
将source所有深度属性读取一次,和effect建立关联,达到自动监听所有属性的目的。 - 异步使用有坑,
watchEffect
仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个await
正常工作前访问到的属性才会被追踪。
再说watchEffect优点:
优点也是非常明显,写法非常简洁,无需显式声明监听哪些变量,一个回调函数搞定,并且默认为立即执行,我认为能满足开发中80%的应用场景。另一方面,由于只监听回调中使用的属性,相比于deep为true的一锅端方式,watchEffect则更加直观明了。
总结
watchSyncEffect、watchPostEffect和watchEffect唯一的区别是:flush分别固定为sync
、post
。所以,watchEffect为watch的衍生,而watchSyncEffect、watchPostEffect为watchEffect的衍生。
对于开发使用上:
- watchPostEffect、watchSyncEffect仅在极少数的特殊场景下才使用,完全可以用watchEffect(fn, { flush: 'sync' | 'post' })代替,多了反而对入门开发者来说是徒增干扰。
- 个人认为应优先使用watchEffect函数,毕竟代码写法上更加简洁,属性依赖上也更加明确。满足不了的场景,再考虑使用watch。
来源:juejin.cn/post/7401415643981185078
vue3为啥推荐使用ref而不是reactive
在 Vue 3 中,ref
和 reactive
都是用于声明响应式状态的工具,但它们的使用场景和内部工作机制有所不同。Vue 3 推荐使用 ref
而不是 reactive
的原因主要涉及到以下几个方面:
- 简单的原始值响应式处理:
ref
更适合处理简单的原始值(如字符串、数字、布尔值等),而reactive
更适合处理复杂的对象或数组。
- 一致性和解构:
- 使用
ref
时,解构不会丢失响应性,因为ref
会返回一个包含.value
属性的对象。而reactive
对象在解构时会丢失响应性。
- 使用
- 类型推导和代码提示:
ref
更容易与 TypeScript 配合使用,提供更好的类型推导和代码提示。
示例代码
以下是一个详细的代码示例,演示为什么在某些情况下推荐使用 ref
而不是 reactive
。
使用 ref
的示例
import { ref } from 'vue';
export default {
setup() {
// 使用 ref 声明响应式状态
const count = ref(0);
function increment() {
count.value++;
}
return {
count,
increment
};
}
};
使用 reactive
的示例
import { reactive } from 'vue';
export default {
setup() {
// 使用 reactive 声明响应式状态
const state = reactive({
count: 0
});
function increment() {
state.count++;
}
return {
state,
increment
};
}
};
解构问题
使用 ref
解构
import { ref } from 'vue';
export default {
setup() {
const count = ref(0);
function increment() {
count.value++;
}
// 解构时不会丢失响应性
const { value: countValue } = count;
return {
countValue,
increment
};
}
};
使用 reactive
解构
import { reactive } from 'vue';
export default {
setup() {
const state = reactive({
count: 0
});
function increment() {
state.count++;
}
// 解构时会丢失响应性
const { count } = state;
return {
count,
increment
};
}
};
代码解释
- 使用
ref
:
ref
返回一个包含.value
属性的对象,因此在模板中使用时需要通过.value
访问实际值。- 解构时,可以直接解构
.value
属性,不会丢失响应性。
- 使用
reactive
:
reactive
适用于复杂的对象或数组,返回一个代理对象。- 直接解构
reactive
对象的属性会丢失响应性,因为解构后得到的属性是原始值,不再是响应式的。
总结
- 简单值:对于简单的原始值(如字符串、数字、布尔值等),推荐使用
ref
,因为它更简洁,并且在解构时不会丢失响应性。 - 复杂对象:对于复杂的对象或数组,推荐使用
reactive
,因为它可以更方便地处理嵌套属性的响应性。 - 一致性:
ref
在解构时不会丢失响应性,而reactive
在解构时会丢失响应性,这使得ref
在某些情况下更为可靠。
通过理解 ref
和 reactive
的不同使用场景和内部工作机制,可以更好地选择适合的工具来管理 Vue 3 应用中的响应式状态。
来源:juejin.cn/post/7402869746175393807
Node拒绝当咸鱼,Node 22大进步
这几年,deno和bun风头正盛,大有你方唱罢我登场的态势,deno和bun的每一次更新版本,Node都会被拿来比较,比较结果总是Node落后了。
这种比较是不是非常熟悉,就像卖手机的跟iPhone比,卖汽车的跟特斯拉比,比较的时候有时候还得来个「比一分钱硬币还薄」的套路。
Node虽然没有落后了,但是确实有点压力了,所以20和22版本都大跨步前进,拒绝当咸鱼了。
因为Node官网对22版本特性的介绍太过简单,所以我决定来一篇详细介绍新特性的文章,让学习Node的朋友们知道,Node现在在第几层。
首先我把新特性分为两类,分别是:开发者可能直接用到的特性、开发者相对无感知的底层更新。本文重点介绍前者,简单介绍后者。先来一个概览:
开发者可能直接用到的特性:
- 支持通过
require()
引入ESM - 运行
package.json
中的脚本 - 监视模式(
--watch
)稳定化 - 内置 WebSocket 客户端
- 增加流的默认高水位线
- 文件模式匹配功能
开发者相对无感知的底层更新:
- V8 引擎升级至 12.4 版本
- Maglev 编译器默认启用
- 改进
AbortSignal
的创建性能
接下来开始介绍。
支持通过 require()
导入 ESM
以前,我们认为 CommonJS 与 ESM 是分离的。
例如,在 CommonJS里,我们用并使用 module.exports
导出模块,用 require()
导入模块:
// CommonJS
// math.js
function add(a, b) {
return a + b;
}
module.exports.add = add;
// useMath.js
const math = require('./math');
console.log(math.add(2, 3));
在 ECMAScript Modules (ESM) **** 里,我们使用 export
导出模块,用 import
导入模块:
// ESM
// math.mjs
export function add(a, b) {
return a + b;
}
// useMath.js
import { add } from './math.mjs';
console.log(add(2, 3));
Node 22 支持新的方式——用 require()
导入 ESM:
// Node 22
// math.mjs
export function add(a, b) {
return a + b;
}
// useMath.js
const { add } = require('./mathModule.mjs');
console.log(add(2, 3));
这么设计的原因是为了给大型项目和遗留系统提供一个平滑过渡的方案,因为这类项目难以快速全部迁移到 ESM,通过允许 require()
导入 ESM,开发者就可以逐个模块迁移,而不是一次性对整个项目进行修改。
目前这种写法还是实验性功能,所以使用是有“门槛”的:
- 启动命令需要添加
-experimental-require-module
参数,如:node --experimental-require-module app.js
- 模块标记:确保 ESM 模块通过
package.json
中的"type": "module"
或文件扩展名是.mjs
。 - 完全同步:只有完全同步的ESM才能被
require()
导入,任何含有顶级await
的ESM都不能使用这种方式加载。
运行package.json
中的脚本
假设我们的 package.json
里有一个脚本:
"scripts": {
"test": "jest"
}
在此之前,我们必须依赖 npm 或者 yanr 这样的包管理器来执行命令,比如:npm run test
。
Node 22 添加了一个新命令行标志 --run
,允许直接从命令行执行 package.json
中定义的脚本,可以直接使用 node --run test
这样的命令来运行脚本。
刚开始我还疑惑这是不是脱裤子放屁的行为,因为有 node 的地方一般都有 npm,我要这 node —run
有何用?
后来思考了一下,主要原因应该还是统一运行环境和提升性能。不同的包管理器在处理脚本时可能会有微小的差异,Node 提供一个标准化的方式执行脚本,有助于统一这些行为;而且直接使用 node 执行脚本要比通过 npm 执行脚本更快,因为绕过了 npm 这个中间层。
监视模式(--watch
)稳定化
在 19 版本里,Node 引入了 —watch
指令,用于监视文件系统的变动,并自动重启。22 版本开始,这个指令成为稳定功能了。
要启用监视模式,只需要在启动 Node 应用时加上 --watch
****参数。例如:
node --watch app.js
正在用 nodemon 做自动重启的朋友们可以正式转战 --watch
了~
内置 WebSocket 客户端
以前,要用 Node 开发一个 socket 服务,必须使用 ws、socket.io 这样的第三方库来实现。第三方库虽然稳如老狗帮助开发者许多年,但是终究是有点不方便。
Node 22 正式内置了 WebSocket,并且属于稳定功能,不再需要 -experimental-websocket
来启用了。
除此之外,WebScoket 的实现还遵循了浏览器中 WebSocket API 的标准,这意味着在 Node 中使用 WebSocket 的方式将与在 JavaScript 中使用 WebSocket 的方式非常相似,有助于减少学习成本并提高代码的一致性。
用法示例:
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", (event) => {
socket.send("Hello Server!");
});
增加流(streams)的默认高水位线(High Water Mark)
streams 在 Node 中有举足轻重的作用,读写数据都得要 streams 来完成。而 streams 可以设置 highWaterMark
参数,用于表示缓冲区的大小。highWaterMark
越大,缓冲区越大,占用内存越多,I/O 操作就减少,highWaterMark
越小,其他信息也对应相反。
用法如下:
const fs = require('fs');
const readStream = fs.createReadStream('example-large-file.txt', {
highWaterMark: 1024 * 1024 // 设置高水位线为1MB
});
readStream.on('data', (chunk) => {
console.log(`Received chunk of size: ${chunk.length}`);
});
readStream.on('end', () => {
console.log('End of file has been reached.');
});
虽然 highWaterMark
是可配置的,但通常情况下,我们是使用默认值。在以前的版本里,highWaterMark
的默认值是 16k,Node 22 版本开始,默认值被提升到 64k 了。
文件模式匹配——glob 和 globSync
Node 22 版本在 fs 模块中新增了 glob
和 globSync
函数,它们用于根据指定模式匹配文件路径。
文件模式匹配允许开发者定义一个匹配模式,以找出符合特定规则的文件路径集合。模式定义通常包括通配符,如 *
(匹配任何字符)和 ?
(匹配单个字符),以及其他特定的模式字符。
glob 函数(异步)
glob
函数是一个异步的函数,它不会阻塞 Node.js 的事件循环。这意味着它在搜索文件时不会停止其他代码的执行。glob
函数的基本用法如下:
const { glob } = require('fs');
glob('**/*.js', (err, files) => {
if (err) {
throw err;
}
console.log(files); // 输出所有匹配的.js文件路径
});
在这个示例中,glob
函数用来查找所有子目录中以 .js
结尾的文件。它接受两个参数:
- 第一个参数是一个字符串,表示文件匹配模式。
- 第二个参数是一个回调函数,当文件搜索完成后,这个函数会被调用。如果搜索成功,
err
将为null
,而files
将包含一个包含所有匹配文件路径的数组。
globSync 函数(同步)
globSync
是 glob
的同步版本,它会阻塞事件循环,直到所有匹配的文件都被找到。这使得代码更简单,但在处理大量文件或在需要高响应性的应用中可能会导致性能问题。其基本用法如下:
const { globSync } = require('fs');
const files = globSync('**/*.js');
console.log(files); // 同样输出所有匹配的.js文件路径
这个函数直接返回匹配的文件数组,适用于脚本和简单的应用,其中执行速度不是主要关注点。
使用场景
这两个函数适用于:
- 自动化构建过程,如自动寻找和处理项目中的 JavaScript 文件。
- 开发工具和脚本,需要对项目目录中的文件进行批量操作。
- 任何需要从大量文件中快速筛选出符合特定模式的文件集的应用。
V8 引擎升级至 12.4 版本
从这一节开始,我们了解一下开发者相对无感知的底层更新,第一个就是 V8 引擎升级到 12.4 版本了,有了以下特性升级:
- WebAssembly 垃圾回收:这一特性将改善 WebAssembly 在内存管理方面的能力。
- Array.fromAsync:这个新方法允许从异步迭代器创建数组。
- Set 方法和迭代器帮助程序:提供了更多内建的Set操作和迭代器操作的方法,增强了数据结构的操作性和灵活性。
Maglev 编译器默认启用
Maglev 是 V8 的新编译器,现在在支持的架构上默认启用。它主要针对短生命周期的命令行程序(CLI程序)性能进行优化,通过改进JIT(即时编译)的效率来提升性能。这对开发者编写的工具和脚本将带来明显的速度提升。
改进AbortSignal
的创建性能
在这次更新中,Node 提高了 AbortSignal
实例的创建效率。AbortSignal
是用于中断正在进行的操作(如网络请求或任何长时间运行的异步任务)的一种机制。通过提升这一过程的效率,可以加快任何依赖这一功能的应用,如使用 fetch
进行HTTP请求或在测试运行器中处理中断的场景。
AbortSignal
的工作方式是通过 AbortController
实例来管理。AbortController
提供一个 signal
属性和一个 abort()
方法。signal
属性返回一个 AbortSignal
对象,可以传递给任何接受 AbortSignal
的API(如fetch
)来监听取消事件。当调用abort()
方法时,与该控制器关联的所有操作将被取消。
const controller = new AbortController();
const signal = controller.signal;
fetch(url, { signal })
.then(response => response.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error('Fetch error:', err);
}
});
// 取消请求
controller.abort();
总结
最后,我只替 Node 说一句:Node 没有这么容易被 deno 和 bun 打败~
来源:juejin.cn/post/7366185272768036883
用了这么久Vue,你用过这几个内置指令提升性能吗?
前言
Vue
的内置指令估计大家都用过不少,例如v-for
、v-if
之类的就是最常用的内置指令,但今天给大家介绍几个平时用的比较少的内置指令。毕竟这几个Vue
内置指令可用可不用,不用的时候系统正常跑,但在对的地方用了却能提升系统性能,下面将结合示例进行详细说明。
一、v-once
作用:在标签上使用v-once
能使元素或者表达式只渲染一次。首次渲染之后,后面数据再发生变化时使用了v-once
的地方都不会更新,因此用在数据不需要变化的地方就能进行性能优化。
v-once
指令实现原理: Vue
组件初始化时会标记上v-once
,首次渲染会正常执行,后续再次渲染时如果看到有v-once
标记则跳过二次渲染。
示例代码: 直接作用在标签上,可以是普通标签也可以是图片标签,当2S
后数据变化时标签上的值不会重新渲染更新。
<template>
<div>
<span v-once>{{ message }}</span>
<img v-once :src="imageUrl"></img>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('Vue指令!');
let imageSrc = ref('/path/my/image.jpg');
setTimeout(() => {
message.value = '修改内容!';
imageUrl.value = '/new/path/my/images.jpg';
}, 2000);
</script>
注意: 作用v-once
会使属性失去响应式,要确保这个地方不需要响应式更新才能使用,否则会导致数据和页面视图对不上。
二、v-pre
作用: 在标签上使用v-pre
后,Vue
编译器会自动跳过这个元素的编译。使用此内置指令后会被视为静态内容。
v-pre
指令实现原理: Vue
初次编译时如果看到有v-pre
标记,那么跳过这部分的编译,直接当成原始的HTML
插入到DOM
中。
示例代码: 常规文本会正常编译成您好!
,但使用了v-pre
后会跳过编译原样输出{{ message }}
。
<template>
<div>
<h2>常规: {{ message }}</h2>
<h2 v-pre>使用v-pre后: {{ message }}</h2>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('您好!');
</script>
注意: 要区分v-pre
和v-once
的区别,v-once
用于只渲染一次,而v-pre
是直接跳过编译。
这个指令可能很多人没想到应用场景有那些,其实最常见的用途就是要在页面上显示
Vue
代码,如果不用v-pre
就会被编译。如下所示使用v-pre
场景效果。
<template>
<div>
<pre v-pre>
<template>
<p>{{ message }}</p>
</template>
<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue!');
</script>
</pre>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('您好!');
</script>
页面上展示: 代码原始显示不会被编译。
三、v-memo(支持3.2+版本)
作用: 主要用于优化组件的渲染方面性能,能控制达到某个条件才重新当堂组件,否则不重新渲染。v-memo
会缓存 DOM
,只有当指定的数据发生变化时才会重新渲染,从而减少渲染次数提升性能。
v-memo
指令实现原理: Vue
初始化组件时会识别是否有v-memo
标记,如果有就把这部分vnode
缓存起来,当数据变化时会对比依赖是否变化,变化再重新渲染。
示例代码: 用v-memo
绑定了arr
,那么当arr
的值变化才会重新渲染,否则不会重新渲染。
<template>
<div>
<ul v-memo="arr">
<li v-for="(item, index) in arr" :key="index">
{{ item.text }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
let arr = ref([
{ text: '内容1' },
{ text: '内容2' },
{ text: '内容3' }
]);
setInterval(() => {
arr.value[1].text = '修改2';
}, 2000);
</script>
注意: 用v-memo
来指定触发渲染的条件,但只建议在长列表或者说复杂的渲染结构才使用。
小结
总结了几个比较冷门的Vue
内置指令,平时用的不多,但用对了地方却能明显提升性能。如果那里写的不对或者有好建议欢迎大佬指出啊。
来源:juejin.cn/post/7407340295115767808
【在线聊天室😻】前端进阶全栈开发🔥
项目效果
登录注册身份认证、私聊、聊天室
项目前端React18仓库:github.com/mcmcCat/mmc…
项目后端Nestjs仓库:github.com/mcmcCat/mmc…
语雀上的笔记:http://www.yuque.com/maimaicat/t…
技术栈:
Nestjs企业级Node服务端框架+TypeOrm(Mysql)+JWT+Socket.IO🎉
React18/服务端渲染Nextjs+Redux-toolkit+styled-components🎉
登录注册身份认证、私聊、聊天室
项目前端React18仓库:github.com/mcmcCat/mmc…
项目后端Nestjs仓库:github.com/mcmcCat/mmc…
语雀上的笔记:http://www.yuque.com/maimaicat/t…
技术栈:
Nestjs企业级Node服务端框架+TypeOrm(Mysql)+JWT+Socket.IO🎉
React18/服务端渲染Nextjs+Redux-toolkit+styled-components🎉
前言
Nestjs 是一个用于构建高效可扩展的一个基于Node js 服务端 应用程序开发框架。本文不过多赘述,网上的教程有很多。
(注意:对于聊天中user模块和message模块的接口可参考仓库代码,在这里只分析登录注册的身份认证)
下面可以放张图稍微感受一下,用nest写接口很方便。 @Post('auth/register')
使用装饰器的方式,当你请求这个接口时,会自动调用下方函数AuthRegister
。另外还可以加一大堆装饰器,用于生成swagger接口文档,做本地验证、jwt验证等等。
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AppService } from './app.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth/auth.service';
import { CreateUserDto } from './user/dto/create-user.dto';
import { LoginDTO } from './auth/dto/login.dto';
import { AuthGuard } from '@nestjs/passport';
// @Controller装饰器来定义控制器,如每一个要成为控制器的类,都需要借助@Controller装饰器的装饰
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@ApiTags('JWT注册')
@Post('auth/register')
async AuthRegister(@Body() body: CreateUserDto) {
return await this.appService.authRegister(body);
}
@UseInterceptors(ClassSerializerInterceptor) //返回的数据中去除实体中被@Exclude()的字段
@UseGuards(AuthGuard('local')) //使用本地策略验证用户名和密码的正确性
@ApiTags('JWT登录')
@Post('auth/login')
async AuthLogin(@Body() body: LoginDTO, @Req() req) {
// 通过了本地策略证明身份验证通过
return await this.appService.authLogin(req.user);
}
}
Nestjs 是一个用于构建高效可扩展的一个基于Node js 服务端 应用程序开发框架。本文不过多赘述,网上的教程有很多。
(注意:对于聊天中user模块和message模块的接口可参考仓库代码,在这里只分析登录注册的身份认证)
下面可以放张图稍微感受一下,用nest写接口很方便。 @Post('auth/register')
使用装饰器的方式,当你请求这个接口时,会自动调用下方函数AuthRegister
。另外还可以加一大堆装饰器,用于生成swagger接口文档,做本地验证、jwt验证等等。
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AppService } from './app.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth/auth.service';
import { CreateUserDto } from './user/dto/create-user.dto';
import { LoginDTO } from './auth/dto/login.dto';
import { AuthGuard } from '@nestjs/passport';
// @Controller装饰器来定义控制器,如每一个要成为控制器的类,都需要借助@Controller装饰器的装饰
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@ApiTags('JWT注册')
@Post('auth/register')
async AuthRegister(@Body() body: CreateUserDto) {
return await this.appService.authRegister(body);
}
@UseInterceptors(ClassSerializerInterceptor) //返回的数据中去除实体中被@Exclude()的字段
@UseGuards(AuthGuard('local')) //使用本地策略验证用户名和密码的正确性
@ApiTags('JWT登录')
@Post('auth/login')
async AuthLogin(@Body() body: LoginDTO, @Req() req) {
// 通过了本地策略证明身份验证通过
return await this.appService.authLogin(req.user);
}
}
Nestjs中如何进行身份认证?
密码加密 和 生成token
我们可以跟着代码仓库,带有详细的注释,一步步地走
app.service.ts
负责定义注册authRegister
和登录authLogin
- 在注册时,拿到用户输入的密码,使用
**bcryptjs.hash()**
将其转换为 hash加密字符串,并存入数据库

- 在(身份认证的)登录时,先进行校验用户登录信息是否正确在这里我们使用的是
[passport-local](http://nestjs.inode.club/recipes/passport#%E5%AE%9E%E7%8E%B0-passport-%E6%9C%AC%E5%9C%B0%E7%AD%96%E7%95%A5)
本地策略来验证,@UseGuards(AuthGuard('local'))
这个装饰器会在此处的post请求@Post('auth/login')
后进行拦截,去local.strategy.ts
中进行validate
检索出该用户的信息,然后我们使用**bcryptjs.compareSync()**
将 **用户输入的密码 **与数据库中用 **hash加密过的密码 **进行解析对比,若登录信息正确则接着调用AuthLogin
,进而调用(认证成功的)登录接口authService.login()
,即向客户端发送登录成功信息并且是携带有**token**
的,
async login(user: any) {
// 准备jwt需要的负载
const payload = { username: user.username, sub: user.id };
return {
code: '200',
// 配合存储着用户信息的负载 payload 来生成一个包含签名的JWT令牌(access_token)。。
access_token: this.jwtService.sign(payload),
msg: '登录成功',
};
}
我们可以跟着代码仓库,带有详细的注释,一步步地走app.service.ts
负责定义注册authRegister
和登录authLogin
- 在注册时,拿到用户输入的密码,使用
**bcryptjs.hash()**
将其转换为 hash加密字符串,并存入数据库 - 在(身份认证的)登录时,先进行校验用户登录信息是否正确在这里我们使用的是
[passport-local](http://nestjs.inode.club/recipes/passport#%E5%AE%9E%E7%8E%B0-passport-%E6%9C%AC%E5%9C%B0%E7%AD%96%E7%95%A5)
本地策略来验证,@UseGuards(AuthGuard('local'))
这个装饰器会在此处的post请求@Post('auth/login')
后进行拦截,去local.strategy.ts
中进行validate
检索出该用户的信息,然后我们使用**bcryptjs.compareSync()**
将 **用户输入的密码 **与数据库中用 **hash加密过的密码 **进行解析对比,若登录信息正确则接着调用AuthLogin
,进而调用(认证成功的)登录接口authService.login()
,即向客户端发送登录成功信息并且是携带有**token**
的,
async login(user: any) {
// 准备jwt需要的负载
const payload = { username: user.username, sub: user.id };
return {
code: '200',
// 配合存储着用户信息的负载 payload 来生成一个包含签名的JWT令牌(access_token)。。
access_token: this.jwtService.sign(payload),
msg: '登录成功',
};
}
校验token合法性
那么这个token
我们在哪里去拦截它进行校验呢?
那就要提到我们 nest 的guard
(守卫)这个概念。其实就好比我们在vue项目中,封装路由前置守卫拦截路由跳转,去获取存储在localStorage
的token
一样。
在 nest 守卫中我们可以去获取到请求体req
,从而获取到请求头中的Authorization
字段,查看是否携带token
,然后去校验token
合法性,authService.verifyToken()
中调用jwtService.verify()
进行token
的令牌格式校验、签名验证、过期时间校验,确保令牌的完整性、真实性和有效性
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from 'src/app.module';
import { AuthService } from './auth.service';
import { UserService } from 'src/user/user.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor() {
super();
}
async canActivate(context: ExecutionContext): Promise<any> {
const req = context.switchToHttp().getRequest();
// 如果是请求路由是白名单中的,则直接放行
if (this.hasUrl(this.whiteList, req.url)) return true;
try {
const accessToken = req.get('Authorization');
if (!accessToken) throw new UnauthorizedException('请先登录');
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const authService = app.get(AuthService);
const userService = app.get(UserService);
const tokenUserInfo = await authService.verifyToken(accessToken);
const resData = await userService.findOne(tokenUserInfo.username);
if (resData[0].id) return true;
} catch (e) {
console.log('1h 的 token 过期啦!请重新登录');
return false;
}
}
// 白名单数组
private whiteList: string[] = ['/auth/register','/auth/login'];
// 验证该次请求是否为白名单内的路由
private hasUrl(whiteList: string[], url: string): boolean {
let flag = false;
if (whiteList.indexOf(url) !== -1) {
flag = true;
}
return flag;
}
}
在guard
中,当我们return true
时,好比路由前置守卫的next()
,就是认证通过了放行的意思
当然,别忘了注册守卫,我们这里可以采用全局守卫的形式注册,在main.ts
中app.useGlobalGuards(new JwtAuthGuard());
那么这个token
我们在哪里去拦截它进行校验呢?
那就要提到我们 nest 的guard
(守卫)这个概念。其实就好比我们在vue项目中,封装路由前置守卫拦截路由跳转,去获取存储在localStorage
的token
一样。
在 nest 守卫中我们可以去获取到请求体req
,从而获取到请求头中的Authorization
字段,查看是否携带token
,然后去校验token
合法性,authService.verifyToken()
中调用jwtService.verify()
进行token
的令牌格式校验、签名验证、过期时间校验,确保令牌的完整性、真实性和有效性
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from 'src/app.module';
import { AuthService } from './auth.service';
import { UserService } from 'src/user/user.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor() {
super();
}
async canActivate(context: ExecutionContext): Promise<any> {
const req = context.switchToHttp().getRequest();
// 如果是请求路由是白名单中的,则直接放行
if (this.hasUrl(this.whiteList, req.url)) return true;
try {
const accessToken = req.get('Authorization');
if (!accessToken) throw new UnauthorizedException('请先登录');
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const authService = app.get(AuthService);
const userService = app.get(UserService);
const tokenUserInfo = await authService.verifyToken(accessToken);
const resData = await userService.findOne(tokenUserInfo.username);
if (resData[0].id) return true;
} catch (e) {
console.log('1h 的 token 过期啦!请重新登录');
return false;
}
}
// 白名单数组
private whiteList: string[] = ['/auth/register','/auth/login'];
// 验证该次请求是否为白名单内的路由
private hasUrl(whiteList: string[], url: string): boolean {
let flag = false;
if (whiteList.indexOf(url) !== -1) {
flag = true;
}
return flag;
}
}
在guard
中,当我们return true
时,好比路由前置守卫的next()
,就是认证通过了放行的意思
当然,别忘了注册守卫,我们这里可以采用全局守卫的形式注册,在main.ts
中app.useGlobalGuards(new JwtAuthGuard());
Socket.IO如何实现即时聊天?
Nest中WebSocket网关的作用
使用 @WebSocketGateway 装饰器配置 WebSocket 网关在 Nest.js 应用中具有以下作用:
- 提供 WebSocket 的入口点:WebSocket 网关允许客户端通过 WebSocket 协议与后端建立实时的双向通信。通过配置网关,你可以定义用于处理 WebSocket 连接、消息传递和事件的逻辑。
- 处理跨域请求:在 WebSocket 中,默认存在跨域限制,即只能与同源的 WebSocket 服务进行通信。通过设置 origin 选项,WebSocket 网关可以解决跨域请求问题,允许来自指定源的请求进行跨域访问。
关于Socket.IO是怎么通讯的可以看看官网给出的图

socketIO
是通过事件监听的形式,我们可以很清晰的区分出消息的类型,方便对不同类型的消息进行处理,客户端和服务端双方事先约定好不同的事件,事件由谁监听,由谁触发,就可以把各种消息进行有序管理了

下面是一个简单的通讯事件示例:
import {
WebSocketGateway,
SubscribeMessage,
WebSocketServer,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { SocketService } from './socket.service';
import { CreateSocketDto } from './dto/create-socket.dto';
import { UpdateSocketDto } from './dto/update-socket.dto';
import { Socket } from 'socket.io';
const roomList = {};
let roomId = null;
let user = '';
@WebSocketGateway(3001, {
allowEIO3: true, // 开启后要求前后端使用的 Socket.io 版本要保持一致
//后端解决跨域
cors: {
// 允许具体源的请求进行跨域访问
origin: 'http://localhost:8080', //这里不要写*,要写 true 或者具体的前端请求时所在的域,否则会出现跨域问题
// 允许在跨域请求中发送凭据
credentials: true,
},
})
export class SocketGateway {
constructor(private readonly socketService: SocketService) {}
@SubscribeMessage('ToClient')
ToClient(@MessageBody() data: any) {
// 转发信息
const forwardMsg: string = '服务端=>客户端';
return {
//通过return返回客户端转发事件
event: 'forward',
data: forwardMsg, //data后面跟携带数据
};
}
//接收并处理来自客户端的消息
@SubscribeMessage('toServer')
handleServerMessage(client: Socket, data: string) {
console.log(data + ' (让我服务端来进行一下处理)');
client.emit('ToClient', data + '(处理完成给客户端)');
}
}
使用 @WebSocketGateway 装饰器配置 WebSocket 网关在 Nest.js 应用中具有以下作用:
- 提供 WebSocket 的入口点:WebSocket 网关允许客户端通过 WebSocket 协议与后端建立实时的双向通信。通过配置网关,你可以定义用于处理 WebSocket 连接、消息传递和事件的逻辑。
- 处理跨域请求:在 WebSocket 中,默认存在跨域限制,即只能与同源的 WebSocket 服务进行通信。通过设置 origin 选项,WebSocket 网关可以解决跨域请求问题,允许来自指定源的请求进行跨域访问。
关于Socket.IO是怎么通讯的可以看看官网给出的图socketIO
是通过事件监听的形式,我们可以很清晰的区分出消息的类型,方便对不同类型的消息进行处理,客户端和服务端双方事先约定好不同的事件,事件由谁监听,由谁触发,就可以把各种消息进行有序管理了
下面是一个简单的通讯事件示例:
import {
WebSocketGateway,
SubscribeMessage,
WebSocketServer,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { SocketService } from './socket.service';
import { CreateSocketDto } from './dto/create-socket.dto';
import { UpdateSocketDto } from './dto/update-socket.dto';
import { Socket } from 'socket.io';
const roomList = {};
let roomId = null;
let user = '';
@WebSocketGateway(3001, {
allowEIO3: true, // 开启后要求前后端使用的 Socket.io 版本要保持一致
//后端解决跨域
cors: {
// 允许具体源的请求进行跨域访问
origin: 'http://localhost:8080', //这里不要写*,要写 true 或者具体的前端请求时所在的域,否则会出现跨域问题
// 允许在跨域请求中发送凭据
credentials: true,
},
})
export class SocketGateway {
constructor(private readonly socketService: SocketService) {}
@SubscribeMessage('ToClient')
ToClient(@MessageBody() data: any) {
// 转发信息
const forwardMsg: string = '服务端=>客户端';
return {
//通过return返回客户端转发事件
event: 'forward',
data: forwardMsg, //data后面跟携带数据
};
}
//接收并处理来自客户端的消息
@SubscribeMessage('toServer')
handleServerMessage(client: Socket, data: string) {
console.log(data + ' (让我服务端来进行一下处理)');
client.emit('ToClient', data + '(处理完成给客户端)');
}
}
私聊模块中的 socket 事件
通过使用client.broadcast.emit('showMessage')
和 client.emit('showMessage')
,你可以实现多人实时聊天的功能。
当一个客户端发送一条消息时,通过 client.broadcast.emit('showMessage')
将该消息广播给其他客户端,让其他客户端可以接收到这条消息并进行相应的处理,从而实现多人实时聊天的效果。
同时,使用 client.emit('showMessage')
可以将消息发送给当前连接的客户端,这样当前客户端也会收到自己发送的消息,以便在界面上显示自己发送的内容。
@SubscribeMessage('sendMessage')
sendMessage(client: Socket) {
// 将该消息广播给其他客户端
client.broadcast.emit('showMessage');
// 将消息发送给当前连接的客户端
client.emit('showMessage');
return;
}
前端中会在UserList.tsx
监听该事件showMessage,并触发更新信息逻辑
useEffect(() => {
socket.on('showMessage', getCurentMessages)
return () => {
socket.off('showMessage')
}
})
通过使用client.broadcast.emit('showMessage')
和 client.emit('showMessage')
,你可以实现多人实时聊天的功能。
当一个客户端发送一条消息时,通过 client.broadcast.emit('showMessage')
将该消息广播给其他客户端,让其他客户端可以接收到这条消息并进行相应的处理,从而实现多人实时聊天的效果。
同时,使用 client.emit('showMessage')
可以将消息发送给当前连接的客户端,这样当前客户端也会收到自己发送的消息,以便在界面上显示自己发送的内容。
@SubscribeMessage('sendMessage')
sendMessage(client: Socket) {
// 将该消息广播给其他客户端
client.broadcast.emit('showMessage');
// 将消息发送给当前连接的客户端
client.emit('showMessage');
return;
}
前端中会在UserList.tsx
监听该事件showMessage,并触发更新信息逻辑
useEffect(() => {
socket.on('showMessage', getCurentMessages)
return () => {
socket.off('showMessage')
}
})
房间模块中的 socket 事件
@SubscribeMessage('sendRoomMessage')
sendRoomMessage(client: Socket, data) {
console.log('服务端接收到了');
// // 将消息发送给指定房间内的所有客户端
this.socketIO.to(roomId).emit('sendRoomMessage', data);
return;
}
在需要发送消息给指定房间时,即我们需要在全局中找到指定房间,所以我们需要整个 WebSocket 服务器的实例
@WebSocketServer()
socketIO: Socket; //它表示整个 WebSocket 服务器的实例。它可以用于执行全局操作,如向所有连接的客户端广播消息或将客户端连接到特定的房间。
@SubscribeMessage('sendRoomMessage')
sendRoomMessage(client: Socket, data) {
console.log('服务端接收到了');
// // 将消息发送给指定房间内的所有客户端
this.socketIO.to(roomId).emit('sendRoomMessage', data);
return;
}
在需要发送消息给指定房间时,即我们需要在全局中找到指定房间,所以我们需要整个 WebSocket 服务器的实例
@WebSocketServer()
socketIO: Socket; //它表示整个 WebSocket 服务器的实例。它可以用于执行全局操作,如向所有连接的客户端广播消息或将客户端连接到特定的房间。
加入和退出房间的 socket API
踩坑
- socket实例的创建写在了函数组件内,useState中的变量的频繁改变,导致的组件不断重新渲染,socket实例也被不断创建,形成过多的连接,让websocket服务崩溃!!!
解决:
把socket实例的创建拿出来放在单独的文件中,这样在各个函数组件中若使用的话只用引用这一共同的socket实例,仅与websocket服务器形成一个连接
- socket事件的监听没有及时的停止,导致对同一事件的监听不断叠加(如sys事件),当触发一次这一事件时,会同时触发到之前叠加的所有监听函数!!!
项目中的效果就是不断重新进入房间时,提示信息的渲染次数会递增的增加,而不是只提示一次
- socket实例的创建写在了函数组件内,useState中的变量的频繁改变,导致的组件不断重新渲染,socket实例也被不断创建,形成过多的连接,让websocket服务崩溃!!!
解决:
把socket实例的创建拿出来放在单独的文件中,这样在各个函数组件中若使用的话只用引用这一共同的socket实例,仅与websocket服务器形成一个连接
- socket事件的监听没有及时的停止,导致对同一事件的监听不断叠加(如sys事件),当触发一次这一事件时,会同时触发到之前叠加的所有监听函数!!!
项目中的效果就是不断重新进入房间时,提示信息的渲染次数会递增的增加,而不是只提示一次
解决:
在离开房间后要socket.off('sys');
要停止事件监听,另外最好是在组件销毁时停止所有事件的监听(此处为Next/React18,即项目前端的代码)
/* client */
useEffect(() => {
console.log('chat组件挂载');
// 连接自动触发
socket.on('connect', () => {
socket.emit('connection');
// 其他客户端事件和逻辑
});
return () => {
console.log('chat组件卸载');
socket.off();// 停止所有事件的监听 !!!
};
}, []);
/* server */
@SubscribeMessage('connection')
connection(client: Socket, data) {
console.log('有一个客户端连接成功', client.id);
// 断连自动触发
client.on('disconnect', () => {
console.log('有一个客户端断开连接', client.id);
// 处理断开连接的额外逻辑
});
return;
}
来源:juejin.cn/post/7295681529606832138
前端时间分片渲染
在经典的面试题中:”如果后端返回了十万条数据要你插入到页面中,你会怎么处理?”
除了像 useVirtualList 这样的虚拟列表来处理外,我们还可以通过 时间分片
来处理
通过 setTimeout
直接上一个例子:
<!--
* @Author: Jolyne
* @Date: 2023-09-22 15:45:45
* @LastEditTime: 2023-09-22 15:47:24
* @LastEditors: Jolyne
* @Description:
-->
<!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>
</head>
<body>
<ul id="list-container"></ul>
<script>
const oListContainer = document.getElementById('list-container')
const fetchData = () => {
return new Promise(resolve => {
const response = {
code: 0,
msg: 'success',
data: [],
}
for (let i = 0; i < 100000; i++) {
response.data.push(`content-${i + 1}`)
}
setTimeout(() => {
resolve(response)
}, 100)
})
}
// 模拟请求后端接口返回十万条数据
// 渲染 total 条数据中的第 page 页,每页 pageCount 条数据
const renderData = (data, total, page, pageCount) => {
// base case -- total 为 0 时没有数据要渲染 不再递归调用
if (total <= 0) return
// total 比 pageCount 少时只渲染 total 条数据
pageCount = Math.min(pageCount, total)
setTimeout(() => {
const startIdx = page * pageCount
const endIdx = startIdx + pageCount
const dataList = data.slice(startIdx, endIdx)
// 将 pageCount 条数据插入到容器中
for (let i = 0; i < pageCount; i++) {
const oItem = document.createElement('li')
oItem.innerText = dataList[i]
oListContainer.appendChild(oItem)
}
renderData(data, total - pageCount, page + 1, pageCount)
}, 0)
}
fetchData().then(res => {
renderData(res.data, res.data.length, 0, 200)
})
</script>
</body>
</html>
上面的例子中,我们使用了 setTimeout
,在每一次宏任务中插入一页数据,然后设置多个这样地宏任务,直到把所有数据都插入为止。
但是很明显能看到的问题是,快速拖动滚动条时,数据列表中会有闪烁的情况
这是因为:
当使用
setTimeout
来拆分大量的 DOM 插入操作时,虽然我们将延迟时间设置为 0ms,但实际上由于 JavaScript 是单线程的,任务执行时会被放入到事件队列中,而事件队列中的任务需要等待当前任务执行完成后才能执行。所以即使设置了 0ms 延迟,setTimeout
的回调函数也不一定会立即执行,可能会受到其他任务的阻塞。
当
setTimeout
的回调函数执行的间隔超过了浏览器每帧更新的时间间隔(一般是 16.7ms),就会出现丢帧现象。丢帧指的是浏览器在更新页面时,没有足够的时间执行全部的任务,导致部分任务被跳过,从而导致页面渲染不连续,出现闪烁的情况
所以,我们改善一下,通过 requestAnimationFrame
来处理
通过 requestAnimationFrame
<!--
* @Author: Jolyne
* @Date: 2023-09-22 15:45:45
* @LastEditTime: 2023-09-22 15:47:24
* @LastEditors: Jolyne
* @Description:
-->
<!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>
</head>
<body>
<ul id="list-container"></ul>
<script>
const oListContainer = document.getElementById('list-container')
const fetchData = () => {
return new Promise(resolve => {
const response = {
code: 0,
msg: 'success',
data: [],
}
for (let i = 0; i < 100000; i++) {
response.data.push(`content-${i + 1}`)
}
setTimeout(() => {
resolve(response)
}, 100)
})
}
// 模拟请求后端接口返回十万条数据
// 渲染 total 条数据中的第 page 页,每页 pageCount 条数据
const renderData = (data, total, page, pageCount) => {
// base case -- total 为 0 时没有数据要渲染 不再递归调用
if (total <= 0) return
// total 比 pageCount 少时只渲染 total 条数据
pageCount = Math.min(pageCount, total)
requestAnimationFrame(() => {
const startIdx = page * pageCount
const endIdx = startIdx + pageCount
const dataList = data.slice(startIdx, endIdx)
// 将 pageCount 条数据插入到容器中
for (let i = 0; i < pageCount; i++) {
const oItem = document.createElement('li')
oItem.innerText = dataList[i]
oListContainer.appendChild(oItem)
}
renderData(data, total - pageCount, page + 1, pageCount)
})
}
fetchData().then(res => {
renderData(res.data, res.data.length, 0, 200)
})
</script>
</body>
</html>
很明显,闪烁的问题被解决了
这是因为:
requestAnimationFrame
会在浏览器每次进行页面渲染时执行回调函数,保证了每次任务的执行间隔是稳定的,避免了丢帧现象。所以在处理大量 DOM 插入操作时,推荐使用requestAnimationFrame
来拆分任务,以获得更流畅的渲染效果
来源:juejin.cn/post/7282756858174980132
前端:“这需求是认真的吗?” —— el-select 的动态宽度解决方案
Hello~大家好。我是秋天的一阵风 ~
前言
最近我遇到了一个神奇的需求,客户要求对 el-select 的 宽度 进行动态设置。
简单来说,就是我们公司有一些选择框,展示的内容像“中华人民共和国/广西壮族自治区/南宁市/西乡塘区”
这么长,一不小心就会内容超长,显示不全。详情请看下面动图:
一般来说,想解决内容展示不全的问题,有几种方法。
第一种:给选择框加个tooltip
效果,在鼠标悬浮时展示完整内容。
第二种:对用户选择label值进行切割,只展示最后一层内容。
但是我们的客户对这两种方案都不接受,要求选择的时候让select选择框的宽度动态增加。
有什么办法呢?客户就是上帝,必须满足,他们说什么就是什么,所以我们只能开动脑筋,动手解决。
思路
我们打开控制台,来侦察一下el-select
的结构,发现它是一个el-input--suffix
的div
包裹着一个input
,如下图所示。

内层input
的宽度是100%,外层div
的宽度是由这个内层input
决定的。也就是说,内层input
的宽度如果动态增加,外层div
的宽度也会随之增加。那么问题来了,如何将内层input
的宽度动态增加呢?
tips:
如果你对width的100%和auto有什么区别感兴趣,可以点击查看我之前的文章
解决方案
为了让我们的el-select
宽度能够跟着内容走,我们可以在内层input
同级别增加一个元素,内容就是用户选中的内容。内容越多,它就像一个胃口很大的小朋友,把外层div的宽度撑开。下面来看图示例 :

借助prefix
幸运的是,el-select
本身有一个prefix的插槽选项,我们可以借助这个选项实现:

我们添加一个prefix
的插槽,再把prefix
的定位改成relative
,并且把input
的定位改成绝对定位absolute
。最后将prefix
的内容改成我们的选项内容。看看现在的效果:
<template>
<div>
<el-select class="autoWidth" v-model="value" placeholder="请选择">
<template slot="prefix">
{{optionLabel}}
</template>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>
</div>
</template>
<script>
export default {
data() {
return {
options: [
{
value: "选项1",
label: "中华人民共和国/广东省/深圳市/福田区",
},
{
value: "选项2",
label: "中华人民共和国/广西壮族自治区/南宁市/西乡塘区",
},
{
value: "选项3",
label: "中华人民共和国/北京市",
},
{
value: "选项4",
label: "中华人民共和国/台湾省",
},
{
value: "选项5",
label: "中华人民共和国/香港特别行政区",
},
],
value: "",
};
},
computed: {
optionLabel() {
return (this.options.find((item) => item.value === this.value) || {})
.label;
},
},
};
</script>
<style lang="scss" scoped>
::v-deep .autoWidth .el-input__prefix {
position: relative;
}
::v-deep .autoWidth input {
position: absolute;
}
</style>

细节调整
现在el-select
已经可以根据选项label的内容长短动态增加宽度了,但是我们还需要继续处理一下细节部分,将prefix
的内容调整到和select
框中的内容位置重叠
,并且将它隐藏
。看看现在的效果
::v-deep .autoWidth .el-input__prefix {
position: relative;
box-sizing: border-box;
border: 1px solid #fff;
padding: 0 30px;
height: 40px;
line-height: 40px;
left: 0px;
visibility: hidden;
}

调整初始化效果(用户未选择内容)
目前已经基本实现了效果了,还有最后一个问题,当用户没有选择内容的时候,select的宽度是“没有”
的,如下图所示。

所以我们还得给他加上一个最小宽度

我们加上最小宽度以后,发现这个select的图标又没对齐,这是因为我们在重写.el-input__prefix
样式的时候设置了padding: 0 30px,
当用户没有选择内容的时候,select
的图标应该是默认位置,我们需要继续调整代码,最后效果如下图所示:

完整代码
最后附上完整代码:
<template>
<div>
<el-select
class="autoWidth"
:class="{ 'has-content': optionLabel }"
v-model="value"
placeholder="请选择"
clearable
>
<template slot="prefix">
{{ optionLabel }}
</template>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>
</div>
</template>
<script>
export default {
data() {
return {
options: [
{
value: "选项1",
label: "中华人民共和国/广东省/深圳市/福田区",
},
{
value: "选项2",
label: "中华人民共和国/广西壮族自治区/南宁市/西乡塘区",
},
{
value: "选项3",
label: "中华人民共和国/北京市",
},
{
value: "选项4",
label: "中华人民共和国/台湾省",
},
{
value: "选项5",
label: "中华人民共和国/香港特别行政区",
},
],
value: "",
};
},
computed: {
optionLabel() {
return (this.options.find((item) => item.value === this.value) || {})
.label;
},
},
};
</script>
<style lang="scss" scoped>
.autoWidth {
min-width: 180px;
}
::v-deep .autoWidth .el-input__prefix {
position: relative;
box-sizing: border-box;
border: 1px solid #fff;
padding: 0 30px;
height: 40px;
line-height: 40px;
left: 0px;
visibility: hidden;
}
::v-deep .autoWidth input {
position: absolute;
}
.autoWidth {
// 当.has-content存在时设置样式
&.has-content {
::v-deep .el-input__suffix {
right: 5px;
}
}
// 当.has-content不存在时的默认或备选样式
&:not(.has-content) {
::v-deep .el-input__suffix {
right: -55px;
}
}
}
</style>
来源:juejin.cn/post/7385825759118196771
告别频繁登录:教你用Axios实现无感知双Token刷新
一、引言
在现代系统中,Token认证已成为保障用户安全的标准做法。然而,尽管许多系统采用了这种认证方式,却在处理Token刷新方面存在不足,导致用户体验不佳。随着Token有效期的缩短,频繁的重新登录成为常见现象,许多系统未能提供一种无缝的、用户无感知的Token刷新机制。通过结合Vue3和Axios这两大前端技术栈,我们可以借助Promise机制,开发出一种更加完善的自动化Token刷新方案,显著提升系统的稳定性和用户体验。本文将深入探讨这一实现过程,帮助你解决Token刷新难题。
在现代系统中,Token认证已成为保障用户安全的标准做法。然而,尽管许多系统采用了这种认证方式,却在处理Token刷新方面存在不足,导致用户体验不佳。随着Token有效期的缩短,频繁的重新登录成为常见现象,许多系统未能提供一种无缝的、用户无感知的Token刷新机制。通过结合Vue3和Axios这两大前端技术栈,我们可以借助Promise机制,开发出一种更加完善的自动化Token刷新方案,显著提升系统的稳定性和用户体验。本文将深入探讨这一实现过程,帮助你解决Token刷新难题。
二、示意图
三、具体实现
了解了基本步骤后,实际的实现过程其实相当简洁。然而,在具体操作中,仍有许多关键细节需要我们仔细考量,以确保Token刷新机制的稳定性和可靠性。
- Token 存储与管理:首先,明确如何安全地存储和管理Access Token与Refresh Token。这涉及到浏览器的存储策略,比如使用
localStorage
、sessionStorage
,存储策略不在本文中提及,本文采用localStorage 进行存储。 - 请求拦截器的设置:在Axios中设置请求拦截器,用于在每次发送请求前检查Token的有效性。如果发现Token过期,则触发刷新流程。这一步骤需注意避免并发请求引发的重复刷新。
- 处理Token刷新的响应逻辑:当Token过期时,通过发送Refresh Token请求获取新的Access Token。在这里,需要处理刷新失败的情况,如Refresh Token也失效时,如何引导用户重新登录。
- 队列机制的引入:在Token刷新过程中,可能会有多个请求被同时发出。为了避免重复刷新Token,可以引入队列机制,确保在刷新Token期间,其他请求被挂起,直到新的Token可用。
- 错误处理与用户体验:最后,要对整个流程中的错误进行处理,比如刷新失败后的重试逻辑、错误提示信息等,确保用户体验不受影响。
通过以上步骤的实现,你可以构建一个用户无感知、稳定可靠的双Token刷新机制,提升应用的安全性与用户体验。接下来,我们将逐一解析这些关键步骤的具体实现。
了解了基本步骤后,实际的实现过程其实相当简洁。然而,在具体操作中,仍有许多关键细节需要我们仔细考量,以确保Token刷新机制的稳定性和可靠性。
- Token 存储与管理:首先,明确如何安全地存储和管理Access Token与Refresh Token。这涉及到浏览器的存储策略,比如使用
localStorage
、sessionStorage
,存储策略不在本文中提及,本文采用localStorage 进行存储。 - 请求拦截器的设置:在Axios中设置请求拦截器,用于在每次发送请求前检查Token的有效性。如果发现Token过期,则触发刷新流程。这一步骤需注意避免并发请求引发的重复刷新。
- 处理Token刷新的响应逻辑:当Token过期时,通过发送Refresh Token请求获取新的Access Token。在这里,需要处理刷新失败的情况,如Refresh Token也失效时,如何引导用户重新登录。
- 队列机制的引入:在Token刷新过程中,可能会有多个请求被同时发出。为了避免重复刷新Token,可以引入队列机制,确保在刷新Token期间,其他请求被挂起,直到新的Token可用。
- 错误处理与用户体验:最后,要对整个流程中的错误进行处理,比如刷新失败后的重试逻辑、错误提示信息等,确保用户体验不受影响。
通过以上步骤的实现,你可以构建一个用户无感知、稳定可靠的双Token刷新机制,提升应用的安全性与用户体验。接下来,我们将逐一解析这些关键步骤的具体实现。
1. 编写请求拦截器
实现请求拦截器的基本逻辑比较简单,即在每次请求时自动附带上Token以进行认证。
service.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
if (userStore.authInfo.accessToken && userStore.authInfo.accessToken !== "") {
// 设置头部 token
config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
}
return config;
}, (error: any) => {
return Promise.reject(error);
}
);
目前的实现方案是,在请求存在有效Token时,将其附带到请求头中发送给服务器。但在一些特殊情况下,某些请求可能不需要携带Token。为此,我们可以在请求配置中通过config
对象来判断是否需要携带Token。例如:
request: (deptId: number, deptForm: DeptForm): AxiosPromise<void> => {
return request<void>({
url: DeptAPI.UPDATE.endpoint(deptId),
method: "put",
data: deptForm,
headers: {
// 根据需要添加Token,或者通过自定义逻辑决定是否包含Authorization字段
token: false
}
});
}
那么在请求拦截器中,您需要多加一个判断,就是判断请求头中token是否需要
// 代码省略
实现请求拦截器的基本逻辑比较简单,即在每次请求时自动附带上Token以进行认证。
service.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
if (userStore.authInfo.accessToken && userStore.authInfo.accessToken !== "") {
// 设置头部 token
config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
}
return config;
}, (error: any) => {
return Promise.reject(error);
}
);
目前的实现方案是,在请求存在有效Token时,将其附带到请求头中发送给服务器。但在一些特殊情况下,某些请求可能不需要携带Token。为此,我们可以在请求配置中通过config
对象来判断是否需要携带Token。例如:
request: (deptId: number, deptForm: DeptForm): AxiosPromise<void> => {
return request<void>({
url: DeptAPI.UPDATE.endpoint(deptId),
method: "put",
data: deptForm,
headers: {
// 根据需要添加Token,或者通过自定义逻辑决定是否包含Authorization字段
token: false
}
});
}
那么在请求拦截器中,您需要多加一个判断,就是判断请求头中token是否需要
// 代码省略
2. 深究响应拦截器
对于双token刷新的难点就在于响应拦截器中,因为在这里后端会返回token过期的信息。我们需要先清楚后端接口响应内容
对于双token刷新的难点就在于响应拦截器中,因为在这里后端会返回token过期的信息。我们需要先清楚后端接口响应内容
2.1 接口介绍
- 正常接口响应内容
// Status Code: 200 OK
{
"code":"0000",
"msg":"操作成功",
"data":{}
}
- accessToken 过期响应内容
// Status Code: 401 Unauthorized
{
"code":"I009",
"msg":"登录令牌过期"
}
- accessToken 刷新响应内容
// Status Code: 200 OK
{
"code": "0000",
"msg": "操作成功",
"data": {
"accessToken": "",
"refreshToken": "",
"expires": ""
}
}
- refreshToken 过期响应内容
// Status Code: 200 OK
{
"code": "I009",
"msg": "登录令牌过期"
}
注意 : 当Status Code
不是200时,Axios的响应拦截器会自动进入error
方法。在这里,我们可以捕捉到HTTP状态码为401的请求,从而初步判断请求是由于Unauthorized
(未授权)引发的。然而,触发401状态码的原因有很多,不一定都代表Token过期。因此,为了准确判断Token是否真的过期,我们需要进一步检查响应体中的code
字段。
- 正常接口响应内容
// Status Code: 200 OK
{
"code":"0000",
"msg":"操作成功",
"data":{}
}
- accessToken 过期响应内容
// Status Code: 401 Unauthorized
{
"code":"I009",
"msg":"登录令牌过期"
}
- accessToken 刷新响应内容
// Status Code: 200 OK
{
"code": "0000",
"msg": "操作成功",
"data": {
"accessToken": "",
"refreshToken": "",
"expires": ""
}
}
- refreshToken 过期响应内容
// Status Code: 200 OK
{
"code": "I009",
"msg": "登录令牌过期"
}
注意 : 当Status Code
不是200时,Axios的响应拦截器会自动进入error
方法。在这里,我们可以捕捉到HTTP状态码为401的请求,从而初步判断请求是由于Unauthorized
(未授权)引发的。然而,触发401状态码的原因有很多,不一定都代表Token过期。因此,为了准确判断Token是否真的过期,我们需要进一步检查响应体中的code
字段。
2.2 响应拦截器编写
有上面的接口介绍,我们编写的就简单,判断error.response?.status === 401、code === I009 即可,如果出现这种情况就直接刷新token。
service.interceptors.response.use(async (response: AxiosResponse) => {
// 正常请求代码忽略
return Promise.reject(new Error(msg || "Error"));
},
async (error: any) => {
const userStore = useUserStore()
if (error.response?.status === 401) {
if (error.response?.data?.code === RequestConstant.Code.AUTH_TOKEN_EXPIRED) {
// token 过期处理
// 1. 刷新 token
const loginResult: LoginResult = await userStore.refreshToken()
if (loginResult) {
// refreshToken 未过期
// 2.1 重构请求头
error.config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
// 2.2 请求
return await service.request(error.config);
} else {
// refreshToken 过期
// 1. 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else {
// 如果是系统发出的401 , 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else if (error.response?.status === 403) {
// 403 结果处理 , 代码省略
} else {
// 其他错误结果处理 , 代码省略
}
return Promise.reject(error.message);
}
);
有上面的接口介绍,我们编写的就简单,判断error.response?.status === 401、code === I009 即可,如果出现这种情况就直接刷新token。
service.interceptors.response.use(async (response: AxiosResponse) => {
// 正常请求代码忽略
return Promise.reject(new Error(msg || "Error"));
},
async (error: any) => {
const userStore = useUserStore()
if (error.response?.status === 401) {
if (error.response?.data?.code === RequestConstant.Code.AUTH_TOKEN_EXPIRED) {
// token 过期处理
// 1. 刷新 token
const loginResult: LoginResult = await userStore.refreshToken()
if (loginResult) {
// refreshToken 未过期
// 2.1 重构请求头
error.config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
// 2.2 请求
return await service.request(error.config);
} else {
// refreshToken 过期
// 1. 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else {
// 如果是系统发出的401 , 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else if (error.response?.status === 403) {
// 403 结果处理 , 代码省略
} else {
// 其他错误结果处理 , 代码省略
}
return Promise.reject(error.message);
}
);
2.3 解决重复刷新问题
编写完成上面的内容,考虑一下多个请求可能同时遇到 Token 过期,如果没有适当的机制控制,这些请求可能会同时发起刷新 Token 的操作,导致重复请求,甚至可能触发后端的安全机制将这些请求标记为危险操作。
为了解决这个问题,我们实现了一个单例 Promise
的刷新逻辑,通过 singletonRefreshToken
确保在同一时间只有一个请求会发起 Token 刷新操作。其核心思想是让所有需要刷新的请求共享同一个 Promise
,这样即使有多个请求同时遇到 Token 过期,它们也只会等待同一个刷新操作的结果,而不会导致多次刷新。
/**
* 刷新 token
*/
refreshToken(): Promise<LoginResult> {
// 如果 singletonRefreshToken 不为 null 说明已经在刷新中,直接返回
if (singletonRefreshToken !== null) {
return singletonRefreshToken
}
// 设置 singletonRefreshToken 为一个 Promise 对象 , 处理刷新 token 请求
singletonRefreshToken = new Promise<LoginResult>(async (resolve) => {
await AuthAPI.REFRESH.request({
accessToken: this.authInfo.accessToken as string,
refreshToken: this.authInfo.refreshToken as string
}).then(({data}) => {
// 设置刷新后的Token
this.authInfo = data
// 刷新路由
resolve(data)
}).catch(() => {
this.resetToken()
})
})
// 最终将 singletonRefreshToken 设置为 null, 防止 singletonRefreshToken 一直占用
singletonRefreshToken.finally(() => {
singletonRefreshToken = null;
})
return singletonRefreshToken
}
编写完成上面的内容,考虑一下多个请求可能同时遇到 Token 过期,如果没有适当的机制控制,这些请求可能会同时发起刷新 Token 的操作,导致重复请求,甚至可能触发后端的安全机制将这些请求标记为危险操作。
为了解决这个问题,我们实现了一个单例 Promise
的刷新逻辑,通过 singletonRefreshToken
确保在同一时间只有一个请求会发起 Token 刷新操作。其核心思想是让所有需要刷新的请求共享同一个 Promise
,这样即使有多个请求同时遇到 Token 过期,它们也只会等待同一个刷新操作的结果,而不会导致多次刷新。
/**
* 刷新 token
*/
refreshToken(): Promise<LoginResult> {
// 如果 singletonRefreshToken 不为 null 说明已经在刷新中,直接返回
if (singletonRefreshToken !== null) {
return singletonRefreshToken
}
// 设置 singletonRefreshToken 为一个 Promise 对象 , 处理刷新 token 请求
singletonRefreshToken = new Promise<LoginResult>(async (resolve) => {
await AuthAPI.REFRESH.request({
accessToken: this.authInfo.accessToken as string,
refreshToken: this.authInfo.refreshToken as string
}).then(({data}) => {
// 设置刷新后的Token
this.authInfo = data
// 刷新路由
resolve(data)
}).catch(() => {
this.resetToken()
})
})
// 最终将 singletonRefreshToken 设置为 null, 防止 singletonRefreshToken 一直占用
singletonRefreshToken.finally(() => {
singletonRefreshToken = null;
})
return singletonRefreshToken
}
重要点解析:
singletonRefreshToken
的使用:singletonRefreshToken
是一个全局变量,用于保存当前正在进行的刷新操作。如果某个请求发现 singletonRefreshToken
不为 null
,就说明另一个请求已经发起了刷新操作,它只需等待这个操作完成,而不需要自己再发起新的刷新请求。
- 共享同一个
Promise
:- 当
singletonRefreshToken
被赋值为一个新的 Promise
时,所有遇到 Token 过期的请求都会返回这个 Promise
,并等待它的结果。这样就避免了同时发起多个刷新请求。
- 刷新完成后的处理:
- 刷新操作完成后(无论成功与否),都会通过
finally
将 singletonRefreshToken
置为 null
,从而确保下一次 Token 过期时能够重新发起刷新请求。
通过这种机制,我们可以有效地避免重复刷新 Token 的问题,同时也防止了由于过多重复请求而引发的后端安全性问题。这种方法不仅提高了系统的稳定性,还优化了资源使用,确保了用户的请求能够正确地处理。
singletonRefreshToken
的使用:singletonRefreshToken
是一个全局变量,用于保存当前正在进行的刷新操作。如果某个请求发现singletonRefreshToken
不为null
,就说明另一个请求已经发起了刷新操作,它只需等待这个操作完成,而不需要自己再发起新的刷新请求。
- 共享同一个
Promise
:- 当
singletonRefreshToken
被赋值为一个新的Promise
时,所有遇到 Token 过期的请求都会返回这个Promise
,并等待它的结果。这样就避免了同时发起多个刷新请求。
- 当
- 刷新完成后的处理:
- 刷新操作完成后(无论成功与否),都会通过
finally
将singletonRefreshToken
置为null
,从而确保下一次 Token 过期时能够重新发起刷新请求。
- 刷新操作完成后(无论成功与否),都会通过
通过这种机制,我们可以有效地避免重复刷新 Token 的问题,同时也防止了由于过多重复请求而引发的后端安全性问题。这种方法不仅提高了系统的稳定性,还优化了资源使用,确保了用户的请求能够正确地处理。
四、测试
- 当我们携带过期token访问接口,后端就会返回401状态和I009。

这时候进入
const loginResult: LoginResult = await userStore.refreshToken()
- 携带之前过期的accessToken和未过期的refreshToken进行刷新
- 携带过期的accessToken的原因 :
- 防止未过期的 accessToken 进行刷新
- 防止 accessToken 和 refreshToken 不是同一用户发出的
- 其他安全性考虑
- 当我们携带过期token访问接口,后端就会返回401状态和I009。
这时候进入
const loginResult: LoginResult = await userStore.refreshToken()
- 携带之前过期的accessToken和未过期的refreshToken进行刷新
- 防止未过期的 accessToken 进行刷新
- 防止 accessToken 和 refreshToken 不是同一用户发出的
- 其他安全性考虑
- 获取到正常结果
作者:翼飞
来源:juejin.cn/post/7406992576513589286
来源:juejin.cn/post/7406992576513589286
前端到底该如何安全的实现“记住密码”?
在 web
应用里,“记住密码”这个小小的功能,可是咱用户的贴心小棉袄啊,用起来超级方便!但话说回来,咱们得怎样做才能既让用户享受这便利,又能牢牢护住他们的数据安全呢?这可得好好琢磨一番哦!接下来,咱们就来聊聊,有哪些靠谱的方法能实现“记住密码”这个功能,而且安全性也是杠杠的!
1. 使用 localStorage
localStorage
是一种持久化存储方式,数据在浏览器关闭后仍然存在。适用于需要长期保存的数据。
示例代码
// 生成对称密钥
async function generateSymmetricKey() {
const key = await crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
},
true,
["encrypt", "decrypt"]
);
return key;
}
// 加密数据
async function encryptData(data, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedData = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
new TextEncoder().encode(data)
);
return { iv, encryptedData };
}
// 解密数据
async function decryptData(encryptedData, key, iv) {
const decryptedData = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
encryptedData
);
return new TextDecoder().decode(decryptedData);
}
// 保存用户信息
async function saveUserInfo(username, password) {
const key = await generateSymmetricKey();
const { iv, encryptedData } = await encryptData(password, key);
localStorage.setItem('username', username);
localStorage.setItem('password', JSON.stringify({ iv, encryptedData }));
// 密钥可以存储在更安全的地方,如服务器端
}
// 获取用户信息
async function getUserInfo() {
const username = localStorage.getItem('username');
const { iv, encryptedData } = JSON.parse(localStorage.getItem('password'));
const key = await generateSymmetricKey(); // 这里应使用同一个密钥
const password = await decryptData(encryptedData, key, iv);
return { username, password };
}
// 示例:用户登录时调用
async function login(username, password, rememberMe) {
if (rememberMe) {
await saveUserInfo(username, password);
}
// 其他登录逻辑
}
// 示例:页面加载时自动填充
window.onload = async function() {
const userInfo = await getUserInfo();
if (userInfo.username && userInfo.password) {
document.getElementById('username').value = userInfo.username;
document.getElementById('password').value = userInfo.password;
}
};
2. 使用 sessionStorage
sessionStorage
是一种会话级别的存储方式,数据在浏览器关闭后会被清除。适用于需要临时保存的数据。
示例代码
// 保存用户信息
async function saveUserInfoSession(username, password) {
const key = await generateSymmetricKey();
const { iv, encryptedData } = await encryptData(password, key);
sessionStorage.setItem('username', username);
sessionStorage.setItem('password', JSON.stringify({ iv, encryptedData }));
// 密钥可以存储在更安全的地方,如服务器端
}
// 获取用户信息
async function getUserInfoSession() {
const username = sessionStorage.getItem('username');
const { iv, encryptedData } = JSON.parse(sessionStorage.getItem('password'));
const key = await generateSymmetricKey(); // 这里应使用同一个密钥
const password = await decryptData(encryptedData, key, iv);
return { username, password };
}
// 示例:用户登录时调用
async function loginSession(username, password, rememberMe) {
if (rememberMe) {
await saveUserInfoSession(username, password);
}
// 其他登录逻辑
}
// 示例:页面加载时自动填充
window.onload = async function() {
const userInfo = await getUserInfoSession();
if (userInfo.username && userInfo.password) {
document.getElementById('username').value = userInfo.username;
document.getElementById('password').value = userInfo.password;
}
};
3. 使用 IndexedDB
IndexedDB 是一种更为复杂和强大的存储方式,适用于需要存储大量数据的场景。
示例代码
// 打开数据库
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('UserDatabase', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore('users', { keyPath: 'username' });
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
// 保存用户信息
async function saveUserInfoIndexedDB(username, password) {
const key = await generateSymmetricKey();
const { iv, encryptedData } = await encryptData(password, key);
const db = await openDatabase();
const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');
store.put({ username, iv, encryptedData });
// 密钥可以存储在更安全的地方,如服务器端
}
// 获取用户信息
async function getUserInfoIndexedDB(username) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('users', 'readonly');
const store = transaction.objectStore('users');
const request = store.get(username);
request.onsuccess = async (event) => {
const result = event.target.result;
if (result) {
const key = await generateSymmetricKey(); // 这里应使用同一个密钥
const password = await decryptData(result.encryptedData, key, result.iv);
resolve({ username: result.username, password });
} else {
resolve(null);
}
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
// 示例:用户登录时调用
async function loginIndexedDB(username, password, rememberMe) {
if (rememberMe) {
await saveUserInfoIndexedDB(username, password);
}
// 其他登录逻辑
}
// 示例:页面加载时自动填充
window.onload = async function() {
const username = 'exampleUsername'; // 从某处获取用户名
const userInfo = await getUserInfoIndexedDB(username);
if (userInfo && userInfo.username && userInfo.password) {
document.getElementById('username').value = userInfo.username;
document.getElementById('password').value = userInfo.password;
}
};
4. 使用 Cookie
Cookie 是一种简单的存储方式,适用于需要在客户端和服务器之间传递少量数据的场景。需要注意的是,Cookie 的安全性较低,建议结合 HTTPS 和 HttpOnly 属性使用。
示例代码
// 设置 Cookie
function setCookie(name, value, days) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = "expires=" + date.toUTCString();
document.cookie = name + "=" + value + ";" + expires + ";path=/";
}
// 获取 Cookie
function getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
// 保存用户信息
async function saveUserInfoCookie(username, password) {
const key = await generateSymmetricKey();
const { iv, encryptedData } = await encryptData(password, key);
setCookie('username', username, 7);
setCookie('password', JSON.stringify({ iv, encryptedData }), 7);
// 密钥可以存储在更安全的地方,如服务器端
}
// 获取用户信息
async function getUserInfoCookie() {
const username = getCookie('username');
const { iv, encryptedData } = JSON.parse(getCookie('password'));
const key = await generateSymmetricKey(); // 这里应使用同一个密钥
const password = await decryptData(encryptedData, key, iv);
return { username, password };
}
// 示例:用户登录时调用
async function loginCookie(username, password, rememberMe) {
if (rememberMe) {
await saveUserInfoCookie(username, password);
}
// 其他登录逻辑
}
// 示例:页面加载时自动填充
window.onload = async function() {
const userInfo = await getUserInfoCookie();
if (userInfo.username && userInfo.password) {
document.getElementById('username').value = userInfo.username;
document.getElementById('password').value = userInfo.password;
}
};
5. 使用 JWT(JSON Web Token)
JWT 是一种常用的身份验证机制,特别适合在前后端分离的应用中使用。JWT 可以安全地传递用户身份信息,并且可以在客户端存储以实现“记住密码”功能。
示例代码
服务器端生成 JWT
假设你使用 Node.js 和 Express 作为服务器端框架,并使用 jsonwebtoken
库来生成和验证 JWT。
const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
const SECRET_KEY = 'your_secret_key';
// 用户登录接口
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 验证用户名和密码
if (username === 'user' && password === 'password') {
// 生成 JWT
const token = jwt.sign({ username }, SECRET_KEY, { expiresIn: '1h' });
res.json({ token });
} else {
res.status(401).json({ message: 'Invalid credentials' });
}
});
// 受保护的资源
app.get('/protected', (req, res) => {
const token = req.headers['authorization'];
if (!token) {
return res.status(401).json({ message: 'No token provided' });
}
jwt.verify(token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(401).json({ message: 'Failed to authenticate token' });
}
res.json({ message: 'Protected resource', user: decoded.username });
});
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
客户端存储和使用 JWT
在客户端,可以使用 localStorage
或 sessionStorage
来存储 JWT,并在后续请求中使用。
// 用户登录
async function login(username, password, rememberMe) {
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
const token = data.token;
if (rememberMe) {
localStorage.setItem('token', token);
} else {
sessionStorage.setItem('token', token);
}
} else {
console.error(data.message);
}
}
// 获取受保护的资源
async function getProtectedResource() {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
if (!token) {
console.error('No token found');
return;
}
const response = await fetch('/protected', {
method: 'GET',
headers: {
'Authorization': token
}
});
const data = await response.json();
if (response.ok) {
console.log(data);
} else {
console.error(data.message);
}
}
// 示例:用户登录时调用
document.getElementById('loginButton').addEventListener('click', async () => {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
await login(username, password, rememberMe);
});
// 示例:页面加载时自动填充
window.onload = async function() {
await getProtectedResource();
};
总结
如上示例,展示了如何使用 localStorage
、sessionStorage
、IndexedDB、Cookie 和 JWT 来实现“记住密码”功能。每种方式都有其适用场景和安全考虑,大家可以根据具体需求选择合适的实现方式。
欢迎在评论区留言讨论~
Happy coding! 🚀
来源:juejin.cn/post/7397284874652942363
uniapp 地图如何添加?你要的教程来喽!
地图在 app 中使用还是很广泛的,常见的应用常见有:
1、获取自己的位置,规划路线。
2、使用标记点进行标记多个位置。
3、绘制多边形,使用围墙标记位置等等。
此篇文章就以高德地图为例,以上述三个常见需求为例,教大家如何在 uniapp 中添加地图。
作为一个不管闲事的前端姑娘,我就忽略掉那些繁琐的账号申请,假设需要的信息问项目经理都要来了,如果你没有现成的信息,还需要申请,请查看:
去高德地图注册账号,根据官网指示获取 key。然后就正式开始前端 uniapp + 高德地图之旅啦!
一、地图配置
在使用地图之前需要配置一下你的地图账号信息,找到项目中的 manifest.json 文件,打开 web 配置,如图:
此处是针对 h5 端,如果我们要打包 安卓和 IOS app 需要配置对应的key信息,如图:
如果这些信息没有人给你提供,就需要自己去官网注册账号实名认证获取。
二、地图使用
2.1、使用标记点进行标记多个位置,具体效果图如下:
<template>
<view class="map-con">
<map style="width: 100%; height: 300px;"
:latitude="latitude"
:longitude="longitude"
:markers="covers"
:scale="12">
</map>
</view>
</template>
<script>
export default {
data() {
return {
longitude: '116.473115',
latitude: '39.993207',
covers: [{
id: 1,
longitude: "116.474595",
latitude: "40.001321",
iconPath: '/static/images/point.png',
},
{
id: 1,
longitude: "116.274595",
latitude: "40.101321",
iconPath: '/static/images/point.png',
},
{
id: 1,
longitude: "116.374595",
latitude: "40.101321",
iconPath: '/static/images/point.png',
},
{
id: 1,
longitude: "116.374595",
latitude: "40.011321",
width: 44,
height: 50,
iconPath:'/static/images/point.png',
}
]
}
}
}
</script>
注意:
看着代码很简单,运行在 h5 之后一切正常,但是运行在安卓模拟器的时候,发现自定义图标没有起作用,显示的是默认标记点。
iconpath 的路径不是相对路径,没有 ../../ 这些,直接根据官网提示写图片路径,虽然模拟器不显示但是真机是正常的。
2.2、绘制多边形,使用围墙标记位置等等。
<template>
<view class="map-con">
<map style="width: 100%; height: 400px;" :latitude="latitude" :longitude="longitude" :scale="11"
:polygons="polygon" :markers="covers">
</map>
</view>
</template>
<script>
export default {
data() {
return {
longitude: '116.304595',
latitude: '40.053207',
polygon: [{
fillColor: '#f00',
strokeColor: '#0f0',
strokeWidth: 3,
points: [{
latitude: '40.001321',
longitude: '116.304595'
},
{
latitude: '40.101321',
longitude: '116.274595'
},
{
latitude: '40.011321',
longitude: '116.374595'
}
]
}],
covers: [{
id: 1,
width: 30,
height: 33,
longitude: "116.314595",
latitude: "40.021321",
iconPath: '/static/images/point.png',
}, ]
}
}
}
</script>
更多样式配置我们去参考官网,官网使用文档写的很细致,地址为:
uniapp 官网:uniapp.dcloud.net.cn/component/m…
三、易错点
1、地图已经显示了,误以为地图未展示
左下角有高德地图标识,就说明地图已经正常显示了,此时可以使用鼠标进行缩放,或设置地图的缩放比例或者修改下地图中心点的经纬度。
2、标记点自定义图标不显示
marker 中的 iconPath 设置标记点的图标路径,可以使用相对路径、base64 等,但是在 h5 查看正常,app 打包之后就不能正常显示了,务必参考官网。
3、uni.getLocation 无法触发
在调试模式中,调用 uni.getLocation 无法触发,其中的 success fail complete 都无法执行,不调用的原因是必须在 https 环境下,所以先保证是在 https 环境下。****
四、有可用插件吗?
uniapp 插件:ext.dcloud.net.cn/search?q=ma…
搜索地图插件的时候,插件挺多的,有免费的也有付费的,即使使用插件也是需要需要注册第三方地图账号的。
我个人认为 uniapp 已经将第三方地图封装过了,使用挺便捷的,具体是否使用插件就根据项目实际情况定。
来源:juejin.cn/post/7271942371637559348
告别轮询,SSE 流式传输可太香了!
今天想和大家分享的一个技术是 SSE 流式传输 。如标题所言,通过 SSE 流式传输的方式可以让我们不再通过轮询的方式获取服务端返回的结果,进而提升前端页面的性能。
对于需要轮询的业务场景来说,采用 SSE 确实是一个更好的技术方案。
接下来,我将从 SSE 的概念、与 Websocket 对比、SSE 应用场景多个方面介绍 SSE 流式传输,感兴趣的同学一起来了解下吧!
什么是 SSE 流式传输
SSE 全称为 Server-sent events , 是一种基于 HTTP 协议的通信技术,允许服务器主动向客户端(通常是Web浏览器)发送更新。
它是 HTML5 标准的一部分,设计初衷是用来建立一个单向的服务器到客户端连接,使得服务器可以实时地向客户端发送数据。
这种服务端实时向客户端发送数据的传输方式,其实就是流式传输。
我们在与 ChatGPT 交互时,可以发现 ChatGPT 的响应总是间断完成。细扒 ChatGPT 的网络传输模式,可以发现,用的也是流式传输。
SSE 流式传输的好处
在 SSE 技术出现之前,我们习惯把需要等待服务端返回的过程称为长轮询。
长轮询的实现其实也是借助 http 请求来完成,一个完整的长轮询过程如下图所示:
从图中可以发现,长轮询最大的弊端是当服务端响应请求之前,客户端发送的所有请求都不会被受理。并且服务端发送响应的前提是客户端发起请求。
前后端通信过程中,我们常采用 ajax 、axios 来异步获取结果,这个过程,其实也是长轮询的过程。
而同为采用 http 协议通信方式的 SSE 流式传输,相比于长轮询模式来说,优势在于可以在不需要客户端介入的情况下,多次向客户端发送响应,直至客户端关闭连接。
这对于需要服务端实时推送内容至客户端的场景可方便太多了!
SSE 技术原理
1. 参数设置
前文说到,SSE 本质是一个基于 http 协议的通信技术。
因此想要使用 SSE 技术构建需要服务器实时推送信息到客户端的连接,只需要将传统的 http 响应头的 contentType 设置为 text/event-stream 。
并且为了保证客户端展示的是最新数据,需要将 Cache-Control 设置为 no-cache 。
在此基础上,SSE 本质是一个 TCP 连接,因此为了保证 SSE 的持续开启,需要将 Connection 设置为 keep-alive 。
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
完成了上述响应头的设置后,我们可以编写一个基于 SSE 流式传输的简单 Demo 。
2. SSE Demo
服务端代码:
const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.static('public'));
app.get('/events', function(req, res) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
let startTime = Date.now();
const sendEvent = () => {
// 检查是否已经发送了10秒
if (Date.now() - startTime >= 10000) {
res.write('event: close\ndata: {}\n\n'); // 发送一个特殊事件通知客户端关闭
res.end(); // 关闭连接
return;
}
const data = { message: 'Hello World', timestamp: new Date() };
res.write(`data: ${JSON.stringify(data)}\n\n`);
// 每隔2秒发送一次消息
setTimeout(sendEvent, 2000);
};
sendEvent();
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
客户端代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SSE Example</title>
</head>
<body>
<h1>Server-Sent Events Example</h1>
<div id="messages"></div>
<script>
const evtSource = new EventSource('/events');
const messages = document.getElementById('messages');
evtSource.onmessage = function(event) {
const newElement = document.createElement("p");
const eventObject = JSON.parse(event.data);
newElement.textContent = "Message: " + eventObject.message + " at " + eventObject.timestamp;
messages.appendChild(newElement);
};
</script>
</body>
</html>
当我们在浏览器中访问运行在 localhost: 3000 端口的客户端页面时,页面将会以 流式模式 逐步渲染服务端返回的结果:
需要注意的是,为了保证使用 SSE 通信协议传输的数据能被客户端正确的接收,服务端和客户端在发送数据和接收数据应该遵循以下规范:
服务端基本响应格式
SSE 响应主要由一系列以两个换行符分隔的事件组成。每个事件可以包含以下字段:
data:事件的数据。如果数据跨越多行,每行都应该以data:开始。
id:事件的唯一标识符。客户端可以使用这个ID来恢复事件流。
event:自定义事件类型。客户端可以根据不同的事件类型来执行不同的操作。
retry:建议的重新连接时间(毫秒)。如果连接中断,客户端将等待这段时间后尝试重新连接。
字段之间用单个换行符分隔,而事件之间用两个换行符分隔。
客户端处理格式
客户端使用 EventSource 接口监听 SSE 消息:
const evtSource = new EventSource('path/to/sse');
evtSource.onmessage = function(event) {
console.log(event.data); // 处理收到的数据
};
SSE 应用场景
SSE 作为基于 http 协议由服务端向客户端单向推送消息的通信技术,对于需要服务端主动推送消息的场景来说,是非常适合的:
SSE 兼容性
可以发现,除了 IE 和低版本的主流浏览器,目前市面上绝大多数浏览器都支持 SSE 通信。
SSE 与 WebSocket 对比
看完 SSE 的使用方式后,细心的同学应该发现了:
SSE 的通信方式和 WebSocket 很像啊,而且 WebSocket 还支持双向通信,为什么不直接使用 WebSocket ?
下表展示了两者之间的对比:
特性/因素 | SSE | WebSockets |
---|---|---|
协议 | 基于HTTP,使用标准HTTP连接 | 单独的协议(ws:// 或 wss://),需要握手升级 |
通信方式 | 单向通信(服务器到客户端) | 全双工通信 |
数据格式 | 文本(UTF-8编码) | 文本或二进制 |
重连机制 | 浏览器自动重连 | 需要手动实现重连机制 |
实时性 | 高(适合频繁更新的场景) | 非常高(适合高度交互的实时应用) |
浏览器支持 | 良好(大多数现代浏览器支持) | 非常好(几乎所有现代浏览器支持) |
适用场景 | 实时通知、新闻feed、股票价格等需要从服务器推送到客户端的场景 | 在线游戏、聊天应用、实时交互应用 |
复杂性 | 较低,易于实现和维护 | 较高,需要处理连接的建立、维护和断开 |
兼容性和可用性 | 基于HTTP,更容易通过各种中间件和防火墙 | 可能需要配置服务器和网络设备以支持WebSocket |
服务器负载 | 适合较低频率的数据更新 | 适合高频率消息和高度交互的场景 |
可以发现,SSE 与 WebSocket 各有优缺点,对于需要客户端与服务端高频交互的场景,WebSocket 确实更适合;但对于只需要服务端单向数据传输的场景,SSE 确实能耗更低,且不需要客户端感知。
参考文档
来源:juejin.cn/post/7355666189475954725
整理最近的生活
Hi,见到你真好 :)
写在开始
自从去年 8月 搬到 上海 之后,就很少再写文章,很多的思路都是断断续续,导致也不知道该写点什么,所以最后是是草稿攒了一堆,大纲整整齐齐,内容空空如也,甚是尴尬。
时间长了后,逐渐就有点开摆的心态。也有点理解为什么很多技术同学突然就不更新了。🫨
不过最近好在有些念头又开始跃跃欲试,故想着写一些东西,活跃活跃生锈的脑子🫡。
故本篇,其实算是一个随记,想到哪里就写到哪里,不包含任何技术指南。
搬家三两事
背景
因为当时是需要从北京搬到上海,家里还有两只猫以及超级多的行李,故需要考虑的事情有下面几点:
- 琐碎的行李怎么处理?
- 如何将 两只猫 安全的送到上海?
- 升降桌、工学椅、冰柜、猫厕所等大件怎么处理?
头脑风暴
- 两只猫托运走
成本过高,一只平均需要 1000+ ,以及需要疫苗齐全,以及需要提前7天以上准备。
- 升降桌、工学椅、冰柜出二手
出二手 = 5折以下,最主要是刚买没半年。😑
- 行李快递走?
货拉拉跨城搬运+快递一部分。
上述总费用: 托运🐱2000 + 二手折损(3000) + 货拉拉(4000+) + 动态费用1000 = 9500 左右。
备注点:
- 托运猫的安全性;
- 如果喊货拉拉,那就不需要二手回收;
算完上述费用之后,我忍不住拍了一下家里两只,嘴里嚷嚷着:要不是你两,我何至于如此!!!
最终解决
小红书约了一个跨城搬家师傅,车型依维柯(长4.9,宽1.9,高2.2),最后只花了 3500 解决了。
关于爱好的倒腾
世界奇奇怪怪,生活慢慢悠悠。
有时候会想,人的一生难得有几个爱好,那可能就会包含折腾 电子小垃圾:)
下面列一下今年折腾过的一些小垃圾:
Pixel 7
绿色- 戴尔
U2723QX
ikbc
高达97键盘ipadAir
M1丐版PS5
国行双手柄SlimStudio Display
带升降- 富士
XT-3
、35-1.4镜头 MacMini M1 16+256
丐版
关于相机
一直以来,其实都比较喜欢富士相机的直出,主要因为自己较懒。所以对于学习修图,实在是提不起感觉,而对于摄像的技巧,也只是草草了解几个构图方式,也谈不上研究,故富士就比较适合我这种[懒人]。
但其实这个事情的背景是,在最开始接触相机时,大概是21年,那时想买富士 xs-10
,结果因为疫情缺货,国内溢价到了1w,属实是离谱。所以当时就买了佳能 M6 Mark2
。
等过了半年左右,觉得佳能差点意思,就又换了索尼的 A7C
,对焦嘎嘎猛,主打的就是一个激情拍摄,后期就是套个滤镜就行🫡。
等新手福利期一过,时间线再往后推半年,相机开始吃土,遂出了二手👀。
来上海后,又想起了相机,故在闲鱼上又收了一个富士XT3,属于和xs20同配置(少几个滤镜),主打的就是一个拍到就是赚到(当然现在也是吃土🤡)。
关于PS5
因为21年淘过一个 PS4 Pro
,平时也是处于吃灰,玩的最多的反而是 双人成行(现在也没和老婆打完,80%)😬。
但抵不住冲动的心,没事就会看几眼 PS5
,为此,老婆专门买了一个,以解我没事的念想。
不过真得知快递信息后,还是心里有点不舍。就以家里还有一个在吃土,买这个没啥用为由退掉🫨。
抵得过暂时,抵不过长久,过了一段时间,念头又上来了,没事又翻起了pdd和二手鱼。
于是,在一个风和日丽的中午,激情下单。
结果卖家的名字居然和我只有一字之差,真的是造化弄人。🤡
到手之后,每周的愿望就是能在周末打一会游戏,结果很难有实现过,唯一一次畅玩 [潜水员戴夫🐟],结果导致身心疲惫。
ps: 截止写完这篇时候,最近在爽玩黑神话悟空,故真正实现了使用。
最近抽空在打大镖客2和黑悟空,遂带上几张图。
关于小主机
之前因为只有一个笔记本,上下班都需要背笔记本,遇到冬天还好,到了夏天,就非常反感,故最近诞生出了买一个 小主机 的想法。
因为不玩pc游戏,故 winodws
系天然不用选择,当然也就不用考虑同事推荐的 零刻 这种小主机,直接去搞一个 mac mini
即可。
最后综合对比了一下,觉得 Mac Mini(M1-16/256)
即可,闲鱼只需要3000左右即可。
对
M1
及以后的Mac设备而言,RAM>CPU>固态 ,故cpu
的性能对我而言足矣。故而以
16g
作为标准,而存储方面,可以考虑外接固态做扩展盘解决,这套方案是性价比最高的。🫡
有了上面的结论之后,就直接去闲鱼收,最后 3100 收了一个 MacMini M1/16g
。
然后又在京东买了一个 硬盘盒子(海康威视20Gbps)+雷神固态(PR5000/读写4.5k)。
本来想去买一个 40Gbps
的硬盘盒,结果一看价,399起步,有点夸张(我盘才4xx),遂放弃。
Tips:
- 不要轻易把系统装到扩展固态(特别是硬盘盒速度不够40Gbps时)上。
- 开机引导会变慢(如果一直不关机当我没说);
- 如果硬盘散热跟不上,系统会卡顿,如同ANR了一样;
- 因为
MacMini
雷电口 不支持usb3.2 Gen2x2
,在不满足雷电口的情况下,最快读写 只有1k。故导致硬盘速度只能到达 1k 读写,也就是硬盘盒的一半速度。
最后全家福如下:
关于显示器
最近一年连着用过了 3 个显示器,价位一路从 1K -> 1.2W,也算又多了一点点经验分享。
先说背景:因为没有游戏与高刷需求,更多的是追求接近Mac体验(用同事话说就是,被果子惯得),故下面的评测仅限于特定范围。
本次的参照物:MacBook Pro14寸自带的 XDR显示器、峰值亮度
1600nt
;
Redmi 27/4k (1.5k)
HDR400、65W Typc、95%P3、E<2
全功能typc、支持kvm,色彩模式有forMac。
塑料机身,但设计挺好看的,观感不错,for Mac模式 能接近 70%
体验;
仔细对比,与mac屏幕差距最大的是通透度与色准问题,如果说MacBook是 100%
通透,红米只有 60-70%
之间;
后期偶尔会出现连接不稳定,屏幕闪烁问题,时好时不好,猜测可能与 typc65w
电压不稳有关。
综合来说,这个价位,性价比非常之高。
戴尔U2723QX (3k)
HDR400、90W Typc、98%P3
全功能typc、支持kvm、接口大满贯,多到发指。
全金属机身,屏幕是 IPS Black
,也就是 LG 的 NanoIPS
面板,不过也算是一块老屏幕了。
整体体验下来不错,有接近MacBook 80%
的体验,色准什么的都很ok,在使用过程中,也没遇见过任何问题。
综合来说,算是 普通消费级代码显示器的王者 ,不过如果要提性价比,可能不如同款屏幕的 LG。
至于戴尔的售后,因为没体验过,所以难评。
Studio Display (1.2w)
5k分辨率、600nits、A13、六扬声器、4麦克风、自带摄像头
接口方面:雷电3(40G) + 3 x typc(10G)
Mac系列的最佳搭配,最接近 MacBook 的色准,非常通透,95%+
的接近水平,亮度差点,不过已经够了;
整体来说,如果对屏幕要求,或者眼睛比较敏感,那么这个显示器是比较不错的选择(别忘了开原彩+自动亮度,看习惯了后,还是很舒服)。
至于不足之处,可能就只有价格这一个因素。
家用设备的倒腾
来上海之后,家庭设备倒腾的比较少,少有的几个物件是:
- 小米除湿机 22L;
- 追觅洗地机
H30Mix
; - 小米55寸 MiniLed 电视;
关于除湿机
当时刚来上海,作为一个土生土长的北方人,遇到黄梅天,那感觉,简直浑身难受,一个字 黏,两个字 闷热。
故当时紧急下单了一款除湿机,一晚上可以除满满一桶,实测大概4-5L,最高档开1小时左右,家里基本就可以感受到干爽,比较适合40m的小家使用。再说说缺点:
- 吵!
- 如果没有接下水管,可能需要隔两天换一次水;
再说说现状,成功实现 100%
吃土状态,几乎毫无悬念,因为空调更省事。。。
最后给一些北方人首次去南方居住的建议:
- 不要选3层以下,太潮;
- 注意白天看房,看看光线如何;
- 注意附近切记不是建筑工地等等;
- 上海合租比较便宜,自如挺合适;
关于洗地机
刚到上海时,之前的 米家扫拖机器人 也一起带过来了,但因为实在 版本太旧,逐渐不堪大用 ,只能用勉强可用这个词来形容,而且特别容易 积攒头发加手动加水清洁 ,时间久了,就比较烦。
故按照我的性格,没事就在看新的替代物件,最开始锁定的是追觅扫拖机器人,但最后经过深思熟虑,觉得家里太小(60m) ,故扫地机器人根本转不开腿,可能咣咣撞椅子了,故退而求其次,去看洗地机。入手了追觅的 H30mix
,洗地吸尘都能干。
最后经过实际证明:洗地机就老老实实洗地,吸尘还是交给专门的吸尘器,主要是拆卸太麻烦🤡。故家里本来已经半个身子准备退休的德尔玛又被强行续命了一波。
再说优点,真的很好用,拖完地放架子上自动洗烘一体,非常方便。实测拖的比我干净,唯一缺点就是,每天洗完需要手动倒一下脏水(不倒可能会反味)。
关于电视
来上海后,一直想换个电视打游戏用,就没事看了看电视。因为之前的 Tcl
邮给了岳父老房子里,于是按耐不住的心又开始躁动了,故某个夜晚就看了下电视,遂对比后下单了小米的55寸 miniLed。考虑到家里地方不是很大,故也顺带卖了一个可移动的支架。
现在电视的价格是真的便宜,Miniled 都被小米干到了
2k
附近,但一分钱一分货,纸面参数终究是纸面参数,最后看看实际观感,也就那样。
关于一些想法
人生不过几十载,如果工作要占用掉最宝贵的20年华,那未免太过于糟糕。
不知为何,最近总感觉上班的时间过得尤为快,每周过了周二后,周五的下一步就又要到了,下个月初也越来越近了。
来上海后,几乎每天都会和老婆晚上下楼走走,近的时候绕着小区,远的时候绕着小区外面的路。起初刚来上海时,脑子里依然会有过几年会北京的想法,但在上海有段时间后,这个想法就变得没那么重了,直到现在,我两都变成了留在上海也许更好(仔细算了算)😐。
写在最后
兜兜转转,这篇也是写了近一个月,属于是想起来写一点,接下来会更新的频繁一点。
下次再见,朋友们 👋
关于我
我是 Petterp ,一个 Android 工程师。如果本文,你觉得写的还不错,不妨点个赞或者收藏,你的支持,是我持续创作的最大鼓励!
来源:juejin.cn/post/7406258856953790515
if-else嵌套太深怎么办?
在前端开发中,if-else
嵌套过深往往会导致代码可读性下降、维护难度增加,甚至引发潜在的逻辑错误。本文将从一个典型的深层 if-else
嵌套案例出发,逐步分析并探讨多种优化策略,帮助开发者解决这一问题。
一、深层 if-else
嵌套的案例
假设我们正在开发一个处理订单状态的功能,根据订单的不同状态执行相应的操作。下面是一个典型的 if-else
嵌套过深的代码示例:
function processOrder(order) {
if (order) {
if (order.isPaid) {
if (order.hasStock) {
if (!order.isCanceled) {
// 处理已付款且有库存的订单
return 'Processing paid order with stock';
} else {
// 处理已取消的订单
return 'Order has been canceled';
}
} else {
// 处理库存不足的订单
return 'Out of stock';
}
} else {
// 处理未付款的订单
return 'Order not paid';
}
} else {
// 处理无效订单
return 'Invalid order';
}
}
****
这段代码展示了多个条件的嵌套判断,随着条件的增多,代码的层级不断加深,使得可读性和可维护性大幅降低。
二、解决方案
1. 使用早返回
早返回是一种有效的方式,可以通过尽早退出函数来避免不必要的嵌套。
function processOrder(order) {
if (!order) {
return 'Invalid order';
}
if (!order.isPaid) {
return 'Order not paid';
}
if (!order.hasStock) {
return 'Out of stock';
}
if (order.isCanceled) {
return 'Order has been canceled';
}
return 'Processing paid order with stock';
}
通过早返回,条件判断被简化为一系列独立的判断,减少了嵌套层级,代码更直观。
2. 使用对象字面量或映射表
当条件判断基于某个特定的值时,可以利用对象字面量替代 if-else
。
const orderStatusActions = {
'INVALID': () => 'Invalid order',
'NOT_PAID': () => 'Order not paid',
'OUT_OF_STOCK': () => 'Out of stock',
'CANCELED': () => 'Order has been canceled',
'DEFAULT': () => 'Processing paid order with stock',
};
function processOrder(order) {
if (!order) {
return orderStatusActions['INVALID']();
}
if (!order.isPaid) {
return orderStatusActions['NOT_PAID']();
}
if (!order.hasStock) {
return orderStatusActions['OUT_OF_STOCK']();
}
if (order.isCanceled) {
return orderStatusActions['CANCELED']();
}
return orderStatusActions['DEFAULT']();
}
使用对象字面量将条件与行为进行映射,使代码更加模块化且易于扩展。
3. 使用策略模式
策略模式可以有效应对复杂的多分支条件,通过定义一系列策略类,将不同的逻辑封装到独立的类中。
class OrderProcessor {
constructor(strategy) {
this.strategy = strategy;
}
process(order) {
return this.strategy.execute(order);
}
}
class PaidOrderStrategy {
execute(order) {
if (!order.hasStock) {
return 'Out of stock';
}
if (order.isCanceled) {
return 'Order has been canceled';
}
return 'Processing paid order with stock';
}
}
class InvalidOrderStrategy {
execute(order) {
return 'Invalid order';
}
}
class NotPaidOrderStrategy {
execute(order) {
return 'Order not paid';
}
}
// 使用策略模式
const strategy = order ? (order.isPaid ? new PaidOrderStrategy() : new NotPaidOrderStrategy()) : new InvalidOrderStrategy();
const processor = new OrderProcessor(strategy);
processor.process(order);
策略模式将不同逻辑分散到独立的类中,避免了大量的 if-else
嵌套,增强了代码的可维护性。
4. 使用多态
通过多态性,可以通过继承和方法重写替代 if-else
条件分支。
优化后的代码:
class Order {
process() {
throw new Error('This method should be overridden');
}
}
class PaidOrder extends Order {
process() {
if (!this.hasStock) {
return 'Out of stock';
}
if (this.isCanceled) {
return 'Order has been canceled';
}
return 'Processing paid order with stock';
}
}
class InvalidOrder extends Order {
process() {
return 'Invalid order';
}
}
class NotPaidOrder extends Order {
process() {
return 'Order not paid';
}
}
// 通过多态处理订单
const orderInstance = new PaidOrder(); // 根据order实例化相应的类
orderInstance.process();
多态性允许我们通过不同的子类实现不同的逻辑,从而避免在同一个函数中使用大量的 if-else
。
5. 使用函数式编程技巧
函数式编程中的 map
, filter
, 和 reduce
可以帮助我们避免复杂的条件判断。
优化后的代码:
const orderProcessors = [
{condition: (order) => !order, process: () => 'Invalid order'},
{condition: (order) => !order.isPaid, process: () => 'Order not paid'},
{condition: (order) => !order.hasStock, process: () => 'Out of stock'},
{condition: (order) => order.isCanceled, process: () => 'Order has been canceled'},
{condition: () => true, process: () => 'Processing paid order with stock'},
];
const processOrder = (order) => orderProcessors.find(processor => processor.condition(order)).process();
通过 find
和 filter
等函数式编程方法,我们可以避免嵌套的 if-else
语句,使代码更加简洁和易于维护。
三、总结
if-else
嵌套过深的问题是前端开发中常见的挑战。通过本文提供的多种解决方案,如早返回、对象字面量、策略模式、多态和函数式编程技巧,开发者可以根据实际需求选择合适的优化方案,从而提高代码的可读性、可维护性和性能。
希望这些方法能对你的开发工作有所帮助,欢迎在评论区分享你的经验与想法!
来源:juejin.cn/post/7406538050228633641
Oracle开始严查Java许可!
0x01、
前段时间在论坛里就看到一个新闻,说“Oracle又再次对Java下手,开始严查Java许可,有企业连夜删除JDK”,当时就曾在网上引起了一阵关注和讨论。
这不最近在科技圈又看到有媒体报道,Oracle再次严查,对于Java许可和版权的审查越来越严格了。
其实很早之前就有看到新闻报道说,甲骨文公司Oracle已经开始将Java纳入其软件许可审查中,并且对一些公司的Java采用情况开启审计,目的是找出那些处于不合规边缘或已经违规的客户。
之前主要还是针对一些小公司发出过审查函件,而现在,甚至包括财富200强在内的一些组织或公司都收到了来自Oracle有关审查方面的信件。
0x02、
还记得去年上半年的时候,Oracle就曾发布过一个PDF格式的新版Java SE收费政策《Oracle Java SE Universal Subscription Global Price List (PDF)》。
打开那个PDF,在里面可以看到Oracle新的Java SE通用订阅全球价目表:
表格底部还举了一个具体计费的例子。
比方说一个公司有28000名总雇员,里面可能包含有23000名全职、兼职、临时雇员,以及5000其他类型员工(比如说代理商、合约商、咨询顾问),那这个总价格是按如下方式进行计算:
28000 * 6.75/年
合着这个新的收费标准是直接基于公司里总的员工数来进行计算的,而不仅仅是使用Java SE的员工数。
这样一来,可能就会使企业在相同软件的的使用情况下会多出不少费用,从而增加软件成本。
看到这里不得不说,Oracle接手之后把Java的商业化运作这块整得是明明白白的。
0x03、
众所周知,其实Java最初是由Sun公司的詹姆斯·高斯林(James Gosling,后来也被称为Java之父)及其团队所研发的。
并且最开始名字并不叫Java,而是被命名为:Oak,这个名字得自于 Gosling 想名字时看到了窗外的一棵橡树。
就在 Gosling 的团队即将发布成果之前,又出了个小插曲——Oak 竟然是一个注册商标。Oak Technology(OAKT)是一家美国半导体芯片制造商,Oak 是其注册商标。
既然不能叫Oak,那应该怎么命名好呢?
后来 Gosling 看见了同事桌上有一瓶咖啡,包装上写着 Java,于是灵感一现。至此,Java语言正式得名,并使用至今。
1995年5月,Oak语言才更名为Java(印度尼西亚爪哇岛的英文名称,因盛产咖啡而闻名),并于当时的SunWorld大会上发布了JAVA 1.0,而且那句“Write Once,Run Anywhere”的slogan也是那时候推出的。
此后,Java语言一直由Sun公司来进行维护开发,一直到早期的JDK 7。
2009年4月,Oracle以74亿美元现金收购了Sun公司,至此一代巨头基本没落。
与此同时,Java商标也被列入Oracle麾下,成为了Oracle的重要资源。
众所周知,Oracle接手Java之后,就迅速开始了商业化之路的实践,也于后续推出了一系列调整和改革的操作。
其实Oracle早在2017年9月就宣布将改变JDK版本发布周期。新版本发布周期中,一改原先以特性驱动的发布方式,而变成了以时间为驱动的版本迭代。
也即:每6个月会发布一个新的Java版本,而每3年则会推出一个LTS版本。
而直到前段时间,Java 22都已经正式发布了。
0x04、
那针对Oracle这一系列动作,以及新的定价策略和订阅问题,有不少网友讨论道,那就不使用Oralce JDK,切换到OpenJDK,或者使用某些公司开源的第三方JDK。
众所周知,OpenJDK是一个基于GPL v2 许可的开源项目,自Java 7开始就是Java SE的官方参考实现。
既然如此,也有不少企业或者组织基于OpenJDK从而构建了自己的JDK版本,这些往往都是基于OpenJDK源码,然后增加或者说定制一些自己的专属内容。
比如像阿里的Dragonwell,腾讯的Kona,AWS的Amazon Corretto,以及Azul提供的Zulu JDK等等,都是这类典型的代表。
它们都是各自根据自身的业务场景和业务需求并基于OpenJDK来打造推出的开源JDK发行版本,像这些也都是可以按需去选用的。
文章的最后,也做个小调查:
大家目前在用哪款JDK和版本来用于开发环境或生产环境的呢?
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7405845617282449462
网页也能像 QQ 一样发出右下角消息?轻松实现桌面通知!
网页也能像 QQ 一样发出右下角消息?轻松实现桌面通知!
大家好,我是蒜鸭。今天我们来聊聊如何让网页像 QQ 那样在右下角弹出消息通知。这个功能不仅能提升用户体验,还能增加网站的互动性。让我们一起探索如何在网页中实现这个酷炫的功能吧!
1. 为什么需要网页通知?
在当今信息爆炸的时代,获取用户注意力变得越来越困难。传统的网页通知方式,如弹窗或页面内提示,往往会打断用户的浏览体验。而类似 QQ 那样的右下角消息通知,既能及时传递信息,又不会过分干扰用户,可以说是一种相当优雅的解决方案。
实现这种通知功能,我们有两种主要方式:使用 Web Notifications API 或自定义 CSS+JavaScript 实现。接下来,我们将详细探讨这两种方法的实现过程、优缺点以及适用场景。
2. 使用 Web Notifications API
2.1 Web Notifications API 简介
Web Notifications API 是现代浏览器提供的一个强大功能,它允许网页向用户发送通知,即使在用户没有打开网页的情况下也能工作。这个 API 的使用非常简单,但功能却十分强大。
2.2 基本实现步骤
- 检查浏览器支持
- 请求用户授权
- 创建并显示通知
让我们来看看具体的代码实现:
// 检查浏览器是否支持通知
if ("Notification" in window) {
// 请求用户授权
Notification.requestPermission().then(function (permission) {
if (permission === "granted") {
// 创建并显示通知
var notification = new Notification("Hello from Web!", {
body: "这是一条来自网页的通知消息",
icon: "path/to/icon.png"
});
// 点击通知时的行为
notification.onclick = function() {
window.open("https://example.com");
};
}
});
}
2.3 优点和注意事项
优点:
– 原生支持,无需额外库
– 可以在用户未浏览网页时发送通知
– 支持富文本和图标
注意事项:
– 需要用户授权,一些用户可能会拒绝
– 不同浏览器的显示样式可能略有不同
– 过度使用可能会引起用户反感
3. 自定义 CSS+JavaScript 实现
如果你想要更多的样式控制,或者希望通知始终显示在网页内,那么使用自定义的 CSS+JavaScript 方案可能更适合你。
3.1 基本思路
- 创建一个固定位置的 div 元素作为通知容器
- 使用 JavaScript 动态创建通知内容
- 添加动画效果使通知平滑显示和消失
3.2 HTML 结构
<div id="notification-container"></div>
3.3 CSS 样式
#notification-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
}
.notification {
background-color: #f8f8f8;
border-left: 4px solid #4CAF50;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
padding: 16px;
margin-bottom: 10px;
width: 300px;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease-in-out;
}
.notification.show {
opacity: 1;
transform: translateX(0);
}
.notification-title {
font-weight: bold;
margin-bottom: 5px;
}
.notification-body {
font-size: 14px;
}
3.4 JavaScript 实现
function showNotification(title, message, duration = 5000) {
const container = document.getElementById('notification-container');
const notification = document.createElement('div');
notification.className = 'notification';
const titleElement = document.createElement('div');
titleElement.className = 'notification-title';
titleElement.textContent = title;
const bodyElement = document.createElement('div');
bodyElement.className = 'notification-body';
bodyElement.textContent = message;
notification.appendChild(titleElement);
notification.appendChild(bodyElement);
container.appendChild(notification);
// 触发重绘以应用初始样式
notification.offsetHeight;
// 显示通知
notification.classList.add('show');
// 设置定时器移除通知
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
container.removeChild(notification);
}, 300);
}, duration);
}
// 使用示例
showNotification('Hello', '这是一条自定义通知消息');
3.5 优点和注意事项
优点:
– 完全可定制的外观和行为
– 不需要用户授权
– 可以轻松集成到现有的网页设计中
注意事项:
– 仅在用户浏览网页时有效
– 需要考虑移动设备的适配
– 过多的通知可能会影响页面性能
4. 高级技巧和最佳实践
4.1 通知分级
根据通知的重要性进行分级,可以使用不同的颜色或图标来区分:
function showNotification(title, message, level = 'info') {
// ... 前面的代码相同
let borderColor;
switch(level) {
case 'success':
borderColor = '#4CAF50';
break;
case 'warning':
borderColor = '#FFC107';
break;
case 'error':
borderColor = '#F44336';
break;
default:
borderColor = '#2196F3';
}
notification.style.borderLeftColor = borderColor;
// ... 后面的代码相同
}
// 使用示例
showNotification('成功', '操作已完成', 'success');
showNotification('警告', '请注意...', 'warning');
showNotification('错误', '出现问题', 'error');
4.2 通知队列
为了避免同时显示过多通知,我们可以实现一个简单的通知队列:
const notificationQueue = [];
let isShowingNotification = false;
function queueNotification(title, message, duration = 5000) {
notificationQueue.push({ title, message, duration });
if (!isShowingNotification) {
showNextNotification();
}
}
function showNextNotification() {
if (notificationQueue.length === 0) {
isShowingNotification = false;
return;
}
isShowingNotification = true;
const { title, message, duration } = notificationQueue.shift();
showNotification(title, message, duration);
setTimeout(showNextNotification, duration + 300);
}
// 使用示例
queueNotification('通知1', '这是第一条通知');
queueNotification('通知2', '这是第二条通知');
queueNotification('通知3', '这是第三条通知');
4.3 响应式设计
为了确保通知在各种设备上都能正常显示,我们需要考虑响应式设计:
@media (max-width: 768px) {
#notification-container {
left: 20px;
right: 20px;
bottom: 20px;
}
.notification {
width: auto;
}
}
4.4 无障碍性考虑
为了提高通知的可访问性,我们可以添加 ARIA 属性和键盘操作支持:
function showNotification(title, message, duration = 5000) {
// ... 前面的代码相同
notification.setAttribute('role', 'alert');
notification.setAttribute('aria-live', 'polite');
const closeButton = document.createElement('button');
closeButton.textContent = '×';
closeButton.className = 'notification-close';
closeButton.setAttribute('aria-label', '关闭通知');
closeButton.addEventListener('click', () => {
notification.classList.remove('show');
setTimeout(() => {
container.removeChild(notification);
}, 300);
});
notification.appendChild(closeButton);
// ... 后面的代码相同
}
5. 性能优化与注意事项
在实现网页通知功能时,我们还需要注意以下几点:
- 防抖和节流:对于频繁触发的事件(如实时通知),使用防抖或节流技术可以有效减少不必要的通知显示。
- 内存管理:确保在移除通知时,同时清理相关的事件监听器和 DOM 元素,避免内存泄漏。
- 优雅降级:对于不支持 Web Notifications API 的浏览器,可以降级使用自定义的 CSS+JavaScript 方案。
- 用户体验:给用户提供控制通知显示的选项,如允许用户设置通知的类型、频率等。
- 安全考虑:在使用 Web Notifications API 时,确保只在 HTTPS 环境下请求权限,并尊重用户的权限设置。
网页通知是一个强大的功能,能够显著提升用户体验和网站的互动性。无论是使用 Web Notifications API 还是自定义的 CSS+JavaScript 方案,都能实现类似 QQ 那样的右下角消息通知。选择哪种方式取决于你的具体需求和目标用户群。通过合理使用通知功能,你可以让你的网站变得更加生动和用户友好。
来源:juejin.cn/post/7403283321793314850
url请求参数带有特殊字符“%、#、&”时,参数被截断怎么办?
是的,最近又踩坑了!
事情是这样的,我们测试小姐姐在一个全局搜索框里输入了一串特殊字符“%%%”,然后进行搜索,结果报错了。而输入常规字符,均可以正常搜索。
一排查,发现特殊字符“%%%”并未成功传给后端。
我们的这个全局搜索功能是需要跳转页面才能查看到搜索结果的。所以,搜索条件是作为参数拼接在页面url上的。
正常的传参:
当输入的是特殊字符“%、#、&”时,参数丢失
也就是说,当路由请求参数带有浏览器url中的特殊含义字符时,参数会被截断,无法正常获取参数。
那么怎么解决这个问题呢?
方案一:encodeURIComponent/decodeURIComponent
拼接参数时,利用encodeURIComponent()进行编码,接收参数时,利用decodeURIComponent()进行解码。
// 编码
this.$router.push({path: `/crm/global-search/search-result?type=${selectValue}&text=${encodeURIComponent(searchValue)}`});
// 解码
const text = decodeURIComponent(this.$route.query.text)
此方法对绝大多数特殊字符都适用,但是唯独输入“%”进行搜索时不行,报错如下。
所以在编码之前,还需进行一下如下转换:
this.$router.push({path: `/crm/global-search/search-result?type=${selectValue}&text=${encodeURIComponent(encodeSpecialChar(searchValue))}`});
/**
* @param {*} char 字符串
* @returns
*/
export const encodeSpecialChar = (char) => {
// #、&可以不用参与处理
const encodeArr = [{
code: '%',
encode: '%25'
},{
code: '#',
encode: '%23'
}, {
code: '&',
encode: '%26'
},]
return char.replace(/[%?#&=]/g, ($) => {
for (const k of encodeArr) {
if (k.code === $) {
return k.encode
}
}
})
}
方案二: qs.stringify()
默认情况下,qs.stringify()方法会使用encodeURIComponent方法对特殊字符进行编码,以保证URL的合法性。
const qs = require('qs');
const searchObj = {
type: selectValue,
text: searchValue
};
this.$router.push({path: `/crm/global-search/search-result?${qs.stringify(searchObj)}`});
使用了qs.stringify()方法,就无需使用encodeSpecialChar方法进行转换了。
来源:juejin.cn/post/7332048519156776979
前端代码重复度检测
在前端开发中,代码的重复度是一个常见的问题。重复的代码不仅增加了代码的维护成本,还可能导致程序的低效运行。为了解决这个问题,有许多工具和技术被用来检测和消除代码重复。其中一个被广泛使用的工具就是jscpd
。
jscpd简介
jscpd
是一款开源的JavaScript
的工具库,用于检测代码重复的情况,针对复制粘贴的代码检测很有效果。它可以通过扫描源代码文件,分析其中的代码片段,并比较它们之间的相似性来检测代码的重复度。jscpd
支持各种前端框架和语言,包括HTML、CSS和JavaScript等150种的源码文件格式。无论是原生的JavaScript、CSS、HTML代码,还是使用typescript
、scss
、vue
、react
等代码,都能很好的检测出项目中的重复代码。
开源仓库地址:github.com/kucherenko/jscpd/tree/master
如何使用
使用jscpd
进行代码重复度检测非常简单。我们需要安装jscpd
。可以通过npm
或yarn
来安装jscpd
。
npm install -g jscpd
yarn global add jscpd
安装完成后,我们可以在终端运行jscpd命令,指定要检测的代码目录或文件。例如,我们可以输入以下命令来检测当前目录下的所有JavaScript文件:
jscpd .
指定目录检测:
jscpd /path/to/code
在命令行执行成功后的效果如下图所示:
简要说明一下对应图中的字段内容:
- Clone found (javascript):
显示找到的重复代码块,这里是javascript文件。并且会显示重复代码在文件中具体的行数,便于查找。 - Format:文件格式,这里是 javascript,还可以是 scss、markup 等。
- Files analyzed:已分析的文件数量,统计被检测中的文件数量。
- Total lines:所有文件的总行数。
- Total tokens:所有的token数量,一行代码一般包含几个到几十个不等的token数量。
- Clones found:找到的重复块数量。
- Duplicated lines:重复的代码行数和占比。
- Duplicated tokens:重复的token数量和占比。
- Detection time:检测耗时。
工程配置
以上示例是比较简单直接检测单个文件或文件夹。当下主流的前端项目大多都是基于脚手架生成或包含相关前端工程化的文件,由于很多文件是辅助工具如依赖包、构建脚本、文档、配置文件等,这类文件都不需要检测,需要排除。这种情况下的工程一般使用配置文件的方式,通过选项配置规范 jscpd
的使用。
jscpd
的配置选项可以通过以下两种方式创建,增加的内容都一致无需区分对应的前端框架。
在项目根目录下创建配置文件 .jscpd.json
,然后在该文件中增加具体的配置选项:
{
"threshold": 0,
"reporters": ["html", "console", "badge"],
"ignore": ["**/__snapshots__/**"],
"absolute": true
}
也可直接在 package.json
文件中添加jscpd
:
{
...
"jscpd": {
"threshold": 0.1,
"reporters": ["html", "console", "badge"],
"ignore": ["**/__snapshots__/**"],
"absolute": true,
"gitignore": true
}
...
}
简要介绍一下上述配置字段含义:
- threshold:表示重复度的阈值,超过这个值,就会输出错误报警。如阈值设为 10,当重复度为18.1%时,会提示以下错误❌,但代码的检测会正常完成。
ERROR: jscpd found too many duplicates (18.1%) over threshold (10%)
- reporters:表示生成结果检测报告的方式,一般有以下几种:
- console:控制台打印输出
- consoleFull:控制台完整打印重复代码块
- json:输出
json
格式的报告 - xml:输出
xml
格式的报告 - csv:输出
csv
格式的报告 - markdown:输出带有
markdown
格式的报告 - html:生成
html
报告到html
文件夹 - verbose:输出大量调试信息到控制台
- ignore:检测忽略的文件或文件目录,过滤一些非业务代码,如依赖包、文档或静态文件等
- format:需要进行重复度检测的源代码格式,目前支持150多种,我们常用的如 javascript、typescript、css 等
- absolute:在检测报告中使用绝对路径
除此之外还有很多其他的配置,有兴趣的可以看源码文档中有详细的介绍。
检测报告
完成以上jscpd
配置后执行以下命令即可输出对应的重复检测报告。运行完毕后,jscpd
会生成一个报告,展示每个重复代码片段的信息。报告中包含了重复代码的位置、相似性百分比和代码行数等详细信息。通过这些信息,我们可以有针对性的进行代码重构。
jscpd ./src -o 'report'
项目中的业务代码通常会选择放在 ./src
目录下,所以可以直接检测该目录下的文件,如果是放在其他目录下根据实际情况调整即可。
通过命令行参数-o 'report'
输出检测报告到项目根目录下的 report
文件夹中,这里的report
也可以自定义其他目录名称,输出的目录结构如下所示:
生成的报告页面如下所示:
项目概览数据:
具体重复代码的位置和行数:
默认检测重复代码的行数(5行)和tokens(50)比较小,所以产生的重复代码块可能比较多,在实际使用中可以针对检测范围进行设置,如下设置参数供参考:
- 最小tokens:
--min-tokens
,简写-k
- 最小行数:
--min-lines
,简写-l
- 最大行数:
--max-lines
,简写-x
jscpd ./src --min-tokens 200 --min-lines 20 -o 'report'
为了更便捷的使用此命令,可将这段命令集成到 package.json
中的 scripts
中,后续只需执行 npm run jscpd
即可执行检测。如下所示:
"scripts": {
...
"jscpd": "jscpd ./src --min-tokens 200 --min-lines 20 -o 'report'",
...
}
忽略代码块
上面所提到的ignore
可以忽略某个文件或文件夹,还有一种忽略方式是忽略文件中的某一块代码。由于一些重复代码在实际情况中是必要的,可以使用代码注释标识的方式忽略检测,在代码的首尾位置添加注释,jscpd:ignore-start
和 jscpd:ignore-end
包裹代码即可。
在js代码中使用方式:
/* jscpd:ignore-start */
import lodash from 'lodash';
import React from 'react';
import {User} from './models';
import {UserService} from './services';
/* jscpd:ignore-end */
在CSS和各种预处理中与js中的用法一致:
/* jscpd:ignore-start */
.style {
padding: 40px 0;
font-size: 26px;
font-weight: 400;
color: #464646;
line-height: 26px;
}
/* jscpd:ignore-end */
在html代码中使用方式:
<!--
// jscpd:ignore-start
-->
<meta data-react-helmet="true" name="theme-color" content="#cb3837"/>
<link data-react-helmet="true" rel="stylesheet" href="https://static.npmjs.com/103af5b8a2b3c971cba419755f3a67bc.css"/>
<link data-react-helmet="true" rel="apple-touch-icon" sizes="120x120" href="https://static.npmjs.com/58a19602036db1daee0d7863c94673a4.png"/>
<link data-react-helmet="true" rel="icon" type="image/png" href="https://static.npmjs.com/b0f1a8318363185cc2ea6a40ac23eeb2.png" sizes="32x32"/>
<!--
// jscpd:ignore-end
-->
总结
jscpd
是一款强大的前端本地代码重复度检测工具。它可以帮助开发者快速发现代码重复问题,简单的配置即可输出直观的代码重复数据,通过解决重复的代码提高代码的质量和可维护性。
使用jscpd
我们可以有效地优化前端开发过程,提高代码的效率和性能。希望本文能够对你了解基于jscpd
的前端本地代码重复度检测有所帮助。
看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~
专注前端开发,分享前端相关技术干货,公众号:南城大前端(ID: nanchengfe)
来源:juejin.cn/post/7288699185981095988
2种纯前端换肤方案
前言
换肤功能是一项普遍的需求,尤其是在夜晚,用户更倾向于使用暗黑模式。在我负责的公司项目中,每个项目都有换肤功能的需求。
过去,我主要使用 SCSS 变量,并利用其提供的函数,如 @each
、map-get
来实现换肤功能。但因其使用成本高,只能适用于SCSS项目,于是后来我改用 CSS 变量来实现换肤。这样无论是基于 LESS 的 React 项目,还是基于 SCSS 的 Vue 项目,都能应用换肤功能。并且使用时只需调用var
函数,降低了使用成本。
Demo地址:github.com/cwjbjy/vite…
1. 一键换肤
1. 前置知识
CSS变量:声明自定义CSS属性,它包含的值可以在整个文档中重复使用。属性名需要以两个减号(--)开始,属性值则可以是任何有效的 CSS 值
--fontColor:'#fff'
Var函数:用于使用CSS变量。第一个参数为CSS变量名称,第二个可选参数作为默认值
color: var(--fontColor);
CSS属性选择器:匹配具有特定属性或属性值的元素。例如[data-theme='black'],将选择所有 data-theme 属性值为 'black' 的元素
2. 定义主题色
1. 新建src/assets/theme/theme-default.css
这里定义字体颜色与布局的背景色,更多CSS变量可根据项目的需求来定义
[data-theme='default'] {
/* 字体 */
--font-primary: #fff;
--font-highlight: #434a50;
/* 布局 */
--background-header: #2f3542;
--background-aside: #545c64;
--background-main: #0678be;
}
2. 新建src/assets/theme/theme-black.css
再定义一套暗黑主题色
[data-theme='black'] {
/* 字体 */
--font-primary: #fff;
--font-highlight: #434a50;
/* 布局 */
--background-header: #303030;
--background-aside: #303030;
--background-main: #393939;
}
3. 新建src/assets/theme/index.css
在index.css文件中导出全部主题色
@import './theme-default.css';
@import './theme-black.css';
4. 引入全局样式
在入口文件引入样式,比如我这里是main.tsx
import '@/assets/styles/theme/index.css';
3. 在html标签上增加自定义属性
修改index.html,在html标签上增加自定义属性data-theme
<html lang="en" data-theme="default"></html>
这里使用data-theme是为了被CSS属性选择器[data-theme='default']选中,也可更换为其他自定义属性,只需与CSS属性选择器对应上即可。
4. 修改CSS主题色
关键点:监听change事件,使用document.documentElement.setAttribute动态修改data-theme属性,然后CSS属性选择器将自动选择对应的css变量
<template>
<div>
<select name="pets" @change="handleChange">
<option value="default">默认色</option>
<option value="black">黑色</option>
</select>
<div>登录页面</div>
</div>
</template>
<script setup lang="ts">
const handleChange = (e: Event) => {
window.document.documentElement.setAttribute('data-theme', (e.target as HTMLSelectElement).value);
};
</script>
<style lang="scss">
body {
color: var(--font-primary);
background-color: var(--background-main);
}
</style>
效果图,默认色:
效果图,暗黑色:
5. 修改JS主题色
切换主题色,除了需要修改css样式,有时也需在js文件中修改样式,例如修改echarts的配置文件,来改变柱状图、饼图等的颜色。
1. 新建src/config/theme.js
定义图像的颜色,这里定义字体的颜色,默认情况下字体为黑色,暗黑模式下,字体为白色
const themeColor = {
default: {
font: '#333',
},
black: {
font: '#fff',
},
};
export default themeColor;
2. 修改vue文件
关键点:
- 定义主题色TS类型,规定默认和暗黑两种:
type ThemeTypes = 'default' | 'black';
- 定义theme响应式变量,用来记录当前主题色:
const theme = ref<ThemeTypes>('default');
- 监听change事件,将选中的值赋给theme:
theme.value = selectTheme;
- 使用watch进行监听,如果theme改变,则重新绘制echarts图形
完整的vue文件:
<template>
<div>
<select name="pets" @change="handleChange">
<option value="default">默认色</option>
<option value="black">黑色</option>
</select>
<div>登录页面</div>
<div ref="echartRef" class="myChart"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import themeColor from '@/config/theme';
import * as echarts from 'echarts';
type ThemeTypes = 'default' | 'black';
const echartRef = ref<HTMLDivElement | null>(null);
const theme = ref<ThemeTypes>('default');
const handleChange = (e: Event) => {
const selectTheme = (e.target as HTMLSelectElement).value as ThemeTypes;
theme.value = selectTheme;
window.document.documentElement.setAttribute('data-theme', selectTheme);
};
const drawGraph = () => {
let echartsInstance = echarts.getInstanceByDom(echartRef.value!);
if (!echartsInstance) {
echartsInstance = echarts.init(echartRef.value);
}
echartsInstance.clear();
var option = {
color: ['#3398DB'],
title: {
text: '柱状图',
left: 'center',
textStyle: {
color: themeColor[theme.value].font,
},
},
xAxis: [
{
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
axisLabel: {
show: true,
color: themeColor[theme.value].font,
},
nameTextStyle: {
color: themeColor[theme.value].font,
},
},
],
yAxis: [
{
type: 'value',
axisLabel: {
show: true,
color: themeColor[theme.value].font,
},
nameTextStyle: {
color: themeColor[theme.value].font,
},
},
],
series: [
{
name: '直接访问',
type: 'bar',
barWidth: '60%',
data: [10, 52, 200, 334, 390, 330, 220],
},
],
};
echartsInstance.setOption(option);
};
onMounted(() => {
drawGraph();
});
watch(theme, () => {
drawGraph();
});
</script>
<style lang="scss">
body {
color: var(--font-primary);
background-color: var(--background-main);
}
.myChart {
width: 300px;
height: 300px;
}
</style>
2. 一键变灰
在特殊的日子里,网页有整体变灰色的需求。可以使用filter 的 grayscale() 改变图像灰度,值在 0% 到 100% 之间,值为0%展示原图,值为100% 则完全转为灰度图像
body {
filter: grayscale(1); //1相当于100%
}
结尾
本文只是介绍大概的思路,更多的功能可根据业务增加。例如将主题色theme存储到pinia上,应用到全局上;将主题色存储到localStorage上,在页面刷新时,防止主题色恢复默认。
本文可结合以下文章阅读:
如果有更多的换肤方案,欢迎在留言区留言讨论。我会根据留言区内容实时更新。
来源:juejin.cn/post/7342527074526019620
实现一个支持@的输入框
近期产品期望在后台发布帖子或视频时,需要添加 @用户 的功能,以便用户收到通知,例如“xxx在xxx提及了您!”。然而,现有的开源库未能满足我们的需求,例如 ant-design 的 Mentions 组件:
但是不难发现跟微信飞书对比下,有两个细节没有处理。
- @用户没有高亮
- 在删除时没有当做一个整体去删除,而是单个字母删除,首先不谈用户是否想要整体删除,在这块有个模糊查询的功能,如果每删一个字母之后去调接口查询数据库造成一些不必要的性能开销,哪怕加上防抖。
然后也是找了其他的库都没达到产品的期望效果,那么好,自己实现一个,先看看最终实现的效果:
封装之后使用:
<AtInput
height={150}
onRequest={async (searchStr) => {
const { data } = await UserFindAll({ nickname: searchStr });
return data?.list?.map((v) => ({
id: v.uid,
name: v.nickname,
wechatAvatarUrl: v.wechatAvatarUrl,
}));
}}
onChange={(content, selected) => {
setAtUsers(selected);
}}
/>
那么实现这么一个输入框大概有以下几个点:
- 高亮效果
- 删除/选中用户时需要整体删除
- 监听@的位置,复制给弹框的坐标,联动效果
- 最后我需要拿到文本内容,并且需要拿到@那些用户,去做表单提交
大多数文本输入框我们会使用input,或者textarea,很明显以上1,2两点实现不了,antd也是使用的textarea,所以也是没有实现这两个效果。所以这块使用富文本编辑,设置contentEditable,将其变为可编辑去做。输入框以及选择器的dom就如下:
<div style={{ height, position: 'relative' }}>
{/* 编辑器 */}
<div
id="atInput"
ref={atRef}
className={'editorDiv'}
contentEditable
onInput={editorChange}
onClick={editorClick}
/>
{/* 选择用户框 */}
<SelectUser
options={options}
visible={visible}
cursorPosition={cursorPosition}
onSelect={onSelect}
/>
</div>
实现思路:
- 监听输入@,唤起选择框。
- 截取@xxx的xxx作为搜素的关键字去查询接口
- 选择用户后需要将原先输入的 @xxx 替换成 @姓名,并且将@的用户缓存起来
- 选择文本框中的姓名时需要变为整体选中状态,这块依然可以给标签设置为不可编辑状态就可实现,contentEditable=false,即可实现整体删除,在删除的同时需要将当前用户从之前缓存的@过的用户数组删除
- 那么可以拿到输入框的文本,@的用户, 最后将数据抛给父组件就完成了
以上提到了监听@文本变化,通常绑定onChange事件就行,但是还有一种用户通过点击移动光标,这块需要绑定change,click两个时间,他们里边的逻辑基本一样,只需要额外处理点击选中输入框中用户时,整体选中g功能,那么代码如下:
const onObserveInput = () => {
let cursorBeforeStr = '';
const selection: any = window.getSelection();
if (selection?.focusNode?.data) {
cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
}
setFocusNode(selection.focusNode);
const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if (lastAtIndex !== -1) {
getCursorPosition();
const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
} else {
setVisible(false);
setSearchStr('');
}
} else {
setVisible(false);
}
};
const selectAtSpanTag = (target: Node) => {
window.getSelection()?.getRangeAt(0).selectNode(target);
};
const editorClick = async (event) => {
onObserveInput();
// 判断当前标签名是否为span 是的话选中当做一个整体
if (e.target.localName === 'span') {
selectAtSpanTag(e.target);
}
};
const editorChange = (event) => {
const { innerText } = event.target;
setContent(innerText);
onObserveInput();
};
每次点击或者文本改变时都会去调用onObserveInput,以上onObserveInput该方法中主要做了以下逻辑:
- 在此之前需要先了解 Selection的一些方法
- 通过getSelection方法可以获取光标的偏移位置,那么可以截取光标之前的字符串,并且使用lastIndexOf从后向前查找最后一个“@”符号,并记录他的下标,那么有了【光标之前的字符串】,【@的下标】就可以拿到到@之后用于过滤用户的关键字,并将其缓存起来。
- 唤起选择器,并通过关键字去过滤用户。这块涉及到一个选择器的位置,直接使用window.getSelection()?.getRangeAt(0).getBoundingClientRect()去获取光标的位置拿到的是光标相对于窗口的坐标,直接用这个坐标会有问题,比如滚动条滚动时,这个选择器发生位置错乱,所以这块同时去拿输入框的坐标,去做一个相减,这样就可以实现选择器跟着@符号联动的效果。
const getCursorPosition = () => {
// 坐标相对浏览器的坐标
const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
// 获取编辑器的坐标
const editorDom = window.document.querySelector('#atInput');
const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
// 光标所在位置
setCursorPosition({ x: x - eX, y: y - eY });
};
选择器弹出后,那么下面就到了选择用户之后的流程了,
/**
* @param id 唯一的id 可以uid
* @param name 用户姓名
* @param color 回显颜色
* @returns
*/
const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
const ele = document.createElement('span');
ele.className = 'at-span';
ele.style.color = color;
ele.id = id.toString();
ele.contentEditable = 'false';
ele.innerText = `@${name}`;
return ele;
};
/**
* 选择用户时回调
*/
const onSelect = (item: Options) => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0) as Range;
// 选中输入的 @关键字 -> @郑
range.setStart(focusNode as Node, currentAtIdx!);
range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
// 删除输入的 @关键字
range.deleteContents();
// 创建元素节点
const atEle = createAtSpanTag(item.id, item.name);
// 插入元素节点
range.insertNode(atEle);
// 光标移动到末尾
range.collapse();
// 缓存已选中的用户
setSelected([...selected, item]);
// 选择用户后重新计算content
setContent(document.getElementById('atInput')?.innerText as string);
// 关闭弹框
setVisible(false);
// 输入框聚焦
atRef.current.focus();
};
选择用户的时候需要做的以下以下几点:
- 删除之前的@xxx字符
- 插入不可编辑的span标签
- 将当前选择的用户缓存起来
- 重新获取输入框的内容
- 关闭选择器
- 将输入框重新聚焦
最后
在选择的用户或者内容发生改变时将数据抛给父组件
const getAttrIds = () => {
const spans = document.querySelectorAll('.at-span');
let ids = new Set();
spans.forEach((span) => ids.add(span.id));
return selected.filter((s) => ids.has(s.id));
};
/** @的用户列表发生改变时,将最新值暴露给父组件 */
useEffect(() => {
const selectUsers = getAttrIds();
onChange(content, selectUsers);
}, [selected, content]);
完整组件代码
输入框主要逻辑代码:
let timer: NodeJS.Timeout | null = null;
const AtInput = (props: AtInputProps) => {
const { height = 300, onRequest, onChange, value, onBlur } = props;
// 输入框的内容=innerText
const [content, setContent] = useState<string>('');
// 选择用户弹框
const [visible, setVisible] = useState<boolean>(false);
// 用户数据
const [options, setOptions] = useState<Options[]>([]);
// @的索引
const [currentAtIdx, setCurrentAtIdx] = useState<number>();
// 输入@之前的字符串
const [focusNode, setFocusNode] = useState<Node | string>();
// @后关键字 @郑 = 郑
const [searchStr, setSearchStr] = useState<string>('');
// 弹框的x,y轴的坐标
const [cursorPosition, setCursorPosition] = useState<Position>({
x: 0,
y: 0,
});
// 选择的用户
const [selected, setSelected] = useState<Options[]>([]);
const atRef = useRef<any>();
/** 获取选择器弹框坐标 */
const getCursorPosition = () => {
// 坐标相对浏览器的坐标
const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
// 获取编辑器的坐标
const editorDom = window.document.querySelector('#atInput');
const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
// 光标所在位置
setCursorPosition({ x: x - eX, y: y - eY });
};
/**获取用户下拉列表 */
const fetchOptions = (key?: string) => {
if (timer) {
clearTimeout(timer);
timer = null;
}
timer = setTimeout(async () => {
const _options = await onRequest(key);
setOptions(_options);
}, 500);
};
useEffect(() => {
fetchOptions();
// if (value) {
// /** 判断value中是否有at用户 */
// const atUsers: any = StringTools.filterUsers(value);
// setSelected(atUsers);
// atRef.current.innerHTML = value;
// setContent(value.replace(/<\/?.+?\/?>/g, '')); //全局匹配内html标签)
// }
}, []);
const onObserveInput = () => {
let cursorBeforeStr = '';
const selection: any = window.getSelection();
if (selection?.focusNode?.data) {
cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
}
setFocusNode(selection.focusNode);
const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if (lastAtIndex !== -1) {
getCursorPosition();
const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
} else {
setVisible(false);
setSearchStr('');
}
} else {
setVisible(false);
}
};
const selectAtSpanTag = (target: Node) => {
window.getSelection()?.getRangeAt(0).selectNode(target);
};
const editorClick = async (e?: any) => {
onObserveInput();
// 判断当前标签名是否为span 是的话选中当做一个整体
if (e.target.localName === 'span') {
selectAtSpanTag(e.target);
}
};
const editorChange = (event: any) => {
const { innerText } = event.target;
setContent(innerText);
onObserveInput();
};
/**
* @param id 唯一的id 可以uid
* @param name 用户姓名
* @param color 回显颜色
* @returns
*/
const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
const ele = document.createElement('span');
ele.className = 'at-span';
ele.style.color = color;
ele.id = id.toString();
ele.contentEditable = 'false';
ele.innerText = `@${name}`;
return ele;
};
/**
* 选择用户时回调
*/
const onSelect = (item: Options) => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0) as Range;
// 选中输入的 @关键字 -> @郑
range.setStart(focusNode as Node, currentAtIdx!);
range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
// 删除输入的 @关键字
range.deleteContents();
// 创建元素节点
const atEle = createAtSpanTag(item.id, item.name);
// 插入元素节点
range.insertNode(atEle);
// 光标移动到末尾
range.collapse();
// 缓存已选中的用户
setSelected([...selected, item]);
// 选择用户后重新计算content
setContent(document.getElementById('atInput')?.innerText as string);
// 关闭弹框
setVisible(false);
// 输入框聚焦
atRef.current.focus();
};
const getAttrIds = () => {
const spans = document.querySelectorAll('.at-span');
let ids = new Set();
spans.forEach((span) => ids.add(span.id));
return selected.filter((s) => ids.has(s.id));
};
/** @的用户列表发生改变时,将最新值暴露给父组件 */
useEffect(() => {
const selectUsers = getAttrIds();
onChange(content, selectUsers);
}, [selected, content]);
return (
<div style={{ height, position: 'relative' }}>
{/* 编辑器 */}
<div id="atInput" ref={atRef} className={'editorDiv'} contentEditable onInput={editorChange} onClick={editorClick} />
{/* 选择用户框 */}
<SelectUser options={options} visible={visible} cursorPosition={cursorPosition} onSelect={onSelect} />
</div>
);
};
选择器代码
const SelectUser = React.memo((props: SelectComProps) => {
const { options, visible, cursorPosition, onSelect } = props;
const { x, y } = cursorPosition;
return (
<div
className={'selectWrap'}
style={{
display: `${visible ? 'block' : 'none'}`,
position: 'absolute',
left: x,
top: y + 20,
}}
>
<ul>
{options.map((item) => {
return (
<li
key={item.id}
onClick={() => {
onSelect(item);
}}
>
<img src={item.wechatAvatarUrl} alt="" />
<span>{item.name}</span>
</li>
);
})}
</ul>
</div>
);
});
export default SelectUser;
以上就是实现一个支持@用户的输入框功能,就目前而言,比较死板,不支持自定义颜色,自定义选择器等等,未来,可以进一步扩展功能,例如添加@用户的高亮样式定制、支持键盘快捷键操作等,从而提升用户体验和功能性。
来源:juejin.cn/post/7357917741909819407
Vue.js 自动路由:告别手动配置,让开发更轻松!
在使用 Vue.js 开发项目的时候,我最头疼的就是创建路由,尤其是项目越来越大的时候,管理 route.ts
或 route.js
文件简直是一场噩梦!
我曾经做过一个页面超级多的项目,每新增一个页面就要更新路由,删除页面也要更新路由文件,不然就会报错,真是烦死人了!
所以,我开始寻找自动生成路由的方法。我在网上搜了很久,但大部分结果都是针对 Webpack 和 Vue 2 的,很难找到适合我的方案。最后,我在 Vue 的 GitHub 仓库的讨论区里提问,终于找到了答案!
那就是 Unplugin Vue Router
! 它可以为 Vue 3 实现基于文件的自动路由,而且支持 TypeScript,设置起来也超级简单! 虽然官方说它还在实验阶段,但用起来已经很方便了。
创建项目,安装插件
首先,我们创建一个新的 Vue 项目。 相信大家都很熟悉用 Vue CLI 创建项目了,这里就不赘述了,不熟悉的小伙伴可以去看看 Vue.js 官网的快速入门指南。
pnpm create vue@latest
我创建项目的时候选择了 TypeScript 和 Vue Router,这样它就会自动生成一些页面和路由。
然后,进入项目目录,安装依赖。我最近开始用 pnpm
来管理依赖,感觉还不错。
pnpm add -D unplugin-vue-router
接下来,更新 vite.config.ts
文件, 注意要把插件放在第 0 个位置 。
import { fileURLToPath, URL } from "node:url";
import VueRouter from "unplugin-vue-router/vite";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
VueRouter({
/* options */
}),
// ⚠️ Vue must be placed after VueRouter()
vue(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});
然后,更新 env.d.ts
文件,让编辑器能够识别插件的类型。
/// <reference types="vite/client" />
/// <reference types="unplugin-vue-router/client" />
最后,更新路由文件 src/router/index.ts
。
import { createRouter, createWebHistory } from "vue-router";
import { routes, handleHotUpdate } from "vue-router/auto-routes";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
if (import.meta.hot) {
handleHotUpdate(router);
}
export default router;
创建页面,自动生成路由
现在,我们可以创建 src/pages
目录了,在这个目录下创建的 Vue 组件会自动变成路由和页面,就像 Nuxt 一样方便!
我们先在 src\pages\about.vue
创建一个关于页面:
<template>
<div>This is the about page</div>
</template>
然后在 src\pages\index.vue
创建首页:
<template>
<div>This is Home Page</div>
</template>
运行 pnpm dev
启动开发服务器,点击 “Home” 链接就会跳转到首页,点击 “About” 链接就会跳转到关于页面。
怎么样,是不是很方便? 如果你不熟悉路由文件夹结构,可以看看这个文档: uvr.esm.is/guide/file-…
动态路由
我们再来试试创建带参数的动态路由。在 src/pages/blog/[id].vue
创建一个组件,内容如下:
<script setup>
const { id } = useRoute().params;
</script>
<template>
<div>This is the blog post with id: {{ id }}</div>
</template>
再次运行 pnpm dev
,然后访问 http://localhost:5173/blog/6
,你就会看到以下内容:
是不是很神奇? 希望这篇简短的博客能帮助你在 Vue.js 的旅程中更轻松地创建路由!
来源:juejin.cn/post/7401354593588199465
JS类型判断的四种方法,你掌握了吗?
引言
JavaScript中有七种原始数据类型和几种引用数据类型,本文将清楚地介绍四种用于类型判断的方法,分别是typeOf
、instanceOf
、Object.prototype.toString.call()
、Array.isArray()
,并介绍其使用方法和判定原理。
typeof
- 可以准确判断除
null
之外的所有原始类型,null
会被判定成object function
类型可以被准确判断为function
,而其他所有引用类型都会被判定为object
let s = '123' // string
let n = 123 // number
let f = true // boolean
let u = undefined // undefined
let nu = null // null
let sy = Symbol(123) // Symbol
let big = 1234n // BigInt
let obj = {}
let arr = []
let fn = function() {}
let date = new Date()
console.log(typeof s); // string typeof后面有无括号都行
console.log(typeof n); // number
console.log(typeof f); // boolean
console.log(typeof u); // undefined
console.log(typeof(sy)); // symbol
console.log(typeof(big)); // bigint
console.log(typeof(nu)); // object
console.log(typeof(obj)); // object
console.log(typeof(arr)); // object
console.log(typeof(date)); // object
console.log(typeof(fn)); // function
判定原理
typeof是通过将值转换为二进制之后,判断其前三位是否为0:都是0则为object,反之则为原始类型。因为原始类型转二进制,前三位一定不都是0;反之引用类型被转换成二进制前三位一定都是0。
null
是原始类型却被判定为object
就是因为它在机器中是用一长串0来表示的,可以把这看作是一个史诗级的bug。
所以用typeof
判断接收到的值是否为一个对象时,还要注意排除null的情况:
function isObject() {
if(typeof(o) === 'object' && o !== null){
return true
}
return false
}
你丢一个值给typeof
,它会告诉你这个字值是什么类型,但是它无法准确告诉你这是一个Array
或是Date
,若想要如此精确地知道一个对象类型,可以用instanceof
告诉你是否为某种特定的类型
instanceof
只能精确地判断引用类型,不能判断原始类型
console.log(obj instanceof Object);// true
console.log(arr instanceof Array);// true
console.log(fn instanceof Function);// true
console.log(date instanceof Date);// true
console.log(s instanceof String);// false
console.log(n instanceof Number);// false
console.log(arr instanceof Object);// true
判定原理
instanceof
既能把数组判定成Array
,又能把数组判定成Object
,究其原因是原型链的作用————顺着数组实例 arr 的隐式原型一直找到了 Object 的构造函数,看下面的代码:
arr.__proto__ = Array.prototype
Array.prototype.__proto__ = Object.prototype
所以我们就知道了,instanceof
能准确判断出一个对象是否为某种类型,就是依靠对象的原型链来查找的,一层又一层地判断直到找到null
为止。
手写instanceOf
根据这个原理,我们可以手写出一个instanceof
:
function myinstanceof(L, R) {
while(L != null) {
if(L.__proto__ === R.prototype){
return true;
}
L = L.__proto__;
}
return false;
}
console.log(myinstanceof([], Array)) // true
console.log(myinstanceof({}, Object)) // true
对象的隐式原型 等于 构造函数的显式原型!可看文章 给我三分钟,带你完全理解JS原型和原型链前言
Object.prototype.toString.call()
可以判断任何数据类型
在浏览器上执行这三段代码,会得到'[object Object]'
,'[object Array]'
,'[object Number]'
var a = {}
Object.prototype.toString.call(a)
var a = {}
Object.prototype.toString.call(a)
var a = 123
Object.prototype.toString.call(a)
原型上的toString的内部逻辑
调用Object.prototype.toString
的时候执行会以下步骤: 参考官方文档:带注释的 ES5
- 如果此值是
undefined
类型,则返回‘[object Undefined]’
- 如果此值是
null
类型,则返回‘[object Null]’
- 将 O 作为
ToObject(this)
的执行结果。toString
执行过程中会调用一个ToObject
方法,执行一个类似包装类的过程,我们访问不了这个方法,是JS自己用的 - 定义一个
class
作为内部属性[[class]]
的值。toString可以读取到这个值并把这个值暴露出来让我们看得见 - 返回由
"[object"
和class
和"]"
组成的字符串
为什么结合call就能准确判断值类型了呢?
① 首先我们要知道Object.prototype.toString(xxx)
往括号中不管传递什么返回结果都是'[object Object]'
,因为根据上面五个步骤来看,它内部会自动执行ToObject()
方法,xxx
会被执行一个类似包装类的过程然后转变成一个对象。所以单独一个Object.prototype.toString(xxx)
不能用来判定值的类型
② 其次了解call方法的核心原理就是:比如foo.call(obj)
,利用隐式绑定的规则,让obj对象拥有foo这个函数的引用,从而让foo函数的this指向obj,执行完foo函数内部逻辑后,再将foo函数的引用从obj上删除掉。手搓一个call的源码就是这样的:
// call方法只允许被函数调用,所以它应该是放在Function构造函数的显式原型上的
Function.prototype.mycall = function(context) {
// 判断调用我的那个哥们是不是函数体
if (typeof this !== 'function') {
return new TypeError(this+ 'is not a function')
}
// this(函数)里面的this => context对象
const fn = Symbol('key') // 定义一个独一无二的fn,防止使用该源码时与其他fn产生冲突
context[fn] = this // 让对象拥有该函数 context={Symbol('key'): foo}
context[fn]() // 触发隐式绑定
delete context[fn]
}
③ 所以Object.prototype.toString.call(xxx)
就相当于 xxx.toString()
,把toString()方法放在了xxx对象上调用,这样就能精准给出xxx的对象类型
toString方法有几个版本:
{}.toString()
得到由"[object" 和 class 和 "]" 组成的字符串
[].toString()
数组的toString方法重写了对象上的toString方法,返回由数组内部元素以逗号拼接的字符串
xx.toString()
返回字符串字面量,比如
let fn = function(){};
console.log( fn.toString() ) // "function () {}"
Array.isArray(x)
只能判断是否是数组,若传进去的x是数组,返回true,否则返回false
总结
typeOf:原始类型除了null都能准确判断,引用类型除了function能准确判断其他都不能。依靠值转为二进制后前三位是否为0来判断
instanceOf:只能把引用类型丢给它准确判断。顺着对象的隐式原型链向上比对,与构造函数的显式原型相等返回true,否则false
Object.prototype.toString.call():可以准确判断任何类型。要了解对象原型的toString()内部逻辑和call()的核心原理,二者结合才有精准判定的效果
Array.isArray():是数组则返回true,不是则返回false。判定范围最狭窄
来源:juejin.cn/post/7403288145196580904
学TypeScript必然要了解declare
背景
declare关键字是为了服务TypeScript的。TypeScript是什么在这里就不多介绍了,但是我们要知道ts文件是需要TypeScript编译器转换为js文件才可以执行,并且在编译阶段就会进行类型检查。但是在TypeScript中并不支持js可识别的所有类型,例如我们使用第三方库JQuery,我们通过一下方法获取一个id为‘foo’的标签元素。
$('#foo');
// or
jQuery('#foo');
然而在ts文件中,使用底下就会爆出一条红线提示到:Cannot find name '$'
因此,需要declare来声明,告诉TypeScript编译器该标识符已存在,通过编译时的检查并在开发时提供类型提示。
定义
在 TypeScript 中,declare关键字告诉编译器存在一个对象(并且可以在代码中引用)。它向 TypeScript 编译器声明该对象。简而言之,它允许开发人员使用在其他地方声明的对象。
注:编译器不会将declare语句编译为 JavaScript。对比下面两段代码:
// declare声明了一个名为 myGlobal 的全局变量,并指定其类型为 any。
// 该声明并不会生成真正的 JavaScript 代码,而只是告诉 TypeScript 编译器该变量存在。
declare var myGlobal: any;
// 给 myGlobal 赋值为 42。
myGlobal = 42;
console.log(myGlobal); // 42
// 直接声明了一个名为 myGlobal 的全局变量,并指定其类型为 any。这会生成真正的 JavaScript 代码。
var myGlobal: any;
// 给 myGlobal 赋值为 42。
myGlobal = 42;
console.log(myGlobal); // 42
使用
- declare var 声明全局变量
- declare function 声明全局方法
- declare class 声明全局类
- declare enum 声明全局枚举类型
- declare namespace 声明(含有子属性的)全局对象
- declare global 扩展全局变量
- declare module 扩展模块
声明文件
通常,在使用第三方库或模块时,有两种方式引入声明文件:
- 全局声明:如果第三方库或模块是全局可访问的,你可以在整个项目的任何地方直接使用它们,而无需显式导入。此时,你只需要确保在 TypeScript 项目中正确引入了相应的声明文件。一般情况下,TypeScript 会自动查找并加载全局声明文件。如果没有自动加载,你可以使用 /// 的方式在具体的源文件中将声明文件引入。
- 模块导入:如果第三方库或模块是通过模块化方式提供的,你需要使用 import 语句将其导入到你的代码中,同时也需要确保相应的声明文件被正确引入。在这种情况下,你可以使用 import 或 require 来引入库,并且不需要显式地引入声明文件,因为 TypeScript 编译器会根据模块的导入语句自动查找和加载相应的声明文件。
有很多第三方库提供了声明文件,可以在packages.json文件中查看。types表示类型声明文件是哪一个。
可以使用 @types 统一管理第三方库的声明文件。@types 的使用方式很简单,直接用 npm 安装对应的声明模块即可,以 jQuery 举例:
npm install @types/jquery --save-dev
来源:juejin.cn/post/7402811318816702515
厉害了,不用js就能实现文字中间省略号
今天发现一个特别有意思的效果,并进行解析,就是标题的效果
参考链接
如果要实现这个功能,我想很多人第一时间想到的都是用js去计算dom容器和文字之间是否溢出吧?但今天带来一个用css实现的效果,不用自己计算,只需要寥寥几行(bushi)就可以实现让人头疼的文字中间省略号功能。
实现思路
1. 简单实现
在用css实现的时候我们不妨用这个思路想想,设置一个当前显示文字span伪元素的width为50%,浮动到当前span上面,并且设置direction: rtl;
显示右边文字,不就可以很简单的实现这个功能了?让我们试试:
<style>
.wrap {
width: 200px;
border: 1px solid white;
}
.test-title {
display: block;
color: white;
overflow: hidden;
height: 20px;
}
.test-title::before {
content: attr(title);
width: 50%;
float: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
}
</style>
<body>
<div class="wrap">
<span class="test-title" title="这是一行文字文字文字文字文字文字文字文字文字文字文字文字文字">
这是一行文字文字文字文字文字文字文字文字文字文字文字文字文字
</span>
</div>
</body>
💡 此处应有图
2. 优化效果
在上面我们已经看到,其实效果我们已经实现了,现在文字中间已经有了省略号了!但是这其中其实有一个弊端不知道大家有没有发现,那就是文本不溢出的情况呢?伪元素是不是会一直显示在上面?这该怎么办?难道我们需要用js监听文本不溢出的情况然后手动隐藏吗?
既然是用css来进行实现,那么我们当然不能用这种方式了。这里原作者用了一种很取巧,但也很好玩的一种方法,让我们来看看吧!
既然我们上面实现的是文本溢出的情况,那么当文本不溢出的时候我们直接显示文字不就行了?你可能想说:“这不是废话吗?但我现在不就是不知道怎么判断吗? ”。hhhhh对,那我们就要用css来想想,css该怎么判断呢?我就不卖关子了,让我们想想,我们给文本的容器添加一个固定宽度,那么当文本溢出的时候会发生什么呢?是不是会换行,高度变大呢,那么当我们设置两个文本元素,一个是正常样式,一个是我们上方的溢出样式。等文本不溢出没换行的时候,显示正常样式,当文本溢出高度变大的时候显示溢出样式可以吗?让我们试试吧
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
background: #333;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
margin: 0;
font-size: 14px;
}
.wrap {
width: 300px;
background: #333;
/* 设置正常行高,并隐藏溢出画面 */
height: 2em;
overflow: hidden;
line-height: 2;
position: relative;
text-align: -webkit-match-parent;
resize: horizontal;
}
.normal-title {
/* 设置最大高度为双倍行高使其可以换行 */
display: block;
max-height: 4em;
}
.test-title {
position: relative;
top: -4em;
display: block;
color: white;
overflow: hidden;
height: 2em;
text-align: justify;
background: inherit;
overflow: hidden;
}
.test-title::before {
content: attr(title);
width: 50%;
float: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
}
</style>
</head>
<body>
<div class="wrap">
<span class="normal-title">这是一行文字文字文字文字文字文字文字文字文字文字文字文字文字</span>
<span class="test-title" title="这是一行文字文字文字文字文字文字文字文字文字文字文字文字文字">
这是一行文字文字文字文字文字文字文字文字文字文字文字文字文字
</span>
</div>
</body>
</html>
大家都试过了吧?那让我们来讲一下这段代码的实现:
实现方式:简单来说这段代码实现的就是一个覆盖的效果,normal-title元素平常是普通高度(1em),等到换行之后就会变成2em,那么我们的溢出样式test-title怎么实现的覆盖呢?这主要依赖于test-title的top属性,让我们这样子想,当normal-title高度为1em的时候,test-title的top为-2em,那么这时候因为wrap的hidden效果,所以test-title是看不到的。那么当normal-title的高度为2em的时候呢?test-title刚好就会覆盖到normal-title上面,所以我们刚好可以看到test-title的省略号效果。
这就是完整的实现过程和方式,css一些取巧的判断方式总会让我们大开眼界,不断学习,方得始终。
来源:juejin.cn/post/7401812292211081226
Vue.js 自动路由:告别手动配置,让开发更轻松!
在使用 Vue.js 开发项目的时候,我最头疼的就是创建路由,尤其是项目越来越大的时候,管理 route.ts
或 route.js
文件简直是一场噩梦!
我曾经做过一个页面超级多的项目,每新增一个页面就要更新路由,删除页面也要更新路由文件,不然就会报错,真是烦死人了!
所以,我开始寻找自动生成路由的方法。我在网上搜了很久,但大部分结果都是针对 Webpack 和 Vue 2 的,很难找到适合我的方案。最后,我在 Vue 的 GitHub 仓库的讨论区里提问,终于找到了答案!
那就是 Unplugin Vue Router
! 它可以为 Vue 3 实现基于文件的自动路由,而且支持 TypeScript,设置起来也超级简单! 虽然官方说它还在实验阶段,但用起来已经很方便了。
创建项目,安装插件
首先,我们创建一个新的 Vue 项目。 相信大家都很熟悉用 Vue CLI 创建项目了,这里就不赘述了,不熟悉的小伙伴可以去看看 Vue.js 官网的快速入门指南。
pnpm create vue@latest
我创建项目的时候选择了 TypeScript 和 Vue Router,这样它就会自动生成一些页面和路由。
然后,进入项目目录,安装依赖。我最近开始用 pnpm
来管理依赖,感觉还不错。
pnpm add -D unplugin-vue-router
接下来,更新 vite.config.ts
文件, 注意要把插件放在第 0 个位置 。
import { fileURLToPath, URL } from "node:url";
import VueRouter from "unplugin-vue-router/vite";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
VueRouter({
/* options */
}),
// ⚠️ Vue must be placed after VueRouter()
vue(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});
然后,更新 env.d.ts
文件,让编辑器能够识别插件的类型。
/// <reference types="vite/client" />
/// <reference types="unplugin-vue-router/client" />
最后,更新路由文件 src/router/index.ts
。
import { createRouter, createWebHistory } from "vue-router";
import { routes, handleHotUpdate } from "vue-router/auto-routes";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
if (import.meta.hot) {
handleHotUpdate(router);
}
export default router;
创建页面,自动生成路由
现在,我们可以创建 src/pages
目录了,在这个目录下创建的 Vue 组件会自动变成路由和页面,就像 Nuxt 一样方便!
我们先在 src\pages\about.vue
创建一个关于页面:
<template>
<div>This is the about page</div>
</template>
然后在 src\pages\index.vue
创建首页:
<template>
<div>This is Home Page</div>
</template>
运行 pnpm dev
启动开发服务器,点击 “Home” 链接就会跳转到首页,点击 “About” 链接就会跳转到关于页面。
怎么样,是不是很方便? 如果你不熟悉路由文件夹结构,可以看看这个文档: uvr.esm.is/guide/file-…
动态路由
我们再来试试创建带参数的动态路由。在 src/pages/blog/[id].vue
创建一个组件,内容如下:
<script setup>
const { id } = useRoute().params;
</script>
<template>
<div>This is the blog post with id: {{ id }}</div>
</template>
再次运行 pnpm dev
,然后访问 http://localhost:5173/blog/6
,你就会看到以下内容:
是不是很神奇? 希望这篇简短的博客能帮助你在 Vue.js 的旅程中更轻松地创建路由!
来源:juejin.cn/post/7401354593588199465
学TypeScript必然要了解declare
背景
declare关键字是为了服务TypeScript的。TypeScript是什么在这里就不多介绍了,但是我们要知道ts文件是需要TypeScript编译器转换为js文件才可以执行,并且在编译阶段就会进行类型检查。但是在TypeScript中并不支持js可识别的所有类型,例如我们使用第三方库JQuery,我们通过一下方法获取一个id为‘foo’的标签元素。
$('#foo');
// or
jQuery('#foo');
然而在ts文件中,使用底下就会爆出一条红线提示到:Cannot find name '$'
因此,需要declare来声明,告诉TypeScript编译器该标识符已存在,通过编译时的检查并在开发时提供类型提示。
定义
在 TypeScript 中,declare关键字告诉编译器存在一个对象(并且可以在代码中引用)。它向 TypeScript 编译器声明该对象。简而言之,它允许开发人员使用在其他地方声明的对象。
注:编译器不会将declare语句编译为 JavaScript。对比下面两段代码:
// declare声明了一个名为 myGlobal 的全局变量,并指定其类型为 any。
// 该声明并不会生成真正的 JavaScript 代码,而只是告诉 TypeScript 编译器该变量存在。
declare var myGlobal: any;
// 给 myGlobal 赋值为 42。
myGlobal = 42;
console.log(myGlobal); // 42
// 直接声明了一个名为 myGlobal 的全局变量,并指定其类型为 any。这会生成真正的 JavaScript 代码。
var myGlobal: any;
// 给 myGlobal 赋值为 42。
myGlobal = 42;
console.log(myGlobal); // 42
使用
- declare var 声明全局变量
- declare function 声明全局方法
- declare class 声明全局类
- declare enum 声明全局枚举类型
- declare namespace 声明(含有子属性的)全局对象
- declare global 扩展全局变量
- declare module 扩展模块
声明文件
通常,在使用第三方库或模块时,有两种方式引入声明文件:
- 全局声明:如果第三方库或模块是全局可访问的,你可以在整个项目的任何地方直接使用它们,而无需显式导入。此时,你只需要确保在 TypeScript 项目中正确引入了相应的声明文件。一般情况下,TypeScript 会自动查找并加载全局声明文件。如果没有自动加载,你可以使用 /// 的方式在具体的源文件中将声明文件引入。
- 模块导入:如果第三方库或模块是通过模块化方式提供的,你需要使用 import 语句将其导入到你的代码中,同时也需要确保相应的声明文件被正确引入。在这种情况下,你可以使用 import 或 require 来引入库,并且不需要显式地引入声明文件,因为 TypeScript 编译器会根据模块的导入语句自动查找和加载相应的声明文件。
有很多第三方库提供了声明文件,可以在packages.json文件中查看。types表示类型声明文件是哪一个。
可以使用 @types 统一管理第三方库的声明文件。@types 的使用方式很简单,直接用 npm 安装对应的声明模块即可,以 jQuery 举例:
npm install @types/jquery --save-dev
来源:juejin.cn/post/7402811318816702515
还在用 来当作空格?别忽视他对样式的影响!
许久没有更新博客了,今天就抽空来分享下之前遇到个有意思的现象~
奇怪的现象,被换行的单词
在一次新需求完工之后,进行国际化样式优化时,我发现了一个奇怪的现象:即使页面元素有word-wrap:break-word;
样式属性,单词也照样会被直接裁断换行。
这又是为什么嘞?细细分析页面元素,突然发现或许这与之前的踩过的坑:特殊的不换行空格有关?!
来复现吧!
那我们马上就来试一试!
<style>
.normal_style{
width:70px;
height:200px;
margin-right:150px;
border:1px solid red;
/* 👇表示 如果一个单词超出行长度,要截取换行,其他默认;👇 */
word-wrap:break-word;
}
</style>
<div style="display:flex;">
<div class='normal_style'>This is a long a long sentence</div>
<div class='normal_style'>This is a long a long sentence</div>
</div>
很明显,单词直接被强行换行拆分了!
那会不会是页面解析的时候,把
连同其他单词一起,当作一长串单词来处理了,所以才不换行的嘞?
你知道空格转义符有几种写法吗?
那我们就再来试试!不使用
转而使用其他空格转义符呢?
其实除了
,还有其他很多种空格转义符。
1. 半角空格
 
它才是典型的“半角空格”,全称是En Space,en是字体排印学的计量单位,为em宽度的一半。根据定义,它等同于字体度的一半(如16px字体中就是8px)。名义上是小写字母n的宽度。此空格传承空格家族一贯的特性:透明的,此空格有个相当稳健的特性,就是其占据的宽度正好是1/2个中文宽度,而且基本上不受字体影响。
2. 全角空格
 
从这个符号到下面, 我们就很少见到了, 它叫“全角空格”,全称是Em Space,em是字体排印学的计量单位,相当于当前指定的点数。例如,1 em在16px的字体中就是16px。此空格也传承空格家族一贯的特性:透明的,此空格也有个相当稳健的特性,就是其占据的宽度正好是1个中文宽度,而且基本上不受字体影响。
3. 窄空格
 
窄空格,全称是Thin Space。我们不妨称之为“瘦弱空格”,就是该空格长得比较瘦弱,身体单薄,占据的宽度比较小。它是em之六分之一宽。
4. 零宽不连字
‌
它叫零宽不连字,全称是Zero Width Non Joiner,简称“ZWNJ”,是一个不打印字符,放在电子文本的两个字符之间,抑制本来会发生的连字,而是以这两个字符原本的字形来绘制。Unicode中的零宽不连字字符映射为“”(zero width non-joiner,U+200C),HTML字符值引用为:
5. 零宽连字
‍
它叫零宽连字,全称是Zero Width Joiner,简称“ZWJ”,是一个不打印字符,放在某些需要复杂排版语言(如阿拉伯语、印地语)的两个字符之间,使得这两个本不会发生连字的字符产生了连字效果。零宽连字符的Unicode码位是U+200D (HTML: )。
再次尝试复现- 
<style>
.normal_style{
width:70px;
height:200px;
margin-right:150px;
border:1px solid red;
/* 👇表示 如果一个单词超出行长度,要截取换行,其他默认;👇 */
word-wrap:break-word;
}
</style>
<div style="display:flex;">
<div class='normal_style'>This is a long a long sentence</div>
<div class='normal_style'>This is a long a long sentence</div>
<div class='normal_style'>This is a long a long sentence</div>
</div>
我们可以看到  
进行转义的话,单词截取换行是正常的!所以,真凶就是
特殊的不换行空格!
如何修订?
因为这个提示框是使用公司自制的 UI 组件实现的,而之所以使用
进行转义是为了修订XSS注入。(对,这个老东西现在没人维护,还是我去啃源码加上的,使用了公共的转义方法)。最后就简单去修改这个公共方法吧!使用了最贴近
宽度的空格转义符: 
!
来源:juejin.cn/post/7403953367766859828
数据大屏的解决方案
1. 使用缩放比例适配各种设备(适用16*9比例的屏幕分辨率)
- 封装一个获取缩放比例的工具函数
/**
* 大屏效果需要满足16:9的屏幕比例,才能达到完美的大屏适配效果
* 其他比例的大屏效果,不能铺满整个屏幕
* @param {*} w 设备宽度 默认 1920
* @param {*} h 设备高度 默认 1080
* @returns 返回值是缩放比例
*/
export function getScale(w = 1920, h = 1080) {
const ww = window.innerWidth / w
const wh = window.innerHeight / h
return ww < wh ? ww : wh
}
- 在
vue
中使用方案如下
<template>
<div class="full-screen-container">
<div id="screen">
大屏展示的内容
</div>
</div>
</template>
<script>
import { getScale } from "@/utils/tool";
import screenfull from "screenfull";
export default {
name: "cockpit",
mounted() {
if (screenfull && screenfull.enabled && !screenfull.isFullscreen) {
screenfull.request();
}
this.setFullScreen();
},
methods: {
setFullScreen() {
const screenNode = document.getElementById("screen");
// 非标准设备(笔记本小于1920,如:1366*768、mac 1432*896)
if (window.innerWidth < 1920) {
screenNode.style.left = "50%";
screenNode.style.transform = `scale(${getScale()}) translate(-50%, 0)`;
} else if (window.innerWidth === 1920) {
// 标准设备 1920 * 1080
screenNode.style.left = 0;
screenNode.style.transform = `scale(1) translate(0, 0)`;
} else {
// 大屏设备(4K 2560*1600)
screenNode.style.left = "50%";
screenNode.style.transform = `scale(${getScale()}) translate(-50%, 0)`;
}
// 监听视口变化
window.addEventListener("resize", () => {
if (window.innerWidth === 1920) {
screenNode.style.left = 0;
screenNode.style.transform = `scale(1) translate(0, 0)`;
} else {
screenNode.style.left = "50%";
screenNode.style.transform = `scale(${getScale()}) translate(-50%, 0)`;
}
});
},
},
};
</script>
<style lang="scss">
.full-screen-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #131a2b;
#screen {
position: fixed;
width: 1920px;
height: 1080px;
top: 0;
transform-origin: left top;
color: #fff;
}
}
</style>
- mac设备上的屏幕分辨率,在适配的时候,可能不是那么完美,以短边缩放为准,所以宽度到达百分之百后,高度不会铺满
- 1432*896 13寸mac本
- 2560*1600
4k
屏幕
2. 使用第三方插件来实现数据大屏(mac设备会产生布局错落)
- 建议在全屏容器内使用百分比搭配flex进行布局,以便于在不同的分辨率下得到较为一致的展示效果。
- 使用前请注意将
body
的margin
设为0,否则会引起计算误差,全屏后不能完全充满屏幕。 - 使用方式
1. npm install @jiaminghi/data-view
2. yarn add @jiaminghi/data-view
// 在vue项目中的main.js入口文件,将自动注册所有组件为全局组件
import {fullScreenContainer} from '@jiaminghi/data-view'
Vue.use(fullScreenContainer)
<template>
<dv-full-screen-container>
要展示的数据大屏内容
这里建议高度使用百分比来布局,而且要考虑mac设备适配问题,防止百分比发生布局错乱
需要注意的点是,一个是宽度,一个是字体大小,不产生换行
</dv-full-screen-container>
</template>
<script>
import screenfull from "screenfull";
export default {
name: "cockpit",
mounted() {
if (screenfull && screenfull.enabled && !screenfull.isFullscreen) {
screenfull.request();
}
}
};
</script>
<style lang="scss">
#dv-full-screen-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #131a2b;
}
</style>
- 插件地址
3. 效果图
来源:juejin.cn/post/7372105071573663763
从编程语言的角度,JS 是不是一坨翔?一个来自社区的暴力观点
给前端以福利,给编程以复利。大家好,我是大家的林语冰。
00. 写在前面
毋庸置疑,JS 的历史包袱确实罄竹难书。用朱熹的话说,天不生 ES6,JS 万古如长夜。
就技术细节而言,ES6 之前,JS 保留了若干臭名昭著的反人类设计,包括但不限于:
typeof null
的抽象泄露==
的无理要求undefined
不是关键词- 其他雷区......
幸运的是,ES5 的“阉割模式”(strict mode)把一大坨 JS 的反人类设计都屏蔽了,后 ES6 时代的 JS 焕然一新。
所以,本期我们就从编程语言的宏观设计来思考,先不纠结 JS 极端情况的技术瑕疵,探讨一下 JS 作为地球上人气最高的编程语言,设计哲学上到底有何魅力?
免责声明:上述统计数据来源于 GitHub 社区,可能存在统计学偏差,仅供粉丝参考。
01. 标准化
编程语言人气排行榜屈居亚军的是 Python,那我们就用 JS 来打败 Python。
根据 MDN 电子书,JS 的核心语言是 ECMAScript,是一门由 ECMA TC39 委员会标准化的编程语言。并不是所有的编程语言都有标准化流程,JS 恰好是“天选之子”,后 ES6 的提案流程也相对稳定,虽然最近突然增加了 stage 2.7,但整体标准化无伤大雅。
值得一提的是,JS 的标准是向下兼容的,用户友好。JS 的大多数技术债务已经诉诸“阉割模式”禁用,而向下兼容则避免了近未来的 ESNext 出现主版本级别的破坏性更新。
举个栗子,ES2024 最新支持的数组分组提案,出于兼容性考虑,提案一度从 Array.prototype.group()
修改为 Object.groupBy()
。
可以看到,JS 的向下兼容设计正是为了防止和某些遗留代码库产生命名冲突,降低迁移成本。
换而言之,JS 不会像 Python 2 升级 Python 3 那样,让用户承担语言迭代伴生的兼容性税,学习成本和心智负担相对较小。
02. 动态类型
编程语言人气排行榜屈居季军的是 TS,那我们就用 JS 来打败 TS。
JS 和 TS 都是 ECMAScript 的超集,TS 则是 JS 的超集。简而言之,TS ≈ JS + 静态类型系统。
JS 和 TS 区别在于动态类型 vs 静态类型,不能说孰优孰劣,只能说各有千秋。我的个人心证是,静态类型对于工程化生态而言不可或缺,比如 IDE 或 Linting,但对于编程应用则不一定,因为没有静态类型,JS 也能开发 Web App。
可以看到,因为 TS 是 JS 的超集,所以虽然没有显式的类型注解,上述代码也可作为 TS 代码,只不过 TS 和 JS 类型检查的粒度和时机并不一致。
一个争论点在于,静态类型的编译时检查有利于服务大型项目,但其实在大型项目中,一般会诉诸单元测试保障代码质量和回归测试。严格而言,静态类型之于大型项目的充要条件并不成立。
事实上,剥离静态类型注解的源码,恰恰体现了编程的本质和逻辑,这也是后来的静态类型语言偏爱半自动化的智能类型系统,因为有的类型注解可能是画蛇添足,而诉诸智能的类型推论可以解放程序猿的生产力。
03. 面向对象
编程语言人气排行榜第四名是 Java,那我们就用 JS 来打败 Java。
作为被误解最深的语言,前 ES6 的 JS 有一个普遍的误区:JS 不是面向对象语言,原因在于 ES5 没有 class。
这种“思想钢印”哪里不科学呢?不科学的地方在于,面向对象编程是面向对象,而不是 面向类。换而言之,类不是面向对象编程的充要条件。
作为经典的面向对象语言,Java 不同于 C艹,不支持多继承。如果说多继承的 C艹 是“面向对象完备”的,那么能且仅能支持单继承的 Java,其面向对象也一定有不完备的地方,需要诉诸其他机制来弥补。
JS 的面向对象是不同于经典类式继承的原型机制,为什么无类、原型筑基的 JS 也能实现多继承的 C艹 的“面向对象完备”呢?搞懂这个问题,才能深入理解“对象”的本质。
可以看到,JS 虽然没有类,但也通过神秘机制具备面向对象的三大特征。如果说 JS 不懂类,那么经典面向对象语言可能不懂面向对象编程,只懂“面向类编程”。
JS 虽然全称 JavaScript,但其实和 Java 一龙一猪,原型筑基的 JS 没有类也问题不大。某种意义上,类可以视为 JS 实现面向对象的语法糖。很多人知道封装、继承和多态的面向对象特性,但不知道面向对象的定义和思想,所以才会把类和“面向对象完备”等同起来,认为 JS 不是面向对象的语言。
04. 异步编程
编程语言人气排行榜第五名是 C#,那我们就用 JS 来打败 C#。
一般认为,JS 是一门单线程语言,有且仅有一个主线程。JS 有一个基于事件循环的并发模型,这个模型与 C# 语言等模型一龙一猪。
在 JS 中,当一个函数执行时,只有在它执行完毕后,JS 才会去执行任何其他的代码。换而言之,函数执行不会被抢占,而是“运行至完成”(除了 ES6 的生成器函数)。这与 C 语言不同,在 C 语言中,如果函数在线程中运行,它可能在任何位置被终止,然后在另一个线程中运行其他代码。
单线程的优势在于,大部分情况下,JS 不需要考虑多线程某些让人头大的复杂处理。因为我只会 JS,所以无法详细说明多线程的痛点,比如竞态、死锁、线程间通信(消息、信道、队列)、Actor 模型等。
但 JS 的单线程确实启发了其他支持多线程的语言,比如阮一峰大大的博客提到,Python 的 asyncio 模块只存在一个线程,跟 JS 一样。
在异步编程方面,JS 的并发模型其实算是奇葩,因为主流的编程语言都支持多线程模型,所以学习资料可以跨语言互相借鉴,C# 关于多线程的文档就有 10 页,而单线程的 JS 就像非主流的孤勇者,很多异步的理论都用不上,所以使用起来较为简单。
05. 高潮总结
JS 自诞生以来就是一种混合范式的“多面手语言”,这是 JS 二十年来依然元气满满的根本原因。
举个栗子,你可能已经见识过“三位一体”的神奇函数了:
可以看到,在 ES6 之前,JS 中的函数其实身兼数职,正式由于 JS 是天生支持混合范式导致的。
前 JS 时代的先驱语言部分是单范式语言,比如纯粹的命令式过程语言 C 语言无法直接支持面向对象编程,而 C艹 11、Java 8 等经典面向对象语言则渐进支持函数式编程等。
后 JS 时代的现代编程语言大都直接拥抱混合范式设计,比如 TypeScript 和 Rust 等。作为混合范式语言,JS 是一种原型筑基、动态弱类型的脚本语言,同时支持面向对象编程、函数式编程、异步编程等编程范式或编程风格。
粉丝请注意,你可能偶尔会看到“JS 是一门解释型语言”的说法,其实后 ES6 时代的 JS 已经不再是纯粹的解释型语言了,也可能是 JIT(即时编译)语言,这取决于宿主环境中 JS 引擎或运行时的具体实现。
值得一提的是,混合范式语言的优势在于博采众家之长,有助于塑造攻城狮的开放式编程思维和心智模型;缺陷在于不同于单范式语言,混合范式语言可能支持了某种编程范式,但没有完全支持。(PS:编程范式完备性的判定边界是模糊的,所以偶尔也解读为风格或思维。)
因此,虽然 JS 不像 PHP 一样是地球上最好的语言,但 JS 作为地表人气最高的语言,背后是有深层原因的。
参考文献
- 阮一峰日志:ruanyifeng.com/blog/2019/1…
- MDN:developer.mozilla.org
- C# 异步编程:learn.microsoft.com/zh-cn/dotne…
来源:juejin.cn/post/7392787221097545780
你真的了解圣杯和双飞翼布局吗?
前言
圣杯和双飞翼布局作为面试常考的题目之一,相信大家肯定都会有着自己的一套知识储备,但是,你真的了解这两种经典布局吗?本文将介绍这两种经典布局产生的来龙去脉,以及实现这两种布局的一些方式,希望大家能够喜欢。
为什么需要圣杯和双飞翼布局
大家思考一个问题,这样一种布局,你该怎么处理呢?

常规
情况下,我们的布局思路应该这样写,从上到下,从左到右
:
<div>header</div>
<div>
<div>left</div>
<div>main</div>
<div>right</div>
</div>
<div>footer</div>
这样的三栏布局也没有什么问题,但是我们要知道一个网站的主要内容就是中间
的部分,比如像掘金:

那么对于用户来说,他们当然是希望最中间的部分首先加载出来的,能看到最重要的内容,但是因为浏览器加载dom的机制是按顺序
加载的,浏览器从HTML文档的开头开始,逐步解析
并构建文档对象模型,所以,我们想让main
首先加载出来的话,那就将它前置
,然后通过一些CSS的样式,将其继续展示出上面的三栏布局的样式:
<div>header</div>
<div>
<div>main</div>
<div>left</div>
<div>right</div>
</div>
<div>footer</div>
这就是所谓的圣杯
布局,最早是Matthew Levine 在2006年1月30日在In Search of the Holy Grail 这篇文章中提出来的;
那么完整
的效果
、实现代码
如下:
<style>
.header,
.footer {
width: 100%;
height: 200px;
background-color: pink;
}
.container {
padding: 0 100px 0 100px;
overflow: hidden;
}
.col {
position: relative;
float: left;
height: 400px;
}
.left {
background-color: green;
width: 100px;
margin-left: -100%;
left: -100px;
}
.main {
width: 100%;
background-color: blue;
}
.right {
width: 100px;
background-color: green;
margin-left: -100px;
right: -100px;
}
</style>
<div class="header">header</div>
<div class="container">
<div class="main col">main</div>
<div class="left col">left</div>
<div class="right col">right</div>
</div>
<div class="footer">footer</div>
如上述代码所示,,使用了相对定位
、浮动
和负值margin
,将left和right装到main的两侧,所以顾名:圣杯
;
但是呢,圣杯是有问题
的,在某些特殊的场景下,比如说,left和right盒子的宽度过宽
的情况下,圣杯就碎掉
了,比如将上述代码的left和right盒子的宽度改为以500px
为基准:
<style>
.header,
.footer {
width: 100%;
height: 200px;
background-color: pink;
}
.container {
padding: 0 500px 0 500px;
overflow: hidden;
}
.col {
position: relative;
float: left;
height: 400px;
}
.left {
background-color: green;
width: 500px;
margin-left: -100%;
left: -500px;
}
.main {
width: 100%;
background-color: blue;
}
.right {
width: 500px;
background-color: green;
margin-left: -500px;
right: -500px;
}
</style>
<div class="header">header</div>
<div class="container">
<div class="main col">main</div>
<div class="left col">left</div>
<div class="right col">right</div>
</div>
<div class="footer">footer</div>
正常情况下,布局还是依然正常
,只是两侧宽了
而已:
但是我们将整个窗口缩小,圣杯就碎掉了:
原因是因为 padding: 0 500px 0 500px;
,当整个窗口的最大宽度已经小于
左右两边的padding共1000px
,left和right就被挤下去了;
于是针对这种情况,淘宝UED的玉伯大大提出来了双飞翼布局
,效果和圣杯布局一样,只是他将其比作一只鸟,左翅膀、中间、右翅膀;
相比于圣杯布局,双飞翼布局在原有的main盒子再加了一层div:
<div>header</div>
<div>
<div><div>main</div></div>
<div>left</div>
<div>right</div>
</div>
<div>footer</div>
实际的效果
和代码
如下,哪怕再怎么缩,都不会被挤下去:
<style>
.header,
.footer {
width: 100%;
height: 200px;
background-color: pink;
}
.container {
padding: 0;
overflow: hidden;
}
.col {
float: left;
height: 400px;
}
.left {
background-color: green;
width: 500px;
margin-left: -100%;
}
.main {
width: 100%;
background-color: blue;
}
.main-in {
margin: 0 500px 0 500px;
}
.right {
width: 500px;
background-color: green;
margin-left: -500px;
}
</style>
<div class="header">header</div>
<div class="container">
<div class="main col">
<div class="main-in">main</div>
</div>
<div class="left col">left</div>
<div class="right col">right</div>
</div>
<div class="footer">footer</div>
圣杯布局实现方式补充
上面介绍了一种圣杯布局的实现方式,这里再介绍一种用绝对定位
的,这种方法其实也能避免
上述说的当左右两侧的盒子过于宽时,圣杯被挤破
的情况:
<style>
.header,
.footer {
width: 100%;
height: 200px;
background-color: pink;
}
.container {
position: relative;
padding: 0 100px;
}
.col {
height: 400px;
}
.left {
background-color: green;
width: 100px;
position: absolute;
left: 0;
top: 0;
}
.main {
width: 100%;
background-color: blue;
}
.right {
width: 100px;
background-color: green;
position: absolute;
right: 0;
top: 0;
}
</style>
<div class="header">header</div>
<div class="container">
<div class="main col">main</div>
<div class="left col">left</div>
<div class="right col">right</div>
</div>
<div class="footer">footer</div>
双飞翼布局实现方式补充
也是使用绝对定位
的:
<style>
.header,
.footer {
width: 100%;
height: 200px;
background-color: pink;
}
.container {
position: relative;
}
.col {
height: 400px;
}
.left {
background-color: green;
width: 500px;
position: absolute;
top: 0;
left: 0;
}
.main {
width: calc(100% - 1000px);
background-color: blue;
margin-left: 500px;
}
.main-in {
/* margin: 0 500px 0 500px; */
}
.right {
width: 500px;
background-color: green;
position: absolute;
top: 0;
right: 0;
}
</style>
<div class="header">header</div>
<div class="container">
<div class="main col">
<div class="main-in">main</div>
</div>
<div class="left col">left</div>
<div class="right col">right</div>
</div>
<div class="footer">footer</div>
其它普通的三列布局的实现
flex布局实现
<style>
* {
padding: 0;
margin: 0;
}
.header,
.footer {
width: 100%;
height: 200px;
background-color: blue;
}
.container {
display: flex;
}
.left {
width: 100px;
height: 300px;
background-color: pink;
}
.main {
flex: 1;
width: 100%;
height: 300px;
background-color: green;
}
.right {
width: 100px;
height: 300px;
background-color: pink;
}
</style>
<div class="header">header</div>
<div class="container">
<div class="left col">left</div>
<div class="main col">main</div>
<div class="right col">right</div>
</div>
<div class="footer">footer</div>
绝对定位实现
<style>
* {
padding: 0;
margin: 0;
}
.header,
.footer {
width: 100%;
height: 200px;
background-color: blue;
}
.container {
position: relative;
padding: 0 100px;
}
.left {
width: 100px;
height: 300px;
background-color: pink;
position: absolute;
top: 0;
left: 0;
}
.main {
width: 100%;
height: 300px;
background-color: green;
}
.right {
width: 100px;
height: 300px;
background-color: pink;
position: absolute;
top: 0;
right: 0;
}
</style>
<div class="header">header</div>
<div class="container">
<div class="left col">left</div>
<div class="main col">main</div>
<div class="right col">right</div>
</div>
<div class="footer">footer</div>
总结
现在真正了解到圣杯布局、双飞翼布局和普通三列布局的思想了吗?虽然它们三者最终的效果可能一样,但是实际的思路,优化也都不一样,希望能对你有所帮助!!!!感谢支持
来源:juejin.cn/post/7405467437564428299
前端中的“+”连接符,居然有鲜为人知的强大功能!
故事背景:"0"和"1"布尔判断
这几天开发,遇到一个问题:根据后端返回的isCritical判断页面是否展示【关键标签】
很难受的是,后端的isCritical的枚举值是字符串
”0“: 非关键
”1“ :关键
这意味着前端得自己转换一下,于是我写出了这样的代码
// html
<van-icon v-if="getCriticalStatus(it.isCritical)" color="#E68600"/>
// js
const getCriticalStatus = (critical: string) => {
if (critical.value === "1") return true;
return false;
}
我以为我这样写很简单了,没想到同事看到后,说我这样写麻烦了,于是给我改了一下代码
// html
<van-icon v-if="+it.isCritical" color="#E68600"/>
我大惊失色脱水缩合,这就行了?看来,我还是小看"+"运算符了!
"+"的常见使用场景
前端对"+"连字符一定不陌生,它的算术运算符功能和字符串连接功能,我们用脚趾头也能敲出来。
算术运算符
在 JavaScript 中,+
是最常见的算术运算符之一,可以用来执行加法运算。
let a = 5;
let b = 10;
let sum = a + b;
// sum的值为15
字符串连接符
+
还可以用来连接字符串。
let firstName = "石";
let lastName = "小石";
let fullName = firstName + " " + lastName; // fullName的值为"石小石"
如果是数字和字符连接,它会把数字转成字符
const a = 1
const b = "2"
const c = a + b; // c的值为字符串"12"
"+"的高级使用场景
除了上述的基本使用场景,其实它还有一些冷门但十分使用的高级使用场景。
URL编码中的空格
在 URL 编码中,+
字符可以表示空格,尤其是在查询字符串中。
http://shixiaoshi.com/search?query=hello+world
上面的代码中,hello+world
表示查询 hello world
,其中的 +
会被解码为一个空格。
但要注意的是,现代 URL 编码规范中推荐使用 %20
表示空格,而不是 +
。
一元正号运算符
+
的高级用法,再下觉得最牛逼的地方就是可以作为一元运算符使用!
+
作为一元运算符时,可以将一个值转换为数字(如果可能的话)。
let str = "123";
let num = +str;
// num的值为123,类型为number
这一用法在处理表单输入时特别有用,因为表单输入通常是字符串类型。
let inputValue = "42";
let numericValue = +inputValue; // 将字符串转换为数字42
那么回到文章开头的问题,我们看看下面的代码为什么可以生效
// html
<van-icon v-if="getCriticalStatus(it.isCritical)" color="#E68600"/>
// js
const getCriticalStatus = (critical: string) => {
if (critical.value === "1") return true;
return false;
}
// html 优化后的代码
<van-icon v-if="+it.isCritical" color="#E68600"/>
由于it.isCritical的值是字符"0"或"1",通过"+it.isCritical"转换后,其值是数字0或1,而恰好0可以当false使用,1可以当true使用!因此,上述代码可以生效!
JavaScript 中的类型转换规则会将某些值隐式转换为布尔值:
- 假值 :在转换为布尔值时被视为
false
的值,包括:false
、0
(数字零)、-0
(负零)、""
(空字符串)、null
、undefined
、NaN
(非数字)
- 真值 :除了上述假值外,所有其他值在转换为布尔值时都被视为
true
。
一元正号运算符的原理
通过上文,我们知道:当使用 +
操作符时,JavaScript 会尝试把目标值转换为数字,它遵循以下规则:。
转换规则
数字类型:
如果操作数是数字类型,一元正号运算符不会改变其值。
例如:+5
还是 5
。
// 数字类型
console.log(+5); // 5(数字)
字符串类型:
如果字符串能够被解析为有效的数字,则返回相应的数字。
如果字符串不能被解析为有效的数字(如含有非数字字符),则返回 NaN
(Not-a-Number)。
例如:+"123"
返回 123
,+"abc"
返回 NaN
。
// 字符串类型
console.log(+"42"); // 42
console.log(+"42abc"); // NaN
布尔类型:
true
会被转换为 1
。
false
会被转换为 0
。
// 布尔类型
console.log(+true); // 1
console.log(+false); // 0
null:
null
会被转换为 0
。
// null
console.log(+null); // 0
undefined:
undefined
会被转换为 NaN
。
// undefined
console.log(+undefined); // NaN
对象类型:
对象首先会通过内部的 ToPrimitive
方法被转换为一个原始值,然后再进行数字转换。通常通过调用对象的 valueOf
或 toString
方法来实现,优先调用 valueOf
。
// 对象类型
console.log(+{}); // NaN
console.log(+[]); // 0
console.log(+[10]); // 10
console.log(+["10", "20"]); // NaN
底层原理
不重要,简单说说:
在 JS引擎内部,执行一元正号运算符时,实际调用了 ToNumber
抽象操作,这个操作试图将任意类型的值转换为数字。ToNumber
操作依据 ECMAScript 规范中的规则,将不同类型的值转换为数字。
总结
一元正号运算符 +
是一个简便的方法,用于将非数字类型转换为数字。
如果你们后端返回字符串0和1,你需要转换成布尔值,使用"+"简直不要太爽
// isCritical 是字符串"0"或"1"
<van-icon v-if="+isCritical" color="#E68600"/>
或者处理表单输入时用
let inputValue = "42";
let value = +inputValue; // 将字符串转换为数字42
来源:juejin.cn/post/7402076531294863360
还在封装 xxxForm,xxxTable 残害你的同事?试试这个工具
之前写过一篇文章 我理想中的低代码开发工具的形态,已经吐槽了各种封装 xxxForm,xxxTable 的行为,这里就不啰嗦了。今天再来看看我的工具达到了什么程度。
多图预警。。。
以管理后台一个列表页为例
选择对应的模板
截图查询区域,使用 OCR 初始化查询表单的配置
截图表头,使用 OCR 初始化 table 的配置
使用 ChatGPT 翻译中文字段
生成代码
效果
目前我们没有写一行代码,就已经达到了如下的效果
下面是一部分生成的代码
import { reactive, ref } from 'vue'
import { IFetchTableListResult } from './api'
interface ITableListItem {
/**
* 决算单状态
*/
settlementStatus: string
/**
* 主合同编号
*/
mainContractNumber: string
/**
* 客户名称
*/
customerName: string
/**
* 客户手机号
*/
customerPhone: string
/**
* 房屋地址
*/
houseAddress: string
/**
* 工程管理
*/
projectManagement: string
/**
* 接口返回的数据,新增字段不需要改 ITableListItem 直接从这里取
*/
apiResult: IFetchTableListResult['result']['records'][0]
}
interface IFormData {
/**
* 决算单状态
*/
settlementStatus?: string
/**
* 主合同编号
*/
mainContractNumber?: string
/**
* 客户名称
*/
customerName?: string
/**
* 客户手机号
*/
customerPhone?: string
/**
* 工程管理
*/
projectManagement?: string
}
interface IOptionItem {
label: string
value: string
}
interface IOptions {
settlementStatus: IOptionItem[]
}
const defaultOptions: IOptions = {
settlementStatus: [],
}
export const defaultFormData: IFormData = {
settlementStatus: undefined,
mainContractNumber: undefined,
customerName: undefined,
customerPhone: undefined,
projectManagement: undefined,
}
export const useModel = () => {
const filterForm = reactive<IFormData>({ ...defaultFormData })
const options = reactive<IOptions>({ ...defaultOptions })
const tableList = ref<(ITableListItem & { _?: unknown })[]>([])
const pagination = reactive<{
page: number
pageSize: number
total: number
}>({
page: 1,
pageSize: 10,
total: 0,
})
const loading = reactive<{ list: boolean }>({
list: false,
})
return {
filterForm,
options,
tableList,
pagination,
loading,
}
}
export type Model = ReturnType<typeof useModel>
这就是用模板生成的好处,有规范,随时可以改,而封装 xxxForm,xxxTable 就是一个黑盒。
原理
下面大致说一下原理
首先是写好一个个模版,vscode 插件读取指定目录下模版显示到界面上
每个模版下可能包含如下内容:
选择模版后,进入动态表单配置界面
动态表单是读取 config/schema.json 里的内容进行动态渲染的,目前支持 amis、form-render、formily
配置表单是为了生成 JSON 数据,然后根据 JSON 数据生成代码。所以最终还是无法避免的使用私有的 DSL ,但是生成后的代码是没有私有 DSL 的痕迹的。生成代码本质是 JSON + EJS 模版引擎编译 src 目录下的 ejs 文件。
为了加快表单的配置,可以自定义脚本进行操作
这部分内容是读取 config/preview.json 内容进行显示的
选择对应的脚本方法后,插件会动态加载 script/index.js 脚本,并执行里面对应的方法
以 initColumnsFromImage 方法为例,这个方法是读取剪贴板里的图片,然后使用百度 OCR 解析出文本,再使用文本初始化表单
initColumnsFromImage: async (lowcodeContext) => {
context.lowcodeContext = lowcodeContext;
const res = await main.handleInitColumnsFromImage();
return res;
},
export async function handleInitColumnsFromImage() {
const { lowcodeContext } = context;
if (!lowcodeContext?.clipboardImage) {
window.showInformationMessage('剪贴板里没有截图');
return lowcodeContext?.model;
}
const ocrRes = await generalBasic({ image: lowcodeContext!.clipboardImage! });
env.clipboard.writeText(ocrRes.words_result.map((s) => s.words).join('\r\n'));
window.showInformationMessage('内容已经复制到剪贴板');
const columns = ocrRes.words_result.map((s) => ({
slot: false,
title: s.words,
dataIndex: s.words,
key: s.words,
}));
return { ...lowcodeContext.model, columns };
}
反正就是可以根据自己的需求定义各种各样的脚本。比如使用 ChatGPT 翻译 JSON 里的指定字段,可以看我的上一篇文章 TypeChat、JSONSchemaChat实战 - 让ChatGPT更听你的话
再比如要实现把中文翻译成英文,然后英文使用驼峰语法,这样就可以将中文转成英文代码变量,下面是实现的效果
选择对应的命令菜单后 vscode 插件会加载对应模版里的脚本,然后执行里面的 onSelect 方法。
main.ts 代码如下
import { env, window, Range } from 'vscode';
import { context } from './context';
export async function bootstrap() {
const clipboardText = await env.clipboard.readText();
const { selection, document } = window.activeTextEditor!;
const selectText = document.getText(selection).trim();
let content = await context.lowcodeContext!.createChatCompletion({
messages: [
{
role: 'system',
content: `你是一个翻译家,你的目标是把中文翻译成英文单词,请翻译时使用驼峰格式,小写字母开头,不要带翻译腔,而是要翻译得自然、流畅和地道,使用优美和高雅的表达方式。请翻译下面用户输入的内容`,
},
{
role: 'user',
content: selectText || clipboardText,
},
],
});
content = content.charAt(0).toLowerCase() + content.slice(1);
window.activeTextEditor?.edit((editBuilder) => {
if (window.activeTextEditor?.selection.isEmpty) {
editBuilder.insert(window.activeTextEditor.selection.start, content);
} else {
editBuilder.replace(
new Range(
window.activeTextEditor!.selection.start,
window.activeTextEditor!.selection.end,
),
content,
);
}
});
}
使用了 ChatGPT。
再来看看,之前生成管理后台 CURD 页面的时候,连 mock 也一起生成了,主要逻辑放在了 complete 方法里,这是插件的一个生命周期函数。
因为 mock 服务在另一个项目里,所以需要跨目录去生成代码,这里我在 mock 服务里加了个接口返回 mock 项目所在的目录
.get(`/mockProjectPath`, async (ctx, next) => {
ctx.body = {
status: 200,
msg: '',
result: __dirname,
};
})
生成代码的时候请求这个接口,就知道往哪个目录生成代码了
const mockProjectPathRes = await axios
.get('http://localhost:3001/mockProjectPath', { timeout: 1000 })
.catch(() => {
window.showInformationMessage(
'获取 mock 项目路径失败,跳过更新 mock 服务',
);
});
if (mockProjectPathRes?.data.result) {
const projectName = workspace.rootPath
?.replace(/\\/g, '/')
.split('/')
.pop();
const mockRouteFile = path.join(
mockProjectPathRes.data.result,
`${projectName}.js`,
);
let mockFileContent = `
import KoaRouter from 'koa-router';
import proxy from '../middleware/Proxy';
import { delay } from '../lib/util';
const Mock = require('mockjs');
const { Random } = Mock;
const router = new KoaRouter();
router{{mockScript}}
module.exports = router;
`;
if (fs.existsSync(mockRouteFile)) {
mockFileContent = fs.readFileSync(mockRouteFile).toString().toString();
const index = mockFileContent.lastIndexOf(')') + 1;
mockFileContent = `${mockFileContent.substring(
0,
index,
)}{{mockScript}}\n${mockFileContent.substring(index)}`;
}
mockFileContent = mockFileContent.replace(/{{mockScript}}/g, mockScript);
fs.writeFileSync(mockRouteFile, mockFileContent);
try {
execa.sync('node', [
path.join(
mockProjectPathRes.data.result
.replace(/\\/g, '/')
.replace('/src/routes', ''),
'/node_modules/eslint/bin/eslint.js',
),
mockRouteFile,
'--resolve-plugins-relative-to',
mockProjectPathRes.data.result
.replace(/\\/g, '/')
.replace('/src/routes', ''),
'--fix',
]);
} catch (err) {
console.log(err);
}
mock 项目也可以通过 vscode 插件快速创建和使用
上面展示的模版都放在了 github.com/lowcode-sca… 仓库里,照着 README 步骤做就可以使用了。
来源:juejin.cn/post/7315242945454735414
领导:你加的水印怎么还能被删掉的,扣工资!
故事是这样的
领导:小李,你加的水印怎么还能被删掉的?这可是关乎公司信息安全的大事!这种疏忽怎么能不扣工资呢?
小李:领导,请您听我解释一下!我确实按照常规的方法加了水印,可是……
领导:(打断)但是什么?难道这就是你对公司资料的保护吗?
小李:我也不明白,按理说水印是无法删除的,我会再仔细检查一下……
领导:我不能容忍这样的失误。这种安全隐患严重影响了我们的机密性。
小李焦虑地试图解释,但领导的目光如同刀剑一般锐利。他决定,这次一定要找到解决方法,否则,这将是一场职场危机……
水印组件
小李想到antd中有现成的水印组件,便去研究了一下。即使删掉了水印div,水印依然存在,因为瞬间又生成了一个相同的水印div。他一瞬间想到了解决方案,并开始了重构水印组件。
原始代码
//app.vue
<template>
<div>
<Watermark text="前端百事通">
<div class="content"></div>
</Watermark>
</div>
</template>
<script setup>
import Watermark from './components/Watermark.vue';
</script>
<style scoped>
.content{
width: 400px;
height: 400px;
background-color: aquamarine;
}
</style>
//watermark.vue
<template>
<div ref="watermarkRef" class="watermark-container">
<slot>
</slot>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
const watermarkRef=ref(null)
const props = defineProps({
text: {
type: String,
default: '前端百事通'
},
fontSize: {
type: Number,
default: 14
},
gap: {
type: Number,
default: 50
},
rotate: {
type: Number,
default: 45
}
})
onMounted(() => {
addWatermark()
})
const addWatermark = () => {
const { rotate, gap, text, fontSize } = props
const color = 'rgba(0, 0, 0, 0.3)'; // 可以从props中传入
const watermarkContainer = watermarkRef.value;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const font=fontSize+'px DejaVu Sans Mono'
// 设置水印文字的宽度和高度
const metrics = context.measureText(text);
const canvasWidth=metrics.width+gap
canvas.width=canvasWidth
canvas.height=canvasWidth
// 绘制水印文字
context.translate(canvas.width/2,canvas.height/2)
context.rotate((-1 * rotate * Math.PI / 180));
context.fillStyle = color;
context.font=font
context.textAlign='center'
context.textBaseline='middle'
context.fillText(text,0,0)
// 将canvas转为图片
const url = canvas.toDataURL('image/png');
// 创建水印元素并添加到容器中
const watermarkLayer = document.createElement('div');
watermarkLayer.style.position = 'absolute';
watermarkLayer.style.top = '0';
watermarkLayer.style.left = '0';
watermarkLayer.style.width = '100%';
watermarkLayer.style.height = '100%';
watermarkLayer.style.pointerEvents = 'none';
watermarkLayer.style.backgroundImage = `url(${url})`;
watermarkLayer.style.backgroundRepeat = 'repeat';
watermarkLayer.style.zIndex = '9999';
watermarkContainer.appendChild(watermarkLayer);
}
</script>
<style>
.watermark-container {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
}
</style>
防篡改思路
- 监听删除dom操作,在删除dom操作的瞬间重新生成一个相同的dom元素
- 监听修改dom样式操作
- 不能使用onMounted,改为watchEffect进行监听操作
使用MutationObserver监听整个区域
let ob
onMounted(() => {
ob=new MutationObserver((records)=>{
console.log(records)
})
ob.observe(watermarkRef.value,{
childList:true,
attributes:true,
subtree:true
})
})
onUnmounted(()=>{
ob.disconnect()
})
在删除水印div之后,打印一下看看records是什么。
在修改div样式之后,打印一下records
很明显,如果是删除,我们就关注removedNodes字段,如果是修改,我们就关注attributeName字段。
onMounted(() => {
ob=new MutationObserver((records)=>{
for(let item of records){
//监听删除
for(let ele of item.removedNodes){
if(ele===watermarkDiv){
generateFlag.value=!generateFlag.value
return
}
}
//监听修改
if(item.attributeName==='style'){
generateFlag.value=!generateFlag.value
return
}
}
})
ob.observe(watermarkRef.value,{
childList:true,
attributes:true,
subtree:true
})
})
watchEffect(() => {
//generateFlag的用处是让watchEffect收集这个依赖
//通过改变generateFlag的值来重新调用生成水印的函数
generateFlag.value
if(watermarkRef.value){
addWatermark()
}
})
最终,小李向领导展示了新的水印组件,取得了领导的认可和赞许,保住了工资。
全剧终。
文章同步发表于前端百事通公众号,欢迎关注!
来源:juejin.cn/post/7362309246556356647
终于搞懂类型声明文件.d.ts和declare了,原来用处如此大
项目中的.d.ts和declare
最近开发项目,发现公司代码里都有一些.d.ts后缀的文件
还有一些奇奇怪怪的declare代码
秉持着虚心学习的态度,我向同事请教了这些知识点,发现这些东西居然蛮重要的。于是,我根据自己的理解,把这些知识简单总结一下。
类型声明文件.d.ts
为什么需要 .d.ts
文件?
如果我们在ts项目中使用第三方库时,如果这个库内置类型声明文件.d.ts,我们在写代码时可以获得对应的代码补全、接口提示等功能。
比如,我们在index.ts中使用aixos时:
当我们引入axios时,ts会检索aixos的package.json文件,并通过其types属性查找类型声明文件,查找到index.d.ts这个文件后,就会根据其内部配置进行语法提示。
但是如果某个库没有内置类型声明文件时,我们使用这个库,不会获得Ts的语法提示,甚至会有类型报错警告
像这种没有内置类型声明文件的,我们就可以自己创建一个xx.d.ts的文件来自己声明,ts会自动读取到这个文件里面的内容的。比如,我们在index.ts中使用"vue-drag",会提示缺少声明文件。
由于这个库没有@types/xxxx声明包,因此,我们可以在项目内自定义一个vueDrag.d.ts声明文件。
// vueDrag.d.ts
declare module 'vue-drag'
这个时候,就不会报错了,没什么警告了。
第三方库的默认类型声明文件
当我们引入第三方库时,ts会自动检索aixos的package.json文件,并通过其types属性查找类型声明文件,查找到index.d.ts这个文件后,就会根据其内部配置进行语法提示。比如,我们刚才说的axios
- "typings"与"types"具有相同的意义,也可以使用它。
- 主声明文件名是index.d.ts并且位置在包的根目录里(与index.js并列),你就不需要使用"types"属性指定了。
第三方库的@types/xxxx类型声明文件
如express这类框架,它们的开发时Ts还没有流行,自然没有使用Ts进行开发,也自然不会有ts的类型声明文件。如果你想引入它们时也获得Ts的语法提示,就需要引入它们对应的声明文件npm包了。
使用声明文件包,不用重构原来的代码就可以在引入这些库时获得Ts的语法提示
比如,我们安装express对应的声明文件包后,就可以获得相应的语法提示了。
npm i --save-dev @types/express
@types/express包内的声明文件
.d.ts声明文件
通过上述的几个示例,我们可以知道.d.ts文件的作用和@types/xxxx包一致,@type/xxx需要下载使用,而.d.ts是我们自己创建在项目内的。
.d.ts文件除了可以声明模块,也可以用来声明变量。
例如,我们有一个简单的 JavaScript 函数,用于计算两个数字的总和:
// math.js
const sum = (a, b) => a + b
export { sum }
TypeScript 没有关于函数的任何信息,包括名称、参数类型。为了在 TypeScript 文件中使用该函数,我们在 d.ts 文件中提供其定义:
// math.d.ts
declare function sum(a: number, b: number): number
现在,我们可以在 TypeScript 中使用该函数,而不会出现任何编译错误。
.ts 是标准的 TypeScript 文件。其内容将被编译为 JavaScript。
*.d.ts 是允许在 TypeScript 中使用现有 JavaScript 代码的类型定义文件,其不会编译为 JavaScript。
shims-vue.d.ts
shims-vue.d.ts
文件的主要作用是声明 Vue 文件的模块类型,使得 TypeScript 能够正确地处理 .vue
文件,并且不再报错。通常这个文件会放在项目的根目录或 src
目录中。
shims-vue.d.ts
文件的内容一般长这样:
// shims-vue.d.ts
declare module '*.vue' {
import { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module '*.vue'
: 这行代码声明了一个模块,匹配所有以.vue
结尾的文件。*
是通配符,表示任意文件名。import { DefineComponent } from 'vue';
: 引入 Vue 的DefineComponent
类型。这是 Vue 3 中定义组件的类型,它具有良好的类型推断和检查功能。const component: DefineComponent<{}, {}, any>;
: 定义一个常量component
,它的类型是DefineComponent
,并且泛型参数设置为{}
表示没有 props 和 methods 的基本 Vue 组件类型。any
用来宽泛地表示组件的任意状态。export default component;
: 将这个组件类型默认导出。这样,当你在 TypeScript 文件中导入.vue
文件时,TypeScript 就知道导入的内容是一个 Vue 组件。
declare
.d.ts 文件中的顶级声明必须以 “declare” 或 “export” 修饰符开头。
通过declare声明的类型或者变量或者模块,在include包含的文件范围内,都可以直接引用而不用去import或者import type相应的变量或者类型。
- declare声明一个类型
declare type Asd {
name: string;
}
- declare声明一个模块
declare module '*.css';
declare module '*.less';
declare module '*.png';
.d.ts文件顶级声明declare最好不要跟export同级使用,不然在其他ts引用这个.d.ts的内容的时候,就需要手动import导入了
在.d.ts文件里如果顶级声明不用export的话,declare和直接写type、interface效果是一样的,在其他地方都可以直接引用
declare type Ass = {
a: string;
}
type Bss = {
b: string;
};
来源:juejin.cn/post/7402891257196691468
前端:金额高精度处理
Decimal 是什么
想必大家在用js 处理 数字的 加减乘除的时候,或许都有遇到过 精度不够 的问题,还有那些经典的面试题 0.2+0.1 !== 0.3,
至于原因,那就是 js 计算底层用的是 IEEE 754 ,精度上有限制,
那么Decimal.js 就是帮助我们解决 js中的精度失准的问题。
原理
- 它的原理就是将数字用字符串表示,字符串在计算机中可以说是无限的。
- 并使用基于字符串的算术运算,以避免浮点数运算中的精度丢失。它使用了一种叫做十进制浮点数算术(Decimal Floating Point Arithmetic)的算法来进行精确计算。
- 具体来说,decimal.js库将数字表示为一个字符串,其中包含整数部分、小数部分和一些其他的元数据。它提供了一系列的方法和运算符,用于执行精确的加减乘除、取模、幂运算等操作。
精度丢失用例
const a = 31181.82
const b = 50090.91
console.log(a+b) //81272.73000000001
Decimal 的引入 与 加减乘除
- 如何引入
npm install --save decimal.js // 安装
import Decimal from "decimal.js" // 具体文件中引入
- 加
let a = 1
let b = 6
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new(推荐带new)
let res = new Decimal(a).add(new Decimal(b))
let res = Decimal(a).add(Decimal(b))
- 减
let a = "4"
let b = "8"
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new
let res = new Decimal(a).sub(new Decimal(b))
let res = Decimal(a).sub(Decimal(b))
- 乘
let a = 1
let b = 6
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new
let res = new Decimal(a).mul(new Decimal(b))
let res = Decimal(a).mul(Decimal(b))
- 除
let a = 1
let b = 6
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new
let res = new Decimal(a).div(new Decimal(b))
let res = Decimal(a).div(Decimal(b))
注意
上面的结果是 一个 Decimal 对象,你可以转换成 Number 或则 String
let res = Decimal(a).div(Decimal(b)).toNumber() // 结果转换成 Number
let res = Decimal(a).div(Decimal(b)).toString() // 结果转换成 String
关于保存几位小数相关
//查看有几位小数 (注意不计算 小数点 最后 末尾 的 0)
y = new Decimal(987000.000)
y.sd() // '3' 有效位数
y.sd(true) // '6' 总共位数
// 保留 多少个位数 (小数位 会补0)
x = 45.6
x.toPrecision(5) // '45.600'
// 保留 多少位有效位数(小数位 不会补0,是计算的有效位数)
x = new Decimal(9876.5)
x.toSignificantDigits(6) // '9876.5' 不会补0 只是针对有效位数
// 保留几位小数 , 跟 js 中的 number 一样
toFixed
x = 3.456
// 向下取整
x.toFixed(2, Decimal.ROUND_DOWN) // '3.45' (舍入模式 向上0 向下1 四舍五入 4,7)
// 向上取整
Decimal.ROUND_UP
//四舍五入
ROUND_HALF_UP //(主要)
// 使用例子
let num2 = 0.2
let num3 = 0.1
let res = new Decimal(num2).add(new Decimal(num3)).toFixed(2, Decimal.ROUND_HALF_UP)
console.log(res); //返回值是字符串类型
超过 javascript 允许的数字
如果使用超过 javascript 允许的数字的值,建议传递字符串而不是数字,以避免潜在的精度损失。
new Decimal(1.0000000000000001); // '1'
new Decimal(88259496234518.57); // '88259496234518.56'
new Decimal(99999999999999999999); // '100000000000000000000'
new Decimal(2e308); // 'Infinity'
new Decimal(1e-324); // '0'
new Decimal(0.7 + 0.1); // '0.7999999999999999'
可读性
与 JavaScript 数字一样,字符串可以包含下划线作为分隔符以提高可读性。
x = new Decimal("2_147_483_647");
其它进制的数字
如果包含适当的前缀,则也接受二进制、十六进制或八进制表示法的字符串值。
x = new Decimal("0xff.f"); // '255.9375'
y = new Decimal("0b10101100"); // '172'
z = x.plus(y); // '427.9375'
z.toBinary(); // '0b110101011.1111'
z.toBinary(13); // '0b1.101010111111p+8'
x = new Decimal(
"0b1.1111111111111111111111111111111111111111111111111111p+1023"
);
// '1.7976931348623157081e+308'
最后:希望本篇文章能帮到您!
来源:juejin.cn/post/7405153695507234867
视差滚动效果实现
视差滚动是一种在网页设计和视频游戏中常见的视觉效果技术,它通过在不同速度上移动页面或屏幕上的多层图像,创造出深度感和动感。
这种效果通过前景、中景和背景以不同的速度移动来实现,使得近处的对象看起来移动得更快,而远处的对象移动得较慢。
在官网中适当的使用视差效果,可以增加视觉吸引力,提高用户的参与度,从而提升网站和品牌的形象。本文通过JavaScript、CSS多种方式并在React框架下进行了视差效果的实现,供你参考指正。
实现方式
1、background-attachment
通过配置该 CSS 属性值为fixed
可以达到背景图像的位置相对于视口固定,其他元素正常滚动的效果。但该方法的视觉表现单一,没有纵深,缺少动感。
.parallax-box {
width: 100%;
height: 100vh;
background-image: url("https://picsum.photos/800");
background-size: cover;
background-attachment: fixed;
display: flex;
justify-content: center;
align-items: center;
}
2、Transform 3D
在 CSS 中使用 3D 变换效果,通过将元素划分至不同的纵深层级,在滚动时相对视口不同距离的元素,滚动所产生的位移在视觉上就会呈现越近的元素滚动速度越快,相反越远的元素滚动速度就越慢。
为方便理解,你可以想象正开车行驶在公路上,汽车向前移动,你转头看向窗外,近处的树木一闪而过,远方的群山和风景慢慢的渐行渐远,逐渐的在视野中消失,而天边的太阳却只会在很长的一段距离细微的移动。
.parallax {
perspective: 1px; /* 设置透视效果,为3D变换创造深度感 */
overflow-x: hidden;
overflow-y: auto;
height: 100vh;
}
.parallax__group {
transform-style: preserve-3d; /* 保留子元素3D变换效果 */
position: relative;
height: 100vh;
}
.parallax__layer {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
}
/* 背景层样式,设置为最远的层 */
.parallax__layer--back {
transform: translateZ(-2px) scale(3);
z-index: 1;
}
/* 中间层样式,设置为中等距离的层 */
.parallax__layer--base {
transform: translateZ(-1px) scale(2);
z-index: 2;
}
/* 前景层样式,设置为最近的层 */
.parallax__layer--front {
transform: translateZ(0px);
z-index: 3;
}
实现原理
通过设置 perspective 属性,为整个容器创建一个 3D 空间。
使用 transform-style: preserve-3d 保持子元素的 3D 变换效果。
将内容分为多个层(背景、中间、前景),使用 translateZ() 将它们放置在 3D 空间的不同深度。
对于较远的层(如背景层),使用 scale() 进行放大,以补偿由于距离产生的视觉缩小效果。
当用户滚动页面时,由于各层位于不同的 Z 轴位置,它们会以不同的速度移动,从而产生视差效果。
3、ReactScrollParallax
想得到更炫酷的滚动视差效果,纯 CSS 的实现方式就会有些吃力。
如下是在 React 中实现示例,通过监听滚动事件,封装统一的视差组件,来达到多样的动画效果。
const Parallax = ({ children, effects = [], speed = 1, style = {} }) => {
// 状态hooks:用于存储动画效果的当前值
const [transform, setTransform] = useState("");
useEffect(() => {
if (!Array.isArray(effects) || effects.length === 0) {
console.warn("ParallaxElement: effects should be a non-empty array");
return;
}
const handleScroll = () => {
// 计算滚动进度
const scrollProgress =
(window.scrollY /
(document.documentElement.scrollHeight - window.innerHeight)) *
speed;
let transformString = "";
// 处理每个效果
effects.forEach((effect) => {
const { property, startValue, endValue, unit = "" } = effect;
const value =
startValue +
(endValue - startValue) * Math.min(Math.max(scrollProgress, 0), 1);
switch (property) {
case "translateX":
case "translateY":
transformString += `${property}(${value}${unit}) `;
break;
case "scale":
transformString += `scale(${value}) `;
break;
case "rotate":
transformString += `rotate(${value}${unit}) `;
break;
// 更多的动画效果...
default:
console.warn(`Unsupported effect property: ${property}`);
}
});
// 更新状态
setTransform(transformString);
};
window.addEventListener("scroll", handleScroll);
// 初始化位置
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [effects, speed]);
// 渲染带有计算样式的子元素
return <div style={{ ...style, transform }}>{children}</div>;
};
在此基础上你可以添加缓动函数
使动画效果更加平滑;以及使用requestAnimationFrame
获得更高的动画性能。
requestAnimationFrame 带来的性能提升
同步浏览器渲染周期:requestAnimationFrame 会在浏览器下一次重绘之前调用指定的回调函数。这确保了动画更新与浏览器的渲染周期同步,从而产生更流畑的动画效果。
提高性能:与使用 setInterval 或 setTimeout 相比,requestAnimationFrame 可以更高效地管理动画。它只在浏览器准备好进行下一次重绘时才会执行,避免了不必要的计算和重绘。
优化电池使用:在不可见的标签页或最小化的窗口中,requestAnimationFrame 会自动暂停,这可以节省 CPU 周期和电池寿命。
适应显示器刷新率:requestAnimationFrame 会自动适应显示器的刷新率。这意味着在 60Hz、120Hz 或其他刷新率的显示器上,动画都能保持流畑。
避免丢帧:由于与浏览器的渲染周期同步,使用 requestAnimationFrame 可以减少丢帧现象,特别是在高负荷情况下。
更精确的时间控制:requestAnimationFrame 提供了一个时间戳参数,允许更精确地控制动画的时间。
4、组件库方案
在当前成熟的前端生态中,想要获得精彩的视差动画效果,你可以通过现有的开源组件库来高效的完成开发。
以下是一些你可以尝试的主流组件库:
引用参考
How to create parallax scrolling with CSS
来源:juejin.cn/post/7406161967617163301
为什么vue:deep、/deep/、>>>样式能穿透到子组件
为什么vue:deep、/deep/、>>>样式能穿透到子组件
在scoped标记的style中,只要涉及三方组件,那deep符号会经常被使用,用来修改外部组件的样式。
小试牛刀
不使用deep
要想修改三方组件样式,只能添加到scoped之外,弊端是污染了全局样式,后续可能出现样式冲突。
<style lang="less">
.container {
.el-button {
background: #777;
}
}
使用 /deep/ deprecated
.container1 {
/deep/ .el-button {
background: #000;
}
}
使用 >>> deprecated
.container2 >>> .el-button {
background: #222;
}
当在vue3使用/deep/
或者>>>
、::v-deep
,console面板会打印警告信息:
the >>> and /deep/ combinators have been deprecated. Use :deep() instead.
由于/deep/
或者>>>
在less或者scss中存在兼容问题,所以不推荐使用了。
使用:deep
.container3 {
:deep(.el-button) {
background: #444;
}
}
那么问题来了,如果我按以下的方式嵌套deep,能生效吗?
.container4 {
:deep(.el-button) {
:deep(.el-icon) {
color: #f00;
}
}
}
源码解析
/deep/或>>>会被编译为什么
编译后的代码为:
.no-deep .container1[data-v-f5dea59b] .el-button { background: #000; }
源代码片段:
if (
n.type === 'combinator' &&
(n.value === '>>>' || n.value === '/deep/')
) {
n.value = ' '
n.spaces.before = n.spaces.after = ''
warn(
`the >>> and /deep/ combinators have been deprecated. ` +
`Use :deep() instead.`,
)
return false
}
当vue编译样式时,先将样式解析为AST对象,例如deep/ .el-button
会被解析为Selector对象,/deep/ .el-button
解析后生成的Selector包含的字段:
{ type: 'combinator', value: '/deep/' }
然后将n.value由/deep/
替换为空
。所以转换出来的结果,.el-button直接变为.container
下的子样式。
:deep会被编译为什么?
编译后的代码:
.no-deep .container3[data-v-f5dea59b] .el-button { background: #444; }
源代码片段:
// .foo :v-deep(.bar) -> .foo[xxxxxxx] .bar
let last: selectorParser.Selector['nodes'][0] = n
n.nodes[0].each(ss => {
selector.insertAfter(last, ss)
last = ss
})
// insert a space combinator before if it doesn't already have one
const prev = selector.at(selector.index(n) - 1)
if (!prev || !isSpaceCombinator(prev)) {
selector.insertAfter(
n,
selectorParser.combinator({
value: ' ',
}),
)
}
selector.removeChild(n)
还是以.container4 :deep(.el-button)
为例,当解析到:deep符号式,selector快照为
parent为.container4 :deep(.el-button)
,当前selector的type正好为伪类标识pseudo
,nodes节点包含一个.el-button
。
经过递归遍历,生成的selector结构为.container4 :deep(.el-button).el-button
,
最后一行代码selector.removeChild(n)
会将:deep(.el-button)
移出,所以输出的最终样式为.container4 .el-button
。
如果样式为:deep(.el-button) { :deep(.el-icon) { color: #f00 } }
,当遍历.el-icon时找不到ancestor,所以直接将:deep(.el-icon)
作为其icon时找不到ancestor,其结果为:
.no-deep .container4[data-v-f5dea59b] .el-button :deep(.el-icon) { color: #f00; }
因此,deep是不支持嵌套的。
结尾
插个广告,麻烦各位大佬为小弟开源项目标个⭐️,点点关注:
- react-native-mapa, react native地图组件库
- mapboxgl-syncto-any,三维地图双屏联动
来源:juejin.cn/post/7397285315822632997
8年前端总结和感想
8年前端总结和感想
本文是我前端工作 8 年的一些总结和感想
主要记录下个人点滴、前端知识点、场景应用、未来的憧憬以及个人规划,供自己以后查漏补缺,也欢迎同道朋友交流学习。
自我介绍
我是一名工作在非知名公司的 8 年前端,双非普通本科,自动化专业(非计算机)。目前也在努力的提升自己,积极找工作状态。虽然工作已经 8 年了,但没待过超 10 人的前端团队,更没有开发过千万级、亿级流量的应用的经历,也是一种遗憾。
16 年才工作那会儿使用原生 JS 和 JQ
比较多,经常写 CSS
动画,主要做企业建站,自学了 PHP
和 ReactNative
,还学过 krpano
的使用;17 年到 19 年,主要使用 react
、 React Native
做 H5 和 跨端开发,也维护过老 NG2.x
项目;19年到22年主要使用 vue、react 做hybrid APP
及小程序,自学了electron
、node
、MongoDB
;22 年至今主要从事 B 端的开发,C端也有部分,也主要是 react
和 vue
相关技术栈;
前端应用场景
前端是直面浏览器的,也可以说是直面用户的,我们的应用场景远广泛于后端,用到的 UI 组件库、插件、基础框架、基础语言也非常繁杂,所以在面试和自我学习提升的时候需要准备的东西非常多,有种学不动的感觉。
常见的应用场景就是 PC
浏览器,需要我们掌握一些兼容不同版本和不同浏览器的知识点,一般采取渐进增强或者优雅降级去处理;当然现在很多公司已经不做IE的兼容了,复杂度降低很多;同时大部分新项目都会使用 postcss
去 autoprefixer
,给 css 加兼容的前缀。其他的就是做后台的表单为主了,用的基本上都是 antd design
或 element ui
。当然复杂点的要涉及到网页编辑器、Low Code、No Code等。
另一个主要场景就是手机浏览器
和 APP
了,H5 WebAPP
会遇到 Android
、IOS
的一些样式和行为需要兼容、列表和图片懒加载问题,还有调用原生的 SDK 进行地图、OCR、拍照、扫一扫等功能进行开发;为了更好的体验,还会选择 RN、flutter 进行跨端开发;复杂的处理场景一般涉及原生端,例如聊天室、直播等。
另一个场景就是小程序,其实还是写H5,主要是使用微信或者支付宝等相关 SDK 去实现,看官网就行了,文档也比较全。
还有一些是做 H5 小游戏,要求数学逻辑思维和算法更好点,初级一点的用 canvas+css3
去做,好一点的用游戏引擎去做,一般是 egret
、 Laya
、 createjs
、 threejs
、 cocos
。
还有一些场景是做TV端的,有的是基于PC浏览器的,有些是套壳APP,一般使用 AntV
、 echarts
做图表数据展示,3D一般用 threejs
去做。
还有一些做桌面应用的,一般来说桌面应用大多基于 C,但一些简单应用前端可以使用 electron
去进行桌面端开发,一般也用于大屏可视化,做数据展示的,当然我们熟悉的 vscode
也基于 electron 开发。
还有一些是做 AR
、 VR
、 3D全景图
的,一般使用 WebGL3D引擎
:threejs
、 babylon.js
、 playcanvas
等,还可以用 css3d-engine
、 krpano
、 pano2vr
去做。
还有一些场景是做 web3
的 DAPP
(去中心化应用程序),大部分是做区块链和数字藏品的,推荐的技术是 Solidity
、 web3.js
、 Ethers.js
。
前端网络
我们前端不管是开发 PC、 H5、小程序、 HyBrid App 还是其他应用,始终离不开是浏览器和 web-view
,与浏览器交互就要了解基础的 HTTP、网络安全、 nginx
方面的知识。
浏览器
浏览器的发展简史和市场份额竞争有空可以自行了解,首先我们要了解计算机架构:
- 底层是机器硬件结构:简单的来说就是电脑主机+各种
IO
设备;复杂的来说有用于输入的鼠标键盘,用于输出的显示器、打印等设备,用于控制计算机的控制器和 CPU(核心大脑),用于存储的硬盘、内存,用于计算的CPU和GPU; - 中层是操作系统:常见的就是
Windows
、MAC OS
、Linux
、CentOS
,手机就是安卓和 IOS(当然还有华为的鸿蒙);可以了解内存分配、进程和线程管理等方面知识。 - 上层就是我们最熟悉的应用程序:有系统自带的核心应用程序、浏览器、各个公司和开发者开发的各种应用。
前端开发必要了解的就是chrome浏览器,可以说大部分开发基于此浏览器去做的。需要了解 进程和线程
概念、了解 chrome
多进程架构(浏览器主进程
、GPU进程
、网络进程
、渲染进程
、插件进程
)。
其中最主要的是要了解主进程,包含多个线程:GUI渲染线程
、JS引擎线程(V8引擎)
、定时触发器线程
、事件线程
、异步HTTP请求线程
。其中 V8
引擎又是核心,需要了解其现有架构:
了解 JS 编译成机器可以识别的机器码的过程:简单的说就是把 JS 通过 Lexer
词法分析器分解成一系列词法 tokens
,再通过 Parser 语法分析为语法树 AST
,再通过 Bytecode Generator
把语法树转成二进制代码 Bytecode
,二进制代码再通过实时编译 JST
编译成机器能识别的汇编代码 MachineCode
去执行。
代码的执行必然会占用大量的内存,那如何自动的把不需要使用的变量回收就叫作 GC 垃圾回收
,有空可以了解其算法和回收机制。
HTTP
对于Http,我们前端首先需要了解其网络协议分层: OSI七层协议
、 TCP/IP四层协议
和 五层协议
,这有助于我们了解应用层和传输层及网络层的工作流程;同时我们也要了解应用层的核心 http1.0
、 http1.1
、 http2.0
及 https
的区别;还要了解传输层的 TCP
、 UDP
、 webSocket
。
- 在前后端交互方面必须了解
GET
和POST
的请求方式,以及浏览器返回状态200
、3xx
、4xx
、5xx
的区别;还有前后端通信传输的request header
头、响应报文体response body
,通信的session
和cookie
。 - 网络安全方面需要了解
https
,了解非对称算法rsa
和对称算法des
,登录认证的JWT(JSON Web Token)
;同时也需要了解怎么防范XSS
、CSRF
、SQL注入
、URL跳转漏洞
、点击劫持
和OS命令注入攻击
。
Nginx
我们的网页都是存储在 web 服务器上的,公司一般都会进行 nginx
的配置,可以对资源进行 gzip
压缩,redirect
重定向,解决 CROS
跨域问题,配置 history
路由拦截。技术方面,我们还要了解其安装、常用命令、反向代理、正向代理和负载均衡。
前端三剑客
前端简单的说就是在写 html
、 css
和 js
的,一般来说 js 我们会更多关注,其实 html 和 css 也大有用处。
HTML
html 的历史可以自行了解,我们需要更关注 文档声明
、各种 标签元素
、 块级元素及非块级元素
、 语义化
、 src与href的区别
、 WebStorage
和 HTML5
的新特性。复杂的页面和功能会更依赖于我们的 canvas
。
css
css 方面主要了解布局相关 盒子模型
、 position
、 伪类和伪元素
、 css选择器优先级
、 各种 水平垂直居中
方法、 清除浮动
、 CSS3新特性
、 CSS动画
、 响应式布局
相关的 rem
、 flex
、 @media
。当然也有部分公司非常重视用户的交互体验和 UI 效果,那会更依赖我们 CSS3 的使用。
JS
js 在现代开发过程中确实是最重要的,我们更关心其底层原理、使用的方法、异步的处理及 ES6
的使用。
- 在底层方面我们需要了解其
作用域及作用域链
、闭包
、this绑定
、原型和原型链
、继承和类
、属性描述符defineProperty
和事件循环Event Loop
。
可以详看我写的javascript随笔
- 在使用方面我们需要了解
值和类型
的判断、内置类型的null
、undefined
、boolean
、number
、string
、object
和symbol
,其中对象类型是个复杂类型,数组
、函数
、Date
、RegExp
等都是一个对象;数组的各种 API 是我们开发中最常用的,了解Dom操作
的API也是必要的。 ES6
方面要了解let、const声明
、块作用域
、解构赋值
、箭头函数
、class
、promise
、async await
、Set
、WeakSet
、Map
、WeakMap
、proxy
和Reflect
。
可以详看我写的(ES6+)随笔
TypeScript
在前端的使用越来越广泛,如果要搞NodeJS
基本上是标配了,而且也是大厂的标配,还是有必要学习下的。要了解TypeScript
的安装配置、基本语法、Type
、泛型<T>
、Class
、Interface
、Enum
、命名空间
和模块
。
可以详看我写的typescript随笔
前端框架
我们在开发过程中直接操作 dom 已经不多了,有的公司可能还要部分维护 JQ,但大多都在使用 React
、 Vue
、 Angular
这三个基础前端框架,很多其他跨平台框架及 UI 组件库都基于此,目前来说国内 React 和 Vue 是绝对的主流,我本人就更擅长React。
React
开发 react,也就是在写 all in js
,或者说是 JSX
,那就必须了解其底层 JSX 是如何转化成虚拟节点 VDom
的。在转换 jsx 转换成 VDom,VDom在转换成真实 Dom,react 的底层做了很多优化,其中大家熟悉的就是 Fiber
、 diff
、 生命周期
以及 事件绑定
。
那我们写 react 都是在写组件化的东西, 组件通信
的各种方式也是需要了解的;还要了解 PureComponent
、 memo
、 forwardRef
等组件类的方法;了解 createElement
、 cloneElement
、 createContext
等工具类的方法;了解 useState
、 useEffect
、 useMemo
、 useCallback
、 useRef
等hooks的使用;还有了解 高阶组件HOC
及自定义 hooks
。
了解 react16
、 react17
、 react18
做了哪些优化。
Vue
vue 方面,我们需要了解 MVVM
原理、 template
的解析、数据的 双向绑定
、vue2 和 vue3 的响应式原理
、其数据更新的 diff
算法;使用方面要了解其生命周期
、组件通信
的各种方式和 vue3
的新特性。
前端工程化
上面写到了前端框架,在使用框架开发的过程中,我们必不可少的在整个开发过程向后端看齐,工程化的思想也深入前端。代码提交时可以使用git的钩子hooks进行流水线的自动化拉取,然后使用 webpack
、 rollup
、 gulp
以及 vite
进行代码编译打包,最后使用 jenkins
、 AWS
、 阿里云效
等平台进行自动化部署,完成整个不同环境的打包部署流程。
可以详看我写的webpack随笔 和 使用rollup搭建工具库并上传npm
webpack
在代码编译打包这块儿, webpack是最重要的,也是更复杂的,所以我们有必要多了解它。
在基础配置方面,我们要了解 mode
、 entry
、 output
、 loader
和 plugin
,其中 loader 和 plugin 是比较复杂的,webpack 默认只支持 js,那意味着要使用 es6 就要用 babel-loader
,css 方面要配置 css-loader
、 style-loader
、 less-loader
、 sass-loader
等,图片文件等资源还要配置 file-loader
;
plugin
方面要配置 antd
的相关配置、清空打包目录的 clean-webpack-plugin
、多线程打包的 HappyPack
、分包的 splitChunks
等等配置。
在不同环境配置方面要基于 cross-env
配置 devServer
和 sourcemap
。
在构建优化方面要配置按需加载
、 hash
、 cache
、 noParse
、 gzip压缩
、 tree-shaking
和 splitChunks
等。
幸运的是,现在很多脚手架都自动的帮你配置了很多,并且支持你选择什么模版去配置。
环境部署
环境部署方面,第一家公司我用的软件 FileZilla
进行手动上传 FTP
服务器,虽然也没出过啥错,但不智能,纯依靠人工,如果项目多,时间匆忙,很容易部署错环境,而且还要手动备份数据。后面学了点终端命令,使用 SSH
远程上传文件,其实还没有软件上传来的直接,也容易出错。后面换了公司,也用上了 CI/CD
持续集成,其本质就是平台帮你自动的执行配置好的命令,有 git
拉取代码的命令、npm run build
的打包命令,最后 SSH 远程存到服务器的目录文件,并重启 nginx
的 web 服务器。
CI/CD
可让持续自动化和持续监控贯穿于应用的整个生命周期(从集成和测试阶段,到交付和部署)。
后端服务
为了更好的完成整个应用,了解后端技术也是必要的,我们可以从 nodejs
、MongoDB
、MySQL
等入手。如果有余力,了解 java
、c#
、c++
也可以帮助我们更好的开发安卓和 IOS 应用。前后端都通了话,不管对于我们工作、面试、接活儿或者做独立开发者都是很必要的。
node
node 这方面,我们了解常用模块
和 Event Loop
是必要的,框架可以选择 express
、 koa
、 egg
,还有我最近刚学的NestJS
也非常不错。
形而上学
了解完上面的文章,基本上你就了解了整个前端大体的开发流程、所需的知识点、环境的部署、线上网络安全。但如果需要进阶且不局限于前端和后端,我们需要了解数据结构
、 设计模式
、 算法
和 英语
。
数据结构
常见的数据结构有8种: 数组
、 栈
、 队列
、 链表
、 树
、 散列表
、 堆
和 图
。
可以详看我写的算法随笔-数据结构(栈)
可以详看我写的算法随笔-数据结构(队列)
设计模式
设计模式方面我们需要了解:
- 六大原则:
单一职责原则
、开放封闭原则
、里氏替换原则
、依赖倒置原则
、接口隔离原则
和迪米特原则(最少知道原则)
- 创建型设计模式:
单例模式
、原型模式
、工厂模式
、抽象工厂模式
和建造者模式
- 结构型设计模式:
适配器模式
、装饰器模式
、代理模式
、外观模式
、桥接模式
、组合模式
和享元模式
- 行为型设计模式:
观察者模式
、迭代器模式
、策略模式
、模板方法模式
、职责链模式
、命令模式
、备忘录模式
、状态模式
、访问者模式
、中介者模式
和解释器模式
算法
算法方面我们需要了解:
- 基础概念:
时间复杂度
和空间复杂度
- 排序方法:初级排序的
选择排序
、插入排序
和冒泡排序
,高级排序的快速排序
、归并排序
、堆排序
- 搜索:
深度优先搜索
和广度优先搜索
- 其他:
递归
、分治
、回溯
、动态规划
和贪心算法
可以详看我写的算法随笔-基础知识
英语
学生时代,觉得英语离我们挺远,进社会就用不到了。现在发现学好英语非常有用,我们可以入职福利待遇比较好的外企、可以更好的看懂文档、甚至起个文件名和变量名都好的多。最近我也在用多邻国学英语,目标是能进行简单的商务交流和国外旅游,还能在未来辅导下孩子英语作业。
前端未来
目前,初级前端确实饱和了,各个公司对前端已经不像我入职第一家公司那样简单就可以找到工作的了,尤其是在这个各种卷的环境里,我们不得不多学习更多前端方面的知识。对于初学者,我建议更多的了解计算机基础、js原理、框架的底层;对于已经工作一俩年想提升的,不妨多学点跨端、跨平台技术,还有后端的一些技术;对于工作多年想让未来路子更宽的,不得不内卷的学习更多的应用场景所需要的知识。
关于AI,我觉得并不是会代替我们的工具,反而缩小了我们和资深前端的距离。我们可以借助AI翻译国外的一些技术文档,学习更新的技术;可以帮我们进行代码纠错;还可以帮助我们实现复杂的算法和逻辑;善用 AI
,让它成为我们的利器;
感想和个人规划
前端很复杂,并不是像很多后端所说的那么简单,处理的复杂度和应对多样的客户群都是比较大的挑战。资深的前端能很快的完成任务需求开发、并保证代码质量,并搭建更好的基础架构,但就业行情的不景气让我一度很迷茫,我们大龄程序员的出路在哪里,经验就不值钱了嘛?
对于未来,我会更多的学习英语、学习后端,向独立开发者转型。
谨以此文,献给未来的自己和同道中人!
来源:juejin.cn/post/7387420922809942035
同事一行代码,差点给我整破防了!
大家好,我是多喝热水。
最近开发公司项目的时候遇到一个哭笑不得的问题,知道真相的我差点破防!
还原现场
周一开周会的时候正常评审需求,在演示的过程中发生了一点小插曲,我们的聚合搜索功能它不能正常使用了,搜到的内容还是首次加载的数据,如下:
看到这种情况,我下意识的以为是后端返回的数据的问题,所以结束会议后我就着手排查了,如下:
结果发现后端的数据是没问题的,这我就很奇怪了,其他的 tab 都能正常展示数据,为什么就只有综合出现了问题?
开始排查
因为这个无限滚动组件是我封装的,所以我猜测会不会是这个组件出了什么问题?
但经过排查我发现,这个组件接收到的数据是没问题的。
那就很奇怪了,我传递的参数是正确的,后端返回的数据也是没问题的,凭什么你不能正常渲染?
直到我看到了这一行代码,我沉默了:
woc,你小子在代码里下毒!
看到这里我基本上可以确定就是这个 index 搞的鬼,在我尝试把它修改成 item.id 后,搜索功能就能正常使用了,如下:
问题复盘
为什么用 id 就正常了?
这里涉及到 React 底层 diff 算法的优化,有经验的小伙伴应该知道,React 源码中判断两个节点是否是同一个节点就是通过这个 key 属性来判断的,key 相同的话会直接复用旧的节点,如下:
这也就解释了为什么切换 tab 后列表中始终都是旧数据,因为我们使用了 index 作为 key,而 index 它是会重复的,新 index 和旧 index 对比,两者相等,React 就直接复用了旧的节点!
但 id 就不一样了,id 我们可以确保它就是唯一的,不会发生重复!
哎,排查问题半小时,解决问题只花 3 秒钟,我 tm.....
这个故事告诉我们:
一定不要在循环节点的时候使用 index 作为 key!
一定不要在循环节点的时候使用 index 作为 key!
一定不要在循环节点的时候使用 index 作为 key!
养成好习惯,特别是这种数据会动态变化的场景!!!
来源:juejin.cn/post/7391744516111564852
fabric.js 实现服装/商品定制预览效果
大家好,我是秦少卫,vue-fabric-editor 开源图片编辑项目的作者,很多开发者有问过我如何使用 fabric.js 实现商品定制的预览效果,今天跟大家分享一下实现思路。
预览图:
简单介绍大部分开发这类产品的开发者,都会提到一个关键词叫做 POD ,按需定制,会通过设计工具简单的对产品进行颜色、图片的修改后,直接下单,获得自己独一无二的商品。
POD是什么?
按需定制(Print On Demand,简称POD),是一种订单履约方式,卖家提前设计好商品模板上架到销售平台,出单后,同步订到给供应商进行生产发货。
使用 fabric.js 实现商品定制预览,有 4 种实现方式
方式一:镂空 PNG 素材
这种方式最简单方便,只需要准备镂空的png素材,将图层放置在顶部不可操作即可,定制的图案在图层底部,进行拖拽修改即可,优点是简单方便,缺点是只能针对一个部位操作。
方式二:png阴影 + 色块 + 图案叠加
如果要进一步实现多个部位的定制设计,不同部位使用不同的定制图,第一种方案就无法满足了,那么可以采用透明阴影 + 色块叠加图案的方式来实现多个位置的定制。
例如这样的商品,上下需要 2 张不同的定制图案。
我们需要准备透明的阴影素材在最上方,下方添加色块区域并叠加图案:
最底部放上原始的图片即可。
方式三:SVG + 图案/颜色填充
fabric.js 支持导入 svg图片,如果是SVG形式的设计文件,只需要导入到编辑器中,对不同区域修改颜色或者叠加图案就可以。
方式四:平面图 + 3D 贴图
最后一种是平面图设计后,将平面图贴图到 3D 模型,为了效果更逼真,需要增加光源、法线等贴图,从实现上并不会太复杂,只是运营成本比较高,每一个 SKU 都需要做一个 3D模型。
参考 Demo:
结束
以上就是fabric.js 实现服装/商品定制预览效果的 4 种思路,如果你正在开发类似产品,也可以使用开源项目快速构建你的在线商品定制工具。
来源:juejin.cn/post/7403245452215386150
前端如何做截图?
一、 背景
页面截图功能在前端开发中,特别是营销场景相关的需求中, 是比较常见的。比如截屏分享,相对于普通的链接分享,截屏分享具有更丰富的展示、更多的信息承载等优势。最近在需求开发中遇到了相关的功能,所以调研了相关的实现和原理。
二、相关技术
前端要实现页面截图的功能,现在比较常见的方式是使用开源的截图npm库,一般使用比较多的npm库有以下两个:
- dom-to-image: github.com/tsayen/dom-…
- html2canvas: github.com/niklasvh/ht…
以上两种常见的npm库,对应着两种常见的实现原理。实现前端截图,一般是使用图形API重新绘制页面生成图片,基本就是SVG(dom-to-image)和Canvas(html2canvas)两种实现方案,两种方案目标相同,即把DOM转为图片,下面我们来分别看看这两类方案。
三、 dom-to-image
dom-to-image库主要使用的是SVG实现方式,简单来说就是先把DOM转换为SVG然后再把SVG转换为图片。
(一)使用方式
首先,我们先来简单了解一下dom-to-image提供的核心api,有如下一些方法:
- toSvg (dom转svg)
- toPng (dom转png)
- toJpeg (dom转jpg)
- toBlob (dom转二进制格式)
- toPixelData (dom转原始像素值)
如需要生成一张png的页面截图,实现代码如下:
import domtoimage from "domtoimage"
const node = document.getElementById('node');
domtoimage.toPng(node,options).then((dataUrl) => {
const img = new Image();
img.src = dataUrl;
document.body.appendChild(img);
})
toPng方法可传入两个参数node和options。
node为要生成截图的dom节点;options为支持的属性配置,具体如下:filter,backgroundColor,width,height,style,quality,imagePlaceholder,cacheBust。
(二)原理分析
dom to image的源码代码不是很多,总共不到千行,下面就拿toPng方法做一下简单的源码解析,分析一下其实现原理,简单流程如下:
整体实现过程用到了几个函数:
- toPng(调用draw,实现canvas=>png )
- Draw(调用toSvg,实现dom=>canvas)
- toSvg(调用cloneNode和makeSvgDataUri,实现dom=>svg)
- cloneNode(克隆处理dom和css)
- makeSvgDataUri(实现dom=>svg data:url)
- toPng
toPng函数比较简单,通过调用draw方法获取转换后的canvas,利用toDataURL转化为图片并返回。
function toPng(node, options) {
return draw(node, options || {})
.then((canvas) => canvas.toDataURL());
}
- draw
draw函数首先调用toSvg方法获得dom转化后的svg,然后将获取的url形式的svg处理成图片,并新建canvas节点,然后借助drawImage()方法将生成的图片放在canvas画布上。
function draw(domNode, options) {
return toSvg(domNode, options)
// 拿到的svg是image data URL, 进一步创建svg图片
.then(util.makeImage)
.then(util.delay(100))
.then((image) => {
// 创建canvas,在画布上绘制图像并返回
const canvas = newCanvas(domNode);
canvas.getContext("2d").drawImage(image, 0, 0);
return canvas;
});
// 新建canvas节点,设置一些样式的options参数
function newCanvas(domNode) {
const canvas = document.createElement("canvas");
canvas.width = options.width || util.width(domNode);
canvas.height = options.height || util.height(domNode);
if (options.bgcolor) {
const ctx = canvas.getContext("2d");
ctx.fillStyle = options.bgcolor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas;
}
}
- toSvg
- toSvg函数实现从dom到svg的处理,大概步骤如下:
- 递归去克隆dom节点(调用cloneNode函数)
- 处理字体,获取所有样式,找到所有的@font-face和内联资源,解析并下载对应的资源,将资源转为dataUrl给src使用。把上面处理完的css rules放入中,并把标签加入到clone的节点中去。
- 处理图片,将img标签的src的url和css中backbround中的url,转为dataUrl使用。
- 获取dom节点转化的dataUrl数据(调用makeSvgDataUri函数)
function toSvg(node, options) {
options = options || {};
// 处理imagePlaceholder、cacheBust值
copyOptions(options);
return Promise.resolve(node)
.then((node) =>
// 递归克隆dom节点
cloneNode(node, options.filter, true))
// 把字体相关的csstext放入style
.then(embedFonts)
// clone处理图片,将图片链接转换为dataUrl
.then(inlineImages)
// 添加options里的style放入style
.then(applyOptions)
.then((clone) =>
// node节点转化成svg
makeSvgDataUri(clone,
options.width || util.width(node),
options.height || util.height(node)));
// 处理一些options的样式
function applyOptions(clone) {
...
return clone;
}
}
- cloneNode
cloneNode函数主要处理dom节点,内容比较多,简单总结实现如下:
- 递归clone原始的dom节点,其中, 其中如果有canvas将转为image对象。
- 处理节点的样式,通过getComputedStyle方法获取节点元素的所有CSS属性的值,并将这些样式属性插入新建的style标签上面, 同时要处理“:before,:after”这些伪元素的样式, 最后处理输入内容和svg。
function cloneNode(node, filter, root) {
if (!root && filter && !filter(node)) return Promise.resolve();
return Promise.resolve(node)
.then(makeNodeCopy)
.then((clone) => cloneChildren(node, clone, filter))
.then((clone) => processClone(node, clone));
function makeNodeCopy(node) {
// 将canvas转为image对象
if (node instanceof HTMLCanvasElement) return util.makeImage(node.toDataURL());
return node.cloneNode(false);
}
// 递归clone子节点
function cloneChildren(original, clone, filter) {
const children = original.childNodes;
if (children.length === 0) return Promise.resolve(clone);
return cloneChildrenInOrder(clone, util.asArray(children), filter)
.then(() => clone);
function cloneChildrenInOrder(parent, children, filter) {
let done = Promise.resolve();
children.forEach((child) => {
done = done
.then(() => cloneNode(child, filter))
.then((childClone) => {
if (childClone) parent.appendChild(childClone);
});
});
return done;
}
}
function processClone(original, clone) {
if (!(clone instanceof Element)) return clone;
return Promise.resolve()
.then(cloneStyle)
.then(clonePseudoElements)
.then(copyUserInput)
.then(fixSvg)
.then(() => clone);
// 克隆节点上的样式。
function cloneStyle() {
...
}
// 提取伪类样式,放到css
function clonePseudoElements() {
...
}
// 处理Input、TextArea标签
function copyUserInput() {
...
}
// 处理svg
function fixSvg() {
...
}
}
}
- makeSvgDataUri
首先,我们需要了解两个特性:
- SVG有一个元素,这个元素的作用是可以在其中使用具有其它XML命名空间的XML元素,换句话说借助标签,我们可以直接在SVG内部嵌入XHTML元素,举个例子:
<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="120" height="50">
<body xmlns="http://www.w3.org/1999/xhtml">
<p>文字。</p>
</body>
</foreignObject>
</svg>
可以看到标签里面有一个设置了xmlns=“http://www.w3.org/1999/xhtml”…标签,此时标签及其子标签都会按照XHTML标准渲染,实现了SVG和XHTML的混合使用。
- XMLSerializer对象能够把一个XML文档或Node对象转化或“序列化”为未解析的XML标记的一个字符串。
基于以上特性,我们再来看一下makeSvgDataUri函数,该方法实现node节点转化为svg,就用到刚刚提到的两个重要特性。
首先将dom节点通过
XMLSerializer().serializeToString() 序列化为字符串,然后在
标签 中嵌入转换好的字符串,foreignObject 能够在 svg
内部嵌入XHTML,再将svg处理为dataUrl数据返回,具体实现如下:
function makeSvgDataUri(node, width, height) {
return Promise.resolve(node)
.then((node) => {
// 将dom转换为字符串
node.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
return new XMLSerializer().serializeToString(node);
})
.then(util.escapeXhtml)
.then((xhtml) => `<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject>`)
// 转化为svg
.then((foreignObject) =>
// 不指定xmlns命名空间是不会渲染的
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${
foreignObject}</svg>`)
// 转化为data:url
.then((svg) => `data:image/svg+xml;charset=utf-8,${svg}`);
}
四、 html2canvas
html2canvas库主要使用的是Canvas实现方式,主要过程是手动将dom重新绘制成canvas,因此,它只能正确渲染可以理解的属性,有许多CSS属性无法正确渲染。
支持的CSS属性的完整列表:
html2canvas.hertzen.com/features/
浏览器兼容性:
Firefox 3.5+ Google Chrome Opera 12+ IE9+ Edge Safari 6+
官方文档地址:
html2canvas.hertzen.com/documentati…
(一)使用方式
// dom即是需要绘制的节点, option为一些可配置的选项
import html2canvas from 'html2canvas'
html2canvas(dom, option).then(canvas=>{
canvas.toDataURL()
})
常用的option配置:
全部配置文档:
html2canvas.hertzen.com/configurati…
(二)原理分析
html2canvas的内部实现相对dom-to-image来说要复杂一些, 基本原理是读取DOM元素的信息,基于这些信息去构建截图,并呈现在canvas画布中。
其中重点就在于将dom重新绘制成canvas的过程,该过程整体的思路是:遍历目标节点和目标节点的子节点,遍历过程中记录所有节点的结构、内容和样式,然后计算节点本身的层级关系,最后根据不同的优先级绘制到canvas画布中。
由于html2canvas的源码量比较大,可能无法像dom-to-image一样详细的分析,但还是可以大致了解一下整体的流程,首先可以看一下源码中src文件夹中的代码结构,如下图:
简单解析一下:
- index:入口文件,将dom节点渲染到一个canvas中,并返回。
- core:工具函数的封装,包括对缓存的处理函数、Context方法封装、日志模块等。
- css:对节点样式的处理,解析各种css属性和特性,进行处理。
- dom:遍历dom节点的方法,以及对各种类型dom的处理。
- render:基于clone的节点生成canvas的处理方法。
基于以上这些核心文件,我们来简单了解一下html2canvas的解析过程, 大致的流程如下:
- 构建配置项
在这一步会结合传入的options和一些defaultOptions,生成用于渲染的配置数据renderOptions。在过程中会对配置项进行分类,比如resourceOptions(资源跨域相关)、contextOptions(缓存、日志相关)、windowOptions(窗口宽高、滚动配置)、cloneOptions(对指定dom的配置)、renderOptions(render结果的相关配置,包括生成图片的各种属性)等,然后分别将各类配置项传到下接下来的步骤中。
- clone目标节点并获取样式和内容
在这一步中,会将目标节点到指定的dom解析方法中,这个过程会clone目标节点和其子节点,获取到节点的内容信息和样式信息,其中clone dom的解析方法也是比较复杂的,这里不做详细展开。获取到目标节点后,需要把克隆出来的目标节点的dom装载到一个iframe里,进行一次渲染,然后就可以获取到经过浏览器视图真实呈现的节点样式。
- 解析目标节点
目标节点的样式和内容都获取到了之后,就需要把它所承载的数据信息转化为Canvas可以使用的数据类型。在对目标节点的解析方法中,递归整个DOM树,并取得每一层节点的数据,对于每一个节点而言需要绘制的部分包括边框、背景、阴影、内容,而对于内容就包含图片、文字、视频等。在整个解析过程中,对目标节点的所有属性进行解析构造,转化成为指定的数据格式,基础数据格式可见以下代码:
class ElementContainer {
// 所有节点上的样式经过转换计算之后的信息
readonly styles: CSSParsedDeclaration;
// 节点的文本节点信息, 包括文本内容和其他属性
readonly textNodes: TextContainer[] = [];
// 当前节点的子节点
readonly elements: ElementContainer[] = [];
// 当前节点的位置信息(宽/高、横/纵坐标)
bounds: Bounds;
flags = 0;
...
}
具体到不同类型的元素如图片、IFrame、SVG、input等还会extends ElementContainer拥有自己的特定数据结构,在此不详细贴出。
- 构建内部渲染器
把目标节点处理成特定的数据结构之后,就需要结合Canvas调用渲染方法了,Canvas绘图需要根据样式计算哪些元素应该绘制在上层,哪些在下层,那么这个规则是什么样的呢?这里就涉及到CSS布局相关的一些知识。
默认情况下,CSS是流式布局的,元素与元素之间不会重叠。不过有些情况下,这种流式布局会被打破,比如使用了浮动(float)和定位(position)。因此需要需要识别出哪些脱离了正常文档流的元素,并记住它们的层叠信息,以便正确地渲染它们。
那些脱离正常文档流的元素会形成一个层叠上下文。元素在浏览器中渲染时,根据W3C的标准,所有的节点层级布局,需要遵循层叠上下文和层叠顺序的规则,具体规则如下:
在了解了元素的渲染需要遵循这个标准后,Canvas绘制节点的时候,需要生成指定的层叠数据,就需要先计算出整个目标节点里子节点渲染时所展现的不同层级,构造出所有节点对应的层叠上下文在内部所表现出来的数据结构,具体数据结构如下:
// 当前元素
element: ElementPaint;
// z-index为负, 形成层叠上下文
negativeZIndex: StackingContext[];
// z-index为0、auto、transform或opacity, 形成层叠上下文
zeroOrAutoZIndexOrTransformedOrOpacity: StackingContext[];
// 定位和z-index形成的层叠上下文
positiveZIndex: StackingContext[];
// 没有定位和float形成的层叠上下文
nonPositionedFloats: StackingContext[];
// 没有定位和内联形成的层叠上下文
nonPositionedInlineLevel: StackingContext[];
// 内联节点
inlineLevel: ElementPaint[];
// 不是内联的节点
nonInlineLevel: ElementPaint[];
基于以上数据结构,将元素子节点分类,添加到指定的数组中,解析层叠信息的方式和解析节点信息的方式类似,都是递归整棵树,收集树的每一层的信息,形成一颗包含层叠信息的层叠树。
- 绘制数据
基于上面两步构造出的数据,就可以开始调用内部的绘制方法,进行数据处理和绘制了。使用节点的层叠数据,依据浏览器渲染层叠数据的规则,将DOM元素一层一层渲染到canvas中,其中核心具体源码如下:
async renderStackContent(stack: StackingContext): Promise<void> {
if (contains(stack.element.container.flags, FLAGS.DEBUG_RENDER)) {
debugger;
}
// 1. the background and borders of the element forming the stacking context.
await this.renderNodeBackgroundAndBorders(stack.element);
// 2. the child stacking contexts with negative stack levels (most negative first).
for (const child of stack.negativeZIndex) {
await this.renderStack(child);
}
// 3. For all its in-flow, non-positioned, block-level descendants in tree order:
await this.renderNodeContent(stack.element);
for (const child of stack.nonInlineLevel) {
await this.renderNode(child);
}
// 4. All non-positioned floating descendants, in tree order. For each one of these,
// treat the element as if it created a new stacking context, but any positioned descendants and descendants
// which actually create a new stacking context should be considered part of the parent stacking context,
// not this new one.
for (const child of stack.nonPositionedFloats) {
await this.renderStack(child);
}
// 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
for (const child of stack.nonPositionedInlineLevel) {
await this.renderStack(child);
}
for (const child of stack.inlineLevel) {
await this.renderNode(child);
}
// 6. All positioned, opacity or transform descendants, in tree order that fall int0 the following categories:
// All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
// For those with 'z-index: auto', treat the element as if it created a new stacking context,
// but any positioned descendants and descendants which actually create a new stacking context should be
// considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
// treat the stacking context generated atomically.
//
// All opacity descendants with opacity less than 1
//
// All transform descendants with transform other than none
for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
await this.renderStack(child);
}
// 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
// order (smallest first) then tree order.
for (const child of stack.positiveZIndex) {
await this.renderStack(child);
}
}
在renderStackContent方法中,首先对元素本身调用renderNodeContent和renderNodeBackgroundAndBorders进行渲染处理。
然后处理各个分类的子元素,如果子元素形成了层叠上下文,就调用renderStack方法,这个方法内部继续调用了renderStackContent,这就形成了对于层叠上下文整个树的递归。
如果子元素是正常元素没有形成层叠上下文,就直接调用renderNode,renderNode包括两部分内容,渲染节点内容和渲染节点边框背景色。
async renderNode(paint: ElementPaint): Promise<void> {
if (paint.container.styles.isVisible()) {
// 渲染节点的边框和背景色
await this.renderNodeBackgroundAndBorders(paint);
// 渲染节点内容
await this.renderNodeContent(paint);
}
}
其中renderNodeContent方法是渲染一个元素节点里面的内容,其可能是正常元素、文字、图片、SVG、Canvas、input、iframe,对于不同的内容也会有不同的处理。
以上过程,就是html2canvas的整体内部流程,在了解了大致原理之后,我们再来看一个更为详细的源码流程图,对上述流程进行一个简单的总结。
五、 常见问题总结
在使用html2canvas的过程中,会有一些常见的问题和坑,总结如下:
(一)截图不全
要解决这个问题,只需要在截图之前将页面滚动到顶部即可:
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
(二)图片跨域
插件在请求图片的时候会有图片跨域的情况,这是因为,如果使用跨域的资源画到canvas中,并且资源没有使用CORS去请求,canvas会被认为是被污染了,canvas可以正常展示,但是没办法使用toDataURL()或者toBlob()导出数据,详情可参考:developer.mozilla.org/en-US/docs/…
解决方案:在img标签上设置crossorigin,属性值为anonymous,可以开启CROS请求。当然,这种方式的前提还是服务端的响应头Access-Control-Allow-Origin已经被设置过允许跨域。如果图片本身服务端不支持跨域,可以使用canvas统一转成base64格式,方法如下。
function getUrlBase64_pro( len,url ) {
//图片转成base64
var canvas = document.createElement("canvas"); //创建canvas DOM元素
var ctx = canvas.getContext("2d");
return new Promise((reslove, reject) => {
var img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function() {
canvas.height = len;
canvas.width = len;
ctx.drawImage(img, 0, 0, len, len);
var dataURL = canvas.toDataURL("image/");
canvas = null;
reslove(dataURL);
};
img.onerror = function(err){
reject(err)
}
img.src = url;
});
}
(三)截图与当前页面有区别
方式一:如果要从渲染中排除某些elements,可以向这些元素添加data-html2canvas-ignore属性,html2cnavas会将它们从渲染中排除,例如,如果不想截图iframe的部分,可以如下:
html2canvas(ele,{
useCORS: true,
ignoreElements: (element: any) => {
if (element.tagName.toLowerCase() === 'iframe') {
return element;
}
return false;
},
})
方式二:可以将需要转化成图片的部分放在一个节点内,再把整个节点,透明度设置为0, 其他部分层级设置高一些,即可实现截图指定区域。
六、 小结
本文针对前端截图实现的方式,对两个开源库dom-to-image和html2canvas的使用和原理进行了简单的使用方式、实现原理方面,进行介绍和分析。
参考资料:
1.dom-to-image原理
2.html2image原理简述
3.浏览器端网页截图方案详解
4.html2canvas
5.html2canvas实现浏览器截图的原理(包含源码分析的通用方法)
来源:juejin.cn/post/7400319811358818340
折腾我2周的分页打印和下载pdf
1.背景
一开始接到任务需要打印html,之前用到了vue-print-nb-jeecg来处理Vue2一个打印的问题,现在是遇到需求要在Vue3项目里面去打印十几页的打印和下载为pdf,难点和坑就在于我用的库vue3-print-nb来做分页打印预览,下载pdf后面介绍
2.预览打印实现
<div id="printMe" style="background:red;">
<p>葫芦娃,葫芦娃</p>
<p>一根藤上七朵花 </p>
<p>小小树藤是我家 啦啦啦啦 </p>
<p>叮当当咚咚当当 浇不大</p>
<p> 叮当当咚咚当当 是我家</p>
<p> 啦啦啦啦</p>
<p>...</p>
</div>
<button v-print="'#printMe'">Print local range</button>
因为官方提供的方案都是DOM加载完成后然后直接打印,但是我的需求是需要点击打印的时候根据id渲染不同的组件然后渲染DOM,后面仔细看官方文档,有个beforeOpenCallback方法在打印预览之前有个钩子,但是这个钩子没办法确定我接口加载完毕,所以我的思路就是用户先点击我写的点击按钮事件,等异步渲染完毕之后,我再同步触发真正的打印预览按钮,这样就变相解决了我的需求。
坑
- 没办法处理接口异步渲染数据展示DOM进行打印操作
- 在布局相对定位的时候在谷歌浏览器会发现有布局整体变小的问题(后续用zoom处理的)
3.掉头发之下载pdf
下载pdf这种需求才是我每次去理发店不敢让tony把我头发打薄的原因,我看了很多技术文章,结合个人业务情况,采取的方案是html2canvas把html转成canvas然后转成图片然后通过jsPDF截取图片分页最后下载到本地。本人秉承着不生产水,只做大自然的搬运工的匠人精神,迅速而又果断的从社区来到社区去,然后找到了适配当前业务的逻辑代码(实践出真知)。
import html2canvas from 'html2canvas'
import jsPDF, { RGBAData } from 'jspdf'
/** a4纸的尺寸[595.28,841.89], 单位毫米 */
const [PAGE_WIDTH, PAGE_HEIGHT] = [595.28, 841.89]
const PAPER_CONFIG = {
/** 竖向 */
portrait: {
height: PAGE_HEIGHT,
width: PAGE_WIDTH,
contentWidth: 560
},
/** 横向 */
landscape: {
height: PAGE_WIDTH,
width: PAGE_HEIGHT,
contentWidth: 800
}
}
// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element: HTMLElement, width: number) {
if (!element) return { width, height: 0 }
// canvas元素
const canvas = await html2canvas(element, {
// allowTaint: true, // 允许渲染跨域图片
scale: window.devicePixelRatio * 2, // 增加清晰度
useCORS: true // 允许跨域
})
// 获取canvas转化后的宽高
const { width: canvasWidth, height: canvasHeight } = canvas
// html页面生成的canvas在pdf中的高度
const height = (width / canvasWidth) * canvasHeight
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0)
return { width, height, data: canvasData }
}
/**
* 生成pdf(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
* @param param0
* @returns
*/
export async function outputPDF({
/** pdf内容的dom元素 */
element,
/** 页脚dom元素 */
footer,
/** 页眉dom元素 */
header,
/** pdf文件名 */
filename,
/** a4值的方向: portrait or landscape */
orientation = 'portrait' as 'portrait' | 'landscape'
}) {
if (!(element instanceof HTMLElement)) {
return
}
if (!['portrait', 'landscape'].includes(orientation)) {
return Promise.reject(
new Error(`Invalid Parameters: the parameter {orientation} is assigned wrong value, you can only assign it with {portrait} or {landscape}`)
)
}
const [A4_WIDTH, A4_HEIGHT] = [PAPER_CONFIG[orientation].width, PAPER_CONFIG[orientation].height]
/** 一页pdf的内容宽度, 左右预设留白 */
const { contentWidth } = PAPER_CONFIG[orientation]
// eslint-disable-next-line new-cap
const pdf = new jsPDF({
unit: 'pt',
format: 'a4',
orientation
})
// 一页的高度, 转换宽度为一页元素的宽度
const { width, height, data } = await toCanvas(element, contentWidth)
// 添加
function addImage(
_x: number,
_y: number,
pdfInstance: jsPDF,
base_data: string | HTMLImageElement | HTMLCanvasElement | Uint8Array | RGBAData,
_width: number,
_height: number
) {
pdfInstance.addImage(base_data, 'JPEG', _x, _y, _width, _height)
}
// 增加空白遮挡
function addBlank(x: number, y: number, _width: number, _height: number) {
pdf.setFillColor(255, 255, 255)
pdf.rect(x, y, Math.ceil(_width), Math.ceil(_height), 'F')
}
// 页脚元素 经过转换后在PDF页面的高度
const { height: tFooterHeight, data: headerData } = footer ? await toCanvas(footer, contentWidth) : { height: 0, data: undefined }
// 页眉元素 经过转换后在PDF的高度
const { height: tHeaderHeight, data: footerData } = header ? await toCanvas(header, contentWidth) : { height: 0, data: undefined }
// 添加页脚
async function addHeader(headerElement: HTMLElement) {
headerData && pdf.addImage(headerData, 'JPEG', 0, 0, contentWidth, tHeaderHeight)
}
// 添加页眉
async function addFooter(pageNum: number, now: number, footerElement: HTMLElement) {
if (footerData) {
pdf.addImage(footerData, 'JPEG', 0, A4_HEIGHT - tFooterHeight, contentWidth, tFooterHeight)
}
}
// 距离PDF左边的距离,/ 2 表示居中
const baseX = (A4_WIDTH - contentWidth) / 2 // 预留空间给左边
// 距离PDF 页眉和页脚的间距, 留白留空
const baseY = 15
// 除去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
const originalPageHeight = A4_HEIGHT - tFooterHeight - tHeaderHeight - 2 * baseY
// 元素在网页页面的宽度
const elementWidth = element.offsetWidth
// PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度 转化为 距离Canvas顶部的高度
const rate = contentWidth / elementWidth
// 每一页的分页坐标, PDF高度, 初始值为根元素距离顶部的距离
const pages = [rate * getElementTop(element)]
// 获取该元素到页面顶部的高度(注意滑动scroll会影响高度)
function getElementTop(contentElement) {
if (contentElement.getBoundingClientRect) {
const rect = contentElement.getBoundingClientRect() || {}
const topDistance = rect.top
return topDistance
}
}
// 遍历正常的元素节点
function traversingNodes(nodes) {
for (const element of nodes) {
const one = element
/** */
/** 注意: 可以根据业务需求,判断其他场景的分页,本代码只判断表格的分页场景 */
/** */
// table的每一行元素也是深度终点
const isTableRow = one.classList && one.classList.contains('ant4-table-row')
// 对需要处理分页的元素,计算是否跨界,若跨界,则直接将顶部位置作为分页位置,进行分页,且子元素不需要再进行判断
const { offsetHeight } = one
// 计算出最终高度
const offsetTop = getElementTop(one)
// dom转换后距离顶部的高度
// 转换成canvas高度
const top = rate * offsetTop
const rateOffsetHeight = rate * offsetHeight
// 对于深度终点元素进行处理
if (isTableRow) {
// dom高度转换成生成pdf的实际高度
// 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
updateTablePos(rateOffsetHeight, top)
}
// 对于普通元素,则判断是否高度超过分页值,并且深入
else {
// 执行位置更新操作
updateNormalElPos(top)
// 遍历子节点
traversingNodes(one.childNodes)
}
updatePos()
}
}
// 普通元素更新位置的方法
// 普通元素只需要考虑到是否到达了分页点,即当前距离顶部高度 - 上一个分页点的高度 大于 正常一页的高度,则需要载入分页点
function updateNormalElPos(top) {
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight)
}
}
// 可能跨页元素位置更新的方法
// 需要考虑分页元素,则需要考虑两种情况
// 1. 普通达顶情况,如上
// 2. 当前距离顶部高度加上元素自身高度 大于 整页高度,则需要载入一个分页点
function updateTablePos(eHeight: number, top: number) {
// 如果高度已经超过当前页,则证明可以分页了
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight)
}
// 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页,将当前高度作为分页位置
else if (
top + eHeight - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight &&
top !== (pages.length > 0 ? pages[pages.length - 1] : 0)
) {
pages.push(top)
}
}
// 深度遍历节点的方法
traversingNodes(element.childNodes)
function updatePos() {
while (pages[pages.length - 1] + originalPageHeight < height) {
pages.push(pages[pages.length - 1] + originalPageHeight)
}
}
// 对pages进行一个值的修正,因为pages生成是根据根元素来的,根元素并不是我们实际要打印的元素,而是element,
// 所��要把它修正,让其值是以真实的打印元素顶部节点为准
const newPages = pages.map(item => item - pages[0])
// 根据分页位置 开始分页
for (let i = 0; i < newPages.length; ++i) {
// 根据分页位置新增图片
addImage(baseX, baseY + tHeaderHeight - newPages[i], pdf, data!, width, height)
// 将 内容 与 页眉之间留空留白的部分进行遮白处理
addBlank(0, tHeaderHeight, A4_WIDTH, baseY)
// 将 内容 与 页脚之间留空留白的部分进行遮白处理
addBlank(0, A4_HEIGHT - baseY - tFooterHeight, A4_WIDTH, baseY)
// 对于除最后一页外,对 内容 的多余部分进行遮白处理
if (i < newPages.length - 1) {
// 获取当前页面需要的内容部分高度
const imageHeight = newPages[i + 1] - newPages[i]
// 对多余的内容部分进行遮白
addBlank(0, baseY + imageHeight + tHeaderHeight, A4_WIDTH, A4_HEIGHT - imageHeight)
}
// 添加页眉
if (header) {
await addHeader(header)
}
// 添加页脚
if (footer) {
await addFooter(newPages.length, i + 1, footer)
}
// 若不是最后一页,则分页
if (i !== newPages.length - 1) {
// 增加分页
pdf.addPage()
}
}
return pdf.save(filename)
}
4.分页的小姿势
如果有需求把打印预览的时候的页眉页脚默认取消不展示,然后自定义页面的边距可以这么设置样式
@page {
size: auto A4 landscape;
margin: 3mm;
}
@media print {
body,
html {
height: initial;
padding: 0px;
margin: 0px;
}
}
5.关于页眉页脚
由于业务是属于比较自定义化的展示,所以我封装成组件,然后根据返回的数据进行渲染到每个界面,然后利用绝对定位放在相同的位置,最后一点小优化就是,公共化提取界面的样式,然后整合为pub.scss然后引入到界面里面,这样即使产品有一定的样式调整,我也可以在公共样式里面去配置和修改,大大的减少本人的工作量。在日常的开发中也是这样,不要去抱怨需求的变动频繁,而是力争在写组件的过程中考虑到组件的健壮性和灵活度,给自己的工作减负,到点下班。
参考文章
来源:juejin.cn/post/7397319113796780042
借助 LocatorJS ,快速定位本地代码
引言
前端coder在刚接触一个新项目时是十分迷茫的,修改一个小 bug 可能要从路由结构入手逐级查找。 LocatorJS 提供了一种更便捷的方式,你只需要按住预设的快捷键选中元素点击,就可以快速打开本地编辑器中的代码,是不是非常神奇?
安装
访问 google 商店进行插件安装 地址
用法
本文以 MacOS 系统为例, Win系统可以用 Control 键替代 options键使用
LocatorJS 是 Chrome 浏览器的一个扩展程序,使用很便捷,只需要进行下面的三个步骤:
- 运行一个本地项目(本文以 LocatorJS源码 的 React 项目为例)
- 打开项目访问本地链接(例如:http://localhost:3348 )
- 按住键盘的 option 键(win系统是 control)后选中某一个元素并点击
这时候,就会跳出一个是否打开的提示,点击 “打开Visual Studio Code” 后 元素所在的本地代码就会通过你的 VsCode
(或者其他编辑器) 打开。是不是很神奇,那么它是怎么实现的呢?
原理解读
解读 Chrome 扩展程序,我们先打开 apps/extension/src/pages 路径,可以看到如下几个文件夹:
● Background 是放置后台代码的文件夹,本插件不涉及
● ClientUI 这里只有一行,引入了 @locator/runtime(本插件的核心代码)
● Content 放着插件与浏览器内容页面的代码,与页面代码一起执行
● Popup 文件夹下是点击浏览器插件图标弹出层的代码
4.1 解读 Content/index.ts
Content/index.ts 中最重要的代码是 injectScript
方法,主要做了两件事情,一个是创建了 Script 标签执行了 hook.bundle.js,另一个是将 client.bundle.js 赋值给了 document.documentElement.dataset.locatorClientUrl
(通过 Dom 传值),其余代码是一些监听事件
function injectScript() {
const script = document.createElement('script');
// script.textContent = code.default;
script.src = browser.runtime.getURL('/hook.bundle.js');
document.documentElement.dataset.locatorClientUrl =
browser.runtime.getURL('/client.bundle.js');
// This script runs before the <head> element is created,
// so we add the script to <html> instead.
if (document.documentElement) {
document.documentElement.appendChild(script);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
}
}
4.2 解读 hook.bundle.js
hook.bundle.js 是 hook 文件夹下的 index文件打包后的产物,因此我们去·看 apps/extension/src/pages/hook/index.ts 即可
import { installReactDevtoolsHook } from '@locator/react-devtools-hook';
import { insertRuntimeScript } from './insertRuntimeScript';
installReactDevtoolsHook();
insertRuntimeScript();
● installReactDevtoolsHook 会确保你的 react devtools扩展已安装 (没安装就install一个,猜测是仅涉及使用 API 的轻量版(笔者未深究))
● insertRuntimeScript 会对页面生命周期做一个监听,尝试加载 LocatorJS 的 runtime
组件, 在 insertRuntimeScript()
中,看到了这两行:
const locatorClientUrl = document.documentElement.dataset.locatorClientUrl;
delete document.documentElement.dataset.locatorClientUrl;
这个 locatorClientUrl
就是之前在 Content/index.ts
里传值的那个 client.bundle.js
,这里笔者简单说下,在尝试加载插件的方法 tryToInsertScript()
第一行判断如下:
if (!locatorClientUrl) {
return 'Locator client url not found';
}
这行判断其实已经可以推测出 client.bundle.js
的重要性了,它加载失败,整个插件直接返回错误信息了。
回过头来看向 ClientUI 文件夹下的 index.tsx 文件:
import '@locator/runtime';
至此,我们已经完成了 locatorJs
的加载逻辑推导,下一步我们讲揭开“定位器”的神秘面纱...
4.3 解读核心代码 runtime 模块
打开 packages/runtime/src/index.ts 文件
在这里我们看到不论是本地加载 runtime,还是浏览器加载扩展的方式都会去执行 initRuntime
initRuntime.ts
packages/runtime/src/initRuntime.ts
的initRuntime
这个文件中声明了一些全局样式,并用 shadow dom 的方式进行了全局的样式隔离,我们关注下底部的这几行代码即可:
// This weird import is needed because:
// SSR React (Next.js) breaks when importing any SolidJS compiled file, so the import has to be conditional
// Browser Extension breaks when importing with "import()"
// Vite breaks when importing with "require()"
if (typeof require !== "undefined") {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { initRender } = require("./components/Runtime");
initRender(layer, adapter, targets || allTargets);
} else {
import("./components/Runtime").then(({ initRender }) => {
initRender(layer, adapter, targets || allTargets);
});
}
兼容了一下服务端渲染和 SolidJs 的引入方式,引入相对路径下的 ./components/Runtime
核心组件 Runtime.tsx
packages/runtime/src/components/Runtime.tsx
抽丝剥茧,我们终于找到了它的核心组件 Runtime
,这是一个使用 SolidJs
框架编写的组件,包含了我们选中元素时出现的红框样式,以及所有的事件:
我们重点关注点击事件 clickListener
,最后点击跳转的方法是 goToLinkProps
export function goToLinkProps(
linkProps: LinkProps,
targets: Targets,
options: OptionsStore
) {
const link = buildLink(linkProps, targets, options);
window.open(link, options.getOptions().hrefTarget || HREF_TARGET);
}
采用逆推的方式,看 clickListener
事件里的 LinkProps
是怎样生成的:
function clickListener(e: MouseEvent) {
...
const elInfo = getElementInfo(target, props.adapterId);
if (elInfo) {
const linkProps = elInfo.thisElement.link;
...
}
...
}
同样的方式,我们去看看 getElementInfo
怎么返回的(过程略过),我们以 react
的实现为例,打开
packages/runtime/src/adapters/react/reactAdapter.ts
, 查看 getElementInfo
方法
export function getElementInfo(found: HTMLElement): FullElementInfo | null {
const labels: LabelData[] = [];
const fiber = findFiberByHtmlElement(found, false);
if (fiber) {
...
const thisLabel = getFiberLabel(fiber, findDebugSource(fiber)?.source);
...
return {
thisElement: {
box: getFiberOwnBoundingBox(fiber) || found.getBoundingClientRect(),
...thisLabel,
},
...
};
}
return null;
}
前面 goToLinkProps
使用的是 thisElement.link
字段, thisLabel
又依赖于 fiber
字段,等等! 这不是我们 react
玩家的老朋友 fiber
吗,我们查看一下生成它的 findFiberByHtmlElement
方法
export function findFiberByHtmlElement(
target: HTMLElement,
shouldHaveDebugSource: boolean
): Fiber | null {
const renderers = window.__REACT_DEVTOOLS_GLOBAL_HOOK__?.renderers;
const renderersValues = renderers?.values();
if (renderersValues) {
for (const renderer of Array.from(renderersValues) as Renderer[]) {
if (renderer.findFiberByHostInstance) {
const found = renderer.findFiberByHostInstance(target as any);
console.log('found', found)
if (found) {
if (shouldHaveDebugSource) {
return findDebugSource(found)?.fiber || null;
} else {
return found;
}
}
}
}
}
return null;
}
可以看到,这里是直接使用的 window
对象下的 __REACT_DEVTOOLS_GLOBAL_HOOK__
属性做的处理,我们先打印一下 fiber 查看下生成的结构
惊奇的发现 _debugSource 字段里竟然包含了点击元素所对应本地文件的路径
我们到 goToLinkProps 方法里打印一下跳转的路径发现果然一致,只是实际跳转的路径加上了 vscode://
开头,进行了协议跳转。
真相解读,_debugOwner 是怎么来的
一路砍瓜切菜终于要接近真相了,回顾代码我们其实只需要搞懂 window.REACT_DEVTOOLS_GLOBAL_HOOK 是怎么来的以及它做了什么,就可以收工了。
- _debugOwner 怎么来的?
_debugOwner
是通过 window.REACT_DEVTOOLS_GLOBAL_HOOK 根据 HtmlElement 生成的 fiber 得来的, 它是 React Devtools 插件的全局变量 HOOK,这就是为什么hook.bundle.js
要确保安装了 React Devtools - REACT_DEVTOOLS_GLOBAL_HOOK 做了什么
它是通过 @babel/plugin-transform-react-jsx-source 实现的,这个 plugin 可以在创建 fiber 的时候,将元素本地代码的位置信息保存下来,以
_debugSource
字段进行抛出
总结
LocatorJs 的 React 方案使用 React Devtools 扩展的全局 Hook,由 @babel/plugin-transform-react-jsx-source
plugin 将元素所在代码路径写入 fiber 对象当中,通过 HtmlElement 查找到相对应的 fiber,取得本地代码的路径,随即可实现定位代码并跳转的功能。
结语
本文粗略的讲解了 LocatorJs 在 React 框架的原理实现,算是抛砖引玉,供大家参考。
篇幅原因,略过很多细节,感兴趣的朋友建议看看源码,结合调试学习
我是饮东,欢迎点赞关注,江湖再见
来源:juejin.cn/post/7358274599883653120
这个字符串”2*(1+3-4)“的结果是多少
大家好,我是火焱。
前两天,在抖音上刷到一个计算器魔术,很有意思。
于是拿出手机尝试,发现不太对,为什么我的计算器直接把输入的内容都展示出来了?看评论区发现很多人都有类似的问题。

既然自带的计算器不好使,那就用小程序写一个。
产品描述
计算器的显示区只展示当前的数字,如果按了运算符(+ - * /),再输入数字时,展示当前的新数字,不展示之前输入的内容,按等于(=)号后,展示计算结果。
从程序员视角看,按等于(=) 时,我们拿到的是四则运算的字符串,比如:"1 + 2 * 3 - 4",然后通过代码计算这个字符串的结果,那么如何计算呢?
初步尝试
对于 javascript,很容易想到通过 eval 或者 new Function 实现,可是小程序...
既然捷径走不通,那就用逆波兰表达式来解决,我们来看下表达式的三种表示方法。
三种表示
中缀表达式,就是我们常用的表示方式:1 + 2 * 3 - 4
前缀表达式,也叫波兰表达式,是把操作符放到操作数前边,表示成:- + 1 * 2 3 4,由于后缀表达式操作起来比较方便,我们重点看下后缀表达式;
后缀表达式,也叫逆波兰表达式,它是把操作符放到操作数后边,表示成:1 2 3 * + 4 -,有了后缀表达式,我们就可以很容易计算结果了,那如何将中缀表达式转化成后序表达式呢?语言表述比较乏力,直接看代码吧,逻辑比较清晰:
/** 中缀表达式 转 后缀表达式 */
function infixToPostfix(infixExpression) {
let output = [];
// 存放运算符
let stack = [];
for (let i = 0; i < infixExpression.length; i++) {
let char = infixExpression[i];
if (!isOperator(char)) { // char 是数字
output.push(char);
} else { // char 是运算符
while (
// 栈不为空
stack.length > 0 &&
// 栈顶操作符的优先级不小于 char 的优先级
getPrecedence(stack[stack.length - 1]) >= getPrecedence(char)
) {
output.push(stack.pop());
}
stack.push(char);
}
}
// 将剩余的运算符弹出并追加到 output 后边
while (stack.length > 0) {
output.push(stack.pop());
}
return output.join('');
}
结合下图理解一下:
表达式:1 + 2 * 3 - 4
处理括号
带括号的表达式,处理逻辑和不带括号是一样的,只是多了对括号的处理。当遇到右括号时,需要把栈中左括号后面的所有运算符弹出,并追加到 output,举个例子:
计算:2 * ( 1 + 3 - 4)
通过这个例子,我们可以看出,后缀表示法居然不需要括号,更简洁。
好了,现在已经有了后序表达式,我们如何的到计算结果呢?
计算结果
计算这一步其实比较简单,直接上代码吧:
const operators = {
'+': function (a, b) { return a + b; },
'-': function (a, b) { return a - b; },
'*': function (a, b) { return a * b; },
'/': function (a, b) { return a / b; }
};
const stack = [];
postfixTokens.forEach(function (token) {
if (!isNaN(token)) {
stack.push(token);
} else if (isOperator(token)) {
var b = stack.pop();
var a = stack.pop();
stack.push(operators[token](a, b));
}
});
总结
中缀表达式对于人比较友好,而后缀表达式对计算机友好,通过对数字和运算符的编排即可实现带优先级的运算。如果本文对你有帮助,欢迎点赞、评论。
来源:juejin.cn/post/7294441582983528484
关于我在uni-app中踩的坑
前言
这段时间刚入坑uni-app小程序,本人使用的编辑器是VScode(不是HbuliderX!!!),在此记录本人所踩的坑
关于官方模板
我采用的是官方提供的Vue3+Vite+ts模板,使用的包管理工具是pnpm。大家可以使用npx下载模板
$ npx degit dcloudio/uni-preset-vue#vite-ts my-vue3-project
当然不出意外,大家下载都是失败的
So这里附上官方gitee下载地址 点击前去下载
下载解压后运行pnpm i,如果有报错可以尝试切换node版本。
微信小程序开发
第一步注册账号 小程序 (qq.com),按官方所需填写即可。
第二步,登录你的小程序账号,在开发->开发管理->开发设置,获取你的AppID(小程序ID)
第三步,在你的项目工程文件里找到manifest.json中的小程序相关填写你上一步获取的AppID
"mp-weixin": {
"appid": "替换你的小程序ID",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
然后终端运行pnpm run dev:mp-weixin
然后会生成一个dist目录,这里存放的是编译成微信小程序的源码
第四步,下载安装微信小程序开发工具 微信开发者工具下载地址
第五步,打开并登录微信小程序开发工具,选择导入项目,选择刚刚生成的dist目录下的mp-weixin即可
成功界面如图
关于node版本
让我们来看看人家官方是怎么说的
注意
- Vue3/Vite版要求 node 版本
^14.18.0 || >=16.0.0
- 如果使用 HBuilderX(3.6.7以下版本)运行 Vue3/Vite 创建的最新的 cli 工程,需要在 HBuilderX 运行配置最底部设置 node路径 为自己本机高版本 node 路径(注意需要重启 HBuilderX 才可以生效)
- HBuilderX Mac 版本菜单栏左上角 HBuilderX->偏好设置->运行配置->node路径
- HBuilderX Windows 版本菜单栏 工具->设置->运行配置->node路径
当然想要把这个官方模板跑起来还真是不容易(T-T),为什么这么说呢,本人使用node18居然跑不起来,按理说应该是可以的,but我最后选择将node版本降到node16
,在前端中我们会经常切换node,小编在这里要强推nvm(一款node版本管理工具),本文不在这里着重介绍,贴心的小编已经为大家附上了nvm的下载地址 点击前去下载
关于easycome配置
对于熟悉前端的小伙伴来说,自定义组件是家常便饭啦,uniapp内置easycom,用于自动导入自己和第三方的组件
首先我们找到pages.json文件,输入(cv)以下代码
"easycom": {
"autoscan": true,
"custom": {
// uni-ui 规则如下配置
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue",
//自定义规则
"^Xtx(.*)":"@/components/Xtx$1.vue"
}
},
自动查找以uni、Xtx开头的Vue文件,一定要注意规则,否则可能导致导入失败,写完后可以在导入的组件中log一下,判断是否导入成功,配置easycom后无需手动导入组件
关于uni-helper插件
如果你想增加在uni-app中开发体验,你可以选择uni-helper插件,首先确保你在vscode中安装了Vue Language Features (Volar)以及TypeScript Vue Plugin (Volar)插件,这俩插件提供Vue高亮显示和ts语法支持。
安装vscode uni-helper相关插件
然后安装3个包
$ pnpm i -D @uni-helper/uni-app-types
$ pnpm i -D @uni-helper/uni-cloud-types
$ pnpm i -D @uni-helper/uni-ui-types
接着在tsconfig.json中将3种类型应用。在compilerOptions的types中添加。配置如下:
{
...
"compilerOptions": {
...
"types": [
"@dcloudio/types",
"@uni-helper/uni-app-types",
"@uni-helper/uni-ui-types",
...
],
}
}
诶,这怎么原生标签报错了呢?别急,出现这个错误是因为unihelp的类型与原生发生了冲突,我们只需要在compilerOptions同级增加以下代码即可解决此问题
{
...
"compilerOptions": {
...
"types": [
"@dcloudio/types",
"@uni-helper/uni-app-types",
"@uni-helper/uni-ui-types",
...
],
}
//增加vueCompilerOptions配置项
"vueCompilerOptions": {
"nativeTags": ["block", "component", "template", "slot"]
},
}
避坑热重载
经过小编的测试发现,把微信开发者工具的自动保存和热重载关闭后,居然可以自动同步代码,起因是一天小编正苦于添加了请求拦截器却无法响应,偶然重新编译后发现可以拦截,于是考虑是否代码没更新,一看源码,果然如此,这里不知道是工具的bug还是vscode编译的bug。有了解的小伙伴可以在评论区留一下言。总之就是踩了很多坑(QWQ)
来源:juejin.cn/post/7286762580876902441
微信小程序:轻松实现时间轴组件
效果图
引言
老板: “我们公司是做基金的,用户都在买买买,可他们的钱去了哪里?没有时间轴,用户会不会觉得自己的钱瞬移了?”
你: “哈哈,确实!时间轴就像用户的投资地图,不然他们可能觉得钱被外星人劫走了。”
老板: “没错!我们得在时间轴上标清‘资金到账’、‘收益结算’这些节点,这样用户就不会担心他们的钱去买彩-票了。”
你: “放心吧,老板,我马上设计一个时间轴,让用户一看就明白他们的钱在干什么,还能时不时地笑一笑!”
老板: “好,赶紧行动,不然用户要开始给我们寄失踪报告了!”
废话不多说,我们直接开始吧!!!
组件定义
以下代码为时间轴组件的实现,详细注释在代码中。如果有任何疑问,欢迎在评论区留言讨论,或者联系我获取完整案例。
组件的 .js
文件:
/*可视化地呈现时间流信息*/
Component({
options: {
multipleSlots: true // 在组件定义时的选项中启用多slot支持
},
properties: {
activities: { // 时间轴列表
type: Array,
value: []
},
shape: { // 时间轴形状
type: String,
value: 'circle' // circle | square
},
ordinal: { // 是否显示序号
type: Boolean,
value: true
},
reverse: { // 是否倒序排列
type: Boolean,
value: false
}
},
lifetimes: {
attached() {
// 是否倒序排列操作数据
const {reverse, activities} = this.data
if (!reverse) return
this.setData({
activities: activities.reverse()
})
}
}
})
组件的.wxml
文件:
<view class="container">
<view class="item" wx:for="{{activities}}" wx:key="item">
<view class="item-tail"></view>
<view class="item-node {{shape}} {{item.status}}">
<block wx:if="{{ordinal}}">{{index + 1}}</block>
</view>
<view class="item-wrapper">
<view class="item-news">
<view class="item-timestamp">{{item.date}}</view>
<view class="item-mark">收益结算</view>
</view>
<view class="item-content">
<view>{{item.content}}</view>
<!--动态slot的实现方式-->
<slot name="operate{{index}}"></slot>
</view>
</view>
</view>
</view>
组件使用
要使用该组件,首先需要在 app.json
或 index.json
中引用组件:
"usingComponents": {
"eod-timeline": "/components/Timeline/Timeline"
}
然后你可以通过以下方式进行基本使用:
<eod-timeline activities="{{dataList}}" ordinal="{{true}}"></eod-timeline>
如果需要结合插槽动态显示操作记录,可以这样实现:
<eod-timeline activities="{{dataList}}" ordinal="{{true}}">
<!--动态slot的实现方式-->
<view wx:for="{{dataList}}" wx:for-index="idx" wx:key="idx" slot="operate{{idx}}">
<view class="row-operate">
<view>操作记录</view>
<view>收益记录</view>
<view>动账记录</view>
</view>
</view>
</eod-timeline>
数据结构与属性说明
dataList
数据结构示例如下:
dataList:[
{date: '2023-05-26 12:04:14', status: 'info', content: '内容一'},
{date: '2023-05-25 12:04:14', status: 'success', content: '内容二'},
{date: '2023-05-24 12:04:14', status: 'success', content: '内容三'},
{date: '2023-05-23 12:04:14', status: 'error', content: '内容四'},
{date: '2023-05-22 12:04:14', status: 'warning', content: '内容五'}
]
组件的属性配置如下表所示:
参数 | 说明 | 可选值 | 类型 | 默认值 |
---|---|---|---|---|
activities | 显示的数据 | — | array | — |
shape | 时间轴点形状 | circle / square | string | circle |
ordinal | 是否显示序号 | — | boolean | true |
reverse | 是否倒序排列 | — | boolean | false |
总结
这个时间轴组件提供了一个简单易用的方式来展示事件的时间顺序。组件支持定制形状、序号显示以及正序或倒序排列,同时允许通过插槽自定义内容,增强了组件的灵活性。代码中有详细注释,方便理解和修改。如果需要更详细的案例或有任何疑问,请在评论区留言。希望这篇文章对你有所帮助!
拓展阅读
关于动态 Slot
实现:
由于动态 slot
目前仅可用于 glass-easel
组件框架,而该框架仅可用于 Skyline
渲染引擎,因此这些特性也同样受此限制。如果需要在非 glass-easel
组件框架中实现动态 slot
,请参考上文标记了 <!--动态slot的实现方式-->
的代码段。
如需了解更多关于 glass-easel
组件框架的信息,请参阅微信小程序官方开发指南。
来源:juejin.cn/post/7399983901812604980