Vue3 -- PDF展示、添加签名(带笔锋)、导出
文章目录
- 笔锋签名
- 方案一
- 实现要点
- 实现过程
- 组件引用
- 页面元素
- 添加引用
- 实现代码
- 效果展示
- 缺点
- 方案二
- 修改页面元素
- 替换引用
- 修改代码
- 效果展示
- 完整代码地址
实现功能的时候采用了两个方案,主要是第一个方案最后的实现效果并不太理想,但实现起来比较简单,要求不高时可以使用。
该 DEMO 会一次性加载并展示所有的 PDF 页面,目的是方便在手机上观看时上下滑动,如果要做成上一页下一页的效果,需要自行实现。
笔锋签名
我是用开源项目 smooth-signature 实现带笔锋签名的功能。
Gitee 地址是 https://github.com/linjc/smooth-signature
npm install --save smooth-signature
使用起来也比较简单,首先获取到需要操作的画布 canvas ,然后生成一个笔锋签名对象 SmoothSignature,optionSign 是初始化的一些简单属性。
const signature = new SmoothSignature(canvas, optionSign);
这样一来,我们的 canvas 就可以画线条了,同时我们可以通过 signature 去做一些操作,比如清空签名、撤回一步的操作等。
方案一
实现要点
- 读取 PDF 文件,并将 PDF 页面渲染到 Canvas 画布上,这里需要动态生成 Canvas
- 将每一个 Canvas 都包装成 SmoothSignature
- 添加一个标识,判断是否允许在 Canvas 上画线(手机滑动会和签名画线冲突,用按钮来控制什么时候允许画线)。
- 保存 PDF 时,先将每一个 Canvas 中的内容转化成图片格式 image/JPEG ,或者 image/PNG ,PNG格式的文件可能会比较大。
- 最后用生成的图片导出一个新的 PDF (实质上 PDF 每一页都是一张图片)。
实现过程
组件引用
| smooth-signature | 笔锋签名 |
| pdfjs-dist | PDF展示等功能 |
| jspdf | PDF导出相关功能 |
npm install --save smooth-signature
npm install --save pdfjs-dist@2.0.943
npm install --save jspdf
页面元素
主要是读取文件、切至签名功能、切回预览功能、撤回签名、清除所有签名以及下载PDF的功能。
<template><div :class="`tab-header`"><div id="editor"><Input:class="`button-common`"type="file"ref="fielinput"accept=".pdf"id="fielinput"@change="uploadFile"/><Button :class="`button-common`" v-if="isSign" @click="handleSign">切回预览</Button><Button :class="`button-common`" v-else @click="handleSign">切至签名</Button><Button :class="`button-common`" @click="handleUndo">撤回</Button><Button :class="`button-common`" @click="handleClear">清除</Button><Button :class="`button-common`" @click="savePDF">下载PDF</Button></div><div><div id="parentDiv"><div ref="contentDiv" id="contentDiv"></div></div></div></div>
</template>
<script lang="ts">
引用
......
实现代码
......
</script>
<style lang="less" scoped>.tab-header {background: rgb(146, 175, 138);padding-left: 1%;padding-right: 1%;}.button-common {margin-right: 2px;max-width: 200px;}#contentDiv {// display: inline-block;}#parentDiv {position: absolute;overflow: auto;top: 5%;bottom: 1%;display: inline-block;}#signShower {position: absolute;left: 50%;top: 5%;bottom: 1%;display: inline-block;}
</style>
添加引用
这里要注意的是,需要给 pdfJS 指定工作路径
import { Button, Input } from 'ant-design-vue';import { defineComponent, ref } from 'vue';import SmoothSignature from 'smooth-signature';import * as pdfJS from 'pdfjs-dist';import * as pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry';import JsPDF from 'jspdf';pdfJS.GlobalWorkerOptions.workerSrc = pdfjsWorker;
实现代码
代码中添加了主要的注释,可以查看下述代码
export default defineComponent({components: { Button, Input },setup() {const fielinput = ref(null);const contentDiv = ref(null);//签名相关const isSign = ref(false); //控制是否允许签名const canvass = ref([]); //保存所有画布元素const signatures = ref([]); //所有签名对象const historys = ref([]); //签名历史 用于回退或者清除,因为是一次性展示多个页面,会存在多个包装好的签名对象,存放历史列表方便操作//PDF展示相关const pdfData = ref(null); // PDF 内容const scale = ref(2); //放大比例 ,有的时候展示可能会比较模糊,可以放大展示//上传控件选择事件,加载选中的 PDF 文件const uploadFile = (e: Event) => {// 断言为HTMLInputElementconst target = e.target as HTMLInputElement;const files = target.files;let reader = new FileReader();reader.readAsDataURL(files[0]);reader.onload = () => {let data = atob(reader.result.substring(reader.result.indexOf(',') + 1));loadPdfData(data);};};//加载PDFfunction loadPdfData(data) {//移除所有旧的 Canvas 画布元素removeChild();//重置对象状态isSign.value = false;canvass.value = [];signatures.value = [];// 引入pdf.js的字体,如果没有引用的话字体可能会不显示let CMAP_URL = 'https://unpkg.com/pdfjs-dist@2.0.943/cmaps/';//读取base64的pdf流文件pdfData.value = pdfJS.getDocument({data: data, // PDF base64编码cMapUrl: CMAP_URL,cMapPacked: true,});//渲染全部页面renderAllPages();}//移除页面上旧的元素function removeChild() {var content = contentDiv.value;var child = content.lastElementChild;while (child) {content.removeChild(child);child = content.lastElementChild;}}//渲染全部页面function renderAllPages() {pdfData.value.promise.then((pdf) => {for (let i = 1; i <= pdf.numPages; i++) {pdf.getPage(i).then((page) => {let viewport = page.getViewport(scale.value);//动态生成 Canvas 画布并设置宽高var canvas = document.createElement('canvas');canvas.height = viewport.height;canvas.width = viewport.width;let ctx = canvas.getContext('2d');let renderContext = {canvasContext: ctx,viewport: viewport,};//将 PDF 页面渲染到 Canvas 上page.render(renderContext).then(() => {});//将画布包装成 SmoothSignatureinitSignatureCanvas(canvas);//将画布元素放入到 div 容器中展示canvass.value.push(canvas);contentDiv.value.appendChild(canvas);});}});}//初始化签名对象const initSignatureCanvas = (canvas) => {const optionSign = {width: canvas.width,height: canvas.height,maxHistoryLength: 100, //最大历史记录};const signature = new SmoothSignature(canvas, optionSign);//初始化时 先移除它内部添加的监听事件,默认不能签名signature.removeListener();//签名对象 addHistory 方法做一下修改,在原来逻辑的基础上添加这一行// historys.value.push(signature); 方便处理历史签名记录signature.addHistory = function () {if (!signature.maxHistoryLength || !signature.canAddHistory) return;signature.canAddHistory = false;signature.historyList.push(signature.canvas.toDataURL());signature.historyList = signature.historyList.slice(-signature.maxHistoryLength);historys.value.push(signature);};signatures.value.push(signature);};/*** 签名预览转换* 允许签名:调用 signature 对象中的 addListener 方法,添加监听事件* 不允许签名:调用 signature 对象中的 removeListener 方法,移除监听事件*/const handleSign = () => {isSign.value = !isSign.value;if (signatures.value && signatures.value.length > 0) {if (isSign.value) {for (let i = 0; i < signatures.value.length; i++) {signatures.value[i].addListener();}} else {for (let i = 0; i < signatures.value.length; i++) {signatures.value[i].removeListener();}}}};/*** 后退操作* 调用历史签名记录中的 signature 对象中的 undo 方法会撤回当前对象中的最后一次的画线记录* 注意:后退后不要忘记将列表中最后一个元素移除*/const handleUndo = () => {if (historys.value && historys.value.length > 0) {const signatureList = historys.value;let signature = signatureList.pop();signature.undo();historys.value = signatureList;}};// 清除所有 循环把所有签名历史都处理了const handleClear = async () => {while (historys.value && historys.value.length > 0) {handleUndo();}};// 下载PDFconst savePDF = () => {//生成新的 PDFlet pdf = new JsPDF('', 'pt', 'a4');if (canvass.value.length > 0) {//将 canvas 内容转化成 JPEGfor (let i = 0; i < canvass.value.length; i++) {const ccccc = canvass.value[i];let pageData = ccccc.toDataURL('image/JPEG');if (i > 0) {pdf.addPage();}pdf.addImage(pageData,'JPEG',0,0,ccccc.width / scale.value,ccccc.height / scale.value,);}//到处新的PDF return pdf.save('TestPdf.pdf');}};return {fielinput,uploadFile,contentDiv,isSign,handleSign,handleUndo,handleClear,savePDF,};},mounted() {},});
效果展示


缺点
1、生成的新的PDF每一页都是一个图片,这就表示 PDF 中的内容无法被解析,文字再也无法被选中了。
2、因为生成的是图片,所以最终效果可能会变模糊,可以通过放大比例去优化展示效果,但是始终不是一个最优的解决方案。
方案二
方案二使用一个新的组件 pdf-lib 来处理最后生成的 PDF
方案二仍旧使用 pdfjs-dist 在 Canvas 上展示 PDF,并使用 smooth-signature 使得画布拥有笔锋签名效果。
不同的是,这一次签名画布和 PDF 展示画布并不再是同一个画布,而是上下重叠的两个分离的画布
这样一来,我们可以将签名画布上的内容生成一个透明背景的 PNG 图片,然后以水印的方式添加到原来的 PDF 文件中。
修改页面元素
需要两个 Div 容器 ,父容器的滚动条需要同步滚动,否则会出现签名在滚动,但是 PDF 页面不动的情况
<template><div :class="`tab-header`"><div id="editor"><Input:class="`button-common`"type="file"ref="fielinput"accept=".pdf"id="fielinput"@change="uploadFile"/><Button :class="`button-common`" v-if="isSign" @click="handleSign">点击预览</Button><Button :class="`button-common`" v-else @click="handleSign">点击签名</Button><Button :class="`button-common`" @click="handleUndo">撤回</Button><Button :class="`button-common`" @click="handleClear">清除</Button><Button :class="`button-common`" @click="savePDF">下载PDF</Button></div><div><div id="parentDiv1"><div ref="contentDiv" id="contentDiv"></div></div><div id="parentDiv2"><div ref="signContentDiv" id="signContentDiv"></div></div></div></div>
</template>
替换引用
//import JsPDF from 'jspdf';import { PDFDocument } from 'pdf-lib';
修改代码
文章底部附完整代码
...
const signCanvass = ref([]); //保存所有签名画布
const base64 = ref(null); //读取的pdf的base64数据
上传文件的方法中添加一行保存PDF base64 ,生成新的 PDF 时使用
const uploadFile = (e: Event) => {...reader.onload = () => {base64.value = reader.result;...};
};
加载 PDF 时,我们要重置的对象增加了,而且加载完之后我们要让两个父容器滚动同步
function loadPdfData(data) {removeChild();...signCanvass.value = []; //重置...renderAllPages();//两个DIV协同滚动var div1 = document.getElementById('parentDiv1');var div2 = document.getElementById('parentDiv2');div1.addEventListener('scroll', function () {div2.scrollLeft = div1.scrollLeft;div2.scrollTop = div1.scrollTop;});div2.addEventListener('scroll', function () {div1.scrollLeft = div2.scrollLeft;div1.scrollTop = div2.scrollTop;});
}
移除页面元素的时候,我们要将两个 div 容器中的元素都移除掉
function removeChild() {var content = contentDiv.value;var child = content.lastElementChild;while (child) {content.removeChild(child);child = content.lastElementChild;}var signContent = signContentDiv.value;var child2 = signContent.lastElementChild;while (child2) {signContent.removeChild(child2);child2 = signContent.lastElementChild;}
}
渲染 PDF 页面的时候,每一个页面都会生成两个相同大小的画布,一个用来展示,一个用来签名,两个画布是重叠的。
function renderAllPages() {pdfData.value.promise.then((pdf) => {for (let i = 1; i <= pdf.numPages; i++) {pdf.getPage(i).then((page) => {// 获取DOM中为预览PDF准备好的canvasDOM对象let viewport = page.getViewport(scale.value);var canvas = document.createElement('canvas');//用来展示var sighCanvas = document.createElement('canvas');//用来签名canvas.height = viewport.height;canvas.width = viewport.width;sighCanvas.height = viewport.height;sighCanvas.width = viewport.width;let ctx = canvas.getContext('2d');let renderContext = {canvasContext: ctx,viewport: viewport,};page.render(renderContext).then(() => {});initSignatureCanvas(sighCanvas);canvass.value.push(canvas);signCanvass.value.push(sighCanvas);contentDiv.value.appendChild(canvas);signContentDiv.value.appendChild(sighCanvas);});}});
}
主要是保存 PDF 的功能与原来完全不一样。
因为我们前面说的签名画布和 PDF 页是同步生成的,所以页码(下标)也是相对应的。
所以我们只要把签名页面转成一个透明背景的 PNG ,然后添加到 PDF 对应页码的页面上,新的 PDF 文件就是我们需要的签名文件 。
const savePDF = async () => {const pdfDoc = await PDFDocument.load(base64.value);const pages = pdfDoc.getPages();for (let i = 0; i < pages.length; i++) {//对应下标的 签名画布中的内容生成 png图片const eleImgCover = await pdfDoc.embedPng(signCanvass.value[i].toDataURL('image/PNG'));//页面中添加水印pages[i].drawImage(eleImgCover, {x: 0,y: 0,width: eleImgCover.width / scale.value, //这里要进行缩放,因为之前的画布我们是放大过的height: eleImgCover.height / scale.value, //这里要进行缩放,因为之前的画布我们是放大过的});}//生成blob流const pdfBytes = await pdfDoc.save();saveBlob(pdfBytes, 'TestPdf');
};
//网上找的 保存 bolb流 的方法
function saveBlob(data, fileName) {if (typeof window.navigator.msSaveBlob !== 'undefined') {window.navigator.msSaveBlob(new Blob([data], { type: 'application/pdf' }),fileName + '.pdf',);} else {let url = window.URL.createObjectURL(new Blob([data], { type: 'application/pdf' })); //定义下载的链接let link = document.createElement('a'); //创建一个超链接元素link.style.display = 'none'; //隐藏该元素link.href = url; //创建下载的链接link.setAttribute('download', fileName + '.pdf');document.body.appendChild(link);link.click(); //点击下载document.body.removeChild(link); //下载完成移除元素window.URL.revokeObjectURL(url); //释放掉blob对象}
}
效果展示
文字内容可以解析、能够被选中


完整代码地址
方案一
方案二
相关文章:
Vue3 -- PDF展示、添加签名(带笔锋)、导出
文章目录笔锋签名方案一实现要点实现过程组件引用页面元素添加引用实现代码效果展示缺点方案二修改页面元素替换引用修改代码效果展示完整代码地址实现功能的时候采用了两个方案,主要是第一个方案最后的实现效果并不太理想,但实现起来比较简单࿰…...
行测-判断推理-图形推理-样式规律-属性规律-曲直性
左边的图全是由曲线构成的选C1 3 5全是由曲线构成的2 4 6全是由直线构成的第三行的图形有曲有直选A1 3 5有曲有直2 4 6全是直线选D图形有曲有直,排除B D外曲内直->内曲外直->外曲内直->内曲外直->外曲内直->内曲外直所以问号出的图形应该是内曲外直选…...
idea集成Alibaba Cloud Toolkit插件
idea集成Alibaba Cloud Toolkit插件 使用该插件主要是简化打包、上传、启动服务的相关操作。 很早之前的方式是使用开发工具(eclipse,idea),使用maven命令完成项目打包(这里指jar),然后通过shell工…...
Win11 文件夹打开慢或卡顿解决方案
问题 目前是 2023/2/27, 我的 Win11 系统点开一个文件夹要等待 2-3 秒才能加载出来, 使用体验极差。网上查阅大量资料, 有些人在系统更新后这个情况就消失了, 但是我这一直存在, 系统也是当前的最新版, 没有修复。 目前得出的结论是, 因为 Win11 的工具栏占用了过多的资源, 需…...
【PostgreSQL的idle in transaction连接状态】
在平时查询pg_stat_activity这个视图的时候,每一行包含了一个进程的相关信息,包含当前正在执行的SQL,或者会话的状态等等,state字段表示当前进程的状态。在PostgreSQL数据库里,其实代码里总共定义了7种BackendState&am…...
cityengine自定义纹理库资源
背景 cityengine虽然可以将shp生成带纹理的三维模型,但是纹理不一定满足我们的要求,这时候我们就想用我们自己制作的纹理 粗略了解规则文件 了解Building_From_Footprint.cga这个规则文件,具体文件位置默认在 “C:\Users[电脑用户名:如Administrator]\Documents\CityEng…...
taobao.top.secret.bill.detail( 服务商的商家解密账单详情查询 )
¥免费必须用户授权 服务商的商家解密账单详情查询,仅对90天内的账单提供SLA保障。 公共参数 请求地址: HTTP地址 http://gw.api.taobao.com/router/rest 公共请求参数: 公共响应参数: 请求参数 响应参数 点击获取key和secret 请求示例 TaobaoClient…...
2023软件测试金三银四常见的软件测试面试题-【抓包和网络协议篇】
八、抓包与网络协议 8.1 抓包工具怎么用 我原来的公司对于抓包这块,在App的测试用得比较多。我们会使用fiddler抓取数据检查结果,定位问题,测试安全,制造弱网环境; 如:抓取数据通过查看请求数据,请求行&…...
vue脚手架多页自动化生成实践
前言 在前端开发过程中,常常面对多种业务场景。到目前为止,前端对于不同场景的处理通常会采用不同的渲染方案来组合处理,常见的渲染方案包括:CSR(Client Side Rendering)、SSR(Server Side Rendering)、SSG(Static Site Generati…...
【SQL语句优化】
SQL语句优化是提高数据库查询性能的重要手段之一,下面是几种常见的SQL语句优化方法和案例: 减少查询的数据量 减少查询的数据量:使用 WHERE 子句和索引来限制检索行数,只检索需要的行,避免检索全部行数据。 例子&am…...
阿里P8:做测试10年我的一些经验分享,希望你们少走弯路
我是在2015年毕业的,当时是读的普通本科,不上不下的专业水平,毕业的时候,恰好遇到了金融危机。校园招聘里阴差阳错的巧合,让我走上了软件测试工程师的道路。 入职第一天,来了个高大上的讲师,记…...
栈在括号匹配中的应用(栈/链栈 纯C实现)
目录 1 问题背景 2 具体思路 3 代码实现 3.1 顺序栈实现 3.2 链栈实现 1 问题背景 栈的括号匹配问题是指在给定一个字符串(包含多种括号),判断其中的括号是否能够正确匹配,即每个左括号是否有一个对应的右括号与之匹配&#x…...
C语言Switch语句用法
C switch 语句 一个 switch 语句允许测试一个变量等于多个值时的情况。每个值称为一个 case,且被测试的变量会对每个 switch case 进行检查。 语法 C 语言中 switch 语句的语法: switch(expression){case constant-expression :statement(s);break;…...
Curl编码请求参数,API接口请求示例参数
请求参数请求参数:num_iid610947572360 参数说明:num_iid:1688商品ID sales_data:&sales_data1 获取近30天成交数据 agent:&agent1 获取1688分销代发价格数据请求示例 测试入口 Curl PHP PHPsdk JAVA C# Python-- 请求示例 url 默认请求参数已经…...
【C/C++】类型限定符extern、const、Volatile、register
1、extern: 声明一个变量,extern声明的变量没有建立存储空间。 extern int a ; //变量在定义的时候创建存储空间。 ①当我们在编译器中试图运行以下代码,系统会报错。 错误原因是“无法解析外部符号_a”.系统认为变量a是没有开辟内存空间的…...
day54【代码随想录】二刷数组
文章目录前言一、二分查找(力扣724)二、移除元素(力扣27)【双指针】三、有序数组的平方(力扣977)【双指针】四、合并两个有序数组(力扣88)五、长度最小的子数组(力扣209&…...
哪个品牌蓝牙耳机性价比高?性价比高的平价蓝牙耳机推荐
现如今,随着蓝牙技术的进步,蓝牙耳机在人们日常生活中的便捷性更胜从前。越来越多的蓝牙耳机品牌被大众看见、认可。那么,哪个品牌的蓝牙耳机性价比高?接下来,我给大家推荐几款性价比高的平价蓝牙耳机,一起…...
揭秘关于TFRcord的五脏六腑
揭秘关于TFRcord的五脏六腑 前言:本篇文章将演示如何创建、解析和使用tf.Example消息,以及如何在.tfrecord文件之间对tf.Example消息进行序列化、写入和读取。 教程讲解使用的都是结构化数据,文章最后还会演示如果将图片写成.tfrecord文件&am…...
【Shell学习笔记】3.Shell 传递参数及数组
前言 本章介绍Shell的传递参数和数组。 Shell 传递参数 我们可以在执行 Shell 脚本时,向脚本传递参数,脚本内获取参数的格式为:$n。n 代表一个数字,1 为执行脚本的第一个参数,2 为执行脚本的第二个参数,…...
【终结Bug】ModuleNotFoundError: No module named ‘cv2’
解决方案: 打开 cmd键入 pip install opencv_python -i https://pypi.tuna.tsinghua.edu.cn/simple...
微信福音:2345清理王微信专清功能介绍
现在大家用微信的时间越来越长,微信里的缓存也越攒越多,经常是好几个G,特别占空间。但是想清理又怕删错重要数据,不敢随便动手。这时候,微信专清功能就显得尤为重要。2345清理王的微信专清功能,完美解决了这…...
【bmc10】route,iptables,macvlan,mii/mdio,ncsi,bond,vlan,dns,ipv6
文章目录 1.局域网 1.1 mac 2.互联网 2.1 tcp 3.route 4.iptables 4.1 filter表 4.2 nat表 5.macvlan 5.1 bridge模式 5.2 private模式 6.mii 6.1 rgmii时序调整 7.mdio 8.uboot&kernel配动态ip 9.ncsi 9.1 驱动分析 10.bond 11.vlan 12.dns 13.ipv6 1.局域网 1.早期通过双…...
2025届必备的五大降AI率神器推荐榜单
Ai论文网站排名(开题报告、文献综述、降aigc率、降重综合对比) TOP1. 千笔AI TOP2. aipasspaper TOP3. 清北论文 TOP4. 豆包 TOP5. kimi TOP6. deepseek 关乎维普检测系统之所涉 AI 降重计策要着重于文本之重新构建以及逻辑之 remodel。首先&#…...
Cursor IDE多智能体协作系统实战:从旅行规划到AI自动化流程构建
1. 项目概述:在Cursor IDE中构建多智能体协作系统最近在探索AI编程助手的高级玩法,发现Cursor IDE内置的智能体(Agent)框架远不止是简单的代码补全。它允许我们像搭积木一样,创建多个具备特定技能的AI智能体࿰…...
AI代码翻译工具Polyglot Transmogrifier:从语法转换到语义保持的跨语言编程实践
1. 项目概述:一个能“翻译”代码的AI技能最近在折腾一个叫OpenClaw的AI智能体平台,发现了一个挺有意思的技能,叫“Polyglot Transmogrifier”。这名字听起来有点唬人,直译过来是“多语言变形器”,但它的功能其实很直接…...
3步搞定漫画文本识别:MangaOCR日语漫画阅读革命
3步搞定漫画文本识别:MangaOCR日语漫画阅读革命 【免费下载链接】manga-ocr Optical character recognition for Japanese text, with the main focus being Japanese manga 项目地址: https://gitcode.com/gh_mirrors/ma/manga-ocr 你是否曾经面对日文漫画中…...
Taotoken的按token计费模式如何让实验性项目的成本更可控
Taotoken的按token计费模式如何让实验性项目的成本更可控 1. 实验性项目的成本挑战 在AI原型验证或小规模实验阶段,开发者通常面临模型选型与成本控制的双重压力。传统包月套餐要求预先支付固定费用,而实验过程中频繁切换模型、调整参数会导致实际用量…...
【PostgreSQL从零到精通】第48篇:PL/Proxy数据分片——PostgreSQL的水平扩展利器
上一篇【第47篇】Bucardo多主复制——实现真正的双向数据同步 下一篇【第49篇】pgpool-II完全指南——连接池复制负载均衡的三合一方案 单台 PostgreSQL 服务器的读写能力总有一个上限。当数据量达到 TB 级别、并发请求达到数万 QPS 时,再怎么优化硬件也无济于事——…...
DuckDB的递归CTE性能改进
来源:https://github.com/duckdb/duckdb/pull/22211 优化递归 CTE 性能 #22211 作者: kryonix 我终于能够分享这个 PR(拉取请求)了,我感到非常兴奋。说实话,我想实现这个功能已经好几年了,但一直没时间真正…...
如何在CI/CD中集成Flow:提升JavaScript代码质量的完整指南
如何在CI/CD中集成Flow:提升JavaScript代码质量的完整指南 【免费下载链接】flow Adds static typing to JavaScript to improve developer productivity and code quality. 项目地址: https://gitcode.com/gh_mirrors/flow30/flow Flow是一个为JavaScript添…...
