阅读视图

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

vxe-gantt vue table 甘特图子任务多层级自定义模板用法

vxe-gantt vue table 甘特图子任务多层级自定义模板用法,通过树结构来渲染多层级的子任务,将数据带有父子层级的数组进行渲染

查看官网:gantt.vxeui.com/
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

安装

npm install xe-utils@3.8.0 vxe-pc-ui@4.10.45 vxe-table@4.17.26 vxe-gantt@4.1.2
// ...
import VxeUIBase from 'vxe-pc-ui'
import 'vxe-pc-ui/es/style.css'

import VxeUITable from 'vxe-table'
import 'vxe-table/es/style.css'

import VxeUIGantt from 'vxe-gantt'
import 'vxe-gantt/lib/style.css'
// ...

createApp(App).use(VxeUIBase).use(VxeUITable).use(VxeUIGantt).mount('#app')
// ...

效果

image

代码

树结构由 tree-config 和 column.tree-node 参数开启,支持自动转换带有父子层级字段的平级列表数据,例如 { id: 'xx', parentId: 'xx' }。只需要设置 tree-config.transform 就可以开启自动转换,通过 task-view-config.scales.headerCellStyle 自定义列头样式

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions">
      <template #task-bar="{ row }">
        <div class="custom-task-bar" :style="{ backgroundColor: row.bgColor }">
          <div class="custom-task-bar-img">
            <vxe-image :src="row.imgUrl" width="60" height="60"></vxe-image>
          </div>
          <div>
            <div>{{ row.title }}</div>
            <div>开始日期:{{ row.start }}</div>
            <div>结束日期:{{ row.end }}</div>
            <div>进度:{{ row.progress }}%</div>
          </div>
        </div>
      </template>

      <template #task-bar-tooltip="{ row }">
        <div>
          <div>任务名称:{{ row.title }}</div>
          <div>开始时间:{{ row.start }}</div>
          <div>结束时间:{{ row.end }}</div>
          <div>进度:{{ row.progress }}%</div>
        </div>
      </template>
    </vxe-gantt>
  </div>
</template>

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

const ganttOptions = reactive({
  border: true,
  height: 600,
  cellConfig: {
    height: 100
  },
  treeConfig: {
    transform: true,
    rowField: 'id',
    parentField: 'parentId'
  },
  taskViewConfig: {
    tableStyle: {
      width: 380
    },
    showNowLine: true,
    scales: [
      { type: 'month' },
      {
        type: 'day',
        headerCellStyle ({ dateObj }) {
          // 周日高亮
          if (dateObj.e === 0) {
            return {
              backgroundColor: '#f9f0f0'
            }
          }
          return {}
        }
      },
      {
        type: 'date',
        headerCellStyle ({ dateObj }) {
          // 周日高亮
          if (dateObj.e === 0) {
            return {
              backgroundColor: '#f9f0f0'
            }
          }
          return {}
        }
      }
    ],
    viewStyle: {
      cellStyle ({ dateObj }) {
        // 周日高亮
        if (dateObj.e === 0) {
          return {
            backgroundColor: '#f9f0f0'
          }
        }
        return {}
      }
    }
  },
  taskBarConfig: {
    showTooltip: true,
    barStyle: {
      round: true
    }
  },
  columns: [
    { field: 'title', title: '任务名称', treeNode: true },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, parentId: null, title: '任务1', start: '2024-03-03', end: '2024-03-10', progress: 20, bgColor: '#c1c452', imgUrl: 'https://vxeui.com/resource/productImg/product9.png' },
    { id: 10002, parentId: 10001, title: '任务2', start: '2024-03-05', end: '2024-03-12', progress: 15, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product8.png' },
    { id: 10003, parentId: 10001, title: '任务3', start: '2024-03-10', end: '2024-03-21', progress: 25, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product1.png' },
    { id: 10004, parentId: 10002, title: '任务4', start: '2024-03-15', end: '2024-03-24', progress: 70, bgColor: '#fad06c', imgUrl: 'https://vxeui.com/resource/productImg/product3.png' },
    { id: 10005, parentId: 10003, title: '任务5', start: '2024-03-20', end: '2024-04-05', progress: 50, bgColor: '#e78dd2', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10006, parentId: null, title: '任务6', start: '2024-03-22', end: '2024-03-29', progress: 38, bgColor: '#8be1e6', imgUrl: 'https://vxeui.com/resource/productImg/product7.png' },
    { id: 10007, parentId: null, title: '任务7', start: '2024-03-28', end: '2024-04-04', progress: 24, bgColor: '#78e6d1', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' },
    { id: 10008, parentId: 10007, title: '任务8', start: '2024-05-18', end: '2024-05-28', progress: 65, bgColor: '#edb695', imgUrl: 'https://vxeui.com/resource/productImg/product4.png' },
    { id: 10009, parentId: 10008, title: '任务9', start: '2024-05-05', end: '2024-05-28', progress: 78, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product6.png' },
    { id: 10010, parentId: 10008, title: '任务10', start: '2024-04-28', end: '2024-05-17', progress: 19, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' },
    { id: 10011, parentId: 10009, title: '任务11', start: '2024-04-01', end: '2024-05-01', progress: 100, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product4.png' },
    { id: 10012, parentId: 10009, title: '任务12', start: '2024-04-09', end: '2024-04-22', progress: 90, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product8.png' },
    { id: 10013, parentId: 10010, title: '任务13', start: '2024-03-22', end: '2024-04-05', progress: 86, bgColor: '#fad06c', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10014, parentId: null, title: '任务14', start: '2024-04-05', end: '2024-04-18', progress: 65, bgColor: '#8be1e6', imgUrl: 'https://vxeui.com/resource/productImg/product6.png' },
    { id: 10015, parentId: 10014, title: '任务15', start: '2024-03-05', end: '2024-03-18', progress: 48, bgColor: '#edb695', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10016, parentId: null, title: '任务16', start: '2024-03-01', end: '2024-03-28', progress: 28, bgColor: '#e78dd2', imgUrl: 'https://vxeui.com/resource/productImg/product12.png' },
    { id: 10017, parentId: null, title: '任务17', start: '2024-03-19', end: '2024-04-02', progress: 36, bgColor: '#c1c452', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' }
  ]
})
</script>

<style lang="scss" scoped>
.custom-task-bar {
  display: flex;
  flex-direction: row;
  padding: 8px 16px;
  width: 100%;
  font-size: 12px;
}
.custom-task-bar-img {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  width: 70px;
  height: 70px;
}
</style>

gitee.com/x-extends/v…

从零构建Vue项目的完全指南:手把手打造现代化前端工程

从零构建Vue项目的完全指南:手把手打造现代化前端工程

一、项目构建整体流程图

让我们先看看完整的项目构建流程:

deepseek_mermaid_20251207_d1ddc8.png

二、详细构建步骤

步骤1:环境准备与项目初始化

首先确保你的开发环境已准备好:

# 检查Node.js版本(建议18+)
node -v

# 检查npm版本
npm -v

# 安装Vue CLI(如果还没有)
npm install -g @vue/cli

# 创建新项目
vue create my-vue-project

# 选择配置(推荐手动选择)
? Please pick a preset: 
  Default ([Vue 2] babel, eslint)
  Default (Vue 3) ([Vue 3] babel, eslint)
❯ Manually select features

# 选择需要的功能
? Check the features needed for your project:
 ◉ Babel
 ◉ TypeScript
 ◉ Progressive Web App (PWA) Support
 ◉ Router
 ◉ Vuex
 ◉ CSS Pre-processors
❯◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing

步骤2:项目目录结构设计

一个良好的目录结构是项目成功的基础。这是我推荐的目录结构:

my-vue-project/
├── public/                    # 静态资源
│   ├── index.html
│   ├── favicon.ico
│   └── robots.txt
├── src/
│   ├── api/                  # API接口管理
│   │   ├── modules/         # 按模块划分的API
│   │   ├── index.ts         # API统一导出
│   │   └── request.ts       # 请求封装
│   ├── assets/              # 静态资源
│   │   ├── images/
│   │   ├── styles/
│   │   └── fonts/
│   ├── components/          # 公共组件
│   │   ├── common/         # 全局通用组件
│   │   ├── business/       # 业务组件
│   │   └── index.ts        # 组件自动注册
│   ├── composables/        # 组合式函数
│   │   ├── useFetch.ts
│   │   ├── useForm.ts
│   │   └── index.ts
│   ├── directives/         # 自定义指令
│   │   ├── permission.ts
│   │   └── index.ts
│   ├── layouts/            # 布局组件
│   │   ├── DefaultLayout.vue
│   │   └── AuthLayout.vue
│   ├── router/             # 路由配置
│   │   ├── modules/       # 路由模块
│   │   ├── index.ts
│   │   └── guard.ts      # 路由守卫
│   ├── store/              # Vuex/Pinia状态管理
│   │   ├── modules/       # 模块化store
│   │   └── index.ts
│   ├── utils/              # 工具函数
│   │   ├── auth.ts        # 权限相关
│   │   ├── validate.ts    # 验证函数
│   │   └── index.ts
│   ├── views/              # 页面组件
│   │   ├── Home/
│   │   ├── User/
│   │   └── About/
│   ├── types/              # TypeScript类型定义
│   │   ├── api.d.ts
│   │   ├── global.d.ts
│   │   └── index.d.ts
│   ├── App.vue
│   └── main.ts
├── tests/                   # 测试文件
├── .env.*                   # 环境变量
├── vite.config.ts          # Vite配置
├── tsconfig.json           # TypeScript配置
└── package.json

步骤3:核心配置详解

1. 配置Vite(vite.config.ts)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@components': path.resolve(__dirname, 'src/components'),
      '@views': path.resolve(__dirname, 'src/views'),
    },
  },
  server: {
    host'0.0.0.0',
    port3000,
    proxy: {
      '/api': {
        target'http://localhost:8080',
        changeOrigintrue,
        rewrite: (path) => path.replace(/^/api/, ''),
      },
    },
  },
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@import "@/assets/styles/variables.scss";`,
      },
    },
  },
})
2. 路由配置(router/index.ts)
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routesRouteRecordRaw[] = [
  {
    path'/',
    name'Home',
    component() => import('@views/Home/Home.vue'),
    meta: {
      title'首页',
      requiresAuthtrue,
    },
  },
  {
    path'/login',
    name'Login',
    component() => import('@views/Login/Login.vue'),
    meta: {
      title'登录',
    },
  },
  {
    path'/user/:id',
    name'User',
    component() => import('@views/User/User.vue'),
    propstrue,
  },
]

const router = createRouter({
  historycreateWebHistory(),
  routes,
})

// 路由守卫
router.beforeEach((to, from, next) => {
  document.title = to.meta.title as string || 'Vue项目'
  
  // 检查是否需要登录
  if (to.meta.requiresAuth && !localStorage.getItem('token')) {
    next('/login')
  } else {
    next()
  }
})

export default router
3. 状态管理(使用Pinia)
// store/user.ts
import { defineStore } from 'pinia'

interface UserState {
  userInfo: {
    namestring
    avatarstring
    rolesstring[]
  } | null
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    userInfonull,
  }),
  actions: {
    async login(credentials: { username: string; password: string }) {
      // 登录逻辑
      const response = await api.login(credentials)
      this.userInfo = response.data
      localStorage.setItem('token', response.token)
    },
    logout() {
      this.userInfo = null
      localStorage.removeItem('token')
    },
  },
  getters: {
    isLoggedIn(state) => !!state.userInfo,
    hasRole(state) => (role: string) => 
      state.userInfo?.roles.includes(role) || false,
  },
})

步骤4:核心工具库和插件选择

这是我在项目中推荐使用的库:

{
  "dependencies": {
    "vue""^3.3.0",
    "vue-router""^4.2.0",
    "pinia""^2.1.0",
    "axios""^1.4.0",
    "element-plus""^2.3.0",
    "lodash-es""^4.17.21",
    "dayjs""^1.11.0",
    "vxe-table""^4.0.0",
    "vue-i18n""^9.0.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue""^4.2.0",
    "@types/node""^20.0.0",
    "sass""^1.62.0",
    "eslint""^8.0.0",
    "prettier""^3.0.0",
    "husky""^8.0.0",
    "commitlint""^17.0.0",
    "vitest""^0.30.0",
    "unplugin-auto-import""^0.16.0",
    "unplugin-vue-components""^0.25.0"
  }
}

步骤5:实用的组件示例

1. 全局请求封装
// src/api/request.ts
import axios from 'axios'
import type { AxiosRequestConfigAxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'

const service = axios.create({
  baseURLimport.meta.env.VITE_API_BASE_URL,
  timeout10000,
})

// 请求拦截器
service.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, data, message } = response.data
    
    if (code === 200) {
      return data
    } else {
      ElMessage.error(message || '请求失败')
      return Promise.reject(new Error(message))
    }
  },
  (error) => {
    if (error.response?.status === 401) {
      // 未授权,跳转到登录页
      localStorage.removeItem('token')
      window.location.href = '/login'
    }
    ElMessage.error(error.message || '网络错误')
    return Promise.reject(error)
  }
)

export default service
2. 自动导入组件配置
// vite.config.ts 补充配置
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    // 自动导入API
    AutoImport({
      imports: ['vue''vue-router''pinia'],
      dts'src/types/auto-imports.d.ts',
      resolvers: [ElementPlusResolver()],
    }),
    // 自动导入组件
    Components({
      dts'src/types/components.d.ts',
      resolvers: [ElementPlusResolver()],
      dirs: ['src/components'],
    }),
  ],
})
3. 实用的Vue 3组合式函数
// src/composables/useForm.ts
import { ref, reactive, computed } from 'vue'
import type { Ref } from 'vue'

export function useForm<T extends object>(initialData: T) {
  const formData = reactive({ ...initialData }) as T
  const errors = reactive<Record<stringstring>>({})
  const isSubmitting = ref(false)

  const validate = async (): Promise<boolean> => {
    // 这里可以集成具体的验证逻辑
    return true
  }

  const submit = async (submitFn: (data: T) => Promise<any>) => {
    if (!(await validate())) return
    
    isSubmitting.value = true
    try {
      const result = await submitFn(formData)
      return result
    } catch (error) {
      throw error
    } finally {
      isSubmitting.value = false
    }
  }

  const reset = () => {
    Object.assign(formData, initialData)
    Object.keys(errors).forEach(key => {
      errors[key] = ''
    })
  }

  return {
    formData,
    errors,
    isSubmittingcomputed(() => isSubmitting.value),
    validate,
    submit,
    reset,
  }
}

步骤6:开发规范与最佳实践

1. 代码提交规范
# 安装Git提交钩子
npx husky install
npm install -D @commitlint/config-conventional @commitlint/cli

# 创建commitlint配置
echo "module.exports = { extends: ['@commitlint/config-conventional'] }" > .commitlintrc.js

# 创建提交信息规范
# feat: 新功能
# fix: 修复bug
# docs: 文档更新
# style: 代码格式
# refactor: 重构
# test: 测试
# chore: 构建过程或辅助工具的变动
2. 环境变量配置
# .env.development
VITE_APP_TITLE=开发环境
VITE_API_BASE_URL=/api
VITE_USE_MOCK=true

# .env.production
VITE_APP_TITLE=生产环境
VITE_API_BASE_URL=https://api.example.com
VITE_USE_MOCK=false

步骤7:性能优化建议

// 路由懒加载优化
const routes = [
  {
    path'/dashboard',
    component() => import(/* webpackChunkName: "dashboard" */ '@/views/Dashboard.vue'),
  },
  {
    path'/settings',
    component() => import(/* webpackChunkName: "settings" */ '@/views/Settings.vue'),
  },
]

// 图片懒加载指令
// src/directives/lazyLoad.ts
import type { Directive } from 'vue'

const lazyLoadDirective = {
  mounted(el, binding) {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.src = binding.value
          observer.unobserve(el)
        }
      })
    })
    observer.observe(el)
  },
}

三、项目启动和常用命令

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
    "format": "prettier --write src/",
    "prepare": "husky install",
    "test": "vitest",
    "test:coverage": "vitest --coverage"
  }
}

四、总结与建议

通过以上步骤,你已经拥有了一个现代化、可维护的Vue项目基础。记住几个关键点:

  1. 1. 保持一致性 - 无论是命名规范还是代码风格
  2. 2. 模块化设计 - 功能解耦,便于维护和测试
  3. 3. 类型安全 - 充分利用TypeScript的优势
  4. 4. 自动化 - 尽可能自动化重复工作
  5. 5. 渐进式 - 不要一开始就追求完美,根据项目需求逐步完善

项目代码就像一座大厦,良好的基础决定了它的稳固性和可扩展性。希望这篇指南能帮助你在Vue项目开发中少走弯路!

vue也支持声明式UI了,向移动端kotlin,swift看齐,抛弃html,pug升级版,进来看看新语法吧

众所周知,新生代的ui框架(如:kotlin,swift,flutter,鸿蒙)都已经抛弃了XML这类的结构化数据标记语言改为使用声明式UI

只有web端还没有支持此类ui语法,此次我开发的ovsjs为前端也带来了此类声明式UI语法的支持,语法如下

项目地址

github.com/alamhubb/ov…

语法插件地址:

marketplace.visualstudio.com/items?itemN…

新语法如下:

image.png

我认为更强的地方是我的新设计除了为前端带来了声明式UI,还支持了 #{ } 不渲染代码块的设计,支持在 声明式UI中编写代码,这样UI和逻辑之间的距离更近,维护更方便,抽象组件也更容易

对比kotlin,swift,flutter,鸿蒙语法如下:

kotlin的语法

import kotlinx.browser.*
import kotlinx.html.*
import kotlinx.html.dom.*

fun main() {
    document.body!!.append.div {
        h1 {
            +"Welcome to Kotlin/JS!"
        }
        p {
            +"Fancy joining this year's "
            a("https://kotlinconf.com/") {
                +"KotlinConf"
            }
            +"?"
        }
    }
}

swiftUI的语法

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 16) {
            Text("Hello SwiftUI")
                .font(.largeTitle)
                .fontWeight(.bold)

            Text("Welcome to SwiftUI world")

            Button("Click Me") {
                print("Button clicked")
            }
        }
        .padding()
    }
}

flutter的语法

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Text(
                "Hello Flutter",
                style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
              ),
              const SizedBox(height: 12),
              const Text("Welcome to Flutter world"),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () {
                  print("Button clicked");
                },
                child: const Text("Click Me"),
              )
            ],
          ),
        ),
      ),
    );
  }
}

鸿蒙 arkts

@Entry
@Component
struct Index {
  @State message: string = 'Hello ArkUI'

  build() {
    Column() {
      Text(this.message)
        .fontSize(28)
        .fontWeight(FontWeight.Bold)

      Text('Welcome to HarmonyOS')
        .margin({ top: 12 })

      Button('Click Me')
        .margin({ top: 16 })
        .onClick(() => {
          console.log('Button clicked')
        })
    }
    .padding(20)
  }
}

原理实现

简述一下实现原理,就是通过parser支持了新语法,然后将新语法转义为 iife包裹的vue的h函数

为什么要iife包裹

因为要支持不渲染代码块

ovs图中的代码对应的编译后的代码是这样的

import {defineOvsComponent} from "/@fs/D:/project/qkyproject/test-volar/ovs/ovs-runtime/src/index.ts";
import {$OvsHtmlTag} from "/@fs/D:/project/qkyproject/test-volar/ovs/ovs-runtime/src/index.ts";
import {ref} from "/node_modules/.vite/deps/vue.js?v=76ca4127";
export default defineOvsComponent(props => {
  const msg = "You did it!";
  let count = ref(0);
  const timer = setInterval(() => {
    count.value = count.value + 1;
  },1000);
  return $OvsHtmlTag.div({class:'greetings',onClick(){
    count.value = 0;
  }},[
    $OvsHtmlTag.h1({class:'green'},[msg]),
    count,
    $OvsHtmlTag.h3({},[
      "You've successfully created a project with ",
      $OvsHtmlTag.a({href:'https://vite.dev/',target:'_blank',rel:'noopener'},['Vite']),
      ' + ',
      $OvsHtmlTag.a({href:'https://vuejs.org/',target:'_blank',rel:'noopener'},['Vue 3']),
      ' + ',
      $OvsHtmlTag.a({href:'https://github.com/alamhubb/ovsjs',target:'_blank',rel:'noopener'},['OVS']),
      '.'
    ])
  ]);
});

parser是我自己写的,抄了 chevortain 的设计,写了个subhuti,支持定义peg语法

github.com/alamhubb/ov…

slimeparser,支持es2025语法的parser,基于subhuti,声明es2025语法就行

github.com/alamhubb/ov…

然后就是ovs继承slimeparser,添加了ovs的语法支持,并且在ast生成的时候将代码转为vue的渲染函数,运行时就是运行的vue的渲染函数的代码,所以完美支持vue的生态

感兴趣的可以试试,入门教程

github.com/alamhubb/ov…

由于本人能力有先,文中存在错误不足之处,请大家指正,有对新语法感兴趣的欢迎留言和我交流

使用husky和fabric规范git提交的注释

一、背景与意义

在项目开发过程中,有些开发人员有时提交git时注释写得很随意,不方便日后管理和问题回溯。对于JavaScript项目,可以使用husky和fabric规范git提交的注释。

二、引入git注释检查

在JavaScript项目中,安装husky和fabric:

npm install husky @umijs/fabric --save-dev

然后初始化husky:

npx husky install

运行上面的命令之后,会生成一个 .husky 目录,在 .husky 目录下创建一个commit-msg文件,其内容如下:

#!/usr/bin/env sh

# Export Git hook params
export GIT_PARAMS=$*

npx --no-install fabric verify-commit

然后使用git命令执行代码提交:

git add .
git commit -m 'test'

执行上述命令时,得到的输出如下:

image.png

显然,git注释规范已经生效。如果是按照规范的注释提交:

git commit -m 'feat: 引入husky与fabric对git注释做规范'

则可以提交成功。

三、解决代码合并时报错的问题

在合并代码时,其自动生成的注释并不符合规范,无法通过校验。
假设当前是master分支,我们创建一个新的分支:

git checkout -b new_branch1

然后随便添加一个提交:

echo '' > test.txt
git add .
git commit -m 'feat: first commit in new_branch1'

然后再切回原来的分支,添加一个提交:

git checkout master
echo '' > test2.txt
git add .
git commit -m 'feat: first commit in master'

接下来,如果做分支合并:

git merge new_branch1

将会报错: image.png

为解决这个问题,需要在 .husky 目录下创建文件 prepare-commit-msg,其内容如下:

#!/bin/sh

case "$2" in
  merge)
    MERGING_BRANCH_SHA=$(cat ".git/MERGE_HEAD")
    MERGING_BRANCH_NAME=$(git name-rev --name-only "$MERGING_BRANCH_SHA")
    MERGE_TARGET=$(git rev-parse --abbrev-ref HEAD)

    cat > "$1" <<EOF
feat: 合并分支,将 "${MERGING_BRANCH_NAME}" 分支的内容合并到 "${MERGE_TARGET}" 分支
EOF
  ;;
esac

接下来 git merge new_branch1 执行时就不会报错,合并代码时,自动生成的注释为:
feat: 合并分支,将 "new_branch1" 分支的内容合并到 "master" 分支

AI真好玩系列-Three.js手势控制游戏开发教程 | Interactive Game Development with Three.js Hand Con

周末的深夜,睡不着觉,闲来无事,打开Gemini哈基米玩一把,废话不多说,先上图看最终效果~

碎碎念:我很想上传视频,但是好像有的博客不支持,这里先放个图片

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

在这里插入图片描述

在这里插入图片描述

💖 提示词

请使用Three.js,创建一个完整的"手势玩游戏"3D交互游戏网页。

📚 项目代码(项目剖析和简介都在下面哦)

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>Three.js 手势控制游戏 - 太空捕手</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #000; font-family: 'Arial', sans-serif; }
        #canvas-container { width: 100%; height: 100vh; display: block; }
        #video-container {
            position: absolute;
            top: 10px;
            left: 10px;
            width: 160px;
            height: 120px;
            z-index: 100;
            border: 2px solid #00ffcc;
            border-radius: 8px;
            overflow: hidden;
            opacity: 0.7;
        }
        #input-video {
            width: 100%;
            height: 100%;
            transform: scaleX(-1); /* 镜像翻转,让操作更直观 */
            object-fit: cover;
        }
        #ui-layer {
            position: absolute;
            top: 20px;
            right: 20px;
            color: #fff;
            text-align: right;
            pointer-events: none;
        }
        h1 { margin: 0; font-size: 24px; text-transform: uppercase; letter-spacing: 2px; color: #00ffcc; }
        #score-board { font-size: 40px; font-weight: bold; }
        #loading {
            position: absolute;
            top: 50%; left: 50%;
            transform: translate(-50%, -50%);
            color: white;
            font-size: 20px;
            background: rgba(0,0,0,0.8);
            padding: 20px;
            border-radius: 10px;
            text-align: center;
        }
    </style>
    
    <!-- 引入 Three.js -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <!-- 引入 MediaPipe Hands -->
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
</head>
<body>

    <!-- 3D 场景容器 -->
    <div id="canvas-container"></div>

    <!-- 摄像头预览 (用于确认手势) -->
    <div id="video-container">
        <video id="input-video"></video>
    </div>

    <!-- UI 界面 -->
    <div id="ui-layer">
        <h1>Cosmic Catcher</h1>
        <div>Score</div>
        <div id="score-board">0</div>
    </div>

    <div id="loading">正在加载 AI 模型和摄像头...<br>请允许摄像头权限</div>

    <script>
        // --- 游戏状态 ---
        let score = 0;
        let gameRunning = false;
        const sceneWidth = 20; // 游戏世界的宽度范围
        const playerY = -8;
        
        // --- 1. Three.js 场景设置 ---
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x1a1a2e);
        // 添加一些迷雾增加深邃感
        scene.fog = new THREE.FogExp2(0x1a1a2e, 0.03);

        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        camera.position.z = 10;
        camera.position.y = 2;
        camera.lookAt(0, 0, 0);

        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.getElementById('canvas-container').appendChild(renderer.domElement);

        // 灯光
        const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
        scene.add(ambientLight);
        
        const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
        dirLight.position.set(5, 10, 7);
        scene.add(dirLight);

        // --- 2. 创建游戏对象 ---
        
        // 玩家 (滑块)
        const playerGeometry = new THREE.BoxGeometry(3, 0.5, 1);
        const playerMaterial = new THREE.MeshPhongMaterial({ color: 0x00ffcc, emissive: 0x004444 });
        const player = new THREE.Mesh(playerGeometry, playerMaterial);
        player.position.y = playerY;
        scene.add(player);

        // 掉落物管理器
        const fallingObjects = [];
        const objectGeometry = new THREE.IcosahedronGeometry(0.6, 0);
        const objectMaterial = new THREE.MeshPhongMaterial({ color: 0xff0055, shininess: 100 });

        function spawnObject() {
            if (!gameRunning) return;
            const obj = new THREE.Mesh(objectGeometry, objectMaterial.clone());
            // 随机颜色
            obj.material.color.setHSL(Math.random(), 1.0, 0.5);
            
            // 随机 X 位置
            obj.position.x = (Math.random() - 0.5) * sceneWidth; 
            obj.position.y = 10; // 从上方掉落
            obj.position.z = 0;
            
            // 随机旋转速度
            obj.userData = {
                rotSpeed: {
                    x: Math.random() * 0.1,
                    y: Math.random() * 0.1
                },
                speed: 0.1 + Math.random() * 0.1 // 掉落速度
            };
            
            scene.add(obj);
            fallingObjects.push(obj);
        }

        // --- 3. MediaPipe Hands 设置 (手势识别) ---
        const videoElement = document.getElementById('input-video');
        const loadingElement = document.getElementById('loading');

        // 映射函数:将视频中的坐标 (0-1) 映射到 3D 场景坐标 (-10 到 10)
        function mapRange(value, inMin, inMax, outMin, outMax) {
            return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
        }

        function onResults(results) {
            loadingElement.style.display = 'none';
            if (!gameRunning) gameRunning = true;

            if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
                // 获取第一只手
                const landmarks = results.multiHandLandmarks[0];
                
                // 获取食指指尖 (索引为 8)
                const indexFingerTip = landmarks[8];
                
                // MediaPipe 的 x 坐标是 0(左) 到 1(右)。
                // 注意:我们在 CSS 里镜像翻转了视频,但数据本身没有翻转。
                // 在游戏里,为了直观,如果你向右移手,我们希望 x 变大。
                // 原生数据:屏幕左边是0,右边是1。
                // 映射到 3D 场景:x 从 -10 到 10。
                
                // 平滑移动 (Lerp) 以减少抖动
                const targetX = mapRange(1 - indexFingerTip.x, 0, 1, -sceneWidth/2, sceneWidth/2); // 1-x 因为自拍模式通常是镜像
                player.position.x += (targetX - player.position.x) * 0.2;
            }
        }

        const hands = new Hands({locateFile: (file) => {
            return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
        }});

        hands.setOptions({
            maxNumHands: 1,
            modelComplexity: 1,
            minDetectionConfidence: 0.5,
            minTrackingConfidence: 0.5
        });
        hands.onResults(onResults);

        const cameraUtils = new Camera(videoElement, {
            onFrame: async () => {
                await hands.send({image: videoElement});
            },
            width: 320,
            height: 240
        });
        cameraUtils.start();

        // --- 4. 游戏主循环 ---
        const scoreEl = document.getElementById('score-board');
        let frameCount = 0;

        function animate() {
            requestAnimationFrame(animate);

            if (gameRunning) {
                frameCount++;
                // 每 60 帧生成一个新物体
                if (frameCount % 60 === 0) {
                    spawnObject();
                }

                // 更新掉落物
                for (let i = fallingObjects.length - 1; i >= 0; i--) {
                    const obj = fallingObjects[i];
                    obj.position.y -= obj.userData.speed;
                    obj.rotation.x += obj.userData.rotSpeed.x;
                    obj.rotation.y += obj.userData.rotSpeed.y;

                    // 碰撞检测 (简单的 AABB 或 距离检测)
                    // 检查 Y 轴高度是否到达玩家高度
                    if (obj.position.y < playerY + 1 && obj.position.y > playerY - 1) {
                        // 检查 X 轴距离
                        if (Math.abs(obj.position.x - player.position.x) < 2.0) {
                            // 接住了!
                            score += 10;
                            scoreEl.innerText = score;
                            
                            // 特效:玩家发光一下
                            player.material.emissive.setHex(0xffffff);
                            setTimeout(() => player.material.emissive.setHex(0x004444), 100);

                            // 移除物体
                            scene.remove(obj);
                            fallingObjects.splice(i, 1);
                            continue;
                        }
                    }

                    // 掉出屏幕
                    if (obj.position.y < -10) {
                        scene.remove(obj);
                        fallingObjects.splice(i, 1);
                    }
                }
            }

            renderer.render(scene, camera);
        }

        // 窗口大小调整适配
        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });

        animate();

    </script>
</body>
</html>

🌟 项目简介 | Project Introduction

这是一个基于 Three.js 和 MediaPipe Hands 构建的前端交互式游戏项目。通过摄像头捕捉你的手势,你可以直接用食指控制屏幕上的太空飞船,接住从天而降的能量球来得分。整个游戏过程无需任何物理控制器,只需要你的手就可以完成所有操作,带来前所未有的沉浸式体验!🚀

📌 前提条件 | Prerequisites

  1. 现代浏览器: Chrome/Firefox/Safari 最新版
  2. 摄像头设备: 内置或外接摄像头
  3. 基础 HTML/CSS/JS 知识: 有助于理解代码结构

🚀 核心技术栈 | Core Technologies

技术 用途 链接
Three.js WebGL 3D 渲染引擎 threejs.org
MediaPipe 手部关键点识别 mediapipe.dev
HTML5 Canvas 3D场景渲染容器 -

👐 手势控制原理 | Hand Control Principle

通过 MediaPipe 获取手部 21 个关键点坐标,我们重点关注食指指尖(索引8)的位置信息,将其映射到3D场景坐标系中,实现精准控制。

  • 食指指尖(Index Finger Tip): 索引 8
  • 坐标映射: 将摄像头画面坐标(0-1)映射到游戏世界坐标(-10至10)

🧩 核心代码片段 | Core Code Snippets

1. 游戏场景初始化 | Scene Initialization

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
// 添加一些迷雾增加深邃感
scene.fog = new THREE.FogExp2(0x1a1a2e, 0.03);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 10;
camera.position.y = 2;
camera.lookAt(0, 0, 0);

2. 手势识别配置 | Hand Detection Configuration

const hands = new Hands({locateFile: (file) => {
    return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
}});

hands.setOptions({
    maxNumHands: 1,
    modelComplexity: 1,
    minDetectionConfidence: 0.5,
    minTrackingConfidence: 0.5
});
hands.onResults(onResults);

3. 手势控制逻辑 | Hand Control Logic

function onResults(results) {
    loadingElement.style.display = 'none';
    if (!gameRunning) gameRunning = true;

    if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
        // 获取第一只手
        const landmarks = results.multiHandLandmarks[0];
        
        // 获取食指指尖 (索引为 8)
        const indexFingerTip = landmarks[8];
        
        // MediaPipe 的 x 坐标是 0(左) 到 1(右)。
        // 注意:我们在 CSS 里镜像翻转了视频,但数据本身没有翻转。
        // 在游戏里,为了直观,如果你向右移手,我们希望 x 变大。
        // 原生数据:屏幕左边是0,右边是1。
        // 映射到 3D 场景:x 从 -10 到 10。
        
        // 平滑移动 (Lerp) 以减少抖动
        const targetX = mapRange(1 - indexFingerTip.x, 0, 1, -sceneWidth/2, sceneWidth/2); // 1-x 因为自拍模式通常是镜像
        player.position.x += (targetX - player.position.x) * 0.2;
    }
}

4. 游戏主循环 | Main Game Loop

function animate() {
    requestAnimationFrame(animate);

    if (gameRunning) {
        frameCount++;
        // 每 60 帧生成一个新物体
        if (frameCount % 60 === 0) {
            spawnObject();
        }

        // 更新掉落物
        for (let i = fallingObjects.length - 1; i >= 0; i--) {
            const obj = fallingObjects[i];
            obj.position.y -= obj.userData.speed;
            obj.rotation.x += obj.userData.rotSpeed.x;
            obj.rotation.y += obj.userData.rotSpeed.y;

            // 碰撞检测 (简单的 AABB 或 距离检测)
            // 检查 Y 轴高度是否到达玩家高度
            if (obj.position.y < playerY + 1 && obj.position.y > playerY - 1) {
                // 检查 X 轴距离
                if (Math.abs(obj.position.x - player.position.x) < 2.0) {
                    // 接住了!
                    score += 10;
                    scoreEl.innerText = score;
                    
                    // 特效:玩家发光一下
                    player.material.emissive.setHex(0xffffff);
                    setTimeout(() => player.material.emissive.setHex(0x004444), 100);

                    // 移除物体
                    scene.remove(obj);
                    fallingObjects.splice(i, 1);
                    continue;
                }
            }

            // 掉出屏幕
            if (obj.position.y < -10) {
                scene.remove(obj);
                fallingObjects.splice(i, 1);
            }
        }
    }

    renderer.render(scene, camera);
}

🎮 游戏机制 | Game Mechanics

  1. 手势控制: 使用食指控制底部的太空飞船左右移动
  2. 物品收集: 接住从天而降的能量球获得分数
  3. 视觉反馈: 成功接住物品时飞船会发出光芒
  4. 实时计分: 右上角显示当前得分

🛠️ 使用指南 | Run Guide

本地运行 | Local Run

  1. 将代码保存为 demo02.html
  2. 用现代浏览器直接打开即可(需允许摄像头权限)

🔧 定制项 | Customization Options

项目 修改方法 效果预览
飞船颜色 更改 color: 0x00ffcc 🟢 青色飞船 → 🟣 紫色飞船
掉落物样式 修改 IcosahedronGeometry 🔺 正二十面体 → 🎈 球体
游戏难度 调整 frameCount % 60 🐌 慢速 → ⚡ 快速
场景主题 更改 scene.background 🌃 深蓝 → 🌌 黑色

🐛 常见问题 | Troubleshooting

  1. 摄像头无法启动?
    • 检查浏览器权限设置
    • 确保没有其他程序占用摄像头
  2. 手势识别不准确?
    • 保持手部在摄像头清晰可见范围内
    • 调整环境光线避免过暗或过曝
  3. 游戏运行卡顿?
    • 降低掉落物生成频率
    • 关闭其他占用资源的程序

📚 扩展学习资源 | Extended Resources

Conclusion | 结语

  • That's all for today~ - | 今天就写到这里啦~

  • Guys, ( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ See you tomorrow~~ | 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • Everyone, be happy every day! 大家要天天开心哦

  • Welcome everyone to point out any mistakes in the article~ | 欢迎大家指出文章需要改正之处~

  • Learning has no end; win-win cooperation | 学无止境,合作共赢

  • Welcome all the passers-by, boys and girls, to offer better suggestions! ~~~ | 欢迎路过的小哥哥小姐姐们提出更好的意见哇~~

image

偷看浏览器后台,发现它比我忙多了

为啥以前 IE 老“崩溃”,而现在开一堆标签页的 Chrome 还能比较稳?答案都藏在浏览器的「进程模型」里。

作为一个还在学习前端的同学,我经常听到几个关键词:
进程、线程、多进程浏览器、渲染进程、V8、事件循环……

下面就是我目前的理解,算是一篇学习笔记式的分享

一、先把概念捋清:进程 vs 线程

  • 进程(Process)

    • 操作系统分配资源的最小单位。
    • 拥有独立的内存空间、句柄、文件等资源。
    • 不同进程间默认互相隔离,通信要通过 IPC。
  • 线程(Thread)

    • CPU 调度、执行代码的最小单位。
    • 共享所属进程的资源(内存、文件句柄等)。
    • 一个进程里可以有多个线程并发执行。

简单理解:

  • 进程 = 一个“应用实例” (开一个浏览器窗口就是一个进程)。
  • 线程 = 应用里的很多“小工人” (一个负责渲染页面,一个负责网络请求……)。

二、单进程浏览器:旧时代的 IE 模型

早期的浏览器(如旧版 IE)基本都是单进程架构:整个浏览器只有一个进程,里面开多个线程来干活。

可以想象成这样一张图(对应你给的“单进程浏览器”那张图):

  • 顶部是「代码 / 数据 / 文件」等资源。

  • 下面有多个线程:

    • 页面线程:负责页面渲染、布局、绘制。
    • 网络线程:负责网络请求。
    • 其他线程:例如插件、定时任务等。
  • 所有线程共享同一份进程资源。

在这个模型下:

  • 页面渲染、JavaScript 执行、插件运行,都挤在同一个进程里。
  • 某个插件或脚本一旦崩溃、死循环、内存泄漏,整个浏览器都会被拖垮
  • 多开几个标签页,本质上依旧是同一个进程里的不同页面线程, “一荣俱荣,一损俱损”

这也是很多人对 IE 的经典印象:

“多开几个页面就卡死,崩一次,所有标签页一起消失”。

三、多进程浏览器:Chrome 的现代架构

Chrome 采用的是多进程、多线程混合架构。打开浏览器时,大致会涉及这些进程:

  • 浏览器主进程(Browser Process)

    • 负责浏览器 UI、地址栏、书签、前进后退等。
    • 管理和调度其他子进程(类似一个大管家)。
    • 负责部分存储、权限管理等。
  • 渲染进程(Render Process)

    • 核心任务:把 HTML / CSS / JavaScript 变成用户可以交互的页面。
    • 布局引擎(如 Blink)和 JS 引擎(如 V8)都在这里。
    • 默认情况下,每个标签页会对应一个独立的渲染进程
  • GPU 进程(GPU Process)

    • 负责 2D / 3D 绘制和加速(动画、3D 变换等)。
    • 统一为浏览器和各渲染进程提供 GPU 服务。
  • 网络进程(Network Process)

    • 负责资源下载、网络请求、缓存等。
  • 插件进程(Plugin Process)

    • 负责运行如 Flash、扩展等插件代码,通常放在更严格的沙箱里。

你给的第一张图,其实就是这么一个多进程架构示意图:中间是主进程,两侧是渲染、网络、GPU、插件等子进程,有的被沙箱保护。

download.png

四、单进程 vs 多进程:核心差异一览(表格对比)

下面这张表,把旧式单进程浏览器和现代多进程浏览器的差异总结出来,适合在文章中重点展示:

对比维度 单进程浏览器(典型:旧版 IE) 多进程浏览器(典型:Chrome)
进程模型 整个浏览器基本只有一个进程,多标签页只是不同线程 浏览器主进程 + 多个子进程(渲染、网络、GPU、插件…),标签页通常独立渲染进程
稳定性 任意线程(脚本、插件)崩溃,可能拖垮整个进程,浏览器整体崩溃 某个标签页崩溃只影响对应渲染进程,其他页面基本不受影响
安全性 代码都在同一进程运行,权限边界模糊,攻击面大 利用多进程 + 沙箱:渲染进程、插件进程被限制访问系统资源,需要通过主进程/IPC
性能体验 多标签共享资源,某个页面卡顿,容易拖慢整体;UI 和页面渲染耦合严重 不同进程之间可以更好地利用多核 CPU,重页面操作不会轻易阻塞整个浏览器 UI
内存占用 单进程内存相对集中,但一旦泄漏难以回收;崩溃时损失全部状态 多进程会有一定内存冗余,但某个进程关闭/崩溃后,其内存可被系统直接回收
插件影响 插件崩溃 = 浏览器崩溃,体验极差 插件独立进程 + 沙箱,崩溃影响有限,可以单独重启
维护与扩展 所有模块耦合在一起,改动风险大 进程边界天然分层,更利于模块化演进和大规模工程化

download.png

五、别被“多进程”骗了:JS 依然是单线程

聊到这里,很多同学容易混淆一个点:

浏览器是多进程的,那 JavaScript 是不是也多线程并行执行了?

答案是否定的:主线程上的 JavaScript 依然是单线程模型。区别在于:

  • 渲染进程内部,有一个主线程负责:

    • 执行 JavaScript。
    • 页面布局、绘制。
    • 处理用户交互(点击、输入等)。
  • JS 代码仍然遵循:同步任务立即执行,异步任务丢进任务队列,由事件循环(Event Loop)调度

为什么要坚持单线程?

  • DOM 是单线程模型,多个线程同时改 DOM,锁会非常复杂。
  • 前端开发心智成本可控;不必像多线程语言那样到处考虑锁和竞态条件。

多进程架构只是把:

  • “这个页面的主线程 + 渲染 + JS 引擎”
    放在一个单独的进程里(渲染进程)。

这也是为什么:

  • 一个页面 JS 写了死循环,会卡死那一个标签页。
  • 但其他标签页通常还能正常使用,因为它们在完全不同的渲染进程内。

六、从架构看体验:为什么我们更喜欢现在的浏览器?

站在前端开发者角度,多进程架构带来的直接收益有:

  • 更好的容错性

  • 更高的安全等级

  • 更顺滑的交互体验

  • 更容易工程化演进

当然,代价也很现实:多进程 = 更高的内存占用。这也是为什么:

  • 多开几十个标签,任务管理器里能看到很多浏览器相关进程。
  • 但换来的是更好的稳定性、安全性和扩展性——在现代硬件下,这是可以接受的 trade-off。

七、总结

  • 早期浏览器采用单进程 + 多线程模式,所有页面、脚本、插件都在同一个进程里,一旦出问题就“全军覆没”。
  • 现代浏览器(代表是 Chrome)使用多进程架构:主进程负责调度,各个渲染、网络、GPU、插件进程各司其职,并通过沙箱强化隔离。
  • 尽管浏览器整体是多进程的,但单个页面里的 JavaScript 依然是单线程 + 事件循环模型,这点没有变。
  • 从用户体验、前端开发、安全性、稳定性来看,多进程架构几乎是全面碾压旧时代单进程浏览器的一次升级。

JSON 转 BPMN 实战:如何用 Flowable 把前端流程图JSON转成 XML(附完整代码)

步骤概览:

  1. 准备 JSON 数据结构
  2. 定义 Node 抽象类及子类
  3. 使用 Jackson 多态解析 JSON
  4. 实现 convert() 方法生成 FlowElement
  5. 构建 SequenceFlow 连接节点
  6. 导出 .bpmn20.xml 文件

开发Flowable实战项目时,需要用到工作流的前端绘制页面,而该页面转换保存的数据为Json格式,在Flowable等工作流引擎中均使用Bpmn文件,其后缀为.bpmn20.xml.bpmn.xml 。通过后缀可以看出该文件为xml文档文件,由此官方提供了flowable-bpmn-converter的Maven依赖,我们可以通过该依赖实现Json转xml:

<dependency>
   <groupId>org.flowable</groupId>
   <artifactId>flowable-bpmn-converter</artifactId>
   <version>7.2.0</version>
</dependency>

准备工作已完成,接下来就是准备转换的Model,我们需要做的是将Json映射到不同的类中,对应好相关的继承关系,最后使用依赖中的转换完成文件的导出。

注:本文中“节点”指 JSON 中的流程节点,“FlowElement”指 Flowable 中的 BPMN 元素。

一、准备工作


1.1 抽象类Node

在开始之前先要理清楚每一个工作流的节点以及他们之间的关系,需要抽象出来一个最开始的Node节点,Node与其继承类的关系大致长这个样子:

在这里插入图片描述

图中可以看出,子类Node均有节点id父节点id节点名称等等共同点,因此可以抽象出来,即:

private String id;          // 节点id
private String parentId;    // 父节点id
private String name;        // 节点名称
private String type;        // 节点类型
private Node next;          // 子节点
private JsonNode props;     // 属性配置
@JsonIgnore
private String branchId;    // 分支id(辅助属性)

因为后续还有转换动作,而每一个节点均需要进行转换,所以需要添加一个convert()的抽象方法,方便后续进行转换。

public abstract List<FlowElement> convert();

1.2 Jackson的多态类型

为了方便做转换,这里我准备了一段Json数据,该数据是由wflow-web-next Vue3版本生成,稍微做了一些小调整,添加了next属性,并将子节点嵌套入了next,后续使用递归转换也可以做到这一点:

  • 简单的Json测试数据

    {
        "name": "未命名流程",
        "groupId": 222,
        "process": {
            "id": "node_root",
            "type": "Start",
            "name": "发起人",
            "parentId": null,
            "next": {
                "id": "node_17628242120246176",
                "type": "Approval",
                "name": "审批人",
                "parentId": "node_root",
                "next": {
                    "id": "node_end",
                    "type": "End",
                    "name": "流程结束",
                    "parentId": "node_17628242120246176",
                    "props": {}
                },
                "props": {
                    "mode": "USER",
                    "ruleType": "ROOT_SELF",
                    "taskMode": {
                        "type": "AND",
                        "percentage": 100
                    },
                    "needSign": false,
                    "assignUser": [],
                    "rootSelect": {
                        "multiple": false
                    },
                    "leader": {
                        "level": 1,
                        "emptySkip": false
                    },
                    "leaderTop": {
                        "level": 0,
                        "toEnd": false,
                        "emptySkip": false
                    },
                    "assignDept": {
                        "dept": [],
                        "type": "LEADER"
                    },
                    "assignRole": [],
                    "noUserHandler": {
                        "type": "TO_NEXT",
                        "assigned": []
                    },
                    "sameRoot": {
                        "type": "TO_SELF",
                        "assigned": []
                    },
                    "timeout": {
                        "enable": false,
                        "time": 1,
                        "timeUnit": "M",
                        "type": "TO_PASS"
                    }
                }
            },
            "props": {}
        },
        "remark": ""
    }
    

Json数据中包含嵌套的节点,这样就很方便我们使用Jackson的多态类型来进行嵌套的转换。

@Data
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME,include = JsonTypeInfo.As.PROPERTY,property = "type",defaultImpl = Node.class,visible = true)
@JsonSubTypes({
        @JsonSubTypes.Type(value = StartNode.class,name = "Start"),
        @JsonSubTypes.Type(value = ApprovalNode.class, name = "Approval"),
        @JsonSubTypes.Type(value = EndNode.class, name = "End")
})

上述代码中,@JsonTypeInfo用来确定我们需要转换的标识,如iJsonTypeInfo.As.PROPERTY代表使用的是属性的名称来确定我们需要转换的不同的类型,所以在@JsonSubTypes中可以看到子类型的注释,当属性type的值为Start时,指定转换为StartNode类的实体,当我们有更多不同的节点时,就需要在这里添加转换的标识了。

二、Node节点


在转换时需要StartEvent、UserTask、EndEvent这三个工作流节点,现在我们来创建他们,值得一提的是,我们在上面的Node节点关系图中看到,AssigneeNode有多个子节点,这是因为用户任务可以在这个基础上分支出Approval类型抄送类型 ,有多种不同的用户指派方式,所以需要再次抽象出一个类:

@EqualsAndHashCode(callSuper = true)
@Data
public abstract class AssigneeNode extends Node {
    // 审批对象
    private AssigneeTypeEnum assigneeType;
    // 表单内人员
    private String formUser;
    // 表单内角色
    private String formRole;
    // 审批人
    private List<String> users;
    // 审批人角色
    private List<String> roles;
    // 主管
    private Integer leader;
    // 组织主管
    private Integer orgLeader;
    // 发起人自选:true-单选,false-多选
    private Boolean choice;
    // 发起人自己
    private Boolean self;

    public abstract List<FlowElement> convert();
}

Approval类型的属性声明及转换方法:

@EqualsAndHashCode(callSuper = true)
@Data
@ToString(callSuper = true)
public class ApprovalNode extends AssigneeNode{

    private ApprovalMultiEnum multi;    // 多人审批方式
    private BigDecimal multiPercent;    // 多人会签通过百分比
    private Node next;                  // 子节点

    @Override
    public List<FlowElement> convert() {

        // 所有节点集合
        ArrayList<FlowElement> elements = new ArrayList<>();
        // 用户节点
        UserTask userTask = new UserTask();
        userTask.setId(this.getId());
        userTask.setName(this.getName());
        // 审批人
        MultiInstanceLoopCharacteristics multiInstanceLoopCharacteristics = new MultiInstanceLoopCharacteristics();
        if (this.getMulti() == ApprovalMultiEnum.SEQUENTIAL) { // 多人审批方式-顺序审批
            multiInstanceLoopCharacteristics.setSequential(true);
        } else if (this.getMulti() == ApprovalMultiEnum.JOINT) { // 多人审批方式-并行审批
            multiInstanceLoopCharacteristics.setSequential(false);
            if (Objects.nonNull(this.getMultiPercent()) && this.getMultiPercent().compareTo(BigDecimal.ZERO) > 0) {
                BigDecimal percent = this.getMultiPercent().divide(new BigDecimal(100), 2, RoundingMode.DOWN);
                multiInstanceLoopCharacteristics.setCompletionCondition(String.format("${nrOfCompletedInstances/nrOfInstances >= %s}", percent));
            }
        } else if (this.getMulti() == ApprovalMultiEnum.SINGLE) { // 正常签名
            multiInstanceLoopCharacteristics.setSequential(false);
            multiInstanceLoopCharacteristics.setCompletionCondition("${nrOfCompletedInstances > 0}");
        }

        String variable = String.format("%sItem", this.getId());
        multiInstanceLoopCharacteristics.setElementVariable(variable);
        multiInstanceLoopCharacteristics.setInputDataItem(String.format("${%sCollection}", this.getId()));
        userTask.setLoopCharacteristics(multiInstanceLoopCharacteristics);
        userTask.setAssignee(String.format("${%s}", variable));
        elements.add(userTask);
        // 下一个节点的连线
        Node next = this.getNext();
        SequenceFlow sequenceFlow = this.buildSequence(next);
        elements.add(sequenceFlow);
        // 下一个节点
        if (Objects.nonNull(next)) {
            next.setBranchId(this.getBranchId());
            List<FlowElement> flowElements = next.convert();
            elements.addAll(flowElements);
        }

        return elements;
    }
}

StartEvent和EndEvent较为简单,所以只需实现转换代码。

public class StartNode extends Node {
    @Override
    public List<FlowElement> convert() {
        ArrayList<FlowElement> elements = new ArrayList<>();
        // 创建开始节点
        StartEvent startEvent = new StartEvent();
        startEvent.setId(this.getId());
        startEvent.setName(this.getName());
        // startEvent.setExecutionListeners(this.buidEventListener()); 监听器
        // 添加节点
        elements.add(startEvent);
        // 获取下一个节点
        Node next = this.getNext();
        SequenceFlow sequenceFlow = this.buildSequence(next);
        elements.add(sequenceFlow);
        // 递归下一个节点及之后的所有节点
        if (Objects.nonNull(next)) {
            List<FlowElement> flowElements = next.convert();
            elements.addAll(flowElements);
        }
        return elements;
    }
}
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Data
public class EndNode extends Node {

    @Override
    public List<FlowElement> convert() {
        ArrayList<FlowElement> elements = new ArrayList<>();
        // 结束节点
        EndEvent endEvent = new EndEvent();
        endEvent.setId(this.getId());
        endEvent.setName(this.getName());
//        endEvent.setExecutionListeners(this.buidEventListener());
        elements.add(endEvent);
        return elements;
    }
}

三、构建SequenceFlow


在这里插入图片描述

每一个节点都需要使用一个SequenceFlow来进行连接,实质上SequenceFlow也是一个节点,因此可以在Node抽象类中增加buildSequence()方法,转换时,构建每个节点的子节点为SequenceFlow:

public SequenceFlow buildSequence(Node next) {
        String sourceRef;
        String targetRef;
        if (Objects.nonNull(next)) {
            sourceRef = next.getParentId();
            targetRef = next.getId();
        } else { // Try to find branch
            if (StringUtils.isNotBlank(this.branchId)) {
                sourceRef = this.id;
                targetRef = this.branchId;
            } else {
                throw new RuntimeException(String.format("节点 %s 的下一个节点不能为空", this.id));
            }
        }
        // Build SequenceFlow
        SequenceFlow sequenceFlow = new SequenceFlow();
        sequenceFlow.setId(String.format("%s-%s", sourceRef, targetRef));
        sequenceFlow.setSourceRef(sourceRef);
        sequenceFlow.setTargetRef(targetRef);
        return sequenceFlow;
 }

四、审批方式枚举

补充上面代码的审批角色枚举类,如果有特殊的需求和审批方式,均可以在这里添加,后续在转换时可以根据类型进行功能调整:

@Getter
@AllArgsConstructor
public enum AssigneeTypeEnum {
    USER("user", "用户"),
    ROLE("role", "角色"),
    CHOICE("choice", "发起人自选"),
    SELF("self", "发起人自己"),
    LEADER("leader", "部门主管"),
    ORG_LEADER("orgLeader", "组织主管"),
    FORM_USER("formUser", "表单用户"),
    FORM_ROLE("formRole", "表单角色"),
    AUTO_REFUSE("autoRefuse", "自动拒绝"),
    AUTO_PASS("autoPass", "自动通过");

    @JsonValue
    private final String type;
    private final String description;
}

审批方式枚举

@Getter
public enum ApprovalMultiEnum {
    SEQUENTIAL("sequential", "多人审批方式-顺序审批"),
    JOINT("joint", "多人审批方式-并行审批"),
    SINGLE("single", "多人审批方式-任何人审批");

    @JsonValue
    private final String multi;
    private final String description;

    ApprovalMultiEnum(String method, String description) {
        this.multi = method;
        this.description = description;
    }

}

五、开始转换

准备工作已经做好,使用压缩转义后的Json代码进行转换:

{"name":"未命名流程","groupId":222,"process":{"id":"node_root","type":"Start","name":"发起人","parentId":null,"next":{"id":"node_17628242120246176","type":"Approval","name":"审批人","parentId":"node_root","next":{"id":"node_end","type":"End","name":"流程结束","parentId":"node_17628242120246176","props":{}},"props":{"mode":"USER","ruleType":"ROOT_SELF","taskMode":{"type":"AND","percentage":100},"needSign":false,"assignUser":[],"rootSelect":{"multiple":false},"leader":{"level":1,"emptySkip":false},"leaderTop":{"level":0,"toEnd":false,"emptySkip":false},"assignDept":{"dept":[],"type":"LEADER"},"assignRole":[],"noUserHandler":{"type":"TO_NEXT","assigned":[]},"sameRoot":{"type":"TO_SELF","assigned":[]},"timeout":{"enable":false,"time":1,"timeUnit":"M","type":"TO_PASS"}}},"props":{}},"remark":""}

在开始转换之前,需要实例化ProcessModel,然后进行转换,我们先使用下面的代码进行测试:

ProcessModel person = objectMapper.readValue(jsonString, ProcessModel.class);
System.out.println(person);

BpmnModel bpmnModel = person.toBpmnModel();
byte[] xmlBytes = new BpmnXMLConverter().convertToXML(bpmnModel);

测试结果出来,如果next没有正常被转换,需要注意添加@ToString(callSuper = true),这样在转换时会包含父类的属性。正常转换打印输出:

ProcessModel(id=null, name=未命名流程, process=Node(id=node_root, parentId=null, name=发起人, type=Start, next=ApprovalNode(super=AssigneeNode(assigneeType=null, formUser=null, formRole=null, users=null, roles=null, leader=null, orgLeader=null, choice=null, self=null), multi=null, multiPercent=null, next=EndNode(super=Node(id=node_end, parentId=node_17628242120246176, name=流程结束, type=End, next=null, props={}, branchId=null))), props={}, branchId=null), groupId=222, remark=)

完整Main方法构建代码:

public class Main {
// 代码中就不做try catch了,输出失败会正常出错误信息
    public static void main(String[] args) throws IOException {
        String jsonString = "{\"name\":\"未命名流程\",\"groupId\":222,\"process\":{\"id\":\"node_root\",\"type\":\"Start\",\"name\":\"发起人\",\"parentId\":null,\"next\":{\"id\":\"node_17628242120246176\",\"type\":\"Approval\",\"name\":\"审批人\",\"parentId\":\"node_root\",\"next\":{\"id\":\"node_end\",\"type\":\"End\",\"name\":\"流程结束\",\"parentId\":\"node_17628242120246176\",\"props\":{}},\"props\":{\"mode\":\"USER\",\"ruleType\":\"ROOT_SELF\",\"taskMode\":{\"type\":\"AND\",\"percentage\":100},\"needSign\":false,\"assignUser\":[],\"rootSelect\":{\"multiple\":false},\"leader\":{\"level\":1,\"emptySkip\":false},\"leaderTop\":{\"level\":0,\"toEnd\":false,\"emptySkip\":false},\"assignDept\":{\"dept\":[],\"type\":\"LEADER\"},\"assignRole\":[],\"noUserHandler\":{\"type\":\"TO_NEXT\",\"assigned\":[]},\"sameRoot\":{\"type\":\"TO_SELF\",\"assigned\":[]},\"timeout\":{\"enable\":false,\"time\":1,\"timeUnit\":\"M\",\"type\":\"TO_PASS\"}}},\"props\":{}},\"remark\":\"\"}";

        ObjectMapper objectMapper = new ObjectMapper();

        // 方式一:先转换为JsonNode,再转换为实体类
        // JsonNode jsonNode = objectMapper.readTree(jsonString);
        // ProcessModel person2 = objectMapper.treeToValue(jsonNode, ProcessModel.class);

        // 方式一:直接转换为实体类
        ProcessModel person = objectMapper.readValue(jsonString, ProcessModel.class);
        System.out.println(person);

        BpmnModel bpmnModel = person.toBpmnModel();
        byte[] xmlBytes = new BpmnXMLConverter().convertToXML(bpmnModel);

        BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream("/Users/macbook/文件/Studio/Java/JsonToBpmnDemo/target/test.bpmn20.xml"));
        BufferedInputStream in = new BufferedInputStream(new ByteArrayInputStream(xmlBytes));
        byte[] buffer = new byte[8096];
        while (true) {
            int count = in.read(buffer);
            if (count == -1) {
                break;
            }
            outputStream.write(buffer, 0, count);
        }

        // 刷新并关闭流
        outputStream.flush();
        outputStream.close();
    }
}

输出成功后,在target文件夹总会正常出现test.bpmn20.xml文件。 在这里插入图片描述

六、最后

该代码适用wflow-web-next 设计器的Json数据,感谢wflow-web-next: wflow-web作者willianfu提供的设计器 ,可以结合该设计器使用,当然也可以使用任何以Flowable为项目的设计器数据,本案例只提供大致的思路,具体可以参考开源代码:lowflow-design-converter: 低代码流程设计器转bpmn,非常感谢蔡晓峰老师项目,给了我很大的启发,后续本项目完成后,我会开源发布在Github上,本篇博客如有任何问题欢迎评论区或私信建议,再次感谢。

在 UmiJS + Vue 3 项目中实现 WebP 图片自动转换和优化

前言

WebP 是一种现代图片格式,相比传统的 JPG/PNG 格式,通常可以减少 25-35% 的文件大小,某些图片甚至可以减少 80% 以上。本文将介绍如何在 UmiJS + Vue 3 项目中实现 WebP 图片的自动转换和智能加载。

功能特性

  • 构建时自动转换:构建时自动将 JPG/PNG 转换为 WebP
  • 智能格式选择:自动检测浏览器支持,优先使用 WebP
  • 自动回退:不支持的浏览器自动使用原始格式
  • 性能优化:使用缓存避免重复检测和重复加载
  • 零配置使用:组件化封装,使用简单

实现步骤

1. 安装依赖

pnpm add -D imagemin imagemin-webp

2. 创建图片转换脚本

创建 scripts/convert-images.mjs

import imagemin from "imagemin"
import imageminWebp from "imagemin-webp"
import path from "path"
import fs from "fs"
import { fileURLToPath } from "url"

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

/**
 * 图片转 WebP 脚本
 * 将 src/assets 目录下的 jpg/jpeg/png 图片转换为 webp 格式
 */
async function convertImages() {
  const assetsDir = path.join(__dirname, "../src/assets")

  // 检查目录是否存在
  if (!fs.existsSync(assetsDir)) {
    console.log("⚠️  assets 目录不存在,跳过图片转换")
    return
  }

  console.log("🖼️  开始转换图片为 WebP 格式...")

  try {
    const files = await imagemin([`${assetsDir}/*.{jpg,jpeg,png}`], {
      destination: assetsDir,
      plugins: [
        imageminWebp({
          quality: 80, // 质量 0-100,80 是质量和文件大小的良好平衡
          method: 6, // 压缩方法 0-6,6 是最慢但压缩率最高
        }),
      ],
    })

    if (files.length === 0) {
      console.log("ℹ️  没有找到需要转换的图片")
    } else {
      console.log(`✅ 成功转换 ${files.length} 张图片为 WebP 格式:`)
      files.forEach((file) => {
        const fileName = path.basename(file.destinationPath)
        const originalSize = fs.statSync(
          file.sourcePath.replace(/\.webp$/, path.extname(file.sourcePath))
        ).size
        const webpSize = fs.statSync(file.destinationPath).size
        const reduction = ((1 - webpSize / originalSize) * 100).toFixed(1)
        console.log(`   - ${fileName} (减少 ${reduction}%)`)
      })
    }
  } catch (error) {
    console.error("❌ 图片转换失败:", error.message)
    process.exit(1)
  }
}

// 执行转换
convertImages()

3. 创建图片工具函数

创建 src/utils/image.ts

/**
 * 图片工具函数 - 支持 WebP 格式
 */

const WEBP_SUPPORT_CACHE_KEY = "__webp_support__"

/**
 * 检测浏览器是否支持 WebP 格式(带缓存)
 * 使用 localStorage 缓存检测结果,避免重复检测
 */
export function checkWebPSupport(): Promise<boolean> {
  // 先检查缓存
  if (typeof window !== "undefined" && window.localStorage) {
    const cached = window.localStorage.getItem(WEBP_SUPPORT_CACHE_KEY)
    if (cached !== null) {
      return Promise.resolve(cached === "true")
    }
  }

  // 如果没有缓存,进行检测
  return new Promise((resolve) => {
    const webP = new Image()
    webP.onload = webP.onerror = () => {
      const supported = webP.height === 2
      // 缓存结果
      if (typeof window !== "undefined" && window.localStorage) {
        window.localStorage.setItem(WEBP_SUPPORT_CACHE_KEY, String(supported))
      }
      resolve(supported)
    }
    webP.src =
      "data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA"
  })
}

/**
 * 同步获取 WebP 支持状态(从缓存)
 * 如果缓存不存在,默认返回 true(现代浏览器都支持)
 * 这样可以避免初始加载非 WebP 资源
 */
export function getWebPSupportSync(): boolean {
  if (typeof window === "undefined" || !window.localStorage) {
    // SSR 环境,默认返回 true
    return true
  }

  const cached = window.localStorage.getItem(WEBP_SUPPORT_CACHE_KEY)
  if (cached !== null) {
    return cached === "true"
  }

  // 没有缓存时,默认假设支持(现代浏览器都支持 WebP)
  // 如果实际不支持,后续检测会更新缓存,下次就会使用正确的值
  return true
}

/**
 * 将图片 URL 转换为 WebP 格式
 * 支持多种转换方式:
 * 1. 内置图片:直接替换扩展名
 * 2. 在线图片:使用图片代理服务或 CDN 转换
 *
 * @param url 原始图片 URL
 * @param options 转换选项
 * @returns WebP 格式的 URL
 */
export function convertToWebP(
  url: string,
  options: {
    // 是否强制使用 WebP(即使浏览器不支持)
    force?: boolean
    // 图片代理服务 URL(用于在线图片转换)
    proxyUrl?: string
    // CDN 转换参数(如腾讯云、阿里云等)
    cdnParams?: string
  } = {}
): string {
  const { force = false, proxyUrl, cdnParams } = options

  // 如果是 data URL,直接返回
  if (url.startsWith("data:")) {
    return url
  }

  // 如果是内置图片(相对路径或 umi 处理后的路径),替换扩展名
  // 支持 umi 处理后的路径格式:/static/yay.7d162f31.jpg -> /static/yay.7d162f31.webp
  if (
    url.startsWith("./") ||
    url.startsWith("../") ||
    (!url.startsWith("http") && !url.startsWith("data:"))
  ) {
    // 匹配 .jpg, .jpeg, .png 扩展名(可能包含 hash)
    return url.replace(/\.(jpg|jpeg|png)(\?.*)?$/i, ".webp$2")
  }

  // 在线图片处理
  if (url.startsWith("http://") || url.startsWith("https://")) {
    // 方式1: 使用图片代理服务
    if (proxyUrl) {
      return `${proxyUrl}?url=${encodeURIComponent(url)}&format=webp`
    }

    // 方式2: 使用 CDN 参数转换(如腾讯云、阿里云等)
    if (cdnParams) {
      const separator = url.includes("?") ? "&" : "?"
      return `${url}${separator}${cdnParams}`
    }

    // 方式3: 使用在线图片转换服务(如 Cloudinary、ImageKit 等)
    // 这里提供一个示例,实际使用时需要根据服务商调整
    // return `https://your-image-service.com/convert?url=${encodeURIComponent(url)}&format=webp`;

    // 方式4: 简单替换扩展名(如果服务器支持)
    return url.replace(/\.(jpg|jpeg|png)(\?.*)?$/i, ".webp$2")
  }

  return url
}

/**
 * 获取图片的最佳格式 URL
 * 如果浏览器支持 WebP,返回 WebP 格式;否则返回原始格式
 *
 * @param originalUrl 原始图片 URL
 * @param webpUrl WebP 格式的 URL(可选,如果不提供则自动生成)
 * @param webpSupported 浏览器是否支持 WebP(可选,如果不提供则自动检测)
 * @returns 最佳格式的 URL
 */
export async function getBestImageUrl(
  originalUrl: string,
  webpUrl?: string,
  webpSupported?: boolean
): Promise<string> {
  const isSupported =
    webpSupported !== undefined ? webpSupported : await checkWebPSupport()

  if (isSupported) {
    return webpUrl || convertToWebP(originalUrl)
  }

  return originalUrl
}

/**
 * 预加载图片
 */
export function preloadImage(url: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const img = new Image()
    img.onload = () => resolve()
    img.onerror = reject
    img.src = url
  })
}

4. 创建 WebP 图片组件

创建 src/components/WebPImage.vue

<template>
  <picture>
    <!-- 如果支持 WebP,优先使用 WebP -->
    <source
      v-if="webpSupported && webpSrc"
      :srcset="webpSrc"
      type="image/webp"
    />
    <!-- 回退到原始格式 -->
    <img
      :src="fallbackSrc"
      :alt="alt"
      :width="width"
      :height="height"
      :class="imgClass"
      :style="imgStyle"
      :loading="loading"
      @load="handleLoad"
      @error="handleError"
    />
  </picture>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from "vue"
import {
  checkWebPSupport,
  getWebPSupportSync,
  convertToWebP,
} from "../utils/image"

interface Props {
  // 原始图片 URL(必需)
  src: string
  // WebP 格式的 URL(可选,如果不提供则自动生成)
  webpSrc?: string
  // 图片描述
  alt?: string
  // 图片宽度
  width?: string | number
  // 图片高度
  height?: string | number
  // CSS 类名
  imgClass?: string
  // 内联样式
  imgStyle?: string | Record<string, any>
  // 懒加载
  loading?: "lazy" | "eager"
  // 图片代理服务 URL(用于在线图片转换)
  proxyUrl?: string
  // CDN 转换参数
  cdnParams?: string
}

const props = withDefaults(defineProps<Props>(), {
  alt: "",
  loading: "lazy",
})

const emit = defineEmits<{
  load: [event: Event]
  error: [event: Event]
}>()

// 使用同步方法获取初始值,避免初始加载非 WebP 资源
// 如果缓存不存在,默认假设支持(现代浏览器都支持)
// 后续异步检测会更新这个值
const webpSupported = ref(getWebPSupportSync())

// 计算 WebP 格式的 URL
const webpSrc = computed(() => {
  // 如果明确提供了 webpSrc,直接使用
  if (props.webpSrc) {
    return props.webpSrc
  }

  // 对于在线图片,只有在提供了转换方式时才生成 WebP URL
  const isOnlineImage =
    props.src.startsWith("http://") || props.src.startsWith("https://")
  if (isOnlineImage) {
    if (!props.proxyUrl && !props.cdnParams) {
      // 如果没有提供转换方式,返回空字符串,使用原始格式
      return ""
    }
    // 有转换方式,生成 WebP URL
    return convertToWebP(props.src, {
      proxyUrl: props.proxyUrl,
      cdnParams: props.cdnParams,
    })
  }

  // 对于内置图片(通过 import 导入的),自动尝试使用 WebP 版本
  // 构建脚本会在同目录下生成 .webp 文件,通过替换扩展名来引用
  // 如果 webp 文件不存在,浏览器会自动回退到原始图片
  return convertToWebP(props.src, {})
})

// 回退到原始格式
const fallbackSrc = computed(() => props.src)

// 在后台异步检测 WebP 支持(如果缓存不存在)
// 这样可以更新缓存,但不影响初始渲染
onMounted(async () => {
  // 如果已经有缓存,就不需要再次检测
  if (typeof window !== "undefined" && window.localStorage) {
    const cached = window.localStorage.getItem("__webp_support__")
    if (cached === null) {
      // 没有缓存,进行检测并更新
      const supported = await checkWebPSupport()
      // 如果检测结果与初始假设不同,更新状态
      // 但此时图片可能已经加载,浏览器会使用 <picture> 标签自动选择
      if (supported !== webpSupported.value) {
        webpSupported.value = supported
      }
    }
  } else {
    // 没有 localStorage,直接检测
    webpSupported.value = await checkWebPSupport()
  }
})

const handleLoad = (event: Event) => {
  emit("load", event)
}

const handleError = (event: Event) => {
  emit("error", event)
}
</script>

5. 配置 TypeScript 类型声明

创建 src/types/images.d.ts

/**
 * 图片文件类型声明
 */
declare module "*.jpg" {
  const content: string
  export default content
}

declare module "*.jpeg" {
  const content: string
  export default content
}

declare module "*.png" {
  const content: string
  export default content
}

declare module "*.gif" {
  const content: string
  export default content
}

declare module "*.webp" {
  const content: string
  export default content
}

declare module "*.svg" {
  const content: string
  export default content
}

declare module "*.ico" {
  const content: string
  export default content
}

declare module "*.bmp" {
  const content: string
  export default content
}

6. 配置 UmiJS

更新 .umirc.ts,添加 WebP 文件支持:

import { defineConfig } from "umi"
import { routes } from "./src/router/index"

const isPrd = process.env.NODE_ENV === "production"

export default defineConfig({
  npmClient: "pnpm",
  presets: [require.resolve("@umijs/preset-vue")],
  manifest: {
    fileName: "manifest.json",
  },
  // 开发环境 vite ,线上环境 webpack 打包
  vite: isPrd ? false : {},
  chainWebpack: function (config, { webpack }) {
    // 配置 webp 文件支持(与 jpg/png 一样处理)
    // umi 默认已经处理图片,但可能不包含 webp,这里确保 webp 被正确处理
    // 使用 asset/resource 类型,让 webpack 将 webp 文件作为静态资源处理
    if (!config.module.rules.has("webp")) {
      config.module
        .rule("webp")
        .test(/\.webp$/)
        .type("asset/resource")
        .generator({
          filename: "static/[name].[hash:8][ext]",
        })
    }

    config.optimization.runtimeChunk(true)
    return config
  },
  codeSplitting: {
    jsStrategy: "depPerChunk",
  },
  hash: true,
  history: { type: "hash" },
  routes,
})

7. 更新 package.json

package.json 中添加构建脚本:

{
  "scripts": {
    "dev": "umi dev",
    "build": "pnpm build:images && umi build",
    "build:prd": "pnpm build:images && UMI_ENV=prd umi build",
    "build:images": "node scripts/convert-images.mjs",
    "postinstall": "umi setup",
    "start": "npm run dev",
    "preview": "umi preview",
    "analyze": "ANALYZE=1 umi build"
  }
}

使用方法

基本使用

<script setup lang="ts">
import WebPImage from "../components/WebPImage.vue"
import yayImage from "../assets/yay.jpg"
import yayWebpImage from "../assets/yay.webp"
</script>

<template>
  <div>
    <!-- 方式1: 自动检测并使用 WebP -->
    <WebPImage :src="yayImage" width="388" alt="图片" />

    <!-- 方式2: 手动指定 WebP 文件 -->
    <WebPImage
      :src="yayImage"
      :webp-src="yayWebpImage"
      width="388"
      alt="图片"
    />
  </div>
</template>

在线图片(使用 CDN 转换)

<template>
  <!-- 阿里云 OSS -->
  <WebPImage
    src="https://your-bucket.oss-cn-hangzhou.aliyuncs.com/image.jpg"
    cdn-params="x-oss-process=image/format,webp"
    width="500"
    alt="CDN 图片"
  />

  <!-- 腾讯云 COS -->
  <WebPImage
    src="https://your-bucket.cos.ap-shanghai.myqcloud.com/image.jpg"
    cdn-params="imageMogr2/format/webp"
    width="500"
    alt="CDN 图片"
  />
</template>

在线图片(使用代理服务)

<script setup lang="ts">
const proxyUrl = "https://your-image-proxy.com/convert"
</script>

<template>
  <WebPImage
    src="https://example.com/image.jpg"
    :proxy-url="proxyUrl"
    width="500"
    alt="在线图片"
  />
</template>

工作原理

1. 构建时转换

  • 运行 pnpm build 时,会先执行 build:images 脚本
  • 脚本扫描 src/assets 目录下的所有 JPG/PNG 图片
  • 使用 imagemin-webp 转换为 WebP 格式
  • 转换后的文件保存在同一目录

2. 运行时加载

  • 组件初始化时,使用 getWebPSupportSync() 同步获取 WebP 支持状态
  • 如果缓存不存在,默认假设支持(避免重复加载)
  • 使用 <picture> 标签,浏览器自动选择最佳格式
  • 后台异步检测,更新缓存供下次使用

3. 性能优化

  • 缓存机制:使用 localStorage 缓存检测结果
  • 避免重复加载:初始值使用同步方法获取,避免先加载原始图片再加载 WebP
  • 智能回退:使用 <picture> 标签,浏览器自动处理回退

效果对比

测试结果显示,yay.jpg (177KB) 转换为 yay.webp (23KB) 后,文件大小减少了 87.0%

注意事项

  1. 开发环境:开发时不会自动转换,需要手动运行 pnpm build:images
  2. Git 管理:建议将 .webp 文件添加到 .gitignore,因为它们可以通过构建脚本自动生成
  3. 质量调整:可在 scripts/convert-images.mjs 中调整质量参数(默认 80)
  4. 浏览器兼容性:现代浏览器都支持 WebP,组件会自动检测并回退

总结

通过以上配置,我们实现了:

  • ✅ 构建时自动转换图片为 WebP
  • ✅ 智能格式选择和自动回退
  • ✅ 性能优化,避免重复加载
  • ✅ 组件化封装,使用简单

这套方案可以显著提升页面加载速度,特别是在图片较多的场景下效果明显。希望这篇文章对你有帮助!

js入门指南之Promise:从''承诺''到理解,告别回调地域

在了解promise之前,我们需要简单了解一些关于异步的知识,方便解释promise具体做了什么,起到什么样的作用

一、异步

  1. js在设计之初时被作为浏览器脚本语言来设计,所以被设计成了单线程以节省用户的性能

  2. js代码中如果存在需要耗时执行的代码(如setTimeout()),则该代码在v8引擎执行到这时,会被暂时挂起,而优先执行后面不耗时的代码(同步代码),这就是js中异步的简单理解

3.通过一段代码直观了解异步


let a = 1
let b = 2
setTimeout(() => {

console.log(a)

},1000)
console.log(b)
   
   
   输出结果为2,一秒后再输出1

所以,如果你不了解什么是异步,那么你是否会觉得是函数在读取到第四行代码后,等待一秒后打印1再打印2?

二、如何处理异步

通过上个标题内容,你是否了解到了在v8中引擎执行的顺序? 那么,js中异步这一种情况会给我们带来什么样的问题与困境呢,不急,请看如下情形中的代码

let  a = 1

function fn(){
    setTimeout(()=>{
    a = 2
      console.log(a)    
    },1000)
   }

function foo(){
     setTimeout(()=>{
     a = 3
    console.log(a)  
    },2000)
}

function bar(){
console.log(a)
   }

foo()
fn()
bar()

此时由于异步的存在,你会发现,无论你如何调用foo(),fn(),bar(),都是以bar(),fn(),foo()这样的顺序依次打印出来,使得你无法控制他们的执行顺序,那么此时我们该如何解决这个问题呢

1、解决方法

(1)通过函数嵌套回调调用,改变执行顺序(容易形成屎山代码,不推荐)

此时你想到一个办法,我通过将下一个执行的函数调用于上一个执行函数的末尾,不就能解决异步的问题了吗,通过这个手段就可以实现执行顺序为bar(),fn(),bar()了,于是你写出了这样的代码

let  a = 1

function fn(){
    setTimeout(()=>{
    a = 2
      console.log(a)    
    },1000)
    bar()
   }

function foo(){
     setTimeout(()=>{
     a = 3
    console.log(a)
    fn()
    },2000)
}

function bar(){
console.log(a)
   }
   
   foo()
缺陷

此时你便成功实现了执行顺序为bar(),fn(),bar(),但是,这又引发了另一个问题,仔细思考,这里是三个函数的回调,

但是你要是有大量的函数需要回调呢?此时你会发现虽然代码依然可以运行,且按照了你预想的顺序执行,但是,一旦出现了bug,你便会发现一件事,那就是你只能在该回调函数中一个一个寻找错误点,使得项目代码难以阅读和维护让人十分难以解决该bug,这就是屎山代码的样子

大量函数代码的回调形成的维护困难这一情况,也称之为回调地域,我们在平时解决异步问题时,要避免造成这种情况

(2)通过使用Promise的链式调用,解决异步问题

虽然函数嵌套回调调用可以解决问题,但是带来的代码难以阅读与维护,寻找错误困难的问题也不是我们希望能看见的,所以,我们此时应用Promise这一函数来解决异步的问题

Promise的救赎

当你第一次见到Promise,你或许会想,这不是承诺的意思吗?什么是承诺?

我们继续通过代码来使得更好的理解

function gohome(){
 setTimeout(() => {
    console.log('成功回家')
 
 },4000)
     }
function  shower(){
    setTimeout(() => {
     console.log('你洗澡了')
    },1000)

   }
function gosleep(){
    setTimeout(() => {
    console.log('你上床休息了')
    },2000)

}

此时由于三个函数均要消耗时间,且根据所需消耗时间来判断,无论什么顺序调用函数,都是以shower,gosleep,gohome的顺序执行的,此时我们便需要使用Promise函数来解决这个问题了

表达式一
function gohome() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('成功回家')
            resolve()
        }, 4000)
           })
   
}

function shower() {
 return new Promise((resolve, reject) => {
    setTimeout(() => {
        console.log('你洗澡了')
        resolve()
    }, 1000)
  })
}
function gosleep() {
    setTimeout(() => {
        console.log('你上床休息了')
    }, 2000)

}

gohome().then(() => {
    shower().then(() => {
     gosleep()
    })
})

运行以上代码,你就能发现,问题解决,使得函数构成了链式的调用,且使得代码更扁平,更易管理

除了上面代码这种方法,promise还有另一种使用方式,能使代码更简洁明了,更加直观

表达式二
function gohome() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('成功回家')
            resolve()
        }, 4000)
        })
   
}

function shower() {
 return new Promise((resolve, reject) => {
    setTimeout(() => {
        console.log('你洗澡了')
        resolve()
    }, 1000)
  })
}
function gosleep() {
    setTimeout(() => {
        console.log('你上床休息了')
    }, 2000)

}

gohome().then(() => {
    return shower()
})
.then(() =>{
    gosleep()
})
详细说明
  1. 使用了Promise函数后,原函数获得一个实例对象,只有由Promise构造出的实例对象后面才能接then()

  2. Promise函数要传入一个函数,而传入的这个函数还需要接受两个参数(resolve,reject),分别需要再Promise函数中调用,用于表示成功与失败若读取到成功,则立即执行其实例对象所接到的then()内部的函数,若失败则不执行

  3. then()只能被由Promise函数创建的实例对象调用,原因是then()存放在Promise()的显示原型上,而其 实例对象的隐式原型继承了其构造函数的显示原型,then()内部要传入一个函数,当其得到Promise()中resolve()被执行的信息后,会立即执行then()内部传入的函数 (若对原型有疑问,参考前文深入浅出:理解js的‘万物皆对象’与原型链)

4.在表达式二中,该表达方法中的第一个then()会默认继承上Promise内读取到的resolve或reject

  1. 在.then中返回的值(无论是普通值还是新的Promise),都会成为下一个.then接受的参数,所以为了在使用表达式二中,避免后续的then全部默认接受同一个参数,记得为每一个then添加一个返回值,避免出现‘‘漂浮的Promise’’

深入 Next.js 源码:Image 组件 Color Placeholder 实现原理

深入 Next.js 源码:Image 组件 Color Placeholder 实现原理

本文将深入 Next.js 源码,解析 Image 组件是如何用一个仅 35 字节的 1x1 像素 GIF,生成全尺寸模糊占位符的。

一、引言

在现代 Web 应用中,图片加载体验直接影响用户的第一印象。当用户打开页面时,如果看到的是空白区域或者布局跳动,体验会大打折扣。

Next.js 的 Image 组件提供了 placeholder="blur" 功能,可以在图片加载完成前显示一个模糊的占位符。对于静态导入的图片,Next.js 会在构建时自动生成 blurDataURL;而对于动态图片,我们可以手动提供一个纯色占位符。这张gif是3G网络状态下录屏效果。

Adobe Express - 4c9a1e5b574e99f432a22cfd68db2fcd.gif

二、使用示例

让我们先看官方examples的 Color Placeholder 源码位置examples/image-component/app/color/page.tsx

// app/color/page.tsx
import Image from "next/image";

// 生成 1x1 像素 GIF 的 base64 编码
const keyStr =
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

const triplet = (e1: number, e2: number, e3: number) =>
  keyStr.charAt(e1 >> 2) +
  keyStr.charAt(((e1 & 3) << 4) | (e2 >> 4)) +
  keyStr.charAt(((e2 & 15) << 2) | (e3 >> 6)) +
  keyStr.charAt(e3 & 63);

const rgbDataURL = (r: number, g: number, b: number) =>
  `data:image/gif;base64,R0lGODlhAQABAPAA${
    triplet(0, r, g) + triplet(b, 255, 255)
  }/yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==`;

export default function Color() {
  return (
    <Image
      alt="Dog"
      src="/dog.jpg"
      placeholder="blur"
      blurDataURL={rgbDataURL(237, 181, 6)}  // 橙黄色
      width={750}
      height={1000}
    />
  );
}

这段代码做了两件事:

  1. rgbDataURL 函数:根据 RGB 值生成一个 1x1 像素的 GIF Data URL
  2. Image 组件:通过 placeholder="blur"blurDataURL 属性,在图片加载前显示该颜色的模糊占位符

运行后,你会看到图片加载前先显示一个橙黄色的模糊背景,加载完成后平滑过渡到真实图片, 就像上面的gif一样。

三、1x1 像素 GIF 原理

3.1 为什么选择 GIF?

在所有图片格式中,GIF 是生成单像素图片最小的选择:

格式 1x1 像素大小 特点
GIF ~35 bytes 最小,支持透明
PNG ~67 bytes 较大,无损压缩
JPEG ~134 bytes 最大,有损压缩

3.2 GIF 文件结构

一个最小的 GIF 文件结构如下:

GIF89a           // 文件头 (6 bytes)
[宽度][高度]      // 逻辑屏幕描述符 (7 bytes)
[全局颜色表]      // 包含我们的颜色 (3 bytes)
[图像描述符]      // 图像块 (10 bytes)
[图像数据]        // LZW 压缩数据 (若干 bytes)
GIF 结束符        // (1 byte)

四、源码分析

Next.js Image 组件的源码位于 vercel/next.js 仓库。我们按照调用链自顶向下分析。

4.1 Image 组件入口

源码位置packages/next/src/client/image-component.tsx

'use client'

import { useState, useCallback, forwardRef } from 'react'
import { getImgProps } from '../shared/lib/get-img-props'

const Image = forwardRef((props, forwardedRef) => {
  // 1. 状态管理:占位符是否完成
  const [blurComplete, setBlurComplete] = useState(false)
  
  // 2. 调用 getImgProps 生成 <img> 的所有属性
  const { props: imgAttributes, meta: imgMeta } = getImgProps(props, {
    defaultLoader,
    imgConf: config,
    blurComplete,  // 传入当前状态
    showAltText
  })

  // 3. 渲染 img 元素
  return (
    <img
      {...imgAttributes}
      onLoad={(event) => {
        // 4. 图片加载完成后,移除占位符
        handleLoading(event.currentTarget, placeholder, setBlurComplete)
      }}
      onError={() => {
        setBlurComplete(true)  // 加载失败也移除占位符
      }}
    />
  )
})

关键点

  • 'use client' 标记:这是一个客户端组件,但支持 SSR(getImgProps 在服务端也能执行)
  • blurComplete 状态:控制占位符的显示/隐藏
  • 图片加载完成后调用 setBlurComplete(true),触发重渲染移除占位符

4.2 图片加载处理

function handleLoading(
  img: HTMLImageElement,
  placeholder: string,
  setBlurComplete: (v: boolean) => void,
) {
  // 等待图片解码完成,避免闪烁
  const p = 'decode' in img ? img.decode() : Promise.resolve()
  
  p.catch(() => {}).then(() => {
    if (placeholder !== 'empty') {
      setBlurComplete(true)  // 触发重渲染
    }
  })
}

为什么要 img.decode()

直接在 onLoad 中移除占位符可能导致闪烁——图片数据虽然下载完成,但浏览器还没解码成像素。img.decode() 确保解码完成后再切换,过渡更平滑。

4.3 属性生成逻辑

源码位置packages/next/src/shared/lib/get-img-props.ts

import { getImageBlurSvg } from './image-blur-svg'

export function getImgProps(props, _state) {
  const { blurComplete } = _state
  
  // 核心:根据状态决定是否生成占位符背景
  const backgroundImage = !blurComplete && placeholder !== 'empty' 
    ? placeholder === 'blur' 
      // 调用 getImageBlurSvg 生成 SVG 模糊滤镜
      ? `url("data:image/svg+xml;charset=utf-8,${getImageBlurSvg({
          widthInt,
          heightInt,
          blurWidth,
          blurHeight,
          blurDataURL: blurDataURL || '',
          objectFit: imgStyle.objectFit
        })}")`
      : `url("${placeholder}")`
    : null;

  // 生成占位符样式
  let placeholderStyle = backgroundImage ? {
    backgroundSize,
    backgroundPosition: imgStyle.objectPosition || '50% 50%',
    backgroundRepeat: 'no-repeat',
    backgroundImage
  } : {};

  return {
    props: {
      style: {
        ...imgStyle,
        ...placeholderStyle,
        color: 'transparent'  // 隐藏 alt 文字
      },
      src: imgAttributes.src,
      srcSet: imgAttributes.srcSet,
      // ...
    }
  }
}

逻辑流程

  1. blurComplete === falseplaceholder === 'blur' → 生成占位符
  2. 调用 getImageBlurSvg() 生成 SVG Data URL
  3. 将 SVG 作为 background-image 设置到 <img> 元素
  4. 图片加载完成后 blurComplete 变为 true,重渲染时 backgroundImagenull

4.4 SVG 模糊滤镜生成

源码位置packages/next/src/shared/lib/image-blur-svg.ts

export function getImageBlurSvg({
  widthInt,
  heightInt,
  blurWidth,
  blurHeight,
  blurDataURL,
  objectFit,
}: {
  widthInt?: number
  heightInt?: number
  blurWidth?: number
  blurHeight?: number
  blurDataURL: string
  objectFit?: string
}): string {
  const std = 20  // 高斯模糊标准差
  
  // 关键:放大 40 倍,为模糊提供足够空间
  const svgWidth = blurWidth ? blurWidth * 40 : widthInt
  const svgHeight = blurHeight ? blurHeight * 40 : heightInt

  const viewBox = svgWidth && svgHeight 
    ? `viewBox='0 0 ${svgWidth} ${svgHeight}'` 
    : ''

  // SVG 滤镜管道
  return `%3Csvg xmlns='http://www.w3.org/2000/svg' ${viewBox}%3E
    %3Cfilter id='b' color-interpolation-filters='sRGB'%3E
      %3CfeGaussianBlur stdDeviation='${std}'/%3E
      %3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E
      %3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E
      %3CfeComposite operator='out' in='s'/%3E
      %3CfeComposite in2='SourceGraphic'/%3E
      %3CfeGaussianBlur stdDeviation='${std}'/%3E
    %3C/filter%3E
    %3Cimage width='100%25' height='100%25' x='0' y='0' 
      preserveAspectRatio='none' 
      style='filter: url(%23b);' 
      href='${blurDataURL}'/%3E
  %3C/svg%3E`
}

SVG 滤镜管道说明

滤镜 作用
feGaussianBlur (第一次) 模糊扩散颜色,但边缘会变成半透明
feColorMatrix 处理 Alpha 通道,消除半透明边缘
feFlood + feComposite 用纯色填充被裁剪的边缘区域
feGaussianBlur (第二次) 再次模糊,让填充后的边缘更自然

为什么不能直接放大?

你可能会问:SVG 本身就支持无损缩放,为什么还要加这么多滤镜?

答案是:这套机制不只是为纯色 GIF 设计的,而是要兼容真实图片缩略图。 用以下对比即可看出区别 场景一:1×1 纯色 GIF

场景二:8×8 真实缩略图

五、设计决策

5.1 为什么放大 40 倍?

const svgWidth = blurWidth ? blurWidth * 40 : widthInt

高斯模糊的有效范围约为 3σ(3 倍标准差)。当 stdDeviation = 20 时,模糊会影响周围约 60 像素。如果 viewBox 太小,模糊会"溢出"边界导致边缘被截断。放大 40 倍确保 1 像素的图片也有足够空间让模糊效果自然过渡。

5.2 为什么用 SVG 而非 CSS blur?

对比项 CSS blur SVG 滤镜
边缘处理 模糊会溢出容器 完美控制
额外 DOM 需要包裹元素 无需额外元素
定制性 有限 可组合多个滤镜原语

5.3 性能考量

  • Data URL 大小:完整 SVG 约 500-600 bytes,内联在 HTML 中
  • 零网络请求:占位符随 HTML 一起到达,首屏即可渲染
  • GPU 加速:SVG 滤镜由浏览器 GPU 渲染
  • 自动清理:图片加载后背景样式被移除,不占用额外内存

参考资料

深入浅出AI流式输出:从原理到Vue实战实现

深入浅出AI流式输出:从原理到Vue实战实现


在当前大模型(LLM)广泛应用的背景下,用户对“响应速度”的感知越来越敏感。当我们向AI提问时,传统“等待完整结果返回”的模式常常带来数秒甚至更久的空白界面——虽然实际网络请求可能只花了1秒,但用户的焦虑感却成倍放大。

流式输出(Streaming Output) 正是破解这一痛点的关键技术。它让AI像“边想边说”一样,把生成的内容逐字推送出来,极大提升了交互的流畅性与真实感。

本文将以 Vue 3 + Vite 为例,手把手带你实现一个完整的 AI 流式对话功能,并深入剖析底层原理,助你在自己的项目中快速落地。


一、为什么需要流式输出?对比两种交互模式

❌ 传统模式:全量响应 → 用户体验差

// 非流式请求
const response = await fetch('/api/llm', { ... });
const data = await response.json();
content.value = data.content; // 一次性赋值
  • ✅ 实现简单
  • ❌ 用户需等待全部内容生成完毕
  • ❌ 长文本场景下容易出现“卡死”错觉
  • ❌ 视觉反馈延迟高,降低信任感

✅ 流式模式:增量返回 → 用户体验飞跃

// 流式请求处理
const reader = response.body.getReader();
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  const text = decoder.decode(value);
  content.value += parseChunk(text); // 实时拼接
}
  • ✅ 1~2秒内即可看到首个字符
  • ✅ 文字“打字机”效果增强沉浸感
  • ✅ 客户端可边接收边渲染,资源利用率更高
  • ✅ 更符合人类对话节奏,提升产品质感

📌 关键洞察:用户并不关心“是否真的快了”,而是关心“有没有动静”。流式输出的本质是用即时反馈对抗等待焦虑


二、技术基石:HTTP 分块传输与浏览器流 API

2.1 协议层支持:Transfer-Encoding: chunked

流式输出依赖于 HTTP/1.1 的 分块传输编码(Chunked Transfer Encoding)

  • 服务器无需知道总长度,可以一边生成数据一边发送
  • 数据被分割为多个“块”(chunk),每个块独立传输
  • 响应头中包含 Transfer-Encoding: chunked
  • 最终以一个空块(0\r\n\r\n)表示结束

这是实现流式响应的基础协议机制。

2.2 前端核心 API:ReadableStream 与 TextDecoder

现代浏览器提供了强大的原生流处理能力:

API 作用
response.body 返回一个 ReadableStream<Uint8Array>
getReader() 获取流读取器,用于逐块读取
TextDecoder 将二进制数据解码为字符串(UTF-8)

这些 API 不需要额外安装库,开箱即用,非常适合轻量级集成。


三、Vue 实战:一步步构建 AI 流式对话系统

我们使用 Vue 3 + Vite 构建一个极简的 Demo,接入 DeepSeek API 实现流式问答。

3.1 初始化项目

npm create vue@latest stream-demo
cd stream-demo
npm install

选择默认配置即可,确保启用 Vue 3 和 JavaScript 支持。

3.2 创建 .env 文件(安全存储密钥)

# .env
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxx

⚠️ 注意:不要提交 .env 到 Git!添加到 .gitignore


3.3 核心逻辑:App.vue

(1)响应式状态定义
<script setup>
import { ref } from 'vue'

// 用户输入的问题
const question = ref('讲一个喜羊羊和灰太狼的故事,20字')

// 是否启用流式输出
const stream = ref(true)

// 存储并展示模型返回内容
const content = ref('')
</script>

利用 ref 实现响应式更新,每次 content.value += delta 都会触发视图重绘。


(2)发送请求 & 处理流式响应
const askLLM = async () => {
  if (!question.value.trim()) {
    alert('请输入问题')
    return
  }

  // 显示加载提示
  content.value = '🧠 思考中...'

  const endpoint = 'https://api.deepseek.com/chat/completions'
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  }

  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        model: 'deepseek-chat',
        stream: stream.value,
        messages: [
          { role: 'user', content: question.value }
        ]
      })
    })

    if (!response.ok) {
      throw new Error(`请求失败:${response.status}`)
    }

    // 区分流式 / 非流式
    if (stream.value) {
      await handleStreamResponse(response)
    } else {
      const data = await response.json()
      content.value = data.choices[0].message.content
    }
  } catch (error) {
    content.value = `❌ 请求失败:${error.message}`
    console.error(error)
  }
}

(3)流式处理核心函数
async function handleStreamResponse(response) {
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer = '' // 缓存不完整 JSON 片段
  let done = false

  while (!done) {
    const { value, done: readerDone } = await reader.read()
    done = readerDone

    // 解码二进制数据
    const chunk = buffer + decoder.decode(value, { stream: true })
    buffer = ''

    // 按行处理 SSE 格式数据
    const lines = chunk.split('\n').filter(line => line.startsWith('data: '))

    for (const line of lines) {
      const raw = line.slice(6).trim() // 去除 "data: "

      if (raw === '[DONE]') {
        done = true
        break
      }

      try {
        const json = JSON.parse(raw)
        const delta = json.choices[0]?.delta?.content
        if (delta) {
          content.value += delta // 实时追加
        }
      } catch (e) {
        // 可能是不完整的 JSON,缓存起来等下一帧
        buffer = 'data:' + raw
      }
    }
  }

  reader.releaseLock()
}

🔍 重点说明:

  • decoder.decode(value, { stream: true }):启用流式解码,防止多字节字符被截断
  • buffer 缓存机制:解决因 TCP 分包导致的 JSON 被拆分问题
  • slice(6) 提取有效数据,过滤 data: 前缀
  • JSON.parsetry-catch 是必须的,避免解析失败中断整个流程

3.4 模板结构:简洁直观的 UI

<template>
  <div class="container">
    <!-- 输入区 -->
    <div class="input-group">
      <label>问题:</label>
      <input v-model="question" placeholder="请输入你想问的问题..." />
      <button @click="askLLM">发送</button>
    </div>

    <!-- 控制开关 -->
    <div class="control">
      <label>
        <input type="checkbox" v-model="stream" />
        启用流式输出
      </label>
    </div>

    <!-- 输出区 -->
    <div class="output-box">
      <h3>AI 回答:</h3>
      <p>{{ content }}</p>
    </div>
  </div>
</template>

3.5 样式美化(可选)

<style scoped>
.container {
  max-width: 800px;
  margin: 40px auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.input-group {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-bottom: 20px;
}

input[type="text"] {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
}

button {
  padding: 10px 20px;
  background: #007aff;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}

button:hover {
  background: #005edc;
}

.control {
  margin: 16px 0;
  font-size: 14px;
}

.output-box {
  border: 1px solid #eee;
  padding: 20px;
  border-radius: 8px;
  min-height: 200px;
  background-color: #f9f9fb;
  white-space: pre-wrap;
  word-wrap: break-word;
}

.output-box p {
  line-height: 1.6;
  color: #333;
}
</style>

四、常见问题与优化策略

✅ Q1:为什么有时会出现乱码或解析错误?

原因:网络传输过程中,一个完整的 JSON 字符串可能被分成两个 Uint8Array 发送,导致单次 decode 得到的是半截字符串。

解决方案

  • 使用 buffer 缓存未完成的数据
  • 在下次读取时拼接后重新尝试解析
  • 设置 stream: true 参数给 TextDecoder.decode()

✅ Q2:如何防止频繁 DOM 更新影响性能?

虽然 Vue 的响应式系统很高效,但高频字符串拼接仍可能导致重排。

优化建议

// 使用 requestAnimationFrame 限制渲染频率
let pending = false
function scheduleUpdate(delta) {
  if (!pending) {
    requestAnimationFrame(() => {
      content.value += delta
      pending = false
    })
  }
}

适用于超高速输出场景(如代码生成)。


✅ Q3:如何支持取消请求?

引入 AbortController 即可:

const controller = new AbortController()

// 在 fetch 中传入 signal
const response = await fetch(url, {
  signal: controller.signal,
  // ...
})

// 提供取消按钮
const cancelRequest = () => controller.abort()

五、应用场景拓展

流式输出不仅限于 LLM 对话,还可用于:

场景 应用示例
🤖 聊天机器人 实时对话、客服系统
📄 文档生成 报告、合同、文案自动生成
💾 文件上传下载 显示进度条
📊 日志监控 实时日志流展示
🧮 数据分析 大批量计算结果逐步呈现

只要涉及“长时间任务 + 渐进式结果”,都可以考虑使用流式思想优化体验。


六、总结:掌握流式输出,让你的 AI 应用脱颖而出

流式输出不是炫技,而是一种以人为本的设计思维。它把“等待”变成了“参与”,让用户感受到系统的“思考过程”,从而建立更强的信任感。

通过本文的学习,你应该已经掌握了:

✅ 如何发起带 stream=true 的 LLM 请求
✅ 如何使用 ReadableStream 处理分块数据
✅ 如何用 TextDecoder 安全解析二进制流
✅ 如何在 Vue 中实现实时渲染
✅ 如何应对流式中的边界情况(如 JSON 截断)

❤️ 写在最后

随着 AIGC 的普及,前端工程师的角色正在从“页面搭建者”转向“智能交互设计师”。掌握流式输出这类核心技术,不仅能做出更好用的产品,也能在未来的技术浪潮中占据先机。

动手是最好的学习方式。 打开编辑器,运行一遍这个 Demo,亲自感受那种“文字跃然屏上”的丝滑体验吧!

JavaScript流式输出技术详解与实践

JavaScript流式输出技术详解与实践

流式输出是现代Web开发中提升用户体验的关键技术,它允许服务器在生成内容的同时,将已生成的部分立即发送给客户端,而非等待完整内容生成后一次性传输。这种"边生成边返回"的模式显著降低了用户等待时间,提供了即时反馈,特别适用于AI对话、实时日志监控等场景。本学习笔记将深入解析JavaScript流式输出的技术原理、实现方法及应用场景,帮助开发者掌握这一重要技术。

一、流式输出概念与技术优势

流式输出,也称为流式传输,是指服务器持续地将数据推送到客户端,而不是一次性发送完毕 。这种模式下,连接一旦建立,服务器就能实时地发送更新给客户端。与传统的一次性加载方式相比,流式输出在用户体验和性能方面具有显著优势

从技术角度看,流式输出的核心优势体现在三个方面:首先,它提供了低延迟特性,允许客户端在数据到达时立即渲染,无需等待完整响应 ;其次,它实现了资源高效利用,通过逐块处理数据,大幅减少内存占用,特别适合处理大规模数据集 ;最后,它支持渐进式内容渲染,用户可以立即看到部分内容,提升交互流畅度 。

具体对比传统一次性加载方式,流式输出在内存占用、首屏时间和适用场景上均有明显优势。传统方式需要一次性加载全部数据到内存,导致高内存占用;而流式输出逐块处理数据,内存占用低 。传统方式首屏时间长,用户需要等待完整内容加载;而流式输出首屏时间极短,内容可边生成边渲染 。适用场景方面,传统方式适合小数据量静态内容;而流式输出特别适合实时数据/大数据量场景,如AI对话、实时日志等 。

二、JavaScript二进制数据处理基础

在实现流式输出时,JavaScript提供了多种处理二进制数据的API,其中ArrayBuffer、Uint8Array和TextEncoder/TextDecoder是核心工具。理解这些API的工作原理对于实现高效的流式输出至关重要

ArrayBuffer是JavaScript中用于表示通用的、固定长度的原始二进制数据缓冲区的数据类型 。它本身是一个脱离实际数据的容器,提供了一种机制来表示固定大小的连续内存块 。ArrayBuffer对象不能直接操作数据,而是创建一个视图(如TypedArray或DataView)来读取和处理数据 。ArrayBuffer的创建方式如下:

// 创建一个12字节的ArrayBuffer
const buffer = new ArrayBuffer(12);
console.log(buffer byteLength); // 输出:12

Uint8Array是JavaScript中的一种类型化数组,用于表示一个8位无符号整型数组 。它提供了一种高效的方式来处理二进制数据,比常规的JavaScript数组更加高效 。Uint8Array可以像普通数组一样进行操作,同时还支持一些额外的方法,如set()、subarray()和slice()等 :

// 从ArrayBuffer创建Uint8Array视图
const view = new Uint8Array(buffer);
console.log(view); // 输出:Uint8Array(12) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

// 填充数据
for (let i = 0; i < view.length; i++) {
    view[i] = i * 2;
}
console.log(view); // 输出:Uint8Array(12) [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22]

TextEncoder和TextDecoder是JavaScript中用于处理文本与二进制数据转换的接口 。TextEncoder负责将文本编码为二进制数据,而TextDecoder则负责将二进制数据解码为文本 :

// 创建编码器
const encoder = new TextEncoder();
console.log(encoder); // 输出:TextEncoder {}

// 编码文本为二进制
const myBuffer = encoder.encode('你好 HTML5');
console.log(myBuffer); // 输出:Uint8Array(10) [228, 184, 160, 203, 195, 185, 72, 76, 77, 53]

// 创建解码器
const decoder = new TextDecoder();

// 解码二进制为文本
const originalText = decoder.decode(myBuffer);
console.log(originalText); // 输出:你好 HTML5

这些二进制处理API在流式输出中扮演关键角色,因为服务器通常以二进制流的形式发送数据,客户端需要将其转换为可读文本。例如,在AI对话场景中,服务器可能以二进制分块形式发送生成的文本,客户端需要通过TextDecoder逐块解码并拼接,最终呈现给用户。

三、前端实现流式输出的三种主要技术方案

JavaScript前端实现流式输出主要有三种技术方案:Server-Sent Events (SSE)、WebSocket和Fetch API+ readableStream。每种技术方案都有其适用场景和实现特点,开发者应根据具体需求选择合适的技术

1. Server-Sent Events (SSE)

SSE是一种允许服务器通过HTTP向客户端推送实时更新的技术,它是WebSockets的一种更简单的替代方案,专门用于单向的服务器到客户端通信 。SSE的实现基于EventSource API和新的"事件流"数据格式(text/event-stream) 。

在JavaScript中,可以通过以下代码实现SSE流式输出:

// 创建EventSource对象
const eventSource = new EventSource('/stream');

// 处理接收到的消息
eventSource.addEventListener('message', function(event) {
    // 解析数据
    const data = JSON.parse(event.data);

    // 将数据添加到页面
    const outputDiv = document.getElementById('output');
    outputDiv.innerHTML += data.content + '<br>';
});

// 错误处理
eventSource.addEventListener('error', function(error) {
    console.error('SSE连接错误:', error);
});

// 连接关闭处理
eventSource.addEventListener('close', function() {
    console.log('SSE连接已关闭');
});

SSE特别适合服务器向客户端单向推送事件的场景,如实时日志监控、新闻推送等。它实现简单,兼容性好,但仅支持单向通信,且无法发送二进制数据(需编码为Base64) 。

2. WebSocket

WebSocket是一种在单个TCP连接上进行全双工通讯的协议,它允许客户端和服务器之间建立持久的连接,双方可以随时发送和接收数据 。WebSocket适合需要双向实时交互的场景,如AI对话、在线协作编辑等

实现WebSocket流式输出的JavaScript代码如下:

// 创建WebSocket对象
const ws = new WebSocket('ws://localhost:8080/chat');

// 连接建立成功
ws.addEventListener('open', function() {
    console.log('WebSocket连接已建立');
    // 发送初始消息
    ws.send(JSON.stringify({ type: 'login', userId: '123' }));
});

// 接收消息
ws.addEventListener('message', function(event) {
    // 解析数据
    const data = JSON.parse(event.data);

    // 根据消息类型处理
    switch(data.type) {
        case 'chunk':
            // 处理分块文本
            const outputDiv = document.getElementById('output');
            outputDiv.innerHTML += data.content + '<br>';
            break;
        case 'completion':
            // 处理完成信号
            console.log('响应已完成');
            break;
        case 'error':
            // 处理错误
            console.error('流式响应报错:', data.message);
            break;
    }
});

// 连接错误
ws.addEventListener('error', function(error) {
    console.error('WebSocket错误:', error);
});

// 连接关闭
ws.addEventListener('close', function() {
    console.log('WebSocket连接已关闭');
});

WebSocket相比SSE具有全双工通信、低延迟、支持二进制数据传输等优势,但实现复杂度更高,需要处理更多连接状态和消息类型。

3. Fetch API + ReadableStream

Fetch API的流式处理是一种利用浏览器原生API逐块读取和处理数据的方式。它特别适合需要精确控制数据流处理的场景,如文件下载、多媒体处理等

实现Fetch流式输出的JavaScript代码如下:

async function fetchStream(url) {
    const response = await fetch(url);
    const reader = response.body.getReader();

    // 创建文本解码器
    const textDecoder = new TextDecoder();

    while (true) {
        // 读取数据块
        const { done, value } = await reader.read();

        // 如果流已结束
        if (done) break;

        // 将字节转换为文本
        const chunkText = textDecoder.decode(value);

        // 处理文本块
        const outputDiv = document.getElementById('output');
        outputDiv.innerHTML += chunkText + '<br>';
    }
}

// 调用流式获取函数
fetchStream('/api/stream');

Fetch API + ReadableStream提供了最大的灵活性,允许开发者精确控制数据块的读取和处理过程。它支持流式传输各种类型的数据,包括文本、二进制和JSON等,适合需要精细处理数据的场景。

四、流式输出在实际应用中的场景与性能优化策略

流式输出技术已在多种实际场景中得到广泛应用,主要包括AI对话、实时日志监控、在线协作编辑和数据仪表盘等。针对不同场景,需要采取相应的性能优化策略,确保流式输出的高效稳定

1. AI对话场景

在AI对话应用中,流式输出允许模型在生成文本的同时,将已生成的部分立即发送给客户端,实现"打字机"效果,显著提升用户体验 。以下是AI对话场景的典型实现:

// 建立SSE连接
let eventSource = null;
const abortController = new AbortController();

// 开始对话
async function startChat() {
    // 清空之前的连接
    if (eventSource) {
        eventSource.close();
    }

    // 创建新连接
    eventSource = new EventSource('/api/chat', {
        signal: abortController.signal
    });

    // 处理消息
    eventSource.addEventListener('message', function(event) {
        const data = JSON.parse(event.data);

        // 处理分块文本
        if (data.type === 'chunk') {
            const outputDiv = document.getElementById('output');
            outputDiv.innerHTML += data.content;
        }

        // 处理完成信号
        if (data.type === 'completion') {
            console.log('对话已完成');
        }

        // 自动滚动到底部
        window.scrollTo(0, document.body.scrollHeight);
    });

    // 错误处理
    eventSource.addEventListener('error', function(error) {
        console.error('对话连接错误:', error);
        // 可能需要重新建立连接
    });

    // 连接关闭
    eventSource.addEventListener('close', function() {
        console.log('对话连接已关闭');
    });
}

// 停止对话
function stopChat() {
    if (eventSource) {
        eventSource.close();
    }

    if (abortController) {
        abortController.abort();
    }
}

AI对话场景的优化策略包括首字节快速返回、动态批处理和反压控制。首字节快速返回(FCP)可降低初始响应时间,让用户立即看到对话开始;动态批处理可提高服务器端的生成效率,减少资源浪费;反压控制则可根据网络状况动态调整服务器的生成速度,避免客户端数据积压 。

2. 实时日志监控场景

实时日志监控是流式输出的另一重要应用场景,它允许开发者实时查看服务器生成的日志信息。以下是实时日志监控的典型实现:

// 创建WebSocket连接
const ws = new WebSocket('ws://localhost:8080/logs');

// 连接建立
ws.addEventListener('open', function() {
    console.log('日志连接已建立');
    // 可能需要发送订阅请求
    ws.send(JSON.stringify({ type: 'subscribe', logs: ['app', 'db'] }));
});

// 接收日志
ws.addEventListener('message', function(event) {
    // 解析日志数据
    const log = JSON.parse(event.data);

    // 根据日志类型渲染
    const logDiv = document.getElementById('logs');
    const logElement = document.createElement('div');
    logElement.className = `log ${log.level}`;
    logElement.textContent = `[${new Date(log.timestamp).toLocaleTimeString()}] ${log.message}`;
    logDiv.appendChild(logElement);

    // 自动滚动
    window.scrollTo(0, document.body.scrollHeight);
});

// 心跳检测
setInterval(() => {
    if (ws readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: 'ping' }));
    }
}, 30000); // 每30秒发送一次心跳

实时日志监控场景的优化策略包括心跳检测、缓冲区管理和消息过滤。心跳检测可确保连接的稳定性,即使在网络不稳定的情况下也能及时恢复 ;缓冲区管理可控制内存使用,避免大量日志导致内存溢出;消息过滤则可减少不必要的数据传输,提升系统性能。

3. 性能优化通用策略

无论哪种流式输出技术,都需要考虑以下通用优化策略:

缓冲区管理:通过合理设置缓冲区大小(如highWaterMark参数),平衡内存占用和吞吐量 。过小的缓冲区会增加I/O开销,影响性能;过大的缓冲区则会增加内存压力,可能导致内存溢出。

// 使用高水位线控制内存
const readableStream = response.body
    . pipeThrough(new TransformStream({
        transform: function(chunk,控制器) {
            // 处理数据块
           控制器控制器.write(chunk);
        }
    }), { highWaterMark: 16 });

错误处理与重连:实现自动重连机制,确保在网络中断后能够快速恢复 。对于SSE,可以监听error事件并尝试重新连接;对于WebSocket,则可以实现心跳检测机制,定期检查连接状态 。

// SSE自动重连
let reconnectionInterval = 3000; // 初始重连间隔3秒
let reconnectionCount = 0;

// SSE错误处理
eventSource.addEventListener('error', function(error) {
    if (eventSource readyState === EventSource.CLOSED) {
        console.error('SSE连接已关闭,尝试重新连接...');

        // 指数退避重连
        setTimeout(() => {
            // 创建新连接
            eventSource = new EventSource('/api/stream', {
                withCredentials: true
            });

            // 重置重连计数器
            reconnectionCount = 0;
            reconnectionInterval = 3000;
        }, reconnectionInterval);

        // 增加重连间隔
        reconnectionInterval *= 2;
        reconnectionCount++;
    }
});

分块策略优化:根据应用场景调整数据块的大小和发送频率,平衡传输效率和用户体验。过小的数据块会增加传输开销,降低效率;过大的数据块则会增加延迟,影响用户体验。

客户端渲染优化:采用虚拟滚动、节流渲染等技术减少DOM操作开销,提升渲染性能。对于大量数据的场景,虚拟滚动只渲染可视区域内的内容,大幅减少内存占用;节流渲染则限制渲染频率,避免频繁的DOM更新。

五、流式输出的实现流程与最佳实践

实现流式输出需要前后端协同工作,建立完整的流式通信流程,从连接建立、数据传输到渲染呈现。以下是流式输出的典型实现流程:

1. 连接建立

无论使用哪种技术,首先需要建立客户端与服务器之间的连接。对于SSE,使用EventSource对象;对于WebSocket,使用WebSocket对象;对于Fetch API,则需要发送请求并获取响应体的Reader 。

// SSE连接建立
const eventSource = new EventSource('/api/stream');

// WebSocket连接建立
const ws = new WebSocket('ws://localhost:8080/stream');

// Fetch API连接建立
const response = await fetch('/api/stream', {
    headers: {
        'Content-Type': 'application/json'
    },
    method: 'POST'
});
const reader = response.body.getReader();

2. 数据传输

连接建立后,服务器可以开始持续推送数据。数据通常以分块形式传输,每块包含部分生成内容。服务器需要确保数据分块的大小和频率合理,以平衡传输效率和用户体验。

// SSE数据传输(服务器端伪代码)
res.writeHeader('Content-Type', 'text/event-stream');
res.writeHeader('Cache-Control', 'no-cache');
res.writeHeader('Connection', 'keep-alive');

// 每生成一个token就推送
while (!isGenerationComplete()) {
    const chunk = generateNextChunk();
    res.write(`data: ${JSON.stringify(chunk)}
`); // 注意末尾的空行
}
res.end();

// WebSocket数据传输(服务器端伪代码)
ws.send(JSON.stringify({ type: 'chunk', content: '这是第一部分内容' }));
ws.send(JSON.stringify({ type: 'chunk', content: '这是第二部分内容' }));
ws.send(JSON.stringify({ type: 'completion' }));

3. 数据处理与渲染

客户端接收到数据块后,需要进行解码和渲染。对于二进制数据,使用TextDecoder解码;对于文本数据,直接解析并渲染。渲染过程中需要考虑性能优化,避免频繁的DOM操作。

// SSE数据处理
eventSource.addEventListener('message', function(event) {
    // 解析数据
    const data = JSON.parse(event.data);

    // 处理分块内容
    if (data.type === 'chunk') {
        // 使用TextDecoder处理二进制数据
        if (data.content instanceof Uint8Array) {
            const decodedText = new TextDecoder().decode(data.content);
            appendToDOM(decodedText);
        } else {
            appendToDOM(data.content);
        }
    }
});

// WebSocket数据处理
ws.addEventListener('message', function(event) {
    // 解析数据
    const data = JSON.parse(event.data);

    // 处理分块内容
    if (data.type === 'chunk') {
        // 处理二进制分块
        if (data.content instanceof Uint8Array) {
            const decodedText = new TextDecoder().decode(data.content);
            appendToDOM(decodedText);
        } else {
            // 处理文本分块
            appendToDOM(data.content);
        }
    }
});

// Fetch API数据处理
while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    // 处理二进制数据
    const chunkText = textDecoder.decode(value);
    appendToDOM(chunkText);
}

4. 连接关闭与清理

流式输出完成后,需要正确关闭连接并清理资源,避免内存泄漏。对于SSE,使用close()方法关闭连接;对于WebSocket,同样使用close()方法;对于Fetch API,则不需要特别处理,连接会在流结束后自动关闭

// SSE连接关闭
eventSource.close();

// WebSocket连接关闭
ws.close();

//Fetch API自动关闭
// 无需特别处理,流结束后连接会自动关闭

六、流式输出的未来发展趋势

随着Web技术的不断发展,流式输出技术也在持续演进。未来,流式输出将更加智能化、高效化和标准化,为开发者提供更便捷的实现方式。

首先,Web Components的普及将使得流式输出组件更容易复用和标准化。开发者可以创建自定义的流式输出元素,封装连接管理、数据处理和渲染逻辑,简化应用开发。

其次,WebAssembly的成熟将为流式输出提供更高效的二进制处理能力。通过WebAssembly,可以在浏览器中运行高性能的二进制处理算法,减少JavaScript的性能开销。

最后,新的流式通信协议的出现将为流式输出提供更丰富的功能和更好的性能。例如,HTTP/3的推广使用将大幅降低网络延迟,提升流式输出的实时性;而新的事件流格式可能支持更复杂的数据结构和更灵活的消息类型。

七、总结与建议

流式输出技术是提升Web应用用户体验的重要手段,它通过"边生成边返回"的模式,显著降低了用户等待时间,提供了即时反馈 。掌握JavaScript流式输出技术,需要理解二进制数据处理基础,熟悉SSE、WebSocket和Fetch API三种实现方案,并根据具体应用场景选择合适的优化策略

对于开发者来说,建议从以下方面入手:

  1. 根据应用场景选择合适的技术:如果仅需单向推送,SSE是最佳选择;如果需要双向实时交互,WebSocket更为适合;如果需要精细控制数据流处理过程,Fetch API+ readableStream则是理想选择。

  2. 优化二进制数据处理:合理使用ArrayBuffer、Uint8Array和TextEncoder/TextDecoder,减少内存占用和解码开销。对于大规模数据,考虑使用缓冲区管理策略,避免内存溢出。

  3. 实现可靠的连接管理:为SSE和WebSocket实现自动重连机制,确保在网络中断后能够快速恢复。对于WebSocket,定期发送心跳包检测连接状态,避免长时间无响应导致的连接关闭。

  4. 优化客户端渲染性能:采用虚拟滚动、节流渲染等技术减少DOM操作开销,提升渲染性能。对于高频更新的场景,考虑使用Web Workers进行数据处理,避免阻塞主线程。

  5. 关注未来技术发展:持续关注Web新技术的发展,如WebAssembly、HTTP/3等,为流式输出提供更高效的实现方式。

流式输出技术是现代Web应用的重要组成部分,随着AI和实时交互需求的增加,其重要性将进一步提升。掌握这一技术,将帮助开发者创建更流畅、更高效的Web应用,为用户提供更好的用户体验。

Unity3D学习笔记-跑酷练习2

⚙️ 五、高级功能与模块化 (29-31 步)

29. 实现空中控制 (AirControl.cs)

为了让 Level 2 更有挑战性,我们通过 组件化 实现了空中控制。

  • 操作: 创建独立脚本 AirControl.cs
  • 原理: AirControl 没有继承 PlayerMovement。它通过 GetComponent<PlayerMovement>() 获取玩家的 isGrounded 状态,然后在空中独立施加修正力。
  • 应用: 只将 AirControl.cs 附加到 Level 2 的 Player 对象上。

💻 核心代码:空中修正力

// AirControl.cs
void FixedUpdate()
{
    // 只有当玩家不在地面上时,才施加侧向修正力
    if (playerMovement != null && !playerMovement.isGrounded)
    {
        float horizontalInput = Input.GetAxis("Horizontal");
        float airForce = horizontalInput * airControlForce * Time.deltaTime;
        
        // 使用 VelocityChange,直接改变玩家速度
        rb.AddForce(airForce, 0, 0, ForceMode.VelocityChange);
    }
}

30. 创建跳板 (Launcher.cs)

引入跳板为关卡设计提供了垂直变化。

  • 操作: 创建 Cube,勾选 Is Trigger,设置 Tag: LaunchPad,附加 Launcher.cs

💻 核心代码:瞬间推力

// Launcher.cs
public float launchForce = 1500f; 

private void OnTriggerEnter(Collider other)
{
    if (other.CompareTag("Player"))
    {
        Rigidbody rb = other.GetComponent<Rigidbody>();
        if (rb != null)
        {
            // 清零垂直速度,确保获得最大的向上推力
            rb.velocity = new Vector3(rb.velocity.x, 0f, rb.velocity.z); 
            
            // 使用 Impulse 施加瞬间的力
            rb.AddForce(Vector3.up * launchForce, ForceMode.Impulse);
            
            // 播放特效
            launchEffect.Play();
        }
    }
}

31. 创建动态陷阱 (移动与旋转)

引入移动和旋转障碍物增加了游戏的动态难度。

  • 物理优化: 动态障碍物的 Rigidbody 组件的 Collision Detection 必须设置为 Continuous

💻 核心代码:往复移动(PingPong)

// MoveBetweenPoints.cs
void Update()
{
    // Math.PingPong(time, length) 使 time 在 0 和 length 之间来回震荡
    timeElapsed += Time.deltaTime * speed;
    float t = Mathf.PingPong(timeElapsed, 1f); 

    // Lerp(A, B, t) 平滑地在起点和终点之间移动
    transform.position = Vector3.Lerp(startPosition, endPosition, t);
}

💻 核心代码:恒定旋转

// RotateAroundAxis.cs
public float rotationSpeed = 100f; 
public Vector3 rotationAxis = Vector3.up; 

void Update()
{
    // 围绕指定轴以指定速度旋转
    transform.Rotate(rotationAxis, rotationSpeed * Time.deltaTime);
}

🗺️ 六、游戏流程控制与启动 (32-33 步)

32. 多场景切换与 Build Settings

实现了从 Level 1 到 Level 2 的自动切换,并将所有场景整合到游戏启动流程中。

  • 操作:File -> Build Settings 中,将所有场景按顺序添加到列表:MainMenu (0) → Level1 (1) → Level2 (2)。

💻 核心代码:延迟加载下一关

// GameController.cs
public void GameWon()
{
    // 停止计时器,显示 UI
    // ...

    // 延迟 3 秒后调用 LoadNextLevel 函数
    Invoke("LoadNextLevel", 3f); 
}

public void LoadNextLevel()
{
    int nextSceneIndex = SceneManager.GetActiveScene().buildIndex + 1;
    
    // 确保索引有效,然后加载下一个场景
    if (nextSceneIndex < SceneManager.sceneCountInBuildSettings)
    {
        SceneManager.LoadScene(nextSceneIndex);
    }
    else
    {
        // 游戏通关,回到主菜单或第一关
        SceneManager.LoadScene(0); 
    }
}

33. 创建主菜单 (MenuManager.cs)

为主菜单添加启动和退出功能,完成游戏的启动循环。

💻 核心代码:启动与退出

using UnityEngine.SceneManagement; 

// MenuManager.cs
public class MenuManager : MonoBehaviour
{
    public void StartGame()
    {
        // 加载 Level 1 场景 (索引 1)
        SceneManager.LoadScene(1); 
    }

    public void QuitGame()
    {
        // 退出游戏应用程序
        Application.Quit();

        // 提示:在 Unity 编辑器中运行时,此代码只会在控制台打印消息
        Debug.Log("Application Quit!"); 
    }
}

✅ 总结与发布(Final Build)

  • 最终检查: 确保所有场景内容完整且已保存。
  • Player Settings 配置: 设置公司名、产品名和游戏图标。
  • 打包:Build Settings 中选择目标平台 (PC/Mac/Linux),点击 Build 导出可执行文件。

Vue3-watchEffect

watchEffect 是 Vue 3 组合式 API 提供的另一个侦听器。它和 watch 目标一致(在数据变化时执行副作用),但使用方式更简洁,因为它会自动追踪依赖

1.核心理念

watchEffect 的核心理念是: “立即执行一次,并自动追踪函数内部用到的所有响应式数据,在它们变化时重新执行。”

它只需要一个参数:一个函数。

<template>
  <p>User ID: {{ userId }}</p>
  <button @click="changeUser">切换用户</button>
</template>

<script setup lang="ts">
import { ref, watchEffect } from 'vue';

const userId = ref(1);

// 1. 立即执行:
// watchEffect 会在 <script setup> 执行到这里时立即运行一次
// 它会自动“发现”内部用到了 userId.value
watchEffect(() => {
  // 当 watchEffect 运行时,它会追踪所有被“读取”的 .value
  console.log(`(watchEffect) 正在获取 ID: ${userId.value} 的数据...`);
  
  // 假设的 API 调用
  // fetchUserData(userId.value);
});

// 2. 自动追踪变化:
// 当 userId 变化时,上面的函数会 *自动* 重新运行
const changeUser = () => {
  userId.value++;
};
</script>

2.watchEffectwatch 的核心区别

特性 watch watchEffect
依赖源 手动指定 (必须明确告诉它侦听谁) 自动追踪 (它会侦听函数体内部用到的所有数据)
立即执行 默认不会 (需配置 { immediate: true }) 默认立即执行
访问旧值 可以 (回调参数 (newVal, oldVal)) 不可以 (回调不接收任何参数)
侧重点 适合精确控制 更“轻量级”,适合简单的、自动化的副作用

3.watchEffect 的高级用法

  • 停止侦听:watchEffect 同样会返回一个“停止句柄” (Stop Handle) 函数。

<script setup> 中,它也会自动绑定到组件生命周期,并在组件卸载时自动停止,所以你通常不需要手动停止。

import { watchEffect } from 'vue';

const stopHandle = watchEffect(() => {
  // ...
});

// 在未来的某个时刻
stopHandle(); // 停止这个 effect
  • 清除副作用 (onInvalidate):watchEffect 的回调函数可以接收一个 onInvalidate 函数作为参数。这个函数用于注册一个“失效”时的回调。

watchEffect 即将重新运行时(或在侦听器被停止时),onInvalidate 注册的回调会先执行。这在处理异步操作竞态时非常有用(例如,短时间内多次触发 API 请求)。

举个栗子

<template>
  <div>
    <h3>onInvalidate</h3>
    <p>在控制台中查看日志,并快速输入:</p>
    <input v-model="query" />
    <p>当前查询: {{ query }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref, watchEffect } from 'vue';

const query = ref('vue');

watchEffect((onInvalidate) => {
  // 1. 创建 AbortController
  const controller = new AbortController();
  const { signal } = controller;

  console.log(`(Fetch) 正在搜索: ${query.value}`);
  
  // 2. 使用 Open Library 的 API
  // 对 URL 进行了编码 (encodeURIComponent) 以处理特殊字符
  const url = `https://openlibrary.org/search.json?q=${encodeURIComponent(query.value)}`;

  fetch(url, { signal })
    .then(response => response.json()) // 将响应解析为 JSON
    .then(data => {
      // 3. 成功获取数据
      console.log(`(Success) 成功获取: ${query.value}`, data.docs.length, '条结果');
    })
    .catch(err => {
      // 4. 捕获错误
      if (err.name === 'AbortError') {
        //  预期的中止错误
        console.log(`(Abort) 已取消上一次请求: ${query.value}`);
      } else {
        // 其他网络错误
        console.error('Fetch 错误:', err);
      }
    });

  // 5. 注册“失效”回调
  onInvalidate(() => {
    console.log('...查询变化太快,Effect 即将失效,中止上一个请求...');
    // 6. 中止上一次的 fetch 请求
    controller.abort();
  });
});

</script>

当快速输入333时,控制台打印

image.png

检查网络,前两次的快速请求都被取消了,只有最后一次成功

image.png

4.总结

onInvalidate 适用于任何有“清理”需求的副作用

  1. 取消异步请求:(最常用) fetch, axios 等。
  2. 清除定时器:清除 setTimeoutsetInterval
  3. 解绑事件监听器:如果在 watchEffect 内部用 window.addEventListener 动态添加了一个事件,你可以在 onInvalidate 中用 window.removeEventListener 将其移除,防止内存泄漏。

两个关键区别:

  • 防抖/节流 (Debounce/Throttle) (用 watch):

    • 目标:减少执行次数
    • 逻辑:“等一等再执行”。
  • 清理副作用 (Cleanup) (用 watchEffect + onInvalidate):

    • 目标:防止旧操作干扰新操作
    • 逻辑:“开始新的之前,先确保旧的已经停止了”。

H5 图片路径不统一,导致线上部分图片无法按预期展示(assetPrefix 与 basePath 行为不一致)

问题:H5网页头像,点赞和评论icon没有出现

  • 环境:
    • 构建: Next.js 13.2.4
    • 配置: assetPrefix = '/pr',basePath 未开启
  • 影响范围:
    • 页面: H5 页面
    • 资源: 小图标/本地图(thumbsup.png、comment.png 等)
  • 现象: 在浏览器中
    • logo 正常:请求路径为 /pr/_next/static/media/logo.xxx.png
    • 其他图片异常:路径为 /_next/image?url=%2Fpr%2Fthumbsup.png...(优化路由未带 /pr 前缀),浏览器 Sources 下看不到对应 /_next/static/media/* 条目

image.png

  • 预期:
    • 所有图片路径风格统一,均能在 test 环境正常展示;优化路由或静态直链应一致且带正确前缀
  • 初步根因分析:
    • Next 的图片优化端点 /_next/image 不受 assetPrefix 影响,仅受 basePath 控制;当前仅配置了 assetPrefix,未开启 basePath,导致优化路由与静态直链前缀不一致
    • 组件内图片用法不统一:部分使用静态导入+自定义 loader(绕过优化),部分使用字符串路径(触发优化路由)
  • 相关证据:
    • 本地构建产物存在 /.next/static/media/thumbsup..png、comment..png
    • 线上通过浏览器面板 Network发现:logo 在路径 /pr/_next/static/media/...下;但是没有thumbsup和comment

image.png

  • 修复方案:
    • 统一使用静态导入并禁用优化:
    • import thumbsup from '@/public/thumbsup.png'
    • <Image src={thumbsup} width={22} height={22} unoptimized /> <Image src={thumbsup} width={22} height={22} loader={({src}) => src}/>

Chromium 渲染机制

Chromium 渲染机制深度解析:从 HTML 响应到页面渲染的完整流程

当我们打开一个网页时,从浏览器收到 HTML 响应到元素最终被渲染到屏幕上,这整个过程背后究竟遵循着什么样的机制?本文将深入探讨 Chromium 浏览器的渲染机制,通过 Chrome 的 tracing 工具来揭示浏览器内部的工作流程。

浏览器为了尽可能高效地协调资源来完成渲染、请求、事件、脚本等工作,采用了事件循环(Event Loop)的工作策略。这种机制确保了浏览器能够有序、高效地处理各种任务。

事件循环机制

让我们通过 Chrome DevTools 的 Tracing 功能来深入理解 Chromium 的工作机制。首先,我们来看一段包含多种异步操作的代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div style="width: 100px; height: 100px; background-color: red;"></div>
  <script>
    console.log(1);
    setTimeout(() => {
      console.log(2);
    }, 0);
    queueMicrotask(() => {
      console.log(3);
    });
    requestAnimationFrame(() => {
      console.log(4);
    });
    console.log(5);
  </script>
</body>
</html>

当开启 tracing 后,刷新打开这个 HTML 页面,我们可以看到如下的 tracing 结果:

image.png

通过使用 w 键放大 CrRendererMain 的工作单元,我们可以看到主渲染线程在不断循环执行两个核心函数:

image.png

任务执行循环

CrRendererMain 线程不断地在循环执行 ThreadControllerImpl::RunTaskBlinkScheduler_OnTaskCompleted。其中,ThreadControllerImpl::RunTask 负责执行具体的任务,比如解析 HTML、遇到同步 script 标签时会执行脚本并阻塞 HTML 解析。

浏览器的实现机制是将不同的工作都定义为一个个 Task,每完成一个 Task 之后,就会检查微任务队列,然后清空微任务队列。

在 W3C 抽象的 JavaScript 执行模型里,JavaScript 的同步代码总是在 V8 的调用栈(Call Stack)中执行。当遇到异步任务时,会将其放入不同的任务队列(Task Queue,对应不同的 Task Source),或者交给独立的模块(如 Timer 模块)执行。这些模块在合适的时机将回调放入任务队列,然后由 BlinkScheduler 负责决定优先级,在下一次 ThreadControllerImpl::RunTask 时执行。

需要注意的是,被抽象为"宏任务"的并不是一个具体的队列,而是多个不同任务源的任务队列集合。这些任务通过优先级调度,不断地被 ThreadControllerImpl::RunTask 执行;而微任务则是在当前宏任务运行完成后,在 BlinkScheduler_OnTaskCompleted 回调中清空的。

多进程架构

通过 Tracing 我们还可以观察到,为了安全性和稳定性,Chromium 采用了多进程架构:

  • 每一个标签页都是独立的 Renderer 进程进行渲染
  • 还有一个独立的 Browser 进程,负责进程管理和协调,以及与操作系统交互

关于 requestAnimationFrame 和 setTimeout 的执行顺序

在上述代码的执行结果中,我们可能会看到两种不同的输出顺序:1, 5, 3, 2, 41, 5, 3, 4, 2。为什么 requestAnimationFramesetTimeout 的执行顺序不明确呢?

这涉及到渲染流程的时序问题:

  1. 渲染流程概述Renderer 线程将 layoutpaint 的数据 commitCompositor Thread 后,Compositor 会进行 rasterization(栅格化)和 compositing(合成),然后将数据提交到 Viz/GPU 进程。

  2. 任务执行的时序:在 Renderer 进行 commit 后,Renderer 线程可以继续执行其他任务。但是 requestAnimationFrame 的回调是在 layout 之前执行的(由 Blink Scheduler 调度)。这意味着:

    • 一帧内可能进行多次 layoutpaint
    • 但只会进行一次 commit
    • commit 后,不会执行 requestAnimationFrame
    • requestAnimationFrame 会进入到下一帧的事件循环里执行
  3. 顺序不确定的原因:这会导致 setTimeout 可能在上一帧的 commit 后的事件循环中完成,而 requestAnimationFrame 在下一帧才完成。这个现象的根本原因是:当你打开页面时,浏览器收到 HTML 请求并开始进行 parser 的时机,不一定是从 VSync 信号刚开始就进行的。因此,页面加载的开始时刻与显示器的刷新周期不同步,导致第一帧的执行时机存在不确定性。

渲染流程详解

渲染的主要步骤

一般来讲,完整的渲染流程包含以下步骤:

  1. 主渲染线程(CrRendererMain)Layout(布局) → Paint(绘制) → Commit(提交)

    • 提交的数据包括:Layer Tree(图层树)、Paint Records(绘制指令)、Scroll Offsets(滚动偏移)
  2. 合成器线程(Compositor Thread)Rasterization(栅格化) → Compositing(合成) → Presentation(呈现)

  3. VSync 信号到来时:将 GPU 进程里的帧数据渲染到屏幕

优化机制

  • Commit 合并:一帧内的多次 paint 操作会进行 commit 合并,减少不必要的提交。

  • requestAnimationFrame 的使用requestAnimationFrame 的回调在每帧内执行完毕,可以利用这个特性,将需要一次 commitlayout 或者 paint 代码放入 requestAnimationFrame 的回调中。每帧结束后会进行回调队列的清空。如果在宏任务里,layoutpaint 被分到两帧运行,则会有多次 commit,可能影响性能。

强制同步布局(FSL - Forced Synchronous Layout)

当 Chromium 执行脚本时,如果脚本对元素属性进行了修改,浏览器不会立即进行 layout,而是将 layout 延迟到下一次 RunTask 执行。但是,如果代码中进行了读取操作(比如读取 offsetWidthoffsetHeight 等布局相关的属性),会中断 JavaScript 的执行,立刻进行 layout(这就是 FSL 策略)。需要注意的是,即使触发 FSL,也不会立即 commitcommit 仍然会在合适的时机统一进行。

让我们通过以下代码来验证这一机制:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div style="width: 100px; height: 100px; background-color: red;" id="box"></div>
  <script>
    console.log(1);
    box.style.width = '200px';
    console.log(box.offsetWidth);
    // queueMicrotask(() => {
    //   const start = performance.now();
    //   while (performance.now() - start < 2000) {}
    //   console.log('start to commit');
    // })
  </script>
</body>
</html>
实验 1:有读取操作的情况

如果有读取操作(如 console.log(box.offsetWidth)),在 Tracing 结果中可以看到,在 v8.run 执行时,有 ForcedStyleAndLayout 子任务:

image.png

这说明读取操作触发了强制同步布局(FSL)。

实验 2:无读取操作的情况

如果将读取代码注释掉,可以看到 layout 是在 v8.run 之后执行的:

image.png 这证明了在没有读取操作的情况下,layout 被延迟到了下一次任务执行。

如何确认 FSL 只触发 layout 而不触发 commit?

首先,渲染不是浏览器想渲染就能渲染的。layout 是内存操作,浏览器可以在任何时候执行;而渲染需要硬件配合,必须等待 VSync 信号。

其次,我们可以在上述代码的最后加入阻塞代码(比如取消注释代码中的 queueMicrotask 部分),通过肉眼观察可以发现:页面会阻塞,但不会立刻渲染新的布局结果。这证明了 FSL 只触发了 layout,并没有立即 commit 和渲染。

VSync 与帧率

显示器会根据刷新率每隔一定的时间进行刷新。刷新是指显示器将 GPU 里的帧数据进行展示(这个动作几乎是瞬间完成的)。通常由硬件发起 VSync(垂直同步)信号,表示一帧的开始(当然也表示上一帧的结束)。

浏览器必须在一帧时间内(通常是 16.6ms,对应 60Hz 的刷新率)完成脚本执行、渲染(将元素的改动进行统一的 commit,提交到合成器线程),然后等待 VSync 信号到来时,显示新的帧数据。

长任务的影响

CrRendererMain 虽然是通过异步的方式调度任务,但是执行任务本身是同步的。比如一个定时器的回调这样写:

setTimeout(() => {
let start = performance.now();
while (performance.now() - start <= 2000) {}
console.log('end');
}, 0)

JavaScript 的执行会占用主线程 2 秒时间。在这 2 秒内,即使 VSync 信号到来,合成器线程里的帧数据永远是上一帧的(因为主线程根本没有时间提交更新),一直在执行 JavaScript。这会导致页面"卡顿",用户体验下降。

总结

Chromium 基于多进程架构,每个窗口的渲染和脚本执行都采用事件循环(Event Loop)模式进行任务调度和执行。核心要点包括:

  1. 任务调度机制:通过 ThreadControllerImpl::RunTask 执行宏任务,在任务完成后通过 BlinkScheduler_OnTaskCompleted 清空微任务队列。

  2. 渲染流程:主渲染线程执行 LayoutPaintCommit,然后由合成器线程进行 RasterizationCompositingPresentation,最终在 VSync 信号到来时呈现到屏幕。

  3. 性能优化:浏览器采用 FSL 策略避免不必要的布局计算,通过 commit 合并减少提交次数,利用 requestAnimationFrame 将渲染相关的代码在合适的时机执行。

  4. 长任务问题:长时间执行的 JavaScript 代码会阻塞主线程,导致无法及时提交新的渲染数据,造成页面卡顿。

理解这些机制有助于我们编写出性能更好的前端代码,避免不必要的强制同步布局,合理使用 requestAnimationFrame 等 API。

名词参考

主渲染线程部分

步骤 作用
Layout (重排) 计算页面上所有元素的 几何信息(尺寸和位置)。
Paint (重绘) 生成 绘制指令(Paint Records),记录如何用颜色、线条填充每个元素的像素。
Commit (提交) 主线程将所有渲染结果打包,提交给合成器线程。
提交数据 Layer Tree (图层树): 描述了元素的分层结构和 3D 位置。
Paint Records (绘制指令): 告诉 GPU 如何生成像素。
Scroll Offsets (滚动偏移): 最新的滚动位置信息。

合成器线程部分

步骤 作用 (GPU 加速核心)
Rasterization (栅格化) 利用 GPU 的并行核心,将 Paint Records 中的 绘制指令 转化为 GPU 内存中的 实际像素数据(位图纹理)
Compositing (合成) 利用 GPU 对 图层 进行 混合和叠加,应用 CSS 变换(transform)、不透明度,生成最终显示在屏幕上的单个图像(Compositor Frame)。
Presentation (呈现) 将合成好的 Compositor Frame 提交给 Viz/GPU 进程,并 等待 VSync 信号 的到来,将帧数据展示到屏幕上。

参考资源

小声说一句,看完感觉似曾相识,react 的 fiber 架构简直就是在模仿浏览器渲染机制啊!!!

vite里postcss里@csstools/postcss-global-data的用法 (importFrom失效后的解决办法

Postcss-custom-properties 默认只能识别:root{} 和:html{}的变量
确保你的css变量符合规定 不然不会生效

图片.png

```postcss: {
            plugins: [
                postcssGlobalData({
                    files: [
                        path.resolve(process.cwd(), "从你vite.config.js到css的相对路径")
                    ]
                }),
                postcssPresetEnv()
            ]

        }
        //这个path和process都是node的 所以还要引入 const path = require("path")
        //这里vite里的postcss对象和postcss.config.js里默认导出的那个对象是一样的

gloabalData需要放到custom_properties前面 代码里的postcssPresetEnv是一堆插件的集合(pluginPackage)其中就包含了custom_properties,按需替换 图片.png

css3的变量不能用于less因为postcss在生命周期上要比less靠后(less先处理postcss后处理 但是其他的css文件都是能正常用的
目前vite模块化css要实现变量和代码解耦只有两种方式
1.全用less然后在preprocessorOptions: {less: {globalVars: 里去写全局变量 或者你单独写个less文件然后在其他文件里用@import url(./xxx.less)引入 globalvars可以和dotEnv联用

2.用css自带的变量写法 :root{ --aaa-bb:变量} 使用var(--aaa-bb)来引用配合global-data和custom-properties来实现\

虽然用less定义css3的变量 在普通的模块化css里也能引入也能正常生效 但是由于按需引入等问题
模块化less在vite里老是不生效 不知道是不是我环境的问题 还是他不支持.module.less的写法

注意:CSS Modules 设计初衷就是配合 JavaScript 使用,无法像传统 CSS 那样直接在 HTML 中通过 class 属性使用原始类名。这是 CSS Modules 实现样式作用域隔离的机制决定的。所以.module.css需要在main.js(入口文件)中引入他才会打包

从不足到精进:H5即开并行加载方案的演进之路

作者: vivo 互联网客户端团队- Chen Long

并行加载是 H5 即开 SDK 的加速技术,通过 native 层在用户打开页面时并行请求关键资源(如 index.html 和 CSR 模式 API),利用 webview 初始化时间窗口提前发起请求,减少加载耗时。其核心挑战是解决 webview 与并行任务间的资源交接问题。

1分钟看图掌握核心观点👇

图片

一、并行加载能力核心解析

1.1 什么是并行加载

并行加载是H5即开SDK提供的一项加速能力,核心逻辑是:在用户打开页面时,通过native层并行请求关键资源,减少页面加载耗时。其本质是利用webview及native页面初始化的时间窗口,提前发起资源请求,实现"时间复用"。

1.2 核心加载资源

并行加载的关键资源包含两类:

  • H5页面首帧渲染依赖的index.html(首帧资源,加载时机极早)。

  • CSR模式下页面依赖的API接口(通常在首帧渲染后调用,加载时机较晚)。

1.3 工作流程示意图

图片

即开SDK在流程中主要完成两件事:

  1. 用户打开URL时,并行请求H5依赖的API接口或index.html文件。

  2. 拦截webview请求,将并行加载的缓存数据导流给webview,实现加速。

二、并行加载的核心挑战:资源交接场景

并行加载的核心问题是如何在webview需要资源时,将并行请求的资源无缝交接。根据资源状态可分为三类场景

下面我们针对三个场景分别看处理方案

2.1 场景一:网络数据尚未响应,webview开始需要资源

解决方案一

忽略网络数据,webview自己加载资源。

矛盾点:

如果是因为服务器压力大,网络环境差导致的响应慢,webview自己去加载也会遇到同样的问题,而且直接放弃已并行加载的任务,等于是浪费了已经处于建联中,可能已经完成了一部分等待的时间。

解决方案二

webview等待网络资源加载成功后,再使用加载成功的资源。

矛盾点:

让webview的资源获取线程等待并行任务响应并返回,那么等多久,如果时间太久怎么办,需要设置超时时间,如何等待,并且在等待的过程中还要监控并行任务是否已经返回。

2.2 场景二:网络数据已响应,webview开始需要资源

场景二需要考虑两种情况

情况一

在webvie需要数据的时候,网络数据流刚好完成建联,webview可以直接使用网络数据流加载。

情况二

网络数据流建联的时候,webview还未开始需要使用数据,并行任务有时间将网络数据流读取到缓冲区中,webview在需要数据的时候,可以读取缓冲区的数据。

矛盾点:

大家知道网络数据读取是有受限于网络环境影响的,预先进行网络的数据读取,再交接给webview,肯定能更大程度上降低读取耗时,但是问题又来了,如果并行任务在读取的过程中,还没有读完,webview就来要数据怎么办,让webview等待吗?如果等待,等待多久?如果不等待,又如何将已读取在缓冲区中的数据和未读取的网络流数据一起交给webview呢。

2.3 场景三:网络数据返回错误,webview开始需要资源

解决方案

并行任务已失败,直接废弃并行任务,让webview自己加载资源。

三、早期方案设计与局限

在最开始,我们希望并行任务设计的足够简单,基于以上所有场景下的理解,权衡开发难度,我们设计了第一个方案。

首先我们希望避开场景二中,网络数据流已建联,网络线程正在读取网络流到缓冲区,读到一半webview来取数据的场景,因为我们觉得这种场景较为复杂,如果返回混合流,可能会出现控制不好的情况,而且整个过程中,两条线程参与的生产者和消费者,存在一个中间态,也就是生产者生产到一半时,消费者过来要消费数据,生产者要立刻停止生产,并把未成品交给消费者,这显然比常规的生产者消费者模式更加复杂,于是我们决定用更简单的方法来处理,方案就是把这个较为复杂的生产者消费者,变回简单的典型生产者消费者,消费者不能打断生产者的生产过程,而是等待生产完成,避免中间态下的复杂处理,虽然做了妥协,但是我们依然希望有较好的性能,所以我们将index.html任务和CSR模式下的API任务分为两个不同的方式来进行处理。

3.1 index.html首帧资源处理

index.html,这是webview完成初始化后第一个要加载的资源,俗称首帧资源。因此index.html的使用时机可以说是非常的靠前,在并行加载任务中,它的可用并行加载时间也大致在100ms左右,我们认为在这个时间内,并行任务大概率可以完成建联,但是可能没有时间再完成数据流的读取。

因此,我们使用了stream对象来保存网络数据流。

用户点击H5入口按钮,并行index任务发起,访问网络开始建联,一但完成有效建联,则保存网络stream对象,当webview需要使用该数据流的时候,判定stream对象是否存在,如果存在,则直接使用该数据流,如果stream对象不存在,则判定网络访问是否返回,如果是因为网络访问失败导致stream为空,则认为是并行任务失败,否则会进入等待模式,等待模式下,线程建立一个循环体,每隔5ms,探测一次stream是否存在,网络是否已完成建联,一但探测到结果,则退出循环体,即使多次没有探测到结果,1500ms后依然退出循环体,此时放弃并行任务数据,改为webview自主加载资源。

我们来看下这个方案

循环体的等待机制实现了消费者等待生产者完成生产的过程,5ms的检测时机,实现了生产者生产完成后,消费者知晓生产者生产状态的能力,1500ms的的超时退出,解决了生产者出现问题时,消费者困在循环等待中的情况。整个方案,只在保存的stream对象上添加volatile关键字,实现轻量的对象可见性线程同步。

整体方案逻辑如下:

3.1.1 index并行任务发起

flowchart TD
    A[用户点击H5入口按钮] --> B[并行发起index任务]
    B --> C[native访问网络开始建联]
    C --> D{建联成功?}
    D -->|是| E[保存网络Stream对象]
    E -->F[记录结束状态]
    D -->|否| F[记录结束状态]

图片

3.1.2 webview使用index资源

flowchart TD
    A[用户点击H5入口按钮] --> B[webview初始化]
    B --> C{需要使用index数据流}
    C -->|是| D[查找stream对象是否存在]
    D -->|存在| E[直接使用数据流]
    D -->|不存在| F{网络访问已结束?}
    F -->|是且stream为空| G[判定并行任务失败]
    F -->|否| H[进入等待模式]
    H --> I[建立探测循环体]
    I --> J[每隔5ms探测]
    J --> K{stream存在?}
    K -->|是| L[使用数据流并退出循环]
    K -->|否| M{并行任务已结束?}
    M -->|是| N[放弃并行任务并退出循环]
    M -->|否| O{达到1500ms?}
    O -->|是| P[超时退出循环]
    O -->|否| J
    P --> Q[webview自主加载资源]
    N --> Q
    L --> R[流程结束]
    G --> Q
    Q --> R

图片

3.2 API 接口资源处理

API接口通常在页面完成第一次渲染后,才开始调用,这个时候页面可能会展示一个loading状态,或者是骨架屏,这个时机相对靠后,并行任务有充足的时间来完成网络的建联任务,甚至有充足的时间将网络流读取到缓冲区中。

针对这种情况,我们保存了一个byte数组,将网络流数据读取到这个byte数组中,webview需要使用数据时,将byte数组包装成ByteArrayInputStream返回。

逻辑图如下:

3.2.1 API并行任务发起

flowchart TD
    A[用户点击H5入口按钮] --> B[并行发起index任务]
    B --> C[native访问网络开始建联]
    C --> D{建联成功?}
    D -->|是| E[读取网络stream]
    E --> F[保存到本地byte数组]
    D -->|否| G[记录结束状态]
    F --> H[流程完成]
    G --> H

图片

3.2.2 webview使用API资源

flowchart TD
    A[用户点击H5入口按钮] --> B[webview初始化]
    B --> C{需要使用index数据流}
    C -->|是| D[查找byte数组是否存在]
    D -->|存在| E[封装为ByteArrayInputStream返回给webview]
    E --> X[流程结束]
    
    D -->|不存在| G{网络访问已结束?}
    G -->|是且byte数组为空| H[判定并行任务失败]
    G -->|否| I[进入等待模式]
    
    I --> J[建立探测循环体]
    J --> K[每隔5ms探测]
    K --> L{byte数组存在?}
    L -->|是| M[封装使用并退出循环]
    L -->|否| N{并行任务已结束?}
    N -->|是| O[放弃并行任务并退出]
    N -->|否| P{达到1500ms?}
    P -->|是| Q[超时退出]
    P -->|否| K
    
    M --> E
    O --> R[webview自主加载]
    Q --> R
    H --> R
    R --> X

图片

从上述的逻辑图中可以看到,一但网络建联的时间不足,数据响应不及时,webview就会处于等待状态。

有两种场景会导致webview建立线程循环体等待资源:

**场景一:**并行发起的网络请求,因为网络速度慢,尚未建联返回有效的网络stream。

**场景二:**在并行API的场景下,虽然已经建联,但是网络stream数据读取中,尚未完全读取到缓冲区,webview会等待缓冲区缓冲完成。

这两种场景,都会造成时间的浪费

场景一的等待虽然不可避免,但是webview的循环体检测机制,每隔5ms才会检测一次,最差的情况下,无效等待时间可以最长达到5ms(上一次循环检测刚结束,网络即完成建联,需要下一个5ms之后才会发起检测)。

场景二的无效等待时间就更长了,即使已经完成建联,webview也要等待缓存全部完成,待缓存完成之后,webview又再次从缓冲区读取一次数据,全量缓存不就浪费了时间,还浪费了内存。

3.3 早期方案的核心局限

  • 时间浪费:循环探测(每 5ms 一次)存在无效等待

  • 内存浪费:API 接口全量缓存占用额外内存

  • 资源利用率低:放弃部分加载的资源,未充分利用并行请求的时间窗口

四、方案演进:优化时间与内存消耗

在反思早期方案的弊端中,我们提出了内存的消耗和时间上的浪费,那么新方案的优化侧重点就是优化内存的消耗和循环等待时间上的消耗,优化耗时的优化大刀是直接干掉循环等待,优化内存消耗的优化大刀是干掉全量的缓存建立,但是做加法简单,做减法就没那么容易。

4.1 优化思路

新方案的核心是解决中间态交接问题,通过线程同步和流桥接技术实现优化。

整体的构思如下:

  • 干掉循环等待:用线程同步机制替代轮询

  • 干掉全量缓存:采用半缓冲模式,仅缓存部分数据

  • 支持中间态交接:允许 webview 打断并行任务,合并已缓冲与未缓冲数据

4.2 技术实现:生产者 - 消费者模型

4.2.1 核心逻辑

我们把网络建联+网络数据读取到缓冲区的整个过程,看作是生产过程, 用一个生产者消费者模型来描述这个过程

  • 生产者接到生产产品任务

  • 消费者随时过来消费产品

  • 如果生产者还未开始生产,消费者可以等待一段时间,但是如果超时就放弃等待

  • 如果生产者正在生产,消费者可以随时可以打断生产,拿走生产了一半的产品,消费者完成剩下产品的生产任务

  • 如果生产者已经完成生产,消费者就可以拿走全部产品

这里涉及到两个技术点

  • 生产者在生产过程中随时被打断

  • 生产者生产了一半的产品可以被用来使用

我们尝试使用线程间同步机制和桥接流技术来实现这些需求:

4.3 代码核心实现

import java.io.*;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

import static java.lang.Thread.sleep;


public class Main2 {

       //模拟响应体
       public static class ResponseBody {
            InputStream stream;

             public ResponseBody(InputStream stream){
                 this.stream = stream;
            }

            public InputStream byteStream(){
                return stream;
            }
        }


        public static class SyncLoadResponseBean {

            private static final String TAG = "SyncLoadResponseBean";

            // 状态常量定义
            public static final int INIT = 1;  // 初始状态
            public static final int READY = 2; // 数据准备就绪
            public static final int OFFER = 3; // 数据已提供
            public static final int DROP = 4;  // 数据已丢弃

            private final String mRequestUrl;
            private final ConcurrentHashMap<String, SyncLoadResponseBean> mSyncLoadCache;
            private final AtomicInteger mStatus = new AtomicInteger(INIT); // 状态机

            // 数据存储相关
            private Map<String, String> mResponseHeader;
            private ByteArrayOutputStream mBufferStream;
            private InputStream mNetworkStream;
            private long mResponseTime;


            public SyncLoadResponseBean(String requestUrl, ConcurrentHashMap<String, SyncLoadResponseBean> syncLoadCache){
                mRequestUrl = requestUrl;
                mSyncLoadCache = syncLoadCache;
                mStatus.set(INIT);
            }

            public boolean before(int status){
               return mStatus.get() < status;
            }

            public boolean during(int status){
                return mStatus.get() == status;
            }

            public boolean after(int status){
                return mStatus.get() >= status;
            }

            /**
             * 唤醒所有等待线程
             * 使用 tryLock 避免长时间阻塞
             */
            public void signalAll(){
                synchronized (SyncLoadResponseBean.this) {
                    this.notifyAll();
                }
            }

            /**
             * 保存响应数据并预处理
             * 网络线程会在得到响应后调用该方法,保存数据
             */
            public void saveResponse(ResponseBody responseBody, Map<String, String> responseHeader){
                streamReady(responseBody, responseHeader);
                preReadStream();
            }

             /**
             * 准备数据流
             */
             private voids treamReady(ResponseBody responseBody, Map<String, String> responseHeader){
                synchronized (SyncLoadResponseBean.this) {
                    TLog.d(TAG, "并行加载 响应保存");
                    mResponseTime = System.currentTimeMillis();
                    mResponseHeader = responseHeader;
                    mBufferStream = new ByteArrayOutputStream();
                    if (responseBody != null) {
                        mNetworkStream = responseBody.byteStream();
                        // 根据流是否有效设置状态
                        if (mNetworkStream != null) {
                            mStatus.set(READY);
                        } else {
                            drop();
                        }
                    } else {
                        drop();
                    }
                    TLog.d(TAG, "并行加载 保存完成 通知消费者");
                    signalAll();
                }
            }


            private void preReadStream(){
                TLog.d(TAG, "并行加载 预读缓存 开始");
                byte[] buffer = new byte[4096];
                int num = 0;
                try {
                    while (during(READY)) {
                        synchronized (SyncLoadResponseBean.this) {
                            try {
                                //双重校验锁
                                if (during(READY)) {
                                   // 读取网络流数据
                                   int bytesRead = mNetworkStream.read(buffer);
                                   if (bytesRead == -1) {
                                        TLog.d(TAG, "并行加载 预读缓存 完成 " + bytesRead);
                                        closeStream(mNetworkStream);
                                        mNetworkStream = null;
                                        return;
                                    }
                                    TLog.d(TAG, "并行加载 预读缓存 " + bytesRead);
                                    mBufferStream.write(buffer, 0, bytesRead);
                                }
                            } finally {
                                num++;
                                TLog.d(TAG, "并行加载 预读缓存 第" + num + "次通知消费者");
                                signalAll();
                            }
                        }
                    }
                    //已经提供了数据,则打印个日志看下
                    if (after(OFFER)) {
                        TLog.d(TAG, "并行加载 数据流已提供 预读缓存 关闭");
                    }
                } catch (IOException e) {
                    TLog.e(TAG, "并行加载 预读缓存 异常 关闭", e);
                    synchronized (SyncLoadResponseBean.this) {
                        //在读取的过程中出现了异常,但是这个时候还没有提供数据,就直接drop调
                        if (!after(OFFER)) {
                            drop();
                        }
                    }
                }
            }

            /**
             * 获取桥接流
             * 浏览器线程调用该方法获取数据流
             */
             public InputStream getBridgedStream(){
                TLog.d(TAG, "并行加载 查找流数据");
                synchronized (SyncLoadResponseBean.this) {
                  try {
                       if (before(READY)) {
                            TLog.d(TAG, "并行加载 查找流数据 进入等待状态");
                            this.wait(5000);
                            TLog.d(TAG, "并行加载 查找流数据 被唤醒");
                        }
                        // 等待结束,再确认一次状态是否可用
                        if (before(READY)) {
                            TLog.d(TAG, "并行加载 查找流数据 依旧没有可用数据 返回空流");
                            drop();
                            return null;
                        } elseif (after(OFFER)) {
                            TLog.d(TAG, "并行加载 查找流数据 数据已被废弃或者被他人被使用 返回空流");
                            return null;
                        } elseif (isTimeOut()) {
                            TLog.d(TAG, "并行加载 查找流数据 数据超时 返回空流");
                            drop();
                            return null;
                        } else {
                            if (mNetworkStream != null && mBufferStream != null) {
                                mStatus.set(OFFER);
                                // 创建新的桥接流实例,包含已缓存数据和剩余网络流
                                ByteArrayInputStream cachedStream = new ByteArrayInputStream(mBufferStream.toByteArray());
                                TLog.d(TAG, "并行加载 查找流数据 返回桥接流");
                                return new SequenceInputStream(cachedStream, mNetworkStream);
                            } elseif (mNetworkStream != null) {
                                mStatus.set(OFFER);
                                TLog.d(TAG, "并行加载 查找流数据 返回网络流");
                                return mNetworkStream;
                            } elseif (mBufferStream != null) {
                                mStatus.set(OFFER);
                                // 创建新的桥接流实例,包含已缓存数据和剩余网络流
                                TLog.d(TAG, "并行加载 查找流数据 返回缓存流");
                                return new ByteArrayInputStream(mBufferStream.toByteArray());
                            } else {
                                drop();
                                TLog.d(TAG, "并行加载 查找流数据 返回空流");
                                 return null;
                            }
                        }
                    } catch (Exception e) {
                        TLog.e("TAG", "Create bridged stream failed", e);
                        drop();
                        return null;
                    }
                }
            }

            /**
             * 获取响应头
             */
             public Map<String, String> getResponseHeader(){
                //如果请求里面不带跨域标识,则带上跨域标识
                if (mResponseHeader != null && !mResponseHeader.containsKey("Access-Control-Allow-Origin")) {
                    mResponseHeader.put("Access-Control-Allow-Origin", "*");
                }
                return mResponseHeader;
            }


            /**
             * 统一关闭流资源操作
             */
             private void closeStream(Closeable stream){
if (stream != null) {
try {
                        stream.close();
                    } catch (Exception e) {
                        TLog.e(TAG, "关闭流失败", e);
                    }
                }
            }


            /**
             * 判断数据有没有超时
             */
             private boolean isTimeOut(){
                 return Math.abs(mResponseTime - System.currentTimeMillis()) > 5000;
            }

            /**
             * 丢弃数据
             */
             public void drop(){
                synchronized (SyncLoadResponseBean.this) {
                    mStatus.set(DROP);
                    mResponseHeader = null;
                    mResponseTime = 0;
                    closeStream(mBufferStream);
                    closeStream(mNetworkStream);
                    mBufferStream = null;
                    mNetworkStream = null;
                    mSyncLoadCache.remove(mRequestUrl);
                    TLog.d(TAG, "并行加速 缓存数据丢弃");
                }
            }
        }


        /**
         * 主方法,测试程序入口
         */
          public static void main(String[] args){

            SyncLoadResponseBean syncLoadResponseBean = new SyncLoadResponseBean("https://www.baidu.com", new ConcurrentHashMap<>());

            //模拟生产者线程
            new Thread(() -> {
                 try {
                    System.out.println("生产者启动");
                    //模拟网络建联
                    sleep(200);
                    //模拟网络资源返回
                    File file = new File("./xxxx.txt");
                    ResponseBody responseBody = new ResponseBody(new FileInputStream(file));
                     //模拟响应
                    syncLoadResponseBean.saveResponse(responseBody, new HashMap<>());
                } catch (FileNotFoundException e) {
                    throw new RuntimeException(e);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    System.out.println("生产线程工作完成,唤醒等待中的消费者");
                    syncLoadResponseBean.signalAll();
                }
            }).start();

              //模拟消费者线程1
              new Thread(() -> {

                System.out.println("消费者启动");
                //模拟读取
                InputStream stream = syncLoadResponseBean.getBridgedStream();
                System.out.println("消费者 " + stream);
            }).start();

        }

    }

4.4 代码解读

代码的工作流程是这样的:

  • 生产者网络线程发起数据请求。

  • 建联后会第一次发起notify,尝试唤醒等待中的消费者webview线程,如果这个时候刚好有消费者webview在等待,还未轮到生产者预读数据,消费者就会拿走数据。

  • 如果没有消费者在等待,就开始预读,预读是一个循环读取的过程,每次循环读取都会有一个加锁->读取->释放锁的过程,保证消费者可以随时打断并拿走读取了一半的数据。

  • 使用synchronized(SyncLoadResponse-

    Bean.this)来实现生产者和消费者线程间协同工作。

  • 使用AtomicInteger来实现状态机的线程间同步。

  • 使用了wait(long timeoutMillis)来实现了等待和锁的超时释放。

  • preReadStream()方法在while循环体内实现了读取时的同步块。

  • 使用SequenceInputStream实现了已读缓冲流与未读网络流的桥接。

  • 在消费者webview等待资源的过程中,生产者会有多个时机让出同步锁,并且发起notify,通知消费者可以消费数据:**时机①:**生产者完成网络建联,保存了响应体,但还没有进行数据预读的时候。**时机②:**生产者在网络数据的预读过程中,每次读取4096之后都会通知消费者。

在代码的最下面,我们提供了标准Java的main函数,有兴趣的同学可以尝试将代码拷贝到本地,并且运行他,看下效果,我们这边也直接把运行效果贴出来,看下是否符合预期。

    生产者启动
    消费者启动
    并行加载 查找流数据
    并行加载 查找流数据 进入等待状态
    并行加载 响应保存
    并行加载 保存完成 通知消费者
    并行加载 预读缓存 开始
    并行加载 预读缓存 4096
    并行加载 预读缓存 第1次通知消费者
    并行加载 预读缓存 4096
    并行加载 预读缓存 第2次通知消费者
    并行加载 预读缓存 4096
    并行加载 预读缓存 第3次通知消费者
    并行加载 预读缓存 4096
    并行加载 预读缓存 第4次通知消费者
    并行加载 预读缓存 4096
    并行加载 预读缓存 第5次通知消费者
    .......
    .......
    .......
    并行加载 预读缓存 4096
    并行加载 预读缓存 第360次通知消费者
    并行加载 预读缓存 4096
    并行加载 预读缓存 第361次通知消费者
    并行加载 预读缓存 4096
    并行加载 预读缓存 第362次通知消费者
    并行加载 查找流数据 被唤醒
    并行加载 查找流数据 返回桥接流
    并行加载 预读缓存 第363次通知消费者
    并行加载 数据流已提供 预读缓存 关闭
    
    消费者 java.io.SequenceInputStream@696759a5

4.5 运行结果解读

这次的test代码运行,工作流程如下:

消费者webview需要数据的时候,生产者的生产任务还没有返回,此时生产者正在进行虚拟的网络建联,因此消费者进入了等待状态,网络建联后,生产者首次尝试唤醒消费者,但正在等待任务的消费者并没有被唤醒,这里已经开始不符合预期了。于是生产者继续进行数据预读,在数据预读的循环体内,每次一个缓冲读完,生产者都会尝试唤醒一次消费者,但是消费者直到第363次notify的时候,才被唤醒,拿到了数据。

我们经过多次尝试,发现消费者被唤醒的时机不确定,有时候是在首次唤醒的时候就能够唤醒,有时候要在唤醒第xxx次的时候才能被唤醒,而且次数还是随机的,显然这样延长了消费者等待的时间,不符合我们既定的想法,还需要进一步的优化。

五、方案演进:优化同步策略

从上述代码实际运行的现象上看,生产者释放锁,并且唤醒消费者的时候,线程锁并没有交接给消费者,反而又被生产者的预读任务给抢了过来。

5.1 锁的公平性

从运行的结果来看,锁的释放和获取并没有符合预期,我们有理由怀疑在线程同步的过程中,有一些线程争抢资源和锁的情况发生,通过查阅java多线程相关资料,我们了解到一个概念:线程锁的公平性。

线程锁的公平性是指多个线程竞争锁时,锁的获取是否按照请求顺序进行分配,同步锁的类型简单来讲可以分为如下两类:

非公平锁

  • 特点:允许线程"插队",新请求锁的线程可能比等待队列中的线程先获取锁

  • 优点:更高的吞吐量,减少线程切换开销

  • 缺点:可能导致某些线程长时间等待(饥饿)

公平锁

  • 特点:严格按照FIFO(先进先出)顺序分配锁

  • 优点:避免线程饥饿,行为更可预测

  • 缺点:性能较低,因为需要维护队列顺序

Java内置的synchronized锁就是是非公平锁,wait和notify 也是基于synchronized来实现的。

在上述的示例代码中,我们使用的就是非公平锁,导致线程之间出现资源抢占,发生了不符合预期的情况。

适合使用公平锁的场景

  • 严格的顺序要求:当线程执行顺序对业务逻辑至关重要时

  • 避免饥饿:当需要确保所有线程都有机会执行时

虽然公平锁会牺牲同步性能,但是在当前业务中,我们是希望消费者能够尽快的获得数据的,所以我们应该选择使用公平锁来实现同步,在java中要实现公平锁,就必须使用ReentrantLock,如果要实现公平锁的等待,就要使用Condition,我们使用ReentrantLock和Condition来修改代码,对代码中与同步锁相关的逻辑进行重构。

公平锁的使用方法

使用公平锁时,无法像使用synchronized关键字一样直接加在方法头上,而是需要手动获得锁和释放锁,示例代码如下:

private final ReentrantLock mLock = new ReentrantLock(true); // 公平锁

public void test(){
    //业务代码执行前获得锁
    mLock.lock();  
    try {
        //实际执行的业务代码
        TLog.d(TAG, "此处执行代码");  
    } finally {  
        //业务代码执行完成后释放锁
        mLock.unlock();  
    }  
}

如果需要手动使线程处于等待状态,则触发等待和唤醒的示例代码如下:

private final ReentrantLock mLock = new ReentrantLock(true); // 公平锁
private final Condition mCondition \= mLock.newCondition(); // 条件变量

/**  
 * 唤醒示例
 * 
 */
public void signal(){  
    if (mLock.tryLock()) {  
        try {  
            mCondition.signal();  
        } finally {  
            mLock.unlock();  
        }  
    }  
}

/**  
 * 等待示例  
 * 等待五秒钟后主动释放
 */  
public void test(){  
    mLock.lock();  
    try {  
        TLog.d(TAG, "实际业务代码执行");  
        mCondition.await(5, TimeUnit.SECONDS);  
        TLog.d(TAG, "实际业务代码执行");  
    } finally {  
        mLock.unlock();  
    }  
}

这里的疑问,为什么lock并且await的时候,其他线程依然可以获得锁并发起signal?

是因为condition.await() 的原子操作,当线程调用 condition.await() 时,会自动释放锁,然后进入等待状态,这是原子性操作,保证在进入等待状态前一定会释放锁。

其他线程可以获取锁,是因为原线程已经在 condition.await() 时释放了锁,其他线程在调用 condition.signal() 时必须持有锁,当等待中的线程被 condition.signal() 唤醒后,会重新尝试获取锁,获取锁成功后才会从 condition.await() 方法返回。

5.2 公平锁代码编写

代码如下

    import java.io.*;
    import java.util.HashMap;
    import java.util.Map;
    import java.util.concurrent.ConcurrentHashMap;
    import java.util.concurrent.TimeUnit;
    import java.util.concurrent.atomic.AtomicInteger;
    import java.util.concurrent.locks.Condition;
    import java.util.concurrent.locks.ReentrantLock;
    
    import static java.lang.Thread.sleep;
    
    
    public class Main {
    
        public static class ResponseBody {
            InputStream stream;
    
            public ResponseBody(InputStream stream){
                this.stream = stream;
            }
    
            public InputStream byteStream(){
                return stream;
            }
        }
    
    
        public static class SyncLoadResponseBean {
    
            private static final String TAG = "SyncLoadResponseBean";
    
            // 状态常量定义
            public static final int INIT = 1;  // 初始状态
            public static final int READY = 2; // 数据准备就绪
            public static final int OFFER = 3; // 数据已提供
            public static finaint DROP = 4;  // 数据已丢弃
    
            private final String mRequestUrl;
            private final ConcurrentHashMap<String, SyncLoadResponseBean> mSyncLoadCache;
            private final ReentrantLock mLock = new ReentrantLock(true); // 公平锁
            private final Condition mCondition = mLock.newCondition();   // 条件变量
            private final AtomicInteger mStatus = new AtomicInteger(INIT); // 状态机
    
            // 数据存储相关
            private Map<String, String> mResponseHeader;
            private ByteArrayOutputStream mBufferStream;
            private InputStream mNetworkStream;
            private long mResponseTime;
    
    
            public SyncLoadResponseBean(String requestUrl, ConcurrentHashMap<String, SyncLoadResponseBean> syncLoadCache){
                mRequestUrl = requestUrl;
                mSyncLoadCache = syncLoadCache;
                mStatus.set(INIT);
            }
    
            public boolean before(int status){
                return mStatus.get() < status;
            }
    
            public boolean during(int status){
                return mStatus.get() == status;
            }
    
            public boolean after(int status){
                return mStatus.get() >= status;
            }
    
            /**
             * 唤醒所有等待线程
             * 使用 tryLock 避免长时间阻塞
             */
            public void signalAll(){
                if (mLock.tryLock()) {
                    try {
                        mCondition.signalAll();
                    } finally {
                        mLock.unlock();
                    }
                }
            }
    
            /**
             * 保存响应数据并预处理
             * 网络线程会在得到响应后调用该方法,保存数据
             */
            public void saveResponse(ResponseBody responseBody, Map<String, String> responseHeader){
                streamReady(responseBody, responseHeader);
                preReadStream();
            }
    
            /**
             * 准备数据流
             */
            private void streamReady(ResponseBody responseBody, Map<String, String> responseHeader){
                mLock.lock();
                try {
                    TLog.d(TAG, "并行加载 响应保存");
                    mResponseTime = System.currentTimeMillis();
                    mResponseHeader = responseHeader;
                    mBufferStream = new ByteArrayOutputStream();
                    if (responseBody != null) {
                        mNetworkStream = responseBody.byteStream();
                        // 根据流是否有效设置状态
                        if (mNetworkStream != null) {
                            mStatus.set(READY);
                        } else {
                            drop();
                        }
                    } else {
                        drop();
                    }
                } finally {
                    TLog.d(TAG, "并行加载 保存完成 通知消费者");
                    mCondition.signalAll();
                    mLock.unlock();
                }
            }
    
    
            private void preReadStream(){
                byte[] buffer = new byte[4096];
                int num = 0;
                try {
                    while (during(READY)) {
                        mLock.lock();
                        try {
                            //双重校验锁
                            if (during(READY)) {
                                // 读取网络流数据
                                int bytesRead = mNetworkStream.read(buffer);
                                if (bytesRead == -1) {
                                    TLog.d(TAG, "并行加载 预读缓存 完成 " + bytesRead);
                                    closeStream(mNetworkStream);
                                    mNetworkStream = null;
                                    break;
                                }
                                TLog.d(TAG, "并行加载 预读缓存 " + bytesRead);
                                mBufferStream.write(buffer, 0, bytesRead);
                            }
                        } finally {
                            num++;
                            TLog.d(TAG, "并行加载 预读缓存 第" + num + "次通知消费者");
                            mCondition.signalAll();
                            mLock.unlock();
                        }
                    }
                    //已经提供了数据,则打印个日志看下
                    if (after(OFFER)) {
                        TLog.d(TAG, "并行加载 数据流已提供 预读缓存 关闭");
                    }
                } catch (IOException e) {
                    TLog.e(TAG, "并行加载 预读缓存 异常 关闭", e);
                    mLock.lock();
                    try {
                        //在读取的过程中出现了异常,但是这个时候还没有提供数据,就直接drop调
                        if (!after(OFFER)) {
                            drop();
                        }
                    } finally {
                        mLock.unlock();
                    }
                }
            }
    
            /**
             * 获取桥接流
             * 浏览器线程调用该方法获取数据流
             */
            public InputStream getBridgedStream(){
                mLock.lock();
                TLog.d(TAG, "并行加载 查找流数据");
                try {
                    if (before(READY)) {
                        long time1 = System.currentTimeMillis();
                        TLog.d(TAG, "并行加载 查找流数据 进入等待状态");
                        mCondition.await(5, TimeUnit.SECONDS);
                        long time2 = System.currentTimeMillis();
                        TLog.d(TAG, "并行加载 查找流数据 被唤醒 等待时长:" + (time2 - time1));
                    }
                    // 等待结束,再确认一次状态是否可用
                    if (before(READY)) {
                        TLog.d(TAG, "并行加载 查找流数据 依旧没有可用数据 返回空流");
                        drop();
                        return null;
                    } elseif (after(OFFER)) {
                        TLog.d(TAG, "并行加载 查找流数据 数据已被废弃或者被他人被使用 返回空流");
                        return null;
                    } elseif (isTimeOut()) {
                        TLog.d(TAG, "并行加载 查找流数据 数据超时 返回空流");
                        drop();
                        return null;
                    } else {
                        if (mNetworkStream != null && mBufferStream != null) {
                            mStatus.set(OFFER);
                            // 创建新的桥接流实例,包含已缓存数据和剩余网络流
                            ByteArrayInputStream cachedStream = new ByteArrayInputStream(mBufferStream.toByteArray());
                            TLog.d(TAG, "并行加载 查找流数据 返回桥接流");
                            returnnew SequenceInputStream(cachedStream, mNetworkStream);
                        } elseif (mNetworkStream != null) {
                            mStatus.set(OFFER);
                            TLog.d(TAG, "并行加载 查找流数据 返回网络流");
                            return mNetworkStream;
                        } elseif (mBufferStream != null) {
                            mStatus.set(OFFER);
                            // 创建新的桥接流实例,包含已缓存数据和剩余网络流
                            TLog.d(TAG, "并行加载 查找流数据 返回缓存流");
                            returnnew ByteArrayInputStream(mBufferStream.toByteArray());
                        } else {
                            drop();
                            TLog.d(TAG, "并行加载 查找流数据 返回空流");
                            return null;
                        }
                    }
                } catch (Exception e) {
                    TLog.e("TAG", "Create bridged stream failed", e);
                    drop();
                    return null;
                } finally {
                    mLock.unlock();
                }
            }
    
            /**
             * 获取响应头(线程安全)
             */
            public Map<String, String> getResponseHeader(){
                mLock.lock();
                try {
                    //如果请求里面不带跨域标识,则带上跨域标识
                    if (mResponseHeader != null && !mResponseHeader.containsKey("Access-Control-Allow-Origin")) {
                        mResponseHeader.put("Access-Control-Allow-Origin", "*");
                    }
                    return mResponseHeader;
                } finally {
                    mLock.unlock();
                }
            }
    
    
            /**
             * 统一关闭流资源操作
             */
            private void closeStream(Closeable stream){
                if (stream != null) {
                    try {
                        stream.close();
                    } catch (Exception e) {
                        TLog.e(TAG, "关闭流失败", e);
                    }
                }
            }
    
    
            /**
             * 判断数据有没有超时
             */
            private boolean isTimeOut(){
                return Math.abs(mResponseTime - System.currentTimeMillis()) > 5000;
            }
    
            /**
             * 丢弃数据
             */
            public void drop(){
                mLock.lock();
                try {
                    mStatus.set(DROP);
                    mResponseHeader = null;
                    mResponseTime = 0;
                    closeStream(mBufferStream);
                    closeStream(mNetworkStream);
                    mBufferStream = null;
                    mNetworkStream = null;
                    mSyncLoadCache.remove(mRequestUrl);
                    TLog.d(TAG, "并行加速 缓存数据丢弃");
                } finally {
                    mLock.unlock();
                }
            }
    
        }
    
    
        /**
         * 主方法,程序入口
         */
        public static void main(String[] args){
    
            SyncLoadResponseBean syncLoadResponseBean = new SyncLoadResponseBean("https://www.baidu.com", new ConcurrentHashMap<>());
    
            //模拟生产者线程
            new Thread(() -> {
                try {
                    System.out.println("生产者启动");
                    //模拟网络建联
                    sleep(200);
                    //模拟网络资源返回
                    File file = new File("./xxxx.txt");
                    ResponseBody responseBody = new ResponseBody(new FileInputStream(file));
                    //模拟响应
                    syncLoadResponseBean.saveResponse(responseBody, new HashMap<>());
                } catch (FileNotFoundException e) {
                    thrownew RuntimeException(e);
                } catch (InterruptedException e) {
                    thrownew RuntimeException(e);
                } finally {
                    System.out.println("生产线程工作完成,唤醒等待中的消费者");
                    syncLoadResponseBean.signalAll();
                }
            }).start();
    
            //模拟消费者线程1
            new Thread(() -> {
                System.out.println("消费者启动");
                //模拟读取
                InputStream stream = syncLoadResponseBean.getBridgedStream();
                System.out.println("消费者 " + stream);
            }).start();
    
        }
    
    }

5.3 公平锁代码解读

  • 使用ReentrantLock实现公平锁

  • 使用Condition实现线程等待

    生产者启动
    消费者启动
    并行加载 查找流数据
    并行加载 查找流数据 进入等待状态
    并行加载 响应保存
    并行加载 保存完成 通知消费者
    并行加载 查找流数据 被唤醒 等待时长:203
    并行加载 查找流数据 返回桥接流
    消费者 java.io.SequenceInputStream@4e8de630
    并行加载 预读缓存 第1次通知消费者
    并行加载 数据流已提供 预读缓存 关闭
    生产线程工作完成,唤醒等待中的消费者
    进程已结束,退出代码为 0
    

可以看到在建联完成,保存响应的时候,首次通知消费者,消费者就能够准确的被唤醒。

再通过调整测试代码sleep的时间模拟数据正在被预读中,消费者打断预读的场景,消费者依然能够被准确的唤醒。

    生产者启动
    消费者启动
    并行加载 响应保存
    并行加载 保存完成 通知消费者
    并行加载 预读缓存 4096
    并行加载 预读缓存 第1次通知消费者
    并行加载 查找流数据
    并行加载 查找流数据 返回桥接流
    并行加载 预读缓存 第2次通知消费者
    并行加载 数据流已提供 预读缓存 关闭
    生产线程工作完成,唤醒等待中的消费者
    消费者 java.io.SequenceInputStream@5a40ed62
    进程已结束,退出代码为 0

整体代码运行符合预期。

5.4 桥接流代码解读

在上述代码中,我们多次提到桥接流的概念,桥接流顾名思义,就是将多个数据流接起来,可以让读流的程序按照顺序先读第一个流,读完第一个再读第二个流,以此类推,这里我们直接使用了java官方的一个类来实现流的桥接。

SequenceInputStream是 Java I/O 类库中的一个输入流类,它允许你将多个输入流按顺序连接起来,形成一个逻辑上的连续输入流。

SequenceInputStream的主要特点

  • 顺序读取:它会按顺序读取多个输入流,先读取第一个流的所有内容,然后自动切换到第二个流,依此类推。

  • 流合并:将多个独立的输入流合并为一个连续的输入流。

  • 自动切换:当一个流读取完毕时,会自动切换到下一个流。

  • 自动关闭:SequenceInputStream 关闭时会自动关闭所有包含的输入流

SequenceInputStream的使用场景

当有多个文件需要按顺序读取,但希望像读取单个文件一样处理时,合并多个来源的数据流,需要将多个输入源串联起来处理,我们可以很方便的把一个或者多个流串联起来按顺序读取,通过这个特性,我们就可以实现缓存流和网络流的无缝桥接。

六、方案对比与总结

公平锁替代非公平锁

  • 问题:synchronized(非公平锁)导致生产者可能重新抢占锁。

  • 解决方案:用ReentrantLock(公平锁)+Condition实现顺序获取。

桥接流技术

  • 用SequenceInputStream合并已缓冲流(ByteArrayInputStream)与网络流。

  • 实现无缝衔接,无需等待全量数据。

半缓冲机制

  • 并行任务每次读取 4096 字节后释放锁,允许消费者打断。

  • 可以平衡预读效率与内存占用。

七、写在最后

回顾上述方案,采用同步锁实现缓冲过程的打断,使用SequenceInputStream实现桥接流,预读过程的打断是通过流读取循环体内的公平锁来实现的,相比最初的循环等待,数据超时废弃的模式,新方案实现了网络流和缓存流的无缝切换,整合了并行请求的资源,充分利用了页面启动时间。

❌