普通视图

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

🧠 Next.js 安全防线:从 CSRF 到 XSS 的黑魔法防护 🌐⚔️

作者 LeonGao
2025年10月30日 09:22

🌋 前言:前端安全是个什么鬼?

想象你在海滩边写一个 Next.js 应用,API、登录、数据全都顺风顺水,突然来了个陌生请求,把你用户的 session 偷了。没错——那就是 CSRF(跨站请求伪造) 在作祟。而另一边,用户输入 <script> 标签在页面上弹出你的名字,这就是 XSS(跨站脚本攻击) 的优雅登场。

现代 Web 应用安全就像玩一场塔防游戏:攻击者只要找到一条缝,你的城堡就塌了。


🧱 一、CSRF:当用户被“借刀杀人”

“你点的是猫猫视频,发的却是转账请求。”

🔍 原理回顾(用人话讲)

CSRF 攻击的核心思想很简单:攻击者诱导用户浏览他们的恶意网站,从而“借”用户的登录凭证(通常是 Cookie),向真正的网站发起请求。

比如用户已登录 bank.com,攻击者在自己的网站上藏了段:

<img src="https://bank.com/transfer?to=hacker&amount=9999" />

用户一打开,浏览器乖乖带上了 Cookie,银行以为是你本人发起的请求 🤡


🧰 两步护法:Next.js + csurf

🧩 Step1:安装依赖

npm install csurf cookie-parser

🧩 Step2:配置中间件

pages/api/_middleware.js 或 Next 13+ 的 middleware.ts 中加入配置逻辑:

import { NextResponse } from 'next/server';
import csurf from 'csurf';
import cookieParser from 'cookie-parser';
import express from 'express';

const app = express();
app.use(cookieParser());
app.use(csurf({ cookie: true }));

export const config = {
  matcher: ['/api/:path*'],
};

export function middleware(req) {
  // 可以在此加入 token 注入逻辑
  return NextResponse.next();
}

💡 提示:CSRF Token 就像访客通行证,每次请求都验证身份,防止“伪装者”。


🕹️ CSRF Token 的工作流程

阶段 行为
🧙 生成 服务端为每个会话发一个 Token
🧾 注入 前端表单请求时带上这个 Token
🔍 验证 后端验证 Token 是否匹配
✅ 通过 如果匹配,请求被允许执行

你可以这么理解:

“服务端说,我送你一张签名卡,只有我认得的签名卡;你下次来买面包得出示这张卡,否则我打死也不认你。”


🦠 二、XSS:从 <script> 到失控的前端世界

“你以为你在输出 JSON,实际上是在输出一场灾难。”

🐍 XSS 攻击的本质

当用户输入的内容没有被安全过滤,最终被原样渲染在页面上时——攻击者就能注入脚本执行:

const comment = "<script>alert('你被骗了')</script>";
document.body.innerHTML = comment;

这就像你让用户在表单写留言,结果 TA 在留言板上开启了天眼通。


🛡️ 用 helmet 给页面加上“头盔”

helmet 是一个 Express 中间件,用来设置各种 HTTP 安全头,阻止常见攻击。

🧩 Step1:安装依赖

npm install helmet

🧩 Step2:配置 next.config.js 或自定义服务器

import helmet from 'helmet';
import express from 'express';
import next from 'next';

const app = next({ dev: true });
const server = express();
const handle = app.getRequestHandler();

server.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:"],
    },
  },
  referrerPolicy: { policy: 'no-referrer' },
}));

server.all('*', (req, res) => handle(req, res));
server.listen(3000);

🧠 Tip:CSP(内容安全策略)是浏览器的防护罩,用它限制脚本与外部资源来源,阻止第三方注入。


📋 三、安全清单:Next.js 项目部署前必查表 ✅

检查点 是否完成
🔐 CSRF csurf 已配置并验证 token
🧱 XSS 已使用 helmet / CSP
🍪 Cookie SameSite=strictHttpOnlySecure
🕵️‍♂️ 输入验证 所有用户输入清理与转义
🚷 CORS 限制跨域来源
🚨 错误信息 不在生产输出敏感堆栈信息
🧰 OWASP 对照 OWASP Cheat Sheet 检查

📚 四、参考:OWASP Cheat Sheet 系列(建议收藏)

主题 推荐阅读
🔒 CSRF 防护 OWASP CSRF Prevention Cheat Sheet
⚔️ XSS 防护 OWASP XSS Prevention Cheat Sheet
🧩 安全头 OWASP Secure Headers Project

🎨 五、彩蛋:一张简化安全架构脑图(ASCII 版)

         🧑 用户浏览器
              │
        [CSRF Token 验证]
              │
   ┌───► Next.js API Route ◄───┐
   │                            │
 [helmet安全头]             [输入过滤]
   │                            │
 [Express Server]         [数据库安全层]
   │                            │
       └────→ 🌐 安全的数据流 ───┘

🧩 结语:安全,就像代码审美

“代码干净是一种修养,安全意识是一种责任。”

CSRF 与 XSS 看似不起眼,却能敲开整个系统的大门。希望在实现酷炫功能的同时,你能戴上安全的头盔、举起防御的盾牌,让前端世界优雅且坚固。

Webpack配置魔法书:从入门到高手的通关秘籍

2025年10月30日 08:41

朋友们,我是小杨!今天咱们来聊聊Webpack配置这个话题。很多人第一次看到webpack.config.js文件时,感觉就像在看天书一样。别担心,今天我就带你从零开始,一步步解锁Webpack配置的奥秘!

初识Webpack:先来个"Hello World"

让我们从一个最简单的配置开始,就像学编程先写Hello World一样:

// 我的第一个webpack配置
const path = require('path');

module.exports = {
  // 入口:告诉Webpack从哪开始打包
  entry: './src/index.js',
  
  // 输出:打包后的文件放哪里
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  
  // 模式:开发还是生产
  mode: 'development'
};

这个基础配置就像搭积木的第一步,虽然简单,但已经能完成基本的打包任务了!

核心配置详解:拆解Webpack的"五脏六腑"

1. Entry(入口):打包的起点

// 单入口(SPA应用)
entry: './src/index.js'

// 多入口(多页面应用)
entry: {
  home: './src/home.js',
  about: './src/about.js',
  contact: './src/contact.js'
}

// 动态入口
entry: () => new Promise((resolve) => {
  resolve('./src/dynamic-entry.js');
})

2. Output(输出):打包成果的归宿

output: {
  path: path.resolve(__dirname, 'dist'),
  // 使用占位符确保文件名唯一
  filename: '[name].[contenthash].js',
  // 清理输出目录
  clean: true,
  // 公共路径(CDN场景很有用)
  publicPath: 'https://cdn.example.com/'
}

3. Loader:文件转换的"翻译官"

Loader是Webpack最强大的功能之一,让我展示几个常用配置:

module: {
  rules: [
    // 处理CSS文件
    {
      test: /.css$/i,
      use: ['style-loader', 'css-loader']
    },
    
    // 处理SCSS文件
    {
      test: /.scss$/i,
      use: [
        'style-loader',
        'css-loader',
        'sass-loader'
      ]
    },
    
    // 处理图片
    {
      test: /.(png|jpg|jpeg|gif|svg)$/i,
      type: 'asset/resource',
      generator: {
        filename: 'images/[name].[hash][ext]'
      }
    },
    
    // 处理字体
    {
      test: /.(woff|woff2|eot|ttf|otf)$/i,
      type: 'asset/resource',
      generator: {
        filename: 'fonts/[name].[hash][ext]'
      }
    },
    
    // Babel转译JS
    {
      test: /.js$/,
      exclude: /node_modules/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      }
    }
  ]
}

4. Plugins:增强功能的"外挂"

插件让Webpack变得更强大,来看我的常用插件组合:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

plugins: [
  // 自动生成HTML文件
  new HtmlWebpackPlugin({
    template: './src/index.html',
    title: '我的应用',
    minify: true
  }),
  
  // 提取CSS到单独文件
  new MiniCssExtractPlugin({
    filename: '[name].[contenthash].css'
  }),
  
  // 清理输出目录
  new CleanWebpackPlugin(),
  
  // 定义环境变量
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify('production')
  })
]

实战配置:搭建完整的开发环境

让我分享一个我在实际项目中使用的完整配置:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';
  
  return {
    entry: {
      main: './src/index.js',
      vendor: './src/vendor.js'
    },
    
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: isProduction 
        ? '[name].[contenthash].js' 
        : '[name].js',
      publicPath: '/'
    },
    
    module: {
      rules: [
        {
          test: /.js$/,
          exclude: /node_modules/,
          use: 'babel-loader'
        },
        {
          test: /.css$/,
          use: [
            isProduction 
              ? MiniCssExtractPlugin.loader 
              : 'style-loader',
            'css-loader',
            'postcss-loader'
          ]
        },
        {
          test: /.(png|jpg|gif)$/,
          type: 'asset/resource'
        }
      ]
    },
    
    plugins: [
      new HtmlWebpackPlugin({
        template: './src/index.html',
        inject: true
      }),
      ...(isProduction 
        ? [new MiniCssExtractPlugin({
            filename: '[name].[contenthash].css'
          })]
        : []
      )
    ],
    
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\/]node_modules[\/]/,
            name: 'vendors',
            priority: 10
          }
        }
      }
    },
    
    devServer: {
      static: './dist',
      hot: true,
      open: true,
      port: 3000
    },
    
    devtool: isProduction ? 'source-map' : 'eval-cheap-module-source-map'
  };
};

环境特定配置:开发vs生产

开发环境配置要点

// webpack.dev.js
module.exports = {
  mode: 'development',
  devtool: 'eval-source-map',
  devServer: {
    static: './dist',
    hot: true,
    open: true,
    historyApiFallback: true
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  }
};

生产环境配置要点

// webpack.prod.js
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  mode: 'production',
  devtool: 'source-map',
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin(),
      new CssMinimizerPlugin()
    ],
    splitChunks: {
      chunks: 'all'
    }
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader']
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin()
  ]
};

高级配置技巧:我的独门秘籍

1. 动态配置

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';
  const isAnalyze = env && env.analyze;
  
  const config = {
    // 基础配置...
  };
  
  if (isAnalyze) {
    const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
    config.plugins.push(new BundleAnalyzerPlugin());
  }
  
  return config;
};

2. 多目标构建

// 同时构建多个配置
module.exports = [
  {
    name: 'client',
    target: 'web',
    entry: './src/client.js',
    output: {
      filename: 'client.bundle.js'
    }
  },
  {
    name: 'server',
    target: 'node',
    entry: './src/server.js',
    output: {
      filename: 'server.bundle.js'
    }
  }
];

常见问题排查:我踩过的那些坑

问题1:文件找不到?
检查路径配置,记得用path.resolve

问题2:Loader不生效?
检查test正则和use数组顺序

问题3:打包文件太大?
合理配置splitChunks和压缩选项

问题4:热更新不工作?
检查devServer配置和HotModuleReplacementPlugin

性能优化配置

optimization: {
  // 代码分割
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      // 第三方库单独打包
      vendor: {
        test: /[\/]node_modules[\/]/,
        name: 'vendors',
        priority: 20
      },
      // 公共代码单独打包
      common: {
        name: 'common',
        minChunks: 2,
        priority: 10,
        reuseExistingChunk: true
      }
    }
  },
  // 运行时代码单独提取
  runtimeChunk: {
    name: 'runtime'
  }
}

总结

Webpack配置就像搭积木,从简单开始,逐步添加需要的功能。记住几个关键点:

  1. 理解核心概念:Entry、Output、Loader、Plugin
  2. 区分环境:开发环境要快,生产环境要小
  3. 渐进式配置:从简单开始,按需添加功能
  4. 善用优化:代码分割、压缩、缓存一个都不能少

配置Webpack不是一蹴而就的,需要在实际项目中不断实践和调整。希望这篇指南能帮你少走弯路,快速掌握Webpack配置的精髓!

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

webpack了解吗,讲一讲原理,怎么压缩代码

2025年10月30日 08:36

大家好,我是小杨!今天我要带大家探索前端工程化的核心魔法——Webpack。很多人觉得Webpack很复杂,但其实掌握了它的原理,你就会发现它就像个智能的"代码料理机",能把各种原料加工成美味佳肴!

Webpack的核心概念:先认识这些"厨房工具"

想象一下,Webpack就是一个现代化的智能厨房:

// 这是我的webpack.config.js - 相当于"菜谱"
module.exports = {
  // 入口起点 - 告诉厨房从哪开始准备食材
  entry: './src/index.js',
  
  // 输出 - 成品要放在哪里
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  
  // 加载器 - 各种食材处理工具
  module: {
    rules: [
      {
        test: /.css$/,        // 遇到CSS食材
        use: ['style-loader', 'css-loader']  // 用这两个工具处理
      },
      {
        test: /.(png|jpg)$/,  // 遇到图片食材
        use: ['file-loader']   // 用文件处理工具
      }
    ]
  },
  
  // 插件 - 高级厨房设备
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html'
    })
  ]
};

Webpack的工作原理:看看"厨房"是怎么运作的

让我用最通俗的方式解释Webpack的工作流程:

第一步:依赖收集(找食材)
Webpack从入口文件开始,像侦探一样找出所有的import和require语句,建立完整的依赖关系图。

// 假设这是我的项目结构
// src/
//   index.js (入口)
//   utils.js  
//   styles.css

// index.js
import { calculateTotal } from './utils.js';
import './styles.css';

const result = calculateTotal(100, 20);
console.log('总价:', result);

// utils.js
export function calculateTotal(price, tax) {
  return price + (price * tax);
}

第二步:模块转换(处理食材)
通过loader系统,把不同类型的文件都转换成JavaScript能理解的模块。

// 比如CSS文件,经过css-loader和style-loader处理
// 原始的styles.css
.body { color: red; }

// 被转换成JavaScript模块
const styles = ".body { color: red; }";
// 然后通过style-loader注入到页面

第三步:代码生成(装盘上菜)
把所有模块组合成一个或多个bundle文件。

代码压缩的魔法:让文件瘦身的秘密武器

说到代码压缩,这可是Webpack的拿手好戏!让我展示几种常见的压缩方式:

1. JavaScript压缩

// 压缩前的代码
function calculatePrice(originalPrice, discountRate) {
    const discountAmount = originalPrice * discountRate;
    const finalPrice = originalPrice - discountAmount;
    return finalPrice;
}

const result = calculatePrice(100, 0.2);
console.log("最终价格是:", result);

// 经过TerserWebpackPlugin压缩后
function n(n,r){return n-n*r}console.log("最终价格是:",n(100,.2));

在webpack中配置:

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin({
      terserOptions: {
        compress: {
          drop_console: true, // 移除console.log
          pure_funcs: ['console.log'] // 移除特定的函数调用
        }
      }
    })]
  }
};

2. CSS压缩

// 压缩前
.container {
    margin: 10px 20px 10px 20px;
    padding: 15px;
    background-color: #ff0000;
}

.title {
    font-size: 16px;
    font-weight: bold;
}

// 压缩后
.container{margin:10px 20px;padding:15px;background-color:red}.title{font-size:16px;font-weight:700}

配置方法:

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [
      new CssMinimizerPlugin(),
    ],
  },
};

3. 图片压缩

module.exports = {
  module: {
    rules: [
      {
        test: /.(png|jpg|jpeg|gif)$/i,
        use: [
          {
            loader: 'image-webpack-loader',
            options: {
              mozjpeg: {
                progressive: true,
                quality: 65
              },
              optipng: {
                enabled: true,
              },
              pngquant: {
                quality: [0.65, 0.90],
                speed: 4
              }
            }
          }
        ]
      }
    ]
  }
};

高级优化技巧:我的实战经验分享

1. 代码分割

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
        },
        common: {
          name: 'common',
          minChunks: 2,
          chunks: 'all',
          minSize: 0
        }
      }
    }
  }
};

2. Tree Shaking

// utils.js - 原始代码
export function usedFunction() {
  return '这个函数会被使用';
}

export function unusedFunction() {
  return '这个函数不会被使用,会被tree shaking掉';
}

// index.js - 只导入usedFunction
import { usedFunction } from './utils';

// 打包后,unusedFunction会被自动移除

实战案例:看我如何优化一个真实项目

让我分享一个真实的优化经历:

// 优化前的配置
module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  }
};

// 优化后的配置
module.exports = {
  entry: {
    main: './src/index.js',
    admin: './src/admin.js'
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          priority: 10
        }
      }
    },
    usedExports: true,
    minimize: true
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css'
    }),
    new CompressionPlugin({
      algorithm: 'gzip'
    })
  ]
};

优化效果:

  • 文件大小减少60%
  • 加载速度提升40%
  • 缓存命中率大幅提高

常见坑点和解决方案

坑1:Tree Shaking不生效
检查:package.json中要有"sideEffects": false

坑2:压缩后代码报错
可能是ES6+语法问题,确保terser配置正确

坑3:文件太大
合理使用代码分割和动态导入

总结

Webpack就像前端开发的"瑞士军刀",掌握它的原理和优化技巧,能让你在性能优化的道路上如鱼得水。记住:好的打包策略不是一蹴而就的,需要根据项目特点不断调整优化

希望这篇分享能帮你更好地理解Webpack!如果有任何问题,欢迎在评论区交流~

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

玩转小程序生命周期:从入门到上瘾

2025年10月30日 08:30

作为前端老司机,我经常被问到:“小杨,小程序的生命周期到底怎么玩?”今天我就用最接地气的方式,带你解锁小程序生命周期的正确打开方式。准备好,我们要发车了!

先来认识下小程序的生命周期家族

想象一下,你的小程序就像一个有生命的个体,从诞生到离开,每个阶段都有专属的“人生时刻”。让我用一个简单的Page示例来演示:

// 我的第一个小程序页面
Page({
  data: {
    userInfo: {},
    isLoading: true
  },
  
  // 生命周期函数们要登场啦!
  onLoad(options) {
    console.log('页面加载啦!');
    // 我在这里初始化数据
    this.fetchUserData();
  },
  
  onShow() {
    console.log('页面显示啦!');
    // 每次进入页面都会执行
    this.updateBadgeCount();
  },
  
  onReady() {
    console.log('页面准备就绪!');
    // 页面渲染完成,可以操作DOM了
    this.initCanvas();
  },
  
  onHide() {
    console.log('页面隐藏了');
    // 页面被收起时清理一些资源
    this.clearTimer();
  },
  
  onUnload() {
    console.log('页面被卸载了');
    // 页面被关闭时彻底清理
    this.cleanup();
  }
})

全局生命周期:小程序的“大脑”

App级别的生命周期就像是整个小程序的大脑,掌控着全局的生死轮回:

App({
  onLaunch(options) {
    // 小程序启动时的初始化
    console.log('小程序诞生了!');
    this.initCloudService();
  },
  
  onShow(options) {
    // 小程序切换到前台
    console.log('小程序被唤醒了');
    this.syncData();
  },
  
  onHide() {
    // 小程序被切换到后台
    console.log('小程序去休息了');
    this.saveState();
  },
  
  onError(msg) {
    // 出错时的处理
    console.error('出问题啦:', msg);
    this.reportError(msg);
  }
})

组件生命周期:精致的“小部件”

组件的生命周期更加精细,让我用一个自定义组件来展示:

Component({
  lifetimes: {
    created() {
      // 组件实例刚被创建
      console.log('组件出生了!');
    },
    
    attached() {
      // 组件进入页面节点树
      console.log('组件安家落户了');
      this.initData();
    },
    
    ready() {
      // 组件渲染完成
      console.log('组件装修完毕');
      this.startAnimation();
    },
    
    detached() {
      // 组件被从页面移除
      console.log('组件搬家了');
      this.releaseResource();
    }
  },
  
  // 还有页面生命周期,专门为组件定制
  pageLifetimes: {
    show() {
      // 页面展示时的逻辑
      this.resumeMusic();
    },
    
    hide() {
      // 页面隐藏时的逻辑
      this.pauseMusic();
    }
  }
})

实战技巧:生命周期的最佳拍档

在我多年的开发经验中,发现这些生命周期组合使用效果最佳:

场景1:数据加载优化

Page({
  data: {
    listData: [],
    hasMore: true
  },
  
  onLoad() {
    // 初次加载数据
    this.loadInitialData();
  },
  
  onShow() {
    // 每次显示时检查更新
    if (this.shouldRefresh()) {
      this.refreshData();
    }
  },
  
  onHide() {
    // 离开时保存状态
    this.saveScrollPosition();
  }
})

场景2:资源管理

Page({
  timer: null,
  
  onShow() {
    // 开启定时器
    this.timer = setInterval(() => {
      this.updateRealTimeData();
    }, 5000);
  },
  
  onHide() {
    // 及时清理,避免内存泄漏
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  },
  
  onUnload() {
    // 双重保险
    this.onHide();
    this.cleanupNetworkRequests();
  }
})

常见坑点与解决方案

坑1:onLoad vs onShow 傻傻分不清

  • onLoad:只在页面创建时执行一次,适合一次性初始化
  • onShow:每次页面显示都执行,适合状态更新

坑2:内存泄漏
记得在onHide或onUnload中清理定时器、事件监听等资源。

坑3:数据状态混乱
合理利用onHide保存状态,onShow恢复状态。

总结

掌握小程序生命周期,就像掌握了小程序的“呼吸节奏”。合理运用它们,能让你的小程序运行更加流畅,用户体验更加丝滑。记住:合适的生命周期做合适的事,这是写出优质小程序的秘诀!

希望这篇分享能帮你更好地理解小程序生命周期。如果有任何问题,欢迎在评论区交流讨论~

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

记录一次修改 PNPM 版本,部署 NextJs 服务时导致服务器崩溃的问题 😡😡😡

作者 Moment
2025年10月30日 08:17

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

最近在使用 NextJs 开发 DocFlow 协同编辑这个项目,运行了很久都没有问题,最近终于在部署方面出现了问题了,早上的时候发现 ssh 连不上了,一开始并没有太在意,但后来打开京东云控制台,好家伙真的好家伙:

20251029202122

真的小母牛坐飞机牛逼上天了!原来是 CPU 爆了,怪不得连接不上了,当然网站也 502 了:

20251029202311

那是什么原因呢,接下来就要开始慢慢排查了,既然 vscode 连不上,那我们就在官方提供的 ssh 来连接:

20251029202432

输入命令 htop,罪魁祸首马上出现:

20251029202653

原来是 next-server 和 node(运行在 /home/DocFlow-Server/dist/main.js)占用了基本全部的 CPU,那这个原因就很清楚了,那么接下来就可以排查了这个问题的原因了。

首先,可以明确的一点是,之前一直都是同样的方式部署的,没有出现问题,但是为什么今天就一直出现这个问题呢,那肯定是在运行的进程出现了问题。

我项目使用的是 pm2 部署的,那打个日志看看咯:

20251029203101

问题找到了,原来是 pm2 一直在重启,把内存全部占用了,具体是什么原因的已经不好查了,有可能是安装了一些依赖,但是我的 Github Action 是有一些问题没有处理的:

name: Deploy Next.js to JD Cloud

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4.2.0

      - name: Deploy to server via SSH
        uses: appleboy/ssh-action@v1.2.1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USERNAME }}
          port: ${{ secrets.SERVER_PORT }}
          password: ${{ secrets.SERVER_PASSWORD }}
          script: |
            # 加载环境配置
            source ~/.bashrc 2>/dev/null || true
            source ~/.profile 2>/dev/null || true

            # 加载 NVM 环境
            export NVM_DIR="$HOME/.nvm"
            [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

            # 设置 PATH
            export PATH="$HOME/.nvm/versions/node/*/bin:/usr/local/bin:/usr/bin:/bin:$HOME/.npm-global/bin:$PATH"

            # 设置工作目录
            PROJECT_DIR="/home/DocFlow"
            mkdir -p $PROJECT_DIR
            cd $PROJECT_DIR

            # 克隆或更新代码
            if [ ! -d ".git" ]; then
              echo "Cloning repository..."
              rm -rf ./*
              git clone git@github.com:xun082/DocFlow.git .
            else
              echo "Updating repository..."
              git fetch origin main
              git reset --hard origin/main
            fi

            # 创建 .npmrc 文件 (如果不存在)
            if [ ! -f ".npmrc" ]; then
              echo "Creating .npmrc file..."
              echo "@tiptap-pro:registry=https://registry.tiptap.dev/" > .npmrc
              echo "//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_AUTH_TOKEN }}" >> .npmrc
            else
              echo ".npmrc file already exists, skipping creation."
            fi

            # 安装全局依赖 (只在需要时安装)
            command -v pnpm >/dev/null 2>&1 || npm install -g pnpm@9.4.0
            command -v pm2 >/dev/null 2>&1 || npm install -g pm2

            # 安装项目依赖
            pnpm install

            # 构建项目
            pnpm run build

            # 重启应用
            pm2 restart DocFlow 2>/dev/null || pm2 start "pnpm start" --name DocFlow

我这个部署脚本是基本没问题的,至少能跑,现在查看这两天的 commit,想起来有一个这样的操作:

20251029204359

那基本就可以确定是这个问题了。

其他部署方案

PM2 是一种流行的进程管理工具,适用于 Node.js 应用,尤其在小型项目和单机部署时很有效。然而,PM2 的部署流程存在一些局限性:

  1. 依赖管理和版本控制:PM2 需要手动确保服务器上安装正确的 Node.js 和 pnpm 版本。每次代码更新后,你还需要在服务器上重新 构建项目(执行 pnpm run build),这可能会导致生产环境与开发环境之间的不一致,并增加了维护工作。

  2. 服务中断问题:每次代码更新时,PM2 需要 重启应用 来使新版本生效。如果没有正确配置或管理,应用在重启过程中可能会停机一段时间,这对于高可用性要求较高的生产环境来说是个问题。

与 PM2 相比,Docker 自动化部署 通过容器化解决了这些问题,提供了更简洁、更一致的部署流程,特别适用于云端和多环境部署。主要优势包括:

  1. 无需依赖管理和环境配置:Docker 将应用及其所有依赖、环境变量、构建过程封装在 Docker 镜像 中。这样,服务器上无需安装特定版本的 Node.js 或 pnpm,避免了手动配置和版本不一致的问题。你只需要在 构建镜像时 配置好所有的依赖和环境,部署时无需再进行任何安装或构建操作。

  2. 代码问题不会影响运行:如果代码有问题,Docker 使得应用 不会受到当前部署的影响。因为 Docker 镜像已经包含了所有必要的依赖,部署失败时,原先的镜像仍然可以继续运行,确保生产环境不受影响。而在 PM2 部署 时,更新代码后需要重启应用,如果代码有问题,应用会直接停机,导致服务中断。

  3. 无缝的 CI/CD 集成:Docker 完美集成 CI/CD 流程。例如,使用 GitHub Actions,你可以自动构建 Docker 镜像并推送到仓库。服务器只需要 拉取最新镜像,并启动新的容器。如果部署失败,不会影响当前运行的容器,你可以迅速恢复服务,且无需手动修复。相反,PM2 需要重新启动应用,且服务停机时需要手动修复代码。

  4. 快速回滚:由于 Docker 镜像的版本化机制,你可以轻松回滚到之前的稳定版本。即便遇到部署失败的情况,只需要拉取旧版本的镜像,应用立即恢复,不需要手动操作,这为应用提供了更高的可靠性。

总结

PM2 部署中,如果代码有问题,必须手动修复并重启应用,这可能会导致 服务中断,并影响用户体验。而且每次更新都需要在服务器上重新构建项目,这可能导致环境不一致,增加了部署的复杂性。

Docker 部署则通过 容器化 确保应用和环境的一致性,部署失败时不会影响生产环境的运行,原有容器仍可继续工作,且通过 CI/CD 流程可以自动恢复和快速回滚,不需要人工干预。即使代码存在问题,原有的容器仍可保持正常服务,确保应用的高可用性。

因此,对于生产环境,Docker 提供了比 PM2 更稳定、高效、自动化的部署方式,尤其适合大规模、高可用的应用部署。

2025年,我为什么建议你先学React再学Vue?

2025年10月30日 07:27

你是不是刚准备入门前端开发,面对React和Vue两个热门框架却不知道如何选择?

看着招聘网站上React和Vue的职位要求,担心选错方向影响未来发展?

别担心,这篇文章就是为你准备的。我会用最直白的语言,带你快速体验两大框架的魅力,并告诉你为什么在2025年的今天,我强烈建议从React开始学起。

读完本文,你将获得两大框架的完整入门指南,还有可以直接复用的代码示例,帮你节省大量摸索时间。

先来看看React:简洁就是美

React的核心思想非常直接——用JavaScript构建用户界面。它不会强迫你学习太多新概念,而是充分利用你已经掌握的JavaScript知识。

让我们看一个最简单的计数器组件:

// 引入React和useState钩子
import React, { useState } from 'react';

// 定义计数器组件
function Counter() {
  // useState是React的核心特性,用于管理组件状态
  // count是当前状态值,setCount是更新状态的函数
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>你点击了 {count} 次</p>
      {/* 点击按钮时调用setCount更新状态 */}
      <button onClick={() => setCount(count + 1)}>
        点我加一
      </button>
    </div>
  );
}

export default Counter;

这段代码展示了React的几个关键特点:组件化、状态管理、声明式编程。你发现了吗?几乎就是纯JavaScript,加上一点类似HTML的JSX语法。

React的学习曲线相对平缓,因为你主要是在写JavaScript。这也是为什么很多公司在新项目中仍然首选React——它更接近编程的本质。

再看看Vue:贴心但需要适应

Vue的设计哲学完全不同,它提供了一套更完整的解决方案,包括模板语法、响应式系统等。

同样的计数器,用Vue 3的Composition API实现:

<template>
  <div>
    <p>你点击了 {{ count }} 次</p>
    <!-- 模板语法更接近原生HTML -->
    <button @click="increment">
      点我加一
    </button>
  </div>
</template>

<script setup>
// 引入ref函数
import { ref } from 'vue'

// 定义响应式数据
const count = ref(0)

// 定义方法
const increment = () => {
  count.value++
}
</script>

Vue的模板语法对初学者很友好,特别是如果你有HTML基础。但注意看,这里出现了新的概念:ref、.value、@click指令等。Vue创造了自己的一套规则,你需要先理解这些概念才能上手。

为什么我推荐先学React?

在2025年的今天,前端技术生态已经相当成熟。基于我的观察和实际项目经验,有三个理由支持先学React:

就业机会更多:打开任何招聘平台,React的职位数量通常是Vue的1.5-2倍。大型科技公司更倾向于使用React,这意味着更好的职业发展空间。

技术迁移成本低:学完React后,你会发现很多概念在其他框架中也通用。状态管理、组件化思想、虚拟DOM等知识都是可以迁移的。反过来,从Vue转到React会困难一些。

更接近现代JavaScript:React鼓励你使用最新的JavaScript特性,而不是框架特定的语法。这对你的长远发展更有帮助,毕竟框架会过时,但JavaScript不会。

真实项目中的代码对比

让我们看一个更实际的例子:用户列表组件。

React版本:

import React, { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  // useEffect处理副作用
  useEffect(() => {
    fetchUsers();
  }, []);

  const fetchUsers = async () => {
    try {
      const response = await fetch('/api/users');
      const data = await response.json();
      setUsers(data);
    } catch (error) {
      console.error('获取用户失败:', error);
    } finally {
      setLoading(false);
    }
  };

  if (loading) return <div>加载中...</div>;

  return (
    <div>
      <h2>用户列表</h2>
      {users.map(user => (
        <div key={user.id}>
          <span>{user.name}</span>
          <span>{user.email}</span>
        </div>
      ))}
    </div>
  );
}

Vue版本:

<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else>
      <h2>用户列表</h2>
      <div v-for="user in users" :key="user.id">
        <span>{{ user.name }}</span>
        <span>{{ user.email }}</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const users = ref([])
const loading = ref(true)

const fetchUsers = async () => {
  try {
    const response = await fetch('/api/users')
    const data = await response.json()
    users.value = data
  } catch (error) {
    console.error('获取用户失败:', error)
  } finally {
    loading.value = false
  }
}

// onMounted是生命周期钩子
onMounted(() => {
  fetchUsers()
})
</script>

注意到区别了吗?React更倾向于用JavaScript解决问题,而Vue提供了更多专用语法。从长远看,深入理解JavaScript比掌握框架语法更有价值。

学习路径建议

如果你决定接受我的建议从React开始,这是最有效的学习路径:

第一周:掌握React基础概念。JSX语法、组件定义、props传递、useState状态管理。不要急着学太多,把基础打牢固。

第二周:深入学习Hooks。useEffect、useContext、useReducer,理解React的数据流和生命周期。

第三周:构建完整项目。找一个实际需求,比如个人博客或者待办事项应用,把学到的知识用起来。

第四周:学习状态管理。了解Redux Toolkit或者Zustand,理解在复杂应用中如何管理状态。

完成这个月的学习后,你再回头看Vue,会发现很多概念都是相通的,学习成本大大降低。

但Vue就一无是处吗?

绝对不是。Vue在某些场景下表现非常出色:

如果你要快速开发中小型项目,Vue的完整生态和约定式配置能显著提升开发效率。

如果你的团队中新手开发者较多,Vue的模板语法和学习曲线确实更容易上手。

在2025年,Vue 3的Composition API让代码组织更加灵活,性能也相当优秀。它仍然是一个很棒的选择,只是从学习路径和职业发展的角度,我更推荐先掌握React。

实际开发中的小技巧

无论你选择哪个框架,这些技巧都能帮你少走弯路:

代码组织:保持组件小而专一。如果一个组件超过100行,考虑拆分。

状态管理:不要过度设计。先从useState开始,真正需要时再引入状态管理库。

性能优化:使用React.memo或Vue的computed属性避免不必要的重新渲染,但不要过早优化。

错误处理:一定要有错误边界,给用户友好的错误提示而不是白屏。

下一步该怎么做?

现在你应该对两大框架有了基本认识。我的建议是:

今天就创建一个React项目,把文章中的计数器例子跑起来。不要只看不练,亲手写代码的感觉完全不同。

遇到问题时,记住这是学习过程的正常部分。React和Vue都有优秀的官方文档和活跃的社区,你遇到的问题很可能已经有人解决过了。

学习框架只是开始,更重要的是理解背后的编程思想和设计模式。这些知识会让你在任何技术变革中都能快速适应。

技术会更新,生态会变化,但解决问题的能力才是你真正的核心竞争力。

力扣热题100(前10道题目)

2025年10月30日 01:10

前言

算法题几乎是面试必考的,许多同学一看到算法题就是一个头两个大,所以笔者这次准备把力扣热题100写成文章与jym一起学习,估计会分为10篇文章来写。在这个过程中会分享一些自己刷题的想法和思路,让大家能够轻松看懂,这些题目我会采用js来写,有看不懂js的同学可以看个思路然后换成自己熟悉的语言去写🔥 LeetCode 热题 HOT 100

在每道题目之前我都会把对应的题目链接贴出来,方便大家可以看完我的解法再去力扣上刷题,而且这些题目我会尽可能多种解法去写,大家可以参考一下。

160. 相交链表

给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null 。

图示两个链表在节点 c1 开始相交

题目数据 保证 整个链式结构中不存在环。

注意,函数返回结果后,链表必须 保持其原始结构 。

自定义评测:

评测系统 的输入如下(你设计的程序 不适用 此输入):

  • intersectVal - 相交的起始节点的值。如果不存在相交节点,这一值为 0
  • listA - 第一个链表
  • listB - 第二个链表
  • skipA - 在 listA 中(从头节点开始)跳到交叉节点的节点数
  • skipB - 在 listB 中(从头节点开始)跳到交叉节点的节点数

评测系统将根据这些输入创建链式数据结构,并将两个头节点 headA 和 headB 传递给你的程序。如果程序能够正确返回相交节点,那么你的解决方案将被 视作正确答案 。

 

示例 1:

输入: intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3
输出: Intersected at '8'
解释: 相交节点的值为 8 (注意,如果两个链表相交则不能为 0)。
从各自的表头开始算起,链表 A[4,1,8,4,5],链表 B[5,6,1,8,4,5]。
在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。
— 请注意相交节点的值不为 1,因为在链表 A 和链表 B 之中值为 1 的节点 (A 中第二个节点和 B 中第三个节点) 是不同的节点。换句话说,它们在内存中指向两个不同的位置,而链表 A 和链表 B 中值为 8 的节点 (A 中第三个节点,B 中第四个节点) 在内存中指向相同的位置。

 

示例 2:

输入: intersectVal = 2, listA = [1,9,1,2,4], listB = [3,2,4], skipA = 3, skipB = 1
输出: Intersected at '2'
解释: 相交节点的值为 2 (注意,如果两个链表相交则不能为 0)。
从各自的表头开始算起,链表 A[1,9,1,2,4],链表 B[3,2,4]。
在 A 中,相交节点前有 3 个节点;在 B 中,相交节点前有 1 个节点。
解法一
思路

如果我们从两个链表的最后一个节点往前遍历,那么相交的这个节点其实就是两个链表最后一个相等的节点,一旦不相等了,说明这个节点的前一个节点就是相交节点,所以我们可以把这两个链表分别放进两个数组里,然后倒序遍历

var getIntersectionNode = function (headA, headB) {
    const list1 = []
    const list2 = []
    while (headA) {
        list1.push(headA)
        headA = headA.next
    }
    while (headB) {
        list2.push(headB)
        headB = headB.next
    }
    let i = list1.length - 1
    let j = list2.length - 1
    let temp
    while (i && j && list1[i] === list2[j]) {
        temp = list1[i]
        i--
        j--
    }
    return temp
};

因为新增了数组,所以这其实并不是最优解,他的时间复杂度和空间复杂度都为O(m+n),m为headA的长度,n为headB的长度。

解法二
思路

:双指针法,先初始化两个指针p = headA,q = headB,然后不断循环直到p = q,每次循环让pq各向后走一步,当p为空时,让pheadBq为空时,qheadA,在循环结束时,如果两个链表相交,那么pq都会在相交的起始节点处就可以返回p了,如果不相交,那么他们都到空节点了,也可以返回p,即空节点

代码如下:

var getIntersectionNode = function (headA, headB) {
    let p = headA
    let q = headB
    while (p !== q) {
        p = p ? p.next : headB
        q = q ? q.next : headA
    }
    return p
};

这种解法的时间复杂度为O(m+n),空间复杂度为O(1)

236. 二叉树的最近公共祖先

示例 1:

输入: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出: 3
解释: 节点 5 和节点 1 的最近公共祖先是节点 3

示例 2:

输入: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出: 5
解释: 节点 5 和节点 4 的最近公共祖先是节点 5 。因为根据定义最近公共祖先节点可以为节点本身。

示例 3:

输入: root = [1,2], p = 1, q = 2
输出: 1
思路

如果当前节点是空节点,就返回当前节点,如果当前节点是p或者q也就返回当前节点,当左右子树都能找到时,他们的公共祖先也就是当前节点,只有左子树能找到就返回左子树递归的结果,只有右子树能找到就返回右子树递归的结果。

var lowestCommonAncestor = function (root, p, q) {
    if (!root || root === p || root === q) return root
    let left = lowestCommonAncestor(root.left, p, q)
    let right = lowestCommonAncestor(root.right, p, q)
    if (left && right) return root
    if (left) return left
    if (right) return right
};

234. 回文链表

给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false 。

 

示例 1:

输入: head = [1,2,2,1]
输出: true

示例 2:

输入: head = [1,2]
输出: false

 

提示:

  • 链表中节点数目在范围[1, 105] 内
  • 0 <= Node.val <= 9

 

进阶: 你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?

解法一
思路

将链表的每个值都存进数组里,然后使用双指针来比较前后两端的元素,一个指针从起点向中间移动看,另一个指针从终点向中间移动。

var isPalindrome = function (head) {
    const res = []
    while (head) {
        res.push(head.val)
        head = head.next
    }
    for (let i = 0, j = res.length - 1; i < j; i++, j--) {
        if (res[i] !== res[j]) return false
    }
    return true
};

由于要逐个访问n个元素,所以他的时间复杂度和空间复杂度都是O(n),来看下面的这个解法,就能让他的空间复杂度变成O(1)。

解法二
思路

找到中间节点并反转链表,如果链表有奇数个节点,就找他正中间的节点,如果链表有偶数个节点,就找他正中间右边的节点。

image.png

image.png 找到中间节点后把中间节点到链表末尾反转如上图所示,把他的头节点记为head2,然后从head2开始,依次遍历原链表的最后一个节点、倒数第二个节点...,最后同时遍历head和head2,循环比较两个值是否相等,相等就返回true,不等就返回false,循环结束后如果没有返回false,就说明链表是回文的。用这个解法前可以先刷下这两道题目 876. 链表的中间结点206. 反转链表

function midNode(head) {
    let slow = head
    let fast = head
    while (fast && fast.next) {
        slow = slow.next
        fast = fast.next.next
    }
    return slow
}
function reverseList(head) {
    let p1 = head
    let p2 = null
    while (p1) {
        let tem = p1.next
        p1.next = p2
        p2 = p1
        p1 = tem
    } return p2
}
var isPalindrome = function (head) {
    const mid = midNode(head)
    let head2 = reverseList(mid)
    while (head2) {
        if (head.val !== head2.val) return false
        head = head.next
        head2 = head2.next
    }
    return true
};

739. 每日温度

给定一个整数数组 temperatures ,表示每天的温度,返回一个数组 answer ,其中 answer[i] 是指对于第 i 天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0 来代替。

 

示例 1:

输入: temperatures = [73,74,75,71,69,72,76,73]
输出: [1,1,4,2,1,1,0,0]

示例 2:

输入: temperatures = [30,40,50,60]
输出: [1,1,1,0]

示例 3:

输入: temperatures = [30,60,90]
输出: [1,1,0]

 

提示:

  • 1 <= temperatures.length <= 105
  • 30 <= temperatures[i] <= 100
解法一
思路

直接暴力解法,对于每个温度,向后遍历查找第一个比他高的温度,然后计算间隔天数,用一个数组来存放0,对于比他高的温度后者-前者就是这个天数

/**
 * @param {number[]} temperatures
 * @return {number[]}
 */
var dailyTemperatures = function (temperatures) {
    const res = new Array(temperatures.length)
    for (let i = 0; i < temperatures.length; i++) {
        res[i] = 0
        for (let j = i + 1; j < temperatures.length; j++) {
            if (temperatures[j] > temperatures[i]) {
                res[i] = j - i
                break
            }
        }
    }
    return res
};

这种解法是最直观的,但是这种解法在力扣上提交会超时 ,因为他的时间复杂度是O(n²),而力扣上的测试用例包含长度高达10⁵的数组,在最坏情况下要执行10¹⁰次比较操作,所以会超时

解法二
思路

单调栈,不主动寻找每个温度后面的第一个高温,当遇到高温时,回头解决之前没有解决的低温问题,用栈来存储温度索引,保持栈中的温度值单调递增。

/**
 * @param {number[]} temperatures
 * @return {number[]}
 */
var dailyTemperatures = function (temperatures) {
    const res = new Array(temperatures.length).fill(0)
    const stack = []
    for (let i = 0; i < temperatures.length; i++) {
        while (stack && temperatures[i] > temperatures[stack[stack.length - 1]]) {
            const index = stack.pop()
            res[index] = i - index
        }
        stack.push(i)
    }
    return res
};

226. 翻转二叉树

给你一棵二叉树的根节点 root ,翻转这棵二叉树,并返回其根节点。

 

示例 1:

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

输入: root = [4,2,7,1,3,6,9]
输出: [4,7,2,9,6,3,1]

示例 2:

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

输入: root = [2,1,3]
输出: [2,3,1]

示例 3:

输入: root = []
输出: []

 

提示:

  • 树中节点数目范围在 [0, 100] 内
  • -100 <= Node.val <= 100
思路

用深度优先搜索(DFS)递归的交换每个节点的左右子节点,核心就是利用分治思想将大问题分解为小问题,然后递归的解决每个子问题

var invertTree = function (root) {
    if (!root) return null
    return {
        val: root.val,
        left: invertTree(root.right),
        right: invertTree(root.left)
    }
};

221. 最大正方形

在一个由 '0' 和 '1' 组成的二维矩阵内,找到只包含 '1' 的最大正方形,并返回其面积。

 

示例 1:

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

输入: matrix = [["1","0","1","0","0"],["1","0","1","1","1"],["1","1","1","1","1"],["1","0","0","1","0"]]
输出: 4

示例 2:

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

输入: matrix = [["0","1"],["1","0"]]
输出: 1

示例 3:

输入: matrix = [["0"]]
输出: 0

 

提示:

  • m == matrix.length
  • n == matrix[i].length
  • 1 <= m, n <= 300
  • matrix[i][j] 为 '0' 或 '1'
思路

用动态规划来解决,以每个位置为右下角,计算能形成的最大正方形的边长,用dp[i][j]来表示以(i,j)为右下角顶点的,只包含'1'的最大正方形的边长,其状态转移方程为 dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1,比如说要形成一个边长为k的正方形,就必须满足上方能形成变成为k-1的正方形,左方能形成边长为k-1的正方形,左上方能形成边长为k-1的正方形,只有这三个条件都满足时,才能在当前位置扩展成边长为k的正方形

var maximalSquare = function (matrix) {
    let maxSideLen = 0
    let dp = new Array(matrix.length)
    for (let i = 0; i < matrix.length; i++) {
        dp[i] = new Array(matrix[i].length).fill(0)
        for (let j = 0; j < matrix[i].length; j++) {
            if (matrix[i][j] === '1') {
                if (i === 0 || j === 0) {
                    dp[i][j] = 1
                } else {
                    dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1],dp[i - 1][j - 1]) + 1
                }
                maxSideLen = Math.max(maxSideLen, dp[i][j])
            }
        }
    }
    return maxSideLen * maxSideLen
}

215. 数组中的第K个最大元素

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。

 

示例 1:

输入: [3,2,1,5,6,4], k = 2
输出: 5

示例 2:

输入: [3,2,3,1,2,4,5,5,6], k = 4
输出: 4

 

提示:

  • 1 <= k <= nums.length <= 105
  • -104 <= nums[i] <= 104
思路

可以使用快速选择的思想来解决,要找第k个最大元素,其实就相当于找第(n-k)小的元素,我们可以先选中一个基准值,然后使用双指针从数组两端向中间扫描,将小于基准值的元素移到左边,大于基准值的元素移到右边,完成一次分区操作后,再根据目标元素应该所在区域来决定继续在左半部分还是右半部分进行递归搜索,这样的话每次都能排除掉一部分元素而不需要处理整个数组

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function (nums, k) {
  const n = nums.length;
  return quickSelect(nums, 0, n - 1, n - k); // n-1是数组长度-1,n-k是第k大的元素对应的下标
};

function quickSelect(nums, l, r, k) {
  // l 和 r 是左右边界,k 是要找的第 k 小元素的下标
  if (l === r) return nums[k];
  const x = nums[l];
  let i = l - 1,
    j = r + 1; // 因为下面的do while循环会先自增或自减一次,所以这里要-1和+1
  while (i < j) {
    // i<j 说明还没有扫描完的数组
    do i++;
    while (nums[i] < x);
    do j--;
    while (nums[j] > x);
    if (i < j) {
      [nums[i], nums[j]] = [nums[j], nums[i]];
    }
  }
  if (k <= j) {
    return quickSelect(nums, l, j, k);
  } else {
    return quickSelect(nums, j + 1, r, k);
  }
}

208. 实现 Trie (前缀树)

207. 课程表

206. 反转链表

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

 

示例 1:

输入: head = [1,2,3,4,5]
输出: [5,4,3,2,1]

示例 2:

输入: head = [1,2]
输出: [2,1]

示例 3:

输入: head = []
输出: []
思路

迭代法,先创建一个空链表,然后用头插法依次把节点插入到这个新链表的头部,就得到了反转后的链表

var reverseList = function (head) {
    let p1 = head
    let p2 = null
    while (p1) {
        let temp = p1.next
        p1.next = p2
        p2 = p1
        p1 = temp
    }
    return p2
};

函数组件和异步组件

2025年10月29日 22:53

“函数式组件” 和 “异步组件” 是 Vue 中两种不同定位的组件形态,前者通过 “无状态、无实例” 精简渲染流程,后者通过 “按需加载” 减少初始资源体积,二者从不同维度优化性能,具体解析如下:

一、函数式组件:无状态、无实例的 “轻量渲染器”

1. 核心定义

函数式组件是 仅接收 props 和 context 作为参数、无自身状态(无 data/reactive)、无组件实例(无 this)、无生命周期钩子 的组件,本质是一个 “纯函数”—— 输入 props 后直接返回虚拟 DOM,不参与组件实例的创建和挂载流程。

在 Vue 2 中需通过 functional: true 声明,Vue 3 中则直接用 “无 <script setup> 的单文件组件” 或 “返回虚拟 DOM 的函数” 实现,例如:

组件UserCard

<!-- Vue 3 函数式组件:仅渲染,无状态 -->
<template functional>
  <div class="user-card">
    <img :src="props.avatar" alt="用户头像" />
    <div>{{ props.name }}</div>
  </div>
</template>

<script>
// 也可通过 JS 定义:接收 props,返回虚拟 DOM
export default function UserCard(props) {
  return h('div', { class: 'user-card' }, [
    h('img', { src: props.avatar, alt: '用户头像' }),
    h('div', props.name)
  ]);
}
</script>

2. 函数组件≠jsx

函数组件可以用jsx写,jsx只是一种语法,函数组件的强调在状态和实例

举个例子:

// 用 JSX 写的普通组件(有状态,不是函数组件)
import { ref } from 'vue';

export default () => {
  // 有自身状态(count),不符合函数组件“无状态”特征
  const count = ref(0);

  // 有自身事件处理逻辑
  const handleAdd = () => {
    count.value++;
  };

  // JSX 渲染,但组件是普通组件
  return (
    <div>
      <span>计数:{count.value}</span>
      <button onClick={handleAdd}>+1</button>
    </div>
  );
};

2. 性能提升原理:跳过 “组件实例创建” 流程

Vue 普通组件的渲染需经历 “创建组件实例 → 初始化状态 → 执行生命周期 → 渲染虚拟 DOM” 等完整流程,而函数式组件会 跳过 “实例创建” 和 “状态初始化” 步骤,直接根据 props 生成虚拟 DOM,减少内存占用和渲染耗时。

3. 适用场景:纯展示、高频复用的轻量组件

仅当组件满足 “无状态、仅渲染” 时,用函数式组件才能提升性能,典型场景:

  • 列表项组件:如表格行、列表项(v-for 循环渲染几十上百个的场景,减少实例数量);
  • 纯展示组件:如标签(Tag)、头像(Avatar)、按钮组(ButtonGroup)等无交互或仅触发父组件事件的组件;
  • 高阶组件包装:如用于封装逻辑、生成新组件的 “容器组件”(无自身状态,仅转发 props)。

注意:若组件需状态(如 ref/reactive)、生命周期(如 onMounted)或复杂交互(如内部事件处理),则不适合用函数式组件 —— 强行使用会导致代码复杂度上升,反而抵消性能优势。

二、异步组件:按需加载的 “延迟渲染组件”

1. 核心定义

异步组件是 不在初始渲染时加载,而是在 “需要时”(如路由跳转、条件渲染触发)才动态加载组件代码 的组件,本质是通过 “代码分割” 将组件打包成独立的 chunk 文件,避免初始包体积过大。

Vue 3 中通过 defineAsyncComponent 声明,Vue 2 中通过 “返回 Promise 的函数” 声明,例如:

// Vue 3 异步组件:路由跳转时才加载 UserDetail 组件
import { defineAsyncComponent } from 'vue';
import { Loading, ErrorComponent } from './components';

// 定义异步组件,指定加载函数、加载中/加载失败占位组件
const UserDetail = defineAsyncComponent({
  loader: () => import('./UserDetail.vue'), // 动态导入,打包成独立 chunk
  loadingComponent: Loading, // 加载中显示的组件
  errorComponent: ErrorComponent, // 加载失败显示的组件
  delay: 200, // 延迟 200ms 显示加载组件(避免闪屏)
  timeout: 3000 // 3 秒加载超时则显示错误组件
});

// 路由配置中使用:访问 /user/:id 时才加载 UserDetail
const routes = [
  { path: '/user/:id', component: UserDetail }
];

2. 性能提升原理:减少初始资源体积

普通组件会被打包到主包(如 app.js)中,若项目包含大量组件(如几十个页面组件),会导致初始包体积过大(如超过 2MB),首屏加载时间变长;而异步组件会 被单独打包成小 chunk(如 UserDetail.[hash].js ,初始加载时仅下载主包,需要时再通过网络请求加载组件 chunk,从而减少首屏加载时间和初始内存占用。

三、核心差异

维度 函数式组件 异步组件
核心特性 无状态、无实例、同步渲染 有状态 / 无状态均可、异步加载、延迟渲染
性能优化点 减少组件实例创建开销,提升渲染速度 减少初始包体积,提升首屏加载速度
适用组件类型 纯展示、高频复用的轻量组件 非首屏、条件触发的重量级组件
代码分割 不涉及代码分割,组件代码在主包中 强制代码分割,组件代码在独立 chunk 中

怎么理解函数式组件会 跳过 “实例创建” 和 “状态初始化” 步骤,呢?

Vue 中普通组件和函数式组件的渲染流程差异,本质是 “是否创建组件实例” 导致的流程分支。下面从源码的角度去拆分下这个过程:

一、普通组件的完整渲染流程(包含实例创建)

普通组件的渲染是一个 “从组件定义到 DOM 挂载” 的完整生命周期,可分为 5 个核心步骤,每一步都和 “组件实例” 强绑定:

1. 解析组件定义,准备创建实例

当 Vue 解析到模板中的组件标签(如 <UserForm>)时,会先读取该组件的选项定义(data/methods/computed 等),然后调用 Vue 内部的 createComponentInstance 方法,初始化一个组件实例对象(VNode 组件实例) 。这个实例对象会包含:

  • 基础属性:uid(唯一标识)、vnode(虚拟 DOM 节点)、parent(父实例)等;
  • 状态容器:ctx(上下文,用于存放 data/props 等)、setupState(组合式 API 的状态)等;
  • 方法引用:emit 方法、生命周期钩子队列等。

2. 初始化组件状态(实例的核心工作)

实例创建后,Vue 会执行 initComponent 方法,为实例 “注入” 状态和能力:

  • 处理 props:将父组件传入的 props 解析后挂载到实例的 ctx 中(如 this.props.name);
  • 初始化 data:执行 data() 函数,将返回的对象通过 reactive 转为响应式数据,挂载到实例(如 this.count);
  • 绑定 computed/watch:将计算属性和监听器与实例关联,依赖收集时绑定到实例的更新逻辑;
  • 处理生命周期:将 mounted/updated 等钩子函数添加到实例的钩子队列,等待触发时机。

3. 执行初始化生命周期钩子

实例状态准备好后,Vue 会按顺序执行初始化阶段的生命周期钩子:

  • beforeCreate:此时 props 和 data 尚未挂载到实例,无法访问;
  • createdprops 和 data 已初始化,可通过 this 访问,但 DOM 尚未生成。

4. 渲染虚拟 DOM(VNode)

初始化完成后,Vue 调用实例的 render 方法(模板会被编译为 render 函数),生成组件的虚拟 DOM 树(VNode)。这个过程中,render 函数通过 this 访问实例上的 props/data(如 this.name),最终生成描述 DOM 结构的 VNode 对象(包含标签名、属性、子节点等信息)。

5. 挂载真实 DOM,执行挂载生命周期

  • 虚拟 DOM 转真实 DOM:Vue 调用 patch 方法,将 VNode 转换为真实 DOM 节点,并插入到父组件的 DOM 中;
  • 执行挂载钩子:触发 beforeMount → 真实 DOM 挂载完成 → 触发 mounted
  • 实例关联 DOM:实例的 el 属性(Vue 2)或 vnode.el(Vue 3)指向真实 DOM,方便后续更新。

二、函数式组件的渲染流程(跳过实例创建)

函数式组件因为 “无状态、无实例”,流程被大幅简化,直接跳过 “实例创建” 和 “状态初始化”,仅保留 “输入 props → 输出 VNode” 的核心步骤:

1. 解析组件定义,确认函数式标识

当 Vue 解析到函数式组件(如 <template functional> 或返回 VNode 的函数)时,会识别其 “函数式” 标识(Vue 3 中通过 functional: true 或无状态函数判断),直接进入轻量渲染流程。

2. 直接接收 props 和上下文(无实例,无初始化)

  • 函数式组件没有实例,所以不需要创建 componentInstance 对象;

  • 父组件传入的 props 和上下文(slots/emit 等)会被直接打包成参数,传递给渲染函数(模板或 JSX 函数)。例如:

    • 模板式函数组件中,通过 props.xxx 直接访问数据,context.emit 触发事件;
    • JSX 函数组件中,函数参数直接接收 props 和 context(props, context) => { ... })。

3. 生成虚拟 DOM(直接渲染,无生命周期)

函数式组件的 “渲染函数”(模板编译后的函数或 JSX 函数)会直接使用 props 和 context 生成 VNode,过程中:

  • 不需要访问 this(因为没有实例);
  • 不需要处理响应式数据初始化(props 由父组件传入,已在父组件中完成响应式处理);
  • 没有生命周期钩子(无需执行 created/mounted 等)。

4. 挂载真实 DOM(复用父组件的挂载流程)

生成的 VNode 会直接进入父组件的 patch 流程,和父组件的其他节点一起被转换为真实 DOM。因为没有实例,所以 DOM 挂载后也不会触发任何生命周期钩子,完成渲染后即结束。

三、一句话总结

普通组件的渲染是 “先创建一个‘管理者(实例)’,由管理者统筹状态、生命周期和渲染”,流程完整但冗余;函数式组件的渲染是 “无管理者,直接用输入数据生成输出结果”,跳过所有和实例相关的步骤,因此更轻量、更快。

CocosCreator 游戏开发 - 利用 AssetsBundle 技术对小游戏包体积进行优化

2025年10月29日 22:31

总所周知,小游戏因为是依附于别的应用软件平台,因为不是一个独立的应用,始终或多或少会收到原依附平台的限制,其中游戏包体积应该大部分的开发者都会遇到过。

其中例如图片、音乐等一些静态资源则可以使用服务器远程加载,但是更多的游戏配置数据和游戏逻辑代码则不能通过远程服务器进行动态加载。一方面为了将小游戏的包体积压缩至平台限定的大小,另一方面为了小游戏的首屏加载时间尽可能短首次加载的代码包体积也应该足够小。

在 Cocos Creator 当中肯定也提供了这方面应对处理的方式,那就是 AssetsBundle 这个小游戏分包技术。

Asset Bundle 介绍 | Cocos Creator

  • 因为 AssetsBundle 写起来文字太多了,后续 AB 包这个简称就是代指 AssestBundle 包。

和小程序分包类似,不过这个是针对小游戏的分包操作。

顺带这里贴个很久之前工作上关于小程序分包优化的历程记录文章:juejin.cn/post/713991…

Cocos Creator 当中想要设置小游戏分包处理和小程序的有不同的操作处理。

设置分包目录

首先需要在 CocosCreator 引擎编辑器当中设置目录作为分包目录:

如图所示,在这里这个 Bundle 勾选项是设置该目录是否需要作为分包进行处理:

勾选了 Bundle 勾选项,后会有相关的一些分包配置:

  • Bundle 分包标识,后续会有相关逻辑需要根据这个标识进行查找这个 Bundle 分包的逻辑;
  • Bundle 分包加载优先级;

拉取 AB 分包资源

设置分包配置目录后,在小游戏运行当中针对 AB 包是并不能直接进行调用使用的,需要先进行拉取加载分包资源先。

  • 这里需要引入 CocosCreator 引擎的 assetManager 依赖调用 assetManager.loadBundle 方法传入前面设置分包包名来进行对应 Bundle 包的加载。
import { assetManager } from 'cc';

assetManager.loadBundle('common', (err, bundle) => {
  // 跟 Node.js 的异步处理方法类似,回调当中 err 参数没信息则证明加载分包成功了
  if (!err) {
    console.warn(bundle)
  }
});
  • 加载成功之后分包 Bundle 除了在回调当中能访问到 Bundle 对象外,还能通过 assetManager.getBundle api 传入分包 Bundle 名字,来进行读取已加载缓存的分包 Bundle 对象。
const commonModuleBundle = assetManager.getBundle('common');

因此我们能够将加载分包和获取 Bundle 的逻辑封装起来一个异步 Promise 方法,方便调用拉取分包获取 Bundle 处理:

// /utils/LoadUtils.ts

import { assetManager, AssetManager } from 'cc';

export const getAssetBundle = (bundleKey: string): Promise<AssetManager.Bundle> => {
    return new Promise((resolve, reject) => {
        if (assetManager.getBundle(bundleKey)) return resolve(assetManager.getBundle(bundleKey));
        return assetManager.loadBundle(bundleKey, (err, bundle) => {
            if (!err) return resolve(bundle);
            return reject(err);
        });
    });
}

读取加载分包的内容

在分包资源拉取后,这时候才是和正常 CocosCreator 的 Resources 加载内容操作类似,只不过这时候就要用前面加载完成的分包 Bundle 对象调用相关加载内容的 api。

  • 相关的加载/预加载/批量加载目录内容的 api 形式也是和 resources 动态加载类似,这里就简单贴几个 eg,具体可以参考对应的 resources 动态加载内容的官方文档:docs.cocos.com/creator/3.8…
// 加载 Prefab
bundle.load(`prefab`, Prefab, function (err, prefab) {
    let newNode = instantiate(prefab);
    director.getScene().addChild(newNode);
});

// 加载 Texture
bundle.load(`image/texture`, Texture2D, function (err, texture) {
    console.log(texture)
});

// 加载 textures 目录下的所有资源
bundle.loadDir("textures", function (err, assets) {
    // ...
});

// 加载 textures 目录下的所有 Texture 资源
bundle.loadDir("textures", Texture2D, function (err, assets) {
    // ...
});

// 预加载
bundle.preload('images/background/spriteFrame', SpriteFrame);
bundle.load('images/background/spriteFrame', SpriteFrame, function (err, spriteFrame) {
    spriteFrame.addRef();
    self.getComponent(Sprite).spriteFrame = spriteFrame;
});

这里给自己开发的一个微信小游戏做下引流,微信搜索 “坦克幸存者” 或者来扫下面的小游戏码多来玩玩小游戏来支持下这位可怜贫困的前端搬砖仔。

wechat-search.png

昨天 — 2025年10月29日掘金 前端

掌握 Stylus:让 CSS 编写效率倍增的预处理器

作者 3秒一个大
2025年10月29日 18:48

掌握 Stylus:让 CSS 编写效率倍增的预处理器

在前端开发中,CSS 作为样式定义的基础语言,其编写效率和可维护性直接影响项目开发效率。而 Stylus 作为一款强大的 CSS 预处理器,凭借其简洁的语法和丰富的特性,成为提升 CSS 开发体验的利器。本文将带你深入了解 Stylus 的优势与用法,助你快速掌握这一高效工具。

什么是 Stylus?

Stylus 是一门富有表现力的 CSS 预处理器,它在传统 CSS 的基础上扩展了诸多实用功能,如变量、函数、混合(mixins)、嵌套等。这些特性不仅能大幅减少重复代码,还能让样式结构更加清晰,显著提升 CSS 的编写效率和可维护性。

需要注意的是,浏览器无法直接解析 Stylus 代码,因此必须将其编译为标准 CSS 后才能在网页中使用。

快速开始:安装与编译

安装 Stylus

通过 npm 可以轻松全局安装 Stylus:

bash

npm i -g stylus

编译 Stylus 文件

将 Stylus 文件编译为 CSS 文件的基本命令:

bash

stylus style.styl -o style.css

若需要实时编译(边写边编译),可使用 watch 模式:

bash

stylus style.styl -o style.css -w

Stylus 的核心优势

简洁的语法

相比传统 CSS,Stylus 语法更为简洁,可省略大括号、冒号和分号:

stylus

// Stylus 写法
.card
    width 45px
    height 45px

编译后生成的 CSS:

css

.card {
    width: 45px;
    height: 45px;
}

强大的嵌套功能

Stylus 支持选择器嵌套,使样式结构与 HTML 结构保持一致,增强代码的可读性和维护性:

stylus

.panel
    background #fff
    padding 10px
    
    &.active  // 表示与上级选择器同一级的 .panel.active
        border 1px solid #000
        
    .title
        font-size 16px

提升 CSS 的编程能力

Stylus 为 CSS 增添了编程特性,包括变量定义、函数、混合等,同时提供了模块化能力和作用域控制,还能自动为 CSS 属性添加浏览器前缀,解决兼容性问题。

实用布局与动画技巧

弹性布局(Flexbox)

弹性布局是移动端布局的主流方案,通过以下属性可实现灵活的父子元素布局:

  • display: flex:创建弹性布局上下文
  • 子元素会失去块级元素默认的换行特性,适合多列布局
  • 主轴对齐:justify-content
  • 侧轴对齐:align-items
  • 主轴方向:flex-direction(默认 row 水平方向,可选 column 垂直方向)
  • 子元素设置 flex: 1 可实现等比例分配空间

过渡动画(Transition)

相比 animation,transition 更简单,无需定义 keyframes,可直接为属性变化添加过渡效果:

stylus

.element
    transition all 700ms ease-in  // 所有属性变化,700ms 过渡时间,ease-in 缓动函数
    
    // 也可指定单个属性及延迟时间
    transition opacity 300ms ease-in 400ms  // 延迟 400ms 后开始,300ms 完成透明度过渡

响应式布局(媒体查询)

通过 @media 可实现不同设备的适配:

stylus

// 针对宽度小于等于 480px 的设备(如 iPhone 等移动设备)
@media (max-width: 480px)
    .container
        width 100%
        padding 5px

总结

Stylus 作为一款优秀的 CSS 预处理器,通过简化语法、增加编程特性和提供丰富功能,极大地提升了 CSS 开发效率。无论是小型项目还是大型应用,使用 Stylus 都能让样式代码更加简洁、可维护性更强。掌握 Stylus,将为你的前端开发工作带来显著的效率提升。

app里video层级最高导致全屏视频上的操作的东西显示不出来的问题

作者 Crystal328
2025年10月29日 18:43

为什么出现这个问题?

UniApp 在 App 端渲染页面时,用的是一个 原生 WebView(当做浏览器看待,手机系统来展示html界面) + 原生组件混合层

  • 页面(HTML、Vue 组件)在 WebView 层
  • <video><map><canvas> 等是 原生控件层
  • 原生层始终盖在 WebView 上面;
  • CSS 的 z-indexposition: fixedoverflow 对原生控件层 完全无效
  • 不管你怎么用 z-index 调整,只要视频是原生控件,它永远在最上面。

需求效果

3df355ab-db26-4d83-97b2-523965e34cf1.jpg

6a774578-fb93-47e9-89f4-5617ac726035.jpg

解决方法

使用subNVue原子窗体开发
uni-app subNVue 原生子窗体开发指南 - DCloud问答
pages.json 页面路由 | uni-app官网(ask.dcloud.net.cn/article/359…)
subNVue是 vue 页面的子窗体,它不是全屏页面,就是用于解决 vue 页面中的层级覆盖和原生界面自定义用的。它也不是组件,就是一个原生子窗体

1. 创建主页面和操作栏页面

项目结构

pages/index/
├── index.vue          # 主页面(视频全屏播放层)
└── subNVue/
    ├── overlay.nvue   # 悬浮UI层(顶部导航 + 底部操作栏 + 用户信息)
    └── comment.nvue   # 评论弹出层(评论列表和交互)

2. pages.json 配置

 {
  "path" : "pages/index/index",
    "style": {
      "navigationStyle": "custom", 
      "app-plus": {
        "subNVues": [
          {
            "id": "overlay",
            "path": "pages/index/subnvue/overlay",
            "style": {
              "position": "absolute",
              "top": 0,
              "left": 0,
              "width": "100%",
              "height": "100%",
              "background": "transparent"
            }
          },
          {
            "id": "commentPopup",
            "path": "pages/index/subnvue/comment",
            "style": {
              "position": "dock",
              "dock": "bottom",
              "width": "100%",
              "height": "900px",
              "background": "transparent"
            }
          }
        ]
      }
  }
  }

3. 主页面 index.vue

只保留视频和顶部导航

  <template>
<view class="video-page">
<!-- 视频全屏播放层 -->
<video 
class="video-bg" 
src="/static/video.mp4"
autoplay 
loop 
:show-fullscreen-btn="false"
:show-center-play-btn="false"
:controls="false"
enable-play-gesture
objectFit="cover"
></video>
</view>
</template>

<script setup>
import { onReady } from '@dcloudio/uni-app'

let overlaySubNVue = null
let commentSubNVue = null

onReady(() => {
// #ifdef APP-PLUS
// 获取并显示overlay层
overlaySubNVue = uni.getSubNVueById('overlay')
if (overlaySubNVue) {
overlaySubNVue.show('none')
console.log('✅ overlay层已显示')
}

// 获取comment层
commentSubNVue = uni.getSubNVueById('commentPopup')
if (commentSubNVue) {
// 先显示再立即隐藏,确保层初始化
commentSubNVue.show('none')
setTimeout(() => {
commentSubNVue.hide('none')
console.log('✅ comment层已初始化并隐藏')
}, 50)
}
// #endif
})
</script>

<style scoped>
.video-page {
width: 100%;
height: 100vh;
background:  rgba(0, 0, 0, 0.9);
}

.video-bg {
width: 100%;
height: 90%;
}
</style>
  

4. 子窗体

4.1 导航栏和底部内容 overlay.nvue

<template>
  <view class="overlay-container">
    <!-- 顶部导航栏 -->
    <view class="top-nav">
      <view class="back-btn" @click="goBack">
        <image src="/static/video/Frame@2x.png" class="back-icon"></image>
      </view>
    </view>

    <!-- 底部内容容器 -->
    <view class="bottom-wrapper">
      <!-- 用户和描述区域 -->
      <view class="content-area">
        <!-- 用户信息 -->
        <view class="user-row">
          <image :src="postData.userAvatar" class="user-avatar" @click="goToUserProfile"></image>
          <text class="user-name" @click="goToUserProfile">{{ postData.username }}</text>
          <view class="btn-follow" :class="{ 'followed': postData.isFollowed }" @click="handleFollow">
            <image 
              v-if="!postData.isFollowed"
              src="/static/video/Frame 2033196032@2x.png" 
              class="btn-follow-img"
            ></image>
            <text v-else class="btn-follow-text">已关注</text>
          </view>
        </view>

        <!-- 描述文字 -->
        <text class="desc-text">{{ postData.content }}</text>
      </view>
    </view>

    <!-- 底部操作栏(移到最外层) -->
    <view class="bottom-action">
      <view class="input" @click="handleComment">
        <text class="input-placeholder">说点什么吧~</text>
      </view>
      
      <view class="actions">
        <view class="action" @click="handleLike">
          <image 
            :src="postData.isLiked ? '/static/video/喜欢 red@2x.png' : '/static/video/喜欢 (4) 1@2x.png'" 
            class="action-icon"
          ></image>
          <text class="action-num">{{ postData.likeCount }}</text>
        </view>

        <view class="action" @click="handleCollect">
          <image 
            :src="postData.isCollected ? '/static/home/收藏 (5) 1@2x.png' : '/static/home/收藏.png'" 
            class="action-icon"
          ></image>
          <text class="action-num">{{ postData.collectCount }}</text>
        </view>
        
        <view class="action" @click="handleComment">
          <image src="/static/video/评论 (1) 1@2x.png" class="action-icon"></image>
          <text class="action-num">{{ postData.commentCount }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref, reactive } from 'vue'

// 容器高度
const containerHeight = ref(500)

// 文章数据
const postData = reactive({
  userAvatar: '/static/video/Ellipse 216@2x.png',
  username: '栗子',
  isFollowed: false,
  content: '早上出来散步,湖边那排柳树都发芽了!看着真清爽,我家小区的树还没冒绿呢。',
  likeCount: 145,
  collectCount: 86,
  commentCount: 76,
  isLiked: false,
  isCollected: false
})

// 返回
const goBack = () => {
  uni.navigateBack()
}

// 跳转个人主页
const goToUserProfile = () => {
  uni.navigateTo({
    url: '/pages/home/home'
  })
}

// 关注/取消关注
const handleFollow = () => {
  postData.isFollowed = !postData.isFollowed
  const title = postData.isFollowed ? '关注成功' : '已取消关注'
  uni.showToast({ title, icon: 'none' })
}

// 点赞
const handleLike = () => {
  postData.isLiked = !postData.isLiked
  postData.likeCount += postData.isLiked ? 1 : -1
}

// 收藏
const handleCollect = () => {
  postData.isCollected = !postData.isCollected
  postData.collectCount += postData.isCollected ? 1 : -1
  
  if (postData.isCollected) {
    // 收藏成功,显示短时间提示
    uni.showToast({ 
      title: '收藏成功', 
      icon: 'none',
      duration: 1500
    })
  } else {
    // 取消收藏,显示长时间提示(不自动消失需要手动关闭)
    uni.showToast({ 
      title: '取消收藏', 
      icon: 'none',
      duration: 1000 // 1秒后自动消失
    })
  }
}

// 打开评论弹窗
const handleComment = () => {
  // #ifdef APP-PLUS
  // 1. 隐藏overlay层
  const overlaySubNVue = uni.getSubNVueById('overlay')
  if (overlaySubNVue) {
    overlaySubNVue.hide()
    console.log('✅ overlay层已隐藏')
  }
  
  // 2. 显示评论弹窗
  const commentSubNVue = uni.getSubNVueById('commentPopup')
  if (commentSubNVue) {
    commentSubNVue.show('slide-in-bottom', 300)
    console.log('✅ 评论弹窗已打开')
  } else {
    console.error('❌ 未找到评论弹窗')
  }
  // #endif
}
</script>

<style>
.overlay-container {
  position: absolute;
  top: 0;
  left: 0;
  width: 750rpx;
  height: 1624rpx;
  flex: 1;
}

/* 顶部导航 */
.top-nav {
  position: absolute;
  top: 88rpx;
  left: 32rpx;
  z-index: 100;
}

.back-btn {
  width: 60rpx;
  height: 60rpx;
  justify-content: center;
  align-items: center;
  flex-direction: row;
}

.back-icon {
  width: 64rpx;
  height: 64rpx;
}

/* ==================== 底部容器 ==================== */
.bottom-wrapper {
  position: absolute;
  bottom: 130rpx;
  left: 0;
  width: 750rpx;
  background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.6) 30%, rgba(0,0,0,0.85) 100%);
}

/* ==================== 内容区域 ==================== */
.content-area {
  padding: 40rpx 32rpx 30rpx 32rpx;
}

/* 用户信息行 */
.user-row {
  flex-direction: row;
  align-items: center;
  margin-bottom: 20rpx;
}

.user-avatar {
  width: 80rpx;
  height: 80rpx;
  border-radius: 40rpx;
  border-width: 4rpx;
  border-color: #FFFFFF;
}

.user-name {
  margin-left: 20rpx;
  font-size: 36rpx;
  color: #FFFFFF;
  font-weight: 500;
}

.btn-follow {
  margin-left: 24rpx;
  width: 120rpx;
  height: 60rpx;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  border-radius: 30rpx;
}

.btn-follow.followed {
  background-color: rgba(255, 255, 255, 0.3);
  border-width: 2rpx;
  border-color: #FFFFFF;
}

.btn-follow-img {
  width: 120rpx;
  height: 60rpx;
}

.btn-follow-text {
  font-size: 26rpx;
  color: #FFFFFF;
  font-weight: 400;
}

/* 描述文字 */
.desc-text {
  font-size: 32rpx;
  line-height: 48rpx;
  color: #FFFFFF;
}

/* ==================== 底部操作栏 ==================== */
.bottom-action {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 750rpx;
  flex-direction: row;
  align-items: center;
  padding: 20rpx 32rpx 30rpx 32rpx;
  background-color:transparent;
  z-index: 10;
}

/* 输入框 */
.input {
  flex: 1;
  height: 70rpx;
  background-color: rgba(26, 26, 26, 0.8);
  border-radius: 35rpx;
  padding-left: 28rpx;
  padding-right: 28rpx;
  margin-right: 20rpx;
  justify-content: center;
}

.input-placeholder {
  font-size: 28rpx;
  color: #999999;
}

/* 操作按钮组 */
.actions {
  flex-direction: row;
  align-items: center;
}

/* 单个操作 */
.action {
  flex-direction: row;
  align-items: center;
  margin-left: 20rpx;
}

.action-icon {
  width: 50rpx;
  height: 50rpx;
}

.action-num {
  margin-left: 10rpx;
  font-size: 26rpx;
  color: #FFFFFF;
}
</style>

4.2 评论弹窗内容 `comment.nvue

<template>
  <view class="comment-popup">
    <!-- 遮罩层 -->
    <view class="mask" @click="closePopup"></view>

    <!-- 评论内容区 -->
    <view class="comment-content">
      <!-- 评论头部 -->
      <view class="comment-header">
        <text class="comment-title">全部评论 {{ commentList.length }}</text>
        <view class="close-btn" @click="closePopup">
          <text class="close-icon">×</text>
        </view>
      </view>

      <!-- 评论列表 -->
      <list class="comment-list" @loadmore="loadMoreComments">
        <cell v-for="(comment, index) in commentList" :key="comment.id">
          <view class="comment-item">
            <!-- 主评论 -->
            <view class="comment-main" @click="handleReply(comment)">
              <image :src="comment.avatar" class="comment-avatar"></image>
              <view class="comment-right">
                <view class="comment-user-info">
                  <view class="comment-user-left">
                    <text class="comment-username" :class="{ 'vip-username': comment.isVip }">{{ comment.username }}</text>
                    <image v-if="comment.isVip" src="/static/video/Group 2033195212@2x.png" class="vip-icon"></image>
                  </view>
                  <view class="comment-like" @click.stop="handleCommentLike(comment)">
                    <text class="comment-like-count">{{ comment.likeCount }}</text>
                    <image 
                      :src="comment.isLiked ? '/static/video/点赞 (3) 2@2x.png' : '/static/video/点赞 (3) 1@2x.png'" 
                      class="like-extra-icon"
                    ></image>
                  </view>
                </view>
                <text class="comment-text">{{ comment.content }}</text>
                <view class="comment-footer">
                  <text class="comment-time">{{ comment.time }}</text>
                  <text class="reply-btn">回复</text>
                </view>

                <!-- 回复列表 -->
                <view class="reply-wrapper" v-if="comment.replies && comment.replies.length > 0" @click.stop>
                  <view class="reply-list" :class="{ 'reply-list-expanded': comment.showAll }">
                    <view class="reply-item" v-for="(reply, rIndex) in comment.showReplies" :key="reply.id" @click="handleReply(reply)">
                      <image :src="reply.avatar" class="reply-avatar"></image>
                      <view class="reply-right">
                        <view class="reply-user-info">
                          <view class="reply-user-left">
                            <text class="reply-username" :class="{ 'vip-username': reply.isVip }">{{ reply.username }}</text>
                            <text class="reply-arrow" v-if="reply.replyToUsername">回复</text>
                            <text class="reply-to-username" v-if="reply.replyToUsername" :class="{ 'vip-username': reply.replyToIsVip }">{{ reply.replyToUsername }}</text>
                            <image v-if="reply.isVip" src="/static/images/huangguan.png" class="vip-icon-small"></image>
                          </view>
                          <view class="reply-like" @click.stop="handleReplyLike(reply)">
                            <text class="like-count">{{ reply.likeCount }}</text>
                            <image 
                              :src="reply.isLiked ? '/static/video/点赞 (3) 2@2x.png' : '/static/video/点赞 (3) 1@2x.png'" 
                              class="like-extra-icon-small"
                            ></image>
                          </view>
                        </view>
                        <text class="reply-text">{{ reply.content }}</text>
                        <view class="reply-footer">
                          <text class="reply-time">{{ reply.time }}</text>
                          <text class="reply-btn">回复</text>
                        </view>
                      </view>
                    </view>
                  </view>

                  <!-- 展开/收起更多回复 -->
                  <view class="expand-replies" v-if="comment.replies.length > 1" @click.stop="toggleReplies(comment)">
                    <text class="expand-line">——————</text>
                    <text class="expand-text">{{ comment.showAll ? '收起回复' : '展开' + (comment.replies.length - 1) + '条回复' }}</text>
                    <image src="/static/video/下  拉 1@2x.png" class="expand-arrow" :class="{ 'arrow-up': comment.showAll }"></image>
                  </view>
                </view>
              </view>
            </view>
          </view>
        </cell>

        <!-- 加载状态 -->
        <cell>
          <view class="load-more">
            <text class="load-more-text" v-if="loadingMore">加载中...</text>
            <text class="load-more-text" v-else-if="noMoreData">没有更多了</text>
          </view>
        </cell>
      </list>

      <!-- 底部输入区域 -->
      <view class="bottom-input-area">

        <!-- 评论输入框 -->
        <view class="comment-input-wrapper">
          <input 
            class="comment-input" 
            :value="inputComment"
            :placeholder="replyPlaceholder"
            placeholder-style="color: #999999"
            @input="onInput"
            @confirm="submitComment"
          />
          <view class="send-btn" :class="{ 'active': inputComment.trim() }" @click="submitComment">
            <text class="send-text">发送</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'

// 加载状态
const loadingMore = ref(false)
const noMoreData = ref(false)
const currentPage = ref(1)

// 评论输入
const inputComment = ref('')
const replyingTo = ref(null)
const replyPlaceholder = ref('说点什么...')

// 评论列表
const commentList = reactive([
  {
    id: 1,
    avatar: '/static/video/Ellipse 2206@2x.png',
    username: '星子落满襟',
    isVip: true,
    content: '岁月静好',
    time: '50分钟前',
    likeCount: 16,
    isLiked: true,
    showAll: false,
    replies: [
      {
        id: 11,
        avatar: '/static/video/Ellipse 2207@2x.png',
        username: '风卷云舒时',
        isVip: false,
        content: '+1',
        time: '5小时前',
        likeCount: 2,
        isLiked: false,
        replyToUsername: '星子落满襟',
        replyToIsVip: true
      },
      {
        id: 12,
        avatar: '/static/images/myInsterest/Ellipse 217.png',
        username: '回复用户2',
        isVip: false,
        content: '同意',
        time: '4小时前',
        likeCount: 1,
        isLiked: false,
        replyToUsername: '风卷云舒时',
        replyToIsVip: false
      }
    ],
    showReplies: []
  },
  {
    id: 2,
    avatar: '/static/video/Ellipse 2208@2x.png',
    username: '巷口路灯下',
    isVip: false,
    content: '这天气太适合出门了',
    time: '1天前',
    likeCount: 15,
    isLiked: false,
    showAll: false,
    replies: [],
    showReplies: []
  },
  {
    id: 3,
    avatar: '/static/video/Ellipse 2207@2x.png',
    username: '风卷云舒时',
    isVip: true,
    content: '很棒的分享',
    time: '2天前',
    likeCount: 3,
    isLiked: false,
    showAll: false,
    replies: [],
    showReplies: []
  }
])

// 输入框输入
const onInput = (e) => {
  inputComment.value = e.detail.value
}

// 取消回复
const cancelReply = () => {
  replyingTo.value = null
  replyPlaceholder.value = '说点什么...'
  inputComment.value = ''
}

// 关闭弹窗
const closePopup = () => {
  // 清空输入和回复状态
  inputComment.value = ''
  replyingTo.value = null
  replyPlaceholder.value = '说点什么...'
  
  // #ifdef APP-PLUS
  // 1. 隐藏评论弹窗
  const wv = plus.webview.currentWebview()
  if (wv) {
    wv.hide('slide-out-bottom', 300)
    console.log('✅ 评论弹窗已关闭')
  }
  
  // 2. 延迟显示overlay层
  setTimeout(() => {
    const overlaySubNVue = uni.getSubNVueById('overlay')
    if (overlaySubNVue) {
      overlaySubNVue.show('none')
      console.log('✅ overlay层已显示')
    }
  }, 100)
  // #endif
}

// 提交评论或回复
const submitComment = () => {
  if (!inputComment.value.trim()) {
    uni.showToast({ title: '请输入内容', icon: 'none' })
    return
  }

  if (replyingTo.value) {
    // 提交回复
    const comment = commentList.find(c =>
      c.id === replyingTo.value.id ||
      (c.replies && c.replies.some(r => r.id === replyingTo.value.id))
    )

    if (comment) {
      const newReply = {
        id: Date.now(),
        avatar: '/static/video/Ellipse 216@2x.png',
        username: '我',
        isVip: false,
        content: inputComment.value,
        time: '刚刚',
        likeCount: 0,
        isLiked: false,
        replyToUsername: replyingTo.value.username,
        replyToIsVip: replyingTo.value.isVip || false
      }

      if (!comment.replies) {
        comment.replies = []
      }
      comment.replies.push(newReply)

      // 更新显示的回复
      if (!comment.showAll) {
        comment.showReplies = [comment.replies[0]]
      } else {
        comment.showReplies = comment.replies
      }

      uni.showToast({ title: '回复成功', icon: 'success' })
    }
  } else {
    // 提交评论
    const newComment = {
      id: Date.now(),
      avatar: '/static/video/Ellipse 216@2x.png',
      username: '我',
      isVip: false,
      content: inputComment.value,
      time: '刚刚',
      likeCount: 0,
      isLiked: false,
      showAll: false,
      replies: [],
      showReplies: []
    }

    commentList.unshift(newComment)
    uni.showToast({ title: '评论成功', icon: 'success' })
  }

  // 清空输入框和回复状态
  inputComment.value = ''
  replyingTo.value = null
  replyPlaceholder.value = '说点什么...'
}

// 展开/收起回复
const toggleReplies = (comment) => {
  if (comment.showAll) {
    comment.showAll = false
    comment.showReplies = [comment.replies[0]]
  } else {
    comment.showAll = true
    comment.showReplies = comment.replies
  }
}

// 评论点赞
const handleCommentLike = (comment) => {
  comment.isLiked = !comment.isLiked
  comment.likeCount += comment.isLiked ? 1 : -1
}

// 回复点赞
const handleReplyLike = (reply) => {
  reply.isLiked = !reply.isLiked
  reply.likeCount += reply.isLiked ? 1 : -1
}

// 回复评论
const handleReply = (item) => {
  replyingTo.value = item
  replyPlaceholder.value = `回复 ${item.username}:`
  
  uni.showToast({
    title: `回复 ${item.username}`,
    icon: 'none',
    duration: 1000
  })
}

// 加载更多评论
const loadMoreComments = () => {
  if (loadingMore.value || noMoreData.value) {
    return
  }

  loadingMore.value = true

  setTimeout(() => {
    const newComment = {
      id: commentList.length + 1,
      avatar: '/static/video/Ellipse 2207@2x.png',
      username: '新用户' + (commentList.length + 1),
      isVip: false,
      content: '这是新加载的评论内容',
      time: '刚刚',
      likeCount: Math.floor(Math.random() * 20),
      isLiked: false,
      showAll: false,
      replies: [],
      showReplies: []
    }

    commentList.push(newComment)
    currentPage.value++

    if (currentPage.value >= 5) {
      noMoreData.value = true
    }

    loadingMore.value = false
  }, 1000)
}

// 组件挂载
onMounted(() => {
  // 初始化评论显示的回复
  commentList.forEach(comment => {
    if (comment.replies && comment.replies.length > 0) {
      comment.showReplies = [comment.replies[0]]
    }
  })
})
</script>

<style>
.comment-popup {
  position: absolute;
  top: 0;
  left: 0;
  width: 750rpx;
  height: 1624rpx;
  justify-content: flex-end;
}

.mask {
  position: absolute;
  top: 0;
  left: 0;
  width: 750rpx;
  height: 1624rpx;
  background-color: rgba(0, 0, 0, 0.5);
}

.comment-content {
  width: 750rpx;
  height: 900rpx;
  background-color: #FFFFFF;
  border-top-left-radius: 32rpx;
  border-top-right-radius: 32rpx;
  overflow: hidden;
  flex-direction: column;
}

/* 评论头部 */
.comment-header {
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  padding: 32rpx 32rpx 16rpx;
  height: 88rpx;
}


/* 评论输入框 */
.comment-input-wrapper {
  flex-direction: row;
  align-items: center;
  padding: 16rpx 32rpx 20rpx 32rpx;
  background-color: #FFFFFF;
}

.comment-input {
  flex: 1;
  height: 70rpx;
  background-color: #F5F5F5;
  border-radius: 35rpx;
  padding-left: 28rpx;
  padding-right: 28rpx;
  font-size: 28rpx;
  color: #333333;
  margin-right: 16rpx;
}

.send-btn {
  width: 120rpx;
  height: 60rpx;
  background-color: #E5E5E5;
  border-radius: 30rpx;
  justify-content: center;
  align-items: center;
}

.send-btn.active {
  background-color: #7E51FF;
}

.send-text {
  font-size: 28rpx;
  color: #fff;
}

.send-btn.active .send-text {
  color: #FFFFFF;
}

.comment-title {
  font-size: 32rpx;
  font-weight: 500;
  color: #333333;
}

.close-btn {
  width: 56rpx;
  height: 56rpx;
  justify-content: center;
  align-items: center;
  flex-direction: row;
}

.close-icon {
  font-size: 56rpx;
  color: #999999;
  line-height: 56rpx;
}

/* 评论列表 */
.comment-list {
  flex: 1;
  height: 0;
  padding-left: 32rpx;
  padding-right: 32rpx;
  padding-bottom: 0rpx;
}

/* 底部输入区域 */
.bottom-input-area {
  width: 750rpx;
  background-color: #FFFFFF;
  border-top-width: 1rpx;
  border-top-color: #F0F0F0;
}

.comment-item {
  padding-top: 24rpx;
  padding-bottom: 24rpx;
}

.comment-main {
  flex-direction: row;
}

.comment-avatar {
  width: 72rpx;
  height: 72rpx;
  border-radius: 36rpx;
}

.comment-right {
  flex: 1;
  margin-left: 20rpx;
}

.comment-user-info {
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12rpx;
}

.comment-user-left {
  flex-direction: row;
  align-items: center;
}

.comment-username {
  font-size: 28rpx;
  font-weight: 500;
  color: #888888;
}

.vip-username {
  color: #7E51FF;
}

.vip-icon {
  width: 32rpx;
  height: 32rpx;
  margin-left: 8rpx;
}

.comment-text {
  font-size: 30rpx;
  line-height: 44rpx;
  color: #333333;
  margin-bottom: 12rpx;
}

.comment-footer {
  flex-direction: row;
  align-items: center;
}

.comment-time {
  font-size: 24rpx;
  color: #888888;
}

.reply-btn {
  font-size: 28rpx;
  color: #333333;
  margin-left: 12rpx;
}

.comment-like {
  flex-direction: row;
  align-items: center;
  padding-right: 37rpx;
}

.comment-like-count {
  font-size: 24rpx;
  color: #999999;
  margin-right: 2rpx;
}

.like-extra-icon {
  width: 45rpx;
  height: 45rpx;
  margin-left: 8rpx;
  margin-right: 10rpx;
}

/* 回复列表容器 */
.reply-wrapper {
  margin-top: 24rpx;
  margin-bottom: 0rpx;
}

.reply-list {
  height: 200rpx;
  overflow: hidden;
}

.reply-list-expanded {
  height: 600rpx;
}

.reply-item {
  flex-direction: row;
  margin-bottom: 24rpx;
}

.reply-avatar {
  width: 56rpx;
  height: 56rpx;
  border-radius: 28rpx;
}

.reply-right {
  flex: 1;
  margin-left: 16rpx;
}

.reply-user-info {
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8rpx;
}

.reply-user-left {
  flex-direction: row;
  align-items: center;
}

.reply-username {
  font-size: 26rpx;
  font-weight: 500;
  color: #888888;
}

.reply-arrow {
  font-size: 24rpx;
  color: #CCCCCC;
  margin-left: 8rpx;
  margin-right: 8rpx;
}

.reply-to-username {
  font-size: 26rpx;
  font-weight: 500;
  color: #888888;
}

.vip-icon-small {
  width: 28rpx;
  height: 28rpx;
  margin-left: 6rpx;
}

.reply-text {
  font-size: 28rpx;
  line-height: 40rpx;
  color: #333333;
  margin-bottom: 8rpx;
}

.reply-footer {
  flex-direction: row;
  align-items: center;
}

.reply-time {
  font-size: 22rpx;
  color: #999999;
}

.reply-like {
  flex-direction: row;
  align-items: center;
  padding-right: 40rpx;
}

.like-count {
  font-size: 24rpx;
  color: #999999;
  margin-right: 6rpx;
}

.like-extra-icon-small {
  width: 45rpx;
  height: 45rpx;
  margin-left: 8rpx;
  margin-right: 10rpx;
}

/* 展开更多回复 */
.expand-replies {
  flex-direction: row;
  align-items: center;
  padding-top: 0rpx;
  padding-bottom: 16rpx;
  margin-top: 0rpx;
}

.expand-line {
  font-size: 20rpx;
  color: #E5E5E5;
  margin-right: 12rpx;
}

.expand-text {
  font-size: 26rpx;
  color: #4A6481;
  margin-right: 8rpx;
}

.expand-arrow {
  width: 24rpx;
  height: 24rpx;
}

.arrow-up {
  transform: rotate(180deg);
}

/* 加载更多 */
.load-more {
  padding-top: 32rpx;
  padding-bottom: 32rpx;
  flex-direction: row;
  align-items: center;
  justify-content: center;
}

.load-more-text {
  font-size: 24rpx;
  color: #999999;
}
</style>

5.总结

  1. 使用subNVue 将页面分为 video 以及 遮罩区域等
  2. 配置pages.json
  3. 代码拆解
  4. 注意nvue的css兼容问题 nvue默认flex布局且默认竖直排列

大模型也栽跟头的 Promise 题!来挑战一下?

2025年10月29日 18:16

🌟 开场白:Promise,你以为的它,真的是它吗?

各位掘友,大家好!

在前端的武林中,Promise 绝对是内功心法级别的存在。我们每天都在用 .then().catch(),用它来处理异步,但你有没有遇到过那种让你直呼“卧槽,这顺序不对啊!”的 Promise 题?

今天,我们就来挑战一道看似简单,实则暗藏玄机的 Promise 经典面试题。它能完美地考察你对 JavaScript 事件循环微任务队列,尤其是 Promise 状态吸收(Promise Resolution Procedure) 的理解。

如果你能准确说出下面这段代码的输出顺序,恭喜你,你的 Promise 功力至少是“内功小成”!


⚠️ 灵魂拷问面试题:输出结果是多少?

请看这段代码,并思考一下,1, 2, 3, 4, 5, 6, 7 这几个数字的打印顺序会是怎样的?

const p1 = Promise.resolve();

const p2 = new Promise((resolve) => {
  // 关键点:用 p1 来 resolve p2
  resolve(p1);
})

console.log(p1)
console.log(p2)

// p2 链
p2.then(()=>{
  console.log(1);
})
  .then(()=>{
    console.log(2);
  })
  .then(()=>{
    console.log(3);
  });

// p1 链
p1.then(()=>{
  console.log(4);
})
  .then(()=>{
    console.log(5);
  })
  .then(()=>{
    console.log(6);
  })
  .then(()=>{
    console.log(7);
  });

如果你心中已经有了答案,不妨先记下来,我们马上揭晓谜底!


✨ 核心知识点:Promise 状态吸收(State Absorption)

为什么这道题容易错?因为它引入了一个“套娃”操作:用一个 Promise (p1) 去解决另一个 Promise (p2)

这就是 Promise 规范中的一个核心机制——Promise Resolution Procedure,俗称状态吸收

🔄 状态吸收:Promise 界的“移魂大法”

想象一下,p2 是一个年轻的学徒,p1 是一个已功成名就的大侠。

当我们在 p2 的构造函数中调用 resolve(p1) 时,就相当于:

学徒 p2 对大侠 p1 说:“我决定,我的命运就由您来决定了!”

根据 Promises/A+ 规范

  1. 当一个 Promise (p2) 被另一个 Promise (p1) 解决时,p2 不会立即进入 fulfilled 状态。
  2. 相反,p2吸收(Adopt) p1 的状态。
  3. 这意味着,p2 上的所有 .then() 回调,都会被转移p1 上去执行。p2 成了一个代理(Proxy)

在本题中:

  • p1 = Promise.resolve(),它已经是 fulfilled 状态。
  • p2 吸收 p1 的状态,所以 p2 的回调 (1, 2, 3) 实际上是挂在了 p1 的回调队列中,和 p1 自己的回调 (4, 5, 6, 7) 一起排队。

总结: 所有的 .then() 回调,无论是来自 p1 链还是 p2 链,现在都在 同一个微任务队列 中,等待 p1 解决后执行。


🔧 调度分析:微任务队列的“插队”艺术

既然所有的回调都在一个队列里,那么它们的执行顺序就取决于两个因素:

  1. .then() 的调用顺序:决定了初始回调的入队顺序。
  2. Promise 链的连续性:决定了后续回调的入队和执行顺序。

第一步:初始入队

同步代码执行时,由于p2在准备阶段,所以p1.then(4) 先被调用。

  • 微任务队列初始状态:[p2准备阶段,p1.then(4),p2吸收阶段,p1.then(5)]

第二步:实际执行与链式调度

在 V8 引擎(Node.js/Chrome)中,Promise 链的调度有一个特性:当一个 Promise 链中的回调执行完毕后,它所返回的新 Promise 的下一个 .then() 回调,会被优先安排到当前微任务队列的末尾。

首先是最直观的过程:

状态吸收:准备、吸收

微队列:

  1. p2准备阶段
  2. p1推入4
  3. p2吸收阶段
  4. p1推入5
  5. p2推入1
  6. p1推入6
  7. p2推入2
  8. p1推入7
  9. p2推入3

现在来模拟执行过程:

序号 执行任务 输出 解释
1 p1.then(4) 4 注意: 尽管 p2.then(1) 先入队,但实际运行时,p1 链的第一个回调被优先执行。输出 4p1 链的下一个回调 p1.then(5) 立即入队。
2 p1.then(5) 5 p1 链的连续性得到体现,p1.then(5) 紧接着执行。输出 5p1.then(6) 立即入队。
3 p2.then(1) 1 此时,轮到 p2 链的第一个回调执行。输出 1p2 链的下一个回调 p2.then(2) 立即入队。
4 p1.then(6) 6 再次回到 p1 链。输出 6p1.then(7) 立即入队。
5 p2.then(2) 2 回到 p2 链。输出 2p2.then(3) 立即入队。
6 p1.then(7) 7 p1 链的最后一个回调。输出 7
7 p2.then(3) 3 p2 链的最后一个回调。输出 3

运行结果图

image.png

最终的正确输出顺序是:

4, 5, 1, 6, 2, 7, 3


💡 总结:面试官想考察你什么?

通过这道题,面试官想考察你的知识点清单:

  1. 同步代码优先console.log(p1)console.log(p2) 总是最先执行。
  2. Promise 状态吸收:当 resolve(Promise) 时,被解决的 Promise (p2) 会将自己的回调转嫁给传入的 Promise (p1),导致所有回调在同一个微任务队列中竞争。
  3. 微任务调度:在 V8 引擎中,Promise 链的执行具有连续性。一旦开始执行某个 Promise 链的回调,它会倾向于执行完该链中所有已准备好的后续回调,直到遇到一个尚未解决的 Promise 或队列中没有该链的后续任务为止。

记住这个机制,下次遇到 Promise 套娃题,你就能轻松应对了!希望这篇博客对你有所帮助,我们下次见!

检测题

了解状态吸收后,来看看下面的输出结果是多少呢?

async function async1() {
  console.log(1);
  await async2();
  console.log('AAA');
}

async function async2() {
  return Promise.resolve(2);
}

async1();

Promise.resolve()
  .then(() => {
    console.log(3);
  })
  .then(() => {
    console.log(4);
  })
  .then(() => {
    console.log(5);
  });

【uniapp】小程序体积优化,分包异步化

2025年10月29日 17:46

前言

在小程序端,分包异步化 是一个重要的减小体积的手段,下面会介绍如何在 uniapp分包异步化

跨分包自定义组件引用

在页面中正常使用: import CustomButton from "@/packageB/components/component1/index.vue";

{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "Index",
        "componentPlaceholder": {
          "custom-button": "view"
        }
      }
    }
  ],
  "subPackages": [
    {
      "root": "packageA",
      "pages": [
        {
          "path": "index/index",
          "style": {
            "navigationBarTitleText": "分包页面",
            // 添加配置
            "componentPlaceholder": {
              "custom-button": "view"
            }
          }
        }
      ]
    },
    {
      "root": "packageB",
      "pages": [
        {
          "path": "index/index",
        }
      ],
    }
  ]
}

此特性依赖配置 componentPlaceholder,目前 uniapp 仅支持在 pages.json 中添加页面级别的配置,如果需要在某个组件或者页面中配置,可以使用 插件,支持 vue2vue3

跨分包 JS 代码引用

小程序端默认支持跨分包 JS 代码引用,需要写小程序原生支持的语法,不能使用静态引入或者动态引入。示例如下:

sub分包 定义 utils.js 文件

// sub/utils.js
export function add(a, b) {
    return a + b
}

sub分包 正常使用 utils.js 文件

// sub/index.vue
<template>
    <view>
        {{ count }}
        <button @tap="handleClick">add one</button>
    </view>
</template>

<script>
    import {
        add
    } from "./utils.js";

    export default {
        data() {
            return {
                count: 1
            }
        },
        methods: {
            handleClick() {
                this.count = add(this.count, 1)
            }
        }
    }
</script>

其他分包使用 sub分包utils.js 文件

// sub2/index.vue
<template>
    <view>
       {{ count }}
        <button @tap="handleClick">add two</button>
    </view>
</template>

<script>
    export default {
        data() {
            return {
                count: 1
            }
        },
        methods: {
            handleClick() {
                require('../sub/utils.js', sub_utils => {
                    this.count = sub_utils.add(this.count, 2);
                }, ({
                    mod,
                    errMsg
                }) => {
                    console.error(`path: ${mod}, ${errMsg}`)
                })
            }
        }
    }
</script>

注意:

  • 引用的文件必须存在
  • 使用小程序支持的原生语法

结语

如果这个库的插件帮助到了你,可以点个 star✨ 鼓励一下。

如果你有什么好的想法或者建议,欢迎在 github.com/uni-toolkit… 提 issue 或者 pr

深入理解 CSS 弹性布局:从传统布局到 Flex 的优雅演进

作者 iNx177
2025年10月29日 15:41

深入理解 CSS Flexbox 布局:从传统方案到现代实践

在 Web 开发中,页面布局是构建用户界面的基础。传统的布局方法,比如 floatinline-block,长期以来被广泛使用,但这些方式存在不少局限。随着 CSS3 的发展,Flexbox 成为了现代一维布局的标准工具,它简化了很多开发者的工作,尤其是在响应式设计中。

本文将带你了解 Flexbox 布局的核心概念,并与传统布局方式做对比,帮助你更好地理解并应用 Flexbox。


一、传统布局方法的痛点

在 Flexbox 出现之前,开发者常常使用 inline-blockfloat 来实现多列布局。然而,这些方法存在诸多缺陷。

1. 使用 inline-block 实现并排布局

.item {
  display: inline-block;
  width: 33.33%;
}
优点:
  • 可设置宽度和高度;
  • 支持文本对齐属性。
缺点:
  • 元素间会产生大约 4px 的空白间隙;
  • 元素宽度超出容器宽度,可能导致布局错位;
  • 必须额外处理间隙(如 HTML 注释合并、字体 hack 或设置父元素 font-size: 0);
  • 居中对齐较为复杂,响应式设计难度较大。

💡 即使设置了 width: 33.33%,由于默认间隙的存在,最终的布局宽度可能会超过 100%。

2. 使用 float 实现浮动布局

.item {
  float: left;
  width: 33.33%;
}
缺点:
  • 元素脱离文档流,可能导致父容器高度塌陷;
  • 需要手动清除浮动(clearfixoverflow: hidden);
  • 不利于响应式设计;
  • 维护成本较高,且现如今已被其他现代布局方法所取代。

二、Flexbox:现代一维布局的解决方案

Flexbox 是一种专为沿着单一方向(无论是行或列)排列子元素而设计的布局模型。它可以高效地分配空间、控制对齐方式,并极大简化了复杂布局的实现。

启用 Flexbox 布局非常简单:

.container {
  display: flex;
}

只需这一行代码,子元素就会进入弹性布局环境,成为“弹性项目”。


三、案例解析:使用 Flexbox 实现等分布局

下面是一个简单的 Flexbox 示例,展示如何使用 Flexbox 实现等分布局。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Flex Layout Example</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }

    .box {
      display: flex;
      height: 100px;
      width: 50%;
      background-color: red;
      margin-bottom: 10px;
    }

    .box:nth-child(2) {
      background-color: blue;
    }

    .item {
      flex: 1;
      font-size: 20px;
      color: black;
      text-align: center;
      line-height: 100px;
    }

    .item:nth-child(odd) {
      background-color: yellow;
    }
  </style>
</head>
<body>
  <div class="box">
    <div class="item">1</div>
    <div class="item">2</div>
    <div class="item">3</div>
  </div>
  <div class="box"></div>
</body>
</html>

关键点解析:

代码片段 作用说明
* { margin: 0; padding: 0; } 清除浏览器默认样式,避免干扰布局
box-sizing: border-box 确保盒模型计算一致,避免宽高计算问题
.box { display: flex; } 启用弹性布局容器,默认主轴为水平方向(flex-direction: row
flex: 1 子元素均分剩余空间,等效于 flex: 1 1 0%,无需手动计算百分比
.item:nth-child(odd) 奇数项背景为黄色,便于区分不同的元素
.box:nth-child(2) 第二个容器背景为蓝色,便于观察

🔍 注意:.box 宽度为 50%,三个 .item 元素通过 flex: 1 自动均分该区域,不会因屏幕尺寸变化而失调。


四、Flexbox 核心属性

属性 说明
display: flex 将容器变为弹性布局容器
flex-direction 主轴方向:row(水平)或 column(垂直)等
justify-content 主轴对齐方式:centerspace-betweenflex-start
align-items 交叉轴对齐方式:centerstretchflex-end
flex-wrap 是否允许换行:nowrap / wrap
flex 子项的缩放属性,常用值 flex: 1 表示等分空间

推荐常用组合(居中场景)

.container {
  display: flex;
  justify-content: center; /* 水平居中 */
  align-items: center;     /* 垂直居中 */
  min-height: 100vh;       /* 配合视口高度使用效果更佳 */
}

✅ 相比传统的 margin: autotransform(-50%) 方法,Flexbox 的居中更直观且兼容性好(支持 IE10+)。


五、Flexbox 与传统布局方式对比

特性 inline-block float flex
空白问题 存在间隙
宽高控制 支持 支持 更灵活
居中对齐 复杂 复杂 简单(一行代码)
等分布局 需要精确计算 不适用 flex: 1 自动均分
响应式设计 较弱 较弱
学习成本

📌 结论:对于大多数一维布局需求(如导航栏、按钮组、卡片列表),Flexbox 是最佳选择。


六、Flexbox 适用场景与注意事项

✅ 推荐使用 Flex 的场景:

  • 水平/垂直居中
  • 导航菜单、页眉页脚布局
  • 表单控件对齐
  • 移动端自适应组件
  • 动态数量的等分布局(如评分星星、标签组)

⚠️ 不推荐使用 Flex 的情况:

  • 复杂的二维网格布局 → 推荐使用 CSS Grid
  • 需要兼容 IE8/9 的项目 → Flexbox 支持 IE10+,但不支持旧版 IE

七、总结

Flexbox 是现代 CSS 布局的核心工具,解决了传统布局中的许多痛点。通过本案例可以看到:

  • 使用 display: flexflex: 1 可以轻松实现等分布局;
  • 不再需要关注空白间隙或浮动塌陷的问题;
  • 布局更具弹性,易于维护和扩展。

建议你在实际项目中实践并调整属性,进一步理解 Flexbox 的强大功能,掌握现代布局的核心技能。


如果本文对你理解 Flexbox 有帮助,欢迎点赞并分享。关注我,获取更多前端技术分享。


ECharts 全局触发click点击事件(柱状图、折线图增大点击范围)

作者 Lsx_
2025年10月28日 09:50

需求

image.png

image.png

如图所示,由于图表联动需求,选中图表中某一列数据,可联动其它图表数据进行渲染。

对于柱状图 需要支持点击其背景区域也可触发点击事件;

对于折线图,需要支持点击其坐标点左右范围的区间也可触发点击事件,并且点击后要保留竖线。

Echarts 点击处理事件,通过chartInstance.on可实现,但只能点击柱状图才能触发,点击label(即:坐标文本)、点击柱状图阴影区域无法触发;通过chartInstance.getZr().on可全局监听Echarts事件,但无法准确的获取当前点击柱状的index;解决办法如下:

解决方案

处理图表全局点击事件

  chartInstance?.getZr().on("click", (params: any) => {
    const pointInPixel = [params.offsetX, params.offsetY];
    const pointInGrid = chartInstance.convertFromPixel({ seriesIndex: 0 }, pointInPixel);
    // 柱状图-竖向(数据的索引值)
    const index = pointInGrid[0];

    console.log(index)
  });

🔍 一步步解析代码

第 1 行
chartInstance?.getZr().on("click", (params) => { ... })
  • chartInstance 是你通过 echarts.init(...) 得到的图表实例。
  • .getZr() 返回的是 ECharts 内部用的 ZRender 实例,它相当于一个底层的“画布层”,能监听点击、鼠标移动等低级事件。
  • .on("click", handler) 表示注册一个画布点击事件,params 是点击事件对象。

👉 所以这一行的作用是:
在整个 ECharts 图的画布上注册一个点击事件监听器。


第 2 行
const pointInPixel = [params.offsetX, params.offsetY];
  • params.offsetX / offsetY 是鼠标点击在画布中的像素坐标。
  • 即:你点击的位置在图表的画布上的绝对坐标点。

👉 这一步是把点击点的屏幕坐标保存成数组,例如 [x, y] = [320, 180]


第 3 行
const pointInGrid = chartInstance.convertFromPixel({ seriesIndex: 0 }, pointInPixel);
  • convertFromPixel 是 ECharts 提供的一个有用方法:

    可以反向计算:“像素坐标 → 对应到数据坐标(数据索引或坐标轴数值)”

  • 第二个参数是刚才的 [x, y] 像素坐标。

  • 第一个参数 { seriesIndex: 0 } 表示使用第 0 个系列(在图表的第一个数据集)来定义转换规则。

👉 如果这是一个柱状图或折线图,调用后得到的 pointInGrid 就是:

[x轴的索引, y轴的数值]

例如点击了第 3 根柱子:

pointInGrid = [3, 200]

第 4-5 行
const index = pointInGrid[0];
console.log(index);
  • 取得反算结果中的第一个值——即 x轴索引值(数据索引)
  • 打印到控制台。

处理图表点击选中态

以上方案只是解决了图表的点击问题,有时候,比如折线图,点击后,需要留下一个选中态的竖线。解决方案如下

let permanentSelectedLine: echarts.graphic.Line | null = null; // 点击列后,选中的竖线
let lastSelectedIndex = -1; // 上次选中的列索引
const SELECTED_LINE_Z = 1000; // 选中竖线的层级

// 点击列并显示虚线
const handleColumnClick = (index: number) => {
  const chartInstance = chartRef.value?.getInstance();
  if (!chartInstance || !data.value?.dayList) return;

  const dayList = data.value.dayList;

  // 检查索引是否有效
  if (index < 0 || index >= dayList.length) return;

  // 获取选中列的日期和数据
  const selectedDate = dayList[index].statDay;
  const selectedData = {
    index,
    date: selectedDate,
    data: dayList[index]
  };

  // 触发事件,用于联动其它图表
  assetDashboardStore.setSelectedOrderIncomeTrendDate(selectedDate);

  // 删除旧的永久选中竖线
  if (permanentSelectedLine) {
    chartInstance.getZr().remove(permanentSelectedLine);
    permanentSelectedLine = null;
  }

  // 如果点击的是同一列,取消选中
  if (lastSelectedIndex === index) {
    lastSelectedIndex = -1;
    return;
  }

  lastSelectedIndex = index;

  // 计算竖线位置
  const xPixel = chartInstance.convertToPixel({ xAxisIndex: 0 }, selectedDate);

  // 获取图表高度,用于绘制竖线
  const chartHeight = chartRef.value?.$el?.clientHeight || 300;

  // 确定竖线的起始和结束位置
  const yStart = 60; // 顶部边距
  const yEnd = chartHeight - 90; // 底部边距

  // 画新竖线(永久选中线,样式对齐 trigger: "axis")
  permanentSelectedLine = new echarts.graphic.Line({
    z: SELECTED_LINE_Z, // 最高层级,确保始终显示在最上层
    shape: { x1: xPixel, y1: yStart, x2: xPixel, y2: yEnd },
    style: {
      stroke: "#666666",
      lineWidth: 1,
      lineDash: [5, 5]
    }
  });

  chartInstance.getZr().add(permanentSelectedLine);
};

🔍 解析代码

这段代码的函数叫 handleColumnClick,它允许在点击某个“列”(比如折线图或柱状图的指定 x 轴点)时执行额外操作:

  1. 在图表上绘制一条永久竖线(虚线)以表示选中项;
  2. 触发一个联动事件(更新状态给其他图表用);
  3. 支持再次点击同一列取消选中。
删除旧的竖线:
if (permanentSelectedLine) {
  chartInstance.getZr().remove(permanentSelectedLine);
  permanentSelectedLine = null;
}
  • 图上可能已经有一条虚线(上次点击生成)。
  • 这一步从画布(ZRender 层)移除旧竖线。
  • 清空变量,以便下一次重新画新的竖线。
计算竖线位置(像素坐标):
const xPixel = chartInstance.convertToPixel({ xAxisIndex: 0 }, selectedDate);
  • selectedDate 是 x 轴的数据值,比如 '2024-10-02'
  • convertToPixel() 把 “坐标点” 转成 “画布像素位置”
  • 返回的是竖线绘制的 x 坐标
获取图表高度范围,确定线的起止点:
const chartHeight = chartRef.value?.$el?.clientHeight || 300;
const yStart = 60;
const yEnd = chartHeight - 90;
  • 计算竖线上下端在画布中的像素位置。
  • 顶部留 60px,底部留 90px,让线看起来不贴边。

创建一条虚线对象:
permanentSelectedLine = new echarts.graphic.Line({
  z: SELECTED_LINE_Z,
  shape: { x1: xPixel, y1: yStart, x2: xPixel, y2: yEnd },
  style: {
    stroke: "#666666",
    lineWidth: 1,
    lineDash: [5, 5]
  }
});

📌 这块非常关键:

  • 调用了 echarts.graphic.Line ——直接使用 ECharts 底层的图形类;
  • 生成一条线段;
  • shape 决定起点终点;style 决定样式;
  • lineDash: [5,5] 表示虚线;
  • z 控制层级(保证线上浮)。

这条线就是真正的「永久选中虚线」。


将虚线添加到图表画布内:
chartInstance.getZr().add(permanentSelectedLine);
  • 调用了 ECharts 的底层绘图引擎(ZRender)的 add() 方法;
  • 这条线直接绘制在图上,不受 tooltip 控制、不会消失;

Vue3 的“批量渲染”机制

作者 CptW
2025年10月29日 17:13

两种机制分别指:

  1. 在 1次 回调里触发 多次 computed/effect,其只会执行 1次
  2. 在 1次 回调里 多次 更新与视图相关的 ref视图更新 也只执行 1次

举两个例子:

// effectA
effect(() => {
  console.log('A:', a.value)
  if (a.value > 5) {
    // 2、再触发第2次 effectB
    b.value = a.value * 2
  }
})

// effectB
effect(() => {
  console.log('B:', b.value)
})

// 1、更新dep, 触发1次 effectA 和 1次 effectB
a.value = 6
// 实际 effectB 只会执行 1 次,而不是 2 次
btn.onclick = () => {
  count.value++
  count.value++
  count.value++
  count.value++
}

return () => {
  console.log('render call')
  return h('div', `count: ${count.value}`)
}
// 点击按钮,实际 'render call' 只会被打印1次

如何实现的?简写源码 来说明: PS: 不熟悉响应式基础的,请移步往期文章

effect 的批处理

修改响应式变量的值后,会触发 setter 里的函数 trigger,之后会沿着链表通知所有 effect

export function traggerRef(dep: RefImpl) {
  ......
    propagate(dep.subs)
  ......
}

/** 传播更新 */
export function propagate(subs: Link) {
  // 链表节点
  let link = subs
  // 收集 effect
  const queuedEffect = []
  while (link) {
    const sub = link.sub
    // 标记 dirty,防止重复触发 effect
    if (!sub.tracking && !sub.dirty) {
      sub.dirty = true
      // ......
      queuedEffect.push(sub)
      // ......
    }
    // 遍历链表
    link = link.nextSub
  }

  // 通知 effect 执行
  queuedEffect.forEach(effect => effect.notify())
}

可以看到 sub 里有一个 dirty 属性,如果同一次回调函数中,多次触发 sub,它只会被放入待执行列表 1 次,也就是不会多次执行。

注意,dirty 标志位会等 Effect 真正执行完成后才重置。

异步渲染 render

mount组件的流程是:使用VNode创建组件实例instance -> 挂载到DOM -> 更新,组件实际上就是创建了一个 Effect 来订阅更新:

const mountComponent = (vnode, container, anchor) => {
    /**
     * 1、创建组件实例
     * 2、初始化状态
     * 3、挂载到DOM
     */
    // 1 实例化
    const instance = createComponentInstance(vnode)
    // 2 初始化
    setupComponent(instance)

    const componentUpdateFn = () => {
      // 首次挂载
      if (!instance.isMounted) {
        // 得到 Virtual DOM
        const subTree = instance.render()
        // 3 挂载
        patch(null, subTree, container, anchor)
        // 保存当前 V-DOM
        instance.subTree = subTree
        // 修改标志位
        instance.isMounted = true
      } else {
        // 更新
        const preSubTree = instance.subTree
        // 获取新的 V-DOM
        const subTree = instance.render()
        // 对比新旧 VNode,更新
        patch(preSubTree, subTree, container, anchor)
        instance.subTree = subTree
      }
    }

    const effect = new ReactiveEffect(componentUpdateFn)
    const update = effect.run.bind(effect)

    instance.update = update

    effect.scheduler = () => {
      queueJob(update)
    }

    effect.run()
  }

但假如有如下例子,假如点击 1次 按钮,将打印 4次 effect execute 和 1次 render call

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
  </head>
  <body>
    <div id="app"></div>
    <button id="btn">+++</button>
    <script type="module">
      import { createApp, h, ref, effect, computed } from 'vue'

      const rootComp = {
        setup() {
          const count = ref(0)

          btn.onclick = () => {
            count.value++
            count.value++
            count.value++
            count.value++
          }

          effect(() => {
            console.log('effect execute', count.value)
          })

          return () => {
            console.log('render call')
            return h('div', `count1: ${count.value}`)
          }
        },
      }

      createApp(rootComp).mount('#app')
    </script>
  </body>
</html>

按理说 render call 也应该打印 4次,Why? 因为代码里利用 Effect.scheduler 做了 异步更新,即重写了scheduler:

const componentUpdateFn = () => {
  //......
  const update = effect.run.bind(effect)

  instance.update = update

  effect.scheduler = () => {
    queueJob(update)
  }
  //......
}

function queueJob(job) {
  Promise.resolve().then(() => {
    job()
  })
}

此时,每次 ref 更新后,不立即重置 dirty,而是等所有同步任务执行完后,再执行渲染,BINGO

用 Stylus 写 CSS 有多爽?从响应式面板案例看透它的优雅

2025年10月29日 17:06

作为前端开发者,你是否也曾被 CSS 的冗余语法、重复代码折磨过?写一个嵌套结构要反复敲选择器,改一个样式要全局搜半天?今天要聊的 Stylus,可能会让你重新爱上写样式 —— 它用极简的语法、强大的编程特性,把 CSS 变成了一门 "可编写" 的语言。

本文不会空谈理论,而是通过一个「响应式图片面板」案例,带你沉浸式体验 Stylus 的优雅:从安装到实战,从布局到交互,看完就能上手。

一、初识 Stylus:比 CSS 更懂开发者的预处理器

Stylus 是三大 CSS 预处理器(Sass、Less、Stylus)中最 "叛逆" 的一个 —— 它彻底抛弃了 CSS 的冗余语法,括号、分号、冒号都成了 "可选品"。这种极简主义,让写样式变得像写伪代码一样流畅。

1. 先装起来,跑通流程

全局安装 Stylus(需要 Node 环境):

bash

npm i -g stylus

新建style.styl文件,写完后编译成浏览器能识别的 CSS:

bash

# 单次编译:将style.styl编译为style.css
stylus style.styl -o style.css

# 实时编译:边写边更,开发必备
stylus style.styl -o style.css -w

2. 语法对比:Stylus vs 原生 CSS

同样一段样式,感受下差异:

原生 CSS

css

.card {
  width: 50px;
  height: 45px;
  background: #fff;
}
.card .title {
  font-size: 16px;
  color: #333;
}
.card.active {
  border: 1px solid #f00;
}

Stylus

stylus

.card
  width 50px
  height 45px
  background #fff
  .title
    font-size 16px
    color #333
  &.active
    border 1px solid #f00

是不是瞬间清爽了?没有括号、分号,嵌套直接用缩进(像 Python 一样),&还能轻松表示父元素自身的状态 —— 这只是 Stylus 的冰山一角。

二、实战:用 Stylus 实现响应式图片面板

我们要做的效果是:一个横向排列的图片面板,点击任意面板会展开放大,其他面板收缩;在手机端自动隐藏部分面板,适配小屏幕。

先看最终效果框架:

  • 桌面端:5 个面板横向排列,点击展开
  • 移动端(≤480px):只显示前 3 个面板,占满屏幕宽度

1. HTML 结构:极简骨架

html

预览

<div class="container">
  <div class="panel active" style="background-image: url('图片1')">
    <h3>Explore The World</h3>
  </div>
  <div class="panel" style="background-image: url('图片2')">
    <h3>Wild Forest</h3>
  </div>
  <!-- 共5个面板,省略后3个 -->
</div>

2. JavaScript 交互:点击切换激活状态

用排他思想实现 "点击谁,谁放大":

javascript

运行

const panels = document.querySelectorAll('.panel');

panels.forEach(panel => {
  panel.addEventListener('click', () => {
    // 移除其他面板的active类
    document.querySelector('.active')?.classList.remove('active');
    // 给当前面板添加active类
    panel.classList.add('active');
  });
});

3. Stylus 样式:核心代码拆解

这部分是重点,我们一步步解析 Stylus 如何用更少的代码实现复杂布局和交互。

(1)基础重置与全局布局

stylus

*
  margin 0
  padding 0

body
  display flex
  flex-direction row
  justify-content center
  align-items center
  height 100vh
  overflow hidden
  • * 选择器重置默认边距,Stylus 中直接写*+ 缩进即可
  • display flex 替代display: flex;,少写冒号和分号
  • 100vh让 body 占满全屏,overflow hidden隐藏滚动条

(2)容器与面板布局:嵌套语法的妙用

stylus

.container
  display flex
  width 90vw  // 桌面端占视口90%宽度

  .panel
    height 80vh
    border-radius 50px
    color #fff
    cursor pointer
    flex 0.5  // 未激活时占比0.5
    margin 10px
    position relative
    background-size cover
    background-position center
    background-repeat no-repeat
    transition all 700ms ease-in  // 过渡动画:所有属性变化用700ms完成

    h3
      font-size 24px
      position absolute
      left 20px
      bottom 20px
      margin 0
      opacity 0  // 初始隐藏标题
      transition opacity 700ms ease-in 400ms  // 标题延迟400ms显示

    &.active  // 激活状态(&代表父元素.panel)
      flex 5  // 激活时占比5(挤压其他面板)
      h3
        opacity 1  // 显示标题

这段代码的优雅之处:

  • 嵌套层级清晰.container包含.panel.panel包含h3,结构和 HTML 一一对应,不用反复写父选择器
  • & 符号的灵活使用&.active直接表示.panel.active,比 CSS 中重复写.panel简洁太多
  • 过渡动画简写transition all 700ms ease-in 替代冗长的transition: all 700ms ease-in;

(3)响应式适配:媒体查询的极简写法

stylus

@media (max-width 480px)
  .container
    width 100vw  // 移动端占满屏幕

  .panel:nth-of-type(4),
  .panel:nth-of-type(5)
    display none  // 隐藏第4、5个面板

对比原生 CSS 的媒体查询:

css

@media (max-width: 480px) {
  .container {
    width: 100vw;
  }
  .panel:nth-of-type(4), .panel:nth-of-type(5) {
    display: none;
  }
}

Stylus 直接省略了括号和分号,甚至连媒体查询的大括号都省了,缩进即层级 —— 写响应式布局像写普通样式一样自然。

三、Stylus 的其他 "爽点":不止于简洁

案例中用到的只是 Stylus 的基础功能,它真正强大的地方在于 "编程特性":

1. 变量:一次定义,多处复用

stylus

// 定义主题变量
primary-color = #3498db
panel-height = 80vh
border-radius = 50px

// 使用变量
.panel
  height panel-height
  border-radius border-radius
  &.active
    border 2px solid primary-color

改样式时只需改变量,不用全局搜索替换,适合大型项目。

2. 混合(Mixins):复用代码块

stylus

// 定义一个"弹性居中"的混合
flex-center()
  display flex
  justify-content center
  align-items center

// 复用混合
.container
  flex-center()  // 直接调用

.btn-group
  flex-center()  // 再次复用

减少重复代码,尤其适合封装常用布局模式(如清除浮动、响应式断点)。

3. 自动前缀:无需手动写 - webkit-

配合stylus-mixin等工具,Stylus 能自动为 CSS3 属性添加浏览器前缀:

stylus

// 写一次
transform scale(1.1)

// 编译后自动生成
-webkit-transform: scale(1.1);
-moz-transform: scale(1.1);
transform: scale(1.1);

四、为什么推荐用 Stylus?

从案例和特性来看,Stylus 的核心优势在于:

  1. 极简语法:少写 80% 的冗余符号,专注逻辑而非格式
  2. 嵌套清晰:HTML 结构和 CSS 样式一一对应,降低维护成本
  3. 编程能力:变量、混合、函数等特性,让 CSS 具备 "可复用、可扩展" 能力
  4. 无缝过渡:完全兼容 CSS 语法,不会 CSS 也能快速上手

五、最后:从案例到生产

本文的案例代码可以直接运行,你可以:

  1. 替换图片 URL 为自己的资源
  2. 调整flex值和transition时间改变动画效果
  3. 新增媒体查询断点适配更多设备

如果要在生产环境使用,建议结合构建工具(如 Webpack、Vite)配置 Stylus-loader,实现自动编译和压缩。

Stylus 不是银弹,但它绝对是提升 CSS 开发效率的 "利器"。如果你受够了写冗余的 CSS,不妨从这个响应式面板案例开始,感受 Stylus 带来的优雅 —— 毕竟,谁不想用更少的代码,做更多的事呢?

Javascript常见面试题

作者 东方石匠
2025年10月29日 16:36

目录

  1. ES6+ 核心特性
  2. 原型链与继承
  3. 闭包与作用域
  4. 异步编程
  5. 手写实现
  6. 进阶技巧

ES6+ 核心特性

1. let 和 const

// var 存在变量提升和函数作用域问题
console.log(a); // undefined
var a = 1;

// let 和 const 是块级作用域,不存在变量提升
console.log(b); // ReferenceError
let b = 2;

// const 声明的常量不能重新赋值
const PI = 3.14;
PI = 3.15; // TypeError

// 但可以修改对象的属性
const obj = { name: 'Tom' };
obj.name = 'Jerry'; // 可以
obj = {}; // TypeError

学习要点:

  • var、let、const 的区别
  • 暂时性死区(TDZ)
  • 块级作用域的应用场景

2. 解构赋值

数组解构

// 基础用法
const [a, b, c] = [1, 2, 3];

// 跳过某些值
const [first, , third] = [1, 2, 3];

// 默认值
const [x = 1, y = 2] = [10];
console.log(x, y); // 10, 2

// 剩余参数
const [head, ...tail] = [1, 2, 3, 4];
console.log(head); // 1
console.log(tail); // [2, 3, 4]

// 交换变量
let a = 1, b = 2;
[a, b] = [b, a];

对象解构

// 基础用法
const { name, age } = { name: 'Tom', age: 18 };

// 重命名
const { name: userName, age: userAge } = { name: 'Tom', age: 18 };

// 默认值
const { x = 10, y = 20 } = { x: 30 };
console.log(x, y); // 30, 20

// 嵌套解构
const user = {
  name: 'Tom',
  address: {
    city: 'Beijing',
    district: 'Chaoyang'
  }
};
const { address: { city } } = user;

// 函数参数解构
function getUserInfo({ name, age = 18 }) {
  console.log(name, age);
}
getUserInfo({ name: 'Tom' });

3. 箭头函数

// 基础语法
const add = (a, b) => a + b;
const square = x => x * x; // 单参数可省略括号
const greet = () => 'Hello'; // 无参数

// 返回对象需要加括号
const getUser = id => ({ id, name: 'Tom' });

// this 指向特性
const obj = {
  name: 'Tom',
  sayHi: function() {
    setTimeout(() => {
      console.log(this.name); // 'Tom',箭头函数继承外层 this
    }, 1000);
  },
  sayHello: function() {
    setTimeout(function() {
      console.log(this.name); // undefined,普通函数 this 指向 window
    }, 1000);
  }
};

箭头函数特点:

  • 没有自己的 this,继承外层作用域的 this
  • 不能作为构造函数
  • 没有 arguments 对象
  • 不能使用 yield 命令

4. 模板字符串

// 基础用法
const name = 'Tom';
const age = 18;
const message = `My name is ${name}, I'm ${age} years old.`;

// 多行字符串
const html = `
  <div>
    <h1>${name}</h1>
    <p>Age: ${age}</p>
  </div>
`;

// 表达式计算
const price = 100;
const count = 3;
console.log(`Total: ${price * count}`);

// 标签模板
function highlight(strings, ...values) {
  return strings.reduce((result, str, i) => {
    return result + str + (values[i] ? `<strong>${values[i]}</strong>` : '');
  }, '');
}

const result = highlight`Name: ${name}, Age: ${age}`;
console.log(result); // Name: <strong>Tom</strong>, Age: <strong>18</strong>

5. 扩展运算符与剩余参数

// 数组扩展运算符
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const combined = [...arr1, ...arr2]; // [1, 2, 3, 4, 5, 6]

// 数组复制
const original = [1, 2, 3];
const copy = [...original];

// 数组去重
const arr = [1, 2, 2, 3, 3, 4];
const unique = [...new Set(arr)]; // [1, 2, 3, 4]

// 对象扩展运算符
const obj1 = { a: 1, b: 2 };
const obj2 = { c: 3, d: 4 };
const merged = { ...obj1, ...obj2 }; // { a: 1, b: 2, c: 3, d: 4 }

// 对象浅拷贝
const user = { name: 'Tom', age: 18 };
const userCopy = { ...user };

// 剩余参数
function sum(...numbers) {
  return numbers.reduce((total, num) => total + num, 0);
}
console.log(sum(1, 2, 3, 4)); // 10

// 结合解构
const [first, ...rest] = [1, 2, 3, 4];
const { name, ...others } = { name: 'Tom', age: 18, city: 'Beijing' };

6. 默认参数

// 基础用法
function greet(name = 'Guest') {
  return `Hello, ${name}`;
}

// 默认参数可以是表达式
function calculate(a, b = a * 2) {
  return a + b;
}

// 默认参数与解构结合
function createUser({ name = 'Anonymous', age = 0 } = {}) {
  return { name, age };
}

// 函数默认值是惰性求值的
let x = 1;
function foo(y = x) {
  console.log(y);
}
foo(); // 1
x = 2;
foo(); // 2

7. Promise

// 基础用法
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve('Success!');
    } else {
      reject('Error!');
    }
  }, 1000);
});

promise
  .then(result => console.log(result))
  .catch(error => console.error(error))
  .finally(() => console.log('Done'));

// Promise 链式调用
fetch('/api/user')
  .then(response => response.json())
  .then(data => {
    console.log(data);
    return fetch(`/api/posts/${data.id}`);
  })
  .then(response => response.json())
  .then(posts => console.log(posts))
  .catch(error => console.error(error));

// Promise.all - 并发执行,全部成功才成功
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);

Promise.all([p1, p2, p3])
  .then(results => console.log(results)); // [1, 2, 3]

// Promise.race - 返回最快的结果
Promise.race([p1, p2, p3])
  .then(result => console.log(result)); // 最快返回的那个

// Promise.allSettled - 等待全部完成,不管成功失败
Promise.allSettled([p1, p2, p3])
  .then(results => console.log(results));

// Promise.any - 只要有一个成功就成功
Promise.any([p1, p2, p3])
  .then(result => console.log(result));

8. async/await

// 基础用法
async function fetchUser() {
  try {
    const response = await fetch('/api/user');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(error);
  }
}

// 并发请求
async function fetchAll() {
  try {
    const [users, posts, comments] = await Promise.all([
      fetch('/api/users').then(r => r.json()),
      fetch('/api/posts').then(r => r.json()),
      fetch('/api/comments').then(r => r.json())
    ]);
    return { users, posts, comments };
  } catch (error) {
    console.error(error);
  }
}

// 顺序执行
async function processInSequence(urls) {
  const results = [];
  for (const url of urls) {
    const response = await fetch(url);
    const data = await response.json();
    results.push(data);
  }
  return results;
}

// 错误处理
async function getUserData(id) {
  try {
    const user = await fetchUser(id);
    const posts = await fetchUserPosts(user.id);
    return { user, posts };
  } catch (error) {
    console.error('Error:', error);
    throw error; // 可以继续向上抛出
  }
}

9. Class 类

// 基础类
class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  sayHi() {
    console.log(`Hi, I'm ${this.name}`);
  }

  // 静态方法
  static create(name, age) {
    return new Person(name, age);
  }

  // getter
  get info() {
    return `${this.name}, ${this.age}`;
  }

  // setter
  set info(value) {
    const [name, age] = value.split(',');
    this.name = name;
    this.age = parseInt(age);
  }
}

// 继承
class Student extends Person {
  constructor(name, age, grade) {
    super(name, age); // 调用父类构造函数
    this.grade = grade;
  }

  sayHi() {
    super.sayHi(); // 调用父类方法
    console.log(`I'm in grade ${this.grade}`);
  }

  study() {
    console.log(`${this.name} is studying`);
  }
}

// 使用
const student = new Student('Tom', 18, 12);
student.sayHi();
student.study();

// 私有属性(ES2022)
class BankAccount {
  #balance = 0; // 私有属性

  deposit(amount) {
    this.#balance += amount;
  }

  getBalance() {
    return this.#balance;
  }
}

10. 模块化

// 导出 - module.js
export const PI = 3.14;
export function add(a, b) {
  return a + b;
}
export class Calculator {
  // ...
}

// 默认导出
export default function multiply(a, b) {
  return a * b;
}

// 导入
import multiply, { PI, add, Calculator } from './module.js';

// 全部导入
import * as math from './module.js';

// 重命名导入
import { add as sum } from './module.js';

// 仅执行模块
import './init.js';

// 动态导入
async function loadModule() {
  const module = await import('./module.js');
  module.default();
}

原型链与继承

1. 原型基础

// 构造函数
function Person(name, age) {
  this.name = name;
  this.age = age;
}

// 在原型上添加方法
Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

const person = new Person('Tom', 18);
person.sayHi(); // Hi, I'm Tom

// 原型链关系
console.log(person.__proto__ === Person.prototype); // true
console.log(Person.prototype.constructor === Person); // true
console.log(person.__proto__.__proto__ === Object.prototype); // true

// 检查原型
console.log(person instanceof Person); // true
console.log(Person.prototype.isPrototypeOf(person)); // true

原型链图解:

person 
  ↓ __proto__
Person.prototype 
  ↓ __proto__
Object.prototype 
  ↓ __proto__
null

2. 继承的多种方式

1) 原型链继承

function Parent() {
  this.name = 'parent';
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child() {
  this.age = 18;
}

Child.prototype = new Parent();

const child1 = new Child();
const child2 = new Child();

// 缺点:所有实例共享引用类型属性
child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue', 'green']

2) 构造函数继承

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name); // 调用父类构造函数
  this.age = age;
}

const child1 = new Child('Tom', 18);
const child2 = new Child('Jerry', 20);

child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue']

// 缺点:无法继承父类原型上的方法
console.log(child1.getName); // undefined

3) 组合继承(推荐)

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name); // 第二次调用 Parent
  this.age = age;
}

Child.prototype = new Parent(); // 第一次调用 Parent
Child.prototype.constructor = Child;

const child1 = new Child('Tom', 18);
const child2 = new Child('Jerry', 20);

child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue']
console.log(child1.getName()); // 'Tom'

// 缺点:调用了两次父类构造函数

4) 寄生组合继承(最佳)

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}

// 关键:使用 Object.create
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

const child = new Child('Tom', 18);
console.log(child.getName()); // 'Tom'

// 封装继承函数
function inherit(Child, Parent) {
  Child.prototype = Object.create(Parent.prototype);
  Child.prototype.constructor = Child;
}

3. Object.create 深入

// Object.create 创建一个新对象,使用现有对象作为原型
const parent = {
  name: 'parent',
  sayHi() {
    console.log(`Hi, I'm ${this.name}`);
  }
};

const child = Object.create(parent);
child.name = 'child';
child.sayHi(); // Hi, I'm child

// Object.create 的实现
function create(proto) {
  function F() {}
  F.prototype = proto;
  return new F();
}

// 创建一个没有原型的对象
const obj = Object.create(null);
console.log(obj.__proto__); // undefined

闭包与作用域

1. 作用域链

// 全局作用域
const globalVar = 'global';

function outer() {
  // 外层函数作用域
  const outerVar = 'outer';
  
  function inner() {
    // 内层函数作用域
    const innerVar = 'inner';
    console.log(globalVar); // 'global'
    console.log(outerVar);  // 'outer'
    console.log(innerVar);  // 'inner'
  }
  
  inner();
  // console.log(innerVar); // ReferenceError
}

outer();

2. 闭包原理

// 基础闭包
function createCounter() {
  let count = 0;
  return function() {
    return ++count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

// 闭包应用:私有变量
function createPerson(name) {
  let _age = 0; // 私有变量
  
  return {
    getName() {
      return name;
    },
    getAge() {
      return _age;
    },
    setAge(age) {
      if (age > 0 && age < 150) {
        _age = age;
      }
    }
  };
}

const person = createPerson('Tom');
person.setAge(18);
console.log(person.getAge()); // 18
console.log(person._age); // undefined

// 闭包应用:模块化
const calculator = (function() {
  let result = 0;
  
  return {
    add(num) {
      result += num;
      return this;
    },
    subtract(num) {
      result -= num;
      return this;
    },
    multiply(num) {
      result *= num;
      return this;
    },
    getResult() {
      return result;
    }
  };
})();

calculator.add(10).multiply(2).subtract(5).getResult(); // 15

3. 闭包常见问题

问题1:循环中的闭包

// 错误示例
for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 输出 5 个 5
  }, 1000);
}

// 解决方案1:使用 IIFE
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j); // 0, 1, 2, 3, 4
    }, 1000);
  })(i);
}

// 解决方案2:使用 let
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 0, 1, 2, 3, 4
  }, 1000);
}

// 解决方案3:使用 bind
for (var i = 0; i < 5; i++) {
  setTimeout(function(j) {
    console.log(j);
  }.bind(null, i), 1000);
}

问题2:内存泄漏

// 可能造成内存泄漏
function outer() {
  const largeData = new Array(1000000).fill('data');
  
  return function inner() {
    console.log('Hello');
    // inner 引用了 outer 的作用域
    // 即使不使用 largeData,它也不会被回收
  };
}

// 改进:只保留需要的数据
function outer() {
  const largeData = new Array(1000000).fill('data');
  const needed = largeData.slice(0, 10);
  
  return function inner() {
    console.log(needed); // 只保留需要的部分
  };
}

4. this 指向详解

// 1. 默认绑定
function foo() {
  console.log(this); // 严格模式: undefined, 非严格模式: window
}

// 2. 隐式绑定
const obj = {
  name: 'Tom',
  sayName() {
    console.log(this.name);
  }
};
obj.sayName(); // 'Tom'

const sayName = obj.sayName;
sayName(); // undefined,this 丢失

// 3. 显式绑定
function greet() {
  console.log(`Hello, ${this.name}`);
}

const user = { name: 'Tom' };
greet.call(user); // Hello, Tom
greet.apply(user); // Hello, Tom
const boundGreet = greet.bind(user);
boundGreet(); // Hello, Tom

// 4. new 绑定
function Person(name) {
  this.name = name;
}
const person = new Person('Tom');
console.log(person.name); // 'Tom'

// 5. 箭头函数
const obj2 = {
  name: 'Tom',
  sayName: () => {
    console.log(this.name); // 继承外层 this
  }
};

// 优先级:new > 显式绑定 > 隐式绑定 > 默认绑定

异步编程

1. 事件循环(Event Loop)

console.log('1'); // 同步任务

setTimeout(() => {
  console.log('2'); // 宏任务
}, 0);

Promise.resolve().then(() => {
  console.log('3'); // 微任务
});

console.log('4'); // 同步任务

// 输出顺序:1, 4, 3, 2

// 复杂示例
async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

async1();

new Promise(resolve => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
});

console.log('script end');

/* 输出顺序:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/

宏任务 vs 微任务

  • 宏任务:setTimeout, setInterval, setImmediate, I/O, UI渲染
  • 微任务:Promise.then, MutationObserver, process.nextTick

执行顺序

  1. 执行同步代码
  2. 执行所有微任务
  3. 执行一个宏任务
  4. 执行所有微任务
  5. 重复 3-4

2. Promise 进阶

实现 Promise.all

Promise.myAll = function(promises) {
  return new Promise((resolve, reject) => {
    if (!Array.isArray(promises)) {
      return reject(new TypeError('参数必须是数组'));
    }
    
    const results = [];
    let completedCount = 0;
    
    if (promises.length === 0) {
      resolve(results);
      return;
    }
    
    promises.forEach((promise, index) => {
      Promise.resolve(promise).then(
        value => {
          results[index] = value;
          completedCount++;
          
          if (completedCount === promises.length) {
            resolve(results);
          }
        },
        reason => {
          reject(reason);
        }
      );
    });
  });
};

实现 Promise.race

Promise.myRace = function(promises) {
  return new Promise((resolve, reject) => {
    if (!Array.isArray(promises)) {
      return reject(new TypeError('参数必须是数组'));
    }
    
    promises.forEach(promise => {
      Promise.resolve(promise).then(resolve, reject);
    });
  });
};

Promise 链式调用

// 串行执行多个异步任务
function runInSequence(tasks) {
  return tasks.reduce((promise, task) => {
    return promise.then(result => {
      return task().then(Array.prototype.concat.bind(result));
    });
  }, Promise.resolve([]));
}

// 使用示例
const tasks = [
  () => Promise.resolve(1),
  () => Promise.resolve(2),
  () => Promise.resolve(3)
];

runInSequence(tasks).then(results => {
  console.log(results); // [1, 2, 3]
});

3. async/await 进阶

并发控制

// 控制并发数量
async function asyncPool(poolLimit, array, iteratorFn) {
  const results = [];
  const executing = [];
  
  for (const [index, item] of array.entries()) {
    const promise = Promise.resolve().then(() => iteratorFn(item, array));
    results.push(promise);
    
    if (poolLimit <= array.length) {
      const e = promise.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e);
      
      if (executing.length >= poolLimit) {
        await Promise.race(executing);
      }
    }
  }
  
  return Promise.all(results);
}

// 使用示例
const timeout = i => new Promise(resolve => setTimeout(() => resolve(i), i));
asyncPool(2, [1000, 5000, 3000, 2000], timeout).then(results => {
  console.log(results);
});

错误重试

async function retry(fn, times = 3, delay = 1000) {
  for (let i = 0; i < times; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === times - 1) {
        throw error;
      }
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// 使用示例
retry(() => fetch('/api/data'), 3, 2000)
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('重试失败:', error));

超时控制

function timeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) => {
      setTimeout(() => reject(new Error('Timeout')), ms);
    })
  ]);
}

// 使用示例
timeout(fetch('/api/data'), 5000)
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error));

手写实现

1. 防抖(Debounce)

/**
 * 防抖:在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时
 * 应用场景:搜索框输入、窗口 resize
 */
function debounce(func, wait, immediate = false) {
  let timeout;
  
  return function(...args) {
    const context = this;
    
    const later = function() {
      timeout = null;
      if (!immediate) func.apply(context, args);
    };
    
    const callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    
    if (callNow) func.apply(context, args);
  };
}

// 使用示例
const input = document.querySelector('input');
input.addEventListener('input', debounce(function(e) {
  console.log('搜索:', e.target.value);
}, 500));

2. 节流(Throttle)

/**
 * 节流:规定时间内只触发一次
 * 应用场景:滚动事件、按钮点击
 */
function throttle(func, wait) {
  let timeout;
  let previous = 0;
  
  return function(...args) {
    const context = this;
    const now = Date.now();
    const remaining = wait - (now - previous);
    
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      func.apply(context, args);
    } else if (!timeout) {
      timeout = setTimeout(() => {
        previous = Date.now();
        timeout = null;
        func.apply(context, args);
      }, remaining);
    }
  };
}

// 使用示例
window.addEventListener('scroll', throttle(function() {
  console.log('滚动位置:', window.scrollY);
}, 1000));

3. 深拷贝

/**
 * 深拷贝:完整复制对象,包括嵌套对象
 */
function deepClone(obj, hash = new WeakMap()) {
  // null 或非对象类型直接返回
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  
  // 处理 Date
  if (obj instanceof Date) {
    return new Date(obj);
  }
  
  // 处理 RegExp
  if (obj instanceof RegExp) {
    return new RegExp(obj);
  }
  
  // 处理循环引用
  if (hash.has(obj)) {
    return hash.get(obj);
  }
  
  // 创建新对象或数组
  const cloneObj = Array.isArray(obj) ? [] : {};
  hash.set(obj, cloneObj);
  
  // 递归拷贝
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      cloneObj[key] = deepClone(obj[key], hash);
    }
  }
  
  return cloneObj;
}

// 使用示例
const original = {
  name: 'Tom',
  hobbies: ['reading', 'coding'],
  address: {
    city: 'Beijing',
    district: 'Chaoyang'
  }
};

const copy = deepClone(original);
copy.address.city = 'Shanghai';
console.log(original.address.city); // 'Beijing'

4. 实现 call、apply、bind

call 实现

Function.prototype.myCall = function(context, ...args) {
  // context 为 null 或 undefined 时,指向 window
  context = context || window;
  
  // 创建唯一的 key 避免覆盖原有属性
  const fn = Symbol('fn');
  context[fn] = this;
  
  // 执行函数
  const result = context[fn](...args);
  
  // 删除添加的属性
  delete context[fn];
  
  return result;
};

// 测试
function greet(greeting, punctuation) {
  console.log(`${greeting}, I'm ${this.name}${punctuation}`);
}

const user = { name: 'Tom' };
greet.myCall(user, 'Hello', '!'); // Hello, I'm Tom!

apply 实现

Function.prototype.myApply = function(context, args = []) {
  context = context || window;
  
  const fn = Symbol('fn');
  context[fn] = this;
  
  const result = context[fn](...args);
  
  delete context[fn];
  
  return result;
};

// 测试
greet.myApply(user, ['Hi', '.']); // Hi, I'm Tom.

bind 实现

Function.prototype.myBind = function(context, ...args1) {
  const fn = this;
  
  return function(...args2) {
    // 如果是通过 new 调用,this 指向实例
    if (this instanceof fn) {
      return new fn(...args1, ...args2);
    }
    
    return fn.apply(context, [...args1, ...args2]);
  };
};

// 测试
const boundGreet = greet.myBind(user, 'Hey');
boundGreet('~'); // Hey, I'm Tom~

5. 实现 new 操作符

function myNew(Constructor, ...args) {
  // 创建一个新对象,原型指向构造函数的 prototype
  const obj = Object.create(Constructor.prototype);
  
  // 执行构造函数,绑定 this
  const result = Constructor.apply(obj, args);
  
  // 如果构造函数返回对象,则返回该对象,否则返回创建的对象
  return result instanceof Object ? result : obj;
}

// 测试
function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

const person = myNew(Person, 'Tom', 18);
person.sayHi(); // Hi, I'm Tom

6. 实现 instanceof

function myInstanceof(obj, constructor) {
  // 获取对象的原型
  let proto = Object.getPrototypeOf(obj);
  
  // 获取构造函数的 prototype
  const prototype = constructor.prototype;
  
  // 沿着原型链查找
  while (proto) {
    if (proto === prototype) {
      return true;
    }
    proto = Object.getPrototypeOf(proto);
  }
  
  return false;
}

// 测试
console.log(myInstanceof([], Array)); // true
console.log(myInstanceof([], Object)); // true
console.log(myInstanceof({}, Array)); // false

7. 柯里化(Curry)

/**
 * 柯里化:将多参数函数转换为单参数函数序列
 */
function curry(fn) {
  return function curried(...args) {
    // 参数够了就执行
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    
    // 参数不够继续返回函数
    return function(...args2) {
      return curried.apply(this, [...args, ...args2]);
    };
  };
}

// 使用示例
function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6

8. 发布订阅模式

class EventEmitter {
  constructor() {
    this.events = {};
  }
  
  // 订阅事件
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  }
  
  // 发布事件
  emit(event, ...args) {
    if (this.events[event]) {
      this.events[event].forEach(callback => {
        callback(...args);
      });
    }
  }
  
  // 取消订阅
  off(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
  }
  
  // 只订阅一次
  once(event, callback) {
    const fn = (...args) => {
      callback(...args);
      this.off(event, fn);
    };
    this.on(event, fn);
  }
}

// 使用示例
const emitter = new EventEmitter();

function handleLogin(user) {
  console.log('用户登录:', user);
}

emitter.on('login', handleLogin);
emitter.emit('login', { name: 'Tom' }); // 用户登录: { name: 'Tom' }

emitter.off('login', handleLogin);
emitter.emit('login', { name: 'Jerry' }); // 无输出

9. 数组扁平化

// 方法1:递归
function flatten1(arr) {
  const result = [];
  
  arr.forEach(item => {
    if (Array.isArray(item)) {
      result.push(...flatten1(item));
    } else {
      result.push(item);
    }
  });
  
  return result;
}

// 方法2:reduce
function flatten2(arr) {
  return arr.reduce((acc, item) => {
    return acc.concat(Array.isArray(item) ? flatten2(item) : item);
  }, []);
}

// 方法3:使用栈
function flatten3(arr) {
  const stack = [...arr];
  const result = [];
  
  while (stack.length) {
    const item = stack.pop();
    if (Array.isArray(item)) {
      stack.push(...item);
    } else {
      result.unshift(item);
    }
  }
  
  return result;
}

// 方法4:指定深度
function flatten4(arr, depth = 1) {
  if (depth === 0) return arr;
  
  return arr.reduce((acc, item) => {
    return acc.concat(
      Array.isArray(item) ? flatten4(item, depth - 1) : item
    );
  }, []);
}

// 测试
const nested = [1, [2, [3, [4, 5]]]];
console.log(flatten1(nested)); // [1, 2, 3, 4, 5]
console.log(flatten4(nested, 2)); // [1, 2, 3, [4, 5]]

// 原生方法
console.log(nested.flat(Infinity)); // [1, 2, 3, 4, 5]

10. Promise 实现

class MyPromise {
  constructor(executor) {
    this.status = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    
    const resolve = (value) => {
      if (this.status === 'pending') {
        this.status = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };
    
    const reject = (reason) => {
      if (this.status === 'pending') {
        this.status = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };
    
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }
  
  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err };
    
    const promise2 = new MyPromise((resolve, reject) => {
      if (this.status === 'fulfilled') {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            resolve(x);
          } catch (error) {
            reject(error);
          }
        });
      }
      
      if (this.status === 'rejected') {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            resolve(x);
          } catch (error) {
            reject(error);
          }
        });
      }
      
      if (this.status === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value);
              resolve(x);
            } catch (error) {
              reject(error);
            }
          });
        });
        
        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason);
              resolve(x);
            } catch (error) {
              reject(error);
            }
          });
        });
      }
    });
    
    return promise2;
  }
  
  catch(onRejected) {
    return this.then(null, onRejected);
  }
  
  finally(callback) {
    return this.then(
      value => MyPromise.resolve(callback()).then(() => value),
      reason => MyPromise.resolve(callback()).then(() => { throw reason })
    );
  }
  
  static resolve(value) {
    return new MyPromise(resolve => resolve(value));
  }
  
  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
}

// 测试
const promise = new MyPromise((resolve, reject) => {
  setTimeout(() => resolve('Success'), 1000);
});

promise.then(value => {
  console.log(value); // Success
});

进阶技巧

1. 数组高级操作

const users = [
  { id: 1, name: 'Tom', age: 18, city: 'Beijing' },
  { id: 2, name: 'Jerry', age: 20, city: 'Shanghai' },
  { id: 3, name: 'Mike', age: 18, city: 'Beijing' },
  { id: 4, name: 'Lucy', age: 22, city: 'Guangzhou' }
];

// 1. 分组
const groupBy = (arr, key) => {
  return arr.reduce((acc, item) => {
    (acc[item[key]] = acc[item[key]] || []).push(item);
    return acc;
  }, {});
};

const groupedByAge = groupBy(users, 'age');
// { 18: [...], 20: [...], 22: [...] }

// 2. 去重
const unique = arr => [...new Set(arr)];

// 对象数组去重
const uniqueBy = (arr, key) => {
  const seen = new Set();
  return arr.filter(item => {
    const k = item[key];
    return seen.has(k) ? false : seen.add(k);
  });
};

// 3. 排序
users.sort((a, b) => a.age - b.age); // 按年龄升序
users.sort((a, b) => b.age - a.age); // 按年龄降序

// 多条件排序
users.sort((a, b) => {
  return a.age - b.age || a.name.localeCompare(b.name);
});

// 4. 查找
const user = users.find(u => u.id === 2);
const userIndex = users.findIndex(u => u.id === 2);

// 5. 筛选
const adults = users.filter(u => u.age >= 18);

// 6. 映射
const names = users.map(u => u.name);

// 7. 累加
const totalAge = users.reduce((sum, u) => sum + u.age, 0);

// 8. 链式操作
const result = users
  .filter(u => u.age >= 18)
  .map(u => ({ ...u, isAdult: true }))
  .sort((a, b) => a.age - b.age);

2. 对象高级操作

// 1. 合并对象
const obj1 = { a: 1, b: 2 };
const obj2 = { b: 3, c: 4 };
const merged = { ...obj1, ...obj2 }; // { a: 1, b: 3, c: 4 }

// 深度合并
function deepMerge(target, source) {
  for (let key in source) {
    if (source[key] instanceof Object && key in target) {
      Object.assign(source[key], deepMerge(target[key], source[key]));
    }
  }
  return Object.assign(target || {}, source);
}

// 2. 对象转数组
const obj = { a: 1, b: 2, c: 3 };
const entries = Object.entries(obj); // [['a', 1], ['b', 2], ['c', 3]]
const keys = Object.keys(obj); // ['a', 'b', 'c']
const values = Object.values(obj); // [1, 2, 3]

// 3. 数组转对象
const arr = [['a', 1], ['b', 2]];
const objFromArr = Object.fromEntries(arr); // { a: 1, b: 2 }

// 4. 对象属性筛选
function pick(obj, keys) {
  return keys.reduce((acc, key) => {
    if (obj.hasOwnProperty(key)) {
      acc[key] = obj[key];
    }
    return acc;
  }, {});
}

const user = { name: 'Tom', age: 18, email: 'tom@example.com' };
const picked = pick(user, ['name', 'age']); // { name: 'Tom', age: 18 }

// 5. 对象属性排除
function omit(obj, keys) {
  return Object.keys(obj)
    .filter(key => !keys.includes(key))
    .reduce((acc, key) => {
      acc[key] = obj[key];
      return acc;
    }, {});
}

const omitted = omit(user, ['email']); // { name: 'Tom', age: 18 }

// 6. 冻结对象
const frozen = Object.freeze({ a: 1 });
frozen.a = 2; // 无效
frozen.b = 3; // 无效

// 7. 密封对象
const sealed = Object.seal({ a: 1 });
sealed.a = 2; // 有效
sealed.b = 3; // 无效

3. 字符串技巧

// 1. 模板字符串高级用法
const tag = (strings, ...values) => {
  return strings.reduce((result, str, i) => {
    return result + str + (values[i] ? `<strong>${values[i]}</strong>` : '');
  }, '');
};

const name = 'Tom';
const age = 18;
const result = tag`Name: ${name}, Age: ${age}`;

// 2. 字符串填充
'5'.padStart(3, '0'); // '005'
'5'.padEnd(3, '0'); // '500'

// 3. 重复
'ha'.repeat(3); // 'hahaha'

// 4. 首字母大写
const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);

// 5. 驼峰转换
const camelCase = str => {
  return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
};
camelCase('hello-world'); // 'helloWorld'

// 6. 蛇形转换
const snakeCase = str => {
  return str.replace(/([A-Z])/g, '_$1').toLowerCase();
};
snakeCase('helloWorld'); // 'hello_world'

// 7. 截断字符串
const truncate = (str, length) => {
  return str.length > length ? str.slice(0, length) + '...' : str;
};

4. 正则表达式

// 1. 邮箱验证
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
emailRegex.test('user@example.com'); // true

// 2. 手机号验证(中国)
const phoneRegex = /^1[3-9]\d{9}$/;
phoneRegex.test('13800138000'); // true

// 3. URL 验证
const urlRegex = /^https?:\/\/.+/;
urlRegex.test('https://example.com'); // true

// 4. 密码强度(至少8位,包含大小写字母和数字)
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;

// 5. 提取所有数字
const extractNumbers = str => str.match(/\d+/g);
extractNumbers('abc123def456'); // ['123', '456']

// 6. 替换
const text = 'Hello World';
text.replace(/World/, 'JavaScript'); // 'Hello JavaScript'
text.replace(/o/g, '0'); // 'Hell0 W0rld'

// 7. 命名捕获组
const dateRegex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = '2024-01-15'.match(dateRegex);
console.log(match.groups); // { year: '2024', month: '01', day: '15' }

5. 性能优化技巧

// 1. 使用对象查找代替 switch
// 不推荐
function getDiscount(type) {
  switch(type) {
    case 'VIP':
      return 0.8;
    case 'SVIP':
      return 0.5;
    default:
      return 1;
  }
}

// 推荐
const discountMap = {
  VIP: 0.8,
  SVIP: 0.5,
  default: 1
};

const getDiscount = type => discountMap[type] || discountMap.default;

// 2. 避免不必要的计算
// 不推荐
for (let i = 0; i < arr.length; i++) {
  // arr.length 每次都计算
}

// 推荐
const len = arr.length;
for (let i = 0; i < len; i++) {
  // 只计算一次
}

// 3. 使用 requestAnimationFrame
function animate() {
  // 动画逻辑
  requestAnimationFrame(animate);
}

// 4. 使用 Web Workers 处理复杂计算
const worker = new Worker('worker.js');
worker.postMessage({ data: largeArray });
worker.onmessage = function(e) {
  console.log('结果:', e.data);
};

// 5. 使用 DocumentFragment 批量操作 DOM
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}
document.querySelector('ul').appendChild(fragment);

学习资源推荐

在线文档

书籍

  • 《JavaScript 高级程序设计(第4版)》
  • 《你不知道的 JavaScript》(上、中、下)
  • 《JavaScript 设计模式与开发实践》
  • 《深入理解 ES6》

练习平台

视频课程

  • 慕课网 JavaScript 进阶课程
  • B站前端技术分享
  • 极客时间《重学前端》

学习建议

  1. 循序渐进:从基础开始,不要跳跃式学习
  2. 多写代码:理论结合实践,每个知识点都要写 Demo
  3. 阅读源码:学习优秀开源项目的代码风格和设计思想
  4. 总结归纳:定期整理笔记,构建知识体系
  5. 持续学习:JavaScript 在不断发展,保持学习热情

练习题目

基础题

  1. 实现一个函数,判断两个对象是否相等
  2. 实现数组的 map、filter、reduce 方法
  3. 实现一个简单的模板引擎
  4. 实现 Promise.all 和 Promise.race
  5. 实现一个 LRU 缓存

进阶题

  1. 实现一个完整的 Promise(符合 Promises/A+ 规范)
  2. 实现一个虚拟 DOM 和 diff 算法
  3. 实现一个简单的响应式系统(类似 Vue)
  4. 实现一个发布订阅模式的事件总线
  5. 实现一个带并发控制的请求调度器

实战项目

  1. 实现一个 TodoList 应用(包含增删改查)
  2. 实现一个图片懒加载库
  3. 实现一个无限滚动列表
  4. 实现一个拖拽排序功能
  5. 实现一个简单的打包工具

持续更新中... 🚀

祝学习顺利!如有问题欢迎交流探讨。

老板问我:AI真能一键画广州旅游路线图?我用 MCP 现场开图

2025年10月29日 16:18

老板那天凑过来:“听说AI能一键画旅游地图?真的假的?”

我笑了:“来,给您现场演示一下。”

打开电脑,三下五除二配置好环境。在对话框里输入:“生成广州旅游路线地图,要能交互的。”

3秒后,一个精美地图跃然屏上——广州塔、沙面、陈家祠等清晰标注。

老板瞪大眼睛:“这就完了?太神奇了吧!”

“这就是MCP的魔力,让AI从聊天工具变成生产力神器。”

他摸着下巴:“看来我们得抓紧用起来了。”

想知道怎么实现?本文将从零开始,教你实现这个"魔法"

效果预览:

生成可交互地图的效果图.gif

MCP简要介绍

MCP(Model Context Protocol)就是能够让 AI 大模型更好地使用各类工具的一个协议。

AI 模型只是一个大脑,而 MCP 协议就是让模型加上手脚,可以帮你干更多的事情,比如:

  • 读取、修改你的本地文件
  • 使用浏览器上网查询信息
  • 查询实时交通路况
  • 生成各种图表、地图路线

总之,MCP 能干的事情可太多了!要知道,大模型本身其实只会问答,它自己并不会使用外部工具,而 MCP 的出现就等于是让大模型拥有了使用各种外部工具的能力。

不过要想使用 MCP,你还得用到一个东西叫做 MCP Host 。

MCP Host

MCP Host 就是一个支持 MCP 协议的软件环境,能"跑"AI 模型,并且让 AI 模型能用各种外部工具。

常见 MCP Host:

  • Claude Desktop
  • Cursor
  • Trae
  • Cline (我们今天要使用的例子)

我们今天使用 Cline 为例来讲讲 MCP 的使用方法。

安装Mcp host (Client)

cline是vscode的一个插件,首先我们要下载vscode编辑器,然后在插件商店搜索cline并安装,安装好了之后侧边栏就会出现一个cline图标,点一下就进入使用cline的地方。

配置Cline用的API key

紧接着,我们需要配置ai模型,cline支持接入不同模型的api,如cluade、gpt、deepseek等模型,我们这里演示使用deepseek模型进行演示。

deepseek的官方网站就有提供api,我们可以到deepseek官方网站注册登录,并充值获取api。

创建好key之后,将key给cline,填好之后,cline会引导我们来到聊天页面,我们随便问它一个问题,给他打个招呼,看他能不能够正常回复,能正常回复就说明接入deepseek模型成功了

概念解释:MCP Server 和 Tool

MCP Server

MCP Server 即 MCP 服务器,跟我们传统意义上的 Server 并没有什么太大的关系,它就是一个程序而已,只不过这个程序的执行是符合 MCP 协议的。

大部分的 MCP 服务器都是本地通过 Node 或者是 Python 启动的,只不过在使用的过程中可能会联网,当然它也可能不联网,纯本地使用也是可以的。不管是联不联网,它都可以叫做 MCP Server,本质就是给 MCP 客户端即 AI 模型提供服务的。

Tool

所以 MCP Server 本质就是一个程序,就像手机上的应用,这些应用都内置了一些功能模块,而 Tool 就是 MCP Server 程序中的一些工具函数。

可以把 MCP Server 理解为一个计算器应用,这个计算器有计算和换算两个功能,作为用户可以根据自己需求选择计算还是换算功能,而这两个功能就是两个Tool。

比如我们要让 DeepSeek 生成一个可交互的广州旅游路线地图,DeepSeek 是没办法完成的,但是我们可以安装一个处理生成图表的 MCP Server,它内部包含一个函数 generate_path_mapTool,这个功能是传入地点、图的大小就可以返回路线地图。

所以我们要一个广州旅游路线地图的话,就得让cline安装处理生成图表的MCP Server,然后deepseek把地点、图的大小这些参数传给MCP Server的generate_path_map就可以拿到一个可交互的广州旅游路线地图了。

配置MCP Server

前面解释了MCP Server和Tool的概念,我们再回到cline这里继续实操。

首先我们打开进入cline,进入MCP Server 设置页面,点击“已安装”,再点击“配置MCP服务器”,之后cline就会跟我们打开一个cline_mcp_settings.json文件。如果我们想新增一个MCPServer的话,我们只需要在里面填入对应的启动命令就行了。

如下操作MCPServer配置.gif

使用他人制作的MCP Server

接下来我们来安装一个别人写好的MCP Server,我们打开mcpmarket.cn/ ,这是一个MCP Server的市场,就跟我们手机的应用市场有点像,这里面有很多别人写好的MCP Server,我们去找生成图表的MCP ,复制配置就可以生效了

生成图表的MCP Serve工具链接: mcpmarket.cn/server/680e…

跟着以下的操作图进行操作使用他人制作的MCP Server.gif

按照网站上的说明,将配置添加到 cline_mcp_settings.json 文件中

window用户

{
  "mcpServers": {
    "mcp-server-chart": {
      "command": "cmd",
      "args": ["/c", "npx", "-y", "@antv/mcp-server-chart"]
    }
  }
}

mac用户

{
  "mcpServers": {
    "mcp-server-chart": {
      "command": "npx",
      "args": ["-y", "@antv/mcp-server-chart"]
    }
  }
}

注意:电脑要装Node.js环境

没有的话要自行安装哦: nodejs.org/en/download…

实战演示

配置完成后,你就可以在 Cline 中输入:

"生成一个广州的旅游路线地图"

DeepSeek 会自动调用 MCP Server 的相关工具,为你生成一个精美的交互式地图!

生成可交互地图的效果图.gif

MCP交互流程详解

sequenceDiagram
    participant 用户
    participant Cline
    participant deepseek
    participant MCP as MCP Server
    
    用户->>Cline: 生成广州旅游路线地图
    Cline->>deepseek: 用户请求 + 可用工具列表
    deepseek->>Cline: 调用generate_interactive_map工具
    Cline->>MCP: generate_interactive_map(广州, 景点列表)
    MCP->>MCP: 生成交互式地图
    MCP->>Cline: 返回地图
    Cline->>deepseek: 工具执行结果
    deepseek->>Cline: 整理回复和说明
    Cline->>用户: 显示地图和旅游建议
  1. 用户 -> Cline: “帮我生成一个广州的旅游路线地图,要可交互的哦!”
  2. Cline -> deepseek模型::cline把用户的请求和可用的mcp-server-chart工具信息一起交给模型deepseek来想办法
  3. deepseek模型 -> Cline:deepseek模型看到请求和工具后,就想:这个任务可以用mcp-server-chart工具的generate_interactive_map功能,需要指定地点、景点和样式。于是它告诉Cline:“你去调用generate_interactive_map工具,参数是广州、这些景点和旅游路线样式。”
  4. Cline -> MCP Server : Cline就拿着这些参数去调用地图工具(mcp-server-chart)的generate_interactive_map函数。
  5. MCP Server -> Cline: 地图工具mcp-server-chart接到命令后,就忙活起来,生成一个可交互的广州旅游路线地图,然后把生成的结果返回给Cline。
  6. Cline -> deepseek模型: Cline拿到地图后,就把这个结果交给deepseek模型
  7. deepseek模型 -> Cline:模型再组织一下语言,比如解释一下地图怎么用,再传给cline
  8. Cline -> 用户: 然后Cline就把最终的回答和地图一起展示给用户。

总结

上述内容我们主要讲了4点,分别是:

  1. MCP 协议的核心概念 :让 AI 模型拥有使用外部工具的能力
  2. 完整的环境搭建 :从 cline安装到 MCP Server的配置
  3. 实战操作流程 :配置 MCP Server 并生成交互式地图
  4. 技术原理理解 :MCP Host、MCP Server 和 Tool 的关系

如果觉得对您有帮助,欢迎点赞 👍 收藏 ⭐ 关注 🔔 支持一下! 往期实战推荐:

❌
❌