普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月28日技术

深入理解 JavaScript Promise:原理、用法与实践

2025年11月28日 18:17

引言

在现代 JavaScript 开发中,异步编程是无法回避的核心话题。随着 Web 应用复杂度的提升,传统的回调函数(Callback)方式逐渐暴露出“回调地狱”(Callback Hell)等问题。为了解决这一难题,ES6 引入了 Promise 对象,提供了一种更加优雅、可读性更强的异步处理机制。

本文将结合提供的代码示例和文档说明,系统性地讲解 Promise 的基本概念、状态机制、核心方法(如 .then().catch())、链式调用、嵌套 Promise 的行为,并通过实际案例展示其在文件读取等场景中的应用。


一、Promise 是什么?

根据 readme.md 中的定义:

Promise 简单说是一个容器(对象),里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。

Promise 有三种状态:

  • pending(进行中) :初始状态,既不是成功也不是失败。
  • fulfilled(已成功) :操作成功完成。
  • rejected(已失败) :操作失败。

关键特性:

  • 状态不可逆:一旦状态从 pending 变为 fulfilled 或 rejected,就不会再改变
  • 状态由内部决定:Promise 的状态变化由其内部的异步操作决定,不受外界影响

二、Promise 的基本用法

1. 创建 Promise

// 1.js 示例
const p = new Promise(function (resolve, reject) {
  setTimeout(function () {
    let err = '数据读取失败';
    reject(err);
  }, 1000);
});

p.then(
  function (value) {
    console.log(value); // 成功回调
  },
  function (reason) {
    console.log(reason); // 失败回调 → 输出 "数据读取失败"
  }
);

在这个例子中,我们创建了一个在 1 秒后调用 reject 的 Promise。.then() 方法接收两个参数:第一个是 resolve 的回调,第二个是 reject 的回调。

注意:虽然可以这样写,但更推荐使用 .catch() 来统一处理错误(见后文)。

2. Promise 立即执行


// 2.js 示例
let promise = new Promise(function (resolve, reject) {
  console.log('Promise'); // 立即执行
  resolve();
});

promise.then(function () {
  console.log('resolved');
});

console.log('Hi!');

// 输出顺序:
// Promise
// Hi!
// resolved

这说明:

  • Promise 构造函数是同步执行的,所以 'Promise' 最先输出。
  • .then() 中的回调是微任务(microtask) ,会在当前宏任务(script 执行)结束后、下一个宏任务开始前执行,因此 'resolved' 最后输出。

三、Promise 的链式调用与返回新 Promise

1. .then() 返回新 Promise

.then() 方法返回的是一个新的 Promise 实例(注意,不是原来那个 Promise 实例)。因此可以采用链式写法。

这意味着我们可以连续调用多个 .then(),每个 .then() 都可以处理上一个 Promise 的结果。

2. 在 .then() 中返回另一个 Promise

// 5.js 示例
getJSON("/post/1.json")
  .then(post => getJSON(post.commentURL)) // 返回新 Promise
  .then(
    comments => console.log("resolved: ", comments),
    err => console.log("rejected: ", err)
  );

这里的关键在于:第一个 .then() 返回的是 getJSON(...) 的结果,它本身就是一个 Promise。因此,第二个 .then() 会等待这个新 Promise 的状态变化。

  • 如果 post.commentURL 请求成功 → 调用第一个回调(打印 comments)
  • 如果任一环节失败 → 调用第二个回调(打印 error)

这种模式极大简化了多层异步依赖的处理。


四、错误处理:.catch() 的作用


// 6.js 示例
getJSON('/posts.json')
  .then(function (posts) {
    // ...
  })
  .catch(function (error) {
    console.log('发生错误!', error);
  });

根据 readme.md

.catch().then(null, rejection) 的别名,用于指定发生错误时的回调函数。

更重要的是:

  • .catch() 能捕获前面所有 .then() 中抛出的错误(包括同步错误和异步 reject)。
  • 它使得错误处理集中化,避免在每个 .then() 中都写错误回调。

例如:


Promise.resolve()
  .then(() => {
    throw new Error('出错了!');
  })
  .catch(err => {
    console.log(err.message); // "出错了!"
  });

五、嵌套 Promise 与状态传递

这是 Promise 中最容易被误解的部分之一。

// 3.js 示例(注释版)
const p1 = new Promise(function(resolve, reject){
  setTimeout(() => reject(new Error('fail')), 3000);
});

const p2 = new Promise(function(resolve, reject){
  setTimeout(() => resolve(p1), 1000); // resolve 传入的是 p1(另一个 Promise)
});

p2
  .then(result => console.log(result))
  .catch(err => console.log(err)); // 输出 Error: fail

关键点解析:

  • p2 在 1 秒后调用 resolve(p1),但 p1 本身是一个 Promise。
  • 当 resolve() 的参数是一个 Promise 实例时,当前 Promise(p2)的状态将由该 Promise(p1)决定
  • 因此,p2 的状态实际上“代理”了 p1 的状态。
  • 2 秒后(总耗时 3 秒),p1 被 reject,于是 p2 也变为 rejected,触发 .catch()

这一机制使得我们可以“转发”或“组合”多个异步操作,而无需手动监听每个 Promise。


六、实战:链式读取多个文件

// 7.js 示例(修正版)
const p = new Promise((resolve, reject) => {
  FileSystem.readFile('./1.txt', (err, data) => {
    if (err) reject(err);
    else resolve(data);
  });
});

p
  .then(value => {
    return new Promise((resolve, reject) => {
      FileSystem.readFile('./2.txt', (err, data) => {
        if (err) reject(err);
        else resolve([value, data]);
      });
    });
  })
  .then(value => {
    return new Promise((resolve, reject) => {
      FileSystem.readFile('./3.txt', (err, data) => {
        if (err) reject(err);
        else resolve([...value, data]);
      });
    });
  })
  .then(value => {
    console.log(value); // [data1, data2, data3]
  })
  .catch(err => {
    console.error('读取文件出错:', err);
  });

这个例子展示了:

  • 如何通过链式 .then() 依次读取多个文件。
  • 每一步都将之前的结果累积到数组中。
  • 使用 .catch() 统一处理任意一步的 I/O 错误。

虽然现代 Node.js 更推荐使用 fs.promisesasync/await,但此例清晰体现了 Promise 链如何管理依赖型异步流程。


七、最佳实践与注意事项

  1. 始终使用 .catch()
    不要只依赖 .then() 的第二个参数,因为 .then() 内部的同步错误无法被其自身捕获,但能被后续 .catch() 捕获。
  2. 避免“Promise 嵌套地狱”
    不要写 new Promise(resolve => { anotherPromise().then(...) }),应直接返回 Promise。
  3. 理解微任务队列
    Promise 回调属于微任务,执行时机早于 setTimeout 等宏任务。
  4. 不要忽略错误
    未处理的 rejected Promise 会导致“未捕获的异常”,在 Node.js 中可能使进程崩溃。
  5. 考虑使用 async/await
    虽然 Promise 很强大,但在复杂逻辑中,async/await 语法更接近同步代码,可读性更高。

结语

Promise 是 JavaScript 异步编程的基石。它通过状态机模型、链式调用和统一的错误处理机制,有效解决了回调地狱问题。通过本文分析,我们不仅掌握了 Promise 的基本用法,还深入理解了其内部状态传递、嵌套行为和实际应用场景。

掌握 Promise,是迈向现代前端与 Node.js 开发的关键一步。在此基础上,进一步学习 async/awaitPromise.all()Promise.race() 等高级特性,将使你能够构建更加健壮、可维护的异步程序。

正如那句老话:“理解了 Promise,你就理解了 JavaScript 的异步灵魂。

深入理解 JavaScript 词法作用域链:从代码到底层实现机制

作者 San30
2025年11月28日 18:13

一、引言:一个令人困惑的示例

先来看一段看似简单却容易出错的 JavaScript 代码:

// 全局环境
var myName = '极客时间';
let myAgent = 10;
let test = 1;

function bar(){
  console.log(myName);
}

function foo(){
  var myName = '极客邦';
  bar();
}

foo(); // 输出什么?

直觉上,很多人会认为输出应该是 '极客邦',因为 bar() 是在 foo() 内部调用的。但实际上,这段代码输出的是 '极客时间'

为什么会出现这样的结果?这就引出了 JavaScript 中一个核心概念——词法作用域链

二、什么是词法作用域?

词法作用域(Lexical Scope)指的是:变量的可见性由函数在源代码中的声明位置决定,而不是函数被调用的位置

换句话说,解析变量名的"查找路径"(即作用域链)在代码的编译/解析阶段就已经确定好了,与运行时调用栈的顺序无关。这就是为什么 bar() 函数始终访问的是全局的 myName,因为它在源码中就是在全局作用域声明的。

三、更复杂的示例:混合作用域类型

让我们看一个更复杂的例子,包含 varlet 和块级作用域:

function bar() {
  var myName = '极客世界';
  let test1 = 100;
  if (1) {
    let myName = 'Chrome';
    console.log(test); // 这里会输出什么?
  }
}

function foo() {
  var myName = '极客邦';
  let test = 2;
  {
    let test = 3;
    bar();
  }
}

var myName = '极客时间';
let test = 1;
foo();

这段代码展示了:

  • var 的函数级作用域
  • let 的块级作用域
  • 不同位置声明的变量如何相互影响

关键点在于:bar 在源码中声明的位置决定了它能访问的外层词法环境。即使 bar()foo 里的某个块中被调用,它也无法看到 foo 的局部变量(除非 bar 是在 foo 内部声明的)。

四、JavaScript 引擎的内部机制

要真正理解作用域链,我们需要深入到 JavaScript 引擎(如 V8)的实现层面。

执行上下文的组成

每个执行上下文(Execution Context)包含三个核心部分:

  1. Variable Environment(变量环境) - 存储 varfunction 声明
  2. Lexical Environment(词法环境) - 存储 let / const / class 声明
  3. ThisBinding(this 绑定) 及可执行代码

现代 JavaScript 引擎中,变量环境和词法环境是两套独立但协同工作的系统,它们各自维护环境记录(Environment Record),并共享相同的外层指针(outer),构成"并行的作用域链结构"。

编译阶段 vs 执行阶段

JavaScript 函数的执行分为两个关键阶段:

1. 编译阶段(Compilation)

在这个阶段,引擎会:

创建 Variable Environment:

  • 登记 var 声明(初始化为 undefined
  • 登记函数声明(初始化为对应函数对象)

创建 Lexical Environment:

  • 登记 let / const / class 声明,但保持在 TDZ(暂时性死区)
  • 为块级作用域创建独立的词法环境

建立 outer 链接:

  • 确定当前环境的外层环境引用
  • 这个链接基于代码的静态结构,而非运行时调用

2. 执行阶段(Execution)

代码真正开始执行时:

  1. 访问变量时,查找顺序为:

    • 先查 Lexical Environment(块级作用域 + let/const)
    • 找不到则查 Variable Environment(var/function)
    • 再沿着 outer 指针向外层环境查找,直到全局
  2. 环境记录中的值会被不断更新(赋值、初始化等)

执行上下文的内部结构

从实现角度看,执行上下文可以表示为:

Execution Context = {
  EnvironmentRecord: {
    Variable Environment,
    Lexical Environment,
    outer // 指向外层词法环境的引用
  },
  code  // 可执行代码
}

不同类型的声明有不同的处理策略:

  • var:在编译阶段被初始化为 undefined
  • function:在编译阶段被绑定为函数对象
  • let/const:在词法环境中登记,但直到执行到声明语句才正式初始化

五、回到示例:为什么是全局的 myName?

现在我们可以完整解释开头的例子了:

  1. bar 在全局作用域声明,因此 bar.[[Environment]] 指向全局词法环境
  2. bar 执行并访问 myName 时,查找路径是:
    • bar 的局部环境(没有找到)
    • 沿着 [[Environment]] 到全局环境
    • 找到 myName = '极客时间'
  3. barfoo 内部调用的事实不改变[[Environment]] 引用

这就是词法作用域(静态作用域)与动态作用域的核心区别。

六、闭包(closure)是如何“借用”词法作用域的

简单版结论:闭包是函数和其声明时关联的词法环境的组合

function foo(){
  var myName = '极客时间';
  let test1 = 1;
  const test2 = 2;
  var innerBar = {
    getName: function(){
      console.log(test1);
      return myName;
    },
    setName: function(newName){
      myName = newName;
    }
  }
  return innerBar;
}

var bar = foo();
bar.setName('极客邦');
console.log(bar.getName()); // '极客邦'

分析:

  • getName / setNamefoo 内声明,因此它们的 [[Environment]] 指向 foo 的词法环境。
  • foo 返回 innerBar 后,foo 的执行上下文弹出调用栈,但 foo 的词法环境并未被回收,因为 innerBar 中的函数仍然通过闭包引用该环境(环境是“可达”的)。这就是闭包保持自由变量存活的机制。

GC(垃圾回收)角度

  • 只有当 foo 的词法环境不再被任何可达对象(如返回的函数对象)引用时,才会被回收。
  • 因此 bar(上例返回的对象)持有对那块环境的引用,导致 myNametest1 等变量继续存活。

七、常见面试/调试陷阱

  1. 函数在哪里声明,在哪里决定它的外部环境:无论何时调用,外部环境由声明位置决定。
  2. 调用栈 vs 环境:调用栈控制运行顺序和执行上下文的创建/销毁;环境控制变量解析路径,二者不同步。环境包括变量环境和词法环境。
  3. varlet/const 的差别var 是函数级(或全局)绑定且会被提前初始化为 undefinedlet/const 是块级绑定且存在 TDZ。
  4. 闭包不等于内存泄漏:闭包让外层环境继续可达,因此不会被 GC;需要手动断开引用(如把返回对象设为 null)来释放内存。

八、实践建议(写更容易理解、调试的代码)

  • 尽量用 let/const 而不是 var,避免意外提升带来的迷惑。
  • 函数如果需要访问周围变量,尽量把它在恰当的词法位置声明,这样阅读代码时能直观得知依赖关系。
  • 对长期持有闭包引用的场景(如事件回调、定时器、长生命周期对象),显式释放引用或把需要缓存的数据放到显式的对象上,以便管理其生命周期。

九、小结(一句话回顾)

词法作用域链在编译阶段就决定了变量解析路径;闭包则是函数与其声明时词法环境的绑定,正是它使得某些局部变量在函数返回后仍然存活。

ehcarts 实现 饼图扇区间隙+透明外描边

作者 黑幕困兽
2025年11月28日 18:10

image.png

以上是UI的效果图,大致实现思路可以参考echarts官网的实例 (饼图扇区间隙)实现类似的效果。

image.png

配置如下:

option = {
  tooltip: {
    trigger: 'item'
  },
  legend: {
    top: '5%',
    left: 'center'
  },
  series: [
    {
      name: 'Access From',
      type: 'pie',
      radius: ['35%', '50%'],
      avoidLabelOverlap: false,
      padAngle: 2,
      itemStyle: {
        borderRadius: 0
      },
      label: {
        show: false,
        position: 'center'
      },
      emphasis: {
        label: {
          show: true,
          fontSize: 20,
          fontWeight: 'bold'
        }
      },
      labelLine: {
        show: false
      },
      data: [
        { value: 1048, name: 'Search Engine' },
        { value: 735, name: 'Direct' },
        { value: 580, name: 'Email' },
        { value: 484, name: 'Union Ads' },
        { value: 300, name: 'Video Ads' }
      ]
    },
    {
      name: 'Access From',
      type: 'pie',
      radius: ['50%', '55%'],
      avoidLabelOverlap: false,
      padAngle: 2,
      itemStyle: {
        borderRadius: 0,
        opacity: 0.2
      },
      label: {
        show: false,
        position: 'center'
      },
      emphasis: {
        label: {
          show: false
        }
      },
      labelLine: {
        show: false
      },
      data: [
        { value: 1048, name: 'Search Engine' },
        { value: 735, name: 'Direct' },
        { value: 580, name: 'Email' },
        { value: 484, name: 'Union Ads' },
        { value: 300, name: 'Video Ads' }
      ]
    }
  ]
};

再调整一些参数,基本上能满足UI的效果,这里不详细赘述。

Next.js SEO 优化完整方案

作者 七淮
2025年11月28日 17:51

适用于 Next.js 15(App Router) 的 SEO 全流程优化指南,包括页面级 SEO、站点级 SEO、组件优化、性能优化、结构化数据、国际化等内容。


1. 页面级 SEO

1.1 使用 metadata 配置页面 SEO

// app/page.tsx
export const metadata = {
  title: "首页标题 | 品牌词",
  description: "页面描述,建议 50~160 字。",
  keywords: ["关键词1", "关键词2"],
  openGraph: {
    title: "OG 标题",
    description: "OG 描述",
    url: "https://xxx.com",
    images: [{ url: "/og.jpg" }],
  },
  alternates: {
    canonical: "https://xxx.com",
  },
};

1.2 动态页面 SEO(如文章详情)

// app/blog/[id]/page.tsx
export async function generateMetadata({ params }) {
  const data = await getPost(params.id);

  return {
    title: data.title,
    description: data.summary,
    openGraph: {
      images: data.cover,
    },
    alternates: {
      canonical: `https://xxx.com/blog/${params.id}`,
    },
  };
}

2. 渲染模式与 SEO

渲染方式 SEO 效果 适用场景
SSR(默认) ⭐⭐⭐⭐ 动态数据页面
SSG ⭐⭐⭐⭐⭐ 静态内容、博客
ISR ⭐⭐⭐⭐⭐ 内容频繁更新页面

ISR 使用示例

export const revalidate = 60; // 页面缓存 60 秒

3. URL 结构优化

  • 使用语义化目录: /blog/xxx
  • 避免 query 作主要结构: /search?q=xxx
  • URL 小写、简短、语义化

4. 站点级 SEO

4.1 robots.txt

// app/robots.ts
export default function Robots() {
  return {
    rules: [{ userAgent: "*", allow: "/" }],
    sitemap: "https://xxx.com/sitemap.xml",
  };
}

4.2 sitemap.xml 自动生成

// app/sitemap.ts
export default async function sitemap() {
  const posts = await getPosts();

  return [
    { url: "https://xxx.com", lastModified: new Date() },
    ...posts.map(p => ({
      url: `https://xxx.com/blog/${p.id}`,
      lastModified: p.updated_at,
    })),
  ];
}

5. 组件级 SEO

5.1 使用语义标签

<main>
<article>
<header>
<footer>
<section>
<nav>

5.2 使用 next/image 优化图片

<Image src="/hero.png" alt="banner" width={800} height={600} />

5.3 延迟加载非关键组件

const Comments = dynamic(() => import('./Comments'), { ssr: false });

6. 性能优化(SEO 强关联)

  • 仅在必要组件使用 use client
  • 使用 next/image(自动压缩、lazyload、webp)
  • 减少 API 延迟:Edge Runtime、Server Actions
  • 打包体积优化(减少第三方库)

7. 国际化 SEO(可选)

export const metadata = {
  alternates: {
    canonical: "https://xxx.com",
    languages: {
      "en-US": "https://xxx.com/en",
      "zh-CN": "https://xxx.com/zh",
    },
  },
};

8. 结构化数据(Rich Snippets)

<script type="application/ld+json">
{JSON.stringify({
  "@context": "https://schema.org",
  "@type": "Article",
  headline: title,
  datePublished: created,
  dateModified: updated,
  author: { "@type": "Person", name: "作者名" }
})}
</script>

9. 上线前 SEO Checklist

项目 状态
页面 metadata 配置完整
sitemap.xml 正常生成
robots.txt 正常访问
canonical 链接填写
OG 信息正常
渲染方式:SSR/SSG/ISR
URL 语义化
图片全部用 next/image
lighthouse ≥ 90
结构化数据(可选)

10. metadata 字段说明

字段 作用
title 页面标题
description SEO 摘要
keywords 关键词(影响极弱,可选)
openGraph 社交媒体分享卡片信息
alternates.canonical 主 URL,用于防止重复页面降权
alternates.languages 多语言 SEO

11. 推荐实践总结

  1. 优先 SSR 或 SSG 渲染关键内容
  2. metadata + canonical + sitemap + robots.txt 配置完整
  3. URL 简短语义化,避免重复
  4. 使用 next/image、语义化标签和动态加载优化性能
  5. 配置 OpenGraph 和结构化数据提升社交分享与搜索展示效果
  6. 国际化站点务必设置语言 alternates
  7. 定期使用 Lighthouse 或 PageSpeed 检测性能

JavaScript 词法作用域与闭包:从底层原理到实战理解

作者 有意义
2025年11月28日 17:41

JS运行机制

词法作用域

“词法”这个词听起来有点抽象,其实它的意思很简单: “词法” = “和你写代码的位置有关”

换句话说,JavaScript 中很多行为在你写代码的时候就已经确定了,而不是等到程序运行时才决定。这种特性也叫静态作用域(static scoping)。

你可以这样理解:

代码怎么写的,它就怎么执行——这非常符合我们的直觉。

比如,letconst 声明的变量之所以不能在声明前使用(会报“暂时性死区”错误),就是因为它们属于词法环境的一部分。而词法环境正是由你在源代码中的书写位置决定的。你把变量写在哪里,它就在哪里生效,不能“穿越”到还没写到的地方去用——这很合理,也很直观。

所以,“词法”本质上就是:看代码结构,而不是看运行过程

看一段关于词法作用域的代码

function bar() {
    console.log(myName);
}
function foo() {
    var myName = '极客邦'
    bar()// 运行时
}
var myName = '极客时间'
foo();

这里输出的是

极客时间 为什么输出的不是 "极客邦"

因为 bar 函数是在全局作用域中声明的,所以它的词法作用域链在定义时就已经固定为:自身作用域 → 全局作用域

JavaScript 查找变量时,遵循的是词法作用域规则——也就是说,它只关心函数在哪里被定义,而不关心函数在哪里被调用

bar 内部访问变量(比如 testmyName)时,引擎会先在 bar 自己的执行上下文中查找;如果找不到,就沿着词法作用域链向外层查找,也就是直接跳到全局作用域,而不会进入 foo 的作用域——尽管 bar 是在 foo 里面被调用的。

因此,bar 根本“看不见” foo 中的 myName = "极客邦",自然也就无法输出它。

image.png

总结:

JavaScript 使用 词法作用域(Lexical Scoping) ,也就是说,函数在定义时就决定了它能访问哪些变量,而不是在调用时

词法作用域链:变量查找的路径

当 JavaScript 引擎执行代码时,会为每一段可执行代码创建一个 执行上下文(Execution Context)
每个执行上下文都包含一个 词法环境(Lexical Environment) ,它不仅保存了当前作用域中声明的变量,还持有一个指向外层词法环境的引用。这些嵌套的词法环境连接起来,就形成了 作用域链(Scope Chain)

  • 全局执行上下文位于调用栈的底部,是程序启动时创建的。
  • 每当调用一个函数,就会创建一个新的函数执行上下文,并将其压入调用栈。
  • 当需要查找某个变量时,JavaScript 会从当前作用域开始,沿着作用域链由内向外逐层查找,直到找到该变量,或最终到达全局作用域为止。

这种机制确保了变量访问遵循词法作用域规则——即“在哪里定义,就看哪里的变量”,而不是“在哪里调用”。

看看这段关于作用域链和块级作用域的代码:

function bar () {
  var myName = "极客世界";
  let test1 = 100;
  if (1) {
    let myName = "Chrome 浏览器"  // 1.先在词法环境查找一下
    console.log(test)
  }
}
function foo() {
  var myName = "极客邦";
  let test = 2;
  {
    let test = 3;
    bar()
  }
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();

这段代码的执行结果不会报错,而是会正常输出:

1

原因正是基于 JavaScript 的 词法作用域(Lexical Scoping) 机制。

虽然 bar() 是在 foo() 内部被调用的,但它的声明位置在全局作用域。因此,当 bar 内部引用变量 test 时,JavaScript 引擎会从 bar 自身的作用域开始查找;找不到时,就沿着词法作用域链向外层查找——也就是直接跳到全局作用域,而不会进入 foo 的作用域。

由于全局作用域中存在 let test = 1;,所以 console.log(test) 最终输出的是 1

image.png

换句话说:变量查找看的是函数“在哪里定义”,而不是“在哪里调用” 。这条由内向外的查找路径,就是我们所说的 作用域链

在 JavaScript 的设计中,每个函数的执行上下文都包含一个内部指针(通常称为 [[Outer]] 或 “outer 引用”),它指向该函数定义时所在的作用域——也就是它的词法外层环境

当你在代码中嵌套定义多个函数时,每个函数都会通过这个 outer 指针,链接到它上一层的词法环境。这样一层套一层,就形成了一条静态的、由代码结构决定的链式结构,我们称之为 词法作用域链(Lexical Scope Chain)

正是这条链,决定了变量查找的路径:从当前作用域开始,沿着 outer 指针逐级向外搜索,直到全局作用域为止。

image.png

这种机制是 JavaScript 闭包、变量访问和作用域行为的核心基础。理解了 outer 指针如何连接各个词法环境,你就真正掌握了词法作用域链的本质。

闭包 ——前面内容的优雅升华

闭包(Closure)是 JavaScript 中一个基于词法作用域的核心机制。掌握它,不仅能写出更灵活、模块化的代码,还能轻松应对面试中的高频问题。下面用通俗易懂的方式,带你彻底搞懂闭包。


一、什么是闭包?

闭包 = 一个函数 + 它定义时所处的词法环境。

换句话说:
当一个函数即使在自己原始作用域之外被调用,仍然能够访问并操作其定义时所在作用域中的变量,这个函数就形成了闭包。

这并不是魔法,而是 JavaScript 词法作用域机制的自然结果


二、闭包形成的两个必要条件(缺一不可)

  1. 函数嵌套:内部函数引用了外部函数的变量;
  2. 内部函数被暴露到外部:比如通过 return 返回、赋值给全局变量、作为回调传递等,并在外部被调用。

只有同时满足这两点,闭包才会真正“生效”。


三、经典示例:直观感受闭包

function foo() {
  var myName = "极客时间";
  let test1 = 1;
  const test2 = 2; // 注意:test2 未被内部函数使用

  var innerBar = {
    getName: function () {
      console.log(test1); // 引用了外部变量 test1
      return myName;      // 引用了外部变量 myName
    },
    setName: function (newName) {
      myName = newName;   // 修改外部变量 myName
    }
  };

  return innerBar; // 将内部对象返回,使内部函数可在外部调用
}

// 执行 foo,获取返回的对象
var bar = foo(); // 此时 foo 已执行完毕,上下文出栈

// 在外部调用内部函数 —— 闭包开始工作!
bar.setName("极客邦");
bar.getName();               // 输出:1
console.log(bar.getName());  // 输出:1 和 "极客邦"

输出结果:

1
1
极客邦

四、关键问题:为什么 foo 的变量没被垃圾回收?

  • 通常情况下,函数执行结束后,其局部变量会被垃圾回收。

  • 但在闭包场景中,只要内部函数仍被外部引用,JavaScript 引擎就会保留该函数所依赖的外部变量

  • 在本例中:

    • getName 和 setName 引用了 myName 和 test1 → 这两个变量被“捕获”并保留在内存中;
    • test2 没有被任何函数使用 → 被正常回收。

📌 重点:闭包不会阻止整个函数上下文销毁,只保留“被引用”的变量
这既保证了功能,又避免了内存浪费。

image.png


五、闭包的本质与词法作用域的关系

1. 闭包的本质

闭包不是某种特殊语法,而是一种运行时行为

函数 + 它出生时的词法环境 = 闭包

你可以把它想象成:函数随身带了一个“背包”,里面装着它定义时能访问的所有外部变量。无论它走到哪里(哪怕在全局调用),都能从背包里取用或修改这些数据。

2. 与词法作用域的关联

  • 词法作用域:变量的作用域由代码书写位置决定(静态的、编译期确定)。
  • 闭包:正是词法作用域在函数被传递到外部后依然生效的体现。

✅ 所以说:闭包不是额外特性,而是词法作用域 + 函数作为一等公民 的必然产物。


💡 记住一句话
闭包不是“不让变量销毁”,而是“还有人用,所以不能销毁”。
它让 JavaScript 实现了私有状态、模块封装、回调记忆等强大能力。

理解闭包,你就真正迈入了 JavaScript 高阶编程的大门。

日本股票市场渲染 KlineCharts K 线图

2025年11月28日 17:35

下面是针对日本股票市场的完整对接方案,包含从获取股票列表渲染 KlineCharts K 线图的详细步骤和代码。

核心流程

  1. 获取日本股票列表:使用 countryId=35 查询日本市场的股票,获取目标股票的 id (即 PID)。
  2. 获取 K 线数据:使用该 pid 请求历史 K 线数据。
  3. 绘制图表:将数据转换为 KlineCharts 格式并渲染。

第一步:获取日本股票 PID (API 调试)

在写代码前,您需要先通过 API 拿到您想展示的日本股票(例如丰田、索尼等)的 id

请求方式:

  • 接口 URL: https://api.stocktv.top/stock/stocks
  • 参数:
    • countryId: 35 (日本)
    • pageSize: 10
    • key: 您的Key

请求示例 (GET):

https://api.stocktv.top/stock/stocks?countryId=35&pageSize=10&page=1&key=您的Key

返回示例 (假设): 您会在返回的 data.records 列表中找到股票信息。

{
  "id": 99999,  <-- 这个是 PID,记下这个数字用于下一步
  "name": "Toyota Motor Corp",
  "symbol": "7203",
  "countryId": 35,
  ...
}

第二步:完整实现代码 (HTML + KlineCharts)

将以下代码保存为 .html 文件。请替换代码顶部的 YOUR_API_KEY 和您在上一步获取到的 JAPAN_STOCK_PID

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>日本股票 K线图 (KlineCharts)</title>
    <script src="https://cdn.jsdelivr.net/npm/klinecharts/dist/klinecharts.min.js"></script>
    <style>
        body { margin: 0; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
        h2 { margin-bottom: 10px; }
        .config-box { 
            background: #f5f5f5; padding: 15px; border-radius: 8px; margin-bottom: 20px; 
            display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
        }
        input, select, button { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
        button { background-color: #007bff; color: white; border: none; cursor: pointer; }
        button:hover { background-color: #0056b3; }
        #chart-container { width: 100%; height: 600px; border: 1px solid #e0e0e0; border-radius: 4px; }
    </style>
</head>
<body>

    <h2>StockTV 日本股票 K线演示 (CountryID=35)</h2>

    <div class="config-box">
        <label>股票PID: <input type="number" id="pidInput" value="953373" placeholder="例如: 953373"></label>
        
        <label>周期: 
            <select id="intervalSelect">
                <option value="P1D">日线 (1 Day)</option>
                <option value="PT1H">1小时 (1 Hour)</option>
                <option value="PT15M">15分钟 (15 Min)</option>
                <option value="PT5M">5分钟 (5 Min)</option>
            </select>
        </label>

        <button onclick="loadChartData()">生成图表</button>
    </div>

    <div id="chart-container"></div>

    <script>
        // 配置您的 API Key
        const API_KEY = '联系我们获取key'; // TODO: 请在此处填入您的真实 Key
        const BASE_URL = 'https://api.stocktv.top';

        // 初始化 KlineCharts
        let chart = klinecharts.init('chart-container');
        
        // 设置一些基础样式
        chart.setStyleOptions({
            candle: {
                tooltip: {
                    labels: ['时间', '开', '收', '高', '低', '成交量']
                }
            }
        });

        chart.createIndicator('VOL'); // 创建成交量指标

        async function loadChartData() {
            const pid = document.getElementById('pidInput').value;
            const interval = document.getElementById('intervalSelect').value;

            if (!pid) {
                alert("请输入股票 PID");
                return;
            }

            console.log(`正在请求日本股票数据: PID=${pid}, Interval=${interval}`);

            try {
                // 构造 StockTV API 请求
                // 文档接口: /stock/kline
                const url = `${BASE_URL}/stock/kline?pid=${pid}&interval=${interval}&key=${API_KEY}`;
                
                const response = await fetch(url);
                const resJson = await response.json();

                if (resJson.code === 200) {
                    const stockData = resJson.data;

                    if (!stockData || stockData.length === 0) {
                        alert("该股票在此周期下无数据");
                        return;
                    }

                    // 数据格式转换
                    // StockTV: { time: 1719818400000, open: 239.42, ... }
                    // KlineCharts: { timestamp: 1719818400000, open: 239.42, ... }
                    const klineData = stockData.map(item => {
                        return {
                            timestamp: item.time, // 直接使用 API 返回的时间戳
                            open: Number(item.open),
                            high: Number(item.high),
                            low: Number(item.low),
                            close: Number(item.close),
                            volume: Number(item.volume)
                        };
                    });

                    // 确保按时间升序排序
                    klineData.sort((a, b) => a.timestamp - b.timestamp);

                    // 渲染数据
                    chart.applyNewData(klineData);
                    console.log("图表渲染成功,数据条数:", klineData.length);
                } else {
                    console.error("API 错误:", resJson);
                    alert("接口报错: " + resJson.message);
                }

            } catch (err) {
                console.error("请求失败:", err);
                alert("网络请求失败,请检查控制台 (F12)");
            }
        }

        // 窗口大小调整时自动调整图表
        window.addEventListener('resize', () => {
            chart.resize();
        });
        
        // 页面加载时自动尝试加载一次(方便测试)
        // 如果您有确定的日本股票PID,可以在 input 的 value 中预设
        // loadChartData(); 
    </script>
</body>
</html>

关键点说明

  1. CountryId=35 的使用

    • countryId=35 主要用于查询列表 (/stock/stocks) 阶段,用于筛选出日本市场的股票及其对应的 PID。
    • 一旦拿到 PID,在请求 K 线数据 (/stock/kline) 时,只需要 PID,不需要再传 countryId。
  2. 数据映射 (Mapping)

    • StockTV 返回的字段是 time, open, high, low, close, volume
    • KlineCharts 要求的字段是 timestamp, open, high, low, close, volume
    • 代码中 timestamp: item.time 这一行完成了关键的转换。
  3. 周期格式

    • 请确保传给 API 的 interval 参数是 P1D (日), PT1H (时) 等 ISO8601 格式,否则 API 可能会报错或返回空数据。

Vue2 通用文件在线预览下载组件:一站式解决多类型文件处理需求(支持视频、文档、图片、Office)

作者 某只天落
2025年11月28日 17:34

在前端开发中,文件预览与下载是高频需求,但不同文件类型(视频、图片、文档、Office)的预览逻辑差异大,且存在 “预览失败降级”“跨浏览器兼容” 等痛点。今天分享一款我封装的 Vue2 文件预览下载组件,无需重复开发,传入文件 URL 即可实现 “能预览则预览,不能预览则下载” 的闭环体验,适配 90%+ 业务场景。

一、组件核心功能一览

组件围绕 “文件预览 + 下载” 核心诉求,拆解为状态管理、分类型预览、错误处理、下载能力四大模块,逻辑闭环且交互友好:

1. 基础状态与信息管理

功能点 实现逻辑 价值
加载状态 初始化显示 “加载中”,延迟 1 秒隐藏(给资源加载留缓冲) 避免用户因文件加载慢误以为 “无内容”,提升交互感知
文件信息解析 从传入的 fileUrl 中分割 URL、解析文件名 / 后缀(如从 http://xxx/test.mp4 提取 test.mp4 和 mp4 自动识别文件属性,无需业务侧手动传入文件名 / 类型
错误状态管理 监听 video/iframe 预览错误,展示针对性提示(如 “视频预览失败”“文件链接无效”) 明确失败原因,引导用户下一步操作(下载)

2. 分类型文件预览(核心核心)

组件按文件类型划分预览策略,覆盖视频、图片、文档、Office 四大类,适配不同文件的原生预览能力:

文件类型 预览方式 特殊处理 支持的格式
视频文件 原生 <video> 标签 + 播放控件 预览失败后自动移除视频格式的预览支持,切换为下载模式 mp4、avi、mov、mkv、flv、wmv
Office 文件(doc/xls/ppt 及新版) iframe 嵌套微软在线预览服务 拼接微软预览 URL(view.officeapps.live.com),解决前端无法直接预览 Office 的痛点 doc、docx、xls、xlsx、ppt、pptx
图片 / 文本 / PDF iframe 直接加载文件 URL 利用浏览器原生渲染能力,无需额外依赖 jpg/png/gif/bmp、txt、html/htm、pdf
不支持的文件(zip/rar/ 未知格式) 无预览,展示下载区域 显示文件图标、类型、提示语,提供下载按钮 zip、rar 及未列入预览列表的格式

3. 错误兜底与降级处理

错误场景 处理逻辑 用户体验
视频预览失败(格式不支持 / 文件损坏) 显示错误提示,同时将视频格式从 “支持预览列表” 中移除,强制切换为下载模式 避免用户看到空白 / 报错的 video 标签,直接引导下载
iframe 预览失败(Office 链接失效 / PDF 损坏) 显示错误提示,补充 “建议下载查看” 的引导 明确失败原因,不阻塞用户获取文件
解析文件信息失败 兜底显示 “未知文件”“未知类型”,仍保留下载功能 兼容异常 URL(如无后缀、URL 格式错误)

4. 轻量化下载功能

通过动态创建<a>标签实现无刷新下载,支持自定义文件名,捕获下载异常并给出友好提示(如 “文件下载失败,请检查链接”)。

5. 友好的视觉与交互

  • 加载状态居中显示 “加载中”,避免用户误以为无内容;
  • 预览区域自适应容器大小,视频采用object-fit: contain防止拉伸;
  • 下载区域用图标 + 文字组合,按钮蓝色强调,提示语浅灰色弱化,视觉层级清晰;
  • 错误提示用红色警示,提升辨识度。

二、应用场景和组件完整代码

该组件适配所有需要 “文件预览 / 下载” 的业务场景,以下是高频落地场景:

1. 后台管理系统(核心场景)
  • 文件管理模块(OA / 企业网盘) :用户上传文件后,列表 / 详情页展示预览,支持在线查看视频 / PDF/Office,压缩包等直接下载;
  • 工单 / 审批系统:审批附件(如报销单 PDF、项目文档 Word)在线预览,无需下载即可审核,提升审批效率;
  • 素材管理系统:运营 / 设计人员上传的视频 / 图片素材在线预览,快速核对内容是否符合要求。
2. 用户中心 / 客户门户
  • 资质审核场景(政务 / 金融) :用户上传的身份证(图片)、营业执照(PDF)在线预览,工作人员无需下载即可审核;
  • 课程 / 培训平台:课程附件(视频、讲义 PDF、课件 PPT)在线预览,学员无需下载即可学习,降低学习门槛;
  • 售后工单系统:用户上传的售后凭证(视频 / 图片)在线预览,客服快速核实问题,提升售后效率。
3. 电商 / 零售系统
  • 商品资料管理:商品视频、说明书 PDF、参数表 Excel 在线预览,运营人员快速核对商品信息;
  • 商家后台:商家上传的资质文件(营业执照、食品经营许可证)在线预览,平台审核人员一键查看。
4. 医疗 / 教育系统
  • 医疗报告预览:检查报告 PDF、医学影像(图片)在线预览,医生 / 患者无需下载即可查看;
  • 在线考试系统:考试附件(试题 PDF、参考资料 Word)在线预览,考生在线答题时可快速查阅。

代码

<template>
  <div class="file-preview-container">
    <!-- 加载状态 -->
    <div v-if="loading" class="loading">加载中...</div>

    <!-- 视频文件:用 video 标签预览 -->
    <video
      v-else-if="isVideo && canPreview"
      :src="fileUrl"
      controls
      class="video-preview"
      @error="handleVideoError"
    >
      您的浏览器不支持视频预览
    </video>

    <!-- 非视频可预览文件:用 iframe 展示 -->
    <iframe
      v-else-if="!isVideo && canPreview"
      :src="iframeSrc"
      width="100%"
      height="100%"
      frameborder="0"
      class="preview-iframe"
      @error="handleIframeError"
    ></iframe>

    <!-- 不支持预览的文件:显示下载按钮 -->
    <div v-else class="download-section">
      <div class="file-icon">
        <i class="el-icon-video-camera" v-if="isVideo"></i>
        <i class="el-icon-document" v-else-if="fileType === 'doc' || fileType === 'docx'"></i>
        <i class="el-icon-table-lines" v-else-if="fileType === 'xls' || fileType === 'xlsx'"></i>
        <i class="el-icon-present" v-else-if="fileType === 'ppt' || fileType === 'pptx'"></i>
        <i class="el-icon-file-pdf" v-else-if="fileType === 'pdf'"></i>
        <i class="el-icon-image" v-else-if="['jpg','jpeg','png','gif','bmp'].includes(fileType)"></i>
        <i class="el-icon-archive" v-else-if="fileType === 'zip' || fileType === 'rar'"></i>
        <i class="el-icon-exclamation" v-else></i>
      </div>
      <div class="file-info">
        <p class="file-name">{{ fileName }}</p>
        <p class="file-type">文件类型:.{{ fileType }}</p>
        <p class="file-tip">
          {{
            isVideo
              ? "视频无法预览,请下载后查看"
              : "该文件类型不支持在线预览,请下载后查看"
          }}
        </p>
        <button class="download-btn" @click="downloadFile">
          <i class="el-icon-download"></i> 下载文件
        </button>
      </div>
    </div>

    <!-- 错误提示 -->
    <div v-if="errorMsg" class="error-message">{{ errorMsg }}</div>
  </div>
</template>

<script>
export default {
  name: "FilePreviewDownload",
  props: {
    // 文件完整链接(如:http://xxx.com/video.mp4、http://xxx.com/image.png)
    fileUrl: {
      type: String,
      required: true,
      validator: (value) => {
        // 简单校验URL格式
        return /^https?://.+/i.test(value) || /^//.+/i.test(value);
      },
    },
    // 自定义文件名(可选,默认从URL提取)
    customFileName: {
      type: String,
      default: "",
    },
  },
  data() {
    return {
      loading: true,
      errorMsg: "",
      fileName: "", // 文件名(如:test.mp4)
      fileType: "", // 文件后缀(如:mp4)
      // 支持的文件类型列表
      previewableTypes: [
        // 视频类
        "mp4", "avi", "mov", "mkv", "flv", "wmv",
        // 文档类
        "pdf", "txt", "html", "htm",
        // 图片类
        "jpg", "jpeg", "png", "gif", "bmp",
        // Office 格式
        "docx", "xlsx", "pptx", "doc", "xls", "ppt",
      ],
      // 视频格式单独区分(用于判断是否用 video 标签)
      videoTypes: ["mp4", "avi", "mov", "mkv", "flv", "wmv"],
    };
  },
  computed: {
    // 判断是否支持预览
    canPreview() {
      return this.previewableTypes.includes(this.fileType);
    },
    // 判断是否为视频文件
    isVideo() {
      return this.videoTypes.includes(this.fileType);
    },
    // iframe 预览地址(处理 Office 文件)
    iframeSrc() {
      // Office 文件用微软在线预览增强兼容性
      if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(this.fileType)) {
        return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(this.fileUrl)}`;
      }
      // 其他文件直接用原链接
      return this.fileUrl;
    },
  },
  created() {
    // 解析文件名和类型
    this.parseFileInfo();
    // 延迟隐藏加载(给资源加载留时间,可通过props自定义延迟)
    setTimeout(() => {
      this.loading = false;
    }, 1000);
  },
  methods: {
    // 解析文件名和后缀
    parseFileInfo() {
      try {
        // 优先使用自定义文件名
        if (this.customFileName) {
          this.fileName = this.customFileName;
          const nameParts = this.customFileName.split(".");
          if (nameParts.length > 1) {
            this.fileType = nameParts[nameParts.length - 1].toLowerCase();
          }
          return;
        }

        // 从URL提取文件名
        const urlParts = this.fileUrl.split("/");
        this.fileName = urlParts[urlParts.length - 1] || "未知文件";
        // 处理URL参数(如:test.pdf?timestamp=123 → test.pdf)
        this.fileName = this.fileName.split("?")[0].split("#")[0];
        // 提取文件后缀
        const nameParts = this.fileName.split(".");
        if (nameParts.length > 1) {
          this.fileType = nameParts[nameParts.length - 1].toLowerCase();
        }
      } catch (err) {
        console.error("解析文件信息失败:", err);
        this.fileName = "未知文件";
        this.fileType = "";
      }
    },

    // 视频预览错误处理
    handleVideoError() {
      this.errorMsg = "视频预览失败,可能格式不支持或文件损坏";
      // 视频预览失败后切换为下载模式
      this.previewableTypes = this.previewableTypes.filter(
        (type) => !this.videoTypes.includes(type)
      );
    },

    // iframe 预览错误处理
    handleIframeError() {
      this.errorMsg = "文件预览失败,可能文件已损坏或链接无效";
      if (this.previewableTypes.includes(this.fileType)) {
        this.errorMsg += ",建议下载文件查看";
      }
    },

    // 下载文件
    downloadFile() {
      try {
        const link = document.createElement("a");
        link.href = this.fileUrl;
        // 解决跨域下载时download属性失效问题(需后端配合设置Content-Disposition)
        link.download = this.fileName;
        document.body.appendChild(link);
        link.click();
        // 触发下载后移除a标签
        setTimeout(() => {
          document.body.removeChild(link);
        }, 100);
      } catch (err) {
        console.error("下载失败:", err);
        this.$message?.error ? this.$message.error("文件下载失败,请检查链接") : alert("文件下载失败,请检查链接");
      }
    },
  },
};
</script>

<style scoped>
.file-preview-container {
  width: 300px;
  min-height: 200px;
  position: relative;
  border: 1px solid #eee;
  border-radius: 4px;
  overflow: hidden;
  box-sizing: border-box;
}

/* 视频预览样式 */
.video-preview {
  width: 100%;
  height: 100%;
  min-height: 200px;
  object-fit: contain;
  background-color: #000;
}

/* iframe 预览样式 */
.preview-iframe {
  min-height: 200px;
  border: none;
}

/* 加载状态 */
.loading {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #666;
  font-size: 14px;
}

/* 下载区域 */
.download-section {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 200px;
  padding: 20px;
  box-sizing: border-box;
}

.file-icon {
  font-size: 60px;
  color: #417aff;
  margin-right: 30px;
}

.file-info {
  max-width: 180px;
}

.file-name {
  font-size: 16px;
  font-weight: 500;
  margin-bottom: 8px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.file-type {
  color: #666;
  font-size: 13px;
  margin-bottom: 8px;
}

.file-tip {
  color: #999;
  font-size: 12px;
  margin-bottom: 16px;
  line-height: 1.4;
}

.download-btn {
  padding: 6px 16px;
  background-color: #417aff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  font-size: 14px;
  transition: background-color 0.3s;
}

.download-btn:hover {
  background-color: #2d62d0;
}

.download-btn i {
  margin-right: 4px;
  font-size: 12px;
}

/* 错误提示 */
.error-message {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #f56c6c;
  text-align: center;
  padding: 0 20px;
  font-size: 14px;
  line-height: 1.5;
}

/* 响应式适配 */
@media (max-width: 768px) {
  .file-preview-container {
    width: 100%;
  }
  .download-section {
    flex-direction: column;
    text-align: center;
  }
  .file-icon {
    margin-right: 0;
    margin-bottom: 16px;
  }
}
</style>

三、快速使用指南

1. 安装依赖(可选)

组件依赖 Element UI 图标,若项目未集成 Element UI,可安装:

npm install element-ui --save

或替换为原生图标(如 Font Awesome),移除 Element UI 依赖。

2. 引入组件

// 在需要使用的页面引入
import FilePreviewDownload from "@/components/FilePreviewDownload.vue";

export default {
  components: {
    FilePreviewDownload,
  },
};

3. 页面中使用

<!-- 基础用法:仅传入文件URL -->
<FilePreviewDownload fileUrl="http://xxx.com/test.pdf" />

<!-- 自定义文件名 -->
<FilePreviewDownload 
  fileUrl="http://xxx.com/123.mp4" 
  customFileName="产品介绍视频.mp4" 
/>

四、组件优势

优势点 说明
通用性强 覆盖视频、图片、文档、Office 等主流文件类型,适配 90%+ 业务场景
体验友好 加载 / 错误 / 降级逻辑完善,用户操作路径清晰(预览→失败→下载)
轻量易集成 基于原生标签 + Vue 开发,仅依赖 Element 图标,接入成本低
解决 Office 预览痛点 借助微软在线预览服务,无需前端集成重型 Office 解析库

1. 通用性强,覆盖全场景

支持视频、图片、文档、Office 等 18 + 常见文件类型,无需为不同文件写专属逻辑,适配后台管理、用户中心、电商等多业务场景。

2. 体验友好,优雅降级

“预览优先,下载兜底” 的逻辑,避免 “无法预览” 的生硬体验;预览失败时给出明确提示,引导用户下一步操作,减少困惑。

3. 轻量无冗余,接入成本低

  • 核心逻辑仅 200 + 行,无重型依赖,打包体积小;
  • 仅需传入fileUrl即可使用,无需配置复杂参数,新手也能快速上手。

4. 适配性强,兼容多端

  • 基于原生 HTML 标签(video/iframe)开发,兼容 Chrome、Firefox、Edge 等主流浏览器;
  • 样式支持响应式,适配移动端 / H5,可直接复用在小程序内嵌页面。

五、局限性与优化方向

局限性 影响场景 优化方向
样式硬编码 容器宽度 300px 固定,适配不同布局(如全屏预览)需修改样式 将宽度 / 高度 / 颜色等作为 props 传入,支持自定义
Office 预览依赖外网 内网环境下微软在线预览失效,Office 文件无法预览 集成开源文件预览服务(如 kkfileview、LibreOffice Online)
视频预览格式有限 小众格式(rmvb、webm)不支持,且无格式转换逻辑 集成 ffmpeg.wasm 实现前端视频格式解码,或后端转码为 mp4
下载功能兼容问题 跨域文件的 download 属性失效,无法直接下载 后端转发文件(前端请求后端接口,后端返回文件流)
加载延迟固定 1 秒 文件加载快时多余显示加载状态,加载慢时提前隐藏 监听 video/iframe 的 onload 事件,动态控制加载状态

1. 现存局限性

  • 样式硬编码:容器宽度默认 300px,适配不同布局需手动修改样式;
  • Office 预览依赖外网:内网环境下微软在线预览服务失效,无法预览 Office 文件;
  • 视频格式支持有限:小众格式(如 rmvb、webm)不支持原生预览;
  • 跨域下载问题:跨域文件的download属性可能失效,需后端配合设置响应头。

2. 扩展方向(按需迭代)

(1)支持自定义样式配置

将宽度、高度、边框、颜色等样式抽离为 props,允许业务侧灵活配置:

<FilePreviewDownload 
  fileUrl="http://xxx.com/test.jpg"
  :styleConfig="{ width: '500px', height: '300px', border: '1px solid #ccc' }"
/>

(2)增加权限控制

支持传入token参数,在预览 URL 中拼接鉴权信息,防止文件链接泄露:

// 扩展iframeSrc计算属性
iframeSrc() {
  let url = this.fileUrl;
  // 拼接鉴权token
  if (this.token) {
    url = `${url}${url.includes("?") ? "&" : "?"}token=${this.token}`;
  }
  if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(this.fileType)) {
    return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`;
  }
  return url;
},

(3)内网环境 Office 预览适配

集成开源文件预览服务(如 kkfileview、LibreOffice Online),替代微软在线预览,解决内网环境预览失效问题:

// 优化downloadFile方法
downloadFile() {
  // 跨域文件通过后端接口下载
  if (this.isCrossDomain) {
    window.open(`/api/file/download?url=${encodeURIComponent(this.fileUrl)}&fileName=${this.fileName}`);
    return;
  }
  // 非跨域文件直接下载(原逻辑)
  // ...
},

(6)增加批量预览 / 下载

扩展为列表级组件,支持多选文件批量预览、批量下载,适配文件管理系统场景。

六、总结

这款文件预览下载组件以 “通用、轻量、友好” 为核心设计理念,解决了前端文件处理的重复开发问题,是后台管理、用户中心等项目的必备基础组件。它不仅能直接复用,还支持按需扩展,可根据业务场景迭代权限控制、内网适配、批量操作等功能。

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

作者 AY1024
2025年11月28日 17:34

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

开场白:一个变量的"无法无天"与它的"寻亲之路"


📖 第一章:黑马喽的嚣张岁月

话说在前端江湖的ES5时代,有个叫var的黑马喽,这家伙简直无法无天!它想来就来,想走就走,完全不顾什么块级作用域的规矩。

// 你们看看这黑马喽的德行
for (var i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i); // 你猜输出啥?3,3,3!
    }, 100);
}
// 循环结束了,i还在外面晃荡
console.log(i); // 3,瞧瞧,跑出来了吧!

但今天咱们不仅要扒一扒var的底裤,还要讲讲变量们是怎么"寻亲"的——这就是作用域作用域链的故事。


🔧 第二章:编译器的三把斧——代码的"梳妆打扮"

要说清楚作用域,得先从JavaScript的编译说起。别看JS是解释型语言,它在执行前也要经历一番"梳妆打扮"。

2.1 词法分析:拆解字符串的魔术

想象一下,编译器就像个认真的语文老师,把代码这个长句子拆成一个个有意义的词语:

var a = 1vara=1

注意:空格要不要拆开,得看它有没有用。就像读书时要不要停顿,得看语气!

2.2 语法分析:构建家谱树

拆完词之后,编译器开始理清关系——谁声明了谁,谁赋值给谁,最后生成一棵抽象语法树(AST)

这就像把一堆零散的家庭成员信息,整理成清晰的家谱。

2.3 代码生成:准备执行

最后,编译器把家谱树转换成机器能懂的指令,准备执行。

关键点:JS的编译发生在代码执行前的一瞬间,快到你几乎感觉不到!


💕 第三章:变量赋值的三角恋

var a = 1这么简单的一行代码,背后居然上演着一场"三角恋":

  • 🎯 编译器:干脏活累活的媒人,负责解析和牵线
  • JS引擎:执行具体动作的新郎
  • 🏠 作用域:管理宾客名单的管家

3.1 订婚仪式(编译阶段)

// 当看到 var a = 1;
编译器:管家,咱们这有叫a的变量吗?
作用域:回大人,还没有。
编译器:那就在当前场合声明一个a!

3.2 结婚典礼(执行阶段)

JS引擎:管家,我要找a这个人赋值!
作用域:大人请,a就在这儿。
JS引擎:好,把1赋给a!

这里涉及到两种查找方式:

LHS查询:找容器(找新娘)

var a = 1; // 找到a这个容器装1

RHS查询:找源头(找新娘的娘家)

console.log(a); // 找到a的值
foo();         // 找到foo函数本身

编译过程示意图


🐒 第四章:黑马喽的罪证展示

在ES5时代,var这家伙真是目中无人:

4.1 无视块级作用域

{
    var rogue = "我是黑马喽,我想去哪就去哪";
}
console.log(rogue); // 照样能访问!

4.2 变量提升的诡计

console.log(naughty); // undefined,而不是报错!
var naughty = "我提升了";

这货相当于:

var naughty;          // 声明提升到顶部
console.log(naughty); // undefined
naughty = "我提升了"; // 赋值留在原地

🙏 第五章:如来佛祖的五指山——let和const

ES6时代,如来佛祖(TC39委员会)看不下去了,派出了letconst两位大神:

5.1 块级作用域的紧箍咒

{
    let disciplined = "我在块里面很老实";
    const wellBehaved = "我也是好孩子";
}
console.log(disciplined); // ReferenceError!出不来咯

5.2 暂时性死区的降妖阵

console.log(rebel); // ReferenceError!此路不通
let rebel = "想提升?没门!";

真相let/const其实也会提升,但是被关进了"暂时性死区"这个五指山里,在声明前谁都别想访问!


🧩 第六章:黑马喽的迷惑行为——词法作用域的真相

6.1 一个让黑马喽困惑的例子

function bar(){
    console.log( myName);  // 黑马喽:这里该输出啥?
}

function foo(){
    var myName = "白吗喽";
    bar()
    console.log("1:", myName)   // 这个我懂,输出"白吗喽"
}

var myName = "黑吗喽";
foo()  // 输出:"黑吗喽","白吗喽"

黑马喽挠着头想:"不对啊!bar()foo()里面调用,不是应该找到foo()里的myName = "白吗喽"吗?怎么会是黑吗喽呢?"

6.2 outer指针:函数的"身份证"

原来,在编译阶段,每个函数就已经确定了自己的"娘家"(词法作用域):

// 编译阶段发生的事情:
// 1. bar函数出生,它的outer指向全局作用域(它声明在全局)
// 2. foo函数出生,它的outer也指向全局作用域(它声明在全局)
// 3. 变量myName声明提升:var myName = "黑吗喽"

// 执行阶段:
var myName = "黑吗喽";  // 全局myName赋值为"黑吗喽"
foo();                 // 调用foo函数

黑马喽的错误理解

bar() → foo() → 全局

实际的作用域查找(根据outer指针):

bar() → 全局

如图

C9AE3D8E-F1DA-4767-AE87-AF4B1AF8B94D.png

6.3 词法作用域 vs 动态作用域

词法作用域(JavaScript):看出生地

var hero = "全局英雄";

function createWarrior() {
    var hero = "部落勇士";
    
    function fight() {
        console.log(hero); // 永远输出"部落勇士"
    }
    
    return fight;
}

const warrior = createWarrior();
warrior(); // "部落勇士" - 记得出生时的环境

动态作用域:看调用地(JavaScript不是这样!)

// 假设JavaScript是动态作用域(实际上不是!)
var hero = "战场英雄";
const warrior = createWarrior();
warrior(); // 如果是动态作用域,会输出"战场英雄"

🗺️ 第七章:作用域链——变量的寻亲路线图

7.1 每个函数都带着"出生证明"

var grandma = "奶奶的糖果";

function mom() {
    var momCookie = "妈妈的饼干";
    
    function me() {
        var myCandy = "我的棒棒糖";
        console.log(myCandy);    // 自己口袋找
        console.log(momCookie);  // outer指向mom
        console.log(grandma);    // outer的outer指向全局
    }
    
    me();
}

mom();

7.2 作用域链的建造过程

// 全局作用域
var city = "北京";

function buildDistrict() {
    var district = "朝阳区";
    
    function buildStreet() {
        var street = "三里屯";
        console.log(street);     // 自己的
        console.log(district);   // outer指向buildDistrict
        console.log(city);       // outer的outer指向全局
    }
    
    return buildStreet;
}

// 编译阶段就确定的关系:
// buildStreet.outer = buildDistrict作用域
// buildDistrict.outer = 全局作用域

如图

9625B41F-066C-4BD2-AF8B-44B93C395CF9.png

⚔️ 第八章:作用域链的实战兵法

8.1 兵法一:模块化开发

function createCounter() {
    let count = 0; // 私有变量,外部无法直接访问
    
    return {
        increment: function() {
            count++; // 闭包:outer指向createCounter作用域
            return count;
        },
        getValue: function() {
            return count;
        }
    };
}

const counter = createCounter();
console.log(counter.increment()); // 1
// console.log(count); // 报错!count是私有的

8.2 兵法二:解决循环陷阱

黑马喽的坑
for (var i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 3, 3, 3 - 所有函数共享同一个i
    }, 100);
}
作用域链的救赎
// 方法1:使用let创建块级作用域
for (let i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 0, 1, 2 - 每个i都有自己的作用域
    }, 100);
}

// 方法2:IIFE创建新作用域
for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(j); // 0, 1, 2 - j在IIFE作用域中
        }, 100);
    })(i);
}

8.3 兵法三:正确的函数嵌套

function foo(){
    var myName = "yang";
    
    function bar(){  // 现在bar的outer指向foo了!
        console.log("2:", myName);  // 找到foo的myName
    }
    
    bar()
    console.log("1:", myName)
}

var myName = "yang1";
foo()  // 输出:2: yang, 1: yang

🚀 第九章:现代JavaScript的作用域体系

9.1 块级作用域的精细化管理

function modernScope() {
    var functionScoped = "函数作用域";
    let blockScoped = "块级作用域";
    
    if (true) {
        let innerLet = "内部的let";
        var innerVar = "内部的var"; // 依然提升到函数顶部!
        
        console.log(blockScoped); // ✅ 可以访问外层的let
        console.log(functionScoped); // ✅ 可以访问外层的var
    }
    
    console.log(innerVar); // ✅ 可以访问
    // console.log(innerLet); // ❌ 报错!let是块级作用域
}

9.2 作用域链的新层级

// 全局作用域
const GLOBAL = "地球";

function country() {
    // 函数作用域
    let nationalLaw = "国家法律";
    
    {
        // 块级作用域1
        let provincialLaw = "省法规";
        
        if (true) {
            // 块级作用域2
            let cityRule = "市规定";
            
            console.log(cityRule);     // ✅ 本市有效
            console.log(provincialLaw); // ✅ 本省有效
            console.log(nationalLaw);   // ✅ 全国有效
            console.log(GLOBAL);        // ✅ 全球有效
        }
        
        // console.log(cityRule); // ❌ 跨市无效
    }
}

⚡ 第十章:作用域链的性能与优化

10.1 作用域查找的代价

var globalVar = "我在最外层";

function level3() {
    // 这个查找要经过:自己 → level2 → level1 → 全局
    console.log(globalVar);
}

function level2() {
    level3();
}

function level1() {
    level2();
}

10.2 优化心法

function optimized() {
    const localCopy = globalVar; // 局部缓存,减少查找深度
    
    function inner() {
        console.log(localCopy); // 直接访问,快速!
    }
    
    inner();
}

🏆 大结局:黑马喽的毕业总结

经过这番学习,黑马喽终于明白了作用域的真谛:

🎯 作用域的进化史

  1. ES5的混乱var无视块级作用域,到处捣乱
  2. ES6的秩序let/const引入块级作用域和暂时性死区
  3. outer指针机制:词法作用域在编译时确定,一辈子不变

🧠 作用域链的精髓

  1. outer指针:函数在编译时就确定了自己的"娘家"
  2. 词法作用域:看出生地,不是看调用地
  3. 就近原则:先找自己,再按outer指针找上级
  4. 闭包的力量:函数永远记得自己出生时的环境

💡 最佳实践心法

// 好的作用域设计就像好的家风
function createFamily() {
    // 外层:家族秘密,内部共享
    const familySecret = "传家宝";
    
    function teachChild() {
        // 中层:教育方法
        const education = "严格教育";
        
        return function child() {
            // 内层:个人成长
            const talent = "天赋异禀";
            console.log(`我有${talent},接受${education},知道${familySecret}`);
        };
    }
    
    return teachChild();
}

const familyMember = createFamily();
familyMember(); // 即使独立生活,依然记得家族传承

🌟 终极奥义

黑马喽感慨地总结道:

"原来JavaScript的作用域就像血缘关系:

  • 作用域是家规(在哪里能活动)
  • 作用域链是族谱(怎么找到祖先)
  • outer指针是出生证明(一辈子不变)
  • 词法作用域是家族传承(看出生地,不是看现住地)"

从此,黑马喽明白了:想要在前端江湖混得好,就要遵守作用域的家规,理解作用域链的族谱,尊重outer指针的出生证明!


🐒 黑马喽寄语:记住,函数的作用域是它的"娘家",编译时定亲,一辈子不变!理解了这套规则,你就能驯服任何JavaScript代码!

封装一个支持动态表头与权限控制的通用 VxeTable 组件

作者 GYY_y
2025年11月28日 17:28

项目背景 基础需求: 我这边的需求是要把表格的表头设置为动态的情况、并且允许默写列进行显示隐藏、冻结、排序、默认某几项进行禁止取消勾选。 在这个基础需求上需要兼容按钮权限、以及每一个单元格内的点击事件和toolbar的按钮动态禁止功能

动态表头配置

不同角色、不同场景下,用户对字段的关注点不同,需支持运行时动态调整表头结构,包括列的显示/隐藏、顺序调整、宽度记忆等。 精细化列控制 允许部分业务列(如“备注”“标签”)被用户自由隐藏; 关键列(如首列、操作列、复选框、序号)必须始终可见,禁止在列设置中取消勾选; 支持列冻结(固定左/右)、排序、宽度拖拽等交互能力。 深度交互与权限集成 每个单元格需支持独立点击事件(如跳转详情、编辑内联); 表格工具栏(Toolbar)按钮需按用户权限动态渲染(兼容 v-permission 等指令); 操作列中的按钮同样需支持权限控制与自定义插槽。 配置持久化

用户对表头的个性化设置(如隐藏了哪些列、调整了哪些顺序)应自动保存至服务端,并在下次访问时还原,提升使用体验。

效果图

图片

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

视频效果

live.csdn.net/v/503221

版本号

在这里插入代码片
  "vxe-pc-ui": "^4.10.30",
   "vxe-table": "^4.17.20",
   "xe-utils": "^3.7.9",
   "vue": "^3.5.18"

Props 说明

参数 类型 默认值 说明
border Boolean true 是否显示表格边框
stripe Boolean true 是否显示斑马纹
cloumnDrag Boolean false 是否允许拖拽调整列顺序
toolDrag Boolean true 是否显示右上角“列设置”按钮(齿轮图标)
height String / Number '500px' 表格高度,支持 '400px''80%'
code String '' 必填!当前页面唯一标识,用于保存/恢复列配置
showCheckbox Boolean true 是否显示复选框列
showIndex Boolean false 是否显示序号列
showAction Boolean false 是否显示操作列
actionWidth Number 100 操作列宽度(单位:px)
slotsFields Array<String> [] 需要用插槽渲染的字段名,如 ['status', 'name']

双向数据绑定

名称 类型 默认值 说明
data Array [] 表格主体数据,每一项为一行记录
buttons Array [] 左侧工具栏按钮配置,格式如:{ code: 'add', name: '新增' }
column Array [] 表头列配置,每列需包含 field(字段名)、title(标题)、visible(是否显示)等属性

插槽

<!-- 渲染 status 字段 --><template #status="{ row, column, $rowIndex }">  
 <span>{{ row.statusText }}</span></template>  

event方法

事件名 回调参数 说明
cellClick (row, column, value, title) 点击单元格时触发
checkAll (selectedRows: Array) 全选/取消全选时触发
check (selectedRows: Array) 单行勾选状态变化时触发
saveSuccess 用户点击【确定】或【恢复默认】后触发(用于重新加载表格)
leftBar (button: Object) 点击左侧工具栏按钮时触发

完整的使用示例

// 组件
<Table  
 ref="tableRef" 
 v-model:column="columns" 
 v-model:data="tableData" 
 v-model:buttons="buttons" 
 :code="ViewName.CRM_MY_CUSTOMER_LIST" 
 :height="tableHeight" 
 :show-action="true" 
 :stripe="false" 
 action-width="200" 
 :slots-fields="slotsFields" 
 @cell-click="handleCellClick" 
 @check-all="handleSelectChange" 
 @check="handleSelectChange" 
 @save-success="initList" 
 @left-bar="handleLeftBar">  
 // 这里的操作栏是action 名称必须固定为action 需配合show-action属性
 <template #action="{ row }"> 
 <div v-if="shouldShowActions(row)"> 
 <el-button v-permission="['customer:my:edit']" link type="primary" @click="handleTableUpdate(row)" >编辑</el-button>  
 </div> 
 </template>  
 // 这次插槽totalConsumeAmount必须要在slotsFields内填写 不然不允许使用
 <template #phoneNumber="{ row }"> 
 <IconifyIconOnline v-if="row.phoneNumber" icon="ep:copy-document" style="display: inline; cursor: pointer" @click="copy(row.phoneNumber)" />&nbsp; {{ row.phoneNumber }} 
 </template>  
 // 这次插槽totalConsumeAmount必须要在slotsFields内填写 不然不允许使用
 <template #followUpCount="{ row }"> 
 <el-button link type="primary" @click="previewTable(row)" >点击查看</el-button> 
 </template>  
 // 这次插槽totalConsumeAmount必须要在slotsFields内填写 不然不允许使用
 <template #totalConsumeAmount="{ row }"> 
 <span class="custom_high" @click="handleClickConsumptionUserInfo(row)" >{{ row.totalConsumeAmount }</span > 
 </template>
 </Table>

<script setup>
import { computed, ref, onMounted, defineOptions, nextTick } from "vue";

const tableRef = ref(null); // 表格ref
const columns = ref([]); // 列名称list
const tableData = ref([]); // 数据list

// 表格高度
const tableHeight = computed(() => {
  // searchHeight 为form的高度
  const searchHeight = searchBoxHeight.value || 0;
  return window.innerHeight - 280 - searchHeight;
});
// 单元格需要用到的插槽 需要在这里定义才能使用 重点!!!
const slotsFields = ref([
  "customerName",
  "sourceChannelName",
  "phoneNumber",
  "followStatus",
  "totalConsumeAmount",
  "totalRechargeAmount"
]);

const selectListIds = ref([]); // 勾选值id列表
// 勾选事件
const handleSelectChange = val => {
  selectListIds.value = val.map(item => item.id);
};
// toolbar上面的按钮
const rawButtons = [
  {
    code: "addMember", // 必传值
    name: "添加客户",  //  必传值 文本(页面展示)
    icon: "vxe-icon-add",  // icon
    status: "primary", // 状态
    permissionCode: "customer:my:add" // 是否有按钮权限。如果不传或者为值为空字符串 代表所有人可见
  },
  {
    code: "batchImport",
    name: "批量导入",
    status: "default",
    permissionCode: "customer:my:batchImport"
  },
  {
    code: "edit", // 必传值
    name: "编辑", //  必传值 文本(页面展示)
    status: "default", // 状态
    dependsOnSelection: true, // 是否被勾选框控制 如果值为true代表被checkbox勾选有关 不传或者为false则不被checkbox控制
    permissionCode: "customer:my:edit" // 是否有按钮权限。如果不传或者为值为空字符串 代表所有人可见
  }
];

// usePermissionButtons 函数为控制toolbar按钮权限处理
const buttons = usePermissionButtons(rawButtons, selectListIds);

const handleLeftBar = val => {
  switch (val.code) {
    case "addMember":
      break;
    case "batchImport":
      break;
    default:
      break;
  }
};

const getHeader = async () => {
  const res = await getTableHeader(ViewName.CRM_MY_CUSTOMER_LIST);
  columns.value = res.data.map(i => {
    return {
      ...i,
      field: i.key, // 必传
      title: i.label, // 必传
      width: i.width ?? "100px" // 必传
    };
  });
};

const initList = () => {
  getHeader(); // 获取表头数据
  getList(); // 获取列表数据
};
</script>

usePermissionButtons文件

// usePermissionButtons.js文件代码

import { computed } from "vue";
import { useUserStore } from "@/store/modules/user";

// store
const userStore = useUserStore();

/**
 * 根据 rawButtons 和选中状态,生成带权限控制和禁用状态的按钮列表
 * @param {Array} rawButtons - 原始按钮配置数组
 * @param {Ref<number[]> | Ref<any[]>} selectListIds - 选中的 ID 列表(ref)
 * @returns {ComputedRef<Array>} 过滤并处理后的按钮列表
 */
export function usePermissionButtons(rawButtons, selectListIds) {
  return computed(() => {
    const isEmpty = selectListIds.value.length === 0;
    const permissionList = getPermissionCodeList(); // 确保这是响应式的或最新值
    if (permissionList[0] === "*:*:*") {
      // admin
      return rawButtons;
    } else {
      // 非admin
      return rawButtons
        .filter(
          btn =>
            !btn.permissionCode || permissionList.includes(btn.permissionCode)
        )
        .map(btn => ({
          ...btn,
          disabled: btn.dependsOnSelection ? isEmpty : (btn.disabled ?? false)
        }));
    }
  });
}


// 获取登录人所有的按钮权限
export const getPermissionCodeList = () => {
  return userStore.permissions || [];
};

Table组件代码


<template>
  <div class="demo-page-wrapper">
    <vxe-grid
      ref="gridRef"
      v-bind="gridOptions"
      @toolbar-button-click="handleLeftToolbar"
      @checkbox-all="selectAllChangeEvent"
      @checkbox-change="selectChangeEvent"
      @cell-click="handleCellClick"
      @custom="handleColumnCustom"
    >
      <template v-for="(_, name) in $slots" #[name]="slotData">
        <slot :name="name" v-bind="slotData" />
      </template>
    </vxe-grid>
  </div>
</template>

<script setup>
import {
  ref,
  defineProps,
  defineExpose,
  defineModel,
  defineEmits,
  computed
} from "vue";
import XEUtils from "xe-utils";
import { headCancel, headSave } from "@/api/view";
import { ElMessage } from "element-plus";

const emits = defineEmits([
  "cellClick",
  "checkAll",
  "check",
  "saveSuccess",
  "leftBar"
]);
// 定义 props
const props = defineProps({
  // 边框线
  border: {
    type: Boolean,
    default: true
  },
  // 斑马线
  stripe: {
    type: Boolean,
    default: true
  },
  // 列拖拽
  cloumnDrag: {
    type: Boolean,
    default: false
  },
  // 自定义拖拽icon
  toolDrag: {
    type: Boolean,
    default: true
  },
  // 表格高度
  height: {
    type: [String, Number],
    default: "500px" // 也支持%
  },
  // 每个数据的唯一code
  code: { type: String, default: "" },
  // 是否展示复选框
  showCheckbox: { type: Boolean, default: true },
  // 是否展示索引号
  showIndex: { type: Boolean, default: false },
  // 是否展示操作列
  showAction: { type: Boolean, default: false },
  // 操作列宽度
  actionWidth: { type: Number, default: 100 },
  // 需要的插槽 例子: ["name", "id", ....]
  slotsFields: {
    type: Array,
    default: () => {
      return [];
    }
  }
});

// 表格数据
const tableData = defineModel("data", {
  default: []
});

// 左侧操作栏
const buttonsList = defineModel("buttons", {
  default: []
});

// 表头数据
const column = defineModel("column", {
  default: []
});

// 将这些值进行禁用
const disabledKeys = computed(() => {
  return column.value.length
    ? ["checkbox", "seq", "action", column.value[0].field]
    : ["checkbox", "seq", "action"];
});
// 处理slot插槽
const processedColumns = computed(() => {
  return column.value.map(col => {
    // 确保是普通数据列且有 field
    if (!col.type && col.field != null) {
      if (props.slotsFields.includes(col.field)) {
        return {
          ...col,
          slots: { default: col.field }
        };
      }
    }
    return col; // 原样返回(包括无 field 的列、type 列等)
  });
});

// 使用 computed,确保每次都是最新值
const gridOptions = computed(() => {
  const cols = [];

  // 复选框列
  if (props.showCheckbox) {
    cols.push({
      type: "checkbox",
      width: 40,
      fixed: "left",
      visible: true,
      field: "checkbox" // 该值是为了禁用复制的唯一值
    });
  }

  // 序号列
  if (props.showIndex) {
    cols.push({
      type: "seq",
      width: 50,
      title: "序号",
      fixed: "left",
      visible: true,
      field: "seq" // 该值是为了禁用复制的唯一值
    });
  }

  //  只加处理后的业务列(已自动注入 slots)
  cols.push(...processedColumns.value);

  // 操作列
  if (props.showAction) {
    cols.push({
      field: "action",
      title: "操作",
      width: props.actionWidth,
      fixed: "right",
      align: "center",
      visible: true,
      slots: { default: "action" }
    });
  }

  return {
    border: props.border,
    stripe: props.stripe,
    showOverflow: true,
    height: props.height,
    loading: false,
    columnConfig: { drag: props.cloumnDrag, resizable: true },
    rowConfig: { isCurrent: true, isHover: true },
    columnDragConfig: { trigger: "cell", showGuidesStatus: true },
    customConfig: {
      // 该列是否允许选中
      checkMethod({ column }) {
        return !disabledKeys.value.includes(column.field);
      }
    },
    toolbarConfig: {
      custom: props.toolDrag,
      zoom: false,
      buttons: buttonsList.value
    },
    checkboxConfig: { range: true },
    columns: cols,
    // columns: cols.filter(i => i.field !== "checkbox" && i.field !== "seq"),
    data: tableData.value
  };
});

const gridRef = ref(null);

// 选中的项
const selectedRows = ref([]);

// 事件处理
const selectAllChangeEvent = ({ checked }) => {
  selectedRows.value = gridRef.value?.getCheckboxRecords() || [];
  emits("checkAll", selectedRows.value);
};

const selectChangeEvent = ({ checked }) => {
  selectedRows.value = gridRef.value?.getCheckboxRecords() || [];
  emits("check", selectedRows.value);
};

// 清空选中的数据
const clearSelectEvent = () => {
  gridRef.value?.clearCheckboxRow();
};

// 获取选中的数据
const getSelectEvent = () => {
  const records = gridRef.value?.getCheckboxRecords() || [];
  console.log(`已选中 ${records.length} 条数据`);
};

// 选中所有
const selectAllEvent = () => {
  gridRef.value?.setAllCheckboxRow(true);
};

// 设置自定义勾选数据
const setSelectRow = (rows, checked = true) => {
  gridRef.value?.setCheckboxRow(rows, checked);
};

// 单元格点击
const handleCellClick = ({
  row,
  rowIndex,
  $rowIndex,
  column,
  columnIndex,
  $columnIndex,
  triggerRadio,
  triggerCheckbox,
  triggerTreeNode,
  triggerExpandNode,
  $event
}) => {
  emits("cellClick", row, column, row[column.property], column.title);
};

// 自定义筛选icon弹窗事件
const handleColumnCustom = params => {
  switch (params.type) {
    case "open":
      break;
    case "confirm": {
      // 白名单列表 将操作列和复选框、序号列过滤
      const whiteList = new Set(["action", null, undefined]);
      // 获取勾选的
      // const visibleColumn = gridRef.value?.getColumns() || [];
      // 获取所有的 visible来区分是否勾选
      const visibleColumn = gridRef.value?.getFullColumns() || [];
      const result = visibleColumn
        .map(i => {
          return {
            key: i.field,
            fixed: i.fixed,
            visible: i.visible,
            width: i.width,
            title: i.title,
            label: i.label,
            field: i.field
          };
        })
        .filter(k => !whiteList.has(k.key))
        .filter(i => i.field !== "checkbox" && i.field !== "seq");

      headSave(result, props.code)
        .then(() => {
          ElMessage.success("保存成功");
          emits("saveSuccess");
        })
        .catch(e => {
          console.error(e);
        });
      break;
    }
    case "reset": {
      // 恢复默认
      headCancel(props.code).then(() => {
        emits("saveSuccess");
      });
      break;
    }
    case "close": {
      break;
    }
  }
};

const handleLeftToolbar = val => {
  const { code, button, $event } = val;
  emits("leftBar", button);
};

// 暴露方法
defineExpose({
  gridRef,
  clearSelectEvent,
  getSelectEvent,
  selectAllEvent,
  setSelectRow
});
</script>

<style lang="scss" scoped>
.demo-page-wrapper {
  height: 100%;
  padding: 0;
  background-color: #fff;
}
</style>

Git:如何排查非线性历史中被隐秘覆盖的修改(完整实战笔记)

2025年11月28日 17:20

在多人协作开发中,尤其是 i18n 文案文件(如 assets/locales/ja_JP.json)体量庞大时,某一段 key 被突然“消失” ,往往不是故意删除,而是:

  • 同事基于旧分支开发
  • merge commit 采取 ours/theirs 策略自动覆盖
  • 或 merge 的某个 parent 版本较旧,导致新 key 在另一个 parent 中被丢弃
  • 没有线性历史,GitLab UI 的 diff 不一定能看到

这个问题很隐蔽,但可以完全定位。

本文总结排查流程。

第一步:确认问题是否被覆盖(非故意删除)

我们要知道:

这个 Key 是 “被人写代码删掉” 还是 “merge 自动覆盖掉”?

使用:

git log -S "modbus_server" -p -- assets/locales/ja_JP.json

含义:

  • S 搜索文本出现/消失的位置
  • p 展示 diff

结果显示:

✔️ Key 的添加出现在最早的 commit

❌ Key 的删除并未以明显 diff 方式出现

→ 说明不是编辑删除,而是 merge 导致覆盖


2️⃣ 第二步:锁定 key 仍存在时的最后一个 commit

通过定位:

2b86a183de12891ae463bcb941defb8a338d2046

这个版本中 key 仍然存在。


3️⃣ 第三步:查找它的直接 children

因为 develop 历史是非线性的,所以不能只看时间顺序。

使用:

git rev-list --children develop | grep 2b86a183

输出:

2b86a183 ... ce3ba83f ... f8e8dc155ae6...
80e7ca4386... 2b86a183...

含义:

  • commit 2b86a183两个 child

    • ce3ba83f
    • f8e8dc155ae6

只要找到哪个 child 删除了 key,就能定位元凶


4️⃣ 第四步:对比两个 child 与 parent

A. 对比第一个 child:

git diff 2b86a183..ce3ba83f -- assets/locales/ja_JP.json

输出明确显示:

✔️ modbus_server 整段内容被 删掉了

关键:这就是删除 key 的确切 commit!

B. 对比第二个 child:

git diff 2b86a183..f8e8dc155ae6

没有涉及该 key

→ 不是它的问题。


5️⃣ 第五步:在 GitLab 远端查看(UI)

GitLab Compare 页面必须使用格式:

<http://xxxx/-/compare/><base>...<target>

三个点:

2b86a183...ce3ba83f

如果使用两个点或反向,会失败。

例如:

<http://code-oss.sigenpower.com:8090/sigen_app/sigenmain/-/compare/2b86a183de12...ce3ba83f5dce>

即可在 UI 中看到该 diff。


6️⃣ 为什么 GitLab 看不到删除 diff?

因为:

  • 提交 ce3ba83f 是一个 merge commit
  • GitLab 默认显示 merge commit 的 diff 是对所有 parent 的 combined diff
  • 若包含文件完全覆盖,GitLab UI 会“隐藏”这类大块变更
  • JSON 巨文件会触发 GitLab 的 “cut diff” 行为,不展示全部内容

所以:

本地 diff 能看到删掉整段

GitLab UI 不一定展示

很常见。


7️⃣ 最终确认:这个 commit 确实就是删除的来源吗?

✔️ 是的。

判断依据:

  1. git diff parent..child 直接显示删除 → 100% 明确
  2. 另一个 child 没删除
  3. git log -S 没找到显式删除的记录 → merge 覆盖导致
  4. Git DAG 可证明唯一路径包含这个 child

结论:

删除源头 commit 明确为:

ce3ba83f5dce2bcda26d1d2081d9259c904aa8e7


8️⃣ 总结:如何在非线性历史中定位“被覆盖的改动”

流程简化版:

  1. 查找 key 最后出现的 commit

    git log -S "xxx-key"
    
  2. 找它的 children

    git rev-list --children develop | grep <commit>
    
  3. 对比 parent 与 children

    git diff <parent>..<child>
    
  4. 哪个 child 删除了 key → 问题提交

  5. GitLab Compare 使用:

    ...   (三个点)
    

AI生成CAD图纸(云原生CAD+AI让设计像聊天一样简单)

2025年11月28日 17:20

项目概述

本章节探讨AI技术与在线CAD相结合,能否打造一个能让CAD"听懂人话"的智能助手。

核心价值:告别繁琐的手动绘图,用自然语言就能完成CAD设计。无论是建筑工程师、机械设计师,还是CAD开发者,都能通过AI大幅提升工作效率。

为什么选择MxCAD来做CAD智能系统?

1. 原子化API - AI时代的CAD开发利器

传统CAD软件的问题是:你只能用它给你的功能,比如"画直线"、"画圆"这样的整体功能。但MxCAD的API把所有功能都拆得特别细,就像乐高积木一样:

// 传统方式:只能调用drawCircle()
drawCircle(center, radius);
// MxCAD原子化API:AI可以精确控制每个细节
const center = new McGePoint3d(100, 100, 0);  // 精确控制圆心
const circle = new McDbCircle();              // 创建圆对象
circle.center = center;                       // 设置圆心
circle.radius = 50;                           // 设置半径
circle.trueColor = new McCmColor(255, 0, 0);  // 精确控制颜色
entitys.push(circle);                         // 添加到图纸

这对AI意味着什么?

- AI可以像人类工程师一样思考,理解每个几何元素的含义

- 可以精确控制颜色、图层、线型等所有属性

- 能处理复杂的空间变换和几何计算

- 生成的代码质量更高,更符合工程规范

2. 智能体策略 - 让AI像专业工程师一样思考

我们设计了三种AI智能体,各自负责不同的专业领域:

A.建模智能体(ModelingAgent)

专业领域 :CAD图形创建和迭代修改

工作流程

1. 接收自然语言指令(如"画一个带圆角的矩形,长100宽60,圆角半径5")

2. 分析需求,拆解为几何元素

3. 生成精确的MxCAD代码

4. 在沙箱中预览效果

5. 自动修复可能的错误

6. 最终插入到图纸中

技术亮点

- 支持代码迭代修改:"刚才那个矩形,把圆角改成10"

- 自动管理实体数组,避免重复和遗漏

- 智能错误修复:代码执行失败时自动分析错误并修复

- 最多重试3次,确保成功率  

B.通用智能体(DefaultAgent)

专业领域 :CAD图纸操作和查询

典型任务

- "选中所有长度大于100的直线"

- "把图层"标注"的颜色改成红色"

- "计算这个区域的面积"

- "导出选中的实体为DXF"  

技术亮点

- 理解CAD专业术语和概念

- 能操作图层、线型、标注等CAD特有功能

- 支持复杂的选择条件和过滤

C.意图识别智能体(IntentRecognitionAgent)

角色 :智能调度员

工作原理

1. 关键词匹配:快速识别用户意图(如包含"画"、"创建"等词 → 建模智能体)

2. LLM深度分析:复杂请求调用大语言模型分析

3. 智能路由:自动选择最合适的智能体处理

优势:用户无需手动选择模式,系统智能判断

3.安全沙箱-让AI代码安全运行

为什么需要沙箱?

AI生成的代码可能包含错误、无限循环或恶意代码,直接在主应用中执行可能导致崩溃或数据丢失。

我们的解决方案

用户输入 → AI生成代码 → 沙箱预览 → 错误检测 → 自动修复 → 用户确认 → 插入图纸

技术实现

- 在隔离的iframe中执行代码

- 限制访问DOM和本地存储

- 捕获所有错误信息

- 提供详细的错误堆栈分析

- 支持最多3次自动修复尝试  

用户体验

- 实时预览AI绘图效果

- 确认无误后再插入图纸

- 随时取消或重新生成

- 支持撤销操作

AI功能详解

自然语言到CAD代码转换

示例1:绘制复杂机械零件

用户:画一个法兰盘,外径200,内径1006个直径20的螺栓孔均匀分布
AI生成代码:
// 创建外圆
const outerCircle = new McDbCircle();
outerCircle.center = new McGePoint3d(0, 0, 0);
outerCircle.radius = 100;
entitys.push(outerCircle);
// 创建内圆
const innerCircle = new McDbCircle();
innerCircle.center = new McGePoint3d(0, 0, 0);
innerCircle.radius = 50;
entitys.push(innerCircle);
// 创建6个螺栓孔
for (let i = 0; i < 6; i++) {
  const angle = (i * 60) * Math.PI / 180;
  const hole = new McDbCircle();
  hole.center = new McGePoint3d(75 * Math.cos(angle), 75 * Math.sin(angle), 0);
  hole.radius = 10;
  entitys.push(hole);
}

示例2:建筑平面图

用户:画一个10m×8m的房间,墙厚240mm,门宽900mm在右侧墙中央
AI自动计算所有尺寸,生成符合建筑规范的墙体和门窗

智能代码优化

自动补全import语句

// AI生成的代码可能缺少import
const line = new McDbLine(); // 错误:McDbLine未定义
// 系统自动补全
import { McDbLine, McGePoint3d } from "mxcad";
const line = new McDbLine(); // 正确

管理实体数组

// AI可能忘记将实体添加到图纸
const circle = new McDbCircle();
// 缺少 entitys.push(circle);
// 系统自动检测并添加
const circle = new McDbCircle();
entitys.push(circle); // 自动添加

智能修复语法错误

// AI可能生成有语法错误的代码
const point = new McGePoint3d(0, 0, 0) // 缺少分号
// 系统自动修复
const point = new McGePoint3d(0, 0, 0); // 自动添加分号

多AI模型支持

支持的AI提供商

- OpenRouter:统一接口,支持DeepSeek、Llama、Gemini等100+模型

- OpenAI:GPT-4、GPT-3.5等官方模型

- iFlow:国产大模型,包括通义千问、Kimi、DeepSeek等

- 自定义:支持任何OpenAI兼容的API

模型选择策略

- 免费模型:适合测试和简单任务

- 付费模型:适合复杂任务和高质量要求

- 国产模型:适合数据安全要求高的场景

实际应用场景

场景一:建筑工程师 - 快速绘制标准户型

传统方式

1. 打开CAD软件

2. 选择画线工具

3. 输入起点坐标(0,0)

4. 输入终点坐标(10000,0)  // 10米墙

5. 重复步骤3-4,画4面墙

6. 选择偏移工具,偏移240mm生成内墙线

7. 选择修剪工具,修剪墙角

8. 插入门、窗图块

9. 添加尺寸标注

10. 整个过程约15-30分钟  

AI方式

输入:画一个10m×8m的房间,墙厚240mm,门宽900mm在右侧墙中央,窗宽1500mm在左侧墙中央
AI响应:✅ 已生成标准房间平面图
- 外墙:10m×8m,墙厚240mm
- 门:900mm宽,位于右侧墙中央
- 窗:1500mm宽,位于左侧墙中央
- 已添加尺寸标注
用时:10秒

场景二:机械设计师 - 参数化零件设计

传统方式

- 手动计算所有尺寸

- 逐个绘制每个特征

- 容易出错,修改困难

AI方式

输入:生成一个M10螺栓,长度50mm,头部六角对边16mm

AI响应:✅ 已生成M10螺栓模型

  • 螺纹公称直径:10mm
  • 螺栓长度:50mm
  • 六角头对边宽度:16mm
  • 符合GB/T 5782标准 用时:5秒

场景三:图纸修改-智能批量操作

传统方式

- 手动查找需要修改的元素

- 逐个修改,耗时且容易遗漏

AI方式

输入:把所有标注文字的字体改成仿宋,字高改为3.5mm

AI响应:✅ 已修改23个标注对象

  • 字体:仿宋
  • 字高:3.5mm
  • 修改对象:23个尺寸标注 用时:3秒  

技术架构深度解析

代码执行流程

代码执行流程.png

核心模块说明

1. agents/AgentStrategy.ts

- 智能体策略接口定义

- 智能体实例管理

- 智能体选择逻辑

2. agents/ModelingAgent.ts

- CAD建模专用智能体

- 代码生成与修改

- 错误自动修复

3. agents/IntentRecognitionAgent.ts

- 用户意图识别

- 智能体路由调度

- 对话状态管理

4. core/LLMClient.ts

- 多AI提供商支持

- 请求管理与取消

- 错误处理与重试

5. core/codeModificationUtils.ts

- 代码智能修改

- JSON指令解析

- 语法错误修复

6. sandbox.ts

- 沙箱环境初始化

- 代码安全执行

- 错误信息捕获

7. services/openRouterAPI.ts

- AI模型管理

- API配置管理

- 模型缓存机制  

快速体验AI智能体服务

首先打开demo2.mxdraw3d.com:3000/mxcad/, 如下图: 点击使用AI服务.png 打开AI服务会弹出一个胶囊输入框。我们点击设置按钮,如下图: 打开设置按钮.png 我们需要线配置AI的api接口。这里我们选择iflow AI服务 这是目前国内免费的最佳供应商,如下图: 设置apiKey的弹框.png

具有配置如下:

首先我们打开iflow.cn 登录账号,然后我们鼠标移入头像,找到api管理,如下图: iflow找到api管理设置.png

我们把api key填写到MxCAD AI服务中,如下图: iflow复制apikey.png

选择模型商: iFlow

填写API Key: 刚刚复制的key粘贴在这里, 模型选择: 支持很多模型,都可以,甚至稍微差一些的模型都可以,iFlow目前所有的模型都是免费使用。

然后我们点击“保存”按钮。就可以开始在胶囊输入框内输入你的需求了,比如:一个比较抽象的需求, "画一朵花" 然后按下回车键,如下图: 需求:花一朵花.png 等待一会儿, 就把代码生成出来给你看,并且还有预览效果,如果满意的话点击确认就可以把这朵花插入到图元中了。如果不满意,我们可以继续与AI对话进行修改,如下图: 需求:花一朵花的效果.png 比如现在我们觉得这个花不够精致。我们和AI说, “花不够精致”。然后按下回车键,如下图: 需求:花不够精致.png

需求:花不够精致的效果.png 我们可以不断的让AI修改代码,从而达到一个满意的效果。但要真正投入使用,还需要结合具体的需求调整提示词和整个智能体的流程,以上演示的是建模智能体的能力。而通用智能体的能力,目前主要是用于操作一些实体。

比如:"选中实体按照原本比例放大10倍,间距还是原本的间距" image.png 我们点击生成的代码点击运行,效果就出来了,如下图: image-1.png 还有很多操作,只要是代码可以完成的操作,都可以通过AI配合网页CAD完成。

JavaScript 中的 简单数据类型:Symbol——是JavaScript成熟的标志

作者 栀秋666
2025年11月28日 17:08

深入理解 JavaScript 中的 Symbol:不只是“唯一值”的哲学

在 JavaScript 的八种数据类型中,Symbol 是 ES6 引入的新成员。它不像 numberstring 那样直观,也不像 object 那样复杂多变。它安静地存在于语言底层,却承载着一种独特的“身份标识”使命。很多人初识 Symbol 时,往往只记住一句话:“它是唯一的值”,然后便止步于此。但真正理解 Symbol,需要我们跳出“唯一性”这个标签,去思考它的设计哲学、使用场景以及它如何悄然改变了 JavaScript 对象模型的运作方式。


一、Symbol 的本质:不是“值”,而是“身份”

从技术层面看,Symbol 是一个原始数据类型(primitive type),通过调用全局函数 Symbol() 创建:

const sym1 = Symbol();
const sym2 = Symbol('description');

每次调用 Symbol() 都会返回一个全新且独一无二的 symbol 值,即使参数相同也是如此:

Symbol('foo') === Symbol('foo'); // false

这说明,Symbol 不是在比较内容,而是在比较“身份”。就像世界上没有两片完全相同的雪花,也没有两个相同的 symbol —— 它们生来就是不同的个体。

这种特性使得 Symbol 天然适合作为对象的“私有键”或“元属性键”。但它真正的价值,并不在于“不可重复”,而在于 “不可预见”“不可枚举”


二、Symbol 与对象:一场关于“命名冲突”的救赎

JavaScript 的对象是动态的,我们可以随时添加属性。但在大型项目或多团队协作中,这种灵活性反而成了隐患:不同模块可能无意中使用了相同的属性名,导致覆盖和 bug。

传统做法是加前缀,比如 _privateProp$$internal,但这只是“约定俗成”,无法真正避免冲突。

Symbol 提供了一种语言级别的解决方案

// 模块 A
const cacheKey = Symbol('cache');
class MyClass {
  [cacheKey] = new Map();

  setCache(key, value) {
    this[cacheKey].set(key, value);
  }

  getCache(key) {
    return this[cacheKey].get(key);
  }
}

// 模块 B 即使也创建了一个同名 Symbol,也不会影响模块 A
const anotherCacheKey = Symbol('cache'); // 完全无关

这里的 cacheKey 是一个 symbol,作为对象的 key 使用时,不会被外部轻易访问或覆盖。更重要的是,其他代码即使知道你用了 'cache' 这个描述,也无法构造出相同的 key —— 因为 symbol 的唯一性不由描述决定。

这就是 Symbol 的核心优势:提供一种机制,让开发者可以安全地向对象注入元信息,而不必担心名字污染。


三、Symbol 的“隐身性”:for...in 看不见它

Symbol 作为对象 key 时,默认不会出现在常规的属性枚举中:

const obj = {
  name: 'Alice'
};

obj[Symbol('secret')] = 'hidden';

for (let key in obj) {
  console.log(key); // 只输出 'name'
}

console.log(Object.keys(obj));        // ['name']
console.log(JSON.stringify(obj));     // {"name":"Alice"}

甚至连 JSON.stringify 都会忽略 symbol 属性!这是有意为之的设计 —— 表明 symbol 更像是“元数据”而非“业务数据”。

但如果你真的想获取这些“隐藏钥匙”,JavaScript 也提供了专门的方法:

Object.getOwnPropertySymbols(obj); 
// 返回 [Symbol(secret)]

这就形成了一种有趣的分层结构:

  • for...inObject.keys():面向公众的属性
  • Object.getOwnPropertySymbols():面向内部或特定上下文的元属性

这种分离让我们可以在不干扰公共 API 的前提下,附加调试信息、缓存、状态标记等。


四、Symbol 的高级用法:不仅仅是 key

除了作为对象 key,Symbol 还有一些内置的“知名符号”(Well-Known Symbols),用于定制 JavaScript 对象的行为。这些以 Symbol.xxx 形式存在的属性,其实是语言内部的钩子(hooks)。

1. Symbol.iterator:让对象可迭代

const myCollection = {
  items: ['a', 'b', 'c'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        return index < this.items.length ?
          { value: this.items[index++], done: false } :
          { done: true };
      }
    };
  }
};

for (let item of myCollection) {
  console.log(item); // a, b, c
}

通过实现 Symbol.iterator,普通对象也能被 for...of 遍历。这是 JavaScript 迭代协议的核心。

2. Symbol.toStringTag:自定义 toString 输出

const myObj = {
  [Symbol.toStringTag]: 'MySpecialObject'
};

Object.prototype.toString.call(myObj); 
// "[object MySpecialObject]"

原本所有对象 toString 都是 [object Object],现在你可以让它更具体。

3. Symbol.hasInstance:控制 instanceof 行为

class MyClass {
  static [Symbol.hasInstance](instance) {
    return instance.type === 'myclass';
  }
}

const obj = { type: 'myclass' };
console.log(obj instanceof MyClass); // true!

这打破了 instanceof 必须基于原型链的传统认知,赋予我们更大的控制权。

这些内置 Symbol 表明:Symbol 不只是一个“防重命名工具”,更是 JavaScript 开放其内部机制的一种手段 —— 它把原本封闭的语言行为,变成了可扩展的接口。


五、Symbol 的局限与误解

尽管强大,Symbol 并非银弹。我们需要清醒认识它的边界:

❌ Symbol 不是真正的“私有”

虽然 symbol key 不易被访问,但并非绝对私有:

const sym = Object.getOwnPropertySymbols(obj)[0];
console.log(obj[sym]); // 依然能拿到

如果有人拿到了 symbol 引用,就能访问对应属性。真正的私有应使用 #field(ES2022 私有字段)。

❌ Symbol 不能序列化

如前所述,JSON.stringify 会忽略 symbol 属性。因此不适合用于需要持久化的数据结构。

❌ 全局 symbol?可以用 Symbol.for()

如果确实需要跨文件共享同一个 symbol,可以使用全局注册表:

const s1 = Symbol.for('shared');
const s2 = Symbol.for('shared');
s1 === s2; // true

注意:Symbol.for(key) 是查找或创建,而 Symbol(key) 永远新建。


六、Symbol 的哲学意义:从“命名”到“标识”

回顾编程史,我们一直在与“命名”斗争。变量名、函数名、类名……每一个名字都是一次承诺,也可能是一次妥协。当系统越来越大,命名空间就变得拥挤不堪。

Symbol 的出现,某种程度上是对“命名中心主义”的反叛。它告诉我们:有些东西不需要名字,只需要身份。

就像现实世界中,每个人都有身份证号,但平时我们用名字称呼彼此。Symbol 就是那个身份证号 —— 不常提起,但在关键时候能准确识别“你是谁”。

这也反映了现代编程的一个趋势:从“显式命名”转向“隐式标识”。无论是 React 的 fiber 节点、Vue 的响应式依赖追踪,还是 Redux 的 action type,越来越多的系统开始使用 symbol 来管理内部状态,避免对外暴露过多细节。


七、实战建议:何时该用 Symbol?

结合以上分析,以下是使用 Symbol 的典型场景:

场景 示例
✅ 防止属性名冲突 插件系统中挂载私有状态
✅ 添加元信息 给 DOM 元素附加调试标记
✅ 实现语言协议 让对象支持迭代、转换字符串等
✅ 模拟私有成员 类的内部缓存、配置项
❌ 数据存储 需要 JSON 序列化的字段
❌ 真正的私有 应使用 #private 字段

结语:Symbol 是 JavaScript 成熟的标志

Symbol 看似小众,实则是 JavaScript 走向成熟的重要一步。它不再满足于做一个“脚本语言”,而是开始构建更严谨的抽象能力。

它教会我们:有时候,“看不见”比“看得见”更有力量;“唯一”不仅是技术特性,更是一种设计哲学。

当你下次面对对象属性命名纠结时,不妨问自己一句:

“这个属性,真的需要一个名字吗?”

也许答案是:它只需要一个 Symbol

Konvajs实现虚拟表格

2025年11月28日 17:07

这是一个专栏 从零实现多维表格,此专栏将带你一步步实现一个多维表格,缓慢更新中

本文涉及的代码

虚拟表格

虚拟表格(Virtual Table) 是一种优化技术,用于处理大量数据时的性能问题。它只渲染当前可见区域(视口)内的表格单元格,而不是渲染整个表格的所有数据。

实现原理

一个简单的虚拟表格实现主要包括以下两点(注:一个完善的虚拟表格需要关注的方面更多,这里只讨论核心实现,后续的优化项会在本专栏的后续文章中实现)

  1. 按需渲染:只创建和渲染用户当前能看到的数据行和列
  2. 滚动监听:监听容器滚动事件,动态计算新的可见范围

代码大纲

基于上述原理,我们可以写出如下代码:

import Konva from "konva";
import { Layer } from "konva/lib/Layer";
import { Stage } from "konva/lib/Stage";

export type Column = {
  title: string;
  width: number;
};

type VirtualTableConfig = {
  container: HTMLDivElement;
  columns: Column[];
  dataSource: Record<string, any>[];
};

type Range = { start: number; end: number };

class VirtualTable {
  // =========== 表格基础属性 ===========
  rows: number = 20;
  cols: number = 20;
  columns: Column[];
  stage: Stage;
  layer: Layer;
  dataSource: TableDataSource;

  // =========== 虚拟表格实现 ===========
  // 滚动相关属性
  scrollTop: number = 0;
  scrollLeft: number = 0;
  maxScrollTop: number = 0;
  maxScrollLeft: number = 0;
  visibleRowCount: number = 0;
  // 可见行列范围
  visibleRows: Range = { start: 0, end: 0 };
  visibleCols: Range = { start: 0, end: 0 };
  // 表格可见宽高
  visibleWidth: number;
  visibleHeight: number;

  constructor(config: VirtualTableConfig) {
    const { container, columns, dataSource } = config;
    this.columns = columns;
    this.dataSource = dataSource;
    this.visibleWidth = container.getBoundingClientRect().width;
    this.visibleHeight = container.getBoundingClientRect().height;
    this.visibleRowCount = Math.ceil(this.visibleHeight / ROW_HEIGHT);
    this.maxScrollTop = Math.max(
      0,
      (this.rows - this.visibleRowCount) * ROW_HEIGHT
    );

    // 计算总列宽
    const totalColWidth = this.columns.reduce((sum, col) => sum + col.width, 0);
    this.maxScrollLeft = Math.max(0, totalColWidth - this.visibleWidth);

    this.stage = new Konva.Stage({
      container,
      height: this.visibleHeight,
      width: this.visibleWidth,
    });
    this.layer = new Konva.Layer();
    this.stage.add(this.layer);

    // 监听滚动事件
    this.bindScrollEvent(container);
    // 初始化调用
    this.updateVisibleRange();
    this.renderCells();
  }

  // 监听滚动事件
  bindScrollEvent() {
    this.updateVisibleRange();
    this.renderCells();
  }

  // 计算可见行列范围
  updateVisibleRange() {}

  // 渲染可见范围内的 cell
  renderCells() {}
}

export default VirtualTable;

计算可见行列范围

updateVisibleRange() {
    // 计算可见行
    const startRow = Math.floor(this.scrollTop / ROW_HEIGHT);
    const endRow = Math.min(
      startRow + this.visibleRowCount,
      this.dataSource.length
    );
    this.visibleRows = { start: startRow, end: endRow };

    // 计算可见列
    let accumulatedWidth = 0;
    let startCol = 0;
    let endCol = 0;

    // 计算开始列
    for (let i = 0; i < this.columns.length; i++) {
      const col = this.columns[i];
      if (accumulatedWidth + col.width >= this.scrollLeft) {
        startCol = i;
        break;
      }
      accumulatedWidth += col.width;
    }

    // 计算结束列
    accumulatedWidth = 0;
    for (let i = startCol; i < this.columns.length; i++) {
      const col = this.columns[i];
      accumulatedWidth += col.width;
      if (accumulatedWidth > this.visibleWidth) {
        endCol = i + 1;
        break;
      }
    }

    this.visibleCols = {
      start: startCol,
      end: Math.min(endCol, this.columns.length),
    };
  }

滚动事件监听


  /**
   * 绑定滚动事件
   */
  bindScrollEvent(container: HTMLDivElement) {
    container.addEventListener("wheel", (e) => {
      e.preventDefault();
      this.handleScroll(e.deltaX, e.deltaY);
    });

    // 支持触摸滚动
    let lastTouchY = 0;
    let lastTouchX = 0;
    container.addEventListener("touchstart", (e: TouchEvent) => {
      const touch = e.touches?.[0];
      if (touch) {
        lastTouchY = touch.clientY;
        lastTouchX = touch.clientX;
      }
    });

    container.addEventListener("touchmove", (e: TouchEvent) => {
      const touch = e.touches?.[0];
      if (touch) {
        const deltaY = lastTouchY - touch.clientY;
        const deltaX = lastTouchX - touch.clientX;
        this.handleScroll(deltaX, deltaY);
        lastTouchY = touch.clientY;
        lastTouchX = touch.clientX;
      }
    });
  }

  /**
   * 处理滚动
   */
  handleScroll(deltaX: number, deltaY: number) {
    // 更新滚动位置
    this.scrollTop = Math.max(
      0,
      Math.min(this.scrollTop + deltaY, this.maxScrollTop)
    );
    this.scrollLeft = Math.max(
      0,
      Math.min(this.scrollLeft + deltaX, this.maxScrollLeft)
    );

    // 更新可见行列范围
    this.updateVisibleRange();

    // 更新单元格渲染
    this.renderCells();
  }

单元格渲染逻辑


  /**
   * 获取指定行的 Y 坐标
   * @param rowIndex - 行索引
   * @returns Y 坐标值
   */
  getRowY(rowIndex: number): number {
    return rowIndex * ROW_HEIGHT;
  }

  /**
   * 获取指定列的 X 坐标
   * @param colIndex - 列索引
   * @returns X 坐标值
   */
  getColX(colIndex: number): number {
    let x = 0;
    for (let i = 0; i < colIndex; i++) {
      const col = this.columns[i];
      if (col) {
        x += col.width;
      }
    }
    return x;
  }

  renderCell(rowIndex: number, colIndex: number) {
    const column = this.columns[colIndex];
    if (!column) return;
    // 计算坐标时考虑滚动偏移
    const x = this.getColX(colIndex) - this.scrollLeft;
    const y = this.getRowY(rowIndex) - this.scrollTop;
    // 创建单元格
    const group = new Konva.Group({
      x,
      y,
    });
    const rect = new Konva.Rect({
      x: 0,
      y: 0,
      width: column.width,
      height: ROW_HEIGHT,
      fill: "#FFF",
      stroke: "#ccc",
      strokeWidth: 1,
    });

    // 创建文本
    const text = new Konva.Text({
      x: 8,
      y: 8,
      width: column.width - 16,
      height: 16,
      text: this.dataSource[rowIndex][colIndex],
      fontSize: 14,
      fill: "#000",
      align: "left",
      verticalAlign: "middle",
      ellipsis: true,
    });
    group.add(rect);
    group.add(text);
    this.layer.add(group);
  }

  /**
   * 渲染可见范围内的所有单元格
   * 首先清除旧单元格,然后按行列重新渲染
   */
  renderCells() {
    this.layer.destroyChildren();
    // 渲染数据行
    for (
      let rowIndex = this.visibleRows.start;
      rowIndex <= this.visibleRows.end;
      rowIndex++
    ) {
      for (
        let colIndex = this.visibleCols.start;
        colIndex <= this.visibleCols.end;
        colIndex++
      ) {
        this.renderCell(rowIndex, colIndex);
      }
    }
  }

本文涉及的代码

VDOM 编年史

2025年11月28日 17:05

前言

作为前端开发者,你应该对以下技术演进过程并不陌生:jQuery → MVC → React/Vue(VDOM)→ Svelte/Solid/Qwik(无 VDOM)

每一次技术变迁都伴随着性能瓶颈、设计哲学与工程场景的变化。VDOM 是前端史上最具代表性的技术转折点之一,它改变了 Web 开发方式,同时也带来了新的挑战与发展方向。

本文将讲述一段“VDOM 编年史”:从浏览器渲染瓶颈到 VDOM 的诞生,再到 Diff 算法进化及无 VDOM 的崛起。

VDOM 之前

在 jQuery 时代,更新视图只能直接操作 DOM。然而,频繁的 DOM 操作会带来性能瓶颈,从而导致页面卡顿。

为什么会卡顿

要解释这个问题,我们需要从浏览器渲染引擎的工作原理说起:

浏览器首先通过解析代码分别构建 DOM 和 CSSOM,然后将两者结合生成渲染树。渲染树用于计算页面上所有内容的大小和位置。布局完成后,浏览器才会将像素绘制到屏幕上。其中,布局计算/重排(Layout/Reflow)是渲染过程中最核心的性能瓶颈。

在下面这些情况下会触发重排:

  • 修改元素几何属性(width/height...)
  • 内容变化、添加、删除 DOM
  • 获取布局信息(offsetTop/Left/Width...)

根据影响范围重排又可以分为

  1. 只影响一个元素及其子元素的简单重排
  2. 影响一个子树下所有元素的局部重排
  3. 整个页面都需要重新计算布局的全量重排

性能消耗对比

布局计算时间的简化模型可以表示为:Layout 时间 ≈ 基础开销 + Σ(每个受影响元素的复杂度 × 元素数量)。这里的‘基础开销’指每次触发布局的固定开销。

可以自行通过示例实验配合 Chrome DevTools 的 Performance 面板来验证。

<!-- 测试模板 -->
<div class="container">
  <div class="box" id="target"></div>
  <div class="children">
    <div v-for="n in 100"></div>
  </div>
</div>
// 性能测试方法
function measure(type) {
  const start = performance.now();
  
  switch(type) {
    case 'simple-reflow':
      target.style.width = '300px'; 
      break;
    case 'partial-reflow':
      container.style.padding = '20px';
      break;
    case 'full-reflow':
      document.body.style.fontSize = '16px';
      break;
    case 'repaint':
      target.style.backgroundColor = '#f00';
  }
  
  // 强制同步布局
  void target.offsetHeight; 
  
  return performance.now() - start;
}

还可以参考行业权威数据:Google 的 RAIL 模型(web.dev/rail/)和 BrowserBench(browserbench.org/)等。

对比测试数据,可以得到以下性能消耗的对比结果

类型 影响范围 计算复杂度 典型耗时
简单重排 单个元素 O(1) 1-5ms
局部重排 子树 O(n) 5-15ms
全量重排 全局 O(n) 15-30ms
重绘 无布局变化 O(1) 0.1-1ms

具体测试结果可能存在误差,受以下因素影响:DOM 树复杂度、样式规则复杂度、GPU 加速是否开启,以及硬件设备和浏览器引擎的差异等。

事件循环与渲染阻塞

事件循环是浏览器处理 JS 任务和页面渲染的核心机制,而渲染阻塞则发生在 JS 执行时间过长时,导致页面无法及时更新。 下面是一个典型的性能问题示例:

// 典型性能问题代码
function badPractice() {
  for(let i=0; i<1000; i++) {
    const div = document.createElement('div');
    document.body.appendChild(div); // 每次循环都触发重排
    div.style.width = i + 'px';     // 再次触发重排
  }
}

性能影响过程:

  1. 每次循环触发 2 次重排
  2. 共 2000 次重排操作
  3. 主线程被完全阻塞
  4. 因此页面呈现卡死直至循环结束

性能消耗计算:

  • 每次循环消耗:2 次重排(≈5ms)×2 = 10ms;
  • 1000 次循环:1000 × 10ms = 10000ms;
  • 因此阻塞总时间约 10000ms,对应丢失约600帧(10000/16.67≈600);

结果是用户将体验到约 10 秒的卡顿。

手动优化方案

离线 DOM 操作(DocumentFragment)

将要添加的多个节点先批量添加到 DocumentFragment 中,最后一次性插入页面,有效降低重排频率。

// 优化前:直接操作 DOM
function appendItemsDirectly(items) {
  const container = document.getElementById('list');
  items.forEach(item => {
    const li = document.createElement('li');
    li.textContent = item;
    container.appendChild(li); // 每次添加都触发重排
  });
}

// 优化后:使用 DocumentFragment
function appendItemsOptimized(items) {
  const fragment = document.createDocumentFragment();
  items.forEach(item => {
    const li = document.createElement('li');
    li.textContent = item;
    fragment.appendChild(li);
  });
  
  document.getElementById('list').appendChild(fragment); // 单次重排
}

读写分离

利用浏览器批量更新机制、避免强制同步布局(Forced Synchronous Layout)进而减少布局计算次数

// 错误写法:交替读写布局属性
function badReadWrite() {
  const elements = document.getElementsByClassName('item');
  for(let i=0; i<elements.length; i++) {
    elements[i].style.width = '200px';        // 写操作
    const height = elements[i].offsetHeight;  // 读操作
    elements[i].style.height = height + 'px'; // 再次写操作
  }
}

// 优化写法:批量读写
function goodReadWrite() {
  const elements = document.getElementsByClassName('item');
  const heights = [];
  // 批量读
  for(let i=0; i<elements.length; i++) {
    heights.push(elements[i].offsetHeight);
  }
  // 批量写
  for(let i=0; i<elements.length; i++) {
    elements[i].style.width = '200px';
    elements[i].style.height = heights[i] + 'px';
  }
}

FastDom

FastDOM 是一个轻量级库,它提供公共接口,可将 DOM 的读/写操作捆绑在一起。它将每次测量(measure)和修改(mutate)操作排入不同队列,并利用 requestAnimationFrame 在下一帧统一批处理,从而降低布局压力。

// 使用 FastDOM 库(自动批处理)
function updateAllElements() {
  elements.forEach(el => {
    fastdom.measure(() => {
      const width = calculateWidth();
      const height = calculateHeight();
      
      fastdom.mutate(() => {
        el.style.width = width;
        el.style.height = height;
      });
    });
  });
}

可以参考此示例了解在修改 DOM 宽高时使用 FastDOM 前后的性能对比(wilsonpage.github.io/fastdom/exa…)

通过以上优化,可以大幅缓解渲染压力。但手动控制 DOM 更新不易维护,且在复杂应用中易出错。这时,虚拟 DOM 概念应运而生。

VDOM 时代

2013 年 Facebook 发布了 React 框架,提出了虚拟 DOM 概念,即用 JavaScript 对象模拟真实 DOM。

虚拟 DOM 树

将真实 DOM 抽象为轻量级的 JavaScript 对象(虚拟节点),形成一棵虚拟 DOM 树。

// 虚拟 DOM 节点结构示例
const vNode = {
  type: 'ul',
  props: { className: 'list' },
  children: [
    { type: 'li', props: { key: '1' }, children: 'Item 1' },
    { type: 'li', props: { key: '2' }, children: 'Item 2' }
  ]
};

差异化更新(Diffing)

简单来说,虚拟 DOM 利用 JavaScript 的计算能力来换取对真实 DOM 直接操作的开销。当数据变化时,框架通过比较新旧虚拟 DOM(即执行 Diff)来确定需要更新的部分,然后只更新相应的视图。

虚拟 DOM 的优点

  • 跨平台与抽象:虚拟 DOM 用 JavaScript 对象表示 DOM 树,脱离浏览器实现细节,可映射到浏览器 DOM、原生组件、小程序等,便于服务端渲染 (SSR) 和跨平台渲染。
  • 只更新变化部分:通过对比新旧虚拟 DOM 树并生成补丁 (patch),框架仅对真实 DOM 做必要的最小修改,避免重建整棵 DOM 树。
  • 性能下限有保障:虚拟 DOM 虽然不是最优方案,但比直接操作 DOM 更稳健,在无需手动优化的情况下能提供可预测的性能表现。
  • 简化 DOM 操作:更新逻辑从命令式变为声明式驱动,开发者只需关注数据变化,框架负责高效更新视图,从而大幅提升开发效率。
  • 增强组件化和编译优化能力:虚拟渲染让组件更易抽象和复用,并可结合 AOT 编译,将更多工作移到构建阶段,以减轻运行时开销。这在高频更新场景下效果尤为显著。

Diff算法

算法目标

找出新旧虚拟 DOM 的差异,并以最小代价更新真实 DOM。

基本策略

  • 只比较同级节点,不跨层级移动元素。

<!-- 之前 -->
<div>           <!-- 层级1 -->
  <p>            <!-- 层级2 -->
    <b> aoy </b>   <!-- 层级3 -->   
    <span>diff</span>
  </p> 
</div>

<!-- 之后 -->
<div>            <!-- 层级1 -->
  <p>             <!-- 层级2 -->
      <b> aoy </b>        <!-- 层级3 -->
  </p>
  <span>diff</span>
</div>

由于 Diff 算法只在同层级比较节点,上例中新增的 <span> 在层级 2,而原有 <span> 在层级 3,因此无法直接复用。框架只能删除旧节点并在层级 2 重新创建 <span>。这也导致了预期移动操作无法实现。

  • 使用 Key 标识可复用节点,提高节点匹配准确性。

例如,对于元素序列 a、b、c、d、e(互不相同),若未设置 key,更新时元素 b 会被视为新节点而被重新创建,旧的 b 节点会被删除。

若给每个元素指定唯一 key,则可正确识别并复用对应节点,如下图所示。

  • 当新旧节点类型不同(如标签名不同)时,框架会直接替换整个节点,而非尝试复用。

Diff 算法的演进

简单 Diff 算法

核心逻辑: 对新节点逐一在线性遍历的旧节点中查找可复用节点(sameVNode),找到则 patch,找不到则创建新节点。遍历完成后,旧节点中未被复用的节点将被删除。

缺点: 实现简单但不是最优,对于节点移动操作效率较低,最坏情况时间复杂度为 O(n²)

function simpleDiff(oldChildren, newChildren) {
  let lastIndex = 0
  for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i]
    let find = false
    for (let j = 0; j < oldChildren.length; j++) {
      const oldVNode = oldChildren[j]
      if (sameVNode(oldVNode, newVNode)) {
        find = true
        patch(oldVNode, newVNode) // 更新节点
        if (j < lastIndex) {
          // 需要移动节点
          const anchor = oldChildren[j+1]?.el
          insertBefore(parentEl, newVNode.el, anchor)
        } else {
          lastIndex = j
        }
        break
      }
    }
    if (!find) {
      // 新增节点
      const anchor = oldChildren[i]?.el
      createEl(newVNode, parentEl, anchor)
    }
  }
  // 删除旧节点...
}

举个例子:

双端 Diff 算法

在简单 Diff 基础上使用四个指针同时跟踪旧/新列表的头尾(oldStartVnodeoldEndVnodenewStartVnodenewEndVnode),从头尾进行四种快速比较:头-头、尾-尾、旧头-新尾、旧尾-新头。若匹配则执行更新,否则退回线性查找或插入操作。优点:对常见的“头部插入、尾部删除”场景非常高效;缺点:若中间区域节点顺序混乱,仍需遍历查找,可能导致较多 DOM 操作。平均时间复杂度 O(n)

function diff(oldChildren, newChildren) {
  let oldStartIdx = 0
  let oldEndIdx = oldChildren.length - 1
  let newStartIdx = 0
  let newEndIdx = newChildren.length - 1
  
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 四种情况比较
    if (sameVNode(oldChildren[oldStartIdx], newChildren[newStartIdx])) {
      // 情况1:头头相同
      patch(...)
      oldStartIdx++
      newStartIdx++
    } else if (sameVNode(oldChildren[oldEndIdx], newChildren[newEndIdx])) {
      // 情况2:尾尾相同
      patch(...)
      oldEndIdx--
      newEndIdx--
    } else if (sameVNode(oldChildren[oldStartIdx], newChildren[newEndIdx])) {
      // 情况3:旧头新尾
      insertBefore(parentEl, oldStartVNode.el, oldEndVNode.el.nextSibling)
      oldStartIdx++
      newEndIdx--
    } else if (sameVNode(oldChildren[oldEndIdx], newChildren[newStartIdx])) {
      // 情况4:旧尾新头
      insertBefore(parentEl, oldEndVNode.el, oldStartVNode.el)
      oldEndIdx--
      newStartIdx++
    } else {
      // 查找可复用节点...
    }
  }
  // 处理剩余节点...
}

举例:若发现 oldEndVnodenewStartVnode 是同一节点(sameVnode),则说明原列表的尾部节点在新列表中移到了开头。执行 patchVnode 时,会将对应的真实 DOM 节点移动到新列表的开始位置。

快速 Diff 算法

核心思路:

  1. 剥离公共前缀/后缀(prefix/suffix),把问题缩减到中间区。
  2. 为新中间区建立 key 映射,生成旧中间区到新索引的映射数组,同时对可复用节点执行 patch
  3. 对映射数组求 最长递增子序列(LIS),LIS 对应节点保持相对顺序,无需移动。
  4. 从右向左遍历新列表,若当前位置属于 LIS,跳过;否则将节点移动到正确位置或创建新节点。

通过 LIS 标识“一组相对顺序正确”的节点,只移动剩余节点,快速 Diff 在减少 DOM 移动次数方面显著优化了算法。但它需要额外的映射表和辅助数组开销。

快速 Diff 算法的优势在于在中间区大量移动/重排时能显著减少 DOM 移动次数与总时间,但是需要额外内存(映射、mapped、LIS 辅助数组)。整体时间复杂度为 O(nlogn)

function quickDiff(oldChildren, newChildren) {
  // 1. 处理前缀
  let i = 0
  while (i <= oldEnd && i <= newEnd && sameVNode(old[i], new[i])) {
    patch(...)
    i++
  }

  // 2. 处理后缀
  let oldEnd = oldChildren.length - 1
  let newEnd = newChildren.length - 1
  while (oldEnd >= i && newEnd >= i && sameVNode(old[oldEnd], new[newEnd])) {
    patch(...)
    oldEnd--
    newEnd--
  }

  // 3. 处理新增/删除
  if (i > oldEnd && i <= newEnd) {
    // 新增节点...
  } else if (i > newEnd) {
    // 删除节点...
  } else {
    // 4. 复杂情况处理
    const keyIndex = {} // 新节点key映射
    for (let j = i; j <= newEnd; j++) {
      keyIndex[newChildren[j].key] = j
    }

    // 找出最长递增子序列
    const lis = findLIS(...)
    
    // 移动/更新节点
    let lisPtr = lis.length - 1
    for (let j = newEnd; j >= i; j--) {
      if (lis[lisPtr] === j) {
        lisPtr--
      } else {
        // 需要移动节点
        insertBefore(...)
      }
    }
  }
}

VDOM 的挑战

  • 运行时开销: 每次状态更新都要重新构建 VDOM 树并进行 Diff,再更新真实 DOM。在高频小更新场景(如动画帧、复杂列表渲染)下,这些计算开销可能会超过直接操作 DOM 的成本。
  • 渲染冗余: 框架通常通过 shouldComponentUpdatememov-if 等手段减少不必要的更新,但这些本质上是人工干预。组件依赖复杂时,仍可能发生级联更新和不必要的 Diff。
  • 生态割裂: 不同框架的 VDOM 实现和优化策略差异较大,开发者需为不同生态编写特定优化代码,增加了学习和维护成本。
  • 设备压力: 在中低端设备或 WebView 场景,VDOM diff 的 CPU 开销显著,容易成为性能瓶颈。

基于以上原因,近年来出现了多种无虚拟 DOM 解决方案,将更多工作提前到编译时或采用细粒度响应式,以降低运行时成本。

无 VDOM 解决方案

无虚拟 DOM 的核心目标是:在编译期生成精确的 DOM 操作,或者将数据响应切分到最小单元,从而避免常规的 VDOM diff。主要技术路线有:

三条主流技术路线

Svelte(编译期生成精确 DOM 操作)

Svelte 在构建阶段将组件模板编译成直接操作 DOM 的 JavaScript 代码,运行时不再创建 VNode 或进行 Diff。编译器静态分析模板,决定哪些节点是静态,哪些依赖于变量,从而生成最小更新路径。

优点: 运行时开销极低、内存分配少、GC 压力低,首屏和交互延迟很低,适合移动端和首屏优化场景。

缺点: 编译器实现复杂,开发调试时依赖高质量 source map;对于运行时高度动态(如动态生成组件)的场景,需要额外方案支持。

Solid(细粒度响应式)

Solid 使用类似信号(signal)机制,将组件内部表达式拆分为最小依赖单元。数据变化只触发与之直接相关的更新回调,这些回调直接操作 DOM。

优点: 更新几乎零延迟,避免整组件或整树的重新渲染,非常适合高频小更新场景(如实时图表仪表盘)。

缺点: 编程模型与传统 VDOM 框架不同,需要理解信号粒度和副作用清理;在大型项目中需要特别注意内存管理和副作用回收。

Qwik(按需恢复的应用)

Qwik 将应用的状态尽量序列化(或在服务端预渲染时生成可恢复信息),客户端仅在需要时“唤醒”对应组件(按需 hydration)。它推迟或避免了不必要的运行时代价。

优点: 首次加载脚本体积小、交互延迟低,非常适合大页面或低算力设备。

缺点: 需要复杂的序列化/恢复机制,对路由和事件绑定有严格要求,迁移成本较高。

此外,以 Vue 为例的 Vapor/opt-in 编译模式 实际上把 Vue 的模板编译成“直达 DOM 的更新指令”,属于编译期优化思路的一种变体:保留 Vue 的语法与生态,同时在性能关键路径上逼近无 VDOM 性能。

性能比较

  • 内存与 GC:无 VDOM 的运行时分配显著下降(少量短期对象),GC 停顿减少;但编译产物体积可能会略增(生成更多特定更新函数)。
  • CPU 时间:高频更新场景中,无 VDOM 通常显著胜出,因为省去了每帧的树构造与 diff 运算。对于低更新频率的普通页面,差异不明显。
  • 开发与调试体验:调试“直接操作 DOM”生成代码有时不如调试抽象语义直观,因此优秀的 source map 与开发工具对这些框架尤为重要。

总结

VDOM 是一个强大的工程抽象,它把浏览器渲染复杂性封装为可预测的模型,推动了跨平台与组件化生态的发展。 但 VDOM 有真实的成本:对象分配、Diff 计算与可能的 GC 停顿,在高频更新或受限环境会成为瓶颈。 无 VDOM 方案并非魔法,而是通过编译期与细粒度响应式把运行时成本下降到“更接近命令式最优”的路径,适用于性能关键场景。

Bipes项目二次开发/设置功能-1(五)

2025年11月28日 17:02

Bipes项目二次开发/设置功能-1(五)

设置功能,这一期改动有点多,可能后期也会继续出设置功能-n文章,这一期是编程模式,那做目的有两个: 1,代码设计 现在确定做的模式有三种,硬件编程,离线编程,海龟编程三种。每种模式所涉及的代码不同,所以得划分出来。

2,可配置性 后期可能会出一些定制开发,界面就可以通过配置,进行界面调整。

编程模式

html

页面初始内容

<div class="settings-preview">
    <div id="settings-modal">
      <h3>设置</h3>
      <div class="settings-group">
        <label>模式选择</label>
        <div class="radio-group">
          <div class="radio-option">
            <input type="radio" id="mode-hardware" name="programMode" value="hardware" checked>
            <span>硬件编程</span>
          </div>
          <div class="radio-option">
            <input type="radio" id="mode-offline" name="programMode" value="offline">
            <span>离线编程</span>
          </div>
          <div class="radio-option">
            <input type="radio" id="mode-turtle" name="programMode" value="turtle">
            <span>海龟编程</span>
          </div>
        </div>
      </div>
      <div class="modal-actions">
        <button id="cancel-settings" class="btn btn-secondary">取消</button>
        <button id="save-settings" class="btn btn-primary">保存</button>
      </div>
    </div>
  </div>

js

import Common from "./common";

export default class SettingPreview extends Common {
    constructor() {
        super()
        this.state = false // 设置弹窗是否显示
    }
    initEvent() {
        $('#settingsButton').on('click', () => {
            this.changeSetting(!this.state)
        })
        
        // 添加取消按钮事件监听
        $('#cancel-settings').on('click', () => {
            this.changeSetting(false)
        })
        
        // 添加确认按钮事件监听
        $('#save-settings').on('click', this.saveSettings.bind(this))
    }
    changeSetting(state) {
        let status = state ? 'on' : 'off'
        $('.settings-preview').css('visibility', (state ? 'visible' : 'hidden'))
        $('#settingsButton').css('background', `url(../media/new-icon/setting-${status}.png) center / cover`)
        
        if (state) {
            // 显示时可以加载已保存的设置
            this.loadSettings();
        }

        this.state = state
    }
    
    // 保存设置到本地缓存
    saveSettings() {
        // 获取选中的模式
        let selectedMode = 'hardware'; // 默认值
        const selectedRadio = $('input[name="programMode"]:checked');
        if (selectedRadio.length > 0) {
            selectedMode = selectedRadio.val();
        }
        
        // 创建设置对象并保存到本地缓存
        const settings = {
            mode: selectedMode
        };
        
        try {
            localStorage.setItem('settings', JSON.stringify(settings));
            console.log('设置已保存:', settings);
        } catch (error) {
            console.error('保存设置失败:', error);
        }

        this.changeSetting(false)
    }
    
    // 从本地缓存加载设置
    loadSettings() {
        try {
            const savedSettings = localStorage.getItem('settings');
            if (savedSettings) {
                const settings = JSON.parse(savedSettings);
                if (settings.mode) {
                    // 设置选中的单选按钮
                    $(`input[name="programMode"][value="${settings.mode}"]`).prop('checked', true);
                }
            }
        } catch (error) {
            console.error('加载设置失败:', error);
        }
    }
}

界面效果

在这里插入图片描述

总结

出这一期主要针对编程模式,不同模式下做不同功能。 硬件编程:保留原有功能,通过连接板子,与板子通信,在板子上运行编写好的代码,做出不同效果 离线编程:学习,了解编程 海龟编程:学习,了解编程,让编程变得不枯燥。

前端高手进阶:从十万到千万,我的性能优化终极指南(实战篇)

作者 ssjlincgavw
2025年11月28日 16:55

性能优化,一个老生常谈却又常谈常新的话题。它不仅是技术的体现,更是一种用户体验至上的产品思维。很多初学者觉得性能优化就是“减少HTTP请求”、“压缩图片”,但这只是冰山一角。今天,我们就来掀开这座冰山,从宏观到微观,构建一套完整的性能优化体系。

一、 核心指标:我们到底在优化什么?

在开始之前,我们必须明确目标。现代前端性能的核心是 Web Vitals,这是谷歌提出的一套关键性能指标:

  1. LCP:最大内容绘制

    • 目标:  < 2.5秒
    • 意义:  衡量页面的主要内容加载速度。慢?用户会觉得“这个网站卡住了”。
  2. FID:首次输入延迟

    • 目标:  < 100毫秒
    • 意义:  衡量页面的可交互性。点击一个按钮没反应?这就是FID太差。
  3. CLS:累积布局偏移

    • 目标:  < 0.1
    • 意义:  衡量页面的视觉稳定性。突然弹出的图片或广告把阅读中的按钮挤走了?这就是CLS问题。

我们的所有优化,都应该围绕着提升这几项核心指标展开。

二、 网络层优化:速度的“第一公里”

80%的性能问题出在网络层面。

1. 拥抱现代构建工具:Vite > Webpack
为什么你的 npm run dev 要等半天?为什么热更新那么慢?是时候了解一下 Vite 了。它利用浏览器原生 ES 模块,实现了闪电般的冷启动和瞬间热更新。对于大型项目,开发体验的提升是颠覆性的。

javascript

// 你的 vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
  build: {
    // 构建产物更小,加载更快
    minify: 'esbuild',
    // 代码分割策略
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom'],
          utils: ['lodash-es', 'dayjs']
        }
      }
    }
  }
})

2. 极致的资源压缩与分发

  • 图片优化是重灾区:

    • 使用现代的 WebP/AVIF 格式,体积比 PNG/JPG 小 30%-70%。
    • 使用 sharp 库在构建时自动压缩图片。
    • 实现响应式图片:<picture> 元素和 srcset 属性,为不同设备提供最合适的图片尺寸。
  • 开启 Gzip/Brotli 压缩:  在服务器(如 Nginx)上开启 Brotli 压缩,压缩率比 Gzip 更高。

  • 利用 HTTP/2 和 CDN:  HTTP/2 的多路复用彻底解决了 HTTP/1.1 的队头阻塞问题。配合全球分布的 CDN,让用户从最近的节点获取资源。

三、 渲染层优化:让每一帧都丝滑

网络资源加载完了,页面为什么还是卡?

1. 代码分割与懒加载
不要把所有代码都打包到一个 bundle.js 里!使用 React.lazy 和 Suspense 实现路由级和组件级的懒加载。

javascript

// 传统方式:首屏就加载所有组件
// import HeavyComponent from './HeavyComponent';

// 优化后:按需加载
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

2. 关键 CSS 与防布局偏移

  • 关键 CSS:  将首屏渲染所必须的 CSS 样式内联到 <head> 中,避免因等待 CSS 文件加载而导致的页面白屏(FOUC)。

  • 防布局偏移:

    • 为图片、视频等元素明确设置 width 和 height 属性。
    • 为广告、嵌入内容预留好空间。
    • 使用 aspect-ratio CSS 属性来维持元素的宽高比。

3. 虚拟列表:海量数据渲染的救星
当你需要渲染成百上千条列表数据时(如表格、聊天记录),直接渲染会导致 DOM 节点过多,页面直接卡死。虚拟列表 技术只渲染可视区域内的元素,性能提升立竿见影。可以使用 react-window 或 vue-virtual-scroller 等库轻松实现。

四、 JavaScript 执行效率:告别卡顿

1. 防抖与节流
滚动、搜索、窗口缩放等高频触发的事件,必须使用防抖或节流。

javascript

// 搜索框防抖
function debounce(func, wait) {
  let timeout;
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout);
      func(...args);
    };
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
  };
}

const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce(function(e) {
  // 发起搜索请求
}, 300));

2. 使用 Web Workers 处理重计算
像图像处理、复杂数据排序、语法高亮等CPU密集型任务,会阻塞主线程,导致页面无法响应。把它们丢给 Web Worker 在后台线程执行,解放主线程。

3. 性能监控与持续优化
优化不是一劳永逸的。在生产环境部署性能监控:

  • 使用 PerformanceObserver API 来监听 Web Vitals 指标。
  • 使用 Chrome DevTools 的 Performance 面板 录制并分析运行时性能,找到长任务和性能瓶颈。

总结与展望

性能优化是一条没有尽头的路,它要求我们不仅懂 API,更要懂浏览器原理、网络协议和用户体验。总结一下本文的核心路径:

构建 → 压缩 → 缓存 → 分割 → 懒加载 → 高效渲染 → 持续监控

将这七个步骤融入到你的日常开发习惯中,你就能从一个“功能实现者”蜕变为“体验打造者”。

前端控制批量请求并发

作者 Nayana
2025年11月28日 16:35

最近准备做大文件上传的业务,就联想到前段时间很火的面试题🤦‍♂️ 假如有100条请求,前端如何控制并发。因此在网上看了一些优化相关性能的方案,简单记录一下

不做并发

async upload(Chunks) {
let queue = [...chunks];
 while (i<queue.length) {
       //此方法是自定义的上传异步方法不赘述
let uploadRes = await this.uploadFetch(queue[i])
        if(uploadRes){
        console.log('成功')
        }else{
        // 可写异常 再重试
        //重试优化先不写  queue.push(queue[i])
        }
        i++;
     }
  },

局限

  • 资源利用率差,对于文件片段多的时候全部接口成功等待长时间。

promnise.all分组并发

将请求数组均分为多个小数组,每次最多只开启concurrency个并发请求,以此来控制并发数量。每当一组请求完成后再发送新的一批请求,可以实现对异步任务的并发控制。

        //concurrency 参数为并发数量
      **浏览器并发限制是指浏览器对同一域名同时发起的HTTP请求数量的上限**‌,通常限制在6-8个之间 **
        //Chrome/Firefox:默认6个 假如预留一个空闲位的话concurrency设置为5 
async concurrencyUpload(chunks,concurrency) {
let queue = [...chunks];
while (queue.length > 0) {
const currentChunks = queue.splice(0, concurrency);
 await Promise.all(
      currentChunks.map(async chunk => {
      try {
                //此方法是自定义的上传异步方法
await this.uploadFetch(chunk);
} catch (err) {
// 重试:将分片重新加入队列
                //可优化重试逻辑 控制单任务重试次数
// queue.push(chunk);
}
})
).catch(err => console.error('出错:', err));
}
}

局限

  • 当前批次请求全部完成后才会继续下一批次,可能存在空闲位。
  • 可能返回无序的结果。
  • 有一个请求失败了,这个 Promise.all就失败了,没有返回值。

使用队列

另一种方式是使用一个队列来手动管理并发。你可以创建一个函数来管理并发请求的发送。

class RequestQueue {
  constructor(maxConcurrent) {
    this.maxConcurrent = maxConcurrent;
    this.queue = [];
    this.running = 0;
  }

  enqueue(promiseFn) {
      this.queue.push(promiseFn);
      this.processQueue();
    });
  }

  processQueue() {
    while (this.running < this.maxConcurrent && this.queue.length > 0) {
      const  promiseFn = this.queue.shift();
      this.running++;
      promiseFn()
        .then(()=>{
        //console.log('单任务返回成功')
        })
        .catch(()=>{
        // 重试:将上传任务重新加入队列
         //可优化重试逻辑 控制单任务重试次数
        this.queue.push(promiseFn)
        })
        .finally(() => {
          this.running--;
          this.processQueue(); // Process the next item in the queue
        });
    }
  }
}

// 使用示例
const queue = new RequestQueue(5); // 最大并发5个请求

// 请求函数 uploadFetch 已经是一个promise方法
chunks.forEach(chunk=>{queue.enqueue(this.uploadFetch(chunk))})

用第三方库 p-limit

安装依赖
npm install p-limit
js实现
import pLimit form 'p-limit';

const limit = pLimit(2); // 限制并发数为2
const results = await Promise.all(chunks.map(chunk => limit(() => this.uploadFetch(chunk))));

Astro 项目升级全栈:EdgeOne Pages 部署指南

2025年11月28日 16:17

背景介绍

最近用腾讯云的 EdgeOne Pages (下文称 Pages)部署了个人站点,记录一下从 SSG 升级到 SSR 的过程。Pages 是腾讯云推出的网站托管服务,支持 SSR 和边缘函数,国内访问稳定性相比而言会更好一点。Astro 是一个"内容优先"的现代 Web 框架,特别适合博客、文档等内容型网站,默认输出零 JavaScript 的静态 HTML,同时也支持 SSR 模式。我的个人博客就是使用的 Pages 进行托管,Pages 之前只支持 SSG 模式,对初期来说我的站点完全够用了,但随着网站内容增多、访问量上升,我希望通过 SSR 获得更好的 SEO 效果。刚好最近看到 Pages 支持了 Astro 的 SSR 模式,正好借此机会升级并记录实践过程。

基本信息

示例项目

本文使用 demo-portfolio 项目作为示例,一个基于 Astro 的个人作品集网站。项目 fork 自原作者仓库,我对其进行了 Astro 版本升级和 bug 修复,以更好地适配 Pages 的 SSR 模式(欢迎给原作者 star)。

Astro 适配器

适配器(Adapter)是 Astro 用于适配不同部署平台的插件,负责将项目转换为目标平台所需的格式。Pages 提供了官方适配器 @edgeone/astro,支持在边缘计算环境中运行 SSR 模式。

Pages 脚手架

Pages 提供命令行工具,通过 npm install edgeone -g 安装,支持项目初始化、本地调试和一键部署。

实践过程

项目准备

首先从 GitHub 下载示例项目到本地:

git clone https://github.com/nuonuo-888/portfolio-sofidev-garrux
cd portfolio-sofidev-garrux
npm install

项目配置

原始 SSG 模式

项目的核心配置文件是 astro.config.mjs,使用 SSG 模式时的配置如下:

import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://examples.com/", // 网站的部署URL,用于生成sitemap和RSS
  output: "static", // 输出模式:static表示静态站点生成(SSG)
  integrations: [react(), tailwind(), sitemap()], // 集成插件:React组件支持、Tailwind CSS、站点地图生成
});

使用 npm run build 构建后,会在 dist/ 目录生成以下结构:

dist/
├── _astro/              # Astro 构建生成的资源文件
├── 404.html            # 404 错误页面
├── about/              # 关于页面
├── assets/             # 静态资源文件(SVG 图标)
├── blog/               # 博客文章目录
├── docs/               # 文档文件
├── img/                # 图片资源
├── index.html          # 首页
├── favicon.svg         # 网站图标
├── sitemap-0.xml       # 站点地图文件
└── sitemap-index.xml   # 站点地图索引

这些文件可以直接部署到任何静态托管服务(如 CDN、对象存储),无需服务器运行时支持。

升级为 SSR 模式

现在我们将项目从 SSG 模式升级为 SSR 模式。首先需要安装 Pages 的适配器:

npm install @edgeone/astro

然后修改 astro.config.mjs 配置文件:

import { defineConfig } from "astro/config";
import edgeone from "@edgeone/astro"; // 引入EdgeOne适配器
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://examples.com/",
  output: "server", // 从 'static' 改为 'server' 启用SSR
  adapter: edgeone(), // 配置EdgeOne适配器
  integrations: [react(), tailwind(), sitemap()],
});

升级为 SSR 模式需要修改两个配置:将 output'static' 改为 'server' 来启用服务端渲染,以及添加 adapter: edgeone() 来配置 EdgeOne 适配器。

配置完成后,执行构建命令:

npm run build

构建完成后会在 .edgeone/ 目录生成以下结构:

.edgeone/
├── assets/                    # 静态资源文件
└── server-handler/            # 服务器端处理文件

其中 assets/ 目录包含所有静态文件(CSS、JS、图片等),server-handler/ 目录包含服务端渲染代码,用于在 Pages 边缘节点上动态处理请求和渲染页面。

构建产物之所以生成到 .edgeone/ 目录,是因为适配器的作用就是将 Astro 的构建结果转换为目标部署平台所需的格式和目录结构。不同平台的适配器会生成对应的目录,例如 @astrojs/vercel 适配器会生成 .vercel/ 目录,@astrojs/netlify 适配器会生成 .netlify/ 目录,这样各平台的部署工具就能识别并正确部署这些构建产物。

适配器参数配置

@edgeone/astro 适配器支持传入以下参数来自定义构建行为:

  • includeFiles(可选):强制包含的文件列表,支持 glob 模式匹配
  • excludeFiles(可选):排除的文件列表,主要用于排除 node_modules 中的特定文件,支持 glob 模式匹配

配置示例:

import edgeone from "@edgeone/astro";

export default defineConfig({
  adapter: edgeone({
    outDir: ".edgeone",
    includeFiles: ["src/locales/**", "public/config.json"],
    excludeFiles: ["node_modules/.cache/**"],
  }),
});

注意:根据 Pages 官方文档 说明,当前版本暂不支持 Astro 的 Image 组件,请使用常规的 <img> 标签来显示图片。

本地验证

在部署前,建议先在本地运行项目确保一切正常:

npm run dev

启动成功后,访问 http://localhost:4321 即可预览网站效果:

首页预览

确认本地运行无误后,就可以进行部署了。

部署步骤

Pages 提供两种部署方式:

方式一:命令行部署(推荐)

首先全局安装命令行工具:

npm install edgeone -g

在项目根目录执行部署命令:

edgeone pages deploy --name my-personal-website --token <your-token>

参数说明:

  • --name:指定项目名称
  • --token:EdgeOne API Token,用于身份验证(可在 Pages API 管理页面 创建)

部署中的输出信息:

[cli][✔] Using provided API token for deployment...
[cli][✔] Deploying /Users/your-mac-account-name/*/portfolio-sofidev-garrux/.edgeone to project my-personal-website (Production environment, global area)...
[cli]❗️ Project my-personal-website doesn't exist. Creating new project.
[cli][CreatePagesProject] Creating new project with name: my-personal-website in global area
[cli][✔] Using Project ID: pages-******
[cli]No existing .env file found, will create new one
[cli]Pulling environment variables...
[cli]No environment variables found.
[cli][Uploader] Uploading file: [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
[cli][✔] File uploaded successfully
[cli][✔] Creating deployment in Production environment...
[cli][✔] Created deployment with Deployment ID: ******
[cli][DeployStatus] Deploying.....

部署成功后的输出信息:

[cli][✔] Deploy Success
[cli][✔] Deployment to EdgeOne Pages completed successfully! To view your project's live URL.
EDGEONE_DEPLOY_URL=https://my-personal-website.edgeone.site?eo_token=******
EDGEONE_DEPLOY_TYPE=preset
EDGEONE_PROJECT_ID=pages-******
[cli][✔] You can view your deployment in the EdgeOne Pages Console at:
https://console.cloud.tencent.com/edgeone/pages/project/*****

部署成功后,你可以访问 Pages 控制台 查看新部署的项目,看到项目列表中出现你的项目即表示发布成功。

在控制台的项目列表中,点击进入你的项目详情页,可以查看项目的预览地址(Preview URL),通过该地址即可访问你部署的网站。

方式二:GitHub 自动部署

访问 Git 项目部署页面,关联 GitHub 账户并选择仓库,配置完成后每次推送代码即可自动触发部署,类似 Vercel、Netlify 的工作流程。

验证 SSR 部署

部署完成后,你可以通过 curl 命令在终端验证 SSR 是否正常工作:

curl https://my-personal-website.edgeone.site

如果返回完整的 HTML 内容,说明服务端渲染已成功运行。

通过中间件验证 SSR

为了更直观地验证 SSR 是否正常工作,你可以在项目中添加 Astro 中间件,在响应头中加入自定义信息和服务端渲染时间。

在项目根目录创建 src/middleware.js 文件:

export function onRequest(context, next) {
  // 记录请求开始时间
  const startTime = Date.now();

  // 继续处理请求
  const response = next();

  // 在响应头中添加自定义信息
  response.headers.set("X-Rendered-By", "EdgeOne-Pages-SSR");
  response.headers.set("X-Render-Time", `${Date.now() - startTime}ms`);

  return response;
}

重新构建并部署项目后,使用 curl 命令查看响应头:

curl -I https://my-personal-website.edgeone.site

你会在响应头中看到:

X-Rendered-By: EdgeOne-Pages-SSR
X-Render-Time: 15ms

这些自定义响应头证明了页面是在服务端动态渲染的,X-Render-Time 显示了服务端渲染所花费的时间。

总结

以上就是使用 Pages 部署 Astro SSR 项目的全过程。从 SSG 升级到 SSR 的步骤比较简单:安装适配器、修改配置文件、重新构建部署。SSR 模式相比 SSG 在 SEO 和首屏加载上会有一些优势,适合内容型网站。

希望这篇文章能对同样想要将 Astro 项目部署到 Pages 的开发者提供参考,如果遇到问题欢迎在评论区交流。

❌
❌