Babel 插件:30分钟从入门到实战

news/2024/5/20 23:01:34/文章来源:https://blog.csdn.net/ByteDanceTech/article/details/126900235

动手点关注 干货不迷路 👇

Babel 是一个 source to source(源码到源码)的 JavaScript 编译器,简单来说,你为 Babel 提供一些 JavaScript 代码,Babel 可以更改这些代码,然后返回给你新生成的代码。Babel 主要用于将 ECMAScript 2015+ 代码转换为能够向后兼容的 JavaScript 版本。Babel 使用插件系统进行代码转换,因此任何人都可以为 babel 编写自己的转换插件,以支持实现广泛的功能。

Babel 编译流程

Babel 的编译流程主要分为三个部分:解析(parse),转换(transform),生成(generate)。

code -> AST -> transformed AST -> transformed code
  • 解析 Parse

将源码转换成抽象语法树(AST, Abstract Syntax Tree)。

比如:

function square(n) {return n * n;
}

以上的程序可以被转换成类似这样的抽象语法树:

- FunctionDeclaration:- id:- Identifier:- name: square- params [1]- Identifier- name: n- body:- BlockStatement- body [1]- ReturnStatement- argument- BinaryExpression- operator: *- left- Identifier- name: n- right- Identifier- name: n
  • 转换 Transform

转换阶段接受一个 AST 并遍历它,在遍历的过程中对树的节点进行增删改。这也是运行 Babel 插件的阶段。

  • 生成 Generate

将经过一系列转换之后的 AST 转换成字符串形式的代码,同时还会创建 sourcemap。

你会用到的一些工具库

对于每一个阶段,Babel 都提供了一些工具库:

  • Parse 阶段可以使用 @babel/parser 将源码转换成 AST。

  • Transform 阶段可以使用 @babel/traverse 调用 visitor 函数遍历 AST,期间可以使用 @babel/types 创建 AST 和检查 AST 节点的类型,批量创建 AST 的场景下可以使用 @babel/template 中途还可以使用 @babel/code-frame 打印报错信息。

  • Generate 阶段可以使用 @babel/generator 根据 AST 生成代码字符串和 sourcemap。

以上提及的包都是 @babel/core 的 dependencies,所以只需要安装 @babel/core 就能访问到它们。

除了上面提到的工具库,以下工具库也比较常用:

  • @babel/helper-plugin-utils:如果插件使用者的 Babel 版本没有您的插件所需的 API,它能给用户提供明确的错误信息。

  • babel-plugin-tester:用于帮助测试 Babel 插件的实用工具,通常配合 jest 使用。

本文不会深入讨论它们的详细用法,当你在编写插件的时候,可以根据功能需求找到它们,我们后文也会涉及到部分用法。

认识 Babel 插件

接下来让我们开始认识 Babel 插件吧。

babel 插件是一个简单的函数,它必须返回一个匹配以下接口的对象。如果 Babel 发现未知属性,它将抛出错误。

7e9131755bd0dcfd1ec63d623921a145.png

以下是一个简单的插件示例:

export default function(api, options, dirname) {return {visitor: {StringLiteral(path, state) {},}};
};

Babel 插件接受 3 个参数:

  • api:一个对象,包含了 types (@babel/types)、traverse (@babel/traverse)、template(@babel/template) 等实用方法,我们能从这个对象中访问到 @babel/core dependecies 中包含的方法。

  • options:插件参数。

  • dirname:目录名。

返回的对象有 name、manipulateOptions、pre、visitor、post、inherits 等属性:

  • name:插件名字。

  • inherits:指定继承某个插件,通过 Object.assign 的方式,和当前插件的 options 合并。

  • visitor:指定 traverse 时调用的函数。

  • pre 和 post 分别在遍历前后调用,可以做一些插件调用前后的逻辑,比如可以往 file(表示文件的对象,在插件里面通过 state.file 拿到)中放一些东西,在遍历的过程中取出来。

  • manipulateOptions:用于修改 options,是在插件里面修改配置的方式。

我们上面提到了一些陌生的概念:visitor、path、state,现在让我们一起来认识它们:

  • visitor 访问者

这个名字来源于设计模式中的访问者模式(https://en.wikipedia.org/wiki/Visitor_pattern)。简单的说它就是一个对象,指定了在遍历 AST 过程中,访问指定节点时应该被调用的方法。

  • 假如我们有这样一段程序:

    function foo() {return 'string'}
  • 这段代码对应的 AST 如下:

    - Program- FunctionDeclaration (body[0])- Identifier (id)- BlockStatement (body)- ReturnStatement (body[0])- StringLiteral (arugument)
  • 当我们对这颗 AST 进行深度优先遍历时,每次访问 StringLiteral 都会调用 visitor.StringLiteral。

当 visitor.StringLiteral 是一个函数时,它将在向下遍历的过程中被调用(即进入阶段)。当 visitor.StringLiteral 是一个对象时({ enter(path, state) {}, exit(path, state) {} }),visitor.StringLiteral.enter 将在向下遍历的过程中被调用(进入阶段),visitor.StringLiteral.exit 将在向上遍历的过程中被调用(退出阶段)。

  • Path 路径

Path 用于表示两个节点之间连接的对象,这是一个可操作和访问的巨大可变对象。

Path 之间的关系如图所示:

152ca04e89e54a45243b732f6c6e7100.png

除了能在 Path 对象上访问到当前 AST 节点、父级 AST 节点、父级 Path 对象,还能访问到添加、更新、移动和删除节点等其他方法,这些方法提高了我们对 AST 增删改的效率。

  • State 状态

在实际编写插件的过程中,某一类型节点的处理可能需要依赖其他类型节点的处理结果,但由于 visitor 属性之间互不关联,因此需要 state 帮助我们在不同的 visitor 之间传递状态。

一种处理方式是使用递归,并将状态往下层传递:

const anotherVisitor = {Identifier(path) {console.log(this.someParam) // => 'xxx'}
};const MyVisitor = {FunctionDeclaration(path, state) {// state.cwd: 当前执行目录// state.opts: 插件 options// state.filename: 当前文件名(绝对路径)// state.file: BabelFile 对象,包含当前整个 ast,当前文件内容 code,etc.// state.key: 当前插件名字path.traverse(anotherVisitor, { someParam: 'xxx' });}
};

另外一种传递状态的办法是将状态直接设置到 this 上,Babel 会给 visitor 上的每个方法绑定 this。在 Babel 插件中,this 通常会被用于传递状态:从 pre 到 visitor 再到 post。

export default function({ types: t }) {return {pre(state) {this.cache = new Map();},visitor: {StringLiteral(path) {this.cache.set(path.node.value, 1);}},post(state) {console.log(this.cache);}};}

常用的 API

Babel 没有完整的文档讲解所有的 api,因此下面会列举一些可能还算常用的 api(并不是所有,主要是 path 和 types 上的方法或属性),我们并不需要全部背下来,在你需要用的时候,能找到对应的方法即可。

你可以通过 babel 的 typescript 类型定义找到以下列举的属性和方法,还可以通过 Babel Handbook 找到它们的具体使用方法。

Babel Handbook:https://astexplorer.net/

  • 查询

    • path.node:访问当前节点

    • path.get():获取属性内部的 path

    • path.inList:判断路径是否有同级节点

    • path.key:获取路径所在容器的索引

    • path.container:获取路径的容器(包含所有同级节点的数组)

    • path.listKey:获取容器的key

    • path.getSibling():获得同级路径

    • path.findParent():对于每一个父路径调用 callback 并将其 NodePath 当作参数,当 callback 返回真值时,则将其 NodePath 返回

    • path.find():与 path.findParent 的区别是,该方法会遍历当前节点

  • 遍历

    • path.stop():跳过遍历当前路径的子路径

    • path.skip():完全停止遍历

  • 判断

    • types.isXxx():检查节点的类型,如 types.isStringLiteral(path.node)

    • path.isReferencedIdentifier():检查标识符(Identifier)是否被引用

  • 增删改

    • path.replaceWith():替换单个节点

    • path.replaceWithMultiple():用多节点替换单节点

    • path.replaceWithSourceString():用字符串源码替换节点

    • path.insertBefore() / path.insertAfter():插入兄弟节点

    • path.get('listKey').unshiftContainer() / path.get('listKey').pushContainer():插入一个节点到数组中,如 body

    • path.remove():删除一个节点

  • 作用域

    • path.scope.hasBinding(): 从当前作用域开始向上查找变量

    • path.scope.hasOwnBinding():仅在当前作用域中查找变量

    • path.scope.generateUidIdentifier():生成一个唯一的标识符,不会与任何本地定义的变量相冲突

    • path.scope.generateUidIdentifierBasedOnNode():基于某个节点创建唯一的标识符

    • path.scope.rename():重命名绑定及其引用

AST Explorer

在 @babel/types 的类型定义中,可以找到所有 AST 节点类型。我们不需要记住所有节点类型,社区内有一个 AST 可视化工具能够帮助我们分析 AST:axtexplorer.net。

在这个网站的左侧,可以输入我们想要分析的代码,在右侧会自动生成对应的 AST。当我们在左侧代码区域点击某一个节点,比如函数名 foo,右侧 AST 会自动跳转到对应的 Identifier AST 节点,并高亮展示。

1aa979a514cf61d5e524e927976543c9.png

我们还可以修改要 parse 的语言、使用的 parser、parser 参数等。

自己实现一个插件吧

现在让我们来实现一个简单的插件吧!以下是插件需要实现的功能:

  1. 将代码里重复的字符串字面量(StringLiteral)提升到顶层作用域。

  1. 接受一个参数 minCount,它是 number 类型,如果某个字符串字面量重复次数大于等于 minCount 的值,则将它提升到顶层作用域,否则不做任何处理。

因此,对于以下输入:

const s1 = "foo";
const s2 = "foo";const s3 = "bar";function f1() {const s4 = "baz";if (true) {const s5 = "baz";}
}

应该输出以下代码:

var _foo = "foo",_baz = "baz";
const s1 = _foo;
const s2 = _foo;
const s3 = "bar";function f1() {const s4 = _baz;if (true) {const s5 = _baz;}
}

通过 https://astexplorer.net/,我们发现代码里的字符串在 AST 上对应的节点叫做 StringLiteral,如果想要拿到代码里所有的字符串并且统计每种字符串的数量,就需要遍历 StringLiteral 节点。

0e4006a6d631c5745e1cded718332445.png

我们需要一个对象用于存储所有 StringLiteral,key 是 StringLiteral 节点的 value 属性值,value 是一个数组,用于存储拥有相同 path.node.value 的所有 path 对象,最后把这个对象存到 state 对象上,以便于在遍历结束时能统计相同字符串的重复次数,从而可以判断哪些节点需要被替换为一个标识符。

export default function() {return {visitor: {StringLiteral(path, state) {state.stringPathMap = state.stringPathMap || {};const nodes = state.stringPathMap[path.node.value] || [];nodes.push(path);state.stringPathMap[path.node.value] = nodes;}}};
}

通过 https://astexplorer.net/,我们发现如果想要往顶层作用域中插入一个变量,其实就是往 Program 节点的 body 上插入 AST 节点。Program 节点也是 AST 的顶层节点,在遍历过程的退出阶段,Program 节点是最后一个被处理的,因此我们需要做的事情是:根据收集到的字符串字面量,分别创建一个位于顶层作用域的变量,并将它们统一插入到 Program 的 body 中,同时将代码中的字符串替换为对应的变量。

fad2d30ba4fb63c336f68e698a04d04b.png

export default function() {return {visitor: {StringLiteral(path, state) { /** ... */ },Program: {exit(path, state) {const { minCount = 2 } = state.opts || {};for (const [string, paths] of Object.entries(state.stringPathMap || {})) {if (paths.length < minCount) {continue;}const id = path.scope.generateUidIdentifier(string);paths.forEach(p => {p.replaceWith(id);});path.scope.push({ id, init: types.stringLiteral(string) });}},},}};
}

完整代码

import { PluginPass, NodePath } from '@babel/core';
import { declare } from '@babel/helper-plugin-utils';interface Options {/*** 当字符串字面量的重复次数大于或小于 minCount,将会被提升到顶层作用域*/minCount?: number;
}type State = PluginPass & {// 以 StringLiteral 节点的 value 属性值为 key,存放所有 StringLiteral 的 Path 对象stringPathMap?: Record<string, NodePath[]>;
};const HoistCommonString = declare<Options>(({ assertVersion, types }, options) => {// 判断当前 Babel 版本是否为 7assertVersion(7);return {// 插件名字name: 'hoist-common-string',visitor: {StringLiteral(path, state: State) {// 将所有 StringLiteral 节点对应的 path 对象收集起来,存到 state 对象里,// 以便于在遍历结束时能统计相同字符串的重复次数state.stringPathMap = state.stringPathMap || {};const nodes = state.stringPathMap[path.node.value] || [];nodes.push(path);state.stringPathMap[path.node.value] = nodes;},Program: {// 将在遍历过程的退出阶段被调用// Program 节点是顶层 AST 节点,可以认为 Program.exit 是最后一个执行的 visitor 函数exit(path, state: State) {// 插件参数。还可以通过 state.opts 拿到插件参数const { minCount = 2 } = options || {};for (const [string, paths] of Object.entries(state.stringPathMap || {})) {// 对于重复次数少于 minCount 的 Path,不做处理if (paths.length < minCount) {continue;}// 基于给定的字符串创建一个唯一的标识符const id = path.scope.generateUidIdentifier(string);// 将所有相同的字符串字面量替换为上面生成的标识符paths.forEach(p => {p.replaceWith(id);});// 将标识符添加到顶层作用域中path.scope.push({ id, init: types.stringLiteral(string) });}},},},};
});

测试插件

测试 Babel 插件有三种常用的方法:

  • 测试转换后的 AST 结果,检查是否符合预期

  • 测试转换后的代码字符串,检查是否符合预期(通常使用快照测试)

  • 执行转换后的代码,检查执行结果是否符合预期

我们一般使用第二种方法,配合 babel-plugin-tester 可以很好地帮助我们完成测试工作。配合 babel-plugin-tester,我们可以对比输入输出的字符串、文件、快照。

import pluginTester from 'babel-plugin-tester';
import xxxPlugin from './xxxPlugin';pluginTester({plugin: xxxPlugin,fixtures: path.join(__dirname, '__fixtures__'),tests: {// 1. 对比转换前后的字符串// 1.1 输入输出完全一致时,可以简写'does not change code with no identifiers': '"hello";',// 1.2 输入输出不一致'changes this code': {code: 'var hello = "hi";',output: 'var olleh = "hi";',},// 2. 对比转换前后的文件'using fixtures files': {fixture: 'changed.js',outputFixture: 'changed-output.js',},// 3. 与上一次生成的快照做对比'using jest snapshots': {code: `function sayHi(person) {return 'Hello ' + person + '!'}`,snapshot: true,},},
});

本文将以快照测试为例,以下是测试我们插件的示例代码:

import pluginTester from 'babel-plugin-tester';
import HoistCommonString from '../index';pluginTester({// 插件plugin: HoistCommonString,// 插件名,可选pluginName: 'hoist-common-string',// 插件参数,可选pluginOptions: {minCount: 2,},tests: {'using jest snapshots': {// 输入code: `const s1 = "foo";const s2 = "foo";const s3 = "bar";function f1() {const s4 = "baz";if (true) {const s5 = "baz";}}`,// 使用快照测试snapshot: true,},},
});

当我们运行 jest 后(更多关于 jest 的介绍,可以查看 jest 官方文档https://jestjs.io/docs/getting-started),会生成一个 snapshots 目录:

fb91d777beb4c4b1fc7acff10f7040c5.png

有了快照以后,每次迭代插件都可以跑一下单测以快速检查功能是否正常。快照的更新也很简单,只需要执行 jest --updateSnapshot

使用插件

如果想要使用 Babel 插件,需要在配置文件里添加 plugins 选项,plugins 选项接受一个数组,值为字符串或者数组。以下是一些例子:

// .babelrc
{"plugins": ["babel-plugin-myPlugin1",["babel-plugin-myPlugin2"],["babel-plugin-myPlugin3", { /** 插件 options */ }],"./node_modules/asdf/plugin"]
}

Babel 对插件名字的格式有一定的要求,比如最好包含 babel-plugin,如果不包含的话也会自动补充。以下是 Babel 插件名字的自动补全规则:

7352958548375b98110af64f366bf4eb.png

到这里,Babel 插件的学习就告一段落了,如果大家想继续深入学习 Babel 插件,可以访问 Babel 的仓库(https://github.com/babel/babel/tree/main/packages)这是一个 monorepo,里面包含了很多真实的插件,通过阅读这些插件,相信你一定能对 Babel 插件有更深入的理解!

参考文档

Babel plugin handbook:https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md

Babel 官方文档:https://babeljs.io/docs/en/

Babel 插件通关秘籍:https://juejin.cn/book/6946117847848321055

🙋 加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经营管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

内推:欢迎扫码投递简历

55b422db981282bf303ac32a0e60052d.png

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.luyixian.cn/news_show_9116.aspx

如若内容造成侵权/违法违规/事实不符,请联系dt猫网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

LeetCode程序员面试金典(第 6 版)上

目录 面试题 01.01. 判定字符是否唯一 面试题 01.03. URL化 面试题 01.04. 回文排列 面试题 01.05. 一次编辑 面试题 01.06. 字符串压缩 面试题 01.07. 旋转矩阵 面试题 01.08. 零矩阵 面试题 01.09. 字符串轮转 面试题 02.01. 移除重复节点 面试题 02.02. 返回倒数第…

BI测试

关于BI测试 前言:由于之前做过一段时间大数据测试,故整理BI测试知识点以供学习。BI测试: BI是从数据接入、数据准备、数据分析、数据可视化到数bai据分发应用的一系列过程,目的是为了辅助企业高效决策。而报表虽然最终也实现了数据可视化,但是对于数据分析的维度、深度、颗…

【数据结构与算法】排序(下篇)

&#x1f431;作者&#xff1a;一只大喵咪1201 &#x1f431;专栏&#xff1a;《数据结构与算法》 &#x1f525;格言&#xff1a;你只管努力&#xff0c;剩下的交给时间&#xff01; 排序⚽归并排序⚾递归实现⚾非递归实现⚽常见排序算法的复杂度和稳定性分析⚾稳定性⚾具体分…

docker安装mysql(单体)

docker安装mysql mac的m1芯片上不支持5.7版本的镜像&#xff0c;因此可以直接选择拉取8.0及之后的版本 docker pull mysql创建mysql的宿主机数据卷挂载的文件夹 # mysql的配置文件&#xff0c;注意conf.d文件夹必须要创建&#xff0c;否则启动容器的时候&#xff0c;数据卷 …

linux 锁-- atomic per_cpu

atomic引入背景 对于 SMP 系统中&#xff0c;在开启 preempt 情况下&#xff0c;对于公共资源&#xff0c;如果存在两个 task 来进行更改&#xff0c;这就面临临界区资源竞争问题&#xff0c;此时会产生意想不到的结果&#xff0c;这是不符合预期的&#xff0c;因此需要来进行…

nginx-nginx的文件服务器的配置

nginx的文件服务器的配置location /data {charset gbk,utf-8;autoindex on;autoindex_exact_size off;autoindex_localtime on;limit_rate_after 10m;alias D:;allow all; }访问文件路径xxx/data访问成功的返回界面

CAS:385437-57-0,DSPE-PEG-Biotin,Biotin-PEG-DSPE,磷脂-聚乙二醇-生物素试剂供应

DSPE-PEG-Biotin&#xff08;磷脂-聚乙二醇-生物素&#xff09;的分子量有&#xff1a;1k&#xff0c;2k&#xff0c;3.4k等&#xff0c;其它的分子量可以定制。质量控制在95%&#xff0c;Biotin-PEG-DSPE主要用于科研实验使用&#xff0c;非药用&#xff0c;非食用。它溶于水和…

ETH 2.0 背景下的新机会与新叙事

以太坊 POW 到 POS 的转型为整个 Web3 行业的发展引入了一层新的叙事&#xff0c;即对于去中心化更深切的要求。TIPS 在 Merge - Shanghai Upgrade 的约 9 个月的小周期内&#xff0c;流通状态的 ETH 在持续单调递减&#xff1b; 从长远来看&#xff0c;未来 ETC 等 Ethhash P…

多人协作多版本开发冲突的正确解决姿势

多人版本开发工作流程&#xff1a;https://blog.csdn.net/qq_32442973/article/details/125717959 这里实际上用上一个关键命令&#xff1a;git merge --no-ff 分支名 注意&#xff1a;无论何种情况&#xff0c;都绝不允许把开发、测试、预生产的代码拉到自己分支上解决冲突&am…

Druid1.2.12版本发布,新增连接池默认配置connectTimeout和socketTimeout详解

新版本特性如下 这个版本连接池默认增加配置connectTimeout和socketTimeout&#xff0c;增强了SQL Parser 连接池DruidDataSource支持新的配置connectTimeout和socketTimeout&#xff0c;分别都是10秒。这个默认值会减少因为网络丢包时导致的连接池无法创建链接。修复连接池D…

股票量化分析工具QTYX使用攻略代码说明——高速版本地行情源v2.5.1

搭建自己的量化系统如果要长期在市场中立于不败之地&#xff01;必须要形成一套自己的交易系统。否则&#xff0c;赚钱或者亏钱我们很难归纳总结&#xff0c;往往是凭借运气赚钱&#xff0c;而不是合理的系统模型&#xff0c;一时凭借运气赚的钱长期来看会因为实力还回去。QTYX…

mac 中配置idea自带maven环境变量

1.查找 maven 地址 访达--应用程序-- idea如图&#xff1a; 2.双击 选择 显示包内容&#xff1a; 3.找到maven地址&#xff1a;/Applications/IntelliJ IDEA.app/Contents/plugins/maven/lib/maven3 4.配置环境变量 &#xff08;1&#xff09;配置环境变量 终端输入 vim …

c++ Primer 第四章 表达式

4.1 基础 略 4.2 算术运算符4.3 逻辑和关系运算符4.4 赋值运算符 略 4.5 递增和递减 ++i 先自增后运算 i++ 先运算后自增 4.6 成员访问运算符 int main() {string s1 = "a string";string *pS1 = &s1;cout << pS1->size() << endl; // 等价于(*p)…

【JavaScript设计模式】增强版发布订阅模式——Webpack的核心Tapable(一)

Tapable简介 Webpack整体架构的实现就是靠它的插件系统&#xff0c;其中Compiler和Compilation负责管理整个构建流程&#xff0c;同时暴露出一些Hook&#xff0c;然后由不同职责的插件来监听这些Hook&#xff0c;并在合适的时机完成具体的工作。Tapable是整个Webpack插件系统的…

CentOS二进制安装Containerd

Containerd有两种安装包∶ 1>. 第一种是containerd-xxx&#xff0c;这种包用于单机测试没问题&#xff0c;不包runC&#xff0c;需要提前安装。 2>. 第二种是cri-containerd-cni-xxx&#xff0c;包含runC和k8s里的所需要的相关文件。k8s集群里需要用到此包&#xff0c;…

Qt5.12.2添加mqtt模块

Qt5.12.2添加mqtt模块下载 mqtt Qt 子模块使用 Qt 编译模块并install 生成动态库qtcreator 打开解压后的 qtmqtt源码下的 pro 工程文件下载 mqtt Qt 子模块 https://download.qt.io/official_releases/qt/5.15/5.15.4/submodules/ 下载后解压&#xff1a; 使用 Qt 编译模块并…

Go语言实现网盘系统(上)

该项目将基于go-zero和xorm go-zero中文文档: https://legacy.go-zero.dev/cn/ Xorm中文文档: http://xorm.topgoer.com/ 功能划分 整个项目可以分为3个模块: 用户模块、存储池模块和文件共享模块数据库设计 用户是一个实体,建立对应的表user_basic,存储了用户信息,DDL如下:…

ViLBERT—(NeurIPS-2019)

ViLBERT(Vision-and-Language BERT)是发表于2019年的论文&#xff0c;在功能上实现了文本图像的多模态特征提取与分类。改论文的特点是使用了双流模型&#xff0c;即先各个模态特征单独自注意力&#xff0c;再经过transformer交叉注意力。单流是将不同模态特征序列先拼接起来&a…

FluentCRM 2.5 – 大量新功能,自动化你的业务!

大家好&#xff01; 等待结束了&#xff01;我知道你们都在热切地等待另一个 FluentCRM 更新并且它已经上线了。 这一次&#xff0c;我很高兴地宣布我们开发了一些史诗级、最令人期待的功能。让我们开始了解 FluentCRM 2.5&#xff01; 客户关系管理是关于从您的潜在客户、顾…

ASO优化之手游该如何获得巨量新增(上)

现今各家应用商店里的应用和手游数量都非常多&#xff0c;那么提高曝光和获得自然新增是一项非常困难的工作。所以&#xff0c;应用产品ASO优化已经是所有同行都必做的功课之一。且每天都有很多款新游戏发布&#xff0c;所以想让你的手游脱颖而出就更加困难。手机游戏现已经成为…