阅读视图

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

使用 openapi-typescript-codegen 自动生成 openapi 接口文档的 TypeScript 类型

安装依赖

npm i openapi-typescript-codegen -D

配置生成脚本

这里配置了只生成接口数据类型的定义,需要生成每个 API 的请求封装需要修改 参数

"generate-api": "npx openapi-typescript-codegen --input http://xxx/v2/api-docs --output ./src/api/generated --exportCore false --exportServices false",

脚本中的 http://xxx/v2/api-docs 换成你接口文档的 JSON 数据源地址,可以在浏览器控制台找到

image.png

生成结果

执行 npm run generate-api 后就能看到一堆类型定义

image.png

使用

然后就可以愉快的使用自动生成的类型定义了

image.png

手摸手带你封装Vue组件库(17)Loading加载组件

loading 我们一般用于页面等待,或者某个模块需要加载的时候,我们一般会使用 loading 组件。

loading 组件的展示一般分为两种,一种是全局 loading,一种是局部 loading。这两种使用也不一样,全局 loading 我们使用方法调用的方式来使用,局部 loading 我们使用指令调用的方式来使用。

创建如下的结构。

project-20250516-1.png

自定义 v-loading 指令

先思考一下,指令不是组件,所以只要用户引入并 use 了组件库,则直接生效,所以我们需要在组件的入口文件 index.js 中注册指令。

/packages/components/index.js

import * as components from "./components";
import "@test-ui/theme-chalk/index.less";

const FUNCTION_COMP = ["TMessage"]; // 方法调用类组件
const DIRECTIVE_COMP = ["TLoading"]; // 指令类组件

export default {
  install(app) {
    Object.entries(components).forEach(([key, value]) => {
      if (!FUNCTION_COMP.includes(key)) app.component(key, value);
      if (DIRECTIVE_COMP.includes(key)) app.use(value);
    });
  },
};

export const TMessage = components.TMessage;
export const TLoading = components.TLoading;

为什么是 use,而不是直接 app.directive,然后组件里面直接写自定义指令不就完了,因为我们还有全局的 loading,全聚德 loading 需要调用方法,所以我们在 loading 的组件内部再写 app.directive

我们这下来写一下 loading 组件入口文件,因为能被 use,所以是抛出的是一个对象,且携带有 install 方法。

import vLoading from "./src/directive.js";

export const TLoading = {
  install(app) {
    app.directive("loading", vLoading);
  },·
  // 后续要写全局的方法在这
};

export default TLoading;

loading/src/directive.js

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
  },
};

export default vLoading;

我们画一个简单的 loading,使用 svg 的动画来实现。

loading/src/loading.vue

<template>
  <div class="'t-loading'">
    <div class="t-loading__spinner">
      <div class="t-loading__spinner-icon">
        <svg width="60" height="30" viewBox="0 0 100 50">
          <circle cx="25" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="1;0.3;1"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
          <circle cx="50" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="0.3;1;0.3"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
          <circle cx="75" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="0.3;1;0.3"
              begin="0.5s"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
        </svg>
      </div>
      <div class="t-loading__text">加载中</div>
    </div>
  </div>
</template>

<script setup></script>

loading.less

.t-loading {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: rgba(255, 255, 255, 0.8);
  z-index: 1001;
  .t-loading__spinner {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    font-size: 14px;
    color: var(--t-primary);
    text-align: center;
  }
}

我们需要在自定义指令中 mounted 的时候,将 loading 组件挂载到当前元素上,所以我们需要在 directive.js 中引入 loading.vue 组件,然后通过 h 函数生成 vnode,然后通过 render 方法将组件挂载到当前元素上。

loading/src/directive.js

import TLoadingComponent from "./loading.vue";
import { h, render } from "vue";

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent);
  render(vnode, el);
};

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
    if (value) createLoading(el);
  },
};

export default vLoading;

我们来写一个示例看一下

<t-table
  :column-data="columnData"
  :table-data="tableData"
  border
  v-loading="true"
/>

project-20250516-2.png

看起来没问题,但是我们一般情况下给传入一个布尔值,当这个值为 true 的时候才显示,false 的时候消失,这时候我们需要根据传入值的变化来控制显示和隐藏,这时候我们在 v-loading 指令中写一个 update 方法,并且当当前组件销毁的时候我们也需要将 loading 组件销毁。

import TLoadingComponent from "./loading.vue";
import { h, render } from "vue";

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent);
  render(vnode, el);
};

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
    if (value) createLoading(el);
  },
  updated(el, binding) {
    if (!binding.value && binding.value !== binding.oldValue) {
      el.removeChild(el.querySelector(".t-loading"));
    } else if (binding.value && binding.value !== binding.oldValue) {
      createLoading(el);
    }
  },
  unmounted(el) {
    el.removeChild(el.querySelector(".t-loading"));
  },
};

export default vLoading;

我们只要发现绑定的值第一次生成的时候是正常的,然后变为 falseloading 消失,然后重新改变为 true 的时候组件不会重新生成了或者说新生成的组件没有在界面渲染,是因为什么呢?因为仅使用 removeChild 移除 DOM 节点不会触发 Vue 的生命周期钩子,导致组件实例仍然存在并保持对 DOM 的引用。这时候怎么处理呢?

import TLoadingComponent from "./loading.vue";
import { h, render } from "vue";

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent);
  render(vnode, el);
};

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
    if (value) createLoading(el);
  },
  updated(el, binding) {
    if (!binding.value && binding.value !== binding.oldValue) {
      el.removeChild(el.querySelector(".t-loading"));
      render(null, el);
    } else if (binding.value && binding.value !== binding.oldValue) {
      createLoading(el);
    }
  },
  unmounted(el) {
    el.removeChild(el.querySelector(".t-loading"));
    render(null, el);
  },
};

export default vLoading;

这时候试一下呢?是不是正常了,你会发现我们添加了一个 render(null, el),这个作用就是将上次的 loading 组件销毁。

补充属性

我们一般情况下需要自定义加载内容,以及加载的背景色,这时候怎么怎么传递呢?我们看一下 element-plus,打开 F12,你会发现他的属性实际是在 DOM 节点上插入的自定义属性,但是是非标准的自定义属性,因为自定义属性是必须 data- 开头,获取可以直接通过 DOM.dateset.[属性名]来获取,那这种非标准的怎么获取属性值呢?我们可以通过 getAttribute(属性名) 来获取,这下知道怎么做也简单了。

<t-table
  :column-data="columnData"
  :table-data="tableData"
  border
  v-loading="loading"
  loading-text="等待中"
  loading-background="rgba(122, 122, 122, 0.6)"
/>

我们在 createLoading 的方法中获取一下属性值,然后传递给组件

packages/loading/src/directive.js

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent, {
    text: el.getAttribute("loading-text"),
    background: el.getAttribute("loading-background"),
  });
  render(vnode, el);
};

然后我们在 loading 组件内部获取一下组件

<template>
  <div
    class="'t-loading'"
    :style="{
      'background-color': background,
    }"
  >
    <div class="t-loading__spinner">
      <div class="t-loading__spinner-icon">
        <svg width="60" height="30" viewBox="0 0 100 50">
          <!-- ... -->
        </svg>
      </div>
      <div class="t-loading__text" v-if="text">{{ text }}</div>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  text: {
    type: String,
  },
  background: {
    type: String,
  },
});
</script>

project-20250516-3.png

全屏加载

全屏加载我们可以使用调用方法来生成组件,我们可以抛出去一个方法,使用者可以使用这个方法来生成一个 loading 组件,然后这个方法返回一个操作 loading 的一个对象,这个对象包含关闭当前 loading 的方法,element-plus 是引入 ElMessage,然后通过 ElLoading.service() 传入一个 loading 的配置对象来显示组件,那我们可以在 loading 的入口文件中 export 一个 service 方法就行就行,然后 service 方法返回一个对象,包含关闭的方法。

import TLoadingComponent from "./loading.vue";
import { h, render } from "vue";

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent, {
    text: el.getAttribute("loading-text"),
    background: el.getAttribute("loading-background"),
  });
  render(vnode, el);
};

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
    if (value) createLoading(el);
  },
  updated(el, binding) {
    if (!binding.value && binding.value !== binding.oldValue) {
      el.removeChild(el.querySelector(".t-loading"));
      render(null, el);
    } else if (binding.value && binding.value !== binding.oldValue) {
      createLoading(el);
    }
  },
  unmounted(el) {
    el.removeChild(el.querySelector(".t-loading"));
    render(null, el);
  },
};

export const createGlobalLoading = ({ text, background }) => {
  let vnode = h(TLoadingComponent, {
    text,
    loadingBackground: background,
    screen: true, // 是否全屏
  });
  window.document.body.classList.add("t-loading-screen-parent");
  render(vnode, window.document.body);
  return {
    close() {
      window.document.body.removeChild(vnode.el);
      render(null, window.document.body); // 该代码作用是清除vnode
      vnode = null;
    },
  };
};

export default vLoading;

我们在给 loading 组件添加一个 screen 属性,如果 screentrue,则全屏显示,否则在绑定的元素上显示,我们也需要设置一个全屏加载的一个 class,来单独设置全屏加载的样式。

<template>
  <div
    :class="['t-loading', { 't-loading-mask--screen': screen }]"
    :style="{
      'background-color': background,
    }"
  >
    <div class="t-loading__spinner">
      <div class="t-loading__spinner-icon">
        <svg width="60" height="30" viewBox="0 0 100 50">
          <!-- ... -->
        </svg>
      </div>
      <div class="t-loading__text" v-if="text">{{ text }}</div>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  text: {
    type: String,
  },
  background: {
    type: String,
  },
  screen: {
    type: Boolean,
  },
});
</script>

loading.less

.t-loading-mask--screen {
  position: fixed;
  width: 100vw;
  height: 100vh;
  background-color: rgba(255, 255, 255, 0.8);
  z-index: 1001;
  pointer-events: none;
}

我们写一个试试

<template>
  <t-button type="primary" @click="openFullScreenLoading"> 全屏加载 </t-button>
</template>
<script setup>
  import { TLoading } from "@test-ui/components";

  const openFullScreenLoading = () => {
    const loading = TLoading.service({
      text: "全屏加载",
      background: "rgba(0, 0, 0, 0.7)",
    });

    setTimeout(() => {
      loading.close();
    }, 3000);
  };
</script>

project-20250516-4.png

目前是正常的,但是细心的小伙伴会发现这个遮罩层是可以触发底部滚动的,那怎么解决呢?我们可以在触发全屏加载的时候给 body 设置一个 overflow: hidden,在关闭的时候再移除这个样式,我们可以给 body 添加一个类名来设置样式,然后关闭的时候移除掉这个类名。

export const createGlobalLoading = ({ text, background }) => {
  let vnode = h(TLoadingComponent, {
    text,
    loadingBackground: background,
    screen: true,
  });
  window.document.body.classList.add("t-loading-screen-parent");
  render(vnode, window.document.body);
  return {
    close() {
      window.document.body.classList.remove("t-loading-screen-parent");
      window.document.body.removeChild(vnode.el);
      render(null, window.document.body); // 该代码作用是清除vnode
      vnode = null;
    },
  };
};
.t-loading-screen-parent {
  overflow: hidden !important;
}

这样就完成了。

自定义图标

我们有时候想要吧加载的动画换一下,这个实现也比较容易,element 是把动画做在了组件内部,你只需要改变组件的图表即可,我们这边是在 svg 里面实现的动画图标,如果你想和 element-plus 一样,你就只需要 spinner 添加 css 动画即可,这边我们就不写了。给我们可以同样使用自定义属性传递 svg 图表,然后在生成 loading 组件的时候获取,然后在组件内部渲染即可。

<template>
  <t-table
    :column-data="columnData"
    :table-data="tableData"
    border
    v-loading="loading"
    loading-text="loading..."
    :loading-spinner="loadingSVG1"
    style="margin-top: 20px"
  />
  <t-button
    type="primary"
    style="margin-top: 20px"
    @click="openFullScreenLoading2"
  >
    全屏加载自定义图标
  </t-button>
</template>
<script setup>
  const loadingSVG1 = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 42" width="42" height="42">
  <circle cx="21" cy="21" r="5" fill="none" stroke="#5e72e4" stroke-width="2">
    <animate attributeName="r" from="5" to="18" dur="1.5s" repeatCount="indefinite"/>
    <animate attributeName="opacity" from="1" to="0" dur="1.5s" repeatCount="indefinite"/>
  </circle>
  <circle cx="21" cy="21" r="5" fill="none" stroke="#5e72e4" stroke-width="2">
    <animate attributeName="r" from="5" to="18" dur="1.5s" begin="0.5s" repeatCount="indefinite"/>
    <animate attributeName="opacity" from="1" to="0" dur="1.5s" begin="0.5s" repeatCount="indefinite"/>
  </circle>
</svg>
`;
  const loadingSVG2 = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 42" width="42" height="42">
  <circle cx="21" cy="21" r="18" fill="none" stroke="#5e72e4" stroke-width="3" stroke-dasharray="5,5">
    <animate attributeName="stroke-dashoffset" from="0" to="20" dur="1s" repeatCount="indefinite"/>
    <animate attributeName="opacity" values="1;0.5;1" dur="2s" repeatCount="indefinite"/>
  </circle>
</svg>
`;

  const openFullScreenLoading2 = () => {
    const loading = TLoading.service({
      text: "全屏加载",
      background: "rgba(0, 0, 0, 0.7)",
      loadingSpinner: loadingSVG2,
    });

    setTimeout(() => {
      loading.close();
    }, 3000);
  };
</script>

然后我们改一下

packages/loading/src/directive.js

import TLoadingComponent from "./loading.vue";
import { h, render } from "vue";

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent, {
    text: el.getAttribute("loading-text"),
    background: el.getAttribute("loading-background"),
    icon: el.getAttribute("loading-spinner"),
  });
  render(vnode, el);
};

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
    if (value) createLoading(el);
  },
  updated(el, binding) {
    if (!binding.value && binding.value !== binding.oldValue) {
      el.removeChild(el.querySelector(".t-loading"));
      render(null, el);
    } else if (binding.value && binding.value !== binding.oldValue) {
      createLoading(el);
    }
  },
  unmounted(el) {
    el.removeChild(el.querySelector(".t-loading"));
    render(null, el);
  },
};

export const createGlobalLoading = ({ text, background, loadingSpinner }) => {
  let vnode = h(TLoadingComponent, {
    text,
    loadingBackground: background,
    screen: true,
    icon: loadingSpinner,
  });
  window.document.body.classList.add("t-loading-screen-parent");
  render(vnode, window.document.body);
  return {
    close() {
      window.document.body.classList.remove("t-loading-screen-parent");
      window.document.body.removeChild(vnode.el);
      render(null, window.document.body); // 该代码作用是清除vnode
      vnode = null;
    },
  };
};

export default vLoading;

packages/loading/src/loading.vue

<template>
  <div
    :class="['t-loading', { 't-loading-mask--screen': screen }]"
    :style="{
      'background-color': background,
    }"
  >
    <div class="t-loading__spinner">
      <div class="t-loading__spinner-icon" v-if="!icon">
        <svg width="60" height="30" viewBox="0 0 100 50">
          <circle cx="25" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="1;0.3;1"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
          <circle cx="50" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="0.3;1;0.3"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
          <circle cx="75" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="0.3;1;0.3"
              begin="0.5s"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
        </svg>
      </div>
      <div v-else v-html="icon"></div>
      <div class="t-loading__text" v-if="text">{{ text }}</div>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  text: {
    type: String,
  },
  background: {
    type: String,
  },
  screen: {
    type: Boolean,
  },
  icon: {
    type: String,
  },
});
</script>

project-20250516-5.png

丸美!

本节的loading加载组件就算开发完了,我们的组件教程依旧会持续更新,大家持续关注。

本专栏源码地址

什么?LocalStorage 也能被监听?为什么我试了却不行?

我们都知道,localStorage 是前端开发中常用的浏览器本地存储方案之一。然而你是否遇到过这样的情况:

网上说 localStorage 可以被监听,但我尝试了之后却没有任何效果?

本文将从源码出发,带你彻底理解 localStorage 的监听机制,并揭秘背后的行为逻辑。


## 事件监听的基本方式

我们先来看一段简单的代码:

```js
window.addEventListener('storage', (event) => {
  console.log('Storage changed:', event);
});

运行这段代码后,监听器貌似什么也没有触发?为什么?


localStorage 的监听机制:跨页面才会触发

关键点在于:

storage 事件 只有在其他页面(同源)对 localStorage 进行修改时,当前页面才会触发事件!

这意味着你在当前页面执行以下代码:

localStorage.setItem('key', 'value');

不会触发当前页面上的 storage 事件的。

但如果你打开了两个同源页面 A.htmlB.html

  • B.html 中执行 localStorage.setItem(...)
  • 此时 A.html 中的监听函数会被触发!

官方文档如何说?

MDN StorageEvent 可以得知:

  • 事件只会在 其他页面改变 localStorage 时触发
  • 同一个页面内的操作不会触发

监听 localStorage 的正确方式

如果你的目标是监听 本页面的 localStorage 改变,你需要自己封装监听机制。

自定义封装方案

比如重写 setItem

(function () {
  const originalSetItem = localStorage.setItem;
  localStorage.setItem = function (key, value) {
    const event = new Event('localstorage-change');
    event.key = key;
    event.newValue = value;
    window.dispatchEvent(event);
    originalSetItem.apply(this, arguments);
  };
})();

然后监听:

window.addEventListener('localstorage-change', (e) => {
  console.log('Key changed:', e.key);
});

原理揭秘:源码中的事件派发

浏览器的 localStorage 事件是如何派发的?

Chromium 源码分析

我们来看 Chromium 的 DOMStorage 实现中一个关键函数:

void StorageArea::DispatchStorageEvent(...) {
  if (SameOrigin && NotCurrentPage) {
    // 触发 storage 事件
  }
}

可见浏览器明确判断了:

  • 当前页面是否是修改者
  • 若是当前页面,不触发事件

为什么要设计成这样?

这是为了避免循环事件触发。设想以下场景:

  • 页面 A 设置 localStorage
  • 触发事件回调,又设置 localStorage
  • 又触发事件,进入死循环...

为防止这种问题,浏览器选择只在「非当前页面」中触发事件。


如何验证?

你可以打开两个同源页面,分别执行如下操作来验证:

  1. 页面 A 中:
window.addEventListener('storage', (e) => {
  console.log('页面 A 收到事件:', e);
});
  1. 页面 B 中:
localStorage.setItem('demo', '123');

你会在页面 A 的控制台中看到触发的 storage 事件。


结语

总结一下:

  • localStoragestorage 事件只能在 其他窗口或标签页触发
  • 如果你想监听 本页面localStorage 变化,需要手动封装逻辑
  • 浏览器这样设计,是为了避免事件循环带来的性能与逻辑问题

ES6中Reflect对象与Proxy结合实现代理和响应式编程

前言

在 JavaScript 中,Reflect 是一个内置对象,提供了拦截和操作 JavaScript 对象的元方法。它是 ES6 (ES2015) 引入的特性,主要用于简化元编程(meta-programming)并与 Proxy 结合使用实现对对象属性更细粒度的操作控制。

代理对象

Proxy 的第一个参数为要进行代理的目标对象,类型为Object,如果我们代理的目标是一个基础数据类型那应该怎么实现呢?

基础数据类型的代理

在 JavaScript 中,基础数据类型(如 numberstringboolean 等)无法直接被 Proxy 代理,因为 Proxy 只能拦截对象(包括数组、函数、类等)的操作。但可以通过对象封装的方式间接代理基础类型:

const target  = {
    value: '123' // 这里可以是number 、string、boolean...
}
const proxy = new Proxy(target, {
    get (target, key, receiver){
        // receiver 指向 proxy实例
        // 思考 ? 此处可否直接返回 target[key]
         return Reflect.get(target, key, receiver)
    },
    set (target, key, value, receiver){
        return Reflect.set(target, key, value, receiver)
    }
})

上述代码中有一个疑问,能否通过return target[key] 取代 return Reflect.get(target, key, receiver)? 事实上这里target[key] 等价于 Reflect.get(target, key), receiver是指向代理的实例,相当于call/apply的第一个参数,用于指定函数执行的上下文,上面例子中如果不涉及this指向,仅仅是对于简单类型的代理可使用Reflect.get(target, key) 或 target[key](但不推荐),接下来我们看下面这个例子说明不推荐的原因:

复杂数据类型的代理

const people  = {
    get name(){
        console.log('thisArg:', this)
        return this._name;
    },
    _name:'xixi'
 }
 
 const proxy1 = new Proxy(people, {
     get (target, key, receiver){
      // 思考 ? 此处可否直接返回 target[key]
      console.log('get name:', target, receiver)
      return Reflect.get(target, key, receiver);
     },
     
 })
 console.log(proxy1.name)
 // 输出:
 // get name: { name: [Getter], _name: 'xixi' } name { name: [Getter], _name: 'xixi' }
 // thisArg: { name: [Getter], _name: 'xixi' }
 // get name: { name: [Getter], _name: 'xixi' } _name { name: [Getter], _name: 'xixi' }
 // xixi
 
 const proxy2 = new Proxy(people, {
     get (target, key, receiver){
      // 思考 ? 此处可否直接返回 target[key]
      console.log('get name:', target, receiver)
      return target[key];
     },
 })
 console.log(proxy2.name)
 // 输出:
 // get name: { name: [Getter], _name: 'xixi' } name { name: [Getter], _name: 'xixi' }
 // thisArg: { name: [Getter], _name: 'xixi' }
 // xixi

结论:通过对比输出结果,可以看出直接return target[key]会导致this无法绑定在代理对象上,当修改属性时代理事件就无法触发导致错误,所以建议直接按照Reflect.get(target, key, receiver)处理。

响应式编程

此处我们以vue3的响应式编程为例,自定义实现ref和reactive的简化版:


// 存储依赖关系的 WeakMap
const targetMap = new WeakMap();

// 当前正在收集依赖的副作用函数
let activeEffect = null;

// 副作用函数
function effect(fn) {
  activeEffect = fn;
  fn(); // 执行副作用,触发依赖收集
  activeEffect = null; // 清空
}

// 依赖收集函数
function track(target, key) {
  if (activeEffect) {
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    
    let dep = depsMap.get(key);
    if (!dep) {
      depsMap.set(key, (dep = new Set()));
    }
    
    dep.add(activeEffect); // 将副作用添加到依赖集合
  }
}

// 触发依赖更新
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  
  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect()); // 执行所有依赖副作用
  }
}

// 简化版 reactive
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);
      track(target, key); // 收集依赖
      return result;
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) {
        trigger(target, key); // 触发更新
      }
      return result;
    }
  });
}

// 简化版 ref
function ref(initialValue) {
  const _value = { value: initialValue }; // 创建一个包裹对象
  
  // 对于对象类型,使用 reactive 转换为响应式
  if (typeof initialValue === 'object' && initialValue !== null) {
    _value.value = reactive(initialValue);
  }
  
  return new Proxy(_value, {
    get(target, key) {
      track(target, key); // 收集依赖
      return target[key];
    },
    set(target, key, value) {
      // 对于对象类型,使用 reactive 转换
      if (key === 'value' && typeof value === 'object' && value !== null) {
        target[key] = reactive(value);
      } else {
        target[key] = value;
      }
      trigger(target, key); // 触发更新
      return true;
    }
  });
}

Three.js 完全学习指南(一)Three.js 简介与核心概念

Three.js 简介与核心概念

什么是 Three.js?

Three.js 是一个轻量级的 3D 图形库,它封装了 WebGL 的底层 API,让开发者能够更容易地创建和展示 3D 图形。它提供了丰富的功能,包括:

  • 场景管理
  • 相机控制
  • 光照系统
  • 材质系统
  • 几何体
  • 动画系统
  • 后期处理
  • 等等

Three.js 示例场景

图 1.1: Three.js 创建的复杂 3D 场景示例

WebGL 基础概念

在开始使用 Three.js 之前,我们需要了解一些 WebGL 的基础概念:

1. 渲染管线

WebGL 的渲染管线主要包含以下步骤:

  1. 顶点着色器(Vertex Shader):处理顶点数据
  2. 图元装配(Primitive Assembly):将顶点组装成图元
  3. 光栅化(Rasterization):将图元转换为像素
  4. 片元着色器(Fragment Shader):处理像素颜色
  5. 帧缓冲(Frame Buffer):存储最终的渲染结果
graph LR
    A[顶点数据] --> B[顶点着色器]
    B --> C[图元装配]
    C --> D[光栅化]
    D --> E[片元着色器]
    E --> F[帧缓冲]

图 1.2: WebGL 渲染管线流程图

2. 坐标系

WebGL 使用右手坐标系:

  • X 轴:向右为正
  • Y 轴:向上为正
  • Z 轴:向外为正
graph TD
    subgraph 右手坐标系
        A((原点)) --> B[X轴]
        A --> C[Y轴]
        A --> D[Z轴]
    end

图 1.3: 右手坐标系示意图

Three.js 坐标系示例

图 1.4: Three.js 中的坐标系应用示例

开发环境搭建

1. 创建项目

首先,创建一个新的项目目录并初始化:

mkdir threejs-demo
cd threejs-demo
npm init -y

2. 安装依赖

安装必要的依赖:

npm install three
npm install vite --save-dev

3. 创建基础项目结构

threejs-demo/
├── index.html
├── src/
│   ├── main.js
│   └── style.css
├── package.json
└── vite.config.js

图 1.5: 项目目录结构

4. 配置 Vite

创建 vite.config.js

export default {
  root: './',
  publicDir: 'public',
  server: {
    host: true
  }
}

5. 创建 HTML 文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js 入门</title>
    <link rel="stylesheet" href="src/style.css">
</head>
<body>
    <div id="app"></div>
    <script type="module" src="src/main.js"></script>
</body>
</html>

6. 添加基础样式

body {
    margin: 0;
    overflow: hidden;
}

#app {
    width: 100vw;
    height: 100vh;
}

第一个 3D 场景

让我们创建一个简单的 3D 场景,包含一个旋转的立方体:

import * as THREE from 'three';

// 创建场景
const scene = new THREE.Scene();

// 创建相机
const camera = new THREE.PerspectiveCamera(
    75, // 视角
    window.innerWidth / window.innerHeight, // 宽高比
    0.1, // 近平面
    1000 // 远平面
);
camera.position.z = 5;

// 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById('app').appendChild(renderer.domElement);

// 创建一个立方体
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({
    color: 0x00ff00,
    wireframe: true
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 动画循环
function animate() {
    requestAnimationFrame(animate);

    // 旋转立方体
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    // 渲染场景
    renderer.render(scene, camera);
}

// 处理窗口大小变化
window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
});

// 开始动画
animate();

第一个 3D 场景

图 1.6: 运行结果:一个旋转的绿色线框立方体

核心概念解析

1. 场景(Scene)

场景是所有 3D 对象的容器,它定义了:

  • 3D 空间
  • 背景色
  • 雾效果
  • 等等

场景示例

图 1.7: 包含多个几何体的场景示例

2. 相机(Camera)

相机决定了我们如何观察场景:

  • 透视相机(PerspectiveCamera):模拟人眼视角
  • 正交相机(OrthographicCamera):无透视效果
graph TD
    subgraph 相机类型
        A[相机] --> B[透视相机]
        A --> C[正交相机]
        B --> D[模拟人眼]
        C --> E[无透视]
    end

图 1.8: 相机类型对比图

3. 渲染器(Renderer)

渲染器负责将场景和相机的内容绘制到屏幕上:

  • WebGLRenderer:使用 WebGL 进行渲染
  • 可配置抗锯齿、阴影等效果

渲染效果对比转存失败,建议直接上传图片文件

图 1.9: 不同渲染效果对比

4. 网格(Mesh)

网格是 3D 对象的基本单位,由两部分组成:

  • 几何体(Geometry):定义形状
  • 材质(Material):定义外观

网格示例转存失败,建议直接上传图片文件

图 1.10: 网格的几何体和法线可视化

常见问题与解决方案

  1. 性能问题

    • 使用 requestAnimationFrame 进行动画
    • 及时释放不需要的资源
    • 使用适当的几何体复杂度
  2. 内存管理

    • 使用 dispose() 方法释放资源
    • 避免频繁创建新对象
    • 重用几何体和材质
  3. 兼容性问题

    • 检查 WebGL 支持
    • 提供降级方案
    • 使用 polyfill 解决兼容性问题

下一步学习

在下一章中,我们将深入学习:

  • 场景的详细配置
  • 不同类型的相机
  • 渲染器的进阶设置
  • 动画系统的基础知识

练习

  1. 修改立方体的颜色和大小
  2. 添加多个立方体
  3. 实现鼠标控制相机
  4. 添加简单的光照效果

资源链接

猜成语小游戏

这是一个猜成语的网页游戏,用户根据显示的成语 首字母 来猜测对应的四字成语。

例如 ABBS = 哀兵必胜 ABJB = 按部就班

屏幕截图 2025-05-19 155410.png

功能特点

  • 随机生成四字成语的首字母缩写
  • 用户输入猜测的成语并提交验证
  • 正确 / 错误答案的反馈
  • 重置功能,确保游戏流程顺畅
  • 界面元素合理布局,视觉层次分明

技术实现

  • 使用idioms对象数组存储成语信息,每个对象包含:
    • code:成语中文写法
    • letter:成语首字母缩写
    • explain:成语解释
  • DOM 操作:通过document.getElementById获取和操作页面元素
  • 事件监听:使用addEventListener绑定按钮点击事件
  • 随机数生成:Math.random()用于随机选择成语
  • 字符串处理:验证用户输入与正确答案

项目结构

QQ20250519-102954.png

项目代码

一、游戏界面和基本结构

整体结构:页面使用了简单的垂直布局,从上到下依次是标题、游戏规则说明和游戏区域。

  1. 使用<link>标签,并行加载CSS文件
  2. 同时在页面中适当添加了[emoji表情],增加页面的趣味性(emoji6.com/emojiall/)
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>猜成语游戏</title>
    <link rel="stylesheet" href="guess.css">
</head>
<body>
    <h1>🎉欢迎来到猜成语游戏!🎉</h1>
    <div class="rule">
        ✨️游戏规则:请根据这个四字成语首字母,来猜出所对应的四字成语
        <br>点击"提示"按钮可以获得成语的解释
    </div>
    <div class="game">
        <div id="clue">abdc</div>
        <input type="text" id="answer" placeholder="请输入成语">
        <button id="submit-btn">提交答案</button>
        <div id="result"></div>
        <button id="hint-btn">提示</button>
        <div id="hint"></div>
        <button id="next-btn">下一个成语</button>
    </div>
    <!-- 引入js文件 -->
    <script src="guess.js"></script>
</body>
</html>

背景设置:使用彩虹图片作为背景,并设置为覆盖整个页面,省去了使用标签,然后设置样式的步骤,避免图片元素对文档流的影响,保持了 HTML 结构的简洁

body{
      background-image:url('img/彩虹.jpg');
      background-size:cover;
    }

游戏区域

  • 成语首字母显示区:以大号字体展示成语首字母
  • 输入框:用户输入猜测的成语
  • 提交按钮:提交答案
  • 结果显示区:显示用户猜测的结果
  • 提示按钮:获取成语解释
  • 下一个成语按钮:开始新的一轮游戏

样式设置: 1.游戏区域使用margin-top: 50px;属性实现整体下移,不遮挡图片

        h1 {
            font-size: 60px;
            text-align: center;
            color: #ee2746;
        }
        .rule {
            text-align: center;
            font-size: 25px;
            color: whitesmoke;
        }
        .game {
            margin: auto;//居中
            width: 900px;
            height: 450px;
            text-align: center;
            margin-top: 50px;
        }
        #clue {
            font-size: 130px;
            font-weight: bold;
            /* 字母间距 */
            letter-spacing: 20px;
            margin: 20px 0;
            color: black;
        }
        .result {
            margin: 20px 0;
            font-size: 18px;
        }
        .hint {
            margin: 10px 0;
            font-size: 30px;
            color: #1ba784;
        }
        input{
            padding: 18px 30px;
            font-size: 35px;
            border: 2px solid #1ba784;
            border-radius: 5px;
        }

2.对按钮进行了样式设置,添加了鼠标悬停效果,使按钮变得更加好看,这里的颜色我是通过[中国色]选择的,里面有特别多颜色,可供选择,其他两个按钮同理(www.zhongguose.com/)

QQ20250519-153142.png

二、代码实现

把信息存储到数组中: 这里成语数据页可以进行自行添加

  1. 使用 Math.random() 生成一个 0 到 1 之间的随机小数,乘以 idioms 数组的长度后取整,得到一个随机的成语索引。
  2. 根据随机索引从 idioms 数组中获取对应成语的首字母缩写。
// 成语数据
        const idioms = [
            { code: "安步当车", letter: "ABDC", explain: "以从容的步行代替乘车。形容轻松缓慢地行走。也指人闲适自得,从容而行。" },
            { code: "爱不释手", letter: "ABSS", explain: "喜爱得舍不得放手。" },
            { code: "按部就班", letter: "ABJB", explain: "按照一定的步骤、顺序进行。也指按老规矩办事,缺乏创新精神。" },
            { code: "八拜之交", letter: "BBZJ", explain: "旧时朋友结为兄弟的关系。" }
            ];
        let currentIdiomIndex = 0;
        // 随机生成首字母
        function generateClue() {
            currentIdiomIndex = Math.floor(Math.random() * idioms.length);//生成随机数,得到一个随机的成语索引
            const letter = idioms[currentIdiomIndex].letter;//从 idioms 数组中获取对应成语的首字母缩写
            document.getElementById('clue').innerText = letter;//将首字母缩写显示在页面上的 clue 元素中
            document.getElementById('result').innerText = '';
            document.getElementById('hint').innerText = '';//清空元素内容
            document.getElementById('next-btn').style.display = 'none';//隐藏按钮
        }

对提交答案按钮进行监听

检查答案是否正确,并给出提示,同时显示 下一个成语 按钮

        // 监听提交答案按钮的点击事件, 
        document.getElementById('submit-btn').addEventListener('click', function () {
            const userAnswer = document.getElementById('answer').value.trim();
            const correctAnswer = idioms[currentIdiomIndex].code;
            if (userAnswer === correctAnswer) {
                document.getElementById('result').innerText = '🎊恭喜你,猜对了,棒棒哒,请继续加油哦!';
                document.getElementById('result').style.color = '#1ba784';
                document.getElementById('result').style.fontSize = '30px';
            } else {
                document.getElementById('result').innerText = '🧨很遗憾,猜错了,请点击提示按钮,再试一次吧!';
                document.getElementById('result').style.color = '#e16c96';
                document.getElementById('result').style.fontSize = '30px';
            }
            document.getElementById('next-btn').style.display = 'inline-block';
        });

提示功能

将当前随机选中成语的解释,显示在页面上的 hint 元素中

        // 提示功能,将当前随机选中成语的解释显示在页面上的 hint 元素中
        document.getElementById('hint-btn').addEventListener('click', function () {
            const explanation = idioms[currentIdiomIndex].explain;
            document.getElementById('hint').innerText = explanation;
        });

生成下一个成语并初始化游戏

调用 generateClue 函数重新随机选取一个成语并显示首字母缩写

        // 下一个成语
        document.getElementById('next-btn').addEventListener('click', function () {
            generateClue();//重新随机选取一个成语并显示首字母缩写
            document.getElementById('answer').value = '';//同时清空用户输入框
        });
        // 初始化游戏
        generateClue();

总结

这是一个由HTML+CSS+JS组成的猜成语小游戏,同时为用户提供了一个有趣且易于使用的成语学习工具

当然我的项目还有不足之处,欢迎大家留言点评,我后续也会进行完善!

使用 Harmony ArkTS 开发数字钱包应用(Dompet App)

Dompet.webp


推荐

  1. 使用 Flutter 开发数字钱包应用(Dompet App)
  2. 开发 ArkTS 版 HarmonyOS 日志库 —— logger

前言

在之前的 Flutter 版本中,我们探索构建了一个现代化的混合应用框架,专注于 状态管理路由管理国际化 ,并整合了 网络请求本地存储WebView 等核心能力,以便快速适配多种业务场景。

如今随着 HarmonyOS 兴起,我们将这一探索和搭建应用开发框架的方式延续到鸿蒙的设备和平台上,实现基于 ArkTS 构建现代化的 HarmonyOS 应用 开发方案。

  1. 基于 @Local@Param@Event@ComponentV2 装饰器实现数据与UI双向交互
  2. 基于 @ohos/axios 第三方库提供 Restful 范式网络请求,并实现全局的请求和响应拦截器
  3. 基于 preferencesrelationalStore 等 API 构建高效的本地数据持久化储存和读取方案
  4. 基于 ArkWebWebviewController 实现 Web 页面渲染,并支持 Web 与 App 端交互
  5. ......

选型

在构建 Harmony App 过程中,我们从 状态管理路由管理国际化 以及 日志库 等方面考虑,引入使用了 @hadss/hmrouter@ohos/axios@ohos/imageknife@hitro/arklogger 等插件。

在 UI 设计方面,我们延续了 Flutter 版本设计,继续采用 Pixso 社区 提供的 《Dompet 数字钱包》 设计风格。不过,就快捷登录部分,我们不再使用 GoogleGithub 第三方登录,而是采用了 华为账号一键登录 的方式。(需是企业开发者账号上架应用!!!)

image.png


特性

在技术上,我们选择了 @hadss/hmrouter@ohos/axiospreferencesrelationalStore 以及 ArkWeb 作为构建框架的核心基础设施,而在 UI 设计方面,则是选择了 《Dompet 数字钱包》 设计稿作为我们 App 用户界面。

在确定了 ArkTS/ArkUI 技术栈、核心插件以及 UI 设计稿后,我们开始着手梳理 Dompet 数字钱包 的 UI 界面和功能需求,以确保高效的开发效率,并成为一个用户体验极佳的 App。

  1. 使用 @hadss/hmrouter 来接管 App 的路由管理、权限拦截/认证、以及转场动画。
  2. 定义 entry/src/main/resources 中不同目录 (eg. zh_CN),实现中英文语言和资源的切换
  3. 使用 @ComponentV2 装饰器 (eg. @Local@Param@Event) 实现 UI 和数据的双向驱动
  4. 使用 relationalStore 数据库,实现用户、账单、消息、银行卡的数据流转,模拟业务处理
  5. 对于 华为本机账号一键登录 我们通过 Authentication kit 来实现其账号一键登录的流转和处理
  6. 对于 UI 设计稿中的折线图表,我们通过 ArkWebEChart 来实现其复杂的图表交互功能和效果
  7. 定义 size 单位转换/绘制 API (eg. size.vpsize.vw ...),支持自动适配不同设备的屏幕以及翻转
  8. 借助 intlsetAppPreferredLanguage 自动处理不同语言和区域的格式 (eg. 日期本地化)
  9. 通过 photoAccessHelper 插件,实现 App 上传来自相册或拍照而的得图片,进而更新用户头像
  10. 虽然 relationalStore 模拟了业务的处理和流转,但网络请求作为 App 基础设施,我们依旧对 Axios 进行了封装,完善了 Request 和 Response 拦截处理 (携带 Token、异常处理等)
  11. ......

重要插件依赖如下:

  • @ohos/axios: 一个运行在 HarmonyOS 的网络请求库,支持 Axios 原有用法和特性
  • @hadss/hmrouter: 一款功能强大的路由框架,支持自定义拦截器、生命周期、转场动画...
  • @ohos/imageknife: 一款为 OpenHarmony 打造的图像加载缓存库,提供更高效、更轻便的 API
  • @ohos/crypto-js: 一款加密算法类库,可以非常方便地在 HarmonyOS 中进行加解密 (eg. MD5)
  • @hitro/ark: 一个基于 ArkTS 实现工具函数库 (eg. 数据类型转换、debounce、Signal 等 API)
  • logger: 一个简单、实用的 HarmonyOS 应用日志框架,支持多种数据类型和日志上报 详情

源码 - Configure 配置

  • configure/axios: 基于 Axios 封装 Request 和 Response 拦截处理 (Token/异常处理、日志埋点等)
  • configure/context: 存储 App 各类运行上下文,支持跨线程引用和传输 (eg. 为 Toast 提供上下文)
  • configure/device: 存储 App 设备相关信息,例如 设备宽高、设备翻转、设备安全区、设备状态等
  • configure/network: 存储 App 设备网络、wifi 连接状态,支持自动更新网络断开、网络重连时情况
  • configure/persistent: 基于 sendablePreferences 封装,支持数据持久化存储,支持跨线程使用
  • configure/scheme: 一个 App 路由调度器,接收并处理来 Want 的信息,使其访问正确 UI 页面
  • configure/socket: 封装了 WebSocket 系统,已实现网络异常处理、断开重连、ping 机制等功能
  • configure/sqlite: 封装了 Sqlite 数据库的初始化和管理,提供了数据库的创建、关闭、销毁等操作

源码 - Components 组件

  • components/Dialog: 提供基于 ArkTS 统一封装的对话框,并扩展了 cancelconfirm 对应事件
  • components/Imager: 封装了 imageknife 图像加载缓存库,预设了 loadingerror 默认图片
  • components/Loading: 封装了一个通用的加载组件 <Loading>,支持 LoadingModifier 自定义
  • components/Toast: 结合 CtxManager 上下专注于 Toast 提示,适用于无 UI 情况 (例如 Axios)

源码 - globals 全局服务

  • globals/store: 全局响应式状态,提供了用户登录登出、语言切换、消息/订单等数据的存取和清理
  • globals/event: 全局统一事件,提供用户登录登出、业务数据同步与更新,协调数据库与状态管理

源码 - utils 工具库

  • utils/crypto: 基于 @ohos/crypto-js 封装,支持对称密钥加解密处理,例如 AES-ECBMD5
  • utils/grant: 封装权限管理逻辑,支持全局开关申请、动态权限请求、权限检测及引导跳转设置页
  • utils/size: 封装了设备尺寸相关的转换和渲染方法,用于自动适配不同屏幕尺寸和分辨率
  • utils/unit: 提供了多种单位之间的转换方法,如 lpx2vpvp2lpxany2vpany2px

注意

虽然我们设计了 华为账号一键登录 以及 获取本机手机号 的 UI 页面和功能,但是调用这些 API 却是需要企业开发者账号才能申请其相关权限,所以比较可惜,目前 Dompet App 暂时无法使用一键登录。不过我们提供了 访客模式 ,无需注册便可进行登录演示。

image.png


演示

https://linpengteng.github.io/resource/dompet-app/hap.gif 前往


GitHub

Dompet App: https://github.com/DompetApp/Dompet.harmony 前往
Webview SDK: https://github.com/DompetApp/Dompet.webview 前往

前往点个赞 Star 👍

🔒“同源策略”到底限制了啥?搞懂它,跨域就不再是问题!

✅ 什么是同源策略(Same-Origin Policy)?

同源策略是浏览器最核心的安全机制之一,它限制不同源之间的交互行为,防止恶意网站窃取用户数据。

🔑 “同源”的定义:

要满足以下三个条件,两个资源才算是“同源”

条件 示例
协议相同 http vs https ❌ 不同源
域名相同 a.example.com vs b.example.com ❌ 不同源
端口相同 example.com:80 vs example.com:8080 ❌ 不同源

例如

http://example.com:80
vs
https://example.com:443 → ❌ 不同源

🚧 同源策略会限制哪些操作?

操作类型 是否受限 说明
读取 Cookie / LocalStorage ✅ 受限 无法访问其他源的数据
发送 AJAX 请求 ✅ 受限 请求可以发出,但响应无法读取(除非 CORS)
DOM 操作 ✅ 受限 不同源的页面不能互相操作 DOM
<img src> 请求 ❌ 不受限 可以加载不同源的图片
<script src> 请求 ❌ 不受限 可用于加载第三方脚本(但存在 XSS 风险)
<link href> 请求 ❌ 不受限 样式文件可跨域加载

🤯 为什么需要同源策略?

  • 防止 CSRF(跨站请求伪造):你登录着某个网站,另一个恶意站点偷偷发请求窃取你的权限。

  • 防止 隐私泄露:通过 iframe、cookie 等手段窃取用户敏感信息。

  • 限制第三方脚本的非授权访问


🎯 常见解决方案(跨域)

  1. CORS(推荐)
    后端设置响应头 Access-Control-Allow-Origin 等,允许指定源访问。

  2. JSONP(仅支持 GET,已过时)
    利用 <script> 不受同源限制的特性。

  3. 反向代理(如 Nginx)
    本质:让前端请求看起来是“同源”的路径,比如 /api → 代理到后端服务器

  4. PostMessage
    用于 iframe、window 跨源安全通信。


🧨 为什么“不是所有跨域都能 CORS”?

有些跨域问题根本不是 CORS 能解决的,下面来看一个案例。

🔍 示例 某些第三方接口不支持 CORS

比如你想访问一个第三方开放接口 https://thirdparty.com/data,但这个接口压根不支持跨域响应头。

即使你发送请求:

fetch('https://thirdparty.com/data')

也会报错:

Access to fetch at 'https://thirdparty.com/data' from origin 'https://yourdomain.com' has been blocked by CORS policy

因为第三方服务没有设置 CORS,你根本无法通过浏览器读取返回的数据

👉 解决方法:

  • 后端中转(如你自己的服务转发请求)

  • 或使用服务器代理请求,再将数据返回给前端。


🧠 总结

同源策略是浏览器的“护城河”,保护用户免受恶意站点的攻击。想要合理“越过”它,就要了解它!

记住这句话:

“不是所有跨域都能 CORS,代理才是真正的解法。”

你不知道的Javascript(上卷) | 第二章难点与细节解读(词法作用域)

作为《你不知道的Javascript》忠实读者,多次拜读该著作,本专栏用来分享我对该书的解读,适合希望深入了解这本书的读者阅读 电子书下载网址:zh.101-c.online

第二章——词法作用域

本章在《你不知道的Javascript(上卷)》中并没用较大的篇幅去描述,书中所讲主要聚焦在定义阶段,以及欺骗词法,我们的解读将由“就是这样”向“为什么是这样”转变。下面我们去讨论一些问题。

一、为何是词法作用域?

原文表述

image.png Javascript便是使用的词法作用域而非动态作用域,那么什么是词法作用域,什么是动态作用域呢?(这里做一个总结,有书中内容,也有总结内容)

词法作用域(Lexical Scope)

定义:词法作用域(也称为静态作用域)是由代码的书写结构决定的,在代码编译阶段(词法分析时)就确定了变量的作用域,不会在运行时改变。

特点

  1. 由代码结构决定:作用域在代码编写时就固定,函数的作用域取决于它被定义的位置,而不是调用的位置。
  2. JavaScript 采用词法作用域:JavaScript 的作用域规则是词法作用域。
  3. 闭包的基础:由于词法作用域的存在,函数可以访问定义时的外层变量,即使在外层函数执行完毕后仍然有效(闭包)。

示例

var a = 10;
function foo() {
    console.log(a); // 10(查找 foo 定义时的作用域,而非调用时的作用域)
}
function bar() {
    var a = 20;
    foo(); // 仍然输出 10,因为 foo 的词法作用域在全局
}
bar();

由于 foo 在全局定义,它的作用域链在编译时就确定了,即使 bar 内部有同名变量 afoo 仍然访问全局的 a


动态作用域(Dynamic Scope)

定义:动态作用域是在运行时根据调用栈决定的,函数的作用域取决于它被调用的位置,而不是定义的位置。

特点

  1. 由调用链决定:变量的查找基于函数的调用顺序,而不是代码结构。
  2. JavaScript 默认不支持动态作用域,但 this 的绑定机制(如 callapplybind)有些类似动态作用域的行为。
  3. Bash、Perl 等语言支持动态作用域

示例(假设 JavaScript 是动态作用域)

var a = 10;
function foo() {
    console.log(a); // 如果是动态作用域,这里会输出 20
}
function bar() {
    var a = 20;
    foo(); // 动态作用域下,foo 会查找 bar 的 a
}
bar();

如果 JavaScript 是动态作用域,foo 会查找调用它的 bar 的作用域,输出 20。但实际 JavaScript 是词法作用域,仍然输出 10

那么之后我们就进入本篇文章的第一个要点 “为什么Javascript要使用词法作用域,而不使用动态作用域?”,想必各位也能想出其中一二,比如动态作用域会使语言的作用域不清晰,不便于开发;动态作用域使代码性能下降等,这里我们做一个系统的分析和总结

1. 可预测性与代码可维护性

  • 词法作用域 在代码 编写时 就确定了变量的作用域,开发者能清晰知道一个变量来自哪里(例如函数定义时的外层作用域)。
  • 动态作用域 的变量查找依赖运行时调用链,导致代码行为难以预测,尤其是大型项目或嵌套调用时。

示例对比

// 词法作用域(可预测)
var x = 10;
function foo() { console.log(x); }
function bar() { var x = 20; foo(); }
bar(); // 输出 10(foo 始终访问定义时的 x)

// 如果是动态作用域(不可预测)
bar(); // 会输出 20(foo 访问调用时的 bar 的 x)

动态作用域下,foo 的输出会因调用位置不同而变化,增加调试难度。


2. 性能优化

  • 词法作用域 在编译阶段即可确定变量引用,引擎可以优化作用域链的查找(如静态分析、内联缓存)。
  • 动态作用域 需要在运行时动态解析变量,每次调用都可能重新计算作用域链,显著降低性能。

底层优化
V8 引擎通过 隐藏类(Hidden Classes) 和 内联缓存(Inline Caches) 加速词法作用域的变量访问,而动态作用域无法应用这些优化。


3. 闭包的支持

  • 词法作用域是闭包的基础:函数可以记住并访问定义时的作用域,即使在外层函数执行完毕后仍然有效。
  • 动态作用域无法实现闭包,因为变量的绑定在运行时才确定。

闭包示例

function outer() {
    var x = 10;
    function inner() { console.log(x); } // 记住 outer 的 x
    return inner;
}
var fn = outer();
fn(); // 输出 10(词法作用域允许闭包)

如果 JavaScript 是动态作用域,inner 无法可靠访问 x,因为它的值取决于调用时的上下文。


4. 模块化的天然支持

  • 词法作用域 允许通过函数嵌套和闭包实现模块化(如 IIFE 模式)。
  • 动态作用域 的变量容易受外部调用污染,难以隔离作用域。

模块化示例

// 模块模式(依赖词法作用域)
var module = (function() {
    var privateVar = 1;
    return { get: function() { return privateVar; } };
})();
module.get(); // 1(privateVar 被保护)

动态作用域下,privateVar 可能被外部代码意外修改。


5. 历史与语言设计哲学

  • JavaScript 受 Scheme/Lisp 影响:Brendan Eich 在设计 JavaScript 时借鉴了 Scheme 的词法作用域特性,强调函数式编程的简洁性。
  • 动态作用域更适合脚本语言:如 Bash、Perl,它们需要频繁依赖运行时上下文,但牺牲了可维护性。

至此,我们的第一部分完结

二、欺骗词法的性能问题

书中欺骗词法的讲述较为清晰,这里我只提一下 “欺骗词法的性能问题”

先来看一下原文表述

image.png

image.png 原文的表述主要聚焦在欺骗词法破坏了代码词法的静态分析,在一定程度上出现了“动态作用域的性能问题”,经管Javascript是词法作用域,但是欺骗词法是词法作用域的优势不再。那有没有更多的角度去分析呢?——

JavaScript 引擎(如 V8)在编译阶段会进行 静态作用域分析,优化变量访问,而 evalwith 等动态作用域操作会破坏这种优化,导致性能下降。具体原因如下:

1. 破坏作用域静态分析

(1) 词法作用域的优化机制

  • 编译阶段:引擎在代码执行前就能确定变量属于哪个作用域,生成高效的字节码或机器码。

    function foo() {
        var a = 1;
        console.log(a); // 引擎知道 a 是局部变量,直接访问栈内存
    }
    
  • 优化手段

    • 内联缓存(Inline Cache):缓存变量位置,避免重复查找。
    • 隐藏类(Hidden Class):快速定位对象属性。

(2) 动态作用域的破坏

eval 或 with 会让引擎无法在编译阶段确定变量来源,必须 运行时动态解析

function riskyEval(str) {
    eval(str); // 可能插入新变量,如 eval("var b = 2;")
    console.log(b); // 引擎无法提前知道 b 是否存在
}
riskyEval("var b = 3;");

后果

  • 引擎无法预分配变量存储位置(栈/堆)。
  • 所有变量访问退化为 慢速的动态查找(类似哈希表查询)。

2. 禁用 JIT 优化

(1) 去优化(Deoptimization)

现代 JavaScript 引擎(如 V8)会先快速生成未优化的字节码,运行中如果发现热点代码(频繁执行),则用 JIT(Just-In-Time)编译 生成优化后的机器码。
动态作用域操作会触发去优化

function unstable(x) {
    with ({ x: 1 }) {
        return x; // 引擎无法静态确定 x 的来源
    }
}
// 首次执行:生成未优化代码
unstable(10); 
// 后续执行:发现 with 无法优化,回退到解释执行

后果

  • 优化后的机器码被丢弃,回退到慢速的解释执行模式。
  • 性能可能下降 10~100 倍

(2) 无法内联缓存(Inline Cache)

  • 正常情况:引擎会缓存对象属性的内存偏移量,加速访问:

    obj.a; // 第一次访问记录位置,后续直接跳转
    
  • 动态作用域下

    with (obj) {
        console.log(a); // 无法缓存,每次都要查找
    }
    

    每次访问都需重新计算属性位置,类似 HashMap.get("a"),速度极慢。


3. 内存与安全开销

(1) 作用域链膨胀

eval 可能意外注入变量,污染作用域:

function leaky() {
    eval("var secret = 123;");
    // secret 泄漏到函数作用域,可能被闭包长期持有
}

后果

  • 变量无法被及时垃圾回收,增加内存占用。
  • 作用域链变长,变量查找时间增长。

(2) 安全风险

动态代码执行(如 eval)可能引发 XSS 攻击或意外行为,引擎会启用额外的安全检查,进一步拖慢速度。

结语

JavaScript 的词法作用域设计体现了语言在灵活性与性能之间的精妙平衡。通过静态作用域规则,JavaScript 既保证了代码的可预测性和可维护性,又为引擎优化提供了坚实基础。 这种设计让开发者能够构建复杂的模块化系统,同时享受现代 JIT 编译器带来的极致性能。词法作用域不仅是 JavaScript 的核心特性,更是理解闭包、this 绑定等高级概念的关键入口。

然而,这种优雅的设计也划定了明确的边界。任何试图"欺骗"词法作用域的操作(如 eval 和 with)都会破坏引擎的静态分析能力,导致严重的性能惩罚。 这提醒我们:在追求动态灵活性的同时,必须尊重语言的核心设计哲学。理解这些底层机制,不仅能帮助我们写出更高效的代码,更能深入体会 JavaScript 作为一门精心设计的语言所蕴含的智慧。

第二章的内容还是太少了,我更期待第三章的内容......

跟着文档学VUE3(3)- 计算属性

🧠计算属性

在 Vue 开发中,计算属性computed)是一个非常核心的概念。它不仅可以简化模板逻辑,还能自动追踪依赖并缓存结果,提高性能。

🎯 一、为什么需要计算属性?

当你需要根据响应式状态进行复杂的逻辑运算时,如果直接写在模板里,会导致代码臃肿、难以维护。

✅ 使用计算属性的目的:

  • 将复杂逻辑封装到一个变量中
  • 自动追踪响应式依赖,仅在必要时重新计算
  • 提升性能,避免重复执行相同计算
  • 减少模板中的冗余代码

🛠️ 基本用法:使用 computed 函数

import {computed, ref} from 'vue'
const count = ref(1)
const doubleCount = computed(() => {
    return count * 2 
})

🔍 参数与返回值说明:

内容 描述
入参 接受一个 getter 函数
返回值 返回一个只读的 ref 对象
访问方式 通过 .value 获取计算结果

⚙️ 特性解析:

  1. 自动追踪依赖:Vue 会监听 count.value 的变化,并在变更时重新计算。
  2. 缓存机制:只要 count 没有改变,多次访问 doubleCount.value 都会返回缓存的结果,不会重复执行函数

⚠️ 注意事项:这些坑你别踩!

// ❌如果没有响应式依赖,则计算属性永远不会更新
const now = computed(() => Date.now())

// 💡如果你需要动态时间戳,请手动触发更新或使用 `watchEffect` 等响应式副作用函数

// 如果不需要缓存,直接在模板中调用方法,结果也是一样的

✨二、可以修改的计算属性- 使用getter和setter创建

可以反过来通过计算属性更新相关依赖 => 有时候我们不仅想“读取”计算属性,还想“设置”它来反向更新源数据。

示例代码:

const firstName = ref('f')
const secontName = ref('c')
// getter => 拼接成完整的姓名
// setter => 允许通过赋值的方式更新fristName 和 secontName
computed({
    get(){
        return firstName.value + '' + secondName.value
    },
    set(newVal){
        [firstName.value, secondName.value] = newVal.split(' ')
    },
})

🔄 三、如何获取上一次的计算结果?(Vue 3.4+ 新特性)

你可以通过 getter 函数的第一个参数,拿到上一次的计算结果。

const myComputed = computed((prev) => {
  // prev 是上一次的返回值
  if (someCondition) {
    return prev
  }
  return someNewValue
})

📌 应用场景:

  • 需要根据历史状态做判断
  • 控制某些状态的变更逻辑(如防抖、节流等)

🧪 四、实践中的注意事项(避坑指南)

1. Getter 不应有副作用

Getter 的职责是:计算并返回一个值,不要做以下事情:

❌ 不推荐行为:

  • 修改其他响应式状态
  • 发起异步请求(如 fetch
  • 直接操作 DOM

✅ 正确做法:

  • 使用 watch 或 watchEffect 来处理副作用

2. 不要直接修改计算属性的值

// ❌ 错误写法 fullName.value = 'Alice Johnson'
  • 计算属性本质上是一个“快照”,它是基于源状态生成的。
  • 修改它不会影响原始数据,也没有意义。
  • 如果你需要通过赋值来改变状态,请使用可写的 computed(setter)

🎁 五、总结:computed 的黄金法则

场景 推荐做法
复杂逻辑展示 使用 computed 简化模板
缓存计算结果 利用其缓存机制提升性能
支持双向绑定 使用 getter + setter
获取上次结果 Vue 3.4+ 中使用 (prev) => {}
有副作用操作 使用 watch 替代

💬 六、结语

computed 是 Vue 响应式系统中最强大、最常用的工具之一。理解它的原理和使用方式,能让你写出更优雅、更高效的 Vue 应用。

掌握JavaScript执行上下文:从基础到高级

❓ 执行上下文(Execution Context)

执行上下文是 JavaScript 代码执行时的核心运行环境,每次函数调用或全局代码执行都会创建一个新的执行上下文,并压入调用栈(Call Stack)。执行上下文分为两个阶段:

  1. 创建阶段

    • 确定作用域链(Scope Chain)。
    • 创建变量环境(Variable Environment)和词法环境(Lexical Environment)。
    • 绑定 this 的值。
  2. 执行阶段

    • 变量赋值、函数执行、代码逐行运行。

示例:调用栈中的执行上下文

function outer() {
  let a = 1;
  function inner() {
    console.log(a);
  }
  inner();
}
outer();
  • 调用 outer() → 创建 outer 的执行上下文。
  • 调用 inner() → 创建 inner 的执行上下文,压入栈顶。
  • inner 执行完毕后弹出栈,接着 outer 弹出。

this是什么

this 是执行上下文中的一个动态属性,其值由函数调用方式决定:

调用方式 this 指向 示例
默认绑定 非严格模式:全局对象(如 window);严格模式:undefined function foo() { console.log(this); } foo();
方法调用 调用该方法的对象 obj.method = function() { console.log(this); }; obj.method();
构造函数 新创建的实例对象 function Person() { this.name = 'Alice'; } const p = new Person();
显式绑定 call/apply/bind 的第一个参数 func.call({ x: 1 });
箭头函数 继承外层词法环境的 this const foo = () => { console.log(this); }; foo();

关键点

  • 箭头函数的 this 在定义时确定,无法通过 callapply 修改。
  • 回调函数(如 setTimeout)中的 this 默认指向全局对象,除非使用箭头函数或显式绑定。

📊 变量环境(Variable Environment)与词法环境(Lexical Environment)

在 ES6 之后,执行上下文中分为了两个环境来处理变量和作用域:

变量环境(Variable Environment) 词法环境(Lexical Environment)
存储 var 声明的变量和函数声明。 存储 letconst 声明的变量和块级作用域。
变量在创建阶段被初始化(变量提升)。 变量在声明前处于“暂时性死区”(TDZ),不可访问。
作用域为函数作用域。 作用域为块级作用域(如 iffor 等)。

示例:变量提升与暂时性死区

// var 的变量提升
console.log(a); // undefined
var a = 1;

// let 的暂时性死区
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 2;

作用域链

  • 每个词法环境都有一个 outer 引用指向外层环境,形成链式结构。
  • 变量查找时,先查找当前词法环境,再沿作用域链向外层查找。

🌍 执行上下文与环境的完整关系图解

context2.png


📝 核心角色说明

角色 作用 关键特性
全局对象 宿主环境提供的顶级对象(如 window/global 存储全局变量和函数,是全局执行上下文中 this 的默认指向。
全局执行上下文 代码执行的初始环境,唯一且最先入栈 包含变量环境(处理 var)和词法环境(处理 let/const),this 指向全局对象。
函数执行上下文 函数调用时创建的环境,入栈执行后出栈 独立的作用域链,this 由调用方式决定(默认、隐式、显式、new)。
变量环境 (VE) 存储 var 声明和函数声明(变量提升) 变量在创建阶段初始化为 undefined
词法环境 (LE) 存储 let/const 声明和块级作用域 变量在声明前处于“暂时性死区”(TDZ),不可访问。
作用域链 由词法环境的 outer 引用链接而成,决定变量查找路径 内部环境可访问外部环境的变量,反之不行。
执行栈 管理执行上下文的调用顺序(后进先出) 栈底是全局执行上下文,栈顶是当前正在执行的函数上下文。
this 指向当前执行上下文所属的“所有者” 动态绑定(普通函数)或静态绑定(箭头函数)。

🚨 常见问题

  1. 为什么 var 有变量提升,而 let 没有?

    • var 在变量环境中初始化值为 undefined,而 let 在词法环境中需要严格按代码顺序初始化。
  2. 如何避免 this 的指向问题?

    • 使用箭头函数、显式绑定(bind)、或在方法内保存 const self = this;
  3. 闭包是如何形成的?

    • 函数保留了对外部词法环境的引用,即使外部函数已执行完毕。

011-各种曲线

该系类文章主要用于记录学习three.js的过程,包括做的一些demo,笔记,以及个人思考;主要学习的课程是 神光的小册《three.js通关秘籍》,感兴趣的可以购买学习,质量还是可以的

本节主要熟悉three中画曲线的各种方法

EllipseCurve

这是画椭圆形的,当然也可以画圆

用法

/**
 * EllipseCurve 为椭圆
 * 0,0 是中心位置
 * 100 是 长轴
 * 50 为 短轴
 */
const arc = new THREE.EllipseCurve(0, 0, 100, 50)
// 从椭圆里面提取20个点
const pointList = arc.getPoints(20)
// 构建几何体
const geomerty = new THREE.BufferGeometry()
// 通过几何体的点
geomerty.setFromPoints(pointList)
/**
 * 点可以直接渲染 成 点(Ponit)
 */
// const material = new THREE.PointsMaterial({
//     color: 0xff0000,
//     // 控制点的大小
//     size: 10
// })
// const mesh = new THREE.Points(geomerty, material)

/**
 * 点也可以渲染成 线(Line)
 */
const material = new THREE.LineBasicMaterial({
    color: 0xff0000,
})
const mesh = new THREE.Line(geomerty, material)

console.log('~~~~mesh', mesh)
export default mesh

SplineCurve

根据多个点生成的曲线,只需要提供多个点即可

用法

const arr = [
    new THREE.Vector2(-100, 0),
    new THREE.Vector2(-50, 50),
    new THREE.Vector2(0, 0),
    new THREE.Vector2(50, -50),
    new THREE.Vector2(100, -30),
    new THREE.Vector2(100, 0)
]

const curve = new THREE.SplineCurve(arr)
const ponitArr = curve.getPoints(20)

const geometry = new THREE.BufferGeometry().setFromPoints(ponitArr)

const material = new THREE.LineBasicMaterial({
    color: 0x0000ff
})

const line = new THREE.Line(geometry, material)

QuadraticBezierCurve

这是2d的贝塞尔曲线,首尾两个点是谷固定的,而中间的点是决定 曲线的曲率的

用法

import * as THREE from 'three';

const p1 = new THREE.Vector2(0, 0)
const p2 = new THREE.Vector2(50, 100)
const p3 = new THREE.Vector2(100, 0)

const curve = new THREE.QuadraticBezierCurve(p1, p2, p3)
const pintArr = curve.getPoints(20)

// 通过贝塞尔曲线抽离出来的点组成的曲线
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pintArr), new THREE.LineBasicMaterial({ color: 0x0000ff }))

const point = new THREE.Points(new THREE.BufferGeometry().setFromPoints(pintArr), new THREE.PointsMaterial({ color: new THREE.Color('red'), size: 3 }))
line.add(point)

/**
 * 用原始的三个点来画 line 与 point
 */
const line2 = new THREE.Line(new THREE.BufferGeometry().setFromPoints([p1, p2, p3]), new THREE.LineBasicMaterial({ color: new THREE.Color('yellow') }))
const ponit2 = new THREE.Points(new THREE.BufferGeometry().setFromPoints([p1, p2, p3]), new THREE.PointsMaterial({ color: new THREE.Color('green'), size: 3 }))

line.add(line2, ponit2)
export default line

CubicBezierCurve3

与上面的一样,只不过是3维空间的,应该也很好理解

用法

import * as THREE from 'three';
//只是这里的坐标换成三位的了
const p1 = new THREE.Vector3(-100, 0, 0);
const p2 = new THREE.Vector3(50, 100, 0);
const p3 = new THREE.Vector3(100, 0, 100);
const p4 = new THREE.Vector3(100, 0, 0);

const curve = new THREE.CubicBezierCurve3(p1, p2, p3, p4);
const pointsArr = curve.getPoints(20);

const geometry = new THREE.BufferGeometry();
geometry.setFromPoints(pointsArr);

const material = new THREE.LineBasicMaterial({
    color: new THREE.Color('orange')
});

const line = new THREE.Line(geometry, material);

const geometry2 = new THREE.BufferGeometry();
geometry2.setFromPoints([p1, p2, p3, p4]);
const material2 = new THREE.PointsMaterial({
    color: new THREE.Color('pink'),
    size: 5
});
const points2 = new THREE.Points(geometry2, material2);
const line2 = new THREE.Line(geometry2, new THREE.LineBasicMaterial());
line.add(points2, line2);

export default line;

CurvePath

多个曲线组合起来变成一个全新的曲线

用法

import * as THREE from 'three';

const p1 = new THREE.Vector2(100, 100);
const p2 = new THREE.Vector2(0, 0);
const line1 = new THREE.LineCurve(p1, p2)

const p3 = new THREE.Vector2(-100, 100);
const p4 = new THREE.Vector2(0, 0);
const line2 = new THREE.LineCurve(p3, p4)

const arc = new THREE.EllipseCurve(0, 100, 100, 100, 0, Math.PI);

const curvePath = new THREE.CurvePath();

curvePath.add(line1);
curvePath.add(arc);
curvePath.add(line2);

const ponitArr = curvePath.getPoints(100);

const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(ponitArr), new THREE.LineBasicMaterial({ color: new THREE.Color('yellow') }));

export default line

##DevEco Studio##如何让模拟器里有图片?【图片下载法】

 API9和API12在模拟器上,有一个巨大的区别,那就是API9(开发工具3的版本),他的模拟器里有一个拍照功能(再往前的版本里甚至还有浏览器,可以通过浏览器下载图片),可以通过拍照功能让相册里有图片,从而测试图片相关的功能。在API12的模拟器中,虽然有图库,但是没有拍照,也没有浏览器……看起来似乎没有办法在模拟器里测试图片的相关功能。

不过大部分用做原生鸿蒙系统项目的学生并不具备有真机调试的能力……所以这个问题还是要想办法解决

前面的文章我分享了一个【文件拖入法】,这个方法虽然简单,但是问题就是在于“图片”属于“文件”,而不是在图库中,没办法进行与图片相关的一些操作。

那么在平时的开发过程中,有一次我开发的项目需要将图片下载到本地,通过文档,我了解到了“安全控件”中的“保存控件“,简单来说,就是可以超级方便的将图片进行保存,相比于传统的方案,代码中不需要考虑授权,不需要考虑选择文件的相关操作。经过测试,完全可以通过这个"保存控件",将图片下载到模拟器的图库中用于后续的操作!

0900086000300134184.20201216095126.86523331460016843504112994983392.png0900086000300134184.20201216095126.86523331460016843504112994983392.png

那么具体的实现方案如下:

  1. 去创建一个新的项目,随便起个名字

  2. 把你想用来测试的图片,放到本地resources/base/media文件夹下

  3. 加入“保存控件”,代码如下:

SaveButton()
          .padding({top: 12, bottom: 12, left: 24, right: 24})
          .onClick(async (event: ClickEvent, result: SaveButtonOnClickResult) => {
            if (result === SaveButtonOnClickResult.SUCCESS) {
              const context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
              // 免去权限申请和权限请求等环节,获得临时授权,保存对应图片。
              savePhotoToGallery(context);
            } else {
              promptAction.showToast({ message: '设置权限失败!' })
            }
          })



async function savePhotoToGallery(context: common.UIAbilityContext) {
  let helper = photoAccessHelper.getPhotoAccessHelper(context);
  try {
    // onClick触发后10秒内通过createAsset接口创建图片文件,10秒后createAsset权限收回。
    let uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');
    // 使用uri打开文件,可以持续写入内容,写入过程不受时间限制。
    let file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
    // $r('app.media.startIcon')需要替换为开发者所需的图像资源文件。
    context.resourceManager.getMediaContent($r('app.media.startIcon').id, 0)
      .then(async value => {
        let media = value.buffer;
        // 写到媒体库文件中。
        await fileIo.write(file.fd, media);
        await fileIo.close(file.fd);
        promptAction.showToast({ message: '已保存至相册!' });
      });
  }
  catch (error) {
    const err: BusinessError = error as BusinessError;
    console.error(`Failed to save photo. Code is ${err.code}, message is ${err.message}`);
  }
}

4. 启动模拟器并运行项目,点击按钮下载即可

  1. 再运行你自己的项目,去选择图片即可

这时候就能看到刚才的图片了

0900086000300134184.20201216095126.86523331460016843504112994983392.png

HTML转PDF导出下载

一、项目背景与需求

在开发过程中,我们常常需要将页面中的某些内容(如表格、图表或文本)导出为 PDF 文件,方便用户保存或打印。Vue 3 作为目前流行的前端框架,提供了强大的组件化开发能力,但本身并不直接支持 PDF 导出功能。因此,我们需要借助第三方库来实现这一目标。

jsPDF 是一个流行的 JavaScript 库,用于生成 PDF 文档。它支持多种格式和操作,可以方便地将文本、图像等内容添加到 PDF 中。而 html2canvas 则是一个用于将 HTML 元素渲染为 Canvas 图像的库。通过将 HTML 内容转换为图像,再将其添加到 PDF 中,我们可以实现 HTML 到 PDF 的转换。

二、实现步骤

(一)安装依赖

在 Vue 3 项目中,首先需要安装 jsPDFhtml2canvas。打开终端,运行以下命令:

npm install jspdf html2canvas

(二)创建JS文件

接下来,创建一个新的 JS 文件(例如 htmlDownPdf.js),并引入所需的库。以下是完整的代码实现:

import html2Canvas from 'html2canvas'
import jsPDF from 'jspdf'

export function htmlDownPdf(html, name) {
    // 创建一个新的 jsPDF 实例
    const pdf = new jsPDF();
    // 遍历每个表格
    html.forEach((table, index) => {
        html2Canvas(table, {
            dpi: window.devicePixelRatio * 4, // 设置渲染的分辨率
            scale: 4 // 设置渲染的缩放比例
        }).then((canvas) => {
            console.log(canvas);

            // 将 Canvas 转换为 图片
            const imgData = canvas.toDataURL('image/jpeg', 1);
            const imgWidth = 200; // 显示区域宽度
            const imgHeight = (canvas.height * imgWidth) / canvas.width;

            // 如果不是第一页,添加新页面
            if (index > 0) {
                pdf.addPage();
            }
            // 将图片添加到 PDF 页面
            pdf.addImage(imgData, 'JPEG', 5, 5, imgWidth, imgHeight);
            // 如果是最后一个表格,保存 PDF
            if (index === html.length - 1) {
                pdf.save(name + '.pdf');
            }
        });
    });
}

(三)组件功能解析

  1. HTML 结构

    • 一个按钮用于触发 PDF 导出操作。
    • 一个 div 容器(#contentToPrint)包含需要导出为 PDF 的内容。
    • div 容器包括多个子容器(.fileTable)用于进行分页。
  2. 导出逻辑

    • 使用 html2canvas 将指定的 HTML 元素转换为 Canvas 图像。
    • 将 Canvas 图像转换为 Base64 数据(imgData)。
    • 使用 jsPDF 创建一个 PDF 文档,并将图像添加到 PDF 中。
    • 设置 PDF 的页面方向、单位和大小。
    • 调用 pdf.save() 方法保存 PDF 文件。

(四)样式优化

为了确保导出的内容在各类大小屏幕中显示正常,我们为 #contentToPrint 添加了 Class样式 ,包括宽度限制等。

(五)使用组件

在父组件中引入并使用 htmlDownPdf 方法:

<template>
  <div id="contentToPrint">
      <table border="1" cellspacing="1" class="fileTable">
                <tbody class="fileTbody"></tbody>
       </table>
       <table border="1" cellspacing="1" class="fileTable">
                <tbody class="fileTbody"></tbody>
       </table>
       
       <el-button type="primary" @click="downShouliBook('#contentToPrint', '申请表','.fileTable')">下载申请表</el-button>
  </div>
</template>
<script setup>
import { htmlDownPdf } from '@/utils/htmlDownPdf';

function downShouliBook(html, name, sonHtml = '') {
  const selectData = document.querySelector(html); // 需要打印的DOM名称
  selectData.classList.add('isNoneDisabled'); // 为需要打印的DOM添加样式
  if (sonHtml) {
    const tables = selectData.querySelectorAll(sonHtml); // 根据分页的需求,获取所有的子DOM集合
    htmlDownPdf(tables, name); // 调用导出方法
  } else {
    htmlDownPdf([selectData], name); // 调用导出方法
  }
  const loading = ElLoading.service({
    lock: true,
    text: '下载中...',
    background: 'rgba(0, 0, 0, 0.7)',
  }); // 开启loading
  loading.close(); // 关闭loading
  selectData.classList.remove('isNoneDisabled'); // 去除样式
}
</script>
<style lang="scss" scoped>
.isNoneDisabled {
    width: 1200px;
 }
</style>

三、功能说明与注意事项

(一)功能说明

  1. html2canvas

    • 将 HTML 元素转换为 Canvas 图像,支持自定义配置,例如缩放比例(scale)和跨域请求(useCORS)。
    • 转换后的图像可以作为 PDF 的内容源。
  2. jsPDF

    • 创建 PDF 文档,并支持多种操作,如添加图像、文本等。
    • 可以设置 PDF 的页面方向、单位和大小。
    • 提供 save() 方法,允许用户下载生成的 PDF 文件。

(二)注意事项

  1. 跨域问题

    • 如果 HTML 内容中包含外部资源(如图片),需要确保服务器支持跨域请求(CORS)。否则,html2canvas 可能无法正确加载这些资源。

四、示例效果

点击“导出为 PDF”按钮后,页面中的指定内容(#contentToPrint)将被转换为 PDF 文件并下载。生成的 PDF 文件将包含与页面中显示的内容一致的图像,用户可以保存或打印该文件。

玩转TreeSelect 组件

以下是关于 TreeSelect组件 的综合使用指南,涵盖主流框架(Vue、React、Ant Design等)的核心功能、配置技巧及高级用法,助你快速掌握树形选择组件的开发精髓。


一、基础使用(以Vant和Ant Design为例)

1. Vant TreeSelect

  • 安装npm install vant

  • 引入与配置

    <template>
      <tree-select :items="items" :main-active-index="0" :active-id="activeId" @click-nav="onClickNav" @click-item="onClickItem" />
    </template>
    <script>
    import { TreeSelect } from 'vant';
    export default {
      components: { [TreeSelect.name]: TreeSelect },
      data() {
        return {
          items: [/* 树形数据结构 */],
          activeId: 1
        };
      }
    };
    </script>
    
    • 关键属性items(树形数据)、main-active-index(导航栏选中索引)、active-id(当前选中节点ID)。
    • 事件监听@click-nav(导航栏点击)、@click-item(节点点击)。

2. Ant Design TreeSelect

  • 安装npm install antd

  • 配置示例

    import { TreeSelect } from 'antd';
    const treeData = [/* 树形数据 */];
    function onChange(value) { console.log(value); }
    <TreeSelect
      treeData={treeData}
      onChange={onChange}
      showSearch
      style={{ width: '100%' }}
    />
    
    • 核心功能:支持搜索(showSearch)、多选(multiple)、异步加载(loadData)。

二、Vue生态中的TreeSelect组件

1. Vue-Treeselect

  • 安装与引入

    npm install @riophae/vue-treeselect
    
    <template>
      <treeselect v-model="value" :options="options" />
    </template>
    <script>
    import Treeselect from '@riophae/vue-treeselect';
    import '@riophae/vue-treeselect/dist/vue-treeselect.css'; // 必须引入样式
    export default { components: { Treeselect } };
    </script>
    
  • 关键配置

    • multiple:多选模式。
    • appendToBody:解决下拉层被遮挡问题。
    • normalizer:自定义数据格式适配。

2. Element UI组合实现

  • 实现思路:结合el-selectel-tree组件,通过插槽嵌入树形结构。

    <el-select v-model="selectedNode">
      <el-option v-for="item in options" :key="item.value" :value="item.value" />
      <el-tree slot="dropdown" :data="treeData" @node-click="handleNodeClick" />
    </el-select>
    
  • 特点:支持动态展开/收起、自定义节点渲染。


三、Ant Design TreeSelect高级功能

1. 节点禁用与选择控制

const treeData = [
  { title: '禁用节点', value: '0-0', disabled: true },
  { title: '不可选父节点', value: '0-1', checkable: false, children: [/* ... */] }
];
  • 属性disabled(禁用节点)、checkable(禁用复选框)、selectable(禁用单选)。

2. 异步加载与动态数据

<TreeSelect
  loadData={({ key, children }) => {
    // 模拟异步加载子节点
    fetch(`/api/nodes/${key}`).then(data => children.push(...data));
  }}
/>
  • 场景:大数据量或层级动态加载时使用。

四、通用技巧与最佳实践

  1. 数据结构设计

    • 确保数据包含idlabelchildren等字段,支持嵌套层级。

    • 示例:

      const data = [
        { id: 1, label: '父节点', children: [/* 子节点 */] }
      ];
      
  2. 性能优化

    • 懒加载:仅加载当前可见节点(如Ant Design的loadData)。
    • 虚拟滚动:对超大数据集使用虚拟化技术(如vue-virtual-scroller)。
  3. 样式定制

    • 通过CSS覆盖默认样式,或使用dropdownClassName指定自定义类名。
  4. 事件处理

    • 监听changenode-click等事件,结合v-model实现双向绑定。

五、常见问题解决

  • 样式丢失:确保引入组件库的CSS文件(如Vue-Treeselect需手动引入样式)。
  • 值重置失败:Vue中需将v-model设为undefined而非空字符串。
  • 下拉层遮挡:设置appendToBody: true或调整z-index

六、扩展场景

  • 多级联动:结合表单验证,实现父子节点联动选择。
  • 自定义节点内容:通过插槽或render函数渲染复杂内容(如图标、按钮)。
  • 国际化支持:根据语言动态切换节点文本。

通过以上指南,你可以灵活运用TreeSelect组件应对各种树形选择场景:)。如需深入某个框架的细节,可参考对应文档或搜索结果中的代码示例。

vscode扩展开发实战篇从0到1详细教学,附源码

vscode扩展开发从0到1实战篇,详细教学,附源码

前言

在我接手公司项目后,最开始的多语言翻译工作是在公司开发的多语言网站上进行文字翻译,然后复制词条到项目替换对应的文字,最后还要下载多语言文件到项目里,整个过程耗时耗力。作为爱偷懒的程序员肯定是不想干这种脏活,就开始琢磨怎么解放双手,提升效率,于是就开发了这个多语言翻译和下载的扩展。

实战是学习技术最快的方式,本文将从0到1记录下多语言翻译扩展从创建、开发到发布的完整的开发过程,供大家学习参考。

项目概述

这是一个VSCode扩展,主要用于处理多语言翻译相关的功能。该扩展提供了一系列工具,方便进行多语言翻译和管理,包括:

  • 添加翻译标记(##标签)
  • 一键翻译多语言内容
  • 复制多语言文件到项目
  • 匹配并显示翻译内容在左侧视图

📖 扩展开发学习要点

本扩展涉及以下 VSCode 扩展开发知识点: 看完这个项目,你可以学到以下知识点

  1. 基础概念

    • 扩展激活事件
    • 命令注册与实现
    • 快捷键绑定
  2. 核心 API 使用

    • 文本选择与编辑
    • 文件系统操作
    • 配置管理
  3. 进阶特性

    • 左侧自定义视图的使用
    • API 调用集成
    • 错误处理

VSCode扩展开发完整流程

1. 创建VSCode扩展项目

1.1 环境准备

在开始创建VSCode扩展之前,需要确保已安装以下工具:

  • Node.js(推荐使用LTS版本)
  • npmpnpm包管理工具
  • Visual Studio Code编辑器
  • YeomanVS Code Extension Generator

安装Yeoman和VS Code Extension Generator:

npm install -g yo generator-code
1.2 创建项目

使用VS Code Extension Generator创建一个新的扩展项目:

# 创建一个新的文件夹,然后运行
 yo code

在运行yo code命令后,会出现一系列问题,根据需要进行选择:

  1. 选择扩展类型:New Extension (TypeScript)
  2. 输入扩展名称:workextension
  3. 输入标识符:workextension
  4. 输入描述:多语言翻译工具
  5. 是否初始化Git仓库:根据需要选择
  6. 包管理器选择:npmpnpm

test.png

命令执行完成后,会生成一个基本的VSCode扩展项目结构。

1.3 项目结构

生成的项目结构如下:

.
├── .vscode/             # VSCode配置文件
├── src/                 # 源代码目录
│   └── extension.ts     # 扩展入口文件
├── package.json         # 项目配置文件
├── tsconfig.json        # TypeScript配置
└── README.md            # 项目说明文档

2. 扩展配置

2.1 package.json配置

package.json是VSCode扩展的核心配置文件,定义了扩展的元数据、激活事件、命令、菜单等。以下是我们多语言翻译扩展的关键配置:

{
  "name": "workextension",
  "displayName": "workextension",
  "description": "",
  "version": "0.0.1",
  "engines": {
    "vscode": "^1.85.0"
  },
  "categories": [
    "Other"
  ],
  "activationEvents": [],
  "main": "./dist/extension.js",
  "contributes": {
    "keybindings": [
      {
        "command": "workExtension.addHashTags",
        "key": "ctrl+3",
        "mac": "ctrl+3",
        "when": "editorTextFocus"
      }
    ],
    "commands": [
      {
        "command": "workExtension.translationI18n",
        "title": "翻译多语言"
      },
      {
        "command": "workExtension.addHashTags",
        "title": "添加##标签"
      },
      {
        "command": "workExtension.copyI18n",
        "title": "复制多语言到项目"
      },
      {
        "command": "workExtension.matchAndShow",
        "title": "匹配并显示内容"
      }
    ],
    "menus": {
      "editor/context": [
        {
          "when": "editorFocus",
          "command": "workExtension.translationI18n"
        },
        {
          "when": "editorFocus",
          "command": "workExtension.addHashTags"
        }
      ],
      "view/title": [
        {
          "command": "workExtension.matchAndShow",
          "when": "view == workExtensionActivity",
          "group": "navigation@1"
        },
        {
          "command": "workExtension.copyI18n",
          "when": "view == workExtensionActivity",
          "group": "navigation@2"
        }
      ]
    },
    "viewsContainers": {
      "activitybar": [
        {
          "id": "workExtension-explorer",
          "title": "工作扩展",
          "icon": "icons/auto.svg"
        }
      ]
    },
    "views": {
      "workExtension-explorer": [
        {
          "id": "workExtensionActivity",
          "name": "workExtension"
        }
      ]
    }
  }
}

主要配置说明:

  • keybindings:定义快捷键,如Ctrl+3用于添加##标签
  • commands:定义扩展提供的命令
  • menus:定义命令在哪些菜单中显示
  • viewsContainers:定义活动栏中的自定义视图容器
  • views:定义视图容器中的视图

3. 核心功能实现

3.1 扩展入口文件

扩展的入口文件extension.ts负责激活扩展并注册命令。以下是我们的入口文件实现:

import * as vscode from 'vscode';
import { Command } from './typings';
import { getAllCommands } from './commands';
import { DataProvider } from './class/dataProvider';
import { ViewItem } from './class/view-item';

export function activate(context: vscode.ExtensionContext) {
  // 获取所有命令
  const commands: Array<Command> = getAllCommands();
  // 遍历所有命令,注册命令
  for (const { name, handler } of commands) {
    const disposable = vscode.commands.registerCommand(name, (viewItem:ViewItem) => {
      handler(provider,viewItem);
    });
    context.subscriptions.push(disposable)
  }
  const provider = new DataProvider(context);
  vscode.window.registerTreeDataProvider('workExtensionActivity', provider);
}

export function deactivate() { }

这里我们使用了模块化的方式组织代码,通过getAllCommands()获取所有命令,然后遍历注册。同时,我们还注册了一个树视图数据提供者,用于左侧视图的显示。

3.2 命令管理

我们在commands/index.ts中集中管理所有命令:

import { Command } from '../typings';
import { addHashTagsCommand } from './addHashTags';
import { translationI18nCommand } from './translationI18n';
import { copyI18nCommand } from './copyI18n';
import { matchAndShowCommand } from './matchAndShow';

//获取所有命令
export function getAllCommands(): Array<Command> {
  return [
    translationI18nCommand(),  //翻译多语言功能
    addHashTagsCommand(),//添加翻译标记功能
    copyI18nCommand(),//复制多语言文件功能
    matchAndShowCommand(),//在左侧视图显示匹配的内容
  ];
}
3.3 添加翻译标记功能

addHashTags.ts实现了添加翻译标记的功能,将选中的文本添加##前后缀:

import * as vscode from 'vscode';
import { Command, } from '../typings';

/**
 * 添加多语言翻译标记命令
 * 该命令用于在选中的文本前后添加 ## 标记,用于后续的多语言翻译处理
 * 使用方式:选中文本后按下快捷键或通过命令面板触发
 */
export function addHashTagsCommand(): Command {
    return {
        // 注册的命令名称,在 package.json 中需要与 contributes.commands 对应
        name: 'workExtension.addHashTags',
        
        /**
         * 命令处理函数
         * 将选中的文本添加 ## 前后缀标记
         * 例如:'用户名' -> '##用户名##'
         */
        handler: async () => {
            // 获取当前活动的编辑器实例
            const editor = vscode.window.activeTextEditor;
            if (!editor) {
                return; // 如果没有打开的编辑器,直接返回
            }

            // 使用 editor.edit 进行文本编辑
            editor.edit((editBuilder: vscode.TextEditorEdit) => {
                // 获取当前选中的文本区域
                const selection = editor.selection;
                // 获取选中区域的文本内容
                const text = editor.document.getText(selection);
                // 在文本前后添加 ## 标记
                const modifiedText = `##${text}##`;
                // 使用新文本替换选中区域的内容
                editBuilder.replace(selection, modifiedText);
            });
        },
    };
}
3.4 翻译多语言功能

translationI18n.ts实现了翻译多语言的功能,通过调用公司API接口将标记的文本翻译成多语言,还增加了一些接口调用报错写入文件的处理。以下代码是针对我司API的处理,仅供参考。

import * as vscode from 'vscode';
import workspace from "../class/workspace";
import { Command } from '../typings';
import fs from 'fs/promises';
import path from 'path';
import dayjs from 'dayjs';
import http from '../class/request';

export function translationI18nCommand(): Command {
  return {
    name: 'workExtension.translationI18n',
    handler: async () => {
      const baseConfig = workspace.getRequestConfig();
      const translationI18nProjectType = workspace.get('translationI18nProjectType');

      // 获取多语言的Alias
      async function geti18n(params: any) {
        const res = await http.post('/MultiLanguageResource/AddResource', params, baseConfig);
        if (res.Data) {
          return res.Data.ResourceAlias;
        } else {
          // 处理错误
          vscode.window.showErrorMessage('Error: 翻译报错');
          const formattedTime = dayjs().format('YYYY-MM-DD HH:mm');
          const errorMessage = `错误消息:${formattedTime}${JSON.stringify(res)}`;
          const workspaceFolder = vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders[0] : null;
          if (!workspaceFolder) {
            vscode.window.showErrorMessage('Error: 无法写入日志文件,因为未找到工作区文件夹。');
            return '';
          }
          const logFilePath = path.join(workspaceFolder.uri.fsPath, 'log.txt');
          await fs.appendFile(logFilePath, errorMessage);
          return '';
        }
      }

      // 使用 withProgress 来显示进度
      await vscode.window.withProgress({
        location: vscode.ProgressLocation.Notification,
        title: "正在翻译文档...",
        cancellable: true
      }, async (progress, token) => {
        // 获取当前编辑器
        const editor = vscode.window.activeTextEditor;
        if (!editor) {
          vscode.window.showErrorMessage('没有打开的编辑器');
          return;
        }

        // 获取文档文本
        const documentText = editor.document.getText();
        // 匹配所有 ##...## 格式的文本
        const matches = documentText.match(/##([\S\s]+?)##/g);

        if (!matches || matches.length === 0) {
          vscode.window.showInformationMessage('没有找到需要翻译的内容');
          return;
        }

        // 处理每个匹配项
        for (let i = 0; i < matches.length; i++) {
          const match = matches[i];
          const text = match.substring(2, match.length - 2); // 去掉前后的##
          
          // 调用翻译API
          const alias = await geti18n({
            ResourceName: text,
            ProjectType: translationI18nProjectType
          });

          if (alias) {
            // 替换文本
            const newDocumentText = editor.document.getText().replace(match, alias);
            const fullRange = new vscode.Range(
              editor.document.positionAt(0),
              editor.document.positionAt(editor.document.getText().length)
            );
            
            // 应用编辑
            await editor.edit(editBuilder => {
              editBuilder.replace(fullRange, newDocumentText);
            });
          }

          // 更新进度
          progress.report({ increment: (100 / matches.length), message: `已翻译 ${i + 1}/${matches.length}` });
        }

        vscode.window.showInformationMessage('翻译完成!');
      });
    },
  };
}
3.5 复制多语言文件功能

copyI18n.ts实现了复制多语言文件到项目的功能:

import * as vscode from 'vscode';
import { Command, } from '../typings';
import workspace from '../class/workspace';
import path from 'path';
import fs from 'fs';
import axios from 'axios';

// 复制多语言到项目
export function copyI18nCommand(): Command {
    return {
        name: 'workExtension.copyI18n',
        handler: async () => {
            // 获取当前活动的工作区文件夹
            const workspaceFolder = vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders[0] : null;
            if (!workspaceFolder) {
                vscode.window.showErrorMessage('没有打开的工作区文件夹。');
                return;
            }
            
            const translationI18nProjectType = workspace.get('translationI18nProjectType')
            let langArr = [{ name: 'ZhCn', value: 1, filePath: 'ZhCn.js' },]
            // 目前只有企业版需要英文文件
            if (translationI18nProjectType === 5500) {
                langArr.push({ name: 'EnUs', value: 2, filePath: 'EnUs.js' })
            }
            
            // 获取工作区根路径
            const rootPath = workspaceFolder.uri.fsPath;
            
            // 下载文件函数
            async function downFile(language: number) {
                const translationI18nService = workspace.get('translationI18nService')
                const translationI18nToken = workspace.get('translationI18nToken')
                const res = await axios({
                    method: 'get',
                    url: `/MultiLanguageResource/DownWebJson?language=${language}`,
                    baseURL: translationI18nService,
                    responseType: 'stream',
                    headers: {
                        'Cookie': translationI18nToken, // 添加 token 到请求头
                    }
                })
                return res
            }
            
            try {
                // 遍历语言数组,下载并保存文件
                for (let lang of langArr) {
                    const langTargetPath = path.join(rootPath, 'src/lang/source', lang.filePath);
                    const tempFilePath = path.join(rootPath, lang.filePath);
                    
                    // 确保目标目录存在
                    const targetDir = path.dirname(langTargetPath);
                    if (!fs.existsSync(targetDir)) {
                        fs.mkdirSync(targetDir, { recursive: true });
                    }
                    
                    // 下载文件
                    const response = await downFile(lang.value);
                    
                    // 创建写入流并保存文件
                    const writer = fs.createWriteStream(tempFilePath);
                    response.data.pipe(writer);
                    
                    // 等待文件写入完成
                    await new Promise((resolve, reject) => {
                        writer.on('finish', resolve);
                        writer.on('error', reject);
                    });
                    
                    // 移动文件到目标位置
                    fs.renameSync(tempFilePath, langTargetPath);
                    
                    vscode.window.showInformationMessage(`${lang.name}多语言文件下载成功!`);
                }
            } catch (error) {
                vscode.window.showErrorMessage(`下载多语言文件失败: ${error.message}`);
            }
        },
    };
}
3.6 匹配并显示内容功能

matchAndShow.ts实现了匹配并显示文件中的多语言标记的功能:

import * as vscode from 'vscode';
import { Command, } from '../typings';

/**
 * 实现左侧视图匹配并显示文件中的多语言标记
 * 该命令会扫描当前打开的文件,查找所有 ##...## 格式的多语言标记
 * 并将结果显示在左侧视图中
 */
export function matchAndShowCommand(): Command {
    return {
        name: 'workExtension.matchAndShow',
        handler: async (
            provider, // TreeDataProvider实例,用于更新左侧视图
            viewItem  // 视图项数据
        ) => {
            // 获取当前活动的编辑器
            const editor = vscode.window.activeTextEditor;
            if (!editor) {
                return; // 如果没有打开的编辑器,则直接返回
            }

            // 获取当前文档的全部文本内容
            const documentText = editor.document.getText();
            
            // 使用正则表达式匹配所有 ##...## 格式的文本
            // [\S\s] 匹配任意字符(包括换行)
            // +? 非贪婪模式匹配,确保正确匹配嵌套的标记
            const matches = documentText.match(/##([\S\s]+?)##/g); 

            // 更新左侧视图的数据
            if (matches) {
                provider.setMatches(matches); // 有匹配项时更新数据
            } else {
                provider.setMatches([]); // 没有匹配项时清空数据
            }
        },
    };
}

4. 自定义视图实现

4.1 数据提供者

dataProvider.ts实现了树视图数据提供者,用于左侧视图的显示: sidebar.png

import * as vscode from "vscode";
import { ViewItem } from "./view-item";

export class DataProvider implements vscode.TreeDataProvider<ViewItem> {
    // 发布订阅模式,用于通知视图更新
    private _onDidChangeTreeData: vscode.EventEmitter<ViewItem | undefined | null> = new vscode.EventEmitter<ViewItem | undefined | null>();
    readonly onDidChangeTreeData: vscode.Event<ViewItem | undefined | null> = this._onDidChangeTreeData.event;

    // 存储匹配到的多语言标记
    public matches: ViewItem[] = [];

    constructor(private context: vscode.ExtensionContext) { }

    // 设置匹配项并触发视图更新
    public setMatches(matches: string[]) {
        this.matches = matches.map(match => new ViewItem(match));
        this._onDidChangeTreeData.fire(null);
    }

    // 获取树项
    getTreeItem(element: ViewItem): vscode.TreeItem {
        return element;
    }

    // 获取子项
    getChildren(element?: ViewItem): Thenable<ViewItem[]> {
        if (!element) {
            return Promise.resolve(this.matches);
        }
        return Promise.resolve([]);
    }
}
4.2 视图项

view-item.ts实现了视图项,用于在左侧视图中显示匹配到的多语言标记:

import * as vscode from "vscode";

export class ViewItem extends vscode.TreeItem {
    constructor(
        public readonly label: string
    ) {
        super(label);
        this.tooltip = this.label;
        this.description = '匹配到的内容';
    }
}

5. 工作区配置管理

workspace.ts实现了工作区配置管理,用于获取和保存配置项:

import * as vscode from "vscode";
import { WorkspaceConfiguration } from "../typings";
import { CONFIG_TAG } from "../constants";
import { AxiosRequestConfig } from "axios";

// 用于获取配置项的类
export class Workspace {
    public constructor() {}

    // 获取配置项
    public get<T extends keyof WorkspaceConfiguration>(key: T): WorkspaceConfiguration[T] {
        const config = vscode.workspace.getConfiguration(CONFIG_TAG);
        return config.get(key) as WorkspaceConfiguration[T];
    }

    // 获取翻译服务的请求配置
    public getRequestConfig(): AxiosRequestConfig {
        const config = vscode.workspace.getConfiguration(CONFIG_TAG);
        const requestConfig = {
            baseURL: config.get<string>('translationI18nService') || '',
            headers: {
                'Cookie': config.get<string>('translationI18nToken') || '',
                'Content-Type': 'application/json'
            }
        }
        return requestConfig
    }

    // 获取全局配置项
    public getGlobal(key: string): any {
        const config = vscode.workspace.getConfiguration();
        return config.get(key);
    }

    // 保存配置项
    public save<T extends keyof WorkspaceConfiguration>(key: T, value: WorkspaceConfiguration[T]): Promise<void> {
        const config = vscode.workspace.getConfiguration(CONFIG_TAG);
        config.update(key, value, false);
        return Promise.resolve();
    }
}

export default new Workspace();

6. 调试与测试

6.1 调试扩展

VSCode提供了强大的调试功能,可以方便地调试扩展。在.vscode/launch.json文件中已经配置好了调试设置:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run Extension",
      "type": "extensionHost",
      "request": "launch",
      "args": ["--extensionDevelopmentPath=${workspaceFolder}"],
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
      "preLaunchTask": "${defaultBuildTask}"
    }
  ]
}

调试步骤:

  1. 按下F5或点击调试面板中的运行按钮
  2. VSCode会启动一个新的扩展开发主机窗口
  3. 在这个窗口中,可以测试扩展功能
6.2 测试扩展功能

测试多语言翻译扩展的各项功能:

  1. 添加翻译标记

    • 在编辑器中选中文本
    • 按下Ctrl+3快捷键或右键菜单选择"添加##标签"
    • 验证选中的文本是否被添加了##前后缀
  2. 翻译多语言

    • 确保文件中有##...##格式的文本
    • 右键菜单选择"翻译多语言"
    • 验证标记的文本是否被替换为多语言别名
  3. 匹配并显示内容

    • 打开包含##...##格式文本的文件
    • 点击左侧视图中的"匹配并显示内容"按钮
    • 验证左侧视图中是否显示了匹配到的内容
  4. 复制多语言到项目

    • 点击左侧视图中的"复制多语言到项目"按钮
    • 验证多语言文件是否被下载并复制到项目中

7. 打包与发布

7.1 安装vsce工具

使用npm安装vsce工具,用于打包和发布VSCode扩展:

npm install -g @vscode/vsce
7.2 打包扩展

在项目根目录下执行以下命令打包扩展:

vsce package

这将生成一个.vsix文件,可以手动安装或分享给他人。

8. 一些唠叨

本扩展项目源于实际工作中的痛点解决,在开发这个扩展开发完后,不仅为我提升了工作效率,也帮助我学习了VSCode扩展的核心机制,包括命令注册、视图定制、文件操作以及API集成等关键技术点。这些知识不仅适用于多语言翻译场景,也为我今后开发其他类型的扩展奠定了坚实基础。

所以我们程序员在工作中,如果想快速提升,在完成日常工作的同时,就得主动思考如何优化流程、提升效率,在为了解决某一问题而学习相关技术的时候会发现更容易掌握,因为实战比理论更有趣。

源码获取

完整源码已上传到GitHub,欢迎各位前端er下载学习和提出改进建议:

github项目地址

如果这个项目对你有所启发,希望点个Star支持!我将持续更新更多前后端技术进阶内容,一起在技术道路上共同成长。

React源码系列——三分钟了解React Scheduler是如何工作的

React Scheduler 工作原理解析

这份代码是 React 的调度器(Scheduler)实现,主要负责协调和安排各种任务的执行优先级和时机。React 的 Scheduler 是 React 并发模式的核心部分,它允许 React 实现时间切片(time slicing)和优先级调度,使 React 能够中断渲染以响应更高优先级的用户交互。

源码地址:github.com/facebook/re…

思维导图:

image.png (图源:github.com/7kms/react-…

核心概念

1. 任务优先级

Scheduler 定义了5种优先级:

  • ImmediatePriority: 最高优先级,需要立即执行
  • UserBlockingPriority: 用户阻塞优先级,如用户输入、点击等交互
  • NormalPriority: 普通优先级,大多数工作的默认优先级
  • LowPriority: 低优先级
  • IdlePriority: 最低优先级,只在浏览器空闲时执行

每种优先级对应不同的超时时间,决定任务可以延迟多久。

2. 任务队列

Scheduler 维护两个主要队列:

  • taskQueue: 存放已经可以执行的任务
  • timerQueue: 存放延迟执行的任务

这两个队列都是最小堆结构,使得优先级最高的任务总是在堆顶。

关键方法解析

workLoop

workLoop 是调度器的核心循环,负责从任务队列中取出任务并执行:

function workLoop(initialTime) {
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  while (currentTask !== null) {
    if (!enableAlwaysYieldScheduler) {
      if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
        // 任务未过期,且需要让出控制权给浏览器
        break;
      }
    }
    
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      
      // 执行任务回调
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      
      if (typeof continuationCallback === 'function') {
        // 如果回调返回了一个函数,说明任务需要继续执行
        currentTask.callback = continuationCallback;
        advanceTimers(currentTime);
        return true;
      } else {
        // 任务已完成
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
    if (enableAlwaysYieldScheduler) {
      if (currentTask === null || currentTask.expirationTime > currentTime) {
        break;
      }
    }
  }
  
  // 返回是否还有更多工作
  return currentTask !== null;
}

workLoop 的主要工作:

  1. 将到期的定时任务从 timerQueue 移到 taskQueue
  2. taskQueue 中取出最高优先级任务执行
  3. 如果浏览器需要处理其他工作,则中断循环让出控制权
  4. 如果任务返回一个函数,说明任务需要继续执行,将其放回队列

advanceTimers

function advanceTimers(currentTime) {
  // 检查不再延迟的任务并添加到主队列
  let timer = peek(timerQueue);
  while (timer !== null) {
    if (timer.callback === null) {
      // 任务被取消
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      // 任务可以执行了,转移到任务队列
      pop(timerQueue);
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
      if (enableProfiling) {
        markTaskStart(timer, currentTime);
        timer.isQueued = true;
      }
    } else {
      // 剩余计时器仍在等待中
      return;
    }
    timer = peek(timerQueue);
  }
}

这个方法检查 timerQueue 中的延迟任务,如果已经到了开始时间,就将其移动到 taskQueue

unstable_scheduleCallback

function unstable_scheduleCallback(
  priorityLevel,
  callback,
  options,
) {
  const currentTime = getCurrentTime();

  // 确定任务开始时间
  let startTime;
  if (typeof options === 'object' && options !== null) {
    const delay = options.delay;
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }

  // 根据优先级确定超时时间
  let timeout;
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = -1; // 立即超时
      break;
    case UserBlockingPriority:
      timeout = userBlockingPriorityTimeout;
      break;
    case IdlePriority:
      timeout = maxSigned31BitInt; // 永不超时
      break;
    case LowPriority:
      timeout = lowPriorityTimeout;
      break;
    case NormalPriority:
    default:
      timeout = normalPriorityTimeout;
      break;
  }

  const expirationTime = startTime + timeout;

  // 创建新任务
  const newTask = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };

  if (startTime > currentTime) {
    // 这是一个延迟任务,放入定时器队列
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    
    // 如果这是最早的延迟任务,设置一个超时来处理它
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      if (isHostTimeoutScheduled) {
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // 这是一个立即执行的任务,放入任务队列
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
    
    // 安排一个主线程回调来执行任务
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback();
    }
  }

  return newTask;
}

这个方法用于安排一个新任务,根据任务的优先级和延迟时间,将其放入不同的队列并安排适当的执行时机。

shouldYieldToHost

function shouldYieldToHost() {
  if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) {
    // 需要绘制,立即让出控制权
    return true;
  }
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    // 主线程阻塞时间很短,小于一帧,暂不让出
    return false;
  }
  // 让出控制权
  return true;
}

这个方法决定是否应该暂停当前工作,让浏览器有机会处理其他任务(如渲染、用户输入等)。它基于两个因素:

  1. 是否需要绘制(needsPaint
  2. 当前任务已经执行了多长时间(是否超过了帧间隔)

调度算法

React Scheduler 的核心调度算法基于以下几个关键点:

1. 优先级队列

使用最小堆数据结构实现优先级队列,确保高优先级任务先执行。任务按照以下方式排序:

  • 对于 timerQueue,按照 startTime 排序
  • 对于 taskQueue,按照 expirationTime 排序

2. 时间切片

Scheduler 实现了时间切片,通过 shouldYieldToHost 方法来决定是否应该中断当前工作。默认情况下,如果执行时间超过了一帧的时间(约16.6ms),就会中断执行,让浏览器有机会处理其他工作。

3. 延续和中断

任务可以返回一个函数,表示需要继续执行。这允许长时间运行的任务被分割成多个较小的部分,每个部分可以在不同的时间片中执行,从而避免阻塞主线程。

4. 平台适配

Scheduler 使用不同的机制来安排回调,优先使用:

  1. setImmediate(如果可用)
  2. MessageChannel(大多数现代浏览器)
  3. setTimeout(最后的备选方案)

其中 MessageChannel 是最常用的,因为它不受4ms最小超时限制(setTimeout有这个限制)。

总结

React Scheduler 是 React 并发模式的核心部分,允许 React:

  1. 根据优先级安排不同的工作
  2. 将长时间运行的工作分割成小块
  3. 在浏览器需要处理用户交互或渲染时让出控制权
  4. 在浏览器空闲时继续执行低优先级工作

这种设计使 React 能够保持应用的响应性,即使在进行大量计算工作的情况下,也能及时响应用户交互。

flutter应用性能优化最佳实践

文章参考:docs.flutter.cn/perf/best-p…

  1. 控制 build() 方法的耗时

    1. 避免在 build() 方法中进行重复且耗时的工作,因为当父 widget 重建时,子 Wdiget 的 build() 方法会被频繁地调用。

      • 网络请求 fetchData()
      • 频繁解析JSON jsonDecode(hugeJsonString);
      • 复杂计算,频繁创建大对象和集合 List.generate(10000, (index) => index);
    2. 避免在一个超长的 build() 方法中返回一个过于庞大的 widget。把它们分拆成不同的 widget,并进行封装

      • 局部刷新: 当在 State 对象上调用 setState()时,所有后代 widget 都将重建。因此, setState() 的调用转移到其 UI 实际需要更改的 widget 子树部分。 如果改变的部分仅包含在 widget 树的一小部分中,请避免在 widget 树的更高层级中调用 setState()

        • 将状态推送到叶子节点。 例如,如果你的页面上有一个滴答作响的时钟,与其将状态放在页面顶部,并在时钟每次滴答作响时重建整个页面,不如创建一个专门的时钟小部件,使其仅更新自身。
      • Child缓存:当重新遇到与前一帧相同的子 widget 实例时,将停止遍历。这种技术在框架内部大量使用,用于优化动画不影响子树的动画。请参阅 TransitionBuilder 模式,理解把Child缓存起来。

        • 如果子树没有变化,则缓存代表该子树的 Widget,并在每次可以使用时重用它。 为此,请将 Widget 分配给final状态变量,并在 build 方法中重用它。重用 Widget 比创建一个新的(但配置相同的)Widget 效率更高。另一种缓存策略是将 Widget 的可变部分提取到 接受 child 参数的StatefulWidget中。
      • 尽可能使用 const 小部件。(这相当于缓存小部件并重复使用。)这将让 Flutter 的 widget 重建时间大幅缩短。要自动提醒使用 const

      • 在构建可复用的 UI 代码时,最好使用 StatelessWidget 而不是函数。

      • 最小化重建有状态小部件

        • 尽量减少 build 方法及其创建的任何 Widget 所传递创建的节点数量。 理想情况下,有状态 Widget 只会创建一个 Widget,并且该 Widget 应该是一个RenderObjectWidget。(当然,这并不总是可行的,但 Widget 越接近理想状态,效率就越高。)
        • 避免更改任何已创建子树的深度,或更改子树中任何控件的类型。 例如,与其返回子控件本身或包装在IgnorePointer中的子控件,不如始终将子控件包装在IgnorePointer中并控制IgnorePointer.ignoring 属性。这是因为更改子树的深度需要重建、布局和绘制整个子树,而仅更改属性则对渲染树的更改尽可能少(例如,对于IgnorePointer,根本不需要布局或重绘)。
        • 如果由于某种原因必须更改深度,请考虑将子树的公共部分包装到具有 GlobalKey 的 Widget 中,该 GlobalKey 在有状态 Widget 的整个生命周期内保持一致。
  2. 谨慎使用 saveLayer()

    1. 为什么代价会大?触发了离屏渲染
    2. 为什么时候需要?在运行时,如果你需要动态地显示各种形状效果(例如),每个形状都有一定地透明度,可能(或可能不)重叠,那么你几乎必须使用 saveLayer()
    3. 尽量减少 saveLayer 的调用
  3. 尽量减少使用不透明度和裁剪

    1. 使用透明的颜色比透明的Opacity更快:

      • 例如,Container(color: Color.fromRGBO(255, 0, 0, 0.5))比快得多Opacity(opacity: 0.5, child: Container(color: Colors.red))
    2. Clipping 不会调用 saveLayer() (除非明确使用 Clip.antiAliasWithSaveLayer),因此这些操作没有 Opacity 那么耗时,但仍然很耗时,所以请谨慎使用。

    3. 能不用 Opacity widget,就尽量不要用。有关将透明度直接应用于图像的示例,请查看 Transparent image,这比使用 Opacity widget 更快。

    4. 要在图像中实现淡入淡出,请考虑使用 FadeInImage widget,该 widget 使用 GPU 的片段着色器应用渐变不透明度。

    5. 要创建带圆角的矩形,而不是裁剪矩形来达到圆角的效果,请考虑使用很多 widget 都提供的 borderRadius 属性。

    6. 陷进:

      • 避免使用 Opacity widget,尤其是在动画中避免使用。可以使用 AnimatedOpacityFadeInImage 代替该操作。
      • 使用 AnimatedBuilder 时,请避免在不依赖于动画的 widget 的构造方法中构建 widget 树,不然,动画的每次变动都会重建这个 widget 树,应当将这部分子树作为 child 传递给 AnimatedBuilder,从而只构建一次。更多内容
      • 避免在动画中裁剪,尽可能的在动画开始之前预先裁剪图像。
      • 避免在 Widget 对象上重写 operator ==。虽然这看起来有助于避免不必要的重建,但在实践中,它实际上损害了性能,因为这是 O(N²) 的行为。比较 widget 的属性可能比重建 widget 更加有效,也能更少改变 widget 的配置。即使在这种情况下,最好还要缓存 widget,因为哪怕有一次对 operator == 进行覆盖也会导致全面性能的下降,编译器也会因此不再认为调用总是静态的
  4. 谨慎使用网格列表和列表

    1. 懒加载:如果大多数 children widget 在屏幕上不可见,请避免使用返回具体列表的构造函数(例如 Column()ListView()),以避免构建成本。使用ListView.build()
    2. 不要使用shrinkWrap:当列表数据超过100条,就会卡顿。内部组件列表从 ListView 改为 SliverList,用 SliverChildBuilderDelegate 委托
  5. 避免内部传递

    1. 不要先算子widget的大小,再去计算父组件的高度。例如轮询计算高度

      1. 例如,你想要所有单元格都具有或大或小的效果 (或类似需要轮询所有单元格的计算) 时,就会发生内部传递。
      2. 例如,考虑一个大型的 卡片 网格列表时。一个网格列表应该有统一大小的单元格,所以布局代码执行了一次传递,从网格列表的根部开始(在 widget 树中),要求网格列表中的 每个 卡片(不仅仅是可见的卡片)来返回 内部 尺寸—假设没有任何限制,widget 更喜欢这样的尺寸。有了这些信息,底层框架就确定了一个统一的单元格尺寸,并再次重新访问所有的网格单元,告诉每个卡片应该使用什么尺寸。
  6. 隔离重绘区域:自定义的绘制使用RepaintBoundary包裹

终于理解闭包是什么了(含防抖、节流)

闭包(Closure)是编程中一个非常重要的概念,尤其在 JavaScript 中广泛使用。它的核心是函数和其周围状态(词法环境)的组合,可以简单理解为“函数记住了自己被创建时的环境”。


闭包的核心定义

  • 闭包是一个函数,它可以访问并记住自己定义时的作用域(即使该作用域已经执行完毕)。

  • 本质:函数内部嵌套的函数,能够“捕获”外部函数的变量,并长期保留这些变量的引用。

用「背包」来类比

假设你是一个学生,每天背着书包去上学。闭包就像你的书包:

  • 书包里有什么? 装着你需要的课本、文具(相当于函数内部需要的变量)。
  • 书包的规则: 你可以在教室里(函数内部)往书包里放东西,即使放学回家了(函数执行完毕) ,书包里的东西依然存在,第二天还能继续用。
  • 关键点: 书包里的东西(变量)只属于你,别人拿不到(私有性)。

闭包的本质: 函数放学回家了(执行完毕),但它依然背着自己的书包(保存了函数创建时的作用域变量),随时可以打开书包用里面的东西。


举个直观的例子 🌰

function outer() {
  const name = "Alice"; // outer 函数的局部变量

  function inner() {
    console.log(name); // inner 函数访问 outer 的变量
  }

  return inner; // 返回 inner 函数
}

const myFunc = outer(); // outer 执行完毕,按理说 name 应该被销毁
myFunc(); // 输出 "Alice" ✅——闭包让 inner 记住了 name 的值!
发生了什么?
  1. outer() 执行后,其作用域按理应该被销毁。

  2. inner 函数被返回,且它引用了 outer 中的变量 name

  3. 闭包保留了 name** 的引用**,因此 myFunc() 仍然能访问到 name


闭包的三大特性

  1. 访问外部作用域:内部函数可以访问外部函数的变量。

    1. 灵活控制: 可以用闭包动态生成不同功能的小工具(如多个独立计数器)。
  2. 变量长期存活:即使外部函数已执行完毕,其变量也不会被垃圾回收。

    1. 记住过去: 函数执行后,依然能记住之前的状态(比如游戏存档)。
  3. 变量私有化:闭包中的变量对外部不可见,实现数据封装。

    1. 保护隐私: 像你的日记本,只有你自己能修改(封装私有变量)。


闭包的经典应用场景

1. 模块化开发(封装私有变量)

function createCounter() {
  let count = 0; // 私有变量,外部无法直接访问

  return {
    increment: () => { count++; },
    getCount: () => { return count; }
  };
}

const counter = createCounter();
counter.increment();
console.log(counter.getCount()); // 1
  • 优势count 被保护在闭包内,只能通过暴露的方法修改,避免全局污染。
 // 计数器(你的小金库)
function 创建小金库() {
    let 余额 = 0; 
    
    // 藏在闭包里的钱,外部无法直接修改
    return {
        存钱: (金额) => { 余额 += 金额; },
        查余额: () => { return 余额; }};
    }
    const 我的小金库 = 创建小金库();
我的小金库.存钱(100);
console.log(我的小金库.查余额()); // 100 ✅
  • 闭包的作用:余额 藏在书包里,只通过特定方法操作,防止别人偷钱(保护数据)。

2. 回调函数(保留上下文)

function fetchData(url) {
  let data = null;

  // 模拟异步请求
  setTimeout(() => {
    data = "响应数据";
  }, 1000);

  return { 
    then: (callback) => { 
      setTimeout(() => {
        callback(data); // 回调函数通过闭包访问 data
      }, 1000);
    }
  };
}

fetchData("https://api.example.com")
  .then((data) => console.log(data)); // 1秒后输出 "响应数据"
// 记住你的偏好(比如网页主题)
function 主题切换器(初始主题) {
  let 当前主题 = 初始主题; // 藏在闭包里的主题

  return {
    切换主题: () => {
      当前主题 = 当前主题 === 'light' ? 'dark' : 'light';
      document.body.style.background = 当前主题 === 'dark' ? '#333' : '#fff';
    }
  };
}

const 切换按钮 = 主题切换器('light');
document.getElementById('themeBtn').addEventListener('click', 切换按钮.切换主题);

//闭包的作用: 点击按钮时,函数依然记得 当前主题 的值(即使函数已经执行完),实现状态持久化。

3. 防抖(Debounce)和节流(Throttle)

防抖 ——「电梯关门」的哲学

想象你在电梯里:

  • 规则:电梯门打开后,如果 10秒内 有人按开门按钮,电梯会重新计时10秒;直到 连续10秒没人按按钮,电梯才会关门。
  • 本质只响应最后一次连续操作,避免频繁触发。
 // 代码示例(搜索框输入)
// 闭包的作用: 用闭包保存 timer 变量,让多次触发的回调函数共享同一个计时器(类似电梯记住倒计时状态)。

function debounce(func, delay) {
  let timer; // 藏在闭包里的计时器(电梯的倒计时)

  return function(...args) {
    clearTimeout(timer); // 每次输入都重置计时(类似按开门按钮)
    timer = setTimeout(() => {
      func.apply(this, args); // 延迟结束后执行搜索(电梯关门)
    }, delay);
  };
}

const 输入框 = document.getElementById('search');
输入框.addEventListener('input', debounce(() => {
  console.log('发送搜索请求...'); // 只在用户停止输入后触发
}, 500));
节流 ——「水龙头滴水」的哲学

想象你拧开水龙头:

  • 规则:无论你拧得多快,水龙头 每秒最多滴一滴水,多余的触发被忽略。
  • 本质固定时间间隔内只触发一次,稀释触发频率。
// 代码示例(窗口滚动事件)
// 闭包的作用:用闭包保存 lastTime 变量,记录上一次执行时间,让多次触发的回调函数共享这个时间戳。

function throttle(func, interval) {
  let lastTime = 0; // 藏在闭包里的上一次执行时间(记录上一次滴水时间)

  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= interval) { // 距离上次执行超过间隔时间
      func.apply(this, args); // 执行函数(滴一滴水)
      lastTime = now; // 更新记录时间
    }
  };
}

window.addEventListener('scroll', throttle(() => {
  console.log('处理滚动逻辑...'); // 每 200ms 最多触发一次
}, 200));
防抖 vs 节流的区别
场景 防抖(Debounce) 节流(Throttle)
核心思想 事件停止后才触发 固定间隔触发一次
类比 电梯关门 水龙头滴水
典型应用 搜索框输入、窗口 resize 结束 滚动事件、鼠标移动、射击游戏连发
代码差异 每次触发重置计时器 根据时间间隔判断是否执行
闭包的关键作用
  • 状态持久化:防抖和节流需要记住 timerlastTime,这些变量必须在多次函数调用间共享
  • 变量私有化:避免将 timerlastTime 暴露在全局,防止污染其他代码(类似电梯的倒计时器只能由电梯自己控制)。
如果没有闭包会怎样?
// 没有闭包时,只能将计时器存在全局
let globalTimer; // 💥 全局变量,多个防抖函数会互相覆盖

function debounce(func, delay) {
  return function() {
    clearTimeout(globalTimer); // 所有防抖共享同一个计时器
    globalTimer = setTimeout(func, delay);
  };
}

// 页面中有两个输入框:
const input1 = document.getElementById('input1');
const input2 = document.getElementById('input2');

input1.addEventListener('input', debounce(() => {
  console.log('搜索框1的请求');
}, 500));

input2.addEventListener('input', debounce(() => {
  console.log('搜索框2的请求');
}, 500));

// 问题:当两个输入框同时触发时,它们会互相清除对方的计时器!
// 结果:只有一个请求会被发送,另一个被取消。
// 没有闭包时,只能将上一次执行时间存在全局
let globalLastTime = 0; // 💥 所有节流共享同一个时间戳

function throttle(func, interval) {
  return function() {
    const now = Date.now();
    if (now - globalLastTime >= interval) {
      func();
      globalLastTime = now;
    }
  };
}

// 页面中有两个需要节流的操作:
window.addEventListener('scroll', throttle(() => {
  console.log('处理滚动逻辑');
}, 200));

document.addEventListener('mousemove', throttle(() => {
  console.log('处理鼠标移动');
}, 200));

// 问题:滚动和鼠标移动共享同一个时间戳!
// 结果:滚动触发后,鼠标移动的逻辑会被强制延迟,反之亦然。

闭包的潜在问题

1. 内存泄漏

function leakMemory() {
  const bigData = new Array(1000000).fill("⚠️"); // 大数据

  return function() {
    console.log("闭包引用了 bigData,导致它无法被回收!");
  };
}

const leakedFunc = leakMemory(); // bigData 一直被闭包引用,无法释放
  • 解决方法:在不需要时手动解除引用(如 leakedFunc = null)。
// 内存泄漏: 如果书包里装了大石头(大数据),又不扔,书包会越来越重(内存占用)。
function 装石头() {
  const 大石头 = new Array(1000000).fill("重"); 
  return () => { console.log(大石头[0]); };
}
const 沉重的书包 = 装石头(); // 大石头一直存在内存中!

// 解决: 不用时把书包置空(沉重的书包 = null)。

2. 意外的闭包(常见于循环)

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 输出 33 ❗(var 没有块级作用域)
  }, 100);
}
  • 解决:用 let 替代 var(利用块级作用域),或使用闭包隔离:

    • for (var i = 0; i < 3; i++) {
        (function(j) { // 立即执行函数创建新作用域
          setTimeout(() => {
            console.log(j); // 输出 0, 1, 2 ✅
          }, 100);
        })(i);
      }
      

闭包与其他语言

  • JavaScript:闭包是核心特性,天然支持。

  • Python/Go/Rust:支持闭包,但可能需要显式声明捕获变量。

  • Java/C++ :通过匿名内部类或 Lambda 表达式模拟闭包,限制较多。


总结

  • 闭包的核心:函数 + 定义时的词法环境。
  • 优点:封装数据、模块化开发、保留上下文。
  • 注意事项:避免内存泄漏、正确处理循环中的闭包。
  • 哲学:闭包是“时间胶囊”,让函数穿越时空,记住过去的状态。
❌