SegmentFault 前端开发最新的文章
2023-03-08T15:44:04+08:00
https://segmentfault.com/feeds/blogs
https://creativecommons.org/licenses/by-nc-nd/4.0/
浅谈移动端单屏解决方案-横屏单屏
https://segmentfault.com/a/1190000043514920
2023-03-08T15:44:04+08:00
2023-03-08T15:44:04+08:00
深红
https://segmentfault.com/u/deepred5
2
<p>前文<a href="https://segmentfault.com/a/1190000042587022">《浅谈移动端单屏解决方案》</a>中,我介绍了移动端单屏场景下的一些常规解决方案。但是,这些方案主要针对的是竖屏的页面,对于一种特殊情况:<strong>横屏单屏页面</strong>,我们则需要进一步适配。</p><h2>强制横屏</h2><p>某些业务场景下,我们的页面需要用户横屏才能进行浏览和操作 (横屏的网页H5游戏、内嵌在横屏手游中的webview活动页...)</p><p>常规且成本最低的解决方案是:当用户处于竖屏状态下浏览我们的网页时,我们只需要在页面上提醒用户手动切换为横屏即可。<br><img src="/img/bVc6KnE" alt="" title=""></p><p>但是对于一些锁定屏幕方向的用户来说,还需要解除竖屏才能访问我们的页面,用户体验上来说的确大打折扣。</p><p>因此,理想状态下,我们更希望的交互是:当用户横屏状态时,网页正常访问;当用户竖屏状态时,展示内容强制横屏。</p><p>解决方法如下: 我们将最外层的容器元素(<code>#app</code>),在竖屏时强行旋转90度即可</p><pre><code class="html"><!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>landscape</title>
<style>
* {
margin: 0;
padding: 0;
}
html,
body {
width: 100%;
height: 100%;
}
#app {
width: 100%;
height: 100%;
background: orange;
position: absolute;
}
</style>
</head>
<body>
<div id='app'>
<p>landscape</p>
</div>
</body>
</html></code></pre><p><code>forceLandscape.js</code></p><pre><code class="js">const forceLandscape = (id = '#app') => {
const handler = () => {
let width = document.documentElement.clientWidth;
let height = document.documentElement.clientHeight;
let targetDom = document.querySelector(id);
if (!targetDom) return;
// 如果宽度比高度大,则认为处于横屏状态
// 也可以获取 window.orientation 方向来判断屏幕状态
if (width > height) {
targetDom.style.position = 'absolute';
targetDom.style.width = `${width}px`;
targetDom.style.height = `${height}px`;
targetDom.style.left = `${0}px`;
targetDom.style.top = `${0}px`;
targetDom.style.transform = 'none';
targetDom.style.transformOrigin = '50% 50%';
} else {
targetDom.style.position = 'absolute';
targetDom.style.width = `${height}px`;
targetDom.style.height = `${width}px`;
targetDom.style.left = `${0 - (height - width) / 2}px`;
targetDom.style.top = `${(height - width) / 2}px`;
targetDom.style.transform = 'rotate(90deg)';
targetDom.style.transformOrigin = '50% 50%';
}
};
const handleResize = () => {
setTimeout(() => {
handler();
}, 300);
};
window.addEventListener('resize', handleResize);
handler();
};
</code></pre><p>点击页面 <a href="https://link.segmentfault.com/?enc=oqAM%2BMiU7i6uSHGV%2FwDsCg%3D%3D.wM0r0HIGMDU9%2BVKIhDsI0YIU5YieaLdHyQS2pq0QzJk%3D" rel="nofollow">强制竖屏</a> 查看效果</p><h2>顶层组件</h2><p>上面的方案,我们只是将最外层的容器元素(<code>#app</code>)进行了90度的旋转,如果一些顶层组件,例如<code>Modal</code>弹窗,它们一般是渲染在<code>#app</code>的外层(防止层级覆盖),强制横屏时,这些UI组件仍然是竖屏样式。</p><p>Ant Design Mobile的<a href="https://link.segmentfault.com/?enc=lrUqDlrsCLsNkImhXFDBvQ%3D%3D.GCrX0V%2FoY3POjbzrEMCbFbwkOAbfjnOrVpXdfpSNK7L05qjov4vr1mFijWt3E3Yu" rel="nofollow">Modal组件</a>就提供了一个<code>getContainer</code>的属性,可以自定义<code>Modal</code>的父级,这时候我们就可以指定<code>#app</code>为其父元素。</p><h2>横屏适配</h2><p>前文总结的<a href="https://link.segmentfault.com/?enc=0wvclq4kaUe36UyAxdZ7iA%3D%3D.wglqXDX9lP7FOfUbQGV8vg3YYWi5PbzJcRY91k%2BAXZuaHcxg3gxJHUkI7ZbgGw8gfRsZDpN53USgzUYNlCIFuo2vfgLMNhMOI4B8l5raHiWb6CBCMFQTFjUQOvs%2FeWYtg23bdBi1T5LFqfwtEneJ61M4kdLTfvq6iAkWXGqyp2RIDKffvbizhYZhAZrS9AfTUaG0fi1C4j0rSe2dnGy%2FjQ%3D%3D" rel="nofollow">几种方案</a>,只要稍作改变,其实都能适配横屏</p><ol><li>在横屏单屏页面中,我们尽量使用<code>vh</code>进行布局:但是在<code>safari</code>中,<code>100vh</code>还包括了地址栏和工具栏的高度,导致实际的<code>100vh</code>比页面的可视区域要大,解决方法可参考<a href="https://link.segmentfault.com/?enc=b5V%2B2bjYVYdaT0C7acCXIw%3D%3D.40fZDJUN5NV3dHx0VX7ndV4QUtIXHornFkepNaUithd0cM%2Fa5WFvdotACLPUC2tv" rel="nofollow">《关于 Safari 100vh 的问题与解决方案》</a></li><li>rem动态改变:依旧使用前文的<a href="https://link.segmentfault.com/?enc=zbIrlRkUjHnNkWlaJNfSxg%3D%3D.XMTgyrkISydUKzPXqUzxZzRdPyKAphwazBLhLDd9w91sDL82z%2BiPcKzdRXAuVjNZxYIOF0aTjlgOmAS%2F2BG6R%2FbSminTR1kiaAj%2BBWsvcHScTROZFU%2FgkpduwlAL9LfPKI%2B2RDBuWcky%2FLCEiK1M0jmjtJG74nOjaa9AFLJvzOzZM0PeZxuLxu1BYLC%2Bkf5OWw8lz57WYHcD7xxOCWNpCK4nN6gM31ayasLFY%2BnbuXI%3D" rel="nofollow">动态修改rem</a>的方案,只不过需要注意修改一处地方</li></ol><pre><code class="js">function onResize() {
let clientWidth = document.documentElement.clientWidth;
let clientHeight = document.documentElement.clientHeight;
// 该页面需要强制横屏,强制横屏时,交换两者的值
if (clientWidth < clientHeight) {
[clientWidth, clientHeight] = [clientHeight, clientWidth];
}
// 省略其他代码
}
</code></pre><p>优化版的<code>flexible.js</code>,支持横屏模式和竖屏模式</p><pre><code class="js">const defaultConfig = {
pageWidth: 750,
pageHeight: 1334,
pageFontSize: 32,
mode: 'portrait', // 默认竖屏模式
};
const flexible = (config = defaultConfig) => {
const {
pageWidth = defaultConfig.pageWidth,
pageHeight = defaultConfig.pageHeight,
pageFontSize = defaultConfig.pageFontSize,
mode = defaultConfig.mode,
} = config;
const pageAspectRatio = defaultConfig.pageAspectRatio || (pageWidth / pageHeight);
// 根据屏幕大小及dpi调整缩放和大小
function onResize() {
let clientWidth = document.documentElement.clientWidth;
let clientHeight = document.documentElement.clientHeight;
// 该页面需要强制横屏
if (mode === 'landscape') {
if (clientWidth < clientHeight) {
[clientWidth, clientHeight] = [clientHeight, clientWidth];
}
}
let aspectRatio = clientWidth / clientHeight;
// 根元素字体
let e = 16;
if (clientWidth > pageWidth) {
// 认为是ipad/pc
console.log('认为是ipad/pc');
e = pageFontSize * (clientHeight / pageHeight);
} else if (aspectRatio > pageAspectRatio) {
// 宽屏移动端
console.log('宽屏移动端');
e = pageFontSize * (clientHeight / pageHeight);
} else {
// 正常移动端
console.log('正常移动端');
e = pageFontSize * (clientWidth / pageWidth);
}
e = parseFloat(e.toFixed(3));
document.documentElement.style.fontSize = `${e}px`;
let realitySize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
if (e !== realitySize) {
e = e * e / realitySize;
document.documentElement.style.fontSize = `${e}px`;
}
}
const handleResize = () => {
onResize();
};
window.addEventListener('resize', handleResize);
onResize();
return (defaultSize) => {
window.removeEventListener('resize', handleResize);
if (defaultSize) {
if (typeof defaultSize === 'string') {
document.documentElement.style.fontSize = defaultSize;
} else if (typeof defaultSize === 'number') {
document.documentElement.style.fontSize = `${defaultSize}px`;
}
}
};
};
</code></pre><p>使用时:</p><pre><code class="javascript">// 调用此方法前,需要先调用forceLandscape强制横屏
forceLandscape();
flexible({
pageWidth: 1334,
pageHeight: 750,
pageFontSize: 100,
mode: 'landscape', // 横屏模式
});</code></pre><pre><code class="css">.safe-content {
width: 13.34rem;
height: 7.5rem;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
}</code></pre><p>这时,只需要把<code>safe-content</code>的<code>witdh</code>和<code>height</code>写死,就能保证宽高比永远保持为<code>1334 * 750</code></p><p>完整页面,点击此处<a href="https://link.segmentfault.com/?enc=rOK1CFZ6SJFZOnPb3kzcfw%3D%3D.kjU0X6%2BUUJqqqOQI6Txx%2FOX5fx953vRajgS%2BnQiEJLaDhfr8w%2BWn4jkivDL50fUu" rel="nofollow">动态修改rem-预览</a> (支持pc和移动端)</p><p><img src="/img/remote/1460000043514923" alt="动态rem gif图" title="动态rem gif图"></p><h2>横屏交互</h2><p>在竖屏状态时,虽然我们的页面被强制旋转90度,实现了强制横屏,但此时页面的交互手势,仍是竖屏状态:最明显的就是轮播图<code>swiper</code>组件</p><p>手机竖屏打开此页面<a href="https://link.segmentfault.com/?enc=0zcNGiT5m5fXvDK15XHknw%3D%3D.OULWuP%2FkWV5PRt%2Ff58H%2BZf7OoZ0KSefccNegfTIjC9j08GR0ct9m7kiQkujFo5lH" rel="nofollow">swiper-预览</a>可以发现,当页面处于强制横屏(<strong>手机仍然是竖屏</strong>)时,我们需要<strong>左右</strong>滑动,才能<strong>上下</strong>切换轮播图</p><p>解决方案是:我们关闭<code>swiper</code>默认的监听手势,由我们自己来实现监听滑动。当处于正常横屏时,我们监听左右滑动;当处于强制横屏(<strong>手机仍然是竖屏</strong>)时,我们监听上下滑动。</p><p>关闭默认的手势监听</p><pre><code class="js">const swiper = new Swiper('.swiper', {
allowTouchMove: false, // 关闭自动监听触摸
});</code></pre><p>自定义监听手势方向</p><pre><code class="js">let startX = 0;
let startY = 0;
let isLandscape = false;
const handler = () => {
let width = document.documentElement.clientWidth;
let height = document.documentElement.clientHeight;
if (width > height) {
isLandscape = true;
} else {
isLandscape = false;
}
};
handler();
window.addEventListener('resize', handler);
const handleTouchstart = (e) => {
startX = e.touches[0].pageX;
startY = e.touches[0].pageY;
};
const handleTouchend = (e) => {
let endX;
let endY;
endX = e.changedTouches[0].pageX;
endY = e.changedTouches[0].pageY;
const dX = endX - startX;
const dY = endY - startY;
if (Math.abs(dX) > Math.abs(dY) && dX > 0) {
console.log('左往右滑');
// 横屏的情况下,监听左右滑动
if (isLandscape) {
swiper.slideNext();
}
} else if (Math.abs(dX) > Math.abs(dY) && dX < 0) {
console.log('右往左滑');
// 横屏的情况下,监听左右滑动
if (isLandscape) {
swiper.slidePrev();
}
} else if (Math.abs(dY) > Math.abs(dX) && dY > 0) {
console.log('上往下滑');
// 强制横屏(本身是竖屏)的情况下,监听上下滑动
if (!isLandscape) {
swiper.slideNext();
}
} else if (Math.abs(dY) > Math.abs(dX) && dY < 0) {
console.log('下往上滑');
// 强制横屏(本身是竖屏)的情况下,监听上下滑动
if (!isLandscape) {
swiper.slidePrev();
}
} else {
console.log('滑了个寂寞');
}
};
const dom = document.querySelector('.safe-content');
dom.addEventListener('touchstart', handleTouchstart);
dom.addEventListener('touchend', handleTouchend);</code></pre><p>手机竖屏打开此页面<a href="https://link.segmentfault.com/?enc=in0O8Z5jFMfmpNl%2BZZmgRw%3D%3D.qtLq4kXir0BwwuErljrBKoNyVU3eZIHJz4%2FvZ%2FqoZ5YPcNT3OtO2qdwAa8e%2FvVnu" rel="nofollow">swiper自定义手势-预览</a>,可以发现这次的交互手势是正常的。</p><h2>单屏工具方法</h2><p>根据前面总结的一系列单屏布局技巧(竖屏、横屏),我封装了两个常用的工具方法,已发布到<code>npm</code>上</p><p><a href="https://link.segmentfault.com/?enc=QGSJiKJZpv8iUhNCLeQ6Bg%3D%3D.jSE533UHgKI0C8QxuO7e4lp8wSZ38MfoYlXsSrheM%2BbtjVIKEM3AiWAtjmOMcSwWqp4riyjMp%2B%2BdR%2F2jHcFx1Q%3D%3D" rel="nofollow">single-screen-utils</a></p><p><code>dynamicRem</code>: 动态设置rem</p><p><code>forceLandscape</code>: 强制横屏方案</p><p>同时这两个方法也支持<code>React</code>的<code>hooks</code>写法: <code>useDynamicRem</code>和<code>useForceLandscape</code></p><pre><code class="js">import { dynamicRem } from 'single-screen-utils';
import { forceLandscape } from 'single-screen-utils';
forceLandscape({
id: '#app',
});
dynamicRem({
pageWidth: 750,
pageHeight: 1334,
});</code></pre><p>更详细的使用方法,请看项目的<a href="https://link.segmentfault.com/?enc=QybjtyRPvV7O%2FRtE6s6PtA%3D%3D.kaD93tcx4eO2yK6Jx7GotXLI6BAuJ6BEKW4WKQ8P%2FSs4qDkgVlbGmdy4LfkilU%2Bu7xD4R%2Fc9Q1HBp%2FmTrXrwEg%3D%3D" rel="nofollow">README</a></p><h2>参考</h2><ul><li><a href="https://link.segmentfault.com/?enc=4MgQ%2B4hU7MDTUovf2fPC4w%3D%3D.idm9mVqgE5IHYxEhsKAr%2BNx1fsBSxGAuKJrqQnc7X3D8OZE9mDHh2%2BhJgTBbRE8W" rel="nofollow">前端 H5 横屏 独特处理方案详解</a></li><li><a href="https://link.segmentfault.com/?enc=B47BmwwXFfR23udD9kDfVQ%3D%3D.OLn1fHmIWhzDUiP6CJzvbvihGgkysoYvc%2FeRebhcYNS7qiDWnzK6vAjehcPm4bMR" rel="nofollow">横屏适配需求</a></li><li><a href="https://link.segmentfault.com/?enc=7aKqtccXvQSh1%2FfXyWKVoQ%3D%3D.Q6v5IwZVC5RlmtTbDFUDyXkm%2FpKhimCuu8nxvf7OpAupCs1e3gbxmuf4sRzH%2Bf6%2Fr1pBeiHlkut2yDMXsFL5Zw%3D%3D" rel="nofollow">H5游戏开发:横屏适配</a></li></ul>
浅谈移动端单屏解决方案
https://segmentfault.com/a/1190000042587022
2022-10-10T09:00:00+08:00
2022-10-10T09:00:00+08:00
深红
https://segmentfault.com/u/deepred5
5
<p>前文<a href="https://link.segmentfault.com/?enc=AbftBN%2F5bf6IB0SEsXP0BA%3D%3D.2X%2BT8I9CarY9hOjmmpVto5910e1LUsREQf1VlA3P3NhaRRO64ojdwmNf8I4GYCbMF6O1ng5FOdPtx1qXdiPyyvtMvGfIwgLmRGTCKcr93c7DdHFi4bFwg4%2BeJQ77VAEHHAidfQSPNP4NPEOhG%2BXoqQ%3D%3D" rel="nofollow">《移动端常见适配方案》</a>中,我介绍了移动端的一些常见适配方案</p><blockquote>移动端适配就是在进行<strong>屏幕宽度</strong>的等比例缩放</blockquote><p>文中我强调了移动端适配是对<strong>屏幕宽度</strong>进行缩放:对于普通的流式布局(长屏幕页面),页面内容是可以上下滚动的。屏幕小,一屏幕看到的东西虽然变少,但是用户可以通过手势滚动页面,继续浏览下一屏的内容。因此在常规情况下,对于屏幕宽度进行等比例缩放已经能解决大部分应用场景了</p><p>但是对于一种特殊的场景:单屏页面(又称翻屏页面),由于需要把<strong>一整屏</strong>的内容<strong>完整</strong>展示给用户,同时又要求页面不能出现滚动条,那么,仅仅只是针对屏幕宽度进行等比例缩放的适配,其实效果并不理想</p><h2>设备独立像素 (CSS像素)</h2><blockquote>设备独立像素是一种可以被程序所控制的虚拟像素,在Web开发中对应CSS像素</blockquote><p>以<code>iphone7</code>举例:</p><p><code>iphone7</code>的<code>设备独立像素</code>为 <code>375 * 667</code></p><p>也就是手机全屏下的大小,同时也是<code>chrome</code>模拟器展示的尺寸</p><p>可以通过js的<code>screen.width</code>和<code>screen.height</code>获取</p><h2>设备像素 (物理像素)</h2><blockquote>设备像素也可以叫物理像素,由设备的屏幕决定,其实就是屏幕中控制显示的最小单位</blockquote><p>以<code>iphone7</code>举例:</p><p><code>iphone7</code>的<code>设备像素</code>为 <code>750 * 1334</code></p><p><code>750 * 1334</code>这个尺寸也可以称为<code>设计像素</code>,我们设计和开发页面时,就是以这个设计像素为准</p><h2>设备像素比(DPR)</h2><blockquote>设备像素比(dpr) = 设备像素 / 设备独立像素</blockquote><p>以<code>iphone7</code>举例:</p><p><code>iphone7</code>的<code>dpr = 2 = 750 / 375</code></p><p>也就是说,在<code>iphone7</code>下,<code>1 css像素 = 2 物理像素</code></p><p>在<code>css</code>中一个<code>1x1</code>大小的正方形里面,其实有4个物理像素</p><p><img src="https://image-static.segmentfault.com/334/700/3347007015-5c1a055769479_fix732" alt="pic" title="pic"></p><p><code>dpr</code>大于2的屏幕也称为视网膜屏幕(Retina)</p><h2>实际物理像素</h2><p><code>iphone7</code>的实际物理像素是<code>750 * 1334</code>,刚好等于<code>设备像素</code>。但不是所有的设备都是<code>实际物理像素</code>等于<code>设备像素</code></p><p><code>iphone7 plus</code>的实际物理像素是<code>1080 * 1920</code>。它的<code>dpr</code>为3,<code>设备独立像素</code>为<code>414 * 736</code>,根据公式可以得出,它的<code>设备像素</code>等于<code>1242 x 2208</code>,远大于实际物理像素。手机会自动把<code>1242 * 2208</code>个像素点塞进<code>1080 * 1920</code>个物理像素点来渲染,我们不用关心这个过程</p><h2>单屏幕</h2><p>前面介绍了这么多概念,其实在真正开发中,我们主要关心的是<code>设备独立像素</code>和<code>设备像素</code></p><p><code>设备像素</code> 决定了设计稿的尺寸。移动端设计稿一般是<code>750 * 1334</code> 尺寸大小( iPhone6 的设备像素为标准的设计图),因此相对比较固定</p><p><code>设备独立像素</code> 决定了设备的屏幕大小。<code>iOS</code>平台下,屏幕尺寸还算相对固定,但是到了<code>Android</code>平台下,屏幕尺寸那就<del>千奇百怪,百花争鸣</del>了。</p><p>特别需要注意的一点:即使<code>设备独立像素</code>确定了大小,我们的网页被用户看到的时候,实际高度还是比<code>设备独立像素</code>的高度小很多</p><p>主要原因是:我们的网页往往是在手机的浏览器上访问的,而这些浏览器自带了顶部地址栏和底部工具栏,这两部分的高度又进一步压缩了我们网页展示的高度(如果我们的网页是在第三方客户端内打开的,比如微博,微信,Twitter, Facebook,那么一般只有顶部地址栏)</p><p>举个例子,<code>iphone11</code>的设备独立像素是<code>414 * 896</code></p><p><img src="/img/bVc2QYq" alt="" title=""><img src="/img/bVc2QYt" alt="" title=""></p><p>第一张图是在<code>safari</code>浏览器下:可以看到上下红框部分是浏览器自带的区域,只有蓝框是实际网页展示的高度,这个蓝框的大小是 <code>414 * 715</code>(documentElement.clientWidth/documentElement.clientHeight),已经比设备独立像素的高度少了<code>181</code>像素(896 - 715)</p><p>第二张图是在<code>微信</code>自带浏览器下:可以看到顶部红框部分是浏览器自带的区域,只有蓝框是实际网页展示的高度,这个蓝框的大小是 <code>414 * 804</code>(documentElement.clientWidth/documentElement.clientHeight),也比设备独立像素的高度少了<code>92</code>像素(896 - 804)</p><p>收集到的一些常见设备尺寸大小:</p><table><thead><tr><th>品牌</th><th>操作系统</th><th>设备</th><th>设备独立像素 (screen.width/screen.height)</th><th>自带浏览器下(clientWidth/clientHeight)</th></tr></thead><tbody><tr><td>苹果</td><td>iOS</td><td>iPhone 7</td><td>375 * 667</td><td>375 * 548</td></tr><tr><td> </td><td> </td><td>iPhone 12</td><td>390 * 844</td><td>390 * 664</td></tr><tr><td> </td><td> </td><td>Ipnone 11/XR</td><td>414 * 896</td><td>414 * 715</td></tr><tr><td> </td><td> </td><td>iPhone X</td><td>375 * 812</td><td>375 * 635</td></tr><tr><td> </td><td> </td><td> </td><td> </td><td> </td></tr><tr><td>华为</td><td>安卓</td><td>P40</td><td>360 * 780</td><td>360 * 625</td></tr><tr><td> </td><td> </td><td>nova 8 SE</td><td>360 * 800</td><td>360 * 659</td></tr><tr><td> </td><td> </td><td>Mate 30</td><td>424 * 918</td><td>424 * 774</td></tr><tr><td> </td><td> </td><td>荣耀8</td><td>360 * 640</td><td>360 * 501</td></tr><tr><td> </td><td> </td><td>P10</td><td>360 * 640</td><td>360 * 526</td></tr><tr><td> </td><td> </td><td>畅玩7x</td><td>360 * 720</td><td>360 * 584</td></tr><tr><td>Oppo</td><td>安卓</td><td>R15x</td><td>360 * 780</td><td>360 * 650</td></tr><tr><td> </td><td> </td><td>R17</td><td>360 * 780</td><td>360 * 628</td></tr><tr><td> </td><td> </td><td>K1</td><td>360 * 780</td><td>360 * 622</td></tr><tr><td> </td><td> </td><td> </td><td> </td><td> </td></tr><tr><td>Xiaomi</td><td>安卓</td><td>MIX 2</td><td>393 * 786</td><td>393 * 666</td></tr><tr><td> </td><td> </td><td>小米10</td><td>393 * 851</td><td>393 * 720</td></tr><tr><td> </td><td> </td><td>小米6</td><td>360 * 640</td><td>360 * 521</td></tr><tr><td> </td><td> </td><td>k40</td><td>393 * 873</td><td>393 * 713</td></tr><tr><td> </td><td> </td><td> </td><td> </td><td> </td></tr></tbody></table><h2>单屏难点</h2><p>设计稿的的宽高比是固定的,但是真实设备的宽高比永远不是统一的,并且网页的可视区域还会随着访问方式(浏览器,APP客户端)有所改变。</p><p>同一份设计稿,却要在不同尺寸的设备上,都展示出良好的布局:页面的内容要尽可能<strong>完整</strong>展示在<strong>一屏</strong>之中(甚至不能有滚动条)</p><h2>安全区 + 长背景图</h2><p><img src="/img/bVc2QYx" alt="" title=""></p><p><code>750 * 1334</code> 的设计稿,需要考虑到长屏幕时,页面的展示情况</p><p>比如默认<code>750 * 1334</code>大小的内容需要完整展示出来(安全区),长屏幕<code>750 * 1750</code>时,把安全区的内容垂直居中展示即可</p><p><img src="/img/bVc2QYz" alt="" title=""></p><p>此时,我们就需要使用一张长背景图(<code>750 * 1750</code>)上下居中作为整个网页的背景</p><pre><code class="css"><style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
/* 750px 的设计图,1rem = 100px */
font-size: calc(100 * 100vw / 750);
}
html,
body {
width: 100%;
height: 100%;
position: relative;
}
#app {
width: 100%;
height: 100%;
position: relative;
/* 长背景图上下居中 */
background: url('./img/bg.jpg') no-repeat center / cover #fff;
overflow: hidden;
}
.safe-content {
width: 100%;
height: 100%;
position: absolute;
left: 0;
/* 限定安全区的高度 */
max-height: 13.34rem;
top: 50%;
/* 安全区上下居中 */
transform: translateY(-50%);
}
</style></code></pre><pre><code class="html"><body>
<div id='app'>
<div class='safe-content'>
<div class='block1'></div>
<div class='block2'></div>
</div>
</div>
</body></code></pre><p>之后,我们把页面所有的内容,相对<code>safe-content</code>进行布局</p><p>完整页面,点击此处<a href="https://link.segmentfault.com/?enc=2HnA%2FkOcdjhCaACKLOoDaA%3D%3D.MEW5ZzbA1LeaVaEtQu2CLcCiyjetBBOPHOt7ZzVijP2GHTbm1OuluUdR5qLyETmb" rel="nofollow">预览</a> (手机模式查看)</p><p>完整代码,直接右键源代码</p><pre><code class="html"><!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>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
/* 750px 的设计图,1rem = 100px */
font-size: calc(100 * 100vw / 750);
}
html,
body {
width: 100%;
height: 100%;
position: relative;
}
#app {
width: 100%;
height: 100%;
position: relative;
background: url('./img/bg.jpg') no-repeat center / cover #fff;
overflow: hidden;
}
.safe-content {
width: 100%;
height: 100%;
position: absolute;
left: 0;
max-height: 13.34rem;
top: 50%;
transform: translateY(-50%);
/* border: 1px solid red; */
}
.go-back {
width: 0.91rem;
height: 0.92rem;
background: url('./img/back.png') no-repeat center / contain;
position: absolute;
left: 0.14rem;
top: 0.15rem;
}
.rule-btn {
width: 0.83rem;
height: 0.83rem;
background: url('./img/rule-btn.png') no-repeat center / contain;
position: absolute;
left: 1.27rem;
top: 0.20rem;
}
.username {
min-width: 1.67rem;
height: 0.41rem;
line-height: 0.41rem;
background: url('./img/user-bg.png') no-repeat center / cover;
position: absolute;
right: 0;
top: 0.25rem;
color: #fcfcfc;
font-size: 0.22rem;
text-align: right;
padding-right: 0.13rem;
padding-left: 0.48rem;
}
.gift-container {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
}
.gift-logo {
width: 6.79rem;
height: 7.03rem;
background: url('./img/game-title.png') no-repeat center / contain;
}
.gift-title {
width: 3.45rem;
height: 0.63rem;
background: url('./img/congratulate.png') no-repeat center / contain;
}
.gift-content {
margin-top: -1.8rem;
display: flex;
flex-direction: column;
align-items: center;
}
.gift-name {
font-size: 0.3216rem;
line-height: 0.24rem;
letter-spacing: 0.032rem;
color: #bd5874;
text-align: center;
margin-top: 0.2rem;
text-shadow: 0 0 5px #fff, 0 0 5px #fff;
}
.gift-icon {
margin: 0.4rem 0 0.5rem;
width: 1.94rem;
height: 1.65rem;
background: url('./img/gift-1.png') no-repeat center / contain;
}
.gift-desc {
font-size: 0.2rem;
line-height: 0.2426rem;
letter-spacing: 0.019rem;
color: #90949e;
text-align: center;
margin-bottom: 0.2rem;
text-shadow: 0 0 5px #fff, 0 0 5px #fff;
}
.gift-get-info {
font-size: 0.23rem;
line-height: 0.30rem;
color: #d16b88;
text-align: center;
text-shadow: 0 0 5px #fff, 0 0 5px #fff;
}
</style>
</head>
<body>
<div id='app'>
<div class="safe-content">
<div class="go-back"></div>
<div class="rule-btn"></div>
<div class="username">深红</div>
<div class="gift-container">
<div class="gift-logo"></div>
<div class="gift-content">
<div class="gift-title"></div>
<p class="gift-name">水漾烛光礼盒</p>
<div class="gift-icon"></div>
<div class="gift-desc">
<p>蒂普提克香氛蜡烛(70g)*1</p>
<p>krramel沐浴套装*1</p>
</div>
<div class="gift-get-info">
<p>请到游戏内【精彩活动-实物周边奖励兑换】</p>
<p>填写领取信息</p>
</div>
</div>
</div>
</div>
</div>
</body>
</html></code></pre><h2>宽高比</h2><p>上面的方案,对于移动端(<strong>屏幕高度大于屏幕宽度</strong>)的大部分场景,的确够用了。</p><p>但是在折叠屏手机(<strong>屏幕宽度和高度差别不大</strong>),ipad,pc端(<strong>屏幕高度小于屏幕宽度</strong>)的设备下,我们的页面就很有可能超出了完整的一屏。</p><p><img src="/img/bVc2QYL" alt="" title=""></p><p>如果此时,父级元素还设置了<code>overflow: hidden;</code>,那么用户甚至不能滑动查看超出屏幕的内容,如果底部是一个可交互的按钮,那么用户就永远不能触发之后的流程了!</p><h2>问题原因</h2><p>我们的<code>rem</code>适配方案,是相对于屏幕宽度进行缩放的,但是不同机型的手机,可视区域的宽高比并不固定,因此对于部分手机,页面内容就很有可能出现<strong>超出屏幕底部</strong>或者<strong>底部留有空白</strong>。</p><p>对于<strong>底部留有空白</strong>,一般发生在可视高度比可视宽度大很多的情况,前面介绍的<code>安全区 + 长背景图</code>方案,就是针对此种情况的解决方案。</p><p>而对于<strong>超出屏幕底部</strong>,一般发生在可视高度和可视宽度相差不大(折叠屏手机),甚至可视高度比可视宽度小(横屏或者pc端)的情况,解决方案一般如下:</p><ul><li>使用<code>css</code>进行宽高比判断</li><li>使用<code>js</code>进行宽高比判断</li><li>使用<code>js</code>动态修改<code>rem</code>大小</li><li>使用<code>js</code>动态缩放整体页面</li><li>使用<code>vw</code>和<code>vh</code>进行布局</li></ul><h2>aspect-ratio</h2><p>注意和<code>device-aspect-ratio</code>进行区分,<code>device-aspect-ratio</code>是和设备尺寸进行绑定的,但是我们之前介绍过:网页的可视区域会随着访问方式(浏览器,APP客户端)有所改变,因此<code>aspect-ratio</code>才是我们真正需要的属性。</p><blockquote>aspect-ratio 定义输出设备中的页面可见区域宽度与高度的比率</blockquote><p>同时它有两个<code>max-aspect-ratio</code>和<code>min-aspect-ratio</code>兄弟属性,可以和<code>max-width</code>和<code>min-width</code>进行类比:</p><pre><code class="css">@media screen and (min-aspect-ratio: 9/16) {
// 只要宽高比大于等于9/16,就会执行
}
@media screen and (min-aspect-ratio: 3/4) {
// 只要宽高比大于等于3/4,就会执行
}
@media screen and (min-aspect-ratio: 1/1) {
// 只要宽高比大于等于1/1,就会执行
}</code></pre><p>对于上面的页面,我们正常的设备独立像素是<code>375 * 667</code>,我们可以这样进行高度划分:</p><ul><li>高度大于667:无需调整,我们只怕高度小,不怕高度大,高度大时已经有前面的方案: <code>安全区 + 长背景图</code></li><li>530-667:还是正常,我们也不需要调整</li><li>490-530</li><li>375-490</li><li>小于375:pc端或者横屏,高度已经比宽度小了</li></ul><pre><code class="css">@media screen and (min-aspect-ratio: 375 / 530) {
.safe-content {
transform: translateY(-50%) scale(0.8);
}
}
@media screen and (min-aspect-ratio: 375 / 490) {
.gift-logo {
transform: scale(0.6);
}
}
@media screen and (min-aspect-ratio: 375 / 375) {
.safe-content {
transform: translateY(-50%) scale(0.32);
}
.gift-logo {
transform: scale(0.4);
}
}</code></pre><p>比较简单暴力的,就是直接对主要页面区域施加 <code>transform: scale(0.8)</code> 这类样式,直接缩小。</p><p>至于我刚刚划分的高度区间,是通过<code>chrome模拟器</code>自己一次次实验调整出来的,这个要<strong>具体页面具体分析</strong>,并没有一个统一的宽高比规定。</p><p>完整页面,点击此处<a href="https://link.segmentfault.com/?enc=Ad2q0p6zbZNw6ktVy9tBeQ%3D%3D.UlZSTSdcSV%2B4hd%2FRxrsEfagEdGzuCCrbaqfyCKY4m6ZK4gfbF2qU08UHt5oOJhvMPr2D0gfu6hqvm9GYihKbXg%3D%3D" rel="nofollow">aspect-ratio-预览</a> (手机模式查看)</p><p>完整代码,直接右键源代码</p><h2>JS添加全局类</h2><pre><code class="javascript">function detectAspectRatio(aspectRatio) {
document.documentElement.classList.remove('pc', 'mobile1', 'mobile2');
if (aspectRatio >= 375 / 375) {
return document.documentElement.classList.add('pc');
}
if (aspectRatio >= 375 / 490) {
return document.documentElement.classList.add('mobile1');
}
if (aspectRatio >= 375 / 530) {
return document.documentElement.classList.add('mobile2');
}
}
function init() {
const clientWidth = document.documentElement.clientWidth;
const clientHeight = document.documentElement.clientHeight;
const aspectRatio = clientWidth / clientHeight;
detectAspectRatio(aspectRatio);
}
init();
window.addEventListener('resize', init);</code></pre><p>本质上就是把css的媒体查询<code>aspect-ratio</code>用js实现了一遍,所以这种方案区别不大。</p><h2>动态修改rem</h2><p>默认情况下,我们的<code>rem</code>是根据可视区域宽度进行计算的,但是在高度较小的情况下,我们可以动态的修改<code>rem</code>的参考对象,让它根据可视高度进行计算</p><p>我们甚至可以实现:无论窗口怎么变,我们的内容都保持原来的比例,并尽量占满窗口</p><p>封装成一个通用<code>flexible.js</code>方法</p><pre><code class="javascript">const defaultConfig = {
pageWidth: 750,
pageHeight: 1334,
pageFontSize: 32,
};
const flexible = (config = defaultConfig) => {
const {
pageWidth = defaultConfig.pageWidth,
pageHeight = defaultConfig.pageHeight,
pageFontSize = defaultConfig.pageFontSize,
} = config;
const pageAspectRatio = defaultConfig.pageAspectRatio || (pageWidth / pageHeight);
// 根据屏幕大小及dpi调整缩放和大小
function onResize() {
let clientWidth = document.documentElement.clientWidth;
let clientHeight = document.documentElement.clientHeight;
let aspectRatio = clientWidth / clientHeight;
// 根元素字体
let e = 16;
if (clientWidth > pageWidth) {
// 认为是ipad/pc
console.log('认为是ipad/pc');
e = pageFontSize * (clientHeight / pageHeight);
} else if (aspectRatio > pageAspectRatio) {
// 宽屏移动端
console.log('宽屏移动端');
e = pageFontSize * (clientHeight / pageHeight);
} else {
// 正常移动端
console.log('正常移动端');
e = pageFontSize * (clientWidth / pageWidth);
}
document.documentElement.style.fontSize = `${e}px`;
let realitySize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
if (e !== realitySize) {
e = e * e / realitySize;
document.documentElement.style.fontSize = `${e}px`;
}
}
const handleResize = () => {
onResize();
};
window.addEventListener('resize', handleResize);
onResize();
return (defaultSize) => {
window.removeEventListener('resize', handleResize);
if (defaultSize) {
if (typeof defaultSize === 'string') {
document.documentElement.style.fontSize = defaultSize;
} else if (typeof defaultSize === 'number') {
document.documentElement.style.fontSize = `${defaultSize}px`;
}
}
};
};
</code></pre><p>使用时:</p><pre><code class="javascript">flexible({ pageFontSize: 100 });</code></pre><pre><code class="css">.safe-content {
width: 7.5rem;
height: 13.34rem;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
}</code></pre><p>这时,只需要把<code>safe-content</code>的<code>witdh</code>和<code>height</code>写死,就能保证宽高比永远保持为<code>750 * 1334</code></p><p>完整页面,点击此处<a href="https://link.segmentfault.com/?enc=LseosFBeSYCU38KvmBAg2Q%3D%3D.SzDmlOft43cctSdyxd0s2nr1yaeJbDJwg2G3mzB%2BVUwFeRI%2FixbmTnp3UtHIZk3B4HTDbH19z5SmjFqjDM3T2g%3D%3D" rel="nofollow">动态修改rem-预览</a> (手机模式查看)</p><p>改变屏幕大小,可以看到<code>safe-content</code>一直都保持正常的宽高比,并且总是宽度占满(宽度比高度小)或者高度占满(高度比宽度小)</p><p>完整代码,直接右键源代码</p><p>另外一个简化版本</p><pre><code class="javascript">(function init(screenRatioByDesign = 750 / 1334) {
let docEle = document.documentElement
function setHtmlFontSize() {
var screenRatio = docEle.clientWidth / docEle.clientHeight;
// 7.5 = 750 / 100 (100是设计稿上的1rem大小)
var fontSize = (
screenRatio > screenRatioByDesign
? (screenRatioByDesign / screenRatio)
: 1
) * docEle.clientWidth / 7.5;
docEle.style.fontSize = fontSize.toFixed(3) + "px";
}
setHtmlFontSize()
window.addEventListener('resize', setHtmlFontSize)
})()</code></pre><p>完整页面,点击此处<a href="https://link.segmentfault.com/?enc=r6MzAjOyHM2YRwejhSSlpA%3D%3D.qNOCmnSHhoFcpUU6AEMxihBEUQT2o6mzvfaifVvyKmS7TptUcD1z9y6f3YDoLfvdzqkDKCfWMBcVbNpxjOBAMQ%3D%3D" rel="nofollow">动态修改rem2-预览</a> (手机模式查看)</p><h2>动态缩放整体页面</h2><p>前面的适配方案,我们都借助了<code>rem</code>进行页面的大小缩放。其实我们也可以直接使用<code>px</code>进行页面的布局,最后统一使用<code>js</code>进行整体缩放</p><pre><code class="javascript"> function init() {
const winWidth = document.documentElement.clientWidth;
const winHeight = document.documentElement.clientHeight;
const winScale = winWidth / winHeight;
const page = document.querySelector('.safe-content');
const pageWidth = 750;
const pageHeight = 1334;
const pageScale = pageWidth / pageHeight;
const origin = '50% 50% 0';
let initialScale = 1;
if (winScale > pageScale) {
// 宽度长了,但是高度不够
// 高度占满,宽度水平居中
console.log('高度占满,宽度水平居中');
initialScale = winHeight / pageHeight;
} else {
console.log('宽度占满,高度垂直居中');
// 高度长了,但是宽度不够
// 宽度占满,高度垂直居中
initialScale = winWidth / pageWidth;
}
page.style.width = pageWidth + 'px';
page.style.height = pageHeight + 'px';
page.style.transform = 'scale(' + initialScale + ')';
page.style.transformOrigin = origin;
page.style.left = (pageWidth - winWidth) / -2 + 'px';
page.style.top = (pageHeight - winHeight) / -2 + 'px';
}
init();
window.addEventListener('resize', init);</code></pre><pre><code class="css">.safe-content {
/* 布局直接写死成设计稿上的大小 */
width: 750px;
height: 1334px;
position: absolute;
left: 0;
top: 0;
border: 1px solid red;
}
.gift-logo {
/* 布局直接写死成设计稿上的大小 */
width: 679px;
height: 703px;
background: url('./img/game-title.png') no-repeat center / contain;
}</code></pre><p>这时,只需要把<code>safe-content</code>的<code>witdh</code>和<code>height</code>写死,就能保证宽高比永远保持为<code>750 * 1334</code></p><p>完整页面,点击此处<a href="https://link.segmentfault.com/?enc=mdcztRC5DyAwJ9XMBrFlqQ%3D%3D.cWmeHlcRE01QGDZgjKqbtYHKEvA7Gn%2FkDGd%2FhecbW0QlDvoZ2%2BeSTbOuvwQsoTub%2Bz7eybm%2Bgs0ZL7t3Oe66Cw%3D%3D" rel="nofollow">动态缩放整体页面-预览</a> (手机模式查看)</p><p>改变屏幕大小,可以看到<code>safe-content</code>一直都保持正常的宽高比,并且总是宽度占满(宽度比高度小)或者高度占满(高度比宽度小)</p><p>完整代码,直接右键源代码</p><h2>vw和vh</h2><p>单屏页面布局时,垂直定位尽可能相对高度进行定位,这时可以选择使用百分比或者<code>vh</code></p><pre><code class="scss">@use 'sass:math';
@function px2vh($px, $height: 1334) {
@return math.div($px, $height) * 100 * 1vh;
}</code></pre><p>可以封装一个<code>scss</code>方法,将测量得到的<code>px</code>转换成<code>vh</code></p><pre><code class="scss">.demo {
position: absolute;
left: 0;
top: 25%; // 垂直定位单位为%
top: px2vh(100); // 垂直定位单位为vh
width: 100%;
height: 1rem;
}</code></pre><p>如果希望页面的元素在不同的高度下,均能完整展示,可以全部使用<code>vh</code>进行布局</p><pre><code class="css">.gift-title {
width: 25.86vh;
height: 4.72vh;
background: url('./img/congratulate.png') no-repeat center / contain;
}
.gift-name {
font-size: 2.39vh;
}</code></pre><p>完整页面,点击此处<a href="https://link.segmentfault.com/?enc=7%2Fvba1U7X5Z7oiBozEkkiQ%3D%3D.ev1iZuhEQ6B5BSSljxO37J7SM40U5vQaMXRzBe7B92Jc9GQDkOv0cejRUZfljXFJ" rel="nofollow">vh-预览</a> (手机模式查看)</p><p>完整代码,直接右键源代码</p><h2>总结</h2><p>前面介绍的5种适配方案,可以总结如下:</p><ol><li>使用<code>vw</code>和<code>vh</code>这两个原生的<code>css3</code>单位,天然支持宽度和高度的适配:对于需要高度适配的元素,使用<code>vh</code>,对于需要宽度适配的元素,使用<code>vw</code></li><li><code>rem</code>相对宽度计算:划分几个高度区域,对于特定的宽高比,单独进行适配。整体页面还是相对宽度进行缩放,只针对部分宽高比,对页面进行特定的样式改动</li><li><code>rem</code>动态改变:即可相对宽度,也可相对高度进行计算,此种方案,可以做到<strong>保持设计稿的宽高比例,并尽量占满窗口</strong>的极致效果</li><li>不使用<code>rem</code>,写死<code>px</code>,直接<code>js</code>整体缩放页面:此种方案,也可以做到<strong>保持设计稿的宽高比例,并尽量占满窗口</strong>的极致效果</li></ol><p>对于<strong>保持设计稿的宽高比例,并尽量占满窗口</strong>的效果,可以点击下面的demo进行预览理解:</p><p>调整屏幕大小,可以看见页面会上下居中或者左右居中,并且保持宽高比</p><p><img src="/img/bVc2QZg" alt="" title=""></p><ul><li><a href="https://link.segmentfault.com/?enc=Lbfhzz9RrV6KHExZOPuFkQ%3D%3D.480EfSN583A2tIEJvowhuwECuOHipOcQsHswzS6pUruJxFBHp532FtQKcWtGkkQH" rel="nofollow">保持16:9的宽高比-rem动态改变</a></li><li><a href="https://link.segmentfault.com/?enc=OUFiKLyM%2BcIS7BPQbf%2Fn6A%3D%3D.sYkX5vAa5DVUaeHXWLyz%2F2GYK2PaLuHdpbPBSuN%2F%2BJlTnI37YevwO9Yx6QZbDOFY" rel="nofollow">保持16:9的宽高比-rem动态改变2</a></li><li><p><a href="https://link.segmentfault.com/?enc=sgPJe6QnsDV6FnFa7DStZQ%3D%3D.Z%2FEf%2BX4rBiYvF9Hl6ooOFwmLIcLuDyS2erPhLW3vDwOg6RDTaf1CI9Ps13YjPXme" rel="nofollow">保持16:9的宽高比-整体缩放</a></p><h2>参考</h2></li><li><a href="https://link.segmentfault.com/?enc=KjZbVmNnfnVBhD%2B%2FRrNRCQ%3D%3D.rG5x%2FehwB3e%2Bi3etZSWrLo5eLp2o%2BjO4Wl0QyR2k4SivGl9IdzGIs0tKCOJaNApi" rel="nofollow">关于移动端适配,你必须要知道的</a></li><li><a href="https://segmentfault.com/a/1190000017784801">不要再问我移动适配的问题了</a></li><li><a href="https://link.segmentfault.com/?enc=O2VmQmOsM15z5j5EYUuKGg%3D%3D.N1DdSnmpqoH032EJYeCHO1a199TTblnapcj11xzwpQXcRIZGwMqJBVWHxPq0%2FedyAWBIEHz4NbPZUY360%2FCrCA%3D%3D" rel="nofollow">device-aspect-ratio与aspect-ratio单屏布局</a></li><li><a href="https://link.segmentfault.com/?enc=DDjBD39klxf6vRmeZs1Q6g%3D%3D.NImofXC9FFKFrTFh%2Fzhidl9%2Bfjpa7Qu0t54FTR%2BaVnLZzg7sYqicNp7F6D7AjFai" rel="nofollow">翻页H5全分辨率适配最佳实践</a></li><li><a href="https://link.segmentfault.com/?enc=zhORoapw8IUeOWdOert5SQ%3D%3D.%2FpUCrqb1tekdS0C2btLpD6XIKcyPpFSUO%2FYAHz6K2QzWgEAR2LI%2FhLsRgfJQqyI4" rel="nofollow">大屏上的全屏页面的自适应适配方案</a></li><li><a href="https://link.segmentfault.com/?enc=p13KR7ApCuNZQLccXGF9%2Bw%3D%3D.iAUoxGB%2F99fPrsspBGHB93PShYw3FpARp6osshqOJFYlTvkKLoeQUHRjurBkONTmE1P1LTMX%2FKEIcA5x4AVSjw%3D%3D" rel="nofollow">移动端单屏解决方案</a></li><li><a href="https://link.segmentfault.com/?enc=zwpG46SxCQGGidY4CkAbIw%3D%3D.oo%2FWCe2YEEjmDPkMAPRobN2gQIUhtAH9wQbNGGB8B%2FpUQR%2BCLP7JT%2BrSLUhoAMvDtY66e43VlWTY8X7SZvVTMw%3D%3D" rel="nofollow">移动端单屏解决方案(续)</a></li><li><a href="https://link.segmentfault.com/?enc=Tf8L9YM1Lsvyht5ZHFt3Hw%3D%3D.6vaVrAgW%2Ba%2Fo4QVvHBPlfLBOtFqHlivx6DxTnpnlNIvY98AAGKrK6KtHO%2BCc%2Fec5X7Hq%2BVtMCF2LChDhK%2B%2Ft779d1N1pAy1gPZw%2F6jm19v0xM76gUZBPrWDGV0Ka95sEfWP6VMvS6uu0tj5eTjdzHEvF7NxfLcUd8EGnU%2BnLb7KL7puGHe4mJk14EjXFwDKT" rel="nofollow">如何打造一个高效适配的H5</a></li><li><a href="https://link.segmentfault.com/?enc=lK4Pvlnr6AL07aFDcdtcrQ%3D%3D.YJ%2FHT4AYskN9nJ8nE7JU%2BSnUEoOZ5FkJBSsKMQU16Wf2Vt5C3AnngAt64%2Ftw8Rab" rel="nofollow">全屏HTML5适配个人见解</a></li><li><a href="https://link.segmentfault.com/?enc=eFpNb5Ea%2FPQIaQpbmLgUWw%3D%3D.%2BaP5TrfFFGQD8TYJwpClBDAiqsKSGudl41Nk6PAfAnzjs1waTDKebOBLiIfjTWXiWnOP9hETYiFY9ZSm5pFnIZE32qHpcffmKmV9TyhaJs0aR4shweoyUKl6OkqgVcjkU3gBcDfsatTr8HhI2Cl9hsPM5lzinB2G0qW7KyfF4hCT%2Bz1ZZoyEO119rrCMfe6oakmIZKCjHh2iZOXjUYPYPMFLCPIfnQBNdaTvG6CVv6Jbd1%2FEBNH7A87Za4hdo%2FiSwFKWUso%2BqnHQ4lUIaNaSl3i6AQoq5OPBiRQJNt9fMAU0ItXVeufBoZ32Ih0trl3M" rel="nofollow">通过rem布局+media-query:aspect-ratio实现移动端全机型适配覆盖</a></li></ul>
移动端常见适配方案
https://segmentfault.com/a/1190000041831493
2022-05-12T09:00:00+08:00
2022-05-12T09:00:00+08:00
深红
https://segmentfault.com/u/deepred5
15
<p>做移动端页面有一段时间了,总结下工作中常用的几种移动端适配方案。</p><h2>基础</h2><p>网上已经有非常多的基础知识总结,不再赘诉,详情可以见</p><p><a href="https://link.segmentfault.com/?enc=mPMlmKnkdvTkgy2v3l8ozg%3D%3D.8JJ6ghxmwR2d9bbw2TBe%2BoFQa1rDO2u2GZwgrq22P4wYOyXz6RvhL7RVvxVrdwUz" rel="nofollow">《关于移动端适配,你必须要知道的》</a></p><p><a href="https://segmentfault.com/a/1190000017784801">《不要再问我移动适配的问题了》</a></p><p>其中容易搞混的概念是<code>视口</code></p><pre><code class="html"><meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,viewport-fit=cover"></code></pre><p><code>meta</code>标签中的<code>viewport</code>属性,就是<code>视图</code>的含义</p><p>视口分为</p><ul><li>布局视口</li><li>视觉视口</li><li>理想视口</li></ul><h3>布局视口</h3><p>也就是<code><meta name="viewport" content="width=device-width"></code>中<code>width</code>属性的含义</p><p>我们在css中写的所有样式,就是相对于<code>布局视口</code>进行布局的</p><p>默认情况下,移动端的布局视口并不是屏幕宽度,而是一般在768px ~ 1024px间(大部分情况下980px)</p><p>可以通过<code>document.documentElement.clientWidth</code>获取 (根据<code>width</code>和<code>initial-scale</code>来确定)</p><h3>视觉视口</h3><p>视觉视口是指用户通过设备屏幕看到的区域,默认等于当前浏览器的窗口大小(当<code>initial-scale</code>为1)</p><p>当用户对浏览器进行缩放时,不会改变布局视口的大小,所以页面布局是不变的,但是<strong>缩放会改变觉视口的大小</strong></p><p>可以通过<code>window.innerWidth</code>获取 (会随着缩放进行改变)</p><p>放大页面,此时<code>window.innerWidth</code>反而减小 (页面放大,你看到的东西也变少了)</p><h3>理想视口</h3><p>理想视口是指网站在移动设备中的理想大小,这个大小就是设备的屏幕大小</p><p>也就是<code><meta name="viewport" content="width=device-width"></code>中<code>device-width</code>的含义</p><p>可以通过<code>screen.width</code>获取 (常量,不会改变)</p><h3>initial-scale</h3><p><code><meta name="viewport" content="width=device-width, initial-scale=0.5"></code></p><p>根据公式<code>initial-scale = 理想视口宽度 / 视觉视口宽度</code></p><p>假设理想视口宽度为<code>414px</code>(device-width),此时设置<code>initial-scale</code>为0.5,那么视觉视口宽度就是<code>414 / 0.5 = 818</code></p><p>如果这时你获取<code>document.documentElement.clientWidth</code>(布局视口)的值,会发现不是<code>414px</code>而是<code>818px</code></p><p>结论: <strong>布局视口宽度取的是width和视觉视口宽度的最大值</strong></p><p>思考题: </p><p><code><meta name="viewport" content="width=600, initial-scale=2"></code></p><p>假设理想视口宽度为<code>414px</code>(device-width),此时<code>document.documentElement.clientWidth</code>(布局视口)的值是多少?</p><pre><code class="bash">视觉视口 = 414 / 2 = 207
布局视口 = Math.max(207, 600)
布局视口 = 600</code></pre><h3>总结</h3><ul><li><code>document.documentElement.clientWidth</code>: 布局视口,css中一般写成<code>width=device-width</code></li><li><code>window.innerWidth</code>: 视觉视口,页面缩放都会实时改变该值</li><li><code>screen.width</code>: 理想视口,页面屏幕大小(设备独立像素),也就是css中的<code>device-width</code></li></ul><h2>常见适配方案</h2><p>简单一句话概括:移动端适配就是在进行<code>屏幕宽度</code>的<strong>等比例缩放</strong>:</p><p>平时我们开发中,拿到的移动端设计稿一般是<code>750 * 1334</code> 尺寸大小( iPhone6 的设备像素为标准的设计图)。那如果在<code>750px</code>设计稿上量出的元素宽度为<code>100px</code>,那么在<code>375px</code>宽度的屏幕下,这个元素宽度就应该等比例缩放成<code>50px</code>。</p><p>所以适配的难点是:如果实现页面的等比例缩放?</p><h3>Rem 方案</h3><p>该方案的核心就是:所有需要动态布局的元素,不再使用<code>px</code>固定尺寸,而是采用<code>rem</code>相对尺寸</p><p><code>rem</code>的大小是相对于根元素<code>html</code>的字体大小:如果<code>html</code>的<code>font-size</code>为100px,那么<code>1rem</code>就等于100px</p><p>现在我们假定:</p><p><code>750px</code> 屏幕下 <code>html</code>的<code>font-size</code>为100px,也就是<code>1rem</code>为100px,那么<code>200px</code>宽度的<code>.box</code>元素,就应该写成<code>2rem</code></p><pre><code class="css">.box {
/* 750px屏幕下,200px */
width: 2rem;
}</code></pre><p>那么现在:</p><p><code>375px</code> 屏幕下,我们需要<code>.box</code>元素渲染成<code>100px</code></p><pre><code class="css">.box {
width: 2rem;
}</code></pre><p>由于<code>.box</code> 的宽度仍然是<code>2rem</code>,因此,这时候我们就需要<code>1rem</code>为50px,也就是说,此时<code>html</code>的<code>font-size</code>为50px</p><p>于是此时,我们可以得出一个公式: </p><p><code>(750) / (100) = (当前屏幕尺寸) / (当前屏幕1rem)</code></p><p>把这个公式进行一次数学转换就能得到:</p><p><code>(当前屏幕1rem) = 100 * (当前屏幕尺寸) / 750</code></p><p>翻译成js语言就是</p><pre><code class="js">document.documentElement.style.fontSize = 100 * (document.documentElement.clientWidth) / 750 + 'px';</code></pre><p>将代码优化一下</p><pre><code class="js">const PAGE_WIDTH = 750; // 设计稿的宽度
const PAGE_FONT_SIZE = 100;// 设计稿1rem的大小
const setView = () => {
//设置html标签的fontSize
document.documentElement.style.fontSize = PAGE_FONT_SIZE * (document.documentElement.clientWidth) / PAGE_WIDTH + 'px';
}
window.onresize = setView; // 如果窗口大小发生改变,就触发 setView 事件
setView()</code></pre><p>考虑到Andorid端字体渲染的问题以及页面大小变化的监听,最终的代码如下:</p><pre><code class="js">(function () {
var timer = null;
var PAGE_WIDTH = 750; // 设计稿的宽度
var PAGE_FONT_SIZE = 100;// 设计稿1rem的大小
function onResize() {
var e = PAGE_FONT_SIZE * document.documentElement.clientWidth / PAGE_WIDTH;
document.documentElement.style.fontSize = e + 'px';
// 二次计算缩放像素,解决移动端webkit字体缩放bug
var realitySize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
if (e !== realitySize) {
e = e * e / realitySize;
document.documentElement.style.fontSize = e + 'px';
}
}
window.addEventListener('resize', function () {
if (timer) clearTimeout(timer);
timer = setTimeout(onResize, 100);
});
onResize();
})();</code></pre><p>注意的是:我们取 <code>100px</code> 作为设计稿的1rem,是因为方便计算,比如设计稿上量出<code>250px</code>,我们就可以很容易的计算出为<code>2.5rem</code>。</p><p>我们当然也可以把 <code>50px</code> 作为设计稿的1rem,这时设计稿上的<code>250px</code>,就要写成<code>5rem</code>。</p><p>其实我们也可以借助于<a href="https://link.segmentfault.com/?enc=7d7Lj8B6u%2BXixcNAkMmTzA%3D%3D.kLLMhZTwcl9n1y2XN6q7Cx%2B%2B6QynIOyz2SAOMYwbkSlCghhAfwOcoQN4Z%2BfcnIG9" rel="nofollow">postcss-pxtorem</a>或者<code>SCSS</code>函数来帮我们自动转换单位</p><pre><code class="scss">@function px2rem($px) {
// 根元素字体为100px
@return $px / 100 * 1rem;
}
.box {
width: px2rem(200);
}</code></pre><p>通过Rem方案,需要动态缩放的元素,我们使用<code>rem</code>相对单位,不需要缩放的元素,我们仍然可以使用<code>px</code>固定单位。</p><p>不过在大屏设备下(例如ipad或者pc端),由于我们的页面是等比例缩放,这时候页面的元素会被放大很多(屏幕宽度大,导致根元素字体1rem也变大)。但是在大屏下,我们真正希望的是用户看到更多的内容,这时候我们可以使用媒体查询的方式来限制根元素的字体,从而防止在大屏下元素过大的问题。</p><pre><code class="css">@media screen and (min-width: 450px) {
html {
font-size: 50px !important;
}
}</code></pre><p>或者修改js脚本的逻辑</p><pre><code class="js">const PAGE_WIDTH = 750; // 设计稿的宽度
let PAGE_FONT_SIZE = 100;// 设计稿1rem的大小
const setView = () => {
if (document.documentElement.clientWidth > 450) {
// 大屏下减小根元素字体
PAGE_FONT_SIZE = 50;
}
document.documentElement.style.fontSize = PAGE_FONT_SIZE * (document.documentElement.clientWidth) / PAGE_WIDTH + 'px';
}</code></pre><h3>VW 方案</h3><p>vw 是相对单位,1vw 表示屏幕宽度的 1%</p><p>其实我们的<code>REM方案</code>就是<code>VW方案</code>的模拟,之前我们有一个公式:</p><p><code>(750) / (100) = (当前屏幕尺寸) / (当前屏幕1rem)</code></p><p>换一个转换方式:</p><p><code>(当前屏幕1rem) = (当前屏幕尺寸) / 7.5 </code></p><p>而 vw 单位其实就是:</p><p><code>(当前屏幕1vw) = (当前屏幕尺寸) / 100 </code></p><p>因此,<code>REM方案</code>就是用 JS 把屏幕宽度分成了7.5份,而 CSS3 中新增的<code>vw</code>单位,原生实现了把屏幕宽度分成了100份</p><p>所以,在<code>VW方案</code>中,我们不再需要使用JS脚本了!</p><p><code>750px</code>设计稿中,<code>1vw</code>等于<code>7.5px</code>(750 / 100),因此,在设计稿中,量出<code>200px</code>的宽度,就因为写成<code>26.667vw</code>(200 / 7.5)</p><pre><code class="css">.box {
/* 750px屏幕下,200px */
width: 26.667vw;
}</code></pre><p>不过使用<code>vw</code>换算,并不像<code>rem</code>那么方便,这时候我们可以借助<a href="https://link.segmentfault.com/?enc=qH8DBlWAIr81ntf7N5hlMg%3D%3D.N3hSehb4ZWRkhw5NiTQ6uw66Qpk9NnbsuwSEyk7pdMjnj0v2BG83NQYCo6opCicVaGQYZavLDlRZroexvOYaeA%3D%3D" rel="nofollow">postcss-px-to-viewport</a>或者<code>SCSS</code>函数来帮我们自动转换单位</p><pre><code class="scss">@function px2vw($px) {
@return $px / 750 * 100vw;
}
.box {
width: px2vw(200);
}</code></pre><p>同样,在大屏设备下,由于屏幕宽度大,所以页面的元素同样会放大很多(屏幕宽度大,1vw也很大)。但是由于<code>vw</code>是相对屏幕宽度的,所以我们不能像<code>REM方案</code>中一样,手动控制<code>html</code>的根字体大小,这也是使用<code>VW方案</code>的一个缺点。</p><h3>REM + VW 方案</h3><p><code>REM方案</code>的优势是可以手动控制<code>rem</code>的大小,防止屏幕太大时,页面元素也缩放很大,但是缺点就是需要使用<code>JS</code>。<code>VW方案</code>刚好相反,无需使用<code>JS</code>但是无法手动控制<code>vw</code>的大小。</p><p>其实我们可以把两者结合:</p><pre><code class="css">html {
/* 750px 的设计图,1rem = 100px */
font-size: calc(100 * 100vw / 750);
}
.box {
/* 750px屏幕下,200px */
width: 2rem;
}</code></pre><p>对于布局元素,我们仍然使用<code>rem</code>单位。但是对于根元素的字体大小,我们不需要使用JS来动态计算了</p><pre><code class="js">100 * (document.documentElement.clientWidth) / 750</code></pre><p>这段js可以直接使用css来实现</p><pre><code class="css">calc(100 * 100vw / 750)</code></pre><p>对于大屏设备,我们使用媒体查询</p><pre><code class="css">@media screen and (min-width: 450px) {
html {
font-size: calc(50 * 100vw / 750);
}
}</code></pre><p>更详细的<code>vw+rem布局方案</code> 可以见<a href="https://link.segmentfault.com/?enc=pw3wa8%2BZ%2BW55h54yKvbt0g%3D%3D.a5CmjF0m4MoStCauGod0N9G30FQgn2OPHZRCuHZbV0hCx48IocqWY5931weRuytjr%2BOFHju0o6i2PdJAwRi%2Fha%2Fzfj8FLJT1HpXIz3svShGBo5hcX9K3z9%2B1BdKgtsYK" rel="nofollow">《基于vw等viewport视区单位配合rem响应式排版和布局》</a></p><h3>viewport 缩放方案</h3><p>还有一种更简单粗暴的方法,就是我们设置<code>initial-scale</code></p><p>我们的布局完全基于设计稿<code>750px</code>,布局元素单位也使用<code>px</code>固定单位 (布局视口写死750px)</p><p>对于<code>375px</code>宽度,我们就将整个页面缩放<code>0.5</code>:</p><p><code><meta name="viewport" content="width=750, initial-scale=0.5, minimum-scale=0.5, maximum-scale=0.5, user-scalable=0"></code></p><pre><code class="html"><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>demo</title>
<script>
var clientWidth = document.documentElement.clientWidth;
var viewport = document.querySelector('meta[name="viewport"]');
var viewportWidth = 750;
var viewportScale = clientWidth / viewportWidth;
viewport.setAttribute('content', 'width=' + viewportWidth + ', initial-scale=' + viewportScale + ', minimum-scale=' + viewportScale + ', maximum-scale=' + viewportScale + ', user-scalable=0');
</script>
</head></code></pre><pre><code class="css">.box {
width: 200px;
}</code></pre><p>此方案的缺点: 整个页面都被缩放了,对于不想缩放的元素无法控制。</p><p>市面上一些营销H5页面,由于是通过后台可视化拖拽搭建出来的,为了适配各种尺寸的屏幕,该方案是成本最低的实现(<a href="https://link.segmentfault.com/?enc=r1oBadxGzqSw1NCBsZVsIg%3D%3D.4hY8k5%2FtUbozTNiB5Lg26%2FWvDuRsCn%2BxRIRpf3%2BkrsU%3D" rel="nofollow">易企秀</a>就是使用这种方案)</p><h2>实战</h2><p>我们拿线上B站的<a href="https://link.segmentfault.com/?enc=XGkQJuwFBote%2F2QZNPjYgg%3D%3D.Au9zzPIu%2Bfa6AvCVs%2F6vl6Aqr3zC9214u3jOOWyhJxQ%3D" rel="nofollow">会员购</a>作为示例</p><ul><li><a href="https://link.segmentfault.com/?enc=Xvy5VO%2BBPMDSfJMyLUTJXA%3D%3D.LAqMoelsfPNFOAUlYfMwGamONzlKYPYU%2FH5lAwJVtcRW6YsArHzWuXdYCvfk1bX0" rel="nofollow">rem方案</a></li><li><a href="https://link.segmentfault.com/?enc=wIx3VjjggW8sf2DXJGxntw%3D%3D.tTfsmDAJxzgOfuVPUj%2BBUxEBWALUZ%2BCbV6biIxSkej20K9Ea%2Bh0IEgG9iLGCPpXx" rel="nofollow">vw方案</a></li><li><a href="https://link.segmentfault.com/?enc=qewNGIEgVriegzKZcN2o5g%3D%3D.sSN5urdD%2BcZuF7cgmAj2v0DNiQZJKiiTNTvNEb%2F4SS1hjE7xE2K4ISTFkc8KGroL" rel="nofollow">rem+vw方案</a></li><li><a href="https://link.segmentfault.com/?enc=1K6Kv%2Fuar64qu637SUfWeA%3D%3D.xlSLj9CDYPpEsQ1nN%2BzGLoikDOTXwApI3FLRGy1NzOd2SPufTuNLS%2FC%2B80Tr1oz7" rel="nofollow">viewport方案</a></li></ul><p>请使用chrome开发者工具模拟移动端设备查看</p><p>源码直接右键查看即可,代码没有经过压缩,可以很直观的看到各种方案的css适配写法</p><h2>参考</h2><ul><li><a href="https://link.segmentfault.com/?enc=uHUz5B9mr8%2F6W3ZXGGVVoQ%3D%3D.Any3kvcrgel5fHy%2FGPq08%2BY6pINVauqm05KAtW8utS%2FKCmhR%2FDWiaYp6bxrx5BdC" rel="nofollow">移动端适配有哪几种方案?</a></li><li><a href="https://segmentfault.com/a/1190000017784801">不要再问我移动适配的问题了</a></li><li><a href="https://link.segmentfault.com/?enc=Q1yjeVvL2MqGebYInImbkQ%3D%3D.oFoyUIc4OjyYzw9ivtfAH07R2Mxhe7Zg8HELEM8Naf2E19dhhE6NFnR4wL%2BNIzpv" rel="nofollow">关于移动端适配,你必须要知道的</a></li><li><a href="https://link.segmentfault.com/?enc=GpzlwE%2BCVxL4m7cY0O3Xvw%3D%3D.4M162F6jRXFuPIkW24A2%2F6lXRZYGs2ByZATtED4ujnx%2B%2FkhFsw636ZkDHfqKsyzGhlM0F%2FkNnc1N8zPxkIkK8JRthHBJZiEbwhkZzbud8z7L2yvxo0to3cj3G2BdVYVo" rel="nofollow">基于vw等viewport视区单位配合rem响应式排版和布局</a></li></ul>
web视频基础教程
https://segmentfault.com/a/1190000038788218
2021-01-04T09:00:00+08:00
2021-01-04T09:00:00+08:00
深红
https://segmentfault.com/u/deepred5
28
<h3>前言</h3><p>提到网页播放视频,大部分前端首先想到的肯定是:</p><pre><code class="html"><video width="600" controls>
<source src="demo.mp4" type="video/mp4">
<source src="demo.ogg" type="video/ogg">
<source src="demo.webm" type="video/webm">
您的浏览器不支持 video 标签。
</video></code></pre><p>的确,一个简单的<code>video</code>标签就可以轻松实现视频播放功能</p><p>但是,当视频的文件很大时,使用<code>video</code>的播放效果就不是很理想:</p><ol><li>播放不流畅(尤其在:<strong>首次初始化视频</strong> 场景时卡顿非常明显)</li><li>浪费带宽,如果用户仅仅观看了一个视频的前几秒,可能已经被提前下载了几十兆流量了。<strong>即浪费了用户的流量,也浪费了服务器的昂贵带宽</strong></li></ol><p>理想状态下,我们希望的播放效果是:</p><ol><li>边播放,边下载(<strong>渐进式下载</strong>),无需一次性下载视频(<strong>流媒体</strong>)</li><li>视频码率的无缝切换(<strong>DASH</strong>)</li><li>隐藏真实的视频访问地址,防止盗链和下载(<strong>Object URL</strong>)</li></ol><p>在这种情况下,普通的<code>video</code>标签就无法满足需求了</p><h3>206 状态码</h3><pre><code class="html"><video width="600" controls>
<source src="demo.mp4" type="video/mp4">
</video></code></pre><p>我们播放<code>demo.mp4</code>视频时,浏览器其实已经做过了部分优化,并不会等待视频全部下载完成后才开始播放,而是先请求部分数据</p><p><img src="/img/remote/1460000038788227" alt="206" title="206"></p><p>我们在请求头添加</p><pre><code class="bash">Range: bytes=3145728-4194303</code></pre><p>表示需要文件的第<code>3145728</code>字节到第<code>4194303</code>字节区间的数据</p><p>后端响应头返回</p><pre><code class="bash">Content-Length: 1048576
Content-Range: bytes 3145728-4194303/25641810</code></pre><p><code>Content-Range</code>表示返回了文件的第<code>3145728</code>字节到第<code>4194303</code>字节区间的数据,请求文件的总大小是<code>25641810</code>字节<br><code>Content-Length</code>表示这次请求返回了<code>1048576</code>字节(4194303 - 3145728 + 1)</p><p>断点续传和本文接下来将要介绍的视频分段下载,就需要使用这个状态码</p><h3>Object URL</h3><p>我们先来看看市面上各大视频网站是如何播放视频?</p><p>哔哩哔哩:<br><img src="/img/remote/1460000038788226" alt="bili-v" title="bili-v"></p><p>腾讯视频:<br><img src="/img/remote/1460000038788221" alt="ten-v" title="ten-v"></p><p>爱奇艺:<br><img src="/img/remote/1460000038788224" alt="iqi-v" title="iqi-v"></p><p>可以看到,上述网站的<code>video</code>标签指向的都是一个以<code>blob</code>开头的地址: <code>blob:https://www.bilibili.com/0159a831-92c9-43d1-8979-fe42b40b0735</code>,该地址有几个特点:</p><ol><li>格式固定: <code>blob:当前网站域名/一串字符</code></li><li>无法直接在浏览器地址栏访问</li><li>即使是同一个视频,每次新打开页面,生成的地址都不同</li></ol><p>其实,这个地址是通过<a href="https://link.segmentfault.com/?enc=aVNRg8Z4DFmy%2B84o9uw14g%3D%3D.wiEo8sFCyw2eBtjRYaDXGB03ZERt16MtjHDBUwgnNOtHuXKwo1JRhb6t7IlP8NYrgiQGFAy9XyGsLknV%2B9GOYWSkGzGB9kcvZTc4O80cpNU%3D" rel="nofollow">URL.createObjectURL</a>生成的<code>Object URL</code></p><pre><code class="javascript">const obj = {name: 'deepred'};
const blob = new Blob([JSON.stringify(obj)], {type : 'application/json'});
const objectURL = URL.createObjectURL(blob);
console.log(objectURL); // blob:https://anata.me/06624c66-be01-4ec5-a351-84d716eca7c0</code></pre><p><code>createObjectURL</code>接受一个<code>File</code>,<code>Blob</code>或者<code>MediaSource</code>对象作为参数,返回的<code>ObjectURL</code>就是这个对象的引用</p><h3>Blob</h3><blockquote>Blob是一个由不可改变的原始数据组成的类似文件的对象;它们可以作为文本或二进制数据来读取,或者转换成一个ReadableStream以便用来用来处理数据</blockquote><p>我们常用的<code>File</code>对象就是继承并拓展了<code>Blob</code>对象的能力</p><p><img src="/img/bVcMULf" alt="image.png" title="image.png"></p><pre><code class="html"><input id="upload" type="file" /></code></pre><pre><code class="javascript">const upload = document.querySelector("#upload");
const file = upload.files[0];
file instanceof File; // true
file instanceof Blob; // true
File.prototype instanceof Blob; // true</code></pre><p>我们也可以创建一个自定义的<code>blob</code>对象</p><pre><code class="javascript">const obj = {hello: 'world'};
const blob = new Blob([JSON.stringify(obj, null, 2)], {type : 'application/json'});
blob.size; // 属性
blob.text().then(res => console.log(res)) // 方法</code></pre><h3>Object URL的应用</h3><pre><code class="html"><input id="upload" type="file" />
<img id="preview" alt="预览" /></code></pre><pre><code class="javascript">const upload = document.getElementById('upload');
const preview = document.getElementById("preview");
upload.addEventListener('change', () => {
const file = upload.files[0];
const src = URL.createObjectURL(file);
preview.src = src;
});</code></pre><p><code>createObjectURL</code>返回的<code>Object URL</code>直接通过<code>img</code>进行加载,即可实现前端的图片预览功能</p><p><img src="/img/remote/1460000038788225" alt="blob-pre" title="blob-pre"></p><p>同理,如果我们用<code>video</code>加载<code>Object URL</code>,是不是就能播放视频了?</p><p><code>index.html</code></p><pre><code class="html"><video controls width="800"></video></code></pre><p><code>demo.js</code></p><pre><code class="javascript">function fetchVideo(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'blob'; // 文件类型设置成blob
xhr.onload = function() {
resolve(xhr.response);
};
xhr.onerror = function () {
reject(xhr);
};
xhr.send();
})
}
async function init() {
const res = await fetchVideo('./demo.mp4');
const url = URL.createObjectURL(res);
document.querySelector('video').src = url;
}
init();</code></pre><p>文件目录如下:</p><pre><code class="bash">├── demo.mp4
├── index.html
├── demo.js</code></pre><p>使用<code>http-server</code>简单启动一个静态服务器</p><pre><code class="bash">npm i http-server -g
http-server -p 4444 -c-1</code></pre><p>访问<code>http://127.0.0.1:4444/</code>,<code>video</code>标签的确能够正常播放视频,但我们使用<code>ajax</code>异步请求了全部的视频数据,这和直接使用<code>video</code>加载原始视频相比,并无优势</p><h3>Media Source Extensions</h3><p>结合前面介绍的<code>206</code>状态码,我们能不能通过<code>ajax</code>请求部分的视频片段(segments),先缓冲到<code>video</code>标签里,然后当视频即将播放结束前,继续下载部分视频,实现分段播放呢?</p><p>答案当然是肯定的,但是我们不能直接使用<code>video</code>加载原始分片数据,而是要通过 <a href="https://link.segmentfault.com/?enc=V%2F%2FRn96pbDi%2Fok0YwLpfHg%3D%3D.NDBTJ8aR77qkwlrc%2ByOIpn%2BtUHRp16Dlv7swId3yzaAjpHI5uxTpwHi9imWQsxpQkRPdWDTc%2FDMzzLKvfPKIQw%3D%3D" rel="nofollow">MediaSource</a> API</p><p>需要注意的是,<strong>普通的mp4格式文件,是无法通过<code>MediaSource</code>进行加载的</strong>,需要我们使用一些转码工具,将普通的mp4转换成fmp4(<a href="https://link.segmentfault.com/?enc=ZrQAYYusukwIpzh1jOCqnw%3D%3D.g7mgNwyFVkadTNrgQrFd69WcFlRXk6Esj7vRO%2BPZOchOWZjeC8HWQEQo46uqe1MYS5IXwTDuf8DbBFZT9ceLAF8%2FPS50g%2FXO3JM5YFb4yRY9XNEz7moNjxL3feGpuLAtgfXb1dAYEl%2BSsm3ziGvHPAL4D6AyShC9SHDJS%2Fz%2BLNCWAEQXxcEKvJTIP57gk55B" rel="nofollow">Fragmented MP4</a>)。为了简单演示,我们这里不使用实时转码,而是直接通过<a href="https://link.segmentfault.com/?enc=xg3q6NNSBsiOYCyuSj4ioQ%3D%3D.wUkUbhp7Lf98J5W5EDS4ey%2BWWLCafQJWIa5Mn%2FoKdiWvH%2FviD2wPqViinycZUQL%2B" rel="nofollow">MP4Box</a>工具,直接将一个完整的mp4转换成fmp4</p><pre><code class="bash">#### 每4s分割1段
mp4box -dash 4000 demo.mp4</code></pre><p>运行命令,会生成一个<code>demo_dashinit.mp4</code>视频文件和一个<code>demo_dash.mpd</code>配置文件。其中<code>demo_dashinit.mp4</code>就是被转码后的文件,这次我们可以使用<code>MediaSource</code>进行加载了</p><p>文件目录如下:</p><pre><code class="bash">├── demo.mp4
├── demo_dashinit.mp4
├── demo_dash.mpd
├── index.html
├── demo.js</code></pre><p><code>index.html</code></p><pre><code class="html"><video width="600" controls></video></code></pre><p><code>demo.js</code></p><pre><code class="javascript">class Demo {
constructor() {
this.video = document.querySelector('video');
this.baseUrl = '/demo_dashinit.mp4';
this.mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
this.mediaSource = null;
this.sourceBuffer = null;
this.init();
}
init = () => {
if ('MediaSource' in window && MediaSource.isTypeSupported(this.mimeCodec)) {
const mediaSource = new MediaSource();
this.video.src = URL.createObjectURL(mediaSource); // 返回object url
this.mediaSource = mediaSource;
mediaSource.addEventListener('sourceopen', this.sourceOpen); // 监听sourceopen事件
} else {
console.error('不支持MediaSource');
}
}
sourceOpen = async () => {
const sourceBuffer = this.mediaSource.addSourceBuffer(this.mimeCodec); // 返回sourceBuffer
this.sourceBuffer = sourceBuffer;
const start = 0;
const end = 1024 * 1024 * 5 - 1; // 加载视频开头的5M数据。如果你的视频文件很大,5M也许无法启动视频,可以适当改大点
const range = `${start}-${end}`;
const initData = await this.fetchVideo(range);
this.sourceBuffer.appendBuffer(initData);
this.sourceBuffer.addEventListener('updateend', this.updateFunct, false);
}
updateFunct = () => {
}
fetchVideo = (range) => {
const url = this.baseUrl;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.setRequestHeader("Range", "bytes=" + range); // 添加Range头
xhr.responseType = 'arraybuffer';
xhr.onload = function (e) {
if (xhr.status >= 200 && xhr.status < 300) {
return resolve(xhr.response);
}
return reject(xhr);
};
xhr.onerror = function () {
reject(xhr);
};
xhr.send();
})
}
}
const demo = new Demo()</code></pre><p><img src="/img/remote/1460000038788223" alt="mse" title="mse"></p><p>实现原理:</p><ol><li>通过请求头<code>Range</code>拉取数据</li><li>将数据喂给<code>sourceBuffer</code>,<code>MediaSource</code>对数据进行解码处理</li><li>通过<code>video</code>进行播放</li></ol><p>我们这次只请求了视频的前5M数据,可以看到,视频能够成功播放几秒,然后画面就卡住了。</p><p><img src="/img/remote/1460000038788222" alt="video-load" title="video-load"></p><p>接下来我们要做的就是,监听视频的播放时间,如果缓冲数据即将不够时,就继续下载下一个5M数据</p><pre><code class="javascript">const isTimeEnough = () => {
// 当前缓冲数据是否足够播放
for (let i = 0; i < this.video.buffered.length; i++) {
const bufferend = this.video.buffered.end(i);
if (this.video.currentTime < bufferend && bufferend - this.video.currentTime >= 3) // 提前3s下载视频
return true
}
return false
}</code></pre><p>当然我们还有很多问题需要考虑,例如:</p><ol><li>每次请求分段数据时,如何更新<code>Range</code>的请求范围</li><li>初次请求数据时,如何确保<code>video</code>有足够的数据能够播放视频</li><li>兼容性问题</li><li>更多细节。。。。</li></ol><p>详细分段下载过程,见<a href="https://link.segmentfault.com/?enc=%2BjK06tNoUs8%2FR7WydtGIHw%3D%3D.SlAffVge8%2F939aSbDu8DhipYoGGuHhsHrSlTbJ%2Fpc190orXP1pnFpWB%2BZePmATgv%2FOsbbixlLlrhm3xtu%2FOcTPBxnAHqcxBIJlO0D2q627I%3D" rel="nofollow">完整代码</a></p><h3>流媒体协议</h3><p>视频服务一般分为:</p><ol><li>点播</li><li>直播</li></ol><p>不同的服务,选择的流媒体协议也各不相同。主流的协议有: RTMP、HTTP-FLV、HLS、DASH、webRTC等等,详见<a href="https://link.segmentfault.com/?enc=N2X7iydYoz%2BWaVIteS%2B0fg%3D%3D.wpug%2FOMQfLJbMl7wXEIwwAPXM%2FlPYweiF3dY0ERNuLtjyaUWCw9eiz2CcnKHPqvCGpwjt2l%2FWc6R7DDDwxe8ckdd1HFhczp3ab%2FlDRvly%2BkW0ndv31cvrvDHLYVb6ZrEfkJMo%2B9%2B%2F1Rt9SlsRfb1Aw%3D%3D" rel="nofollow">《流媒体协议的认识》</a></p><p>我们之前的示例,其实就是使用的DASH协议进行的点播服务。还记得当初使用<code>mp4box</code>生成的<code>demo_dash.mpd</code>文件吗?<code>mpd</code>(Media Presentation Description)文件就存储了fmp4文件的各种信息,包括视频大小,分辨率,分段视频的码率。。。</p><p>b站的点播就是采用的DASH协议<br><img src="/img/remote/1460000038795229" alt="" title=""></p><p>HLS协议的<code>m3u8</code>索引文件就类似DASH的<code>mpd</code>描述文件</p><table><thead><tr><th>协议</th><th>索引文件</th><th>传输格式</th></tr></thead><tbody><tr><td>DASH</td><td>mpd</td><td>m4s</td></tr><tr><td>HLS</td><td>m3u8</td><td>ts</td></tr></tbody></table><h3>开源库</h3><p>我们之前使用原生<code>Media Source</code>手写的加载过程,其实市面上已经有了成熟的开源库可以拿来即用,例如:<a href="https://link.segmentfault.com/?enc=KL1KtBQoV1qOCrO6rzj0LQ%3D%3D.nsFAS8AWXTc0h%2Fg0IDJ9bZZBiqsFe4PHO98li3yZNTEyUmETDUdca9WirgXYhH4%2F" rel="nofollow">http-streaming</a>,<a href="https://link.segmentfault.com/?enc=2pJwQ1gzluRMTItTtOnVeg%3D%3D.mR7RwtwlD7xHinNHQiUn96WO5dw72R906y7j1r8kLOQESqCn0dbwmO%2B%2B1pjxn7dB" rel="nofollow">hls.js</a>,<a href="https://link.segmentfault.com/?enc=VPRPHlW1dMBxCHA1K9IUjA%3D%3D.GcegNCDYIWWYPpqe55RARWVh5MYif5WehJXa9RdUMuwfQp3MK%2BagoF7xPfnPRG3W" rel="nofollow">flv.js</a>。同时搭配一些解码转码库,也可以很方便的在浏览器端进行文件的实时转码,例如<a href="https://link.segmentfault.com/?enc=pxJnk%2BikuzNu1hF5r%2BXNvA%3D%3D.ylAoayWWN7UsQR4wkcStfoJn4InX5oDCPxGKqQ%2Bid7u0U%2BhGsR4OVPm4TknnAc2M" rel="nofollow">mp4box.js</a>,<a href="https://link.segmentfault.com/?enc=kGaAAWImYJz5pFSxci1GxQ%3D%3D.u61b%2FAMFCgr2psfEkz8nM6lM2sBA8E6pc1KqpPpEAdUP0HcvsWkwbutihJGDBiEO" rel="nofollow">ffmpeg.js</a></p><h3>总结</h3><p>本文简单介绍了 <code>Media Source Extensions</code> 实现视频渐进式播放的原理,涉及到基础的点播直播相关知识。由于音视频技术涉及的内容很多,加上本人<del>水平的限制</del>,所以只能帮助大家初步入个门而已</p><h3>参考</h3><ul><li><a href="https://link.segmentfault.com/?enc=o59WOwXjhq5LOgW0VyW44Q%3D%3D.1chy8%2F1N5Uzql3zNSCfPqwrz6qP7h8cTacmndmrDZYHPCSrtQNBlGRLx5k0o8fpy" rel="nofollow">为什么视频网站的视频链接地址是blob?</a></li><li><a href="https://link.segmentfault.com/?enc=MLdqNZhAwfaA6%2F7SGWqYLg%3D%3D.MRki1dZjMqyBw4hFyU4Yn3wMR19yY%2BpYkAmNcOiW068nq6eVC4m6lOnwlRrlqfN7%2FXgyst8J3ntr6FnWYvg%2B1g%3D%3D" rel="nofollow">从天猫某活动视频不必要的3次请求说起</a></li><li><a href="https://link.segmentfault.com/?enc=OQb4OASmwJ7h2kF6ZMInfA%3D%3D.cm2htxdXOHVYXeESzyGtrxPQmEI9TuFyBA3a%2BRbeDgYbPia3sXkyQysBygqaETDq" rel="nofollow">我们为什么使用DASH</a></li><li><a href="https://link.segmentfault.com/?enc=vsYzAYUaya3%2FWx3aV165Cw%3D%3D.bR%2FhubM9OdNM9qVMQsogayPnSXBzg5JAIn8JHDWOVYlpacoDzLaaoo6UGKEN9aYT" rel="nofollow">使用 MediaSource 搭建流式播放器</a></li><li><a href="https://link.segmentfault.com/?enc=mwpVBRmMhph8AoLg7NiVAQ%3D%3D.FfFENCzcGFLMATaGP1ZhSoqZaCDHoWhaPodD4mQYHd6qemwvzke3OdOJLiUWLqM6" rel="nofollow">Web 视频播放的那些事儿</a></li><li><a href="https://link.segmentfault.com/?enc=L4gdlIDcZADHFU%2B17MQdGg%3D%3D.Lz9KDnBDGnYIUsm8KJcoSaQt1VN%2Bu2%2BEwbv9ZZGF8C6AcZijfo3VCl5pIUA1KQfg6vt0JxvVPDgQjWysvz3gW7p%2BxGyb6GGy1i8jxIOSZNc%3D" rel="nofollow">Building a simple MPEG-DASH streaming player</a></li><li><a href="https://link.segmentfault.com/?enc=5NGQc8F7zaFOQ8Izy6u58A%3D%3D.8uD%2BUVbio3A7xiNem95k6%2Bkr7OJ8kPaTMmltnhD7Zy%2F6pb2ZhrVuIS2%2FEkj819aHK2Z7iiulIkWMajU5todbYA%3D%3D" rel="nofollow">前端视频直播技术总结及video.js在h5页面中的应用</a></li><li><a href="https://link.segmentfault.com/?enc=B%2FGvAfP294A4M8j5Qmqz0Q%3D%3D.ZH8H1%2BODni1rrBn8q%2FUeTT8ZEd%2B74PlE3H7bjD%2BZ%2BD35tvTEvewQYuCoePTiVHR2JvFXNiSBd3mQxA1jSCKaF2AFRjtNh%2FQImVYN8Ty3o0Pk%2FaS0Xyw1vj8joxoZSFv8rEDfuofTJhahaU080bmcIw%3D%3D" rel="nofollow">流媒体协议的认识</a></li><li><a href="https://link.segmentfault.com/?enc=6J7JrgxfCvakuwwMv0LM5Q%3D%3D.%2B8RaXWBbrgbuuolZ77VPf8a12d6j3C0xfPbbUdVNlSlzJ5%2BheQXrwV4BjPOC7j07lvg6OMrCwVj5if5yWlvNPQ%3D%3D" rel="nofollow">让html5视频支持分段渐进式下载的播放</a></li></ul>
Ant Design Pro V2升级到V4 小结
https://segmentfault.com/a/1190000038145297
2020-11-12T13:44:00+08:00
2020-11-12T13:44:00+08:00
深红
https://segmentfault.com/u/deepred5
4
<h2>前言</h2><p>前不久接手过一个历史悠久的项(shi)目(shan),技术栈之复杂(<del>混乱</del>)令我潸然泪下:你甚至可以在一个项目里使用前端三大框架(Angular1, Vue, React)。</p><blockquote>三份的代码,本应该给我带来更多的快乐,但是为什么会变成这样呢?</blockquote><p>鉴于接到的是一个全新的需求,于是我又双叒叕引入了<code>Ant Design Pro V4</code>全家桶(<strong>第四份的快乐</strong>)。<code>Hooks</code>和<code>Ant Design V4</code>的搭配,的确用着很香,尤其是<code>Form</code>表单的重写,大大提高了开发效率。于是趁着空闲时间,我决定把一个自己负责的<code>Ant Design Pro V2</code>项目也升级到<code>V4</code>版本。</p><p>特此记录下升级过程。</p><h2>UMI3升级</h2><p>我当时使用的是<a href="https://link.segmentfault.com/?enc=%2F3Moio3snHzlH7908K%2BL3Q%3D%3D.YpthwAHBn2ELUKBvcNHSZamXbc34ivqssFVBuR2qipTkI%2BnVACLGk2OwMWyhGN5EqdTr3QPT4tWWWMnpmBRogw%3D%3D" rel="nofollow">ant-design-pro 2.2.0 脚手架</a> 生成的前端项目(JS版,非TS版),使用的是<code>umi@2</code>和<code>antd@3</code>。因此,首先要把<code>UMI</code>升级到最新的<code>V3</code>版本。</p><p>参考官方文档:</p><ol><li><a href="https://link.segmentfault.com/?enc=JEkW33FFpISCFMwt00aW%2Bw%3D%3D.IK%2F5zW%2F5sRi3F5aWe2mdfc%2BWqgDZDzaqX3WZPFPQ%2FJQAE9%2FIe%2BfreXJ4IkQ4kOG0" rel="nofollow">《快速升级到 umi@3》</a></li><li><a href="https://link.segmentfault.com/?enc=t804UVEqu4sIX4oqtoBHZg%3D%3D.kuGM9c8sRzfSgxY6xpyKPDOEEyF16sXrkhintkBVLLWYprUN3ZyqtpDae1l6xH%2F70s1zG%2F3uBqGm%2FpGfFUtqjA%3D%3D" rel="nofollow">《升级 antd pro 项目到 umi@3》</a></li></ol><p>a. 删除<code>package.json</code>里的<code>dva</code>和<code>umi-plugin</code>开头的插件,改成<code>"umi": "^3.0.0"</code> 和<code>"@umijs/preset-react": "^1.2.2"</code></p><p>其中<code>@umijs/preset-react</code>已经包含了之前的<code>umi-plugin</code>插件</p><pre><code class="bash">{
"dependencies": {
- "dva": "^2.6.0-beta.16",
},
"devDependencies": {
- "umi": "^2.13.0",
- "umi-types": "^0.5.9"
- "umi-plugin-react": "^1.14.10",
- "umi-plugin-ga": "^1.1.3",
- "umi-plugin-pro": "^1.0.2",
- "umi-plugin-antd-icon-config": "^1.0.2",
- "umi-plugin-antd-theme": "^1.0.1",
- "umi-plugin-pro-block": "^1.3.2",
+ "umi": "^3.0.0",
+ "@umijs/preset-react": "^1.2.2"
}
}</code></pre><p>执行<code>npm install</code>重新安装</p><p>实践过程中发现:</p><p>需要更新<code>antd@3</code>至最新版:<code>npm i antd@3.26.20</code></p><p>重新安装<code>npm i redux react-redux</code></p><p>b. 修改<code>config/config.js</code> 配置文件</p><p>原先是直接导出一个对象:</p><pre><code class="javascript">export default {
}</code></pre><p>现在改成:</p><pre><code class="javascript">import { defineConfig } from 'umi';
export default defineConfig({
})</code></pre><p>删除废弃的属性: <code>plugins</code> 和 <code>disableRedirectHoist</code></p><p>删除<code>devtool</code>的配置,使用默认配置即可</p><p>大致改成如下格式:</p><pre><code class="javascript">import { defineConfig, utils } from 'umi';
const { winPath } = utils;
export default defineConfig({
// 通过 package.json 自动挂载 umi 插件,不需再次挂载
// plugins: [],
antd: {},
dva: {
hmr: true,
},
locale: {
default: 'zh-CN',
baseNavigator: true,
},
dynamicImport: {
// 无需 level, webpackChunkName 配置
// loadingComponent: './components/PageLoading/index'
loading: '@/components/PageLoading/index',
},
// 暂时关闭
pwa: false,
lessLoader: { javascriptEnabled: true },
cssLoader: {
// 这里的 modules 可以接受 getLocalIdent
modules: {
getLocalIdent: (context, localIdentName, localName) => {
if (
context.resourcePath.includes('node_modules') ||
context.resourcePath.includes('ant.design.pro.less') ||
context.resourcePath.includes('global.less')
) {
return localName;
}
const match = context.resourcePath.match(/src(.*)/);
if (match && match[1]) {
const antdProPath = match[1].replace('.less', '');
const arr = winPath(antdProPath)
.split('/')
.map(a => a.replace(/([A-Z])/g, '-$1'))
.map(a => a.toLowerCase());
return `antd-pro${arr.join('-')}-${localName}`.replace(/--/g, '-');
}
return localName;
},
}
}
})
</code></pre><p>c. 模块导入方式改变</p><pre><code class="javascript">// 导入方式改变
- import Link from 'umi/link';
- import { connect } from 'dva';
- import { getLocale, setLocale, formatMessage } from 'umi-plugin-react/locale';
+ import {
+ Link,
+ connect,
+ getLocale,
+ setLocale,
+ formatMessage,
+ } from 'umi';
// 路由跳转方式改变
- import { router } from 'umi';
+ import { history } from 'umi';
- router.push()
+ history.push()</code></pre><p>d. 路由配置修改</p><p><code>umi2</code>中,权限路由是配置<code>Routes</code>属性。在<code>umi3</code>中,则改成了<code>wrappers</code>属性。</p><p>修改<code>config/router.config.js</code></p><pre><code class="javascript">export default [
// app
{
path: '/',
component: '../layouts/BasicLayout',
wrappers: ['../pages/Authorized'], // Routes 变成了 wrappers
routes: [],
},
];</code></pre><p>e. 重新启动项目</p><p><code>npm run start</code> 理论上,项目应该能够被<code>umi3</code>启动起来了。</p><p>如果仍然报错,则自行根据报错原因修改代码即可。</p><h2>Ant Design Pro 内置组件升级</h2><p><code>Ant Design Pro v2</code>脚手架提供的<code>Layout</code>组件,已被抽离成了一个单独的npm包<code>@ant-design/pro-layout</code>。同时官方又封装了几个常用的组件,方便快速进行业务开发,详见<a href="https://link.segmentfault.com/?enc=hsM7fvT2b3NAmJGwxWF8LA%3D%3D.%2BAZaKa0WhdU4TCQH8bdB5Dp0gsUS5dGcICla1xhJpI%2FfSj8O0a%2FqTvKARxBm2BTZ" rel="nofollow">ProComponents官网</a>。</p><p>不过我原项目中的<code>Layout</code>组件功能暂时够用,考虑到代码改动较大,因此暂时没有升级该组件。</p><h2>Ant Design 4 升级</h2><p>参考官方文档: <a href="https://link.segmentfault.com/?enc=8%2FFJxn85TQIAEZDborb4gQ%3D%3D.%2F%2FaSMUZsbJy3t7HhCwYHcXzO99gR70S%2FclTawGSIey8hTLhrW4fbsrWFMY884m36" rel="nofollow">《从 v3 到 v4》</a></p><p>a. <code>antd</code>升级到<code>3.x</code>最新版本(前面我们已经在升级<code>umi3</code>的过程升级了<code>antd</code>),按照控制台 warning 信息移除/修改相关的 API</p><p>b. 升级项目 React 16.12.0 以上 <code>npm i react@^16.12.0</code></p><p>重新运行项目,查看是否能正确运行</p><p>c. 使用命令行工具快速升级</p><p>由于<code>antd4</code>重构了大量的组件,为了兼容已有<code>antd2</code>废弃的组件(比如旧版本的<code>Form</code>),官方提供了<code>@ant-design/compatible</code>这个npm包</p><pre><code class="javascript">import { Form, Mention } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.css';</code></pre><p>官方提供了一个cli工具,可以自动转换代码的引入方式。<strong>在运行cli前,请先提交你的本地代码修改</strong>。</p><pre><code class="bash"># 进入旧项目
cd myproject
# 通过 npx 直接运行
# src 就是前端源代码目录夹
npx -p @ant-design/codemod-v4 antd4-codemod src</code></pre><p>对于无法自动修改的部分,codemod 会在命令行进行提示,建议按提示手动修改。修改后可以反复运行上述命令进行检查。</p><p>d. 升级<code>antd</code>版本</p><p><code>npm i antd@^4.0.0 @ant-design/icons@^4.0.0 @ant-design/compatible@^1.0.0</code></p><p>安装成功后,重启项目,查看页面效果。</p><h2>升级后的问题</h2><p>a. 样式问题</p><p>升级后的的历史代码,<code>Form</code>组件引用的是<code>@ant-design/compatible</code>,class类名发生了变化,从<code>ant-form</code>变成了<code>ant-legacy-form</code>。如果你的项目里对这部分的样式进行了修改,则需要手动修改类名了。</p><p>样式问题只能靠自己一个个页面去排查了。。。</p><p>b. API 问题</p><pre><code class="jsx">// 旧版
<TextArea autosize={{ minRows: 5 }} />
// 新版
<TextArea autoSize={{ minRows: 5 }} /></code></pre><p>这种api的变化,只能靠人工修改和页面报错来修改了。。。</p><p>c. antd4 自身的bug</p><p>比如 <a href="https://link.segmentfault.com/?enc=IrmmwIAP9erscmWuadUSLA%3D%3D.5gLPzX%2Fq2fOSCIxj2ivvqvnbmr0WomZjJ2OrHqUix9S4iW%2B6AAdk7ilZUKDGh7sjvmBTeje9ztczhkVQcjYsVw%3D%3D" rel="nofollow">RangePicker属性defaultPickerValue无效</a></p><p>升级有风险,挖坑需谨慎!</p><h2>总结</h2><p>此次升级的过程比我预想的要轻松很多,不过也是在项目页面不多(只有20多个页面),初期底层框架由我搭建(系统较熟悉)的前提下完成的。</p><p>前言中我有提到的那个历史遗留的巨石应用,其实已经在一个岌岌可危,即将不可维护的状态下了。即使我又引入了最新的技术栈,然而若干年后,接手的人员肯定会吐槽:<code>Antd pro 4</code> 这版本也太老了吧!</p><p>市面上这几年也提出了<a href="https://link.segmentfault.com/?enc=RP%2BgB8BK6VCTAr3jpnIcFg%3D%3D.QTFCLjHa%2Bs%2FgQBpxgpOfPepnxKT1%2FiW97xVfPI87Ryg%3D" rel="nofollow">微前端</a>的概念,相应的微前端框架<a href="https://link.segmentfault.com/?enc=xvZawvvJkCVvdvobrEOYmQ%3D%3D.8rMlklgNARSudAU6d1Txgx87CPuyCa63nKuz2nI02c716o3gRqqr0gmYebumy1RN" rel="nofollow">single-spa</a>,<a href="https://link.segmentfault.com/?enc=AgSU9jVNgAg8QjUoaiP3lA%3D%3D.dKdEtlU3j%2FPInxOyeStHSnONJmNhVY5xUepcvKdgXYx%2BvPUpmqisv0Ck2hobBYU3" rel="nofollow">qiankun</a>也应运而生。</p><p>看着手里维护的各种技术栈的代码,我想起了一句名言:</p><blockquote>世上本没有微前端,吹的人多了,也便成了KPI。老夫写代码都是jQuery一把梭! ——— 鲁迅</blockquote>
web支付基础教程
https://segmentfault.com/a/1190000022914619
2020-06-12T14:12:42+08:00
2020-06-12T14:12:42+08:00
深红
https://segmentfault.com/u/deepred5
5
<h2>前言</h2><p>由于在公司的交易支付部门<del>搬砖</del>,因此To C端的前端支付页面,基本由我这边负责</p><p>一般来说,一次正常的交易流程,用户会经过几个阶段:</p><ol><li>浏览商品列表</li><li>查看商品详情</li><li>点击购买或加入购物车</li><li>商品结算(确认购买)</li><li>收银台(进行支付)</li><li>支付成功</li></ol><p>其中<strong>收银台</strong>作为交易成功的最后一公里,其承担的职责之重可想而知</p><p>我们先来看看市面上常见网站的收银台:</p><p>哔哩哔哩会员购:</p><p>触屏端</p><p><img src="/img/remote/1460000022914623" alt="" title=""></p><p>pc端</p><p><img src="/img/remote/1460000022914622" alt="" title=""></p><p>app端</p><p><img src="/img/remote/1460000022914624" alt="" title=""></p><p>慕课网:</p><p>触屏端</p><p><img src="/img/remote/1460000022914625" alt="" title=""></p><p>pc端</p><p><img src="/img/remote/1460000022914626" alt="" title=""></p><p>app端</p><p><img src="/img/remote/1460000022914627" alt="" title=""></p><p>可以看出,收银台页面一般要适配3个终端:pc端,触屏端,app端。因此,主流的第三方支付平台(微信,支付宝,花呗分期,京东白条)也需要能支持这三种场景的支付</p><p>接下来,我们就来分析下不同支付渠道在不同终端下,支付的实现方式</p><p>由于支付涉及部门核心业务,因此就不拿公司线上的收银台做讲解了。支付交互流程,主要参考<strong>哔哩哔哩会员购</strong>和<strong>慕课网</strong>(<del>没有利益相关</del>)</p><p>注意:本文只考虑前端支付业务的实现,后端支付业务的实现,暂不考虑,有兴趣可以参考<a href="https://link.segmentfault.com/?enc=GkiR%2FQ2sRJlDAOHKeb08Og%3D%3D.w0x32QBVmjQCcSx1YWm1oZRVgBqcWJeTZN5%2BD8ehdlzs217O%2F2sPnJJ9oNwlStLf2PMGOWYiF5ln4InHaUaVZQ%3D%3D" rel="nofollow">《支付渠道路由系统进化史》</a></p><h2>支付宝(花呗分期)</h2><p><a href="https://link.segmentfault.com/?enc=8lxrkETkBCJ3OOirCBV7HQ%3D%3D.dPfpMZicQDzHeEZtkdeCxuVOBxaWPMejboJIqPfveQ%2FI%2FuJy2v8P62oJ6TDSL9bM" rel="nofollow">支付宝开发文档</a></p><p>花呗分期其实就是支付宝的拓展,原理基本一致,就不重复累赘</p><h3>pc端</h3><p>交互方式1:</p><p>在pc端点击支付宝支付,网页新打开一个页面(<code>window.open()</code>),这个页面指向的是支付宝官方收银台页面</p><p><img src="/img/remote/1460000022914628" alt="" title=""></p><p>交互方式2:</p><p>在pc端点击支付宝支付,网页展示一个二维码,需要用户打开支付宝app进行扫码支付</p><p><img src="/img/remote/1460000022914629" alt="" title=""></p><p>b站提供了一个很巧妙的思路:把微信,支付宝,qq三个支付二维码统一成了一个二维码。(原理后面会<a href="#JSAPI">讲解</a>,本质是调用不同容器的<code>JSAPI</code>)</p><p>两种交互方式,点击支付按钮时,其实都是把当前的订单号以及一些相关信息发给后端</p><pre><code class="javascript">const payNum = '123abc';
ajax({
url: '/api/alipay', // 支付api
type: 'POST',
data: {
payNum: payNum, // 订单号
other: 'demo', // 其他参数
}).then((res) => {
const { payUrl } = res;
// 交互方法1:
// payUrl如果是支付宝的收银台,则新打开一个页面
// payUrl一般是 https://mapi.alipay.com/gateway.do 这种,一般会带上return_url参数和其他各种数据,页面最后被重定向到支付宝收银台
window.open(payUrl);
// 交互方式2:
// payUrl如果是支付宝的扫码地址,则创建一个二维码弹窗
// payUrl一般是 https://qr.alipay.com/bax06893swswc4inaknv505d 这种,页面最后被重定向到支付宝收银台,该收银台可以唤起支付宝app
qrcode({
width: 175,
height: 175,
url: payUrl
});
}).catch((err) => {
console.log('提交失败')
})</code></pre><p>由于支付是异步进行的,所以需要前端去查询该笔订单是否支付成功</p><p>对于交互方式1,由于支付页面已经转移到支付宝收银台,所以在支付宝收银台支付成功后,支付宝收银台会自动跳转回<code>return_url</code>(<code>return_url</code>是我们当初跳到支付宝收银台时带上的参数,一般指向支付成功页)。</p><p>不过由于我们使用的是<code>window.open</code>打开的新页面,所以当用户回到我们的收银台时,我们需要打开一个弹框,主动询问用户是否支付成功。如果用户点击了支付完成,我们需要查询该笔订单是否真正支付成功。</p><p><img src="/img/remote/1460000022914630" alt="" title=""></p><pre><code class="javascript">// 打开支付宝收银台
window.open(payUrl);
// 在当前页面打开弹窗,询问用户是否支付成功
createFinishWindow()</code></pre><p>对于交互方式2,由于仍然是在当前页进行扫码支付,因此创建二维码弹窗后,我们马上就要轮询进行查询订单状态</p><pre><code class="javascript">const payNum = '123abc'
// 创建一个二维码弹窗
qrcode({
width: 175,
height: 175,
url: payUrl
});
// 轮询查询订单状态
function getPayStatus() {
ajax({
url: '/api/getPayStatus', // 支付状态api
data: {
payNum: payNum, // 订单号
other: 'demo', // 其他参数
},
type: 'POST'
).then((res) => {
if (res.payStatus === 0) {
// 支付成功,跳到成功页
window.location.href = `/success/${payNum}`;
clearTimeout(statusTimeId);
} else {
// 还未支付,继续轮询
statusTimeId = setTimeout(getPayStatus, 3000);
}
}).catch((err) => {
// 接口报错,继续轮询
statusTimeId = setTimeout(getPayStatus, 3000);
})
}
let statusTimeId = setTimeout(getPayStatus, 3000);</code></pre><h3>触屏端</h3><p>交互方式:</p><p>在触屏端点击支付宝支付,页面直接跳转到支付宝收银台,该页面会尝试唤起手机上的支付宝app</p><p><img src="/img/remote/1460000022914632" alt="" title=""></p><p>其实触屏端原理和pc端基本一样,只不过在触屏端,有可能需要自己拼装一个form表单,而不是直接跳到链接(当然主要看后端的实现)</p><pre><code class="javascript">const payNum = '123abc';
// 模拟表单提交
function formSubmit(formData, url) {
const form = $('<form method="post" target="_self"></form>');
form.attr('action', url);
let input;
$.each(formData, function (i, v) {
input = $('<input type="hidden">');
input.attr("name", i);
input.attr("value", v);
form.append(input);
});
$(document.body).append(form);
form.submit();
form.remove();
}
ajax({
url: '/api/alipay', // 支付api
type: 'POST',
data: {
payNum: payNum, // 订单号
other: 'demo', // 其他参数
}).then((res) => {
const { formData, url } = res;
if (formData) {
// 需要前端自己构建表单
formSubmit(formData, url)
} else {
// 直接跳转链接(后端已经拼装好表单)
window.location.href = url;
}
}).catch((err) => {
console.log('提交失败')
})</code></pre><p>支付成功后,同理支付宝会跳转到<code>return_url</code>的地址</p><p>需要注意:在微信浏览器里,支付宝是不能被唤起的(<del>微信生态圈日常封杀</del>)</p><p>解决方法:</p><p>方法一:微信环境隐藏支付宝入口</p><p>方法二:微信环境,点击支付宝支付,引导用户使用其他浏览器打开页面</p><h3>JSAPI</h3><p>如果我们能诱导用户使用支付宝客户端的<code>扫一扫</code>打开我们触屏端的收银台页面,那么其实我们也可以使用支付宝提供的<code>JSAPI</code>唤起收银台</p><p><strong>这也是b站实现微信,支付宝,qq同一个二维码都能付款的原理,这三个客户端都提供了自己的<code>JSAPI</code>,用户用不同的客户端扫码,都会进入同一个页面(b站实现),这个中转页根据容器环境,调用不同<code>JSAPI</code>的支付功能</strong></p><p><a href="https://link.segmentfault.com/?enc=f5XMKr0O%2FjN8aU45Ybr5yw%3D%3D.gZHP4NCDCw%2BesAozhG8AHmNlSoR84XSldLhXMWEuds3Zm3G4auGS0naUQajGrkow" rel="nofollow">支付宝H5开放文档</a></p><p>关于<code>jsbridge</code>的知识,可以查看我之前的文章<a href="https://link.segmentfault.com/?enc=EaIjAYsCI5OjyQ5dIGJdjg%3D%3D.0UksbW6saBpqpP54agstvpOKSGWrh%2FRmOTps7Lzb6xZtesqnEHeQzHIPHuX87alOpiPhZz%2FLRSJdm2ETfOTx9A%3D%3D" rel="nofollow">jsbridge初探</a></p><p><code>JSAPI</code>的简单示例</p><pre><code class="javascript">function ready(callback) {
// 如果jsbridge已经注入则直接调用
if (window.AlipayJSBridge) {
callback && callback();
} else {
// 如果没有注入则监听注入的事件
document.addEventListener('AlipayJSBridgeReady', callback, false);
}
}
ready(function () {
// 显示一个提示框
AlipayJSBridge.call('toast', {
content: 'hello'
});
});</code></pre><p>唤起收银台需要使用<a href="https://link.segmentfault.com/?enc=7ansgm8KKc3HJRtHTvGf5Q%3D%3D.e52dm6KB9cOCA%2B2SRwj0WWJfAC5sIT86ip0A4IVlhH8aWfM3yOcPMhLdX8%2Bo%2FbCK32vhO%2FG0UCMzoAxCoFUrxA%3D%3D" rel="nofollow">Alipay JSSDK</a></p><pre><code class="html"><script src="https://gw.alipayobjects.com/as/g/h5-lib/alipayjsapi/3.1.1/alipayjsapi.inc.min.js"></script>
<button id="J_btn" class="btn btn-default">支付</button>
<script>
var btn = document.querySelector('#J_btn');
btn.addEventListener('click', function(){
ap.tradePay({
tradeNO: '201802282100100427058809844'
}, function(res){
ap.alert(res.resultCode);
});
});
</script></code></pre><h3>app端</h3><p>现在的app基本都是<code>Hybrid App</code>,如果在app端,你的收银台页面不是原生实现的,那么就可以直接使用webview加载触屏端的线上收银台即可</p><blockquote>手机网站支付产品不建议在APP端使用</blockquote><p>这是支付宝官网文档建议的,因此如果你希望得到最佳的支付体验,建议客户端的开发同学接入支付宝SDK,当然这部分已经超出了前端的范围</p><p>不过一般在app端中,我们仍然使用webview加载触屏端的前端页面,只不过在app中,我们的前端代码,通过<code>jsbridge</code>,调用客户端的支付方法即可</p><pre><code class="javascript">const payNum = '123abc';
// 支付回调函数
window.ali_pay_callback = function(res) {
if (res.status === 0) {
// 支付成功
} else {
// 支付失败
}
}
// APPSDK是webview注入的全局对象,可以调用原生方法
APPSDK.invoke('ali_pay', {
payNum: payNum, // 订单号
other: 'demo', // 其他参数
}, 'ali_pay_callback');</code></pre><h3>小程序</h3><p><a href="https://link.segmentfault.com/?enc=p3OZjBWstXsZsgpkQXyM6g%3D%3D.Ml3FI3eJNWmLE92MC6v0sKw%2BZKqGZsPzYPEsFQNX%2F3hcV1v8YZXyTyKSly54RtP5juUwXXwRtFV09xrLwEcSpA%3D%3D" rel="nofollow">小程序唤起支付文档</a></p><p>小程序支付和APP支付的支付流程与体验基本一致,可以在当前页面唤起支付宝收银台</p><pre><code class="javascript">const payNum = '123abc';
my.request({
url: 'https://demo.com/api/alipay',// 须加httpRequest域白名单
method: 'POST',
data: { // data里的key、value是开发者自定义的
from: '支付宝',
payNum: payNum, // 订单号
other: 'demo', // 其他参数
},
dataType: 'json',
success: function(res) {
// 唤起收银台
my.tradePay({
// 调用统一收单交易创建接口(alipay.trade.create),获得返回字段支付宝交易号trade_no
tradeNO: res.tradeNO,
success: (res) => {
my.alert({
content: JSON.stringify(res),
});
},
fail: (res) => {
my.alert({
content: JSON.stringify(res),
});
}
});
},
fail: function(res) {
my.alert({content: 'fail'});
},
complete: function(res) {
my.hideLoading();
my.alert({content: 'complete'});
}
});
</code></pre><h3>支付宝支付小结</h3><p>我们从pc端,触屏端,app端三个方面了解了支付宝支付的基本原理。可以看出:支付的前端实现,其实并不复杂,而真正的难点在于后端支付系统的实现。至于最难的支付宝唤起问题,其实支付宝收银台自身已经实现了唤起功能,无需我们实现</p><h2>微信</h2><p><a href="https://link.segmentfault.com/?enc=7nOBbGgfWFfAzhylc4z2ww%3D%3D.lBy%2BAxumSSwIz6HNjUwxUISDCgpUhDZWZKHir0wpMVHBpLFD3Z5brzj8vCJ1tr37SEuVbr9fbpBXDHFXw5cxkseOuBu%2BnXTxuYu5PUbfYBg%3D" rel="nofollow">微信支付开发文档</a></p><h3>pc端</h3><p><a href="https://link.segmentfault.com/?enc=EQPR3dWSgmE3u37aa4prSg%3D%3D.caJOLM92KVu9hS6WVoQZBC4Jhdl4wZKFh1bZkAoGEGvtY7BG6ed9w%2FS9%2F0OATRKirBj17TXGzbd4d7LnbejZ5Q%3D%3D" rel="nofollow">扫码支付文档</a></p><p>交互方式:</p><p>由于微信并没有像支付宝提供了pc端的官方收银台,所以点击微信支付,我们一般都是直接弹出二维码弹窗,要求用户进行扫码支付,用户扫码则可以直接唤起微信支付。弹出二维码的同时,我们需要立即轮询查询支付状态。</p><p><img src="/img/remote/1460000022914633" alt="" title=""></p><pre><code class="javascript">const payNum = '123abc';
ajax({
url: '/api/weixinpay', // 支付api
type: 'POST',
data: {
payNum: payNum, // 订单号
other: 'demo', // 其他参数
}).then((res) => {
const { qrUrl } = res;
// qrUrl是微信的扫码地址,一般是 weixin://wxpay/bizpayurl?pr=P1oi4x6 ,这段schema通过微信扫一扫可以唤起微信支付
qrcode({
width: 175,
height: 175,
url: qrUrl
});
// 开始轮询支付结果
// 代码省略,可以参考之前的支付宝pc端实现
}).catch((err) => {
console.log('提交失败')
})</code></pre><h3>触屏端</h3><p><a href="https://link.segmentfault.com/?enc=0Zto7MoIE1v4opWvFajdCw%3D%3D.Kdv%2BBlbGYdneNR5hpM4257gTvZTFClvJIOmq%2B1qtcnA19YnOF9j%2FXncOTLT4VOlL0yt9m%2BEee1vy0C3%2BI5cfqg%3D%3D" rel="nofollow">H5支付文档</a></p><p>交互方式:</p><p>在触屏端点击微信支付,页面直接跳转到微信支付中间页,该页面会尝试唤起微信支付</p><p>与支付宝收银台不同的是,微信支付中间页在调起微信收银台后超过5秒,会自动跳转回<code>redirect_url</code>,因此无法保证页面回跳时,支付流程已结束,所以商户设置的<code>redirect_url</code>地址不能自动执行查单操作,应让用户去点击按钮触发查单操作</p><p><img src="/img/remote/1460000022914631" alt="" title=""></p><pre><code class="javascript">// 代码省略,基本和支付宝的触屏端一样
// 微信支付中转页一般是这种格式的url地址
// https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx111408048537349a5434e53d1930739300&package=1982317760&redirect_url=https://m.imooc.com/myorder</code></pre><p>需要注意:微信支付中转页一般不能直接用浏览器访问,因为中转页需要判断<code>referer</code>是否是商户申请H5时提交的授权域名。如果你直接用浏览器访问,<code>referer</code>为空,导致页面并不会加载成功。如果是APP里调起H5支付,需要在webview中手动设置<code>referer</code></p><p>还有一种取巧的方法,我们可以不使用微信中转页,直接在当前页唤起支付</p><pre><code class="javascript">// 后端直接返回一段schema
const schema = `weixin://wap/pay?appid%3Dwxd6841de60b02faef%26noncestr%3D095525b24fc94111a3663068c8dc8a90%26package%3DWAP%26prepayid%3Dwx091027118037832f961440d31092022500%26sign%3D2CF5A14607C6AAEDE382758CA87B973F%26timestamp%3D1591669631`
// 移动端就能唤起微信支付
window.location.href = schema;</code></pre><p>不过这种方法,<code>schema</code>容易被第三方app的<code>webveiw</code>拦截,从而调起支付失败。比如在微博访问收银台,如果使用该方法,就会唤起微信失败。因此,还是建议使用微信中转页,由中转页唤起微信比较保险。当然,支付宝里不管用啥方法,都无法进行微信支付(<del>相爱相杀</del>)。</p><h3>JSAPI</h3><p>如果我们的收银台页面是在微信浏览器里打开的,那么我们可以使用微信提供的<code>JSAPI</code>唤起支付</p><p><code>JSAPI</code> 支付又称公众号支付</p><p><a href="https://link.segmentfault.com/?enc=3t46viI3dP3JTKR%2Fcj%2F6bQ%3D%3D.KROrFSSnihxtt2XY0sKCCLK5WCz1cGwoc5NOBOFVe%2FCUH4V3IcIamXKZXJhoj90w3Hxhx0In5CU7KCaDSUPz8Ym38xlHwzQH5t9KFJ%2BXGZ4%3D" rel="nofollow">JSAPI支付文档</a></p><pre><code class="javascript">const payNum = '123abc';
function onBridgeReady(wxJsApiParam) {
window.WeixinJSBridge.invoke(
'getBrandWCPayRequest',
wxJsApiParam,//josn串
function (res) {
if (res.err_msg == "get_brand_wcpay_request:ok") {
// 支付成功
location.href = `/success/${payNum}`;
} else if (res.err_msg == "get_brand_wcpay_request:fail") {
// 支付失败
}
}
);
}
function weixinPay(wxJsApiParam) {
if (typeof WeixinJSBridge == "undefined") {
if (document.addEventListener) {
document.addEventListener('WeixinJSBridgeReady', function () { onBridgeReady(wxJsApiParam) }, false);
} else if (document.attachEvent) {
document.attachEvent('WeixinJSBridgeReady', function () { onBridgeReady(wxJsApiParam) });
document.attachEvent('onWeixinJSBridgeReady', function () { onBridgeReady(wxJsApiParam) });
}
} else {
onBridgeReady(wxJsApiParam);
}
}
ajax({
url: '/api/weixin_jsapi', // 支付api
type: 'POST',
data: {
payNum: payNum, // 订单号
other: 'demo', // 其他参数
}).then((res) => {
const { jsapiData } = res;
// jsapiData是一串json字符串,里面包含了appId,paySign等各种数据,用来调起微信支付
weixinPay(JSON.parse(jsapiData));
}).catch((err) => {
console.log('提交失败')
})</code></pre><p>使用<code>JSAPI</code>需要我们有微信公众平台,因为下单必传的参数<code>openid</code>,需要我们在公众平台设置获取openid的域名,才能获取成功</p><p>除了使用微信浏览器内置的<code>WeixinJSBridge</code>对象,我们也可以使用<a href="https://link.segmentfault.com/?enc=ig%2BZu0hYzXkm46WRgjj4GQ%3D%3D.%2B8HzlZ4nQ%2B1YIL%2B3KurkjKANm626A60jVPOL5RQ4%2Bxd6%2BLluiWLRn%2BSfCErcKVe7Hrtx3YIHlxh5kmLQrq5Z4yBmY8o%2BHNhb26Gke54cBrA%3D" rel="nofollow">JSSDK</a></p><pre><code class="html"><script src="http://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
wx.chooseWXPay({
timestamp: 0,
nonceStr: '',
package: '',
signType: '',
paySign: '',
success: function (res) {
// 支付成功后的回调函数
}
});
</script></code></pre><p><strong>2020.11.15 更新</strong><br>由于微信推出了一个所谓的<a href="https://link.segmentfault.com/?enc=70kr1Dz4jjxb6q1HIcQoAQ%3D%3D.fNceJUk%2FN3f2TU95yQLwHKFsG%2BcMy2r7cIWnf3GupLYd2M5YhDhIJNCTkPys9viPJ1mvZ7g3tNzDOX%2FBVyo3lTvJyhIKMNs3vH1sq1O%2BVNE%3D" rel="nofollow">《点金计划》</a>,因此如果你的<code>JSAPI</code>后端对接的不是微信直连渠道,而是第三方支付渠道(比如<a href="https://link.segmentfault.com/?enc=wmvOUiDb2St%2FoPJPVeAqwQ%3D%3D.RxBFNv%2B51YspcFbY0rFYr7UrTRyunCfLST7lfCi61I0%3D" rel="nofollow">易宝支付</a>),那么支付成功后,不会调用回调函数,而是直接进入微信的官方小票页面(取决于你的服务商有没有给你打开点金计划,若没有则直接关闭支付页)</p><p>更多详情请见<a href="https://link.segmentfault.com/?enc=ptN6UkOOquXCm9RIiBnfKw%3D%3D.ckP4Fah4LvdsV%2FBSXKOJcdjcyXu%2FQfcmRZOz54ypJVN3kgqfO5RIw4%2BjtC4q4tHpjCH3Zhcc9q7BH5N2C2sDamTFw2d7t%2B0VRNLQCfmxQaUfVqCVCDSvO6G3QgUjW1rB" rel="nofollow">《微信点金计划商家小票对接踩坑记录》</a></p><h3>app端</h3><p><a href="https://link.segmentfault.com/?enc=tjhTpSFeJVwANzzrBvN2iQ%3D%3D.Nlqmlr6wNpqiF9D9bepCH0sOlYt4iT6u2ztaiiXBtjlRNY02gAEr6NHjbLe2ZUu2UJM2vhLU%2Bxxq7zE1MFYrWw%3D%3D" rel="nofollow">APP支付文档</a></p><blockquote>H5支付不建议在APP端使用,如需要在APP中使用微信支付,请接APP支付</blockquote><p>微信官方文档同样不建议在APP端使用触屏端的支付方式,因此最好接入微信SDK。前端同样可以使用<code>jsbridge</code>调用客户端的微信支付方法,可以参考前面支付宝的<code>app端</code>方式。</p><h3>小程序</h3><p><a href="https://link.segmentfault.com/?enc=OPjJk4AN1C1yA3oiJPXKvg%3D%3D.4L9bCg0btRQdxh2%2FeTijJOvnacPF4CTdF85VULXXj%2FXP1SckVek7peTENyOBZeb2z9kxq0lHMEOFqD1YR0SW4f2v%2B7XmX8vsp5YAbSPPqgs%3D" rel="nofollow">小程序支付文档</a></p><p>小程序支付其实和微信<code>JSAPI</code>支付非常类似,都需要先获取到<code>Openid</code>,调用相同的API</p><pre><code class="javascript">wx.requestPayment({
timeStamp: '',
nonceStr: '',
package: '',
signType: 'MD5',
paySign: '',
success (res) { },
fail (res) { }
})</code></pre><h3>微信支付小结</h3><p>微信支付在<code>JSAPI</code>和小程序的流程上比较复杂些,因为涉及到公众号<code>access_token</code> <code>openid</code> 等一系列权限的获取。不过总的来说,复杂难度主要还是在后端方面。</p><h2>其他第三方支付</h2><p>除了主流的微信支付和支付宝支付,我们有可能还需要对接一些其他第三方支付平台,比如:QQ,PayPal, 银联,京东白条,各大银行等等,当然原理也是大同小异。</p><p>同时,我们也可以使用第三方聚合支付平台,比如<a href="https://link.segmentfault.com/?enc=aRVltjzt89s9OKJY6IODZw%3D%3D.PsyLdF3tCtckGUEGq7oOTyBS0sokXfR6nXJLXcnkYWrPikAM4ZJ7AQtJZK85G4Db" rel="nofollow">度小满支付</a>,这些平台已经集成好了各大银行信用卡和存储卡支付功能,我们可以很容易的接入sdk,节约开发成本。</p><h2>总结</h2><p>web支付由于开发条件要求很高(至少要有注册公司),因此大部分同学日常工作接触并不是很多。当然本文也仅仅是回顾了下日常开发中,前端在web支付中的一些常见套路。</p><p>真正实际项目里,我们仍然会面临很多问题和难点,这时就需要我们见招拆招了。</p><h2>参考</h2><ol><li><a href="https://link.segmentfault.com/?enc=ZBVOlK8zC1XaLlLcuxozrg%3D%3D.0XLcnYLfRwGDxsY9jobwFLr6WGBMhRbh0h3eCuGLk4Fa74TNwNXUQvzODYGVTBNN" rel="nofollow">web开发中的支付宝支付和微信支付</a></li><li><a href="https://link.segmentfault.com/?enc=cPxiGJoTENiY%2BwDYhxaMJA%3D%3D.tJwriEtIKyxrRc03OycVf3z6kkbrgzDbR3eL8YjMWyzzcV9C19KehaHiyHQf3vJGHwt3dtn2XQi%2FnvHO8grVkigWF0v9O4GZe%2FZBef%2B1CVI%3D" rel="nofollow">微信支付文档</a></li><li><a href="https://link.segmentfault.com/?enc=dZAP6uv1BA%2Bf%2FJtLMnBh8g%3D%3D.jnMs1NXFLStjq6GtHwxZNs3zBJzFEaoTuI3NQ9FI3lACT8w8E%2F5qhSFlp56jJ7WC" rel="nofollow">支付宝文档</a></li></ol><h2>拓展</h2><p>有读者提起了<a href="https://link.segmentfault.com/?enc=K45CIivqfhXfZFqziiK5uw%3D%3D.gZLTIFdris9x9ireKSSyDW1MR3S7%2FSZyBtS0Nq7S3PNPEcquUyfFlHUQScX5pRC4RJ0Au97KKbUEmEvlwQAIrt4v6Bcj%2Fkntj%2BiyNocYJJc%3D" rel="nofollow">Payment Request API</a>这个W3C提供的原生支付api。</p><blockquote>Payment Request API 是一个旨在消灭结账表单的系统。它显著改进了购物流程期间的用户工作流,为用户提供更一致的体验,并让电商公司能够轻松地利用各种完全不同的支付方式。</blockquote><p>这个api可以唤起浏览器自带的结算支付页面(原生UI)</p><p><img src="/img/remote/1460000022933748" alt="" title=""></p><p>然而,<code>PaymentRequest</code>在Chrome中仅支持以下标准信用卡:<code>amex</code>、<code>diners</code>、<code>discover</code>、<code>jcb</code>、<code>maestro</code>、<code>mastercard</code>、<code>unionpay</code>和<code>visa</code>。因此这个API至少在国内来说,其实并不实用。</p><p>如果有兴趣,可以查看详细教程<a href="https://link.segmentfault.com/?enc=%2FznrHHbAJPaEj8y4XPhDgQ%3D%3D.PskiOyLsvlT1l1Qstky41YRBq%2F%2BpQ2L9CUeNKJnQzEXfdFoyap5D8hJC12w07tbOtv529c0huyDgeNE7gd8mOA%3D%3D" rel="nofollow">《Payment Request API:集成指南》</a></p>
SameSite小识
https://segmentfault.com/a/1190000022210375
2020-03-31T10:32:07+08:00
2020-03-31T10:32:07+08:00
深红
https://segmentfault.com/u/deepred5
3
<p>如果你最近有关注过chrome的控制台,可能会发现经常报一些warning:</p>
<blockquote>A cookie associated with a cross-site resource at <a href="https://link.segmentfault.com/?enc=rW5NMboHpyi0pWNFs9r3SQ%3D%3D.AsrErpKxstgSK%2BYnKpglWbvq%2BhpxgtZMDWaIRFuHfhY%3D" rel="nofollow">http://baidu.com/</a> was set without the <code>SameSite</code> attribute. A future release of Chrome will only deliver cookies with cross-site requests if they are set with <code>SameSite=None</code> and <code>Secure</code>.</blockquote>
<p><img src="/img/remote/1460000022210378" alt="" title=""></p>
<p>出现这个警告的原因是:chrome在80版本之后,更新了cookies的携带机制,把原来Cookie的<code>SameSite</code>属性值,由<code>None</code>改成了<code>Lax</code>,这就会导致一些需要第三方cookie的应用产生了异常。</p>
<p>在介绍<code>SameSite</code>属性之前,我们先来复习一下cookie的基础知识</p>
<h3>Cookie基础</h3>
<p>Cookie常见的属性:</p>
<ul>
<li>Name: cookie名。</li>
<li>Value: cookie值。</li>
<li>Domain: cookie的域。如果设成<code>.deepred.com</code>,那么<code>a.deepred.com</code>和<code>b.deepred.com</code>域名下,都可以使用<code>.deepred.com</code>的cookie。</li>
<li>Path: cookie的路径。请求资源的路径一定要包含这个path才能携带cookie。一般设置成<code>/</code>即可。</li>
<li>Expires/Max-Age: cookie过期时间。默认不设置,则是<code>Session</code>会话,关闭页面后,该cookie立即失效。</li>
<li>HttpOnly: 设成<code>true</code>后,JS使用<code>document.cookie</code>则访问不到。常用于避免XSS攻击。</li>
<li>Secure: 标记为Secure的cookie只应通过被HTTPS协议加密过的请求发送给服务端。</li>
<li>SameSite: 用来限制第三方Cookie</li>
</ul>
<p><strong>最后一个属性非常重要,也就是我们即将要说的<code>SameSite</code>了。</strong></p>
<h3>Cookie携带的场景</h3>
<p>我们假设有一个名字为<code>sessionId</code>的<code>cookie</code>,<code>domain</code>设置成了<code>.demo.com</code>。</p>
<p>1.在<code>a.demo.com</code>域名下,ajax在该域名下的所有请求,都会自动带上<code>sessionId</code></p>
<pre><code class="javascript">ajax.get('/api/data') // 自动带上sessionId</code></pre>
<p>2.在<code>b.demo.com</code>域名下,ajax在该域名下的所有请求,都会自动带上<code>sessionId</code></p>
<pre><code class="javascript">ajax.post('/api2/data2') // 自动带上sessionId</code></pre>
<p>3.在<code>b.demo.com</code>域名下,ajax请求<code>a.demo.com</code>的api,需要设置<code>withCredentials</code>才能带上<code>sessionId</code></p>
<pre><code class="javascript">ajax.get('https://a.demo.com/api/data') // 不能自动带上sessionId
ajax.get('https://a.demo.com/api/data', {withCredentials: true}) // 自动带上sessionId
</code></pre>
<p><strong>注意一下:<code>https://a.demo.com/api/data</code>需要支持cors跨域,并且<code>Access-Control-Allow-Origin</code>不能设成<code>*</code>,要设置成<code>https://b.demo.com</code>,只有这样,<code>withCredentials</code>才有用</strong></p>
<pre><code class="javascript">router.get('/api/data', (ctx, next) => {
ctx.set('Access-Control-Allow-Origin', ctx.headers.origin);
ctx.set('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , myheader');
ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
ctx.set('Access-Control-Allow-Credentials', 'true');
};</code></pre>
<p>4.在<code>b.demo.com</code>域名下,使用<code>iframe</code>加载<code>a.demo.com</code>,会自动带上<code>sessionId</code></p>
<p><strong><code>a.demo.com</code>和<code>b.demo.com</code>同属一个域名下的子域名(同站)</strong></p>
<p>5.在<code>a.demo2.com</code>域名下,ajax请求<code>a.demo.com</code>的api,需要设置<code>withCredentials</code>才能带上<code>sessionId</code></p>
<pre><code class="javascript">ajax.get('https://a.demo.com/api/data') // 不能自动带上sessionId
ajax.get('https://a.demo.com/api/data', {withCredentials: true}) // 自动带上sessionId</code></pre>
<p>6.在<code>a.demo2.com</code>域名下,使用<code>iframe</code>加载<code>a.demo.com</code>,会自动带上<code>sessionId</code></p>
<p><strong><code>a.demo.com</code>和<code>a.demo2.com</code>属于完全不相干的两个网站(跨站)</strong></p>
<p>目前为止,都是我们所熟知的cookie携带场景。</p>
<p>然而,在chrome 80版本之后,谷歌把cookie的<code>SameSite</code>属性,从<code>None</code>改成了<code>Lax</code>。<strong>这时候,会导致第5和第6种场景,<code>sessionId</code>由于跨站会丢失!</strong></p>
<pre><code>**跨站解释**
`a.demo.com`和`b.demo.com`属于同站,`a.demo.com`和`a.demo2.com`属于跨站
注意和`跨域`做比较: `a.demo.com`和`b.demo.com`属于跨域</code></pre>
<h3>SameSite</h3>
<p>cookie的<code>SameSite</code>属性用来限制第三方Cookie,从而减少安全风险(防止CSRF)</p>
<p><code>SameSite</code>可以有下面三种值:</p>
<ol>
<li>
<code>Strict</code>仅允许一方请求携带Cookie,即浏览器将只发送相同站点请求的Cookie,即当前网页URL与请求目标URL完全一致。</li>
<li>
<code>Lax</code>允许部分第三方请求携带Cookie</li>
<li>
<code>None</code>无论是否跨站都会发送Cookie</li>
</ol>
<p><img src="/img/remote/1460000022210379" alt="" title=""></p>
<p>从上图可以看出,<code>SameSite</code>从<code>None</code>改成了<code>Lax</code>后,<code>Form</code>,<code>Iframe</code>,<code>Ajax</code>和<code>Image</code>中跨站的请求受到的影响最大。</p>
<h3>解决方法</h3>
<p>解决方法也很简单粗暴:强行把<code>SameSite</code>设置成<code>None</code>。不过需要特别注意几点:</p>
<p>1.<code>SameSite</code>设置成<code>None</code>后,Cookie就必须同时加上<code>Secure</code>属性</p>
<pre><code class="javascript">ctx.cookies.set('sessionId', {
maxAge: 1000 * 60 * 60,
secure: true,
sameSite: 'none',
});</code></pre>
<p>这也意味着,你的网站需要支持<code>https</code>!(<code>Lax</code>和<code>Strict</code>不需要支持https)</p>
<p>如果线上的网站同时支持<code>http</code>和<code>https</code>,你可能需要让运维将<code>http</code>强制重定向到<code>https</code>(建议使用307状态码而不是302状态码)</p>
<p>2.部分浏览器不能加<code>SameSite=none</code>,比如IOS 12的Safari,以及一些老版本的chrome浏览器,它们会错误的把<code>SameSite=none</code>识别成<code>SameSite=strict</code>。</p>
<p><strong>具体不兼容的浏览器可以见<a href="https://link.segmentfault.com/?enc=hrx%2BX%2BqBwmdZt3fCxdZeew%3D%3D.BatLZ%2BUKdnEv3Kk3LznK1o4rjkuNwm587%2BYJUIsSM97IEMGGSs0tz%2Bymz4ISmbO1wKq6xvaenCLV%2FPQixn07HA%3D%3D" rel="nofollow">这里</a></strong> </p>
<p>因此后端要根据<code>UA</code>来判断是否加上<code>SameSite=none</code></p>
<h3>参考</h3>
<ul>
<li><a href="https://segmentfault.com/a/1190000022055666">预测最近面试会考 Cookie 的 SameSite 属性</a></li>
<li><a href="https://link.segmentfault.com/?enc=VnI1ocNfATSoKkVUTnz9%2Fg%3D%3D.zQvub7u%2B6Chsfmh67IZltCkAQdb1%2BOtf582qCqAlvtBfv2uhrXaYKA%2FXwX3nztgrJ8S7yyXfI9SVMmouNnJawQ%3D%3D" rel="nofollow">Chrome 80.0中将SameSite的默认值设为Lax,对现有的Cookie使用有什么影响?</a></li>
<li><a href="https://link.segmentfault.com/?enc=4wFORU%2BsDBq%2BA4B%2BNIyShw%3D%3D.5worsJczZbbS8nSUlS8itxssOA8gdTNci637a7PHY8thDHyVqkUJ5ckl5BmdGEdhmk1EwxKrw%2B%2Fdr1pkhnOEFQ%3D%3D" rel="nofollow">Cookie 的 SameSite 属性</a></li>
</ul>
jsbridge初探
https://segmentfault.com/a/1190000021919586
2020-03-06T09:00:00+08:00
2020-03-06T09:00:00+08:00
深红
https://segmentfault.com/u/deepred5
2
<p><code>jsbridge</code>是随着<code>Hybrid App</code>的流行而产生的一种技术。那么<code>Hybrid App</code>是啥?<code>Hybrid App</code>又称<code>混合App</code>,即同时使用了前端web技术(js,css,html)和原生native技术(java,kotlin,swfit,object-c)进行开发的移动应用。</p>
<h3>混合开发的优缺点</h3>
<ul>
<li>优点:开发快,易更新,开发周期短,跨平台</li>
<li>缺点:性能问题,兼容性问题</li>
</ul>
<h3>常见的混合开发框架</h3>
<ul>
<li>webview渲染:Cordova,uni-app</li>
<li>原生渲染:React Native,Weex,Flutter</li>
<li>混合渲染:小程序</li>
</ul>
<h3>jsbridge</h3>
<p>现在很多App的页面,不一定都是原生实现的,可能是通过webview直接加载一个线上的h5站点。比如打开某<del>粉红App</del>的会员购页面,其实就是个<a href="https://link.segmentfault.com/?enc=bdzgI5EDqo8VcMaONoCMDA%3D%3D.QD0EnkGS5h%2Fg7J6GEt1EOfApukGcBBjli6LylgvYiNrBcZ3edaOT2Sl1d02PKnQzcPVrKK5KGETdjqCQU9cXNw%3D%3D" rel="nofollow">移动端</a>的网站。</p>
<p><img src="/img/remote/1460000021919589" alt="" title=""></p>
<p>这么一说,好像和混合开发也没啥联系。不过你仔细看下页面的右上角,会发现有个分享按钮:点击分享图标,可以把当前页面分享到第三方平台,分享后,web页面需要知道是否分享成功。</p>
<p>这里就涉及了native端和web端的通信:native分享的内容,需要web端的js进行设置(js -> native);native分享成功后,需要把消息通知给js(natvie -> js)。为实现两端的双向通信机制,就需要<code>jsbridge</code>技术了。</p>
<h3>Native通知JS</h3>
<p>因为h5网页是通过原生端的webview加载的,所以原生端对当前网页拥有很高的权限:Native端可以直接在当前webview里执行js代码。</p>
<pre><code class="javascript">// web端
function nativeCallback(data) {
console.log('data', data);
}</code></pre>
<p>我们在js的执行环境里定义了一个全局方法<code>nativeCallback</code>,native端可以直接执行<code>nativeCallback(123)</code>方法,也就把数据传给了js。</p>
<p>这种方案是不是有点熟悉,<code>jsonp</code>就是类似的原理:只不过调用全局方法的时机,从服务器端改成了native端。</p>
<h3>JS通知Native</h3>
<p>前端常见的协议有:</p>
<ol>
<li>http/https协议:<code>https://www.baidu.com</code>
</li>
<li>本地file协议: <code>file:///Users/deepred/myproject/index.html</code>
</li>
</ol>
<p>其实我们也可以自定义协议:<code>sslocal://openModal?text=hello</code>,客户端通过分析这段<code>scheme</code>就能知道web端要调用原生的哪些方法,同时数据也通过query参数进行了传递。</p>
<p>那web端如何发送这段scheme给native端呢?</p>
<ul><li>拦截<code>console</code> <code>alert</code> <code>prompt</code> 全局方法。</li></ul>
<pre><code class="javascript">alert('sslocal://openModal?text=hello')</code></pre>
<p>native可以拦截webview中的这些方法,从而调用原生方法。</p>
<ul><li>拦截url请求</li></ul>
<pre><code class="javascript">const ifr = document.createElement('iframe');
ifr.style.display = 'none';
ifr.src = 'sslocal://openModal?text=hello';
document.body.appendChild(ifr);</code></pre>
<p>web端加载了一个iframe,请求了<code>sslocal://openModal?text=hello</code>, native端通过拦截url请求,从而调用原生方法。</p>
<p>使用<code>scheme</code>字符串来调用方法始终不够直观,其实我们还可以向webview里注入一个js全局对象,这个全局对象拥有调用native的方法的能力。</p>
<ul><li>API注入</li></ul>
<pre><code class="javascript">// nativeApp是由native端注入的全局变量
nativeApp.openModal('hello');</code></pre>
<h3>双向通信</h3>
<p>前面我们介绍的几种方法,都只能单向通信。如何进行双向通信呢?这时候就需要前端自己实现一个JS-SDK,维护js回调函数的Map。</p>
<p>首先,我们假设客户端会向webview中注入一个全局对象<code>BILIAPP</code></p>
<pre><code class="javascript">// BILIAPP是原生端注入的
const BILIAPP = {
invoke(methodName, param, onSuccessKey, onFailKey) {}
}</code></pre>
<p>该对象有个<code>invoke</code>方法,接收4个参数:</p>
<ul>
<li>调用的原生方法名</li>
<li>方法参数</li>
<li>成功回调函数id</li>
<li>失败回调函数id</li>
</ul>
<p>我们没法直接传函数给原生方法,所以这里只能传回调函数的id,id对应的实际函数,由前端这边维护。</p>
<p><code>sdk.js</code></p>
<pre><code class="javascript">let id = 1;
const uuid = () => {
return `callback_${id++}`;
};
// BILISDK是web端注入的
const BILISDK = {
// key是回调函数的id
// value是回调函数的值
callbacks: {
},
// 暴露给前端使用的方法,支持Promise
invokeP(methodName, param) {
return new Promise((resolve, reject) => {
const successCb = (data) => {
resolve(data);
};
const failureCb = (data) => {
reject(data);
};
return BILISDK._invoke(methodName, param, successCb, failureCb);
});
},
// 实际真正调用原生对象的方法
_invoke(methodName, param, successCb, failureCb) {
const onSuccessKey = uuid();
const onFailKey = uuid();
// 存入callbacks hash表中
this.callbacks[onSuccessKey] = successCb;
this.callbacks[onFailKey] = failureCb;
// BILIAPP是否注入成功
BILIAPP && BILIAPP.invoke && BILIAPP.invoke(methodName, JSON.stringify(param), onSuccessKey, onFailKey);
},
// 暴露给原生端使用的方法
invokeFromNative(key, param) {
if (typeof param === "string") {
try {
param = JSON.parse(param)
} catch (ex) {
}
}
const callback = this.callbacks[key];
if (callback) {
callback(param);
}
}
}
// 使用BILISDK调用原生方法
BILISDK.invokeP('getVersion').then((res) => {
console.log('res', res);
})</code></pre>
<p>现在前端调原生方法,不要直接使用<code>BILIAPP.invoke</code>,而是通过<code>BILISDK.invokeP</code>间接调用。<code>BILISDK.invokeP</code>支持Promise化,同时维护了一个hash表</p>
<pre><code class="javascript">const BILISDK = {
callbacks: {
'callback_1': function() {},
'callback_2': function() {},
},
}</code></pre>
<p><code>BILISDK.invokeFromNative</code>是暴露给Native端使用的。当原生方法调用完成后,根据成功还是失败,Native端可以调用<code>BILISDK.invokeFromNative(成功或者失败的id)</code>,而这个id就是当初<code>BILIAPP.invoke</code>调用时传进来的id。</p>
<p>通过上面的方法,我们就实现了js -> native -> js 的双向通信了。当然理论上,我们还需实现:native -> js -> native 的双向通信,但是原理是一样的,这时客户端就需要自己实现一个Native-SDK,维护Native端回调函数的Map。</p>
<h3>JS-SDK的接入</h3>
<p>前面我们实现的<code>sdk.js</code>,如何引入web站点呢?</p>
<p>把sdk打包成umd规范的js静态文件,上传到cdn或者发布到npm</p>
<ul>
<li>在index.html里面直接通过script标签引入或者js直接<code>import</code>导入即可。该方案,前端维护sdk。(维护成本高)</li>
<li>客户端在初始化一个WebView打开页面时,直接注入sdk。该方案,客户端维护sdk。(<strong>优先推荐</strong>)</li>
</ul>
<h3>参考</h3>
<ol>
<li><a href="https://link.segmentfault.com/?enc=5%2BTxRVcLDHB4tvBBAl6Y1Q%3D%3D.6Ng63B%2BafOYDpuRX7qv0HwToQoleNcjP8%2BcQBn7D4CdKhsyVCQFBme%2B3QArL6dUQ" rel="nofollow">Hybrid App技术解析 -- 原理篇</a></li>
<li><a href="https://link.segmentfault.com/?enc=jXOy%2FQoNkWAX7vBcDuZzTA%3D%3D.vl12jk2cf%2BSLYbayiPoDBpfLxW0h1dRoNFpZsHRC4nLUAcJnBQwtOCItPbUnL4tG" rel="nofollow">小白必看,JSBridge 初探</a></li>
<li><a href="https://link.segmentfault.com/?enc=94WRC0eGT4ihQZT4N3buFQ%3D%3D.UuH0hLz93ToMN2iIx5Lux3NOESxdXlo1iwEuzRu0I0OjGSv1meW3rCuKRUdfAa40" rel="nofollow">2小时搞定移动端混合开发基础入门</a></li>
</ol>
再谈前后端分离开发和部署
https://segmentfault.com/a/1190000021882747
2020-03-02T09:00:00+08:00
2020-03-02T09:00:00+08:00
深红
https://segmentfault.com/u/deepred5
4
<p>前后端分离开发已成为业界的共识,但分离的同时也带来了部署的问题。传统web模式下,前端和后端同属一个项目,模板的渲染理所当然由后端渲染。然而随着node的流行,以及webpack的模块化打包方案,让前端在<strong>开发阶段</strong>完全有能力脱离后端环境:通过本地node启动一个服务器,搭配Mock数据,马上就可以进行业务开发了。</p>
<p>但是到了<strong>部署阶段</strong>,问题也就显现出来:前端最后打包出来的js,css以及index.html,到底放在哪里?静态文件js,css或者图片,我们还可以在CI阶段上传到cdn服务器上,但是最后的html模板<code>index.html</code>一定需要放在一个服务器上,然而这个服务器到底由前端还是后端维护?</p>
<h3>前端维护HTML</h3>
<p>如果html模板由前端维护,那么前端就需要自己使用一个静态服务器:提供<code>HTML</code>的渲染和<code>API</code>接口的转发。常见的单页应用,也是推荐使用Nginx进行部署。</p>
<p>使用Nginx部署,这里又分两种情况:</p>
<ul>
<li>静态资源完全由Nginx托管,也就是js,css和index.html放在同一个<code>dist</code>目录里。在这种情况下,webpack的<code>publicPath</code>一般不用特别设置,使用默认的<code>/</code>即可。</li>
<li>静态资源上传CDN,Nginx只提供<code>index.html</code>。在这种情况下,webpack的<code>publicPath</code>要设置成cdn的地址,例如:<code>//static.demo.cn/activity/share/</code>。但这又会引发一个问题,由于qa环境,验证环境,生产环境的cdn地址通常不同,为了让<code>index.html</code>可以引入正确的静态文件路径,你需要打包三次,仅仅为了生成三份引用了不同路径的html(即使三次打包的js内容完全一样)</li>
</ul>
<p><code>nginx配置</code></p>
<pre><code class="bash">server {
listen 80;
server_name localhost;
location / {
root /app/dist; # 打包的路径
index index.html index.htm;
try_files $uri $uri/ /index.html; # 单页应用防止重刷新返回404,如果是多页应用则无需这条命令
}
location /api {
proxy_pass https://anata.me; #后台转发地址
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
}</code></pre>
<p>理论上qa,yz,prod环境的接口转发地址也不同,因此你还需要三份<code>nginx.conf</code>配置</p>
<h3>后端维护HTML</h3>
<p>很多情况下,我们需要渲染的页面带上后端注入的动态数据,又或者页面需要支持SEO,这种情况下,我们只能把模板交给后端渲染。那么后端维护的html模板怎么获取打包后的hash值呢?</p>
<ul>
<li>前端打包后的<code>index.html</code>直接发给后端(简单粗暴,并不推荐)</li>
<li>前端打包时通过插件<code>webpack-manifest-plugin</code>后生成一个<code>manifest.json</code>文件,该文件其实是个key-value的键值对,key代表了资源名称,value记录了资源的hash</li>
</ul>
<pre><code class="javascript">{
"common.css": "/css/common/common-bundle.804a717f.css",
"common.js": "/js/common/common-bundle.fcb76db9.js",
"manifest.js": "/js/manifest/manifest-bundle.551ff423.js",
"vendor.js": "/js/vendor/vendor-bundle.d99dc0e4.js",
"page1.css": "/css/demo/demo-bundle.795bbee4.css",
"page1.js": "/js/demo/demo-bundle.e382801f.js",
}
</code></pre>
<p>后端的<code>index.html</code></p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo</title>
<link href="<%= manifest['page1.css']%>" rel="stylesheet">
</head>
<body>
<h1>demo</h1>
<script src="<%= manifest['page1.js'] %>"></script>
</body>
</html></code></pre>
<p>后端通过读取这个<code>json</code>文件,就可以动态渲染出文件的引用路径。</p>
<p>如果你曾经用过百度的打包工具<a href="https://link.segmentfault.com/?enc=3LLfHFwpRqmEUQttow3XDA%3D%3D.LEoPHXObqicWFwLa3A8ENdLTgmFCENCuXPYBuZsdEPs%3D" rel="nofollow">FIS</a>,它最后打包产出的<a href="https://link.segmentfault.com/?enc=00USVFTkaiq8%2Bvw%2FminVLg%3D%3D.qicBybLHJXIdeevUwQvth%2B8L8OvGCSPo2UsPkHbmRXUOBBYo6eSIaELenHdeLQ9oCt%2BMtgJWR5aAKPFFyUnkJ%2Fxh%2F6Y9HDdWod9tAADLPqwN%2FOya42yTBL%2BjbElDTITVB5JlPaWTN97xih8UU8pKq2J3HRrptSDT6Bo21lKYcbdnOEeNacir7Q9TlOPyZXjnHqXSnhP%2BUglVAp8ke9bqZcWc1q7nsVQuE4vGnJ27zYk6JOA%2F0yHk%2FVSMuoo3b8ywlGSVpkb6LuLRIe4KJfCK%2BA%3D%3D" rel="nofollow">map.json</a>就是类似的资源文件列表。</p>
<p>使用这种方法还有一个好处:前面我们说过,如果文件上传至cdn,那么前端维护的html可能需要打包三次,因为不同环境的cdn地址不同。现在html交给后端维护了,那么这个问题就很好解决,前端只需要打包一次,不同环境的cdn地址可以让后端动态拼接生成。</p>
<p>当然,使用这种方法也会带来一个问题,这个json文件,后端怎么获取?</p>
<ul><li>把这个json文件和其他静态资源一起打包上传到cdn上,后端服务器每次启动时,先到cdn上获取这个json文件,然后存到内存里</li></ul>
<pre><code class="bash">wget --tries=3 --quiet -O manifest.json http://static.demo.cn/demo/manifest.json?`date +%s` ## 防止缓存</code></pre>
<p>方案的优点:简单方便,每次前端打包,<code>manifest.json</code>就会自动更新,上传到cdn同时覆盖前一个版本。<br>方案的缺点:如果<code>manifest.json</code>更新了,后端则需要重启服务以便获取新的配置,当集群多的时候,重启耗费的代价可能很大。</p>
<ul><li>将<code>manifest.json</code>的内容放在<strong>配置中心</strong>里,后端则需要接入配置中心。每次CI打包后,调用配置中心更新接口,后端就能自动获取最新的配置。</li></ul>
<p>在我平时工作项目中,这两种方案均有实现。</p>
<h3>Node中间层</h3>
<p>使用Nginx部署时,为了解决跨域问题,我们一般需要配置<code>proxy_pass</code>指向提供api的后端服务。</p>
<p>后端采用了SOA,微服务的架构时,<code>proxy_pass</code>指向的api服务器,其实本质也是一个转发服务。</p>
<p>前端ajax请求</p>
<pre><code class="javascript">// 获取商品列表
ajax.get('/api/queryProductList')
// 获取价格列表
ajax.get('/api/queryPriceList')</code></pre>
<p>Nginx转发</p>
<pre><code>location /api {
proxy_pass https://demo.com; #后台转发地址
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}</code></pre>
<p>接口转发到 <br><code>https://demo.com/api/queryProductList</code><br><code>https://demo.com/api/queryPriceList</code></p>
<p>查询商品列表和查询价格列表其实是由两个不同的soa服务提供:</p>
<p>查询商品: <code>product.soa.neko.com</code><br>查询价格: <code>price.soa.neko.com</code></p>
<p>因此,本质上<code>https://demo.com</code>这个服务也只是用来转发接口,同时对数据做部分的组装。那么这个服务,就可以用Node中间层来替代。使用了Node中间层,模板的渲染也可以从Nginx转移到Node了。</p>
<p>当然,多了一层Node,对于前端的综合要求也随之提高,后端的部署,监控,日志,性能等等问题也随之而来,全<del>栈</del>(干)工程师应运而生。</p>
<h3>工作现状</h3>
<p>我司大部分to C的前端项目,都是采用Node层渲染模板加转发接口的开发模式,还有少量项目采用Java tomcat渲染html模板。</p>
<p>大部分页面都是多页应用,并不是典型的单页应用。</p>
<p>Node层渲染模板,又分两种情况:</p>
<ul>
<li>需要支持SEO,则采用传统的模板渲染,填充展示数据。但是JS的业务代码,依旧前后端分离,并不在Node项目里。这类页面,一般都是采用Jquery+webpack模块化打包。</li>
<li>不需要支持SEO,则Node只渲染一个空html模板,页面内容完全由JS生成。这类页面,一般采用最新的前端MVC框架,比如Vue和React。</li>
</ul>
<p>当然近几年比较流行的SSR方案,让Node渲染模板时可以直接使用Vue和React的同构组件,直出页面后,用户的交互体验又如单页应用般流畅,只能说:历史总是惊人的相似。</p>
<blockquote>从某种程度上说,SSR是一种向传统模式的回归,不过这种回归并不是倒退,而是一种螺旋式的发展。</blockquote>
<h3>实战</h3>
<p>理论知识讲了那么多,现在我们来实战一下。在上一篇<a href="https://link.segmentfault.com/?enc=GuCDBXeitwF%2BEU5wWTpqyg%3D%3D.tXwCe8iPt6aodNssNSarCUTP1KZUokeF%2FCocAsSb3ftCnb4kmtgHGKmXHSGk837i9lK3rlOG0JZPfuwtsoM8qXHPFXTzMMwteCczrQsYIV2re8xrojNaMQZp%2BBvdgIlwuMu%2BZMjk31IyzHF6AFDycg%3D%3D" rel="nofollow">文章</a>里,我介绍了webpack多页打包的原理,同时搭建了一个简单的<a href="https://link.segmentfault.com/?enc=AmjMRcPXkURLXrIsT9QVmw%3D%3D.DjV0cYH6%2BKrQeJ7Coeq8LgSCruQay7ulTQEa7xqD%2F3P1OrjZGz6yR7Kxis17zTozSoFxBcrM%2Bh2xNVaQRYEHOQ%3D%3D" rel="nofollow">webpack4-boilerplate</a>。这个模板只是一个前端开发模板,其实它还对应着一个node后端模板<a href="https://link.segmentfault.com/?enc=BZfQbKNZfSTdeMw6WEpafQ%3D%3D.7kJtpKvPB9PxgqDGBTYv%2FpoFEwtsUq6Gi1GwcJxMUQAjPUB24LnJ9z%2BYcV4CJl5rCRMKoWt4bQ1HGMjVGjvLig%3D%3D" rel="nofollow">koa2-multipage-boilerplate</a>。</p>
<p>这个node项目最重要的就是实现了前面说的:如何读取<code>manifest.json</code>文件,动态渲染静态文件的引用路径,从而前后端分离开发和部署。</p>
<p>详情见<a href="https://link.segmentfault.com/?enc=qolFHNy1AiKdIKueP7IV3Q%3D%3D.pQtP6BFu3uX%2FVcNi0MUAQWe3cOdOS2HglZA4uq28quMkgZuGAUDjciiAXLHkqB0Ui%2FLj9YqGaalXJFdHHHoBmhoKnAi7wxZKvPNM8M%2BEnsOmz79WyGaaZb5X3PWGVZbY" rel="nofollow">chunkmap.js</a>这个koa2中间件的源码。</p>
<pre><code class="javascript">const chunkmap = require('./chunkmap');
app.use(chunkmap({
staticServer: '//0.0.0.0:9001',
staticResourceMappingPath: './mainfest.json'
}));</code></pre>
<p>这个中间件接受两个参数</p>
<ul>
<li>staticServer:静态资源服务器地址,本地开发时,填写的就是<code>webpack4-boilerplate</code>这个前端项目启动的服务器。到了qa,产线时,则填写真正的cdn地址</li>
<li>staticResourceMappingPath: 资源映射文件路径,也就是<code>manifest.json</code>文件</li>
</ul>
<p>本地开发时的<code>manifest.json</code>,不带hash值</p>
<pre><code class="javascript">{
"home.css": "/css/home/home-bundle.css",
"home.js": "/js/home/home-bundle.js",
}</code></pre>
<p>打包后的<code>manifest.json</code>,带hash值</p>
<pre><code class="javascript">{
"home.css": "/css/home/home-bundle.d2378378.css",
"home.js": "/js/home/home-bundle.cb49dfaf.js",
}</code></pre>
<p>使用了这个中间件后,koa的<code>ctx.state</code>全局变量上就带有一个<code>bundle</code>属性,里面的内容是:</p>
<pre><code class="javascript">{
"home.css": "//0.0.0.0:9001/css/home/home-bundle.d2378378.css",
"home.js": "//0.0.0.0:9001/js/home/home-bundle.cb49dfaf.js",
}</code></pre>
<p>然后通过模板引擎,动态渲染出实际页面。当然你也可以在页面中动态生成展示内容,从而支持SEO。</p>
<pre><code class="html"><!DOCTYPE html>
<html lang="zh-cn">
<head>
<title><%= title %></title>
<link href="<%= bundle['home.css']%>" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script src="<%= bundle['home.js']%>"></script>
</body>
</html></code></pre>
<h3>总结</h3>
<p>前后端分离带来了工作效率上的提高,Node中间层则给前端打开了一条走进后端的道路。当然机遇总是与挑战并存,在前端技术日新月异的今天,真想说一句:<del>老子学不动啦!</del></p>
<h3>参考</h3>
<p><a href="https://link.segmentfault.com/?enc=SVsJ9hD4DSGCk%2BHbewt%2B5w%3D%3D.wQdNgoowrKLOnGgb9525osOoApjyMQPgTe7GOHixVFe9yJhAOH5l6IXzXKh5FCP8" rel="nofollow">大公司里怎样开发和部署前端代码?</a></p>
webpack多页面打包实践
https://segmentfault.com/a/1190000021861887
2020-02-28T10:38:49+08:00
2020-02-28T10:38:49+08:00
深红
https://segmentfault.com/u/deepred5
5
<p>前不久从零开始写了一个webpack多页面打包boilerplate(<a href="https://link.segmentfault.com/?enc=HIDCeElYVjn9%2B65ytOef9g%3D%3D.0gB7qGRJArxrc6MFZq%2FcmbeYSXXYiI9Dr4ZmYDmCkpus5eyNij2iKqUebi92XeWIaFr4PgEPDSmyfl77yNGUBQ%3D%3D" rel="nofollow">webpack4-boilerplate</a>),方便以后工作可以开箱即用,特此记录下开发过程中的要点。</p>
<p>注意:本文不会详细介绍webpack的基础知识,如果完全不会,建议看下我之前写过的基础文章</p>
<ul>
<li><a href="https://link.segmentfault.com/?enc=2bZWxVBilTtjhv4%2FtUn17A%3D%3D.9rP2aeIwfa%2FrN3HZgT2aJRBEk7W0qtLiM6%2FvMB%2BFfO%2FOi9VMD0S30H6dUDdeXy3I8yL%2FCKV1qh1%2BHY8xvXCi%2FiNMTfxcys%2FnndlFB%2FaNCPqiSniOVsDjcElZdIRqKGn%2F8jkfMiyuQQmCE%2Bq9Ttd2Q2ny5IAei7JlBZPQntlRsdph3GTklDyuFatYMop9M3BXC0KMcmtNG9zMPRe17v2qw50NTsJ1966hdteY2L%2FgWi1jfL4odTwNUOloLA3%2FYlOIE0QFFW99fRMwYvR9ENitcA%3D%3D" rel="nofollow">从零开始搭建一个简单的基于webpack的vue开发环境</a></li>
<li><a href="https://link.segmentfault.com/?enc=oub%2BpdbWv%2FlhEbY1qQVrmg%3D%3D.7vPKMtoAMZwSy8dRGaOAAlv%2B%2FQsAlMlFWEWo0Y05GzSs4CqnjtjgGQrD0qFha2BBRb3EqCmMH5nJH7OWTi%2F7i91TOrSOCx%2FIa2pg86gejb2wILXAEaO1s6Y0vlvBX1VvM7gTs9gr3bnkLIfUqLikDkIBmuP7aSeoXOcgXuM8uoXf6tk%2B1XnKLQpIECQ4HpuVyz3DkpwzjDb4I3TA4dck6DRBXJFvQWaAymNvMaAZ5V0%3D" rel="nofollow">从零开始搭建一个简单的基于webpack的react开发环境</a></li>
</ul>
<h3>多页打包的原因</h3>
<p>首先发出一个直击灵魂的拷问:为什么要多页面打包?</p>
<p>习惯了React,Vue全家桶的同学,可能觉得写代码不就是:<code>npm run dev</code> <code>npm run build</code>一把梭吗?</p>
<p>然而现实是骨感的,很多场景下,单页应用的开发模式并不适用。比如公司经常开发一些活动页:<br><code>https://www.demo.com/activity/activity1.html</code> <br><code>https://www.demo.com/activity/activity2.html</code><br><code>https://www.demo.com/activity/activity3.html</code></p>
<p>上述三个页面是完全不相干的活动页,页面之间并没有共享的数据。然而每个页面都使用了React框架,并且三个页面都使用了通用的弹框组件。在这种场景下,就需要使用webpack多页面打包的方案了:</p>
<ol>
<li>保留了传统单页应用的开发模式:使用Vue,React等前端框架(当然也可以使用jQuery),支持模块化打包,你可以把<strong>每个页面看成是一个单独的单页应用</strong>
</li>
<li>独立部署:每个页面相互独立,可以单独部署,解耦项目的复杂性,你甚至可以在不同的页面选择不同的技术栈</li>
</ol>
<p>因此,我们可以把多页应用看成是<font color="orange"><del>乞丐版的前端微服务</del></font>。</p>
<h3>多页面打包的原理</h3>
<p>首先我们约定:<br><code>src/pages</code>目录下,每个文件夹为单独的一个页面。每个页面至少有两个文件配置:</p>
<ul>
<li>
<code>app.js</code>: 页面的逻辑入口</li>
<li>
<code>index.html</code>: 页面的html打包模板</li>
</ul>
<pre><code>src/pages
├── page1
│ ├── app.js
│ ├── index.html
│ ├── index.scss
└── page2
├── app.js
├── index.html
└── index.scss</code></pre>
<p>前面我们说过:<strong>每个页面可以看成是个独立的单页应用</strong>。</p>
<p>单页应用怎么打包的?单页应用是通过配置webpack的的entry</p>
<pre><code class="javascript">module.exports = {
entry: './src/main.js', // 项目的入口文件,webpack会从main.js开始,把所有依赖的js都加载打包
output: {
path: path.resolve(__dirname, './dist'), // 项目的打包文件路径
filename: 'build.js' // 打包后的文件名
}
};</code></pre>
<p>因此,多页应用只需配置多个entry即可</p>
<pre><code class="javascript">module.exports = {
entry: {
'page1': './src/pages/page1/app.js', // 页面1
'page2': './src/pages/page2/app.js', // 页面2
},
output: {
path: path.resolve(__dirname, './dist'),
filename: 'js/[name]/[name]-bundle.js', // filename不能写死,只能通过[name]获取bundle的名字
}
}</code></pre>
<p>同时,因为多页面的index.html模板各不相同,所以需要配置多个HtmlWebpackPlugin。</p>
<p>注意:<code>HtmlWebpackPlugin</code>一定要配<code>chunks</code>,否则所有页面的js都会被注入到当前html里</p>
<pre><code class="javascript">module.exports = {
plugins: [
new HtmlWebpackPlugin(
{
template: './src/pages/page1/index.html',
chunks: ['page1'],
}
),
new HtmlWebpackPlugin(
{
template: './src/pages/page2/index.html',
chunks: ['page2'],
}
),
]
}</code></pre>
<p><strong>多页面打包的原理就是:配置多个<code>entry</code>和多个<code>HtmlWebpackPlugin</code></strong></p>
<h3>多页打包的细节</h3>
<h4>代码分割</h4>
<ol>
<li>把多个页面共用的第三方库(比如React,Fastclick)单独打包出一个<code>vendor.js</code>
</li>
<li>把多个页面共用的逻辑代码和共用的全局css(比如css-reset,icon字体图标)单独打包出<code>common.js</code>和<code>common.css</code>
</li>
<li>把运行时代码单独提取出来<code>manifest.js</code>
</li>
<li>把每个页面自己的业务代码打包出<code>page1.js</code>和<code>page1.css</code>
</li>
</ol>
<p>前3项是每个页面都会引入的公共文件,第4项才是每个页面自己单独的文件。</p>
<p>实现方式也很简单,配置<code>optimization</code>即可:</p>
<pre><code class="javascript">module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
// 打包业务中公共代码
common: {
name: "common",
chunks: "initial",
minSize: 1,
priority: 0,
minChunks: 2, // 同时引用了2次才打包
},
// 打包第三方库的文件
vendor: {
name: "vendor",
test: /[\\/]node_modules[\\/]/,
chunks: "initial",
priority: 10,
minChunks: 2, // 同时引用了2次才打包
}
}
},
runtimeChunk: { name: 'manifest' } // 运行时代码
}
}</code></pre>
<h4>hash</h4>
<p>最后打包出来的文件,我们希望带上hash值,这样可以充分利用浏览器缓存。webpack中有<code>hash</code>,<code>chuckhash</code>,<code>contenthash</code>:生产环境时,我们一般使用<code>contenthash</code>,而开发环境其实可以不指定hash。</p>
<pre><code class="javascript">// dev开发环境
module.exports = {
output: {
filename: 'js/[name]/[name]-bundle.js',
chunkFilename: 'js/[name]/[name]-bundle.js',
},
}
// prod生产环境
module.exports = {
output: {
filename: 'js/[name]/[name]-bundle.[contenthash:8].js',
chunkFilename: 'js/[name]/[name]-bundle.[contenthash:8].js',
},
}</code></pre>
<h4>mock和proxy</h4>
<p>开发环境,通常需要mock数据,还需要代理api到服务器。我们可以通过<code>devServer</code>配合<a href="https://link.segmentfault.com/?enc=zebZZfVeaT279877dFqlGg%3D%3D.9943YyT9pskc9%2BA40SXa%2ByxaKd6XTIyu%2B0N0iYrMHZgi15ER%2F6zua%2Fj6Bq93KUwx" rel="nofollow">mocker-api</a>第三方库实现。</p>
<pre><code class="javascript">const apiMocker = require('mocker-api');
// dev开发环境
module.exports = {
devServer: {
before(app) { // 本地mock数据
apiMocker(app, path.resolve(__dirname, '../mock/index.js'))
},
proxy: { // 代理接口
'/api': {
target: 'https://anata.me', // 后端联调地址
changeOrigin: true,
secure: false,
},
}
},
}</code></pre>
<h4>拆分webpack配置</h4>
<p>为了通用配置,把webpack的配置文件分成3份。</p>
<pre><code>build
├── webpack.base.js // 共用部分
├── webpack.dev.js // dev
└── webpack.prod.js // 生产</code></pre>
<p><code>dev</code>和<code>prod</code>配置的主要区别:</p>
<ul>
<li>
<code>dev</code>配置<code>devServer</code>,方便本地调试开发</li>
<li>
<code>prod</code>打包压缩文件,单独提取css (dev不提取是为了css热更新),生成静态资源清单<code>manifest.json</code>
</li>
</ul>
<p>关于为什么要生成一份<code>manifest.json</code>,以及打包后的代码如何部署,我将会在下一篇文章详情介绍。</p>
<h3>总结</h3>
<p>webpack的学习始终是前端绕不过去的一道坎。通过这次从零搭建多页面打包模板,也算是巩固了一下基础知识,离<del>webpack配置工程师</del>又近了一步。</p>
PixiJS基础教程
https://segmentfault.com/a/1190000021237123
2019-12-09T16:09:21+08:00
2019-12-09T16:09:21+08:00
深红
https://segmentfault.com/u/deepred5
7
<p><a href="https://link.segmentfault.com/?enc=b%2FE4u%2B6w%2FrJy4Ajzq4auXA%3D%3D.nHaNEQaLMLoWCg0AzY514A2FiLG4OOP2AYuoUecqR%2FLJcUb2BUZwn1mALwsF9u8A" rel="nofollow">PixiJS</a>是一个轻量级的2D渲染引擎,它能自动侦测使用WebGL还是Canvas来创建图形。这个库经常被用来制作HTML5游戏以及有复杂交互的H5活动页。</p>
<h2>搭建环境</h2>
<p><strong>注意:本文使用pixi最新的v5版本,同时使用<a href="https://link.segmentfault.com/?enc=NV%2FGWjSuoFAboez9ClZlPg%3D%3D.4Cgt%2BJq8ItIPfUqXHC8SJ1or7MBHP93iYCHnEWpD%2Ba4%3D" rel="nofollow">Parcel</a>进行模块化打包</strong></p>
<p>项目初始化</p>
<pre><code>mkdir learn-pixi
cd learn-pixi
npm init -y</code></pre>
<p>安装依赖</p>
<pre><code>npm i pixi.js -save
npm i parcel-bundler -save-dev</code></pre>
<p>根目录创建<code>index.html</code></p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>learn-pixi</title>
</head>
<body>
<script src="./src/index.js"></script>
</body>
</html></code></pre>
<p>根目录创建<code>src</code>目录,新建<code>src/index.js</code></p>
<pre><code class="javascript">alert('pixi');</code></pre>
<p>修改<code>package.json</code></p>
<pre><code class="javascript">"scripts": {
"dev": "parcel index.html -p 8080",
"build": "parcel build index.html"
}</code></pre>
<p>运行<code>npm run dev</code>,访问 <a href="https://link.segmentfault.com/?enc=IPwSqFFqJ3oU86biEEMe%2BA%3D%3D.dlSHDc1j2fdV0RgaiNf%2FULlGrHV5r7Bo%2FVfTCQsSxps%3D" rel="nofollow">http://localhost</a>:8080/ 即可看到效果</p>
<h2>快速开始</h2>
<p><code>index.js</code></p>
<pre><code class="javascript">import { Application } from 'pixi.js';
const app = new Application({
width: 300,
height: 300,
antialias: true,
transparent: false,
resolution: 1,
backgroundColor: 0x1d9ce0
});
// app.view就是个canvas元素,挂载到页面上
document.body.appendChild(app.view);
</code></pre>
<p>页面上就出现了一个300*300的蓝色矩形,矩形是由pixi.js创建的canvas渲染的。<br><img src="/img/remote/1460000021237126" alt="" title=""></p>
<p>我们可以继续创建新的图形,然后渲染到canvas里</p>
<pre><code class="javascript">import { Application, Graphics } from 'pixi.js';
const app = new Application({
width: 300,
height: 300,
antialias: true,
transparent: false,
resolution: 1,
backgroundColor: 0x1d9ce0
});
document.body.appendChild(app.view);
// 创建一个半径为32px的圆
const circle = new Graphics();
circle.beginFill(0xfb6a8f);
circle.drawCircle(0, 0, 32);
circle.endFill();
circle.x = 130;
circle.y = 130;
// 添加到app.stage里,从而可以渲染出来
app.stage.addChild(circle);</code></pre>
<p>我们还可以渲染图片</p>
<pre><code class="javascript">import { Application, Sprite } from 'pixi.js';
const app = new Application({
width: 300,
height: 300,
antialias: true,
transparent: false,
resolution: 1,
backgroundColor: 0x1d9ce0
});
document.body.appendChild(app.view);
// 创建一个图片精灵
const avatar = new Sprite.from('http://anata.me/img/avatar.jpg');
// 图片宽高缩放0.5
avatar.scale.set(0.5, 0.5);
app.stage.addChild(avatar);</code></pre>
<p><img src="/img/remote/1460000021237127" alt="" title=""></p>
<p>我们让这个图片精灵变得可以交互:点击图片后,图片透明度变成0.5</p>
<pre><code class="javascript">const avatar = new Sprite.from('http://anata.me/img/avatar.jpg');
avatar.scale.set(0.5, 0.5);
// 居中展示
avatar.x = 100;
avatar.y = 100;
// 可交互
avatar.interactive = true;
// 监听事件
avatar.on('click', () => {
// 透明度
avatar.alpha= 0.5;
})
app.stage.addChild(avatar);</code></pre>
<p>我们还能让图片一直旋转</p>
<pre><code class="javascript">const avatar = new Sprite.from('http://anata.me/img/avatar.jpg');
avatar.scale.set(0.5, 0.5);
avatar.x = 150;
avatar.y = 150;
// 修改旋转中心为图片中心
avatar.anchor.set(0.5, 0.5)
app.stage.addChild(avatar);
app.ticker.add(() => {
// 每秒调用该方法60次(60帧动画)
avatar.rotation += 0.01;
})</code></pre>
<p><img src="/img/remote/1460000021237128" alt="" title=""></p>
<h2>基本概念</h2>
<p><code>pixi</code>有几个重要的Class:</p>
<ul>
<li>Container (舞台,场景)</li>
<li>Renderer (渲染器)</li>
<li>Ticker (计时器)</li>
<li>Loader (资源加载器)</li>
<li>Sprite (精灵)</li>
</ul>
<pre><code class="javascript">const app = new Application({
width: 300,
height: 300
});</code></pre>
<p><code>Application</code>是pixi提供的一个工具方法,它能自动创建renderer,ticker 和container,我们通常使用该方法快速创建应用。</p>
<h2>Container</h2>
<p><code>app.stage</code>是一个<code>Container</code>的实例,作为最底层的舞台(stage),所有要渲染的图形都应放在它的内部</p>
<pre><code class="javascript">const app = new Application({
width: 300,
height: 300
});
// 添加不同的图形
app.stage.addChild(circle1);
app.stage.addChild(circle2);</code></pre>
<p>我们也可以创建自己的<code>Container</code>,自定义的Container通常用来分组</p>
<pre><code class="javascript">import { Application, Container, Graphics } from 'pixi.js';
const app = new Application({
width: 300,
height: 300,
antialias: true,
transparent: false,
resolution: 1,
backgroundColor: 0x1d9ce0
});
// 自定义Container
const myContainer = new Container();
// 相对于根节点偏移
myContainer.position.set(40, 40);
let rectangle = new Graphics();
rectangle.beginFill(0x000000);
rectangle.drawRect(0, 0, 64, 64);
rectangle.endFill();
let rectangle2 = new Graphics();
rectangle2.beginFill(0xFFFFFF);
rectangle2.drawRect(0, 0, 64, 64);
rectangle2.endFill();
// 相对于自定义Container偏移
rectangle2.position.set(20, 20);
// 两个图形加到自定义Container里
myContainer.addChild(rectangle);
myContainer.addChild(rectangle2);
// 自定义Container最后需要添加到app.stage
app.stage.addChild(myContainer);
document.body.appendChild(app.view);
</code></pre>
<p><img src="/img/remote/1460000021237129" alt="" title=""></p>
<p>分组的好处在于,修改container的属性,位于其中的子节点,都会受到影响。比如上面的例子,我们把<code>rectangle</code>和<code>rectangle2</code>分到了同一个组里,如果希望同时隐藏这两个元素,只需修改它们父级container的透明度即可。</p>
<pre><code class="javascript">// 父级透明,则子级也透明
myContainer.alpha = 0;</code></pre>
<p>一种常见的做法是,我们创建一个最顶层的<code>rootContainer</code>,之后所有的内容,都添加到<code>rootContainer</code>里。而<code>rootContainer</code>作为顶级元素,可以进行一些缩放来适配不同的分辨率:</p>
<pre><code class="javascript">const rootContainer = new Container();
app.stage.addChild(rootContainer);
// 相对于设计稿750px进行缩放(竖屏状态)
const screenScaleRito = window.innerWidth / 750; // 横屏则用innerHeight
rootContainer.scale.set(screenScaleRito, screenScaleRito);</code></pre>
<p>这种方法类似我们前端的rem布局</p>
<h2>Renderer</h2>
<p><code>app.renderer</code>是一个<code>Renderer</code>的实例,如果你希望重新渲染页面,就需要使用它</p>
<pre><code class="javascript">// 把画布重新渲染为500*500大小
app.renderer.resize(500, 500);
// 渲染一个容器
const container = new Container();
app.renderer.render(container);</code></pre>
<h2>Sprite</h2>
<p>Sprite精灵,你可以把它看成普通的矢量图形,只不过它是根据图片渲染出来的。</p>
<pre><code class="javascript">const avatar = new Sprite.from('http://anata.me/img/avatar.jpg');
// 和普通的图形一样可以设置各种属性
avatar.width = 100;
avatar.height = 200;
avatar.position.set(20, 30);
avatar.scale.set(2, 2);</code></pre>
<p>加载图片通常需要耗费一定的时间,因此我们常常使用<code>Loader</code>来预加载图片,当图片全部加载成功后,才渲染出来。</p>
<h2>Loader</h2>
<pre><code class="javascript">import { Application, Sprite, Loader } from 'pixi.js';
// Loader.shared内置的单例loader
const loader = Loader.shared;
// 也可以使用自定义的loader
const loader = new Loader();
const app = new Application({
width: 300,
height: 300,
antialias: true,
transparent: false,
resolution: 1,
backgroundColor: 0x1d9ce0
});
document.body.appendChild(app.view);
loader
.add('bili', 'http://pic.deepred5.com/bilibili.jpg')
.add('avatar', 'http://anata.me/img/avatar.jpg')
.load(setup)
// 监听加载事件
loader.onProgress.add((loader) => {
console.log(loader.progress);
});
function setup() {
const bili = new Sprite(
loader.resources["bili"].texture
);
bili.width = 50;
bili.height = 50;
const avatar = new Sprite(
loader.resources["avatar"].texture
);
avatar.width = 50;
avatar.height = 50;
avatar.position.set(50, 50);
app.stage.addChild(bili);
app.stage.addChild(avatar);
}
</code></pre>
<p>通过<code>add</code>方法添加需要加载的图片,所有图片加载完成后,<code>load</code>方法会调用传入的<code>setup</code>回调函数,这时就可以把图片精灵加入到<code>app.stage</code>里。<code>onProgress</code>事件可以监听加载的进度,通过这个方法,可以很方便的制作进度条动画。</p>
<p>前端有时会把多张图片合并成一张图片,通过设置<code>background-position</code>来显示不同的图片。<code>pixi.js</code>也有类似的技术,我们可以利用<a href="https://link.segmentfault.com/?enc=8IgpOomshY6lA78Ne58B%2Bw%3D%3D.u%2FbN4EuijED5hbadKT63ZEVmj8gLk8Up2vOTAIfzBlZqHNC9QrwsbH3w9M6PJThC" rel="nofollow">Texture Packer</a>软件,把多张图片合并成一张图片,合并的同时,软件会生成一份<code>json</code>配置文件,记录了每张图片的相对位置。</p>
<p>具体教程见<a href="https://link.segmentfault.com/?enc=ccg8l1WZfhppsu7ILtiiQQ%3D%3D.ErMq95q9BGGn89WgRgcIDG9LivsCeXiHYPnod%2FxfwucsXqgr%2BgvMURN4vaz34IWd9sm8xNcjmt%2BsgIFMwe7asA%3D%3D" rel="nofollow">这里</a></p>
<pre><code class="javascript">import { Application, Container, Sprite, Graphics, Loader, Spritesheet } from 'pixi.js';
// myjson记录了每张图片的相对位置
import myjosn from './assets/treasureHunter.json';
// mypng里面有多张图片
import mypng from './assets/treasureHunter.png';
const loader = Loader.shared;
const app = new Application({
width: 300,
height: 300,
antialias: true,
transparent: false,
resolution: 1,
backgroundColor: 0x1d9ce0
});
document.body.appendChild(app.view);
loader
.add('mypng', mypng)
.load(setup)
function setup() {
const texture = loader.resources["mypng"].texture.baseTexture;
const sheet = new Spritesheet(texture, myjosn);
sheet.parse((textures) => {
// mypng里面的一张叫treasure.png的图片
const treasure = new Sprite(textures["treasure.png"]);
treasure.position.set(0, 0);
// mypng里面的一张叫blob.png的图片
const blob = new Sprite(textures["blob.png"]);
blob.position.set(100, 100);
app.stage.addChild(treasure);
app.stage.addChild(blob);
});
}</code></pre>
<h2>Ticker</h2>
<p><code>Ticker</code>有点类似前端的<code>requestAnimationFrame</code>,当浏览器的显示频率刷新的时候,此函数会被执行,因此常常用来制作动画。</p>
<p><code>app.ticker</code>就是一个<code>Ticker</code>实例。</p>
<pre><code class="javascript">import { Application, Sprite, Loader } from 'pixi.js';
const loader = Loader.shared;
const app = new Application({
width: 300,
height: 300,
antialias: true,
transparent: false,
resolution: 1,
backgroundColor: 0x1d9ce0
});
document.body.appendChild(app.view);
loader
.add('bili', 'http://pic.deepred5.com/bilibili.jpg')
.load(setup)
function setup() {
const bili = new Sprite(
loader.resources["bili"].texture
);
bili.width = 50;
bili.height = 50;
app.stage.addChild(bili);
app.ticker.add(() => {
if (bili.x <= 200) {
bili.x += 1;
}
})
}
</code></pre>
<p>我们也可以使用<code>requestAnimationFrame</code>实现这个效果</p>
<pre><code class="javascript">function setup() {
const bili = new Sprite(
loader.resources["bili"].texture
);
bili.width = 50;
bili.height = 50;
app.stage.addChild(bili);
function move() {
if (bili.x <= 200) {
bili.x += 1;
requestAnimationFrame(move)
}
}
requestAnimationFrame(move)
}</code></pre>
<h2>补间动画</h2>
<p><code>Ticker</code>可以实现简单的动画,但如果我们希望实现一些复杂效果,则需要自己编写很多代码,这时就可以选择一个兼容<code>pixi</code>的动画库。市面上比较常见的动画库有:<a href="https://link.segmentfault.com/?enc=ePgQFkbNvKV2YGnnBPDg8w%3D%3D.FnqFIUBV8T%2Bg5p11S%2Bx0jNsuLNFVq%2F8MdU4EpJ1up22azWMdBNIMWOvMFRzfME35" rel="nofollow">Tween.js</a>,<a href="https://link.segmentfault.com/?enc=OvUhvQywV1Ogo2G1jKXgZw%3D%3D.eSZWLXICSSLwJGHzROyV3scWUUHrJyNYzi1A8VjYQEht5sUUcvsAwvRN5ZzQHAuZ" rel="nofollow">TweenMax</a>,这里我们使用<code>TweenMax</code>来演示效果。</p>
<p>安装动画库</p>
<pre><code class="bash">npm i gsap</code></pre>
<pre><code class="javascript">import { Application, Sprite, Loader } from 'pixi.js';
import { TweenMax } from 'gsap/all';
const loader = Loader.shared;
const app = new Application({
width: 300,
height: 300,
antialias: true,
transparent: false,
resolution: 1,
backgroundColor: 0x1d9ce0
});
document.body.appendChild(app.view);
loader
.add('bili', 'http://pic.deepred5.com/bilibili.jpg')
.load(setup)
function setup() {
const bili = new Sprite(
loader.resources["bili"].texture
);
bili.width = 50;
bili.height = 50;
app.stage.addChild(bili);
// 1s内x和y轴移动100
TweenMax.to(bili, 1, { x: 100, y: 100 });
}
</code></pre>
<p><img src="/img/remote/1460000021237130" alt="" title=""></p>
<p><code>TweenMax</code>还提供了一个<a href="https://link.segmentfault.com/?enc=MnUWdoohYKzEdrW4n2PJcA%3D%3D.d4KR1p%2Brd9cuYPR8dmycYnJZa6wcVE%2FwAlTR3%2B9fJDuMHzf4NTDXEKsAxWnSrHZQ%2BewmNqueTrr1DKHroyMW6Q%3D%3D" rel="nofollow">PixiPlugin</a>,可以一次修改多个pixi属性</p>
<pre><code class="javascript">import { Application, Sprite, Loader } from 'pixi.js';
import * as PIXI from 'pixi.js';
import gsap, { TweenMax, PixiPlugin } from 'gsap/all';
// 注册插件
gsap.registerPlugin(PixiPlugin);
PixiPlugin.registerPIXI(PIXI);
const loader = Loader.shared;
const app = new Application({
width: 300,
height: 300,
antialias: true,
transparent: false,
resolution: 1,
backgroundColor: 0x1d9ce0
});
document.body.appendChild(app.view);
loader
.add('bili', 'http://pic.deepred5.com/bilibili.jpg')
.load(setup)
function setup() {
const bili = new Sprite(
loader.resources["bili"].texture
);
bili.width = 50;
bili.height = 50;
app.stage.addChild(bili);
// 一次修改多个属性
TweenMax.to(bili, 1, { pixi: { scaleX: 1.2, scaleY: 1.2, skewX: 10, rotation: 20 } });
}
</code></pre>
<p><img src="/img/remote/1460000021237131" alt="" title=""></p>
<h2>自定义的Application</h2>
<p>我们通常使用Pixi提供的<code>Application</code>方法来创建一个应用,它能自动创建renderer,ticker 和container。但其实,我们可以自己来创建这些对象。</p>
<pre><code class="javascript">import { Container, Renderer, Sprite, Loader, Ticker } from 'pixi.js';
import { TweenMax } from 'gsap/all';
// 自定义render
const renderer = new Renderer({
width: 300,
height: 300,
antialias: true,
transparent: false,
resolution: 1,
backgroundColor: 0x1d9ce0
});
// 自定义container
const stage = new Container();
// 自定义loader
const loader = new Loader();
// 自定义ticker
const ticker = new Ticker();
// 每次屏幕刷新重新渲染,否则只会渲染第一帧
ticker.add(() => {
renderer.render(stage);
});
// 开始执行ticker,一定要调用这个方法,注册的回调函数才会被执行!!!
ticker.start();
document.body.appendChild(renderer.view);
loader
.add('bili', 'http://pic.deepred5.com/bilibili.jpg')
.load(setup)
function setup() {
const bili = new Sprite(
loader.resources["bili"].texture
);
bili.width = 50;
bili.height = 50;
stage.addChild(bili);
// 动画效果
ticker.add(() => {
if (bili.x <= 200) {
bili.x += 2;
}
});
TweenMax.to(bili, 1, { y: 100, delay: 3 });
}</code></pre>
<p>其实<a href="https://link.segmentfault.com/?enc=BnbkBNNdKIPYrIL84zlfog%3D%3D.cZ3ukxYc8LtkGGJ9bcZcmR%2BsI9CCciTgWfL4fZcdJbWmxFuezzzPXiJF8nFILxhiCFkyL7muO1IspGkqSAFmI1sF%2BALqvvrhn0bqxD2%2BxdQ%3D" rel="nofollow">PIXI.Application</a>的底层就是帮我们简化了上述的操作。</p>
<h2>参考</h2>
<p><a href="https://link.segmentfault.com/?enc=VrUj%2B%2FQE%2Bt9AXC4apaB5hQ%3D%3D.6E0rV7crsOiysXaJpGod0nrC%2Bdf8K%2BjfuYMsISGpPhBEYormTy6jfpMAn1vS0S5r" rel="nofollow">Pixi教程中文版</a></p>
<p><a href="https://link.segmentfault.com/?enc=NHe%2F2fR6iaFH0H0Hnyh4nw%3D%3D.HolH%2BGbL8JUHDGRvrhs0wbripO%2FbYcWwOIgJGORAeuDdiyru8t86I5J1CFns%2B%2B69" rel="nofollow">Pixi wiki</a></p>
<p><a href="https://segmentfault.com/a/1190000018190147">学习 PixiJS — 补间动画</a></p>
写给前端工程师看的Docker教程-实战篇
https://segmentfault.com/a/1190000020610106
2019-10-08T10:43:14+08:00
2019-10-08T10:43:14+08:00
深红
https://segmentfault.com/u/deepred5
5
<p>在上篇文章里,我们学习了Docker常用的命令和基本操作,现在可以开始实战了。</p>
<h3>单页应用</h3>
<p>前端工作中最常见的就是单页应用了。我们首先用<code>create-react-app</code>快速创建一个应用</p>
<pre><code class="bash">npm i create-react-app -g
create-react-app react-app
cd react-app
npm run start</code></pre>
<p>可以看见正常启动的页面。</p>
<p>打包试一下</p>
<pre><code>npm run build</code></pre>
<p>可以看到本地生成了一个build目录,这就是最后线上运行的代码。</p>
<p><!-- more --></p>
<p>我们先在本地运行下build目录看看</p>
<pre><code class="bash">npm i http-server -g
http-server -p 4444 ./build</code></pre>
<p>访问 <a href="https://link.segmentfault.com/?enc=4Hra%2BmNBQzPgFZ%2BiWJFr0Q%3D%3D.hvpaR%2FmKeSG0AYusuPBUc9zQSQ9XF93%2BeC9sgush%2B9U%3D" rel="nofollow">http://localhost</a>:4444 即可看到打包后的页面</p>
<h3>单页应用Docker化</h3>
<p>在<code>react-app</code>目录下新建<code>Dockerfile</code> <code>.dockerignore</code>和<code>nginx.conf</code></p>
<p><code>.dockerignore</code></p>
<pre><code>node_modules
build</code></pre>
<p><code>dockerignore</code>指定了哪些文件不需要被拷贝进镜像里,类似<code>.gitignore</code>。</p>
<p>我们知道单页应用的路由一般都被js托管,所以对于nginx需要特别配置</p>
<p><code>nginx.conf</code></p>
<pre><code class="bash">server {
listen 80;
server_name localhost;
location / {
root /app/build; # 打包的路径
index index.html index.htm;
try_files $uri $uri/ /index.html; # 防止重刷新返回404
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}</code></pre>
<p><code>Dockerfile</code></p>
<pre><code class="bash"># 基于node11
FROM node:11
# 设置环境变量
ENV PROJECT_ENV production
ENV NODE_ENV production
# 安装nginx
RUN apt-get update && apt-get install -y nginx
# 把 package.json package-lock.json 复制到/app目录下
# 为了npm install可以缓存
COPY package*.json /app/
# 切换到app目录
WORKDIR /app
# 安装依赖
RUN npm install --registry=https://registry.npm.taobao.org
# 把所有源代码拷贝到/app
COPY . /app
# 打包构建
RUN npm run build
# 拷贝配置文件到nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
# 启动nginx,关闭守护式运行,否则容器启动后会立刻关闭
CMD ["nginx", "-g", "daemon off;"]</code></pre>
<p>需要特别注意的是:</p>
<pre><code class="bash">COPY package*.json /app/
RUN npm install
COPY . /app</code></pre>
<p>我们单独把<code>package.json</code>文件先拷贝到<code>app</code>,安装完依赖,然后才把所有的文件拷贝到<code>app</code>,这是为什么?</p>
<p>这是为了充分利用docker缓存</p>
<pre><code>COPY . /app
RUN npm install</code></pre>
<p>如果这么写,那么每一次重新构建镜像,都需要下载一次npm包,这是非常浪费时间的!而把<code>package.json</code>与源文件分隔开写入镜像,这样只有当<code>package.json</code>发生改变了,才会重新下载npm包。</p>
<p>当然缓存有时候也会造成一些麻烦,比如在进行一些shell操作输出内容时,由于缓存的存在,导致新构建的镜像里的内容还是旧版本的。</p>
<p>我们可以指定构建镜像时不使用缓存</p>
<pre><code class="bash">docker build --no-cache -t deepred5/react-app .</code></pre>
<p>最佳实践是在文件顶部指定一个环境变量,如果希望不用缓存,则更新这个环境变量即可,因为缓存失效是从第一条发生变化的指令开始。</p>
<p><strong>打包镜像</strong></p>
<pre><code class="bash">docker build -t deepred5/react-app . </code></pre>
<p><strong>启动容器</strong></p>
<pre><code class="bash">docker run -d --name my-react-app -p 8888:80 deepred5/react-app</code></pre>
<p>访问 <a href="https://link.segmentfault.com/?enc=IOylzp1vGI7MaFSUIP%2FS5Q%3D%3D.HWD%2FCZ6KJfbOfJpUeK%2BBfApK%2FDyiaQ3VYeupvF2prEA%3D" rel="nofollow">http://localhost</a>:8888 即可看到页面</p>
<p>访问 <a href="https://link.segmentfault.com/?enc=T3ySWqjT8YsksRtEdmbbyA%3D%3D.ZKTkbMzwA%2BtoTK4mKQgSsDj%2Bku%2F%2B2jKxXZv1%2BqTmavE%3D" rel="nofollow">http://localhost</a>:8888/deepred5, 也可以看见页面,说明nginx防刷新配置生效了!</p>
<h3>多层构建</h3>
<p>我们之前写的<code>Dockerfile</code>其实是有些问题的: 镜像基于node11,但是整个镜像用到node环境的地方只是为了前端打包,真正启动的是Nginx。镜像里的项目源代码以及<code>node_modules</code>其实根本没有用,这些冗余文件造成了镜像的体积变得非常庞大。</p>
<p>而我们仅仅需要打包出来的静态文件以及启动一个静态服务器Nginx即可。</p>
<p>这时就可以使用<a href="https://link.segmentfault.com/?enc=5mQOviJcaL%2BbZZUBcTBWNQ%3D%3D.WEer9lvoZs92CFZkhwIiaUAGaBltzxs55zmwfJTzVcHqUExA2IzU9OzF4UyR3GgaFwnffvbNVNLugCpeG1yjAQzM%2BplsCU2B%2B1GdUj6fB40%3D" rel="nofollow">multi-stage</a>多层构建。</p>
<p>新建一个<code>Dockerfile.multi</code></p>
<pre><code class="bash"># node镜像仅仅是用来打包文件
FROM node:alpine as builder
ENV PROJECT_ENV production
ENV NODE_ENV production
COPY package*.json /app/
WORKDIR /app
RUN npm install --registry=https://registry.npm.taobao.org
COPY . /app
RUN npm run build
# 选择更小体积的基础镜像
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/build /app/build</code></pre>
<p>这个文件里,我们使用了两个<code>FROM</code>基础镜像,第一个<code>node:alpine</code>仅仅作为打包环境,真正的基础镜像是<code>nginx:alpine</code></p>
<p><strong>打包镜像</strong></p>
<pre><code class="bash"># -f 指定使用Dockerfile.multi进行构建
docker build -t deepred5/react-app-multi . -f Dockerfile.multi</code></pre>
<p><strong>启动容器</strong></p>
<pre><code class="bash">docker run -d --name my-react-app-multi -p 8889:80 deepred5/react-app-multi</code></pre>
<p>访问 <a href="https://link.segmentfault.com/?enc=dQLpqAi%2BWRnYPpybpq43lA%3D%3D.F%2F1naERKiOdA%2F2%2FpfY16QAkHbqrHAVTDguvAM6Ip3D4%3D" rel="nofollow">http://localhost</a>:8889 即可看到页面</p>
<p><strong>查看镜像大小</strong></p>
<pre><code class="bash">docker images deepred5/react-app-multi
docker images deepred5/react-app</code></pre>
<p>可以发现,两者的大小相差巨大。</p>
<p><code>deepred5/react-app</code>镜像有1G多,而<code>deepred5/react-app-multi</code>只有20多M</p>
<p>主要原因是:<code>deepred5/react-app</code>的基础镜像<code>node:11</code>就有900M,而<code>deepred5/react-app-multi</code>的基础镜像<code>nginx:alpine</code>只有20M。由此可见多层构建对于减少镜像大小是非常有帮助的。</p>
<h3>Node应用</h3>
<p>前端有时也会参与到Node BFF层的开发。我们来创建一个Node结合Redis的简单项目</p>
<pre><code>mkdir node-redis
cd node-redis
npm init -y
npm i koa koa-router ioredis
touch index.js</code></pre>
<p><code>node-redis/index.js</code></p>
<pre><code class="javascript">const Koa = require('koa');
const Router = require('koa-router');
const Redis = require("ioredis");
const app = new Koa();
const router = new Router();
const redis = new Redis({
port: 6379,
host: '127.0.0.1'
});
router.get('/', (ctx, next) => {
ctx.body = 'hello world.';
});
router.get('/api/json/get', async (ctx, next) => {
const result = await redis.get('age');
ctx.body = result;
});
router.get('/api/json/set', async (ctx, next) => {
const result = await redis.set('age', ctx.query.age);
ctx.body = {
status: result,
age: ctx.query.age
}
});
app
.use(router.routes())
.use(router.allowedMethods());
app.listen(3000, () => {
console.log('server start at localhost:3000');
})</code></pre>
<p>我们首先需要本地安装Redis,然后启动redis</p>
<pre><code>redis-server</code></pre>
<p>启动Node项目</p>
<pre><code>node index.js</code></pre>
<p>访问 <a href="https://link.segmentfault.com/?enc=Cvp52OH4Si929YwUaXagnA%3D%3D.s0CsOfw2W01ECWkGNpQWKWfwdyUCILUeXoOr8eR66zw%3D" rel="nofollow">http://localhost</a>:3000/ 即可看到页面</p>
<p>访问 <a href="https://link.segmentfault.com/?enc=NragfGfA81YcV4htyvGLOg%3D%3D.3VegBVeAAu6nUIryOLebeRRqVg%2BuVrtKoKWXxXl8r%2Fg%3D" rel="nofollow">http://localhost</a>:3000/api/json/set?age=2 ,我们就向Redis里设置<code>age</code>的值为2</p>
<p>访问 <a href="https://link.segmentfault.com/?enc=KyWt0dD5i%2F7ktx2ktrqWtg%3D%3D.n9oI8DTsnrgUuhhg0krDG0USlBJ2gar3diJtypRu6QI%3D" rel="nofollow">http://localhost</a>:3000/api/json/get ,我们就取得Redis里<code>age</code>的值</p>
<h3>Node应用Docker化</h3>
<p>首先我们来思考下,这个后端应用涉及Node和Redis。如果我们要部署到Docker里,应该怎么构建镜像?</p>
<ol>
<li>方案一:基于一个最基础的<code>ubuntu</code>镜像,然后我们在其中安装Node和Redis,这样Node和Redis之间就可以进行通信了。这种方案只需要启动一个容器,因为Node和Redis已经在这个容器里了。</li>
<li>方案二:我们基于<code>Redis</code>镜像启动一个容器,专门用来跑Redis。基于<code>Node</code>镜像再启动一个容器,专门用来跑Node。</li>
</ol>
<p>Docker的理念更倾向于方案二。我们希望一个镜像专注于做一件事,现在流行的微服务,微前端也是这种思想。</p>
<p>在<a href="https://link.segmentfault.com/?enc=Q6x2YaqlsCxRnjEcSMT6UA%3D%3D.%2BUwGLSeQTfXqIKouHMuQDLFFkZ4%2FHuXiTFOz3TA1jEBC7m8wI68Fv1YYrQPXzvKh" rel="nofollow">中级篇</a>中我们说过每个容器都是相互隔离的,通过映射端口才能访问容器里的网络应用。但是容器和容器之间怎么进行通信呢?</p>
<p>Docker里使用<code>Networking</code>进行容器间的通信</p>
<h3>Networking</h3>
<pre><code class="bash"># 创建一个app-test网络
docker network create app-test</code></pre>
<p>我们只需要把需要通信的容器都加入到<code>app-test</code>网络里,之后容器间就可以互相访问了。</p>
<pre><code class="bash">docker run -d --name redis-app --network app-test -p 6389:6379 redis
docker run -it --name node-app --network app-test node:11 /bin/bash</code></pre>
<p>我们创建了两个容器,这两个容器都在<code>app-test</code>网络里。</p>
<p>我们进入<code>node-app</code>容器里,然后<code>ping redis-app</code>,发现可以访ping通,说明容器间可以通信了!</p>
<p>我们修改之前的代码:</p>
<pre><code class="javascript">const redis = new Redis({
port: 6379,
host: 'db',
});</code></pre>
<p>redis的<code>host</code>改为<code>db</code></p>
<p>新建一个<code>Dockerfile</code></p>
<pre><code class="bash">FROM node:11
COPY package*.json /app/
WORKDIR /app
RUN npm install
COPY . /app
EXPOSE 3000
CMD ["node","index.js"]</code></pre>
<p><strong>构建镜像</strong></p>
<pre><code>docker build -t deepred5/node-redis-app .</code></pre>
<p><strong>启动容器</strong></p>
<pre><code class="bash"># 创建网络
docker network create app-test
# 启动redis容器
docker run -d --name db --network app-test -p 6389:6379 redis
# 启动node容器
docker run --name node-redis-app -p 4444:3000 --network app-test -d deepred5/node-redis-app</code></pre>
<p>访问 <a href="https://link.segmentfault.com/?enc=p460sxu4h4ZbDEmQiXxw1A%3D%3D.yMP81uMFCr81nVKRh7x0NdOKNDvhFsusHrbUnxldHvs%3D" rel="nofollow">http://localhost</a>:4444/ 即可看到页面</p>
<p>还记得我们之前做的<code>react-app</code>单页应用吗?我们可以也把这个应用加入到<code>app-test</code>网络里来,这样前端单页应用也可以访问后端了!</p>
<p>修改<code>react-app</code>目录下的<code>nginx.conf</code></p>
<pre><code class="bash">server {
listen 80;
server_name localhost;
location / {
root /app/build; # 打包的路径
index index.html index.htm;
try_files $uri $uri/ /index.html; # 防止重刷新返回404
}
location /api {
proxy_pass http://node-redis-app:3000; #后台转发地址
}
}</code></pre>
<p>重新构建镜像</p>
<pre><code class="bash">docker build -t deepred5/react-app-multi . -f Dockerfile.multi</code></pre>
<p>启动容器</p>
<pre><code>docker run -d --name my-react-app-multi --network app-test -p 9999:80 deepred5/react-app-multi</code></pre>
<p>访问 <a href="https://link.segmentfault.com/?enc=7JMa2FNnzYaCNSDIouyJlg%3D%3D.hEgvEBKSf1P0f%2FJwhYy1hEWEntGtwZRycLfDl7H0bjE%3D" rel="nofollow">http://localhost</a>:9999/api/json/set?age=55 成功返回数据</p>
<h3>Docker compose</h3>
<p>我们现在这个项目有3个启动镜像:</p>
<ul>
<li>
<code>deepred5/react-app-multi</code> 前端单页应用</li>
<li>
<code>redis</code> 数据缓存</li>
<li>
<code>deepred5/node-redis-app</code> 后端服务,访问redis,同时给前端提供接口</li>
</ul>
<p>如果要把这个项目完整的启动起来,按照顺序需要这样启动:</p>
<pre><code class="bash"># 启动redis容器
docker run -d --name db --network app-test -p 6389:6379 redis
# 启动node容器
docker run --name node-redis-app -p 4444:3000 --network app-test -d deepred5/node-redis-app
# 启动前端容器
docker run -d --name my-react-app-multi --network app-test -p 9999:80 deepred5/react-app-multi</code></pre>
<p>这还仅仅只是3个容器的项目,如果容器再多,启动就变得非常复杂了!</p>
<p>这时,就需要<code>docker compose</code>出场了。</p>
<p>首先需要安装<a href="https://link.segmentfault.com/?enc=IMFT2tVBPE%2BUkZNSSnA2RQ%3D%3D.awjnyAfGpTjVWItVinbzkz6ATKaC0CtCLFYzCofjl%2BKnSORl%2FZ069HQsnykZSDW0" rel="nofollow">docker compose</a>,安装完成之后</p>
<p>我们新建一个<code>my-all-app</code>目录,然后新建<code>docker-compose.yml</code></p>
<pre><code class="bash">mkdir my-all-app
cd my-all-app
touch docker-compose.yml</code></pre>
<pre><code class="yml">version: '3.7'
services:
db:
image: redis
restart: always
ports:
- 6389:6379
networks:
- app-test
node-redis-app:
image: deepred5/node-redis-app
restart: always
depends_on:
- db
ports:
- 4444:3000
networks:
- app-test
react-app-multi:
image: deepred5/react-app-multi
restart: always
depends_on:
- node-redis-app
ports:
- 9999:80
networks:
- app-test
networks:
app-test:
driver: bridge</code></pre>
<pre><code class="bash"># 启动所有容器
docker-compose up -d
# 停止所有容器
docker-compose stop</code></pre>
<p>访问 <a href="https://link.segmentfault.com/?enc=Y%2BflBvzyM8iImGQEniJjYQ%3D%3D.Xft3ClsSBshSxtSqpLpclsuHsfofk5MbiiSnBhILl9Y%3D" rel="nofollow">http://localhost</a>:9999 查看前端页面</p>
<p>访问 <a href="https://link.segmentfault.com/?enc=DvPI8Ds2KQ%2FoykrBNKV4JA%3D%3D.oY28k5peaYo6tZXG48ol%2BDF8XKwdBVbsvdfnf43Z7ko%3D" rel="nofollow">http://localhost</a>:4444 查看后端接口</p>
<p>可以看见,使用<code>docker-compose.yml</code>配置完启动步骤后,启动多容器就变得十分简单了。</p>
<h3>参考</h3>
<ul>
<li><a href="https://link.segmentfault.com/?enc=JDtJTr1Jx6R410MiD5UYPA%3D%3D.JRp0NKV%2BcJKC8hZMmdboAMvV5sqg1rORxYU2ccmtyEwYFBBM2hCAWm0XBWEdpmK4" rel="nofollow">如何使用docker部署前端应用</a></li>
<li><a href="https://link.segmentfault.com/?enc=fJTNHxBUoFwZf0IKlMSG%2Fg%3D%3D.%2BhJBYK1Iy2DUL3QfWxErZX2SojEVf%2F9ZszOb5SL6TOzb1qVJeKiSMQu90QMAxEUw" rel="nofollow">第一本Docker书 修订版</a></li>
</ul>
写给前端工程师看的Docker教程-基础篇
https://segmentfault.com/a/1190000020609836
2019-10-08T10:37:57+08:00
2019-10-08T10:37:57+08:00
深红
https://segmentfault.com/u/deepred5
2
<p>最近公司在推进容器化和k8s,项目都要改成Docker部署。负责的工程里有几个node项目,只能从零开始学习Docker了。</p>
<h3>安装</h3>
<p>Docker支持window, Mac, Linux, 教程参考<a href="https://link.segmentfault.com/?enc=%2BUx70uNQDYXdvD%2FSQl%2FBLQ%3D%3D.aOnf%2FSrR6OtvhM6OjZizfzcIGvCBcyZvWcqbK8W7ivWIK6IDbD9brCOs6xtTo77w%2BIj4i05GzOsXI7alcS34sA%3D%3D" rel="nofollow">Docker安装教程</a>。</p>
<p>建议在Mac和Linux系统里使用Docker。</p>
<p>平时开发,我使用的是vscode编辑器,可以顺便安装docker插件。在插件商店搜索<code>docker</code>,安装完成后,我们可以很方便的管理Docker镜像和容器。</p>
<h3>快速使用</h3>
<p>首先我们来体验一下Docker。</p>
<p>平时工作中,如果我们电脑的开发环境是Windows, 有一天希望在Linux环境做一些事情,那该怎么办?(没有云服务器的前提下)大多数人这时会选择去用虚拟机安装一个ubuntu系统。不过安装虚拟机前,你得先去下载几个G的镜像,然后在VMware里配置一些参数,最后还要等待最少十几分钟的系统安装。等你安装完一个ubuntu系统,估计已经浪费了几个小时。</p>
<p>然而使用Docker,你只需要几分钟!</p>
<pre><code class="bash"># 拉取ubuntu镜像
docker pull ubuntu
# 创建一个ubuntu容器并且使用终端进行交互
docker run -it --name my-ubuntu --rm ubuntu /bin/bash</code></pre>
<p>创建成功后,你就进入一个ubuntu系统里,现在你可以在其中进行任意的操作了。</p>
<p><strong>注意:虽然当前容器里是ubuntu系统,但是你只能把它想象成一个精简版的ubuntu,因此有很多常用命令,需要自己去安装。</strong></p>
<pre><code class="bash">curl -v bilibili.com</code></pre>
<p>直接运行<code>curl</code>命令会提示命令不存在</p>
<pre><code class="bash"># 安装curl
apt-get update
apt-get install -y curl</code></pre>
<p>安装完成后,才能使用<code>curl</code>命令</p>
<p>退出容器</p>
<pre><code class="bash">exit</code></pre>
<h3>基本概念</h3>
<ol>
<li>镜像(Image):类似于虚拟机中的镜像。镜像有两种:基础镜像和个人镜像。基础镜像由各大厂商提供,比如<code>ubuntu</code>镜像,<code>node</code>镜像。个人镜像则是由个人开发者构建上传。</li>
<li>容器(Container):类似于一个轻量级的沙盒。容器是基于镜像来创建的,<code>ubuntu</code>镜像并不能和我们进行各种交互,我们希望有个环境能运行<code>ubuntu</code>,于是基于<code>ubuntu</code>镜像创建了一个容器。</li>
<li>仓库(Repository):类似于代码仓库,这里是镜像仓库,是Docker用来集中存放镜像文件的地方。</li>
</ol>
<p>我们可以这样类比:</p>
<pre><code class="bash"># 下载源代码
git clone deepred5/app
# 启动app
npm run start</code></pre>
<pre><code class="bash"># 拉取镜像
docker pull deepred5/app
# 创建容器
docker run deepred5/app</code></pre>
<p>Docker是基于c/s架构:我们在Client中执行Docker命令,最后创建的Container和Image则会在Server中运行</p>
<pre><code class="bash"># 可以查看server和client信息
docker info</code></pre>
<h3>镜像(Image)</h3>
<p><strong>常用命令</strong></p>
<pre><code class="bash"># 查找镜像
docker search ubuntu
# 拉取特定tag版本的镜像(默认是latest)
docker pull ubuntu:18.0.4
# 查看下载的所有本地镜像
docker images
# 删除镜像
docker rmi ubuntu:18.0.4</code></pre>
<p><strong>构建镜像</strong></p>
<p>我们一般都是基于基础镜像来构建个人镜像。镜像是由一条条指令构建出来(Dockerfile)</p>
<p>我们来构建一个<code>node-pm2</code>镜像,这个镜像自带node和pm2:</p>
<p>创建一个<code>node-pm2</code>目录,并新建一个<code>Dockerfile</code>文件</p>
<pre><code class="bash">mkdir node-pm2
cd node-pm2
touch Dockerfile</code></pre>
<p>编辑<code>Dockerfile</code></p>
<pre><code class="bash"># 基于node11基础镜像
FROM node:11
# 一些元数据,比如作者信息
LABEL maintainer="deepred5 <deepred5@gamil.com>"
# 安装pm2
RUN npm install pm2 -g --registry=https://registry.npm.taobao.org
# 暴露容器的端口
EXPOSE 80 443</code></pre>
<p>基于这个<code>Dockerfile</code>创建我们自己的镜像<code>deepred5/node-pm2</code></p>
<pre><code class="bash">docker build -t deepred5/node-pm2:1.0 .</code></pre>
<p>注意最后有一个<code>.</code></p>
<p>查看我们自己的镜像</p>
<pre><code class="bash"># 可以看到deepred5/node-pm2镜像了
docker images</code></pre>
<p>基于<code>deepred5/node-pm2</code>镜像启动一个容器</p>
<pre><code class="bash">docker run -it deepred5/node-pm2:1.0 /bin/bash</code></pre>
<p>进入容器后,我们运行<code>pm2 -v</code>,可以看见pm2已经安装成功了</p>
<p><strong>上传镜像</strong></p>
<p>我们本地构建的镜像如果希望可以被其他人使用,就需要把镜像上传到仓库。登录<a href="https://link.segmentfault.com/?enc=3MvZfU2VkXIo9ZNdinm%2BOw%3D%3D.IwdQ49x%2B8NkW%2F9qiOxSukK%2BVs9N9wM5H%2FcKlZ3dv2BA%3D" rel="nofollow">dockerhub</a>,注册一个账户。</p>
<pre><code class="bash"># 登入账户,输入用户名和密码
docker login
# 上传镜像
docker push deepred5/node-pm2:1.0</code></pre>
<p>注意:<code>deepred5/node-pm2</code>改成<code>你的用户名/node-pm2</code>,你需要重新构建一个<code>你的用户名/node-pm2</code>的镜像,然后才能上传到dockerhub</p>
<h3>容器(Container)</h3>
<p>我们平时基本都是在和容器打交道。</p>
<pre><code class="bash"># 基于ubuntu镜像创建my-ubuntu容器。如果本地没有ubuntu镜像,会先去docker pull下载
docker run -it ubuntu:latest --name my-ubuntu /bin/bash</code></pre>
<p>参数解释:</p>
<p><code>-i</code>: 允许你对容器内的标准输入 (STDIN) 进行交互</p>
<p><code>-t</code>: 在新容器内指定一个伪终端或终端。</p>
<p><code>--name</code>: 容器的名字,默认是随机的名字</p>
<p><code>/bin/bash</code>: 启动容器后立即执行的命令</p>
<pre><code class="bash"># 停止容器
docker stop my-ubuntu
# 启动容器
docker start my-ubuntu
# 删除容器
docker rm my-ubuntu
# 删除所有容器
docker rm `docker ps -aq`</code></pre>
<pre><code class="bash"># 查看正在运行的容器
docker ps
# 查看所有创建过的容器(运行或者关闭)
docker ps -a</code></pre>
<p><code>docker start my-ubuntu</code>启动的容器,虽然容器运行着,但是我们无法进入到容器里。</p>
<p>如何再次进入到容器里?</p>
<pre><code class="bash">docker exec -it my-ubuntu /bin/bash</code></pre>
<p><strong>容器运行的两种方式</strong></p>
<ul>
<li>交互式运行(-it)</li>
<li>守护式运行(没有交互式会话,长期运行,适合运行应用程序和服务)(-d)</li>
</ul>
<p>可以这样类比:</p>
<p><code>node index.js</code>: 交互式运行</p>
<p><code>pm2 start index.js</code>: 守护式运行</p>
<p>大部分情况都是运行守护式容器(daemonized container)</p>
<pre><code class="bash"># 启动了容器,然后容器立即关闭
docker run ubuntu /bin/bash
# 启动了容器,并开启了交互式的终端,只有输入exit才退出终端,退出终端后,容器仍然在后台运行
docker run -it ubuntu /bin/bash
# 启动了容器,并且在后台一直运行,每隔1s输出hello world
docker run -d ubuntu /bin/sh -c "while true; do echo hello world; sleep 1; done"</code></pre>
<p><strong>查看容器日志</strong></p>
<pre><code class="bash">docker run -d --name my_container ubuntu /bin/sh -c "while true; do echo hello world; sleep 1; done"</code></pre>
<pre><code class="bash"># 查看后台运行的日志
docker logs my_container
# 实时监控(类似tail -f)
docker logs -f my_container
# 获取最后10行
docker logs --tail 10 my_container
# 实时查看最近的日志
docker logs --tail 0 -f my_container
# 加上时间戳
docker logs -t my_container</code></pre>
<h3>Nginx</h3>
<p>前端最常使用的静态服务器就是Nginx了。</p>
<pre><code class="bash">docker run -d --name my-nginx -p 8888:80 nginx</code></pre>
<p>访问 <a href="https://link.segmentfault.com/?enc=pXD7jNLQN9dGVQK6FMhu9Q%3D%3D.fpDMkkdofO6cG2HFq5LqP7jlTpUCHkj88rUeOS0EsqQ%3D" rel="nofollow">http://localhost</a>:8888/ 即可看到熟悉的欢迎页面</p>
<p>参数解释:</p>
<p><code>-d</code>: 基础篇里已经解释过了,守护运行方式</p>
<p><code>-p</code>: 端口映射。<code>8888:80</code>表示把本地的8888端口映射到容器的80端口</p>
<p>为什么要映射端口?因为Docker里每个容器都是相对独立的,拥有自己的内部ip。容器里运行的一些网络应用,要让外部也可以访问,就需要将端口映射到宿主机上。</p>
<pre><code class="bash">docker port my-nginx </code></pre>
<p><code>80/tcp -> 0.0.0.0:8888</code>即可看到映射的端口了</p>
<p>如果我们希望修改Nginx欢迎页的内容,怎么办?</p>
<p>最容易想到的方法是:我们进入到容器里,然后修改<code>/usr/share/nginx/html</code>目录里的<code>index.html</code></p>
<pre><code class="bash"># 进入nginx容器里
docker exec -it my-nginx /bin/bash</code></pre>
<p>不过这种方法拓展性不高,假如有多个Nginx容器,难道我们需要一个个的进入容器去修改?</p>
<p>这时就要引出数据卷(Volume)的概念了。</p>
<h3>数据卷(Volume)</h3>
<p>类似端口映射,我们可以把容器内部的目录映射到宿主机的目录,实现容器之间实现共享和重用。</p>
<p>新建<code>my-nginx</code>目录,新建<code>index.html</code></p>
<pre><code class="bash">mkdir my-nginx
cd my-nginx
touch index.html</code></pre>
<p><code>index.html</code></p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<h1>hello world</h1>
</body>
</html></code></pre>
<pre><code class="bash">docker run --name nginx-test \
--rm -p 8888:80 \
-v $PWD:/usr/share/nginx/html \
-d nginx</code></pre>
<p><strong>小技巧:如果命令行过长,可以使用符号多行书写</strong></p>
<p>访问 <a href="https://link.segmentfault.com/?enc=fInl8yUS2XGWHp%2Bh2W7q3A%3D%3D.jr3E7b00of8QpQYKmow9sgUAzXftry%2F2%2FoOwohsLb6w%3D" rel="nofollow">http://localhost</a>:8888/ 已经发生变化了!</p>
<p>参数解释:</p>
<p><code>-v</code>: <code>$PWD:/usr/share/nginx/html</code>表示把容器内的<code>/usr/share/nginx/html</code>映射到当前目录,也就是<code>my-nginx</code>目录。于是nginx返回的<code>index.html</code>也就变成了我们本地的<code>index.html</code>了。</p>
<p>我们可以试着在本地新建一个<code>1.html</code>,然后访问 <a href="https://link.segmentfault.com/?enc=2t1JycQXL19NNSV1bRWgEQ%3D%3D.vVY4PFSXZDg1NQ%2BFHLbNg9sNOX0lGpR5iUvqinaO%2BuQ%3D" rel="nofollow">http://localhost</a>:8888/1.html 也可以看到输出了内容。</p>
<p>同理,如果我们希望修改容器里Nginx的配置,也可以把容器的<code>/etc/nginx/conf.d/</code>映射到本地,然后在本地新建配置<code>mydefault.conf</code></p>
<p>为了复习一下基础篇的内容,我们希望构建一个本地的镜像,这个镜像基于Nginx,默认的欢迎页面内容就是我们刚刚新建的index.html</p>
<p>在<code>my-nginx</code>目录,新建<code>Dockerfile</code></p>
<pre><code class="bash">FROM nginx
# 将当前的index.html拷贝到容器的/usr/share/nginx/html/index.html
COPY ./index.html /usr/share/nginx/html/index.html
EXPOSE 80</code></pre>
<p><code>docker build -t my-nginx .</code>构建镜像</p>
<p><code>docker run -d --rm -p 4445:80 my-nginx</code> 创建容器,访问 <a href="https://link.segmentfault.com/?enc=ggEOA9dAF1FrJUkWvlVCuw%3D%3D.8rWeXY10h%2FpJLvIBbj9HWH6NjIeVE%2FobVIeyjwiaK%2Bg%3D" rel="nofollow">http://localhost</a>:4445 可以看到效果了。</p>
<h3>Redis</h3>
<p>我们也可以在Docker里运行Redis。</p>
<pre><code class="bash">docker pull redis
docker run -d --name my-redis -p 6389:6379 redis</code></pre>
<p>进入容器并且连接到redis</p>
<pre><code class="bash"># 进入my-redis容器里,并且在容器里执行redis-cli命令
docker exec -it my-redis redis-cli </code></pre>
<p>于是我们就连接到redis里了,并且可以执行相应的redis命令</p>
<pre><code class="bash"># 设置name
set name tc
# 获取name
get name</code></pre>
<p>因为我们把容器的6379端口映射到了本机的6389,所以我们也可以直接在本地连接容器里的redis</p>
<pre><code class="bash"># 需要你本地安装了redis-cli
redis-cli -h 127.0.0.1 -p 6389
# 返回tc
get name</code></pre>
<h3>总结</h3>
<p>我们主要学习了Docker里镜像和容器的基本概念,掌握了端口映射(-p)和目录映射(-v)的用法,同时学习了如何在Docker里使用Nginx和Redis。在<a href="https://segmentfault.com/a/1190000020610106">下一篇文章</a>里,会继续介绍Docker实战。</p>
<h3>参考</h3>
<ul>
<li><a href="https://link.segmentfault.com/?enc=pnhNsmRPwq7WssIR%2FTVlVw%3D%3D.ymxBxM0v23Q%2B6Ys33d1E86T6L92LJ6CCDo9MiiZcgD5UWu2tdYMyhu7Zhu%2BvqXPZ" rel="nofollow">只要一小时,零基础入门Docker</a></li>
<li><a href="https://link.segmentfault.com/?enc=8yzruuMkGE%2Fet%2FTwT1xSBA%3D%3D.Y6Mo%2BzFH0AnkW0mQeLtE6u5syO%2B13QDUt9mgWOf8J6zNf2BA1%2Bd1zMgWkKmhf4kh" rel="nofollow">10分钟看懂Docker和K8S</a></li>
</ul>
使用Web Worker优化代码
https://segmentfault.com/a/1190000020346103
2019-09-10T14:35:31+08:00
2019-09-10T14:35:31+08:00
深红
https://segmentfault.com/u/deepred5
0
<p>前段时间有个需求,需要前端导出excel。一般来说,对于导出大量数据的功能,最好还是交给后端来做,然而后端老哥并不想做(<del>撕逼失败</del>),只能自力更生。</p>
<p>前端导出excel本身已经有很成熟的库了,比如<a href="https://link.segmentfault.com/?enc=2vU3sPqIaTmJol5417lZbA%3D%3D.tP8hcIqE8Se0ZmAOZV8%2BladW%2BcYu8P7BemDAJdD62FRL9edaXdTRJiHjgPEdfx48" rel="nofollow">js-xlsx</a>, <a href="https://link.segmentfault.com/?enc=IUpgSZcnxzKMP8yYZasAuA%3D%3D.GSF1GQMmHmTTBGF5yph4oN7FCiRoX6WyVahxHc9vMK%2BlsUHuwQizK%2BWnYrMTT0wD" rel="nofollow">js-export-excel</a>,所以实现起来并不难。但是,当导出的数据达到几万条时,就会发现页面产生了明显的卡顿。原因也很简单: 一般我们都是基于后端返回的json数据来生成excel,但是后端返回的数据一般都不能直接用来生成数据,我们还需要进行一些格式化:</p>
<pre><code class="javascript">const list = await request('/api/getExcelData');
const format = list.map((item) => {
// 对返回的json数据进行格式化
item.time = moment(item.time).format('YYYY-MM-DD HH:mm');
// ... 省略其他各种操作
});
// 根据json生成excel
const toExcel = new ExportJsonExcel(format).saveExcel();</code></pre>
<p>卡顿就发生在对大量数据进行<code>map</code>操作。由于JS是单线程的,所以在进行大量复杂运算时会独占主线程,导致页面的其他事件无法及时响应,造成页面假死的现象。</p>
<p>那我们能不能把复杂的循环操作单独放在一个线程里呢?这时就要请出<a href="https://link.segmentfault.com/?enc=vhFnTxbASEsJiY3J9IoU4w%3D%3D.tkfqYSqCmmvaLQ9uYPc51TpqOnlYBBz%2FPFVqt6Pbk0d%2B0uT8wJHaBOoFAv7w1UvU4po1GzXDRwCYrQraw4aqam3l8QeUYdN11SHuGH9fNfTLEa7xKTS7lcf%2FqT1z9bEb" rel="nofollow">web worker</a>了</p>
<h3>Web Worker</h3>
<p>首先看个简单的例子</p>
<pre><code class="html"><button id="btn1">js</button>
<button id="btn2">worker</button>
<input type="text"></code></pre>
<p><code>index.js</code></p>
<pre><code class="javascript">const btn1 = document.getElementById('btn1');
btn1.addEventListener('click', function () {
let total = 1;
for (let i = 0; i < 5000000000; i++) {
total += i;
}
console.log(total);
})</code></pre>
<p>点击btn1时,js会进行大量计算,你会发现页面卡死了,点击input不会有任何反应</p>
<p>我们使用web worker优化代码:<br><code>worker.js</code></p>
<pre><code class="javascript">onmessage = function(e) {
if (e.data === 'total') {
let total = 1;
for (let i = 0; i < 5000000000; i++) {
total += i;
}
postMessage(total);
}
}</code></pre>
<p><code>index.js</code></p>
<pre><code class="javascript">if (window.Worker) {
const myWorker = new Worker('worker.js');
myWorker.onmessage = function (e) {
console.log('total', e.data);
};
const btn1 = document.getElementById('btn1');
const btn2 = document.getElementById('btn2');
btn1.addEventListener('click', function () {
let total = 1;
for (let i = 0; i < 5000000000; i++) {
total += i;
}
console.log('total', total);
})
btn2.addEventListener('click', function () {
myWorker.postMessage('total');
});
}</code></pre>
<p>点击btn2时,页面并不会卡死,你可以正常的对input进行输入操作</p>
<p>我们开启了一个单独的worker线程来进行复杂操作,通过<code>postMessage</code>和<code>onmessage</code>来进行两个线程间的通信。</p>
<h3>优化导出excel表格</h3>
<p>看过前面的例子,我们可以同理使用web worker进行复杂的map操作<br><code>worker.js</code></p>
<pre><code class="javascript">onmessage = function(e) {
const format = e.data.map((item) => {
// 对返回的json数据进行格式化
item.time = moment(item.time).format('YYYY-MM-DD HH:mm');
// ... 省略其他各种操作
});
postMessage(format);
}</code></pre>
<pre><code class="javascript">const myWorker = new Worker('worker.js');
myWorker.onmessage = function (e) {
// 根据json生成excel
const toExcel = new ExportJsonExcel(e.data).saveExcel();
};
const list = await request('/api/getExcelData');
myWorker.postMessage(list);</code></pre>
<p>当然实际项目,我们一般都是用webpack打包的,这时就要进行一些特别处理,需要使用<a href="https://link.segmentfault.com/?enc=FS3dBjyUMbLI9UJ16WrXhg%3D%3D.8ig5QFkTqo%2BugkNW%2BxC7gSz45zSd%2FTjVSBD2C9IFDoSFlg8JGU2S6l1kpQC7q8zzuezyIj%2BsVTZJN1uqtRYqrQ%3D%3D" rel="nofollow">worker-loader</a>,可以参考<a href="https://link.segmentfault.com/?enc=Q6%2FLLal7zGahWKdg4diAzw%3D%3D.tYUmikTg4vKuD2tvSb06wOfAckUEBWjlDAP46foDrSzlD66QyjU1doI9z%2FtH%2Fo1X" rel="nofollow">《怎么在 ES6+Webpack 下使用 Web Worker》</a>文章学习。</p>
<h3>进一步优化</h3>
<p>在上面的代码修改中,我们只是优化了业务逻辑里面的map操作。因为我使用的js库是<code>js-export-excel</code>,从它的<a href="https://link.segmentfault.com/?enc=O%2Bid1VP%2BRThLGFQWFYcUmg%3D%3D.rCHfgfFKToNfswogGRbtdbUYcLX8DFDpwiP2S0zKgahpU63DB9BV8SPjxLpBVI3LDTQ%2FZ0ozBYLaLmsW2OfdUeHnahCS%2BqnGt3FPIXUZN%2BIc6FMo35TBOsdDpXKdIv%2FU" rel="nofollow">源码</a>里可以看见,对于我们传进来的数据,它还会再一次forEach循环操作,进行数据的二进制转换。因此,这一步的forEach循环,理论上也可以在web worker里面进行操作。</p>
<p>最简单想到的方法是:<br><code>worker.js</code></p>
<pre><code class="javascript">onmessage = function(e) {
const format = e.data.map((item) => {
// 对返回的json数据进行格式化
item.time = moment(item.time).format('YYYY-MM-DD HH:mm');
// ... 省略其他各种操作
});
// 直接在worker里面生成excel
const toExcel = new ExportJsonExcel(format).saveExcel();
}</code></pre>
<p>直接在<code>worker.js</code>里面生成excel。然而,<code>saveExcel</code>这个方法需要用到<code>document</code>对象,但是在worker里,我们不能访问类似<code>window</code> <code>document</code>的全局对象。</p>
<p>因此,只能魔改源码了。。。</p>
<p>真正用到<code>document</code>对象的是<a href="https://link.segmentfault.com/?enc=iUn5RQc0AS9bC3yXcMUgqg%3D%3D.iAohLIe%2FhsxpB7%2Fa7%2BRf727ez%2FjlKqpnTdCHjUf8kLCigs1GzjNFtjuvX%2FMh4vY62OVBcoTEUwMg7vid5z3fV0Ud85sCwSM82fSV8IZS%2B7VQHActvtYwOtxQ18LKDDXu" rel="nofollow">源码</a>这一句:</p>
<pre><code class="javascript">// saveAs和Blob用到了document
saveAs(
new Blob([s2ab(wbout)], {
type: "application/octet-stream"
}),
_options.fileName + ".xlsx"
);</code></pre>
<p><code>saveExcel</code>方法只需改成:</p>
<pre><code class="javascript">// 不生成excel,只返回数据
return s2ab(wbout);</code></pre>
<p><code>worker.js</code></p>
<pre><code class="javascript">onmessage = function(e) {
const format = e.data.map((item) => {
// 对返回的json数据进行格式化
item.time = moment(item.time).format('YYYY-MM-DD HH:mm');
// ... 省略其他各种操作
});
// saveExcel只返回blob数据
const blob = new ExportJsonExcel(format).saveExcel();
postMessage(blob);
}</code></pre>
<p><code>index.js</code></p>
<pre><code class="javascript">myWorker.onmessage = function (e) {
// 在主线程生成excel
saveAs(
new Blob([e.data], {
type: "application/octet-stream"
}),
"test.xlsx"
);
};</code></pre>
<p>原理就是:我们只把数据转换放在worker里,最后生成excel仍然在主线程里完成。</p>
<p>至此,优化完成了!</p>
<h3>总结</h3>
<p>我们可以把一些耗性能的操作放在worker线程里(比如大文件上传),这样主线程就能及时响应用户操作而不会造成卡顿现象。需要注意的是,在worker里进行的复杂计算,运行时间并不会变短,有时耗费时间甚至更长,毕竟开启worker也需要消耗一定的性能。</p>
lerna管理package
https://segmentfault.com/a/1190000019700577
2019-07-09T09:49:39+08:00
2019-07-09T09:49:39+08:00
深红
https://segmentfault.com/u/deepred5
10
<p>最近发现公司一个项目的目录组织挺奇怪的,所有的子项目都放在了<code>packages</code>目录里,还有这种骚操作?特意查了下资料,发现是一种比较流行的<code>monorepo</code>项目管理模式。近几年比较火的React,Vue,Babel都是用的这种模式:</p>
<p><img src="/img/remote/1460000019704391?w=2112&h=1182" alt="" title=""></p>
<p>我们平常一般采用的都是<code>multiple repositories</code>的项目管理模式:把一个大项目拆分成若干个小项目,每个小项目都独立的放在gitlab上。这种模式其实也没啥不好,但是某些情况下,子项目A依赖子项目B,如果子项目B经常改动,那么每次B改动了,都要修改A,这时就非常麻烦。在开发一个前端框架或者UI库时,就经常会遇到上述情况,这时我们就可以考虑下<code>monorepo</code>。</p>
<p><code>monorepo</code>说到底也只是一个理念,那么怎么才能实现这种代码组织呢?</p>
<ul>
<li><a href="https://link.segmentfault.com/?enc=vapSkoYTtoluEujm1tQ5jw%3D%3D.qgKieyC21HpyB37dyQ6ZRnbw%2BzFRVZzIAm2YbtNAF24%3D" rel="nofollow">lerna</a></li>
<li>yarn中的<a href="https://link.segmentfault.com/?enc=EFZBFV6Xnx5rgmENSmNJdg%3D%3D.NAJsemuLJTXK99w73iorKkeHoZBjUxhNui%2BHvHJ1ER530smmaVSRrGzfgFt6TmyfTIQHywi90MlGLPx3wKOm%2BA%3D%3D" rel="nofollow">Workspace</a>
</li>
</ul>
<p>本文主要介绍下lerna的使用</p>
<p><a href="https://link.segmentfault.com/?enc=bBGVhGGW8R8ST8PeRX34WQ%3D%3D.Ke37Vdwhph%2BV1l9mNID1se0nrbjLw8oQfRnRSVGau4mDQOx3pB5ek5klsL7gY2X9" rel="nofollow">源码参考</a></p>
<h3>lerna</h3>
<p>全局安装<a href="https://link.segmentfault.com/?enc=RfdpcAQVMO6f62J5qNLNHA%3D%3D.8Z6tCXInl6usnIQIfVDft7pPHe%2Bbiz5a4MpZp3EDbrI%3D" rel="nofollow">lerna</a></p>
<pre><code>npm i lerna -g</code></pre>
<p>lerna是基于git的,在github上新建一个项目<code>learn-lerna</code></p>
<pre><code>git clone git@github.com:deepred5/learn-lerna.git
cd learn-lerna</code></pre>
<p>初始化项目:</p>
<pre><code>lerna init</code></pre>
<p><img src="/img/remote/1460000019704392?w=240&h=133" alt="" title=""></p>
<p>lerna会自动创建一个<code>packages</code>目录夹,我们以后的项目都新建在这里面。同时还会在根目录新建一个<code>lerna.json</code>配置文件</p>
<pre><code class="javascript">{
"packages": [
"packages/*"
],
"version": "0.0.0" // 共用的版本,由lerna管理
}</code></pre>
<h3>创建package</h3>
<p>我们创建两个package:</p>
<pre><code>cd packages
mkdir prpr-lerna-core
cd prpr-lerna-core
npm init -y</code></pre>
<pre><code>cd packages
mkdir prpr-lerna-popular
cd prpr-lerna-popular
npm init -y</code></pre>
<p><strong>注意:这两个package我们最后都是要发布到npm上的,所以名字请取特殊些,不能被人用过</strong></p>
<h3>添加依赖</h3>
<p><code>prpr-lerna-popular</code>依赖<code>prpr-lerna-core</code>,这时有两种方法添加依赖:</p>
<p>第一种方法是修改<code>prpr-lerna-popular/package.json</code>,添加</p>
<pre><code class="javascript">{
"dependencies": {
"prpr-lerna-core": "^1.0.0"
}
}
</code></pre>
<p>然后运行<code>lerna bootstrap</code></p>
<p>第二种方法是直接使用命令<code>add</code></p>
<pre><code>lerna add prpr-lerna-core --scope=prpr-lerna-popular</code></pre>
<p>运行之后,我们发现<code>prpr-lerna-popular</code>生成了<code>node_modules</code>,而<code>node_modules</code>里生成了指向<code>prpr-lerna-core</code>的<strong>软链</strong>,类似<code>npm link</code>的效果:<br><img src="/img/remote/1460000019704393?w=695&h=112" alt="" title=""></p>
<p>新建<code>prpr-lerna-core/index.js</code></p>
<pre><code class="javascript">const API = 'https://yande.re/post/popular_recent.json';
module.exports = {
API
}</code></pre>
<p><code>prpr-lerna-popular</code>除了依赖<code>prpr-lerna-core</code>,还可以依赖其他开源的库,比如我们使用<code>axios</code></p>
<pre><code>lerna add axios --scope=prpr-lerna-popular</code></pre>
<p>新建<code>prpr-lerna-popular/index.js</code></p>
<pre><code class="javascript">const { API } = require('prpr-lerna-core');
const axios = require('axios');
const getPopularImg = () => axios.get(API)
module.exports = getPopularImg;
// 测试代码,发布时删除
getPopularImg().then((res) => console.log(res.data.length));</code></pre>
<p>测试一下:<br><code>node packages/prpr-lerna-popular/index.js</code><br>正常情况下可以输出结果</p>
<h3>发布到npm</h3>
<p>首先把所有的代码提交</p>
<pre><code>cd learn-lerna
git add .
git commit -m "test publish"</code></pre>
<p>注册一个<a href="https://link.segmentfault.com/?enc=L6hQVQLX5LvgO6oiJddLQQ%3D%3D.tSvUOoyYZeOf39YsoVQr%2BV4FbNx8yj%2F1WPYDeuBHgJ8%3D" rel="nofollow">npmjs</a>账户</p>
<pre><code>npm login</code></pre>
<p>登入你的账户,如果本地npm是淘宝镜像,一定要换回<code>https://registry.npmjs.org/</code>地址!!!</p>
<pre><code>lerna publish</code></pre>
<p>运行<code>publish</code>,选择发布的版本号<br><img src="/img/remote/1460000019704394?w=994&h=496" alt="" title=""></p>
<p>lerna可以帮我们管理版本号,非常方便!</p>
<p><img src="/img/remote/1460000019704395?w=882&h=512" alt="" title=""></p>
<h3>常用命令</h3>
<pre><code>lerna init #初始化
lerna bootstrap #下载依赖包或者生成本地软连接
lerna add axios #所有包都添加axios
lerna add prpr-lerna-core --scope=prpr-lerna-popular #给包prpr-lerna-popularx添加prpr-lerna-core依赖
lerna list
lerna clean</code></pre>
<h3>其他事项</h3>
<ul>
<li>lerna默认使用的是集中版本,所有的package共用一个version。如果希望不同的package拥有自己的版本,可以使用<a href="https://link.segmentfault.com/?enc=Xa6rXBuIai6Q%2BeHOkFeUmg%3D%3D.298h4wfGqL0GgZfnINR4Yh76WeMKNW3NDLKyuH7j%2B2oFQxxXvwcCSrX1KLZdigmUTpMQ9KXEowLuEuHGOhFsSQ%3D%3D" rel="nofollow">Independent</a>模式</li>
<li>发布package的名字如果是以<code>@</code>开头的,例如<code>@deepred/core</code>,npm默认以为是私人发布,需要使用<code>npm publish --access public</code>发布。但是<code>lerna publish</code>不支持该参数,解决方法参考: <a href="https://link.segmentfault.com/?enc=dIA19P7DmX1a8xJzFj86IQ%3D%3D.cJJS9pvTXTSoFbiv1XEhs6SHIWIhXmyhYfHlVEIJ%2FqvxVBt7CXc8tLGUO3kTL3pn" rel="nofollow">issues</a>
</li>
</ul>
<h4>参考</h4>
<p><a href="https://link.segmentfault.com/?enc=uHkMSY5j3DluJpJdP3xTxg%3D%3D.Z%2F32EIFEykiGwbPITRr5dkhQEtXn3sV1k7NDlOPfp1zHqgmEiuSrnBRG97P8n4Lu" rel="nofollow">浅谈monorepo</a></p>
Koa源码浅析
https://segmentfault.com/a/1190000019603834
2019-06-28T09:00:00+08:00
2019-06-28T09:00:00+08:00
深红
https://segmentfault.com/u/deepred5
15
<p>Koa源码十分精简,只有不到2k行的代码,总共由4个模块文件组成,非常适合我们来学习。<br><img src="/img/remote/1460000019605610" alt="koa1" title="koa1"></p>
<p>参考代码: <a href="https://link.segmentfault.com/?enc=5%2B23gJYeXV3nTCSujCcAjQ%3D%3D.794ks%2BvfhqTkyq%2B56vJ98JwKiqyAvCo%2Fm5yvwbit4dTK9tBbfJH0ekEmxyQmdruT" rel="nofollow">learn-koa2</a> </p>
<p>我们先来看段原生Node实现Server服务器的代码:</p>
<pre><code class="javascript">const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world');
});
server.listen(3000, () => {
console.log('server start at 3000');
});</code></pre>
<p>非常简单的几行代码,就实现了一个服务器Server。<code>createServer</code>方法接收的<code>callback</code>回调函数,可以对每次请求的<code>req</code> <code>res</code>对象进行各种操作,最后返回结果。不过弊端也很明显,<code>callback</code>函数非常容易随着业务逻辑的复杂也变得臃肿,即使把<code>callback</code>函数拆分成各个小函数,也会在繁杂的异步回调中渐渐失去对整个流程的把控。</p>
<p>另外,Node原生提供的一些API,有时也会让开发者疑惑:</p>
<pre><code class="javascript">res.statusCode = 200;
res.writeHead(200);</code></pre>
<p>修改<code>res</code>的属性或者调用<code>res</code>的方法都可以改变<code>http</code>状态码,这在多人协作的项目中,很容易产生不同的代码风格。</p>
<p>我们再来看段Koa实现Server:</p>
<pre><code class="javascript">const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
console.log('1-start');
await next();
console.log('1-end');
});
app.use(async (ctx, next) => {
console.log('2-start');
ctx.status = 200;
ctx.body = 'Hello World';
console.log('2-end');
});
app.listen(3000);
// 最后输出内容:
// 1-start
// 2-start
// 2-end
// 1-end</code></pre>
<p>Koa使用了中间件的概念来完成对一个http请求的处理,同时,Koa采用了async和await的语法使得异步流程可以更好的控制。<code>ctx</code>执行上下文代理了原生的<code>res</code>和<code>req</code>,这让开发者避免接触底层,而是通过代理访问和设置属性。</p>
<p>看完两者的对比后,我们应该会有几个疑惑:</p>
<ul>
<li>
<code>ctx.status</code>为什么就可以直接设置状态码了,不是根本没看到<code>res</code>对象吗?</li>
<li>中间件中的<code>next</code>到底是啥?为什么执行<code>next</code>就进入了下一个中间件?</li>
<li>所有中间件执行完成后,为什么可以再次返回原来的中间件(洋葱模型)?</li>
</ul>
<p>现在让我们带着疑惑,进行源码解读,同时自己实现一个简易版的<a href="https://link.segmentfault.com/?enc=l9stJO%2FttWJ%2BQkjuEGLc%2Bw%3D%3D.3MdbBKOueKTe7EbNEzbVcHY7xQmXEUwyFLhV2E1MjWGwAahrQw6QhxFo3pvbL3ok7U7V43SrxoDa5MVtJtnpSw%3D%3D" rel="nofollow">Koa</a>吧!</p>
<h3>封装http Server</h3>
<p>参考代码: <a href="https://link.segmentfault.com/?enc=MgqIaMWpx6mUeSIBUPeDvw%3D%3D.IAEzf46BFvhXZLLfyo1j6luJuGcoMvB5PGjAfjj%2B4aLqrumOL0ZSFC0cy4dcqHQwuLx4RvUUFs%2F1lzaKJqOh0g%3D%3D" rel="nofollow">step-1</a></p>
<pre><code class="javascript">// Koa的使用方法
const Koa = require('koa');
const app = new Koa();
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);</code></pre>
<p>我们首先模仿koa的使用方法,搭建一个最简易的骨架:</p>
<p>新建<code>kao/application.js</code>(特意使用了<strong>Kao</strong>,区别<code>Koa</code>,并非笔误!!!)</p>
<pre><code class="javascript">const http = require('http');
class Application {
constructor() {
this.callbackFn = null;
}
use(fn) {
this.callbackFn = fn;
}
callback() {
return (req, res) => this.callbackFn(req, res)
}
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
}
module.exports = Application;</code></pre>
<p>新建测试文件<code>kao/index.js</code></p>
<pre><code class="javascript">const Kao = require('./application');
const app = new Kao();
app.use(async (req, res) => {
res.writeHead(200);
res.end('hello world');
});
app.listen(3001, () => {
console.log('server start at 3001');
});</code></pre>
<p>我们已经初步封装好http server:通过<code>new</code>实例一个对象,<code>use</code>注册回调函数,<code>listen</code>启动server并传入回调。</p>
<p>注意的是:调用<code>new</code>时,其实没有开启server服务器,真正开启是在<code>listen</code>调用时。</p>
<p>不过这段代码有明显的不足:</p>
<ul>
<li>use传入的回调函数,接收的参数依旧是原生的<code>req</code>和<code>res</code>
</li>
<li>多次调用use,会覆盖上一个中间件,并不是依次执行多个中间件</li>
</ul>
<p>我们先来解决第一个问题</p>
<h3>封装req和res对象,构造context</h3>
<p>参考代码: <a href="https://link.segmentfault.com/?enc=uZEsNSh1mUvtt2%2By%2FBY8kA%3D%3D.RSr29Su%2BW1fsBnd3LHf7fPTeB4QYadSlU2U2sRhfobaeo%2B4bo7FsGUE5nQeTF%2FJmmauSVSjV49D5Eqt4q%2FWjWA%3D%3D" rel="nofollow">step-2</a></p>
<p>先来介绍下ES6中的get和set <a href="https://segmentfault.com/a/1190000009029639">参考</a></p>
<p>基于普通对象的get和set</p>
<pre><code class="javascript">const demo = {
_name: '',
get name() {
return this._name;
},
set name(val) {
this._name = val;
}
};
demo.name = 'deepred';
console.log(demo.name);</code></pre>
<p>基于<code>Class</code>的get和set</p>
<pre><code class="javascript">class Demo {
constructor() {
this._name = '';
}
get name() {
return this._name;
}
set name(val) {
this._name = val;
}
}
const demo = new Demo();
demo.name = 'deepred';
console.log(demo.name);</code></pre>
<p>基于<code>Object.defineProperty</code>的get和set</p>
<pre><code class="javascript">const demo = {
_name: ''
};
Object.defineProperty(demo, 'name', {
get: function() {
return this._name
},
set: function(val) {
this._name = val;
}
});</code></pre>
<p>基于<code>Proxy</code>的get和set</p>
<pre><code class="javascript">const demo = {
_name: ''
};
const proxy = new Proxy(demo, {
get: function(target, name) {
return name === 'name' ? target['_name'] : undefined;
},
set: function(target, name, val) {
name === 'name' && (target['_name'] = val)
}
});</code></pre>
<p>还有<code>__defineSetter__</code>和<code>__defineGetter__</code>的实现,不过现已废弃。</p>
<pre><code class="javascript">const demo = {
_name: ''
};
demo.__defineGetter__('name', function() {
return this._name;
});
demo.__defineSetter__('name', function(val) {
this._name = val;
});</code></pre>
<p>主要区别是,<code>Object.defineProperty</code> <code>__defineSetter__</code> <code>Proxy</code>可以动态设置属性,而其他方式只能在定义时设置。</p>
<p>Koa源码中 <code>request.js</code>和<code>response.js</code>就使用了大量的<code>get</code>和<code>set</code>来代理</p>
<p>新建<code>kao/request.js</code></p>
<pre><code class="javascript">module.exports = {
get header() {
return this.req.headers;
},
set header(val) {
this.req.headers = val;
},
get url() {
return this.req.url;
},
set url(val) {
this.req.url = val;
},
}</code></pre>
<p>当访问<code>request.url</code>时,其实就是在访问原生的<code>req.url</code>。需要注意的是,<code>this.req</code>原生对象此时还没有注入!</p>
<p>同理新建<code>kao/response.js</code></p>
<pre><code class="javascript">module.exports = {
get status() {
return this.res.statusCode;
},
set status(code) {
this.res.statusCode = code;
},
get body() {
return this._body;
},
set body(val) {
// 源码里有对val类型的各种判断,这里省略
/* 可能的类型
1. string
2. Buffer
3. Stream
4. Object
*/
this._body = val;
}
}</code></pre>
<p>这里对body进行操作并没有使用原生的this.res.end,因为在我们编写koa代码的时候,会对body进行多次的读取和修改,所以真正返回浏览器信息的操作是在<code>application.js</code>里进行封装和操作</p>
<p>同样需要注意的是,<code>this.res</code>原生对象此时还没有注入!</p>
<p>新建<code>kao/context.js</code></p>
<pre><code class="javascript">const delegate = require('delegates');
const proto = module.exports = {
// context自身的方法
toJSON() {
return {
request: this.request.toJSON(),
response: this.response.toJSON(),
app: this.app.toJSON(),
originalUrl: this.originalUrl,
req: '<original node req>',
res: '<original node res>',
socket: '<original node socket>'
};
},
}
// delegates 原理就是__defineGetter__和__defineSetter__
// method是委托方法,getter委托getter,access委托getter和setter。
// proto.status => proto.response.status
delegate(proto, 'response')
.access('status')
.access('body')
// proto.url = proto.request.url
delegate(proto, 'request')
.access('url')
.getter('header')</code></pre>
<p><code>context.js</code>代理了<code>request</code>和<code>response</code>。<code>ctx.body</code>指向<code>ctx.response.body</code>。但是此时<code>ctx.response</code> <code>ctx.request</code>还没注入!</p>
<p>可能会有疑问,为什么<code>response.js</code>和<code>request.js</code>使用<code>get set</code>代理,而<code>context.js</code>使用<code>delegate</code>代理? 原因主要是: <code>set</code>和<code>get</code>方法里面还可以加入一些自己的逻辑处理。而<code>delegate</code>就比较纯粹了,只代理属性。</p>
<pre><code class="javascript">{
get length() {
// 自己的逻辑
const len = this.get('Content-Length');
if (len == '') return;
return ~~len;
},
}
// 仅仅代理属性
delegate(proto, 'response')
.access('length')</code></pre>
<p>因此<code>context.js</code>比较适合使用<code>delegate</code>,仅仅是代理<code>request</code>和<code>response</code>的属性和方法。</p>
<p>真正注入原生对象,是在<code>application.js</code>里的<code>createContext</code>方法中注入的!!!</p>
<pre><code class="javascript">const http = require('http');
const context = require('./context');
const request = require('./request');
const response = require('./response');
class Application {
constructor() {
this.callbackFn = null;
// 每个Kao实例的context request respones
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
use(fn) {
this.callbackFn = fn;
}
callback() {
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx)
};
return handleRequest;
}
handleRequest(ctx) {
const handleResponse = () => respond(ctx);
// callbackFn是个async函数,最后返回promise对象
return this.callbackFn(ctx).then(handleResponse);
}
createContext(req, res) {
// 针对每个请求,都要创建ctx对象
// 每个请求的ctx request response
// ctx代理原生的req res就是在这里代理的
let ctx = Object.create(this.context);
ctx.request = Object.create(this.request);
ctx.response = Object.create(this.response);
ctx.req = ctx.request.req = req;
ctx.res = ctx.response.res = res;
ctx.app = ctx.request.app = ctx.response.app = this;
return ctx;
}
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
}
module.exports = Application;
function respond(ctx) {
// 根据ctx.body的类型,返回最后的数据
/* 可能的类型,代码删减了部分判断
1. string
2. Buffer
3. Stream
4. Object
*/
let content = ctx.body;
if (typeof content === 'string') {
ctx.res.end(content);
}
else if (typeof content === 'object') {
ctx.res.end(JSON.stringify(content));
}
}</code></pre>
<p>代码中使用了<code>Object.create</code>的方法创建一个全新的对象,通过原型链继承原来的属性。这样可以有效的防止污染原来的对象。</p>
<p><code>createContext</code>在每次http请求时都会调用,每次调用都新生成一个<code>ctx</code>对象,并且代理了这次http请求的原生的对象。</p>
<p><code>respond</code>才是最后返回http响应的方法。根据执行完所有中间件后<code>ctx.body</code>的类型,调用<code>res.end</code>结束此次http请求。</p>
<p><img src="/img/remote/1460000019605611" alt="" title=""></p>
<p>现在我们再来测试一下:<br><code>kao/index.js</code></p>
<pre><code class="javascript">const Kao = require('./application');
const app = new Kao();
// 使用ctx修改状态码和响应内容
app.use(async (ctx) => {
ctx.status = 200;
ctx.body = {
code: 1,
message: 'ok',
url: ctx.url
};
});
app.listen(3001, () => {
console.log('server start at 3001');
});</code></pre>
<h3>中间件机制</h3>
<p>参考代码: <a href="https://link.segmentfault.com/?enc=T0guYdmUpInmu%2BPcAfpm%2Bg%3D%3D.QdWFD1RAOnsrB%2BxiSW9bzSp2Hkuvxe%2BpFyo%2FG28KITTLa%2F3tvr7z71PZHXj0omu2nIlvBV2L%2BeCouQpRCmZ%2FIA%3D%3D" rel="nofollow">step-3</a></p>
<pre><code class="javascript">const greeting = (firstName, lastName) => firstName + ' ' + lastName
const toUpper = str => str.toUpperCase()
const fn = compose([toUpper, greeting]);
const result = fn('jack', 'smith');
console.log(result);</code></pre>
<p>函数式编程有个<code>compose</code>的概念。比如把<code>greeting</code>和<code>toUpper</code>组合成一个复合函数。调用这个复合函数,会先调用<code>greeting</code>,然后把返回值传给<code>toUpper</code>继续执行。</p>
<p>实现方式:</p>
<pre><code class="javascript">// 命令式编程(面向过程)
function compose(fns) {
let length = fns.length;
let count = length - 1;
let result = null;
return function fn1(...args) {
result = fns[count].apply(null, args);
if (count <= 0) {
return result
}
count--;
return fn1(result);
}
}
// 声明式编程(函数式)
function compose(funcs) {
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}</code></pre>
<p>Koa的中间件机制类似上面的<code>compose</code>,同样是把多个函数包装成一个,但是koa的中间件类似洋葱模型,也就是从A中间件执行到B中间件,B中间件执行完成以后,仍然可以再次回到A中间件。<br><img src="/img/remote/1460000019605612?w=478&h=435" alt="" title=""></p>
<p>Koa使用了<code>koa-compose</code>实现了中间件机制,源码非常精简,但是有点难懂。建议先了解下<a href="https://link.segmentfault.com/?enc=Hjmi%2BkiE6kepJnf%2FgKqQzQ%3D%3D.Cy9lldOv1smlELz9FuQz%2F3XXW%2B6JN9xVlLnMnbufzvBhAmvdP0c%2BpYnOcKNLQz5M9SpNYf%2FrqSB%2BO1qQD2mHuO5CkcJr4SrddjsL5%2Fg%2F3pSOCKkWknS5NvbI9QL%2BCR1db8Z2ljlb7zVdJnBQ25FFZfAW3%2BnzuSUkL67ydPu1jaX6uDlgt4SkPBf6c7uJhpBC" rel="nofollow">递归</a></p>
<pre><code class="javascript">
function compose (middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
// 一个中间件里多次调用next
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
// fn就是当前的中间件
let fn = middleware[i]
if (i === middleware.length) fn = next // 最后一个中间件如果也next时进入(一般最后一个中间件是直接操作ctx.body,并不需要next了)
if (!fn) return Promise.resolve() // 没有中间件,直接返回成功
try {
/*
* 使用了bind函数返回新的函数,类似下面的代码
return Promise.resolve(fn(context, function next () {
return dispatch(i + 1)
}))
*/
// dispatch.bind(null, i + 1)就是中间件里的next参数,调用它就可以进入下一个中间件
// fn如果返回的是Promise对象,Promise.resolve直接把这个对象返回
// fn如果返回的是普通对象,Promise.resovle把它Promise化
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
// 中间件是async的函数,报错不会走这里,直接在fnMiddleware的catch中捕获
// 捕获中间件是普通函数时的报错,Promise化,这样才能走到fnMiddleware的catch方法
return Promise.reject(err)
}
}
}
}</code></pre>
<pre><code class="javascript">const context = {};
const sleep = (time) => new Promise(resolve => setTimeout(resolve, time));
const test1 = async (context, next) => {
console.log('1-start');
context.age = 11;
await next();
console.log('1-end');
};
const test2 = async (context, next) => {
console.log('2-start');
context.name = 'deepred';
await sleep(2000);
console.log('2-end');
};
const fn = compose([test1, test2]);
fn(context).then(() => {
console.log('end');
console.log(context);
});</code></pre>
<p>递归调用栈的执行情况:<br><img src="/img/remote/1460000019605613?w=312&h=466" alt="" title=""></p>
<p>弄懂了中间件机制,我们应该可以回答之前的问题:</p>
<blockquote>
<code>next</code>到底是啥?洋葱模型是怎么实现的?</blockquote>
<p>next就是一个包裹了dispatch的函数</p>
<p>在第n个中间件中执行next,就是执行dispatch(n+1),也就是进入第n+1个中间件</p>
<p>因为dispatch返回的都是Promise,所以在第n个中间件await next(); 进入第n+1个中间件。当第n+1个中间件执行完成后,可以返回第n个中间件</p>
<p>如果在某个中间件中不再调用next,那么它之后的所有中间件都不会再调用了</p>
<p>修改<code>kao/application.js</code></p>
<pre><code class="javascript">class Application {
constructor() {
this.middleware = []; // 存储中间件
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
use(fn) {
this.middleware.push(fn); // 存储中间件
}
compose (middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
callback() {
// 合成所有中间件
const fn = this.compose(this.middleware);
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn)
};
return handleRequest;
}
handleRequest(ctx, fnMiddleware) {
const handleResponse = () => respond(ctx);
// 执行中间件并把最后的结果交给respond
return fnMiddleware(ctx).then(handleResponse);
}
createContext(req, res) {
// 针对每个请求,都要创建ctx对象
let ctx = Object.create(this.context);
ctx.request = Object.create(this.request);
ctx.response = Object.create(this.response);
ctx.req = ctx.request.req = req;
ctx.res = ctx.response.res = res;
ctx.app = ctx.request.app = ctx.response.app = this;
return ctx;
}
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
}
module.exports = Application;
function respond(ctx) {
let content = ctx.body;
if (typeof content === 'string') {
ctx.res.end(content);
}
else if (typeof content === 'object') {
ctx.res.end(JSON.stringify(content));
}
}</code></pre>
<p>测试一下</p>
<pre><code class="javascript">const Kao = require('./application');
const app = new Kao();
app.use(async (ctx, next) => {
console.log('1-start');
await next();
console.log('1-end');
})
app.use(async (ctx) => {
console.log('2-start');
ctx.body = 'hello tc';
console.log('2-end');
});
app.listen(3001, () => {
console.log('server start at 3001');
});
// 1-start 2-start 2-end 1-end
</code></pre>
<h3>错误处理机制</h3>
<p>参考代码: <a href="https://link.segmentfault.com/?enc=jZLg5EmTd0AU3dKPB4zM1A%3D%3D.EEsDT%2Bi1UAlVkE6Kf%2BtX84cZdjRByRPwQ35J9h3sRc09sGZLt5Z%2FDKCZHZaWzZ2QbpsO%2BGYmQf6RaYsy6GFMkA%3D%3D" rel="nofollow">step-4</a></p>
<p>因为<code>compose</code>组合之后的函数返回的仍然是Promise对象,所以我们可以在<code>catch</code>捕获异常</p>
<p><code>kao/application.js</code></p>
<pre><code class="javascript">handleRequest(ctx, fnMiddleware) {
const handleResponse = () => respond(ctx);
const onerror = err => ctx.onerror(err);
// catch捕获,触发ctx的onerror方法
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}</code></pre>
<p><code>kao/context.js</code></p>
<pre><code class="javascript">const proto = module.exports = {
// context自身的方法
onerror(err) {
// 中间件报错捕获
const { res } = this;
if ('ENOENT' == err.code) {
err.status = 404;
} else {
err.status = 500;
}
this.status = err.status;
res.end(err.message || 'Internal error');
}
}</code></pre>
<pre><code class="javascript">const Kao = require('./application');
const app = new Kao();
app.use(async (ctx) => {
// 报错可以捕获
a.b.c = 1;
ctx.body = 'hello tc';
});
app.listen(3001, () => {
console.log('server start at 3001');
});</code></pre>
<p>现在我们已经实现了中间件的错误异常捕获,但是我们还缺少框架层发生错误的捕获机制。我们可以让<code>Application</code>继承原生的<code>Emitter</code>,从而实现<code>error</code>监听</p>
<p><code>kao/application.js</code></p>
<pre><code class="javascript">const Emitter = require('events');
// 继承Emitter
class Application extends Emitter {
constructor() {
// 调用super
super();
this.middleware = [];
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
}</code></pre>
<p><code>kao/context.js</code></p>
<pre><code class="javascript">const proto = module.exports = {
onerror(err) {
const { res } = this;
if ('ENOENT' == err.code) {
err.status = 404;
} else {
err.status = 500;
}
this.status = err.status;
// 触发error事件
this.app.emit('error', err, this);
res.end(err.message || 'Internal error');
}
}</code></pre>
<pre><code class="javascript">const Kao = require('./application');
const app = new Kao();
app.use(async (ctx) => {
// 报错可以捕获
a.b.c = 1;
ctx.body = 'hello tc';
});
app.listen(3001, () => {
console.log('server start at 3001');
});
// 监听error事件
app.on('error', (err) => {
console.log(err.stack);
});</code></pre>
<p>至此我们可以了解到Koa异常捕获的两种方式:</p>
<ul>
<li>中间件捕获(Promise catch)</li>
<li>框架捕获(Emitter error)</li>
</ul>
<pre><code class="js">// 捕获全局异常的中间件
app.use(async (ctx, next) => {
try {
await next()
} catch (error) {
return ctx.body = 'error'
}
}
)</code></pre>
<pre><code class="js">// 事件监听
app.on('error', err => {
console.log('error happends: ', err.stack);
});</code></pre>
<h3>总结</h3>
<p>Koa整个流程可以分成三步:</p>
<p><strong>初始化阶段:</strong></p>
<pre><code class="javascript">const Koa = require('koa');
const app = new Koa();
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);</code></pre>
<p><code>new</code>初始化一个实例,<code>use</code>搜集中间件到middleware数组,<code>listen</code> 合成中间件<code>fnMiddleware</code>,返回一个callback函数给<code>http.createServer</code>,开启服务器,等待http请求。</p>
<p><strong>请求阶段:</strong></p>
<p>每次请求,<code>createContext</code>生成一个新的<code>ctx</code>,传给<code>fnMiddleware</code>,触发中间件的整个流程</p>
<p><strong>响应阶段:</strong></p>
<p>整个中间件完成后,调用<code>respond</code>方法,对请求做最后的处理,返回响应给客户端。</p>
<p>参考下面的流程图:<br><img src="/img/remote/1460000019605614" alt="" title=""></p>
傻傻分不清的Manifest
https://segmentfault.com/a/1190000019395237
2019-06-05T13:58:05+08:00
2019-06-05T13:58:05+08:00
深红
https://segmentfault.com/u/deepred5
37
<p>在前端,说到<code>manifest</code>,其实是有歧义的,就我了解的情况来说,<code>manifest</code>可以指代下列含义:</p>
<ol>
<li>
<code>html</code>标签的<code>manifest</code>属性: <a href="https://link.segmentfault.com/?enc=S1EVe7m0PME%2F8%2FjImzXDuQ%3D%3D.V%2BqKpCyfXCE8%2Bge5boDXhekQnOJ9i9D%2FedeteFGrPVCSNYUxTpmzG9JNWZn9%2FVZK1nbR3H80QCE35naZCO%2Fc8G0lxs8nNqQRCMwoLcG4UP4%3D" rel="nofollow">离线缓存</a>(目前已被废弃)</li>
<li>
<a href="https://link.segmentfault.com/?enc=gvgHFtlwQhAnCLxhFAtElA%3D%3D.9CjvpDqt1H5BhPy4nzrDUoyYkChNYr%2B%2B6ZNg7w1TQRjglWT7sq6clR8t2mS9TNgxoR9uBVB6%2Fdhq938CpYkjGw%3D%3D" rel="nofollow">PWA</a>: 将Web应用程序安装到设备的主屏幕</li>
<li>webpack中<a href="https://link.segmentfault.com/?enc=SW7OP40CFpM7TdQgf4VM2Q%3D%3D.2A7gNVVNWDzCi278LS%2FKTu2XJulM0viCwKMctqJQU3055GjDSzTDvrzrQ51SXly%2Bldo1Oe%2FmRh6qBDbMlQf46A%3D%3D" rel="nofollow">webpack-manifest-plugin</a>插件打包出来的<code>manifest.json</code>文件,用来生成一份资源清单,为后端渲染服务</li>
<li>webpack中<a href="https://link.segmentfault.com/?enc=v2ts0eplap3OThzb1ZkvFA%3D%3D.owWaiLcWfMEfaBzc0KBFfqlHxylcPiSuaxZ%2BX9WuPGXKDDje57nv43xYwYNzmE6Q" rel="nofollow">DLL</a>打包时,输出的<code>manifest.json</code>文件,用来分析已经打包过的文件,优化打包速度和大小</li>
<li>webpack中<a href="https://link.segmentfault.com/?enc=qC8tyPTZhNIOH20XqPrEvw%3D%3D.g2cqjDHO6EoamXSTnc6526ZEQK1QCFJR8o4kaVzkHu0GGXkA8HEVT%2BVHJ8xfrdV5" rel="nofollow">manifest</a>运行时代码</li>
</ol>
<p>下面我们来一一介绍下</p>
<h3>html属性</h3>
<pre><code class="html"><!DOCTYPE html>
<html lang="en" manifest="/tc.mymanifest">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<link rel="stylesheet" href="/theme.css">
<script src="/main.js"></script>
<script src="/main2.js"></script>
</head>
<body>
</body>
</html></code></pre>
<p>浏览器解析这段html标签时,就会去访问<code>tc.mymanifest</code>这个文件,这是一个缓存清单文件</p>
<p><code>tc.mymanifest</code></p>
<pre><code># v1 这是注释
CACHE MANIFEST
/theme.css
/main.js
NETWORK:
*
FALLBACK:
/html5/ /404.html</code></pre>
<p><code>CACHE MANIFEST</code>指定需要缓存的文件,第一次下载完成以后,文件都不会再从网络请求了,即使用户不是离线状态,除非<code>tc.mymanifest</code>更新了,缓存清单更新之后,才会再次下载。标记了manifest的html本身也被缓存</p>
<p><code>NETWORK</code>指定非缓存文件,所有类似资源的请求都会绕过缓存,即使用户处于离线状态,也不会读缓存</p>
<p><code>FALLBACK</code>指定了一个后备页面,当资源无法访问时,浏览器会使用该页面。<br>比如离线访问/html5/目录时,就会用本地的/404.html页面</p>
<p>缓存清单可以是任意后缀名,不过必须指定<code>content-type</code>属性为<code>text/cache-manifest</code></p>
<p>那如何更新缓存?一般有以下几种方式:</p>
<ul>
<li>用户清空浏览器缓存</li>
<li>manifest 文件被修改(即使注释被修改)</li>
<li>由程序来更新应用缓存</li>
</ul>
<p>需要特别注意:用户第一次访问该网页,缓存文件之后,第二次进入该页面,发现<code>tc.mymanifest</code>缓存清单更新了,于是会重新下载缓存文件,但是,<strong>第二次进入显示的页面仍然执行的是旧文件,下载的新文件,只会在第三次进入该页面后执行!!!</strong></p>
<p>如果希望用户立即看到新内容,需要js监听更新事件,重新加载页面</p>
<pre><code class="javascript">window.addEventListener('load', function (e) {
window.applicationCache.addEventListener('updateready', function (e) {
if (window.applicationCache.status == window.applicationCache.UPDATEREADY) {
// 更新缓存
// 重新加载
window.applicationCache.swapCache();
window.location.reload();
} else {
}
}, false);
}, false);</code></pre>
<p>建议对<code>tc.mymanifest</code>缓存清单设置永不缓存</p>
<p>不过,manifest也有很多<a href="https://link.segmentfault.com/?enc=tn9iVooz7kiHnCJaqBdFXQ%3D%3D.h0T1uiYSHzzYT2jz8HTm9Sgm879Ak3o7Dcn48POcKKE49XiAxxtRiYHtv9kycqQr" rel="nofollow">缺点</a>,比如需要手动一个个填写缓存的文件,更新文件之后需要二次刷新,如果更新的资源中有一个资源更新失败了,将导致全部更新失败,将用回上一版本的缓存</p>
<p>HTML5规范也废弃了这个属性,因此不建议使用</p>
<h3>PWA</h3>
<p>为了实现PWA应用添加至桌面的功能,除了要求站点支持HTTPS之外,还需要准备 <code>manifest.json</code>文件去配置应用的图标、名称等信息</p>
<pre><code class="html"><link rel="manifest" href="/manifest.json"></code></pre>
<pre><code class="js">{
"name" : "Minimal PWA" ,
"short_name" : "PWA Demo" ,
"display" : "standalone" ,
"start_url" : "/" ,
"theme_color" : "#313131" ,
"background_color" : "#313131" ,
"icons" : [
{
"src": "images/touch/homescreen48.png",
"sizes": "48x48",
"type": "image/png"
}
]
}</code></pre>
<p>通过一系列配置,就可以把一个PWA像APP一样,添加一个图标到手机屏幕上,点击图标即可打开站点</p>
<h3>基于webpack的react开发环境</h3>
<p>本文默认你已经了解最基本的webpack配置,如果完全不会,建议看下这篇<a href="https://link.segmentfault.com/?enc=sCXdeFIzB9u98onJdOs1yA%3D%3D.Fff4qWuNWYEOXtRDToJ7KJi0f29A60%2FC99cTg9HlF4WRFwMHFb%2Fni0gw%2BSrcqJ6yqBStmqt6l8Hx32pocGZCyffl8wpBMLP%2FiMTuQX10Kr9JFBaSvWrbd5SyX6fVRjYeBOBku6cGmaMZINVkWt7qlTI0k9ADnmZkBUztsO1mgpNoR5Gn5yeQzTyuDTfRf%2Be186%2FquzRsrzHgQ%2FrUustKR2i89KhsQumW6aVfLhgCZhjPVcg%2FEHSVRK%2BvISNMWQqEvdJQjrzIb%2FaNna8yhRQ9zQ%3D%3D" rel="nofollow">文章</a></p>
<p>我们首先搭建一个最简单的基于webpack的react开发环境</p>
<p><strong>源代码地址</strong>:<a href="https://link.segmentfault.com/?enc=5g8sc3789N4rW%2BPOBHW1Yg%3D%3D.DBctZTGoBLD0JFL%2Bjrht70QlRnTKbB%2F60RShlgC0NUJXNBvZHS22OH9oWDarWb0u" rel="nofollow">https://github.com/deepred5/l...</a></p>
<pre><code class="bash">mkdir learn-dll
cd learn-dll</code></pre>
<p>安装依赖</p>
<pre><code class="bash">npm init -y
npm install @babel/polyfill react react-dom --save</code></pre>
<pre><code>npm install webpack webpack-cli webpack-dev-server @babel/core @babel/preset-env @babel/preset-react add-asset-html-webpack-plugin autoprefixer babel-loader clean-webpack-plugin css-loader html-webpack-plugin mini-css-extract-plugin node-sass postcss-loader sass-loader style-loader --save-dev</code></pre>
<p>新建<code>.bablerc</code></p>
<pre><code class="javascript">{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage", // 根据browserslis填写的浏览器,自动添加polyfill
"corejs": 2,
}
],
"@babel/preset-react" // 编译react
],
"plugins": []
}</code></pre>
<p>新建<code>postcss.config.js</code></p>
<pre><code class="javascript">module.exports = {
plugins: [
require('autoprefixer') // 根据browserslis填写的浏览器,自动添加css前缀
]
}</code></pre>
<p>新建<code>.browserslistrc</code></p>
<pre><code>last 10 versions
ie >= 11
ios >= 9
android >= 6</code></pre>
<p>新建<code>webpack.dev.js</code>(基本配置不再详细介绍)</p>
<pre><code class="javascript">const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
devtool: 'cheap-module-eval-source-map',
entry: {
main: './src/index.js'
},
output: {
path: path.resolve(__dirname, './dist'),
filename: '[name].js',
chunkFilename: '[name].chunk.js',
},
devServer: {
historyApiFallback: true,
overlay: true,
port: 9001,
open: true,
hot: true,
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel-loader"
},
{
test: /\.css$/,
use: ['style-loader',
'css-loader',
'postcss-loader'
],
},
{
test: /\.scss$/,
use: ['style-loader',
{
loader: 'css-loader',
options: {
modules: false,
importLoaders: 2
}
},
'sass-loader',
'postcss-loader'
],
},
]
},
plugins: [
new HtmlWebpackPlugin({ template: './src/index.html' }), // index打包模板
]
}</code></pre>
<p>新建<code>src</code>目录,并新建<code>src/index.html</code></p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>learn dll</title>
</head>
<body>
<div id="app"></div>
</body>
</html></code></pre>
<p>新建<code>src/Home.js</code></p>
<pre><code class="javascript">import React from 'react';
import './Home.scss';
export default () => <div className="home">home</div></code></pre>
<p>新建<code>src/Home.scss</code></p>
<pre><code class="css">.home {
color: red;
}</code></pre>
<p>新建<code>src/index.js</code></p>
<pre><code class="javascript">import React, { Component } from 'react';
import ReactDom from 'react-dom';
import Home from './Home';
class Demo extends Component {
render() {
return (
<Home />
)
}
}
ReactDom.render(<Demo/>, document.getElementById('app'));</code></pre>
<p>修改<code>package.json</code></p>
<pre><code class="bash">"scripts": {
"dev": "webpack-dev-server --config webpack.dev.js"
},</code></pre>
<p>最后,运行<code>npm run dev</code>,应该可以看见效果</p>
<p>新建<code>webpack.prod.js</code></p>
<pre><code class="javascript">const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
mode: 'production',
entry: {
main: './src/index.js'
},
output: {
path: path.resolve(__dirname, './dist'),
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel-loader"
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, // 单独提取css文件
'css-loader',
'postcss-loader'
],
},
{
test: /\.scss$/,
use: [MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: false,
importLoaders: 2
}
},
'sass-loader',
'postcss-loader'
],
},
]
},
plugins: [
new HtmlWebpackPlugin({ template: './src/index.html' }),
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
chunkFilename: '[id].[contenthash:8].css',
}),
new CleanWebpackPlugin(), // 打包前先删除之前的dist目录
]
};</code></pre>
<p>修改<code>package.json</code>,添加一句<code>"build": "webpack --config webpack.prod.js"</code></p>
<p>运行<code>npm run build</code>,可以看见打包出来的<code>dist</code>目录</p>
<p>html,js,css都单独分离出来了</p>
<p><img src="/img/remote/1460000019395240?w=282&h=86" alt="" title=""></p>
<p>至此,一个基于webpack的react环境搭建完成</p>
<h3>webpack-manifest-plugin</h3>
<p>通常情况下,我们打包出来的js,css都是带上版本号的,通过<code>HtmlWebpackPlugin</code>可以自动帮我们在<code>index.html</code>里面加上带版本号的js和css</p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>learn dll</title>
<link href="main.198b3634.css" rel="stylesheet"></head>
<body>
<div id="app"></div>
<script type="text/javascript" src="main.d312f172.js"></script></body>
</html></code></pre>
<p>但是在某些情况,<code>index.html</code>模板由后端渲染,那么我们就需要一份打包清单,知道打包后的文件对应的真正路径</p>
<p>安装插件<code>webpack-manifest-plugin</code></p>
<p><code>npm i webpack-manifest-plugin -D</code></p>
<p>修改<code>webpack.prod.js</code></p>
<pre><code class="javascript">const ManifestPlugin = require('webpack-manifest-plugin');
module.exports = {
// ...
plugins: [
new ManifestPlugin()
]
};</code></pre>
<p>重新打包,可以看见<code>dist</code>目录新生成了一个<code>manifest.json</code></p>
<pre><code class="javascript">{
"main.css": "main.198b3634.css",
"main.js": "main.d312f172.js",
"index.html": "index.html"
}</code></pre>
<p>比如在SSR开发时,前端打包后,node后端就可以通过这个json数据,返回正确资源路径的html模板</p>
<pre><code class="javascript">const buildPath = require('./dist/manifest.json');
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>ssr</title>
<link href="${buildPath['main.css']}" rel="stylesheet"></head>
<body>
<div id="app"></div>
<script type="text/javascript" src="${buildPath['main.js']}"></script></body>
</html>
`);</code></pre>
<h3>代码分割</h3>
<p>我们之前的打包方式,有一个缺点,就是把业务代码和库代码都统统打到了一个<code>main.js</code>里面。每次业务代码改动后,<code>main.js</code>的hash值就变了,导致客户端又要重新下载一遍<code>main.js</code>,但是里面的库代码其实是没改变的!</p>
<p>通常情况下,<code>react</code> <code>react-dom</code>之类的库,都是不经常改动的。我们希望单独把这些库代码提取出来,生成一个<code>vendor.js</code>,这样每次改动代码,只是下载<code>main.js</code>,<code>vendor.js</code>可以充分缓存(也就是所谓的代码分割code splitting)</p>
<p>webpack4自带<a href="https://link.segmentfault.com/?enc=8h%2BN%2BTiarnuHzHogx4wfCw%3D%3D.%2FsWcg3MUFrqMdX8459rerjxsXYc7SXSesOvuYEGBV4%2Bky939y7UubFxdlR42bvOP" rel="nofollow">代码分割</a>功能,只要配置:</p>
<pre><code class="javascript">optimization: {
splitChunks: {
chunks: 'all'
}
}</code></pre>
<p><code>webpack.prod.js</code></p>
<pre><code class="javascript">const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ManifestPlugin = require('webpack-manifest-plugin');
module.exports = {
mode: 'production',
entry: {
main: './src/index.js'
},
output: {
path: path.resolve(__dirname, './dist'),
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel-loader"
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader'
],
},
{
test: /\.scss$/,
use: [MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: false,
importLoaders: 2
}
},
'sass-loader',
'postcss-loader'
],
},
]
},
plugins: [
new HtmlWebpackPlugin({ template: './src/index.html' }),
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
chunkFilename: '[id].[contenthash:8].css',
}),
new CleanWebpackPlugin(),
new ManifestPlugin()
],
optimization: {
splitChunks: {
chunks: 'all'
}
}
};</code></pre>
<p>重新打包,发现新生成了一个<code>vendor.js</code>文件,公用的一些代码就被打包进去了</p>
<p><img src="/img/remote/1460000019395241?w=299&h=136" alt="" title=""></p>
<p>重新修改<code>src/Home.js</code>,然后打包,你会发现<code>vendor.js</code>的hash没有改变,这也是我们希望的</p>
<h3>DLL打包</h3>
<p>上面的打包方式,随着项目的复杂度上升后,打包速度会开始变慢。原因是,每次打包,webpack都要分析哪些是公用库,然后把他打包到<code>vendor.js</code>里</p>
<p>我们可不可以在第一次构建<code>vendor.js</code>以后,下次打包,就直接跳过那些被打包到<code>vendor.js</code>里的代码呢?这样打包速度可以明显提升</p>
<p>这就需要<code>DllPlugin</code>结合<code>DllRefrencePlugin</code>插件的运用</p>
<p>dll打包原理就是:</p>
<ol>
<li>把指定的库代码打包到一个<code>dll.js</code>,同时生成一份对应的<code>manifest.json</code>文件</li>
<li>webpack打包时,读取<code>manifest.json</code>,知道哪些代码可以直接忽略,从而提高构建速度</li>
</ol>
<p>我们新建一个<code>webpack.dll.js</code></p>
<pre><code class="javascript">const path = require('path');
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = {
mode: 'production',
entry: {
vendors: ['react', 'react-dom'] // 手动指定打包哪些库
},
output: {
filename: '[name].[hash:8].dll.js',
path: path.resolve(__dirname, './dll'),
library: '[name]'
},
plugins: [
new CleanWebpackPlugin(),
new webpack.DllPlugin({
path: path.join(__dirname, './dll/[name].manifest.json'), // 生成对应的manifest.json,给webpack打包用
name: '[name]',
}),
],
}
</code></pre>
<p>添加一条命令:</p>
<p><code>"build:dll": "webpack --config webpack.dll.js"</code></p>
<p>运行dll打包</p>
<p><code>npm run build:dll</code></p>
<p>发现生成一个<code>dll</code>目录</p>
<p><img src="/img/remote/1460000019395242?w=293&h=72" alt="" title=""></p>
<p>修改<code>webpack.prod.js</code></p>
<pre><code class="javascript">const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ManifestPlugin = require('webpack-manifest-plugin');
const webpack = require('webpack');
module.exports = {
mode: 'production',
entry: {
main: './src/index.js'
},
output: {
path: path.resolve(__dirname, './dist'),
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel-loader"
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader'
],
},
{
test: /\.scss$/,
use: [MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: false,
importLoaders: 2
}
},
'sass-loader',
'postcss-loader'
],
},
]
},
plugins: [
new HtmlWebpackPlugin({ template: './src/index.html' }),
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
chunkFilename: '[id].[contenthash:8].css',
}),
new webpack.DllReferencePlugin({
manifest: path.resolve(__dirname, './dll/vendors.manifest.json') // 读取dll打包后的manifest.json,分析哪些代码跳过
}),
new CleanWebpackPlugin(),
new ManifestPlugin()
],
optimization: {
splitChunks: {
chunks: 'all'
}
}
};</code></pre>
<p>重新<code>npm run build</code>,发现<code>dist</code>目录里,<code>vendor.js</code>没有了</p>
<p>这是因为<code>react</code>,<code>react-dom</code>已经打包到<code>dll.js</code>里了,<code>webpack</code>读取<code>manifest.json</code>之后,知道可以忽略这些代码,于是就没有再打包了</p>
<p>但这里还有个问题,打包后的<code>index.html</code>还需要添加<code>dll.js</code>文件,这就需要<code>add-asset-html-webpack-plugin</code>插件</p>
<p><code>npm i add-asset-html-webpack-plugin -D</code></p>
<p>修改<code>webpack.prod.js</code></p>
<pre><code class="javascript">const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ManifestPlugin = require('webpack-manifest-plugin');
const webpack = require('webpack');
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
module.exports = {
mode: 'production',
entry: {
main: './src/index.js'
},
output: {
path: path.resolve(__dirname, './dist'),
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel-loader"
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader'
],
},
{
test: /\.scss$/,
use: [MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: false,
importLoaders: 2
}
},
'sass-loader',
'postcss-loader'
],
},
]
},
plugins: [
new HtmlWebpackPlugin({ template: './src/index.html' }),
new AddAssetHtmlPlugin({ filepath: path.resolve(__dirname, './dll/*.dll.js') }), // 把dll.js加进index.html里,并且拷贝文件到dist目录
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
chunkFilename: '[id].[contenthash:8].css',
}),
new webpack.DllReferencePlugin({
manifest: path.resolve(__dirname, './dll/vendors.manifest.json') // 读取dll打包后的manifest.json,分析哪些代码跳过
}),
new CleanWebpackPlugin(),
new ManifestPlugin()
],
optimization: {
splitChunks: {
chunks: 'all'
}
}
};
</code></pre>
<p>重新<code>npm run build</code>,可以看见<code>dll.js</code>也被打包进<code>dist</code>目录了,同时<code>index.html</code>也正确引用</p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>learn dll</title>
<link href="main.198b3634.css" rel="stylesheet"></head>
<body>
<div id="app"></div>
<script type="text/javascript" src="vendors.8ec3d1ea.dll.js"></script><script type="text/javascript" src="main.0bc9c924.js"></script></body>
</html></code></pre>
<p><img src="/img/remote/1460000019395243?w=289&h=201" alt="" title=""></p>
<h3>runtime</h3>
<p>webpack中有<a href="https://link.segmentfault.com/?enc=M475hKswmL%2Bc4C1OhXy3CQ%3D%3D.USX4HJ12ZapM9%2FjLKZKU8%2Fbg5j74OLq0snQQ5SKccl7m7H9%2BA9wbQXfhk8M%2Fz7sD" rel="nofollow">运行时</a>的概念,比如我们通过webpack打包后分割成了<code>dll.js</code>,<code>vendors.js</code>,<code>main.js</code>,那这三个代码,到底哪个先调用,哪个后调用,他们运行顺序就是由运行时代码组织(通过读取<code>manifest</code>数据)</p>
<p>通常情况下我们无需关心运行时代码,但如果希望尽可能的优化浏览器缓存,那么我们可以把运行时代码单独提取出来,这样某些文件发生改变后,一些与之相关的文件hash值并不会也随之改变。</p>
<p>通过配置<code>runtimeChunk</code>即可</p>
<pre><code class="javascript">optimization: {
runtimeChunk: { name: 'manifest' }
}</code></pre>
<h3>小结</h3>
<p>我们介绍了5种<code>manifest</code>相关的前端技术。<code>manifest</code>的英文含义是<strong>名单</strong>, 5种技术的确都是把<code>manifest</code>当做清单使用:</p>
<ol>
<li>缓存清单</li>
<li>PWA清单</li>
<li>打包资源路径清单</li>
<li>dll打包清单</li>
<li>代码加载顺序清单</li>
</ol>
<p>只不过是在不同的场景中使用特定的清单来完成某些功能</p>
<p>所以,学好英文是多么重要,这样才不会傻傻分不清<code>manifest</code>到底是干啥的!</p>
重拾JSX
https://segmentfault.com/a/1190000018579297
2019-03-20T09:35:43+08:00
2019-03-20T09:35:43+08:00
深红
https://segmentfault.com/u/deepred5
4
<h3>React.createElement语法糖</h3>
<p><a href="https://link.segmentfault.com/?enc=7oACZ3cl8oyMR8F0BaRMxg%3D%3D.9aRlsLOKyDKg%2FJpNGN%2BNQRhM2dBy12NiMakHET81VAsIOfHX6odGw4nQ3JEP6aSR" rel="nofollow">JSX</a>是一种JavaScript的语法拓展,可以使用它来进行UI的展示:</p>
<pre><code class="javascript">const element = <h1>Hello, world!</h1>;</code></pre>
<p>我们一般会在组件的<code>render</code>方法里使用JSX进行布局和事件绑定:</p>
<pre><code class="javascript">class Home extends Component {
render() {
return (
<div onClick={() => console.log('hello')}>
<h1>Hello, world!</h1>
<Blog title="deepred" />
</div>
);
}
}</code></pre>
<p>React的核心机制之一就是可以创建虚拟的DOM元素,利用虚拟DOM来减少对实际DOM的操作从而提升性能,JSX正是为了虚拟DOM而存在的语法糖</p>
<p>我们在平时的组件编写中,通常都这么写:</p>
<pre><code class="javascript">import React, { Component } from 'react';
class Demo extends Component {
render() {
return (
<h1>Hello, world!</h1>
)
}
}</code></pre>
<p>然而代码里面并没有用到React,为什么要引入这个变量呢?</p>
<p>因为JSX是<code>React.createElement</code>这个方法的语法糖:</p>
<pre><code class="javascript">const element = <h1 id="container" className="home">Hello</h1>;
// 等价于
const element = React.createElement("h1", {
id: "container",
className: "home"
}, "Hello");</code></pre>
<p>推荐大家在<a href="https://link.segmentfault.com/?enc=20calCa5d8e7VzfhjNZWgg%3D%3D.C72q5izTUELCjtZmgmVcqwCRzJ3cttyTaOWqU%2FTW1mY%3D" rel="nofollow">babeljs.io</a>上看下JSX编译后的实际效果<br><img src="/img/remote/1460000018579304?w=1183&h=207" alt="jsx" title="jsx"></p>
<p><a href="https://link.segmentfault.com/?enc=CvkGIH%2BdpwykLEY5%2Fl2tmA%3D%3D.LZmWL12WMjcjJEyNSTityp2X%2FFTvGf%2FTkzc90FyhN%2Fw8LnNJl5dcCrHyWKufIOTJjKeRMM6Xo7FXoRqw2xtEAw%3D%3D" rel="nofollow">React.createElement</a>有三个参数:</p>
<pre><code class="javascript">React.createElement(
type, // dom类型,比如div,h1
[props], // dom属性,比如id,class,事件
[...children] // 子节点,字符串或者React.createElement生成的一个对象
)</code></pre>
<p>JSX用一种类似HTML的语法替代了比较繁琐的<code>React.createElement</code>纯JS方法,而<code>@babel/preset-react</code>插件就起到了最关键的一步:负责在webpack编译时,把所有的JSX都改成<code>React.createElement</code>:</p>
<pre><code class="javascript">class Home extends Component {
render() {
return (
<div onClick={() => console.log('hello')}>
<h1>Hello, world!</h1>
<Blog title="deepred" />
</div>
);
}
}</code></pre>
<p>编译后:</p>
<pre><code class="javascript">class Home extends Component {
render() {
return React.createElement("div", {
onClick: () => console.log('hello')
}, React.createElement("h1", null, "Hello, world!"), React.createElement(Blog, {
title: "deepred"
}));
}
}</code></pre>
<p>在开发中,有了JSX后我们基本不怎么需要用到<code>createElement</code>方法,但如果我们需要实现这样一个组件:</p>
<pre><code class="javascript">// 根据传入的type属性,渲染成相应的html元素
<Tag type="h1" id="hello" onClick={() => console.log('hello')}>this is a h1</Tag>
<Tag type="p">this is a p</Tag></code></pre>
<p>我们不太可能根据type的属性,一个个<code>if else</code>去判断对应的标签:</p>
<pre><code class="javascript">function Tag(props) {
const { type, ...other } = props;
if (type === 'h1') {
return <h1 {...other}>{props.children}</h1>
}
if (type === 'p') {
return <p {...other}>{props.children}</p>
}
}</code></pre>
<p>这时,就需要用到底层的api了:</p>
<pre><code class="javascript">function Tag(props) {
const { type, ...other } = props;
return React.createElement(type, other, props.children);
}</code></pre>
<h3>自己实现一个JSX渲染器</h3>
<p>虚拟dom本质就是一个js对象:</p>
<pre><code class="javascript">const vnode = {
tag: 'div',
attrs: {
className: 'container'
},
children: [
{
tag: 'img',
attrs: {
src: '1.png'
},
children: []
},
{
tag: 'h3',
attrs: {},
children: ['hello']
}
]
}</code></pre>
<p>可以通过在每个文件的上方添加<code>/** @jsx h */</code>来告诉<code>@babel/preset-react</code>用<code>h</code>方法名代替JSX(默认方法是React.createElement)</p>
<pre><code class="javascript">/** @jsx h */
const element = <h1 id="container" className="home">Hello</h1>;</code></pre>
<pre><code class="javascript">/** @jsx h */
const element = h("h1", {
id: "container",
className: "home"
}, "Hello");</code></pre>
<p><img src="/img/remote/1460000018579305?w=1113&h=148" alt="jsx2" title="jsx2"></p>
<p>现在让我们开始创建自己的<code>h</code>函数吧!</p>
<pre><code class="javascript">function h(nodeName, attributes, ...args) {
// 使用concat是为了扁平化args,因为args数组里面的元素可能也是数组
// h('div', {}, [1, 2, 3]) h('d', {}, 1, 2, 3) 都是合法的调用
const children = args.length ? [].concat(...args) : null;
return { nodeName, attributes, children };
}
</code></pre>
<pre><code class="javascript">const vnode = h("div", {
id: "urusai"
}, "Hello!");
// 返回
// {
// "nodeName": "div",
// "attributes": {
// "id": "urusai"
// },
// "children": [
// "Hello!"
// ]
// }</code></pre>
<p><code>h</code>的作用就是返回一个vnode,有了vnode,我们还需要把vnode转成真实的dom:</p>
<pre><code class="javascript">function render(vnode) {
if (typeof vnode === 'string') {
// 生成文本节点
return document.createTextNode(vnode);
}
// 生成元素节点并设置属性
const node = document.createElement(vnode.nodeName);
const attributes = vnode.attributes || {};
Object.keys(attributes).forEach(key => node.setAttribute(key, attributes[key]));
if (vnode.children) {
// 递归调用render生成子节点
vnode.children.forEach(child => node.appendChild(render(child)));
}
return node;
}</code></pre>
<p>现在让我们使用这两个方法吧:</p>
<pre><code class="javascript">/** @jsx h */
const vnode = <div id="urusai">Hello!</div>;
const node = render(vnode);
document.body.appendChild(node);</code></pre>
<p>编译转码后:</p>
<pre><code class="javascript">/** @jsx h */
const vnode = h("div", {
id: "urusai"
}, "Hello!");
const node = render(vnode);
document.body.appendChild(node);</code></pre>
<p>我们还可以遍历数组:</p>
<pre><code class="javascript">/** @jsx h */
const items = ['baga', 'hentai', 'urusai'];
const vnode = <ul>{items.map((item, index) => <li key={index}>{item}</li>)}</ul>;
const list = render(vnode);
document.body.appendChild(list);</code></pre>
<p>编译转码后:</p>
<pre><code class="javascript">/** @jsx h */
const items = ['baga', 'hentai', 'urusai'];
const vnode = h("ul", null, items.map((item, index) => h("li", {
key: index
}, item)));
const list = render(vnode);
document.body.appendChild(list);</code></pre>
<p>通过<code>h</code> <code>render</code>两个函数,我们就实现了一个很简单的JSX渲染器!!!</p>
<h3>参考</h3>
<ul>
<li><a href="https://link.segmentfault.com/?enc=vSjypII6zpQxKS8rg1JtMA%3D%3D.wRdyaju2o31XOykC86z0d0W2ytm42L8kBXyPHFoH919GAIMQzoXYjQJORQmzNfCd" rel="nofollow">WTF is JSX</a></li>
<li><a href="https://link.segmentfault.com/?enc=VxgXK8FN8Cm2PzmyQdFD1Q%3D%3D.ygNWlBMBCqViYmnszQICCnResuu4aiZ0RPFDNuh2kWL5H%2Fw51vlXCZEBu8%2BlIOn9" rel="nofollow">JSX In Depth</a></li>
<li><a href="https://link.segmentfault.com/?enc=sNVNCZjRNyRJsAbTNYq%2Fyw%3D%3D.VKiLXyFC0TnMnj6ws4SiwBAH4Kr0DbYFHetOaxYCvJtioQYeFjwai14b9qx1d73O" rel="nofollow">React Without JSX</a></li>
</ul>
IntersectionObserve初试
https://segmentfault.com/a/1190000018346569
2019-02-28T22:36:06+08:00
2019-02-28T22:36:06+08:00
深红
https://segmentfault.com/u/deepred5
13
<p><code>IntersectionObserve</code>这个API,可能知道的人并不多(我也是最近才知道...),这个API可以很方便的监听元素是否进入了可视区域。</p>
<pre><code class="html"><style>
* {
margin: 0;
padding: 0;
}
.test {
width: 200px;
height: 1000px;
background: orange;
}
.box {
width: 150px;
height: 150px;
margin: 50px;
background: red;
}
</style>
<div class="test">test</div>
<div class="box">box</div></code></pre>
<p>上图代码中,<code>.box</code>元素目前并不在可视区域(viewport)内,如何监听它进入可视区域内?</p>
<p>传统做法是:监听scroll事件,实时计算<code>.box</code>距离viewport的top值:</p>
<pre><code class="javascript">const box = document.querySelector('.box');
const viewHeight = document.documentElement.clientHeight;
window.addEventListener('scroll', () => {
const top = box.getBoundingClientRect().top;
if (top < viewHeight) {
console.log('进入可视区域');
}
});</code></pre>
<p>然而<code>scroll</code>事件会频繁触发,因此我们还需要手动节流。</p>
<p>使用<code>IntersectionObserve</code>就非常方便了:</p>
<pre><code class="javascript">const box = document.querySelector('.box');
const intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach((item) => {
if (item.isIntersecting) {
console.log('进入可视区域');
}
})
});
intersectionObserver.observe(box);</code></pre>
<p>IntersectionObserver API是异步的,不随着目标元素的滚动同步触发,所以不会有性能问题。</p>
<h2>IntersectionObserver构造函数</h2>
<pre><code class="javascript">const io = new IntersectionObserver((entries) => {
console.log(entries);
});
io.observe(dom);</code></pre>
<p>调用IntersectionObserver时,需要给它传一个回调函数。当监听的dom元素<strong>进入可视区域</strong>或者<strong>从可视区域离开</strong>时,回调函数就会被调用。</p>
<p><strong>注意</strong>: 第一次调用<code>new IntersectionObserver</code>时,callback函数会先调用一次,即使元素未进入可视区域。</p>
<p>构造函数的返回值是一个观察器实例。实例的observe方法可以指定观察哪个 DOM 节点。</p>
<pre><code class="javascript">// 开始观察
io.observe(document.getElementById('example'));
// 停止观察
io.unobserve(element);
// 关闭观察器
io.disconnect();</code></pre>
<h2>IntersectionObserverEntry对象</h2>
<p>callback函数被调用时,会传给它一个数组,这个数组里的每个对象就是当前进入可视区域或者离开可视区域的对象(IntersectionObserverEntry对象)</p>
<p>这个对象有很多属性,其中最常用的属性是:</p>
<ul>
<li>target: 被观察的目标元素,是一个 DOM 节点对象</li>
<li>isIntersecting: 是否进入可视区域</li>
<li>intersectionRatio: 相交区域和目标元素的比例值,进入可视区域,值大于0,否则等于0</li>
</ul>
<h2>options</h2>
<p>调用IntersectionObserver时,除了传一个回调函数,还可以传入一个option对象,配置如下属性:</p>
<ul>
<li>threshold: 决定了什么时候触发回调函数。它是一个数组,每个成员都是一个门槛值,默认为[0],即交叉比例(intersectionRatio)达到0时触发回调函数。用户可以自定义这个数组。比如,[0, 0.25, 0.5, 0.75, 1]就表示当目标元素 0%、25%、50%、75%、100% 可见时,会触发回调函数。</li>
<li>root: 用于观察的根元素,默认是浏览器的视口,也可以指定具体元素,指定元素的时候用于观察的元素必须是指定元素的子元素</li>
<li>rootMargin: 用来扩大或者缩小视窗的的大小,使用css的定义方法,10px 10px 30px 20px表示top、right、bottom 和 left的值</li>
</ul>
<pre><code class="javascript">const io = new IntersectionObserver((entries) => {
console.log(entries);
}, {
threshold: [0, 0.5],
root: document.querySelector('.container'),
rootMargin: "10px 10px 30px 20px",
});</code></pre>
<h2>懒加载</h2>
<p>图片懒加载的原理就是:给img标签一个自定义属性,用来记录真正的图片地址。默认img标签只加载一个占位符。当图片进入可视区域时,再把img的src属性更换成真正的图片地址。</p>
<pre><code class="html"><div>
<img src="/empty.jpg" data-src="/img/1.jpg" />
<img src="/empty.jpg" data-src="/img/2.jpg" />
</div></code></pre>
<pre><code class="javascript">const intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach((item) => {
if (item.isIntersecting) {
item.target.src = item.target.dataset.src;
// 替换成功后,停止观察该dom
intersectionObserver.unobserve(item.target);
}
})
}, {
rootMargin: "150px 0px" // 提前150px进入可视区域时就加载图片,提高用户体验
});
const imgs = document.querySelectorAll('[data-src]');
imgs.forEach((item) => {
intersectionObserver.observe(item)
});</code></pre>
<h2>打点上报</h2>
<p>前端页面经常有上报数据的需求,比如统计页面上的某些元素是否被用户查看,点击。这时,我们就可以封装一个<code>Log</code>组件,用于当元素进入可视区域时,就上报数据。<br>以React为例,我们希望:被<code>Log</code>组件包裹的元素,进入可视区域后,就向后台发送<code>{ appid: 1234, type: 'news'}</code>数据</p>
<pre><code class="javascript"><Log
className="container"
log={{ appid: 1234, type: 'news'}}
style={{ marginTop: '1400px' }}
onClick={() => console.log('log')}
>
<div className="news" onClick={() => console.log('news')}>
<p>其他内容</p>
</div>
</Log></code></pre>
<pre><code class="javascript">import React, { Component } from 'react';
import PropTypes from 'prop-types';
class Log extends Component {
static propTypes = {
log: PropTypes.object
};
constructor(props) {
super(props);
this.io = null;
this.ref = React.createRef();
}
componentDidMount() {
this.io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
console.log('进入可视区域,将要发送log数据', this.props.log);
sendLog(this.props.log);
this.io.disconnect();
}
}
);
this.io.observe(this.ref.current);
}
componentWillUnmount() {
this.io && this.io.disconnect();
}
render() {
// 把log属性去掉,否则log属性也会渲染在div上
// <div log="[object Object]"></div>
const { log, ...props } = this.props;
return (
<div ref={this.ref} {...props}>
{this.props.children}
</div>
)
}
}
export default Log</code></pre>
<h2>兼容性</h2>
<p>safari并不支持该API,因此为了兼容主流浏览器,我们需要引入polyfill<br><a href="https://link.segmentfault.com/?enc=A5t5%2FT%2FcP%2F632szPAvi3Rg%3D%3D.5FxGGpQiGqAJLfRnfqcpaIiCkJfoapokkhOuCyCc3h9Ln4OAxWnlzGbZF6%2FjFItdsG6iNlBZzYAbXg5rZS8FCw%3D%3D" rel="nofollow">intersection-observer</a></p>
JavaScript实现自定义的生命周期
https://segmentfault.com/a/1190000017892146
2019-01-15T09:00:00+08:00
2019-01-15T09:00:00+08:00
深红
https://segmentfault.com/u/deepred5
10
<p>React,Vue 和 Angular 的流行,让“生命周期”这个名词常常出现在前端们的口中,以至于面试中最常见的一个问题也是:</p>
<blockquote>介绍下React, Vue的生命周期以及使用方法?</blockquote>
<p>听起来高大上的“生命周期”,其实也就是一些普通的方法,只是在不同的时期传参调用它们而已。我们可以照着React的生命周期,自己模拟一个简单的类,并让这个类拥有一些生命周期钩子</p>
<p>我们希望实现一个<code>State</code>类,这个类拥有以下方法和生命周期:</p>
<p>方法:</p>
<ul><li>setState</li></ul>
<p>生命周期:</p>
<ul>
<li>willStateUpdate (nextState): 状态将要改变</li>
<li>shouldStateUpdate (nextState): 是否要让状态改变,只有返回true才会改变状态</li>
<li>didStateUpdate (prevState): 状态改变后(要是 shouldStateUpdate 返回的不为true则不会调用)</li>
</ul>
<pre><code class="javascript">class User extends State {
constructor(name) {
super();
this.state = { name }
}
willStateUpdate(nextState) {
console.log('willStateUpdate', nextState);
}
shouldStateUpdate(nextState) {
console.log('shouldStateUpdate', nextState);
if (nextState.name === this.state.name) {
return false;
}
return true;
}
didStateUpdate(prevState) {
console.log('didStateUpdate', prevState);
}
}
const user = new User('deepred');
user.setState({ name: 'hentai' });</code></pre>
<p>首先,你需要知道JavaScript的面向对象基础知识,如果还不是很了解,可以先看下这篇文章<a href="https://link.segmentfault.com/?enc=N90Or7Su9tjX47YOBNHrxw%3D%3D.qJbMRQe7lsNuaq6fXW8%2FlUI9zHKT22nuv5N%2FfwvWvglRW53C%2B7RPFCZJ4QJshCWXYGJApZELS9oqoH6JX10pFnm6kECdJ8mhqWwgVK%2BpCfwD186JS5K%2BPSVwnrkSvWhG" rel="nofollow">JavaScript的面向对象</a></p>
<h3>setState的实现</h3>
<pre><code class="javascript">class State {
constructor() {
this.state = {};
}
setState(nextState) {
const preState = this.state;
this.state = {
...preState,
...nextState,
};
}
}</code></pre>
<pre><code class="javascript">class User extends State {
constructor(name) {
super();
this.state = {
name
}
}
}
const user = new User('tc');
user.setState({age: 10}); // {name: 'tc', age: 10}</code></pre>
<p>在React中,<code>setState</code>方法只会改变特定属性的值,因此,我们需要在方法里用一个变量<code>preState</code>保留之前的<code>state</code>,然后通过展开运算符,将新旧<code>state</code>合并</p>
<h3>willStateUpdate的实现</h3>
<p><code>willStateUpdate</code>是<code>state</code>状态更新前调用的。因此只要在合并<code>state</code>前调用<code>willStateUpdate</code>就行</p>
<pre><code class="javascript">class State {
constructor() {
this.state = {};
}
setState(nextState) {
// 更新前调用willStateUpdate
this.willStateUpdate(nextState);
const preState = this.state;
this.state = {
...preState,
...nextState,
};
}
willStateUpdate() {
// 默认啥也不做
}
}</code></pre>
<pre><code class="javascript">class User extends State {
constructor(name) {
super();
this.state = {
name
}
}
// 覆盖父级同名方法
willStateUpdate(nextState) {
console.log('willStateUpdate', nextState);
}
}
const user = new User('tc');
user.setState({age: 10}); // {name: 'tc', age: 10}</code></pre>
<h3>shouldStateUpdate的实现</h3>
<p>我们规定只有<code>shouldStateUpdate</code>返回true时,才更新<code>state</code>。因此在合并<code>state</code>前,还要调用<code>shouldStateUpdate</code></p>
<pre><code class="javascript">class State {
constructor() {
this.state = {};
}
setState(nextState) {
this.willStateUpdate(nextState);
const update = this.shouldStateUpdate(nextState);
if (update) {
const preState = this.state;
this.state = {
...preState,
...nextState,
};
}
}
willStateUpdate() {
// 默认啥也不做
}
shouldStateUpdate() {
// 默认返回true,一直都是更新
return true;
}
}</code></pre>
<pre><code class="javascript">class User extends State {
constructor(name) {
super();
this.state = {
name
}
}
// 覆盖父级同名方法
willStateUpdate(nextState) {
console.log('willStateUpdate', nextState);
}
// 自定义何时更新
shouldStateUpdate(nextState) {
if (nextState.name === this.state.name) {
return false;
}
return true;
}
}
const user = new User('tc');
user.setState({ age: 10 }); // {name: 'tc', age: 10}
user.setState({ name: 'tc', age: 11 }); // 没有更新</code></pre>
<h3>didStateUpdate的实现</h3>
<p>懂了<code>willStateUpdate</code>也就知道<code>didStateUpdate</code>如何实现了</p>
<pre><code class="javascript">class State {
constructor() {
this.state = {};
}
setState(nextState) {
this.willStateUpdate(nextState);
const update = this.shouldStateUpdate(nextState);
if (update) {
const preState = this.state;
this.state = {
...preState,
...nextState,
};
this.didStateUpdate(preState);
}
}
willStateUpdate() {
// 默认啥也不做
}
didStateUpdate() {
// 默认啥也不做
}
shouldStateUpdate() {
// 默认返回true,一直都是更新
return true;
}
}</code></pre>
<pre><code class="javascript">class User extends State {
constructor(name) {
super();
this.state = {
name
}
}
// 覆盖父级同名方法
willStateUpdate(nextState) {
console.log('willStateUpdate', nextState);
}
// 覆盖父级同名方法
didStateUpdate(preState) {
console.log('didStateUpdate', preState);
}
shouldStateUpdate(nextState) {
console.log('shouldStateUpdate', nextState);
if (nextState.name === this.state.name) {
return false;
}
return true;
}
}
const user = new User('tc');
user.setState({ age: 10 });
user.setState({ name: 'tc', age: 11 });</code></pre>
<p>通过几十行的代码,我们就已经实现了一个自带生命周期的<code>State</code>类了!</p>
由一次重构代码所想到的
https://segmentfault.com/a/1190000016326029
2018-09-09T12:43:11+08:00
2018-09-09T12:43:11+08:00
深红
https://segmentfault.com/u/deepred5
7
<p>事件的起因源于我大三时写过的一个chrome插件:<a href="https://link.segmentfault.com/?enc=fjq5hFqfbH%2FlRwDocuXv5Q%3D%3D.OcDHc%2BQeEQNlDZkFkn0p54dqixyrAqRYpNLx4h7q5x6auypgyWPZkrJ37frlMDoc4nZNPZgrmofenEB58fOne7Nb%2BufEHaLu0S7haUAa4Ec%2FU1EeVox1pszahj7AzI6JThwM1xliMrjM2foLqqmrJA%3D%3D" rel="nofollow">老司机的工具箱</a>,当时因为某XX御所开启了老司机模式,导致资源下载链接被隐藏,再加上那时无意间看了一篇教程<a href="https://link.segmentfault.com/?enc=Rq9SklIJq30GZBEgXF4WaA%3D%3D.%2B63hiCZghcBMaONH2EsV4pd4A0t9FjWcISWPXv4xdI2Nd6wqlTmJU34wwweqcRW5" rel="nofollow">Chrome扩展及应用开发</a>,于是<del>性致勃勃</del>的花了几天时间,写出了这个插件:用来显示被隐藏的下载地址和自动填写百度网盘密码。之后插件也陆陆续续迭代了几个版本,不过最后不了了之。</p>
<p>插件发布到如今,两年时间里,也有几千用户了,这点倒是让我挺意外的,看来世上还是绅士多吧。。。</p>
<p>前几天在家无事,于是就review了代码(<a href="https://link.segmentfault.com/?enc=8D4hniEHh45ASDyKJ%2FGrOg%3D%3D.fX9xNZTV76mdqY8xZo8hBiK%2BF0Tz0LUGCFjiSqYEU6iLr5HAGBcW9x%2BCQup1F7FZ" rel="nofollow">项目地址</a>): <strong>两年前的代码看懂是不可能看懂的,这辈子都不能看懂,只能重构下代码这样子。</strong> 不过在重构的过程中,也不禁感叹两年的时间,前端还真的是风云变幻,当年的自己的确菜的抠脚。</p>
<h2>前端工程化</h2>
<p>重构时最大的区别就是工程化了。</p>
<p>两年前的代码,我还是停留在html页面直接引入js,css,写代码就是jQuery一把梭子的层面。</p>
<p>而如今,在真正写代码前,我可能需要花些时间,来配置一些诸如webpack,babel的构建、编译工具。配置的繁琐带来的是开发时的便捷,2年前没有模块化的js和css是我现在不敢想象的。</p>
<h2>代码风格</h2>
<p>两年前的代码到处充斥着各种全局变量和函数,随意的DOM操作和callback调用,使得面条代码让人看得更加凌乱。</p>
<p>而如今,我更加倾向于面向对象和函数式编程。</p>
<p>两年前我应该会毫不犹豫写出这样的代码:</p>
<pre><code class="javascript">window.addEventListener('DOMContentLoaded', function() {
function renderContainer(data) {
// 对数据进行一些加工
return newData;
}
ajax({
url: api,
type: 'GET',
dataType: 'json',
success: function(data) {
const container = document.querySelector('#container');
container.innerHTML = renderContainer(data);
}
})
const btn = document.querySelector('#btn');
btn.addEventListener('click', function() {
// 处理事件
})
})</code></pre>
<p>现在我会这样写:</p>
<pre><code class="javascript">class Demo {
constructor() {
this.container = document.querySelector('#container');
this.btn = document.querySelector('#btn');
this.init();
}
renderTemplate(data) {
// 对数据进行一些加工
return newData;
}
init() {
this.renderContainer();
this.bindHandler();
}
async renderContainer() {
const data = await ajax({
url: api,
type: 'GET',
dataType: 'json'
});
this.container.innerHTML = this.renderTemplate(data);
}
bindHandler() {
this.btn.addEventListener('click', function() {
// 处理事件
})
}
}
window.addEventListener('DOMContentLoaded', function() {
const demo = new Demo();
})</code></pre>
<p>其实这种写法已经类似于React和Vue了。MVVM框架除了带来数据驱动的理念,其实也在一定程度上推动了面向对象和函数式编程的思想。</p>
<h2>解决问题</h2>
<p>两年前我写这个插件的时候,遇到了一个很费解的bug:就是进入网站首页,点击文章标题进入详情页面后,并不能显示隐藏的下载地址,每次都需要我手动刷新一遍页面才能成功。</p>
<p>当时水平有限,想了半天也不明白为啥会这样,拖着拖着就忘了。这次重构,想起了这个bug,分析了一下,其实很简单:网站采用了pjax技术,进入首页后,插件注入的js就被触发,寻找被隐藏的下载地址dom,然而这时并没有这个dom。点击标题进入详情页,这时我们需要的dom被插入了,但是由于使用了pjax,整个页面其实并没有重新加载,插件注入的js已经被执行过一次了,所以这时就无法把dom展示出来,而需要我们手动刷新,重新执行一遍注入的js。</p>
<p>解决方法是,利用<code>MutationObserver</code>监听pjax更新的dom元素,如果发现更新了dom,就再次执行js方法</p>
<p>还遇到了一个问题:</p>
<pre><code class="javascrpt">`【磁力链接】
magnet:?xt=urn:btih:404d1cf190660dfd301e289411cfc3185fcb2c92
【百度云】
传送门 提取码:lmys
`</code></pre>
<p>如何在把lmys提取出来?</p>
<p>当时很拙劣的使用了字符串截取:</p>
<pre><code class="javascript">function getPwd(str) {
var index1 = str.indexOf('提取码');
var index2 = str.indexOf('\n', index1);
if (index1 !== -1 && index2 !== -1) {
return str.slice(index1 + 4, index2).trim();
}
return '';
}</code></pre>
<p>现在看来,一行正则就搞定的事情:</p>
<pre><code class="javascript">const regPassword = /提取码.*([a-zA-Z0-9]{4})/;</code></pre>
<h2>总结</h2>
<p>废话了那么多,其实就是想说,每个人在每个阶段都会受限于当时的技术水平和眼界格局,而写出在当时自认为是最好的代码。</p>
<p>如果你最近觉得自己水平一直上不去,技术遇到了瓶颈,这时不妨---</p>
<p>拔掉网线,关上电脑,读几页《Angular从入门到放弃》,出门去漫展走走,要么去女装,天黑了约几个好久不见的肥宅找个地方喝点快乐水、聊聊纸片老婆,随便做些什么。一天下来,你就会发现,还是jQuery写的爽!</p>
Promise必知必会
https://segmentfault.com/a/1190000015938310
2018-08-08T22:56:02+08:00
2018-08-08T22:56:02+08:00
深红
https://segmentfault.com/u/deepred5
16
<p>前端开发中经常会进行一些异步操作,常见的异步有:</p>
<ul>
<li>网络请求:ajax</li>
<li>IO操作: readFile</li>
<li>定时器:setTimeout</li>
</ul>
<p><a href="https://link.segmentfault.com/?enc=BaZRqxUyBtF%2BUfqqWMWkvQ%3D%3D.4gxANC7kPvOu%2BSQQPGlI8A%3D%3D" rel="nofollow">博客地址</a></p>
<h2>回调</h2>
<p>最基础的异步解决方案莫过于回调函数了</p>
<p>前端经常会在成功时和失败时分别注册回调函数</p>
<pre><code class="javascript">const req = new XMLHttpRequest();
req.open('GET', URL, true);
req.onload = function () {
// 成功的回调
if (req.status === 200) {
console.log(req.statusText)
}
};
req.onerror = function () {
// 失败的回调
console.log(req.statusText)
};
req.send();</code></pre>
<p>node的异步api,则通常只注册一个回调函数,通过约定的参数来判断到底是成功还是失败:</p>
<pre><code class="javascript">const fs = require("fs");
fs.readFile('input.txt', function (err, data) {
// 回调函数
// 第一个参数是err,如果有err,则表示调用失败
if (err) {
return console.error(err);
}
console.log("异步读取: " + data.toString());
});</code></pre>
<p>回调的异步解决方案本身也简单易懂,但是它有一个致命的缺点:<strong>无法优雅的控制异步流程</strong></p>
<p>什么意思?</p>
<p>单个异步当然可以很简单的使用回调函数,但是对于多个异步操作,就会陷入回调地狱中</p>
<pre><code class="javascript">// 请求data1成功后再请求data2,最后请求data3
const ajax = $.ajax({
url: 'data1.json',
success: function(data1) {
console.log(data1);
$.ajax({
url: 'data2.json',
success: function(data2) {
console.log(data2);
$.ajax({
url: 'data3.json',
success: function(data3) {
console.log(data3);
}
})
}
})
}
})</code></pre>
<p>这种要按顺序进行异步流程控制的场景,回调函数就显得捉襟见肘了。这时,Promise的异步解决方案就被提了出来。</p>
<h2>Promise</h2>
<p>当初在学Promise时,看得我真是一脸懵逼,完全不明白这货到底怎么用。其实,Promise的api要分成两部分来理解:</p>
<ol>
<li>Promise构造函数:resolve reject (改变内部状态)</li>
<li>Promise对象: then catch (流程控制)</li>
</ol>
<h2>Promise对象</h2>
<p>Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)</p>
<p>初始时,该对象状态为pending,之后只能变成fulfilled和rejected其中的一个</p>
<p>then方法有两个参数,分别对应状态为fulfilled和rejected时的回调函数,其中第二个参数可选</p>
<pre><code class="javascript">
promise.then(function(value) {
// success
}, function(error) {
// failure
});</code></pre>
<p>通常我们会省略then的第二个参数,而改用catch来注册状态变为rejected时的回调函数</p>
<pre><code class="javascript">promise.then(function(value) {
// success
}).catch(function(error) {
// failure
});</code></pre>
<h2>Promise构造函数</h2>
<p>Promise对象怎么生成的呢?就是通过构造函数new出来的。</p>
<pre><code class="javascript">const promise = new Promise(function(resolve, reject) {
});</code></pre>
<p>Promise构造函数接收一个函数作为参数,这个函数可以接收两个参数:resolve和reject</p>
<p>resolve, reject是两个函数,由JavaScript引擎提供,不用自己编写</p>
<p>前面我们说过,Promise对象有三种状态,初始时为pending,之后可以变成fulfilled或者rejected,那怎么改变状态呢?答案就是调用resolve或者reject</p>
<p>调用resolve时,状态变成fulfilled,表示异步已经完成;调用reject时,状态变成rejected,表示异步失败。</p>
<h2>回调和Promise的对比</h2>
<p><strong>其实这里就是Promise最难理解的地方了,我们先看下例子:</strong></p>
<p>回调函数封装</p>
<pre><code class="javascript">function getURL(URL, success, error) {
const req = new XMLHttpRequest();
req.open('GET', URL, true);
req.onload = function () {
if (req.status === 200) {
success(req.responseText);
} else {
error(new Error(req.statusText));
}
};
req.onerror = function () {
error(new Error(req.statusText));
};
req.send();
}
const URL = "http://httpbin.org/get";
getURL(URL, function onFulfilled(value) {
console.log(value);
}, function onRejected(error) {
console.error(error);
})</code></pre>
<p>Promise封装</p>
<pre><code class="javascript">function getURL(URL) {
return new Promise(function (resolve, reject) {
const req = new XMLHttpRequest();
req.open('GET', URL, true);
req.onload = function () {
if (req.status === 200) {
resolve(req.responseText);
} else {
reject(new Error(req.statusText));
}
};
req.onerror = function () {
reject(new Error(req.statusText));
};
req.send();
});
}
const URL = "http://httpbin.org/get";
getURL(URL).then(function onFulfilled(value){
console.log(value);
}).catch(function onRejected(error){
console.error(error);
});</code></pre>
<p>两段代码最大的区别就是:</p>
<p><strong>用回调函数封装的getURL函数,需要明显的传给它成功和失败的回调函数,success和error的最终调用是在getURL里被调用的</strong></p>
<p><strong>用Promise封装的getURL函数,完全不关心成功和失败的回调函数,它只需要在ajax成功时调用resolve(),告诉promise对象,你现在的状态变成了fulfilled,在ajax失败时,调用reject()。而真正的回调函数,是在getURL的外面被调用的,也就是then和catch中调用</strong></p>
<p>then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。</p>
<pre><code class="javascript">function getURL(URL) {
return new Promise(function (resolve, reject) {
const req = new XMLHttpRequest();
req.open('GET', URL, true);
req.onload = function () {
if (req.status === 200) {
resolve(req.responseText);
} else {
reject(new Error(req.statusText));
}
};
req.onerror = function () {
reject(new Error(req.statusText));
};
req.send();
});
}
const URL = "http://httpbin.org/get";
const URL2 = "http://deepred5.com/cors.php?search=ntr";
getURL(URL).then(function onFulfilled(value){
console.log(value);
// 返回了一个新的Promise对象
return getURL(URL2)
}).then(function onFulfilled(value){
console.log(value);
}).catch(function onRejected(error){
console.error(error);
});
</code></pre>
<p>这段代码就充分说明了Promise对于流程控制的优势:读取URL的数据后再读取URL2,没有了之前的回调地狱问题。</p>
<h2>Promise应用</h2>
<p>Promise经常用于对函数的异步流程封装</p>
<pre><code class="javascript">function getURL(URL) {
return new Promise(function (resolve, reject) {
const req = new XMLHttpRequest();
req.open('GET', URL, true);
req.onload = function () {
if (req.status === 200) {
resolve(req.responseText);
} else {
reject(new Error(req.statusText));
}
};
req.onerror = function () {
reject(new Error(req.statusText));
};
req.send();
});
}</code></pre>
<pre><code class="javascript">const preloadImage = function (path) {
return new Promise(function (resolve, reject) {
const image = new Image();
image.onload = resolve;
image.onerror = reject;
image.src = path;
});
};</code></pre>
<pre><code class="javascript">const fs = require('fs')
const path = require('path')
const readFilePromise = function (fileName) {
return new Promise((resolve, reject) => {
fs.readFile(fileName, (err, data) => {
if (err) {
reject(err)
} else {
resolve(data.toString())
}
})
})
}</code></pre>
<p>结合上面几个例子,我们可以看出Promise封装代码的基本套路:</p>
<pre><code class="javascript">const methodPromise = function() {
return new Promise((resolve, reject) => {
// 异步流程
if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
})
}</code></pre>
<h2>Promise.race Promise.all</h2>
<p>Promise.all 接收一个promise对象的数组作为参数,当这个数组里的所有promise对象全部变为resolve的时候,它才会去调用then方法,如果其中有一个变为rejected,就直接调用catch方法</p>
<p>传给then方法的是一个数组,里面分别对应promise返回的结果</p>
<pre><code class="javascript">function getURL(URL) {
return new Promise(function (resolve, reject) {
const req = new XMLHttpRequest();
req.open('GET', URL, true);
req.onload = function () {
if (req.status === 200) {
resolve(req.responseText);
} else {
reject(new Error(req.statusText));
}
};
req.onerror = function () {
reject(new Error(req.statusText));
};
req.send();
});
}
Promise.all([getURL('http://deepred5.com/cors.php?search=ntr'), getURL('http://deepred5.com/cors.php?search=rbq')])
.then((dataArr) => {
const [data1, data2] = dataArr;
}).catch((err) => {
console.log(err)
})</code></pre>
<p>Promise.race类似,只不过只要有一个Promise变成resolve就调用then方法</p>
<h2>Promise.resolve Promise.reject</h2>
<pre><code class="javascript">Promise.resolve(42);
// 等价于
new Promise(function(resolve){
resolve(42);
});
Promise.reject(new Error("出错了"))
// 等价于
new Promise(function(resolve,reject){
reject(new Error("出错了"));
});</code></pre>
<pre><code class="javascript">Promise.resolve(42).then(function(value){
console.log(value);
});
Promise.reject(new Error("出错了")).catch(function(error){
console.error(error);
});</code></pre>
<p>Promise.resolve方法另一个作用就是将thenable对象转换为promise对象</p>
<pre><code class="javascript">const promise = Promise.resolve($.ajax('/json/comment.json'));// => promise对象
promise.then(function(value){
console.log(value);
});</code></pre>
<p>thenable对象指的是具有then方法的对象:</p>
<pre><code class="javascript">let thenable = {
then: function(resolve, reject) {
resolve(42);
}
};
let p1 = Promise.resolve(thenable);
p1.then(function(value) {
console.log(value); // 42
});</code></pre>
<h2>异常捕获</h2>
<p>理想状态下,Promise可以通过catch捕获到异常,但是如果我们没有使用catch,那么虽然控制台会打印错误,但是这次错误并不会终止脚本执行</p>
<pre><code class="html"><script>
const a = b.c.d;
console.log(1); // 代码报错,不会运行到此处
</script>
<script>
console.log(2); // 代码运行
</script></code></pre>
<p>上述代码只会打印2</p>
<pre><code class="html"><script>
const promise = new Promise((resolve, reject) => {
const a = b.c.d;
resolve('ok');
})
promise.then(data => {
console.log(data)
})
console.log(1); // 代码报错,但是会运行到此处
</script>
<script>
console.log(2); // 代码运行
</script></code></pre>
<p>打印1和2</p>
<p>解决方法:<br>window有一个unhandledRejection事件,专门监听未捕获的reject错误</p>
<pre><code class="javascript">window.onunhandledrejection = function(e) {
console.log(e.reason);
}
const promise = new Promise((resolve, reject) => {
const a = b.c.d;
resolve('ok');
})
promise.then(data => {
console.log(data)
})</code></pre>
<h2>参考</h2>
<ul>
<li><a href="https://link.segmentfault.com/?enc=fD5yqxT9hmVnrHqki%2BDnlA%3D%3D.yX48iR1HKX%2BCGUJDgWiZsJWojic0haetLG3Ci1FnIu9Yr%2BdMCW7EHUSfG3Ok97hg" rel="nofollow">ECMAScript 6 入门</a></li>
<li><a href="https://link.segmentfault.com/?enc=PlKLaXgNI4LBZQCTcZfk7A%3D%3D.Sya1G%2BhP6kaKn2oa5%2BhmkdqzhrZ0uVxOEafuZPvvtnd8H%2B%2FA1HoNqPD%2FO077Qmbr" rel="nofollow">JavaScript Promise迷你书</a></li>
<li><a href="https://link.segmentfault.com/?enc=8FPgOkpbRF7Rb2gyedXgZw%3D%3D.HSCxAJ6%2BJlt35oe8vvDlkakQOsba%2B%2FD%2B2q0sLfAo61hztFOXB2evxkZbadUNGf8ajG%2BvvRIDyBKxvNHm3dZC6w%3D%3D" rel="nofollow">深入理解 JavaScript 异步</a></li>
</ul>
简单易懂的现代魔法-递归
https://segmentfault.com/a/1190000015826387
2018-07-31T10:34:49+08:00
2018-07-31T10:34:49+08:00
深红
https://segmentfault.com/u/deepred5
29
<p>平时在前端开发中,好像也没啥用到递归的地方。不过这并不代表递归不重要,如果你看过一些框架的源码,就会经常见到它的影子:比如渲染虚拟DOM的render函数,webpack中require依赖分析,Koa2洋葱式的中间件模型,其实都运用到了递归算法。</p>
<p><a href="https://link.segmentfault.com/?enc=vtiqVGmxOKtX%2BxOEKKZJwg%3D%3D.Yh6n9YQ6a%2FtwTZ2dVut9d0S%2BiVw1EUAFRT9C3gYY12A%3D" rel="nofollow">博客原文</a></p>
<h2>递归概念</h2>
<p>那么递归到底是啥?先上两张图:</p>
<p>图1:<img src="/img/remote/1460000015826390?w=427&h=540" alt="" title=""></p>
<p>图2:<img src="/img/remote/1460000015826391?w=960&h=600" alt="" title=""></p>
<blockquote><strong>递归,就是在运行的过程中调用自己</strong></blockquote>
<p>我们来看个最简单的阶乘函数:</p>
<pre><code class="javascript">5! = 5 * 4 * 3 * 2 * 1</code></pre>
<pre><code class="javascript">function factorial(num) {
if (num === 1) { // 基线条件
return 1;
}
// 递归条件
return num * factorial(num-1);
}
factorial(5);</code></pre>
<p>一个常规的递归函数都有两部分:</p>
<ol>
<li>基线条件(<code>if (num === 1)</code>):保证函数不再调用自己,避免无限循环</li>
<li>递归条件(<code>num * factorial(num-1)</code>):保证函数能够调用自己</li>
</ol>
<h2>调用栈</h2>
<p>栈是一种先进后出的数据结构,它只有两种操作,出栈和入栈</p>
<pre><code class="javascript">const nekopara = ['chocolat', 'Coconut'];
nekopara.push('vanilla'); // 入栈
nekopara.pop(); // 出栈</code></pre>
<p>代码在运行过程中,会有一个叫做调用栈(call stack)的概念。</p>
<pre><code class="javascript">function greet(name) {
console.log(`hello, ${name}!`)
greet2(name);
console.log(`getting ready to say bye`);
bye();
}
function greet2(name) {
console.log(`how are you, ${name}?`);
}
function bye() {
console.log(`bye`);
}
greet('deepred');</code></pre>
<p>调用<code>greet('deepred')</code>时,计算机会首先给该函数分配一块内存,并将变量名<code>name</code>设置为<code>deepred</code></p>
<p><img src="/img/remote/1460000015826392?w=290&h=129" alt="greet" title="greet"></p>
<p>每当调用函数时,都会分配一个内存块并将涉及到的变量值存储到内存中。</p>
<p>打印<code>hello, deepred</code>后,调用了<code>greet2('deepred')</code>。同样,计算机再次分配了一块内存,并且该内存块位于第一个内存块上面。</p>
<p><img src="/img/remote/1460000015826393?w=571&h=352" alt="greet" title="greet"></p>
<p>调用栈的最上面表示当前运行的函数,如图所示,现在正在运行的是greet2函数,打印输出<code>how are you, deepred?</code>后,函数greet2执行完毕,栈顶的内存块被弹出。</p>
<p>现在栈顶的内存块又变回greet,这意味着我们从greet2的函数中跳出,再次返回到了greet。</p>
<p>我们在greet中调用了greet2时,greet只执行了一部分。</p>
<p>特别注意:<strong>调用另外一个函数时,当前函数暂停并且处于未完成状态,暂停函数的所有变量的值仍然在内存中</strong>。</p>
<p>执行完greet2后,我们回到了greet,并从离开的地方开始接着往下执行:首先打印<code>getting ready to say bye</code>,然后调用bye函数。</p>
<p>在栈顶添加了bye函数的内存块后,开始执行bye函数,打印<code>bye</code>,然后函数返回,内存块被弹出。</p>
<p>我们又再次回到了greet中,这次没有其他要运行的代码了,于是从greet函数中返回,内存块被弹出,调用栈最后为空。</p>
<p><strong>完整的一次调用流程</strong>:</p>
<p><img src="/img/remote/1460000015826394?w=309&h=354" alt="greet" title="greet"></p>
<h2>递归调用栈</h2>
<p>递归同样使用调用栈</p>
<p>我们来分析下阶乘<code>fact(3)</code>的调用栈</p>
<pre><code class="javascript">function fact(num) {
if (num === 1) {
return 1;
}
return num * fact(num-1);
}
fact(3);</code></pre>
<p>直接看图:<br><img src="/img/remote/1460000015826395?w=697&h=399" alt="greet" title="greet"></p>
<h2>递归注意事项</h2>
<p>递归会导致程序的性能变低</p>
<p>如果递归嵌套很深,那么调用栈会很长,这将占用大量内存,可能会导致栈溢出</p>
JS实现监控微信小程序
https://segmentfault.com/a/1190000015290257
2018-06-14T16:30:07+08:00
2018-06-14T16:30:07+08:00
深红
https://segmentfault.com/u/deepred5
5
<p><a href="https://link.segmentfault.com/?enc=lgWQT%2FTLgSF%2F%2B0T5jXTQ8Q%3D%3D.tKZlYrrzbwcITqNv63Wnu%2BRY0CtC1zexE%2BhK%2FLrt5ndOE6yKvqdYsG4%2B7DvRR376tsKC7aPtp0SlLT5OIxnc0Bib%2FJwXpJ9DCriClilgM8aSG%2BWS07xAIu1gwVtip6bOcjG47eZCFr%2BiYJSpN0eRYQ%3D%3D" rel="nofollow">博客地址</a></p>
<p><a href="https://link.segmentfault.com/?enc=CO5NrafnJG%2BqkKwbRnLQDQ%3D%3D.cUNvvgLs9tLIATFqfFRzsOLQde3V99%2FBVQM1sHgNRv3n64QXta5yV23%2FM6JgSqxgxAinS2SzjP1tIH1Pwvj5e%2BSFqvmWDzdy3fom2khiflTdBIwjiprqiRnJtNGB4xH%2BoVMonHvJCgFRzoiZoWbbMYhIGebK%2BfM%2BPwM8qsHEo8NlGACutGQZTuBUdK7aQagMl%2BjEC99U%2FgbNNowJGwdMzm8ELNdcOwCLN4spMd6EYdA%3D" rel="nofollow">《使用模块化工具打包自己开发的JS库》</a>文章中有提到,当时需要写一个SDK,监控小程序的后台接口调用和页面报错,今天就来说下实现原理吧!</p>
<h2>原理</h2>
<p>之前也做过浏览器web端的SDK数据埋点上报,其实原理大同小异:通过劫持原始方法,获取需要上报的数据,最后再执行原始方法,这样就能实现无痕埋点。</p>
<p>举个例子:我希望监控所有web页面的ajax请求,每次发送ajax,都需要在控制台打印出发送的url</p>
<p>平时我们开发,发送ajax一般用的都是封装好的库,例如jQuery,Axios等,然而这些库,底层仍然用的是浏览器原生的XMLHttpRequest对象,因此,我们只需要修改XMLHttpRequest对象即可</p>
<p><strong>注意:由于JS的灵活性,修改原生方法是一件很容易的事,然而并不鼓励这样做!</strong></p>
<pre><code class="javascript">// 把这段代码放在所有JS代码之前,我们就实现了拦截ajax的需求
window.XMLHttpRequest.prototype.open = (function(originOpen) {
return function(method, url, async) {
console.log('发送了ajax,url是: ', url);
return originOpen.apply(this, arguments);
};
})(window.XMLHttpRequest.prototype.open);
</code></pre>
<p>在这个立即执行函数中,我们把原生的<code>open</code>方法通过<code>originOpen</code>暂时存储起来,然后在外面包裹一层函数,实现了打印输出url的功能,最后通过<code>originOpen.apply</code>让原生方法运行,这样就实现了无痕拦截。</p>
<h2>监控小程序</h2>
<h3>拦截wx.request</h3>
<p>小程序的运行环境并没有<code>window</code>和<code>document</code>对象,它只暴露了一个<code>wx</code>全局对象,发送网络请求则是通过<a href="https://link.segmentfault.com/?enc=4noZfOtfrMBvWr0DOtdY5Q%3D%3D.XzFRd%2Fl0CB3shrP870cxDtOMUQtARt9IxyY%2FdfPoDq%2FIX%2FseGFSfnHA%2FulY890%2F%2BKmRkHAjSJ4Mlt%2F3GjjqG71t719YtNcN3u6x%2FEw5C%2Bsc%3D" rel="nofollow">wx.request</a>这个api,因此,这次我们需要拦截的就是<code>wx.request</code>方法</p>
<p>我们试着更改一下<code>wx.request</code></p>
<pre><code class="javascript">wx.request = function() {
console.log('66666');
}</code></pre>
<p>这时控制台会报错<code>TypeError: Cannot set property request of #<Object> which has only a getter</code></p>
<p>这是因为,<code>wx.request</code>这个属性,只有<code>get</code>方法而没有<code>set</code>方法,我们可以通过<code>Object.getOwnPropertyDescriptor</code>验证:</p>
<pre><code class="javascript">const des = Object.getOwnPropertyDescriptor(wx, 'request');
// des {
// configurable: true,
// enumerable: true,
// get: f(),
// set: undefined
// }</code></pre>
<p>我们可以换种方式修改:</p>
<pre><code class="javascript">const originRequest = wx.request;
Object.defineProperty(wx, 'request', {
configurable: true,
enumerable: true,
writable: true,
value: function() {
const config = arguments[0] || {};
const url = config.url;
console.log('发送了ajax,url是: ', url);
return originRequest.apply(this, arguments);
}
});
</code></pre>
<p>这次就实现拦截功能了!</p>
<h3>监控异常</h3>
<p>小程序的注册函数<code>App</code>有个全局的<code>onError</code>方法,我们可以在小程序的入口文件<code>app.js</code>先注册一个该方法:</p>
<pre><code class="javascript">App({
onError: function(err) {
console.log('上报错误啦!');
wx.request({
url: 'http://monitor.com/monitor/error',
data: err
})
}
})
App({
// 其他逻辑
})</code></pre>
<p>不过需要注意的是:如果后续的程序重写了onError的话,将会导致之前注册的onError失效。</p>
<p>解决方法可以是:我们监控SDK可以暴露一个接口,让接入方自己在onError中调用我们的接口。</p>
<pre><code class="javascript">App({
onError: function (err) {
monitor.notifyError(err)
}
})</code></pre>
<h3>上报数据</h3>
<p>收集好需要的数据后,当然就要上报后台。怎么上报?当然还是用的<code>wx.request</code>发送请求。</p>
<p>这里就容易出现一个<strong>死循环</strong>: 如果用之前被我们包装过的<code>wx.request</code>上报数据,那么上报数据这个ajax请求,也会被我们认为是普通的ajax请求,然后又会触发上报,这样来来回回,无穷无尽的发送上报数据。</p>
<p>解决方法有多种,比如:</p>
<p><strong>方案1</strong></p>
<p>可以在包装<code>wx.request</code>的时候,判断发送的url如果是上报接口,那么就不再上报了。</p>
<pre><code class="javascript">const originRequest = wx.request;
Object.defineProperty(wx, 'request', {
configurable: true,
enumerable: true,
writable: true,
value: function() {
const config = arguments[0] || {};
const url = config.url;
if (url.indexOf('http://monitor.com') > -1) {
// 直接发送请求,不上报
return originRequest.apply(this, arguments);
}
console.log('上报ajax数据啦!');
wx.request({
url: 'http://monitor.com/monitor/ajax',
data: config.data
})
return originRequest.apply(this, arguments);
}
});</code></pre>
<p><strong>方案2</strong></p>
<p>在包装<code>wx.request</code>之前,保留一份最原始的<code>wx.request</code>方法,所有的上报请求,就不走被包装过的方法,而走最原始的方法。</p>
<pre><code class="javascript">const myRequest = wx.request;
const wrapRequest = function () {
const originRequest = wx.request;
Object.defineProperty(wx, 'request', {
configurable: true,
enumerable: true,
writable: true,
value: function() {
const config = arguments[0] || {};
const url = config.url;
console.log('上报数据啦!');
// 使用最原始的request方法
myRequest({
url: 'http://monitor.com/monitor/ajax',
data: config.data
})
return originRequest.apply(this, arguments);
}
});
}
wrapRequest();</code></pre>
<h2>其他事项</h2>
<p>实际开发中当然还有更多的细节,比如监控项目的鉴权,SDK的代码结构,上报前的数据收集和聚合等等,本文就不详细展开了。</p>
使用模块化工具打包自己开发的JS库
https://segmentfault.com/a/1190000015221988
2018-06-08T08:53:00+08:00
2018-06-08T08:53:00+08:00
深红
https://segmentfault.com/u/deepred5
23
<p><a href="https://link.segmentfault.com/?enc=eTvLyngWjWxL%2F%2Fn2xml02w%3D%3D.akwVOdLTZoOqoSZfIwchPt0UF2wktRdrTjPDLhDSuSZXDKOn0fFL8Tz7zemXGyA5R9bO06TD2exxWjU7kDiQNqNs%2Fxov1FYshmaN3XYJlRgv3Cbz79xeYHMlM0UG15DtqQcVLuJdQcB%2F1jiYf8rUrs9RdGi61dY3UDU710MLe0DGoKiNb%2BpfodHsKlcHcvg767TUZAl9tr16JPcsFtELW%2BXFoHLohksP0GVDIDjtlHM%3D" rel="nofollow">博客地址</a></p>
<p>最近有个需求,需要为小程序写一个SDK,监控小程序的后台接口调用和页面报错(类似<a href="https://link.segmentfault.com/?enc=50OwXv9yCw%2F%2Ba5dg0n1lrg%3D%3D.Vn2OBdfTuqLsXaWh0STWyXoQpyNlUCO%2BBWEZpR0nFg%2BgcLnQwl3W60%2Fy%2FamX8le5%2Bq1rZJwRItrIA1TuaDZ1hw%3D%3D" rel="nofollow">fundebug</a>)</p>
<p>听起来高大上的SDK,其实就是一个JS文件,类似平时开发中我们引入的第三方库:</p>
<pre><code class="javascript">const moment = require('moment');
moment().format();</code></pre>
<p>小程序的模块化采用了Commonjs规范。也就是说,我需要提供一个<code>monitor.js</code>文件,并且该文件需要支持Commonjs,从而可以在小程序的入口文件<code>app.js</code>中导入:</p>
<pre><code class="javascript">// 导入sdk
const monitor = require('./lib/monitor.js');
monitor.init('API-KEY');
// 正常业务逻辑
App({
...
})</code></pre>
<p>所以问题来了,我应该怎么开发这个SDK? (<strong>注意:本文并不具体讨论怎么实现监控小程序</strong>)</p>
<p>方案有很多种:比如直接把所有的逻辑写在一个<code>monitor.js</code>文件里,然后导出</p>
<pre><code class="javascript">module.exports = {
// 各种逻辑
}</code></pre>
<p>但是考虑到代码量,为了降低耦合度,我还是倾向于把代码拆分成不同模块,最后把所有JS文件打包成一个<code>monitor.js</code>。平时有使用过Vue和React开发的同学,应该能体会到模块化开发的好处。</p>
<p><a href="https://link.segmentfault.com/?enc=EOpqPGcYjtkWKRtAsM9WxA%3D%3D.e2PCUAZCZzRDn59fZM8i2b1YwBlzmnXJPN%2BG%2F%2BQLgIkFkvNdGc5wDAkHroRVwp9B" rel="nofollow"><strong>演示代码下载</strong></a></p>
<p>如下是定义的目录结构:<br><img src="/img/remote/1460000015221991?w=310&h=191" alt="pic" title="pic"></p>
<p>src目录下存放源代码,dist目录打包最后的<code>monitor.js</code></p>
<p><code>src/main.js</code> SDK入口文件</p>
<pre><code class="javascript">import { Engine } from './module/Engine';
let monitor = null;
export default {
init: function (appid) {
if (!appid || monitor) {
return;
}
monitor = new Engine(appid);
}
}</code></pre>
<p><code>src/module/Engine.js</code></p>
<pre><code class="javascript">import { util } from '../util/';
export class Engine {
constructor(appid) {
this.id = util.generateId();
this.appid = appid;
this.init();
}
init() {
console.log('开始监听小程序啦~~~');
}
}</code></pre>
<p><code>src/util/index.js</code></p>
<pre><code class="javascript">export const util = {
generateId() {
return Math.random().toString(36).substr(2);
}
}</code></pre>
<p>所以,怎么把这堆js打包成最后的<code>monitor.js</code>文件,并且程序可以正确执行?</p>
<h2>webpack</h2>
<p>我第一个想到的就是用webpack打包,毕竟工作经常用React开发,最后打包项目用的就是它。<br><strong>基于webpack4.x版本</strong></p>
<pre><code>npm i webpack webpack-cli --save-dev</code></pre>
<p>靠着我对于webpack玄学的微薄知识,<del>含泪</del>写下了几行配置:<br><code>webpack.config.js</code></p>
<pre><code class="javascript">var path = require('path');
var webpack = require('webpack');
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
filename: 'monitor.js',
}
};</code></pre>
<p>运行<code>webpack</code>,打包倒是打包出来了,但是引入到小程序里试试</p>
<p>小程序入口文件<code>app.js</code></p>
<pre><code class="javascript">var monitor = require('./dist/monitor.js');</code></pre>
<p>控制台直接报错。。。<br><img src="/img/remote/1460000015221992?w=616&h=192" alt="" title=""><br><img src="/img/remote/1460000015221993?w=300&h=300" alt="" title=""></p>
<p>原因很简单:打包出来的<code>monitor.js</code>使用了<code>eval</code>关键字,而小程序内部并支持eval。</p>
<p>我们只需要更改webpack配置的<code>devtool</code>即可</p>
<pre><code class="javascript">var path = require('path');
var webpack = require('webpack');
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
filename: 'monitor.js',
},
devtool: 'source-map'
};</code></pre>
<p><code>source-map</code>模式就不会使用<code>eval</code>关键字来方便debug,它会多生成一个<code>monitor.js.map</code>文件来方便debug</p>
<p>再次<code>webpack</code>打包,然后倒入小程序,问题又来了:</p>
<pre><code class="javascript">var monitor = require('./dist/monitor.js');
console.log(monitor); // {}</code></pre>
<p>打印出来的是一个空对象!</p>
<p><code>src/main.js</code></p>
<pre><code class="javascript">import { Engine } from './module/Engine';
let monitor = null;
export default {
init: function (appid) {
if (!appid || monitor) {
return;
}
monitor = new Engine(appid);
}
}</code></pre>
<p><code>monitor.js</code>并没有导出一个含有init方法的对象!</p>
<p>我们期望的是<code>monitor.js</code>符合commonjs规范,但是我们在配置中并没有指出,所以webpack打包出来的文件,什么也没导出。</p>
<p>我们平时开发中,打包时也不需要导出一个变量,只要打包的文件能在浏览器上立即执行即可。你随便翻一个Vue或React的项目,看看入口文件是咋写的?<br><code>main.js</code></p>
<pre><code class="javascript">import Vue from 'vue'
import App from './App'
new Vue({
el: '#app',
components: { App },
template: '<App/>'
})</code></pre>
<pre><code class="javascript">import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';
ReactDOM.render(
<App />,
document.getElementById('root')
);</code></pre>
<p>是不是都类似这样的套路,最后只是立即执行一个方法而已,并没有导出一个变量。</p>
<h2>libraryTarget</h2>
<p>libraryTarget就是问题的关键,通过设置该属性,我们可以让webpack知道使用何种规范导出一个变量</p>
<pre><code class="javascript">var path = require('path');
var webpack = require('webpack');
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
filename: 'monitor.js',
libraryTarget: 'commonjs2'
},
devtool: 'source-map'
};</code></pre>
<p><code>commonjs2</code>就是我们希望的commonjs规范</p>
<p>重新打包,这次就正确了</p>
<pre><code class="javascript">var monitor = require('./dist/monitor.js');
console.log(monitor);</code></pre>
<p><img src="/img/remote/1460000015221994?w=631&h=89" alt="" title=""></p>
<p>我们导出的对象挂载到了<code>default</code>属性上,因为我们当初导出时:</p>
<pre><code class="javascript">export default {
init: function (appid) {
if (!appid || monitor) {
return;
}
monitor = new Engine(appid);
}
}</code></pre>
<p>现在,我们可以愉快的导入SDK</p>
<pre><code class="javascript">var monitor = require('./dist/monitor.js').default;
monitor.init('45454');</code></pre>
<p><img src="/img/remote/1460000015221995?w=518&h=74" alt="" title=""></p>
<p>你可能注意到,我打包时并没有使用babel,因为小程序是支持es6语法的,所以开发该sdk时无需再转一遍,如果你开发的类库需要兼容浏览器,则可以加一个babel-loader</p>
<pre><code class="javascript">module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
}
]
}</code></pre>
<p>注意点:</p>
<ol>
<li>平时开发调试sdk时可以直接<code>webpack -w</code>
</li>
<li>最后打包时,使用<code>webpack -p</code>进行压缩</li>
</ol>
<p>完整的<code>webpack.config.js</code></p>
<pre><code class="javascript">var path = require('path');
var webpack = require('webpack');
module.exports = {
mode: 'development', // production
entry: './src/main.js',
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
filename: 'monitor.js',
libraryTarget: 'commonjs2'
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
}
]
},
devtool: 'source-map' // 小程序不支持eval-source-map
};</code></pre>
<p>其实,使用webpack打包纯JS类库是很简单的,比我们平时开发一个应用,配置少了很多,毕竟不需要打包css,html,图片,字体这些静态资源,也不用按需加载。</p>
<h2>rollup</h2>
<p>文章写到这里本来可以结束了,但是在前期调研如何打包模块的时候,我特意看了下Vue和React是怎么打包代码的,结果发现,这俩都没使用webpack,而是使用了rollup。</p>
<blockquote>Rollup 是一个 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码,例如 library 或应用程序。</blockquote>
<p><a href="https://link.segmentfault.com/?enc=24ysA0s8Y0pWzPo9I0SSqg%3D%3D.bEsey9lWw6D1TKoyKey%2F3FnEBstlmspLM8FWuXbH%2FYM%3D" rel="nofollow">Rollup官网</a>的这段介绍,正说明了rollup就是用来打包library的。</p>
<p>如果你有兴趣,可以看一下webpack打包后的<code>monitor.js</code>,绝对会吐槽,这一坨代码是啥东西?</p>
<pre><code class="javascript">module.exports =
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
// 以下省略1万行代码</code></pre>
<p>webpack自己实现了一套<code>__webpack_exports__</code> <code>__webpack_require__</code> <code>module</code>机制</p>
<pre><code class="javascript">/***/ "./src/util/index.js":
/*!***************************!*\
!*** ./src/util/index.js ***!
\***************************/
/*! exports provided: util */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "util", function() { return util; });
const util = {
generateId() {
return Math.random().toString(36).substr(2);
}
}
/***/ })</code></pre>
<p>它把每个js文件包裹在一个函数里,实现模块间的引用和导出。</p>
<p>如果使用rollup打包,你就会惊讶的发现,打包后的代码可读性简直和webpack不是一个级别!</p>
<pre><code>npm install --global rollup</code></pre>
<p>新建一个<code>rollup.config.js</code></p>
<pre><code class="javascript">export default {
input: './src/main.js',
output: {
file: './dist/monitor.js',
format: 'cjs'
}
};</code></pre>
<p><code>format: cjs</code>指定打包后的文件符合commonjs规范</p>
<p>运行<code>rollup -c</code></p>
<p>这时会报错,说<code>[!] Error: Could not resolve '../util' from src\module\Engine.js</code></p>
<p>这是因为,rollup识别<code>../util/</code>时,并不会自动去查找util目录下的<code>index.js</code>文件(webpack默认会去查找),所以我们需要改成<code>../util/index</code></p>
<p>打包后的文件:</p>
<pre><code class="javascript">'use strict';
const util = {
generateId() {
return Math.random().toString(36).substr(2);
}
};
class Engine {
constructor(appid) {
this.id = util.generateId();
this.appid = appid;
this.init();
}
init() {
console.log('开始监听小程序啦~~~');
}
}
let monitor = null;
var main = {
init: function (appid) {
if (!appid || monitor) {
return;
}
monitor = new Engine(appid);
}
}
module.exports = main;</code></pre>
<p>是不是超简洁!</p>
<p>而且导入的时候,无需再写个default属性<br>webpack打包</p>
<pre><code class="javascript">var monitor = require('./dist/monitor.js').default;
monitor.init('45454');</code></pre>
<p>rollup打包</p>
<pre><code class="javascript">var monitor = require('./dist/monitor.js');
monitor.init('45454');</code></pre>
<p>同样,平时开发时我们可以直接<code>rollup -c -w</code>,最后打包时,也要进行压缩</p>
<pre><code>npm i rollup-plugin-uglify -D</code></pre>
<pre><code class="javascript">import { uglify } from 'rollup-plugin-uglify';
export default {
input: './src/main.js',
output: {
file: './dist/monitor.js',
format: 'cjs'
},
plugins: [
uglify()
]
};</code></pre>
<p>然而这样运行会报错,因为uglify插件只支持es5的压缩,而我这次开发的sdk并不需要转成es5,所以换一个插件</p>
<pre><code>npm i rollup-plugin-terser -D</code></pre>
<pre><code class="javascript">import { terser } from 'rollup-plugin-terser';
export default {
input: './src/main.js',
output: {
file: './dist/monitor.js',
format: 'cjs'
},
plugins: [
terser()
]
};
</code></pre>
<p>当然,你也可以使用babel转码</p>
<pre><code>npm i rollup-plugin-terser babel-core babel-preset-latest babel-plugin-external-helpers -D</code></pre>
<p><code>.babelrc</code></p>
<pre><code class="javascript">{
"presets": [
["latest", {
"es2015": {
"modules": false
}
}]
],
"plugins": ["external-helpers"]
}</code></pre>
<p><code>rollup.config.js</code></p>
<pre><code class="javascript">import { terser } from 'rollup-plugin-terser';
import babel from 'rollup-plugin-babel';
export default {
input: './src/main.js',
output: {
file: './dist/monitor.js',
format: 'cjs'
},
plugins: [
babel({
exclude: 'node_modules/**'
}),
terser()
]
};</code></pre>
<h2>UMD</h2>
<p>我们刚刚打包的SDK,并没有用到特定环境的API,也就是说,这段代码,其实完全可以运行在node端和浏览器端。</p>
<p>如果我们希望打包的代码可以兼容各个平台,就需要符合UMD规范(兼容AMD,CMD, Commonjs, iife)</p>
<pre><code class="javascript">import { terser } from 'rollup-plugin-terser';
import babel from 'rollup-plugin-babel';
export default {
input: './src/main.js',
output: {
file: './dist/monitor.js',
format: 'umd',
name: 'monitor'
},
plugins: [
babel({
exclude: 'node_modules/**'
}),
terser()
]
};</code></pre>
<p>通过设置<code>format</code>和<code>name</code>,这样我们打包出来的<code>monitor.js</code>就可以兼容各种运行环境了</p>
<p>在node端</p>
<pre><code class="javascript">var monitor = require('monitor.js');
monitor.init('6666');</code></pre>
<p>在浏览器端</p>
<pre><code class="javascript"><script src="./monitor.js"></srcipt>
<script>
monitor.init('6666');
</srcipt></code></pre>
<p>原理其实也很简单,你可以看下打包后的源码,或者看我之前写过的一篇<a href="https://link.segmentfault.com/?enc=9OtOYZwB1XaQca7HpIRA5Q%3D%3D.8klZMyqCvZVI5r58tiSCMonneeO3SAz4M7Xv9X3Xn2NMHTW8hjuJqhYlaEA4diO16xuOKbPVYmV7UrWTSJigC2xs%2BpsEfWhM%2BgaMMt5fajHAreZmnxoRHSptBNZicZ4a%2B%2BBeHqSpTZJvKFRsan96o6BcIhCqvJiB0YKHTYl9%2BckZn%2BNveo4MUmYGRVK%2FNRNFxqsNsa27vaTHRaHzAjQRYw%3D%3D" rel="nofollow">文章</a></p>
<h2>总结</h2>
<p>rollup通常适用于打包JS类库,通过rollup打包后的代码,体积较小,而且没有冗余的代码。rollup默认只支持ES6的模块化,如果需要支持Commonjs,还需下载相应的插件<a href="https://link.segmentfault.com/?enc=T0anQ0dau2F0yTwaSyo4QA%3D%3D.uz0qRtFr490BMm5UDfo6I8sIZbqqdc88YYO%2FeO3JfxaaUZQ0wsxK%2FhL5qmhGYPPcqKwnmWeHjUAHRgqFP2Pz8w%3D%3D" rel="nofollow">rollup-plugin-commonjs</a></p>
<p>webpack通常适用于打包一个应用,如果你需要代码拆分(Code Splitting)或者你有很多静态资源需要处理,那么可以考虑使用webpack</p>
前端开发碎碎念
https://segmentfault.com/a/1190000014359088
2018-04-13T10:42:36+08:00
2018-04-13T10:42:36+08:00
深红
https://segmentfault.com/u/deepred5
6
<p>工作也有一段时间了,平时忙于业务代码的编写中,发现身边的一些人以及自己,对一些基本概念理解有所偏差,可能闹出笑话,会问出下面这些常识性错误的奇怪问题:</p>
<ol>
<li>
<a href="https://link.segmentfault.com/?enc=066ClHRk4afiTfYpz0UQcw%3D%3D.MAli%2BUv1lsUmCBp7gcKPYVTZXm4DRRluqqZDi%2FJ2sOLCjhBi7fXv0HnVKaYN7M7D" rel="nofollow">vuejs怎么在服务器部署?</a>(我提交到服务器之后执行了 npm run dev之后关闭了是可以打开网页的,但是关闭了ssh之后,服务马上就不能用了,请问正确的部署方式是怎么样的?)</li>
<li><a href="https://link.segmentfault.com/?enc=aJ2GCByT6nxN3OPz%2B7BU2g%3D%3D.x9yxmC8%2FNaN%2FaFTxiDKuGaNpCCoGiV6Z1y5N4HHN3zJCi0IvqW0kKA5Pv3yIPOx9" rel="nofollow">vue开发的项目,前端写的.vue文件中的生命周期方法,线上还存在吗?</a></li>
</ol>
<p>2333,怎么都是关于Vue的问题。。。我真没黑Vue开发者,不过也可以看出,Vue的小白受众的确比较多。</p>
<p><a href="https://link.segmentfault.com/?enc=P1Yn1ykXGNVPYpVB9Gkp2w%3D%3D.dI2%2FUOlstbbHHZks2tJdrsVycsORbr2P8gP9GfBaD6NiLShpm4XWDwGx2ulcYkGcdIZTB8DaPDL92yXIwXQEx53xuWAIejAs6JkCNkIRV1EnHTWh%2FyGMhMq76dchGIE7" rel="nofollow">博客原文</a></p>
<h2>webpack和webpack-dev-server</h2>
<p>现在基于Vue,React的SPA单页应用开发,都倾向于采用webpack的模块化构建方案。可能大多数人,开发一个项目,会使用脚手架工具(vue-cli, create-react-app)<br>我们本地开发时,运行命令行<code>npm run dev</code>,然后就开始编写业务逻辑了,对于其中发生了什么,大多数人可能不太关心。其实运行了该命令,就是运行了<code>webpack-dev-server</code> </p>
<p>webpack-dev-server是webpack官方提供的一个小型Express服务器,正是因为webpack-dev-server自己开启了一个服务器,我们才能够前后端分离开发(我们不需要关心后端的代码)。前端启动的这个服务器,是用来构建和渲染页面,并提供了自动刷新和热替换功能。</p>
<p>简单来说:<br>webpack只是构建(<code>npm run build</code>)<br>webpack-dev-server除了构建,还提供web服务(<code>npm run dev</code>)</p>
<h2>路由</h2>
<p>什么是路由?<br>简单来说:<code>/about/deepred</code> <code>/home/</code> 这些就是路由</p>
<p>在web开发中,路由分为前端路由和后台路由<br>其实在单页应用还没有流行前,路由基本指的是后台路由。如果你熟悉传统的后台web开发,可能对下面的代码很熟悉:</p>
<pre><code class="javascript">app.get('/about', function (req, res) {
res.render('about', { title: 'Hey', message: 'Hello there!'});
});
app.get('/', function (req, res) {
res.render('index');
});</code></pre>
<p>传统的web网站,所有的路由都是由后台定义的。当我们想访问一个页面<code>http://anata.me/about/</code>,首先向后台发送一个请求,后台根据定义好的路由,决定渲染哪个页面。</p>
<p>然而单页应用的出现,改变了这个模式。如果你是前端开发,应该对这段代码更加熟悉:</p>
<pre><code class="javascript">routes: [
{
path: '/user/:userId',
name: 'user',
component: User
},
{
path: '/about/',
name: 'about',
component: About
}
]</code></pre>
<p>前端路由是将页面的渲染权交给了js控制,不通过请求服务器来判断渲染页面。前端一般利用histroy和hash来控制,达到不刷新页面可以使显示内容发生变化,这样速度更快,用户体验更好。前端路由解放了服务端,专心提供接口数据服务。</p>
<h2>打包部署</h2>
<p>脚手架生成的项目,一般运行<code>npm run build</code>之后,会在项目根目录生成一个dist目录,这就是我们打包好后的静态资源文件。<br><strong>注意的是</strong>:</p>
<ol>
<li>我们线上运行的单页应用,就是打包好后的dist文件,并不是src目录下的源文件</li>
<li>线上部署更不是运行<code>npm run dev</code>启动项目。<code>npm run dev</code>启动的服务器只是为了开发而使用的,真正线上的服务器,是由后台提供的(比如PHP,Java, python, Node...)</li>
</ol>
<p>部署的方式有很多,比如可以把dist文件和后台代码放在一起,后台把dist文件当做静态资源读取即可。不过因为采用了前端路由的方案,后台还需要配置一下,以Express举例:</p>
<pre><code class="javascript">// 访问静态资源文件 这里是访问所有dist目录下的静态资源文件
app.use(express.static(path.resolve(__dirname, '../dist')))
// 因为是单页应用 所有请求都走/dist/index.html
// 这一句要放在所有其他路由的后面
app.get('*', function(req, res) {
const html = fs.readFileSync(path.resolve(__dirname, '../dist/index.html'), 'utf-8')
res.send(html)
})</code></pre>
<p>也可以把dist静态文件和后台代码分开,通过Nginx部署</p>
<pre><code>server {
listen 80;
server_name 127.0.0.1;
location / {
root /data/deered/dist; #前端打包后的dist文件位置
try_files $uri $uri/ /index.html; #防止页面刷新404
index index.html;
}
}</code></pre>
<h2>跨域</h2>
<p>因为webpack-dev-server启动了一个服务器,所以在开发时,前端去请求真正的后台接口,是存在跨域问题的。webpack提供了跨域的解决方案,原理就是让服务器反向代理请求真正的接口</p>
<p>vue-cli配置跨域</p>
<pre><code class="javascript">proxyTable: {
'/api': {
target: 'http://localhost:8089/api/',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
}
},</code></pre>
<p>前端请求<code>/api/xxxx</code>时,webpack-dev-server启动的服务器会帮我们请求<code>http://localhost:8089/api/xxxx</code>,同时返回数据。</p>
<p>有些人就会有疑惑,那打包后的文件,是不是也能跨域。前面我们说了,线上部署就不是运行<code>npm run dev</code>,所以,前端是不是跨域要看你怎么部署了。</p>
<p>如果你把打包后的dist文件和后端代码放在一起,那么根本就不存在跨域问题!<br>如果前端静态文件和后端不在一起,那么可以用Nginx做转发</p>
<pre><code>server {
listen 80;
server_name 127.0.0.1;
location / {
root /data/deered/dist; #前端打包后的dist文件位置
index index.html;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://127.0.0.1:8089 #后台地址
}
}</code></pre>
<h2>Vue和React</h2>
<p>Vue的指令和模板语言让开发者可以很简洁的完成一个复杂的功能,而React的JSX语法,则让开发者拥有更多的自主权。<br>从Vue转向React的开发者,一开始可能会非常不适应,毕竟<code>v-for</code> <code>v-if</code> <code>v-model</code>这些最基本的功能,React竟然全都要我们自己去实现。</p>
<p>我们其实可以从本质上来看:</p>
<p>一个Vue的组件:</p>
<pre><code class="javascript"><template>
<div class="hello" @click="say">
<h1>{{ msg }}</h1>
<h2 v-if="show">show me</h2>
</div>
</template>
<script>
export default {
name: 'Hello World',
data () {
return {
msg: 'Welcome to Your Vue.js App',
show: false
}
}
methods: {
say() {
console.log('hi')
}
}
}
</script>
<style scoped>
h1, h2 {
font-weight: normal;
}
</style>
</code></pre>
<p>一个React的组件</p>
<pre><code class="javascript">const styles = {
fontWeight: 'normal'
}
export default class Hello extends Component {
constructor(props) {
super(props);
this.state = {
msg: 'Welcome to Your React App',
show: false
};
}
say() {
console.log('hi')
}
render() {
return (
<div class="hello" onClick={this.say}>
<h1 className={styles}>{this.state.msg}</h1>
{this.state.show ? <h2>show me</h2> : null}
</div>
)
}
}</code></pre>
<p>从两个组件对比就能看见:<br>React组件完全就是一个Class类,你一直在写各种类方法,甚至你的css也是个对象,所以React要求开发者有较好的ES6基础,因为你无时无刻不在写JS</p>
<p>而Vue就不一样了,Vue组件其实就是个普通的对象,你只是在修改这个对象的属性:<code>name</code> <code>data</code> <code>methods</code> <code>components</code>,说的通俗点,你根本就是在配置对象,例如:你配置了这个对象的<code>components</code>属性,于是就可以在模板中使用自定义组件</p>
<p>因此,React本质上是不可能给你提供类似<code>v-for</code>的API,因为JS已经有了for循环,数组也有map方法,你写React就是在写JS,为啥还需要额外的遍历方法呢?而Vue就不同了,它提供的指令,其实就是在内部帮你写JS,所以从React转向Vue的开发者,一开始会觉得,Vue的代码更简洁了。不过,这是靠牺牲自由度换来的,毕竟在React里,怎么实现遍历,完全由你自己决定</p>
JavaScript的面向对象
https://segmentfault.com/a/1190000014001219
2018-03-26T23:10:19+08:00
2018-03-26T23:10:19+08:00
深红
https://segmentfault.com/u/deepred5
2
<p>JavaScript的面向对象与其他语言的面向对象,其实有很大的区别。</p>
<p>JavaScript是基于原型的面向对象系统,而传统语言(比如java)的面向对象都是基于类的。</p>
<h2>构造函数</h2>
<pre><code class="javascript">function Person(name, age, job) {
this.age = age;
this.name = name;
this.job = job;
}
Person.prototype.sayName = function() {
console.log(this.name);
}</code></pre>
<pre><code class="javascript">var cody = new Person('cody', '24', 'frontend');
cody.name;
cody.sayName();</code></pre>
<p>在这个常见的构造函数写法中,我们需要知道以下几点:</p>
<p>1.<code>Person</code>这个函数被定义后,会自带一个<code>prototype</code>属性,这个属性指向一个对象,既<strong>原型对象</strong>,这个对象同时有一个<code>constructor</code>属性,指向<code>Person</code></p>
<pre><code class="javascript">Person.prototype = {
constructor: Person
}</code></pre>
<p>注意的是,所有函数都有<code>prototype</code>属性,并不是只有构造函数才有。</p>
<p>2.<code>cody</code>这个通过构造函数生成的对象,有一个<code>__proto__</code>属性,它指向构造函数<code>Person</code>的原型对象,既<code>Person.prototype</code><br>访问<code>cody.name</code>时,在它自身寻找该属性,找到了,于是就返回该值。<br>访问<code>cody.sayName</code>时,也是先在它自身寻找该属性,但是无法找到,于是开始通过<strong>原型链</strong>向上寻找: 寻找<code>cody.__proto__</code>,也就是<code>Person.prototype</code>原型对象,发现了该属性,于是返回这个方法。<br>访问<code>cody.toString</code>时,在<code>Person.prototype</code>原型对象上也找不到,这时,继续向上寻找,既寻找<code>Person.prototype.__proto__</code>也就是<code>Object.prototype</code>,在它上面,可以找到<code>toString</code></p>
<h2>继承</h2>
<pre><code class="javascript">function Animal(name) {
this.name = name;
}
Animal.prototype.sayName = function() {
console.log(this.name);
}
function Duck(name, color) {
Animal.call(this, name);
this.color = color;
}
Duck.prototype = new Animal();
// 也可以优化成这样,减少一次父级构造函数调用
// Duck.prototype = Object.create(Animal.prototype);
Duck.prototype.constructor = Duck;
Duck.prototype.sayColor = function() {
console.log(this.color);
}</code></pre>
<pre><code class="javascript">var duck = new Duck('duck', 'yellow');
duck.sayColor();
duck.sayName();</code></pre>
<p>前面我们说过,JS是基于原型的面向对象。所以,继承并不一定需要使用构造函数,我们可以基于一个已经存在的对象,对这个对象进行继承。</p>
<pre><code class="javascript">var obj = {
name: 'cody',
say: function() {
console.log(this.name)
}
};
var sub = Object.create(obj);
sub.name = 'deepred';
sub.say(); // deepred</code></pre>
<p><code>Object.create</code>的原理是:</p>
<pre><code class="javascript">Object.create = Object.create || function(obj) {
var F = function() {};
F.prototype = obj;
return new F();
}</code></pre>
<h2>ES6的class语法</h2>
<p>ES6引入了class语法,让JS看起来更像是面向对象的语言,但这仅仅是语法糖而已,背后仍然是基于原型的继承方式。</p>
<pre><code class="javascript">class Animal {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name)
}
}
class Duck extends Animal {
constructor(name, color) {
super(name);
this.color = color;
}
sayColor() {
console.log(this.color);
}
sayName() {
// 调用父级同名方法
super.sayName();
console.log('duck sayname')
}
}</code></pre>
<pre><code class="javascript">var duck = new Duck('duck', 'red');
duck.sayColor();
duck.sayName();</code></pre>
RxJS基础教程
https://segmentfault.com/a/1190000013829356
2018-03-19T14:25:51+08:00
2018-03-19T14:25:51+08:00
深红
https://segmentfault.com/u/deepred5
13
<p>RxJS是一个基于可观测数据流在异步编程应用中的库。</p>
<blockquote>ReactiveX is a combination of the best ideas from<br>the <strong>Observer pattern</strong>, the <strong>Iterator pattern</strong>, and <strong>functional programming</strong>
</blockquote>
<p>正如官网所说,RxJS是基于观察者模式,迭代器模式和函数式编程。因此,首先要对这几个模式有所理解</p>
<h2>观察者模式</h2>
<pre><code class="javascript">window.addEventListener('click', function(){
console.log('click!');
})</code></pre>
<p>JS的事件监听就是天生的观察者模式。给window的click事件(被观察者)绑定了一个listener(观察者),当事件发生,回调函数就会被触发</p>
<p><!-- more --></p>
<h2>迭代器模式</h2>
<p>迭代器模式,提供一种方法顺序访问一个聚合对象中的各种元素,而又不暴露该对象的内部表示。</p>
<p>ES6里的Iterator即可实现:</p>
<pre><code class="javascript">let arr = ['a', 'b', 'c'];
let iter = arr[Symbol.iterator]();
iter.next() // { value: 'a', done: false }
iter.next() // { value: 'b', done: false }
iter.next() // { value: 'c', done: false }
iter.next() // { value: undefined, done: true }</code></pre>
<p>反复调用迭代对象的<code>next</code>方法,即可顺序访问</p>
<h2>函数式编程</h2>
<p>提到函数式编程,就要提到声明式编程和命令式编程<br>函数式编程是声明式编程的体现</p>
<p>问题:将数组<code>[1, 2, 3]</code>的每个元素乘以2,然后计算总和。</p>
<p>命令式编程</p>
<pre><code class="javascript">const arr = [1, 2, 3];
let total = 0;
for(let i = 0; i < arr.length; i++) {
total += arr[i] * 2;
}</code></pre>
<p>声明式编程</p>
<pre><code class="javascript">const arr = [1, 2, 3];
let total = arr.map(x => x * 2).reduce((total, value) => total + value)</code></pre>
<p>声明式的特点是专注于描述结果本身,不关注到底怎么到达结果。而命令式就是真正实现结果的步骤</p>
<p>声明式编程把原始数据经过一系列转换(map, reduce),最后得到想要的数据</p>
<p>现在前端流行的MVC框架(Vue,React,Angular),也都是提倡:编写UI结构时使用声明式编程,在编写业务逻辑时使用命令式编程</p>
<h2>RxJS</h2>
<p>RxJS里有两个重要的概念需要我们理解:<br><code>Observable</code> (可观察对象)<br><code>Observer</code> (观察者)</p>
<pre><code class="javascript">var btn = document.getElementById('btn');
var handler = function() {
console.log('click');
}
btn.addEventListener('click', handler)</code></pre>
<p>上面这个例子里:<br><code>btn</code>这个DOM元素的<code>click</code>事件就是一个Observable<br><code>handler</code>这个函数就是一个Observer,当btn的click事件被触发,就会调用该函数</p>
<p>改用RxJS编写;</p>
<pre><code class="javascript">Rx.Observable.fromEvent(btn, 'click')
.subscribe(() => console.log('click'));</code></pre>
<p><code>fromEvent</code>把一个event转成了一个<code>Observable</code>,然后它就可以被订阅<code>subscribe</code>了</p>
<h2>流stream</h2>
<p><strong>Observable</strong>其实就是数据流<strong>stream</strong><br><strong>流</strong>是在时间流逝的过程中产生的一系列事件。它具有时间与事件响应的概念。</p>
<p>我们可以把一切输入都当做数据流来处理,比如说:</p>
<ul>
<li>用户操作</li>
<li>网络响应</li>
<li>定时器</li>
<li>Worker</li>
</ul>
<h3>产生新流</h3>
<p>当产生了一个流后,我们可以通过操作符(Operator)对这个流进行一系列加工操作,然后产生一个新的流</p>
<pre><code class="javascript">Rx.Observable.fromEvent(window, 'click')
.map(e => 1)
.scan((total, now) => total + now)
.subscribe(value => {
console.log(value)
})</code></pre>
<p><code>map</code>把流转换成了一个每次产生1的新流,然后<code>scan</code>类似<code>reduce</code>,也会产生一个新流,最后这个流被订阅。最终实现了:每次点击累加1的效果</p>
<p>可以用一个效果图来表示该过程:<br><img src="/img/remote/1460000013829361?w=644&h=538" alt="gif" title="gif"></p>
<h3>合并流</h3>
<p>也可以对若干个数据流进行组合:</p>
<p>例子:我们要实现下面这个效果:</p>
<p><img src="/img/remote/1460000013829362?w=146&h=63" alt="gif" title="gif"></p>
<pre><code class="javascript">Rx.Observable.fromEvent(document.querySelector('input[name=plus]'), 'click')
.mapTo(1)
.merge(
Rx.Observable.fromEvent(document.querySelector('input[name=minus]'), 'click')
.mapTo(-1)
)
.scan((total, now) => total + now)
.subscribe(value => {
document.querySelector('#counter').innerText = value;
})</code></pre>
<p><code>merge</code>可以把两个数据流整个在一起,效果可以参考如下:</p>
<p><img src="/img/remote/1460000013829363?w=849&h=394" alt="gif" title="gif"></p>
<p>刚才那个例子的数据流如下:</p>
<p><img src="/img/remote/1460000013829364?w=367&h=219" alt="gif" title="gif"></p>
<p>以RxJS的写法,就是把按下加1当成一个数据流,把按下减1当成一个数据流,再通过merge把两个数据流合并,最后通过<code>scan</code>操作符,把新流上的数据累加,这就是我们想要的计数器效果</p>
<h3>扁平化流</h3>
<p>有时候,我们的Observable送出的是一个新的Observable:</p>
<pre><code class="javascript">var click = Rx.Observable.fromEvent(document.body, 'click');
var source = click.map(e => Rx.Observable.of(1, 2, 3));
source.subscribe(value => {
console.log(value)
});</code></pre>
<p>这里,console打印出来的是对象,而不是我们想要的1,2,3,这是因为<code>map</code>返回的<code>Rx.Observable.of(1, 2, 3)</code>本身也是个Observable</p>
<p>用图表示如下:</p>
<pre><code class="javascript">click : ------c------------c--------
map(e => Rx.Observable.of(1,2,3))
source : ------o------------o--------
\ \
(123)| (123)|</code></pre>
<p>因此,我们订阅到的value值就是一个Observable对象,而不是普通数据1,2,3</p>
<p>我想要的其实不是Observable本身,而是属于这个Observable里面的那些东西,现在这个情形就是Observable里面又有Observable,有两层,可是我想要让它变成一层就好,该怎么办呢?</p>
<p>这就需要把Observable扁平化</p>
<pre><code class="javascript">const arr = [1, [2, 3], 4];
// 扁平化后:
const flatArr = [1, 2, 3, 4];
</code></pre>
<p><code>concatAll</code>这个操作符就可以把Observable扁平化</p>
<pre><code class="javascript">var click = Rx.Observable.fromEvent(document.body, 'click');
var source = click.map(e => Rx.Observable.of(1, 2, 3));
var example = source.concatAll();
example.subscribe(value => {
console.log(value)
})</code></pre>
<pre><code class="javascript">click : ------c------------c--------
map(e => Rx.Observable.of(1,2,3))
source : ------o------------o--------
\ \
(123)| (123)|
concatAll()
example: ------(123)--------(123)------------</code></pre>
<p><code>flatMap</code>操作符也可以实现同样的作用,就是写法有些不同:</p>
<pre><code class="javascript">var click = Rx.Observable.fromEvent(document.body, 'click');
var source = click.flatMap(e => Rx.Observable.of(1, 2, 3));
source.subscribe(value => {
console.log(value)
})</code></pre>
<pre><code class="javascript">click : ------c------------c--------
flatMap(e => Rx.Observable.of(1,2,3))
source: ------(123)--------(123)------------</code></pre>
<h3>简单拖拽实例</h3>
<p>学完前面几个操作符,我们就可以写一个简单的实例了</p>
<p>拖拽的原理是:</p>
<ul>
<li>监听拖拽元素的mousedown</li>
<li>监听body的mousemove</li>
<li>监听body的mouseup</li>
</ul>
<pre><code class="html"><style type="text/css">
html, body {
height: 100%;
background-color: tomato;
position: relative;
}
#drag {
position: absolute;
display: inline-block;
width: 100px;
height: 100px;
background-color: #fff;
cursor: all-scroll;
}
</style>
<div id="drag"></div></code></pre>
<pre><code class="javascript">const mouseDown = Rx.Observable.fromEvent(dragDOM, 'mousedown');
const mouseUp = Rx.Observable.fromEvent(body, 'mouseup');
const mouseMove = Rx.Observable.fromEvent(body, 'mousemove');</code></pre>
<p>首先给出3个Observable,分别代表3种事件,我们希望mousedown的时候监听mousemove,然后mouseup时停止监听,于是RxJS可以这么写:</p>
<pre><code class="javascript">const source = mouseDown
.map(event => mouseMove.takeUntil(mouseUp))</code></pre>
<p><code>takeUntil</code>操作符可以在某个条件符合时,发送<code>complete</code>事件</p>
<pre><code>source: -------e--------------e-----
\ \
--m-m-m-m| -m--m-m--m-m|</code></pre>
<p>从图上可以看出,我们还需要把source扁平化,才能获取所需数据。</p>
<p>完整代码:</p>
<pre><code class="javascript">const dragDOM = document.getElementById('drag');
const body = document.body;
const mouseDown = Rx.Observable.fromEvent(dragDOM, 'mousedown');
const mouseUp = Rx.Observable.fromEvent(body, 'mouseup');
const mouseMove = Rx.Observable.fromEvent(body, 'mousemove');
mouseDown
.flatMap(event => mouseMove.takeUntil(mouseUp))
.map(event => ({ x: event.clientX, y: event.clientY }))
.subscribe(pos => {
dragDOM.style.left = pos.x + 'px';
dragDOM.style.top = pos.y + 'px';
})</code></pre>
<h2>Observable Observer</h2>
<p>前面的例子,我们都在讨论<code>fromEvent</code>转换的Observable,其实还有很多种方法产生一个<code>Observable</code>,其中<code>create</code>也是一种常见的方法,可以用来创建自定义的Observable</p>
<pre><code class="javascript">var observable = Rx.Observable.create(function (observer) {
observer.next(1);
observer.next(2);
observer.next(3);
setTimeout(() => {
observer.next(4);
observer.complete();
}, 1000);
});
console.log('just before subscribe');
observable.subscribe({
next: x => console.log('got value ' + x),
error: err => console.error('something wrong occurred: ' + err),
complete: () => console.log('done'),
});
console.log('just after subscribe');</code></pre>
<p>控制台执行的结果:</p>
<pre><code>just before subscribe
got value 1
got value 2
got value 3
just after subscribe
got value 4
done</code></pre>
<p>Observable 执行可以传递三种类型的值:</p>
<p>"Next" 通知: 发送一个值,比如数字、字符串、对象,等等。<br>"Error" 通知: 发送一个 JavaScript 错误 或 异常。<br>"Complete" 通知: 不再发送任何值。<br>"Next" 通知是最重要,也是最常见的类型:它们表示传递给观察者的实际数据。"Error" 和 "Complete" 通知可能只会在 Observable 执行期间发生一次,并且只会执行其中的一个。</p>
<pre><code class="javascript">var observable = Rx.Observable.create(function subscribe(observer) {
try {
observer.next(1);
observer.next(2);
observer.next(3);
observer.complete();
} catch (err) {
observer.error(err); // 如果捕获到异常会发送一个错误
}
});</code></pre>
<p><strong>Observer</strong>观察者只是一组回调函数的集合,每个回调函数对应一种 Observable 发送的通知类型:next、error 和 complete 。</p>
<pre><code class="javascript">var observer = {
next: x => console.log('Observer got a next value: ' + x),
error: err => console.error('Observer got an error: ' + err),
complete: () => console.log('Observer got a complete notification'),
};</code></pre>
<p>Observer和Observable是通过subscribe方法建立联系的</p>
<pre><code class="javascript">observable.subscribe(observer);</code></pre>
<h2>unsubscribe</h2>
<p>observer订阅了Observable之后,还可以取消订阅</p>
<pre><code class="javascript">var observable = Rx.Observable.from([10, 20, 30]);
var subscription = observable.subscribe(x => console.log(x));
// 稍后:
subscription.unsubscribe();</code></pre>
<p><strong>unsubscribe</strong>陷阱:</p>
<pre><code class="javascript">let stream$ = new Rx.Observable.create((observer) => {
let i = 0;
let id = setInterval(() => {
console.log('setInterval');
observer.next(i++);
},1000)
})
let subscription = stream$.subscribe((value) => {
console.log('Value', value)
});
setTimeout(() => {
subscription.unsubscribe();
}, 3000)</code></pre>
<p>3秒后虽然取消了订阅,但是开启的setInterval定时器并不会自动清理,我们需要自己返回一个清理函数</p>
<pre><code class="javascript">let stream$ = new Rx.Observable.create((observer) => {
let i = 0;
let id = setInterval(() => {
observer.next(i++);
},1000)
// 返回了一个清理函数
return function(){
clearInterval( id );
}
})
let subscription = stream$.subscribe((value) => {
console.log('Value', value)
});
setTimeout(() => {
subscription.unsubscribe() // 在这我们调用了清理函数
}, 3000)</code></pre>
<h2>Ajax异步操作</h2>
<pre><code class="html"><input type="text"></code></pre>
<pre><code class="javascript">function sendRequest(search) {
return Rx.Observable.ajax.getJSON(`http://deepred5.com/cors.php?search=${search}`)
.map(response => response)
}
Rx.Observable.fromEvent(document.querySelector('input'), 'keyup')
.map(e => e.target.value)
.flatMap(search => sendRequest(search))
.subscribe(value => {
console.log(value)
})</code></pre>
<p>用户每次在input框每次进行输入,均会触发ajax请求,并且每个ajax返回的值都会被打印一遍</p>
<p>现在需要实现这样一个功能:<br>希望用户在300ms以内停止输入,才发送请求(防抖),并且console打印出来的值只要最近的一个ajax返回的</p>
<pre><code class="javascript">Rx.Observable.fromEvent(document.querySelector('input'), 'keyup')
.debounceTime(300)
.map(e => e.target.value)
.switchMap(search => sendRequest(search))
.subscribe(value => {
console.log(value)
})</code></pre>
<p><code>debounceTime</code>表示经过n毫秒后,没有流入新值,那么才将值转入下一个环节<br><code>switchMap</code>能取消上一个已无用的请求,只保留最后的请求结果流,这样就确保处理展示的是最后的搜索的结果</p>
<p>可以看到,RxJS对异步的处理是非常优秀的,对异步的结果能进行各种复杂的处理和筛选。</p>
<h2>React + Redux 的异步解決方案:redux-observable</h2>
<p>Redux的action都是同步的,所以默认情况下也只能处理同步数据流。</p>
<p>为了生成异步action,处理异步数据流,有许多不同的解決方案,例如 redux-thunk、redux-promise、redux-saga 等等。</p>
<p>以<strong>redux-thunk</strong>举例:</p>
<p>调用一个异步API,首先要先定义三个同步action构造函数,分别表示</p>
<ul>
<li>请求开始</li>
<li>请求成功</li>
<li>请求失败</li>
</ul>
<p>然后再定义一个异步action构造函数,该函数不再是返回普通的对象,而是返回一个函数,在这个函数里,进行ajax异步操作,然后根据返回的成功和失败,分别调用前面定义的同步action</p>
<p><code>actions.js</code></p>
<pre><code class="javascript">
export const FETCH_STARTED = 'WEATHER/FETCH_STARTED';
export const FETCH_SUCCESS = 'WEATHER/FETCH_SUCCESS';
export const FETCH_FAILURE = 'WEATHER/FETCH_FAILURE';
// 普通action构造函数,返回普通对象
export const fetchWeatherStarted = () => ({
type: FETCH_STARTED
});
export const fetchWeatherSuccess = (result) => ({
type: FETCH_SUCCESS,
result
})
export const fetchWeatherFailure = (error) => ({
type: FETCH_FAILURE,
error
})
// 异步action构造函数,返回一个函数
export const fetchWeather = (cityCode) => {
return (dispatch) => {
const apiUrl = `/data/cityinfo/${cityCode}.html`;
dispatch(fetchWeatherStarted())
return fetch(apiUrl).then((response) => {
if (response.status !== 200) {
throw new Error('Fail to get response with status ' + response.status);
}
response.json().then((responseJson) => {
dispatch(fetchWeatherSuccess(responseJson.weatherinfo));
}).catch((error) => {
dispatch(fetchWeatherFailure(error));
});
}).catch((error) => {
dispatch(fetchWeatherFailure(error));
})
};
}</code></pre>
<p>现在如果想要异步请求,只要:</p>
<pre><code class="javascript">// fetchWeather是个异步action构造函数
dispatch(fetchWeather('23333'));</code></pre>
<p>我们再来看看<code>redux-observable</code>:</p>
<p>调用一个异步API,不再需要定义一个异步action构造函数,所有的action构造函数都只是返回普通的对象</p>
<p>那么ajax请求在哪里发送?</p>
<p>答案是在<strong>Epic</strong>进行异步操作</p>
<blockquote>Epic是redux-observable的核心原语。<br>它是一个函数,接收 actions 流作为参数并且返回 actions 流。 Actions 入, actions 出.</blockquote>
<pre><code class="javascript">export const FETCH_STARTED = 'WEATHER/FETCH_STARTED';
export const FETCH_SUCCESS = 'WEATHER/FETCH_SUCCESS';
export const FETCH_FAILURE = 'WEATHER/FETCH_FAILURE';
export const fetchWeather = cityCode => ({ type: FETCH_STARTED, cityCode });
export const fetchWeatherSuccess = result => (
{ type: FETCH_SUCCESS, result };
);
export const fetchWeatherFailure = (error) => (
{
type: FETCH_FAILURE,
error
}
)
export const fetchWeatherEpic = action$ =>
action$.ofType(FETCH_STARTED)
.mergeMap(action =>
ajax.getJSON(`/data/cityinfo/${action.cityCode}.html`)
.map(response => fetchWeatherSuccess(response.weatherinfo))
// 这个处理异常的action必须使用Observable.of方法转为一个observable
.catch(error => Observable.of(fetchWeatherFailure(error)))
);</code></pre>
<p>现在如果想要异步请求,只要:</p>
<pre><code class="javascript">// fetchWeather只是个普通的action构造函数
dispatch(fetchWeather('23333'));</code></pre>
<p>相较于thunk中间件,使用redux-observable来处理异步action,有以下优点:</p>
<ul>
<li>不需要修改action构造函数,返回的仍然是普通对象</li>
<li>epics中间件会将action封装成Observable对象,可以使用RxJs的相应api来控制异步流程,它就像一个拥有许多高级功能的Promise,现在我们在Redux中也可以得到它的好处。</li>
</ul>
<h2>总结</h2>
<p>原生JS传统解决异步的方式:callback、Generator、Promise、async/await</p>
<p>RxJS解决的是数据流的问题,它可以让批量数据处理起来更方便</p>
<p>可以想象的一些使用场景:</p>
<ul>
<li>多个服务端实时消息流,通过RxJS进行高阶处理,最后到 view 层就是很清晰的一个Observable,但是view层本身处理用户事件依然可以沿用原有的范式。</li>
<li>爬虫抓取,每次对一个网站的前5页做平行请求,每个请求如果失败就重试,重试3次之后再放弃。</li>
</ul>
<p>可以看出,这种需要对流进行复杂操作的场景更加适合RxJS</p>
<p>公司内部目前的大部分系统,前端就可能不太适合用RxJS,因为大部分是后台CRUD系统,整体性、实时性的要求都不高,并且也没有特别复杂的数据流操作</p>
<p>我们推荐在适合RxJS的地方用RxJS,但是不强求RxJS for everything。RxJS给了我们另一种思考和解决问题的方式,但这不一定是必要的</p>
<h2>参考</h2>
<p><a href="https://link.segmentfault.com/?enc=%2BnIr%2FgMSeQD%2BiGuR7tMDiQ%3D%3D.4%2BLLWtLPJ4qS%2FUjtO7m0WHY7JWKub%2Ff3e7Ehw7UxsM9Mk4cAGpwB2PlKemMiQwvf" rel="nofollow">构建流式应用—RxJS详解</a></p>
<p><a href="https://link.segmentfault.com/?enc=b%2BrqEXy6XqzH1tFsgoaOFQ%3D%3D.8%2FjVQe1kUgDFAFPYsJIDjQ7Rtd7MhWxZyJ6hKKLY0zMKklUAaqgZ2ZHV7kXfPu%2BUmO6Sze54roIE86C%2F3eKv33Pr36hTcCMWbA9zWNBMYh0AjSPD9TqBic7kZlJeonhDuPM1HRgTWOsRoENeszvI%2FA%3D%3D" rel="nofollow">希望是最淺顯易懂的RxJS教學</a></p>
<p><a href="https://link.segmentfault.com/?enc=G6is2fWSEy0VOn3bKgxl0g%3D%3D.iom1Quzvbm9RKKhrV0y30g5TKeFJQSXrAKG%2F6pv44ajFLLk1%2BU9HMedMUU606yC9" rel="nofollow">RxJS入门指引和初步应用</a></p>
<p><a href="https://link.segmentfault.com/?enc=l3HBxxvGVZTrFP8r5Ap1NQ%3D%3D.P6oZhSY5T1ve0yQ7JeDP57Z8SfeREgXRmvKSp8K7AGW9nnmKN54g0ICJjXdBh2%2BdQMmDyWgLTfeMQp9bk9ataQ%3D%3D" rel="nofollow">30天精通RxJS系列</a></p>
从零开始搭建一个简单的基于webpack的vue开发环境
https://segmentfault.com/a/1190000012789253
2018-01-10T19:29:18+08:00
2018-01-10T19:29:18+08:00
深红
https://segmentfault.com/u/deepred5
111
<p>更新:2019/07/28</p>
<p><a href="https://link.segmentfault.com/?enc=fXER1wDjUvgt4jrIH2UrAQ%3D%3D.M4JpPR1kx1%2BEQhmcYfh%2FjmfGkQUdv3QDMArS8vmr09380zguDgLyCbVOTjr9QC8EZuBESpjT7skZjQzaU7Wi33bMHlnlzQHWFgid4leoRV%2FHY7SpuUK7gAKWF8v83oVXPraskP%2Fcelg99K1nn29uVK23zycmkKh7IUSUpxZMz5ZVd4lYLiymyKz5hixi1kpe4cs98mGN2ooAVdN4Lk9HHd0ceJzFKRr0manHfMCsins%3D" rel="nofollow">从零开始搭建一个简单的基于webpack的react开发环境</a></p>
<p>原文:</p>
<p>都8102年了,现在还来谈webpack的配置,额,是有点晚了。而且,基于vue-cli或者create-react-app生成的项目,也已经一键为我们配置好了webpack,看起来似乎并不需要我们深入了解。</p>
<p>不过,为了学习和理解webpack解决了前端的哪些痛点,还是有必要从零开始自己搭建一个简单的开发环境。本文的webpack配置参考了vue-cli提供<code>webpack-simple </code>模板,这也是vue-cli里面最简单的一个webpack配置,非常适合从零开始学习。</p>
<p><strong>注: 本文webpack基于3.10.0, webpack4部分内容可能不再适用!!!!</strong></p>
<h3><a href="https://link.segmentfault.com/?enc=qiHSI3uBxliA6qdWFiy95Q%3D%3D.hsUDWSDBhKMldY2bRjW4XHiIiwiWPUPaZYQ4%2FoXnGGfnbAK3fIe7LJnzmxdIH9xi" rel="nofollow">演示代码下载</a></h3>
<h2>安装webpack</h2>
<pre><code>npm i webpack -g</code></pre>
<p>webpack4还要单独安装</p>
<pre><code>npm i webpack-cli -g</code></pre>
<h2>项目初始化</h2>
<p>新建一个文件夹vue-webpack-simple </p>
<p>新建package.json</p>
<pre><code>npm init -y</code></pre>
<p>安装vue webpack webpack-dev-server</p>
<pre><code>npm i vue --save</code></pre>
<pre><code>npm i webpack webpack-dev-server --save-dev</code></pre>
<p>根目录下新建index.html</p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
</body>
</html></code></pre>
<p>根目录下新建webpack.config.js</p>
<pre><code class="javascript">var path = require('path');
var webpack = require('webpack');
module.exports = {};</code></pre>
<p>新建src文件夹,src文件夹下新建main.js</p>
<p>目前整个项目的结构如下:<br><img src="/img/remote/1460000012789258?w=329&h=226" alt="" title=""></p>
<h2>js模块化</h2>
<p>在ES6出现之前,js是没有统一的模块体系。<br>服务器端使用CommonJS规范,而浏览器端又有AMD和CMD两种规范</p>
<p>webpack的思想就是一切皆模块,官方推荐使用commonJS规范,这使得我们浏览器端也可以使用commonJS的模块化写法</p>
<pre><code class="javascript">module.exports = {}</code></pre>
<p>src目录下新建一个util.js</p>
<pre><code class="javascript">module.exports = function say() {
console.log('hello world');
}</code></pre>
<p>main.js</p>
<pre><code class="javascript">var say = require('./util');
say();</code></pre>
<p>修改webpack.config.js</p>
<pre><code class="javascript">var path = require('path');
var webpack = require('webpack');
module.exports = {
entry: './src/main.js', // 项目的入口文件,webpack会从main.js开始,把所有依赖的js都加载打包
output: {
path: path.resolve(__dirname, './dist'), // 项目的打包文件路径
publicPath: '/dist/', // 通过devServer访问路径
filename: 'build.js' // 打包后的文件名
},
devServer: {
historyApiFallback: true,
overlay: true
}
};</code></pre>
<p>修改package.josn</p>
<pre><code>"scripts": {
"dev": "webpack-dev-server --open --hot",
"build": "webpack --progress --hide-modules"
},</code></pre>
<p>注意:webpack-dev-server会自动启动一个静态资源web服务器 --hot参数表示启动热更新</p>
<p>修改index.html,引入打包后的文件</p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<script src="/dist/build.js"></script>
</body>
</html></code></pre>
<p>运行</p>
<pre><code>npm run dev</code></pre>
<p>可以发现浏览器自动打开的一个页面,查看控制台,有<code>hello world</code>打出</p>
<p>我们随意修改util.js,可以发现浏览器会自动刷新,非常方便。</p>
<p>如果我们希望看打包后的bundle.js文件,运行</p>
<pre><code>npm run build</code></pre>
<p>可以看到生成了一个dist目录,里面就有打包好后的bundle.js<br><img src="/img/remote/1460000012789259?w=300&h=232" alt="" title=""></p>
<p>webpack默认不支持转码es6,但是<code>import</code> <code>export</code>这两个语法却单独支持。所以我们可以改写前面的模块化写法</p>
<p>util.js</p>
<pre><code class="javascript">export default function say() {
console.log('hello world ');
}</code></pre>
<p>main.js</p>
<pre><code class="javascript">import say from './util';
say();
</code></pre>
<h2>引入vue</h2>
<p>下面我们来试着引入vue(目前不考虑单文件.vue)</p>
<p>main.js</p>
<pre><code class="javascript">import Vue from 'vue';
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
});
</code></pre>
<p>index.html</p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app">
{{message}}
</div>
<script src="/dist/build.js"></script>
</body>
</html></code></pre>
<p>还要注意一点:要修改webpack.config.js文件</p>
<pre><code class="javascript">var path = require('path');
var webpack = require('webpack');
module.exports = {
entry: './src/main.js',
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
filename: 'build.js'
},
devServer: {
historyApiFallback: true,
overlay: true
},
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
}
}
};</code></pre>
<p>重新运行npm run dev,可以看到,页面正常显示了<code>Hello World</code></p>
<h2>引入scss和css</h2>
<p>webpack默认只支持js的模块化,如果需要把其他文件也当成模块引入,就需要相对应的loader解析器</p>
<pre><code>npm i node-sass css-loader vue-style-loader sass-loader --save-dev</code></pre>
<p>webpack.config.js</p>
<pre><code class="javascript">var path = require('path');
var webpack = require('webpack');
module.exports = {
entry: './src/main.js',
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
filename: 'build.js'
},
devServer: {
historyApiFallback: true,
overlay: true
},
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
}
},
module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
],
}
]
}
};</code></pre>
<p>解释:</p>
<pre><code class="javascript">{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
],
}</code></pre>
<p>这段代码意思是:匹配后缀名为css的文件,然后分别用css-loader,vue-style-loader去解析<br>解析器的执行顺序是从下往上(先css-loader再vue-style-loader)</p>
<p>注意:因为我们这里用vue开发,所以使用vue-style-loader,其他情况使用style-loader</p>
<p>css-loader使得我们可以用模块化的写法引入css,vue-style-loader会将引入的css插入到html页面里的style标签里</p>
<p>要引入scss也是同理的配置写法:</p>
<pre><code class="javascript">module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
],
},
{
test: /\.scss$/,
use: [
'vue-style-loader',
'css-loader',
'sass-loader'
],
},
{
test: /\.sass$/,
use: [
'vue-style-loader',
'css-loader',
'sass-loader?indentedSyntax'
],
}]
}</code></pre>
<p>我们现在来试下<br>在src目录下新建style目录,style目录里新建common.scss</p>
<pre><code class="css">body {
background: #fed;
}</code></pre>
<p>main.js</p>
<pre><code class="javascript">import './style/common.scss';</code></pre>
<p>发现css样式有用了</p>
<h2>使用babel转码</h2>
<p>ES6的语法大多数浏览器依旧不支持,bable可以把ES6转码成ES5语法,这样我们就可以大胆的在项目中使用最新特性了</p>
<pre><code>npm i babel-core babel-loader babel-preset-env babel-preset-stage-3 --save-dev</code></pre>
<p>在项目根目录新建一个.babelrc文件</p>
<pre><code class="javascript">{
"presets": [
["env", { "modules": false }],
"stage-3"
]
}
</code></pre>
<p>webpack.config.js添加一个loader</p>
<pre><code class="javascript">{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
}</code></pre>
<p>exclude表示忽略node_modules文件夹下的文件,不用转码</p>
<p>现在我们来试下async await语法吧<br>util.js</p>
<pre><code class="javascript">export default function getData() {
return new Promise((resolve, reject) => {
resolve('ok');
})
}</code></pre>
<p>main.js</p>
<pre><code class="javascript">import getData from './util';
import Vue from 'vue';
import './style/common.scss';
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
},
methods: {
async fetchData() {
const data = await getData();
this.message = data;
}
},
created() {
this.fetchData();
}
});</code></pre>
<p>这时控制台会报一个错误<code>regeneratorRuntime is not defined</code>,因为我们没有安装<code>babel-polyfill</code></p>
<pre><code class="javascript">npm i babel-polyfill --save-dev</code></pre>
<p>然后修改webpack.config.js的入口</p>
<pre><code class="javascript">entry: ['babel-polyfill', './src/main.js'],</code></pre>
<p>重新npm run dev,可以发现正常运行了</p>
<h2>引入图片资源</h2>
<p>把图片也当成模块引入</p>
<pre><code>npm i file-loader --save-dev</code></pre>
<p>webpack.config.js添加一个loader</p>
<pre><code class="javascript">{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]?[hash]'
}
}</code></pre>
<p>在src目录下新建一个img目录,存放一张图片logo.png</p>
<p>修改main.js</p>
<pre><code class="javascript">import getData from './util';
import Vue from 'vue';
import './style/common.scss';
Vue.component('my-component', {
template: '<img :src="url" />',
data() {
return {
url: require('./img/logo.png')
}
}
})
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue !'
},
methods: {
async fetchData() {
const data = await getData();
this.message = data;
}
},
created() {
this.fetchData()
}
});
</code></pre>
<p>修改index.html</p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app">
{{message}}
<my-component/>
</div>
<script src="/dist/build.js"></script>
</body>
</html>
</code></pre>
<p>可以看见,图片也被正确加载了</p>
<h2>单文件组件</h2>
<p>在前面的例子里,我们使用 Vue.component 来定义全局组件<br>在实际项目里,更推荐使用单文件组件</p>
<pre><code>npm i vue-loader vue-template-compiler --save-dev</code></pre>
<p>添加一个loader</p>
<pre><code class="javascript">{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {
'scss': [
'vue-style-loader',
'css-loader',
'sass-loader'
],
'sass': [
'vue-style-loader',
'css-loader',
'sass-loader?indentedSyntax'
]
}
}
}</code></pre>
<p>在src目录下新建一个App.vue</p>
<pre><code class="html"><template>
<div id="app">
<h1>{{ msg }}</h1>
<img src="./img/logo.png">
<input type="text" v-model="msg">
</div>
</template>
<script>
import getData from './util';
export default {
name: 'app',
data () {
return {
msg: 'Welcome to Your Vue.js'
}
},
created() {
this.fetchData();
},
methods: {
async fetchData() {
const data = await getData();
this.msg = data;
}
}
}
</script>
<style lang="scss">
#app {
font-family: "Avenir", Helvetica, Arial, sans-serif;
h1 {
color: green;
}
}
</style>
</code></pre>
<p>main.js</p>
<pre><code class="javascript">import Vue from 'vue';
import App from './App.vue';
import './style/common.scss';
new Vue({
el: '#app',
template: '<App/>',
components: { App }
})
</code></pre>
<p>index.html</p>
<pre><code class="html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="/dist/build.js"></script>
</body>
</html></code></pre>
<p>npm run dev,可以发现单文件被正确加载了</p>
<h2>source-map</h2>
<p>在开发阶段,调试也是非常重要的一项需求。</p>
<p>App.vue</p>
<pre><code class="javascript">created() {
this.fetchData();
console.log('23333');
}</code></pre>
<p>我们故意打一个console,打开控制台<br><img src="/img/remote/1460000012789260?w=1915&h=222" alt="" title=""></p>
<p>我们点击进入这个console的详细地址<br><img src="/img/remote/1460000012789261?w=543&h=277" alt="" title=""></p>
<p>进入的是打包后的build.js,我并不知道是在哪个组件里写的,这就造成了调试困难</p>
<p>这时就要修改webpack.config.js</p>
<pre><code class="javascript">module.exports = {
entry: ['babel-polyfill', './src/main.js'],
// 省略其他...
devtool: '#eval-source-map'
};</code></pre>
<p>重新npm run dev<br><img src="/img/remote/1460000012789262?w=448&h=391" alt="" title=""><br>这次调试,它直接返回那个组件的源代码了,这不是被打包过的!</p>
<h2>打包发布</h2>
<p>我们先试着npm run build打包一下文件<br><img src="/img/remote/1460000012789263?w=728&h=138" alt="" title=""></p>
<p>会发现,打包后的build.js非常大,有500多k了</p>
<p>在实际发布时,会对文件进行压缩,缓存,分离等等优化处理</p>
<pre><code>npm i cross-env --save-dev</code></pre>
<p>修改package.json</p>
<pre><code class="javascript">"scripts": {
"dev": "cross-env NODE_ENV=development webpack-dev-server --open --hot",
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules"
}</code></pre>
<p>这次我们设置了环境变量,打包时,NODE_ENV是production</p>
<p>然后修改webpack.config.js,判断NODE_ENV为production时,压缩js代码</p>
<pre><code class="javascript">var path = require('path');
var webpack = require('webpack');
module.exports = {
// 省略...
}
if (process.env.NODE_ENV === 'production') {
module.exports.devtool = '#source-map';
module.exports.plugins = (module.exports.plugins || []).concat([
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: '"production"'
}
}),
new webpack.optimize.UglifyJsPlugin(),
])
}
</code></pre>
<p>重新打包<br><img src="/img/remote/1460000012789264?w=732&h=127" alt="" title=""></p>
<p>可以看见,压缩效果非常明显!</p>
<p>至此,一个非常简单的vue开发环境搭建成功。</p>
<p>注意:<strong>本文中的配置还有非常多可以优化的地方,比如分离js和css</strong></p>
<p>读者可以自行了解相关知识,这里只是带领大家了解最基础的webpack配置。</p>
在Node中使用ES6语法
https://segmentfault.com/a/1190000012709705
2018-01-05T08:36:27+08:00
2018-01-05T08:36:27+08:00
深红
https://segmentfault.com/u/deepred5
5
<p>Node本身已经支持部分ES6语法,但是<code>import</code> <code>export</code>,以及<code>async</code> <code>await</code>(<strong>Node 8 已经支持</strong>)等一些语法,我们还是无法使用。为了能使用这些新特性,我们就需要使用babel把ES6转成ES5语法</p>
<h3>安装babel</h3>
<pre><code class="javascript">npm install babel-cli -g</code></pre>
<p><!-- more --></p>
<h3>基础知识</h3>
<p>babel的配置文件是<code>.babelrc</code></p>
<pre><code class="javascript">{
"presets": []
}</code></pre>
<p>新建一个<code>demo</code>文件夹,文件夹下新建 <code>1.js</code></p>
<pre><code>const arr = [1, 2, 3];
arr.map(item => item + 1);</code></pre>
<p>同时新建<code>.babelrc</code>配置文件</p>
<pre><code class="javascript">{
"presets": []
}</code></pre>
<p>终端运行</p>
<pre><code>babel 1.js -o dist.js</code></pre>
<p>可以看见,在文件夹下,新建了一个dist.js,这就是babel转码后的文件<br>但是,dist.js目前是没有任何变化的,因为我们在配置文件里面没有声明转码规则,所以babel无法转码</p>
<p>安装转码插件</p>
<pre><code>npm install --save-dev babel-preset-es2015 babel-preset-stage-0</code></pre>
<p>修改配置文件</p>
<pre><code class="javascript">{
"presets": [
"es2015",
"stage-0"
]
}</code></pre>
<p><code>es2015</code>可以转码es2015的语法规则,<code>stage-0</code>可以转码ES7语法(比如async await)</p>
<p>再次运行终端</p>
<pre><code>babel 1.js -o dist.js</code></pre>
<p>可以看见,箭头函数被转码了</p>
<pre><code class="javascript">var arr = [1, 2, 3];
arr.map(function (item) {
return item + 1;
});</code></pre>
<p>我们试下async await</p>
<pre><code class="javascript">async function start() {
const data = await test();
console.log(data);
}
function test() {
return new Promise((resolve, reject) => {
resolve('ok');
})
}</code></pre>
<p>转码后的文件</p>
<pre><code class="javascript">'use strict';
var start = function () {
var _ref = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee() {
var data;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return test();
case 2:
data = _context.sent;
console.log(data);
case 4:
case 'end':
return _context.stop();
}
}
}, _callee, this);
}));
return function start() {
return _ref.apply(this, arguments);
};
}();
function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; }
function test() {
return new Promise(function (resolve, reject) {
resolve('ok');
});
}
</code></pre>
<p>再试下 import export</p>
<p><code>util.js</code></p>
<pre><code class="javascript">export default function say() {
console.log('2333');
}</code></pre>
<p><code>1.js</code></p>
<pre><code class="javascript">import say from './util';
say();
</code></pre>
<p>这次,要把<code>1.js</code>和<code>util.js</code>都转码,我们可以把整个文件夹转码</p>
<pre><code>babel demo -d dist</code></pre>
<p>新生成的dist文件夹下,就有转码后的文件。可以看见,转码后,仍然使用的是<code>module.exports</code> commonjs模块加载</p>
<h3>babel-preset-env</h3>
<p>上面的转码其实有个缺陷,就是babel会默认把所有的代码转成es5,这意味着,即使node支持<code>let</code>关键字,转码后,也会被转成<code>var</code><br>我们可以使用<code>babel-preset-env</code>这个插件,它会自动检测当前node版本,只转码node不支持的语法,非常方便</p>
<pre><code>npm install --save-dev babel-preset-env</code></pre>
<p><code>.babelrc</code></p>
<pre><code>{
"presets": [
["env", {
"targets": {
"node": "current"
}
}]
]
}</code></pre>
<p><code>1.js</code></p>
<pre><code class="javascript">class F {
say() {
}
}
const a = 1;</code></pre>
<pre><code>babel 1.js -o dist.js</code></pre>
<p>编译出来后</p>
<pre><code class="javascript">"use strict";
class F {
say() {}
}
const a = 1;
</code></pre>
<p>可以看见,<code>class</code>和<code>const</code>并没有被转码,因为当前node版本(8.9.3)支持该语法</p>
<h3>在实际项目中使用ES6语法</h3>
<p>Koa2需要Node v7.6.0以上的版本来支持<code>async</code>语法,同时,我们也想在Koa2中使用<code>import</code>模块化写法</p>
<pre><code>npm install --save-dev babel-register</code></pre>
<pre><code>npm install koa --save</code></pre>
<p>新建一个文件夹<code>app</code></p>
<p><code>util.js</code></p>
<pre><code class="javascript">export function getMessage() {
return new Promise((resolve, reject) => {
resolve('Hello World!');
})
}</code></pre>
<p><code>app.js</code></p>
<pre><code class="javascript">import Koa from 'koa';
import { getMessage } from './util'
const app = new Koa();
app.use(async ctx => {
const data = await getMessage();
ctx.body = data;
});
app.listen(3000);</code></pre>
<p>如果直接启动文件,肯定会报错</p>
<pre><code>node app</code></pre>
<p>我们需要一个入口文件,来转码</p>
<p><code>index.js</code></p>
<pre><code class="javascript">require("babel-register");
require("./app.js");</code></pre>
<pre><code>node index</code></pre>
<p>访问<a href="https://link.segmentfault.com/?enc=f4vmj0QBh%2FbQVwTgOASqxw%3D%3D.UG4yGWIEqGr7%2FvSeVfjx7fjfhLE22aEaJGUncttqp5o%3D" rel="nofollow">http://localhost:3000/</a>可以看见页面了!</p>
<p><code>babel-register</code>是实时转码的,所以实际发布时,应该先把整个app文件夹转码</p>
<pre><code>babel app -d dist</code></pre>
<p>这次,只要启动dist下的<code>app.js</code>即可</p>
<pre><code>node app</code></pre>
从零开始教你写一个NPM包
https://segmentfault.com/a/1190000011095467
2017-09-10T00:41:33+08:00
2017-09-10T00:41:33+08:00
深红
https://segmentfault.com/u/deepred5
37
<h2>前言</h2>
<p>本文主要记录我开发一个npm包:<code>pixiv-login</code> 时的心得体会,其中穿插了一些日常开发的流程和技巧,希望对新手有所启发,大佬们看看就好_(:3」∠)</p>
<p><strong>2018-11-8 更新</strong> :</p>
<ul>
<li>pixiv已经被墙了,所以你可能登不上去</li>
<li>图片好像挂了,可以跳转到该地址看 <a href="https://link.segmentfault.com/?enc=%2F6ajo%2BUyvMv5ePj5c3iEyQ%3D%3D.2erB6PA4OlJtywQMFAc6uesVQgvYBduMOMB%2BHe%2BMMUdghL8ng%2Fch5kmBTHXNseOP" rel="nofollow">点我</a>
</li>
</ul>
<h2>pixiv-login</h2>
<p><code>pixiv-login</code>的功能就是模拟用户登录网站pixiv,获取cookie<br><a href="https://link.segmentfault.com/?enc=kT%2BpwxUupFHQ0UPuW8ZgPQ%3D%3D.MhAtcLn%2FhMjEv2Wh67MNvlfQ0SFvBI8HZ%2FVr63sA5COrYEAi3UllASfSBj52tC%2F2" rel="nofollow">源码</a> <a href="https://link.segmentfault.com/?enc=6ofyznqFp4HaqptAwBbgTQ%3D%3D.UGW0PWJIXJ8G2jN3xnROCANnJU8PVuq5ZvFca2w2w52zBxJTnFMUcRIJOMvO1%2FhL" rel="nofollow">npm</a></p>
<p>安装:</p>
<pre><code class="javascript">npm install --save pixiv-login</code></pre>
<p>使用:</p>
<pre><code class="javascript">const pixivLogin = require('pixiv-login');
pixivLogin({
username: '你的用户名',
password: '你的密码'
}).then((cookie) => {
console.log(cookie);
}).catch((error) => {
console.log(error);
})</code></pre>
<h2>开发工具</h2>
<p>日常开发中,我常用的IDE是vscode+webstorm+sublime,其中vscode因为其启动快,功能多,调试方便,受到大多数开发者的青睐。在接下来的教程中,我就以vscode进行演示了。至于终端,由于是在windows平台,所以我选择了cmder代替原生cmd,毕竟cmder支持大多数linux命令。</p>
<h2>初始化项目</h2>
<pre><code>mkdir pixiv-login
cd pixiv-login
npm init</code></pre>
<p>一路回车就好</p>
<h2>安装依赖</h2>
<p>要模拟登陆,我们就需要一个http库,这里我选择了<a href="https://link.segmentfault.com/?enc=g9CQQ9hb37htiIFBf7GxfQ%3D%3D.U%2FGGLKcEawtLfG0kcP8ajhOMHxbSrv7O2EoKtjIW5aiiXkCnnDMZheldouJeM2Ms" rel="nofollow">axios</a>,同时获取的html字符串我们需要解析,<a href="https://link.segmentfault.com/?enc=MPyFUPZj%2FnPXXCvckeBKZQ%3D%3D.1P6w%2B8nSeqKbEsfN%2FlINaEAvXyKoIETilSqI43ZwzWb6PW1TuSNM1YjpiH%2B15QVE" rel="nofollow">cheerio</a>就是首选了</p>
<pre><code class="javascript">npm i axios cheerio --save</code></pre>
<h2>debug</h2>
<p>毕业参加工作也有几个月了,其中学到了很重要的一个技能就是debug。说出来也不怕大家笑,在大学时,debug就是<code>console.log</code>大法好,基本就不用断点来追踪,其实用好断点,效率比<code>console.log</code>要高很多。还记得当初看到同事花式debug时,心中不禁感慨:<strong>为什么你们会这么熟练啊!</strong></p>
<p>使用vscode进行node调试是非常方便的</p>
<p>首先新建一个index.js文件,项目结构如下(我本地的npm版本是5.x,所以会多一个package-lock.json文件,npm 3.x的没有该文件):<br><img src="/img/remote/1460000011095472" alt="pic" title="pic"><br>然后点击左侧第4个图标,添加配置 <br><img src="/img/remote/1460000011095473" alt="pic" title="pic"></p>
<p>配置文件如下:</p>
<pre><code class="javascript">{
// Use IntelliSense to learn about possible Node.js debug attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceRoot}\\index.js"
}
]
}</code></pre>
<p>其中最重要的一句是:<code> "program": "${workspaceRoot}\\index.js"</code>,它表示debug时,项目的启动文件是index.js<br>至此,debug的配置就完成了。</p>
<p>现在编写index.js文件</p>
<pre><code class="javascript">const axios = require('axios');
const cheerio = require('cheerio');
axios.get('https://www.pixiv.net')
.then(function (response) {
const $ = cheerio.load(response.data);
const title = $('title').text();
debugger;
console.log(title);
})
.catch(function (error) {
console.log(error);
});</code></pre>
<p>按下F5启动调试模式,如果一切正常,那么效果如下:<br><img src="/img/remote/1460000011095474" alt="pic" title="pic"></p>
<p>可以看到,程序卡在了第8行<br>如果你把鼠标移到response变量上,可以发现,vscode会自动显示该变量的值,这比直接<code>console.log(response)</code>清晰简洁多了<br><img src="/img/remote/1460000011095475" alt="pic" title="pic"></p>
<p><img src="/img/remote/1460000011095476" alt="pic" title="pic"></p>
<p>如果想继续执行程序,可以接着按下F5或者右上角的绿色箭头<br><img src="/img/remote/1460000011095477" alt="pic" title="pic"></p>
<p>程序执行完成,控制台打出了pixiv首页的title值<br><img src="/img/remote/1460000011095478" alt="pic" title="pic"></p>
<p>除了使用<code>debugger</code>语句打断点,你也可以直接点击代码的行数打断点<br><img src="/img/remote/1460000011095479" alt="pic" title="pic"></p>
<p>比如上图,我就在第8行处打了一个断点,效果是一样的</p>
<p>还有一个小技巧,在debug模式下,你可以随意修改变量的值,比如现在程序卡在了第8行,这时你在控制台修改title的值<br><img src="/img/remote/1460000011095480" alt="pic" title="pic"></p>
<p>按下回车,然后继续执行代码,这时控制台输出的title值就是'deepred',而不是真正的title值<br><img src="/img/remote/1460000011095481" alt="pic" title="pic"></p>
<p>这个技巧,在平时开发过程中,当需要绕过某些验证时,非常有用</p>
<h2>正式开始</h2>
<p>虽然我们最后是要写一个npm包,但是首先,我们先把获取cookie的功能实现了,然后再思考怎么封装为一个npm包,供其他人使用。</p>
<p>进入登录页面 <a href="https://link.segmentfault.com/?enc=HjlEH%2BqM7Kh3NHZlkQOqew%3D%3D.4QIwIHOvKsl7UGakJb2vSKEvFBFP%2FXCrWEWAG97WKYUmCCuCfh30sDag8QVVx%2BS03cdX8N4eC4AG4bfxLMRGDX8QhFI9bFD5%2Bcq4xO4cP%2FlgeY22gh2RIWlUlrFk9CuRAMgkNKx3uVEEOJd%2Fb8VwLw%3D%3D" rel="nofollow">https://accounts.pixiv.net/login?lang=zh&source=pc&view_type=page&ref=wwwtop_accounts_index</a>,我们先登录一次,看看前端向后台发送了哪些数据<br><img src="/img/remote/1460000011095482" alt="pic" title="pic"></p>
<p>这里需要特别注意,我们要勾选<code>preserve log</code>,这样,即使页面刷新跳转了,http请求记录仍然会记录下来<br><img src="/img/remote/1460000011095483" alt="pic" title="pic"><br><img src="/img/remote/1460000011095484" alt="pic" title="pic"><br><img src="/img/remote/1460000011095485" alt="pic" title="pic"><br>可以看到,<strong>post_key</strong>是登录的关键点,P站使用了该值来防止CSRF<br>post_key怎么获取呢?<br>经过页面分析,发现在登录页面,有个隐藏表单域(后来发现,其实在<a href="https://link.segmentfault.com/?enc=TV0ZtPEdR6yAeIjT%2FfC6Gw%3D%3D.G%2FhxSWhG1dFH3z4MVhVq29h1t0UPzobV%2FBaw8ekaK0o%3D" rel="nofollow">首页</a>就已经写出来了):</p>
<p><img src="/img/remote/1460000011095486" alt="pic" title="pic"></p>
<p>可以清楚看到,post_key已经写出来了,我们只需要用<code>cheerio</code>解析出该input的值就ok了</p>
<pre><code class="javascript">const post_key = $('input[name="post_key"]').val();</code></pre>
<p>获取post_key</p>
<pre><code class="javascript">const axios = require('axios');
const cheerio = require('cheerio');
const LOGIN_URL = 'https://accounts.pixiv.net/login?lang=zh&source=pc&view_type=page&ref=wwwtop_accounts_index';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36';
const LOGIN_API = 'https://accounts.pixiv.net/api/login?lang=zh';
const getKey = axios({
method: 'get',
url: LOGIN_URL,
headers: {
'User-Agent': USER_AGENT
}
}).then((response) => {
const $ = cheerio.load(response.data);
const post_key = $('input[name="post_key"]').val();
const cookie = response.headers['set-cookie'].join('; ');
if (post_key && cookie) {
return { post_key, cookie };
}
return Promise.reject("no post_key");
}).catch((error) => {
console.log(error);
});
getKey.then(({ post_key, cookie }) => {
debugger;
})</code></pre>
<p>F5运行代码<br><img src="/img/remote/1460000011095487" alt="pic" title="pic"></p>
<p><img src="/img/remote/1460000011095488" alt="pic" title="pic"><br>注意:打开注册页时,注册页会返回一些cookie,这些cookie在登录时也是需要随密码,用户名一起发送过去的</p>
<p>获取到了post_key, cookie,我们就可以愉快的把登录数据发送给后台接口了</p>
<pre><code class="javascript">const querystring = require('querystring');
getKey.then(({ post_key, cookie }) => {
axios({
method: 'post',
url: LOGIN_API,
headers: {
'User-Agent': USER_AGENT,
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Origin': 'https://accounts.pixiv.net',
'Referer': 'https://accounts.pixiv.net/login?lang=zh&source=pc&view_type=page&ref=wwwtop_accounts_index',
'X-Requested-With': 'XMLHttpRequest',
'Cookie': cookie
},
data: querystring.stringify({
pixiv_id: '你的用户名',
password: '你的密码',
captcha: '',
g_recaptcha_response: '',
post_key: post_key,
source: 'pc',
ref: 'wwwtop_accounts_index',
return_to: 'http://www.pixiv.net/'
})
}).then((response) => {
if (response.headers['set-cookie']) {
const cookie = response.headers['set-cookie'].join(' ;');
debugger;
} else {
return Promise.reject(new Error("no cookie"))
}
}).catch((error) => {
console.log(error);
});
});</code></pre>
<p>注意其中这段代码:</p>
<pre><code class="javascript">data: querystring.stringify({
pixiv_id: '你的用户名',
password: '你的密码',
captcha: '',
g_recaptcha_response: '',
post_key: post_key,
source: 'pc',
ref: 'wwwtop_accounts_index',
return_to: 'http://www.pixiv.net/'
})</code></pre>
<p>这里有个巨大的坑,<code>axios</code>默认把数据转成json格式,如果你想发送<code>application/x-www-form-urlencoded</code>的数据,就需要使用<code>querystring</code>模块<br>详情见: <a href="https://link.segmentfault.com/?enc=u0MrfRowoye1OS2OCI3Rvg%3D%3D.EEyLHX6XaXVqDZX1WbxNrhUbJ7ZXOIpCnGFu1mnKJ4FCqOiPmhafEOWYDZuYIfqUa4j8befIxK1a9xjbHfCt%2FF7HGpIlkMIc6euWTA3qnWRy%2B5545TUeH6XMtyR3Ugyx" rel="nofollow">using-applicationx-www-form-urlencoded-format</a></p>
<p>如果一切正常,那么效果如下:<br><img src="/img/remote/1460000011095489" alt="pic" title="pic"></p>
<p>其中的PHPSESSID和device_token就是服务器端返回的登录标识,说明我们登录成功了</p>
<p>程序运行的同时,你也很可能收到P站的登录邮件<br><img src="/img/remote/1460000011095490" alt="pic" title="pic"></p>
<p>好了,目前为止,我们已经成功获取到了cookie,实现了最基本的功能。</p>
<p><strong>特别注意</strong><br>程序不要运行太多次,因为每次运行,你就登录一次P站,如果被P站监测到频繁登录,它会开启验证码模式,这时,你除了需要发送用户名和密码,还需要向后台发送验证码值</p>
<pre><code class="javascript">data: querystring.stringify({
pixiv_id: '你的用户名',
password: '你的密码',
captcha: '你还需要填验证码',
g_recaptcha_response: '',
post_key: post_key,
source: 'pc',
ref: 'wwwtop_accounts_index',
return_to: 'http://www.pixiv.net/'
})</code></pre>
<p>也就是,<code>captcha</code>字段不再是空值了!</p>
<p>基本功能的完整代码</p>
<pre><code class="javascript">const axios = require('axios');
const cheerio = require('cheerio');
const querystring = require('querystring');
const LOGIN_URL = 'https://accounts.pixiv.net/login?lang=zh&source=pc&view_type=page&ref=wwwtop_accounts_index';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36';
const LOGIN_API = 'https://accounts.pixiv.net/api/login?lang=zh';
const getKey = axios({
method: 'get',
url: LOGIN_URL,
headers: {
'User-Agent': USER_AGENT
}
}).then((response) => {
const $ = cheerio.load(response.data);
const post_key = $('input[name="post_key"]').val();
const cookie = response.headers['set-cookie'].join('; ');
if (post_key && cookie) {
return { post_key, cookie };
}
return Promise.reject("no post_key");
}).catch((error) => {
console.log(error);
});
getKey.then(({ post_key, cookie }) => {
axios({
method: 'post',
url: LOGIN_API,
headers: {
'User-Agent': USER_AGENT,
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Origin': 'https://accounts.pixiv.net',
'Referer': 'https://accounts.pixiv.net/login?lang=zh&source=pc&view_type=page&ref=wwwtop_accounts_index',
'X-Requested-With': 'XMLHttpRequest',
'Cookie': cookie
},
data: querystring.stringify({
pixiv_id: '你的用户名',
password: '你的密码',
captcha: '',
g_recaptcha_response: '',
post_key: post_key,
source: 'pc',
ref: 'wwwtop_accounts_index',
return_to: 'http://www.pixiv.net/'
})
}).then((response) => {
if (response.headers['set-cookie']) {
const cookie = response.headers['set-cookie'].join(' ;');
console.log(cookie);
} else {
return Promise.reject(new Error("no cookie"));
}
}).catch((error) => {
console.log(error);
});
});
</code></pre>
<h2>封装成一个npm包</h2>
<p><code>登录P站获取cookie</code>这个功能,如果我们想让其他开发者也能方便调用,就可以考虑将其封装为一个npm包发布出去,这也算是对开源社区做出自己的一份贡献。</p>
<p>首先我们回想一下,我们调用其他npm包时是怎么做的?</p>
<pre><code class="javascript">const cheerio = require('cheerio');
const $ = cheerio.load(response.data);</code></pre>
<p>同理,我们现在规定<code>pixiv-login</code>的用法:</p>
<pre><code class="javascript">const pixivLogin = require('pixiv-login');
pixivLogin({
username: '你的用户名',
password: '你的密码'
}).then((cookie) => {
console.log(cookie);
}).catch((error) => {
console.log(error);
})
</code></pre>
<p><code>pixiv-login</code>对外暴露一个函数,该函数接受一个配置对象,里面记录了用户名和密码</p>
<p>现在,我们来改造index.js</p>
<pre><code class="javascript">const pixivLogin = ({ username, password }) => {
};
module.exports = pixivLogin;</code></pre>
<p>最基本的骨架就是定义一个函数,然后把该函数导出</p>
<p>由于我们需要支持Promise写法,所以导出的<code>pixivLogin </code>本身要返回一个Promise</p>
<pre><code class="javascript">const pixivLogin = ({ username, password }) => {
return new Promise((resolve, reject) => {
})
};
</code></pre>
<p>之后,只要把原先的代码套进去就好了</p>
<p>完整代码:</p>
<pre><code class="javascript">const axios = require('axios');
const cheerio = require('cheerio');
const querystring = require('querystring');
const LOGIN_URL = 'https://accounts.pixiv.net/login?lang=zh&source=pc&view_type=page&ref=wwwtop_accounts_index';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36';
const LOGIN_API = 'https://accounts.pixiv.net/api/login?lang=zh';
const pixivLogin = ({ username, password }) => {
return new Promise((resolve, reject) => {
const getKey = axios({
method: 'get',
url: LOGIN_URL,
headers: {
'User-Agent': USER_AGENT
}
}).then((response) => {
const $ = cheerio.load(response.data);
const post_key = $('input[name="post_key"]').val();
const cookie = response.headers['set-cookie'].join('; ');
if (post_key && cookie) {
return { post_key, cookie };
}
reject(new Error('no post_key'));
}).catch((error) => {
reject(error);
});
getKey.then(({ post_key, cookie }) => {
axios({
method: 'post',
url: LOGIN_API,
headers: {
'User-Agent': USER_AGENT,
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Origin': 'https://accounts.pixiv.net',
'Referer': 'https://accounts.pixiv.net/login?lang=zh&source=pc&view_type=page&ref=wwwtop_accounts_index',
'X-Requested-With': 'XMLHttpRequest',
'Cookie': cookie
},
data: querystring.stringify({
pixiv_id: username,
password: password,
captcha: '',
g_recaptcha_response: '',
post_key: post_key,
source: 'pc',
ref: 'wwwtop_accounts_index',
return_to: 'http://www.pixiv.net/'
})
}).then((response) => {
if (response.headers['set-cookie']) {
const cookie = response.headers['set-cookie'].join(' ;');
resolve(cookie);
} else {
reject(new Error('no cookie'));
}
}).catch((error) => {
reject(error);
});
});
})
}
module.exports = pixivLogin;</code></pre>
<h2>发布npm包</h2>
<h4>README</h4>
<p>每个npm包,一般都需要配一段介绍文字,来告诉使用者如何安装使用,比如<a href="https://link.segmentfault.com/?enc=SwUvZ9ldfaM3XSoAIOjqOg%3D%3D.yJ%2BKgKu9%2B42hOgI30%2BTddNK8NDAXRCPvBmi6eOKhULPcwTFTDy%2FU184bkO%2BH1fRV" rel="nofollow">lodash</a>的首页<br><img src="/img/remote/1460000011095491" alt="pic" title="pic"></p>
<p>新建一个README.md,填写相关信息<br><img src="/img/remote/1460000011095492" alt="pic" title="pic"></p>
<p>有时,我们会看到一些npm包有很漂亮的版本号图标:<br><img src="/img/remote/1460000011095493" alt="pic" title="pic"></p>
<p>这些图标,其实可以在<a href="https://link.segmentfault.com/?enc=%2FOd7XVXXzduKldHCKb7Aqw%3D%3D.ygQ0gTryXXbRbq%2F3yLWvTbXj30yudocgBqeFbmXHjhs%3D" rel="nofollow">https://shields.io/</a> 上制作<br>登录该网站,下拉到最下面<br><img src="/img/remote/1460000011095494" alt="pic" title="pic"><br>输入你想要的文字,版本号,颜色, 然后点击按钮<br><img src="/img/remote/1460000011095495" alt="pic" title="pic"><br>就可以得到图片的访问地址了<br><img src="/img/remote/1460000011095496" alt="pic" title="pic"><br>修改刚才的README.md,加上我们的版本号吧!<br><img src="/img/remote/1460000011095497" alt="pic" title="pic"></p>
<h4>gitignore</h4>
<p>我们现在的文件夹目录应该如下所示:<br><img src="/img/remote/1460000011095498" alt="pic" title="pic"></p>
<p>其实node_modules以及.vscode是完全不用上传的,所以为了防止发布时带上这些文件夹,我们要新建一个<code>.gitignore</code></p>
<pre><code class="javascript">.vscode/
node_modules/</code></pre>
<h4>注册</h4>
<p>到 <a href="https://link.segmentfault.com/?enc=UtcKhuXYGsZERNt4uDsGKw%3D%3D.YFyQA%2FS2imT2ZivSE%2FogVjTMMg38%2F1KL2P%2BwffCiUcI%3D" rel="nofollow">https://www.npmjs.com/</a> 上注册一个账号<br>然后在终端输入</p>
<pre><code class="javascript">npm adduser</code></pre>
<p>输入用户名,密码,邮箱即可登入成功<br><strong>这里还有一个坑!</strong><br> 如果你的npm使用的是淘宝镜像,那么是无法登陆成功的 <br>最简单的解决方法:</p>
<pre><code class="javascript">npm i nrm -g
nrm use npm</code></pre>
<p><code>nrm</code>是个npm镜像管理工具,可以很方便的切换镜像源</p>
<p>登陆成功后,输入</p>
<pre><code class="javascript">npm whoami</code></pre>
<p><img src="/img/remote/1460000011095499" alt="pic" title="pic"></p>
<p>如果出现了你的用户名,说明你已经成功登陆了</p>
<h4>发布</h4>
<p><strong>特别注意</strong>:<br>因为<code>pixiv-login</code>这个名字已经被我占用了,所以你需要改成其他名字<br>修改pacakge.json文件的<code>name</code>字段</p>
<pre><code class="javascript">npm publish</code></pre>
<p>即可发布成功啦!</p>
<h4>下载</h4>
<p>发布成功后,我们就可以下载自己的包了</p>
<pre><code class="javascript">npm i pixiv-login</code></pre>
<h2>使用pixiv-login包</h2>
<p>我们可以用<code>pixiv-login</code>做一些有趣(♂)的事<br>比如:</p>
<blockquote>下载 R-18每周排行榜的图片</blockquote>
<p>没登录的用户是无法访问R18区的,所以我们需要模拟登陆</p>
<pre><code class="javascript">
const fs = require('fs');
const axios = require('axios');
const pixivLogin = require('pixiv-login');
const cheerio = require('cheerio');
const USER_AGENT = 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36';
pixivLogin({
username: '你的用户名',
password: '你的密码'
}).then((cookie) => {
// 把cookie写入文件中,则下次无需再次获取cookie,直接读取文件即可
fs.writeFileSync('cookie.txt', cookie);
}).then((response) => {
const cookie = fs.readFileSync('cookie.txt', 'utf8');
axios({
method: 'get',
url: 'https://www.pixiv.net/ranking.php?mode=weekly_r18',
headers: {
'User-Agent': USER_AGENT,
'Referer': 'https://www.pixiv.net',
'Cookie': cookie
},
})
.then(function (response) {
const $ = cheerio.load(response.data);
const src = $('#1 img').data('src');
return src;
}).then(function (response) {
axios({
method: 'get',
url: response,
responseType: 'stream'
})
.then(function (response) {
const url = response.config.url;
const fileName = url.substring(url.lastIndexOf('/') + 1);
response.data.pipe(fs.createWriteStream(fileName)).on('close', function () {
console.log(`${fileName}下载完成`);
});;
});
})
})</code></pre>
<p>同时,我们的<code>pixiv-login</code>是支持<code>async await</code>的!</p>
<pre><code class="javascript">const pixivStart = async () => {
try {
const cookie = await pixivLogin({
username: '你的用户名',
password: '你的密码'
});
fs.writeFileSync('cookie.txt', cookie);
const data = fs.readFileSync('cookie.txt', 'utf8');
const response = await axios({
method: 'get',
url: 'https://www.pixiv.net/ranking.php?mode=weekly_r18',
headers: {
'User-Agent': USER_AGENT,
'Referer': 'https://www.pixiv.net',
'Cookie': cookie
},
});
const $ = cheerio.load(response.data);
const src = $('#1 img').data('src');
const pic = await axios({
method: 'get',
url: src,
responseType: 'stream'
});
const fileName = pic.config.url.substring(pic.config.url.lastIndexOf('/') + 1);
pic.data.pipe(fs.createWriteStream(fileName)).on('close', function () {
console.log(`${fileName}下载完成`);
});;
} catch (err) {
console.log(err)
}
};
pixivStart();</code></pre>
<h2>参考</h2>
<ol>
<li><a href="https://link.segmentfault.com/?enc=F3ZM7RerT4EHBPxvRXgc4g%3D%3D.jrJ6CIBNU2dUDT4VkDflQ7gLItGtGYLVinwpylaf4XGg0JOvhYAoXk9vdieygN1kWFk46d%2F2zH4VPpRtcFX75A%3D%3D" rel="nofollow">模拟登录pixiv.net</a></li>
<li><a href="https://link.segmentfault.com/?enc=OltW%2BLxWerFTuRFTpTNrfA%3D%3D.tYZ4c%2FqsybdeCmFKlQ8znORzXf4na%2BXKqEz7G5mx7X58L%2FIx4mq%2BfRjO4h%2B%2B%2FukDA9JDciDReoSPaS21oq3fEw%3D%3D" rel="nofollow">Python爬虫入门:爬取pixiv</a></li>
</ol>
节流事件
https://segmentfault.com/a/1190000007421282
2016-11-08T19:26:18+08:00
2016-11-08T19:26:18+08:00
深红
https://segmentfault.com/u/deepred5
3
<p>有一些事件是会频繁触发的,比如scroll resize mousemove keyup<br>如果在这些事件上绑定函数,并且这些函数要进行耗性能的计算,那么会导致页面忽急忽缓,反应迟钝,这时就需要使用节流事件来控制函数被触发的频率。</p>
<pre><code class="javascript">function handler() {
// 处理一些耗性能的计算
// 或者发送ajax请求
console.log('2333');
}
$(window).scroll(handler); // 反复触发handler,影响性能
</code></pre>
<h2>方法一 setTimeout</h2>
<pre><code class="javascript">var timer = 0;
$(window).scroll(function() {
if (!timer) {
timer = setTimeout(function() {
handler();
timer = 0;
}, 1000);
}
});
</code></pre>
<h2>方法二 setInterval</h2>
<pre><code class="javascript">// scroll虽然绑定了一个会频繁触发的函数,但是该函数只是改变scrolled的值,不会影响性能
var scrolled = false;
$(window).on('scroll', function() {
scrolled = true;
});
setInterval(function() {
if (scrolled) {
handler();
scrolled = false;
}
}, 1000);</code></pre>
<hr>
<p>想象一个场景:实时搜索</p>
<p>在输入框输入关键词后就要马上显示结果,通常做法是在keyup上绑定handler处理函数,发送ajax请求。但是如果用户输入速度很快,那么keyup会触发多次,发送多个ajax请求,而我们只是想要在用户停止输入的时间超过1s后才发送ajax</p>
<p>这和前面的scroll事件又有些不同,在这里我只想handler函数在keyup触发后执行一次<br>前面两种方法只是减少了handler()触发的频率,但是仍然会触发多次</p>
<h2>方法三 clearTimeout</h2>
<pre><code class="javascript">var searchTimeout = null;
$('#input').on('keyup', function(event) {
//每次keyup时直接取消上次计时器,只有当keyup超过1s时才执行handler
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() {
handler();
}, 1000);
});</code></pre>
<p>参考:《jQuery基础教程》</p>