阅读视图

发现新文章,点击刷新页面。

umi+模块联邦-配置指南

背景

目前我们将公共组件封装到npm包里,在每个应用使用npm包的方式进行消费

问题

每次更新组件需要发布npm包,本地开发不能实时看到组件更新效果,需要重新安装npm包才能生效。

解决方法

模块联邦可以在多个 webpack 编译产物之间共享模块、依赖、页面甚至应用,通过全局变量的组合,还可以在不同模块之前进行数据的获取,让跨应用间做到模块共享真正的插拔式的便捷使用。比如a应用如果想使用b应用中table的组件,通过模块联邦可以直接在a中进行import('b/table')非常的方便。

线上 Runtime(运行时) 的效果,让代码直接在项目间利用 CDN 直接共享,不再需要本地安装npm 包、构建再发布了。

umi提供了**Module Federation 插件**

怎么做

共享应用

封装公共组件

导出的模块按照约定,将源代码目录下的 exposes 一级子目录名作为导出项,导出文件为该目录下的 index 文件

//   /exposesCounter/index.tsx
import React from 'react';

export default (props: { init?: number }) => {
  const [c, setC] = React.useState(props.init ?? 10);

  return (
    <div>
      <h1> remote Counter</h1>
      <div>
        <button
          data-testid="remote-button"
          onClick={() => {
            setC((c) => c + 1);
          }}
        >
          click to add new
        </button>
      </div>
      <div>
        remote hooks counter
        <span data-testid="remote-counter">{c}</span>
      </div>
    </div>
  );
};

umirc.ts配置

import { defineConfig } from '@umijs/max';

const shared = {
  react: {
    singleton: true,
    eager: false,
  },
  'react-dom': {
    singleton: true,
    eager: false,
  },
};

const moduleFederationName = 'remoteCounter'; // 模块名称

export default defineConfig({
  mfsu: false,
  mf: {
    name: moduleFederationName,
    shared,
  },
  publicPath: 'http://127.0.0.1:9000/',
});

使用方

  • keyResolver 用于在运行时决定使用 entries 哪个 key; 推荐使用 立即调用函数表达式 的形式,可以在函数中实现较复杂的功能。不支持异步的函数。
  • keyResolver 也可以使用静态的值,配置形式 keyResolver: '"PROD"'

umirc.ts配置

import { defineConfig } from "@umijs/max";

const shared = {
  react: {
    singleton: true,
    eager: false,
  },
  "react-dom": {
    singleton: true,
    eager: false,
  },
};

export default defineConfig({
  mfsu:false,
  mf: {
    name: "hostUser",
    remotes: [
      {
        name: "remoteCounter",
        entries: {
          DEV: "http://127.0.0.1:9000/remote.js", // 本地 地址
          PROD: "http://127.0.0.1:9000/remote.js",// 线上地址
        },
        keyResolver: `(()=> 'DEV')()`,
      }
    ],
    shared,
  },
});

使用公共组件

import React, { Suspense } from 'react';

const RemoteCounter = React.lazy(() => {
  // @ts-ignore
  return import('remoteCounter/Counter');
});
export default () => {
    <RemoteCounter />
}

配置项说明

(一)webpack ModuleFederationPlugin配置

  1. name 必须,当前应用的名字,全局唯一ID,通过 name/{expose} 的方式使用
  2. library 可选,打包方式,与 name 保持一致即可
  3. filename 可选,打包后的文件名,对应上面的 remoteEntry.js
  4. remotes 可选,表示当前应用是一个 Host,可以引用 Remote 中 expose 的模块
  5. exposes 可选,表示当前应用是一个 Remote,exposes 内的模块可以被其他的 Host 引用,引用方式为 import(name/{expose})
  6. shared 可选,依赖的包(下面包含了 shared 中包含的配置项)
  • 如果配置了这个属性。webpack在加载的时候会先判断本地应用是否存在对应的包,如果不存在,则加载远程应用的依赖包。

  • 以 app2 来说,因为它是一个远程应用,配置了["react", "react-dom"] ,而它被 app1 所消费,所以 webpack 会先查找 app1 是否存在这两个包,如果不存在就使用 app2 自带包。 app1里面同样申明了这两个参数,因为 app1 是本地应用,所以会直接用 app1 的依赖。

  • shared 配置项指示 remote 应用的输出内容和 host 应用可以共用哪些依赖。 shared 要想生效,则 host 应用和 remote 应用的 shared 配置的依赖要一致。

  • import 共享依赖的实际的 package name,如果未指定,默认为用户自定义的共享依赖名,即 react-shared。如果是这样的话,webpack 打包是会抛出异常的,因为实际上并没有 react-shared 这个包。

  • singleton 是否开启单例模式,true 则开启。如何启用单例模式,那么 remote 应用组件和 host 应用共享的依赖只加载一次,且与版本无关。 如果版本不一致,会给出警告。不开启单例模式下,如果 remote 应用和 host 应用共享依赖的版本不一致,remote 应用和 host 应用需要分别各自加载依赖。

  • requiredVersion 指定共享依赖的版本,默认值为当前应用的依赖版本。- 如果 requiredVersion 与实际应用的依赖的版本不一致,会给出警告。

  • strictVersion 是否需要严格的版本控制。单例模式下,如果 strictVersion 与实际应用的依赖的版本不一致,会抛出异常。默认值为 false。

  • shareKey 共享依赖的别名, 默认值值 shared 配置项的 key 值。

  • shareScope 当前共享依赖的作用域名称,默认为 default。

  • eager 共享依赖在打包过程中是否被分离为 async chunk。eager 为 false, 共享依赖被单独分离为 async chunk; eager 为 true, 共享依赖会打包到 main、remoteEntry,不会被分离。默认值为 false,如果设置为 true, 共享依赖其实是没有意义的。

  • shareScope 所用共享依赖的作用域名称,默认为 default。如果 shareScope 和 share["xxx"].shareScope 同时存在,share["xxx"].shareScope 的优先级更高。

示例代码

gitlab.evaluateai.cn/liangchaofe…

参考

umijs.org/docs/max/mf…

Mf配置项: ModuleFederationPlugin | webpack

mf配置项的一些解读:juejin.cn

mf原理:Module Federation | webpack 中文文档

Zustand状态管理库:轻量级、高效的React解决方案

Zustand是一个轻量级的React状态管理库,旨在为开发者提供一个简洁、可扩展和高效的状态管理解决方案。它使用简单的API,基于React Hooks机制,允许开发者通过自定义钩子来访问和更新状态。

常用场景

Zustand常用于以下场景:

  • 状态管理:Zustand帮助开发者集中化管理应用状态,特别适用于小型和中型应用。
  • 异步数据处理:支持异步操作,例如数据获取和更新。
  • 持久化存储:通过中间件实现状态持久化,例如使用localStorage存储数据。
  • 组件间状态共享:使得组件间状态共享变得简单,无需复杂的上下文或提供者。

解决的问题

Zustand解决了以下问题:

  • 简化状态管理:提供了比Redux等库更简洁的API,减少了样板代码。
  • 提高性能:仅在状态变化时重新渲染相关组件,避免不必要的渲染。
  • 灵活性和可扩展性:支持中间件扩展,适应不同项目需求。
  • 易于集成:可以与其他状态管理库共存,方便迁移。

基本用法

安装

使用npm或yarn安装Zustand:

npm install zustand

yarn add zustand

创建状态存储

import create from 'zustand';

const useStore = create(set => ({
  count: 0,
  increase: () => set(state => ({ count: state.count + 1 })),
  decrease: () => set(state => ({ count: state.count - 1 })),
}));

使用状态存储

import React from 'react';
import { useStore } from './store';

function Counter() {
  const { count, increase, decrease } = useStore();
  
  return (
    
      {count}
      Increase
      Decrease
    
  );
}

异步状态管理

import React, { useEffect } from 'react';
import create from 'zustand';

const useStore = create(set => ({
  data: null,
  loading: false,
  error: null,
  fetchData: async () => {
    set({ loading: true });
    try {
      const response = await fetch('https://api.example.com/data');
      const data = await response.json();
      set({ data, loading: false });
    } catch (error) {
      set({ error, loading: false });
    }
  },
}));

function DataFetcher() {
  const { data, loading, error, fetchData } = useStore();
  useEffect(() => {
    fetchData();
  }, [fetchData]);

  if (loading) return Loading...;
  if (error) return Error: {error.message};

  return (
    
      {data && (
        
          {data.map(item => (
            {item.name}
          ))}
        
      )}
    
  );
}

持久化存储

Zustand支持通过中间件实现状态持久化,例如使用localStorage存储数据。

import create from 'zustand';
import { persist } from 'zustand/middleware';

const useStore = create(
  persist(
    set => ({
      theme: 'light',
      toggleTheme: () => set(state => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
    }),
    {
      name: 'theme', // 存储名称
      getStorage: () => localStorage, // 存储介质
    }
  )
);

中间件支持

Zustand支持中间件扩展,允许开发者添加额外功能,如日志记录、持久化存储等。

import create from 'zustand';

const logTrace = config => (set, get, api) => config(
  (...args) => {
    console.log('Before applying: ', args);
    set(...args);
    console.log('After applying: ', get());
  },
  get,
  api
);

const useStore = create(
  logTrace(set => ({
    count: 0,
    increase: () => set(state => ({ count: state.count + 1 })),
  }))
);

Zustand的轻量级、简洁的API和灵活的扩展能力,使其成为React状态管理的优雅解决方案。

chromium魔改——navigator.webdriver 检测

chromium源码官网 https://source.chromium.org/chromium/chromium/src

说下修改的chromium源码思路:

首先在修改源码过检测之前,我们要知道它是怎么检测的,找到他通过哪个JS的API来做的检测,只有知道了如何检测,我们才能想办法去绕过,俗话说,知己知彼百战百胜嘛!

绕过 navigator.webdriver 检测

在浏览器中环境对抗的时候,会通过 navigator.webdriver 来检测是否为自动化在控制网站,如果返回为true,则表示是自动化在控制,否则不是,所以我们只需要将他的返回值永远返回false即可

自动化控制返回的值 在这里插入图片描述

正常浏览器返回的值 在这里插入图片描述

在chromium源码中,找到以下路径

\src\third_party\blink\renderer\core\frame\navigator.cc

有以下两种修改方式,选其中一种即可。

方式一: 在这里插入图片描述

方式二: 在这里插入图片描述

修改完之后保存代码即可,在修改的时候不要将原始代码删除,注释即可方便出错的时候查问题。

保存好了之后,运行以下命令,这个命令就是前面编译章节中的编译命令,你当时编译的时候用的是什么,这里就写什么,他不是重新编译,是增量编译,只对新增的内容进行编译,所以速度会快很多。

autoninja -C out/Default chrome

在这里插入图片描述

编译好之后,通过自动化程序检测一下

我们这里使用 playwright 来测试

from playwright.sync_api import sync_playwright

def run():
    with sync_playwright() as p:
        browser = p.chromium.launch(
            executable_path="E:\my_code\chromium\src\out\Default\chrome.exe",
            headless=False
        )
        # 创建一个新的浏览器上下文
        context = browser.new_context()
        # 打开一个新页面
        page = context.new_page()
        # 访问目标网站
        page.goto("https://www.baidu.com")

        # 等待用户输入后再关闭浏览器
        input("Press Enter to close the browser...")
        
        # 关闭浏览器
        browser.close()

if __name__ == "__main__":
    run()

可以看到是已经成功了

在这里插入图片描述

Cookie、LocalStorage 和 SessionStorage 的全面解析

存储方式.png

在网络应用的世界里,HTTP 作为一种无状态协议,虽具备高效传输的优势,但在实际的 Web 应用中,却常需要保持一定的状态信息。基于此需求,cookie、(HTML5 中的)localStorage 和 sessionStorage 应运而生,它们在客户端数据存储与管理方面发挥着重要作用。

Cookie:穿梭于客户端与服务器的信使

  • 生成与存储:Cookie 由服务端生成并发送给浏览器,存储在本地。

  • 数据传递特性:每次浏览器向服务器发送请求时,都会自动携带相应的 Cookie 数据。

Cookie 的优缺点

  • 优点:

    • 能在浏览器和服务器间灵活传递数据,适用于追踪用户信息。
    • 可通过手动设置过期时间,灵活调节数据存储时长。
  • 缺点:

    • 每次请求都携带 Cookie 数据,不仅增加了网络流量,还会对性能产生一定影响。
    • Cookie 在 HTTP 中以明文形式传递,安全性欠佳,容易遭受篡改。
    • 其存储容量相对较小,一般仅为 4KB

应用场景示例:

  • 网站可利用 Cookie 识别用户登录状态。
  • 记住用户在购物车中添加的商品。
  • 收集用户兴趣和偏好信息,用于定制个性化广告。

(在客户端和服务器之间频繁传递少量数据的场景、身份验证、会话管理)

LocalStorage:客户端的长期记忆库

  • 存储特点:LocalStorage 和 SessionStorage 均是在浏览器端进行数据存储。LocalStorage 的显著特点是存储容量较大,通常可达 5MB,且存储的数据会永久存在于浏览器中,无需担忧数据丢失问题。
  • 访问方式:通过 JavaScript,开发者能方便地对 LocalStorage 进行访问,其操作简单,API 十分友好。

LocalStorage 的优缺点

  • 优点:

    • 存储容量较大一般为5M

    • 存储的数据可以在客户端浏览器中永久保存不必要担心丢失

    • 操作方便,API友好可通过JavaScript直接访问

  • 缺点:

    • 由于同源机制的限制,一个网站只能访问自身域名下的 LocalStorage,这对于跨域资源共享以及单点登录等应用场景不太友好。
    • 不支持不同浏览器之间共享数据。

LocalStorage 的使用场景

  • 常用于存储用户个人偏好,比如主题选择、隐私设置等。

  • 以电商平台为例,商品详情页包含丰富信息,当用户查看商品详情时,页面可将这些数据缓存到 LocalStorage,以便后续快速访问,提升用户体验。

SessionStorage:会话期间的临时守护者

  • 存储时效与独立性:SessionStorage 的数据同样存储在客户端,与 LocalStorage 不同的是,它仅在关闭浏览器窗口前有效。在页面刷新时,SessionStorage 中的数据依然会保留,且每个标签和窗口之间的数据相互独立。

SessionStorage 的优缺点

  • 优点:

    • 数据它主要存储在客户端

    • 页面刷新,仍然会保留原来会话的数据

    • 每个标签和窗口之间的数据独立

  • 缺点:

    • 不适合长期存储。
    • 数据不共享,每个 Tab 页都拥有独立的数据。

image.png

SessionStorage 的使用场景

对于多步骤表单流程,SessionStorage 是个不错的选择。在用户点击上 / 下一步修改刚刚填写的数据时,SessionStorage 可确保数据在当前会话期间的完整性与准确性。

三者的共同点

  • 存储位置:Cookie、LocalStorage 和 SessionStorage 都保存在浏览器端。
  • 跨域限制:都受同源策略的影响,不支持跨域操作。

在实际应用中:

  • 在客户端和服务器之间共享数据 + 长期存储的话 ---> cookie

  • 只在客户端长期存储 + 无需与服务器交互 ---> storage

  • 只在客户端 + 会话期间暂存 ---> session storage 深入理解并合理运用这三种存储方式,能为 Web 应用的开发带来极大便利,提升用户体验。

特性 Cookie LocalStorage SessionStorage
存储位置 浏览器端 浏览器端 浏览器端
跨域限制 受同源策略限制,不支持跨域 受同源策略限制,不支持跨域 受同源策略限制,不支持跨域
数据传递 可在客户端与服务器间来回传递 仅在客户端存储,不与服务器交互 仅在客户端存储,不与服务器交互
有效期 可手动设置过期时间 永久有效 会话期间有效,关闭浏览器窗口即失效
存储容量 一般为 4KB 一般为 5MB 一般为 5MB
优点 适用于追踪用户信息,可灵活设置过期时间 存储容量大,数据永久保存,操作方便 页面刷新数据保留,每个标签和窗口数据独立
缺点 每次请求携带数据增加流量和性能损耗,明文传递不安全,存储容量小 受同源机制限制,不支持不同浏览器共享数据 不适合长期存储,数据不共享
使用场景 身份验证、会话管理、少量数据在客户端和服务器间频繁传递,如用户登录状态、购物车部分信息 存储用户个人偏好,如主题选择、隐私设置;电商商品详情页数据缓存 多步骤表单流程,保证会话内数据暂存的完整性

陈年旧事: 基于JS的三维光学引擎

写这篇博客其实是想为大学时候的一段代码做个总结. 跨越许久的时光,这段代码不禁让人感到雀跃

先看一下效果

4532.png

仓库地址: github.com/kaori-seaso…

核心思想

生成一系列在三维空间中相互连接的节点,并在这些节点之间移动数据粒子,模拟出一种抽象的光学网络

核心概念与数据结构

  • Connection(连接):

    • 表示三维空间中的一个节点。
    • 包含其三维坐标(x, y, z)、大小(size)以及屏幕坐标(screen)。
    • 维护一个连接列表(links),用于存储与其相连的其他节点。
    • 通过link()方法递归生成子节点,形成三维网络结构。
    • setScreen()方法将3D坐标转换为2D屏幕坐标。
// 节点构造函数 
function Connection(x, y, z, size) { 
    this.x = x; this.y = y; this.z = z; this.size = size; 
    this.screen = {}; // 存储屏幕坐标 
    this.links = []; // 存储连接的节点 
    this.probabilities = []; // 连接概率 this.isEnd = false; // 是否为末端节点 
    this.glowSpeed = opts.baseGlowSpeed + opts.addedGlowSpeed * Math.random(); // 发光速度 
}
  • Data(数据):

    • 表示在节点之间移动的数据粒子。
    • 维护当前节点(connection)和下一个节点(nextConnection)的信息。
    • 通过step()方法在两个节点之间进行线性插值移动,模拟数据的流动。
    • setScreen()方法将3D坐标转换为2D屏幕坐标.
// 数据粒子构造函数
function Data(connection) {
    // 设置数据粒子的发光速度,随机增加一些变化
    this.glowSpeed = opts.baseGlowSpeed + opts.addedGlowSpeed * Math.random();

    // 设置数据粒子的移动速度,同样随机增加一些变化
    this.speed = opts.baseSpeed + opts.addedSpeed * Math.random();

    // 初始化数据粒子的屏幕坐标对象
    this.screen = {};

    // 设置数据粒子初始连接的节点
    this.setConnection(connection);
}
-   **`this.glowSpeed`:**

    -   用于控制数据粒子的发光效果的速度。
    -   通过将基础发光速度 `opts.baseGlowSpeed` 与一个随机增加的量相加来创建变化,使得每个粒子的发光速度略有不同。

-   **`this.speed`:**

    -   控制数据粒子在节点之间移动的速度。
    -   与发光速度类似,它也通过随机增加一些变化,使得每个粒子的移动速度略有不同。

-   **`this.screen`:**

    -   一个空对象,用于存储数据粒子在屏幕上的坐标和其他相关信息。
    -   在此对象中添加 `x``y``z` 等属性,用于将三维坐标转换为二维屏幕坐标。

-   **`this.setConnection(connection)`:**

    -   用于设置数据粒子初始连接的节点。
    -   它接收一个 `connection` 参数,表示数据粒子要连接的 `Connection` 对象。
    -   此方法将初始化数据粒子的位置和移动目标。
  • 全局变量:

    • opts:存储了各种视觉效果和行为的配置参数。
    • connections:存储所有的Connection对象。
    • data:存储所有的Data对象。
    • all:存储所有的connection和data对象。
    • tick:动画帧计数器,用于控制动画速度。

初始化与配置

window.onload = function () {
    var c = document.getElementById('c'), // 获取canvas元素
        w = c.width = window.innerWidth, // 设置canvas宽度为窗口宽度
        h = c.height = window.innerHeight, // 设置canvas高度为窗口高度
        ctx = c.getContext('2d'), // 获取2D渲染上下文

        // 配置参数
        opts = {
            range: 180, // 节点生成的范围
            // ... 其他配置参数 ...
        };

    // ... 其他初始化代码 ...
};

三维节点网络生成

function Connection(x, y, z, size) {
    // ... 节点构造函数 ...
}

Connection.prototype.link = function () {
    // ... 生成子节点 ...
};

// ... 初始化节点网络 ...
  • Connection构造函数用于创建三维节点对象,包含坐标、大小、连接列表等属性。

  • Connection.prototype.link方法递归生成子节点,形成三维网络结构。

通过随机生成角度和长度,计算子节点坐标,并确保它们在指定范围内,且与其他节点保持一定距离。

数据粒子流动

function Data(connection) {
    // ... 数据粒子构造函数 ...
}

Data.prototype.step = function () {
    // ... 更新粒子位置 ...
};

// ... 创建数据粒子 ...

三维到二维投影

Connection.prototype.setScreen = Data.prototype.setScreen = function () {
    // ... 三维到二维坐标转换 ...
};
  • setScreen方法将三维坐标转换为二维屏幕坐标,实现透视投影效果。应用旋转变换和透视投影公式,计算出屏幕坐标和缩放比例。

动画循环与渲染

function anim() {
    window.requestAnimationFrame(anim);
    // ... 渲染逻辑 ...
}

// ... 启动动画循环 ...
  • anim函数使用requestAnimationFrame创建动画循环。

  • 在每一帧中,它更新节点和粒子位置,并使用Canvas 2D API绘制它们。

  • 通过调整globalCompositeOperation实现发光效果,使用sort方法实现深度效果。

交互与事件处理

window.addEventListener('resize', function () {
    // ... 窗口大小改变处理 ...
});

window.addEventListener('click', init);
  • 监听窗口大小改变事件,调整Canvas大小和消失点。以及监听点击事件,重新初始化动画。

这其中我们会发现粒子总是会流动到各个连接的边上,这是怎么做到的呢?

首先,物体的每一次旋转是不可能一直保存上一次描绘的点的,这对渲染的压力太大. 所以需要一个函数负责生成当前节点的子节点,并确保这些子节点在三维空间中的分布合理。

连接点与边构建的逻辑

分为四个部分:末端节点检查,生成连接点,创建子节点,递归生成

  • 末端节点检查

    • 如果当前节点的大小已经小于 opts.minSize,则说明它已经足够小,不需要生成子节点。将其标记为末端节点,并返回。
  • 生成连接点

    • 通过随机生成角度和长度,计算出新连接点的三维坐标。
    • 使用 squareDist 函数检查新连接点是否与现有节点和已生成的连接点太近,以避免节点过于密集。
    • 如果新连接点通过了所有检查,则将其添加到 links 数组中。
  • 创建子节点

    • 如果成功生成了连接点,则为每个连接点创建一个新的 Connection 对象。
    • 将新创建的子节点添加到 this.links 数组、all 数组和 connections 数组中。
    • 将新创建的子节点添加到 toDevelop 数组中,以便在后续的循环中继续生成它们的子节点。
  • 递归生成

    • 通过将子节点添加到 toDevelop 数组,实现了递归生成节点的效果。
Connection.prototype.link = function () {
    // 如果当前节点的大小小于最小允许大小,则将其标记为末端节点并返回
    if (this.size < opts.minSize)
        return this.isEnd = true;

    // 初始化一些局部变量
    var links = [], // 存储新生成的连接点坐标
        connectionsNum = opts.baseConnections + Math.random() * opts.addedConnections | 0, // 计算连接数量(基础连接数 + 随机额外连接数)
        attempt = opts.connectionAttempts, // 连接尝试次数

        alpha, beta, len, // 用于生成新连接点的角度和长度
        cosA, sinA, cosB, sinB, // 预先计算的正弦和余弦值
        pos = {}, // 存储新连接点的坐标
        passedExisting, passedBuffered; // 用于检查新连接点是否有效

    // 循环尝试生成新连接点,直到达到连接数量或尝试次数上限
    while (links.length < connectionsNum && --attempt > 0) {
        // 生成随机角度和长度
        alpha = Math.random() * Math.PI; // 垂直角度
        beta = Math.random() * Tau; // 水平角度
        len = opts.baseDist + opts.addedDist * Math.random(); // 连接长度

        // 预先计算正弦和余弦值
        cosA = Math.cos(alpha);
        sinA = Math.sin(alpha);
        cosB = Math.cos(beta);
        sinB = Math.sin(beta);

        // 计算新连接点的坐标
        pos.x = this.x + len * cosA * sinB;
        pos.y = this.y + len * sinA * sinB;
        pos.z = this.z + len * cosB;

        // 检查新连接点是否在允许的范围内
        if (pos.x * pos.x + pos.y * pos.y + pos.z * pos.z < squareRange) {
            // 初始化通过检查标志
            passedExisting = true;
            passedBuffered = true;

            // 检查新连接点是否与现有节点太近
            for (var i = 0; i < connections.length; ++i)
                if (squareDist(pos, connections[i]) < squareAllowed)
                    passedExisting = false;

            // 如果新连接点通过了现有节点检查,则检查是否与已缓冲的连接点太近
            if (passedExisting)
                for (var i = 0; i < links.length; ++i)
                    if (squareDist(pos, links[i]) < squareAllowed)
                        passedBuffered = false;

            // 如果新连接点通过了所有检查,则将其添加到连接点列表
            if (passedExisting && passedBuffered)
                links.push({ x: pos.x, y: pos.y, z: pos.z });
        }
    }

    // 如果没有生成任何连接点,则将其标记为末端节点
    if (links.length === 0)
        this.isEnd = true;
    else {
        // 创建新的连接节点
        for (var i = 0; i < links.length; ++i) {
            var pos = links[i], // 获取连接点坐标
                connection = new Connection(pos.x, pos.y, pos.z, this.size * opts.sizeMultiplier); // 创建新节点

            // 将新节点添加到连接列表和全局节点列表
            this.links[i] = connection;
            all.push(connection);
            connections.push(connection);
        }
        // 将新节点添加到待建立列表,以便进一步生成子节点
        for (var i = 0; i < this.links.length; ++i)
            toDevelop.push(this.links[i]);
    }
}

渲染逻辑

  • window.requestAnimationFrame(anim);:
    • 这是动画循环的核心。它告诉浏览器在下一次重绘之前调用 anim 函数。
    • 浏览器会优化这个调用,使其与显示器的刷新率同步,从而实现流畅的动画,并在页面不可见时节省资源。
    • 这创建了一个递归循环:anim 函数自身,由浏览器的渲染周期驱动。
  • Canvas 准备工作:
    • ctx.globalCompositeOperation = 'source-over';: 设置 Canvas 的合成模式为默认的 'source-over'。这意味着新的像素会覆盖在旧的像素之上,确保每一帧都干净地绘制。
    • ctx.fillStyle = opts.repaintColor;: 设置 Canvas 的填充颜色。opts.repaintColor 变量通常存储一个深色值 (例如 #111),用作背景色。
    • ctx.fillRect(0, 0, w, h);: 使用当前填充颜色清空整个 Canvas。这会擦除上一帧的画面,为绘制新的一帧做准备。
  • 帧计数器:
    • ++tick;: 递增 tick 变量,它用来记录已经渲染了多少帧。这个计数器用于控制 3D 场景的旋转。
  • 计算旋转角度:
    • var rotX = tick * opts.rotVelX, rotY = tick * opts.rotVelY;: 计算 X 轴和 Y 轴的旋转角度。
      • tick: 当前帧数。
      • opts.rotVelX, opts.rotVelY: 在 opts 对象中定义的旋转速度 (弧度/帧)。
    • 旋转角度随着帧数线性增加,使场景以恒定速度旋转。
  • 预先计算正弦和余弦:
    • cosX = Math.cos(rotX); sinX = Math.sin(rotX); cosY = Math.cos(rotY); sinY = Math.sin(rotY);: 计算旋转角度的正弦和余弦值。
    • 这些值对于执行 3D 旋转变换至关重要。 在这里计算它们是为了提高效率,避免在 setScreen 函数 (它会被每个节点和粒子调用) 中重复计算。
  • 添加数据粒子:
    • if (data.length < connections.length * opts.dataToConnections) { ... }: 检查场景中的数据粒子数量是否少于期望的数量。粒子数量与连接点 (节点) 的数量相关。
    • var datum = new Data(connections[0]);: 创建一个新的 Data 对象 (数据粒子)。 它被初始化为连接到某个连接点 (可能是根节点)。
    • data.push(datum); all.push(datum);: 将新粒子添加到 data 数组 (专门用于存储粒子) 和 all 数组 (存储所有需要渲染的物体) 中。
  • 准备绘制线框:
    • ctx.globalCompositeOperation = 'lighter';: 将合成模式设置为 'lighter'。 这种模式会将新像素的颜色与 Canvas 上已有的颜色相加。 当多个线条或形状重叠时,重叠区域会变得更亮,产生发光效果。 这用于使 3D 结构的线框更加突出。
    • ctx.beginPath();: 开始一条新的绘制路径。路径是由一系列绘图命令 (线段、弧形等) 组成的序列。
    • ctx.lineWidth = opts.wireframeWidth; ctx.strokeStyle = opts.wireframeColor;: 设置将要绘制的线条的样式:粗细和颜色。
  • 更新所有物体:
    • all.map(function (item) { item.step(); });: 这是关键的一步。 它遍历 all 数组中的每个物体 (包括 Connection 和 Data 对象),并调用每个物体的 step() 方法。
      • 对于 Connection 对象,step() 方法计算其在 2D 屏幕上的坐标 (使用之前计算的 cosX、sinX、cosY、sinY 进行旋转),并设置其颜色。它还会准备绘制到其子节点的线条。
      • 对于 Data 对象,step() 方法更新数据粒子的位置,因为它从一个连接点移动到另一个连接点,并计算其屏幕坐标和颜色。
  • 绘制线框:
    • ctx.stroke();: 这个命令实际地绘制线框。 Connection 对象的 step() 方法已经定义了要绘制的线条 (从每个节点到其子节点)。 ctx.stroke() 使用之前设置的 lineWidth 和 strokeStyle 绘制这些线条,从而创建线框效果。
  • 准备深度排序:
    • ctx.globalCompositeOperation = 'source-over';: 将合成模式恢复为默认值 'source-over'。 'lighter' 模式只用于绘制线框。 现在,我们需要按照正确的顺序绘制物体,使近处的物体遮挡住远处的物体。
    • all.sort(function (a, b) { return b.screen.z - a.screen.z; });: 根据物体在屏幕上的 z 坐标对 all 数组中的物体进行排序。
      • a.screen.z, b.screen.z: 物体投影到 2D 屏幕后的 Z 坐标。 较小的 z 值表示物体离观察者更近。
      • b.screen.z - a.screen.z: 这个排序函数将物体按 z 坐标降序排列。 z 值较大的物体 (更远) 会排在数组的前面,这意味着它们会首先被绘制。 这可能与你最初的直觉相反,但它是正确的,可以实现Painter's Algorithm。
  • 绘制所有物体 (按深度排序):
    • all.map(function (item) { item.draw(); });: 这个命令遍历排序后的 all 数组,并调用每个物体的 draw() 方法。
      • 因为数组是按 Z 坐标排序的 (从远到近),所以物体会按照正确的顺序绘制,从而产生 3D 空间的感觉。 较远的物体先绘制,然后较近的物体在它们之上绘制,遮挡住较远的物体。
      • 对于 Connection 对象,draw() 方法绘制一个表示节点的圆形。
      • 对于 Data 对象,draw() 方法绘制一个表示其运动的线段。
  • 调试逻辑:
    • 在消失点绘制一个红色圆圈,其半径与场景的深度和焦距有关。这可能用于可视化透视投影和 3D 空间的范围。
  • window.addEventListener('resize', function () { ... });:
    • 这会设置一个事件监听器,在浏览器窗口大小改变时被调用。
    • 在监听器内部:
      • opts.vanishPoint.x = (w = c.width = window.innerWidth) / 2;: 将消失点的 X 坐标更新为新的窗口宽度的一半。 w 变量会被更新为新的宽度。
      • opts.vanishPoint.y = (h = c.height = window.innerHeight) / 2;: 将消失点的 Y 坐标更新为新的窗口高度的一半。 h 变量会被更新为新的高度。
      • ctx.fillRect(0, 0, w, h);: 使用背景色清空整个 Canvas。 这是必要的,以便在窗口大小改变后正确地重绘场景。
  • window.addEventListener('click', init);:
    • 这会设置一个事件监听器,在用户点击浏览器窗口中的任何位置时被调用。
    • 在监听器内部:
      • init();: 调用 init 函数。 init 函数会初始化整个 3D 场景,创建连接点 (节点) 并设置初始状态。 这允许用户通过单击窗口来重置动画。
function anim() {  
    //请求下一个动画帧  
    window.requestAnimationFrame(anim);

    // 准备 Canvas 以绘制新的一帧  
    ctx.globalCompositeOperation \= 'source-over';  // 重置 Canvas 的合成模式  
    ctx.fillStyle \= opts.repaintColor;         // 设置填充颜色为背景色  
    ctx.fillRect(0, 0, w, h);            // 使用背景色清空整个 Canvas

    // 更新帧计数器  
    \++tick;

    // 计算这一帧的旋转角度  
    var rotX \= tick \* opts.rotVelX,    // 绕 X 轴旋转的角度  
        rotY \= tick \* opts.rotVelY;    // 绕 Y 轴旋转的角度

    // 计算旋转角度的正弦和余弦值 (用于 3D 变换)  
    cosX \= Math.cos(rotX);  
    sinX \= Math.sin(rotX);  
    cosY \= Math.cos(rotY);  
    sinY \= Math.sin(rotY);

    // 可能会添加新的数据粒子  
    if (data.length \< connections.length \* opts.dataToConnections) {  
        var datum \= new Data(connections\[0\]); // 创建一个新的数据粒子  
        data.push(datum);         // 将其添加到数据数组中  
        all.push(datum);          // 并添加到包含所有物体的数组中  
    }

    //  准备绘制 3D 结构  
    ctx.globalCompositeOperation \= 'lighter'; // 设置合成模式为 'lighter',实现发光效果  
    ctx.beginPath();             // 开始一条新的绘制路径  
    ctx.lineWidth \= opts.wireframeWidth;    // 设置线条宽度  
    ctx.strokeStyle \= opts.wireframeColor;  // 设置线条颜色

    // 更新场景中所有物体的状态  
    all.map(function (item) {  
        item.step(); // 调用每个物体的 step() 方法来更新其状态 (位置、颜色等)  
    });

    // 绘制 3D 结构的线框  
    ctx.stroke();

    // 准备按深度排序物体  
    ctx.globalCompositeOperation \= 'source-over'; // 将合成模式恢复为默认值  
    all.sort(function (a, b) {  
        return b.screen.z \- a.screen.z; // 按 Z 坐标 (深度) 降序排列物体 (远处的物体在前)  
    });

    // 绘制所有物体 (节点和粒子)  
    all.map(function (item) {  
        item.draw(); // 调用每个物体的 draw() 方法来在 Canvas 上绘制它  
    });

    // 用于调试,绘制一个圆形  
    ctx.beginPath();  
    ctx.strokeStyle \= 'red';  
    ctx.arc(opts.vanishPoint.x, opts.vanishPoint.y, opts.range \* opts.focalLength / opts.depth, 0, Tau);  
    ctx.stroke();  
    \*/  
}

// 窗口大小改变事件的监听器  
window.addEventListener('resize', function () {  
    opts.vanishPoint.x \= (w \= c.width \= window.innerWidth) / 2; // 更新消失点的 X 坐标  
    opts.vanishPoint.y \= (h \= c.height \= window.innerHeight) / 2; // 更新消失点的 Y 坐标  
    ctx.fillRect(0, 0, w, h);             // 使用背景色重新填充整个 Canvas  
});

// 鼠标点击事件的监听器  
window.addEventListener('click', init); // 点击窗口时重新初始化场景

Continue..未完待续

React 和 Next.js 的基础知识对比

React 和 Next.js 是两个非常流行的前端开发工具,它们在文件命名、路由管理、渲染方式、代码组织层次以及错误处理等方面存在一些关键差异。下面我们将通过简单的表达方式来介绍这些差异,并提供一些示例代码和案例,让大家更容易理解。

1. 文件命名和组织结构

React

  • 文件命名:通常使用 kebab-case(例如 my-component.tsx),组件名使用 PascalCase(例如 MyComponent)。
  • 组织结构:根据功能模块划分,如 componentscontainers 等。

Next.js

  • 文件命名:在 pages 目录下,文件名直接对应路由路径(例如 index.tsx 对应 / 路由)。
  • 组织结构:包括 apppagespublic 等标准化目录。

示例

// React
// components/my-component.tsx
import React from 'react';

function MyComponent() {
  return Hello World!;
}

export default MyComponent;

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

function HomePage() {
  return (
    
      
        Home Page
      
      Welcome to Home Page
    
  );
}

export default HomePage;

2. 路由管理

React

  • 路由管理:需要额外引入 React Router 等库来处理路由。

Next.js

  • 路由管理:内置路由管理功能,通过文件系统自动映射路由。

示例

// React (使用 React Router)
import { BrowserRouter, Route, Link } from 'react-router-dom';

function App() {
  return (
    
      
        Home
        
      
    
  );
}

// Next.js
// pages/about.tsx
function AboutPage() {
  return About Page;
}

export default AboutPage;

3. 渲染方式

React

  • 渲染方式:默认使用客户端渲染(CSR),需要额外配置才能实现服务器端渲染(SSR)。

Next.js

  • 渲染方式:支持服务器端渲染(SSR)、静态站点生成(SSG)和增量静态再生(ISR)。

示例

// React (CSR)
import React from 'react';

function App() {
  return Hello World!;
}

// Next.js (SSR)
import { GetServerSideProps } from 'next';

function HomePage({ data }) {
  return {data.title};
}

export const getServerSideProps: GetServerSideProps = async () => {
  const res = await fetch('https://api.example.com/data');
  const data = await res.json();
  return { props: { data } };
};

4. 代码组织层次

React

  • 代码组织:通常根据功能或组件进行组织,没有特定的层次结构约定。

Next.js

  • 代码组织:通常遵循特定的层次结构,如 appcomponentsservices 等目录。

示例

// React
// components/
//   MyComponent.tsx
// containers/
//   MyContainer.tsx

// Next.js
// app/
//   dashboard/
//     page.tsx
// components/
//   MyComponent.tsx

5. 错误处理

React

  • 错误处理:需要手动实现错误边界(Error Boundary)等功能。

Next.js

  • 错误处理:提供了内置的错误处理机制,包括使用 ErrorBoundary 组件来捕获和处理错误。

示例

// React
import React, { Component } from 'react';

class ErrorBoundary extends Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return Something went wrong.;
    }

    return this.props.children;
  }
}

// Next.js
// pages/_app.tsx
import ErrorBoundary from 'next/error';

function MyApp({ Component, pageProps }) {
  return (
    
      
    
  );
}

综上所述,Next.js 提供了一个更为完整的应用框架,包括路由、渲染方式、错误处理等方面的支持,而 React 则更为灵活,需要额外的库和配置来实现类似的功能。

如何自建一个类似antd的组件库?

一、自研的优缺点

优点:

  • 代码控制力强,可以结合不同组件库的特性。
  • 可以使用社区最新的生态组件,没有历史遗留负担。

缺点:

  • 开发成本高
  • 维护成本高

我放弃 antd 的理由 - 掘金

关于自建组件库的思考 - 掘金

二、组件库的概念区分

juejin.cn/post/723971…

从上图可以很清晰的看出,我们所构建的 UI 分为 3 个部分。

  • CSS ++
  • Behavior Libraries
  • Style System

我们所熟知的 antd 就是由他的行为库 react-component 加上设计规范 Antd Design 构成。在我们使用 antd 来构建 UI 的时候,通常我们会加上自己的样式扩展,三者交汇在一起,构成了我们的 UI

这在纯应对业务上没有什么问题,可以说算是比较不错的选择。但是很多时候我们不仅仅需要满足功能上的需求,更重要的是我们需要足够的个性化以及差异化。

三、技术选型

(1)Behavior Libaries 选择 shadcn/ui

www.libhunt.com/compare-ui-…

headlessui的组件数量比较有限,像Form、Button都没有。

shadcn/ui文档更加完善一些,包括主题、深浅色如何配置,更加细致,不过2023年才出。通过copy形式进行代码二次编写

(2)Css++使用的tailwindcss

四、理解antd、arco、semi等组件库的机制思路。

“在 Design Token 中我们提供了一套更加贴合设计的三层结构,将 Design Token 拆解为 Seed Token、Map Token 和 Alias Token 三部分。这三组 Token 并不是简单的分组,而是一个三层的派生关系,由 Seed Token 派生 Map Token,再由 Map Token 派生 Alias Token。在大部分情况下,使用 Seed Token 就可以满足定制主题的需要。但如果您需要更高程度的主题定制,您需要了解 antd 中 Design Token 的生命周期。”

antd4 的less变量对应着 antd5的component token。ant.design/docs/react/…

antd作为开源库,有了这种seed token就可以轻易定制主题,十分方便。

公司内部建设组件库,不建议使用这种阶梯型的token机制,因为没有应用场景,而且会增加token之间的算法、如何映射维护的复杂度;以及token命名会变得复杂。

另外arco.design/react/docs/… antd的map token:ant.design/theme-edito… token的阶梯概念。

五、公司组件库怎么去支持主题定制的需求??

(一)全局:参照antd map token 和arco设置全局的 token变量值。

(二)组件内:根据token使用css变量,

可以考虑使用tailwindcss 实现样式ui.shadcn.com/docs/themin…

六、暗黑模式和浅色模式

(一)antd方法

通过修改算法可以快速生成风格迥异的主题,5.0 版本中默认提供三套预设算法,分别是默认算法 theme.defaultAlgorithm、暗色算法 theme.darkAlgorithm 和紧凑算法 theme.compactAlgorithm。你可以通过修改 ConfigProvider 中 theme 属性的 algorithm 属性来切换算法。

你的页面的整体风格,需要利用一些 CSS 变量 调整为暗色。

(二)arco参照: arco.design/react/docs/…

通过设置 arco-theme 为 dark,只是将组件库切换成了暗色模式,你的页面的整体风格,需要利用一些 CSS 变量 调整为暗色。具体可查看变量

七、组件库的默认参数值

(1) arco实现了更改组件默认配置功能;antd没有该功能。

(2)antd有修改组件的classnames和style的功能(5.7.0)

八、组件库和D2C

(一)antd官方维护的是sketch组件包;有非官方维护的figma。

ant.design/docs/resour…

(二)semi对figma的支持更好?semi.design/zh-CN/start…

(1)主题定制后,可以通过C2D生成UIKIT, 避免UI手工大量工作去生成UIKIT;这部分工作也是UI不愿意去维护的。

目前antd无法做到这一点。

(2)semi同时支持D2C的功能。这点antd也没有去实现。

各个大厂是怎么实现组件库和相应扩展的?基础组件、区块、页面

一、antd pro 方案

总结:antd 的颗粒度是组件细粒度的;antd pro可以看做区块、page页面级别颗粒度的组件库,将业务常见的交互预设融入组件库中。

所以antd 更加灵活,可以组装不同的业务场景;在特定的场景下,antd pro的开发效率更高,但是难以扩展。

(一)、antd pro定位是一个前端脚手架,用于快速搭建中后台页面。

官网地址:pro.ant.design/zh-CN/docs/…

跟Umi做的事情有点类似,umi定位是前端开发框架。

(二)、antd pro的模板组件pro-components

官网地址:procomponents.ant.design/

(1)技术栈分析

每个业务组件都是一个包;mono单仓库,采用pnpm-workspace 和 lerna实现。

另外,antd的底层逻辑组件库react-component是一百多个单仓库组成github.com/orgs/react-… (大部分采用dumi框架,也有瞥见使用umi框架的)

(二)文档

文档说明写得一般,demos不如antd那么细致,可读性较差。跟组件功能糅杂了太多基础组件也有关系。

(三)优劣势

优势:pro-components的组件提升了开发效率,通过props暴露功能。

劣势1:pro-components的组件 预设了很多行为和交互,对于无UI要求的项目,是可以考虑的。有UI要求的,灵活性太差了,不能直接安装使用。

劣势2:需要理解新增的props的用途,文档引导不够完善的情况下,反而拖累了开发效率。前期需要大量阅读文档和试验组件特性

二、arco Pro 方案

arco.design/docs/materi…

何为物料

在中后台项目开发中,我们早已习惯了使用 UI 组件库提升开发效率。基础的 UI 组件库极大降低了界面开发成本,但此类组件通常都是一些与业务逻辑完全解耦的原子组件,不能完全满足复杂的业务场景。

实际项目中包含了大量的可以复用的业务逻辑强耦合的模块,例如站点导航条、员工选择器。如何在团队内部最大程度复用基础业务模块以提升效率、降低冗余?物料的概念就是为了解决这一问题,将基础业务模块从项目之中抽离,统一维护,它们便可以被称为「物料」。

物料分类

在 Arco 的物料体系中,我们将物料细分为了三个种类:组件、区块、页面。

组件

组件的概念与 Arco 提供的基础组件最为接近,它们是页面的基础元素构成,但是可能与业务逻辑产生了耦合。使用方式与组件库保持一致,通过引入 NPM 依赖来引用组件。

区块

区块相比组件更为复杂,可以理解为多个组件的集合。一个页面通常由若干个区块组成,开发者可以将区块添加到自己的页面进行二次开发。由于区块的复杂性和二次开发的需求存在,区块使用方式与组件不同,本质上是将区块的源码直接下载到本地项目之中。

arco.design/material/de…

在项目中执行以下语句,会在src/pages插入arco-mono-input 目录以及相关的区块内容。

arco block use @zdzd/arco-mono-input 

页面

页面即其字面意思,使用方式与区块类似。典型的例子为 ArcoDesign Pro 所提供的页面。

Arco 提供了基于 Node 的脚手架工具 arco-cli,它大致包含了以下的功能:

  • 根据模板创建基于 ArcoDesign 的物料项目;
  • 发布、管理、使用物料;
  • 在项目中使用物料区块或者页面;

脚手架内置了自己的“dumi”去实现业务组件的开发。

三、semi 没有实现pro方案,

四、个人意见

思考点:

  1. 区块组件、页面组件更适合作为模板的形式,而不是 npm 包的形式。

  2. 区块组件、页面组件,通过脚手架或者copy,怎么管理依赖问题?约束性:一般就是将所有用到的utils等方法放在一个目录下,只依赖基础组件库和react。如果需要其他特殊依赖,就得在demo文档说明。

参考:如何看待字节同时开源 Arco 和 Semi,它们和 Ant Design 有什么区别和优势? - 知乎 www.zhihu.com/question/49…

服务端渲染原理解析

一. 什么是服务端渲染

随着ReactVue等前端框架盛行,SPA也成为前端业务开发中最常用的开发方式,其主要特点是采用客户端渲染。但客户端渲染有一个缺点是对SEO不友好,所以衍生出服务端渲染解决方案。

简单来说,就是访问一个站点时,会在服务端先解析执行要返回的页面内容html片段,然后插入到html文档中返回给客户端,此时客户端拿到的html文档就是带有页面内容,而不是空文档,然后再执行客户端的水合过程,完成DOM树更新。

二. 服务端渲染原理

注意: 本文主要讲解服务端同步渲染流程,异步渲染流程会另写一篇文章单独讲解。

主要理解两个要点,一个是服务端如何解析获取html片段,另一个是客户端如何完成水合过程。

2.1 renderToString

renderToString方法是React提供的API,主要作用是将虚拟DOM树转换成对应的html片段。

代码示例如下,即将要渲染的组件作为入参传入即可获取对应的html片段

function HelloWorld() {
  return (
    <div>
      <h1>hello world</h1>
    </div>
  )
}

function App() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <h1 onClick={() => setCount(count + 1)}>{count}</h1>
      <HelloWorld />
    </div>
  )
}

const html = renderToString(<App />)
// 输出结果:<div><h1>0</h1><div><h1>hello world</h1></div></div>

主要关注以下处理逻辑:

  • 解析获取html片段逻辑
  • 标签属性处理逻辑
  • Hook方法调用逻辑

2.1.1 解析获取html片段

首先<App />会转换成React.createElement(App, null)方法调用,创建对应的ReactElement对象实例,接着将其作为起始vnode,采用深度优先遍历算法递归遍历child vnode,遍历过程中会同步收集对应的html片段,递归结束后进行拼接即可获取完整的html

function pushStartGenericElement(target, props, tag) {
  target.push(`<${tag}`)
  let children = null
  for (const propKey in props) {
    switch (propKey) {
      case 'children':
        children = props[propKey]
        break
      default:
        pushAttribute(target, propKey, props[propKey])
    }
  }
  target.push('>')
  if (typeof children === 'string') {
    target.push(children)
    return null
  }
  return children
}

function renderElement(task, type, props) {
  if (typeof type === 'function') {
    const children = type(props)
    task.node = children
    retryNode(task)
  } else if (typeof type === 'string') {
    const { chunks } = task.blockedSegment
    const children = pushStartGenericElement(chunks, props, type)
    task.node = children
    retryNode(task)
    chunks.push(`</${type}>`)
  }
}

function renderChildrenArray(task, children) {
  for (let i = 0; i < children.length; i++) {
    task.node = children[i]
    retryNode(task)
  }
}

function retryNode(task) {
  const {
    node,
    blockedSegment: { chunks },
  } = task
  if (node === null) return
  if (Array.isArray(node)) {
    renderChildrenArray(task, node)
    return
  }
  if (typeof node === 'object') {
    switch (node.$$typeof) {
      case REACT_ELEMENT_TYPE:
        renderElement(task, node.type, node.props)
        break
    }
    return
  }
  chunks.push(`${node}`)
}

function renderToString(children) {
  const task = {
    node: children, // 当前vnode
    blockedSegment: {
      chunks: [], // html片段
    },
  }
  // 递归遍历vnode,收集html片段
  retryNode(task)
  let result = ''
  // 拼接html片段
  const {
    blockedSegment: { chunks },
  } = task
  for (let i = 0; i < chunks.length; i++) {
    result += chunks[i]
  }
  return result
}

2.1.2 标签属性

这里主要举例classNamestyle的处理逻辑,需要注意的是不会处理事件属性。

const uppercasePattern = /([A-Z])/g

function pushStyleAttribute(target, style) {
  const ans = []
  for (const styleName in style) {
    const styleValue = style[styleName]
    ans.push(
      `${styleName
        .replace(uppercasePattern, '-$1')
        .toLowerCase()}:${styleValue}`,
    )
  }
  target.push(` style="${ans.join(';')}"`)
}

function pushAttribute(target, name, value) {
  switch (name) {
    case 'className':
      target.push(` class="${value}"`)
      break
    case 'style':
      pushStyleAttribute(target, value)
      break
  }
}

2.1.3 Hook方法调用

服务端渲染有独立一套Hook方法,这里主要举例useStateuseEffect两个Hook方法调用逻辑。逻辑都比较简单,useEffect是空函数调用,useState主要是处理初始值。

function noop() {}

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action
}

function useState(initialState) {
  return useReducer(basicStateReducer, initialState)
}

function useReducer(reducer, initialState) {
  // 如果传入的初始值是functIon,则调用执行获取返回值作为初始state值
  if (typeof initialState === 'function') initialState = initialState()
  return [initialState, noop]
}

export const HooksDispatcher = {
  useState,
  useReducer,
  useEffect: noop,
  useLayoutEffect: noop,
}

2.2 hydrateRoot

注意: hydrateRootcreateRoot整体逻辑很类似,读者可以参考文档手写mini React,理解React渲染原理了解客户端渲染时React渲染流程。本文主要解析服务端渲染和客户端渲染的不同点。

当采用服务端渲染,客户端需要调用hydrateRoot方法构建React应用。该方法接收两个入参,第一个是挂载dom节点,第二个是要渲染的组件。

代码示例如下

import { hydrateRoot } from 'react-dom/client'
import App from './App'

hydrateRoot(document.querySelector('#app'), <App />)

服务端渲染触发的React渲染流程最大的差异点在于首次渲染,由于采用服务端渲染,挂载节点下会有初始内容,即DOM树。那在首次渲染构建虚拟DOM树时会判断当前的DOM树节点是否可以复用,可以则直接赋值给FiberNodestateNode属性,就不需要额外创建DOM节点,其优点在于在更新DOM阶段只需要对当前DOM树做颗粒度更新即可,优化性能。

2.2.1 enterHydrationState

enterHydrationState方法用于处理根FiberNode,核心逻辑如下:

  • isHydrating变量赋值为true,表示当前渲染处于hydrate阶段
  • 获取挂载节点的firstChild并赋值给nextHydratableInstance变量
  • 将根FiberNode赋值给hydrationParentFiber变量
// 判断是否处于hydrate阶段
let isHydrating = false
// 当前hydrate fiber节点
let hydrationParentFiber = null
// 当前hydrate dom节点
let nextHydratableInstance = null

function enterHydrationState(fiber) {
  const parentInstance = fiber.stateNode.containerInfo
  nextHydratableInstance = getFirstHydratableChild(parentInstance)
  isHydrating = true
  hydrationParentFiber = fiber
}

2.2.2 tryToClaimNextHydratableInstance

tryToClaimNextHydratableInstance方法用于处理标签节点类型的FiberNode,核心逻辑如下:

  • 将当前hydrate dom节点赋值给FiberNodestateNode属性
  • 获取下一个hydrate dom节点并赋值给nextHydratableInstance变量
function tryToClaimNextHydratableInstance(fiber) {
  if (!isHydrating) return
  if (nextHydratableInstance !== null) {
    // 将当前hydrate dom节点赋值给fiber的stateNode属性
    fiber.stateNode = nextHydratableInstance
    hydrationParentFiber = fiber
    // 获取下一个hydrate dom节点
    nextHydratableInstance = getFirstHydratableChild(nextHydratableInstance)
  }
}

2.2.3 tryToClaimNextHydratableTextInstance

tryToClaimNextHydratableInstance方法用于处理纯文本类型的FiberNode。逻辑比较简单,需要注意的是纯文本节点是没有child节点的,所以将nextHydratableInstance赋值为null即可。

function tryToClaimNextHydratableTextInstance(fiber) {
  if (!isHydrating) return
  if (nextHydratableInstance !== null) {
    fiber.stateNode = nextHydratableInstance
    hydrationParentFiber = fiber
    nextHydratableInstance = null
  }
}

2.2.4 popHydrationState

当遍历到叶子节点时会调用popHydrationState方法,核心逻辑如下:

  • hydrationParentFiber赋值为当前FiberNode的父节点
  • 获取下一个hydrate dom节点并赋值给nextHydratableInstance变量
function popToNextHostParent(fiber) {
  hydrationParentFiber = fiber.return
  while (hydrationParentFiber) {
    switch (hydrationParentFiber.tag) {
      case HostRoot:
      case HostComponent:
        return
      default:
        hydrationParentFiber = hydrationParentFiber.return
    }
  }
}

function popHydrationState(fiber) {
  if (!isHydrating) return false
  popToNextHostParent(fiber)
  nextHydratableInstance = hydrationParentFiber
    ? getNextHydratableSibling(fiber.stateNode)
    : null
  return true
}

三. 总结

本篇文章中主要介绍了服务端渲染的同步执行流程。首先需要在服务端拼接需要返回的html片段,其核心原理是通过深度优先遍历算法递归遍历vnode,获取其对应的html片段,最后进行拼接。接着客户端会先渲染初始内容的DOM树,然后构建React应用完成水合过程,其核心原理是在构建虚拟DOM树时判断是否可以复用当前DOM树的节点,优化更新DOM树阶段的性能。代码仓库

创作不易,如果文章对你有帮助,那点个小小的赞吧,你的支持是我持续更新的动力!

vue2升级vue3踩坑——【依赖注入】可能成功了,但【依赖注入】成功了不太可能

背景

博主最近做一些页面迁移的工作,即将某些页面以子应用的方式嵌入到其他系统的基座应用中(基于micro-app这一微前端方案实现)。

在迁移过程中,和主管聊了聊,觉得这是一个将老项目从vue2升级成vue3的契机,于是便说干就干了。

image.png

框架升级最优先的工作,我认为是梳理,明确哪些功能或写法是以前过度设计了,哪些是滥用了等等。

本文主要讲的是我在梳理完一些页面后,发现它们在状态管理方面存在滥用第三方库的现象,导致很多完全没必要依赖vuex的地方,使用了vuex。

(根本就没用上vuex提供的别的功能!!!🤯)

我的解决方案是使用reactive维护store,再将这个类似store的状态变量provide出去,从而替换掉原先vue2版本项目中的通过vuex引入store的写法。

看到这里可能有同学会问了:“那么什么时候推荐使用vuex或者pinia这些状态管理库呢?”

这个话题我们下回再讨论~😊

本篇博客主要想和大家分享,我在使用【依赖注入】时发现的几种方式与其特点(优、劣)

如果选择了错误的方式,则会让页面出现丢失响应式的情况,导致渲染异常

image.png

信息对齐

什么是vue的响应式?

c4cf6e8b211e9e41e23f5d0a2c75261.png

什么是丢失响应式?

建立在上一小节内容的基础上,本文中所讨论的“丢失响应式”,就是指模板中使用的值发生变化后,Vue并不会检测这个变化,也不会相应地更新DOM,从而导致渲染异常的现象。

使用【依赖注入】的几种方式与其特点

  1. 当一个reactive对象被provide出去之后,如果在子孙组件以下方的方式使用,则会丢失响应式

script setup:

const compareMessage = inject('compareMessage')
const compareMessageContent =  compareMessage.content
// 👆等同于对一个reactive进行解构赋值的写法
// const { content }  = compareMessage

template:

 <p>
     <!-- 会丢失响应式 -->
    compareMessage to grand child: <em>{{ compareMessageContent}}</em>
  </p>
  1. 当一个reactive对象被provide出去之后,如果期望它还是响应式的数据,则可以有以下几种方式:
  • 直接在template中使用xxx.yyyy的方式,如:
 <p>
    compareMessage to grand child: <em>{{ compareMessage.content }}</em>
  </p>
  • 可以使用computed进行包裹,不过不太推荐🙅‍,因为计算属性主要用于描述依赖响应式状态的复杂逻辑

script setup:

const compareMessage = inject('compareMessage')
const compareMessageContent = computed(() => compareMessage.content) 

template:

 <p>
      <!-- 不会丢失响应式 -->
    compareMessage to grand child: <em>{{ compareMessageContent}}</em>
  </p>

  • 可以使用toRefs,将其改为响应式,这种解法就更不推荐了×,硬解,和使用computed一样,toRefs最适合用在从组合式函数中返回响应式对象时
const compareMessage = toRefs(reactive({ content: 'hello2'}))
//...

  <input v-model="compareMessage.content.value">

<script setup>
const compareMessage = inject('compareMessage')
const {content} = compareMessage

</script>
<template>
<p>compareMessage to grand child: <em>{{ content}}</em></p>
</template>

【依赖注入】的推荐写法

  • 👉 个人认为的最佳实践,在provide一个类似于vuex的store对象时,这样去写
const content = ref('hello')
provide('compareMessage', { content })

子孙组件:

<script setup>
const compareMessage = inject('compareMessage')
// 解构赋值
const {content} = compareMessage
// 或者
// const compareMessageContent = compareMessage.content

</script>
<template>
      <!-- 不会丢失响应式 -->
<p>compareMessage to grand child: <em>{{ content}}</em></p>
</template>

结语

我用的最久的技术栈还是React,因此,本文提到的一些使用vue相关特性的方式和给出的解法,都是基于自己目前对于vue的理解。

如果掘友们有更好的想法与解法,欢迎评论区留言~

image.png

Vite 与传统 Bundler(如 Webpack)在 Node.js 应用的性能对比

在 Node.js 应用中,选择合适的构建工具可以显著影响开发效率和应用性能。Vite 和传统的 bundler(如 Webpack)是两种常见的选择。下面我们来比较它们在冷启动时间、热更新、开发服务器性能以及生产环境构建方面的差异。

1. 冷启动时间(Cold Start Time)

  • Vite: Vite 利用 esbuild 预编译依赖项,大大减少冷启动时间。esbuild 是用 Go 编写的,速度比传统的 JavaScript bundler 快 10-100 倍。例如,一个有大量依赖的应用可能在 Vite 中只需 1.5 秒启动,而传统 bundler 可能需要 28 秒

  • 传统 Bundler(如 Webpack): 需要在启动时对整个应用进行构建,这导致冷启动时间较长。

2. 热更新(Hot Module Replacement, HMR)

  • Vite: Vite 的 HMR 设计得非常快且高效,即使在项目规模较大时也能保持快速更新。更新速度通常在 50ms以内。

  • 传统 Bundler(如 Webpack): 传统 bundler 的 HMR 也能实现快速更新,但通常不如 Vite 快,更新速度可能在 100-500ms之间。

3. 开发服务器性能

  • Vite: Vite 利用浏览器的原生 ES 模块功能,减少了 bundler 的工作量,提高了开发服务器的性能。开发服务器可以几乎瞬间启动,甚至在大型项目中也能保持快速响应。

  • 传统 Bundler(如 Webpack): 需要对所有代码进行打包,这可能导致开发服务器的性能较低,尤其是在大型项目中。

4. 生产环境构建

  • Vite: Vite 使用 Rollup 进行生产环境的构建,尽管 Rollup 的速度不如 esbuild,但提供了更好的灵活性和插件支持。例如,Vite 可以在 8.9 秒内完成生产环境构建,而传统工具可能需要 40 秒

  • 传统 Bundler(如 Webpack): 在生产环境构建时可能需要更多配置和优化,尽管可以通过各种插件和配置来优化,但通常需要更多时间和资源。

示例代码

Vite 配置示例

import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    port: 4000, // 指定 Vite 开发服务器端口
    proxy: {
      '/api': 'http://localhost:3000', // 将 API 请求代理到 Node.js 服务器
    },
  },
});

Node.js 服务器示例

import express from 'express';

const app = express();

app.get('/api', (req, res) => {
  res.json({ message: 'Hello from Vite and Node.js!' });
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

总结

Vite 在冷启动时间、热更新速度以及开发服务器性能方面都优于传统的 bundler,如 Webpack。然而,传统 bundler 可能在某些特定场景下提供更多的配置选项和优化可能性。因此,选择 Vite 或传统 bundler,取决于项目的具体需求和开发团队的偏好。

Node.js 中 fs.readFile API 的使用详解

在 Node.js 的开发世界里,文件系统操作是极为常见的任务。Node.js 提供了强大的fs模块来处理文件系统相关的操作,今天我们就来深入探讨fs模块中的fs.readFile API,它用于异步读取文件的内容。

基本语法

fs.readFile的基本语法如下:

fs.readFile(path, [options], callback);
  • path:必选参数,表示要读取的文件路径,可以是绝对路径或相对路径。
  • options:可选参数,可以是一个字符串(指定编码格式)或一个对象(包含encoding, flag等属性)。如果不指定options,读取的结果将是一个原始的 Buffer 对象。
  • callback:必选参数,是一个回调函数,当读取操作完成后会被调用。回调函数接受两个参数,第一个参数err表示错误信息(如果读取过程中发生错误),第二个参数data表示读取到的文件内容。

简单示例

假设我们有一个名为example.txt的文本文件,内容为Hello, Node.js!,现在我们使用fs.readFile来读取它的内容。首先,确保你已经引入了fs模块:

const fs = require('fs');

然后编写读取文件的代码:

fs.readFile('example.txt', 'utf8', (err, data) => {
    if (err) {
        console.error('读取文件时发生错误:', err);
        return;
    }
    console.log('文件内容:', data);
});

在这个例子中,我们指定了utf8编码格式,这样读取到的data就是一个字符串。如果不指定编码格式,data将是一个Buffer对象,你可以通过data.toString('utf8')将其转换为字符串。

处理大文件

对于大文件的读取,fs.readFile可能不是最佳选择,因为它会一次性将整个文件读入内存。但如果只是示例场景下的相对大文件读取(这里假设为几百 KB 到 1MB 左右,实际应用中需根据具体情况评估),仍然可以使用。例如,我们有一个largeFile.txt文件,假设其大小为 500KB:

const fs = require('fs');
fs.readFile('largeFile.txt', 'utf8', (err, data) => {
    if (err) {
        console.error('读取大文件时发生错误:', err);
        return;
    }
    console.log('大文件部分内容:', data.slice(0, 100)); // 这里只打印前100个字符,实际应用可根据需求处理
});

需要注意的是,在处理真正的大文件时,建议使用fs.createReadStream进行流式读取,以避免内存占用过高的问题。

错误处理

在使用fs.readFile时,正确的错误处理至关重要。常见的错误包括文件不存在、权限不足等。如上述示例中,我们在回调函数中通过检查err参数来判断是否发生错误。如果发生错误,err将包含错误的详细信息,我们可以根据这些信息进行相应的处理,比如向用户返回友好的错误提示,或者记录错误日志等。

通过以上对fs.readFile API 的介绍和示例,相信你已经对它有了较为深入的理解。在实际的 Node.js 项目开发中,合理运用fs.readFile可以高效地完成文件读取任务。同时,要根据文件的大小和具体的业务场景,选择最合适的文件读取方式,以确保程序的性能和稳定性。

深入理解 Three.js 中的 PerspectiveCamera

在 Three.js 这个强大的 JavaScript 3D 库中,相机(Camera)是决定我们如何观察 3D 场景的关键要素。其中,PerspectiveCamera(透视相机)模拟了人眼观察世界的方式,为我们创建逼真的 3D 场景效果提供了重要支持。

1. PerspectiveCamera 基础概念

PerspectiveCamera遵循透视投影原理,即离相机越远的物体看起来越小,符合我们日常生活中的视觉经验。这与OrthographicCamera(正交相机)不同,正交相机观察到的物体无论远近大小一致,常用于 2D 场景或需要精确尺寸的工程图等场景。

2. 创建 PerspectiveCamera 实例

在 Three.js 中创建一个PerspectiveCamera实例非常简单,其构造函数接受四个参数:

const camera = new THREE.PerspectiveCamera(
    fov, // 视野角度 (Field of View),通常为45 - 75度
    aspect, // 宽高比,通常为画布的宽度除以高度
    near, // 近裁剪平面,定义相机能看到的最近距离
    far // 远裁剪平面,定义相机能看到的最远距离
);

例如:

const fov = 75;
const aspect = window.innerWidth / window.innerHeight;
const near = 0.1;
const far = 1000;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 0, 5); // 设置相机位置

在上述代码中,我们创建了一个视野角度为 75 度,宽高比根据窗口大小自适应,近裁剪平面为 0.1,远裁剪平面为 1000 的透视相机,并将其位置设置在 z 轴正方向 5 个单位处。

3. 常用属性及调整

视野角度(FOV)

视野角度决定了相机能够看到的场景范围。增大 FOV 会使场景看起来更广阔,如同使用广角镜头;减小 FOV 则会使场景看起来更聚焦,类似长焦镜头。你可以通过修改camera.fov属性来调整视野角度,并调用camera.updateProjectionMatrix()方法更新投影矩阵以应用更改:

// 动态改变视野角度
function changeFOV() {
    camera.fov += 5;
    if (camera.fov > 120) {
        camera.fov = 45;
    }
    camera.updateProjectionMatrix();
}

相机位置与方向

通过camera.position属性可以设置相机在场景中的位置,它是一个THREE.Vector3对象,分别对应 x、y、z 轴坐标。同时,使用camera.lookAt(target)方法可以让相机看向某个目标点,target同样是一个THREE.Vector3对象。例如,让相机看向场景中心 (0, 0, 0):

const target = new THREE.Vector3(0, 0, 0);
camera.lookAt(target);

4. 实际应用示例

下面我们通过一个简单的示例,展示PerspectiveCamera在一个包含立方体的 3D 场景中的应用。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Three.js PerspectiveCamera Example</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
        }
    </style>
</head>
<body>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <script>
        // 创建场景
        const scene = new THREE.Scene();
        // 创建相机
        const fov = 75;
        const aspect = window.innerWidth / window.innerHeight;
        const near = 0.1;
        const far = 1000;
        const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
        camera.position.set(0, 0, 5);
        // 创建渲染器
        const renderer = new THREE.WebGLRenderer();
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(renderer.domElement);
        // 创建立方体
        const geometry = new THREE.BoxGeometry(1, 1, 1);
        const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
        const cube = new THREE.Mesh(geometry, material);
        scene.add(cube);
        function animate() {
            requestAnimationFrame(animate);
            cube.rotation.x += 0.01;
            cube.rotation.y += 0.01;
            renderer.render(scene, camera);
        }
        animate();
    </script>
</body>
</html>

在这个示例中,我们创建了一个简单的 3D 场景,包含一个绿色的立方体。通过PerspectiveCamera,我们能够以透视的方式观察这个立方体,当立方体旋转时,透视效果让其看起来更加真实。

通过以上对PerspectiveCamera的介绍,你可以看到它在 Three.js 构建 3D 场景中的重要性和灵活性。掌握好透视相机的使用,将为你创建丰富多彩、逼真的 3D 世界打下坚实的基础。

Socket.IO 简明教程:实时通信的基础知识

Socket.IO 是一个强大的 JavaScript 库,用于实现客户端和服务器之间的实时、双向通信。它广泛应用于实时聊天、游戏、直播等场景。以下是 Socket.IO 的一些基本概念和使用方法。

1. 连接和断开事件

  • io.on("connection", (socket) => {...}): 当客户端连接到服务器时触发。
  • socket.on("disconnect", () => {...}): 当客户端断开连接时触发。

示例代码

// 服务器端
const io = require('socket.io')(3000);

io.on('connection', (socket) => {
  console.log('客户端连接');
  
  socket.on('disconnect', () => {
    console.log('客户端断开连接');
  });
});

2. 事件发送和监听

  • socket.emit(eventName, ...args): 向客户端或服务器发送事件。
  • socket.on(eventName, (args) => {...}): 监听来自客户端或服务器的事件。

示例代码

// 服务器端
io.on('connection', (socket) => {
  socket.on('hello', (arg) => {
    console.log(arg); // 'world'
    socket.emit('hello', 'got it'); // 回复客户端
  });
});

// 客户端
const socket = io('http://localhost:3000');
socket.emit('hello', 'world');
socket.on('hello', (arg) => {
  console.log(arg); // 'got it'
});

3. 房间管理

  • socket.join(roomName): 将客户端加入到指定房间。
  • socket.leave(roomName): 将客户端从指定房间移除。
  • io.to(roomName).emit(eventName, ...args): 向指定房间内的所有客户端发送事件。

示例代码

// 服务器端
io.on('connection', (socket) => {
  socket.join('room1');
  io.to('room1').emit('message', 'Hello, room1!');
});

4. 错误处理

  • socket.io.on("error", (error) => {...}): 监听连接错误。

示例代码

socket.io.on("error", (error) => {
  console.log('连接错误:', error);
});

5. 重新连接

  • socket.io.on("reconnect", (attempt) => {...}): 监听成功重新连接事件。
  • socket.io.on("reconnect_attempt", (attempt) => {...}): 监听重新连接尝试事件。

示例代码

socket.io.on("reconnect", (attempt) => {
  console.log('成功重新连接,尝试次数:', attempt);
});

6. 心跳机制

Socket.IO 自带心跳机制,用于检测连接状态。心跳间隔可以通过配置进行调整。

7. 数据缓冲

当客户端断开连接时,数据会被缓冲,直到客户端重新连接。这种机制确保了数据不丢失。

总结

Socket.IO 为开发实时应用提供了强大的支持。通过这些基本的 API,你可以轻松实现实时通信、房间管理、错误处理等功能。以下是一个完整的示例代码,展示了如何使用 Socket.IO 建立一个简单的实时聊天应用:

// 服务器端
const express = require('express');
const app = express();
const server = require('http').createServer(app);
const io = require('socket.io')(server);

app.get('/', (req, res) => {
  res.sendFile(__dirname + '/index.html');
});

io.on('connection', (socket) => {
  console.log('客户端连接');
  
  socket.on('chat message', (msg) => {
    io.emit('chat message', msg);
  });
  
  socket.on('disconnect', () => {
    console.log('客户端断开连接');
  });
});

server.listen(3000, () => {
  console.log('服务器启动,监听端口 3000');
});

// 客户端(index.html)

  const socket = io('http://localhost:3000');
  
  document.getElementById('send').addEventListener('click', () => {
    const message = document.getElementById('message').value;
    socket.emit('chat message', message);
    document.getElementById('message').value = '';
  });
  
  socket.on('chat message', (msg) => {
    const item = document.createElement('li');
    item.textContent = msg;
    document.getElementById('messages').appendChild(item);
  });

这个示例展示了如何使用 Socket.IO 实现实时聊天功能,包括客户端与服务器的连接、消息发送和接收。

事件循环

JavaScript 事件循环

单线程的 JavaScript

JavaScript 是单线程的,这意味着它一次只能执行一个任务。然而,由于 JavaScript 的异步特性,它可以同时处理多个任务,而不会阻塞主线程。

  • JavaScript 是单线程的,但是它通过事件循环机制实现了异步编程
  • 事件循环机制包括两个主要部分:调用栈和任务队列
  • 调用栈用于执行同步代码,任务队列用于处理异步代码
  • 当一个异步操作完成时,它的回调函数会被添加到任务队列中,等待调用栈为空时执行
  • 事件循环会不断检查任务队列,如果有任务就将其添加到调用栈中执行
  • 事件循环机制使得 JavaScript 可以在等待异步操作完成的同时继续执行其他代码,从而实现非阻塞的异步编程

同步和异步

同步:代码按照顺序执行,一个任务执行完才会执行下一个任务

异步:代码可以同时执行多个任务,一个任务执行完不会等待其他任务执行完,而是继续执行下一个任务

JavaScript 本身是单线程的,为了处理异步任务,宿主环境(浏览器 /v8)会将其交给其他线程处理, 执行

事件循环

事件循环是宿主环境处理 js 异步操作的方式,让其能够非阻塞式运行的机制

  • 浏览器进程

    • 主进程,无论打开多少个浏览器窗口,它仅有一个,负责浏览器界面显示、用户管理、进程管理等
  • 网络进程

    • 处理网站的数据请求和响应,网络进程内部会开启多个线程,以实现网络请求的异步处理
  • 渲染进程

    • 主要解析 html、css、js 等资源,并生成渲染树、执行布局、绘制,负责页面渲染

浏览器中的 Event Loop

2025-03-15-22-02-46-image.png

  • 宏队列和微队列

    • 宏队列排队宏任务(DOM 操作回调,定时器回调,UI 绘制)
    • 微队列排队微任务(Promise 回调)

除了微队列外,队列的种类和数量可能不同,这取决与浏览器厂商

以 Chrome 为例:

  • 微队列:用于存放需要执行最快的任务,优先级最高,比如:promise.then(),MutationObserve
  • 交互队列:用于存放用户操作后产生的事件任务,优先级仅次于微队列
  • 延迟队列:用于存放定时器到达后的回调任务,优先级次于交互队列

人工合成的事件派发,即直接在代码里写的 dom.click()dispatchEvent()相对于浏览器而言并不是真正的用户交互,会被当作同步任务执行

执行栈和任务队列

JS 在解析一段代码的时候,会将同步代码顺序排在某个地方,即执行栈,然后依此执行里面的函数。

当遇到异步任务就交给其他线程处理,待当前执行栈所有的同步代码执行完成后,会从一个队列中取出已完成的异步任务的回调加入到执行栈中继续执行,遇到异步任务又交给其他线程……如此循环往复

宏任务和微任务

任务队列不止一个,根据任务的种类不同,可以分为微任务(micro task)队列和宏任务(macro task)队列

事件循环的过程中,执行栈在同步代码执行完成后,优先检查微任务队列中是否有任务需要执行,如果没有,再去宏任务队列检查,如此往复

微任务一般在当前循环就会优先执行,而宏任务会等到下一次循环

微任务一般比宏任务先执行,并且微任务队列只有一个,宏任务队列可能有多个

常见的宏任务

  • setTimeout()
  • setInterval()
  • setImmediate()

常见的微任务

  • Promise.then(),Promise.cathch()
  • new MutaionObserver()
  • processs.nextTick()
console.info('同步代码1')
setTimeout(()={
    console.info('setTimeot')
})
new Promise((resolve)=>{
    console.info('同步代码2')
    resolve()
}).then(()=>{
    console.info('promise.then')
})
console.info('同步代码3')

宏任务和微任务的本质区别

对于 promise.then(微任务),当执行到promise.then的时候,浏览器引擎并不会将异步任务交给浏览器其他线程,而是将回调存放在自己的一个任务队列中,待当前执行栈执行完成后,立马去执行promise.then存放的队列 promise.then微任务本身没有多线程参与 setTimeout 有“定时等待”的任务,需要定时器现行执行。ajax 请求有“发送请求”这个任务,需要 HTTP 线程处理 宏任务特征: 有明确的异步任务需要执行回调,并且需要等待异步任务执行完成,比如setTimeoutsetIntervalajax微任务特征: 没有明确的异步执行任务,不需要等待异步任务执行完成,比如promise.thenprocess.nextTick

视图更新渲染

视图重绘之前会先执行 requestAnimationFrame回调

浏览器 JS 异步执行的原理

浏览器是多线程的,当 js 需要执行异步任务的时候,浏览器会启动另一个线程去执行这个任务

const btn = document.querySelector("button");
function handleClick() {}

渲染进程启动后,会开启一个渲染主线程,它是浏览器中最繁忙的线程,负责处理各种任务

  • 解析 html、css,计算样式、布局,构建 DOM 树和 CSSOM 树

  • 处理涂层,绘制页面

  • 执行 js 代码,包括同步代码和异步代码

  • 调用栈(Call Stack)

    • 用于执行同步代码,当调用栈为空时,事件循环会从任务队列中取出一个任务执行
  • 任务队列(Task Queue)

    • 用于存放异步任务,当异步任务完成时,它的回调函数会被添加到任务队列中,等待调用栈为空时执行
  • 微任务队列(Microtask Queue)

Node.js 事件循环

基于 Libuv 实现的,Libuv 是一个跨平台的异步 I/O 库,它提供了事件循环、文件系统操作、网络操作等功能 Libuv: 一个用 C 语言实现的高性能解决单线程非阻塞异步 I/O 的开源库

Node.js Event Loop

  • 宏队列

    • timers(重要)

    • penging callback

      • 调用上一次事件循环没有在 pool 阶段立即执行,而延迟的 IO 回调函数
    • idle prepare

      • 仅供 nodejs 内部使用
    • poll(重要)

    • check(重要)

    • close callback

      • 执行所有注册 close 事件的回调函数
  • 微队列

    • nextTick
    • Promise

timers

定时器队列,负责处理setTimeoutsetInterval的回调函数 不管是nodejs还是浏览器,所有的定时器回调函数都不能精准保证到达时间后立即执行

  • 一是因为计算机硬件和底层操作系统
  • 二是pool阶段对timers阶段的深刻影响。因为在没有满足pool阶段的结束条件前,就无法进入下一次事件循环的timers阶段

Pool

pool成为轮询队列,该阶段会处理timerscheck队列外的绝大多数 IO 回调任务,比如文件读取、监听用户请求等 当事件循环到达该阶段,它的运行方式是:

  • 如果pool队列中有回调任务,则依此执行回调,直到队列清空

  • 如果pool队列中没有回调任务

    • 如果其他队列中后续可能会出现回调任务,则一直等待,等其他队列中后续的回调任务来临时,结束该阶段
    • 如果等待的时间超过预设的时间限制,则也会自动进入下一次事件循环
    • 若其他队列中后续不可能出现回调任务了,则立即结束该阶段,并在本轮事件循环完成后,退出node程序
const fs = require("fs");
const start = Data.now();
setTimeout(() => {
    console.info("setTimeout exe", Data.now() - start);
}, 200);

fs.readFile("/index.js", "utf-8", (err, data) => {
    console.info("file read");
    const start = Data.now();
    while (Data.now() - start < 300) {}
});

check

check称为检查队列,负责处理setImmediate定义的回调函数 setImmediatenodejs特有的定时器,它会在当前事件循环的末尾执行回调函数,类似于setTimeout,但setImmediate的回调函数会在pool阶段结束后立即执行 在nodejs中,setImmediate的执行效率远远高于setTimeout,setImmediate的执行顺序无法预测

setTimeout(() => {
  console.info("setTimeout");
}, 0);
setImmediate(() => {
  console.info("setImmediate");
}

nextTick

我们可以通过process.nextTick()将回调函数加入到nextTick队列中,和通过Promise.resolve().then()将回调函数加入到Promise队列,并且nextTick队列中优先级高于Promise队列。所有process.nextTick()是 nodejs 执行最快的异步操作

Promise 面试

  • Promise 特点
  • 事件循环
  • 解题思路
console.info("1");
new Promise((resolve) => {
    resolve();
    console.info("2");
}).then(() => {
    console.info("3");
});
setTimeout(() => {
    console.info("4");
}, 0);
console.info("5");
  1. new Promise会立即执行,所以会先输出2
  2. resolve或者reject之后状态不再改变,但是后面代码会执行
  3. new Promise((res) => {
       res();
       console.info("test");
       reject();
    });
    
  4. promisethen(catch)回调放入到微任务队列,setTimeout放入到宏任务队列
  5. 调用栈中代码执行完后,先去微任务队列中的任务执行,直到微任务队列为空
  6. 微任务队列为空,取宏任务队列中的一个任务开始执行,然后重复上一步,直到宏任务队列为空

CSS专题之盒模型

前言

石匠敲击石头的第 6 次

CSS 盒模型是前端面试常问的知识点,所以打算写一篇文章来好好梳理一下,如果哪里写的有问题欢迎指出。

什么是盒模型

CSS 盒模型用于描述 HTML 元素尺寸的计算方式和布局规则,决定了浏览器如何计算元素总大小和其在页面中的位置,网页中的任何元素都可以看做是一个盒子。

一个盒模型的实际大小主要由以下三个属性来决定:

  • content(内容): 元素的实际内容,如元素中的文本、图像或其他媒体内容。
  • padding(内边距): 盒子内容与边框之间的空白区域。
  • border(边框): 盒子的边框大小。

image-20250403083811875

⚠️ 注意:margin 外边距不影响盒子的实际大小,但会影响盒子在页面上的占位空间。

盒模型的类型

盒模型分为两种,分别是标准盒模型和替代盒模型。

标准盒模型

W3C 规范制定的标准盒模型,也叫 W3C 盒模型,如果不指定元素是什么盒模型,默认就是标准盒模型(box-sizing: content-box)。

在标准盒模型中,widthheight 设置的是盒子内容区域(content)的大小,盒子最终的实际大小需要加上内边距(padding)和边框(border)的大小

盒子实际宽度 = 元素内容宽度 + 左右内边距大小 + 左右边框大小

盒子实际高度 = 元素内容高度 + 上下内边距大小 + 上下边框大小

image-20250404093011727

举个例子

.box {
  width: 100px;
  height: 100px;
  border: 1px solid #ccc;
  padding: 10px;
}

上述代码盒子实际宽度和高度都为 122px

  • 总宽度 = 内容宽度 + 左右 padding + 左右 border

    = 100px + 10px + 10px + 1px + 1px = 122px

  • 总高度 = 内容高度 + 上下 padding + 上下 border

    = 100px + 10px + 10px + 1px + 1px = 122px

替代盒模型

也叫IE盒模型(怪异盒模型),之所以叫 IE 盒模型,因为该盒模型最早 Internet Explorer(IE)浏览器引入的,并且在 IE5 及更早版本中是默认行为。

后来在 IE6 中允许在标准模式下使用 W3C 盒模型,但在怪异模式(Quirks Mode)中仍然使用 IE 盒模型,所以也叫怪异盒模型。

如今替代盒模型已经是 W3C 规范的一部分,给元素设置 box-sizing: border-box; 即可改变元素的盒模型。

在替代盒模型中 widthheight 设置的是盒子最终的实际大小,内容区域(content)的大小会根据内边距(padding)和边框(border)的大小自动收缩,以此来保证盒子最终的实际大小不变。

盒子实际宽度 = 设定的 width = (收缩的元素内容宽度 + 左右内边距大小 + 左右边框大小

盒子实际高度 = 设定的 height = (收缩的元素内容高度 + 上下内边距大小 + 上下边框大小

image-20250404093409737

举个例子

.box {
  width: 100px;
  height: 100px;
  border: 1px solid #ccc;
  padding: 10px;
  box-sizing: border-box;
}

上述代码盒子实际宽度和高度都为 100px

块级盒子与行内盒子

盒模型定义了每个 HTML 元素的结构(包括 content、padding、border、margin),但是这些盒子在页面中如何进行排列,还需要看它是块级盒子(Block Box)还是行内盒子(Inline Box)。

块级盒子

浏览器会将块级元素(如 divph1section 等这些默认 display: block 的元素)在页面中渲染为一个块级盒子(Block box)。

块级盒子有以下几个特点:

  • 单独占一行,上下排列,即使宽度没有占满当前行
  • 可以通过 CSS 明确指定 widthheight
  • 如果不设置 width默认宽度为父容器的 100%
  • 盒模型完整生效,marginpaddingborder垂直和水平方向都能影响布局
  • 垂直方向相邻的 margin 可能发生外边距合并(margin collapse)

行内盒子

浏览器会将行内元素(如 spanastrong 等这些默认 display: inline 的元素)在页面中渲染为一个行内盒子(Inline box)。

行内盒子有以下几个特点:

  • 不会单独占一行,而是与其它行内元素共享一行,在水平方向上排列
  • 无法设置 widthheight,这些属性对行内元素无效,元素的宽度由内容决定
  • 上下的 marginpadding 不会影响布局,但上下的 padding 会影响视觉效果。左右的 marginpadding 会生效,影响元素间的间距
  • 垂直方向相邻的 margin 不会发生外边距合并(margin collapse)

⚠️ 注意: 这里说的上下的 padding 会影响视觉效果,是指如下情况。

.inline-padding {
  /* 给行内盒子添加四周的内边距 */
  padding: 10px;
  background-color: yellow;
}
<p>
  这是一段文本,其中有一个<span class="inline-padding">带内边距的行内元素</span></p>

image-20250314115643740

上述代码似乎看起来没什么问题,但是当文字换行时会出现被遮挡的问题。

image-20250314115756814

这就是所说的上下的 padding 会影响视觉效果。

实践建议

想象一个场景,假设你根据设计稿给页面中的元素(已经设置了 wdithheight )添加了内边距,默认情况下,元素的最终大小比你预期要大,然后为了元素不变大你需要自己进行计算来减少内容区域的大小。

但其实你只需要给元素 box-sizing: border-box; 就可以解决这个问题,省去了自己计算的麻烦。

我们在实际开发中页面会有很多的元素,如果一个一个添加 box-sizing: border-box; 也是个麻烦事,所以在开发中建议将如下代码放在样式表的开头,以此调整全局元素的盒模型为替代盒模型。

:root {
  box-sizing: border-box;
}

*,
::before,
::after {
  box-sizing: inherit;
}

⚠️ 注意:

  • * 选择器不会选中伪元素,所以这里需要加上 ::before::after 选择器。

  • 这里之所以使用 inherit 继承的方式是便于调整第三方组件的盒子模型。

    如果使用的第三方组件的盒子模型不是替代盒模型,会导致第三方组件的所有元素都是 border-box,大小出现问题,如果不使用继承的方式想要调整第三方组件的盒子模型非常麻烦(需要将所有组件元素的 box-sizing 都设置为 content-box),而使用继承的方式,只需要修改第三方组件根元素的盒模型即可

    /* 调整第三方组件根元素的盒子模型 */
    .third-party-component {
      box-sizing: content-box;
    }
    

总结

  • 盒模型决定了 HTML 元素的尺寸计算方式,影响元素在页面中的布局,主要包括:内容区域(content)、内边距(padding)、边框(border)和外边距(margin)。
  • 盒模型的类型
    • 标准盒模型box-sizing: content-boxwidthheight 只设置内容区域的大小,内边距和边框会额外增加到总尺寸。
    • 替代盒模型box-sizing: border-boxwidthheight 设置元素的总尺寸,内容区域 (content)的大小会根据内边距(padding)和边框(border)的大小自动收缩,以此来保证盒子最终的实际大小不变。
  • 块级盒子与行内盒子
    • 块级盒子:占一整行,可以设置宽度和高度,垂直方向的 margin 可能会合并。
    • 行内盒子:不会占用一整行,宽度和高度由内容决定,只有左右的 paddingmargin 会影响布局。
  • 实践建议:将全局元素的盒模型调整为替代盒模型,省去了自己计算的麻烦。

参考文章

博客地址:github.com/wjw020206/b…

promise 规范应用

promise 规范及应用

Promises / PromiseA+ 规范

PromiseA+ 规范

  • promise:一个标准、规范
  • 三种状态:pendingfulfilledreject,状态一旦改变不允许再修改
let promise_0 = new Promise((resolve) => {
    setTimeout(() => {
        resolve("promise_0");
    }, 100);
});

let promise_1 = Promise.resolve(promise_0);
// promise_1 会在  promise_0 fulfilled时,自动以同样的值 fulfilled

let thenable = {
    then: function (resolve, reject) {
        setTimeout(() => resolve("success"), 100);
    },
};
let promise_2 = Promise.resolve(thenable);
// promise_2 最终 fulfilled,值为 "success"
  1. 状态

    • Promise 必须从 pending 转为 fulfilled 或 rejected,且一旦转换之后,状态和值(或原因)就固定下来,不会再变
    • 例如:一个异步操作,在成功时调用 resolve 并传入数据,在出错时候调用 reject 并传入错误原因
  2. then 方法

    • then 接受两个回调,分别用于处理成功和失败

    • 如果回调返回值,则将值传递给下一个 then,如果抛出异常,则下一个 then 会接收到拒绝原因

      someAsyncOperation().then((value) => {
       console.info("success", value);
       return value + 1;
      }).then((value) => {
       console.info("链式调用", value);
      }).catch((reason) => {
       console.error("error", reason
      })
      
  3. Promise 解析过程

    • 如果一个 then 回调返回了另一个 Promise,那么当前 Promise 就会 “跟随”这个返回的 Promise 状态,并且采用它的值

传统异步任务的处理

  • 回调函数

    • 一个简单的请求:

      • 调用一个函数,这个函数中返回网络请求
      • 发送网络请求成功,就告知调用者成功,并返回数据
      • 发送网络请求失败,就告知调用者失败,并返回失败信息
    function request(cb) {
        // 模拟发送网络请求
        let flag = Math.random() > 0.5 ? true : false;
        setTimeout(() => {
          cb(flag ? "success" : "fail"
        },500)
    }
    // ...
    console.info("request: 发起请求")
    request((status,msg)=>{
        console.info("request: ",status,msg)
    })
    // 回调函数
    request((s1,m1)=>{
        // 业务处理逻辑1
        request((s2,m2)=>{
          // 业务处理逻辑2
            request((s3,m3)=>{
            // 业务处理逻辑3
                console.info("request: ",s3,m3)
        })
    })
    
  • 回调地狱

    • 如果是自己封装的请求方法,必须设计好规范,如果使用他人库,则必须要通过查看文档或源码才知道如何使用这个函数
    • 及其容易出现回调地狱

Promise

  1. 什么是 Promise
  • 是一个类,用来封装异步操作并获取其成功或失败的结果

  • 当通过 new 创建 Promise 实例,需要传入一个回调函数,我们称之为 executor

    • 这个回调函数会被立即执行,并传入两个参数 resolve 和 reject
    • 当调用 resolve 回调函数时,会执行 Promise 对象的 then 方法传入的回调
    • 当调用 reject 回调函数时,会执行 Promise 对象的 catch 方法传入的回调
  • Promise 是一个状态机,分为 pending、fulfilled、rejected 三个状态

    • pending:初始状态,执行了 executor 后,处于该状态
    • fulfilled:成功状态,调用 resolve() 后,Promise 的状态变为 fulfilled,且无法再改变
    • rejected:失败状态,调用 reject() 后,Promise 的状态变为 rejected,且无法再改变
function request(cb) {
    // 模拟发送网络请求
    let flag = Math.random() > 0.5 ? true : false;
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (flag) {
                resolve("success");
                return;
            }
            reject("error");
        });
    }, 500);
}
console.info("request: 发起请求");
request().then(
    (msg) => console.info("request: ", msg),
    (err) => console.error("request: ", err)
);
  1. resolve 的参数
  • 如果传入的是普通的值或者对象,则会传递到 then 的参数中
  • 如果传入的是一个 Promise,那么当前的 Promise 状态会由传入的 Promise 决定
const newPromise = new Promise((resolve, reject) => {
    resolve("success");
});

new Promise((resolve, reject) => {
    // 当前 Promise 的状态由 newPromise 决定
    resolve(newPromise);
})
    .then((res) => console.info(res)) // success
    .catch((err) => console.error(err));
  • 如果传入的是一个对象,并且该对象实现了 then 方法 (thenable),也会执行该 then 方法,并由该 then 方法决定后续的状态
new Promise((resolve, reject) => {
    resolve({
        then(resolve, reject) {
            reject("error");
        },
    });
}).then(
    (res) => console.info(res),
    (err) => console.error(err) // error
);
  1. Promise 的实例方法
  • then 方法:通过 then 方法可以对 Promise 的 resolve 进行处理,then 方法的返回值是一个 Promise 实例

    • 多次调用 then 方法:同一个 Promise 实例可以多次调用 then 方法,当 Promise 中 resolve 被回调时,所有 then 方法传入的传入的函数都会被调用
const promise_0 = new Promise((resolve, reject) => {
    resolve("success");
});
// 同时调用
promise_0.then((res) => console.info(res));
promise_0.then((res) => console.info(res));
promise_0.then((res) => console.info(res));
  • then 方法传入的回调函数可以有返回值

    • 如果返回的是普通值,那么这个普通值作为一个新的 Promise 的 resolve 的值
const promise_1 = new Promise((resolve, reject) => {
    resolve("success");
});
promise_1.then(() => "then").then((res) => console.info(res)); // then

// promise_1.then(() => "then") 等价于
promise_1.then(() => {
    return new Promise((resolve) => {
        resolve("then");
    });
});
  • 如果返回的是 Promise,那么就可以再次调用 then 方法
  • 如果返回的是一个对象,并且该对象实现了 thenable,该 then 函数有两个参数 resolve,reject,则 resolve 会传递给下一个 Promise
const promise_2 = new Promise((resolve, reject) => {
    resolve("success");
});
promise_2
    .then(() => {
        return {
            then(resolve) {
                return resolve("then"); // resolve 传递给下一个 Promise
            },
        };
    })
    .then((res) => console.info(res)); // then
  • catch 方法

    • 除了 then 方法的第二个参数来捕获 reject 之外, catch 方法用于处理 Promise 的 reject,catch 方法返回的也是一个 Promise 实例
const promise_3 = new Promise((resolve, reject) => {
    resolve("success");
});
promise_3.then((undefined, err) => {
    // 打印 err
    console.error(err);
});
// 这种写法不太符合 `promise/A+ 规范`
promise_3.catch((err) => {
    // 打印 err
    console.error(err);
});
// 符合规范的写法
promise_3.then(
    () => {},
    (err) => {
        console.error(err);
    }
);
  • catch 方法也是可以多次调用的,只要 Promise 实例的状态是 reject,那么就会调用 catch 方法

  • finally 方法

    • 无论一个 Promise 实例是 fulfilled 还是 rejected,finally 方法都会被调用。finally 方法不接收参数,返回的也是一个 Promise 实例
const promise_4 = new Promise((resolve, reject) => {
    resolve("success");
});
promise_4
    .then(() => {})
    .catch((err) => console.error(err))
    .finally(() => console.info("finally code execute"));
  • Promise 的类方法
  1. resolve 方法
const foo = { name: "Fa-ce" };
function bar(obj) {
    return new Promise((resolve) => {
        resolve(obj);
    });
}
bar(foo).then((res) => console.info(res)); // { name: 'Fa-ce' }
  • resolve 参数:

    • 参数本身是 Promise
    • 参数是原始值/对象
    • 参数是一个 thenable
  1. reject 方法
  2. all 方法
  3. race 方法
  4. allSettled 方法
  5. any 方法
  • all 方法:接收一个 Promise[],返回一个 Promise 实例,当所有的 Promise 执行完毕且都是 fulfilled 状态时,该实例的状态才会变成 fulfilled,只要队列中有一个实例的状态是 rejected,那么该实例的状态就会变成 rejected

    • 如果 Promise 队列中所有的实例状态都是 fulfilled,那么 Promise.all() 方法返回的实例状态就会变成 fulfilled,并且 then() 的参数使用给数组,按照顺序放置队列中每个 Promise 成功后的结果
let i = 0;
function genPromise() {
    return new Promise((resolve) => {
        resolve(`success${i++}`);
    });
}

const promiseArr = [genPromise(), genPromise(), genPromise()];
Promise.all(promiseArr).then((res) => console.info(res)); // ['success0', 'success1', 'success2']
  • 如果队列中 Promise 实例有一个是 rejected,那么 Promise.all() 方法返回的实例状态就会变成 rejected,并且 reject() 的参数使用给第一个 rejected 的结果
let i = 0;
function genPromise() {
    return new Promise((resolve) => {
        resolve(`success${i++}`);
    });
}
const promiseArr = [
    genPromise(),
    new Promise((resolve, reject) => reject("error1")),
    new Promise((resolve, reject) => reject("error2")),
    genPromise(),
];
Promise.all(promiseArr).catch((err) => console.info(err)); // error1
  • allSettled 方法:该方法返回的 Promise 实例,会在所有 Promise 实例执行完毕后,状态变成 fulfilled,并且只会是 fulfilled

    • 无论队列中的 Promise 实例的状态如何,都能取到结果
    • 打印的结果,会包含状态以及值/原因
let i = 0;
function genPromise() {
    return new Promise((resolve) => {
        resolve(`success${i++}`);
    });
}
const promiseArr = [
    genPromise(),
    new Promise((resolve, reject) => reject("error1")),
    new Promise((resolve, reject) => reject("error2")),
    genPromise(),
];
Promise.allSettled(promiseArr)
    .then((res) => console.info(res))
    .catch((err) => console.info(err));
  • race 方法

    • Promise.race() 方法接收一个Promise数组,返回一个Promise实例,只要数组中的某个Promise实例状态改变,那么返回的Promise实例状态就会改变,并且使用第一个改变状态的Promise实例的值/原因
    • 返回最先执行完的Promise实例
const promiseArr = [
    new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("success");
        }, 1000);
    }),
    new Promise((resolve, reject) => {
        setTimeout(() => {
            reject("error");
        }, 1500);
    }),
    new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("success");
        }, 600);
    }),
];
Promise.race(promiseArr)
    .then((res) => console.info(res)) // success
    .catch((err) => console.info(err));
  • any 方法

    • Promise.any() 方法会等待一个fulfilled状态,才会决定返回的Promise实例的状态
    • 如果队列中所有的实例都是reject,那么也需要等所有执行完毕后才返回,并且返回的实例状态是rejected,并且使用第一个reject的值/原因
const promiseArr = [
    new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("success");
            // reject("reject-0");
        }, 1000);
    }),
    new Promise((resolve, reject) => {
        setTimeout(() => {
            reject("error");
        }, 1500);
    }),
    new Promise((resolve, reject) => {
        setTimeout(() => {
            // resolve("success");
            reject("reject_2");
        }, 600);
    }),
];

Promise.any(promiseArr)
    .then((res) => console.info(res))
    .catch((err) => console.info(err));
// 遇到第一个 fulfilled时,转变返回的 Promise 实例状态
// 如果所有都是 rejected,那么只有所有执行完毕后,才会返回

Promise 回调和 async/await

Promise.resolve("green").then((result) => {
    console.log(result);

    Promise.resolve("red").then((result) => {
        console.log(result);

        Promise.resolve("yellow").then((result) => {
            console.log(result);
        });
    });
});
async () => {
    await Promise.resolve("green").then((res) => {
        console.log(res);
    });
    await Promise.resolve("red").then((res) => {
        console.log(res);
    });
    await Promise.resolve("yellow").then((res) => {
        console.log(res);
    });
};
// 浏览器不直接支持 async/await,需要 babel 转译
  • async 是 function 的一个前缀,只有 async 函数中才能使用 await 语法
  • async 函数是一个 Promise 对象,有无 resolve 取决于有无函数中 return 值
  • await 后边跟得是一个 Promise 对象,如果不是,则会包裹一层 Promise.resolve(),将其转化为 Promise 对象

await 只能在 async 函数中使用,不能在普通函数中使用,它会暂停函数执行, 直到 等待的 Promise 成功或者失败

async function fetchData() {
    return "Data fetched successfully";
}

async function showData() {
    const data = await fetchData();
    console.log(data);
}

/* ************ */
async function processData() {
    try {
        const data = await fetchData();
        console.log(data);
    } catch (err) {
        console.log(err);
    }
}
async function processSequentAll() {
    const p1 = await fetchData1();
    const p2 = await fetchData2(data2);
    const p3 = await fetchData3(data3);
}
// 或者
async function processSequentAll() {
    const p1 = fetchData1();
    const p2 = fetchData2(data2);
    const p3 = fetchData3(data3);

    const result = await Promise.all([p1, p2, p3]);
    console.log(result);
}
  • 从 async/await 到 promise
function fetchDataAsPromise() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("data fetched successfully");
        }, 1000);
    });
}
// 从 promise 到 async
async function useFetchDataAsPromise() {
    const data = await fetchDataAsPromise();
    console.log(data);
}
useFetchDataAsPromise();

【亲测】1.5万搞定DeepSeek满血版!本地部署避坑指南+内网穿透黑科技揭秘

服务器总“繁忙”?不如自己当老板!

17.jpg 最近疯狂追着AI大模型的朋友们应该都懂,每次想用官网或APP测试DeepSeek R1 671B,总被一句“服务器繁忙,请稍后再试”气到血压飙升。我也不例外——直到花1.5万组装了一台“战车”,在家门口把满血版给跑起来了!速度居然还能飙到2.18 tokens/s,这波操作直接把我从“被服务器卡脖子的韭菜”变成了“自己掌控节奏的老铁”。

为什么非要折腾本地部署?

痛点一:“稍后再试”的魔咒 之前用APP测生成效果,经常等半小时才能成功一次。有次赶工写方案,连刷5次提示“服务器过载”,差点把手机摔了(还好老婆没看到)。

痛点二:蒸馏版 vs 满血版的差距 有人推荐我试试轻量级模型,但测试下来效果真心不如满血版丝滑。比如让模型写技术文档,蒸馏版总漏掉关键参数,而671B的大模型能直接把代码片段和公式都整出来——这钱不花白不花!

pexels-cottonbro-6804604.jpg

1.5万的硬件配置真香

我的“性价比战车”配置单

服务器:技嘉X99

CPU:单路E5-2673v4

显卡:2080Ti 22G*4

内存:512G

机箱:工作站全塔机箱

电源:1200W

14.jpg

远程访问神器——cpolar的“魔法”

为什么需要内网穿透? 本地部署的最大好处是速度快,但问题来了:出差在外还能用吗?这时候 cpolar 真是个宝藏工具。

  • 场景1:在家训练,在公司调用 我把模型放在家里电脑跑,通过cpolar生成一个外网链接。到办公室一开网页,直接远程操作,连咖啡都不用等。带着个私人秘书上班不但提高效率,摸鱼时间更多,还能时不时的装那么一下子。
  • 场景2:朋友借我模型?没问题,分分钟搞定! 好哥们想试试DeepSeek效果,我给他发个专属链接,他手机、平板都能用——前提是得提醒他别乱改我的训练参数😀。

本地部署的“真·自由”

现在我终于能任性地测试各种场景了——比如让模型生成代码时故意给错参数,看它怎么纠错,或者半夜突发灵感直接调出大模型将灵感变现。而这一切的成本,cpolar比租用云服务一年还省一半

下面就看看部署过程吧!保证有图有真相

前言

最近因为访问人数过多,大家在访问Deepseek官网或是使用手机APP版本的时候,经常会遇到服务器繁忙,请稍后再试的情况。于是就出现了很多本地部署DeepSeek大模型的文章或者视频教程,但是跑跑轻量级的还好,如果想体验满血版DeepSeek R1 671B这种几百个G的大模型,那在本地部署所需的硬件配置与费用也会更高。

各种版本的DeepSeek参数和本地部署的要求大家可以参考下图:

image-20250219144230816

为了能花更少的钱体验到满血版的DeepSeek和蒸馏版的到底使用起来有什么区别,笔者尝试使用一套更具性价比的硬件配置挑战将DeepSeek R1 671B 的满血量化版Q4大模型运行在个人PC上,成本压缩在1.5万,经过测试,跑出的速度可达到2.18 tokens/s ,基本可用,如下图所示:

QQ20250211-191422

下面就来详细分享一下具体配置与实际运行效果。

1. 硬件配置

服务器:技嘉X99

CPU:单路E5-2673v4

显卡:2080Ti 22G*4

内存:512G

机箱:工作站全塔机箱

电源:1200W

image-20250218112655164

2. 软件配置

操作系统:Ubuntu 22.04.5

本地大模型运行工具:Ollama

curl -fsSL https://ollama.com/install.sh | sh

大模型:DeepSeek R1 671B满血量化版 404GB

ollama run deepseek-r1:671b

3. 调优配置

如果按默认安装Ollama会报内存溢出错误,这里配置了几个调优参数,可以使得显卡不再内存溢出,会使用内存当显存,同时因为是4张显卡启动,所以在开机初始化时,加了延迟10秒启动Ollama,这样会让4张显卡启动正常后,再加载Ollama。

ExecStartPre=sleep 10
Environment="OLLAMA_HOST=0.0.0.0"
Environment="GGML_CUDA_ENABLE_UNIFIED_MEMORY=1"

image-20250218113229495

4. 实际运行效果

首先ssh登录到服务器后,查看Ollama当前环境所有大模型列表:

ollama list

image-20250218112753842

可以看到,当前已经下载好的deepseek-r1:671b满血全量模型,大小 404GB

然后运行Deepseek R1 671B:

ollama run deepseek-r1:671b

进入运行画面后,设置输出明细信息:/set verbose

image-20250218112946416

这里加载可能需要一些时间,加载完成后进行提问,即能看到实际运行的速率了:

QQ20250211-191422

5. 安装Dify服务

如果想要在网页中与Deepseek大模型交互,我们可以使用Docker来本地部署Dify服务来实现,首先需要安装Docker:

sudo curl -fsSL https://gitee.com/tech-shrimp/docker_installer/releases/download/latest/linux.sh | bash -s docker --mirror Aliyun

然后本地部署Dify:

克隆 Dify 源代码至本地环境:

git clone https://github.com/langgenius/dify.git

环境配置:

cd dify/docker  #进入 Dify 源代码的 Docker 目录
cp .env.example .env  #复制环境配置文件

启动容器:

sudo docker compose up -d

Dify 接入Ollama:

进入Dify主界面,点击设置—模型供应商—Ollama,添加模型名称,基础URL,支持Vision保存。

7cfcff463b949cc043836214a500cd1.png

image-20250219163910801

回到主页面当中,点击创建空白应用,选择deepseek-r1:671b模型,在文本框中编辑文字即可进行对话。

image-20250219162424420转存失败,建议直接上传图片文件

6.安装内网穿透远程访问

如果想实现其他用户跨网络环境,远程访问本地部署的Deepseek R1 671B,可以通过安装cpolar内网穿透工具实现。

sudo curl https://get.cpolar.sh | sh

安装完成后,执行下方命令查看cpolar服务状态:(如图所示即为正常启动)

sudo systemctl status cpolar

img

Cpolar安装和成功启动服务后,在浏览器上输入Ubuntu主机IP加9200端口即:【http://localhost:9200】访问Cpolar管理界面,使用Cpolar账号登录后即可看到配置界面:

点击左侧仪表盘的隧道管理——创建隧道:

  • 隧道名称:可自定义,本例使用了: dify 注意不要与已有的隧道名称重复
  • 协议:http
  • 本地地址:80
  • 域名类型:随机域名
  • 地区:选择China Top

点击创建

e236629c30c2161232c1655493d22ad.png

创建成功后,打开左侧在线隧道列表,可以看到刚刚通过创建隧道生成了两个公网地址,接下来就可以在其他电脑(异地)上,使用任意一个网址在浏览器中访问即可。

成功实现使用公网地址异地远程访问本地部署的Dify在网页中与Deepseek R1 671B聊天了!

bb5906f2d50520ec228aa78b1b8b859.png

为了方便演示,我们在上边的操作过程中使用了cpolar生成的公网地址隧道,其公网地址是随机生成的。这种随机地址的优势在于建立速度快,可以立即使用,然而,它的缺点是网址是随机生成,这个地址在24小时内会发生随机变化,更适合于临时使用。

如果有长期远程访问本地 Dify开发应用平台或者其他本地部署的服务的需求,但又不想每天重新配置公网地址,还想地址好看又好记,那我推荐大家选择使用固定的二级子域名方式来远程访问,带宽会更快,使用cpolar在其他用途还可以保留多个子域名,支持多个cpolar在线进程。

配置固定Dify公网地址

我们可以为Dify配置二级子域名,该地址为固定地址,不会随机变化【ps:cpolar.cn已备案】

注意需要将cpolar套餐升级至基础套餐或以上,且每个套餐对应的带宽不一样。

登录cpolar官网,点击左侧的预留,选择保留二级子域名,地区选择china vip,然后设置一个二级子域名名称,填写备注信息,点击保留。

fe72be4b9c350c2fb34cdfb88e49320.png

然后登录cpolar web UI管理界面,点击左侧仪表盘的隧道管理——隧道列表,找到所要配置的隧道:dify,点击右侧的编辑

修改隧道信息,将保留成功的二级子域名配置到隧道中

  • 域名类型:选择二级子域名
  • Sub Domain:填写保留成功的二级子域名
  • 地区: China VIP

点击更新

463706456a7806bb596036ec151044a.png

更新完成后,打开在线隧道列表,此时可以看到随机的公网地址已经发生变化,地址名称也变成了保留和固定的二级子域名名称。

82274f0c737fed53d9b60a375935c82.png

最后,我们使用固定的公网地址访问 Dify 界面可以看到访问成功,这样一个永久不会变化的网址就设置好了。

5315f47a37dae1b8a7663b1d1d64ba6.png

接下来就可以随时随地进行cpolar生成的公网地址来远程使用本地部署的Dify与deepseek满血大模型聊天了,你也可以把分享这个固定的公网地址,方便团队协作,同时也大大提高了工作效率!自己用的话,无需云服务器,还可以实现异地其他设备登录!

总结

本次挑战中,笔者使用的基本均为多年前上市但极具性价比的硬件来实现本地部署deepseek满血大模型,并结合cpolar内网穿透工具将你本地的deepseek服务发布公网,轻松实现让好友或是同事也能远程访问你在本机部署的大模型,无需二次部署,节省成本的同时也不用再体验deepseek服务器繁忙的情况了。

目前笔者正在测试新的部署方案,期望是达成提高每秒tokens速度的同时还能进一步降低硬件的配置预算,敬请期待.

有兴趣的小伙伴赶紧去试试吧

我的主页里,藏着更多让你惊喜的内容:

💡这软件让我AI助理们排队打工!Cherry Studio+内网穿透=效率狂魔的终极组合 - 掘金

💡云端跳跃:在NAS上用cpolar重现马里奥的童趣时光 - 掘金

💡十年Python老司机分享:那些实战中比写代码更重要的事 - 掘金

💡老板让我三天做AI客服?有了Flowise我边吃火锅边回:"行啊,链接发您微信了" - 掘金

💡告别云盘付费焦虑!Zfile+cpolar手把手教你搭建私人数据王国 - 掘金

💡手机扫码秒传文件!你的“私人云管家”ZFile,让全家文件不再散落天涯-掘金

💡PHPStudy+WordPress+cpolar建立商业网站的心得:从忐忑到惊喜的旅程怎一个爽字了得😊 - 掘金

💡代码写不动?命令行狂按到手酸?这个AI开发助手让我效率翻了3倍!- 掘金

💡3分钟生成4K电影海报?这个AI工具让小白秒变大神!用内网穿透还能远程操控!普通人也能玩转的“造梦机器”——SD 3.5 - 掘金

关注我,成为第一批掌握cpolar内网穿透科技赋能的大神

Flask学习笔记 - 模板渲染

Flask 模板渲染

模板是包含占位符的 HTML 文件

Flask 使用 Jinja2 模板引擎来处理模板渲染。模板渲染允许你将动态内容插入到 HTML 页面中,使得应用能够生成动态的网页内容。

  1. 创建模板:将 HTML 文件放在 templates 文件夹中,使用 Jinja2 占位符。
  2. 渲染模板:使用 render_template 函数在视图函数中渲染模板。
  3. 模板继承:创建基础模板,允许其他模板继承和扩展。
  4. 控制结构:使用条件语句和循环在模板中控制逻辑。
  5. 过滤器:使用过滤器格式化变量数据。
  6. 宏和模板包含:创建和使用宏以及模板包含,提高模板的复用性。
  7. 安全性:Jinja2 默认对模板变量进行自动转义以防止 XSS 攻击。
  8. 模板上下文:将数据传递给模板,并在模板中使用这些数据。

基本概念/创建模板

模板是包含占位符的 HTML 文件。

Flask 使用 Jinja2 模板引擎来渲染这些模板,将 Python 数据插入到 HTML 中,从而生成最终的网页。

<!DOCTYPE html>
<html>
<head>
    <title>Welcome</title>
</head>
<body>
    <h1>{{ title }}</h1>
    <p>Hello, {{ name }}!</p>
</body>
</html>

app.py

from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
    return render_template('index.html',title='Home',name='Misha')

if __name__ == '__main__':
    app.run(debug=True)

格式: {{ 变量名 }}

01.png

模板继承

root.html

<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}My Website{% endblock %}</title>
</head>
<body>
    <header>
        <h1>My Website</h1>
    </header>
    <main>
        {% block content %}{% endblock %}
    </main>
    <footer>
        <p>Footer content</p>
    </footer>
</body>
</html>

leaf.html

{% extends "root.html" %}

{% block title %}Home Page{% endblock %}

{% block content %}
<h2>Welcome to the Home Page!</h2>
<p>Content goes here.</p>
{% endblock %}

app.py

@app.route('/leaf')
def leaf():
    return render_template('leaf.html')

检查响应的内容确实被替换了

02.png

格式:可替换区域 {% block 变量名 %} {% endblock %}

控制结构

Jinja2 提供了多种控制结构,用于在模板中实现条件逻辑和循环。

ctrl_flow.html

<!DOCTYPE html>
<html>
<head>
    <title>Welcome</title>
</head>
<body>
    <!-- 条件语句 -->
    {% if user %}
    <p>Welcome, {{ user }}!</p>
    {% else %}
    <p>Please log in.</p>
    {% endif %}
    
    <!-- 循环语句 -->
    <ul>
        {% for item in items %}
        <li>{{ item }}</li>
        {% endfor %}
    </ul>
</body>
</html>

app.py

@app.route('/ctrl_flow')
def ctrl_flow():
    # return render_template('ctrl_flow.html',user="Zhangsan")
    # return render_template('ctrl_flow.html')
    return render_template('ctrl_flow.html',user="Zhangsan",items=['apple','banana','orange'])

传了user 03.png

未传user和items 04.png

传了user和items 05.png

过滤器

过滤器用于在模板中格式化和处理变量数据。

filter.html

<!DOCTYPE html>
<html>
<head>
    <title>Welcome</title>
</head>
<body>
<p>{{ name|capitalize }}</p>
<p>{{ price|round(2) }}</p>
</body>
</html>

app.py

@app.route('/filter')
def filter():
    return render_template('filter.html',name='wangwu',price=2.999)

过滤器的写法与shell中的管道一样都是用"|"来表示

格式: {{ 变量名|处理方法 }}

06.png

宏和模板包含

macros.html

{% macro render_item(item) %}
    <div>
        <h3>{{ item.title }}</h3>
        <p>{{ item.description }}</p>
    </div>
{% endmacro %}

使用宏: macros_demo.html

{% from "macros.html" import render_item %}

<h1>Items</h1>
{% for item in items %}
    {{ render_item(item) }}
{% endfor %}

app.py

@app.route('/macros')
def macros():
    # return render_template('macros_demo.html',items=['apple','banana','orange'])
    return render_template('macros_demo.html',items=[{"title":"apple","description":"苹果"},{"title":"banana","description":"香蕉"},{"title":"orange","description":"橘子"}])

变量不正确时的效果

07_1.png

正常的显示

07_2.png

安全性

security.html

<html>
<head> security </head>
<body>
<p>{{ user_input }}</p>
</body>
</html>
@app.route('/xss')
def xss():
    return render_template('security.html',user_input='<script> alert(1) </script>')

自动转义:Jinja2 默认会对模板中的变量进行自动转义,防止 XSS 攻击。

<script>标签不会被认为是html元素

08.png

模板上下文

视图函数中传递的变量成为模板的上下文,这些变量可以在模板中直接使用。

profile.html

<h1>{{ user.name }}</h1>
<p>Age: {{ user.age }}</p>

app.py

@app.route('/profile/<username>')
def profile(username):
    user = {'name': username, 'age': 25}
    return render_template('profile.html', user=user)

09.png

参考

  1. Flask模板渲染
❌