普通视图

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

ofd文件

作者 林太白
2026年1月7日 15:24

ofd文件处理预览

文章内容所涉及的源码地址,求朋友们给个star啊

::: tip

个人网站 ([https://nexuslin.github.io/](https://nexuslin.github.io/))

文章内容所涉及的源码地址,求朋友们给个star啊


同路之人,幸得君顾,盼得君之一赞!

与君同行,愿得青眼相加!

你的star

如春风化雨,润物无声;

如山间清泉,滋润心田;

如长河落日,映照初心;

亦如暗夜明灯,照亮前路;

是吾辈前行之明灯,亦是我坚持的动力!

愿君前程似锦,代码如诗,人生如画!

【GIthub地址】([https://github.com/lintaibai/TG](https://gitee.com/lintaibai/TG))

【Gitee地址】([https://gitee.com/lintaibai/TG](https://gitee.com/lintaibai/TG))


:::

OFD文件是什么

本质上就是一种国产的压缩文件

2016年成为国家标准(GB/T 33190-2016// 国家标准网站
https://openstd.samr.gov.cn/bzgk/gb/newGbInfo?hcno=3AF6682D939116B6F5EED53D01A9DB5D

OFD(Open Fixed-layout Document)开放式版式文件

中国自主研发的一种电子文档格式‌

中国国家标准版的电子文件格式,类似于 PDF

具有以下特点:

  • 基于XML和ZIP技术
  • 支持数字签名
  • 支持版式固定
  • 支持国产密码算法
  • 支持长期保存格式

OFD 文件结构

OFD 文件本质上是一个 ZIP 压缩包,包含以下结构

document.ofd
├── OFD.xml          // 文档根文件
├── Doc_0/
│   ├── Doc_0/       // 文档目录
│   │   ├── Document.xml
│   │   └── Pages/
│   │       └── Page_0/
│   │           ├── Page.xml
│   │           └── Res/        // 资源目录
│   │               ├── image/
│   │               └── font/
└── Public.xml       // 公共资源

相关库的认识

想要解决ofd文件的预览,我们必须认识相关的两个依赖库

jszip 
JSZipUtils 

这两个库存的作用和认识

jszip的认识使用

认识

JSZip 是一个用于创建、读取和编辑 .zip 文件的JavaScript库。它可以在浏览器和Node.js环境中使用。

主要功能:
  • 创建新的 ZIP 文件
  • 读取现有的 ZIP 文件
  • 向 ZIP 文件中添加或删除文件
  • 生成 ZIP 文件并下载
使用示例
// 创建一个新的 ZIP 文件
var zip = new JSZip();

// 添加一个文本文件
zip.file("hello.txt", "Hello World\n");

// 添加一个文件夹和其中的文件
zip.folder("images").file("smile.gif", "base64数据", {base64: true});

// 生成 ZIP 文件
zip.generateAsync({type:"blob"})
.then(function(content) {
    // 在浏览器中下载
    saveAs(content, "example.zip");
});
常见应用场景:
  • 在前端打包多个文件供用户下载
  • 动态生成包含多个文件的压缩包
  • 处理上传的 ZIP 文件内容

JSZipUtils 的认识使用

认识

JSZipUtils 是 JSZip 的一个辅助工具库,主要用于处理二进制数据,特别是在获取远程文件时非常有用。

主要功能:
  • 获取二进制数据
  • 处理跨域请求
  • 提供便捷的文件获取方法
基本使用示例:
// 获取远程二进制数据
JSZipUtils.getBinaryContent("path/to/file.zip", function(err, data) {
    if(err) {
        throw err;
    }
    
    // 使用获取到的数据创建 JSZip 对象
    JSZip.loadAsync(data)
    .then(function(zip) {
        // 处理 zip 文件内容
        return zip.file("hello.txt").async("string");
    })
    .then(function(text) {
        console.log(text);
    });
});
常见应用场景:
  • 下载并处理远程 ZIP 文件
  • 获取二进制文件内容
  • 处理跨域的二进制数据请求

为什么 JSZipUtils 被废弃

JSZipUtils 被废弃的主要原因有:

  1. 现代浏览器原生支持更好:fetch API 已经成为现代浏览器的标准,提供了更强大和灵活的功能
  2. 维护问题:JSZipUtils 已经很久没有更新,可能存在安全漏洞
  3. 功能冗余:fetch API 可以完全覆盖 JSZipUtils 的功能,而且提供更多特性
使用 fetch API 替代的示例
原来的 JSZipUtils 写法:
JSZipUtils.getBinaryContent("path/to/file.zip", function(err, data) {
    if(err) {
        throw err;
    }
    JSZip.loadAsync(data)
    .then(function(zip) {
        // 处理 zip 文件
    });
});
使用 fetch API 的现代写法:
fetch("path/to/file.zip")
    .then(response => {
        if (!response.ok) {
            throw new Error("Network response was not ok");
        }
        return response.arrayBuffer();
    })
    .then(data => {
        return JSZip.loadAsync(data);
    })
    .then(zip => {
        // 处理 zip 文件
    })
    .catch(error => {
        console.error("Error:", error);
    });
fetch API 的优势
  1. 更好的错误处理:
fetch(url)
    .then(response => {
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.arrayBuffer();
    })
  1. 支持更多数据类型:
// 获取不同类型的数据
fetch(url)
    .then(response => response.arrayBuffer())  // 二进制数据
    .then(response => response.blob())        // Blob 对象
    .then(response => response.text())        // 文本数据
    .then(response => response.json())        // JSON 数据
  1. 支持请求配置:
fetch(url, {
    method: 'GET',
    headers: {
        'Authorization': 'Bearer token',
        'Content-Type': 'application/octet-stream'
    },
    mode: 'cors',
    credentials: 'include'
})
  1. 支持异步/等待语法:
async function loadZip(url) {
    try {
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error('Network response was not ok');
        }
        const data = await response.arrayBuffer();
        const zip = await JSZip.loadAsync(data);
        return zip;
    } catch (error) {
        console.error('Error:', error);
        throw error;
    }
}
完整的迁移示例

假设我们要下载一个 ZIP 文件,解压后获取其中的文件内容:

// 使用 async/await 的现代写法
async function processZipFile(url) {
    try {
        // 1. 获取文件
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        // 2. 转换为二进制数据
        const arrayBuffer = await response.arrayBuffer();
        
        // 3. 加载到 JSZip
        const zip = await JSZip.loadAsync(arrayBuffer);
        
        // 4. 处理文件内容
        const files = Object.keys(zip.files);
        for (const filename of files) {
            if (!zip.files[filename].dir) {
                const content = await zip.file(filename).async('string');
                console.log(`${filename}: ${content}`);
            }
        }
        
        return zip;
    } catch (error) {
        console.error('Error processing zip file:', error);
        throw error;
    }
}

// 使用示例
processZipFile('path/to/file.zip')
    .then(zip => {
        console.log('Zip file processed successfully');
    })
    .catch(error => {
        console.error('Failed to process zip file:', error);
    });
兼容性考虑

如果需要支持较老的浏览器,可以添加 polyfill:

// 添加 fetch polyfill(如果需要)
import 'whatwg-fetch';

// 或者使用条件加载
if (!window.fetch) {
    // 加载 fetch polyfill
}
总结

迁移到 fetch API 的好处:

  1. 更现代的 API 设计
  2. 更好的错误处理机制
  3. 更灵活的数据处理能力
  4. 更好的性能和浏览器支持
  5. 更少的依赖,减少包大小
  6. 更好的 TypeScript 支持

建议在新的项目中直接使用 fetch API,在现有项目中逐步将 JSZipUtils 的调用替换为 fetch API 的实现。

JSZipUtils和jszip的配合使用

通常在实际应用中,这两个库会配合使用:

// 下载远程文件并创建新的 ZIP 包
JSZipUtils.getBinaryContent("path/to/image.jpg", function(err, data) {
    if(err) throw err;
    
    var zip = new JSZip();
    zip.file("image.jpg", data, {binary: true});
    
    zip.generateAsync({type:"blob"})
    .then(function(content) {
        saveAs(content, "new.zip");
    });
});
注意事项
  1. 在浏览器中使用时,如果处理大文件,要注意内存使用情况
  2. 处理远程文件时要注意跨域问题
  3. JSZipUtils 已经被标记为废弃,推荐使用原生的 fetch API 替代
  4. 现代项目中,也可以考虑使用更新的替代方案,如 JSZip 的最新版本配合 fetch API

这两个库在前端处理文件压缩和解压缩任务时非常实用,特别是在需要动态处理文件内容的场景中。

vue3之中预览ofd.js文件

接下来我们就简单实现一下ofd.js文件的预览,我们的想法是在vue3之中替代掉老旧的JSZipUtils库,当然,和之前一样我们还是以实现功能然后进行优化为主

因为ofd文件的本质上是一种压缩包,所以我们需要先解压 OFD 文件,然后解析其中的内容。

先来看看标准的ofd文件是什么样子的

{
    "name": "OFD.xml",
    "dir": false,
    "date": "2020-08-22T16:21:20.000Z",
    "comment": null,
    "unixPermissions": null,
    "dosPermissions": 32,
    "_data": {
        "compressedSize": 446,
        "uncompressedSize": 1269,
        "crc32": -2125441896,
        "compression": {
            "magic": "\b\u0000"
        },
        "compressedContent": {
            "0": 157,
            "1": 84,
            "2": 209,
            "3": 110,
             xxx
        }
    },
    "_dataBinary": true,
    "options": {
        "compression": null,
        "compressionOptions": null
    },
    "unsafeOriginalName": "OFD.xml"
}

方式1-使用easyofd的方式预览

推荐的预览方式

安装相关依赖
pnpm i jszip x2js jb2 opentype.js easyofd
预览文件
<script setup>
import EasyOFD from "easyofd";
import { onMounted } from 'vue'

onMounted(() => {
  let yourElement=document.getElementById("1111111");
  let ofd=new EasyOFD('myofdID', yourElement);
})

</script>

<template>
      <div id="1111111"> </div>

</template>

<style >
 .OfdButton{
      padding: 10px 20px;
      background-color: #007bff;
      color: #fff;
      border: none;
      border-radius: 5px;
      cursor: pointer;
      margin-right: 10px;
    }
</style>

方式2-使用ofd的方式预览

相关源码地址
https://github.com/DLTech21/ofd.js
安装依赖

这里我们先安装必须的依赖,后面剔除

pnpm install jszip
pnpm install jszip-utils(后面剔除)
pnpm i @lapo/asn1js
pnpm i js-sha1
pnpm i ofd-xml-parser js-md5 jsrsasign jsrsasign-util sm-crypto

安装成功以后我们首要的就是将ofd之前的版本兼容到vue3

这里需要我们main.TS之中设置一下全局

// 设置全局变量
if (typeof window !== 'undefined') {
  window.global = window;
}
文件之中使用
<template>
  <el-container style="width:100vw; height: 100vh;">
    <el-header style="background:#F5F5F5;display: flex; height: 40px; border: 1px solid #e8e8e8; align-items: center;">
      <div class="upload-icon" @click="uploadFile">
        <div class="upload-icon">打开OFD</div>
        <font-awesome-icon icon="cloud-upload-alt"/>
        <input type="file" ref="fileRef" class="hidden" accept=".ofd" @change="fileChanged">
      </div>

      <div class="upload-icon" @click="uploadPdfFile">
        <div class="upload-icon">PDF2OFD</div>
        <font-awesome-icon icon="cloud-upload-alt"/>
        <input type="file" ref="pdfFileRef" class="hidden" accept=".pdf" @change="pdfFileChanged">
      </div>

      <div style="display: flex;align-items: center" v-if="ofdObj">
        <div class="upload-icon" style="margin-left: 10px" @click="downPdf" v-if="ofdBase64">
          下载PDF
          <font-awesome-icon icon="download"/>
        </div>

        <div class="scale-icon" style="margin-left: 10px" @click="plus">
          <font-awesome-icon icon="search-plus"/>
        </div>

        <div class="scale-icon" @click="minus">
          <font-awesome-icon icon="search-minus" />
        </div>
        <div class="scale-icon">
          <font-awesome-icon icon="step-backward" @click="firstPage"/>
        </div>

        <div class="scale-icon" style="font-size: 18px" @click="prePage">
          <font-awesome-icon icon="caret-left"/>
        </div>

        <div class="scale-icon">
          {{pageIndex}}/{{pageCount}}
        </div>

        <div class="scale-icon" style="font-size: 18px" @click="nextPage">
          <font-awesome-icon icon="caret-right"/>
        </div>
        <div class="scale-icon" @click="lastPage">
          <font-awesome-icon icon="step-forward"/>
        </div>
      </div>
    </el-header>

    <el-main style="height: auto;background: #808080;;padding: 0" v-loading="loading">
      <div id="leftMenu" class="left-section">
        <div class="text-icon" @click="demo(1)">
          <p>电子发票</p>
        </div>
        <div class="text-icon" @click="demo(2)">
          <p>电子公文</p>
        </div>
        <div class="text-icon" @click="demo(3)">
          <p>骑缝章</p>
        </div>
        <div class="text-icon" @click="demo(4)">
          <p>多页文档</p>
        </div>
      </div>
      <div class="main-section" id="content" ref="contentDivRef" @mousewheel="scrool"></div>
    </el-main>

    <div class="SealContainer" id="sealInfoDiv" hidden="hidden" ref="sealInfoDivRef">
      <div class="SealContainer mask" @click="closeSealInfoDialog"></div>
      <div class="SealContainer-layout">
        <div class="SealContainer-content">
          <p class="content-title">签章信息</p>
          <div class="subcontent">
            <span class="title">签章人</span>
            <span class="value" id="spSigner">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">签章提供者</span>
            <span class="value" id="spProvider">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">原文摘要值</span>
            <span class="value" id="spHashedValue" @click="showMore('原文摘要值', 'spHashedValue')" style="cursor: pointer">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">签名值</span>
            <span class="value" id="spSignedValue" @click="showMore('签名值', 'spSignedValue')" style="cursor: pointer">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">签名算法</span>
            <span class="value" id="spSignMethod">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">版本号</span>
            <span class="value" id="spVersion">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">验签结果</span>
            <span class="value" id="VerifyRet">[无效的签章结构]</span>
          </div>

          <p class="content-title">印章信息</p>
          <div class="subcontent">
            <span class="title">印章标识</span>
            <span class="value" id="spSealID">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">印章名称</span>
            <span class="value" id="spSealName">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">印章类型</span>
            <span class="value" id="spSealType">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">有效时间</span>
            <span class="value" id="spSealAuthTime">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">制章日期</span>
            <span class="value" id="spSealMakeTime">[无效的签章结构]</span>
          </div>
          <div class="subcontent">
            <span class="title">印章版本</span>
            <span class="value" id="spSealVersion">[无效的签章结构]</span>
          </div>
        </div>
        <input style="position:absolute;right:1%;top:1%;" type="button" name="" id="" value="X" @click="closeSealInfoDialog()"/>
      </div>
    </div>

    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible">
      <span style="text-align: left">{{dialogValue}}</span>
      <div slot="footer" class="dialog-footer">
        <el-button type="primary" @click="dialogVisible = false">确 定</el-button>
      </div>
    </el-dialog>
  </el-container>
</template>

<script setup>
import { ref, onMounted, reactive } from 'vue'
import { parseOfdDocument, renderOfd, renderOfdByScale, digestCheck, getPageScale, setPageScale } from "@/utils/ofd/ofd.js"
import JSZipUtils from "jszip-utils"
import { ElMessage, ElMessageBox } from 'element-plus'
// 响应式数据
const fileRef = ref(null)
const pdfFileRef = ref(null)
const contentDivRef = ref(null)
const sealInfoDivRef = ref(null)

const state = reactive({
  pdfFile: null,
  ofdBase64: null,
  loading: false,
  pageIndex: 1,
  pageCount: 0,
  scale: 0,
  dialogTitle: null,
  dialogValue: null,
  dialogVisible: false,
  ofdObj: null,
  screenWidth: document.body.clientWidth
})

// 方法
const uploadFile = () => {
  state.pdfFile = null
  fileRef.value.click()
}

const fileChanged = (e) => {
  const file = e.target.files[0]
  const ext = file.name.replace(/.+\./, "")
  
  if (["ofd"].indexOf(ext) === -1) {
    ElMessageBox.alert('仅支持ofd类型', 'error', {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
    return
  }
  
  if (file.size > 100 * 1024 * 1024) {
    ElMessageBox.alert('文件大小需 < 100M', 'error', {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
    return
  }
  
  const reader = new FileReader()
  reader.readAsDataURL(file)
  reader.onload = (e) => {
    state.ofdBase64 = e.target.result.split(',')[1]
  }
  
  getOfdDocumentObj(file, state.screenWidth)
  fileRef.value.value = null
}

const uploadPdfFile = () => {
  state.pdfFile = null
  pdfFileRef.value.click()
}

const pdfFileChanged = (e) => {
  const file = e.target.files[0]
  const ext = file.name.replace(/.+\./, "")
  
  if (["pdf"].indexOf(ext) === -1) {
    ElMessageBox.alert('仅支持pdf类型', 'error', {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
    return
  }
  
  if (file.size > 100 * 1024 * 1024) {
    ElMessageBox.alert('文件大小需 < 100M', 'error', {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
    return
  }
  
  const reader = new FileReader()
  reader.readAsDataURL(file)
  reader.onload = (e) => {
    const pdfBase64 = e.target.result.split(',')[1]
    downOfd(pdfBase64)
  }
  
  pdfFileRef.value.value = null
}

const downOfd = (pdfBase64) => {
  state.loading = true
  axios({
    method: "post",
    url: "https://51shouzu.xyz/api/ofd/convertOfd",
    data: {
      pdfBase64,
    }
  }).then(response => {
    state.loading = false
    const binary = atob(response.data.data.replace(/\s/g, ''))
    const len = binary.length
    const buffer = new ArrayBuffer(len)
    const view = new Uint8Array(buffer)
    for (let i = 0; i < len; i++) {
      view[i] = binary.charCodeAt(i)
    }
    const blob = new Blob([view], null)
    const url = URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.style.display = 'none'
    link.href = url
    link.setAttribute('download', 'ofd.ofd')
    document.body.appendChild(link)
    link.click()
  }).catch(error => {
    console.log(error, "error")
    ElMessageBox.alert('PDF打开失败', error, {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
  })
}

const downPdf = () => {
  state.loading = true
  axios({
    method: "post",
    url: "https://51shouzu.xyz/api/ofd/convertPdf",
    data: {
      ofdBase64: state.ofdBase64
    }
  }).then(response => {
    state.loading = false
    const binary = atob(response.data.data.replace(/\s/g, ''))
    const len = binary.length
    const buffer = new ArrayBuffer(len)
    const view = new Uint8Array(buffer)
    for (let i = 0; i < len; i++) {
      view[i] = binary.charCodeAt(i)
    }
    const blob = new Blob([view], { type: "application/pdf" })
    const url = URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.style.display = 'none'
    link.href = url
    link.setAttribute('download', 'ofd.pdf')
    document.body.appendChild(link)
    link.click()
  }).catch(error => {
    console.log(error, "error")
    ElMessageBox.alert('OFD打开失败', error, {
      confirmButtonText: '确定',
      callback: action => {
        ElMessage({
          type: 'info',
          message: `action: ${action}`
        })
      }
    })
  })
}

const plus = () => {
  setPageScale(++state.scale)
  const divs = renderOfdByScale(state.ofdObj)
  displayOfdDiv(divs)
}

const minus = () => {
  setPageScale(--state.scale)
  const divs = renderOfdByScale(state.ofdObj)
  displayOfdDiv(divs)
}

const prePage = () => {
  const contentDiv = document.getElementById('content')
  const ele = contentDiv.children.item(state.pageIndex - 2)
  ele?.scrollIntoView(true)
  if (ele) state.pageIndex = state.pageIndex - 1
}

const firstPage = () => {
  const contentDiv = document.getElementById('content')
  const ele = contentDiv.firstElementChild
  ele?.scrollIntoView(true)
  if (ele) state.pageIndex = 1
}

const nextPage = () => {
  const contentDiv = document.getElementById('content')
  const ele = contentDiv.children.item(state.pageIndex)
  ele?.scrollIntoView(true)
  if (ele) ++state.pageIndex
}

const lastPage = () => {
  const contentDiv = document.getElementById('content')
  const ele = contentDiv.lastElementChild
  ele?.scrollIntoView(true)
  if (ele) state.pageIndex = contentDiv.childElementCount
}

const demo = (value) => {
  let ofdFile = null
  switch (value) {
    case 1:
      ofdFile = '999.ofd'
      break
    case 2:
      ofdFile = 'n.ofd'
      break
    case 3:
      ofdFile = 'h.ofd'
      break
    case 4:
      ofdFile = '2.ofd'
      break
  }
  JSZipUtils.getBinaryContent(ofdFile, (err, data) => {
    if (err) {
      console.log("JSZipUtils===1");
      console.log(err)
    } else {
      const base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(data)))
      console.log("JSZipUtils===2");
      state.ofdBase64 = base64String
    }
  })
  getOfdDocumentObj(ofdFile, state.screenWidth)
}

const getOfdDocumentObj = (file, screenWidth) => {
  const t = new Date().getTime()
  state.loading = true
  
  parseOfdDocument({
    ofd: file,
    success(res) {
      console.log(res)
      const t1 = new Date().getTime()
      console.log('解析ofd', t1 - t)
      state.ofdObj = res[0]
      state.pageCount = res[0].pages.length
      const divs = renderOfd(screenWidth, res[0])
      const t2 = new Date().getTime()
      console.log('xml转svg', t2 - t1)
      displayOfdDiv(divs)
      const t3 = new Date().getTime()
      console.log('svg渲染到页面', t3 - t2)
      state.loading = false
    },
    fail(error) {
      console.log(error)
      state.loading = false
      ElMessageBox.alert('OFD打开失败', error, {
        confirmButtonText: '确定',
        callback: action => {
          ElMessage({
            type: 'info',
            message: `action: ${action}`
          })
        }
      })
    }
  })
}

const displayOfdDiv = (divs) => {
  state.scale = getPageScale()
  const contentDiv = document.getElementById('content')
  contentDiv.innerHTML = ''
  
  for (const div of divs) {
    contentDiv.appendChild(div)
  }
  
  for (const ele of document.getElementsByName('seal_img_div')) {
    addEventOnSealDiv(ele, JSON.parse(ele.dataset.sesSignature), JSON.parse(ele.dataset.signedInfo))
  }
}

const addEventOnSealDiv = (div, SES_Signature, signedInfo) => {
  try {
    global.HashRet = null
    global.VerifyRet = signedInfo.VerifyRet
    
    div.addEventListener("click", () => {
      document.getElementById('sealInfoDiv').hidden = false
      document.getElementById('sealInfoDiv').setAttribute('style', 'display:flex;align-items: center;justify-content: center;')
      
      if (SES_Signature.realVersion < 4) {
        document.getElementById('spSigner').innerText = SES_Signature.toSign.cert['commonName']
        document.getElementById('spProvider').innerText = signedInfo.Provider['@_ProviderName']
        document.getElementById('spHashedValue').innerText = SES_Signature.toSign.dataHash.replace(/\n/g, '')
        document.getElementById('spSignedValue').innerText = SES_Signature.signature.replace(/\n/g, '')
        document.getElementById('spSignMethod').innerText = SES_Signature.toSign.signatureAlgorithm.replace(/\n/g, '')
        document.getElementById('spSealID').innerText = SES_Signature.toSign.eseal.esealInfo.esID
        document.getElementById('spSealName').innerText = SES_Signature.toSign.eseal.esealInfo.property.name
        document.getElementById('spSealType').innerText = SES_Signature.toSign.eseal.esealInfo.property.type
        document.getElementById('spSealAuthTime').innerText = "从 " + SES_Signature.toSign.eseal.esealInfo.property.validStart + " 到 " + SES_Signature.toSign.eseal.esealInfo.property.validEnd
        document.getElementById('spSealMakeTime').innerText = SES_Signature.toSign.eseal.esealInfo.property.createDate
        document.getElementById('spSealVersion').innerText = SES_Signature.toSign.eseal.esealInfo.header.version
      } else {
        document.getElementById('spSigner').innerText = SES_Signature.cert['commonName']
        document.getElementById('spProvider').innerText = signedInfo.Provider['@_ProviderName']
        document.getElementById('spHashedValue').innerText = SES_Signature.toSign.dataHash.replace(/\n/g, '')
        document.getElementById('spSignedValue').innerText = SES_Signature.signature.replace(/\n/g, '')
        document.getElementById('spSignMethod').innerText = SES_Signature.signatureAlgID.replace(/\n/g, '')
        document.getElementById('spSealID').innerText = SES_Signature.toSign.eseal.esealInfo.esID
        document.getElementById('spSealName').innerText = SES_Signature.toSign.eseal.esealInfo.property.name
        document.getElementById('spSealType').innerText = SES_Signature.toSign.eseal.esealInfo.property.type
        document.getElementById('spSealAuthTime').innerText = "从 " + SES_Signature.toSign.eseal.esealInfo.property.validStart + " 到 " + SES_Signature.toSign.eseal.esealInfo.property.validEnd
        document.getElementById('spSealMakeTime').innerText = SES_Signature.toSign.eseal.esealInfo.property.createDate
        document.getElementById('spSealVersion').innerText = SES_Signature.toSign.eseal.esealInfo.header.version
      }
      
      document.getElementById('spVersion').innerText = SES_Signature.toSign.version
      document.getElementById('VerifyRet').innerText = "文件摘要值后台验证中,请稍等... " + (global.VerifyRet ? "签名值验证成功" : "签名值验证失败")
      
      if (global.HashRet == null || global.HashRet == undefined || Object.keys(global.HashRet).length <= 0) {
        setTimeout(() => {
          const signRetStr = global.VerifyRet ? "签名值验证成功" : "签名值验证失败"
          global.HashRet = digestCheck(global.toBeChecked.get(signedInfo.signatureID))
          const hashRetStr = global.HashRet ? "文件摘要值验证成功" : "文件摘要值验证失败"
          document.getElementById('VerifyRet').innerText = hashRetStr + " " + signRetStr
        }, 1000)
      }
    })
  } catch (e) {
    console.log(e)
  }
  
  if (!global.VerifyRet) {
    div.setAttribute('class', 'gray')
  }
}

const closeSealInfoDialog = () => {
  sealInfoDivRef.value.setAttribute('style', 'display: none')
  document.getElementById('spSigner').innerText = "[无效的签章结构]"
  document.getElementById('spProvider').innerText = "[无效的签章结构]"
  document.getElementById('spHashedValue').innerText = "[无效的签章结构]"
  document.getElementById('spSignedValue').innerText = "[无效的签章结构]"
  document.getElementById('spSignMethod').innerText = "[无效的签章结构]"
  document.getElementById('spSealID').innerText = "[无效的签章结构]"
  document.getElementById('spSealName').innerText = "[无效的签章结构]"
  document.getElementById('spSealType').innerText = "[无效的签章结构]"
  document.getElementById('spSealAuthTime').innerText = "[无效的签章结构]"
  document.getElementById('spSealMakeTime').innerText = "[无效的签章结构]"
  document.getElementById('spSealVersion').innerText = "[无效的签章结构]"
  document.getElementById('spVersion').innerText = "[无效的签章结构]"
  document.getElementById('VerifyRet').innerText = "[无效的签章结构]"
}

const showMore = (title, id) => {
  state.dialogVisible = true
  state.dialogValue = document.getElementById(id).innerText
  state.dialogTitle = title
}

const scrool = () => {
  const scrolled = contentDivRef.value.firstElementChild?.getBoundingClientRect()?.top - 60
  let top = 0
  let index = 0
  
  for (let i = 0; i < contentDivRef.value.childElementCount; i++) {
    top += (Math.abs(contentDivRef.value.children.item(i)?.style.height.replace('px', '')) + Math.abs(contentDivRef.value.children.item(i)?.style.marginBottom.replace('px', '')))
    if (Math.abs(scrolled) < top) {
      index = i
      break
    }
  }
  
  state.pageIndex = index + 1
}

// 生命周期钩子
onMounted(() => {
  state.screenWidth = document.body.clientWidth - document.getElementById('leftMenu').getBoundingClientRect().width
  
  contentDivRef.value.addEventListener('scroll', scrool)
  
  window.onresize = () => {
    state.screenWidth = (document.body.clientWidth - 88)
    const divs = renderOfd(state.screenWidth, state.ofdObj)
    displayOfdDiv(divs)
  }
})
</script>

<style scoped>
/* 保持原有的样式不变 */
.upload-icon {
  display: flex;
  cursor: pointer;
  justify-content: center;
  align-items: center;
  height: 28px;
  padding-left: 10px;
  padding-right: 10px;
  background-color: rgb(59, 95, 232);
  border-radius: 1px;
  border-color: #5867dd;
  font-weight: 500;
  font-size: 12px;
  color: white;
  margin: 1px;
}

.scale-icon {
  display: flex;
  cursor: pointer;
  justify-content: center;
  align-items: center;
  width: 33px;
  height: 28px;
  background-color: #F5F5F5;;
  border-radius: 1px;
  font-weight: 500;
  font-size: 12px;
  color: #333333;
  text-align: center;
  padding: 2px;
}

.scale-icon :active {
  color: rgb(59, 95, 232);
}

.scale-icon :hover {
  color: rgb(59, 95, 232);
}

.text-icon {
  display: flex;
  cursor: pointer;
  justify-content: center;
  align-items: center;
  height: 28px;
  width: 90%;
  background-color: rgb(59, 95, 232);
  border-radius: 1px;
  border-color: #5867dd;
  font-weight: 500;
  font-size: 10px;
  color: white;
  margin-top: 20px;
}

.hidden {
  display: none !important;
}

.SealContainer {
  z-index: 99999;
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
}

.SealContainer .mask {
  background: #000000;
  opacity: 0.3;
}

.content-title {
  font-size: 16px;
  text-align: center;
  border-bottom: 1px solid rgb(59, 95, 232);
  color: rgb(59, 95, 232);
  margin-top: 10px;
}

.SealContainer-content {
  position: relative;
  width: 100%;
  height: 100%;
  overflow-y: auto;
  background: white;
  display: flex;
  flex-direction: column;
  padding: 10px;
  align-items: center;
}

.SealContainer-layout {
  position: relative;
  width: 60%;
  height: 80vh;
  overflow-y: auto;
  background: white;
  z-index: 100;
  display: flex;
  flex-direction: column;
  padding: 10px;
  align-items: center;
}

.subcontent {
  width: 80%;
  display: flex;
  flex-direction: column;
  text-align: left;
  margin-bottom: 10px;
  font-family: simsun;
}

.subcontent .title {
  font-weight: 600;
}

.subcontent .value {
  font-weight: 400;
  -webkit-line-clamp: 1;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.left-section {
  position: fixed;
  width: 88px;
  height: 100%;
  background:#F5F5F5;
  border: 1px solid #e8e8e8;
  align-items: center;
  display: flex;
  flex-direction: column
}

.main-section {
  padding-top: 20px;
  margin-left:88px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: #808080;
  overflow: hidden
}

@media (max-width: 767px) {
  .SealContainer-layout {
    position: relative;
    width: 90%;
    height: 90vh;
    overflow-y: auto;
    background: white;
    z-index: 100;
    display: flex;
    flex-direction: column;
    padding: 10px;
    align-items: center;
  }

  .subcontent {
    width: 95%;
    display: flex;
    flex-direction: column;
    text-align: left;
    margin-bottom: 10px;
    font-family: simsun;
  }

  .left-section {
    position: fixed;
    width: 0px;
    height: 100%;
    background:#F5F5F5;
    border: 1px solid #e8e8e8;
    align-items: center;
    display: none;
    flex-direction: column;
  }

  .main-section {
    padding-top: 20px;
    margin-left:0px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: #808080;
    overflow: hidden
  }
}
</style>

方式3-ofd.js文件预览以及使用(废弃)

查看了一下npm上已经需要付费了,所以我果断放弃了

// npm地址
https://www.npmjs.com/package/ofd.js

// 安装
pnpm install ofd.js

Git 焚决!一个绝招助你找回丢失的代码文件!

作者 闲云一鹤
2026年1月7日 15:16

众所周知,代码只要提交到 git 仓库就有记录。哪怕是被覆盖或者删除了,都可以从仓库进行还原

但是如果代码没有提交,却不小心被搞丢了,还有办法找回吗?

答案是有的,我已经踩过坑并整理到文章中了

希望大家永远不会遇到这种糟心事

一旦碰上了,那滋味简直...不堪回首

你也不想代码被不小心搞丢后从头再写一遍吧?

赶紧收藏转发,保不齐哪一天就能用上呢

也可以提前学习,养成良好习惯,避免文件找不回来,有备无患!

1. 准备工作

我在项目中新增了一个“歌词本”文件夹,存了 50 首爱听的歌

1.png

2.png

因为不是所有的情况都能找回丢失的文件,只能找回已暂存(git add)过的文件

所以下面我将模拟 4 种场景进行测试,测试哪一种场景才能找回丢失的文件

2. 场景测试

2.0 测试说明

接下来我会在 4 种不同的场景下分别运行这个 git bash 命令

git fsck --lost-found

注意:命令皆在项目根目录(与.git 文件夹同级)下执行

预期:如果根目录(与.git 文件夹同级)下生成了 lost-found 文件夹即说明文件恢复成功

2.1 场景-1:未暂存 -> 运行 git fsck --lost-found 命令

现在是未暂存状态

3.png

运行命令

git fsck --lost-found

4.png

命令执行成功,但是却没有生成 lost-found 文件夹

2.2 场景-2:未暂存 -> 删除文件 -> 运行 git fsck --lost-found 命令

为了节省文章空间,这里就省略截图了

重新运行命令

也没有生成 lost-found 文件夹

2.3 场景-3:已暂存 -> 运行 git fsck --lost-found 命令

现在我把“歌词本”文件夹暂存(git add .)

5.png

再次运行命令

4.png

还是没有生成 lost-found 文件夹

2.4 场景-4:已暂存 -> 删除文件 -> 运行 git fsck --lost-found 命令

节省文章空间,截图就省略了

重新运行命令

也没有生成 lost-found 文件夹

2.5 场景-5:已暂存 -> 删除文件 -> 再次暂存 -> 运行 git fsck --lost-found 命令

然后我把“歌词本”文件夹整个给丢弃了,再次暂存(git add .)

6.png

最后运行命令

git fsck --lost-found

7.png

这次成功了!!!

.git 文件夹下面生成了 lost-found 文件夹,打开后里面有 other 文件夹, other 里面就是曾经暂存过的所有文件列表

可以看到正好 50 个项目

8.png

这些文件是以其对象的 SHA 哈希值命名的,但是文件内容就是原始内容(如果是文本文件,就可以直接打开查看)

用 vscode 打开整个 lost-found 文件夹,可以看到,里面全部都是刚才被我故意丢弃的歌词文件

9.png

在实际项目中,我们还可以再结合 vscode 的时间线功能,就可以把丢失的代码文件恢复的八九不离十了;

随堂笔记:不是 git add 之后,文件就会到.git/lost-found/other 目录中,而是当内容删除后才存在!!

3. 找回前提:文件 add 到暂存区过!

现在来解释,为啥只有 场景-5:已暂存 -> 删除文件 -> 再次暂存 -> 运行 git fsck --lost-found 命令 才能找回丢失文件的原因

执行 git add . 命令的时候,Git 会把 歌词本里面的 50 个.txt 文件内容存成 50 个 blob 对象,写入 .git/objects/ 文件夹

但是只有“悬挂”(dangling)-无引用,但存在的对象能通过 fsck 恢复到 .git/lost-found/ 目录中

未被 git 记录的文件,和 git 中正常存在(有引用关系)的文件是不会出现在 .git/objects/ 目录中,这就是为什么只有添加到暂存区后主动删除后才能生成到 .git/lost-found/ 文件夹的原因

只要你把文件暂存到 git 中,git 就会记住它,哪怕你之后 git reset,git rebase,cherry-pick,或者提交拉取覆盖,切换分支甚至删除文件。只要这个 blob 还没被 git gc 清理,它就能被 git fsck --lost-found 找出来

git fsck --lost-found 本质是恢复 Git 管理过的数据,对于没有被 git add 过的文件, Git 从来就不知道这个文件存在过,自然也就找不到了

4. git fsck --lost-found 命令解释

git fsck --lost-found 是 Git 内置命令,用于检查 Git 仓库的对象数据库(object database),并将任何“悬挂”(dangling)或“丢失”的对象恢复到 .git/lost-found/ 目录中。这是一个诊断和恢复工具,常用于修复损坏的仓库或找回意外丢失的数据。

  • git fsck:这是核心命令,用于验证 Git 仓库中对象的完整性。它会检查所有对象(如提交、树、blob 和标签)的连通性、有效性和 SHA-1 校验和。如果发现问题(如损坏的对象或无法访问的引用),它会报告错误。

  • --lost-found 选项:这个选项扩展了 fsck 的行为,不仅检查问题,还会将那些“悬挂”的对象(即存在于对象数据库中,但没有被任何引用如分支、标签或 HEAD 指向的对象)复制到 .git/lost-found/ 目录下。具体来说:

    悬挂的提交(commits)会放在 .git/lost-found/commit/ 目录中,每个文件名为其 SHA-1 值,是一个包含提交元数据的文本文件。

    其他悬挂对象(如树、blob 或标签)会放在 .git/lost-found/other/ 目录中,同样以 SHA-1 命名。

这些悬挂对象通常是因为操作如 git rebase、git reset 或手动删除引用而产生的。它们不是仓库损坏的标志,而是 Git 的正常行为(Git 默认保留这些对象一段时间,以防需要恢复)。

5. 通过关键词快速搜索

我想在一大堆文件中找到想要的文件怎么办?

直接 vscode 打开整个 .git/lost-found/other 目录,然后全局搜索关键词即可

10.png

我本来还研究了半天的搜索命令,最后发现还不如在 vscode 中全局搜索方便😓

6. 搜索进阶!查找最近修改的 15 个文件

不过新的问题也随之出现,文件太多怎么办?如果我 .git/lost-found/other 文件夹下面有几千个文件,我想找回最近被误删的文件,难道要挨个打开查看文件内容吗?

好问题,我也给你想到了解决方法。继续往下看。

前面的图片中显示 .git/lost-found/other 文件夹很干净,只有被我手动丢弃的 50 个歌词文件,是因为我之前清理了 git 的缓存,其实在我清理缓存之前 .git/lost-found/other 文件夹是存在两千多个文件的,如下图所示

11.png

要想在这两千多个文件中找到我丢失的那 50 个歌词文件,工作量可想而知!

毕竟不是所有的文件我都记得住,通过关键词查找回来

别急,我有好办法!

我现在新增 15 个不同格式的文件进行测试

12.png

然后把文件暂存后删除,测试能否精确找回这 15 个文件

注意!需要重新运行 git fsck --lost-found 命令才能更新.git/lost-found/other 文件夹哦!否则会搜出来旧的数据

还是在项目根目录(与.git 文件夹同级)下运行命令

#!/bin/bash
mkdir -p recent_lost
> temp_times.txt  # 先创建空文件,避免后续错误

for lost_file in .git/lost-found/other/*; do
    if [ ! -f "$lost_file" ]; then  # 如果没有文件,跳过(处理空目录)
        continue
    fi
    sha=$(basename "$lost_file")
    obj_dir=".git/objects/${sha:0:2}"
    obj_file="$obj_dir/${sha:2}"
    if [ -f "$obj_file" ]; then
        mtime=$(stat -c %Y "$obj_file" 2>/dev/null)  # 获取 Unix 时间戳,忽略错误
        if [ -n "$mtime" ]; then
            echo "$mtime $sha" >> temp_times.txt
        fi
    fi
done

if [ -s temp_times.txt ]; then  # 只在文件非空时排序
    sort -nr temp_times.txt | head -n 15 | while read mtime sha; do
        cp ".git/lost-found/other/$sha" "recent_lost/$sha"
        echo "Copied recent object: $sha (mtime: $(date -d @$mtime))"
    done
else
    echo "No recent objects found or no mtime available. This may happen if objects are packed (not loose) or no matching files in .git/objects/."
fi

rm -f temp_times.txt  # 用 -f 忽略如果文件不存在

13.png

14.png

成功提取出最近修改的 15 个文件,并将其存入自动创建的 recent_lost/ 文件夹中

15.png

用 vscode 打开项目,会发现除了 js,html,vue,txt 等文本文件打开后可以直观的看见内容外,其他的格式根本看不了一点,那咋搞?别急,请看下文-判断文件类型

解释:脚本遍历 other/ 中的 SHA,查找对应 .git/objects/ 中的文件,获取 mtime,按时间降序复制前 N 个到新目录 recent_lost/。这样你只需检查少数文件。

tip:如果想调整找回的数量,自己把 15 这个数字更改即可

7. 如何判断文件类型?

运行下面这个循环脚本能批量检查文件类型

for file in recent_lost/*; do
    file_type=$(file "$file")
    echo "$file: $file_type"
done

16.png

如图所示,html,json,js,excel,jpg,mp4,pdf,dwg 等格式都能正确判断出来

然后挨个复制哈希值,在 recent_lost 文件搜索文件:按组合键 chrl + p -> 粘贴哈希值

17.png

18.png

在 vscode 中找到这个 excel 的哈希值对应的文件,然后鼠标右键,在文件资源管理器中显示

然后手动更改文件后缀为对应的文件类型,即可还原内容啦!

19.png

其他的格式我就不挨个还原啦,步骤都跟还原 excel 一样的

8. git 瘦身

  • 强制垃圾回收并立即 prune:

    git gc --aggressive --prune=now
    

    运行这个命令后,删除 lost-found 文件夹,然后新增文件暂存删除暂存后重新 git fsck --lost-found,就会发现,以前的被清理干净了,只剩下了刚才新增删除的文件

  • 压缩 .git/objects 文件夹

    git repack -a -d -f --depth=50 --window=50
    

    命令的作用是删除冗余的旧 pack 文件和松散对象,释放空间;命令只影响存储格式,不修改仓库历史、提交或文件内容。SHA 值不变,仓库功能完整。

    可是我运行后发现并没啥用,估计是我这个仓库有好几年的历史了,长期累积导致体积变大,压无可压了

  • 重新 clone 项目

    如果你不嫌麻烦的话,重新 git clone git 仓库也是可以的,不过我已经实践了一遍,重新拉取代码跟运行 git gc --aggressive --prune=now 命令并无区别,.git/objects 文件夹依然是同样的大小

    记得提前将 .git/config 文件备份,clone 后再覆盖哦

总结

  • 如果没有暂存过,就无法通过 git 命令找回。不过可以试试编辑器的历史记录功能,这里用 vscode 举例

    操作步骤:同时按住 ctrl + shift + p 调用快捷键 -> 输入并打开 本地历史记录:查找要还原的条目 -> 就可以看见本地历史文件列表了

  • 如果是用 Socurcetree 提交代码的时候,报错然后丢失了文件,或许去贮藏菜单下面可以找到代码(我今天就被 Socurcetree 给坑了,报错后自己给我丢到贮藏去了)

  • 只能还原丢失的文件内容,无法还原文件名,不过我想能找回内容也够用了

  • 养成良好习惯,随时 git add . 呀!

Flexbox 布局中的滚动失效问题:为什么需要 `min-h-0`?

2026年1月7日 15:01

Flexbox 布局中的滚动失效问题:为什么需要 min-h-0

前言

在使用 Flexbox 布局时,你是否遇到过这样的问题:明明设置了 overflow-y-auto,但内容却无法滚动,反而把整个布局撑开了?这是一个常见的 Flexbox 陷阱,本文将深入分析其原因,并提供解决方案。

问题场景

假设我们有一个典型的后台管理系统布局:

<div className="flex flex-col h-full">
  {/* 顶部导航栏 */}
  <div className="h-[57px] shrink-0">顶部栏</div>

  {/* 内容区域 */}
  <div className="flex-1 flex">
    {/* 左侧菜单 */}
    <div className="w-fit h-full">
      <div className="CustomMenu flex flex-col h-full">
        <div className="flex-1 overflow-y-auto">
          {/* 菜单项很多,超过容器高度 */}
          <Menu items={menuItems} />
        </div>
        <Button>折叠按钮</Button>
      </div>
    </div>

    {/* 右侧内容区 */}
    <div className="flex-1">内容区</div>
  </div>
</div>

预期效果:当菜单项很多时,菜单区域应该可以滚动,折叠按钮固定在底部。

实际效果:菜单无法滚动,整个布局被撑开,按钮被挤到视口外。

问题根源:Flexbox 的 min-height: auto

Flexbox 的默认行为

在 Flexbox 布局中,flex 子项有一个默认的 min-height: auto(注意:不是 0!)。

这意味着:flex 子项的最小高度不能小于其内容的高度

问题流程分析

让我们看看没有 min-h-0 时发生了什么:

1. 菜单内容高度 = 2000px(假设有很多菜单项)
   ↓
2. flex-1 overflow-y-auto 容器被内容撑到 2000px3. CustomMenu (h-full) 被撑到 2000px(继承子元素高度)
   ↓
4. 左侧菜单容器 (h-full) 被撑到 2000px5. flex-1 flex 容器被撑到 2000px(因为 min-height: auto 阻止缩小)
   ↓
6. 整个布局被撑开,按钮被挤到视口外
   ↓
7. overflow-y-auto 不生效(因为容器高度 = 内容高度,没有"溢出"

可视化对比

没有 min-h-0(错误)

┌─────────────────┐
│ 顶部栏 (57px)    │
├─────────────────┤
│                 │
│  菜单内容       │ ← 容器被内容撑开
│  (2000px)       │
│                 │
│                 │
│  [按钮被挤下去] │ ← 按钮看不到
└─────────────────┘

min-h-0(正确)

┌─────────────────┐
│ 顶部栏 (57px)    │
├─────────────────┤
│ ┌─────────────┐ │
│ │ 菜单内容    │ │ ← 容器高度固定
│ │ (可滚动)    │ │ ← overflow 生效
│ │             │ │
│ └─────────────┘ │
│ [按钮固定底部]  │ ← 按钮可见
└─────────────────┘

解决方案:使用 min-h-0

修复代码

在需要滚动的 flex 容器上添加 min-h-0

<div className="flex flex-col h-full">
  <div className="h-[57px] shrink-0">顶部栏</div>

  {/* 关键:添加 min-h-0 */}
  <div className="flex-1 flex min-h-0">
    <div className="w-fit h-full">
      <div className="CustomMenu flex flex-col h-full">
        <div className="flex-1 overflow-y-auto">
          <Menu items={menuItems} />
        </div>
        <Button>折叠按钮</Button>
      </div>
    </div>
    <div className="flex-1">内容区</div>
  </div>
</div>

修复后的流程

1. flex-1 flex min-h-0 容器高度 = 可用空间(比如 800px)
   ↓
2. 左侧菜单容器 (h-full) = 800px(继承父容器)
   ↓
3. CustomMenu (h-full) = 800px(继承父容器)
   ↓
4. flex-1 overflow-y-auto 容器 = 800px(继承父容器)
   ↓
5. 菜单内容 2000px > 容器 800px → 产生溢出
   ↓
6. overflow-y-auto 生效 → 可以滚动!

为什么需要在整个布局链中传递?

高度限制需要在整个布局链中正确传递:

h-full (最外层)
  ↓
flex-1 flex min-h-0  ← 关键:允许缩小
  ↓
h-full (左侧菜单容器)
  ↓
h-full (CustomMenu)
  ↓
flex-1 overflow-y-auto  ← 可以滚动

每一层都需要正确的高度限制,min-h-0 是打破 Flexbox 默认行为的关键。

其他相关场景

场景 1:水平滚动

同样的问题也适用于水平滚动:

.container {
  display: flex;
  min-width: 0; /* 允许水平缩小 */
}

.scrollable {
  overflow-x: auto;
}

场景 2:Grid 布局

Grid 布局也有类似的问题,但 Grid 的默认 min-size0,所以通常不需要额外设置。

场景 3:嵌套 Flexbox

在多层嵌套的 Flexbox 中,每一层可能都需要 min-h-0

<div className="flex flex-col h-full">
  <div className="flex-1 flex min-h-0">
    {' '}
    {/* 第一层 */}
    <div className="flex-1 flex min-h-0">
      {' '}
      {/* 第二层 */}
      <div className="flex-1 overflow-y-auto">
        {' '}
        {/* 可以滚动 */}
        内容
      </div>
    </div>
  </div>
</div>

最佳实践

  1. 在需要滚动的 flex 容器上,总是添加 min-h-0
  2. 使用浏览器开发者工具检查元素的实际高度
  3. 理解 Flexbox 的默认行为:min-height: auto
  4. 在布局设计时,考虑内容可能超出容器的情况

总结

  • 问题:Flexbox 默认 min-height: auto 会阻止容器缩小
  • 结果:容器被内容撑开,overflow 不生效
  • 解决min-h-0 允许容器缩小,overflow 才能正常工作

这是一个常见的 Flexbox 布局陷阱,记住这个简单的规则:在需要滚动的 flex 容器上,记得加 min-h-0

2025 年最火的前端项目出炉,No.1 易主!

作者 冴羽
2026年1月7日 14:44

1. 前言

快来看!JavaScript Rising Stars 公布了 2025 年 JavaScript 明星项目榜单。

此榜单根据 2025 年 GitHub 新增的星标数量,还建立了以下榜单:

最受欢迎的项目、前端框架、React 生态系统、后端/全栈、构建工具、人工智能、移动端、Vue 生态系统、状态管理、CSS in JS、组件库、测试、桌面端、静态站点、GraphQL。

让我们看一看都是哪些项目上榜了。

2. 最受欢迎项目

2025 最受欢迎的 JavaScript 项目 Top 10 分别是:

2.1. 总冠军:n8n 🏆

n8n 是 2025 年的绝对赢家,一年内获得了超过 112000 个星标。过往从来没有一个项目在一年内获得如此多的星标。

n8n 是一个工作流自动化平台,具备原生 AI 功能,可通过可视化工作流连接各种应用程序和服务。它的成功反映了市场对无代码自动化工具日益增长的需求。

此外,在前 10 中还有 3 个与 AI 有关的项目,分别是:

  • Onlook:为 React 应用带来 AI 优先的视觉编辑功能
  • Dyad:一款免费、本地化、开源的 AI 应用构建器,也是 v0/lovable/Bolt 的替代方案
  • Stagehand:AI 驱动的浏览器自动化

2.2. 亚军:React Bits

React Bits 是一系列精美的 React 动画组件(背景效果、文本动画、卡片等),非常适合构建令人印象深刻的网站。

有趣的是,它以 shadcn/ui 项目的形式分发,可以通过命令行从 shadcn/ui 注册表获取,也可以通过传统的复制粘贴方式添加到代码库中。

该文档附带一个后台工作室,可让你调整和自定义所有组件的设置(颜色、速度、粒子数量……),并将其导出为代码片段,你可以将其复制粘贴到代码库中。

2.3. 季军:shadcn-ui

shadcn-ui 是 2023 年和 2024 年的冠军,2025 年依然保持了强劲的发展势头。

这是一套设计精良、注重细节(例如可访问性、键盘交互等)且风格统一的 React 组件,融合了 Radix UI、TanStack Table 等无头组件的精华……

shadcn/ui 最令人惊叹的特点是,它在开箱即用的功能和可定制性之间找到了最佳平衡点。

3. 前端框架

前端框架 Top 15 分别是:

React 重新夺回了 2024 年被 htmx 夺走的王冠。

虽然有关于 Solid 或 Svelte 等替代方案是否更适合新项目的争论,但因为 LLMs 基于 React 代码库进行训练,使得这些替代方案很难获得市场份额。

React 19 引入了重大改进,包括 Activity API 和用于管理用户事件的增强型 hooks。

说到 effects,Cloudflare 因为错误使用 useEffect ,导致无限调用其 API ,最终导致自身遭受 DDoS 攻击,造成了服务中断。

React 向服务器端迁移,推出了 React Server Components,这是近年来最大的变革。然而,这种变革也带来了巨大的功能和风险,例如 React2Shell 漏洞,这是一个 React Server Components 中的远程代码执行 (RCE) 漏洞,需要紧急发布补丁进行修复。(2025 年 12 月 3 日2025 年 12 月 11 日)

Ripple 位列第二,是前五名中的新秀。它是一个全新的 UI 框架,融合了 React、Solid 和 Svelte 的优点。它拥有响应式原语、组件化架构和模板语法。

目前仍处于早期开发阶段。React 有 Next.js,Vue.js 有 Nuxt,Svelte 有 SvelteKit,Solid 有 SolidStart……Ripple 是否也会有自己的元框架来处理服务器端渲染?

Svelte 连续第三年位列第三。Svelte 5 的 Runes 响应式系统(statestate、derived、$effect)已成为状态管理的标准方式。

4. React 生态系统

React 生态系统 Top 10 分别是:

2025 年,React 生态系统迎来了一个明显的转折点,其核心在于一个长期存在的矛盾:日益强大的服务器端功能与保持客户端开发简洁性和可预测性之间的冲突。

在 Next.js 的引领下,React 向服务器组件、服务器函数和流式传输的转型,开启了性能和架构方面的新可能。与此同时,这种转变也引入了一种新的思维模式:理解客户端-服务器边界、数据生命周期和渲染阶段对于日常开发至关重要。

社区对此反应不一。

一些人欣然接受新的服务器优先方向,认为这是 React 的自然演进;而另一些人则质疑,这种增加的复杂性是否值得用于日常 UI 开发。围绕服务器功能和请求边界的安全事件进一步加剧了这场争论。这些事件暴露了高度抽象的全栈模式带来的风险,同时也表明了另一件事:React 的服务器端模型已经达到了实际应用的程度,其假设正在生产环境中接受压力测试、审计和挑战。

在此背景下,TanStack Start 因其以更以客户端为中心(且同构)的视角看待 React 的新功能而备受关注,它优先考虑清晰性、类型安全性和显式控制。这自然而然地将讨论的焦点从框架本身转移到了关于开发者应该接受多少抽象以及复杂性真正归属的理念之争,而非功能竞赛。

5. 后端 / 全栈

后端 / 全栈 Top 10 分别是:

一款新晋产品荣获后端/全栈类别冠军!

Motia 代表了后端工程领域的范式转变,它将以往需要多个独立框架才能实现的功能整合到一个系统中。用户无需再为 API、后台任务、队列、工作流、数据流和 AI 代理等各种工具而烦恼,Motia 提供了一个涵盖所有后端功能的统一框架。

Motia 的核心是使用名为“步骤”(Steps)的基本单元,它是一种单一的抽象概念,定义了代码的运行方式、运行时间、运行地点以及运行内容。每个步骤都包含一个配置(用于定义触发器、路径和计划任务)和一个处理程序(用于执行业务逻辑)。更改步骤类型,相同的模式即可应用于不同的用例:API 端点、事件处理程序或定时任务。

步骤可以用 TypeScript 或 Python 编写。它还通过 Workbench 提供内置的可观察性,Workbench 是一个可视化控制面板,用于管理、调试和观察运行情况,此外还内置了状态管理和流式传输功能。

接下来的 4 个项目与 2024 年相同,只是 Hono 和 Astro 的位置互换了。

去年排名第一的 Payload 是一款基于 Next.js 的混合型无头 CMS 和管理面板。最大的新闻是它被 Figma 收购,其最终目标是缩小设计与代码之间的差距。

Next.js 16 引入了缓存组件,使缓存机制更加明确和灵活。开发者可以创建包含来自服务器的动态内容流的静态页面框架。

Astro 位列第四,它继续闪耀着光芒,作为一个多功能的框架,可以构建内容丰富的应用程序(例如您喜爱的 JS Rising Stars!),同时提供良好的开发者体验并注重性能。

Hono 位列第五,凭借其轻量级核心(可在 Node.js 运行时、Cloudflare 工作进程等各种环境中运行)以及丰富的处理器和中间件生态系统,成为现代 Web 服务器的标准(即使 Express 仍然流行!)。

元框架类别中最大的变化是Tanstack Start的崛起,如果你想在 React 之上构建全栈应用程序,它是 Next.js 的最佳替代方案之一。

6. 工具

工具 Top 5 分别是:

Bun 的持续努力最终获得了回报,这款一体化 JavaScript 工具包荣登榜首。在过去的一年中,它不断提升性能、增强对Node.js 的兼容性,并推出许多实用新功能,使其成为全栈 JavaScript 开发的理想平台。年底,Bun 被 Anthropic 收购,我们非常期待 2026 年 Bun 的发展。

今年对 void(0) 来说也意义非凡。该公司在 2024 年底宣布,其正在开发新一代前端基础设施工具(Oxc 和 Rolldown),并已让我们得以一窥未来发展方向。其旗舰项目 Vite 在这一年中持续改进,稳定了新的Environment API,并实现了对基于 Rust 的全新打包工具 Rolldown 的支持。该项目甚至还拥有了自己的纪录片。Vitest 也发布了其最受期待的功能之一:浏览器模式。Oxc 生态系统中涌现出许多令人兴奋的新项目,它们将成为现有工具极具吸引力的替代方案:Oxlint有望成为新一代 ESLint,而Oxfmt 则可能成为新一代 Prettier。该公司在年底完成了新一轮 A 轮融资,并发布了其首款商业产品Vite+

值得一提的还有Rspack。这款来自字节跳动的基于 Rust 的打包工具,速度更快,而且可以即插即用,是 webpack 的替代方案,因此它今年被广泛采用也就不足为奇了。更广泛的 Rstack 生态系统也值得关注,它催生了RstestRslint等新工具。

Next.js 最近将默认打包工具切换为 Turbopack ,相比之前的打包工具 webpack,速度有了显著提升。需要注意的是,也可以使用 Rspack 构建 Next.js 应用

尽管尚未完全成熟,但微软用 Go 语言重写 TypeScript无疑是今年最重要的公告之一,这将显著提升其速度。团队近期分享了最新进展,原生 TypeScript 体验似乎已经准备好迎接早期用户。TypeScript 6.0 将是最后一个基于 JavaScript 的版本,它将作为过渡到 TypeScript 7.0(Go 重写版)的桥梁。展望 2025 年,现代基础设施工具越来越多地源自大型企业或风险投资支持的公司,这引发了一个重要问题:成熟的、社区驱动的项目能否在未来一年保持竞争力?

7. 人工智能

人工智能 Top 10 分别是:

聊天机器人时代已经结束。如今开发者们更关注的工具并非聊天机器人库或提示器,而是工作流引擎。

n8n 的表现令人瞩目,预计 2025 年用户数量将增长 11.2 万。这并不令人意外,n8n 正在成为连接前沿模型和构建这些代理工具的首选工具。

DyadFlowiseMastraStagehand 等公司堪称自动化和代理工作流领域的佼佼者。Vercel 仍然是那些希望从底层掌控聊天和代理流程的用户的首选(但他们也在不断添加代理功能)。TanStack AI 虽然是后起之秀,但凭借其强大的代理功能,正在迅速崛起。

所以,这是你们 2026 年的作业:别再问“如何让我的 LLM 做出更好的回应”,而是开始问“哪些工作流程可以完全交给 AI 处理?”

选择 n8n 或 Flowise,构建一个能够根据事件触发、推理选项并自动执行操作的程序,无需征得许可。搭建一个Mastra 代理,使其能够跨多个工具和平台进行协调。尝试使用 Stagehand 来自动化浏览器任务。

聊天机器人只是辅助轮,是时候卸掉它们了。

哦,还有,请关注一下“code mode”这匹黑马。它可能是自 MCP 以来这个领域最重大的突破。

8. 移动端

移动端 Top 10 分别是:

在 JavaScript Rising Stars 评选活动长达十年的历史中,React Native 及其主要元框架 Expo 首次未能跻身移动端榜首。取而代之的是两个新晋框架 ValdiLynx——它们分别是 Snap(Snapchat 的母公司)和字节跳动(TikTok 的母公司)的内部框架。

ValdiLynx对 React Native 开发者来说并不陌生。它们都借鉴了 Web 技术,能够渲染原生视图,同时支持 TypeScript、JSX、Flexbox 布局、热重载和 CSS。Valdi 组件的外观与 React 类组件类似,而 Lynx 则同时支持命令式 API 和功能齐全的 React 抽象层(默认情况下推荐使用后者)。

它们之间的区别在于它们针对的业务需求进行了优化。Valdi 的设计目标是轻量级、延迟加载和可扩展,从而支持用户逐屏选择性地启用新功能,而不会造成显著的性能损失。Lynx 的设计目标是提供丰富的交互体验,它采用双线程架构,提供类似 Web 的抽象层,避免单线程瓶颈。

但它们并非唯一撼动 React Native 霸主地位的新兴框架。排名第四的 Dioxus 是一个雄心勃勃的框架,旨在基于 Web 技术打造“更优秀的 Flutter ”,但又无需功能齐全的 Web 视图。

虽然目前 Dioxus 默认使用系统 Web 视图,但其长期目标是稳定 Blitz ——一个轻量级的 Web 渲染器,它通过wgpu使用原生图形 API(例如 Vulkan 和 Metal)进行绘制。目前,Dioxus 应用仅支持 Rust 脚本,但未来计划支持更多语言。

9. Vue 生态系统

10. 状态管理

11. 样式 / CSS in JS

12. 组件库

13. 测试

image.png

14. 桌面端

15. 静态站点

16. GraphQL

最后

作为准前端开发专家的你,第一时间获取前端资讯、技术干货、AI 课程,那不得关注下我的公众号「冴羽」。

查看 Base64 编码的字体包对应的字符集

2026年1月7日 14:39

Base64 转换为 字体文件 .woff

若从代码中看到Base64 编码的字体包,不好确认包含哪些字体,可以通过截取的方式进行解析:

@font-face {
    font-family: 'SourceHanSerifCN-Regular';
    src: url('data:font/woff2;charset=utf-8;base64,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') format('woff2'),
        url('data:font/woff;charset=utf-8;base64,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') format('woff');
    font-weight: normal;
    font-style: normal;
    font-display: swap;
}

截取Base64的部分

@font-face {
  font-family: 'MyFont';
  src: url(data:font/woff2;base64,你的base64字符串) format('woff2');
  font-weight: normal;
  font-style: normal;
}

截取后通过JS解析(可以直接在浏览器端运行)

// 解码 Base64 为二进制数据
function base64ToFont(base64String, filename = 'font.woff') {
  // 移除 data URL 前缀(如果有)
  const base64Data = base64String.replace(/^data:font\/\w+;base64,/, '');
  
  // 解码 Base64
  const binaryString = atob(base64Data);
  const bytes = new Uint8Array(binaryString.length);
  
  for (let i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i);
  }
  
  // 创建 Blob 对象
  const blob = new Blob([bytes], { type: 'font/woff' });
  
  // 创建下载链接
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = filename;
  link.click();
  
  // 清理
  URL.revokeObjectURL(url);
}

比如上述的文件

base64ToFont('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')

解析后浏览器自动下载了 font.woff 文件

image.png

查看字体包的 Character set

Wakamai Fondue: Drop A Font ! 只需要把 font.woff 文件传上去,即可查看字体文件对应的字符集(如图)

SourceHanSerifCN-Regular from 1.0 css.png

从图中就可以查看到对应的字符集包含哪些内容,更清晰了解到哪些字体不在字体包内。

字符集说明

需要注意一点,字符集对应的编码是【十六进制】

如上图所看到的字符 【A】 对应的编码是 【FF21】 (说明是个特殊字符)

这跟我们平时键盘敲出来的字符A是不同的,键盘 【A】 的是 【0x41】 (如下图所示)

image.png

要确认清楚字符集的对应,这样才能更好地了解清楚,避免错认。

大写字母A-Z编码

字符 十进制 十六进制 二进制
A 65 0x41 01000001
B 66 0x42 01000010
C 67 0x43 01000011
D 68 0x44 01000100
E 69 0x45 01000101
F 70 0x46 01000110
G 71 0x47 01000111
H 72 0x48 01001000
I 73 0x49 01001001
J 74 0x4A 01001010
K 75 0x4B 01001011
L 76 0x4C 01001100
M 77 0x4D 01001101
N 78 0x4E 01001110
O 79 0x4F 01001111
P 80 0x50 01010000
Q 81 0x51 01010001
R 82 0x52 01010010
S 83 0x53 01010011
T 84 0x54 01010100
U 85 0x55 01010101
V 86 0x56 01010110
W 87 0x57 01010111
X 88 0x58 01011000
Y 89 0x59 01011001
Z 90 0x5A 01011010

小写字母a-z编码

字符 十进制 十六进制 二进制
a 97 0x61 01100001
b 98 0x62 01100010
c 99 0x63 01100011
d 100 0x64 01100100
e 101 0x65 01100101
f 102 0x66 01100110
g 103 0x67 01100111
h 104 0x68 01101000
i 105 0x69 01101001
j 106 0x6A 01101010
k 107 0x6B 01101011
l 108 0x6C 01101100
m 109 0x6D 01101101
n 110 0x6E 01101110
o 111 0x6F 01101111
p 112 0x70 01110000
q 113 0x71 01110001
r 114 0x72 01110010
s 115 0x73 01110011
t 116 0x74 01110100
u 117 0x75 01110101
v 118 0x76 01110110
w 119 0x77 01110111
x 120 0x78 01111000
y 121 0x79 01111001
z 122 0x7A 01111010

0-9的数字编码如下

字符 十进制 十六进制 二进制
0 48 0x30 00110000
1 49 0x31 00110001
2 50 0x32 00110010
3 51 0x33 00110011
4 52 0x34 00110100
5 53 0x35 00110101
6 54 0x36 00110110
7 55 0x37 00110111
8 56 0x38 00111000
9 57 0x39 00111001

第8章 Three.js入门

作者 XiaoYu2002
2026年1月7日 14:06

8.1 初始化项目

three.js 是一个基于JavaScript 的WebGL 引擎,可直接在浏览器中运行GPU 驱动的游戏与图形驱动的应用。 three.js 的库提供了大量用于在浏览器中绘制3D场景的特性与API。我们的入门就基于three.js库去调用对应的API。

需要完成的前置条件如下4点:

(1)创建一个空项目THREEJS。

(2)创建index.html和main.ts文件,用于后续编写示例代码。

(3)安装three.js库和对应声明文件。

(4)安装Vite用于启动项目。

// 安装three.js库和对应声明文件
npm i three
npm i --save-dev @types/three
// 安装Vite用于启动项目
npm i vite -D

其中index.html是作为展示3D场景界面的文件,然后需要导入main.ts文件,main.ts文件是作为编写three.js的代码文件。

// index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    html,
    body {
      margin: 0;
      padding: 0;
      overflow: hidden;
    }
  </style>
</head>

<body>
  <script src="./main.ts" type="module"></script>
</body>

</html>

接着到package.json文件中配置vite的启动命令,然后启动项目。

// package.json
{
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "three": "^0.182.0"
  },
  "devDependencies": {
    "@types/three": "^0.182.0",
    "vite": "^7.3.0"
  }
}

8.2 案例搭建

完成项目的初始化并启动项目后,项目界面是一片空白,因为我们还没有编写对应代码,接下来回到main.ts文件中,完成以下3点操作:

(1)引入three库。

(2)创建场景

(3)创建网格。

创建场景永远是第一件要做的事情,它是所有3D物体的容器,就像在2D Canvas中必须先获取画布上下文一样基础。接着是创建网格,three.js中的所有可见的3D物体都是基于网格去组成,网格可以有多个,网格包含几何体和材质。

几何体(Geometry):定义了物体的形状,即顶点、面等结构信息。

材质(Material):定义了物体表面的外观,例如颜色、纹理、光滑度等。

创建网格也意味着需要创建几何体和材质,然后放入网格中。几何体有多种形状,对应不同方法,填入不同参数;材质也有多种材质选择,通过不同方法去操作。

import * as THREE from 'three';

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

//创建几何体
const geometry = new THREE.BoxGeometry(100, 100, 100); // x,y,z三轴

//创建材质
//MeshBasicMaterial 这个材质是不受光照影响
//MeshLambertMaterial 这个材质是受光照影响 漫反射材质
//MeshPhongMaterial 这个材质是受光照影响 镜面高光
const material = new THREE.MeshBasicMaterial({color: 0x00ff00});

//网格 几何体 + 材质 可以有多个
const mesh = new THREE.Mesh(geometry, material);

//将网格添加到场景中
scene.add(mesh);

网格需要包含几何体和材质是很好理解的,几何体是物品的形状,而材质是物品的表面,网格就是将两者结合起来的“完整物体”,类似于3D建模。

创建网格属于场景的部分,而一个最简的Three.js代码结构需要包含三个核心组件:

(1)Scene(场景):是舞台。所有演员、道具、灯光都必须放在这个舞台上。

(2)Camera(相机):是摄像机。它决定了你从哪个角度、以何种视野去观看舞台。

(3)Renderer(渲染器):是负责把摄像机拍到的画面,实际绘制到屏幕画布上的“渲染引擎”。没有它,一切准备都只是数据,看不到图像。

因此创建网格并填充对应的几何体和材质意味着我们拥有了一个最简单的物品填入场景中作为被观察对象(网格需要添加到场景中),在这之后还需要创建相机和渲染器。接下来我们开始创建相机。

相机通过THREE.PerspectiveCamera()创建,需要四个参数分别是:视野角度(fov)、宽高比(aspect)、近裁剪面(near)、远裁剪面(far)。它们一起构成了一个视锥体,决定了相机能看到什么。视野角度控制可见范围的垂直开合程度,类似摄像机的镜头焦距;宽高比确保渲染不变形,通常直接使用窗口比例;远近裁剪面则定义了相机能看清的最小和最大距离,就像人眼的最近视点和最远视点。相机视角如图8-1所示。

image.png

图8-1 相机视角

在我们以下代码示例中,第一个参数 75 是垂直视野角度,类似人眼睁开的角度,值越大看到的场景越广;第二个参数 window.innerWidth / window.innerHeight 是宽高比,通常设置为渲染区域的宽除以高,以确保物体不被拉伸变形;第三个参数 0.1 是近裁剪面,表示相机能看清的最短距离,比这更近的物体将被裁剪不可见;第四个参数 1000 是远裁剪面,表示相机能看清的最远距离,比这更远的物体同样不可见,这四个参数共同划定了相机在三维空间中实际能观察到的范围。

接着我们需要设置相机放置的位置,就和现实一样,拍摄所在的位置决定了画面的叙事视角、视觉重点和情感基调。将相机靠近物体并采用低角度,能像电影特写一样赋予主体压迫感和权威性,常用于突出核心元素或营造紧张氛围;反之,将相机拉远并提升高度,则形成俯瞰式的宏观视角,适合展现场景全貌、空间关系或个体的渺小感。通过精确控制相机与主体的距离、高度和角度,能够决定整个场景是通过一个“第一人称”的沉浸式窗口呈现,还是作为一个“上帝视角”的客观全景被观察。

最后,我们需要将相机加入场景中,正如前面所说的所有演员、道具、灯光都必须放在这个舞台(场景)上。

// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 设置相机放置的位置
camera.position.set(0, 0, 400);
// 将相机加入场景
scene.add(camera);

最后,我们需要创建渲染器,将摄像机拍到的画面,实际绘制到屏幕画布。

// 创建WebGL渲染器实例,这是Three.js用来绘制3D场景的核心工具
const renderer = new THREE.WebGLRenderer();
// 设置渲染器输出画布的尺寸为整个浏览器窗口的宽度和高度
renderer.setSize(window.innerWidth, window.innerHeight);
// 将渲染器自动生成的<canvas>画布DOM元素添加到网页的<body>中,这样画面才能显示出来
document.body.appendChild(renderer.domElement);
// 执行一次性的渲染操作:命令渲染器从指定相机(camera)的视角,将场景(scene)中的所有物体绘制到画布上
renderer.render(scene, camera);

实际完整Demo代码如下:

如果创建材质选择MeshPhongMaterial这种受光照影响的要素,那么需要添加光照,否则看不见。如果你的画面看不到物体的话,你需要考虑去看下代码部分中的材质是否受光照影响。

import * as THREE from 'three';

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

//创建几何体
const geometry = new THREE.BoxGeometry(100, 100, 100); // x,y,z三轴

//创建材质
//MeshBasicMaterial 这个材质是不受光照影响
//MeshLambertMaterial 这个材质是受光照影响 漫反射材质
//MeshPhongMaterial 这个材质是受光照影响 镜面高光
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });

//网格 几何体 + 材质 可以有多个
const mesh = new THREE.Mesh(geometry, material);

//将网格添加到场景中
scene.add(mesh);


// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 设置相机放置的位置
camera.position.set(0, 0, 400);
// 将相机加入场景
scene.add(camera);

// 创建WebGL渲染器实例,这是Three.js用来绘制3D场景的核心工具
const renderer = new THREE.WebGLRenderer();
// 设置渲染器输出画布的尺寸为整个浏览器窗口的宽度和高度
renderer.setSize(window.innerWidth, window.innerHeight);
// 将渲染器自动生成的<canvas>画布DOM元素添加到网页的<body>中,这样画面才能显示出来
document.body.appendChild(renderer.domElement);
// 执行一次性的渲染操作:命令渲染器从指定相机(camera)的视角,将场景(scene)中的所有物体绘制到画布上
renderer.render(scene, camera);

Three.js创建的Demo画面如图8-2所示。

image-20251218205238843

图8-2 Three.js创建Demo画面

目前我们场景内只有一个正方块物体,正被相机拍摄着,但看着就像2D的画面。因此我们可以通过引入OrbitControls(轨道控制器)来实现拖动效果,从而实现3D效果。

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
//创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);

const animate = () => {
    requestAnimationFrame(animate);
    controls.update();
    renderer.render(scene, camera);
}
animate();

轨道控制器拖动效果如图8-3所示。由于目前正方体是没有边缘线的,因此静止的时候看起来更像是不规则的平面物体,我稍微添加了几条红色线条(不太规范)来辅助理解,大致能看出这是一个正方体。

image-20251218205731942

图8-3 轨道控制器拖动效果

以上是Three.js入门的一个简单案例。在创建轨道控制器的时候,我们添加了一个定时器,并且使用了递归,但不会出现死循环导致爆栈的情况。因为使用的是浏览器原生API requestAnimationFrame 实现的动画循环,它并不是传统意义上的递归死循环。

代码步骤思路为以下2步:

(1)requestAnimationFrame(animate):向浏览器“预约”下一帧,告诉浏览器:“在下次屏幕刷新绘制时,请调用animate函数”。它不会立即、连续地调用自身。

(2)浏览器控制节奏:浏览器会以屏幕刷新率(通常是60FPS,即每秒约60次) 的节奏来回调animate函数。当页面隐藏或最小化时,浏览器会自动暂停这些回调以节省资源。

所以通过requestAnimationFrame执行循环,每帧执行完后会释放主线程,等待浏览器下一次绘制时机(执行时机在DOM回流和重绘之前),浏览器牢牢掌控住绘制的运行时间间隔,甚至决定了什么时候会暂停,所以自然不会出现死循环的情况。

这种非阻塞的协作式循环在性能优化(与屏幕刷新同步,避免不必要的重复渲染),节能(页面不可见时自动暂停)和流畅动画(动画更新与屏幕刷新率一致)方面都很不错。这种技术被称为RAF技术。

const animate = () => {
  // 递归调用animate
  requestAnimationFrame(animate);
  controls.update();
  renderer.render(scene, camera);
}
animate();

通过以上思路,需要先有整体的场景,然后往场景里添加被观察对象(演员、道具、灯光等),接着是观察对象(相机),最后用渲染器将相机拍到的画面渲染出来。在这里场景是我们(导演)的视角,而摄像机才是观众的视角。相机所拍摄的部分才是我们想展现的部分。我们应该从实际摄影所带来的经验中去思考如何拍摄。

8.3 添加灯光

接下来,我们修改材质,将其设置为MeshPhongMaterial这种受光照影响的材质,然后加入灯光,看效果如何。

const material = new THREE.MeshPhongMaterial({ color: 0x00ff00 });

// 添加平行光源(模拟太阳光)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 1, 1).normalize();
scene.add(directionalLight);

模拟太阳光对材质进行照射如图8-4所示。可见物体呈现了不一样的质感。像太阳光属于平行光的一种,只能照射到正方体的正面,因此正方体背面还是不可见的。

image-20251218211611489

图8-4 模拟太阳光

在Three.js中,灯光是塑造三维空间体积感、材质属性和场景氛围的核心工具,本质上是通过模拟光线与物体材质的交互来定义视觉层次。主要分为四种基本类型:

(1)环境光提供均匀无方向的基底照明,如同阴天的漫射光,用于消除纯黑阴影;

(2)平行光模拟无限远处的光源(如太阳),发出平行光线,产生方向明确的阴影,适合户外场景;

(3)点光源从一个点向所有方向均匀辐射光线,像灯泡或蜡烛,能营造真实的衰减和柔和的明暗过渡;

(4)聚光灯则形成锥形的光束,像手电筒或舞台追光,带有清晰的照射范围和边缘衰减,常用于突出特定物体或制造戏剧性焦点。

实际应用中,通常需要组合多种灯光——例如用环境光奠定基调,再用平行光或点光源刻画主次和投影——才能构建出既有层次又自然可信的三维视觉空间。

react基础概念合集

作者 加油乐
2026年1月7日 13:23

前言

什么是 React?

React 是一个用于构建交互式 UI 的 JavaScript 库。它的主要特点包括:

  • 组件化:UI 被拆分成独立的组件,便于复用和管理。
  • 声明式 UI:使用 JSX(类 HTML 语法)编写代码,使 UI 状态更新更加直观。
  • 虚拟DOM:通过 Diffing 算法优化 DOM 更新,提高性能。

文中所有组件及方法为方便理解与概述做出了不同程度的整合实际使用可根据具体业务拆分

一、创建React项目

1、使用CDN(不推荐)

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>React 入门</title>
    <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
</head>
<body>
    <div id="root"></div>
    <script type="text/babel">
        function App() {
            return <h1>Hello, React!</h1>;
        }
        ReactDOM.createRoot(document.getElementById('root')).render(<App />);
    </script>
</body>
</html>

2、使用Vite并创建React项目(推荐)

  • npm create vite@latest my-react-app --template react

进入项目目录

  • cd my-react-app

安装依赖

  • npm install

启动开发服务器

  • npm run dev

二、react的基本概念

入口文件main.jsx解析

StrictMode
  • StrictMode在开发环境下会:
    1. ✅ 检测废弃的生命周期方法使用
    1. ✅ 警告使用过时的字符串ref API
    1. ✅ 检测意外的副作用(组件会渲染两次)
    1. ✅ 检测过时的context API
    1. ✅ 检测不安全的副作用(未来并发特性相关)
  • 注意:StrictMode只影响开发环境,生产构建时会自动移除
常见扩展模式:
    1. 添加路由:用<BrowserRouter>包裹<App />
    1. 添加状态管理:用<Provider>包裹<App />
    1. 添加主题:用<ThemeProvider>包裹<App />
    1. 添加错误边界:用<ErrorBoundary>包裹<App />
// StrictMode是React的开发工具,用于在开发阶段检测潜在问题
import { StrictMode } from 'react'
// createRoot用于创建React根容器,替代旧版的ReactDOM.render()
import { createRoot } from 'react-dom/client'
//导入全局CSS样式文件
import './index.css'
//导入主应用组件
import App from './App.jsx'

/**
 * 创建React根节点并渲染应用
 * 
 * 1. document.getElementById('root') - 获取HTML中的挂载点
 * 2. createRoot() - 创建React 18的并发根容器
 * 3. render() - 将React组件渲染到DOM中
 */
createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
  /**
   * render()方法的第二个参数(可选):
   * 可以在这里添加回调函数,在组件渲染或更新后执行
   * 示例:.render(<App />, () => console.log('渲染完成'))
   */
)

1、JSX基本语法

  • 组件首字母必须大写
  • 必须返回单一根元素,可以用 <></>
  • 标签必须闭合,如<img>必须为<img/>
  • 属性用 camelCase 方式,如 className(而不是 class
  • 样式相关可直接引入外部文件,例:import './assets/style/index.css'
  • 此处为方便理解定义为export default function App(){...},定义function App(){...}可直接书写export default App
  • window.location.replace("juejin.cn/user/840368…")
export default function App() {
return (
<>
<div className="title">标题文字</div>
</>
);
}

2、条件渲染

  • 变量、动态类名、动态样式的使用
  • 注意style的使用
function App() {
let titleText = '标题文字';
let num = 8;
let styleObj = { color: titleText?.length > 3 ? 'red' : 'blue' };
return (
<>
{/*变量的使用*/ }
<div>{ titleText }</div>
{/*动态类名的使用*/ }
<div className={ num > 5 ? 'big-class' : 'small-class' }>{ titleText }</div>
{/*动态样式的使用*/ }
{/*此处style后的第一层{}为包裹,第二层{}为样式对象,勿混淆*/ }
<div style={ { color: titleText?.length > 3 ? 'red' : 'blue' } }>{ titleText }</div>
{/*上面的style同下*/}
<div style={ styleObj }>{ titleText }</div>
</>
);
}
  • 可以选择性地将一些JSX赋值给变量,然后用大括号将其嵌入到其他JSX中
  • 下列几种用法均可灵活组合。
  • window.location.replace("juejin.cn/user/840368…")
function User({ userData }) {
let jsxAgeContent = userData.age >= 18 ? (<div>年龄:{ userData.age }</div>) : (<div>年龄:未成年</div>);
let jsxNameContent;
if(userData.name?.length > 2) {
jsxNameContent = (<div>{ `${ userData.name[0] }*${ userData.name[2] }` }</div>);
}
else {
jsxNameContent = (<div>{ `${ userData.name[0] }*` }</div>);
}
return (
<>
{ jsxNameContent }
{ jsxAgeContent }
<div>{ userData.isWork && <span>薪酬:¥ { userData.salary }/天</span> }</div>
</>
);
}

function Index() {
return (
<>
<User
userData={ { name: '张三三', age: 17, isWork: false, salary: null } }
/>
<User
userData={ { name: '李四', age: 21, isWork: true, salary: 888 } }
/>
<User
userData={ { name: '王五五', age: 36, isWork: true, salary: 8888 } }
/>
</>
);
}

export default Index;

3、列表渲染

  • 使用map重构、filter筛选等方法,渲染出一个组件列表
  • 注意唯一标识key,此处为方便演示使用index,实际开发要避免使用index作为key
  • 方法一、方法二为条件渲染的写法, 根据js逻辑可灵活运用不局限于map、filter、forEach等方法。
  • 注意return用法,此处逻辑相对简单,已隐式省略
export default function List() {
let listData = [
{ name: '张三三', age: 17, isWork: false, salary: null },
{ name: '李四', age: 21, isWork: true, salary: 888 },
{ name: '王五五', age: 36, isWork: true, salary: 8888 }
];

let listItems = listData.map((item, index) =>
<li key={ index }>{ item.name }</li>
);
// 方法一
// let filterData = listData.filter((item) => item.age > 18);
// let listItems = filterData.map((item, index) => <li key={ index }>{ item.name }</li>);
// 方法二
// let listItems = [];
// listData.forEach((item, index) => {
// if(item.age > 18) {
// listItems.push(<li key={ index }>{ item.name }</li>);
// }
// });
return <ul>{ listItems }</ul>;
}

4、事件处理

  • 基础用法及事件传参
export default function List() {
let listData = [
{ name: '张三三', age: 17, isWork: false, salary: null },
{ name: '李四', age: 21, isWork: true, salary: 888 },
{ name: '王五五', age: 36, isWork: true, salary: 8888 }
];

function clickItem(item) {
alert(`点击了${ item.name }的信息`);
}

// 基础用法
// let listItems = listData.map((item, index) =>
// <li key={ index } item={item} onClick={ clickItem }>{ item.name }</li>
// );
// 事件传参
let listItems = listData.map((item, index) =>
<li key={ index } onClick={ () => clickItem(item) }>{ item.name }</li>
);
return <ul>{ listItems }</ul>;
}

5、组件通信Props(父传子)

  • props的作用与函数的参数相同,接收的永远是一个对象,是组件的唯一参数
  • 通过参数=值的形式设置默认参数,在该参数不存在时使用默认值
  • 在子组件标签上通过{...props},可以将所有属性扩展至子标签不建议这么用,如有需求,可以通过jsx传递子组件
//写法一:
function User({ userData, title='个人信息' }) {
return (
   <>
<h3>{ title }</h3>
<div>姓名:{ userData.name }</div>
<div>年龄:{ userData.age }</div>
   </>
);
}
//写法二:
// function User(props) {
// let { title, userData } = props;
// // 等同于
// // let title=props.title;
// // let userData=props.userData;
// return (
// <>
// <h3>{ title }</h3>
// <div>姓名:{ userData.name }</div>
// <div>年龄:{ userData.age }</div>
// </>
// );
// }
//写法三:
// function User(props) {
// return (
// <>
// <h3>{ props.title }</h3>
// <div>姓名:{ props.userData.name }</div>
// <div>年龄:{ props.userData.age }</div>
// </>
// );
// }

function Index() {
return (
<>
<User
userData={ { name: '张三三', age: 18 } }
title="用户信息"
/>
<User
userData={ { name: '李四四', age: 21 } }
title="用户信息"
/>
<User
userData={ { name: '王五五', age: 36 } }
title="用户信息"
/>
</>
);
}

export default Index;
  • 注意:嵌套JSX,将被视为父组件的children prop
  • Props是只读的时间快照,每次渲染都会收到新版本的props
  • 不能改变props,需要交互性时,可以设置state
  • window.location.replace("juejin.cn/user/840368…")
function User({ userData, title = '个人信息' }) {
return (
<>
<h3>{ title }</h3>
<div>姓名:{ userData.name }</div>
<div>年龄:{ userData.age }</div>
</>
);
}

function Index() {
return (
<>
<Content>
<User
userData={ { name: '张三三', age: 18 } }
title="用户信息"
/>
<User
userData={ { name: '李四四', age: 21 } }
/>
<User
userData={ { name: '王五五', age: 36 } }
/>
</Content>
</>
);
}

function Content({ children }) {
return (
<div className="user">
{ children }
</div>
);
}


export default Index;

6、状态管理(useState)

  • useState 是一个 React Hook,它允许你向组件添加一个状态变量
  • useState 返回一个由两个值组成的数组:①初始化的state ②可以将state更新为不同的值并触发重新渲染
  • 只能在组件的顶层调用,不能在循环或条件语句中调用
  • set函数可以直接传递新状态,也可以传递一个根据先前状态来计算新状态的函数
  • set函数没有返回值
  • 注意:调用set函数不会更新已经运行代码中的状态变量,如有同一变量的多次处理逻辑,应该处理完毕后再返回更新

基础用法

import { useState } from 'react';

export default function Index() {
const [age, setAge] = useState(18);
// 方法一
// function add() {
// setAge(agePrev => agePrev + 1);
// }
//
// return <>
// <button onClick={ add }>+1岁</button>
// 年龄:{ age }
// </>;

// 方法二
return <>
<button onClick={ ()=>setAge(agePrev => agePrev + 1) }>+1岁</button>
年龄:{ age }
</>;
}

常规处理:

import { useState } from 'react';

export default function List() {
const [listData, setListData] = useState([
{ id: '0', name: '张三三', age: 17, isWork: false, salary: null },
{ id: '1', name: '李四', age: 21, isWork: true, salary: 888 },
{ id: '2', name: '王五五', age: 36, isWork: true, salary: 8888 }
]);
// 初始数据
const initialStats = handleWorkData(listData);
const [isAllWork, setIsAllWork] = useState(initialStats.newIsAllWork);
const [workNum, setWorkNum] = useState(initialStats.newWorkNum);
// 数据处理
function handleWorkData(list) {
return {
newIsAllWork: list.every(item => item.isWork),
newWorkNum: list.filter(item => item.isWork).length
};
}

// 是否参加工作变动
function isWorkChange(e, id) {
setListData((prevData) => {
// prevData为原数据,set必须有返回值
const newData = prevData.map(item => {
if(item.id === id) {
return { ...item, isWork: e.target.checked };
}
return item;
});
const { newIsAllWork, newWorkNum } = handleWorkData(newData);
setIsAllWork(newIsAllWork);
setWorkNum(newWorkNum);
return newData;
});
}

// 渲染
let listItems = listData.map(item => {
return <li key={ item.id }>
<span>{ item.name }</span>
<input type="checkbox" checked={ item.isWork } onChange={ e => isWorkChange(e, item.id) } />
</li>;
});
return <>
<div>当前参加工作人数:{ workNum }</div>
{ isAllWork && <h3>已全部参加工作</h3> }
<ul>{ listItems }</ul>
</>;
}

状态提升(组件共享状态、父控子、子传父)

  • 子组件状态仅影响本组件,如果需要子组件共享状态需要状态提升
  • 核心:子的公共状态提升由父控制,状态及事件由父做交互并传递给子组件,子组件调用父组件的状态及事件
  • 下面是个tab切换颜色值案例:
import { useState } from 'react';

// 颜色选择按钮
function ColorButton({ color, isSelected, setSelectedColorFn }) {
return (
<button
onClick={ setSelectedColorFn }
style={ {
backgroundColor: color,
color: 'white',
padding: '10px 20px',
margin: '5px',
border: isSelected ? '3px solid black' : '3px solid transparent',
} }
>
{ color }
</button>
);
}

export default function ColorPicker() {
// 色值组
const colors = ['red', 'blue', 'green', 'greenYellow', 'purple'];
// 当前选择的颜色
const [selectedColor, setSelectedColor] = useState('red');
return (
<div style={ { padding: '20px' } }>
<h2>🎨颜色选择器</h2>
<p>当前颜色: <span style={ { color: selectedColor } }>{ selectedColor }</span></p>
{/* 所有颜色按钮 */ }
<div>
{ colors.map(color => (
<ColorButton
key={ color }
color={ color }
// 是否选中
isSelected={ selectedColor === color }
// 回调函数传递给子组件  此处名称可自定义
setSelectedColorFn={ () => setSelectedColor(color) }
/>
)) }
</div>
</div>
);
}

7、状态管理(useReducer)

  • useReducer主要作用是将过于分散的事件处理逻辑整合
  • window.location.replace("juejin.cn/user/840368…")
  • 以下通过两个案例对比简述使用方法及区别

使用useState的todo

import { useState } from 'react';

let nextId = 3;
let initialTasks = [
{ id: 0, text: '代办事项1', isComplete: true, isEdit: false },
{ id: 1, text: '代办事项2', isComplete: false, isEdit: false },
{ id: 2, text: '代办事项3', isComplete: false, isEdit: false }
];

function AddSearch({ setTasks }) {
const [inputValue, setInputValue] = useState('');

function add() {
if(inputValue.trim().length > 0) {
setTasks((prevTasks) => [...prevTasks,
{
id: nextId++,
text: inputValue,
isComplete: false,
isEdit: false
}
]);
setInputValue('');
}
}

return (
<>
<input type="text"
       value={ inputValue }
       onChange={ (e) => setInputValue(e.target.value) }
       onKeyDown={ (e) => {
       if(e.key === 'Enter') add();
       } }
/>
<button onClick={ add }>新增</button>
</>
);
}

export default function TaskApp() {
const [tasks, setTasks] = useState(initialTasks);

function checkboxChange(id) {
setTasks((prevTasks) => {
return prevTasks.map(task => {
if(task.id === id) {
return {
...task,
isComplete: !task.isComplete
};
}
return task;
});

});
}

function edit({ id, isEdit }) {
setTasks((prevTasks) =>
prevTasks.map(task => {
if(task.id === id) {
return {
...task,
isEdit: !isEdit
};
}
return task;
})
);
}

function del(id) {
setTasks((prevTasks) => prevTasks.filter(task => task.id !== id));
}

function textChange(text, id) {
setTasks((prevTasks) => {
return prevTasks.map(task => {
if(task.id === id) {
return { ...task, text };
}
return task;
});
});
}

let tasksList = tasks.map((task) => {
return (
<div key={ task.id }>
<input type="checkbox" checked={ task.isComplete } onChange={ () => checkboxChange(task.id) } />
{ !task.isEdit
?
<div>{ task.text }</div>
:
<input type="text"
       value={ task.text }
       onChange={ (e) => textChange(e.target.value, task.id) }
/> }
<div>是否已完成:{ task.isComplete ? '是' : '否' }</div>
<button onClick={ () => edit(task) }>{ task.isEdit ? '保存' : '编辑' }</button>
<button onClick={ () => del(task.id) }>删除</button>
</div>
);
});
return (
<>
<AddSearch tasks={ tasks } setTasks={ setTasks } />
{ tasksList }
</>
);
}

使用useReducer的todo

import { useReducer, useState } from 'react';

let nextId = 3;
let initialTasks = [
{ id: 0, text: '代办事项1', isComplete: true, isEdit: false },
{ id: 1, text: '代办事项2', isComplete: false, isEdit: false },
{ id: 2, text: '代办事项3', isComplete: false, isEdit: false }
];

// 定义reducer函数
function tasksReducer(state, action) {
switch(action.type) {
case 'add': {
return [
...state,
{
id: action.id,
text: action.text,
isComplete: false,
isEdit: false
}
];
}
case 'edit': {
return state.map(task => {
if(task.id === action.id) {
return {
...task,
isEdit: !task.isEdit
};
}
return task;
});
}
case 'change': {
return state.map(task => {
if(task.id === action.id) {
return {
...task,
text: action.text
};
}
return task;
});
}
case 'toggle': {
return state.map(task => {
if(task.id === action.id) {
return {
...task,
isComplete: !task.isComplete
};
}
return task;
});
}
case 'delete': {
return state.filter(task => task.id !== action.id);
}
default: {
throw new Error('未知的action类型: ' + action.type);
}
}
}

function AddSearch({ dispatch }) {
const [inputValue, setInputValue] = useState('');

function add() {
if(inputValue.trim().length > 0) {
dispatch({
type: 'add',
id: nextId++,
text: inputValue
});
setInputValue('');
}
}

return (
<>
<input type="text"
       value={ inputValue }
       onChange={ (e) => setInputValue(e.target.value) }
       onKeyDown={ (e) => {
       if(e.key === 'Enter') add();
       } }
/>
<button onClick={ add }>新增</button>
</>
);
}

export default function TaskApp() {
// 使用 useReducer
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
let tasksList = tasks.map((task) => {
return (
<div key={ task.id }>
<input
type="checkbox"
checked={ task.isComplete }
onChange={ () => dispatch({ type: 'toggle', id: task.id }) }
/>
{ !task.isEdit
?
<div>{ task.text }</div>
:
<input
type="text"
value={ task.text }
onChange={ (e) => dispatch({
type: 'change',
id: task.id,
text: e.target.value
}) }
onKeyDown={ (e) => {
if(e.key === 'Enter') dispatch({ type: 'edit', id: task.id });
} }
autoFocus
/>
}
<div>是否已完成:{ task.isComplete ? '是' : '否' }</div>
<button onClick={ () => task.text.trim().length > 0 && dispatch({ type: 'edit', id: task.id }) }>
{ task.isEdit ? '保存' : '编辑' }
</button>
<button onClick={ () => dispatch({ type: 'delete', id: task.id }) }>
删除
</button>
</div>
);
});

return (
<>
<AddSearch dispatch={ dispatch } />
{ tasksList }
</>
);
}

8、组件通信

父传子

可以在前置5、组件通信props查看,详解中已概述使用方法

子传父-回调函数用法

  1. 父组件传递一个函数给子组件
  2. 子组件调用该函数并传递数据作为参数
  3. 父组件在函数中接收数据并更新状态
import { useState } from 'react';

// 子组件
function Child({ onSendData }) {
const [inputValue, setInputValue] = useState('');
const handleSend = () => {
// 调用父组件传递的回调函数,并传递数据
onSendData(inputValue);
};

return (
<div>
<h3>子组件</h3>
<input
value={ inputValue }
onChange={ (e) => setInputValue(e.target.value) }
placeholder="输入要传递的数据"
/>
<button onClick={ handleSend }>发送给父组件</button>
</div>
);
}

// 父组件
export default function Parent() {
const [messageFromChild, setMessageFromChild] = useState('');
// 回调函数,用于接收子组件的数据
const handleChildData = (data) => {
setMessageFromChild(data);
};

return (
<div>
<h2>父组件</h2>
<p>来自子组件的消息:{ messageFromChild }</p>
<Child onSendData={ handleChildData } />
</div>
);
}

子传父/兄弟传值-状态提升用法

  • 将状态提升到共同的父组件中,通过props传递给子组件,子组件通过回调函数修改该状态。
  • 此处兄弟组件也可以共享到相同父级的数据,通过其他子组件修改的值也会传递至兄弟元素子传父/兄弟传值通用
import { useState } from 'react';
// 父组件
export default function Parent() {
const [sharedData, setSharedData] = useState('');

return (
<div>
<h2>共享数据:{ sharedData }</h2>
<Child text="来自ChildA的数据" onDataChange={ setSharedData } data={ sharedData } />
<Child text="来自ChildB的数据" onDataChange={ setSharedData } data={ sharedData } />
<Child text="来自ChildC的数据" onDataChange={ setSharedData } data={ sharedData } />
</div>
);
}

// 子组件
function Child({ text, data, onDataChange }) {
return (
<div>
<div>当前数据源:{ data }</div>
<button onClick={ () => onDataChange(text) }>
更新数据
</button>
</div>
);
}

子传父-状态提升用法

import { useState } from 'react';
// 父组件
export default function Parent() {
const [sharedData, setSharedData] = useState('');

return (
<div>
<h2>共享数据:{ sharedData }</h2>
<Child text="来自ChildA的数据" onDataChange={ setSharedData } data={ sharedData } />
<Child text="来自ChildB的数据" onDataChange={ setSharedData } data={ sharedData } />
<Child text="来自ChildC的数据" onDataChange={ setSharedData } data={ sharedData } />
</div>
);
}

// 子组件
function Child({ text, data, onDataChange }) {
return (
<div>
<div>当前数据源:{ data }</div>
<button onClick={ () => onDataChange(text) }>
更新数据
</button>
</div>
);
}

祖先传值/后代传祖先/数据共享(useContext)

特性 说明
createContext() 创建一个 context 对象
Provider 包裹需要共享的组件树,传入 value
useContext(context) 读取 context 的值
默认值 如果没有 Provider,读取的是 createContext 的默认值
  • DataContext.Provider组件接受一个 value 属性,用于传递上下文的值给后代组件。当 Provider 的 value 发生变化时,所有依赖于这个上下文的后代组件都会重新渲染
  • 此处示例为方便理解仅嵌套一层,Child只要包裹在DataContext.Provider内,无论处于多少层后代,都可以通过useContext获取到
import React, { createContext, useState, useContext } from 'react';

// 创建Context
const DataContext = createContext();

// 父组件
export default function Parent() {
const [data, setData] = useState('来自父组件的数据');

return (
<DataContext.Provider value={ { data, setData } }>
<div>
<h2>父组件接收的数据:{ data }</h2>
<Child />
</div>
</DataContext.Provider>
);
}

// 子组件
function Child() {
const { data, setData } = useContext(DataContext);

return (
<div>
<div>子组件接受的数据:{ data }</div>
<button onClick={ () => setData('来自子组件的数据') }>
更新父组件数据
</button>
</div>
);
}

建议推荐:

  • 简单场景:使用回调函数最直观
  • 多层嵌套:使用 Context API
  • 兄弟组件通信:状态提升到共同父组件

9、useRef

①. 访问 DOM 元素

  • 最常见的用途是直接访问和操作 DOM 元素。
import { useRef, useEffect } from 'react';

function TextInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载后自动聚焦输入框
    inputRef.current.focus();
  }, []);

  return <input ref={inputRef} type="text" />;
}

②. ## 存储可变值(不会触发重渲染)

  • 存储一个在组件生命周期内持久化更改不会触发重渲染的值。
import { useRef, useState } from 'react';

function Timer() {
  const [count, setCount] = useState(0);
  // 存储 intervalID
  const intervalRef = useRef(null); 

  const startTimer = () => {
    intervalRef.current = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);
  };

  const stopTimer = () => {
    clearInterval(intervalRef.current);
  };

  return (
    <div>
      <p>计时: {count} 秒</p>
      <button onClick={startTimer}>开始</button>
      <button onClick={stopTimer}>停止</button>
    </div>
  );
}

10、useEffect

基本使用示例

  • useEffect类似于vue的生命周期+监听组合体
  • 通过依赖/更新/清理的形式灵活使用
import { useState, useEffect } from 'react';

export default function SimpleExample() {
// 1. 首先需要一个状态变量
const [count, setCount] = useState(0);

// 2. 最简单的 useEffect 结构
useEffect(() => {
// 当 count 变化时,这里面的代码会自动执行
console.log(`Count更新了!现在是: ${count}`);
// 更新页面标题(一个常见的副作用)
document.title = `点击次数: ${count}`;

// 3. 可选的"清理函数"(这里不需要,所以不写 return)
// 如果需要清理(如移除事件监听、清除定时器),在这里返回一个函数
// return () => { ...清理代码... }

}, [count]); // 这里是"依赖数据",可以是一个也可以是多个,以数组的形式传递
// 第二个参数分别有以下三种情况
// [count] 表示:当 count 变化时,执行上面的副作用函数
// [] 空数组表示:只在组件第一次渲染时执行一次
// 不写第二个参数:每次组件渲染都执行(很少用)

return (
<div>
<h1>useEffect示例</h1>
<p>当前计数: {count}</p>
<button onClick={() => setCount(count + 1)}>
点击增加
</button>
</div>
);
}

场景一:页面加载时获取数据

import { useEffect, useState } from "react";

export default function Fridge() {
const [foods, setFoods] = useState([]);

useEffect(() => {
console.log("📦 打开冰箱门,查看食物...");
// 模拟获取数据
setTimeout(() => {
setFoods(["苹果", "牛奶", "鸡蛋"]);
}, 1000);
}, []); // [] 表示只执行一次

return <div>冰箱里有: { foods.join(', ') }</div>;
}

场景二:值变化时更新

  • 类似场景如:依赖不同id获取展示详情
  • 类似场景如:tab切换模块组件等
import { useState, useEffect } from "react";

export default function WeatherApp() {
const [temperature, setTemperature] = useState(25);
const [clothes, setClothes] = useState("");

useEffect(() => {
console.log(`🌡️ 温度变成 ${ temperature }°C 了`);

if(temperature > 30) {
setClothes("穿短袖");
}
else if(temperature > 20) {
setClothes("穿长袖");
}
else {
setClothes("穿外套");
}
}, [temperature]); // [temperature] 表示温度变化就执行,即依赖数据变化就执行

return (
<div>
<div>当前温度: { temperature }°C</div>
<div>穿衣建议: { clothes }</div>
<button onClick={ () => setTemperature(temperature + 5) }>升温</button>
<button onClick={ () => setTemperature(temperature - 5) }>降温</button>
</div>
);
}

Vue 3 中 v-for 动态组件 ref 收集失败问题排查与解决

作者 Hashan
2026年1月7日 12:50

Vue 3 中 v-for 动态组件 ref 收集失败问题排查与解决

问题描述

在开发部门管理页面的搜索栏功能时,遇到了一个奇怪的问题:在 v-for 循环中渲染的动态组件,无法正确收集到 ref 数组中。

image.png

问题现象

// schema-search-bar.vue
const searchComList = ref([]);

const getValue = () => {
  let dtoObj = {};
  console.log("searchComList", searchComList.value); // 输出: Proxy(Array) {}
  searchComList.value.forEach((component) => {
    dtoObj = { ...dtoObj, ...component.getValue() };
  });
  return dtoObj; // 返回: {}
};

现象:

  • searchComList.value 始终是空数组 []
  • 无法获取到任何子组件的实例
  • 导致搜索功能无法正常工作

代码结构

<template>
  <el-form v-if="schema && schema.properties" :inline="true">
    <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
      <!-- 动态组件 -->
      <component 
        :ref="searchComList"  <!-- ❌ 问题所在 -->
        :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
        :schemaKey="key"
        :schema="schemaItem">
      </component>
    </el-form-item>
  </el-form>
</template>

<script setup>
const searchComList = ref([]);
</script>

排查过程

1. 初步怀疑:打印时机问题

最初怀疑是打印时机不对,组件还没有挂载完成。但即使使用 nextTick 或在 onMounted 中打印,searchComList.value 仍然是空数组。

2. 对比其他正常工作的代码

在同一个项目中,发现 schema-view.vue 中类似的代码却能正常工作:

<!-- schema-view.vue - ✅ 正常工作 -->
<component 
  v-for="(item, key) in components" 
  :key="key" 
  :is="ComponentConfig[key]?.component" 
  ref="comListRef"  <!-- ✅ 使用字符串形式 -->
  @command="onComponentCommand">
</component>

<script setup>
const comListRef = ref([]);
// comListRef.value 能正确收集到所有组件实例
</script>

3. 发现关键差异

对比两个文件的代码,发现了关键差异:

文件 ref 写法 结果
schema-view.vue ref="comListRef" (字符串) ✅ 正常工作
schema-search-bar.vue :ref="searchComList" (绑定对象) ❌ 无法收集

根本原因

Vue 3 中 v-for 使用 ref 的机制

在 Vue 3 中,v-for 中使用 ref 时,两种写法的行为完全不同

1. 字符串形式的 ref(自动收集到数组)
<component v-for="item in list" ref="comListRef" />

行为:

  • Vue 会自动将 ref 的值设置为一个数组
  • 数组中的元素按顺序对应 v-for 中的每一项
  • 这是 Vue 3 的特殊处理机制
2. 绑定 ref 对象(不会自动收集)
<component v-for="item in list" :ref="comListRef" />

行为:

  • :ref 绑定的是一个 ref 对象,Vue 会直接赋值
  • v-for 中,不会自动收集到数组
  • 每次循环都会覆盖上一次的值
  • 最终只会保留最后一个组件的引用

官方文档说明

根据 Vue 3 官方文档:

当在 v-for 中使用 ref 时,ref 的值将是一个数组,包含所有循环项对应的组件实例。

关键点: 这个特性只适用于字符串形式的 ref,不适用于 :ref 绑定。

解决方案

方案一:使用字符串形式的 ref(推荐)

<template>
  <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
    <component 
      ref="searchComList"  <!-- ✅ 去掉冒号,使用字符串形式 -->
      :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
      :schemaKey="key"
      :schema="schemaItem">
    </component>
  </el-form-item>
</template>

<script setup>
const searchComList = ref([]);
// 现在 searchComList.value 会自动收集到所有组件实例
</script>

方案二:使用函数形式的 ref(更灵活)

如果需要更精细的控制(比如去重、按 key 索引等),可以使用函数形式:

<template>
  <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
    <component 
      :ref="(el) => handleRef(el, key)"  <!-- ✅ 函数形式 -->
      :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
      :schemaKey="key"
      :schema="schemaItem">
    </component>
  </el-form-item>
</template>

<script setup>
const searchComList = ref([]);
const componentMap = new Map();

const handleRef = (el, key) => {
  if (el) {
    // 如果已经存在,先移除旧的(避免重复)
    if (componentMap.has(key)) {
      const oldIndex = searchComList.value.indexOf(componentMap.get(key));
      if (oldIndex > -1) {
        searchComList.value.splice(oldIndex, 1);
      }
    }
    // 添加新的组件实例
    componentMap.set(key, el);
    searchComList.value.push(el);
  } else {
    // 组件卸载时,从 Map 和数组中移除
    if (componentMap.has(key)) {
      const oldEl = componentMap.get(key);
      const index = searchComList.value.indexOf(oldEl);
      if (index > -1) {
        searchComList.value.splice(index, 1);
      }
      componentMap.delete(key);
    }
  }
};
</script>

技术要点总结

1. Vue 3 ref 在 v-for 中的行为

写法 在 v-for 中的行为 适用场景
ref="xxx" 自动收集到数组 ✅ 推荐,简单场景
:ref="xxx" 不会自动收集,会覆盖 ❌ 不适用于 v-for
:ref="(el) => fn(el)" 手动控制收集逻辑 ✅ 需要精细控制时

2. 最佳实践

  1. 在 v-for 中使用 ref 时,优先使用字符串形式

    <component v-for="item in list" ref="comListRef" />
    
  2. 如果需要按 key 索引或去重,使用函数形式

    <component v-for="(item, key) in list" :ref="(el) => handleRef(el, key)" />
    
  3. 避免在 v-for 中使用 :ref="refObject"

    <!-- ❌ 不推荐 -->
    <component v-for="item in list" :ref="comListRef" />
    

3. 调试技巧

当遇到 ref 收集问题时,可以:

  1. 检查 ref 的写法:确认是字符串还是绑定对象
  2. 使用 nextTick 延迟检查:确保组件已挂载
  3. 对比正常工作的代码:找出差异点
  4. 查看 Vue DevTools:检查组件实例是否正确创建

相关资源

总结

这个问题看似简单,但实际上涉及到 Vue 3 中 refv-for 中的特殊处理机制。关键点在于:

  1. 字符串形式的 ref 在 v-for 中会自动收集到数组
  2. 绑定形式的 :ref 在 v-for 中不会自动收集
  3. 函数形式的 :ref 可以手动控制收集逻辑

记住这个规则,可以避免很多类似的坑。在开发过程中,如果遇到 ref 收集问题,首先检查是否在 v-for 中使用了错误的 ref 写法。

npm推送包失败需要Two-factor权限认证问题解决

2026年1月7日 12:01

npm 发布 403(2FA/Token)排查记录

本文记录发布到 npm 时出现 403 权限错误的原因与处理方式。

报错

npm error 403 Forbidden - PUT https://registry.npmjs.org/@gogec%2fthree-scene-vue3
npm error Two-factor authentication or granular access token with bypass 2fa enabled is required to publish packages.

原因说明

账号开启了 two-factor auth: auth-and-writes,发布必须满足以下任一条件:

  • 发布时输入一次性验证码(OTP)
  • 使用 带 publish 权限且允许 bypass 2FA 的 Granular Access Token

如果使用的 token 没有发布权限,或未携带 OTP,就会 403。

解决方案

方式一:带 OTP 发布

npm publish --access public --otp=123456

方式二:使用有发布权限的 token

在 npm 官网创建 Granular Access Token

  • 勾选 Publish 权限
  • 如需绕过 2FA,勾选 bypass 2FA

image.png

image.png

设置对应权限之后,页面滚动到底部,设置过期时间,点击生成即可

image.png

如何写入 _authToken

推荐使用 npm config set

npm config set //registry.npmjs.org/:_authToken=你的token

只作用于当前项目(写入项目根目录 .npmrc):

npm config set //registry.npmjs.org/:_authToken=你的token --location=project

完成以上步骤,重新npm publish --access public推送包即可成功

image.png

基于vue3完成领域模型架构建设

作者 ccccc__
2026年1月7日 11:51

概述

crud一直在做重复性工作,那么我们是不是可以将这些重复性工作去做一个配置化操作来减少这些无效的工作内容呢。依靠一份JSON Schema的数据去驱动架构配置,以配置化的方式去构建页面。

页面功能划分

  • 横向导航区域(header-view)
  • 纵向导航区域(sider-view)
  • 内容展示区域(content-view)

内容展示区域有三种类型

  • -嵌套内容 iframe-view,
  • -自定义内容 custom-view,
  • -模板内容 schema-view,

模板内容又可以细致划分成为

  • -表单操作区schema-search-bar
  • -表单内容区 shcema-table-view
    |--------------------------------------------------------------|
                           #hearder-view
    |-------------|------------------------------------------------|
    | #sider-view |            #content-view                       |
    |             | (schema-view) | (iframe-view) | (custom-view)  |
    |             |  (search-bar) |               |                |
    |             | --------------|               |                |
    |             |  (table-view) |               |                |
    |-------------|---------------|---------------|----------------|
    

那么基于此我们就可以开始进行构造一个我们基础的一个数据格式modal了

{
    mode: 'dashboard',
    name: '' // 名称,
    desc: '' // 描述,
    homePage: '' // 具体路径
    menu: [
     {
      key: '',
      name: '',
      menuType: 'group/module', // group类型定义会存在自属的下拉菜单
      // group类型下配置
      submenu: [{}],
      // module类型下配置
      moduleType: 'iframe/custom/schema/sider', // 这里就对应我们上述设计的三大模块
      `${moduleType}Config`: {
          // sider类型
          menu: [{}] //参考如上配置
          // schema类型
          api: '', //列表请求数据接口
          schema: {
            type: 'object',
            properties: {
              key: {
               type: '', // 字段类型
               label: '', // 字段名称
               tableOptions: {
                  ...elTableColunmConfig, // el-table-colunm 配置
                  visible: true // 是否显示字段
               }
               searchOptions: {
                  ...elComponentConfig, // el-component 配置
                  comType: '' // 组件类型
                  defalut: '' // 默认展示值
                  // 当类型为select的时候
                  enmuList: [], // 配置下拉选项
               }
              }
            }
          }
          tableConfig: {
              headerButtons: [
                { 
                    label: '', // 按钮名称
                    eventKey: '', // 按钮事件标识
                    eventOption: {}, // 按钮具体配置
                    ...elButtonConfig, // el-button配置
                }
              ],
              rowButtons: [
               {
                    label: '', // 按钮名称
                    eventKey: '', // 按钮事件标识
                    eventOption: {
                        params: {
                          paramsKey: rowValueKey
                        }
                    }, // 按钮具体配置
                    ...elButtonConfig, // el-button配置
               }
              ]
          } //配置table相关
          searchConfig: {} // 配置搜索条件相关
          // 其他类型
          path: 'xxxxxx'
      }
     }
    ]
}

这样已经基础的modal模型就搭建好了,可以开始着手准备开发了

第一步我们将根据这个modal类型来创建一下我们的数据源,给定一个场景我们有多个系统,根据选择的不同系统展示不同的内容(但是结构是一致的)。

image.png

我们创建一个modal文件夹,每个modal文件夹的构造如下。 我们设计的思路如下,我们首先创建一个基类model.js,然后project.js中的是不同的子类,然后继承基类的相关内容,那么接下来就是实现这个继承功能

module.exports = (app) => {
    // 遍历当前的model文件,确定好我们要操作的文件入口
    // 遍历所有的js配置文件,通过文件名称来区分是子类还是基类
    // 初始化构造数据结构,组合project和model,=> model.project, 将project挂载到model上
    // 进一步整理数据,让project继承modal的内容(规则是先匹配key,遇到了就覆盖,如果内部还有相关数组就递归,没有就新增)
}

到这一步就已经处理好我们所需要的数据源格式了,接下来我们就要逐步实现我们的配置化渲染层面的代码了,规划一下我们的页面要展示成什么样子的。

首页

首页就需要展示各自modal和project大概内容 page下新建一个page-list的入口文件 我们需要实现一个组件header-container,主要分为三块区域左上方信息展示,右上方系统设置,以及中间的展示区域(使用插槽定义各个模块),然后在首页我们只需要自定义显示主题内容所以就使用template定位我们在header-container中的主要内容展示插槽,参考如下

    |--------------------------------------------------------------|
                                                            #setting-content
    |-------------|------------------------------------------------|
    |#menu-content|                #main-content                   |
    |             |                                                |
    |-------------|------------------------------------------------|
    
    //page-list这个页面主要绘制main-content的显示和请求我们封装好的获取数据源接口model-list, 然后渲染出来

dashboard

page下新建一个dashboard文件,我们后续的dashBoard模块的内容都在下面实现

1.创建入口文件dashboard.vue

2.创建complex-view文件,新增header-view文件

    // header-view 需要实现的就是动态渲染横向导航条数据,也就是menu下的各个对象渲染
    // 这其中就包含了自带下拉内容的子项和需要渲染侧边栏的子项
    // 其余的就渲染主体的main-content,main-content中又有多种类型区分,shcema-view就是我们这次主要实现的配置化页面

schema-view

重点实现这个配置化的页面

首先我们先将其拆分成两块内容

1.搜索区域

2.表单渲染区域

同理,那我们在schema-view下也创建一个complex-view文件用于存放我们的这两个组件 再来看下schema的配置

schemaConfig: {
        api: "/api/proj/product",
        schema: {
          type: "object",
          properties: {
            product_id: {
              type: "string",
              label: "商品ID",
              tableOption: {
                width: 300,
                "show-overflow-tooltip": true,
              },
               searchOption: {
                comType: 'select',
                enmuList: [{value:-1,label: '全部'}, {value: 1, label: 1}, {value: 2, label: 2}]
              }
            },
            product_name: {
              type: "string",
              label: "商品名称",
              tableOption: {
                width: 200,
              },
               searchOption: {
                comType: 'dynamicSelect'
              }
            },
            price: {
              type: "number",
              label: "价格",
              tableOption: {
                width: 200,
              },
               searchOption: {
                comType: 'input',
              }
            },
            inventory: {
              type: "number",
              label: "库存",
              tableOption: {
                width: 200,
              },
            },
            create_time: {
              type: "string",
              label: "创建时间",
              tableOption: {},
              searchOption: {
                comType: 'dateRange'
              },
            },
          },
          tableConfig: {
            headerButtons: [
              {
                label: "新增商品",
                eventKey: "addProduct",
                type: "primary",
                plain: true,
              },
            ],
            rowButtons: [
              {
                label: "编辑",
                eventKey: "editProduct",
                type: "warning",
              },
              {
                label: "删除",
                eventKey: "deleteProduct",
                eventOption: {
                  params: {
                    product_id: "schema::product_id",
                  },
                },
                type: "danger",
              },
            ],
          },
        },
      },

对于这一份的配置我们可以做到对一个字段进行列表和搜索的一起控制,这样我们就无需单独给搜索字段和列表字段都进行额外的处理了。这是一个思想,不单单是针对某一个功能,实际上后续我们如果想要扩展的话,可以依照此去进行配置。

回到schema-view的配置中,我们会实现一个hook,为什么需要实现这个hook呢,因为我们需要解析schema中配置,那么这份配置就在hook中实现,我们处理成我们需要的格式。

大体上简单描述了一下整体的实现过程,在这过程我们其实会对代码进行部分轻重构,将一些部分合理化简单化,例如watch监听多项,以及统一参数传递封装,路由hash等等,包括一些防抖节流类似的对接口请求的一些优化操作。我们在开发过程中发现问题,并且解决问题。而不是等到整体项目完结后才着手去做一个叫重构的事情,这样的话一旦代码间的联系多了起来,就会导致重构的难度变大。所有的组件实现都是通过入口-> 下发数据配置,行为管理 -> 具体功能实现,组件在开发过程中需要考虑三件事情,需要什么参数,接收什么数据,抛出哪些方法。

总结

总结一下,整体的思路,重点就是我们的所有开发都要依靠于这套配置文件,我们基于配置文件去进行模块的拆分,再进行功能的拆分,从而抽象各个模块的功能和各自负责的事项,赋予了可扩展性和易维护性。那么我们首先需要的就是了解我们在做什么,我们要做的东西是什么样的,大概的一个架构是怎么样的,我们才能够基于此去对其进行一个地基的建造,我们的大体方向才能够展示出来,再对其进行细致的拆分和梳理,完成各个分支的功能建设。

    |--------------------------------------------------------------|
                           #hearder-view
    |-------------|------------------------------------------------|
    | #sider-view |            #content-view                       |
    |             | (schema-view) | (iframe-view) | (custom-view)  |
    |             |  (search-bar) |               |                |
    |             | --------------|               |                |
    |             |  (table-view) |               |                |
    |-------------|---------------|---------------|----------------|
                            |               |
                  BFF(通过bff层将配置数据返回给到页面)
 ----------------------------------------------------------------------         
        子类  子类  子类      子类  子类  子类    子类  子类  子类 
               |                    |                  | 
              基类                 基类               基类
                                 
                                 配置文件

小程序"邪修"秘籍:那些官方文档不会告诉你的骚操作

2026年1月7日 11:46

摘要:小程序开发,表面上是"戴着镣铐跳舞",实际上是"在夹缝中求生存"。官方文档写得云淡风轻,实际开发却处处是坑。本文收录了多年小程序开发中积累的"邪修"技巧——那些不太正经但确实有效的解决方案。警告:部分技巧可能随时失效,请谨慎使用。


引言:小程序开发者的日常崩溃

场景一: 产品经理:"这个页面能不能像 H5 一样丝滑滚动?" 你:"小程序有性能限制..." 产品经理:"竞品可以。" 你:(内心崩溃)

场景二: 设计师:"这个动画效果很简单啊,就是一个弹性回弹。" 你:"小程序的动画 API..." 设计师:"Figma 里一秒钟就做出来了。" 你:(开始掉头发)

场景三: 测试:"这个在 iOS 上正常,安卓上怎么崩了?" 你:"我看看..."(打开微信开发者工具,一切正常) 你:"真机调试..."(问题复现) 你:"这..." 测试:"还有,在微信 8.0.32 版本上也有问题。" 你:(想转行)

欢迎来到小程序开发的世界。

今天,我要分享一些"邪修"技巧——那些官方文档不会告诉你,但能救你命的骚操作。


第一章:性能优化の黑魔法

1.1 setData 的"分片"艺术

问题: setData 数据量大时,页面卡顿严重。

官方建议: 减少 setData 的数据量。

邪修技巧: 数据分片 + 路径更新

// ❌ 错误做法:一次性更新大数组
this.setData({
  list: newList, // 假设有 1000 条数据
})

// ✅ 邪修技巧一:路径更新(只更新变化的部分)
// 假设只有第 5 条数据变了
this.setData({
  "list[5].name": "新名字",
  "list[5].status": "updated",
})

// ✅ 邪修技巧二:分片更新(大数据量时)
async function setDataInChunks(data, chunkSize = 20) {
  const keys = Object.keys(data)
  for (let i = 0; i < keys.length; i += chunkSize) {
    const chunk = {}
    keys.slice(i, i + chunkSize).forEach((key) => {
      chunk[key] = data[key]
    })
    await new Promise((resolve) => {
      this.setData(chunk, resolve)
    })
  }
}

// ✅ 邪修技巧三:虚拟列表(终极方案)
// 只渲染可视区域的数据
Page({
  data: {
    visibleList: [], // 当前可见的数据
    startIndex: 0, // 起始索引
    itemHeight: 100, // 每项高度
    containerHeight: 0, // 容器高度
  },

  fullList: [], // 完整数据放在非响应式属性中

  onScroll(e) {
    const { scrollTop } = e.detail
    const startIndex = Math.floor(scrollTop / this.data.itemHeight)
    const visibleCount =
      Math.ceil(this.data.containerHeight / this.data.itemHeight) + 2

    // 只有 startIndex 变化时才更新
    if (startIndex !== this.data.startIndex) {
      this.setData({
        startIndex,
        visibleList: this.fullList.slice(startIndex, startIndex + visibleCount),
      })
    }
  },
})

1.2 图片加载の"障眼法"

问题: 大量图片加载时,页面白屏或卡顿。

邪修技巧: 渐进式加载 + 占位图 + 懒加载

<!-- WXML -->
<view class="image-wrapper">
  <!-- 占位骨架 -->
  <view
    class="skeleton"
    wx:if="{{!imageLoaded}}"
    style="background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite;"
  />

  <!-- 缩略图(先加载小图) -->
  <image
    wx:if="{{!imageLoaded}}"
    class="thumbnail"
    src="{{thumbnailUrl}}"
    mode="aspectFill"
  />

  <!-- 原图(懒加载) -->
  <image
    class="main-image {{imageLoaded ? 'loaded' : ''}}"
    src="{{imageUrl}}"
    mode="aspectFill"
    lazy-load
    bindload="onImageLoad"
    binderror="onImageError"
  />
</view>
// JS
Page({
  data: {
    imageLoaded: false,
    thumbnailUrl: "", // 缩略图 URL(可以用 OSS 的图片处理参数生成)
    imageUrl: "",
  },

  onLoad() {
    const originalUrl = "https://example.com/big-image.jpg"
    this.setData({
      // 阿里云 OSS 图片处理:生成 100px 宽的缩略图
      thumbnailUrl: `${originalUrl}?x-oss-process=image/resize,w_100`,
      imageUrl: originalUrl,
    })
  },

  onImageLoad() {
    this.setData({ imageLoaded: true })
  },

  onImageError() {
    // 加载失败时显示默认图
    this.setData({
      imageUrl: "/images/default.png",
      imageLoaded: true,
    })
  },
})
/* WXSS */
.image-wrapper {
  position: relative;
  width: 100%;
  height: 200px;
  overflow: hidden;
}

.skeleton {
  position: absolute;
  inset: 0;
}

.thumbnail {
  position: absolute;
  inset: 0;
  filter: blur(10px);
  transform: scale(1.1);
}

.main-image {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.3s ease;
}

.main-image.loaded {
  opacity: 1;
}

@keyframes shimmer {
  0% {
    background-position: -200% 0;
  }
  100% {
    background-position: 200% 0;
  }
}

1.3 长列表の"回收站"策略

问题: 无限滚动列表,滚动久了内存爆炸。

邪修技巧: DOM 回收 + 骨架占位

// 核心思路:只保留可视区域 ± 缓冲区的真实 DOM,其他用骨架占位
Component({
  data: {
    list: [],
    recycledIndexes: new Set(), // 被回收的索引
  },

  // 配置
  BUFFER_SIZE: 5, // 缓冲区大小
  RECYCLE_THRESHOLD: 20, // 超出多少开始回收

  methods: {
    onScroll(e) {
      const { scrollTop } = e.detail
      const itemHeight = 120
      const viewportHeight = this.viewportHeight || 600

      // 计算可视区域
      const startIndex = Math.floor(scrollTop / itemHeight)
      const endIndex = Math.ceil((scrollTop + viewportHeight) / itemHeight)

      // 计算需要保留的范围(可视区 + 缓冲区)
      const keepStart = Math.max(0, startIndex - this.BUFFER_SIZE)
      const keepEnd = Math.min(
        this.data.list.length,
        endIndex + this.BUFFER_SIZE
      )

      // 回收超出范围的 DOM
      const recycledIndexes = new Set()
      this.data.list.forEach((_, index) => {
        if (
          index < keepStart - this.RECYCLE_THRESHOLD ||
          index > keepEnd + this.RECYCLE_THRESHOLD
        ) {
          recycledIndexes.add(index)
        }
      })

      this.setData({ recycledIndexes: [...recycledIndexes] })
    },
  },
})
<!-- WXML:根据是否被回收显示不同内容 -->
<scroll-view scroll-y bindscroll="onScroll" style="height: 100vh;">
  <view wx:for="{{list}}" wx:key="id" class="list-item" style="height: 120px;">
    <!-- 被回收的显示骨架 -->
    <block wx:if="{{recycledIndexes.includes(index)}}">
      <view class="skeleton-item" />
    </block>

    <!-- 未被回收的显示真实内容 -->
    <block wx:else>
      <image src="{{item.image}}" lazy-load />
      <view class="content">
        <text>{{item.title}}</text>
        <text>{{item.desc}}</text>
      </view>
    </block>
  </view>
</scroll-view>

第二章:样式の"奇技淫巧"

2.1 安全区域の"万能公式"

问题: iPhone 刘海屏、底部安全区域适配。

邪修技巧: CSS 变量 + env() + 兜底值

/* 在 app.wxss 中定义全局变量 */
page {
  --safe-area-top: env(safe-area-inset-top, 0px);
  --safe-area-bottom: env(safe-area-inset-bottom, 0px);
  --safe-area-left: env(safe-area-inset-left, 0px);
  --safe-area-right: env(safe-area-inset-right, 0px);

  /* 导航栏高度(状态栏 + 导航栏) */
  --nav-height: calc(var(--safe-area-top) + 44px);

  /* 底部 TabBar 高度 */
  --tabbar-height: calc(var(--safe-area-bottom) + 50px);
}

/* 自定义导航栏 */
.custom-navbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: var(--nav-height);
  padding-top: var(--safe-area-top);
  background: #fff;
  z-index: 999;
}

/* 页面内容(避开导航栏) */
.page-content {
  padding-top: var(--nav-height);
  padding-bottom: var(--tabbar-height);
  min-height: 100vh;
  box-sizing: border-box;
}

/* 底部固定按钮 */
.bottom-button {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 12px 16px;
  padding-bottom: calc(12px + var(--safe-area-bottom));
  background: #fff;
}

2.2 1px 边框の"像素级"方案

问题: 在高清屏上,1px 边框看起来太粗。

邪修技巧: 伪元素 + transform 缩放

/* 通用 1px 边框 mixin(用 class 实现) */

/* 底部 1px 边框 */
.border-bottom-1px {
  position: relative;
}

.border-bottom-1px::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background: #e5e5e5;
  transform: scaleY(0.5);
  transform-origin: 0 100%;
}

/* 四周 1px 边框 */
.border-1px {
  position: relative;
}

.border-1px::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 200%;
  height: 200%;
  border: 1px solid #e5e5e5;
  border-radius: inherit;
  transform: scale(0.5);
  transform-origin: 0 0;
  pointer-events: none;
  box-sizing: border-box;
}

/* 带圆角的 1px 边框 */
.border-1px-radius {
  position: relative;
  border-radius: 8px;
}

.border-1px-radius::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 200%;
  height: 200%;
  border: 1px solid #e5e5e5;
  border-radius: 16px; /* 圆角也要 *2 */
  transform: scale(0.5);
  transform-origin: 0 0;
  pointer-events: none;
  box-sizing: border-box;
}

2.3 文字截断の"终极方案"

/* 单行截断 */
.text-ellipsis {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* 多行截断(兼容性最好的方案) */
.text-ellipsis-2 {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
  text-overflow: ellipsis;
  word-break: break-all;
}

/* 多行截断 + 展开收起(需要 JS 配合) */
.text-expandable {
  position: relative;
  max-height: calc(1.5em * 3); /* 3 行 */
  overflow: hidden;
  transition: max-height 0.3s ease;
}

.text-expandable.expanded {
  max-height: none;
}

.text-expandable::after {
  content: "...展开";
  position: absolute;
  right: 0;
  bottom: 0;
  padding-left: 20px;
  background: linear-gradient(to right, transparent, #fff 50%);
  color: #1890ff;
}

.text-expandable.expanded::after {
  content: none;
}

第三章:交互の"黑科技"

3.1 下拉刷新の"自定义"方案

问题: 原生下拉刷新样式太丑,无法自定义。

邪修技巧: 禁用原生 + 自己实现

// page.json
{
  "enablePullDownRefresh": false,
  "disableScroll": false
}
<!-- WXML -->
<view class="pull-refresh-container">
  <!-- 下拉提示区域 -->
  <view
    class="pull-refresh-header"
    style="transform: translateY({{pullDistance - 80}}px);"
  >
    <view
      class="refresh-icon {{refreshing ? 'rotating' : ''}}"
      style="transform: rotate({{pullDistance * 2}}deg);"
    ></view>
    <text>{{refreshText}}</text>
  </view>

  <!-- 内容区域 -->
  <scroll-view
    scroll-y
    class="content-scroll"
    style="transform: translateY({{pullDistance}}px);"
    bindtouchstart="onTouchStart"
    bindtouchmove="onTouchMove"
    bindtouchend="onTouchEnd"
    bindscroll="onScroll"
  >
    <slot />
  </scroll-view>
</view>
// JS
Component({
  data: {
    pullDistance: 0,
    refreshing: false,
    refreshText: "下拉刷新",
    startY: 0,
    scrollTop: 0,
  },

  THRESHOLD: 80, // 触发刷新的阈值

  methods: {
    onTouchStart(e) {
      if (this.data.refreshing) return
      this.setData({ startY: e.touches[0].clientY })
    },

    onTouchMove(e) {
      if (this.data.refreshing) return
      if (this.data.scrollTop > 0) return // 不在顶部时不触发

      const currentY = e.touches[0].clientY
      const distance = currentY - this.data.startY

      if (distance > 0) {
        // 阻尼效果:拉得越远,阻力越大
        const pullDistance = Math.min(distance * 0.5, 120)
        const refreshText =
          pullDistance >= this.THRESHOLD ? "释放刷新" : "下拉刷新"

        this.setData({ pullDistance, refreshText })
      }
    },

    onTouchEnd() {
      if (this.data.refreshing) return

      if (this.data.pullDistance >= this.THRESHOLD) {
        // 触发刷新
        this.setData({
          pullDistance: this.THRESHOLD,
          refreshing: true,
          refreshText: "刷新中...",
        })

        this.triggerEvent("refresh")
      } else {
        // 回弹
        this.setData({ pullDistance: 0 })
      }
    },

    onScroll(e) {
      this.setData({ scrollTop: e.detail.scrollTop })
    },

    // 外部调用:刷新完成
    stopRefresh() {
      this.setData({
        pullDistance: 0,
        refreshing: false,
        refreshText: "下拉刷新",
      })
    },
  },
})

3.2 手势密码の"纯 Canvas"实现

// 手势密码组件
Component({
  data: {
    points: [], // 9 个点的坐标
    selectedPoints: [], // 已选中的点
    touchPoint: null, // 当前触摸点
  },

  lifetimes: {
    attached() {
      this.initCanvas()
    },
  },

  methods: {
    initCanvas() {
      const query = this.createSelectorQuery()
      query
        .select("#gesture-canvas")
        .fields({ node: true, size: true })
        .exec((res) => {
          const canvas = res[0].node
          const ctx = canvas.getContext("2d")

          // 设置 canvas 尺寸
          const dpr = wx.getSystemInfoSync().pixelRatio
          canvas.width = res[0].width * dpr
          canvas.height = res[0].height * dpr
          ctx.scale(dpr, dpr)

          this.canvas = canvas
          this.ctx = ctx
          this.canvasWidth = res[0].width
          this.canvasHeight = res[0].height

          // 初始化 9 个点
          this.initPoints()
          this.draw()
        })
    },

    initPoints() {
      const padding = 50
      const width = this.canvasWidth - padding * 2
      const gap = width / 2
      const points = []

      for (let row = 0; row < 3; row++) {
        for (let col = 0; col < 3; col++) {
          points.push({
            x: padding + col * gap,
            y: padding + row * gap,
            index: row * 3 + col,
          })
        }
      }

      this.setData({ points })
    },

    draw() {
      const { ctx, canvasWidth, canvasHeight } = this
      const { points, selectedPoints, touchPoint } = this.data

      // 清空画布
      ctx.clearRect(0, 0, canvasWidth, canvasHeight)

      // 画连线
      if (selectedPoints.length > 0) {
        ctx.beginPath()
        ctx.strokeStyle = "#1890ff"
        ctx.lineWidth = 3
        ctx.lineCap = "round"
        ctx.lineJoin = "round"

        selectedPoints.forEach((pointIndex, i) => {
          const point = points[pointIndex]
          if (i === 0) {
            ctx.moveTo(point.x, point.y)
          } else {
            ctx.lineTo(point.x, point.y)
          }
        })

        // 连接到当前触摸点
        if (touchPoint) {
          ctx.lineTo(touchPoint.x, touchPoint.y)
        }

        ctx.stroke()
      }

      // 画点
      points.forEach((point, index) => {
        const isSelected = selectedPoints.includes(index)

        // 外圈
        ctx.beginPath()
        ctx.arc(point.x, point.y, 25, 0, Math.PI * 2)
        ctx.strokeStyle = isSelected ? "#1890ff" : "#ddd"
        ctx.lineWidth = 2
        ctx.stroke()

        // 内圈
        ctx.beginPath()
        ctx.arc(point.x, point.y, isSelected ? 10 : 5, 0, Math.PI * 2)
        ctx.fillStyle = isSelected ? "#1890ff" : "#ddd"
        ctx.fill()
      })
    },

    onTouchStart(e) {
      this.setData({ selectedPoints: [], touchPoint: null })
      this.handleTouch(e)
    },

    onTouchMove(e) {
      this.handleTouch(e)
    },

    onTouchEnd() {
      const { selectedPoints } = this.data

      if (selectedPoints.length >= 4) {
        // 触发事件,返回密码
        this.triggerEvent("complete", {
          password: selectedPoints.join(""),
        })
      } else if (selectedPoints.length > 0) {
        // 密码太短
        this.triggerEvent("error", {
          message: "至少连接 4 个点",
        })
      }

      this.setData({ touchPoint: null })
      this.draw()
    },

    handleTouch(e) {
      const touch = e.touches[0]
      const { points, selectedPoints } = this.data

      // 获取相对于 canvas 的坐标
      const query = this.createSelectorQuery()
      query
        .select("#gesture-canvas")
        .boundingClientRect((rect) => {
          const x = touch.clientX - rect.left
          const y = touch.clientY - rect.top

          // 检查是否触碰到某个点
          points.forEach((point, index) => {
            const distance = Math.sqrt(
              Math.pow(x - point.x, 2) + Math.pow(y - point.y, 2)
            )

            if (distance < 30 && !selectedPoints.includes(index)) {
              selectedPoints.push(index)
              this.setData({ selectedPoints })
            }
          })

          this.setData({ touchPoint: { x, y } })
          this.draw()
        })
        .exec()
    },
  },
})

第四章:数据の"骚操作"

4.1 本地存储の"加密"方案

问题: wx.setStorageSync 存储的数据是明文,容易被篡改。

邪修技巧: 简单加密 + 签名校验

// utils/secureStorage.js
const SECRET_KEY = "your-secret-key-here" // 实际项目中应该更复杂

// 简单的加密函数(生产环境建议用更强的加密)
function encrypt(data) {
  const str = JSON.stringify(data)
  // Base64 编码 + 简单混淆
  const base64 = wx.arrayBufferToBase64(new TextEncoder().encode(str))
  // 添加签名
  const signature = generateSignature(base64)
  return `${base64}.${signature}`
}

function decrypt(encryptedData) {
  try {
    const [base64, signature] = encryptedData.split(".")

    // 验证签名
    if (generateSignature(base64) !== signature) {
      console.warn("数据签名验证失败,可能被篡改")
      return null
    }

    // 解码
    const buffer = wx.base64ToArrayBuffer(base64)
    const str = new TextDecoder().decode(buffer)
    return JSON.parse(str)
  } catch (e) {
    console.error("解密失败", e)
    return null
  }
}

// 生成签名(简单实现,生产环境用 HMAC)
function generateSignature(data) {
  let hash = 0
  const str = data + SECRET_KEY
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i)
    hash = (hash << 5) - hash + char
    hash = hash & hash
  }
  return Math.abs(hash).toString(16)
}

// 封装的安全存储 API
export const secureStorage = {
  set(key, value, options = {}) {
    const { encrypt: shouldEncrypt = true, expire = 0 } = options

    const data = {
      value,
      timestamp: Date.now(),
      expire: expire > 0 ? Date.now() + expire : 0,
    }

    const storageValue = shouldEncrypt ? encrypt(data) : JSON.stringify(data)
    wx.setStorageSync(key, storageValue)
  },

  get(key, options = {}) {
    const { decrypt: shouldDecrypt = true, defaultValue = null } = options

    try {
      const storageValue = wx.getStorageSync(key)
      if (!storageValue) return defaultValue

      const data = shouldDecrypt
        ? decrypt(storageValue)
        : JSON.parse(storageValue)

      if (!data) return defaultValue

      // 检查是否过期
      if (data.expire > 0 && Date.now() > data.expire) {
        wx.removeStorageSync(key)
        return defaultValue
      }

      return data.value
    } catch (e) {
      return defaultValue
    }
  },

  remove(key) {
    wx.removeStorageSync(key)
  },
}

// 使用示例
secureStorage.set("userToken", "abc123", { expire: 7 * 24 * 60 * 60 * 1000 }) // 7天过期
const token = secureStorage.get("userToken")

4.2 请求の"智能重试"

// utils/request.js
const MAX_RETRY = 3
const RETRY_DELAY = 1000

// 判断是否应该重试
function shouldRetry(error, retryCount) {
  if (retryCount >= MAX_RETRY) return false

  // 网络错误重试
  if (error.errMsg?.includes("request:fail")) return true

  // 超时重试
  if (error.errMsg?.includes("timeout")) return true

  // 5xx 错误重试
  if (error.statusCode >= 500) return true

  return false
}

// 延迟函数
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

// 带重试的请求
async function requestWithRetry(options, retryCount = 0) {
  try {
    const response = await new Promise((resolve, reject) => {
      wx.request({
        ...options,
        success: (res) => {
          if (res.statusCode >= 200 && res.statusCode < 300) {
            resolve(res)
          } else {
            reject({ ...res, errMsg: `HTTP ${res.statusCode}` })
          }
        },
        fail: reject,
      })
    })

    return response
  } catch (error) {
    if (shouldRetry(error, retryCount)) {
      console.log(
        `请求失败,${RETRY_DELAY}ms 后重试 (${retryCount + 1}/${MAX_RETRY})`
      )

      // 指数退避
      await delay(RETRY_DELAY * Math.pow(2, retryCount))

      return requestWithRetry(options, retryCount + 1)
    }

    throw error
  }
}

// 请求队列(防止并发过多)
class RequestQueue {
  constructor(maxConcurrent = 5) {
    this.maxConcurrent = maxConcurrent
    this.currentCount = 0
    this.queue = []
  }

  async add(requestFn) {
    if (this.currentCount >= this.maxConcurrent) {
      // 等待队列
      await new Promise((resolve) => this.queue.push(resolve))
    }

    this.currentCount++

    try {
      return await requestFn()
    } finally {
      this.currentCount--

      // 释放队列中的下一个
      if (this.queue.length > 0) {
        const next = this.queue.shift()
        next()
      }
    }
  }
}

const requestQueue = new RequestQueue(5)

// 最终封装的请求函数
export async function request(options) {
  return requestQueue.add(() =>
    requestWithRetry({
      timeout: 10000,
      ...options,
      header: {
        "Content-Type": "application/json",
        ...options.header,
      },
    })
  )
}

4.3 全局状态の"响应式"方案

问题: 小程序没有 Vuex/Redux,跨页面状态管理很麻烦。

邪修技巧: 简易响应式 Store

// store/index.js
class Store {
  constructor(initialState = {}) {
    this.state = initialState
    this.listeners = new Map()
    this.computedCache = new Map()
  }

  // 获取状态
  getState(path) {
    if (!path) return this.state
    return path.split(".").reduce((obj, key) => obj?.[key], this.state)
  }

  // 设置状态
  setState(path, value) {
    const keys = path.split(".")
    const lastKey = keys.pop()
    const target = keys.reduce((obj, key) => {
      if (!obj[key]) obj[key] = {}
      return obj[key]
    }, this.state)

    const oldValue = target[lastKey]
    target[lastKey] = value

    // 通知监听者
    this.notify(path, value, oldValue)
  }

  // 批量更新
  batchUpdate(updates) {
    Object.entries(updates).forEach(([path, value]) => {
      this.setState(path, value)
    })
  }

  // 订阅变化
  subscribe(path, callback, immediate = false) {
    if (!this.listeners.has(path)) {
      this.listeners.set(path, new Set())
    }
    this.listeners.get(path).add(callback)

    // 立即执行一次
    if (immediate) {
      callback(this.getState(path), undefined)
    }

    // 返回取消订阅函数
    return () => {
      this.listeners.get(path)?.delete(callback)
    }
  }

  // 通知监听者
  notify(path, newValue, oldValue) {
    // 精确匹配
    this.listeners.get(path)?.forEach((cb) => cb(newValue, oldValue))

    // 父路径也要通知(如 'user' 变化时,'user.name' 的监听者也要通知)
    const parts = path.split(".")
    for (let i = parts.length - 1; i > 0; i--) {
      const parentPath = parts.slice(0, i).join(".")
      this.listeners.get(parentPath)?.forEach((cb) => {
        cb(this.getState(parentPath), undefined)
      })
    }

    // 清除相关的计算缓存
    this.computedCache.clear()
  }

  // 计算属性
  computed(name, getter) {
    if (this.computedCache.has(name)) {
      return this.computedCache.get(name)
    }

    const value = getter(this.state)
    this.computedCache.set(name, value)
    return value
  }
}

// 创建全局 store
export const store = new Store({
  user: null,
  cart: [],
  settings: {
    theme: "light",
    language: "zh-CN",
  },
})

// 页面 Mixin:自动绑定 store 到页面 data
export function connectStore(mapState = {}) {
  return {
    data: {},

    onLoad() {
      this._storeUnsubscribes = []

      // 订阅 store 变化
      Object.entries(mapState).forEach(([dataKey, storePath]) => {
        // 初始化数据
        this.setData({ [dataKey]: store.getState(storePath) })

        // 订阅变化
        const unsubscribe = store.subscribe(storePath, (value) => {
          this.setData({ [dataKey]: value })
        })

        this._storeUnsubscribes.push(unsubscribe)
      })
    },

    onUnload() {
      // 取消订阅
      this._storeUnsubscribes?.forEach((unsub) => unsub())
    },
  }
}

// 使用示例
// pages/cart/cart.js
import { store, connectStore } from "../../store/index"

Page({
  ...connectStore({
    cartItems: "cart",
    user: "user",
  }),

  addToCart(item) {
    const cart = store.getState("cart")
    store.setState("cart", [...cart, item])
  },

  clearCart() {
    store.setState("cart", [])
  },
})

第五章:调试の"神器"

5.1 自制调试面板

// components/debug-panel/debug-panel.js
Component({
  data: {
    visible: false,
    logs: [],
    systemInfo: {},
    networkType: "",
    performance: {},
  },

  lifetimes: {
    attached() {
      // 劫持 console
      this.hijackConsole()

      // 获取系统信息
      this.getSystemInfo()

      // 监听网络变化
      this.watchNetwork()

      // 性能监控
      this.watchPerformance()
    },
  },

  methods: {
    toggle() {
      this.setData({ visible: !this.data.visible })
    },

    hijackConsole() {
      const originalLog = console.log
      const originalError = console.error
      const originalWarn = console.warn

      const addLog = (type, args) => {
        const log = {
          type,
          content: args
            .map((arg) =>
              typeof arg === "object"
                ? JSON.stringify(arg, null, 2)
                : String(arg)
            )
            .join(" "),
          time: new Date().toLocaleTimeString(),
        }

        this.setData({
          logs: [...this.data.logs.slice(-99), log], // 最多保留 100 条
        })
      }

      console.log = (...args) => {
        addLog("log", args)
        originalLog.apply(console, args)
      }

      console.error = (...args) => {
        addLog("error", args)
        originalError.apply(console, args)
      }

      console.warn = (...args) => {
        addLog("warn", args)
        originalWarn.apply(console, args)
      }
    },

    getSystemInfo() {
      const systemInfo = wx.getSystemInfoSync()
      this.setData({ systemInfo })
    },

    watchNetwork() {
      wx.getNetworkType({
        success: (res) => {
          this.setData({ networkType: res.networkType })
        },
      })

      wx.onNetworkStatusChange((res) => {
        this.setData({ networkType: res.networkType })
      })
    },

    watchPerformance() {
      // 获取性能数据
      const performance = wx.getPerformance()
      const observer = performance.createObserver((entryList) => {
        const entries = entryList.getEntries()
        entries.forEach((entry) => {
          console.log(`[Performance] ${entry.name}: ${entry.duration}ms`)
        })
      })

      observer.observe({ entryTypes: ["render", "script", "navigation"] })
    },

    clearLogs() {
      this.setData({ logs: [] })
    },

    copyLogs() {
      const text = this.data.logs
        .map((log) => `[${log.time}] [${log.type}] ${log.content}`)
        .join("\n")

      wx.setClipboardData({
        data: text,
        success: () => {
          wx.showToast({ title: "已复制到剪贴板" })
        },
      })
    },
  },
})
<!-- debug-panel.wxml -->
<view class="debug-trigger" bindtap="toggle">🐛</view>

<view class="debug-panel {{visible ? 'visible' : ''}}">
  <view class="debug-header">
    <text>调试面板</text>
    <text bindtap="toggle"></text>
  </view>

  <view class="debug-tabs">
    <text class="tab active">日志</text>
    <text class="tab">系统</text>
    <text class="tab">网络</text>
  </view>

  <scroll-view class="debug-content" scroll-y>
    <view wx:for="{{logs}}" wx:key="index" class="log-item log-{{item.type}}">
      <text class="log-time">{{item.time}}</text>
      <text class="log-content">{{item.content}}</text>
    </view>
  </scroll-view>

  <view class="debug-footer">
    <button size="mini" bindtap="clearLogs">清空</button>
    <button size="mini" bindtap="copyLogs">复制</button>
  </view>
</view>

5.2 性能监控埋点

// utils/performance.js
class PerformanceMonitor {
  constructor() {
    this.marks = new Map()
    this.measures = []
  }

  // 标记开始
  mark(name) {
    this.marks.set(name, Date.now())
  }

  // 测量耗时
  measure(name, startMark, endMark) {
    const start = this.marks.get(startMark)
    const end = endMark ? this.marks.get(endMark) : Date.now()

    if (!start) {
      console.warn(`Mark "${startMark}" not found`)
      return
    }

    const duration = end - start
    const measure = { name, duration, timestamp: Date.now() }

    this.measures.push(measure)

    // 超过阈值告警
    if (duration > 1000) {
      console.warn(`[Performance] ${name} 耗时 ${duration}ms,超过 1s 阈值`)
    }

    return duration
  }

  // 自动测量函数执行时间
  async measureAsync(name, fn) {
    const startMark = `${name}_start`
    this.mark(startMark)

    try {
      const result = await fn()
      this.measure(name, startMark)
      return result
    } catch (error) {
      this.measure(`${name}_error`, startMark)
      throw error
    }
  }

  // 获取报告
  getReport() {
    return {
      measures: this.measures,
      summary: {
        total: this.measures.length,
        avgDuration:
          this.measures.reduce((sum, m) => sum + m.duration, 0) /
          this.measures.length,
        maxDuration: Math.max(...this.measures.map((m) => m.duration)),
        slowCount: this.measures.filter((m) => m.duration > 1000).length,
      },
    }
  }

  // 上报数据
  report() {
    const report = this.getReport()

    // 上报到服务器
    wx.request({
      url: "https://your-api.com/performance",
      method: "POST",
      data: report,
    })

    // 清空数据
    this.measures = []
  }
}

export const perfMonitor = new PerformanceMonitor()

// 使用示例
// 页面加载性能监控
Page({
  onLoad() {
    perfMonitor.mark("pageLoad_start")
  },

  onReady() {
    perfMonitor.measure("pageLoad", "pageLoad_start")
  },

  async fetchData() {
    const data = await perfMonitor.measureAsync("fetchData", async () => {
      const res = await request({ url: "/api/data" })
      return res.data
    })

    this.setData({ data })
  },
})

写在最后:邪修有风险,使用需谨慎

这些"邪修"技巧,都是在实际项目中踩坑后总结出来的。

它们有几个共同特点:

  1. 官方文档不会告诉你:因为这些不是"标准做法"
  2. 可能随时失效:微信更新后,某些 hack 可能不再有效
  3. 有一定风险:绕过官方限制,可能带来兼容性问题
  4. 但确实有效:在特定场景下,能解决实际问题

使用建议:

  • 优先使用官方方案
  • 邪修技巧作为备选
  • 做好兼容性测试
  • 关注微信更新日志
  • 随时准备替代方案

最后,愿你的小程序:

  • 性能如丝般顺滑
  • 体验如原生般流畅
  • Bug 如晨露般消散
  • 审核如绿灯般通过

💬 互动时间:你在小程序开发中遇到过什么奇葩问题?用了什么骚操作解决的?评论区分享一下你的"邪修"经验!

觉得这篇文章有用?点赞 + 在看 + 转发,让更多小程序开发者少踩坑~


本文作者是一个在小程序坑里摸爬滚打多年的老开发。关注我,一起在微信的"围墙花园"里优雅地生存。

scss mixin svg 颜色控制 以及与 png 方案对比讨论

2026年1月7日 11:40

初来时发现团队内项目对图标的使用上,已经形成了围绕 svg 的一套 设计 & code 规范:

  • 图标基本是 svg 作为组件引入项目中
  • 设计师使用 svg 的 fill 属性来控制图标颜色,方便开发使用 css 直接变换颜色

优势就是一些 hover 之类的复杂交互状态下,需要图标变色时,可以直接通过 css 控制颜色,而不是引入多张像素图,确实比较方便。 但是这种方式依然存在一些问题

  1. css 中每次需要变色时在单独处理 css fill 等属性,效率低下
  2. 一些色彩丰富的图是没有设计变色状态的,不应该被复用的交互变色 css 覆盖样式
  3. 一些颜色是通过 stroke 而不是 fill 实现,fill 反而被用作透明 filter 掉底色
  4. 复杂的非规则图形,使用 path 实现,大小上相较于 png 反而没有容量优势

基于遇到的问题,逐步探索出了一些经验以及方法

scss mixin

首先使用 scss mixin 构建了一个 快捷方法

/** 可以保持 文本颜色与 svg 颜色一致 */
@mixin colorWithSvg($color: #ffffff) {
  & {
    fill: $color;
    path {
      fill: $color;
    }
    ellipse {
      stroke: $color;
      fill: $color;
    }
  }
}

进一步解决 不需要变色的 svg 的问题 以及 只想要影响 svg 不影响文本色

问题 3 中只能规范设计,而 filter 底色的问题 css 不会覆盖 path 上的 fill-opacity ,实际使用时注意实际效果即可


因为多色图标属于少数,所以在项目中引入 svg 时手动添加一个 owner-color 属性来标记不需要被变色的 svg

<svg owner-color="true" ...></svg>
/** svg 填充色 */
@mixin colorFillSvg($color: #ffffff, $svgCheckOwner: false) {
  @if $svgCheckOwner {
    &:not([owner-color]) {
      // ...
    }
  } @else {
    & {
      // ...
    }
  }
}

/** font color 与下级 svg 填充色一致 */
@mixin colorWithSvg($color: #ffffff, $svgCheckOwner: false) {
  color: $color;

  svg {
    @include colorFillSvg(currentColor, $svgCheckOwner);
  }
}

svg 压缩

svg 如果绘制了一个非常复杂的图形,那么将会失去他的体积优势,这时候就需要对 svg 进行压缩,可以参考张鑫旭 - SVG 在线压缩合并工具


当前 svg 使用时需要注意的是能使用的 安全压缩选项 是有限的,压缩后仍需要注意检查,路径精度压缩 即可压缩大部分体积

有选择地使用 svg

在实际使用中可以总结出 svg 的优点:

  • 变色方便(需要检查实际效果)
  • 无损放大
  • 体积小(简单图形,需要注意压缩路径精度)

但仍有一些缺点:

  • 复杂图形体积大,2x.png 小得多
  • 相同 svg 在一个上下文中,def 会因为当前生效 css 互相影响
  • 变色仍有一定的限制
  • document reflow/repaint 影响性能

所以 svg 图标还是一个需要花费精力处理的资源类型,不能盲目追求 svg 化

Chrome 插件开发实战:5 分钟上手 + 原理深度解析

作者 借个火er
2026年1月7日 11:39

Chrome 插件开发实战:5 分钟上手 + 原理深度解析

本文带你从零做出第一个插件,再深入理解底层运行机制。

一、5 分钟做出你的第一个插件

Chrome 插件本质上就是一个文件夹,里面放几个文件。

最小结构(只需 2 个文件)

hello-extension/
├── manifest.json   ← 插件的"身份证"
└── popup.html      ← 点击图标弹出的页面

动手做

1. 创建 manifest.json

{
  "manifest_version": 3,
  "name": "Hello 插件",
  "version": "1.0.0",
  "action": {
    "default_popup": "popup.html"
  }
}

2. 创建 popup.html

<!DOCTYPE html>
<html>
<head>
  <style>
    body { width: 200px; padding: 20px; text-align: center; }
  </style>
</head>
<body>
  <h2>🎉 成功了!</h2>
</body>
</html>

3. 加载到 Chrome

  1. 地址栏输入 chrome://extensions/
  2. 打开右上角「开发者模式」
  3. 点击「加载已解压的扩展程序」
  4. 选择文件夹

点击工具栏图标,看到弹窗就成功了!


二、理解插件的运行机制

2.1 插件的三个运行环境

Chrome 插件有三个独立的运行环境,它们各司其职:

┌──────────────────────────────────────────────────────────────┐
│                       Chrome 浏览器                           │
│                                                              │
│  ┌────────────────┐                                          │
│  │    Popup       │  用户界面                                 │
│  │   (弹窗页面)    │  • 点击图标时创建,关闭即销毁              │
│  │                │  • 有自己独立的 DOM 和 JS 上下文           │
│  └───────┬────────┘                                          │
│          │                                                   │
│          │ chrome.scripting.executeScript()                  │
│          ▼                                                   │
│  ┌────────────────────────────────────────────────────────┐ │
│  │                    网页 (Web Page)                      │ │
│  │  ┌──────────────────────────────────────────────────┐  │ │
│  │  │              Content Script                       │  │ │
│  │  │              (注入的脚本)                          │  │ │
│  │  │                                                   │  │ │
│  │  │  ✅ 可以访问网页 DOM                               │  │ │
│  │  │  ✅ 可以读取 localStorage / Cookie                 │  │ │
│  │  │  ✅ 发起请求时自动携带该网站的登录态                │  │ │
│  │  │  ❌ 不能访问网页的 JS 变量                         │  │ │
│  │  └──────────────────────────────────────────────────┘  │ │
│  └────────────────────────────────────────────────────────┘ │
│                                                              │
│  ┌────────────────┐                                          │
│  │  Background    │  后台服务                                 │
│  │ (Service Worker)│  • 监听浏览器事件(标签切换、安装等)      │
│  │                │  • 处理跨页面的逻辑                        │
│  │                │  • 按需唤醒,空闲时休眠                    │
│  └────────────────┘                                          │
└──────────────────────────────────────────────────────────────┘

2.2 为什么要有 Content Script?

这是很多新手困惑的点。看这个场景:

需求:做一个插件,点击按钮后获取当前网页的 Cookie

错误做法:直接在 popup.js 里读

// ❌ 这样拿到的是插件自己的 Cookie,不是网页的
document.cookie  // 空的

正确做法:注入代码到网页执行

// ✅ 把代码注入到网页上下文执行
chrome.scripting.executeScript({
  target: { tabId: tab.id },
  func: () => document.cookie  // 这行代码在网页里执行
});

原理图解

Popup 的世界                    网页的世界
┌─────────────┐                ┌─────────────┐
│ popup.js    │                │ example.com │
│             │                │             │
│ document    │                │ document    │
│ .cookie     │                │ .cookie     │
│   ↓         │                │   ↓         │
│  空的       │   注入代码 →    │ "token=xxx" │
└─────────────┘                └─────────────┘
   独立沙箱                        网页上下文

2.3 三种环境的能力对比

能力 Popup Content Script Background
显示界面
访问网页 DOM
读取网页 Cookie
发请求带网页登录态
使用 Chrome APIs 部分
持久运行

三、权限系统详解

3.1 权限声明

在 manifest.json 中声明需要的权限:

{
  "permissions": ["activeTab", "scripting", "storage"],
  "host_permissions": ["https://example.com/*"]
}

3.2 常用权限速查

权限 用途 什么时候需要
activeTab 访问当前标签页 获取页面 URL、标题
scripting 注入脚本到网页 操作网页内容
storage 存储数据 保存用户设置
cookies 读写 Cookie 管理登录态
tabs 管理所有标签页 批量操作标签
notifications 发送通知 提醒用户

3.3 host_permissions 的作用

"host_permissions": ["https://example.com/*"]

这行配置决定了:

  • 你的插件能往哪些网站注入代码
  • 你的插件能访问哪些网站的 Cookie

最小权限原则:只申请需要的域名,不要用 <all_urls>


四、数据通信机制

4.1 Popup → Content Script(最常用)

// popup.js
const [result] = await chrome.scripting.executeScript({
  target: { tabId: tab.id },
  func: (param) => {
    // 这段代码在网页里执行
    console.log('收到参数:', param);
    return document.title;
  },
  args: ['hello']  // 传参
});

console.log('网页标题:', result.result);

4.2 各组件间通信

// 发送消息
chrome.runtime.sendMessage({ action: 'getData' });

// 接收消息
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'getData') {
    sendResponse({ data: 'hello' });
  }
});

4.3 数据存储

// 存储(异步)
await chrome.storage.local.set({ token: 'xxx' });

// 读取
const { token } = await chrome.storage.local.get('token');

// 监听变化
chrome.storage.onChanged.addListener((changes) => {
  console.log('数据变了:', changes);
});

五、实战案例:API 调用器

5.1 需求

做一个插件,在 erp.example.com 网站上一键调用 API,自动携带登录态。

5.2 难点分析

直接从 popup.js 发请求:
  popup.jsfetch(erp.example.com) → ❌ 跨域,没有 Cookie

解决方案:
  popup.js → 注入代码到网页 → 在网页里 fetch → ✅ 同源,有 Cookie

5.3 核心代码

// popup.js
document.getElementById('btn').onclick = async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  
  const [result] = await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: async (apiParams) => {
      // ↓↓↓ 这段代码在网页上下文执行 ↓↓↓
      
      // 读取 Cookie 中的 token
      const getToken = () => {
        const match = document.cookie.match(/token=([^;]+)/);
        return match ? match[1] : '';
      };
      
      // 发起请求(自动携带 Cookie)
      const response = await fetch('/api/data', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${getToken()}`,
          'Content-Type': 'application/json'
        },
        credentials: 'include',
        body: JSON.stringify(apiParams)
      });
      
      return await response.json();
    },
    args: [{ key: 'value' }]
  });
  
  console.log('API 返回:', result.result);
};

5.4 manifest.json

{
  "manifest_version": 3,
  "name": "API 调用器",
  "version": "1.0.0",
  "action": { "default_popup": "popup.html" },
  "permissions": ["activeTab", "scripting"],
  "host_permissions": ["https://erp.example.com/*"]
}

六、调试技巧

调试目标 方法
Popup 页面 右键插件图标 → 检查弹出内容
注入的代码 网页 F12 → Console
Background 扩展管理页 → 点击「Service Worker」
网络请求 网页 F12 → Network

热更新:修改代码后,去 chrome://extensions/ 点刷新按钮


七、常见坑点

7.1 图标不显示

icon.svg   → Chrome 不支持 SVG
✅ icon.png   → 必须是 PNG,建议 128x128

7.2 代码不生效

// ❌ 错误:executeScript 的 func 不能引用外部变量
const url = 'https://api.com';
chrome.scripting.executeScript({
  func: () => fetch(url)  // url 是 undefined!
});

// ✅ 正确:通过 args 传参
chrome.scripting.executeScript({
  func: (url) => fetch(url),
  args: ['https://api.com']
});

7.3 跨域问题

症状:fetch 报 CORS 错误
原因:从 popup.js 直接请求其他域名
解决:用 executeScript 注入到目标网页执行

八、发布到 Chrome 商店

  1. 注册开发者账号(一次性 $5)
  2. 打包成 zip(不含 node_modules)
  3. 上传到 Chrome Web Store
  4. 填写描述、截图、隐私政策
  5. 等待审核(1-3 天)

九、总结

Chrome 插件 = manifest.json + 界面 + 逻辑

三个运行环境:
├── Popup      → 用户界面,点击图标显示
├── Content    → 注入网页,操作页面内容
└── Background → 后台服务,监听事件

核心技能:
├── chrome.scripting.executeScript → 注入代码到网页
├── chrome.storage → 存储数据
└── chrome.runtime.sendMessage → 组件间通信

掌握这些,你就能开发 90% 的 Chrome 插件了。

参考资料

从一行好奇的代码说起:React的 useEffect 到底是不是生命周期?

作者 雲墨款哥
2026年1月7日 11:37

React的 useEffect 到底是不是生命周期?

useEffect 并不是 React 为“生命周期”起的别名,而是连接纯函数世界与副作用世界的桥梁,它用“同步”重新定义了“时机”。

最近换了新工作,时间比较充裕。作为一名一直使用 Vue 的前端开发者,我决定系统性地学习一下 React。这不,我很快就接触到了 useEffect 这个核心 Hook。

初步了解后,我发现它的用途很特别:在组件渲染之后,可以用来请求数据、操作 DOM 或者订阅事件,执行那些被称为“副作用”的操作。我脑海里立刻冒出一个想法——这应该就是 React 的“生命周期”方法了吧?  就像 Vue 里的 onMountedonUpdated 那样。

然而,随着我查阅更多文档和教程,深入理解它的设计后,我发现了一个更有趣的真相。useEffect 并不是对传统生命周期概念的简单封装,其背后体现了 React 与 Vue 在核心设计模式与开发者心智模型上的根本性差异。这种差异远比 API 表面的不同要深刻得多。

于是,我将自己的学习与思考整理下来,与大家分享这次从“好奇”到“解惑”的探索过程。

从一行“像生命周期”的代码说起

在教程里我遇到了下面这段“神奇”的代码:

import { useState, useEffect } from 'react';

function MyComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    // 在这里获取数据
    fetchData().then(setData);
  }, []); // 注意这个空数组!

  return <div>{data}</div>;
}

这个时候,我就想在vue中不也是有这种操作吗,就是在组件挂载的时候,我们请求数据,也就是onMounted()函数,就像下面这样

<!-- 这是我熟悉的Vue写法 -->
<script setup>
import { ref, onMounted } from 'vue'

const data = ref(null)

onMounted(() => {
  // 在挂载完成后执行
  fetchData().then(res => data.value = res)
})
</script>

所以,我当时的第一想法就是,useEffect就是react的生命周期方法,那对应的肯定还得有像update,unMounted这种函数吧

但是,随着我的深入了解,我惊奇的发现,在 React 的函数组件世界里,之前想象的onCreatedonMountedonUpdated,似乎都指向了同一个答案——useEffect 这“一招”就能全部搞定

useEffect是如何搞定的?

为了理解这个巨大的差异,我们先来看看在Vue中,一个典型的、包含完整生命周期的组件是怎样的:

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

const userId = ref(1)
const userData = ref(null)

// 1. 创建后:数据观测完成,可访问响应式数据,但DOM未挂载
console.log('组件实例已创建')

// 2. 挂载后:DOM已就绪,可安全操作DOM
onMounted(() => {
  fetchUser(userId.value).then(data => userData.value = data)
})

// 3. 更新后:任意响应式数据变化导致的DOM更新完成后
onUpdated(() => {
  console.log('视图已更新')
})

// 4. 监听特定数据变化
watch(userId, (newId) => {
  fetchUser(newId).then(data => userData.value = data)
})

// 5. 卸载前:清理资源
onUnmounted(() => {
  console.log('组件即将销毁')
})
</script>

现在,让我们再来看看在React中,useEffect是如何“一招鲜”实现上述所有逻辑:

import { useState, useEffect } from 'react';

function UserProfile({ initialUserId }) {
  const [userId, setUserId] = useState(initialUserId);
  const [userData, setUserData] = useState(null);
  
  // 1. 类似"创建后":函数体本身就是创建阶段
  console.log('组件函数执行(每次渲染都会发生)');
  
  // 2. 类似"挂载后" + 监听userId变化:用依赖数组控制执行时机
  useEffect(() => {
    // 这部分逻辑在组件首次渲染后执行(类似onMounted)
    // 并且在userId变化时重新执行(类似watch(userId, ...))
    fetchUser(userId).then(setUserData);
    
    // 5. 清理函数:类似onUnmounted,但更精细
    return () => {
      console.log('清理与本次userId相关的资源');
    };
  }, [userId]); // 关键:依赖数组声明了执行条件
  
  // 3. 类似"任意更新后":没有依赖数组的useEffect
  useEffect(() => {
    console.log('组件渲染完成(任意状态变化后都会执行)');
    // 注意:这里可以访问到更新后的DOM
  }); // 没有依赖数组 = 每次渲染后都执行
  
  // 4. 纯挂载逻辑:空依赖数组
  useEffect(() => {
    console.log('仅在组件首次挂载后执行一次');
    // 执行一次性的初始化操作
  }, []); // 空数组 = 不依赖任何值,只执行一次

  return (
    <div>
      <div>用户: {userData?.name}</div>
      <button onClick={() => setUserId(userId + 1)}>切换用户</button>
    </div>
  );
}

看到这里,我恍然大悟:useEffect根本不是一个“生命周期钩子”,而是一个声明同步规则的API。它的核心在于:

依赖数组:同步规则的“开关”

useEffect的第二参数——依赖数组,决定了副作用的执行规则:

依赖数组 对应的Vue概念 执行时机 用途
[] (空数组) onMounted + onUnmounted 组件挂载后执行一次,卸载时清理 一次性初始化:事件监听、数据获取、订阅
[dep1, dep2] watch([dep1, dep2], callback) 依赖项变化时执行,变化前清理上一次 响应特定状态变化:数据重新获取、参数变化更新
无依赖数组 onUpdated 每次组件渲染后都执行 调试、DOM操作、与React外部系统强制同步

依赖数组的精髓在于声明了“副作用函数与哪些数据保持同步” 。React会对比前后两次渲染的依赖值,只有当它们发生变化时,才会重新执行副作用。

心智模型的根本转变

理解到这里,我意识到从Vue到React,需要一次根本性的心智模型转变

Vue思维(时机驱动)

  • “我想在组件挂载时获取数据”(onMounted
  • “我想在用户ID变化时重新获取数据”(watch(userId, ...)
  • “我想在组件销毁前清理资源”(onUnmounted

React思维(同步驱动)

  • “我需要保持用户数据与用户ID同步”(useEffect(() => {...}, [userId])
  • “我需要保持全局事件监听与组件生命周期同步”(useEffect(() => {...}, [])
  • “我需要每次渲染后都执行某些DOM操作”(useEffect(() => {...})

这种转变最初让我很不适应。在Vue中,我思考的是“在什么时间点做什么事”;在React中,我需要思考“哪些数据变化时需要同步什么副作用”。

为什么React选择这样的设计?

经过更深入的学习,我理解了React团队这样设计的几个关键原因:

1. 解决“生命周期地狱”

在类组件时代,同一个功能的代码经常被拆分到不同的生命周期方法中:

// React类组件:同一个数据获取逻辑被拆分到三个地方
class UserProfile extends React.Component {
  componentDidMount() {
    this.fetchData(this.props.userId);
    this.setupSubscription();
  }
  
  componentDidUpdate(prevProps) {
    if (prevProps.userId !== this.props.userId) {
      this.fetchData(this.props.userId);
    }
  }
  
  componentWillUnmount() {
    this.cleanupSubscription();
  }
  
  fetchData(userId) { /* ... */ }
  setupSubscription() { /* ... */ }
  cleanupSubscription() { /* ... */ }
}

useEffect让相关逻辑可以集中在一起:

// 函数组件:相关逻辑组织在一起
function UserProfile({ userId }) {
  useEffect(() => {
    // 1. 数据获取
    fetchData(userId);
    
    // 2. 设置订阅
    const subscription = setupSubscription();
    
    // 3. 统一清理
    return () => {
      cleanupSubscription(subscription);
    };
  }, [userId]); // 清晰声明依赖
}

2. 更好的类型安全和可预测性

Vue的响应式系统虽然方便,但有时很难追踪数据流的变化来源。React的显式依赖声明让数据流变得更加透明和可预测。

3. 拥抱函数式编程思想

React鼓励将组件视为纯函数:给定相同的props和state,总是返回相同的UI。useEffect是这个纯函数世界与外部副作用世界之间唯一且受控的桥梁

实践中的关键细节

在真正使用useEffect时,有几个关键点需要特别注意:

1. 依赖必须诚实

// ❌ 错误:遗漏依赖
useEffect(() => {
  fetchData(userId);
}, []); // 缺少userId依赖,userId变化时不会重新获取

// ✅ 正确:包含所有依赖
useEffect(() => {
  fetchData(userId);
}, [userId]); // 明确声明依赖

2. 清理函数的重要性

useEffect(() => {
  const timer = setInterval(() => {
    console.log('定时器运行');
  }, 1000);
  
  // 必须返回清理函数
  return () => {
    clearInterval(timer);
    console.log('定时器已清理');
  };
}, []);

3. 避免无限循环

// ❌ 危险:依赖项在每次渲染都创建新对象
useEffect(() => {
  console.log('这可能无限循环');
}, [{ id: 1 }]); // 对象字面量每次渲染都是新值

// ✅ 使用useMemo稳定值
const config = useMemo(() => ({ id: 1 }), []);
useEffect(() => {
  console.log('现在安全了');
}, [config]);

总结:它到底是什么?

回到最初的问题:React的useEffect到底是不是生命周期?

从功能覆盖的角度看,是的——它可以模拟Vue中几乎所有生命周期钩子的行为。

但从设计哲学上看,完全不是

useEffect代表了一种范式转换:

  • Vue提供的是时间线上的锚点,让你在组件生命周期的特定阶段执行代码
  • React提供的是数据同步的声明,让你描述副作用与哪些数据需要保持同步

这种差异就像两种不同的导航方式:

  • Vue像一份详细的日程表:8:00起床,9:00工作,12:00午餐...
  • React像一套响应规则:当饥饿时吃饭,当困倦时休息,当有工作时处理...

两种方式都能让你完成一天的活动,但思维方式截然不同。

对于从Vue转向React的开发者来说,最重要的不是记住useEffect的语法,而是完成这次心智模型的转变:从思考“时机”转向思考“同步”。

这需要时间和实践,但一旦掌握,你会发现这种声明式的同步思维让复杂的数据流和副作用管理变得更加清晰和可维护。而这,正是深入理解现代React的关键一步。


思考与讨论
你在学习useEffect时,是否也经历过类似的心智模型转变?是更偏爱Vue明确的生命周期阶段,还是更欣赏React声明式的同步思维?欢迎在评论区分享你的经验和看法!

前端向架构突围系列 - 架构方法(一):概述 4+1 视图模型

2026年1月7日 11:32

Gemini_Generated_Image_wtlcmdwtlcmdwtlc.png

这个模型由 Philippe Kruchten 在 1995 年提出。它的本质含义是:没有一种单一的视图能够涵盖系统的方方面面。不同的利益相关者(Stakeholders)关心的是不同的东西。

  • 业务方关心功能(能不能用?)。
  • 开发关心代码结构(好不好写?)。
  • 运维关心部署和硬件(稳不稳定?)。
  • 用户关心操作流程(顺不顺畅?)。

架构师的职责,就是通过这 5 个视角,把这些“鸡同鸭讲”的需求统一成一个完整的系统设计。

为了让你更好理解,我将这个经典的后端/通用架构概念,完整“翻译”成前端架构师的视角


1. 场景视图 (Scenarios / Use Cases View) —— “+1” 的那个核心

本质:系统的灵魂,它驱动了其他 4 个视图。 这是架构设计的起点。如果不知道系统要干什么,设计就无从谈起。

  • 关注点:用户怎么用这个系统?核心业务流程是什么?

  • 谁看:所有利益相关者(产品经理、测试、开发、用户)。

  • 前端架构视角

    • 这不是指某个具体的 Button 点击事件,而是关键链路 (Critical User Journeys)
    • 例子:用户进入首页 -> 登录 -> 浏览商品 -> 加入购物车 -> 结算。
    • 架构决策:如果“秒杀”是核心场景,那么你在后续的“处理视图”中就必须设计高并发的防抖策略;在“物理视图”中就要考虑 CDN 缓存。
graph LR
    %% 样式定义
    classDef icon fill:#fff9c4,stroke:#fbc02d,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef step fill:#fff,stroke:#fbc02d,stroke-width:2px,color:#333,rx:10,ry:10;
    classDef note fill:#fffde7,stroke:none,color:#666;

    %% 左侧:核心概念
    User((用户<br/>User)):::icon

    %% 右侧:关键链路 (Critical Journey)
    subgraph Journey [关键链路: 秒杀场景]
        direction LR
        Step1[进入详情页]:::step --> Step2[抢购点击]:::step
        Step2 --> Step3[排队等待]:::step
        Step3 --> Step4[创建订单]:::step
        Step4 --> Step5[支付成功]:::step
    end

    User ==> Step1

    %% 架构决策点
    Note1(架构决策点:<br/>CDN缓存, 骨架屏):::note -.-> Step1
    Note2(架构决策点:<br/>高并发防抖, 乐观UI):::note -.-> Step2

2. 逻辑视图 (Logical View) —— “功能是怎么组织的?”

本质:系统的抽象模型。 这是最接近业务逻辑的一层,忽略具体的代码文件,只看概念

  • 关注点:系统有哪些“部件”?它们之间是什么关系?

  • 谁看:开发人员、业务分析师。

  • 前端架构视角

    • 组件模型:原子组件 vs 业务组件。
    • 领域模型:User, Product, Order 等实体定义(TypeScript Interface 定义)。
    • 状态管理设计:全局状态(Redux/Pinia)存什么?局部状态存什么?模块间如何通信?
    • 例子:画一张图,展示 OrderList 组件依赖 UserStoreAPI Service,而不关心它们具体写在哪个文件里。
graph LR
    %% 样式定义
    classDef icon fill:#e1f5fe,stroke:#039be5,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef layer fill:#fff,stroke:#039be5,stroke-width:2px,color:#333,rx:5,ry:5;
    classDef rel stroke:#90caf9,stroke-width:2px,stroke-dasharray: 5 5;

    %% 左侧
    Logic((抽象<br/>逻辑)):::icon

    %% 右侧:分层架构
    subgraph LayerSystem [前端逻辑分层]
        direction TB
        UI[<b>表现层 UI Layer</b><br/>Button, Layout, Page]:::layer
        Adapter[<b>适配层 Adapter</b><br/>Hooks, Presenters]:::layer
        Domain[<b>领域层 Domain</b><br/>UserEntity, CartModel]:::layer
        Infra[<b>基础层 Infra</b><br/>Axios, Storage, Logger]:::layer
    end

    Logic ==> UI

    %% 依赖关系 (单向依赖是架构的关键)
    UI --> Adapter
    Adapter --> Domain
    Adapter --> Infra

3. 开发视图 (Development / Implementation View) —— “代码是怎么写的?”

本质:系统的静态组织结构。 这是程序员每天面对的 IDE 里的样子。

  • 关注点:文件目录怎么分?用什么框架?依赖怎么管?

  • 谁看:开发人员、构建工程师。

  • 前端架构视角

    • 工程化结构:Monorepo (Nx/Turborepo) 还是 Multirepo?
    • 目录规范src/components, src/hooks, src/utils 怎么归类?
    • 依赖管理package.json 里的依赖,公共库(Shared Library)如何抽取?
    • 构建工具:Vite/Webpack 配置,分包策略(Chunking)。
    • 例子:决定把所有的 API 请求封装在 @api 目录下,并禁止组件直接调用 axios,这就是开发视图的约束。
graph LR
    %% 样式定义
    classDef icon fill:#f3e5f5,stroke:#8e24aa,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef file fill:#fff,stroke:#8e24aa,stroke-width:2px,color:#333,rx:2,ry:2;
    classDef tool fill:#f3e5f5,stroke:#8e24aa,stroke-width:1px,color:#333,stroke-dasharray: 5 5;

    %% 左侧
    Dev((工程<br/>代码)):::icon

    %% 右侧:目录与工具
    subgraph ProjectStructure [工程化与目录规范]
        direction TB
        
        subgraph Mono [Monorepo 仓库]
            Pkg1[packages/ui-lib]:::file
            Pkg2[apps/web-client]:::file
            Config[tsconfig.json]:::file
        end

        subgraph Toolchain [构建工具链]
            Vite(Vite / Webpack):::tool
            Lint(ESLint / Prettier):::tool
        end
    end

    Dev ==> Mono
    Mono -.-> Toolchain

4. 处理视图 (Process View) —— “系统是怎么运行的?”

本质:系统的动态行为、并发与性能。 对于前端来说,这是最容易被忽视,但最考验功底的一层。

  • 关注点:性能、并发、同步/异步、时序。

  • 谁看:系统集成人员、高级开发。

  • 前端架构视角

    • 异步流控:接口竞态问题(Race Condition)怎么处理?Promise 并发限制。
    • 生命周期:SSR(服务端渲染)的数据注水(Hydration)流程是怎样的?
    • 性能优化:Web Worker 处理复杂计算,避免阻塞主线程(UI 线程)。
    • 通信机制:WebSocket 怎么保持心跳?跨 Tab 通信(SharedWorker/LocalStorage)怎么做?
    • 例子:设计一个“大文件分片上传”的功能,你需要画出切片、上传、暂停、续传的时序图,这属于处理视图。

graph LR
    %% 样式定义
    classDef icon fill:#e8f5e9,stroke:#43a047,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef action fill:#fff,stroke:#43a047,stroke-width:2px,color:#333,rx:10,ry:10;
    classDef async fill:#c8e6c9,stroke:none,color:#333,rx:5,ry:5;

    %% 左侧
    Run((运行<br/>时序)):::icon

    %% 右侧:大文件上传时序
    subgraph AsyncProcess [大文件分片上传流程]
        direction TB
        Start[开始上传]:::action --> Check{检查文件}:::action
        Check -->|太大| Slice[Web Worker<br/>进行切片计算]:::async
        Slice --> Upload[并发上传切片<br/>Promise.all]:::action
        Upload --> Pause{网络中断?}:::action
        Pause -->|是| Wait[暂停 & 记录断点]:::async
        Pause -->|否| Finish[合并请求]:::action
    end

    Run ==> Start

5. 物理视图 (Physical / Deployment View) —— “代码跑在哪里?”

本质:软件到硬件的映射。 前端代码最终是要通过网络传输并运行在用户设备上的。

  • 关注点:部署、网络拓扑、硬件限制。

  • 谁看:运维工程师 (DevOps)、系统管理员。

  • 前端架构视角

    • 部署策略:静态资源上 CDN,Nginx 反向代理配置。
    • 运行环境:BFF 层运行在 Docker 容器里;前端代码运行在用户的 Chrome/Safari 里(考虑兼容性)。
    • 网络环境:弱网情况下如何降级?离线包(PWA)策略。
    • 多端适配:同一套代码是跑在 PC 浏览器,还是内嵌在 App 的 WebView 里?
    • 例子:决定使用“灰度发布”系统,将新版本的 JS 文件只推给 10% 的用户,这属于物理视图的范畴。
graph LR
    %% 样式定义
    classDef icon fill:#fff3e0,stroke:#fb8c00,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef device fill:#fff,stroke:#fb8c00,stroke-width:2px,color:#333,rx:5,ry:5;
    classDef net fill:#ffe0b2,stroke:none,color:#333,rx:20,ry:20;

    %% 左侧
    Deploy((部署<br/>环境)):::icon

    %% 右侧:部署拓扑
    subgraph NetworkTopology [资源分发与运行环境]
        direction LR
        
        subgraph Cloud [云端设施]
            CICD[CI/CD 构建产物]:::device --> OSS[对象存储]:::device
            OSS --> CDN((CDN 边缘节点)):::net
        end

        subgraph Client [用户终端]
            CDN --> Browser[PC 浏览器]:::device
            CDN --> Mobile[手机 WebView]:::device
            CDN --> Hybrid[小程序]:::device
        end
    end

    Deploy ==> Cloud

总结:软件开发的本质是什么?

通过 4+1 视图,我们可以得出软件开发的本质:

  1. 控制复杂度 (Managing Complexity) : 如果没有分层和视图,系统就是一团乱麻。4+1 试图把复杂问题拆解成 5 个维度分别解决。

  2. 沟通与妥协 (Communication & Trade-offs) : 架构不是追求“完美的代码”,而是平衡各方利益。

    • 为了物理视图的加载速度(上 CDN),可能要牺牲开发视图的便利性(复杂的构建流程)。
    • 为了处理视图的流畅度(虚拟列表),可能要增加逻辑视图的复杂度。

此时此刻,你可以做的 Next Step

作为想转型的架构师,不要只写代码,开始写文档

你可以挑选你当前负责的一个复杂模块,尝试写一份简易版的技术设计文档 (TDD) ,强制自己包含以下三点:

  1. 逻辑图:用方块图画出组件和数据流。
  2. 处理图:用时序图画出关键的用户交互流程。
  3. 部署说明:说明代码构建后怎么发布,有没有缓存策略。

VTJ.PRO「AI + 低代码」应用开发平台的前端架构设计

2026年1月7日 11:13

目的与范围

本文档描述了 VTJ.PRO 平台的前端架构,涵盖五个不同的 HTML 入口点、它们的初始化流程以及如何与 @vtj/renderer 引擎集成。前端设计为一个多上下文系统,每个入口点服务于特定目的:主应用(管理后台和工作台)、平台运行时(Web、H5、UniApp)、开发环境和独立项目模板。

有关特定路由配置的信息,请参阅路由系统。有关 VTJ 渲染器页面渲染过程的详细信息,请参阅多平台运行时系统。有关后端模块架构的信息,请参阅后端模块系统。

iShot_2026-01-04_15.59.29.png

架构概述

前端包含五个架构上下文,每个上下文都有自己的入口点和初始化逻辑:

1ea57df4-c6a4-4841-850a-14ce900f9bc3.png

入口点对比

每个入口点服务于不同的目的并具有不同的初始化要求:

入口点 HTML 文件 主脚本 用途 路由模式 访问控制 VTJ 提供者
主应用 frontend/index.html src/main.ts 管理后台、工作台、认证页面 Hash 或 History 是(含白名单)
Web 运行时 frontend/web/index.html src/platform/web/main.ts 部署 Web 应用程序 Hash 是(运行时模式)
H5 运行时 frontend/h5/index.html src/platform/h5/main.ts 部署 H5 应用程序 Hash 是(运行时模式)
开发环境 frontend/dev/index.html src/platform/dev/main.ts 应用/模板设计器 Hash 否(由设计器添加)
认证流程 frontend/auth.html src/auth.ts 独立认证流程 Hash 或 History

主应用架构

主应用入口点服务于三个主要上下文:管理后台、工作台和认证页面。

初始化流程

bded2235-8789-424c-bb86-6ae73702837a.png

路由结构

主应用定义了三个布局上下文及其相应的路由:

6ae90ad6-d7df-47fc-8bd5-5d4eeb395987.png

访问控制集成

主应用使用基于白名单的访问控制系统:

  • whiteList 函数:对路径 ['/login', '/unauthorized', '/register', '/password'] 返回 true
  • unauthorized 行为:设置为 undefined,允许自定义处理
  • 存储键:来自共享配置的 STORAGE_KEY
  • 私钥:用于令牌加密的 ACCESS_PRIVATE_KEY

平台运行时架构

Web、H5 和 UniApp 平台运行时共享通用的架构模式,但在平台特定实现上有所不同。

运行时初始化流程

7bbbd63d-bd9c-4d2b-bacb-396434b49230.png

提供者配置

createProvider() 函数使用平台特定配置调用:

Web 平台配置

createProvider({
  nodeEnv: preview ? NodeEnv.Development : NodeEnv.Production,
  mode: ContextMode.Runtime,
  service,
  project: {
    id: code,
    platform: AppPlatform.Web
  },
  materialPath: MATERIAL_PATH,
  dependencies: {
    Vue: () => import('vue'),
    VueRouter: () => import('vue-router'),
    Pinia: () => import('pinia')
  },
  router,
  enableStaticRoute: true,
  routeAppendTo: ROUTER_APPEND_TO,
  adapter: {
    notify,
    loading,
    alert,
    useTitle
  }
});

H5 平台配置:

  • 与 Web 几乎相同,平台参数为:AppPlatform.H5
  • 使用移动端优化的适配器函数

UniApp 平台配置:

  • 不包含 router 或 routeAppendTo(路由由 UniApp 框架处理)
  • 使用 @vtj/uni 处理平台特定组件
  • 需要通过 setupUniApp() 进行特殊初始化

平台路由

平台运行时使用最小化路由,因为页面由 VTJ 渲染器动态创建:

Web/H5 路由器:

createRouter({
  history: createWebHashHistory(),
  routes: [
    {
      path: '/',
      component: Page,
      name: ROUTER_APPEND_TO // 渲染器将路由追加到这里
    },
    {
      path: '/:pathMatch(.*)*',
      name: 'NotFound',
      component: NotFound
    }
  ]
});

支持的 URL 模式:

  • 生产环境:/web/:code/#/page/:fileId
  • 预览环境:/web/:code/preview/#/page/:fileId
  • 版本环境:/web/:code/version/:versionId/#/page/:fileId

上下文模式

平台运行时使用不同的上下文模式:

模式 使用场景 描述
运行时 ContextMode.Runtime Web/H5/UniApp 平台 部署应用程序的生产运行时
开发环境 NodeEnv.Development 预览模式 (preview=true) 启用开发功能
生产环境 NodeEnv.Production 生产部署 优化的运行时

开发环境架构

开发环境提供用于创建和编辑应用程序和模板的设计器界面。

设计器路由

8d3f1ce2-e5a0-4107-be57-bf874382a448.png

设计器初始化

36751766-4fb0-434d-aefc-0e574b9b49be.png

设计器 URL 模式

设计器支持平台特定的 URL:

  • Web 应用开发:/dev/web/#/app/:code?id=xxx
  • H5 应用开发:/dev/h5/#/app/:code?id=xxx
  • UniApp 应用开发:/dev/uniapp/#/app/:code?id=xxx
  • Web 模板开发:/dev/web/#/template/:id
  • H5 模板开发:/dev/h5/#/template/:id
  • UniApp 模板开发:/dev/uniapp/#/template/:id

项目模板架构

templates/ 目录包含可以独立生成和使用的独立项目模板。

模板类型

系统提供三种模板类型:

0bae740a-0315-4fac-b235-3c8f1d9f09ad.png

模板初始化模式

所有模板都遵循使用 LocalService 的类似初始化模式:

Web 模板

const app = createApp(App);
const adapter = createAdapter({ loading, notify, Startup, useTitle });
const service = new LocalService(createServiceRequest(notify));
const { provider, onReady } = createProvider({
  nodeEnv: process.env.NODE_ENV as NodeEnv,
  mode: ContextMode.Raw,
  modules: createModules(),
  adapter,
  service,
  router,
  dependencies: {
    Vue: () => import('vue'),
    VueRouter: () => import('vue-router'),
    Pinia: () => import('pinia'),
    VueI18n: () => import('vue-i18n')
  },
  project: {
    id: vtj?.id || name
  },
  enableStaticRoute: true
});

与平台运行时的关键区别:

  • 使用 LocalService 而不是远程服务
  • 模式为 ContextMode.Raw(非 ContextMode.Runtime
  • 包含模块:createModules() 用于本地模块定义
  • 项目 ID 来自 package.jsonvtj.id 或 name
  • 在生产环境中启用自动更新

模板路由配置

模板使用最小化路由,因为 VTJ 提供者管理页面路由:

Web/H5 模板路由器:

createRouter({
  history: createWebHashHistory(),
  routes: [
    {
      path: '/unauthorized',
      name: 'Unauthorized',
      component: () => import('@/views/unauthorized.vue')
    },
    {
      path: '/:pathMatch(.*)*',
      name: 'NotFound',
      component: () => import('@/views/not-found.vue')
    }
  ]
});

共享基础设施

前端在所有入口点使用共享的基础设施组件。

服务层

b8e88fdb-8e51-4534-a63e-7f184d4a805a.png

访问控制系统

来自 @vtj/renderer 的访问控制系统在所有入口点进行配置:

配置参数:

  • alert:用于显示消息的警告函数
  • storageKey:localStorage 令牌存储的键(STORAGE_KEY
  • privateKey:令牌加密的密钥(ACCESS_PRIVATE_KEY
  • whiteList:确定公共路由的函数(仅主应用)
  • unauthorized:自定义重定向行为

连接方法:

  • connect({ request, router, mode }):将访问控制连接到路由器和请求处理器
  • 模式选项:ContextMode.Runtime(平台运行时)或 undefined(主应用)

TypeScript 配置

所有前端上下文共享类似的 TypeScript 声明:

Vue 组件类型增强:

declare module 'vue' {
  interface ComponentCustomProperties {
    $uploader: any;
    $reqeust: any;
    $apis: any;
    $libs: any;
  }
}

这些全局属性由 VTJ 渲染器注入,在所有 Vue 组件中可用。

依赖管理

前端使用动态导入核心依赖以实现代码分割:

提供者依赖

平台运行时和模板为 VTJ 提供者配置依赖:

Web/H5 平台依赖:

dependencies: {
  Vue: () => import('vue'),
  VueRouter: () => import('vue-router'),
  Pinia: () => import('pinia')
}

模板依赖:

dependencies: {
  Vue: () => import('vue'),
  VueRouter: () => import('vue-router'),
  Pinia: () => import('pinia'),
  VueI18n: () => import('vue-i18n')
}

UniApp 依赖:

dependencies: {
  VueI18n: async () => VueI18n;
}

目的:这些懒加载的依赖允许 VTJ 渲染器使用与宿主应用程序相同的版本,防止版本冲突。

平台特定适配器

每个平台提供用于平台特定 UI 操作的适配器函数:

适配器接口

56ef6f06-dd66-4cdb-a50a-aef6a3a0b5a3.png

总结

VTJ.PRO 前端架构设计为一个多上下文系统,具有五个不同的入口点:

  • 主应用:具有全面路由和访问控制的管理后台和工作台
  • Web/H5 平台:使用 @vtj/renderer 部署应用程序的运行时环境
  • UniApp 平台:具有特殊初始化的跨平台移动运行时
  • 开发环境:用于构建应用程序和模板的设计器界面
  • 项目模板:使用 LocalService 的独立启动项目

所有上下文共享通用基础设施(服务层、访问控制、TypeScript 配置),同时保持平台特定的实现。VTJ 渲染器(@vtj/renderer)作为核心渲染引擎,在所有平台上将 DSL 定义转换为功能性的 Vue 应用程序。

VTJ.PRO引擎源码仓库:gitee.com/newgateway/…

本地完成「新建 GitHub 仓库 react-ts-demo → 关联本地 React+TS 项目 → 提交初始代码」的完整操作流程

2026年1月7日 11:11

步骤 1:GitHub 官网新建仓库

(Repository name:react-ts-demo(必须和本地项目名一致,避免后续麻烦))

步骤 2:本地 Git 基础配置(首次使用必做)

(因为git config user.name已经配置了一个公用的,现在,这个是自学提升的,所以,这里配置的自学账号是只配置在当前仓库,不影响全局的公用账号的配置)

image.png

这个是查询到本地已经配置的全局公用账号:git config user.name 或者git config --list 查看更多git配置信息

//配置个人 GitHub 用户名(替换成你的) 
git config user.name "你的个人GitHub用户名"
//配置个人 GitHub 邮箱(替换成你的) 
git config user.email "你的个人GitHub注册邮箱@xxx.com"

image.png

当直接进入本地项目文件夹中直接配置自学账号(仓库级配置:只对当前仓库生效,优先级高于全局配置(给个人项目单独配个人账号)),如上图所示遇到的 fatal: not in a git directory 报错,是因为执行 git config user.name 时,还没进入 Git 仓库目录,或者该目录还没初始化 Git

步骤 3:初始化 Git 仓库(关键!解决「not in a git directory」)

如果这个目录还没初始化 Git,先执行初始化(生成 .git 隐藏文件夹):

// 初始化 Git 仓库(仅执行一次即可)
git init

执行后终端会提示:Initialized empty Git repository in xxx/react-ts-learning/.git/,说明初始化成功。

image.png

image.png

步骤4 继续完成关联 GitHub 仓库并提交代码

// 1. 添加 .gitignore 文件(之前教过的内容,先创建好) 
// 2. 暂存所有文件 
git add . 
//3. 提交初始代码(此时用的是个人账号) 
git commit -m "初始化 React+TS 学习项目" 
//4. 关联 GitHub 远程仓库(替换成你的仓库地址) 
git remote add origin https://github.com/你的个人用户名/react-ts-learning.git 
//5. 推送代码(首次推送,输入用户名+PAT 即可) 
git push -u origin main

git push -u origin main 这条命令的具体含义: 把本地 main 分支的代码推送到名为 origin 的远程仓库,并将本地 main 分支与远程 origin/main 分支建立「追踪关系」(后续推送 / 拉取可简化命令) -u:建立追踪关系(关键,全称 --set-upstream

image.png

image.png

如上图所示,执行 git push -u origin master 时遇到了 ! [rejected] master -> master (fetch first) 报错,核心原因是远程 Gitee 仓库的 master 分支已有内容(比如你在 Gitee 网页端勾选了初始化 README / 许可证),而本地仓库是全新的,Git 拒绝直接覆盖远程内容

解决方案:拉取远程内容并合并(推荐,保留远程文件)

这个方案会把远程的 README.md 等文件拉到本地,合并后再推送,既保留远程内容,又能上传你的代码,是最规范的做法:

步骤 1:拉取远程 master 分支内容并合并

// 拉取远程 origin 仓库的 master 分支内容,并合并到本地 master 分支 
git pull origin master --allow-unrelated-histories

--allow-unrelated-histories:关键参数!因为本地和远程仓库是 “无关联历史”(本地是全新的,远程有初始化文件),不加这个参数会提示「fatal: refusing to merge unrelated histories」。

步骤 2:解决可能的合并冲突(如果有)

执行完 git pull 后,如果远程的 README.md 和你本地的 README.md 内容冲突(比如你本地也建了 README),终端会提示冲突文件,此时:

  1. 打开冲突文件(比如 README.md),会看到类似标记:
<<<<<<< HEAD 
本地 README 的内容 
======= 
远程 README 的内容 
>>>>>>> origin/master
  1. 手动编辑保留需要的内容(比如保留远程的基础 README,补充你的学习笔记),删除冲突标记 <<<<<<</=======/>>>>>>>

  2. 保存文件后,执行以下命令标记冲突已解决:

git add . 
git commit -m "合并远程 README 文件,解决冲突"

步骤 3:重新推送代码到远程

git push -u origin master

✅ 执行后就能成功推送,远程仓库会同时有你的 React+TS 代码和初始化的 README 文件。

用 return“瘦身“if-else:让代码少嵌套、好维护

作者 尽欢i
2026年1月7日 11:07

前言

写代码时,条件判断是家常便饭,if-else 语句就像我们的 "基础工具"。但如果条件一层套一层,代码会变得又长又绕,读起来费劲儿,改的时候还容易出错。今天就聊聊怎么用 return 语句给代码 "减减肥",让逻辑更清晰、维护更省心。

一、先看看传统 if-else 的坑

咱们先看个典型的例子,这段代码是处理用户请求的:

function processUser(user) {
  if (user) {
    if (user.isActive) {
      if (user.hasPermission) {
        // 处理有权限的活跃用户
        const data = fetchUserData(user.id);
        if (data) {
          return transformData(data);
        } else {
          return null;
        }
      } else {
        console.log('用户无权限');
        return null;
      }
    } else {
      console.log('用户未激活');
      return null;
    }
  } else {
    console.log('用户不存在');
    return null;
  }
}

这段代码看着是不是有点头大?它的问题很明显:

  • 嵌套太深:if 里面套 if,一层叠一层,代码越写越靠右,像 "金字塔" 一样
  • 记不住上下文:读的时候得一直想着 "前面满足了什么条件才到这里",脑子累
  • 容易出错:维护时想加个条件,一不小心就插错了层级
  • 重复代码:好几处都返回 null,看着冗余

二、用 return 优化:提前 "踢掉" 不满足的情况

其实我们可以换个思路:不满足条件的情况,直接用 return 提前退出,不用再往里面嵌套。重构后是这样的:

function processUser(user) {
  // 用户不存在?直接返回,后面的逻辑都不用走了
  if (!user) {
    console.log('用户不存在');
    return null;
  }

  // 没激活?也直接返回
  if (!user.isActive) {
    console.log('用户未激活');
    return null;
  }

  // 没权限?继续返回
  if (!user.hasPermission) {
    console.log('用户无权限');
    return null;
  }

  // 到这里都是符合条件的,专心处理核心逻辑
  const data = fetchUserData(user.id);
  if (!data) {
    return null;
  }

  return transformData(data);
}

优化后是不是清爽多了?好处很直观:

  • 代码变扁平:没有嵌套了,从头到尾一路往下读,不用左右来回看
  • 逻辑更清晰:每个 if 都只检查一个条件,像 "闯关" 一样,过不了就直接出局
  • 不用记上下文:读的时候不用惦记前面的条件,看到一个判断就知道 "这里是检查什么"
  • 好维护:想加新条件(比如检查用户是否实名认证),直接在前面加一个 if+return 就行,不影响其他逻辑
  • 不容易错:代码结构简单了,测试和修改时都不容易出问题

三、再进阶:卫语句模式

这种 "提前返回" 的写法,在编程里叫 "卫语句"(Guard Clauses)—— 就像门口的保安,先把不符合要求的人拦在外面,里面的人再专心办正事。

再看个计算折扣的例子,用卫语句写特别直观:

function calculateDiscount(user, order) {
  // 卫语句:先处理所有"特殊情况"
  if (!user || !user.isRegistered) {
    return 0// 没注册的用户,没折扣
  }

  if (order.total < 100) {
    return 0// 订单金额不够,也没折扣
  }

  if (user.isMember) {
    return 0.1// 会员直接给10%折扣,不用往下看了
  }

  if (order.items.length > 5) {
    return 0.05// 买5件以上,给5%折扣
  }

  // 其他情况,没折扣
  return 0;
}

这里每个卫语句都解决一个特定问题,不用嵌套,一眼就能看明白各种情况下的折扣是多少。

4️四、这些场景用 return 优化,效果翻倍

不是所有 if-else 都需要改,但遇到以下情况,用提前 return 准没错:

  1. 参数检查:比如函数接收的参数是不是 null、是不是符合要求(比如订单金额不能是负数)
  2. 权限验证:比如判断用户能不能访问某个接口、能不能操作某个功能
  3. 边界情况:比如计算时遇到除数为 0、数组为空的情况
  4. 递归函数:比如递归计算斐波那契数列,提前定义 "什么时候停止递归"
  5. 多条件分支:比如有好几个独立的判断条件,不是 "非此即彼" 的关系

五、注意事项:别用错了反而添乱

虽然 return 优化很好用,但也不能乱用:

  • 简单判断别过度优化:如果就一个 if-else(比如 "如果是会员就打 9 折,否则不打折"),直接写反而更清楚,不用强行拆成两个 return
  • 保持风格统一:同一个项目里,要么都用卫语句,要么都用传统嵌套,别有的地方这么写,有的地方那么写,反而混乱
  • 别忘了释放资源:如果代码里打开了文件、连接了数据库,或者创建了定时器,提前 return 之前一定要把这些资源关掉,不然会造成浪费或错误

总结一下:写条件判断时,别一上来就往 if 里面套 if,先想想哪些情况是 "不符合要求就直接退出" 的,用 return 提前处理掉,剩下的核心逻辑自然就清晰了。这样写出来的代码,自己半年后看也能一眼看明白,同事维护起来也不用骂街~

2026最新款Vue3+DeepSeek-V3.2+Arco+Markdown网页端流式生成AI Chat

作者 xiaoyan2015
2026年1月7日 11:05

一周左右爆肝迭代研发,最新款vite7.2+vue3接入deepseek api网页版ai系统,完结了!

未标题-ee.png

p4-1.gif

技术栈

  • 开发工具:vscode
  • 技术框架:vite^7.2.4+vue^3.5.24+vue-router^4.6.4
  • 大模型ai框架:DeepSeek-R1 + OpenAI
  • UI组件库:arco-design^2.57.0 (字节桌面端组件库)
  • 状态插件:pinia^3.0.4
  • 本地存储:pinia-plugin-persistedstate^4.7.1
  • 高亮插件:highlight.js^11.11.1
  • markdown插件:markdown-it
  • katex公式:@mdit/plugin-katex^0.24.1

未标题-aa.png

p0.gif

项目功能性

  1. 最新框架vite7.x集成deepseek流式生成,效果更丝滑流畅
  2. 提供暗黑+浅色主题、侧边栏展开/收缩
  3. 支持丰富Markdown样式,代码高亮/复制/收缩功能
  4. 支持思考模式DeepSeek-R1
  5. 支持Katex数学公式
  6. 支持Mermaid各种甘特图/流程图/类图等图表

p4-4.gif

项目结构目录

360截图20260104094519850.png

项目环境变量.env

根据自己申请的deepseek apikey替换项目根目录下.env文件里的key即可体验ai流式打字效果。

eea27d7de7408d26b80a8e50eb3a9103_1289798-20260104231837231-58531704.png

# title
VITE_APP_TITLE = 'Vue3-Web-DeepSeek'

# port
VITE_PORT = 3001

# 运行时自动打开浏览器
VITE_OPEN = true

# 开启https
VITE_HTTPS = false

# 是否删除生产环境 console
VITE_DROP_CONSOLE = true

# DeepSeek API配置
VITE_DEEPSEEK_API_KEY = 替换为你的 API Key
VITE_DEEPSEEK_BASE_URL = https://api.deepseek.com

未标题-kk.png

未标题-bb.png

公共布局模板

2e8247992f19e747d1a9eaa2a6f909cc_1289798-20260104232329143-383578235.png

项目整体结构如下图所示:

90b5497d6fdf0a31688d4a4e0c2cb09a_1289798-20260104232839646-1862838173.png

<script setup>
  import Sidebar from '@/layouts/components/sidebar/index.vue'
</script>

<template>
  <div class="vu__container">
    <div class="vu__layout flexbox flex-col">
      <div class="vu__layout-body flex1 flexbox">
        <!-- 侧边区域 -->
        <Sidebar />

        <!-- 主面板区域 -->
        <div class="vu__layout-main flex1">
          <router-view v-slot="{ Component, route }">
            <keep-alive>
              <component :is="Component" :key="route.path" />
            </keep-alive>
          </router-view>
        </div>
      </div>
    </div>
  </div>
</template>

001360截图20260103080509704.png

002360截图20260103081002002.png

002360截图20260103084412883.png

002360截图20260103085708292.png

003360截图20260103091843214.png

004360截图20260103115744725.png

005360截图20260103120220903.png

007360截图20260103120754686.png

008360截图20260103124947743.png

008360截图20260103125240325.png

vue3集成deepseek深度思考模式

8719e16c9af153eafdefa8121102ba4a_1289798-20260104233124703-1428202818.png

6a20e5f59713e1bc0d6d9b639dd4be1c_1289798-20260104233216967-269432762.png

// 调用deepseek接口
const completion = await openai.chat.completions.create({
  // 单一会话
  /* messages: [
    {role: 'user', content: editorValue}
  ], */
  // 多轮会话
  messages: props.multiConversation ? historySession.value : [{role: 'user', content: editorValue}],
  // deepseek-chat对话模型 deepseek-reasoner推理模型
  model: sessionstate.thinkingEnabled ? 'deepseek-reasoner' : 'deepseek-chat',
  stream: true, // 流式输出
  max_tokens: 8192, // 一次请求中模型生成 completion 的最大 token 数(默认使用 4096)
  temperature: 0.4, // 严谨采样
})

006360截图20260103120533402.png

008360截图20260103124947743.png

010360截图20260103125925726.png

012360截图20260103130203476.png

vue3-deepseek集成katex和mermaid插件

import { katex } from "@mdit/plugin-katex"; // 支持数学公式
import 'katex/dist/katex.min.css'
// 渲染mermaid图表
import { markdownItMermaidPlugin } from '@/components/markdown/plugins/mermaidPlugin'

解析markdown结构

<Markdown
  :source="item.content"
  :html="true"
  :linkify="true"
  :typographer="true"
  :plugins="[
    [katex, {delimiters: 'all'}],
    [markdownItMermaidPlugin, { ... }]
  ]"
  @copy="onCopy"
/>

49d19d2a7fb1c55ccd846a129970d002_1289798-20260104234115865-193884745.png

21bcd22f4247c768a9f61f878444dd1c_1289798-20260104234151062-1791535844.png

vue3调用deepseek api流式对话

// 调用deepseek接口
const completion = await openai.chat.completions.create({
  // 单一会话
  // messages: [{role: 'user', content: editorValue}],
  // 多轮会话
  messages: props.multiConversation ? historySession.value : [{role: 'user', content: editorValue}],
  // deepseek-chat对话模型 deepseek-reasoner推理模型
  model: sessionstate.thinkingEnabled ? 'deepseek-reasoner' : 'deepseek-chat',
  stream: true, // 流式输出
  max_tokens: 8192,
  temperature: 0.4
})

处理流式生成内容。

for await (const chunk of completion) {
  // 检查是否已终止
  if(sessionstate.aborted) break

  const content = chunk.choices[0]?.delta?.content || ''
  // 获取推理内容
  const reasoningContent = chunk.choices[0]?.delta?.reasoning_content || ''
  
  if(content || reasoningContent) {
    answerText += content
    reasoningText += reasoningContent

    // 限制更新频率:每100ms最多更新一次
    const now = Date.now()
    if(now - lastUpdate > 100) {
      lastUpdate = now
      requestAnimationFrame(() => {
        // ...
      })
    }
  }
  if(chunk.choices[0]?.finish_reason === 'stop') {
    // ...
  }
}

2026最新款Vite7+Vue3+DeepSeek-V3.2+Markdown流式输出AI会话

基于uniapp+vue3+deepseek+markdown搭建app版流式输出AI模板

electron38.2-vue3os系统|Vite7+Electron38+Pinia3+ArcoDesign桌面版OS后台管理

基于electron38+vite7+vue3 setup+elementPlus电脑端仿微信/QQ聊天软件

2025最新款Electron38+Vite7+Vue3+ElementPlus电脑端后台系统Exe

自研2025版flutter3.38实战抖音app短视频+聊天+直播商城系统

基于uni-app+vue3+uvui跨三端仿微信app聊天模板【h5+小程序+app】

基于uniapp+vue3+uvue短视频+聊天+直播app系统

基于flutter3.32+window_manager仿macOS/Wins风格桌面os系统

flutter3.27+bitsdojo_window电脑端仿微信Exe应用

自研tauri2.0+vite6.x+vue3+rust+arco-design桌面版os管理系统Tauri2-ViteOS

❌
❌