普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月7日技术

Vue 3 Composition API深度解析:构建可复用逻辑的终极方案

作者 bluceli
2026年3月7日 10:13

引言

Vue 3的Composition API是Vue框架最重大的更新之一,它提供了一种全新的组件逻辑组织方式。与传统的Options API相比,Composition API让我们能够更灵活地组织和复用代码逻辑。本文将深入探讨Vue 3 Composition API的8大核心特性,帮助你掌握这个构建可复用逻辑的终极方案。

setup函数基础

1. setup函数的基本使用

setup函数是Composition API的入口点,它在组件创建之前执行。

import { ref, reactive } from 'vue';

export default {
  setup() {
    // 定义响应式数据
    const count = ref(0);
    const user = reactive({
      name: 'Vue 3',
      version: '3.0'
    });

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

    // 返回给模板使用
    return {
      count,
      user,
      increment
    };
  }
};

2. setup函数的参数

setup函数接收两个参数:props和context。

export default {
  props: {
    title: String,
    initialCount: {
      type: Number,
      default: 0
    }
  },
  setup(props, context) {
    // props是响应式的,不能解构
    console.log(props.title);
    
    // context包含attrs、slots、emit等
    const { attrs, slots, emit } = context;
    
    // 触发事件
    const handleClick = () => {
      emit('update', props.initialCount + 1);
    };
    
    return { handleClick };
  }
};

响应式API详解

3. ref与reactive的选择

ref和reactive是创建响应式数据的两种方式,各有适用场景。

import { ref, reactive, toRefs } from 'vue';

// ref - 适合基本类型和单一对象
const count = ref(0);
const message = ref('Hello');

// 访问ref的值需要.value
console.log(count.value);
count.value++;

// reactive - 适合复杂对象
const state = reactive({
  count: 0,
  user: {
    name: 'Vue',
    age: 3
  }
});

// 访问reactive的值不需要.value
console.log(state.count);
state.count++;

// 在模板中自动解包,不需要.value
// <template>
//   <div>{{ count }}</div>
//   <div>{{ state.count }}</div>
// </template>

4. toRefs的使用

当需要从reactive对象中解构属性时,使用toRefs保持响应性。

import { reactive, toRefs } from 'vue';

export default {
  setup() {
    const state = reactive({
      count: 0,
      name: 'Vue 3',
      isActive: true
    });

    // 不推荐 - 失去响应性
    // const { count, name } = state;

    // 推荐 - 使用toRefs保持响应性
    const { count, name, isActive } = toRefs(state);

    const increment = () => {
      count.value++;
    };

    return {
      count,
      name,
      isActive,
      increment
    };
  }
};

计算属性与侦听器

5. computed计算属性

computed用于创建计算属性,支持getter和setter。

import { ref, computed } from 'vue';

export default {
  setup() {
    const firstName = ref('John');
    const lastName = ref('Doe');

    // 只读计算属性
    const fullName = computed(() => {
      return firstName.value + ' ' + lastName.value;
    });

    // 可写计算属性
    const writableFullName = computed({
      get() {
        return firstName.value + ' ' + lastName.value;
      },
      set(value) {
        const [first, last] = value.split(' ');
        firstName.value = first;
        lastName.value = last;
      }
    });

    return {
      firstName,
      lastName,
      fullName,
      writableFullName
    };
  }
};

6. watch与watchEffect

watch和watchEffect用于侦听数据变化。

import { ref, reactive, watch, watchEffect } from 'vue';

export default {
  setup() {
    const count = ref(0);
    const user = reactive({
      name: 'Vue',
      age: 3
    });

    // watchEffect - 自动追踪依赖
    watchEffect(() => {
      console.log(`Count is: ${count.value}`);
      console.log(`User is: ${user.name}`);
    });

    // watch - 显式指定侦听源
    watch(count, (newValue, oldValue) => {
      console.log(`Count changed from ${oldValue} to ${newValue}`);
    });

    // 侦听多个源
    watch([count, () => user.name], ([newCount, newName], [oldCount, oldName]) => {
      console.log(`Count: ${oldCount} -> ${newCount}, Name: ${oldName} -> ${newName}`);
    });

    // watch的配置选项
    watch(
      () => user.name,
      (newValue) => {
        console.log(`Name changed to: ${newValue}`);
      },
      {
        immediate: true,  // 立即执行
        deep: true        // 深度侦听
      }
    );

    return { count, user };
  }
};

生命周期钩子

7. 生命周期钩子的使用

Composition API中的生命周期钩子以on开头。

import { 
  onMounted, 
  onUpdated, 
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount
} from 'vue';

export default {
  setup() {
    onBeforeMount(() => {
      console.log('组件挂载前');
    });

    onMounted(() => {
      console.log('组件已挂载');
      // 可以在这里访问DOM
    });

    onBeforeUpdate(() => {
      console.log('组件更新前');
    });

    onUpdated(() => {
      console.log('组件已更新');
    });

    onBeforeUnmount(() => {
      console.log('组件卸载前');
    });

    onUnmounted(() => {
      console.log('组件已卸载');
      // 清理工作
    });

    return {};
  }
};

自定义组合函数

8. 创建可复用的逻辑

自定义组合函数是Composition API的核心优势,让我们能够提取和复用逻辑。

// useCounter.js - 计数器逻辑
import { ref, computed } from 'vue';

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);

  const increment = () => {
    count.value++;
  };

  const decrement = () => {
    count.value--;
  };

  const reset = () => {
    count.value = initialValue;
  };

  const double = computed(() => count.value * 2);

  return {
    count,
    increment,
    decrement,
    reset,
    double
  };
}

// useMouse.js - 鼠标位置追踪
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  const update = (event) => {
    x.value = event.pageX;
    y.value = event.pageY;
  };

  onMounted(() => {
    window.addEventListener('mousemove', update);
  });

  onUnmounted(() => {
    window.removeEventListener('mousemove', update);
  });

  return { x, y };
}

// 在组件中使用
import { useCounter, useMouse } from './composables';

export default {
  setup() {
    const { count, increment, decrement, double } = useCounter(10);
    const { x, y } = useMouse();

    return {
      count,
      increment,
      decrement,
      double,
      x,
      y
    };
  }
};

依赖注入

9. provide与inject

provide和inject用于跨组件层级传递数据。

// 父组件
import { provide, ref } from 'vue';

export default {
  setup() {
    const theme = ref('dark');
    const user = ref({
      name: 'Vue User',
      role: 'admin'
    });

    // 提供数据
    provide('theme', theme);
);
    provide('user', user);

    return { theme };
  }
};

// 子组件
import { inject } from 'vue';

export default {
  setup() {
    // 注入数据
    const theme = inject('theme');
    const user = inject('user');

    // 提供默认值
    const config = inject('config', {
      debug: false,
      version: '1.0'
    });

    return { theme, user, config };
  }
};

模板引用

10. 使用ref获取DOM元素

在Composition API中使用ref获取模板引用。

import { ref, onMounted } from 'vue';

export default {
  setup() {
    // 创建模板引用
    const inputRef = ref(null);
    const listRef = ref(null);

    onMounted(() => {
      // 访问DOM元素
      inputRef.value.focus();
      
      // 访问组件实例
      console.log(listRef.value.items);
    });

    const focusInput = () => {
      inputRef.value.focus();
    };

    return {
      inputRef,
      listRef,
      focusInput
    };
  }
};

// 模板中使用
// <template>
//   <input ref="inputRef" />
//   <MyList ref="listRef" />
// </template>

实战案例

11. 表单处理组合函数

// useForm.js
import { ref, reactive } from 'vue';

export function useForm(initialValues, validationRules) {
  const values = reactive({ ...initialValues });
  const errors = reactive({});
  const touched = reactive({});

  const validate = () => {
    let isValid = true;
    
    for (const field in validationRules) {
      const rules = validationRules[field];
      const value = values[field];
      
      for (const rule of rules) {
        if (rule.required && !value) {
          errors[field] = rule.message || '此字段必填';
          isValid = false;
          break;
        }
        
        if (rule.pattern && !rule.pattern.test(value)) {
          errors[field] = rule.message || '格式不正确';
          isValid = false;
          break;
        }
        
        if (rule.validator && !rule.validator(value)) {
          errors[field] = rule.message || '验证失败';
          isValid = false;
          break;
        }
      }
    }
    
    return isValid;
  };

  const handleChange = (field) => (event) => {
    values[field] = event.target.value;
    touched[field] = true;
    
    if (errors[field]) {
      validate();
    }
  };

  const handleBlur = (field) => () => {
    touched[field] = true;
    validate();
  };

  const reset = () => {
    Object.assign(values, initialValues);
    Object.keys(errors).forEach(key => {
      errors[key] = '';
    });
    Object.keys(touched).forEach(key => {
      touched[key] = false;
    });
  };

  const submit = (callback) => () => {
    if (validate()) {
      callback(values);
    }
  };

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    validate,
    reset,
    submit
  };
}

// 使用示例
export default {
  setup() {
    const { values, errors, handleChange, handleBlur, submit } = useForm(
      {
        username: '',
        email: '',
        password: ''
      },
      {
        username: [
          { required: true, message: '用户名必填' },
          { pattern: /^[a-zA-Z0-9_]{3,20}$/, message: '用户名格式不正确' }
        ],
        email: [
          { required: true, message: '邮箱必填' },
          { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式不正确' }
        ],
        password: [
          { required: true, message: '密码必填' },
          { validator: (value) => value.length >= 6, message: '密码至少6位' }
        ]
      }
    );

    const handleSubmit = submit((formData) => {
      console.log('表单提交:', formData);
      // 发送API请求
    });

    return {
      values,
      errors,
      handleChange,
      handleBlur,
      handleSubmit
    };
  }
};

12. 异步数据获取组合函数

// useAsyncData.js
import { ref, onMounted } from 'vue';

export function useAsyncData(fetchFn, options = {}) {
  const {
    immediate = true,
    initialData = null,
    onSuccess,
    onError
  } = options;

  const data = ref(initialData);
  const loading = ref(false);
  const error = ref(null);

  const execute = async (...args) => {
    loading.value = true;
    error.value = null;

    try {
      const result = await fetchFn(...args);
      data.value = result;
      
      if (onSuccess) {
        onSuccess(result);
      }
      
      return result;
    } catch (err) {
      error.value = err;
      
      if (onError) {
        onError(err);
      }
      
      throw err;
    } finally {
      loading.value = false;
    }
  };

  if (immediate) {
    onMounted(execute);
  }

  return {
    data: data,
    loading: loading,
    error: error,
    execute: execute,
    refresh: execute
  };
}

// 使用示例
export default {
  setup() {
    const { data, loading, error, refresh } = useAsyncData(
      async (userId) => {
        const response = await fetch(`/api/users/${userId}`);
        return response.json();
      },
      {
        immediate: true,
        onSuccess: (data) => {
          console.log('数据加载成功:', data);
        },
        onError: (error) => {
          console.error('数据加载失败:', error);
        }
      }
    );

    return {
      data,
      loading,
      error,
      refresh
    };
  }
};

总结

Vue 3 Composition API为我们提供了更强大、更灵活的代码组织方式:

核心优势

  1. 逻辑复用:通过自定义组合函数轻松复用逻辑
  2. 代码组织:相关逻辑可以组织在一起,而不是分散在options中
  3. 类型推断:更好的TypeScript支持
  4. 灵活性:更灵活的代码组织方式

最佳实践

  1. 合理使用ref和reactive:基本类型用ref,复杂对象用reactive
  2. 提取组合函数:将可复用逻辑提取为独立的组合函数
  3. 保持单一职责:每个组合函数只负责一个功能
  4. 善用toRefs:解构reactive对象时使用toRefs保持响应性
  5. 合理使用生命周期:在setup中正确使用生命周期钩子

学习路径

  1. 掌握setup函数和响应式API
  2. 学习computed和watch的使用
  3. 理解生命周期钩子
  4. 实践自定义组合函数
  5. 掌握依赖注入和模板引用

Composition API不仅是一种新的API,更是一种新的思维方式。它让我们能够以更函数式、更模块化的方式组织代码,提高了代码的可维护性和可测试性。开始在你的项目中使用Composition API吧,体验Vue 3带来的全新开发体验!


本文首发于掘金,欢迎关注我的专栏获取更多前端技术干货!

大道至简 - Juejin Notifier - 掘金消息通知小助手

作者 fthux
2026年3月7日 10:07

1.png

在说正事之前,还是要祝各位彦祖亦菲在新的一年里,身体如龙马般精神健硕,事业如朝阳般蒸蒸日上;愿家中灯火可亲,有爱人相伴,有暖茶在心;愿前路浩浩荡荡,万事尽可期待,所求皆如愿,所行化坦途,岁岁常欢愉,年年皆胜意。新年快乐,阖家安康!

好了,收。RT,这个扩展只做一件事:掘金消息通知,就只是告诉你哪类消息有几条未读,无了。

开发初衷:作为一名合格的牛马,必然是天天坐在电脑前认真的搬砖,能够方便的知道有新消息来了,然后再摸着去网站上看一眼就够了。扩展就应该做扩展该做的事情,如果发文章这类事情都交给扩展来做,那 web 站用来做什么?所以这次扩展来掘城只办三件事:通知通知还是TMD通知。

Juejin Notifier 是一款 Chrome 扩展,通过掘金的官方 API 获取消息通知(顺道显示了一下头像和昵称,咱就确认一下是自己的账号就行了,别整了半天是别人的号)。无需频繁的打开掘金 web 站,即可第一(也可能是第二?)时间获取赞和收藏、评论、新增粉丝、私信、系统通知五类消息动态。

代码已开源,点击这里跳转到 GitHub 仓库 ↗

功能

  • 消息通知:及时获取新消息,不错过任何互动,快速查看各类消息通知条数。

  • 个性化设置:可忽略不关心的消息类型;自定义刷新时间间隔;也可手动刷新。

  • 多主题支持(跟随系统 / 浅色 / 深色)。

截图预览

1.png2.png3.png4.png

安装方法

方式一:Chrome Web Store 安装(推荐)

访问 Chrome Web Store 上的 Juejin Notifier ↗ 页面安装。

image.png

方式二:本地安装(开发者模式)

克隆项目,打开 Chrome 浏览器,进入扩展管理页面 (chrome://extensions/),开启右上角的“开发者模式”,点击“加载已解压的扩展程序”,选择扩展文件夹即可。

写在最后

如果各位彦祖亦菲还有什么过分的需求,尽管开口,我努力做到。

也不是我吹牛,反正在座的各位今年一定百事百顺,父母一定长命百岁,做什么事一定手气爆棚。

如果这款扩展真的帮助到了你,还请给个好评 ↗。如果没帮到你,说明我还有很大的进步空间,也请给个好评 ↗以资鼓励。数据只存在本地,代码已开源,可以点击这里跳转到 GitHub 仓库 ↗查看。

前端权限控制设计

2026年3月7日 09:41

一、展示控制

前端权限控制的目的是,根据当前用户的身份控制其能访问的页面和可执行的操作。需要注意的是:前端权限控制主要是为了提升用户体验(如隐藏无权限的菜单,按钮),正真的数据安全必须依赖后端实现。

二、RBAC

业界主流的权限管理模型是RBAC(基于角色的访问控制),其核心思想是将"权限"授予"角色",将"角色"授予"用户",实现了用户与权限的逻辑分离,极大的简化了权限的分配与管理。

三、主要流程

主要包括用户身份认证、权限分配、权限校验和页面展示控制。

  • 用户登录后,前端从后端获取用户的权限列表。
  • 前端根据用户权限信息,决定展示哪些菜单或按钮。
  • 路由级别做权限校验,未授权用户访问受限页面时自动跳转到无权限提示页或登录页。
  • 组件级别做权限控制,操作按钮或表单项根据权限动态展示或禁用。

四、实现要点

1.获取用户权限信息

// context/AuthProvider

const AuthContext = createContext(undefined);

export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);

  // 从本地存储中恢复用户权限信息
  useEffect(() => {
    const user = localStorage.getItem('user');
    if (user) {
      setUser(JSON.parse(user));
    }
  }, []);

  const login = async (username, password) => {
    const user = await loginApi(username,password);
    setUser(user);
    // 登录后缓存用户权限信息
    localStorage.setItem('user', JSON.stringify(user));
  };

  const logout = () => {
    setUser(null);
    // 登出后清除本地缓存
    localStorage.removeItem('user');
  };

  const hasPermission = (permission: string | string[]): boolean => {
    if (!user) return false;
    if (Array.isArray(permission)) {
      return permission.some(p => user.permissions.includes(p));
    }
    
    return user.permissions.includes(permission);
  };

  const value = {
    user,
    login,
    logout,
    hasPermission
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

2.封装路由权限校验组件

// components/AuthRoute.js
import { useAuth } from '../context/AuthProvider'; // 自定义 hook,获取用户信息

const AuthRoute = ({ children, meta }) => {
  const { user, hasPermission } = useAuth();

   // 用户未登录,重定向到登录页面
  if (meta.requiresAuth && !user) {
    return <Navigate to="/login" replace />;
  }

  // 用户没有权限,重定向到未授权页面
  if (meta.permission && !hasPermission(meta.permission)) {
    return <Navigate to="/403" replace />;
  }

  // 权限通过,渲染子组件
  return children;
};

export default AuthRoute;

3.创建路由

// router/index.js
import AuthRoute from '../components/AuthRoute';

const Router = () => {
  const element = routes.map(({ path, element:Component, meta }) => ({
      path,
      element: (
        <AuthRoute meta={meta}>
          <Component />
        </AuthRoute>
      )
  }));
  return <RouterProvider router={createBrowserRouter(routers)} />;
};

export default Router;

4.封装按钮权限校验组件


import { useAuth } from '../context/AuthProvider'; // 自定义 hook,获取用户信息

export const AuthButton = ({
  permission,
  children,
  onClick,
}) => {
  const { hasPermission } = useAuth();
  const hasAccess = hasPermission(permission);

  if (!hasAccess) {
    return null;
  }

  return (
    <button 
      onClick={onClick} 
    >
      {children}
    </button>
  );
};

5.按钮权限控制

import { AuthButton } from '../components/AuthButton';

export const ContentManagement = () => {
  
  return (
     <AuthButton 
        permission="content.edit"
        onClick={() => handleEdit(item.id)}
     >
        编辑
     </AuthButton>
  );
};

五、技术难点

1.多粒度权限控制

  • 页面级权限控制:通过前端路由守卫实现,例如,React Router的高阶组件、Vue Router 的beforeEach钩子。
  • 组件级权限控制:通过条件渲染隐藏或禁用无权限的按钮。

2.细粒度权限控制

按钮、表单项等细粒度权限控制,难点在于检查点分散,如果每个按钮都要添加额外的权限控制逻辑,维护成本高;另外权限检查函数频繁执行(如在列表中渲染几十个按钮),可能造成性能问题。

常用的做法是封装自定义 Hook(如 usePermission)或高阶组件,并且缓存组件的权限检查结果。

3.状态管理的复杂性

用户权限信息需要全局共享且保持一致性。难点在于:

  • 初始化时机:页面渲染时可能还没拿到用户信息,容易导致未授权页面闪现。
  • Token 过期:接口返回Token过期,需要自动跳转登录,同时清空本地缓存。
  • 多标签页同步:如果一个标签页登出,其他标签页也需要更新状态,否则可能操作报错。

解决方案通常是利用 Context全局共享,使用webStorage本地缓存,利用广播实现多标签页同步。

4.前后端权限一致性

前端权限控制本质是提升用户体验,正真的数据安全必须依赖后端实现。但难点在于:

  • 双重校验的一致性:前端隐藏了按钮,用户仍可能通过直接访问 API 进行操作,所以后端必须对所有接口做权限校验。
  • 数据同步滞后:如果后端修改了用户权限,前端可能仍保留旧的权限缓存,导致用户看到不应看到的操作或无法访问新功能。需要设计合适的刷新机制(如定时拉取、权限变更后强制刷新)。

事件循环底层原理:从 V8 引擎到浏览器实现

2026年3月7日 01:35

前阵子面试被问到:async/await 被编译成什么样了?

我答不上来。面试官说:你用了这么久 async/await,连它怎么实现的都不知道?

回来研究了 V8 源码和 ECMAScript 规范,才发现异步编程的水比想象中深得多。

一、async/await 不是语法糖

很多人说 async/await 是 Promise 的语法糖,严格来说不对。

它更接近 Generator + Promise 的自动执行器。V8 引擎会把 async 函数编译成状态机。

看这段代码:

async function foo() {
  console.log(1);
  await bar();
  console.log(2);
}

V8 编译后大致等价于:

function foo() {
  return new Promise(resolve => {
    const stateMachine = {
      state: 0,
      next(value) {
        switch (this.state) {
          case 0:
            console.log(1);
            this.state = 1;
            return Promise.resolve(bar()).then(v => this.next(v));
          case 1:
            console.log(2);
            resolve();
            return;
        }
      }
    };
    stateMachine.next();
  });
}

每个 await 把函数分成不同的状态,执行完一个 await 就切换到下一个状态。

这就是为什么 await 后面的代码会被放进微任务队列——因为它实际上是 .then() 的回调。

面试追问:为什么 async/await 比 Promise.then 性能好?

因为 V8 对 async/await 做了优化,减少了 Promise 对象的创建。手写 .then().then().then() 会创建多个 Promise 实例,而 async/await 内部可能只创建一个。

二、微任务队列的真实实现

网上都说"微任务队列",但实际上不止一个队列。

根据 HTML 规范,浏览器有:

  1. 微任务队列(Microtask Queue)

    • Promise.then/catch/finally
    • MutationObserver
    • queueMicrotask
  2. Job Queue(ECMAScript 层面)

    • Promise Jobs
    • 这是 ES 规范定义的,比 HTML 规范更底层

Node.js 更复杂:

process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);

Node.js 输出:nextTickpromisetimeoutimmediate

Node.js 有多个队列:

  • nextTick Queue(优先级最高)
  • Promise Queue
  • Timer Queue(setTimeout/setInterval)
  • Check Queue(setImmediate)
  • Poll Queue(I/O)
  • Close Queue

这是一个很多人不知道的点:Node.js 和浏览器的事件循环实现完全不同。

浏览器:HTML 规范定义,一个微任务队列 + 一个宏任务队列

Node.js:libuv 实现,多个阶段,每个阶段有自己的队列

三、MutationObserver 为什么是微任务?

MutationObserver 用来监听 DOM 变化:

const observer = new MutationObserver(() => {
  console.log('DOM changed');
});
observer.observe(document.body, { childList: true });

document.body.appendChild(document.createElement('div'));
console.log('sync');

输出:syncDOM changed

DOM 变化后,回调不是立即执行,而是放进微任务队列。

为什么这样设计?

假设一个循环里改了 100 次 DOM:

for (let i = 0; i < 100; i++) {
  document.body.appendChild(document.createElement('div'));
}

如果每次 DOM 变化都触发回调,会执行 100 次。但如果放进微任务队列,100 次修改完成后只执行一次回调(批量处理)。

这是性能优化的经典设计。

四、Promise 的 then 为什么返回新 Promise?

看这道题:

const p = Promise.resolve(1);
const p2 = p.then(val => val + 1);

console.log(p === p2); // false

then 返回的是新 Promise,不是原来的。

为什么?

为了链式调用。如果返回同一个 Promise,链就会断掉:

Promise.resolve(1)
  .then(val => val + 1) // 返回新 Promise,resolve(2)
  .then(val => val + 2) // 拿到上一个 then 返回的 Promise
  .then(console.log);   // 4

每个 then 都返回新 Promise,形成一条链。

深层问题:then 返回的 Promise 什么时候 settle?

const p = new Promise(resolve => {
  setTimeout(() => resolve('done'), 1000);
});

const p2 = p.then(val => val + '!');

p2 不是立即 settle 的,而是等 p resolve 后,then 的回调执行完,p2 才 resolve。

这涉及 Promise Resolution Procedure(Promise 解决过程),是 ES 规范里最复杂的部分之一。

五、手写 Promise 的核心难点

网上手写 Promise 的文章很多,但大部分都漏了关键点。

1. then 的回调可以返回 Promise

Promise.resolve(1)
  .then(val => Promise.resolve(val + 1))
  .then(console.log); // 2

then 的回调如果返回 Promise,要等这个 Promise settle 后,外层 then 返回的 Promise 才 settle。

then(onFulfilled) {
  return new Promise((resolve, reject) => {
    const result = onFulfilled(this.value);
    // 关键:如果 result 是 Promise,要等它
    if (result instanceof Promise) {
      result.then(resolve, reject);
    } else {
      resolve(result);
    }
  });
}

2. then 可以被调用多次

const p = Promise.resolve(1);
p.then(console.log); // 1
p.then(console.log); // 1
p.then(console.log); // 1

每个 then 都要执行,所以要维护一个回调数组:

class MyPromise {
  constructor(executor) {
    this.callbacks = [];
    
    const resolve = value => {
      this.value = value;
      this.callbacks.forEach(cb => cb(value));
    };
    
    executor(resolve);
  }
  
  then(onFulfilled) {
    this.callbacks.push(onFulfilled);
  }
}

3. 错误穿透

Promise.reject('error')
  .then(val => val + 1)
  .then(val => val + 2)
  .catch(err => console.log(err)); // error

错误会沿着链传递,直到遇到 catch。

then(onFulfilled, onRejected) {
  return new Promise((resolve, reject) => {
    const handle = () => {
      if (this.state === 'fulfilled') {
        try {
          const result = onFulfilled(this.value);
          resolve(result);
        } catch (err) {
          reject(err);
        }
      } else if (this.state === 'rejected') {
        if (onRejected) {
          try {
            const result = onRejected(this.reason);
            resolve(result);
          } catch (err) {
            reject(err);
          }
        } else {
          // 错误穿透:没有 onRejected 就继续传递
          reject(this.reason);
        }
      }
    };
    
    if (this.state) {
      // 已 settle,异步执行
      queueMicrotask(handle);
    } else {
      // pending,加入队列
      this.callbacks.push(handle);
    }
  });
}

六、性能优化:避免 Promise 地狱

问题:Promise 创建是有开销的

// 不好:创建大量不必要的 Promise
async function processItems(items) {
  const results = [];
  for (const item of items) {
    const result = await Promise.resolve(item).then(x => x * 2);
    results.push(result);
  }
  return results;
}

// 好:直接处理
async function processItems(items) {
  return items.map(item => item * 2);
}

问题:微任务队列堆积

// 这段代码会导致微任务队列堆积,阻塞渲染
async function bad() {
  while (true) {
    await Promise.resolve();
    // 这个循环会永远执行,UI 会卡死
  }
}

微任务不会让出执行权给渲染,所以长时间运行的微任务会让页面卡顿。

解决方案:偶尔让出控制权

async function good() {
  while (true) {
    await new Promise(resolve => setTimeout(resolve, 0));
    // 让出控制权,让浏览器有机会渲染
  }
}

setTimeout(0) 会创建宏任务,每次宏任务之间浏览器有机会渲染。

七、冷门但重要的知识点

1. Promise 的构造函数是同步执行的

const p = new Promise(resolve => {
  console.log('executor');
  resolve(1);
});

console.log('after new');

// 输出:executor → after new

Promise 构造函数里的代码是同步执行的,只有 then 回调是异步的。

2. unhandledrejection 事件

Promise.reject('error');

window.addEventListener('unhandledrejection', event => {
  console.log('未处理的 rejection:', event.reason);
});

Promise 被 reject 但没有 catch,会触发这个事件。

Node.js 类似:

process.on('unhandledRejection', (reason, promise) => {
  console.log('未处理的 rejection:', reason);
});

3. Promise.finally 的特殊行为

Promise.resolve(1)
  .finally(() => {
    console.log('finally');
    return 2; // 返回值被忽略
  })
  .then(console.log); // 1,不是 2

finally 不改变传递的值,只执行副作用。

但如果 finally 返回 rejected Promise:

Promise.resolve(1)
  .finally(() => {
    return Promise.reject('error');
  })
  .then(
    val => console.log(val),
    err => console.log(err) // error
  );

4. async 函数的隐式 try-catch

async function foo() {
  throw new Error('fail');
}

foo();
// 错误被包装成 rejected Promise,不会抛到全局

等价于:

function foo() {
  return new Promise((resolve, reject) => {
    try {
      throw new Error('fail');
    } catch (err) {
      reject(err);
    }
  });
}

八、调试异步代码的技巧

1. Chrome DevTools 的 Async Stack Trace

勾选 Console 的 "Async" 选项,可以看到异步调用栈:

async function a() {
  await b();
}

async function b() {
  await c();
}

async function c() {
  console.log('here');
  throw new Error('fail');
}

a();

不开启 Async Stack Trace,调用栈只有 c。

开启后,可以看到 a → b → c 的完整调用链。

2. Node.js 的 --async-stack-traces

node --async-stack-traces app.js

Node.js 12+ 支持,让异步错误堆栈更清晰。

总结

异步编程的难点不在 API,而在于:

  1. 理解底层机制 — V8 如何编译 async/await,事件循环如何调度
  2. 知道边界情况 — Node.js 和浏览器的差异,微任务堆积问题
  3. 能写出正确实现 — Promise 的 resolve procedure,then 的链式调用

面试时,面试官问你"async/await 怎么实现的",不是让你背答案,而是看你是否真的理解原理。


参考资料:

跨域获取 iframe 选中文本?自己写个代理中间层,再也不求后端!

作者 _柳青杨
2026年3月7日 01:25

RAGFlow 跨域文本选中无法获取?自己写个代理中间层,零后端搞定!

教育志项目需要嵌入 RAGFlow 的原文预览,并获取用户选中的文本插入编辑器。RAGFlow 无后端接口、无法修改代码,跨域三要素全占,怎么办?自己写个 Node 代理中间层,轻松破局!

前言

最近参与了一个教育志编修项目,核心需求是多人协同编写教育年鉴,并依赖 RAGFlow 对原始文献进行切片管理。作者在编写文档时,需要随时检索、查看 RAGFlow 中的原始文献,并能够将原文中选中的片段直接插入到正在编写的文档中

技术方案很自然:在编辑器旁边通过 iframe 嵌入 RAGFlow 的原文预览页面,用户选中文字,点击“引用”按钮,即可将选中内容插入编辑器。然而,现实给了我们一记重拳——跨域

RAGFlow 是独立部署的系统,与教育志项目的主应用完全不同源(协议、域名、端口三要素全占)。更棘手的是:RAGFlow 没有提供任何后端接口,没有技术支持,我们无法修改它的代码,也没有办法通过后端代理去抓取页面(因为涉及动态交互) 。浏览器同源策略像一堵无法逾越的墙,父窗口无法通过 contentWindow.document 访问 iframe 内的 DOM,更别说监听选中事件了。

常规方案纷纷失效

方案 为什么不行
postMessage 需要目标页面内配合发送消息,但 RAGFlow 代码无法修改
CORS 跨域资源共享 只适用于接口请求,对 DOM 操作无效
服务器端代理 由后端抓取页面再返回,但 RAGFlow 页面是动态交互的,无法模拟用户选中行为

项目工期紧,前端必须自己杀出一条血路。最终,我们采用了一个“骚操作”——自建 Node 代理中间层,在代理层动态修改 HTML,注入我们需要的脚本,让 iframe 和父窗口“同源”,从而实现跨域 DOM 操作。

本文将完整还原这一方案,并附上可直接运行的源码。无论你遇到的是 RAGFlow 还是任何其他跨域页面,只要你想获取 iframe 内的用户选区,这套方法都能帮你“曲线救国”。

最终效果

我们搭建的代理服务运行在本地 3002 端口,前端只需将 iframe 的 src 指向代理地址,例如:

html

<iframe src="http://localhost:3002/ragflow/docs/123.html"></iframe>

当用户在 iframe 内选中任何文本,父窗口就能收到包含文本内容、位置、上下文等详细信息的消息:

json

{
  "type": "TEXT_SELECTED",
  "text": "光绪二十四年(1898年),京师大学堂成立...",
  "context": {
    "before": "此前,中国近代教育...",
    "after": "此后,各省纷纷设立学堂..."
  },
  "position": { "x": 150, "y": 200 },
  "meta": { "charCount": 48, "wordCount": 9 }
}

父窗口收到消息后,可以立即将文本插入编辑器中,整个过程对用户透明,RAGFlow 无需任何改动,教育志项目后端也无需介入

原理图解

整个方案的核心是:利用 Node.js 创建一个代理服务器,将 RAGFlow 的页面“偷”回来,然后在返回前注入我们自己的脚本

text

浏览器 (教育志项目) 
    │
    │ iframe src="http://localhost:3002/ragflow/docs/..."
    ▼
代理服务 (Node.js)  ← 这是我们自己写的,独立部署
    │
    │ 1. 向 RAGFlow 服务器发起请求(无任何修改)
    ▼
RAGFlow 服务器 (https://ragflow.example.com)  ← 完全不知情
    │
    │ 2. 返回 HTML 内容
    ▼
代理服务
    │
    │ 3. 解压、修改 HTML
    │    ├─ 插入 <base> 标签(修正资源路径)
    │    └─ 注入自定义脚本(不仅限于文本选中,可以是任意你需要的脚本)
    │ 4. 返回修改后的 HTML 给 iframe
    ▼
iframe 加载修改后的页面,注入的脚本开始工作
    │
    │ 5. 根据注入脚本的功能执行操作(如监听 mouseup、捕获选中文本)
    │ 6. 通过 window.parent.postMessage 发送给父窗口
    ▼
父窗口收到消息,将文本插入编辑器

通过这种方式,iframe 的源变成了代理服务的源(例如 http://localhost:3002),与父窗口同源,postMessage 通信畅通无阻,且脚本可以自由操作 iframe 的 DOM。整个过程对 RAGFlow 完全透明,它甚至不知道自己被代理了。

更关键的是:注入的脚本不限于文本选择——你可以利用这个能力,在目标页面中植入任何你想要的功能,例如:

  • 自动填充表单
  • 追踪用户点击行为
  • 修改页面样式
  • 劫持 Ajax 请求
  • 甚至是一个完整的调试工具

代理层就像是一个“中间人”,让你在不修改原始页面的前提下,为它增加任意前端能力。

核心代码逐段解析

1. 启动 HTTP 服务器

javascript

const http = require("http");
const url = require("url");

const PORT = process.env.PROXY_PORT || 3002;
const TARGET_HOST = process.env.TARGET_HOST || "ragflow.example.com"; // 你的 RAGFlow 域名

const server = http.createServer((req, res) => {
  const parsed = url.parse(req.url, true);
  const pathname = parsed.pathname;

  // 健康检查
  if (pathname === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "ok" }));
  } else {
    // 其他所有请求都交给代理函数处理
    proxyRequest(pathname + (parsed.search || ""), res).catch(err => {
      res.writeHead(502);
      res.end("Proxy Error: " + err.message);
    });
  }
});

server.listen(PORT, () => {
  console.log(`Proxy running at http://localhost:${PORT}`);
});

2. 代理请求函数 proxyRequest

这是最核心的部分,负责向 RAGFlow 发起请求,并根据返回内容做不同处理。

javascript

const https = require("https");
const zlib = require("zlib");

async function proxyRequest(targetPath, res) {
  const options = {
    hostname: TARGET_HOST,
    port: 443,
    protocol: "https:",
    path: targetPath,
    headers: {
      "User-Agent": "Mozilla/5.0 ...",
      "Accept-Encoding": "gzip, deflate, br",
      // ... 其他头
    },
    rejectUnauthorized: false, // 忽略证书错误(调试用)
  };

  return new Promise((resolve, reject) => {
    const proxyReq = https.request(options, async (proxyRes) => {
      // 收集数据
      const chunks = [];
      proxyRes.on("data", chunk => chunks.push(chunk));
      proxyRes.on("end", async () => {
        const buffer = Buffer.concat(chunks);
        const encoding = proxyRes.headers["content-encoding"];
        const decompressed = await decompress(buffer, encoding);

        const contentType = proxyRes.headers["content-type"] || "";
        const statusCode = proxyRes.statusCode;

        // 处理重定向
        if (statusCode >= 300 && statusCode < 400 && proxyRes.headers.location) {
          const location = proxyRes.headers.location;
          const newPath = location.startsWith("http")
            ? url.parse(location).path
            : location;
          return proxyRequest(newPath, res).then(resolve).catch(reject);
        }

        // 非200错误
        if (statusCode !== 200) {
          res.writeHead(statusCode, { "Content-Type": "text/plain" });
          res.end("Error: " + statusCode);
          return resolve();
        }

        // 判断是否为 HTML(RAGFlow 的原文页面通常是 HTML)
        const isHtml = contentType.includes("text/html");

        const headers = { "Access-Control-Allow-Origin": "*" };

        if (isHtml) {
          // 修改 HTML 并注入脚本
          let html = decompressed.toString("utf-8");
          html = modifyHtml(html, TARGET_HOST);
          headers["Content-Type"] = "text/html; charset=utf-8";
          headers["Content-Length"] = Buffer.byteLength(html);
          res.writeHead(200, headers);
          res.end(html);
        } else {
          // 非 HTML 资源(CSS、JS、图片等)直接透传
          headers["Content-Type"] = contentType || "application/octet-stream";
          res.writeHead(200, headers);
          res.end(decompressed);
        }
        resolve();
      });
    });

    proxyReq.on("error", reject);
    proxyReq.on("timeout", () => {
      proxyReq.destroy();
      reject(new Error("Timeout"));
    });
    proxyReq.end();
  });
}

3. 解压函数 decompress

支持 gzip、deflate、br 解压。

javascript

function decompress(buffer, encoding) {
  return new Promise((resolve, reject) => {
    if (!encoding || encoding === "identity") resolve(buffer);
    else if (encoding === "gzip") zlib.gunzip(buffer, (e, r) => e ? reject(e) : resolve(r));
    else if (encoding === "deflate") zlib.inflate(buffer, (e, r) => e ? reject(e) : resolve(r));
    else if (encoding === "br") zlib.brotliDecompress(buffer, (e, r) => e ? reject(e) : resolve(r));
    else resolve(buffer);
  });
}

4. 修改 HTML 并注入脚本 modifyHtml

这里做了两件事:替换相对路径为绝对路径(防止资源加载失败),并注入我们的自定义脚本。你可以把脚本换成任何你需要的功能,不局限于文本选择。

javascript

// 注入脚本 - 这里以文本选择捕获为例
// 你可以根据需求替换为其他任意功能
const INJECTED_SCRIPT = `<script>
(function() {
    if (window.__knowledgeProxyInjected) return;
    window.__knowledgeProxyInjected = true;

    console.log('[RAGFlow Proxy] 脚本已注入');

    // 示例:监听文本选择
    document.addEventListener('mouseup', function(e) {
        const selection = window.getSelection();
        const text = selection.toString().trim();
        if (!text) return;

        // 获取选区位置、上下文等信息
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        // 提取上下文(前后各100字符)
        const container = range.commonAncestorContainer;
        const fullText = container.textContent || '';
        const index = fullText.indexOf(text);
        const before = index > 0 ? fullText.substring(Math.max(0, index - 100), index) : '';
        const after = index + text.length < fullText.length ? fullText.substring(index + text.length, index + text.length + 100) : '';

        window.parent.postMessage({
            type: 'TEXT_SELECTED',
            text: text,
            context: { before, after },
            position: {
                x: e.clientX,
                y: e.clientY,
                rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
            },
            meta: {
                charCount: text.length,
                wordCount: text.split(/\s+/).filter(w => w.length > 0).length,
                timestamp: Date.now()
            }
        }, '*');
    });

    // 也可以注入其他功能,比如:
    // - 监听点击事件并上报
    // - 自动填充表单
    // - 修改页面样式
    // - 劫持 fetch 请求
    // - 添加调试面板

    // 通知父窗口 iframe 已就绪
    window.parent.postMessage({ type: 'IFRAME_READY' }, '*');
})();
</script>`;

function modifyHtml(html, targetHost) {
  // 替换相对路径为绝对路径
  html = html.replace(/(href|src)=["']/([^"']+)["']/gi, '$1="https://' + targetHost + '/$2"');
  html = html.replace(/url(["']?/([^"')]+)["']?)/gi, 'url(https://' + targetHost + '/$1)');

  // 插入 base 标签和脚本
  const baseTag = '<base href="https://' + targetHost + '/">';
  const headEndIndex = html.toLowerCase().indexOf('</head>');
  if (headEndIndex !== -1) {
    html = html.slice(0, headEndIndex) + baseTag + INJECTED_SCRIPT + html.slice(headEndIndex);
  } else {
    html = baseTag + INJECTED_SCRIPT + html;
  }
  return html;
}

5. (可选)DOCX 等二进制文件的友好处理

RAGFlow 中可能包含 Word 文档,浏览器无法直接预览,我们可以返回一个下载提示页,并提供“请求转换”的扩展点(用于调用后端转换服务)。

javascript

function generateDocxPage(targetHost, targetPath) {
  return `<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>文档下载</title><style>...</style></head>
<body>
  <div class="box">
    <h2>Word 文档</h2>
    <p>该文档为 DOCX 格式,无法直接在浏览器中预览</p>
    <a class="btn" href="https://${targetHost}${targetPath}" download>下载文档</a>
    <button class="btn" onclick="window.parent.postMessage({type:'REQUEST_DOCX_CONVERT',url:window.location.href},'*')">请求转换</button>
  </div>
</body>
</html>`;
}

如何集成到教育志项目中

  1. 部署代理服务
    将上述 server.js 部署到服务器(或本地开发环境),通过环境变量 TARGET_HOST 指定 RAGFlow 的域名,例如:

    bash

    export TARGET_HOST=ragflow.example.com
    node server.js
    

    服务默认运行在 3002 端口。

  2. 修改前端代码
    在需要展示原文的页面中,将 iframe 的 src 指向代理地址:

    html

    <iframe id="ragflowPreview" src="http://your-proxy-domain:3002/ragflow/path/to/document"></iframe>
    
  3. 监听消息并插入编辑器
    在父窗口中监听 message 事件,收到 TEXT_SELECTED 消息后,将文本插入编辑器(如 TinyMCE、Quill 或自定义编辑器):

    javascript

    window.addEventListener('message', (event) => {
      if (event.data.type === 'TEXT_SELECTED') {
        editor.insertText(event.data.text); // 根据实际编辑器 API 调整
      }
    });
    

整个过程完全无侵入:RAGFlow 不需要任何改动,教育志项目后端也不需要提供新接口,前端只需要修改 iframe 的 src 地址即可。

为什么不用其他方案?(再次强调)

方案 问题
postMessage 需要 RAGFlow 页面内添加代码,不可能
CORS 只适用于接口,不适用于 DOM
后端代理抓取 需要后端配合,且无法模拟用户交互(选中文本)
浏览器插件 需要用户安装,不现实

而我们的代理中间层方案,独立部署、零侵入、纯前端集成,完美解决了所有痛点。

进阶功能:注入任意脚本,扩展无限可能

代理层的核心价值在于:你可以在目标页面中执行任何你想要的 JavaScript 代码。除了文本选择捕获,你还可以:

  • 用户行为分析:监听点击、滚动、停留时间,上报给父窗口进行埋点。
  • 动态样式调整:根据父窗口的主题,动态修改 iframe 内的 CSS,实现视觉统一。
  • 表单自动填充:为 RAGFlow 的搜索框自动填入关键词(父窗口传递)。
  • 请求拦截与修改:劫持 iframe 内的 fetch/XHR 请求,添加认证头或修改返回值。
  • 注入调试工具:在开发环境中注入 Eruda 或 vConsole,方便调试。

你只需要修改 INJECTED_SCRIPT 的内容,就可以像操作自己的页面一样操作跨域 iframe 内的所有内容。这为前端开发打开了无限的可能性。

注意事项

  • CSP 限制:如果 RAGFlow 页面有严格的 Content-Security-Policy,可能阻止内联脚本执行。此时需要更复杂的处理(如通过 nonce 或动态创建 script 标签),但大多数系统不会设置如此严格的策略。
  • 证书问题:如果 RAGFlow 使用自签名证书,设置 rejectUnauthorized: false 可临时绕过,生产环境建议妥善配置证书。
  • 性能优化:代理会缓冲整个响应体,对于超大 HTML 可能占用内存。可考虑流式转发,但修改 HTML 需要完整内容,此处不再展开。

完整源码

最后,附上整合了以上所有功能的 server.js 完整源码(可直接运行):

javascript

// server.js - 教育志 RAGFlow 代理中间层
const http = require("http");
const https = require("https");
const url = require("url");
const zlib = require("zlib");

const PORT = process.env.PROXY_PORT || 3002;
const TARGET_HOST = process.env.TARGET_HOST || "ragflow.example.com";

// 注入脚本 - 你可以根据需要自由修改!
const INJECTED_SCRIPT = `<script>
(function() {
    if (window.__knowledgeProxyInjected) return;
    window.__knowledgeProxyInjected = true;

    console.log('[RAGFlow Proxy] 脚本已注入');

    // 示例:监听文本选择
    document.addEventListener('mouseup', function(e) {
        const selection = window.getSelection();
        const text = selection.toString().trim();
        if (!text) return;

        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        // 提取上下文
        const container = range.commonAncestorContainer;
        const fullText = container.textContent || '';
        const index = fullText.indexOf(text);
        const before = index > 0 ? fullText.substring(Math.max(0, index - 100), index) : '';
        const after = index + text.length < fullText.length ? fullText.substring(index + text.length, index + text.length + 100) : '';

        window.parent.postMessage({
            type: 'TEXT_SELECTED',
            text: text,
            context: { before, after },
            position: {
                x: e.clientX,
                y: e.clientY,
                rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
            },
            meta: { charCount: text.length, wordCount: text.split(/\s+/).filter(w => w.length > 0).length }
        }, '*');
    });

    // 你可以在这里注入任意其他功能:
    // - 监听点击事件并上报
    // - 自动填充表单
    // - 修改页面样式
    // - 劫持 fetch 请求
    // - 添加调试面板

    window.parent.postMessage({ type: 'IFRAME_READY' }, '*');
})();
</script>`;

// 解压函数
function decompress(buffer, encoding) {
  return new Promise((resolve, reject) => {
    if (!encoding || encoding === "identity") resolve(buffer);
    else if (encoding === "gzip") zlib.gunzip(buffer, (err, result) => err ? reject(err) : resolve(result));
    else if (encoding === "deflate") zlib.inflate(buffer, (err, result) => err ? reject(err) : resolve(result));
    else if (encoding === "br") zlib.brotliDecompress(buffer, (err, result) => err ? reject(err) : resolve(result));
    else resolve(buffer);
  });
}

// 修改 HTML
function modifyHtml(html, targetHost) {
  html = html.replace(/(href|src)=["']/([^"']+)["']/gi, '$1="https://' + targetHost + '/$2"');
  html = html.replace(/url(["']?/([^"')]+)["']?)/gi, 'url(https://' + targetHost + '/$1)');

  const baseTag = '<base href="https://' + targetHost + '/">';
  const headEndIndex = html.toLowerCase().indexOf('</head>');
  if (headEndIndex !== -1) {
    html = html.slice(0, headEndIndex) + baseTag + INJECTED_SCRIPT + html.slice(headEndIndex);
  } else {
    html = baseTag + INJECTED_SCRIPT + html;
  }
  return html;
}

// 代理请求
async function proxyRequest(targetPath, res) {
  const options = {
    hostname: TARGET_HOST,
    port: 443,
    protocol: "https:",
    path: targetPath,
    method: "GET",
    headers: {
      "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
      "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
      "Accept-Language": "zh-CN,zh;q=0.9",
      "Accept-Encoding": "gzip, deflate, br",
      "Connection": "keep-alive",
    },
    timeout: 30000,
    rejectUnauthorized: false,
  };

  return new Promise((resolve, reject) => {
    const proxyReq = https.request(options, async (proxyRes) => {
      try {
        const chunks = [];
        proxyRes.on("data", (chunk) => chunks.push(chunk));

        const buffer = await new Promise((resolve, reject) => {
          proxyRes.on("end", () => resolve(Buffer.concat(chunks)));
          proxyRes.on("error", reject);
        });

        const encoding = proxyRes.headers["content-encoding"];
        const decompressed = await decompress(buffer, encoding);

        const contentType = proxyRes.headers["content-type"] || "";
        const statusCode = proxyRes.statusCode;

        // 处理重定向
        if (statusCode >= 300 && statusCode < 400 && proxyRes.headers.location) {
          const location = proxyRes.headers.location;
          const newPath = location.startsWith("http") ? url.parse(location).path : location;
          return proxyRequest(newPath, res).then(resolve).catch(reject);
        }

        if (statusCode !== 200) {
          res.writeHead(statusCode, { "Content-Type": "text/plain" });
          res.end("Error: " + statusCode);
          return resolve();
        }

        const isHtml = contentType.includes("text/html");
        const headers = { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache" };

        if (isHtml) {
          let html = decompressed.toString("utf-8");
          html = modifyHtml(html, TARGET_HOST);
          headers["Content-Type"] = "text/html; charset=utf-8";
          headers["Content-Length"] = Buffer.byteLength(html);
          res.writeHead(200, headers);
          res.end(html);
          console.log("[Proxy] HTML 已处理并注入脚本");
        } else {
          headers["Content-Type"] = contentType || "application/octet-stream";
          res.writeHead(200, headers);
          res.end(decompressed);
        }
        resolve();
      } catch (err) {
        reject(err);
      }
    });

    proxyReq.on("error", reject);
    proxyReq.on("timeout", () => {
      proxyReq.destroy();
      reject(new Error("Timeout"));
    });
    proxyReq.end();
  });
}

// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
  const parsed = url.parse(req.url, true);
  const pathname = parsed.pathname;

  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");

  if (req.method === "OPTIONS") {
    res.writeHead(200);
    return res.end();
  }

  if (pathname === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "ok", target: TARGET_HOST }));
  } else {
    proxyRequest(pathname + (parsed.search || ""), res).catch((err) => {
      console.error("[Proxy Error]", err);
      res.writeHead(502, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: err.message }));
    });
  }
});

server.listen(PORT, () => {
  console.log(`[教育志 RAGFlow 代理] 运行在 http://localhost:${PORT}`);
  console.log(`目标主机: ${TARGET_HOST}`);
});

总结

通过自建 Node 代理中间层,我们在零后端配合的情况下,完美实现了跨域 iframe 中选中文本的捕获,并将文本实时传递到教育志项目的编辑器中。但更重要的是,这个方案为你打开了在任意第三方网页上执行任意脚本的大门——注入文本选择只是其中一个小小例子。

当你再次面对跨域 iframe DOM 操作难题时,不妨试试这个“中间人”思路。代码在手,跨域我有!


希望这篇文章能帮助到遇到类似问题的同行。有任何疑问或改进建议,欢迎在评论区留言交流。

Vue3 命令式弹窗原理和 provide/inject 隔离机制详解

2026年3月7日 00:53

Vue 3 命令式弹窗组件 这篇文章是 Vue3 命令式弹窗的实现,本文针对实现进行原理讲解。

核心问题

问题:通过命令式方法(如render函数)创建多个弹窗组件实例时,为什么每个实例调用provide()时数据不会互相污染?

关键代码与机制

1. 创建命令式弹窗的核心代码

export const useCommandComponent = (Component) => {
  const parentInstance = getCurrentInstance()
  
  // 创建新的 appContext,继承父级上下文
  const appContext = Object.create(parentInstance?.appContext)
  
  // 关键步骤:设置 appContext.provides 为父级的 provides
  if (appContext) {
    Reflect.set(appContext, 'provides', parentInstance?.provides)
  }
  
  const container = document.createElement('div')
  
  const CommandComponent = (options = {}) => {
    const vNode = createVNode(Component, options)
    vNode.appContext = appContext  // 我们设置的 appContext
    
    // 注意:render 函数没有传入 parentComponent
    // 这意味着命令式组件被当作"根组件"处理
    render(vNode, container)
    
    document.body.appendChild(container)
    return vNode
  }
  
  return CommandComponent
}

2. Vue 内部渲染逻辑

// Vue 内部的 render 函数简化逻辑
const render = (vnode, container) => {
  // 第5个参数是 parentComponent,对于命令式组件,这里传的是 null
  patch(null, vnode, container, null, null, null, namespace)
  //                                   ↑
  // 这个 null 就是 parent,意味着命令式组件没有父组件
}

3. 组件实例创建的关键逻辑

// Vue 源码:createComponentInstance
function createComponentInstance(vnode, parent, suspense) {
  const instance = {
    // 关键:命令式组件的 parent 是 null!
    parent: parent,  // 对于命令式组件,parent = null
    
    appContext: vnode.appContext,  // 我们设置的 appContext
    
    // 最关键的部分:provides 的初始化方式
    provides: parent 
      ? parent.provides  // 有 parent 时,直接继承(标准组件)
      : Object.create(vnode.appContext.provides)  // 无 parent 时,创建新对象
      //   ↑
      // 命令式组件走到这个分支
      // 创建一个以 vnode.appContext.provides 为原型的新空对象
  }
  return instance
}

为什么不会互相污染?

1. 实例创建过程

// 创建弹窗1时
const instance1 = createComponentInstance(vNode1, parent = null, suspense)
// 因为 parent = null,所以:
instance1.provides = Object.create(vnode.appContext.provides)
// 结果:instance1.provides 是一个新的空对象 {}
// 但这个对象的 __proto__ 指向 vnode.appContext.provides(即父组件的 provides)

// 创建弹窗2时
const instance2 = createComponentInstance(vNode2, parent = null, suspense)
// 同样因为 parent = null:
instance2.provides = Object.create(vnode.appContext.provides)
// 结果:instance2.provides 是另一个新的空对象 {}
// 注意:instance1.provides 和 instance2.provides 是两个不同的对象

2. 实例状态对比

// 弹窗1实例的状态
instance1 = {
  parent: null,  // 没有父组件
  provides: {},  // 全新的空对象1
  
  // 关键:这个空对象的原型指向父组件的 provides
  // provides.__proto__ === 父组件的provides
}

// 弹窗2实例的状态
instance2 = {
  parent: null,  // 同样没有父组件
  provides: {},  // 全新的空对象2
  
  // 注意:instance2.provides 和 instance1.provides 是不同的对象!
  // 但它们的原型都指向同一个父组件的 provides
  // provides.__proto__ === 父组件的provides
}

3. 调用 provide 时的行为

// 弹窗1调用 provide
provide('key1', 'value1')
// 实际执行:instance1.provides.key1 = 'value1'
// 结果:instance1.provides = { key1: 'value1' }

// 弹窗2调用 provide
provide('key2', 'value2')
// 实际执行:instance2.provides.key2 = 'value2'
// 结果:instance2.provides = { key2: 'value2' }

// 重要:这两个操作完全独立
// instance1.provides 和 instance2.provides 是两个不同的对象
// 所以不会互相影响

内存结构可视化

父组件
  │
  ├── provides: { parentKey: 'parentValue' }
  │
  ├── 弹窗1实例
  │     ├── parent: null
  │     ├── provides: { key1: 'value1' }  ← 这是自有属性
  │     │
  │     └── provides.__proto__
  │              ↓
  │         { parentKey: 'parentValue' } ← 父组件的 provides
  │
  └── 弹窗2实例
        ├── parent: null
        ├── provides: { key2: 'value2' }  ← 这是自有属性
        │
        └── provides.__proto__
                 ↓
            { parentKey: 'parentValue' } ← 父组件的 provides

关键点总结

1. 为什么 parent 是 null?

  • 命令式组件不是通过父组件模板渲染的
  • 而是通过 render()函数直接挂载到 DOM
  • Vue 内部将其视为独立的"根组件"
  • 所以 parent 参数为 null

2. 为什么 provides 是独立的对象?

  • parentnull时,Vue 会执行:

    provides: Object.create(vnode.appContext.provides)
    
  • 这创建了一个新的空对象,其原型指向父组件的 provides

  • 每个命令式组件实例都会执行这个操作

  • 所以每个实例都有自己独立的 provides对象

3. 如何实现数据共享?

  • 虽然每个实例的 provides 是独立的对象

  • 但这些对象的原型都指向同一个父组件的 provides

  • 当调用 inject()查找数据时:

    // 简化版的 inject 逻辑
    function inject(key) {
      // 对于命令式组件,instance.parent 为 null
      const provides = instance.parent == null
        // 走这个分支,刚好 appContext.provides 就是父组件的 provides
        ? instance.vnode.appContext.provides  
        : instance.parent.provides
    
      if (provides && key in provides) {
        return provides[key]
      }
    }
    
  • 所以所有命令式组件都能访问父组件提供的数据

4. 为什么不会互相污染?

  • 每个实例的 provides 是不同的对象
  • 调用 provide() 时,数据写入各自实例的 provides 对象
  • 实例A写入的数据在实例A的 provides 对象上
  • 实例B写入的数据在实例B的 provides 对象上
  • 它们之间没有直接联系,所以不会互相影响

实际示例

// 父组件提供配置
provide('appConfig', { theme: 'dark', version: '1.0' })

// 创建命令式弹窗
const showModal = useCommandComponent(Modal)

// 打开两个弹窗
const modal1 = showModal({ title: '弹窗1' })
const modal2 = showModal({ title: '弹窗2' })

// 在 Modal 组件内部:
setup() {
  // 两个弹窗都能获取到父组件的 appConfig
  const config = inject('appConfig')
  // config = { theme: 'dark', version: '1.0' }
  
  // 弹窗1 provide 数据
  provide('modalData', 'data from modal1')
  // 这个数据只在 modal1 内部有效
  // modal2 无法访问到
}

如何优雅地处理 iframe 跨域通信?这是我的开源方案

2026年3月7日 00:45

一、开篇破局:被误解的iframe,从未真正退场

在微前端大行其道的今天,很多人觉得 iframe 已经过时了。但每当业务遇到绝对的安全沙箱隔离、第三方老旧系统接入、跨域广告/挂件嵌入时,大家转了一圈还是会乖乖回到 iframe 的怀抱——毕竟它是浏览器原生的、最彻底的隔离方案。 究其原因,无外乎它是浏览器原生支持、隔离性最彻底的方案,没有之一。但凡事皆有两面性,iframe的隔离有多极致,跨域通信就有多棘手,这也是无数开发者对它又爱又恨的核心原因。

但是,iframe 的隔离有多完美,它的跨域通信就有多让人头疼! 但凡用原生window.postMessage开发过稍复杂的跨域业务,大概率都踩过这些让人崩溃的坑,堪称前端开发的“隐形绊脚石”:

  • 回调地狱:发出去了消息,不知道对方收没收到,只能满屏幕写 addEventListener 去匹配消息 ID。

  • 时序问题:父页面急着发数据,子页面还没 onload,消息直接石沉大海。

  • 恶心的双滚动条:子页面内容变多被撑开,父页面无法感知,高度死活对不上。

  • 状态同步灾难:父页面切了深色模式,子页面还是亮瞎眼的白色,状态完全割裂。

“原生长篇大论的事件监听代码” vs “iframe-js 一行 await 代码” 的对比截图对比:

// 原生 postMessage 跨域获取数据
function fetchRemoteData(userId) {
    return new Promise((resolve, reject) => {
        const messageId = 'req_' + Date.now();

        // 1. 必须注册全局监听器
        const handler = (event) => {
            // 安全第一:手动死磕 origin 校验
            if (event.origin !== 'https://target-domain.com') return;

            // 必须通过唯一 ID 匹配,不然会串线
            if (event.data?.id === messageId && event.data?.action === 'USER_INFO_RES') {
                clearTimeout(timer);
                window.removeEventListener('message', handler); // 极易忘写导致内存泄漏
                resolve(event.data.result);
            }
        };
        window.addEventListener('message', handler);

        // 2. 发送请求
        const targetIframe = document.getElementById('my-iframe').contentWindow;
        targetIframe.postMessage({
            action: 'USER_INFO_REQ',
            id: messageId,
            payload: { userId }
        }, 'https://target-domain.com');

        // 3. 手动处理超时逻辑
        const timer = setTimeout(() => {
            window.removeEventListener('message', handler);
            reject(new Error('跨域请求超时'));
        }, 5000);
    });
}
// 使用 iframe-js 的 RPC 远程调用
async function fetchRemoteData(userId) {
    try {
        // 就像调用本地异步函数一样丝滑!
        const userInfo = await iframeApp.callRemote('getUserInfo', { userId }, 5000);
        return userInfo;
    } catch (error) {
        // 完美捕获超时或对方抛出的异常
        console.error('调用失败:', error.message);
    }
}

二、破局方案:iframe-js 2.2.1开源,降维打击通信痛点

为了彻底消灭这些恶心人的痛点,我重构并开源了 iframe-js(目前最新版本 2.2.1)。它不是对 postMessage 的简单封装,而是将 iframe 通信直接拉升到了现代前端工程化的标准。iframe-js 的四大杀手锏功能

他的核心思路就是抛弃传统的发布订阅,直接用现代前端的思维(RPC、状态机、Promise 回执)去降维打击这些痛点。今天开源出来,给大家分享一下。

三、四大核心功能:彻底解决iframe通信难题

1. 像调用本地函数一样跨域:RPC 远程调用

这是我个人最喜欢的功能。以前你想让子页面去查个数据,得先 postMessage 过去,子页面查完再 postMessage 回来,逻辑被严重撕裂。 现在,你可以用 RPC (Remote Procedure Call) 模式,直接用 async/await 拿到跨域函数的返回值!

提供方(如父页面):

// 暴露一个名为 'getUserInfo' 的异步服务
iframeApp.expose('getUserInfo', async (params) => {
  const res = await fetch(`/api/user/${params.id}`);
  return await res.json(); // 直接 return 即可!
});

调用方(如子页面):

// 像调用本地函数一样丝滑,天然支持超时控制和 try/catch 错误穿透!
try {
  const userInfo = await childApp.callRemote('getUserInfo', { id: 1001 }, 5000);
  console.log('跨域拿到数据啦:', userInfo);
} catch(err) {
  console.error('调用超时或报错:', err);
}

2. 彻底告别双滚动条:自动高度适应 (Auto Resize)

同域下我们可以直接读 DOM 高度,跨域下怎么办?iframe-js 内置了基于现代浏览器 ResizeObserver 的高度同步机制。性能极致,零 CPU 轮询消耗,甚至连 display: none 导致的 0px 高度塌陷陷阱都在底层帮你规避了。

父页面一行代码授权:

iframeApp.enableAutoResize();

子页面一行代码开启探测:

// 当内部存在图片懒加载、列表下拉导致 DOM 撑开时,父页面的 iframe 标签会自动随之伸缩!
childApp.startAutoResizer({ offset: 20 }); // 还能额外补偿 20px 底部间距

3. 跨越 Iframe 的状态机:全局状态共享 (State Sync)

业务里经常遇到父子页面需要共享上下文的情况(主题色、语言包、当前登录用户信息)。与其用事件发来发去,不如直接用微缩版“Pinia/Vuex”。 不管子页面加载有多慢,只要它一 onload,父页面的最新状态就会自动全量同步过去。

// 父页面随时更新状态
iframeApp.setState({ theme: 'dark', lang: 'zh-CN' });

// 子页面响应式监听
childApp.onStateChange((newState) => {
  if (newState.theme === 'dark') {
    document.body.classList.add('dark-mode');
  }
});

4. 绝对可靠的送达:Promise ACK 与内置队列

原生的 postMessage 是典型的“Fire-and-Forget(发后不理)”。 而在 iframe-js 中,你可以使用 emitWithAck。底层会自动为你分配唯一 ID 并追踪回执。

// 如果返回 true,说明不仅发过去了,而且对方的代码已经成功执行了业务逻辑!
const isSuccess = await parentApp.emitToChildWithAck('updateData', { a: 1 });

更绝的是内置队列机制:如果父页面初始化后立刻发消息,而子页面还没准备好,消息绝不会丢!

iframe-js 会自动将消息存入内存队列,等子页面打通连接的瞬间,依次重发。 怎么用?

四、极简上手:开箱即用,全链路TS支持

iframe-js无需复杂配置,开箱即用,全面支持TypeScript类型推导,兼顾开发效率与类型安全,一行命令即可安装:

npm install iframe-js

五、Live Demo实测:眼见为实,上手即体验

文字描述再详尽,不如直接上手实操。我针对核心功能打造了3大极限测试场景Demo,打开F12控制台查看底层日志,更能直观感受通信流程的丝滑:

六、写在最后

开发iframe-js的初衷,就是想让开发者在处理微前端嵌套、低代码平台渲染区、第三方系统接入等场景时,摆脱iframe跨域通信的繁琐痛点,少踩坑、少加班,专注核心业务开发。

跨域场景复杂多变,如果你在使用过程中遇到奇葩报错,或是有点击穿透拦截、快捷键透传等个性化需求,欢迎前往GitHub仓库提Issue交流,一起完善工具生态。

开源地址: github.com/1503963513/…,如果这款工具帮你解决了实际问题,欢迎点亮Star支持!

腾讯域名拦截查询 在线工具核心JS实现

作者 滕青山
2026年3月6日 23:47

这篇只讲功能层 JavaScript/TypeScript 实现,围绕“输入一个域名,得到可读的拦截状态”这一条主链路展开。

工具有两条查询通道(第三方接口):

  • QQ通道:https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url={url}&ticket={ticket}&randstr={randstr}&_={timestamp}
  • 微信通道:https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url={url}

在线工具网址:see-tool.com/tencent-dom…
工具截图:
工具截图.png

1. 输入规范化是第一道关口

这个工具不直接信任用户输入,而是统一走 normalizeInput

const normalizeInput = (value) => {
  const rawValue = String(value || '').trim()
  if (!rawValue) return ''

  const cleaned = rawValue.replace(/\s+/g, '')
  const withProtocol = /^https?:\/\//i.test(cleaned) ? cleaned : `http://${cleaned}`

  try {
    const url = new URL(withProtocol)
    if (!['http:', 'https:'].includes(url.protocol)) return ''
    return url.toString()
  } catch {
    return ''
  }
}

这里做了三件关键事:去空白、补协议、用 URL 做结构化校验。后续所有请求都只使用规范化后的值。

2. 查询动作编排

点击查询时,动作顺序是固定的:

  1. 判空
  2. 规范化
  3. 清理旧结果
  4. 标记查询通道
  5. 进入对应通道请求
const startQqQuery = () => {
  if (!input.value.trim()) {
    errorMessage.value = '请输入要查询的域名'
    return
  }

  const normalized = normalizeInput(input.value)
  if (!normalized) {
    errorMessage.value = '请输入有效的网址'
    return
  }

  input.value = normalized
  errorMessage.value = ''
  resultData.value = null
  lastQueryType.value = 'qq'
  submitQqQuery(normalized, ticket, randstr)
}

const startWeChatQuery = () => {
  if (!input.value.trim()) {
    errorMessage.value = '请输入要查询的域名'
    return
  }

  const normalized = normalizeInput(input.value)
  if (!normalized) {
    errorMessage.value = '请输入有效的网址'
    return
  }

  input.value = normalized
  errorMessage.value = ''
  resultData.value = null
  lastQueryType.value = 'wx'
  submitWeChatQuery(normalized, captchaPayload)
}

这一层不做网络请求细节,只负责把交互状态整理干净。

3. 请求提交与异常回传

真正请求在 submit 函数里,统一处理 loading、异常捕获和结果写入。两条通道分别请求不同第三方 API。

const submitQqQuery = async (url, ticket, randstr) => {
  loading.value = true
  errorMessage.value = ''

  try {
    const apiUrl = `https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url=${encodeURIComponent(
      url
    )}&ticket=${encodeURIComponent(ticket)}&randstr=${encodeURIComponent(randstr)}&_=${Date.now()}123`

    const response = await fetch(apiUrl, {
      method: 'GET',
      headers: {
        Referer: 'https://urlsec.qq.com/check.html',
        'User-Agent':
          'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
        Accept: 'application/json',
        'Accept-Language': 'zh-CN,zh;q=0.8',
        Connection: 'close'
      }
    })

    const text = await response.text()
    const data = parseJsonp(text)
    if (!data || data.reCode !== 0 || !data.data?.results) {
      throw new Error(data?.data || '查询失败')
    }

    resultData.value = buildQqResult(url, data.data.results)
  } catch (error) {
    errorMessage.value = error?.message || '查询失败'
  } finally {
    loading.value = false
  }
}

const submitWeChatQuery = async (url, captchaPayload) => {
  loading.value = true
  errorMessage.value = ''

  try {
    const apiUrl = `https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url=${encodeURIComponent(url)}`

    const response = await fetch(apiUrl, {
      method: 'GET',
      headers: {
        Referer: 'https://urlsec.qq.com/check.html',
        'User-Agent':
          'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
        Accept: 'application/json',
        'Accept-Language': 'zh-CN,zh;q=0.8',
        Connection: 'close'
      }
    })

    const text = await response.text()
    const data = JSON.parse(text)
    const isBlocked = data.data === 'ok'

    resultData.value = {
      url,
      status: {
        type: isBlocked ? 'wechat_blocked' : 'wechat_safe'
      }
    }
  } catch (error) {
    errorMessage.value = error?.message || '查询失败'
  } finally {
    loading.value = false
  }
}

前端只认统一的返回结构:{ status: 'ok', data: ... }

4. 服务端 URL 清洗与请求拦截

服务端入口先拦截无效请求,再代理到上游接口:

const normalizeUrl = (input: string) => {
  const rawValue = String(input || '').trim()
  if (!rawValue) return ''

  const cleaned = rawValue.replace(/\s+/g, '')
  const withProtocol = /^https?:\/\//i.test(cleaned) ? cleaned : `http://${cleaned}`

  try {
    const target = new URL(withProtocol)
    if (!['http:', 'https:'].includes(target.protocol)) return ''
    return target.toString()
  } catch {
    return ''
  }
}

if (!url) {
  setResponseStatus(event, 400)
  return { status: 'error', message: 'Invalid url' }
}

这样可以保证前后端都执行相同的输入约束,避免脏数据直接进入上游请求。

5. QQ通道:JSONP 解析与状态映射

QQ通道第三方接口:https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url={url}&ticket={ticket}&randstr={randstr}&_={timestamp}

QQ通道返回的是 JSONP,不是纯 JSON,所以先解包:

const parseJsonp = (jsonpStr: string) => {
  const match = jsonpStr.match(/url_query\((.+)\)/)
  if (match && match[1]) {
    try {
      return JSON.parse(match[1])
    } catch {
      return null
    }
  }
  return null
}

拿到对象后,再把复杂字段折叠成前端可消费的数据模型:

if (result.whitetype === 3 || result.whitetype === 4) {
  data.status.type = 'whitelist'
} else if (result.whitetype === 2) {
  data.status.type = 'blocked'
  data.status.wordingTitle = result.WordingTitle || ''
  data.status.wording = result.Wording || ''
} else if (result.whitetype === 1) {
  if (result.eviltype === 2800 || result.eviltype === 2804) {
    data.status.type = 'qq_blocked'
  } else if (result.eviltype && result.eviltype !== 0) {
    data.status.type = 'other_blocked'
    data.status.evilType = result.eviltype
  } else {
    data.status.type = 'safe'
  }
}

这一步的重点不是“原样透传”,而是“转成稳定业务语义”。

6. 微信通道:返回值压平

微信通道第三方接口:https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url={url}

微信查询通道返回结构更简单,核心逻辑就是把上游标记转成统一状态:

const isBlocked = data.data === 'ok'

return {
  status: 'ok',
  data: {
    url,
    status: {
      type: isBlocked ? 'wechat_blocked' : 'wechat_safe'
    }
  }
}

两条通道虽然来源不同,但最终都对齐到 data.status.type,前端渲染就能复用同一套逻辑。

7. 结果渲染:从对象到行数据

页面不直接硬编码每一行,而是先把结果对象转换成 resultRows

const resultRows = computed(() => {
  const data = resultData.value
  if (!data) return []

  const rows = []
  const addRow = (key, label, value, extra = {}) => {
    if (value === undefined || value === null || value === '') return
    rows.push({ key, label, value, ...extra })
  }

  addRow('url', '检测地址', data.url)
  addRow('status', '检测结果', statusText.value, { isStatus: true, toneClass: statusTone.value })

  if (data.status?.wordingTitle) addRow('reasonTitle', '原因标题', data.status.wordingTitle)
  if (data.status?.wording) addRow('reasonDetail', '原因详情', data.status.wording)

  return rows
})

这种“先标准化、再渲染”的模式,能让字段增减时只改一处映射逻辑。

到这里,核心链路就是:输入标准化 → 查询编排 → 服务端映射 → 统一结果模型 → 页面渲染。

从 URL 输入到页面展示:一场跨越进程与协议的“装修”大戏

2026年3月6日 23:37

摘要:春招季将至,“从 URL 输入到页面展示”是前端与后端面试中出场率高达 80% 的“八股文”之王。很多候选人习惯堆砌知识点,却难以串联成线。本文将摒弃枯燥的列表式回答,以“装修房子”为喻,结合浏览器多进程架构、操作系统原理、网络协议栈及 DNS 解析机制,为你构建一套清晰、深刻且通俗易懂的知识体系。这不仅是一次面试通关指南,更是一次对计算机底层逻辑的深度巡礼。


引言:不仅仅是“回车”那么简单

当你在浏览器地址栏输入 www.geekbang.org 并按下回车键的那一刻,看似平静的操作背后,实则上演着一场横跨应用层、网络层、传输层乃至操作系统内核的宏大交响乐。

在面试中,如果你只回答“DNS 解析 -> TCP 握手 -> 发送请求 -> 渲染”,考官可能会觉得你只是背了书。真正的高手,能够像项目经理一样,清晰地描述出浏览器主进程如何调度、网络进程如何采购、渲染进程如何在沙箱中施工,以及底层操作系统如何分配资源。

今天,我们就把整个页面加载过程比作一次**“装修房子”**,带你深入这场技术大戏的幕后。


第一幕:项目经理接单(浏览器主进程)

在现代浏览器(如 Chrome)的多进程架构中,浏览器主进程(Browser Process) 扮演着“项目经理”的角色。它不直接干活(不渲染页面,不下载数据),但它负责指挥、调度、验收以及处理用户交互。

1.1 接收指令与导航启动

当你输入 URL 时,主进程首先介入:

  • URL 补全与预处理:如果你只输入了关键词,主进程会将其交给默认搜索引擎;如果输入的是域名,它会尝试补全 http://https://
  • 历史管理:主进程会将此次导航记录压入“后退栈”(Backward Stack),并清空“前进栈”(Forward Stack)。这就是为什么刷新后无法“前进”的原因。
  • 状态反馈:界面立刻显示 Loading 图标,告知用户“工程已启动”。

1.2 安全拦截:beforeunload

在正式动工前,主进程会检查当前页面是否有未保存的数据。它会通知旧的渲染进程触发 beforeunload 事件。如果页面返回了拦截信号,浏览器会弹出原生确认框:“您确定要离开吗?未保存的修改可能会丢失。”这是防止用户误操作导致数据丢失的最后一道防线。

一旦确认无误,主进程正式将 URL 转发给网络进程,准备开始“采购材料”。


第二幕:采购员出动与地址查询(网络进程 & DNS)

网络进程(Network Process) 是浏览器的“采购员 + 物流司机”。它的核心任务是搞定网络连接,把服务器上的资源(HTML、CSS、图片等)拉取回来。但在此之前,它必须知道“仓库”在哪里。

2.1 DNS 解析:分布式的全球电话簿

计算机之间通信靠的是 IP 地址,而不是人类可读的域名。因此,第一步是将域名转换为 IP。DNS(Domain Name System)是一个巨大的分布式数据库。

解析过程遵循“就近原则”,层层递进:

  1. 浏览器缓存:Chrome 内部有独立的 DNS 缓存(可通过 chrome://net-internals/#dns 查看)。这是最快的路径。

  2. 操作系统缓存:如果浏览器没找到,会查询操作系统的 DNS 缓存。这里涉及一个特殊的文件——Hosts 文件(Windows 位于 C:\Windows\System32\drivers\etc\hosts)。开发者常在此配置本地域名映射(如 127.0.0.1 www.douyin.com)进行本地测试。

    • 面试题深挖:为什么修改 Hosts 文件后有时不生效?因为浏览器有自己的缓存机制,甚至可能复用了之前的 TCP 长连接(Keep-Alive)。此时需清除浏览器 DNS 缓存或重启浏览器。
  3. 本地 DNS 服务器(LDNS) :通常由 ISP(如抚州电信)提供。

  4. 根域名服务器与顶级域名服务器:如果 LDNS 也没有,请求会逐级向上,经过根服务器(.)、顶级域服务器(.org),最终找到权威域名服务器,拿到目标 IP。

负载均衡的奥秘
DNS 返回的往往不是一个 IP,而是一组 IP 数组。这背后是负载均衡技术在起作用。就像“媒婆”介绍对象,DNS 会根据你的地域(地域特性机房)、服务器负载情况(轮询算法 Round Robin),将你引导至离你最近、压力最小的服务器集群(Nginx 反向代理)。

2.2 建立连接:三次握手

拿到 IP 后,网络进程需要与服务器建立可靠的传输通道。这就用到了 TCP 协议

  • 为什么是 TCP? 网页内容要求完整无误,不能像视频流(UDP)那样允许丢包。TCP 提供了可靠性保证。

  • 三次握手

    1. 客户端发送 SYN:我想和你聊天。
    2. 服务器回复 SYN + ACK:好的,我也想和你聊,我准备好了。
    3. 客户端回复 ACK:收到,那我们开始吧。

    这三次握手确保了双方都具备发送和接收能力,并同步了初始序列号,为后续数据传输打下基础。

2.3 发送请求与接收响应

连接建立后,网络进程发送 HTTP 请求:

  • 请求行GET /index.html HTTP/1.1
  • 请求头:携带 Cookie(会话信息)、Authorization(JWT 令牌)、User-Agent 等关键信息。

服务器处理后返回响应:

  • 状态码

    • 200 OK:成功。
    • 301/302:重定向。例如访问 http://time.geekbang.org 会被强制跳转到 https:// 版本。
    • 404:资源未找到。
    • 500:服务器内部错误。
  • Content-Type:告诉浏览器接下来收到的数据是什么。如果是 text/html,浏览器就知道要准备渲染了;如果是 image/jpeg,则直接下载展示。


第三幕:沙箱中的施工队(渲染进程)

当网络进程拿到 HTML 数据流后,它不能直接渲染,而是通过 IPC(进程间通信) 将数据交给渲染进程(Renderer Process)

3.1 为什么要用沙箱?

渲染进程是浏览器的“施工队”,负责画图、砌墙(解析 DOM/CSS)、刷漆(合成图层)。但它运行在**安全沙箱(Sandbox)**中。

  • 最小权限原则:沙箱不是操作系统送的,而是浏览器利用 OS 底层机制(Windows Token、Linux Seccomp-BPF、macOS Seatbelt)主动构建的“牢房”。
  • 限制:渲染进程不能直接读写磁盘、不能直接访问网络、不能调用敏感系统 API。
  • 意义:即使渲染进程加载了恶意代码被黑客攻破,黑客也仅仅控制了“牢房”里的内容,无法窃取用户硬盘数据或控制系统。所有的网络请求和文件读写,都必须通过 IPC 请求主进程或网络进程代劳。

3.2 提交文档与解析

  1. 提交文档:渲染进程向主进程发送“确认提交”消息。主进程收到后,移除旧文档,更新 UI 状态。
  2. 构建 DOM 树:渲染进程接收 HTML 字节流,将其解析为 DOM 树(Document Object Model)。这是页面的骨架。
  3. 构建 CSSOM 树:同时,解析 CSS 文件,生成 CSSOM 树(CSS Object Model)。这是页面的样式规则。
  4. 生成渲染树(Render Tree) :将 DOM 和 CSSOM 合并,剔除不可见节点(如 display: none),形成渲染树。
  5. 布局(Layout) :计算每个节点在屏幕上的确切位置和大小。
  6. 绘制(Paint) :将渲染树转换为像素,生成位图。
  7. 合成(Composite) :如果有多个图层(如视频、固定定位元素),GPU 会将它们合成为最终的图像展示给用户。

在这个过程中,如果遇到 <script> 标签,解析可能会暂停(除非标记为 asyncdefer),去加载并执行 JavaScript。JS 可以修改 DOM 和 CSSOM,导致重新布局(Reflow)和重绘(Repaint)。


第四幕:底层基石与协议深析

在上述流程中,有几个核心的计算机基础概念支撑着整个大厦。

4.1 操作系统:进程与线程

  • 进程(Process) :资源分配的最小单元。浏览器的每个标签页通常对应一个独立的渲染进程,互不干扰。一个标签页崩溃不会影响其他标签页。
  • 线程(Thread) :CPU 调度的最小单元。一个进程内包含多个线程,如主线程(负责 JS 执行、DOM 操作)、合成线程(负责图层合成)、网络线程等。
  • 进程间通信(IPC) :由于进程隔离,主进程、网络进程、渲染进程之间必须通过 IPC 传递消息。这是多进程架构的开销所在,也是安全性的保障。

4.2 OSI 七层模型与 TCP/IP

虽然实际应用中常用 TCP/IP 四层模型,但理解 OSI 七层有助于厘清职责:

  1. 物理层:比特流传输(光纤、网线)。

  2. 数据链路层:MAC 地址寻址,帧传输。

  3. 网络层:IP 地址寻址,路由选择(路由器工作在此层)。

  4. 传输层:TCP/UDP 协议,端到端连接,流量控制,差错重传。

    • 丢包重传:TCP 通过序号和确认应答机制,确保数据包丢失后能重发,保证文件不损坏。
  5. 会话层:管理会话(如保持登录状态)。

  6. 表示层:数据格式转换(加密、压缩)。

  7. 应用层:HTTP、DNS 等协议,直接面向用户。

4.3 正向代理 vs 反向代理

  • 正向代理(代购) :客户端主动配置代理,代表客户端去访问服务器。服务器不知道真实客户端是谁,只知道代理。场景:翻墙、突破内网限制。
  • 反向代理(前台) :服务端部署代理,代表服务器接收请求。客户端不知道真实服务器是谁,只知道代理。场景:负载均衡、隐藏后端架构、SSL 卸载。Nginx 是最典型的反向代理服务器。

结语:从知识点到知识体系

回顾整个过程,从用户在地址栏敲下第一个字符,到页面绚丽地展现在眼前:

  1. 浏览器主进程像项目经理一样统筹全局,管理历史、处理交互、调度子进程。
  2. 网络进程像精明的采购员,通过复杂的 DNS 层级找到目标,利用 TCP 三次握手建立可靠通道,并通过负载均衡策略获取最优资源。
  3. 渲染进程像被关在沙箱中的专业施工队,在严格的安全限制下,将 HTML/CSS 代码一步步转化为像素图像。
  4. 底层的操作系统提供了进程隔离、线程调度和 IPC 机制,保障了系统的稳定与安全。
  5. 网络协议栈则像精密的交通规则,确保数据包在全球网络中准确、有序地抵达。

在春招面试中,当你能够用这样一条清晰的逻辑线,配合生动的比喻,将操作系统、计算机网络、浏览器原理串联起来时,你就不再是一个只会背诵“八股文”的考生,而是一个具备系统观的工程师。

记住,技术不仅仅是知识点的堆砌,更是万物互联的逻辑之美。 祝各位在春招中旗开得胜,Offer 多多!


作者注:本文基于 Chromium 架构及通用网络原理编写。实际浏览器实现可能因版本不同略有差异,但核心思想一致。希望这篇文章能成为你面试路上的坚实护城河。

从零开始用 TypeScript + React 打造类型安全的 Todo 应用

2026年3月6日 19:34

从零开始用 TypeScript + React 打造类型安全的 Todo 应用

引言:为什么选择 TypeScript + React?

React 作为当下最流行的前端库,以其组件化和声明式开发著称。而 TypeScript 作为 JavaScript 的超集,带来了静态类型检查强大的 IDE 支持。两者结合,堪称黄金搭档。

在纯 JavaScript 的 React 项目中,我们常常遇到:

  • 组件 props 类型不确定,传错属性难以及时发现;
  • 状态更新时,不小心修改了不该改的数据;
  • 调用自定义 Hook 返回的方法时,参数类型模糊不清。

TypeScript 可以完美解决这些问题。它让你在编写代码时就能发现错误,并且提供精准的代码补全和文档提示。今天我们就通过一个经典的 Todo 应用,来体验 TypeScript 在 React 项目中的魅力。


一、项目初始化

首先创建一个 React + TypeScript 项目(使用 Create React App):

npx create-react-app todo-ts --template typescript
cd todo-ts

项目结构我们会按照功能模块组织:

src/
├── components/
│   ├── TodoInput.tsx
│   ├── TodoItem.tsx
│   └── TodoList.tsx
├── hooks/
│   └── useTodos.ts
├── types/
│   └── todo.ts
├── utils/
│   └── storages.ts
└── App.tsx

二、定义核心类型:Todo 接口

数据是整个应用的核心,TypeScript 通过接口(interface) 来约束数据的形状。

src/types/todo.ts

export interface Todo {
    id: number;          // 唯一标识
    title: string;       // 标题
    completed: boolean;  // 是否完成
}

这个接口将被多个组件和 Hook 使用,确保整个应用中 Todo 的数据结构始终一致。


三、封装 localStorage 工具函数(泛型实战)

为了方便存取数据,我们封装两个工具函数,并利用 TypeScript 的泛型让它们支持任意类型。

src/utils/storages.ts

export function getStorage<T>(key: string, defaultValue: T): T {
    const value = localStorage.getItem(key);
    return value ? JSON.parse(value) : defaultValue;
}

export function setStorage<T>(key: string, value: T): void {
    localStorage.setItem(key, JSON.stringify(value));
}

泛型 <T> 的作用:

  • getStorage 的返回值类型与 defaultValue 类型一致,调用时可以明确知道返回的是什么类型。
  • setStoragevalue 参数类型为 T,确保存入的数据类型与取出时的预期相符。

例如,当我们存储 Todo 数组时,可以这样调用:

const todos = getStorage<Todo[]>('todos', []);
setStorage<Todo[]>('todos', todos);

如果传入了错误类型,TypeScript 会立即报错。


四、自定义 Hook:useTodos(核心业务逻辑)

useTodos 负责管理待办事项的状态,并与 localStorage 同步。我们看看 TypeScript 如何让这个 Hook 变得健壮。

src/hooks/useTodos.ts

import { useState, useEffect } from 'react';
import type { Todo } from '../types/todo';
import { getStorage, setStorage } from '../utils/storages';

const STORAGE_KEY = 'todos';

export default function useTodos() {
    // 1. 初始化状态,使用泛型指定状态类型,并懒加载从 localStorage 读取数据
    const [todos, setTodos] = useState<Todo[]>(() => 
        getStorage<Todo[]>(STORAGE_KEY, [])
    );

    // 2. 自动同步到 localStorage
    useEffect(() => {
        setStorage<Todo[]>(STORAGE_KEY, todos);
    }, [todos]);

    // 3. 添加待办
    const addTodo = (title: string) => {
        const newTodo: Todo = {
            id: +new Date(),      // 使用时间戳作为简单 ID
            title,
            completed: false,
        };
        setTodos([...todos, newTodo]);
    };

    // 4. 切换完成状态
    const toggleTodo = (id: number) => {
        const newTodos = todos.map(todo =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
        );
        setTodos(newTodos);
    };

    // 5. 删除待办
    const removeTodo = (id: number) => {
        const newTodos = todos.filter(todo => todo.id !== id);
        setTodos(newTodos);
    };

    return {
        todos,
        addTodo,
        toggleTodo,
        removeTodo,
    };
}

关键点解析

  • useState<Todo[]>:明确状态类型,后续 setTodos 只能传入 Todo[] 类型数据。
  • 懒初始化函数() => getStorage<Todo[]>(...) 确保 localStorage 读取只在首次渲染时执行。
  • useEffect 依赖 [todos]:每当 todos 变化,自动调用 setStorage 同步到本地。
  • 操作方法参数类型addTodo 接收 stringtoggleTodoremoveTodo 接收 number,杜绝传错参数的可能。
  • 返回对象:TypeScript 会自动推断返回值的类型,在组件中使用时能得到完整的类型提示。

五、编写 React 组件

1. TodoInput:受控输入框

src/components/TodoInput.tsx

import * as React from 'react';

interface Props {
    onAdd: (title: string) => void;   // 回调函数类型
}

const TodoInput: React.FC<Props> = ({ onAdd }) => {
    const [value, setValue] = React.useState<string>('');

    const handleAdd = () => {
        if (!value.trim()) return;      // 忽略空输入
        onAdd(value.trim());
        setValue('');                    // 清空输入框
    };

    return (
        <div>
            <input
                value={value}
                onChange={e => setValue(e.target.value)}
            />
            <button onClick={handleAdd}>添加</button>
        </div>
    );
};

export default TodoInput;

TypeScript 亮点

  • React.FC<Props> 定义了函数组件的 props 类型。
  • onAdd 的类型是 (title: string) => void,确保调用时传入正确的参数。
  • useState<string> 显式声明状态类型(虽然可以推导,但写上更清晰)。

2. TodoItem:单个待办项

src/components/TodoItem.tsx

import type { Todo } from '../types/todo';
import * as React from 'react';

interface Props {
    todo: Todo;
    onToggle: (id: number) => void;
    onRemove: (id: number) => void;
}

const TodoItem: React.FC<Props> = ({ todo, onToggle, onRemove }) => {
    return (
        <li>
            <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
                {todo.title}
            </span>
            <button onClick={() => onRemove(todo.id)}>删除</button>
        </li>
    );
};

export default TodoItem;

TypeScript 亮点

  • todo: Todo 明确传入的对象符合 Todo 接口。
  • onToggleonRemove 的参数类型明确为 number,使用 todo.id 时类型匹配。

3. TodoList:渲染列表

src/components/TodoList.tsx

import type { Todo } from '../types/todo';
import TodoItem from './TodoItem';
import * as React from 'react';

interface Props {
    todos: Todo[];
    onToggle: (id: number) => void;
    onRemove: (id: number) => void;
}

const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
    return (
        <ul>
            {todos.map(todo => (
                <TodoItem
                    key={todo.id}
                    todo={todo}
                    onToggle={onToggle}
                    onRemove={onRemove}
                />
            ))}
        </ul>
    );
};

export default TodoList;

TypeScript 亮点

  • todos: Todo[] 明确数组元素类型。
  • map 循环中,todo 自动推导为 Todo 类型。

六、组合应用:App 组件

src/App.tsx

import useTodos from './hooks/useTodos';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';

export default function App() {
    const { todos, addTodo, toggleTodo, removeTodo } = useTodos();

    return (
        <div>
            <h1>TodoList</h1>
            <TodoInput onAdd={addTodo} />
            <TodoList
                todos={todos}
                onToggle={toggleTodo}
                onRemove={removeTodo}
            />
        </div>
    );
}

在 App 中,我们从 useTodos 获取状态和方法,然后直接传递给子组件。由于所有类型都已定义,这里传参时完全类型安全,如果 addTodo 需要传入 number 类型,TypeScript 会立刻报错。


七、TypeScript 带来的好处总结

通过这个简单的 Todo 应用,我们可以看到 TypeScript 在 React 项目中的实际价值:

  1. 接口即文档TodoProps 等接口清晰地描述了数据结构,新成员加入项目能快速理解。
  2. 类型安全的状态管理useState<Todo[]> 确保状态始终符合预期,不会意外混入错误数据。
  3. 精确的事件处理onToggle={(id: number) => ...} 让调用方明确知道需要传递什么参数。
  4. 自动补全与重构:在 VS Code 中,输入 todo. 会立刻弹出 idtitlecompleted 提示;修改接口字段后,所有用到的地方都会报错,重构零风险。
  5. 减少运行时错误:很多低级错误(如传错参数类型、访问不存在的属性)在编译阶段就被捕获。

八、扩展思考

这个 Todo 应用虽然简单,但已经涵盖了 TypeScript + React 的核心实践。在此基础上,你可以继续探索:

  • 更高级的泛型:比如封装通用的请求函数,使用泛型约束返回数据类型。
  • 类型工具PartialPickOmit 等工具类型,用于灵活地处理类型变换。
  • Redux Toolkit + TypeScript:大型状态管理中的类型安全。
  • 类型定义文件(.d.ts):为第三方无类型库编写声明。

结语

TypeScript 并不是一个陌生的新语言,它只是为 JavaScript 添加了一层“安全网”。在 React 项目中使用 TypeScript,初期可能会觉得有些繁琐,但一旦你习惯了类型带来的自信和效率,就很难再回到纯 JavaScript 的开发方式。

希望这篇文章能帮助你迈出 TypeScript + React 的第一步。如果你有任何问题或想法,欢迎在评论区留言交流!


效果图

屏幕录制 2026-03-06 193319.gif参考资料TypeScript 官方文档 | React 官方类型定义

跨域方案汇总

2026年3月6日 19:10

一、先理解核心概念:什么是跨域?

跨域是浏览器的同源策略导致的安全限制:当请求的协议(http/https)、域名、端口三者任意一个与当前页面不一致时,就会触发跨域拦截。比如:

  • http://localhost:3000 访问 http://localhost:8080(端口不同)
  • http://www.a.com 访问 http://api.a.com(子域名不同)
  • http://a.com 访问 https://a.com(协议不同)

二、跨域请求的主流解决方案

1. CORS(跨域资源共享)- 最推荐的方案

核心原理:后端在响应头中添加允许跨域的配置,明确告知浏览器哪些域名、请求方法、头信息可以访问资源,是 W3C 标准,也是现代浏览器最支持的方案。

适用场景:前后端分离项目(Vue/React+Node/Java/PHP 等),后端可修改的场景。

实现示例

  • 后端配置(以 Node.js/Express 为例)

    const express = require('express');
    const app = express();
    
    // 全局跨域中间件
    app.use((req, res, next) => {
      // 允许指定域名跨域(* 表示允许所有,生产环境不推荐)
      res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3000');
      // 允许的请求方法
      res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
      // 允许的自定义请求头
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
      // 允许携带cookie(需前后端同时配置)
      res.setHeader('Access-Control-Allow-Credentials', 'true');
      // 预检请求(OPTIONS)的缓存时间,避免重复发送
      res.setHeader('Access-Control-Max-Age', '86400');
    
      // 处理预检请求(OPTIONS)
      if (req.method === 'OPTIONS') {
        return res.sendStatus(200);
      }
      next();
    });
    
    // 接口示例
    app.get('/api/data', (req, res) => {
      res.json({ code: 200, data: '跨域成功' });
    });
    
    app.listen(8080, () => console.log('后端服务运行在8080端口'));
    
  • 前端请求(以 Axios 为例)

    import axios from 'axios';
    
    axios({
      url: 'http://localhost:8080/api/data',
      method: 'GET',
      withCredentials: true, // 如需携带cookie,必须开启
    }).then(res => console.log(res.data));
    

2. 代理服务器 - 开发环境首选

核心原理:浏览器有跨域限制,但服务器之间没有。通过本地开发服务器(如 Webpack Dev Server、Vite)做代理,将前端请求转发到目标后端,规避跨域问题。

适用场景:本地开发阶段(生产环境需配置 Nginx 代理)。

实现示例

  • Vite 配置(vite.config.js)

    export default {
      server: {
        proxy: {
          // 匹配以/api开头的请求
          '/api': {
            target: 'http://localhost:8080', // 目标后端地址
            changeOrigin: true, // 开启跨域代理
            // rewrite: (path) => path.replace(/^/api/, ''), // 可选:去掉/api前缀
          },
        },
      },
    };
    
  • 前端请求(无需写完整域名)

    axios.get('/api/data').then(res => console.log(res.data));
    
  • 生产环境 Nginx 配置

    server {
      listen 80;
      server_name www.frontend.com;
    
      # 代理后端接口
      location /api/ {
        proxy_pass http://localhost:8080/api/; # 转发到后端地址
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
      }
    
      # 前端静态资源
      location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html; # 适配SPA路由
      }
    }
    

3. JSONP - 兼容老旧浏览器

核心原理:利用<script>标签不受同源策略限制的特性,通过动态创建 script 标签请求后端接口,后端返回一段带回调函数的 JS 代码,前端执行回调获取数据。

局限:仅支持 GET 请求,安全性较低(可能存在 XSS 风险),仅推荐兼容老旧浏览器时使用。

实现示例

  • 后端(Node.js/Express)

    app.get('/api/jsonp', (req, res) => {
      const { callback } = req.query; // 获取前端传的回调函数名
      const data = { code: 200, data: 'JSONP跨域成功' };
      // 返回回调函数+数据(格式:callback(data))
      res.send(`${callback}(${JSON.stringify(data)})`);
    });
    
  • 前端

    function jsonpRequest(url, callbackName) {
      return new Promise((resolve) => {
        // 1. 创建script标签
        const script = document.createElement('script');
        script.src = `${url}?callback=${callbackName}`;
        // 2. 定义回调函数
        window[callbackName] = (data) => {
          resolve(data);
          document.body.removeChild(script); // 执行完移除script
          delete window[callbackName]; // 清理全局函数
        };
        // 3. 插入到页面
        document.body.appendChild(script);
      });
    }
    
    // 调用
    jsonpRequest('http://localhost:8080/api/jsonp', 'handleJsonp').then(res => {
      console.log(res);
    });
    

4. postMessage - 页面间跨域通信

核心原理:HTML5 新增的 API,允许不同域名的页面(如 iframe、多窗口)之间安全地传递数据,不是用于 AJAX 请求,而是页面间通信。

适用场景:iframe 嵌套跨域页面、多窗口通信。

实现示例

  • 父页面(a.com

    <iframe id="iframe" src="http://b.com"></iframe>
    <script>
      const iframe = document.getElementById('iframe');
      // 等子页面加载完成后发送消息
      iframe.onload = () => {
        iframe.contentWindow.postMessage(
          { type: 'data', content: '来自a.com的消息' },
          'http://b.com' // 只允许发送给该域名,* 表示所有
        );
      };
      // 接收子页面的回复
      window.addEventListener('message', (e) => {
        if (e.origin === 'http://b.com') { // 验证来源,防止恶意消息
          console.log('收到回复:', e.data);
        }
      });
    </script>
    
  • 子页面(b.com

    // 接收父页面消息
    window.addEventListener('message', (e) => {
      if (e.origin === 'http://a.com') {
        console.log('收到消息:', e.data);
        // 回复父页面
        e.source.postMessage({ type: 'reply', content: '已收到消息' }, e.origin);
      }
    });
    

5. 其他补充方案

  • WebSocket:不受同源策略限制,适用于实时通信(如聊天、推送),协议是 ws/wss,而非 http。
  • document.domain:仅适用于主域名相同、子域名不同的场景(如a.comapi.a.com),需双方页面设置document.domain = 'a.com',但兼容性差,不推荐。
  • CORS 的预检请求:PUT/DELETE/ 带自定义头的 POST 请求会先发送 OPTIONS 预检请求,后端需正确处理(如上面 CORS 示例中的 OPTIONS 逻辑)。

总结

  1. 核心推荐方案:开发环境用代理服务器,生产环境用CORS(后端配置)+ Nginx 代理,覆盖 99% 的场景;
  2. 特殊场景:老旧浏览器兼容用JSONP,页面间通信用postMessage,实时通信用WebSocket
  3. 关键原则:跨域的本质是浏览器的限制,服务器之间无跨域,因此 “代理” 和 “后端配置 CORS” 是最根本的解决思路。

选择方案时优先看场景:能改后端就用 CORS,开发阶段用代理,仅 GET 请求且需兼容老浏览器才用 JSONP。

javascript 结构化克隆

作者 helloweilei
2026年3月6日 18:45

Node.js 支持 structuredClone API。这是一个全局可用的函数,用于执行深拷贝,自 Node.js 17.0.0 版本开始提供原生支持。

🚀 如何在 Node.js 中使用

structuredClone 是一个全局函数,你可以在任何地方直接调用,无需引入任何模块。

基本语法:

const clonedObject = structuredClone(originalObject);

它还可以接受一个可选的 options 参数,用于转移可转移对象(如 ArrayBuffer),而不是克隆它们。

structuredClone(value, { transfer: [transferableObjects] });

📝 使用示例

1. 基础深拷贝 这是最常用的方式,用于创建一个与原对象完全独立的副本。

const original = {
  name: "Node.js",
  types: ["JavaScript", "C++"],
  details: { stable: true }
};

// 使用 structuredClone 进行深拷贝
const cloned = structuredClone(original);

// 修改克隆对象的属性
cloned.types.push("Rust");
cloned.details.stable = false;

console.log(original.types); // 输出: ['JavaScript', 'C++'] (原数组未改变)
console.log(original.details.stable); // 输出: true (原对象未改变)
console.log(cloned.types); // 输出: ['JavaScript', 'C++', 'Rust']

2. 处理复杂数据类型 structuredClone 的强大之处在于它能正确处理许多 JSON.stringify 无法处理的类型。

const complexOriginal = {
  date: new Date(),
  regex: /hello/gi,
  map: new Map([['key', 'value']]),
  set: new Set([1, 2, 3]),
  circular: {} 
};
complexOriginal.circular.self = complexOriginal; // 循环引用

const complexClone = structuredClone(complexOriginal);

console.log(complexClone.date instanceof Date); // 输出: true (而 JSON 方法会将其转为字符串)
console.log(complexClone.regex instanceof RegExp); // 输出: true (而 JSON 方法会将其转为空对象 {})
console.log(complexClone.map instanceof Map); // 输出: true
console.log(complexClone.set instanceof Set); // 输出: true
console.log(complexClone.circular.self === complexClone); // 输出: true (循环引用被保留)

3. 转移 ArrayBuffer 当处理大型二进制数据时,可以使用 transfer 选项将数据的所有权从原对象转移到克隆对象,这是一种零拷贝操作,性能更好。

const buffer = new ArrayBuffer(16);
const int32View = new Int32Array(buffer);
int32View[0] = 42;

const transferred = structuredClone({ buf: buffer }, { transfer: [buffer] });

console.log(transferred.buf.byteLength); // 输出: 16 (克隆对象中的 buffer 仍可用)

// 尝试访问原 buffer 会导致错误,因为它已被转移
// console.log(buffer.byteLength); // 抛出 TypeError: Cannot perform operation on a detached ArrayBuffer

⚠️ 重要限制

尽管功能强大,structuredClone 无法克隆所有内容。以下类型会导致抛出 DataCloneError 异常或被忽略:

  • 函数Function
  • DOM 节点(在 Node.js 中不适用,但在浏览器环境中需要注意)
  • 属性描述符settergetter
  • 原型链(克隆后的对象不再继承自原对象的原型)
  • Symbol
  • WeakMapWeakSet

🔧 兼容旧版本 Node.js

如果你的 Node.js 版本低于 17,直接使用会报错。可以编写一个回退函数来保证代码兼容性:

function deepClone(obj) {
  if (typeof structuredClone === 'function') {
    return structuredClone(obj);
  } else {
    // 注意:这是一个功能受限的回退方案
    return JSON.parse(JSON.stringify(obj));
  }
}

对于更完整的兼容性,也可以考虑使用第三方 polyfill,如 @ungap/structured-clone

🌐 浏览器支持详情

structuredClone API 在浏览器中也得到了广泛的支持

所有现代浏览器(包括 Chrome、Firefox、Safari、Edge)都从 2022 年 3 月起,在稳定版本中提供了对该方法的支持。你可以像在 Node.js 中一样,在浏览器的主线程或 Web Worker 中直接使用它。

根据最新的标准文档和浏览器兼容性数据,各主流浏览器的支持情况如下:

浏览器 支持版本 备注
Chrome 98+ 从 v98 开始支持
Edge 98+ 从 v98 开始支持
Firefox 94+ 从 v94 开始支持
Safari 15.4+ 从 v15.4 开始支持
Opera 84+ 基于 Chromium,对应支持
Internet Explorer 不支持 任何版本都不支持

这个 API 目前已被广泛使用,你可以在 Can I use 上查看最新的统计数据。

💡 使用方式与限制

在浏览器中使用时,其语法和功能与 Node.js 完全一致:

// 创建一个包含各种类型数据的对象
const original = {
  name: '浏览器',
  date: new Date(),
  map: new Map([['key', 'value']])
};

// 进行深拷贝
const cloned = structuredClone(original);

console.log(cloned.date instanceof Date); // true

主要限制(与 Node.js 环境相同):

  • 无法克隆 函数 (Function)
  • 无法克隆 DOM 节点
  • 不会复制对象的原型链
  • 不会复制属性描述符、setter/getter 等元数据

📦 如何处理旧版本浏览器?

如果你的用户群体可能还在使用较老的浏览器版本(如 Safari 15.4 以下),或者需要支持 Internet Explorer,你可以使用 polyfill 来提供降级方案。

  1. 核心 polyfill 库:推荐使用 core-js 提供的稳定 polyfill 。

    npm install core-js
    

    然后在你的代码入口处引入:

    import 'core-js/stable/structured-clone';
    // 或者
    require('core-js/stable/structured-clone');
    
  2. 自定义回退函数:你也可以自己编写一个简单的降级逻辑,但需要注意,JSON.parse(JSON.stringify()) 这种方式无法处理 DateMapSet、循环引用等复杂情况。

    function safeStructuredClone(obj) {
      if (typeof structuredClone === 'function') {
        return structuredClone(obj);
      } else {
        // 警告:这是一个功能受限的降级方案,仅适用于简单对象
        try {
          return JSON.parse(JSON.stringify(obj));
        } catch (e) {
          console.error('当前环境不支持深拷贝该对象', e);
          return null;
        }
      }
    }
    

总的来说,对于绝大多数现代浏览器项目,你可以放心地直接使用 structuredClone。如果你需要支持非常老的浏览器,或者有兼容性方面的顾虑,可以告诉我,我们再一起看看具体的解决方案。

TypeScript 协变与逆变:你的泛型组件 Props 为什么总是类型报错?

2026年3月6日 18:42

TypeScript 协变与逆变:你的泛型组件 Props 为什么总是类型报错?

上周封装一个通用列表组件,Props 里有个 onSelect 回调,类型大概长这样:

interface ListProps<T> {
  items: T[]
  onSelect: (item: T) => void
}

看着没毛病吧?结果一传具体类型就炸了——Type '(item: Dog) => void' is not assignable to type '(item: Animal) => void'

改了半天,越改越乱。后来才搞明白,这压根不是泛型的问题,是函数参数的逆变在搞事。

先把协变和逆变说人话

这俩词听着唬人,其实就一句话:父子类型在"容器"里的方向问题

假设 Dog extends Animal,那 Dog 是 Animal 的子类型。

interface Animal { name: string }
interface Dog extends Animal { breed: string }

let animal: Animal = { name: '旺财' }
let dog: Dog = { name: '旺财', breed: '柴犬' }

animal = dog // ✅ 子类型赋值给父类型,没问题
// dog = animal // ❌ 反过来不行,animal 上没有 breed

这是最基本的子类型赋值,没啥好说的。问题出在把类型塞进容器之后,方向可能会反。

协变(Covariant)——方向不变:

// Dog 是 Animal 的子类型
// → Dog[] 也是 Animal[] 的子类型(方向一致)
let dogs: Dog[] = [{ name: '旺财', breed: '柴犬' }]
let animals: Animal[] = dogs // ✅ 协变:子类型数组 → 父类型数组

逆变(Contravariant)——方向反了:

type Handler<T> = (arg: T) => void

let handleAnimal: Handler<Animal> = (a) => console.log(a.name)
let handleDog: Handler<Dog> = (d) => console.log(d.breed)

// 注意这里!方向反过来了
handleDog = handleAnimal // ✅ 父类型的 handler 赋值给子类型的 handler
// handleAnimal = handleDog // ❌ 反过来不行

等等,为什么 Handler<Animal> 反而能赋值给 Handler<Dog>?Dog 明明是子类型啊,怎么函数这里反过来了?

为什么函数参数天然逆变

想一下实际调用场景:

// handleDog 的调用方会传入一个 Dog
handleDog({ name: '旺财', breed: '柴犬' })

// 如果 handleDog 的实际实现是 handleAnimal:
// (a) => console.log(a.name)
// 收到一个 Dog,只用了 name → 完全没问题

// 反过来,如果 handleAnimal 的实际实现是 handleDog:
// (d) => console.log(d.breed)
// 收到一个普通 Animal,没有 breed → 运行时爆炸

所以函数参数是逆变的:你承诺能处理子类型,那实际实现必须至少能处理父类型。处理能力越宽泛,才越安全。

用个不太严谨但好记的比喻:你招了个岗位说要"能修柴犬的兽医",来了个"什么动物都能修的全科兽医"——没问题。反过来,岗位要"全科兽医",来了个"只会修柴犬的"——不行。

回到那个组件:问题出在哪

回到开头的 ListProps<T>

interface ListProps<T> {
  items: T[]          // T 在输出位置 → 协变
  onSelect: (item: T) => void  // T 在函数参数位置 → 逆变
}

同一个泛型参数 T,在 items 里是协变的,在 onSelect 的参数里是逆变的。这就导致 T 处于一个既要协变又要逆变的位置——术语叫不变(Invariant)

实际后果:

function renderList<T>(props: ListProps<T>) { /* ... */ }

const dogList: ListProps<Dog> = {
  items: [{ name: '旺财', breed: '柴犬' }],
  onSelect: (dog) => console.log(dog.breed)
}

// 想把 ListProps<Dog> 当 ListProps<Animal> 用?
// 不行。因为 T 既协变又逆变,类型锁死了
const animalList: ListProps<Animal> = dogList // ❌ Type error

这在封装通用组件时特别烦。你想让组件接受各种子类型的 Props,但类型系统不让。

实战解法:拆开读写位置

核心思路:别让同一个泛型参数同时出现在协变和逆变位置

方案一:用 extends 约束代替直接传递

interface ListProps<T extends Animal> {
  items: T[]
  // 回调参数放宽到 Animal,不跟 T 绑定
  onSelect: (item: Animal) => void
}

// 现在可以这样用
function DogList() {
  const props: ListProps<Dog> = {
    items: [{ name: '旺财', breed: '柴犬' }],
    onSelect: (animal) => console.log(animal.name) // 只能访问 Animal 的属性
  }
}

缺点很明显:onSelect 里拿不到 Dog 特有的属性。有时候能接受,有时候不行。

方案二:分离读和写的泛型

interface ListProps<TItem, TSelect = TItem> {
  items: TItem[]                    // TItem 只在协变位置
  onSelect: (item: TSelect) => void // TSelect 只在逆变位置
}

// 精确版:读写都是 Dog
type DogListExact = ListProps<Dog, Dog>

// 宽松版:读 Dog,回调接受 Animal 就行
type DogListLoose = ListProps<Dog, Animal>

这个方案灵活,但两个泛型参数用起来心智负担大。组件泛型参数一多,调用方看着就头疼。

方案三:我个人更倾向的方式

实际项目里我用得最多的是这种——回调用泛型函数签名

interface ListProps<T> {
  items: T[]
  onSelect: <U extends T>(item: U) => void  // 回调本身是泛型的
}

// 或者更常见的做法:直接用 readonly 把数组锁住
interface ListProps<T> {
  items: readonly T[]  // readonly → 去掉数组的"写"能力 → 纯协变
  onSelect: (item: T) => void
  renderItem: (item: T) => React.ReactNode
}

第二种写法虽然没完全解决逆变问题,但 readonly 至少在数组层面消除了一些不安全的操作。真实 React 组件里,items 基本不会在组件内部被修改,加 readonly 是好习惯。

strictFunctionTypes 这个坑必须提

TypeScript 2.6 引入了 strictFunctionTypes,开了之后函数参数才是严格逆变的。没开的话,函数参数是双变的(Bivariant)——既协变又逆变都允许。

// strictFunctionTypes: false(默认在非 strict 模式下)
handleAnimal = handleDog // ✅ 不报错,但运行时可能炸
handleDog = handleAnimal // ✅ 这个本来就是安全的

// strictFunctionTypes: true(推荐)
handleAnimal = handleDog // ❌ 正确地报错了
handleDog = handleAnimal // ✅

之前接手一个老项目,strict 没全开,一堆回调类型赋值都没报错。上线后各种 Cannot read property of undefined,查了半天才发现是函数参数类型不安全赋值导致的。后来开了 strict 一编译,好家伙,200 多个类型错误。

所以新项目一定开 strict。老项目迁移的话,可以先单独开 strictFunctionTypes,影响范围相对可控。

复杂场景:嵌套泛型组件的 Props 传递

真实业务里,组件经常是嵌套的。比如一个 Table 里面用了 Column

interface ColumnProps<T> {
  dataIndex: keyof T
  render: (value: T[keyof T], record: T) => React.ReactNode
  // render 的两个参数都是逆变位置
  // dataIndex 是... 额,keyof T 比较特殊,先不展开
}

interface TableProps<T> {
  data: readonly T[]
  columns: ColumnProps<T>[]
  onRowClick?: (record: T) => void
}

这里 ColumnProps<T> 里的 render 参数是逆变的,而 ColumnProps<T>[] 整体又被放在 TableProps<T> 的协变位置。逆变套协变,结果还是逆变。协变套协变还是协变,逆变套逆变反而变成协变——跟负负得正一个道理。

// 型变的组合规则:
// 协变 × 协变 = 协变  (正 × 正 = 正)
// 协变 × 逆变 = 逆变  (正 × 负 = 负)
// 逆变 × 逆变 = 协变  (负 × 负 = 正)

实际写组件时不需要时刻想着这个公式。但如果碰到类型报错死活想不通,把泛型参数在每一层的位置标出来,按这个规则推一遍,基本就清楚了。

来个实际踩坑场景:

interface FormFieldProps<T> {
  value: T                          // 协变
  onChange: (newValue: T) => void   // 逆变
  validate: (value: T) => string | null  // 逆变
}

// 想做一个高阶组件,给 FormField 加默认校验
function withValidation<T>(
  WrappedField: React.ComponentType<FormFieldProps<T>>,
  defaultValidator: (value: T) => string | null
) {
  // 这里 WrappedField 的泛型参数 T 在 ComponentType 的参数位置
  // ComponentType<P> 中 P 是逆变的(props 是函数参数)
  // 所以 T 经过两层:逆变(ComponentType的P) × 逆变(onChange的参数) = 协变
  // 也就是说对于 onChange 这条链路,T 最终是协变的
  // 但对于 value 这条链路:逆变(ComponentType的P) × 协变(value) = 逆变
  // T 同时协变和逆变 → 不变
  // 所以这个 HOC 的 T 是不变的,不能传子类型替代
  return WrappedField
}

看到没?HOC 里泛型的型变分析能绕晕人。我的经验是:如果高阶组件的类型推导搞得太复杂,换成 hooks 或者 render props 往往更好处理。不是说 HOC 不能用,而是 HOC 天然多一层类型嵌套,在 TS 里确实更容易出问题。

几个判断型变的快速技巧

写了这么多,分享几个我日常用的快速判断方法:

看位置:

  • 函数返回值、属性值、Promise 的 resolve 值 → 协变位置(输出)
  • 函数参数、回调参数 → 逆变位置(输入)

看 readonly:

  • readonly T[]T[] 在型变上更友好,因为去掉了写入操作
  • Readonly<Record<string, T>> 同理

看报错:

  • 如果报错是 Type 'A' is not assignable to type 'B',而你觉得 A 明明是 B 的子类型——大概率是你碰到逆变了,检查一下这个类型是不是在函数参数位置

实在搞不定:

// 最终手段:用 type assertion 或 as unknown as
// 但要确保你真的理解为什么类型不兼容
// 别无脑 as any,那跟写 JavaScript 有什么区别
const handler = dogHandler as unknown as Handler<Animal>

聊到这

协变逆变不是什么高深的类型体操。说到底就一件事:类型安全在"输入"和"输出"两个方向上的要求是相反的。输出可以更具体(协变),输入必须更宽泛(逆变)。

设计泛型组件 Props 的时候,把每个泛型参数的位置标一下,哪些是输出、哪些是输入,型变关系自然就清楚了。碰到实在不兼容的情况,优先考虑拆分泛型参数或者调整 API 设计,而不是上来就 as any

有一点我到现在也没想通:TypeScript 对方法(method)的类型检查默认是双变的,而对函数属性(function property)才是逆变的。比如 interface Foo { bar(x: T): void }interface Foo { bar: (x: T) => void }strictFunctionTypes 下的行为居然不一样。官方说是为了兼容性,但这个设计确实容易让人踩坑。

从 0 手写 Promise:拆解 Promise 链式调用的实现原理

作者 龙猫不热
2026年3月6日 18:38

手写promise思路

1. promise本质

本质promise就是一个状态机 + 回调队列 + 链式调用规则

核心就3件事:

  1. 状态管理
  2. 回调存储执行
  3. then 链式调用

2. 第一步: 实现 Promise状态机

promise有三种状态

pending   初始状态
fulfilled 成功
rejected  失败

状态转换规则:

pending -> fulfilled
pending -> rejected

注意:

状态一旦改变就不能再变

所以需要:

this.status = "pending"
this.value = undefined
this.reason = undefined

3. 第二步: 实现resolve / reject

Promise 构造函数会接受一个 executor

new Promise((resolve,reject)=>{})

这个函数:

  • 立即执行
  • 会收到resolvereject

实现逻辑:

// value: resolve的值
// reason: reject的值, 失败原因

const resolve = (value)=>{
  if(this.status !== "pending") return
  this.status = "fulfilled"
  this.value = value
}

const reject = (reason)=>{
  if(this.status !== "pending") return
  this.status = "rejected"
  this.reason = reason
}

注意两点:

  1. 状态只能改一次
  2. 保留value / reason

4. 第三步: 实现then (核心)

Promise必须支持:

promise.then(onFulfilled, onRejected)

then 有三个行为:


4.1 情况1: Promise 已经fulfilled

立即执行 onFulfilled

但注意:

必须放到微任务

queueMicrotask(()=>{
  onFulfilled(this.value)
})

4.2 情况2: Promise 已经rejected

执行onRejected

queueMicrotask(()=>{
  onRejected(this.reason)
})

4.3 情况3: Promise 还在 pending

这时候问题来了:

resolve 可能未来才执行

所以: 要把回调存起来

this.onFulfilledCallbacks = []
this.onRejectedCallbacks = []

then 里:

// 保证回调可以正常使用
onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) => v;
onRejected =
    typeof onRejected === "function"
    ? onRejected
: (r) => {
    throw r;
};


this.onFulfilledCallbacks.push(() => {
    queueMicrotask(() => {
        onFulfilled(this.value)
    });
});

this.onRejectedCallbacks.push(() => {
    queueMicrotask(() => {
        onFulfilled(this.value)
    });
});

等到 resolve / rejected时

this.onFulfilledCallbacks.forEach(fn=>fn());
或
this.onRejectedCallbacks.forEach(fn=>fn());

5. 第四步: then必须返回新的Promise

规范规定:

then 一定要返回一个新的 Promise
const promise2 = new MyPromise(...)
return promise2

因为Promise需要支持链式调用

promise
  .then()
  .then()
  .then()

6. 第五步: then 返回值决定下一个Promise

最难的部分

const x = onFulfilled(this.value)

然后:

promise2 的状态 = x 决定

规则

6.1 情况1: x是普通值

resolve(x)

例:

then(()=>100)

6.2 情况2: x是 promise

then(()=>Promise)

那就:

promise 跟随这个 Promise最后的执行状态

例:

then(()=>new Promise(...))

6.3 情况3: x是 thenable

thenable:

const obj = { then: function(){} };
// 或
function fn(){
    // ....
}

fn.prototype.then = function(){
    // ....
}

也要按照 Promise处理


7. 第六步:reslovePromise 算法

所以需要写一个 统一解析函数

resolvePromise(promise2,x,resolve,reject)

作用:

解析x的类型

步骤:

7.1 防止循环引用

if(promise2 === x){
 reject(new TypeError("循环引用"))
}

例:

p.then(() => p) // 会死循环

7.2 如果 x 是对象或函数

typeof x === 'object' || typeof x === 'function'

说明可能是 thenable。


7.3 取 then

then = x.then

7.4 如果then是函数

当做Promise处理:

then.call(x, resolve, reject)

使用call的原因是防止里面有this调用

const obj = {
    value: 111,
    then(){
        console.log(this.value);
    }
}

7.5 如果then 不是函数

说明只是普通对象, 直接resolve:

resolve(x)

7.6 called锁

Promise规范规定

resolve / reject 只能调用一次

所以:

let called = false;

8. 第七步: 为什么要微任务

Promise 规范规定:

then 回调必须是异步执行的

所以必须:

queueMicrotask: 传入一个回调函数, 将回调函数中的代码加入到微任务队列中执行
// https://developer.mozilla.org/zh-CN/docs/Web/API/Window/queueMicrotask

而不是同步:

例:

Promise.resolve(1)
console.log(2)

// 2
// 1

9. 完整代码:

实现顺序

1 实现 Promise 状态
2 实现 resolve / reject
3 executor 立即执行
4 then 方法
5 then 返回新 Promise
6 回调队列
7 resolvePromise 解析返回值
8 微任务

完整结构其实只有 三块

class MyPromise
    constructor
    then

resolvePromise
class MyPromise {
  constructor(executor) {
    // 初始状态
    this.status = "pending";
    // 成功的值
    this.value = undefined;
    // 失败的原因
    this.reason = undefined;

    // 存储成功和失败的回调函数
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      // 保证状态不可逆
      if (this.status !== "pending") return;
      this.status = "fulfilled";
      this.value = value;
      // 执行成功的回调函数
      this.onFulfilledCallbacks.forEach((callback) => callback());
    };

    const reject = (reason) => {
      // 保证状态不可逆
      if (this.status !== "pending") return;
      this.status = "rejected";
      this.reason = reason;
      // 执行失败的回调函数
      this.onRejectedCallbacks.forEach((callback) => callback());
    };

    // 执行 executor,并捕获异常
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) => v;
    onRejected =
      typeof onRejected === "function"
        ? onRejected
        : (r) => {
            throw r;
          };

    // .then需要可以返回一个新的promise
    // 并且promise的状态是按照回调函数的结果来做的
    const promise2 = new MyPromise((resolve, reject) => {
      if (this.status === "fulfilled") {
        queueMicrotask(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      } else if (this.status === "rejected") {
        queueMicrotask(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      } else if (this.status === "pending") {
        // 将回调函数保存起来,等到状态改变的时候再执行
        onFulfilled &&
          this.onFulfilledCallbacks.push(() => {
            queueMicrotask(() => {
              try {
                const x = onFulfilled(this.value);
                resolvePromise(promise2, x, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          });
        onRejected &&
          this.onRejectedCallbacks.push(() => {
            queueMicrotask(() => {
              try {
                const x = onRejected(this.reason);
                resolvePromise(promise2, x, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          });
      }
    });
    return promise2;
  }

  /**
   * 1. 首先需要判断x是否和promise2相等, 如果相等将会造成循环引用, 需要reject出去一个error
   * 2. 如果 x 不是对象 或 函数, 则说明是普通值, resolve出去即可
   * 3. 如果是对象/函数, 需要看属性/原型上是否有 `then` 函数, 只要有就当成 promise 来处理
   * 4. 如果是对象/函数, 但没有`then`函数 或 `then`不是函数, 则直接resolve出去即可
   *
   * @param {*} promise2 将要返回的promise实例
   * @param {*} x 回调函数的返回值
   * @param {*} resolve
   * @param {*} reject
   * @returns
   */
}

function resolvePromise(promise2, x, resolve, reject) {
  if (x == promise2) {
    reject(new TypeError("Chaining cycle detected for promise"));
    return;
  }

  // 因为null也是object, 所以组合判断下
  if ((typeof x === "object" && x !== null) || typeof x == "function") {
    // 到这里说明是对象/函数

    let then;
    // Promise 只能 resolve 或 reject 一次, 做个锁
    let called = false;
    // 获取 then放到 try...catch中, 防止找不到then属性报错
    try {
      then = x.then;

      // 如果是个函数, 调用它
      // called做锁, 避免多次调用resolve 或 reject
      // 并且递归调用resolvePromise, 处理then返回的值
      if (typeof then === "function") {
        then.call(
          x,
          (y) => {
            if (called) return;
            called = true;
            resolvePromise(promise2, y, resolve, reject);
          },
          (r) => {
            // reject就不需要再递归调用了
            if (called) return;
            called = true;
            reject(r);
          },
        );
      } else {
        // then不是函数就直接 resolve出去
        resolve(x);
      }
    } catch (error) {
      // 这边也要判断一下,如果called已经被调用过了, 就不再调用
      if (called) return;
      called = true;
      reject(error);
    }
  } else {
    resolve(x);
  }
}

Python 性能微观世界:列表推导式 vs for 循环

2026年3月7日 09:23

前言:你一定听过列表推导式(List Comprehension),但作为一个追求性能的工程狮,我们不能只看它写起来帅,更要搞清楚:在底层,凭什么往往比传统的 for 循环更快?


1. 语义对比:从“怎么做”到“做什么”

  • for 循环:命令式编程。你告诉 Python:先创建一个空列表,然后取出一个元素,处理一下,最后塞进列表。
  • 列表推导式:声明式编程。你告诉 Python:我想要这样一个列表,它的元素来源于此,规则如下。

Python

# 需求:生成 1 到 100 万的平方列表
# for 循环写法
squares_for = []
for i in range(1000000):
    squares_for.append(i * i)

# 列表推导式写法
squares_comp = [i * i for i in range(1000000)]

2. 性能深度拆解:为什么推导式更快?

很多人以为推导式只是 for 循环的简写,其实不然。两者的差异在于字节码(Bytecode)执行效率

A. 减少了 append 的函数查找

for 循环中,每次执行 squares_for.append(),Python 都要做两件事:

  1. 加载属性:在内存中查找 squares_for 对象的 append 方法。
  2. 函数调用:调用该方法并将结果推入列表。

而在列表推导式中,Python 使用了专门的字节码指令 LIST_APPEND。这是一条直接在 C 语言层面实现的底层操作,跳过了在循环中反复查找 append 属性的过程。

B. 字节码证据

我们用 Python 内置的 dis 模块来观察两者的“真面目”:

Python

import dis

def for_loop():
    l = []
    for i in range(10):
        l.append(i)

def list_comp():
    l = [i for i in range(10)]

print("--- For 循环字节码 ---")
dis.dis(for_loop)
print("\n--- 列表推导式字节码 ---")
dis.dis(list_comp)

关键差异点:

  • for_loop 中会反复出现 LOAD_METHODCALL_METHOD
  • list_comp 中直接使用了 LIST_APPEND,执行效率更高。

3. 实战避坑:推导式是万能的吗?

虽然推导式快,但在工程实践中,我们要警惕三个“重灾区”:

① 内存炸弹

推导式会立即生成整个列表。如果你处理的是 10 亿条数据,列表推导式会瞬间撑爆你的 RAM。

  • 对策:使用生成器表达式(Generator Expression) 。只需把 [] 换成 ()

Python

# 生成器:省内存,随用随取,O(1) 空间复杂度
squares_gen = (i * i for i in range(1000000000)) 

② 可读性灾难(Nested Logic)

当推导式嵌套超过两层,或者带有复杂的 if-else 时,它就变成了“代码天书”。

  • 原则:如果一行推导式超过 80 个字符,或者逻辑嵌套太深,请老老实实写回 for 循环。

③ 逻辑副作用

推导式应该只用于生成新列表。如果你在推导式里调用具有副作用的函数(比如打印 log、修改全局变量),那简直是代码维护者的噩梦。


4. 性能实测数据

在 Python 3.11+ 环境下,处理 1000 万个数据点:

方法 耗时 (ms) 相对速度
for 循环 + append ~850 100% (基准)
map + lambda ~720 118%
列表推导式 ~510 166%

💡 总结

  1. 首选推导式:在简单的数据转换和过滤场景下,列表推导式是性能和简洁度的双重赢家。
  2. 拒绝炫技:嵌套推导式(Nested Comprehension)是代码质量的杀手,业务代码中尽量保持单层。
  3. 大数据的归宿:处理大数据流时,请务必转投 生成器(Generator) 的怀抱。

TypeScript 强力护航:PropType 与组件事件类型的声明

作者 wuhen_n
2026年3月7日 07:34

前言

在 Vue 3 + TypeScript 的项目中,组件的类型安全是一个核心话题。很多开发者可能有过这样的经历:使用一个第三方组件时,完全不知道它接受哪些 Props,也不知道事件应该传递什么参数,只能去翻文档。或者在自己的项目中,修改了一个组件的 Props,结果到处报错,不得不全局搜索手动修改。

TypeScript 的出现改变了这一切。通过为组件 Props 和事件声明类型,我们不仅能获得完美的智能提示,还能让编译器在开发阶段就发现类型错误。本文将深入探讨如何在 Vue 3 中为组件定义类型安全的 Props 和事件,包括复杂的泛型组件实现。

Vue 组件类型系统的演进

Options API 中的 Prop 类型:运行时校验

在 Options API 中,我们通过对象形式定义 Props:

export default {
  props: {
    // 基础类型检查
    name: String,
    age: Number,
    
    // 带验证的写法
    email: {
      type: String,
      required: true,
      validator: (value: string) => value.includes('@')
    },
    
    // 复杂类型
    user: {
      type: Object,
      default: () => ({})
    }
  }
}

这种写法存在很多局限性:

  • 运行时类型检查:这些类型只在运行时验证,TypeScript 无法在编译时捕获错误
  • 复杂类型无法表达:user: Object 无法描述对象的内部结构
  • 没有智能提示:在模板中使用 props 时,编辑器不知道有哪些属性

Composition API 带来的类型优势

Composition API 配合 TypeScript,让类型推导变得更加强大:

<script setup lang="ts">
// 现在可以获得类型推导
const props = defineProps({
  name: String,
  age: Number
})

// props.name 被推导为 string | undefined
// props.age 被推导为 number | undefined
</script>

但这种方法仍然有局限性,无法定义复杂的嵌套类型。

为什么需要显式的 PropType?

当 Props 的类型不是简单的 String、Number 等构造函数时,就需要 PropType 来帮助 TypeScript 理解类型。我们先来看一个反例:

// ❌ 这样写,TypeScript 会报错
defineProps({
  user: {
    type: Object as User, // 'User' only refers to a type, but is being used as a value here
    required: true
  }
})

正确写法:

defineProps({
  user: {
    type: Object as PropType<User>, // 告诉 TypeScript 这是一个 User 类型
    required: true
  },
  
  // 联合类型
  status: {
    type: String as PropType<'active' | 'inactive'>,
    default: 'active'
  },
  
  // 复杂对象
  config: {
    type: Object as PropType<{
      theme: string
      fontSize: number
    }>,
    default: () => ({ theme: 'light', fontSize: 14 })
  }
})

Props 定义的三种方式

运行时声明 + 类型推导(基础写法)

<script setup lang="ts">
// 基础类型会自动推导
const props = defineProps({
  name: String,           // props.name: string | undefined
  age: Number,            // props.age: number | undefined
  isActive: Boolean,      // props.isActive: boolean | undefined
  tags: Array,            // props.tags: any[] | undefined
  user: Object            // props.user: Record<string, any> | undefined
})

// 设置默认值
const propsWithDefault = defineProps({
  count: {
    type: Number,
    default: 0
  },                      // props.count: number
  items: {
    type: Array,
    default: () => []
  }                       // props.items: any[]
})
</script>
  • 优点:写法简单,有运行时类型检查
  • 缺点:复杂类型无法表达,如 string[] 会被推导为 any[]

纯类型声明(推荐)

这是 Vue 3.3+ 推荐的方式,使用 TypeScript 接口或类型别名:

<script setup lang="ts">
// 定义 Props 接口
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

interface Config {
  theme: 'light' | 'dark'
  fontSize: number
  showAvatar?: boolean
}

interface Props {
  title: string
  count?: number
  user: User
  config: Config
  tags: string[]
  status: 'loading' | 'success' | 'error'
}

// 直接使用接口
const props = defineProps<Props>()

// 需要默认值时,使用 withDefaults
const propsWithDefault = withDefaults(defineProps<Props>(), {
  count: 0,
  tags: () => [],
  config: () => ({ theme: 'light', fontSize: 14 })
})
</script>
  • 优点:

    • 完美的类型推导
    • 支持任何复杂的 TypeScript 类型
    • 编辑器智能提示完美
  • 缺点:

    • 需要 Vue 3.3+ 版本
    • 不能同时使用运行时验证(如 validator 函数)

复杂类型的处理:PropType 工具类型

当需要运行时验证,又想保留类型时,使用 PropType:

<script setup lang="ts">
import type { PropType } from 'vue'

// 定义复杂类型
interface User {
  id: number
  name: string
  email: string
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
  }
}

type Status = 'pending' | 'processing' | 'completed' | 'failed'

// 使用 PropType 辅助类型推导
const props = defineProps({
  // 对象类型
  user: {
    type: Object as PropType<User>,
    required: true,
    validator: (user: User) => user.name.length > 0
  },
  
  // 联合类型
  status: {
    type: String as PropType<Status>,
    default: 'pending'
  },
  
  // 数组类型
  tags: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 函数类型
  onSave: {
    type: Function as PropType<(data: User) => Promise<void>>,
    required: false
  },
  
  // 复杂的嵌套类型
  config: {
    type: Object as PropType<{
      pagination: {
        pageSize: number
        currentPage: number
      }
      filters: Record<string, any>
    }>,
    default: () => ({
      pagination: { pageSize: 10, currentPage: 1 },
      filters: {}
    })
  }
})
</script>

适用场景:

  • 需要运行时验证(如 validator)
  • 需要设置复杂的默认值逻辑
  • 需要与 Options API 混用

事件发射的类型安全

defineEmits 的基础用法

<script setup lang="ts">
// 基础写法:字符串数组
const emit = defineEmits(['change', 'update', 'delete'])

// 使用时没有任何类型提示
emit('change', 123) // 可以传任意参数
emit('update', 'any', 'thing') // 没问题
</script>

为事件负载定义类型(推荐)

<script setup lang="ts">
// 使用类型声明
interface Emits {
  // 基础事件
  (e: 'change', value: string): void
  (e: 'update:id', id: number): void
  (e: 'delete'): void
  
  // 多个参数
  (e: 'item-move', fromIndex: number, toIndex: number): void
  
  // 联合类型的事件名
  (e: 'success' | 'error', message: string): void
}

const emit = defineEmits<Emits>()

// 使用时的类型检查
emit('change', '新值')      // ✅ 正确
emit('change', 123)         // ❌ 错误:参数类型必须是 string
emit('update:id', 1)        // ✅ 正确
emit('delete')              // ✅ 正确
emit('item-move', 0, 5)     // ✅ 正确
emit('item-move', 0)        // ❌ 错误:缺少第二个参数
</script>

v-model 的类型安全

<script setup lang="ts">
// 单个 v-model
interface Emits {
  (e: 'update:modelValue', value: string): void
  (e: 'update:searchText', value: string): void
  (e: 'update:selectedIds', ids: number[]): void
}

const emit = defineEmits<Emits>()

// 多个 v-model 的使用
function handleInput(value: string) {
  emit('update:modelValue', value)
}

function handleSearch(value: string) {
  emit('update:searchText', value)
}

function handleSelect(ids: number[]) {
  emit('update:selectedIds', ids)
}
</script>

<template>
  <!-- 父组件使用时获得类型提示 -->
  <ChildComponent 
    v-model="text"
    v-model:search-text="searchText"
    v-model:selected-ids="selectedIds"
  />
</template>

泛型组件的实现技巧

使用 defineComponent 配合泛型

在 Vue 3.3 之前,需要使用 defineComponent 来创建泛型组件:

// GenericTable.ts
import { defineComponent, PropType } from 'vue'

export default defineComponent({
  name: 'GenericTable',
  
  props: {
    data: {
      type: Array as PropType<any[]>,
      required: true
    },
    columns: {
      type: Array as PropType<TableColumn<any>[]>,
      required: true
    },
    rowKey: {
      type: [String, Function] as PropType<string | ((row: any) => string)>,
      required: true
    }
  },
  
  emits: {
    'sort-change': (sort: SortState) => true,
    'row-click': (row: any, index: number) => true
  },
  
  setup(props, { emit }) {
    // 实现逻辑
    return () => {
      // 渲染函数
    }
  }
})

// 使用时需要手动指定类型
const table = GenericTable as <T extends Record<string, any>>(
  new () => {
    $props: TableProps<T>
  }
)

在 SFC 中使用

Vue 3.3 引入了 generic 属性,让泛型组件的实现变得简单:

<script setup lang="ts" generic="T extends { id: string | number }">
// T 必须包含 id 属性
defineProps<{
  items: T[]
  selectedId?: T['id']
}>()

defineEmits<{
  select: [id: T['id']]
}>()
</script>

类型推导的局限性及解决方案

问题 1:模板中的类型推导

<script setup lang="ts" generic="T">
defineProps<{
  data: T[]
  format: (item: T) => string
}>()
</script>

<template>
  <div v-for="item in data" :key="item.id">
    <!-- ❌ item.id 可能不存在于 T 上 -->
    {{ format(item) }}
  </div>
</template>
解决方案:添加泛型约束
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{
  data: T[]
  format: (item: T) => string
}>()
</script>

问题 2:事件参数的类型推导

<script setup lang="ts" generic="T">
const emit = defineEmits<{
  (e: 'update', item: T): void  // ❌ T 在这里无法推导
}>()
</script>
解决方案:使用运行时声明 + PropType
<script setup lang="ts">
import type { PropType } from 'vue'

const props = defineProps({
  items: {
    type: Array as PropType<T[]>,
    required: true
  }
})

const emit = defineEmits({
  'update': (item: any) => true
})
</script>

类型安全组件的收益

使用组件时的智能提示

当其他开发者在使用我们的组件时,VS Code 会提供完美的智能提示:

<template>
  <!-- 输入 <Table 就会弹出所有 Props 提示 -->
  <Table
    :data="users"
    :columns="columns"
    :row-key="'id'"
    @sort-change="handleSortChange"
    @row-click="handleRowClick"
  />
</template>

错误提前暴露

<script setup>
// ❌ 编译时报错:Property 'nme' does not exist on type 'User'
const columns = [
  { key: 'nme', title: '姓名' } // 拼写错误
]

// ❌ 编译时报错:Type 'string' is not assignable to type 'number'
const handleSortChange = (sort: SortState) => {
  sort.field = 123 // 类型错误
}
</script>

更好的可维护性

当需要修改组件 Props 时,TypeScript 会标记所有使用错误的地方:

// 将 Props 从 TableColumn 改为 ColumnConfig
interface TableProps<T> {
  columns: ColumnConfig<T>[] // 修改了类型
  // ...
}

// 所有使用了旧类型的地方都会报错,不需要手动查找

类型安全组件的最佳实践清单

  • 优先使用纯类型声明(defineProps())
  • 复杂类型使用 PropType 辅助
  • 为所有事件定义类型,包括负载参数
  • 使用泛型创建可复用组件,并添加必要约束
  • 导出组件的 Props 和 Emits 类型,方便使用者
  • 为插槽定义类型,提供更好的使用体验

结语

类型安全不是一蹴而就的,而是在开发过程中逐步完善的。它不仅是为了迎合 TypeScript ,更是为了让我们的代码更加健壮,让团队协作更加顺畅。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

组件设计原则:如何设计一个高内聚、低耦合的 Vue 组件

作者 wuhen_n
2026年3月7日 07:33

前言

在 Vue 应用开发中,组件就像是乐高积木,组件设计可以决定这些积木的形状和接口。好的设计可以让积木自由组合,构建出各种复杂的应用;而一个坏的设计则让积木之间互不兼容,最终导致代码难以维护、难以复用、难以测试。

尤其是随着项目规模的增长,组件设计的重要性愈发凸显。本文将深入探讨高内聚低耦合的核心概念,通过大量实战案例,帮助我们掌握 Vue 组件设计的精髓。

为什么组件设计如此重要?

现实痛点

开篇之前,我们先来看一个设计不良的组件会带来哪些问题:

<!-- ❌ 反例:一个上千行的 "上帝组件" -->
<template>
  <div>
    <!-- 用户信息区域 -->
    <div class="user-section">
      <img :src="user.avatar">
      <h2>{{ user.name }}</h2>
      <!-- 几百行用户相关代码 -->
    </div>
    
    <!-- 好友列表区域 -->
    <div class="friends-section">
      <!-- 又是几百行好友列表代码 -->
    </div>
    
    <!-- 动态列表区域 -->
    <div class="activities-section">
      <!-- 还有几百行动态列表代码 -->
    </div>
  </div>
</template>

<script>
export default {
  props: ['user'], // 什么类型?不知道
  data() {
    return {
      user: {},
      friends: [],
      activities: [],
      loading: false,
      error: null,
      // ... 还有诸多数据字段
    }
  },
  methods: {
    // 所有方法全部混在一起
    fetchUser() { /* ... */ },
    fetchFriends() { /* ... */ },
    fetchActivities() { /* ... */ },
    followUser() { /* ... */ },
    unfollowUser() { /* ... */ },
    likeActivity() { /* ... */ },
    // ... 其他方法
  }
}
</script>

这个组件存在的问题:

  • 牵一发而动全身:修改用户信息的样式,可能会意外影响好友列表
  • 难以复用:想在另一个页面显示好友列表?那只能复制粘贴上百行代码
  • 难以理解:新接手的人需要花一天时间才能理清逻辑
  • 难以测试:如何单独测试好友列表的功能?

好的组件设计带来的收益

<!-- ✅ 好的设计:拆分为独立组件 -->
<template>
  <div class="user-profile-page">
    <UserInfoCard :user="user" />
    <FriendList :friends="friends" @follow="handleFollow" />
    <ActivityFeed :activities="activities" @like="handleLike" />
  </div>
</template>

<script setup>
// 容器组件:只负责数据获取和组合
const { user, friends, activities } = await fetchUserData(props.userId)

function handleFollow(userId) { /* ... */ }
function handleLike(activityId) { /* ... */ }
</script>

这个组件带来的好处:

  • 可维护性:每个组件独立修改,互不影响
  • 可复用性:这个组件可以在任何地方使用
  • 可测试性:可以为每个组件编写独立的单元测试
  • 可读性:代码即文档,一目了然

高内聚低耦合:组件设计的黄金法则

什么是高内聚?

高内聚是指组件内部的元素(数据、方法、模板等)紧密相关,共同完成一个明确的职责:

<!-- ✅ 高内聚的计数器组件:所有逻辑都服务于"计数"这个单一职责 -->
<template>
  <div class="counter">
    <button @click="decrement" :disabled="count <= min">-</button>
    <span class="count">{{ count }}</span>
    <button @click="increment" :disabled="count >= max">+</button>
  </div>
</template>

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

const props = defineProps<{
  min?: number
  max?: number
  initial?: number
}>()

// 所有数据和方法都围绕 count 展开
const count = ref(props.initial ?? 0)

function increment() {
  if (count.value < (props.max ?? Infinity)) {
    count.value++
  }
}

function decrement() {
  if (count.value > (props.min ?? -Infinity)) {
    count.value--
  }
}
</script>

<style scoped>
/* 样式也只服务于这个组件 */
.counter {
  display: flex;
  align-items: center;
  gap: 8px;
}
</style>

高内聚的特征

  • 组件名称准确地描述了它的功能
  • 组件的所有代码都是为了实现这个功能
  • 移除任何一个部分都会影响核心功能

什么是低耦合?

低耦合是指组件之间的依赖关系简单、明确,修改一个组件不需要修改另一个组件:

<!-- 父组件 -->
<template>
  <div>
    <UserCard
      :user="user"
      @follow="handleFollow"
      @unfollow="handleUnfollow"
    />
  </div>
</template>

<!-- 子组件:不知道父组件的任何信息 -->
<template>
  <div class="user-card">
    <img :src="user.avatar" :alt="user.name">
    <h3>{{ user.name }}</h3>
    <button 
      v-if="!isFollowing"
      @click="$emit('follow', user.id)"
    >
      关注
    </button>
    <button 
      v-else
      @click="$emit('unfollow', user.id)"
    >
      取消关注
    </button>
  </div>
</template>

<script setup>
defineProps<{
  user: { id: number; name: string; avatar: string }
  isFollowing?: boolean
}>()

defineEmits<{
  follow: [userId: number]
  unfollow: [userId: number]
}>()
</script>

低耦合的特征

  • 组件只通过 Props 接收数据,通过 Events 发送消息
  • 组件内部不依赖全局状态(除非必要)
  • 修改组件内部实现,不需要修改使用它的地方

内聚与耦合的关系

高内聚和低耦合是相辅相成的:

  • 高内聚是低耦合的基础:只有组件内部职责清晰,才能设计出清晰的接口
  • 低耦合让高内聚更有价值:如果组件之间耦合度高,即使每个组件内聚再好,系统也难以维护

组件划分的边界艺术

如何判断一个组件是否应该拆分?

当我们在犹豫是否要拆分一个组件时,可以问问自己这几个问题:

  • 独立复用:这个部分能否在其他地方使用?
  • 独立逻辑:这个部分是否有独立的业务逻辑?
  • 频繁变化:这个部分是否会频繁修改?
  • 代码规模:代码是否过长,如是否超过 300 行?
  • 过度拆分:是否为了拆分而拆分,导致组件冗余?

原子设计方法论

原子设计方法论是由 Brad Frost 提出的一种用于构建设计系统的方法论。它借鉴了化学中的基本概念,认为所有的用户界面(UI)都可以由一系列基本的、不可再分的元素(原子)组合而成。其核心思想是分层构建,就像搭积木一样,从最小的单元开始,逐步组合成越来越复杂的结构,这个过程分为五个层次:

原子(Atoms)→ 分子(Molecules)→ 组织(Organisms)→ 模板(Templates)→ 页面(Pages)

原子

原子 是构成用户界面的最基本、最小的元素,无法再进一步细分。其本身不具备独立的功能性,但它们定义了所有设计元素的基础样式和属性。比如一个 <label> 标签、一个 <input> 输入框、一个 <button> 按钮、颜色调色板、字体、动画等:

<template>
  <button>原子按钮</button>
</template>

分子

分子 由多个原子组合在一起形成的相对简单的 UI 组件,具有简单、明确的功能,遵循“单一职责原则”,即:只做一件事,且把这件事做得很好。比如一个“搜索框”分子可以由一个 <label> 原子(“搜索”文字)、一个 <input> 原子(输入框)和一个 <button> 原子(“搜索”按钮)组合而成。这三个原子结合在一起,就形成了一个能执行搜索功能的最小单元:

<template>
  <div class="search-bar">
    <label>搜索:<label>
    <input v-model="searchText" />
    <button @click="search">搜索</button>
  </div>
</template>

组织

组织 由分子、原子以及其他组织组合而成的相对复杂的 UI 结构。它们构成了页面中一个独立的区域,作为页面中功能完善的模块,但本身还不是一个完整的页面。比如“用户列表”,由多个“用户卡片”分子构成:

<template>
  <div class="user-list">
    <UserCard v-for="user in users" :key="user.id" :user="user" />
  </div>
</template>

模板

模板 将多个组织、分子和原子组合在一起,形成页面的 骨架和布局结构。其关注的是内容在页面上的 排布方式,展示了各组件的相对位置和功能。如一个“管理布局”模板,定义了头部组织、正文内容区域和底部组织分别放在什么位置:

<template>
  <div class="layout">
    <header />
    <main>
      <SearchBar @search="handleSearch" />
      <UserList :users="filteredUsers" />
    </main>
    <footer />
  </div>
</template>

注:模板是 抽象 的,它没有填充真实的内容,只有占位符。只是定义了 布局结构

页面

页面 是模板的具体实例。它将真实的内容(文本、图片等)填充到模板中,并精确地调整整个界面的样式和逻辑,最终呈现给用户的样子。

原子设计方法论与 Vue3 的结合

Vue3 的原子:Vue3 中的基础元素组件

在 Vue3 中,原子通常对应那些只封装了最基础 HTML 元素和样式的组件。它们通常只通过 props 接收数据,并通过 $emitv-model 向外发送事件:

<!-- 1. 原子:BaseInput.vue -->
<template>
  <div class="base-input">
    <input
      :id="id"
      :type="type"
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      v-bind="$attrs"
    />
  </div>
</template>

<script setup lang="ts">
defineProps({
  id: String,
  type: { type: String, default: 'text' },
  modelValue: [String, Number]
})
defineEmits(['update:modelValue'])
</script>

Vue3 的分子:Vue3 中的功能组件

<!-- 分子:SearchForm.vue -->
<template>
  <form class="search-form" @submit.prevent="handleSubmit">
    <BaseInput
      v-model="searchText"
      label="搜索"
      placeholder="请输入关键词..."
    />
    <BaseButton type="submit">搜索</BaseButton>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'
import BaseButton from './BaseButton.vue'

const searchText = ref('')
const emit = defineEmits(['search'])

const handleSubmit = () => {
  emit('search', searchText.value)
}
</script>

Vue3 的组织:Vue3 中的区块组件

<!-- 组织:HeaderOrganism.vue -->
<template>
  <header class="site-header">
    <div class="logo">
      <img src="/logo.png" alt="Logo" />
      <span>My App</span>
    </div>
    <nav class="nav-menu">
      <a v-for="item in navItems" :key="item.link" :href="item.link">{{ item.text }}</a>
    </nav>
    <SearchForm @search="handleGlobalSearch" />
  </header>
</template>

<script setup lang="ts">
import SearchForm from './SearchForm.vue' // 导入分子

const navItems = [ /* ... */ ]
const handleGlobalSearch = (query) => { /* 处理全局搜索 */ }
</script>

Vue3 中的模板:Vue3 中的布局或页面组件(此时无数据)

模板在 Vue 中通常对应一个布局组件或一个无具体数据的页面级组件。它负责定义页面的骨架结构,引入各种组织组件,并将它们摆放在正确的位置。此时,组件接收的 propsslot 插槽内容都是抽象的占位符:

<!-- 模板:ArticlePageTemplate.vue -->
<template>
  <div class="article-page">
    <HeaderOrganism />
    <main class="content-wrapper">
      <aside class="sidebar">
        <!-- 这里是一个插槽,用于放置侧边栏内容,具体内容由页面填充 -->
        <slot name="sidebar" />
      </aside>
      <article class="main-content">
        <!-- 这里是主要内容插槽 -->
        <slot />
      </article>
    </main>
    <FooterOrganism />
  </div>
</template>

<script setup lang="ts">
import HeaderOrganism from './HeaderOrganism.vue'
import FooterOrganism from './FooterOrganism.vue'
</script>

Vue3 中的页面:Vue2 中的完整页面组件(有数据)

<!-- 页面:ArticlePage.vue -->
<template>
  <ArticlePageTemplate>
    <!-- 向模板的 sidebar 插槽填充真实内容 -->
    <template #sidebar>
      <AuthorCard :author="article.author" />
      <RelatedArticles :articles="article.related" />
    </template>

    <!-- 向默认插槽填充文章正文 -->
    <ArticleContent :article="article" />
  </ArticlePageTemplate>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ArticlePageTemplate from './ArticlePageTemplate.vue'
import AuthorCard from './AuthorCard.vue'
import RelatedArticles from './RelatedArticles.vue'
import ArticleContent from './ArticleContent.vue'

const article = ref({})
onMounted(async () => {
  article.value = await fetchArticleData()
})
</script>

Props 设计:定义组件的公开 API

Props 设计的黄金法则

法则一:尽可能少,尽可能明确

只接收必要的数据,不要接收和组件不相关的数据:

defineProps<{
  user: User
  isEditable?: boolean
}>()

法则二:提供合理的默认值

interface Props {
  placeholder?: string
  disabled?: boolean
  maxLength?: number
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '请输入',
  disabled: false,
  maxLength: 100
})

法则三:使用 TypeScript 定义类型

interface User {
  id: number
  name: string
  avatar: string
  role: 'admin' | 'user' | 'guest'
}

defineProps<{
  user: User
  permissions: string[]
}>()

法则四:避免传递不必要的 props

<ChildComponent :user="user" />

Props 的 4 种类型及使用场景

1. 数据型 Props:单纯的数据展示

<UserCard 
  :user="user"
  :posts="userPosts"
/>

2. 配置型 Props:控制组件行为

<DataTable
  :show-header="true"
  :allow-sort="true"
  :page-size="20"
  :theme="'dark'"
/>

3. 回调型 Props:事件处理

<FormComponent
  @submit="handleSubmit"
  @cancel="handleCancel"
/>

4. 节点型 Props:自定义渲染

<ModalComponent>
  <template #header>
    <h2>自定义标题</h2>
  </template>
  <template #footer>
    <button>确认</button>
  </template>
</ModalComponent>

Props 命名的最佳实践

1. 使用完整单词

defineProps<{
  userName: string      // 不是 uname
  userAvatar: string    // 不是 uavatar(除非是标准术语)
}>()

2. 布尔值用 is/has/should 开头

defineProps<{
  isActive: boolean     // 状态
  hasPermission: boolean // 拥有
  shouldShow: boolean   // 应该
}>()

3. 回调函数用 on 开头

defineProps<{
  onSubmit: () => void
  onClose: () => void
}>()

4. 数组等用复数

defineProps<{
  users: User[]
}>()

事件通信:让组件之间优雅地对话

组件通信的 5 种方式及选择策略

1. Props + Events:父子组件直接通信(最常用)

<!-- 父组件 -->
<ChildComponent 
  :data="parentData"
  @update="handleUpdate"
/>

<!-- 子组件 -->
<script setup>
defineProps<{ data: string }>()
const emit = defineEmits<{
  update: [value: string]
}>()
</script>

2. v-model:双向绑定的场景(表单类)

<InputComponent v-model="searchText" />

3. Slots:父组件控制渲染内容(布局类)

<CardComponent>
  <template #header>标题</template>
  内容
  <template #footer>底部</template>
</CardComponent>

4. Provide/Inject:跨多层组件传递(主题、用户信息)

// 祖先组件
provide('theme', 'dark')
// 后代组件
const theme = inject('theme')

5. Pinia:全局状态(用户信息、购物车)

const userStore = useUserStore()

事件设计的 3 个原则

原则一:只通知,不下命令

子组件只需要告诉父组件发生了什么,至于事件发生后该做什么,要怎么做,由父组件决定,子组件不作任何处理:

const emit = defineEmits<{
  'item-selected': [item: Item]
  'form-submitted': [data: FormData]
}>()

原则二:事件粒度适中

一个操作对应一个事件,不要把所有操作放在一个事件中(太粗),也不要把不需要处理的操作放在事件中(太细):

// ✅ 好:一个操作一个事件
const emit = defineEmits<{
  'save-success': []
  'save-error': [error: Error]
}>()

// ❌ 差:太细或太粗
const emit = defineEmits<{
  'button-mousedown': []      // 太细,外部不需要知道
  'button-mouseup': []        // 太细
  'data-operation': [         // 太粗,不知道发生了什么
    type: 'create' | 'update' | 'delete',
    data: any
  ]
}>()

原则三:保持一致性

统一的命名风格,使用冒号 : 分隔命名空间:

const emit = defineEmits<{
  'user:created': [user: User]
  'user:updated': [user: User]
  'user:deleted': [userId: string]
}>()

插槽设计:让组件拥有无限可能

插槽的 3 种形式及适用场景

1. 默认插槽:简单的内容占位

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-content">
      <slot>
        <!-- 提供默认内容 -->
        <p>暂无内容</p>
      </slot>
    </div>
  </div>
</template>

<!-- 使用 -->
<Card>
  <p>这是卡片内容</p>
</Card>

2. 具名插槽:多个位置的定制

<!-- Modal.vue -->
<template>
  <div class="modal">
    <header>
      <slot name="header">默认标题</slot>
    </header>
    
    <main>
      <slot name="content">默认内容</slot>
    </main>
    
    <footer>
      <slot name="footer">
        <button @click="close">关闭</button>
      </slot>
    </footer>
  </div>
</template>

<!-- 使用 -->
<Modal>
  <template #header>
    <h2>自定义标题</h2>
  </template>
  
  <template #content>
    <p>自定义内容</p>
  </template>
  
  <template #footer>
    <button @click="confirm">确认</button>
    <button @click="cancel">取消</button>
  </template>
</Modal>

3. 作用域插槽:让父组件访问子组件数据

<!-- DataTable.vue -->
<template>
  <div class="data-table">
    <table>
      <tbody>
        <tr v-for="(item, index) in data" :key="index">
          <td v-for="col in columns" :key="col.key">
            <slot 
              :name="`column-${col.key}`"
              :value="item[col.key]"
              :row="item"
              :index="index"
            >
              {{ item[col.key] }}
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<!-- 使用 -->
<DataTable :data="users" :columns="columns">
  <template #column-status="{ value, row }">
    <Badge :type="value === 'active' ? 'success' : 'default'">
      {{ value }}
    </Badge>
  </template>
</DataTable>

插槽设计的 3 个最佳实践

1. 提供合理的默认内容

<template>
  <div class="empty-state">
    <slot name="icon">
      <EmptyIcon />
    </slot>
    
    <slot name="message">
      <p>暂无数据</p>
    </slot>
    
    <slot name="action">
      <button @click="$emit('refresh')">刷新</button>
    </slot>
  </div>
</template>

2. 保持作用域数据的精简

<template>
  <!-- ✅ 好:只暴露必要的数据 -->
  <slot 
    :item="item"
    :index="index"
    :is-first="index === 0"
    :is-last="index === items.length - 1"
  />
  
  <!-- ❌ 差:暴露整个组件实例 -->
  <slot :this="this" :$el="$el" :$props="$props" />
</template>

3. 使用 TypeScript 定义插槽类型

<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

defineSlots<{
  // 默认插槽不接受 props
  default(props: {}): any
  
  // 具名插槽
  header(props: {}): any
  
  // 作用域插槽
  'user-item'(props: { 
    user: User
    index: number
    isSelected: boolean
  }): any
  
  // 可选插槽
  footer?(props: {}): any
}>()
</script>

组件设计的 SOLID 原则(Vue 视角)

SOLID 原则 Vue 中的体现 实践建议
单一职责 一个组件只做一件事 组件代码不超过 300 行,功能单一明确
开闭原则 对扩展开放,对修改关闭 多用插槽,少改内部逻辑;通过 Props 配置行为
里氏替换 子组件可替换父组件 保持 Props 接口一致,遵循相同的契约
接口隔离 Props 尽可能少 避免传递整个对象,只传必要字段;用多个小 Props 替代一个大对象
依赖倒置 依赖抽象,不依赖实现 用事件通信,不直接调用父组件方法;用 provide/inject 解耦

组件设计的 10 个坏味道(Anti-Patterns)

  1. 上帝组件:超过 500 行的组件
  2. Props 泛滥:超过 10 个 props
  3. 多层级 Props 透传:props 穿过 3 层以上
  4. 组件内直接修改 props:违反了单向数据流
  5. 模板内复杂逻辑:模板中有三元运算符嵌套
  6. CSS 全局污染:没有使用 scoped 或 CSS Modules
  7. 依赖父组件结构:组件假设父组件一定有某个 DOM 结构
  8. 过度抽象:为了复用而拆分,反而更难用
  9. 隐式通信:通过修改 store 来通知兄弟组件
  10. 没有 TypeScript:组件 API 全靠文档记忆

组件设计的检查清单

设计前思考

  • 这个组件的职责是否单一?
  • 是否真的需要拆分成独立组件?
  • 这个组件会在哪些地方被使用?

设计时检查

  • Props 命名是否清晰易懂?
  • 是否提供了合理的默认值?
  • 是否使用了 TypeScript 定义类型?
  • 事件命名是否表达了发生了什么?
  • 插槽是否有合理的默认内容?
  • 样式是否 scoped?

设计后验证

  • 组件能否独立运行?(不依赖外部数据)
  • 修改组件内部,会影响外部吗?(低耦合验证)
  • 其他开发者能看懂这个组件吗?(可读性验证)
  • 能否为这个组件写单元测试?(可测试性验证)
  • 组件文档是否清晰?(可用性验证)

结语

好的组件设计不是一蹴而就的,而是在每一次重构中不断完善的过程。当我们开始思考"这个组件是否应该拆分"、"这个 Props 命名是否合理"的时候,我们就已经走在了正确的道路上了。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Vue调试神器:Vue DevTools使用指南

2026年3月6日 21:52

image

一、初识Vue Devtools

Vue DevTools 概述

  在现代前端开发中,Vue.js 应用的组件化架构虽然提升了代码复用性,但也带来了复杂的状态管理和组件交互问题。当应用包含数十个嵌套组件时,传统的 console.log 调试方式如同在黑暗中摸索。Vue.js Devtools 作为官方调试工具,通过可视化界面将组件结构、状态变化和性能数据直观呈现,让开发者能够像"透视"一样观察应用内部运行机制。

image

  Vue Devtools 是 Vue 官方发布的调试浏览器插件,可以安装在 Chrome、Firefox、Edge等浏览器上,可以帮助我们监控和管理 Vue 应用的状态、事件和性能。通过 Vue Devtools,我们可以查看组件的结构、属性和方法,以及父子组件之间的关系。此外,Vue Devtools 还提供了时间轴功能,让我们可以更好地了解应用的状态变化。

Vue DevTools 功能说明

  1. 组件树检视:能够清晰展示出应用中的组件层级结构,方便开发者理解和导航。
  2. 状态和数据查看:可以检查组件的状态,包括props、data、computed properties等。
  3. 调试事件:可以监听和触发事件,便于开发者查看事件的响应和效果。
  4. 时间旅行:这是 Vue DevTools 的高级功能之一,能够记录组件的快照,允许开发者在不同的快照之间切换,观察应用状态的变化。
  5. 控制台集成:Vue DevTools 提供了集成到浏览器控制台的能力,可以通过控制台直接与Vue实例交互。
  6. 组件信息展示:可以查看每个组件所对应的虚拟DOM结构和渲染细节。

二、环境适配:多场景下的安装与配置

浏览器扩展

  目前 Vue DevTools 主要支持 Chrome 浏览器和 Firefox 浏览器,并提供对应的浏览器扩展。对于其他平台(如Safari或Edge)的支持情况,可以通过各种主流浏览器的扩展商店进行安装。

插件:www.chajianxw.com/developer/1…

  打开 Chrome 浏览器,选择菜单“更多程序”→“扩展程序”,打开扩展程序界面,打开开发者模式,单击“加载已解压的扩展程序”按钮,将vue-devtools插件安装到Chrome 浏览器,安装结果如图:

image

  安装完成后,开发者需要在浏览器的扩展管理页面启用Vue DevTools。在使用Vue DevTools时,通常需要在Vue应用中直接运行,这时DevTools会自动识别并展示调试信息。若未看到,刷新页面或检查是否为 Vue 应用。

image

Vite Plugin

单体应用

对于Electron应用、移动端应用(NativeScript/Capacitor)或者服务端渲染应用,浏览器扩展可能无法直接使用。别担心,Vue Devtools还提供了NPM包版本

npm install -g @vue/devtools

Vue DevTools 默认仅适用于 Vue 的开发版本(非压缩版),在生产环境中默认禁用,否则就好比把家里的“透视眼镜”给小偷戴上,会暴露应用内部状态。

三、功能解析:掌握调试工具的核心能力

  在安装了 Vue Devtools 的浏览器中,打开你的 Vue 应用。然后右键点击页面,选择“Inspect”,在弹出的开发者工具中找到“Vue”选项卡,点击即可打开 Vue Devtools。

3.1 Components面板:组件世界的“上帝视角”

  在现代的前端开发中,组件化已经成为一种标准的实践方式。Vue.js 也不例外,它提供了一种灵活的方式来构建用户界面,通过组件树的层级结构来组织界面的不同部分。在 Vue 应用中,组件的父子关系是通过组件嵌套和属性传递来定义的。父组件通过在模板中声明子组件标签,并通过 props 将数据传递给子组件,从而建立起父子关系,Vue Devtools 提供了一个直观的方式来查看组件之间的这种层级结构。

  在 Vue DevTools 的“Components”标签页中,可以直观地看到整个应用的组件树结构,类似于文件系统的目录结构,从根组件(Root)开始,层层展开,让我们可以更好地了解组件的结构。每个组件都是一个节点,父组件之下包含子组件,形成清晰的层级关系。通过展开组件节点,可以查看其子组件,帮助开发者快速定位问题发生的组件区域。在组件树视图中,可以通过输入关键字来筛选组件,快速定位到关心的组件,这对于大型应用中组件众多的情况非常实用。

image

  在组件树中,选中某个组件后,右侧面板会显示该组件的属性、数据、计算属性和方法等信息。开发者可以实时查看组件状态的变化,无需在控制台中进行繁琐的打印操作。

image

  组件树中的每个组件节点不仅显示了组件的类型,还可以展开来查看其详细信息,包括组件的属性、数据、计算属性以及样式等。最刺激的是实时编辑功能——直接在Devtools中直接修改组件的 data 属性值,比如把一个按钮的 disabled 从 true 改为 false ,页面上的按钮立即变得可点击!无需刷新页面,无需重新编译,就像用手指直接拨动乐高积木一样神奇。这对于调试数据驱动的问题非常有帮助,能够快速验证数据的正确性和对组件的影响。

image

3.2 Events面板:事件流的“监听器”

  在 Vue Devtools 中,Events 面板用来监控Vue实例的所有事件。

  • 事件历史:按时间顺序显示所有触发的Vue事件(包括自定义事件)
  • 按组件筛选:只看某个特定组件触发的事件
  • 事件详情:点击事件可查看事件名称、目标组件、传递参数等信息
  • 复制数据:支持将事件数据复制到剪贴板

这对于调试复杂的组件通信(比如爷孙组件传值、兄弟组件通信)非常有用,帮助我们更好地了解事件的处理情况。

3.3 状态追踪:应用数据的"黑匣子记录仪"

  如果应用使用了Vuex(Vue 2)或Pinia(Vue 3官方推荐),Vue Devtools 会自动显示状态面板,这个面板就是你的“中央监控室”。左侧显示完整的 store 状态树,所有数据一目了然。可以展开每一个节点,查看当前所有共享状态的值。在这里,我们可以查看state、getters、mutations(Vuex)或actions(Pinia),以及它们的详细信息。通过时间线视图,开发者可以查看状态树是如何随时间变化的,帮助理解状态变化的流程。

3.4 最炫酷的“时间旅行”

  Vue Devtools 提供了一个时间轴功能,可以让我们更好地了解应用的状态变化。在时间轴中,我们可以查看每个组件的状态变化,以及它们之间的依赖关系。开发者可以回溯到过去的状态,进行状态差异的比较分析。这对于调试复杂的状态管理逻辑非常有用,能够快速定位状态变化导致的问题。

3.5 Router面板:路由导航的“导航仪”

  如果应用使用了Vue Router,Router 面板就是你的“导航仪”。在“Router”标签页中,可以查看当前路由的信息,包括路径、查询参数、路由参数等,如下图所示。

image

  同时,还能看到路由的历史记录,方便开发者了解应用的导航流程。通过观察路由的变化,开发者可以调试路由跳转、参数传递等问题。例如,当遇到路由跳转后页面不更新的问题时,可以通过查看路由变化记录,分析错误发生的原因。

3.6 Timeline面板:应用优化的"体检报告"

如何录制性能数据

  1. 切换到Timeline面板
  2. 点击左上角的“Start recording”(开始录制)按钮
  3. 在页面上执行你想要分析的操作(比如点击一个会加载大量数据的按钮)
  4. 点击“Stop recording”停止录制

数据解读:谁在“摸鱼”?

录制完成后,你会看到类似心电图的时间轴:

  • 组件渲染时间:每个组件从开始渲染到完成花了多久
  • 组件更新次数:某些组件是不是在“无效加班”(频繁无意义地重新渲染)
  • 生命周期钩子执行时间:比如mounted钩子里是不是放了太多代码导致阻塞

性能优化实战案例

通过Timeline面板,你可能会发现:

  • 某个表格组件渲染要500ms → 考虑使用虚拟滚动
  • 某个computed属性被频繁重新计算 → 考虑使用缓存或shallowRef
  • 某个组件在父组件更新时跟着乱更新 → 添加v-once或合理使用key

四、总结

  Vue Devtools是一款非常实用的工具,可以帮助我们更好地理解和管理Vue应用。使用 Vue DevTools 进行调试与性能优化,能够极大地方便开发者的工作。通过可视化 的组件树、实时数据修改、Vuex 状态跟踪及时间旅行功能,我们可以更加高效地定位问题,优化处理逻辑,提升应用性能。

image

三维模型瓦片服务三剑客:3D Tiles、I3S与S3M全解析

作者 charlee44
2026年3月6日 21:10

本文节选自新书《GIS基础原理与技术实践》第8章。当 GIS 迈入三维时代,如何高效发布与可视化海量三维模型成为关键挑战。目前,Cesium 的 3D Tiles、Esri 的 I3S 和 超图的 S3M 已成为三大主流三维瓦片标准。本文将带你深入其核心机制——从瓦片树、包围体、几何误差,到 b3dm/i3dm/pnts 格式细节,再到要素化与声明式样式,全面解析这“三维瓦片三剑客”的异同与适用场景。

GIS基础原理与技术实践

8.8 三维模型数据服务

与矢量切片服务和地形切片服务一样,三维模型数据服务也多数是以静态资源的形式进行发布的,毕竟他们还没形成比较标准的规范,不用提供额外的空间操作,只需要保证能获取资源进行可视化就可以了。因此,三维模型数据服务大多直接使用三维模型瓦片数据格式发布的静态资源即可。

8.8.1 三维模型瓦片数据格式

一般情况下,三维模型的数据量比单纯的栅格数据或者矢量数据大得多,因此也需要进行类似于切片的处理,将三维模型轻量化。其具体的原理也不复杂,使用的就是在第7.4节中我们介绍的分页LOD技术,通过分层和分块,将三维模型划分成不同精细度、不同范围的瓦片,根据三维场景的需要,使渲染端动态调度出适配场景精细度的三维模型瓦片。

第7.4节中我们是通过倾斜摄影模型介绍的具有分页LOD技术的OSGB格式数据,但推而广之,其实第7.5节中介绍的所有类型的三维模型数据都可以使用OSGB格式来进行表达。不过,OSGB格式数据是一个适合桌面端的数据格式,并没有针对Web端环境进行优化和适配。目前,经常用作三维模型数据服务的三维模型数据是Cesium的3D Tiles格式,ArcGIS的I3S格式以及国内超图软件的S3M格式。其中,3D Tiles和I3S已经是国际OGC标准,而S3M则是CAGIS(中国地理信息产业协会)空间三维模型数据格式标准。

根据3D Tiles官方文档(github.com/CesiumGS/3d… 提供的定义,3D Tiles是专为流式传输和渲染大量3D地理空间内容而设计的三维模型数据格式,例如倾斜摄影测量数据、3D建筑数据、BIM/CAD、实例化要素和点云数据等。与OSGB使用的分页LOD技术类似,3D Tiles使用分层细节级别 (HLOD,Hierarchical Level of Detail)的空间数据结构,保证只有可见的瓦片才会被流式传输和渲染,从而提高三维模型数据整体性能。

3D Tiles有1.0和1.1两个版本,但是目前3D Tiles 1.0是使用最广泛的三维模型瓦片数据格式,以下我们会以3D Tiles 1.0为例,具体介绍一下三维模型瓦片数据格式的内容。

8.8.2 瓦片集和瓦片(Tilesets and Tiles)

3D Tiles合适文件通常是一个散列的包含文件和文件夹的数据集,数据集的入口通常是一个名为tileset的JSON文件。如文件名表达的含义一样,这个JSON文件就是3D Tiles的根数据集(Tilesets),一个典型的例子如下例8.6所示:

例8.6 3D Tiles的根数据集

{
    "asset": {},
    "properties": {},
    "geometricError": 100,
    "root": {
        "geometricError": 20,
        "boundingVolume": {
            "region": []
        },
        "refine": "ADD",
        "children": [
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "house.b3dm"
                },
                "children": [
                    {
                        "geometricError": 5,
                        "boundingVolume": {},
                        "content": {
                            "uri": "detailsA.b3dm"
                        }
                    },
                    {
                        "geometricError": 5,
                        "boundingVolume": {},
                        "content": {
                            "uri": "detailsB.b3dm"
                        }
                    }
                ]
            },
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "tree.pnts"
                }
            },
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "fence.i3dm"
                }
            },
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "external.json"
                }
            }
        ]
    }
}

在这个JSON文件中,最主要的部分就是名为root的元素,以及children数组中的元素。其实两者的属性是相同的,都应该包含content、children、boundingVolume、geometricError以及refine键值对,只不过有的键值对被省略掉了。具体来说,3D Tiles中的瓦片(Tiles),指的就是这个元素。

从例8.1可以看出,root元素包含了一个children数组元素,children数组中的一个元素又可以包含一个children数组元素...如此可以进行多层嵌套,就组成了一个瓦片树。我们可以回忆一下第7.4.3节的内容,这与OSGB格式的节点树非常相似。在这个瓦片树中,越往上,模型精细度越低,但是分块越少;越往下,模型精细度越高,但是分块越多。父亲节点与所有的子节点表达的数据内容是一样的,只是精细度有差别。

3D Tiles瓦片和瓦片集的示意图如下图8.53所示,一个JSON瓦片集能包含多个瓦片,瓦片的content就是具体的模型实体。不过正如例8.6所展示的那样,瓦片的content也可以指向另一个JSON瓦片集,像这样重复嵌套,我们可以组成一个非常复杂的表达三维场景的瓦片树。

图8.53 3D Tiles瓦片和瓦片集的示意图

8.8.3 包围体(Bounding Volumes)

在例8.6中我们就看到的boundingVolume元素就是包围体。可能这里说包围盒这个概念更容易让人理解一点,但是3D Tiles中有三种不同的表达切片范围的体要素,所以将其称为包围体更好一点。这三种包围体分别是包围盒(Bounding Box),包围球(Bounding sqhere)和包围区域(Bounding region),如下图8.54所示:

图8.54 3D Tiles瓦片不同包围体类型

包围盒是我们最熟悉的,但是这里反而最不好理解,一个包围盒参数的例子如下所示:

"boundingVolume": {
    "box": [
        0, 0, 10,
        20, 0, 0,
        0, 30, 0,
        0, 0, 10
    ]
}

可以看到这里一共12个参数,前3个参数表示中心点的位置坐标,接下来的三个元素定义x轴方向和半长,再接下来的三个元素定义y轴方向和半长,最后三个元素定义z轴方向和半长。这个例子的包围盒的描述就是,中心点坐标为(0,0,10),X方向长度为40,Y方向长度为60,Z方向为20。这种包围盒在三维中称为AABB(Axis-Aligned Bounding Box,轴对称包围盒)包围盒,一般情况下这么用就可以了。

但是如果深入了解一下,就会发现12个参数中有很多值是0,这些0值其实是用来表达旋转的,或者说方向的。AABB包围盒其实对三维物体对象的贴合不够紧密,如果调整一下包围盒的方向,就有可能让包围盒的范围进一步缩小(想象一下从西北到东南的长条状物体的包围盒)。这种包围盒就被称为OBB包围盒(Oriented Bounding Box,有向包围盒)。复习前面第3.7.1节的知识就会明白,后面9个参数实质是定义了旋转变换+缩放变换的几何变换矩阵。因为OBB包围盒的方式复杂一些,所以这种表达形式使用的比较少。

而包围球就最简单了,由中心点坐标和半径定义四个参数定义,如下所示:

"boundingVolume": {
    "sphere": [
        10, 5, 15,
        140.0
    ]
}

最后的包围区域则是三维图形中没有的概念,实际上这个区域其实指的是地理区域,由6个参数定义,分别是WGS84坐标系中西至经度,南至纬度,东至经度,北至纬度,最小椭球高,最大椭球高,经纬度使用弧度为单位,高度以米为单位。如下所示:

"boundingVolume": {
    "region": [
        -1.319700,
        0.698858,
        -1.319659,
        0.698889,
        0.0,
        20.0
    ]
}

包围体是三维图形中就非常重要的参数,可用于优化渲染和高效空间查询,例如在Ceisum中,就通过使用包围体实现可见性查询和视锥体剔除,显著提升了渲染性能。

8.8.4 空间数据结构

我们在前面论述过,3D Tiles中的瓦片集以树形数据结构进行组织。但是,这种树形数据结构不是任意组织的,而是具有空间一致性:父瓦片的包围体始终包含其所有子瓦片的内容。这对于可见性测试和相交性测试特别重要,当在三维场景中我们看不到某个瓦片的时候,那么必然看不到它的所有子瓦片。通过这种方式,我们可以筛选需要的瓦片进行展示,这对性能的提升非常有帮助。

另外,与基于二维的地图切片不同,3D Tiles的瓦片数据结构通常是基于三维的,因此要更加复杂,例如KD树或者八叉树,且每个瓦片可能并不均匀。这样可能就会造成一个现象,就是父瓦片的包围盒可能并不能完全包含子瓦片的包围盒。当然,父瓦片的包围体包含其子瓦片的内容的特性还是存在的。具体的空间结构示意图如下图8.55所示:

图8.55 3D Tiles空间数据结构示意图

8.8.5 几何误差(Geometric Error)

几何误差(Geometric Error)就是例8.6中的geometricError元素。复习一下我们在第7.4.3节中介绍的知识,OpenSceneGraph和OSGB格式使用瓦片包围球映射到屏幕端直径来决定渲染的精细度层级;而几何误差的作用也非常类似,决定了3D Tiles在渲染客户端(如Cesium)以何种细节级别进行渲染,从而在性能和渲染质量之间提供最佳权衡。

虽然都是控制LOD级别的因子,3D Tiles格式的几何误差表达的含义则与OSGB格式使用的参数完全不同,几何误差表达的含义是简化的几何体与真实的几何体之间的误差,以米为单位。在可视化端实现的时候,会将这个参数转换成屏幕空间误差(screen-space error,SSE),单位为像素。当SSE超过某个阈值(CesiumJS中会设定一个最大屏幕空间误差值)的时候,运行的时候将会渲染更高级别的细节。具体示意图如下图8.56所示:

图8.56 3D Tiles中的几何误差和屏幕空间误差

那么,几何误差是如何转换成屏幕空间误差呢?Cesium官方给出了一个公式,对于透视投影,他们的转换公式如下式(8-3):

sse=geometricErrorscreenHeighttileDistance2tan(fovy/2)(8-3)sse = \frac{geometricError ⋅ screenHeight}{tileDistance ⋅ 2 ⋅ tan(fovy / 2)} \tag{8-3}

其中,screenHeight是渲染屏幕的高度(以像素为单位),tileDistance是瓦片到视点的距离,fovy是视锥体的y方向的张角。

8.8.6 细化策略(Refinement Strategies)

细化策略(Refinement Strategies)就是例8.6中的refine参数。这个参数决定了以何种方式在高细节层级瓦片中增加细节。通常的方式是替换(REPLACE),意思是子瓦片节点会替换其父瓦片,这也是OSGB格式采取的策略;Cesium中还额外支持新增(ADD),意思是子瓦片在父瓦片的基础上,增加新的内容。具体示意图如下图8.57所示:

图8.57 3D Tiles中的细化策略

每个瓦片都可以设置细化策略参数,如果未指定,说明该瓦片的细化策略继承自父瓦片。

8.8.7 渲染优化算法

假设已经存在一个3D Tiles瓦片集和相机视锥体如下图8.58所示。3D Tiles瓦片集我们比较好理解,关键元素我们已经在前面几小节中介绍过了。相机视锥体是三维图形中经常要用到的一个概念,好比真实世界中,我们需要拍摄到一个物体,必须让相机调整到合适的位置(Position),调整好合适的角度(Orientation)以及调整合适的焦距(Field-of-view angle,视场角)。

图8.58 3D Tiles瓦片集和相机视锥体

接下来,我们可以模拟出在可视化客户端渲染实现中,3D Tiles格式是如何平衡任何比例的渲染性能和视觉质量了。虽然我们在前面中已经将这个思想(分页LOD机制/HLOD)论述了很多次了,但这里我们可以对照下图8.59所示进行进一步理解:

  1. 最开始加载的是JSON格式的瓦片集文件,并测试视锥体与根瓦片边界体积是否相交。在这里,视锥体与根瓦片的包围体相交,这意味着该瓦片可能需要被加载进行渲染。
  2. 由于根瓦片是没有内容的,那么就测试子瓦片的包围体与视锥体的相交。在这里,三个子瓦片中的两个的包围体确实与视锥体相交,这意味着这些子瓦片的内容会被考虑进行渲染;而剩下的一个瓦片就被直接剔除不用渲染。
  3. 此时检查瓦片的几何误差,根据式(8-3)计算此时的屏幕空间误差。此时由于没有超过阈值18.0,说明内容呈现的精细度正好合适。
  4. 然后,当用户进行交互,例如放大某个建筑物时,根据式(8-3)可知瓦片的屏幕空间误差会增大而超过阈值,有可能需要进行下一层级的渲染。并且新的视锥体可能会剔除更多的瓦片不用渲染,只有一小部分瓦片集可见。
  5. 根据所选的细化策略加载和渲染具有较高细节级别的内容。由于较高细节级别瓦片的几何误差较小,导致屏幕空间误差低于阈值,此时可以呈现更高精细度的视觉质量。

图8.59 3D Tiles中的细化策略

8.8.8 瓦片内容数据

3D Tiles瓦片内容数据通常以URI的形式引用外部文件,如例8.6中的house.b3dm、detailsA.b3dm和detailsB.b3dm。因为这些文件是3D Tiles瓦片的主体,所以很多情况下为了方便使用就将其当成瓦片本身。3D Tiles瓦片的格式可以有以下四种表现形式:

  1. Batched 3D Model(b3dm):批处理三维模型,最常规的三维模型。
  2. Instanced 3D Model(i3dm):实例化三维模型,相同三维模型的多个实例。
  3. Point Clouds(pnts):点云,大量点组成的数据。
  4. Composite Tiles(cmpt):以上三种的复合数据。

3D Tiles瓦片其实就是一种普通的三维模型数据,我们可以按照第7章三维模型介绍的内容来理解它。不过3D Tiles瓦片与普通三维模型最大的不同就在于它是按照GIS矢量要素特性来进行设计的,具体来说,就是3D Tiles瓦片中除了三维模型之外,还有要素表(Feature Table)和批处理表(Batch Table)来作为属性数据。另一方面,三维模型自身也被逻辑上拆分成多个要素模型,通过ID与属性表相关联。实际上,正如第7.5.2节中所述,这种设计实现了三维模型的单体化,在业务应用中有很大的实用意义。

1. 批处理三维模型(Batched 3D Models)

批处理三维模型(Batched 3D Models,b3dm)是3D Tiles常用的瓦片数据格式,因为其本质上就是最常规的三维模型数据。具体有多常规呢,b3dm内部直接嵌入了一个我们在第7.2节中介绍的glTF三维模型文件,具体数据布局如下图8.60所示。根据其数据布局,我们可以作一个大概的说明:

  • magic是魔法值的意思,其实就是文件标识符,具体就是“b3dm”四个字符。
  • version和byteLength分别代表版本和整个b3dm文件的字节长度。
  • featureTableJSONByteLength、featureTableBinaryByteLength、batchTableJSONByteLength和batchTableBinaryByteLength的大小分别描述了要素表JSON部分的字节长度、要素表二进制部分的字节长度、批处理表JSON部分的字节长度、批处理表二进制部分的字节长度。
  • 文件主体包含三个部分,分别是要素表(这是必须的),批处理表(可选的)以及内嵌的glTF三维模型文件。

图8.60 3D Tiles的b3dm格式瓦片数据布局

b3dm的文件数据组织我们已经初步了解,那么是如何将三维模型其拆分成多个要素模型呢?方法很简单,是通过扩展了一个名为batchId的顶点属性来实现的。对于不同的要素模型,我们分别赋予其不同的batchId值,这样在将三维模型渲染成二维画面的时候,通过二维画面像素关联的batchId值,我们就区分哪些画面像素是属于哪个要素的。如下图8.61所示:

图8.61 b3dm中不同的要素模型存储的不同的batchId值

现在已经有了batchId值了,那么我们就需要将其关联到要素表和批处理表。对于b3dm瓦片格式来说,图8.61对应的要素表的JSON部分通常为:

{
    "BATCH_LENGTH": 2
}

BATCH_LENGTH是要素表的必须属性,表示要素的个数为2。b3dm通常不使用要素表的二进制部分,而将要素模型的属性数据放入到批处理表中。例如,图8.61对应的批处理表的JSON部分通常为:

{
    "height": [
        16.2
        23.0,        
    ],
    "address": [
        "234 Second Street",
        "123 Main Street"
    ]
}

这里表达了批处理表中高度字段属性和地址字段属性,每个字段属性值都是一个数组元素,而batchId就是这个数组元素的索引。很显然,这正是batchId关联属性表的关键:第1个模型要素的高度是16.2,地址是234 Second Street;第2个模型要素的高度是23.0,地址是123 Main Street。

一般情况下,只使用批处理表的JSON部分就可以表达要素模型的属性表了。批处理表的二进制部分则是用来配合JSON部分来表达特定数据类型的属性,例如当JSON部分为如下所示时:

{
    "location": {
        "byteOffset": 0,
        "componentType": "FLOAT",
        "type": "VEC2"
    },
    "id": {
        "byteOffset": 32,
        "componentType": "INT",
        "type": "SCALAR"
    }
}

那么location和id属性字段值就会在二进制部分中进行查找,byteOffset表示起始位置字节偏移,type表示数据类型,componentType则表示数据分量类型。其实这三个参数与glTF中的顶点属性数据的表达非常像,type和componentType值的要求也与glTF中值的要求一致,复习以下第7.2节中glTF的介绍就会非常容易理解。

话说回来,我们说b3dm是参照矢量要素的设计思路实现的,是从GIS的角度进行出发论述。其实从“批处理”这个命名来说,设计者更多的是从图形渲染的角度出发来进行设计的。在图形渲染行业中,术语“批处理”是指多个模型的几何数据进行合并,组合成单个的缓冲区进入GPU显存中进行渲染,这样可以减少复制操作带来的损耗,最小化渲染绘制调用次数,从而提高渲染性能。不得不说,b3dm的设计确实很精妙,很多学问到了最深处往往都是相通的。

2. 实例化三维模型(Instanced 3D Models)

有了b3dm作为基础,实例化三维模型(Instanced 3D Model,i3dm)就比较容易理解了。不过,我们首先需要知道为什么这种瓦片格式叫做实例化三维模型。其实“实例化”这个术语是图形渲染中的一种技术,通过实例化技术可以一次性渲染大量相同的模型,只不过这些模型有一些特定的变化。例如我们渲染大量的树木,我们可以使用同一个树木模型,然后让每个树木模型的位置、旋转和缩放不同,就可以得到一大片形态各异的树林。实例化的优点就在于,既然创建一个树木对象进行渲染是很耗费性能的,那么就将这个树木对象改变一下位置、朝向以及大小进行复制粘贴,这样就可以很轻易绘制出包含大量三维模型数据的场景,并且能保证性能。

实例化技术具有非常多的应用场景,因为很多现实中的物体是有规范和标准的,比如城市中的部件,BIM中的基础设施,工业设计中的零件等,它们往往都有非常相似的外观,使用实例化技术可以有非常好的效果。这也是为什么3D Tiles将实例化三维模型作为一种瓦片数据格式。

从前面的介绍不难理解,i3dm相比较普通三维模型数据,最大的区别在于多了表达变化的实例化参数(比如前面提到的位置、旋转和缩放)。i3dm实例化参数信息是放置在要素表中的,因此,i3dm瓦片数据布局与b3dm瓦片数据布局基本一致,如下图8.62所示:

图8.62 3D Tiles的i3dm格式瓦片数据布局

除了多了一个表达gltf是外部还是内嵌的参数gltfFormat,i3dm与b3dm最大的不同就在于要素表和批处理表。要素表中需要存放实例化参数,例如一个要素表的JSON部分如下所示:

{
    "INSTANCES_LENGTH": 3,
    "POSITION": {
        "byteOffset": 0
    },
    "NORMAL_UP": {
        "byteOffset": 36
    },
    "NORMAL_RIGHT": {
        "byteOffset": 72
    },
    "SCALE": {
        "byteOffset": 108
    }
}

INSTANCES_LENGTH是必须的参数,表示实例化个数。POSITION、NORMAL_UP、NORMAL_RIGHT和SCALE是预先定义好的语义,分别表示位置、旋转的上方向、旋转的右方向以及缩放,它们分别用3个float型、3个float型、3个float型以及1个float型来表示,配合起始位置字节偏移byteOffset,我们可以很容易找出存储在要素表二进制部分的实例化参数,如下图8.63所示:

图8.63 i3dm中的实例化参数

另外,i3dm也是遵循要素化的设计思路的,不过与b3dm不同,i3dm是以单个的实例化对象为单个要素,并且关联属性。在要素表中,可以在JSON部分增加一个名为BATCH_ID的语义,在二进制部分存储不同实例化对象的batchId值。而批处理表中则像b3dm一样进行存储其他属性数据,这样就实现了单个的实例化模型与属性信息的关联。

3. 点云(Point Clouds)

相比较b3dm和i3dm,点云(Point Clouds,pnts)形式的瓦片数据格式就更加简单了,甚至不用内嵌glTF。点云pnts的数据布局如下图8.64所示:

图8.64 3D Tiles的pnts格式瓦片数据布局

点云除了记录点的位置属性之外,还可能有法向量、颜色等属性,这些属性数据都是记录在要素表中的。如下所示是一个pnts要素表的JSON部分:

{
    "POINTS_LENGTH": "219",
    "POSITION": {
        "byteOffset": 0
    },
    "NORMAL": {
        "byteOffset": 2628
    },
    "RGB": {
        "byteOffset": 5256
    }
}

类似i3dm的要素表,这里的POINTS_LENGTH表示点的个数,而POSITION、NORMAL和RGB这些属性名称也是预定义的语义类型,配合起始位置字节偏移量byteOffset可以找到点属性具体的属性值,具体示意图如下图8.65所示:

图8.65 pnts将点云属性存储在要素表中

pnts也是遵循要素化的设计思路,从要素表来看,似乎点云中一个点就是一个要素,但这样理解并不准确。pnts需要表达的是一个要素模型,例如一个点云瓦片表示的是一个房屋,那么房屋内部中的门、窗或者屋顶才是我们想要知道的要素模型。要实现这样的要素识别非常简单,还是使用如同b3dm或i3dm相同的办法,在要素表中增加一个名为BATCH_ID的字段,记录每个点云的batchId值,如下图8.66所示:

图8.66 pnts通过Batch ID区分不同的点云要素

剩下的就还是如同b3dm一样,在批处理表中存储其他属性数据,实现多个点组成的要素模型与属性信息相关联。

4. 复合瓦片(Composite Tiles)

复合瓦片(Composite Tiles,cmpt)是以上介绍的瓦片格式的复合数据格式。举例来说,一组建筑物可以存储在b3dm中,一组树木可以存储在i3dm中,如果这些元素出现在同一地理位置时,就可以将其组合成cmpt,实现单个的请求获取该地理位置所有的可渲染内容,如下图 8.67所示。这样的设计可以减少访问的请求个数,改善瓦片数据加载时的视觉效果。

图 8.67 3D Tiles的cmpt格式瓦片实现示意图

cmpt的数据组织非常灵活,可以包含b3dm、i3dm和pnts中的任意种类任意个数的瓦片数据,甚至可以包含另一个cmpt瓦片数据。但它的数据布局就简单了,如下图8.68所示。文件头通过tilesLength标识包含的子瓦片的个数,文件主体则是具体的子瓦片数据内容。

图8.68 3D Tiles的pnts格式瓦片数据布局

8.8.9 声明式样式(Declarative Styling)

既然3D Tiles的瓦片数据格式是按照要素特性来进行设计的,那么免不了要面对的就是模型要素符号化的问题。3D Tiles使用声明性样式在运行时修改功能的外观,所谓声明性样式,具体来说就是包含一组表达式的JSON。这种样式JSON规定了一些变量,表达式以及条件,可以看作是一种简单的样式语言。例如我们让一组表达建筑的3D Tiles根据其高度呈现不同的颜色,可以使用如下样式JSON:

{
    "color": {
        "conditions": [
            ["${height} >= 300", "rgba(45, 0, 75, 0.5)"],
            ["${height} >= 200", "rgb(102, 71, 151)"],
            ["${height} >= 100", "rgb(170, 162, 204)"],
            ["${height} >= 50", "rgb(224, 226, 238)"],
            ["${height} >= 25", "rgb(252, 230, 200)"],
            ["${height} >= 10", "rgb(248, 176, 87)"],
            ["${height} >= 5", "rgb(198, 106, 11)"],
            ["true", "rgb(127, 59, 8)"]
        ]
    }
}

其中,color是要素模型的颜色值属性,决定要素模型渲染的颜色。height则表示3D Tiles瓦片中批处理表种的height字段,根据这个字段值的不同,给模型要素赋予不同的颜色。在CesiumJS中实现效果如下图8.69所示:

图8.69 3D Tiles的声明式样式的效果图

虽然很多写实的三维模型可能用不到这个功能,但是这个设计实现在业务系统中很有用处,也很容易扩展,可以帮助我们实现更酷炫更有价值的可视化效果,值得我们进一步研究。

8.8.10 其他

从以上对3D Tiles格式的介绍可以感受到,3D Tiles确实是设计的非常完善的三维模型瓦片数据格式,也因此得到了最为广泛的使用。除此之外,另一个OGC标准——ArcGIS设计的I3S(Indexed 3D Scene Layers)三维模型瓦片数据格式也很优秀,与3D Tiles相比,它的一些特点给笔者留下了比较深刻的印象,主要是:

  • 3D Tiles是离散文件集形式的静态资源,I3S则可以打包成.slpk这种zip格式的单文件,也支持使用RESTful接口访问。
  • 3D Tiles空间坐标参考默认是WGS84椭球的地心地固坐标系,少部分参数使用WGS84地理坐标系;而I3S则专业很多,支持目前绝大多数地理空间坐标参考。
  • 不知道是否是处于兼容性的考虑,I3S设计的参数非常多,但可视化的时候很多参数都没有用上(这也是ArcGIS的一贯特色);3D Tiles这方面则简练很多,只提供了最简单的参数要求,其余的需求通过扩展来实现。
  • I3S在设计中实现了几何数据、属性数据、纹理材质的解耦,这意味着这些资源可以共享,在一些渲染实现中可以通过这种机制来提升性能。
  • I3S确定LOD层级的算法与3D Tiles不同,而跟OSGB比较类似,通过计算包围球投影到屏幕空间的像素大小来确定。

而I3S其余的设计实现,基于与3D Tiles大同小异,笔者这里就不多作介绍了。值得一提的是,I3S虽然没有提供具体的代码实现,但是其官方在线文档 github.com/Esri/i3s-sp… 中提供了一个可用于浏览I3S数据的在线浏览器,以及各个版本的I3S数据下载,这对于我们的研究学习很有帮助。

最后,国内还有一种使用的比较多的三维模型瓦片数据格式:主要由超图软件开发设计的S3M(spatial 3D model)格式。尽管S3M是中国地理信息产业协会的空间三维模型数据格式标准,但这个格式笔者接触的不多,毕竟愿意使用S3M格式的数据,多少有点敏感性,是不太容易获取进行研究的。

不过,笔者还是查阅了一下S3M官方在线文档(github.com/SuperMap/s3… Tiles和I3S最有诚意的一点是除了提供与其他三维瓦片数据的转换工具,还提供了读写S3M瓦片数据的JavaScript和C++代码实现,并且一直在更新。不过,缺点就是文档不够完善,至少笔者也没有看到S3M1.0、S3M2.0和S3M3.0不同版本之间的演进。而仅存的一版S3M标准文档的内容,相对于3D Tiles文档中完善的技术指导和参数说明也失之简陋。重于实现而轻于文档,这一点也只能说是国内开源工作的通病了。


本文节选自作者新书《GIS基础原理与技术实践》第8章。书中系统讲解 GIS 核心理论与多语言实战,适合开发者与高校师生。

📚 配套资源开源GitHub | GitCode 🛒 支持正版京东当当

❌
❌