需求背景
最近要做一个将第三方聊天库打包为链接,以便于顾客端使用,类似于这个样子 <script src="打包后库的地址?deployId=部署ID" name="唯一标识符"></script>
, 以实现将代码嵌入到顾客端网站的</body>
标签之前即可完成部署,开发过程中遇到诸多难点
需求难点
打包工具选型
最容易想到的办法就是沿用公司之前的 webpack 框架,这里升级到了 webpack4(见之前的文章 Webpack 配置笔记),考虑到打包后可能会包含一些 webpack 的多余代码,因此不是最优方案。但迫于技术的不成熟以及开发时间的压力,还是把 webpack 作为此次选型。这里需要后期优化,考虑到的方案有 rollup.js、jQuery、VanillaJS(原生 js)
打包工具重构
这里需要将所有代码打包成一个类似于一个 js 文件,所以这里需要对框架做出调整,以下只列出改动的地方,其中标红的部分是最核心的改动
webpack.base.babel.js
// const CopyWebpackPlugin = require('copy-webpack-plugin'); 去掉 copy-webpack-plugin
{
test: /\.(jpe?g|png|gif|svg)$/,
use: [
{
loader: 'url-loader',
options: {
// Inline files smaller than 10 kB,尽可能将所有的图片内联到 js
limit: 100 * 1024,
},
},
],
},
new webpack.optimize.MinChunkSizePlugin({ minChunkSize: Infinity, // Minimum number of characters,尽可能的打包 js 为一个文件}),
// 屏蔽 CopyWebpackPlugin 没用
// new CopyWebpackPlugin([
// 'app/favicon.ico',
// 'app/manifest.json',
// ].map((src) => ({ from: src, to: path.resolve(process.cwd(), 'build') }))),
webpack.prod.babel.js
// const LodashModuleReplacementPlugin = require('lodash-webpack-plugin'); 实测对打包成一个 js 时有影响,暂未找到原因
cssDebug: true, // 打开 cssDebug,以避免使用 MiniCssExtractPlugin,会产生多余的 css
entry: './app/app.js', // 改变入口,不分包
output: {
filename: 'bundle.js', // 只输出一个 bundle.js 文件
},
splitChunks: false, // 关闭分包优化,下面同理
runtimeChunk: false,
// new LodashModuleReplacementPlugin(), // lodash 优化,暂时屏蔽,理由同上
// new webpack.HashedModuleIdsPlugin(), // 不需要固定 chunkhash,因为只有一个文件
以上就实现了将所有代码都打包到一个 bundle.js 文件
bundle.js 插入渲染结点
原来的方式是在 index.html 中写要渲染的 div 标签,由于引入到顾客端时是不可能在别人的 html 中写入 div 标签,所以解决方案如下
主要是对 app.js 入口文件做出改动,在渲染 app.js 时通过主动创建一个 div 结点加入到 body 标签的最后
const render = () => {
const root = document.createElement('div');
document.querySelector('body').appendChild(root);
ReactDOM.render(<App />, root);
};
render();
bundle.js 二次引入 babel-polyfill
在顾客端同样引用 babel-polyfill 的情况下,会造成 bundle.js 的报错,此时需要判断
if (!global._babelPolyfill) {
// 防止二次引入
require('babel-polyfill');
}
bundle.js 报错会引起顾客端整个崩溃
需要引入 ErrorBoundary 组件,即使报错也只有引入的 bundle.js 聊天窗口崩溃
import React from 'react';
import PropTypes from 'prop-types';
class ErrorBoundary extends React.Component {
static propTypes = {
children: PropTypes.node
};
constructor(props) {
super(props);
this.state = { error: null, errorInfo: null };
}
componentDidCatch(error, errorInfo) {
console.log(error, errorInfo);
// Display fallback UI
this.setState({ error, errorInfo });
// You can also log the error to an error reporting service
}
render() {
// if (this.state.hasError) {
// // You can render any custom fallback UI
// return <h1>Something went wrong.</h1>;
// }
return this.props.children;
}
}
export default ErrorBoundary;
然后在预测可能要报错的结点前包裹 ErrorBoundary
修改主题色
由于需要实现读取后端给出的样式变量,所以需要实现在 css 样式中读取到 js 变量,找了很多资料,发现最理想的还是 styled-components 可以在内联的 css 中读取到变量
但是由于 bundle.js 的大小已经达到了 1.6M 左右,再引入 styled-components 只怕会更大,所以暂时只能通过 style 样式在代码里写,当然,这个是不能实现比如说在 hover 状态下改变 css 样式,对于这个问题,暂未想到比较好的解决方案
nginx 相关设置
跨域设置 cookie
由于顾客端是未知域名,所以涉及到跨域设置 cookie 的问题,需要前后端同时设置才能做到跨域设置 cookie
背景知识
CORS 把 HTTP 请求分成两类,不同类别按不同的策略进行跨域资源共享协商。
-
简单跨域请求
当 HTTP 请求出现以下两种情况时,浏览器认为是简单跨域请求:
- 请求方法是 GET、HEAD 或者 POST,并且当请求方法是 POST 时,Content-Type 必须是 application/x-www-form-urlencoded, multipart/form-data 或着 text/plain 中的一个值。
- 请求中没有自定义 HTTP 头部。
对于简单跨域请求,浏览器要做的就是在 HTTP 请求中添加 Origin Header,将 JavaScript 脚本所在域填充进去,向其他域的服务器请求资源。服务器端收到一个简单跨域请求后,根据资源权限配置,在响应头中添加 Access-Control-Allow-Origin Header。浏览器收到响应后,查看 Access-Control-Allow-Origin Header,如果当前域已经得到授权,则将结果返回给 JavaScript,否则浏览器忽略此次响应。
-
带预检 (Preflighted) 的跨域请求
当 HTTP 请求出现以下两种情况时,浏览器认为是带预检 (Preflighted) 的跨域请求:
- 除 GET、HEAD 和 POST(only with application/x-www-form-urlencoded, multipart/form-data, text/plain Content-Type) 以外的其他 HTTP 方法。
- 请求中出现自定义 HTTP 头部。
带预检 (Preflighted) 的跨域请求需要浏览器在发送真实 HTTP 请求之前先发送一个 OPTIONS 的预检请求,检测服务器端是否支持真实请求进行跨域资源访问,真实请求的信息在 OPTIONS 请求中通过 Access-Control-Request-Method Header 和 Access-Control-Request-Headers Header 描述,此外与简单跨域请求一样,浏览器也会添加 Origin Header。服务器端接到预检请求后,根据资源权限配置,在响应头中放入 Access-Control-Allow-Origin Header、Access-Control-Allow-Methods 和 Access-Control-Allow-Headers Header,分别表示允许跨域资源请求的域、请求方法和请求头。此外,服务器端还可以加入 Access-Control-Max-Age Header,允许浏览器在指定时间内,无需再发送预检请求进行协商,直接用本次协商结果即可。浏览器根据 OPTIONS 请求返回的结果来决定是否继续发送真实的请求进行跨域资源访问,这个过程对真实请求的调用者来说是透明的。
XMLHttpRequest 支持通过 withCredentials 属性实现在跨域请求携带身份信息 (Credential,例如 Cookie 或者 HTTP 认证信息)。浏览器将携带 Cookie Header 的请求发送到服务器端后,如果服务器没有响应 Access-Control-Allow-Credentials Header,那么浏览器会忽略掉这次响应。
这里讨论的 HTTP 请求是指由 Ajax XMLHttpRequest 对象发起的,所有的 CORS HTTP 请求头都可由浏览器填充,无需在 XMLHttpRequest 对象中设置。以下是 CORS 协议规定的 HTTP 头,用来进行浏览器发起跨域资源请求时进行协商:
- Origin。HTTP 请求头,任何涉及 CORS 的请求都必需携带。
- Access-Control-Request-Method。HTTP 请求头,在带预检 (Preflighted) 的跨域请求中用来表示真实请求的方法。
- Access-Control-Request-Headers。HTTP 请求头,在带预检 (Preflighted) 的跨域请求中用来表示真实请求的自定义 Header 列表。
- Access-Control-Allow-Origin。HTTP 响应头,指定服务器端允许进行跨域资源访问的来源域。可以用通配符 * 表示允许任何域的 JavaScript 访问资源,但是在响应一个携带身份信息 (Credential) 的 HTTP 请求时,Access-Control-Allow-Origin 必需指定具体的域,不能用通配符。
- Access-Control-Allow-Methods。HTTP 响应头,指定服务器允许进行跨域资源访问的请求方法列表,一般用在响应预检请求上。
- Access-Control-Allow-Headers。HTTP 响应头,指定服务器允许进行跨域资源访问的请求头列表,一般用在响应预检请求上。
- Access-Control-Max-Age。HTTP 响应头,用在响应预检请求上,表示本次预检响应的有效时间。在此时间内,浏览器都可以根据此次协商结果决定是否有必要直接发送真实请求,而无需再次发送预检请求。
- Access-Control-Allow-Credentials。HTTP 响应头,凡是浏览器请求中携带了身份信息,而响应头中没有返回 Access-Control-Allow-Credentials:true 的,浏览器都会忽略此次响应。
后端
# 设置到某个文件下的别名,可以通过 /im 访问到调试端
location ^~ /im {
alias /home/frontend/pony/;
}
# 设置到某个文件下的别名,可以通过 /bundle.js 直接访问到 bundle.js
location = /bundle.js {
alias /home/frontend/pony/bundle.js;
}
# 反代设置跨域并允许设置 cookie
location /service-tp-api/ {
add_header Access-Control-Allow-Credentials true; # 是否允许发送 Cookie
add_header Access-Control-Allow-Origin $http_origin; # 这里 $http_origin 代表引用 bundle.js 的网站域名(真实域名),不能设置为 * ,否则不能成功设置 cookie
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; # 用到哪些 HTTP 方法
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,Cookie,Set-Cookie,x-requested-with,content-type,pragma'; # 允许使用的 header 字段,可以查看自己的 request 来进行添加
if ($request_method = 'OPTIONS') {
# 跨域预检
return 204;
}
# 以下是反代到哪里
proxy_pass http://localhost:10602/api/im-service/;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
}
前端
主要就是 fetch 方法里面添加 mode 和 credentials 字段,或者如果是 xhr 的话设置 withCredentials
export function createDebugUserInfo(data) {
return httpFetch('/service-tp-api/create-debug-user', {
method: 'POST',
data,
mode: 'cors',
credentials: 'include'
});
}
以上更新于2019-5-11 17:52:48
光标位置的读取写入
由于要实现中间插入标签或者换行符的效果,光标位置的读取与写入还是非常重要的,否则光标会“乱飘”
读取光标位置
function getCursorPosition(textDom) {
let cursorPos = 0;
if (document.selection) {
// IE Support
textDom.focus();
const selectRange = document.selection.createRange();
selectRange.moveStart('character', -textDom.value.length);
cursorPos = selectRange.text.length;
} else if (textDom.selectionStart || textDom.selectionStart === 0) {
// Firefox support
cursorPos = textDom.selectionStart;
}
return cursorPos;
}
写入光标位置
function setCursorPosition(elem, index) {
const val = elem.value;
const len = val.length;
// 超过文本长度直接返回
if (len < index) return;
// 注意这里的 setTimeout 延迟写入,否则不生效
setTimeout(() => {
elem.focus();
if (elem.setSelectionRange) {
// 标准浏览器
elem.setSelectionRange(index, index);
} else {
// IE9-
const range = elem.createTextRange();
range.moveStart('character', -len);
range.moveEnd('character', -len);
range.moveStart('character', index);
range.moveEnd('character', 0);
range.select();
}
}, 0);
}
以上更新于2019-5-22 10:01:50