做前端或者 Node 的同学,日常工作里恐怕没有一个项目能绕开字符串拼接。拼一条接口参数、拼一段 HTML 模板、拼一个 SQL 的 in 条件、拼一条日志,甚至面试题里也总爱问“你有哪几种方式把几个字符串合并成一个”。这个标题看起来基础,可我见过不少写了三四年 JavaScript 的人,翻来覆去只会一招+,遇到循环里拼一千个片段时性能崩了也不知道为什么,然后反过来怪 JS 慢。这篇就把合并拼接字符串的 5 种方法彻底摊开,包括每种写法的背后规则、类型转换的坑、不同数据量级下的性能表现,以及我在真实项目里踩过的几个雷。新同学可以按顺序通读,老手可以直接跳到性能实测和选型那两节。
1. 五种字符串拼接方式逐项拆解
1.1 “+” 运算符:最老牌也最容易想当然
+应该是绝大多数人学会的第一种拼接方式,写法最直白:
const str1 = 'Hello'; const str2 = 'World'; const result = str1 + ' ' + str2; console.log(result); // "Hello World"规则只有一条:只要+两侧任意一侧是字符串,JavaScript 就会把另一边也转成字符串,然后合并。听起来简单,但实际执行时有个从左到右的问题,特别容易让人翻车:
console.log(1 + 2 + '3'); // "33" console.log('3' + 1 + 2); // "312"第一个例子最有迷惑性。1 + 2先被当成数学加法算出了3,然后3 + '3'因为遇到了字符串,才转为拼接,最终结果是"33"。第二个例子则完全反过来,'3' + 1率先进入拼接模式,结果是"31",继续+ 2也在拼接,于是得到"312"。这里没有任何弯弯绕,就是“从左到右、逐段判断”的规则在起作用。
如果你想把所有参数都当成字符串来拼,最稳妥的办法是先把数字部分用String()或者模板字符串处理掉,而不是依赖隐式转换。别小看这行规则,很多线上 bug 就是这么来的。
1.2+=运算符:循环场景里的高频用法
+=是result = result + right的语法糖,本质和+是一套规则,但使用场景非常固定:循环里面逐条追加内容。
let result = ''; const list = ['苹果', '香蕉', '橘子']; for (const item of list) { result += item + '、'; } // 去掉末尾多余的分隔符 result = result.slice(0, -1); console.log(result); // "苹果、香蕉、橘子"这里有一个很常见的小技巧叫“先拼后切”:循环体内统一在后面加分隔符,循环结束后用slice(0, -1)把最后一个多余的分隔符去掉。比起在循环里写if (i < list.length - 1)判断要不要加分隔符,这种写法可读性高很多,也不容易出错。
使用+=时要注意一个反直觉的点:字符串本身是不可变的。str += 'x'并不是在原来的字符串尾巴上追加字符,而是创建了一个全新的字符串,然后把变量指向它。老教程里说这样很慢,在早期浏览器里确实如此;但现代的 V8 引擎内部对字符串拼接做了不少优化,尤其是“短字符串 + 短字符串”的场景,引擎会用一种类似链表的结构暂存拼接片段,等到真正需要取值时才合并成完整字符串。所以你在日常数据量下使用+=并不会有什么问题,真正需要担心的场景我在后面性能部分再展开。
1.3 concat() 方法:被很多人遗忘的正规 API
String.prototype.concat()是 JavaScript 内置的字符串方法,可以接收多个参数,一次性全部拼进去:
const str = 'Hello'; const result = str.concat(' ', 'World', '!'); console.log(result); // "Hello World!"它的特点是不会修改原字符串,而是返回一个拼接后的新字符串。这一点和+一样,字符串本身不可变,任何拼接操作都会产生新值。
不过说句实话,日常业务代码里我很少看到有人用concat()。主要原因有两个:一是+写起来更短,二是模板字符串和join()在很多场景的可读性更好。但这并不代表concat()没有价值,至少有两类场景它比+顺手:
第一,当你已经有一个数组,想用类似“流式拼接”的方式把多个值串起来的时候,concat()接收不定长参数的特性就体现出来了:
const parts = ['2026', '03', '25']; const dateStr = ''.concat(...parts, ' 00:00:00'); console.log(dateStr); // "20260325 00:00:00"第二,面试题偶尔会问+和concat()的区别。这里有个容易混淆的点:+运算符只要有一边是字符串就走拼接,而concat()方法一定会把参数转成字符串再拼接,两者最终行为其实非常接近,最大区别是concat()传数组进来时会把数组转成字符串,结果和数组本身的toString()一致。
1.4 数组 join() 方法:把“拼一个字符串”变成“拼一组字符串”
前面几种方法都是一个一个把字符串粘起来,而Array.prototype.join()的思路完全不同:先把所有片段放进一个数组,然后用一个分隔符统一合并。
const parts = ['2026', '03', '25']; console.log(parts.join('-')); // "2026-03-25"join()的语法是数组.join(分隔符),如果不传分隔符,默认用逗号;如果传空字符串'',那就是无缝拼接。
这个方法真正厉害的地方在于,它把“拼接”和“动态决定是否加入某个片段”彻底解耦了。比如说你要拼一个查询字符串,有的参数可能为空,需要在拼接前先过滤掉:
const params = { keyword: '手机', page: 1, size: 20, tag: '' }; const queryString = Object.entries(params) .filter(([, value]) => value !== '' && value !== undefined && value !== null) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join('&'); console.log(queryString); // "keyword=%E6%89%8B%E6%9C%BA&page=1&size=20"这种用filter + map + join的写法非常干净,如果用+=来拼,你会写出一大堆条件判断,代码立刻变得又臭又长。
另外,join()和split()是一对黄金搭档。把一个字符串按某个分隔符拆开、处理后再拼回去,这种“split 转换 join”的模式在处理 CSV、日志、URL path 时非常常见。比如去掉字符串里所有数字里的逗号:'1,234,567'.split(',').join('')得到"1234567"。
1.5 模板字符串:开发效率最高的现代写法
ES6 引入的模板字符串(Template Literals)是现在我最推荐的拼接方式。它用反引号包裹整个字符串,通过${}插入变量或表达式:
const user = { name: '李雷', age: 18 }; const text = `姓名:${user.name},年龄:${user.age}`; console.log(text); // "姓名:李雷,年龄:18"模板字符串最大的优势不只是省去一堆加号和引号,而是它天然支持多行文本。以前拼多行 HTML 需要写一堆\n和加号,现在直接原样书写:
const html = ` <div class="card"> <h2>${user.name}</h2> <p>年龄:${user.age}</p> </div> `;这在拼 HTML 模板、表格行、邮件正文等场景下,可读性提升是质变级别的。
${}里面也不限于变量,可以是任意表达式,甚至嵌套模板字符串:
const items = ['苹果', '香蕉']; const listHtml = ` <ul> ${items.map(item => `<li>${item}</li>`).join('')} </ul> `;这里我用了两个小技巧:一是.map()返回的数组用.join('')无缝合并成字符串,避免了输出数组默认的逗号;二是内层的反引号模板字符串可以正常使用,嵌套模板在动态生成 DOM 时几乎是刚需。
性能方面需要说句公道话:模板字符串更多是语法层面的便利,底层仍然要走字符串拼接逻辑,并不存在“模板字符串一定更快”的说法。真正决定快慢的是你拼了多少次、每次内容多长,具体见后面性能章节。
为了方便对照,五种方法的基本特性我整理成了表格:
| 方法 | 语法 | 原字符串是否改变 | 适合场景 | 备注 |
|---|---|---|---|---|
+ | a + b | 否 | 简单拼接 | 存在隐式转换陷阱 |
+= | a += b | 否(重新赋值) | 循环内逐条追加 | 注意初始值 |
concat() | a.concat(b, c) | 否 | 不定长参数拼接 | 日常用得少 |
join() | arr.join(sep) | 否 | 批量拼接、列表转字符串 | 天然支持数组 |
| 模板字符串 | `${a}${b}` | 否 | 插值、多行文本 | 现代项目首选 |
2. 拼接背后的类型转换逻辑
很多人拼接时踩坑,问题不是出在方法本身,而是没搞懂 JavaScript 的隐式类型转换规则。这一节专门把这条“暗线”拉出来讲清楚,你会少踩很多坑。
2.1 “+” 到底是加法还是拼接
+是 JavaScript 里唯一一个身兼数职的运算符:它既能做数学加法,又能做字符串拼接。具体执行哪一种,取决于操作数的类型。
规则是这样的:如果+两侧有任何一侧是字符串,那就走拼接;如果两侧都是数字(或者能转成数字的布尔值等),就走数学加法。这条规则本身不难,难的是它会在表达式从左到右执行的过程中反复变化:
console.log(1 + 2 + '3'); // "33"(先加后拼) console.log('1' + 2 + 3); // "123"(全程拼接) console.log(1 + '2' + 3); // "123"(先拼再拼)理解了这一点,再看这类题就不会再晕了。处理混合类型的正确思路是:如果你需要的是字符串结果,先把所有非字符串内容显式转成字符串;如果你需要的是数字结果,避免让字符串混进加法表达式。
2.2 对象、数组和 null/undefined 参与拼接时发生什么
当一个对象参与+拼接时,JavaScript 会尝试把它转成原始值。转换顺序大致是:先调用valueOf(),如果返回的不是原始值,再调用toString()。大多数普通对象最终会落到toString(),所以结果通常是"[object Object]"。
console.log('obj: ' + {}); // "obj: [object Object]" console.log('arr: ' + [1, 2, 3]); // "arr: 1,2,3" console.log('emptyArr: ' + []); // "emptyArr: "数组的情况更有意思,[1, 2, 3]转字符串得到的是"1,2,3",空数组得到空字符串""。这里有个非常经典的坑:
console.log([] + ''); // "" console.log({} + ''); // "[object Object]"至于null和undefined,它们转字符串很直白,一个变成"null",一个变成"undefined"。但问题在于,这个结果往往不是你想要的:
const user = { name: '李雷', age: null }; console.log(`年龄:${user.age}`); // "年龄:null"如果你拼的是用户可见文案,把null原样输出去是明显不合适的。正确做法是先做默认值处理:
const ageText = user.age ?? '未知'; console.log(`年龄:${ageText}`); // "年龄:未知"2.3 String() 与 toString() 的差异
处理显式转换时,很多同学用value.toString(),但这个方法不是万能的:
const value = null; console.log(value.toString()); // TypeError: Cannot read properties of nullnull和undefined没有toString()方法,一调用就抛错。相比之下,String(value)是全局函数,它内部会处理原始值转换:
console.log(String(null)); // "null" console.log(String(undefined)); // "undefined"所以我的习惯是:在拼接外部数据时,只要拿不准类型,就用String()保底;如果确定是数字或普通对象,再用toString()也不迟。另外,数字转字符串还有个常用技巧:'' + 数字也能完成转换,但可读性不如String(数字)直观。
3. 性能实测:不同数据量级下的真实差距
说到拼接方法,网上文章最喜欢渲染“哪种最快”。我想直接说结论:在你我日常写业务代码的绝大多数场景里,这几种方法的性能差距可以忽略不计。真正拉开差距的是拼接次数和单次长度,而不是方法本身。但为了让结论有依据,我给出可以自己复现的测试思路。
3.1 可复现的对比测试
测试环境是 Node.js 18,你可以直接复制到本地跑。思路很简单:准备一个数组,分别用+=、join()、concat()、模板字符串去把数组元素拼成一个大字符串,然后用console.time记录耗时。
const list = Array.from({ length: 100000 }, (_, i) => 'item-' + i); // 方式一:+= console.time('+= '); let result1 = ''; for (const item of list) { result1 += item + ','; } console.timeEnd('+= '); // 方式二:join console.time('join'); const result2 = list.join(','); console.timeEnd('join'); // 方式三:concat console.time('concat'); let result3 = ''; for (const item of list) { result3 = result3.concat(item, ','); } console.timeEnd('concat'); // 方式四:模板字符串 console.time('template'); let result4 = ''; for (const item of list) { result4 = `${result4}${item},`; } console.timeEnd('template');我本地跑了一次,结果大致是:join最快,+=和template次之,concat最慢。但说实话,差距也就是几十毫秒级别,而且不同引擎、不同数据长度下排名还会变。
3.2 老说法“数组 join 最快”现在还成立吗
这个说法在十几年前的 IE 时代是成立的,那时字符串拼接每次都会创建新字符串,循环一千次就是一千次内存分配,join()先攒后拼的策略优势明显。但现在 V8 等现代引擎引入了对字符串拼接的优化,+=在大多数场景下并不慢。
那是不是说join()就没用了?不是。它真正的价值不是性能,而是语义。当你需要拼的是一组动态片段,每个片段还要经过过滤、映射处理时,join()配合数组方法比一串+=要清晰得多。性能只是它顺带的一个好处,没必要把它神化。
3.3 什么情况下才需要认真关心拼接性能
只有两类场景我会认真做性能考量:
第一类,超大数据量的日志或上报内容拼接。比如一次要拼几万条记录、每条还带时间戳和上下文,这时候我倾向于用数组收集后统一join(),或者直接交给专门的序列化工具处理,而不是在循环里不断+=。
第二类,高频执行路径里的字符串拼接。比如requestAnimationFrame回调里每帧都要拼一次 UI 文案,或者 WebSocket 消息处理里对大量短消息做格式化。这种场景要尽量减少创建大字符串的次数,能直接输出就输出,能分段就分段。
// 不推荐:每帧创建新字符串 function renderBad(count) { let text = ''; for (let i = 0; i < count; i++) { text += `第${i}个 `; } return text; } // 推荐:先收集再拼接 function renderGood(count) { const parts = []; for (let i = 0; i < count; i++) { parts.push(`第${i}个`); } return parts.join(' '); }这里的核心思想是减少大字符串的重复创建,而不是纠结用哪个方法。只要这个原则记住了,性能一般不会出大问题。
4. 真实项目里的选型逻辑与踩坑记录
4.1 我的日常选型标准
讲完原理和性能,说说我平时写代码时实际怎么选。我把规则浓缩成三条:
- 单条简单拼接,变量不超过两个:用
+就够,比如拼个带单位的值:price + '元'。 - 超过两个变量、需要换行、或涉及表达式:直接用模板字符串,别犹豫。
- 要拼接的内容是一个列表,且每个元素需要单独处理:用
map + join(),这是最稳的组合。
concat()在我日常代码里确实很少出现在业务逻辑中,更多是在写工具函数、处理可变参数时出现。它不是不好,而是有更好的替代品。不要因为某个方法“存在”就在所有场景硬套,代码是给人读的,可读性优先。
4.2 拼 URL 参数时的经典错误
这是我在 Code Review 里见过最多的问题。拿+拼 URL 时,最典型的两处坑:一是 base 地址末尾是否带?或&没判断,二是参数值没有编码。
// 错误示范 const keyword = '手机 华为'; const url = '/api/search?keyword=' + keyword + '&page=1'; // 空格、中文等特殊字符直接出现在 URL 里,很容易出问题 // 正确示范 const params = new URLSearchParams({ keyword, page: 1 }); const url = `/api/search?${params.toString()}`; console.log(url); // "/api/search?keyword=%E6%89%8B%E6%9C%BA+%E5%8D%8E%E4%B8%BA&page=1"如果不想用URLSearchParams,至少要记得对每个参数值调用encodeURIComponent()。凡是拼接外部输入到 URL 里的场景,都要先编码,不然你会踩到中文乱码、特殊字符截断参数等一系列问题。
4.3 拼 SQL 和 HTML 时的安全提醒
拼 SQL 是所有后端开发最早学会、也最应该警惕的场景。字符串拼接拼 SQL 看起来方便,但只要你把外部参数直接用+拼进 SQL,就等于给 SQL 注入开了大门:
// 极其危险的示范 const sql = `SELECT * FROM users WHERE name = '${userInput}'`;正确做法是使用数据库驱动提供的参数化查询或预编译语句,让值通过占位符传递,而不是拼进 SQL 文本。这条红线不能破,不管项目多小、多急,都不能把用户输入直接拼进 SQL。
拼 HTML 时也有类似问题。用模板字符串拼 HTML 片段,如果变量里有用户生成的内容,很容易产生 XSS 注入风险。正确做法是把变量经过转义处理后再插入,或者使用框架自带的安全插值机制,而不是裸奔innerHTML = 模板字符串。
4.4 浮点数精度在拼接时的隐藏问题
这个坑可能很多人没注意到。浮点数运算有精度问题,一旦你把计算结果拼成字符串,精度问题就会直接暴露出来:
console.log(0.1 + 0.2 + ''); // "0.30000000000000004" console.log(`${0.1 + 0.2}`); // "0.30000000000000004" console.log(String(0.1 + 0.2)); // "0.30000000000000004"不管用哪种拼接方式,结果都一样难看。如果你要展示金额或百分比,务必先做精度处理:
const total = (0.1 * 10 + 0.2 * 10) / 10; // 先转成整数运算 console.log(`${total}元`); // "0.3元" // 或者用 toFixed console.log((0.1 + 0.2).toFixed(2)); // "0.30"toFixed()返回的是字符串,可以直接用于展示,但要注意它返回的是四舍五入后的结果,而不是精确的十进制,金融场景下还是要用专门的十进制库。
4.5 多行模板字符串的缩进问题
模板字符串的多行能力是优点,但也带来一个容易忽视的副作用:代码里的缩进空格会被原样保留在结果字符串中。
const html = ` <div> <p>hello</p> </div> `; console.log(JSON.stringify(html)); // "\n <div>\n <p>hello</p>\n </div>\n"输出结果里包含了换行和每行开头的空格。这在大部分展示场景下没问题,但如果你拿这个字符串去做精确匹配校验或生成文件,多出来的空白字符就可能引发 bug。我的习惯是:如果对字符串内容要求干净,就加一行.trim():
const trimmed = html.trim();或者把整个模板字符串左对齐书写,虽然缩进难看,但输出干净。这两种方案里,trim()更省事,推荐优先使用。
4.6 不要忽略 Unicode 编码问题
最后补一个偏门但真实存在的坑。String.prototype.length统计的是 UTF-16 码元数量,而不是用户感知的“字符数”。当你拼接用户输入或特殊 emoji 时,可能看到奇怪的长度:
const emoji = '👍'.length; console.log(emoji); // 2这不会直接影响拼接,但如果你把字符串按索引切分拼接,就可能把一个完整字符从中间截断,产生乱码。处理含 emoji 的字符串时,建议用Array.from()或for...of遍历,而不是普通下标切分。
我个人在实际项目里的体会是,模板字符串和数组join()这两招已经能覆盖九成以上的拼接需求,+在理解类型转换规则后也完全够用,concat()更像是压箱底的备用技能。最后再分享一个小技巧:如果你经常要在几种写法之间来回改,说明你的函数边界可能没划清楚,与其纠结字符串怎么拼,不如先想清楚哪些内容应该由函数返回、哪些应该由调用方拼接,代码会清爽很多。字符串拼接这件事本身不难,难的是在正确的地方用正确的方式,希望这篇能帮你把这块补完整。