普通视图

发现新文章,点击刷新页面。
今天 — 2025年4月5日掘金 前端

helux v5 发布了,像pinia一样优雅地管理你的react状态吧

2025年4月5日 00:47

why store-pinia

服务于新闻web的状态库helux已发布了4个大版本,在这其中收到不少了其他用户觉得api过于复杂需要改进的建议,在这过程中了解到pinia是vue官方推荐的替代vuex的首选状态管理库,以其简单api的设计赢得广大用户喜爱,那么helux作为以atom为底层概念构架的react状态库,是否能基于这些底层api构建出类似pinia开发体验,并提供相比pinia更佳优雅的使用方式呢?

这也是我们发布第5个大版本的原因,把helux作为面向库开发者的底层工具,向上继续提炼,带来了全新的状态库@helux/store-pinia,它是一个简单易用类型安全性能高效的react状态库,基于helux构建,100%对齐pinia使用方式,并额外提供了更多pinia做不到的特性,从此刻起,你可以像pinia一样优雅地管理你的react状态了

线上体验地址

TLDR,你可以访问store-pinia线上体验地址快速了解和开始。我们提供了一个简单store和复杂store供你参考。

在 store-pinia里,store 可以访问其他 store,可以调用其他 store 方法,只要确认好依赖关系顺序即可

安装

# 内部自动安装 peer 依赖 helux@5.* 版本
npm i @helux/store-pinia@latest

定义状态(必须)

配置state字典工厂函数即可

const counterStoreCtx = defineStore("Counter", {
  state: () => ({ count: 1, mountCount: 1 }),
});

// 创建好即可组件外使用,无需激活过程
// counterStoreCtx.{ state, actions, getters, useStore, useLoading, getSnap, reset ... }

配置可缓存的计算属性(可选)

const counterStoreCtx = defineStore("Counter", {
  state: () => ({ count: 1, mountCount: 1 }),
    getters: {
    // 由 state 派生出 double ,上游依赖不变化时此函数不再重复计算
    double() {
      return this.count * 2;
    },
    // 由其他 getters 派生出 plus10 ,上游依赖不变化时此函数不再重复计算
    plus10() {
      return this.double + 10;
    },
  },
});

配置同步或异步方法(可选)

const counterStoreCtx = defineStore("Counter", {
  // state, getters
  actions: { // 函数体修改的是limu生成的结构共享的副本
    // 同步方法
    changeCount(payload: number) {
      this.count = payload;
    },
    // 异步方法
    async changeCountSync(p1: number, p2: number) {
      this.changeCount(p1);
      await delay();
      this.count += p2;
    },
  },
});

生命周期(可选)

const counterStoreCtx = defineStore("Counter", {
  // lifecyle 里可以访问 actions 调用方法
  // lifecycle的方法由框架负责调用,在 actions 里是访问不到的(类型上已屏蔽),由框架负责调用
  lifecycle: {
    // 【可选】第一个使用当前共享对象的组件实例将要挂载时触发 willMount
// 【可选】第一个使用当前共享对象的组件实例挂载完毕触发 mounted
// 【可选】最后一个使用当前共享对象的组件实例卸载前触发 willUnmount
    mounted() {
      // this.changeCount(888);
      this.mountCount += 1;
    },
// or willMount willUnmount
  },
});

组件内使用状态

组件内使用状态,并调用方法修改

function Counter() {
  const store = counterStoreCtx.useStore();
  // 此组件依赖仅是 count ( 支持默认6层深度收集,可独立配置)
  return (
    <div>
      <h3>total {store.count}</h3>
      <button onClick={() => store.changeCountSync(1, 2)}>changeCountSync</button>
      <button onClick={() => store.changeCount(Date.now())}>changeCount</button>
    </div>
  );
}

组件内直接定义修改方式

此种方式不推荐,尽量统一走 actions,方便devtool可追溯

function Demo() {
  const store = storeCtx.useStore();
  // 定义一个临时的修改状态的方法
  const onClick = () => {
    // 注:这里修改的是内部指向的临时副本,修改完毕后会自动结束生成新的结构共享的状态
    store.count++; 
  }
  const seeSnap(){
    console.log(storeCtx.getSnap());
  };
}

组件内使用函数运行状态

function CounterWithLoading() {
  const store = counterStoreCtx.useStore();
  const ld = counterStoreCtx.useLoading();
  // 可获取到所有关系的 action 函数运行状态
  const { ok, loading, err } = ld.changeCountSync;

  return (
    <div>
      {ok && <h1>{store.count}</h1>}
      {loading && <h1>loading...</h1>}
      {err && <h1>{err.message}</h1>}
      <button onClick={() => store.changeCountSync(1, 2)}>
        changeCountSync
      </button>
      <button onClick={() => store.changeCount(Date.now())}>changeCount</button>
    </div>
  );
}

配置 devtool 插件

import { HeluxPluginDevtool } from '@helux/plugin-devtool';
import { addPlugin } from '@helux/store-pinia';

addPlugin(HeluxPluginDevtool);

image.png

添加自定义插件

接收来自 helux 内部的各种事件并做对应处理

import { addPlugin, IPlugin } from '@helux/store-pinia';

const MyPlugin: IPlugin = {
  install(pluginCtx) {
    pluginCtx.on('ON_SHARE_CREATED', (dataInfo) => {
      // do some staff here
      // console.log('ON_SHARE_CREATED', dataInfo);
    });
    pluginCtx.on('ON_DATA_CHANGED', (dataInfo) => {
      // console.log('ON_DATA_CHANGED', dataInfo);
    });
  },
  name: 'MyPlugin',
};
addPlugin(MyPlugin);

添加自定义中间件

中间件在变更提交之前按添加顺序依次执行

import { addMiddleware, Middleware } from '@helux/store-pinia';

const myMiddleware: Middleware = (mid) => {
  if (mid.moduleName) { // 来自某个模块
    mid.draft.timestamp = new Date(); // 修改一下事件戳
  }
}

one more thing

如何不喜欢state打散到store上(defineStore这样做是为了和pinia对齐),可以使用 defineLayeredStore,这样就可从 this.state上独立获取状态了

const counterStoreCtx = defineLayeredStore("Counter", {
  state: () => ({ count: 1, mountCount: 1 }),
  getters: {
    double() {
      return this.state.count * 2;
    },
    actions: {
      change() {
        // this.state.count 
      }
    }
  },
});

// 组件里从 userState 获取状态,从 useGetters 获取派生
function Demo(){
  const [ state ] = storeCtx.useState();
  // state.xxx
  const getters = storeCtx.useGetters();
  // getters.xxx
}

组合api vs 可选 api

store-pinia 屏蔽了 helux 底层复杂的api,提供一种面向选项组织store的方式,当然如果喜欢组合api 那么依然可以基于helux组织状态管理的代码,萝卜青菜各有所爱,并没有定论表明哪一种方式更好。

Atom 的困局

这里提到Atom 的困局这个关键词,是因为看到了react的atom状态库鼻祖recoil在2025月2月2日进入归档状态,意味着这个实验性的状态库已彻底弃坑,不再提供任何维护服务而有感而发。

image.png

当然了用户依然可以从他的接任者jotai这里继续畅游在atom的世界里,但atom真的那么完美吗?为何recoil弃坑呢,这里从我的理解层面聊聊atom现在面临的困局。

atom 是什么

聊困局之前,先谈谈atom是什么,让未体验过的用户先在全局层面了解这个概念后,我们再抽丝剥茧的挖出关于atom的使用问题。

2020的时候,facebook开源了一个全新的状态库recoil,在里面提出了atom这个概念,号称以更细粒度的方式管理react应用状态。

image.png

从上图可看出recoil提到的atom概率更像一种更小粒度的共享状态单元(即原子化的状态管理方式),方便人们自低而上的去组织你的应用状态,强调去中心化管理。

举个例子,我么使用atom接口创建一个字符串类型的共享状态

const inputValueState = atom({
  key: "inputValue",
  default: ""
});

然后组件中就用钩子useAtom 来使用你的共享状态了

const InputA = () => {
  const [value, setValue] = useRecoilState(inputValueState);
  return <input value={value} onChange={e => setValue(e.target.value)} />;
};

当然你也可以使用selectors来派生你的共享状态,并配合useRecoilValue来在组件里使用

import { useRecoilValue } from 'recoil'; 

export const prefixedInputValue = selector({
  key: 'prefixedInput',
  get: ({ get }) => {
    const input = get(inputValueState);
    return `hello_${input}`;
  },
});

function MyComponent() {   
const prefixedInput = useRecoilValue(prefixedInputValue);   
return <h1>{prefixedInput}</h1>
}

以上就是一个简单的atom示例简介,而jotai则是更进一步的去掉key定义,让书写更简单高效。

// 组件外定义
const countAtom = atom(0);
// 组件中使用
const [ count, setCount ] = useAtom(countAtom);
<h1 onClick={()=>setCount(v=>v+1)}>{count}</h1>

但随着人们大规模使用atom后发现并发那么相信的美好...

atom过多与过大的矛盾

主要体验再 atom过多与更新粒度过大之间的矛盾,如何在两者之间找到平衡点是一个较大的心智负担。

准确的说atom强调小而美,这本生不是atom的问题,但应用开发者往往在构建复杂应用时,共享状态拆的过细就面临着海量atom的问题,和应用的状态建模是分裂的。

const userInfoAtom = atom({ info: ... });
const userNameAtom = atom('hi atom');
const userAddrAtom = atom('beijin');
const userAgeAtom = atom({ realAge:1, dispalyAge: 30 });
// ...etc

而把atom定义得过大,又会面临更新粒度过大的问题,因为atom的响应更新机制是只要atom更新,使用这个atom的组件就重绘,

const userAtom = atom({ User });

// userAtom 拆分到各个组件中使用时,修改user下任意一个属性均导致所有组件重绘

完美的atom存在么

到这里我们抛出一个命题:完美的atom应该是怎样的,它存在么?

在我的心目中,atom 应该满足以下条件:

  • atom 定义可大可小,不应该成为性能瓶颈。
  • atom可自底向上组合,也可自顶而下的拆分,不限制使用方式。
  • atom 依然可无缝对接redux社区大量的优秀生态,而不是另起炉灶自称一派

号称atom理念完美继任者的jotai依然只是对api语法做了改良,并不算是彻底革新,作者也深知atom碎片化的问题,又出了个使用代理对象构建的状态库voltio来满足此类用户的需求,集中定义状态、派生来满足大型应用的建模,但它也只是对mobx-react做语法改良,并无其他特殊亮点。

同时该作者还同时提供了 zustand 来接任 redux ,以便提高react开发体验,所以不放大胆一点,如果 atom 还能像 redux 那样去组织代码并复用redux生态也是极好的。

完美的atom应是如此

如果能像vue3那样运行atom就能彻底革新atom的使用体验,存在这样的库么,这就是helux诞生的初衷:使用 Proxy 构建 atom 世界!

可以近似理解为 jotai + voltio + zustand = helux,依托于强大的底层api,这3种编码方式都能使用 helux去实现。

初识helux,看起来几乎和jotai一模一样

import { atom, useAtom } from 'helux';
const [numAtom] = atom(1); // { val: 1 }

function Demo() {
  const [num, setAtom] = useAtom(numAtom); // num 自动拆箱
  // onClick=()=>setAton(v=>v+1); // 也支持回调形式
  return <h1 onClick={()=>setAtom(Math.random()}>{num}</h1>;
}

但我们进一步看,helux使用了Proxy来创建atom,进入修改时提供的是副本,用户可以对任意节点修改,由于有依赖收集功能只会影响使用了改节点的视图重绘

import { useAtom } from 'helux';
const [objAtom, setObj] = atom({ a: 1, b: { b1: 1 } });

// 修改草稿,生成具有数据结构共享的新状态,当前修改只会触发 Demo1 组件渲染
setObj((draft) => (draft.a = Math.random()));

function Demo1() {
  const [obj] = useAtom(objAtom);
  // 仅当 obj.a 发生变化时才触发重渲染
  return <h1>{obj.a}</h1>;
}

function Demo2() {
  const [obj] = useAtom(objAtom);
  // 仅当 obj.b.b1 发生变化时才触发重渲染
  return <h1>{obj.b.b1}</h1>;
}

依赖收集是实时的,故对ui里的依赖追踪会更加精确

import { atomx } from 'helux';
const { state, setDraft, useState } = atomx({ a: 1, b: { b1: 1 } });
const changeA = () => setDraft((draft) => (draft.a += 1));
const changeB = () => setDraft((draft) => (draft.b.b1 += 1));
function Demo1() {
  const [obj] = useState();
  // 大于 3 时,依赖为 a, b.b1  
  if (obj.a > 3) {
    return (<h1>{obj.a} - {obj.b.b1} </h1>);
  }

  // <=3 时,依赖仅为 q
  return <h1>{obj.a} </h1>;
}

为了让依赖收集过程更加高效,不可变数据没有使用immer库作为底层,而是重新实现了比immer快至少10倍左右(不冻结场景)的limu,仅测试碎片化小数据场景可达30多倍,

image.png

然后才依托limu继续构建了高效的响应式atom状态库helux

image.png

V5 全部特性一览

经过一段时间的打磨后发布的v5.0 版本里带来了以下最新特性

新增 lifecycle 定义

在 recoil 或 jotai 里,如果想对共享状态初始化时机做精细化的控制,仅当有组件使用时才初始化,没有组件时就清理是办不到的.

如果我们采用react的自身的思路去做,大概如下:

  • 模拟willMount提前获取
function Demo(){
   const fetchRef = useRef(false);
   if(!fetchRef.current){
      fetchRef.current = true;
      fetchData().then(...); // 初始你的atom
   }
}
  • 模拟mounted获取
function Demo(){
   useEffect(()=>{
     fetchRef.current = true;
      fetchData().then(...); // 初始你的atom
   }, []);
}
  • 模拟 willUnmount 获取
   useEffect(()=>{
     return ()=> console.log('clear logic');
   }, []);

对于非共享状态这样做没问题,但是提升为状态后,这样的代码就行不通了,因为只需要第一个组件发起请求即可,其他的复用,通常我们可以认为使用顶层组件来做这个事情,但这是一个极其脆弱的约定,同时共享状态何时该清理以便减轻内存消耗也是一个问题。

框架层面提供lifecycle接口可完美解决上述问题(框架内部很容易知道共享状态被多少组件使用中),用户不需要关注组件位置在哪里,如何设计第一个请求再哪里发起,或者改造底层fetch请求只允许同一时间发起一个,只需要定义lifecycle. willMount或者lifecycle.mounted来告诉框架,当前共享状态存在有第一个组件开始将要挂载时或者第一个组件挂载完毕时去做对应的事情(例如请求数据),存在最后一个组件将要卸载时去做对应的事情(例如状态清理),将用户彻底从react自身的生命周期里解放出来(react的生命周期只能服务于本地状态,应对共享状态存在天然的不足)。

有了 lifecycle 后,我么现在可以这样组织代码了

const atomCtx = atomx({ User ... })
atomCtx.defineLifecycle({
// 【可选】第一个使用当前共享对象的组件实例将要挂载时触发 willMount
willMount(){ /** code */ },
// 【可选】第一个使用当前共享对象的组件实例挂载完毕触发 mounted
mounted(){ /** code */ },
// 【可选】最后一个使用当前共享对象的组件实例卸载前触发 willUnmount
willUnmount(){ /** code */ },
})

更稳定的 useEffect

react18之后提供严格模式,在严格模式下的 useEffect 会产生双调用问题,号称是为了在开发模式下帮助用户加粗不合规范的 useEffect 编写方式(18版本之后 useEffect 是用来推荐作为 dom 操作的后门之用的,即逃生舱模式)

React.useEffect(()=>{
console.log('mounted');
return ()=> console.log('clear up');
}, []);

// 组件初次挂载时,打印
// mounted
// clear up
// mounted

卸载后后,再打印
// clear up

但对部分传统用户的确会照成困扰,v5之后提供更稳定的 useEffect句柄,支持 18 19版本,支持StrictMode组件包裹在任意节点也能有效工作

import {useEffect, useLayoutEffect} from 'helux';

// 同样是上面的例子,组件初次挂载时,打印
// mounted

// 卸载时,打印
// clear up

atom支持类组件使用

虽然react推崇函数式组件,对于部分依然习惯类组件的用户来说,在v5版本里可以用类组件来消费 atom 了,且依然可享受100%到的类型提示,和函数组件一样拥有依赖收集和精确更新能力。

  • 连接单个 atom
import { atom, withAtom, assignThisHX } from 'helux';
const [numAtom] = atom({ num: 1, info: { addr: 'bj' } });

class DemoCls extends React.Component<any> {
  // 先声明,运行时会由 withAtom 将值注入到此属性上
  private hx = assignThisHX(this);
  render() {
    // this.hx.atom.setState 修改atom
    console.log(this.hx.atom.state); // 获取到 atom state
  }
}

const IDemo = withAtom(DemoCls, { atom: numAtom });
  • 连接多个 atom
import { atom, withAtom, assignThisHX } from 'helux';

const [numAtom] = atom({ num: 1, info: { addr: 'bj' } });
const [bookAtom] = atom({ name: 'book', list: [] });

class DemoCls extends React.Component {

  private hx = assignThisHX(this);
  addNum = () => {
    this.hx.atoms.num.setState((draft: any) => void (draft.num += 2));
  };
  render() {
    // 从 atoms 字典上去各个子 atom
    const { num: { state }, book: { state } } = this.hx.atoms;
    return <div>hello num {state.num}<button onClick={this.addNum}> add num </button></div>;
  }
}

const IDemo = withAtom(DemoCls, { atoms: { num: numAtom, book: bookAtom } });

发布 @helux/store-pinia

伴随v5我们发布了进一步强化atom使用体验,弱化atom概念的状态库@helux/store-pinia,它基于helux向上构建,提供100%的类型安全,且100%对齐pinia使用体验,同时还结合helux自身的特色消除了pinia部分弱点。

  • 无需在组件里激活

piniadefineStore返回的是一个钩子函数,必须在组件里使用后才能激活,@helux/store-pinia则可以任意地方使用

const storeCtx = defineStore('Counter', {
  state: () => ({ count: 0 }),
  getters: {/** */ },
  actions: {/** */ },
});

// 拿到的 storeCtx 可以任意地方使用
storeCtx.useStore(); // 组件里
storeCtx.getStore(); // 组件外
  • 100% 对齐helux底层能力

依赖收集(默认收集到6层深度)

function Counter() {
  const store = storeCtx.useStore();
  // 此处仅依赖 count
  return (<h3>count: {store.count}</h3>;
}

信号更新(无hook)

import { $ } from 'helux';

function Counter() {
  // 此处仅依赖 count
  return (<h3>count: {$(store.count)}</h3>;
}

静态块更新(无hook)

import { block } from 'helux';

const Counter = ()=> block(()=>({
<div>{store.count}</div>;
}))
  • 增强了devtool,store-pinia 也能完美使用 devtool工具了

例如以下代码包含有一个复杂的 action 函数 fetchList

image.png

对应 devtool 可以查看变更明细

image.png

结语

期望@helux/store-pinia能为你的react应用带来更好的开发体验吧,欢迎关注我们的其他开源项目

helux , 一个集atomsignal依赖收集派生观察为一体,支持细粒度响应式更新的状态引擎,支持所有类 react 框架(包括 react 18)

hel-micro 工具链无感的微模块方案

limu基于读时浅拷贝和写时标记修改机制,可使用相同的api替代immer的高性能不可变库。

ff-creator一个基于node.js的高速视频制作库

昨天 — 2025年4月4日掘金 前端

Jetpack Compose UI组件封装(一)

作者 peakmain9
2025年4月4日 21:49

项目地址:github.com/Peakmain/Co…

文档地址:www.yuque.com/peakmain/al… 《Compose UI组件封装》

1. 添加依赖

How To

  • Step 1. Add the JitPack repository to your build file
    • gradle
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        mavenCentral()
        maven { url 'https://jitpack.io' }
    }
}
    • gradle.kts
dependencyResolutionManagement {
  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
  repositories {
    mavenCentral()
    maven { url = uri("https://jitpack.io") }
  }
}
  • Step 2. Add the dependency
dependencies {
    implementation("com.github.Peakmain:ComposeUI:+")
}

2. 基础组件

2.1. 顶部应用栏(AppBar)组件

2.1.1. 介绍

TopAppBarCenter 是一个高度定制的顶部应用栏(App Bar)组件,基于 Jetpack Compose 实现。主要特性包括:

  • 标题居中显示,支持自定义标题内容
  • 可配置左侧导航图标(如返回按钮)和右侧操作图标组
  • 支持沉浸式状态栏(透明状态栏,内容延伸到状态栏下方)
  • 动态控制状态栏图标颜色(深色或浅色)
  • 无缝集成 Scaffold,可直接作为其 TopBar 参数使用

2.1.2. 参数

参数名 参数类型 是否必填 默认值 说明
title @Composable () -> Unit 标题内容(支持任意 Composable 内容,默认居中显示)
modifier Modifier Modifier 布局修饰符,会与组件内部修饰符合并
navigationIcon @Composable (() -> Unit)? null 左侧导航图标(如返回按钮),若为 null则不显示
backgroundColor Color MaterialTheme.colors.primarySurface 应用栏背景颜色
actions @Composable RowScope.() -> Unit {} 右侧操作图标组(可包含多个图标按钮)
isImmersive Boolean false 是否启用沉浸式状态栏(透明状态栏,内容向上延伸)
darkIcons Boolean false 状态栏图标颜色是否为深色(true为黑色,false为白色),仅在 isImmersive=true时生效
content @Composable (PaddingValues) -> Unit 页面主体内容(通过 Scaffold插入到应用栏下方)

2.2. 标题组件

2.2.1. 介绍

  • 用于快速实现设计规范中的标题和内文样式。通过预定义的 PkTitleType 密封类(包含大标题、标题、小标题、内文等多种样式),开发者可以快速选择字体大小、字重等属性,保证 UI 一致性。
  • 组件支持自定义颜色、对齐方式、溢出处理等常见文本属性,同时保留与原生 Text 组件的兼容性

2.2.2. 参数说明

参数名 参数类型 是否必填 说明
text String 需要显示的文本内容
type PkTitleType 标题类型(默认为 BigTitle1),支持选择预定义的标题样式(如大标题、小标题、内文等)
modifier Modifier 布局修饰符(默认 Modifier),用于调整文本的布局、尺寸、间距等
color Color 文本颜色(默认 0xFF333333
fontStyle FontStyle? 字体风格(如斜体 FontStyle.Italic),默认 null表示不设置
textAlign TextAlign? 文本对齐方式(如居中 TextAlign.Center),默认 null表示继承父布局对齐
overflow TextOverflow 文本溢出处理方式(默认 TextOverflow.Clip),支持 Ellipsis等效果
maxLines Int 文本最大行数(默认无限制 Int.MAX_VALUE
style TextStyle 自定义文本样式(默认 LocalTextStyle.current),可覆盖 type中的部分属性

2.2.3. PkTitleType 类型说明

PkTitleType 是一个密封类,定义了以下预置样式:

类型名称 字体大小 字重 说明
BigTitle1 24.sp W500 大标题1(默认类型)
BigTitle2 22.sp W500 大标题2
BigTitle3 18.sp W500 大标题3
TitleBold1 16.sp W500 标题1(加粗)
TitleNormal1 16.sp W400 标题1(常规)
TitleBold2 15.sp W500 标题2(加粗)
TitleNormal2 15.sp W400 标题2(常规)
SmallTitleBold 14.sp W500 小标题(加粗)
SmallTitleNormal 14.sp W400 小标题(常规)
TextBold1 12.sp W500 内文1(加粗)
TextNormal1 12.sp W400 内文1(常规)
TextBold2 11.sp W500 内文2(加粗)
TextNormal2 11.sp W400 内文2(常规)

2.2.4. 示例代码

Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
    PkTitle("大标题1", PkTitleType.BigTitle1())
    PkTitle("大标题2", PkTitleType.BigTitle2())
    PkTitle("大标题3", PkTitleType.BigTitle3())
    PkDivider(modifier = Modifier.padding(vertical = 10.dp), isHorizontal = true)
    PkTitle("标题1加粗", PkTitleType.TitleBold1())
    PkTitle("标题1常规", PkTitleType.TitleNormal1())
    PkTitle("标题2加粗", PkTitleType.TitleBold2())
    PkTitle("标题2常规", PkTitleType.TitleNormal2())
    PkDivider(modifier = Modifier.padding(vertical = 10.dp), isHorizontal = true)
    PkTitle("小标题加粗", PkTitleType.SmallTitleBold())
    PkTitle("小标题常规", PkTitleType.SmallTitleNormal())
    PkDivider(modifier = Modifier.padding(vertical = 10.dp), isHorizontal = true)
    PkTitle("内文1加粗", PkTitleType.TextBold1())
    PkTitle("内文1正常", PkTitleType.TextNormal1())
    PkTitle("内文2加粗", PkTitleType.TextBold2())
    PkTitle("内文2正常", PkTitleType.TextNormal2())
}

3. 展示组件

3.1. 网格布局

3.1.1. 介绍

GridLayout 是一个基于 Jetpack Compose 的网格布局组件,支持动态生成多行多列的布局结构。通过指定列数 columns,组件会自动计算行数并按顺序填充数据。支持以下特性:

  • 自动填充数据项,不足一行的位置用空白占位
  • 可自定义水平分割线(默认或自定义样式)
  • 灵活的内容渲染逻辑,开发者可完全控制每个网格项的 UI

3.1.2. 参数

参数名 参数类型 是否必填 默认值 说明
columns Int(取值范围 ≥1) 每行显示的列数
data MutableList<E> 数据源列表,泛型 E表示数据项类型
isShowHorizontalDivider Boolean false 是否显示水平分割线(仅在行之间显示)
divider @Composable (() -> Unit)? null 自定义分割线组件,若未提供且 isShowHorizontalDivider=true,则使用默认 Divider
content @Composable (Int, E) -> Unit 定义每个网格项的内容,参数为数据项索引和对应的数据项 E

3.1.3. 示例代码

GridLayout(
    2,
    data = arrayListOf("111","222","333","444","555"),
) { index,item->
    Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
        Text(item, modifier = Modifier.padding(10.dp))
    }
}

3.2. 分割线

3.2.1. PkDivider分割线

3.2.1.1. 介绍
  • 支持设置水平/垂直 虚线或实线分割线
  • PkDashDivider(虚线)和 PkFullDivider(实线)整合类

3.2.1.2. 导入依赖
import com.peakmain.compose.ui.divier.PkDivider
3.2.1.3. 参数
参数名 参数类型 说明
modifier Modifier 用于添加额外修饰符的 Modifier。
color Color 分隔线的颜色,默认为 0xFFF1EFE9。
height Dp 分隔线的高度,仅在垂直分隔线生效,默认为 28.dp。
thickness Dp 分隔线的厚度,仅在非虚线时生效,默认为 1.dp。
startIndent Dp 分隔线的起始缩进,仅在非虚线时生效,默认为 0.dp。
isHorizontal Boolean 是否为水平分隔线,默认为 false。
isDash Boolean 是否绘制虚线分隔线,默认为 false。
strokeWidth Dp 虚线的宽度,仅在绘制虚线时生效,默认为 0.5.dp。
dashLength Dp 虚线的线段长度,仅在绘制虚线时生效,默认为 2.dp。
gapLength Dp 虚线的间隔长度,仅在绘制虚线时生效,默认为 2.dp。
3.2.1.4. 示例代码
//默认垂直实线
PkDivider()
//水平实线
PkDivider(modifier = Modifier.padding(top = 10.dp), isHorizontal = true)
//垂直虚线
PkDivider(isDash=true)
//水平虚线
PkDivider(modifier = Modifier.padding(top = 10.dp), isHorizontal = true, isDash = true)

3.2.2. PkFullDivider实线分割线

3.2.2.1. 介绍
  • 创建一个水平/垂直实线分隔线组件
3.2.2.2. 导入依赖
import com.peakmain.compose.ui.divier.PkFullDivider
3.2.2.3. 参数
参数名 参数类型 说明
modifier Modifier 用于添加额外修饰符的 Modifier。
color Color 分隔线的颜色,默认为 0xFFF1EFE9。
height Dp 分隔线的高度,垂直方向生效默认为 28.dp。
thickness Dp 分隔线的厚度,默认为 1.dp。
startIndent Dp 分隔线的起始缩进,默认为 0.dp。
isHorizontal Boolean 水平分割线 (true)/ 垂直分割线 (false),默认是 false
3.2.2.4. 示例代码
PkFullDivider(modifier = Modifier.padding(top = 10.dp), isHorizontal = true)

3.2.3. PkDashDivider虚线分割线

3.2.3.1. 介绍
  • 创建一个水平/垂直虚线分隔线组件
3.2.3.2. 导入依赖
import com.peakmain.compose.ui.divier.PkDashDivider
3.2.3.3. 参数
参数名 参数类型 说明
modifier Modifier 用于添加额外修饰符的 Modifier。
color Color 分隔线的颜色,默认为 0xFFF1EFE9。
height Dp 分隔线的高度,垂直方向生效,默认为 28.dp。
strokeWidth Dp 虚线的宽度,默认为 0.5.dp。
dashLength Dp 虚线的长度,默认为 2.dp。
gapLength Dp 虚线之间的间隔长度,默认为 2.dp。
isHorizontal Boolean 水平分割线 (true)/ 垂直分割线 (false),默认是 false
3.2.3.4. 示例代码
 PkDashDivider(modifier = Modifier.padding(top = 10.dp), isHorizontal = true)

3.3. 流式布局PkFlow

3.3.1. 介绍

PkFlowRow 是一个定制化的流式布局组件,基于 Jetpack Compose 的 FlowRow 扩展而来。核心功能如下:

  • 支持子项水平排列,自动换行
  • 限制最大行数(超出部分自动隐藏)
  • 可自定义子项间水平/垂直间距
  • 自动裁剪超出最大高度的内容(通过 clipToBounds 实现)
  • 适用于标签组、动态分类展示等需要紧凑布局的场景

3.3.2. 参数

参数名 参数类型 是否必填 默认值 说明
modifier Modifier Modifier 布局修饰符(如尺寸、背景等),会与内部修饰符合并
horizontalSpacing Dp 0.dp 子项之间的水平间距
verticalSpacing Dp 0.dp 行之间的垂直间距
maxLine Int(取值范围 ≥1) 2 最大显示行数,超出部分将被隐藏
content @Composable () -> Unit 子项内容,支持任意Composable 组件

3.3.3. 示例代码

@Preview
@Composable
fun PkFlowRowPreview() {
    val tags = listOf("Android", "Kotlin", "Jetpack Compose", "KMP","Material Design", "UI", "Development")

    PkFlowRow(
        horizontalSpacing = 8.dp,
        verticalSpacing = 12.dp,
        maxLine = 1
    ) {
        tags.forEach { tag ->
            Text(
                text = tag,
                modifier = Modifier
                    .background(Color.LightGray, RoundedCornerShape(16.dp))
                    .padding(horizontal = 12.dp, vertical = 8.dp)
            )
        }
    }
}

4. 工具类

4.1. ImagePainterUtils工具类

4.1.1. 介绍

  • 根据图片 URL 获取 AsyncImagePainter 对象

4.1.2. 导入依赖

import com.peakmain.compose.utils.ImagePainterUtils

4.1.3. 参数

参数名 参数类型 说明
imageUrl String 图片的 URL,如果为空则显示占位图。
errorDrawableResId Int 图片加载失败时显示的 Drawable 资源 ID,默认为 R.drawable.icon_loading。
placeDrawableResId Int 图片加载过程中显示的占位图 Drawable 资源 ID,默认为 R.drawable.icon_loading。

4.1.4. 示例代码

Image(
    painter = ImagePainterUtils.getPainter(it.imageUrl),
    contentDescription = null,
    modifier = Modifier
        .clip(RoundedCornerShape(8.dp))
        .weight(1f)
        .height(75.dp),
    contentScale = ContentScale.Crop
)

5. 扩展类

5.1. List扩展类

5.1.1. 介绍

可空List的扩展类,作用如下

  • 可空List的大小
  • 可空List的大小是否大于0
  • 可空List大小是否为0

5.1.2. 导入依赖

import com.peakmain.compose.ext.orSize

5.1.3. 方法

方法名 返回值 说明
orSize Int 获取 List 的大小。若 List 为 null,则返回 0;若 List 不为 null,则返回其 size。
sizeBigZero Boolean 判断 List 大小是否大于 0。若 List 为 null,则返回 false;若 List 不为 null,则判断其大小是否大于 0。
sizeEqualZero Boolean 判断 List 的大小是否为 0。若 List 为 null,则返回 true;若 List 不为 null,则判断其大小是否等于 0。

5.1.4. 示例代码

params?.imageList.orSize() > 0
params?.imageList.sizeBigZero()

AI 时代如何更高效开发前端组件?21st.dev 给了一种答案

作者 范文杰
2025年4月4日 21:31

给大家推荐一个好东西:21st.dev ,大致上你可以将它理解为一个非常前卫的组件托管市场,特别之处在于:

  1. 它参考 shadcn/ui 的设计理念提供了一种原子化的,Code Out 形式的依赖安装、管理模式;
  2. 并且更具有启发性的,它为每一个组件都提供了一套用于生成组件代码的 Prompt,用户可以借此在特定项目上下文中生成适配度更高的组件效果。

借助 21st.dev 与 cursor,我做了这样一个 demo:

这一切都是在 5min 内,不写一行代码的情况下实现的!更值得称谓的是,21st.dev 的功能设计真正做到了 AI 友好,能够很好地应用在各类 AI 工具中(cursor、v0.dev、bolt.new、cline 等等),并且这套设计逻辑还非常适合复用到各种 TO-D 场景中,

21st.dev 是什么

21st.dev 是一个开源的 React UI 组件市场,专门为设计工程师和前端开发者提供高质量的 UI 组件。它的灵感来自于 shadcn/ui,旨在帮助开发者快速构建精美的用户界面,尤其适用于 AI 产品的开发。21st.dev 目前已经托管了海量开源组件,类型涵盖各种极具设计感(由官方审核)头像、dialog、按钮、日历,甚至完整 Page,应有尽有,并且这些组件都经过 21st.dev 官方审核,质量上还是比较有保证的。

与 npm 等传统市场的主要差异点:

  1. 21st.dev 偏向于一次性交付,安装后会把代码 clone 到本地,之后就算是跟市场本身脱钩了,这种方式缺点是无法持续跟进组件本身的迭代;但好处则是这会大大减少版本概念带来的复杂性(《NPM 依赖管理的复杂性》),并且,代码被复制下来后,可按项目的具体上下文做任意调整,比较适合一些复杂度不是很高的代码复用场景;
  2. 21st.dev 提供了一些 AI 友好的交互,一是为每一个共享组件都设置了用于生成组件代码的 Prompt,用户可直接复制粘贴到 LLM 工具中(推荐用 Cursor),即可生成对应组件代码;其次,它还提供了 MCP 实现,用户可将之接入到各类支持 MCP 协议的工具中;

相对而言,21st.dev 的模式更匹配 AI 时代的用户习惯:Codeout、可定制、AI 可理解,因此它在海外也就迅速走红,Producthub 评分非常高。

怎么用

使用之前,建议先进入市场内(21st.dev/)看看有那些可用组件,…

21st.dev 本质上是一个组件市场,提供了多种组件消费方式:

  1. 使用 dlx 工具:进入 21st.dev ,选定组件,进入组件详情页后,直接复制右上角的 install component 命令,之后在项目目录中执行,即可生成对应代码;

  1. 使用 Prompt:同样的,进入组件详情页后,点击 copy prompt 按钮,之后使用 cursor、cline 等工具,即可生成组件代码:

  1. 使用 21st.dev MCP 服务:参考:github.com/21st-dev/ma… 文档,配置 MCP 服务接口,之后在 prompt 中使用 /ui 指令明确要求调用 21st.dev 生成组件(具体用法,下面有详细介绍);

这里强烈推荐使用第三种:MCP 服务,这种方式相当于将 21st.dev 的组件知识外挂到 cursor 等 IDE,之后这类 IDE 可根据实际场景中的上下文,自行规划实现路径,以及自行判定如何基于 21st.dev 提供的组件设计知识(注意,这里是设计知识,而不是代码本身)实现用户需求。

如果你还不了解 MCP,可阅读科普文档:使用 MCP 扩展 Cursor 能力: ecn5ehmm9iou.feishu.cn/wiki/EyrOwR…

亮点:一键复制组件 Prompt

最让人惊喜的是,组件详情页提供了 Copy Prompt 按钮:

点击即可复制用于实现该组件的 Prompt,用户可将之粘贴到 Curosr 等工具中由 LLM 协助生成该组件的代码,例如用于实现 AI Chat 对话框的组件:

过去,我们在开源市场见到一些惊艳的组件,至少需要经过学习、demo、编码、调试之后才能复用起来,但 21st.dev 提供的 prompt,真的就能实现一键复刻,即使你完全不读组件代码也不妨碍你的使用和微调,效率高出许多。

亮点:MCP 服务

其次,21st.dev 官方还提供了一套 MCP 服务 (github.com/21st-dev/ma… MCP 的工具中使用:

以 Cursor 为例:

  1. 注册 21st.dev 服务,拿到 api key
  2. 在 cursor 中配置 MCP 服务:

  注意几个点:

  1.    Type 设置为 `command`
    
  2.    Command 中需要输入如下命令:
    
    npx -y @smithery/cli@latest run @21st-dev/magic-mcp --config "{"TWENTY_FIRST_API_KEY":"your-api-key"}"
    
  3. 配置完成,并且确认 MCP 服务状态正常可用后,使用 /ui 开头的 prompt 生成组件,例如:

对应实现效果:

虽然颜色有点丑,但真的能在几分钟能完成初版内容,交互流程比 v0.dev、bolt.new 等工具都流畅许多,并且所有代码都严格按照我仓库设置的 .cursorrules 规则生成,几乎没有调整成本。

一些启发

私有组件库的复用思路

试用下来,我觉得 21st.dev 的爆火并非偶然,它实在太适合 AI 时代了,过往都是基于 package 粒度做组件共享,消费者需要阅读理解组件文档后,再嵌入到业务系统中使用,这会引发几个问题:

  • 学习成本比较高,开发者需要花时间精力理解这些组件,分析组件质量(通过单测、源码等);
  • 组件代码与版本号绑定,一旦发布,消费者几乎没法修改,作者给了你啥,基本就得吃啥;
  • Package 与子 package 之间的相互依赖关系非常复杂,非常容易出现版本管理问题

而 21st.dev 这种源码共享方式,配合 AI 自动理解、生成、修改代码的能力,则很好解决了上述这些传统组件复用手段的学习成本、版本化与灵活性等方面的问题,不仅仅大幅降低代码复用成本,还能针对具体场景(技术栈、需求等)自动做好调整适配,某种程度上这已经是一种新的范式:不是代码复用,而是设计逻辑复用了。

沿着这个解题思路,假设将 21st.dev 部署到私域,托管私有组件库,那么我们只需集中精力维护好核心组件的质量、性能、文档工程,之后借助 21st.dev + cursor 就可以轻易完成私域场景中的业务组件复用、复刻、组装、功能开发等方面的任务,提升开发效率,这是一个非常值得探索的方向。

Prompt 优于代码

如果 21st.dev 只是提供了传统的组件托管、分发能力的话,那这只会是另一个俗套的 npm。但它创新地为每一个组件都提供了一套有针对性的 prompt,并且进一步提供了集成 MCP 服务,这性质可就变了。配合 AI 工具后,具体组件的代码(僵化,无法调整的知识)已经不是很重要,取而代之的是指导 AI 实现同类效果的思路(灵活,可调整的知识),AI 可以根据实现思路与具体上下文约束,更灵活调整出更适配的具体代码,算是从鱼到渔的转变了。

沿着这个思路,假设你正在开发一些 TO-D 类的产品,其实也可以考虑为每一个具体接口提供定制的 Prompt、功能描述以及集成 MCP 服务,提供一套转为 AI 优化的知识面板,那么原本面向人类智能的交互,也就能够转换成面向 AI 的交互,对人类而言学习成本、使用成本都会急剧降低,也就更容易推广。

因此,非常建议做 TO-D 工具的同学都往 AI 友好这个方向思考,包括但不限于:开源产品、Open API 类平台、私有组件库、私有代码库等。

基于 antd 的实践

这只是一个实例,但这种模式其实可以被复用到各种基于 md 的信息场景,例如私有组件库,代码我已经整理好了:github.com/Tecvan-fe/v…

基于上述启发,有没有可能在 antd 基础上配合 Cursor 与 MCP 协议实现代码自动生成效果呢?答案是必然的,并且成本并不高,需要做几件事情:

  1. 为基础组件梳理完善的使用文档,并且最好是基于 MDX 格式的,方便LLM 理解;

  2. 开发 MCP 服务,让 LLM 有机会理解 antd 所提供的能力细节,虽然我们也能通过 docs 等方式实现这一点,但出于扩展性与可维护性考虑,这里采用 MCP 方式;

    1. 可用的组件列表
    2. 每一个组件的使用方法
  3. 在 Cursor(其他工具同理) 中配置 MCP 服务;

  4. 写好 .cursorrules ,让 Cursor 知道我们的编码规范,按正确的规则生成代码;

这些动作,本质上就是把正确的、充足的信息,喂给 LLM ,让它理解私域知识,满足特定上下文下的开发需求(感谢 MCP)。基于这套 MCP ,我试着做了一个简单页面:

  • 原始页面效果:

  • 初次实现效果:

  • 初次生成代码:

从结果来说,还原度还是比较高的,并且也正确用了各种 antd 的各类组件。虽然各种样式细节对不上,但我们可以继续要求 LLM 持续优化代码,不断逼近最终要求;其次,初次生成的代码结构虽然比较粗糙,但也同样可以要求 LLM 持续优化直至符合技术规范。

重点在于,这是一个可持续迭代优化的过程,虽然结果上只能无限逼近理想状态,但也比纯粹靠人力从零开始开发、调优,要高效的多。并且,假如未来我们进一步整理出更多可复用的基础业务组件,AI 的组件生成能力还可以进一步扩展,业务功能的开发成本还可以进一步降低。


接下来演示具体使用方法,以 Cursor 为例:

  1. 配置 MCP 服务,类型是 Command,命令是 npx @tecvan-fe/mdc-mcp@alpha start,例如:

  1. 在 Composer 中输入关键词 /ui 以及,你需要实现的效果,建议截图贴进去,例如:

  1. 后续针对样式效果和代码结构继续提出优化需求,例如:

不过,真实开发场景是很复杂的,这套逻辑还只能生成静态代码,后续还是需要继续开发数据交互、用户交互、调试、测试、上线、监控等方面的工作后续我会继续输出如何借助 Cursor 高效编码的更多内容。

问题

说完优点,不能免俗的还是得聊聊 21st.dev 的问题,我目前看到的:

  • 底层基于 shadcn 实现,暂时无法脱离 react 技术栈,且从组件代码来看,还深度绑定了 nextjs,有点捆绑销售的意思;好消息是,它为每一个组件都生成了 prompt,可以很方便复制到项目中,之后借助 cursor 等工具做深度优化;
  • 不支持私有化托管,因此虽然能快速做出各种小产品的原型,但对大型项目并不友好;
  • 21st.dev 目前仅提供纯前端类型的组件,并没有很好串联起前后端整套机制;

不过,即使如此,Cursor + 21st.dev 组合依然比传统开发方式要高效太多了,非常建议大家深入学习。

刷刷题50(常见的js数据通信与渲染问题)

2025年4月4日 21:21

1. 如何用 JavaScript 的 Proxy 实现数据的双向绑定?写出关键代码并说明其与 Vue 4.0 的响应式系统的差异。

关键代码实现:

// 数据模型
const data = { value: "" };

// 监听数据变化的回调函数集合
const callbacks = new Set();

// 创建 Proxy 代理
const proxy = new Proxy(data, {
  set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver);
    // 触发所有回调(模拟视图更新)
    callbacks.forEach(cb => cb());
    return result;
  }
});

// 视图绑定(假设有一个 input 元素)
const input = document.querySelector("input");
input.addEventListener("input", (e) => {
  proxy.value = e.target.value; // 数据变化触发 Proxy.set
});

// 注册更新视图的回调
callbacks.add(() => {
  input.value = proxy.value; // 数据到视图的绑定
});

与 Vue 4.0 的差异:

  1. 依赖追踪‌:

    • Proxy 实现‌:需要手动管理依赖(如示例中的 callbacks),无法自动追踪数据与视图的关联。
    • Vue 4.0‌:通过 effect 和 track/trigger 实现自动依赖收集,精确更新相关组件。
  2. 性能优化‌:

    • Proxy 实现‌:每次数据变化都会触发所有回调,可能造成不必要的渲染。
    • Vue 4.0‌:使用虚拟 DOM 和异步批处理更新,减少实际 DOM 操作次数。
  3. 数据类型支持‌:

    • Proxy 实现‌:对数组的 push/pop 等方法需要额外处理。
    • Vue 4.0‌:重写了数组方法,确保能够触发响应式更新。
  4. 嵌套对象‌:

    • Proxy 实现‌:需要递归代理嵌套对象。
    • Vue 4.0‌:通过惰性代理(按需转换)优化性能。

2. 设计一个 JavaScript 的异步任务调度器,支持任务优先级调度和超时熔断。

关键代码实现:

class TaskScheduler {
  constructor(concurrency = 2) {
    this.queue = [];
    this.running = 0;
    this.concurrency = concurrency;
  }

  add(task, priority = 0, timeout = 5000) {
    const taskWrapper = {
      task: () => Promise.race([
        task(),
        new Promise((_, reject) => 
          setTimeout(() => reject(new Error("Timeout")), timeout)
        )
      ]),
      priority
    };

    // 按优先级插入队列(数值越大优先级越高)
    const index = this.queue.findIndex(t => t.priority < priority);
    if (index === -1) this.queue.push(taskWrapper);
    else this.queue.splice(index, 0, taskWrapper);
    
    this.run();
  }

  run() {
    while (this.running < this.concurrency && this.queue.length > 0) {
      const { task } = this.queue.shift();
      this.running++;
      task()
        .catch(err => console.error("Task failed:", err))
        .finally(() => {
          this.running--;
          this.run();
        });
    }
  }
}

// 使用示例
const scheduler = new TaskScheduler();
scheduler.add(() => fetch("/api/data"), 2, 3000); // 高优先级
scheduler.add(() => console.log("Low priority task"), 0);

核心特性:

  • 优先级调度‌:通过插入排序实现优先级队列。
  • 超时熔断‌:使用 Promise.race 实现任务超时自动拒绝。
  • 并发控制‌:限制同时运行的任务数量。

3. 如何用 JavaScript 的 Intersection Observer 实现无限滚动列表?写出关键代码并说明其性能优化策略。

关键代码实现:

let page = 1;
const observer = new IntersectionObserver((entries) => {
  if (entries.isIntersecting) {
    loadMore();
  }
}, { threshold: 0.1 });

// 初始占位元素
const sentinel = document.createElement("div");
document.body.appendChild(sentinel);
observer.observe(sentinel);

async function loadMore() {
  observer.unobserve(sentinel); // 防止重复触发
  const data = await fetch(`/api/items?page=${page++}`).then(res => res.json());
  renderItems(data);
  sentinel.scrollIntoView({ behavior: "smooth" });
  observer.observe(sentinel); // 重新观察
}

性能优化策略:

  1. 虚拟列表‌:只渲染可视区域内的元素(如使用 react-window 库)。
  2. 请求防抖‌:确保滚动结束时才触发加载。
  3. 内存管理‌:移除视口外的 DOM 元素。
  4. 缓存策略‌:缓存已加载的数据,避免重复请求。

4. 在 JavaScript 中,如何通过 Web Workers 实现多线程计算?写出关键代码并说明其与主线程的通信机制。

关键代码实现:

主线程代码‌:

const worker = new Worker("worker.js");

// 发送数据到 Worker
worker.postMessage({ type: "CALC", data: 1000000 });

// 接收 Worker 结果
worker.onmessage = (e) => {
  console.log("Result:", e.data.result);
};

// 错误处理
worker.onerror = (err) => console.error("Worker error:", err);

worker.js‌:

self.onmessage = (e) => {
  if (e.data.type === "CALC") {
    const result = heavyCalculation(e.data.data);
    self.postMessage({ result });
  }
};

function heavyCalculation(n) {
  // 模拟耗时计算
  return Array.from({ length: n }, (_, i) => i).reduce((a, b) => a + b);
}

通信机制:

  • 数据传递‌:通过 postMessage 传递结构化克隆或 Transferable 对象。
  • 无 DOM 访问‌:Worker 线程无法操作 DOM。
  • 异步通信‌:消息传递是非阻塞的。

5. 如何用 JavaScript 的 WebSocket 实现实时消息推送?写出关键代码并说明其与 HTTP 长轮询的优劣。

关键代码实现:

const socket = new WebSocket("wss://api.example.com/ws");

socket.onopen = () => {
  socket.send(JSON.stringify({ subscribe: "updates" }));
};

socket.onmessage = (e) => {
  console.log("New message:", JSON.parse(e.data));
};

socket.onclose = () => {
  console.log("Connection closed");
};

// 发送消息
document.querySelector("button").addEventListener("click", () => {
  socket.send(JSON.stringify({ message: "Hello" }));
});

对比 HTTP 长轮询:

特性 WebSocket HTTP 长轮询
连接类型 全双工持久连接 半双工,每次请求后关闭
延迟 低(无需频繁握手) 较高(每次请求需要重新建立连接)
服务器推送 支持 需等待客户端轮询
资源消耗 较少(维持单一连接) 较高(频繁连接/断开)
浏览器兼容性 IE10+ 所有浏览器
数据传输效率 高效(无 HTTP 头开销) 较低(每次请求携带完整头信息)

选择建议‌:需要高频双向通信(如聊天室)用 WebSocket;低频场景(如邮件通知)可用长轮询。

JS多线程Webworks中的几种实战场景演示

2025年4月4日 21:18

一、案例 1:大规模数据聚合与可视化

场景说明

在金融交易仪表盘中,前端需要实时处理每秒数千条交易记录,按时间维度聚合后生成热力图。直接在主线程处理会导致界面卡顿。


代码实现
主线程 (main.js)
// 1. 创建 Web Worker
const dataWorker = new Worker('./dataAggregator.worker.js');

// 2. 从 WebSocket 接收原始数据
const ws = new WebSocket('wss://api.example.com/trades');
ws.onmessage = (event) => {
  const rawData = JSON.parse(event.data);
  
  // 3. 发送原始数据到 Worker(避免 JSON 序列化开销)
  const buffer = new ArrayBuffer(rawData.length * 4);
  const view = new DataView(buffer);
  rawData.forEach((val, i) => view.setFloat32(i * 4, val.price));
  dataWorker.postMessage(buffer, [buffer]); // 使用 Transferable 对象
};

// 4. 接收聚合结果
dataWorker.onmessage = (event) => {
  const { heatmapData } = event.data;
  renderHeatmap(heatmapData); // 主线程只负责渲染
};
Worker 线程 (dataAggregator.worker.js)
self.onmessage = (event) => {
  // 1. 接收二进制数据(零拷贝)
  const buffer = event.data;
  const view = new DataView(buffer);
  const prices = [];
  
  // 2. 解析二进制数据
  for (let i = 0; i < buffer.byteLength / 4; i++) {
    prices.push(view.getFloat32(i * 4));
  }

  // 3. 执行聚合计算(耗时操作)
  const heatmapData = prices.reduce((acc, price) => {
    const timeSlot = Math.floor(Date.now() / 1000) * 1000; // 按秒聚合
    if (!acc[timeSlot]) acc[timeSlot] = { sum: 0, count: 0 };
    acc[timeSlot].sum += price;
    acc[timeSlot].count++;
    return acc;
  }, {});

  // 4. 返回结果(通过结构化克隆)
  self.postMessage({ heatmapData });
};
关键意义
  • 主线程零阻塞‌:二进制数据传输避免序列化开销,1 万条数据处理时间从 200ms 降至 20ms
  • 可视化流畅性‌:主线程专注于 60fps 渲染,Worker 线程处理脏数据

二、案例 2:图像滤镜处理

场景说明

在图片编辑器中,用户上传 10MB 的高清图片后,需要实时应用高斯模糊滤镜。主线程处理会导致操作界面冻结。


代码实现
主线程 (main.js)
// 1. 创建 Worker 池(4 线程并行)
const workers = Array.from({ length: 4 }, () => new Worker('./imageProcessor.worker.js'));

// 2. 用户选择图片后分片处理
input.addEventListener('change', async (e) => {
  const image = await loadImage(e.target.files);
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  ctx.drawImage(image, 0, 0);
  
  // 3. 获取像素数据并分片
  const imageData = ctx.getImageData(0, 0, image.width, image.height);
  const totalPixels = imageData.data.length;
  const chunkSize = Math.ceil(totalPixels / workers.length);
  
  // 4. 分配任务到 Worker
  workers.forEach((worker, i) => {
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, totalPixels);
    const chunk = new Uint8ClampedArray(imageData.data.slice(start, end));
    
    // 使用 Transferable 对象传输
    worker.postMessage({
      chunk,
      width: image.width,
      startIndex: start
    }, [chunk.buffer]);
  });
});
Worker 线程 (imageProcessor.worker.js)
self.onmessage = (event) => {
  // 1. 接收图像分片数据
  const { chunk, width, startIndex } = event.data;
  
  // 2. 应用高斯模糊滤镜(核心算法)
  const processedChunk = applyGaussianBlur(chunk, width);
  
  // 3. 返回处理结果
  self.postMessage({
    chunk: processedChunk,
    startIndex
  });
};

function applyGaussianBlur(data, width) {
  // 实现卷积运算(此处为简化示例)
  const kernel = [1, 2, 1, 2, 4, 2, 1, 2, 1];
  const result = new Uint8ClampedArray(data.length);
  
  for (let i = 0; i < data.length; i += 4) {
    // 每个像素计算耗时约 0.1ms
    const neighbors = getNeighbors(i, width, data);
    const r = convolve(neighbors.r, kernel);
    const g = convolve(neighbors.g, kernel);
    const b = convolve(neighbors.b, kernel);
    
    result.set([r, g, b, 255], i);
  }
  
  return result;
}
关键意义
  • 并行加速‌:4 个 Worker 并行处理,2000x2000 图片处理时间从 8 秒缩短至 2 秒
  • 用户体验‌:主线程实时显示处理进度条,避免界面卡死

三、案例 3:实时语音特征分析

场景说明

在语音会议系统中,需要实时分析用户麦克风输入的音频数据,提取语速、音调等特征。连续处理 16kHz 采样率的音频流会严重占用主线程资源。


代码实现
主线程 (main.js)
// 1. 创建 AudioWorklet + Worker 混合架构
const audioContext = new AudioContext();
await audioContext.audioWorklet.addModule('audio-processor.js');

// 2. 创建 Web Worker 进行特征分析
const audioWorker = new Worker('./audioAnalyzer.worker.js');

// 3. 设置音频输入流
navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
  const source = audioContext.createMediaStreamSource(stream);
  const workletNode = new AudioWorkletNode(audioContext, 'audio-processor');
  
  source.connect(workletNode);
  workletNode.port.onmessage = (event) => {
    // 4. 将音频数据发送到 Worker
    audioWorker.postMessage(event.data.audioBuffer);
  };
});

// 5. 接收分析结果
audioWorker.onmessage = (event) => {
  displaySpeechStats(event.data.pitch, event.data.volume);
};
AudioWorklet 处理器 (audio-processor.js)
class AudioProcessor extends AudioWorkletProcessor {
  process(inputs) {
    const inputBuffer = inputs; // 获取单声道数据
    this.port.postMessage({ audioBuffer: inputBuffer });
    return true;
  }
}
registerProcessor('audio-processor', AudioProcessor);
Worker 线程 (audioAnalyzer.worker.js)
self.onmessage = (event) => {
  // 1. 接收原始 PCM 数据
  const audioData = event.data;
  
  // 2. 执行 FFT 和特征分析
  const fftResult = performFFT(audioData);
  const pitch = calculatePitch(fftResult);
  const volume = calculateRMS(audioData);
  
  // 3. 返回实时分析结果
  self.postMessage({ pitch, volume });
};

function performFFT(data) {
  // 使用汉宁窗和 2048 点 FFT
  const windowedData = applyHanningWindow(data);
  const fft = new FFT(2048);
  fft.forward(windowedData);
  return fft.spectrum;
}
关键意义
  • 实时性保障‌:AudioWorklet 在音频渲染线程直接处理,Worker 进行复杂计算
  • 精度提升‌:FFT 分析在 Worker 中可无中断执行,避免音频卡顿

四、Web Workers 的核心价值

  1. 性能维度‌:

    • CPU 密集型任务处理时间减少 60%-80%
    • 主线程 FPS 保持在 55-60 之间(对比未使用时的 10-15 FPS)
  2. 架构优势‌:

    graph TD
    A[主线程] -->|轻量任务| B[DOM 渲染]
    A -->|分发任务| C[Web Worker 1]
    A -->|分发任务| D[Web Worker 2]
    C -->|结果返回| A
    D -->|结果返回| A
    
  3. 线程优化‌:

    • 共享内存‌:使用 SharedArrayBuffer 实现 Worker 间数据共享
    // 主线程
    const sharedBuffer = new SharedArrayBuffer(1024);
    worker.postMessage({ buffer: sharedBuffer });
    
    // Worker
    self.onmessage = (e) => {
      const sharedArray = new Int32Array(e.data.buffer);
      Atomics.add(sharedArray, 0, 1); // 原子操作
    };
    
    • 动态负载均衡‌:根据 Worker 处理速度自动调整任务分片大小

五、注意事项

  1. 通信成本控制‌:

    • 10MB 数据通过 postMessage 的传输时间约 30ms
    • 使用 Transferable 对象后传输时间可降至 0.01ms
  2. 错误处理增强‌:

    worker.onerror = (error) => {
      console.error('Worker crashed:', error);
      restartWorker(); // 自动重启机制
    };
    
  3. 浏览器兼容方案‌:

    <!-- 通过 Blob 动态创建内联 Worker -->
    <script id="workerScript" type="text/js-worker">
      self.onmessage = (e) => { /* ... */ };
    </script>
    
    <script>
      const blob = new Blob([document.querySelector('#workerScript').textContent]);
      const worker = new Worker(URL.createObjectURL(blob));
    </script>
    

了解Nest.js和Next.js:如何选择合适的框架

2025年4月4日 19:58

什么是Nest.js?

Nest.js 是一个用于构建高效、可扩展的 Node.js 服务器端应用程序的框架。它使用 TypeScript 编写,并支持 JavaScript。Nest.js 结合了面向对象编程(OOP)、函数式编程(FP)和函数式响应式编程(FRP)的元素。它的设计灵感来自 Angular,提供了一个模块化的架构,支持依赖注入和面向切面编程等特性。在底层,Nest.js 默认使用 Express 作为 HTTP 服务器框架,也可以配置为使用 Fastify

Nest.js的优点

  • 高可扩展性:适合构建大规模的后端应用。
  • 高性能:支持实时交互应用。
  • 模块化架构:易于维护和扩展。

示例代码

下面是一个简单的Nest.js应用例子:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}

bootstrap();

什么是Next.js?

Next.js 是一个用于构建前端 Web 应用的框架,主要用于需要 服务器端渲染(SSR)静态站点生成(SSG) 的场景。它特别适合需要 SEO 优化 和快速页面加载的应用。

Next.js的优点

  • SEO 优化:通过服务器端渲染提高搜索引擎排名。
  • 快速页面加载:通过静态站点生成减少加载时间。
  • React 集成:与 React 框架无缝集成。

Nest.js和Next.js的选择依据

后端需求

  • Nest.js 适用于构建复杂的后端逻辑、API 服务或微服务。
  • 示例场景:构建一个高性能的API服务,使用Nest.js可以轻松实现。

前端需求

  • Next.js 适用于需要优化前端性能、SEO 和快速页面加载的场景。
  • 示例场景:构建一个需要SEO优化的博客网站,使用Next.js可以提高搜索引擎排名。

技术栈

  • 如果团队已经使用 Node.js 和 TypeScript,Nest.js 是更好的后端解决方案。
  • 如果团队使用 React,Next.js 是更自然的前端选择。

Astro 框架:快速构建内容驱动型网站的利器

2025年4月4日 19:54

什么是 Astro 框架?

Astro 是一种现代化的前端框架,专注于构建快速、以内容为中心的网站,如博客、文档站点和营销网站。它的主要特点包括:

  • 组件驱动:支持多种前端框架(React、Vue、Svelte 等),允许在单个项目中使用多种框架。
  • 静态生成:支持静态站点生成(SSG),在构建时生成 HTML 文件,提高网站加载速度。
  • 零 JavaScript 开销:默认情况下不加载 JavaScript,仅在需要时为交互组件加载 JavaScript,减少不必要的开销。
  • 灵活整合:支持 Markdown 和多种 CMS 集成,适合内容丰富的网站。

Astro 框架的技术原理

Astro 的技术原理基于以下几个方面:

  1. Island 架构:这种架构允许开发者仅为需要客户端交互的组件加载 JavaScript,从而最大化性能。
  2. 视图转换:提供了视图过渡和页面切换的功能,即使在静态网站中也能实现平滑的用户体验。
  3. 出色的开发体验:提供快速的重载、有用的错误消息和丰富的开发文档,确保开发者能够轻松上手并享受编码过程。

Astro 框架的使用场景

Astro 最适合以下场景:

  • 静态网站:如博客、文档站点或营销网站,需要高性能和快速加载。
  • 内容驱动型网站:通过减少 JavaScript 使用提高 SEO 和加载速度。
  • 多框架支持:适合团队使用多种前端框架的项目。

Astro 框架的优势

  1. 性能优势:Astro 的网站在 Google 核心网络指标(CWV)中表现突出,是唯一一个合格率超过 50% 的框架。
  2. 灵活性:支持多种前端框架,适合不同团队的技术栈。
  3. 快速开发:学习曲线较低,适合快速开发和部署的项目。

Astro 框架与 Next.js 的比较

特性 Astro Next.js
最佳用途 内容驱动型静态网站 复杂、动态 web 应用
JavaScript 使用 默认不加载 JavaScript 需要较多 JavaScript
学习曲线 较低,HTML-first 中等,需要 React 知识
框架支持 多种框架(React、Vue、Svelte) 主要支持 React

何时选择 Astro 而不是 Next.js?

  1. 静态网站:如果你需要构建高性能的静态网站,如博客或文档站点,Astro 是更好的选择。

  2. 内容驱动型网站:对于内容驱动型网站,Astro 的岛屿架构可以有效减少 JavaScript 的使用,从而提高页面加载速度和 SEO。

  3. 多框架支持:如果你的团队使用多种前端框架,Astro 提供了更好的灵活性和兼容性。

  4. 快速开发和部署:Astro 的构建速度快,学习曲线较低,适合快速开发和部署的项目。

相比之下,Next.js 更适合构建复杂、动态的 web 应用,尤其是需要服务器端渲染(SSR)和强大的后端功能的项目。

React与Next.js:基础知识及应用场景

2025年4月4日 19:52

什么是React?

React 是一个由Facebook维护的开源JavaScript库,主要用于构建用户界面。它是一个声明式、基于组件的库,通过虚拟DOM提高渲染效率,并支持组件化开发、单向数据流和声明式编程。React的特点包括:

  • 高效的虚拟DOM:React使用虚拟DOM来减少与实际DOM的交互,从而提高页面更新的效率。
  • 组件化开发:React通过组件化的方式组织代码,使得开发和维护变得更容易。
  • 强大的社区支持:React拥有庞大的开发者社区和丰富的文档,易于学习和使用。
  • 易于与第三方库集成:React可以与许多其他JavaScript库和框架很好地协同工作。

React示例代码

import React from 'react';
import ReactDOM from 'react-dom';

function Hello() {
  return Hello, React!;
}

ReactDOM.render(, document.getElementById('root'));

什么是Next.js?

Next.js 是一个基于React的框架,提供了服务器端渲染、静态站点生成等功能,适合需要SEO优化和快速页面加载的应用。Next.js的主要特点包括:

  • 服务器端渲染(SSR):Next.js可以在服务器上预先渲染页面,提高加载速度和SEO效果。
  • 静态站点生成(SSG):适合博客、文档站等内容型网站,通过预渲染静态页面来提高性能。
  • 内置路由和API支持:Next.js提供了文件系统路由和API路由功能,简化了开发流程。

Next.js示例代码

// pages/index.js
import Head from 'next/head';

export default function Home() {
  return (
    
      
        Next.js Demo
      
      Welcome to Next.js!
    
  );
}

何时使用React而不用Next.js?

以下是选择使用React而不用Next.js的情况:

  1. 简单客户端渲染应用:如果你的应用不需要服务器端渲染或静态站点生成,仅需客户端渲染,那么使用React即可。

  2. 离线应用或动态路由:如果你的应用需要离线支持或复杂的动态路由,React可能更合适,因为Next.js主要针对服务器端渲染。

  3. 灵活性和自定义:如果你需要更多的自定义和灵活性,React提供了更高的自由度,而Next.js则提供了适度的灵活性。

  4. 现有项目改造:如果你已经有一个现有的React项目,并不需要服务器端渲染或静态站点生成,那么继续使用React可能更方便。

综上所述,React适合于不需要服务器端渲染或静态站点生成的应用,而Next.js则更适合需要这些功能的项目。

Remix 框架:性能与易用性的完美结合

2025年4月4日 19:49

Remix 是一个全新的全栈式前端框架,旨在为开发人员提供高性能、可维护的 Web 应用程序解决方案。它基于 React,并由 React Router 的开发团队创建,最近被 Shopify 收购。Remix 结合了服务器端渲染(SSR)和客户端渲染(CSR)的优点,提供了统一的路由系统、简化的数据加载方式以及优异的用户体验。

Remix 的核心特点

  • 服务器端渲染(SSR):Remix 默认支持 SSR,这意味着页面在服务器端预渲染后直接发送给客户端,提高了首屏加载速度和 SEO。
  • 客户端渲染(CSR):在客户端,Remix 允许 JavaScript 执行以增强交互性。
  • 路由管理:Remix 支持嵌套路由和动态路由段,简化了复杂应用的构建。
  • 数据加载:通过 loader 函数,Remix 可以在服务器端预加载数据,减少不必要的网络请求。
  • 表单处理:Remix 使用传统的 HTML 表单处理方式,无需 JavaScript 即可提交表单。

使用 Remix 的场景

1. 性能和服务器端逻辑

  • Remix 适合场景:需要极致性能和复杂服务器端逻辑的项目,如实时仪表盘或电商网站,Remix 是更好的选择。它通过预渲染和流式渲染相结合,显著提高首屏加载速度。
  • Next.js 适合场景:Next.js 更适合构建大型单页应用(SPA),其丰富的社区资源和插件生态有助于快速构建功能丰富的应用。

2. 数据驱动应用

  • Remix 适合场景:对于需要处理大量数据的应用,如社交网络或新闻网站,Remix 提供了简化的数据加载方式,可以高效地进行数据管理。
  • Next.js 适合场景:Next.js 适合静态内容较多的场景,通过静态站点生成(SSG)可以预渲染页面,提升加载速度。

3. SEO 和国际化

  • Next.js 适合场景:如果项目需要优化 SEO 或国际化支持,Next.js 是一个更好的选择,因为它内置了静态生成和国际化支持。
  • Remix 适合场景:Remix 虽然不如 Next.js 在 SEO 方面有优势,但它提供了更好的用户体验和数据加载优化。

4. 开发体验和团队技能

  • Remix 适合场景:Remix 简化了路由和状态管理,提高了开发效率,适合团队熟悉 React Router 并需要快速开发高性能应用的场景。
  • Next.js 适合场景:Next.js 拥有丰富的社区资源和插件生态,适合团队需要快速构建功能丰富的应用,并且有大量现成的解决方案可用的场景。

Remix 示例代码

1. 路由和数据加载

// app/routes/index.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export async function loader() {
  return json({ message: "Hello, Remix!" });
}

export default function Index() {
  const data = useLoaderData();
  return (
    
      {data.message}
    
  );
}

2. 表单处理

// app/routes/contact.tsx
import { Form } from "remix";

export default function ContactForm() {
  return (
    
      
        Name: 
      
      Submit
    
  );
}

export async function action({ request }) {
  const formData = await request.formData();
  const name = formData.get("name");
  // 处理表单提交
  return { message: `Hello, ${name}!` };
}

综上所述,选择 Remix 或 Next.js 取决于项目的具体需求、团队技能以及长期维护成本。Remix 适合需要高性能、复杂服务器端逻辑和优化数据加载的项目,而 Next.js 更适合大型 SPA、SEO 优化和静态内容丰富的场景。

Node.js 包管理器:npm vs pnpm

2025年4月4日 19:33

npm和pnpm是Node.js开发中常用的两种包管理器,它们在安装速度、磁盘空间使用、依赖管理等方面有明显的区别。下面我们来详细介绍它们的主要区别和使用场景。

npm和pnpm的主要区别

1. 安装速度

  • npm:npm的安装速度相对较慢,尤其是在处理大型项目时。例如,安装一个包含多个依赖的项目可能需要几分钟的时间。
  • pnpm:pnpm通过并发下载和内容寻址存储,能够显著加快安装速度。例如,使用pnpm安装同样的项目可能只需要几秒钟。

2. 磁盘空间使用

  • npm:npm为每个项目创建一个独立的node_modules目录,这意味着相同的依赖包会被多次复制,导致磁盘空间占用较大。例如,如果你有10个项目都依赖于React,npm会存储10份React的副本。
  • pnpm:pnpm使用一个全局的存储仓库,所有项目共用同一份依赖包。通过硬链接和符号链接,pnpm可以显著减少磁盘空间占用。例如,上述10个项目只需要存储一份React的副本。

3. 依赖管理

  • npm:npm使用扁平化的依赖树,这可能导致“幽灵依赖”问题,即某些包没有在package.json中声明却被使用。
  • pnpm:pnpm采用非扁平化结构,通过符号链接确保依赖关系清晰,避免“幽灵依赖”问题。

4. Monorepo支持

  • pnpm:pnpm对Monorepo支持更好,能够高效处理大型项目。它提供了内置的工作区功能,帮助管理多个包。

何时常用pnpm而不用npm

  1. 大型项目或Monorepo:pnpm的快速安装和高效磁盘空间使用使其成为大型项目的理想选择。
  2. 磁盘空间受限:pnpm通过共享依赖包减少磁盘占用,适合资源有限的环境。
  3. 高性能要求:pnpm的内容寻址存储和缓存机制提供了更好的性能。
  4. 新项目:如果团队不熟悉npm或希望尝试新工具,pnpm是一个不错的选择。

示例代码

安装pnpm

首先,你需要安装pnpm:

npm install -g pnpm

使用pnpm安装依赖

使用pnpm安装一个项目的依赖:

pnpm install

添加新依赖

使用pnpm添加一个新依赖:

pnpm add express

更新依赖

使用pnpm更新所有依赖:

pnpm update

总结

pnpm在大型项目、磁盘空间受限、或需要高性能的场景下更为合适,而npm则适合小型项目或团队已经熟悉其工作流的场景。通过选择合适的包管理器,你可以提高项目的开发效率和稳定性。

前端开发日常工作每日记录笔记(2019至2024合集)

2025年4月4日 18:30

从2019年开始,在工作之余会主动记录一些东西,像每天遇到的问题、感想和学到的新的知识点,后面搭建了博客又开始写博客,也整理和记录了很多的笔记。

2024年12月12日

JWT不需要后端维护登录信息,只需要解密信息,加密用的密钥和盐值在后端,很难被劫持伪造出来。

分布式架构和用户多设备登录的今天,JWT明显过时了,属于PHP、jQuery那个年代的东西了。

jwt 简单的应用可以用,现在基本都是需要多设备、权限控制、过期这些的,即使用jwt还是需要后端再维护一份登录信息,那为啥不一开始就直接 redis +token。

2024年11月1日

垃圾Java与Python对比

  • A:听说Python的第三方库很多
  • B:哪有Java嵌套的空文件夹多啊

AI万能提问公式:角色定位+明确需求+内容提供

2024年7月10日

  • protobuf int64 json 时为什么会被转成 string,int64、int32、js中最大数字、精度丢失
  • 在 vscode 中 ctrl + p 输入文件完整路径可以直接打开文件
  • postman 中可以直接通过 import 导入从浏览器 network 接口里复制出来的请求信息 Copy as cURL(bash)

2023年7月12日

  • vue-check-view,监听元素是否进入视口
  • vuex 可以 modules 和全局共存,还可以用 modules 嵌入子模块
  • bind()方法调用的函数,函数名name属性值前会加上“bound ”前缀:foo.bind({}).name // "bound foo"
  • Chrome 浏览器设置-无障碍-使用文本光标浏览网页,打开这一项网页上点击文本内容时都会出现一个像输入框一样的聚焦光标

2023年6月26日

  • axios 不同版本 Content-Type 的坑:0.x 版本会根据数据类型自己设置 Content-Type,你封装的时候设置的或者传参设置的无效;1.x 版本会先判断有没有自己设置,有设置就取设置的
  • webpack 的 productionSourceMap 设置为 true,可以在浏览器的 控制台 source 里看到项目源代码,会造成项目源代码泄露
  • vue 组件懒加载不止router路由里可以用,在普通页面里也可以用,像页面里的各种弹窗组件就应该用impot()函数动态导入来优化性能
  • uniapp 中用 uni-simple-router 来实现路由功能,在 created 生命周期里用 this.$Route.query 在小程序中拿不到参数(H5中没问题),在 mounted 生命周期里可以拿到,uni-simple-router 官方文档里也有提到这个问题,跨端获取路由信息要在 this.$AppReady.then 里去获取
  • el-form 表单组件里,获取数据直接填充默认值后,输入框填充数据导致假死无法输入,开始还以为是不是 el-select 下拉框组件数据太多导致卡顿,结果最后才发现是绑定的 form 数据变量里没有设置默认的属性,导致双向绑定失效

2023年5月31日

  • 浏览器原生复制文本 navigator.clipboard.writeText 注意要自己 catch 一下,有时会报:DOMException: Document is not focused.
  • python、java 项目注意环境和端口,如果一直启动不起来或者启动了一直访问不了,把端口杀死再重新启动
  • stylus 和 sass 可义通过 @extend 关键字后面直接跟选择器名,继承复用样式,直接 @加上属性名引用自身的样式值(常用的 @width、@height...其实其他的也可以这样用,如:@color)

2023年5月19日

  • 项目依赖安装超时,导致报错可以通过 npmrc/yarnrc 文件配置镜像源和第三方包的地址
  • 项目中尽量不要用 replaceAll 方法,有兼容性问题,在部分浏览器或版本里会报错(即使常用的Chrome也要大于85版本):replaceAll is not a function,替换成 replace 和正则加 g
  • 前端项目本地运行,只要打开控制台一点刷新按钮就一直加载中,开始还以为是电脑、浏览器内存占满了或项目打包配置出问题了啥的,更新浏览器到最新版本、重启电脑都不行,最后才发现是 Network 网络哪里可能是啥时候定位问题选成了 Fast 3G,现在都5G了,3G肯定慢嘛,那个地方要选 No throttling

2023年5月8日

  • element-ui 的消息提示框 alertalert、confirm,如果不用 promise 的 then 和 catch 使用方式,不加 callback 这个参数,点右上角关闭按钮时会报错:uncaught (in promise) cancel
  • element-ui的上传组件el-upload,接口还在上传时直接点击删除会报错:TypeError: reqs[uid].abort is not a function,解决办法:上传中时将 el-uplaod 的 disabled 属性设为 true,就不会出现那个删除图标
  • node版本管理工具 nvm,要安装最新的 20.0.0,要升级到 1.1.11版本,1.1.09版本会报错
  • 用构造函数 new RegExp 构造正则表达式时,一般表达式中需要插入变量时只能用构造函数方式,注意里面的特殊字符需要转义,否则容易报错,如 new RegExp('(+861347)', 'ig') 会报 SyntaxError: Invalid regular expression: /(+861347)/: Nothing to repeat

2023年4月21日

  • 注意 vuex 的 mutations 和 actions 是不支持传递多个参数的,多个参数可以直接放到对象或者数组中作为一个传递进去
  • 复制文本一般用css的 user-select 就能解决,如果在手机上能选择文本但是不弹出系统的右键菜单,也就是无法复制、全选这些,看下是不是阻止了 contextmenu 事件
  • 前端直接下载文件时,注意下载名后面要加上文件后缀,否则浏览器不能自动识别出来的文件是不会自动加上后缀的,需要用户自己下载下来手动加上文件后缀才能正常打开

2023年3月27日

  • svg图标上添加 click 点击事件无效,自己在外面再套一层 span 标签
  • 对象属性key可以重复的教训:methods 里写了两个同名的函数,调试发现逻辑死活不生效,最后才发现一直再第一个里面写逻辑,后面的直接给你覆盖了的,所以配置 eslint 禁止重名定义是个好习惯:eslint.org/docs/latest…
  • 不能直接将带 · 的多级字符串 key 用 [] 去取对象的属性值,会被整个当成一个 key,例:res['data.records'],正确的:'data.records'.split('.').reduce((value, entry) => value && value[entry], res)
  • 路由前置守卫 router.beforeEach 里增加判断逻辑,本地可以部署到线上就一直白屏,最后才发现是里面用到了数组的 includes 方法去判断权限,但是这个方法前面未做空判断,导致路由白屏但是也没有具体的报错信息,排查老半天
  • 遇到客户 bmp 格式图片无法上传,原来是项目里用 canvas 和 img 压缩图片,客户的源图片有问题,导致 img.onerror 报错,用 windows 自带的画图工具另存为一下 bmp 图片就好了

2023年3月15日

  • promise 的reject只能在 catch 里去捕获,否则会报错:Uncaught (in promise)
  • 不要嵌套三元表达式,逻辑复杂了很难看懂
  • 初始化表单复杂数据的默认值,之前都是每个地方自己写一遍,或者用JSON.stringify这些去深度克隆,其实可以直接学vue组件的data函数,每次直接返回一个对象,需要使用的地方直接调用模板的方法就可以了
// 数据模板
  const formTemp = () => ({ name: '', age: null, friends: [] })
  • sql查询数据库:模糊查询手机号 SELECT * FROM address WHERE phone like '152%876'
  • https下不管访问http里的静态资源,还是调http域名下的接口,都是不行的哟

2023年2月21日

  • vue项目中把api这种会不断增大的数据挂在this下并不好,会造成那vue下挂载的数据越来越大,可以按 api/user.js、api/order.js 这样按业务或模块分,页面中按需引入,不要啥都挂载到 vue.prototype 全局上,学习 vue3、react... 这种按需引入,全局一时爽,后期火葬场!
  • react函数组件,函数入口是 props,出口是 html。理解 JSX 语法:遇到 {} 符号内部解析为 JS 代码,遇到成对的 <> 符号内部解析为 HTML 代码
  • axios响应拦截器axios.interceptors.response.use的第一个回调 2xx 范围内的状态码都会触发该函数,第二个超出 2xx 范围的状态码都会触发该函数
axios.interceptors.response.use(function (response) {
    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么
    return response;
  }, function (error) {
    // 超出 2xx 范围的状态码都会触发该函数。
    // 对响应错误做点什么
    return Promise.reject(error);
  })

fetch:下一代ajax,全局window的 fetch() 方法用于发起获取资源的请求。它返回一个 promise,这个 promise 会在请求响应后被 resolve,并传回 Response 对象。需要两个Promise才能获取到接口返回的数据,第一个Promise成功以后,得到的是一个Response对象,它对应服务器的 HTTP 回应;第二个Promise需要根据服务器返回的类型的数据,使用不同的读取方法异步读取数据:

  • response.text():得到文本字符串
  • response.json():得到 JSON 对象
  • response.blob():得到二进制 Blob 对象
  • response.formData():得到 FormData 表单对象
  • response.arrayBuffer():得到二进制 ArrayBuffer 对象

函数可以直接 return 一个 await,跟 return new Promise()一样

2023年2月21日

  • 但 IPv4 网络标准为 localhost 保留了 127.0.0.1 – 127.255.255.255 范围,使用范围内的任意一个 IP 地址会导致相同或相似的方式,所以我们平时启动的前端项目服务,用 http://127.200.1.3:8080 也是可以访问的
  • class类的public公开方法里不能去调用类的静态方法,即静态成员方法不能直接使用非静态成员
  • es6函数结构参数,记得给个默认的空对象结构,否则不传参时会报错:
function createBoy({ name='zhou', age=18 } = {}) {
  console.log(name, age)
}

2023年2月9日

  • map 无法遍历直接 Array(10) 创建的数组,在 JavaScript 内部,数组就是用数字作为键名的对象,当使用 Array 构造函数创建了一个新的数组时,实际上是创建了一个 length 属性等于传给 Array 的参数的空对象,对象中并没有数组对应的索引键(index key)
  • 浏览器页面进入全屏模式 document.documentElement.requestFullscreen(),退出全屏 document.exitFullscreen()
  • console.log 添加样式,%c 处的字符一次匹配后面的样式,网址还可以直接点击
console.log(
  '%c 样式文本一 %c 样式文本二 \n https://www.baidu.com',
   'color: #ffffff; background: #3c9cff; padding:5px 0;',
  'color: #3c9cff;background: #ffffff; padding:5px 0;',
)
  • 阻止浏览器默认的账号密码自动填充,用 hidden 或 display:none 隐藏输入框无效
<!-- #ifdef H5 -->
<!-- <input type="text" style="width: 0; height: 0; position: absolute; z-index: -1;"> -->
<input type="password" style="width: 0; height: 0; position: absolute; z-index: -1;">
<!-- #endif -->

2023年2月3日

  • 自我价值,文档输出,代码,公共组件
  • 技术对公司业务价值的思考
  • 需求与实现的平衡
  • 执行力,不要追求完美,没啥万事俱备,先干就完了
  • 更多的去思考技术能创造的价值,能为公司创造的价值,不能创造出实际价值的代码,即使写得在优雅健壮也一文不值
  • 比你优秀的人,学习人家的优点,不要论资排辈,不要从心理上排斥别人,虚心学习
  • 机会来了就要抓住,不要拖,后面的不一定会更好,把握不住的多张嘴问别人确认,不要靠自己猜
  • 不要把计划告诉别人,把结果给他们看。
  • 慢慢来,比较快

2023年1月13日

  • 注意其他第三方包对项目数据的修改影响,项目中用了一个自己封装的 npm 组件包,里面用到了自己封装的一个菜单组件,每次点击菜单会去把菜单下的操作权限 id 缓存到 sessionStorage 里,项目里再去通过 sessionStorage 里缓存的权限值判断权限,开始在项目中搜索,一直没找到到底是在哪去缓存的权限值,这坑太深了

2023年1月6日

form 表单里只有一个 input 时按回车会触发默认提交事件,导致当前页面刷新,解决方法:

  • ① 多加一个隐藏的 input
  • ② 阻止表单默认事件 @submit.native.prevent

svg 图标上传后在网页中使用有默认颜色无法覆盖,是因为 svg 文件里加了 fill 属性填充了颜色,直接给去掉就可以了,用 Ai 设计或修改svg图标时,不要用导出,直接用储存为 svg,可以避免很多奇奇怪怪的问题

2022年12月23日

  • 查看http请求的协议,打开浏览器 network - 选中接口右键 - 标头选项 - 勾选上:协议,就能在 network 的接口面板上多一栏:协议,里面会显示 http 具体的版本:http/1.1、h2
  • 输入字符用逗号空格切割成数组:xxx.split(/[,, ]/).filter(m => m)

2022年12月16日

  • 项目依赖问题,可以直接去查看 node_modules 下的安装包源码,从里面的package.json、readme.md文件中就能发现很多有用信息
  • 二次封装组件时,可以利用 attrsattrs 和 listeners 往下透传属性和事件,不用自己一个一个地去写
  • 组件 props 如果传递的是引用数据类型,因为是传递的地址,所以其实是可以直接去改变里面的属性值的

2022年12月14日

  • 父组件调用子组件方法,除了通过 ref 获取到子组件实例再调用,也可以在子组件中通过监听一个 props 变量值去触发,如在父组件中切换 Tab 选项卡获取对应 TabItem 子组件的数据,就可以在组件中 watch 当前选中的 Tab。还可以利用 parentparent 和 children
  • windows cmd 终端命令不区分大小写,Linux 上区分大小写
  • .m-class:nth-of-type 类名加上 nth-of-type 去选择同类名元素坑:和咱直觉相悖,并不是选中具有 m-class 类名的第 n 个元素,就是要先数标签(第 n 个),然后再是那个类名就选中了,所以要选中类名中的第 n 个,要标签也一样,而且和里面的其他标签区分开:
    • ① 这个元素是它的父元素下同一个标签名的元素中的第 n 个;
    • ② 要选中的元素的类名是class。
  • 想测试 safari 浏览器兼容性,没有 mac 笔记本时,可以在苹果手机上打开 safari 测试
  • overflow-y:auto 设置滚动条没超过最大高度也出现滚动条,有可能是父元素的 box-sizing 导致的
  • display想同时使用 flex 和 inline-block,用 display: inline-flex; 就行了

2022年11月30日

  • windows下 win+R,然后输入 recent 可以看到电脑上的操作记录
  • 小程序设置剪贴板内容 uni.setClipboardData,uni 文档上说微信小程序在成功回调success里设置toast可覆盖自带的轻提示,实测无效
  • 浏览器的 console 控制台里也可以用终端命令里一样的 ↑↓ 箭头切换历史输入
  • 浏览器提供的地理位置接口:HTML5 Geolocation 和 navigator.geolocation

2022年11月28日

  • 前端用 join 去拼接数组内容做页面展示时,自己判下空,否则会出现连续的分隔符情况
  • iframe 外层盒子不加 overflow: hidden,外面盒子 overflow-y 的滚动条设置会出问题
  • 段落文字记得加 word-break:break-all,否则全英文或者数字不会换行

2022年11月24日

  • 小程序动态 style 以对象形式绑定 uni 编译到微信小程序会变成 ,uni 文档上有说:小程序端不支持 classObject 和 styleObject 语法 uniapp.dcloud.net.cn/tutorial/vu…
  • uniapp 开发工具 HBuilderX 云打包安卓app时,manifest.json 配置文件里 加上 "ios" : { "dSYMs" : false } 这个后,打包一直无反应,去掉就好了
  • 有时一不小心会把 vscode 搞成全屏模式,windows 上直接按 F11 就可以退出全屏
  • 注意 git clone 和 git checkout\pull\fetch ... 这些命令的操作目录,后面的这些都要先进入项目目录才能操作,否则会报错
  • 渐变样式 UI 在 sketch 软件中拉渐变时,渐变轴可能不会按照边界拉,往往会超出形状边界,但是蓝湖里标注图上的渐变样式会直接按照整个渐变轴来取色,可能中造成最终渐变样式差异较大,所以最好前端自己去吸边界上的色值,太复杂的渐变可以找UI提供更详细的一整个形状为边界的色值
  • 微信小程序静态资源会被缓存,明明服务器上已经更新了,但是小程序里还是老的,解决办法资源路径后面带时间戳,或者自己清理微信小陈新股缓存:我 - 设置 - 通用 - 储存空间
  • slot 插槽简要写法:
<template #action="{ row }"></template>

<!-- 对应之前的 -->
<template slot="action" slot-scope="{ row }"></template>
  • uni 项目中容易误用的方法,pc端常用的router(这个已经被uni官方占用了,所以其他路由插件都大写R)和其他插件的router(这个已经被uni官方占用了,所以其他路由插件都大写R) 和其他插件的 Router,MessageMessage 和 tips 封装的提示组件

2022年11月22日

  • 开发:历史问题不用管,脏数据不用管,测试:一天都是不用管,这也不用管那也不用管
  • 命令行输入 winver 可以弹出 windows 版本,查看系统信息 systeminfo
  • vscode 里的终端命令可以右下角终端面板的 + 图标,同时开启多个服务,也可以选择不同的终端命令,如果windows安装了wsl,还可以直接用 linux 命令(不过要配置对应的node环境这些才能用),对于用 uniapp 这种多端框架比较有用,比如同时开启小程序打包和H5打包,可以同时调试两个端,一次修改,端端调试
  • iview select 组件单选,如果值是 number,初始值为 null,resetFields 重置表单会有bug
  • iview 的 Table 里有异步操作项,可以直接用 Table 的 loading 来让整个表格处于加载状态中,防止用户多次操作,Table 也有提供 loading 的 slot 可以自定义加载提示内容
  • const 常量建议采用全大写的命名,单词以_分割
  • v-if 里面判断有 + 号,uni打包到微信小程序里会报错:Bad attr wx:if with message: unexpected token +
<template>
  <p v-if="+status === 6" >周小黑</p>
</template>
<script>
  export default {
    data() {
      return {
        status: '6'
      }
    }
  }
</script>

2022年11月14日

  • vscode 插件安装目录:C:\Users\Administrator.vscode\extensions
  • 阿里云效 git 添加的 ssh 密钥配置文件夹:C:\Users\Administrator.ssh,里面有这2个文件 id_rsa、id_rsa.pub
  • git bash 操作的历史命令在这儿:C:\Users\Administrator.bash_history
  • 后端 post 接口用 postman 调用,如果没有文件上传,一般都用的 Body-raw-JSON,有文件上传才用的 form-data
  • uniapp 中 ref 属性非 H5 端只能用于获取自定义组件,不能用于获取内置组件(如:view、text),文档上有写:uniapp.dcloud.net.cn/tutorial/vu…
  • 字符编码问题,php-jwt 封装 token 时,测试解码 JWT::decode 一直报错,因为 token 字符我直接复制粘贴的在控制台打印出来的字符,可能编码不同,换成直接获取到 token 变量就好了,在小黑窗和PowerShell中右键 -属性 -选项,可以看到下面的当前代码页:936(ANSI/OEM - 简体中文 GBK),所以不能直接复制终端命令里的字符瞎用呀。
  • 微信小程序开发者工具从下面“固定任务栏”打开时,有时有各种奇奇怪怪的问题,比如:调试控制台下面的 Wxml、Console ... 这些菜单一直出不来,尽量自己直接从开始菜单打开
  • vuepress 的 markdown 文件中你可以直接写 html、script 和 style,对于 style 你还可以直接写 stylus 语法,因为 vuepress 已经内置了 stylus 和 stylus-loader(注意是1.x版本才内置了,最新的2.x版本木有),vuepress.vuejs.org/zh/guide/us…
  • 【此问题最后项目排查不是谷歌浏览器的设置问题,此笔记有误】异步代码中用 window.open 打开新页面,最新的谷歌浏览器会阻止打开,要在浏览器右上角 ... - 设置 - 隐私设置和安全 - 网站设置 - 弹出式窗口和重定向 - 网站可以发送弹出式窗口并使用重定向,当然这只是个临时方案。一般通过用户的点击事件触发的在新标签页中打开链接,浏览器是不会拦截的,因为这种形式打开新窗口浏览器会认为是用户自己需要的,ajax异步请求成功后需要在新窗口中打开返回的url地址,使用window.open()会被拦截,因为这种情况下浏览器认为该操作不是用户主动触发的,所以会拦截
  • uniapp 中 这种动态组件写法,只有 H5 和 APP 支持,微信小程序不支持,uniapp.dcloud.net.cn/tutorial/vu…
  • input type="radio" 取消选中要放在 setTimeout 或 $nextTick 中才有效
  • 基于 async-validator 插件的表单的重置校验方法 this.$refs['form'].resetFields(),会对整个表单进行重置,将【所有字段值重置为空】并移除校验结果,所以新增的时候其实不用自己在手动重置一遍表单数据了,直接 resetFields 重置校验就可以了

2022年11月02日

  • 前端路径参数超过3个让后端存库,最好都只携带一个 id 参数,否则后期就是给自己埋坑
  • 查询get请求参数超过2个改用 post 请求,参数放 body 里
  • 状态名枚举尽量不要前端直接写死,最好让后端处理好,前端直接负责显示
  • 小程序 uview 弹窗里内容内部滚动,需要用 scroll-view,如果内容里是 html 标签,要去掉 \r\n 否者会造成行首缩进,行尾文字被截掉部分bug
  • vscode 中如果文件夹中只有一个文件夹会在一行上显示,查看文件感觉别扭也不方便,修改首选项-设置,搜素 Compat Folders,去掉默认的勾选
  • 尽量不要用 JSON.parse 和 JSON.stringify 来深度克隆数据,这两货不能克隆 Function 和 Date
  • css 多行文本截取显示省略号,盒子里不要用 padding 来设置上下间距,否则截取的部分也会在 padding 上展示出来
  • '' == false 结果为 true,但是注意 '0' == false 结果也为 true,js 中字符串和布尔值进行比较的时候会隐式转换,会先把布尔值转成数值,前面的也就是 '0' == 0
  • 正则中 \d \n \w \s 这些不用加转义符
  • 浏览器记住账号密码,查看实际密码:① 去浏览器里输入开机密码,查看具体的真实密码 ② 直接 F12 将 input 的 type 属性由 password 改成 text
  • 带输入操作的弹窗,记得禁止掉弹窗点击蒙层关闭的功能
  • 可选链操作符 ?. 目前处于Stage 3提案阶段,暂时不可以直接使用,需安装 @babel/plugin-proposal-optional-chaining,但是呢在 node 12 以上版本中是可以直接使用的
  • 一般代码重构或者功能改动较大时可能会重新调整文件目录,git 调整文件路径尽量用 git mv 提交,保留原始提交记录,可以先将要调整的步骤理出来,再开始分步骤调整
  • 提交后台的数据记得去除首位空格 trim,多选用逗号、空格去分隔参数时记得过滤下空数据的(如连续敲几个空格、逗号就会产生空的数据)
  • RegExp 这个对象会在我们调用了正则表达式的方法后, 自动将最近一次的结果保存在里面, 所以我们可以直接使用 RegExp.1RegExp.1 到 RegExp.99
  • dayjs 格式化时间时 format,注意大小写的区别,如 dayjs().format('YYYY-MM-DD hh:mm:ss'),会被格式化成12小时制 2022-10-2512:00:00
  • 重置页面初始数据:Object.assign(this.data,this.data, this.options.data.call(this))
  • process.cwd():current work directory,当前工作目录的路径,就是你当前运行小黑窗的目录
  • __dirname:当前执行的 js 脚本的目录
  • __filename:当前执行的 js 脚本的带着文件名的完整路径
  • computed 计算属性里不能监听 localStorage 和 sessionStorage 的数据变化。要监听 localStorage,自己封装监听事件,或者可以用 vuex 结合来实现

2022年9月21日

小程序分包的时候注意,import 导入资源时,只能分包引用主包的,不能反过来主包导入分包的,因为分包一开始并不会被加载。

小程序分享用 uni 在路径中带参数,用模板字符串直接【this.变量名】一直undefined,需要自己先用个变量存一下,在写到模板字符串里就没问题了。

2022年9月13日

iview 的 AutoCompelete 自动完成组件, 如果想要在无搜索关键词时默认匹配所有下拉选项, 可以设置完整的数据源 data, 然后前端再利用提供的 filter-method 方法来绑定前端搜索过滤方法, 这样就可以实现既可以输又可以选, 为空还可以展示出所有的选项.

Select\DatePicker 之类的组件放在弹窗中, 为了不撑开布局加上 transfer, 滚动的时候会导致下拉框不收回固定在页面上错位, iview 有提供一个 events-enabled(4.6.0, 是否开启 Popper 的 eventsEnabled 属性,开启可能会牺牲一定的性能), 这个东西就是 Popper 会在窗口变化会页面滚动的时候会实时去重新计算位置, 会有明显的卡顿, 而且元素滚动出了视线区下拉框并不会消失, 会更奇葩浮在弹窗外, 所以这个属性其实也没多大用. 之后的解决办法是通过 vue 的 scroll 事件去监听滚动再模拟点击, 让下拉框自动收回.

2022年8月9日

javascript 省略花括号{}的几种表达式:

/**
 * if 简写
 */
let a = 1
let b = 2
if(a > b) console.log('a')
if(a < b) console.log('b')
/**/
if(a > b) console.log('a')
else console.log('b')

/**
 * for简写
 */
for(let i=0; i<10; ++i) console.log(i)

/**
 * while简写
 */
while (i > 10) console.log(i)

2022年7月29日

一直感觉C盘老容易满,160G空间感觉也不小,之前一直觉得是不是各种开发工具装太多了,所以占硬盘比较大,但是大部分的软件都是装在D盘的,今天闲逛C盘偶然发现:C:\Users\Administrator\AppData\Local\微信开发者工具,这玩意居然占了30多个G,果断直接删了,企鹅这东西也太辣鸡了。

2022年6月20日

页面路由守卫 beforeRouteLeave(to, from, next),在页面子组件中并不会触发

2022年6月1日

  • 小程序上拉加载更多每次push数据,带参数过来记得先清空数据和查询条件
  • onLoad、onShow、mounted、created这些生命周期初始化数据,尽量放到一个里处理,否则也会造成重复
  • 弹窗里渲染富文本内容,如果UI弹窗宽度太小图片容易被压缩显得模糊(评需求的时候注意有相关的报价表格类的图片需要比较情绪的,可以考虑单独上传图片或弹窗宽度不能太小)
  • 小程序用 img 图片标签裁切属性mode无效,要用 image
  • 小程序textarea放到弹窗里,默认的placeholder设置无效,要先设置个值然后清空才会生效,hack:弹窗弹出先赋值,然后再用定时器清空
  • mp-vue和uni-app小程序里路由里的参数,要去onLoad生命周期里取this.$route.query,直接在data里这样赋值会是undefined

uniapp打包命令

"build:mp-weixin-test": "cross-env VUE_APP_ENV=test NODE_ENV=production UNI_PLATFORM=mp-weixin vue-cli-service uni-build --minimize --watch",

// UNI_INPUT_DIR=指定源代码绝对路径
// UNI_OUTPUT_DIR=dist/release/h5
  • uni-app组件绑定props的时候,不能用data开头的驼峰(如 dataList ),data-是原生小程序在节点上绑定参数的方式
  • uni-app 新项目运行 stylus 报错,可能是 stylus-loader 版本太高,可以降到 @3.x.x 试下,uview-ui 2.x 版本cli在 vue.config.js 里配置了 transpileDependencies: ['uview-ui'] 还是提示报错,可能是 node 版本太高,降到 @12.x 试下

网络图片转base64报跨域错误前端解决方案:

  • 1、要求图片资源服务器端已经设置了允许跨域访问,否则下面的都是无用功
  • 2、设置 img.setAttribute('crossOrigin', 'anonymous')
  • 3、图片url缓存还是会偶尔报跨域,所以在路径参数后面加个动态参数
img.src = url + `${url.includes('?') ? '&' : '?'}` + new Date().valueOf()

参考:stackoverflow.com/questions/4…

this.$wj.cookie('inviteCode', query.inviteCode, { expires: 7 })
this.$wj.cookie('inviteCode', '') // 有一个过期参数 expires,直接这样清除无效

让浏览器滚动条不占用页面宽度(不会挤压布局0):overflow:overlay。行为与auto相同,但滚动条绘制在内容之上而不是占用空间。 仅在基于WebKit(例如,Safari)和基于Blink的(例如,Chrome或Opera)浏览器中受支持。

iview modal弹窗里放select下拉框组件,不加transfer属性有时下拉框层级会出问题,被弹窗挡住了。

iview的form表单校验:

  • 1、form表单中带有prop属性的子组件进行校验规则绑定时,是在vue声明周期mounted生命周期里完成的,如果想用v-if来动态控制表单项会出现校验错误失效的问题,动态动态校验同样也会出现这个问题,iview官网的动态表单校验是通过额外的一个status标识去判断该不该渲染(不是直接去splice数组,如果后面要提交参数还要自己再去filter一下),要解决这个校验出错的问题可以直接用 v-show 去控制动态表单,然后自己自定义 validator(不过required参数好像不能动态修改,待再验证)
  • 2、注意 v-show 控制显示隐藏校验会生效,因为校验域其实还在页面上

抖音小程序获取用户手机号的api需要企业主体,而且要先申请审核通过了才可以用

2022年5月21日

和js一样弱类型的php也是一样的结果,注意:

console.log('0' == false) // true

2022年3月9日

user-select 属性规定是否能选取元素的文本

  • auto默认。如果浏览器允许,则可以选择文本。
  • none防止文本选取。
  • text文本可被用户选取。
  • all单击选取文本,而不是双击。
    有时我们加了user-select all让单击选中文本,但是想按钮之类的并不需要选中,需要设置user-select none

flex 布局会将直接子元素是行内元素的自动转换成块元素(可以在Chrome浏览器调试面板“计算样式”里看到变化),transform动画对行内 inline 元素无效,要转成 inline-block 或 block(前一点flex布局会自动转成block,所以有时不容易发现这个问题) 文件名中尽量不要包含中文和一些特殊字符,有个文件名中包含好几个\,从github上克隆仓库时就一直报错,应该是/被判断成路径了,找不到此文件所以一直报错:invalid path,实在要用应该要加个转义符的\,像这样 \

文字按钮加 hover 效果字体变粗,整体宽度会边长,造成布局抖动,找到下面两个

  • ① 可变字体,部分浏览器不支持,font-variation-settings,需要字体支持,developer.mozilla.org/zh-CN/docs/…
  • ② 用字体阴影来模拟加粗,视觉上看着稍微有点不足 text-shadow -0.06ex 0 black, 0.06ex 0 black
  • ③ 用伪元素来将字体加粗,原本的字体颜色改为透明或和背景色一样,伪元素 content 想取到文字内容,直接在元素上绑定一个属性如 data-hover="我是文字",content: attr(data-hover) 就能取到了

iview里的 menu 组件如果是动态去设置的菜单列表数据,在子菜单里刷新当前页面默认自动折叠起来了,open-names 有值也不生效,要自己用这个方法 updateOpened 手动展开下

windows

  • 刷新本地DNS缓存:ipconfig /flushdns
  • 显示dns缓存:ipconfig /displaydns

程序员沟通很重要

  • 大家是一个团队,自己很久也解决不了的问题可以找同事、领导协助解决
  • 大家的目标都是一致的,齐心协力把产品做好,说话的艺术:
  • 1、没有这么做的、没见过这样的 => 我建议是xx这样比较好,你可以去对比下,参考下
  • 2、咱们不支持、没有这些功能 => 这个因为咱现有的系统没有,需要额外开发,我的建议是在目前资源比较紧张的情况下可以先xx
  • 不要带着个人情绪工作,高情商,脾气发出来是本能,压下去是本事

空值合并操作符(??) 是一个逻辑操作符,与逻辑或操作符 || 类似,不过只有 ?? 左侧为 null 或 undefined 时,才会返回右侧的值,而 || 只要左侧是 false、null、undefined、0、NaN、'' 这6个都会返回右侧的值

2022-05-16T18:35:48.906Z 这种格式是 UTC世界时间,与北京时间相差8个时区,格式化时要用 dayjs.utc,直接用 dayjs 格式化要减去 8 个小时才对

dayjs.utc().format()   // 2022-05-18T01:54:50Z
dayjs().format()        // 2022-05-18T09:54:50+08:00
new Date()              // Wed May 18 2022 09:54:50 GMT+0800 (中国标准时间)

excel 制作模板要固定表头:视图 - 冻结窗格,禁止编辑单元格:

  • ① 点左上角全选整张表,设置单元格格式 - 保护,取消掉勾选“锁定”
  • ② 选中要禁止的单元格,设置单元格格式 - 保护,勾选上“锁定”
  • ③ 审阅 - 保护工作表 -设置密码(下面的那些可以都勾选上)

2021年11月11日

在js里其实是可以用中文做变量名,但是一般不建议使用超出ASCII码的字符,unicode水很深

  • unicode:统一码(万国码、单一码),是国际组织制定的可以容纳世界上所有文字和符号的字符编码方案
  • ASCII码:大多数计算机采用ASCII码(美国标准信息交换码),它是表示所有大小写字母、数字、标点符号和控制字符的7位编码方案。统一码(Unicode)包含ASCII码,'\u0000'到'\u007F'对应全部128个ACSII字符

InputElement,输入元素是JS词法扫描程序拿到的最基本元素:

  • WhiteSpace
  • LineTerminator
  • Commit
  • Token(Punctuator、(IdentiferName:Keywords、Identifer(变量名、属性名)、Future reserved Keywords)、Literal)

构建知识体系,前端技能模型:

  • 领域知识
  • 前端知识
  • 编程能力
  • 架构能力
  • 工程能力

箭头加个新的文件名,会直接生成这个新的:

babel demo.js >newDemo.js

2021年11月10日

交互小优化:

  • 1、能点击的地方加上 cursor pointer,
  • 2、表单输入选择框加上可清除属性 clearable(ivu的select选中后没法清除,加了filterable支持搜索敲键盘上清除键删完了失去焦点又会默认选到之前的选项,所以对于一些非必填选择框要加上clearable让用户可以删除),
  • 3、input输入框注意自动去掉首尾的空格 v-model.trim,maxlength限制最长输入
  • 4、对于段落文字加上 text-align: justify 排版更整齐
  • 5、小程序适配字体:段落文字可以直接写px,像标题之类的描述文字用rpx,适配出来不同屏幕更符合UI效果图

2021年11月9日

iview校验一直不通过:prop的字段要先写到form里的,不能是动态添加的(vue不能监听到动态新增的属性???)

2021年9月2日

用v-html渲染富文本,里面的style标签样式会影响到页面里的样式

// 富文本内容,过滤掉js和style标签(style里的样式会影响到全局)
if (key === 'cost_channel_reparation_rule' && value) { // 赔付方案
  let regJs = /<script[^>]*?>[\s\S]*?<\/script>/ig
  let regStyle = /<style[^>]*?>[\s\S]*?<\/style>/ig
  let val = value.replace(regJs, '').replace(regStyle, '')
  tableItem[key] = val
}

2021年4月16日

后端把Long类型数据传给前端,会出现精度丢失的问题

let n = 1290850452280250366  
console.log(n)  
1290850452280250400

像列表id之类的,要让后端返字符串,数值类型过大会出现精度丢失,id错误问题

2021年03月23日

狗日的,又废接口:wx.getUserInfo => wx.getUserProfile <button class="btn-login" open-type="getUserInfo" :data-mtaid="'login.button.submit'" @getuserinfo="getWxUserInfo">登录

<button class="btn-login" @click="getWxUserInfo">登录

// 获取用户微信信息
getWxUserInfo() {
    let _this = this
    wx.getSetting({
        success (res) {
            if (res.authSetting['scope.userInfo']) {
                // 已经授权
                wx.getUserInfo({
                    success: function(res) {
                        _this.wxUserInfo = res.userInfo
                        _this.$storage('wxUserInfo', res.userInfo)
                        _this.setWxUserInfo()

                        _this.login()
                    }
                })
            } else {
                _this.$tips.toast('为了更好的使用体验,请允许授权!')
            }
        }
    })
},

getWxUserInfo() {
    wx.getUserProfile({
        lang: 'zh_CN',
        desc: '登录后个人信息展示',
        success: res => {
            this.wxUserInfo = res.userInfo
            this.$storage('wxUserInfo', res.userInfo)
            this.setWxUserInfo()

            this.login()
        }
    })
}

2021年02月10日

iview的select组件开启远程搜索后,直接放在用modal组件封装的弹窗内,弹出弹窗回显默认值的时候,界面上不显示,要在外面用v-if去控制modal显隐的时候才会有效(周颖发现的,原因后面有空探究下)

2021年1月11日

上个月替换加盟商小程序接口 api.51.xxx.com => api-51.xxx.com,忘记在小程序管理后台添加服务器域名了,小程序修改任何东西都要提交给测试验证(测试、线上)。

2020年11月26日

vue文档上有说:cn.vuejs.org/v2/guide/co… default 数组/对象的默认值应当由一个工厂函数返回,如果你的 type 是 Object,你需要这么写

default: () => ({})

而不是

default: () => {}

不加括号的话应该是一个空函数体,没有返回值了

2020年11月2日

用伪元素渲染单选图标

.radio
    margin-right 30rpx
    font-size 28rpx
    color #333333
    &.on
    font-weight 500
    &:before
        color #1890FF
        content "\e84b"
    &:before
    display inline-block
    font-family "iconfont" !important
    font-size 16px
    font-style normal
    -webkit-font-smoothing antialiased
    -moz-osx-font-smoothing grayscale
    content "\e823"
    margin-right 8rpx
    color #979797

2020年3月31日

iview的table组件添加固定列 fixed:'right',如果第一列宽度 width: 60(大于和小于都没事),会造成右边fixed固定列错位

2020年3月30日

iview的 InputNumber组件同时限制 :min="2000"和:max="6000",默认值一变化就会去验证(on-change),操作体验超级差,值来回在2000和6000切换。不用min和max限制,在失去焦点时on-blur里校正又没法监听到步骤器的加减

解决方案:用min和max限制,添加一个属性 :active-change="false"

(iview文档里又说明,InputNumber组件属性的最后一个:active-change是否实时响应数据,设置为 false 时,只会在失焦时更改数据),就会在失去焦点时才会去验证是否符合最大最小值

2020年3月19日

下载流文件

let blob = new Blob([res], { type: 'application/vnd.ms-excel;application/octet-stream' })
let href = URL.createObjectURL(blob)

2020年3月18日

  • iview开关组件 switch 不要使用switch 或 Switch 作为组件名,如果没有使用 iview-loader 时会报错
  • vue组件名不能乱起,组件名不能是html标签,svg标签等,还不能是component和slot,注意,其中svg标签和component,slot是大小写都不允许存在(例如,Switch,SWITCH都不行);html标签是不能存在小写,大些可以(例如,button不行,Button可以)

2020年3月13日

iview Input组件on-change事件默认返回当前值,如果要在事件中传递自己的参数,$event就代表默认的参数:

@on-change="onChange"                   // 不传参数第一个参数就是默认的当前值 onChange(val) { console.log(val) }
@on-change="onChange(item, $event)"     // 第二个参数是默认的当前值
@on-change="onChange($event, item)"     // 第一个参数是默认的当前值

2020年3月12日

vee-validate表单验证插件

表单项双向绑定的初始值要保证为一个空字符串或默认值,不可以是 undefined,否则再blur、change事件里绑定验证的时候第一次必定返回“false”,要第二次触发验证才恢复正常

// 不要
formData:  {}

// 要
formData:  {
    name: '',
    age: ''
}

父子组件传值 sync 修饰符

2020年3月10日

  • position: sticky,可以做粘性布局,吸顶交互效果
  • v-click-outside-x:vue插件,可以监听元素外部得事件(如iview里的Poptip 气泡提示组件,点击元素外部区域,关闭当前组件)

vscode 快捷键

    1. 折叠所有区域代码的快捷: ctrl + k ctrl + 0 ; 先按下 ctrl 和 K,再按下 ctrl 和 0 ; ( 注意这个是零,不是欧 )
    1. 展开所有折叠区域代码的快捷:ctrl +k ctrl + J ; 先按下 ctrl 和 K,再按下 ctrl 和 J

2020年3月6日

在渲染多个元素时候可以把一个template元素作为包装元素,并在上面使用v-if进行条件判断,template最终不会被渲染

  • 注意:v-show不支持 template 语法(注意,v-show 不支持 template 元素,也不支持 v-else。关于这一点vue官方文档有说明cn.vuejs.org/v2/guide/co…

2020年3月4日

justify-content: space-around;    /* 两边的间距是中间间距的1/2 */
justify-content: space-evenly;    /* 所有间距相等 */

2019年12月31日

清除缓存

npm cache clean --force
yarn cache clean

2019年12月25日

用vue-scrollwatch模拟鼠标滚轮滑动一下 滚动一屏

window.addEventListener('mousewheel', this.setScrollTop, { passive: false })
window.addEventListener('DOMMouseScroll', this.setScrollTop, { passive: false }) // 兼容firefox

如果把浏览器的缩放比调整了(不是正常的100%),会出现监听错误,造成滚动错乱(害我找了半天bug)

2019年12月24日

  • 动态路由传参 query 和 params
  • param -> params

params传参的坑

  • 1、要在路由定义的时候带上参数 path: 'user/:id'
  • 2、this.$route.push的时候只能用name,而且要和定义路由时的name对应
  • 3、params 这个单词别拼错了

2019年12月18日

全数字时间日期格式化(正则替换)

let date = '20191218175924'.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
console.log(date) 2019-12-18 17:59:24

时间戳转格式要是数值类型的才行,字符串的会NaN

new Date('num')   ->  NaN

2019年11月14日

限制弹窗高度

// 方法一:
max-height 80vh
overflow auto

// 方法二:
mounted() {
    let h = window.innerHeight
    this.screenHeight = h - 500
},
// 给元素动态绑定最大高度
:style="{maxHeight: screenHeight+'px', overflow: 'auto'}"

2019年11月6号

  • 不能这样判断 this.arr === [],复杂类型全等比较的是类型和引用地址,可以这样判断 if (!this.arr.length)
  • map方法不能用来过滤掉数据,过滤用filter

2019年10月17号

  • window.open在vue中跳转会自动添加本地服务器的地址,需要完整的https才可以,http也不行
  • window.open('www.baidu.com','_blank')

2019年9月20日

css :not选择器性能不好?

not选择器排除列表元素最后一个会造成页面抖动,在弹出框里特别明显,它的规则是先全部选出来在排除,在v-for的外面层再加一个判断:v-if="data.length",就不会出现抖动了

Collapse 折叠面板用v-for异步请求渲染的数据,第一次进入会有bug,不管点哪个都会展开第一行,要点第二次后才会恢复正常,也是在外面层再加一个判断:v-if="data.length",就不会有这个bug了

iview框架table的行禁选,动态渲染render

v-for和v-if同时使用

Vue报错 Duplicate keys detected: 'xxx'. This may cause an update error.

  • 情况一、错误信息展示为关键字‘keys‘,此时应该检查for循环中的key,循环的key值不为唯一性 (很普通)
  • 情况二、有两个相同的for循环,而这两个for循环的key值是一样的,此时可以将一个的key值加一个数字或者加一个字符串

2019年9月18号

ivew修改组件的默认样式必须用深度选择器才有效 >>>

2019年9月5号

所有用到的组件要在plugins文件夹下的iview.js里注册引入

2019年9月3日

超出可以滚动并且隐藏滚动条

.box {
   overflow-x: hidden;
   overflow-y: scroll;
}
.box::-webkit-scrollbar {
   display none
}

伪元素的父盒子设置了 z-index后,伪元素的z-index会失效,所以如果用 ::after来给盒子做阴影时,只用给伪元素设置z-index为负数就可以了

2019年6月18日

margin-left: auto 块级元素右对齐

如果想让某个块状元素右对齐,脑子里不要就一个 float:right,很多时候,margin- left:auto 才是最佳的实践,浮动毕竟是个“小魔鬼”。甚至可以这么说 margin 属性的 auto 计算就是为块级元素左中右对齐而设计的,和内联元素使用 text-align 控制左中右对齐正好遥相呼应!

巧妙互换变量的值

如果不用第三个变量,如何交换两个数值变量的值?

var a = 9;
var b = 20;
b = a + b;
a = b - a;
b = b - a;
console.log(a, b);

还有下面这个十分的巧妙解法,想出来的人一定是大神,除非他是托梦想出来的,简单粗暴一行代码交换了a和b的变量值:

var a = 9;
var b = 20;
a = [b, b=a][0];

根据运算符优先级,首先执行 b=a,此时的b直接得到了a的变量值,后一步数组索引让a得到了b的值(简直不能再厉害)。

2019年5月7日

mac笔记本快捷键

安全与隐私里显示出:任何来源

  • ① 打开终端
  • ② 输入命令行语句:sudo spctl --master-disable
  • ③ 回车后,输入密码,再去打开安全与隐私里,就选到任何来源了 显示出隐藏文件,快捷键:command + shift + .

UI笔记

切图命名以模块为前缀,如:模块_类别_功能_状态.png

模块:

  • 登陆页面(login)
  • 公共(common) 
  • 需求a(need)
  • 需求b(demand) 
  • 发现(discover)
  • 消息(message)
  • 我(me)   类别:
  • 导航栏(nav)
  • 菜单栏(tab)
  • 按钮(btn)
  • 图标(icon)
  • 背景图片(bg)
  • 默认图片(def)   功能:
  • 菜单(menu)
  • 返回(back)
  • 关闭(close)
  • 编辑(eidt)
  • 消息(message)
  • 删除(delete)   状态:
  • 选中(selected)
  • 不可点(disabled)
  • 按下(pressed)
  • 正常(normal)

人人都是码农:AI时代,零基础也能学会编程!关于作者:从美工、前端开发一路成功转型Java后端的野生码农 ‍,分享UI转前端、前端转Java、全栈开发、AI人工智能和码农搞钱副业...

前端开发中JavaScript、HTML、CSS常见避坑问题

2025年4月4日 18:08

javascript

try catch捕获异常

JSON.parse、JSON.stringify、encodeURIComponent、async await 要放到 try catch 中。

new RegExp 最好也放到 try catch 中,用构造函数 new RegExp 构造正则表达式时,一般表达式中需要插入变量时只能用构造函数方式,注意里面的特殊字符需要转义,尤其是之际接收用户输入的字符,否则容易报错,如:

new RegExp('+861347', 'ig')

// 会报 SyntaxError: Invalid regular expression: /(+861347)/: Nothing to repeat

数字精度问题

接口数据中比较长的 id 要用字符串,否则大数字会出现失精导致 id 对不上。

浮点数运算失精,涉及到计算的用number-precision这类计算库。

时期时间

macOS 系统下日期时间格式要用斜杠 / 代替横杠 -。

资源访问

https 下访问 http 资源会报错

replaceAll 替换字符串中内容

项目中尽量不要用 replaceAll 方法,有兼容性问题,在部分浏览器或版本里会报错(即使常用的Chrome也要大于85版本):replaceAll is not a function,替换成 replace 和正则加 g。

for循环中间的分号

for循环里的条件这个地方中间的是分号,不是逗号

for(var i = 0; i < 100; i++) {  
}

等号判断相等

判断是不是相等是用 == 或 ===,切记不要用 = 去判断是不是相等,一个等号是赋值。

alert方法

alert方法只能弹出一段字符串,所以默认会调用toString()方法,要弹出多个变量需要用 + 号拼接,如果用逗号分割,则只会弹出第一个变量的值。

请求接口路径中的斜杠

找了半个小时的bug,一直拿不到数据,用postman又可以:注意如果用变量拼接地址,原地址后面有/的,这个地方就不用再加了:

const baseUrl = 'http://localhost:8888/api/private/v1/'

// 有问题的
const api = `${url}/login`

// 正确的
const api2 = `/${url}/login`

Math.max()

Math.max()方法是找出一串数字中的最大值(不是数组),如果直接传入数组返回的就是NaN,如果要判断数组中的最大值:

  • ① 用ES6中的展开运算符,直接将数组中的元素展开 Math.max(…arr)
  • ② 用apply方法改变this的指向 Math.max(null, arr)

方法中return时省略分号

javascript有自动补全功能,所以每行可以加分号也可以不加:

function foo(){
   return 
   {
       b: 2
    }
}

会被解析成:

function foo(){
   return ;
   {
       b: 2
    }
};

所以最后的返回结果是 undefined。

map返回一个新的数组

用map遍历数组时如果是要得到一个新的数组,一定要记得return item,要不然原数组里的元素都变成undefined了。

html

标签自定义属性

自定义属性不可以通过【元素.自定义属性名】来获取,要用 getAttribute('自定义属性名')。

百度小程序template绑定数据

百度小程序模板 template 绑定数据那是3对花括号,不是2对

<template is="personCard" data="{{{...person}}}" />

微信那就是2对:

<template is="msgItem" data="{{...item}}" />

狗日的百度,净搞些鬼迷日眼的。

微信小程序页面节点上绑定数据

微信小程序里,wx:for="{{arr}}",这里一定要加 {{}},只有事件名才不用加双花括号,其他的只要是变量都要加,wx:key="index"(这个地方也不用加花括号)。

css

行内元素添加transform动画

transform 对于行内 inline 元素是无效的,如果要对其添加transform动画,需要先用display将行内元素变成行内块或块元素。

小程序button默认样式

小程序button去掉默认边框要设置

button::after {
    border: none;
}

自己设置border后行高要根据内减模型(box-sizing: border-box)来计算,里面的文字才能垂直居中。

img图片裁切 object-fit

给img标签加上

.img {
    object-fit: cover;
}

被替换的内容在保持其宽高比的同时填充元素的整个内容框。如果对象的宽高比与内容框不相匹配,该对象将被剪裁以适应内容框。

图片继承父盒子的宽,width: 100%,在设置object-fit: cover;不起作用,原因:要给img同时设置出宽高,才能裁切。

Tabbar导航栏定位问题

给tabbar设置position: fixed;后,不要设置left: 0;在大屏上tabbar就会跑到浏览器左下角去了,因为他是相对浏览器来做定位的。

设置背景色渐变

不是设置background-color: linear-gradient(),而是background: linear-gradient()。

自闭合标签伪元素

伪元素只有双标签才可以用,伪元素的本质是往标签元素的内部添加额外的元素,所以自闭合标签比如 img 是不能添加伪元素的。

用a标签放置网站Logo

<div class="logo">
    <h1><a href="#"></a></h1>
</div>

用a标签的背景图放网站logo,切记a标签是行内元素,不可以设置宽高,所以要先display: inline-block,然后设置宽高后,背景图才能显示出来,如果div是用的flex布局,a要设置成block才行。

导航栏的菜单也要注意,如果要给a设置宽高,也得先转成行内块inline-block(最好设置成block,设置成inline-block会出现其他一些问题)。

<li>
    <a href=“#”>我是导航</a>
</li>

用span或者i标签放置背景图时也要先改变元素显示类型,才能设置宽高。

人人都是码农:AI时代,零基础也能学会编程!关于作者:从美工、前端开发一路成功转型Java后端的野生码农 ‍,分享UI转前端、前端转Java、全栈开发、AI人工智能和码农搞钱副业...

Apollo Client Cache的缓存原理

2025年4月4日 17:36

Apollo Client 的 InMemoryCache 使用的是一种 层次化的缓存策略,结合了:

  1. 基于 field + arguments 的关键词缓存(Query 层级)
  2. 基于 __typename + id 的实体归一化缓存(Entity normalization)

这是 Apollo Cache 高性能和强可拓展性的根本所在。


📦 Apollo 缓存体系 详解

✅ 第一层:Query 层作为入口 —— fieldName + arguments 唯一标识

当你发起一个 GraphQL 查询:

query GetBooks($category: String!) {
  books(category: $category) {
    id
    title
  }
}

假如你传了变量 { category: "fiction" },Apollo 会在缓存中以以下格式为入口:

ROOT_QUERY: {
  "books({\"category\":\"fiction\"})": [ // 对应的是 books 字段,带参数
    { __ref: "Book:1" },
    { __ref: "Book:2" }
  ]
}

这里的 key 是 books({"category":"fiction"}),它是由字段名 + 参数组成。


✅ 第二层:实体归一化(Normalization) —— __typename:id 为缓存主键

当 Apollo 发现你返回的数据中包含了一个对象,并且这个对象中有 __typename + id(或者在 typePolicies 中配置的 key 字段),它会将其单独提取出来,作为唯一实体存储。

"Book:1": {
  id: "1",
  __typename: "Book",
  title: "The Great Gatsby"
},
"Book:2": {
  id: "2",
  __typename: "Book",
  title: "1984"
}

这样做的好处

  • 去重:一本书可能出现在多个查询里,统一存储避免数据重复。
  • 响应式更新:当某个字段更新时(如 title),所有引用它的地方都会自动响应式更新。
  • 强大的缓存合并能力:通过定制 typePolicies.merge(),你可以合并数据,而不是覆盖。

🛠️ 自定义 Primary Key: typePolicies.keyFields

默认情况下,Apollo 使用 __typename + id 作为唯一主键,但你可以自定义:

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      keyFields: ["isbn"]  // 用 isbn 替代 id
    },
  },
});

🤯 多层连接:如何看缓存结构?

查询嵌套字段时,如:

{
  user {
    id
    name
    posts {
      id
      title
    }
  }
}

缓存结构如下:

ROOT_QUERY: {
  "user": { __ref: "User:1" }
},
"User:1": {
  id: "1",
  __typename: "User",
  name: "Alice",
  posts: [
    { __ref: "Post:1" },
    { __ref: "Post:2" }
  ]
},
"Post:1": {
  id: "1",
  __typename: "Post",
  title: "Hello World"
},
"Post:2": {
  id: "2",
  __typename: "Post",
  title: "GraphQL Rocks"
}

🧠 如果内层对象没有提供id,缓存结构又是什么样了?

graphql
{
  user {
    id
    name
    posts {
      title
    }
  }
}

若只有 title 没有 id/__typename,Apollo cache 结构将类似:

jsonc
{
  "User:1": {
    id: "1",
    __typename: "User",
    name: "Alice",
    posts: [
      {
        title: "Hello World"
      },
      {
        title: "GraphQL Rocks"
      }
    ]
  }
}
  • ✅ posts 数组被内联嵌套存储在 User:1 的字段中。
  • ❌ 而不是像 posts: [{ __ref: "Post:1" }] 这样使用 __ref
  • ❌ 这些 post 项不会被 Apollo 单独归一化处理。

🎯 总结

特性 描述
查询记录 Apollo 会将查询结果按 fieldName + arguments 存成一个入口(如 books({ category: "fiction" })
实体归一化 所有实体根据 __typename + id 作为主键,单独提取并保存
引用机制 Query 层只是一个数组或对象的引用(__ref
自定义主键 使用 keyFields 修改默认的主键策略
优势 响应式 UI 更新、缓存去重、高可定制性

💡 正因为如此,Apollo 的缓存才如此强大灵活。你可以根据查询字段缓存数据,也可以通过 id 快速访问或修改实体,同时还能避免冗余和状态不一致的问题。

Nuxt3能上生产吗?

作者 溪饱鱼
2025年4月4日 16:55

Nuxt载荷和安全问题

前一段时间,我完成了整个产品,从Nuxt到Next的迁移,因为面临了一些在框架层面就无法解决的问题。

payload json化

在所有的的Nuxt中,我们都能看到有这样一个东西。

image.png

其实有这个东西也很正常,在Next中也会把服务端渲染的数据挂载html保持数据同步,这就是一个水合的必要步骤。在Next中是这样的。

image.png

可以看到在Next新一点的版本中是压缩过的字符串(老版本Page Router,也是JSON格式),而在Nuxt中采用的是JSON格式.

为什么Nuxt要采用JSON?有什么好处?会面临什么问题?

好处:

其实很好理解,就是为了性能和水合的加速,我的直觉因为是因为V8的性能加速对于JSON格式,V8参考资料。所以它不做压缩。

image.png

问题:违背SSR原则

这其实有点不符合SSR的设计原则,本身来说SSR是要在更快的时间看到页面和加载完成,这种设计会让整个html document的文件大小大量的增加。假设项目有18n文件,或者首屏的请求接口非常的多在服务端完成,就会导致拉长整个接口时间。

在本身现代浏览器如此之快的背景下,去加快水合时间,而拖慢请求完成的时间,确实让我非常的不理解。

安全问题

我靠,你敢相信吗,这么大一个框架,在一定情况下,会把NUXT_PUBLIC公开的环境变量直接挂在html里(如果用到了环境变量),人都要晕了。 image.png

可以参考这个issue:github.com/nuxt/nuxt/i… 这个问题从2017年已经到2024年了。

生态问题

不可否认的是,整个生态是欣欣向荣的,但在一些更商业和大型库生态在我看来Nuxt是不够深入的(就是Nuxt有非常多高级的语法糖和渲染方式和写法、社区在遇到更具体问题的时候解法很少),反之整个生态的方向都导向了工具、组件库、提效、性能类似的方向,让我感觉很迷茫有时候,就是大家都不做应用是把。

夸一下

毫无疑问,Nuxt框架在不考虑上述这些因素的情况下,在纯前端层面上的性能、语法便捷度、用户体验(框架基础)上绝对都是大于Next的。他上生产也没啥大问题~

欢迎加入群聊,我们一起讨论一些更有趣的技术、商业、闲聊。

chromium魔改——CDP(Chrome DevTools Protocol)检测01

2025年4月4日 16:40

环境检测网站,www.browserscan.net/bot-detecti…,这是一个检测机器人的网站,检测点很多,可以用来验证自己当前的环境是否正常。

正常打开的状态是这样的,说名环境正常,测试通过

在这里插入图片描述

这是非正常状态,就会显示Test Results:Robot,说明知道我们在试图调试

在这里插入图片描述在这里插入图片描述

当我们打开网站之后,再打开devtool调试工具,然后再刷新,并且devtool与窗口即便分离状态下,它依然能检测到,通过分析网站源码,发现他的检测位置是在这里,至于如何分析出他的检测点,这里就先略过,不是本章要说的内容。

在这里插入图片描述

实现CDP检测

以下是一个简化案例,还原了它的检测过程,我们先通过正向的角度分析完,然后再通过逆向去绕过。

将下面这段代码保存为cdp.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0">
    <title>Chrome DevTools Protocol Detection</title>
</head>
<body>
    <div id="cdp">devTool:<b id="status"></b></div>
    <script>
        const hook = new Error()
        let num = -1
        Object.defineProperty(hook, "stack", {
            get: function () {
                num += 1
                document.getElementById('status').innerText = num ? '打开了' : '关闭了'
                return num;
            }
        });
        function isOpenDevTool() {
            num = -1
            console.debug(hook);
            hook.stack
            requestAnimationFrame(isOpenDevTool);
        }
        requestAnimationFrame(isOpenDevTool);
    </script>
</body>
</html>

正常浏览器中,当我们打开devtool的时候,他就会检测到打开,关闭的时候就会显示关闭

在这里插入图片描述

这段代码的检测能力非常强,我们尝试用自动化框架去打开cdp.html,即便我们不打开devtool,他也会检测到你是机器人。

在这里插入图片描述

通过查阅MD文档,了解一下console.debug这个API的原理

在这里插入图片描述

知道了原理之后,我们就可以修改chromium的源码实现绕过了。

源码怎么找,思路是这样的,首先你要确定这个api是属于哪一类,console我们知道,他是一个很底层的api,所有什么浏览器也好,electron,nodejs也好,他都有一个共同的引擎,就是v8,所以,我们直接在chromium的源码中再他的v8文件夹下找就可以,然后找到console的实现,再进行分析和修改。

先根据官方目录去查关键字,然后一个一个检查

修改的位置在这里

/src/v8/src/inspector/v8-console.cc

在这里插入图片描述

我们只需要空实现这个函数即可,直接将实现部分注释掉

在这里插入图片描述

然后运行以下代码完成编译

autoninja -C out/Default chrome

然后再通过自动化程序测试,可以看到是正常了

在这里插入图片描述

vue3基础知识(结合TypeScript)

作者 Kagerou
2025年4月4日 15:26

一.使用vite创建项目

不选择vue-cli脚手架,换成使用vite,vite最大特点就是快,不需要进行打包,热加载快 是一个通用的脚手架工具

image.png

image.png

这里出了一个问题,就是vetur不支持vue3,所以HelloWorld组件就不能正常使用,需要把它卸载换成

二.ESlint初步使用

安装配置eslint: image.png

添加rules之后:

eslint.config.js:(检测有没有分号)

import globals from "globals";
import tseslint from "typescript-eslint";


/** @type {import('eslint').Linter.Config[]} */
export default [
  { files: ["**/*.{js,mjs,cjs,ts}"] },
  { languageOptions: { globals: globals.browser } },
  ...tseslint.configs.recommended,
  {
    rules: {
      semi: 2
    }
  },
];

image.png

代码中也会有相应的提示:

image.png

extends字段:添加规则组

后期和vite结合,添加vue和typeScript的规则组

三.响应式API:ref、reactive

ref:

<script lang="ts">
import { defineComponent,ref } from 'vue';
export default defineComponent({
     name:'APP',
     setup()
     {
         const count=ref(0); //声明响应式对象
         const increase=()=>{
          count.value++; //这里要加上value
         }
         return {
          count,
          increase
         }
     }
})
</script>

<template>
  <div>
    <h1>{{ count }}</h1>
    <button type="button" v-on:click="increase">increase</button>
    
    <a href="https://vite.dev" target="_blank">
      <img src="/vite.svg" class="logo" alt="Vite logo" />
    </a>
    <a href="https://vuejs.org/" target="_blank">
      <img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
    </a>
  </div>
</template>

reactive:参数必须是一个对象

怎么把响应式对象和TS结合起来呢,使用泛型来定义ref的类型,value++之前进行判断

<script lang="ts">
import { defineComponent,ref,reactive} from 'vue';
export default defineComponent({
     name:'APP',
     setup()
     {
         const count=ref<string | number>(0); //声明响应式对象
         const user=reactive({
             name:'viking',
             age:30,
         })
         const increase=()=>{
          if(typeof count.value==='number')
          count.value++; //这里要加上value
          user.age++; //不用加value进行访问
         }

         return {
          count,
          increase,
          user,
         }
     }
})
</script>

给reactive标记类型:

<script lang="ts">
import { defineComponent,ref,reactive} from 'vue';
interface person {
  name:string,
  age:number
}
export default defineComponent({
     name:'APP',
     setup()
     {
         const count=ref<string | number>(0); //声明响应式对象
         const user:person = reactive({
             name:'viking',
             age:30,
         })
         const increase=()=>{
          if(typeof count.value==='number')
          count.value++; //这里要加上value
          user.age++; //不用加value进行访问
         }

         return {
          count,
          increase,
          user,
         }
     }
})
</script>

四.computed计算属性

 <button type="button" :disabled="user.age<10">{{ user.age>=10 ?'可以参与':'不可以参与'}}</button>

如果要都写表达式的话,会使模板变得很复杂,所以就可以使用计算属性

const buttonStatus=computed(()=>{
              return {
                text:user.age>=10?'可以参加':'不可以参加',
                disable:user.age<10
              }
         })
<button type="button" :disabled="buttonStatus.disable">{{ buttonStatus.text }}</button>

计算属性在数据更新的时候才会重新计算

五.watch监听器

watch(count,(newValue,oldValue)=>{
              console.log('old',oldValue);
              console.log('new',newValue);
              document.title=`目前点击的数是${newValue}`
         })

不能直接监听一个对象的属性,要添加在箭头函数返回值后面

watch(()=>user.age,(newValue,oldValue)=>{
              console.log('old age',oldValue);
              console.log('new age',newValue);
              document.title=`目前点击的数是${newValue}`
         })

或者直接传入一个参数

watch(user,(newValue,oldValue)=>{
              console.log('old age',oldValue);
              console.log('new age',newValue);
              document.title=`目前点击的数是${newValue}`
         })

立即监听执行器,加上 {immediate:true}

六.生命周期、模板引用

onMounted(()=>{
            console.log('mounted');
          })
onUpdated(()=>{
console.log('updated');
})

onMounted用得是最多的,加载完毕的时候进行一些操作,比如发送异步请求

模板引用

const headline=ref(null);
<h1 ref="headline">{{ count }}</h1>

//onmounted中:
onMounted(()=>{
            console.log('mounted',headline.value);
          })

可以访问到dom节点:

image.png 使用TS的联合类型定义:(组件未加载完毕的时候可能是null)

const headline=ref<null|HTMLElement>(null);
onMounted(()=>{
            if(headline.value)
            console.log('mounted',headline.value);
          })

七.组件基础-属性Props

MyFile组件:

<template>
    <div class="profile-component"></div>
    <h1>name:{{ name }}</h1>
    <h1>age:{{ age }}</h1>
    <h1>doubleAge:{{ doubleAge }}</h1>
 </template>
 
 <script lang="ts">
 import { computed, defineComponent } from 'vue';
 export default defineComponent ({
     name:'MyFile',
     props:{
         name:{
             type:String,
             required:true
            
         },
         age:{
             type:Number,
             default:30
         }
     },
     setup(props) {
         const doubleAge=computed(()=>props.age*2)
         return{
             doubleAge
         }
     },
 })
 </script>
 
 <style scoped>
 
 </style>

在App.vue中给它传值:

<MyFile name="lyt"/>

把属性修改成一个对象:使用TS来标注类型(PropType)

<template>
    <div class="profile-component"></div>
    <h1>name:{{ user.name }}</h1>
    <h1>age:{{ user.age }}</h1>
    <h1>doubleAge:{{ doubleAge }}</h1>
 </template>
 
 <script lang="ts">
 import type { PropType } from 'vue';
 import { computed, defineComponent} from 'vue';
 interface Person{
    name:string,
    age:number
 }
 export default defineComponent ({
     name:'MyFile',
     props:{
         user:{
            type:Object as PropType<Person>,
            required:true
         }

     },
     setup(props) {
        const doubleAge=computed(()=>props.user.age*2)
        return {
            doubleAge
        }
     },
 })
 </script>
 
 <style scoped>
 
 </style>

在App.vue中使用要进行改变:

<MyFile :user="user"/>

八.组件自定义事件

子组件显示和隐藏功能:emit中添加了change事件,把isHidden的value传给父组件

<template>
    <div class="profile-component"></div>
    <h1>name:{{ user.name }}</h1>
    <h1 v-if="!isHidden">age:{{ user.age }}</h1>
    <h1 v-if="!isHidden">doubleAge:{{ doubleAge }}</h1>
    <button type="button" @click="toggleHide">{{ isHidden?'显示':'隐藏' }}</button>
 </template>
 
 <script lang="ts">
 import type { PropType } from 'vue';
 import { computed, defineComponent,ref} from 'vue';
 interface Person{
    name:string,
    age:number
 }
 export default defineComponent ({
     name:'MyFile',
     props:{
         user:{
            type:Object as PropType<Person>,
            required:true
         }
     },
     emits:['change'],
     setup(props,ctx) {
        const isHidden=ref(false);
        const doubleAge=computed(()=>props.user.age*2)
        const toggleHide=()=>{
            isHidden.value=!isHidden.value
            ctx.emit('change',isHidden.value)
        }
        return {
            doubleAge,
            isHidden,
            toggleHide
        }
     },
 })
 </script>
 
 <style scoped>
 
 </style>

App.vue中写相应的事件:获取到子组件的状态

const onChange=(hidden:boolean)=>{
             document.title=hidden?'隐藏':'显示'
          }
          
          
 <MyFile :user="user" @change="onChange"/>   
          

JavaScript 闭包:强大特性背后的概念、应用与内存考量

2025年4月4日 15:19

在 JavaScript 编程的世界里,闭包是一个既强大又微妙的概念。它能为我们的程序带来诸多便利,但如果使用不当,也可能引发一些棘手的问题。

一、闭包的概念

闭包是函数和其词法作用域的组合。它使内部函数(返回的函数)能够访问并记住其外层函数作用域中的变量,即使是在外部函数执行结束且在其作用域之外进行调用时,依然可以使用这些变量。

来看一个简单的示例代码:

function createCounter() {
    let count = 0;
    function increment() {
        count++;
        console.log(count);
    }
    return increment;
}

let counter = createCounter();
counter(); // 输出 1
counter(); // 输出 2
counter(); // 输出 3

这个例子中,闭包中的函数为 increment 函数,它在 createCounter 函数内部定义,是内部函数,且作为 createCounter 函数的返回值。

词法作用域的概念

词法作用域,也叫静态作用域,由代码编写位置决定。在该规则下,变量和函数的作用域取决于其定义位置,而非调用位置。函数在定义时就记住了所在作用域,无论在何处调用,都能访问定义时所处作用域的变量。

词法作用域与闭包形成

increment 函数的词法作用域是 createCounter 函数的作用域。由于函数在定义时记住词法作用域,increment 函数在 createCounter 函数内定义,便记住了该作用域。

createCounter 函数执行时,创建局部变量 count 并初始化为 0,同时定义 increment 函数,该函数引用了 count 变量。createCounter 函数返回 increment 函数后,increment 函数与 createCounter 函数的词法作用域结合形成闭包。

闭包对变量的影响

createCounter 函数执行结束,其执行上下文从调用栈移除,按常规作用域规则 count 变量应被销毁。但因 increment 函数形成的闭包对 count 有引用,count 变量所在内存空间不会释放。

将 increment 函数赋值给 counter 变量,在 createCounter 函数作用域之外调用 counter 函数(即 increment 函数),increment 函数仍能访问并修改 count 变量,每次调用 counter 函数,count 就增加 1 并打印。

二、闭包的用途

(一)封装私有变量

在传统的面向对象编程里,存在访问控制的概念,比如私有成员(private members),这些成员只能在类的内部被访问和修改。不过 JavaScript 原生并没有直接提供这样的私有成员机制,但借助闭包就能模拟实现。

以下是一个简单的封装示例:

function Person() {
    let name = "John";
    return {
        getName: function() {
            return name;
        },
        setName: function(newName) {
            name = newName;
        }
    };
}
let person = Person();
console.log(person.getName()); // 输出 John
person.setName("Jane");
console.log(person.getName()); // 输出 Jane
  • Person 函数里定义了一个局部变量 name,并将其初始化为 "John"。这个变量仅存在于 Person 函数的作用域内。

  • Person 函数返回了一个对象,该对象包含两个方法:getName 和 setName。这两个方法都形成了闭包,因为它们引用了 Person 函数作用域中的 name 变量。

  • 外部代码调用 Person() 时,会得到返回的对象,并将其赋值给 person 变量。

  • 外部代码无法直接访问 name 变量,只能通过 person.getName() 和 person.setName() 这两个方法来获取和修改 name 的值。这样就实现了对 name 变量的封装,保护了数据的安全性和完整性。

(二)数据缓存

在软件开发中,有些计算操作的成本非常高,比如需要大量的 CPU 时间、内存或者网络资源等。如果在程序运行过程中,多次对相同的输入进行这些高成本的计算,会造成资源的浪费,降低程序的性能。而闭包可以用来实现数据缓存,避免重复计算,提高程序的运行效率。

考虑这样一个场景,我们需要频繁计算某个复杂数学函数的结果:

function expensiveCalculation() {
    let cache = {};
    return function(n) {
        if (cache[n]) {
            return cache[n];
        } else {
            let result = n * n * n; // 这里假设是一个复杂计算
            cache[n] = result;
            return result;
        }
    };
}
let calculate = expensiveCalculation();
console.log(calculate(5)); // 计算并输出 125
console.log(calculate(5)); // 从缓存中获取并输出 125

在这个例子中:

  • expensiveCalculation 函数内部定义了一个空对象 cache,用于存储已经计算过的结果。
  • 当调用 calculate(5) 时,首先检查 cache 对象中是否已经有 5 对应的计算结果。如果没有,就进行计算,并将结果存储到 cache 对象中,然后返回计算结果。
  • 当再次调用 calculate(5) 时,由于 cache 对象中已经有 5 对应的计算结果,所以直接从 cache 对象中获取并返回该结果,避免了重复计算。

(三)实现函数柯里化

函数柯里化指的是把一个多参数函数转换为一系列单参数函数的过程。通过这种转换,函数可以逐步接收参数,利用闭包记住已传入的参数,从而实现更灵活的函数调用和复用。

  • 假设去超市买东西,结账的时候需要做两件事:一是扫码商品,二是付款。原本这两件事可能需要一次性把所有商品信息(价格总和)和付款金额这两个 “参数” 提供给收银员,就像一个多参数函数一样。

  • 而函数柯里化就像是把这个过程拆分开来。你可以先把商品扫码,记录下商品的总价,这就相当于先传入了一个参数;之后再去付款,提供付款金额,这相当于传入了另一个参数。这样原本需要一次性完成的两个操作,现在可以分步进行了。

以一个简单的加法函数为例,来展示如何通过闭包实现柯里化:

function add(a) {
    return function(b) {
        return a + b;
    };
}
let add5 = add(5);
console.log(add5(3)); // 输出 8
console.log(add(2)(4)); // 输出 6
  • 多参数函数的常规理解:通常的加法函数可能是 function add(a, b) { return a + b; },调用时需要一次性传入两个参数,比如 add(5, 3),就像在超市一次性把商品总价和付款金额告诉收银员一样。

  • 函数柯里化的过程

  • add 函数接收一个参数 a,此时,add 函数内部定义了一个新的函数,这个新函数引用了外部 add 函数的参数 a,从而形成了闭包。它会把这个 a 值保存起来,即使 add 函数执行完毕,这个值也不会丢失。

  • add 函数返回一个新的函数,这个新函数等待接收另一个参数 b,就像你记录好商品总价后,等待付款时提供付款金额 b。由于闭包的存在,这个新函数能够随时获取之前记录的 a 值。

  • let add5 = add(5); 这一步先传入了参数 5,得到了一个新的函数 add5。这里的 add5 其实就是之前形成闭包的那个新函数,它通过闭包记住了之前传入的 5,即使 add 函数已经完成了它的使命。

  • add5(3) 这一步,你传入了另一个参数 3,新函数 add5 利用闭包的特性,把之前记住的 5 和现在传入的 3 相加,得到结果 8。闭包确保了 add5 函数在任何时候都能准确地使用之前记录的 5 这个值来进行计算。

三、闭包与内存管理

闭包虽功能强大,但在使用中需留意其对内存管理的影响。当闭包形成,内部函数对外部函数作用域变量的引用,会致使这些变量在外部函数执行完毕后依旧驻留在内存中。这一特性在某些场景下,可能会引发内存泄漏问题。

例如在处理 DOM 事件的闭包场景中,如果闭包持续持有对 DOM 元素的引用,即便该元素已从 DOM 树移除,元素所占用的内存也无法正常释放,进而导致内存占用不必要地增加。

避免闭包引发内存问题的方法

使用具名函数作为事件监听器

将事件监听器设为具名函数,而不是匿名函数。这样在需要移除监听器时,可以通过函数名精准操作。

  • 匿名函数没有明确的标识符,当你想要移除它时,无法直接引用该函数。这就会导致即使你不再需要这个事件监听器,它依然会和元素绑定,无法被垃圾回收机制回收,进而造成内存泄漏。
// 具名函数作为事件监听器
function handleClick() {
    console.log('按钮被点击');
}
const button = document.getElementById('myButton');
button.addEventListener('click', handleClick);
// 当不再需要该事件监听器时
button.removeEventListener('click', handleClick);

及时清理闭包引用

若闭包中存在对大对象或不再使用对象的引用,应在合适时机手动将这些引用设为null,以便垃圾回收机制回收内存。如:

function outerFunction() {
    let largeObject = { /* 一个大对象 */ };
    function innerFunction() {
        console.log(largeObject);
    }
    // 在某些条件下,当确定不再需要largeObject时
    innerFunction();
    largeObject = null;
    return innerFunction;
}

控制闭包的生命周期

确保闭包仅在必要时段存在。闭包在函数内部创建时,如果它的功能仅在该函数执行期间有用,就要避免将其返回或赋值给全局变量,否则闭包会因被长期持有,而让其引用的外部变量一直占用内存。

例如:

function processData() {
    let data = [1, 2, 3, 4, 5];
    function inner() {
        // 处理数据
        return data.map(num => num * 2);
    }
    // 仅在processData函数内部使用inner闭包
    let result = inner();
    // inner闭包在processData函数执行结束后不再被引用,其引用的data等变量可被回收
    return result;
}

在上述代码中,processData 函数内的 inner 函数形成闭包,它引用了外部的 data 变量。但 inner 仅在 processData 函数内部被调用,当 processData 执行完毕,inner 不再被引用,data 变量就可被垃圾回收机制回收。

与之相反,如果闭包被长期持有,情况就大不一样。比如:

let globalClosure;
function processData() {
    let data = [1, 2, 3, 4, 5];
    function inner() {
        return data.map(num => num * 2);
    }
    // 将闭包返回并赋值给全局变量
    globalClosure = inner;
    return inner();
}
processData();

这里 inner 闭包被赋值给全局变量 globalClosure,即便 processData 函数执行结束,inner 因被全局变量引用而持续存在,其引用的 data 变量也会一直占用内存,无法被释放,进而可能引发内存泄漏问题 。

使用 WeakMap

  1. 强引用

强引用是最常见的引用类型。当一个变量直接指向一个对象时,就形成了强引用。只要这个变量在作用域内存在且没有被重新赋值或销毁,那么它所引用的对象就会一直存在于内存中,垃圾回收机制不会回收该对象。

例如:

let person = {
    name: "Alice",
    age: 30
};

在上述代码中,person 变量对创建的对象 {name: "Alice", age: 30} 进行了强引用,只要 person 变量还在作用域内,这个对象就会一直占用内存空间。

  1. 弱引用
  • 弱引用不会阻止对象被垃圾回收。当一个对象只被弱引用所指向,而没有其他任何强引用时,在垃圾回收机制运行时,这个对象就会被回收,无论它是否还在被使用。

  • 需要注意的是,在 JavaScript 中,WeakMap 是与弱引用密切相关的一种数据结构,它的键就是基于弱引用实现的。

例如:

const weakRefObj = {};
const weakMap = new WeakMap();
weakMap.set(weakRefObj, { data: "相关数据" });
weakRefObj = null; 
  • 在 JavaScript 中,变量是对对象的引用。当执行 const weakRefObj = {}; 时,创建了一个对象 {},并让 weakRefObj 变量引用它。此时,对象有一个强引用,即 weakRefObj

  • 当执行 weakRefObj = null; 时,weakRefObj 不再指向之前创建的对象,而是被赋值为 null。这意味着原来对象的强引用被移除了,因为 weakRefObj 这个唯一的强引用变量不再引用它。

  • 由于 WeakMap 的键是弱引用,当对象的所有强引用(这里就是 weakRefObj)都被移除后,垃圾回收机制就会认为这个对象可以被回收了。并且,因为 WeakMap 中该对象作为键所关联的整个键值对依赖于这个键对象,当键对象被回收时,与之对应的键值对也会从 WeakMap 中被移除(在垃圾回收机制运行时) 。

第五章 使用Context和订阅来共享组件状态

2025年4月4日 14:51

在前面两章,我们学习了如何使用Context和订阅来实现全局状态。它们二者各有其利弊:Context允许我们在不同的子树注入不同的值,而订阅可以避免一些额外的重新渲染。

在这一章,我们会学习一个新的方法:把React Context 和 订阅 组合起来。这样组合的话,我们可以得到这两个方法各自的优点:

  • Context可以为一棵子树提供全局状态,而且Context的provider可以被嵌套。Context允许我们在React 组件生命周期内,通过类似 useState风格的钩子来获取全局状态。
  • 另一方面,订阅允许我们控制重新渲染。

同时享有这两个方法的优点,对于大型应用是很好的 - 因为,我们可以为不同的子树注入不同的值,同时还避免额外的重新渲染。

在这一章,我们会讨论这几个主题:

  • 探索模块状态的局限
  • 理解何时使用Context
  • 实现Context和订阅的组合

探索模块状态的局限

因为模块状态是定义在React 组件之外,它有一个局限:这个模块状态是走单例模式的,所以,你无法为不同的子树注入不同的值。

让我们复习一下在第四章实现的 createStore方法:

const createStore = (initialState) => {
    let state = initialState;
    const callback = new Set();
    const getState = () => state;
    const setState = (nextState) => {
        state = typeof nextState === 'function'
        ? nextState(state) : nextState;
        callbacks.forEach((callback) => callback());
    };
    const subscribe = (callback) => {
        callbacks.add(callback);
        return () => { callbacks.delete(callback)' };
    };
    return { getState, setState, subscribe };
}

我们可以使用createStore来定义一个store:

const store = createStore({ count: 0 })

注意,store是定义在React组件之外的。

为了在React组件中使用store,我们要借助 useStore。下面这个例子,是有两个组件展示共同的来找store的值。我们可以借助在第四章实现的 useStore来实现:

const Counter = () => {
    const [state, setState] = useStore(store);
    const inc = () => {
        setState((prev) => {
            ...prev,
            count: prev.count + 1
        });
    };
    
    return (
        <div>
            {state.count} <button onClick={inc}>+1</button>
        </div>
    )
}

const Component = () => {
    <>
        <Counter />
        <Counter />
    </>
}

我们有用来展示store对象内 count 的 Counter组件,还有一个用于 更新 count 值的 button。因为Counter组件是可复用的,Component组件有两个 Counter组件 实例。Component组件 会为 我们展示一对组件展示共同的状态。

现在,假设我们想展示另一对 Counter组件。我需要在 Component组件 中 展示 另外两个新的组件,但是这对组件 所展示的 counter 参数需要和原来不一样。

我们可以生成一个 新的 count值。我们可以 为 我们已经定义的 store 对象 添加一个新的值。但是如果我们想把不同的 sotre 拆分开来,我们可以这样:

const store2 = createStore({ count: 0 });

因为 createStore 是可复用的,创建 store2 因此很简单。

现在,我们可以创建Counter2 组件:

const Counter2 = () => {
    const [state, setState] = useStore(store2);
    const inc = () => {
        setState((prev) => {
            ...prev,
            count: prev.count + 1
        });
    };
    
    return (
        <div>
            {state.count} <button onClick={inc}>+1</button>
        </div>
    )
}

const Component2 = () => {
    <>
        <Counter2 />
        <Counter2 />
    </>
}

你也许发现了,CounterCounter2 组件的 相似性 - 它们都有14行,唯一的不同在于store的指向不同。我们也许还需要Counter3Counter4组件来展示更多store的值。理想情况而言,Counter应该是可复用的,但是因为 块级状态是定义在 React外的,所以它无法服用。这是块级状态的局限。

如果Counter可以给不同的store复用,将会非常好。这是伪代码:

const Component = () => (
     <StoreProvider>
         <Counter />
         <Counter />
     </StoreProvider>
);

const Component2 = () => (
     <Store2Provider>
         <Counter />
         <Counter />
     </Store2Provider>
);

const Component3 = () => (
     <Store3Provider>
         <Counter />
         <Counter />
     </Store3Provider>
);

观察这段代码,你会发现Component1,Component2, Component3, Component4几乎一样。唯一的不同在于Provider组件。这正是React的Context该登场的时候。

现在,你已经知道了 块级状态的局限了,也知道了多sotre的理想模式。接下来,我们要复习Context并探索其使用。

理解何时使用Context。

在我们学习如何 组合 Context 与 订阅之前,我们先复习一下 Context是如何运作的。

下面是一个Context的简单用例:

const ThemeContext = createContext("light");


const Component = () => {
    const theme = useContext(ThemeContext);
    return <div>Theme: {theme}</div>
}

useContext的返回值,取决于Context所以在的组件树。

如果要改变Context的值,我们可以使用Provider:

<ThemeContext.Provider value="dark">
    <Component />
</ThemeContext.Provder>

如此一来,Compoenent展示的是 dark。

Provider是可以被嵌套的。而useContext取值是遵从就近原则:

<ThemeContext.Provider value="this value is not used">
    <ThemeContext.Provider value="this value is not used">
        <ThemeContext.Provider value="this is the value used">
            <Component />
        </ThemeContext.Provider>
    </ThemeContext.Provider>
</ThemeContext.Provider>

如果组件树中没有provider,useContext取默认值。

比如说,我们假设Root组件位于组件树最顶端:

const Root = () => {
    <>
        <Component />
    </>
}

此时,Component组件展示的是light。

再看看provider提供的是默认值的情况:

const Root = () => {
    <ThemeContext.Provider value="light">
        <Component />
    <ThemeContext.Provider/>
}

一样,Component组件展示的还是light。

接下来,我们讨论一下何时该使用Context。首先,我们要回顾之前的例子,有provider和无provider有什么区别?我们可以说,没有。没有provider返回的是默认值。

对于一个Context来说,设置合适的默认值是有必要的。而Context的provider可以理解为针对默认值的重写函数。

ThemeContext的例子中,我们已经有一个可用的默认值了,那么设置provider的意义为何?因为,我们需要在不同的子树注入不同的值。否则,我们直接使用默认值即可。

使用Context来管理全局状态时,你可能只会在根节点处使用一个提供者(Provider)。这是一种合理的使用场景,但这种场景也可以通过第 4 章 中介绍的带订阅功能的模块状态来实现。鉴于模块状态能够涵盖在根节点使用一个上下文提供者的使用场景,那么只有当我们需要为不同的子树提供不同的值时,才需要使用上下文来管理全局状态。

在本节中,我们回顾了 React 上下文,并了解了何时使用它。接下来,我们将学习如何将Conetxt和订阅结合使用。

实现Context和订阅的组合

我们知道,使用Context来注入全局状态时,会有一个问题:它会产生不必要的 重新渲染。

模块状态的订阅没有额外 重新渲染的问题,但它有另一问题:它只能为整个组件树提供一个值。

我们现在要把这两者结合起来,来避免这两者的缺点。让我们来实现这个特性。我们先从createStore开始:

getState: () => T;
    setState: (action: T | ((prev: T) => T)) => void;
    subscribe: (callback: () => void) => () => void;
};

const createStore = <T extends unknown>(
    initialState: T
    ): Store<T> => {
    let state = initialState;
    const callbacks = new Set<() => void>();
    const getState = () => state;
    const setState = (nextState: T | ((prev: T) => T)) => {
        state =
            typeof nextState === "function"
            ? (nextState as (prev: T) => T)(state)
            : nextState;
        callbacks.forEach((callback) => callback());
    };
 
    const subscribe = (callback: () => void) => {
        callbacks.add(callback);
        return () => {
            callbacks.delete(callback);
        };
    };
    return{ getState, setState, subscribe };
};

在第四章,我们把createStore用在模块状态上。现在,我们要把createStore用在Context的值上。

下面这段代码用于创建Context。其默认值用于传给createContext,其指向为默认store:

type State = { count: number; text?: string };

const StoreConext = createContext<Store<State>>(
    createStore<State>({ count: 0, text: "hello" })
)

此时,这个store有两个属性: counttext

为了把这些值提供给不同的子树,我们要创建StoreContext:

const StoreProvider = ({
    initialState,
    children
} : {
    initialState: State,
    children: ReactNode
}) => {
    const storeRef = useRef<Store<State>>();
    if (!storeRef.current) {
        storeRef.current = createStore(initialState);
    }
    return (
        <StoreContext.Provider value={storeRef.current}>
            {children}
       </StoreContext.Provider>
    )
}

useRef用于确保store对象只会在第一次渲染时进行初始化。

为了使用store对象,我们要实现一个useSelector钩子。不像第四章的useStoreSelector以store为参数,useSelector的参数中没有store:

const useSelector = <S extends unknown>(
    selector: (state: State) => S
) => {
    const store = useContext(StoreContext);
    return useSubscription(
        useMemo(
            () => ({
                getCurrentValue: () => selector(store.getState()),
                subscribe: store.subscribe,
            }),
            [store, selector]
        )
    )
}

useContextuseSubscription,是这个模式的关键。

不像模块状态,我们需要提供一个用Context来更新状态的方法。useSetState是一个简单的可以返回setState的钩子:

const useSetState = () => {
    const store = useContext(StoreContext);
    return store.setState;
}

现在,我们可以使用我们实现的方法了。下面是一个用于展示store中count的组件。我们在组件外定义了一个selectCount,否则,我们要用useCallback来包裹它:

const selectCount = (state: State) => state.count;

const Component = () => {
    const count = useSelector(selectCount);
    const setState = useSetState();
    const inc = () => {
        setState((prev) => ({
            ...prev,
            count: prev.count + 1,
        }));
    };
    return (
        <div>
            count: {count} <button onClick={inc}> +1</button>
        </div>
    )
};

需要注意的是,这个Component组件并不被绑定到任何特定stor。这个组件可以用在不同的store上。

我们可以让这个Component组件在不同的地方:

  • 在任何provider之外
  • 在第一个provider之中
  • 在第二个provider之中

下面这个App组件,把Component组件放在了三个地方:1.provider之外;2.在第一个provider之中;3.在第二个provider之中。在不同provider中的Component,则享有不同的值:

const App = () => (
     <>
         <h1>Using default store</h1>
         <Component />
         <Component />
         <StoreProvider initialState={{ count: 10 }}>
             <h1>Using store provider</h1>
             <Component />
             <Component />
             <StoreProvider initialState={{ count: 20 }}>
                 <h1>Using inner store provider</h1>
                 <Component />
                 <Component />
            </StoreProvider>
        </StoreProvider>
    </>
);

消费了相同store对象的Component组件会享有相同的count值。在这个例子中,在不同组件树层级中的组件,会消费不同的store,所以在不同地方的组件会展示不同的count:

image.png

如果你在 “使用默认存储” 中点击 “+1” 按钮,你会看到 “使用默认存储” 中的两个计数会一起更新。如果你在 “使用存储提供者” 中点击 “+1” 按钮,你会看到 “使用存储提供者” 中的两个计数会一起更新。“使用内部存储提供者” 的情况也是如此。

在本节中,我们学习了如何利用Context和订阅的相关优势来实现全局状态。由于上下文的存在,我们能够将状态隔离在一个子树中;而由于订阅的作用,我们能够避免额外的重新渲染。

概要

在这一章,我们学习了一个新的模式,组合Context和订阅。它将两者的优点结合在了一起:为不同的子树注入独立的值,并且避免不必要的重新渲染。这个模式对中大型项目特别有用。在中大型应用中,时候会发生不同的子树有不同的值的问题,而使用这个模式可以解决这一问题,还避免了不必要的重新渲染。

从下一章开始,我们要深入一些全局状态库。我们将会学习这些库是如何基于我们现在学习的知识建立起来的。

React 和 Vue3 在事件传递的区别

作者 gongzemin
2025年4月3日 16:51

最近用React19写了一个笔记应用,发现React和Vue3 在 事件传递 方式上不一样:

React:父组件定义方法,作为 props 传递给子组件,子组件在合适的时候调用这些方法(由父组件控制逻辑)
Vue3:子组件使用 defineEmits 触发事件,父组件监听事件并执行对应的回调函数(由子组件通知父组件)

最近写的笔记应用,我们以这个搜索组件来举例React和Vue的区别 image.png

React事件传递方式

在React中,父组件通过props(属性)向子组件传递数据和事件处理函数,使得子组件能够与父组件通信。我们以上面截图的搜索框组件为例,其中父组件传递了多个事件处理函数(onChange、handleSearch、onClearSearch)到子组件。

父组件

import React, { useState } from 'react'
import SearchBar from './SearchBar'

const ParentComponent = () => {
  const [searchQuery, setSearchQuery] = useState('')
  // 处理搜索逻辑
  const handleSearch = () => {
    console.log('搜索:', searchQuery)
  }
  
  // 清空搜索框
  const onClearSearch = () => {
    setSearchQuery('')
  }
  
  return (
   <SearchBar
     value={searchQuery}
     onChange={({ target }) => setSearchQuery(target.value)}
     handleSearch={handleSearch}
     onClearSearch={onClearSearch}
     />
  )
}
export default ParentComponent

示例:子组件(SearchBar.tsx),样式用了tailwindcss v4

import React from 'react'
import { FaMagnifyingGlass } from 'react-icons/fa6'
import { IoMdClose } from 'react-icons/io'

interface SearchBarProps {
  value: string
  onChange: (event: React.ChangeEvent<HTMLInputElement>) => void
  handleSearch: () => void
  onClearSearch: () => void
}

const SearchBar: React.FC<SearchBarProps> = ({
  value,
  onChange,
  handleSearch,
  onClearSearch,
}) => {
  return (
    <div className="w-80 flex items-center px-4 bg-slate-100 rounded-md">
      <input type="text" placeholder="搜索笔记"
          className="w-full text-xs bg-transparent py-[11px] outline-none"
          value={value}
          onChange={onChange} // 触发父组件的onChange方法
        />
       {value && (
          <IoMdClose 
            className="text-xl text-slate-500 cursor-pointer hover:text-black mr-3"
            onClick={onClearSearch} // 触发父组件的 handleSearch方法
            />
        )}
        <FaMagnifyingGlass
            className="text-slate-400 cursor-pointer hover:text-black"
            onClick={handleSearch} // 触发父组件的handleSearch方法
         />
     </div>
  )
}
export default SearchBar
事件传递过程分析
  1. 父组件定义方法
  • handleSearch(): 处理搜索逻辑
  • onClearSearch(): 清空搜索框
  • onChange(): 更新输入框内容
  1. 父组件通过props把这些方法传递给子组件
<SearchBar
 value={searchQuery}
 onChange={({ target }) => setSearchQuery(target.value)}
 handleSearch={handleSearch}
 onClearSearch={onClearSearch}
/>
  1. 子组件SearchBar接受props并在事件触发时调用这些方法
  • 当输入框内容变化时,触发onChange(event), 更新searchQuery
  • 当点击放大镜图标时,调用handleSearch,执行搜索
  • 当点击关闭按钮时,调用onClearSearch,清空搜索框
  1. 为什么要这样做?
  • 父组件控制数据状态,子组件只是一个UI组件(受控组件)
  • 子组件无需管理数据,它只是接受value和事件处理函数
  • 代码更清晰,可复用性更高,不同地方的搜索框可以使用相同的SearchBar组件
  1. 总结
    ✅ 父组件定义事件方法,并通过 props 传递给子组件
    ✅ 子组件调用 props 里的方法 来通知父组件(例如 onChange、handleSearch、onClearSearch)
    ✅ 父组件负责管理状态,子组件只是一个展示组件
Vue3事件传递方式

在Vue3中,子组件不会调用父组件的方法,而是通过emit事件方式通知父组件,父组件在模板中监听该事件并执行回调。

父组件

<script setup>
import { ref } from 'vue'
import SearchBar from './SearchBar.vue'

const searchQuery = ref('')
// 监听子组件的事件
const handleSearch = () => {
  console.log('搜索:', searchQuery.value)
}

const onClearSearch = () => {
  searchQuery.value = ''
}
</script>

<template>
  <SearchBar
    v-model="searchQuery"
    @search="handleSearch"
    @clear="onClearSearch"
   />
</template>

子组件 SearchBar.vue

<script setup>
import { defineEmits, defineProps } from 'vue'

const props = defineProps({
  modelValue: String,
})

const emit = defineEmits(['update:modelValue', 'search', 'clear'])

const handleInput = (event) => {
  emit('update:modelValue', event.target.value) // 触发 v-model
}

const handleSearch = () => {
  emit('search') // 触发 search 事件
}

const handleClear = () => {
  emit('update:modelValue', '') // 触发 v-model 清空输入框
  emit('clear') // 触发 clear 事件
}
</script>

<template>
  <div class="search-bar">
    <input
      type="text"
      :value="modelValue"
      @input="handleInput"
      placeholder="搜索笔记"
    />
    <button v-if="modelValue" @click="handleClear">清空</button>
    <button @click="handleSearch">搜索</button>
  </div>
</template>

上面的子组件我们可以用3.4版本的defineModel简化下代码,

<script setup>
import { defineEmits } from 'vue'

const model = defineModel()
const emit = defineEmits(['search', 'clear'])
const handleSearch = () => {
  emit('search') // 触发 search 事件
}

const handleClear = () => {
  model.value = ''
  emit('clear') // 触发 clear 事件
}
</script>

<template>
  <div class="search-bar">
    <input
      type="text"
      v-model="model"
      placeholder="搜索笔记"
    />
    <button v-if="model" @click="handleClear">清空</button>
    <button @click="handleSearch">搜索</button>
  </div>
</template>

Vue SFC Playground.gif

对比项 React (父传子) Vue 3 (emit 子传父)
事件触发方式 子组件调用父组件的 props 方法 子组件 emit 事件,父组件监听
状态管理方式 父组件控制状态,通过 props 传递 子组件通知父组件,父组件修改数据
v-model 实现 需要 useState + onChange defineProps + defineEmits
灵活性 父组件完全控制,代码可读性更高 子组件可独立触发事件,但需要 emit
总结
  1. React 是 “父组件控制子组件”
    • 逻辑和状态都在父组件
    • 子组件只调用 props 传递的方法,不需要管理状态

  2. Vue 3 是 “子组件通知父组件”
    • 子组件使用 emit 触发事件
    • 父组件监听事件并修改状态(比如 v-model)

两者设计理念不同,但都能够实现组件间的通信,React 更强调单向数据流,而 Vue 3 更灵活,支持 v-model 和 emit 来进行双向绑定。

❌
❌