前言
在一个比较大的项目里面(有国际化需求的),国际化的支持是一个必不可少的;
那如何落地就得具体问题具体分析了,这里说说我遇到过并落地的一个改造方案;
说说项目背景,是一个迭代多年的产研类项目(整个系统是围绕react生态去研发的),历史包袱挺多;
多种第三方库并存,也有iframe的场景以及自研的插件机制系统(现代沙盒隔离那一套);
方案仅供参考,哈!
方案
基础信息(技术栈)
- 构建工具流:Gulp 4 + Webpack 4
- 第三方库(lib)
- moment
- dayjs
- gantt
- ckeditor
- ...
- react 标准全家桶
聚焦点
整合所有i18n资源,集中打包,前置加载(页面头部-C端渲染);
而且我们这边不考虑IE,聚焦现代化的浏览器~
从以下个方面入手语言包覆盖
- 业务层面全部用i18next作为字段文案维护;
- 所有非第三方库自身,都可以算作是业务层面
- 组件库提供语言包端字段映射对象
- 第三方微微魔改
- 没有多语支持或者版本太老旧不好升级的
- 初始化时机篡改原型链
- 对于支持 切换的,比如moment,dayjs,ck
- 把对应的需要的语言对象构建好,丢给他们自己初始化即可!
语言资源必须集中化维护!(所以我们之前花了些时间做了整个系统的统一)
语言切换时机
- 页面加载过程中阻塞加载语言包,再继续后面的初始化逻辑
- 语言切换采用重载(reload)方案
为什么采用重载?因为会比较彻底和正确响应;
上面说到了,这是一个新老技术融合的项目,不纯粹!
重载有两个非常大的好处
- 从接口层发出语言标识,在进入用户界面时候数据就能拉到正确的响应数据(不同语言的response)
- 其次语言资源可以按需加载(也能非常正确的初始化)
流程图
gulp
为什么用gulp?gulp 在一些场景很好用(比如一些静态资源的转换,迁移等等);
一股脑的丢webpack这类其实会带来很多构建开销;
所以语言文件用gulp watch实时去监听,产物打到特定的位置就好了;
这边的语言资源是作为一个npm模块来维护的,如图
locale下面就是不同语种,watch整个目录即可!
比如这个task就是构建语言产物的,这个导出再并入gulp stream即可!(仅供参考)
import { resolve } from 'path'; import { src, dest, parallel, watch } from 'gulp'; import { accessSync, constants, statSync, readdirSync } from 'fs'; import gulpEsbuild from 'gulp-esbuild'; import { getDevModeAndParams } from '../../utils'; function checkDirExist(checkPath) { try { accessSync(checkPath, constants.R_OK | constants.W_OK); console.log(`${checkPath} 路径gulp能读写`); } catch (err) { console.error(`${checkPath} 无法尝试访问,请先检测是否存在`, err); process.exit(1); } } function getLocaleDirName(path) { if (!path) throw new Error('path no exist'); try { const localeDirName = []; const localeGulpTaskName = []; const readList = readdirSync(path); for (const item of readList) { const fullPath = resolve(path, item); const stats = statSync(fullPath); if (stats.isDirectory()) { localeDirName.push(item); localeGulpTaskName.push(`${item}_build_locale_task`); } } return { localeDirName, localeGulpTaskName, }; } catch (error) { console.log( '%c 🍇 error: ', 'font-size:20px;background-color: #7F2B82;color:#fff;', '找不到语言文件', error ); } } function localeBuild(srcPath, DestDirPath, outputName) { return () => { const inputFile = resolve(srcPath, 'index.js'); const isRelease = getDevModeAndParams('release', true); const esbuildPipe = () => { return gulpEsbuild({ incremental: !isRelease, outfile: `${outputName}.js`, bundle: true, charset: 'utf8', format: 'iife', minify: !isRelease, sourcemap: false, platform: 'browser', loader: { '.js': 'js', }, }); }; return src(inputFile).pipe(esbuildPipe()).pipe(dest(DestDirPath)); }; } export function langBuild() { const SrcDirPath = resolve( process.cwd(), 'node_modules', '@ones-ai', 'lang/locale' ); const DestDirPath = resolve(process.cwd(), 'dest/locale'); checkDirExist(SrcDirPath); const { localeDirName } = getLocaleDirName(SrcDirPath); const tasksFunction = (srcPath, destPath) => localeDirName.map((localeKey) => localeBuild(resolve(srcPath, localeKey), destPath, localeKey) ); const watchLocaleBuild = (cb) => { watch( [`${SrcDirPath}/**/*.js`], parallel(...tasksFunction(SrcDirPath, DestDirPath)) ); cb(); }; const isDevWatch = getDevModeAndParams('release', true) ? [] : [watchLocaleBuild]; const taskQueue = [...tasksFunction(SrcDirPath, DestDirPath), ...isDevWatch]; return parallel(...taskQueue); }
webpack
webpack在这个流程中,更多的是gulp 和webpack及页面的联动打通;
包括注入一些变量,打包产物结构调整等等~~
当然gulp 启动,webpack 启动都要手动介入也是不合理的;
所以在封装的CLI里面已经打通了!
工程
index.tpl的可能不是很清楚,我再辅助一个伪代码截图,就很清晰了
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> <script> // 这里是通过html-webpack-plugin 插件注入的变量 window.htmlInjectTplParams =<%= htmlInjectTplParams %> </script> <!-- 这里动态去获取相关的语言标识 --> <script src="locale-resource-loader/index.js" id="locale-source-loader" ></script> <script> // 通过document.write跟随文档流初始化标准的scripts(会同步阻塞!) document.write( '<script src="' + window.I18N_LOCALE_SOURCE_URL + '"><\/script>' ); </script> </head> <body> <!-- 这里再去初始化react 工程相关的请求及数据 --> </body> </html>
唯一标识根据你们业务设计取吧,
这边是cookie -> localStorage -> navigator.language ->defaultLang
function getCookie(name) { const cookie = `; ${document.cookie}`; const parts = cookie.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return; } const isValidLang = (lang) => { const validLang = ['zh','en','de','ja']; if (!lang) { return false; } for (let index = 0; index < validLang.length; index++) { const supportLang = validLang[index]; if (lang === supportLang) { return true; } } return false; }; function loadJS(FILE_URL, getContainer = 'body', async) { let scriptEle = document.createElement('script'); scriptEle.setAttribute('src', FILE_URL); scriptEle.setAttribute('type', 'text/javascript'); if (async !== undefined) { scriptEle.setAttribute('async', async); } const container = document.querySelector(getContainer); container.parentNode.insertBefore(scriptEle, container.nextElementSibling); const { htmlInjectTplParams: { isRelease }, } = window; // success event scriptEle.addEventListener('load', () => { if (!isRelease) { console.info(`${FILE_URL} 资源已加载`); } }); // error event scriptEle.addEventListener('error', () => { if (!isRelease) { console.error(`${FILE_URL} 资源加载失败`); } }); } // 获取当前locale语言标识 const getLocaleKey = () => { let lang = 'zh'; const getValidLang = [ getCookie('language'), localStorage.getItem('language'), navigator.language.slice(0, 2), ] .filter(Boolean) .filter((item) => isValidLang(item)); return getValidLang.length === 0 ? lang : getValidLang[0]; }; const getLocaleUrl = (lang, isAbsolute = false) => { const { htmlInjectTplParams: { isRelease, commit }, } = window; return `${isAbsolute ? '/' : ''}locale/${lang}.js?version=${ isRelease ? commit : new Date().getTime() }`; }; const localeUrl = getLocaleUrl(getLocaleKey()); window.I18N_LOCALE_SOURCE_URL = localeUrl; // 异步加载JS,有缓存后无法正确阻塞 // loadJS(localeUrl, '#locale-source-loader', false);
缓存策略
肯定有人会想到一个资源缓存到问题(静态资源可以通过query来做资源缓存加载[disk cache]),
没有缓存策略是不可行的,不然每次都去拉取全新的资源(也是一笔额外的网络开销);
就这个玩意
而固定的标识(不能跟随标品变也是不合理的),因为后续迭代有新增文案等等!!
这个问题其实好解决,因为我们现在大多数开发的代码工作流基本围绕Git搞的!
没错,就是git commit hash!!(这是一个可以保证跟随代码一起变的标识)
构建(开发模式)
- 开发模式下,query用的时间戳,只要重载就全新拉,问题不大
产物(生产模式)
- 这里用的是git commit hash
那么怎么跟随标品走呢?这里就用到html-webpack-plugin的动态注入变量来;
在构建的时候,把当前代码的git commit hash 注入到env,再写进入代码!
为什么要写进去?
写进去的好处不仅仅作为缓存策略的标识,
更重要的是你给客户定位也能快速通过这个hash 反向查这个工单的版本!!!
优缺点
优点
- 因为是reload,所以切换语言会很彻底
- 从接口到页面,链路重新走了一遍,很干净
- 因为语言资源是挂载在window上,可以通过一些手段派发给其他
- 微前端体系
- iframe
待改善
- 开发模式
- gulp watch后我没有让其自动reload
- 因为字段的变更不是高频操作!
- 业务自身的变更也会出发webpack热更新,部分场景也会自动reload页面
- 生产模式
- 资源包大小的问题,目前是全量字段打进去,体积还算可以接受
- 单个语种一万多个字段压缩后的体积大概在1m出头
- 等真到了一定程度(字段量),减少体积的手段
- 可以选择字节压缩编码那种方案,时间换空间,初始化过程再拼装
- 缓存策略依赖Git commit hash , 对于非git维护的场景需要具体设计一套跟随代码标品化的唯一标识
效果图
早期效果图