普通视图

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

Elpis - 基于 Koa + Vue3 的企业级全栈应用框架

2025年7月4日 18:39

前言

对于大多数中后台开发者而言,日常开发的核心工作往往聚焦于业务逻辑的CRUD实现——无论是简单的基础功能还是复杂的业务场景,本质上都是对数据的增删改查操作,存在大量重复性劳动。 全栈领域模型框架elpis正是为解决这一痛点而生。我们通过领域驱动设计(DDD)对业务模型进行抽象建模,创新性地实现了:

  1. 标准化描述体系 - 通过统一模型定义前端UI界面、后端API接口及数据库Schema
  2. 自动化生成引擎 - 内置三大核心引擎:
    • 前端UI渲染引擎:动态生成界面
    • 后端接口生成引擎:自动输出RESTful API
    • 数据库建表引擎:智能构建数据存储层
  3. 全链路协同 - 模型变更实时同步至前后端及数据库,确保系统一致性 这种模型驱动的开发范式,将传统CRUD的开发效率提升10倍以上,让开发者能够专注于真正的业务创新而非重复编码。

elpis npm包下载使用:点击这里

1. elpis-core 设计

KOA.jpg

BFF.jpg

1.1 elpis-core 是什么?

elpis是一款面向开发者的企业级全栈框架,其核心目标是通过约定范式彻底解决重复性CRUD开发的效率问题。基于Node.js技术栈(Koa2驱动),elpis以服务化形式运行,为开发者提供了一套开箱即用的高效开发范式。

核心设计理念:约定优于配置

elpis通过严格的约定范式,将开发流程标准化。开发者只需遵循约定编写业务模块,框架即可自动完成功能集成,显著降低重复劳动。

智能加载器体系

elpis内置多类加载器,实现模块的自动化集成:

  • 环境配置:configLoader
  • 功能扩展:extendLoader + middlewareLoader
  • 逻辑分层:serviceLoader + controllerLoader
  • 路由管理:routerSchemaLoader + routerLoader

所有模块将智能注入Koa2的服务上下文(Context)应用实例(App) ,开发者可直接调用,无需关注底层集成细节。

灵活的可扩展性

在保持约定范式的同时,elpis允许以上所有加载器都可以添加自定义扩展

通过这种 "约定为主,扩展为辅" 的设计,elpis既保障了开发效率,又满足了企业级项目的定制需求。

1.2 loader 代码示例

以extendLoader为例,实现代码如下:

const glob = require('glob')
const path = require('path')
const { sep } = path
const { set } = require('lodash')

/**
 * extend loader
 * @param {object} app  Koa 实例
 * 
 * 加载所有 extend,可通过 'app.${目录}.${文件}' 访问
 * 
    例子:
    app/extend
        丨 -- custom-module
                |  -- custom-extend.js
    
    => app.customModule.customExtend
 * 
 */
module.exports = (app) => {
  // 读取 elpis/app/extend/**.js 下所有文件
  const elpisExtendPath = path.resolve(__dirname, `..${sep}..${sep}app${sep}extend`)
  const elpisFileList = glob.sync(path.resolve(elpisExtendPath, `.${sep}**${sep}**.js`))
  elpisFileList.forEach((file) => handleFile(file))

  // 读取 业务根目录/app/extend/**.js 下所有文件
  const bussinessExtendPath = path.resolve(app.bussinessPath, `.${sep}extend`)
  const bussinessFileList = glob.sync(path.resolve(bussinessExtendPath, `.${sep}**${sep}**.js`))
  bussinessFileList.forEach((file) => handleFile(file))

  // 把内容加载到 app 下
  function handleFile(file) {
    // 提取文件名称
    let name = path.resolve(file)

    // 截取路径 app/extend/custom-module/custom-extend.js => custom-module/custom-extend
    name = name.substring(
      name.lastIndexOf(`extend${sep}`) + `extend${sep}`.length,
      name.lastIndexOf(`.`)
    )
    // 把 '-' 统一改成驼峰式,  custom-module/custom-extend => customModule.customExtend
    name = name.replace(/[_-][a-z]/gi, (s) => s.substring(1).toUpperCase()).replace(sep, '.')

    // 过滤app已经存在的key
    for (const key in app) {
      if (key === name) {
        console.log(`[${name}] is already exists in app, please check your extend file.`)
        return
      }
    }

    // 挂载 extend 到 app 上
    set(app, name, require(path.resolve(file))(app))
  }
}

2. elpis 工程化

elpis企业级多系统框架:混合渲染与智能工程化体系

elpis致力于构建新一代企业级应用框架,其核心创新在于混合渲染架构全链路工程化解决方案,完美平衡多系统集成的复杂度与开发体验。

混合渲染架构设计

采用关键页面SSR,非关键页面CSR的复合渲染模式

  • 系统入口层:通过SSR动态分发多站点入口,实现服务端精准路由控制
  • 业务应用层:每个入口承载独立SPA应用,保持前端交互体验一致性
  • 模板引擎:智能生成差异化页面模板,自动注入代码块(如埋点、权限校验等)

全生命周期工程化支持

  1. 生产级构建流水线

    • 多环境适配:自动处理ES语法降级、Polyfill注入
    • 性能优化:代码分包(Code Splitting)、Tree Shaking、Gzip压缩
    • 质量保障:Bundle分析报告、依赖大小可视化
  2. 极致开发体验

    • 热更新(HMR)体系:模块级热替换,保存即生效
    • 增量编译:毫秒级响应代码变更
    • 调试增强:SourceMap与错误追踪深度集成
  3. 标准化交付方案

    • 框架即产品:完备的npm包发布规范
    • 版本管理:语义化版本控制(SemVer)
    • 私有化支持:无缝对接企业私有仓库

框架定位

elpis既是开发加速器(通过约定范式提升效率),又是工程规范实施者(通过标准化流程保障质量),最终实现:

  • 多系统切换成本降低70%
  • 构建效率提升3倍
  • 生产环境稳定性达99.9%

2.1 SSR 入口文件的处理

背景痛点

在构建多入口SSR应用时,传统方案需要为每个入口重复编写框架初始化代码(如Vue的createApp或React的createRoot),导致:

  1. 代码冗余度高
  2. 维护成本增加
  3. 技术栈升级困难

解决方案

我们设计了标准化应用启动器(Bootloader) ,通过抽象框架初始化逻辑实现:

  1. 统一初始化接口

    • 提供 boot.js 核心模块,不同的项目直接调用即可
    • 封装框架特有的实例化过程,获取 Koa 注入模板页面的__ELPIS_ENTRY_NAME__即可动态获取路由基础路径
// boot.js
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/theme-chalk/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import '@elpis/assets/custom.css'
import pinia from '@elpis/store'
import { createRouter, createWebHistory } from 'vue-router'

/**
 * vue 页面主入口,用于启动 vue
 * @param pageComponent vue 入口文件
 * @param routes 路由列表
 * @param libs 页面依赖的第三方包
 * @param basePath 路由基础路径
 */
export default (pageComponent, { routes, libs } = {}) => {
  // 动态获取路由基础路径
  const entryName =
    typeof window !== 'undefined' && window.__ELPIS_ENTRY_NAME__ ? window.__ELPIS_ENTRY_NAME__ : ''
  const basePath = entryName ? `/view/${entryName}` : '/view'

  const app = createApp(pageComponent)
  app.use(ElementPlus)
  app.use(pinia)

  //引入第三方包
  if (libs && libs.length) {
    for (let i = 0; i < libs.length; i++) {
      app.use(libs[i])
    }
  }

  //页面路由
  if (routes && routes.length) {
    const router = createRouter({
      history: createWebHistory(basePath),
      routes
    })
    app.use(router)
    router.isReady().then(() => app.mount('#app'))
  } else {
    app.mount('#app')
  }
}

2.2 entry 入口文件的处理

当前挑战

在传统Webpack配置中,多入口项目通常需要在webpack.base.js中显式声明每个入口的模板和代码块:

// 反模式:手动声明每个入口
entry: {
  entryA: {
    import: './src/entryA.js',
    template: './templates/a.html',
    chunks: ['vendor', 'common', 'entryA']
  },
  entryB: {
    // 重复配置...
  }
  // 随着入口增长,配置急剧膨胀
}

这种模式会导致:

  1. 配置文件臃肿难维护
  2. 新增入口需修改核心配置
  3. 容易产生配置冲突

工程化解决方案

我们采用约定优于配置原则,将入口文件放在pages/**/entry.*.js,通过自动化处理实现:

// 动态构造 elpisPageEntrys  elpisHtmlWebpackPluginList
const elpisPageEntrys = {}
const elpisHtmlWebpackPluginList = []
// 获取 elpis/app/pages 目录下所有入口文件 (entry.xx.js)
const elpisEntryList = path.resolve(__dirname, '../../pages/**/entry.*.js')
glob
  .sync(elpisEntryList)
  .forEach((file) => handleFile(file, elpisPageEntrys, elpisHtmlWebpackPluginList))

// 动态构造 businessPageEntrys  businessHtmlWebpackPluginList
const businessPageEntrys = {}
const businessHtmlWebpackPluginList = []
// 获取 业务根目录/app/pages 目录下所有入口文件 (entry.xx.js)
const businessEntryList = path.resolve(process.cwd(), './app/pages/**/entry.*.js')
glob
  .sync(businessEntryList)
  .forEach((file) => handleFile(file, businessPageEntrys, businessHtmlWebpackPluginList))

// 构造相关 webpack 处理的数据结构
function handleFile(file, entries = {}, htmlWebpackPluginList = []) {
  const entryName = path.basename(file, '.js')
  entries[entryName] = file
  htmlWebpackPluginList.push(
    new HtmlWebpackPlugin({
      // 指定要使用的模板文件
      template: path.resolve(__dirname, '../../view/entry.tpl'),
      // 要注入的代码块
      chunks: [entryName],
      // 产物(最终模板)输出路径
      filename: path.resolve(process.cwd(), 'app/public/dist/', `${entryName}.tpl`),
      inject: true,
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeAttributeQuotes: true
      }
    })
  )
}
module.exports ={
  entry: Object.assign({}, elpisPageEntrys, businessPageEntrys),
  plugins: [ ...htmlWebpackPluginList ]
}

2.3 打包优化

性能挑战

随着项目规模增长,传统构建模式会面临两大核心问题:

  1. 构建速度指数级下降
    • 典型表现:200+模块项目冷启动构建超过8分钟
    • 根本原因:未优化的依赖解析和全量编译
  2. 产物体积失控
    • 常见问题:主包超过2MB导致首屏加载缓慢
    • 关键因素:未做代码分割和按需加载

系统性解决方案

一、构建加速体系

  1. 增量编译优化
// webpack配置
cache: {
    type: 'filesystem',
    buildDependencies: { config: \[\_\_filename] }
}
  • 效果:二次构建速度提升70%+
  1. 多进程处理
// 使用thread-loader并行化
npm install thread-loader --save-dev
  1. 依赖预编译
externals: {
    vue: 'Vue',
    lodash: '\_'
}

二、代码瘦身策略

  1. 智能代码分割
{
    optimization: {
      /**
       * 把 js 文件打包成3种类型
       * 1. vendor 第三方lib库,基本不会改动,除非依赖版本升级
       * 2. common 业务组件代码的公共部分收取出来,改动较少
       * 3. entry.{page}: 不同页面 entry 里的业务组件代码的差异部分,会经常改动
       * 目的:把改动和引用频率不一样的 js 区分出来,以达到更好利用浏览器缓存效果
       */
      splitChunks: {
        chunks: 'all', //对同步和异步模块都进行分割
        // maxSize: 500000, // 500KB
        // minSize: 30000, // 30KB
        maxAsyncRequests: 10, // 每次异步加载的最大并行请求数
        maxInitialRequests: 10, // 入口点的最大并行请求数
        cacheGroups: {
          vendor: {
            // 第三方库
            name: 'vendor',
            test: /[\\/]node_modules[\\/]/,
            priority: 20, // 优先级,数字越大,优先级越高
            enforce: true, // 强制执行
            reuseExistingChunk: true // 复用已有的公共 chunk
          },
          common: {
            // 公共模块
            name: 'common',
            test: /[\\/]common|components[\\/]/,
            minChunks: 2, // 被两处引用的即被归为公共模块
            priority: 10,
            reuseExistingChunk: true
          }
        }
      },
      minimize: true,
      // 将 webpack 运行时代码抽离成单独文件
      runtimeChunk: true
    }
 }
  1. 按需加载
// 动态导入语法
const Login = () => import(/* webpackPrefetch: true */ './Login.vue')
  1. 高级压缩
new TerserPlugin({
  parallel: true,
  terserOptions: { compress: { drop_console: true } }
})

2.4 开发环境搭建

核心架构设计

我们采用 Express + Webpack 中间件 构建高性能开发服务器,实现真正的模块热替换(HMR)能力:

// webpack.dev.js
// 基类配置
const path = require('path')
const merge = require('webpack-merge')
const os = require('os')
const webpack = require('webpack')

//基础配置
const baseConfig = require('./webpack.base.js')

//devServer配置
const DEV_SERVER_CONFIG = {
  HOST: '127.0.0.1',
  PORT: 9002,
  HMR_PATH: '__webpack_hmr',
  TIMEOUT: 20000
}

//开发阶段的 entry 配置需要加入的hmr
Object.keys(baseConfig.entry).forEach((entryName) => {
  if (entryName !== 'vendor') {
    baseConfig.entry[entryName] = [
      //主入口
      baseConfig.entry[entryName],
      //hmr更新入口
      `${require.resolve('webpack-hot-middleware/client')}?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`
    ]
  }
})

// 生产环境配置
const webpackConfig = merge.smart(baseConfig, {
  mode: 'development',
  //source-map 开发工具,呈现代码的映射关系,便于在开发过程中调试代码
  devtool: 'eval-cheap-module-source-map',
  output: {
    filename: 'js/[name]_[chunkhash:8].bundle.js',
    path: path.resolve(process.cwd(), './app/public/dist/dev/'), // 输出路径
    publicPath: `http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/public/dist/dev/`, // 静态资源访问路径(在devServer内存中)
    globalObject: 'this'
  },
  module: {
      //...此处省略
  },
  plugins: [
    //  HMR热更新
    new webpack.HotModuleReplacementPlugin({
      multiStep: false
    })
  ]
})

module.exports = { webpackConfig, DEV_SERVER_CONFIG }

本地开发环境启动入口文件,如下:

// dev.js
const webpack = require('webpack')
const express = require('express')
const consoler = require('consoler')
const path = require('path')
const devMiddleware = require('webpack-dev-middleware')
const hotMiddleware = require('webpack-hot-middleware')

module.exports = () => {
  const { webpackConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev.js')

  const app = express()

  const compiler = webpack(webpackConfig)

  //指定静态文件目录
  app.use(express.static(path.join(process.cwd(), './app/public/dist')))

  //引用 devMiddleware 中间件(监控文件改动)
  app.use(
    devMiddleware(compiler, {
      //落地文件
      writeToDisk: (filePath) => filePath.endsWith('.tpl'), // 页面模板(如 entry.page1.tpl)需要实际写入磁盘,通过 express.static 提供访问。
      //资源路径
      publicPath: webpackConfig.output.publicPath, // JS/CSS 等资源由 webpack-dev-middleware 托管在内存中,走 HMR 热更新。
      //headers配置
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
        'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization'
      },
      stats: {
        colors: true
      }
    })
  )
  //引用 hotMiddleware 中间件(实现HMR)
  app.use(
    hotMiddleware(compiler, {
      path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
      log: false
    })
  )

  consoler.info('请等待webpack初次构建完成提示......')

  //启动 devServer
  const port = DEV_SERVER_CONFIG.PORT
  app.listen(port, () => {
    console.log(`webpack-dev-server is listening on port ${port}`)
  })
}

2.5 增加启动脚本

{
  "build:dev": "cross-env NODE_ENV=local node --max_old_space_size=4096 ./app/webpack/dev.js",
}

3. 领域模型 DSL 设计

DSL领域模型.jpg DSL(Domain Specific Language,领域特定语言 )领域模型是为特定业务领域定制的语言及配套模型体系,聚焦解决该领域的问题。

3.1 为什么用 DSL 领域模型?

1. 降低领域沟通成本

  • 让业务人员(非技术)也能 “用领域语言” 参与开发:比如运营可通过配置 DSL 调整页面表格列,不用懂 Vue/React代码。
  • 统一 “业务描述 - 技术实现” 的语言:开发说 “用 schema-table 组件”,业务能理解是 “表格展示”,减少需求传递偏差。

2. 提升开发效率(复用 + 配置化 = 80% / 自定义 + 扩展 = 20%)

  • 组件 / 模板复用:沉淀通用 DSL 模型(如 dashboard 模板),新需求直接改配置,不用重复写页面逻辑。
  • 适配后端变化:后端接口字段变了,只需在 DSL 模型层(中间层)做数据转换,前端组件不用逐个修改

3. 聚焦领域问题解决

  • 相比通用代码,DSL 更 “轻量、专注”:比如用 DSL 描述 “页面要一个带筛选的表格”,只需几行配置;用通用代码则要写组件引入、数据请求、渲染逻辑,冗余且易出错。

3.2 如何在项目中使用?

一个领域模型可以衍生出若干个项目,领域模型项目的关系是对象继承关系,项目(子类)继承于领域模型(基类),领域模型可以沉淀各个项目中重复功能/页面,实现复用。

通过如下配置,即可生成一个项目

{
  mode: 'dashboard', // 模板类型,不同模板类型对应不一样的模板数据结构
  name: '', //名称
  desc: '', //描述
  icon: '', //图标
  homePage: '', //首页(项目配置)
  menu: [
    {
      key: '', //菜单唯一描述
      name: '', //菜单名称
      menuType: '', //枚举值  group/module

      // 当menuType == group 时,可填
      subMenu: [{}],

      moduleType: '', // 枚举值  sider/iframe/custom/schema

      //当 moduleType == sider 时
      siderConfig: {
        menu: [{}]
      },

      //当 moduleType == iframe 时
      iframeConfig: {
        path: '' // iframe 路径
      },

      //当 moduleType == custom 时
      customConfig: {
        path: '' // 自定义路由路径
      },

      //当 moduleType == schema 时
      schemaConfig: {
        api: '', // 数据源API (遵循 restfull 规范)
        schema: {
          type: 'object',
          properties: {
            key: {
              type: '', //字段类型
              label: '', //字段中文名
              // 字段在 table 中的相关配置
              tableOption: {
                ...elTableColumnConfig, // 标准 el-table-column 配置
                toFixed: 0,
                visible: true // 默认为 true (false表示不在表单中显示)
              },
              // 字段在 search-bar 中的相关配置
              searchOption: {
                ...elComponentConfig, // 标准 el-component-config 配置
                comType: '', // 配置组件类型  input/select/...
                default: '', // 默认值

                // 当 comType === 'select' 时,可配置
                enumList: [
                  {
                    label: '',
                    value: ''
                  }
                ],

                // 当 comType === 'dynamicSelect' 时,可配置
                api: '' // 数据源API (遵循 restfull 规范)
              },
              // 字段在不同动态 component 中的相关配置,前缀对应 componentConfig 中的键值
              // 如:componentConfig.createForm  这里对应 createFormOption
              // 字段在 createForm 中相关配置
              createFormOption: {
                ...elComponentConfig, // 标准 el-component-config 配置
                comType: '', // 配置组件类型  input/select/...
                visible: true, // 是否展示,默认为 true
                disabled: false, // 是否禁用,默认为 false
                default: '', // 默认值

                // 当 comType === 'select' 时,可配置
                enumList: [
                  {
                    label: '',
                    value: ''
                  }
                ]
              },
              // 字段在 editForm 中相关配置
              editFormOption: {
                ...elComponentConfig, // 标准 el-component-config 配置
                comType: '', // 配置组件类型  input/select/...
                visible: true, // 是否展示,默认为 true
                disabled: false, // 是否禁用,默认为 false
                default: '', // 默认值

                // 当 comType === 'select' 时,可配置
                enumList: [
                  {
                    label: '',
                    value: ''
                  }
                ]
              },
              detailPanelOption: {
                ...elComponentConfig // 标准 el-component-config 配置
              },
              apiOption: {}, // 数据源配置
              dbOption: {} // 数据库配置
            }
          },
          required: [] // 标记哪些字段为必填项
        },
        tableConfig: {
          headerButtons: [
            {
              label: '', // 按钮中文名
              eventKey: '', // 按钮事件名
              // 按钮具体配置
              eventOption: {
                // 当 eventKey === 'showComponent'
                comName: '' // 组件名
              },
              ...elButtonConfig // 标准 el-button 配置
            }
          ], // 表头按钮
          rowButtons: [
            {
              label: '', // 按钮中文名
              eventKey: '', // 按钮事件名
              eventOption: {
                // 当 eventKey === 'showComponent'
                comName: '', // 组件名

                // 当 eventKey === 'remove'
                params: {
                  idKey: 'schema::idKey' // 当格式为 schema::tableKey 的时候,到 table 中找相应的字段
                }
              }, // 按钮具体配置
              ...elButtonConfig // 标准 el-button 配置
            }
          ] // 行按钮
        }, // table 相关配置
        searchConfig: {}, //search-bar 相关配置
        // 动态组件 相关配置
        componentConfig: {
          // createForm 表单相关配置
          createForm: {
            title: '', // 表单标题
            saveBtnText: '' // 保存按钮文案
          },
          // editForm 表单相关配置
          editForm: {
            mainKey: '', // 表单主键,用于唯一标识要修改的数据对象
            title: '', // 表单标题
            saveBtnText: '' // 保存按钮文案
          },
          detailPanel: {
            mainKey: '', // 表单主键,用于唯一标识要修改的数据对象
            title: '' // 表单标题
          }
        }
      }
    }
  ]
}

SchemaView 页面的实现是项目的核心,是在菜单 menu 中配置一份基于 json-schema 规范的 schema配置,结合各个解析器,实现页面渲染 schema.jpg

3.3 如何通过schema配置生成各个解析器?

// 通用构建 schema 方法 (清除噪音)
  const buildDtoSchema = (_schema, comName) => {
    if (!_schema?.properties) return {}
    const dtoSchema = {
      type: 'object',
      properties: {}
    }

    // 提取有效 schema 字段信息
    for (const key in _schema.properties) {
      const props = _schema.properties[key]
      if (props[`${comName}Option`]) {
        let dtoProps = {}
        // 提取 props 中非 option 的部分,存放到 dtoProps 中
        for (const pKey in props) {
          if (pKey.indexOf('Option') < 0) {
            dtoProps[pKey] = props[pKey]
          }
        }
        // 处理 comName Option
        dtoProps = Object.assign({}, dtoProps, { option: props[`${comName}Option`] })

        // 处理 required 字段
        const { required } = _schema
        if (required && Array.isArray(required) && required.includes(key)) {
          if (dtoProps.option) {
            dtoProps.option.required = true
          }
        }

        dtoSchema.properties[key] = dtoProps
      }
    }
    return dtoSchema
  }

  // 构造 schemaConfig 相关配置,输送给 schemaView 解析
  const buildData = () => {
    const { key, sider_key: siderKey } = route.query
    const menuItem = menuStore.findMenuItem({
      key: 'key',
      value: siderKey ?? key
    })
    if (menuItem && menuItem.schemaConfig) {
      const { schemaConfig: sConfig } = menuItem

      const configSchema = JSON.parse(JSON.stringify(sConfig.schema))
      api.value = sConfig.api ?? ''
      tableSchema.value = {}
      tableConfig.value = undefined
      searchSchema.value = {}
      searchConfig.value = undefined
      components.value = {}
      nextTick(() => {
        // 构造 tableSchema 和 tableConfig
        tableSchema.value = buildDtoSchema(configSchema, 'table')
        tableConfig.value = sConfig.tableConfig ?? {}

        // 构造 searchSchema 和 searchConfig
        const dtoSearchSchema = buildDtoSchema(configSchema, 'search')
        for (const key in dtoSearchSchema.properties) {
          if (route.query[key] !== undefined) {
            dtoSearchSchema.properties[key].option.default = route.query[key]
          }
        }
        searchSchema.value = dtoSearchSchema
        searchConfig.value = sConfig.searchConfig ?? {}

        // 构造 components = { comKey: { schema: {}, config: {} } }
        const { componentConfig } = sConfig
        if (componentConfig && Object.keys(componentConfig).length > 0) {
          const dtoComponents = {}

          for (const comName in componentConfig) {
            dtoComponents[comName] = {
              schema: buildDtoSchema(configSchema, comName),
              config: componentConfig[comName]
            }
          }
          components.value = dtoComponents
        }
      })
    }
  }

生成过后的schema结构如下所示:

/**
   * schemaForm 的 schema 配置,结构如下
     {
          type: 'object',
          properties: {
            key: {
              ...schema, // 标准 schema 配置
              type: '', // 字段类型
              label: '', // 字段中文名
              // 字段在 form 中的相关配置
              option: {
                ...elComponentConfig, // 标准 el-component-config 配置
                comType: '', // 配置组件类型  input/select/...
                visible: true, // 是否展示,默认为 true
                disabled: false, // 是否禁用,默认为 false
                default: '', // 默认值
                required: false // 是否必填,默认为 false

                // 当 comType === 'select' 时,可配置
                enumList: [
                  {
                    label: '',
                    value: ''
                  }
                ]
              }
            }
          },
        }
     */

/**
   * schemaSearchBar 的 schema 配置,结构如下
    {
        type: 'object',
        properties:  {
            key: {
                ...schema, // 标准 schema 配置
                type: '', //字段类型
                label: '', //字段中文名
                // 字段在 search-bar 中的相关配置
                option: {
                    ...elComponentConfig, // 标准 el-component-config 配置
                    comType: '', // 配置组件类型  input/select/...
                    default: '' // 默认值
                }
            }
        }
    }
   */

/**
   * schemaTable 的 schema配置,结构如下:
    {
      type: 'object',
      properties: {
        key: {
          ...schema, // 标准 schema 配置
          type: '', //字段类型
          label: '', //字段中文名
          // 字段在 table 中的相关配置
          option: {
            ...elTableColumnConfig, // 标准 el-table-column 配置
            visible: true // 默认为 true (false 或 不配置,表示不再表单中显示)
          }
        }
      }
    }
   */

有了这样的设计,我们只需维护一份 schema 配置,即可直接生成一个CRUD的查询页,极大地提高了开发效率。

4. 发布npm包

纯净内核架构

  • 框架作为技术中台,严格遵循"零业务逻辑"准则

    入口文件提供服务端基础,以及frontendBuild、serverStart,提供给业务项目调用。

// 引入 elpis-core
const ElpisCore = require('./elpis-core')
// 引入 前端工程化构建方法
const FEBuildDev = require('./app/webpack/dev.js')
const FEBuildProd = require('./app/webpack/prod.js')

module.exports = {
  /**
   * 服务端基础
   */
  Controller: { Base: require('./app/controller/base.js') },
  Service: { Base: require('./app/service/base.js') },

  /**
   * 编译构建前端工程
   * @params  env 环境变量 local/production
   */
  frontendBuild(env) {
    if (env === 'local') {
      FEBuildDev()
    } else if (env === 'production') {
      FEBuildProd()
    }
  },

  /**
   * 启动 Elpis
   * @params  options 项目配置,透传到 elpis-core
   */
  serverStart(options = {}) {
    const app = ElpisCore.start(options)
    return app
  }
}

通过如下 webpack 配置整合 elpis 及 业务代码,配置 alias 暴露给业务项目调用 elpis 内部功能。

{
  alias: (() => {
    const aliasMap = {}
    const blankModulePath = path.resolve(__dirname, '../libs/blank.js') // 空文件兜底

    // dashboard 路由拓展配置
    const bussinessDashboardRouterConfig = path.resolve(
      process.cwd(),
      './app/pages/dashboard/router.js'
    )
    aliasMap['@bussinessDashboardRouterConfig'] = fs.existsSync(bussinessDashboardRouterConfig)
      ? bussinessDashboardRouterConfig
      : blankModulePath

    // schemaView component 扩展配置
    const bussinessComponentConfig = path.resolve(
      process.cwd(),
      './app/pages/dashboard/components/schemaView/component-config.js'
    )
    aliasMap['@bussinessComponentConfig'] = fs.existsSync(bussinessComponentConfig)
      ? bussinessComponentConfig
      : blankModulePath

    // schemaForm form-item 扩展配置
    const bussinessFormItemConfig = path.resolve(
      process.cwd(),
      './app/pages/components/schemaForm/form-item-config.js'
    )
    aliasMap['@bussinessFormItemConfig'] = fs.existsSync(bussinessFormItemConfig)
      ? bussinessFormItemConfig
      : blankModulePath

    // schemaSearchBar search-item 扩展配置
    const bussinessSearchItemConfig = path.resolve(
      process.cwd(),
      './app/pages/components/schemaSearchBar/search-item-config.js'
    )
    aliasMap['@bussinessSearchItemConfig'] = fs.existsSync(bussinessSearchItemConfig)
      ? bussinessSearchItemConfig
      : blankModulePath

    return {
      '@elpis/pages': path.resolve(__dirname, '../../pages'),
      '@elpis/assets': path.resolve(__dirname, '../../pages/assets'),
      '@elpis/common': path.resolve(__dirname, '../../pages/common'),
      '@elpis/curl': path.resolve(__dirname, '../../pages/common/curl.js'),
      '@elpis/utils': path.resolve(__dirname, '../../pages/common/utils.js'),

      '@elpis/components': path.resolve(__dirname, '../../pages/components'),
      '@elpis/headerContainer': path.resolve(
        __dirname,
        '../../pages/components/headerContainer/index.vue'
      ),
      '@elpis/siderContainer': path.resolve(
        __dirname,
        '../../pages/components/siderContainer/index.vue'
      ),
      '@elpis/schemaSearchBar': path.resolve(
        __dirname,
        '../../pages/components/schemaSearchBar/index.vue'
      ),
      '@elpis/schemaForm': path.resolve(
        __dirname,
        '../../pages/components/schemaForm/index.vue'
      ),
      '@elpis/schemaTable': path.resolve(
        __dirname,
        '../../pages/components/schemaTable/index.vue'
      ),

      '@elpis/store': path.resolve(__dirname, '../../pages/store'),
      '@elpis/boot': path.resolve(__dirname, '../../pages/boot.js'),
      ...aliasMap
    }
  })()
}

欢迎使用 elpis npm包:点击这里

前端地图可视化的新宠儿:Cesium 地图封装实践

作者 uncleTom666
2025年7月4日 17:59

Cesium地图二次封装与实战应用(附完整源码)

本文将带你如何用 JavaScript 对 Cesium 地图进行二次封装,并在 Vue(包含vue2、vue3) 项目中实现丰富的地图交互功能。文末附完整源码,适合前端地图开发者参考和实践。


一、为什么要封装 Cesium 地图?

Cesium 是业界领先的 WebGL 三维地球引擎,功能强大但 API 较为底层。实际项目中,我们常常需要:

  • 统一管理地图实例
  • 简化点、线、面、围栏、3D Tiles 等常用操作
  • 支持自定义交互、测量、标注、弹窗等业务需求

因此,进行一层面向业务的封装,可以极大提升开发效率和代码可维护性。


二、CesiumMap.js 封装核心代码

我们将 Cesium 的常用操作封装成一个类,支持点、线、面、圆、围栏、3D Tiles、热力图、测量、截图等功能。

文件结构:

src/
  utils/
    CesiumMap.js   // Cesium地图封装
  components/
    CesiumMapDemo.vue  // Vue组件实战

1. CesiumMap.js 封装类(核心片段)

// src/utils/CesiumMap.js
import * as Cesium from 'cesium';

Cesium.Ion.defaultAccessToken = '你的CesiumToken';

export default class CesiumMap {
  constructor(containerId, options = {}) {
    this.viewer = new Cesium.Viewer(containerId, {
      animation: false,
      timeline: false,
      baseLayerPicker: false,
      // ... 其他参数
      ...options
    });
  }

  // 添加点
  addEntity(entityOptions) {
    return this.viewer.entities.add(entityOptions);
  }

  // 移除实体
  removeEntity(entity) {
    this.viewer.entities.remove(entity);
  }

  // 相机飞行
  flyTo(target, options = {}) {
    // 支持实体、坐标、数组等多种形式
    // ... 见完整代码
  }

  // 添加marker
  addMarker({ lon, lat, image, width = 32, heightPx = 32, name = '', description = '' }) {
    return this.viewer.entities.add({
      name,
      position: Cesium.Cartesian3.fromDegrees(lon, lat),
      billboard: { image, width, height: heightPx },
      description,
    });
  }

  // 添加折线
  addPolyline({ positions, color = Cesium.Color.BLUE, width = 3, name = '', description = '' }) {
    return this.viewer.entities.add({
      name,
      polyline: {
        positions: positions.map(p => Cesium.Cartesian3.fromDegrees(...p)),
        width,
        material: color,
      },
      description,
    });
  }

  // 添加圆
  addCircle({ lon, lat, radius, color = Cesium.Color.GREEN.withAlpha(0.5), outlineColor = Cesium.Color.GREEN, outlineWidth = 2, name = '', description = '' }) {
    return this.viewer.entities.add({
      name,
      position: Cesium.Cartesian3.fromDegrees(lon, lat),
      ellipse: {
        semiMajorAxis: radius,
        semiMinorAxis: radius,
        material: color,
        outline: true,
        outlineColor,
        outlineWidth,
      },
      description,
    });
  }

  // ... 还有多边形、围栏、3D Tiles、热力图、测量等方法
  // 见文末完整代码
}

完整 CesiumMap.js 源码见项目仓库或文末下载方式。


三、Vue3 组件实战:CesiumMapDemo.vue

我们将 CesiumMap 封装类应用到 Vue3 组件中,实现丰富的地图交互。

1. 组件结构

<template>
  <div class="cesium-map-demo">
    <div ref="mapContainer" class="map-container"></div>
    <div class="toolbar">
      <button @click="addPoint">添加点</button>
      <button @click="addPolyline">画线</button>
      <button @click="addCircle">画圆</button>
      <button @click="addPolygon">画多边形</button>
      <!-- ... 其他按钮 ... -->
    </div>
    <div v-if="measureResult" class="measure-result">{{ measureResult }}</div>
    <!-- 属性弹窗 -->
    <div v-if="popupVisible" class="popup" :style="{ left: popupScreen.x + 'px', top: popupScreen.y + 'px' }" v-html="popupContent"></div>
  </div>
</template>

2. 组件核心逻辑

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import CesiumMap from '../utils/CesiumMap';

const mapContainer = ref(null);
let cesiumMap = null;
let pointEntity = null;
let polylineEntity = null;
let circleEntity = null;
let polygonEntity = null;

onMounted(() => {
  cesiumMap = new CesiumMap(mapContainer.value);
  // 注册实体点击事件,显示弹窗
  cesiumMap.onEntityClick(({ entity, screenPosition }) => {
    // ... 弹窗逻辑
  });
});

onBeforeUnmount(() => {
  cesiumMap && cesiumMap.destroy();
});

// 添加点
function addPoint() {
  if (pointEntity) return;
  pointEntity = cesiumMap.addEntity({
    position: CesiumMap.Cartesian3.fromDegrees(116.3913, 39.9075, 100),
    point: { pixelSize: 10, color: CesiumMap.Color.RED },
    description: '北京',
  });
}

// 添加折线
function addPolyline() {
  if (polylineEntity) return;
  polylineEntity = cesiumMap.addPolyline({
    positions: [
      [116.3913, 39.9075],
      [116.3974, 39.9087],
      [116.4053, 39.9200],
    ],
    color: CesiumMap.Color.BLUE,
    width: 4,
    name: '示例线',
    description: '北京示例线',
  });
}

// ... 还有画圆、画多边形、交互绘制、测量、围栏、弧形、标记点等丰富功能
</script>

完整 CesiumMapDemo.vue 源码见文末。


四、主要功能亮点与效果说明

  • 支持点、线、面、圆、围栏、3D Tiles、热力图等一键添加
  • 支持鼠标交互绘制线、面、圆、围栏、弧形
  • 支持距离、面积测量
  • 支持属性弹窗、标记点、围栏管理等
  • 支持相机飞行、截图、事件注册等高级功能
  • 兼容性检测,适配不同 Cesium 版本

效果演示:

  • 具体效果可以到文本末进行下载查看

image.png

五、总结与参考

通过对 Cesium 地图的二次封装,我们可以极大提升三维地图开发效率,并实现高度可复用、可维护的业务组件。

本方案支持点、线、面、圆、围栏、3D Tiles、热力图、测量、截图、弹窗等常用功能,适合大部分前端三维可视化项目。 可以直接将CesiumMap.js拿到你的项目中直接使用,最好使用和uncleTom一样的版本 uncleTom用的版本是 cesium: 1.130.1-splats.0

参考链接:


demo源码地址: uncleTom/Cesium-utils

如有任何问题,欢迎评论区留言交流!希望这篇文章对你能用帮助,如果喜欢给uncleTom点个关注哟,我以后也会多多分享更多前端内容

Vue响应式原理推导过程

作者 code二极管
2025年7月4日 17:43

Vue响应式原理推导过程

1. 最基础的响应式模型

最初的响应式模型非常简单,就是手动实现函数与对象的绑定关系:

/**
 * 阶段一:函数与对象的绑定
 *
 * 解释:一个或多个函数有该对象的属性,在对象属性发生变化时,重新调用函数,结果也发生变化
 *
 *
 * 缺点:
 *      1.函数需要在对象属性修改后重新手动调用
 *      2.对象属性依赖绑定,某一个属性发生变化,所有函数都要调用
 *      3.多对象依赖绑定,某一对象发生变化,所有对象依赖函数都要调用
 *      4.函数需要一个一个手动调用
 */

const obj={
    name:'mike',
    age:18
}

function foo(){
    console.log(obj.name)
    console.log(obj.age)
}

//初始调用
foo() //mike 18

//修改对象属性
obj.name='jack'

//重新调用
foo() //jack 18

存在的问题:

  • 需要手动调用函数
  • 对象属性发生变化时,所有函数都要手动重新调用
  • 多对象依赖混乱
  • 每个函数需要单独调用

优势:

  • 概念简单,容易理解
  • 实现方式直接明了
  • 不需要额外的监听机制,代码量少

2. 集中管理响应函数

改进方向是使用数组收集需要响应的函数,方便统一管理和调用:

/**
 * 阶段二:函数与对象的绑定
 *
 * 改变:1.新增收集函数
 *
 * 解释:一个或多个函数有该对象的属性,在对象属性发生变化时,重新调用函数,结果也发生变化
 *
 *
 * 缺点:
 *      1.函数需要在对象属性修改后重新手动调用
 *      2.对象属性依赖绑定,某一个属性发生变化,所有函数都要调用
 *      3.多对象依赖绑定,某一对象发生变化,所有对象依赖函数都要调用
 *
 * 优点:
 *      1.函数可以一起调用
 */

const obj={
    name:'mike',
    age:18
}


//需要响应式的函数都存入该数组里面
const reactiveFns=[]

//收集响应式函数
function watchFn(fn){
    reactiveFns.push(fn)
}

function foo(){
    console.log('first:'+obj.name)
    console.log('first:'+obj.age)
}

//调用收集函数(第一种调用方法,foo仍然可以手动调用)
watchFn(foo)

//调用收集函数(第二种调用方法,需使用数组才可手动调用)
watchFn(function(){
    console.log('second:'+obj.name)
    console.log('second:'+obj.age)
})

reactiveFns.forEach(fn=>{
    fn()
})

// first:mike
// first:18
// second:mike
// second:18


obj.name='jack'

reactiveFns.forEach(fn=>{
    fn()
})

// first:jack
// first18
// second:jack
// second:18

改进点:

  • 函数可以统一调用
  • 响应式函数集中管理

仍存在的问题:

  • 属性修改后需要手动触发函数调用
  • 不同对象的依赖函数混在一起

优势:

  • 统一管理所有需要响应的函数
  • 批量执行响应函数,减少重复代码
  • 可以动态添加新的响应函数

3. 使用Depend类管理依赖

为了更好地组织代码,引入Depend类来管理依赖关系:

/**
 * 阶段三:函数与对象的绑定
 *
 * 改变:
 *      1.新增收集函数
 *      2.新增类Depend
 *
 * 解释:一个或多个函数有该对象的属性,在对象属性发生变化时,重新调用函数,结果也发生变化
 *
 *
 * 缺点:
 *      1.函数需要在对象属性修改后重新手动调用
 *      2.对象属性依赖绑定,某一个属性发生变化,所有函数都要调用
 *
 *
 * 优点:
 *      1.函数可以一起调用
 *      2.对象可以分类管理
 */

class Depend{
    constructor(){
        this.reactiveFns=[]
    }

    //收集依赖函数
    addDepend(fn){
        this.reactiveFns.push(fn)
    }

    //调用依赖函数
    notify(){
        this.reactiveFns.forEach(fn=>{
            fn()
        })
    }
}

// ========= 分类管理 obj =============

const obj={
    name:'mike',
    age:18
}

const dep=new Depend()

dep.addDepend(function(){
    console.log('second:'+obj.name)
    console.log('second:'+obj.age)
})

dep.notify()
// second:mike
// second:18

obj.name='jack'

dep.notify()
// second:jack
// second:18

改进点:

  • 对象依赖可以分类管理
  • 代码结构更加清晰

优势:

  • 面向对象的设计,更加结构化
  • 可以为不同对象创建独立的依赖管理
  • 封装了依赖收集和通知逻辑,使用更加灵活
  • 为后续扩展提供了基础设施

4. 使用Object.defineProperty实现自动响应

通过Object.defineProperty监听对象的属性变化,实现自动响应:

/**
 * 阶段四:函数与对象的绑定
 *
 * 改变:
 *      1.新增收集函数
 *      2.新增类Depend
 *      3.使用Object.defineProperty监听对象属性====>vue2
 *
 * 解释:一个或多个函数有该对象的属性,在对象属性发生变化时,重新调用函数,结果也发生变化
 *
 *
 * 缺点:
 *      1.对象属性依赖绑定,某一个属性发生变化,所有函数都要调用
 *
 *
 * 优点:
 *      1.函数可以一起调用
 *      2.对象可以分类管理
 *      3.属性被监听,属性修改时,函数自动调用
 */

class Depend{
    constructor(){
        this.reactiveFns=[]
    }

    //收集依赖函数
    addDepend(fn){
        this.reactiveFns.push(fn)
    }

    //调用依赖函数
    notify(){
        this.reactiveFns.forEach(fn=>{
            fn()
        })
    }
}

// ========= 分类管理 obj =============

const obj={
    name:'mike',
    age:18
}

const dep=new Depend()

dep.addDepend(function(){
    console.log('second:'+obj.name)
    console.log('second:'+obj.age)
})

Object.keys(obj).forEach(key=>{
    let value=obj[key]

    Object.defineProperty(obj,key,{
        set:(newValue)=>{
            value=newValue
            dep.notify()
        },
        get:()=>{
            return value
        }
    })
})

dep.notify()
// second:mike
// second:18

obj.name='jack'//监听修改后调用dep.notify()
// second:jack
// second:18

改进点:

  • 属性被监听,修改时自动调用依赖函数
  • 无需手动调用notify()

优势:

  • 实现了真正的响应式,属性变化自动触发更新
  • 简化了使用流程,无需手动监听属性变化
  • 提供了更加透明的使用体验
  • 在底层实现变化监听,应用代码更加纯净

5. 完善依赖收集与精确响应

为每个对象的每个属性创建独立的依赖收集器,实现更精确的响应:

/**
 * 阶段五:函数与对象的绑定
 *
 * 改变:
 *      1.新增收集函数
 *      2.新增类Depend
 *      3.使用Object.defineProperty监听对象属性====>vue2
 *      4.新增map结构封装函数,给每个对象和每个属性设置对应的dep实例
 *
 * 解释:一个或多个函数有该对象的属性,在对象属性发生变化时,重新调用函数,结果也发生变化
 *
 *
 *
 *
 * 优点:
 *      1.函数可以一起调用
 *      2.对象可以分类管理
 *      3.属性被监听,属性修改时,函数自动调用
 *      4.对象属性之间减少依赖关系,独立调用对应函数
 */

class Depend{
    constructor(){
        this.reactiveFns=new Set() //避免重复添加依赖函数
    }

    //收集依赖函数
    addDepend(fn){
        this.reactiveFns.add(fn)
    }

    //调用依赖函数
    notify(){
        this.reactiveFns.forEach(fn=>{
            fn()
        })
    }
}

//存储响应式函数
let reactiveFn=null

function watchFn(fn){
    reactiveFn=fn
    fn() //激活监听响应
}

//对象map源头,弱引用weakMap
const objMap=new WeakMap()

//map结构的封装函数(分配dep实例)
function getDepend(obj,key){
    //1.根据源头map,找到obj对应的map
    let map=objMap.get(obj)
    //若map不存在
    if(!map){
        map=new Map()
        objMap.set(obj,map)
    }

    //2.根据obj的map对象,找到key对应的depend对象(有一个选择,如果key也是一个对象,是否需要深层响应)
    let dep=map.get(key)
    //若dep不存在
    if(!dep){
        dep=new Depend()
        map.set(key,dep)
    }

    return dep
}


//添加响应式函数
function reactive(obj){
    Object.keys(obj).forEach(key=>{
    let value=obj[key]

    Object.defineProperty(obj,key,{
        set:(newValue)=>{
            value=newValue
            const dep=getDepend(obj,key)
            dep.notify()
        },
        get:()=>{
            const dep=getDepend(obj,key)
            //防止重复添加依赖函数
            dep.addDepend(reactiveFn)
            return value
        }
    })
})
    return obj
}

改进点:

  • 对象属性与依赖函数的关系更加精确
  • 只触发依赖特定属性的函数
  • 自动收集依赖关系

优势:

  • 细粒度的依赖收集,只有真正依赖某属性的函数才会被触发
  • 使用WeakMap避免内存泄漏问题
  • 使用Set避免重复添加依赖函数
  • 自动在属性获取时收集依赖,无需手动指定依赖关系
  • 提供了完整的reactive函数,封装了响应式逻辑

6. 使用Proxy实现全面的响应式

最终使用ES6的Proxy替代Object.defineProperty,实现更强大的响应式系统:

/**
 * 阶段六:函数与对象的绑定
 *
 * 改变:
 *      1.新增收集函数
 *      2.新增类Depend
 *      3.使用Proxy代理对象属性====>vue3
 *      4.新增map结构封装函数,给每个对象和每个属性设置对应的dep实例
 *
 * 解释:一个或多个函数有该对象的属性,在对象属性发生变化时,重新调用函数,结果也发生变化
 *
 *
 *
 *
 * 优点:
 *      1.函数可以一起调用
 *      2.对象可以分类管理
 *      3.属性被监听,属性修改时,函数自动调用
 *      4.对象属性之间减少依赖关系,独立调用对应函数
 */

//添加响应式函数
function reactive(obj){
    const objProxy=new Proxy(obj,{
        set(target,key,newValue,receiver){
            Reflect.set(target,key,newValue,receiver)
            const dep=getDepend(target,key)
            dep.notify()
        },
        get(target,key,receiver){
            const dep=getDepend(target,key)
            dep.addDepend(reactiveFn)
            return Reflect.get(target,key,receiver)
        }
    })
    return objProxy
}

Proxy的优势:

  1. 可以监听动态添加的属性
  2. 可以监听数组的变化
  3. 可以监听更多种类的操作(不仅限于get/set)
  4. 性能更好,不需要递归遍历对象的所有属性
  5. 返回的是一个新对象,不会修改原始对象
  6. 可以拦截更多的操作,如删除属性、检查属性是否存在等

总结

JavaScript响应式原理的演进经历了以下几个阶段:

  1. 最基础的手动绑定与调用
  2. 集中管理响应函数
  3. 使用Depend类组织依赖关系
  4. Object.defineProperty实现自动响应
  5. 完善依赖收集与精确响应
  6. 使用Proxy实现全面的响应式系统

打包一个工具类

2025年7月4日 17:41

FXUtils 项目超详细配置说明

本项目是一个 TypeScript 工具库,采用 Rollup 打包,支持 npm、浏览器等多种使用场景,内置 Babel + core-js polyfill,兼容老环境。本文档面向新手,逐项详细解释每个配置文件和打包流程。


目录结构简述

├── dist/                # 打包输出目录
│   ├── index.cjs.js     # CommonJS 产物
│   ├── index.esm.js     # ESModule 产物
│   ├── index.umd.js     # UMD 产物(浏览器/Node 通用)
│   └── types/           # 类型声明文件
├── src/                 # 源码目录
│   ├── index.ts         # 主入口
│   └── utils/           # 工具函数目录
├── package.json         # npm 配置
├── tsconfig.json        # TypeScript 配置
├── .babelrc             # Babel 配置
├── rollup.config.js     # 打包配置
├── .gitignore           # 忽略文件
├── index.html           # 浏览器 demo
└── 文档.txt             # 常用命令

1. package.json 详细说明

{
  "name": "@fuxing666/fxutils",         // 包名,npm 发布和引用时用
  "version": "2.1.0",                   // 版本号
  "main": "dist/index.cjs.js",          // CommonJS 入口(Node.js require 时用)
  "module": "dist/index.esm.js",        // ESModule 入口(现代打包工具/浏览器用)
  "types": "dist/types/index.d.ts",     // TypeScript 类型声明入口
  "scripts": {
    "build": "rimraf dist && rollup -c" // build 命令,先删 dist 再 rollup 打包
  },
  "devDependencies": {                   // 只在开发/打包时需要的依赖
    "@babel/core": "^7.28.0",           // Babel 主程序
    "@babel/preset-env": "^7.28.0",     // Babel 预设,转译新 JS 语法
    "@babel/preset-typescript": "^7.27.1", // Babel 支持 TypeScript
    "@rollup/plugin-babel": "^6.0.4",   // Rollup Babel 插件
    "@rollup/plugin-commonjs": "^28.0.6", // Rollup CommonJS 插件
    "@rollup/plugin-node-resolve": "^16.0.1", // Rollup 解析 node_modules
    "@rollup/plugin-typescript": "^12.1.4", // Rollup TypeScript 插件
    "rimraf": "^6.0.1",                 // 跨平台删除 dist
    "rollup": "^2.79.2",                // 打包工具
    "rollup-plugin-terser": "^7.0.2",   // 代码压缩插件
    "typescript": "^5.8.3"              // TypeScript 编译器
  },
  "dependencies": {                      // 产物运行时需要的依赖
    "core-js": "^3.43.0",               // polyfill 运行时,Babel 注入新语法补丁
    "regenerator-runtime": "^0.14.1",   // async/await、generator 支持
    "tslib": "^2.8.1"                   // TypeScript 辅助库,importHelpers:true 时用
  }
}

重点说明:

  • devDependencies 只在开发/打包时用,用户用你的包时不需要。
  • dependencies 是产物运行时必须的,用户用你的包时会自动安装。
  • core-jsregenerator-runtimetslib 必须放 dependencies,否则用户运行时会缺包。

2. .babelrc 详细说明

{
  "presets": [
    [
      "@babel/preset-env",              // Babel 预设,转译新 JS 语法
      {
        "modules": false,               // 不转译模块语法,交给 Rollup 处理
        "useBuiltIns": "usage",         // 按需注入 polyfill(只补你用到的特性)
        "corejs": 3,                     // 使用 core-js v3 作为 polyfill 源
        "targets": {                     // 指定要兼容的目标环境
          "browsers": ["> 1%", "last 2 versions", "not dead"], // 主流现代浏览器
          "node": "12"                  // Node.js 12 及以上
        }
      }
    ],
    "@babel/preset-typescript"           // 支持 TypeScript 语法
  ]
}

重点说明:

  • Babel 会自动分析源码用到了哪些新语法,只注入需要的 polyfill,产物更小。
  • modules: false 让 Rollup 负责模块打包,避免重复处理。
  • corejs: 3 指定 polyfill 版本,和 package.json 里的 core-js 保持一致。
  • targets 字段决定 Babel 会把代码降级到什么标准,本配置兼容主流现代浏览器和 Node.js 12+,可根据实际需求调整。
    • 目标环境越新,Babel 注入的降级代码(polyfill 和语法转换)就越少,产物体积更小,运行效率更高。
    • 目标环境越老,兼容代码越多,产物体积会变大,但兼容性更好。
    • 推荐根据你的实际用户环境合理设置 targets,既保证兼容性又优化产物体积。

3. tsconfig.json 详细说明

{
  "compilerOptions": {
    "target": "ESNext",                 // 输出 JS 目标版本,交给 Babel/Rollup 再降级
    "module": "ESNext",                 // 输出模块格式,交给 Rollup 处理
    "declaration": true,                 // 生成 .d.ts 类型声明文件
    "declarationDir": "dist/types",     // 类型声明输出目录
    "outDir": "dist",                   // 编译后 JS 输出目录
    "rootDir": "src",                   // 源码根目录
    "strict": true,                      // 开启所有严格类型检查
    "esModuleInterop": true,             // 允许默认导入非 esModule 的模块
    "moduleResolution": "node",         // 按 node 方式解析模块
    "forceConsistentCasingInFileNames": true, // 文件名大小写一致性检查
    "skipLibCheck": true,                // 跳过依赖库类型检查,加快编译
    "importHelpers": true,                // 辅助函数用 tslib 引入,产物更小
    "resolveJsonModule": true             // 允许直接 import JSON 文件(如 package.json)
  },
  "include": ["src"],                   // 只编译 src 目录
  "exclude": ["node_modules", "dist"]  // 排除 node_modules 和 dist
}

重点说明:

  • importHelpers: true 让 TypeScript 产物更小,需配合 dependencies 里的 tslib。
  • resolveJsonModule: true 让 TypeScript 支持 import { version } from '../package.json' 这种写法,常用于在代码中读取版本号等信息。
  • 其它配置保证类型安全、兼容性和产物结构清晰。

4. rollup.config.js 详细说明

import typescript from '@rollup/plugin-typescript';      // 支持 TS 编译
import { terser } from 'rollup-plugin-terser';           // 产物压缩
import babel from '@rollup/plugin-babel';                // Babel 转译
import resolve from '@rollup/plugin-node-resolve';       // 解析 node_modules
import commonjs from '@rollup/plugin-commonjs';          // 支持 CommonJS 依赖
import json from '@rollup/plugin-json';                  // 支持 import JSON 文件

export default [
  {
    input: 'src/index.ts',                               // 入口文件
    output: [
      {
        file: 'dist/index.cjs.js',                      // CommonJS 产物
        format: 'cjs',
        sourcemap: true,                                 // 生成 sourcemap 方便调试
        exports: 'named',
      },
      {
        file: 'dist/index.esm.js',                      // ESModule 产物
        format: 'esm',
        sourcemap: true,
      },
      {
        file: 'dist/index.umd.js',                      // UMD 产物,浏览器/Node 通用
        format: 'umd',
        name: 'FXUtils',                                 // 浏览器全局变量名 window.FXUtils
        sourcemap: true,
        exports: 'named',
      },
    ],
    plugins: [
      json(),                                               // 必须放在最前面,确保 JSON 能被正确处理
      resolve(),                                        // 先解析 node_modules
      commonjs(),                                       // 再转 CommonJS
      typescript({
        tsconfig: './tsconfig.json',
        declaration: true,
        declarationDir: 'dist/types',
        rootDir: 'src',
      }),
      babel({
        babelHelpers: 'bundled',                        // Babel 辅助函数打包进产物
        extensions: ['.js', '.ts'],
        include: ['src/**/*'],
      }),
      terser(),                                         // 压缩产物
    ],
    external: undefined,                                // 不 external 任何依赖,全部打包进产物
  },
];

重点说明:

  • 插件顺序很重要:先 json,再 resolve,再 commonjs,再 typescript,再 babel,最后 terser。
  • external: undefined,确保所有依赖都被打包进产物,浏览器可直接用。
  • 输出三种格式,兼容 Node、现代打包工具和浏览器。
  • @rollup/plugin-json 插件让你可以在源码中 import JSON 文件(如 package.json),并在打包时自动内联到产物中。
  • 常用于库/工具包自动读取自身版本号等信息。

5. 打包流程详细说明

  1. 清理 dist 目录
    • 通过 rimraf 删除 dist,保证每次打包都是全新产物。
  2. TypeScript 编译
    • @rollup/plugin-typescript 读取 tsconfig.json,把 src 里的 TS 源码编译成 JS,并生成类型声明(.d.ts)。
    • 如果用到 TS 辅助函数(如 __extends),会 import 'tslib'。
  3. Babel 转译和 polyfill 注入
    • @rollup/plugin-babel 读取 .babelrc,把 JS 代码降级为兼容老环境的 JS,并按需注入 core-js polyfill。
    • 只补充你实际用到的新语法,产物更小。
  4. 依赖解析与 CommonJS 转换
    • @rollup/plugin-node-resolve 让 Rollup 能找到 node_modules 里的依赖。
    • @rollup/plugin-commonjs 把 core-js 等 CommonJS 依赖转成 ESModule,方便 Rollup 合并。
  5. 产物压缩
    • rollup-plugin-terser 压缩最终 JS 文件,减小体积。
  6. 输出多格式产物
    • 输出 CJS(Node)、ESM(现代打包工具)、UMD(浏览器/Node 通用)三种格式,满足各种使用场景。
  7. 类型声明输出
    • 所有导出函数的类型声明会输出到 dist/types 目录,方便 TS 用户获得类型提示。

6. 总结

  • 本项目配置适合新手学习和实际生产使用。
  • 每个配置项都为产物兼容性、体积、类型安全和易用性服务。
  • 如需扩展工具函数,直接在 src/utils/ 下添加并在 index.ts 导出即可。
  • 有任何配置相关问题,欢迎随时提问!

主要工具函数说明

add(a: number, b: number): number

加法工具,返回 a + b。

div(a: number, b: number): number

减法工具,返回 a - b。

log(args: Log): void

打印对象,参数类型:

export interface Log {
  a: number;
  b: string;
  c: boolean;
}

sortPlusUtil(arr, fieldConfigs, _sortConfig)

多字段智能排序工具,支持数字、中文、英文、日期、区间、版本号等多类型混排。

  • arr: 需要排序的对象数组
  • fieldConfigs: 字段及排序方式配置
  • _sortConfig: 可选,权重与正则自定义

类型声明

  • 所有工具函数均有完整类型声明,位于 dist/types/ 目录。
  • 例如 sortPlusUtil 类型:
declare function sortPlusUtil(arr?: AnyObject[], fieldConfigs?: FieldConfig[], _sortConfig?: Partial<SortConfig>): void;

浏览器使用

  • 直接在 html 中引入 UMD 产物:
<script src="dist/index.umd.js"></script>
<script>
  const { add, log, div, sortPlusUtil } = FXUtils;
  log({ a: 1, b: 'test', c: true });
  console.log(add(1, 2));
</script>

常用命令

  • yarn buildnpm run build:打包产物到 dist/
  • npm publish:发布到 npm
  • 其它 npm 相关命令见 文档.txt

其它说明

  • 支持 polyfill,兼容老浏览器和 Node 环境。
  • 产物自包含,无 require 依赖,浏览器可直接用。
  • 如需扩展工具函数,直接在 src/utils/ 下添加并在 index.ts 导出即可。

主要类型导出与使用说明

如何在其他项目中导入 OrderProp 类型?

本库已将 OrderProp 类型通过 export 导出,并在主入口 re-export,方便外部项目直接导入类型。

1. 类型声明与 re-export 实现

src/utils/sortPlusUtil.ts 中:

export type OrderProp = 'asc' | 'desc';

src/index.ts 主入口中:

export type { OrderProp } from './utils/sortPlusUtil';
2. 用户在其他项目中的用法
import type { OrderProp } from '@fuxing666/fxutils';

这样即可获得类型提示和类型安全。

===================

src/index.ts 代码

import add from "./utils/add";
import div from "./utils/div";
import log from "./utils/log";
import sortPlusUtil from "./utils/sortPlusUtil";

// // Polyfill 测试代码
// const arr = [1, 2, 3];
// console.log('includes:', arr.includes(2)); // 需要 polyfill

// Promise.resolve(1).finally(() => {
//   console.log('Promise finally polyfill test');
// });

export { add, log, div,sortPlusUtil };
 

export default {
  add,
  log,
  div,
  sortPlusUtil
};

export type { OrderProp } from './utils/sortPlusUtil';

===================

package.json 代码

{
  "name": "@fuxing666/fxutils",
  "version": "2.1.2",
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "types": "dist/types/index.d.ts",
  "scripts": {
    "build": "rimraf dist && rollup -c"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "devDependencies": {
    "@babel/core": "^7.28.0",
    "@babel/preset-env": "^7.28.0",
    "@babel/preset-typescript": "^7.27.1",
    "@rollup/plugin-babel": "^6.0.4",
    "@rollup/plugin-commonjs": "^28.0.6",
    "@rollup/plugin-json": "^6.1.0",
    "@rollup/plugin-node-resolve": "^16.0.1",
    "@rollup/plugin-typescript": "^12.1.4",
    "rimraf": "^6.0.1",
    "rollup": "^2.79.2",
    "rollup-plugin-terser": "^7.0.2",
    "typescript": "^5.8.3"
  },
  "dependencies": {
    "core-js": "^3.43.0",
    "regenerator-runtime": "^0.14.1",
    "tslib": "^2.8.1"
  }
}

===================

rollup.config.js 代码

import typescript from '@rollup/plugin-typescript';
import { terser } from 'rollup-plugin-terser';
import babel from '@rollup/plugin-babel';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';

export default [
  {
    input: 'src/index.ts',
    output: [
      {
        file: 'dist/index.cjs.js',
        format: 'cjs',
        sourcemap: true,
        exports: 'named',
      },
      {
        file: 'dist/index.esm.js',
        format: 'esm',
        sourcemap: true,
      },
      {
        file: 'dist/index.umd.js',
        format: 'umd',
        name: 'FXUtils',
        sourcemap: true,
        exports: 'named',
      },
    ],
    plugins: [
      json(),
      resolve(),
      commonjs(),
      typescript({
        tsconfig: './tsconfig.json',
        declaration: true,
        declarationDir: 'dist/types',
        rootDir: 'src',
      }),
      babel({
        babelHelpers: 'bundled',
        extensions: ['.js', '.ts'],
        include: ['src/**/*'],
      }),
      terser(),
    ],
    external: undefined, // 不 external 任何依赖,全部打包进产物
  },
]; 

===================

tsconfig.json 代码

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "declaration": true,
    "declarationDir": "dist/types",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "moduleResolution": "node",
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "importHelpers": true,
    "resolveJsonModule": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
} 

===================

.babelrc 代码

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false,
        "useBuiltIns": "usage",
        "corejs": 3,
        "targets": {
          "browsers": ["> 1%", "last 2 versions", "not dead"],
          "node": "12"
        }
      }
    ],
    "@babel/preset-typescript"
  ]
} 

Webpack[TBC]

2025年7月4日 17:41

核心概念

  1. 本质:模块打包器

    • 将各种资源(JS/CSS/图片等)视为模块
    • 通过依赖关系构建依赖图
    • 输出静态资源bundle
  2. 五大核心概念

    • Entry:打包入口起点,entry: './src/index.js'
    • Output:输出位置配置,path: path.resolve(__dirname, 'dist')
    • Loaders:处理非JS文件,{ test: /\.css$/, use: ['style-loader', 'css-loader'] }
    • Plugins:扩展功能,new HtmlWebpackPlugin()
    • Mode:环境模式,mode:'development'

核心工作原理

打包流程

graph LR
    A[入口文件 Entry] --> B[解析模块依赖]
    B --> C[构建依赖图]
    C --> D[应用 Loaders 转换]
    D --> E[打包模块资源]
    E --> F[执行插件优化]
    F --> G[输出 Bundles]
    G --> H[完成打包]

    classDef default fill:#f9f,stroke:#333,stroke-width:2px;
    classDef process fill:#6cf,stroke:#333,stroke-width:2px;
    classDef output fill:#2ecc71,stroke:#333,stroke-width:2px;
    
    class A,H process;
    class B,C,D,E,F process;
    class G output;

关键过程

  1. 入口文件
    • 从配置的入口文件开始打包过程
    • 通常是index.js/main.js
  2. 解析模块依赖
    • 分析文件中的import/require语句
    • 递归查找所有依赖模块
  3. 构建依赖图
    • 创建模块依赖关系图谱
    • 确定模块加载顺序
  4. 应用Loaders转换
    • 使用配置的Loaders处理各类资源
    • 如:JS转译、CSS预处理、图片优化
  5. 打包模块资源
    • 将所有模块合并为代码块chunk
    • 应用代码分割规则
  6. 执行插件优化
    • 运行插件进行额外处理
    • 如:代码压缩、资源注入、生成html
  7. 输出bundles
    • 将最终结果写入文件系统
    • 生成JS/CSS/资源文件
  8. 完成打包
    • 输出构建统计信息
    • 触发回调通知

配置webpack.config.js

基础结构

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.[contenthash].js',
        path: path.resolve(__dirname, 'dist'),
        clean: true,
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: 'babel-loader',
            },
        ],
    },
    plugins: [
        new HtmlWebpackPlugin({ template: './src/index.html' }),
    ],
    mode:'production',
};

Loaders

文件类型 Loader配置
Javascript babel-loader, @babel/preset-env
CSS ['style-loader', 'css-loader', 'postcss-loader']
SASS ['sass-loader'] [需配合css-loader]
图片/字体 type: 'asset/resource' [webpack5内置]
SVG @svg/webpack

Plugins

插件 用途
HtmlWebpackPlugin 生成html文件
MiniCssExtractPlugin 提取CSS到单独文件
DefinePlugin 定义环境变量
CleanWebpackPlugin 清理构建目录
BundleAnalyzerPlugin 包体积分析

高级优化策略

代码分割Code Splitting

将应用代码拆分为多个独立报chunk,按需加载或并行加载,减少初始加载体积。

目的

  1. 减少初始加载体积:避免用户下载整个应用代码
  2. 提高加载速度:并行加载多个小文件比单个大文件更快
  3. 按需加载:只加载当前视图需要的代码
  4. 缓存优化:第三方库单独打包,充分利用浏览器缓存

实现方式

  1. 入口点分割 \手动
// webpack.config.js
module.exports = {
    entry: {
        app: './src/app.js',
        vendor: ['react', 'react-dom']
    },
    output: {
        filename: '[name].bundles.js'
    }
};
  1. 动态导入
// 使用import语法
const loadComponent = () => import('./HeavyComponent');

button.addEventListener('click', () => {
    loadComponent().then(module => {
        module.default.render();
    });
});
  1. 智能分割SplitChunksPlugin
optimization: {
    splitChunks: {
        chunks: 'all',
        minSize: 20000,  // 超过20kb才分割
        maxSize: 0,
        minChunks: 1,
        cachedGroups: {
            vendors: {
                test: /[\\/]node_modules[\\/]/,
                name: 'vendors',
                priority: -10
            },
            commons:{
                name: 'commons',
                minChunks: 2,  // 被2个以上chunk引用
                priority: -20,
                reuseExistingChunk: true
            }
        }
    }
}

代码分割策略

策略类型 适用场景 示例
入口分割 多页面应用 entry: { home: './home.js', about: './about.js' }
动态分割 路由组件/弹窗 () => import('./UserProfile')
第三方库 稳定不常更新的库 cacheGroups.vendors
运行时代码 Webpack运行时 runtimeChunk: 'single'

Tree Shaking

  • 使用ES6模块语法import/export
  • 生产模式自动启动
  • package.json中添加:
"sideEffects": ["*.css", "*.global.js"]

缓存优化

output: {
    filename: '[name].[contenthash].js'
}

懒加载

// dynamic import
const loadModule  = () => import('./heavyModule.js');

自定义扩展

自定义loader

module.exports = function(source) {
    return source.replace(/console\.log\(.*\);?/g, '');
};

自定义plugin

class myPlugin {
    apply(complier) {
        commplier.hooks.done.tap('MyPlugin', stats => {
            console.log('编译完成!');
        });
    }
}

性能优化实践

构建速度优化

方法 实现
缓存loaders use: ['cache-loader', 'babel-loader']
多进程处理 thread-loader
DLL预构建 DllPlugin + DllReferencePlugin
缩小搜索范围 resolve: { modules: [path.resolve('node_modules')] }

输出优化

方法 实现
CDN加速 output.publicPath: 'https://cdn.example.com/'
Gzip压缩 compression-webpack-plugin
图片优化 image-webpack-loader

原理深入

Tapable事件流机制

compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
    // 处理逻辑
    callback();
});

核心对象关系

classDiagram
  Compiler *-- Compilation
  Compilation *-- Module
  Module *-- Dependency
  Compiler : hooks
  Compilation : modules
  Compilation : chunks

HMR热更新原理

  1. 建立WebSocket连接
  2. 文件修改触发重新编译
  3. 服务端发生更新消息
  4. 客户端拉取更新模块
  5. 执行模块替换逻辑

基于全屏 Quad 的 Three.js 后处理全解析

作者 良辰未晚
2025年7月4日 17:33

一、问题背景:

最近在使用three渲染点云的时候遇到一个问题,当我缩放相机到离得很远的时候,这时调整不透明度,就会变得很白很亮,开始我以为可以通过调整设置来解决,就测试深度写入、测试、混合模式之间的各种组合,但是效果都不理想。后面借鉴已有的edl渲染,终于解决这个问题了。

二、后处理核心原理:全屏 Quad 的妙用

在 Three.js 中,后处理的本质是对渲染结果进行二次加工。而 “全屏 Quad”(全屏平面)是实现这一过程的核心载体,其原理可概括为:

  1. 第一步:渲染场景到缓冲区
    先将 3D 场景渲染到一个离屏缓冲区(WebGLRenderTarget),得到包含颜色、深度信息的纹理。
  2. 第二步:用全屏 Quad 加工纹理
    创建一个铺满屏幕的平面(2x2 大小,刚好覆盖标准化设备坐标 NDC),通过自定义着色器材质对第一步得到的纹理进行处理,最终将结果渲染到屏幕。
  3. 核心工具:Utils.screenPass
    具体来说就是封装一个全屏 Quad、场景和相机,在得到纹理图之后,通过把纹理的深度、颜色等信息发送给自定义着色器来二次加工并输出至屏幕。其核心逻辑是通过render方法动态替换材质,实现不同效果的快速切换。

    实现步骤:

  • 定义着色器材质

    javascript

    const invertMaterial = new THREE.ShaderMaterial({
      uniforms: {
        uSceneTexture: { value: null } // 接收场景纹理
      },
      vertexShader: `
        varying vec2 vUv;
        void main() {
          vUv = uv; // 传递纹理坐标
          gl_Position = vec4(position, 1.0); // 全屏Quad坐标
        }
      `,
      fragmentShader: `
        uniform sampler2D uSceneTexture;
        varying vec2 vUv;
        void main() {
          vec4 color = texture2D(uSceneTexture, vUv);
          gl_FragColor = vec4(1.0 - color.rgb, color.a); // 反色计算
        }
      `
    });
    
  • 使用screenPass渲染

javascript

// 1. 创建渲染目标,用于存储场景原始渲染结果
const renderTarget = new THREE.WebGLRenderTarget(
  window.innerWidth,
  window.innerHeight
);

// 2. 先将场景渲染到目标缓冲区
renderer.render(scene, camera, renderTarget);

// 3. 传入反色材质,用screenPass渲染到屏幕
invertMaterial.uniforms.uSceneTexture.value = renderTarget.texture;
Utils.screenPass.render(renderer, invertMaterial);
  • screenPass函数实现

javascript

const screenPass = new function () {
// @ts-expect-error
this.screenScene = new THREE.Scene();
// @ts-expect-error
this.screenQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2, 1));
// @ts-expect-error
this.screenQuad.material.depthTest = true;
// @ts-expect-error
this.screenQuad.material.depthWrite = true;
// @ts-expect-error
this.screenQuad.material.transparent = true;
// @ts-expect-error
this.screenScene.add(this.screenQuad);
// @ts-expect-error
this.camera = new THREE.Camera();
// @ts-expect-error
this.render = function (renderer, material, target) {
this.screenQuad.material = material;

if (typeof target === 'undefined') {
renderer.render(this.screenScene, this.camera);
} else {
renderer.render(this.screenScene, this.camera, target);
}
};
}();

三、总结:

上面就是基于全屏 Quad 的全流程了,也算是后处理框架中最灵活、最易扩展的方案。通过Quad自定义着色器我们可以快速实现从简单颜色调整到复杂辉光、边缘检测的各种效果。核心要点在于:

  • 理解 “渲染→加工→再渲染” 的后处理流水线;

  • 掌握着色器中纹理采样和像素操作的技巧;

  • 学会组合多个后处理步骤实现复杂效果。

希望本文能帮助你打开后处理的大门,让你的 3D 场景焕发新的视觉活力!

# vue 的 Diff 算法

作者 胡清波
2025年7月4日 17:27

数据发生变化,Vue 是如何工作,更新 DOM 的

通常界面的 DOM 很少会出现大量的修改,都是局部的Ui的变化,

diff 算法之前 :这时如果要修改其中一个DOM元素。直接渲染到真实dom上会引起整个dom树的重绘和重排。

diff 算法:解决这个问题,帮我们精准的查找到了,那个需要修改的 dom。实现了局部更新。

比喻:有两个人名 notdiff 和 diff, 他们都刚洗好澡,手都同时脏了。notdiff 他重新洗了个澡,但是 diff 有思想了,只把手给洗干净了。

真实工作流程:在组件运行过程中,框架首先根据真实 DOM 构建一棵 Virtual DOM(虚拟节点树)。当组件数据发生变化时,会生成一个新的 Virtual DOM(newVnode)。随后,框架将新旧虚拟节点(newVnodeoldVnode)进行对比(diff),找出变更部分,并以最小化的方式更新真实 DOM。最后,将 newVnode 替换为 oldVnode,用于下一次对比。

diff算法的过程就是调用名为patch的函数,比较新旧节点,一边比较一边给真实的DOM打补丁。

Virtual DOM和真实DOM的区别

virtual DOM是将真实的DOM的数据抽取出来,以对象的形式模拟树形结构。比如dom是这样的:

<div>
    <p>123</p>
</div>

对应的virtual DOM(伪代码):

var Vnode = {
    tag: 'div',
    children: [
        { tag: 'p', text: '123' }
    ]
};

diff 算法的比较方式

在采取diff算法比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较。这样的做法是为了降低比较的复杂程度。

<div>
    <p>123</p>
</div>

<div>
    <span>456</span>
</div>

上面的代码会分别比较同一层的两个div以及第二层的p和span,但是不会拿div和span作比较。

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

diff 算法流程图

当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者(Watcher),订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。

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

具体分析

看看patch是怎么打补丁的(代码只保留核心部分)

function patch (oldVnode, vnode) {
    // some code
    if (sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode)
    } else {
    const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
    let parentEle = api.parentNode(oEl)  // 父元素
    createEle(vnode)  // 根据Vnode生成新元素
    if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
            oldVnode = null
    }
    }
    // some code 
    return vnode
}

sameVnode

  • 判断两节点是否相同,相同则执行patchVnode 进行深入检查他们的子节点比较

    function sameVnode (a, b) {
      return (
        a.key === b.key &&  // key值
        a.tag === b.tag &&  // 标签名
        a.isComment === b.isComment &&  // 是否为注释节点
        // 是否都定义了data,data包含一些具体信息,例如onclick , style
        isDef(a.data) === isDef(b.data) &&  
        sameInputType(a, b) // 当标签是<input>的时候,type必须相同
      )
    }
    
    
  • 不相同比较则用Vnode替换oldVnode

虽然这两个节点不一样但是他们的子节点一样怎么办?别忘了,diff可是逐层比较的,如果第一层不一样那么就不会继续深入比较第二层了。(我在想这算是一个缺点吗?相同子节点不能重复利用了...)不算,这是在性能上的取舍,为了降低对比的复杂程度。

patchVnode

当确定两个节点是一致后,需要进一步对其子节点进行比较patchVnode方法。那么这个方法做了什么呢?

patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
    if (oldCh && ch && oldCh !== ch) {
            updateChildren(el, oldCh, ch)
    }else if (ch){
            createEle(vnode) //create el's children dom
    }else if (oldCh){
            api.removeChildren(el)
    }
    }
}

这个函数做了以下事情:

  • 找到对应的真实dom,称为el
  • 判断VnodeoldVnode是否指向同一个对象,如果是,那么直接return
  • 如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点。
  • 如果oldVnode有子节点而Vnode没有,则删除el的子节点
  • 如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化DOM之后添加到el
  • 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要

其他几个点都很好理解,我们详细来讲一下updateChildren

updateChildren

代码量很大,不方便一行一行的讲解,所以下面结合一些示例图来描述一下。

  • 代码

    updateChildren (parentElm, oldCh, newCh) {
        let oldStartIdx = 0, newStartIdx = 0
        let oldEndIdx = oldCh.length - 1
        let oldStartVnode = oldCh[0]
        let oldEndVnode = oldCh[oldEndIdx]
        let newEndIdx = newCh.length - 1
        let newStartVnode = newCh[0]
        let newEndVnode = newCh[newEndIdx]
        let oldKeyToIdx
        let idxInOld
        let elmToMove
        let before
        while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
            if (oldStartVnode == null) {   // 对于vnode.key的比较,会把oldVnode = null
                oldStartVnode = oldCh[++oldStartIdx] 
            }else if (oldEndVnode == null) {
                oldEndVnode = oldCh[--oldEndIdx]
            }else if (newStartVnode == null) {
                newStartVnode = newCh[++newStartIdx]
            }else if (newEndVnode == null) {
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldStartVnode, newStartVnode)) {
                patchVnode(oldStartVnode, newStartVnode)
                oldStartVnode = oldCh[++oldStartIdx]
                newStartVnode = newCh[++newStartIdx]
            }else if (sameVnode(oldEndVnode, newEndVnode)) {
                patchVnode(oldEndVnode, newEndVnode)
                oldEndVnode = oldCh[--oldEndIdx]
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldStartVnode, newEndVnode)) {
                patchVnode(oldStartVnode, newEndVnode)
                api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
                oldStartVnode = oldCh[++oldStartIdx]
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldEndVnode, newStartVnode)) {
                patchVnode(oldEndVnode, newStartVnode)
                api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
                oldEndVnode = oldCh[--oldEndIdx]
                newStartVnode = newCh[++newStartIdx]
            }else {
               // 使用key时的比较
                if (oldKeyToIdx === undefined) {
                    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
                }
                idxInOld = oldKeyToIdx[newStartVnode.key]
                if (!idxInOld) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                    newStartVnode = newCh[++newStartIdx]
                }
                else {
                    elmToMove = oldCh[idxInOld]
                    if (elmToMove.sel !== newStartVnode.sel) {
                        api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                    }else {
                        patchVnode(elmToMove, newStartVnode)
                        oldCh[idxInOld] = null
                        api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                    }
                    newStartVnode = newCh[++newStartIdx]
                }
            }
        }
        if (oldStartIdx > oldEndIdx) {
            before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
            addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
        }else if (newStartIdx > newEndIdx) {
            removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
        }
    }
    
    

先说一下这个函数做了什么

  • Vnode的子节点Vch,和oldVnode的子节点oldCh提取出来
  • oldChvCh各有两个头尾的变量StartIdxEndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配上,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldChvCh至少有一个已经遍历完了,就会结束比较。

图解updateChildren

上面的总结相信很多人也看得一脸懵逼,下面我们好好说道说道。

灰色的部分为 oldVNode 和 白色 nVNode

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

它们取出来并分别用 s 和 e 指针 指向它们的 头 child 和 尾 child

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

现在分别对oldS、oldE、S、E组合比较sameVnode比较,有四种比较方式 头头、尾尾、头尾、尾头

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

匹配成功:当某一个节点通过了 sameVnode 比较那么即认为它们代表同一个真实 DOM 节点时,

Diff 算法不会重新创建新的 DOM 元素,而是复用旧节点对应的真实 DOM

并将这个旧节点对应的真实 DOM 通过 patch 操作更新成新节点的状态(属性、子节点等)

最终新节点 nVnode.el 会指向这个复用的真实 DOM 元素。

匹配失败:(头尾指针对应的节点都不能复用,需要更灵活地在“中间区域”寻找可以复用的节点。)

如果四种匹配没有一对是成功的,分为两种情况

如果新旧子节点都存在key,那么会根据oldChild的key生成一张hash表,用S的key与hash表做匹配,匹配成功就判断S和匹配节点是否为sameNode如果是,就在真实 dom 中将成功的节点移到最前面匹配失败否则,将S生成对应的真实DOM插入到dom中对应的oldS位置,S指针向中间移动。在这个过程中被匹配old中的节点置为null。如果没有key,则直接将S生成新的节点插入真实DOM (ps:这下可以解释为什么v-for的时候需要设置key了,如果没有key那么就只会做四种匹配,就算指针中间有可复用的节点都不能被复用了)

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

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

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

当这些节点sameVnode成功后就会紧接着执行patchVnode了,可以看一下上面的代码

if (sameVnode(oldStartVnode, newStartVnode)) {
    patchVnode(oldStartVnode, newStartVnode)
}

就这样层层递归下去,直到将oldVnode和Vnode中的所有子节点比对完。也将dom的所有补丁都打好啦。那么现在再回过去看updateChildren的代码会不会容易很多呢?

本文地址:www.notion.so/vue-Diff-21… 参考地址:文章 :详解vue的diff算法

视频地址:www.bilibili.com/video/BV1nD…

el-table虚拟列表封装

2025年7月4日 17:25

框架:vue2

UI 库:Element UI

背景

商品编辑规格部分,规格项和规格值通过笛卡尔积运算之后会生产 sku 表格,因为笛卡尔积是每组规格值的相乘计算,所以很容易产生大数据量的表格;经测试,Element UI 的表格在商品编辑页面中(固定列数)展示超过 100 条以上,页面就会有明显的卡顿;我们的客户有做图书类目的,规格数目有超过 700 条、1000 条的,进入编辑页面直接卡死;所以需要优化此部分性能;

虚拟列表优化方案确定

虚拟列表最终确定实现方案为:改造 Element UI 的表格

实现思路

思路: 固定高度的表格区域内,写一个滚动条,每一行固定高度,通过表格数量*每一行的高度就能算出来这个滚动条应该是什么长度;监听滚动条的滚动距离就能算出来当前表格应该展示第 n 到 m 行数据;然后数据驱动视图渲染;

表格部分分为 2 个元素,表格元素和滚动条元素

  1. 表格元素和滚动条元素一样大小区域,层级为上下级关系,上下布局:表格覆盖滚动条,或者滚动条覆盖表格
    1. 弊端:表格区域无法点击 (内部表单元素无法聚焦),或者滚动条无法聚焦拖动
  1. 表格元素和滚动条元素同高不同宽,同层级,左右布局
    1. 弊端:触摸板滚动在表格区域无法滚动

表格部分 1 个元素,借用表格原来的滚动条,❓如何撑起来表格原声的滚动条呢?

  1. 表格行首加一个占位元素,给这个元素设置表格该有的高度(行高 * 行数 - 表格实际展示行数 * 行高)
    1. 弊端:实际展示表格的区域被挤下去了,页面中看不到
  1. 表格行尾加一个占位元素,给这个元素设置表格该有的高度(行高 * 行数 - 表格实际展示行数 * 行高)
    1. 弊端:实际展示表格的区域被挤上去了,页面中看不到

表格中行首、行尾各加一个占位元素,两个元素高度之和 = 行高 * 行数 - 表格实际展示行数 * 行高

初始化状态:行首高度为 0

滚动时:动态计算高度,分别赋高度给行首占位元素和行尾占位元素

滚动条滚动到底时:尾部元素高度为 0

代码实现

1. 流程图:

2. 核心方法执行顺序:

初始化阶段 mounted() → initTable() → updateData(0) → setTableHeight()

滚动更新阶段 handleScroll() → updateIndex() → initTable() → updateData() → 智能合并数据 → setTableHeight()

数据更新阶段 watch:data() → initTable() → 重新计算渲染范围

激活状态同步 eventBus.$on('changeIndex') ←→ updateData()中的索引边界检查

3. 关键算法说明:

智能数据合并:当新旧数据范围有重叠时,采用slice+concat方式保留重叠区域DOM,避免重新渲染

激活行同步:通过eventBus在currentRealActiveIndex超出渲染范围时重置激活状态

滚动节流:使用requestAnimationFrame实现滚动事件节流,确保16ms触发一次更新

<template>
  <el-table
    ref="vTable"
    :data="vData"
    :max-height="height"
    class="v-table"
    v-bind="$attrs">
    <slot name="default"></slot>
    <template #append>
      <slot name="append"></slot>
    </template>
  </el-table>
</template>

<script>
import Vue from 'vue'

export default {
  name: 'ElVTable',
  props: {
    height: {
      type: Number,
    },
    data: Array,
    useVitrual: {
      type: Boolean,
      default: false
    },
    itemHeight: {
      required: true,
      type: Number,
    }
  },
  data () {
    return {
      topSit: null,
      bottomSit: null,
      vData: [],
      vIndex: 0,
      ticking: false,
      scrollLis: null,
      preStart: -1,
      preEnd: -1,
      currentRealActiveIndex: -1,
      eventBus: null,
    }
  },
  provide () {
    return {
      getCurrentIndex: (index) => this.getCurrentIndex(index),
      itemHeight: this.itemHeight,
      eventBus: () => this.eventBus,
    }
  },
  created () {
    this.eventBus = new Vue()
    this.eventBus.$on('changeIndex', data => {
      this.currentRealActiveIndex = data
    })
  },
  mounted () {
    if (this.height && this.useVitrual) {
      this.initTable()
      this.initScroll()
    }
  },
  beforeDestroy () {
    this.clearScroll()
  },
  watch: {
    data (val) {
      if (val && this.height && this.useVitrual) {
        this.initTable()
      } else {
        this.vData = val
      }
    }
  },
  computed: {},
  methods: {
    getTable () {
      return this.$refs.vTable
    },
    handleScroll () {
      if (!this.ticking) {
        requestAnimationFrame(() => {
          this.updateIndex()
          this.ticking = false
        })
        this.ticking = true
      }
    },
    initScroll () {
      const scrollWrapper = this.$refs.vTable?.$refs?.bodyWrapper
      if (scrollWrapper) {
        this.scrollLis = scrollWrapper.addEventListener('scroll', this.handleScroll)
      }
    },
    clearScroll () {
      const scrollWrapper = this.$refs.vTable.$refs.bodyWrapper
      scrollWrapper.removeEventListener('scroll', this.handleScroll)
    },
    setTableHeight (start, end) {
      const tableBody = this.$refs.vTable.$children.find(item => item.$options.name === 'ElTableBody')
      if (!this.topSit) {
        this.topSit = document.createElement('div')
        tableBody.$el.insertBefore(this.topSit, tableBody.$el.children[0])
      }
      if (!this.bottomSit) {
        this.bottomSit = document.createElement('div')
        tableBody.$el.appendChild(this.bottomSit)
      }
      this.topSit.style.height = start === 0 ? '0px' : (start) * this.itemHeight + 'px'
      this.bottomSit.style.height = end >= this.data.length ? '0px' : (this.data.length - end) * this.itemHeight + 'px'
    },
    updateIndex () {
      const bodyWrapper = this.$refs.vTable.$refs.bodyWrapper
      const scrollTop = bodyWrapper.scrollTop
      this.vIndex = Math.floor(scrollTop / this.itemHeight)
      this.initTable()
    },
    initTable () {
      const {
        start,
        end
      } = this.updateData(this.vIndex)
      this.setTableHeight(start, end)
    },
    updateData (index) {
      const visibleItems = Math.ceil(this.height / this.itemHeight) // 计算可见行数
      const buffer = 3 // 滚动缓冲区

      // 计算新的起止索引(带缓冲区)
      let start = Math.max(0, index - buffer)
      if (start > 0 && start >= this.data.length - visibleItems - 2 * buffer) { // buffer * 2 判断的是滚动到底部之后可能会往回滚动
        start = this.data.length - visibleItems - 2 * buffer
      }
      const end = Math.min(
        this.data.length,
        index + visibleItems + buffer
      )

      // 如果范围没有变化则跳过更新
      if (this.preStart === start && this.preEnd === end) {
        return {
          start,
          end
        }
      }

      // 智能更新数据逻辑
      if (this.preStart !== -1 && this.preEnd !== -1) {
        if (start >= this.preStart && end <= this.preEnd) { // 情况1:新范围在旧范围内
          // 不需要更新数据
        } else if (start <= this.preEnd && end >= this.preStart) { // 情况2:有部分重叠
          const overlapStart = Math.max(start, this.preStart) // 重叠区起始索引
          const overlapEnd = Math.min(end, this.preEnd) // 重叠区结束索引
          // 获取需要新增的前部数据(旧范围之前的新数据)
          const newFrontData = this.data.slice(start, overlapStart)
          // 获取需要新增的尾部数据(旧范围之后的新数据)
          const newTailData = this.data.slice(overlapEnd, end)
          this.vData = [
            ...newFrontData, // 新增前部数据
            ...this.vData.slice( // 保留重叠区域现有数据
              overlapStart - this.preStart, // 计算重叠区在现有数据中的起始偏移
              overlapEnd - this.preStart // 计算重叠区在现有数据中的结束偏移
            ),
            ...newTailData // 新增尾部数据
          ]
        } else { // 情况3:完全无重叠
          this.vData = this.data.slice(start, end)
        }
      } else {
        this.vData = this.data.slice(start, end)
      }

      this.$nextTick(() => {
        this.$emit('updateData', {
          start,
          end
        })
      })

      // 更新索引并重置激活状态
      this.preStart = start
      this.preEnd = end

      if (this.currentRealActiveIndex < start || this.currentRealActiveIndex >= end) {
        this.currentRealActiveIndex = -1
        this.eventBus.$emit('changeIndex', -1)
      }

      return {
        start,
        end
      }
    },
    getCurrentIndex (index) {
      return index + this.preStart
    },
  }
}

</script>

<style scoped>
/* 修正后的选择器写法 */
::v-deep .el-table__body-wrapper::-webkit-scrollbar {
  width: 12px; /* 纵向滚动条宽度 */
  height: 12px; /* 横向滚动条高度 */
}

::v-deep .el-table__body-wrapper::-webkit-scrollbar-track {
  background: #f5f5f5;
  border-radius: 6px;
}

::v-deep .el-table__body-wrapper::-webkit-scrollbar-thumb {
  background: #c1c1c1;
  border-radius: 6px;
}

::v-deep .el-table__body-wrapper::-webkit-scrollbar-thumb:hover {
  background: #a8a8a8;
}

</style>

4. 实际落地后白屏严重

由于直接拖动滚动条后,数据重新渲染,而且每一行表单元素较多,表单元素(如输入框、下拉菜单、按钮等)需要处理用户输入、焦点状态、验证规则等动态交互,浏览器需为其构建更复杂的事件监听机制

所以有如果直接拖动滚动条,数据没有重叠的时候就会产生白屏;

解决方案: 初次渲染为只读元素,鼠标浮入变表单元素

<template>
  <el-table-column v-bind="$attrs">
    <template #header="{row, $index}">
      <slot :$index="$index" :row="row" name="header">
        {{ $attrs.label }}
      </slot>
    </template>
    <template slot-scope="{row,$index}">
      <div :style="{height: (itemHeight - 17) + 'px'}" class="detail-cell" @mouseenter="handleShowEdit(row,$index)">
        <slot :$index="$index" :isEditing="isEditing($index)" :row="row">
          {{ row[$attrs.prop] }}
        </slot>
      </div>
    </template>
  </el-table-column>
</template>

<script>

export default {
  name: 'el-v-column',
  props: {
    useVirtualDetail: {
      type: Boolean,
      default: true
    }
  },
  inject: ['getCurrentIndex', 'itemHeight', 'eventBus'],
  data () {
    return {
      init: false,
      currentActiveIndex: -1, // 记录虚拟表格中的索引位置
      currentRealActiveIndex: -1, // 用于记录当前真正的索引位置
      currentActiveEl: null
    }
  },
  computed: {
    isEditing () {
      return (index) => {
        return this.currentRealActiveIndex === this.getCurrentIndex(0) + index
      }
    }
  },
  mounted () {
    this.eventBus().$on('changeIndex', (data) => {
      if (this.currentActiveEl && data === -1) {
        this.removeElListener(this.currentActiveEl)
      }
      this.currentRealActiveIndex = data
    })
  },
  methods: {
    handleShowEdit (row, index) {
      this.bindClickOutside(row, index)
    },
    getTableChildren (table, index) {
      const tableBody = table.$children.find(item => item.$options.name === 'ElTableBody')
      const rowList = tableBody.$children.filter(item => item.$options.name === 'ElTableRow')
      return rowList.find(item => item.index === index)
    },
    getParentTable () {
      let parent = this.$parent
      while (parent.$options.name !== 'ElTable') {
        parent = parent.$parent
      }
      return parent
    },
    getCurrentEl (index) {
      const table = this.getParentTable()
      const currentRow = this.getTableChildren(table, index)
      return currentRow.$el
    },
    bindClickOutside (row, index) {
      const el = this.getCurrentEl(index)

      const isSameCell = this.currentRealActiveIndex === this.getCurrentIndex(0) + index

      if (isSameCell) return

      // 不匹配时 清空上一个元素的事件
      if (!isSameCell && this.currentActiveIndex !== -1) {
        this.currentActiveIndex = -1
        this.removeElListener(this.currentActiveEl)
      }

      const _this = this
      this.init = false
      this.currentActiveIndex = index
      this.currentRealActiveIndex = this.getCurrentIndex(this.currentActiveIndex)
      this.eventBus().$emit('changeIndex', this.currentRealActiveIndex)
      // this.currentActiveEl = el
      // // 在元素上绑定一个点击事件监听器
      // el.clickOutsideEvent = function (event) {
      //   if (!(_this.currentActiveEl === event.target || _this.currentActiveEl?.contains(event.target)) && _this.init) {
      //     _this.currentRealActiveIndex = -1
      //     _this.currentActiveIndex = -1
      //     _this.removeElListener(el)
      //     _this.init = false
      //
      //     _this.eventBus().$emit('changeIndex', -1)
      //   }
      //   // 若点击前后为不同cell 或者没有绑定cell  则初始化点击事件
      //
      //   if (!_this.init && (!isSameCell || _this.currentActiveIndex === -1)) {
      //     _this.init = true
      //   }
      // }
      // // 在文档上添加点击事件监听器
      // document.addEventListener("click", el.clickOutsideEvent)
    },
    removeElListener () {
      if (this.currentActiveEl) {
        document.removeEventListener("click", this.currentActiveEl.clickOutsideEvent)
        this.currentActiveEl.clickOutsideEvent = null
      }

      this.currentActiveEl = null
    },

  },

  beforeDestroy () {
    if (this.currentActiveEl) {
      document.removeEventListener("click", this.currentActiveEl.clickOutsideEvent)
    }
    this.eventBus().$off('changeIndex')
  },
}

</script>
<style>
.detail-cell {
  overflow-y: auto;
  display: flex;
  align-items: center;
}

</style>

数据校验

原有 以 实际 dom 元素为基准 触发的数据校验机制已经无法满足我们的场景了;此时得换用以数据为基准触发校验了;

1. 数据整体保存时校验,

告知用户第几行有问题

validTableData (tableData) {
  return new Promise((resolve, reject) => {
    const errors = []
    const errorsRowIndex = []
    // 遍历所有SKU项
    tableData?.forEach((row, index) => {
      // 校验SKU编码
      this.validPrdSnRules(null, row.prdSn, (error) => {
        error && errors.push(`第${index + 1}行SKU编码: ${error.message}`)
        error && errorsRowIndex.push(index + 1)
      }, row)

      // 校验库存
      this.validatePrdNumber(null, row.prdNumber, (error) => {
        error && errors.push(`第${index + 1}行库存: ${error.message}`)
        error && errorsRowIndex.push(index + 1)
      }, row)

      // 校验价格
      this.validatePrdPrice(null, row.prdPrice, (error) => {
        error && errors.push(`第${index + 1}行价格: ${error.message}`)
        error && errorsRowIndex.push(index + 1)
      }, row)
    })

    // 返回校验结果
    resolve({
      type: errors.length > 0 ? 'error' : 'success',
      data: errors,
      message: errors.length > 0 ? `请完善如下行规格信息:  ${[...new Set(errorsRowIndex)].join(',    ')}` : ''
    })
  })
}

2. 表格滑动时校验

标红处理每一行的报错明细

<template>
  <elVTable
    :key="tableKey"
    ref="multiSpecTable"
    :data="showTableData"
    :height="600"
    :itemHeight="108"
    row-key="prdDesc"
    border
    header-row-class-name="tableClss"
    useVitrual
    @updateData="handleUpdateData"
  ></elVTable>
</template>
<script>
  export default {
    data() {
      return {
        debouncedValidate: null
      }
    },
    mounted(){
      // 创建防抖校验方法
      this.debouncedValidate = _.debounce((indexArray) => {
        this.$nextTick(() => {
          indexArray.forEach(item => {
            this.formRef.validateField(`goodsPublishProduct.${item}.prdNumber`)
            this.formRef.validateField(`goodsPublishProduct.${item}.prdPrice`)
          })
        })
      }, 1000)
    },
    methods:{
      createRangeArray (start, end) {
        return Array.from({ length: end - start + 1 }, (_, i) => start + i)
      },
      
      handleUpdateData ({start,end}) {
        const indexArray = this.createRangeArray(start, end)
        // 使用预先创建的防抖方法
        this.debouncedValidate(indexArray)
      },
    }
  }

</script>

效果预览

需求:el-upload实现上传/粘贴图片功能

2025年7月4日 17:15

效果:

image.png

需求:

  1. 使用el-upload支持用户上传图片,粘贴图片
  2. Proof这里是上传的证明材料;原先设计的是根据不同的type,支持用户传图片或者输入相关链接

代码:

  1. 页面
<div class="info-item" style="align-items: start">
                    <span class="label">Proof:</span>
                    <div class="two-col-container">
                      <div
                        class="item"
                        v-for="(
                          proofType, index
                        ) in approvalInformationObj.requiredProofTypes"
                        :key="proofType.id"
                      >
                        <div class="proof-item">
                          <div class="label" style="text-align: left;display: flex;">
                            <span v-if="proofType.required" style="color: red"
                              >*</span
                            >
                            <span
                              class="proof-label-text"
                              :title="proofType.value"
                              >{{ proofType.value }}:</span
                            >
                          </div>
                          <div class="upload-container">
                            <!-- 图片上传类型 (type: 0) -->
                            <template v-if="proofType.type === 0">
                              <div
                                :class="`upload-area ${
                                  currentFocusArea === index
                                    ? 'active-upload'
                                    : ''
                                }`"
                                @click="setActiveUploadArea(index)"
                                @mouseenter="showUploadHint(index, $event)"
                                @mouseleave="hideUploadHint()"
                              >
                                <el-upload
                                  :class="`image-uploader proof-uploader-${index}`"
                                  :action="uploadAction"
                                  :show-file-list="false"
                                  :on-success="(response: any, file: any) => handleProofSuccess(response, file, index)"
                                  :before-upload="beforeUpload"
                                  :http-request="(options: any) => customUploadProof(options, index)"
                                  :auto-upload="true"
                                  list-type="picture-card"
                                >

                                <el-icon title="Supports uploading files"><Plus /></el-icon>

                                  <!-- <img
                                    v-if="proofData[index]?.displayUrl"
                                    :src="proofData[index].displayUrl"
                                    class="upload-image"
                                  />
                                  <div v-else class="upload-placeholder">
                                    <i class="el-icon-plus"></i>
                                    <div>Upload pictures</div>
                                  </div> -->
                                </el-upload>
                                <!-- 当前活跃上传区域指示器 -->
                                <!-- <div
                                  v-if="currentFocusArea === index"
                                  class="active-indicator"
                                >
                                  <span>当前选中</span>
                                </div> -->
                              </div>

                              <!-- 显示已上传的多个图片 -->
                              <div
                                v-if="proofData[index]?.images?.length > 0"
                                class="uploaded-images"
                              >
                                <div
                                  v-for="(image, imageIndex) in proofData[index]
                                    .images"
                                  :key="imageIndex"
                                  class="image-item"
                                  :class="{
                                    active:
                                      image.url === proofData[index].displayUrl,
                                  }"
                                >
                                  <img
                                    :src="image.url"
                                    class="thumbnail"
                                    @click="
                                      changeDisplayImage(index, image.url)
                                    "
                                  />
                                  <div class="image-actions">
                                    <el-button
                                      type="primary"
                                      :icon="Search"
                                      circle
                                      @click.stop="previewImage(image.url)"
                                      title="preview"
                                    />
                                    <el-button
                                      type="danger"
                                      :icon="Delete"
                                      circle
                                      @click.stop="
                                        removeImage(index, imageIndex)
                                      "
                                      title="delete"
                                    />
                                  </div>
                                </div>
                              </div>
                            </template>

                            <!-- 链接输入类型 (type: 1) -->
                            <template v-else-if="proofType.type === 1">
                              <el-input
                                v-model="proofData[index].url"
                                type="textarea"
                                :rows="3"
                                :placeholder="`Please input ${proofType.value} URL or text content`"
                                style="margin-top: 10px"
                              />
                            </template>
                          </div>
                          <!-- 描述信息 -->
                          <!-- <div
                            v-if="proofType.description"
                            class="proof-description"
                          >
                            {{ proofType.description }}
                          </div> -->
                        </div>
                      </div>
                    </div>
                  </div>
  1. 方法,相关方法自行搜索吧,太多了,懒得拆分了,记录下
<script lang="ts" setup>

import { formatAmount } from "@/utils/format";
import {
  CommissionType,
  CommissionTypeEnum,
  COMMISSION_TYPE_MAP,
} from "@/types/performance";

import { removeFormulaPrefix } from "@/utils/format";


const pageId = ref(0);

interface PersonWorkload {
  id: number;
  name: string;
  role: string;
  proportionNum: string;
}

interface QuarterItem {
  name: string;
  value: {
    year: number;
    beginTime: string;
    endTime: string;
    quarter: number;
  };
}

// 表单数据
const commissionType = ref<CommissionType>("");
const formulaBase = ref(36);
const formulaMultiplier = ref(180);
const selectedCategory = ref();
const selectedQuarter = ref();

// Other类型相关变量
const selectedJournal = ref();

const selectedRole = ref();
const proportionNum = ref();

const commissionTypeEnumList = ref<CommissionTypeEnum[]>([]);
const categoryList = ref([]);

// 计算当前选中category的label值,用于title属性
const selectedCategoryLabel = computed(() => {
  if (!selectedCategory.value || !categoryList.value.length) {
    return "";
  }
  const selectedItem = categoryList.value.find(
    (item: any) => item.id === selectedCategory.value
  );
  return selectedItem ? selectedItem.value : "";
});

let personalInformationObj = reactive<any>({
  email: "",
  chineseName: "",
  role: "",
  journals: [],
  time: "",
  qcPassRate: "",
  paidConfirmed: "",
  pi: null,
  createdTime: "", 
  finalConfirmed: ''
});
interface ProofType {
  id: number;
  type: number;
  value: string;
  description: string;
  required: boolean;
}

let approvalInformationObj = reactive({
  performanceSettingId: "",
  commissionTypeId: "",
  jmRole: "",
  jmPaidConfirmed: "",
  jmPassRate: "",
  jmProportion: "",
  calcalationFormula: "",
  paperId: "",
  sectionId: "",
  remark: "",
  amount: "",
  siTitle: "",
  siPublished: null,
  siType: "",
  articleType: "",
  paymentAmount: "",
  requiredProofTypes: [] as ProofType[],
  deTotalCommission: null,
});

const showSave = ref(false);
const showSendToApprove = ref(false);

// 图片上传相关
const uploadAction = ref(""); // 实际使用时替换为后端上传接口

// 新的证明材料数据结构
const proofData = ref<{
  [key: number]: {
    displayUrl?: string;
    url?: string;
    note?: string;
    fileId?: number;
    images?: { url: string; name: string; fileId?: number }[];
  };
}>({});

// 压缩包上传相关数据
const uploadedFiles = ref<{
  name: string;
  size: number;
  fileId?: number;
  originalFile?: File;
}[]>([]);;

// 当前焦点区域
const currentFocusArea = ref<any>(0);
// 是否显示焦点提示
const showFocusHint = ref(false);
// 焦点提示的位置
const focusHintPosition = ref({ top: 0, left: 0 });

// 设置活跃上传区域
const setActiveUploadArea = (index: number) => {
  currentFocusArea.value = index;

  // 显示简短提示
  showFocusHint.value = true;
  setTimeout(() => {
    showFocusHint.value = false;
  }, 1500);
};

// 显示上传提示
const showUploadHint = (index: number, event: MouseEvent) => {
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
  focusHintPosition.value = {
    top: rect.top - 30,
    left: rect.left + rect.width / 2 - 100,
  };

  // 临时更新焦点区域用于显示提示文本
  const tempFocus = currentFocusArea.value;
  currentFocusArea.value = index;
  showFocusHint.value = true;

  // 恢复原来的焦点区域
  setTimeout(() => {
    if (currentFocusArea.value === index && !showFocusHint.value) {
      currentFocusArea.value = tempFocus;
    }
  }, 100);
};

// 隐藏上传提示
const hideUploadHint = () => {
  showFocusHint.value = false;
};

// 显示焦点提示
const showFocusIndicator = (e: MouseEvent, index: number) => {
  // 更新当前焦点区域
  currentFocusArea.value = index;

  // 计算提示位置
  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
  focusHintPosition.value = {
    top: rect.top - 25,
    left: rect.left + rect.width / 2 - 75,
  };

  // 显示提示
  showFocusHint.value = true;

  // 3秒后自动隐藏
  setTimeout(() => {
    showFocusHint.value = false;
  }, 2000);
};

// 更新事件监听器
const updateEventListeners = () => {
  // 移除旧的事件监听器,避免重复绑定
  document.querySelectorAll(".upload-container").forEach((container) => {
    container.removeEventListener("click", () => {});
  });

  // 添加新的事件监听器
  const invitationUploader = document.querySelector(".invitation-uploader");
  const scopusUploader = document.querySelector(".scopus-uploader");
  const referencesUploader = document.querySelector(".references-uploader");
  const identityUploader = document.querySelector(".identity-uploader");

  if (invitationUploader) {
    invitationUploader.addEventListener("click", (e) => {
      currentFocusArea.value = "invitation";
      showFocusIndicator(e as MouseEvent, "invitation");
    });
  }

  if (scopusUploader) {
    scopusUploader.addEventListener("click", (e) => {
      currentFocusArea.value = "scopus";
      showFocusIndicator(e as MouseEvent, "scopus");
    });
  }

  if (referencesUploader) {
    referencesUploader.addEventListener("click", (e) => {
      currentFocusArea.value = "references";
      showFocusIndicator(e as MouseEvent, "references");
    });
  }

  if (identityUploader) {
    identityUploader.addEventListener("click", (e) => {
      currentFocusArea.value = "identity";
      showFocusIndicator(e as MouseEvent, "identity");
    });
  }

  // 添加上传区域的容器监听
  document.querySelectorAll(".upload-container").forEach((container) => {
    // 判断这是哪个上传区域
    if (container.querySelector(".invitation-uploader")) {
      container.addEventListener("click", (e) => {
        if (e.target === container) {
          currentFocusArea.value = "invitation";
          showFocusIndicator(e as MouseEvent, "invitation");
          // 点击容器时,模拟点击上传组件
          const uploader = container.querySelector(".invitation-uploader");
          if (uploader && uploader instanceof HTMLElement) {
            uploader.click();
          }
        }
      });
    } else if (container.querySelector(".scopus-uploader")) {
      container.addEventListener("click", (e) => {
        if (e.target === container) {
          currentFocusArea.value = "scopus";
          showFocusIndicator(e as MouseEvent, "scopus");
          // 点击容器时,模拟点击上传组件
          const uploader = container.querySelector(".scopus-uploader");
          if (uploader && uploader instanceof HTMLElement) {
            uploader.click();
          }
        }
      });
    } else if (container.querySelector(".references-uploader")) {
      container.addEventListener("click", (e) => {
        if (e.target === container) {
          currentFocusArea.value = "references";
          showFocusIndicator(e as MouseEvent, "references");
          // 点击容器时,模拟点击上传组件
          const uploader = container.querySelector(".references-uploader");
          if (uploader && uploader instanceof HTMLElement) {
            uploader.click();
          }
        }
      });
    } else if (container.querySelector(".identity-uploader")) {
      container.addEventListener("click", (e) => {
        if (e.target === container) {
          currentFocusArea.value = "identity";
          showFocusIndicator(e as MouseEvent, "identity");
          // 点击容器时,模拟点击上传组件
          const uploader = container.querySelector(".identity-uploader");
          if (uploader && uploader instanceof HTMLElement) {
            uploader.click();
          }
        }
      });
    }
  });
};

const quarterList = ref<QuarterItem[]>([]);

// 获取当前选中季度的数据
const currentQuarterData = computed(() => {
  return quarterList.value?.find((item) => item.name === selectedQuarter.value);
});

const needTimeTypes = [
  CommissionType.DE_JOB_GRADE,
  CommissionType.AE_JOB_GRADE,
];

const beginTime = !needTimeTypes.includes(commissionType.value)
  ? currentQuarterData.value?.value.beginTime
  : null;
const endTime = !needTimeTypes.includes(commissionType.value)
  ? currentQuarterData.value?.value.endTime
  : null;



// 存储原始金额,用于按比例计算
const originalAmount = ref<string>("");

// 计算金额的函数
const calculateAmount = () => {
  if (proportionNum.value && originalAmount.value) {
    const proportion = parseFloat(proportionNum.value) / 100; // 将百分比转换为小数
    const original = parseFloat(originalAmount.value);
    if (!isNaN(proportion) && !isNaN(original)) {
      approvalInformationObj.amount = (original * proportion).toFixed(2);
    }
  } else if (!proportionNum.value && originalAmount.value) {
    // 如果比例为空,恢复原始金额
    approvalInformationObj.amount = originalAmount.value;
  }
};

// 监听proportionNum变化,重新计算amount
watch(proportionNum, () => {
  calculateAmount();
});

const canEditRequestedAmount = ref(false);
const performanceSettingLength = ref();

const sameApplications = ref(false)

// 查询申请信息,及基本信息qc pass rate, paid confirmed, pi等信息
const getApprovalDetailsData = (
  categoryId: number | string,
  beginTime?: string,
  endTime?: string,
  needCategoryList: boolean = true,
  uncheckedNumber: number = 0
) => {

  const needPaperId = [CommissionType.PAPER_EXTRA, CommissionType.OTHER].includes(commissionType.value);
  const needSectionId = [CommissionType.SI_EXTRA, CommissionType.OTHER].includes(commissionType.value);


  performanceSettingsByCommissionId({
    commissionId: commissionType.value,
    categoryId: categoryId || 0,
    journalId: selectedJournal.value,
    beginTime: beginTime || null,
    endTime: endTime || null,
    requestedId: needPaperId ? approvalInformationObj.paperId : null, // 文章id
    sectionId: needSectionId ? approvalInformationObj.sectionId : null,
    uncheckedNumber: uncheckedNumber || uncheckedIds.value.length
  }).then((res) => {
    if (res && res.code === 2000 && res.data) {
      // 获取category数据
      if (needCategoryList) {
        categoryList.value = res.data.performanceCategoryEnumList || [];
      }
      // 获取基本信息
      personalInformationObj.qcPassRate =
        res.data.calculateConfigValueMap?.qcPassRate || 0;
      personalInformationObj.paidConfirmed =
        res.data.calculateConfigValueMap?.paidConfirmedNumber || 0;

      personalInformationObj.finalConfirmed = personalInformationObj.paidConfirmed - uncheckedIds.value.length


      personalInformationObj.pi =
        res.data.calculateConfigValueMap?.piNumber || 0;

      // 获取申请信息
      approvalInformationObj.calcalationFormula =
        res.data.performanceSetting?.[0]?.amount || "";

      // 保存原始金额
      const requestedAmount =
        res.data.performanceSetting?.[0]?.requestedAmount ?? "";
      originalAmount.value = requestedAmount;
      approvalInformationObj.amount = requestedAmount;

      // 如果proportionNum已有值,重新计算金额
      setTimeout(() => {
        calculateAmount();
      }, 0);

      approvalInformationObj.performanceSettingId =
        res.data.performanceSetting?.[0]?.id || "";
      approvalInformationObj.commissionTypeId =
        res.data.performanceSetting?.[0]?.commissionTypeId || "";

     
      approvalInformationObj.articleType = res.data.article?.articleType;
      approvalInformationObj.paymentAmount = res.data.article?.apc;

      approvalInformationObj.requiredProofTypes =
        res.data.requiredProofTypes || [];
      approvalInformationObj.deTotalCommission =
        res.data.calculateConfigValueMap?.deTotalCommission || 0;

      canEditRequestedAmount.value = res.data.canEditRequestedAmount;
      performanceSettingLength.value = res.data.performanceSetting?.length;
      sameApplications.value = res.data.sameApplications?.length > 0

      // 初始化 证明材料数据
      if (
        res.data.requiredProofTypes &&
        res.data.requiredProofTypes.length > 0
      ) {
        res.data.requiredProofTypes.forEach((_, index: number) => {
          if (!proofData.value[index]) {
            proofData.value[index] = {
              images: [],
              url: "",
              displayUrl: "",
              note: "",
              fileId: undefined,
            };
          }
        });
      }
    }
  });
};

const journalOptions = ref<any[]>([]);
const roleOptions = ref<any[]>([]);

// 监听粘贴事件,实现粘贴上传图片
onMounted(() => {
  document.addEventListener("paste", handlePaste);

  // 初始化事件监听器
  setTimeout(updateEventListeners, 500);

  // 添加拖拽区域监听
  window.addEventListener("dragover", (e) => {
    // 阻止默认行为
    e.preventDefault();
  });

  window.addEventListener("drop", handleDrop);

 


  return () => {
    window.removeEventListener("dragover", (e) => e.preventDefault());
  };
});



// 处理拖拽事件
const handleDrop = (e: DragEvent) => {
  e.preventDefault();
  // 检查拖拽的是否为文件
  if (e.dataTransfer?.files.length) {
    // 根据拖拽位置判断目标区域
    const target = document.elementFromPoint(e.clientX, e.clientY);
    if (!target) return;

    // 查找最近的上传区域
    let proofIndex = -1;
    let container = null;

    // 尝试从点击元素向上查找上传容器
    let element = target as HTMLElement;
    while (element && proofIndex === -1) {
      if (element.classList?.contains("upload-container")) {
        container = element;
        // 查找proof-uploader的索引
        const uploader = element.querySelector("[class*='proof-uploader-']");
        if (uploader) {
          const classList = Array.from(uploader.classList);
          const uploaderClass = classList.find((cls) =>
            cls.startsWith("proof-uploader-")
          );
          if (uploaderClass) {
            proofIndex = parseInt(uploaderClass.split("-")[2]);
          }
        }
        break;
      }

      // 直接检查当前元素是否是上传器
      if (element.classList.value.includes("proof-uploader-")) {
        const classList = Array.from(element.classList);
        const uploaderClass = classList.find((cls) =>
          cls.startsWith("proof-uploader-")
        );
        if (uploaderClass) {
          proofIndex = parseInt(uploaderClass.split("-")[2]);
        }
        break;
      }

      element = element.parentElement as HTMLElement;
    }

    if (proofIndex !== -1) {
      // 更新当前焦点区域
      currentFocusArea.value = proofIndex;
      // 处理所有拖拽的文件
      Array.from(e.dataTransfer.files).forEach((file) => {
        if (file.type.startsWith("image/")) {
          handleImageFile(file, proofIndex);
        }
      });

      // 如果找到了容器,显示焦点提示
      if (container) {
        showFocusIndicator(e as unknown as MouseEvent, proofIndex);
      }
    }
  }
};

// 组件卸载时移除事件监听
onUnmounted(() => {
  document.removeEventListener("paste", handlePaste);
  window.removeEventListener("dragover", (e) => e.preventDefault());
  window.removeEventListener("drop", handleDrop);
});

// 处理粘贴事件
const handlePaste = (event: ClipboardEvent) => {
  const items = event.clipboardData?.items;
  if (!items) return;

  // 检查当前焦点元素,判断应该上传到哪个区域
  const activeElement = document.activeElement;
  let targetIndex = currentFocusArea.value;
  let foundArea = false;

  // 根据当前激活的元素判断上传区域
  if (activeElement) {
    // 尝试从当前元素向上查找上传容器
    let element = activeElement as HTMLElement;
    while (element && !foundArea) {
      if (element.classList?.contains("upload-container")) {
        // 查找proof-uploader的索引
        const uploader = element.querySelector("[class*='proof-uploader-']");
        if (uploader) {
          const classList = Array.from(uploader.classList);
          const uploaderClass = classList.find((cls) =>
            cls.startsWith("proof-uploader-")
          );
          if (uploaderClass) {
            targetIndex = parseInt(uploaderClass.split("-")[2]);
            foundArea = true;
          }
        }
        break;
      }

      // 直接检查当前元素是否是上传器
      if (element.classList.value.includes("proof-uploader-")) {
        const classList = Array.from(element.classList);
        const uploaderClass = classList.find((cls) =>
          cls.startsWith("proof-uploader-")
        );
        if (uploaderClass) {
          targetIndex = parseInt(uploaderClass.split("-")[2]);
          foundArea = true;
        }
        break;
      }

      element = element.parentElement as HTMLElement;
    }
  }

  let imageFound = false;

  // 处理所有粘贴的图片
  for (let i = 0; i < items.length; i++) {
    if (items[i].type.indexOf("image") !== -1) {
      const file = items[i].getAsFile();
      if (!file) continue;

      imageFound = true;
      // 使用确定的目标区域上传图片
      handleImageFile(file, targetIndex);
    }
  }

  // 只有找到图片时才阻止默认粘贴行为
  if (imageFound) {
    event.preventDefault();
    // 更新当前焦点区域,以便下次粘贴时使用
    currentFocusArea.value = targetIndex;
  }
};

// 上传前检查
const beforeUpload = (file: File) => {
  const isImage = file.type.startsWith("image/");
  const isLt5M = file.size / 1024 / 1024 < 5;

  if (!isImage) {
    ElMessage.error("Only image files can be uploaded!");
    return false;
  }
  if (!isLt5M) {
    ElMessage.error("The image size must not exceed 5MB.");
    return false;
  }
  return true;
};

// 处理图片文件
const handleImageFile = async (file: File, index: number) => {
  if (!beforeUpload(file)) return;

  // 初始化该索引的数据对象
  if (!proofData.value[index]) {
    proofData.value[index] = { images: [] };
  }
  if (!proofData.value[index].images) {
    proofData.value[index].images = [];
  }

  try {
    // 调用真实上传接口
    const uploadResponse = await uploadFileApiNew(file);

    if (uploadResponse.code === 2000 && uploadResponse.data) {
      const fileId = uploadResponse.data.uploadFileData.id || "";
      const imgUrl = URL.createObjectURL(file);
      const imageData = {
        url: imgUrl,
        name: file.name,
        fileId: fileId,
      };

      proofData.value[index].images!.push(imageData);
      proofData.value[index].displayUrl = imgUrl;
      proofData.value[index].fileId = fileId; // 记录当前显示图片的文件ID

      ElMessage.success("mage uploaded successfully.");
    } else {
      ElMessage.error("Image upload failed.");
    }
  } catch (error) {
    console.error("Upload error:", error);
    ElMessage.error("Image upload failed.");
  }
};

// 自定义上传函数
const customUploadProof = (options: any, index: number) => {
  handleImageFile(options.file, index);
};

// 上传成功回调
const handleProofSuccess = async (response: any, file: any, index: number) => {
  // 初始化该索引的数据对象
  if (!proofData.value[index]) {
    proofData.value[index] = { images: [] };
  }
  if (!proofData.value[index].images) {
    proofData.value[index].images = [];
  }

  try {
    // 调用真实上传接口
    const uploadResponse = await uploadFileApiNew(file.raw);

    if (uploadResponse.code === 2000 && uploadResponse.data) {
      const fileId = uploadResponse.data.id;
      const url = URL.createObjectURL(file.raw);

      proofData.value[index].images!.push({
        url,
        name: file.name,
        fileId: fileId,
      });
      proofData.value[index].displayUrl = url;
      proofData.value[index].fileId = fileId; // 记录当前显示图片的文件ID

      ElMessage.success("Image uploaded successfully.");
    } else {
      ElMessage.error("Image upload failed.");
    }
  } catch (error) {
    console.error("Upload error:", error);
    ElMessage.error("Image upload failed.");
  }
};

// 移除图片
const removeImage = (proofIndex: number, imageIndex: number) => {
  if (proofData.value[proofIndex]?.images) {
    proofData.value[proofIndex].images!.splice(imageIndex, 1);

    if (proofData.value[proofIndex].images!.length === 0) {
      proofData.value[proofIndex].displayUrl = "";
    } else {
      proofData.value[proofIndex].displayUrl =
        proofData.value[proofIndex].images![0].url;
    }
  }
};

// 预览图片
const previewImage = (url: string) => {
  // 使用 el-dialog 预览图片
  previewImageUrl.value = url;
  imagePreviewVisible.value = true;
};

// 切换展示图片
const changeDisplayImage = (proofIndex: number, url: string) => {
  if (proofData.value[proofIndex]) {
    proofData.value[proofIndex].displayUrl = url;

    // 找到对应图片的文件ID并更新
    const selectedImage = proofData.value[proofIndex].images?.find(
      (img) => img.url === url
    );
    if (selectedImage?.fileId) {
      proofData.value[proofIndex].fileId = selectedImage.fileId;
    }
  }
};

// 验证证明材料
const validateProofMaterials = () => {
  for (let i = 0; i < approvalInformationObj.requiredProofTypes.length; i++) {
    const proofType = approvalInformationObj.requiredProofTypes[i];

    if (proofType.required) {
      if (proofType.type === 0) {
        // 图片上传类型检查
        if (!proofData.value[i]?.images?.length) {
          ElMessage.warning(`Please upload the supporting document for ${proofType.value}`);
          return false;
        }
      } else if (proofType.type === 1) {
        // 链接输入类型检查
        if (!proofData.value[i]?.url?.trim()) {
          ElMessage.warning(`Please enter the content for ${proofType.value}.`);
          return false;
        }
      }
    }
  }
  return true;
};

const applicationId = ref();

// 存储未勾选记录的ID列表
const uncheckedIds = ref<string[]>([]);

// 处理未勾选ID列表更新
const handleUncheckedIdsUpdate = (ids: string[]) => {
  uncheckedIds.value = ids;
};

// 处理弹窗关闭事件
const handleDialogClosed = (uncheckedNumber: number) => {
  // 弹窗关闭时调用接口,传递uncheckedNumber参数
  if (currentQuarterData.value) {
    getApprovalDetailsData(
      selectedCategory.value || 0,
      currentQuarterData.value.value.beginTime,
      currentQuarterData.value.value.endTime,
      false, // needCategoryList
      uncheckedNumber // 传递未勾选数量
    );
  }
};

const handleSendToApproval = async () => {
  // 验证证明材料
  if (!validateProofMaterials()) {
    return;
  }

};



const handleSave = () => {
  if (!commissionType.value) {
    return ElMessage.warning("Commission Type cannot be empty.");
  }

  // 验证Time必填(当Time字段显示时)
  const needTimeTypes = [
    CommissionType.DE_JOB_GRADE,
    CommissionType.AE_JOB_GRADE,
  ];
  if (!needTimeTypes.includes(commissionType.value) && !selectedQuarter.value) {
    return ElMessage.warning("Time cannot be empty.");
  }

  const needCategoryTypes = [
    CommissionType.PAPER_EXTRA,
    CommissionType.SI_EXTRA,
    CommissionType.OTHER,
    CommissionType.DE_JOB_GRADE,
    CommissionType.AE_JOB_GRADE,
  ];
  if (
    needCategoryTypes.includes(commissionType.value) &&
    !selectedCategory.value
  ) {
    return ElMessage.warning("Category cannot be empty.");
  }

  // 验证paper id
  if (
    [CommissionType.PAPER_EXTRA].includes(commissionType.value) &&
    !approvalInformationObj.paperId
  ) {
    return ElMessage.warning("Paper ID cannot be empty.");
  }

  // 验证SI ID必填(当SI ID字段显示时)
  const needSectionIdTypes = [CommissionType.SI_EXTRA];
  if (
    needSectionIdTypes.includes(commissionType.value) &&
    !approvalInformationObj.sectionId?.trim()
  ) {
    return ElMessage.warning("SI ID cannot be empty.");
  }

  // 验证Remark必填
  if (!approvalInformationObj.remark?.trim()) {
    return ElMessage.warning("Remark cannot be empty.");
  }

   // 验证证明材料
   if (!validateProofMaterials()) {
    return;
  }

  if(sameApplications.value && !approvalInformationObj.paperId){
    return ElMessage.warning("Please note that this ID has already been submitted. ");
  }
  if(sameApplications.value && !approvalInformationObj.sectionId){
    return ElMessage.warning("Please note that this ID has already been submitted. ");
  }
  

  // 构建证明材料数据 - 按照后端要求的格式
  const proofRelations = approvalInformationObj.requiredProofTypes
    .map((proofType, index) => {
      const data = proofData.value[index];

      if (proofType.type === 0) {
        // 图片类型:如果有多张图片,每张图片作为一条数据
        if (data?.images?.length > 0) {
          return data.images.map((image) => ({
            fileId: image.fileId || null,
            note: "",
            fileType: proofType.id,
          }));
        } else {
          // 没有图片时返回一条空数据
          // return [
          //   {
          //     fileId: null,
          //     note: "",
          //     fileType: proofType.id,
          //   },
          // ];
        }
      } else if (proofType.type === 1) {
        // 文本输入类型:只返回用户输入的文本,fileId为空
        return [
          {
            fileId: null,
            note: data?.url || "",
            fileType: proofType.id,
          },
        ];
      }

      return null;
    })
    .filter((item) => item !== null) // 过滤掉空值
    .flat(); // 将嵌套数组展平

  // 添加文件上传数据 (当Commission Type为Other且Category为26时)
  const fileUploadRelations: any = [];
  if (commissionType.value === CommissionType.OTHER && selectedCategory.value === 26 && uploadedFiles.value.length > 0) {
   
    uploadedFiles.value.forEach((file) => {
      const fileRelation = {
        fileId: file.fileId || null,
        note: "",
        fileType: -1, // 文件类型固定传-1
      };
      fileUploadRelations.push(fileRelation);
    });
  }

  // 合并证明材料和文件上传数据
  const allProofRelations = [...proofRelations, ...fileUploadRelations];

  const needPaperId = [CommissionType.PAPER_EXTRA, CommissionType.OTHER].includes(commissionType.value);
  const needSectionId = [CommissionType.SI_EXTRA, CommissionType.OTHER].includes(commissionType.value);


  ElMessageBox.confirm("Confirm the approval of this application?", "Tips", {
    confirmButtonText: "Confirm",
    cancelButtonText: "Cancel",
    type: "success",
  })
    .then(() => {
      performanceAppCreate(params).then((res: any) => {
        if (res.code === 2000) {
          ElMessage({
            type: "success",
            message: res.message,
          });

          applicationId.value = res.data.application?.id;

          let routeData = router.resolve({
            path: needTimeTypes.includes(commissionType.value)
              ? "/performanceManagement/jobGradeApprovals"
              : "/performanceManagement/approvalRequestList",
            query: {
              approverEmail: personalInformationObj.email,
            },
          });
          window.open(routeData.href, "_blank");
        }
      });
    })
    .catch(() => {});
};



const rulesDialogVisible = ref(false);
const performanceSettingData = ref();

// 图片预览相关
const imagePreviewVisible = ref(false);
const previewImageUrl = ref("");

// 添加显示Matching Rules详情的方法
const showMatchingRulesDetails = () => {
  tableDialogTitle.value = "Matching Rules Details";
  rulesDialogVisible.value = true;

  performanceSettingsRuleId({
    id: approvalInformationObj.performanceSettingId,
  }).then((res) => {
    if (res.code === 2000) {
      performanceSettingData.value = res.data.performanceSetting;
    }
  });
};




const cancelModal = () => {
  rulesDialogVisible.value = false
}

// 文件上传相关方法
const beforeFileUpload = (file: File) => {
  const validExtensions = [
    '.zip', '.rar', '.7z', '.tar', '.gz',  // 压缩包
    '.pdf',                                // PDF文档
    '.doc', '.docx',                       // Word文档
    '.xls', '.xlsx',                       // Excel文档
    '.ppt', '.pptx',                       // PowerPoint文档
    '.txt', '.csv',                        // 文本文件
    '.json', '.xml'                        // 数据文件
  ];
  const fileName = file.name.toLowerCase();
  const isValidType = validExtensions.some(ext => fileName.endsWith(ext));
  const isLt50M = file.size / 1024 / 1024 < 50;

  if (!isValidType) {
    ElMessage.error("Supported file types: .zip, .rar, .7z, .tar, .gz, .pdf, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .txt, .csv, .json, .xml");
    return false;
  }
  if (!isLt50M) {
    ElMessage.error("The file size must not exceed 50MB.");
    return false;
  }
  return true;
};

// 自定义文件上传函数
const customFileUpload = async (options: any) => {
  const file = options.file;
  
  if (!beforeFileUpload(file)) return;
  
  try {
    const uploadResponse = await uploadFileApiNew(file);
    
    if (uploadResponse.data) {
      // 根据API响应结构获取正确的fileId
      const fileId = uploadResponse.data.uploadFileData?.id || uploadResponse.data.id;
      
      const fileData = {
        name: file.name,
        size: file.size,
        fileId: fileId,
        originalFile: file
      };
      
      uploadedFiles.value.push(fileData);
      console.log("文件上传成功,fileId:", fileId, "文件数据:", fileData);
      ElMessage.success("File uploaded successfully.");
    } else {
      ElMessage.error("File upload failed.");
    }
  } catch (error) {
    console.error("Upload error:", error);
    ElMessage.error("File upload failed.");
  }
};

// 处理文件上传成功
const handleFileUploadSuccess = (response: any, file: any) => {
  // 这个方法在使用 http-request 时不会被调用,但保留以防需要
};

// 移除已上传的文件
const removeUploadedFile = (index: number) => {
  uploadedFiles.value.splice(index, 1);
  ElMessage.success("File removed successfully.");
};

// 格式化文件大小
const formatFileSize = (bytes: number): string => {
  if (bytes === 0) return '0 B';
  const k = 1024;
  const sizes = ['B', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
</script>
  1. 测试数据
 "requiredProofTypes": [
            {
                "description": "222222",
                "id": 3,
                "type": 0,
                "value": "222222",
                "required": true  // 判断是否必填
            },
            {
                "description": "链接",
                "id": 4,
                "type": 1,
                "value": "Link",
                "required": true
            },
            {
                "description": "hhhhh",
                "id": 5,
                "type": 0,
                "value": "hhhhh",
                "required": true
            }
        ],

大佬们指点一下倒计时有什么问题吗?

2025年7月4日 17:10

看了另外一张帖子,关于倒计时组件的,就自己写了一个,大家帮忙看下,写得怎么样?有什么问题,可以指出来!

<div class="time"></div>
function countdown(time) {
    let lastTime = Date.now();
    let rafId;
    function refresh() {
        const curTime = Date.now();
        const more = curTime - lastTime - 1000;
        if (more > 0) {
            lastTime = curTime + more;
            const nextTime = time--;
            if (nextTime === 0) {
                cancelAnimationFrame(rafId);
                return;
            }
            update(time);
        }
        rafId = requestAnimationFrame(refresh);
    }

    function update(restTime) {
        const h = restTime > 3600 ? Math.floor(restTime / 60) : 0;
        const m = Math.floor((restTime - h * 3600) / 60);
        const s = restTime - h * 3600 - m * 60;
        const dom = document.querySelector('.time');
        dom.innerHTML = `剩余${h}小时${m}分钟${s}秒`;
    }
    refresh();
    update(time);
}
countdown(1000);

前端 Service Worker最佳实践(下):基于IndexedDB的POST请求缓存方案

作者 米花丶
2025年7月4日 17:10

背景

在部分业务场景下,为了安全性、参数复杂度等原因,团队会选择将所有 API 请求统一为 POST 方法。但浏览器和 Service Worker 的标准缓存机制只对 GET 请求生效,POST 请求无法直接缓存。本文介绍一种基于 Service Worker + IndexedDB 的 POST API 缓存方案,适用于幂等、数据变化不频繁的接口,用于提升接口响应速度和离线体验。


一、缓存策略设计与实现

1.1 缓存策略的分析

对于API的缓存可以参考文件的缓存策略:

  • Cache First(缓存优先)策略:

    • 优先返回本地缓存,如果没有缓存再请求网络,并将网络响应缓存起来
    • Cache First策略在API接口中的适用场景极少,除非你能100%确定该接口数据极少变动且对时效性要求不高。大部分业务API并不适合Cache First
  • Stale-While-Revalidate(SWR)策略:

    • 命中缓存时:立即返回缓存数据,保证响应速度,同时:后台异步发起网络请求,获取最新数据并更新缓存

    • SWR适合对时效性有一定要求,但又希望响应速度快的场景,常见于:

      • 幂等的、变化频率不高的的API
      • 用户体验优先、允许短暂数据延迟的场景
  • Network First(网络优先)策略

    • 优先请求网络,只有在网络不可用时才返回缓存
    • 对数据实时性要求较高、需要保证获取最新数据,同时希望在离线或网络异常时仍能回退到缓存的API接口场景

通过以上分析,我们只需要把API划分为两类策略:Network First 与 SWR,其中Network First策略的主要目的是离线访问,SWR策略的主要目的是加快接口响应速度

2 策略配置与分流实现

设计思路:

  • 通过白名单配置每个API的缓存策略和时效。
  • Service Worker拦截POST请求,根据配置分流到不同的缓存处理逻辑。

配置示例:

const POST_API_CACHE_STRATEGY = {
  '/api/xxx/yyy': { strategy: 'NetworkFirst' },
  '/api/xxx/zzz': { strategy: 'StaleWhileRevalidate', maxAgeSeconds: 604800 },
};

分流流程图:

graph TD
    A[拦截POST请求] --> B{是否在白名单}
    B -- 否 --> C[直接请求网络]
    B -- 是 --> D[查找策略]
    D --> E{NetworkFirst or SWR}
    E -- NetworkFirst --> F1[走NetworkFirst缓存逻辑]
    E -- SWR --> F2[走SWR缓存逻辑]

代码实现要点:

  • fetch事件中,根据API配置选择不同的缓存处理分支。
  • NetworkFirst分支:先请求网络,失败时查缓存。
  • SWR分支:先查缓存立即返回,后台异步请求网络并更新缓存。 关键代码(已脱敏,精简版):
self.addEventListener('fetch', (event) => {
  const req = event.request;
  const url = new URL(req.url);

  if (req.method === 'POST' && POST_API_LIST.includes(url.pathname)) {
    const config = POST_API_CACHE_STRATEGY[url.pathname];
    const strategy = config.strategy || 'NetworkFirst';

    event.respondWith(
      (async () => {
        const cacheKey = await generateCacheKey(req);

        if (strategy === 'StaleWhileRevalidate') {
          const cached = await getCachedResponse(cacheKey, strategy);
          fetch(req.clone()).then(res => {
            if (res.ok) saveResponse(cacheKey, res.clone(), strategy);
          });
          return cached || fetch(req);
        } else {
          try {
            const res = await fetch(req.clone());
            if (res.ok) saveResponse(cacheKey, res.clone(), strategy);
            return res;
          } catch {
            return await getCachedResponse(cacheKey, strategy) || new Response('Offline', { status: 503 });
          }
        }
      })()
    );
  }
});

二、缓存空间的分离与管理

2.1 为什么要分开存储空间?

  • 不同策略的缓存数据生命周期和清理方式不同,如 SWR 允许短暂过期,NetworkFirst 只在离线兜底。
  • 便于分别设置最大缓存条数、过期策略,防止互相影响。
  • 管理和调试更清晰,便于后续扩展。

2.2 如何分开?

设计:

  • IndexedDB 中为每种策略建立独立的 objectStore(如 NetworkFirstResponses、StaleWhileRevalidateResponses)。
  • fetch 事件处理时,根据策略类型选择对应的存储空间。 关键代码讲解:
const DB_NAME = 'CampPostCacheDB';
const STORE_NETWORK_FIRST = 'NetworkFirstResponses';
const STORE_SWR = 'StaleWhileRevalidateResponses';

// 打开数据库时分别创建两个objectStore
const openDB = () => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 3);
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains(STORE_NETWORK_FIRST)) {
        db.createObjectStore(STORE_NETWORK_FIRST, { keyPath: 'key' });
      }
      if (!db.objectStoreNames.contains(STORE_SWR)) {
        db.createObjectStore(STORE_SWR, { keyPath: 'key' });
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = (e) => reject(e.target.error);
  });
};

三、唯一请求标识(Cache Key)设计

3.1 为什么需要唯一Key?

  • POST请求的响应不仅与URL有关,还与请求体、请求头密切相关。
  • 只有将这些内容都纳入Key,才能避免缓存串用。

3.2 设计思路与关键代码

  • 取URL路径、请求体、请求头,拼接后做哈希,生成唯一Key。
  • 这样即使同一个API,不同参数、不同用户的请求也不会串用缓存。

关键代码

const generateCacheKey = async (request) => {
  const url = new URL(request.url);
  const body = await request.clone().text();
  // 收集并排序请求头
  const headersObj = {};
  for (const [key, value] of request.headers.entries()) {
    headersObj[key] = value;
  }
  const sortedHeaderKeys = Object.keys(headersObj).sort();
  const headersStr = sortedHeaderKeys.map((k) => `${k}:${headersObj[k]}`).join('|');
  const keySource = body + '|' + headersStr;
  // 生成hash
  const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(keySource));
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
  return `POST|${url.pathname}|${hashHex}`;
};

四、缓存时效与清理机制

4.1 时效配置

  • 每个接口可单独配置缓存时效(maxAgeSeconds),未配置时用默认值(如7天)。

4.2 时效实现

  • 保存缓存时,记录过期时间(expires)。
  • 读取缓存时,判断是否过期,过期则删除。

关键代码

// 保存缓存时
const saveResponse = async (key, response, strategyType) => {
  const db = await openDB();
  const storeName = strategyType === 'NetworkFirst' ? STORE_NETWORK_FIRST : STORE_SWR;
  const apiConfig = POST_API_CACHE_STRATEGY[new URL(response.url).pathname];
  const maxAgeSeconds = apiConfig?.maxAgeSeconds || 7 * 24 * 60 * 60;
  const expires = Date.now() + maxAgeSeconds * 1000;
  const tx = db.transaction(storeName, 'readwrite');
  const store = tx.objectStore(storeName);
  store.put({
    key,
    data: {
      body: await response.clone().text(),
      status: response.status,
      headers: Array.from(response.headers.entries()),
    },
    expires,
  });
};

// 读取缓存时
const getCachedResponse = async (key, strategyType) => {
  const db = await openDB();
  const storeName = strategyType === 'NetworkFirst' ? STORE_NETWORK_FIRST : STORE_SWR;
  const tx = db.transaction(storeName, 'readwrite');
  const store = tx.objectStore(storeName);
  const request = store.get(key);
  return new Promise((resolve) => {
    request.onsuccess = () => {
      const result = request.result;
      if (!result) return resolve(null);
      if (Date.now() > result.expires) {
        store.delete(key);
        return resolve(null);
      }
      resolve(new Response(result.data.body, {
        status: result.data.status,
        headers: new Headers(result.data.headers),
      }));
    };
  });
};

总结

  • 通过 Service Worker + IndexedDB,可以为幂等的 POST 接口实现灵活的缓存策略。
  • 设计唯一请求标识,避免缓存串用。
  • 按策略分开存储空间,便于管理和清理。
  • 支持缓存时效配置,保证数据新鲜度和安全性。

深入解析WeUI Uploader组件源码:移动端上传组件实现

2025年7月4日 17:04

最近在研究WeUI的源码,发现Uploader组件的设计真的很有意思。我想分享一下对这个组件的深度解析。

为什么要学习WeUI Uploader?

说实话,uploader组件在大厂面试中出现频率真的很高。不仅仅是因为它功能重要,更是因为它背后蕴含的设计思想和编码技巧。通过研究高质量的源码,我们能学到:

  • 语义化标签的正确使用
  • BEM命名规范的实际应用
  • 弹性布局的巧妙运用
  • CSS预处理器的模块化思维
  • 移动端适配的细节处理

整体架构分析

让我们先看看HTML结构,这里的设计很有讲究:

<div class="weui-cells weui-cells_form">
  <div class="weui-cell weui-cell_uploader">
    <div class="weui-cell__bd">
      <div class="weui-uploader">
        <div class="weui-uploader__hd">
          <p class="weui-uploader__title">图片上传</p>
          <div class="weui-uploader__info">
            <span>0</span> / <span>2</span>
          </div>
        </div>
        <div class="weui-uploader__bd">
          <ul class="weui-uploader__files">
            <!-- 文件列表 -->
          </ul>
        </div>
      </div>
    </div>
  </div>
</div>

这个结构乍看复杂,但仔细分析会发现设计得很巧妙:

  • 最外层 .weui-cells 提供了统一的表单容器
  • 中间层 .weui-cell 作为单个表单项的包装
  • 内层 .weui-uploader 才是真正的上传组件

这种层级设计的好处是什么?复用性和一致性.weui-cells 不仅用于uploader,还能用于其他表单元素,保持了整个UI体系的一致性。

移动端适配的精妙细节

在CSS实现中,有几个细节特别值得关注:

1. 滚动优化

.page {
  overflow: scroll;
  -webkit-overflow-scrolling: touch;
}

这里的 -webkit-overflow-scrolling: touch 是个神奇的属性。它能让移动端的滚动更加顺滑,感知touch事件更敏感。虽然是webkit前缀的实验性属性,但在移动端(iOS和Android都基于webkit内核)使用效果很好。

2. 弹性布局的应用

.weui-uploader__hd {
  display: flex;
  align-items: center;
}
.weui-uploader__title {
  flex: 1;
}

这里用flex布局来处理标题和计数器的对齐,flex: 1 让标题占据剩余空间,计数器自然靠右对齐。简洁而优雅。

3. 伪元素的巧妙运用

.weui-cells::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  height: 1px;
  background-color: #86e98c;
  z-index: 2;
}

用伪元素画分割线,这比直接用border要灵活得多,可以精确控制线条的位置和样式。

Stylus预处理器的威力

项目中使用了Stylus作为CSS预处理器,这里有几个值得学习的技巧:

变量系统

$weui-bg-0 = #6ad8eb
$weui-bg-2 = #fff
$weui-fg-1 = #c177e6 
$weui-fg-2 = #888
$weui-fg-3 = #86e98c

这套变量命名很有规律:bg表示背景色,fg表示前景色,数字表示层级。这种命名方式让主题切换变得非常简单。

嵌套语法的优势

.weui-uploader
  .weui-uploader__hd
    display flex
    .weui-uploader__title
      flex 1
    .weui-uploader__info
      color $weui-fg-2

Stylus的嵌套语法让CSS结构更清晰,配合BEM命名规范,代码可读性大大提升。

float布局的考量

在文件列表的实现中,使用了float布局:

.weui-uploader__file {
  float: left;
  margin-right: 8px;
  margin-bottom: 8px;
  width: 96px;
  height: 96px;
}

可能有人会问:都2024年了,为什么不用grid或flex?

其实这里有几个考虑:

  1. 兼容性:float布局兼容性最好,特别是在一些老旧的webview中
  2. 性能:对于简单的网格布局,float的性能开销更小
  3. 灵活性:float布局在处理不等高元素时更加灵活

BEM命名规范的实践

整个组件严格遵循BEM命名规范:

  • Block(块).weui-uploader
  • Element(元素).weui-uploader__hd.weui-uploader__bd
  • Modifier(修饰符).weui-cell_uploader

这种命名方式的好处是:

  1. 语义明确,一眼就能看出元素的作用
  2. 避免样式冲突
  3. 便于维护和扩展

实际开发中的应用

在实际项目中,我们可以基于这个设计思路来优化自己的组件:

// 编译命令
// stylus -w common.styl -o common.css

这个编译命令告诉我们,项目采用了watch模式,修改styl文件后自动编译成CSS。这种开发方式大大提高了效率。

成果展示(换了颜色)

image.png

总结

通过分析WeUI Uploader组件的源码,我们学到了很多有价值的技巧:

  1. 分层设计思维:通过合理的层级结构提高复用性
  2. 移动端优化:关注细节,提升用户体验
  3. CSS预处理器:变量系统和嵌套语法的合理运用
  4. 命名规范:BEM规范让代码更易维护
  5. 技术选型:根据实际需求选择合适的布局方案

这些技巧不仅适用于组件开发,在日常的前端开发中都很有参考价值。希望这篇分析能帮助大家更好地理解移动端UI组件的设计思路。

最后想说的是,学习源码不是为了炫技,而是为了提升我们的代码质量和设计思维。每一个看似简单的组件背后,都蕴含着开发者的智慧和经验。

✅ Lodash 常用函数精选(按用途分类)

作者 浩龙不eMo
2025年7月4日 17:00

🧩 一、数组处理

函数 功能 示例
_.chunk 将数组拆分成指定大小的块 _.chunk([1,2,3,4], 2) → [[1,2],[3,4]]
_.uniq 去重 _.uniq([1,2,2,3]) → [1,2,3]
_.flatten / _.flattenDeep 扁平化数组 _.flatten([1,[2,[3]]]) → [1,2,[3]]
_.intersection 求交集 _.intersection([1,2],[2,3]) → [2]
_.difference 求差集 _.difference([1,2,3],[2]) → [1,3]
_.zip 多数组按索引组合 _.zip(['a','b'],[1,2]) → [['a',1],['b',2]]

🧱 二、对象操作

函数 功能 示例
_.get 安全取值(避免深层访问报错) _.get(obj, 'a.b.c', '默认')
_.set 安全赋值 _.set(obj, 'a.b.c', 123)
_.merge 深度合并对象 _.merge(obj1, obj2)
_.omit / _.pick 删除/保留指定属性 _.omit(user, ['password'])
_.cloneDeep 深拷贝 _.cloneDeep(obj)

🔧 三、函数工具

函数 功能 示例
_.debounce 防抖 输入框防止频繁触发请求
_.throttle 节流 滚动监听时控制触发频率
_.once 只执行一次 初始化操作
_.memoize 缓存函数调用结果 重复调用节省性能

💡Vue 和 React 中常与防抖节流配合使用!


📊 四、集合处理(对象数组常用)

函数 功能 示例
_.groupBy 按字段分组 _.groupBy(users, 'age')
_.orderBy 排序 _.orderBy(users, ['age'], ['desc'])
_.filter 条件过滤(增强版) _.filter(users, {active: true})
_.map 转换集合 _.map(users, 'name')
_.find / _.findLast 查找符合条件的第一项 _.find(users, {id: 1})

🧠 五、类型判断

函数 功能 示例
_.isArray 判断是否数组 _.isArray([])
_.isEmpty 判断是否为空 _.isEmpty({}) → true
_.isEqual 判断值是否深度相等 _.isEqual(obj1, obj2)
_.isPlainObject 是否普通对象 _.isPlainObject({})

🧪 六、字符串处理

函数 功能 示例
_.camelCase 转驼峰 _.camelCase('hello world') → 'helloWorld'
_.kebabCase 转中划线格式 _.kebabCase('Hello World') → 'hello-world'
_.capitalize 首字母大写 _.capitalize('hello') → 'Hello'
_.trim 去除两端空格 _.trim(' abc ') → 'abc'

📌 推荐使用组合

举个业务中常见的例子:

ts
复制编辑
// 多维对象中取值 + 判断是否为空
if (!_.isEmpty(_.get(userInfo, 'profile.name'))) {
  console.log('用户已填写名称');
}

🧰 快速参考图(Mermaid)

mermaid
复制编辑
graph LR
A[Lodash核心函数] --> B[数组类]
A --> C[对象类]
A --> D[函数类]
A --> E[集合类]
A --> F[类型判断]
A --> G[字符串类]

📚 推荐练习项目

适用水平 项目
初级 在 Vue/React 中封装表格查询功能,使用 _.get/_.merge
中级 实现“标签去重+分组显示”功能
高级 封装数据格式转换器(深层字段映射、字段重命名)

Monaco Editor实现diff对比差异,vue3

2025年7月4日 16:58

背景

Monaco Editor 实现差异对比工具,理由是他是微软开源的,功能强大,并且他是为 VS Code 提供动力的代码编辑器。

image.png

演练场:Monaco Editor Playground

代码封装

注意点

  • worker需要引入,否则会有个toUrl的警告
  • 高度需要默认指定,所以最好设置alwaysConsumeMouseWheel: true,否则浏览器默认滚动行为会被禁止,比如说,你在这个编辑器滚动底部时,不会触发父容器滚动
  • 页面卸载时,需要释放编辑器实例
  • watch监听到数据变化时,需要重新setModel,并且还需要释放实例
<script setup lang="ts">
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'

// 设置 Monaco worker
self.MonacoEnvironment = {
  getWorker(_: string, label: string) {
    switch (label) {
      case 'json':
        return new jsonWorker()
      case 'css':
      case 'scss':
      case 'less':
        return new cssWorker()
      case 'html':
      case 'handlebars':
      case 'razor':
        return new htmlWorker()
      case 'typescript':
      case 'javascript':
        return new tsWorker()
      default:
        return new editorWorker()
    }
  },
}

interface Props {
  oldData: unknown
  newData: unknown
  language?: string
  height?: string
  theme?: string
}

const props = withDefaults(defineProps<Props>(), {
  language: 'json',
  height: '300px',
  theme: 'vs', // vs vs-dark hc-black
})

interface EditorOptions extends monaco.editor.IDiffEditorConstructionOptions {
  theme: string
}

const editorOptions: EditorOptions = {
  theme: props.theme,
  automaticLayout: true, // 自动布局
  readOnly: true, // 只读模式
  renderSideBySide: true, // false: 内联对比
  scrollBeyondLastLine: false, // 不允许滚动到最后一行之后
  hideUnchangedRegions: {
    enabled: true, // 折叠
    revealLineCount: 40,
    minimumLineCount: 1,
    contextLineCount: 1,
  },
  scrollbar: {
    alwaysConsumeMouseWheel: false, // 允许鼠标滚轮滚动
  },
}

const diffEditorContainer = ref<HTMLElement | null>(null)
let diffEditorInstance: monaco.editor.IStandaloneDiffEditor | null = null

const oldDataStr = computed(() => simpleStringify(props.oldData))
const newDataStr = computed(() => simpleStringify(props.newData))

function createModels(original: string, modified: string, language: string) {
  const originalModel = monaco.editor.createModel(original, language)
  const modifiedModel = monaco.editor.createModel(modified, language)
  return { originalModel, modifiedModel }
}

function simpleStringify(value: unknown): string {
  if (value === null || value === undefined) return ''

  const valueType = typeof value
  if (['number', 'boolean', 'string'].includes(valueType)) return String(value)

  // JSON.stringify 会跳过函数和 symbol;若存在循环引用则会抛错。
  // 这里不考虑循环引用情况,因为json配置不会有这种情况

  try {
    return JSON.stringify(value, null, 2)
  } catch {
    return typeof value === 'object' ? '[Object parse error]' : String(value)
  }
}

watch([oldDataStr, newDataStr], ([newOriginal, newModified]) => {
  if (!diffEditorInstance) return

  const currentModel = diffEditorInstance.getModel()
  if (
    currentModel?.original.getValue() === newOriginal &&
    currentModel.modified.getValue() === newModified
  ) {
    return
  }

  currentModel?.original?.dispose()
  currentModel?.modified?.dispose()

  const { originalModel, modifiedModel } = createModels(newOriginal, newModified, props.language)
  diffEditorInstance.setModel({ original: originalModel, modified: modifiedModel })
})

onMounted(() => {
  if (!diffEditorContainer.value) return

  diffEditorInstance = monaco.editor.createDiffEditor(diffEditorContainer.value, editorOptions)

  const { originalModel, modifiedModel } = createModels(
    oldDataStr.value,
    newDataStr.value,
    props.language
  )
  diffEditorInstance.setModel({ original: originalModel, modified: modifiedModel })
})

onBeforeUnmount(() => {
  if (diffEditorInstance) {
    const model = diffEditorInstance.getModel()
    model?.original?.dispose()
    model?.modified?.dispose()
    diffEditorInstance.dispose()
    diffEditorInstance = null
  }
})
</script>

<template>
  <div ref="diffEditorContainer" class="monaco-diff-editor" :style="{ height: height }" />
</template>

<style lang="scss" scoped>
.monaco-diff-editor {
  width: 100%;
}
</style>

具体使用

<MonacoDiff old-data="111" new-Data="222" />

写100个前端效率工具(3):国际化 js 库 yyq-i18n

2025年7月4日 16:57

我们开发前端产品时,有时候会涉及到适应不同国家或者地区,这时候,我们需要根据不同国家或者地区的语言进行国际化处理。

yyq-i18n 是一个很mini的国际化库,可以帮助我们快速实现国际化处理。当然了,需要自行写集成如 Vue、React 等响应绑定处理,很简单的啦。

📦 安装指南

选择你喜欢的包管理器安装:

# npm
npm install yyq-i18n@latest

# yarn
yarn add yyq-i18n@latest

# pnpm
pnpm add yyq-i18n@latest

✨ 核心优势:

  • 轻量级 - 代码体积小,没有外部依赖
  • 框架无关 - 不依赖于任何前端框架,可在任何JavaScript环境中使用
  • 简单直接 - API简洁,容易理解和使用
  • 定制灵活 - 可以根据你想要的,轻松修改和扩展

1、轻量级

除了定义外,核心代码实际上就30行左右,没有外部依赖。

2、框架无关

使用 Typescript 和 ES6 编写,(打包后的代码)可以使用在任何 JavaScript 环境中。

3、简单直接

为什么说简单直接呢,你们往下看看就知道了。

add 方法

如其名,add 就是用来添加语言资源。

import { Locale, add, use, t } from 'yyq-i18n'
const zhCNJson = { lang: '中文简体' }
add('zh-CN', zhCNJson)
use('zh-CN')
console.log('currentLang', Locale.currentLang) // zh-CN
console.log('currentResource', Locale.currentResource) // { lang: '中文简体' }

merge 方法

merge 方法用于合并语言资源。不知道有没有用处,先放着。

import { Locale, add, merge, use, t } from 'yyq-i18n'
const zhCNJson = { lang: '中文简体' }
add('zh-CN', zhCNJson)
use('zh-CN')
console.log('currentResource', Locale.currentResource) // { lang: '中文简体' }

// 扩展
merge('zh-CN', { more: '我是后边来的更多内容' })
console.log('currentResource', Locale.currentResource) // { lang: '中文简体'。 more: '我是后边来的更多内容' }

use 方法

use 方法用于切换语言,比如说自动获取当前所在的语言。

import { Locale, use } from 'yyq-i18n'
console.log('currentLang', Locale.currentLang) // zh-CN

 // 假设返回: zh-TW
const userAgentLang = navigator.language || navigator.userLanguage
use(userAgentLang)
console.log('currentLang', Locale.currentLang) // zh-TW

t 方法

t 方法,根据传入的参数,替换成当前设置的语言所在的翻译文本。支持默认值处理、插值处理。

type funcT = <T extends string>(key: PropertyKey, paramsOrDefaultValue?: Partial<Resource> | T) => ValueOf<Resource> | T
import { Locale, add, use, t } from 'yyq-i18n'
const zhCNJson = { lang: '中文简体', welcome: '你好,{name}', today: '{today}是{date}' }
add('zh-CN', zhCNJson)
use('zh-CN')

console.log(t('lang')) // 中文简体

// 默认值处理
console.log(t('name', '默认值'))

// 插值处理
console.log(t('welcome', { name: '张三' })) // 你好,张三
console.log(t('today', { today: '今天', date: '2025年10月1日' })) // 今天是2025年10月1日

😇 最后

yyq-i18n  希望大家喜欢,求个 star。

非对称加密RSA简介

2025年7月4日 16:38

非对称加密简介

数据加密大概分为对称加密和非对称加密两种

  • 对称加密

    只需要一个密钥,即可用同一套算法加密和解密

    用户A要发送abc, 通过在abc后面加上密钥_key,得到abc_key发给用户B,用户B通过截取掉密钥_key,得到真实信息abc

    用户A需要告知用户B密钥,线上传输密钥有泄露风险,用户A如果要给很多人发消息,那就得给每个用户告知密钥,网络传输密钥被截取时,即可解密数据

    常用对称加密算法:AES(常用) 、DES(已不安全)、SM4(国产)

  • 非对称加密

    使用一对密钥(公钥、私钥)进行加解密,加密和解密用的不是同一个密钥

    B先把公钥发给A,A用B的公钥加密,B再用自己的私钥解密,只有公钥在网络传输,第三方即使截取了公钥,也破解不了数据

    常用非对称加密算法:RSA,SM2(中国自主研发)

    虽然公钥私钥可以互相解密数据,但公钥、私钥需要严格区分,不能互换,因为通过公钥想暴力破解私钥很难,但是通过私钥暴力破解公钥会简单很多

    常用场景:

    场景 谁加密 谁解密 为什么
    保密通信 他人(用你的公钥) 你(用你的私钥) 保证:只有你能看到内容
    数字签名 你(用自己的私钥) 所有人(用你的公钥) 保证:消息确实来自你、未被篡改
    登录认证 你(用自己的私钥) 所有人(用你的公钥) 和数字签名类似,可以免登录,比如git@xxx的方式克隆代码可以免输密码

RSA算法简介

参考阮一峰写的:RSA算法原理(二)

参考:奇妙的安全旅行之RSA算法

算法原理

RSA 是目前最有影响力的非对称加密算法,其中有些细节涉及数论中的一些内容,比较烧脑。该算法基于一个十分简单的数论事实:将两个大素数相乘十分容易,但想要对其乘积进行因式分解却极其困难,「因此可以将乘积公开作为加密密钥,即公钥,而两个大素数组合成私钥」。公钥是可发布的供任何人使用,私钥则为自己所有,供解密之用。

类型 公式 (mod是取余操作)
公钥KU n:两素数 p 和 q 的乘积(p 和 q 必须保密)
e:与(p - 1)(q - 1)互质
私钥KR d:e^-1 (mod(p-1)(q-1))这是乘法逆元的表示方式
de%((p-1)(q-1)) = 1 这是没学过数论的能看懂的公式
n: p 和 q 的乘积
加密 C ≡ m^e mod n
解密 m ≡ c^d mod n

其中公钥一般表示为数字(e, n),私钥数字(d, n)

在加密通信中 明文M,密文C公式如下:

通过明文和公钥计算密文:C = M**e % n

密文C传输给用户,通过密文加密钥解密得到明文:M = C**d % n

密文C传输过程中即使被人截取也获取不了明文,实现加密

在数字签名中 在数字签名中,M是小明针对文件的数字签名,通过某种算法如sha256计算文件摘要C1

先通过密钥和C1算出数字签名M:M = C1**d % n

把数字签名和文件一起发给用户

  • 通过数字签名和公钥算出文件摘要C2:C2 = M**e % n
  • sha256计算文件摘要C1,比对C1和C2,如果相同,说明文件未被篡改,同时说明这个数字签名M确实是由小明签的,因为只有小明的公钥加小明的数字签名才能得到验证摘要正确
  • 数字签名具有防文件篡改、防伪造、防抵赖的功能

如何攻破数字签名

何为攻破数字签名?发给用户假文件,令其以为是真的;或者即使是真文件,但不是真实的签名

  • 方式一:把假文件的摘要C1和真文件的搞成一样,签名验证依然可以通过,这就是hash碰撞攻击

    著名案例

    MD5:2004年被王小云团队攻破,可在普通计算机上几小时内构造碰撞。典型案例包括伪造CA证书(2008年)。

    SHA1:2017年Google团队公开SHAttered攻击,构造两个显示内容不同但哈希值相同的PDF文件,成本约11万美元(CPU+GPU集群运算数月)

  • 方式二:让你得到错误的公钥,攻击者用自身的私钥签名任何文件都能通过验证

如何伪造公钥

中间人攻击:在用户获取公钥的过程中,用假公钥替换为真的

如何解决中间人攻击

CA登场了,CA (Certificate Authority)」证书中心,由一个CA认证中心通过他的私钥对公钥A签名得到一个数字证书(包含公钥A和CA签名等信息)传输给用户,用户先用CA的公钥验证签名信息确保公钥A真实,等于是又做了一层嵌套加密。

  • 怎么保证CA的公钥又是如何传输的呢?

    浏览器内置通过了WebTrust认证的CA的公钥,被称为根证书

    用户手动导入

    浏览器弹窗询问用户后自动导入

Https加密通信过程

非对称加解密数据比对称加密慢很多,实际应用中一般用于加密”对称加密密钥“,后续在用对称加密算法加密数据,https就是基于这个逻辑运行的

HTTPS 的加密过程可以分为以下步骤:

  • 客户端向服务器发送 HTTPS 请求。
  • 服务器将公钥证书发送给客户端。
  • 客户端验证服务器的证书。
  • 如果验证通过,客户端生成一个用于会话的对称密钥。
  • 客户端使用服务器的公钥对对称密钥进行加密,并将加密后的密钥发送给服务器。
  • 服务器使用私钥对客户端发送的加密密钥进行解密,得到对称密钥。
  • 服务器和客户端使用对称密钥进行加密和解密数据传输。

微前端:化解前端开发困境之道

作者 李成泽
2025年7月4日 16:35

一、面临的痛点

(一)巨石项目之困

项目随着业务拓展,代码量迅猛增长,演变成巨石项目。这种项目犹如一座庞大且复杂的 “巨石”,牵一发而动全身。开发新功能或修改既有功能时,开发人员不得不深入钻研整个庞大的代码库,开发效率大幅降低,同时出错风险显著提高。

(二)技术栈混杂带来的高维护成本

在企业的发展进程中,不同阶段或不同团队基于各种因素,选用了各式各样的前端技术栈。多种技术栈共存的局面,使得项目维护的复杂度直线上升。开发人员不仅需要掌握多种技术,增加了学习成本,而且团队协作难度加大,不同技术栈之间的整合与兼容更是困难重重。

(三)复用层的挑战

在传统架构里,复用层的设计与维护常常不尽如人意。一方面,复用组件可能因与整体项目耦合度过高,难以在其他项目或模块中顺利复用;另一方面,复用层的更新与优化,可能会对依赖它的各个部分产生难以预料的影响,导致维护成本攀升,复用效果大打折扣。

二、解决方案

(一)iframe 实现

iframe 提供了一种简单直观的方式,能够将不同的子应用嵌入到主页面。每个 iframe 宛如一个独立的窗口,其中的子应用可独立进行开发与部署,具备一定程度的隔离性。不过,该方式也存在一些弊端,比如 iframe 之间的通信相对复杂,样式隔离并非十全十美,还可能出现滚动条等兼容性问题。

以下是一个简单的使用 iframe 嵌入子应用的 HTML 示例:

html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Using iframe for Micro - Frontend</title>
</head>

<body>
    <h1>Main Application with iframe</h1>
    <iframe src="sub - application.html" width="800" height="600"></iframe>
</body>

</html>

(二)框架整合方案

诸如 single - spa 和 qiankun 这类框架,为微前端提供了更为完善的解决方案。它们能够将采用不同技术栈开发的子项目、子应用接入到统一平台。借助这些框架,主应用能够更有效地管理子应用的加载、卸载、路由等操作,达成不同技术栈子应用的无缝集成。

以 single - spa 为例,以下是一个简单的 single - spa 主应用注册子应用的 JavaScript 示例(假设使用 React 技术栈):

javascript

import singleSpa from'single - spa';
import { registerApplication, start } from'single - spa';

// 注册 React 子应用
registerApplication({
    name: '@single - spa/react - app',
    app: () => import('@single - spa/react - app'),
    activeWhen: ['/react - app']
});

start();

(三)微前端体系设计

从宏观层面规划微前端体系,涵盖明确各个子应用的职责边界,精心设计合理的通信机制,以及妥善解决子应用之间的样式隔离与集成等问题。通过全面系统的体系设计,保障微前端架构高效、稳定地运行。

例如,在微前端体系中,可以通过自定义事件来实现子应用间的通信。以下是一个简单的自定义事件通信示例(以 JavaScript 原生代码为例):

javascript

// 子应用 A 触发事件
const event = new CustomEvent('subAppAToSubAppBEvent', {
    detail: {
        message: 'Hello from Sub - App A'
    }
});
document.dispatchEvent(event);

// 子应用 B 监听事件
document.addEventListener('subAppAToSubAppBEvent', function (e) {
    console.log('Received message from Sub - App A:', e.detail.message);
});

三、核心内容

(一)微前端架构的关键要素

  1. 主应用:承担着整体布局的规划任务,犹如城市的规划师,决定各个区域(子应用)的位置与展示方式。同时,主应用负责管理路由,如同交通指挥员,引导用户在不同子应用之间顺畅切换。此外,主应用还肩负着子应用的加载工作,确保子应用在需要的时候能及时呈现在用户面前。
  2. 子应用:每个子应用专注于一个特定模块,像是城市中各司其职的功能区。它们拥有独立的视图,为用户呈现独特的界面;具备自己的路由,以便在子应用内部实现页面跳转;同时拥有独立的状态,保证自身业务逻辑的完整性与独立性。
  3. 应用之间的通信:子应用之间需要一套有效的通信机制,就像城市中不同功能区之间的交通网络,确保信息能够顺畅流通。这有助于实现子应用之间的协同工作,比如一个子应用中的数据更新,能够及时通知相关的其他子应用进行相应调整。
  4. 隔离与集成:要确保不同子应用之间的样式相互隔离,避免样式冲突,如同每个功能区都有自己独特的建筑风格,互不干扰。同时,又要实现子应用之间的有机集成,使整个微前端应用在用户看来是一个统一、协调的整体。

(二)微前端的技术方案

  1. single - spa

    • 多框架支持 / 框架无关性:如同一个包容万象的舞台,single - spa 支持运行多个前端框架,能够将不同技术栈的应用集成到同一个大应用中。无论子应用是基于 React、Vue 还是 Angular 开发,都能在 single - spa 的框架下和谐共处。
    • 灵活性和扩展性:它提供了一套完整的生命周期管理机制,好比为每个子应用配备了一个专属的日程表。开发人员可以在应用加载、渲染、卸载等各个阶段插入自定义逻辑,根据项目需求灵活定制子应用的行为。
    • 应用集成管理:通过注册多个微应用,single - spa 能够像智能管家一样,按需加载和卸载子应用,避免资源的无端浪费。只有在用户真正需要某个子应用时,才将其加载并投入使用,提升了应用的整体性能。
    • 活跃的社区和文档:作为一个活跃的开源项目,single - spa 拥有丰富的文档资源,如同详尽的使用手册,为开发人员提供了全面的指导。同时,活跃的社区为开发人员搭建了交流平台,遇到问题时能够迅速获得帮助与建议。
  2. qiankun:qiankun 同样是一款优秀的微前端框架,它基于 single - spa 进行了进一步的封装与拓展,针对国内开发者的使用习惯和常见业务场景,提供了更为便捷、高效的微前端解决方案。它在继承 single - spa 诸多优点的基础上,对应用的加载、通信、样式隔离等方面进行了优化,使得微前端架构的搭建与维护更加简单和直观 ,帮助开发团队更快速地实现不同技术栈子应用的整合与管理。

react案例动态表单(受控组件)

作者 lichenyang453
2025年7月4日 16:01

         学了react基础的这些state,props,以及配置react.react-dom,react-scriprs这些环境,和组件,检验这些最直接的方式就是写出点什么,现在我们去写一个受控组件(用state去操作渲染标签内容,用标签的value去操作更新state,双向绑定),那么我们开始吧。

        首先要写一个表单,先写出来结构。

        编辑

        首先是用户输入区域,还有展示区,首先展示区有默认展示的内容,我们直接定义一个state去代替服务器返回的数据。然后这是一个数组对象,我们的输入区就是输入之后,动态的改变我们state数组对象,然后展示区只管重新遍历数组对象然后展示。还有删除,以及加了一个筛选器,其实根本的实现原理就是改变我们的state数组对象,因为最本质的页面内容就是靠展示这些数据的,然后无非就是样式以及html结构。我们开始吧。

1.实现展示区

        首先就是下面的展示区,我们分组件,一个壳子组件里面展示的是列表,都是重复的所以遍历一个组件即可。ok展示区是两个组件,然后列表我们左右两个区域,我们在分一下组件,也就是左边日历是列表的子组件,结构清晰了开始写代码吧。 


//函数组件 组件的首字母必须大写 函数组件就是一个返回jsx的普通函数
//类组件也是必须返回一个jsx不过需要继承Component然后还需要调用render方法去返回jsx
import React from 'react'
import Logs from './components/Log'
import './App.css'
import LogsForm from './components/LogsForm'
export default function App() {
    const [logsData, setLogsData] = React.useState([{
        id: "001",
        date: new Date(2021, 9, 20, 19, 0),
        title: '学习九阳神功',
        time: 30
    },
    {
        id: "002",
        date: new Date(2021, 4, 20, 19, 0),
        title: '学习我我我功',
        time: 20
    },
    {
        id: "003",
        date: new Date(2021, 5, 20, 19, 0),
        title: '学习你你神功',
        time: 10
    },
    {
        id: "004",
        date: new Date(2022, 1, 20, 19, 0),
        title: '学习九大大滴神功',
        time: 34
    }
    ])
    const onHandle = (newLog) => {
        newLog.id = Date.now() + ''
        setLogsData(() => {
            return ([newLog, ...logsData])
        })
    }
    const deleteHandle = (id) => {
        // setLogsData((preState) => {
        //     const newLogs = [...preState]
        //     newLogs.splice(index, 1)
        //     return newLogs
        // })
        console.log('zhesi id', id);
        setLogsData((preState) => {
            return (preState.filter((item) => { return (item.id !== id) }))
        })
    }
    return (
        <div className='app'>

            <LogsForm onHandle={onHandle} />
            <Logs logsData={logsData} deleteHandle={deleteHandle} />
        </div>
    )
}

     这是App外壳组件,也就是所有组件都在这里超级拼装成页面。不多说了,因为是所有组件的根组件也就是父亲,我们把数据默认放在这里。当作是服务器响应回来的数据。然后传给展示组件Logs。

import React from 'react'
import LogItem from '../LogItem'
import './index.css'
import Logfilter from '../Logfilter'
import Card from '../UI/Card'
export default function Logs(props) {
    //模拟一组从服务器加载的数据
    const [year, setYear] = React.useState(2022)
    let filterDate = props.logsData.filter((item) => { return (item.date.getFullYear() === year) })
    let contetn = filterDate.map((item) => {
        return (
            <LogItem key={item.id} deleteHandle={() => { props.deleteHandle(item.id) }} date={item.date} title={item.title} time={item.time} />
            // <LogItem {...item} />
        )
    })
    if (contetn.length === 0) {
        contetn = <p className='lll'>现在列表空了</p>
    }
    const handleYear = (year) => {
        setYear(() => {
            return year
        })
    }
    return (
        // 如果组件中的数据全部写死会导致组件无法动态设置
        //希望组件数据可以由外部设置 在组件间父组件可以通过props给子组件传递数据
        <Card className='logs'>
            <Logfilter year={year} handleYear={handleYear} />
            {/* 在父组件给子组件设置属性在函数组件中可以通过参数来保存 */}
            {
                contetn
            }
        </Card>
    )
}

        okLogs组件通过props接收,然后我们遍历数组,然后每次渲染都给子组件传递当前元素的展示内容,通过props,然后是列表组件

import React from 'react'
import MyDate from '../MyDate'
import Card from '../UI/Card'
import ConFirmModer from '../UI/ConFirmModel'
import './index.css'
export default function LogItem(props) {
    // 添加一个state 记录是否显示窗口
    const [show, setShow] = React.useState(false)
    // console.log(props);
    // console.log(props.data);
    const handleDelete = () => {
        //confirm执行前询问
        // const del = window.confirm('确认吗该操作不可恢复');
        setShow(() => {
            return (true)
        })
        // if (del) {
        //     //删除当前的item 其实就是从数据state移出指定的数据
        //     props.deleteHandle()
        // }

    }
    const handleCancel = () => {
        setShow(false)
    }
    const handleOk = () => {
        props.deleteHandle()
    }
    return (
        //函数组件的行参定义一个props props指向一个对象 包含父组件传递所有参数

        <Card className="item">
            {show && <ConFirmModer confirmText="该操作不可恢复,请确认" handleCancel={handleCancel} handleOk={handleOk} />}
            <MyDate date={props.date} />
            <div className="content">
                <h2 className='title'>{props.title}</h2>
                <div className="time">{props.time}</div>
            </div>
            {/* 创建一个删除按钮 */}
            <div>
                <div className='delete' onClick={handleDelete}>X</div>
            </div>
        </Card>

    )
}

        列表组件接收参数渲染,然后日历同样是这样。没什么好说的。那么展示区就完成了。

2.实现表单动态输入改变展示区

        那么下一个就是用户输入内容,展示区新添加展示列表了。首先就是输入框作为一个组件。     

import React from 'react'
import Card from '../UI/Card'
import './index.css'
export default function LogsForm(props) {
    //创建三个变量存储表单数据
    const [inputDate, setInputDate] = React.useState('')
    const [title, setTitle] = React.useState('')
    const [time, setTime] = React.useState('')
    //将表单数据统一到一个state中
    // const [formData, setFormData] = React.useState({
    //     inputDate: '',
    //     title: '',
    //     time: ''
    // })
    //获取用户输入的内容 当表单项发生变化的时候 监听事件
    //创建响应函数监听表单的变化
    // const titleRef = React.useRef()
    //首先拿到DOM对象 用ref 或者document.getElementById()
    //ref.current id.value
    const dateChangeHandle = (event) => {
        //事件对象event保存发生当前事件触发所以信息
        //event.target执行的是触发事件的对象
        // setFormData(() => {
        //     return ({ ...formData, inputDate: event.target.value })
        // })
        setInputDate(() => {
            return (event.target.value)
        })
    }
    const timeChangeHandle = (event) => {
        // setFormData(() => {
        //     return ({ ...formData, time: event.target.value })
        // })
        setTime(() => {
            return (event.target.value)
        })
    }
    const titleChangeHandle = (event) => {
        // setFormData(() => {
        //     return ({ ...formData, title: event.target.value })
        // })
        setTitle(() => {
            return (event.target.value)
        })
    }
    //表单提交时汇总表单中的数据 react表单不需要自行提交 通过react提交
    const formSubmitHandler = (e) => {
        //取消表单的默认行为
        e.preventDefault()
        //获取表单数据
        // console.log(inputDate, title, time);
        //将数据拼装成对象
        const newLog = { date: new Date(inputDate), title: title, time: +time }
        // console.log(newLog);
        props.onHandle(newLog)
        setInputDate('')
        setTime('')
        setTitle('')
        // setFormData({
        //     inputDate: '',
        //     title: '',
        //     time: ''
        // })


    }
    return (
        <Card className='form'>
            <form onSubmit={formSubmitHandler}>
                <div className='form-item'>
                    <label htmlFor="date">日期</label>
                    <input onChange={dateChangeHandle} value={inputDate} type="date" name="" id="date" />
                </div>
                <div className='form-item'>
                    <label htmlFor="title">内容</label>
                    <input onChange={titleChangeHandle} value={title} type="text" name="" id="title" />
                </div>
                <div className='form-item'>
                    <label htmlFor="time">时间</label>
                    <input onChange={timeChangeHandle} value={time} type="text" name="" id="time" />
                </div>
                <div className='form-btn'>
                    <button>添加</button>
                </div>
            </form>
        </Card>
    )
}

        三个输入框,我们需要把输入框输入的内容获取到,然后我们state数据是数组对象,我们就需要把输入的内容拼一个对象然后推进state数据里面。首先设置state然后用更新方法添加监听事件获取到输入的value值,然后在把输入框value值绑定我们设置的state。把输入组件变成受控组件,即state操作value,value操作state。。。然后表单添加事件,我们子给父传需要用调用函数时传递参数的形式,id我们用new Date()时间戳代替,这样我们就实现了这个功能。

3.删除一个列表 

        我们希望添加列表也可以删除列表,我们无非就是筛选数组,我们给每个列表添加一个按钮,点击的时候就传递当前这个列表的id给父组件,然后父组件通过数组的filter筛选留下所有id不是传过来的id的元素。然后删除就实现了。

        当然我们删除时候是不可逆的,那么就需要一个提示框,在每次删除前强制用户看到这个,让她们判断是否确定删除。

        我们去创造一个组件,

import React from 'react'
import BackDrop from '../BackDrop'
import './index.css'
export default function ConFirmModer(props) {
    return (
        <BackDrop>
            <div className='confirm'>
                <div className='confirm-text'>{props.confirmText}</div>
                <div className='confirm-btn'>
                    <button onClick={props.handleOk} className='ok'>确认</button>
                    <button onClick={props.handleCancel}>取消</button>
                </div>
            </div>
        </BackDrop>
    )
}
.confirm {
    display: flex;
    flex-direction: column;
    width: 400px;
    height: 200px;
    background-color: white;
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    margin: auto;
    padding: 10px;
}

.confirm-text {
    height: 150px;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 22px;
}

.confirm-btn {
    display: flex;
    flex: auto;
    justify-content: flex-end;
}

.confirm-btn button {
    width: 100px;
    margin: 0 10px;
    border: none;
    background-color: antiquewhite;
    font-size: 20px;
}

.confirm-btn .ok {
    background-color: red;
    color: white;
}

        当然还需要在列表里面添加一个state去控制只有删除事件发生的时候才显示。但是这里有一个问题,显示的框需要阻止其他行为,也就是必须盖住页面,用户只能去选择。我们就需要一个壳子在页面上面,壳子上面是这个提示框。

        编辑

        

        

import React from 'react'
import './index.css'
import ReactDOM from 'react-dom'
//获取backdrop的根元素
const backdropRoot = document.getElementById('backdrop-root')
export default function BackDrop(props) {
    return (
        ReactDOM.createPortal(<div className='backdrop'>
            {props.children}
        </div>, backdropRoot)
    )
}

        这个可以需要用一个根节点去展示,在index.html去定义一个容器展示这个外壳,保证是在图层最上面。然后外壳渲染到这个位置。然后再

        

import React from 'react'
import BackDrop from '../BackDrop'
import './index.css'
export default function ConFirmModer(props) {
    return (
        <BackDrop>
            <div className='confirm'>
                <div className='confirm-text'>{props.confirmText}</div>
                <div className='confirm-btn'>
                    <button onClick={props.handleOk} className='ok'>确认</button>
                    <button onClick={props.handleCancel}>取消</button>
                </div>
            </div>
        </BackDrop>
    )
}

         然后把输入框放里面,这样就完成了删除。

4.筛选列表 

        我们在列表上面添加一个选择框,去筛选列表,跟删除列表一样,只不过这次是通过一个新的state去判断是否留下。

        

import React from 'react'

export default function Logfilter(props) {
    const handleY = (event) => {
        console.log(event.target.value);
        props.handleYear(+event.target.value)
    }
    return (
        <div>
            年份:<select onChange={handleY} value={props.year}>
                <option value="2022">2022</option>
                <option value="2021">2021</option>
                <option value="2020">2020</option>
            </select>
        </div >
    )
}

        ok受控组件,只不过state是在父组件里面定义的,我们props在中间人,然后根据year筛选即可。

        这是一个简单的案例,用到了props,state,一些操作数组的方法,也算是掌握基础react使用的一个体现。如果有问题希望大家可以指出。

面试官:useEffect 为什么总背刺?我:闭包、ref 和依赖数组的三角恋

作者 Kincy
2025年7月4日 16:01

🧠 系列前言:

面试题千千万,我来帮你挑重点。每天一道,通勤路上、蹲坑时、摸鱼中,技术成长不设限!本系列主打幽默 + 深度 + 面霸必备语录,你只管看,面试场上稳拿 offer!

💬 面试官发问:

“说说你对 useEffect 的理解?依赖项为什么总是填不对?闭包陷阱怎么解?”

哎哟妈呀,这题一出,多少前端人梦回凌晨 2 点 debug 页面逻辑,满脸问号:我明明写对了,怎么又触发了?

🎯 快答区(面霸速记版)

  • useEffect 是一个副作用钩子,默认在组件渲染后执行
  • 依赖项数组控制副作用的触发时机
  • 如果你不理解闭包和引用变化useEffect 就会变身背刺小王子
  • React 的规则是:只要依赖项变了就重新执行

所以填错依赖数组 = 自找 bug

🧬 useEffect 的爱恨情仇

🪝 一、useEffect 到底干嘛的?

在类组件中我们有:

componentDidMount() // 初始化执行一次
componentDidUpdate() // 每次更新都执行
componentWillUnmount() // 组件卸载时执行清理

而在函数组件里,一个 useEffect 全包了:

useEffect(() => {
  console.log('副作用逻辑来咯~')

  return () => {
    console.log('组件卸载 or 依赖变化,清理啦!')
  }
}, [依赖项])

你可以认为:

useEffect = didMount + didUpdate + willUnmount 的组合技。

🎭 二、为什么依赖项这么重要?

你写副作用:

useEffect(() => {
  fetchData(keyword)
}, [])

看起来没毛病吧?但 keyword 改了,页面没更新,debug 一看:

啊这……你把 keyword 忘写进依赖数组了!

React 的机制是:

只要依赖数组里的值发生变化useEffect 就重新执行。

而且还有 ESLint 小助手在旁边耳语:

“你漏了依赖项,要不要加上?”

别不信邪,真不加,等着被 bug 追着打。

🧟‍♂️ 三、闭包 + useEffect = 鬼打墙现场

来看经典误区:

const [count, setCount] = useState(0)

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count) // 👈 永远是 0!
  }, 1000)
}, [])

你以为能打印 count 的实时值?结果它永远是 0。为啥?

闭包记住的是第一次的 count 值,后续不会变。

React 不会每次都重新创建这个函数,它只在第一次 [] 时执行了一次副作用。

✅ 正确解法 1:依赖更新版本

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count)
  }, 1000)

  return () => clearInterval(timer)
}, [count]) // 每次 count 变化都重新注册

但这样每次都清除+重新 setInterval,其实效率不高。

✅ 正确解法 2:使用 ref 保存可变值

const countRef = useRef(count)

useEffect(() => {
  countRef.current = count
}, [count])

useEffect(() => {
  const timer = setInterval(() => {
    console.log(countRef.current) // 永远拿到最新值
  }, 1000)

  return () => clearInterval(timer)
}, [])

完美解决闭包问题,让副作用逻辑始终拿到最新值。

🕳 四、useEffect 执行时机:同步还是异步?

很多人以为 useEffect 是异步的,其实更准确地说是:

useEffect 是 在浏览器完成 paint 之后 执行的副作用,也就是 非阻塞渲染

🎥 补充一个:

  • useEffect页面绘制后执行
  • useLayoutEffectDOM 变更后、页面绘制前同步执行(可能会阻塞渲染)

一般推荐默认用 useEffect,只有你要测量 DOM 或强制修改布局时,才上 useLayoutEffect

🎯 五、React 官方建议怎么写依赖项?

✅ 尽可能声明清晰依赖

useEffect(() => {
  fetchData(keyword)
}, [keyword])

❌ 不推荐写成这样:

useEffect(() => {
  fetchData(keyword)
}, []) // 靠闭包?你会后悔的

✅ 对象依赖,记得 memo

const filter = useMemo(() => ({ name }), [name])

useEffect(() => {
  fetchData(filter)
}, [filter])

避免每次都触发,因为 { name } 每次都是新对象。

🎓 装 X 语录(限时使用)

“useEffect 的本质是响应式副作用收集器,依赖数组的变化驱动副作用重跑。”

“闭包陷阱其实是 JS 的机制,不是 React 的锅。ref 是解决数据脱离组件周期的利器。”

“副作用的清理逻辑相当于生命周期中的 willUnmount,能防止内存泄漏和状态污染。”

说完记得压低语气、语速慢一点,表现你是“老油条 + 热爱原理派”。

✅ 总结一句话

useEffect = 渲染之后的副作用管理器,依赖数组驱动重跑,闭包问题靠 ref 或更新依赖解决

写对它,你是高手;写错它,它就是你项目里的定时炸弹💣。

🔮 明日预告

明天我们聊聊 useCallback 和 useMemo,它们到底是性能优化神器,还是“性能幻想剂”?怎么用才能不白费 CPU?⚙️

📌 点赞 + 收藏 + 关注系列,React Hook 不再“Hook”住你!

❌
❌